@staix/agent-hub 0.12.14 → 0.12.16
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 +17 -0
- package/README.md +16 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/cooperbench.md +17 -1
- package/docs/operations.md +178 -9
- package/docs/quickstart.md +2 -2
- package/docs/security.md +2 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +104 -0
- package/docs/verified.json +159 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4 -4
- package/src/adapters/codex-appserver.ts +7 -0
- package/src/adapters/pi.ts +28 -19
- package/src/cli/init.ts +40 -30
- package/src/cli/launch.ts +18 -1
- package/src/cli/main.ts +34 -11
- package/src/cli/preview.ts +80 -0
- package/src/cli/status-lines.ts +8 -2
- package/src/cli/statusline-tee.ts +14 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +5 -2
- package/src/hub/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +126 -32
- package/src/hub/events.ts +1 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +57 -1
- package/src/models/relay.ts +79 -4
- package/src/pi/ceiling.ts +54 -0
- package/src/pi/extension.ts +20 -2
- package/src/pi/launch.ts +20 -0
- package/src/ui/index.html +6 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"documents": {
|
|
4
|
+
"README.md": {
|
|
5
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
6
|
+
"paths": [
|
|
7
|
+
"package.json",
|
|
8
|
+
"src/",
|
|
9
|
+
"templates/",
|
|
10
|
+
"plugins/agent-hub/.claude-plugin/plugin.json"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"docs/security.md": {
|
|
14
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
15
|
+
"paths": [
|
|
16
|
+
"src/",
|
|
17
|
+
"templates/"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"docs/operations.md": {
|
|
21
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
22
|
+
"paths": [
|
|
23
|
+
"package.json",
|
|
24
|
+
"src/",
|
|
25
|
+
"templates/",
|
|
26
|
+
"scripts/overlaps.ts",
|
|
27
|
+
"scripts/check-docs.mjs",
|
|
28
|
+
"scripts/seeded-check.ts",
|
|
29
|
+
"scripts/seeds.json",
|
|
30
|
+
".github/workflows/check.yml",
|
|
31
|
+
"scripts/ci-gates.mjs",
|
|
32
|
+
"scripts/ci-reuse.mjs",
|
|
33
|
+
".github/workflows/release.yml"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"docs/quickstart.md": {
|
|
37
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
38
|
+
"paths": [
|
|
39
|
+
"package.json",
|
|
40
|
+
"src/",
|
|
41
|
+
"templates/"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"docs/agent-notes/adapters.md": {
|
|
45
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
46
|
+
"paths": [
|
|
47
|
+
"src/adapters/",
|
|
48
|
+
"src/pi/",
|
|
49
|
+
"src/hub/peers.ts",
|
|
50
|
+
"src/hub/child-process.ts",
|
|
51
|
+
"src/hub/lifecycle.ts",
|
|
52
|
+
"src/memory/capture.ts",
|
|
53
|
+
"scripts/benchmarks/teardown.ts",
|
|
54
|
+
"src/hub/daemon.ts"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
"docs/agent-notes/benchmarks.md": {
|
|
58
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
59
|
+
"paths": [
|
|
60
|
+
"scripts/benchmarks/",
|
|
61
|
+
"test/benchmarks/",
|
|
62
|
+
"src/pi/",
|
|
63
|
+
"src/adapters/acp.ts",
|
|
64
|
+
"src/adapters/pi.ts",
|
|
65
|
+
"src/models/relay.ts",
|
|
66
|
+
"src/local/"
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"docs/agent-notes/budget.md": {
|
|
70
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
71
|
+
"paths": [
|
|
72
|
+
"src/hub/budget.ts",
|
|
73
|
+
"src/cli/statusline-tee.ts",
|
|
74
|
+
"src/hub/bus.ts",
|
|
75
|
+
"src/hub/delivery-journal.ts",
|
|
76
|
+
"src/hub/restart.ts",
|
|
77
|
+
"src/hub/context-window.ts",
|
|
78
|
+
"src/hub/daemon.ts"
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
"docs/agent-notes/bus.md": {
|
|
82
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
83
|
+
"paths": [
|
|
84
|
+
"src/hub/bus.ts",
|
|
85
|
+
"src/hub/envelope.ts",
|
|
86
|
+
"src/hub/limits.ts",
|
|
87
|
+
"src/hub/delivery-journal.ts",
|
|
88
|
+
"src/hub/inference.ts",
|
|
89
|
+
"src/hub/daemon.ts"
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
"docs/agent-notes/daemon.md": {
|
|
93
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
94
|
+
"paths": [
|
|
95
|
+
"src/hub/daemon.ts",
|
|
96
|
+
"src/hub/control-client.ts",
|
|
97
|
+
"src/adapters/claude-channel.ts",
|
|
98
|
+
"src/hub/restart.ts",
|
|
99
|
+
"src/hub/recovery-store.ts",
|
|
100
|
+
"src/hub/snapshots.ts",
|
|
101
|
+
"src/hub/manager.ts",
|
|
102
|
+
"src/hub/lifecycle.ts",
|
|
103
|
+
"src/hub/crash.ts",
|
|
104
|
+
"src/cli/setup.ts",
|
|
105
|
+
"src/cli/upgrade*.ts",
|
|
106
|
+
"src/cli/terminal-recovery.ts",
|
|
107
|
+
"src/cli/recovery-package.ts",
|
|
108
|
+
"src/cli/facts-hook.ts",
|
|
109
|
+
"src/pi/process-signature.ts",
|
|
110
|
+
"src/hub/context-window.ts"
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
"docs/agent-notes/local-worker.md": {
|
|
114
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
115
|
+
"paths": [
|
|
116
|
+
"src/adapters/local-worker.ts",
|
|
117
|
+
"src/local/",
|
|
118
|
+
"src/memory/capture.ts",
|
|
119
|
+
"src/hub/facts.ts"
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
"docs/agent-notes/models.md": {
|
|
123
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
124
|
+
"paths": [
|
|
125
|
+
"src/models/",
|
|
126
|
+
"src/hub/inference.ts",
|
|
127
|
+
"src/omniroute/",
|
|
128
|
+
"src/switchyard/"
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"docs/agent-notes/tasks.md": {
|
|
132
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
133
|
+
"paths": [
|
|
134
|
+
"src/hub/tasks.ts",
|
|
135
|
+
"src/hub/board.ts",
|
|
136
|
+
"src/hub/routing.ts",
|
|
137
|
+
"src/hub/hub-tools.ts",
|
|
138
|
+
"src/hub/task-sweep.ts",
|
|
139
|
+
"src/cli/launch.ts",
|
|
140
|
+
"src/cli/main.ts",
|
|
141
|
+
"src/cli/preview.ts"
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
"docs/agent-notes/tests.md": {
|
|
145
|
+
"verifiedAgainst": "2f6274d007d47aa3db0976c4989021520c03a3ef",
|
|
146
|
+
"paths": [
|
|
147
|
+
"test/",
|
|
148
|
+
"scripts/check.sh",
|
|
149
|
+
"scripts/hang-watch.sh",
|
|
150
|
+
"scripts/seeded-check.ts",
|
|
151
|
+
"scripts/seeds.json",
|
|
152
|
+
"scripts/ci-gates.mjs",
|
|
153
|
+
"scripts/ci-reuse.mjs",
|
|
154
|
+
".github/workflows/check.yml",
|
|
155
|
+
".github/workflows/release.yml"
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.16",
|
|
4
4
|
"description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Young Joon Lee",
|
|
@@ -15291,8 +15291,8 @@ function projectContext(cwd, env = process.env) {
|
|
|
15291
15291
|
function stateDirFor(cwd) {
|
|
15292
15292
|
return projectContext(cwd).stateDir;
|
|
15293
15293
|
}
|
|
15294
|
-
var PROTOCOL =
|
|
15295
|
-
var RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, PROTOCOL];
|
|
15294
|
+
var PROTOCOL = 14;
|
|
15295
|
+
var RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, PROTOCOL];
|
|
15296
15296
|
function readControl(stateDir) {
|
|
15297
15297
|
try {
|
|
15298
15298
|
const status = JSON.parse(readFileSync2(join2(stateDir, "status.json"), "utf8"));
|
|
@@ -15403,7 +15403,7 @@ class ControlClient {
|
|
|
15403
15403
|
// package.json
|
|
15404
15404
|
var package_default = {
|
|
15405
15405
|
name: "@staix/agent-hub",
|
|
15406
|
-
version: "0.12.
|
|
15406
|
+
version: "0.12.16",
|
|
15407
15407
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15408
15408
|
license: "MIT",
|
|
15409
15409
|
type: "module",
|
|
@@ -15499,7 +15499,7 @@ var TASK_TOOLS = [
|
|
|
15499
15499
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
15500
15500
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
15501
15501
|
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
15502
|
-
tool("hub_checkpoint", "Answer a checkpoint request from the hub (
|
|
15502
|
+
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
15503
15503
|
tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"])
|
|
15504
15504
|
];
|
|
15505
15505
|
var TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
@@ -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 { BasePeer } from "../hub/peers.ts";
|
|
5
|
+
import { codexContext, type ContextReading } from "../hub/context-window.ts";
|
|
5
6
|
import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
6
7
|
|
|
7
8
|
export interface CodexOptions {
|
|
@@ -20,6 +21,7 @@ export interface CodexOptions {
|
|
|
20
21
|
onUsage?: (rateLimits: unknown, hard: boolean) => void;
|
|
21
22
|
/** Tokens the thread used since the previous update (see `tokenTotal` for how they are counted). */
|
|
22
23
|
onTokens?: (added: number) => void;
|
|
24
|
+
onContext?: (reading: ContextReading) => void;
|
|
23
25
|
/** The native id of each turn as it starts, after the peer turned busy (issue #33: `ahub undo --context`). */
|
|
24
26
|
onTurn?: (turnId: string) => void;
|
|
25
27
|
/**
|
|
@@ -62,6 +64,8 @@ export class CodexPeer extends BasePeer {
|
|
|
62
64
|
/** Claimed as soon as a TUI WebSocket opens, before it can start a thread. */
|
|
63
65
|
private claimedTui: Link | undefined;
|
|
64
66
|
private threadId = "";
|
|
67
|
+
private contextEpoch = 0;
|
|
68
|
+
get sessionGeneration(): number { return this.contextEpoch; }
|
|
65
69
|
/**
|
|
66
70
|
* The thread's running token total at the last update, and whether the thread started here. Codex 0.156.1 also
|
|
67
71
|
* sends `thread/tokenUsage/updated` for compaction (an estimate in `last`, `total` unchanged), a usage-limit refresh
|
|
@@ -344,6 +348,7 @@ export class CodexPeer extends BasePeer {
|
|
|
344
348
|
clearInterval(this.usageTimer);
|
|
345
349
|
this.link = undefined;
|
|
346
350
|
this.threadId = "";
|
|
351
|
+
this.contextEpoch++;
|
|
347
352
|
this.activeTurns.clear();
|
|
348
353
|
for (const p of this.pending.values()) {
|
|
349
354
|
if (p.deliveryId) this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
|
|
@@ -396,6 +401,7 @@ export class CodexPeer extends BasePeer {
|
|
|
396
401
|
this.freshThread = fresh;
|
|
397
402
|
this.link = link;
|
|
398
403
|
this.threadId = threadId;
|
|
404
|
+
this.contextEpoch++;
|
|
399
405
|
this.activeTurns.clear();
|
|
400
406
|
this.forgetTurn();
|
|
401
407
|
this.opts.log?.(`[${this.id}] thread ${threadId}`);
|
|
@@ -440,6 +446,7 @@ export class CodexPeer extends BasePeer {
|
|
|
440
446
|
}
|
|
441
447
|
if (link !== this.link || params.threadId !== this.threadId) return;
|
|
442
448
|
if (method === "thread/tokenUsage/updated") {
|
|
449
|
+
this.opts.onContext?.(codexContext(params.tokenUsage, this.threadId, Date.now()));
|
|
443
450
|
const total = Number(params.tokenUsage?.total?.totalTokens);
|
|
444
451
|
if (!Number.isFinite(total)) return;
|
|
445
452
|
const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
|
package/src/adapters/pi.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildPiLaunch } from "../pi/launch.ts";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
4
|
import { join, resolve, relative } from "node:path";
|
|
@@ -7,6 +8,8 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
7
8
|
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
9
|
import { processSignature } from "../pi/process-signature.ts";
|
|
9
10
|
import { realPath } from "../hub/project.ts";
|
|
11
|
+
import { toolResultFailed } from "../local/tools.ts";
|
|
12
|
+
import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
|
|
10
13
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
11
14
|
|
|
12
15
|
export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
|
|
@@ -22,7 +25,8 @@ export interface PiOptions {
|
|
|
22
25
|
admitBudget?: (envs: Envelope[], unit: ExecutionUnit) => Promise<ExecutionBudgetDecision[]>;
|
|
23
26
|
/** Reported message usage as increments, before agent_settled releases the turn. */
|
|
24
27
|
onTokens?: (added: number) => void;
|
|
25
|
-
|
|
28
|
+
/** `ceiling` is the validated, session/turn-bound tool-step ceiling signal (#179), present only when the trusted extension emitted one for this turn. */
|
|
29
|
+
onTurnFailure?: (envs: Envelope[], reason: string, ceiling?: PiToolStepCeiling) => Promise<void>;
|
|
26
30
|
watchdogMs?: number; log?: (line: string) => void;
|
|
27
31
|
/** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
|
|
28
32
|
stopGraceMs?: number;
|
|
@@ -32,14 +36,6 @@ type RpcMessage = { type?: string; id?: string | number; command?: string; succe
|
|
|
32
36
|
type VerifiedEmptyResume = { sessionId: string };
|
|
33
37
|
function ownerStillAlive(pid: number, signature: string | undefined): boolean { const current = processSignature(pid); if (current !== undefined) return current === signature; try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
34
38
|
|
|
35
|
-
const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
|
|
36
|
-
const ids = models.map((m) => m.id);
|
|
37
|
-
const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
|
|
38
|
-
if (backend === "mlx") return find("mlx/fast");
|
|
39
|
-
if (backend === "dgx") return find("dgx/coding");
|
|
40
|
-
if (ids.includes("hub/auto")) return "hub/auto";
|
|
41
|
-
return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
|
|
42
|
-
};
|
|
43
39
|
|
|
44
40
|
export class PiPeer extends BasePeer {
|
|
45
41
|
readonly hubNative = true;
|
|
@@ -58,6 +54,7 @@ export class PiPeer extends BasePeer {
|
|
|
58
54
|
private executionBudgetTimer?: ReturnType<typeof setTimeout>;
|
|
59
55
|
private executionAbort?: AbortController;
|
|
60
56
|
private readonly budgetStops = new Map<number, string>();
|
|
57
|
+
private readonly ceilingStops = new Map<number, PiToolStepCeiling>();
|
|
61
58
|
private readonly idleBashReservations = new Map<string, { generation: number; expiresAt: number; deadlineAt?: number }>();
|
|
62
59
|
private budgetGeneration = 0;
|
|
63
60
|
private modelStep = 0;
|
|
@@ -185,8 +182,11 @@ export class PiPeer extends BasePeer {
|
|
|
185
182
|
this.activeTools++;
|
|
186
183
|
if (this.state === "idle") this.setState("busy");
|
|
187
184
|
if (this.state === "busy") this.touch();
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
// `failed` is the managed-tool failure verdict (toolResultFailed, the one contract): Pi 1.0.1
|
|
186
|
+
// classifies a native tool result by isError === true alone, so the extension needs the flag,
|
|
187
|
+
// not just the text (#181). An older extension simply ignores the extra field.
|
|
188
|
+
try { const text = await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal); return Response.json({ text, failed: toolResultFailed(String(body.name), text) }); }
|
|
189
|
+
catch (error) { return Response.json({ text: `error: ${(error as Error).message}`, failed: true }, { status: 200 }); }
|
|
190
190
|
finally {
|
|
191
191
|
clearTimeout(idleBashTimer);
|
|
192
192
|
this.activeTools--;
|
|
@@ -267,14 +267,11 @@ export class PiPeer extends BasePeer {
|
|
|
267
267
|
const extension = resolve(join(import.meta.dir, "../pi/extension.ts"));
|
|
268
268
|
const inherited: NodeJS.ProcessEnv = {};
|
|
269
269
|
for (const key of ["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TERM", "TERM_PROGRAM", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "CODEX_HOME"]) if (process.env[key]) inherited[key] = process.env[key];
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
if (this.opts.sessionFile) args.push("--session", this.opts.sessionFile); else if (this.opts.sessionId) args.push("--session-id", this.opts.sessionId);
|
|
273
|
-
const command = this.opts.cmd ?? ["pi"];
|
|
274
|
-
this._tuiLaunch = { cmd: command[0]!, args: [...command.slice(1), ...args], env };
|
|
270
|
+
this._tuiLaunch = buildPiLaunch(this.opts, inherited, extension, bridge.port!, token);
|
|
271
|
+
const { cmd: bin, args, env } = this._tuiLaunch;
|
|
275
272
|
if (this.opts.mode === "tui") { return; }
|
|
276
273
|
// Its own process group, stopped as a whole (#115, as Codex's in #113).
|
|
277
|
-
this.proc = spawn(
|
|
274
|
+
this.proc = spawn(bin, args, { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
278
275
|
trackGroup(this.proc);
|
|
279
276
|
this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
|
|
280
277
|
this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
|
|
@@ -426,7 +423,17 @@ export class PiPeer extends BasePeer {
|
|
|
426
423
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
|
|
427
424
|
if (generation <= this.budgetGeneration) return;
|
|
428
425
|
this.modelStep = 0;
|
|
429
|
-
this.usageSeen.clear(); this.idleBashReservations.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
|
|
426
|
+
this.usageSeen.clear(); this.idleBashReservations.clear(); this.ceilingStops.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
|
|
427
|
+
}
|
|
428
|
+
// #179: the extension's tool-step ceiling signal. Only a schema-valid event bound to THIS session,
|
|
429
|
+
// THIS turn generation and a running turn is stored; the first signal of a turn stands. Anything
|
|
430
|
+
// else (stale, cross-session, free text) is dropped and the failure stays unclassified.
|
|
431
|
+
if (event.type === "ceiling") {
|
|
432
|
+
const ceiling = piToolStepCeiling(event);
|
|
433
|
+
if (ceiling && ceiling.sessionId === this.sessionId && ceiling.generation === this.budgetGeneration && this.agentRunning && this.state === "busy" && !this.ceilingStops.has(ceiling.generation)) {
|
|
434
|
+
this.ceilingStops.set(ceiling.generation, ceiling);
|
|
435
|
+
for (const old of this.ceilingStops.keys()) if (old < ceiling.generation - 8) this.ceilingStops.delete(old);
|
|
436
|
+
}
|
|
430
437
|
}
|
|
431
438
|
if (event.type === "activity" && this.state === "busy") this.touch();
|
|
432
439
|
if (event.type === "tokens" && this.state === "busy" && typeof event.id === "string" && event.id.length <= 100 && Number.isSafeInteger(event.tokens) && event.tokens >= 0 && !this.usageSeen.has(event.id)) {
|
|
@@ -451,14 +458,16 @@ export class PiPeer extends BasePeer {
|
|
|
451
458
|
if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
|
|
452
459
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
|
|
453
460
|
const text = this.settledText.trim(); const error = this.budgetStops.get(generation) ?? this.settledError; const cancelled = !error && this.settledCancelled;
|
|
461
|
+
const ceiling = this.ceilingStops.get(generation);
|
|
454
462
|
this.budgetStops.delete(generation);
|
|
463
|
+
this.ceilingStops.delete(generation);
|
|
455
464
|
this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
|
|
456
465
|
// Answer the peers this turn was for, not every peer on the bus (issue #29). activeEnvs still holds the
|
|
457
466
|
// whole delivery here, steered additions included.
|
|
458
467
|
const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
|
|
459
468
|
if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
|
|
460
469
|
else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
|
|
461
|
-
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
|
|
470
|
+
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
|
|
462
471
|
else if (text) this.onMessage?.(text, reply);
|
|
463
472
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
464
473
|
this.activeDeliveryIds.clear();
|
package/src/cli/init.ts
CHANGED
|
@@ -23,47 +23,57 @@ export function removeBlock(existing: string): string {
|
|
|
23
23
|
return rest && `${rest}\n`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
export interface InitChange {
|
|
27
|
+
action: "create" | "modify" | "delete";
|
|
28
|
+
path: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
managedBlock?: "insert" | "replace" | "remove";
|
|
31
|
+
}
|
|
32
|
+
/** Content is internal to apply, never included in a public preview. */
|
|
33
|
+
interface PlannedWrite extends InitChange { next?: string; }
|
|
34
|
+
function changes(cwd: string): PlannedWrite[] {
|
|
35
|
+
const changed: PlannedWrite[] = [];
|
|
36
|
+
const write = (path: string, next: string, reason: string, managedBlock?: InitChange["managedBlock"]) => {
|
|
37
|
+
const exists = existsSync(path);
|
|
38
|
+
if (exists && readFileSync(path, "utf8") === next) return;
|
|
39
|
+
changed.push({ action: exists ? "modify" : "create", path, next, reason, ...(managedBlock ? { managedBlock } : {}) });
|
|
33
40
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const routing = join(cwd, ".agenthub", "routing.toml");
|
|
40
|
-
if (!existsSync(routing)) write(routing, readFileSync(join(TEMPLATES, "routing.toml"), "utf8"));
|
|
41
|
-
|
|
41
|
+
for (const name of ["config.json", "routing.toml"]) {
|
|
42
|
+
const path = join(cwd, ".agenthub", name);
|
|
43
|
+
if (!existsSync(path)) write(path, readFileSync(join(TEMPLATES, name), "utf8"), "create missing project defaults");
|
|
44
|
+
}
|
|
42
45
|
const agents = join(cwd, "AGENTS.md");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// Any CLAUDE.md, even an empty one, stops Claude Code from loading AGENTS.md: take back the block older versions wrote there.
|
|
46
|
-
// Only a regular file of its own: a symlink may lead out of the project, and a link to AGENTS.md
|
|
47
|
-
// (hard, or AGENTS.md -> CLAUDE.md) holds the block just written.
|
|
46
|
+
const existing = existsSync(agents) ? readFileSync(agents, "utf8") : "";
|
|
47
|
+
write(agents, upsertBlock(existing, readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8")), "refresh hub instructions; preserve surrounding text", existing.includes("<!-- AGENT_HUB:BEGIN") ? "replace" : "insert");
|
|
48
48
|
const claude = join(cwd, "CLAUDE.md");
|
|
49
49
|
const own = lstatSync(claude, { throwIfNoEntry: false });
|
|
50
|
-
|
|
51
|
-
const
|
|
50
|
+
// The write preserves an existing inode, including AGENTS.md -> CLAUDE.md and hard links.
|
|
51
|
+
const target = existsSync(agents) ? statSync(agents) : undefined;
|
|
52
|
+
const legacy = own?.isFile() && !(target && own.dev === target.dev && own.ino === target.ino) ? readFileSync(claude, "utf8") : "";
|
|
52
53
|
const rest = removeBlock(legacy);
|
|
53
54
|
if (rest !== legacy) {
|
|
54
|
-
if (rest) write(claude, rest);
|
|
55
|
-
else {
|
|
56
|
-
unlinkSync(claude);
|
|
57
|
-
changed.push(claude);
|
|
58
|
-
}
|
|
55
|
+
if (rest) write(claude, rest, "remove legacy hub instructions; preserve surrounding text", "remove");
|
|
56
|
+
else changed.push({ action: "delete", path: claude, reason: "remove legacy-only instructions", managedBlock: "remove" });
|
|
59
57
|
}
|
|
60
|
-
|
|
61
58
|
const ignore = join(cwd, ".gitignore");
|
|
62
59
|
let lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
|
|
63
|
-
// The machine's own config: only a file nobody committed may choose commands and credential files (issue #17).
|
|
64
60
|
for (const entry of [".agenthub/state/", ".agenthub/config.local.json"]) {
|
|
65
61
|
if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
|
|
66
62
|
}
|
|
67
|
-
write(ignore, lines);
|
|
63
|
+
write(ignore, lines, "ignore runtime state and machine-local configuration");
|
|
68
64
|
return changed;
|
|
69
65
|
}
|
|
66
|
+
/** Read-only metadata, deliberately omitting project/user file contents. */
|
|
67
|
+
export function planInit(cwd: string): InitChange[] {
|
|
68
|
+
return changes(cwd).map(({ next, ...change }) => change);
|
|
69
|
+
}
|
|
70
|
+
/** Returns the paths it changed, applying the same plan as the preview. */
|
|
71
|
+
export function init(cwd: string): string[] {
|
|
72
|
+
const plan = changes(cwd);
|
|
73
|
+
mkdirSync(join(cwd, ".agenthub"), { recursive: true });
|
|
74
|
+
for (const change of plan) {
|
|
75
|
+
if (change.action === "delete") unlinkSync(change.path);
|
|
76
|
+
else writeFileSync(change.path, change.next!);
|
|
77
|
+
}
|
|
78
|
+
return plan.map(change => change.path);
|
|
79
|
+
}
|
package/src/cli/launch.ts
CHANGED
|
@@ -55,6 +55,15 @@ export function statusLineSettings(tee: StatusLineTee): string {
|
|
|
55
55
|
export interface FactsHook {
|
|
56
56
|
script: string;
|
|
57
57
|
stateDir: string;
|
|
58
|
+
/** The same hook transport can observe native turns without injecting facts. */
|
|
59
|
+
purpose?: "facts" | "idle" | "facts-and-idle";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Shared by actual launch and read-only preview; advisory facts remain disabled. */
|
|
63
|
+
export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean } }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook | undefined {
|
|
64
|
+
const facts = config.coordination === "turn-free";
|
|
65
|
+
const idle = config.task_sweep?.enabled === true;
|
|
66
|
+
return facts || idle ? { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : "idle" } : undefined;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
/**
|
|
@@ -90,7 +99,8 @@ export function buildLaunch(
|
|
|
90
99
|
const notes = [
|
|
91
100
|
unattended ? UNATTENDED_WARNING : "",
|
|
92
101
|
ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : "",
|
|
93
|
-
ctx.facts && own ? "note: you passed --settings, so the hub's turn-free facts hooks are off for this session: Claude will not see the other agents' changes at its tool calls." : "",
|
|
102
|
+
ctx.facts && own && ctx.facts.purpose !== "idle" ? "note: you passed --settings, so the hub's turn-free facts hooks are off for this session: Claude will not see the other agents' changes at its tool calls." : "",
|
|
103
|
+
ctx.facts && own && (ctx.facts.purpose === "idle" || ctx.facts.purpose === "facts-and-idle") ? "note: you passed --settings, so native idle observation hooks are off for this session: task idle sweeps cannot verify Claude between turns." : "",
|
|
94
104
|
].filter(Boolean);
|
|
95
105
|
return {
|
|
96
106
|
cmd: "claude",
|
|
@@ -105,3 +115,10 @@ export function buildLaunch(
|
|
|
105
115
|
...warning,
|
|
106
116
|
};
|
|
107
117
|
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
/** The daemon and preview use exactly the same ACP command insertion. */
|
|
121
|
+
export function buildKimiLaunch(command: string[], model?: string): Launch {
|
|
122
|
+
const [cmd, ...args] = command;
|
|
123
|
+
return { cmd: cmd!, args: model ? ["--model", model, ...args] : args };
|
|
124
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -14,8 +14,9 @@ import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
|
14
14
|
import type { BusEvent } from "../hub/bus.ts";
|
|
15
15
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
16
16
|
import { MemoryClient } from "../memory/client.ts";
|
|
17
|
-
import { init } from "./init.ts";
|
|
18
|
-
import {
|
|
17
|
+
import { init, planInit } from "./init.ts";
|
|
18
|
+
import { launcherPreview } from "./preview.ts";
|
|
19
|
+
import { buildLaunch, claudeObservationHooks, UNATTENDED_WARNING } from "./launch.ts";
|
|
19
20
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
20
21
|
import { CLASSES } from "../hub/board.ts";
|
|
21
22
|
import { VERSION } from "../version.ts";
|
|
@@ -29,7 +30,8 @@ import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
|
29
30
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
30
31
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
31
32
|
|
|
32
|
-
import {
|
|
33
|
+
import { unknownContext } from "../hub/context-window.ts";
|
|
34
|
+
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
33
35
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
34
36
|
import { formatReport, summarize } from "../hub/report.ts";
|
|
35
37
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
@@ -53,15 +55,15 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
53
55
|
ahub ui --all [--no-open] open the unified project dashboard
|
|
54
56
|
ahub ui --all --stop stop only the dashboard manager
|
|
55
57
|
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
56
|
-
ahub init
|
|
58
|
+
ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
|
|
57
59
|
ahub up [--unattended] start the daemon for this directory
|
|
58
60
|
ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
|
|
59
61
|
ahub restart [--dry-run] [--yes] recover this project's runtime
|
|
60
62
|
ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
|
|
61
|
-
ahub claude [args...] launch Claude Code with the hub channel [--unattended]
|
|
62
|
-
ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
63
|
-
ahub kimi [--model <alias>] start Kimi headless under ACP
|
|
64
|
-
ahub pi [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
|
|
63
|
+
ahub claude [--print-command] [args...] launch Claude Code with the hub channel [--unattended]
|
|
64
|
+
ahub codex [--print-command] [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
65
|
+
ahub kimi [--print-command] [--model <alias>] start Kimi headless under ACP
|
|
66
|
+
ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
|
|
65
67
|
ahub models setup|status|start|stop prepare or inspect local Ollama MLX (legacy stop is explicit)
|
|
66
68
|
ahub local [--route <id> | --model <id>]
|
|
67
69
|
start the hub-native worker on the self-hosted models (routing.toml)
|
|
@@ -469,6 +471,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
469
471
|
},
|
|
470
472
|
|
|
471
473
|
init: () => {
|
|
474
|
+
if (args.includes("--dry-run")) {
|
|
475
|
+
const plan = planInit(cwd);
|
|
476
|
+
return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
|
|
477
|
+
}
|
|
472
478
|
assertLifecycleAvailable();
|
|
473
479
|
const changed = init(cwd);
|
|
474
480
|
registeredProject();
|
|
@@ -490,6 +496,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
490
496
|
},
|
|
491
497
|
|
|
492
498
|
claude: async () => {
|
|
499
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
500
|
+
try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
501
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
502
|
+
}
|
|
493
503
|
assertLifecycleAvailable();
|
|
494
504
|
const control = readControl(stateDir);
|
|
495
505
|
if (control?.instanceId) {
|
|
@@ -506,14 +516,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
506
516
|
// no such file, or no status line in it
|
|
507
517
|
}
|
|
508
518
|
}
|
|
509
|
-
//
|
|
510
|
-
const facts = projectConfig()
|
|
519
|
+
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
520
|
+
const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
511
521
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
512
522
|
if (launch.warning) console.error(launch.warning);
|
|
513
523
|
exec(launch.cmd, launch.args);
|
|
514
524
|
},
|
|
515
525
|
|
|
516
526
|
codex: async () => {
|
|
527
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
528
|
+
try { return console.log(JSON.stringify(launcherPreview("codex", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
529
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
530
|
+
}
|
|
517
531
|
assertLifecycleAvailable();
|
|
518
532
|
const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
|
|
519
533
|
const hub = await connect();
|
|
@@ -528,6 +542,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
528
542
|
},
|
|
529
543
|
|
|
530
544
|
kimi: async () => {
|
|
545
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
546
|
+
try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
547
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
548
|
+
}
|
|
531
549
|
const i = args.indexOf("--model");
|
|
532
550
|
const model = i === -1 ? undefined : args[i + 1] ?? fail("--model needs an alias");
|
|
533
551
|
const hub = await connect();
|
|
@@ -538,6 +556,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
538
556
|
},
|
|
539
557
|
|
|
540
558
|
pi: async () => {
|
|
559
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
560
|
+
try { return console.log(JSON.stringify(launcherPreview("pi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
561
|
+
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
562
|
+
}
|
|
541
563
|
const options = piFlags();
|
|
542
564
|
const hub = await connect();
|
|
543
565
|
const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
@@ -642,6 +664,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
642
664
|
const hub = await connect();
|
|
643
665
|
hub.onPush = (msg) => {
|
|
644
666
|
if (msg.t === "event") console.log(render(msg.e));
|
|
667
|
+
else if (msg.t === "context") console.log(` ${msg.peer}: ${contextLine(msg.reading)}`);
|
|
645
668
|
else if (msg.t === "notice") console.log(` * ${msg.line}`);
|
|
646
669
|
else if (msg.t === "permission") {
|
|
647
670
|
const options = msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ");
|
|
@@ -775,7 +798,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
775
798
|
if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
|
|
776
799
|
for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
|
|
777
800
|
const peers = Object.entries(status.peers as Record<string, PeerRow>);
|
|
778
|
-
for (const [id, p] of peers) console.log(peerLine(id, p));
|
|
801
|
+
for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
|
|
779
802
|
const models = (status as any).models?.backends as BackendRow[] | undefined;
|
|
780
803
|
if (models?.length) for (const backend of models) console.log(backendLine(backend));
|
|
781
804
|
if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
|