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.
- package/CHANGELOG.md +27 -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/http/sessionSocket.js +12 -11
- 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-DinzDcF1.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-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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
151
|
-
//
|
|
152
|
-
|
|
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
|
|
25
|
-
|
|
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 (
|
|
32
|
+
if (f.mtimeMs < latest(kept.people) && f.mtimeMs < latest(kept.programs))
|
|
28
33
|
break;
|
|
29
|
-
const
|
|
30
|
-
if (!
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
if (
|
|
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 (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 &&
|
|
130
|
-
firstPrompt =
|
|
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(
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
if (
|
|
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.
|
|
43
|
+
if (e.k !== 'claude:queue')
|
|
51
44
|
continue;
|
|
52
|
-
if (e.
|
|
53
|
-
held.push(
|
|
54
|
-
else if (e.
|
|
45
|
+
if (e.op === 'enqueue')
|
|
46
|
+
held.push(e.text ?? '');
|
|
47
|
+
else if (e.op === 'dequeue')
|
|
55
48
|
held.shift();
|
|
56
|
-
else if (e.
|
|
49
|
+
else if (e.op === 'popAll')
|
|
57
50
|
held.length = 0;
|
|
58
|
-
else
|
|
59
|
-
const i = held.indexOf(e.
|
|
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 =
|
|
60
|
+
const all = events(lines, () => true);
|
|
92
61
|
for (let i = all.length - 1; i >= 0; i--) {
|
|
93
|
-
const s =
|
|
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 =
|
|
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
|
-
|
|
68
|
-
|
|
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();
|