@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.
- package/dist/agent-anchor.js +3 -0
- package/dist/file-browsing.js +12 -28
- package/dist/file-edits.js +82 -0
- package/dist/harness-registry.js +8 -0
- package/dist/local-session-sources.js +167 -4
- package/dist/path-resolution.js +15 -9
- package/dist/queue-worker.js +56 -9
- package/dist/upload-sessions.js +32 -11
- package/package.json +1 -1
package/dist/agent-anchor.js
CHANGED
|
@@ -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
|
}
|
package/dist/file-browsing.js
CHANGED
|
@@ -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 {
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/harness-registry.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/path-resolution.js
CHANGED
|
@@ -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 {
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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,
|
|
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
|
-
...(
|
|
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
|
});
|
package/dist/queue-worker.js
CHANGED
|
@@ -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
|
|
1041
|
-
*
|
|
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
|
-
...
|
|
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()],
|
package/dist/upload-sessions.js
CHANGED
|
@@ -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
|
|
305
|
-
|
|
306
|
-
if (descriptor
|
|
307
|
-
|
|
308
|
-
|
|
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 (
|
|
360
|
+
if (isSubagent && !parentSourcePath)
|
|
340
361
|
return [];
|
|
341
362
|
return [
|
|
342
363
|
{
|
|
343
364
|
...descriptor,
|
|
344
|
-
isSubagent
|
|
365
|
+
isSubagent,
|
|
345
366
|
...(lineage.nativeTitle ? { nativeTitle: lineage.nativeTitle } : {}),
|
|
346
367
|
...(lineage.nativeSubagentType ? { nativeSubagentType: lineage.nativeSubagentType } : {}),
|
|
347
368
|
...(lineage.nativeSubagentDepth ? { nativeSubagentDepth: lineage.nativeSubagentDepth } : {}),
|