@turns/dev 0.0.4 → 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.
@@ -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;
@@ -1436,9 +1436,11 @@ class Anchor {
1436
1436
  // send_message crosses a local socket after the queue claim crosses a
1437
1437
  // WebSocket, so either acknowledgement can be lost after the agent has
1438
1438
  // already accepted the turn. A replacement claim for the same prompt must
1439
- // adopt a running/successful turn instead of executing it again. Failed or
1440
- // interrupted turns remain retryable when the server issues a new claim.
1441
- if (sameClaim || (sameMessage && ['running', 'completed'].includes(this.currentTurn?.status))) {
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))) {
1442
1444
  if (claimToken && claimToken !== this.currentTurn.claimToken) {
1443
1445
  this.currentTurn = { ...this.currentTurn, claimToken };
1444
1446
  }
@@ -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
  /**
@@ -168,6 +168,27 @@ export function parseLettaSessionIdentity(value) {
168
168
  const match = /^letta:(agent-(?:local-)?[0-9a-f-]+):(.+)$/iu.exec(String(value || ''));
169
169
  return match ? { agentId: match[1], conversationId: match[2] } : { agentId: '', conversationId: String(value || '') };
170
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
+ }
171
192
  export const RPC_PROFILES = {
172
193
  // Prime selects an existing session only at process launch. The transcript
173
194
  // path is authoritative (its header id can differ from its filename id), so
@@ -45,6 +45,70 @@ export async function reserveCreatingNativeSession(request, creating, sessionId,
45
45
  return;
46
46
  await request({ type: 'session.native.reserve', sessionId, agentSessionId });
47
47
  }
48
+ function workingDirectoryFailure(message, directory, error) {
49
+ const detail = error instanceof Error ? error.message : String(error || 'Unknown filesystem error.');
50
+ return new Error(`${message}: ${directory}. ${detail}`, { cause: error });
51
+ }
52
+ /** Resolve the directory typed into a new-session draft on the machine that
53
+ * will run it. The browser cannot expand `~` because the daemon's home may be
54
+ * different from the viewer's, and a new project directory may not exist yet.
55
+ *
56
+ * Check the nearest existing ancestor against the daemon allowlist before
57
+ * creating anything. The final assertion repeats that check after mkdir so a
58
+ * symlinked ancestor cannot launch a session outside the allowlist.
59
+ */
60
+ export function prepareNewSessionWorkingDirectory(requestedDirectory, allowedDirectories = [], homeDirectory = os.homedir()) {
61
+ const requested = String(requestedDirectory || '').trim();
62
+ if (!requested)
63
+ throw new Error('Choose a working directory.');
64
+ const expanded = requested === '~'
65
+ ? homeDirectory
66
+ : requested.startsWith('~/')
67
+ ? path.join(homeDirectory, requested.slice(2))
68
+ : requested;
69
+ if (!path.isAbsolute(expanded)) {
70
+ throw new Error(`Working directory must be an absolute path or start with ~/: ${requested}`);
71
+ }
72
+ const resolved = path.resolve(expanded);
73
+ let exists = true;
74
+ try {
75
+ fs.statSync(resolved);
76
+ }
77
+ catch (error) {
78
+ if (error?.code === 'ENOENT')
79
+ exists = false;
80
+ else
81
+ throw workingDirectoryFailure('Working directory is inaccessible', resolved, error);
82
+ }
83
+ if (!exists) {
84
+ let ancestor = path.dirname(resolved);
85
+ while (!fs.existsSync(ancestor)) {
86
+ const parent = path.dirname(ancestor);
87
+ if (parent === ancestor)
88
+ break;
89
+ ancestor = parent;
90
+ }
91
+ if (allowedDirectories.length && !workingDirectoryIsAllowed(ancestor, allowedDirectories)) {
92
+ throw new Error(`Working directory is outside the daemon allowlist: ${resolved}`);
93
+ }
94
+ try {
95
+ fs.mkdirSync(resolved, { recursive: true });
96
+ }
97
+ catch (error) {
98
+ throw workingDirectoryFailure('Working directory could not be created', resolved, error);
99
+ }
100
+ }
101
+ try {
102
+ const canonical = assertAllowedWorkingDirectory(resolved, allowedDirectories);
103
+ fs.accessSync(canonical, fs.constants.X_OK);
104
+ return canonical;
105
+ }
106
+ catch (error) {
107
+ if (/outside the daemon allowlist/iu.test(String(error?.message || error)))
108
+ throw error;
109
+ throw workingDirectoryFailure('Working directory is inaccessible', resolved, error);
110
+ }
111
+ }
48
112
  /** The origin serving the same deployment as a `wss://…/api/socket` endpoint. */
49
113
  export function httpBaseUrl(websocketUrl) {
50
114
  const url = new URL(websocketUrl);
@@ -244,7 +308,13 @@ export function resultError(result) {
244
308
  }
245
309
  export function dispatchErrorIsRetryable(error) {
246
310
  const message = String(error?.message || error || '');
247
- return !(/(?:not supported|does not support|no resumable native session|session key.*unavailable|no local transcript descriptor|working directory is unavailable|outside the daemon allowlist|agent executable not found)/iu.test(message) || /attachment .* (?:size mismatch|download failed \(4\d\d\))/iu.test(message));
311
+ // OpenCode emits this only after its provider stream has exhausted its own
312
+ // backoff. Re-queueing the turn starts that entire retry sequence again and
313
+ // also persists another copy of the user's prompt in the native session.
314
+ return !(/(?:not supported|does not support|no resumable native session|session key.*unavailable|no local transcript descriptor|working directory (?:is (?:unavailable|not a directory|inaccessible)|must be|could not be created)|outside the daemon allowlist|agent executable not found|provider finish_reason:\s*network_error)/iu.test(message) || /attachment .* (?:size mismatch|download failed \(4\d\d\))/iu.test(message));
315
+ }
316
+ export function queueFailureIsRetryable(error, promptStarted) {
317
+ return !promptStarted && dispatchErrorIsRetryable(error);
248
318
  }
249
319
  export async function actualHarnessStatus(entry) {
250
320
  if (!entry || entry.connection.closed)
@@ -505,7 +575,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
505
575
  promptId: claim.prompt.id,
506
576
  claimToken: claim.claimToken,
507
577
  error: redactor.redactText(error.message),
508
- retryable: dispatchErrorIsRetryable(error),
578
+ // The anchor accepted and finished this prompt. Replaying it can
579
+ // duplicate side effects and turns persistent harness failures (for
580
+ // example, expired authentication) into an unbounded retry loop.
581
+ retryable: queueFailureIsRetryable(error, true),
509
582
  });
510
583
  }
511
584
  else {
@@ -572,11 +645,11 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
572
645
  const workingDirectory = pendingNativeSession
573
646
  ? String(claim.session?.workingDirectory || '')
574
647
  : await nativeDescriptorWorkingDirectory(descriptor);
575
- const fallbackDirectory = path.dirname(descriptor.file);
576
- const selectedDirectory = workingDirectory && path.isAbsolute(workingDirectory) && fs.existsSync(workingDirectory)
577
- ? workingDirectory
578
- : fallbackDirectory;
579
- cwd = assertAllowedWorkingDirectory(selectedDirectory, options.allowedDirectories);
648
+ cwd = pendingNativeSession
649
+ ? prepareNewSessionWorkingDirectory(workingDirectory, options.allowedDirectories)
650
+ : assertAllowedWorkingDirectory(workingDirectory && path.isAbsolute(workingDirectory) && fs.existsSync(workingDirectory)
651
+ ? workingDirectory
652
+ : path.dirname(descriptor.file), options.allowedDirectories);
580
653
  if (!fs.statSync(cwd).isDirectory())
581
654
  throw new Error(`Working directory is unavailable: ${cwd}`);
582
655
  entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId);
@@ -587,6 +660,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
587
660
  ...claim,
588
661
  entry,
589
662
  renewTimer: null,
663
+ // Recovery only observes turns that an anchor already accepted. Treat
664
+ // an uncertain recovery failure conservatively for the same reason as a
665
+ // failed live turn: the prompt may already have produced side effects.
666
+ promptStarted: Boolean(recovery),
590
667
  };
591
668
  claims.set(sessionId, queued);
592
669
  const renewClaim = () => request({
@@ -645,6 +722,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
645
722
  // boundary before starting this attempt so an earlier turn_error with
646
723
  // that ID cannot immediately settle the new turn.
647
724
  const eventCursor = entry.connection.eventCursor();
725
+ // A local request can lose its response after the anchor has accepted
726
+ // the prompt, so crossing this boundary permanently disables automatic
727
+ // replay even when the exact delivery outcome is unknown.
728
+ queued.promptStarted = true;
648
729
  await entry.connection.request('send_message', {
649
730
  source: descriptor.harness,
650
731
  sessionId,
@@ -712,7 +793,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
712
793
  promptId: claim.prompt.id,
713
794
  claimToken: claim.claimToken,
714
795
  error: redactor.redactText(error?.message || String(error)),
715
- retryable: dispatchErrorIsRetryable(error),
796
+ retryable: queueFailureIsRetryable(error, Boolean(held?.promptStarted)),
716
797
  }).catch(() => { });
717
798
  }
718
799
  claims.delete(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",