@shanesaravia/hive 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +18 -1
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/status.js +9 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +191 -1
  11. package/node_modules/@hive/shared/dist/types.js +27 -0
  12. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  13. package/node_modules/@hive/shared/dist/workers.js +22 -0
  14. package/package.json +1 -1
  15. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  16. package/packages/server/dist/api/rest.js +538 -23
  17. package/packages/server/dist/api/ws.js +90 -9
  18. package/packages/server/dist/control/launcher.js +50 -11
  19. package/packages/server/dist/control/messaging.js +3 -2
  20. package/packages/server/dist/control/missionQuiesce.js +66 -0
  21. package/packages/server/dist/health/deriveAlerts.js +9 -2
  22. package/packages/server/dist/hooks/hookIngest.js +69 -10
  23. package/packages/server/dist/index.js +44 -4
  24. package/packages/server/dist/loops/loopCommand.js +56 -0
  25. package/packages/server/dist/loops/loopNoop.js +38 -0
  26. package/packages/server/dist/loops/loopScheduler.js +58 -0
  27. package/packages/server/dist/loops/loopStore.js +118 -0
  28. package/packages/server/dist/loops/monitors.js +38 -0
  29. package/packages/server/dist/messages/attachmentStore.js +92 -0
  30. package/packages/server/dist/messages/messagesStore.js +110 -33
  31. package/packages/server/dist/missions/missionsStore.js +9 -0
  32. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  33. package/packages/server/dist/plans/planReconcile.js +114 -0
  34. package/packages/server/dist/plans/plansStore.js +46 -3
  35. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  36. package/packages/server/dist/roster/missionReplay.js +82 -0
  37. package/packages/server/dist/roster/replyAsk.js +62 -0
  38. package/packages/server/dist/roster/rosterBuilder.js +78 -147
  39. package/packages/server/dist/roster/workerIdentity.js +923 -0
  40. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  41. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  42. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  43. package/packages/server/dist/terminals/providerDetection.js +27 -0
  44. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  45. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  46. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  47. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  48. package/packages/server/dist/terminals/terminalStream.js +30 -0
  49. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  50. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  51. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  52. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  53. package/packages/web/dist/index.html +2 -2
  54. package/templates/agents/hive-orchestrator.md +1 -0
  55. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  56. package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
@@ -50,17 +50,41 @@ function collectPluginSkills(root) {
50
50
  }
51
51
  return output;
52
52
  }
53
+ /**
54
+ * A mission's recorded repository can be a directory that no longer exists —
55
+ * reaching a terminal state reclaims its worktree. The project half of
56
+ * discovery is unavailable then, but the user's own skills are not, so this
57
+ * degrades to them rather than failing outright. Failing threw out ten usable
58
+ * skills over a missing project directory, and, because `sendToMission`
59
+ * discovers skills before it falls back to a live repository, made messaging
60
+ * such a mission impossible.
61
+ */
62
+ function projectSkills(cwd, provider) {
63
+ let project;
64
+ try {
65
+ project = requireWorkingDirectory(cwd);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ return provider === "codex"
71
+ ? collect(path.join(project, ".codex", "skills"), "project")
72
+ : [
73
+ ...collect(path.join(project, ".claude", "skills"), "project"),
74
+ ...collect(path.join(project, ".claude", "commands"), "project"),
75
+ ];
76
+ }
53
77
  export function discoverSkills(cwd, provider = "claude") {
54
- const project = requireWorkingDirectory(cwd);
55
78
  const claudeConfig = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
56
79
  const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
80
+ // Project entries lead, so the dedup below keeps them over a user skill of
81
+ // the same name.
57
82
  const all = provider === "codex" ? [
58
- ...collect(path.join(project, ".codex", "skills"), "project"),
83
+ ...projectSkills(cwd, provider),
59
84
  ...collect(path.join(codexHome, "skills"), "user"),
60
85
  ...collectPluginSkills(path.join(codexHome, "plugins", "cache")),
61
86
  ] : [
62
- ...collect(path.join(project, ".claude", "skills"), "project"),
63
- ...collect(path.join(project, ".claude", "commands"), "project"),
87
+ ...projectSkills(cwd, provider),
64
88
  ...collect(path.join(claudeConfig, "skills"), "user"),
65
89
  ...collect(path.join(claudeConfig, "commands"), "user"),
66
90
  ];
@@ -0,0 +1,90 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Duplex } from "node:stream";
3
+ export function claudeStreamArguments(options) {
4
+ const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
5
+ if (options.resumeSessionId)
6
+ args.push("--resume", options.resumeSessionId);
7
+ if (options.model)
8
+ args.push("--model", options.model);
9
+ return args;
10
+ }
11
+ /** Reproducible lifecycle spike; production Claude --bg remains follow-up only. */
12
+ export class ClaudeStreamClient {
13
+ stream;
14
+ interruptProcess;
15
+ terminateProcess;
16
+ buffer = "";
17
+ closed = false;
18
+ listeners = new Set();
19
+ constructor(stream, interruptProcess = () => undefined, terminateProcess = () => undefined) {
20
+ this.stream = stream;
21
+ this.interruptProcess = interruptProcess;
22
+ this.terminateProcess = terminateProcess;
23
+ stream.setEncoding("utf8");
24
+ stream.on("data", (chunk) => this.consume(chunk));
25
+ stream.on("close", () => this.finish());
26
+ stream.on("error", (error) => this.emit({ type: "hive/error", message: error.message }));
27
+ }
28
+ static spawn(options, command = "claude") {
29
+ const child = spawn(command, claudeStreamArguments(options), { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"] });
30
+ child.stderr.resume();
31
+ return new ClaudeStreamClient(new ChildDuplex(child), () => { child.kill("SIGINT"); }, () => { child.kill("SIGTERM"); });
32
+ }
33
+ send(text, sessionId) {
34
+ if (this.closed)
35
+ throw new Error("Claude stream client is closed");
36
+ const message = { type: "user", message: { role: "user", content: text } };
37
+ if (sessionId)
38
+ message.session_id = sessionId;
39
+ this.stream.write(`${JSON.stringify(message)}\n`);
40
+ }
41
+ onEvent(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
42
+ interrupt() { if (!this.closed)
43
+ this.interruptProcess(); }
44
+ endInput() { if (!this.closed)
45
+ this.stream.end(); }
46
+ close() { if (!this.closed) {
47
+ this.closed = true;
48
+ this.stream.end();
49
+ this.terminateProcess();
50
+ } }
51
+ consume(chunk) {
52
+ this.buffer += chunk;
53
+ let newline;
54
+ while ((newline = this.buffer.indexOf("\n")) >= 0) {
55
+ const line = this.buffer.slice(0, newline).trim();
56
+ this.buffer = this.buffer.slice(newline + 1);
57
+ if (!line)
58
+ continue;
59
+ try {
60
+ const event = JSON.parse(line);
61
+ if (event && typeof event.type === "string")
62
+ this.emit(event);
63
+ }
64
+ catch {
65
+ this.emit({ type: "hive/invalid-json", line });
66
+ }
67
+ }
68
+ }
69
+ finish() { if (!this.closed) {
70
+ this.closed = true;
71
+ this.emit({ type: "hive/connection/closed" });
72
+ } }
73
+ emit(event) { for (const listener of this.listeners)
74
+ listener(event); }
75
+ }
76
+ class ChildDuplex extends Duplex {
77
+ child;
78
+ constructor(child) {
79
+ super();
80
+ this.child = child;
81
+ child.stdout.on("data", (chunk) => this.push(chunk));
82
+ child.stdout.on("end", () => this.push(null));
83
+ child.stdout.on("error", (error) => this.destroy(error));
84
+ child.stdin.on("error", (error) => this.destroy(error));
85
+ child.once("exit", () => this.push(null));
86
+ }
87
+ _read() { }
88
+ _write(chunk, encoding, callback) { this.child.stdin.write(chunk, encoding, callback); }
89
+ _final(callback) { this.child.stdin.end(callback); }
90
+ }
@@ -0,0 +1,195 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Duplex } from "node:stream";
3
+ /** Minimal JSON-RPC client for the versioned Codex app-server boundary. */
4
+ export class CodexAppServerClient {
5
+ stream;
6
+ child;
7
+ nextId = 1;
8
+ buffer = "";
9
+ closed = false;
10
+ pending = new Map();
11
+ listeners = new Set();
12
+ constructor(stream, child) {
13
+ this.stream = stream;
14
+ this.child = child;
15
+ stream.setEncoding("utf8");
16
+ stream.on("data", (chunk) => this.consume(chunk));
17
+ stream.on("error", (error) => this.failAll(error));
18
+ stream.on("close", () => {
19
+ if (!this.closed) {
20
+ this.closed = true;
21
+ this.emit({ method: "hive/connection/closed" });
22
+ }
23
+ this.failAll(new Error("Codex app-server connection closed"));
24
+ });
25
+ }
26
+ static spawn(command = "codex") {
27
+ const child = spawn(command, ["app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"] });
28
+ // The protocol lives on stdout. Drain stderr so warnings cannot block it.
29
+ child.stderr.resume();
30
+ return new CodexAppServerClient(new ChildDuplex(child), child);
31
+ }
32
+ /** Restart the stdio server and rejoin a persisted thread by id. */
33
+ static async reconnectStdio(threadId, options = {}, command = "codex") {
34
+ const client = CodexAppServerClient.spawn(command);
35
+ try {
36
+ await client.initialize();
37
+ await client.resumeThread(threadId, options);
38
+ return client;
39
+ }
40
+ catch (error) {
41
+ client.close();
42
+ throw error;
43
+ }
44
+ }
45
+ static connectUnix(socketPath) {
46
+ // The daemon endpoint is a control socket, not raw JSONL. Codex owns its
47
+ // framing contract and exposes `proxy` as the supported stdio bridge.
48
+ const child = spawn("codex", ["app-server", "proxy", "--sock", socketPath], { stdio: ["pipe", "pipe", "pipe"] });
49
+ child.stderr.resume();
50
+ return Promise.resolve(new CodexAppServerClient(new ChildDuplex(child), child));
51
+ }
52
+ /** Rejoin a durable daemon connection and resubscribe to an existing thread. */
53
+ static async reconnectUnix(socketPath, threadId, options = {}) {
54
+ const client = await CodexAppServerClient.connectUnix(socketPath);
55
+ try {
56
+ await client.initialize();
57
+ await client.resumeThread(threadId, options);
58
+ return client;
59
+ }
60
+ catch (error) {
61
+ client.close();
62
+ throw error;
63
+ }
64
+ }
65
+ async initialize() {
66
+ const result = await this.request("initialize", { clientInfo: { name: "hive", title: "Hive", version: "0.3.0" }, capabilities: { experimentalApi: true, requestAttestation: false } });
67
+ this.notify("initialized");
68
+ return result;
69
+ }
70
+ async startThread(options) {
71
+ const result = await this.request("thread/start", { cwd: options.cwd, model: options.model, approvalPolicy: options.approvalPolicy ?? "never", sandbox: options.sandbox ?? "workspace-write", threadSource: "appServer" });
72
+ return { threadId: nestedId(result, "thread") };
73
+ }
74
+ async resumeThread(threadId, options = {}) {
75
+ const result = await this.request("thread/resume", { threadId, cwd: options.cwd, model: options.model, approvalPolicy: options.approvalPolicy, sandbox: options.sandbox, excludeTurns: true });
76
+ return { threadId: nestedId(result, "thread") };
77
+ }
78
+ async startTurn(threadId, text) {
79
+ const result = await this.request("turn/start", { threadId, input: [{ type: "text", text, text_elements: [] }] });
80
+ return { turnId: nestedId(result, "turn") };
81
+ }
82
+ async interruptTurn(threadId, turnId) {
83
+ await this.request("turn/interrupt", { threadId, turnId });
84
+ }
85
+ async deleteThread(threadId) { await this.request("thread/delete", { threadId }); }
86
+ onNotification(listener) {
87
+ this.listeners.add(listener);
88
+ return () => this.listeners.delete(listener);
89
+ }
90
+ emit(notification) {
91
+ for (const listener of this.listeners)
92
+ listener(notification);
93
+ }
94
+ waitForNotification(predicate, timeoutMs = 30_000) {
95
+ return new Promise((resolve, reject) => {
96
+ const unsubscribe = this.onNotification((notification) => {
97
+ if (!predicate(notification))
98
+ return;
99
+ clearTimeout(timer);
100
+ unsubscribe();
101
+ resolve(notification);
102
+ });
103
+ const timer = setTimeout(() => { unsubscribe(); reject(new Error("Timed out waiting for a Codex app-server notification")); }, timeoutMs);
104
+ timer.unref?.();
105
+ });
106
+ }
107
+ async request(method, params, timeoutMs = 10_000) {
108
+ if (this.closed)
109
+ throw new Error("Codex app-server client is closed");
110
+ const id = this.nextId++;
111
+ const response = new Promise((resolve, reject) => {
112
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Codex app-server ${method} timed out`)); }, timeoutMs);
113
+ timer.unref?.();
114
+ this.pending.set(id, { resolve, reject, timer });
115
+ });
116
+ this.write({ method, id, params });
117
+ const result = await response;
118
+ return object(result);
119
+ }
120
+ notify(method, params) { this.write(params === undefined ? { method } : { method, params }); }
121
+ close() {
122
+ if (this.closed)
123
+ return;
124
+ this.closed = true;
125
+ this.stream.end();
126
+ if (this.child && !this.child.killed)
127
+ this.child.kill("SIGTERM");
128
+ this.failAll(new Error("Codex app-server client closed"));
129
+ }
130
+ write(message) { this.stream.write(`${JSON.stringify(message)}\n`); }
131
+ consume(chunk) {
132
+ this.buffer += chunk;
133
+ let newline;
134
+ while ((newline = this.buffer.indexOf("\n")) >= 0) {
135
+ const line = this.buffer.slice(0, newline).trim();
136
+ this.buffer = this.buffer.slice(newline + 1);
137
+ if (!line)
138
+ continue;
139
+ let message;
140
+ try {
141
+ message = object(JSON.parse(line));
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ if (typeof message.id === "number") {
147
+ const pending = this.pending.get(message.id);
148
+ if (!pending)
149
+ continue;
150
+ this.pending.delete(message.id);
151
+ clearTimeout(pending.timer);
152
+ if (message.error)
153
+ pending.reject(new Error(rpcError(message.error)));
154
+ else
155
+ pending.resolve(message.result);
156
+ }
157
+ else if (typeof message.method === "string") {
158
+ const notification = { method: message.method, params: isObject(message.params) ? message.params : undefined };
159
+ this.listeners.forEach((listener) => listener(notification));
160
+ }
161
+ }
162
+ }
163
+ failAll(error) { for (const request of this.pending.values()) {
164
+ clearTimeout(request.timer);
165
+ request.reject(error);
166
+ } this.pending.clear(); }
167
+ }
168
+ /** Bridges a child's separate stdin/stdout into the Duplex shape used by sockets and tests. */
169
+ class ChildDuplex extends Duplex {
170
+ input;
171
+ constructor(child) {
172
+ super();
173
+ this.input = child.stdin;
174
+ child.stdout.on("data", (chunk) => this.push(chunk));
175
+ child.stdout.on("end", () => this.push(null));
176
+ child.stdout.on("error", (error) => this.destroy(error));
177
+ child.stdin.on("error", (error) => this.destroy(error));
178
+ child.once("exit", () => this.push(null));
179
+ }
180
+ _read() { }
181
+ _write(chunk, encoding, callback) {
182
+ this.input.write(chunk, encoding, callback);
183
+ }
184
+ _final(callback) { this.input.end(callback); }
185
+ }
186
+ function nestedId(result, key) {
187
+ const id = object(result[key]).id;
188
+ if (typeof id !== "string" || !id)
189
+ throw new Error(`Codex app-server ${key} response omitted its id`);
190
+ return id;
191
+ }
192
+ function isObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
193
+ function object(value) { if (!isObject(value))
194
+ throw new Error("Codex app-server returned an invalid response"); return value; }
195
+ function rpcError(value) { const error = isObject(value) ? value : {}; return typeof error.message === "string" ? error.message : "Codex app-server request failed"; }
@@ -0,0 +1,27 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const systemRun = (file, args) => execFileSync(file, args, { encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"] });
3
+ function versionOf(output) {
4
+ return output.match(/\d+\.\d+\.\d+(?:[-+][\w.-]+)?/)?.[0];
5
+ }
6
+ export function detectProviderTerminal(provider, run = systemRun) {
7
+ try {
8
+ const versionOutput = run(provider, ["--version"]);
9
+ const version = versionOf(versionOutput);
10
+ const help = provider === "codex" ? run("codex", ["app-server", "--help"]) : run("claude", ["--help"]);
11
+ if (provider === "codex") {
12
+ const supported = help.includes("--listen") && help.includes("stdio://") && help.includes("unix://");
13
+ return { provider, installed: true, version, interactiveTransport: supported ? "codex_app_server" : undefined, reason: supported ? undefined : "Installed Codex does not advertise the app-server transports Hive requires." };
14
+ }
15
+ const supported = help.includes("--input-format") && help.includes("stream-json") && help.includes("--output-format");
16
+ return { provider, installed: true, version, interactiveTransport: supported ? "claude_stream_json" : undefined, reason: supported ? undefined : "Installed Claude does not advertise bidirectional stream-json mode." };
17
+ }
18
+ catch (cause) {
19
+ return { provider, installed: false, reason: cause.message };
20
+ }
21
+ }
22
+ let cached;
23
+ /** Detect once per server process; CLI help can touch keychains and must not run per request. */
24
+ export function providerTerminalCatalog() {
25
+ return cached ??= { claude: detectProviderTerminal("claude"), codex: detectProviderTerminal("codex") };
26
+ }
27
+ export function resetProviderTerminalCatalogForTest() { cached = undefined; }
@@ -0,0 +1,45 @@
1
+ import { providerTerminalCatalog } from "./providerDetection.js";
2
+ import { terminalFeatureFlags } from "./terminalFeatures.js";
3
+ /**
4
+ * What input the current transcript source can honestly accept.
5
+ *
6
+ * Hive currently reconstructs output from provider transcript files; it does
7
+ * not own a PTY. Manager follow-ups are still real input, but they start or
8
+ * resume a provider turn instead of typing into the process shown above.
9
+ */
10
+ export function terminalCapability(source, provider, lifecycle, detection = providerTerminalCatalog()[provider], providerTurnActive = false, features = terminalFeatureFlags()) {
11
+ const detected = { provider, providerVersion: detection.version, detectedInteractiveTransport: detection.interactiveTransport };
12
+ if (source.kind === "worker") {
13
+ return { ...detected, mode: "read_only", connected: false, acceptsInterrupt: false, acceptsResize: false, reason: "Worker transcripts do not expose a safe input channel." };
14
+ }
15
+ if (lifecycle === "completed" || lifecycle === "archived") {
16
+ return { ...detected, mode: "read_only", connected: false, acceptsInterrupt: false, acceptsResize: false, reason: "This mission is finished. Reopen it from Conversation before sending another turn." };
17
+ }
18
+ if (provider === "codex" && !features.codexInput) {
19
+ return { ...detected, mode: "read_only", connected: false, acceptsInterrupt: false, acceptsResize: false, reason: "Writable Codex Terminal input is disabled by HIVE_TERMINAL_CODEX_INPUT." };
20
+ }
21
+ if (provider === "claude" && !features.claudeFollowUp) {
22
+ return { ...detected, mode: "read_only", connected: false, acceptsInterrupt: false, acceptsResize: false, reason: "Claude Terminal follow-ups are disabled by HIVE_TERMINAL_CLAUDE_FOLLOW_UP. Conversation remains available." };
23
+ }
24
+ if (providerTurnActive) {
25
+ return { ...detected, mode: "read_only", connected: true, acceptsInterrupt: false, acceptsResize: false, reason: "The current provider turn is still running. Input becomes available when it finishes." };
26
+ }
27
+ if (provider === "codex" && detection.interactiveTransport === "codex_app_server") {
28
+ return {
29
+ ...detected,
30
+ mode: "interactive",
31
+ connected: true,
32
+ acceptsInterrupt: true,
33
+ acceptsResize: false,
34
+ reason: "Input starts a live Codex app-server turn in this mission thread.",
35
+ };
36
+ }
37
+ return {
38
+ ...detected,
39
+ mode: "follow_up",
40
+ connected: true,
41
+ acceptsInterrupt: false,
42
+ acceptsResize: false,
43
+ reason: `Sends a new ${provider === "codex" ? "Codex" : "Claude"} turn; it is not live terminal stdin.`,
44
+ };
45
+ }
@@ -0,0 +1,11 @@
1
+ function enabled(value, fallback = true) {
2
+ if (value === undefined)
3
+ return fallback;
4
+ return !["0", "false", "off", "no"].includes(value.trim().toLowerCase());
5
+ }
6
+ export function terminalFeatureFlags(env = process.env) {
7
+ return {
8
+ codexInput: enabled(env.HIVE_TERMINAL_CODEX_INPUT),
9
+ claudeFollowUp: enabled(env.HIVE_TERMINAL_CLAUDE_FOLLOW_UP),
10
+ };
11
+ }
@@ -0,0 +1,21 @@
1
+ /** Privacy-safe local counters and bounded audit metadata; raw input never enters this object. */
2
+ export class TerminalObservability {
3
+ onAudit;
4
+ auditLimit;
5
+ counters = new Map();
6
+ recent = [];
7
+ constructor(onAudit, auditLimit = 200) {
8
+ this.onAudit = onAudit;
9
+ this.auditLimit = auditLimit;
10
+ }
11
+ metric(name) { this.counters.set(name, (this.counters.get(name) ?? 0) + 1); }
12
+ input(record) {
13
+ const entry = { ts: Date.now(), ...record };
14
+ this.recent.push(entry);
15
+ if (this.recent.length > this.auditLimit)
16
+ this.recent.splice(0, this.recent.length - this.auditLimit);
17
+ this.metric(`input.${record.provider}.${record.outcome}`);
18
+ this.onAudit?.(entry);
19
+ }
20
+ snapshot() { return { counters: Object.fromEntries(this.counters), recentInputOperations: this.recent.map((entry) => ({ ...entry })) }; }
21
+ }
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CodexAppServerClient } from "./codexAppServerClient.js";
3
+ import { TerminalStream } from "./terminalStream.js";
4
+ import { TerminalObservability } from "./terminalObservability.js";
5
+ /** Owns interactive Codex turns without changing the legacy mission launcher. */
6
+ export class TerminalRuntime {
7
+ connectCodex;
8
+ observability;
9
+ stream;
10
+ active = new Map();
11
+ receipts = new Map();
12
+ pendingInputs = new Map();
13
+ launching = new Set();
14
+ constructor(stream = new TerminalStream(), connectCodex = (threadId, options) => CodexAppServerClient.reconnectStdio(threadId, options), observability = new TerminalObservability()) {
15
+ this.connectCodex = connectCodex;
16
+ this.observability = observability;
17
+ this.stream = stream;
18
+ }
19
+ async submitCodex(input) {
20
+ const audit = (outcome, reason) => this.observability.input({ missionId: input.missionId, source: input.source, provider: "codex", inputCharacters: input.text.length, outcome, reason });
21
+ audit("attempted");
22
+ const receiptKey = `${input.missionId}:${input.source}:${input.clientInputId}`;
23
+ const digest = createHash("sha256").update(input.text).digest("hex");
24
+ const prior = this.receipts.get(receiptKey);
25
+ if (prior) {
26
+ if (prior.digest !== digest) {
27
+ audit("failed", "client_input_id_content_mismatch");
28
+ throw new Error("clientInputId was already used for different terminal input");
29
+ }
30
+ audit("duplicate", "replayed_client_input_id");
31
+ return { ...prior.receipt, status: "duplicate" };
32
+ }
33
+ const pending = this.pendingInputs.get(receiptKey);
34
+ if (pending) {
35
+ if (pending.digest !== digest) {
36
+ audit("failed", "client_input_id_content_mismatch");
37
+ throw new Error("clientInputId is already being used for different terminal input");
38
+ }
39
+ const receipt = await pending.promise;
40
+ audit("duplicate", "coalesced_client_input_id");
41
+ return { ...receipt, status: "duplicate" };
42
+ }
43
+ if (this.active.has(input.missionId) || this.launching.has(input.missionId)) {
44
+ audit("failed", "turn_already_running");
45
+ throw new Error("An interactive terminal turn is already running for this mission");
46
+ }
47
+ this.launching.add(input.missionId);
48
+ const operation = this.startCodex(input, receiptKey);
49
+ this.pendingInputs.set(receiptKey, { promise: operation, digest });
50
+ try {
51
+ const receipt = await operation;
52
+ audit("accepted");
53
+ return receipt;
54
+ }
55
+ catch (error) {
56
+ audit("failed", "provider_input_failed");
57
+ throw error;
58
+ }
59
+ finally {
60
+ this.pendingInputs.delete(receiptKey);
61
+ this.launching.delete(input.missionId);
62
+ }
63
+ }
64
+ async startCodex(input, receiptKey) {
65
+ this.observability.metric("connection.codex.reconnect_attempt");
66
+ const client = await this.connectCodex(input.threadId, { cwd: input.cwd, model: input.model, approvalPolicy: "never", sandbox: input.policy.allowedRoots.length ? "workspace-write" : "read-only" });
67
+ this.observability.metric("connection.codex.connected");
68
+ let completedEarly = false;
69
+ const unsubscribe = client.onNotification((notification) => {
70
+ this.publishNotification(input.missionId, input.source, notification);
71
+ if (notification.method !== "turn/completed" && notification.method !== "error" && notification.method !== "hive/connection/closed")
72
+ return;
73
+ if (notification.method === "hive/connection/closed")
74
+ this.observability.metric("cleanup.codex.provider_exit");
75
+ if (this.active.has(input.missionId))
76
+ this.finish(input.missionId);
77
+ else
78
+ completedEarly = true;
79
+ });
80
+ try {
81
+ const turn = await client.startTurn(input.threadId, input.text);
82
+ const accepted = this.stream.publish({ missionId: input.missionId, source: input.source, method: "hive/input/accepted", params: { clientInputId: input.clientInputId, turnId: turn.turnId } });
83
+ const receipt = { clientInputId: input.clientInputId, status: "accepted", streamId: accepted.streamId, sequence: accepted.sequence, turnId: turn.turnId };
84
+ this.receipts.set(receiptKey, { receipt, digest: createHash("sha256").update(input.text).digest("hex") });
85
+ this.trimReceipts();
86
+ if (completedEarly) {
87
+ unsubscribe();
88
+ client.close();
89
+ }
90
+ else
91
+ this.active.set(input.missionId, { client, threadId: input.threadId, turnId: turn.turnId, unsubscribe });
92
+ return receipt;
93
+ }
94
+ catch (error) {
95
+ unsubscribe();
96
+ client.close();
97
+ throw error;
98
+ }
99
+ }
100
+ async interrupt(missionId) {
101
+ const active = this.active.get(missionId);
102
+ if (!active)
103
+ throw new Error("No interactive terminal turn is running for this mission");
104
+ await active.client.interruptTurn(active.threadId, active.turnId);
105
+ this.observability.metric("interrupt.codex.accepted");
106
+ }
107
+ close() { for (const missionId of [...this.active.keys()]) {
108
+ this.observability.metric("cleanup.codex.server_shutdown");
109
+ this.finish(missionId);
110
+ } }
111
+ publishNotification(missionId, source, notification) {
112
+ this.stream.publish({ missionId, source, method: notification.method, params: boundedParams(notification.params) });
113
+ }
114
+ finish(missionId) { const active = this.active.get(missionId); if (!active)
115
+ return; this.active.delete(missionId); active.unsubscribe(); active.client.close(); }
116
+ trimReceipts() { while (this.receipts.size > 2_000)
117
+ this.receipts.delete(this.receipts.keys().next().value); }
118
+ }
119
+ /** Never retain an unbounded notification object in the reconnect buffer. */
120
+ function boundedParams(params) {
121
+ if (!params)
122
+ return undefined;
123
+ const json = JSON.stringify(params);
124
+ return json.length <= 64_000 ? params : { truncated: true, preview: json.slice(0, 64_000) };
125
+ }
@@ -0,0 +1,30 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ /** Bounded in-memory live stream; durable transcripts remain the restart fallback. */
4
+ export class TerminalStream {
5
+ limit;
6
+ streamId = randomUUID();
7
+ sequence = 0;
8
+ events = [];
9
+ emitter = new EventEmitter();
10
+ constructor(limit = 2_000) {
11
+ this.limit = limit;
12
+ }
13
+ publish(input) {
14
+ const event = { ...input, streamId: this.streamId, sequence: ++this.sequence, ts: input.ts ?? Date.now() };
15
+ this.events.push(event);
16
+ if (this.events.length > this.limit)
17
+ this.events.splice(0, this.events.length - this.limit);
18
+ this.emitter.emit("event", event);
19
+ return event;
20
+ }
21
+ replay(missionId, source, after = 0) {
22
+ const matching = this.events.filter((event) => event.missionId === missionId && event.source === source);
23
+ const oldestRetained = this.events[0]?.sequence;
24
+ return { streamId: this.streamId, events: matching.filter((event) => event.sequence > after), truncated: oldestRetained !== undefined && after < oldestRetained - 1, latestSequence: this.sequence };
25
+ }
26
+ onEvent(listener) {
27
+ this.emitter.on("event", listener);
28
+ return () => this.emitter.off("event", listener);
29
+ }
30
+ }