@staix/agent-hub 0.7.7 → 0.7.8
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 +13 -1
- package/README.md +5 -4
- package/docs/operations.md +41 -13
- package/docs/quickstart.md +1 -1
- package/docs/security.md +2 -1
- package/docs/smoke.md +52 -11
- package/docs/specs/2026-09-19-agent-hub-design.md +68 -3
- package/docs/specs/2026-09-20-durable-delivery-design.md +1 -1
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -1
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2 -2
- package/src/adapters/acp.ts +13 -1
- package/src/adapters/codex-appserver.ts +19 -4
- package/src/hub/bus.ts +9 -1
- package/src/hub/checks.ts +91 -0
- package/src/hub/daemon.ts +119 -8
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/tasks.ts +122 -9
- package/templates/config.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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
|
+
|
|
3
5
|
## Unreleased
|
|
4
6
|
|
|
7
|
+
## 0.7.8
|
|
8
|
+
|
|
9
|
+
- README, the operations guide and the upgrade-recovery spec no longer describe protocol 10 and the queue commands as unreleased (#9).
|
|
10
|
+
- Codex answers a digest to every sender in it, and a sender steered into its turn, like the other adapters; an answer to a condensed digest no longer goes to the reserved `digest` sender and reaches no peer, also when a steer joined the turn (the bus now maps `digest` for any adapter). A TUI that detaches mid-turn and comes back no longer answers the old turn's senders (#3).
|
|
11
|
+
- A waiting approval raises a macOS notification naming the peer and, for Kimi, the tool (never the payload); the approval timeout is configurable as `approvals.timeout_s`, and an unanswered request leaves a console and log line (#5).
|
|
12
|
+
- Claims: a self-claim without a class is filed as `implement` when no model can name one; the earlier owner of an overlapping task hears of it with its next message; an owner offline past `tasks.release_after_min` (default 30) loses its open tasks to a peer that can take them (#6).
|
|
13
|
+
- A class can carry a completion check (`checks.<class>` in a `.agenthub/config.json` git confirms nobody committed; outside a repository none runs): marking such a task done runs it, sends the task to review with the result on success, and keeps it with its owner with the failure otherwise. A hub stop interrupts a check without a verdict (#7).
|
|
14
|
+
- `scripts/overlaps.ts` counts the claim overlap warnings in hub logs per week and names the task pairs, for the two-week measurement that decides whether per-task worktrees are built. The procedure and the numbers go in `docs/smoke.md` (#8).
|
|
15
|
+
- Releases publish to npm through trusted publishing (OIDC) instead of a stored token, and the workflows use the Node 24 releases of `actions/checkout` and `actions/setup-node` (#4).
|
|
16
|
+
|
|
5
17
|
## 0.7.7
|
|
6
18
|
|
|
7
19
|
- `ahub setup` without a terminal and without `--yes` prints the step it would take and exits non-zero instead of waiting forever at its prompt, as `restart` and `upgrade` already did. A piped answer (`yes | ahub setup`) is no longer read: use `--yes` (#73).
|
|
@@ -91,7 +103,7 @@
|
|
|
91
103
|
## 0.4.1 (included in 0.5.0)
|
|
92
104
|
|
|
93
105
|
- Synchronize the daemon digest integration fixture without changing batching semantics or weakening digest/FYI assertions (#15).
|
|
94
|
-
- Reconcile completed live smoke and npm publication evidence; infrastructure-dependent checks remain tracked in #12.
|
|
106
|
+
- Reconcile completed live smoke and npm publication evidence; infrastructure-dependent checks remain tracked in #1 (formerly #12).
|
|
95
107
|
|
|
96
108
|
## 0.4.0
|
|
97
109
|
|
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 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.7.
|
|
7
|
+
Status: 0.7.8, control protocol 10. 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
|
|
|
@@ -28,7 +28,7 @@ cd <your project> && ahub init && ahub up && ahub tail
|
|
|
28
28
|
Or install the same version from GitHub:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
bun add -g github:STAIxBWLB/agent-hub#v0.7.
|
|
31
|
+
bun add -g github:STAIxBWLB/agent-hub#v0.7.8 && ahub setup
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
The installed commands remain `ahub` and `agent-hub`.
|
|
@@ -113,8 +113,9 @@ A project has one managed Pi session owner. Mode changes require the agent to
|
|
|
113
113
|
settle and preserve the session file. Before the first generation, a live source
|
|
114
114
|
verified to be empty can instead retain its exact session ID without a file.
|
|
115
115
|
Missing history from a nonempty session blocks handover. Protocol 9 adds Pi
|
|
116
|
-
recovery metadata. Protocol 10
|
|
117
|
-
receipts and conservative queue recovery; it is
|
|
116
|
+
recovery metadata. Protocol 10, released in 0.7.0, adds durable delivery
|
|
117
|
+
receipts and conservative queue recovery; it is verified live (see the
|
|
118
|
+
[smoke checklist](docs/smoke.md)).
|
|
118
119
|
Older protocol hubs still require their matching CLI and an attended bootstrap.
|
|
119
120
|
|
|
120
121
|
## Runtime
|
package/docs/operations.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Operations guide
|
|
2
2
|
|
|
3
|
-
This guide describes ahub 0.7.
|
|
3
|
+
This guide describes ahub 0.7.8 and control protocol 10. Live verification
|
|
4
4
|
results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
|
|
5
5
|
|
|
6
6
|
## Install and start
|
|
@@ -58,11 +58,32 @@ ahub review <task-id> approved "verified against the requested behavior"
|
|
|
58
58
|
ahub review <task-id> changes_requested "describe the required correction"
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
A class can carry a completion check in `.agenthub/config.json`, for example
|
|
62
|
+
`"checks": { "implement": "scripts/check.sh", "timeout_s": 600 }`. When the owner
|
|
63
|
+
marks such a task done, the tool answers at once, the hub runs the command in the
|
|
64
|
+
project root (one at a time; what it left in its process group is killed when it
|
|
65
|
+
exits, at the timeout or on shutdown), and then either
|
|
66
|
+
sends the task to review with the command, exit code and output tail, or keeps it
|
|
67
|
+
with its owner and tells it what failed. The hub runs `checks` only when git
|
|
68
|
+
confirms that nobody committed `.agenthub/config.json`: a committed file must not
|
|
69
|
+
choose commands the hub runs, and outside a git repository it runs none. Keys that
|
|
70
|
+
are not task classes are ignored with a log line. A hub stopped mid-check leaves
|
|
71
|
+
the task in progress (`check interrupted`); the owner marks it done again.
|
|
72
|
+
|
|
61
73
|
A task reaching `approved` is a board transition. It is not proof that an
|
|
62
74
|
external side effect happened unless the task's refs and a live readback show
|
|
63
75
|
that effect. The hub can reassign after repeated review changes according to
|
|
64
76
|
the routing configuration.
|
|
65
77
|
|
|
78
|
+
An agent can claim work nobody assigned it by proposing a task with itself as
|
|
79
|
+
owner; without a class, and with no model to name one, the claim is filed as
|
|
80
|
+
`implement`. When a task's paths overlap another owner's open task, the newcomer
|
|
81
|
+
is told to settle it and the earlier owner gets one line with its next message,
|
|
82
|
+
at no turn of its own. An owner offline longer than `tasks.release_after_min`
|
|
83
|
+
(default 30, `0` turns it off) in `.agenthub/config.json` loses its open tasks
|
|
84
|
+
to a peer routing can give them to; with nobody to take them they stay, and a
|
|
85
|
+
paused peer or a hub in a recovery operation is left alone.
|
|
86
|
+
|
|
66
87
|
Use targeted messages for coordination:
|
|
67
88
|
|
|
68
89
|
```bash
|
|
@@ -86,7 +107,15 @@ ahub permit <request-id> allow
|
|
|
86
107
|
```
|
|
87
108
|
|
|
88
109
|
Use the exact option shown by `ahub tail`; do not approve an unresolved or
|
|
89
|
-
unexpected request.
|
|
110
|
+
unexpected request. On macOS a waiting request also raises a desktop
|
|
111
|
+
notification that names the peer and, for Kimi, the tool, never what it would
|
|
112
|
+
run. An unanswered request is cancelled after `approvals.timeout_s` (default
|
|
113
|
+
120, 30 to 3600) in `.agenthub/config.json`, and the console and log say so.
|
|
114
|
+
Set `approvals.notify` to `false` to turn notifications off; a hub started
|
|
115
|
+
without a project config file raises none. While Kimi waits for an answer its
|
|
116
|
+
turn is kept alive, so a timeout longer than the inactivity watchdog does not
|
|
117
|
+
cancel it.
|
|
118
|
+
`ahub pause <peer>` holds delivery and `ahub resume
|
|
90
119
|
<peer>` releases a manual pause. Budget pauses are distinct:
|
|
91
120
|
|
|
92
121
|
```bash
|
|
@@ -114,11 +143,10 @@ ahub queue show <delivery-id>
|
|
|
114
143
|
ahub queue resolve <delivery-id> --action completed|retry|discard --reason "<text>"
|
|
115
144
|
```
|
|
116
145
|
|
|
117
|
-
These queue commands
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
an attached peer.
|
|
146
|
+
These queue commands ship with protocol 10 (0.7.0 and later), where a known
|
|
147
|
+
offline recipient may also be queued explicitly; broadcast and automatic
|
|
148
|
+
assignment still require an attached peer. A hub still on protocol 9 (0.6.x)
|
|
149
|
+
has only `ahub status`, `ahub tail` and the task board.
|
|
122
150
|
|
|
123
151
|
Resolution requires the current observed revision. A retry closes the old
|
|
124
152
|
record and creates one linked attempt. Stale or conflicting resolutions fail.
|
|
@@ -186,8 +214,8 @@ source and carries every recovery fix released up to it. Protocol 8 and older
|
|
|
186
214
|
project directory, without replacing the global CLI first:
|
|
187
215
|
|
|
188
216
|
```bash
|
|
189
|
-
bunx --package @staix/agent-hub@0.7.
|
|
190
|
-
bunx --package @staix/agent-hub@0.7.
|
|
217
|
+
bunx --package @staix/agent-hub@0.7.8 ahub upgrade --to 0.7.8 --dry-run
|
|
218
|
+
bunx --package @staix/agent-hub@0.7.8 ahub upgrade --to 0.7.8 --yes
|
|
191
219
|
```
|
|
192
220
|
|
|
193
221
|
| Running now | Coordinator to use |
|
|
@@ -209,19 +237,19 @@ The coordinator verifies and retains the exact target package, preserves its
|
|
|
209
237
|
own source, and promotes the global CLI only after restored projects pass
|
|
210
238
|
readback.
|
|
211
239
|
|
|
212
|
-
Once the installed CLI is 0.7.
|
|
240
|
+
Once the installed CLI is 0.7.8, review the current project or all registered
|
|
213
241
|
projects first:
|
|
214
242
|
|
|
215
243
|
```bash
|
|
216
244
|
ahub restart --dry-run
|
|
217
|
-
ahub upgrade --to 0.7.
|
|
245
|
+
ahub upgrade --to 0.7.8 --dry-run
|
|
218
246
|
```
|
|
219
247
|
|
|
220
248
|
Apply only after reviewing the plan:
|
|
221
249
|
|
|
222
250
|
```bash
|
|
223
251
|
ahub restart --yes
|
|
224
|
-
ahub upgrade --to 0.7.
|
|
252
|
+
ahub upgrade --to 0.7.8 --yes
|
|
225
253
|
ahub recovery status <operation-id>
|
|
226
254
|
ahub recovery resume <operation-id>
|
|
227
255
|
ahub recovery abort <operation-id>
|
|
@@ -264,7 +292,7 @@ peers and therefore did not show that backlog. No messages were deleted. The
|
|
|
264
292
|
0.7.1 attended cutover and production readback verified queue visibility and
|
|
265
293
|
completion receipts; see the final section of the smoke checklist.
|
|
266
294
|
|
|
267
|
-
Issue [#
|
|
295
|
+
Issue [#1](https://github.com/STAIxBWLB/agent-hub/issues/1) remains open for
|
|
268
296
|
off-campus Access credentials and a natural near-limit budget pause. Tests,
|
|
269
297
|
synthetic approvals, and an authenticated internal network do not close those
|
|
270
298
|
prerequisites. Adapter acceptance is not task completion, and package
|
package/docs/quickstart.md
CHANGED
package/docs/security.md
CHANGED
|
@@ -6,7 +6,8 @@ agent-hub connects agents that can each run commands. This page says what the hu
|
|
|
6
6
|
|
|
7
7
|
- **Other agents' text is untrusted.** Every message that crosses from one peer to another is framed as untrusted input (a channel tag with `meta.source` for Claude, a fixed header line plus a standing instruction for the others). A message body cannot forge the hub's own headers: such lines are quoted (`sanitize`). Replies inherit a hop count capped at 3, so agents cannot ping-pong forever; neither a digest nor a steer can reset it.
|
|
8
8
|
- **The control link is loopback plus a secret.** The daemon and the Codex proxy bind 127.0.0.1 only. The control WebSocket requires a per-run token (`.agenthub/state/control-token`, mode 600), and both servers refuse any request that carries an `Origin` header: any web page can open a WebSocket to localhost, and browsers always send `Origin`. External clients cannot claim the console user's id or a hub-managed peer's id.
|
|
9
|
-
- **Permission prompts stay on.** `ahub claude` and `ahub codex` add nothing that weakens the agents' own prompts. Kimi's and `local`'s permission requests are relayed to the console and cancelled after 120 s of silence. The one exception is the hub's own tools (`hub_send` and the task tools, matched by exact name): Kimi's requests for them are approved once without a prompt and logged by name, the same trust Codex gets through `approval_mode` in the hub's config. They touch no file and run no process, and every call passes the hub's own checks. The match relies on the agent putting the tool name in the request's `title`, as Kimi does; an ACP agent that titles calls with model-written text must not be configured as `kimi_cmd`. A payload longer than the console shows is marked as cut and never offers a session-wide grant. `--unattended` turns prompts off, says so loudly, and is never the default.
|
|
9
|
+
- **Permission prompts stay on.** `ahub claude` and `ahub codex` add nothing that weakens the agents' own prompts. Kimi's and `local`'s permission requests are relayed to the console and cancelled after `approvals.timeout_s` (default 120 s) of silence; the macOS notification for a waiting request carries the peer and the tool name only. The one exception is the hub's own tools (`hub_send` and the task tools, matched by exact name): Kimi's requests for them are approved once without a prompt and logged by name, the same trust Codex gets through `approval_mode` in the hub's config. They touch no file and run no process, and every call passes the hub's own checks. The match relies on the agent putting the tool name in the request's `title`, as Kimi does; an ACP agent that titles calls with model-written text must not be configured as `kimi_cmd`. A payload longer than the console shows is marked as cut and never offers a session-wide grant. `--unattended` turns prompts off, says so loudly, and is never the default.
|
|
10
|
+
- **Completion checks run only from a config nobody committed.** A command in `checks` runs as you, outside the local worker's sandbox, like a git hook, when a task of that class is marked done. The hub runs `checks` only when git confirms that `.agenthub/config.json` is not committed, in any letter case, and that `.agenthub` is not a committed symlink or submodule; without a repository, or when git fails, it runs none. So a cloned repository cannot choose a check. Nothing in `routing.toml` or in task text is ever run. The launch commands in the same file (`kimi_cmd`, `codex_bin`, `pi.cmd`) have no such guard: start a hub only in a checkout whose `.agenthub/config.json` you trust.
|
|
10
11
|
- **The local worker is boxed in.** Paths are resolved through symlinks and must stay inside the project; a secrets denylist (`.env*`, keys, credential files, the hub's own state) applies to its file tools, its git arguments and its memory capture alike; `.git` and `.agenthub` are not writable. Writes, edits, shell commands and mutating git wait for approval, and the approver sees what will be written or run, with control characters escaped. Everything it executes runs under the macOS sandbox, attended or not: no writes outside the project, its git dir and temp; the home directory is unreadable except toolchains; no `.git/hooks` or `.git/config` writes; no network, loopback included. Without the sandbox there is no `bash` tool.
|
|
11
12
|
- **PII has an enforced path.** A task matching `signals.pii_patterns` goes to `local` or to nobody; its text is absent from other peers' envelopes, the console stream, the log and the board listing; the console user reviews it; `local` answers such a turn to the console only, keeps it out of its history, refuses it when the only gateway is off campus, and may not save notes or spin off tasks during it. Nothing about it is sent to claude-mem, whose observer is a cloud model.
|
|
12
13
|
- **Secrets stay where they are read.** The gateway key and Cloudflare Access values are read inside the gateway client and go only into request headers: never into logs, errors, envelopes, tool output, memory, or the generated Switchyard config (the key travels by environment variable name). Subscription logins of Claude, Codex and Kimi are never proxied or pooled.
|
package/docs/smoke.md
CHANGED
|
@@ -170,7 +170,7 @@ Verified on 2026-09-20, without upgrading the developer's running hubs:
|
|
|
170
170
|
Still pending: a real Orca smoke with native Codex/Claude sessions after an
|
|
171
171
|
attended protocol-8 bootstrap; actual registry package/plugin upgrade; production
|
|
172
172
|
rollout. Fake terminal receipts do not establish native conversation restoration.
|
|
173
|
-
Issue #
|
|
173
|
+
Issue #1's Access and natural-quota prerequisites remain unchanged.
|
|
174
174
|
|
|
175
175
|
## Install
|
|
176
176
|
|
|
@@ -333,9 +333,9 @@ No production task or source file was used as a test target.
|
|
|
333
333
|
| Claude task proposal and review tools | pass | The actual plugin `hub_task_propose` created scratch task #1, `ISSUE7-REVIEW-FLOW`, at 12:44:20 UTC, attributed to `claude`. The first assignment remained empty because the Codex peer had been detached; proposal success is separate from handoff success. Claude subsequently issued two actual `hub_review` calls with `changes_requested` at 12:47:50 and 12:47:59 UTC. |
|
|
334
334
|
| Real Codex TUI queue and MCP tools | pass | With Codex 0.155.1 TUI attached, a typed user turn ran `sleep 30`; the status message showed `busy queued 1`, appeared in the transcript only after the user turn completed, and invoked the actual `hub_task_list` before replying `QUEUE-DELIVERED`. |
|
|
335
335
|
| Real Codex TUI steer | pass | In a second typed user turn, the TUI prompt explicitly authorized a console marker update while `sleep 20` ran. An important message arrived at 12:48:15 UTC; queue remained 0; the same turn ended at 12:48:39 UTC with `STEER-VERIFIED` at hop 1. The first probe delivered its steer but the model retained the original user instruction, so that attempt was not counted as behavioral success. |
|
|
336
|
-
| Unassisted Claude proposal -> Codex completion -> Claude review and escalation | fail,
|
|
337
|
-
| Off-campus Cloudflare Access headers | blocked, [#
|
|
338
|
-
| Natural Codex budget pause with TUI attached | blocked, [#
|
|
336
|
+
| Unassisted Claude proposal -> Codex completion -> Claude review and escalation | fail, #11 (previous repository) | Task #1 was delivered to Codex at 12:47:05 UTC; it replied `Task #1 noted; no action taken on the hub reference message.` and left the task proposed. Generated instructions classify every hub item as reference-only memory. After a corrective user prompt in the TUI, board history recorded Codex accepted/done, Claude changes_requested, Codex done, Claude changes_requested, and hub escalation to Kimi. Kimi then accepted/done through its real tools, and Claude approved the task. That assisted success does not erase the default-flow failure. |
|
|
337
|
+
| Off-campus Cloudflare Access headers | blocked, [#1](https://github.com/STAIxBWLB/agent-hub/issues/1) | Internal gateway reachable; both Access credential-file settings empty; unauthenticated public models probe HTTP 403. Needs authorized Access credentials and an off-campus network. VPN was not disconnected. |
|
|
338
|
+
| Natural Codex budget pause with TUI attached | blocked, [#1](https://github.com/STAIxBWLB/agent-hub/issues/1) | Actual TUI-connected weekly reading was approximately 12%, below the gate. No manual budget reading was injected. Needs a naturally near-limit account; parser and manual-injection tests are not this live leg. |
|
|
339
339
|
|
|
340
340
|
### Environment findings
|
|
341
341
|
|
|
@@ -368,7 +368,7 @@ check: OK
|
|
|
368
368
|
```
|
|
369
369
|
|
|
370
370
|
This gate covers type checking, bundle freshness, package contents and tests;
|
|
371
|
-
it does not replace the live outcomes or unblock #11 and #
|
|
371
|
+
it does not replace the live outcomes or unblock #11 (previous repository) and #1.
|
|
372
372
|
|
|
373
373
|
## Issue #11 default workflow retest (0.3.1)
|
|
374
374
|
|
|
@@ -400,7 +400,7 @@ instruction was entered into the Codex TUI. Real board history for
|
|
|
400
400
|
|
|
401
401
|
This closes the default acceptance/review/escalation failure found in the
|
|
402
402
|
0.3.0 issue #7 run. The earlier failure is retained above as historical evidence.
|
|
403
|
-
The off-campus Access and natural budget-pause prerequisites in #
|
|
403
|
+
The off-campus Access and natural budget-pause prerequisites in #1 remain open.
|
|
404
404
|
|
|
405
405
|
Regression gate: `scripts/check.sh` completed with `159 pass`, `0 fail`,
|
|
406
406
|
`1045 expect() calls`, `check: OK`. Added coverage exercises rendered workflow
|
|
@@ -492,7 +492,7 @@ The 0.7.0 protocol-10 durable-delivery implementation, source-9-to-target-10
|
|
|
492
492
|
transition, queue CLI, receipt reconciliation, and attended Claude
|
|
493
493
|
confirmation remain pending live verification. Do not claim a successful
|
|
494
494
|
0.7.0 production cutover from package installation, tests, or synthetic
|
|
495
|
-
approval alone. Issue [#
|
|
495
|
+
approval alone. Issue [#1](https://github.com/STAIxBWLB/agent-hub/issues/1)
|
|
496
496
|
remains open for off-campus Access credentials and a natural near-limit budget
|
|
497
497
|
pause.
|
|
498
498
|
|
|
@@ -522,7 +522,7 @@ pause.
|
|
|
522
522
|
- The synthetic Claude control peer acknowledged bridge receipt and replied
|
|
523
523
|
with a correlated FYI. This does not establish a real Claude inference turn.
|
|
524
524
|
- Production application, attended Claude restoration and post-deploy readback
|
|
525
|
-
remain pending until the tagged artifact is installed. Issue #
|
|
525
|
+
remain pending until the tagged artifact is installed. Issue #1 remains open.
|
|
526
526
|
|
|
527
527
|
|
|
528
528
|
## 0.7.0 attended production cutover and follow-up
|
|
@@ -569,7 +569,7 @@ The 0.7.1 attended repeat is recorded separately after application.
|
|
|
569
569
|
- `ahub doctor` reported all configured checks healthy, including the delivery
|
|
570
570
|
journal and Claude recovery identity. The hwp-cli hub remained stopped.
|
|
571
571
|
- These are supervised live checks. Native confirmation screens, uncertain
|
|
572
|
-
delivery review, and the infrastructure prerequisites in #
|
|
572
|
+
delivery review, and the infrastructure prerequisites in #1 remain explicit
|
|
573
573
|
operator responsibilities; this does not certify unattended operation.
|
|
574
574
|
|
|
575
575
|
|
|
@@ -595,7 +595,7 @@ verified application; no daemon, plugin, terminal, or journal mutation.
|
|
|
595
595
|
for the 0.7.1 application; no source change is present on HEAD.
|
|
596
596
|
- Kimi reports 2.0.2 in doctor; earlier live legs on this page used 2.0.1.
|
|
597
597
|
|
|
598
|
-
Issue [#
|
|
598
|
+
Issue [#1](https://github.com/STAIxBWLB/agent-hub/issues/1) remains open
|
|
599
599
|
for off-campus Access credentials and a natural near-limit budget pause.
|
|
600
600
|
|
|
601
601
|
|
|
@@ -690,8 +690,10 @@ cutover; it also exposed three recovery defects that became 0.7.3 and 0.7.4.
|
|
|
690
690
|
sourceRoot digest guard correctly refused a hand-patched receipt.
|
|
691
691
|
- `ahub setup` is interactive (one confirmation gates all steps); piping an
|
|
692
692
|
answer works, running it detached does not.
|
|
693
|
+
(Changed in 0.7.7: without a terminal it no longer reads a piped answer; use
|
|
694
|
+
`--yes`.)
|
|
693
695
|
|
|
694
|
-
Issue [#
|
|
696
|
+
Issue [#1](https://github.com/STAIxBWLB/agent-hub/issues/1) remains open
|
|
695
697
|
for off-campus Access credentials and a natural near-limit budget pause.
|
|
696
698
|
|
|
697
699
|
## Shared notes and claims live run (issue #68, 0.7.6, 2026-09-30)
|
|
@@ -806,3 +808,42 @@ ran a released hub through `bunx --package @staix/agent-hub@<version> ahub up`:
|
|
|
806
808
|
with the 0.7.0 coordinator, see above), and session or terminal planning: no peer
|
|
807
809
|
was attached, so `peers` and `terminals` were empty.
|
|
808
810
|
- Both hubs were stopped and their registrations removed after recording.
|
|
811
|
+
|
|
812
|
+
## Approval notice and timeout (issue #5, 2026-09-30)
|
|
813
|
+
|
|
814
|
+
A disposable project initialised by the PR branch (`approvals: { timeout_s: 120 }`
|
|
815
|
+
from the template, `notify` left to its macOS default) with real Kimi 2.1.1. Times
|
|
816
|
+
are hub.log timestamps (UTC).
|
|
817
|
+
|
|
818
|
+
- Asked to run `echo notify-live-5`, Kimi's request was logged at 11:33:43.793Z as
|
|
819
|
+
`permission 8743d41c requested by kimi (38 chars, shown on the console; cancelled
|
|
820
|
+
after 120s)`. Notifications were on by the macOS default (the template sets no
|
|
821
|
+
`notify`), and no notifier failure line appeared (it logs a spawn error, a
|
|
822
|
+
non-zero exit or its 5 s kill). A clean exit does not prove a banner showed: that
|
|
823
|
+
is visual and not captured here, so the owner confirms it on the next use.
|
|
824
|
+
- The request was rejected from the console; Kimi said so at 11:34:03.468Z and ran
|
|
825
|
+
nothing.
|
|
826
|
+
- The disposable hub was stopped and its registration removed after recording.
|
|
827
|
+
|
|
828
|
+
## Overlap measurement (issue #8, from 2026-09-30)
|
|
829
|
+
|
|
830
|
+
Whether per-task worktrees get built depends on this: two weeks of normal use with
|
|
831
|
+
the claim overlap warnings (0.7.6 and later), then the owner decides in issue #8.
|
|
832
|
+
|
|
833
|
+
- Count, from a checkout of this repository: `ahub projects --json | jq -r
|
|
834
|
+
'.[].stateDir + "/hub.log"' | tr '\n' '\0' | xargs -0 bun scripts/overlaps.ts
|
|
835
|
+
--since <start date>`. It reads only the hub's overlap notice lines and prints,
|
|
836
|
+
per week (Monday, UTC), the number of warnings and each pair of tasks once per
|
|
837
|
+
project, with its owners, shared paths, first warning and count. A registered
|
|
838
|
+
project without a hub.log is skipped with a note.
|
|
839
|
+
- Per pair, decide whether it led to conflicting edits: from that project's root,
|
|
840
|
+
with its hub running, `ahub task show <id>` for both tasks, and `git log --since
|
|
841
|
+
<first warning> -- <shared paths>`. A conflicting edit is a commit that
|
|
842
|
+
overwrote or reverted the other task's change, a merge conflict between the two,
|
|
843
|
+
or a build or test run broken by the other task's half-done change.
|
|
844
|
+
- Record only the numbers and the verdicts below. The script's output names tasks
|
|
845
|
+
and paths of private projects: it stays local.
|
|
846
|
+
|
|
847
|
+
| Week of | Warnings | Task pairs | Pairs with a conflicting edit | Notes |
|
|
848
|
+
|---|---|---|---|---|
|
|
849
|
+
| 2026-09-28 | 0 | 0 | 0 | baseline counted on 2026-09-30 over every registered project; the issue #68 live run's project was already removed |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# agent-hub design spec
|
|
2
2
|
|
|
3
|
-
Date: 2026-09-19. Status: M1 to M5 merged; M6 implemented on `feat/m6-packaging-inference` (phase spec: issue #2 of the public repository). Version 0.1.0.
|
|
3
|
+
Date: 2026-09-19. Status: M1 to M5 merged; M6 implemented on `feat/m6-packaging-inference` (phase spec: issue #2 of the first public repository, archived). Version 0.1.0.
|
|
4
4
|
Owner: Young Joon Lee. Repo: STAIxBWLB/agent-hub.
|
|
5
5
|
|
|
6
6
|
Facts below are tagged **verified** (measured on 2026-09-19 on the owner's Mac) or
|
|
@@ -565,7 +565,7 @@ M2 coordination
|
|
|
565
565
|
|
|
566
566
|
M3 local worker and routing L2/L3
|
|
567
567
|
- [x] Local worker agent loop with cwd-scoped tools, secrets denylist, approvals and seatbelt sandbox
|
|
568
|
-
- [x] OmniRoute client with Cloudflare Access headers and the owner-issued inference key; the off-campus live path remains blocked in `docs/smoke.md` (#
|
|
568
|
+
- [x] OmniRoute client with Cloudflare Access headers and the owner-issued inference key; the off-campus live path remains blocked in `docs/smoke.md` (#1)
|
|
569
569
|
- [x] Switchyard sidecar: config generation, lifecycle, health, fallback to fixed model
|
|
570
570
|
- [x] `ahub local`, smoke through OmniRoute with provider header check, and through the real sidecar
|
|
571
571
|
- [x] Local worker capture into claude-mem (`sessions/init`, `observations`, `summarize`, `session-end`, skip list)
|
|
@@ -584,7 +584,7 @@ M5 budget relay
|
|
|
584
584
|
- [x] Reassignment to local, one resume envelope, idempotency and restart recovery
|
|
585
585
|
- [x] Handoff context (checkpoint summary or memory block) plus task briefs in the task envelope
|
|
586
586
|
- [x] Live: the Claude status line tee in an interactive session (`docs/smoke.md`, 2026-09-19)
|
|
587
|
-
- [ ] Live: a real pause driven by Codex's own numbers with a TUI attached; the natural near-limit prerequisite remains blocked by issue #
|
|
587
|
+
- [ ] Live: a real pause driven by Codex's own numbers with a TUI attached; the natural near-limit prerequisite remains blocked by issue #1
|
|
588
588
|
|
|
589
589
|
M6 internal inference, packaging
|
|
590
590
|
- [x] Status digests and triage through `sy/fast` (amended: optional and fail-open with a backoff. Only plain status
|
|
@@ -615,6 +615,9 @@ M6 internal inference, packaging
|
|
|
615
615
|
publicly with provenance and `NPM_TOKEN`. The token belongs to an authorized
|
|
616
616
|
organization member. Organization conversion and credential provisioning are
|
|
617
617
|
owner operations; this implementation does not perform them.
|
|
618
|
+
- Amended 2026-09-30 (issue #4 of this repository): publishing uses npm trusted
|
|
619
|
+
publishing (OIDC) with no stored token; npm 11.5.1 or later publishes provenance by
|
|
620
|
+
itself. Registering the trusted publisher on npmjs.com is an owner operation.
|
|
618
621
|
- The v0.4.0 release workflow completed registry publication with provenance.
|
|
619
622
|
Registry metadata/provenance readback and real Claude setup from a clean registry
|
|
620
623
|
installation remain to be verified.
|
|
@@ -751,3 +754,65 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
|
|
|
751
754
|
anything outside `[a-zA-Z0-9_-]`, and a server passed in `session/new` shadows a
|
|
752
755
|
configured server of the same name, so only the hub's `agent-hub` server can
|
|
753
756
|
produce the approved names.
|
|
757
|
+
|
|
758
|
+
## Amendment: approval notifications and timeout (issue #5)
|
|
759
|
+
|
|
760
|
+
- A waiting permission request raises one macOS notification (`osascript`) with
|
|
761
|
+
the peer and, when the adapter reports it apart from the title (ACP), the tool
|
|
762
|
+
name; never the title or payload, which can quote a PII turn. Failures are logged
|
|
763
|
+
once and never affect the request.
|
|
764
|
+
- `approvals.timeout_s` (default 120, accepted 30 to 3600, otherwise 120 with a log
|
|
765
|
+
line) replaces the fixed 120 s; an unanswered request leaves a console and log
|
|
766
|
+
line.
|
|
767
|
+
- `approvals.notify` defaults to on for macOS when a project config file exists and
|
|
768
|
+
to off otherwise (tests, a hub without a config), so test runs raise nothing; a
|
|
769
|
+
non-boolean value is ignored.
|
|
770
|
+
- The ACP adapter keeps its inactivity watchdog alive while a permission request
|
|
771
|
+
waits (as the local worker already did), so a timeout above `watchdog_ms` does
|
|
772
|
+
not cancel the turn. The tool name travels to the notifier only; the console
|
|
773
|
+
`permission` push keeps its shape, so the control protocol is unchanged.
|
|
774
|
+
|
|
775
|
+
## Amendment: claim robustness (issue #6)
|
|
776
|
+
|
|
777
|
+
- A self-claim (`owner` equal to the caller) without a class, when triage names
|
|
778
|
+
none, is filed as `implement` and its history says so; a proposal for another
|
|
779
|
+
owner still needs a class.
|
|
780
|
+
- When a new owner's paths overlap an open task of another owner, that earlier
|
|
781
|
+
owner gets one line through the preface path (`note from hub [finding]: task #N
|
|
782
|
+
(owner X) now overlaps your #M on <paths>; X is told to settle it`), capped with
|
|
783
|
+
shared notes, never a delivery of its own. PII tasks stay out on both sides.
|
|
784
|
+
- An owner offline longer than `tasks.release_after_min` (default 30; 0 disables)
|
|
785
|
+
loses its open tasks, released and reassigned by class routing (not the budget
|
|
786
|
+
handoff's local-first list), but only when routing finds another owner;
|
|
787
|
+
otherwise the task stays. Each task is re-read before it moves, so one changed
|
|
788
|
+
meanwhile (reassigned, finished, owner back) is left alone; runs never overlap,
|
|
789
|
+
and a recovery commit waits for a run in progress. Paused peers and hubs in a
|
|
790
|
+
recovery operation are skipped. A peer never seen attached counts from hub
|
|
791
|
+
start. The gone owner gets a line on its next delivery naming where each task
|
|
792
|
+
went. Reviewers are not moved by this.
|
|
793
|
+
|
|
794
|
+
## Amendment: completion checks (issue #7)
|
|
795
|
+
|
|
796
|
+
- `checks.<class>` in `.agenthub/config.json` names a command; `checks.timeout_s`
|
|
797
|
+
(default 600, 1 to 3600) bounds it. The hub runs `checks` only when git
|
|
798
|
+
confirms nobody committed that file (any letter case, nor a symlink or submodule
|
|
799
|
+
at `.agenthub`); without a repository or on a git error it runs none. Keys that
|
|
800
|
+
are not task classes are ignored with a log line.
|
|
801
|
+
- `hub_task_done` on such a class records `done (checking)` and answers at once
|
|
802
|
+
(a check can outlast an agent's tool timeout); the task stays `in_progress`. The
|
|
803
|
+
hub runs checks one at a time in the project root, in their own process group,
|
|
804
|
+
killed at the timeout or on shutdown; a check still queued at shutdown never
|
|
805
|
+
starts. A check ends when its command exits: what it left in its process group
|
|
806
|
+
is killed then, and a descendant that left the group gets 0.5 s more to flush
|
|
807
|
+
output and is not waited for. Exit 0: the task
|
|
808
|
+
completes as before, the review envelope carrying `Check: <command> -> exit 0`
|
|
809
|
+
and the output tail; shared memory gets the summary and the outcome line, never
|
|
810
|
+
the output. Any other outcome: the task stays with its owner, who gets a task
|
|
811
|
+
envelope with the command, outcome and tail; no reviewer turn is spent. A second
|
|
812
|
+
`done` while the check runs is refused; a new owner who took the task meanwhile is
|
|
813
|
+
told to try again later, since the result reaches only the owner the check was
|
|
814
|
+
started for. A task with any board event since the
|
|
815
|
+
`done` other than an answer or a reviewer change only records the result
|
|
816
|
+
(`check finished late`); if it is in progress with the same owner again, that
|
|
817
|
+
owner is asked to mark it done again. A check interrupted by a hub stop records
|
|
818
|
+
`check interrupted`, is not resumed, and the owner marks the task done again.
|
|
@@ -8,7 +8,7 @@ Target: 0.7.0, protocol 10
|
|
|
8
8
|
|
|
9
9
|
Make ahub suitable for supervised daily development: expose disconnected recipients' pending work, survive daemon termination without silently losing accepted queued messages, and make uncertain execution an explicit operator decision. Publish an English operations guide and verify the release before applying it to the current installation.
|
|
10
10
|
|
|
11
|
-
The user chose conservative recovery and deployment through production application. Never automatically replay uncertain or partially executed work. Exactly-once external effects, automatic rollback, and unattended native approval handling remain outside this release. Infrastructure prerequisites in issue #
|
|
11
|
+
The user chose conservative recovery and deployment through production application. Never automatically replay uncertain or partially executed work. Exactly-once external effects, automatic rollback, and unattended native approval handling remain outside this release. Infrastructure prerequisites in issue #1 remain pending unless independently available; never manufacture quota exhaustion or disconnect the user's network.
|
|
12
12
|
|
|
13
13
|
## Delivery model
|
|
14
14
|
|
|
@@ -113,6 +113,12 @@ stopped with its matching CLI before a protocol-8 manager is launched.
|
|
|
113
113
|
|
|
114
114
|
## Current 0.7.0 correction and recovery contract
|
|
115
115
|
|
|
116
|
+
Amended 2026-09-30: 0.7.0 shipped protocol 10 and the queue commands; protocol 10
|
|
117
|
+
went into production with 0.7.0 and was verified there with 0.7.1, where
|
|
118
|
+
`ahub queue list` read back live receipts ([smoke checklist](../smoke.md)). `ahub queue show` and `ahub queue resolve` are
|
|
119
|
+
covered by tests, not by a live run. The paragraphs below keep the pre-release
|
|
120
|
+
wording.
|
|
121
|
+
|
|
116
122
|
The earlier sections record the protocol-8 implementation history. The current
|
|
117
123
|
release is 0.6.4 with protocol 9; the next target is 0.7.0 with protocol 10.
|
|
118
124
|
This correction supersedes the earlier protocol-8 source/target statement for
|
|
@@ -146,6 +152,6 @@ The documented verification sequence is: run a dry-run, verify package and
|
|
|
146
152
|
session identity, manually confirm Claude recovery, read back version, session,
|
|
147
153
|
queue, task, and budget state, then release the held queues. Do not claim a
|
|
148
154
|
successful 0.7.0 cutover from tests, synthetic approvals, or package
|
|
149
|
-
installation alone. Issue [#
|
|
155
|
+
installation alone. Issue [#1](https://github.com/STAIxBWLB/agent-hub/issues/1)
|
|
150
156
|
remains open for off-campus Access credentials and a natural near-limit budget
|
|
151
157
|
pause.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
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",
|
|
@@ -15727,7 +15727,7 @@ class ControlClient {
|
|
|
15727
15727
|
// package.json
|
|
15728
15728
|
var package_default = {
|
|
15729
15729
|
name: "@staix/agent-hub",
|
|
15730
|
-
version: "0.7.
|
|
15730
|
+
version: "0.7.8",
|
|
15731
15731
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15732
15732
|
license: "MIT",
|
|
15733
15733
|
type: "module",
|
|
@@ -15806,7 +15806,7 @@ var TASK_TOOLS = [
|
|
|
15806
15806
|
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
|
|
15807
15807
|
tool("hub_task_accept", "Take a task that was assigned to you.", { id }, ["id"]),
|
|
15808
15808
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
|
|
15809
|
-
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
15809
|
+
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
15810
15810
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] } }),
|
|
15811
15811
|
tool("hub_review", "Give your verdict on a task you were asked to review. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str }, ["id", "verdict"]),
|
|
15812
15812
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
|
package/src/adapters/acp.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface PermissionRequest {
|
|
|
13
13
|
peer: PeerId;
|
|
14
14
|
title: string;
|
|
15
15
|
options: PermissionOption[];
|
|
16
|
+
/** The tool's name alone, when the adapter knows it apart from the title (which can quote the payload). */
|
|
17
|
+
tool?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface AcpOptions {
|
|
@@ -243,7 +245,17 @@ export class AcpPeer extends BasePeer {
|
|
|
243
245
|
// An unknown payload is never dressed up as a description, and it must not buy a blanket grant.
|
|
244
246
|
const title: string = raw === undefined ? `${call.title ?? "tool call"} (payload not reported by the agent)` : `${call.title ?? "tool call"}${input}`;
|
|
245
247
|
const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => (raw !== undefined && !cut) || o.kind !== "allow_always");
|
|
246
|
-
|
|
248
|
+
// Only a bare tool name travels on its own (a desktop notice shows it); anything prose-like stays in the title.
|
|
249
|
+
const tool = typeof call.title === "string" && /^[\w.:@-]{1,80}$/.test(call.title) ? call.title : undefined;
|
|
250
|
+
// Waiting for a person is not the agent going silent: keep the watchdog from cancelling the turn meanwhile.
|
|
251
|
+
const turn = this.turn;
|
|
252
|
+
const alive = setInterval(() => this.state === "busy" && turn === this.turn && this.touch(), Math.max(10, Math.min(30_000, Math.floor(this.watchdogMs / 3))));
|
|
253
|
+
let picked: string | undefined;
|
|
254
|
+
try {
|
|
255
|
+
picked = await this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}) }).catch(() => undefined);
|
|
256
|
+
} finally {
|
|
257
|
+
clearInterval(alive); // also when there is no handler at all
|
|
258
|
+
}
|
|
247
259
|
const valid = options.some((o) => o.optionId === picked);
|
|
248
260
|
this.send({
|
|
249
261
|
jsonrpc: "2.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
|
-
import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
3
|
+
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import { BasePeer } from "../hub/peers.ts";
|
|
5
5
|
import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
|
|
6
6
|
|
|
@@ -54,6 +54,7 @@ export class CodexPeer extends BasePeer {
|
|
|
54
54
|
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer" }>();
|
|
55
55
|
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
56
56
|
private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
|
|
57
|
+
private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
|
|
57
58
|
private lastAnswer = "";
|
|
58
59
|
private readonly deltas = new Map<string, string[]>();
|
|
59
60
|
private primed = false;
|
|
@@ -146,6 +147,7 @@ export class CodexPeer extends BasePeer {
|
|
|
146
147
|
resolve: (result) => {
|
|
147
148
|
this.primed = true;
|
|
148
149
|
this.injected = replyParent(envs);
|
|
150
|
+
this.answering = [...envs];
|
|
149
151
|
const nativeTurn = typeof result?.turn?.id === "string" ? result.turn.id : undefined;
|
|
150
152
|
if (deliveryId) {
|
|
151
153
|
if (nativeTurn) this.addTurnDelivery(nativeTurn, deliveryId);
|
|
@@ -188,6 +190,7 @@ export class CodexPeer extends BasePeer {
|
|
|
188
190
|
this.primed = true;
|
|
189
191
|
// The turn now answers these too; the highest hop wins so a steer cannot reset the hop cap.
|
|
190
192
|
this.injected = replyParent(this.injected ? [this.injected, ...envs] : envs);
|
|
193
|
+
this.answering.push(...envs);
|
|
191
194
|
if (deliveryId) {
|
|
192
195
|
this.addTurnDelivery(expectedTurnId, deliveryId);
|
|
193
196
|
this.delivery({ id: deliveryId, state: "accepted" });
|
|
@@ -216,6 +219,7 @@ export class CodexPeer extends BasePeer {
|
|
|
216
219
|
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
217
220
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
218
221
|
this.injected = undefined;
|
|
222
|
+
this.answering = [];
|
|
219
223
|
this.lastAnswer = "";
|
|
220
224
|
this.abandonSteers("turn went silent");
|
|
221
225
|
super.onWatchdog();
|
|
@@ -297,9 +301,17 @@ export class CodexPeer extends BasePeer {
|
|
|
297
301
|
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
|
|
298
302
|
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
|
|
299
303
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
304
|
+
this.forgetTurn();
|
|
300
305
|
this.setState("offline");
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
/** Nothing of an interrupted turn may address a later answer: a TUI that comes back starts its own. */
|
|
309
|
+
private forgetTurn(): void {
|
|
310
|
+
this.injected = undefined;
|
|
311
|
+
this.answering = [];
|
|
312
|
+
this.lastAnswer = "";
|
|
313
|
+
}
|
|
314
|
+
|
|
303
315
|
private fromTui(link: Link, raw: string): void {
|
|
304
316
|
const msg = parse(raw);
|
|
305
317
|
if (msg?.id !== undefined && TRACKED.has(msg.method)) link.tracked.set(msg.id, msg.method);
|
|
@@ -329,6 +341,7 @@ export class CodexPeer extends BasePeer {
|
|
|
329
341
|
this.link = link;
|
|
330
342
|
this.threadId = threadId;
|
|
331
343
|
this.activeTurns.clear();
|
|
344
|
+
this.forgetTurn();
|
|
332
345
|
this.opts.log?.(`[${this.id}] thread ${threadId}`);
|
|
333
346
|
this.setState("idle");
|
|
334
347
|
this.readUsage();
|
|
@@ -385,11 +398,13 @@ export class CodexPeer extends BasePeer {
|
|
|
385
398
|
this.turnDeliveries.delete(nativeTurn);
|
|
386
399
|
}
|
|
387
400
|
const inReplyTo = this.injected;
|
|
401
|
+
const to = replyAudience(this.answering);
|
|
388
402
|
this.injected = undefined;
|
|
403
|
+
this.answering = [];
|
|
389
404
|
this.deltas.clear();
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo } : {});
|
|
405
|
+
// Addressed like every other adapter's answer (issue #3): each sender it answers, including one steered in;
|
|
406
|
+
// the bus maps a condensed delivery's `digest` back to the senders it replaced, a steer beside it or not.
|
|
407
|
+
if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo, to } : {});
|
|
393
408
|
this.lastAnswer = "";
|
|
394
409
|
this.setState("idle");
|
|
395
410
|
}
|
package/src/hub/bus.ts
CHANGED
|
@@ -130,9 +130,12 @@ export class Bus {
|
|
|
130
130
|
const last = this.lastDelivery.get(peer.id);
|
|
131
131
|
const answers = opts?.inReplyTo?.id !== undefined && last?.out.some((e) => e.id === opts.inReplyTo!.id);
|
|
132
132
|
const originals = answers ? last!.originals : undefined;
|
|
133
|
+
// `digest` in the audience stands for the condensed delivery even when a steer, not the digest, is the parent.
|
|
134
|
+
const condensed = !!last?.out.some((e) => e.from === DIGEST);
|
|
135
|
+
const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
|
|
133
136
|
this.publish(newEnvelope(peer.id, body, {
|
|
134
137
|
...opts,
|
|
135
|
-
...(opts?.to?.length ? { to: resolveTo(opts.to,
|
|
138
|
+
...(opts?.to?.length ? { to: resolveTo(opts.to, audienceOriginals) } : {}),
|
|
136
139
|
priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals),
|
|
137
140
|
}));
|
|
138
141
|
};
|
|
@@ -387,6 +390,11 @@ export class Bus {
|
|
|
387
390
|
return () => this.taps.delete(fn);
|
|
388
391
|
}
|
|
389
392
|
|
|
393
|
+
/** Paused by the console or the budget relay, whatever the adapter's own state. */
|
|
394
|
+
isPaused(id: PeerId): boolean {
|
|
395
|
+
return this.paused.has(id);
|
|
396
|
+
}
|
|
397
|
+
|
|
390
398
|
/** The adapter's state, or `paused` while the peer is held by `pause()`. */
|
|
391
399
|
stateOf(id: PeerId): PeerState {
|
|
392
400
|
const state = this.peers.get(id)?.state ?? "offline";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { childEnv } from "./child-process.ts";
|
|
3
|
+
|
|
4
|
+
export interface CheckResult {
|
|
5
|
+
code: number | null;
|
|
6
|
+
timedOut: boolean;
|
|
7
|
+
/** Killed because the hub stopped: not a verdict on the work. */
|
|
8
|
+
interrupted?: boolean;
|
|
9
|
+
/** The last lines of stdout and stderr together, capped. */
|
|
10
|
+
tail: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TAIL_LINES = 40;
|
|
14
|
+
const TAIL_CHARS = 4000;
|
|
15
|
+
const KEEP_CHARS = 64 * 1024;
|
|
16
|
+
/** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
|
|
17
|
+
const DRAIN_MS = 500;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Why the completion checks of issue #7 must not run, or undefined when git confirms nobody committed the config.
|
|
21
|
+
* A command a cloned repository ships would otherwise run on the owner's machine the first time an agent marks a task
|
|
22
|
+
* done. Unknown is not untracked: no repository, no git, or a git error all refuse. The directory itself counts too (a
|
|
23
|
+
* committed symlink or submodule at `.agenthub`), and so does any spelling of the path, since macOS opens either.
|
|
24
|
+
*/
|
|
25
|
+
export function checksRefusal(cwd: string): string | undefined {
|
|
26
|
+
const r = spawnSync("git", ["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
|
|
27
|
+
if (r.status !== 0) return "git could not confirm that .agenthub/config.json is untracked";
|
|
28
|
+
const tracked = r.stdout.split("\0").some((row) => {
|
|
29
|
+
const path = row.slice(row.indexOf("\t") + 1).toLowerCase();
|
|
30
|
+
return path === ".agenthub" || path === ".agenthub/config.json";
|
|
31
|
+
});
|
|
32
|
+
return tracked ? ".agenthub/config.json is committed to git" : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
|
|
36
|
+
export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>): Promise<CheckResult> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let out = "";
|
|
39
|
+
let timedOut = false;
|
|
40
|
+
let interrupted = false;
|
|
41
|
+
let settled = false;
|
|
42
|
+
let exited = false;
|
|
43
|
+
const child = spawn("sh", ["-c", command], { cwd, env: childEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
44
|
+
const killGroup = () => {
|
|
45
|
+
try {
|
|
46
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
47
|
+
} catch {
|
|
48
|
+
// already gone
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const stop = () => {
|
|
52
|
+
if (exited) return; // finished before the hub stopped: its own result stands
|
|
53
|
+
interrupted = true;
|
|
54
|
+
killGroup();
|
|
55
|
+
};
|
|
56
|
+
running?.add(stop);
|
|
57
|
+
const keep = (chunk: string) => {
|
|
58
|
+
out += chunk;
|
|
59
|
+
if (out.length > KEEP_CHARS) out = out.slice(-KEEP_CHARS);
|
|
60
|
+
};
|
|
61
|
+
for (const s of [child.stdout, child.stderr]) {
|
|
62
|
+
s?.setEncoding("utf8"); // decodes across chunk edges, so Korean output is not cut into U+FFFD
|
|
63
|
+
s?.on("data", keep);
|
|
64
|
+
}
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
timedOut = true;
|
|
67
|
+
killGroup();
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
const done = (code: number | null) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
running?.delete(stop);
|
|
74
|
+
child.stdout?.destroy();
|
|
75
|
+
child.stderr?.destroy();
|
|
76
|
+
const tail = out.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
|
|
77
|
+
resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail });
|
|
78
|
+
};
|
|
79
|
+
child.on("error", (e) => {
|
|
80
|
+
out += `\n${e.message}`;
|
|
81
|
+
done(null);
|
|
82
|
+
});
|
|
83
|
+
child.on("exit", (code) => {
|
|
84
|
+
exited = true;
|
|
85
|
+
clearTimeout(timer); // it finished in time; only its output may still be on the way
|
|
86
|
+
killGroup(); // a background job it left behind (sh has no job control, so it is still in the group)
|
|
87
|
+
setTimeout(() => done(code), DRAIN_MS);
|
|
88
|
+
});
|
|
89
|
+
child.on("close", (code) => done(code));
|
|
90
|
+
});
|
|
91
|
+
}
|
package/src/hub/daemon.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { childEnv } from "./child-process.ts";
|
|
7
|
+
import { checksRefusal, runCheck } from "./checks.ts";
|
|
5
8
|
import type { ServerWebSocket } from "bun";
|
|
6
9
|
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
7
10
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -16,7 +19,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
16
19
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
17
20
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
18
21
|
import { Briefs } from "../memory/brief.ts";
|
|
19
|
-
import { Board, type TaskClass } from "./board.ts";
|
|
22
|
+
import { Board, CLASSES, type TaskClass } from "./board.ts";
|
|
20
23
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
21
24
|
import { HUB } from "./envelope.ts";
|
|
22
25
|
import { trimToTokens } from "../memory/recall.ts";
|
|
@@ -53,6 +56,12 @@ export interface HubConfig {
|
|
|
53
56
|
pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
|
|
54
57
|
mlx: Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
|
|
55
58
|
local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[] };
|
|
59
|
+
/** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
|
|
60
|
+
approvals: { timeout_s: number; notify: boolean };
|
|
61
|
+
/** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
|
|
62
|
+
tasks: { release_after_min: number };
|
|
63
|
+
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
64
|
+
checks: { timeout_s: number; [cls: string]: string | number };
|
|
56
65
|
}
|
|
57
66
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
58
67
|
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
@@ -69,6 +78,10 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
69
78
|
pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
|
|
70
79
|
mlx: { provider: "ollama", model: "agenthub-fast-mlx:4b-8k", sourceModel: "qwen3.5:4b-mlx", contextWindow: 8192, maxInputTokens: 6000, maxTokens: 2048, maxConcurrency: 1 },
|
|
71
80
|
local: { deny: [], bash_network: false, max_steps: 30, read_allow: [] },
|
|
81
|
+
// Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
|
|
82
|
+
approvals: { timeout_s: 120, notify: false },
|
|
83
|
+
tasks: { release_after_min: 30 },
|
|
84
|
+
checks: { timeout_s: 600 },
|
|
72
85
|
};
|
|
73
86
|
|
|
74
87
|
export { stateDirFor };
|
|
@@ -98,6 +111,12 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
98
111
|
omniroute: { ...DEFAULT_CONFIG.omniroute, ...file.omniroute },
|
|
99
112
|
local: { ...DEFAULT_CONFIG.local, ...file.local },
|
|
100
113
|
pi: { ...DEFAULT_CONFIG.pi, ...file.pi },
|
|
114
|
+
approvals: {
|
|
115
|
+
timeout_s: file.approvals?.timeout_s ?? DEFAULT_CONFIG.approvals.timeout_s,
|
|
116
|
+
notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
|
|
117
|
+
},
|
|
118
|
+
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
|
|
119
|
+
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
101
120
|
mlx,
|
|
102
121
|
};
|
|
103
122
|
} catch (error) {
|
|
@@ -121,6 +140,8 @@ export interface DaemonOptions {
|
|
|
121
140
|
/** Auto-approve ACP permission requests with the agent's allow_once option. */
|
|
122
141
|
unattended?: boolean;
|
|
123
142
|
permissionTimeoutMs?: number;
|
|
143
|
+
/** Tests replace the desktop notifier; the default posts a macOS notification when approvals.notify is on. */
|
|
144
|
+
notifier?: (title: string, body: string) => void;
|
|
124
145
|
/** Called once when shutdown begins, however it was triggered; the lifecycle uses it to arm a hard deadline. */
|
|
125
146
|
onShutdownStart?: () => void;
|
|
126
147
|
/** How often the daemon checks that its project root and state dir still exist. Tests shrink this. */
|
|
@@ -301,6 +322,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
301
322
|
};
|
|
302
323
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
303
324
|
startupCleanup.push(() => board.close());
|
|
325
|
+
// Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
|
|
326
|
+
const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => (CLASSES as readonly string[]).includes(k) && typeof v === "string" && v.trim())) as Record<string, string>;
|
|
327
|
+
const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
|
|
328
|
+
if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
|
|
329
|
+
const checksRefused = Object.keys(checkCommands).length ? checksRefusal(opts.cwd) : undefined;
|
|
330
|
+
if (checksRefused) log(`checks ignored: ${checksRefused}, and only a file nobody committed may choose commands the hub runs`);
|
|
331
|
+
const checksAllowed = Object.keys(checkCommands).length > 0 && !checksRefused;
|
|
332
|
+
const checkTimeoutS = typeof config.checks.timeout_s === "number" && config.checks.timeout_s >= 1 ? Math.min(config.checks.timeout_s, 3600) : 600;
|
|
333
|
+
const runningChecks = new Set<() => void>();
|
|
334
|
+
let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
|
|
335
|
+
const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
|
|
304
336
|
const tasks = new Tasks({
|
|
305
337
|
board,
|
|
306
338
|
bus,
|
|
@@ -309,6 +341,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
309
341
|
project: chain.at(-1)!,
|
|
310
342
|
...(config.memory.enabled ? { memory, briefs: new Briefs(memory, chain.at(-1)!, config.memory.brief_items) } : {}),
|
|
311
343
|
notify,
|
|
344
|
+
...(checksAllowed ? { check: (cls: string) => checkCommands[cls], runCheck: async (command: string) => (checksClosed ? interrupted : runCheck(command, opts.cwd, checkTimeoutS * 1000, runningChecks)) } : {}),
|
|
312
345
|
// What a peer learned reaches the peers working now, on their next delivery, instead of at their next session (#68).
|
|
313
346
|
// Fail-open: the note is already saved, so a bus that cannot persist costs the sharing, not the tool call.
|
|
314
347
|
share: (by, line) => {
|
|
@@ -318,6 +351,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
318
351
|
log(`note from ${by} not shared: ${(e as Error).message}`);
|
|
319
352
|
}
|
|
320
353
|
},
|
|
354
|
+
tell: (peer, line) => {
|
|
355
|
+
try {
|
|
356
|
+
bus.note(peer, line);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
log(`note for ${peer} not kept: ${(e as Error).message}`);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
321
361
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
322
362
|
});
|
|
323
363
|
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
@@ -435,8 +475,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
435
475
|
return line(tasks.accept(by, a.id));
|
|
436
476
|
case "hub_task_decline":
|
|
437
477
|
return line(await tasks.decline(by, a.id, a.reason));
|
|
438
|
-
case "hub_task_done":
|
|
439
|
-
|
|
478
|
+
case "hub_task_done": {
|
|
479
|
+
const t = await tasks.done(by, a.id, a.summary, a.refs);
|
|
480
|
+
return tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
|
|
481
|
+
}
|
|
440
482
|
case "hub_review":
|
|
441
483
|
return line(await tasks.review(by, a.id, a.verdict, a.note));
|
|
442
484
|
case "hub_remember":
|
|
@@ -481,8 +523,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
481
523
|
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
482
524
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
|
|
483
525
|
};
|
|
526
|
+
let releasing = false; // gone-owner release (#6): one run at a time, and a recovery commit waits for it
|
|
484
527
|
const recoveryReady = () => {
|
|
485
|
-
if (!recoveryActive() || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
528
|
+
if (!recoveryActive() || releasing || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
486
529
|
if (!recoveryPeerSnapshot) return true;
|
|
487
530
|
const current = recoveryPeers();
|
|
488
531
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -577,17 +620,70 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
577
620
|
// message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
|
|
578
621
|
bus.onQueues = () => { if (!stopping) writeStatus(); };
|
|
579
622
|
|
|
623
|
+
// When each peer went offline; a peer never seen attached counts from hub start (issue #6).
|
|
624
|
+
const offlineSince = new Map<PeerId, number>();
|
|
625
|
+
const hubStartedAt = Date.now();
|
|
626
|
+
const releaseGoneOwners = async () => {
|
|
627
|
+
const limit = config.tasks.release_after_min;
|
|
628
|
+
if (!(limit > 0) || releasing || stopping || recoveryActive()) return;
|
|
629
|
+
releasing = true;
|
|
630
|
+
try {
|
|
631
|
+
await releaseOwners(limit);
|
|
632
|
+
} finally {
|
|
633
|
+
releasing = false;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
const releaseOwners = async (limit: number) => {
|
|
637
|
+
const owners = new Set(board.list().filter((t) => t.owner && ["proposed", "in_progress", "changes_requested"].includes(t.state)).map((t) => t.owner!));
|
|
638
|
+
for (const owner of owners) {
|
|
639
|
+
if (stopping || recoveryActive()) return;
|
|
640
|
+
if (owner === USER || owner === "hub" || bus.isPaused(owner) || bus.stateOf(owner) !== "offline") continue;
|
|
641
|
+
const since = offlineSince.get(owner) ?? hubStartedAt;
|
|
642
|
+
if (Date.now() - since < limit * 60_000) continue;
|
|
643
|
+
const moved = await tasks.releaseFromGone(owner, Math.round((Date.now() - since) / 60_000)).catch((e) => (log(`releasing tasks of ${owner} failed: ${(e as Error).message}`), []));
|
|
644
|
+
for (const m of moved) notify(`task ${m.title} moved from ${owner} (offline) to ${m.to ?? "nobody"}`);
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
const releaseTimer = setInterval(() => void releaseGoneOwners(), 60_000);
|
|
648
|
+
releaseTimer.unref?.();
|
|
649
|
+
intervals.push(releaseTimer);
|
|
650
|
+
|
|
580
651
|
bus.tap((e) => {
|
|
581
652
|
e = redact(e);
|
|
582
653
|
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
583
654
|
if (uiEvents.length > 200) uiEvents.shift();
|
|
584
|
-
if (e.t === "state")
|
|
655
|
+
if (e.t === "state") {
|
|
656
|
+
log(`state ${e.peer} -> ${e.state}`);
|
|
657
|
+
if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
|
|
658
|
+
else offlineSince.delete(e.peer);
|
|
659
|
+
}
|
|
585
660
|
else if (e.t === "undeliverable") log(`UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}`);
|
|
586
661
|
else if (e.t === "overflow") log(`OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
587
662
|
else log(`msg ${e.env.from} -> ${e.env.to?.join(",") ?? "*"} ${e.env.priority} hop=${e.env.hop}${e.dropped ? ` NOT DELIVERED(${e.dropped})` : ""}: ${e.env.body.slice(0, 200)}`);
|
|
588
663
|
if (!stopping) writeStatus();
|
|
589
664
|
});
|
|
590
665
|
|
|
666
|
+
const approvalTimeoutS = (() => {
|
|
667
|
+
const s = config.approvals.timeout_s;
|
|
668
|
+
if (typeof s === "number" && s >= 30 && s <= 3600) return s;
|
|
669
|
+
log(`approvals.timeout_s ${JSON.stringify(s)} is outside 30-3600 seconds; using 120`);
|
|
670
|
+
return 120;
|
|
671
|
+
})();
|
|
672
|
+
let notifierBroken = false;
|
|
673
|
+
// Peer and tool name only: a title can quote what a PII turn is about to write (issue #5).
|
|
674
|
+
const desktop = opts.notifier ?? ((title: string, body: string) => {
|
|
675
|
+
const quote = (t: string) => `"${t.replace(/[\\"]/g, "\\$&")}"`;
|
|
676
|
+
const child = spawn("osascript", ["-e", `display notification ${quote(body)} with title ${quote(title)}`], { stdio: "ignore", env: childEnv(process.env) });
|
|
677
|
+
const kill = setTimeout(() => child.kill(), 5000);
|
|
678
|
+
kill.unref?.();
|
|
679
|
+
const failed = (why: string) => {
|
|
680
|
+
if (!notifierBroken) log(`desktop notification failed (${why}); later ones are not reported`);
|
|
681
|
+
notifierBroken = true;
|
|
682
|
+
};
|
|
683
|
+
child.on("error", (e: Error) => (clearTimeout(kill), failed(e.message)));
|
|
684
|
+
child.on("exit", (code: number | null, signal: string | null) => (clearTimeout(kill), code || signal ? failed(signal ? `osascript stopped by ${signal}` : `osascript exit ${code}`) : undefined));
|
|
685
|
+
});
|
|
686
|
+
|
|
591
687
|
async function onPermission(req: PermissionRequest): Promise<string | undefined> {
|
|
592
688
|
if (stopping) return undefined;
|
|
593
689
|
if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
|
|
@@ -597,11 +693,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
597
693
|
const title = req.title.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
598
694
|
// The title can quote what a PII turn is about to write. It is for the person approving, on the console; the log
|
|
599
695
|
// (which `ahub ask` reads as evidence) only records that a request was made.
|
|
600
|
-
|
|
601
|
-
|
|
696
|
+
const timeoutMs = opts.permissionTimeoutMs ?? approvalTimeoutS * 1000;
|
|
697
|
+
log(`permission ${id} requested by ${req.peer} (${title.length} chars, shown on the console; cancelled after ${timeoutMs / 1000}s)`);
|
|
698
|
+
// The tool name is for the desktop notice only: the console push keeps its shape.
|
|
699
|
+
const { tool: _tool, ...shown } = req;
|
|
700
|
+
const push = JSON.stringify({ t: "permission", id, ...shown, title });
|
|
602
701
|
for (const c of consoles) if (c.data.tail) c.send(push);
|
|
702
|
+
if (config.approvals.notify) {
|
|
703
|
+
try {
|
|
704
|
+
desktop("agent-hub", `${req.peer} asks for approval${req.tool ? `: ${req.tool}` : ""} (ahub tail)`);
|
|
705
|
+
} catch {
|
|
706
|
+
// never let a notifier break the request itself
|
|
707
|
+
}
|
|
708
|
+
}
|
|
603
709
|
return new Promise((resolve) => {
|
|
604
|
-
const timer = setTimeout(() =>
|
|
710
|
+
const timer = setTimeout(() => {
|
|
711
|
+
notify(`permission ${id} from ${req.peer} was not answered within ${Math.round(timeoutMs / 1000)}s and was cancelled`);
|
|
712
|
+
done(undefined);
|
|
713
|
+
}, timeoutMs);
|
|
605
714
|
const done = (optionId: string | undefined) => {
|
|
606
715
|
clearTimeout(timer);
|
|
607
716
|
permissions.delete(id);
|
|
@@ -1278,6 +1387,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1278
1387
|
}
|
|
1279
1388
|
async function stopOnce(): Promise<void> {
|
|
1280
1389
|
stopping = true;
|
|
1390
|
+
checksClosed = true;
|
|
1391
|
+
for (const kill of runningChecks) kill();
|
|
1281
1392
|
log("hub stopping");
|
|
1282
1393
|
writeStatus();
|
|
1283
1394
|
dashboard?.stop();
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
26
26
|
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
|
|
27
27
|
tool("hub_task_accept", "Take a task that was assigned to you.", { id }, ["id"]),
|
|
28
28
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
|
|
29
|
-
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
29
|
+
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
30
30
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] } }),
|
|
31
31
|
tool("hub_review", "Give your verdict on a task you were asked to review. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str }, ["id", "verdict"]),
|
|
32
32
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
|
package/src/hub/tasks.ts
CHANGED
|
@@ -15,14 +15,22 @@ export interface TasksDeps {
|
|
|
15
15
|
memory?: MemoryClient;
|
|
16
16
|
/** a line for the human: console tail and hub.log */
|
|
17
17
|
notify: (line: string) => void;
|
|
18
|
+
/** The completion check configured for a class, if any (local config only; issue #7). */
|
|
19
|
+
check?: (cls: TaskClass) => string | undefined;
|
|
20
|
+
/** Runs one check; the hub runs them one at a time. */
|
|
21
|
+
runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
|
|
18
22
|
/** Hands a saved note to every other peer; it rides on their next delivery. */
|
|
19
23
|
share?: (by: PeerId, line: string) => void;
|
|
24
|
+
/** Hands one peer a line that rides on its next delivery, without a turn of its own (issue #6). */
|
|
25
|
+
tell?: (peer: PeerId, line: string) => void;
|
|
20
26
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
21
27
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
const ESCALATE_AFTER = 2;
|
|
25
31
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
32
|
+
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
33
|
+
const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
|
|
26
34
|
|
|
27
35
|
/** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
|
|
28
36
|
const samePlace = (a: string, b: string) => {
|
|
@@ -90,10 +98,14 @@ export class Tasks {
|
|
|
90
98
|
if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
91
99
|
triaged = !!cls;
|
|
92
100
|
}
|
|
101
|
+
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
102
|
+
const defaulted = !cls && input.owner === by;
|
|
103
|
+
if (defaulted) cls = "implement";
|
|
93
104
|
if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
|
|
94
105
|
const draft = { ...text, class: cls };
|
|
95
106
|
let task = this.d.board.propose(by, { ...draft, signals });
|
|
96
107
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
108
|
+
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
97
109
|
this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
|
|
98
110
|
// Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
|
|
99
111
|
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
|
|
@@ -105,18 +117,65 @@ export class Tasks {
|
|
|
105
117
|
* sides: their refs are hidden from cloud peers.
|
|
106
118
|
*/
|
|
107
119
|
overlaps(task: Task, forOwner = true): string {
|
|
108
|
-
const
|
|
109
|
-
if (!mine.length || this.isPii(task)) return "";
|
|
110
|
-
const hits = this.d.board.list().flatMap((t) => {
|
|
111
|
-
if (t.id === task.id || !t.owner || t.owner === task.owner || !OPEN.includes(t.state) || this.isPii(t)) return [];
|
|
112
|
-
const shared = mine.filter((p) => (t.refs.paths ?? []).some((q) => samePlace(p, q)));
|
|
113
|
-
return shared.length ? [`#${t.id} (owner ${t.owner}) on ${shared.join(", ")}`] : [];
|
|
114
|
-
});
|
|
120
|
+
const hits = this.overlapHits(task).map((h) => `#${h.task.id} (owner ${h.task.owner}) on ${h.paths.join(", ")}`);
|
|
115
121
|
if (!hits.length) return "";
|
|
116
122
|
const who = forOwner ? "Settle it with that owner via hub_send before editing those paths." : `${task.owner ?? "Whoever takes it"} is told to settle it.`;
|
|
117
123
|
return `Overlaps ${hits.join("; ")}. ${who}`;
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
/** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
|
|
127
|
+
private releasing: PeerId | undefined;
|
|
128
|
+
|
|
129
|
+
private overlapHits(task: Task): { task: Task; paths: string[] }[] {
|
|
130
|
+
const mine = task.refs.paths ?? [];
|
|
131
|
+
if (!mine.length || this.isPii(task)) return [];
|
|
132
|
+
return this.d.board.list().flatMap((t) => {
|
|
133
|
+
if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
|
|
134
|
+
const paths = mine.filter((p) => (t.refs.paths ?? []).some((q) => samePlace(p, q)));
|
|
135
|
+
return paths.length ? [{ task: t, paths }] : [];
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The earlier owner hears of an overlap on its next delivery, costing it no turn; the newcomer settles it (#6). */
|
|
140
|
+
private tellEarlierOwners(task: Task): void {
|
|
141
|
+
if (!task.owner || !this.d.tell) return;
|
|
142
|
+
for (const hit of this.overlapHits(task)) {
|
|
143
|
+
if (hit.task.owner === USER || hit.task.owner === HUB) continue;
|
|
144
|
+
this.d.tell(hit.task.owner!, noteLine(HUB, "finding", `task #${task.id} (owner ${task.owner}) now overlaps your #${hit.task.id} on ${hit.paths.join(", ")}; ${task.owner} is told to settle it`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* An owner offline past the limit loses its open tasks back to routing, through the same path a budget pause uses.
|
|
150
|
+
* Only when some other peer can take a task: with nobody to hand it to, it stays with its owner.
|
|
151
|
+
*/
|
|
152
|
+
async releaseFromGone(peer: PeerId, minutes: number): Promise<{ id: number; title: string; to: PeerId | null }[]> {
|
|
153
|
+
const moved: { id: number; title: string; to: PeerId | null }[] = [];
|
|
154
|
+
const why = `owner ${peer} offline for ${minutes} min`;
|
|
155
|
+
const ids = this.d.board.list().filter((t) => t.owner === peer && OPEN.includes(t.state)).map((t) => t.id);
|
|
156
|
+
this.releasing = peer;
|
|
157
|
+
try {
|
|
158
|
+
for (const id of ids) {
|
|
159
|
+
// Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
|
|
160
|
+
const task = this.d.board.get(id);
|
|
161
|
+
if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
|
|
162
|
+
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer] }).owner) continue;
|
|
163
|
+
try {
|
|
164
|
+
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
165
|
+
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", note: why });
|
|
166
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
|
|
167
|
+
// The gone owner hears it on its next delivery, if it comes back mid-work.
|
|
168
|
+
if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
|
|
169
|
+
} catch (e) {
|
|
170
|
+
this.d.notify(`task ${this.publicTitle(task)}: could not be released from ${peer}: ${(e as Error).message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
this.releasing = undefined;
|
|
175
|
+
}
|
|
176
|
+
return moved;
|
|
177
|
+
}
|
|
178
|
+
|
|
120
179
|
/** Same code as assignment, without doing it. */
|
|
121
180
|
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
122
181
|
const routing = this.d.routing();
|
|
@@ -140,7 +199,10 @@ export class Tasks {
|
|
|
140
199
|
}
|
|
141
200
|
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`);
|
|
142
201
|
const overlap = this.overlaps(next, false);
|
|
143
|
-
if (overlap)
|
|
202
|
+
if (overlap) {
|
|
203
|
+
this.d.notify(`task ${this.publicTitle(next)} (${next.owner}): ${overlap}`);
|
|
204
|
+
this.tellEarlierOwners(next);
|
|
205
|
+
}
|
|
144
206
|
if (opts.claim && a.owner === by) {
|
|
145
207
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
146
208
|
this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
@@ -197,13 +259,64 @@ export class Tasks {
|
|
|
197
259
|
return this.assignOwner(back, HUB, { event: "reassigned", clearOnFail: true });
|
|
198
260
|
}
|
|
199
261
|
|
|
262
|
+
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
263
|
+
private readonly checking = new Map<number, PeerId | null>();
|
|
264
|
+
private checkQueue: Promise<void> = Promise.resolve();
|
|
265
|
+
|
|
266
|
+
/** Whether a completion check is still running for this task. */
|
|
267
|
+
isChecking = (id: number) => this.checking.has(id);
|
|
268
|
+
|
|
200
269
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
201
270
|
let task = this.need(id);
|
|
202
271
|
this.mine(task, by, "owner");
|
|
203
272
|
if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
|
|
273
|
+
if (this.checking.has(task.id)) {
|
|
274
|
+
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
275
|
+
throw new Error(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
276
|
+
}
|
|
204
277
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }); // done without a separate accept
|
|
278
|
+
const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
|
|
279
|
+
if (!command) return this.complete(task, by, summary, refs);
|
|
280
|
+
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
281
|
+
this.checking.set(task.id, task.owner);
|
|
282
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
|
|
283
|
+
this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
284
|
+
const seen = { events: pending.history.length, owner: pending.owner };
|
|
285
|
+
this.checkQueue = this.checkQueue
|
|
286
|
+
.then(() => this.finishChecked(pending.id, by, summary, command, seen))
|
|
287
|
+
.catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`));
|
|
288
|
+
return pending;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
|
|
292
|
+
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
293
|
+
this.checking.delete(id);
|
|
294
|
+
const task = this.d.board.get(id);
|
|
295
|
+
if (!task) return;
|
|
296
|
+
const outcome = `${command} -> ${result.interrupted ? "interrupted by a hub stop" : result.timedOut ? "timed out" : `exit ${result.code ?? "?"}`}`;
|
|
297
|
+
// Not a verdict on the work, and nobody is listening any more: the owner marks the task done again (spec).
|
|
298
|
+
if (result.interrupted) return void this.d.board.update(id, HUB, "check interrupted", {}, outcome);
|
|
299
|
+
const pii = this.isPii(task);
|
|
300
|
+
// Moved on meanwhile (escalated, reassigned, released, and maybe back again): the result is kept, nothing else changes.
|
|
301
|
+
if (task.state !== "in_progress" || task.history.slice(seen.events).some((h) => !QUIET_EVENTS.has(h.event))) {
|
|
302
|
+
this.d.board.update(id, HUB, "check finished late", {}, outcome);
|
|
303
|
+
if (task.state === "in_progress" && task.owner === seen.owner) this.tell(task, `Task #${id}: the check of your earlier hub_task_done finished after the task changed hands (${outcome}). Call hub_task_done again when it is ready.`, pii);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (result.code === 0 && !result.timedOut) {
|
|
307
|
+
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
|
|
308
|
+
// The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
|
|
309
|
+
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
313
|
+
this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
|
|
314
|
+
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
|
|
205
318
|
const reviewer = task.reviewer;
|
|
206
|
-
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, summary);
|
|
319
|
+
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
|
|
207
320
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
|
|
208
321
|
if (!reviewer) this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
209
322
|
else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
|
package/templates/config.json
CHANGED
|
@@ -23,5 +23,8 @@
|
|
|
23
23
|
"cf_client_id_file": "",
|
|
24
24
|
"cf_client_secret_file": ""
|
|
25
25
|
},
|
|
26
|
-
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] }
|
|
26
|
+
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
|
|
27
|
+
"approvals": { "timeout_s": 120 },
|
|
28
|
+
"tasks": { "release_after_min": 30 },
|
|
29
|
+
"checks": { "timeout_s": 600 }
|
|
27
30
|
}
|