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.
Files changed (73) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +5 -3
  3. package/dist/build.json +1 -1
  4. package/dist/server/agents/claude/screen/background.js +7 -0
  5. package/dist/server/agents/claude/screen/prompt.js +34 -0
  6. package/dist/server/agents/claude/screen/screen.js +42 -3
  7. package/dist/server/agents/claude/terminals.js +67 -12
  8. package/dist/server/agents/claude/transcript/chat.js +98 -135
  9. package/dist/server/agents/claude/transcript/events.js +158 -0
  10. package/dist/server/agents/claude/transcript/transcriptFile.js +50 -10
  11. package/dist/server/agents/claude/transcript/transcripts.js +118 -30
  12. package/dist/server/agents/claude/transcript/turn.js +40 -61
  13. package/dist/server/agents/codex/agent.js +9 -2
  14. package/dist/server/agents/codex/events.js +107 -0
  15. package/dist/server/agents/codex/items.js +54 -74
  16. package/dist/server/agents/codex/notifications.js +14 -13
  17. package/dist/server/agents/codex/threads.js +54 -40
  18. package/dist/server/agents/events.js +4 -0
  19. package/dist/server/agents/turnSays.js +58 -0
  20. package/dist/server/cli.js +2 -0
  21. package/dist/server/commands/activity.js +28 -0
  22. package/dist/server/http/app.js +8 -4
  23. package/dist/server/http/auth/guard.js +8 -4
  24. package/dist/server/http/auth/passkey.js +6 -3
  25. package/dist/server/http/routes/appUpdate.js +1 -1
  26. package/dist/server/http/routes/chat.js +10 -0
  27. package/dist/server/http/routes/claude.js +70 -16
  28. package/dist/server/http/routes/codex.js +38 -3
  29. package/dist/server/http/routes/journal.js +3 -1
  30. package/dist/server/http/routes/push.js +36 -5
  31. package/dist/server/http/routes/pushRelay.js +62 -0
  32. package/dist/server/http/routes/sessions.js +32 -1
  33. package/dist/server/http/serve.js +8 -2
  34. package/dist/server/journal/db.js +4 -0
  35. package/dist/server/journal/index.js +4 -0
  36. package/dist/server/journal/ingest.js +26 -13
  37. package/dist/server/notify/apns.js +12 -7
  38. package/dist/server/notify/push.js +30 -4
  39. package/dist/server/notify/seal.js +12 -0
  40. package/dist/server/ops/doctor.js +7 -6
  41. package/dist/server/ops/problems.js +12 -7
  42. package/dist/server/sessions/control.js +65 -0
  43. package/dist/server/sessions/events.js +7 -5
  44. package/dist/server/sessions/machine/input.js +21 -2
  45. package/dist/server/sessions/machine/lifecycle.js +151 -81
  46. package/dist/server/sessions/machine/moves.js +65 -13
  47. package/dist/server/sessions/machine/naming.js +85 -0
  48. package/dist/server/sessions/machine/state.js +2 -0
  49. package/dist/server/sessions/machine/status.js +1 -2
  50. package/dist/server/sessions/machine/takeOvers.js +92 -0
  51. package/dist/server/sessions/machine.js +87 -23
  52. package/dist/server/sessions/outbox.js +7 -1
  53. package/dist/server/system/config.js +16 -1
  54. package/dist/server/system/errors.js +2 -2
  55. package/dist/server/system/messages.js +14 -176
  56. package/dist/server/system/messages.zh.js +186 -0
  57. package/dist/server/system/project.js +65 -0
  58. package/dist/server/system/store.js +1 -1
  59. package/dist/server/system/tmux.js +1 -1
  60. package/dist/web/assets/{Setup-C27bNf5I.js → Setup-OaIjxfwl.js} +1 -1
  61. package/dist/web/assets/main-DFndf3Z8.js +132 -0
  62. package/dist/web/assets/main-mChS_ic8.css +1 -0
  63. package/dist/web/icon-180.png +0 -0
  64. package/dist/web/icon-192.png +0 -0
  65. package/dist/web/icon-512.png +0 -0
  66. package/dist/web/icon-maskable-512.png +0 -0
  67. package/dist/web/icon.svg +14 -6
  68. package/dist/web/index.html +2 -2
  69. package/dist/web/manifest.webmanifest +1 -1
  70. package/dist/web/sw.js +4 -4
  71. package/package.json +3 -3
  72. package/dist/web/assets/main-C8GiLoJy.css +0 -1
  73. 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
- 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);
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: 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 }) } },
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
- 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)
82
+ case 'codex:fileChange':
83
+ return e.changes.flatMap((c, n) => {
84
+ if (!c.path)
93
85
  return [];
94
- const kind = str(obj(change?.kind)?.type);
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, str(change?.diff)) } },
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
- 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');
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: !!error || str(i.status) === 'failed' || i.success === false } },
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(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:
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. 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) {
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 = 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');
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 { askOf, entriesOf, isApproval, turnNote, usageOf } from './items.js';
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 item = obj(params.item);
63
- if (item?.type === 'fileChange')
64
- t.files.set(str(item.id), item);
65
- if (item?.type === 'contextCompaction')
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
- 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));
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 (item?.type === 'fileChange')
78
- t.files.delete(str(item.id));
79
- if (item?.type === 'contextCompaction')
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 e;
178
- let p;
199
+ let v;
179
200
  try {
180
- e = obj(JSON.parse(line));
181
- p = obj(e?.payload);
201
+ v = JSON.parse(line);
182
202
  }
183
203
  catch {
184
204
  continue;
185
205
  }
186
- const type = str(p?.type);
187
- if (type === 'task_started') {
206
+ const e = rolloutEventOf(v);
207
+ const turn = turnSays(e);
208
+ if (turn === 'starts') {
188
209
  busy = true;
189
- turnAt = Date.parse(str(e?.timestamp)) || 0;
210
+ turnAt = Date.parse(e.at ?? '') || 0;
190
211
  }
191
- else if (type === 'task_complete' || type === 'turn_aborted')
212
+ else if (turn === 'over')
192
213
  busy = false;
193
- else if (type === 'item_completed' && obj(p?.item)?.type === 'UserMessage') {
194
- const text = (Array.isArray(obj(p?.item)?.content) ? obj(p?.item).content : [])
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,4 @@
1
+ // For a switch over every kind: a kind no case handles leaves this called with it, which does not compile.
2
+ export function unhandled(e) {
3
+ throw new Error(`agent event not handled: ${JSON.stringify(e)}`);
4
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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, lastReply } from '../agents/claude/transcript/transcriptFile.js';
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 ? lastReply(path) : null;
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
- origin;
30
- constructor(logins, origin) {
29
+ sites;
30
+ constructor(logins, sites) {
31
31
  this.logins = logins;
32
- this.origin = origin;
32
+ this.sites = sites;
33
33
  }
34
34
  originOk(req) {
35
- return req.headers.origin === this.origin;
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