@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.
- package/dist/agent-anchor.js +10 -5
- package/dist/file-browsing.js +20 -27
- package/dist/harness-registry.js +29 -0
- package/dist/local-session-sources.js +167 -4
- package/dist/path-resolution.js +14 -9
- package/dist/queue-worker.js +92 -17
- package/dist/upload-sessions.js +32 -11
- package/package.json +1 -1
package/dist/agent-anchor.js
CHANGED
|
@@ -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
|
|
1440
|
-
//
|
|
1441
|
-
|
|
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
|
}
|
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. */
|
|
@@ -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
|
}
|
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.
|
|
@@ -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
|
|
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, 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
|
});
|
package/dist/queue-worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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:
|
|
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
|
|
960
|
-
*
|
|
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
|
-
...
|
|
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) {
|
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 } : {}),
|