@1agents/session-reader 0.5.1 → 0.6.1

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.
@@ -0,0 +1,554 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readJsonl } from '../util/jsonl.js';
5
+ import { canonicalizePath } from '../util/paths.js';
6
+ import { clip, looksLikeInstructions, oneLine, stripPromptEnvelope } from '../util/text.js';
7
+ import { toolArgsOf } from './provider.js';
8
+ import { emptyProviderStats, } from '../types.js';
9
+ /**
10
+ * Grok keeps one directory per session, filed under the percent-encoded cwd:
11
+ * `sessions/%2FUsers%2Fme%2Fproj/<uuid>/`. The conversation of record is
12
+ * `chat_history.jsonl`, which carries no timestamps at all — those live in the
13
+ * side files next to it, so this parser reads the directory, not one file.
14
+ */
15
+ const SESSIONS_DIR = path.join(os.homedir(), '.grok', 'sessions');
16
+ const TRANSCRIPT = 'chat_history.jsonl';
17
+ const SESSION_ID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
18
+ /** `<system-reminder>` receipts Grok injects as user messages. */
19
+ const TASK_RECEIPT = /Background task "([^"]+)"/;
20
+ const SUBAGENT_RECEIPT = /Background subagent "([^"]+)"/;
21
+ const RECEIPT_COMMAND = /^Command:\s*(.+)$/m;
22
+ /** Grok wraps the real prompt; the envelope around it is boilerplate. */
23
+ const USER_QUERY = /<user_query>([\s\S]*?)<\/user_query>/;
24
+ /**
25
+ * Matches a message to the moment it was streamed by its own text — an
26
+ * identity, not an alignment, so it survives the two files disagreeing about
27
+ * how many messages there were. Repeats are handed out in stream order.
28
+ */
29
+ function clockByText(chunks) {
30
+ const byText = new Map();
31
+ for (const chunk of chunks) {
32
+ const times = byText.get(chunk.text);
33
+ if (times)
34
+ times.push(chunk.at);
35
+ else
36
+ byText.set(chunk.text, [chunk.at]);
37
+ }
38
+ return (text) => byText.get(text)?.shift();
39
+ }
40
+ function textOf(content) {
41
+ if (typeof content === 'string')
42
+ return content;
43
+ if (!Array.isArray(content))
44
+ return '';
45
+ return content
46
+ .map((block) => block?.text ?? '')
47
+ .filter(Boolean)
48
+ .join('\n');
49
+ }
50
+ function parseArguments(value) {
51
+ if (typeof value === 'string') {
52
+ try {
53
+ return toolArgsOf(JSON.parse(value)) ?? { input: value };
54
+ }
55
+ catch {
56
+ return { input: value };
57
+ }
58
+ }
59
+ return toolArgsOf(value);
60
+ }
61
+ /**
62
+ * The side files come and go — a subagent session has no `rewind_points.jsonl`,
63
+ * an older one no `updates.jsonl`. A missing file is an absence of facts, not a
64
+ * reason to fail the session that the transcript itself describes fine.
65
+ */
66
+ async function* optionalJsonl(file) {
67
+ if (!(await fs.stat(file).then((stat) => stat.isFile()).catch(() => false)))
68
+ return;
69
+ yield* readJsonl(file);
70
+ }
71
+ async function readJson(file) {
72
+ return fs
73
+ .readFile(file, 'utf8')
74
+ .then((raw) => JSON.parse(raw))
75
+ .catch(() => undefined);
76
+ }
77
+ /**
78
+ * `tool_started` carries no id and `tool_completed` carries no start, so the
79
+ * two streams are zipped by position — and only when they line up exactly.
80
+ * A mismatch means the session was cut mid-call; ids still give the ends.
81
+ */
82
+ async function readTimings(sessionDir, stats) {
83
+ const timings = new Map();
84
+ const started = [];
85
+ const completed = [];
86
+ for await (const raw of optionalJsonl(path.join(sessionDir, 'events.jsonl'))) {
87
+ const event = raw;
88
+ switch (event.type) {
89
+ case 'tool_started':
90
+ if (event.ts)
91
+ started.push(event.ts);
92
+ break;
93
+ case 'tool_completed':
94
+ completed.push({
95
+ ...(event.tool_call_id ? { id: event.tool_call_id } : {}),
96
+ ...(event.ts ? { ts: event.ts } : {}),
97
+ ...(typeof event.duration_ms === 'number' ? { durationMs: event.duration_ms } : {}),
98
+ failed: event.outcome !== undefined && event.outcome !== 'success',
99
+ });
100
+ break;
101
+ case 'turn_started':
102
+ stats.turnBoundaries.push({
103
+ id: String(raw.turn_number ?? stats.turnBoundaries.length),
104
+ ...(event.ts ? { startedAt: event.ts } : {}),
105
+ completed: false,
106
+ });
107
+ break;
108
+ case 'turn_ended': {
109
+ // Only ever one turn open at a time, so the oldest unfinished one is it.
110
+ // A turn that ended badly still ended: the boundary is paired either
111
+ // way, and *how* it ended is a separate fact kept in `extras`.
112
+ const open = stats.turnBoundaries.find((boundary) => !boundary.completed);
113
+ if (open) {
114
+ open.completed = true;
115
+ if (event.ts)
116
+ open.endedAt = event.ts;
117
+ if (open.startedAt && event.ts) {
118
+ open.durationMs = Math.max(0, Date.parse(event.ts) - Date.parse(open.startedAt));
119
+ }
120
+ }
121
+ if (event.outcome && event.outcome !== 'completed') {
122
+ stats.extras[`turn:${event.outcome}`] = (stats.extras[`turn:${event.outcome}`] ?? 0) + 1;
123
+ }
124
+ break;
125
+ }
126
+ case 'mcp_server_connected':
127
+ stats.extras.mcpServers = (stats.extras.mcpServers ?? 0) + 1;
128
+ break;
129
+ default:
130
+ break;
131
+ }
132
+ }
133
+ const zipped = started.length === completed.length;
134
+ completed.forEach((record, i) => {
135
+ if (!record.id)
136
+ return;
137
+ timings.set(record.id, {
138
+ ...(zipped && started[i] ? { startedAt: started[i] } : {}),
139
+ ...(record.ts ? { endedAt: record.ts } : {}),
140
+ ...(record.durationMs !== undefined ? { durationMs: record.durationMs } : {}),
141
+ ...(record.failed ? { failed: true } : {}),
142
+ });
143
+ });
144
+ return timings;
145
+ }
146
+ /** `prompt_index` → the moment Grok recorded for that prompt. */
147
+ async function readPromptTimes(sessionDir) {
148
+ const times = new Map();
149
+ for await (const raw of optionalJsonl(path.join(sessionDir, 'rewind_points.jsonl'))) {
150
+ const point = raw;
151
+ if (typeof point.prompt_index === 'number' && point.created_at) {
152
+ times.set(point.prompt_index, point.created_at);
153
+ }
154
+ }
155
+ return times;
156
+ }
157
+ /**
158
+ * The ACP stream Grok mirrors every session to — present for most sessions but
159
+ * not all, so everything it gives is an enrichment, never a prerequisite.
160
+ */
161
+ async function readUpdates(sessionDir) {
162
+ const file = path.join(sessionDir, 'updates.jsonl');
163
+ if (!(await fs.stat(file).then(() => true).catch(() => false)))
164
+ return undefined;
165
+ const timing = { user: [], thought: [], message: [], byCall: new Map(), plans: 0 };
166
+ const tokens = { input: 0, output: 0, total: 0 };
167
+ let cacheRead = 0;
168
+ for await (const raw of readJsonl(file)) {
169
+ const line = raw;
170
+ const update = line.params?.update;
171
+ if (!update)
172
+ continue;
173
+ // Seconds since the epoch, unlike every other file in the directory.
174
+ const at = typeof line.timestamp === 'number' ? new Date(line.timestamp * 1000).toISOString() : undefined;
175
+ const callId = update.toolCallId;
176
+ switch (update.sessionUpdate) {
177
+ case 'user_message_chunk':
178
+ if (at)
179
+ timing.user.push(at);
180
+ break;
181
+ case 'agent_thought_chunk':
182
+ case 'agent_message_chunk': {
183
+ const text = update.content?.text;
184
+ if (!at || !text)
185
+ break;
186
+ const into = update.sessionUpdate === 'agent_thought_chunk' ? timing.thought : timing.message;
187
+ into.push({ text, at });
188
+ break;
189
+ }
190
+ case 'tool_call':
191
+ if (callId && at)
192
+ timing.byCall.set(callId, { ...(timing.byCall.get(callId) ?? {}), calledAt: at });
193
+ break;
194
+ case 'tool_call_update':
195
+ if (callId && at && update.status === 'completed') {
196
+ timing.byCall.set(callId, { ...(timing.byCall.get(callId) ?? {}), endedAt: at });
197
+ }
198
+ break;
199
+ case 'plan':
200
+ timing.plans++;
201
+ break;
202
+ case 'turn_completed': {
203
+ const usage = update.usage;
204
+ if (usage) {
205
+ // Grok counts cache reads *inside* `inputTokens`; reporting them as
206
+ // input would turn a 200k-context session into "39M tokens".
207
+ const cached = usage.cachedReadTokens ?? 0;
208
+ tokens.input += Math.max(0, (usage.inputTokens ?? 0) - cached);
209
+ tokens.output += usage.outputTokens ?? 0;
210
+ cacheRead += cached;
211
+ }
212
+ break;
213
+ }
214
+ default:
215
+ break;
216
+ }
217
+ }
218
+ if (tokens.input || tokens.output) {
219
+ timing.tokens = {
220
+ ...tokens,
221
+ total: tokens.input + tokens.output,
222
+ ...(cacheRead ? { cacheRead } : {}),
223
+ };
224
+ }
225
+ return timing;
226
+ }
227
+ /** Plans and walkthroughs Grok's goal tracker writes next to the transcript. */
228
+ async function readArtifacts(sessionDir) {
229
+ const dir = path.join(sessionDir, 'goal');
230
+ const artifacts = [];
231
+ for (const name of await fs.readdir(dir).catch(() => [])) {
232
+ if (!name.endsWith('.md'))
233
+ continue;
234
+ const full = path.join(dir, name);
235
+ const stat = await fs.stat(full).catch(() => undefined);
236
+ if (!stat?.isFile())
237
+ continue;
238
+ const content = await fs.readFile(full, 'utf8').catch(() => undefined);
239
+ artifacts.push({
240
+ name,
241
+ path: full,
242
+ kind: 'markdown',
243
+ ...(content ? { content, summary: oneLine(content.split('\n').find((line) => line.trim()), 120) } : {}),
244
+ sizeBytes: stat.size,
245
+ updatedAt: new Date(stat.mtimeMs).toISOString(),
246
+ });
247
+ }
248
+ return artifacts;
249
+ }
250
+ /**
251
+ * A receipt Grok injects when a background task or subagent reports back. Its
252
+ * presence is the completion proof `jobLedger` otherwise has to do without.
253
+ */
254
+ function receiptOf(text, logDir) {
255
+ const task = TASK_RECEIPT.exec(text);
256
+ const id = task?.[1] ?? SUBAGENT_RECEIPT.exec(text)?.[1];
257
+ if (!id)
258
+ return undefined;
259
+ // The command for a task, the agent descriptor for a subagent.
260
+ const title = RECEIPT_COMMAND.exec(text)?.[1] ?? text.split('\n').find((line) => line.includes(id));
261
+ return {
262
+ id,
263
+ ...(title ? { title: clip(title.trim(), 120) } : {}),
264
+ ...(task ? { log: path.join(logDir, `${id}.log`) } : {}),
265
+ finished: true,
266
+ };
267
+ }
268
+ /** `sessions/<percent-encoded cwd>/<uuid>/`, one entry per session. */
269
+ async function sessionDirs() {
270
+ let projects;
271
+ try {
272
+ projects = await fs.readdir(SESSIONS_DIR);
273
+ }
274
+ catch {
275
+ return [];
276
+ }
277
+ const found = [];
278
+ for (const project of projects) {
279
+ // Everything Grok files here starts with the encoded leading `/`; the
280
+ // loose `session_search.sqlite` and the dotfiles beside it do not.
281
+ if (!project.startsWith('%2F'))
282
+ continue;
283
+ for (const entry of await fs.readdir(path.join(SESSIONS_DIR, project)).catch(() => [])) {
284
+ if (!SESSION_ID.test(entry))
285
+ continue;
286
+ found.push({ id: entry, dir: path.join(SESSIONS_DIR, project, entry) });
287
+ }
288
+ }
289
+ return found;
290
+ }
291
+ /**
292
+ * The directory name is the percent-encoded cwd, so it decodes back exactly —
293
+ * unlike Claude's lossy slug, this is an answer rather than a prefilter.
294
+ */
295
+ export function workspaceFromProjectDir(name) {
296
+ try {
297
+ return canonicalizePath(decodeURIComponent(name));
298
+ }
299
+ catch {
300
+ return '';
301
+ }
302
+ }
303
+ export const grokAdapter = {
304
+ provider: 'grok',
305
+ async listCandidates() {
306
+ const found = [];
307
+ for (const { id, dir } of await sessionDirs()) {
308
+ const transcript = path.join(dir, TRANSCRIPT);
309
+ const stat = await fs.stat(transcript).catch(() => undefined);
310
+ // A directory holding nothing but `summary.json` is a session that never
311
+ // produced a transcript; there is nothing to read.
312
+ if (!stat?.isFile())
313
+ continue;
314
+ // `summary.json` is rewritten on every update, so the newer of the two
315
+ // mtimes is what "when did this session last move" actually means.
316
+ const summary = await fs.stat(path.join(dir, 'summary.json')).catch(() => undefined);
317
+ found.push({
318
+ id,
319
+ path: transcript,
320
+ mtimeMs: Math.max(stat.mtimeMs, summary?.mtimeMs ?? 0),
321
+ sizeBytes: stat.size,
322
+ });
323
+ }
324
+ return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
325
+ },
326
+ workspaceOf(candidate) {
327
+ return workspaceFromProjectDir(path.basename(path.dirname(path.dirname(candidate.path)))) || undefined;
328
+ },
329
+ /** The side files change without the transcript growing; fold them in. */
330
+ async auxFingerprint(candidate) {
331
+ const dir = path.dirname(candidate.path);
332
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => undefined);
333
+ if (!entries)
334
+ return undefined;
335
+ let newest = 0;
336
+ let count = 0;
337
+ for (const entry of entries) {
338
+ if (!entry.isFile())
339
+ continue;
340
+ const stat = await fs.stat(path.join(dir, entry.name)).catch(() => undefined);
341
+ if (!stat)
342
+ continue;
343
+ count++;
344
+ newest = Math.max(newest, Math.round(stat.mtimeMs));
345
+ }
346
+ return `${count}:${newest}`;
347
+ },
348
+ async scanRef(candidate) {
349
+ const dir = path.dirname(candidate.path);
350
+ const summary = await readJson(path.join(dir, 'summary.json'));
351
+ let title = summary?.generated_title || summary?.session_summary || undefined;
352
+ const workspace = summary?.info?.cwd
353
+ ? canonicalizePath(summary.info.cwd)
354
+ : workspaceFromProjectDir(path.basename(path.dirname(dir)));
355
+ if (!title) {
356
+ for await (const raw of readJsonl(candidate.path, { maxLines: 40 })) {
357
+ const line = raw;
358
+ if (line.type !== 'user' || line.synthetic_reason)
359
+ continue;
360
+ const text = stripPromptEnvelope(textOf(line.content));
361
+ if (text && !looksLikeInstructions(text)) {
362
+ title = oneLine(text, 120);
363
+ break;
364
+ }
365
+ }
366
+ }
367
+ return {
368
+ id: candidate.id,
369
+ provider: 'grok',
370
+ path: candidate.path,
371
+ ...(title ? { title } : {}),
372
+ ...(workspace ? { workspace } : {}),
373
+ ...(summary?.created_at ? { createdAt: summary.created_at } : {}),
374
+ updatedAt: summary?.updated_at ?? summary?.last_active_at ?? new Date(candidate.mtimeMs).toISOString(),
375
+ sizeBytes: candidate.sizeBytes,
376
+ };
377
+ },
378
+ async parse(candidate) {
379
+ const dir = path.dirname(candidate.path);
380
+ const stats = emptyProviderStats();
381
+ const turns = [];
382
+ const summary = await readJson(path.join(dir, 'summary.json'));
383
+ const signals = await readJson(path.join(dir, 'signals.json'));
384
+ const timings = await readTimings(dir, stats);
385
+ const promptTimes = await readPromptTimes(dir);
386
+ const updates = await readUpdates(dir);
387
+ const artifacts = await readArtifacts(dir);
388
+ const logDir = path.join(dir, 'terminal');
389
+ if (summary?.current_model_id)
390
+ stats.models.push(summary.current_model_id);
391
+ if (summary?.head_branch)
392
+ stats.branches.push(summary.head_branch);
393
+ if (summary?.session_kind && summary.session_kind !== 'primary') {
394
+ stats.extras[`session:${summary.session_kind}`] = 1;
395
+ }
396
+ if (signals?.compactionCount)
397
+ stats.extras.compactions = signals.compactionCount;
398
+ if (updates?.plans)
399
+ stats.extras.plans = updates.plans;
400
+ if (updates?.tokens)
401
+ stats.tokens = updates.tokens;
402
+ // Messages carry no id in either file, so a message is matched to its
403
+ // streamed moment by its own text. The transcript sometimes concatenates
404
+ // what the stream sent in pieces, so whatever is left over falls back to
405
+ // position — and only when the two counts agree, because a shifted
406
+ // timestamp is worse than a missing one.
407
+ const positions = {
408
+ user: [],
409
+ thought: [],
410
+ message: [],
411
+ };
412
+ const thoughtAt = clockByText(updates?.thought ?? []);
413
+ const messageAt = clockByText(updates?.message ?? []);
414
+ let title = summary?.generated_title || summary?.session_summary || undefined;
415
+ let firstPrompt;
416
+ // An explicit `timestamp: undefined` is not the same object as one without
417
+ // the key, and the index stores absent columns as absent — so a session read
418
+ // back from it would stop deep-equalling a fresh parse.
419
+ const push = ({ timestamp, ...turn }) => {
420
+ turns.push({
421
+ ...turn,
422
+ ...(timestamp ? { timestamp } : {}),
423
+ index: turns.length,
424
+ id: `${candidate.id}#${turns.length}`,
425
+ });
426
+ };
427
+ for await (const raw of readJsonl(candidate.path)) {
428
+ const line = raw;
429
+ switch (line.type) {
430
+ case 'user': {
431
+ const rawText = textOf(line.content);
432
+ if (line.synthetic_reason) {
433
+ // Receipts are the only synthetic messages carrying new facts;
434
+ // the rest is boilerplate the harness re-injects every turn.
435
+ const receipt = receiptOf(rawText, logDir);
436
+ if (!receipt) {
437
+ const key = `injected:${line.synthetic_reason}`;
438
+ stats.extras[key] = (stats.extras[key] ?? 0) + 1;
439
+ break;
440
+ }
441
+ if (!stats.backgroundTasks.some((task) => task.id === receipt.id)) {
442
+ stats.backgroundTasks.push(receipt);
443
+ }
444
+ push({
445
+ kind: 'tool_result',
446
+ toolResult: rawText.replace(/<\/?system-reminder>/g, '').trim(),
447
+ timestamp: promptTimes.get(line.prompt_index ?? -1),
448
+ });
449
+ break;
450
+ }
451
+ const query = USER_QUERY.exec(rawText)?.[1] ?? rawText;
452
+ const text = stripPromptEnvelope(query);
453
+ if (!text || looksLikeInstructions(text))
454
+ break;
455
+ firstPrompt ??= oneLine(text, 120);
456
+ positions.user.push(turns.length);
457
+ push({ kind: 'user', text, timestamp: promptTimes.get(line.prompt_index ?? -1) });
458
+ break;
459
+ }
460
+ case 'reasoning': {
461
+ const text = (line.summary ?? []).map((block) => block?.text ?? '').filter(Boolean).join('\n');
462
+ if (!text)
463
+ break;
464
+ positions.thought.push(turns.length);
465
+ push({ kind: 'thinking', text, timestamp: thoughtAt(text) });
466
+ break;
467
+ }
468
+ case 'assistant': {
469
+ if (line.model_id && !stats.models.includes(line.model_id))
470
+ stats.models.push(line.model_id);
471
+ const text = typeof line.content === 'string' ? line.content : textOf(line.content);
472
+ if (text.trim()) {
473
+ positions.message.push(turns.length);
474
+ push({ kind: 'assistant', text, timestamp: messageAt(text) });
475
+ }
476
+ for (const call of line.tool_calls ?? []) {
477
+ const timing = call.id ? timings.get(call.id) : undefined;
478
+ const stream = call.id ? updates?.byCall.get(call.id) : undefined;
479
+ push({
480
+ kind: 'tool_call',
481
+ toolName: call.name,
482
+ toolArgs: parseArguments(call.arguments),
483
+ timestamp: timing?.startedAt ?? stream?.calledAt,
484
+ });
485
+ }
486
+ break;
487
+ }
488
+ case 'tool_result': {
489
+ const timing = line.tool_call_id ? timings.get(line.tool_call_id) : undefined;
490
+ const stream = line.tool_call_id ? updates?.byCall.get(line.tool_call_id) : undefined;
491
+ push({
492
+ kind: 'tool_result',
493
+ toolResult: textOf(line.content) || String(line.content ?? ''),
494
+ // Grok stores the outcome in the event log, never on the result.
495
+ ...(timing ? { isError: timing.failed === true } : {}),
496
+ timestamp: timing?.endedAt ?? stream?.endedAt,
497
+ ...(timing?.durationMs !== undefined ? { durationMs: timing.durationMs } : {}),
498
+ });
499
+ break;
500
+ }
501
+ case 'backend_tool_call': {
502
+ const kind = line.kind?.tool_type ?? 'unknown';
503
+ stats.extras[`backend:${kind}`] = (stats.extras[`backend:${kind}`] ?? 0) + 1;
504
+ break;
505
+ }
506
+ default:
507
+ break;
508
+ }
509
+ }
510
+ // The stream is only trusted when it produced exactly as many chunks of a
511
+ // kind as the transcript has events of it; anything else means the two
512
+ // files disagree about what happened, and then neither is a clock.
513
+ const streams = {
514
+ user: updates?.user ?? [],
515
+ thought: (updates?.thought ?? []).map((chunk) => chunk.at),
516
+ message: (updates?.message ?? []).map((chunk) => chunk.at),
517
+ };
518
+ for (const kind of ['user', 'thought', 'message']) {
519
+ const stream = streams[kind];
520
+ const where = positions[kind];
521
+ if (!stream.length || stream.length !== where.length)
522
+ continue;
523
+ where.forEach((index, i) => {
524
+ const event = turns[index];
525
+ if (event && !event.timestamp)
526
+ event.timestamp = stream[i];
527
+ });
528
+ }
529
+ // A task receipt names the log Grok writes under `terminal/`; keep the
530
+ // pointer only while that file is still on disk.
531
+ stats.backgroundTasks = await Promise.all(stats.backgroundTasks.map(async (task) => task.log && !(await fs.stat(task.log).then(() => true).catch(() => false))
532
+ ? { id: task.id, ...(task.title ? { title: task.title } : {}), finished: task.finished }
533
+ : task));
534
+ title ??= firstPrompt;
535
+ const workspace = summary?.info?.cwd
536
+ ? canonicalizePath(summary.info.cwd)
537
+ : workspaceFromProjectDir(path.basename(path.dirname(dir)));
538
+ return {
539
+ ref: {
540
+ id: candidate.id,
541
+ provider: 'grok',
542
+ path: candidate.path,
543
+ ...(title ? { title } : {}),
544
+ ...(workspace ? { workspace } : {}),
545
+ ...(summary?.created_at ? { createdAt: summary.created_at } : {}),
546
+ updatedAt: summary?.updated_at ?? summary?.last_active_at ?? new Date(candidate.mtimeMs).toISOString(),
547
+ sizeBytes: candidate.sizeBytes,
548
+ },
549
+ turns,
550
+ artifacts,
551
+ stats,
552
+ };
553
+ },
554
+ };
@@ -14,6 +14,13 @@ export interface ProviderAdapter {
14
14
  scanRef(candidate: SessionCandidate): Promise<SessionRef>;
15
15
  /** Full parse into ordered turns. */
16
16
  parse(candidate: SessionCandidate): Promise<NormalizedSession>;
17
+ /**
18
+ * The workspace the file's own location proves, for providers that encode
19
+ * the cwd losslessly in the path (Grok percent-encodes it). Lets a scoped
20
+ * listing reject a candidate before opening it — never a substitute for the
21
+ * workspace `scanRef` reads out of the session itself.
22
+ */
23
+ workspaceOf?(candidate: SessionCandidate): string | undefined;
17
24
  /**
18
25
  * Extra identity for providers whose `parse` reads more than the transcript
19
26
  * (antigravity also picks up artifacts next to it). Folded into the index
@@ -3,8 +3,16 @@ import path from 'node:path';
3
3
  import { antigravityAdapter } from './parsers/antigravity.js';
4
4
  import { claudeAdapter } from './parsers/claude.js';
5
5
  import { codexAdapter } from './parsers/codex.js';
6
+ import { dshAdapter } from './parsers/dsh.js';
7
+ import { grokAdapter } from './parsers/grok.js';
6
8
  import { canonicalizePath, isInside, slugifyWorkspace } from './util/paths.js';
7
- export const adapters = [antigravityAdapter, claudeAdapter, codexAdapter];
9
+ export const adapters = [
10
+ antigravityAdapter,
11
+ claudeAdapter,
12
+ codexAdapter,
13
+ dshAdapter,
14
+ grokAdapter,
15
+ ];
8
16
  /** How many files we are willing to open when nothing narrows the search. */
9
17
  const DEFAULT_SCAN = 60;
10
18
  /** A workspace filter rejects most candidates, so it needs a wider net. */
@@ -31,11 +39,16 @@ function matchesWorkspace(ref, workspace) {
31
39
  return isInside(workspace, ref.workspace);
32
40
  }
33
41
  /**
34
- * Claude stores sessions under a slugified cwd, so the directory name alone
35
- * tells us whether a file can possibly belong to the workspace.
42
+ * Whether a file can possibly belong to the workspace, judged from its path
43
+ * alone. Only ever used to skip an open, so it must never reject a session it
44
+ * is unsure about: Claude's slug is lossy (`-` stands for several characters),
45
+ * Grok's percent-encoded directory is exact, everyone else says "maybe".
36
46
  */
37
- function couldBelong(candidate, provider, workspace) {
38
- if (provider !== 'claude')
47
+ function couldBelong(candidate, adapter, workspace) {
48
+ const exact = adapter.workspaceOf?.(candidate);
49
+ if (exact)
50
+ return isInside(workspace, exact);
51
+ if (adapter.provider !== 'claude')
39
52
  return true;
40
53
  const slug = slugifyWorkspace(workspace);
41
54
  const dir = path.basename(path.dirname(candidate.path));
@@ -61,7 +74,7 @@ export async function listResolvedSessions(options = {}) {
61
74
  break; // candidates are newest first
62
75
  if (scanned >= budget)
63
76
  break;
64
- if (workspace && !couldBelong(candidate, adapter.provider, workspace))
77
+ if (workspace && !couldBelong(candidate, adapter, workspace))
65
78
  continue;
66
79
  scanned++;
67
80
  const ref = await adapter.scanRef(candidate).catch(() => undefined);
@@ -22,11 +22,19 @@ export const SESSION_CAPABILITIES = [
22
22
  ];
23
23
  export async function buildManifest(baseUrl) {
24
24
  const identity = await nodeIdentity();
25
- // 有 MagicDNS 就用它:`http://scott-mac:7777` 跨网络稳定,不怕 IP 变,
26
- // 而请求里的 Host 只是"调用方碰巧用了哪个地址"。
27
- const advertised = identity.dnsName
28
- ? baseUrl.replace(/\/\/[^/]+/, `//${identity.dnsName}:${new URL(baseUrl).port || String(DEFAULT_PORTS['session-registry'])}`)
29
- : baseUrl;
25
+ const port = new URL(baseUrl).port || String(DEFAULT_PORTS['session-registry']);
26
+ const at = (host) => ({
27
+ protocol: 'http',
28
+ base_url: `http://${host}:${port}/v1`,
29
+ });
30
+ // MagicDNS 名在前(可读,IP 变了也不用改),tailnet IP 兜底:调用方的 DNS
31
+ // 可能被劫持——实测一台装了 fake-ip 代理的 Mac 会把 MagicDNS 名解析到
32
+ // 198.18.x.x,只给名字的话那台机器就永远连不上。
33
+ // 请求里的 Host 只是"调用方碰巧用了哪个地址",两者都拿不到时才退回它。
34
+ const access = [
35
+ ...(identity.dnsName ? [at(identity.dnsName)] : []),
36
+ ...(identity.ipv4 && identity.ipv4 !== identity.dnsName ? [at(identity.ipv4)] : []),
37
+ ];
30
38
  return {
31
39
  node_id: identity.node_id,
32
40
  name: identity.name,
@@ -41,7 +49,7 @@ export async function buildManifest(baseUrl) {
41
49
  kind: 'session_registry',
42
50
  capabilities: [...SESSION_CAPABILITIES],
43
51
  resources: [{ scheme: 'session', description: 'session://<node>/<runtime>/<session_id>' }],
44
- access: [{ protocol: 'http', base_url: `${advertised}/v1` }],
52
+ access: access.length > 0 ? access : [{ protocol: 'http', base_url: `${baseUrl}/v1` }],
45
53
  },
46
54
  ],
47
55
  };
@@ -1,6 +1,6 @@
1
1
  /** The bundled skill's directory name, used as the entry name in every agent. */
2
2
  export declare const SKILL_NAME = "1session";
3
- export type SkillAgent = 'claude' | 'codex' | 'antigravity';
3
+ export type SkillAgent = 'claude' | 'codex' | 'antigravity' | 'grok' | 'dsh';
4
4
  export interface AgentTarget {
5
5
  agent: SkillAgent;
6
6
  /** Where this agent loads user skills from. */
@@ -9,8 +9,8 @@ export interface AgentTarget {
9
9
  homeDir: string;
10
10
  }
11
11
  /**
12
- * All three agents load `<dir>/<name>/SKILL.md` with the same YAML frontmatter,
13
- * so one bundled skill can serve all of them unchanged.
12
+ * Every one of them loads `<dir>/<name>/SKILL.md` with the same YAML
13
+ * frontmatter, so one bundled skill can serve all of them unchanged.
14
14
  */
15
15
  export declare function agentTargets(home?: string): AgentTarget[];
16
16
  /**