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.
- package/LICENSE +21 -0
- package/README.md +519 -0
- package/assets/send-pururu.wav +0 -0
- package/bin/codexteer-wrapper.mjs +10 -0
- package/bin/codexteer.mjs +5 -0
- package/docs/distribution.md +186 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +22 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/node_modules/ws/lib/receiver.js +743 -0
- package/node_modules/ws/lib/sender.js +607 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +562 -0
- package/node_modules/ws/lib/websocket.js +1407 -0
- package/node_modules/ws/package.json +74 -0
- package/node_modules/ws/wrapper.mjs +21 -0
- package/package.json +41 -0
- package/scripts/accessibility.applescript +43 -0
- package/scripts/inspect.applescript +72 -0
- package/scripts/send.applescript +45 -0
- package/src/app-server.mjs +117 -0
- package/src/bridge.mjs +79 -0
- package/src/checkpoint.mjs +128 -0
- package/src/cli.mjs +358 -0
- package/src/compatibility.mjs +115 -0
- package/src/desktop.mjs +207 -0
- package/src/directive.mjs +39 -0
- package/src/distribution.mjs +192 -0
- package/src/evidence.mjs +33 -0
- package/src/help.mjs +145 -0
- package/src/journal.mjs +120 -0
- package/src/launcher.mjs +145 -0
- package/src/monitor.mjs +123 -0
- package/src/observe.mjs +185 -0
- package/src/paged-read.mjs +228 -0
- package/src/prompt.mjs +51 -0
- package/src/resource.mjs +70 -0
- package/src/rpc.mjs +162 -0
- package/src/runner.mjs +32 -0
- package/src/runtime.mjs +130 -0
- package/src/settings.mjs +49 -0
- package/src/sound.mjs +16 -0
- package/src/store.mjs +78 -0
- package/src/subscription.mjs +118 -0
- package/src/supervise.mjs +57 -0
- package/src/thread-id.mjs +35 -0
- package/src/thread-store.mjs +72 -0
- package/src/version.mjs +19 -0
- 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
|
+
}
|