@turns/dev 0.0.3 → 0.0.5
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/README.md +1 -0
- package/dist/agent-anchor.js +187 -4
- package/dist/file-browsing.js +10 -0
- package/dist/harness-registry.js +30 -1
- package/dist/letta-session-context.js +70 -0
- package/dist/local-session-sources.js +21 -0
- package/dist/queue-worker.js +89 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ invocations and daemon starts. On Linux the other persistent files are:
|
|
|
44
44
|
|
|
45
45
|
- `~/.config/turns/machine-id`
|
|
46
46
|
- `~/.local/state/turns/native-upload-state.json`
|
|
47
|
+
- `~/.local/state/turns/letta-sessions/*.json` (Letta conversation-to-project associations)
|
|
47
48
|
- `~/.config/systemd/user/turns.service`
|
|
48
49
|
|
|
49
50
|
The credential file contains a seven-day access token and a signed refresh
|
package/dist/agent-anchor.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import readline from 'node:readline';
|
|
7
8
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { WebSocket } from 'ws';
|
|
10
|
-
import { ACP_PROFILES, PROCESS_HARNESSES, 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
|
+
import { recordLettaSessionContext } from "./letta-session-context.js";
|
|
11
13
|
const socketIndex = process.argv.indexOf('--socket');
|
|
12
14
|
const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
|
|
13
15
|
if (!socketPath)
|
|
@@ -333,6 +335,165 @@ class ClaudeTransport extends LineTransport {
|
|
|
333
335
|
}
|
|
334
336
|
}
|
|
335
337
|
}
|
|
338
|
+
class LettaTransport extends LineTransport {
|
|
339
|
+
conversationId;
|
|
340
|
+
agentId;
|
|
341
|
+
permissionMode;
|
|
342
|
+
turnWaiter;
|
|
343
|
+
control;
|
|
344
|
+
initWaiter;
|
|
345
|
+
constructor(anchor, createSession) {
|
|
346
|
+
const existing = parseLettaSessionIdentity(anchor.agentSessionId);
|
|
347
|
+
const nativePermissionMode = anchor.permissionMode === 'bypassPermissions'
|
|
348
|
+
? 'unrestricted'
|
|
349
|
+
: anchor.permissionMode === 'acceptEdits'
|
|
350
|
+
? 'acceptEdits'
|
|
351
|
+
: 'standard';
|
|
352
|
+
const args = [
|
|
353
|
+
'-p',
|
|
354
|
+
'--input-format',
|
|
355
|
+
'stream-json',
|
|
356
|
+
'--output-format',
|
|
357
|
+
'stream-json',
|
|
358
|
+
'--include-partial-messages',
|
|
359
|
+
'--permission-mode',
|
|
360
|
+
nativePermissionMode,
|
|
361
|
+
'--reflection-trigger',
|
|
362
|
+
'off',
|
|
363
|
+
...(anchor.model && anchor.model !== 'auto' ? ['--model', anchor.model] : []),
|
|
364
|
+
...(createSession ? ['--new'] : ['--conversation', existing.conversationId]),
|
|
365
|
+
];
|
|
366
|
+
super(anchor, 'letta', resolveHarnessCommand('letta', { command: 'letta' }), args, anchor.cwd, lettaHeadlessEnvironment());
|
|
367
|
+
this.conversationId = createSession ? '' : existing.conversationId;
|
|
368
|
+
this.agentId = createSession ? '' : existing.agentId;
|
|
369
|
+
this.permissionMode = anchor.permissionMode;
|
|
370
|
+
this.turnWaiter = null;
|
|
371
|
+
this.control = new Map();
|
|
372
|
+
this.initWaiter = deferred();
|
|
373
|
+
}
|
|
374
|
+
onFrame(frame) {
|
|
375
|
+
if (!frame || typeof frame !== 'object')
|
|
376
|
+
return;
|
|
377
|
+
if (frame.type === 'system' && frame.subtype === 'init') {
|
|
378
|
+
this.agentId = String(frame.agent_id || frame.session_id || '');
|
|
379
|
+
this.conversationId = String(frame.conversation_id || '');
|
|
380
|
+
if (this.conversationId) {
|
|
381
|
+
const identity = this.agentId ? lettaSessionIdentity(this.agentId, this.conversationId) : this.conversationId;
|
|
382
|
+
const transcriptPath = this.agentId
|
|
383
|
+
? path.join(os.homedir(), '.letta', 'transcripts', this.agentId, this.conversationId, 'transcript.jsonl')
|
|
384
|
+
: '';
|
|
385
|
+
if (this.agentId && transcriptPath) {
|
|
386
|
+
recordLettaSessionContext({
|
|
387
|
+
agentId: this.agentId,
|
|
388
|
+
conversationId: this.conversationId,
|
|
389
|
+
workingDirectory: this.anchor.cwd,
|
|
390
|
+
transcriptPath,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (identity !== this.anchor.agentSessionId ||
|
|
394
|
+
(transcriptPath && path.resolve(transcriptPath) !== this.anchor.transcriptPath)) {
|
|
395
|
+
this.anchor.updateIdentity(identity, transcriptPath);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
this.initWaiter.resolve(frame);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (frame.type === 'result' && this.turnWaiter) {
|
|
402
|
+
const waiter = this.turnWaiter;
|
|
403
|
+
this.turnWaiter = null;
|
|
404
|
+
frame.subtype === 'error' || frame.stop_reason === 'error'
|
|
405
|
+
? waiter.reject(new Error(frame.result || 'Letta turn failed.'))
|
|
406
|
+
: waiter.resolve(frame);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (frame.type === 'control_response') {
|
|
410
|
+
const requestId = frame.response?.request_id;
|
|
411
|
+
const waiter = this.control.get(requestId);
|
|
412
|
+
if (!waiter)
|
|
413
|
+
return;
|
|
414
|
+
this.control.delete(requestId);
|
|
415
|
+
frame.response?.subtype === 'error'
|
|
416
|
+
? waiter.reject(new Error(frame.response.error || 'Letta control request failed.'))
|
|
417
|
+
: waiter.resolve(frame.response?.response ?? frame.response);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (frame.type === 'control_request')
|
|
421
|
+
void this.answerReverse(frame);
|
|
422
|
+
}
|
|
423
|
+
onClose(error) {
|
|
424
|
+
this.initWaiter.reject(error);
|
|
425
|
+
if (this.turnWaiter) {
|
|
426
|
+
this.turnWaiter.reject(error);
|
|
427
|
+
this.turnWaiter = null;
|
|
428
|
+
}
|
|
429
|
+
for (const waiter of this.control.values())
|
|
430
|
+
waiter.reject(error);
|
|
431
|
+
this.control.clear();
|
|
432
|
+
}
|
|
433
|
+
async open(timeoutMs = 90_000) {
|
|
434
|
+
const timer = setTimeout(() => this.initWaiter.reject(new Error('Letta init timed out.')), timeoutMs);
|
|
435
|
+
timer.unref?.();
|
|
436
|
+
try {
|
|
437
|
+
await this.initWaiter.promise;
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
controlRequest(subtype, extra = {}, timeoutMs = 30_000) {
|
|
444
|
+
const requestId = `turns_letta_${subtype}_${crypto.randomUUID()}`;
|
|
445
|
+
const waiter = deferred();
|
|
446
|
+
this.control.set(requestId, waiter);
|
|
447
|
+
this.emit({ type: 'control_request', request_id: requestId, request: { subtype, ...extra } });
|
|
448
|
+
const timer = setTimeout(() => {
|
|
449
|
+
if (this.control.delete(requestId))
|
|
450
|
+
waiter.reject(new Error(`Letta control request timed out: ${subtype}`));
|
|
451
|
+
}, timeoutMs);
|
|
452
|
+
timer.unref?.();
|
|
453
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
454
|
+
}
|
|
455
|
+
async answerReverse(frame) {
|
|
456
|
+
let response;
|
|
457
|
+
try {
|
|
458
|
+
response = await this.anchor.askInteraction(frame);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.emit({ type: 'control_response', response: { subtype: 'success', request_id: frame.request_id, response } });
|
|
464
|
+
}
|
|
465
|
+
prompt(text, timeoutMs) {
|
|
466
|
+
if (this.turnWaiter)
|
|
467
|
+
throw new Error('Letta already has an active turn.');
|
|
468
|
+
const waiter = deferred();
|
|
469
|
+
this.turnWaiter = waiter;
|
|
470
|
+
this.emit({ type: 'user', message: { role: 'user', content: text } });
|
|
471
|
+
const timer = setTimeout(() => {
|
|
472
|
+
if (this.turnWaiter === waiter)
|
|
473
|
+
this.turnWaiter = null;
|
|
474
|
+
waiter.reject(new Error('Letta turn timed out.'));
|
|
475
|
+
}, timeoutMs);
|
|
476
|
+
timer.unref?.();
|
|
477
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
478
|
+
}
|
|
479
|
+
async interrupt() {
|
|
480
|
+
try {
|
|
481
|
+
return await this.controlRequest('interrupt');
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
try {
|
|
485
|
+
process.kill(-this.child.pid, 'SIGINT');
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
try {
|
|
489
|
+
this.child.kill('SIGINT');
|
|
490
|
+
}
|
|
491
|
+
catch { }
|
|
492
|
+
}
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
336
497
|
class CodexAppServerTransport extends LineTransport {
|
|
337
498
|
threadId;
|
|
338
499
|
permissionMode;
|
|
@@ -1275,9 +1436,11 @@ class Anchor {
|
|
|
1275
1436
|
// send_message crosses a local socket after the queue claim crosses a
|
|
1276
1437
|
// WebSocket, so either acknowledgement can be lost after the agent has
|
|
1277
1438
|
// already accepted the turn. A replacement claim for the same prompt must
|
|
1278
|
-
// adopt
|
|
1279
|
-
//
|
|
1280
|
-
|
|
1439
|
+
// adopt it regardless of outcome instead of repeating work or turning a
|
|
1440
|
+
// persistent harness failure into an automatic retry loop. A deliberate
|
|
1441
|
+
// user retry arrives with a new queue message ID.
|
|
1442
|
+
if (sameClaim ||
|
|
1443
|
+
(sameMessage && ['running', 'completed', 'interrupted', 'error'].includes(this.currentTurn?.status))) {
|
|
1281
1444
|
if (claimToken && claimToken !== this.currentTurn.claimToken) {
|
|
1282
1445
|
this.currentTurn = { ...this.currentTurn, claimToken };
|
|
1283
1446
|
}
|
|
@@ -1380,6 +1543,26 @@ class Anchor {
|
|
|
1380
1543
|
stopReason: result?.method || 'turn/completed',
|
|
1381
1544
|
};
|
|
1382
1545
|
}
|
|
1546
|
+
if (this.source === 'letta') {
|
|
1547
|
+
if (!(this.transport instanceof LettaTransport) ||
|
|
1548
|
+
this.transport.child.exitCode !== null ||
|
|
1549
|
+
lettaSessionIdentity(this.transport.agentId, this.transport.conversationId) !== this.agentSessionId ||
|
|
1550
|
+
this.transport.permissionMode !== this.permissionMode) {
|
|
1551
|
+
this.transport?.stop?.();
|
|
1552
|
+
this.transport = new LettaTransport(this, createSession);
|
|
1553
|
+
await this.transport.open();
|
|
1554
|
+
}
|
|
1555
|
+
if (interruptedBeforePrompt())
|
|
1556
|
+
return interruptedBeforePrompt();
|
|
1557
|
+
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1558
|
+
return {
|
|
1559
|
+
exitCode: 0,
|
|
1560
|
+
signal: null,
|
|
1561
|
+
timedOut: false,
|
|
1562
|
+
error: null,
|
|
1563
|
+
stopReason: result?.stop_reason || result?.subtype || 'completed',
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1383
1566
|
if (PROCESS_HARNESSES.has(this.source)) {
|
|
1384
1567
|
const invocation = processInvocation({
|
|
1385
1568
|
harness: this.source,
|
package/dist/file-browsing.js
CHANGED
|
@@ -31,9 +31,11 @@ export const FILE_TYPE_SAMPLE_BYTES = 64 * 1024;
|
|
|
31
31
|
/** Extensions carrying their own type, where guessing text would be wrong. */
|
|
32
32
|
const BINARY_CONTENT_TYPES = new Map([
|
|
33
33
|
['.apng', 'image/apng'],
|
|
34
|
+
['.aac', 'audio/aac'],
|
|
34
35
|
['.avif', 'image/avif'],
|
|
35
36
|
['.bmp', 'image/bmp'],
|
|
36
37
|
['.gif', 'image/gif'],
|
|
38
|
+
['.flac', 'audio/flac'],
|
|
37
39
|
['.gz', 'application/gzip'],
|
|
38
40
|
['.heic', 'image/heic'],
|
|
39
41
|
['.ico', 'image/x-icon'],
|
|
@@ -41,12 +43,20 @@ const BINARY_CONTENT_TYPES = new Map([
|
|
|
41
43
|
['.jpg', 'image/jpeg'],
|
|
42
44
|
['.mp3', 'audio/mpeg'],
|
|
43
45
|
['.mp4', 'video/mp4'],
|
|
46
|
+
['.m4a', 'audio/mp4'],
|
|
47
|
+
['.m4v', 'video/mp4'],
|
|
48
|
+
['.mov', 'video/quicktime'],
|
|
49
|
+
['.oga', 'audio/ogg'],
|
|
50
|
+
['.ogg', 'audio/ogg'],
|
|
51
|
+
['.ogv', 'video/ogg'],
|
|
52
|
+
['.opus', 'audio/ogg'],
|
|
44
53
|
['.pdf', 'application/pdf'],
|
|
45
54
|
['.png', 'image/png'],
|
|
46
55
|
['.svg', 'image/svg+xml'],
|
|
47
56
|
['.wasm', 'application/wasm'],
|
|
48
57
|
['.webm', 'video/webm'],
|
|
49
58
|
['.webp', 'image/webp'],
|
|
59
|
+
['.wav', 'audio/wav'],
|
|
50
60
|
['.zip', 'application/zip'],
|
|
51
61
|
]);
|
|
52
62
|
/**
|
package/dist/harness-registry.js
CHANGED
|
@@ -159,7 +159,36 @@ export const ACP_PROFILES = {
|
|
|
159
159
|
// These harnesses keep one bidirectional process attached to the anchor. They
|
|
160
160
|
// are listed separately from process harnesses so queued turns cannot
|
|
161
161
|
// accidentally fall back to one-shot CLI invocations.
|
|
162
|
-
export const RESIDENT_HARNESSES = new Set(['claude', 'codex']);
|
|
162
|
+
export const RESIDENT_HARNESSES = new Set(['claude', 'codex', 'letta']);
|
|
163
|
+
/** Letta's agent + conversation pair identifies one exact transcript. */
|
|
164
|
+
export function lettaSessionIdentity(agentId, conversationId) {
|
|
165
|
+
return `letta:${String(agentId)}:${String(conversationId)}`;
|
|
166
|
+
}
|
|
167
|
+
export function parseLettaSessionIdentity(value) {
|
|
168
|
+
const match = /^letta:(agent-(?:local-)?[0-9a-f-]+):(.+)$/iu.exec(String(value || ''));
|
|
169
|
+
return match ? { agentId: match[1], conversationId: match[2] } : { agentId: '', conversationId: String(value || '') };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Letta's TUI stores cloud credentials in ~/.letta/settings.json, while its
|
|
173
|
+
* headless transport requires LETTA_API_KEY in the spawned process. Return a
|
|
174
|
+
* narrow child-only environment overlay; explicit daemon environment wins.
|
|
175
|
+
*/
|
|
176
|
+
export function lettaHeadlessEnvironment(home = HOME) {
|
|
177
|
+
if (process.env.LETTA_API_KEY)
|
|
178
|
+
return {};
|
|
179
|
+
try {
|
|
180
|
+
const settings = JSON.parse(fs.readFileSync(path.join(home, '.letta', 'settings.json'), 'utf8'));
|
|
181
|
+
const apiKey = typeof settings?.env?.LETTA_API_KEY === 'string' ? settings.env.LETTA_API_KEY.trim() : '';
|
|
182
|
+
const baseUrl = typeof settings?.env?.LETTA_BASE_URL === 'string' ? settings.env.LETTA_BASE_URL.trim() : '';
|
|
183
|
+
return {
|
|
184
|
+
...(apiKey ? { LETTA_API_KEY: apiKey } : {}),
|
|
185
|
+
...(!process.env.LETTA_BASE_URL && baseUrl ? { LETTA_BASE_URL: baseUrl } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
163
192
|
export const RPC_PROFILES = {
|
|
164
193
|
// Prime selects an existing session only at process launch. The transcript
|
|
165
194
|
// path is authoritative (its header id can differ from its filename id), so
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { applicationPaths } from "./auth.js";
|
|
5
|
+
import { lettaSessionIdentity } from "./harness-registry.js";
|
|
6
|
+
function contextDirectory(stateDirectory = applicationPaths().stateDirectory) {
|
|
7
|
+
return path.join(stateDirectory, 'letta-sessions');
|
|
8
|
+
}
|
|
9
|
+
function contextFile(identity, stateDirectory) {
|
|
10
|
+
const digest = crypto.createHash('sha256').update(identity).digest('hex');
|
|
11
|
+
return path.join(contextDirectory(stateDirectory), `${digest}.json`);
|
|
12
|
+
}
|
|
13
|
+
/** Letta transcripts identify an agent and conversation but do not persist the
|
|
14
|
+
* cwd. Keep Turns-owned launch context outside Letta's data directory so a
|
|
15
|
+
* later daemon scan can safely resume the conversation in the same project. */
|
|
16
|
+
export function recordLettaSessionContext(context, stateDirectory) {
|
|
17
|
+
if (!context.agentId ||
|
|
18
|
+
!context.conversationId ||
|
|
19
|
+
!path.isAbsolute(context.workingDirectory) ||
|
|
20
|
+
!path.isAbsolute(context.transcriptPath))
|
|
21
|
+
return;
|
|
22
|
+
const identity = lettaSessionIdentity(context.agentId, context.conversationId);
|
|
23
|
+
const directory = contextDirectory(stateDirectory);
|
|
24
|
+
const file = contextFile(identity, stateDirectory);
|
|
25
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
26
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ identity, ...context })}\n`, { mode: 0o600 });
|
|
29
|
+
try {
|
|
30
|
+
fs.renameSync(temporary, file);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (process.platform !== 'win32')
|
|
34
|
+
throw error;
|
|
35
|
+
try {
|
|
36
|
+
fs.unlinkSync(file);
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
fs.renameSync(temporary, file);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
try {
|
|
44
|
+
fs.unlinkSync(temporary);
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function readLettaSessionContext(identity, stateDirectory) {
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(fs.readFileSync(contextFile(identity, stateDirectory), 'utf8'));
|
|
52
|
+
if (value?.identity !== identity ||
|
|
53
|
+
!value.agentId ||
|
|
54
|
+
!value.conversationId ||
|
|
55
|
+
!path.isAbsolute(value.workingDirectory || '') ||
|
|
56
|
+
!path.isAbsolute(value.transcriptPath || ''))
|
|
57
|
+
return null;
|
|
58
|
+
if (lettaSessionIdentity(value.agentId, value.conversationId) !== identity)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
agentId: String(value.agentId),
|
|
62
|
+
conversationId: String(value.conversationId),
|
|
63
|
+
workingDirectory: String(value.workingDirectory),
|
|
64
|
+
transcriptPath: String(value.transcriptPath),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -4,6 +4,8 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
6
|
import { readDeepseekTrajectoryHeader } from "./deepseek-trajectories.js";
|
|
7
|
+
import { lettaSessionIdentity } from "./harness-registry.js";
|
|
8
|
+
import { readLettaSessionContext } from "./letta-session-context.js";
|
|
7
9
|
const HOME = os.homedir();
|
|
8
10
|
const PROJECTION_ROOT = path.join(HOME, '.local', 'state', 'turns-dev', 'session-projections');
|
|
9
11
|
const DEEPSEEK_SESSIONS_ROOT = path.join(path.resolve(process.env.DSH_HOME || path.join(HOME, '.dsh')), 'sessions');
|
|
@@ -91,6 +93,10 @@ const EXTRA_FILE_STORES = {
|
|
|
91
93
|
root: path.join(HOME, '.fx', 'sessions'),
|
|
92
94
|
include: (file) => path.basename(file) === 'events.jsonl',
|
|
93
95
|
},
|
|
96
|
+
letta: {
|
|
97
|
+
root: path.join(HOME, '.letta', 'transcripts'),
|
|
98
|
+
include: (file) => path.basename(file) === 'transcript.jsonl',
|
|
99
|
+
},
|
|
94
100
|
};
|
|
95
101
|
function safeStat(file) {
|
|
96
102
|
try {
|
|
@@ -253,6 +259,7 @@ function nativeSessionIdFromPath(harness, sourcePath) {
|
|
|
253
259
|
cline: /\/([^/]+)\.messages\.json$/iu,
|
|
254
260
|
poolside: /trajectory-(?:standalone_)?([0-9a-f-]{20,})\.ndjson$/iu,
|
|
255
261
|
fx: /\/sessions\/([^/]+)\/events\.jsonl$/iu,
|
|
262
|
+
letta: /\/transcripts\/agent-(?:local-)?[0-9a-f-]+\/([^/]+)\/transcript\.jsonl$/iu,
|
|
256
263
|
};
|
|
257
264
|
return patterns[harness]?.exec(normalized)?.[1] || '';
|
|
258
265
|
}
|
|
@@ -1097,6 +1104,13 @@ export function nativeDescriptorWorkingDirectory(descriptor) {
|
|
|
1097
1104
|
cursor: cursorMetadataWorkingDirectory,
|
|
1098
1105
|
gemini: geminiMetadataWorkingDirectory,
|
|
1099
1106
|
kimi: kimiMetadataWorkingDirectory,
|
|
1107
|
+
letta: (candidate) => {
|
|
1108
|
+
const normalized = String(candidate.sourcePath || candidate.file || '').replaceAll('\\', '/');
|
|
1109
|
+
const match = /\/transcripts\/(agent-(?:local-)?[0-9a-f-]+)\/([^/]+)\/transcript\.jsonl$/iu.exec(normalized);
|
|
1110
|
+
if (!match)
|
|
1111
|
+
return '';
|
|
1112
|
+
return readLettaSessionContext(lettaSessionIdentity(match[1], match[2]))?.workingDirectory || '';
|
|
1113
|
+
},
|
|
1100
1114
|
fx: (candidate) => absolutePathCandidate([
|
|
1101
1115
|
sidecarJson(candidate, 'session.json')?.workspace_root,
|
|
1102
1116
|
sidecarJson(candidate, 'session.json')?.origin_workspace_root,
|
|
@@ -1176,6 +1190,12 @@ export async function nativeDescriptorAgentSessionId(descriptor) {
|
|
|
1176
1190
|
// into work whose lifecycle and ownership belong to its parent agent.
|
|
1177
1191
|
if (subagent)
|
|
1178
1192
|
return '';
|
|
1193
|
+
if (descriptor?.harness === 'letta') {
|
|
1194
|
+
const normalized = String(descriptor?.sourcePath || descriptor?.file || '').replaceAll('\\', '/');
|
|
1195
|
+
const match = /\/transcripts\/(agent-(?:local-)?[0-9a-f-]+)\/([^/]+)\/transcript\.jsonl$/iu.exec(normalized);
|
|
1196
|
+
if (match)
|
|
1197
|
+
return lettaSessionIdentity(match[1], match[2]);
|
|
1198
|
+
}
|
|
1179
1199
|
const structural = descriptorNativeSessionId(descriptor);
|
|
1180
1200
|
if (structural) {
|
|
1181
1201
|
// Kimi persists its ACP key as `session_<uuid>`. The path parser used to
|
|
@@ -1309,6 +1329,7 @@ const HARNESS_PROCESS_NAMES = {
|
|
|
1309
1329
|
hermes: ['hermes'],
|
|
1310
1330
|
kilo: ['kilo'],
|
|
1311
1331
|
kimi: ['kimi', 'kimi-code'],
|
|
1332
|
+
letta: ['letta'],
|
|
1312
1333
|
muse: ['muse'],
|
|
1313
1334
|
omp: ['omp'],
|
|
1314
1335
|
openclaw: ['openclaw'],
|
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);
|