@turns/dev 0.0.22 → 0.0.24

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 CHANGED
@@ -69,6 +69,13 @@ On macOS, credentials and daemon state live under
69
69
  LaunchAgent `~/Library/LaunchAgents/dev.turns.cli.plist`. Its output is written
70
70
  to `~/Library/Application Support/turns/daemon.log`.
71
71
 
72
+ Daemon runtime logs (including reconnects, upload progress, and errors) prefix
73
+ each line with a UTC ISO 8601 timestamp, for example
74
+ `[2026-10-19T09:12:34.567Z] Reconnected to wss://turns.dev/api/socket ...`.
75
+ Foreground uploads use the same timestamped runtime logs. Multiline messages
76
+ share one timestamp and retain their indentation; stdout and stderr remain
77
+ separate. Help, version, and service-management output keep their plain format.
78
+
72
79
  The credential file contains a seven-day access token and a signed refresh
73
80
  token. The CLI refreshes the access token at least once every 24 hours. A new
74
81
  machine opens a one-time turns.dev URL, waits on an authentication WebSocket,
@@ -209,6 +209,8 @@ class LineTransport {
209
209
  */
210
210
  const CLAUDE_NOTIFICATION_RESULT_GRACE_MS = 2_000;
211
211
  class ClaudeTransport extends LineTransport {
212
+ model;
213
+ thinkingLevel;
212
214
  nativeTurnActive = false;
213
215
  sessionId;
214
216
  permissionMode;
@@ -242,6 +244,8 @@ class ClaudeTransport extends LineTransport {
242
244
  super(anchor, 'claude', resolveHarnessCommand('claude', profile), args, anchor.cwd, {
243
245
  CLAUDE_CODE_ENTRYPOINT: 'turns-agent-anchor',
244
246
  });
247
+ this.model = anchor.model;
248
+ this.thinkingLevel = anchor.thinkingLevel;
245
249
  this.sessionId = anchor.agentSessionId;
246
250
  this.permissionMode = anchor.permissionMode;
247
251
  this.turnWaiter = null;
@@ -789,7 +793,12 @@ class CodexAppServerTransport extends LineTransport {
789
793
  this.turnId = null;
790
794
  this.turnIdWaiter = deferred();
791
795
  this.turnWaiter = waiter;
792
- const started = await this.request('turn/start', { threadId: this.threadId, input: [{ type: 'text', text }] }, timeoutMs).catch((error) => {
796
+ const started = await this.request('turn/start', {
797
+ threadId: this.threadId,
798
+ input: [{ type: 'text', text }],
799
+ ...(this.anchor.model && this.anchor.model !== 'auto' ? { model: this.anchor.model } : {}),
800
+ ...(this.anchor.thinkingLevel ? { effort: this.anchor.thinkingLevel } : {}),
801
+ }, timeoutMs).catch((error) => {
793
802
  if (this.turnWaiter === waiter)
794
803
  this.turnWaiter = null;
795
804
  this.turnIdWaiter?.resolve('');
@@ -1491,6 +1500,7 @@ class Anchor {
1491
1500
  protocol: 'turns.agent-anchor.v1',
1492
1501
  owner: 'turns.dev',
1493
1502
  capabilities: [
1503
+ 'model_settings',
1494
1504
  'interrupt',
1495
1505
  'shutdown',
1496
1506
  'answer_interaction',
@@ -1733,8 +1743,10 @@ class Anchor {
1733
1743
  cwd: path.resolve(request.cwd),
1734
1744
  transcriptPath: path.resolve(request.transcriptPath),
1735
1745
  workerId: String(request.workerId || ''),
1736
- model: String(request.model || ''),
1737
- thinkingLevel: String(request.thinkingLevel || ''),
1746
+ // Launch hints disappear after binding. Keep a warm session's choices
1747
+ // until the next explicit selection; never leak them across identities.
1748
+ model: String(request.model || (request.sessionId === this.sessionId ? this.model : '') || ''),
1749
+ thinkingLevel: String(request.thinkingLevel || (request.sessionId === this.sessionId ? this.thinkingLevel : '') || ''),
1738
1750
  permissionMode: requestedPermissionMode(request.permissionMode),
1739
1751
  codexHistoryMode,
1740
1752
  idleTimeoutMs: Math.max(1_000, Number(request.idleTimeoutMs) || 300_000),
@@ -1776,6 +1788,8 @@ class Anchor {
1776
1788
  if (!(this.transport instanceof ClaudeTransport) ||
1777
1789
  this.transport.child.exitCode !== null ||
1778
1790
  this.transport.sessionId !== this.agentSessionId ||
1791
+ this.transport.model !== this.model ||
1792
+ this.transport.thinkingLevel !== this.thinkingLevel ||
1779
1793
  this.transport.permissionMode !== this.permissionMode) {
1780
1794
  this.transport?.stop?.();
1781
1795
  this.transport = new ClaudeTransport(this, createSession);
@@ -372,7 +372,7 @@ export function processInvocation({ harness, sessionId, transcriptPath, cwd, pro
372
372
  sessionId,
373
373
  ...(!createSession ? ['--allow-workspace-switch'] : []),
374
374
  ...(model && model !== 'auto' ? ['--model', model] : []),
375
- ...(thinkingLevel ? ['--reasoning-effort', thinkingLevel === 'max' ? 'ultra' : thinkingLevel] : []),
375
+ ...(thinkingLevel ? ['--reasoning-effort', thinkingLevel] : []),
376
376
  prompt,
377
377
  ],
378
378
  };
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { applicationPaths, authenticate, CREDENTIAL_REFRESH_INTERVAL_MS, credentialsNeedRefresh, exchangeLegacyToken, normalizeOrigin, readCredentials, recoverRejectedCredentials, refreshCredentials, removeCredentials, uploadSocketUrl, writeCredentials, } from "./auth.js";
7
7
  import { daemonStatus, installDaemon, uninstallDaemon } from "./daemon.js";
8
8
  import { normalizeAllowedDirectories } from "./directory-policy.js";
9
+ import { logError, logInfo } from "./logging.js";
9
10
  const HERE = path.dirname(fileURLToPath(import.meta.url));
10
11
  const CLI_DIRECTORY = path.basename(HERE) === 'dist' ? path.dirname(HERE) : HERE;
11
12
  const pkg = JSON.parse(fs.readFileSync(path.join(CLI_DIRECTORY, 'package.json'), 'utf8'));
@@ -50,11 +51,11 @@ export async function migrateLegacyCredentials(paths, origin, { legacyTokenFile
50
51
  const credentials = await exchangeLegacyToken(normalizeOrigin(origin || 'https://turns.dev'), legacyToken);
51
52
  writeCredentials(credentials, paths);
52
53
  fs.unlinkSync(legacyTokenFile);
53
- process.stdout.write(`Upgraded the existing uploader credential for @${credentials.user.login}.\n`);
54
+ logInfo(`Upgraded the existing uploader credential for @${credentials.user.login}.\n`);
54
55
  return credentials;
55
56
  }
56
57
  catch (error) {
57
- process.stderr.write(`Could not upgrade the existing uploader credential: ${error.message}\n`);
58
+ logError(`Could not upgrade the existing uploader credential: ${error.message}\n`);
58
59
  return null;
59
60
  }
60
61
  }
@@ -166,7 +167,7 @@ function credentialProvider(initial, { paths, interactive }) {
166
167
  };
167
168
  const timer = setInterval(() => {
168
169
  refreshIfDue().catch((error) => {
169
- process.stderr.write(`Turns credential refresh failed: ${error.message}; retrying later.\n`);
170
+ logError(`Turns credential refresh failed: ${error.message}; retrying later.\n`);
170
171
  });
171
172
  }, Math.min(CREDENTIAL_REFRESH_INTERVAL_MS, 60 * 60_000));
172
173
  timer.unref?.();
@@ -282,7 +283,7 @@ async function main(rawArguments = process.argv.slice(2)) {
282
283
  credentials = await authenticate({ origin: parsed.origin, paths, interactive: false });
283
284
  }
284
285
  if (allowedDirectories.length) {
285
- process.stdout.write(`Daemon directory allowlist:\n${allowedDirectories.map((value) => ` ${value}`).join('\n')}\n`);
286
+ logInfo(`Daemon directory allowlist:\n${allowedDirectories.map((value) => ` ${value}`).join('\n')}\n`);
286
287
  }
287
288
  await runUpload(credentials, {
288
289
  paths,
@@ -312,7 +313,7 @@ async function main(rawArguments = process.argv.slice(2)) {
312
313
  const selected = scopedWatchOptions(command === 'upload' ? argv[1] || process.cwd() : process.cwd(), {
313
314
  once: parsed.once,
314
315
  });
315
- process.stdout.write(selected.watch
316
+ logInfo(selected.watch
316
317
  ? `Watching sessions in ${selected.directory} as @${credentials.user.login}. Press Ctrl-C to stop.\n`
317
318
  : `Uploading sessions in ${selected.directory} as @${credentials.user.login}.\n`);
318
319
  await runUpload(credentials, {
@@ -330,7 +331,7 @@ try {
330
331
  catch { }
331
332
  if (invokedAsMain) {
332
333
  main().catch((error) => {
333
- process.stderr.write(`turns: ${error?.message || error}\n`);
334
+ logError(`turns: ${error?.message || error}\n`);
334
335
  process.exitCode = 1;
335
336
  });
336
337
  }
@@ -0,0 +1,14 @@
1
+ import process from 'node:process';
2
+ /** Callers supply complete log messages, including their trailing newline. */
3
+ export function formatLogMessage(message, now = new Date()) {
4
+ const prefix = `[${now.toISOString()}] `;
5
+ // Timestamp every physical line, but do not add a dangling prefix after the
6
+ // final newline. Keep indentation and use one time for the whole message.
7
+ return message.replace(/^(?=[\s\S])/gmu, prefix);
8
+ }
9
+ export function logInfo(message) {
10
+ return process.stdout.write(formatLogMessage(message));
11
+ }
12
+ export function logError(message) {
13
+ return process.stderr.write(formatLogMessage(message));
14
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { Transform } from 'node:stream';
5
+ import { logError } from "./logging.js";
5
6
  /**
6
7
  * Turning the paths an agent writes into things a reader can open.
7
8
  *
@@ -526,7 +527,7 @@ export function createPathResolver({ options, machine, baseUrl, appendResolution
526
527
  }
527
528
  const chain = (chains.get(sessionId) || Promise.resolve()).then(() => resolveBatch(sessionId, workingDirectory, fresh).catch((error) => {
528
529
  const message = error?.message || String(error);
529
- process.stderr.write(`Path resolution failed: ${redactor ? redactor.redactText(message) : message}\n`);
530
+ logError(`Path resolution failed: ${redactor ? redactor.redactText(message) : message}\n`);
530
531
  }));
531
532
  chains.set(sessionId, chain);
532
533
  return chain;
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
+ import { logError } from "./logging.js";
7
8
  import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
8
9
  import { createCodexUpdateManager } from "./codex-update.js";
9
10
  import { isAsyncQuestionId } from "./codex-async-question-recovery.js";
@@ -384,7 +385,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
384
385
  catch (error) {
385
386
  const message = String(error?.message || error);
386
387
  state.failures = (state.failures || 0) + 1;
387
- process.stderr.write(`Runtime event append failed for ${sessionId}: ${message}\n`);
388
+ logError(`Runtime event append failed for ${sessionId}: ${message}\n`);
388
389
  // Socket failures are retried inside request(). Keep valid anchor
389
390
  // events across other transient Worker/R2 failures too. Only discard
390
391
  // a batch when the session is gone or the internally constructed row
@@ -399,7 +400,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
399
400
  state.failures = 0;
400
401
  }
401
402
  else if (state.failures >= MAX_RUNTIME_FLUSH_FAILURES) {
402
- process.stderr.write(`Dropping ${events.length} undeliverable runtime events for ${sessionId} after ` +
403
+ logError(`Dropping ${events.length} undeliverable runtime events for ${sessionId} after ` +
403
404
  `${state.failures} attempts: ${message}\n`);
404
405
  state.events.splice(0, events.length);
405
406
  state.failures = 0;
@@ -504,7 +505,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
504
505
  if (state === 'idle')
505
506
  await flushRuntime(sessionId);
506
507
  await reportHarness(sessionId, state, entry);
507
- })().catch((error) => process.stderr.write(`Harness status upload failed: ${error?.message || error}\n`));
508
+ })().catch((error) => logError(`Harness status upload failed: ${error?.message || error}\n`));
508
509
  }
509
510
  if (message.type === 'live_event') {
510
511
  const turnMessageId = String(message.messageId || entry.snapshot.currentTurn?.messageId || '');
@@ -559,21 +560,25 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
559
560
  }
560
561
  bindAnchor(connection, snapshot);
561
562
  }
562
- const usableAnchor = async (sessionId, source, agentSessionId) => {
563
+ const usableAnchor = async (sessionId, source, agentSessionId, modelSettings = false) => {
563
564
  let entry = anchors.get(sessionId);
564
565
  if (entry && !entry.connection.closed) {
565
566
  entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
566
567
  if (String(entry.snapshot.source || '') === source &&
567
- String(entry.snapshot.agentSessionId || '') === agentSessionId)
568
+ String(entry.snapshot.agentSessionId || '') === agentSessionId &&
569
+ (!modelSettings || entry.snapshot.capabilities?.includes('model_settings')))
568
570
  return entry;
569
571
  if (entry.snapshot.status === 'running')
570
- throw new Error('The surviving anchor is running a different session identity.');
572
+ throw new Error('The surviving anchor must finish its turn before it can be replaced.');
571
573
  await entry.connection.request('shutdown', {}, 2_000).catch(() => { });
572
574
  entry.connection.close();
573
575
  anchors.delete(sessionId);
574
576
  }
575
577
  const connection = await launchAgentAnchor(sessionId);
576
578
  entry = bindAnchor(connection, connection.snapshot || {}, sessionId);
579
+ if (modelSettings && !entry.snapshot.capabilities?.includes('model_settings')) {
580
+ throw new Error('The installed agent anchor does not support session model settings. Update the Turns CLI.');
581
+ }
577
582
  await reportHarness(sessionId, 'idle', entry);
578
583
  return entry;
579
584
  };
@@ -629,7 +634,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
629
634
  // Dispatch deliberately does not wait for that publish: the queue preserves
630
635
  // order on its own, and a frame the Worker will not accept must not be able
631
636
  // to hold a claimed prompt back from the agent.
632
- void enqueueRuntime(sessionId, queuePromptBoundary(recovery?.entry || anchors.get(sessionId), claim), true).catch((error) => process.stderr.write(`Queue prompt boundary publish failed for ${sessionId}: ${error?.message || error}\n`));
637
+ void enqueueRuntime(sessionId, queuePromptBoundary(recovery?.entry || anchors.get(sessionId), claim), true).catch((error) => logError(`Queue prompt boundary publish failed for ${sessionId}: ${error?.message || error}\n`));
633
638
  let descriptor;
634
639
  let agentSessionId = '';
635
640
  let creating = false;
@@ -684,7 +689,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
684
689
  // Codex installation leaves a recoverable unsent prompt in the queue.
685
690
  if (descriptor.harness === 'codex')
686
691
  resolveHarnessCommand('codex');
687
- entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId);
692
+ entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId, Boolean(claim.session?.modelSettings));
688
693
  }
689
694
  if (!entry)
690
695
  throw new Error(`No surviving agent anchor exists for ${sessionId}.`);
@@ -713,7 +718,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
713
718
  void reportHarness(sessionId, 'stopped', entry).catch(() => { });
714
719
  return;
715
720
  }
716
- void renewClaim().catch((error) => process.stderr.write(`Queue lease renewal failed: ${error?.message || error}\n`));
721
+ void renewClaim().catch((error) => logError(`Queue lease renewal failed: ${error?.message || error}\n`));
717
722
  // Never renew lifecycle state from an old in-memory snapshot. The
718
723
  // anchor may have disconnected since the claim began, which would
719
724
  // otherwise resurrect a canonical stopped edge as running.
@@ -816,7 +821,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
816
821
  queueMicrotask(() => void tryClaim(sessionId));
817
822
  }
818
823
  catch (error) {
819
- process.stderr.write(`Queue dispatch failed for ${sessionId}: ${error?.message || error}\n`);
824
+ logError(`Queue dispatch failed for ${sessionId}: ${error?.message || error}\n`);
820
825
  const held = claims.get(sessionId);
821
826
  stopClaimRenewal(held);
822
827
  if (held?.entry && !held.entry.connection.closed) {
@@ -891,7 +896,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
891
896
  await reportHarness(sessionId, 'stopped', held?.entry || entry).catch(() => { });
892
897
  }
893
898
  });
894
- process.stderr.write(`Anchor recovery failed for ${sessionId}: ${error?.message || error}\n`);
899
+ logError(`Anchor recovery failed for ${sessionId}: ${error?.message || error}\n`);
895
900
  });
896
901
  }
897
902
  };
@@ -1330,7 +1335,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1330
1335
  // as their identity: a pane already showing them recognizes the
1331
1336
  // refresh instead of being offered its own save as a new version.
1332
1337
  contentEtag: String(edit.afterHash || ''),
1333
- }).catch((error) => process.stderr.write(`Post-edit refresh failed for ${applied.path}: ${error?.message || error}\n`));
1338
+ }).catch((error) => logError(`Post-edit refresh failed for ${applied.path}: ${error?.message || error}\n`));
1334
1339
  }
1335
1340
  replyRpc.notify({
1336
1341
  type: 'session.file.edit.result',
@@ -1399,6 +1404,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1399
1404
  machineId: machine,
1400
1405
  machineHostname: os.hostname(),
1401
1406
  capabilities: {
1407
+ sessionModelSettings: true,
1402
1408
  fileSearch: true,
1403
1409
  git: true,
1404
1410
  fileRanges: true,
@@ -1415,7 +1421,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1415
1421
  void tryClaim(sessionId);
1416
1422
  }
1417
1423
  catch (error) {
1418
- process.stderr.write(`Queue registration failed: ${error?.message || error}\n`);
1424
+ logError(`Queue registration failed: ${error?.message || error}\n`);
1419
1425
  }
1420
1426
  finally {
1421
1427
  registering.delete(control);
@@ -1434,7 +1440,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1434
1440
  void tryClaim(sessionId);
1435
1441
  }
1436
1442
  catch (error) {
1437
- process.stderr.write(`Machine heartbeat failed: ${error?.message || error}\n`);
1443
+ logError(`Machine heartbeat failed: ${error?.message || error}\n`);
1438
1444
  }
1439
1445
  }
1440
1446
  }
@@ -6,6 +6,7 @@ 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 { logError, logInfo } from "./logging.js";
9
10
  import { streamDeepseekTrajectoryLines } from "./deepseek-trajectories.js";
10
11
  import { normalizeAllowedDirectories, workingDirectoryIsAllowed } from "./directory-policy.js";
11
12
  import { discoverGitAssociation } from "./git-association.js";
@@ -131,7 +132,7 @@ function reportingRedactor(tokens) {
131
132
  return;
132
133
  const rules = Object.fromEntries([...counts].sort((left, right) => right[1] - left[1]));
133
134
  counts.clear();
134
- process.stderr.write(`Secret filtering applied (v${REDACTION_VERSION}): ${JSON.stringify(rules)}\n`);
135
+ logError(`Secret filtering applied (v${REDACTION_VERSION}): ${JSON.stringify(rules)}\n`);
135
136
  };
136
137
  const timer = setInterval(report, 60_000);
137
138
  timer.unref?.();
@@ -690,7 +691,7 @@ export async function connectUploader(options, machine, reconnect = false) {
690
691
  });
691
692
  try {
692
693
  const ready = await rpc.ready;
693
- process.stdout.write(`${reconnect ? 'Reconnected' : 'Connected'} to ${options.url} as @${ready.user.login} ` +
694
+ logInfo(`${reconnect ? 'Reconnected' : 'Connected'} to ${options.url} as @${ready.user.login} ` +
694
695
  `from ${os.hostname()} (${machine})\n`);
695
696
  return rpc;
696
697
  }
@@ -700,14 +701,14 @@ export async function connectUploader(options, machine, reconnect = false) {
700
701
  authenticationRecoveries += 1;
701
702
  const credentials = await options.credentialProvider.recover();
702
703
  options.token = credentials.accessToken;
703
- process.stderr.write('Uploader authentication was rejected; refreshed credentials.\n');
704
+ logError('Uploader authentication was rejected; refreshed credentials.\n');
704
705
  attempt -= 1;
705
706
  continue;
706
707
  }
707
708
  if (!isSocketError(error) || attempt === MAX_SOCKET_RETRIES)
708
709
  throw error;
709
710
  const delay = reconnectDelay(attempt);
710
- process.stderr.write(` WebSocket connection failed: ${error.message}; retrying in ` +
711
+ logError(` WebSocket connection failed: ${error.message}; retrying in ` +
711
712
  `${(delay / 1000).toFixed(1)}s (${attempt + 1}/${MAX_SOCKET_RETRIES})\n`);
712
713
  await wait(delay);
713
714
  }
@@ -756,7 +757,7 @@ export class SharedUploaderConnection {
756
757
  this.reconnectTimer = setTimeout(() => {
757
758
  this.reconnectTimer = null;
758
759
  void this.current().catch((error) => {
759
- process.stderr.write(`Shared uploader reconnect failed: ${error?.message || error}\n`);
760
+ logError(`Shared uploader reconnect failed: ${error?.message || error}\n`);
760
761
  this.scheduleReconnect();
761
762
  });
762
763
  }, 1_000);
@@ -921,7 +922,7 @@ async function uploadFile(rpc, machine, root, capture, redactor = DEFAULT_REDACT
921
922
  }
922
923
  nextRow = Number(acknowledgement.nextRow);
923
924
  sent += batch.rows.length;
924
- process.stdout.write(` ${capture.relative}: ${nextRow}/${rows.length} rows · revision ${acknowledgement.revision}${acknowledgement.archived ? ' · archived' : ''}\n`);
925
+ logInfo(` ${capture.relative}: ${nextRow}/${rows.length} rows · revision ${acknowledgement.revision}${acknowledgement.archived ? ' · archived' : ''}\n`);
925
926
  }
926
927
  return { rows: rows.length, sent, skipped: false };
927
928
  }
@@ -1372,7 +1373,7 @@ async function reportExternalManagement(rpc, machine, descriptors, previous = ne
1372
1373
  managedKeys = externallyManagedNativeDescriptorKeys(descriptors);
1373
1374
  }
1374
1375
  catch (error) {
1375
- process.stderr.write(`External ownership scan failed: ${error?.message || error}\n`);
1376
+ logError(`External ownership scan failed: ${error?.message || error}\n`);
1376
1377
  return { rpc, managed: null };
1377
1378
  }
1378
1379
  const observedAt = new Date().toISOString();
@@ -1391,7 +1392,7 @@ async function reportExternalManagement(rpc, machine, descriptors, previous = ne
1391
1392
  });
1392
1393
  }
1393
1394
  catch (error) {
1394
- process.stderr.write(`External ownership upload failed: ${error?.message || error}\n`);
1395
+ logError(`External ownership upload failed: ${error?.message || error}\n`);
1395
1396
  return { managed: null, previous };
1396
1397
  }
1397
1398
  }
@@ -1408,11 +1409,11 @@ async function watchExternalManagement(rpc, machine, getDescriptors) {
1408
1409
  previous = reported.previous;
1409
1410
  if (reported.managed !== null && reported.managed !== lastManagedCount) {
1410
1411
  lastManagedCount = reported.managed;
1411
- process.stdout.write(`External ownership: ${reported.managed} managed session${reported.managed === 1 ? '' : 's'}\n`);
1412
+ logInfo(`External ownership: ${reported.managed} managed session${reported.managed === 1 ? '' : 's'}\n`);
1412
1413
  }
1413
1414
  }
1414
1415
  catch (error) {
1415
- process.stderr.write(`External ownership watcher failed: ${error?.message || error}\n`);
1416
+ logError(`External ownership watcher failed: ${error?.message || error}\n`);
1416
1417
  }
1417
1418
  await wait(NATIVE_WATCH_INTERVAL_MS);
1418
1419
  }
@@ -1456,9 +1457,9 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1456
1457
  const byHarness = new Map();
1457
1458
  for (const session of initialSessions)
1458
1459
  byHarness.set(session.harness, (byHarness.get(session.harness) || 0) + 1);
1459
- process.stdout.write(`Native inventory: ${initialSessions.length} sessions across ${byHarness.size} harnesses\n`);
1460
+ logInfo(`Native inventory: ${initialSessions.length} sessions across ${byHarness.size} harnesses\n`);
1460
1461
  for (const [harness, count] of [...byHarness].sort())
1461
- process.stdout.write(` ${harness}: ${count}\n`);
1462
+ logInfo(` ${harness}: ${count}\n`);
1462
1463
  if (options.dryRun)
1463
1464
  return;
1464
1465
  const state = createNativeUploadState(options.nativeStateFile, machine);
@@ -1586,7 +1587,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1586
1587
  stableSessionId: stableNativeSessionId,
1587
1588
  redactor,
1588
1589
  }).catch((error) => {
1589
- process.stderr.write(`Queue worker stopped unexpectedly: ${error?.stack || error}\n`);
1590
+ logError(`Queue worker stopped unexpectedly: ${error?.stack || error}\n`);
1590
1591
  });
1591
1592
  }
1592
1593
  const totals = { current: 0, uploaded: 0, rows: 0, empty: 0, failed: 0 };
@@ -1623,7 +1624,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1623
1624
  const message = error?.message || String(error);
1624
1625
  if (message !== uploadProgressError) {
1625
1626
  uploadProgressError = message;
1626
- process.stderr.write(`Upload progress report failed: ${message}\n`);
1627
+ logError(`Upload progress report failed: ${message}\n`);
1627
1628
  }
1628
1629
  }
1629
1630
  };
@@ -1685,7 +1686,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1685
1686
  cursor = {};
1686
1687
  cursors.set(sessionId, cursor);
1687
1688
  seen.delete(sessionId);
1688
- process.stdout.write(` ${descriptor.harness} ${descriptor.sourcePath}: reset for ${redactionUpgrade ? 'secret-filtered' : 'full'} replay\n`);
1689
+ logInfo(` ${descriptor.harness} ${descriptor.sourcePath}: reset for ${redactionUpgrade ? 'secret-filtered' : 'full'} replay\n`);
1689
1690
  }
1690
1691
  result = await uploadNativeSession(rpc, machine, descriptor, cursor, (progress) => {
1691
1692
  // Persist an explicitly incomplete fingerprint while a long replay
@@ -1693,7 +1694,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1693
1694
  // but the empty fingerprint still schedules the unfinished tail.
1694
1695
  state.set(sessionId, '', cursor, REDACTION_VERSION);
1695
1696
  scheduleUploadProgress();
1696
- process.stdout.write(` ${index + 1}/${changed.length} ${descriptor.harness}: ` +
1697
+ logInfo(` ${index + 1}/${changed.length} ${descriptor.harness}: ` +
1697
1698
  `${progress.nextRow}/${progress.total} rows` +
1698
1699
  `${progress.archived ? ' · archived' : ''}\n`);
1699
1700
  }, redactor, {
@@ -1717,7 +1718,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1717
1718
  failure = error;
1718
1719
  break;
1719
1720
  }
1720
- process.stderr.write(` ${index + 1}/${changed.length} ${descriptor.harness}: ${error.message}; ` +
1721
+ logError(` ${index + 1}/${changed.length} ${descriptor.harness}: ${error.message}; ` +
1721
1722
  `reconnecting (${attempt + 1}/${MAX_SOCKET_RETRIES})\n`);
1722
1723
  rpc.close();
1723
1724
  await wait(reconnectDelay(attempt));
@@ -1734,7 +1735,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1734
1735
  failures.delete(sessionId);
1735
1736
  state.tombstone(sessionId);
1736
1737
  totals.current += 1;
1737
- process.stdout.write(` ${index + 1}/${changed.length} ${descriptor.harness}: deleted on turns.dev\n`);
1738
+ logInfo(` ${index + 1}/${changed.length} ${descriptor.harness}: deleted on turns.dev\n`);
1738
1739
  scheduleUploadProgress();
1739
1740
  continue;
1740
1741
  }
@@ -1759,7 +1760,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1759
1760
  totals.rows += result.sent;
1760
1761
  }
1761
1762
  if (!result.skipped || (index + 1) % 50 === 0 || index + 1 === changed.length) {
1762
- process.stdout.write(` ${index + 1}/${changed.length} ${descriptor.harness} ${descriptor.sourcePath}: ` +
1763
+ logInfo(` ${index + 1}/${changed.length} ${descriptor.harness} ${descriptor.sourcePath}: ` +
1763
1764
  `${result.empty ? 'no conversation rows' : result.skipped ? `current (${result.rows} rows)` : `${result.sent} rows uploaded`}\n`);
1764
1765
  }
1765
1766
  scheduleUploadProgress();
@@ -1774,7 +1775,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1774
1775
  attempts,
1775
1776
  retryAt: Date.now() + nativeFailureRetryDelay(attempts),
1776
1777
  });
1777
- process.stderr.write(` ${index + 1}/${changed.length} ${descriptor.harness} ${descriptor.sourcePath}: ${error?.message || error}\n`);
1778
+ logError(` ${index + 1}/${changed.length} ${descriptor.harness} ${descriptor.sourcePath}: ${error?.message || error}\n`);
1778
1779
  scheduleUploadProgress();
1779
1780
  }
1780
1781
  }
@@ -1800,7 +1801,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1800
1801
  rememberInventory(sessions);
1801
1802
  const unsettled = changedSessions();
1802
1803
  if (unsettled.length) {
1803
- process.stderr.write(`${unsettled.length} native sessions kept changing; run \`turns\` without --once to follow them continuously.\n`);
1804
+ logError(`${unsettled.length} native sessions kept changing; run \`turns\` without --once to follow them continuously.\n`);
1804
1805
  }
1805
1806
  break;
1806
1807
  }
@@ -1816,9 +1817,9 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1816
1817
  await reportUploadProgress(true);
1817
1818
  rpc.close();
1818
1819
  totals.failed = failures.size;
1819
- process.stdout.write(`Native upload complete: ${JSON.stringify(totals)}\n`);
1820
+ logInfo(`Native upload complete: ${JSON.stringify(totals)}\n`);
1820
1821
  if (failures.size) {
1821
- process.stderr.write(`${JSON.stringify({ failures: [...failures.values()] }, null, 2)}\n`);
1822
+ logError(`${JSON.stringify({ failures: [...failures.values()] }, null, 2)}\n`);
1822
1823
  process.exitCode = 1;
1823
1824
  }
1824
1825
  return;
@@ -1962,7 +1963,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1962
1963
  shutdown();
1963
1964
  process.exit(0);
1964
1965
  });
1965
- process.stdout.write(`Watching ${initialSessions.length} native sessions via ${nativeMonitor.count} directories; ` +
1966
+ logInfo(`Watching ${initialSessions.length} native sessions via ${nativeMonitor.count} directories; ` +
1966
1967
  `polling known files every ${NATIVE_POLL_INTERVAL_MS / 1000}s and auditing every ` +
1967
1968
  `${NATIVE_DISCOVERY_INTERVAL_MS / 1000}s…\n`);
1968
1969
  let historicalSessionId = '';
@@ -1979,7 +1980,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1979
1980
  scheduleUploadProgress();
1980
1981
  }
1981
1982
  catch (error) {
1982
- process.stderr.write(`Native inventory refresh failed: ${error?.message || error}\n`);
1983
+ logError(`Native inventory refresh failed: ${error?.message || error}\n`);
1983
1984
  }
1984
1985
  }
1985
1986
  const changed = changedSessions();
@@ -2064,7 +2065,7 @@ export async function runUploader(argv = process.argv.slice(2), { credentialProv
2064
2065
  const result = await uploadFile(rpc, machine, options.runs, capture, filtering.redactor);
2065
2066
  seen.set(capture.file, fingerprint);
2066
2067
  if (result.skipped)
2067
- process.stdout.write(` ${capture.relative}: current (${result.rows} rows)\n`);
2068
+ logInfo(` ${capture.relative}: current (${result.rows} rows)\n`);
2068
2069
  }
2069
2070
  };
2070
2071
  await sweep();
@@ -2072,7 +2073,7 @@ export async function runUploader(argv = process.argv.slice(2), { credentialProv
2072
2073
  rpc.close();
2073
2074
  return;
2074
2075
  }
2075
- process.stdout.write('Watching for appended capture rows…\n');
2076
+ logInfo('Watching for appended capture rows…\n');
2076
2077
  for (;;) {
2077
2078
  await new Promise((resolve) => setTimeout(resolve, 2_000));
2078
2079
  await sweep();
@@ -2085,7 +2086,7 @@ export async function runUploader(argv = process.argv.slice(2), { credentialProv
2085
2086
  }
2086
2087
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
2087
2088
  runUploader().catch((error) => {
2088
- process.stderr.write(`${error?.stack || error}\n`);
2089
+ logError(`${error?.stack || error}\n`);
2089
2090
  process.exitCode = 1;
2090
2091
  });
2091
2092
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",