workoffdesk 1.0.0 → 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 (74) hide show
  1. package/CHANGELOG.md +27 -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/http/sessionSocket.js +12 -11
  35. package/dist/server/journal/db.js +4 -0
  36. package/dist/server/journal/index.js +4 -0
  37. package/dist/server/journal/ingest.js +26 -13
  38. package/dist/server/notify/apns.js +12 -7
  39. package/dist/server/notify/push.js +30 -4
  40. package/dist/server/notify/seal.js +12 -0
  41. package/dist/server/ops/doctor.js +7 -6
  42. package/dist/server/ops/problems.js +12 -7
  43. package/dist/server/sessions/control.js +65 -0
  44. package/dist/server/sessions/events.js +7 -5
  45. package/dist/server/sessions/machine/input.js +21 -2
  46. package/dist/server/sessions/machine/lifecycle.js +151 -81
  47. package/dist/server/sessions/machine/moves.js +65 -13
  48. package/dist/server/sessions/machine/naming.js +85 -0
  49. package/dist/server/sessions/machine/state.js +2 -0
  50. package/dist/server/sessions/machine/status.js +1 -2
  51. package/dist/server/sessions/machine/takeOvers.js +92 -0
  52. package/dist/server/sessions/machine.js +87 -23
  53. package/dist/server/sessions/outbox.js +7 -1
  54. package/dist/server/system/config.js +16 -1
  55. package/dist/server/system/errors.js +2 -2
  56. package/dist/server/system/messages.js +14 -176
  57. package/dist/server/system/messages.zh.js +186 -0
  58. package/dist/server/system/project.js +65 -0
  59. package/dist/server/system/store.js +1 -1
  60. package/dist/server/system/tmux.js +1 -1
  61. package/dist/web/assets/{Setup-DinzDcF1.js → Setup-OaIjxfwl.js} +1 -1
  62. package/dist/web/assets/main-DFndf3Z8.js +132 -0
  63. package/dist/web/assets/main-mChS_ic8.css +1 -0
  64. package/dist/web/icon-180.png +0 -0
  65. package/dist/web/icon-192.png +0 -0
  66. package/dist/web/icon-512.png +0 -0
  67. package/dist/web/icon-maskable-512.png +0 -0
  68. package/dist/web/icon.svg +14 -6
  69. package/dist/web/index.html +2 -2
  70. package/dist/web/manifest.webmanifest +1 -1
  71. package/dist/web/sw.js +4 -4
  72. package/package.json +3 -3
  73. package/dist/web/assets/main-C8GiLoJy.css +0 -1
  74. package/dist/web/assets/main-w7i8PvU7.js +0 -132
@@ -0,0 +1,158 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+ const str = (v) => (typeof v === 'string' ? v : '');
3
+ const object = (v) => (v && typeof v === 'object' ? v : null);
4
+ // As text, without the colours and links a terminal would have drawn from it.
5
+ export function blocksText(content) {
6
+ if (typeof content === 'string')
7
+ return stripVTControlCharacters(content);
8
+ if (!Array.isArray(content))
9
+ return '';
10
+ return content
11
+ .map((b) => (b?.type === 'text' ? stripVTControlCharacters(str(b.text)) : b?.type === 'image' ? '[image]' : ''))
12
+ .filter(Boolean)
13
+ .join(' ');
14
+ }
15
+ // Only what was typed: the text blocks, without pictures.
16
+ const typedText = (content) => typeof content === 'string' ? content : Array.isArray(content) ? content.flatMap((b) => (b?.type === 'text' && typeof b.text === 'string' ? [b.text] : [])).join(' ') : '';
17
+ // Text as it was pasted at the prompt, which Claude Code records wrapped in a tag naming the paste.
18
+ const unpasted = (text) => text.replace(/<pasted_content id="([^"]*)">\n*([\s\S]*?)\n*<\/pasted_content id="\1">/g, '$2');
19
+ const XML_ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
20
+ export const tagText = (text, tag) => new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text)?.[1].replace(/&(?:amp|lt|gt|quot|apos);/g, (e) => XML_ENTITIES[e]).trim() ?? '';
21
+ const INTERRUPTED = '[Request interrupted';
22
+ const COMMAND = /<command-name>([^<]*)<\/command-name>/;
23
+ const COMMAND_OUTPUT = /^<local-command-std(?:out|err)>([\s\S]*?)(?:<\/local-command-std(?:out|err)>)?$/;
24
+ function commandOf(text, local) {
25
+ const command = COMMAND.exec(text);
26
+ if (command)
27
+ return { k: 'claude:command', name: command[1].trim(), args: tagText(text, 'command-args'), local };
28
+ const output = COMMAND_OUTPUT.exec(text);
29
+ return output ? { k: 'claude:commandOutput', text: output[1], local } : null;
30
+ }
31
+ // Teammates' messages, which older Claude Code records with no sender: it wraps them in this envelope instead.
32
+ const TEAMMATES = 'Another Claude session sent a message:\n<teammate-message ';
33
+ const teammates = (text) => [...text.matchAll(/<teammate-message\s[^>]*?teammate_id="([^"]*)"[^>]*>([\s\S]*?)<\/teammate-message>/g)].map(([, from, body]) => ({ from, body: body.trim() }));
34
+ // What the person's own record says, once it is known to be theirs.
35
+ function personSaid(r) {
36
+ const content = r.message?.content;
37
+ if (r.isCompactSummary)
38
+ return { k: 'claude:compacted' };
39
+ if (Array.isArray(content) && content.some((b) => b?.type === 'tool_result')) {
40
+ const results = content
41
+ .filter((b) => b?.type === 'tool_result' && typeof b.tool_use_id === 'string')
42
+ .map((b) => ({
43
+ id: b.tool_use_id,
44
+ text: blocksText(b.content),
45
+ error: b.is_error === true,
46
+ image: Array.isArray(b.content) && b.content.some((c) => c?.type === 'image'),
47
+ }));
48
+ return { k: 'claude:results', results, detail: r.toolUseResult };
49
+ }
50
+ const text = unpasted(blocksText(content)).trim();
51
+ if (text.startsWith(INTERRUPTED))
52
+ return { k: 'claude:interrupted' };
53
+ const command = commandOf(text, false);
54
+ if (command)
55
+ return command;
56
+ if (text.startsWith('<bash-input>'))
57
+ return { k: 'claude:shell', command: tagText(text, 'bash-input') };
58
+ if (text.startsWith('<bash-stdout>'))
59
+ return { k: 'claude:shellOutput', text: [tagText(text, 'bash-stdout'), tagText(text, 'bash-stderr')].filter(Boolean).join('\n') };
60
+ return { k: 'claude:prompt', text, words: unpasted(typedText(content)) };
61
+ }
62
+ // A user record: the person's, or one Claude Code records who sent. Older versions record nobody, and then it
63
+ // is the person's, unless it is teammates' messages in their envelope.
64
+ function userEvent(r) {
65
+ const origin = object(r.origin);
66
+ if (!origin) {
67
+ const text = blocksText(r.message?.content).trim();
68
+ if (text.startsWith(TEAMMATES))
69
+ return { k: 'claude:teammates', messages: teammates(text) };
70
+ return r.isMeta ? { k: 'claude:noted' } : personSaid(r);
71
+ }
72
+ if (origin.kind === 'peer') {
73
+ const pid = origin.verifiedPeerPid;
74
+ return { k: 'claude:peer', from: str(origin.name), body: str(origin.body), ...(Number.isSafeInteger(pid) && { pid: pid }) };
75
+ }
76
+ if (r.isMeta)
77
+ return { k: 'claude:noted' };
78
+ if (origin.kind === 'human')
79
+ return personSaid(r);
80
+ const text = blocksText(r.message?.content).trim();
81
+ return origin.kind === 'task-notification' ? { k: 'claude:taskNotification', text } : { k: 'claude:fromOther', text };
82
+ }
83
+ function replyEvent(r) {
84
+ const content = r.message?.content;
85
+ if (r.isApiErrorMessage)
86
+ return { k: 'claude:apiError', limit: r.error === 'rate_limit', text: blocksText(content) };
87
+ const blocks = (Array.isArray(content) ? content : []).flatMap((b) => {
88
+ if (b?.type === 'text' && typeof b.text === 'string')
89
+ return [{ type: 'text', text: b.text }];
90
+ if (b?.type === 'tool_use' && typeof b.id === 'string')
91
+ return [{ type: 'tool_use', id: b.id, name: str(b.name), input: object(b.input) ?? {} }];
92
+ return [];
93
+ });
94
+ return { k: 'claude:reply', blocks };
95
+ }
96
+ function systemEvent(r) {
97
+ if (r.subtype === 'stop_hook_summary')
98
+ return { k: 'claude:turnOver' };
99
+ if (r.subtype === 'compact_boundary')
100
+ return { k: 'claude:compactBoundary', manual: object(r.compactMetadata)?.trigger === 'manual' };
101
+ if (r.subtype === 'local_command')
102
+ return commandOf(str(r.content).trim(), true) ?? { k: 'claude:other' };
103
+ return { k: 'claude:other' };
104
+ }
105
+ // A message the person sent while Claude worked, which Claude took in the middle of its turn.
106
+ function midTurnEvent(r) {
107
+ const a = object(r.attachment);
108
+ if (a?.type !== 'queued_command' || a.isMeta)
109
+ return { k: 'claude:other' };
110
+ const origin = object(a.origin);
111
+ if (origin?.kind === 'task-notification' || a.commandMode === 'task-notification')
112
+ return { k: 'claude:midTurnTaskEnd', text: blocksText(a.prompt) };
113
+ if ((a.commandMode ?? 'prompt') !== 'prompt' || (origin && origin.kind !== 'human'))
114
+ return { k: 'claude:other' };
115
+ return { k: 'claude:midTurnPrompt', text: unpasted(blocksText(a.prompt)).trim() };
116
+ }
117
+ const QUEUE_OPS = ['enqueue', 'dequeue', 'remove', 'popAll'];
118
+ function kindOf(r) {
119
+ if (r.isSidechain)
120
+ return { k: 'claude:sidechain' };
121
+ switch (r.type) {
122
+ case 'user':
123
+ return object(r.message) ? userEvent(r) : { k: 'claude:other' };
124
+ case 'assistant':
125
+ return object(r.message) ? replyEvent(r) : { k: 'claude:other' };
126
+ case 'system':
127
+ return systemEvent(r);
128
+ case 'attachment':
129
+ return midTurnEvent(r);
130
+ case 'queue-operation': {
131
+ const op = QUEUE_OPS.find((o) => o === r.operation);
132
+ if (!op)
133
+ return { k: 'claude:other' };
134
+ return typeof r.content === 'string' ? { k: 'claude:queue', op, text: r.content } : { k: 'claude:queue', op };
135
+ }
136
+ case 'custom-title':
137
+ return { k: 'claude:title', by: 'person', text: str(r.customTitle) };
138
+ case 'ai-title':
139
+ return { k: 'claude:title', by: 'claude', text: str(r.aiTitle) };
140
+ case 'last-prompt':
141
+ return { k: 'claude:lastPrompt', text: str(r.lastPrompt) };
142
+ default:
143
+ return { k: 'claude:other' };
144
+ }
145
+ }
146
+ export function eventOf(v) {
147
+ const r = object(v);
148
+ if (!r)
149
+ return { k: 'claude:other' };
150
+ const event = kindOf(r);
151
+ return typeof r.timestamp === 'string' ? { ...event, at: r.timestamp } : event;
152
+ }
153
+ // What a record says beside its kind: the folder it was written in, and whether a program rather than a person
154
+ // ran the conversation.
155
+ export function envelopeOf(v) {
156
+ const r = object(v);
157
+ return { cwd: typeof r?.cwd === 'string' && r.cwd ? r.cwd : null, automated: r?.entrypoint === 'sdk-cli' };
158
+ }
@@ -1,5 +1,5 @@
1
- import { readdir, open, stat } from 'node:fs/promises';
2
- import { join } from 'node:path';
1
+ import { readdir, readFile, open, stat } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
3
  import { chatItems } from './chat.js';
4
4
  // A session's transcript file read for the chat: from where the phone's copy ends, back from where it
5
5
  // starts, its last reply, and where it is.
@@ -8,9 +8,11 @@ const MAX_READ = 4 * 1024 * 1024;
8
8
  // About a screenful of turns many times over, little enough to come at once on a slow phone.
9
9
  const PAGE_TURNS = 100;
10
10
  const CHUNK = 256 * 1024;
11
- function lineItems(l) {
11
+ // sidechain: the file is a sub-agent's, whose records are all marked as not its parent's, and are its own here.
12
+ function lineItems(l, sidechain = false) {
12
13
  try {
13
- return chatItems(JSON.parse(l));
14
+ const r = JSON.parse(l);
15
+ return chatItems(sidechain && r && typeof r === 'object' ? { ...r, isSidechain: false } : r);
14
16
  }
15
17
  catch {
16
18
  return [];
@@ -65,13 +67,17 @@ export async function linesFrom(path, from, maxRead = MAX_READ) {
65
67
  export async function readChat(path, from, o = {}) {
66
68
  const size = (await stat(path)).size;
67
69
  if (from === 0 || from > size)
68
- return readChatBefore(path, size, { turns: o.turns });
70
+ return readChatBefore(path, size, { turns: o.turns, sidechain: o.sidechain });
69
71
  const fh = await open(path, 'r');
70
72
  try {
71
73
  const buf = Buffer.alloc(Math.min(size - from, o.maxRead ?? MAX_READ));
72
74
  await fh.read(buf, 0, buf.length, from);
73
75
  const end = buf.lastIndexOf(0x0a) + 1;
74
- const items = buf.subarray(0, end).toString('utf8').split('\n').flatMap(lineItems);
76
+ const items = buf
77
+ .subarray(0, end)
78
+ .toString('utf8')
79
+ .split('\n')
80
+ .flatMap((l) => lineItems(l, o.sidechain));
75
81
  return { items, from, next: from + end, skipped: false };
76
82
  }
77
83
  finally {
@@ -133,7 +139,7 @@ export async function readChatBefore(path, before, o = {}) {
133
139
  next ??= start + line.length + 1;
134
140
  if (count >= turns || (from !== undefined && next - start > maxRead))
135
141
  break;
136
- const items = lineItems(line.toString('utf8'));
142
+ const items = lineItems(line.toString('utf8'), o.sidechain);
137
143
  taken.push(items);
138
144
  count += items.filter((i) => i.k !== 'result').length;
139
145
  from = start;
@@ -147,11 +153,17 @@ export async function readChatBefore(path, before, o = {}) {
147
153
  }
148
154
  const REPLY_TAIL = 256 * 1024;
149
155
  const REPLY_CHARS = 120;
150
- // What Claude said last, or the error its turn ended in, as a line of plain words for a list to show under the session's name: the
151
- // Markdown it writes in is dropped. Only the end of the transcript is read; null when nothing is there.
152
- export async function lastReply(path, tail = REPLY_TAIL) {
156
+ // What a list shows of a conversation, from the end of its transcript. reply: what Claude said last, or the
157
+ // error its turn ended in, as a line of plain words (the Markdown it writes in is dropped). at: when the last
158
+ // thing in it happened. Each null when nothing is there.
159
+ export async function lastSaid(path, tail = REPLY_TAIL) {
153
160
  const size = (await stat(path)).size;
154
161
  const { items } = await readChat(path, Math.max(0, size - tail));
162
+ const dated = items.findLast((i) => 'at' in i && i.at);
163
+ const at = dated && 'at' in dated && dated.at ? Date.parse(dated.at) : NaN;
164
+ return { reply: replyOf(items), at: Number.isFinite(at) ? at : null };
165
+ }
166
+ function replyOf(items) {
155
167
  const last = items.findLast((i) => i.k === 'text' || (i.k === 'note' && 'event' in i && i.event.e === 'failed'));
156
168
  if (!last)
157
169
  return null;
@@ -192,3 +204,31 @@ export async function findTranscript(root, sessionId) {
192
204
  }
193
205
  return null;
194
206
  }
207
+ // A sub-agent a conversation started, by the id of the call that started it: its transcript, beside
208
+ // <session id>/subagents/agent-<id>.meta.json, which names the call, under the conversation's own folder.
209
+ export async function findSubagent(root, sessionId, toolUseId) {
210
+ const parent = await findTranscript(root, sessionId);
211
+ if (!parent)
212
+ return null;
213
+ const dir = join(dirname(parent), sessionId, 'subagents');
214
+ let names;
215
+ try {
216
+ names = await readdir(dir);
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ for (const n of names.filter((n) => /^agent-[A-Za-z0-9]+\.meta\.json$/.test(n))) {
222
+ try {
223
+ const meta = JSON.parse(await readFile(join(dir, n), 'utf8'));
224
+ if (!meta || typeof meta !== 'object' || meta.toolUseId !== toolUseId)
225
+ continue;
226
+ const description = meta.description;
227
+ return { path: join(dir, n.replace(/\.meta\.json$/, '.jsonl')), description: typeof description === 'string' ? description : '' };
228
+ }
229
+ catch {
230
+ // being written, or not a sub-agent's
231
+ }
232
+ }
233
+ return null;
234
+ }
@@ -1,6 +1,9 @@
1
1
  import { open, readdir, readFile, stat } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
+ import { envelopeOf, eventOf } from './events.js';
5
+ import { unhandled } from '../../events.js';
6
+ import { findTranscript } from './transcriptFile.js';
4
7
  const HEAD_BYTES = 256 * 1024;
5
8
  const TAIL_BYTES = 64 * 1024;
6
9
  const TITLE_MAX = 60;
@@ -21,21 +24,53 @@ export function listRecentClaudeSessions(root, limit = 50) {
21
24
  async function listRecent(root, limit) {
22
25
  const files = await transcriptFiles(root);
23
26
  files.sort((a, b) => b.mtimeMs - a.mtimeMs);
24
- const out = [];
25
- const count = { people: 0, programs: 0 };
27
+ const kept = { people: [], programs: [] };
28
+ // Nothing was said in a file after it was last written, so once a file was written before the limit-th
29
+ // latest message of each kind, no file after it can be among them.
30
+ const latest = (list) => (list.length < limit ? -Infinity : list[limit - 1].lastActivity);
26
31
  for (const f of files.slice(0, limit * 6)) {
27
- if (count.people >= limit && count.programs >= limit)
32
+ if (f.mtimeMs < latest(kept.people) && f.mtimeMs < latest(kept.programs))
28
33
  break;
29
- const parsed = await parsedOnce(f);
30
- if (!parsed || isScratch(parsed.cwd))
31
- continue;
32
- const kind = parsed.automated ? 'programs' : 'people';
33
- if (count[kind] >= limit)
34
+ const r = await rowOf(f);
35
+ if (!r || isScratch(r.cwd))
34
36
  continue;
35
- count[kind]++;
36
- out.push({ sessionId: basename(f.path, '.jsonl'), ...parsed, lastActivity: Math.round(f.mtimeMs) });
37
+ const list = kept[r.automated ? 'programs' : 'people'];
38
+ list.push(r);
39
+ list.sort((a, b) => b.lastActivity - a.lastActivity);
40
+ list.splice(limit);
37
41
  }
38
- return out;
42
+ return [...kept.people, ...kept.programs].sort((a, b) => b.lastActivity - a.lastActivity);
43
+ }
44
+ // Every conversation Claude Code still keeps, newest first, programs' too. Asked again while a listing runs, the
45
+ // one running answers.
46
+ let listingAll = null;
47
+ export function listAllClaudeSessions(root) {
48
+ listingAll ??= listAll(root).finally(() => {
49
+ listingAll = null;
50
+ });
51
+ return listingAll;
52
+ }
53
+ async function listAll(root) {
54
+ const all = [];
55
+ for (const f of await transcriptFiles(root)) {
56
+ const r = await rowOf(f);
57
+ if (r && !isScratch(r.cwd))
58
+ all.push(r);
59
+ }
60
+ return all.sort((a, b) => b.lastActivity - a.lastActivity);
61
+ }
62
+ // One conversation Claude Code still keeps, as the lists have it, however old; null when it keeps none by that id.
63
+ export async function oneClaudeSession(root, sessionId) {
64
+ const path = await findTranscript(root, sessionId);
65
+ const st = path ? await stat(path).catch(() => null) : null;
66
+ return path && st ? rowOf({ path, size: st.size, mtimeMs: st.mtimeMs }) : null;
67
+ }
68
+ async function rowOf(f) {
69
+ const parsed = await parsedOnce(f);
70
+ if (!parsed)
71
+ return null;
72
+ const { saidAt, ...shown } = parsed;
73
+ return { sessionId: basename(f.path, '.jsonl'), ...shown, lastActivity: saidAt ?? Math.round(f.mtimeMs) };
39
74
  }
40
75
  // A conversation in a temporary folder is a throwaway run (a test, a probe), not work to come back to.
41
76
  const SCRATCH_ROOTS = ['/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/', join(tmpdir(), '/')];
@@ -89,7 +124,7 @@ async function transcriptFiles(root) {
89
124
  // What a transcript was read as, kept while the file stays as it was: the list is asked for often, and
90
125
  // reading the head and tail of hundreds of files each time took seconds.
91
126
  const parsedCache = new Map();
92
- const PARSED_KEPT = 2000;
127
+ const PARSED_KEPT = 20_000;
93
128
  async function parsedOnce(f) {
94
129
  const kept = parsedCache.get(f.path);
95
130
  if (kept && kept.size === f.size && kept.mtimeMs === f.mtimeMs)
@@ -104,34 +139,92 @@ async function parsedOnce(f) {
104
139
  }
105
140
  return parsed;
106
141
  }
142
+ // saidAt: when the last message was said, by the person or Claude; null when no message carries a time.
107
143
  async function parseTranscript(path, size) {
108
144
  const records = await readRecords(path, size);
109
145
  if (!records.length)
110
146
  return null;
111
- let cwd = null;
147
+ const cwds = [];
112
148
  let customTitle = null;
113
149
  let aiTitle = null;
114
150
  let firstPrompt = null;
115
151
  let lastPrompt = null;
116
152
  let automated = false;
153
+ let saidAt = null;
117
154
  for (const r of records) {
118
- if (!cwd && typeof r.cwd === 'string' && r.cwd)
119
- cwd = r.cwd;
120
- if (r.entrypoint === 'sdk-cli')
155
+ const e = eventOf(r);
156
+ const envelope = envelopeOf(r);
157
+ if (envelope.cwd)
158
+ cwds.push(envelope.cwd);
159
+ if (envelope.automated)
121
160
  automated = true;
122
- if (r.type === 'custom-title' && typeof r.customTitle === 'string' && r.customTitle.trim())
123
- customTitle = truncate(r.customTitle.replace(/\s+/g, ' ').trim());
124
- if (r.type === 'ai-title' && typeof r.aiTitle === 'string' && r.aiTitle.trim())
125
- aiTitle = r.aiTitle.trim();
126
- const said = r.type === 'user' && !r.isMeta && !r.isCompactSummary ? promptText(r.message?.content) : r.type === 'last-prompt' ? promptText(r.lastPrompt) : null;
161
+ if (e.at && isSaid(e)) {
162
+ const at = Date.parse(e.at);
163
+ if (Number.isFinite(at) && at > (saidAt ?? -Infinity))
164
+ saidAt = at;
165
+ }
166
+ if (e.k === 'claude:title' && e.text.trim()) {
167
+ if (e.by === 'person')
168
+ customTitle = truncate(e.text.replace(/\s+/g, ' ').trim());
169
+ else
170
+ aiTitle = e.text.trim();
171
+ }
172
+ const said = e.k === 'claude:prompt' ? promptText(e.words) : e.k === 'claude:lastPrompt' ? promptText(e.text) : null;
127
173
  if (said)
128
174
  lastPrompt = said;
129
- if (!firstPrompt && r.type === 'user')
130
- firstPrompt = promptText(r.message?.content);
175
+ if (!firstPrompt && e.k === 'claude:prompt')
176
+ firstPrompt = said;
131
177
  }
132
178
  const title = customTitle ?? aiTitle ?? (firstPrompt ? truncate(firstPrompt) : basename(path, '.jsonl').slice(0, 8));
133
- return { cwd, title, lastPrompt: lastPrompt && truncate(lastPrompt, LAST_PROMPT_MAX), automated };
179
+ return { cwd: conversationFolder(cwds), title, lastPrompt: lastPrompt && truncate(lastPrompt, LAST_PROMPT_MAX), automated, saidAt };
180
+ }
181
+ // Whether an event is something said, by the person or Claude, rather than a note or a record of Claude Code's.
182
+ function isSaid(e) {
183
+ switch (e.k) {
184
+ case 'claude:prompt':
185
+ case 'claude:shell':
186
+ case 'claude:shellOutput':
187
+ case 'claude:results':
188
+ case 'claude:interrupted':
189
+ case 'claude:compacted':
190
+ case 'claude:teammates':
191
+ case 'claude:taskNotification':
192
+ case 'claude:fromOther':
193
+ case 'claude:reply':
194
+ case 'claude:apiError':
195
+ return true;
196
+ case 'claude:command':
197
+ case 'claude:commandOutput':
198
+ return !e.local;
199
+ case 'claude:noted':
200
+ case 'claude:peer':
201
+ case 'claude:compactBoundary':
202
+ case 'claude:turnOver':
203
+ case 'claude:queue':
204
+ case 'claude:midTurnPrompt':
205
+ case 'claude:midTurnTaskEnd':
206
+ case 'claude:title':
207
+ case 'claude:lastPrompt':
208
+ case 'claude:sidechain':
209
+ case 'claude:other':
210
+ return false;
211
+ default:
212
+ return unhandled(e);
213
+ }
214
+ }
215
+ // The folder a conversation last ran in, from the folders its records were written in, oldest first. Within a
216
+ // run each is the run's folder or one inside it (a cd); one that is neither starts an earlier run elsewhere.
217
+ function conversationFolder(cwds) {
218
+ let folder = null;
219
+ for (const cwd of cwds.toReversed()) {
220
+ if (folder === null || within(folder, cwd))
221
+ folder = cwd;
222
+ else if (!within(cwd, folder))
223
+ break;
224
+ }
225
+ return folder;
134
226
  }
227
+ const within = (inner, outer) => inner === outer || inner.startsWith(outer.endsWith('/') ? outer : `${outer}/`);
135
228
  async function readRecords(path, size) {
136
229
  const fh = await open(path, 'r');
137
230
  try {
@@ -166,12 +259,7 @@ function parseLines(text) {
166
259
  }
167
260
  return out;
168
261
  }
169
- function promptText(content) {
170
- const raw = typeof content === 'string'
171
- ? content
172
- : Array.isArray(content)
173
- ? content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join(' ')
174
- : '';
262
+ function promptText(raw) {
175
263
  // An agent runner's wake names its issue on a line of its own; that, not its template, says what it is.
176
264
  const issue = /^- issue:\s*(.+)$/m.exec(raw)?.[1];
177
265
  const text = (issue ?? raw.replace(/^#{1,6}\s+/gm, '')).replace(/\s+/g, ' ').trim();
@@ -1,100 +1,69 @@
1
1
  import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ import { turnSays } from '../../turnSays.js';
3
+ import { eventOf } from './events.js';
2
4
  // What Claude Code's transcript says of the turn it is on, read from its end: whether the turn is over, and
3
5
  // what Claude still holds in its own queue. A message typed while it works waits there (enqueue) until it is
4
6
  // taken into the turn under way (remove), starts a turn of its own (dequeue) or is pulled back into the
5
7
  // prompt to be changed (popAll).
6
8
  // The end of the transcript read for it: what was typed long before was taken long ago.
7
9
  const TAIL_BYTES = 256 * 1024;
8
- const INTERRUPTED = '[Request interrupted';
9
- const COMMAND = /<command-name>/;
10
- const COMMAND_OUTPUT = /^<local-command-std(out|err)>/;
11
- // The entries of the lines, but for commands Claude Code ran on its own (/model, /effort): each is written as
12
- // the command and its output, and is no part of any turn. A command whose prompt goes to Claude (/review)
13
- // has no output of its own and stays, as the prompt of its turn.
14
- function entries(lines, wanted) {
10
+ // The events of the lines, but for commands Claude Code ran on its own at the prompt (/model, /effort): each
11
+ // is the command and what it printed, and no part of any turn. A command whose prompt goes to Claude (/review)
12
+ // prints nothing of its own and stays, as the prompt of its turn.
13
+ function events(lines, wanted) {
15
14
  const out = [];
16
15
  for (const line of lines) {
17
16
  if (!wanted(line))
18
17
  continue;
18
+ let v;
19
19
  try {
20
- const e = JSON.parse(line);
21
- if (!e || typeof e !== 'object')
22
- continue;
23
- if (userSays(e, COMMAND_OUTPUT)) {
24
- const command = out.findLastIndex((x) => userSays(x, COMMAND));
25
- if (command >= 0)
26
- out.splice(command, 1);
27
- continue;
28
- }
29
- out.push(e);
20
+ v = JSON.parse(line);
30
21
  }
31
22
  catch {
32
- // a line cut off by the end of what was read, or one being written
23
+ continue; // a line cut off by the end of what was read, or one being written
33
24
  }
25
+ const e = eventOf(v);
26
+ if (e.k === 'claude:commandOutput' && !e.local) {
27
+ const command = out.findLastIndex((x) => x.k === 'claude:command' && !x.local);
28
+ if (command >= 0)
29
+ out.splice(command, 1);
30
+ continue;
31
+ }
32
+ out.push(e);
34
33
  }
35
34
  return out;
36
35
  }
37
- const texts = (content) => typeof content === 'string'
38
- ? [content]
39
- : Array.isArray(content)
40
- ? content.flatMap((b) => (b?.type === 'text' && typeof b.text === 'string' ? [b.text] : []))
41
- : [];
42
- const userSays = (e, text) => e.type === 'user' && texts(e.message?.content).some((t) => text.test(t));
43
36
  // The messages Claude still holds, oldest first, by the queue operations since the last turn began: what
44
37
  // was held before then was taken by then.
45
38
  export function heldByClaude(lines) {
46
39
  const held = [];
47
- for (const e of entries(lines, (l) => l.includes('"queue-operation"') || l.includes('"user"'))) {
48
- if (startsTurn(e))
40
+ for (const e of events(lines, (l) => l.includes('"queue-operation"') || l.includes('"user"'))) {
41
+ if (turnSays(e) === 'starts')
49
42
  held.length = 0;
50
- if (e.type !== 'queue-operation')
43
+ if (e.k !== 'claude:queue')
51
44
  continue;
52
- if (e.operation === 'enqueue')
53
- held.push(typeof e.content === 'string' ? e.content : '');
54
- else if (e.operation === 'dequeue')
45
+ if (e.op === 'enqueue')
46
+ held.push(e.text ?? '');
47
+ else if (e.op === 'dequeue')
55
48
  held.shift();
56
- else if (e.operation === 'popAll')
49
+ else if (e.op === 'popAll')
57
50
  held.length = 0;
58
- else if (e.operation === 'remove') {
59
- const i = held.indexOf(e.content);
51
+ else {
52
+ const i = e.text === undefined ? -1 : held.indexOf(e.text);
60
53
  held.splice(i < 0 ? 0 : i, 1);
61
54
  }
62
55
  }
63
56
  return held;
64
57
  }
65
- // A prompt: what a turn begins with, as against the answer to a call, a note or Esc.
66
- function startsTurn(e) {
67
- if (e.type !== 'user' || e.isMeta === true)
68
- return false;
69
- const c = e.message?.content;
70
- if (Array.isArray(c) && c.every((b) => b?.type === 'tool_result'))
71
- return false;
72
- return !texts(c).some((t) => t.startsWith(INTERRUPTED));
73
- }
74
- // What an entry says of the turn: that it ended there, that the turn goes on, or nothing.
75
- function said(e) {
76
- if (e.type === 'system')
77
- return e.subtype === 'stop_hook_summary' ? 'over' : null;
78
- if (e.type === 'queue-operation')
79
- return e.operation === 'dequeue' ? 'going' : null;
80
- if (e.type === 'assistant')
81
- return e.isApiErrorMessage === true ? 'over' : 'going';
82
- if (e.type === 'user') {
83
- if (e.isMeta === true)
84
- return null;
85
- return texts(e.message?.content).some((t) => t.startsWith(INTERRUPTED)) ? 'over' : 'going';
86
- }
87
- return null;
88
- }
89
58
  // Whether the turn the lines end in is over, and when it ended; null when they hold no turn.
90
59
  export function lastTurn(lines) {
91
- const all = entries(lines, () => true);
60
+ const all = events(lines, () => true);
92
61
  for (let i = all.length - 1; i >= 0; i--) {
93
- const s = said(all[i]);
94
- if (s === 'going')
62
+ const s = turnSays(all[i]);
63
+ if (s === 'starts' || s === 'going')
95
64
  return { over: false };
96
65
  if (s === 'over') {
97
- const at = typeof all[i].timestamp === 'string' ? Date.parse(all[i].timestamp) : NaN;
66
+ const at = Date.parse(all[i].at ?? '');
98
67
  return { over: true, at: Number.isFinite(at) ? at : 0 };
99
68
  }
100
69
  }
@@ -120,11 +89,21 @@ export function transcriptTail(path) {
120
89
  closeSync(fd);
121
90
  }
122
91
  }
92
+ // The messages pulled back out of Claude's queue into its prompt at the Mac, each with when: from there they are
93
+ // the person's to change, send or drop, so none of them goes on as it was sent.
94
+ function pulledBack(lines) {
95
+ return events(lines, (l) => l.includes('"popAll"')).flatMap((e) => e.k === 'claude:queue' && e.op === 'popAll' && e.text !== undefined ? [{ text: e.text, at: Date.parse(String(e.at)) }] : []);
96
+ }
123
97
  // heldByClaude for a transcript on disk; none when it cannot be read.
124
98
  export function heldInTranscript(path) {
125
99
  const lines = transcriptTail(path);
126
100
  return lines ? heldByClaude(lines) : [];
127
101
  }
102
+ // Claude's queue in a transcript on disk: what it holds, and what was pulled back out of it.
103
+ export function queueInTranscript(path) {
104
+ const lines = transcriptTail(path) ?? [];
105
+ return { held: heldByClaude(lines), pulled: pulledBack(lines) };
106
+ }
128
107
  // lastTurn for a transcript on disk, where a turn that ended with a message still in Claude's own queue goes
129
108
  // on with it; null when it cannot be read.
130
109
  export function turnInTranscript(path) {
@@ -64,8 +64,12 @@ export class CodexAgent {
64
64
  // The threads people had on this Mac, newest first, as the app server lists them: it reads every session file
65
65
  // to answer, which takes seconds.
66
66
  async listThreads(params) {
67
- const { data } = await (await this.link()).request('thread/list', params, LIST_MS);
68
- return Array.isArray(data) ? data : [];
67
+ return (await this.listThreadPage(params)).data;
68
+ }
69
+ // One page of threads, and the cursor of the next; null when it is the last.
70
+ async listThreadPage(params) {
71
+ const { data, nextCursor } = await (await this.link()).request('thread/list', params, LIST_MS);
72
+ return { data: Array.isArray(data) ? data : [], next: typeof nextCursor === 'string' && nextCursor ? nextCursor : null };
69
73
  }
70
74
  // One thread's metadata, read without loading it; undefined when there is no such thread.
71
75
  async readThread(threadId) {
@@ -123,6 +127,9 @@ export class CodexAgent {
123
127
  throw e;
124
128
  }
125
129
  }
130
+ async rename(threadId, name) {
131
+ await (await this.link()).request('thread/name/set', { threadId, name });
132
+ }
126
133
  close() {
127
134
  this.closed = true;
128
135
  this.rpc?.close();