@staix/agent-hub 0.12.28 → 0.12.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the previous repository, archived on 2026-09-30 when this repository's history was rewritten; the one exception is the open smoke-check issue, formerly #12, which moved here as #1. Numbers in newer entries refer to this repository.
4
4
 
5
+ ## 0.12.29
6
+
7
+ - Let an explicitly authorized agent run live verification through `ahub --live-test`, with operational console authority scoped to the invocation and its ahub continuations, the initiating peer retained in a mandatory delegation audit, and ordinary agent permissions unchanged (#408).
8
+
9
+ - Remove the site caveat card accent edge and refresh the #273 live-verification ledger, including the machine-wide upgrade isolation requirements (#406, #273).
10
+
5
11
  ## 0.12.28
6
12
 
7
13
  - Recover an offline Pi owner with person-selected preserved history via `ahub pi --replace-session --session-file`, retaining prior identity metadata and queued work while refusing live owners and unsettled effects (#403).
package/README.md CHANGED
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
4
4
  hub-owned local-LLM worker, with optional Qwen Code and Jcode support, collaborate as peers in independent project directories, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.12.28, control protocol 16. Durable delivery records distinguish queued
7
+ Status: 0.12.29, control protocol 16. Durable delivery records distinguish queued
8
8
  work from uncertain execution. The [smoke checklist](docs/smoke.md) records
9
9
  verified paths and remaining prerequisites.
10
10
 
@@ -33,7 +33,7 @@ cd <your project> && ahub init && ahub up
33
33
  Or install the same version from GitHub:
34
34
 
35
35
  ```bash
36
- bun add -g github:STAIxBWLB/agent-hub#v0.12.28 && ahub setup
36
+ bun add -g github:STAIxBWLB/agent-hub#v0.12.29 && ahub setup
37
37
  ```
38
38
 
39
39
  The installed commands remain `ahub` and `agent-hub`.
@@ -1,5 +1,8 @@
1
1
  # Daemon, control protocol and recovery
2
2
 
3
+ - For explicitly user-authorized live verification, use the per-invocation CLI delegation in `docs/security.md` §Agent CLI identity and conductor authority; keep caller markers and record native evidence separately from fixtures.
4
+ - Keep `export const LIVE_TEST_SUPPORTED = true;` in `src/cli/identity.ts`: delegated recovery staging reads this declaration before any source effect.
5
+
3
6
  - Console attention navigation selects a delivery only by an exact visible row id. A missing id or row prints read-only guidance; never select by peer or fall back to the first Queue row.
4
7
 
5
8
  Scope: the operator console, control WS and its protocol, state files, the dashboard listener and settings, the Claude channel's reconnect, `ahub setup`, git snapshots, upgrade and recovery. Read before editing `src/cli/console.ts`, `src/cli/console-state.ts`, `src/cli/attention.ts`, `src/ui/needs.ts`, `src/hub/daemon.ts`, `src/hub/ui.ts`, `src/hub/settings.ts`, `src/hub/control-client.ts`, `src/adapters/claude-channel.ts`, `src/hub/restart.ts`, `src/hub/recovery-store.ts`, `src/hub/snapshots.ts`, `src/hub/manager.ts`, `src/hub/lifecycle.ts`, `src/hub/crash.ts`, `src/hub/start-mode.ts`, `src/hub/terminal-open.ts`, `src/hub/recovery-error.ts`, `src/hub/db-timeout.ts`, `src/cli/setup.ts`, `src/cli/upgrade*.ts`, `src/cli/terminal-recovery.ts`, `src/cli/recovery-package.ts` or `src/cli/facts-hook.ts`, or before adding a native peer.
@@ -1,8 +1,46 @@
1
1
  # Operations guide
2
2
 
3
- This guide describes ahub 0.12.28 and control protocol 16. Live verification
3
+ This guide describes ahub 0.12.29 and control protocol 16. Live verification
4
4
  results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
5
5
 
6
+ ## Agent-operated live verification
7
+
8
+ When the user explicitly authorizes a live test, its agent may run console
9
+ commands using `ahub --live-test [--project <path|id>] <command> [args...]`.
10
+ For example, `ahub --live-test --project <scratch-path> ui --no-open` starts the
11
+ real dashboard listener and prints its one-time link without opening a browser.
12
+ `ahub --project <scratch-path> --live-test reset --all --yes` exercises reset on
13
+ that selected disposable project. The global options precede the command and
14
+ may appear in either order; a flag after the command is not delegation.
15
+
16
+ This grants console authority for operational console-class and peer-lifecycle conductor-class commands, including destructive actions, for one
17
+ invocation and its ahub continuations. Use only the user-authorized targets and
18
+ commands; destructive live checks belong in disposable projects. Existing
19
+ project discovery still applies: initialize the scratch folder as its own Git
20
+ repository before `ahub init`, then confirm `status --json` reports that exact
21
+ `cwd` before any destructive check.
22
+ Existing confirmations, process ownership checks, native identity fences and recovery
23
+ locks still apply. Do not remove agent markers. The initiating peer and command
24
+ label are audited before effects; an unavailable audit blocks delegation. A new
25
+ unflagged invocation has ordinary peer authority again. Native tools launched
26
+ by the command keep their own peer identity and receive no delegation flag.
27
+ Allowed-class commands keep peer identity and permissions even with the flag:
28
+ `--live-test say` sends as the peer. Peer starts, pause and resume may use
29
+ delegated console authority. Flagged task assign/escalate refuse; omit the flag
30
+ to use the ordinary actor-preserving conductor path when the peer has that role.
31
+ Person verdicts and user-authored workflow writes are not delegated. Dashboard
32
+ sessions issued through the flag retain operational controls but refuse message
33
+ and task/planning content writes. Use native peer tools for agent-authored work.
34
+
35
+ Use a CLI/coordinator containing this feature throughout the experiment. Older
36
+ daemons do not understand the new delegated audit outcome; run the candidate
37
+ daemon to capture its delegated event and console notice. This policy does not
38
+ change external computer-use restrictions. Recovery staging refuses old targets
39
+ without live-test support before source effects. Delegated headless recovery restores through the ordinary target CLI, carrying
40
+ its operationId on the delegated console connection; existing native ownership
41
+ and session-identity checks remain. Record actual observations in the
42
+ smoke ledger; the presence of this option proves no outstanding #273 live leg.
43
+
6
44
  ## Command output
7
45
 
8
46
  `ahub status`, `board`, `budget`, `doctor`, `projects`, `queue list`, `turns` and `report` print shared console tables and labelled details.
@@ -1621,21 +1659,21 @@ section is what they run.
1621
1659
 
1622
1660
  Upgrade running projects with the target release's own coordinator. It accepts
1623
1661
  a running source on control protocol 9 (0.6.x), 10 (0.7.0 through 0.12.0),
1624
- 11 (0.12.1 and 0.12.2), 12 (0.12.3), 13 (0.12.4 through 0.12.15), 14 (0.12.16), 15 (0.12.17 through 0.12.19) or 16 (0.12.20 through 0.12.28), and only
1662
+ 11 (0.12.1 and 0.12.2), 12 (0.12.3), 13 (0.12.4 through 0.12.15), 14 (0.12.16), 15 (0.12.17 through 0.12.19) or 16 (0.12.20 through 0.12.29), and only
1625
1663
  a target on its own protocol, so the target's coordinator fits every supported
1626
1664
  source and carries every recovery fix released up to it. Protocol 8 and older
1627
1665
  (0.5.x and earlier) are refused as `manual-bootstrap-required`. Run from the
1628
1666
  project directory, without replacing the global CLI first:
1629
1667
 
1630
1668
  ```bash
1631
- bunx --package @staix/agent-hub@0.12.28 ahub upgrade --to 0.12.28 --dry-run
1632
- bunx --package @staix/agent-hub@0.12.28 ahub upgrade --to 0.12.28 --yes
1669
+ bunx --package @staix/agent-hub@0.12.29 ahub upgrade --to 0.12.29 --dry-run
1670
+ bunx --package @staix/agent-hub@0.12.29 ahub upgrade --to 0.12.29 --yes
1633
1671
  ```
1634
1672
 
1635
1673
  | Running now | Coordinator to use |
1636
1674
  | --- | --- |
1637
1675
  | 0.6.x (protocol 9) | the target's, through `bunx` as above |
1638
- | 0.7.0 through 0.12.0 (protocol 10), 0.12.1 and 0.12.2 (protocol 11), 0.12.3 (protocol 12), 0.12.4 through 0.12.15 (protocol 13), 0.12.16 (protocol 14), 0.12.17 through 0.12.19 (protocol 15), 0.12.20 through 0.12.28 (protocol 16) | the target's, through `bunx` as above |
1676
+ | 0.7.0 through 0.12.0 (protocol 10), 0.12.1 and 0.12.2 (protocol 11), 0.12.3 (protocol 12), 0.12.4 through 0.12.15 (protocol 13), 0.12.16 (protocol 14), 0.12.17 through 0.12.19 (protocol 15), 0.12.20 through 0.12.29 (protocol 16) | the target's, through `bunx` as above |
1639
1677
  | any supported source, with the installed CLI already at the target | `ahub upgrade` below, which is the same coordinator |
1640
1678
  | 0.5.x or earlier (protocol 8 and older) | not supported: bootstrap by hand with the matching CLI |
1641
1679
 
@@ -1667,14 +1705,14 @@ projects first:
1667
1705
 
1668
1706
  ```bash
1669
1707
  ahub restart --dry-run
1670
- ahub upgrade --to 0.12.28 --dry-run
1708
+ ahub upgrade --to 0.12.29 --dry-run
1671
1709
  ```
1672
1710
 
1673
1711
  Apply only after reviewing the plan:
1674
1712
 
1675
1713
  ```bash
1676
1714
  ahub restart --yes
1677
- ahub upgrade --to 0.12.28 --yes
1715
+ ahub upgrade --to 0.12.29 --yes
1678
1716
  ahub recovery status [<operation-id>]
1679
1717
  ahub recovery resume [<operation-id>]
1680
1718
  ahub recovery abort [<operation-id>]
@@ -17,7 +17,7 @@ ahub setup # installs the Claude Code channel plu
17
17
  Or use the matching GitHub release:
18
18
 
19
19
  ```bash
20
- bun add -g github:STAIxBWLB/agent-hub#v0.12.28
20
+ bun add -g github:STAIxBWLB/agent-hub#v0.12.29
21
21
  ahub setup
22
22
  ```
23
23
 
package/docs/security.md CHANGED
@@ -71,10 +71,35 @@ or the native Claude/Codex shell marker when the hub marker is absent. It connec
71
71
  as that peer in tools mode. Messages and task changes retain that actor;
72
72
  `say` defaults to status priority and important messages still require the peer's
73
73
  capability. Conflicting or malformed markers fail closed. There is no `--as-user`.
74
- Human-only operations are refused before connecting, including permission answers
74
+ By default, console operations are refused before connecting, including permission answers
75
75
  and permission modes, queue resolution, budget overrides, benchmark runs, research
76
76
  backfill and task labels, stopping a peer (`ahub stop`), lifecycle and recovery
77
77
  operations, and `ask`.
78
+ For live verification explicitly authorized by the user, an agent may invoke
79
+ `ahub --live-test [--project <path|id>] <command> [args...]`. The flag delegates
80
+ the existing console authority for console-class operations and conductor-class peer starts/holds, including approvals,
81
+ settings, roster and lifecycle operations. It does not restrict effects to a
82
+ scratch project: the agent must stay within the authorized commands and targets.
83
+ Malformed or conflicting caller markers still refuse. The original peer and
84
+ closed command label are recorded with outcome `delegated` before execution;
85
+ if that audit cannot be written, the command refuses. The current daemon emits
86
+ `agent_cli` with `delegated: true` and `refused: false`. Allowed-class commands retain the peer identity and capability checks, including
87
+ messages, proposals and reviews. Peer starts, pause and resume may delegate
88
+ operational console authority; task assign/escalate under the flag refuse. `--as-user`, person task labels,
89
+ person-authored planning/release/memory/goal writes, SDLC record, benchmark run
90
+ and the pet composer are refused. No argument text is copied into this audit.
91
+ Delegated dashboard restrictions are bound to its server-issued ticket/session:
92
+ operational settings/start/stop controls remain, but message, proposal and
93
+ workflow-history actions refuse even if the body tries to clear the restriction.
94
+ The flag is not stored in settings or exported to native agent children. Ahub's
95
+ own daemon launch, console subprocesses, recovery runner, interactive follow-up commands and upgrade coordinator
96
+ handover carry it explicitly in argv. Each new top-level invocation needs its
97
+ own flag. An older CLI does not support it; use a candidate with this feature
98
+ for the continuation too. Staging rejects a target without the pinned
99
+ `LIVE_TEST_SUPPORTED` declaration before preparation or commit, including a
100
+ previously cached stage. Delegated headless-native restoration uses the ordinary target CLI with its
101
+ operationId through that operational console authority. Native and TUI
102
+ restoration keep their existing identity and ownership checks. External computer-use restrictions remain unchanged.
78
103
  The operator uses a plain terminal or `ahub console`. A Claude `!` command that
79
104
  inherits the agent markers follows the same rule. The console strips control
80
105
  sequences from agent and daemon text before it quotes forged headers, and its
package/docs/smoke.md CHANGED
@@ -2,6 +2,60 @@
2
2
 
3
3
  `scripts/check.sh` covers everything against fakes. The legs below need real accounts and an interactive terminal, so they are run by hand and recorded here.
4
4
 
5
+ ## Issue #273 operator execution plan (2026-10-16)
6
+
7
+ The [execution plan and blocker record](verification/2026-10-16-273-live-verification.md)
8
+ maps the outstanding issue entries to their procedures and required observations.
9
+ The installed CLI and running project daemon both reported 0.12.28. This is an
10
+ environment readback only. No outstanding live leg passed in this session:
11
+ the installed 0.12.28 CLI refused `ahub ui` from Codex, and LCU refused Terminal access.
12
+ These are dated observations. From 0.12.29, authorized agents use the audited
13
+ `--live-test` route described below and in the execution plan; the external LCU
14
+ restriction remains separate. The operator initially chose procedure preparation;
15
+ #273 remains open.
16
+
17
+ ## Agent-operated verification (#408)
18
+
19
+ For user-authorized live checks, the candidate CLI supports
20
+ `ahub --live-test [--project <path|id>] <command> [args...]`, using audited console
21
+ authority for that invocation. Follow [the operating procedure](operations.md#agent-operated-live-verification)
22
+ and record each actual result below. References to a person in historical
23
+ procedures describe their original authority requirement; an explicitly
24
+ authorized agent may now execute those CLI steps through this option. Native
25
+ account readiness, real terminal/browser observations, sign-off requirements and
26
+ external computer-use restrictions still apply. No pending #273 item is checked
27
+ by adding this path.
28
+
29
+ 2026-10-16, macOS, candidate CLI/source `40d13bc4`, daemon 0.12.28/protocol 16:
30
+ an actual Codex-marked shell with a separate hub home and disposable Git project
31
+ ran delegated `init`, `up --no-console`, `ui --no-open`, `permission pi ask`,
32
+ `kill` and `reset --all --yes`, all exit 0. Unflagged `status --json` reported
33
+ the exact scratch cwd. The dashboard URL returned HTTP
34
+ 200 and HTML; no browser or ticket/session exchange was performed in that shell run. The daemon recorded
35
+ `agent_cli` events for init/up/daemon/ui/permission with peer `codex`,
36
+ `delegated: true` and `refused: false`. The reset archive mode was 0700 and the
37
+ scratch daemon was stopped. This verifies the delegated CLI path, without
38
+ claiming a model turn, terminal look, conversation restoration or the complete
39
+ #214/#273 live criteria. The automated console-subprocess and recovery-target
40
+ checks remain separate fixture evidence.
41
+
42
+ 2026-10-16 review follow-up, source `fcbb18ec`: the same isolated Git scratch
43
+ project was started from a Codex-marked shell. Flagged `say` produced a bus
44
+ envelope from `codex`; person task-label writing refused. A delegated dashboard
45
+ ticket exchanged for a session with HTTP 200, its snapshot returned HTTP 200,
46
+ and a send action trying `liveTest: false` returned HTTP 403. The scratch daemon
47
+ was stopped (kill exit 0). This was an actual CLI/HTTP run, without a model turn
48
+ or browser visual inspection.
49
+
50
+ Task #65 follow-up, source `5fbe50b6`: operational peer starts, pause and resume
51
+ may delegate console authority; allowed-class messages/proposals/reviews retain
52
+ the peer, while flagged task assign/escalate refuse. The temporary headless
53
+ staging ceiling is removed. The recovery driver -> real target CLI -> scripted
54
+ control server regression verifies headless Pi mode, saved sessionId,
55
+ operationId, console hello and initiating-peer audit (225 related tests passed).
56
+ This is fixture/CLI proof, not an actual native Pi restoration; that live leg
57
+ remains owed in #273.
58
+
5
59
  ## Hub memory (#258)
6
60
 
7
61
  Not run yet (2026-10-12). Source and fakes cover tiers, the graph, retrieval, sync between two simulated homes and a bare remote, leases, import and suggestions (`test/memory-*.test.ts`).
@@ -1947,10 +1947,17 @@ task list polling does not. `hub_status` stays conductor-only.
1947
1947
  proposer as the actor with reason `manual` (on the `reassigned` or `reserved`
1948
1948
  entry), and writes no conduct audit event; during a PII turn it is refused
1949
1949
  like the conductor's.
1950
+ ## Amendment: delegated agent live verification (#408)
1951
+
1952
+ - Decision, 2026-10-16: an agent explicitly authorized by the user to perform a live test may use the global `--live-test` flag, before the command and in either order with `--project`. Duplicate global options refuse. Default command classification and malformed/conflicting identity refusals stay unchanged.
1953
+ - Console-class operations and conductor-class peer starts/pause/resume may use console authority after the original caller is validated; allowed-class commands retain peer identity and capability checks. Flagged task assign/escalate refuse rather than writing user-attributed history. A mandatory ids-only audit records its peer, closed command label and `delegated` outcome before execution. The daemon emits an `agent_cli` event with `delegated: true`, `refused: false`, and a console notice naming the peer. User impersonation, person labels and person-authored workflow writes refuse. Delegated dashboard tickets/sessions allow operational controls but refuse user-attributed content and history actions on both project and unified surfaces; request bodies cannot widen the session.
1954
+ - This is a user-authorized operating policy, not a sandbox or authenticated grant. It includes console mutations and requires the agent to remain within the authorized targets and operations. Command-specific confirmations, process ownership, provider identity and recovery locks still apply.
1955
+ - No settings/environment opt-in persists. Native argv does not receive the flag. Ahub's own daemon launch, console subprocesses, recovery runner, interactive continuation commands and newer-version coordinator handover carry it explicitly; older CLIs cannot accept it. The recovery driver's target CLI environment preserves the initiating peer for its delegation audit. Staging checks the target's pinned LIVE_TEST_SUPPORTED declaration before its cached-stage return and before prepare/commit; unsupported old targets refuse without source effects. Headless-native restoration uses the ordinary target CLI with the operationId on its delegated console connection; no headless staging ceiling is added. External computer-use policies remain outside this change. No #273 live success is inferred from this implementation.
1956
+
1950
1957
  ## Amendment: ahub reset (issue #214)
1951
1958
 
1952
1959
  `ahub reset [--all] [--yes]` returns a project's hub to a clean state. It is
1953
- human-only (the CLI identity gate refuses it from an agent shell) and a dry run
1960
+ console-only by default (explicit authorized delegation is described in #408) and a dry run
1954
1961
  unless `--yes`: the dry run reads `hub.db` read-only (with `immutable=1` when
1955
1962
  `hub.db-wal` or `-shm` is absent, so it creates neither) and prints delivery ids,
1956
1963
  peer names and counts per category, never task or message text. With `--yes`
@@ -0,0 +1,25 @@
1
+ # 0.12.29 release verification
2
+
3
+ This record covers release preparation and source review. Publication and installation receipts are recorded separately after their gates succeed.
4
+
5
+ ## Candidate
6
+
7
+ 0.12.29 carries PR #409 (#408), the site correction in PR #406 and the #273 execution-plan corrections in PR #407. Control protocol remains 16.
8
+
9
+ - Explicit `--live-test` delegation permits operational console and peer lifecycle actions with a mandatory initiating-peer audit. Allowed agent commands retain their peer identity; person-attributed writes refuse under delegation.
10
+ - Dashboard delegation is bound to the issued session and checked server-side. Recovery refuses unsupported target packages before effects; supported headless restores use the ordinary target CLI with the recovery operation id.
11
+ - The #273 ledger includes the current native checks and makes machine-wide upgrade isolation explicit. No native leg is marked passed by these preparation changes.
12
+
13
+ ## Document review
14
+
15
+ Security, Operations and the design specification describe the reviewed delegation boundary. README, Quickstart and the current Operations examples name 0.12.29. The stale area notes are checked against the CLI identity, console, recovery, dashboard and audit changes; their existing rules remain applicable. Document stamps name the release preparation commit, which must remain an ancestor of the merge commit.
16
+
17
+ ## Recorded checks
18
+
19
+ - PR #409 head a8b9a56b: independent source review and board tasks #64/#65 approved. Exact-head Ubuntu and macOS full gates passed (`check: OK`); the headless restoration regression is fixture/CLI proof, not native Pi restoration.
20
+ - Original PR #407 head c637a088: Ubuntu, macOS and seeded guards passed in CI run 38791043404. The corrected execution plan was independently reviewed at 780853b6.
21
+ - The final release head requires its own Ubuntu/macOS `scripts/check.sh` and seeded guards before merging. Committed document freshness and stamp ancestry are checked again before tagging. The release workflow requires green CI for the identical tree before npm publication.
22
+
23
+ ## Not verified by this record
24
+
25
+ Native, account, visual and research checks remain tracked in #273 and #1. Package publication, global CLI/plugin installation and running-hub upgrade are separate operational results; this preparation record does not claim them.
@@ -0,0 +1,150 @@
1
+ # Issue #273 operator execution plan
2
+
3
+ ## Scope and dated observations
4
+
5
+ - Date: 2026-10-16, Asia/Seoul.
6
+ - Initial observation source: main `426bab99`, release 0.12.28, protocol 16. The following CLI refusals describe that installed version, before #408.
7
+ - Issue #273 was read live, including its 2026-10-14 additions at initial preparation; the matrix below was refreshed for the five 2026-10-16 additions before release. Every checkbox remained unchecked.
8
+ - `ahub --version` returned `0.12.28`.
9
+ - `ahub status --json` returned daemon version `0.12.28`, protocol `16`, `stopping: false`, Claude idle, Codex busy, Kimi offline and Pi idle. These are status observations, not model-delivery or restoration receipts.
10
+ - `ahub ui` exited 1 with `ahub: codex cannot run ahub ui; the person runs it in ahub console or a terminal`.
11
+ - LCU app selection for `com.apple.Terminal` returned `Computer Use is not allowed to use the app 'com.apple.Terminal' for safety reasons.` No Terminal inspection or input occurred.
12
+ - The operator selected procedure and blocker preparation before live execution. No identity markers were removed, no console connection was impersonated, and no lifecycle, reset, roster, quota or provider experiment was run.
13
+
14
+ ## Supported execution route and remaining boundaries
15
+
16
+ From 0.12.29, an explicitly authorized agent runs operational CLI steps with
17
+ `ahub --live-test <command> [args...]`, including `init`, `up`, `console`,
18
+ `settings`, `reset`, `upgrade`, `recovery` and roster operations. Follow
19
+ [Agent-operated live verification](../operations.md#agent-operated-live-verification).
20
+ Record the initiating peer and the mandatory delegation audit (`agent_cli`
21
+ event or retained CLI audit), alongside the command and operational receipts.
22
+ The original 0.12.28 refusal above is historical evidence, not a current CLI
23
+ blocker. Identity markers remain intact; the supported option supplies authority.
24
+
25
+ Allowed-class commands retain the agent's peer identity, including messaging
26
+ and review verdicts. The flag refuses person-attributed writes, including
27
+ `--as-user`, task labels, task assign/escalate and planning/memory writes.
28
+ Delegated dashboard sessions permit operational controls but refuse sending,
29
+ proposals and person-attributed planning/history actions; changing a request body
30
+ cannot remove that restriction. Exercises requiring those actions or a person's
31
+ sign-off need the ordinary authorized actor path and cannot be completed merely
32
+ by adding the flag.
33
+
34
+ LCU's Terminal denial is a separate external computer-use restriction and remains
35
+ in force. CLI delegation does not grant Terminal app access or prove native
36
+ visual, account or accessibility observations. Record any inaccessible native
37
+ surface as unverified while continuing reachable authorized CLI checks.
38
+
39
+ ## Execution order
40
+
41
+ Use a disposable Git project under the workspace scratchpad, outside this
42
+ checkout, and initialize its own Git boundary before `ahub init`. An authorized
43
+ agent runs `ahub --live-test init`, reviews the resulting configuration and starts
44
+ it with `ahub --live-test up`. Record its absolute path, project id and delegation
45
+ audit. Keep destructive recovery/reset experiments separate from the project
46
+ used for browser and model checks. Recovery can hold a machine-wide lock, so
47
+ inspect `ahub --live-test recovery status` before starting an operation and wait
48
+ for unrelated operations to finish. Upgrade isolation has the additional
49
+ machine-wide and shared-configuration requirements below.
50
+
51
+ Use installed, authenticated native tools. Record each version before its first
52
+ turn. Do not change credentials, buy access or exhaust quota to produce a result.
53
+ Do not run another session's stop or reset. Keep identity fencing in effect and
54
+ use the documented delegation route rather than removing identity markers.
55
+
56
+ 1. Run reachable operational checks through `ahub --live-test`; retain their delegation audits and readbacks. Inspect terminal/dashboard rendering only through an available permitted native surface.
57
+ 2. Run native messaging and role/planner exercises on the disposable project using the ordinary peer or person path as required by attribution; the delegation flag does not authorize person-content writes.
58
+ 3. Run roster and recovery experiments with disposable conversations only, using the delegated operational CLI route and recording native continuity separately.
59
+ 4. Run the real repository SDLC cycle only with an explicitly selected issue and release window, retaining required person verdicts.
60
+ 5. Record each result in `docs/smoke.md`, retaining any unverified remainder and external blocker.
61
+
62
+ ## Required observations by issue entry
63
+
64
+ All rows below are pending. A procedure reference is not evidence of execution.
65
+ Section names refer to [the smoke checklist](../smoke.md).
66
+
67
+ | Entry | Procedure or setup | Evidence required before checking the issue box |
68
+ | --- | --- | --- |
69
+ | #201 AC2/AC7, #213 AC7 | Console layout and readability | Light/dark terminal, stream and panels; terminal name, dimensions, readable text and observed clipping |
70
+ | #205 AC3 | Claude without channel evidence | Managed Claude push and settlement beside plain Claude tools-only; correlated message and settlement ids |
71
+ | #206 AC4 | Upgrade procedure below | Two distinct releases, unmanaged Claude marked reconnect-only, same native conversation after reconnect |
72
+ | #214 AC6 | ahub reset on a scratch project | Both reset scopes, queue/board checks, archive permissions and restored board, same project id |
73
+ | #215 AC6 | Expired-lease procedure below | Real expired lease and missing transcript, daemon/CLI/lock/project/archive outcomes, completion or audited disposition |
74
+ | #225 AC9 | Target-crash procedure below | Target killed after peers-restored, supported resume, original conversation and restart count readback |
75
+ | #269 AC10 | Settings from the dashboard and the terminal, parts 1-2 | Light/dark, phone width and keyboard path; actual Orca and terminal.open launches with native Pi attached |
76
+ | #285 phase 1 | Record table, #285 live item 1 | Real Kimi turn, status context source/value, usage_update timing relative to correlated prompt result |
77
+ | #284 phase 1 | One-shot command output | status/board/budget/doctor, light/dark, 80 and wider columns, console rendering and full suggested ids |
78
+ | #298 | Unified dashboard slow start | Real browser unconfirmed text, 30-second hold and keyboard focus after Start; a deterministic slow native start is a prerequisite |
79
+ | #296 | Ending Codex turn | Correlated native turn/steer response, elapsed time and exact refusal; a successful steer alone does not prove refusal matching |
80
+ | #315 Codex | Live roster return | Removal exit, printed resume command, restored same thread and peer reattachment |
81
+ | #315 Pi | Live roster return | Retained terminal/process, admission refused off roster, same TUI reattaches; prepare blocked until retained TUI ends |
82
+ | #316 trust and ingestion | Kimi TUI pull mode | Actual trust screen, project MCP tools and model-visible managed instructions/tool descriptions |
83
+ | #316 messaging/discovery | Kimi TUI pull mode | Correlated hub_inbox/hub_send exchange with another peer; trusted project and overlay discovery |
84
+ | #316 headless return | Kimi TUI pull mode | TUI to headless to TUI, session/config restoration readback; argv fixtures are insufficient |
85
+ | #256 identity/modes | Qwen Code 0.25.0 production peer | Real ACP supported identity and advertised modes; stop on CLI/ACP mismatch |
86
+ | #256 native provider | Qwen Code 0.25.0 production peer | Actual model identity, delivery, exact tool call/result and mode confirmation |
87
+ | #256 stop/recovery | Qwen Code 0.25.0 production peer | Owned stop and controlled/crash recovery retain the recorded conversation |
88
+ | #256 quota | Observe an organic refusal only | Version and exact sanitized native refusal; unknown remains failed delivery |
89
+ | #259 AC11 | Research-profile exercise | Two native agents, three sources, configured routing/classes, plans, role/tool ingestion, deterministic citations and person sign-off |
90
+ | #259 converters | Intended formats and configured converters | Actual conversion, review and undo previews; timing, caps and named-only fallback |
91
+ | #259 guidance | Same native profile sessions | Native profile/custom-stage guidance retention; preserve separate #295 and research measurements |
92
+ | #257 AC6 | SDLC gates | Real repository issue through merge/release under advise; issue/PR/head ids, gate blockers and release report |
93
+ | #295 AC16 | Native role/task cycle | Role text ingestion, retention after actual compaction, assignment/reassignment notice and review duty |
94
+ | #280 AC11 | Console attention, Global attention status, Approved character, Dashboard character | Per-phase light/dark, tmux, colour off, blocks/ascii and actual dashboard look |
95
+ | #263 AC11 | Planning and release controls | Native planner creates two releases; planned/actual dates read back |
96
+ | #263 board/intake | Project board | Desktop/phone browser against real hub, permission/state readback and actual GitHub issue intake |
97
+ | #306 | Observe an organic Kimi weekly/monthly refusal only | CLI version and exact sanitized prefix; do not deliberately consume quota |
98
+ | #403 | Offline Pi preserved-history recovery | Actual selected native session restored with identity/queue retained; a live owner refused |
99
+ | #398 | Pull-only important-message advisory | Real Kimi TUI and plain Claude see the tool-result advisory; hub_inbox drains it |
100
+ | #391 pet lifecycle | Standalone pet window | Actual stop changes it to Offline and host exits; native screenshots of states |
101
+ | #391 dashboard CSP | Split dashboard views | Browser-console capture of CSP behavior, separate from the earlier owner browser look |
102
+ | #394 VoiceOver | Office desk More control | Native VoiceOver traversal; DOM/click/Enter/Space regressions remain separate |
103
+
104
+ ## Upgrade with unmanaged Claude (#206)
105
+
106
+ 1. An authorized operator starts the disposable project on a known older release, records its version/project id and attaches an unmanaged Claude session using the documented plain-Claude tools-only setup. Record its native session id and a harmless conversation marker.
107
+ 2. Use an isolated test environment for the hub home/registry and shared Claude plugin setup. Upgrade is machine-wide, even when invoked from the scratch cwd: it plans all registered running projects and changes the shared package/plugin; `--project` does not scope it. Run the target coordinator with `ahub --live-test upgrade --to 0.12.29 --dry-run` and retain the complete plan. Verify every planned root is a disposable fixture, record the shared CLI/plugin fate, and confirm the intended unmanaged Claude is reconnect-only. Stop without applying if any non-disposable project or shared setup is in scope. A separate AGENTHUB_HOME alone does not isolate the shared plugin; use a dedicated native configuration/test environment too.
108
+ 3. The authorized agent applies that same target with `ahub --live-test upgrade --to 0.12.29 --yes` after reviewing the plan (a person may use the ordinary interactive path). Retain the delegation audit. Record operation id, source and target versions, terminal fate and plugin reconnect outcome. Do not substitute a restart at the same version.
109
+ 4. Read back `ahub status`, the original native session id and marker, and one correlated hub message receipt. Record whether a second terminal appeared or a conversation was replaced.
110
+ 5. Capture final operation and lock status. A refusal or failed reconnect is a failed leg, with its exact wording; it is not a pass based on the dry-run plan.
111
+
112
+ ## Expired preparation lease with missing Codex transcript (#215)
113
+
114
+ This needs an isolated failure-injection setup before it is executable. There is
115
+ no public `ahub recovery prepare` CLI. Do not invent one or edit production
116
+ recovery JSON to claim a native reproduction. `test/upgrade.test.ts`,
117
+ `expiredLease`, is a scripted-driver fixture and is only a setup reference.
118
+
119
+ 1. Give disposable Codex its own native session store and complete a harmless turn so its transcript exists. Record project id, daemon instance, native thread id and transcript path. Keep the original transcript outside the fixture's missing-path condition; never remove a production transcript.
120
+ 2. In a controlled scratch-only harness, stop the coordinator after real source preparation and before commit, preserving its operation receipt. Record the daemon's preparation, terminal close receipts and runner identity. The harness and its pause point must be reviewed before use.
121
+ 3. Make only the disposable transcript unavailable and allow the real preparation lease to expire. Confirm expiry from the daemon log/readback rather than changing a clock or receipt timestamp.
122
+ 4. The authorized agent runs `ahub --live-test recovery status <operation-id>` and the supported next action it offers. Capture the exact missing-session refusal. Restore the disposable transcript and resume, or use the offered audited disposition with a reason. Do not force a fresh conversation and label it restoration.
123
+ 5. Record daemon and CLI outcomes, operation/lock state, project id before/after, archive paths and modes, and the final continuity or lostContinuity disposition. Preserve failed intermediate receipts.
124
+
125
+ ## Target crash after peers-restored (#225)
126
+
127
+ 1. In a separate disposable project, establish native conversations with harmless markers and start a controlled recovery. Save its operation id and source conversation ids.
128
+ 2. Observe the real `peers-restored` phase before release. A controlled pause at that boundary is required: if the operation already completed, this run cannot demonstrate this failure point. Do not fabricate the phase by editing a receipt.
129
+ 3. Verify the target pid belongs to this disposable project's daemon and record its instance id. The authorized operator ends only that target daemon through a permitted process-control surface. Record native launcher/process state after the exit.
130
+ 4. Run `ahub --live-test recovery status <operation-id>`, then its offered `ahub --live-test recovery resume <operation-id>`. Retain the delegation audits. If a surviving launcher blocks it, end the identified disposable launcher as instructed and retry the supported action.
131
+ 5. Verify the new target instance, recorded restart count, same native conversations and markers, no duplicate terminal, final operation status and lock release. A missing snapshot or failed native resume remains a failure, with the precise refusal and the supported disposition recorded.
132
+
133
+ ## Recording and closure
134
+
135
+ For each execution record: date/time, source commit, hub/native versions, project
136
+ id, exact command or interaction, expected observation, actual observation,
137
+ correlated ids, sanitized evidence path and pass/fail/unverified verdict. Record
138
+ partial success at the substep level. Keep raw tokens, cookies, private task text
139
+ and account details out of committed evidence.
140
+
141
+ On a confirmed defect, preserve its failure wording and open a linked defect
142
+ issue within the user's authorized scope. Check a #273 item only after its dated
143
+ record covers the entire item, or a failed run is recorded with a linked defect.
144
+ Environment/tool restrictions remain unverified prerequisites. They do not meet
145
+ the issue's failed-product-run alternative. #273 cannot close from this plan.
146
+
147
+ The initial preparation ran no full check or native model turn. Its original
148
+ commit c637a088 subsequently passed Ubuntu/macOS full gates and seeded guards
149
+ in CI run 38791043404. The release candidate must earn its own exact-head checks;
150
+ none of those automated results completes a live leg.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
5
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
14
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
21
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
38
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
46
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -74,7 +74,7 @@
74
74
  ]
75
75
  },
76
76
  "docs/agent-notes/budget.md": {
77
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
77
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
78
78
  "paths": [
79
79
  "src/hub/budget.ts",
80
80
  "src/cli/statusline-tee.ts",
@@ -86,7 +86,7 @@
86
86
  ]
87
87
  },
88
88
  "docs/agent-notes/bus.md": {
89
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
89
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
90
90
  "paths": [
91
91
  "src/hub/bus.ts",
92
92
  "src/hub/envelope.ts",
@@ -98,7 +98,7 @@
98
98
  ]
99
99
  },
100
100
  "docs/agent-notes/daemon.md": {
101
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
101
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
102
102
  "paths": [
103
103
  "src/hub/daemon.ts",
104
104
  "src/hub/control-client.ts",
@@ -155,7 +155,7 @@
155
155
  ]
156
156
  },
157
157
  "docs/agent-notes/tasks.md": {
158
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
158
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
159
159
  "paths": [
160
160
  "src/hub/tasks.ts",
161
161
  "src/hub/board.ts",
@@ -172,7 +172,7 @@
172
172
  ]
173
173
  },
174
174
  "docs/agent-notes/tests.md": {
175
- "verifiedAgainst": "2d3b120ab177e4fd9e6f991d038c22ca4ccb6977",
175
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
176
176
  "paths": [
177
177
  "test/",
178
178
  "scripts/check.sh",
@@ -191,7 +191,7 @@
191
191
  ]
192
192
  },
193
193
  "docs/agent-notes/README.md": {
194
- "verifiedAgainst": "6dcc7fe00c38f13613bcf53524fa3d1527a708e5",
194
+ "verifiedAgainst": "0463ea1f251cea17ec7f32a2861de8c554fe4062",
195
195
  "paths": [
196
196
  "src/cli/console-state.ts",
197
197
  "src/cli/attention.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.28",
3
+ "version": "0.12.29",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.28",
3
+ "version": "0.12.29",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -16157,7 +16157,7 @@ import { join as join4 } from "path";
16157
16157
  // package.json
16158
16158
  var package_default = {
16159
16159
  name: "@staix/agent-hub",
16160
- version: "0.12.28",
16160
+ version: "0.12.29",
16161
16161
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
16162
16162
  license: "MIT",
16163
16163
  type: "module",
@@ -28,6 +28,8 @@ export interface ConsoleOptions {
28
28
  terminal?: ConsoleTerminal;
29
29
  color?: boolean;
30
30
  noPet?: boolean;
31
+ /** Explicit delegation inherited only by this console's ahub subprocesses. */
32
+ liveTest?: boolean;
31
33
  /** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
32
34
  runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
33
35
  pollMs?: number;
@@ -188,7 +190,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
188
190
  finally { polling = false; }
189
191
  };
190
192
  const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
191
- const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
193
+ const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...(options.liveTest ? ["--live-test"] : []), ...args], {
192
194
  cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
193
195
  env: { ...process.env, COLUMNS: String(commandColumns), NO_COLOR: "1" },
194
196
  });
package/src/cli/help.ts CHANGED
@@ -11,6 +11,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
11
11
  ["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
12
12
  ["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
13
13
  ["ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
14
+ ["ahub --live-test [--project <path|id>] <command> [args...]", "user-authorized operational console delegation; ordinary calls retain peer identity; person-authored content refuses"],
14
15
  ["ahub help [command]", "this help, or one command's entries; also --help and -h"],
15
16
  ["ahub version | --version", "print the installed version"],
16
17
  ]],
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { cliCommandLabel } from "./identity.ts";
4
4
 
5
5
  export interface CliAudit {
6
- v: 1; id: string; at: number; peer: string; command: string; outcome: "run" | "refused" | "invalid";
6
+ v: 1; id: string; at: number; peer: string; command: string; outcome: "run" | "refused" | "invalid" | "delegated";
7
7
  }
8
8
  const MAX_FILES = 256;
9
9
  const MAX_BYTES = 64 * 1024;
@@ -14,7 +14,7 @@ const valid = (row: any): row is CliAudit => row?.v === 1 && typeof row.id === "
14
14
  && typeof row.at === "number" && Number.isFinite(row.at) && row.at > 0
15
15
  && typeof row.peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row.peer) && !/\s/.test(row.peer)
16
16
  && typeof row.command === "string" && row.command.length <= 64 && row.command === cliCommandLabel(...splitLabel(row.command))
17
- && ["run", "refused", "invalid"].includes(row.outcome);
17
+ && ["run", "refused", "invalid", "delegated"].includes(row.outcome);
18
18
  const splitLabel = (label: string): [string, string[]] => { const [command = "unknown", ...args] = label.split(" "); return [command, args]; };
19
19
 
20
20
  /**
@@ -1,5 +1,19 @@
1
1
  import { isSupportedPeer } from "../hub/peer-catalog.ts";
2
2
 
3
+ /** Recovery reads this declaration before delegating to another package's CLI. */
4
+ export const LIVE_TEST_SUPPORTED = true;
5
+
6
+ /** Person verdicts and user-authored workflow content are never delegated. */
7
+ export function liveTestRefusal(command: string, args: readonly string[]): boolean {
8
+ if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
9
+ const sub = args.find(arg => arg !== "--json");
10
+ return ["task", "role", "goal"].includes(command)
11
+ || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
12
+ || (command === "memory" && sub !== "graph")
13
+ || (command === "board" && args[0] === "import") || (command === "sdlc" && args[0] === "record")
14
+ || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
15
+ }
16
+
3
17
  export type CliIdentity =
4
18
  | { role: "console" }
5
19
  | { role: "tools"; peer: string }
package/src/cli/main.ts CHANGED
@@ -63,7 +63,7 @@ import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, label
63
63
  import { summarize, summarizeByTask } from "../hub/report.ts";
64
64
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
65
65
  import { pathWarnings } from "../hub/conflicts.ts";
66
- import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
66
+ import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
67
67
  import { recordCliAudit } from "./identity-audit.ts";
68
68
  import { runConsole } from "./console.ts";
69
69
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
@@ -83,10 +83,16 @@ const since = (): number => {
83
83
 
84
84
  const argv = process.argv.slice(2);
85
85
  let selector: string | undefined;
86
- if (argv[0] === "--project") {
87
- argv.shift();
88
- selector = argv.shift();
89
- if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
86
+ let liveTest = false;
87
+ while (argv[0] === "--project" || argv[0] === "--live-test") {
88
+ if (argv.shift() === "--live-test") {
89
+ if (liveTest) fail("--live-test may appear only once");
90
+ liveTest = true;
91
+ } else {
92
+ if (selector !== undefined) fail("--project may appear only once");
93
+ selector = argv.shift();
94
+ if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
95
+ }
90
96
  }
91
97
  const [given = "help", ...rest] = argv;
92
98
  // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
@@ -114,17 +120,27 @@ const projectConfig = () => {
114
120
  return config;
115
121
  };
116
122
  const stateDir = selected.stateDir;
117
- const identity = detectCliIdentity(process.env);
123
+ const caller = detectCliIdentity(process.env);
118
124
  const commandLabel = cliCommandLabel(cmd, args);
119
125
  const commandAccess = classifyPeerCommand(cmd, args);
126
+ // Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
127
+ const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
128
+ const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
129
+ const identity = delegatedPeer ? { role: "console" as const } : caller;
120
130
  function audit(outcome: "run" | "refused" | "invalid"): void {
121
- if (identity.role === "console") return;
122
- try { recordCliAudit(stateDir, identity.role === "tools" ? identity.peer : "unknown", commandLabel, outcome); }
123
- catch { console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)"); }
131
+ if (caller.role === "console") return;
132
+ try { recordCliAudit(stateDir, caller.role === "tools" ? caller.peer : "unknown", commandLabel, delegatedPeer && outcome === "run" ? "delegated" : outcome); }
133
+ catch {
134
+ if (delegatedPeer) fail("live-test delegation audit unavailable; nothing was executed");
135
+ console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)");
136
+ }
124
137
  }
125
138
  if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
139
+ if (delegationRefused) { audit("refused"); fail("live-test cannot write person-attributed content; use peer tools or a person's terminal"); }
126
140
  if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
127
- if (identity.role === "tools") audit("run");
141
+ if (identity.role === "tools" || delegatedPeer) audit("run");
142
+ // Forward only to ahub's own continuation commands, never to native agent argv or an environment flag.
143
+ const liveTestArgs = liveTest ? ["--live-test"] : [];
128
144
  const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
129
145
  try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
130
146
  /** One-shot output flags do not alter the fetched JSON document. */
@@ -381,7 +397,7 @@ async function hold(t: "pause" | "resume"): Promise<void> {
381
397
  }
382
398
 
383
399
  function spawnRecovery(operation: RecoveryOperation, quiet = false): void {
384
- const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
400
+ const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), ...liveTestArgs, "recovery-run", operation.id], {
385
401
  cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
386
402
  });
387
403
  child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
@@ -415,7 +431,7 @@ async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<voi
415
431
  // The command owns the terminal while it runs; a reader whose input already ended has nothing to pause.
416
432
  if (!ended) rl.pause();
417
433
  const done = (code: number) => { if (!ended) rl.resume(); resolve(code); };
418
- const child = spawn(process.execPath, argv, { cwd, stdio: "inherit" });
434
+ const child = spawn(process.execPath, [argv[0]!, ...liveTestArgs, ...argv.slice(1)], { cwd, stdio: "inherit" });
419
435
  child.on("error", () => done(1));
420
436
  child.on("close", (code) => done(code ?? 1));
421
437
  }),
@@ -444,7 +460,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
444
460
  lock: recoveryLock,
445
461
  read: (id) => readOperation<RecoveryOperation>(id),
446
462
  runner: recoveryRunner,
447
- live: (op) => liveProjects(op, makeRecoveryDriver().inspect),
463
+ live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer).inspect),
448
464
  endPeer: (planned, peer) => endPlannedPeer(planned, peer),
449
465
  runnerStoppable: (id) => signedRunner(id) !== undefined,
450
466
  stopRunner: (id) => stopSignedRunner(id),
@@ -470,7 +486,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
470
486
  if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
471
487
  // An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
472
488
  if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
473
- const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", "upgrade", "--to", to!, ...rest];
489
+ const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest];
474
490
  console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
475
491
  if (interactive) {
476
492
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -532,31 +548,31 @@ const commands: Record<string, () => Promise<void> | void> = {
532
548
  const runner = recoveryRunner(id);
533
549
  if (action === "status") {
534
550
  // #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
535
- const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver().inspect);
551
+ const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect);
536
552
  console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
537
553
  }
538
- else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver()); console.log("preflight cancelled; no committed transition was rolled back"); }
554
+ else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer)); console.log("preflight cancelled; no committed transition was rolled back"); }
539
555
  else if (action === "dispose") {
540
- // #215: human-only (the identity gate refuses agent shells); the reason is kept in the operation's audit.
556
+ // Console authority (including explicit --live-test delegation); the reason is kept in the operation's audit.
541
557
  const { one, rest: flags } = takeFlags(rest, ["--fresh-session", "--reason"], []);
542
558
  const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
543
559
  const reason = one["--reason"]?.trim() ?? "";
544
560
  if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
545
- const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver());
561
+ const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver(undefined, delegatedPeer));
546
562
  if (!stop) { console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`); return spawnRecovery(result); }
547
563
  console.log(JSON.stringify(publicOperation(result), null, 2));
548
564
  console.log(`${result.plan.kind} abandoned, not completed; the recovery lock is released. A project whose target ran starts again with that version's CLI.`);
549
565
  }
550
566
  else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
551
567
  // #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
552
- else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver().inspect))}`);
568
+ else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect))}`);
553
569
  else if (runner === "unknown") console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
554
570
  else if (runner) console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
555
571
  else spawnRecovery(operation);
556
572
  },
557
573
  "recovery-run": async () => {
558
574
  if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
559
- const result = await runRecovery(args[0]!, makeRecoveryDriver());
575
+ const result = await runRecovery(args[0]!, makeRecoveryDriver(undefined, delegatedPeer));
560
576
  if (result.phase !== "completed") process.exitCode = 1;
561
577
  },
562
578
  help: () => {
@@ -593,12 +609,13 @@ const commands: Record<string, () => Promise<void> | void> = {
593
609
  return console.log("dashboard manager stopped; project hubs were kept running");
594
610
  }
595
611
  let url: string;
596
- if (args.includes("--all")) url = await openManager();
612
+ if (args.includes("--all")) url = await openManager({ liveTest: !!delegatedPeer });
597
613
  else {
598
614
  const hub = await connect();
599
615
  try {
600
- const res = await hub.request({ t: "ui", ...(settings ? { settings: true } : {}) }, 10_000);
616
+ const res = await hub.request({ t: "ui", ...(settings ? { settings: true } : {}), ...(delegatedPeer ? { liveTest: true } : {}) }, 10_000);
601
617
  if (!res.ok) fail(res.error);
618
+ if (delegatedPeer && res.liveTest !== true) fail("this hub cannot restrict delegated dashboard content; use a live-test-capable hub");
602
619
  // A hub older than #269 ignores the field and would hand out an ordinary session without saying so.
603
620
  if (settings && res.settings !== true) fail("this hub does not open settings sessions; upgrade the running hub to use ahub ui --settings");
604
621
  url = res.url;
@@ -673,17 +690,17 @@ const commands: Record<string, () => Promise<void> | void> = {
673
690
  if (args.some(arg => arg !== "--panels" && arg !== "--no-pet" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--no-pet] [--color=auto|always|never]");
674
691
  const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice("--color=".length), { isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
675
692
  if (color instanceof Error) return fail("usage: ahub console [--panels] [--no-pet] [--color=auto|always|never]");
676
- await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), noPet: args.includes("--no-pet"), color });
693
+ await runConsole({ client: await connect(), cwd, stateDir, liveTest, panels: args.includes("--panels"), noPet: args.includes("--no-pet"), color });
677
694
  },
678
695
 
679
696
  up: async () => {
680
697
  const unattended = unattendedEnv || args.includes("--unattended");
681
698
  if (unattended) console.error(UNATTENDED_WARNING);
682
- const status = await startProject(registeredProject(), { unattended });
699
+ const status = await startProject(registeredProject(), { unattended, liveTest });
683
700
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
684
701
  try { if (loadPetConfig(cwd).window) console.log(await startPetWindow(stateDir, cwd)); }
685
702
  catch { console.error("Pet window was not started; run ahub pet --window to inspect it."); }
686
- if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir });
703
+ if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir, liveTest });
687
704
  },
688
705
 
689
706
  claude: async () => {
@@ -323,7 +323,7 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
323
323
  return dest;
324
324
  }
325
325
 
326
- export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver {
326
+ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string): RecoveryDriver {
327
327
  const now = () => Date.now();
328
328
  const sleep = (ms: number) => Bun.sleep(ms);
329
329
  const env = (op: RecoveryOperation, project?: Project): NodeJS.ProcessEnv => {
@@ -333,6 +333,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
333
333
  (project && /(?:_API_KEY|_ACCESS_TOKEN|_SECRET|_TOKEN)$/.test(key))) delete value[key];
334
334
  value.AGENTHUB_HOME = hubHome(); value.AGENTHUB_RECOVERY_OPERATION = op.id;
335
335
  value.AGENTHUB_UNATTENDED = "0";
336
+ if (liveTestPeer) value.AGENTHUB_PEER_ID = liveTestPeer;
336
337
  if (project) {
337
338
  value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
338
339
  delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
@@ -345,7 +346,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
345
346
  return value;
346
347
  };
347
348
  const command = async (op: RecoveryOperation, args: string[], project?: Project) => {
348
- const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...args],
349
+ const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
349
350
  { env: env(op, project), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
350
351
  if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
351
352
  return result;
@@ -448,6 +449,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
448
449
  if (packageDigest(op.sourceRoot) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
449
450
  const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version) }
450
451
  : await stageRelease(op.plan.version, op.plan.integrity!, run);
452
+ const identityPath = join(target.root, "src/cli/identity.ts");
453
+ if (liveTestPeer && (!existsSync(identityPath) || !readFileSync(identityPath, "utf8").includes("export const LIVE_TEST_SUPPORTED = true;"))) {
454
+ throw new FinalRefusal("target does not support live-test delegation; choose a supporting target before retrying; runtimes unchanged");
455
+ }
451
456
  // #215: the protocol and waiver checks are properties of these bytes. Once they passed (the runner recorded the
452
457
  // digest), a resume does not check again, so a transient probe failure cannot refuse an operation for good;
453
458
  // changed bytes are the runner's final "staged target changed".
package/src/hub/daemon.ts CHANGED
@@ -1459,9 +1459,12 @@ export async function startDaemon(opts: DaemonOptions) {
1459
1459
  supervision.checkRound();
1460
1460
  try {
1461
1461
  for (const audit of drainCliAudits(opts.stateDir)) {
1462
- const refused = audit.outcome !== "run";
1463
- event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused });
1464
- notify(refused ? `${audit.peer} refused ahub ${audit.command}` : `${audit.peer} ran ahub ${audit.command} as ${audit.peer}`);
1462
+ const delegated = audit.outcome === "delegated";
1463
+ const refused = audit.outcome !== "run" && !delegated;
1464
+ event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}) });
1465
+ notify(refused ? `${audit.peer} refused ahub ${audit.command}` : delegated
1466
+ ? `${audit.peer} ran ahub ${audit.command} with delegated live-test console authority`
1467
+ : `${audit.peer} ran ahub ${audit.command} as ${audit.peer}`);
1465
1468
  }
1466
1469
  } catch { log("CLI audit outbox unavailable; inspect state before retrying an agent command"); }
1467
1470
  for (const peer of bus.knownPeers()) {
@@ -4204,7 +4207,7 @@ export async function startDaemon(opts: DaemonOptions) {
4204
4207
  writeStatus();
4205
4208
  // #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
4206
4209
  if (msg.settings === true) notify("settings session opened from ahub ui --settings: for 15 minutes that dashboard session may change permission modes, routing and start settings");
4207
- return void reply({ t: "ui", ok: true, url: dashboard.issue(msg.settings === true), ...(msg.settings === true ? { settings: true } : {}) });
4210
+ return void reply({ t: "ui", ok: true, url: dashboard.issue(msg.settings === true, msg.liveTest === true), ...(msg.settings === true ? { settings: true } : {}), ...(msg.liveTest === true ? { liveTest: true } : {}) });
4208
4211
  } catch {
4209
4212
  return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
4210
4213
  }
package/src/hub/events.ts CHANGED
@@ -43,7 +43,7 @@ export type HubEvent =
43
43
  | ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number; preparedRoles?: RoleIdentity[] | null } & Partial<TaskAttribution>)
44
44
  | { type: "native_turn_end"; peer: string; id?: string }
45
45
  | { type: "conduct"; peer: string; action: string; task?: number; target?: string }
46
- | { type: "agent_cli"; peer: string; command: string; refused: boolean }
46
+ | { type: "agent_cli"; peer: string; command: string; refused: boolean; delegated?: true }
47
47
  | { type: "permission"; id: string; peer: string; event: "requested" | "answered" | "expired" | "cancelled"; latencyMs?: number; surface?: "console" | "dashboard" | "terminal"; option?: "allow_once" | "allow_always" | "reject_once" | "reject_always" }
48
48
  /** Completed native turns that received supervision, not feed enqueues or inferred costs. */
49
49
  | { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
@@ -138,7 +138,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
138
138
  }
139
139
  }
140
140
 
141
- export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv } = {}): Promise<any> {
141
+ export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean } = {}): Promise<any> {
142
142
  assertLifecycleAvailable();
143
143
  const registry = new Registry();
144
144
  try {
@@ -160,7 +160,7 @@ export async function startProject(project: Project, options: { unattended?: boo
160
160
  // Native sessions can outlive the operation which launched them. An expired
161
161
  // operation ID must not make a later ordinary startup load a retired snapshot.
162
162
  if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
163
- const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, "daemon",
163
+ const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, ...(options.liveTest ? ["--live-test"] : []), "daemon",
164
164
  ...(options.unattended ? ["--unattended"] : [])], {
165
165
  cwd: project.root, detached: true, stdio: ["ignore", log, log],
166
166
  env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
@@ -21,12 +21,12 @@ export type Lifecycle = {
21
21
  startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
22
22
  stopProject(project: Project, expectedInstance?: string): Promise<void>;
23
23
  };
24
- export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number;
24
+ export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number; liveTest?: boolean;
25
25
  /** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
26
26
  forwardTimeoutMs?: (action: string, nominalMs: number) => number };
27
27
  type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
28
28
  type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
29
- const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
29
+ const active = new Map<string, { handle: ManagerHandle; issue(liveTest?: boolean): string }>();
30
30
  const CONTROL_TIMEOUT = 3000;
31
31
  // A current CLI may authenticate the previous protocol-9 manager while
32
32
  // refreshing it, then launch the current manager implementation.
@@ -178,7 +178,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
178
178
  };
179
179
  dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
180
180
  server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
181
- fetch(req) {
181
+ async fetch(req) {
182
182
  if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
183
183
  if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
184
184
  if (req.method !== "POST") return new Response("POST required", { status: 405 });
@@ -186,7 +186,10 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
186
186
  if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
187
187
  if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
188
188
  const path = new URL(req.url).pathname;
189
- if (path === "/open") return Response.json({ ok: true, instanceId, protocol: PROTOCOL, url: dashboard!.issue() });
189
+ if (path === "/open") {
190
+ const input = await req.json().catch(() => ({})) as { liveTest?: boolean };
191
+ return Response.json({ ok: true, instanceId, protocol: PROTOCOL, url: dashboard!.issue(false, input.liveTest === true), ...(input.liveTest === true ? { liveTest: true } : {}) });
192
+ }
190
193
  if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
191
194
  return new Response("not found", { status: 404 });
192
195
  },
@@ -204,31 +207,32 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
204
207
  }, options.orphanWatchMs ?? 10_000);
205
208
  orphanWatch.unref?.();
206
209
  const handle = { stop, stopped };
207
- active.set(home, { handle, issue: () => dashboard!.issue() });
210
+ active.set(home, { handle, issue: (liveTest) => dashboard!.issue(false, liveTest) });
208
211
  return handle;
209
212
  } catch (error) { cleanup(); throw error; }
210
213
  }
211
214
 
212
- async function managerRequest(home: string, path: "/open" | "/stop"): Promise<{ status: Manifest; body: any }> {
215
+ async function managerRequest(home: string, path: "/open" | "/stop", liveTest = false): Promise<{ status: Manifest; body: any }> {
213
216
  const status = readManifest(home);
214
217
  if (!status) throw new Error("manager manifest is unavailable");
215
218
  if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
216
219
  const token = readFileSync(files(home).token, "utf8").trim();
217
220
  if (!token) throw new Error("manager token is unavailable");
218
221
  const response = await fetch(`http://127.0.0.1:${status.port}${path}`, {
219
- method: "POST", headers: { authorization: `Bearer ${token}`, "x-agenthub-instance": status.instanceId }, signal: AbortSignal.timeout(CONTROL_TIMEOUT),
222
+ method: "POST", headers: { authorization: `Bearer ${token}`, "x-agenthub-instance": status.instanceId }, ...(liveTest ? { body: JSON.stringify({ liveTest: true }) } : {}), signal: AbortSignal.timeout(CONTROL_TIMEOUT),
220
223
  });
221
224
  if (!response.ok) throw new Error(`manager refused request (${response.status}); ownership was not verified`);
222
225
  const body = await response.json() as { ok?: boolean; instanceId?: string; url?: string };
223
226
  if (!body.ok || body.instanceId !== status.instanceId) throw new Error("manager identity changed; refresh before retrying");
227
+ if (liveTest && (body as { liveTest?: boolean }).liveTest !== true) throw new Error("manager cannot restrict delegated dashboard content; use a live-test-capable manager");
224
228
  return { status, body };
225
229
  }
226
230
 
227
231
  export async function openManager(options: ManagerOptions = {}): Promise<string> {
228
232
  const home = options.home ?? hubHome();
229
233
  const local = active.get(home);
230
- if (local) return local.issue();
231
- try { return (await managerRequest(home, "/open")).body.url; }
234
+ if (local) return local.issue(options.liveTest);
235
+ try { return (await managerRequest(home, "/open", options.liveTest)).body.url; }
232
236
  catch (error) {
233
237
  const old = readManifest(home);
234
238
  if (old && processLiveness(old.pid, old.pidSignature) !== "gone") throw error;
@@ -246,7 +250,7 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
246
250
  const deadline = Date.now() + 8000;
247
251
  let error: unknown;
248
252
  while (Date.now() < deadline) {
249
- try { return (await managerRequest(home, "/open")).body.url; }
253
+ try { return (await managerRequest(home, "/open", options.liveTest)).body.url; }
250
254
  catch (caught) { error = caught; await Bun.sleep(100); }
251
255
  }
252
256
  throw new Error(`manager readiness not confirmed: ${safeError(error)}; see ${join(f.dir, "manager.log")}`);
package/src/hub/ui.ts CHANGED
@@ -10,7 +10,7 @@ import { createHash, randomBytes } from "node:crypto";
10
10
  import { readFileSync } from "node:fs";
11
11
 
12
12
  /** What a request's session may do (#269): `settings` while a session opened by `ahub ui --settings` is inside its window. */
13
- export interface DashboardSession { settings: boolean; settingsUntil?: number }
13
+ export interface DashboardSession { settings: boolean; settingsUntil?: number; liveTest?: true }
14
14
  export interface DashboardOptions {
15
15
  snapshot: (after: number, input?: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
16
16
  action: (input: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
@@ -30,8 +30,8 @@ const secret = () => randomBytes(32).toString("hex");
30
30
  /** Separate browser surface. Only the authenticated control console may construct it and issue tickets. */
31
31
  export function startDashboard(options: DashboardOptions) {
32
32
  const now = options.now ?? Date.now;
33
- const tickets = new Map<string, { expires: number; settings: boolean }>();
34
- const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
33
+ const tickets = new Map<string, { expires: number; settings: boolean; liveTest?: true }>();
34
+ const sessions = new Map<string, { expires: number; settingsUntil?: number; liveTest?: true }>();
35
35
  // Inject the same pure public-board model used by the console before computing CSP hashes.
36
36
  const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
37
37
  .replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
@@ -57,7 +57,7 @@ export function startDashboard(options: DashboardOptions) {
57
57
  }
58
58
  const sessionOf = (cookie: string | undefined): DashboardSession => {
59
59
  const until = cookie ? sessions.get(cookie)?.settingsUntil : undefined;
60
- return until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false };
60
+ return { ...(until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false }), ...(cookie && sessions.get(cookie)?.liveTest ? { liveTest: true as const } : {}) };
61
61
  };
62
62
  const server = Bun.serve({
63
63
  hostname: "127.0.0.1",
@@ -99,7 +99,7 @@ export function startDashboard(options: DashboardOptions) {
99
99
  if (!issued || !tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
100
100
  if (sessions.size >= LIMIT) return reject(429, "too many sessions; try later");
101
101
  const session = secret();
102
- sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}) });
102
+ sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}), ...(issued.liveTest ? { liveTest: true } : {}) });
103
103
  return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
104
104
  }
105
105
  if (path === "/projects") {
@@ -131,6 +131,9 @@ export function startDashboard(options: DashboardOptions) {
131
131
  }
132
132
  }
133
133
  try {
134
+ if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
135
+ return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
136
+ }
134
137
  if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
135
138
  else if (input.action === "backlog_split") server.timeout(req, Math.ceil((BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000) / 1000) + 5);
136
139
  return json(await options.action(input, sessionOf(cookie)));
@@ -149,11 +152,11 @@ export function startDashboard(options: DashboardOptions) {
149
152
  return {
150
153
  origin,
151
154
  /** `settings`: the ticket of `ahub ui --settings`, whose session may raise authority for SETTINGS_MS (#269). */
152
- issue(settings = false): string {
155
+ issue(settings = false, liveTest = false): string {
153
156
  prune(tickets);
154
157
  if (tickets.size >= LIMIT) tickets.delete(tickets.keys().next().value!);
155
158
  const ticket = secret();
156
- tickets.set(ticket, { expires: now() + TICKET_MS, settings });
159
+ tickets.set(ticket, { expires: now() + TICKET_MS, settings, ...(liveTest ? { liveTest: true } : {}) });
157
160
  return `${origin}/#${ticket}`;
158
161
  },
159
162
  stop() {