@turns/dev 0.0.21 → 0.0.23

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.
@@ -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);
@@ -559,21 +559,25 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
559
559
  }
560
560
  bindAnchor(connection, snapshot);
561
561
  }
562
- const usableAnchor = async (sessionId, source, agentSessionId) => {
562
+ const usableAnchor = async (sessionId, source, agentSessionId, modelSettings = false) => {
563
563
  let entry = anchors.get(sessionId);
564
564
  if (entry && !entry.connection.closed) {
565
565
  entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
566
566
  if (String(entry.snapshot.source || '') === source &&
567
- String(entry.snapshot.agentSessionId || '') === agentSessionId)
567
+ String(entry.snapshot.agentSessionId || '') === agentSessionId &&
568
+ (!modelSettings || entry.snapshot.capabilities?.includes('model_settings')))
568
569
  return entry;
569
570
  if (entry.snapshot.status === 'running')
570
- throw new Error('The surviving anchor is running a different session identity.');
571
+ throw new Error('The surviving anchor must finish its turn before it can be replaced.');
571
572
  await entry.connection.request('shutdown', {}, 2_000).catch(() => { });
572
573
  entry.connection.close();
573
574
  anchors.delete(sessionId);
574
575
  }
575
576
  const connection = await launchAgentAnchor(sessionId);
576
577
  entry = bindAnchor(connection, connection.snapshot || {}, sessionId);
578
+ if (modelSettings && !entry.snapshot.capabilities?.includes('model_settings')) {
579
+ throw new Error('The installed agent anchor does not support session model settings. Update the Turns CLI.');
580
+ }
577
581
  await reportHarness(sessionId, 'idle', entry);
578
582
  return entry;
579
583
  };
@@ -684,7 +688,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
684
688
  // Codex installation leaves a recoverable unsent prompt in the queue.
685
689
  if (descriptor.harness === 'codex')
686
690
  resolveHarnessCommand('codex');
687
- entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId);
691
+ entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId, Boolean(claim.session?.modelSettings));
688
692
  }
689
693
  if (!entry)
690
694
  throw new Error(`No surviving agent anchor exists for ${sessionId}.`);
@@ -1399,6 +1403,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1399
1403
  machineId: machine,
1400
1404
  machineHostname: os.hostname(),
1401
1405
  capabilities: {
1406
+ sessionModelSettings: true,
1402
1407
  fileSearch: true,
1403
1408
  git: true,
1404
1409
  fileRanges: true,
@@ -24,6 +24,11 @@ const MAX_BATCH_ROWS = 100;
24
24
  const MAX_RUNTIME_EVENT_BATCH = 250;
25
25
  const RPC_TIMEOUT_MS = 5 * 60_000;
26
26
  const MAX_SOCKET_RETRIES = 8;
27
+ /** Deadline for an uploader socket to finish its TCP, TLS, and upgrade
28
+ * handshake and receive the server's `ready` acknowledgement. A stalled
29
+ * handshake emits neither `error` nor `close`, so without this the daemon
30
+ * would wait in CONNECTING forever and silently stop uploading. */
31
+ const READY_TIMEOUT_MS = 30_000;
27
32
  const MAX_NATIVE_SETTLE_PASSES = 4;
28
33
  const MAX_EXTERNAL_STATUS_BATCH = 1_000;
29
34
  const NATIVE_STATE_VERSION = 1;
@@ -548,28 +553,43 @@ export class RpcSocket {
548
553
  resolveClosed;
549
554
  ready;
550
555
  socket;
551
- constructor(url, token, devUser = '') {
556
+ constructor(url, token, devUser = '', { readyTimeoutMs = READY_TIMEOUT_MS } = {}) {
552
557
  this.pending = new Map();
553
558
  this.listeners = new Set();
554
559
  this.closed = new Promise((resolve) => {
555
560
  this.resolveClosed = resolve;
556
561
  });
557
562
  this.ready = new Promise((resolve, reject) => {
563
+ // Settle exactly once so the deadline, the socket's own events, and the
564
+ // abort error that `terminate()` raises afterwards cannot race.
565
+ let settled = false;
566
+ let readyTimer = null;
567
+ const settle = (complete, value) => {
568
+ if (settled)
569
+ return;
570
+ settled = true;
571
+ clearTimeout(readyTimer);
572
+ complete(value);
573
+ };
574
+ readyTimer = setTimeout(() => {
575
+ settle(reject, socketError(`Turns did not acknowledge the uploader within ${readyTimeoutMs / 1000} seconds.`));
576
+ this.socket.terminate();
577
+ }, readyTimeoutMs);
558
578
  const headers = devUser
559
579
  ? { 'x-turns-dev-user': devUser, 'x-turns-dev-role': 'uploader' }
560
580
  : { Authorization: `Bearer ${token}` };
561
- this.socket = new WebSocket(url, { headers });
581
+ this.socket = new WebSocket(url, { headers, handshakeTimeout: readyTimeoutMs });
562
582
  this.socket.once('unexpected-response', (_request, response) => {
563
583
  response.resume?.();
564
584
  if (response.statusCode === 401 || response.statusCode === 403) {
565
- reject(authenticationError(`Turns rejected uploader authentication (HTTP ${response.statusCode}).`));
585
+ settle(reject, authenticationError(`Turns rejected uploader authentication (HTTP ${response.statusCode}).`));
566
586
  }
567
587
  else {
568
- reject(socketError(`Unexpected WebSocket response (HTTP ${response.statusCode}).`));
588
+ settle(reject, socketError(`Unexpected WebSocket response (HTTP ${response.statusCode}).`));
569
589
  }
570
590
  });
571
- this.socket.once('error', (error) => reject(socketError(`WebSocket connection failed: ${error.message}`, error)));
572
- this.socket.on('message', (data) => this.onMessage(String(data), resolve, reject));
591
+ this.socket.once('error', (error) => settle(reject, socketError(`WebSocket connection failed: ${error.message}`, error)));
592
+ this.socket.on('message', (data) => this.onMessage(String(data), (ready) => settle(resolve, ready), (error) => settle(reject, error)));
573
593
  this.socket.on('close', (code, reason) => {
574
594
  const error = socketError(`WebSocket closed (${code}): ${String(reason)}`);
575
595
  for (const request of this.pending.values()) {
@@ -665,7 +685,9 @@ export async function connectUploader(options, machine, reconnect = false) {
665
685
  const credentials = await options.credentialProvider.refreshIfDue();
666
686
  options.token = credentials.accessToken;
667
687
  }
668
- const rpc = new RpcSocket(options.url, options.token, options.devUser);
688
+ const rpc = new RpcSocket(options.url, options.token, options.devUser, {
689
+ readyTimeoutMs: options.readyTimeoutMs,
690
+ });
669
691
  try {
670
692
  const ready = await rpc.ready;
671
693
  process.stdout.write(`${reconnect ? 'Reconnected' : 'Connected'} to ${options.url} as @${ready.user.login} ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",