@khorsheed/dsh-local-agent-claude-code 0.1.0-rc.6

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 (38) hide show
  1. package/README.en.md +120 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +120 -0
  4. package/cordis.patch.yml +42 -0
  5. package/lib/client.js +1065 -0
  6. package/lib/index.js +3771 -0
  7. package/lib/invariant.js +31 -0
  8. package/lib/types/claude-cli-provider.d.ts +389 -0
  9. package/lib/types/claude-cli-provider.js +1509 -0
  10. package/lib/types/client/SettingsCard.d.ts +78 -0
  11. package/lib/types/client/SettingsCard.js +198 -0
  12. package/lib/types/client/icons.d.ts +18 -0
  13. package/lib/types/client/icons.js +2 -0
  14. package/lib/types/client/index.d.ts +36 -0
  15. package/lib/types/client/index.js +87 -0
  16. package/lib/types/client/locales.d.ts +64 -0
  17. package/lib/types/client/locales.js +55 -0
  18. package/lib/types/control-requests.d.ts +15 -0
  19. package/lib/types/control-requests.js +62 -0
  20. package/lib/types/index.d.ts +81 -0
  21. package/lib/types/index.js +300 -0
  22. package/lib/types/invariant.d.ts +16 -0
  23. package/lib/types/invariant.js +33 -0
  24. package/lib/types/live-driver.d.ts +172 -0
  25. package/lib/types/live-driver.js +1057 -0
  26. package/lib/types/live-switch.d.ts +67 -0
  27. package/lib/types/live-switch.js +85 -0
  28. package/lib/types/model-broker.d.ts +131 -0
  29. package/lib/types/model-broker.js +248 -0
  30. package/lib/types/model-catalog.d.ts +27 -0
  31. package/lib/types/model-catalog.js +118 -0
  32. package/lib/types/provision.d.ts +63 -0
  33. package/lib/types/provision.js +132 -0
  34. package/lib/types/records.d.ts +120 -0
  35. package/lib/types/records.js +687 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +106 -0
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Claude Code session records adapter: scans the scoped home's `projects/`
3
+ * session files (one JSONL per session under `projects/<cwd-slug>/`, the
4
+ * slug being the workspace path with separators replaced by dashes), plus
5
+ * the transcript model read-back ({@link readClaudeTranscriptModel}) the
6
+ * model broker's `lastObserved` backstop runs on. The
7
+ * slug is a LOSSY encoding of the cwd — different paths can collide on the
8
+ * same slug — so the listed cwd always comes from the file CONTENT (the
9
+ * first `user` event's `cwd` field), never from the directory name. Only the
10
+ * head of each file is read — the first user event carries the id, cwd, and
11
+ * timestamp the listing needs; the rest of the file (conversation) is not
12
+ * parsed. Files are append-only with torn-tail tolerance, and contents are
13
+ * always untrusted input.
14
+ * @module @khorsheed/dsh-local-agent-claude-code/records
15
+ */
16
+ import { execFile } from 'node:child_process';
17
+ import { createHash } from 'node:crypto';
18
+ import { open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { promisify } from 'node:util';
21
+ /** Session files live under this directory inside the scoped home. */
22
+ const PROJECTS_ROOT = 'projects';
23
+ /** Bound on the head prefix read from one session file. */
24
+ const HEAD_BYTES = 64 * 1024;
25
+ /**
26
+ * Bound on the tail read when extracting a transcript's model. Assistant
27
+ * lines are frequent, so the last window almost always names one; a bigger
28
+ * conversation is never read whole.
29
+ */
30
+ const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
31
+ /** Cap on project directories the transcript reads scan. */
32
+ const TRANSCRIPT_SCAN_DIRS = 200;
33
+ /** Cap on transcript files the harness-level newest-scan stats. */
34
+ const TRANSCRIPT_SCAN_FILES = 500;
35
+ /** Cap on sidechain (`agent-*.jsonl`) candidates inspected for one session id. */
36
+ const TRANSCRIPT_SIDECHAIN_FILES = 100;
37
+ /** How many newest transcripts the harness-level read tries before giving up. */
38
+ const TRANSCRIPT_NEWEST_TRIES = 3;
39
+ /** Test-swappable process exec (the keychain read goes through `security`). */
40
+ export const internals = {
41
+ exec: promisify(execFile),
42
+ };
43
+ /**
44
+ * The scoped `.claude.json`'s modification stamp (epoch ms), undefined when
45
+ * absent. A completed login rewrites this file, so the stamp distinguishes a
46
+ * fresh login from a leftover marker (a revoked token's record lingers).
47
+ * @param homeDir - the `claude-code` harness's scoped home.
48
+ * @returns the marker's mtime, or undefined when no scoped config exists.
49
+ */
50
+ export async function claudeCredentialStamp(homeDir) {
51
+ try {
52
+ return (await stat(join(homeDir, '.claude.json'))).mtimeMs;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ /**
59
+ * The keychain entry name current claude uses for a non-default config dir:
60
+ * the bare name belongs to `~/.claude`, other dirs get a path-hash suffix.
61
+ * @param homeDir - the `claude-code` harness's scoped home.
62
+ * @returns the macOS keychain service name holding the OAuth credential.
63
+ */
64
+ function keychainService(homeDir) {
65
+ const hash = createHash('sha256').update(homeDir).digest('hex').slice(0, 8);
66
+ return `Claude Code-credentials-${hash}`;
67
+ }
68
+ /**
69
+ * Whether a credential blob is USABLE: the token record nests under
70
+ * `claudeAiOauth`, and a blob whose access AND refresh tokens are both empty
71
+ * is a shell. macOS keeps historical items under the same service (an early
72
+ * claude wrote an `acct=unknown` placeholder with blank tokens and
73
+ * `expiresAt: 0`), and mirroring one into the credentials file makes every
74
+ * delegation report "OAuth session expired and could not be refreshed" —
75
+ * indistinguishable from a real expiry while login looks successful. A
76
+ * non-empty token is the bar for mirroring a blob or keeping a file.
77
+ * @param text - the raw credential JSON.
78
+ * @returns true when the blob carries a non-empty token.
79
+ */
80
+ function credentialUsable(text) {
81
+ try {
82
+ const parsed = JSON.parse(text);
83
+ const oauth = parsed.claudeAiOauth;
84
+ return (typeof oauth?.accessToken === 'string' && oauth.accessToken !== '')
85
+ || (typeof oauth?.refreshToken === 'string' && oauth.refreshToken !== '');
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ /**
92
+ * A `security dump-keychain` timedate as epoch ms; undefined when the shape is
93
+ * not recognized (the item then sorts as oldest).
94
+ *
95
+ * The shape that matters is the COMPACT Zulu stamp `20260916040855Z`, because
96
+ * that is what the tool actually prints — with the attribute's trailing NUL
97
+ * rendered as a literal `\000` after the closing digit, so the match must not
98
+ * anchor at the end. Only the spaced `2026-09-01 02:03:04 +0000` form was
99
+ * parsed before, which no `security` build was observed to emit: every item
100
+ * fell to the `?? 0` default, the newest-first sort over all-equal keys
101
+ * degenerated to the dump's own order (a stable sort), and
102
+ * `readKeychainCredential` returned whichever usable item the dump happened to
103
+ * list first. A scoped home that had been logged in twice then kept serving the
104
+ * SUPERSEDED credential — the fresh login sat unread in the keychain while the
105
+ * runtime ran on a generation whose refresh token was already spent. The
106
+ * spaced form is still accepted in case some build does emit it.
107
+ * @param text - the quoted timedate string from the dump.
108
+ * @returns the epoch milliseconds, or undefined.
109
+ */
110
+ function keychainTimestamp(text) {
111
+ const trimmed = text.trim();
112
+ const compact = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/.exec(trimmed);
113
+ if (compact !== null) {
114
+ const at = (index) => Number(compact[index]);
115
+ const ms = Date.UTC(at(1), at(2) - 1, at(3), at(4), at(5), at(6));
116
+ return Number.isFinite(ms) ? ms : undefined;
117
+ }
118
+ const spaced = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec(trimmed);
119
+ if (spaced === null)
120
+ return undefined;
121
+ const part = (index) => Number(spaced[index]);
122
+ const offsetMinutes = (part(8) * 60 + part(9)) * (spaced[7] === '+' ? 1 : -1);
123
+ const ms = Date.UTC(part(1), part(2) - 1, part(3), part(4), part(5), part(6)) - offsetMinutes * 60_000;
124
+ return Number.isFinite(ms) ? ms : undefined;
125
+ }
126
+ /**
127
+ * Every account holding an item under the service, newest write first. macOS
128
+ * allows SEVERAL items per service, and an unscoped `find-generic-password
129
+ * -w` returns the FIRST match — which can be a historical empty shell (see
130
+ * `credentialUsable`) — so the sync enumerates instead of trusting the first
131
+ * hit. The dump prints metadata only (no secrets); a failing dump or a parse
132
+ * miss yields an empty list, and the caller then falls back to the unscoped
133
+ * read.
134
+ * @param service - the keychain service name.
135
+ * @returns the accounts, newest modification first.
136
+ */
137
+ async function keychainItems(service) {
138
+ let stdout;
139
+ try {
140
+ ({ stdout } = await internals.exec('security', ['dump-keychain']));
141
+ }
142
+ catch {
143
+ return [];
144
+ }
145
+ const items = [];
146
+ let svce;
147
+ let acct;
148
+ let mdat = 0;
149
+ const flush = () => {
150
+ if (svce === service && acct !== undefined)
151
+ items.push({ acct, mdat });
152
+ svce = undefined;
153
+ acct = undefined;
154
+ mdat = 0;
155
+ };
156
+ for (const line of stdout.split('\n')) {
157
+ // Each item block opens with its `keychain:` header line.
158
+ if (line.startsWith('keychain:')) {
159
+ flush();
160
+ continue;
161
+ }
162
+ const trimmed = line.trim();
163
+ const svceMatch = /^"svce"<blob>="(.*)"$/.exec(trimmed);
164
+ if (svceMatch !== null) {
165
+ svce = svceMatch[1];
166
+ continue;
167
+ }
168
+ const acctMatch = /^"acct"<blob>="(.*)"$/.exec(trimmed);
169
+ if (acctMatch !== null) {
170
+ acct = acctMatch[1];
171
+ continue;
172
+ }
173
+ const mdatMatch = /^"mdat"<timedate>=0x[0-9a-fA-F]+\s+"(.*)"$/.exec(trimmed);
174
+ if (mdatMatch !== null)
175
+ mdat = keychainTimestamp(mdatMatch[1] ?? '') ?? 0;
176
+ }
177
+ flush();
178
+ return items.sort((a, b) => b.mdat - a.mdat);
179
+ }
180
+ /**
181
+ * One keychain read: the item for `acct` when given, else the first match.
182
+ * Returns the raw blob only when it is USABLE (non-empty token); anything
183
+ * else — missing item, unreadable payload, an empty shell — is undefined.
184
+ * @param service - the keychain service name.
185
+ * @param acct - the account to read, or undefined for the unscoped first match.
186
+ * @returns the credential blob, or undefined.
187
+ */
188
+ async function readKeychainEntry(service, acct) {
189
+ try {
190
+ const args = ['find-generic-password', '-s', service];
191
+ if (acct !== undefined)
192
+ args.push('-a', acct);
193
+ args.push('-w');
194
+ const { stdout } = await internals.exec('security', args);
195
+ const blob = stdout.trim();
196
+ return credentialUsable(blob) ? blob : undefined;
197
+ }
198
+ catch {
199
+ return undefined;
200
+ }
201
+ }
202
+ /**
203
+ * The usable credential blob for this scoped home, or undefined. Every
204
+ * account under the service is tried newest-write-first and the first
205
+ * non-empty token wins; when enumeration itself is unavailable (not macOS, a
206
+ * failing dump) the legacy unscoped read still runs, held to the same
207
+ * non-empty-token bar.
208
+ * @param homeDir - the `claude-code` harness's scoped home.
209
+ * @returns the credential blob, or undefined when nothing usable is stored.
210
+ */
211
+ async function readKeychainCredential(homeDir) {
212
+ const service = keychainService(homeDir);
213
+ const items = await keychainItems(service);
214
+ for (const item of items) {
215
+ const blob = await readKeychainEntry(service, item.acct);
216
+ if (blob !== undefined)
217
+ return blob;
218
+ }
219
+ // An enumerated-but-all-empty service has no fallback worth trying.
220
+ return items.length > 0 ? undefined : readKeychainEntry(service, undefined);
221
+ }
222
+ /**
223
+ * The stored credential's expiry (epoch ms), read from the USABLE macOS
224
+ * keychain item for this scoped home (see `readKeychainCredential`);
225
+ * undefined when no usable item exists or it carries no expiry (presence
226
+ * alone then decides, as before). The READ side takes the LATER of the
227
+ * access-token and refresh-token expiries: the CLI refreshes on use, so a
228
+ * live refresh token means the credential still works.
229
+ * @param homeDir - the `claude-code` harness's scoped home.
230
+ * @returns the expiry, or undefined when unknown.
231
+ */
232
+ async function readCredentialExpiry(homeDir) {
233
+ const blob = await readKeychainCredential(homeDir);
234
+ return blob === undefined ? undefined : credentialFileExpiry(blob);
235
+ }
236
+ /**
237
+ * The ACCESS-token expiry (epoch ms) of a credential blob — the freshness key
238
+ * the sync compares the two stores on. Deliberately not
239
+ * {@link credentialFileExpiry}: that one answers "is this credential still
240
+ * usable" and takes the LATER of the two expiries, so two generations of one
241
+ * grant share a refresh expiry and compare equal. A refresh always mints a
242
+ * later ACCESS expiry, which is exactly what tells the generations apart.
243
+ * @param text - the raw credential JSON.
244
+ * @returns the access-token expiry, or undefined when absent or unparseable.
245
+ */
246
+ function credentialAccessExpiry(text) {
247
+ try {
248
+ const access = JSON.parse(text).claudeAiOauth?.expiresAt;
249
+ return typeof access === 'number' ? access : undefined;
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ }
255
+ /**
256
+ * Reconcile the keychain credential with `<homeDir>/.credentials.json`,
257
+ * NEWER WINS. Claude 2.1.236 on macOS WRITES the scoped login to the hashed
258
+ * keychain entry but READS the credentials file at runtime (the same
259
+ * write/read split as the Linux #47661 bug) — a login that lands only in the
260
+ * keychain still answers "Not logged in", so the file has to be kept current.
261
+ *
262
+ * The file is not this process's private mirror, though: a containerized
263
+ * round bind-mounts this very directory read-write (the unit has no keychain,
264
+ * so the CLI inside it reads and writes the file), and the pre-spawn sync runs
265
+ * against the HOST path moments before the unit reads it through the mount.
266
+ * An unconditional mirror therefore makes this function the one participant
267
+ * that can move the credential chain BACKWARD: it hands the next run a refresh
268
+ * token the other writer already spent, the endpoint rejects it, and the CLI
269
+ * answers by CLEARING the file — which logs the instance out, because that
270
+ * file is the instance's credential. So the keychain blob is written only when
271
+ * it is not older than the file's. Undefined expiry on either side keeps the
272
+ * pre-newer-wins behavior (the keychain writes), and a file that is not USABLE
273
+ * never wins.
274
+ *
275
+ * Only a USABLE blob (non-empty token, see `readKeychainCredential`) is
276
+ * mirrored; with none in the keychain, a file holding an empty-token shell —
277
+ * debris from an earlier first-match read, or a cleared credential — is
278
+ * REMOVED, so the runtime says "Not logged in" instead of the misleading
279
+ * "OAuth session expired". Idempotent and content-compare before write.
280
+ * @param homeDir - the `claude-code` harness's scoped home.
281
+ * @param log - warn channel for the two decisions that used to be silent.
282
+ * @returns true when a usable credential is in the file after the call.
283
+ */
284
+ export async function syncClaudeCredentialFile(homeDir, log) {
285
+ const file = join(homeDir, '.credentials.json');
286
+ let existing;
287
+ try {
288
+ existing = await readFile(file, 'utf8');
289
+ }
290
+ catch {
291
+ // Absent or unreadable: the keychain decides alone.
292
+ }
293
+ const fileUsable = existing !== undefined && credentialUsable(existing);
294
+ const blob = await readKeychainCredential(homeDir);
295
+ if (blob === undefined) {
296
+ // A usable file with nothing in the keychain is the only credential there
297
+ // is (a Linux host stores nowhere else) — left exactly as it stands.
298
+ if (existing !== undefined && !fileUsable)
299
+ await rm(file, { force: true });
300
+ return fileUsable;
301
+ }
302
+ if (existing !== undefined && existing.trim() === blob)
303
+ return true;
304
+ if (fileUsable) {
305
+ const fileExpiry = credentialAccessExpiry(existing);
306
+ const blobExpiry = credentialAccessExpiry(blob);
307
+ if (fileExpiry !== undefined && blobExpiry !== undefined && fileExpiry > blobExpiry) {
308
+ // The other writer is ahead. Said out loud because it is rare, because
309
+ // it is the signature of a containerized round having rotated the grant,
310
+ // and because reconstructing the previous occurrence took a file mtime
311
+ // and a pilot log. Expiries only — never token material.
312
+ log?.(`local-agent-claude-code: ${homeDir}/.credentials.json is AHEAD of the keychain`
313
+ + ` (file access expiry ${new Date(fileExpiry).toISOString()},`
314
+ + ` keychain ${new Date(blobExpiry).toISOString()}) — keeping the file;`
315
+ + ' something else refreshed this grant (a containerized round mounts this directory read-write)');
316
+ return true;
317
+ }
318
+ }
319
+ if (existing !== undefined && !fileUsable) {
320
+ log?.(`local-agent-claude-code: ${homeDir}/.credentials.json held a cleared credential`
321
+ + ' (both tokens empty) and is being restored from the keychain'
322
+ + ` (access expiry ${new Date(credentialAccessExpiry(blob) ?? 0).toISOString()});`
323
+ + ' if the next round fails to authenticate, this grant was already spent and the scope needs a fresh login');
324
+ }
325
+ await writeFile(file, blob, { mode: 0o600 });
326
+ return true;
327
+ }
328
+ /**
329
+ * The expiry (epoch ms) from a `.credentials.json` payload; the token record
330
+ * nests under `claudeAiOauth`. Returns the LATER of the access-token and
331
+ * refresh-token expiries — the CLI refreshes on use, so a live refresh token
332
+ * keeps the credential usable past the access expiry. Undefined when
333
+ * absent/unparseable.
334
+ * @param text - the file contents.
335
+ * @returns the expiry, or undefined.
336
+ */
337
+ function credentialFileExpiry(text) {
338
+ try {
339
+ const parsed = JSON.parse(text);
340
+ const access = parsed.claudeAiOauth?.expiresAt;
341
+ const refresh = parsed.claudeAiOauth?.refreshTokenExpiresAt;
342
+ const accessMs = typeof access === 'number' ? access : undefined;
343
+ const refreshMs = typeof refresh === 'number' ? refresh : undefined;
344
+ if (accessMs === undefined && refreshMs === undefined)
345
+ return undefined;
346
+ return Math.max(accessMs ?? 0, refreshMs ?? 0);
347
+ }
348
+ catch {
349
+ return undefined;
350
+ }
351
+ }
352
+ /**
353
+ * Whether the scoped home holds usable Claude Code credentials: the scoped
354
+ * `.claude.json` carries an `oauthAccount` record once a device-code login
355
+ * has completed, AND the keychain credential is not past its stored expiry
356
+ * (when the expiry is readable — a server-side REVOCATION is not visible
357
+ * locally; providers report those through `localAgent.reportAuthFailure`).
358
+ * The real credential lives in the macOS keychain under a hashed entry
359
+ * (`Claude Code-credentials-<sha256(path)[:8]>`, hashed from the config-dir
360
+ * path) or in the default `~/.claude/.credentials.json` on Linux (upstream
361
+ * bug #47661: `CLAUDE_CONFIG_DIR` does not isolate the credentials file
362
+ * there — it writes the scoped dir but READS the default home, so Linux
363
+ * credentials may exist even when the scoped home has none). Spawning the
364
+ * CLI to probe auth is avoided; the platform differences are documented in
365
+ * the README.
366
+ * @param homeDir - the `claude-code` harness's scoped home.
367
+ * @param log - warn channel handed to the credential reconcile below.
368
+ * @returns true when a completed login is recorded and not locally expired.
369
+ */
370
+ export async function claudeAuthenticated(homeDir, log) {
371
+ // The runtime reads `.credentials.json` first (claude 2.1.236 writes the
372
+ // keychain but reads the file) — and a CLI run refreshes the KEYCHAIN copy
373
+ // without touching the file, so reconcile keychain↔file before judging, or a
374
+ // refreshed credential still reads expired. Newer wins: this probe runs on
375
+ // every status read and every readiness check, so an unconditional mirror
376
+ // here would undo a containerized round's refresh just as surely as the
377
+ // spawn sites would.
378
+ await syncClaudeCredentialFile(homeDir, log);
379
+ let fileExpiry;
380
+ let filePresent = false;
381
+ try {
382
+ const text = await readFile(join(homeDir, '.credentials.json'), 'utf8');
383
+ fileExpiry = credentialFileExpiry(text);
384
+ filePresent = true;
385
+ }
386
+ catch {
387
+ // No credentials file: fall through to the keychain-backed check.
388
+ }
389
+ if (filePresent)
390
+ return fileExpiry === undefined || fileExpiry > Date.now();
391
+ let text;
392
+ try {
393
+ text = await readFile(join(homeDir, '.claude.json'), 'utf8');
394
+ }
395
+ catch (error) {
396
+ // No scoped config yet: not authenticated.
397
+ if (error.code === 'ENOENT')
398
+ return false;
399
+ throw error;
400
+ }
401
+ let config;
402
+ try {
403
+ config = JSON.parse(text);
404
+ }
405
+ catch {
406
+ // A torn or malformed config reads as not authenticated.
407
+ return false;
408
+ }
409
+ if (config.oauthAccount === undefined || config.oauthAccount === null)
410
+ return false;
411
+ // Presence established; refuse a credential past its stored expiry. A
412
+ // server-side revocation is not locally visible — that path reports through
413
+ // the delegation's 401/403, not this probe.
414
+ const expiry = await readCredentialExpiry(homeDir);
415
+ return expiry === undefined || expiry > Date.now();
416
+ }
417
+ /**
418
+ * Parse the head of a session file for the listing fields. The first `user`
419
+ * event carries `sessionId`, `cwd`, and `timestamp`; its `cwd` is the only
420
+ * trustworthy workspace source (the directory slug is lossy). Unknown or
421
+ * malformed values yield an undefined record rather than failing the list.
422
+ * @param text - the raw session file head (only the head is read).
423
+ * @returns the record, or undefined when no user event is found.
424
+ */
425
+ export function projectRecord(text) {
426
+ for (const raw of text.split('\n')) {
427
+ const line = raw.trim();
428
+ if (line === '')
429
+ continue;
430
+ let event;
431
+ try {
432
+ event = JSON.parse(line);
433
+ }
434
+ catch {
435
+ continue;
436
+ }
437
+ if (event.type !== 'user')
438
+ continue;
439
+ if (typeof event.sessionId !== 'string' || event.sessionId === '')
440
+ return undefined;
441
+ if (typeof event.cwd !== 'string')
442
+ return undefined;
443
+ const record = { id: event.sessionId, workDir: event.cwd };
444
+ const startedAt = typeof event.timestamp === 'string' ? Date.parse(event.timestamp) : undefined;
445
+ if (startedAt !== undefined && Number.isFinite(startedAt))
446
+ record.startedAt = startedAt;
447
+ return record;
448
+ }
449
+ return undefined;
450
+ }
451
+ /**
452
+ * Read the scoped home's session files. The listing walks the projects
453
+ * directory and reads each jsonl head; a torn or malformed file is skipped
454
+ * rather than failing the whole list.
455
+ * @param homeDir - the `claude-code` harness's scoped home.
456
+ * @returns the parsed records, empty when no sessions exist yet.
457
+ */
458
+ export async function listClaudeSessions(homeDir) {
459
+ const records = [];
460
+ let projectDirs;
461
+ try {
462
+ projectDirs = await readdir(join(homeDir, PROJECTS_ROOT));
463
+ }
464
+ catch (error) {
465
+ // No projects root yet: nothing to list.
466
+ if (error.code === 'ENOENT')
467
+ return [];
468
+ throw error;
469
+ }
470
+ for (const projectDir of projectDirs) {
471
+ let files;
472
+ try {
473
+ files = await readdir(join(homeDir, PROJECTS_ROOT, projectDir));
474
+ }
475
+ catch {
476
+ continue;
477
+ }
478
+ for (const name of files) {
479
+ if (!name.endsWith('.jsonl'))
480
+ continue;
481
+ try {
482
+ // Only the head matters; read a bounded prefix rather than the whole
483
+ // (potentially long) conversation log.
484
+ const handle = await open(join(homeDir, PROJECTS_ROOT, projectDir, name), 'r');
485
+ let text;
486
+ try {
487
+ const buffer = Buffer.alloc(HEAD_BYTES);
488
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
489
+ text = buffer.subarray(0, bytesRead).toString('utf8');
490
+ }
491
+ finally {
492
+ await handle.close();
493
+ }
494
+ const record = projectRecord(text);
495
+ if (record !== undefined)
496
+ records.push(record);
497
+ }
498
+ catch {
499
+ continue;
500
+ }
501
+ }
502
+ }
503
+ return records;
504
+ }
505
+ /**
506
+ * Extract the model one transcript names, reading its TAIL (bounded, never
507
+ * the whole conversation) from the newest line backward: the first line
508
+ * carrying a model wins — the top-level `model` of the stream-json init
509
+ * event's shape, or the nested `message.model` of an assistant line. Newest
510
+ * first because a session's later rounds may run a different model than its
511
+ * first, and "what ran last" is the question the caller asks. A torn final
512
+ * line is skipped, never failed on.
513
+ * @param path - the transcript file.
514
+ * @returns the model identifier, or undefined when the window names none.
515
+ */
516
+ async function transcriptFileModel(path) {
517
+ let text;
518
+ try {
519
+ const handle = await open(path, 'r');
520
+ try {
521
+ const { size } = await handle.stat();
522
+ const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
523
+ const buffer = Buffer.alloc(length);
524
+ const { bytesRead } = await handle.read(buffer, 0, length, Math.max(0, size - length));
525
+ text = buffer.subarray(0, bytesRead).toString('utf8');
526
+ }
527
+ finally {
528
+ await handle.close();
529
+ }
530
+ }
531
+ catch {
532
+ return undefined;
533
+ }
534
+ const lines = text.split('\n');
535
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
536
+ const line = lines[index].trim();
537
+ if (line === '')
538
+ continue;
539
+ let event;
540
+ try {
541
+ event = JSON.parse(line);
542
+ }
543
+ catch {
544
+ continue;
545
+ }
546
+ if (typeof event.model === 'string' && event.model !== '')
547
+ return event.model;
548
+ if (typeof event.message?.model === 'string' && event.message.model !== '')
549
+ return event.message.model;
550
+ }
551
+ return undefined;
552
+ }
553
+ /** The project directories under one scoped home, capped; empty on any failure. */
554
+ async function projectDirs(homeDir) {
555
+ try {
556
+ return (await readdir(join(homeDir, PROJECTS_ROOT))).slice(0, TRANSCRIPT_SCAN_DIRS);
557
+ }
558
+ catch {
559
+ return [];
560
+ }
561
+ }
562
+ /** The jsonl file names inside one project directory; empty on any failure. */
563
+ async function projectFiles(homeDir, projectDir) {
564
+ try {
565
+ return (await readdir(join(homeDir, PROJECTS_ROOT, projectDir))).filter(name => name.endsWith('.jsonl'));
566
+ }
567
+ catch {
568
+ return [];
569
+ }
570
+ }
571
+ /**
572
+ * Whether a sidechain (`agent-*.jsonl`) transcript belongs to one session:
573
+ * its head lines carry the PARENT session's id (`isSidechain: true`). Only
574
+ * the bounded head is read.
575
+ */
576
+ async function sidechainSessionMatches(path, cliSessionId) {
577
+ let text;
578
+ try {
579
+ const handle = await open(path, 'r');
580
+ try {
581
+ const buffer = Buffer.alloc(HEAD_BYTES);
582
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
583
+ text = buffer.subarray(0, bytesRead).toString('utf8');
584
+ }
585
+ finally {
586
+ await handle.close();
587
+ }
588
+ }
589
+ catch {
590
+ return false;
591
+ }
592
+ for (const raw of text.split('\n')) {
593
+ const line = raw.trim();
594
+ if (line === '')
595
+ continue;
596
+ try {
597
+ if (JSON.parse(line).sessionId === cliSessionId)
598
+ return true;
599
+ }
600
+ catch {
601
+ continue;
602
+ }
603
+ }
604
+ return false;
605
+ }
606
+ /**
607
+ * The model a member's OWN CLI transcript names — the history backstop for
608
+ * the broker's `lastObserved` when no delegation record observed one (every
609
+ * member whose rounds predate the live-settle report). The live-settle
610
+ * channel remains primary (the record's `observedModel` wins when present);
611
+ * this reader only answers "what did this session last run" from the CLI's
612
+ * own files, read-only — nothing is backfilled into `delegations.jsonl`.
613
+ *
614
+ * Location rule: with `cliSessionId`, the main transcript
615
+ * `projects/<cwd-slug>/<cliSessionId>.jsonl` (found by NAME across the
616
+ * project dirs — the slug is lossy, so it is never computed); when it is
617
+ * absent, a sidechain `agent-*.jsonl` whose head names the session id
618
+ * (bounded candidate count) answers instead. Without `cliSessionId` (the
619
+ * settings card's memberless read), the NEWEST transcript across the
620
+ * projects tree (mtime, bounded dir/file scan) answers; a newest file that
621
+ * names no model yields to the next, up to a few tries. Every failure —
622
+ * missing tree, unreadable file, no model in the read window — is
623
+ * `undefined`, never a throw.
624
+ * @param homeDir - the `claude-code` harness's scoped home.
625
+ * @param cliSessionId - the delegation record's CLI session id, when the
626
+ * member's own transcript is wanted.
627
+ * @returns the model identifier, or undefined.
628
+ */
629
+ export async function readClaudeTranscriptModel(homeDir, cliSessionId) {
630
+ if (cliSessionId !== undefined) {
631
+ let main;
632
+ const sidechains = [];
633
+ for (const projectDir of await projectDirs(homeDir)) {
634
+ for (const name of await projectFiles(homeDir, projectDir)) {
635
+ if (name === `${cliSessionId}.jsonl` && main === undefined) {
636
+ main = join(homeDir, PROJECTS_ROOT, projectDir, name);
637
+ continue;
638
+ }
639
+ if (name.startsWith('agent-') && sidechains.length < TRANSCRIPT_SIDECHAIN_FILES) {
640
+ sidechains.push(join(homeDir, PROJECTS_ROOT, projectDir, name));
641
+ }
642
+ }
643
+ }
644
+ // The main transcript answers first; a main file that names no model in
645
+ // its tail window yields to the sidechains.
646
+ if (main !== undefined) {
647
+ const model = await transcriptFileModel(main);
648
+ if (model !== undefined)
649
+ return model;
650
+ }
651
+ // A sidechain sharing the session id is the only other place the
652
+ // session's model is written (e.g. the round ran as a Task subagent, or
653
+ // the main file is too sparse to name one).
654
+ for (const path of sidechains) {
655
+ if (await sidechainSessionMatches(path, cliSessionId)) {
656
+ const model = await transcriptFileModel(path);
657
+ if (model !== undefined)
658
+ return model;
659
+ }
660
+ }
661
+ return undefined;
662
+ }
663
+ // Harness level: the newest transcript in the tree speaks for the harness.
664
+ const candidates = [];
665
+ let scanned = 0;
666
+ for (const projectDir of await projectDirs(homeDir)) {
667
+ for (const name of await projectFiles(homeDir, projectDir)) {
668
+ if (scanned >= TRANSCRIPT_SCAN_FILES)
669
+ break;
670
+ scanned += 1;
671
+ const path = join(homeDir, PROJECTS_ROOT, projectDir, name);
672
+ try {
673
+ candidates.push({ path, mtimeMs: (await stat(path)).mtimeMs });
674
+ }
675
+ catch {
676
+ continue;
677
+ }
678
+ }
679
+ }
680
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
681
+ for (const candidate of candidates.slice(0, TRANSCRIPT_NEWEST_TRIES)) {
682
+ const model = await transcriptFileModel(candidate.path);
683
+ if (model !== undefined)
684
+ return model;
685
+ }
686
+ return undefined;
687
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Claude Delegation",
4
+ "description": "Delegate coding tasks to local Claude Code with live answers."
5
+ }
6
+ }