@staix/agent-hub 0.29.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 +51 -0
- package/README.md +15 -7
- package/docs/agent-notes/README.md +13 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +10 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +10 -4
- package/docs/agent-notes/tests.md +8 -4
- package/docs/design/adapters.md +108 -0
- package/docs/design/agent-hub-architecture.md +58 -0
- package/docs/design/bus.md +147 -1
- package/docs/design/task-board.md +100 -0
- package/docs/dogfood.md +29 -8
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +410 -86
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +25 -5
- package/docs/sdlc.md +1 -1
- package/docs/security.md +182 -62
- package/docs/smoke.md +13 -2
- 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/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- 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/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- 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 +4565 -3617
- 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 +13 -13
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +82 -23
- package/src/adapters/pi.ts +140 -16
- package/src/adapters/registry-types.ts +16 -0
- package/src/adapters/registry.ts +4 -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/autonomy.ts +66 -1
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +82 -17
- 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 +10 -5
- package/src/cli/identity.ts +21 -4
- package/src/cli/main.ts +167 -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/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +396 -20
- package/src/hub/bus.ts +497 -57
- 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/conductor.ts +4 -3
- package/src/hub/config-apply.ts +8 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1795 -142
- 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/dogfood-read.ts +62 -9
- 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 +66 -11
- package/src/hub/manager.ts +67 -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/peer-doing.ts +33 -3
- package/src/hub/peers.ts +32 -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/settings.ts +3 -0
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +607 -71
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/pi/extension.ts +43 -3
- 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 +271 -17
- package/src/ui/needs.ts +10 -4
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +219 -28
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
|
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
5
|
+
import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
|
|
5
6
|
import { BasePeer, type FrontendProxyPort } from "../hub/peers.ts";
|
|
6
7
|
import { codexContext, type ContextReading } from "../hub/context-window.ts";
|
|
7
8
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
@@ -135,7 +136,7 @@ export class CodexPeer extends BasePeer {
|
|
|
135
136
|
}
|
|
136
137
|
private unboundAccepted = false;
|
|
137
138
|
private nextId = -1;
|
|
138
|
-
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string) => void }>();
|
|
139
|
+
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error, failureCause?: DeliveryFailureCause) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string, failureCause?: DeliveryFailureCause) => void }>();
|
|
139
140
|
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
140
141
|
private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
|
|
141
142
|
private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
|
|
@@ -260,12 +261,12 @@ export class CodexPeer extends BasePeer {
|
|
|
260
261
|
return new Promise<void>((resolve, reject) => {
|
|
261
262
|
let invalidated = false;
|
|
262
263
|
const retiredAtCancel = new Set<string>();
|
|
263
|
-
const invalidate = (reason: string) => {
|
|
264
|
+
const invalidate = (reason: string, failureCause?: DeliveryFailureCause) => {
|
|
264
265
|
if (invalidated) return;
|
|
265
266
|
invalidated = true;
|
|
266
267
|
for (const turn of [...this.activeTurns, ...this.turnDeliveries.keys()]) if (!turn.startsWith("unknown:")) retiredAtCancel.add(turn);
|
|
267
268
|
envs = []; // only the late native-id callback survives, never the cancelled audience/prompt
|
|
268
|
-
if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason });
|
|
269
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason, ...(failureCause ? { failureCause } : {}) });
|
|
269
270
|
reject(new Error(reason));
|
|
270
271
|
};
|
|
271
272
|
this.pending.set(id, {
|
|
@@ -330,9 +331,9 @@ export class CodexPeer extends BasePeer {
|
|
|
330
331
|
return new Promise<void>((resolve, reject) => {
|
|
331
332
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
332
333
|
const settle = () => { clearTimeout(timer); this.steers.delete(id); };
|
|
333
|
-
const fail = (e: Error) => {
|
|
334
|
+
const fail = (e: Error, failureCause?: DeliveryFailureCause) => {
|
|
334
335
|
settle();
|
|
335
|
-
if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
|
|
336
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message, ...(failureCause ? { failureCause } : {}) });
|
|
336
337
|
reject(e);
|
|
337
338
|
};
|
|
338
339
|
this.pending.set(id, {
|
|
@@ -342,7 +343,7 @@ export class CodexPeer extends BasePeer {
|
|
|
342
343
|
timer = setTimeout(() => {
|
|
343
344
|
const pending = this.pending.get(id);
|
|
344
345
|
this.pending.delete(id);
|
|
345
|
-
pending?.reject(new Error("steer unanswered: RPC timeout"));
|
|
346
|
+
pending?.reject(new Error("steer unanswered: RPC timeout"), "timeout");
|
|
346
347
|
}, this.opts.steerTimeoutMs ?? 10_000);
|
|
347
348
|
timer.unref?.();
|
|
348
349
|
} : undefined,
|
|
@@ -410,17 +411,17 @@ export class CodexPeer extends BasePeer {
|
|
|
410
411
|
this.opts.log?.(UNIDENTIFIED_CANCELLATION_NOTICE);
|
|
411
412
|
}
|
|
412
413
|
for (const turnId of known) this.retireTurn(turnId);
|
|
413
|
-
for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed");
|
|
414
|
+
for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed", "timeout");
|
|
414
415
|
this.activeTurns.clear();
|
|
415
416
|
this.unboundAccepted = false;
|
|
416
|
-
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
417
|
-
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
417
|
+
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout", failureCause: "timeout" });
|
|
418
|
+
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout", failureCause: "timeout" });
|
|
418
419
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
419
420
|
this.injected = undefined;
|
|
420
421
|
this.answering = [];
|
|
421
422
|
this.lastAnswer = "";
|
|
422
423
|
this.deltas.clear();
|
|
423
|
-
this.abandonSteers("turn went silent");
|
|
424
|
+
this.abandonSteers("turn went silent", "timeout");
|
|
424
425
|
if (this.unidentifiedCancellation) this.setState("offline");
|
|
425
426
|
else super.onWatchdog();
|
|
426
427
|
}
|
|
@@ -432,11 +433,11 @@ export class CodexPeer extends BasePeer {
|
|
|
432
433
|
}
|
|
433
434
|
|
|
434
435
|
/** A steer nobody answered must not vanish: rejecting it sends the envelope back to the queue. */
|
|
435
|
-
private abandonSteers(reason: string): void {
|
|
436
|
+
private abandonSteers(reason: string, failureCause?: DeliveryFailureCause): void {
|
|
436
437
|
for (const id of this.steers) {
|
|
437
438
|
const p = this.pending.get(id);
|
|
438
439
|
this.pending.delete(id);
|
|
439
|
-
p?.reject(new Error(`steer unanswered: ${reason}`));
|
|
440
|
+
p?.reject(new Error(`steer unanswered: ${reason}`), failureCause);
|
|
440
441
|
}
|
|
441
442
|
}
|
|
442
443
|
|
package/src/adapters/compat.ts
CHANGED
|
@@ -29,16 +29,16 @@ export interface CompatPolicy {
|
|
|
29
29
|
|
|
30
30
|
const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
|
|
31
31
|
export const COMPAT = {
|
|
32
|
-
//
|
|
33
|
-
pi: { label: "Pi", verified: ["1.4.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
|
|
32
|
+
// Receipts: docs/verification/2026-10-26-pi-140-compat.md (1.4.0); docs/verification/2026-11-03-pi-150-compat.md (1.5.0)
|
|
33
|
+
pi: { label: "Pi", verified: ["1.4.0", "1.5.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
|
|
34
34
|
parse: out => plain(out.trim()),
|
|
35
|
-
trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
|
|
36
|
-
receipt: "docs/verification/2026-
|
|
37
|
-
// Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; host-wrapper receipt below repeats with them.
|
|
38
|
-
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316"], gate: "contract", probe: ["claude", "--version"],
|
|
35
|
+
trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
|
|
36
|
+
receipt: "docs/verification/2026-11-03-pi-150-compat.md" },
|
|
37
|
+
// Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.md; host-wrapper receipt below repeats with them.
|
|
38
|
+
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316", "2.1.318"], gate: "contract", probe: ["claude", "--version"],
|
|
39
39
|
parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
|
|
40
40
|
trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
|
|
41
|
-
receipt: "docs/verification/2026-
|
|
41
|
+
receipt: "docs/verification/2026-11-05-claude-implementer-2.1.318.md" },
|
|
42
42
|
// 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md
|
|
43
43
|
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0", "0.166.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
44
44
|
parse: out => plain(out.trim().replace(/^codex-cli /, "")),
|
|
@@ -53,14 +53,14 @@ export const COMPAT = {
|
|
|
53
53
|
parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
|
|
54
54
|
trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
|
|
55
55
|
receipt: "docs/verification/2026-10-26-jcode-0.99.1.md" },
|
|
56
|
-
agy: { label: "agy", verified: ["1.4.2", "1.4.7"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
|
|
56
|
+
agy: { label: "agy", verified: ["1.4.2", "1.4.7", "1.4.12"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
|
|
57
57
|
parse: out => plain(out.trim()),
|
|
58
|
-
trustNote: "Only 1.4.2 and 1.4.
|
|
59
|
-
receipt: "docs/verification/2026-
|
|
60
|
-
qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
58
|
+
trustNote: "Only 1.4.2, 1.4.7 and 1.4.12 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
|
|
59
|
+
receipt: "docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md" },
|
|
60
|
+
qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
61
61
|
parse: out => plain(out.trim()),
|
|
62
|
-
trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4; real model turns remain unverified. Another version may change those contracts.",
|
|
63
|
-
receipt: "docs/verification/2026-
|
|
62
|
+
trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4 and 0.26.5; real model turns remain unverified. Another version may change those contracts.",
|
|
63
|
+
receipt: "docs/verification/2026-11-03-qwen-0265-acp.md" },
|
|
64
64
|
// Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
|
|
65
65
|
// the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
|
|
66
66
|
"qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
@@ -65,7 +65,7 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
|
|
|
65
65
|
identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"], modelSelect: true },
|
|
66
66
|
permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools, proven read-only shell commands and bound task commands inside the owned worktree may run automatically; protected paths and other commands ask" } },
|
|
67
67
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: false, launchKind: "claude-implementer", crashPlan: () => ({ resume: {}, how: "Claude implementer resumes its recorded per-task sessions" }) },
|
|
68
|
-
turnEnd: "adapter", budget: { account: "claude" },
|
|
68
|
+
turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "claude" },
|
|
69
69
|
cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
|
|
70
70
|
};
|
|
71
71
|
|
|
@@ -74,7 +74,7 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
|
|
|
74
74
|
export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
75
75
|
{
|
|
76
76
|
projection: { roleRank: 0, helpRank: 0, plannerEligible: true },
|
|
77
|
-
api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
77
|
+
api: 1, kind: "claude", questionWaitMs: 45000, displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
78
78
|
roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
79
79
|
start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true, upStart: { allowed: true, default: true },
|
|
80
80
|
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
|
|
@@ -84,27 +84,27 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
84
84
|
"ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
|
|
85
85
|
"never-ask": "every tool runs without asking" } },
|
|
86
86
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
|
|
87
|
-
turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
87
|
+
turnEnd: "native-hook", settle: { signal: "native-stop" }, budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
88
88
|
cli: { command: "claude", entry: { lifecycleAdmission: true, execute: runClaudeEntry }, preview: previewClaude, usage: [["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"]], retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
|
|
89
89
|
reviewerVariant: CLAUDE_REVIEWER_VARIANT, headlessVariant: CLAUDE_IMPLEMENTER_VARIANT,
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
92
|
reviewerVariant: CODEX_REVIEWER_VARIANT,
|
|
93
93
|
projection: { roleRank: 1, startRank: 1, helpRank: 1, plannerEligible: true },
|
|
94
|
-
api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
94
|
+
api: 1, kind: "codex", questionWaitMs: 45000, displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
95
95
|
roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
96
96
|
start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true, upStart: { allowed: true, default: true },
|
|
97
97
|
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
|
|
98
98
|
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
99
99
|
permission: { trackUnattended: true, grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
|
|
100
100
|
session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
|
|
101
|
-
turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
101
|
+
turnEnd: "adapter", settle: { signal: "turn-completed" }, budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
102
102
|
cli: { command: "codex", entry: { lifecycleAdmission: true, execute: runCodexEntry }, preview: previewCodex, usage: [["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"]], launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
reviewerVariant: KIMI_REVIEWER_VARIANT,
|
|
106
106
|
projection: { roleRank: 2, startRank: 2, helpRank: 2, plannerEligible: true },
|
|
107
|
-
api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
107
|
+
api: 1, kind: "kimi", questionWaitMs: 45000, displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
108
108
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
109
109
|
start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
|
|
110
110
|
// ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
|
|
@@ -114,45 +114,45 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
114
114
|
permission: { grants: { ask: "Kimi asks as it does on its own", "ask-when-needed": "Kimi's yolo mode: it asks only where it judges it must", "never-ask": "Kimi's auto mode: nothing asks" },
|
|
115
115
|
unmanagedForms: Object.freeze(["tui"] as const), unmanagedStatusNote: "Kimi approves tools in its own TUI; no hub permission mode", unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
|
|
116
116
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
|
|
117
|
-
turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
117
|
+
turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
118
118
|
cli: { command: "kimi", entry: { lifecycleAdmission: true, execute: runKimiEntry }, preview: previewKimi, usage: [["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"]], launchConfigKeys: ["kimi_cmd", "peers.kimi.start_mode"], stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
|
|
122
|
-
api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
122
|
+
api: 1, kind: "pi", questionWaitMs: 300000, title: "hub", titlePersistence: "native", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
123
123
|
roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
|
|
124
124
|
start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
|
|
125
125
|
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
126
126
|
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
127
127
|
permission: { grants: { ...hubGrants } },
|
|
128
128
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
|
|
129
|
-
turnEnd: "adapter", budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
129
|
+
turnEnd: "adapter", settle: { signal: "agent-settled" }, budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
130
130
|
routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
|
|
131
131
|
cli: { command: "pi", entry: { lifecycleAdmission: false, execute: runPiEntry }, preview: previewPi, usage: [["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"]], launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
|
|
135
|
-
api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
135
|
+
api: 1, kind: "local", questionWaitMs: 300000, title: "hub", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
136
136
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
|
|
137
137
|
start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
|
|
138
138
|
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
139
139
|
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
140
140
|
permission: { grants: { ...hubGrants } },
|
|
141
141
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
|
|
142
|
-
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
142
|
+
turnEnd: "adapter", settle: { signal: "route-outcome" }, budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
143
143
|
routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
|
|
144
144
|
cli: { command: "local", entry: { lifecycleAdmission: false, execute: runLocalEntry }, usage: [["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"]], launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
projection: { roleRank: 6, startRank: 3, helpRank: 3, plannerEligible: true },
|
|
148
|
-
api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
|
|
148
|
+
api: 1, kind: "jcode", questionWaitMs: 45000, displayName: "Jcode", family: "jcode", transport: "acp",
|
|
149
149
|
roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
|
|
150
150
|
start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
|
|
151
151
|
// #685: an extra Jcode is a headless instance with its own native home, socket and state (instances.<id>.jcode_home).
|
|
152
152
|
identity: { mcp: "project-file" }, instances: { mode: "headless", nativeHome: true },
|
|
153
153
|
permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
|
|
154
154
|
ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
|
|
155
|
-
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode", context: { source: "jcode_usage_update", read: jcodeContextValue } },
|
|
155
|
+
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "jcode", context: { source: "jcode_usage_update", read: jcodeContextValue } },
|
|
156
156
|
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
157
157
|
cli: { command: "jcode", entry: { lifecycleAdmission: true, execute: runJcodeEntry }, preview: previewJcode, usage: [["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"]], authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
158
158
|
},
|
|
@@ -71,6 +71,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
71
71
|
},
|
|
72
72
|
},
|
|
73
73
|
turnEnd: "adapter",
|
|
74
|
+
settle: { signal: "message-stop" },
|
|
74
75
|
budget: { account: "qwen" },
|
|
75
76
|
capabilities: { watch: true },
|
|
76
77
|
cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless|tui] [--model <alias>] [--session-id <id>] [--print-command]", `start optional Qwen Code ACP (${describe("qwen")}; roster.qwen, default off); --mode tui runs Qwen's own TUI in this terminal, driven through its dual-output files (${describe("qwen-dual")}); a hub deny ends the turn without an answer and a stalled turn is stopped with Esc in the Qwen terminal`]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isReviewObserverExcluded, reviewCaptureExcluded } from "../hub/memory-policy.ts";
|
|
1
2
|
import { HubRouteRuntime, type RouteEvent, type AdvisorEvent } from "../models/route/runtime.ts";
|
|
2
3
|
import type { HubRoute } from "../models/route/config.ts";
|
|
3
4
|
import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.ts";
|
|
@@ -5,8 +6,8 @@ import type { StaySwitchPolicy } from "../models/route/stage.ts";
|
|
|
5
6
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
6
7
|
import { randomUUID } from "node:crypto";
|
|
7
8
|
import { MAX_HOP, remoteEnvelope, renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
|
|
8
|
-
import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
|
|
9
|
-
import { BasePeer } from "../hub/peers.ts";
|
|
9
|
+
import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools, SESSION_TITLE_TOOL } from "../hub/hub-tools.ts";
|
|
10
|
+
import { BasePeer, type SessionTitleResult } from "../hub/peers.ts";
|
|
10
11
|
import type { PublishResult } from "../hub/bus.ts";
|
|
11
12
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
12
13
|
import type { DocReadPort } from "../local/doc-read.ts";
|
|
@@ -17,11 +18,14 @@ import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/
|
|
|
17
18
|
import { safeModelLabel } from "../omniroute/usage.ts";
|
|
18
19
|
import { effectiveLocalRoute } from "../hub/routing.ts";
|
|
19
20
|
import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
|
|
21
|
+
import type { OwnedInputContext, OwnedInputObserver } from "./claude-print-child.ts";
|
|
20
22
|
|
|
21
23
|
export interface LocalOptions {
|
|
22
24
|
profile?: import("../hub/profiles.ts").ProfileSelection;
|
|
23
25
|
sdlcStages?: readonly string[];
|
|
24
26
|
sdlcCriteria?: boolean;
|
|
27
|
+
/** Host-owned conditional instruction, sampled once per delivered turn. */
|
|
28
|
+
titlePreface?: () => string | undefined;
|
|
25
29
|
cwd: string;
|
|
26
30
|
omni: OmniRoute;
|
|
27
31
|
/** In-process hub route id; legacy routes use the fixed model. */
|
|
@@ -34,6 +38,11 @@ export interface LocalOptions {
|
|
|
34
38
|
onRoute?: (event: RouteEvent) => void;
|
|
35
39
|
onAdvisor?: (event: AdvisorEvent) => void;
|
|
36
40
|
onRouteOutcome?: (event: RouteLabelEvent) => void;
|
|
41
|
+
/**
|
|
42
|
+
* #1054: the worker ended its turn at a ceiling it enforces itself, as a closed fact and never its text: `steps` (it ran out of `maxSteps`, or the review asked for more at the last
|
|
43
|
+
* step) or `budget` (an execution budget stopped it). The daemon records it as the run's `limit` evidence; a turn that reports none is `done`.
|
|
44
|
+
*/
|
|
45
|
+
onLimit?: (kind: "steps" | "budget") => void;
|
|
37
46
|
/** The daemon turn id created synchronously on busy, shared with turn_start/end. */
|
|
38
47
|
turnId?: () => string | undefined;
|
|
39
48
|
onApprovalStop?: (reason: string) => void;
|
|
@@ -41,7 +50,11 @@ export interface LocalOptions {
|
|
|
41
50
|
tools: { docRead?: DocReadPort; webFetch?: WebFetchPort; deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
|
|
42
51
|
capture?: Capture;
|
|
43
52
|
/** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
|
|
44
|
-
taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
|
|
53
|
+
taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean; signal?: AbortSignal }) => Promise<string>;
|
|
54
|
+
/** Final per-turn input binding after the Bus pre-handoff hold; never a native-session authority port. */
|
|
55
|
+
beforeInput?: OwnedInputObserver;
|
|
56
|
+
/** Actual local model handoff metadata; never a budget, native-session or task-authority port. */
|
|
57
|
+
inputReleased?: OwnedInputObserver;
|
|
45
58
|
/** Successful provider responses only; usage may be absent when the gateway omits it. Never includes prompt data. */
|
|
46
59
|
onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string; task?: number }) => void;
|
|
47
60
|
/** Atomic task/run admission immediately before every model request or tool execution. */
|
|
@@ -82,6 +95,22 @@ export class LocalPeer extends BasePeer {
|
|
|
82
95
|
readonly deliveryOnly: boolean = true;
|
|
83
96
|
readonly hubOwned: boolean = true;
|
|
84
97
|
private readonly history: ChatMessage[] = [];
|
|
98
|
+
private title = "";
|
|
99
|
+
get sessionTitle(): string { return this.title; }
|
|
100
|
+
get titleSession(): string { return this.sessionId; }
|
|
101
|
+
/** Privacy-only identity for review-observer provenance; intentionally not PeerAdapter.nativeSession. */
|
|
102
|
+
get observerSessionId(): string { return this.sessionId; }
|
|
103
|
+
async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
|
|
104
|
+
if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean") return { ok: false, reason: "invalid", error: "Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again." };
|
|
105
|
+
if (this.state === "offline") return { ok: false, reason: "invalid", error: "Local worker session is offline; start the peer and check ahub status before naming it." };
|
|
106
|
+
let admitted = false;
|
|
107
|
+
try { admitted = current?.() !== false; } catch { /* host admission failure is a refusal */ }
|
|
108
|
+
if (!admitted) return { ok: false, reason: "invalid", error: "Session title admission changed before the write; check ahub status and retry only in the current public session." };
|
|
109
|
+
if (onlyIfUnnamed && this.title) return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
|
|
110
|
+
this.title = title;
|
|
111
|
+
try { this.onDoing?.("title", title); } catch { /* observation failure cannot undo the saved local title */ }
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
85
114
|
private readonly sessionId = `agent-hub-local-${randomUUID()}`;
|
|
86
115
|
private readonly routes: HubRouteRuntime;
|
|
87
116
|
private readonly warnedLegacyRoutes = new Set<string>();
|
|
@@ -97,6 +126,8 @@ export class LocalPeer extends BasePeer {
|
|
|
97
126
|
private approvalAnswerEpoch: number | undefined;
|
|
98
127
|
private approvalStopReason = "";
|
|
99
128
|
private activeDeliveryId: string | undefined;
|
|
129
|
+
private currentObserverInputContext: OwnedInputContext | undefined;
|
|
130
|
+
private observerInputReleasedId: string | undefined;
|
|
100
131
|
private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
|
|
101
132
|
/** What served the last call, for `ahub status`. */
|
|
102
133
|
lastServedBy = "";
|
|
@@ -113,7 +144,8 @@ export class LocalPeer extends BasePeer {
|
|
|
113
144
|
await this.requireBudget(envs, "model_calls");
|
|
114
145
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
115
146
|
if (signal.aborted) throw new Error("turn cancelled before model request");
|
|
116
|
-
const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
147
|
+
const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
|
|
148
|
+
this.releaseObserverInput(this.currentObserverInputContext, envs);
|
|
117
149
|
const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
|
|
118
150
|
this.recordUsage(result, model, task);
|
|
119
151
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
@@ -155,6 +187,14 @@ export class LocalPeer extends BasePeer {
|
|
|
155
187
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
|
|
156
188
|
throw new Error(`${this.id} is ${this.state}`);
|
|
157
189
|
}
|
|
190
|
+
const inputContext: OwnedInputContext = Object.freeze({ sessionId: this.observerSessionId, inputId: randomUUID() });
|
|
191
|
+
try { this.opts.beforeInput?.(inputContext, envs); }
|
|
192
|
+
catch {
|
|
193
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "review input provenance unavailable" });
|
|
194
|
+
throw new Error("review input provenance unavailable");
|
|
195
|
+
}
|
|
196
|
+
this.currentObserverInputContext = inputContext;
|
|
197
|
+
this.observerInputReleasedId = undefined;
|
|
158
198
|
const turn = ++this.turn;
|
|
159
199
|
this.budgetStopReason = "";
|
|
160
200
|
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
|
|
@@ -165,21 +205,22 @@ export class LocalPeer extends BasePeer {
|
|
|
165
205
|
if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
|
|
166
206
|
// The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
|
|
167
207
|
// never leave a tool call without its result (strict servers reject that history forever after).
|
|
168
|
-
const msgs: ChatMessage[] = [{ role: "user", content: renderDigest(envs, true) }];
|
|
208
|
+
const msgs: ChatMessage[] = [{ role: "user", content: [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n") }];
|
|
169
209
|
const progress = { sideEffects: 0, last: "" };
|
|
170
210
|
const requestedPolicy = this.opts.turnPolicy?.(envs);
|
|
171
211
|
const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
|
|
172
212
|
const remoteTurn = envs.some(remoteEnvelope);
|
|
213
|
+
const reviewScope = { excluded: envs.some(isReviewObserverExcluded) };
|
|
173
214
|
const preserveHistory = !policy?.pii && !remoteTurn;
|
|
174
215
|
const labelTurn = this.beginRouteTurn(turn, !!policy?.pii || envs.some(env => env.private), policy?.task);
|
|
175
216
|
let completed = false;
|
|
176
217
|
// A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
|
|
177
218
|
// The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
|
|
178
219
|
const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), to: replyAudience(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
|
|
179
|
-
this.run(envs, turn, msgs, progress, policy, reply)
|
|
220
|
+
this.run(envs, turn, msgs, progress, policy, reply, reviewScope)
|
|
180
221
|
.then((answer) => {
|
|
181
222
|
if (turn !== this.turn) return;
|
|
182
|
-
if (preserveHistory) this.commit(msgs); // Private and
|
|
223
|
+
if (preserveHistory && !reviewScope.excluded) this.commit(msgs); // Private, remote and review turns never enter later local capture through model history.
|
|
183
224
|
if (answer) this.onMessage?.(answer, reply);
|
|
184
225
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
|
|
185
226
|
completed = true;
|
|
@@ -187,19 +228,19 @@ export class LocalPeer extends BasePeer {
|
|
|
187
228
|
.catch((e: Error) => {
|
|
188
229
|
if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
|
|
189
230
|
if (e instanceof ApprovalWaitStop) {
|
|
190
|
-
const message = remoteTurn ? "remote turn failed" : e.message;
|
|
231
|
+
const message = remoteTurn ? "remote turn failed" : reviewScope.excluded ? "review turn failed" : e.message;
|
|
191
232
|
this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
|
|
192
233
|
msgs.push({ role: "assistant", content: message });
|
|
193
|
-
if (preserveHistory) this.commit(msgs);
|
|
234
|
+
if (preserveHistory && !reviewScope.excluded) this.commit(msgs);
|
|
194
235
|
this.onMessage?.(message, reply);
|
|
195
236
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: message });
|
|
196
237
|
return; // even without effects, an unanswered approval must not trigger safe replay
|
|
197
238
|
}
|
|
198
239
|
if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
|
|
199
|
-
const message = remoteTurn ? "remote turn failed" : e.message;
|
|
240
|
+
const message = remoteTurn ? "remote turn failed" : reviewScope.excluded ? "review turn failed" : e.message;
|
|
200
241
|
this.opts.log?.(`[${this.id}] turn failed: ${message}`);
|
|
201
242
|
if (e instanceof ExecutionBudgetStop) {
|
|
202
|
-
this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory, deliveryId, remoteTurn);
|
|
243
|
+
this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory || reviewScope.excluded, deliveryId, remoteTurn, reviewScope.excluded);
|
|
203
244
|
return;
|
|
204
245
|
}
|
|
205
246
|
if (!progress.sideEffects) {
|
|
@@ -209,9 +250,9 @@ export class LocalPeer extends BasePeer {
|
|
|
209
250
|
}
|
|
210
251
|
// Tools already changed things. Redelivering would redo approved writes and commits, so report instead.
|
|
211
252
|
// A model call is the only thing that throws here, and every tool call before it has its result: msgs is consistent.
|
|
212
|
-
const note = remoteTurn ? message : `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
|
|
253
|
+
const note = remoteTurn || reviewScope.excluded ? message : `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
|
|
213
254
|
msgs.push({ role: "assistant", content: note });
|
|
214
|
-
if (preserveHistory) this.commit(msgs);
|
|
255
|
+
if (preserveHistory && !reviewScope.excluded) this.commit(msgs);
|
|
215
256
|
this.onMessage?.(note, reply);
|
|
216
257
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: message });
|
|
217
258
|
})
|
|
@@ -225,7 +266,7 @@ export class LocalPeer extends BasePeer {
|
|
|
225
266
|
|
|
226
267
|
protected override onWatchdog(): void {
|
|
227
268
|
this.endRouteTurn(this.labelTurnId, "failed");
|
|
228
|
-
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout" });
|
|
269
|
+
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout", failureCause: "timeout" });
|
|
229
270
|
this.activeDeliveryId = undefined;
|
|
230
271
|
this.turn++;
|
|
231
272
|
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
@@ -240,6 +281,7 @@ export class LocalPeer extends BasePeer {
|
|
|
240
281
|
progress: { sideEffects: number; last: string },
|
|
241
282
|
policy: { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined,
|
|
242
283
|
reply: EnvelopeOpts,
|
|
284
|
+
reviewScope: { excluded: boolean },
|
|
243
285
|
): Promise<string> {
|
|
244
286
|
const { maxSteps = 30 } = this.opts;
|
|
245
287
|
const turnSignal = this.abort!.signal;
|
|
@@ -266,9 +308,10 @@ export class LocalPeer extends BasePeer {
|
|
|
266
308
|
const concluded = await this.conclude(msgs, policy, turn, step, maxSteps, progress.last, turnSignal, res.routeDecision);
|
|
267
309
|
if (turn !== this.turn) return "";
|
|
268
310
|
if (concluded === undefined) continue;
|
|
269
|
-
if (usedTools) capture?.summarize(concluded);
|
|
311
|
+
if (usedTools && !reviewScope.excluded) capture?.summarize(concluded);
|
|
270
312
|
return concluded;
|
|
271
313
|
}
|
|
314
|
+
if (res.message.tool_calls.some(call => call.function.name.replace(/^mcp__[^ ]+__/, "") === "hub_review" && reviewCaptureExcluded(call.function.name, call.function.arguments))) reviewScope.excluded = true;
|
|
272
315
|
const results: ChatMessage[] = [];
|
|
273
316
|
try {
|
|
274
317
|
for (const call of res.message.tool_calls) {
|
|
@@ -279,7 +322,13 @@ export class LocalPeer extends BasePeer {
|
|
|
279
322
|
await this.requireBudget(envs, "tool_calls");
|
|
280
323
|
if (turnSignal.aborted) throw new ExecutionBudgetStop(this.budgetStopReason || "turn cancelled before tool execution");
|
|
281
324
|
} catch (error) { clearInterval(alive); throw error; }
|
|
282
|
-
const
|
|
325
|
+
const taskToolName = name.replace(/^mcp__[^ ]+__/, "");
|
|
326
|
+
const reviewNoteWrite = taskToolName === "hub_remember" || taskToolName === "hub_task_propose";
|
|
327
|
+
const running = reviewScope.excluded && reviewNoteWrite
|
|
328
|
+
? Promise.resolve("error: anchored review in this batch; save notes in a later turn")
|
|
329
|
+
: (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) && this.opts.taskTool
|
|
330
|
+
? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii, signal: turnSignal }).catch((e: Error) => `error: ${e.message}`)
|
|
331
|
+
: runTool(name, call.function.arguments, ctx);
|
|
283
332
|
const output = await running.finally(() => clearInterval(alive));
|
|
284
333
|
if (turn !== this.turn) return "";
|
|
285
334
|
this.touch();
|
|
@@ -288,15 +337,16 @@ export class LocalPeer extends BasePeer {
|
|
|
288
337
|
const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
|
|
289
338
|
msgs.push(result); results.push(result);
|
|
290
339
|
if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
|
|
291
|
-
if (!remoteTurn) this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
340
|
+
if (!remoteTurn && !reviewScope.excluded && name !== "hub_set_title" && name !== "hub_ask" && !reviewCaptureExcluded(name, call.function.arguments)) this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
292
341
|
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
293
342
|
// doc_read text is converted document content (a converter can surface hidden text), never memory evidence: not captured, whatever the turn.
|
|
294
343
|
// web_fetch text is a third party's page and its URL is the model's own request: neither is memory evidence either (#515b).
|
|
295
|
-
if (name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
344
|
+
if (!reviewScope.excluded && name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
296
345
|
}
|
|
297
346
|
} finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
|
|
298
347
|
}
|
|
299
|
-
if (usedTools) capture?.summarize(progress.last);
|
|
348
|
+
if (usedTools && !reviewScope.excluded) capture?.summarize(progress.last);
|
|
349
|
+
this.opts.onLimit?.("steps");
|
|
300
350
|
return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
|
|
301
351
|
}
|
|
302
352
|
|
|
@@ -353,10 +403,11 @@ export class LocalPeer extends BasePeer {
|
|
|
353
403
|
};
|
|
354
404
|
}
|
|
355
405
|
|
|
356
|
-
private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false): void {
|
|
357
|
-
|
|
406
|
+
private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false, reviewTurn = false): void {
|
|
407
|
+
this.opts.onLimit?.("budget");
|
|
408
|
+
const message = remoteTurn ? "remote turn failed" : reviewTurn ? "review turn failed" : e.message;
|
|
358
409
|
this.completeMissingToolResults(msgs, `not run: ${message}`);
|
|
359
|
-
const detail = remoteTurn ? message : progress.sideEffects
|
|
410
|
+
const detail = remoteTurn || reviewTurn ? message : progress.sideEffects
|
|
360
411
|
? `(execution budget stopped after ${progress.sideEffects} tool call(s) with side effects; partial work may exist and needs review) ${progress.last}`.trim()
|
|
361
412
|
: `(execution budget stopped before any tool side effects; no work was repeated) ${progress.last}`.trim();
|
|
362
413
|
msgs.push({ role: "assistant", content: detail });
|
|
@@ -401,18 +452,25 @@ export class LocalPeer extends BasePeer {
|
|
|
401
452
|
}
|
|
402
453
|
const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
|
|
403
454
|
const task = policy?.task === undefined ? undefined : Number(policy.task);
|
|
404
|
-
const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
455
|
+
const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
|
|
405
456
|
const signal = this.abort!.signal;
|
|
406
457
|
const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
|
|
407
458
|
const routed = await this.hubCall(route, messages, policy, signal);
|
|
408
459
|
if (routed) return routed;
|
|
409
460
|
await this.requireBudget(envs, "model_calls");
|
|
461
|
+
this.releaseObserverInput(this.currentObserverInputContext, envs);
|
|
410
462
|
const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
|
|
411
463
|
this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
|
|
412
464
|
this.recordUsage(res, fixedModel, task);
|
|
413
465
|
return res;
|
|
414
466
|
}
|
|
415
467
|
|
|
468
|
+
private releaseObserverInput(context: OwnedInputContext | undefined, envs: Envelope[]): void {
|
|
469
|
+
if (!context || this.currentObserverInputContext !== context || this.observerInputReleasedId === context.inputId) return;
|
|
470
|
+
this.observerInputReleasedId = context.inputId;
|
|
471
|
+
try { this.opts.inputReleased?.(context, envs); } catch { /* observer metadata never changes the local model result */ }
|
|
472
|
+
}
|
|
473
|
+
|
|
416
474
|
private beginRouteTurn(generation: number, pii: boolean, task?: string): string {
|
|
417
475
|
let id: string | undefined;
|
|
418
476
|
try { id = this.opts.turnId?.(); } catch { /* optional host metadata */ }
|
|
@@ -457,6 +515,7 @@ export class LocalPeer extends BasePeer {
|
|
|
457
515
|
const feedback = await this.routes.review(route, config, messages, this.routeScope(!!policy?.pii, turn), !!policy?.pii, signal, decisionId);
|
|
458
516
|
if (turn !== this.turn || !feedback) return answer;
|
|
459
517
|
msgs.push({ role: "user", content: feedback });
|
|
518
|
+
if (step + 1 >= maxSteps) this.opts.onLimit?.("steps");
|
|
460
519
|
return step + 1 < maxSteps ? undefined : `(review requested changes; step limit reached) ${answer}`;
|
|
461
520
|
}
|
|
462
521
|
|