@staix/agent-hub 0.12.16 → 0.12.18
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 +14 -0
- package/README.md +4 -4
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +1 -0
- package/docs/events.md +37 -3
- package/docs/operations.md +149 -10
- package/docs/quickstart.md +3 -3
- package/docs/security.md +28 -1
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +176 -1
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +17 -5
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +3 -2
- package/src/adapters/local-worker.ts +16 -12
- package/src/cli/console-state.ts +279 -0
- package/src/cli/console.ts +224 -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/launch.ts +15 -2
- package/src/cli/main.ts +75 -32
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/attribution.ts +20 -0
- package/src/hub/board.ts +6 -2
- package/src/hub/bus.ts +88 -10
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +196 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +388 -51
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +10 -4
- package/src/hub/hub-tools.ts +13 -0
- package/src/hub/report.ts +162 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/tasks.ts +30 -15
- package/src/hub/usage.ts +37 -2
- package/src/pi/launch.ts +2 -1
package/src/cli/facts-hook.ts
CHANGED
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
// call; the hub confirms it from the transcript row Claude Code writes for it, by tool use id, before it counts as seen.
|
|
6
6
|
import { ControlClient } from "../hub/control-client.ts";
|
|
7
7
|
|
|
8
|
+
/** A library call may target another hub; only the managed command hook inherits that target's native identity. */
|
|
9
|
+
export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.ProcessEnv = process.env): { nativeInstanceId?: string; nativeLaunchId?: string } {
|
|
10
|
+
if (env.AGENTHUB_STATE_DIR !== stateDir || env.AGENTHUB_PEER_ID !== peer) return {};
|
|
11
|
+
return { ...(env.AGENTHUB_INSTANCE_ID ? { nativeInstanceId: env.AGENTHUB_INSTANCE_ID } : {}), ...(env.AGENTHUB_LAUNCH_ID ? { nativeLaunchId: env.AGENTHUB_LAUNCH_ID } : {}) };
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
/** The hook's stdout for one Claude Code hook input, or undefined for none. */
|
|
9
15
|
export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000): Promise<string | undefined> {
|
|
10
16
|
const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
|
|
11
|
-
const phase = ({ PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
|
|
17
|
+
const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
|
|
12
18
|
const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
|
|
13
19
|
try {
|
|
14
20
|
const res = await hub.request({
|
|
15
21
|
t: "facts",
|
|
16
22
|
phase,
|
|
23
|
+
...nativeHookIdentity(stateDir, peer),
|
|
17
24
|
tool: typeof input.tool_name === "string" ? input.tool_name : "",
|
|
18
25
|
input: input.tool_input ?? {},
|
|
19
26
|
...(typeof input.tool_use_id === "string" ? { toolUseId: input.tool_use_id } : {}),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cliCommandLabel } from "./identity.ts";
|
|
4
|
+
|
|
5
|
+
export interface CliAudit {
|
|
6
|
+
v: 1; id: string; at: number; peer: string; command: string; outcome: "run" | "refused" | "invalid";
|
|
7
|
+
}
|
|
8
|
+
const MAX_FILES = 256;
|
|
9
|
+
const MAX_BYTES = 64 * 1024;
|
|
10
|
+
const MAX_ROW_BYTES = 512;
|
|
11
|
+
const READY = /^[a-f0-9-]{36}\.json$/;
|
|
12
|
+
const PENDING = /^[a-f0-9-]{36}\.tmp$/;
|
|
13
|
+
const valid = (row: any): row is CliAudit => row?.v === 1 && typeof row.id === "string" && /^[a-f0-9-]{36}$/.test(row.id)
|
|
14
|
+
&& typeof row.at === "number" && Number.isFinite(row.at) && row.at > 0
|
|
15
|
+
&& typeof row.peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row.peer) && !/\s/.test(row.peer)
|
|
16
|
+
&& typeof row.command === "string" && row.command.length <= 64 && row.command === cliCommandLabel(...splitLabel(row.command))
|
|
17
|
+
&& ["run", "refused", "invalid"].includes(row.outcome);
|
|
18
|
+
const splitLabel = (label: string): [string, string[]] => { const [command = "unknown", ...args] = label.split(" "); return [command, args]; };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An ids-only outbox, published atomically without a writer lock. Concurrent writers can overshoot
|
|
22
|
+
* the checked 256-file / 64 KiB ceiling by at most one bounded row per concurrent writer.
|
|
23
|
+
* Unpublished temp files count against the ceiling, so a crashed writer cannot grow the spool forever.
|
|
24
|
+
*/
|
|
25
|
+
export function recordCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]): void {
|
|
26
|
+
const row: CliAudit = { v: 1, id: crypto.randomUUID(), at: Date.now(), peer, command, outcome };
|
|
27
|
+
if (!valid(row)) throw new Error("invalid CLI audit identifiers");
|
|
28
|
+
const dir = join(stateDir, "cli-audit");
|
|
29
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
const files = readdirSync(dir).filter(name => READY.test(name) || PENDING.test(name));
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
for (const name of files) {
|
|
33
|
+
try { bytes += statSync(join(dir, name)).size; }
|
|
34
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
35
|
+
}
|
|
36
|
+
const text = JSON.stringify(row) + "\n";
|
|
37
|
+
if (Buffer.byteLength(text) > MAX_ROW_BYTES || files.length >= MAX_FILES || bytes + Buffer.byteLength(text) > MAX_BYTES) throw new Error("CLI audit outbox is full");
|
|
38
|
+
const pending = join(dir, `${row.id}.tmp`);
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(pending, text, { mode: 0o600, flag: "wx" });
|
|
41
|
+
renameSync(pending, join(dir, `${row.id}.json`));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ }
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Consume ready files only; rebuild fixed fields so titles or other injected text cannot be rendered. */
|
|
49
|
+
export function drainCliAudits(stateDir: string): CliAudit[] {
|
|
50
|
+
const dir = join(stateDir, "cli-audit");
|
|
51
|
+
let files: string[];
|
|
52
|
+
try { files = readdirSync(dir).filter(name => READY.test(name)).sort(); }
|
|
53
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
|
|
54
|
+
const rows: CliAudit[] = [];
|
|
55
|
+
for (const name of files) {
|
|
56
|
+
const path = join(dir, name);
|
|
57
|
+
try {
|
|
58
|
+
if (statSync(path).size <= MAX_ROW_BYTES) {
|
|
59
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
60
|
+
if (valid(parsed) && `${parsed.id}.json` === name) rows.push({ v: 1, id: parsed.id, at: parsed.at, peer: parsed.peer, command: parsed.command, outcome: parsed.outcome });
|
|
61
|
+
}
|
|
62
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
|
|
63
|
+
try { unlinkSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
64
|
+
}
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type CliIdentity =
|
|
2
|
+
| { role: "console" }
|
|
3
|
+
| { role: "tools"; peer: string }
|
|
4
|
+
| { role: "invalid"; reason: string };
|
|
5
|
+
|
|
6
|
+
/** Honest defaults for native shells, not authentication against an agent that can edit its environment. */
|
|
7
|
+
export function detectCliIdentity(env: Readonly<Record<string, string | undefined>>): CliIdentity {
|
|
8
|
+
const hub = env.AGENTHUB_PEER_ID;
|
|
9
|
+
const claude = env.CLAUDECODE;
|
|
10
|
+
const session = env.CLAUDE_CODE_SESSION_ID;
|
|
11
|
+
const codex = env.CODEX_THREAD_ID;
|
|
12
|
+
const invalid = (reason: string): CliIdentity => ({ role: "invalid", reason });
|
|
13
|
+
if (hub !== undefined && (!/^[a-z][a-z0-9-]{0,31}$/.test(hub) || /\s/.test(hub) || ["user", "hub", "digest"].includes(hub))) return invalid("invalid AGENTHUB_PEER_ID marker");
|
|
14
|
+
if (claude !== undefined && claude !== "1") return invalid("invalid CLAUDECODE marker");
|
|
15
|
+
if (session !== undefined && (!session || /\s/.test(session))) return invalid("invalid CLAUDE_CODE_SESSION_ID marker");
|
|
16
|
+
if (codex !== undefined && (!codex || /\s/.test(codex))) return invalid("invalid CODEX_THREAD_ID marker");
|
|
17
|
+
if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
|
|
18
|
+
if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
|
|
19
|
+
const native = claude !== undefined ? "claude" : codex !== undefined ? "codex" : undefined;
|
|
20
|
+
if (hub !== undefined && native !== undefined && hub !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
|
|
21
|
+
const peer = hub ?? native;
|
|
22
|
+
return peer === undefined ? { role: "console" } : { role: "tools", peer };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PeerCommandAccess = "allowed" | "conductor" | "console";
|
|
26
|
+
|
|
27
|
+
/** A closed list: unknown commands cannot acquire console authority through an agent shell. */
|
|
28
|
+
export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
|
|
29
|
+
if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
|
|
30
|
+
if (command === "task") {
|
|
31
|
+
if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
|
|
32
|
+
if (["assign", "escalate"].includes(args[0] ?? "")) return "conductor";
|
|
33
|
+
return "console";
|
|
34
|
+
}
|
|
35
|
+
if (command === "route") return args[0] === "explain" ? "allowed" : "console";
|
|
36
|
+
if (command === "budget") return !args.length || args.every((arg) => arg === "--json") ? "allowed" : "console";
|
|
37
|
+
if (["claude", "codex", "kimi", "pi", "local", "pause", "resume"].includes(command)) return "conductor";
|
|
38
|
+
if (["help", "--help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
|
|
39
|
+
return "console";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** No argument text is included: notices and refusal logs must not copy task text or credentials. */
|
|
43
|
+
export function cliCommandLabel(command: string, args: readonly string[]): string {
|
|
44
|
+
if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "status", "logs", "export", "report", "facts", "check-path", "turns", "undo", "kill", "doctor"].includes(command)) return "unknown";
|
|
45
|
+
if (["task", "queue", "budget", "route", "recovery", "models", "projects"].includes(command)) {
|
|
46
|
+
const sub = args[0];
|
|
47
|
+
if (sub && ["propose", "show", "assign", "escalate", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "setup", "start", "stop", "remove"].includes(sub)) return `${command} ${sub}`;
|
|
48
|
+
}
|
|
49
|
+
return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function peerCommandRefusal(peer: string, command: string): string {
|
|
53
|
+
return `${peer} cannot run ahub ${command}; the person runs it in ahub console or a terminal`;
|
|
54
|
+
}
|
package/src/cli/launch.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { peerChildEnv } from "../hub/child-process.ts";
|
|
4
5
|
export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
|
|
5
6
|
|
|
7
|
+
/** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
|
|
8
|
+
export function nativeLaunchEnv(tool: "claude" | "codex" | "pi", source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
9
|
+
const env = peerChildEnv(tool, source);
|
|
10
|
+
delete env.AGENTHUB_UNATTENDED;
|
|
11
|
+
return env;
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
/** A scoped candidate MCP server can be selected without promoting the installed plugin. */
|
|
7
15
|
function claudeChannel(userArgs: string[]): string {
|
|
8
16
|
const bundle = resolve(import.meta.dir, "../../plugins/agent-hub/server.js");
|
|
@@ -57,13 +65,16 @@ export interface FactsHook {
|
|
|
57
65
|
stateDir: string;
|
|
58
66
|
/** The same hook transport can observe native turns without injecting facts. */
|
|
59
67
|
purpose?: "facts" | "idle" | "facts-and-idle";
|
|
68
|
+
/** Observe native sessions/turns for every enabled facts, idle or conductor hook configuration. */
|
|
69
|
+
observeNative?: boolean;
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** 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 {
|
|
73
|
+
export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean }; roles?: Record<string, string[]> }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook | undefined {
|
|
64
74
|
const facts = config.coordination === "turn-free";
|
|
65
75
|
const idle = config.task_sweep?.enabled === true;
|
|
66
|
-
|
|
76
|
+
const observeNative = facts || idle || config.roles?.claude?.includes("conductor") === true;
|
|
77
|
+
return facts || idle || observeNative ? { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : "idle", ...(observeNative ? { observeNative: true } : {}) } : undefined;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
/**
|
|
@@ -76,6 +87,7 @@ export function sessionSettings(tee: StatusLineTee, facts?: FactsHook): string {
|
|
|
76
87
|
if (facts) {
|
|
77
88
|
const hooks = [{ type: "command", command: `AGENTHUB_STATE_DIR=${sh(facts.stateDir)} bun ${sh(facts.script)}`, timeout: 5 }];
|
|
78
89
|
settings.hooks = { PreToolUse: [{ matcher: "*", hooks }], PostToolUse: [{ matcher: "*", hooks }], Stop: [{ hooks }] };
|
|
90
|
+
if (facts.observeNative) Object.assign(settings.hooks as object, { SessionStart: [{ matcher: "*", hooks }], UserPromptSubmit: [{ hooks }] });
|
|
79
91
|
}
|
|
80
92
|
return JSON.stringify(settings);
|
|
81
93
|
}
|
|
@@ -101,6 +113,7 @@ export function buildLaunch(
|
|
|
101
113
|
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)." : "",
|
|
102
114
|
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
115
|
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." : "",
|
|
116
|
+
ctx.facts?.observeNative && own ? "note: you passed --settings, so native session/turn observation hooks are off: completed turns and supervision tokens may be unavailable." : "",
|
|
104
117
|
].filter(Boolean);
|
|
105
118
|
return {
|
|
106
119
|
cmd: "claude",
|
package/src/cli/main.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { currentRouting } from "../hub/routing.ts";
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
8
|
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
@@ -11,12 +12,11 @@ import { projectContext, realPath } from "../hub/project.ts";
|
|
|
11
12
|
import { Registry, type Project } from "../hub/registry.ts";
|
|
12
13
|
import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
13
14
|
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
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
17
|
import { init, planInit } from "./init.ts";
|
|
18
18
|
import { launcherPreview } from "./preview.ts";
|
|
19
|
-
import { buildLaunch, claudeObservationHooks, UNATTENDED_WARNING } from "./launch.ts";
|
|
19
|
+
import { buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
20
20
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
21
21
|
import { CLASSES } from "../hub/board.ts";
|
|
22
22
|
import { VERSION } from "../version.ts";
|
|
@@ -33,9 +33,14 @@ import { setupOllamaModel } from "./models-setup.ts";
|
|
|
33
33
|
import { unknownContext } from "../hub/context-window.ts";
|
|
34
34
|
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
35
35
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
36
|
-
import { formatReport, summarize } from "../hub/report.ts";
|
|
36
|
+
import { formatReport, summarize, formatTaskReport, summarizeByTask } from "../hub/report.ts";
|
|
37
37
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
38
38
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
39
|
+
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
40
|
+
import { recordCliAudit } from "./identity-audit.ts";
|
|
41
|
+
import { runConsole } from "./console.ts";
|
|
42
|
+
import { resolveColor } from "./console-state.ts";
|
|
43
|
+
import { renderTailEvent } from "./tail-render.ts";
|
|
39
44
|
|
|
40
45
|
/** `--since 7d|24h|<iso>` for export and report; everything when absent. */
|
|
41
46
|
const since = (): number => {
|
|
@@ -56,7 +61,8 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
56
61
|
ahub ui --all --stop stop only the dashboard manager
|
|
57
62
|
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
58
63
|
ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
|
|
59
|
-
ahub up [--unattended]
|
|
64
|
+
ahub up [--unattended] [--no-console] start the daemon; interactive terminals enter console
|
|
65
|
+
ahub console [--panels] [--color=auto|always|never] enter the human console, leaving the daemon running on exit
|
|
60
66
|
ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
|
|
61
67
|
ahub restart [--dry-run] [--yes] recover this project's runtime
|
|
62
68
|
ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
|
|
@@ -91,7 +97,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
91
97
|
ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
|
|
92
98
|
ahub status | logs [-f] | doctor | kill
|
|
93
99
|
ahub export [--since 7d|<iso>] structured events (events.jsonl) as JSON lines; never message bodies
|
|
94
|
-
ahub report [--since 7d|<iso>] [--json] turns, tokens, messages, overlaps and task events per period
|
|
100
|
+
ahub report [--since 7d|<iso>] [--by task] [--json] turns, tokens, messages, overlaps and task events per period
|
|
95
101
|
ahub check-path <file> [--peer <id>] other owners' open tasks that claim or changed a file
|
|
96
102
|
ahub check-path --hook the same as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks
|
|
97
103
|
ahub facts --hook turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks
|
|
@@ -129,10 +135,21 @@ const projectConfig = () => {
|
|
|
129
135
|
return config;
|
|
130
136
|
};
|
|
131
137
|
const stateDir = selected.stateDir;
|
|
138
|
+
const identity = detectCliIdentity(process.env);
|
|
139
|
+
const commandLabel = cliCommandLabel(cmd, args);
|
|
140
|
+
const commandAccess = classifyPeerCommand(cmd, args);
|
|
141
|
+
function audit(outcome: "run" | "refused" | "invalid"): void {
|
|
142
|
+
if (identity.role === "console") return;
|
|
143
|
+
try { recordCliAudit(stateDir, identity.role === "tools" ? identity.peer : "unknown", commandLabel, outcome); }
|
|
144
|
+
catch { console.error("ahub: CLI audit could not be recorded (outbox unavailable or full)"); }
|
|
145
|
+
}
|
|
146
|
+
if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
|
|
147
|
+
if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
|
|
148
|
+
if (identity.role === "tools") audit("run");
|
|
132
149
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
133
150
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
134
151
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
135
|
-
const connect = () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
|
|
152
|
+
const connect = () => ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd } : { role: "console", projectRoot: cwd });
|
|
136
153
|
|
|
137
154
|
function registeredProject(): Project {
|
|
138
155
|
const registry = new Registry();
|
|
@@ -147,14 +164,15 @@ async function healthy(): Promise<boolean> {
|
|
|
147
164
|
finally { hub?.close(); }
|
|
148
165
|
}
|
|
149
166
|
|
|
150
|
-
function exec(bin: string, argv: string[]): never {
|
|
151
|
-
const
|
|
167
|
+
function exec(bin: string, argv: string[], tool?: "claude" | "codex"): never {
|
|
168
|
+
const env = tool ? nativeLaunchEnv(tool, childEnv()) : childEnv();
|
|
169
|
+
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
152
170
|
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
153
171
|
process.exit(res.status ?? 1);
|
|
154
172
|
}
|
|
155
173
|
|
|
156
174
|
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
|
|
157
|
-
const env: NodeJS.ProcessEnv = { ...extra, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
175
|
+
const env: NodeJS.ProcessEnv = { ...nativeLaunchEnv("pi", extra), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
158
176
|
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
159
177
|
delete env.AGENTHUB_UNATTENDED;
|
|
160
178
|
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
|
|
@@ -284,26 +302,11 @@ function fail(message: string): never {
|
|
|
284
302
|
process.exit(1);
|
|
285
303
|
}
|
|
286
304
|
|
|
287
|
-
function render(e: BusEvent): string {
|
|
288
|
-
if (e.t === "state") return ` . ${e.peer} is ${e.state}`;
|
|
289
|
-
if (e.t === "undeliverable") return ` ! gave up delivering ${e.env.id} (from ${e.env.from}) to ${e.peer}`;
|
|
290
|
-
if (e.t === "envelope" && e.env.from === "hub" && e.env.kind !== "chat") {
|
|
291
|
-
return `${new Date(e.env.ts).toLocaleTimeString()} hub -> ${e.env.to?.join(",")} [${e.env.kind}${e.env.refs?.task ? ` #${e.env.refs.task}` : ""}]\n${e.env.body.split("\n")[0]!.replace(/^/, " ")}`;
|
|
292
|
-
}
|
|
293
|
-
if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
|
|
294
|
-
if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
|
|
295
|
-
if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
|
|
296
|
-
const { env } = e;
|
|
297
|
-
const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
|
|
298
|
-
const head = `${env.from} -> ${env.to?.join(",") ?? "*"}${env.priority === "important" ? " !" : ""}${note}`;
|
|
299
|
-
return `${new Date(env.ts).toLocaleTimeString()} ${head}\n${env.body.replace(/^/gm, " ")}`;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
305
|
async function taskOp(op: string, a: Record<string, unknown>): Promise<string> {
|
|
303
306
|
const hub = await connect();
|
|
304
307
|
const res = await hub.request({ t: "task", op, args: a });
|
|
305
308
|
hub.close();
|
|
306
|
-
if (!res.ok) fail(res.error);
|
|
309
|
+
if (!res.ok) { audit("refused"); fail(res.error); }
|
|
307
310
|
return res.text;
|
|
308
311
|
}
|
|
309
312
|
|
|
@@ -488,11 +491,19 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
488
491
|
await runProjectDaemon(registeredProject(), unattendedEnv || args.includes("--unattended"));
|
|
489
492
|
},
|
|
490
493
|
|
|
494
|
+
console: async () => {
|
|
495
|
+
if (args.some(arg => arg !== "--panels" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--color=auto|always|never]");
|
|
496
|
+
const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice("--color=".length), { isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
|
|
497
|
+
if (color instanceof Error) return fail(color.message);
|
|
498
|
+
await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), color });
|
|
499
|
+
},
|
|
500
|
+
|
|
491
501
|
up: async () => {
|
|
492
502
|
const unattended = unattendedEnv || args.includes("--unattended");
|
|
493
503
|
if (unattended) console.error(UNATTENDED_WARNING);
|
|
494
504
|
const status = await startProject(registeredProject(), { unattended });
|
|
495
505
|
console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
|
|
506
|
+
if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir });
|
|
496
507
|
},
|
|
497
508
|
|
|
498
509
|
claude: async () => {
|
|
@@ -504,7 +515,12 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
504
515
|
const control = readControl(stateDir);
|
|
505
516
|
if (control?.instanceId) {
|
|
506
517
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
507
|
-
await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
518
|
+
const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
519
|
+
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
520
|
+
// Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
|
|
521
|
+
const file = join(stateDir, "claude-launch.json");
|
|
522
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
|
|
523
|
+
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
508
524
|
}
|
|
509
525
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
510
526
|
// project local, then project, then user.
|
|
@@ -520,7 +536,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
520
536
|
const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
521
537
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
522
538
|
if (launch.warning) console.error(launch.warning);
|
|
523
|
-
exec(launch.cmd, launch.args);
|
|
539
|
+
exec(launch.cmd, launch.args, "claude");
|
|
524
540
|
},
|
|
525
541
|
|
|
526
542
|
codex: async () => {
|
|
@@ -538,7 +554,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
538
554
|
if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
539
555
|
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
540
556
|
if (launch0.warning) console.error(launch0.warning);
|
|
541
|
-
exec(launch.cmd, launch.args);
|
|
557
|
+
exec(launch.cmd, launch.args, "codex");
|
|
542
558
|
},
|
|
543
559
|
|
|
544
560
|
kimi: async () => {
|
|
@@ -663,7 +679,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
663
679
|
tail: async () => {
|
|
664
680
|
const hub = await connect();
|
|
665
681
|
hub.onPush = (msg) => {
|
|
666
|
-
if (msg.t === "event") console.log(
|
|
682
|
+
if (msg.t === "event") console.log(renderTailEvent(msg.e));
|
|
667
683
|
else if (msg.t === "context") console.log(` ${msg.peer}: ${contextLine(msg.reading)}`);
|
|
668
684
|
else if (msg.t === "notice") console.log(` * ${msg.line}`);
|
|
669
685
|
else if (msg.t === "permission") {
|
|
@@ -812,7 +828,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
812
828
|
for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
|
|
813
829
|
},
|
|
814
830
|
report: () => {
|
|
815
|
-
const
|
|
831
|
+
const by = args.indexOf("--by");
|
|
832
|
+
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
833
|
+
const events = readEvents(join(stateDir, "events.jsonl"), since());
|
|
834
|
+
if (by >= 0) {
|
|
835
|
+
const r = summarizeByTask(events);
|
|
836
|
+
return console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatTaskReport(r).join("\n"));
|
|
837
|
+
}
|
|
838
|
+
const r = summarize(events);
|
|
816
839
|
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatReport(r).join("\n"));
|
|
817
840
|
},
|
|
818
841
|
facts: async () => {
|
|
@@ -998,9 +1021,29 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
998
1021
|
},
|
|
999
1022
|
};
|
|
1000
1023
|
|
|
1001
|
-
|
|
1024
|
+
async function runConductorCommand(): Promise<void> {
|
|
1025
|
+
let op: string;
|
|
1026
|
+
let input: Record<string, unknown>;
|
|
1027
|
+
if (cmd === "task") {
|
|
1028
|
+
op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
|
|
1029
|
+
if (!args[1] || (args[0] === "assign" && !args[2]) || args.length !== (args[0] === "assign" ? 3 : 2)) fail("usage: ahub task assign <id> <peer> | escalate <id>");
|
|
1030
|
+
input = { id: Number(args[1]), ...(args[0] === "assign" ? { peer: args[2] } : {}) };
|
|
1031
|
+
} else if (cmd === "pause" || cmd === "resume") {
|
|
1032
|
+
if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
|
|
1033
|
+
op = cmd === "pause" ? "hub_peer_hold" : "hub_peer_release";
|
|
1034
|
+
input = { peer: args[0] };
|
|
1035
|
+
} else {
|
|
1036
|
+
// Native TUIs return a human launch command through the daemon; an agent shell never executes it.
|
|
1037
|
+
if (args.length && !(cmd === "pi" && args.length === 2 && args[0] === "--mode" && ["headless", "tui"].includes(args[1] ?? ""))) fail(`conductor starts accept no launch overrides; use ahub ${cmd}${cmd === "pi" ? " [--mode headless|tui]" : ""}`);
|
|
1038
|
+
op = "hub_peer_start";
|
|
1039
|
+
input = { peer: cmd, ...(cmd === "pi" ? { mode: args[1] ?? "headless" } : {}) };
|
|
1040
|
+
}
|
|
1041
|
+
console.log(await taskOp(op, input));
|
|
1042
|
+
}
|
|
1043
|
+
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
|
|
1002
1044
|
try {
|
|
1003
1045
|
await run();
|
|
1004
1046
|
} catch (e) {
|
|
1047
|
+
audit("refused");
|
|
1005
1048
|
fail((e as Error).message);
|
|
1006
1049
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BusEvent } from "../hub/bus.ts";
|
|
2
|
+
|
|
3
|
+
export function renderTailEvent(e: BusEvent): string {
|
|
4
|
+
if (e.t === "state") return ` . ${e.peer} is ${e.state}`;
|
|
5
|
+
if (e.t === "undeliverable") return ` ! gave up delivering ${e.env.id} (from ${e.env.from}) to ${e.peer}`;
|
|
6
|
+
if (e.t === "envelope" && e.env.from === "hub" && e.env.kind !== "chat") {
|
|
7
|
+
return `${new Date(e.env.ts).toLocaleTimeString()} hub -> ${e.env.to?.join(",")} [${e.env.kind}${e.env.refs?.task ? ` #${e.env.refs.task}` : ""}]\n${e.env.body.split("\n")[0]!.replace(/^/, " ")}`;
|
|
8
|
+
}
|
|
9
|
+
if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
|
|
10
|
+
if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
|
|
11
|
+
if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
|
|
12
|
+
const { env } = e;
|
|
13
|
+
const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
|
|
14
|
+
const head = `${env.from} -> ${env.to?.join(",") ?? "*"}${env.priority === "important" ? " !" : ""}${note}`;
|
|
15
|
+
return `${new Date(env.ts).toLocaleTimeString()} ${head}\n${env.body.replace(/^/gm, " ")}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -114,7 +114,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
114
114
|
catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
|
|
115
115
|
if (source.state === "stopped" || source.state === "missing") continue;
|
|
116
116
|
const blockers = [...source.blockers];
|
|
117
|
-
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13 or protocol-
|
|
117
|
+
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13, protocol-14 or protocol-15 source is required");
|
|
118
118
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
119
119
|
const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
|
|
120
120
|
for (const peer of source.peers) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Envelope } from "./envelope.ts";
|
|
2
|
+
|
|
3
|
+
export type Attribution = "delivery" | "single_open" | "unattributed";
|
|
4
|
+
export interface TaskAttribution { task?: number; attribution: Attribution; pii?: true }
|
|
5
|
+
|
|
6
|
+
/** Only one distinct positive task id in the original delivery can identify its turn. */
|
|
7
|
+
export function deliveryTask(originals: Envelope[]): number | undefined {
|
|
8
|
+
const ids = new Set(originals.flatMap(env => {
|
|
9
|
+
const id = Number(env.refs?.task);
|
|
10
|
+
return Number.isSafeInteger(id) && id > 0 ? [id] : [];
|
|
11
|
+
}));
|
|
12
|
+
return ids.size === 1 ? ids.values().next().value : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Rule order is deliberate: a delivered review belongs to its task, regardless of ownership. */
|
|
16
|
+
export function attribute(delivery: number | undefined, inProgress: number[]): TaskAttribution {
|
|
17
|
+
if (delivery !== undefined && Number.isSafeInteger(delivery) && delivery > 0) return { task: delivery, attribution: "delivery" };
|
|
18
|
+
if (inProgress.length === 1) return { task: inProgress[0]!, attribution: "single_open" };
|
|
19
|
+
return { attribution: "unattributed" };
|
|
20
|
+
}
|
package/src/hub/board.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type TaskPlan = Partial<Record<(typeof PLAN_KEYS)[number], string[]>>;
|
|
|
18
18
|
/** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
|
|
19
19
|
export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
|
|
20
20
|
|
|
21
|
+
export const TASK_MOVE_REASONS = ["manual", "budget", "offline", "idle", "rejections", "declined", "inference_failed", "delivery_failed"] as const;
|
|
22
|
+
export type TaskMoveReason = (typeof TASK_MOVE_REASONS)[number];
|
|
21
23
|
export interface HistoryEntry {
|
|
22
24
|
at: number;
|
|
23
25
|
by: PeerId;
|
|
@@ -27,6 +29,8 @@ export interface HistoryEntry {
|
|
|
27
29
|
owner?: PeerId | null;
|
|
28
30
|
/** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
|
|
29
31
|
profile?: string;
|
|
32
|
+
/** Structured cause recorded at the operation source, never inferred from note text. */
|
|
33
|
+
reason?: TaskMoveReason;
|
|
30
34
|
/** Persisted task-idle ladder; never counted as fresh task activity (#186). */
|
|
31
35
|
sweep?: SweepRecord;
|
|
32
36
|
}
|
|
@@ -122,14 +126,14 @@ export class Board {
|
|
|
122
126
|
|
|
123
127
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
124
128
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
125
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep"> = {}): Task {
|
|
129
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason"> = {}): Task {
|
|
126
130
|
const task = this.get(id);
|
|
127
131
|
if (!task) throw new Error(`no task #${id}`);
|
|
128
132
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
129
133
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
130
134
|
}
|
|
131
135
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
132
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}) }];
|
|
136
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}) }];
|
|
133
137
|
this.db
|
|
134
138
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
135
139
|
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
|