@staix/agent-hub 0.12.15 → 0.12.17

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +18 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/budget.md +5 -1
  5. package/docs/agent-notes/bus.md +2 -0
  6. package/docs/agent-notes/tasks.md +3 -1
  7. package/docs/agent-notes/tests.md +3 -1
  8. package/docs/operations.md +277 -11
  9. package/docs/quickstart.md +4 -4
  10. package/docs/security.md +26 -1
  11. package/docs/smoke.md +93 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
  13. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  14. package/docs/verified.json +168 -0
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +18 -6
  18. package/src/adapters/acp.ts +2 -2
  19. package/src/adapters/claude-channel.ts +3 -2
  20. package/src/adapters/codex-appserver.ts +10 -2
  21. package/src/adapters/local-worker.ts +5 -5
  22. package/src/adapters/pi.ts +4 -14
  23. package/src/cli/console-state.ts +213 -0
  24. package/src/cli/console.ts +201 -0
  25. package/src/cli/facts-hook.ts +8 -1
  26. package/src/cli/identity-audit.ts +66 -0
  27. package/src/cli/identity.ts +54 -0
  28. package/src/cli/init.ts +40 -30
  29. package/src/cli/launch.ts +31 -1
  30. package/src/cli/main.ts +95 -39
  31. package/src/cli/preview.ts +80 -0
  32. package/src/cli/status-lines.ts +8 -2
  33. package/src/cli/statusline-tee.ts +14 -0
  34. package/src/cli/tail-render.ts +17 -0
  35. package/src/cli/upgrade-runtime.ts +1 -1
  36. package/src/hub/board.ts +9 -2
  37. package/src/hub/bus.ts +82 -10
  38. package/src/hub/child-process.ts +11 -0
  39. package/src/hub/conductor.ts +196 -0
  40. package/src/hub/context-window.ts +72 -0
  41. package/src/hub/control-client.ts +3 -3
  42. package/src/hub/daemon.ts +492 -77
  43. package/src/hub/envelope.ts +1 -1
  44. package/src/hub/events.ts +7 -1
  45. package/src/hub/hub-tools.ts +14 -1
  46. package/src/hub/report.ts +53 -4
  47. package/src/hub/supervision.ts +152 -0
  48. package/src/hub/task-sweep.ts +59 -0
  49. package/src/hub/tasks.ts +86 -15
  50. package/src/hub/usage.ts +37 -2
  51. package/src/models/relay.ts +6 -1
  52. package/src/pi/launch.ts +21 -0
  53. package/src/ui/index.html +6 -0
@@ -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 = 13;
15295
- var RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, PROTOCOL];
15294
+ var PROTOCOL = 15;
15295
+ var RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 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.15",
15406
+ version: "0.12.17",
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,11 +15499,22 @@ 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 (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
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));
15506
+ var CONDUCTOR_TOOLS = [
15507
+ tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
15508
+ tool("hub_task_show", "Read a task's public view and history. PII tasks remain stubs. Only the conductor may use this tool.", { id }, ["id"]),
15509
+ tool("hub_task_assign", "Move a task to another peer, as the conductor. Requires assign capability when the conductor has an explicit capabilities list.", { id, peer: str }, ["id", "peer"]),
15510
+ tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
15511
+ tool("hub_peer_start", "Start local, kimi or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["peer"]),
15512
+ tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
15513
+ tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"])
15514
+ ];
15515
+ var CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
15506
15516
  var ROLE_TEXT = {
15517
+ conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
15507
15518
  planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs, and after: [ids] for work that must wait for other tasks) instead of doing everything yourself.",
15508
15519
  implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs. With a claim or an accept, give a plan: the files, symbols and signatures you will change and where new code goes.",
15509
15520
  verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
@@ -15690,7 +15701,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
15690
15701
  inputSchema: { type: "object", properties: { delivery_id: { type: "string" }, delivery_generation: { type: "string" } }, required: ["delivery_id", "delivery_generation"], additionalProperties: false }
15691
15702
  }
15692
15703
  ],
15693
- ...TASK_TOOLS
15704
+ ...TASK_TOOLS,
15705
+ ...CONDUCTOR_TOOLS
15694
15706
  ]
15695
15707
  }));
15696
15708
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -15720,7 +15732,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
15720
15732
  const sent = `sent to: ${res.targets.join(", ") || "(no other peers attached)"}`;
15721
15733
  return text(typeof res.notice === "string" ? `${sent}; ${res.notice}` : sent);
15722
15734
  }
15723
- if (TASK_TOOL_NAMES.has(name)) {
15735
+ if (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) {
15724
15736
  if (!hub)
15725
15737
  return text(offline());
15726
15738
  const res = await hub.request({ t: "task", op: name, args: args ?? {} });
@@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
- import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
5
+ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
6
 
7
7
  export interface PermissionOption {
8
8
  optionId: string;
@@ -135,7 +135,7 @@ export class AcpPeer extends BasePeer {
135
135
  async start(): Promise<void> {
136
136
  const [bin, ...args] = this.opts.cmd;
137
137
  // Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
138
- const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
138
+ const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
139
139
  this.proc = proc;
140
140
  trackGroup(proc);
141
141
  proc.on("error", (e) => this.down(`spawn failed: ${e.message}`));
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { ControlClient, stateDirFor } from "../hub/control-client.ts";
10
10
  import { VERSION } from "../version.ts";
11
- import { DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
11
+ import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
12
12
  import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
13
13
 
14
14
  // A native session is pinned at launch. Unlike a new CLI invocation after `cd`,
@@ -201,6 +201,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
201
201
  },
202
202
  ]),
203
203
  ...TASK_TOOLS,
204
+ ...CONDUCTOR_TOOLS,
204
205
  ],
205
206
  }));
206
207
 
@@ -225,7 +226,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
225
226
  const sent = `sent to: ${res.targets.join(", ") || "(no other peers attached)"}`;
226
227
  return text(typeof res.notice === "string" ? `${sent}; ${res.notice}` : sent);
227
228
  }
228
- if (TASK_TOOL_NAMES.has(name)) {
229
+ if (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) {
229
230
  if (!hub) return text(offline());
230
231
  const res = await hub.request({ t: "task", op: name, args: args ?? {} });
231
232
  return text(res.ok ? res.text : `error: ${res.error}`);
@@ -2,7 +2,8 @@ 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 { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
5
+ import { codexContext, type ContextReading } from "../hub/context-window.ts";
6
+ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
7
 
7
8
  export interface CodexOptions {
8
9
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
@@ -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
@@ -301,9 +305,10 @@ export class CodexPeer extends BasePeer {
301
305
  throw new Error(`port ${port} already answers /healthz: an app-server the hub does not own is running (orphan from a crashed hub?)`);
302
306
  }
303
307
  let gone = "";
308
+ const env = peerChildEnv("codex", { ...process.env, ...(this.opts.env ?? {}) });
304
309
  this.proc = spawn(this.opts.bin ?? "codex", ["app-server", "--listen", `ws://127.0.0.1:${port}`, ...(this.opts.extraArgs ?? [])], {
305
310
  cwd: this.opts.cwd,
306
- env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }),
311
+ env,
307
312
  stdio: ["ignore", "ignore", "pipe"],
308
313
  detached: true, // its own process group, stopped as a whole (#113): `codex` is a launcher with a native child
309
314
  });
@@ -344,6 +349,7 @@ export class CodexPeer extends BasePeer {
344
349
  clearInterval(this.usageTimer);
345
350
  this.link = undefined;
346
351
  this.threadId = "";
352
+ this.contextEpoch++;
347
353
  this.activeTurns.clear();
348
354
  for (const p of this.pending.values()) {
349
355
  if (p.deliveryId) this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
@@ -396,6 +402,7 @@ export class CodexPeer extends BasePeer {
396
402
  this.freshThread = fresh;
397
403
  this.link = link;
398
404
  this.threadId = threadId;
405
+ this.contextEpoch++;
399
406
  this.activeTurns.clear();
400
407
  this.forgetTurn();
401
408
  this.opts.log?.(`[${this.id}] thread ${threadId}`);
@@ -440,6 +447,7 @@ export class CodexPeer extends BasePeer {
440
447
  }
441
448
  if (link !== this.link || params.threadId !== this.threadId) return;
442
449
  if (method === "thread/tokenUsage/updated") {
450
+ this.opts.onContext?.(codexContext(params.tokenUsage, this.threadId, Date.now()));
443
451
  const total = Number(params.tokenUsage?.total?.totalTokens);
444
452
  if (!Number.isFinite(total)) return;
445
453
  const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
@@ -4,7 +4,7 @@ import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.t
4
4
  import type { ToolObservation } from "../models/route/signals.ts";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
7
- import { TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
7
+ import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
8
8
  import { BasePeer } from "../hub/peers.ts";
9
9
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
10
10
  import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
@@ -98,7 +98,7 @@ export class LocalPeer extends BasePeer {
98
98
  await this.requireBudget(envs, "model_calls");
99
99
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
100
100
  if (signal.aborted) throw new Error("turn cancelled before model request");
101
- const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? TASK_TOOLS.map(asFunction) : [])];
101
+ const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...TASK_TOOLS, ...CONDUCTOR_TOOLS].map(asFunction) : [])];
102
102
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
103
103
  this.recordUsage(result, model);
104
104
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
@@ -250,7 +250,7 @@ export class LocalPeer extends BasePeer {
250
250
  await this.requireBudget(envs, "tool_calls");
251
251
  if (turnSignal.aborted) throw new ExecutionBudgetStop(this.budgetStopReason || "turn cancelled before tool execution");
252
252
  } catch (error) { clearInterval(alive); throw error; }
253
- const running = TASK_TOOL_NAMES.has(name) && this.opts.taskTool ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii }).catch((e: Error) => `error: ${e.message}`) : runTool(name, call.function.arguments, ctx);
253
+ const running = (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) && this.opts.taskTool ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii }).catch((e: Error) => `error: ${e.message}`) : runTool(name, call.function.arguments, ctx);
254
254
  const output = await running.finally(() => clearInterval(alive));
255
255
  if (turn !== this.turn) return "";
256
256
  this.touch();
@@ -281,7 +281,7 @@ export class LocalPeer extends BasePeer {
281
281
  });
282
282
  },
283
283
  sandboxProfile: this.sandboxProfile,
284
- sandboxEnv: proxyEnv(this.opts.tools.bashNetwork ?? false),
284
+ sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },
285
285
  signal: turnSignal,
286
286
  send: (text, to) => {
287
287
  const refused = this.onMessage?.(text, pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
@@ -334,7 +334,7 @@ export class LocalPeer extends BasePeer {
334
334
  // A task turn asks for its class's route; a route needs the sidecar, which exists only when the worker was started with one.
335
335
  const route = policy?.route ?? this.opts.route;
336
336
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
337
- const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? TASK_TOOLS.map(asFunction) : [])];
337
+ const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...TASK_TOOLS, ...CONDUCTOR_TOOLS].map(asFunction) : [])];
338
338
  const signal = this.abort!.signal;
339
339
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
340
340
  const routed = await this.hubCall(route, messages, policy, signal);
@@ -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";
@@ -35,14 +36,6 @@ type RpcMessage = { type?: string; id?: string | number; command?: string; succe
35
36
  type VerifiedEmptyResume = { sessionId: string };
36
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; } }
37
38
 
38
- const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
39
- const ids = models.map((m) => m.id);
40
- const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
41
- if (backend === "mlx") return find("mlx/fast");
42
- if (backend === "dgx") return find("dgx/coding");
43
- if (ids.includes("hub/auto")) return "hub/auto";
44
- return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
45
- };
46
39
 
47
40
  export class PiPeer extends BasePeer {
48
41
  readonly hubNative = true;
@@ -274,14 +267,11 @@ export class PiPeer extends BasePeer {
274
267
  const extension = resolve(join(import.meta.dir, "../pi/extension.ts"));
275
268
  const inherited: NodeJS.ProcessEnv = {};
276
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];
277
- const env: NodeJS.ProcessEnv = { ...inherited, PI_CODING_AGENT_DIR: join(this.opts.stateDir, "pi"), AGENTHUB_PI_BRIDGE_URL: `http://127.0.0.1:${bridge.port}`, AGENTHUB_PI_BRIDGE_TOKEN: token, AGENTHUB_PI_OWNER_TOKEN: token, AGENTHUB_PI_RELAY_URL: this.opts.relay.url, AGENTHUB_PI_RELAY_TOKEN: this.opts.relay.token, AGENTHUB_PI_MODELS: JSON.stringify(this.opts.relay.models), AGENTHUB_PI_TOOLS: JSON.stringify(this.opts.tools), AGENTHUB_PI_MAX_STEPS: String(this.opts.maxSteps ?? 30) };
278
- const args = [ ...(this.opts.mode === "headless" ? ["--mode", "rpc"] : []), "--provider", "agent-hub-local", "--model", this.opts.model ?? modelFor(this.opts.backend, this.opts.relay.models), "--models", this.opts.relay.models.map((model) => `agent-hub-local/${model.id}`).join(","), "--no-builtin-tools", "--no-skills", "--no-prompt-templates", "--no-extensions", "--extension", extension, ...(this.opts.preamble ? ["--append-system-prompt", this.opts.preamble] : []), "--session-dir", join(this.opts.stateDir, "pi-sessions")];
279
- if (this.opts.sessionFile) args.push("--session", this.opts.sessionFile); else if (this.opts.sessionId) args.push("--session-id", this.opts.sessionId);
280
- const command = this.opts.cmd ?? ["pi"];
281
- 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;
282
272
  if (this.opts.mode === "tui") { return; }
283
273
  // Its own process group, stopped as a whole (#115, as Codex's in #113).
284
- this.proc = spawn(command[0]!, [...command.slice(1), ...args], { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
274
+ this.proc = spawn(bin, args, { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
285
275
  trackGroup(this.proc);
286
276
  this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
287
277
  this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
@@ -0,0 +1,213 @@
1
+ import { sanitize } from "../hub/envelope.ts";
2
+
3
+ export interface Approval {
4
+ id: string; peer: string; title: string; expiresAt: number;
5
+ options: { optionId: string; name: string; kind: string }[];
6
+ }
7
+ export interface ConsoleEvent { text: string; peer?: string; kind?: string }
8
+ export interface ConsoleState {
9
+ mode: "stream" | "panels"; panel: number; selection: number; approvalIndex: number;
10
+ input: string; editing: boolean; history: string[]; historyIndex: number;
11
+ approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
12
+ tasks: any[]; queue: any[]; detail?: string; detailOffset: number; help: boolean; notice: string;
13
+ peerFilter?: string; kindFilter?: string;
14
+ confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] };
15
+ optionChoice?: string;
16
+ }
17
+ export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
18
+ { type: "command"; args: string[] } | { type: "show"; panel: number; id: string };
19
+ export function initialConsoleState(panels = false): ConsoleState {
20
+ return { mode: panels ? "panels" : "stream", panel: 1, selection: 0, approvalIndex: 0,
21
+ input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], queue: [], detailOffset: 0, help: false, notice: "" };
22
+ }
23
+ /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
24
+ export function terminalText(value: unknown): string {
25
+ return sanitize(String(value ?? ""))
26
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "")
27
+ .replace(/\x1b[P_X^][\s\S]*?(?:\x1b\\|$)/g, "")
28
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
29
+ .replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
30
+ }
31
+ export function fit(value: unknown, columns: number): string {
32
+ const text = terminalText(value).replace(/\n/g, " ");
33
+ if (columns <= 0) return "";
34
+ if (Bun.stringWidth(text) <= columns) return text;
35
+ let result = ""; const marker = ".".repeat(Math.min(3, columns));
36
+ for (const char of text) { if (Bun.stringWidth(result + char) > columns - marker.length) break; result += char; }
37
+ return result + marker;
38
+ }
39
+ export function wrap(value: unknown, columns: number): string[] {
40
+ const lines: string[] = [];
41
+ for (const part of terminalText(value).split("\n")) {
42
+ let line = "";
43
+ for (const char of part) {
44
+ if (Bun.stringWidth(line + char) > columns) { lines.push(line); line = ""; }
45
+ line += char;
46
+ }
47
+ lines.push(line);
48
+ }
49
+ return lines;
50
+ }
51
+ export function pruneApprovals(s: ConsoleState, now: number): ConsoleState {
52
+ const approvals = s.approvals.filter(a => a.expiresAt > now);
53
+ const live = (id: string) => approvals.some(a => a.id === id);
54
+ return { ...s, approvals, approvalIndex: Math.min(s.approvalIndex, Math.max(0, approvals.length - 1)),
55
+ ...(s.confirm?.type === "permission" && !live(s.confirm.id) ? { confirm: undefined } : {}),
56
+ ...(s.optionChoice && !live(s.optionChoice) ? { optionChoice: undefined } : {}) };
57
+ }
58
+ const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
59
+ /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
60
+ export function parseConsoleCommand(input: string): string[] {
61
+ const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
62
+ for (const char of input.trim()) {
63
+ if (escaped) { word += char; escaped = false; started = true; }
64
+ else if (char === "\\") { escaped = true; started = true; }
65
+ else if (quote) { if (char === quote) quote = ""; else word += char; }
66
+ else if (char === '"' || char === "'") { quote = char; started = true; }
67
+ else if (/\s/.test(char)) { if (started) { words.push(word); word = ""; started = false; } }
68
+ else { word += char; started = true; }
69
+ }
70
+ if (quote || escaped) throw new Error("unfinished quote or escape");
71
+ if (started) words.push(word);
72
+ if (!COMMANDS.has(words[0] ?? "")) throw new Error("use another shell for this command");
73
+ if (words.some(w => w === "--project" || w.startsWith("--project="))) throw new Error("console commands stay in the selected project");
74
+ return words;
75
+ }
76
+ export function commandNeedsConfirmation(args: string[]): boolean {
77
+ return (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
78
+ }
79
+ export function panelRows(s: ConsoleState): any[] {
80
+ if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
81
+ if (s.panel === 2) return s.approvals;
82
+ if (s.panel === 3) {
83
+ const states = ["proposed", "in_progress", "in_review", "changes_requested"];
84
+ return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
85
+ }
86
+ if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
87
+ return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
88
+ }
89
+ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
90
+ let s = pruneApprovals({ ...state }, now); const effects: ConsoleEffect[] = [];
91
+ const done = () => ({ state: s, effects });
92
+ if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
93
+ if (s.confirm) {
94
+ const confirm = s.confirm; s.confirm = undefined;
95
+ if (key === "y") {
96
+ if (confirm.type === "command") effects.push({ type: "command", args: confirm.args });
97
+ else if (s.approvals.some(a => a.id === confirm.id && a.options.some(o => o.optionId === confirm.option && o.kind.startsWith("allow")))) {
98
+ effects.push({ type: "permit", id: confirm.id, option: confirm.option });
99
+ s.approvals = s.approvals.filter(a => a.id !== confirm.id);
100
+ }
101
+ }
102
+ return done();
103
+ }
104
+ if (s.editing || s.input) {
105
+ if (key === "\x1b") { s.input = ""; s.editing = false; }
106
+ else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
107
+ else if (key === "\x15") s.input = "";
108
+ else if (key === "\x1b[A" || key === "\x1b[B") {
109
+ s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
110
+ s.input = s.history[s.historyIndex] ?? "";
111
+ } else if (key === "\r" || key === "\n") {
112
+ const input = s.input; s.input = ""; s.editing = false;
113
+ if (!input.trim()) return done();
114
+ try {
115
+ const args = parseConsoleCommand(input);
116
+ if (args[0] === "queue" && args[1] === "resolve") {
117
+ const reason = args.indexOf("--reason");
118
+ if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
119
+ }
120
+ s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
121
+ if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
122
+ else effects.push({ type: "command", args });
123
+ } catch (error) { s.notice = String((error as Error).message); }
124
+ } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
125
+ return done();
126
+ }
127
+ if (key === "q") { effects.push({ type: "exit" }); return done(); }
128
+ if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; return done(); }
129
+ if (key === ":") { s.editing = true; return done(); }
130
+ if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; return done(); }
131
+ if (key === "?") { s.help = !s.help; return done(); }
132
+ if (key === "\x1b") { s.detail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
133
+ if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
134
+ if (s.detail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
135
+ s.selection = Math.max(0, Math.min(panelRows(s).length - 1, s.selection + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
136
+ if (s.panel === 2) s.approvalIndex = s.selection;
137
+ return done();
138
+ }
139
+ if (key === "[" || key === "]") {
140
+ s.approvalIndex = (s.approvalIndex + (key === "[" ? -1 : 1) + s.approvals.length) % Math.max(1, s.approvals.length); return done();
141
+ }
142
+ const approval = s.approvals[s.approvalIndex];
143
+ if (s.optionChoice) {
144
+ const a = s.approvals.find(a => a.id === s.optionChoice); const options = a?.options.filter(o => o.kind.startsWith("allow")) ?? [];
145
+ const option = options[Number(key) - 1];
146
+ if (/^[1-9]$/.test(key) && a && option) { s.confirm = { type: "permission", id: a.id, peer: a.peer, option: option.optionId }; s.optionChoice = undefined; }
147
+ return done();
148
+ }
149
+ if (approval && (s.mode === "stream" || s.panel === 2)) {
150
+ if (key === "d") { effects.push({ type: "permit", id: approval.id }); s.approvals = s.approvals.filter(a => a.id !== approval.id); return done(); }
151
+ if (key === "a") {
152
+ const options = approval.options.filter(o => o.kind.startsWith("allow"));
153
+ if (options.length === 1) s.confirm = { type: "permission", id: approval.id, peer: approval.peer, option: options[0]!.optionId };
154
+ else if (options.length) s.optionChoice = approval.id;
155
+ return done();
156
+ }
157
+ if (key === "v") { s.detail = approval.title; s.detailOffset = 0; return done(); }
158
+ }
159
+ const row = panelRows(s)[s.selection];
160
+ if (s.mode === "panels" && row) {
161
+ if (key === "\r" || key === "\n") {
162
+ if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
163
+ else { s.detail = s.panel === 2 ? row.title : s.panel === 5 ? row.text : JSON.stringify(row, null, 2); s.detailOffset = 0; }
164
+ return done();
165
+ }
166
+ const prefill = s.panel === 1 && key === "p" ? `pause ${row.id}` : s.panel === 1 && key === "r" ? `resume ${row.id}` :
167
+ s.panel === 3 && key === "a" ? `task assign ${row.id} ` : s.panel === 3 && key === "r" ? `review ${row.id} ` :
168
+ s.panel === 4 && key === "r" ? `queue resolve ${row.id} --action retry --reason ` : undefined;
169
+ if (prefill) { s.input = prefill; s.editing = true; return done(); }
170
+ }
171
+ if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
172
+ const field = key === "f" ? "peer" : "kind"; const current = key === "f" ? s.peerFilter : s.kindFilter;
173
+ const values = [...new Set(s.events.map(e => e[field]).filter((x): x is string => !!x))];
174
+ const next = values[values.indexOf(current ?? "") + 1];
175
+ if (key === "f") s.peerFilter = next; else s.kindFilter = next;
176
+ s.selection = 0; return done();
177
+ }
178
+ if (s.mode === "stream" && !key.startsWith("\x1b")) { s.editing = true; s.input = terminalText(key).replace(/\n/g, ""); }
179
+ return done();
180
+ }
181
+ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
182
+ const permission = s.approvals[s.approvalIndex];
183
+ let prompt = s.editing ? `: ${s.input}` : "q quit | Tab panels | : command | a allow d deny v view [ ] select";
184
+ if (s.confirm?.type === "permission") prompt = `allow ${s.confirm.option} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
185
+ if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
186
+ if (s.optionChoice) prompt = s.approvals.find(a => a.id === s.optionChoice)?.options.filter(o => o.kind.startsWith("allow")).map((o, i) => `${i + 1}:${o.name}`).join(" | ") ?? "";
187
+ const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
188
+ const summary = Object.entries(s.peers).map(([id, p]) => `${id}:${p.state} q${p.queued ?? 0}${p.needsReview ? ` review${p.needsReview}` : ""}${p.paused ? " paused" : ""}`).join(" ");
189
+ const footer = [fit(summary + (quotas ? ` | ${quotas}` : ""), columns), fit(`${s.approvals.length} approvals${permission ? ` | ${permission.peer} ${permission.id} ${Math.max(0, Math.ceil((permission.expiresAt - now) / 1000))}s` : ""}${s.notice ? ` | ${s.notice}` : ""}`, columns), fit(prompt, columns)];
190
+ if (s.mode === "stream") return footer;
191
+ const title = ["Peers", "Approvals", "Tasks", "Queue", "Events"][s.panel - 1];
192
+ const lines = [fit(`agent-hub | 1 Peers 2 Approvals 3 Tasks 4 Queue 5 Events | ${title}`, columns), fit(`j/k move Enter detail Esc back ? keys${s.panel === 5 ? ` | f peer:${s.peerFilter ?? "all"} g kind:${s.kindFilter ?? "all"}` : ""}`, columns)];
193
+ if (s.help) lines.push(...wrap("Tab stream/panels; 1-5 panel; j/k or arrows move; Enter detail; Esc back; : command; q quit. Approvals: a allow (confirm y), d deny, v title, [ ] select. Peers: p pause, r resume. Tasks: a assign (confirm y), r review. Queue: r resolve (reason and confirm y). Events: f peer filter, g kind filter.", columns));
194
+ else if (s.detail) { const detail = wrap(s.detail, columns); lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - (rows - 5))))); }
195
+ else {
196
+ const data = panelRows(s); const capacity = Math.max(1, s.panel === 2 ? Math.floor((rows - 6) / 2) : rows - 6); const start = Math.max(0, s.selection - capacity + 1);
197
+ for (const [i, row] of data.slice(start, start + capacity).entries()) {
198
+ let text = "";
199
+ if (s.panel === 1) {
200
+ const b = s.budget[row.id];
201
+ text = `${row.id} ${row.state} ${row.attached === false ? "detached" : "attached"} q:${row.queued ?? 0} !:${row.queuedImportant ?? 0} review:${row.needsReview ?? 0} ${row.paused ? `paused:${JSON.stringify(row.paused)}` : ""} ${b?.paused ? `budget pause ${b.paused.reason} reset:${b.paused.resetsAt}` : ""} ${b?.windows?.map((w: any) => `${w.id}:${Math.round(w.used * 100)}% reset:${w.resetsAt ?? "?"}`).join(" ") ?? "quota:?"} model:${row.servedBy ?? row.requestedModel ?? "?"}`;
202
+ } else if (s.panel === 2) text = `${row.id} ${row.peer} ${Math.max(0, Math.ceil((row.expiresAt - now) / 1000))}s ${row.title}`;
203
+ else if (s.panel === 3) { const at = row.history?.at(-1)?.at ?? row.updated ?? row.created; text = `#${row.id} ${row.state}${row.ready ? " ready" : ""} ${row.owner ?? "-"} review:${row.reviewer ?? "-"} ${row.class} age:${at ? Math.max(0, Math.floor((now - at) / 1000)) + "s" : "?"} ${row.title}`; }
204
+ else if (s.panel === 4) text = `${row.id} ${row.peer} ${row.state} rev:${row.revision} age:${row.createdAt ? Math.max(0, Math.floor((now - row.createdAt) / 1000)) + "s" : "?"}`;
205
+ else text = row.text;
206
+ lines.push(fit(`${start + i === s.selection ? ">" : " "} ${text}`, columns));
207
+ }
208
+ if (!data.length) lines.push("(empty)");
209
+ if (s.panel === 2 && permission) lines.push(...wrap(permission.title, columns).slice(0, Math.max(0, rows - 3 - lines.length)));
210
+ }
211
+ const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push("");
212
+ return [...body, ...footer];
213
+ }