Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
408d32df88
commit
74b2f27997
20 changed files with 608 additions and 182 deletions
|
|
@ -14,8 +14,11 @@ uv sync --extra dev
|
||||||
# Install/upgrade the user command
|
# Install/upgrade the user command
|
||||||
make install
|
make install
|
||||||
|
|
||||||
# Start the operator alpha session
|
# Start neutral repository shells
|
||||||
tamq start --command codex railiance-platform activity-core
|
tamq flex-auth audit-core
|
||||||
|
|
||||||
|
# Run an explicit initial command only when requested
|
||||||
|
tamq --command codex flex-auth audit-core
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Repo Identity
|
## Repo Identity
|
||||||
|
|
||||||
**Purpose:** Local durable message queue and tmux endpoint runtime for coordinating agent workers across gita-registered repositories.
|
**Purpose:** Repository-aware tmux sessions and durable local messaging without assuming what runs in a pane.
|
||||||
|
|
||||||
**Domain:** communication
|
**Domain:** communication
|
||||||
**Repo slug:** tmux-amq
|
**Repo slug:** tmux-amq
|
||||||
|
|
@ -267,9 +267,10 @@ To create a new workplan:
|
||||||
|
|
||||||
- Install development dependencies with `uv sync --extra dev`.
|
- Install development dependencies with `uv sync --extra dev`.
|
||||||
- Install or upgrade the user command with `make install`.
|
- Install or upgrade the user command with `make install`.
|
||||||
- The operator alpha acceptance command is
|
- The terminal-neutral acceptance command is `tamq flex-auth audit-core`; it
|
||||||
`tamq start --command codex railiance-platform activity-core`; it must open
|
must open untouched shells in both exact gita paths and inject no pane input.
|
||||||
both exact gita paths and launch Codex once in each new window.
|
- Explicit `--command` runs exactly the requested initial command. Manual
|
||||||
|
`tamq send`/`tamq inbox` exchange must remain durable and non-invasive.
|
||||||
- Run the test suite with `uv run pytest` or `make test`.
|
- Run the test suite with `uv run pytest` or `make test`.
|
||||||
- Before handoff, run `make check`; it combines tests, whitespace validation,
|
- Before handoff, run `make check`; it combines tests, whitespace validation,
|
||||||
and Python bytecode compilation.
|
and Python bytecode compilation.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ updated: "2026-08-24"
|
||||||
|
|
||||||
## Why it exists
|
## Why it exists
|
||||||
|
|
||||||
Provide a local, durable, inspectable transport for direct messages between agent workers running in tmux, with strict repository targeting, explicit endpoint lifecycle, bounded retry behavior, and future interoperability with coordination-engine.
|
Provide repository-aware tmux sessions and a local, durable, inspectable
|
||||||
|
transport for direct messages between their users or processes, with strict
|
||||||
|
targeting, explicit endpoint lifecycle, safe delivery boundaries, and future
|
||||||
|
interoperability with coordination-engine.
|
||||||
|
|
||||||
## Governing principle
|
## Governing principle
|
||||||
|
|
||||||
|
|
@ -17,7 +20,8 @@ workplan before implementation.
|
||||||
|
|
||||||
## What it enables
|
## What it enables
|
||||||
|
|
||||||
- Agent workers can address one another by registered repository slug.
|
- Terminal users and processes can address one another by registered repository
|
||||||
|
slug without tamq assuming what runs in a pane.
|
||||||
- Messages, leases, delivery state, and acknowledgements survive process restarts.
|
- Messages, leases, delivery state, and acknowledgements survive process restarts.
|
||||||
- Operators can inspect, export, replay, and purge local queue history.
|
- Operators can inspect, export, replay, and purge local queue history.
|
||||||
- A later coordination-engine adapter can use a stable local socket boundary
|
- A later coordination-engine adapter can use a stable local socket boundary
|
||||||
|
|
|
||||||
91
README.md
91
README.md
|
|
@ -1,7 +1,7 @@
|
||||||
# tmux-amq
|
# tmux-amq
|
||||||
|
|
||||||
Tmux Agentic Message Queueing (`tamq`) is a local, durable message queue for
|
Tmux Agentic Message Queueing (`tamq`) provides repository-aware tmux sessions
|
||||||
agent workers running in gita-registered repositories.
|
and a local, durable message queue. It does not assume who or what uses a pane.
|
||||||
|
|
||||||
The local alpha provides tmux endpoint lifecycle, local SQLite history and
|
The local alpha provides tmux endpoint lifecycle, local SQLite history and
|
||||||
leases, direct `@repo:` routing, JSONL export/replay, and a Unix-socket protocol
|
leases, direct `@repo:` routing, JSONL export/replay, and a Unix-socket protocol
|
||||||
|
|
@ -10,8 +10,7 @@ for a later coordination-engine adapter.
|
||||||
## Install and start a local session
|
## Install and start a local session
|
||||||
|
|
||||||
Prerequisites must be available on `PATH`: Python 3.11+, [uv](https://docs.astral.sh/uv/),
|
Prerequisites must be available on `PATH`: Python 3.11+, [uv](https://docs.astral.sh/uv/),
|
||||||
tmux, gita, and the agent command you want to run (`codex` by default). Each
|
tmux, and gita. Each repository must already have an exact gita slug and path.
|
||||||
repository must already have an exact gita slug and path.
|
|
||||||
|
|
||||||
Install or upgrade `tamq` as an isolated user tool:
|
Install or upgrade `tamq` as an isolated user tool:
|
||||||
|
|
||||||
|
|
@ -21,47 +20,74 @@ command -v tamq
|
||||||
tamq --version
|
tamq --version
|
||||||
```
|
```
|
||||||
|
|
||||||
Start an attached two-repository Codex session:
|
Start an attached two-repository shell session:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tamq start --command codex railiance-platform activity-core
|
tamq flex-auth audit-core
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates or reuses the managed `tamq` session, opens same-named windows in
|
This creates or reuses the managed `tamq` session, opens same-named windows in
|
||||||
the exact gita paths for `railiance-platform` and `activity-core`, launches Codex
|
the exact gita paths, leaves tmux's ordinary interactive shells untouched, and
|
||||||
behind `tamq tap` in each new window, and attaches to the first window. Codex is
|
attaches to the first window. The explicit form is equivalent:
|
||||||
the default, so this is equivalent:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tamq start railiance-platform activity-core
|
tamq start flex-auth audit-core
|
||||||
```
|
```
|
||||||
|
|
||||||
Set up the session without attaching, then attach later:
|
Set up the session without attaching, then attach later:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tamq start --detach --command codex railiance-platform activity-core
|
tamq start --detach flex-auth audit-core
|
||||||
tamq status
|
tamq status
|
||||||
tamq attach
|
tamq attach
|
||||||
```
|
```
|
||||||
|
|
||||||
Repeated starts reuse existing windows and do not launch a second agent in
|
No initial command runs unless requested. To run one explicitly in each newly
|
||||||
them. `--cmd` remains an alias for `--command`. A quoted command may include
|
created window, use `--command`; tamq runs exactly that command and makes no
|
||||||
arguments, for example `--command 'codex --profile local'`.
|
assumption about its purpose:
|
||||||
|
|
||||||
An already-running pane keeps the `tamq tap` code that launched it. After
|
```bash
|
||||||
upgrading from an earlier alpha, recreate the managed session once so terminal
|
tamq --command codex flex-auth audit-core
|
||||||
mode and resize fixes take effect:
|
tamq start --command 'htop --tree' flex-auth audit-core
|
||||||
|
```
|
||||||
|
|
||||||
|
`--cmd` remains an alias for `--command`. Repeated starts reuse existing
|
||||||
|
windows and never run another initial command in them.
|
||||||
|
|
||||||
|
## Exchange messages manually
|
||||||
|
|
||||||
|
Each managed shell exports its own repository slug as `TAMQ_REPO`. From the
|
||||||
|
`flex-auth` window, queue a message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tamq send '@audit-core: please review the auth boundary'
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `audit-core` window, inspect and acknowledge it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tamq inbox
|
||||||
|
tamq ack <message-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Outside a managed window, use `tamq inbox --repo audit-core` and optionally
|
||||||
|
`--json`. Manual messages remain durable and pending until acknowledged. They
|
||||||
|
are never injected as terminal keystrokes, so they cannot corrupt a command
|
||||||
|
being typed in the target pane.
|
||||||
|
|
||||||
|
After upgrading from the earlier Codex-default alpha, recreate the managed
|
||||||
|
session once so existing panes are replaced by neutral shells and the broker is
|
||||||
|
re-registered in manual delivery mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tamq stop
|
tamq stop
|
||||||
tmux kill-session -t tamq
|
tmux kill-session -t tamq
|
||||||
tamq start --command codex railiance-platform activity-core
|
tamq flex-auth audit-core
|
||||||
```
|
```
|
||||||
|
|
||||||
For degraded tmux-only use, `tamq start --no-service ...` launches the selected
|
For degraded tmux-only use, `tamq start --no-service ...` skips socket endpoint
|
||||||
agent directly, without `tamq tap`, socket endpoint registration, or message
|
registration and durable messaging. Stop the broker and remove the managed tmux
|
||||||
delivery. Stop the broker and remove the managed tmux session explicitly when
|
session explicitly when finished:
|
||||||
finished:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tamq stop
|
tamq stop
|
||||||
|
|
@ -77,16 +103,14 @@ anywhere with `uv tool uninstall tmux-amq`.
|
||||||
uv run pytest
|
uv run pytest
|
||||||
uv run tamq --help
|
uv run tamq --help
|
||||||
uv run tamq --version
|
uv run tamq --version
|
||||||
uv run tamq start --cmd codex net-kingdom railiance-platform
|
uv run tamq start net-kingdom railiance-platform
|
||||||
uv run tamq attach
|
uv run tamq attach
|
||||||
```
|
```
|
||||||
|
|
||||||
The alpha includes the local SQLite queue and a tmux endpoint
|
The alpha includes the local SQLite queue, neutral tmux endpoint manager, strict
|
||||||
manager, strict gita target validation, and a control-mode client for tmux
|
gita target validation, manual inbox, control-mode client, and an explicit PTY
|
||||||
operations/injection, and the PTY tap for full-duplex input observation. The
|
tap for integration experiments. Coordination-engine integration remains a
|
||||||
control-mode client alone does not expose arbitrary pane input; `tamq tap` is
|
later phase.
|
||||||
the supported input-broker hook. `tamq send` remains available as an explicit
|
|
||||||
fallback. coordination-engine integration remains a later phase.
|
|
||||||
|
|
||||||
## Terminal architecture
|
## Terminal architecture
|
||||||
|
|
||||||
|
|
@ -94,16 +118,17 @@ fallback. coordination-engine integration remains a later phase.
|
||||||
|
|
||||||
1. tmux control mode is the topology and output/control stream;
|
1. tmux control mode is the topology and output/control stream;
|
||||||
2. the local broker assigns endpoint/source identity and durably queues intent;
|
2. the local broker assigns endpoint/source identity and durably queues intent;
|
||||||
3. `tamq tap` is a full-duplex PTY proxy around the agent process. It forwards
|
3. Explicit `tamq tap` mode is a full-duplex PTY proxy around a command. It forwards
|
||||||
bytes unchanged in raw terminal mode, propagates terminal resize and lifecycle
|
bytes unchanged in raw terminal mode, propagates terminal resize and lifecycle
|
||||||
signals, and observes complete input lines for `@repo:` routing.
|
signals, and observes complete input lines for `@repo:` routing.
|
||||||
|
|
||||||
This keeps tmux-specific topology concerns separate from reusable terminal I/O
|
This keeps tmux-specific topology concerns separate from reusable terminal I/O
|
||||||
observation and message identity.
|
observation and message identity.
|
||||||
|
|
||||||
Pending messages are delivered through the control-mode client as
|
Neutral endpoints use manual delivery: messages stay in the durable inbox and
|
||||||
`#sender-repo: body`, then marked `injected` in SQLite. Delivery is
|
never become pane input. The legacy pane-delivery path is available only with
|
||||||
endpoint-scoped and uses the same message ID for retry/deduplication.
|
the explicit `tamq start --tap --command ...` opt-in; it remains subject to the
|
||||||
|
retry and acknowledgement limitations tracked by `TAMQ-WP-0003`.
|
||||||
The visible endpoint label is `tmux-amq-<PID>`; each boot also receives an
|
The visible endpoint label is `tmux-amq-<PID>`; each boot also receives an
|
||||||
instance nonce so PID reuse cannot collide with prior leases or receipts.
|
instance nonce so PID reuse cannot collide with prior leases or receipts.
|
||||||
|
|
||||||
|
|
|
||||||
48
SCOPE.md
48
SCOPE.md
|
|
@ -2,20 +2,23 @@
|
||||||
|
|
||||||
## One-liner
|
## One-liner
|
||||||
|
|
||||||
Local durable message queue and tmux endpoint runtime for coordinating agent workers across gita-registered repositories.
|
Repository-aware tmux sessions and durable local messaging for users or
|
||||||
|
processes working across gita-registered repositories.
|
||||||
|
|
||||||
## Core Idea
|
## Core Idea
|
||||||
|
|
||||||
Keep tmux topology, terminal input observation, and durable local message state
|
Keep neutral tmux topology and durable local message state behind a small CLI
|
||||||
behind a small CLI and Unix-socket protocol that agents can use without owning
|
and Unix-socket protocol. Pane occupants may be humans, shells, tools, or agents;
|
||||||
a distributed coordination service.
|
tamq does not choose or infer them.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- Local SQLite message history, leases, endpoint registrations, delivery state,
|
- Local SQLite message history, leases, endpoint registrations, delivery state,
|
||||||
acknowledgements, replay, export, and bounded purging.
|
acknowledgements, replay, export, and bounded purging.
|
||||||
- Managed tmux endpoint lifecycle, control-mode output/injection, and the
|
- Managed neutral-shell tmux lifecycle and explicit initial commands.
|
||||||
full-duplex `tamq tap` PTY broker.
|
- Durable manual send/inbox/acknowledgement with per-window repository identity.
|
||||||
|
- Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY
|
||||||
|
broker for integration experiments.
|
||||||
- Exact `gita` repository validation and direct `@repo: message` routing.
|
- Exact `gita` repository validation and direct `@repo: message` routing.
|
||||||
- Unix-socket operations for local clients and a future coordination-engine
|
- Unix-socket operations for local clients and a future coordination-engine
|
||||||
adapter.
|
adapter.
|
||||||
|
|
@ -27,6 +30,8 @@ a distributed coordination service.
|
||||||
- Owning goal planning, workflow scheduling, or cross-host coordination; those
|
- Owning goal planning, workflow scheduling, or cross-host coordination; those
|
||||||
belong to `coordination-engine` and its consumers.
|
belong to `coordination-engine` and its consumers.
|
||||||
- Acting as a network-accessible or multi-host message broker.
|
- Acting as a network-accessible or multi-host message broker.
|
||||||
|
- Selecting a coding agent, starting one implicitly, or assuming a pane accepts
|
||||||
|
machine-generated terminal input.
|
||||||
- Bypassing `gita` registration or injecting arbitrary pane input outside the
|
- Bypassing `gita` registration or injecting arbitrary pane input outside the
|
||||||
supported tap/control-mode boundaries.
|
supported tap/control-mode boundaries.
|
||||||
- Owning tmux, Codex, State Hub, or adjacent repositories' lifecycle.
|
- Owning tmux, Codex, State Hub, or adjacent repositories' lifecycle.
|
||||||
|
|
@ -40,41 +45,42 @@ transport.
|
||||||
| Intent capability | State | Evidence and remaining gap |
|
| Intent capability | State | Evidence and remaining gap |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Direct repository addressing | Implemented | Exact `gita` validation and `@repo:` parsing are covered by tests. |
|
| Direct repository addressing | Implemented | Exact `gita` validation and `@repo:` parsing are covered by tests. |
|
||||||
| Durable, inspectable local queue | Implemented | SQLite history, leases, endpoint records, inspect/history, JSONL export/replay, acknowledgement, and purge are present. |
|
| Durable, inspectable local queue | Implemented | SQLite history, manual inbox, leases, endpoint records, inspect/history, JSONL export/replay, acknowledgement, and purge are present. |
|
||||||
| Local socket service | Implemented | Peer-credential checks and structured ping/register/send/history/ack/endpoints/disconnect operations are tested. |
|
| Local socket service | Implemented | Peer-credential checks and structured ping/register/send/history/ack/endpoints/disconnect operations are tested. |
|
||||||
| Tmux endpoint lifecycle and delivery | Implemented for local alpha | Real isolated-tmux and installed-package tests prove exact repository windows, stable reuse, control-mode injection, durable history transition, service stop, and bounded cleanup. |
|
| Neutral tmux session lifecycle | Implemented for local alpha | Repository-first startup opens untouched shells at exact gita paths, exports per-window identity, and runs no initial command unless `--command` is explicit. Stable reuse, service restart, and cleanup are covered by the installed-package test. |
|
||||||
| Full-duplex input observation | Implemented for local alpha | `tamq tap` uses raw outer-terminal mode, copies and propagates window dimensions, forwards lifecycle signals, preserves mouse/control bytes, and observes CR/LF address lines. A real tmux fixture covers geometry, resize, and raw mouse input. |
|
| Safe manual messaging | Implemented for local alpha | Manual endpoints never inject pending messages into panes. Installed testing proves send/inbox/ack exchange while the target pane remains unchanged; legacy rows migrate to manual mode. |
|
||||||
|
| Full-duplex input observation | Explicit opt-in | `--tap --command ...` enables the PTY integration path. It is absent from neutral startup and remains covered for geometry, resize, raw mouse input, and lifecycle behavior. |
|
||||||
| Bounded retry behavior | Not enforced | Failed injection remains pending and becomes claimable after lease expiry, but no attempt counter or terminal failure state applies the configured cap. |
|
| Bounded retry behavior | Not enforced | Failed injection remains pending and becomes claimable after lease expiry, but no attempt counter or terminal failure state applies the configured cap. |
|
||||||
| Acknowledgement policy | Partially implemented | Explicit acknowledgement and the configuration field exist; delivery always marks a successful tmux injection as `injected`, irrespective of `delivery_ack_mode`. |
|
| Acknowledgement policy | Partially implemented | Explicit acknowledgement and the configuration field exist; delivery always marks a successful tmux injection as `injected`, irrespective of `delivery_ack_mode`. |
|
||||||
| Coordination-engine interoperability | Not implemented | The adapter contract and implementation remain in `TAMQ-WP-0002`. |
|
| Coordination-engine interoperability | Not implemented | The adapter contract and implementation remain in `TAMQ-WP-0002`. |
|
||||||
|
|
||||||
## Practical Usability
|
## Practical Usability
|
||||||
|
|
||||||
The installable alpha path was exercised successfully on 2026-08-24 with both
|
The terminal-neutral alpha path was exercised successfully on 2026-08-24 with
|
||||||
isolated fixtures and the operator's registered repositories. `make install`
|
an isolated installed tool. Repository-first startup created two ordinary
|
||||||
produced a user-level `tamq` command; `tamq start --detach --command codex
|
shells at exact gita paths without sending initial keystrokes. The test proved
|
||||||
railiance-platform activity-core` created two same-named live panes rooted at
|
per-window repository identity, stable reuse, manual send/inbox/ack exchange,
|
||||||
their exact gita paths. Repeating the command retained the same session instance
|
zero target-pane mutation, service restart, endpoint disappearance, explicit
|
||||||
and pane PIDs. The automated installed-package path also proved message
|
initial-command startup, and cleanup.
|
||||||
injection, history, status, service stop, and cleanup.
|
|
||||||
|
|
||||||
Suitable today:
|
Suitable today:
|
||||||
|
|
||||||
- Local queue, history, export/replay, and diagnostic use.
|
- Local queue, history, export/replay, and diagnostic use.
|
||||||
- Interactive local Codex sessions over one or more gita-registered repositories.
|
- Interactive local shell or explicitly commanded sessions over one or more
|
||||||
- Controlled message-routing experiments between managed tmux endpoints.
|
gita-registered repositories.
|
||||||
|
- Durable manual message exchange between managed repository windows.
|
||||||
- Developing and testing the future coordination-engine adapter against the
|
- Developing and testing the future coordination-engine adapter against the
|
||||||
local socket boundary.
|
local socket boundary.
|
||||||
|
|
||||||
Not yet suitable:
|
Not yet suitable:
|
||||||
|
|
||||||
- Unattended or high-confidence delivery where bounded retries and positive
|
- Unattended pane injection where bounded retries and positive recipient
|
||||||
recipient acknowledgement are required.
|
acknowledgement are required.
|
||||||
- Production-style operation without longer-running crash/terminal soak tests
|
- Production-style operation without longer-running crash/terminal soak tests
|
||||||
and stronger process-supervision evidence.
|
and stronger process-supervision evidence.
|
||||||
- Cross-host messaging or use as a general-purpose broker.
|
- Cross-host messaging or use as a general-purpose broker.
|
||||||
|
|
||||||
The suite currently has 69 passing tests and 72% statement coverage. Coverage
|
The suite currently has 77 passing tests and 74% statement coverage. Coverage
|
||||||
is strongest in durable storage and registry handling, and weakest in the PTY
|
is strongest in durable storage and registry handling, and weakest in the PTY
|
||||||
tap and CLI orchestration; PTY statement coverage increased from 23% to 33%,
|
tap and CLI orchestration; PTY statement coverage increased from 23% to 33%,
|
||||||
while subprocess behavior is primarily proven by the real-tmux test. The
|
while subprocess behavior is primarily proven by the real-tmux test. The
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
| workplan | TAMQ-WP-0002 | active | — | workplans/TAMQ-WP-0002-coordination-engine-adapter.md |
|
| workplan | TAMQ-WP-0002 | active | — | workplans/TAMQ-WP-0002-coordination-engine-adapter.md |
|
||||||
| workplan | TAMQ-WP-0003 | active | — | workplans/TAMQ-WP-0003-delivery-reliability.md |
|
| workplan | TAMQ-WP-0003 | active | — | workplans/TAMQ-WP-0003-delivery-reliability.md |
|
||||||
| workplan | TAMQ-WP-0004 | finished | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md |
|
| workplan | TAMQ-WP-0004 | finished | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md |
|
||||||
| workplan | TAMQ-WP-0005 | active | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
| workplan | TAMQ-WP-0005 | finished | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
||||||
| task | TAMQ-WP-ADHOC-2026-08-24-T01 | done | — | workplans/ADHOC-2026-08-24.md |
|
| task | TAMQ-WP-ADHOC-2026-08-24-T01 | done | — | workplans/ADHOC-2026-08-24.md |
|
||||||
| task | TAMQ-WP-0001-T01 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
|
| task | TAMQ-WP-0001-T01 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
|
||||||
| task | TAMQ-WP-0001-T02 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
|
| task | TAMQ-WP-0001-T02 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
| task | TAMQ-WP-0004-T03 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md |
|
| task | TAMQ-WP-0004-T03 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md |
|
||||||
| task | TAMQ-WP-0004-T04 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md |
|
| task | TAMQ-WP-0004-T04 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md |
|
||||||
| task | TAMQ-WP-0004-T05 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md |
|
| task | TAMQ-WP-0004-T05 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md |
|
||||||
| task | TAMQ-WP-0005-T01 | progress | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
| task | TAMQ-WP-0005-T01 | done | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
||||||
| task | TAMQ-WP-0005-T02 | todo | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
| task | TAMQ-WP-0005-T02 | done | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
||||||
| task | TAMQ-WP-0005-T03 | todo | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
| task | TAMQ-WP-0005-T03 | done | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
||||||
| task | TAMQ-WP-0005-T04 | wait | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
| task | TAMQ-WP-0005-T04 | done | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md |
|
||||||
|
|
|
||||||
195
src/tamq/cli.py
195
src/tamq/cli.py
|
|
@ -25,6 +25,14 @@ from .ptytap import PtyTap
|
||||||
from .control import ControlModeClient
|
from .control import ControlModeClient
|
||||||
from .diagnostics import configure
|
from .diagnostics import configure
|
||||||
from .policy import load_profile
|
from .policy import load_profile
|
||||||
|
from .registry import RegistryError, validate_targets
|
||||||
|
|
||||||
|
|
||||||
|
SUBCOMMANDS = frozenset(
|
||||||
|
"start attach serve stop status ping inbox history inspect ack send export replay purge completion db-version config tap".split()
|
||||||
|
)
|
||||||
|
START_OPTIONS = frozenset({"--command", "--cmd", "--tap", "--detach", "--no-service"})
|
||||||
|
GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"})
|
||||||
|
|
||||||
|
|
||||||
def parse_size(value: str) -> int:
|
def parse_size(value: str) -> int:
|
||||||
|
|
@ -70,6 +78,46 @@ def ensure_service() -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stop_service_process() -> int | None:
|
||||||
|
try:
|
||||||
|
pid = int(pid_path().read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, ValueError):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pid_path().unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_manual_service() -> bool:
|
||||||
|
"""Ensure the broker understands neutral endpoints before registration."""
|
||||||
|
if not ensure_service():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
capabilities = asyncio.run(request({"op": "ping"})).get("capabilities", [])
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
if "manual_delivery" in capabilities:
|
||||||
|
return True
|
||||||
|
stop_service_process()
|
||||||
|
if not ensure_service():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
capabilities = asyncio.run(request({"op": "ping"})).get("capabilities", [])
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
return "manual_delivery" in capabilities
|
||||||
|
|
||||||
|
|
||||||
def preflight_runtime_paths() -> None:
|
def preflight_runtime_paths() -> None:
|
||||||
"""Ensure local state/runtime parents are available before tmux mutation."""
|
"""Ensure local state/runtime parents are available before tmux mutation."""
|
||||||
parents = {db_path().parent, socket_path().parent, pid_path().parent, lock_path().parent}
|
parents = {db_path().parent, socket_path().parent, pid_path().parent, lock_path().parent}
|
||||||
|
|
@ -87,7 +135,7 @@ def attach_session(session: str) -> int:
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="tamq",
|
prog="tamq",
|
||||||
description="Tmux Agentic Message Queueing for local repository workers.",
|
description="Repository-aware tmux sessions with durable local messaging.",
|
||||||
)
|
)
|
||||||
parser.add_argument("--version", "-V", action="version", version=__version__)
|
parser.add_argument("--version", "-V", action="version", version=__version__)
|
||||||
parser.add_argument("--orwell", action="store_true", help="enable unsafe local diagnostics")
|
parser.add_argument("--orwell", action="store_true", help="enable unsafe local diagnostics")
|
||||||
|
|
@ -97,7 +145,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
|
|
||||||
start = subparsers.add_parser("start", help="start or reuse a tamq endpoint")
|
start = subparsers.add_parser("start", help="start or reuse a tamq endpoint")
|
||||||
start.add_argument("repos", nargs="*", help="gita-registered repository slugs")
|
start.add_argument("repos", nargs="*", help="gita-registered repository slugs")
|
||||||
start.add_argument("--command", "--cmd", dest="agent_command", default="codex", help="agent command for new windows (default: codex)")
|
start.add_argument("--command", "--cmd", dest="initial_command", default=None, help="explicit initial command for newly created windows (default: ordinary shell)")
|
||||||
|
start.add_argument("--tap", action="store_true", help="explicitly opt in to PTY input observation and pane message injection")
|
||||||
start.add_argument("--detach", action="store_true", help="detach after startup")
|
start.add_argument("--detach", action="store_true", help="detach after startup")
|
||||||
start.add_argument("--no-service", action="store_true", help="open tmux windows without service registration or messaging")
|
start.add_argument("--no-service", action="store_true", help="open tmux windows without service registration or messaging")
|
||||||
attach = subparsers.add_parser("attach", help="attach to the managed tmux session")
|
attach = subparsers.add_parser("attach", help="attach to the managed tmux session")
|
||||||
|
|
@ -110,14 +159,19 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
history = subparsers.add_parser("history", help="inspect local message history")
|
history = subparsers.add_parser("history", help="inspect local message history")
|
||||||
history.add_argument("--repo", dest="target_repo")
|
history.add_argument("--repo", dest="target_repo")
|
||||||
history.add_argument("--state")
|
history.add_argument("--state")
|
||||||
|
inbox = subparsers.add_parser("inbox", help="show durable messages for a repository without injecting terminal input")
|
||||||
|
inbox.add_argument("--repo", dest="target_repo", help="target repository (default: TAMQ_REPO in a managed window)")
|
||||||
|
inbox.add_argument("--all", action="store_true", help="include non-pending messages")
|
||||||
|
inbox.add_argument("--json", action="store_true", help="emit one JSON object per message")
|
||||||
inspect = subparsers.add_parser("inspect", help="inspect one message")
|
inspect = subparsers.add_parser("inspect", help="inspect one message")
|
||||||
inspect.add_argument("message_id")
|
inspect.add_argument("message_id")
|
||||||
ack = subparsers.add_parser("ack", help="acknowledge one injected message")
|
ack = subparsers.add_parser("ack", help="acknowledge one durable message")
|
||||||
ack.add_argument("message_id")
|
ack.add_argument("message_id")
|
||||||
send = subparsers.add_parser("send", help="queue a direct message")
|
send = subparsers.add_parser("send", help="queue a direct message")
|
||||||
send.add_argument("address", help="@repo: message")
|
send.add_argument("address", help="@repo: message")
|
||||||
send.add_argument("body", nargs="*", help="message body when address is a repo slug")
|
send.add_argument("body", nargs="*", help="message body when address is a repo slug")
|
||||||
send.add_argument("--endpoint-id")
|
send.add_argument("--endpoint-id")
|
||||||
|
send.add_argument("--from", dest="sender_repo", help="sender repository (default: TAMQ_REPO or local)")
|
||||||
export = subparsers.add_parser("export", help="export history as JSONL")
|
export = subparsers.add_parser("export", help="export history as JSONL")
|
||||||
export.add_argument("--output", required=True)
|
export.add_argument("--output", required=True)
|
||||||
export.add_argument("--repo", dest="target_repo")
|
export.add_argument("--repo", dest="target_repo")
|
||||||
|
|
@ -134,16 +188,41 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
completion.add_argument("shell", choices=("bash", "zsh", "fish"))
|
completion.add_argument("shell", choices=("bash", "zsh", "fish"))
|
||||||
subparsers.add_parser("db-version", help="show SQLite schema version")
|
subparsers.add_parser("db-version", help="show SQLite schema version")
|
||||||
subparsers.add_parser("config", help="show effective configuration paths and policy")
|
subparsers.add_parser("config", help="show effective configuration paths and policy")
|
||||||
tap = subparsers.add_parser("tap", help="run an agent behind the full-duplex PTY tap")
|
tap = subparsers.add_parser("tap", help="explicitly run a command behind the full-duplex PTY tap")
|
||||||
tap.add_argument("--repo", required=True, help="source gita repository slug")
|
tap.add_argument("--repo", required=True, help="source gita repository slug")
|
||||||
tap.add_argument("--endpoint", required=True, help="tmux-amq endpoint identity")
|
tap.add_argument("--endpoint", required=True, help="tmux-amq endpoint identity")
|
||||||
tap.add_argument("agent_command", nargs=argparse.REMAINDER, help="agent command after --")
|
tap.add_argument("wrapped_command", nargs=argparse.REMAINDER, help="command after --")
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_argv(argv: list[str]) -> list[str]:
|
||||||
|
"""Allow repository-first startup while retaining explicit subcommands."""
|
||||||
|
if not argv:
|
||||||
|
return argv
|
||||||
|
index = 0
|
||||||
|
while index < len(argv):
|
||||||
|
token = argv[index]
|
||||||
|
if token in GLOBAL_FLAGS or token.startswith("--policy-profile="):
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if token == "--policy-profile" and index + 1 < len(argv):
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
if index == len(argv):
|
||||||
|
return argv
|
||||||
|
candidate = argv[index]
|
||||||
|
if candidate in START_OPTIONS or (
|
||||||
|
not candidate.startswith("-") and candidate not in SUBCOMMANDS
|
||||||
|
):
|
||||||
|
return [*argv[:index], "start", *argv[index:]]
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
args = parser.parse_args(normalize_argv(raw_argv))
|
||||||
configure(args.orwell, args.verbose)
|
configure(args.orwell, args.verbose)
|
||||||
try:
|
try:
|
||||||
profile = load_profile(selected=args.policy_profile or os.environ.get("TAMQ_POLICY_PROFILE", "default"))
|
profile = load_profile(selected=args.policy_profile or os.environ.get("TAMQ_POLICY_PROFILE", "default"))
|
||||||
|
|
@ -158,25 +237,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
if args.command == "ping":
|
if args.command == "ping":
|
||||||
return 0 if asyncio.run(ping()) else 1
|
return 0 if asyncio.run(ping()) else 1
|
||||||
if args.command == "stop":
|
if args.command == "stop":
|
||||||
try:
|
pid = stop_service_process()
|
||||||
pid = int(pid_path().read_text(encoding="utf-8"))
|
if pid is not None:
|
||||||
os.kill(pid, signal.SIGTERM)
|
|
||||||
deadline = time.monotonic() + 2.0
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
os.kill(pid, 0)
|
|
||||||
except ProcessLookupError:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
print(f"stopped tamq service {pid}")
|
print(f"stopped tamq service {pid}")
|
||||||
return 0
|
return 0
|
||||||
except ProcessLookupError:
|
print("tamq service is not running", file=sys.stderr)
|
||||||
pid_path().unlink(missing_ok=True)
|
return 1
|
||||||
print("tamq service is not running", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
except (FileNotFoundError, ValueError):
|
|
||||||
print("tamq service is not running", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
if args.command == "serve":
|
if args.command == "serve":
|
||||||
try: asyncio.run(Service().run())
|
try: asyncio.run(Service().run())
|
||||||
except KeyboardInterrupt: return 0
|
except KeyboardInterrupt: return 0
|
||||||
|
|
@ -190,11 +256,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
print(json.dumps({"config": str(config_path()), "state_dir": str(state_dir()), "database": str(db_path()), "socket": str(socket_path()), "pidfile": str(pid_path()), "lockfile": str(lock_path()), "policy_profile": profile.name, "delivery_ack_mode": profile.delivery_ack_mode}, sort_keys=True))
|
print(json.dumps({"config": str(config_path()), "state_dir": str(state_dir()), "database": str(db_path()), "socket": str(socket_path()), "pidfile": str(pid_path()), "lockfile": str(lock_path()), "policy_profile": profile.name, "delivery_ack_mode": profile.delivery_ack_mode}, sort_keys=True))
|
||||||
return 0
|
return 0
|
||||||
if args.command == "tap":
|
if args.command == "tap":
|
||||||
command = list(args.agent_command)
|
command = list(args.wrapped_command)
|
||||||
if command and command[0] == "--":
|
if command and command[0] == "--":
|
||||||
command = command[1:]
|
command = command[1:]
|
||||||
if not command:
|
if not command:
|
||||||
print("tamq tap requires an agent command after --", file=sys.stderr)
|
print("tamq tap requires a command after --", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
store = Store(db_path())
|
store = Store(db_path())
|
||||||
try:
|
try:
|
||||||
|
|
@ -202,19 +268,25 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
if args.command == "start":
|
if args.command == "start":
|
||||||
|
if args.tap and args.initial_command is None:
|
||||||
|
print("tamq: --tap requires an explicit --command", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.tap and args.no_service:
|
||||||
|
print("tamq: --tap cannot be combined with --no-service", file=sys.stderr)
|
||||||
|
return 2
|
||||||
manager = TmuxManager()
|
manager = TmuxManager()
|
||||||
try:
|
try:
|
||||||
if not args.no_service:
|
if not args.no_service:
|
||||||
preflight_runtime_paths()
|
preflight_runtime_paths()
|
||||||
launch_plan = manager.preflight(args.repos, args.agent_command)
|
launch_plan = manager.preflight(args.repos, args.initial_command)
|
||||||
except (TmuxError, OSError) as exc:
|
except (TmuxError, OSError) as exc:
|
||||||
print(f"tamq: {exc}", file=sys.stderr)
|
print(f"tamq: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
if not args.no_service and not ensure_service():
|
if not args.no_service and not ensure_manual_service():
|
||||||
print("tamq service failed to start; use --no-service to open repos without messaging", file=sys.stderr)
|
print("tamq service failed to start with manual-delivery safety; use --no-service to open repos without messaging", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
try:
|
try:
|
||||||
endpoint = manager.ensure_plan(launch_plan, tap=not args.no_service)
|
endpoint = manager.ensure_plan(launch_plan, tap=args.tap)
|
||||||
except (TmuxError, OSError) as exc:
|
except (TmuxError, OSError) as exc:
|
||||||
print(f"tamq: {exc}", file=sys.stderr)
|
print(f"tamq: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
@ -222,20 +294,22 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
registered = False
|
registered = False
|
||||||
if not args.no_service:
|
if not args.no_service:
|
||||||
try:
|
try:
|
||||||
registration = asyncio.run(request({"op": "register", "endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "pid": endpoint.pid, "session": endpoint.session, "repos": endpoint.repos}))
|
delivery_mode = "pane" if args.tap else "manual"
|
||||||
|
registration = asyncio.run(request({"op": "register", "endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "pid": endpoint.pid, "session": endpoint.session, "repos": endpoint.repos, "delivery_mode": delivery_mode}))
|
||||||
if not registration.get("ok"):
|
if not registration.get("ok"):
|
||||||
raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}")
|
raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}")
|
||||||
registered = True
|
registered = True
|
||||||
control = ControlModeClient(endpoint.session)
|
if args.tap:
|
||||||
queue_store = Store(db_path())
|
control = ControlModeClient(endpoint.session)
|
||||||
try:
|
queue_store = Store(db_path())
|
||||||
control.start()
|
try:
|
||||||
delivered = InputBroker(queue_store, BrokerIdentity(endpoint.instance_key, endpoint.repos[0])).deliver_pending(
|
control.start()
|
||||||
control, window_for_repo=lambda repo: f"{endpoint.session}:{repo}"
|
delivered = InputBroker(queue_store, BrokerIdentity(endpoint.instance_key, endpoint.repos[0])).deliver_pending(
|
||||||
)
|
control, window_for_repo=lambda repo: f"{endpoint.session}:{repo}"
|
||||||
finally:
|
)
|
||||||
control.close()
|
finally:
|
||||||
queue_store.close()
|
control.close()
|
||||||
|
queue_store.close()
|
||||||
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
|
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
manager.rollback(endpoint)
|
manager.rollback(endpoint)
|
||||||
print(f"tamq: {exc}", file=sys.stderr)
|
print(f"tamq: {exc}", file=sys.stderr)
|
||||||
|
|
@ -248,6 +322,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"delivered": delivered,
|
"delivered": delivered,
|
||||||
"service": not args.no_service,
|
"service": not args.no_service,
|
||||||
"messaging": registered,
|
"messaging": registered,
|
||||||
|
"delivery_mode": "none" if args.no_service else ("pane" if args.tap else "manual"),
|
||||||
}
|
}
|
||||||
print(json.dumps(summary), flush=True)
|
print(json.dumps(summary), flush=True)
|
||||||
if args.detach:
|
if args.detach:
|
||||||
|
|
@ -256,7 +331,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
store = Store(db_path())
|
store = Store(db_path())
|
||||||
try:
|
try:
|
||||||
advisory = history_advisory(store)
|
advisory = history_advisory(store)
|
||||||
if advisory and args.command in {"start", "serve", "status", "history", "send"}:
|
if advisory and args.command in {"start", "serve", "status", "history", "inbox", "send"}:
|
||||||
print(advisory, file=sys.stderr)
|
print(advisory, file=sys.stderr)
|
||||||
if args.command == "send":
|
if args.command == "send":
|
||||||
text = " ".join([args.address, *args.body]).strip()
|
text = " ".join([args.address, *args.body]).strip()
|
||||||
|
|
@ -264,18 +339,44 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
print("tamq send expects @repo: message", file=sys.stderr); return 2
|
print("tamq send expects @repo: message", file=sys.stderr); return 2
|
||||||
target, body = text[1:].split(":", 1); body = body.strip()
|
target, body = text[1:].split(":", 1); body = body.strip()
|
||||||
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2
|
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2
|
||||||
if args.endpoint_id:
|
sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local"
|
||||||
if not asyncio.run(ping()):
|
try:
|
||||||
print("tamq: service is not running", file=sys.stderr); return 1
|
validate_targets([target])
|
||||||
response = asyncio.run(request({"op": "send", "endpoint_id": args.endpoint_id, "sender_repo": "local", "target_repo": target, "body": body}))
|
if sender != "local":
|
||||||
|
validate_targets([sender])
|
||||||
|
except RegistryError as exc:
|
||||||
|
print(f"tamq: {exc}", file=sys.stderr); return 2
|
||||||
|
if asyncio.run(ping()):
|
||||||
|
payload = {"op": "send", "sender_repo": sender, "target_repo": target, "body": body}
|
||||||
|
if args.endpoint_id:
|
||||||
|
payload["endpoint_id"] = args.endpoint_id
|
||||||
|
response = asyncio.run(request(payload))
|
||||||
if not response.get("ok"):
|
if not response.get("ok"):
|
||||||
print(f"tamq: {response.get('error', 'send failed')}", file=sys.stderr); return 1
|
print(f"tamq: {response.get('error', 'send failed')}", file=sys.stderr); return 1
|
||||||
print(response["message_id"]); return 0
|
print(response["message_id"]); return 0
|
||||||
|
if args.endpoint_id:
|
||||||
|
print("tamq: service is not running", file=sys.stderr); return 1
|
||||||
try:
|
try:
|
||||||
print(store.add("local", target, body))
|
print(store.add(sender, target, body))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
print(f"tamq: {exc}", file=sys.stderr); return 2
|
print(f"tamq: {exc}", file=sys.stderr); return 2
|
||||||
return 0
|
return 0
|
||||||
|
if args.command == "inbox":
|
||||||
|
target = args.target_repo or os.environ.get("TAMQ_REPO")
|
||||||
|
if not target:
|
||||||
|
print("tamq inbox requires --repo outside a managed tamq window", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
validate_targets([target])
|
||||||
|
except RegistryError as exc:
|
||||||
|
print(f"tamq: {exc}", file=sys.stderr); return 2
|
||||||
|
rows = store.list(target, None if args.all else "pending")
|
||||||
|
for row in rows:
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(dict(row), sort_keys=True))
|
||||||
|
else:
|
||||||
|
print(f"{row['message_id']} {row['sender_repo']} -> {row['target_repo']}: {row['body']}")
|
||||||
|
return 0
|
||||||
if args.command == "history":
|
if args.command == "history":
|
||||||
for row in store.list(args.target_repo, args.state): print(json.dumps(dict(row), sort_keys=True))
|
for row in store.list(args.target_repo, args.state): print(json.dumps(dict(row), sort_keys=True))
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -332,7 +433,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
|
|
||||||
def completion_script(shell: str) -> str:
|
def completion_script(shell: str) -> str:
|
||||||
commands = "start attach serve stop status ping history inspect ack send export replay purge completion db-version config tap"
|
commands = " ".join(sorted(SUBCOMMANDS))
|
||||||
if shell == "bash":
|
if shell == "bash":
|
||||||
return f"""_tamq_complete() {{
|
return f"""_tamq_complete() {{
|
||||||
local commands=\"{commands}\"
|
local commands=\"{commands}\"
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ class Service:
|
||||||
if not control.session_exists(expected_pid=int(endpoint["pid"])):
|
if not control.session_exists(expected_pid=int(endpoint["pid"])):
|
||||||
self.store.disconnect_endpoint(endpoint["endpoint_id"])
|
self.store.disconnect_endpoint(endpoint["endpoint_id"])
|
||||||
continue
|
continue
|
||||||
|
if endpoint["delivery_mode"] != "pane":
|
||||||
|
continue
|
||||||
pending = [row for row in self.store.list(state="pending") if row["endpoint_id"] in (None, endpoint["endpoint_id"]) and row["target_repo"] in repos]
|
pending = [row for row in self.store.list(state="pending") if row["endpoint_id"] in (None, endpoint["endpoint_id"]) and row["target_repo"] in repos]
|
||||||
if not pending:
|
if not pending:
|
||||||
continue
|
continue
|
||||||
|
|
@ -107,7 +109,7 @@ class Service:
|
||||||
if protocol.split(".")[0] != PROTOCOL_VERSION.split(".")[0]:
|
if protocol.split(".")[0] != PROTOCOL_VERSION.split(".")[0]:
|
||||||
response = {"ok": False, "error": f"incompatible protocol: {protocol}", "protocol": PROTOCOL_VERSION}
|
response = {"ok": False, "error": f"incompatible protocol: {protocol}", "protocol": PROTOCOL_VERSION}
|
||||||
elif op == "ping":
|
elif op == "ping":
|
||||||
response = {"ok": True, "protocol": PROTOCOL_VERSION, "capabilities": ["register", "send", "history"]}
|
response = {"ok": True, "protocol": PROTOCOL_VERSION, "capabilities": ["register", "send", "history", "manual_delivery"]}
|
||||||
elif op == "register":
|
elif op == "register":
|
||||||
required = ("endpoint_id", "pid", "session", "repos")
|
required = ("endpoint_id", "pid", "session", "repos")
|
||||||
if any(key not in request for key in required):
|
if any(key not in request for key in required):
|
||||||
|
|
@ -116,7 +118,14 @@ class Service:
|
||||||
try:
|
try:
|
||||||
validate_targets(list(request["repos"]))
|
validate_targets(list(request["repos"]))
|
||||||
endpoint_id = request.get("instance_id", request["endpoint_id"])
|
endpoint_id = request.get("instance_id", request["endpoint_id"])
|
||||||
self.store.register_endpoint(endpoint_id, int(request["pid"]), request["session"], list(request["repos"]))
|
delivery_mode = request.get("delivery_mode", "manual")
|
||||||
|
self.store.register_endpoint(
|
||||||
|
endpoint_id,
|
||||||
|
int(request["pid"]),
|
||||||
|
request["session"],
|
||||||
|
list(request["repos"]),
|
||||||
|
delivery_mode,
|
||||||
|
)
|
||||||
response = {"ok": True, "endpoint_id": request["endpoint_id"], "instance_id": endpoint_id, "protocol": PROTOCOL_VERSION}
|
response = {"ok": True, "endpoint_id": request["endpoint_id"], "instance_id": endpoint_id, "protocol": PROTOCOL_VERSION}
|
||||||
except (RegistryError, ValueError) as exc:
|
except (RegistryError, ValueError) as exc:
|
||||||
response = {"ok": False, "error": str(exc)}
|
response = {"ok": False, "error": str(exc)}
|
||||||
|
|
@ -127,6 +136,8 @@ class Service:
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
validate_targets([request["target_repo"]])
|
validate_targets([request["target_repo"]])
|
||||||
|
if request["sender_repo"] != "local":
|
||||||
|
validate_targets([request["sender_repo"]])
|
||||||
endpoint_id = request.get("endpoint_id")
|
endpoint_id = request.get("endpoint_id")
|
||||||
if endpoint_id:
|
if endpoint_id:
|
||||||
endpoint = self.store.endpoint(endpoint_id)
|
endpoint = self.store.endpoint(endpoint_id)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
class Store:
|
||||||
|
|
@ -38,6 +38,7 @@ class Store:
|
||||||
pid INTEGER NOT NULL,
|
pid INTEGER NOT NULL,
|
||||||
session TEXT NOT NULL,
|
session TEXT NOT NULL,
|
||||||
repos TEXT NOT NULL,
|
repos TEXT NOT NULL,
|
||||||
|
delivery_mode TEXT NOT NULL DEFAULT 'manual',
|
||||||
connected_at REAL NOT NULL,
|
connected_at REAL NOT NULL,
|
||||||
disconnected_at REAL
|
disconnected_at REAL
|
||||||
);
|
);
|
||||||
|
|
@ -49,23 +50,43 @@ class Store:
|
||||||
expires_at REAL NOT NULL
|
expires_at REAL NOT NULL
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
self.db.execute("INSERT OR IGNORE INTO metadata(key,value) VALUES('schema_version',?)", (str(SCHEMA_VERSION),))
|
endpoint_columns = {
|
||||||
|
row["name"] for row in self.db.execute("PRAGMA table_info(endpoints)")
|
||||||
|
}
|
||||||
|
if "delivery_mode" not in endpoint_columns:
|
||||||
|
self.db.execute(
|
||||||
|
"ALTER TABLE endpoints ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'manual'"
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO metadata(key,value) VALUES('schema_version',?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
(str(SCHEMA_VERSION),),
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self.db.close()
|
self.db.close()
|
||||||
|
|
||||||
def register_endpoint(self, endpoint_id: str, pid: int, session: str, repos: list[str]) -> None:
|
def register_endpoint(
|
||||||
|
self,
|
||||||
|
endpoint_id: str,
|
||||||
|
pid: int,
|
||||||
|
session: str,
|
||||||
|
repos: list[str],
|
||||||
|
delivery_mode: str = "manual",
|
||||||
|
) -> None:
|
||||||
import json
|
import json
|
||||||
|
if delivery_mode not in {"manual", "pane"}:
|
||||||
|
raise ValueError(f"invalid delivery mode: {delivery_mode}")
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"UPDATE endpoints SET disconnected_at=strftime('%s','now') "
|
"UPDATE endpoints SET disconnected_at=strftime('%s','now') "
|
||||||
"WHERE pid=? AND session=? AND endpoint_id<>? AND disconnected_at IS NULL",
|
"WHERE pid=? AND session=? AND endpoint_id<>? AND disconnected_at IS NULL",
|
||||||
(pid, session, endpoint_id),
|
(pid, session, endpoint_id),
|
||||||
)
|
)
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"INSERT INTO endpoints(endpoint_id,pid,session,repos,connected_at,disconnected_at) VALUES(?,?,?,?,strftime('%s','now'),NULL) "
|
"INSERT INTO endpoints(endpoint_id,pid,session,repos,delivery_mode,connected_at,disconnected_at) VALUES(?,?,?,?,?,strftime('%s','now'),NULL) "
|
||||||
"ON CONFLICT(endpoint_id) DO UPDATE SET pid=excluded.pid, session=excluded.session, repos=excluded.repos, connected_at=excluded.connected_at, disconnected_at=NULL",
|
"ON CONFLICT(endpoint_id) DO UPDATE SET pid=excluded.pid, session=excluded.session, repos=excluded.repos, delivery_mode=excluded.delivery_mode, connected_at=excluded.connected_at, disconnected_at=NULL",
|
||||||
(endpoint_id, pid, session, json.dumps(repos)),
|
(endpoint_id, pid, session, json.dumps(repos), delivery_mode),
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,21 +84,23 @@ class TmuxManager:
|
||||||
)
|
)
|
||||||
return pid, instance_id
|
return pid, instance_id
|
||||||
|
|
||||||
def preflight(self, repos: list[str], command: str = "codex") -> LaunchPlan:
|
def preflight(self, repos: list[str], command: str | None = None) -> LaunchPlan:
|
||||||
if not repos:
|
if not repos:
|
||||||
raise TmuxError("at least one repository is required")
|
raise TmuxError("at least one repository is required")
|
||||||
if not self.tmux_command or shutil.which(self.tmux_command[0]) is None:
|
if not self.tmux_command or shutil.which(self.tmux_command[0]) is None:
|
||||||
raise TmuxError("tmux is required; install it and ensure 'tmux' is on PATH")
|
raise TmuxError("tmux is required; install it and ensure 'tmux' is on PATH")
|
||||||
if shutil.which("gita") is None:
|
if shutil.which("gita") is None:
|
||||||
raise TmuxError("gita is required; install it and ensure 'gita' is on PATH")
|
raise TmuxError("gita is required; install it and ensure 'gita' is on PATH")
|
||||||
try:
|
command_parts: tuple[str, ...] = ()
|
||||||
command_parts = tuple(shlex.split(command))
|
if command is not None:
|
||||||
except ValueError as exc:
|
try:
|
||||||
raise TmuxError(f"invalid agent command: {exc}") from exc
|
command_parts = tuple(shlex.split(command))
|
||||||
if not command_parts:
|
except ValueError as exc:
|
||||||
raise TmuxError("agent command must not be empty")
|
raise TmuxError(f"invalid initial command: {exc}") from exc
|
||||||
if shutil.which(command_parts[0]) is None:
|
if not command_parts:
|
||||||
raise TmuxError(f"agent command not found on PATH: {command_parts[0]}")
|
raise TmuxError("initial command must not be empty")
|
||||||
|
if shutil.which(command_parts[0]) is None:
|
||||||
|
raise TmuxError(f"initial command not found on PATH: {command_parts[0]}")
|
||||||
unique_repos = tuple(dict.fromkeys(repos))
|
unique_repos = tuple(dict.fromkeys(repos))
|
||||||
try:
|
try:
|
||||||
validate_targets(list(unique_repos))
|
validate_targets(list(unique_repos))
|
||||||
|
|
@ -117,10 +119,12 @@ class TmuxManager:
|
||||||
self._existing_session_identity()
|
self._existing_session_identity()
|
||||||
return LaunchPlan(unique_repos, selected_paths, command_parts)
|
return LaunchPlan(unique_repos, selected_paths, command_parts)
|
||||||
|
|
||||||
def ensure(self, repos: list[str], command: str = "codex") -> Endpoint:
|
def ensure(self, repos: list[str], command: str | None = None) -> Endpoint:
|
||||||
return self.ensure_plan(self.preflight(repos, command))
|
return self.ensure_plan(self.preflight(repos, command))
|
||||||
|
|
||||||
def ensure_plan(self, plan: LaunchPlan, *, tap: bool = True) -> Endpoint:
|
def ensure_plan(self, plan: LaunchPlan, *, tap: bool = False) -> Endpoint:
|
||||||
|
if tap and not plan.command:
|
||||||
|
raise TmuxError("PTY tap requires an explicit initial command")
|
||||||
created_session = False
|
created_session = False
|
||||||
created_windows: list[str] = []
|
created_windows: list[str] = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -129,6 +133,7 @@ class TmuxManager:
|
||||||
first_repo = plan.repos[0]
|
first_repo = plan.repos[0]
|
||||||
self._run(
|
self._run(
|
||||||
"new-session", "-d", "-s", self.session, "-n", "__tamq_boot",
|
"new-session", "-d", "-s", self.session, "-n", "__tamq_boot",
|
||||||
|
"-e", f"TAMQ_REPO={first_repo}",
|
||||||
"-c", plan.paths[first_repo],
|
"-c", plan.paths[first_repo],
|
||||||
)
|
)
|
||||||
created_session = True
|
created_session = True
|
||||||
|
|
@ -155,18 +160,22 @@ class TmuxManager:
|
||||||
self._run("rename-window", "-t", f"{self.session}:__tamq_boot", repo)
|
self._run("rename-window", "-t", f"{self.session}:__tamq_boot", repo)
|
||||||
windows[windows.index("__tamq_boot")] = repo
|
windows[windows.index("__tamq_boot")] = repo
|
||||||
else:
|
else:
|
||||||
self._run("new-window", "-d", "-t", self.session, "-n", repo, "-c", plan.paths[repo])
|
self._run(
|
||||||
|
"new-window", "-d", "-t", self.session, "-n", repo,
|
||||||
|
"-e", f"TAMQ_REPO={repo}", "-c", plan.paths[repo],
|
||||||
|
)
|
||||||
windows.append(repo)
|
windows.append(repo)
|
||||||
created_windows.append(repo)
|
created_windows.append(repo)
|
||||||
command = (
|
if plan.command:
|
||||||
[
|
command = (
|
||||||
*self.tamq_command,
|
[
|
||||||
"tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command,
|
*self.tamq_command,
|
||||||
]
|
"tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command,
|
||||||
if tap
|
]
|
||||||
else list(plan.command)
|
if tap
|
||||||
)
|
else list(plan.command)
|
||||||
self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(command), "C-m")
|
)
|
||||||
|
self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(command), "C-m")
|
||||||
self._run("select-window", "-t", f"{self.session}:{plan.repos[0]}")
|
self._run("select-window", "-t", f"{self.session}:{plan.repos[0]}")
|
||||||
return Endpoint(
|
return Endpoint(
|
||||||
self.session,
|
self.session,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from tamq.cli import build_parser, main
|
from tamq.cli import build_parser, main, normalize_argv
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,9 +33,32 @@ def test_start_parser_supports_command_and_cmd_alias():
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
canonical = parser.parse_args(["start", "--command", "codex --quiet", "a", "b"])
|
canonical = parser.parse_args(["start", "--command", "codex --quiet", "a", "b"])
|
||||||
compatibility = parser.parse_args(["start", "--cmd", "claude", "a"])
|
compatibility = parser.parse_args(["start", "--cmd", "claude", "a"])
|
||||||
assert canonical.agent_command == "codex --quiet"
|
neutral = parser.parse_args(["start", "a", "b"])
|
||||||
|
assert canonical.initial_command == "codex --quiet"
|
||||||
assert canonical.repos == ["a", "b"]
|
assert canonical.repos == ["a", "b"]
|
||||||
assert compatibility.agent_command == "claude"
|
assert compatibility.initial_command == "claude"
|
||||||
|
assert neutral.initial_command is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_first_and_command_first_start_shorthand():
|
||||||
|
assert normalize_argv(["flex-auth", "audit-core"]) == [
|
||||||
|
"start",
|
||||||
|
"flex-auth",
|
||||||
|
"audit-core",
|
||||||
|
]
|
||||||
|
assert normalize_argv(["--command", "codex", "flex-auth"]) == [
|
||||||
|
"start",
|
||||||
|
"--command",
|
||||||
|
"codex",
|
||||||
|
"flex-auth",
|
||||||
|
]
|
||||||
|
assert normalize_argv(["status"]) == ["status"]
|
||||||
|
assert normalize_argv(["--verbose", "flex-auth", "audit-core"]) == [
|
||||||
|
"--verbose",
|
||||||
|
"start",
|
||||||
|
"flex-auth",
|
||||||
|
"audit-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_detached_no_service_start_skips_socket_registration(monkeypatch, capsys):
|
def test_detached_no_service_start_skips_socket_registration(monkeypatch, capsys):
|
||||||
|
|
@ -54,7 +77,7 @@ def test_detached_no_service_start_skips_socket_registration(monkeypatch, capsys
|
||||||
class Manager:
|
class Manager:
|
||||||
def preflight(self, repos, command):
|
def preflight(self, repos, command):
|
||||||
assert repos == ["a", "b"]
|
assert repos == ["a", "b"]
|
||||||
assert command == "codex"
|
assert command is None
|
||||||
return "plan"
|
return "plan"
|
||||||
|
|
||||||
def ensure_plan(self, plan, *, tap=True):
|
def ensure_plan(self, plan, *, tap=True):
|
||||||
|
|
@ -121,7 +144,7 @@ def test_registration_failure_rolls_back_created_windows(monkeypatch, capsys):
|
||||||
return "plan"
|
return "plan"
|
||||||
|
|
||||||
def ensure_plan(self, plan, *, tap=True):
|
def ensure_plan(self, plan, *, tap=True):
|
||||||
assert tap is True
|
assert tap is False
|
||||||
return endpoint
|
return endpoint
|
||||||
|
|
||||||
def rollback(self, value):
|
def rollback(self, value):
|
||||||
|
|
@ -132,8 +155,15 @@ def test_registration_failure_rolls_back_created_windows(monkeypatch, capsys):
|
||||||
|
|
||||||
monkeypatch.setattr("tamq.cli.TmuxManager", Manager)
|
monkeypatch.setattr("tamq.cli.TmuxManager", Manager)
|
||||||
monkeypatch.setattr("tamq.cli.preflight_runtime_paths", lambda: None)
|
monkeypatch.setattr("tamq.cli.preflight_runtime_paths", lambda: None)
|
||||||
monkeypatch.setattr("tamq.cli.ensure_service", lambda: True)
|
monkeypatch.setattr("tamq.cli.ensure_manual_service", lambda: True)
|
||||||
monkeypatch.setattr("tamq.cli.request", failed_registration)
|
monkeypatch.setattr("tamq.cli.request", failed_registration)
|
||||||
assert main(["start", "--detach", "a"]) == 1
|
assert main(["start", "--detach", "a"]) == 1
|
||||||
assert rolled_back == [endpoint]
|
assert rolled_back == [endpoint]
|
||||||
assert "endpoint registration failed: denied" in capsys.readouterr().err
|
assert "endpoint registration failed: denied" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_tap_requires_explicit_command_and_service(capsys):
|
||||||
|
assert main(["start", "--tap", "a"]) == 2
|
||||||
|
assert "--tap requires an explicit --command" in capsys.readouterr().err
|
||||||
|
assert main(["start", "--tap", "--no-service", "--command", "sh", "a"]) == 2
|
||||||
|
assert "--tap cannot be combined with --no-service" in capsys.readouterr().err
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
from tamq.cli import parse_size
|
from tamq.cli import ensure_manual_service, parse_size
|
||||||
|
|
||||||
|
|
||||||
def test_parse_size():
|
def test_parse_size():
|
||||||
assert parse_size("100MB") == 100_000_000
|
assert parse_size("100MB") == 100_000_000
|
||||||
assert parse_size("2KB") == 2_000
|
assert parse_size("2KB") == 2_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_service_restarts_legacy_broker(monkeypatch):
|
||||||
|
capabilities = iter([[], ["manual_delivery"]])
|
||||||
|
starts = []
|
||||||
|
stops = []
|
||||||
|
|
||||||
|
async def request(payload):
|
||||||
|
assert payload == {"op": "ping"}
|
||||||
|
return {"ok": True, "capabilities": next(capabilities)}
|
||||||
|
|
||||||
|
monkeypatch.setattr("tamq.cli.ensure_service", lambda: starts.append(True) or True)
|
||||||
|
monkeypatch.setattr("tamq.cli.stop_service_process", lambda: stops.append(True) or 42)
|
||||||
|
monkeypatch.setattr("tamq.cli.request", request)
|
||||||
|
|
||||||
|
assert ensure_manual_service() is True
|
||||||
|
assert len(starts) == 2
|
||||||
|
assert stops == [True]
|
||||||
|
|
|
||||||
|
|
@ -94,34 +94,59 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
||||||
started = json.loads(
|
started = json.loads(
|
||||||
run(
|
run(
|
||||||
str(tamq),
|
str(tamq),
|
||||||
"start",
|
|
||||||
"--detach",
|
|
||||||
"--command",
|
|
||||||
"alpha-agent",
|
|
||||||
"railiance-platform",
|
"railiance-platform",
|
||||||
"activity-core",
|
"activity-core",
|
||||||
|
"--detach",
|
||||||
).stdout
|
).stdout
|
||||||
)
|
)
|
||||||
assert started["repos"] == ["railiance-platform", "activity-core"]
|
assert started["repos"] == ["railiance-platform", "activity-core"]
|
||||||
|
assert started["delivery_mode"] == "manual"
|
||||||
rows = run(
|
rows = run(
|
||||||
*tmux,
|
*tmux,
|
||||||
"list-windows",
|
"list-windows",
|
||||||
"-t",
|
"-t",
|
||||||
"tamq",
|
"tamq",
|
||||||
"-F",
|
"-F",
|
||||||
"#{window_name}|#{pane_current_path}|#{pane_pid}",
|
"#{window_name}|#{pane_current_path}|#{pane_pid}|#{pane_current_command}",
|
||||||
).stdout.splitlines()
|
).stdout.splitlines()
|
||||||
parsed = {name: (path, pid) for name, path, pid in (row.split("|", 2) for row in rows)}
|
parsed = {
|
||||||
|
name: (path, pid, command)
|
||||||
|
for name, path, pid, command in (row.split("|", 3) for row in rows)
|
||||||
|
}
|
||||||
assert parsed["railiance-platform"][0] == str(repo_a)
|
assert parsed["railiance-platform"][0] == str(repo_a)
|
||||||
assert parsed["activity-core"][0] == str(repo_b)
|
assert parsed["activity-core"][0] == str(repo_b)
|
||||||
|
assert all(command != "alpha-agent" for _, _, command in parsed.values())
|
||||||
|
assert all(
|
||||||
|
"installed-alpha-ready"
|
||||||
|
not in run(*tmux, "capture-pane", "-p", "-t", f"tamq:{repo}").stdout
|
||||||
|
for repo in ("railiance-platform", "activity-core")
|
||||||
|
)
|
||||||
|
|
||||||
|
for repo in ("railiance-platform", "activity-core"):
|
||||||
|
run(
|
||||||
|
*tmux,
|
||||||
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
f"tamq:{repo}",
|
||||||
|
"printf 'TAMQ_REPO=%s\\n' \"$TAMQ_REPO\"",
|
||||||
|
"C-m",
|
||||||
|
)
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
identity_captures = {
|
||||||
|
repo: run(*tmux, "capture-pane", "-p", "-t", f"tamq:{repo}").stdout
|
||||||
|
for repo in ("railiance-platform", "activity-core")
|
||||||
|
}
|
||||||
|
if all(f"TAMQ_REPO={repo}" in output for repo, output in identity_captures.items()):
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert all(f"TAMQ_REPO={repo}" in output for repo, output in identity_captures.items())
|
||||||
|
|
||||||
repeated = json.loads(
|
repeated = json.loads(
|
||||||
run(
|
run(
|
||||||
str(tamq),
|
str(tamq),
|
||||||
"start",
|
"start",
|
||||||
"--detach",
|
"--detach",
|
||||||
"--command",
|
|
||||||
"alpha-agent",
|
|
||||||
"railiance-platform",
|
"railiance-platform",
|
||||||
"activity-core",
|
"activity-core",
|
||||||
).stdout
|
).stdout
|
||||||
|
|
@ -133,7 +158,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
||||||
"-t",
|
"-t",
|
||||||
"tamq",
|
"tamq",
|
||||||
"-F",
|
"-F",
|
||||||
"#{window_name}|#{pane_current_path}|#{pane_pid}",
|
"#{window_name}|#{pane_current_path}|#{pane_pid}|#{pane_current_command}",
|
||||||
).stdout.splitlines()
|
).stdout.splitlines()
|
||||||
assert repeated_rows == rows
|
assert repeated_rows == rows
|
||||||
pre_delivery_status = json.loads(run(str(tamq), "status").stdout)
|
pre_delivery_status = json.loads(run(str(tamq), "status").stdout)
|
||||||
|
|
@ -141,22 +166,49 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
||||||
assert [item["endpoint_id"] for item in pre_delivery_status["endpoints"]] == [
|
assert [item["endpoint_id"] for item in pre_delivery_status["endpoints"]] == [
|
||||||
started["instance_id"]
|
started["instance_id"]
|
||||||
]
|
]
|
||||||
|
assert pre_delivery_status["endpoints"][0]["delivery_mode"] == "manual"
|
||||||
|
|
||||||
message_id = run(
|
target_before = run(
|
||||||
str(tamq), "send", "@activity-core:", "installed-message"
|
*tmux, "capture-pane", "-p", "-t", "tamq:activity-core"
|
||||||
).stdout.strip()
|
).stdout
|
||||||
|
run(
|
||||||
|
*tmux,
|
||||||
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
"tamq:railiance-platform",
|
||||||
|
f"{tamq} send '@activity-core: installed-message'",
|
||||||
|
"C-m",
|
||||||
|
)
|
||||||
deadline = time.monotonic() + 5
|
deadline = time.monotonic() + 5
|
||||||
history = []
|
inbox = []
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
history = [
|
inbox = [
|
||||||
json.loads(line)
|
json.loads(line)
|
||||||
for line in run(str(tamq), "history", "--repo", "activity-core").stdout.splitlines()
|
for line in run(
|
||||||
|
str(tamq), "inbox", "--repo", "activity-core", "--json"
|
||||||
|
).stdout.splitlines()
|
||||||
]
|
]
|
||||||
if history and history[-1]["state"] == "injected":
|
if inbox:
|
||||||
break
|
break
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
assert history[-1]["message_id"] == message_id
|
assert inbox[-1]["sender_repo"] == "railiance-platform"
|
||||||
assert history[-1]["state"] == "injected"
|
assert inbox[-1]["body"] == "installed-message"
|
||||||
|
assert inbox[-1]["state"] == "pending"
|
||||||
|
message_id = inbox[-1]["message_id"]
|
||||||
|
target_after = run(
|
||||||
|
*tmux, "capture-pane", "-p", "-t", "tamq:activity-core"
|
||||||
|
).stdout
|
||||||
|
assert target_after == target_before
|
||||||
|
|
||||||
|
assert run(str(tamq), "ack", message_id).stdout.strip() == message_id
|
||||||
|
assert run(str(tamq), "inbox", "--repo", "activity-core").stdout == ""
|
||||||
|
all_messages = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in run(
|
||||||
|
str(tamq), "inbox", "--repo", "activity-core", "--all", "--json"
|
||||||
|
).stdout.splitlines()
|
||||||
|
]
|
||||||
|
assert all_messages[-1]["state"] == "acknowledged"
|
||||||
status = json.loads(run(str(tamq), "status").stdout)
|
status = json.loads(run(str(tamq), "status").stdout)
|
||||||
assert status["service"] is True
|
assert status["service"] is True
|
||||||
assert [item["endpoint_id"] for item in status["endpoints"]] == [started["instance_id"]]
|
assert [item["endpoint_id"] for item in status["endpoints"]] == [started["instance_id"]]
|
||||||
|
|
@ -168,8 +220,6 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
||||||
str(tamq),
|
str(tamq),
|
||||||
"start",
|
"start",
|
||||||
"--detach",
|
"--detach",
|
||||||
"--command",
|
|
||||||
"alpha-agent",
|
|
||||||
"railiance-platform",
|
"railiance-platform",
|
||||||
"activity-core",
|
"activity-core",
|
||||||
).stdout
|
).stdout
|
||||||
|
|
@ -181,7 +231,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
||||||
"-t",
|
"-t",
|
||||||
"tamq",
|
"tamq",
|
||||||
"-F",
|
"-F",
|
||||||
"#{window_name}|#{pane_current_path}|#{pane_pid}",
|
"#{window_name}|#{pane_current_path}|#{pane_pid}|#{pane_current_command}",
|
||||||
).stdout.splitlines() == rows
|
).stdout.splitlines() == rows
|
||||||
|
|
||||||
run(*tmux, "kill-session", "-t", "tamq")
|
run(*tmux, "kill-session", "-t", "tamq")
|
||||||
|
|
@ -205,6 +255,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
||||||
).stdout
|
).stdout
|
||||||
)
|
)
|
||||||
assert recovered["instance_id"] != started["instance_id"]
|
assert recovered["instance_id"] != started["instance_id"]
|
||||||
|
assert recovered["delivery_mode"] == "manual"
|
||||||
recovered_rows = run(
|
recovered_rows = run(
|
||||||
*tmux,
|
*tmux,
|
||||||
"list-windows",
|
"list-windows",
|
||||||
|
|
@ -217,6 +268,16 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
||||||
f"railiance-platform|{repo_a}",
|
f"railiance-platform|{repo_a}",
|
||||||
f"activity-core|{repo_b}",
|
f"activity-core|{repo_b}",
|
||||||
]
|
]
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
captures = [
|
||||||
|
run(*tmux, "capture-pane", "-p", "-t", f"tamq:{repo}").stdout
|
||||||
|
for repo in ("railiance-platform", "activity-core")
|
||||||
|
]
|
||||||
|
if all("installed-alpha-ready" in output for output in captures):
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert all("installed-alpha-ready" in output for output in captures)
|
||||||
assert run(str(tamq), "stop").returncode == 0
|
assert run(str(tamq), "stop").returncode == 0
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
|
|
|
||||||
37
tests/test_manual_messaging.py
Normal file
37
tests/test_manual_messaging.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from tamq.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
async def service_is_down():
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_send_inbox_and_ack_use_window_repository_identity(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
|
||||||
|
monkeypatch.setenv("TAMQ_REPO", "flex-auth")
|
||||||
|
monkeypatch.setattr("tamq.cli.ping", service_is_down)
|
||||||
|
monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None)
|
||||||
|
|
||||||
|
assert main(["send", "@audit-core:", "review", "this"]) == 0
|
||||||
|
message_id = capsys.readouterr().out.strip()
|
||||||
|
|
||||||
|
assert main(["inbox", "--repo", "audit-core", "--json"]) == 0
|
||||||
|
row = json.loads(capsys.readouterr().out)
|
||||||
|
assert row["message_id"] == message_id
|
||||||
|
assert row["sender_repo"] == "flex-auth"
|
||||||
|
assert row["target_repo"] == "audit-core"
|
||||||
|
assert row["body"] == "review this"
|
||||||
|
assert row["state"] == "pending"
|
||||||
|
|
||||||
|
assert main(["ack", message_id]) == 0
|
||||||
|
capsys.readouterr()
|
||||||
|
assert main(["inbox", "--repo", "audit-core"]) == 0
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbox_requires_repository_outside_managed_window(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
|
||||||
|
monkeypatch.delenv("TAMQ_REPO", raising=False)
|
||||||
|
assert main(["inbox"]) == 2
|
||||||
|
assert "requires --repo" in capsys.readouterr().err
|
||||||
|
|
@ -67,7 +67,7 @@ def test_tap_propagates_terminal_size_resize_and_raw_mouse_input(tmp_path, monke
|
||||||
pytest.fail(f"timed out waiting for {marker!r}; pane contained:\n{output}")
|
pytest.fail(f"timed out waiting for {marker!r}; pane contained:\n{output}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
manager.ensure_plan(plan)
|
manager.ensure_plan(plan, tap=True)
|
||||||
output = wait_for("READY")
|
output = wait_for("READY")
|
||||||
dimensions = manager._run(
|
dimensions = manager._run(
|
||||||
"display-message",
|
"display-message",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class FakeControl:
|
||||||
|
|
||||||
def test_service_delivery_injects_pending_messages(tmp_path, monkeypatch):
|
def test_service_delivery_injects_pending_messages(tmp_path, monkeypatch):
|
||||||
store = Store(tmp_path / "queue.sqlite3")
|
store = Store(tmp_path / "queue.sqlite3")
|
||||||
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"])
|
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "pane")
|
||||||
message_id = store.add("repo-a", "repo-b", "continue")
|
message_id = store.add("repo-a", "repo-b", "continue")
|
||||||
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
||||||
Service(store=store)._deliver_once()
|
Service(store=store)._deliver_once()
|
||||||
|
|
@ -34,6 +34,18 @@ def test_service_delivery_injects_pending_messages(tmp_path, monkeypatch):
|
||||||
assert store.list()[0]["state"] == "injected"
|
assert store.list()[0]["state"] == "injected"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_never_injects_manual_endpoint_messages(tmp_path, monkeypatch):
|
||||||
|
store = Store(tmp_path / "queue.sqlite3")
|
||||||
|
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "manual")
|
||||||
|
store.add("repo-a", "repo-b", "continue")
|
||||||
|
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
||||||
|
|
||||||
|
Service(store=store)._deliver_once()
|
||||||
|
|
||||||
|
assert FakeControl.instances[-1].injected == []
|
||||||
|
assert store.list()[0]["state"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
def test_service_disconnects_disappeared_tmux_endpoint(tmp_path, monkeypatch):
|
def test_service_disconnects_disappeared_tmux_endpoint(tmp_path, monkeypatch):
|
||||||
store = Store(tmp_path / "queue.sqlite3")
|
store = Store(tmp_path / "queue.sqlite3")
|
||||||
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"])
|
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"])
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
from tamq.store import Store
|
from tamq.store import Store
|
||||||
|
|
||||||
|
|
@ -13,3 +14,32 @@ def test_message_history_and_jsonl(tmp_path):
|
||||||
assert json.loads(output.read_text())["body"] == "hello"
|
assert json.loads(output.read_text())["body"] == "hello"
|
||||||
assert store.db.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
|
assert store.db.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_migrates_legacy_endpoint_rows_to_manual_delivery(tmp_path):
|
||||||
|
path = tmp_path / "legacy.sqlite3"
|
||||||
|
db = sqlite3.connect(path)
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
INSERT INTO metadata VALUES('schema_version', '1');
|
||||||
|
CREATE TABLE endpoints (
|
||||||
|
endpoint_id TEXT PRIMARY KEY,
|
||||||
|
pid INTEGER NOT NULL,
|
||||||
|
session TEXT NOT NULL,
|
||||||
|
repos TEXT NOT NULL,
|
||||||
|
connected_at REAL NOT NULL,
|
||||||
|
disconnected_at REAL
|
||||||
|
);
|
||||||
|
INSERT INTO endpoints VALUES('legacy', 1, 'tamq', '[\"repo\"]', 1, NULL);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
store = Store(path)
|
||||||
|
assert store.endpoints()[0]["delivery_mode"] == "manual"
|
||||||
|
assert store.db.execute(
|
||||||
|
"SELECT value FROM metadata WHERE key='schema_version'"
|
||||||
|
).fetchone()[0] == "2"
|
||||||
|
store.close()
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def test_real_tmux_starts_two_repo_windows_and_reuses_them(tmp_path, monkeypatch
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
endpoint = manager.ensure_plan(plan)
|
endpoint = manager.ensure_plan(plan, tap=True)
|
||||||
assert endpoint.created_session is True
|
assert endpoint.created_session is True
|
||||||
assert endpoint.created_windows == ("railiance-platform", "activity-core")
|
assert endpoint.created_windows == ("railiance-platform", "activity-core")
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ def test_real_tmux_starts_two_repo_windows_and_reuses_them(tmp_path, monkeypatch
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
assert all("alpha-ready" in capture for capture in captures)
|
assert all("alpha-ready" in capture for capture in captures)
|
||||||
|
|
||||||
reused = manager.ensure_plan(plan)
|
reused = manager.ensure_plan(plan, tap=True)
|
||||||
assert reused.created_session is False
|
assert reused.created_session is False
|
||||||
assert reused.created_windows == ()
|
assert reused.created_windows == ()
|
||||||
reused_rows = manager._run(
|
reused_rows = manager._run(
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ def test_tmux_manager_builds_tap_windows(tmp_path, monkeypatch):
|
||||||
return ""
|
return ""
|
||||||
monkeypatch.setattr(manager, "_run", run)
|
monkeypatch.setattr(manager, "_run", run)
|
||||||
monkeypatch.setattr(tmux.subprocess, "run", lambda *args, **kwargs: type("R", (), {"returncode": 1})())
|
monkeypatch.setattr(tmux.subprocess, "run", lambda *args, **kwargs: type("R", (), {"returncode": 1})())
|
||||||
endpoint = manager.ensure(["a", "b"], "codex --quiet")
|
plan = manager.preflight(["a", "b"], "codex --quiet")
|
||||||
|
endpoint = manager.ensure_plan(plan, tap=True)
|
||||||
assert endpoint.endpoint_id == "tmux-amq-42"
|
assert endpoint.endpoint_id == "tmux-amq-42"
|
||||||
assert endpoint.instance_id.startswith("tmux-amq-42-")
|
assert endpoint.instance_id.startswith("tmux-amq-42-")
|
||||||
assert endpoint.repos == ["a", "b"]
|
assert endpoint.repos == ["a", "b"]
|
||||||
|
|
@ -118,6 +119,34 @@ def test_tmux_only_plan_starts_agent_without_tap(tmp_path, monkeypatch):
|
||||||
assert "tamq tap" not in send
|
assert "tamq tap" not in send
|
||||||
|
|
||||||
|
|
||||||
|
def test_neutral_plan_starts_shell_without_sending_keystrokes(tmp_path, monkeypatch):
|
||||||
|
manager = tmux.TmuxManager("tamq-test")
|
||||||
|
calls = []
|
||||||
|
plan = tmux.LaunchPlan(("a", "b"), {"a": str(tmp_path), "b": str(tmp_path)}, ())
|
||||||
|
|
||||||
|
def run(*args, check=True):
|
||||||
|
calls.append(args)
|
||||||
|
if args[:2] == ("display-message", "-p"):
|
||||||
|
return "42"
|
||||||
|
if args[:2] == ("list-windows", "-t"):
|
||||||
|
return "__tamq_boot"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(manager, "_run", run)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tmux.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda *args, **kwargs: type("R", (), {"returncode": 1})(),
|
||||||
|
)
|
||||||
|
manager.ensure_plan(plan)
|
||||||
|
|
||||||
|
assert not any(call and call[0] == "send-keys" for call in calls)
|
||||||
|
new_session = next(call for call in calls if call and call[0] == "new-session")
|
||||||
|
new_window = next(call for call in calls if call and call[0] == "new-window")
|
||||||
|
assert "TAMQ_REPO=a" in new_session
|
||||||
|
assert "TAMQ_REPO=b" in new_window
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_rejects_missing_agent(tmp_path, monkeypatch):
|
def test_preflight_rejects_missing_agent(tmp_path, monkeypatch):
|
||||||
repo = tmp_path / "a"
|
repo = tmp_path / "a"
|
||||||
repo.mkdir()
|
repo.mkdir()
|
||||||
|
|
@ -125,7 +154,7 @@ def test_preflight_rejects_missing_agent(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(tmux, "validate_targets", lambda repos: None)
|
monkeypatch.setattr(tmux, "validate_targets", lambda repos: None)
|
||||||
monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)})
|
monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)})
|
||||||
monkeypatch.setattr(tmux.shutil, "which", lambda command: None if command == "missing-agent" else f"/bin/{command}")
|
monkeypatch.setattr(tmux.shutil, "which", lambda command: None if command == "missing-agent" else f"/bin/{command}")
|
||||||
with pytest.raises(tmux.TmuxError, match="agent command not found"):
|
with pytest.raises(tmux.TmuxError, match="initial command not found"):
|
||||||
manager.preflight(["a"], "missing-agent")
|
manager.preflight(["a"], "missing-agent")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Terminal-neutral sessions and safe manual messaging"
|
title: "Terminal-neutral sessions and safe manual messaging"
|
||||||
domain: communication
|
domain: communication
|
||||||
repo: tmux-amq
|
repo: tmux-amq
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: coulomb-social
|
topic_slug: coulomb-social
|
||||||
planning_priority: P0
|
planning_priority: P0
|
||||||
|
|
@ -26,6 +26,12 @@ interactive program—owns a pane.
|
||||||
tamq start flex-auth audit-core
|
tamq start flex-auth audit-core
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The repository-first shorthand is equivalent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tamq flex-auth audit-core
|
||||||
|
```
|
||||||
|
|
||||||
This opens or reuses ordinary interactive shells in the exact registered
|
This opens or reuses ordinary interactive shells in the exact registered
|
||||||
repository paths. It runs no initial command, installs no PTY observer, and
|
repository paths. It runs no initial command, installs no PTY observer, and
|
||||||
never injects a queued message into an active terminal input line.
|
never injects a queued message into an active terminal input line.
|
||||||
|
|
@ -52,7 +58,7 @@ The sender defaults to the repository identity of the current managed window.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TAMQ-WP-0005-T01
|
id: TAMQ-WP-0005-T01
|
||||||
status: progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "84d3b151-3a0a-56ca-8c00-9000ecbb4751"
|
state_hub_task_id: "84d3b151-3a0a-56ca-8c00-9000ecbb4751"
|
||||||
```
|
```
|
||||||
|
|
@ -63,11 +69,15 @@ command. Set a trustworthy per-window `TAMQ_REPO` identity without changing the
|
||||||
user's shell or terminal behavior. Retain explicit compatibility aliases where
|
user's shell or terminal behavior. Retain explicit compatibility aliases where
|
||||||
they do not weaken the neutral default.
|
they do not weaken the neutral default.
|
||||||
|
|
||||||
|
Completed with repository-first and explicit `start` forms, no default command,
|
||||||
|
untouched tmux shells, per-window exported `TAMQ_REPO`, and exact opt-in
|
||||||
|
`--command`/`--cmd` execution only for newly created windows.
|
||||||
|
|
||||||
## Separate durable messaging from pane input
|
## Separate durable messaging from pane input
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TAMQ-WP-0005-T02
|
id: TAMQ-WP-0005-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6c0f04a6-2af1-53b7-9976-325a9483a838"
|
state_hub_task_id: "6c0f04a6-2af1-53b7-9976-325a9483a838"
|
||||||
```
|
```
|
||||||
|
|
@ -77,11 +87,18 @@ from turning their queued messages into `send-keys` input. Make any legacy PTY
|
||||||
observation/injection path explicit opt-in, visible in status, and absent from
|
observation/injection path explicit opt-in, visible in status, and absent from
|
||||||
the default startup path.
|
the default startup path.
|
||||||
|
|
||||||
|
Completed with schema-versioned endpoint `delivery_mode`. New and migrated
|
||||||
|
endpoints default to `manual`; the broker never injects their pending messages.
|
||||||
|
Pane observation/injection requires both explicit `--tap` and `--command`, is
|
||||||
|
reported as `pane` mode, and cannot be combined with `--no-service`. Startup
|
||||||
|
also restarts a legacy broker that lacks the manual-delivery capability before
|
||||||
|
registration.
|
||||||
|
|
||||||
## Add a practical manual inbox
|
## Add a practical manual inbox
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TAMQ-WP-0005-T03
|
id: TAMQ-WP-0005-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "80bb61bb-8182-58d3-9067-3530026a59a4"
|
state_hub_task_id: "80bb61bb-8182-58d3-9067-3530026a59a4"
|
||||||
```
|
```
|
||||||
|
|
@ -91,11 +108,15 @@ automation, use the current window's `TAMQ_REPO` as sender identity, validate
|
||||||
targets, and preserve explicit acknowledgement. Manual exchange must work
|
targets, and preserve explicit acknowledgement. Manual exchange must work
|
||||||
without corrupting either terminal's current input.
|
without corrupting either terminal's current input.
|
||||||
|
|
||||||
|
Completed with `tamq inbox`, repository-context sender identity, local target
|
||||||
|
validation, human-readable and JSON output, pending-by-default filtering, and
|
||||||
|
the existing explicit acknowledgement command.
|
||||||
|
|
||||||
## Prove and document the neutral workflow
|
## Prove and document the neutral workflow
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TAMQ-WP-0005-T04
|
id: TAMQ-WP-0005-T04
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "08ca9746-97d1-56d8-badd-5a5ac2c8e147"
|
state_hub_task_id: "08ca9746-97d1-56d8-badd-5a5ac2c8e147"
|
||||||
```
|
```
|
||||||
|
|
@ -105,3 +126,10 @@ per-window repository identity, durable send/inbox/ack exchange, and proof that
|
||||||
pending manual messages never appear as pane input. Update the install/start
|
pending manual messages never appear as pane input. Update the install/start
|
||||||
quickstart and practical-usability assessment, then record the local acceptance
|
quickstart and practical-usability assessment, then record the local acceptance
|
||||||
evidence. This task follows T01-T03.
|
evidence. This task follows T01-T03.
|
||||||
|
|
||||||
|
Completed with the isolated installed-package acceptance. It proves untouched
|
||||||
|
shells at exact paths, stable reuse, per-window identities, manual
|
||||||
|
send/inbox/ack exchange, byte-for-byte unchanged target-pane output, service
|
||||||
|
restart and endpoint recovery, and exact explicit-command startup. The full
|
||||||
|
suite passes 77 tests with 74% statement coverage; README, INTENT, SCOPE, agent
|
||||||
|
workflow, and command rules now state the terminal-neutral boundary.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue