@neta-art/cohub-cli 8.2.0 → 8.3.1

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 (76) hide show
  1. package/dist/commands/runtime.d.ts +0 -12
  2. package/dist/commands/runtime.js +129 -144
  3. package/dist/commands/search.js +3 -9
  4. package/dist/commands/spaces.js +5 -2
  5. package/dist/output.d.ts +6 -0
  6. package/dist/output.js +11 -0
  7. package/dist/runtime/codex-usage.d.ts +0 -2
  8. package/dist/runtime/codex-usage.js +0 -12
  9. package/dist/runtime/connection.d.ts +5 -5
  10. package/dist/runtime/connection.js +27 -16
  11. package/dist/runtime/harness.d.ts +8 -16
  12. package/dist/runtime/harness.js +1 -366
  13. package/dist/runtime/instance.d.ts +8 -1
  14. package/dist/runtime/instance.js +15 -8
  15. package/dist/runtime/launch.js +11 -5
  16. package/dist/runtime/native/adapters.d.ts +32 -0
  17. package/dist/runtime/native/adapters.js +79 -0
  18. package/dist/runtime/{native-archive.d.ts → native/archive.d.ts} +0 -3
  19. package/dist/runtime/{native-archive.js → native/archive.js} +2 -17
  20. package/dist/runtime/native/attach.d.ts +38 -0
  21. package/dist/runtime/native/attach.js +113 -0
  22. package/dist/runtime/native/codex-link.d.ts +40 -0
  23. package/dist/runtime/native/codex-link.js +249 -0
  24. package/dist/runtime/native/config.d.ts +16 -0
  25. package/dist/runtime/native/config.js +24 -0
  26. package/dist/runtime/native/daemon.d.ts +88 -0
  27. package/dist/runtime/native/daemon.js +367 -0
  28. package/dist/runtime/native/execution.d.ts +45 -0
  29. package/dist/runtime/native/execution.js +337 -0
  30. package/dist/runtime/native/identity.d.ts +9 -0
  31. package/dist/runtime/native/identity.js +15 -0
  32. package/dist/runtime/native/ingest.d.ts +241 -0
  33. package/dist/runtime/native/ingest.js +823 -0
  34. package/dist/runtime/native/install.d.ts +9 -0
  35. package/dist/runtime/native/install.js +113 -0
  36. package/dist/runtime/native/pi-extension.d.ts +35 -0
  37. package/dist/runtime/native/pi-extension.js +160 -0
  38. package/dist/runtime/native/pi-link.d.ts +67 -0
  39. package/dist/runtime/native/pi-link.js +238 -0
  40. package/dist/runtime/native/results.d.ts +41 -0
  41. package/dist/runtime/native/results.js +128 -0
  42. package/dist/runtime/native/sessions.d.ts +45 -0
  43. package/dist/runtime/native/sessions.js +92 -0
  44. package/dist/runtime/{native-transcript.d.ts → native/transcript.d.ts} +5 -5
  45. package/dist/runtime/{native-transcript.js → native/transcript.js} +37 -11
  46. package/dist/runtime/native/translate.d.ts +44 -0
  47. package/dist/runtime/native/translate.js +263 -0
  48. package/dist/runtime/native/transport.d.ts +7 -0
  49. package/dist/runtime/native/transport.js +16 -0
  50. package/dist/runtime/native/version.d.ts +5 -0
  51. package/dist/runtime/native/version.js +18 -0
  52. package/dist/runtime/presentation.d.ts +7 -10
  53. package/dist/runtime/presentation.js +35 -10
  54. package/dist/runtime/projection-store.d.ts +6 -13
  55. package/dist/runtime/projection-store.js +11 -74
  56. package/dist/runtime/supervisor.d.ts +2 -0
  57. package/dist/runtime/supervisor.js +36 -110
  58. package/dist/runtime/turn-projection.d.ts +1 -3
  59. package/dist/runtime/turn-projection.js +3 -10
  60. package/package.json +4 -5
  61. package/dist/runtime/native-attach.d.ts +0 -28
  62. package/dist/runtime/native-attach.js +0 -47
  63. package/dist/runtime/native-codex-hook.d.ts +0 -1
  64. package/dist/runtime/native-codex-hook.js +0 -28
  65. package/dist/runtime/native-install.d.ts +0 -21
  66. package/dist/runtime/native-install.js +0 -130
  67. package/dist/runtime/native-ipc.d.ts +0 -37
  68. package/dist/runtime/native-ipc.js +0 -118
  69. package/dist/runtime/native-pi-extension.d.ts +0 -20
  70. package/dist/runtime/native-pi-extension.js +0 -47
  71. package/dist/runtime/native-sync-store.d.ts +0 -116
  72. package/dist/runtime/native-sync-store.js +0 -495
  73. package/dist/runtime/native-sync.d.ts +0 -65
  74. package/dist/runtime/native-sync.js +0 -326
  75. package/dist/runtime/session-store.d.ts +0 -84
  76. package/dist/runtime/session-store.js +0 -601
@@ -0,0 +1,9 @@
1
+ export declare const piAgentDirectory: () => string;
2
+ /** The extension shipped with this CLI: one self-contained module, loadable by Pi as is. */
3
+ export declare const piExtensionSource: () => string;
4
+ export type PiExtensionState = "installed" | "outdated" | "missing" | "foreign";
5
+ export declare function piExtensionState(): Promise<PiExtensionState>;
6
+ /** Install or upgrade the Pi extension in the user's Pi configuration. */
7
+ export declare function installPiExtension(): Promise<PiExtensionState>;
8
+ /** Remove the Codex hooks earlier releases installed; Cohub now reads Codex's own files instead. */
9
+ export declare function removeLegacyCodexHooks(): Promise<boolean>;
@@ -0,0 +1,113 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, extname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { withRuntimeSpaceBindingsLock } from "../space-binding.js";
7
+ const missing = (error) => error.code === "ENOENT";
8
+ /** First line of the installed extension; a file without it belongs to someone else. */
9
+ const PI_EXTENSION_HEADER = "// Cohub control extension for Pi. Managed by `cohub runtime`; local edits are replaced on upgrade.\n";
10
+ /** CLI 8.0–8.2 installed a re-export under this comment; its module is gone, so Pi fails to start while it remains. */
11
+ const LEGACY_PI_EXTENSION_HEADER = "// Cohub native Turn sync";
12
+ /** Earlier releases installed Codex hooks; they are removed on upgrade. */
13
+ const LEGACY_CODEX_HOOKS = /\n*# BEGIN COHUB NATIVE SYNC\n[\s\S]*?# END COHUB NATIVE SYNC\n?/;
14
+ export const piAgentDirectory = () => process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
15
+ const codexConfigPath = () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "config.toml");
16
+ /** The extension shipped with this CLI: one self-contained module, loadable by Pi as is. */
17
+ export const piExtensionSource = () => fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./pi-extension.ts" : "./pi-extension.js", import.meta.url));
18
+ const piExtensionPath = (extension) => join(piAgentDirectory(), "extensions", `cohub${extension}`);
19
+ const readOptional = (path) => readFile(path, "utf8").catch((error) => { if (missing(error))
20
+ return null; throw error; });
21
+ const isLegacy = (text) => Boolean(text?.startsWith(LEGACY_PI_EXTENSION_HEADER));
22
+ export async function piExtensionState() {
23
+ const source = piExtensionSource();
24
+ const [expected, current, legacy] = await Promise.all([readFile(source, "utf8"), readOptional(piExtensionPath(extname(source))), readOptional(piExtensionPath(".ts"))]);
25
+ if (isLegacy(legacy))
26
+ return "outdated";
27
+ if (current === null)
28
+ return "missing";
29
+ if (current === PI_EXTENSION_HEADER + expected)
30
+ return "installed";
31
+ return current.startsWith(PI_EXTENSION_HEADER) ? "outdated" : "foreign";
32
+ }
33
+ /** Install or upgrade the Pi extension in the user's Pi configuration. */
34
+ export async function installPiExtension() {
35
+ const source = piExtensionSource();
36
+ const content = PI_EXTENSION_HEADER + await readFile(source, "utf8");
37
+ const target = piExtensionPath(extname(source));
38
+ await writeManaged(target, (existing) => {
39
+ if (existing !== null && !existing.startsWith(PI_EXTENSION_HEADER) && !isLegacy(existing))
40
+ throw new Error(`${target} exists and is not managed by Cohub; move it aside, then retry`);
41
+ return content;
42
+ }, { backup: false });
43
+ // Pi loads every file here: drop a legacy or managed copy under the other extension.
44
+ for (const other of [".ts", ".js"].map(piExtensionPath).filter((path) => path !== target)) {
45
+ const text = await readOptional(other).catch(() => null);
46
+ if (isLegacy(text) || text?.startsWith(PI_EXTENSION_HEADER))
47
+ await rm(other, { force: true });
48
+ }
49
+ return "installed";
50
+ }
51
+ /** Remove the Codex hooks earlier releases installed; Cohub now reads Codex's own files instead. */
52
+ export async function removeLegacyCodexHooks() {
53
+ const path = codexConfigPath();
54
+ // Read first: the lock would create `~/.codex` for someone who never used Codex.
55
+ if (!LEGACY_CODEX_HOOKS.test(await readOptional(path) ?? ""))
56
+ return false;
57
+ let removed = false;
58
+ await writeManaged(path, (existing) => {
59
+ if (existing === null || !LEGACY_CODEX_HOOKS.test(existing))
60
+ return existing;
61
+ removed = true;
62
+ return existing.replace(LEGACY_CODEX_HOOKS, "\n");
63
+ }).catch((error) => { if (!missing(error))
64
+ throw error; });
65
+ return removed;
66
+ }
67
+ /** Replace a user configuration file atomically, keeping one backup of what was there. */
68
+ async function writeManaged(path, update, options = { backup: true }) {
69
+ await withRuntimeSpaceBindingsLock(async () => {
70
+ const info = await lstat(path).catch((error) => { if (missing(error))
71
+ return null; throw error; });
72
+ if (info && (!info.isFile() || info.isSymbolicLink()))
73
+ throw new Error(`Refusing to replace a non-regular file: ${path}`);
74
+ const original = info ? await readFile(path, "utf8") : null;
75
+ const next = update(original);
76
+ if (next === null || next === original)
77
+ return;
78
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
79
+ if (original !== null && options.backup) {
80
+ const backup = `${path}.cohub-backup-${createHash("sha256").update(original).digest("hex").slice(0, 16)}`;
81
+ const file = await open(backup, "wx", 0o600).catch((error) => { if (error.code === "EEXIST")
82
+ return null; throw error; });
83
+ if (file) {
84
+ try {
85
+ await file.writeFile(original);
86
+ await file.sync();
87
+ }
88
+ finally {
89
+ await file.close();
90
+ }
91
+ }
92
+ }
93
+ const temporary = `${path}.${randomUUID()}.tmp`;
94
+ try {
95
+ const file = await open(temporary, "wx", info?.mode ?? 0o600);
96
+ try {
97
+ await file.writeFile(next);
98
+ await file.sync();
99
+ }
100
+ finally {
101
+ await file.close();
102
+ }
103
+ const current = await readFile(path, "utf8").catch((error) => { if (missing(error))
104
+ return null; throw error; });
105
+ if (current !== original)
106
+ throw new Error(`Configuration changed during installation: ${path}`);
107
+ await rename(temporary, path);
108
+ }
109
+ finally {
110
+ await rm(temporary, { force: true });
111
+ }
112
+ }, { lockPath: `${path}.cohub-lock` });
113
+ }
@@ -0,0 +1,35 @@
1
+ /** Bumped when the daemon and the extension must be installed together. */
2
+ export declare const COHUB_PI_PROTOCOL = 1;
3
+ type PiContext = {
4
+ cwd: string;
5
+ sessionManager: {
6
+ getSessionFile(): string | undefined;
7
+ getSessionId(): string;
8
+ };
9
+ isIdle(): boolean;
10
+ abort(): void;
11
+ modelRegistry?: {
12
+ find(provider: string, id: string): unknown;
13
+ };
14
+ };
15
+ type Content = string | Array<{
16
+ type: "text";
17
+ text: string;
18
+ } | {
19
+ type: "image";
20
+ data: string;
21
+ mimeType: string;
22
+ }>;
23
+ type Pi = {
24
+ on(event: string, handler: (event: unknown, context: PiContext) => unknown): void;
25
+ sendUserMessage(content: Content, options?: {
26
+ deliverAs?: "steer" | "followUp";
27
+ }): void;
28
+ appendEntry(customType: string, data?: unknown): void;
29
+ setModel?(model: unknown): Promise<boolean> | boolean;
30
+ setThinkingLevel?(level: string): void;
31
+ };
32
+ /** Runtime directory shared with the CLI; one socket per bound directory, hashed to stay short. */
33
+ export declare function controlSocketPath(root: string): string;
34
+ export default function cohub(pi: Pi): void;
35
+ export {};
@@ -0,0 +1,160 @@
1
+ // Cohub control bridge for Pi. Self-contained: Node built-ins only, so it installs as one file.
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { connect } from "node:net";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ /** Bumped when the daemon and the extension must be installed together. */
8
+ export const COHUB_PI_PROTOCOL = 1;
9
+ /** Pi's tools read the process environment when they run, so this reaches the next tool call. */
10
+ function applyEnvironment(env) {
11
+ for (const [key, value] of Object.entries(env ?? {})) {
12
+ if (!key.startsWith("COHUB_"))
13
+ continue;
14
+ if (value === null)
15
+ delete process.env[key];
16
+ else
17
+ process.env[key] = value;
18
+ }
19
+ }
20
+ /** Runtime directory shared with the CLI; one socket per bound directory, hashed to stay short. */
21
+ export function controlSocketPath(root) {
22
+ const state = process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state");
23
+ return join(state, "cohub", "control", `${createHash("sha256").update(root).digest("hex").slice(0, 32)}.sock`);
24
+ }
25
+ /** The nearest bound ancestor of `cwd`: a Runtime bound to a parent directory owns its subdirectories. */
26
+ function locateSocket(cwd) {
27
+ let directory;
28
+ try {
29
+ directory = realpathSync(cwd);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ for (;;) {
35
+ const socket = controlSocketPath(directory);
36
+ if (existsSync(socket))
37
+ return socket;
38
+ const parent = dirname(directory);
39
+ if (parent === directory)
40
+ return null;
41
+ directory = parent;
42
+ }
43
+ }
44
+ /** How often a missing or lost Runtime is looked for again. */
45
+ const RECONNECT_MS = 3_000;
46
+ /** Streamed events the daemon uses; everything else Pi emits stays local. */
47
+ const FORWARDED = new Set(["agent_start", "agent_end", "agent_settled", "turn_start", "turn_end", "message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_update", "tool_execution_end"]);
48
+ export default function cohub(pi) {
49
+ // Cohub may load this file explicitly into a Pi it starts; never attach twice.
50
+ const marker = Symbol.for("cohub.pi.control");
51
+ const globals = globalThis;
52
+ if (globals[marker])
53
+ return;
54
+ globals[marker] = true;
55
+ let socket = null;
56
+ let context = null;
57
+ let buffer = "";
58
+ let retry = null;
59
+ let probedAt = 0;
60
+ const send = (message) => { if (socket && !socket.destroyed)
61
+ socket.write(`${JSON.stringify(message)}\n`); };
62
+ const run = async (command) => {
63
+ if (!context)
64
+ throw new Error("Pi session is not ready");
65
+ if (command.type === "abort") {
66
+ context.abort();
67
+ return { idle: context.isIdle() };
68
+ }
69
+ if (command.type === "context") {
70
+ applyEnvironment(command.env);
71
+ return { idle: context.isIdle() };
72
+ }
73
+ const idle = context.isIdle();
74
+ // A Cohub Turn is a Turn of its own. If the terminal user started one first, nothing is written
75
+ // and Cohub waits for it to settle: queued behind it, the marker and the events would
76
+ // interleave.
77
+ if (command.turnId && !idle)
78
+ return { idle, accepted: false };
79
+ if (idle && command.model && context.modelRegistry && pi.setModel) {
80
+ const model = context.modelRegistry.find(command.model.provider, command.model.id);
81
+ if (!model || !await pi.setModel(model))
82
+ throw new Error("Model is unavailable in this Pi");
83
+ }
84
+ if (idle && command.thinkingLevel && pi.setThinkingLevel)
85
+ pi.setThinkingLevel(command.thinkingLevel);
86
+ // Choosing the model awaited; the terminal may have started meanwhile.
87
+ if (command.turnId && !context.isIdle())
88
+ return { idle: false, accepted: false };
89
+ // The marker precedes the user message, so the transcript itself records which cloud Turn it is.
90
+ applyEnvironment(command.env);
91
+ if (command.turnId)
92
+ pi.appendEntry("cohub.turn", { turnId: command.turnId });
93
+ pi.sendUserMessage(command.content, idle ? undefined : { deliverAs: command.deliverAs ?? "followUp" });
94
+ return { idle, accepted: true };
95
+ };
96
+ const attach = (ctx) => {
97
+ context = ctx;
98
+ if (socket && !socket.destroyed)
99
+ return;
100
+ if (Date.now() - probedAt < RECONNECT_MS)
101
+ return;
102
+ probedAt = Date.now();
103
+ const path = locateSocket(ctx.cwd);
104
+ if (!path)
105
+ return;
106
+ const current = connect(path);
107
+ socket = current;
108
+ buffer = "";
109
+ current.on("connect", () => send({
110
+ type: "hello", protocol: COHUB_PI_PROTOCOL, pid: process.pid, cwd: ctx.cwd,
111
+ sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile() ?? null, idle: ctx.isIdle(),
112
+ }));
113
+ current.on("data", (bytes) => {
114
+ buffer += bytes.toString("utf8");
115
+ for (let end = buffer.indexOf("\n"); end >= 0; end = buffer.indexOf("\n")) {
116
+ const line = buffer.slice(0, end);
117
+ buffer = buffer.slice(end + 1);
118
+ if (!line)
119
+ continue;
120
+ let command;
121
+ try {
122
+ command = JSON.parse(line);
123
+ }
124
+ catch {
125
+ continue;
126
+ }
127
+ run(command).then((result) => send({ type: "result", id: command.id, ...result }), (error) => send({ type: "error", id: command.id, message: error instanceof Error ? error.message : String(error) }));
128
+ }
129
+ });
130
+ // A Runtime that restarts is reached again on the next event; nothing is buffered meanwhile.
131
+ current.on("error", () => undefined);
132
+ current.on("close", () => {
133
+ if (socket === current)
134
+ socket = null;
135
+ retry ??= setTimeout(() => { retry = null; if (context)
136
+ attach(context); }, RECONNECT_MS);
137
+ retry.unref?.();
138
+ });
139
+ };
140
+ pi.on("session_start", (_event, ctx) => attach(ctx));
141
+ for (const name of FORWARDED) {
142
+ pi.on(name, (event, ctx) => {
143
+ context = ctx;
144
+ if (!socket)
145
+ attach(ctx);
146
+ send({ type: "event", sessionId: ctx.sessionManager.getSessionId(), event: { ...event, type: name } });
147
+ });
148
+ }
149
+ // Pi replaces this instance on /new, /resume, /fork and /reload, and loads the next one only
150
+ // after this shutdown; the next instance attaches with its own session.
151
+ pi.on("session_shutdown", () => {
152
+ if (retry)
153
+ clearTimeout(retry);
154
+ retry = null;
155
+ socket?.end();
156
+ socket = null;
157
+ context = null;
158
+ globals[marker] = false;
159
+ });
160
+ }
@@ -0,0 +1,67 @@
1
+ import type { Version } from "./version.js";
2
+ /**
3
+ * Oldest Pi whose extension API Cohub relies on (`agent_settled`, `appendEntry`, steer and follow-
4
+ * up delivery).
5
+ */
6
+ export declare const PI_MIN_VERSION: Version;
7
+ /** Delivered to a session's subscribers when its Pi goes away. */
8
+ export declare const PI_DISCONNECTED = "cohub_disconnected";
9
+ /** A Pi process that is connected and can be driven: a terminal session or one Cohub started. */
10
+ export type PiSessionLink = {
11
+ pid: number;
12
+ cwd: string;
13
+ sessionId: string;
14
+ sessionFile: string | null;
15
+ idle: boolean;
16
+ settledAt: number;
17
+ };
18
+ /**
19
+ * The Pi half of the control plane. Cohub never instructs Pi through a private channel it owns: the
20
+ * extension inside the running Pi process is the single interface, so a terminal session and one
21
+ * Cohub started behave identically.
22
+ */
23
+ export declare class PiLink {
24
+ private readonly options;
25
+ private server;
26
+ unavailable: string | null;
27
+ private readonly sessions;
28
+ private readonly listeners;
29
+ private counter;
30
+ constructor(options: {
31
+ socketPath: string;
32
+ owns: (cwd: string) => boolean;
33
+ onEvent?: (session: PiSessionLink, event: Record<string, unknown>) => void;
34
+ onConnect?: (session: PiSessionLink) => void;
35
+ onDisconnect?: (session: PiSessionLink) => void;
36
+ });
37
+ get socketPath(): string;
38
+ subscribe(sessionId: string, listener: (event: Record<string, unknown>) => void): () => void;
39
+ waitFor(match: {
40
+ sessionId: string;
41
+ sessionFile: string;
42
+ }, signal: AbortSignal, timeoutMs?: number): Promise<PiSessionLink>;
43
+ idle(sessionId: string, signal: AbortSignal): Promise<void>;
44
+ listen(signal: AbortSignal): Promise<void>;
45
+ list(): PiSessionLink[];
46
+ find(sessionId: string): PiSessionLink | null;
47
+ isStreaming(sessionId: string): boolean | null;
48
+ prompt(sessionId: string, input: {
49
+ turnId?: string;
50
+ content: unknown;
51
+ deliverAs?: "steer" | "followUp";
52
+ model?: {
53
+ provider: string;
54
+ id: string;
55
+ } | null;
56
+ thinkingLevel?: string | null;
57
+ env?: Record<string, string | null>;
58
+ }): Promise<{
59
+ accepted: boolean;
60
+ }>;
61
+ context(sessionId: string, env: Record<string, string | null>): Promise<void>;
62
+ abort(sessionId: string): Promise<void>;
63
+ close(): Promise<void>;
64
+ private request;
65
+ private accept;
66
+ private hello;
67
+ }
@@ -0,0 +1,238 @@
1
+ import { createServer } from "node:net";
2
+ import { chmod, mkdir, rm } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { COHUB_PI_PROTOCOL } from "./pi-extension.js";
5
+ const MAX_LINE_BYTES = 8 * 1024 * 1024;
6
+ const REQUEST_TIMEOUT_MS = 20 * 60_000;
7
+ /**
8
+ * Oldest Pi whose extension API Cohub relies on (`agent_settled`, `appendEntry`, steer and follow-
9
+ * up delivery).
10
+ */
11
+ export const PI_MIN_VERSION = [0, 85, 1];
12
+ /** Delivered to a session's subscribers when its Pi goes away. */
13
+ export const PI_DISCONNECTED = "cohub_disconnected";
14
+ /**
15
+ * The Pi half of the control plane. Cohub never instructs Pi through a private channel it owns: the
16
+ * extension inside the running Pi process is the single interface, so a terminal session and one
17
+ * Cohub started behave identically.
18
+ */
19
+ export class PiLink {
20
+ options;
21
+ server = null;
22
+ unavailable = null;
23
+ sessions = new Map();
24
+ listeners = new Map();
25
+ counter = 0;
26
+ constructor(options) {
27
+ this.options = options;
28
+ }
29
+ get socketPath() { return this.options.socketPath; }
30
+ subscribe(sessionId, listener) {
31
+ let set = this.listeners.get(sessionId);
32
+ if (!set) {
33
+ set = new Set();
34
+ this.listeners.set(sessionId, set);
35
+ }
36
+ set.add(listener);
37
+ return () => { set?.delete(listener); if (!set?.size)
38
+ this.listeners.delete(sessionId); };
39
+ }
40
+ async waitFor(match, signal, timeoutMs = 30_000) {
41
+ const deadline = Date.now() + timeoutMs;
42
+ while (Date.now() < deadline) {
43
+ signal.throwIfAborted();
44
+ const found = this.list().find((session) => session.sessionId === match.sessionId || session.sessionFile === match.sessionFile);
45
+ if (found)
46
+ return found;
47
+ await new Promise((resolve) => setTimeout(resolve, 50));
48
+ }
49
+ throw new Error("Pi did not attach to the Runtime; check that the Cohub extension loads");
50
+ }
51
+ async idle(sessionId, signal) {
52
+ if (this.sessions.get(sessionId)?.idle !== false)
53
+ return;
54
+ await new Promise((resolve, reject) => {
55
+ const finish = (error) => { off(); signal.removeEventListener("abort", onAbort); if (error)
56
+ reject(error);
57
+ else
58
+ resolve(); };
59
+ const onAbort = () => finish(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
60
+ const off = this.subscribe(sessionId, (event) => {
61
+ if (event.type === "agent_settled")
62
+ finish();
63
+ if (event.type === PI_DISCONNECTED)
64
+ finish(new Error("Pi disconnected"));
65
+ });
66
+ signal.addEventListener("abort", onAbort, { once: true });
67
+ });
68
+ }
69
+ async listen(signal) {
70
+ const { socketPath } = this.options;
71
+ // The socket accepts commands that drive the user's agent: only this user may reach it.
72
+ await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
73
+ if (process.platform !== "win32")
74
+ await chmod(dirname(socketPath), 0o700);
75
+ await rm(socketPath, { force: true });
76
+ const server = createServer((socket) => this.accept(socket));
77
+ await new Promise((resolve, reject) => {
78
+ server.once("error", reject);
79
+ server.listen(socketPath, () => { server.removeListener("error", reject); resolve(); });
80
+ });
81
+ if (process.platform !== "win32")
82
+ await chmod(socketPath, 0o600);
83
+ this.server = server;
84
+ signal.addEventListener("abort", () => {
85
+ for (const session of this.sessions.values())
86
+ session.socket.destroy();
87
+ this.sessions.clear();
88
+ server.close();
89
+ void rm(socketPath, { force: true }).catch(() => undefined);
90
+ }, { once: true });
91
+ }
92
+ list() {
93
+ return [...this.sessions.values()].map(({ socket: _socket, pending: _pending, ...link }) => link);
94
+ }
95
+ find(sessionId) {
96
+ const live = this.sessions.get(sessionId);
97
+ if (!live)
98
+ return null;
99
+ const { socket: _socket, pending: _pending, ...link } = live;
100
+ return link;
101
+ }
102
+ isStreaming(sessionId) {
103
+ const live = this.sessions.get(sessionId);
104
+ return live ? !live.idle : null;
105
+ }
106
+ async prompt(sessionId, input) {
107
+ const result = await this.request(sessionId, { type: "prompt", ...input });
108
+ // A Cohub Turn is refused while the terminal runs one of its own; nothing was written then.
109
+ return { accepted: result?.accepted !== false };
110
+ }
111
+ async context(sessionId, env) {
112
+ await this.request(sessionId, { type: "context", env });
113
+ }
114
+ async abort(sessionId) {
115
+ await this.request(sessionId, { type: "abort" });
116
+ }
117
+ async close() {
118
+ for (const session of this.sessions.values())
119
+ session.socket.destroy();
120
+ this.sessions.clear();
121
+ await new Promise((resolve) => this.server?.close(() => resolve()) ?? resolve());
122
+ }
123
+ request(sessionId, command) {
124
+ const live = this.sessions.get(sessionId);
125
+ if (!live || live.socket.destroyed)
126
+ return Promise.reject(new Error("Pi is not connected"));
127
+ const id = `cohub-${++this.counter}`;
128
+ return new Promise((resolve, reject) => {
129
+ const timer = setTimeout(() => { live.pending.delete(id); reject(new Error("Pi did not answer in time")); }, REQUEST_TIMEOUT_MS);
130
+ live.pending.set(id, { resolve, reject, timer });
131
+ live.socket.write(`${JSON.stringify({ ...command, id })}\n`);
132
+ });
133
+ }
134
+ accept(socket) {
135
+ let buffer = "";
136
+ /** This connection's link; a newer connection for the same session replaces it in the map. */
137
+ let own = null;
138
+ socket.setNoDelay?.(true);
139
+ socket.on("error", () => socket.destroy());
140
+ socket.on("close", () => {
141
+ if (!own)
142
+ return;
143
+ const live = own;
144
+ for (const pending of live.pending.values()) {
145
+ clearTimeout(pending.timer);
146
+ pending.reject(new Error("Pi disconnected"));
147
+ }
148
+ live.pending.clear();
149
+ if (this.sessions.get(live.sessionId) !== live)
150
+ return;
151
+ this.sessions.delete(live.sessionId);
152
+ for (const listener of this.listeners.get(live.sessionId) ?? [])
153
+ listener({ type: PI_DISCONNECTED });
154
+ const { socket: _socket, pending: _pending, ...link } = live;
155
+ this.options.onDisconnect?.(link);
156
+ });
157
+ socket.on("data", (bytes) => {
158
+ buffer += bytes.toString("utf8");
159
+ if (buffer.length > MAX_LINE_BYTES) {
160
+ socket.destroy();
161
+ return;
162
+ }
163
+ for (let end = buffer.indexOf("\n"); end >= 0; end = buffer.indexOf("\n")) {
164
+ const line = buffer.slice(0, end);
165
+ buffer = buffer.slice(end + 1);
166
+ if (!line)
167
+ continue;
168
+ let message;
169
+ try {
170
+ message = JSON.parse(line);
171
+ }
172
+ catch {
173
+ continue;
174
+ }
175
+ if (message.type === "hello") {
176
+ // One session per connection; a Pi that switches sessions reconnects.
177
+ const attached = own ? null : this.hello(socket, message);
178
+ if (!attached) {
179
+ socket.destroy();
180
+ return;
181
+ }
182
+ own = attached;
183
+ continue;
184
+ }
185
+ const live = own && this.sessions.get(own.sessionId) === own ? own : null;
186
+ if (!live)
187
+ continue;
188
+ if (message.type === "event") {
189
+ const event = (message.event ?? {});
190
+ if (event.type === "agent_start")
191
+ live.idle = false;
192
+ if (event.type === "agent_settled") {
193
+ live.idle = true;
194
+ live.settledAt = Date.now();
195
+ }
196
+ const { socket: _socket, pending: _pending, ...link } = live;
197
+ this.options.onEvent?.(link, event);
198
+ for (const listener of this.listeners.get(live.sessionId) ?? [])
199
+ listener(event);
200
+ continue;
201
+ }
202
+ const id = typeof message.id === "string" ? message.id : null;
203
+ const pending = id ? live.pending.get(id) : null;
204
+ if (!pending || !id)
205
+ continue;
206
+ live.pending.delete(id);
207
+ clearTimeout(pending.timer);
208
+ if (message.type === "error")
209
+ pending.reject(new Error(typeof message.message === "string" ? message.message : "Pi rejected the command"));
210
+ else
211
+ pending.resolve(message);
212
+ }
213
+ });
214
+ }
215
+ hello(socket, message) {
216
+ if (message.protocol !== COHUB_PI_PROTOCOL)
217
+ return null;
218
+ const cwd = typeof message.cwd === "string" ? message.cwd : "";
219
+ const sessionId = typeof message.sessionId === "string" ? message.sessionId : "";
220
+ const pid = typeof message.pid === "number" ? message.pid : 0;
221
+ if (!sessionId || !this.options.owns(cwd))
222
+ return null;
223
+ const live = {
224
+ pid, cwd, sessionId,
225
+ sessionFile: typeof message.sessionFile === "string" ? message.sessionFile : null,
226
+ idle: message.idle !== false,
227
+ settledAt: message.idle !== false ? Date.now() : 0,
228
+ socket,
229
+ pending: new Map(),
230
+ };
231
+ // A Pi that reconnects after a Runtime restart replaces its stale link.
232
+ this.sessions.get(sessionId)?.socket.destroy();
233
+ this.sessions.set(sessionId, live);
234
+ const { socket: _socket, pending: _pending, ...link } = live;
235
+ this.options.onConnect?.(link);
236
+ return live;
237
+ }
238
+ }
@@ -0,0 +1,41 @@
1
+ import { type RuntimeExecutionEvent, type RuntimePendingExecution, type RuntimeTurnInput } from "@neta-art/cohub";
2
+ import type { NativeSession } from "./sessions.js";
3
+ export declare class ContextRequiredError extends Error {
4
+ }
5
+ type Receipt = {
6
+ version: 2;
7
+ requestId: string | null;
8
+ sessionId: string;
9
+ turnId: string;
10
+ session: NativeSession;
11
+ events?: RuntimeExecutionEvent[];
12
+ uncertainCleanup?: {
13
+ processGroupId: number;
14
+ error: string;
15
+ confirmedAt?: string;
16
+ };
17
+ };
18
+ export type RecoveredResult = {
19
+ session: NativeSession;
20
+ events: RuntimeExecutionEvent[];
21
+ };
22
+ /** One small receipt per Cohub-executed Turn until the server acknowledges its result. */
23
+ export declare class ExecutionResults {
24
+ private readonly root;
25
+ constructor(root: string);
26
+ private path;
27
+ private read;
28
+ /**
29
+ * Admit a new Turn. An unacknowledged earlier Turn is released once the server has settled it;
30
+ * otherwise the server must decide first, so a result is never overwritten unseen.
31
+ */
32
+ admit(input: RuntimeTurnInput): Promise<string | null>;
33
+ start(session: NativeSession, input: Pick<RuntimeTurnInput, "sessionId" | "turnId">, requestId: string | null): Promise<void>;
34
+ record(session: NativeSession, input: Pick<RuntimeTurnInput, "sessionId" | "turnId">, requestId: string | null, events: RuntimeExecutionEvent[], uncertainCleanup?: Receipt["uncertainCleanup"]): Promise<void>;
35
+ recover(input: Pick<RuntimeTurnInput, "sessionId" | "turnId" | "harness">, requestId?: string): Promise<RecoveredResult | null>;
36
+ acknowledge(sessionId: string, turnId: string): Promise<void>;
37
+ pendingTurnIds(sessionId: string): Promise<string[]>;
38
+ pendingBatches(): AsyncGenerator<RuntimePendingExecution[]>;
39
+ private fromTranscript;
40
+ }
41
+ export {};