workoffdesk 1.0.1 → 1.0.2
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/README.md +5 -3
- package/dist/build.json +1 -1
- package/dist/server/agents/claude/screen/background.js +7 -0
- package/dist/server/agents/claude/screen/prompt.js +34 -0
- package/dist/server/agents/claude/screen/screen.js +42 -3
- package/dist/server/agents/claude/terminals.js +67 -12
- package/dist/server/agents/claude/transcript/chat.js +98 -135
- package/dist/server/agents/claude/transcript/events.js +158 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +50 -10
- package/dist/server/agents/claude/transcript/transcripts.js +118 -30
- package/dist/server/agents/claude/transcript/turn.js +40 -61
- package/dist/server/agents/codex/agent.js +9 -2
- package/dist/server/agents/codex/events.js +107 -0
- package/dist/server/agents/codex/items.js +54 -74
- package/dist/server/agents/codex/notifications.js +14 -13
- package/dist/server/agents/codex/threads.js +54 -40
- package/dist/server/agents/events.js +4 -0
- package/dist/server/agents/turnSays.js +58 -0
- package/dist/server/cli.js +2 -0
- package/dist/server/commands/activity.js +28 -0
- package/dist/server/http/app.js +8 -4
- package/dist/server/http/auth/guard.js +8 -4
- package/dist/server/http/auth/passkey.js +6 -3
- package/dist/server/http/routes/appUpdate.js +1 -1
- package/dist/server/http/routes/chat.js +10 -0
- package/dist/server/http/routes/claude.js +70 -16
- package/dist/server/http/routes/codex.js +38 -3
- package/dist/server/http/routes/journal.js +3 -1
- package/dist/server/http/routes/push.js +36 -5
- package/dist/server/http/routes/pushRelay.js +62 -0
- package/dist/server/http/routes/sessions.js +32 -1
- package/dist/server/http/serve.js +8 -2
- package/dist/server/journal/db.js +4 -0
- package/dist/server/journal/index.js +4 -0
- package/dist/server/journal/ingest.js +26 -13
- package/dist/server/notify/apns.js +12 -7
- package/dist/server/notify/push.js +30 -4
- package/dist/server/notify/seal.js +12 -0
- package/dist/server/ops/doctor.js +7 -6
- package/dist/server/ops/problems.js +12 -7
- package/dist/server/sessions/control.js +65 -0
- package/dist/server/sessions/events.js +7 -5
- package/dist/server/sessions/machine/input.js +21 -2
- package/dist/server/sessions/machine/lifecycle.js +151 -81
- package/dist/server/sessions/machine/moves.js +65 -13
- package/dist/server/sessions/machine/naming.js +85 -0
- package/dist/server/sessions/machine/state.js +2 -0
- package/dist/server/sessions/machine/status.js +1 -2
- package/dist/server/sessions/machine/takeOvers.js +92 -0
- package/dist/server/sessions/machine.js +87 -23
- package/dist/server/sessions/outbox.js +7 -1
- package/dist/server/system/config.js +16 -1
- package/dist/server/system/errors.js +2 -2
- package/dist/server/system/messages.js +14 -176
- package/dist/server/system/messages.zh.js +186 -0
- package/dist/server/system/project.js +65 -0
- package/dist/server/system/store.js +1 -1
- package/dist/server/system/tmux.js +1 -1
- package/dist/web/assets/{Setup-C27bNf5I.js → Setup-OaIjxfwl.js} +1 -1
- package/dist/web/assets/main-DFndf3Z8.js +132 -0
- package/dist/web/assets/main-mChS_ic8.css +1 -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-maskable-512.png +0 -0
- package/dist/web/icon.svg +14 -6
- package/dist/web/index.html +2 -2
- package/dist/web/manifest.webmanifest +1 -1
- package/dist/web/sw.js +4 -4
- package/package.json +3 -3
- package/dist/web/assets/main-C8GiLoJy.css +0 -1
- package/dist/web/assets/main-DQFM4yJU.js +0 -132
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// What Codex writes down of a conversation, as events, decided here and nowhere else: its app server's items
|
|
2
|
+
// and turns (the protocol is marked experimental, every field may be missing) and the lines of its rollout
|
|
3
|
+
// files. Each source has its own translator: the app server's items are camelCase, the rollout's PascalCase.
|
|
4
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
|
|
5
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
6
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
7
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
8
|
+
const other = () => ({ k: 'codex:other' });
|
|
9
|
+
// The command of a command execution or of an approval to run one: as Codex parsed it rather than the shell
|
|
10
|
+
// line that wraps it (`/bin/zsh -c '…'`).
|
|
11
|
+
export const commandOf = (o) => str(obj(arr(o.commandActions)[0])?.command) || str(o.command);
|
|
12
|
+
const textParts = (content) => arr(content)
|
|
13
|
+
.map((c) => (obj(c)?.type === 'text' ? str(obj(c)?.text) : ''))
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join('\n');
|
|
16
|
+
function itemKind(i, id) {
|
|
17
|
+
switch (i.type) {
|
|
18
|
+
case 'userMessage':
|
|
19
|
+
return { k: 'codex:userMessage', id, text: textParts(i.content), clientId: str(i.clientId) };
|
|
20
|
+
case 'agentMessage':
|
|
21
|
+
return { k: 'codex:agentMessage', id, text: str(i.text) };
|
|
22
|
+
case 'plan':
|
|
23
|
+
return { k: 'codex:plan', id, text: str(i.text) };
|
|
24
|
+
case 'commandExecution': {
|
|
25
|
+
const exitCode = num(i.exitCode);
|
|
26
|
+
return { k: 'codex:commandExecution', id, command: commandOf(i), output: str(i.aggregatedOutput), status: str(i.status), ...(exitCode !== undefined && { exitCode }) };
|
|
27
|
+
}
|
|
28
|
+
case 'fileChange':
|
|
29
|
+
return {
|
|
30
|
+
k: 'codex:fileChange',
|
|
31
|
+
id,
|
|
32
|
+
status: str(i.status),
|
|
33
|
+
changes: arr(i.changes).map((c) => ({ path: str(obj(c)?.path), kind: str(obj(obj(c)?.kind)?.type), diff: str(obj(c)?.diff) })),
|
|
34
|
+
};
|
|
35
|
+
case 'mcpToolCall':
|
|
36
|
+
case 'dynamicToolCall': {
|
|
37
|
+
const error = str(obj(i.error)?.message);
|
|
38
|
+
const given = arr(obj(i.result)?.content ?? i.contentItems).map((c) => str(obj(c)?.text)).filter(Boolean).join('\n');
|
|
39
|
+
return {
|
|
40
|
+
k: 'codex:toolCall',
|
|
41
|
+
id,
|
|
42
|
+
name: [str(i.server) || str(i.namespace), str(i.tool)].filter(Boolean).join('.'),
|
|
43
|
+
args: obj(i.arguments) ?? {},
|
|
44
|
+
text: error || given,
|
|
45
|
+
failed: !!error || str(i.status) === 'failed' || i.success === false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
case 'webSearch':
|
|
49
|
+
return { k: 'codex:webSearch', id, query: str(i.query) };
|
|
50
|
+
case 'imageView':
|
|
51
|
+
return { k: 'codex:imageView', id, path: str(i.path) };
|
|
52
|
+
case 'contextCompaction':
|
|
53
|
+
return { k: 'codex:compaction', id };
|
|
54
|
+
default:
|
|
55
|
+
return other();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// An item of a thread, as the app server gives it; at: when it started or completed, as the caller knows.
|
|
59
|
+
export function itemEventOf(item, at) {
|
|
60
|
+
const i = obj(item);
|
|
61
|
+
const id = str(i?.id);
|
|
62
|
+
if (!i || !id)
|
|
63
|
+
return other();
|
|
64
|
+
const event = itemKind(i, id);
|
|
65
|
+
return at && event.k !== 'codex:other' ? { ...event, at } : event;
|
|
66
|
+
}
|
|
67
|
+
// A turn as the app server gives it: an event once it has ended.
|
|
68
|
+
export function turnEventOf(turn) {
|
|
69
|
+
const t = obj(turn);
|
|
70
|
+
const id = str(t?.id);
|
|
71
|
+
if (!t || !id)
|
|
72
|
+
return other();
|
|
73
|
+
const ended = (how) => ({ k: 'codex:turnEnded', id, how, error: str(obj(t.error)?.message) });
|
|
74
|
+
switch (t.status) {
|
|
75
|
+
case 'completed':
|
|
76
|
+
case 'interrupted':
|
|
77
|
+
case 'failed':
|
|
78
|
+
return ended(t.status);
|
|
79
|
+
default:
|
|
80
|
+
return other();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// A line of a rollout file, once parsed.
|
|
84
|
+
export function rolloutEventOf(line) {
|
|
85
|
+
const e = obj(line);
|
|
86
|
+
const p = obj(e?.payload);
|
|
87
|
+
if (!e || e.type !== 'event_msg' || !p)
|
|
88
|
+
return other();
|
|
89
|
+
const at = typeof e.timestamp === 'string' ? { at: e.timestamp } : {};
|
|
90
|
+
switch (p.type) {
|
|
91
|
+
case 'task_started':
|
|
92
|
+
return { k: 'codex:rolloutTurnStarted', ...at };
|
|
93
|
+
case 'task_complete':
|
|
94
|
+
return { k: 'codex:rolloutTurnEnded', how: 'completed', ...at };
|
|
95
|
+
case 'turn_aborted':
|
|
96
|
+
return { k: 'codex:rolloutTurnEnded', how: 'aborted', ...at };
|
|
97
|
+
case 'item_completed': {
|
|
98
|
+
const item = obj(p.item);
|
|
99
|
+
return item?.type === 'UserMessage' ? { k: 'codex:rolloutPrompt', text: textParts(item.content), ...at } : other();
|
|
100
|
+
}
|
|
101
|
+
// How Codex before September wrote it.
|
|
102
|
+
case 'user_message':
|
|
103
|
+
return { k: 'codex:rolloutPrompt', text: str(p.message), ...at };
|
|
104
|
+
default:
|
|
105
|
+
return other();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { unhandled } from '../events.js';
|
|
4
|
+
import { commandOf, itemEventOf, turnEventOf } from './events.js';
|
|
3
5
|
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
|
|
4
6
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
5
7
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
6
8
|
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
7
9
|
const SUMMARY_MAX = 200;
|
|
8
10
|
const RESULT_MAX = 2000;
|
|
11
|
+
const INPUT_MAX = 4000;
|
|
9
12
|
const DIFF_LINES_MAX = 300;
|
|
10
13
|
const DIFF_LINE_MAX = 400;
|
|
11
14
|
const DETAIL_MAX = 4000;
|
|
12
15
|
const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}…` : s);
|
|
13
16
|
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
17
|
function unifiedDiff(file, kind, text) {
|
|
22
18
|
const lines = text.replace(/\n$/, '').split('\n');
|
|
23
19
|
const hunks = [];
|
|
@@ -61,80 +57,64 @@ function unifiedDiff(file, kind, text) {
|
|
|
61
57
|
const failedOrStopped = (status) => ({ error: status === 'failed', ...(status === 'declined' && { stopped: true }) });
|
|
62
58
|
// What one completed item adds to the chat; nothing for what the chat does not show (reasoning).
|
|
63
59
|
export function entriesOf(item, at) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
case 'agentMessage':
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const exit = num(i.exitCode);
|
|
60
|
+
return entriesOfEvent(itemEventOf(item, at));
|
|
61
|
+
}
|
|
62
|
+
// A turn that did not end by itself, as the line the chat puts after it.
|
|
63
|
+
export function turnNote(turn) {
|
|
64
|
+
return entriesOfEvent(turnEventOf(turn))[0] ?? null;
|
|
65
|
+
}
|
|
66
|
+
export function entriesOfEvent(e) {
|
|
67
|
+
const stamp = e.at ? { at: e.at } : {};
|
|
68
|
+
switch (e.k) {
|
|
69
|
+
case 'codex:userMessage':
|
|
70
|
+
return e.text ? [{ id: e.id, item: { k: 'user', text: e.text, ...stamp }, ...(e.clientId && { clientId: e.clientId }) }] : [];
|
|
71
|
+
case 'codex:agentMessage':
|
|
72
|
+
case 'codex:plan':
|
|
73
|
+
return e.text ? [{ id: e.id, item: { k: 'text', text: e.text, ...stamp } }] : [];
|
|
74
|
+
case 'codex:commandExecution': {
|
|
75
|
+
const { id, command } = e;
|
|
76
|
+
const summary = line1(command);
|
|
82
77
|
return [
|
|
83
|
-
{ id, item: { k: 'tool', id, name: 'Bash', summary:
|
|
84
|
-
{ id: `${id}#r`, item: { k: 'result', id, text: clip(
|
|
78
|
+
{ id, item: { k: 'tool', id, name: 'Bash', summary, ...(summary !== command && { input: clip(command, INPUT_MAX) }), ...stamp } },
|
|
79
|
+
{ id: `${id}#r`, item: { k: 'result', id, text: clip(e.output, RESULT_MAX), ...failedOrStopped(e.status), ...(e.exitCode !== undefined && e.exitCode !== 0 && { error: true }) } },
|
|
85
80
|
];
|
|
86
81
|
}
|
|
87
|
-
case 'fileChange':
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const change = obj(c);
|
|
91
|
-
const path = str(change?.path);
|
|
92
|
-
if (!path)
|
|
82
|
+
case 'codex:fileChange':
|
|
83
|
+
return e.changes.flatMap((c, n) => {
|
|
84
|
+
if (!c.path)
|
|
93
85
|
return [];
|
|
94
|
-
const
|
|
95
|
-
const cid = `${id}:${n}`;
|
|
86
|
+
const cid = `${e.id}:${n}`;
|
|
96
87
|
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,
|
|
88
|
+
{ id: cid, item: { k: 'tool', id: cid, name: c.kind === 'add' ? 'Write' : 'Edit', summary: line1(c.path), ...stamp } },
|
|
89
|
+
{ id: `${cid}#r`, item: { k: 'result', id: cid, text: '', ...failedOrStopped(e.status), diff: unifiedDiff(c.path, c.kind, c.diff) } },
|
|
99
90
|
];
|
|
100
91
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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');
|
|
92
|
+
case 'codex:toolCall': {
|
|
93
|
+
const { id } = e;
|
|
94
|
+
const summary = line1(Object.values(e.args).map(str).find(Boolean) ?? '');
|
|
109
95
|
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:
|
|
96
|
+
{ id, item: { k: 'tool', id, name: e.name || 'tool', summary, ...stamp } },
|
|
97
|
+
{ id: `${id}#r`, item: { k: 'result', id, text: clip(e.text, RESULT_MAX), error: e.failed } },
|
|
112
98
|
];
|
|
113
99
|
}
|
|
114
|
-
case 'webSearch':
|
|
115
|
-
return [{ id, item: { k: 'tool', id, name: 'WebSearch', summary: line1(
|
|
116
|
-
case 'imageView':
|
|
117
|
-
return [{ id, item: { k: 'tool', id, name: 'Read', summary: line1(
|
|
118
|
-
case '
|
|
119
|
-
return [{ id, item: { k: 'note', event: { e: 'compacted' } } }];
|
|
120
|
-
|
|
100
|
+
case 'codex:webSearch':
|
|
101
|
+
return [{ id: e.id, item: { k: 'tool', id: e.id, name: 'WebSearch', summary: line1(e.query), ...stamp } }];
|
|
102
|
+
case 'codex:imageView':
|
|
103
|
+
return [{ id: e.id, item: { k: 'tool', id: e.id, name: 'Read', summary: line1(e.path), ...stamp } }];
|
|
104
|
+
case 'codex:compaction':
|
|
105
|
+
return [{ id: e.id, item: { k: 'note', event: { e: 'compacted' } } }];
|
|
106
|
+
case 'codex:turnEnded':
|
|
107
|
+
if (e.how === 'interrupted')
|
|
108
|
+
return [{ id: `${e.id}#end`, item: { k: 'note', event: { e: 'interrupted' } } }];
|
|
109
|
+
if (e.how === 'failed')
|
|
110
|
+
return [{ id: `${e.id}#end`, item: { k: 'note', event: { e: 'failed' }, ...(e.error && { detail: e.error }) } }];
|
|
121
111
|
return [];
|
|
112
|
+
case 'codex:other':
|
|
113
|
+
return [];
|
|
114
|
+
default:
|
|
115
|
+
return unhandled(e);
|
|
122
116
|
}
|
|
123
117
|
}
|
|
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
118
|
export const COMMAND_APPROVAL = 'item/commandExecution/requestApproval';
|
|
139
119
|
export const FILE_APPROVAL = 'item/fileChange/requestApproval';
|
|
140
120
|
export const isApproval = (method) => method === COMMAND_APPROVAL || method === FILE_APPROVAL;
|
|
@@ -152,9 +132,9 @@ const offered = (params, decision) => {
|
|
|
152
132
|
const all = arr(params.availableDecisions);
|
|
153
133
|
return !all.length || all.includes(decision);
|
|
154
134
|
};
|
|
155
|
-
// An approval Codex asks for, as the phone's permission card.
|
|
156
|
-
//
|
|
157
|
-
export function askOf(askId, method, params,
|
|
135
|
+
// An approval Codex asks for, as the phone's permission card. files: the file change it is for, as it started,
|
|
136
|
+
// which names the files the request itself does not.
|
|
137
|
+
export function askOf(askId, method, params, files) {
|
|
158
138
|
const reason = str(params.reason);
|
|
159
139
|
if (method === COMMAND_APPROVAL) {
|
|
160
140
|
const command = commandOf(params);
|
|
@@ -169,9 +149,9 @@ export function askOf(askId, method, params, fileItem) {
|
|
|
169
149
|
always: amendment ? { rules: [amendment.acceptWithExecpolicyAmendment.execpolicy_amendment.join(' ')], dirs: [], mode: null } : null,
|
|
170
150
|
};
|
|
171
151
|
}
|
|
172
|
-
const changes =
|
|
173
|
-
const paths = changes.map((c) =>
|
|
174
|
-
const diffs = changes.map((c) =>
|
|
152
|
+
const changes = files ?? [];
|
|
153
|
+
const paths = changes.map((c) => c.path).filter(Boolean);
|
|
154
|
+
const diffs = changes.map((c) => c.diff).filter(Boolean).join('\n');
|
|
175
155
|
return {
|
|
176
156
|
id: askId,
|
|
177
157
|
kind: 'permission',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { itemEventOf } from './events.js';
|
|
3
|
+
import { askOf, entriesOfEvent, isApproval, turnNote, usageOf } from './items.js';
|
|
3
4
|
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
|
|
4
5
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
5
6
|
// How often the reply being written is put in the journal while deltas come.
|
|
@@ -59,24 +60,24 @@ export class CodexNotifications {
|
|
|
59
60
|
return;
|
|
60
61
|
}
|
|
61
62
|
case 'item/started': {
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
t.files.set(
|
|
65
|
-
if (
|
|
63
|
+
const e = itemEventOf(params.item);
|
|
64
|
+
if (e.k === 'codex:fileChange')
|
|
65
|
+
t.files.set(e.id, e.changes);
|
|
66
|
+
if (e.k === 'codex:compaction')
|
|
66
67
|
this.compacting(t, true);
|
|
67
68
|
return;
|
|
68
69
|
}
|
|
69
70
|
case 'item/completed': {
|
|
70
|
-
const item = obj(params.item);
|
|
71
71
|
const at = typeof params.completedAtMs === 'number' ? new Date(params.completedAtMs).toISOString() : undefined;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
const e = itemEventOf(params.item, at);
|
|
73
|
+
this.put(t, entriesOfEvent(e));
|
|
74
|
+
if (e.k === 'codex:agentMessage') {
|
|
75
|
+
t.said = e.text || t.said;
|
|
76
|
+
this.liveDone(t, e.id);
|
|
76
77
|
}
|
|
77
|
-
if (
|
|
78
|
-
t.files.delete(
|
|
79
|
-
if (
|
|
78
|
+
if (e.k === 'codex:fileChange')
|
|
79
|
+
t.files.delete(e.id);
|
|
80
|
+
if (e.k === 'codex:compaction')
|
|
80
81
|
this.compacting(t, false);
|
|
81
82
|
return;
|
|
82
83
|
}
|
|
@@ -2,10 +2,13 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { realpathSync } from 'node:fs';
|
|
3
3
|
import { open } from 'node:fs/promises';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
|
+
import { turnSays } from '../turnSays.js';
|
|
6
|
+
import { rolloutEventOf } from './events.js';
|
|
5
7
|
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
|
|
6
8
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
7
9
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
8
10
|
const LIMIT = 50;
|
|
11
|
+
const ALL_PAGE = 100;
|
|
9
12
|
// How long the list is kept: it takes the app server seconds to make.
|
|
10
13
|
const KEEP_MS = 5 * 60_000;
|
|
11
14
|
// How much of the end of a rollout is read for how its last turn went and what the person said last.
|
|
@@ -43,15 +46,49 @@ function anyThread(t) {
|
|
|
43
46
|
updatedAt: num(t.updatedAt) * 1000,
|
|
44
47
|
};
|
|
45
48
|
}
|
|
49
|
+
// What `make` makes, kept for KEEP_MS: everyone who asks while it is made waits for the one making; once old it
|
|
50
|
+
// is made again in the background while the kept one answers, and kept on when that fails.
|
|
51
|
+
function kept(make) {
|
|
52
|
+
let had = null;
|
|
53
|
+
let making = null;
|
|
54
|
+
return () => {
|
|
55
|
+
if (!(had && Date.now() - had.at < KEEP_MS) && !making) {
|
|
56
|
+
making = make()
|
|
57
|
+
.then((value) => {
|
|
58
|
+
had = { at: Date.now(), value };
|
|
59
|
+
return value;
|
|
60
|
+
})
|
|
61
|
+
.finally(() => {
|
|
62
|
+
making = null;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (had) {
|
|
66
|
+
making?.catch(() => { });
|
|
67
|
+
return Promise.resolve(had.value);
|
|
68
|
+
}
|
|
69
|
+
return making;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
46
72
|
export class CodexThreads {
|
|
47
73
|
o;
|
|
48
|
-
kept = null;
|
|
49
|
-
listing = null;
|
|
50
74
|
asking = null;
|
|
51
75
|
locks;
|
|
76
|
+
threads;
|
|
77
|
+
everyThread;
|
|
52
78
|
constructor(o) {
|
|
53
79
|
this.o = o;
|
|
54
80
|
this.locks = join(o.codexHome, 'thread-writer-locks');
|
|
81
|
+
this.threads = kept(async () => (await o.agent.listThreads({ limit: LIMIT, sourceKinds: ['cli', 'vscode'] })).flatMap((d) => threadOf(d) ?? []));
|
|
82
|
+
this.everyThread = kept(async () => {
|
|
83
|
+
const threads = [];
|
|
84
|
+
let cursor = null;
|
|
85
|
+
do {
|
|
86
|
+
const page = await o.agent.listThreadPage({ limit: ALL_PAGE, sourceKinds: ['cli', 'vscode'], ...(cursor && { cursor }) });
|
|
87
|
+
threads.push(...page.data.flatMap((d) => threadOf(d) ?? []));
|
|
88
|
+
cursor = page.next;
|
|
89
|
+
} while (cursor);
|
|
90
|
+
return threads;
|
|
91
|
+
});
|
|
55
92
|
}
|
|
56
93
|
// The latest threads and every thread open somewhere now, newest first. Asked again while one runs, the one
|
|
57
94
|
// running answers.
|
|
@@ -61,6 +98,12 @@ export class CodexThreads {
|
|
|
61
98
|
});
|
|
62
99
|
return this.asking;
|
|
63
100
|
}
|
|
101
|
+
// Every thread people opened, however old, newest first, as list() has them.
|
|
102
|
+
async all() {
|
|
103
|
+
const [threads, holders] = await Promise.all([this.everyThread(), this.holders()]);
|
|
104
|
+
const shown = await Promise.all(threads.map((t) => this.live(t, holders.get(t.id) ?? null)));
|
|
105
|
+
return shown.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
106
|
+
}
|
|
64
107
|
// One thread as list() has it, also when it is older than the list goes or not one list() shows; null when
|
|
65
108
|
// there is none.
|
|
66
109
|
async where(id) {
|
|
@@ -82,27 +125,6 @@ export class CodexThreads {
|
|
|
82
125
|
const shown = await Promise.all(all.map((t) => this.live(t, holders.get(t.id) ?? null)));
|
|
83
126
|
return shown.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
84
127
|
}
|
|
85
|
-
// The kept list, made again in the background once old; the first ask waits for it.
|
|
86
|
-
threads() {
|
|
87
|
-
const fresh = this.kept && Date.now() - this.kept.at < KEEP_MS;
|
|
88
|
-
if (!fresh && !this.listing) {
|
|
89
|
-
this.listing = this.o.agent
|
|
90
|
-
.listThreads({ limit: LIMIT, sourceKinds: ['cli', 'vscode'] })
|
|
91
|
-
.then((data) => {
|
|
92
|
-
const threads = data.flatMap((d) => threadOf(d) ?? []);
|
|
93
|
-
this.kept = { at: Date.now(), threads };
|
|
94
|
-
return threads;
|
|
95
|
-
})
|
|
96
|
-
.finally(() => {
|
|
97
|
-
this.listing = null;
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
if (this.kept) {
|
|
101
|
-
this.listing?.catch(() => { });
|
|
102
|
-
return Promise.resolve(this.kept.threads);
|
|
103
|
-
}
|
|
104
|
-
return this.listing;
|
|
105
|
-
}
|
|
106
128
|
// The thread's state as its rollout says now.
|
|
107
129
|
async live(t, holder) {
|
|
108
130
|
const end = t.path ? await rolloutEnd(t.path).catch(() => null) : null;
|
|
@@ -174,31 +196,23 @@ export async function rolloutEnd(path) {
|
|
|
174
196
|
for (const line of lines) {
|
|
175
197
|
if (!line.includes('"event_msg"'))
|
|
176
198
|
continue;
|
|
177
|
-
let
|
|
178
|
-
let p;
|
|
199
|
+
let v;
|
|
179
200
|
try {
|
|
180
|
-
|
|
181
|
-
p = obj(e?.payload);
|
|
201
|
+
v = JSON.parse(line);
|
|
182
202
|
}
|
|
183
203
|
catch {
|
|
184
204
|
continue;
|
|
185
205
|
}
|
|
186
|
-
const
|
|
187
|
-
|
|
206
|
+
const e = rolloutEventOf(v);
|
|
207
|
+
const turn = turnSays(e);
|
|
208
|
+
if (turn === 'starts') {
|
|
188
209
|
busy = true;
|
|
189
|
-
turnAt = Date.parse(
|
|
210
|
+
turnAt = Date.parse(e.at ?? '') || 0;
|
|
190
211
|
}
|
|
191
|
-
else if (
|
|
212
|
+
else if (turn === 'over')
|
|
192
213
|
busy = false;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
.map((c) => (obj(c)?.type === 'text' ? str(obj(c)?.text) : ''))
|
|
196
|
-
.join(' ');
|
|
197
|
-
lastPrompt = promptOf(text) ?? lastPrompt;
|
|
198
|
-
}
|
|
199
|
-
// How Codex before September wrote it.
|
|
200
|
-
else if (type === 'user_message')
|
|
201
|
-
lastPrompt = promptOf(str(p?.message)) ?? lastPrompt;
|
|
214
|
+
if (e.k === 'codex:rolloutPrompt')
|
|
215
|
+
lastPrompt = promptOf(e.text) ?? lastPrompt;
|
|
202
216
|
}
|
|
203
217
|
return { busy, turnAt, lastPrompt, mtime: mtimeMs };
|
|
204
218
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { unhandled } from './events.js';
|
|
2
|
+
// What an event, of either agent, says of the turn: that one begins with it, that it goes on, that it ended there, or nothing.
|
|
3
|
+
export function turnSays(e) {
|
|
4
|
+
switch (e.k) {
|
|
5
|
+
case 'claude:prompt':
|
|
6
|
+
case 'claude:shell':
|
|
7
|
+
case 'claude:shellOutput':
|
|
8
|
+
case 'claude:teammates':
|
|
9
|
+
case 'claude:taskNotification':
|
|
10
|
+
case 'claude:fromOther':
|
|
11
|
+
return 'starts';
|
|
12
|
+
case 'claude:command':
|
|
13
|
+
return e.local ? null : 'starts';
|
|
14
|
+
case 'claude:results':
|
|
15
|
+
case 'claude:reply':
|
|
16
|
+
return 'going';
|
|
17
|
+
case 'claude:queue':
|
|
18
|
+
return e.op === 'dequeue' ? 'going' : null;
|
|
19
|
+
case 'claude:interrupted':
|
|
20
|
+
case 'claude:apiError':
|
|
21
|
+
case 'claude:turnOver':
|
|
22
|
+
return 'over';
|
|
23
|
+
// A compaction asked for runs between turns; one Claude Code starts on its own can fall in the middle of one.
|
|
24
|
+
case 'claude:compactBoundary':
|
|
25
|
+
return e.manual ? 'over' : null;
|
|
26
|
+
case 'claude:commandOutput':
|
|
27
|
+
case 'claude:compacted':
|
|
28
|
+
case 'claude:noted':
|
|
29
|
+
case 'claude:peer':
|
|
30
|
+
case 'claude:midTurnPrompt':
|
|
31
|
+
case 'claude:midTurnTaskEnd':
|
|
32
|
+
case 'claude:title':
|
|
33
|
+
case 'claude:lastPrompt':
|
|
34
|
+
case 'claude:sidechain':
|
|
35
|
+
case 'claude:other':
|
|
36
|
+
return null;
|
|
37
|
+
// Codex marks its turns itself; what is said inside one says nothing more of it.
|
|
38
|
+
case 'codex:rolloutTurnStarted':
|
|
39
|
+
return 'starts';
|
|
40
|
+
case 'codex:rolloutTurnEnded':
|
|
41
|
+
case 'codex:turnEnded':
|
|
42
|
+
return 'over';
|
|
43
|
+
case 'codex:rolloutPrompt':
|
|
44
|
+
case 'codex:userMessage':
|
|
45
|
+
case 'codex:agentMessage':
|
|
46
|
+
case 'codex:plan':
|
|
47
|
+
case 'codex:commandExecution':
|
|
48
|
+
case 'codex:fileChange':
|
|
49
|
+
case 'codex:toolCall':
|
|
50
|
+
case 'codex:webSearch':
|
|
51
|
+
case 'codex:imageView':
|
|
52
|
+
case 'codex:compaction':
|
|
53
|
+
case 'codex:other':
|
|
54
|
+
return null;
|
|
55
|
+
default:
|
|
56
|
+
return unhandled(e);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/server/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { account } from './commands/account.js';
|
|
3
|
+
import { activity } from './commands/activity.js';
|
|
3
4
|
import { attach } from './commands/attach.js';
|
|
4
5
|
import { doctor } from './commands/doctor.js';
|
|
5
6
|
import { install } from './commands/install.js';
|
|
@@ -31,6 +32,7 @@ const commands = {
|
|
|
31
32
|
serve,
|
|
32
33
|
doctor,
|
|
33
34
|
status,
|
|
35
|
+
activity,
|
|
34
36
|
attach,
|
|
35
37
|
account,
|
|
36
38
|
claude: (args) => openHere('claude', args),
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { watchActivity } from '../sessions/control.js';
|
|
2
|
+
import { m } from '../system/messages.js';
|
|
3
|
+
import { homeDir, socketPath } from '../system/paths.js';
|
|
4
|
+
import { fail } from './common.js';
|
|
5
|
+
const real = () => ({
|
|
6
|
+
sock: socketPath(homeDir(), 'control.sock'),
|
|
7
|
+
print: (line) => console.log(line),
|
|
8
|
+
fail,
|
|
9
|
+
orphaned: () => process.ppid === 1,
|
|
10
|
+
});
|
|
11
|
+
// What the menu bar app runs: how many sessions are working, a line of JSON now and at each change, until the
|
|
12
|
+
// server or the app goes.
|
|
13
|
+
export async function activity(args, io = real()) {
|
|
14
|
+
if (!args.includes('--json'))
|
|
15
|
+
return io.fail(m.cli.activityUsage);
|
|
16
|
+
const watch = watchActivity(io.sock, (working) => io.print(JSON.stringify({ working })));
|
|
17
|
+
const check = setInterval(() => io.orphaned() && watch.stop(), io.checkMs ?? 5000);
|
|
18
|
+
try {
|
|
19
|
+
await watch.done;
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
const code = e.code;
|
|
23
|
+
return io.fail(code === 'ENOENT' || code === 'ECONNREFUSED' ? m.cli.notRunning : e.message);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
clearInterval(check);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/server/http/app.js
CHANGED
|
@@ -3,7 +3,8 @@ import fastifyWebsocket from '@fastify/websocket';
|
|
|
3
3
|
import Fastify, { LogController } from 'fastify';
|
|
4
4
|
import { Guard, clientIp, sessionCookie } from './auth/guard.js';
|
|
5
5
|
import { LoginSessions } from './auth/sessions.js';
|
|
6
|
-
import { findTranscript,
|
|
6
|
+
import { findTranscript, lastSaid } from '../agents/claude/transcript/transcriptFile.js';
|
|
7
|
+
import { sites } from '../system/config.js';
|
|
7
8
|
import { DomainError, errorReply } from '../system/errors.js';
|
|
8
9
|
import { EventHub } from '../sessions/events.js';
|
|
9
10
|
import { register as appUpdate } from './routes/appUpdate.js';
|
|
@@ -16,6 +17,7 @@ import { register as journal } from './routes/journal.js';
|
|
|
16
17
|
import { register as mac } from './routes/mac.js';
|
|
17
18
|
import { register as media } from './routes/media.js';
|
|
18
19
|
import { register as push } from './routes/push.js';
|
|
20
|
+
import { register as pushRelay } from './routes/pushRelay.js';
|
|
19
21
|
import { register as sessions } from './routes/sessions.js';
|
|
20
22
|
import { register as web } from './routes/web.js';
|
|
21
23
|
import { VERSION } from '../system/version.js';
|
|
@@ -48,13 +50,13 @@ export async function buildApp(deps) {
|
|
|
48
50
|
return reply.code(status).send(body);
|
|
49
51
|
});
|
|
50
52
|
const logins = new LoginSessions(store, now);
|
|
51
|
-
const guard = new Guard(logins, origin);
|
|
53
|
+
const guard = new Guard(logins, sites({ origin, rpId: deps.rpId, extraOrigins: deps.extraOrigins ?? [] }));
|
|
52
54
|
const secure = origin.startsWith('https://');
|
|
53
55
|
const hub = new EventHub(machine, async (s) => {
|
|
54
56
|
if (s.kind === 'codex')
|
|
55
|
-
return machine.lastReply(s.id);
|
|
57
|
+
return { reply: machine.lastReply(s.id), at: null };
|
|
56
58
|
const path = s.claudeSessionId && deps.claudeProjectsRoot ? await findTranscript(deps.claudeProjectsRoot, s.claudeSessionId) : null;
|
|
57
|
-
return path ?
|
|
59
|
+
return path ? lastSaid(path) : { reply: null, at: null };
|
|
58
60
|
});
|
|
59
61
|
const audit = (req, method, ok) => {
|
|
60
62
|
const line = { t: new Date(now()).toISOString(), ip: clientIp(req), ua: req.headers['user-agent'] ?? '', method, ok };
|
|
@@ -137,6 +139,8 @@ export async function buildApp(deps) {
|
|
|
137
139
|
const ctx = { deps, guard, logins, hub, secure, now, audit, createLogin, startLogin, admit, dropRevokedSockets, machineOr404 };
|
|
138
140
|
for (const register of [health, auth, sessions, chat, journal, media, claude, codex, push, appUpdate, mac])
|
|
139
141
|
await register(app, ctx);
|
|
142
|
+
if (deps.relay?.relayTopic)
|
|
143
|
+
await pushRelay(deps.relay)(app, ctx);
|
|
140
144
|
if (deps.extend)
|
|
141
145
|
await deps.extend(app, ctx);
|
|
142
146
|
await web(app, ctx);
|
|
@@ -26,13 +26,17 @@ export function clientIp(req) {
|
|
|
26
26
|
}
|
|
27
27
|
export class Guard {
|
|
28
28
|
logins;
|
|
29
|
-
|
|
30
|
-
constructor(logins,
|
|
29
|
+
sites;
|
|
30
|
+
constructor(logins, sites) {
|
|
31
31
|
this.logins = logins;
|
|
32
|
-
this.
|
|
32
|
+
this.sites = sites;
|
|
33
33
|
}
|
|
34
34
|
originOk(req) {
|
|
35
|
-
return
|
|
35
|
+
return this.sites.some((s) => s.origin === req.headers.origin);
|
|
36
|
+
}
|
|
37
|
+
// The address the page is on, or the origin for a request that names none of them.
|
|
38
|
+
siteOf(req) {
|
|
39
|
+
return this.sites.find((s) => s.origin === req.headers.origin) ?? this.sites[0];
|
|
36
40
|
}
|
|
37
41
|
// The login a request claims, and whether it came in the cookie. A browser sends the cookie by itself to
|
|
38
42
|
// whatever page makes the request; the Authorization header only the app's own code puts there. Where
|