@staix/agent-hub 0.12.21 → 0.12.22
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 +10 -0
- package/README.md +13 -6
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +4 -0
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +2 -0
- package/docs/events.md +13 -4
- package/docs/operations.md +253 -22
- package/docs/quickstart.md +6 -3
- package/docs/security.md +9 -6
- package/docs/smoke.md +61 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +38 -5
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verified.json +21 -15
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/acp.ts +10 -4
- package/src/adapters/codex-appserver.ts +27 -22
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/help.ts +6 -5
- package/src/cli/identity.ts +2 -2
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +137 -18
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +56 -2
- package/src/cli/upgrade.ts +27 -10
- package/src/hub/bus.ts +5 -3
- package/src/hub/daemon.ts +145 -60
- package/src/hub/events.ts +2 -1
- package/src/hub/peers.ts +2 -2
- package/src/hub/recovery-store.ts +38 -0
- package/src/hub/tasks.ts +32 -15
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +25 -6
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +17 -1
- package/src/ui/index.html +15 -11
|
@@ -812,8 +812,15 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
|
|
|
812
812
|
Loaded non-default Kimi modes are reset for project ask.
|
|
813
813
|
- Codex overlays on-request/never on every outgoing turn/start without changing
|
|
814
814
|
sandbox fields. Ask restores a known native policy once after an accepted
|
|
815
|
-
turn; rejected restoration remains pending.
|
|
816
|
-
|
|
815
|
+
turn; rejected restoration remains pending. Restoration debt and captured
|
|
816
|
+
policies are per thread and survive proxy detach and thread/start or thread/resume.
|
|
817
|
+
A known restoring turn retains its baseline even if the TUI echoes the resumed
|
|
818
|
+
response's sticky override, in any hub permission mode. After restoration is
|
|
819
|
+
admitted, a later repeated TUI policy is indistinguishable from an explicit
|
|
820
|
+
native choice and passes through; only the restoring turn is protected.
|
|
821
|
+
Unknown native baseline refuses restoration while attached, with instructions
|
|
822
|
+
to close the TUI and run `ahub permission codex ask`; detached ask succeeds and
|
|
823
|
+
returns a CLI-visible note about the old thread's unchanged native policy. Actual native unattended flags, not broker-wide flags, govern
|
|
817
824
|
the refusal to change a bypassed session.
|
|
818
825
|
- Claude ask-when-needed allows only Read/Edit/Write/MultiEdit/NotebookEdit/LS
|
|
819
826
|
and bounded Glob/Grep targets resolved with realPath beneath project root,
|
|
@@ -823,8 +830,17 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
|
|
|
823
830
|
Merged/generated settings are 0600 state files passed by path, never inline
|
|
824
831
|
in argv. Preview writes no settings file. The owning launcher removes its settings file
|
|
825
832
|
after native exit; earlier active or unknown launchers retain theirs. Verified
|
|
826
|
-
dead wrapper and native identities permit crash fallback cleanup.
|
|
827
|
-
|
|
833
|
+
dead wrapper and native identities permit crash fallback cleanup. Direct binary
|
|
834
|
+
launches publish the native child PID/signature while running, bound to the exact
|
|
835
|
+
launch record; crash cleanup additionally requires its PID absent from a verified
|
|
836
|
+
process table. Script/interpreter native ownership remains unknown. Without
|
|
837
|
+
separate native identity proof, prior files remain for manual inspection. Every
|
|
838
|
+
launch atomically publishes its managed hook/session record without depending
|
|
839
|
+
on the native-proof lock. Exact-binding native proof is stored separately, so
|
|
840
|
+
an old callback cannot replace a later launch record. The populated signed
|
|
841
|
+
native-proof lock waits up to 250 ms for a live owner; an unknown/busy lock
|
|
842
|
+
disables only native identity/crash cleanup with exact-path guidance. Managed
|
|
843
|
+
hooks keep working for the current launch.
|
|
828
844
|
- Permission-only Claude PreToolUse never drives busy state. Only the former
|
|
829
845
|
observation purposes retain native turn bookkeeping. Hook identity/purpose
|
|
830
846
|
are launch/session bound; unverified hook status reads unverified.
|
|
@@ -2075,7 +2091,7 @@ acts on the stopped state directory.
|
|
|
2075
2091
|
## Amendment: proven Pi tool refusals and idle exit recovery (issues #254, #255)
|
|
2076
2092
|
|
|
2077
2093
|
- A trusted guard uses `PreEffectToolRefusal` only when that operation has performed no task or board effect; PII screening and its telemetry record may precede the refusal. Pi receives the same `error: <message>` refusal as the control client, and its session/call receipt is `done`. Initial task ownership/dependency/state checks and conductor entry peer/mode/assignment/escalation validation opt into the type; shared helpers default to ordinary errors, and conductor checks after an awaited release validation stay ordinary. Generic errors, board readback failures after a write, and receipt settlement write failures remain uncertain/pending and are never repeated. A legacy pending row has no pre-effect proof in its stored fingerprint/state/result, so startup preserves it; neither present board state nor a matching error message establishes historical refusal.
|
|
2078
|
-
- Each Pi owner reports one exit with cause, exit code or signal, whether startup completed, whether a turn/tool was active, whether teardown was expected, and the last validated tool name only. TUI shutdown/owner-loss and verified stop teardown lack observed OS status and record unknown. A successfully stopped claimed TUI owner reports once; a never-owned launch reports no invented exit. Internal teardown notices direct inspection and `ahub pi`, without claiming a person requested them. Console notices name the next action or automatic-start state; headless code 0 is still an exit and settles active deliveries as uncertain.
|
|
2094
|
+
- Each Pi owner reports one exit with cause, exit code or signal, whether startup completed, whether a turn/tool was active, whether teardown was expected, and the last validated tool name only. TUI shutdown/owner-loss and verified stop teardown lack observed OS status and record unknown. A successfully stopped claimed TUI owner reports once; a never-owned launch reports no invented exit. Replacement teardown says the owner stopped for a new Pi owner even if replacement startup is refused. Internal teardown notices direct inspection and `ahub pi`, without claiming a person requested them. Replacement, failed-start and active-turn guidance takes precedence over the automatic-start-disabled notice. Console notices name the next action or automatic-start state; headless code 0 is still an exit and settles active deliveries as uncertain.
|
|
2079
2095
|
- With `pi.auto_start`, an unexpected idle headless exit attempts its verified persisted session once in a 60 s window. The daemon owns the attempt/window across adapter replacements, blocks throughout an in-flight attempt, and rearms the 60 s window when that attempt settles. It checks current-owner identity, completed startup, turn/tool inactivity, start serialization, shutdown and recovery before and after awaited receipt drain/session capture. Missing or invalid persisted state leaves Pi offline with `ahub pi` guidance, with no fresh-session fallback. A second exit inside the window stays offline. Requested stops, stale owners, startup failures, active turns/tools and native terminal exits never trigger this automatic path; crash recovery's existing #66 behavior remains separate.
|
|
2080
2096
|
## Amendment: benchmarks (issue #251)
|
|
2081
2097
|
|
|
@@ -2104,3 +2120,20 @@ acts on the stopped state directory.
|
|
|
2104
2120
|
settings for the rest.
|
|
2105
2121
|
- The dashboard snapshot carries run summaries and per-arm measures only. `bench run` is a person's (identity gate,
|
|
2106
2122
|
wherever `--json` stands); reading results is not. Schema and commands: [bench](../bench.md).
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
## Amendment: Pi/local approval expiry and cancellation (issue #253)
|
|
2126
|
+
|
|
2127
|
+
- An unanswered Pi/local approval returns an explicit expiry result: no person answered, do not retry that call, hand off or stop. It is not an answered denial. Two consecutive expiry results in the same native Pi turn or local worker turn end that turn with a console notice. An expiry counts toward the streak only when no person answered a request of that peer while it was pending (a per-peer answer epoch makes a parallel batch safe for an answering person); a grant served from the always cache or a permission mode is not an answer and does not reset the streak, and an actual new turn resets it. The turn ends through the same abort path an execution-budget stop uses, so Pi stays attached and the sender is told the approval reason. Supported Pi and local complete that correlated delivery after the reason reply, allowing the next queued message without an operator release. Pi clears the turn-only approval state at settlement and failure, so idle human shells remain usable and later failures cannot inherit its reason; a delayed approval abort rechecks that the same turn is still running. An extension that cannot abort a turn (one that does not advertise `approvalTurnAbort`) leaves Pi offline instead, with `ahub pi` restart and held-delivery settlement guidance. The second result remains an expiry tool failure before the abort. Approval stops do not invoke automatic safe replay or inference handoff.
|
|
2128
|
+
- Tool cancellation reaches the central approval registry. Settlement atomically removes the request and timer/abort listener before publishing closure or resolving. Late answers fail, cannot populate Pi's exact-tool always cache, and do not hold recovery waiting for approvals. Tool and budget continuations retain their captured session/generation; an admission response rechecks the owner and controller before changing a timer or recording a stop, and an old extension callback never aborts a later turn. Normal tool requests carry the session and generation captured before budget admission; the bridge refuses missing/stale lineage before registry, receipt or effect work, with restart guidance for an older extension. Spawned headless owners are pinned by the actual child process signature and its proven RPC session; TUI owners require the native claim. A Pi native abort is scoped to the verified owner, session, turn generation and call id; it withdraws only that call. Early native aborts fence a bounded set of call ids, with overflow failing closed for the remainder of that running turn. A managed tool that does not settle within the stop grace does not block the teardown: the stop goes on (shutdown, owner teardown, process stop) and reports the failure at the end.
|
|
2129
|
+
- A person's TUI shell command uses the existing authenticated idle reservation, session/owner/generation validation, sandbox and run-budget admission. It bypasses hub approval only in that trusted context and returns the sandbox's structured exit code through the receipt and native extension. Model tool arguments or printed output cannot establish human origin or exit status.
|
|
2130
|
+
- The dashboard counts waiting Pi/local approvals and directs the person to the console or `ahub tail` / `ahub permit`. It keeps those rows deny-only and never reveals private titles.
|
|
2131
|
+
|
|
2132
|
+
## Amendment: requested headless peer stop (issue #278)
|
|
2133
|
+
|
|
2134
|
+
- A console-role `{ t: "peer_stop", peer }` request stops a hub-owned headless ACP, Pi or local runtime through its adapter's owned-runtime teardown. Success is `{ ok: true, state: "offline" }`; refusal or incomplete stop is `{ ok: false, error }` naming the reason and next action. New requests already receive an unknown-request reply on older hubs, so `PROTOCOL` stays unchanged.
|
|
2135
|
+
- Refuse agent/tools-role callers, unknown or offline peers, Claude/Codex and Pi TUI owners, and recovery/shutdown-held mutations. Starts, requested stops and permission changes are fenced per peer; recovery preparation waits until requested stops finish. A TUI is ended in its terminal, or by the upgrade plan's verified terminal binding.
|
|
2136
|
+
- A requested stop marks each in-flight delivery `needs_review` with a fixed ids-only stop reason, cancels pending approvals, and fences stale native completion/failure callbacks. It never completes or implicitly redelivers interrupted work, invokes failure escalation, or triggers Pi's idle auto-restart. The operator inspects effects and explicitly resolves the delivery before replay. Queued unattempted work remains for the next manual start under existing holds/pauses.
|
|
2137
|
+
- `ahub stop <peer>` is human-only in `classifyPeerCommand`, prints the returned state, and reports an older hub's unknown request as requiring an upgrade, with a nonzero exit. The upgrade plan's headless `endPlannedPeer` uses the same console request; the daemon does not close a native terminal.
|
|
2138
|
+
|
|
2139
|
+
- Requested stop admission also holds per-peer drain, pull and steering before any teardown await, without changing operator/budget pauses. Arriving work remains queued for explicit restart. Idle Pi captures live empty-session proof before stopping; a first accepted prompt with unpersisted history is not silently recreated as empty.
|
|
@@ -17,16 +17,24 @@ their matching CLI; the new coordinator never guesses a PID to terminate.
|
|
|
17
17
|
|
|
18
18
|
## Commands
|
|
19
19
|
|
|
20
|
-
- `ahub upgrade --to <exact-version> --dry-run`: inspect the running registered
|
|
21
|
-
projects, release identity, native session mappings and blockers.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
- `ahub upgrade [--to <exact-version>] --dry-run`: inspect the running registered
|
|
21
|
+
projects, release identity, native session mappings and blockers. Without
|
|
22
|
+
`--to` the target is the registry's latest release (#272).
|
|
23
|
+
- `ahub upgrade [--to <exact-version>] [--yes]`: review the plan, revalidate it and
|
|
24
|
+
schedule an independent recovery process. `--yes` accepts the displayed scope
|
|
25
|
+
and needs `--to`; it never overrides blockers. On a terminal without either
|
|
26
|
+
flag the review, the apply and the progress are one interactive session (see
|
|
27
|
+
"Interactive screens").
|
|
28
|
+
- A target newer than the running CLI is handed to that release's own
|
|
29
|
+
coordinator (`bun x --package @staix/agent-hub@<version> ahub upgrade --to
|
|
30
|
+
<version>` with the same flags), named on stderr first and confirmed on a
|
|
31
|
+
terminal (#272).
|
|
25
32
|
- `ahub --project <path|id> restart [--dry-run] [--yes]`: recover one project using
|
|
26
33
|
the current package, without changing the global installation or plugin.
|
|
27
|
-
- `ahub recovery status|resume <operation-id
|
|
28
|
-
the recorded operation after checking live ownership.
|
|
29
|
-
|
|
34
|
+
- `ahub recovery status|resume [<operation-id>]`: read redacted progress or resume
|
|
35
|
+
the recorded operation after checking live ownership. Without an id every
|
|
36
|
+
`recovery` command takes the operation that holds the machine's lock (#272).
|
|
37
|
+
- `ahub recovery abort [<operation-id>]`: cancel a preflight and release its lock
|
|
30
38
|
only before a runtime has stopped or a terminal mutation has been attempted.
|
|
31
39
|
|
|
32
40
|
Upgrade uses all affected running registrations. Stopped projects stay stopped.
|
|
@@ -508,3 +516,71 @@ lose. Detaching forgets nothing; a thread whose start the log does not show
|
|
|
508
516
|
(resumed, an older hub, a pruned log) is unsure and stays a plan blocker, as
|
|
509
517
|
does one with turns. A planned fresh start still needs the store to show the
|
|
510
518
|
rollout missing: an unreadable store blocks the close and the create.
|
|
519
|
+
|
|
520
|
+
### Interactive screens (#272)
|
|
521
|
+
|
|
522
|
+
`ahub upgrade`, `ahub restart` and bare `ahub recovery` on a terminal are a
|
|
523
|
+
front end to the commands above, never a second coordinator.
|
|
524
|
+
|
|
525
|
+
- The screens (`src/cli/upgrade-interactive.ts`) read the plan, the receipt, the
|
|
526
|
+
runner claim and the live inspections, and change an operation only by running
|
|
527
|
+
`recovery resume|abort|dispose` or `reset` as a child process, with the
|
|
528
|
+
operation's own coordinator (`recoveryArgv`, the argv form of
|
|
529
|
+
`recoveryCommand`). Each command decides its own refusal.
|
|
530
|
+
- The operation screen's choices are `nextChoices`, the structured form of the
|
|
531
|
+
`next` list: `nextActions` is its text, and the invariant test that holds
|
|
532
|
+
`next` to what the commands accept also holds the two together.
|
|
533
|
+
- One choice is not in `next`: cancelling while a runner only waits for its
|
|
534
|
+
source (`cancellableWait`: phase `running`, step `prepare:<project>`, no
|
|
535
|
+
effect receipt, no commit request). The screen stops only a runner that
|
|
536
|
+
`signedRunner` names: a claim whose recorded process signature still matches.
|
|
537
|
+
`recoveryRunner` answers an unsigned claim (a coordinator of 0.12.20 or older)
|
|
538
|
+
from its bare pid, which is enough to wait for and never enough to signal, so
|
|
539
|
+
cancel is not offered for it. The receipt is read again at the key press, and
|
|
540
|
+
`recovery abort` then reads what is live once more. A runner killed after it
|
|
541
|
+
recorded an effect leaves an operation that abort refuses and resume
|
|
542
|
+
continues, as after any runner crash.
|
|
543
|
+
- The plan screen can end attached agents before the operation exists (user
|
|
544
|
+
request, 2026-10-10: choose to end headless or TUI agents during an upgrade).
|
|
545
|
+
A TUI agent is ended by closing the terminal the plan bound to its session; a
|
|
546
|
+
headless agent by the console-role request `peer_stop` to its hub (#278),
|
|
547
|
+
which an older hub answers as unknown. The plan is not acted on as it was
|
|
548
|
+
drawn: `endPlannedPeer` reads again the lock, the hub instance and the peer's
|
|
549
|
+
attachment, and for a TUI agent the attached session and the terminal's
|
|
550
|
+
handle and incarnation (`inspectTerminals` for that one session); any
|
|
551
|
+
difference ends nothing. `closeTerminal` itself compares only the terminal's
|
|
552
|
+
handle, incarnation, worktree and root. An ended agent is offline in the next
|
|
553
|
+
plan, so it is neither waited for nor restored. Nothing is ended once an
|
|
554
|
+
operation holds the lock: the coordinator's roster check reads a planned peer
|
|
555
|
+
that left as a changed source. With several projects in the plan an agent is
|
|
556
|
+
named `<project id>/<peer id>`, and a bare name attached in more than one
|
|
557
|
+
selects none. A Ctrl+C between two agents leaves the rest alone.
|
|
558
|
+
- A Ctrl+C while `apply` checks the plan again creates no operation.
|
|
559
|
+
- Ctrl+C leaves the screens and stops nothing; the terminal is not read again
|
|
560
|
+
after it, because a parent process that died on the same signal (`bun x`, a
|
|
561
|
+
hand-over) may have returned the terminal to the shell. While a progress view
|
|
562
|
+
runs, a line (Enter) opens the operation screen instead. A prompt answers an
|
|
563
|
+
interrupt or the end of input with no value, never with text.
|
|
564
|
+
- A follow that starts right after a command scheduled a runner (resume, a
|
|
565
|
+
fresh session) waits for that runner's first write, up to 5 seconds: until
|
|
566
|
+
then the receipt still shows the blocked state it was scheduled from. It
|
|
567
|
+
starts from the receipt as the command left it (a fresh-session choice writes
|
|
568
|
+
the receipt itself before it schedules the runner).
|
|
569
|
+
- The daemon's `recovery inspect` lists every condition `recoveryReady()` waits
|
|
570
|
+
for in `blockers` (task commands, completion checks, Pi calls and a release
|
|
571
|
+
in flight, an unsettled Pi, besides busy peers, approvals, startup and a
|
|
572
|
+
budget transition). The roster identity comparison is not listed. A hub that
|
|
573
|
+
is not ready and names no cause is shown as such.
|
|
574
|
+
- The progress view prints the receipt's steps and, during `prepare`, the
|
|
575
|
+
source's own readiness blockers. `inspectRecovery` copies them into
|
|
576
|
+
`Inspection.recovery.waiting` for display only; `planFingerprint` leaves
|
|
577
|
+
`recovery` out, so they never change a plan's identity.
|
|
578
|
+
- Without `--to` the target is `npm view @staix/agent-hub version`. `--yes`
|
|
579
|
+
keeps requiring `--to`, so an unattended apply names its release. `--to` is
|
|
580
|
+
checked as an exact version before it reaches the registry or a package spec.
|
|
581
|
+
- The dry-run plan gains `projects[].source.recovery.waiting` when the hub
|
|
582
|
+
reports something in progress; nothing else in the non-interactive output
|
|
583
|
+
changes.
|
|
584
|
+
- Out of scope: a hub-side interrupt of a running turn (the running source may
|
|
585
|
+
be older and would not know the request), and answering approvals from the
|
|
586
|
+
screens.
|
package/docs/verified.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"documents": {
|
|
4
4
|
"README.md": {
|
|
5
|
-
"verifiedAgainst": "
|
|
5
|
+
"verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
|
|
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": "
|
|
14
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
15
15
|
"paths": [
|
|
16
16
|
"src/",
|
|
17
17
|
"templates/"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"docs/operations.md": {
|
|
21
|
-
"verifiedAgainst": "
|
|
21
|
+
"verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
|
|
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": "
|
|
38
|
+
"verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
|
|
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": "
|
|
46
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
47
47
|
"paths": [
|
|
48
48
|
"src/adapters/",
|
|
49
49
|
"src/pi/",
|
|
@@ -55,11 +55,12 @@
|
|
|
55
55
|
"src/hub/daemon.ts",
|
|
56
56
|
"src/hub/permission-mode.ts",
|
|
57
57
|
"src/hub/tool-refusal.ts",
|
|
58
|
-
"src/cli/identity.ts"
|
|
58
|
+
"src/cli/identity.ts",
|
|
59
|
+
"src/local/tools.ts"
|
|
59
60
|
]
|
|
60
61
|
},
|
|
61
62
|
"docs/agent-notes/benchmarks.md": {
|
|
62
|
-
"verifiedAgainst": "
|
|
63
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
63
64
|
"paths": [
|
|
64
65
|
"scripts/benchmarks/",
|
|
65
66
|
"test/benchmarks/",
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
]
|
|
72
73
|
},
|
|
73
74
|
"docs/agent-notes/budget.md": {
|
|
74
|
-
"verifiedAgainst": "
|
|
75
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
75
76
|
"paths": [
|
|
76
77
|
"src/hub/budget.ts",
|
|
77
78
|
"src/cli/statusline-tee.ts",
|
|
@@ -83,7 +84,7 @@
|
|
|
83
84
|
]
|
|
84
85
|
},
|
|
85
86
|
"docs/agent-notes/bus.md": {
|
|
86
|
-
"verifiedAgainst": "
|
|
87
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
87
88
|
"paths": [
|
|
88
89
|
"src/hub/bus.ts",
|
|
89
90
|
"src/hub/envelope.ts",
|
|
@@ -95,7 +96,7 @@
|
|
|
95
96
|
]
|
|
96
97
|
},
|
|
97
98
|
"docs/agent-notes/daemon.md": {
|
|
98
|
-
"verifiedAgainst": "
|
|
99
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
99
100
|
"paths": [
|
|
100
101
|
"src/hub/daemon.ts",
|
|
101
102
|
"src/hub/control-client.ts",
|
|
@@ -118,11 +119,15 @@
|
|
|
118
119
|
"src/cli/console-state.ts",
|
|
119
120
|
"src/cli/identity.ts",
|
|
120
121
|
"src/cli/identity-audit.ts",
|
|
121
|
-
"src/hub/registry.ts"
|
|
122
|
+
"src/hub/registry.ts",
|
|
123
|
+
"src/cli/main.ts",
|
|
124
|
+
"src/cli/launch.ts",
|
|
125
|
+
"src/hub/child-process.ts",
|
|
126
|
+
"src/hub/bus.ts"
|
|
122
127
|
]
|
|
123
128
|
},
|
|
124
129
|
"docs/agent-notes/local-worker.md": {
|
|
125
|
-
"verifiedAgainst": "
|
|
130
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
126
131
|
"paths": [
|
|
127
132
|
"src/adapters/local-worker.ts",
|
|
128
133
|
"src/local/",
|
|
@@ -141,7 +146,7 @@
|
|
|
141
146
|
]
|
|
142
147
|
},
|
|
143
148
|
"docs/agent-notes/tasks.md": {
|
|
144
|
-
"verifiedAgainst": "
|
|
149
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
145
150
|
"paths": [
|
|
146
151
|
"src/hub/tasks.ts",
|
|
147
152
|
"src/hub/board.ts",
|
|
@@ -158,7 +163,7 @@
|
|
|
158
163
|
]
|
|
159
164
|
},
|
|
160
165
|
"docs/agent-notes/tests.md": {
|
|
161
|
-
"verifiedAgainst": "
|
|
166
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
162
167
|
"paths": [
|
|
163
168
|
"test/",
|
|
164
169
|
"scripts/check.sh",
|
|
@@ -172,7 +177,8 @@
|
|
|
172
177
|
"src/hub/child-process.ts",
|
|
173
178
|
"src/switchyard/sidecar.ts",
|
|
174
179
|
"src/local/sandbox.ts",
|
|
175
|
-
"src/models/mlx.ts"
|
|
180
|
+
"src/models/mlx.ts",
|
|
181
|
+
"src/cli/upgrade-interactive.ts"
|
|
176
182
|
]
|
|
177
183
|
}
|
|
178
184
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.22",
|
|
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",
|
|
@@ -15404,7 +15404,7 @@ class ControlClient {
|
|
|
15404
15404
|
// package.json
|
|
15405
15405
|
var package_default = {
|
|
15406
15406
|
name: "@staix/agent-hub",
|
|
15407
|
-
version: "0.12.
|
|
15407
|
+
version: "0.12.22",
|
|
15408
15408
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15409
15409
|
license: "MIT",
|
|
15410
15410
|
type: "module",
|
package/src/adapters/acp.ts
CHANGED
|
@@ -128,6 +128,7 @@ export class AcpPeer extends BasePeer {
|
|
|
128
128
|
private readonly toolTitles = new Map<string, string>(); // the title a call was announced with, per call id (#138)
|
|
129
129
|
private primed = false;
|
|
130
130
|
private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
|
|
131
|
+
private stopping = false;
|
|
131
132
|
private activeDeliveryId: string | undefined;
|
|
132
133
|
private deliveryAccepted = false;
|
|
133
134
|
|
|
@@ -144,6 +145,7 @@ export class AcpPeer extends BasePeer {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
async start(): Promise<void> {
|
|
148
|
+
this.stopping = false;
|
|
147
149
|
const [bin, ...args] = this.opts.cmd;
|
|
148
150
|
// Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
|
|
149
151
|
const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
@@ -222,18 +224,21 @@ export class AcpPeer extends BasePeer {
|
|
|
222
224
|
this.permissionMode = mode;
|
|
223
225
|
}
|
|
224
226
|
|
|
225
|
-
async stop(): Promise<void> {
|
|
226
|
-
|
|
227
|
+
async stop(reason?: string): Promise<void> {
|
|
228
|
+
this.stopping = true;
|
|
229
|
+
if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
|
|
230
|
+
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "ACP session stopped before settlement" });
|
|
227
231
|
this.activeDeliveryId = undefined;
|
|
228
232
|
const proc = this.proc;
|
|
229
|
-
if (!proc) return;
|
|
233
|
+
if (!proc) { this.setState("offline"); return; }
|
|
230
234
|
await stopOwnedProcess(proc, { group: true }); // also when it exited: what it left in its group fails the stop
|
|
231
235
|
if (this.proc === proc) this.proc = undefined;
|
|
236
|
+
this.setState("offline");
|
|
232
237
|
}
|
|
233
238
|
|
|
234
239
|
/** Resolves once the prompt is in flight; the turn result arrives on its own. */
|
|
235
240
|
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
236
|
-
if (this.state !== "idle") {
|
|
241
|
+
if (this.state !== "idle" || this.stopping) {
|
|
237
242
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
|
|
238
243
|
throw new Error(`${this.id} is ${this.state}`);
|
|
239
244
|
}
|
|
@@ -313,6 +318,7 @@ export class AcpPeer extends BasePeer {
|
|
|
313
318
|
}
|
|
314
319
|
|
|
315
320
|
private onLine(line: string): void {
|
|
321
|
+
if (this.stopping) return; // buffered native callbacks cannot recreate approvals after peer_stop
|
|
316
322
|
let msg: any;
|
|
317
323
|
try {
|
|
318
324
|
msg = JSON.parse(line);
|
|
@@ -63,9 +63,9 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
|
|
|
63
63
|
*/
|
|
64
64
|
export class CodexPeer extends BasePeer {
|
|
65
65
|
private permissionMode: PermissionMode;
|
|
66
|
-
private
|
|
67
|
-
private
|
|
68
|
-
private readonly restoringApprovals = new
|
|
66
|
+
private readonly nativeApprovalPolicies = new Map<string, unknown>();
|
|
67
|
+
private readonly overriddenApprovals = new Set<string>();
|
|
68
|
+
private readonly restoringApprovals = new Map<string | number, string>();
|
|
69
69
|
private proc: ChildProcess | undefined;
|
|
70
70
|
private server: Server<Link> | undefined;
|
|
71
71
|
private link: Link | undefined; // the connection that owns the current thread
|
|
@@ -191,7 +191,6 @@ export class CodexPeer extends BasePeer {
|
|
|
191
191
|
this.pending.set(id, {
|
|
192
192
|
deliveryId, kind: "deliver",
|
|
193
193
|
resolve: (result) => {
|
|
194
|
-
if (this.restoringApprovals.delete(id) && this.permissionMode === "ask") this.approvalOverridden = false;
|
|
195
194
|
this.primed = true;
|
|
196
195
|
this.injected = replyParent(envs);
|
|
197
196
|
this.answering = [...envs];
|
|
@@ -388,44 +387,50 @@ export class CodexPeer extends BasePeer {
|
|
|
388
387
|
|
|
389
388
|
/** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
|
|
390
389
|
setStartupPermissionMode(mode: PermissionMode): void {
|
|
391
|
-
if (this.link || this.state !== "offline" || this.
|
|
390
|
+
if (this.link || this.state !== "offline" || this.overriddenApprovals.size) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
|
|
392
391
|
this.permissionMode = mode;
|
|
393
392
|
}
|
|
394
393
|
|
|
395
|
-
/**
|
|
396
|
-
* The hub's choice was cleared while no TUI is attached: this proxy sends no override from now on. Returns false
|
|
397
|
-
* when an override it already sent cannot be undone on that thread (its native policy was never reported): a thread
|
|
398
|
-
* started or resumed through the proxy afterwards reports its own policy, and that one is left alone.
|
|
399
|
-
*/
|
|
394
|
+
/** Clear the overlay while detached, retaining each thread's restoration debt across a resume. */
|
|
400
395
|
clearPermissionMode(): boolean {
|
|
401
|
-
const restorable =
|
|
396
|
+
const restorable = [...this.overriddenApprovals].every(thread => this.nativeApprovalPolicies.has(thread));
|
|
402
397
|
this.permissionMode = "ask";
|
|
403
398
|
return restorable;
|
|
404
399
|
}
|
|
405
400
|
|
|
406
401
|
async setPermissionMode(mode: PermissionMode): Promise<void> {
|
|
407
402
|
if (!this.link || this.link.up.readyState !== WebSocket.OPEN) throw new Error("codex permission mode unavailable: attach the TUI through ahub codex first");
|
|
408
|
-
if (mode === "ask" && this.
|
|
403
|
+
if (mode === "ask" && [...this.overriddenApprovals].some(thread => !this.nativeApprovalPolicies.has(thread))) throw new Error("codex cannot restore ask: native approval policy unavailable; close the TUI, then run ahub permission codex ask before you restart the Codex session with ahub codex");
|
|
409
404
|
this.permissionMode = mode;
|
|
410
405
|
}
|
|
411
406
|
|
|
412
407
|
private applyPermissionPolicy(params: Record<string, unknown>, id: string | number): Record<string, unknown> {
|
|
408
|
+
const thread = typeof params.threadId === "string" ? params.threadId : this.threadId;
|
|
413
409
|
if (this.permissionMode !== "ask") {
|
|
414
|
-
this.
|
|
410
|
+
this.overriddenApprovals.add(thread);
|
|
415
411
|
return { ...params, approvalPolicy: this.permissionMode === "never-ask" ? "never" : "on-request" };
|
|
416
412
|
}
|
|
417
|
-
if (this.
|
|
418
|
-
this.restoringApprovals.
|
|
419
|
-
return { ...params, approvalPolicy: this.
|
|
413
|
+
if (this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread)) {
|
|
414
|
+
this.restoringApprovals.set(id, thread);
|
|
415
|
+
return { ...params, approvalPolicy: this.nativeApprovalPolicies.get(thread) };
|
|
420
416
|
}
|
|
421
417
|
return params;
|
|
422
418
|
}
|
|
423
419
|
|
|
420
|
+
private settlePermissionRestore(id: string | number, failed: boolean): void {
|
|
421
|
+
const thread = this.restoringApprovals.get(id);
|
|
422
|
+
this.restoringApprovals.delete(id);
|
|
423
|
+
if (thread !== undefined && !failed && this.permissionMode === "ask") this.overriddenApprovals.delete(thread);
|
|
424
|
+
}
|
|
425
|
+
|
|
424
426
|
private fromTui(link: Link, raw: string): void {
|
|
425
427
|
const msg = parse(raw);
|
|
426
428
|
if (msg?.method === "turn/start" && msg.params && link === this.link) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
+
const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
|
|
430
|
+
// A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
|
|
431
|
+
const restoring = this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread);
|
|
432
|
+
if (msg.params.approvalPolicy != null && !restoring) this.nativeApprovalPolicies.set(thread, msg.params.approvalPolicy);
|
|
433
|
+
if (this.permissionMode !== "ask" || this.overriddenApprovals.has(thread)) {
|
|
429
434
|
// Native turn overrides persist: ask restores the captured native policy once on successful admission.
|
|
430
435
|
msg.params = this.applyPermissionPolicy(msg.params, msg.id);
|
|
431
436
|
raw = JSON.stringify(msg);
|
|
@@ -441,6 +446,7 @@ export class CodexPeer extends BasePeer {
|
|
|
441
446
|
if (this.state === "busy") this.touch();
|
|
442
447
|
if (!msg) return void link.tui.send(raw);
|
|
443
448
|
|
|
449
|
+
if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
|
|
444
450
|
if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
|
|
445
451
|
const p = this.pending.get(msg.id);
|
|
446
452
|
this.pending.delete(msg.id);
|
|
@@ -448,14 +454,13 @@ export class CodexPeer extends BasePeer {
|
|
|
448
454
|
else p?.resolve(msg.result);
|
|
449
455
|
return; // ours: the TUI never asked for it
|
|
450
456
|
}
|
|
451
|
-
if (msg.id !== undefined && !msg.method && this.restoringApprovals.delete(msg.id) && !msg.error && this.permissionMode === "ask") this.approvalOverridden = false;
|
|
452
457
|
if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
|
|
453
458
|
const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
|
|
454
459
|
if (tracked && link.tracked.delete(msg.id)) {
|
|
455
460
|
if (typeof msg.result?.thread?.id === "string") {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
this.
|
|
461
|
+
// A resumed response may report the sticky hub override, not the original native choice.
|
|
462
|
+
const thread = msg.result.thread.id;
|
|
463
|
+
if (!this.overriddenApprovals.has(thread) && msg.result.approvalPolicy != null) this.nativeApprovalPolicies.set(thread, msg.result.approvalPolicy);
|
|
459
464
|
this.adopt(link, msg.result.thread.id, tracked === "thread/start");
|
|
460
465
|
}
|
|
461
466
|
}
|
|
@@ -8,7 +8,7 @@ import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, t
|
|
|
8
8
|
import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
|
|
9
9
|
import { BasePeer } from "../hub/peers.ts";
|
|
10
10
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
11
|
-
import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
|
|
11
|
+
import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
|
|
12
12
|
import type { Capture } from "../memory/capture.ts";
|
|
13
13
|
import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/client.ts";
|
|
14
14
|
import { safeModelLabel } from "../omniroute/usage.ts";
|
|
@@ -32,6 +32,7 @@ export interface LocalOptions {
|
|
|
32
32
|
onRouteOutcome?: (event: RouteLabelEvent) => void;
|
|
33
33
|
/** The daemon turn id created synchronously on busy, shared with turn_start/end. */
|
|
34
34
|
turnId?: () => string | undefined;
|
|
35
|
+
onApprovalStop?: (reason: string) => void;
|
|
35
36
|
onTool?: (observation: ToolObservation, task?: string) => void;
|
|
36
37
|
tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[] };
|
|
37
38
|
capture?: Capture;
|
|
@@ -85,6 +86,9 @@ export class LocalPeer extends BasePeer {
|
|
|
85
86
|
private abort: AbortController | undefined;
|
|
86
87
|
private budgetTimer?: ReturnType<typeof setTimeout>;
|
|
87
88
|
private budgetStopReason = "";
|
|
89
|
+
private approvalExpiries = 0;
|
|
90
|
+
private approvalAnswerEpoch: number | undefined;
|
|
91
|
+
private approvalStopReason = "";
|
|
88
92
|
private activeDeliveryId: string | undefined;
|
|
89
93
|
private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
|
|
90
94
|
/** What served the last call, for `ahub status`. */
|
|
@@ -127,15 +131,15 @@ export class LocalPeer extends BasePeer {
|
|
|
127
131
|
this.setState("idle");
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
async stop(): Promise<void> {
|
|
134
|
+
async stop(reason?: string): Promise<void> {
|
|
131
135
|
this.endRouteTurn(this.labelTurnId, "failed");
|
|
132
|
-
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn stopped before settlement" });
|
|
136
|
+
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "turn stopped before settlement" });
|
|
133
137
|
this.activeDeliveryId = undefined;
|
|
134
138
|
this.turn++;
|
|
135
139
|
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
136
140
|
this.abort?.abort();
|
|
141
|
+
this.setState("offline"); // fence direct delivery before the bounded memory request awaits
|
|
137
142
|
await this.opts.capture?.end();
|
|
138
|
-
this.setState("offline");
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/** Resolves once the turn is claimed; a turn that cannot reach any model hands the envelopes back through onFailed. */
|
|
@@ -146,6 +150,7 @@ export class LocalPeer extends BasePeer {
|
|
|
146
150
|
}
|
|
147
151
|
const turn = ++this.turn;
|
|
148
152
|
this.budgetStopReason = "";
|
|
153
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
|
|
149
154
|
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
150
155
|
this.activeDeliveryId = deliveryId;
|
|
151
156
|
this.abort = new AbortController();
|
|
@@ -172,6 +177,14 @@ export class LocalPeer extends BasePeer {
|
|
|
172
177
|
})
|
|
173
178
|
.catch((e: Error) => {
|
|
174
179
|
if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
|
|
180
|
+
if (e instanceof ApprovalWaitStop) {
|
|
181
|
+
this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
|
|
182
|
+
msgs.push({ role: "assistant", content: e.message });
|
|
183
|
+
if (!policy?.pii) this.commit(msgs);
|
|
184
|
+
this.onMessage?.(e.message, reply);
|
|
185
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: e.message });
|
|
186
|
+
return; // even without effects, an unanswered approval must not trigger safe replay
|
|
187
|
+
}
|
|
175
188
|
if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
|
|
176
189
|
this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
|
|
177
190
|
if (e instanceof ExecutionBudgetStop) {
|
|
@@ -263,6 +276,7 @@ export class LocalPeer extends BasePeer {
|
|
|
263
276
|
if (SIDE_EFFECTS.has(call.function.name) && !output.startsWith("error:")) progress.sideEffects++;
|
|
264
277
|
const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
|
|
265
278
|
msgs.push(result); results.push(result);
|
|
279
|
+
if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
|
|
266
280
|
this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
267
281
|
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
268
282
|
if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
@@ -277,14 +291,34 @@ export class LocalPeer extends BasePeer {
|
|
|
277
291
|
return {
|
|
278
292
|
cwd: this.opts.cwd,
|
|
279
293
|
deny: this.opts.tools.deny,
|
|
280
|
-
permit: async (title, tool, _signal,
|
|
281
|
-
if (turnSignal.aborted) return
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
294
|
+
permit: async (title, tool, _signal, canonicalTarget) => {
|
|
295
|
+
if (turnSignal.aborted) return "aborted";
|
|
296
|
+
const generation = this.turn;
|
|
297
|
+
let observed = false;
|
|
298
|
+
const observe = (provenance: ApprovalProvenance) => {
|
|
299
|
+
observed = true;
|
|
300
|
+
if (generation !== this.turn || turnSignal.aborted || this.approvalStopReason) return;
|
|
301
|
+
if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
|
|
302
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
|
|
303
|
+
}
|
|
304
|
+
if (provenance.source === "person") this.approvalExpiries = 0;
|
|
305
|
+
if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
|
|
306
|
+
this.approvalStopReason = "Local turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
|
|
307
|
+
try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const picked = await new Promise<ToolApproval>((resolve, reject) => {
|
|
311
|
+
const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
|
|
312
|
+
const onAbort = () => finish("aborted");
|
|
285
313
|
turnSignal.addEventListener("abort", onAbort, { once: true });
|
|
286
|
-
this.opts.tools.permit(title, tool, turnSignal,
|
|
314
|
+
this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
287
315
|
});
|
|
316
|
+
if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
|
|
317
|
+
// Legacy permit callbacks can represent a person's denial without provenance. A true
|
|
318
|
+
// result may be automatic, so it must never reset the unanswered-request streak.
|
|
319
|
+
if (!observed && picked === false) observe({ source: "person", answerEpoch: (this.approvalAnswerEpoch ?? 0) + 1 });
|
|
320
|
+
if (!observed && picked === "expired") observe({ source: "expired", answerEpoch: this.approvalAnswerEpoch ?? 0, eligibleExpiry: true });
|
|
321
|
+
return picked;
|
|
288
322
|
},
|
|
289
323
|
sandboxProfile: this.sandboxProfile,
|
|
290
324
|
sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },
|