@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.
- package/README.en.md +120 -0
- package/README.i18n.yaml +6 -0
- package/README.md +120 -0
- package/cordis.patch.yml +42 -0
- package/lib/client.js +1065 -0
- package/lib/index.js +3771 -0
- package/lib/invariant.js +31 -0
- package/lib/types/claude-cli-provider.d.ts +389 -0
- package/lib/types/claude-cli-provider.js +1509 -0
- package/lib/types/client/SettingsCard.d.ts +78 -0
- package/lib/types/client/SettingsCard.js +198 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/index.js +87 -0
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/locales.js +55 -0
- package/lib/types/control-requests.d.ts +15 -0
- package/lib/types/control-requests.js +62 -0
- package/lib/types/index.d.ts +81 -0
- package/lib/types/index.js +300 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +33 -0
- package/lib/types/live-driver.d.ts +172 -0
- package/lib/types/live-driver.js +1057 -0
- package/lib/types/live-switch.d.ts +67 -0
- package/lib/types/live-switch.js +85 -0
- package/lib/types/model-broker.d.ts +131 -0
- package/lib/types/model-broker.js +248 -0
- package/lib/types/model-catalog.d.ts +27 -0
- package/lib/types/model-catalog.js +118 -0
- package/lib/types/provision.d.ts +63 -0
- package/lib/types/provision.js +132 -0
- package/lib/types/records.d.ts +120 -0
- package/lib/types/records.js +687 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- 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
|
+
}
|