@turns/dev 0.0.4 → 0.0.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.
@@ -8,7 +8,7 @@ import readline from 'node:readline';
8
8
  import { DatabaseSync } from 'node:sqlite';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { WebSocket } from 'ws';
11
- import { ACP_PROFILES, lettaSessionIdentity, PROCESS_HARNESSES, parseLettaSessionIdentity, processEventSessionId, processInvocation, RPC_PROFILES, resolveHarnessCommand, rpcFrameSessionIdentity, rpcUiRequestIsInformational, } from "./harness-registry.js";
11
+ import { ACP_PROFILES, lettaHeadlessEnvironment, lettaSessionIdentity, PROCESS_HARNESSES, parseLettaSessionIdentity, processEventSessionId, processInvocation, RPC_PROFILES, resolveHarnessCommand, rpcFrameSessionIdentity, rpcUiRequestIsInformational, } from "./harness-registry.js";
12
12
  import { recordLettaSessionContext } from "./letta-session-context.js";
13
13
  const socketIndex = process.argv.indexOf('--socket');
14
14
  const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
@@ -363,7 +363,7 @@ class LettaTransport extends LineTransport {
363
363
  ...(anchor.model && anchor.model !== 'auto' ? ['--model', anchor.model] : []),
364
364
  ...(createSession ? ['--new'] : ['--conversation', existing.conversationId]),
365
365
  ];
366
- super(anchor, 'letta', resolveHarnessCommand('letta', { command: 'letta' }), args, anchor.cwd);
366
+ super(anchor, 'letta', resolveHarnessCommand('letta', { command: 'letta' }), args, anchor.cwd, lettaHeadlessEnvironment());
367
367
  this.conversationId = createSession ? '' : existing.conversationId;
368
368
  this.agentId = createSession ? '' : existing.agentId;
369
369
  this.permissionMode = anchor.permissionMode;
@@ -868,6 +868,9 @@ class AcpTransport extends LineTransport {
868
868
  prompt: [{ type: 'text', text }],
869
869
  });
870
870
  }
871
+ if (this.profile.steer === 'session/prompt') {
872
+ return this.prompt(text, 12 * 60 * 60_000);
873
+ }
871
874
  return this.prompt(`/steer ${text}`, 12 * 60 * 60_000);
872
875
  }
873
876
  }
@@ -1436,9 +1439,11 @@ class Anchor {
1436
1439
  // send_message crosses a local socket after the queue claim crosses a
1437
1440
  // WebSocket, so either acknowledgement can be lost after the agent has
1438
1441
  // already accepted the turn. A replacement claim for the same prompt must
1439
- // adopt a running/successful turn instead of executing it again. Failed or
1440
- // interrupted turns remain retryable when the server issues a new claim.
1441
- if (sameClaim || (sameMessage && ['running', 'completed'].includes(this.currentTurn?.status))) {
1442
+ // adopt it regardless of outcome instead of repeating work or turning a
1443
+ // persistent harness failure into an automatic retry loop. A deliberate
1444
+ // user retry arrives with a new queue message ID.
1445
+ if (sameClaim ||
1446
+ (sameMessage && ['running', 'completed', 'interrupted', 'error'].includes(this.currentTurn?.status))) {
1442
1447
  if (claimToken && claimToken !== this.currentTurn.claimToken) {
1443
1448
  this.currentTurn = { ...this.currentTurn, claimToken };
1444
1449
  }
@@ -3,24 +3,20 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { workingDirectoryIsAllowed } from "./directory-policy.js";
6
- import { uploadFileBytes, uploadFileStream } from "./path-resolution.js";
6
+ import { uploadFileStream } from "./path-resolution.js";
7
7
  /**
8
8
  * Answering "what is in that directory?" from the machine that knows.
9
9
  *
10
10
  * A reader's browser cannot reach this machine, and this machine will not open
11
11
  * a port to let it try. Directories are small structured answers, so the daemon
12
- * sends their listing on the control socket immediately and stores the same
13
- * bytes in the background for offline browsing. Regular files still stream to
14
- * object storage; nothing stays open once the one browse upload finishes.
15
- *
16
- * A directory's fallback is stored at `_index.json` beneath it. Every path is
17
- * canonicalised before the allowlist is consulted, so a symlink pointing out
18
- * of an allowed tree is not a way to read the rest of the disk.
12
+ * sends their listing on the control socket for the server to normalize into
13
+ * SQLite. Regular files still stream to object storage; nothing stays open
14
+ * once the one browse upload finishes. Every path is canonicalised before the
15
+ * allowlist is consulted, so a symlink pointing out of an allowed tree is not
16
+ * a way to read the rest of the disk.
19
17
  */
20
18
  /** Kept in step with `MAX_DIRECTORY_ENTRIES` in the Worker's shared listing. */
21
19
  export const MAX_DIRECTORY_ENTRIES = 2_000;
22
- /** Kept in step with `DIRECTORY_INDEX_NAME` in the Worker's shared listing. */
23
- export const DIRECTORY_INDEX_NAME = '_index.json';
24
20
  /** One MB below the Worker request-body ceiling, matching the R2 endpoint. */
25
21
  export const MAX_BROWSE_FILE_BYTES = 199 * 1024 * 1024;
26
22
  /** The derived label used in reader-facing errors. */
@@ -31,9 +27,11 @@ export const FILE_TYPE_SAMPLE_BYTES = 64 * 1024;
31
27
  /** Extensions carrying their own type, where guessing text would be wrong. */
32
28
  const BINARY_CONTENT_TYPES = new Map([
33
29
  ['.apng', 'image/apng'],
30
+ ['.aac', 'audio/aac'],
34
31
  ['.avif', 'image/avif'],
35
32
  ['.bmp', 'image/bmp'],
36
33
  ['.gif', 'image/gif'],
34
+ ['.flac', 'audio/flac'],
37
35
  ['.gz', 'application/gzip'],
38
36
  ['.heic', 'image/heic'],
39
37
  ['.ico', 'image/x-icon'],
@@ -41,12 +39,20 @@ const BINARY_CONTENT_TYPES = new Map([
41
39
  ['.jpg', 'image/jpeg'],
42
40
  ['.mp3', 'audio/mpeg'],
43
41
  ['.mp4', 'video/mp4'],
42
+ ['.m4a', 'audio/mp4'],
43
+ ['.m4v', 'video/mp4'],
44
+ ['.mov', 'video/quicktime'],
45
+ ['.oga', 'audio/ogg'],
46
+ ['.ogg', 'audio/ogg'],
47
+ ['.ogv', 'video/ogg'],
48
+ ['.opus', 'audio/ogg'],
44
49
  ['.pdf', 'application/pdf'],
45
50
  ['.png', 'image/png'],
46
51
  ['.svg', 'image/svg+xml'],
47
52
  ['.wasm', 'application/wasm'],
48
53
  ['.webm', 'video/webm'],
49
54
  ['.webp', 'image/webp'],
55
+ ['.wav', 'audio/wav'],
50
56
  ['.zip', 'application/zip'],
51
57
  ]);
52
58
  /**
@@ -167,10 +173,6 @@ export function readDirectory(directory) {
167
173
  // clock inside the body would make every read produce different bytes.
168
174
  return { path: directory, entries, truncated: names.length > MAX_DIRECTORY_ENTRIES };
169
175
  }
170
- /** Where one directory's listing is stored, as a path in the machine's space. */
171
- export function directoryIndexPath(directory) {
172
- return `${String(directory || '').replace(/\/+$/u, '')}/${DIRECTORY_INDEX_NAME}`;
173
- }
174
176
  export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', fetchImpl = fetch, }) {
175
177
  const resolved = resolveBrowsePath(requested, {
176
178
  workingDirectory,
@@ -182,16 +184,6 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
182
184
  if (body.byteLength > MAX_BROWSE_FILE_BYTES)
183
185
  throw new Error(`Directory listing is too large: ${resolved.path}`);
184
186
  const etag = crypto.createHash('sha256').update(body).digest('hex');
185
- const cache = uploadFileBytes({
186
- baseUrl,
187
- options,
188
- machine,
189
- absolutePath: directoryIndexPath(resolved.path),
190
- contentType: 'application/json',
191
- contentEtag: etag,
192
- body,
193
- fetchImpl,
194
- });
195
187
  return {
196
188
  path: resolved.path,
197
189
  kind: 'directory',
@@ -199,7 +191,6 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
199
191
  contentType: 'application/json',
200
192
  listing,
201
193
  etag,
202
- cache,
203
194
  };
204
195
  }
205
196
  if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
@@ -207,8 +198,9 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
207
198
  }
208
199
  const sample = readFileTypeSample(resolved.path, resolved.stat.size);
209
200
  const contentType = browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size);
201
+ const modifiedAt = isoTime(resolved.stat.mtimeMs);
210
202
  if (resolved.stat.size === 0)
211
- return { path: resolved.path, kind: 'file', bytes: 0, contentType };
203
+ return { path: resolved.path, kind: 'file', bytes: 0, contentType, modifiedAt };
212
204
  await uploadFileStream({
213
205
  baseUrl,
214
206
  options,
@@ -216,7 +208,8 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
216
208
  absolutePath: resolved.path,
217
209
  contentType,
218
210
  bytes: resolved.stat.size,
211
+ modifiedAt,
219
212
  fetchImpl,
220
213
  });
221
- return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType };
214
+ return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType, modifiedAt };
222
215
  }
@@ -41,6 +41,10 @@ export const ACP_PROFILES = {
41
41
  command: 'opencode',
42
42
  args: ['acp'],
43
43
  homePaths: [['.opencode', 'bin', 'opencode']],
44
+ // OpenCode 1.18.21 gates its native task(background=true) schema behind
45
+ // this flag. Older versions ignore it; current versions expose a real
46
+ // independently addressable subagent lifecycle to daemon-launched turns.
47
+ env: { OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS: 'true' },
44
48
  options: [{ id: 'mode', value: 'build' }],
45
49
  mcp: true,
46
50
  },
@@ -101,6 +105,10 @@ export const ACP_PROFILES = {
101
105
  command: 'pool',
102
106
  args: ['acp'],
103
107
  resumeMethods: ['session/load'],
108
+ // Pool 1.0.16 implements ACP's same-session, concurrent prompt semantics:
109
+ // a second session/prompt while the first is running is folded into that
110
+ // turn. It is not the `/steer` slash command used by Hermes.
111
+ steer: 'session/prompt',
104
112
  // Poolside exposes permission policy as an ACP config option rather than
105
113
  // a session mode. Apply it per anchor so bypass runs cannot inherit its
106
114
  // interactive `default` (always ask) selection.
@@ -168,6 +176,27 @@ export function parseLettaSessionIdentity(value) {
168
176
  const match = /^letta:(agent-(?:local-)?[0-9a-f-]+):(.+)$/iu.exec(String(value || ''));
169
177
  return match ? { agentId: match[1], conversationId: match[2] } : { agentId: '', conversationId: String(value || '') };
170
178
  }
179
+ /**
180
+ * Letta's TUI stores cloud credentials in ~/.letta/settings.json, while its
181
+ * headless transport requires LETTA_API_KEY in the spawned process. Return a
182
+ * narrow child-only environment overlay; explicit daemon environment wins.
183
+ */
184
+ export function lettaHeadlessEnvironment(home = HOME) {
185
+ if (process.env.LETTA_API_KEY)
186
+ return {};
187
+ try {
188
+ const settings = JSON.parse(fs.readFileSync(path.join(home, '.letta', 'settings.json'), 'utf8'));
189
+ const apiKey = typeof settings?.env?.LETTA_API_KEY === 'string' ? settings.env.LETTA_API_KEY.trim() : '';
190
+ const baseUrl = typeof settings?.env?.LETTA_BASE_URL === 'string' ? settings.env.LETTA_BASE_URL.trim() : '';
191
+ return {
192
+ ...(apiKey ? { LETTA_API_KEY: apiKey } : {}),
193
+ ...(!process.env.LETTA_BASE_URL && baseUrl ? { LETTA_BASE_URL: baseUrl } : {}),
194
+ };
195
+ }
196
+ catch {
197
+ return {};
198
+ }
199
+ }
171
200
  export const RPC_PROFILES = {
172
201
  // Prime selects an existing session only at process launch. The transcript
173
202
  // path is authoritative (its header id can differ from its filename id), so
@@ -3,20 +3,24 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { DatabaseSync } from 'node:sqlite';
6
+ import { StringDecoder } from 'node:string_decoder';
6
7
  import { readDeepseekTrajectoryHeader } from "./deepseek-trajectories.js";
7
8
  import { lettaSessionIdentity } from "./harness-registry.js";
8
9
  import { readLettaSessionContext } from "./letta-session-context.js";
9
10
  const HOME = os.homedir();
11
+ const CLAUDE_LIVE_SESSIONS_ROOT = path.join(HOME, '.claude', 'sessions');
10
12
  const PROJECTION_ROOT = path.join(HOME, '.local', 'state', 'turns-dev', 'session-projections');
11
13
  const DEEPSEEK_SESSIONS_ROOT = path.join(path.resolve(process.env.DSH_HOME || path.join(HOME, '.dsh')), 'sessions');
12
14
  const JSON_FILE_CACHE = new Map();
13
15
  const CURSOR_WORKER_CWD_CACHE = new Map();
14
16
  const ANTIGRAVITY_TRANSCRIPT_CWD_CACHE = new Map();
17
+ const GROK_SPAWNED_SUBAGENT_CACHE = new Map();
15
18
  const SUBAGENT_MARKER_CACHE = new Map();
16
19
  const LINEAGE_CACHE = new Map();
17
20
  const STORE_DESCRIPTOR_CACHE = new Map();
18
21
  const MAX_METADATA_CACHE_ENTRIES = 16_384;
19
22
  const OPAQUE_UUID_SOURCE = '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}';
23
+ const OPAQUE_UUID = new RegExp(`^${OPAQUE_UUID_SOURCE}$`, 'iu');
20
24
  function jsonObject(value) {
21
25
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
22
26
  }
@@ -63,7 +67,8 @@ const EXTRA_FILE_STORES = {
63
67
  },
64
68
  qwen: {
65
69
  root: path.join(HOME, '.qwen', 'projects'),
66
- include: (file) => /[\\/]chats[\\/][^\\/]+\.jsonl$/i.test(file),
70
+ include: (file) => /[\\/]chats[\\/][^\\/]+\.jsonl$/i.test(file) ||
71
+ /[\\/]subagents[\\/][^\\/]+[\\/]agent-[^\\/]+\.jsonl$/i.test(file),
67
72
  },
68
73
  codebuddy: {
69
74
  root: path.join(HOME, '.codebuddy', 'projects'),
@@ -187,6 +192,14 @@ const STRUCTURAL_SUBAGENT_STORES = {
187
192
  parentSourcePath: `${match[1]}/messages.jsonl`,
188
193
  }),
189
194
  },
195
+ qwen: {
196
+ path: /^(.*)\/subagents\/([^/]+)\/(agent-[^/]+)\.jsonl$/iu,
197
+ lineage: (match) => ({
198
+ nativeSessionId: match[3],
199
+ parentNativeSessionId: match[2],
200
+ parentSourcePath: `${match[1]}/chats/${match[2]}.jsonl`,
201
+ }),
202
+ },
190
203
  };
191
204
  /** Interpret one harness's structural child path. `uploadable` lets the same
192
205
  * rule reject internal sidechains before discovery and lineage can diverge. */
@@ -385,6 +398,95 @@ function parsedObject(value) {
385
398
  return null;
386
399
  }
387
400
  }
401
+ function grokSummaryFile(descriptor) {
402
+ if (descriptor?.harness !== 'grok' || path.basename(String(descriptor?.file || '')) !== 'chat_history.jsonl') {
403
+ return '';
404
+ }
405
+ return path.join(path.dirname(path.resolve(descriptor.file)), 'summary.json');
406
+ }
407
+ function grokSummary(descriptor) {
408
+ const file = grokSummaryFile(descriptor);
409
+ return file ? jsonFile(file) : null;
410
+ }
411
+ /** Grok 1.0.5 stores native subagents beside their parent with the same
412
+ * transcript shape. The adjacent summary is the child's durable type marker. */
413
+ function grokSummaryMarksSubagent(descriptor) {
414
+ return (String(grokSummary(descriptor)?.session_kind || '')
415
+ .trim()
416
+ .toLowerCase() === 'subagent');
417
+ }
418
+ /** Child ids announced by one Grok transcript's successful spawn results.
419
+ * Results can occur anywhere in a long session, so scan the complete JSONL and
420
+ * cache by the append-only transcript's physical identity. */
421
+ export function grokSpawnedSubagentIds(descriptor) {
422
+ if (descriptor?.harness !== 'grok')
423
+ return [];
424
+ const file = String(descriptor?.file || '');
425
+ const stat = safeStat(file);
426
+ if (!stat?.isFile())
427
+ return [];
428
+ const fingerprint = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
429
+ const cached = GROK_SPAWNED_SUBAGENT_CACHE.get(file);
430
+ if (cached?.fingerprint === fingerprint) {
431
+ GROK_SPAWNED_SUBAGENT_CACHE.delete(file);
432
+ GROK_SPAWNED_SUBAGENT_CACHE.set(file, cached);
433
+ return cached.ids;
434
+ }
435
+ const ids = new Set();
436
+ const inspect = (line) => {
437
+ if (!line.includes('Subagent started in background.'))
438
+ return;
439
+ try {
440
+ const record = jsonObject(JSON.parse(line));
441
+ if (record?.type !== 'tool_result' || typeof record.content !== 'string')
442
+ return;
443
+ const id = /^Subagent started in background\.\r?\nsubagent_id:\s*([^\s]+)(?:\r?\n|$)/u.exec(record.content)?.[1];
444
+ if (id && OPAQUE_UUID.test(id))
445
+ ids.add(id);
446
+ }
447
+ catch { }
448
+ };
449
+ let handle;
450
+ try {
451
+ handle = fs.openSync(file, 'r');
452
+ const decoder = new StringDecoder('utf8');
453
+ const buffer = Buffer.alloc(64 * 1024);
454
+ let pending = '';
455
+ let offset = 0;
456
+ while (offset < stat.size) {
457
+ const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, offset);
458
+ if (!bytesRead)
459
+ break;
460
+ offset += bytesRead;
461
+ pending += decoder.write(buffer.subarray(0, bytesRead));
462
+ let newline = pending.indexOf('\n');
463
+ while (newline >= 0) {
464
+ inspect(pending.slice(0, newline).replace(/\r$/u, ''));
465
+ pending = pending.slice(newline + 1);
466
+ newline = pending.indexOf('\n');
467
+ }
468
+ }
469
+ pending += decoder.end();
470
+ if (pending)
471
+ inspect(pending.replace(/\r$/u, ''));
472
+ }
473
+ catch {
474
+ return [];
475
+ }
476
+ finally {
477
+ try {
478
+ if (handle !== undefined)
479
+ fs.closeSync(handle);
480
+ }
481
+ catch { }
482
+ }
483
+ const value = [...ids];
484
+ GROK_SPAWNED_SUBAGENT_CACHE.set(file, { fingerprint, ids: value });
485
+ if (GROK_SPAWNED_SUBAGENT_CACHE.size > MAX_METADATA_CACHE_ENTRIES) {
486
+ GROK_SPAWNED_SUBAGENT_CACHE.delete(GROK_SPAWNED_SUBAGENT_CACHE.keys().next().value);
487
+ }
488
+ return value;
489
+ }
388
490
  function recordMarksSubagent(record) {
389
491
  const root = parsedObject(record);
390
492
  if (!root)
@@ -435,6 +537,8 @@ export function nativeDescriptorIsSubagent(descriptor) {
435
537
  return true;
436
538
  if (descriptor?.isSubagent === true || descriptor?.threadSource === 'subagent')
437
539
  return true;
540
+ if (grokSummaryMarksSubagent(descriptor))
541
+ return true;
438
542
  const file = String(descriptor?.file || '');
439
543
  const stat = safeStat(file);
440
544
  const fingerprint = stat?.isFile() ? `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}` : '';
@@ -835,7 +939,8 @@ export function nativeDescriptorLineage(descriptor) {
835
939
  const sourcePath = String(descriptor?.sourcePath || descriptor?.file || '').replaceAll('\\', '/');
836
940
  const stat = safeStat(String(descriptor?.file || ''));
837
941
  const metadataStat = safeStat(claudeSubagentMetadataFile(descriptor));
838
- const fingerprint = [stat, metadataStat]
942
+ const grokSummaryStat = safeStat(grokSummaryFile(descriptor));
943
+ const fingerprint = [stat, metadataStat, grokSummaryStat]
839
944
  .map((candidate) => candidate?.isFile() ? `${candidate.dev}:${candidate.ino}:${candidate.size}:${candidate.mtimeMs}` : '-')
840
945
  .join('|');
841
946
  const cacheKey = [
@@ -1309,7 +1414,7 @@ function nativeProcessSnapshots(procRoot) {
1309
1414
  }
1310
1415
  catch { }
1311
1416
  if (argv.length || environ)
1312
- processes.push({ argv, cmdline: argv.join(' '), environ, fdTargets });
1417
+ processes.push({ pid: entry.name, argv, cmdline: argv.join(' '), environ, fdTargets });
1313
1418
  }
1314
1419
  return processes;
1315
1420
  }
@@ -1368,6 +1473,55 @@ function processRunsHarness(process, harness) {
1368
1473
  return true;
1369
1474
  return SCRIPT_RUNTIMES.has(executable) && names.has(executableStem(process.argv?.[1]));
1370
1475
  }
1476
+ function nativeProcessStartTime(procRoot, pid) {
1477
+ try {
1478
+ const stat = fs.readFileSync(path.join(procRoot, pid, 'stat'), 'utf8');
1479
+ const commandEnd = stat.lastIndexOf(')');
1480
+ if (commandEnd < 0)
1481
+ return '';
1482
+ // Fields after the command start at proc(5) field 3; starttime is field 22.
1483
+ return stat.slice(commandEnd + 1).trim().split(/\s+/u)[19] || '';
1484
+ }
1485
+ catch {
1486
+ return '';
1487
+ }
1488
+ }
1489
+ /** Claude records an exact PID -> native-session association here even for a
1490
+ * plain `claude` launch whose command line contains no session ID. Validate the
1491
+ * kernel start time so a stale registration cannot attach to a reused PID. */
1492
+ function registeredClaudeSessionIds(processes, procRoot, sessionsRoot) {
1493
+ const externalClaudeByPid = new Map(processes
1494
+ .filter((process) => !process.environ.includes('TURNS_QUEUE_WORKER_ID=') && processRunsHarness(process, 'claude'))
1495
+ .map((process) => [process.pid, process]));
1496
+ if (!externalClaudeByPid.size)
1497
+ return new Set();
1498
+ let entries = [];
1499
+ try {
1500
+ entries = fs.readdirSync(sessionsRoot, { withFileTypes: true });
1501
+ }
1502
+ catch {
1503
+ return new Set();
1504
+ }
1505
+ const sessionIds = new Set();
1506
+ for (const entry of entries) {
1507
+ const pid = /^(\d+)\.json$/u.exec(entry.name)?.[1];
1508
+ if (!pid || !entry.isFile() || !externalClaudeByPid.has(pid))
1509
+ continue;
1510
+ try {
1511
+ const registration = JSON.parse(fs.readFileSync(path.join(sessionsRoot, entry.name), 'utf8'));
1512
+ const sessionId = String(registration?.sessionId || '').trim();
1513
+ const registeredStart = String(registration?.procStart || '').trim();
1514
+ if (String(registration?.pid) === pid &&
1515
+ OPAQUE_UUID.test(sessionId) &&
1516
+ registeredStart &&
1517
+ registeredStart === nativeProcessStartTime(procRoot, pid)) {
1518
+ sessionIds.add(sessionId);
1519
+ }
1520
+ }
1521
+ catch { }
1522
+ }
1523
+ return sessionIds;
1524
+ }
1371
1525
  function queueManagedKeysFromProcesses(descriptors, processes) {
1372
1526
  const transcriptFiles = new Set();
1373
1527
  for (const process of processes) {
@@ -1403,7 +1557,7 @@ function queueManagedKeysFromProcesses(descriptors, processes) {
1403
1557
  export function queueManagedNativeDescriptorKeys(descriptors, procRoot = '/proc') {
1404
1558
  return queueManagedKeysFromProcesses(descriptors, nativeProcessSnapshots(procRoot));
1405
1559
  }
1406
- export function externallyManagedNativeDescriptorKeys(descriptors, procRoot = '/proc') {
1560
+ export function externallyManagedNativeDescriptorKeys(descriptors, procRoot = '/proc', claudeSessionsRoot = CLAUDE_LIVE_SESSIONS_ROOT) {
1407
1561
  const snapshots = nativeProcessSnapshots(procRoot);
1408
1562
  const queueManaged = queueManagedKeysFromProcesses(descriptors, snapshots);
1409
1563
  const externalProcesses = snapshots.filter((process) => !process.environ.includes('TURNS_QUEUE_WORKER_ID='));
@@ -1424,7 +1578,16 @@ export function externallyManagedNativeDescriptorKeys(descriptors, procRoot = '/
1424
1578
  byHarness.set(descriptor.harness, group);
1425
1579
  }
1426
1580
  const managed = new Set();
1581
+ const registeredClaude = byHarness.has('claude')
1582
+ ? registeredClaudeSessionIds(snapshots, procRoot, claudeSessionsRoot)
1583
+ : new Set();
1427
1584
  for (const [harness, candidates] of byHarness) {
1585
+ if (harness === 'claude' && registeredClaude.size) {
1586
+ for (const candidate of candidates) {
1587
+ if (candidate.session && registeredClaude.has(candidate.session))
1588
+ managed.add(candidate.key);
1589
+ }
1590
+ }
1428
1591
  for (const process of externalProcesses) {
1429
1592
  if (!processRunsHarness(process, harness))
1430
1593
  continue;
@@ -145,7 +145,12 @@ export function resolvePathCandidate(candidate, workingDirectory, { allowedDirec
145
145
  continue;
146
146
  if (allowedDirectories.length && !insideAllowedDirectory(file, allowedDirectories))
147
147
  continue;
148
- return { file, bytes: stat.size, contentType: agentImageContentType(file) };
148
+ return {
149
+ file,
150
+ bytes: stat.size,
151
+ contentType: agentImageContentType(file),
152
+ modifiedAt: Number.isFinite(stat.mtimeMs) ? new Date(stat.mtimeMs).toISOString() : '',
153
+ };
149
154
  }
150
155
  return null;
151
156
  }
@@ -167,13 +172,11 @@ function uploadHeaders(options) {
167
172
  /**
168
173
  * Put bytes in the account's private prefix, under the path they came from.
169
174
  *
170
- * The path is the object's identity rather than a description of it, so a
171
- * listing the machine composed for a directory is stored the same way a file
172
- * read off the disk is: whoever fetches it addresses the same path back. It is
173
- * addressed by machine and path alone — the session that happened to prompt the
174
- * upload is not part of what was stored, and nothing records it.
175
+ * The path is the object's identity rather than a description of it. It is
176
+ * addressed by machine and path alone — the session that happened to prompt
177
+ * the upload is not part of what was stored.
175
178
  */
176
- export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, contentEtag, body, fetchImpl = fetch, }) {
179
+ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, modifiedAt, body, fetchImpl = fetch, }) {
177
180
  const url = new URL(`/api/file/${encodeURIComponent(machine)}/${encodeURIComponent(absolutePath)}`, String(baseUrl));
178
181
  const response = await fetchImpl(url, {
179
182
  method: 'PUT',
@@ -181,7 +184,7 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
181
184
  ...uploadHeaders(options),
182
185
  'content-type': contentType,
183
186
  'content-length': String(body.byteLength),
184
- ...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
187
+ ...(modifiedAt ? { 'x-turns-file-modified-at': modifiedAt } : {}),
185
188
  },
186
189
  body,
187
190
  });
@@ -190,7 +193,7 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
190
193
  return true;
191
194
  }
192
195
  /** Stream one on-disk file to R2 without retaining its body in the daemon. */
193
- export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, fetchImpl = fetch, }) {
196
+ export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, fetchImpl = fetch, }) {
194
197
  const url = new URL(`/api/file/${encodeURIComponent(machine)}/${encodeURIComponent(absolutePath)}`, String(baseUrl));
195
198
  const body = fs.createReadStream(absolutePath);
196
199
  try {
@@ -200,6 +203,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
200
203
  ...uploadHeaders(options),
201
204
  'content-type': contentType,
202
205
  'content-length': String(bytes),
206
+ ...(modifiedAt ? { 'x-turns-file-modified-at': modifiedAt } : {}),
203
207
  },
204
208
  body,
205
209
  // Node requires this opt-in for a streaming request body.
@@ -226,6 +230,7 @@ export async function uploadResolvedFile({ baseUrl, options, machine, resolved,
226
230
  machine,
227
231
  absolutePath: resolved.file,
228
232
  contentType: resolved.contentType,
233
+ modifiedAt: resolved.modifiedAt,
229
234
  body,
230
235
  fetchImpl,
231
236
  });
@@ -45,6 +45,70 @@ export async function reserveCreatingNativeSession(request, creating, sessionId,
45
45
  return;
46
46
  await request({ type: 'session.native.reserve', sessionId, agentSessionId });
47
47
  }
48
+ function workingDirectoryFailure(message, directory, error) {
49
+ const detail = error instanceof Error ? error.message : String(error || 'Unknown filesystem error.');
50
+ return new Error(`${message}: ${directory}. ${detail}`, { cause: error });
51
+ }
52
+ /** Resolve the directory typed into a new-session draft on the machine that
53
+ * will run it. The browser cannot expand `~` because the daemon's home may be
54
+ * different from the viewer's, and a new project directory may not exist yet.
55
+ *
56
+ * Check the nearest existing ancestor against the daemon allowlist before
57
+ * creating anything. The final assertion repeats that check after mkdir so a
58
+ * symlinked ancestor cannot launch a session outside the allowlist.
59
+ */
60
+ export function prepareNewSessionWorkingDirectory(requestedDirectory, allowedDirectories = [], homeDirectory = os.homedir()) {
61
+ const requested = String(requestedDirectory || '').trim();
62
+ if (!requested)
63
+ throw new Error('Choose a working directory.');
64
+ const expanded = requested === '~'
65
+ ? homeDirectory
66
+ : requested.startsWith('~/')
67
+ ? path.join(homeDirectory, requested.slice(2))
68
+ : requested;
69
+ if (!path.isAbsolute(expanded)) {
70
+ throw new Error(`Working directory must be an absolute path or start with ~/: ${requested}`);
71
+ }
72
+ const resolved = path.resolve(expanded);
73
+ let exists = true;
74
+ try {
75
+ fs.statSync(resolved);
76
+ }
77
+ catch (error) {
78
+ if (error?.code === 'ENOENT')
79
+ exists = false;
80
+ else
81
+ throw workingDirectoryFailure('Working directory is inaccessible', resolved, error);
82
+ }
83
+ if (!exists) {
84
+ let ancestor = path.dirname(resolved);
85
+ while (!fs.existsSync(ancestor)) {
86
+ const parent = path.dirname(ancestor);
87
+ if (parent === ancestor)
88
+ break;
89
+ ancestor = parent;
90
+ }
91
+ if (allowedDirectories.length && !workingDirectoryIsAllowed(ancestor, allowedDirectories)) {
92
+ throw new Error(`Working directory is outside the daemon allowlist: ${resolved}`);
93
+ }
94
+ try {
95
+ fs.mkdirSync(resolved, { recursive: true });
96
+ }
97
+ catch (error) {
98
+ throw workingDirectoryFailure('Working directory could not be created', resolved, error);
99
+ }
100
+ }
101
+ try {
102
+ const canonical = assertAllowedWorkingDirectory(resolved, allowedDirectories);
103
+ fs.accessSync(canonical, fs.constants.X_OK);
104
+ return canonical;
105
+ }
106
+ catch (error) {
107
+ if (/outside the daemon allowlist/iu.test(String(error?.message || error)))
108
+ throw error;
109
+ throw workingDirectoryFailure('Working directory is inaccessible', resolved, error);
110
+ }
111
+ }
48
112
  /** The origin serving the same deployment as a `wss://…/api/socket` endpoint. */
49
113
  export function httpBaseUrl(websocketUrl) {
50
114
  const url = new URL(websocketUrl);
@@ -244,7 +308,13 @@ export function resultError(result) {
244
308
  }
245
309
  export function dispatchErrorIsRetryable(error) {
246
310
  const message = String(error?.message || error || '');
247
- return !(/(?:not supported|does not support|no resumable native session|session key.*unavailable|no local transcript descriptor|working directory is unavailable|outside the daemon allowlist|agent executable not found)/iu.test(message) || /attachment .* (?:size mismatch|download failed \(4\d\d\))/iu.test(message));
311
+ // OpenCode emits this only after its provider stream has exhausted its own
312
+ // backoff. Re-queueing the turn starts that entire retry sequence again and
313
+ // also persists another copy of the user's prompt in the native session.
314
+ return !(/(?:not supported|does not support|no resumable native session|session key.*unavailable|no local transcript descriptor|working directory (?:is (?:unavailable|not a directory|inaccessible)|must be|could not be created)|outside the daemon allowlist|agent executable not found|provider finish_reason:\s*network_error)/iu.test(message) || /attachment .* (?:size mismatch|download failed \(4\d\d\))/iu.test(message));
315
+ }
316
+ export function queueFailureIsRetryable(error, promptStarted) {
317
+ return !promptStarted && dispatchErrorIsRetryable(error);
248
318
  }
249
319
  export async function actualHarnessStatus(entry) {
250
320
  if (!entry || entry.connection.closed)
@@ -505,7 +575,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
505
575
  promptId: claim.prompt.id,
506
576
  claimToken: claim.claimToken,
507
577
  error: redactor.redactText(error.message),
508
- retryable: dispatchErrorIsRetryable(error),
578
+ // The anchor accepted and finished this prompt. Replaying it can
579
+ // duplicate side effects and turns persistent harness failures (for
580
+ // example, expired authentication) into an unbounded retry loop.
581
+ retryable: queueFailureIsRetryable(error, true),
509
582
  });
510
583
  }
511
584
  else {
@@ -572,11 +645,11 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
572
645
  const workingDirectory = pendingNativeSession
573
646
  ? String(claim.session?.workingDirectory || '')
574
647
  : await nativeDescriptorWorkingDirectory(descriptor);
575
- const fallbackDirectory = path.dirname(descriptor.file);
576
- const selectedDirectory = workingDirectory && path.isAbsolute(workingDirectory) && fs.existsSync(workingDirectory)
577
- ? workingDirectory
578
- : fallbackDirectory;
579
- cwd = assertAllowedWorkingDirectory(selectedDirectory, options.allowedDirectories);
648
+ cwd = pendingNativeSession
649
+ ? prepareNewSessionWorkingDirectory(workingDirectory, options.allowedDirectories)
650
+ : assertAllowedWorkingDirectory(workingDirectory && path.isAbsolute(workingDirectory) && fs.existsSync(workingDirectory)
651
+ ? workingDirectory
652
+ : path.dirname(descriptor.file), options.allowedDirectories);
580
653
  if (!fs.statSync(cwd).isDirectory())
581
654
  throw new Error(`Working directory is unavailable: ${cwd}`);
582
655
  entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId);
@@ -587,6 +660,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
587
660
  ...claim,
588
661
  entry,
589
662
  renewTimer: null,
663
+ // Recovery only observes turns that an anchor already accepted. Treat
664
+ // an uncertain recovery failure conservatively for the same reason as a
665
+ // failed live turn: the prompt may already have produced side effects.
666
+ promptStarted: Boolean(recovery),
590
667
  };
591
668
  claims.set(sessionId, queued);
592
669
  const renewClaim = () => request({
@@ -645,6 +722,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
645
722
  // boundary before starting this attempt so an earlier turn_error with
646
723
  // that ID cannot immediately settle the new turn.
647
724
  const eventCursor = entry.connection.eventCursor();
725
+ // A local request can lose its response after the anchor has accepted
726
+ // the prompt, so crossing this boundary permanently disables automatic
727
+ // replay even when the exact delivery outcome is unknown.
728
+ queued.promptStarted = true;
648
729
  await entry.connection.request('send_message', {
649
730
  source: descriptor.harness,
650
731
  sessionId,
@@ -712,7 +793,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
712
793
  promptId: claim.prompt.id,
713
794
  claimToken: claim.claimToken,
714
795
  error: redactor.redactText(error?.message || String(error)),
715
- retryable: dispatchErrorIsRetryable(error),
796
+ retryable: queueFailureIsRetryable(error, Boolean(held?.promptStarted)),
716
797
  }).catch(() => { });
717
798
  }
718
799
  claims.delete(sessionId);
@@ -956,8 +1037,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
956
1037
  *
957
1038
  * Unlike the rest of these handlers this touches no harness: browsing is a
958
1039
  * property of the machine, so it answers for a stopped session as readily as
959
- * for a running one. Directory bytes answer directly; their offline copy is
960
- * stored behind that reply.
1040
+ * for a running one. Directory bytes answer directly and the server indexes
1041
+ * them in its Durable Object; only regular file bodies are uploaded to R2.
961
1042
  */
962
1043
  const handleFileBrowse = async (message, replyRpc) => {
963
1044
  const sessionId = String(message.sessionId || '');
@@ -969,18 +1050,12 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
969
1050
  options,
970
1051
  workingDirectory: await browseWorkingDirectory(sessionId),
971
1052
  });
972
- const { cache, ...stored } = browsed.kind === 'directory' ? browsed : { ...browsed, cache: undefined };
973
1053
  replyRpc.notify({
974
1054
  type: 'session.file.browse.result',
975
1055
  requestId: message.requestId,
976
1056
  sessionId,
977
1057
  ok: true,
978
- ...stored,
979
- });
980
- // The direct listing is already useful. Its R2 copy is only the fallback
981
- // for a later offline browse, so storage latency must not hold this reply.
982
- void cache?.catch((error) => {
983
- console.warn(`Directory browse fallback upload failed: ${redactor.redactText(error?.message || String(error))}`);
1058
+ ...browsed,
984
1059
  });
985
1060
  }
986
1061
  catch (error) {
@@ -6,12 +6,12 @@ import path from 'node:path';
6
6
  import process from 'node:process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import WebSocket from 'ws';
9
- import { createPathResolver, pathCandidates } from "./path-resolution.js";
10
9
  import { streamDeepseekTrajectoryLines } from "./deepseek-trajectories.js";
11
10
  import { normalizeAllowedDirectories, workingDirectoryIsAllowed } from "./directory-policy.js";
12
11
  import { discoverGitAssociation } from "./git-association.js";
13
- import { discoverLocalSessions, externallyManagedNativeDescriptorKeys, nativeDescriptorAgentSessionId, nativeDescriptorLineage, nativeDescriptorWorkingDirectory, nativeSessionShallowWatchRoots, nativeSessionWatchRoots, } from "./local-session-sources.js";
12
+ import { discoverLocalSessions, externallyManagedNativeDescriptorKeys, grokSpawnedSubagentIds, nativeDescriptorAgentSessionId, nativeDescriptorLineage, nativeDescriptorWorkingDirectory, nativeSessionShallowWatchRoots, nativeSessionWatchRoots, } from "./local-session-sources.js";
14
13
  import { compactNativeRaw } from "./native-record-compaction.js";
14
+ import { createPathResolver, pathCandidates } from "./path-resolution.js";
15
15
  import { httpBaseUrl, watchQueuedPrompts } from "./queue-worker.js";
16
16
  import { createSecretRedactor, REDACTION_VERSION } from "./secret-redaction.js";
17
17
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -292,6 +292,14 @@ export function linkNativeSessionParents(machine, descriptors) {
292
292
  descriptor,
293
293
  lineage: nativeDescriptorLineage(descriptor),
294
294
  }));
295
+ const storeNamespace = (descriptor) => {
296
+ const sourcePath = String(descriptor?.sourcePath || '').replaceAll('\\', '/');
297
+ if (descriptor?.harness === 'gemini')
298
+ return /^(.*?\/chats)(?:\/|$)/iu.exec(sourcePath)?.[1] || '';
299
+ if (descriptor?.harness === 'grok')
300
+ return /^(.*?\/sessions\/[^/]+)(?:\/|$)/iu.exec(sourcePath)?.[1] || '';
301
+ return '';
302
+ };
295
303
  const byNativeId = new Map();
296
304
  for (const record of records) {
297
305
  if (!record.lineage.nativeSessionId)
@@ -301,12 +309,18 @@ export function linkNativeSessionParents(machine, descriptors) {
301
309
  candidates.push(record.descriptor);
302
310
  byNativeId.set(key, candidates);
303
311
  }
304
- const storeNamespace = (descriptor) => {
305
- const sourcePath = String(descriptor?.sourcePath || '').replaceAll('\\', '/');
306
- if (descriptor?.harness === 'gemini')
307
- return /^(.*?\/chats)(?:\/|$)/iu.exec(sourcePath)?.[1] || '';
308
- return '';
309
- };
312
+ const grokParentsByChildId = new Map();
313
+ for (const record of records) {
314
+ if (record.descriptor.harness !== 'grok')
315
+ continue;
316
+ for (const childId of grokSpawnedSubagentIds(record.descriptor)) {
317
+ if (childId === record.lineage.nativeSessionId)
318
+ continue;
319
+ const candidates = grokParentsByChildId.get(childId) || [];
320
+ candidates.push(record.descriptor);
321
+ grokParentsByChildId.set(childId, candidates);
322
+ }
323
+ }
310
324
  return records.flatMap(({ descriptor, lineage }) => {
311
325
  // Internal harness sidechains such as Claude's compaction worker are still
312
326
  // children for identity/resume safety, but never become uploaded sessions.
@@ -329,19 +343,26 @@ export function linkNativeSessionParents(machine, descriptors) {
329
343
  ? [...localCandidates].sort((left, right) => Number(right.mtimeMs || 0) - Number(left.mtimeMs || 0) ||
330
344
  String(right.sourcePath).localeCompare(String(left.sourcePath)))[0]
331
345
  : null;
346
+ const grokParentCandidates = descriptor.harness === 'grok' ? grokParentsByChildId.get(lineage.nativeSessionId) || [] : [];
347
+ const grokNamespace = storeNamespace(descriptor);
348
+ const localGrokParents = grokNamespace
349
+ ? grokParentCandidates.filter((candidate) => storeNamespace(candidate) === grokNamespace)
350
+ : grokParentCandidates;
351
+ const grokParent = localGrokParents.length === 1 ? localGrokParents[0] : null;
332
352
  // An exact structural parent path is stronger than a harness-wide native
333
353
  // ID match. This matters for Claude descendants, whose short runtime agent
334
354
  // IDs can recur in another project while the adjacent sidecar names the
335
355
  // correct transcript in this session's flat subagents directory.
336
- const parentSourcePath = lineage.parentSourcePath || parent?.sourcePath;
356
+ const parentSourcePath = lineage.parentSourcePath || grokParent?.sourcePath || parent?.sourcePath;
357
+ const isSubagent = lineage.isSubagent || Boolean(grokParent);
337
358
  // An unresolved child cannot satisfy the sidebar contract. Leave it out of
338
359
  // native upload until its store exposes enough lineage to nest it safely.
339
- if (lineage.isSubagent && !parentSourcePath)
360
+ if (isSubagent && !parentSourcePath)
340
361
  return [];
341
362
  return [
342
363
  {
343
364
  ...descriptor,
344
- isSubagent: lineage.isSubagent,
365
+ isSubagent,
345
366
  ...(lineage.nativeTitle ? { nativeTitle: lineage.nativeTitle } : {}),
346
367
  ...(lineage.nativeSubagentType ? { nativeSubagentType: lineage.nativeSubagentType } : {}),
347
368
  ...(lineage.nativeSubagentDepth ? { nativeSubagentDepth: lineage.nativeSubagentDepth } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",