codexteer 0.14.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +519 -0
  3. package/assets/send-pururu.wav +0 -0
  4. package/bin/codexteer-wrapper.mjs +10 -0
  5. package/bin/codexteer.mjs +5 -0
  6. package/docs/distribution.md +186 -0
  7. package/node_modules/ws/LICENSE +20 -0
  8. package/node_modules/ws/README.md +548 -0
  9. package/node_modules/ws/browser.js +8 -0
  10. package/node_modules/ws/index.js +22 -0
  11. package/node_modules/ws/lib/buffer-util.js +131 -0
  12. package/node_modules/ws/lib/constants.js +19 -0
  13. package/node_modules/ws/lib/event-target.js +292 -0
  14. package/node_modules/ws/lib/extension.js +203 -0
  15. package/node_modules/ws/lib/limiter.js +55 -0
  16. package/node_modules/ws/lib/permessage-deflate.js +530 -0
  17. package/node_modules/ws/lib/receiver.js +743 -0
  18. package/node_modules/ws/lib/sender.js +607 -0
  19. package/node_modules/ws/lib/stream.js +161 -0
  20. package/node_modules/ws/lib/subprotocol.js +62 -0
  21. package/node_modules/ws/lib/validation.js +152 -0
  22. package/node_modules/ws/lib/websocket-server.js +562 -0
  23. package/node_modules/ws/lib/websocket.js +1407 -0
  24. package/node_modules/ws/package.json +74 -0
  25. package/node_modules/ws/wrapper.mjs +21 -0
  26. package/package.json +41 -0
  27. package/scripts/accessibility.applescript +43 -0
  28. package/scripts/inspect.applescript +72 -0
  29. package/scripts/send.applescript +45 -0
  30. package/src/app-server.mjs +117 -0
  31. package/src/bridge.mjs +79 -0
  32. package/src/checkpoint.mjs +128 -0
  33. package/src/cli.mjs +358 -0
  34. package/src/compatibility.mjs +115 -0
  35. package/src/desktop.mjs +207 -0
  36. package/src/directive.mjs +39 -0
  37. package/src/distribution.mjs +192 -0
  38. package/src/evidence.mjs +33 -0
  39. package/src/help.mjs +145 -0
  40. package/src/journal.mjs +120 -0
  41. package/src/launcher.mjs +145 -0
  42. package/src/monitor.mjs +123 -0
  43. package/src/observe.mjs +185 -0
  44. package/src/paged-read.mjs +228 -0
  45. package/src/prompt.mjs +51 -0
  46. package/src/resource.mjs +70 -0
  47. package/src/rpc.mjs +162 -0
  48. package/src/runner.mjs +32 -0
  49. package/src/runtime.mjs +130 -0
  50. package/src/settings.mjs +49 -0
  51. package/src/sound.mjs +16 -0
  52. package/src/store.mjs +78 -0
  53. package/src/subscription.mjs +118 -0
  54. package/src/supervise.mjs +57 -0
  55. package/src/thread-id.mjs +35 -0
  56. package/src/thread-store.mjs +72 -0
  57. package/src/version.mjs +19 -0
  58. package/src/wrapper.mjs +149 -0
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "ws",
3
+ "version": "8.21.3",
4
+ "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
5
+ "keywords": [
6
+ "HyBi",
7
+ "Push",
8
+ "RFC-6455",
9
+ "WebSocket",
10
+ "WebSockets",
11
+ "real-time"
12
+ ],
13
+ "homepage": "https://github.com/websockets/ws",
14
+ "bugs": "https://github.com/websockets/ws/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/websockets/ws.git"
18
+ },
19
+ "author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
20
+ "license": "MIT",
21
+ "main": "index.js",
22
+ "exports": {
23
+ ".": {
24
+ "browser": "./browser.js",
25
+ "import": "./wrapper.mjs",
26
+ "require": "./index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "browser": "browser.js",
31
+ "engines": {
32
+ "node": ">=10.0.0"
33
+ },
34
+ "files": [
35
+ "browser.js",
36
+ "index.js",
37
+ "lib/*.js",
38
+ "wrapper.mjs"
39
+ ],
40
+ "scripts": {
41
+ "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
42
+ "integration": "mocha --throw-deprecation test/*.integration.js",
43
+ "lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
44
+ },
45
+ "peerDependencies": {
46
+ "bufferutil": "^4.0.1",
47
+ "utf-8-validate": ">=5.0.2"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "bufferutil": {
51
+ "optional": true
52
+ },
53
+ "utf-8-validate": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "@eslint/js": "^10.0.1",
59
+ "benchmark": "^2.1.4",
60
+ "bufferutil": "^4.0.1",
61
+ "eslint": "^10.0.1",
62
+ "eslint-config-prettier": "^10.0.1",
63
+ "eslint-plugin-prettier": "^5.0.0",
64
+ "globals": "^17.0.0",
65
+ "mocha": "^8.4.0",
66
+ "nyc": "^15.0.0",
67
+ "prettier": "^3.0.0",
68
+ "utf-8-validate": "^6.0.0"
69
+ },
70
+ "allowScripts": {
71
+ "bufferutil": true,
72
+ "utf-8-validate": true
73
+ }
74
+ }
@@ -0,0 +1,21 @@
1
+ import createWebSocketStream from './lib/stream.js';
2
+ import extension from './lib/extension.js';
3
+ import PerMessageDeflate from './lib/permessage-deflate.js';
4
+ import Receiver from './lib/receiver.js';
5
+ import Sender from './lib/sender.js';
6
+ import subprotocol from './lib/subprotocol.js';
7
+ import WebSocket from './lib/websocket.js';
8
+ import WebSocketServer from './lib/websocket-server.js';
9
+
10
+ export {
11
+ createWebSocketStream,
12
+ extension,
13
+ PerMessageDeflate,
14
+ Receiver,
15
+ Sender,
16
+ subprotocol,
17
+ WebSocket,
18
+ WebSocketServer
19
+ };
20
+
21
+ export default WebSocket;
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "codexteer",
3
+ "version": "0.14.1",
4
+ "description": "Send steering messages to running Codex Desktop threads from a terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "codexteer": "bin/codexteer.mjs"
8
+ },
9
+ "scripts": {
10
+ "check": "node scripts/check-syntax.mjs",
11
+ "test": "node --test",
12
+ "test:protocol": "node scripts/probe-app-server.mjs",
13
+ "benchmark:read": "node scripts/benchmark-read.mjs",
14
+ "test:package": "node scripts/test-package.mjs"
15
+ },
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "dependencies": {
20
+ "ws": "8.21.3"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/vinhphatfsg/codexteer.git"
26
+ },
27
+ "files": [
28
+ "bin/*.mjs",
29
+ "src/*.mjs",
30
+ "scripts/*.applescript",
31
+ "assets/send-pururu.wav",
32
+ "docs/distribution.md"
33
+ ],
34
+ "bundleDependencies": [
35
+ "ws"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ }
41
+ }
@@ -0,0 +1,43 @@
1
+ on run argv
2
+ if (count of argv) is less than 1 then error "Expected enable or restore." number 64
3
+ set operation to item 1 of argv
4
+
5
+ tell application "System Events"
6
+ set appProcess to first application process whose bundle identifier is "com.openai.codex"
7
+ tell appProcess
8
+ if operation is "enable" then
9
+ try
10
+ set manualWasEnabled to (value of attribute "AXManualAccessibility") as boolean
11
+ if manualWasEnabled is false then
12
+ set value of attribute "AXManualAccessibility" to true
13
+ return "manual"
14
+ end if
15
+ return "unchanged"
16
+ on error
17
+ set enhancedWasEnabled to false
18
+ try
19
+ set enhancedWasEnabled to (value of attribute "AXEnhancedUserInterface") as boolean
20
+ end try
21
+ if enhancedWasEnabled is false then
22
+ set value of attribute "AXEnhancedUserInterface" to true
23
+ return "enhanced"
24
+ end if
25
+ return "unchanged"
26
+ end try
27
+ end if
28
+
29
+ if operation is "restore" then
30
+ if (count of argv) is not 2 then error "Expected accessibility mode to restore." number 64
31
+ set accessibilityMode to item 2 of argv
32
+ if accessibilityMode is "manual" then
33
+ set value of attribute "AXManualAccessibility" to false
34
+ else if accessibilityMode is "enhanced" then
35
+ set value of attribute "AXEnhancedUserInterface" to false
36
+ end if
37
+ return "restored"
38
+ end if
39
+ end tell
40
+ end tell
41
+
42
+ error "Expected enable or restore." number 64
43
+ end run
@@ -0,0 +1,72 @@
1
+ on run argv
2
+ if (count of argv) is not 1 then error "Expected wait time in milliseconds." number 64
3
+ set waitSeconds to ((item 1 of argv) as integer) / 1000
4
+
5
+ tell application "ChatGPT" to activate
6
+ delay waitSeconds
7
+
8
+ tell application "System Events"
9
+ set appProcess to first application process whose bundle identifier is "com.openai.codex"
10
+ tell appProcess
11
+ set frontmost to true
12
+ if not (exists front window) then error "Codex Desktop has no front window." number 69
13
+ set focusedElement to missing value
14
+ set focusedRole to "missing"
15
+ set focusedSubrole to ""
16
+ set focusedDescription to ""
17
+ set focusedTitle to ""
18
+ set focusedHelp to ""
19
+ set windowTitle to ""
20
+ try
21
+ set focusedElement to value of attribute "AXFocusedUIElement"
22
+ end try
23
+ if focusedElement is not missing value then
24
+ try
25
+ set focusedRole to role of focusedElement as text
26
+ end try
27
+ try
28
+ set focusedSubrole to (value of attribute "AXSubrole" of focusedElement) as text
29
+ end try
30
+ try
31
+ set focusedDescription to (value of attribute "AXDescription" of focusedElement) as text
32
+ end try
33
+ try
34
+ set focusedTitle to (value of attribute "AXTitle" of focusedElement) as text
35
+ end try
36
+ try
37
+ set focusedHelp to (value of attribute "AXHelp" of focusedElement) as text
38
+ end try
39
+ end if
40
+ try
41
+ set windowTitle to (value of attribute "AXTitle" of front window) as text
42
+ end try
43
+
44
+ set textAreaCount to 0
45
+ set textFieldCount to 0
46
+ set webAreaCount to 0
47
+ set windowContents to entire contents of front window
48
+ repeat with candidate in windowContents
49
+ try
50
+ set candidateRole to role of candidate
51
+ if candidateRole is "AXTextArea" then
52
+ set textAreaCount to textAreaCount + 1
53
+ else if candidateRole is "AXTextField" then
54
+ set textFieldCount to textFieldCount + 1
55
+ else if candidateRole is "AXWebArea" then
56
+ set webAreaCount to webAreaCount + 1
57
+ end if
58
+ end try
59
+ end repeat
60
+ end tell
61
+ end tell
62
+
63
+ return "window_title=" & windowTitle & linefeed & ¬
64
+ "focused_role=" & focusedRole & linefeed & ¬
65
+ "focused_subrole=" & focusedSubrole & linefeed & ¬
66
+ "focused_description=" & focusedDescription & linefeed & ¬
67
+ "focused_title=" & focusedTitle & linefeed & ¬
68
+ "focused_help=" & focusedHelp & linefeed & ¬
69
+ "text_area_count=" & textAreaCount & linefeed & ¬
70
+ "text_field_count=" & textFieldCount & linefeed & ¬
71
+ "web_area_count=" & webAreaCount
72
+ end run
@@ -0,0 +1,45 @@
1
+ on run argv
2
+ if (count of argv) is not 4 then error "Expected wait time, submit shortcut, focus policy, and previous process ID." number 64
3
+ set waitSeconds to ((item 1 of argv) as integer) / 1000
4
+ set submitShortcut to item 2 of argv
5
+ set focusPolicy to item 3 of argv
6
+ set previousProcessId to (item 4 of argv) as integer
7
+ if submitShortcut is not "plain" and submitShortcut is not "command" and submitShortcut is not "command-shift" then error "Invalid submit shortcut." number 64
8
+ if focusPolicy is not "restore" and focusPolicy is not "keep" then error "Invalid focus policy." number 64
9
+
10
+ if focusPolicy is "restore" then my restoreFocus(previousProcessId)
11
+ delay waitSeconds
12
+
13
+ try
14
+ tell application "System Events"
15
+ set appProcess to first application process whose bundle identifier is "com.openai.codex"
16
+ tell appProcess
17
+ set frontmost to true
18
+ if not (exists front window) then error "Codex Desktop has no front window." number 69
19
+ end tell
20
+
21
+ if submitShortcut is "command" then
22
+ key code 36 using {command down}
23
+ else if submitShortcut is "command-shift" then
24
+ key code 36 using {command down, shift down}
25
+ else
26
+ key code 36
27
+ end if
28
+ end tell
29
+
30
+ if focusPolicy is "restore" then my restoreFocus(previousProcessId)
31
+ on error errorMessage number errorNumber
32
+ if focusPolicy is "restore" then my restoreFocus(previousProcessId)
33
+ error errorMessage number errorNumber
34
+ end try
35
+
36
+ return "sent"
37
+ end run
38
+
39
+ on restoreFocus(processId)
40
+ if processId is 0 then return
41
+ tell application "System Events"
42
+ set matchingProcesses to application processes whose unix id is processId
43
+ if (count of matchingProcesses) > 0 then set frontmost of item 1 of matchingProcesses to true
44
+ end tell
45
+ end restoreFocus
@@ -0,0 +1,117 @@
1
+ import { mkdir, rm, readFile, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import { discoverRuntime, verifyControlEndpoint } from "./runtime.mjs";
5
+ import { RpcClient, RpcFailure } from "./rpc.mjs";
6
+ import { normalizeThreadId } from "./thread-id.mjs";
7
+ import { ensureDesktopSubscription } from "./subscription.mjs";
8
+ import { assertRuntimeOperation, compatibleClient } from "./compatibility.mjs";
9
+
10
+ export class SendFailure extends Error {
11
+ constructor(message, { code = "NOT_SENT", uncertain = false, threadId, rpcCode, capability, operation } = {}) {
12
+ super(message);
13
+ this.code = code;
14
+ this.delivery_status = uncertain ? "unknown" : "not_sent";
15
+ this.sent = uncertain ? null : false;
16
+ this.thread_id = threadId;
17
+ this.rpc_code = rpcCode;
18
+ this.capability = capability;
19
+ this.operation = operation;
20
+ }
21
+ }
22
+
23
+ function activeTurn(thread) {
24
+ const turns = thread.turns?.filter(turn => turn.status === "inProgress") ?? [];
25
+ return thread.status?.type === "active" && turns.length === 1 ? turns[0] : null;
26
+ }
27
+
28
+ function verifyThread(thread, threadId) {
29
+ if (thread?.id !== threadId) throw new Error("App Server returned a different task ID. Nothing was sent.");
30
+ if (thread.canAcceptDirectInput === false) throw new Error("This task does not accept direct input.");
31
+ if (!Array.isArray(thread.turns)) throw new Error("App Server did not return the task's turn state.");
32
+ }
33
+
34
+ export async function sendOnClient(client, threadId, message, { newTurn = false, subscribe, clientMessageId = randomUUID(), beforeSend } = {}) {
35
+ const { thread } = await client.request("thread/read", { threadId, includeTurns: true });
36
+ verifyThread(thread, threadId);
37
+ const input = [{ type: "text", text: message }];
38
+ if (!newTurn) {
39
+ const turn = activeTurn(thread);
40
+ if (!turn) throw new Error("The task has no unambiguous active turn. Use --new-turn for an idle task.");
41
+ await beforeSend?.(thread, client);
42
+ // Desktop uses clientId on the resulting userMessage to render external
43
+ // steering as a user bubble instead of just an anonymous "steered" marker.
44
+ // This is a presentation/correlation ID, not a retry/idempotency guarantee.
45
+ const clientUserMessageId = clientMessageId;
46
+ const accepted = await client.request("turn/steer", { threadId, expectedTurnId: turn.id, input, clientUserMessageId }, { mutation: true });
47
+ if (accepted?.turnId !== turn.id) throw new RpcFailure("App Server returned an unexpected turn ID. Do not retry automatically.", { code: "PROTOCOL_ERROR", uncertain: true });
48
+ return { turn_id: accepted.turnId, client_message_id: clientUserMessageId };
49
+ }
50
+ if (!["idle", "notLoaded"].includes(thread.status?.type)) throw new Error("--new-turn requires an idle task. Nothing was sent.");
51
+ // Resume through Desktop's long-lived connection so approval ownership
52
+ // survives the short-lived sender. Never resume only on the sender connection.
53
+ if (!subscribe) throw new Error("Desktop subscription support is required for --new-turn.");
54
+ // Optional safety checks (e.g. a paged --based-on cursor) must be usable before
55
+ // resuming, too. Validate again after resume to catch intervening user input.
56
+ await beforeSend?.(thread, client);
57
+ await subscribe(threadId);
58
+ const resumed = await client.request("thread/read", { threadId, includeTurns: true });
59
+ verifyThread(resumed.thread, threadId);
60
+ if (resumed.thread.status?.type !== "idle") throw new Error("Task became active while resuming. Nothing was sent.");
61
+ await beforeSend?.(resumed.thread, client);
62
+ const clientUserMessageId = clientMessageId;
63
+ const accepted = await client.request("turn/start", { threadId, input, clientUserMessageId }, { mutation: true });
64
+ if (typeof accepted?.turn?.id !== "string") throw new RpcFailure("App Server did not confirm a turn ID. Do not retry automatically.", { code: "PROTOCOL_ERROR", uncertain: true });
65
+ return { turn_id: accepted.turn.id, client_message_id: clientUserMessageId };
66
+ }
67
+
68
+ export async function sendAppServerMessage(threadInput, message, { dryRun = false, newTurn = false, clientMessageId = randomUUID(), beforeSend, connect = RpcClient.connect, discover = discoverRuntime, subscribe = ensureDesktopSubscription, inspectControl = verifyControlEndpoint } = {}) {
69
+ const threadId = normalizeThreadId(threadInput);
70
+ if (typeof message !== "string" || !message.trim()) throw new Error("Message must not be empty.");
71
+ const plan = { thread_id: threadId, backend: "app-server", delivery_action: newTurn ? "new-turn" : "steer", message_characters: [...message].length, dry_run: dryRun };
72
+ if (dryRun) return { ...plan, sent: false, delivery_status: "not_sent" };
73
+ let client;
74
+ let lock;
75
+ let result;
76
+ const token = randomUUID();
77
+ const operation = newTurn ? "send_new_turn" : "send";
78
+ try {
79
+ const { paths, state } = await discover();
80
+ assertRuntimeOperation(state, operation);
81
+ if (newTurn) await inspectControl(paths);
82
+ const candidate = path.join(paths.lease, `send-${threadId}`);
83
+ try { await mkdir(candidate, { mode: 0o700 }); } catch (error) {
84
+ if (error.code === "EEXIST") throw new Error("Another send is in progress for this task, or a previous sender exited unexpectedly. Restart the shared Desktop after checking delivery before retrying.");
85
+ throw error;
86
+ }
87
+ lock = candidate;
88
+ await writeFile(path.join(lock, "owner"), token, { mode: 0o600 });
89
+ client = compatibleClient(await connect(paths.socket), state);
90
+ const checkRuntime = async () => {
91
+ const current = await discover(); assertRuntimeOperation(current.state, operation);
92
+ if (current.state.instance !== state.instance || current.paths.socket !== paths.socket || (newTurn && current.paths.control !== paths.control)) {
93
+ throw Object.assign(new Error("Runtime changed before delivery. Read the target again before sending."), { code: "RUNTIME_CHANGED" });
94
+ }
95
+ if (newTurn) await inspectControl(current.paths);
96
+ };
97
+ const receipt = await sendOnClient(client, threadId, message, { newTurn, clientMessageId,
98
+ beforeSend: async (...args) => { await beforeSend?.(...args); await checkRuntime(); },
99
+ subscribe: async id => { await checkRuntime(); return subscribe(paths.control, id); },
100
+ });
101
+ result = { ...plan, ...receipt, sent: true, delivery_status: "accepted" };
102
+ return result;
103
+ } catch (error) {
104
+ throw new SendFailure(error.uncertain
105
+ ? "Delivery is unknown because App Server did not confirm receipt. Check the target task before retrying."
106
+ : error.message, { code: error.code ?? "NOT_SENT", uncertain: error.uncertain, threadId, rpcCode: error.rpcCode, capability: error.capability, operation: error.operation });
107
+ } finally {
108
+ // Cleanup failure must never turn an acknowledged send into a retryable
109
+ // error. Keep its acceptance receipt and leave any stale lock fail-closed.
110
+ try {
111
+ client?.close();
112
+ if (lock && await readFile(path.join(lock, "owner"), "utf8").catch(() => null) === token) {
113
+ await rm(lock, { recursive: true, force: true });
114
+ }
115
+ } catch { if (result) result.runtime_cleanup_required = true; }
116
+ }
117
+ }
package/src/bridge.mjs ADDED
@@ -0,0 +1,79 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ import { once } from "node:events";
3
+ import WebSocket from "ws";
4
+
5
+ const MAX_MESSAGE = 64 * 1024 * 1024;
6
+
7
+ // Desktop owns initialization and every server-initiated approval/tool response.
8
+ // The subscription adapter may consume its own thread/resume response; all other
9
+ // responses and every notification/request are forwarded to Desktop unchanged.
10
+ export async function relay(input, output, socket, { onDesktopMessage = () => {}, onServerMessage = () => {} } = {}) {
11
+ const decoder = new StringDecoder("utf8");
12
+ let pending = "";
13
+ let outputQueue = Promise.resolve();
14
+ let outputBytes = 0;
15
+ let fail;
16
+ let finished = false;
17
+ const failure = new Promise((_, reject) => { fail = reject; });
18
+ // Attach before asynchronous iteration starts; errors must also break blocked reads.
19
+ const onError = () => fail(new Error("Desktop relay connection failed."));
20
+ const onClose = () => { if (!finished) fail(new Error("Desktop relay connection closed.")); };
21
+ const onMessage = (buffer, binary) => {
22
+ try {
23
+ if (binary) throw new Error("Unexpected binary App Server frame.");
24
+ const text = buffer.toString();
25
+ const parsed = JSON.parse(text);
26
+ if (onServerMessage(parsed) === false) return;
27
+ // JSON.parse is only for routing. Preserve number literals (including
28
+ // integers above JS's safe range), escaping and unknown fields on the wire.
29
+ // Actual CR/LF in valid JSON can only be whitespace outside strings.
30
+ const line = text.replace(/[\r\n]/g, "") + "\n";
31
+ outputBytes += Buffer.byteLength(line);
32
+ if (outputBytes > MAX_MESSAGE * 2) throw new Error("Desktop relay output buffer limit exceeded.");
33
+ socket.pause();
34
+ outputQueue = outputQueue.then(async () => {
35
+ if (!output.write(line)) await once(output, "drain");
36
+ outputBytes -= Buffer.byteLength(line);
37
+ if (outputBytes === 0 && socket.readyState === WebSocket.OPEN) socket.resume();
38
+ });
39
+ outputQueue.catch(fail);
40
+ } catch { fail(new Error("Invalid or oversized App Server relay message.")); }
41
+ };
42
+ socket.on("message", onMessage);
43
+ socket.on("error", onError);
44
+ socket.on("close", onClose);
45
+ output.on("error", onError);
46
+ const send = async line => {
47
+ if (!line.trim()) return;
48
+ if (Buffer.byteLength(line) > MAX_MESSAGE) throw new Error("Desktop relay input limit exceeded.");
49
+ let parsed;
50
+ try { parsed = JSON.parse(line); } catch { throw new Error("Invalid Desktop protocol message."); }
51
+ onDesktopMessage(parsed);
52
+ await new Promise((resolve, reject) => socket.send(line, error => error ? reject(new Error("Desktop relay send failed.")) : resolve()));
53
+ };
54
+ const copyInput = async () => {
55
+ for await (const chunk of input) {
56
+ pending += decoder.write(chunk);
57
+ let end;
58
+ while ((end = pending.indexOf("\n")) !== -1) {
59
+ const line = pending.slice(0, end);
60
+ pending = pending.slice(end + 1);
61
+ await send(line);
62
+ }
63
+ if (Buffer.byteLength(pending) > MAX_MESSAGE) throw new Error("Desktop relay input limit exceeded.");
64
+ }
65
+ pending += decoder.end();
66
+ if (pending.trim()) await send(pending);
67
+ await outputQueue;
68
+ };
69
+ try { await Promise.race([copyInput(), failure]); } finally {
70
+ finished = true;
71
+ socket.off("message", onMessage);
72
+ socket.off("error", onError);
73
+ socket.off("close", onClose);
74
+ output.off("error", onError);
75
+ socket.terminate();
76
+ // Unblock a pending stdin read when the server dies.
77
+ input.destroy();
78
+ }
79
+ }
@@ -0,0 +1,128 @@
1
+ import { lstat, readdir, realpath, readlink } from "node:fs/promises";
2
+ import { watch } from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { fingerprintFile, collectEvidence, verifyEvidence } from "./evidence.mjs";
6
+ import { digest } from "./observe.mjs";
7
+ import { normalizeThreadId } from "./thread-id.mjs";
8
+ import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
9
+ import { runCommand } from "./runner.mjs";
10
+
11
+ const within = (file, root) => file === root || file.startsWith(root + path.sep);
12
+ function excluded(file, excludes) { return file.split(path.sep).includes(".git") || excludes.some(x => within(file, x)); }
13
+
14
+ export async function captureInputs(roots, excludes = [], { allowMissing = false } = {}) {
15
+ const records = new Map();
16
+ async function visit(file) {
17
+ if (excluded(file, excludes) || records.has(file)) return;
18
+ let info;
19
+ try { info = await lstat(file); } catch (e) { if (e.code === "ENOENT" && allowMissing) return; throw e; }
20
+ if (info.isSymbolicLink()) {
21
+ const target = await realpath(file), targetInfo = await lstat(target);
22
+ if (targetInfo.isDirectory()) throw new Error("Directory symlinks are not snapshot inputs. Specify the real directory explicitly.");
23
+ records.set(file, { ...await fingerprintFile(file), link: await readlink(file), mtime_ms: targetInfo.mtimeMs, ctime_ms: targetInfo.ctimeMs });
24
+ } else if (info.isDirectory()) {
25
+ records.set(file, { type: "directory", ref: file, mode: info.mode & 0o777 });
26
+ for (const name of (await readdir(file)).sort()) await visit(path.join(file, name));
27
+ } else if (info.isFile()) records.set(file, { ...await fingerprintFile(file), mtime_ms: info.mtimeMs, ctime_ms: info.ctimeMs });
28
+ else throw new Error("Snapshot inputs must be files or directories.");
29
+ }
30
+ for (const root of roots) await visit(root);
31
+ const files = [...records.values()].sort((a, b) => a.ref.localeCompare(b.ref));
32
+ return { roots, excludes, files, digest: digest(files) };
33
+ }
34
+
35
+ function differences(before, after) {
36
+ const a = new Map(before.files.map(f => [f.ref, digest(f)])), b = new Map(after.files.map(f => [f.ref, digest(f)]));
37
+ return { added: [...b.keys()].filter(k => !a.has(k)), removed: [...a.keys()].filter(k => !b.has(k)), changed: [...a.keys()].filter(k => b.has(k) && a.get(k) !== b.get(k)) };
38
+ }
39
+
40
+ export async function getCheckpoint(threadInput, id, storage = {}) {
41
+ const threadId = normalizeThreadId(threadInput), entry = await readRecord("checkpoints", id, storage);
42
+ if (!entry || entry.thread_id !== threadId || entry.id !== id) throw new Error("Checkpoint not found for this task.");
43
+ return entry;
44
+ }
45
+
46
+ export async function createCheckpoint(threadInput, name, { paths, excludes = [], cwd = process.cwd(), ...storage }) {
47
+ const threadId = normalizeThreadId(threadInput);
48
+ if (!name?.trim() || !paths?.length) throw new Error("capture requires a name and at least one --path.");
49
+ const roots = [...new Set(paths.map(p => path.resolve(cwd, p)))], omit = excludes.map(p => path.resolve(cwd, p));
50
+ const snapshot = await captureInputs(roots, omit);
51
+ if (!snapshot.files.some(f => f.type === "file")) throw new Error("No input files were selected.");
52
+ const entry = { schema: 1, id: randomUUID(), thread_id: threadId, name, cwd: path.resolve(cwd), created_at: new Date().toISOString(), snapshot, runs: [], artifacts: [] };
53
+ await writeRecord("checkpoints", entry.id, entry, storage);
54
+ return checkpointSummary(entry);
55
+ }
56
+
57
+ const withoutOutput = ({ output_tail, ...rest }) => rest;
58
+ export function checkpointSummary(entry, includeOutput = false) {
59
+ return { ...entry, runs: includeOutput ? entry.runs : entry.runs.map(withoutOutput), snapshot: { ...entry.snapshot, files: undefined, file_count: entry.snapshot.files.filter(f => f.type === "file").length, directory_count: entry.snapshot.files.filter(f => f.type === "directory").length } };
60
+ }
61
+
62
+ export async function verifyCheckpoint(threadInput, id, storage = {}) {
63
+ const entry = await getCheckpoint(threadInput, id, storage);
64
+ const current = await captureInputs(entry.snapshot.roots, entry.snapshot.excludes, { allowMissing: true });
65
+ const inputsCurrent = current.digest === entry.snapshot.digest;
66
+ const latest = entry.runs.at(-1) ?? null;
67
+ const artifacts = await Promise.all(entry.artifacts.map(async a => {
68
+ try { await verifyEvidence([a]); return { ...a, current: true }; } catch { return { ...a, current: false }; }
69
+ }));
70
+ return { ...checkpointSummary(entry), inputs_current: inputsCurrent, differences: differences(entry.snapshot, current), artifacts,
71
+ latest_run: latest ? withoutOutput(latest) : null, valid: inputsCurrent && latest?.status === "completed" && latest.exit_code === 0 && !latest.error && !latest.timed_out && latest.inputs_unchanged === true && artifacts.filter(a => a.run_id === latest.id).every(a => a.current) };
72
+ }
73
+
74
+ function watchInputs(snapshot) {
75
+ const touched = new Set(), watchers = []; let error = null;
76
+ const files = new Map(snapshot.files.map(f => [f.ref, f]));
77
+ const dirs = [...new Set(snapshot.roots.map(r => files.get(r)?.type === "directory" ? r : path.dirname(r)))];
78
+ try {
79
+ for (const dir of dirs) {
80
+ const watcher = watch(dir, { recursive: true }, (_, filename) => {
81
+ const file = filename ? path.resolve(dir, filename.toString()) : dir;
82
+ if (!excluded(file, snapshot.excludes) && (!filename || snapshot.roots.some(r => within(file, r)))) { if (touched.size < 100) touched.add(file); }
83
+ });
84
+ watcher.on("error", e => { error = e.message; }); watchers.push(watcher);
85
+ }
86
+ } catch (e) { for (const w of watchers) w.close(); throw e; }
87
+ return { touched, get error() { return error; }, close() { for (const w of watchers) w.close(); } };
88
+ }
89
+
90
+ export async function runCheckpoint(threadInput, id, argv, { timeoutMs = 0, ...storage } = {}) {
91
+ if (!argv.length || !argv[0] || !Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 86400000) throw new Error("A command and timeout-ms between 0 and 86400000 are required.");
92
+ const threadId = normalizeThreadId(threadInput);
93
+ return withStoreLock(`checkpoint-${id}`, async () => {
94
+ const entry = await getCheckpoint(threadId, id, storage), watcher = watchInputs(entry.snapshot);
95
+ try {
96
+ const before = await captureInputs(entry.snapshot.roots, entry.snapshot.excludes, { allowMissing: true });
97
+ if (before.digest !== entry.snapshot.digest) throw new Error("Inputs changed since capture. Capture a new checkpoint before running.");
98
+ const run = { id: randomUUID(), argv, status: "running", started_at: new Date().toISOString() };
99
+ entry.runs.push(run); await writeRecord("checkpoints", id, entry, storage);
100
+ const result = await runCommand(argv, { cwd: entry.cwd, timeoutMs, onStart: async pid => { run.pid = pid; await writeRecord("checkpoints", id, entry, storage); } });
101
+ let after, snapshotError;
102
+ try { after = await captureInputs(entry.snapshot.roots, entry.snapshot.excludes, { allowMissing: true }); } catch (e) { snapshotError = e.message; }
103
+ Object.assign(run, result, { status: "completed", inputs_unchanged: after?.digest === entry.snapshot.digest && !watcher.touched.size && !watcher.error,
104
+ touched_paths: [...watcher.touched], observation_error: watcher.error ?? snapshotError ?? null, differences: after ? differences(entry.snapshot, after) : null });
105
+ await writeRecord("checkpoints", id, entry, storage);
106
+ return { checkpoint_id: id, ...run, valid: run.exit_code === 0 && !run.error && !run.timed_out && run.inputs_unchanged };
107
+ } finally { watcher.close(); }
108
+ }, storage);
109
+ }
110
+
111
+ export async function attachArtifacts(threadInput, id, refs, storage = {}) {
112
+ if (!refs.length) throw new Error("At least one --artifact file is required.");
113
+ return withStoreLock(`checkpoint-${id}`, async () => {
114
+ const verified = await verifyCheckpoint(threadInput, id, storage);
115
+ if (!verified.valid) throw new Error("A successful run against unchanged inputs is required before attaching artifacts.");
116
+ const artifacts = await collectEvidence(refs);
117
+ if (artifacts.some(a => a.type !== "file")) throw new Error("Artifacts must be local files.");
118
+ const entry = await getCheckpoint(threadInput, id, storage);
119
+ entry.artifacts.push(...artifacts.map(a => ({ ...a, run_id: entry.runs.at(-1).id, attached_at: new Date().toISOString() })));
120
+ await writeRecord("checkpoints", id, entry, storage);
121
+ return checkpointSummary(entry);
122
+ }, storage);
123
+ }
124
+
125
+ export async function listCheckpoints(threadInput, storage = {}) {
126
+ const threadId = normalizeThreadId(threadInput);
127
+ return (await listRecords("checkpoints", storage)).filter(c => c?.thread_id === threadId).sort((a, b) => a.created_at.localeCompare(b.created_at)).map(entry => checkpointSummary(entry));
128
+ }