@turns/dev 0.0.5 → 0.0.7

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.
@@ -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
  }
@@ -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. */
@@ -177,11 +173,7 @@ export function readDirectory(directory) {
177
173
  // clock inside the body would make every read produce different bytes.
178
174
  return { path: directory, entries, truncated: names.length > MAX_DIRECTORY_ENTRIES };
179
175
  }
180
- /** Where one directory's listing is stored, as a path in the machine's space. */
181
- export function directoryIndexPath(directory) {
182
- return `${String(directory || '').replace(/\/+$/u, '')}/${DIRECTORY_INDEX_NAME}`;
183
- }
184
- export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', fetchImpl = fetch, }) {
176
+ export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', contentEtag = '', fetchImpl = fetch, }) {
185
177
  const resolved = resolveBrowsePath(requested, {
186
178
  workingDirectory,
187
179
  allowedDirectories: options.allowedDirectories || [],
@@ -192,16 +184,6 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
192
184
  if (body.byteLength > MAX_BROWSE_FILE_BYTES)
193
185
  throw new Error(`Directory listing is too large: ${resolved.path}`);
194
186
  const etag = crypto.createHash('sha256').update(body).digest('hex');
195
- const cache = uploadFileBytes({
196
- baseUrl,
197
- options,
198
- machine,
199
- absolutePath: directoryIndexPath(resolved.path),
200
- contentType: 'application/json',
201
- contentEtag: etag,
202
- body,
203
- fetchImpl,
204
- });
205
187
  return {
206
188
  path: resolved.path,
207
189
  kind: 'directory',
@@ -209,7 +191,6 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
209
191
  contentType: 'application/json',
210
192
  listing,
211
193
  etag,
212
- cache,
213
194
  };
214
195
  }
215
196
  if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
@@ -217,8 +198,9 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
217
198
  }
218
199
  const sample = readFileTypeSample(resolved.path, resolved.stat.size);
219
200
  const contentType = browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size);
201
+ const modifiedAt = isoTime(resolved.stat.mtimeMs);
220
202
  if (resolved.stat.size === 0)
221
- return { path: resolved.path, kind: 'file', bytes: 0, contentType };
203
+ return { path: resolved.path, kind: 'file', bytes: 0, contentType, modifiedAt };
222
204
  await uploadFileStream({
223
205
  baseUrl,
224
206
  options,
@@ -226,7 +208,9 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
226
208
  absolutePath: resolved.path,
227
209
  contentType,
228
210
  bytes: resolved.stat.size,
211
+ modifiedAt,
212
+ ...(contentEtag ? { contentEtag } : {}),
229
213
  fetchImpl,
230
214
  });
231
- return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType };
215
+ return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType, modifiedAt };
232
216
  }
@@ -0,0 +1,82 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { workingDirectoryIsAllowed } from "./directory-policy.js";
7
+ /**
8
+ * Applying a browser edit to a file this machine holds.
9
+ *
10
+ * The server's queue row says what the edit expects (`beforeHash`) and what it
11
+ * produces (`afterHash`); the bytes themselves are downloaded from the
12
+ * account's bucket only once the file on disk has been shown to still match
13
+ * what the editor started from. The replacement is written beside the target
14
+ * and renamed over it, so a crash mid-write leaves the original untouched, and
15
+ * the downloaded bytes are verified against `afterHash` before the rename —
16
+ * corrupt or truncated downloads never reach the file.
17
+ *
18
+ * A redelivered edit is settled by content: a file that already hashes to
19
+ * `afterHash` reports success without touching the disk, which is what lets
20
+ * the server re-offer an edit whose earlier answer was lost.
21
+ */
22
+ /** SHA-256 of one file's bytes, streamed so large files cost no memory. */
23
+ export async function hashFileSha256(file) {
24
+ const hash = crypto.createHash('sha256');
25
+ await pipeline(fs.createReadStream(file), hash);
26
+ return hash.digest('hex');
27
+ }
28
+ export async function applyFileEdit({ edit, baseUrl, options, fetchImpl = fetch, }) {
29
+ const requested = String(edit.path || '').trim();
30
+ if (!requested || !path.isAbsolute(requested)) {
31
+ throw new Error(`File edits need an absolute path: ${requested || '(empty)'}`);
32
+ }
33
+ let canonical;
34
+ let stat;
35
+ try {
36
+ canonical = fs.realpathSync(requested);
37
+ stat = fs.statSync(canonical);
38
+ }
39
+ catch {
40
+ throw new Error(`No such file: ${requested}`);
41
+ }
42
+ if (!stat.isFile())
43
+ throw new Error(`Not an editable file: ${canonical}`);
44
+ // The same allowlist that bounds browsing bounds writing: a file is editable
45
+ // exactly when the directory holding it could be browsed.
46
+ if (!workingDirectoryIsAllowed(path.dirname(canonical), options.allowedDirectories || [])) {
47
+ throw new Error(`Path is outside the daemon allowlist: ${canonical}`);
48
+ }
49
+ const current = await hashFileSha256(canonical);
50
+ if (current === edit.afterHash)
51
+ return { path: canonical, alreadyApplied: true };
52
+ if (current !== edit.beforeHash) {
53
+ throw new Error('The file changed on this machine after the edit was made, so it was not applied.');
54
+ }
55
+ const url = new URL(`/api/file-edits/${encodeURIComponent(String(edit.id || ''))}`, String(baseUrl));
56
+ const response = await fetchImpl(url, {
57
+ headers: { authorization: `Bearer ${options.token}` },
58
+ });
59
+ if (!response.ok || !response.body) {
60
+ throw new Error(`The edited file could not be downloaded (${response.status}).`);
61
+ }
62
+ const temporary = path.join(path.dirname(canonical), `.${path.basename(canonical)}.turns-edit-${process.pid}.tmp`);
63
+ try {
64
+ await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(temporary, { mode: 0o600 }));
65
+ const written = fs.statSync(temporary);
66
+ if (Number(edit.bytes) !== written.size) {
67
+ throw new Error('The downloaded edit did not match its recorded size.');
68
+ }
69
+ if ((await hashFileSha256(temporary)) !== edit.afterHash) {
70
+ throw new Error('The downloaded edit did not match its recorded content hash.');
71
+ }
72
+ // The temporary file was created 0600 regardless of umask; give the
73
+ // replacement the permissions the original had before it takes its name.
74
+ fs.chmodSync(temporary, stat.mode & 0o7777);
75
+ fs.renameSync(temporary, canonical);
76
+ }
77
+ catch (error) {
78
+ fs.rmSync(temporary, { force: true });
79
+ throw error;
80
+ }
81
+ return { path: canonical, alreadyApplied: false };
82
+ }
@@ -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.
@@ -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, contentEtag, 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,8 @@ 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 } : {}),
207
+ ...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
203
208
  },
204
209
  body,
205
210
  // Node requires this opt-in for a streaming request body.
@@ -226,6 +231,7 @@ export async function uploadResolvedFile({ baseUrl, options, machine, resolved,
226
231
  machine,
227
232
  absolutePath: resolved.file,
228
233
  contentType: resolved.contentType,
234
+ modifiedAt: resolved.modifiedAt,
229
235
  body,
230
236
  fetchImpl,
231
237
  });
@@ -7,6 +7,7 @@ import { pipeline } from 'node:stream/promises';
7
7
  import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
8
8
  import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
9
9
  import { browseMachinePath } from "./file-browsing.js";
10
+ import { applyFileEdit } from "./file-edits.js";
10
11
  import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
11
12
  import { compactRuntimeValue } from "./native-record-compaction.js";
12
13
  import { createSecretRedactor } from "./secret-redaction.js";
@@ -1037,8 +1038,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1037
1038
  *
1038
1039
  * Unlike the rest of these handlers this touches no harness: browsing is a
1039
1040
  * property of the machine, so it answers for a stopped session as readily as
1040
- * for a running one. Directory bytes answer directly; their offline copy is
1041
- * stored behind that reply.
1041
+ * for a running one. Directory bytes answer directly and the server indexes
1042
+ * them in its Durable Object; only regular file bodies are uploaded to R2.
1042
1043
  */
1043
1044
  const handleFileBrowse = async (message, replyRpc) => {
1044
1045
  const sessionId = String(message.sessionId || '');
@@ -1050,18 +1051,12 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1050
1051
  options,
1051
1052
  workingDirectory: await browseWorkingDirectory(sessionId),
1052
1053
  });
1053
- const { cache, ...stored } = browsed.kind === 'directory' ? browsed : { ...browsed, cache: undefined };
1054
1054
  replyRpc.notify({
1055
1055
  type: 'session.file.browse.result',
1056
1056
  requestId: message.requestId,
1057
1057
  sessionId,
1058
1058
  ok: true,
1059
- ...stored,
1060
- });
1061
- // The direct listing is already useful. Its R2 copy is only the fallback
1062
- // for a later offline browse, so storage latency must not hold this reply.
1063
- void cache?.catch((error) => {
1064
- console.warn(`Directory browse fallback upload failed: ${redactor.redactText(error?.message || String(error))}`);
1059
+ ...browsed,
1065
1060
  });
1066
1061
  }
1067
1062
  catch (error) {
@@ -1074,6 +1069,55 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1074
1069
  });
1075
1070
  }
1076
1071
  };
1072
+ /**
1073
+ * Apply one queued browser edit, and report how it went.
1074
+ *
1075
+ * Edits are serialized through one chain: the server sends every pending
1076
+ * edit for this machine in queue order, and applying them strictly in
1077
+ * arrival order is what lets several edits to one file chain — each one's
1078
+ * before-hash is the previous one's after-hash. After a real application the
1079
+ * file is re-browsed so the stored copy readers open reflects the new bytes.
1080
+ */
1081
+ let fileEditChain = Promise.resolve();
1082
+ const handleFileEdit = (message, replyRpc) => {
1083
+ fileEditChain = fileEditChain.then(async () => {
1084
+ const sessionId = String(message.sessionId || '');
1085
+ const edit = message.edit || {};
1086
+ try {
1087
+ const applied = await applyFileEdit({ edit, baseUrl, options });
1088
+ if (!applied.alreadyApplied) {
1089
+ await browseMachinePath({
1090
+ requested: applied.path,
1091
+ machine,
1092
+ baseUrl,
1093
+ options,
1094
+ // The applied bytes were verified against this hash, so store it
1095
+ // as their identity: a pane already showing them recognizes the
1096
+ // refresh instead of being offered its own save as a new version.
1097
+ contentEtag: String(edit.afterHash || ''),
1098
+ }).catch((error) => process.stderr.write(`Post-edit refresh failed for ${applied.path}: ${error?.message || error}\n`));
1099
+ }
1100
+ replyRpc.notify({
1101
+ type: 'session.file.edit.result',
1102
+ requestId: message.requestId,
1103
+ sessionId,
1104
+ editId: String(edit.id || ''),
1105
+ ok: true,
1106
+ });
1107
+ }
1108
+ catch (error) {
1109
+ replyRpc.notify({
1110
+ type: 'session.file.edit.result',
1111
+ requestId: message.requestId,
1112
+ sessionId,
1113
+ editId: String(edit.id || ''),
1114
+ ok: false,
1115
+ error: redactor.redactText(error?.message || String(error)),
1116
+ });
1117
+ }
1118
+ });
1119
+ return fileEditChain;
1120
+ };
1077
1121
  const heartbeat = setInterval(() => {
1078
1122
  for (const [sessionId, entry] of anchors) {
1079
1123
  if (entry.connection.closed)
@@ -1095,6 +1139,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1095
1139
  void handleInteractionAnswer(message, rpc);
1096
1140
  if (message.type === 'session.file.browse.request')
1097
1141
  void handleFileBrowse(message, rpc);
1142
+ if (message.type === 'session.file.edit.request')
1143
+ void handleFileEdit(message, rpc);
1098
1144
  });
1099
1145
  const registered = new WeakSet();
1100
1146
  const registering = new WeakSet();
@@ -1106,6 +1152,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1106
1152
  const ready = await control.request({
1107
1153
  type: 'machine.register',
1108
1154
  machineId: machine,
1155
+ machineHostname: os.hostname(),
1109
1156
  workerId,
1110
1157
  workerInstanceId,
1111
1158
  anchorSessionIds: [...anchors.keys()],
@@ -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.5",
3
+ "version": "0.0.7",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",