@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.
- package/CHANGELOG.md +17 -0
- package/README.md +18 -5
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +277 -11
- package/docs/quickstart.md +4 -4
- package/docs/security.md +26 -1
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +168 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +18 -6
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +10 -2
- package/src/adapters/local-worker.ts +5 -5
- package/src/adapters/pi.ts +4 -14
- package/src/cli/console-state.ts +213 -0
- package/src/cli/console.ts +201 -0
- package/src/cli/facts-hook.ts +8 -1
- package/src/cli/identity-audit.ts +66 -0
- package/src/cli/identity.ts +54 -0
- package/src/cli/init.ts +40 -30
- package/src/cli/launch.ts +31 -1
- package/src/cli/main.ts +95 -39
- 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/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +9 -2
- package/src/hub/bus.ts +82 -10
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +196 -0
- package/src/hub/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +492 -77
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +7 -1
- package/src/hub/hub-tools.ts +14 -1
- package/src/hub/report.ts +53 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +86 -15
- package/src/hub/usage.ts +37 -2
- package/src/models/relay.ts +6 -1
- package/src/pi/launch.ts +21 -0
- 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 =
|
|
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.
|
|
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 (
|
|
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 ?? {} });
|
package/src/adapters/acp.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 {
|
|
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
|
|
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);
|
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";
|
|
@@ -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
|
-
|
|
278
|
-
const
|
|
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(
|
|
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
|
+
}
|