workoffdesk 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
|
|
4
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
5
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
6
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
7
|
+
const SUMMARY_MAX = 200;
|
|
8
|
+
const RESULT_MAX = 2000;
|
|
9
|
+
const DIFF_LINES_MAX = 300;
|
|
10
|
+
const DIFF_LINE_MAX = 400;
|
|
11
|
+
const DETAIL_MAX = 4000;
|
|
12
|
+
const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}…` : s);
|
|
13
|
+
const line1 = (s) => clip(s.split('\n')[0], SUMMARY_MAX);
|
|
14
|
+
// What the user typed, as the chat shows it; a picture sent along is named by its path in the text already.
|
|
15
|
+
const userText = (content) => arr(content)
|
|
16
|
+
.map((c) => (obj(c)?.type === 'text' ? str(obj(c)?.text) : ''))
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.join('\n');
|
|
19
|
+
// The command as Codex parsed it rather than the shell line that wraps it (`/bin/zsh -c '…'`).
|
|
20
|
+
const commandOf = (i) => str(obj(arr(i.commandActions)[0])?.command) || str(i.command);
|
|
21
|
+
function unifiedDiff(file, kind, text) {
|
|
22
|
+
const lines = text.replace(/\n$/, '').split('\n');
|
|
23
|
+
const hunks = [];
|
|
24
|
+
let added = 0;
|
|
25
|
+
let removed = 0;
|
|
26
|
+
let kept = 0;
|
|
27
|
+
let cut = false;
|
|
28
|
+
const push = (h, l) => {
|
|
29
|
+
if (l.startsWith('+'))
|
|
30
|
+
added++;
|
|
31
|
+
else if (l.startsWith('-'))
|
|
32
|
+
removed++;
|
|
33
|
+
if (kept >= DIFF_LINES_MAX)
|
|
34
|
+
return void (cut = true);
|
|
35
|
+
h.lines.push(clip(l, DIFF_LINE_MAX));
|
|
36
|
+
kept++;
|
|
37
|
+
};
|
|
38
|
+
if (!lines.some((l) => l.startsWith('@@'))) {
|
|
39
|
+
// A file made or removed whole comes as its content.
|
|
40
|
+
const sign = kind === 'delete' ? '-' : '+';
|
|
41
|
+
const h = { oldStart: kind === 'delete' ? 1 : 0, newStart: kind === 'delete' ? 0 : 1, lines: [] };
|
|
42
|
+
for (const l of text ? lines : [])
|
|
43
|
+
push(h, sign + l);
|
|
44
|
+
if (h.lines.length)
|
|
45
|
+
hunks.push(h);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
let h = null;
|
|
49
|
+
for (const l of lines) {
|
|
50
|
+
const at = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
|
|
51
|
+
if (at) {
|
|
52
|
+
h = { oldStart: Number(at[1]), newStart: Number(at[2]), lines: [] };
|
|
53
|
+
hunks.push(h);
|
|
54
|
+
}
|
|
55
|
+
else if (h && /^[ +-]/.test(l))
|
|
56
|
+
push(h, l);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { file, added, removed, ...(kind === 'add' && { created: true }), ...(cut ? { cut: true } : {}), hunks };
|
|
60
|
+
}
|
|
61
|
+
const failedOrStopped = (status) => ({ error: status === 'failed', ...(status === 'declined' && { stopped: true }) });
|
|
62
|
+
// What one completed item adds to the chat; nothing for what the chat does not show (reasoning).
|
|
63
|
+
export function entriesOf(item, at) {
|
|
64
|
+
const i = obj(item);
|
|
65
|
+
const id = str(i?.id);
|
|
66
|
+
if (!i || !id)
|
|
67
|
+
return [];
|
|
68
|
+
const stamp = at ? { at } : {};
|
|
69
|
+
switch (i.type) {
|
|
70
|
+
case 'userMessage': {
|
|
71
|
+
const text = userText(i.content);
|
|
72
|
+
const clientId = str(i.clientId);
|
|
73
|
+
return text ? [{ id, item: { k: 'user', text, ...stamp }, ...(clientId && { clientId }) }] : [];
|
|
74
|
+
}
|
|
75
|
+
case 'agentMessage':
|
|
76
|
+
return str(i.text) ? [{ id, item: { k: 'text', text: str(i.text), ...stamp } }] : [];
|
|
77
|
+
case 'plan':
|
|
78
|
+
return str(i.text) ? [{ id, item: { k: 'text', text: str(i.text), ...stamp } }] : [];
|
|
79
|
+
case 'commandExecution': {
|
|
80
|
+
const status = str(i.status);
|
|
81
|
+
const exit = num(i.exitCode);
|
|
82
|
+
return [
|
|
83
|
+
{ id, item: { k: 'tool', id, name: 'Bash', summary: line1(commandOf(i)), ...stamp } },
|
|
84
|
+
{ id: `${id}#r`, item: { k: 'result', id, text: clip(str(i.aggregatedOutput), RESULT_MAX), ...failedOrStopped(status), ...(exit !== undefined && exit !== 0 && { error: true }) } },
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
case 'fileChange': {
|
|
88
|
+
const status = str(i.status);
|
|
89
|
+
return arr(i.changes).flatMap((c, n) => {
|
|
90
|
+
const change = obj(c);
|
|
91
|
+
const path = str(change?.path);
|
|
92
|
+
if (!path)
|
|
93
|
+
return [];
|
|
94
|
+
const kind = str(obj(change?.kind)?.type);
|
|
95
|
+
const cid = `${id}:${n}`;
|
|
96
|
+
return [
|
|
97
|
+
{ id: cid, item: { k: 'tool', id: cid, name: kind === 'add' ? 'Write' : 'Edit', summary: line1(path), ...stamp } },
|
|
98
|
+
{ id: `${cid}#r`, item: { k: 'result', id: cid, text: '', ...failedOrStopped(status), diff: unifiedDiff(path, kind, str(change?.diff)) } },
|
|
99
|
+
];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
case 'mcpToolCall':
|
|
103
|
+
case 'dynamicToolCall': {
|
|
104
|
+
const name = [str(i.server) || str(i.namespace), str(i.tool)].filter(Boolean).join('.');
|
|
105
|
+
const args = obj(i.arguments) ?? {};
|
|
106
|
+
const summary = line1(Object.values(args).map(str).find(Boolean) ?? '');
|
|
107
|
+
const error = str(obj(i.error)?.message);
|
|
108
|
+
const text = error || arr(obj(i.result)?.content ?? i.contentItems).map((c) => str(obj(c)?.text)).filter(Boolean).join('\n');
|
|
109
|
+
return [
|
|
110
|
+
{ id, item: { k: 'tool', id, name: name || 'tool', summary, ...stamp } },
|
|
111
|
+
{ id: `${id}#r`, item: { k: 'result', id, text: clip(text, RESULT_MAX), error: !!error || str(i.status) === 'failed' || i.success === false } },
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
case 'webSearch':
|
|
115
|
+
return [{ id, item: { k: 'tool', id, name: 'WebSearch', summary: line1(str(i.query)), ...stamp } }];
|
|
116
|
+
case 'imageView':
|
|
117
|
+
return [{ id, item: { k: 'tool', id, name: 'Read', summary: line1(str(i.path)), ...stamp } }];
|
|
118
|
+
case 'contextCompaction':
|
|
119
|
+
return [{ id, item: { k: 'note', event: { e: 'compacted' } } }];
|
|
120
|
+
default:
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// A turn that did not end by itself, as the line the chat puts after it.
|
|
125
|
+
export function turnNote(turn) {
|
|
126
|
+
const t = obj(turn);
|
|
127
|
+
const id = str(t?.id);
|
|
128
|
+
if (!t || !id)
|
|
129
|
+
return null;
|
|
130
|
+
if (t.status === 'interrupted')
|
|
131
|
+
return { id: `${id}#end`, item: { k: 'note', event: { e: 'interrupted' } } };
|
|
132
|
+
if (t.status === 'failed') {
|
|
133
|
+
const detail = str(obj(t.error)?.message);
|
|
134
|
+
return { id: `${id}#end`, item: { k: 'note', event: { e: 'failed' }, ...(detail && { detail }) } };
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
export const COMMAND_APPROVAL = 'item/commandExecution/requestApproval';
|
|
139
|
+
export const FILE_APPROVAL = 'item/fileChange/requestApproval';
|
|
140
|
+
export const isApproval = (method) => method === COMMAND_APPROVAL || method === FILE_APPROVAL;
|
|
141
|
+
// The decision that allows from now on what the request offers to, if it offers that.
|
|
142
|
+
function amendmentOf(params) {
|
|
143
|
+
for (const d of arr(params.availableDecisions)) {
|
|
144
|
+
const a = obj(obj(d)?.acceptWithExecpolicyAmendment);
|
|
145
|
+
const prefix = arr(a?.execpolicy_amendment).map(str);
|
|
146
|
+
if (prefix.length && prefix.every(Boolean))
|
|
147
|
+
return { acceptWithExecpolicyAmendment: { execpolicy_amendment: prefix } };
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const offered = (params, decision) => {
|
|
152
|
+
const all = arr(params.availableDecisions);
|
|
153
|
+
return !all.length || all.includes(decision);
|
|
154
|
+
};
|
|
155
|
+
// An approval Codex asks for, as the phone's permission card. fileItem: the file change it is for, as it
|
|
156
|
+
// started, which names the files the request itself does not.
|
|
157
|
+
export function askOf(askId, method, params, fileItem) {
|
|
158
|
+
const reason = str(params.reason);
|
|
159
|
+
if (method === COMMAND_APPROVAL) {
|
|
160
|
+
const command = commandOf(params);
|
|
161
|
+
const amendment = amendmentOf(params);
|
|
162
|
+
const detail = [command, reason, str(params.cwd)].filter(Boolean).join('\n\n');
|
|
163
|
+
return {
|
|
164
|
+
id: askId,
|
|
165
|
+
kind: 'permission',
|
|
166
|
+
tool: 'Bash',
|
|
167
|
+
title: line1(command || reason),
|
|
168
|
+
detail: clip(detail, DETAIL_MAX),
|
|
169
|
+
always: amendment ? { rules: [amendment.acceptWithExecpolicyAmendment.execpolicy_amendment.join(' ')], dirs: [], mode: null } : null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const changes = arr(fileItem?.changes).map(obj);
|
|
173
|
+
const paths = changes.map((c) => str(c?.path)).filter(Boolean);
|
|
174
|
+
const diffs = changes.map((c) => str(c?.diff)).filter(Boolean).join('\n');
|
|
175
|
+
return {
|
|
176
|
+
id: askId,
|
|
177
|
+
kind: 'permission',
|
|
178
|
+
tool: 'Edit',
|
|
179
|
+
title: line1(paths.join(', ') || str(params.grantRoot) || reason),
|
|
180
|
+
detail: clip([reason, diffs].filter(Boolean).join('\n\n'), DETAIL_MAX),
|
|
181
|
+
always: offered(params, 'acceptForSession') ? { rules: [], dirs: [], mode: 'acceptEdits' } : null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// The reply to an approval. No is the Mac's No: the turn stops.
|
|
185
|
+
export function decisionOf(method, params, answer) {
|
|
186
|
+
if (!answer.allow)
|
|
187
|
+
return { decision: offered(params, 'cancel') ? 'cancel' : 'decline' };
|
|
188
|
+
if (!answer.always)
|
|
189
|
+
return { decision: 'accept' };
|
|
190
|
+
if (method === COMMAND_APPROVAL)
|
|
191
|
+
return { decision: amendmentOf(params) ?? 'acceptForSession' };
|
|
192
|
+
return { decision: 'acceptForSession' };
|
|
193
|
+
}
|
|
194
|
+
// What the usage pill shows, from the thread's last token count and the plan's limits.
|
|
195
|
+
export function usageOf(o) {
|
|
196
|
+
const u = {};
|
|
197
|
+
if (o.model)
|
|
198
|
+
u.model = o.model.slice(0, 80);
|
|
199
|
+
if (o.effort)
|
|
200
|
+
u.effort = o.effort.slice(0, 80);
|
|
201
|
+
const tokens = obj(o.tokenUsage);
|
|
202
|
+
const window = num(tokens?.modelContextWindow);
|
|
203
|
+
const last = num(obj(tokens?.last)?.totalTokens);
|
|
204
|
+
if (window && last !== undefined)
|
|
205
|
+
u.context = Math.min(100, Math.round((last / window) * 100));
|
|
206
|
+
const limits = obj(o.rateLimits);
|
|
207
|
+
const limit = (w) => {
|
|
208
|
+
const used = num(obj(w)?.usedPercent);
|
|
209
|
+
const resetsAt = num(obj(w)?.resetsAt);
|
|
210
|
+
return used === undefined ? undefined : { used: Math.min(100, Math.max(0, used)), ...(resetsAt !== undefined && { resetsAt }) };
|
|
211
|
+
};
|
|
212
|
+
const fiveHour = limit(limits?.primary);
|
|
213
|
+
const sevenDay = limit(limits?.secondary);
|
|
214
|
+
if (fiveHour)
|
|
215
|
+
u.fiveHour = fiveHour;
|
|
216
|
+
if (sevenDay)
|
|
217
|
+
u.sevenDay = sevenDay;
|
|
218
|
+
return Object.keys(u).length ? u : null;
|
|
219
|
+
}
|
|
220
|
+
// A picture sent from the phone travels as its path in the text (libs/client/src/chat/images.ts); Codex is also
|
|
221
|
+
// given it as an image.
|
|
222
|
+
const UPLOADED_PICTURE = /~?\/[^\s"]*\/uploads\/\d{4}-\d{2}-\d{2}-\d{6}-[0-9a-f]{6}\.(?:png|jpg|jpeg|gif|webp)/g;
|
|
223
|
+
export function inputOf(text) {
|
|
224
|
+
const pictures = [...new Set(text.match(UPLOADED_PICTURE) ?? [])].map((p) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p));
|
|
225
|
+
return [{ type: 'text', text, text_elements: [] }, ...pictures.map((path) => ({ type: 'localImage', path }))];
|
|
226
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { askOf, entriesOf, isApproval, turnNote, usageOf } from './items.js';
|
|
3
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
|
|
4
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
5
|
+
// How often the reply being written is put in the journal while deltas come.
|
|
6
|
+
const LIVE_MS = 200;
|
|
7
|
+
const realDir = (p) => {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(p);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return p;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
// What the app server tells of the threads the agent follows, and what it asks: turned into each session's
|
|
16
|
+
// status, chat, asks and usage.
|
|
17
|
+
export class CodexNotifications {
|
|
18
|
+
host;
|
|
19
|
+
threads;
|
|
20
|
+
agent;
|
|
21
|
+
rateLimits = null;
|
|
22
|
+
constructor(host, threads, agent) {
|
|
23
|
+
this.host = host;
|
|
24
|
+
this.threads = threads;
|
|
25
|
+
this.agent = agent;
|
|
26
|
+
}
|
|
27
|
+
notified({ method, params }) {
|
|
28
|
+
if (method === 'account/rateLimits/updated') {
|
|
29
|
+
this.rateLimits = params.rateLimits;
|
|
30
|
+
for (const t of this.threads.values())
|
|
31
|
+
this.usage(t);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (method === 'thread/started')
|
|
35
|
+
return this.started(obj(params.thread));
|
|
36
|
+
const t = this.threads.get(str(params.threadId));
|
|
37
|
+
if (!t)
|
|
38
|
+
return;
|
|
39
|
+
switch (method) {
|
|
40
|
+
case 'thread/status/changed':
|
|
41
|
+
this.statusIs(t, obj(params.status));
|
|
42
|
+
if (!t.joined)
|
|
43
|
+
void this.agent.join(t);
|
|
44
|
+
return;
|
|
45
|
+
case 'thread/closed':
|
|
46
|
+
t.joined = false;
|
|
47
|
+
return;
|
|
48
|
+
case 'thread/name/updated':
|
|
49
|
+
this.nameIs(t, params.threadName);
|
|
50
|
+
return;
|
|
51
|
+
case 'turn/completed': {
|
|
52
|
+
const note = turnNote(params.turn);
|
|
53
|
+
if (note)
|
|
54
|
+
this.put(t, [note]);
|
|
55
|
+
if (t.asks.size) {
|
|
56
|
+
t.asks.clear();
|
|
57
|
+
this.host.asksChanged(t.session);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
case 'item/started': {
|
|
62
|
+
const item = obj(params.item);
|
|
63
|
+
if (item?.type === 'fileChange')
|
|
64
|
+
t.files.set(str(item.id), item);
|
|
65
|
+
if (item?.type === 'contextCompaction')
|
|
66
|
+
this.compacting(t, true);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
case 'item/completed': {
|
|
70
|
+
const item = obj(params.item);
|
|
71
|
+
const at = typeof params.completedAtMs === 'number' ? new Date(params.completedAtMs).toISOString() : undefined;
|
|
72
|
+
this.put(t, entriesOf(item, at));
|
|
73
|
+
if (item?.type === 'agentMessage') {
|
|
74
|
+
t.said = str(item.text) || t.said;
|
|
75
|
+
this.liveDone(t, str(item.id));
|
|
76
|
+
}
|
|
77
|
+
if (item?.type === 'fileChange')
|
|
78
|
+
t.files.delete(str(item.id));
|
|
79
|
+
if (item?.type === 'contextCompaction')
|
|
80
|
+
this.compacting(t, false);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
case 'item/agentMessage/delta':
|
|
84
|
+
t.live.set(str(params.itemId), (t.live.get(str(params.itemId)) ?? '') + str(params.delta));
|
|
85
|
+
t.liveTimer ??= setTimeout(() => this.liveNow(t), LIVE_MS);
|
|
86
|
+
return;
|
|
87
|
+
case 'thread/tokenUsage/updated':
|
|
88
|
+
t.tokenUsage = params.tokenUsage;
|
|
89
|
+
this.usage(t);
|
|
90
|
+
return;
|
|
91
|
+
case 'thread/settings/updated': {
|
|
92
|
+
const s = obj(params.threadSettings);
|
|
93
|
+
t.model = str(s?.model) || t.model;
|
|
94
|
+
t.effort = str(s?.effort) || t.effort;
|
|
95
|
+
this.usage(t);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
case 'serverRequest/resolved': {
|
|
99
|
+
const gone = [...t.asks.entries()].find(([, a]) => a.requestId === params.requestId);
|
|
100
|
+
if (!gone)
|
|
101
|
+
return;
|
|
102
|
+
t.asks.delete(gone[0]);
|
|
103
|
+
this.host.asksChanged(t.session);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// An approval is held for the phone; the TUI has it too and either may answer. Anything else Codex asks is
|
|
109
|
+
// left to the TUI.
|
|
110
|
+
requested({ id, method, params }) {
|
|
111
|
+
const t = this.threads.get(str(params.threadId));
|
|
112
|
+
if (!t || !isApproval(method))
|
|
113
|
+
return;
|
|
114
|
+
const askId = `codex-${String(id)}`;
|
|
115
|
+
const ask = askOf(askId, method, params, t.files.get(str(params.itemId)));
|
|
116
|
+
t.asks.set(askId, { requestId: id, method, params, ask });
|
|
117
|
+
this.host.asksChanged(t.session);
|
|
118
|
+
if (t.status === 'waiting')
|
|
119
|
+
this.host.moved(t.session, 'waiting', ask.title);
|
|
120
|
+
}
|
|
121
|
+
started(thread) {
|
|
122
|
+
const threadId = str(thread?.id);
|
|
123
|
+
if (!thread || !threadId || thread.ephemeral === true || thread.parentThreadId || this.threads.has(threadId))
|
|
124
|
+
return;
|
|
125
|
+
const sessions = this.host.sessions();
|
|
126
|
+
if (sessions.some((s) => s.threadId === threadId))
|
|
127
|
+
return;
|
|
128
|
+
// Only the sessions' TUIs run on this app server: a thread started in a folder is the oldest session there
|
|
129
|
+
// with none yet. Its TUI may have asked something first (trust this folder?) for any time.
|
|
130
|
+
const cwd = realDir(str(thread.cwd));
|
|
131
|
+
const [owner] = sessions
|
|
132
|
+
.filter((s) => !s.threadId && !this.agent.following(s.id) && realDir(s.cwd) === cwd)
|
|
133
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
134
|
+
if (!owner)
|
|
135
|
+
return;
|
|
136
|
+
this.host.setThread(owner.id, threadId);
|
|
137
|
+
const t = this.agent.track(owner.id, threadId);
|
|
138
|
+
// Joined only once its first turn writes it; what it runs on is known now.
|
|
139
|
+
this.ranOn(t, thread);
|
|
140
|
+
this.host.moved(owner.id, t.status, '');
|
|
141
|
+
}
|
|
142
|
+
nameIs(t, name) {
|
|
143
|
+
if (typeof name === 'string' && name.trim())
|
|
144
|
+
this.host.named(t.session, name);
|
|
145
|
+
}
|
|
146
|
+
statusIs(t, status) {
|
|
147
|
+
const type = str(status?.type);
|
|
148
|
+
const flags = Array.isArray(status?.activeFlags) ? status.activeFlags : [];
|
|
149
|
+
if (type === 'active')
|
|
150
|
+
t.status = flags.length ? 'waiting' : 'working';
|
|
151
|
+
else if (type === 'idle' || type === 'systemError')
|
|
152
|
+
t.status = 'idle';
|
|
153
|
+
else
|
|
154
|
+
return;
|
|
155
|
+
this.show(t);
|
|
156
|
+
}
|
|
157
|
+
compacting(t, on) {
|
|
158
|
+
t.compacting = on;
|
|
159
|
+
this.show(t);
|
|
160
|
+
}
|
|
161
|
+
show(t) {
|
|
162
|
+
const status = t.compacting ? 'compacting' : t.status;
|
|
163
|
+
const said = status === 'waiting' ? ([...t.asks.values()].at(-1)?.ask.title ?? '') : t.said;
|
|
164
|
+
this.host.moved(t.session, status, said);
|
|
165
|
+
}
|
|
166
|
+
// The model and effort a thread runs on, as the thread itself says.
|
|
167
|
+
ranOn(t, thread) {
|
|
168
|
+
t.model = str(thread?.model) || t.model;
|
|
169
|
+
t.effort = str(thread?.reasoningEffort) || t.effort;
|
|
170
|
+
this.usage(t);
|
|
171
|
+
}
|
|
172
|
+
usage(t) {
|
|
173
|
+
this.host.setUsage(t.session, usageOf({ tokenUsage: t.tokenUsage, rateLimits: this.rateLimits, model: t.model, effort: t.effort }));
|
|
174
|
+
}
|
|
175
|
+
put(t, entries) {
|
|
176
|
+
if (entries.length)
|
|
177
|
+
this.host.journal.putAgentItems(t.session, t.threadId, entries);
|
|
178
|
+
}
|
|
179
|
+
liveNow(t) {
|
|
180
|
+
t.liveTimer = null;
|
|
181
|
+
if (this.threads.get(t.threadId) !== t)
|
|
182
|
+
return;
|
|
183
|
+
this.host.journal.live(t.session, [...t.live.values()].at(-1) || null);
|
|
184
|
+
}
|
|
185
|
+
liveDone(t, itemId) {
|
|
186
|
+
t.live.delete(itemId);
|
|
187
|
+
if (t.liveTimer)
|
|
188
|
+
clearTimeout(t.liveTimer);
|
|
189
|
+
t.liveTimer = null;
|
|
190
|
+
this.host.journal.live(t.session, [...t.live.values()].at(-1) || null);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { VERSION } from '../../system/version.js';
|
|
4
|
+
export class RpcError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
// Codes of this client's own: the connection closed, or no answer came in time.
|
|
7
|
+
static CLOSED = -1;
|
|
8
|
+
static TIMEOUT = -2;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const REQUEST_MS = 15_000;
|
|
15
|
+
const CONNECT_MS = 5_000;
|
|
16
|
+
// A client of Codex's app server: JSON-RPC 2.0 messages, one per WebSocket frame, over its unix socket.
|
|
17
|
+
export class CodexRpc {
|
|
18
|
+
ws;
|
|
19
|
+
seq = 0;
|
|
20
|
+
pending = new Map();
|
|
21
|
+
notified = new Set();
|
|
22
|
+
requested = new Set();
|
|
23
|
+
closed = new Set();
|
|
24
|
+
constructor(ws) {
|
|
25
|
+
this.ws = ws;
|
|
26
|
+
ws.on('message', (data) => this.receive(String(data)));
|
|
27
|
+
ws.on('close', () => {
|
|
28
|
+
for (const p of this.pending.values()) {
|
|
29
|
+
clearTimeout(p.timer);
|
|
30
|
+
p.reject(new RpcError(RpcError.CLOSED, 'codex app server closed'));
|
|
31
|
+
}
|
|
32
|
+
this.pending.clear();
|
|
33
|
+
for (const cb of this.closed)
|
|
34
|
+
cb();
|
|
35
|
+
});
|
|
36
|
+
ws.on('error', () => { });
|
|
37
|
+
}
|
|
38
|
+
// Connected and introduced; throws when nothing listens on the socket.
|
|
39
|
+
static async connect(sock) {
|
|
40
|
+
// Codex keeps a socket whose path is too long behind a symlink, which a unix socket address cannot follow.
|
|
41
|
+
const path = realpathSync(sock);
|
|
42
|
+
const ws = new WebSocket(`ws+unix://${path}:/`, { handshakeTimeout: CONNECT_MS });
|
|
43
|
+
await new Promise((resolve, reject) => {
|
|
44
|
+
ws.once('open', () => resolve());
|
|
45
|
+
ws.once('error', reject);
|
|
46
|
+
});
|
|
47
|
+
const rpc = new CodexRpc(ws);
|
|
48
|
+
await rpc.request('initialize', { clientInfo: { name: 'workoffdesk', title: 'WorkOffDesk', version: VERSION }, capabilities: { experimentalApi: true } });
|
|
49
|
+
rpc.send({ method: 'initialized' });
|
|
50
|
+
return rpc;
|
|
51
|
+
}
|
|
52
|
+
get open() {
|
|
53
|
+
return this.ws.readyState === WebSocket.OPEN;
|
|
54
|
+
}
|
|
55
|
+
request(method, params, timeoutMs = REQUEST_MS) {
|
|
56
|
+
const id = `wod-${++this.seq}`;
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
this.pending.delete(id);
|
|
60
|
+
reject(new RpcError(RpcError.TIMEOUT, `${method} did not answer in time`));
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
this.pending.set(id, { resolve: resolve, reject, timer });
|
|
63
|
+
if (!this.send({ id, method, params })) {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
this.pending.delete(id);
|
|
66
|
+
reject(new RpcError(RpcError.CLOSED, 'codex app server closed'));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
respond(id, result) {
|
|
71
|
+
return this.send({ id, result });
|
|
72
|
+
}
|
|
73
|
+
onNotification(cb) {
|
|
74
|
+
this.notified.add(cb);
|
|
75
|
+
}
|
|
76
|
+
onRequest(cb) {
|
|
77
|
+
this.requested.add(cb);
|
|
78
|
+
}
|
|
79
|
+
onClose(cb) {
|
|
80
|
+
this.closed.add(cb);
|
|
81
|
+
}
|
|
82
|
+
close() {
|
|
83
|
+
this.ws.close();
|
|
84
|
+
}
|
|
85
|
+
send(msg) {
|
|
86
|
+
if (!this.open)
|
|
87
|
+
return false;
|
|
88
|
+
this.ws.send(JSON.stringify(msg));
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
receive(text) {
|
|
92
|
+
let msg;
|
|
93
|
+
try {
|
|
94
|
+
msg = JSON.parse(text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const params = (msg.params && typeof msg.params === 'object' ? msg.params : {});
|
|
100
|
+
if (typeof msg.method === 'string') {
|
|
101
|
+
if (msg.id === undefined)
|
|
102
|
+
for (const cb of this.notified)
|
|
103
|
+
cb({ method: msg.method, params });
|
|
104
|
+
else
|
|
105
|
+
for (const cb of this.requested)
|
|
106
|
+
cb({ id: msg.id, method: msg.method, params });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (msg.id === undefined)
|
|
110
|
+
return;
|
|
111
|
+
const p = this.pending.get(msg.id);
|
|
112
|
+
if (!p)
|
|
113
|
+
return;
|
|
114
|
+
this.pending.delete(msg.id);
|
|
115
|
+
clearTimeout(p.timer);
|
|
116
|
+
if (msg.error)
|
|
117
|
+
p.reject(new RpcError(Number(msg.error.code ?? 0), String(msg.error.message ?? 'error')));
|
|
118
|
+
else
|
|
119
|
+
p.resolve(msg.result ?? {});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { parseChoices } from '../claude/screen/choices.js';
|
|
2
|
+
// What Codex's TUI asks before its thread starts (trust this folder?), read off the screen as Claude Code's
|
|
3
|
+
// choices are: the same numbered list, its current option marked › rather than ❯. Only before: once a thread
|
|
4
|
+
// runs, the messages sent are drawn after a › too, and what Codex asks comes from its app server.
|
|
5
|
+
export const codexChoices = (screen) => parseChoices(screen.replace(/›/g, '❯'));
|