workoffdesk 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { isOwnHookCommand } from '../hooks/hooks.js';
|
|
3
|
+
const SUMMARY_MAX = 200;
|
|
4
|
+
const DIFF_LINES_MAX = 300;
|
|
5
|
+
const DIFF_LINE_MAX = 400;
|
|
6
|
+
const RESULT_MAX = 2000;
|
|
7
|
+
// What Claude Code records as the answer of a call it was stopped before running.
|
|
8
|
+
const STOPPED = /^The user doesn't want to (proceed with this tool use|take this action)/;
|
|
9
|
+
const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}…` : s);
|
|
10
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
11
|
+
// As text, without the colours and links a terminal would have drawn from it.
|
|
12
|
+
function blocksText(content) {
|
|
13
|
+
if (typeof content === 'string')
|
|
14
|
+
return stripVTControlCharacters(content);
|
|
15
|
+
if (!Array.isArray(content))
|
|
16
|
+
return '';
|
|
17
|
+
return content
|
|
18
|
+
.map((b) => (b?.type === 'text' ? stripVTControlCharacters(str(b.text)) : b?.type === 'image' ? '[image]' : ''))
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join(' ');
|
|
21
|
+
}
|
|
22
|
+
export function toolSummary(name, input) {
|
|
23
|
+
const pick = (...keys) => keys.map((k) => str(input[k])).find(Boolean);
|
|
24
|
+
const named = {
|
|
25
|
+
Bash: () => pick('description', 'command'),
|
|
26
|
+
Read: () => pick('file_path'),
|
|
27
|
+
Edit: () => pick('file_path'),
|
|
28
|
+
Write: () => pick('file_path'),
|
|
29
|
+
NotebookEdit: () => pick('notebook_path'),
|
|
30
|
+
Grep: () => pick('pattern'),
|
|
31
|
+
Glob: () => pick('pattern'),
|
|
32
|
+
WebFetch: () => pick('url'),
|
|
33
|
+
WebSearch: () => pick('query'),
|
|
34
|
+
Agent: () => pick('description', 'prompt'),
|
|
35
|
+
Task: () => pick('description', 'prompt'),
|
|
36
|
+
}[name];
|
|
37
|
+
const s = named?.() ?? Object.values(input).map(str).find(Boolean) ?? '';
|
|
38
|
+
return clip(s.split('\n')[0], SUMMARY_MAX);
|
|
39
|
+
}
|
|
40
|
+
// What Claude Code prints after a command for each hook that ran, when the hook is workoffdesk's own.
|
|
41
|
+
const ownHookLine = (line) => {
|
|
42
|
+
const ran = /^\w+ \[(.*)\] completed successfully$/.exec(line);
|
|
43
|
+
return ran !== null && isOwnHookCommand(ran[1]);
|
|
44
|
+
};
|
|
45
|
+
// A task TaskCreate made: Claude Code gives it its id only in the result.
|
|
46
|
+
function taskMade(v) {
|
|
47
|
+
const task = v && typeof v === 'object' ? v.task : null;
|
|
48
|
+
if (!task || typeof task !== 'object')
|
|
49
|
+
return null;
|
|
50
|
+
const t = task;
|
|
51
|
+
return typeof t.id === 'string' && typeof t.subject === 'string' ? { k: 'task', id: t.id, subject: t.subject, status: 'pending' } : null;
|
|
52
|
+
}
|
|
53
|
+
// A command Claude ran in the background, or one the user sent there while it ran.
|
|
54
|
+
function backgroundStarted(v, tool) {
|
|
55
|
+
const id = v && typeof v === 'object' ? v.backgroundTaskId : null;
|
|
56
|
+
return typeof id === 'string' ? [{ k: 'bg', id, tool, status: 'running' }] : [];
|
|
57
|
+
}
|
|
58
|
+
// A change to the task list, from what Claude asked for.
|
|
59
|
+
function taskItems(name, input) {
|
|
60
|
+
if (name === 'TaskUpdate' && typeof input.taskId === 'string') {
|
|
61
|
+
const item = { k: 'task', id: input.taskId };
|
|
62
|
+
if (typeof input.subject === 'string')
|
|
63
|
+
item.subject = input.subject;
|
|
64
|
+
if (typeof input.status === 'string')
|
|
65
|
+
item.status = input.status;
|
|
66
|
+
return [item];
|
|
67
|
+
}
|
|
68
|
+
const stopped = name === 'TaskStop' ? input.task_id : name === 'KillShell' || name === 'KillBash' ? input.shell_id : undefined;
|
|
69
|
+
if (typeof stopped === 'string')
|
|
70
|
+
return [{ k: 'bg', id: stopped, status: 'killed' }];
|
|
71
|
+
if (name === 'TodoWrite' && Array.isArray(input.todos)) {
|
|
72
|
+
const items = input.todos.flatMap((t) => t && typeof t === 'object' && typeof t.content === 'string' && typeof t.status === 'string' ? [{ subject: t.content, status: t.status }] : []);
|
|
73
|
+
return [{ k: 'todos', items }];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
// Claude Code records what a file tool changed beside its result, for the record's one result.
|
|
78
|
+
function fileChange(v) {
|
|
79
|
+
if (!v || typeof v !== 'object')
|
|
80
|
+
return null;
|
|
81
|
+
const t = v;
|
|
82
|
+
if (typeof t.filePath !== 'string' || !t.filePath)
|
|
83
|
+
return null;
|
|
84
|
+
const created = t.type === 'create' && typeof t.content === 'string';
|
|
85
|
+
const hunks = created
|
|
86
|
+
? [{ oldStart: 0, newStart: 1, lines: t.content.replace(/\n$/, '').split('\n').map((l) => `+${l}`) }]
|
|
87
|
+
: (Array.isArray(t.structuredPatch) ? t.structuredPatch : []).flatMap((h) => h && typeof h === 'object' && Array.isArray(h.lines) && h.lines.every((l) => typeof l === 'string')
|
|
88
|
+
? [{ oldStart: Number(h.oldStart) || 0, newStart: Number(h.newStart) || 0, lines: h.lines }]
|
|
89
|
+
: []);
|
|
90
|
+
const all = hunks.flatMap((h) => h.lines);
|
|
91
|
+
const added = all.filter((l) => l.startsWith('+')).length;
|
|
92
|
+
const removed = all.filter((l) => l.startsWith('-')).length;
|
|
93
|
+
if (!added && !removed)
|
|
94
|
+
return null;
|
|
95
|
+
let room = DIFF_LINES_MAX;
|
|
96
|
+
const kept = hunks
|
|
97
|
+
.map((h) => {
|
|
98
|
+
const lines = h.lines.slice(0, Math.max(0, room)).map((l) => clip(l, DIFF_LINE_MAX));
|
|
99
|
+
room -= lines.length;
|
|
100
|
+
return { ...h, lines };
|
|
101
|
+
})
|
|
102
|
+
.filter((h) => h.lines.length);
|
|
103
|
+
return { file: t.filePath, added, removed, ...(created ? { created: true } : {}), ...(all.length > DIFF_LINES_MAX ? { cut: true } : {}), hunks: kept };
|
|
104
|
+
}
|
|
105
|
+
function userItems(r, at) {
|
|
106
|
+
const content = r.message?.content;
|
|
107
|
+
if (r.isCompactSummary)
|
|
108
|
+
return [{ k: 'note', event: { e: 'compacted' } }];
|
|
109
|
+
if (Array.isArray(content) && content.some((b) => b?.type === 'tool_result')) {
|
|
110
|
+
const results = content.filter((b) => b?.type === 'tool_result' && typeof b.tool_use_id === 'string');
|
|
111
|
+
const diff = results.length === 1 && results[0].is_error !== true ? fileChange(r.toolUseResult) : null;
|
|
112
|
+
const made = results.length === 1 && results[0].is_error !== true ? taskMade(r.toolUseResult) : null;
|
|
113
|
+
const shown = results.map((b) => {
|
|
114
|
+
const text = blocksText(b.content);
|
|
115
|
+
if (STOPPED.test(text))
|
|
116
|
+
return { k: 'result', id: b.tool_use_id, text: '', error: false, stopped: true };
|
|
117
|
+
const item = { k: 'result', id: b.tool_use_id, text: clip(text, RESULT_MAX), error: b.is_error === true };
|
|
118
|
+
if (diff)
|
|
119
|
+
item.diff = diff;
|
|
120
|
+
return Array.isArray(b.content) && b.content.some((c) => c?.type === 'image') ? { ...item, image: true } : item;
|
|
121
|
+
});
|
|
122
|
+
const started = results.length === 1 ? backgroundStarted(r.toolUseResult, results[0].tool_use_id) : [];
|
|
123
|
+
return [...shown, ...(made ? [made] : []), ...started];
|
|
124
|
+
}
|
|
125
|
+
const text = blocksText(content).trim();
|
|
126
|
+
if (!text)
|
|
127
|
+
return [];
|
|
128
|
+
if (text.startsWith('[Request interrupted'))
|
|
129
|
+
return [{ k: 'note', event: { e: 'interrupted' }, retry: true }];
|
|
130
|
+
const command = /<command-name>([^<]*)<\/command-name>/.exec(text);
|
|
131
|
+
if (command?.[1].trim() === '/compact')
|
|
132
|
+
return [];
|
|
133
|
+
if (command) {
|
|
134
|
+
const args = /<command-args>([^<]*)<\/command-args>/.exec(text)?.[1].trim();
|
|
135
|
+
return [{ k: 'note', text: [command[1].trim(), args].filter(Boolean).join(' '), command: true, at }];
|
|
136
|
+
}
|
|
137
|
+
const stdout = /^<local-command-std(?:out|err)>([\s\S]*)<\/local-command-std(?:out|err)>$/.exec(text);
|
|
138
|
+
if (stdout) {
|
|
139
|
+
const shown = stdout[1].split('\n').filter((l) => !ownHookLine(l)).join('\n').trim();
|
|
140
|
+
return shown ? [{ k: 'note', text: clip(shown, RESULT_MAX) }] : [];
|
|
141
|
+
}
|
|
142
|
+
return [{ k: 'user', text, at }];
|
|
143
|
+
}
|
|
144
|
+
// The words a message sent to Claude has when the transcript records it as this item: a prompt's, or a
|
|
145
|
+
// command's typed at the prompt. Null for anything a message cannot be.
|
|
146
|
+
export function sentWords(item) {
|
|
147
|
+
if (item.k === 'user')
|
|
148
|
+
return item.text;
|
|
149
|
+
return item.k === 'note' && 'command' in item && item.command ? item.text : null;
|
|
150
|
+
}
|
|
151
|
+
const XML_ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
152
|
+
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() ?? '';
|
|
153
|
+
function taskNote(text) {
|
|
154
|
+
const summary = tagText(text, 'summary');
|
|
155
|
+
const what = /^Background command "([\s\S]*)" (?:completed|failed|was stopped)/.exec(summary)?.[1] ?? summary;
|
|
156
|
+
const status = tagText(text, 'status');
|
|
157
|
+
const id = tagText(text, 'task-id');
|
|
158
|
+
const note = { k: 'note', event: { e: 'task', status, what }, detail: summary };
|
|
159
|
+
return id && status ? [note, { k: 'bg', id, status }] : [note];
|
|
160
|
+
}
|
|
161
|
+
// Teammates' messages, which Claude Code records with no sender: it wraps them in this envelope instead.
|
|
162
|
+
// A body that is a JSON object is a status update between agents (idle, shutting down), for Claude alone.
|
|
163
|
+
const TEAMMATES = 'Another Claude session sent a message:\n<teammate-message ';
|
|
164
|
+
function fromTeammates(text) {
|
|
165
|
+
if (!text.startsWith(TEAMMATES))
|
|
166
|
+
return null;
|
|
167
|
+
return [...text.matchAll(/<teammate-message\s[^>]*?teammate_id="([^"]*)"[^>]*>([\s\S]*?)<\/teammate-message>/g)].flatMap(([, from, body]) => {
|
|
168
|
+
const detail = body.trim();
|
|
169
|
+
return detail && !/^\{[\s\S]*\}$/.test(detail) ? [{ k: 'note', event: { e: 'peer', from }, detail }] : [];
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// A prompt that did not come from the user: Claude Code records who sent it. Older versions record
|
|
173
|
+
// nobody, and then the prompt is the user's, unless it is a teammate's message in its envelope.
|
|
174
|
+
function sentByOther(r) {
|
|
175
|
+
const origin = r.origin && typeof r.origin === 'object' ? r.origin : null;
|
|
176
|
+
if (!origin)
|
|
177
|
+
return fromTeammates(blocksText(r.message?.content).trim());
|
|
178
|
+
if (origin.kind === 'human')
|
|
179
|
+
return null;
|
|
180
|
+
if (origin.kind === 'peer')
|
|
181
|
+
return [{ k: 'note', event: { e: 'peer', from: str(origin.name) }, detail: str(origin.body) }];
|
|
182
|
+
if (r.isMeta)
|
|
183
|
+
return [];
|
|
184
|
+
const text = blocksText(r.message?.content).trim();
|
|
185
|
+
if (origin.kind === 'task-notification')
|
|
186
|
+
return taskNote(text);
|
|
187
|
+
return text ? [{ k: 'note', text: clip(text, RESULT_MAX) }] : [];
|
|
188
|
+
}
|
|
189
|
+
// The plan Claude asks to go ahead with, shown whole rather than as a line: written into Claude Code's
|
|
190
|
+
// plans folder in plan mode, or given to ExitPlanMode itself by older versions.
|
|
191
|
+
const PLAN_FILE = /\/\.claude\/plans\/[^/]+\.md$/;
|
|
192
|
+
function planOf(name, input) {
|
|
193
|
+
if (name === 'ExitPlanMode')
|
|
194
|
+
return str(input.plan).trim();
|
|
195
|
+
if (name === 'Write' && PLAN_FILE.test(str(input.file_path)))
|
|
196
|
+
return str(input.content).trim();
|
|
197
|
+
return '';
|
|
198
|
+
}
|
|
199
|
+
function assistantItems(r, at) {
|
|
200
|
+
const content = r.message?.content;
|
|
201
|
+
if (!Array.isArray(content))
|
|
202
|
+
return [];
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const b of content) {
|
|
205
|
+
if (b?.type === 'text' && str(b.text).trim())
|
|
206
|
+
out.push({ k: 'text', text: b.text.trim(), at });
|
|
207
|
+
if (b?.type === 'tool_use' && typeof b.id === 'string') {
|
|
208
|
+
const name = str(b.name) || 'Tool';
|
|
209
|
+
const input = b.input && typeof b.input === 'object' ? b.input : {};
|
|
210
|
+
const plan = planOf(name, input);
|
|
211
|
+
out.push({ k: 'tool', id: b.id, name, summary: toolSummary(name, input), at, ...(plan && { plan }) }, ...taskItems(name, input));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
// A message the user sent while Claude worked, which Claude took in the middle of its turn: the transcript
|
|
217
|
+
// has it only as this attachment.
|
|
218
|
+
function takenMidTurn(r, at) {
|
|
219
|
+
const a = r.attachment && typeof r.attachment === 'object' ? r.attachment : null;
|
|
220
|
+
if (a?.type !== 'queued_command' || a.isMeta)
|
|
221
|
+
return [];
|
|
222
|
+
const origin = a.origin && typeof a.origin === 'object' ? a.origin : null;
|
|
223
|
+
// How a background command ended, taken in mid-turn: only the end is kept, the note comes as its own prompt.
|
|
224
|
+
if (origin?.kind === 'task-notification' || a.commandMode === 'task-notification')
|
|
225
|
+
return taskNote(blocksText(a.prompt)).filter((i) => i.k === 'bg');
|
|
226
|
+
if ((a.commandMode ?? 'prompt') !== 'prompt' || (origin && origin.kind !== 'human'))
|
|
227
|
+
return [];
|
|
228
|
+
const text = blocksText(a.prompt).trim();
|
|
229
|
+
return text ? [{ k: 'user', text, at }] : [];
|
|
230
|
+
}
|
|
231
|
+
// What one transcript record shows in the conversation. Subagents' own turns, Claude's thinking and
|
|
232
|
+
// text injected for Claude alone are not part of it.
|
|
233
|
+
export function chatItems(v) {
|
|
234
|
+
if (!v || typeof v !== 'object')
|
|
235
|
+
return [];
|
|
236
|
+
const r = v;
|
|
237
|
+
if (r.isSidechain)
|
|
238
|
+
return [];
|
|
239
|
+
const at = typeof r.timestamp === 'string' ? r.timestamp : undefined;
|
|
240
|
+
if (r.type === 'attachment')
|
|
241
|
+
return takenMidTurn(r, at);
|
|
242
|
+
if (!r.message || typeof r.message !== 'object')
|
|
243
|
+
return [];
|
|
244
|
+
if (r.type === 'user')
|
|
245
|
+
return sentByOther(r) ?? (r.isMeta ? [] : userItems(r, at));
|
|
246
|
+
// A reply the API failed to give: what Claude Code wrote in its place says why.
|
|
247
|
+
if (r.type === 'assistant' && r.isApiErrorMessage) {
|
|
248
|
+
const event = r.error === 'rate_limit' ? { e: 'failed', limit: true } : { e: 'failed' };
|
|
249
|
+
return [{ k: 'note', event, detail: clip(blocksText(r.message.content).replace(/^API Error:\s*/, ''), RESULT_MAX), retry: true }];
|
|
250
|
+
}
|
|
251
|
+
if (r.type === 'assistant')
|
|
252
|
+
return assistantItems(r, at);
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readdir, open, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { chatItems } from './chat.js';
|
|
4
|
+
// A session's transcript file read for the chat: from where the phone's copy ends, back from where it
|
|
5
|
+
// starts, its last reply, and where it is.
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
+
const MAX_READ = 4 * 1024 * 1024;
|
|
8
|
+
// About a screenful of turns many times over, little enough to come at once on a slow phone.
|
|
9
|
+
const PAGE_TURNS = 100;
|
|
10
|
+
const CHUNK = 256 * 1024;
|
|
11
|
+
function lineItems(l) {
|
|
12
|
+
try {
|
|
13
|
+
return chatItems(JSON.parse(l));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Each item of a transcript line under a name that stays the same however the file is read: the line's
|
|
20
|
+
// uuid and the item's place in it; a line without one is named by where it starts.
|
|
21
|
+
export function idItems(line, at) {
|
|
22
|
+
let v;
|
|
23
|
+
try {
|
|
24
|
+
v = JSON.parse(line);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const uuid = v && typeof v === 'object' && typeof v.uuid === 'string' ? v.uuid : `b${at}`;
|
|
30
|
+
return chatItems(v).map((item, n) => ({ id: `${uuid}:${n}`, item }));
|
|
31
|
+
}
|
|
32
|
+
// The whole lines from byte `from`, each with where it starts, up to maxRead bytes, and where the next
|
|
33
|
+
// read starts. A line still being written is left for the next read.
|
|
34
|
+
export async function linesFrom(path, from, maxRead = MAX_READ) {
|
|
35
|
+
const fh = await open(path, 'r');
|
|
36
|
+
try {
|
|
37
|
+
const size = (await fh.stat()).size;
|
|
38
|
+
if (from >= size)
|
|
39
|
+
return { lines: [], next: Math.min(from, size), size };
|
|
40
|
+
let buf = Buffer.alloc(0);
|
|
41
|
+
let end = 0;
|
|
42
|
+
// A line longer than maxRead (a picture pasted into the prompt) is read whole on its own.
|
|
43
|
+
for (let want = maxRead; !end && buf.length < size - from; want *= 2) {
|
|
44
|
+
buf = Buffer.alloc(Math.min(size - from, want));
|
|
45
|
+
await fh.read(buf, 0, buf.length, from);
|
|
46
|
+
end = buf.lastIndexOf(0x0a) + 1;
|
|
47
|
+
}
|
|
48
|
+
const lines = [];
|
|
49
|
+
for (let s = 0; s < end;) {
|
|
50
|
+
const e = buf.indexOf(0x0a, s);
|
|
51
|
+
if (e > s)
|
|
52
|
+
lines.push({ at: from + s, line: buf.subarray(s, e).toString('utf8') });
|
|
53
|
+
s = e + 1;
|
|
54
|
+
}
|
|
55
|
+
return { lines, next: from + end, size };
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await fh.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// The items added to the transcript since byte `from`, where reading really started (not `from` when it
|
|
62
|
+
// started over: what was read before is void), and where the next read starts. Only whole lines are
|
|
63
|
+
// taken; one still being written is read next time. A first read opens on the last turns (skipped: older
|
|
64
|
+
// ones were left out); a long addition comes in pieces, each ending on a line.
|
|
65
|
+
export async function readChat(path, from, o = {}) {
|
|
66
|
+
const size = (await stat(path)).size;
|
|
67
|
+
if (from === 0 || from > size)
|
|
68
|
+
return readChatBefore(path, size, { turns: o.turns });
|
|
69
|
+
const fh = await open(path, 'r');
|
|
70
|
+
try {
|
|
71
|
+
const buf = Buffer.alloc(Math.min(size - from, o.maxRead ?? MAX_READ));
|
|
72
|
+
await fh.read(buf, 0, buf.length, from);
|
|
73
|
+
const end = buf.lastIndexOf(0x0a) + 1;
|
|
74
|
+
const items = buf.subarray(0, end).toString('utf8').split('\n').flatMap(lineItems);
|
|
75
|
+
return { items, from, next: from + end, skipped: false };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await fh.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// The whole lines before byte `end`, newest first, with where each starts. A line cut off at `end`, still
|
|
82
|
+
// being written, is not one of them.
|
|
83
|
+
async function* linesBefore(fh, end, chunk) {
|
|
84
|
+
let pos = end;
|
|
85
|
+
let rest = Buffer.alloc(0);
|
|
86
|
+
let whole = false;
|
|
87
|
+
for (;;) {
|
|
88
|
+
if (pos > 0) {
|
|
89
|
+
const n = Math.min(chunk, pos);
|
|
90
|
+
pos -= n;
|
|
91
|
+
const piece = Buffer.alloc(n);
|
|
92
|
+
await fh.read(piece, 0, n, pos);
|
|
93
|
+
rest = Buffer.concat([piece, rest]);
|
|
94
|
+
}
|
|
95
|
+
if (!whole) {
|
|
96
|
+
const nl = rest.lastIndexOf(0x0a);
|
|
97
|
+
if (nl < 0) {
|
|
98
|
+
if (pos > 0)
|
|
99
|
+
continue;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
rest = rest.subarray(0, nl + 1);
|
|
103
|
+
whole = true;
|
|
104
|
+
}
|
|
105
|
+
// rest ends with the newline of the newest line not given yet.
|
|
106
|
+
let e = rest.length - 1;
|
|
107
|
+
while (e >= 0) {
|
|
108
|
+
const p = e > 0 ? rest.lastIndexOf(0x0a, e - 1) : -1;
|
|
109
|
+
if (p < 0 && pos > 0)
|
|
110
|
+
break;
|
|
111
|
+
yield { start: pos + p + 1, line: rest.subarray(p + 1, e) };
|
|
112
|
+
e = p;
|
|
113
|
+
}
|
|
114
|
+
if (e < 0)
|
|
115
|
+
return;
|
|
116
|
+
rest = rest.subarray(0, e + 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// The last turns before byte `before`, which is where the caller's copy starts: from is where they
|
|
120
|
+
// start, next where they end. Reading stops at a budget, to answer quickly however far apart the
|
|
121
|
+
// turns are, but always goes back at least one line. A file shorter than `before` is read from its end.
|
|
122
|
+
export async function readChatBefore(path, before, o = {}) {
|
|
123
|
+
const turns = o.turns ?? PAGE_TURNS;
|
|
124
|
+
const maxRead = o.maxRead ?? MAX_READ;
|
|
125
|
+
const fh = await open(path, 'r');
|
|
126
|
+
try {
|
|
127
|
+
const end = Math.min(before, (await fh.stat()).size);
|
|
128
|
+
const taken = [];
|
|
129
|
+
let count = 0;
|
|
130
|
+
let next;
|
|
131
|
+
let from;
|
|
132
|
+
for await (const { start, line } of linesBefore(fh, end, o.chunk ?? CHUNK)) {
|
|
133
|
+
next ??= start + line.length + 1;
|
|
134
|
+
if (count >= turns || (from !== undefined && next - start > maxRead))
|
|
135
|
+
break;
|
|
136
|
+
const items = lineItems(line.toString('utf8'));
|
|
137
|
+
taken.push(items);
|
|
138
|
+
count += items.filter((i) => i.k !== 'result').length;
|
|
139
|
+
from = start;
|
|
140
|
+
}
|
|
141
|
+
from ??= next ?? 0;
|
|
142
|
+
return { items: taken.reverse().flat(), from, next: next ?? from, skipped: from > 0 };
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await fh.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const REPLY_TAIL = 256 * 1024;
|
|
149
|
+
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) {
|
|
153
|
+
const size = (await stat(path)).size;
|
|
154
|
+
const { items } = await readChat(path, Math.max(0, size - tail));
|
|
155
|
+
const last = items.findLast((i) => i.k === 'text' || (i.k === 'note' && 'event' in i && i.event.e === 'failed'));
|
|
156
|
+
if (!last)
|
|
157
|
+
return null;
|
|
158
|
+
// A turn that ended in an error says so, in Claude Code's own words.
|
|
159
|
+
if (last.k === 'note')
|
|
160
|
+
return [...`API Error: ${last.detail ?? ''}`.trim()].slice(0, REPLY_CHARS).join('');
|
|
161
|
+
if (last.k !== 'text')
|
|
162
|
+
return null;
|
|
163
|
+
const plain = last.text
|
|
164
|
+
.replace(/^```.*$/gm, '')
|
|
165
|
+
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+[.)])\s+/gm, '')
|
|
166
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
167
|
+
.replace(/(\*\*|__|`)/g, '')
|
|
168
|
+
.replace(/\s+/g, ' ')
|
|
169
|
+
.trim();
|
|
170
|
+
return plain ? [...plain].slice(0, REPLY_CHARS).join('') : null;
|
|
171
|
+
}
|
|
172
|
+
// Where Claude Code keeps the transcript of a session: <root>/<project folder>/<session id>.jsonl.
|
|
173
|
+
export async function findTranscript(root, sessionId) {
|
|
174
|
+
if (!UUID.test(sessionId))
|
|
175
|
+
return null;
|
|
176
|
+
let dirs;
|
|
177
|
+
try {
|
|
178
|
+
dirs = await readdir(root);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
for (const d of dirs) {
|
|
184
|
+
const p = join(root, d, `${sessionId}.jsonl`);
|
|
185
|
+
try {
|
|
186
|
+
if ((await stat(p)).isFile())
|
|
187
|
+
return p;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// not in this folder
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { open, readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
const HEAD_BYTES = 256 * 1024;
|
|
5
|
+
const TAIL_BYTES = 64 * 1024;
|
|
6
|
+
const TITLE_MAX = 60;
|
|
7
|
+
const LAST_PROMPT_MAX = 200;
|
|
8
|
+
// Up to `limit` of the latest sessions people started, and as many of those programs started, newest
|
|
9
|
+
// first: a busy agent runner does not push a person's sessions off the list.
|
|
10
|
+
// Asked for again while a listing runs (several pages and phones open at once), the one running answers.
|
|
11
|
+
const running = new Map();
|
|
12
|
+
export function listRecentClaudeSessions(root, limit = 50) {
|
|
13
|
+
const key = `${limit}:${root}`;
|
|
14
|
+
const now = running.get(key);
|
|
15
|
+
if (now)
|
|
16
|
+
return now;
|
|
17
|
+
const next = listRecent(root, limit).finally(() => running.delete(key));
|
|
18
|
+
running.set(key, next);
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
21
|
+
async function listRecent(root, limit) {
|
|
22
|
+
const files = await transcriptFiles(root);
|
|
23
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
24
|
+
const out = [];
|
|
25
|
+
const count = { people: 0, programs: 0 };
|
|
26
|
+
for (const f of files.slice(0, limit * 6)) {
|
|
27
|
+
if (count.people >= limit && count.programs >= limit)
|
|
28
|
+
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
|
+
continue;
|
|
35
|
+
count[kind]++;
|
|
36
|
+
out.push({ sessionId: basename(f.path, '.jsonl'), ...parsed, lastActivity: Math.round(f.mtimeMs) });
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
// A conversation in a temporary folder is a throwaway run (a test, a probe), not work to come back to.
|
|
41
|
+
const SCRATCH_ROOTS = ['/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/', join(tmpdir(), '/')];
|
|
42
|
+
function isScratch(cwd) {
|
|
43
|
+
return cwd !== null && SCRATCH_ROOTS.some((r) => cwd.startsWith(r));
|
|
44
|
+
}
|
|
45
|
+
export function listProjectDirs(recent, pinned) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const d of [...pinned, ...recent.map((r) => r.cwd)]) {
|
|
49
|
+
if (d && !seen.has(d)) {
|
|
50
|
+
seen.add(d);
|
|
51
|
+
out.push(d);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
async function transcriptFiles(root) {
|
|
57
|
+
let dirs;
|
|
58
|
+
try {
|
|
59
|
+
dirs = await readdir(root);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const files = [];
|
|
65
|
+
for (const d of dirs) {
|
|
66
|
+
let names;
|
|
67
|
+
try {
|
|
68
|
+
names = await readdir(join(root, d));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const n of names) {
|
|
74
|
+
if (!n.endsWith('.jsonl'))
|
|
75
|
+
continue;
|
|
76
|
+
const p = join(root, d, n);
|
|
77
|
+
try {
|
|
78
|
+
const s = await stat(p);
|
|
79
|
+
if (s.isFile())
|
|
80
|
+
files.push({ path: p, mtimeMs: s.mtimeMs, size: s.size });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// vanished between readdir and stat
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
// What a transcript was read as, kept while the file stays as it was: the list is asked for often, and
|
|
90
|
+
// reading the head and tail of hundreds of files each time took seconds.
|
|
91
|
+
const parsedCache = new Map();
|
|
92
|
+
const PARSED_KEPT = 2000;
|
|
93
|
+
async function parsedOnce(f) {
|
|
94
|
+
const kept = parsedCache.get(f.path);
|
|
95
|
+
if (kept && kept.size === f.size && kept.mtimeMs === f.mtimeMs)
|
|
96
|
+
return kept.parsed;
|
|
97
|
+
const parsed = await parseTranscript(f.path, f.size).catch(() => null);
|
|
98
|
+
parsedCache.delete(f.path);
|
|
99
|
+
parsedCache.set(f.path, { size: f.size, mtimeMs: f.mtimeMs, parsed });
|
|
100
|
+
for (const old of parsedCache.keys()) {
|
|
101
|
+
if (parsedCache.size <= PARSED_KEPT)
|
|
102
|
+
break;
|
|
103
|
+
parsedCache.delete(old);
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
async function parseTranscript(path, size) {
|
|
108
|
+
const records = await readRecords(path, size);
|
|
109
|
+
if (!records.length)
|
|
110
|
+
return null;
|
|
111
|
+
let cwd = null;
|
|
112
|
+
let customTitle = null;
|
|
113
|
+
let aiTitle = null;
|
|
114
|
+
let firstPrompt = null;
|
|
115
|
+
let lastPrompt = null;
|
|
116
|
+
let automated = false;
|
|
117
|
+
for (const r of records) {
|
|
118
|
+
if (!cwd && typeof r.cwd === 'string' && r.cwd)
|
|
119
|
+
cwd = r.cwd;
|
|
120
|
+
if (r.entrypoint === 'sdk-cli')
|
|
121
|
+
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;
|
|
127
|
+
if (said)
|
|
128
|
+
lastPrompt = said;
|
|
129
|
+
if (!firstPrompt && r.type === 'user')
|
|
130
|
+
firstPrompt = promptText(r.message?.content);
|
|
131
|
+
}
|
|
132
|
+
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 };
|
|
134
|
+
}
|
|
135
|
+
async function readRecords(path, size) {
|
|
136
|
+
const fh = await open(path, 'r');
|
|
137
|
+
try {
|
|
138
|
+
if (size <= HEAD_BYTES + TAIL_BYTES) {
|
|
139
|
+
return parseLines((await fh.readFile()).toString('utf8'));
|
|
140
|
+
}
|
|
141
|
+
const head = Buffer.alloc(HEAD_BYTES);
|
|
142
|
+
await fh.read(head, 0, HEAD_BYTES, 0);
|
|
143
|
+
const tail = Buffer.alloc(TAIL_BYTES);
|
|
144
|
+
await fh.read(tail, 0, TAIL_BYTES, size - TAIL_BYTES);
|
|
145
|
+
return [...parseLines(head.toString('utf8')), ...parseLines(tail.toString('utf8'))];
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await fh.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// A line cut by the head/tail window is never valid JSON on its own, so the
|
|
152
|
+
// parse below discards it without special handling.
|
|
153
|
+
function parseLines(text) {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const l of text.split('\n')) {
|
|
156
|
+
if (!l.startsWith('{'))
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
const v = JSON.parse(l);
|
|
160
|
+
if (v && typeof v === 'object')
|
|
161
|
+
out.push(v);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// partial or foreign line
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
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
|
+
: '';
|
|
175
|
+
// An agent runner's wake names its issue on a line of its own; that, not its template, says what it is.
|
|
176
|
+
const issue = /^- issue:\s*(.+)$/m.exec(raw)?.[1];
|
|
177
|
+
const text = (issue ?? raw.replace(/^#{1,6}\s+/gm, '')).replace(/\s+/g, ' ').trim();
|
|
178
|
+
return text && !text.startsWith('<') ? text : null;
|
|
179
|
+
}
|
|
180
|
+
function truncate(s, max = TITLE_MAX) {
|
|
181
|
+
return [...s].slice(0, max).join('');
|
|
182
|
+
}
|
|
183
|
+
// The conversations Claude Code's background sessions hold (~/.claude/jobs: a folder per session, named by
|
|
184
|
+
// the first eight characters of its conversation, its state.json naming the transcript it follows). One
|
|
185
|
+
// held there must not also be resumed here: two Claudes would write the same conversation.
|
|
186
|
+
export async function heldInBackground(jobsRoot) {
|
|
187
|
+
let names;
|
|
188
|
+
try {
|
|
189
|
+
names = await readdir(jobsRoot);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return () => false;
|
|
193
|
+
}
|
|
194
|
+
const followed = new Set();
|
|
195
|
+
const named = new Set();
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
let state;
|
|
198
|
+
try {
|
|
199
|
+
state = JSON.parse(await readFile(join(jobsRoot, name, 'state.json'), 'utf8'));
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
named.add(name);
|
|
205
|
+
const path = state && typeof state === 'object' ? state.linkScanPath : undefined;
|
|
206
|
+
if (typeof path === 'string')
|
|
207
|
+
followed.add(basename(path, '.jsonl'));
|
|
208
|
+
}
|
|
209
|
+
return (sessionId) => followed.has(sessionId) || named.has(sessionId.slice(0, 8));
|
|
210
|
+
}
|