@skrr-ai/cli 0.1.77 → 0.1.79

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.
@@ -26,6 +26,8 @@ class DedicatedMachinesCp extends base_command_1.BaseCommand {
26
26
  'machine is created.',
27
27
  'As with cp and scp, a destination that ends in / or is an existing directory receives the file ' +
28
28
  'under its source name.',
29
+ 'Files marked executable keep owner execute permission on the copy. Other permissions use ' +
30
+ 'destination defaults; source special permission bits are not copied.',
29
31
  'Neither side is left half-written: the file is staged beside its destination and renamed into ' +
30
32
  'place at the end. A file that changes while it is copied is refused, not spliced. Ctrl-C removes ' +
31
33
  'the partial copy and exits 130 (143 for SIGTERM, 129 when the terminal closes). Progress is ' +
@@ -220,7 +220,7 @@ async function uploadFile(localPath, leaseId, remotePath, scope, io) {
220
220
  dataBase64: buffer.subarray(0, bytesRead).toString('base64'),
221
221
  // A file that GREW is caught here: the machine refuses to commit
222
222
  // when what it staged is not the size declared at the start.
223
- ...(final ? { final: true, totalSize: info.size } : {}),
223
+ ...(final ? { final: true, totalSize: info.size, executable: (info.mode & 0o111) !== 0 } : {}),
224
224
  }, scope), signal);
225
225
  offset += bytesRead;
226
226
  onProgress?.(offset, info.size);
@@ -276,6 +276,7 @@ async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scop
276
276
  let offset = 0;
277
277
  let size = -1;
278
278
  let mtimeMs = -1;
279
+ let executable;
279
280
  try {
280
281
  for (;;) {
281
282
  throwIfCancelled(signal);
@@ -285,11 +286,17 @@ async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scop
285
286
  if (size === -1) {
286
287
  size = chunk.size;
287
288
  mtimeMs = chunk.mtimeMs;
289
+ executable = chunk.executable;
288
290
  }
289
- else if (chunk.size !== size || chunk.mtimeMs !== mtimeMs) {
291
+ else if (chunk.size !== size || chunk.mtimeMs !== mtimeMs ||
292
+ (typeof chunk.executable === 'boolean' && typeof executable === 'boolean' &&
293
+ chunk.executable !== executable)) {
290
294
  // Chunks from two versions of a file splice into one that never existed.
291
295
  throw changedWhileCopying(remotePath, 'on the machine');
292
296
  }
297
+ // Older API replicas omit this additive metadata during a rollout.
298
+ if (typeof chunk.executable === 'boolean')
299
+ executable = chunk.executable;
293
300
  const bytes = Buffer.from(chunk.dataBase64, 'base64');
294
301
  if (bytes.byteLength !== chunk.bytesRead) {
295
302
  throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_TRANSFER_FAILED', 'A chunk arrived damaged; nothing was written to the destination.');
@@ -304,6 +311,11 @@ async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scop
304
311
  throw changedWhileCopying(remotePath, 'on the machine');
305
312
  }
306
313
  }
314
+ // Set only the owner execute bit on the pinned staging inode. Other
315
+ // permissions keep destination defaults; source special bits never travel.
316
+ if (executable === true) {
317
+ await handle.chmod(((await handle.stat()).mode & 0o777) | 0o100);
318
+ }
307
319
  await handle.close();
308
320
  closed = true;
309
321
  await (0, promises_1.rename)(temporary, target);
@@ -129,6 +129,7 @@ class DedicatedLeaseCommand extends base_command_1.BaseCommand {
129
129
  ...(flags.cwd ? { cwd: flags.cwd } : {}),
130
130
  ...(initialCommand ? { initialCommand } : {}),
131
131
  persistent,
132
+ resumeExistingSupported: capabilities.includes('persistent_terminal_resume_v1'),
132
133
  ...(sessionName ? { sessionName } : {}),
133
134
  leaseId,
134
135
  input: process.stdin,
@@ -466,6 +466,7 @@ export interface DedicatedFileChunk {
466
466
  path: string;
467
467
  size: number;
468
468
  mtimeMs: number;
469
+ executable?: boolean;
469
470
  offset: number;
470
471
  bytesRead: number;
471
472
  dataBase64: string;
@@ -491,6 +492,7 @@ export declare function writeDedicatedRuntimeFileChunk(leaseId: string, input: {
491
492
  final?: boolean;
492
493
  abort?: boolean;
493
494
  totalSize?: number;
495
+ executable?: boolean;
494
496
  }, scope?: DedicatedRuntimeScope): Promise<DedicatedFileWriteResult>;
495
497
  export type DedicatedCopyPlan = {
496
498
  direction: 'upload';
@@ -112,6 +112,7 @@ export interface SshStartAck {
112
112
  type: string;
113
113
  key: string;
114
114
  }[];
115
+ receiverAckV1?: boolean;
115
116
  }
116
117
  export interface SshProxyOptions {
117
118
  baseURL: string;
@@ -343,6 +343,7 @@ function runSshProxy(options) {
343
343
  channelId,
344
344
  keyType: identity.keyType,
345
345
  keyBlob: identity.keyBlob,
346
+ receiverAckV1: true,
346
347
  });
347
348
  });
348
349
  socket.on('dedicated:ssh:start:ack', (ack) => {
@@ -366,11 +367,23 @@ function runSshProxy(options) {
366
367
  input.resume();
367
368
  input.on('data', onInput);
368
369
  });
369
- socket.on('dedicated:ssh:data', (frame) => {
370
+ socket.on('dedicated:ssh:data', (frame, receipt) => {
370
371
  if (!frame || frame.channelId !== channelId || finished)
371
372
  return;
372
373
  if (typeof frame.data === 'string' && frame.data.length > 0) {
373
- output.write(Buffer.from(frame.data, 'base64'));
374
+ const bytes = Buffer.from(frame.data, 'base64');
375
+ if (typeof receipt === 'function') {
376
+ // A frame can precede start-ack (sshd's banner). The owned channel
377
+ // already advertised support; confirm only when stdout flushed.
378
+ output.write(bytes, (error) => {
379
+ receipt({ channelId, ok: !error && !finished });
380
+ if (error)
381
+ finish(1, 'Could not write SSH output.');
382
+ });
383
+ }
384
+ else {
385
+ output.write(bytes);
386
+ }
374
387
  }
375
388
  });
376
389
  socket.on('dedicated:ssh:closed', (frame) => {
@@ -27,6 +27,7 @@ export interface TerminalOutputFrame {
27
27
  encoding?: 'utf8' | 'base64';
28
28
  exitCode?: number | null;
29
29
  signal?: string | null;
30
+ code?: string;
30
31
  }
31
32
  export interface TerminalStartAck {
32
33
  sessionId?: string;
@@ -66,6 +67,8 @@ export interface DedicatedTerminalOptions {
66
67
  * capability before asking.
67
68
  */
68
69
  persistent?: boolean;
70
+ /** Capability-gated recovery can attach only an existing named shell. */
71
+ resumeExistingSupported?: boolean;
69
72
  /**
70
73
  * The session suffix (`skrr-term-<name>`). The same name re-attaches the
71
74
  * same session; omitted under `persistent`, the machine names the session
@@ -67,6 +67,9 @@ function describeTerminalStartRefusal(ack) {
67
67
  if (code === 'DEDICATED_RUNTIME_GENERATION_STALE') {
68
68
  return 'The machine was replaced since its daemon registered. Wait a minute and try again.';
69
69
  }
70
+ if (code === 'persistent-session-missing') {
71
+ return 'The original terminal no longer exists. Start a new terminal explicitly; no replacement shell was opened.';
72
+ }
70
73
  if (code === 'not_found' || code === 'offline') {
71
74
  return 'The machine’s daemon is not connected. Wait for it to come online and try again.';
72
75
  }
@@ -94,6 +97,9 @@ function runDedicatedTerminal(options) {
94
97
  let sessionReady = false;
95
98
  let reattaching = false;
96
99
  let persistentName = options.sessionName;
100
+ let retryTimer;
101
+ let recoveryTimer;
102
+ let connected = false;
97
103
  const socket = connect((0, agentic_stream_1.toWebSocketOrigin)(options.baseURL), {
98
104
  path: '/ws/agentic',
99
105
  auth: {
@@ -170,6 +176,10 @@ function runDedicatedTerminal(options) {
170
176
  const onSigterm = () => finish(143, true);
171
177
  const onSighup = () => finish(129, true);
172
178
  const onStartTimeout = () => {
179
+ if (reattaching) {
180
+ scheduleRetry();
181
+ return;
182
+ }
173
183
  fail(new DedicatedTerminalError('The machine did not answer the terminal start in time. Its daemon may be busy or offline.', 'TERMINAL_START_TIMEOUT'));
174
184
  };
175
185
  let startTimer = setTimeout(onStartTimeout, exports.TERMINAL_START_TIMEOUT_MS);
@@ -190,6 +200,8 @@ function runDedicatedTerminal(options) {
190
200
  return;
191
201
  finished = true;
192
202
  clearTimeout(startTimer);
203
+ clearTimeout(retryTimer);
204
+ clearTimeout(recoveryTimer);
193
205
  pending?.close();
194
206
  restoreTty();
195
207
  detachReconnectFailed();
@@ -203,6 +215,8 @@ function runDedicatedTerminal(options) {
203
215
  return;
204
216
  finished = true;
205
217
  clearTimeout(startTimer);
218
+ clearTimeout(retryTimer);
219
+ clearTimeout(recoveryTimer);
206
220
  pending?.close();
207
221
  restoreTty();
208
222
  detachReconnectFailed();
@@ -212,15 +226,19 @@ function runDedicatedTerminal(options) {
212
226
  socket.on('connect_error', (err) => {
213
227
  // A failed retry while reattaching is not fatal — the attempt countdown
214
228
  // keeps running and `reconnect_failed` ends it if they all miss.
215
- if (persistent && reattaching)
229
+ if (persistent && reattaching && !err.data?.code)
216
230
  return;
217
231
  fail(new DedicatedTerminalError(`Could not connect to skrr: ${err.message}`, err.data?.code || 'TERMINAL_CONNECT_FAILED'));
218
232
  });
219
- socket.on('connect', () => {
220
- if (finished)
233
+ function sendStart() {
234
+ if (finished || !connected)
221
235
  return;
236
+ // Clear the previous relay entry too; never replay bytes sent before loss.
237
+ if (sessionId)
238
+ socket.emit(events.stop, { sessionId });
222
239
  sessionId = (0, node_crypto_1.randomUUID)();
223
240
  clearTimeout(startTimer);
241
+ clearTimeout(retryTimer);
224
242
  startTimer = setTimeout(onStartTimeout, exports.TERMINAL_START_TIMEOUT_MS);
225
243
  startTimer.unref();
226
244
  socket.emit(events.start, {
@@ -228,13 +246,44 @@ function runDedicatedTerminal(options) {
228
246
  sessionId,
229
247
  ...(options.cwd ? { cwd: options.cwd } : {}),
230
248
  ...(persistent ? { persistent: true } : {}),
231
- // The RESOLVED name, not just the requested one: a `--new` session the
232
- // machine named itself comes back in the ack, and a re-attach must
233
- // name it — `tmux -A` without `-s` would open a fresh session instead.
249
+ ...(reattaching ? { resumeExisting: true } : {}),
234
250
  ...(persistentName ? { sessionName: persistentName } : {}),
235
251
  ...(options.leaseId ? { leaseId: options.leaseId } : {}),
236
252
  ...size(),
237
253
  });
254
+ }
255
+ function scheduleRetry() {
256
+ clearTimeout(startTimer);
257
+ clearTimeout(retryTimer);
258
+ if (finished || !reattaching)
259
+ return;
260
+ retryTimer = setTimeout(sendStart, 2_000);
261
+ retryTimer.unref();
262
+ }
263
+ function recover() {
264
+ if (finished || reattaching)
265
+ return;
266
+ started = false;
267
+ pending?.close(); // The sign-in command is never typed a second time.
268
+ clearTimeout(startTimer);
269
+ if (!persistent || !persistentName || !options.resumeExistingSupported) {
270
+ errorOutput.write(`\r\n[terminal attachment lost; rerun the same command to reattach${persistentName ? ` to '${persistentName}'` : ''}]\r\n`);
271
+ finish(1, false);
272
+ return;
273
+ }
274
+ reattaching = true;
275
+ errorOutput.write(`\r\n[lost the terminal attachment — reattaching to session '${persistentName}'…]\r\n`);
276
+ recoveryTimer = setTimeout(() => {
277
+ errorOutput.write(`\r\n[could not reattach within 60 seconds; rerun the same command for session '${persistentName}']\r\n`);
278
+ finish(1, false);
279
+ }, 60_000);
280
+ recoveryTimer.unref();
281
+ if (connected)
282
+ sendStart();
283
+ }
284
+ socket.on('connect', () => {
285
+ connected = true;
286
+ sendStart();
238
287
  });
239
288
  socket.on(events.startAck, (ack) => {
240
289
  if (!ack || ack.sessionId !== sessionId || started || finished)
@@ -242,6 +291,12 @@ function runDedicatedTerminal(options) {
242
291
  if (ack.daemonId && ack.daemonId !== options.daemonId)
243
292
  return;
244
293
  if (!ack.ok) {
294
+ // Only daemon availability refusals are retryable. Authorization,
295
+ // generation fencing and a missing original shell end recovery.
296
+ if (reattaching && (ack.code === 'offline' || ack.code === 'not_found')) {
297
+ scheduleRetry();
298
+ return;
299
+ }
245
300
  fail(new DedicatedTerminalError(describeTerminalStartRefusal(ack), ack.code || 'TERMINAL_START_REFUSED'));
246
301
  return;
247
302
  }
@@ -249,6 +304,8 @@ function runDedicatedTerminal(options) {
249
304
  started = true;
250
305
  reattaching = false;
251
306
  clearTimeout(startTimer);
307
+ clearTimeout(retryTimer);
308
+ clearTimeout(recoveryTimer);
252
309
  if (typeof ack.persistentName === 'string' && ack.persistentName) {
253
310
  persistentName = ack.persistentName;
254
311
  }
@@ -278,8 +335,13 @@ function runDedicatedTerminal(options) {
278
335
  return;
279
336
  if (frame.daemonId && frame.daemonId !== options.daemonId)
280
337
  return;
338
+ if (frame.code === 'daemon-disconnected' ||
339
+ (frame.type === 'error' && (frame.code === 'not-found' || frame.code === 'closed'))) {
340
+ recover();
341
+ return;
342
+ }
281
343
  if (frame.type === 'exit') {
282
- finish(typeof frame.exitCode === 'number' ? frame.exitCode : 0, false);
344
+ finish(typeof frame.exitCode === 'number' ? frame.exitCode : 1, false);
283
345
  return;
284
346
  }
285
347
  if (frame.type === 'error' || frame.type === 'info') {
@@ -318,17 +380,11 @@ function runDedicatedTerminal(options) {
318
380
  }
319
381
  }
320
382
  socket.on('disconnect', () => {
383
+ connected = false;
321
384
  if (finished)
322
385
  return;
323
386
  if (persistent) {
324
- // The tmux session outlives the link — the tmux client's death only
325
- // detaches. socket.io retries and the next start re-attaches by name.
326
- started = false;
327
- if (!reattaching) {
328
- reattaching = true;
329
- const named = persistentName ? ` '${persistentName}'` : '';
330
- errorOutput.write(`\r\n[lost the connection to skrr — reattaching to session${named}…]\r\n`);
331
- }
387
+ recover();
332
388
  return;
333
389
  }
334
390
  if (!started) {