@staix/agent-hub 0.30.0 → 0.31.0
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 +39 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +9 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +77 -16
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +493 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1569 -134
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +138 -7
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1054 T6: the detail line of a result, built at DISPLAY and never stored. A completion row keeps envelope ids only (`completions-store.ts`), so the head of the
|
|
3
|
+
* starting prompt is read from the envelope the bus still holds when the person looks, run through the hub's own screens, and thrown away after it is shown. Nothing here
|
|
4
|
+
* writes, logs or emits an event.
|
|
5
|
+
*
|
|
6
|
+
* Pure: the daemon passes the facts (the envelope it could resolve, whether the run was private, whether the text passed the hub's screens) and this decides what shows.
|
|
7
|
+
* A result with no readable envelope (the bus cache turned over, or the hub restarted) shows no detail: an honest absence, never a guess.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The longest head of a prompt that is shown: one short line, enough to recognise the request. */
|
|
11
|
+
export const PROMPT_HEAD_MAX = 120;
|
|
12
|
+
|
|
13
|
+
/** What a detail line is, so a test and the page can tell a stub from a head from an absence. */
|
|
14
|
+
export type ResultDetail =
|
|
15
|
+
| { kind: "head"; text: string }
|
|
16
|
+
| { kind: "stub"; text: string }
|
|
17
|
+
| { kind: "none" };
|
|
18
|
+
|
|
19
|
+
export const PRIVATE_DETAIL = "a private request";
|
|
20
|
+
export const WITHHELD_DETAIL = "request withheld by the privacy screen";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The head of a prompt as one line: whitespace and control characters collapsed, cut by code point (half an emoji is invalid text), with an ellipsis when it was cut.
|
|
24
|
+
* A hub header forged in the body is quoted by `sanitize` at the caller, not here: this only shapes text.
|
|
25
|
+
*/
|
|
26
|
+
export function promptHead(body: string, max: number = PROMPT_HEAD_MAX): string {
|
|
27
|
+
const flat = body.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]+/g, " ").trim();
|
|
28
|
+
const points = Array.from(flat);
|
|
29
|
+
return points.length > max ? `${points.slice(0, Math.max(0, max - 1)).join("").trimEnd()}\u2026` : flat;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DetailInput {
|
|
33
|
+
/** The run was private (a PII turn, or the peer held PII): its prompt is never shown. */
|
|
34
|
+
private: boolean;
|
|
35
|
+
/** The starting prompt's envelope body, when the bus could still resolve it. Absent: no detail. */
|
|
36
|
+
body?: string;
|
|
37
|
+
/** The hub's own screens (`boundedSafe`: secrets, PII patterns, private paths) cleared the head. Only asked for a non-private run with a body. */
|
|
38
|
+
safe?: (text: string) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** private -> stub (the body is never read); no body -> none; a head the screens refuse -> withheld stub; else the head. */
|
|
42
|
+
export function resultDetail(input: DetailInput): ResultDetail {
|
|
43
|
+
if (input.private) return { kind: "stub", text: PRIVATE_DETAIL };
|
|
44
|
+
if (input.body === undefined) return { kind: "none" };
|
|
45
|
+
const head = promptHead(input.body);
|
|
46
|
+
if (!head) return { kind: "none" };
|
|
47
|
+
// Screen the whole bounded head, not a prefix of it: a pattern cut in half by the bound must not slip through as two harmless halves.
|
|
48
|
+
if (!input.safe || !input.safe(head) || !input.safe(input.body.slice(0, 4000))) return { kind: "stub", text: WITHHELD_DETAIL };
|
|
49
|
+
return { kind: "head", text: head };
|
|
50
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1054 T2: the completions table in hub.db. Ids only, never a prompt or a reply: `prompt_envelope` and `reply_envelope` name envelopes the usual privacy path reads when a
|
|
3
|
+
* person looks. Three additive tables (no format bump): an older hub ignores them, which `Daemon` guards for recovery with the `completions` feature.
|
|
4
|
+
*
|
|
5
|
+
* - `completion_runs`: one row per OPEN run (a person started it, it has not settled). A restart settles each one `interrupted`.
|
|
6
|
+
* - `completions`: one row per settled run, keyed by the run's first turn id, so a repeated restart or a replayed settle can never make a second.
|
|
7
|
+
* - `completion_receipts`: created here for the next task (receipts); nothing writes it yet.
|
|
8
|
+
*/
|
|
9
|
+
import { openHubDatabase, type Database } from "./db-schema.ts";
|
|
10
|
+
import { OUTCOMES, type Outcome, type Run } from "./completions.ts";
|
|
11
|
+
|
|
12
|
+
export interface Completion {
|
|
13
|
+
id: string;
|
|
14
|
+
peer: string;
|
|
15
|
+
runTurn: string;
|
|
16
|
+
lastTurn: string;
|
|
17
|
+
outcome: Outcome;
|
|
18
|
+
settledAt: number;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
promptEnvelope?: string;
|
|
21
|
+
replyEnvelope?: string;
|
|
22
|
+
private: boolean;
|
|
23
|
+
}
|
|
24
|
+
interface RawCompletion {
|
|
25
|
+
id: string; peer: string; run_turn: string; last_turn: string; outcome: string; settled_at: number; started_at: number;
|
|
26
|
+
prompt_envelope: string | null; reply_envelope: string | null; private: number;
|
|
27
|
+
}
|
|
28
|
+
const completionOf = (raw: RawCompletion): Completion => ({
|
|
29
|
+
id: raw.id, peer: raw.peer, runTurn: raw.run_turn, lastTurn: raw.last_turn, outcome: raw.outcome as Outcome, settledAt: raw.settled_at, startedAt: raw.started_at,
|
|
30
|
+
...(raw.prompt_envelope ? { promptEnvelope: raw.prompt_envelope } : {}), ...(raw.reply_envelope ? { replyEnvelope: raw.reply_envelope } : {}), private: raw.private === 1,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** An open run as the store keeps it: the ids a restart needs to settle it, nothing else. */
|
|
34
|
+
export interface OpenRun { peer: string; runTurn: string; lastTurn: string; startedAt: number; promptEnvelope?: string; private: boolean }
|
|
35
|
+
|
|
36
|
+
export class CompletionStore {
|
|
37
|
+
private readonly db: Database;
|
|
38
|
+
constructor(dbPath: string) {
|
|
39
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
40
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS completion_runs (
|
|
41
|
+
run_turn TEXT PRIMARY KEY, peer TEXT NOT NULL, last_turn TEXT NOT NULL, started_at INTEGER NOT NULL, prompt_envelope TEXT, private INTEGER NOT NULL DEFAULT 0)`);
|
|
42
|
+
this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS completion_runs_by_peer ON completion_runs(peer)");
|
|
43
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS completions (
|
|
44
|
+
id TEXT PRIMARY KEY, run_turn TEXT NOT NULL UNIQUE, peer TEXT NOT NULL, last_turn TEXT NOT NULL,
|
|
45
|
+
outcome TEXT NOT NULL CHECK (outcome IN (${OUTCOMES.map(outcome => `'${outcome}'`).join(",")})),
|
|
46
|
+
settled_at INTEGER NOT NULL, started_at INTEGER NOT NULL, prompt_envelope TEXT, reply_envelope TEXT, private INTEGER NOT NULL DEFAULT 0)`);
|
|
47
|
+
this.db.run("CREATE INDEX IF NOT EXISTS completions_by_peer ON completions(peer, settled_at)");
|
|
48
|
+
// Unread means no receipt. One receipt per completion at most (UNIQUE), so a double mark is one write and a mark-unread has exactly one row to delete.
|
|
49
|
+
this.db.run("CREATE TABLE IF NOT EXISTS completion_receipts (completion_id TEXT NOT NULL, seen_at INTEGER NOT NULL, surface TEXT NOT NULL)");
|
|
50
|
+
this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS completion_receipts_by_completion ON completion_receipts(completion_id)");
|
|
51
|
+
}
|
|
52
|
+
close(): void { try { this.db.close(); } catch { /* already closed */ } }
|
|
53
|
+
|
|
54
|
+
/** Record an open run. One per peer: a second start for the same peer replaces nothing (the first run is still the run). */
|
|
55
|
+
openRun(run: Run): void {
|
|
56
|
+
this.db.query("INSERT OR IGNORE INTO completion_runs(run_turn, peer, last_turn, started_at, prompt_envelope, private) VALUES(?, ?, ?, ?, ?, ?)")
|
|
57
|
+
.run(run.runTurn, run.peer, run.lastTurn, run.startedAt, run.promptEnvelope ?? null, run.private ? 1 : 0);
|
|
58
|
+
}
|
|
59
|
+
/** A continuation moved the run's latest turn, or marked it private. */
|
|
60
|
+
touchRun(run: Run): void {
|
|
61
|
+
this.db.query("UPDATE completion_runs SET last_turn = ?, private = ? WHERE run_turn = ?").run(run.lastTurn, run.private ? 1 : 0, run.runTurn);
|
|
62
|
+
}
|
|
63
|
+
/** A run stopped quietly by the person, or one that never earned a completion: its row goes and nothing is created. */
|
|
64
|
+
dropRun(runTurn: string): void { this.db.query("DELETE FROM completion_runs WHERE run_turn = ?").run(runTurn); }
|
|
65
|
+
openRuns(): OpenRun[] {
|
|
66
|
+
return (this.db.query("SELECT * FROM completion_runs ORDER BY started_at, run_turn").all() as { run_turn: string; peer: string; last_turn: string; started_at: number; prompt_envelope: string | null; private: number }[])
|
|
67
|
+
.map(row => ({ peer: row.peer, runTurn: row.run_turn, lastTurn: row.last_turn, startedAt: row.started_at, ...(row.prompt_envelope ? { promptEnvelope: row.prompt_envelope } : {}), private: row.private === 1 }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Settle a run: write its completion and remove its open row in one transaction. Keyed by the run's first turn id (`UNIQUE`), so a replay, a second restart or two callers
|
|
72
|
+
* racing on the same run yield exactly one completion; the loser gets undefined and creates nothing.
|
|
73
|
+
*/
|
|
74
|
+
settle(run: OpenRun & { replyEnvelope?: string }, outcome: Outcome, now: number, id: string): Completion | undefined {
|
|
75
|
+
const write = this.db.transaction(() => {
|
|
76
|
+
const made = this.db.query(`INSERT OR IGNORE INTO completions(id, run_turn, peer, last_turn, outcome, settled_at, started_at, prompt_envelope, reply_envelope, private)
|
|
77
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, run.runTurn, run.peer, run.lastTurn, outcome, now, run.startedAt, run.promptEnvelope ?? null, run.replyEnvelope ?? null, run.private ? 1 : 0);
|
|
78
|
+
this.db.query("DELETE FROM completion_runs WHERE run_turn = ?").run(run.runTurn);
|
|
79
|
+
return made.changes === 1;
|
|
80
|
+
});
|
|
81
|
+
return write() ? this.get(id) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get(id: string): Completion | undefined {
|
|
85
|
+
const raw = this.db.query("SELECT * FROM completions WHERE id = ?").get(id) as RawCompletion | null;
|
|
86
|
+
return raw ? completionOf(raw) : undefined;
|
|
87
|
+
}
|
|
88
|
+
byRun(runTurn: string): Completion | undefined {
|
|
89
|
+
const raw = this.db.query("SELECT * FROM completions WHERE run_turn = ?").get(runTurn) as RawCompletion | null;
|
|
90
|
+
return raw ? completionOf(raw) : undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Newest first, bounded. */
|
|
93
|
+
list(limit = 100): Completion[] {
|
|
94
|
+
return (this.db.query("SELECT * FROM completions ORDER BY settled_at DESC, id LIMIT ?").all(limit) as RawCompletion[]).map(completionOf);
|
|
95
|
+
}
|
|
96
|
+
/** Completions with no receipt: what the next task shows as unread. */
|
|
97
|
+
unread(): Completion[] {
|
|
98
|
+
return (this.db.query("SELECT c.* FROM completions c WHERE NOT EXISTS (SELECT 1 FROM completion_receipts r WHERE r.completion_id = c.id) ORDER BY c.settled_at DESC, c.id").all() as RawCompletion[]).map(completionOf);
|
|
99
|
+
}
|
|
100
|
+
/** One completion as the reads show it: the record and whether a receipt exists. Newest first. */
|
|
101
|
+
listWithState(limit = 100, unreadOnly = false): (Completion & { seenAt?: number; surface?: string })[] {
|
|
102
|
+
// The unread predicate is part of the query, BEFORE the limit: filtering after a LIMIT would hide an old unread result behind newer read ones while status still counts it.
|
|
103
|
+
return (this.db.query(`SELECT c.*, r.seen_at AS seen_at, r.surface AS surface FROM completions c LEFT JOIN completion_receipts r ON r.completion_id = c.id ${unreadOnly ? "WHERE r.completion_id IS NULL" : ""} ORDER BY c.settled_at DESC, c.id LIMIT ?`).all(limit) as (RawCompletion & { seen_at: number | null; surface: string | null })[])
|
|
104
|
+
.map(raw => ({ ...completionOf(raw), ...(raw.seen_at !== null ? { seenAt: raw.seen_at } : {}), ...(raw.surface ? { surface: raw.surface } : {}) }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether any completion has been read: the one fact the person's mark-unread needs to know it has something to relight. */
|
|
108
|
+
hasRead(): boolean {
|
|
109
|
+
return this.db.query("SELECT 1 AS one FROM completion_receipts LIMIT 1").get() !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** How many completions have no receipt: the number status counts, so a listing can say when it showed fewer. */
|
|
113
|
+
unreadCount(): number {
|
|
114
|
+
return (this.db.query("SELECT COUNT(*) AS n FROM completions c WHERE NOT EXISTS (SELECT 1 FROM completion_receipts r WHERE r.completion_id = c.id)").get() as { n: number }).n;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Mark exactly one completion read. A compare-and-set on "no receipt yet": a completion that does not exist, or is already read, is refused with the reason, and of two callers
|
|
119
|
+
* racing on one completion exactly one writes. Never infers "seen" from anything but this call.
|
|
120
|
+
*/
|
|
121
|
+
markSeen(id: string, now: number, surface: string): { ok: true } | { ok: false; error: string } {
|
|
122
|
+
if (!this.get(id)) return { ok: false, error: "no such result" };
|
|
123
|
+
const made = this.db.query("INSERT OR IGNORE INTO completion_receipts(completion_id, seen_at, surface) VALUES(?, ?, ?)").run(id, now, surface);
|
|
124
|
+
return made.changes === 1 ? { ok: true } : { ok: false, error: "that result is already read" };
|
|
125
|
+
}
|
|
126
|
+
/** Mark every unread completion of the project read, in one transaction. Returns the ids it marked. */
|
|
127
|
+
markAllSeen(now: number, surface: string): string[] {
|
|
128
|
+
const write = this.db.transaction(() => {
|
|
129
|
+
const ids = (this.db.query("SELECT c.id FROM completions c WHERE NOT EXISTS (SELECT 1 FROM completion_receipts r WHERE r.completion_id = c.id) ORDER BY c.settled_at, c.id").all() as { id: string }[]).map(row => row.id);
|
|
130
|
+
for (const id of ids) this.db.query("INSERT OR IGNORE INTO completion_receipts(completion_id, seen_at, surface) VALUES(?, ?, ?)").run(id, now, surface);
|
|
131
|
+
return ids;
|
|
132
|
+
});
|
|
133
|
+
return write();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Relight the completion the person read LAST: its receipt, the latest one, is deleted. Ordered by `seen_at`; `markAllSeen` gives many rows one `seen_at`, so settle time and id break the
|
|
137
|
+
* tie, newest first. Undefined when nothing is read.
|
|
138
|
+
*/
|
|
139
|
+
unmarkLast(): string | undefined {
|
|
140
|
+
const write = this.db.transaction(() => {
|
|
141
|
+
const row = this.db.query("SELECT r.completion_id AS id FROM completion_receipts r JOIN completions c ON c.id = r.completion_id ORDER BY r.seen_at DESC, c.settled_at DESC, c.id DESC LIMIT 1").get() as { id: string } | null;
|
|
142
|
+
if (!row) return undefined;
|
|
143
|
+
this.db.query("DELETE FROM completion_receipts WHERE completion_id = ?").run(row.id);
|
|
144
|
+
return row.id;
|
|
145
|
+
});
|
|
146
|
+
return write();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Retention (#1054 review nit): the table is bounded. Past `keep` completions the oldest READ ones go, with their receipts; an unread completion is never deleted, so a person who has
|
|
151
|
+
* not looked yet loses nothing, and neither does an open run. A pruned completion's first-turn id could be settled again only by a replay of a run that ended long ago, which no code path makes.
|
|
152
|
+
* Returns how many completions it deleted.
|
|
153
|
+
*/
|
|
154
|
+
prune(keep = 500): number {
|
|
155
|
+
const write = this.db.transaction(() => {
|
|
156
|
+
const doomed = (this.db.query(`SELECT c.id FROM completions c JOIN completion_receipts r ON r.completion_id = c.id ORDER BY c.settled_at DESC, c.id DESC LIMIT -1 OFFSET ?`).all(Math.max(0, keep)) as { id: string }[]).map(row => row.id);
|
|
157
|
+
for (const id of doomed) { this.db.query("DELETE FROM completion_receipts WHERE completion_id = ?").run(id); this.db.query("DELETE FROM completions WHERE id = ?").run(id); }
|
|
158
|
+
return doomed.length;
|
|
159
|
+
});
|
|
160
|
+
return write();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What an older daemon would silently orphan: an open run, or a completion nobody has seen. */
|
|
164
|
+
hasPending(): boolean {
|
|
165
|
+
return (this.db.query(`SELECT (EXISTS(SELECT 1 FROM completion_runs)
|
|
166
|
+
OR EXISTS(SELECT 1 FROM completions c WHERE NOT EXISTS (SELECT 1 FROM completion_receipts r WHERE r.completion_id = c.id))) AS present`).get() as { present: number }).present === 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1054 T2: runs a person started, and the completion each one earns when it settles. Pure: the daemon supplies the facts (an adapter signal, the pending-work reads) and the
|
|
3
|
+
* store, this decides. Ids and counts only: no prompt or reply text is ever passed in, kept or returned.
|
|
4
|
+
*
|
|
5
|
+
* A run starts at the `turn_start` that consumes a person-originated delivery. It is settled only when ALL hold for its peer: the adapter's own settle signal fired, no queued
|
|
6
|
+
* delivery will start a continuation turn, no permission or question is open, and no owned child, running check or held delivery remains. While any is open the run reads as
|
|
7
|
+
* working (or, for a permission or question, as needing the person), even after a `turn_end`.
|
|
8
|
+
*
|
|
9
|
+
* An adapter that declares no `settle` signal creates no completion: nothing here guesses one (owner decision 2026-11-04, issue #1054).
|
|
10
|
+
*
|
|
11
|
+
* Restart reconstruction reads the store's open-run rows (`completions-store.ts`), not `events.jsonl`: a `turn_start` event does not say who started the turn, so events alone
|
|
12
|
+
* would invent completions for runs nobody asked for. A run is recorded, ids only, when it starts and removed when it settles or is stopped quietly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The facts one settle check reads, all counts the daemon already holds. */
|
|
16
|
+
export interface SettleFacts {
|
|
17
|
+
/** The adapter's settle signal fired for the run's last turn (a verified native Stop, `turn/completed`, `agent_settled`, a route outcome, a prompt response, `message_stop`). */
|
|
18
|
+
signal: boolean;
|
|
19
|
+
/** Deliveries in the peer's queue that will start another turn (task, chat, review, important), never a notice that starts none. */
|
|
20
|
+
queuedContinuations: number;
|
|
21
|
+
openPermissions: number;
|
|
22
|
+
openQuestions: number;
|
|
23
|
+
/** An owned child process the hub can see (a headless implementer's print child that outlived its turn). */
|
|
24
|
+
ownedChildren: number;
|
|
25
|
+
/** Check runs (`src/hub/check-runner.ts`) owned by this peer that are queued or running. */
|
|
26
|
+
runningChecks: number;
|
|
27
|
+
/** Rows held or in `needs_review`: uncertain, so never settled. */
|
|
28
|
+
heldDeliveries: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type RunState = "working" | "needs-input" | "settled";
|
|
32
|
+
|
|
33
|
+
/** The one rule. A permission or question wins over everything else: the person has to act, so the run is needs-input and never "done". */
|
|
34
|
+
export function settleVerdict(facts: SettleFacts): RunState {
|
|
35
|
+
if (facts.openPermissions > 0 || facts.openQuestions > 0) return "needs-input";
|
|
36
|
+
if (!facts.signal || facts.queuedContinuations > 0 || facts.ownedChildren > 0 || facts.runningChecks > 0 || facts.heldDeliveries > 0) return "working";
|
|
37
|
+
return "settled";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const OUTCOMES = Object.freeze(["done", "failed", "limit", "interrupted"] as const);
|
|
41
|
+
export type Outcome = (typeof OUTCOMES)[number];
|
|
42
|
+
|
|
43
|
+
/** What a run saw while it lasted. Closed facts the daemon sets; none carries text. */
|
|
44
|
+
export interface RunEvidence {
|
|
45
|
+
/** A turn of this run ended in a failure the adapter reported (a prompt that ended without normal completion, a refused turn). */
|
|
46
|
+
failed?: true;
|
|
47
|
+
/** A budget, step ceiling or context limit ended it. */
|
|
48
|
+
limit?: true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const outcomeOf = (evidence: RunEvidence): Exclude<Outcome, "interrupted"> => evidence.limit ? "limit" : evidence.failed ? "failed" : "done";
|
|
52
|
+
|
|
53
|
+
export interface Run {
|
|
54
|
+
peer: string;
|
|
55
|
+
/** The turn id of the `turn_start` that began the run: the deduplication key of its completion. */
|
|
56
|
+
runTurn: string;
|
|
57
|
+
/** The latest turn of the run (a continuation moves it). */
|
|
58
|
+
lastTurn: string;
|
|
59
|
+
startedAt: number;
|
|
60
|
+
/** Envelope ids only, so a display can read the prompt and the reply later through the usual privacy path. */
|
|
61
|
+
promptEnvelope?: string;
|
|
62
|
+
replyEnvelope?: string;
|
|
63
|
+
private: boolean;
|
|
64
|
+
evidence: RunEvidence;
|
|
65
|
+
/** The adapter's settle signal for the latest turn has fired. */
|
|
66
|
+
signalled: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** In-memory runs, one per peer. The store keeps an id-only row for each open run, so a restart can settle it `interrupted`. */
|
|
70
|
+
export class RunTracker {
|
|
71
|
+
private readonly runs = new Map<string, Run>();
|
|
72
|
+
|
|
73
|
+
get(peer: string): Run | undefined { return this.runs.get(peer); }
|
|
74
|
+
open(): Run[] { return [...this.runs.values()]; }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A turn started. It begins a run only when `personOriginated` and the peer has none; a turn that starts while a run is open is a continuation and moves `lastTurn`,
|
|
78
|
+
* whoever started it. Returns the run the turn belongs to, or undefined when it belongs to none (an ordinary agent-to-agent turn).
|
|
79
|
+
*/
|
|
80
|
+
turnStarted(input: { peer: string; turn: string; at: number; personOriginated: boolean; promptEnvelope?: string; private: boolean }): Run | undefined {
|
|
81
|
+
const current = this.runs.get(input.peer);
|
|
82
|
+
if (current) {
|
|
83
|
+
current.lastTurn = input.turn; current.signalled = false;
|
|
84
|
+
if (input.private) current.private = true;
|
|
85
|
+
return current;
|
|
86
|
+
}
|
|
87
|
+
if (!input.personOriginated) return undefined;
|
|
88
|
+
const run: Run = { peer: input.peer, runTurn: input.turn, lastTurn: input.turn, startedAt: input.at, ...(input.promptEnvelope ? { promptEnvelope: input.promptEnvelope } : {}), private: input.private, evidence: {}, signalled: false };
|
|
89
|
+
this.runs.set(input.peer, run);
|
|
90
|
+
return run;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The adapter's settle signal fired for the peer's latest turn. */
|
|
94
|
+
signalled(peer: string, replyEnvelope?: string): Run | undefined {
|
|
95
|
+
const run = this.runs.get(peer);
|
|
96
|
+
if (!run) return undefined;
|
|
97
|
+
run.signalled = true;
|
|
98
|
+
if (replyEnvelope) run.replyEnvelope = replyEnvelope;
|
|
99
|
+
return run;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
note(peer: string, evidence: RunEvidence): void {
|
|
103
|
+
const run = this.runs.get(peer);
|
|
104
|
+
if (!run) return;
|
|
105
|
+
if (evidence.failed) run.evidence.failed = true;
|
|
106
|
+
if (evidence.limit) run.evidence.limit = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** An explicit stop by the person: the run ends and creates nothing. */
|
|
110
|
+
quietStop(peer: string): boolean { return this.runs.delete(peer); }
|
|
111
|
+
|
|
112
|
+
/** Remove the run once its completion exists. */
|
|
113
|
+
close(peer: string): void { this.runs.delete(peer); }
|
|
114
|
+
|
|
115
|
+
/** The state a settle check reads off a run's own signal plus the pending-work facts. */
|
|
116
|
+
state(peer: string, pending: Omit<SettleFacts, "signal">): RunState | undefined {
|
|
117
|
+
const run = this.runs.get(peer);
|
|
118
|
+
return run ? settleVerdict({ ...pending, signal: run.signalled }) : undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
|
26
26
|
import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
|
|
27
27
|
import { CHARACTER_IDS } from "../pet/characters.ts";
|
|
28
28
|
import { autonomyOwnership, checkSettingValue, settingDef } from "./settings.ts";
|
|
29
|
-
import { petPalette } from "../pet/config.ts";
|
|
29
|
+
import { petPalette, stripLegacyPetWindow, PET_WINDOW_RETIRED_NOTICE } from "../pet/config.ts";
|
|
30
30
|
import { parseSpecsConfig } from "./specs-config.ts";
|
|
31
31
|
|
|
32
32
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
@@ -67,6 +67,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
|
|
|
67
67
|
const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
|
|
68
68
|
const runners = key === "research.runners" || key.startsWith("research.runners.") || key === "research"
|
|
69
69
|
&& [before, after].some(value => !!value && typeof value === "object" && Object.hasOwn(value, "runners"));
|
|
70
|
+
if (input.source === "config" && key === "pet.window") return result(false, false, PET_WINDOW_RETIRED_NOTICE);
|
|
70
71
|
if (runners) {
|
|
71
72
|
if (input.source !== "config" || input.sourceFile !== "config.local.json" || input.tracked !== false) return result(false, true, "research.runners requires untracked config.local.json");
|
|
72
73
|
if (input.authority === "conductor") return result(false, true, "runner registration is person only");
|
|
@@ -176,11 +177,10 @@ const petFields: Record<string, Check> = {
|
|
|
176
177
|
blink: bool,
|
|
177
178
|
bell: bool,
|
|
178
179
|
palette: (v, k) => { if (!petPalette(v)) fail(k, "unknown choice"); },
|
|
179
|
-
window: bool,
|
|
180
180
|
};
|
|
181
181
|
export const validatePetConfig: Check = (v, k) => {
|
|
182
182
|
if (v === false) return;
|
|
183
|
-
block(petFields)(v, k);
|
|
183
|
+
block(petFields)(stripLegacyPetWindow(v), k);
|
|
184
184
|
};
|
|
185
185
|
shapes.set(validatePetConfig, { fields: petFields });
|
|
186
186
|
const nativeValidators = { bool, text, strings, argv, number, words, block };
|
|
@@ -207,7 +207,7 @@ const schema: Record<string, Check> = {
|
|
|
207
207
|
conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
|
|
208
208
|
budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
|
|
209
209
|
context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
|
|
210
|
-
bus: block({ max_attempts: operationalNumberCheck("bus.max_attempts"), digest_max: operationalNumberCheck("bus.digest_max"), pull_budget_bytes: operationalNumberCheck("bus.pull_budget_bytes"), notes_kept: operationalNumberCheck("bus.notes_kept"), terminal_history_limit: operationalNumberCheck("bus.terminal_history_limit") }),
|
|
210
|
+
bus: block({ max_attempts: operationalNumberCheck("bus.max_attempts"), digest_max: operationalNumberCheck("bus.digest_max"), pull_budget_bytes: operationalNumberCheck("bus.pull_budget_bytes"), notes_kept: operationalNumberCheck("bus.notes_kept"), terminal_history_limit: operationalNumberCheck("bus.terminal_history_limit"), accepted_settle_ms: operationalNumberCheck("bus.accepted_settle_ms") }),
|
|
211
211
|
adapter_timing: block({ acp_tool_ceiling_min: operationalNumberCheck("adapter_timing.acp_tool_ceiling_min"), acp_activity_batch_ms: operationalNumberCheck("adapter_timing.acp_activity_batch_ms"), jcode_steer_timeout_s: operationalNumberCheck("adapter_timing.jcode_steer_timeout_s") }),
|
|
212
212
|
model_relay: block({ cooldown_failures: operationalNumberCheck("model_relay.cooldown_failures"), cooldown_s: operationalNumberCheck("model_relay.cooldown_s"), cooldown_cap_s: operationalNumberCheck("model_relay.cooldown_cap_s"), terminal_history_limit: operationalNumberCheck("model_relay.terminal_history_limit") }),
|
|
213
213
|
console: block({ poll_ms: operationalNumberCheck("console.poll_ms"), notice_ms: operationalNumberCheck("console.notice_ms"), history_limit: operationalNumberCheck("console.history_limit"), pending_stream_limit: operationalNumberCheck("console.pending_stream_limit"), attention_limit: operationalNumberCheck("console.attention_limit") }),
|
|
@@ -228,6 +228,8 @@ const schema: Record<string, Check> = {
|
|
|
228
228
|
} },
|
|
229
229
|
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool, runners: researchRunners }), bench: block({ enabled: bool }),
|
|
230
230
|
worktrees: block({ quiet_days: number(0, 3650, true), alert_over: number(0, 100_000, true) }),
|
|
231
|
+
// #1053: how long an unanswered question waits (`ttl_s`, 1 minute to 30 days) and, per adapter kind, how long a `hub_ask` call waits for the person before it answers `pending`.
|
|
232
|
+
questions: block({ ttl_s: number(60, 2_592_000, true), wait_ms: map(number(1000, 600_000, true)) }),
|
|
231
233
|
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
232
234
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
233
235
|
start: (v, k) => { block({ agents: strings })(v, k); if (object(v, k).agents !== undefined) checkStartAgents(object(v, k).agents, `${k}.agents`); },
|
|
@@ -274,7 +276,7 @@ const restart: Record<string, string> = {
|
|
|
274
276
|
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
275
277
|
text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
|
|
276
278
|
};
|
|
277
|
-
const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "worktrees", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet", "specs"]);
|
|
279
|
+
const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "worktrees", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "questions", "pet", "specs"]);
|
|
278
280
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
279
281
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
280
282
|
export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
|
package/src/hub/config-reload.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { validateRawConfig, type ConfigLayer, type ConfigIdentitySnapshot } from "./config-apply.ts";
|
|
7
|
+
import { stripLegacyPetWindow } from "../pet/config.ts";
|
|
7
8
|
import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
|
|
8
9
|
import { CLASSES } from "./board.ts";
|
|
9
10
|
import { readInstanceConfig, withoutInertInstancePolicy, type DeclaredExternalInstance } from "./instance-config.ts";
|
|
@@ -125,6 +126,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
|
|
|
125
126
|
const text = texts[name];
|
|
126
127
|
if (text === null) continue;
|
|
127
128
|
let raw: Record<string, unknown>;
|
|
129
|
+
let trustRaw: Record<string, unknown> | undefined;
|
|
128
130
|
try {
|
|
129
131
|
const value: unknown = name.endsWith(".json") ? JSON.parse(text) : Bun.TOML.parse(text);
|
|
130
132
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
|
@@ -134,7 +136,14 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
|
|
|
134
136
|
continue;
|
|
135
137
|
}
|
|
136
138
|
if (name === "config.json" || name === "config.local.json") {
|
|
139
|
+
const pet = stripLegacyPetWindow(raw.pet);
|
|
140
|
+
if (pet !== raw.pet) {
|
|
141
|
+
trustRaw = structuredClone(raw);
|
|
142
|
+
if (pet && typeof pet === "object" && !Object.keys(pet).length) delete raw.pet;
|
|
143
|
+
else raw.pet = pet;
|
|
144
|
+
}
|
|
137
145
|
raw = withoutInertInstancePolicy(raw, inertInstances);
|
|
146
|
+
if (trustRaw) trustRaw = withoutInertInstancePolicy(trustRaw, inertInstances);
|
|
138
147
|
if (!fallbackRouting) {
|
|
139
148
|
try {
|
|
140
149
|
const structural = { ...raw };
|
|
@@ -146,7 +155,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
|
|
|
146
155
|
}
|
|
147
156
|
layers.push({ name, raw });
|
|
148
157
|
}
|
|
149
|
-
trustLayers.push({ name, raw });
|
|
158
|
+
trustLayers.push({ name, raw: trustRaw ?? raw });
|
|
150
159
|
}
|
|
151
160
|
let routing = fallbackRouting;
|
|
152
161
|
if (!routing) {
|
|
@@ -21,7 +21,7 @@ export const PROTOCOL = 17;
|
|
|
21
21
|
/** The descriptor's legacy protocol field remains this floor for previous published clients. */
|
|
22
22
|
export const PROTOCOL_MIN = 16;
|
|
23
23
|
// Literal metadata is read without executing a recovery target; consistency is pinned in tests.
|
|
24
|
-
export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard", "design_specs", "queue_discard_states", "tool_write_idempotency"] as const;
|
|
24
|
+
export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard", "design_specs", "queue_discard_states", "tool_write_idempotency", "research_evidence", "review_comments", "questions", "accepted_delivery_settlement", "completions", "review_observer_input_state", "task_handoff_key", "task_handoff_seq", "peer_takeover"] as const;
|
|
25
25
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
26
26
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
|
|
27
27
|
|
|
@@ -58,6 +58,8 @@ export interface Hello {
|
|
|
58
58
|
peer?: string;
|
|
59
59
|
/** `peer` only: false when the session cannot show channel pushes; its queue then waits for `inbox` (issue #205). */
|
|
60
60
|
channel?: boolean;
|
|
61
|
+
/** Closed, display-only evidence for Claude's channel attachment decision. */
|
|
62
|
+
channelEvidence?: "development-channel-launch" | "inherited-marker" | "launch-unverified" | "unknown" | "unreported";
|
|
61
63
|
/** Managed Claude launch identity; absent for plain or pre-daemon sessions. */
|
|
62
64
|
launchId?: string;
|
|
63
65
|
/** Optional canonical native execution cwd; ownership requires independent host proof. */
|
|
@@ -70,6 +72,10 @@ export interface Hello {
|
|
|
70
72
|
session?: string;
|
|
71
73
|
/** Reclaim after replacement, never an intentional initial takeover. */
|
|
72
74
|
standby?: boolean;
|
|
75
|
+
/** Opt in to a retained, person-selectable standby connection when another session owns this peer. */
|
|
76
|
+
retainStandby?: true;
|
|
77
|
+
/** Display-only process id, never native-session or authorization evidence. */
|
|
78
|
+
clientPid?: number;
|
|
73
79
|
projectId?: string;
|
|
74
80
|
instanceId?: string;
|
|
75
81
|
projectRoot?: string;
|
|
@@ -117,14 +123,36 @@ const instancePins = new Map<string, { instanceId: string }>();
|
|
|
117
123
|
/** Maximum supported 3600 s approval plus admission/transport margin; applies to every hub_invoke client path. */
|
|
118
124
|
export const INVOCATION_CONTROL_TIMEOUT_MS = 3_665_000;
|
|
119
125
|
export function controlRequestTimeout(msg: Record<string, unknown>, requested = 30_000): number {
|
|
126
|
+
if (msg.t === "task" && msg.op === "hub_ask" && requested !== 0) return Math.max(Number.isFinite(requested) ? requested : 0, QUESTION_CONTROL_TIMEOUT_MS);
|
|
127
|
+
if (msg.t === "task" && msg.op === "hub_task_handoff" && requested !== 0) return Math.max(Number.isFinite(requested) ? requested : 0, HANDOFF_CONTROL_TIMEOUT_MS);
|
|
120
128
|
return msg.t === "task" && msg.op === "hub_invoke" && requested !== 0
|
|
121
129
|
? Math.max(Number.isFinite(requested) ? requested : 0, INVOCATION_CONTROL_TIMEOUT_MS) : requested;
|
|
122
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* #731 (agent-hub#1203): `hub_task_handoff` can legitimately take longer than the default 30 s, because the origin's own recovery runs inside it: a connect and a request to the target, the same
|
|
133
|
+
* again for the resend with the same key, then a connect and a request for the by-key lookup. The caller (the CLI and the MCP path alike) must outlast that, or it reports a generic timeout before
|
|
134
|
+
* `outcome_unknown` can name the key to retry with. The budgets are shared with `handoffOut`, so changing one moves the deadline with it.
|
|
135
|
+
*/
|
|
136
|
+
export const HANDOFF_CONNECT_MS = 5_000;
|
|
137
|
+
export const HANDOFF_REQUEST_MS = 30_000;
|
|
138
|
+
export const HANDOFF_LOOKUP_CONNECT_MS = 3_000;
|
|
139
|
+
export const HANDOFF_LOOKUP_REQUEST_MS = 5_000;
|
|
140
|
+
/** Two attempts, one by-key lookup, and a margin for the hello and the reply to travel back. */
|
|
141
|
+
export const HANDOFF_CONTROL_TIMEOUT_MS = 2 * (HANDOFF_CONNECT_MS + HANDOFF_REQUEST_MS) + HANDOFF_LOOKUP_CONNECT_MS + HANDOFF_LOOKUP_REQUEST_MS + 12_000;
|
|
142
|
+
/**
|
|
143
|
+
* #1053: `hub_ask` holds its request open for the daemon's bounded wait (at most `QUESTION_WAIT_MAX_MS`) plus a margin, so the client's own 30 s cap never turns a
|
|
144
|
+
* working wait into "no answer from the hub". The daemon, not this bound, decides when the wait ends.
|
|
145
|
+
*/
|
|
146
|
+
export const QUESTION_WAIT_MAX_MS = 600_000;
|
|
147
|
+
export const QUESTION_CONTROL_TIMEOUT_MS = QUESTION_WAIT_MAX_MS + 15_000;
|
|
123
148
|
|
|
124
149
|
|
|
125
150
|
/** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
|
|
126
151
|
export class ControlClient {
|
|
127
152
|
features: readonly string[] = [];
|
|
153
|
+
/** A retained peer connection awaiting a person-requested takeover. */
|
|
154
|
+
standby = false;
|
|
155
|
+
standbyId?: string;
|
|
128
156
|
/** The id the hub minted for a distinct session (agent-hub#1036); absent for every ordinary connection. */
|
|
129
157
|
sessionId?: string;
|
|
130
158
|
/** Selected wire version, checked against the welcome when that legacy-optional field is present. */
|
|
@@ -192,7 +220,13 @@ export class ControlClient {
|
|
|
192
220
|
try { msg = JSON.parse(String(ev.data)); } catch { return refuse(new Error("invalid hub response")); }
|
|
193
221
|
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return refuse(new Error("invalid hub response"));
|
|
194
222
|
const done = msg.rid !== undefined ? client.pending.get(msg.rid) : undefined;
|
|
195
|
-
if (!done)
|
|
223
|
+
if (!done) {
|
|
224
|
+
if (msg.t === "peer_attached" && msg.standbyId === client.standbyId) {
|
|
225
|
+
client.standby = false;
|
|
226
|
+
client.standbyId = undefined;
|
|
227
|
+
}
|
|
228
|
+
return client.onPush(msg);
|
|
229
|
+
}
|
|
196
230
|
client.pending.delete(msg.rid);
|
|
197
231
|
done(msg);
|
|
198
232
|
};
|
|
@@ -208,6 +242,8 @@ export class ControlClient {
|
|
|
208
242
|
client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
|
|
209
243
|
client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
|
|
210
244
|
client.sessionId = typeof reply.session === "string" && reply.session.length <= 64 ? reply.session : undefined;
|
|
245
|
+
client.standby = reply.standby === true;
|
|
246
|
+
client.standbyId = client.standby && typeof reply.standbyId === "string" ? reply.standbyId : undefined;
|
|
211
247
|
clearTimeout(timer);
|
|
212
248
|
resolve(client);
|
|
213
249
|
});
|
|
@@ -237,7 +273,7 @@ export class ControlClient {
|
|
|
237
273
|
|
|
238
274
|
private requestBody(msg: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<any> {
|
|
239
275
|
if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel") return Promise.resolve({ ok: false, error: "hub connection is not open" });
|
|
240
|
-
const invocation = msg.t === "task" && msg.op === "hub_invoke";
|
|
276
|
+
const invocation = msg.t === "task" && (msg.op === "hub_invoke" || msg.op === "hub_ask");
|
|
241
277
|
const operation = toolWriteOperation(msg), write = operation !== undefined;
|
|
242
278
|
const keyedWrite = write && operation !== "hub_delivery_done";
|
|
243
279
|
if (keyedWrite && msg.args && typeof msg.args === "object" && !Array.isArray(msg.args) && Object.hasOwn(msg.args, "request_id")) {
|
|
@@ -273,7 +309,7 @@ export class ControlClient {
|
|
|
273
309
|
if (invocation && this.ws.readyState === WebSocket.OPEN) {
|
|
274
310
|
try { this.ws.send(JSON.stringify({ t: "request_cancel", request: rid })); } catch { /* Socket close also withdraws admission. */ }
|
|
275
311
|
}
|
|
276
|
-
done(write ? uncertain(error) : { ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
|
|
312
|
+
done(write ? uncertain(error) : { ok: false, error: invocation ? `${error}; ${msg.op === "hub_ask" ? "the question may still be open: it is answered later as a message of kind answer, or never" : "invocation admission unconfirmed, inspect before retrying"}` : error });
|
|
277
313
|
};
|
|
278
314
|
const aborted = () => abandon("hub request cancelled");
|
|
279
315
|
this.pending.set(rid, done);
|