@skrr-ai/cli 0.1.48 → 0.1.50

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.
Files changed (117) hide show
  1. package/README.md +5 -4
  2. package/dist/base-command.js +16 -4
  3. package/dist/commands/agent-worker.d.ts +12 -0
  4. package/dist/commands/agent-worker.js +140 -0
  5. package/dist/commands/agents/create.js +14 -7
  6. package/dist/commands/agents/update.js +11 -5
  7. package/dist/commands/code/index.d.ts +1 -0
  8. package/dist/commands/code/index.js +49 -5
  9. package/dist/commands/code/run.js +5 -1
  10. package/dist/commands/commitments/cycles.d.ts +19 -0
  11. package/dist/commands/commitments/cycles.js +46 -0
  12. package/dist/commands/commitments/effective-policy.js +26 -1
  13. package/dist/commands/commitments/explain.d.ts +17 -0
  14. package/dist/commands/commitments/explain.js +40 -0
  15. package/dist/commands/goals/create.d.ts +1 -0
  16. package/dist/commands/goals/create.js +22 -0
  17. package/dist/commands/logout.js +12 -1
  18. package/dist/commands/machines/dedicated/index.js +1 -1
  19. package/dist/commands/machines/dedicated/sign-in.js +4 -1
  20. package/dist/commands/machines/dedicated/terminal/kill.d.ts +21 -0
  21. package/dist/commands/machines/dedicated/terminal/kill.js +55 -0
  22. package/dist/commands/machines/dedicated/terminal/ls.d.ts +22 -0
  23. package/dist/commands/machines/dedicated/terminal/ls.js +70 -0
  24. package/dist/commands/machines/dedicated/terminal/rename.d.ts +23 -0
  25. package/dist/commands/machines/dedicated/terminal/rename.js +64 -0
  26. package/dist/commands/machines/dedicated/terminal.d.ts +9 -0
  27. package/dist/commands/machines/dedicated/terminal.js +27 -2
  28. package/dist/commands/machines/hosted/connect.js +2 -2
  29. package/dist/commands/machines/hosted/destroy.js +1 -1
  30. package/dist/commands/machines/hosted/exec.js +1 -1
  31. package/dist/commands/machines/hosted/list.js +1 -1
  32. package/dist/commands/machines/hosted/pause.js +1 -1
  33. package/dist/commands/machines/hosted/pull.js +1 -1
  34. package/dist/commands/machines/hosted/resume.js +1 -1
  35. package/dist/commands/machines/hosted/start.js +2 -2
  36. package/dist/commands/machines/hosted/status.js +2 -1
  37. package/dist/commands/tasks/create.js +1 -0
  38. package/dist/commands/tasks/list.js +1 -0
  39. package/dist/commands/tasks/update.js +1 -0
  40. package/dist/lib/agent-config.d.ts +2 -0
  41. package/dist/lib/agent-config.js +3 -1
  42. package/dist/lib/api-fetch.js +19 -0
  43. package/dist/lib/auth-storage.d.ts +14 -0
  44. package/dist/lib/auth-storage.js +14 -0
  45. package/dist/lib/commitments.d.ts +17 -2
  46. package/dist/lib/commitments.js +114 -0
  47. package/dist/lib/daemon-installer.d.ts +6 -5
  48. package/dist/lib/daemon-installer.js +4 -17
  49. package/dist/lib/daemonBroker.d.ts +120 -31
  50. package/dist/lib/daemonBroker.js +313 -20
  51. package/dist/lib/dedicated-lease-command.d.ts +14 -0
  52. package/dist/lib/dedicated-lease-command.js +30 -1
  53. package/dist/lib/dedicated-machines.js +8 -25
  54. package/dist/lib/dedicated-service-command.d.ts +11 -3
  55. package/dist/lib/dedicated-service-command.js +20 -3
  56. package/dist/lib/dedicated-service.d.ts +46 -0
  57. package/dist/lib/dedicated-service.js +85 -7
  58. package/dist/lib/dedicated-terminal.d.ts +21 -0
  59. package/dist/lib/dedicated-terminal.js +104 -9
  60. package/dist/lib/first-party-harness-broker.d.ts +10 -0
  61. package/dist/lib/first-party-harness-broker.js +9 -0
  62. package/dist/lib/first-party-harness-doctor.js +41 -1
  63. package/dist/lib/first-party-harness-managed.d.ts +4 -3
  64. package/dist/lib/first-party-harness-project-trust.d.ts +125 -0
  65. package/dist/lib/first-party-harness-project-trust.js +364 -0
  66. package/dist/lib/first-party-harness.d.ts +9 -2
  67. package/dist/lib/first-party-harness.js +6 -5
  68. package/dist/lib/hosted-machines.d.ts +10 -1
  69. package/dist/lib/hosted-machines.js +36 -2
  70. package/dist/lib/login.js +16 -0
  71. package/dist/lib/machine-spend-cap.d.ts +23 -0
  72. package/dist/lib/machine-spend-cap.js +68 -0
  73. package/dist/lib/node-adapter.js +15 -3
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +90 -0
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +113 -0
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.d.ts +47 -0
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.js +69 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.d.ts +109 -0
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.js +171 -0
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarness.d.ts +1 -1
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarness.js +1 -1
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -1
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +4 -2
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.d.ts +8 -3
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.js +9 -4
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +8 -9
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refreshClassification.d.ts +6 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refreshClassification.js +32 -9
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseManifest.d.ts +38 -0
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseManifest.js +66 -1
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/sessionPermissionAuthority.d.ts +81 -0
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/sessionPermissionAuthority.js +87 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/types.d.ts +6 -1
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +90 -0
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +105 -0
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.d.ts +47 -0
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.js +66 -0
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.d.ts +109 -0
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.js +164 -0
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarness.d.ts +1 -1
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarness.js +1 -1
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.d.ts +8 -3
  105. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.js +9 -4
  106. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +8 -9
  107. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refreshClassification.d.ts +6 -0
  108. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refreshClassification.js +31 -9
  109. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseManifest.d.ts +38 -0
  110. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseManifest.js +64 -0
  111. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/sessionPermissionAuthority.d.ts +81 -0
  112. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/sessionPermissionAuthority.js +80 -0
  113. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/types.d.ts +6 -1
  114. package/dist/node_modules/@skrr-ai/auth-core/package.json +41 -1
  115. package/dist/node_modules/@skrr-ai/data-provider/index.js +3413 -3343
  116. package/oclif.manifest.json +8930 -8466
  117. package/package.json +4 -1
@@ -43,6 +43,31 @@ export declare const DEDICATED_SERVICE_EVENTS: {
43
43
  readonly logClosed: "dedicated:service:log:closed";
44
44
  readonly proposed: "dedicated:service:proposed";
45
45
  };
46
+ /**
47
+ * Persistent-terminal management (always-on §6.4) — the sibling lane on the
48
+ * same admitted socket. A session is a tmux `skrr-term-<name>` the machine
49
+ * keeps alive across attaches; `terminal` opens one over the ordinary
50
+ * terminal start lane, and these verbs enumerate, end and rename it.
51
+ */
52
+ export declare const DEDICATED_TERMINAL_EVENTS: {
53
+ readonly list: "dedicated:terminal:list";
54
+ readonly kill: "dedicated:terminal:kill";
55
+ readonly rename: "dedicated:terminal:rename";
56
+ readonly ack: "dedicated:terminal:ack";
57
+ };
58
+ export type TerminalVerb = 'list' | 'kill' | 'rename';
59
+ /** `skrr-term-<name>`'s suffix — the pattern the relay and daemon both enforce. */
60
+ export declare const DEDICATED_TERMINAL_NAME_PATTERN: RegExp;
61
+ /** The wire capability a live daemon must announce for persistent terminals. */
62
+ export declare const DEDICATED_PERSISTENT_TERMINAL_CAPABILITY = "persistent_terminal_v1";
63
+ /** One `dedicated:terminal:list` row — the tmux session under its suffix. */
64
+ export interface DedicatedTerminalSessionInfo {
65
+ name: string;
66
+ /** tmux `session_created` — SECONDS when numeric, not milliseconds. */
67
+ createdAt?: string | number;
68
+ attached?: boolean;
69
+ windows?: number;
70
+ }
46
71
  /** How long a verb has to answer before the machine is declared unreachable. */
47
72
  export declare const SERVICE_CALL_TIMEOUT_MS = 30000;
48
73
  /** §5.3 — the scope name `skrr-svc-<name>` derives from it. */
@@ -167,6 +192,17 @@ export declare function describeServiceRefusal(ack: Pick<DedicatedServiceAck, 'c
167
192
  verb?: string;
168
193
  name?: string;
169
194
  }): string;
195
+ /**
196
+ * A persistent-terminal refusal in the same register `describeServiceRefusal`
197
+ * gives the service lane — a named condition and the next step, never a bare
198
+ * code. `dedicated:terminal:*` acks share this channel's requestId shape, so
199
+ * the same refusal codes arrive; the words name terminal sessions, not specs.
200
+ */
201
+ export declare function describeTerminalRefusal(ack: Pick<DedicatedServiceAck, 'code' | 'error'>, context?: {
202
+ leaseId?: string;
203
+ verb?: string;
204
+ name?: string;
205
+ }): string;
170
206
  /** Validation that costs the daemon nothing to re-check — §5.3's table, enforced client-side first. */
171
207
  export declare function validateServiceSpec(doc: unknown, options?: {
172
208
  requireName?: boolean;
@@ -251,6 +287,16 @@ export declare class DedicatedServiceChannel {
251
287
  result: T | undefined;
252
288
  ack: DedicatedServiceAck;
253
289
  }>;
290
+ /**
291
+ * A persistent-terminal verb — `dedicated:terminal:list|kill|rename`,
292
+ * answered on `dedicated:terminal:ack`. Same requestId/ack discipline as
293
+ * the service verbs; the event family and the refusal's wording are the
294
+ * only differences.
295
+ */
296
+ callTerminal<T = unknown>(verb: TerminalVerb, payload: Record<string, unknown>, timeoutMs?: number): Promise<{
297
+ result: T | undefined;
298
+ ack: DedicatedServiceAck;
299
+ }>;
254
300
  /** The shared request/ack round-trip. */
255
301
  private callEvent;
256
302
  /** Subscribe to a server-pushed frame (`state`, `log`, `log:closed`, `proposed`). */
@@ -3,9 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.GUEST_SERVICE_SOCKET_PATH = exports.DedicatedServiceChannel = exports.DedicatedServiceError = exports.SERVICE_ACTIONS = exports.SERVICE_RESTART_POLICIES = exports.SERVICE_RESERVED_NAMES = exports.SERVICE_SECRET_NAME_PATTERN = exports.SERVICE_NAME_PATTERN = exports.SERVICE_CALL_TIMEOUT_MS = exports.DEDICATED_SERVICE_EVENTS = void 0;
6
+ exports.GUEST_SERVICE_SOCKET_PATH = exports.DedicatedServiceChannel = exports.DedicatedServiceError = exports.SERVICE_ACTIONS = exports.SERVICE_RESTART_POLICIES = exports.SERVICE_RESERVED_NAMES = exports.SERVICE_SECRET_NAME_PATTERN = exports.SERVICE_NAME_PATTERN = exports.SERVICE_CALL_TIMEOUT_MS = exports.DEDICATED_PERSISTENT_TERMINAL_CAPABILITY = exports.DEDICATED_TERMINAL_NAME_PATTERN = exports.DEDICATED_TERMINAL_EVENTS = exports.DEDICATED_SERVICE_EVENTS = void 0;
7
7
  exports.isServiceUnsupportedCode = isServiceUnsupportedCode;
8
8
  exports.describeServiceRefusal = describeServiceRefusal;
9
+ exports.describeTerminalRefusal = describeTerminalRefusal;
9
10
  exports.validateServiceSpec = validateServiceSpec;
10
11
  exports.parseServiceSpecDocument = parseServiceSpecDocument;
11
12
  exports.specPayload = specPayload;
@@ -72,6 +73,22 @@ exports.DEDICATED_SERVICE_EVENTS = {
72
73
  logClosed: 'dedicated:service:log:closed',
73
74
  proposed: 'dedicated:service:proposed',
74
75
  };
76
+ /**
77
+ * Persistent-terminal management (always-on §6.4) — the sibling lane on the
78
+ * same admitted socket. A session is a tmux `skrr-term-<name>` the machine
79
+ * keeps alive across attaches; `terminal` opens one over the ordinary
80
+ * terminal start lane, and these verbs enumerate, end and rename it.
81
+ */
82
+ exports.DEDICATED_TERMINAL_EVENTS = {
83
+ list: 'dedicated:terminal:list',
84
+ kill: 'dedicated:terminal:kill',
85
+ rename: 'dedicated:terminal:rename',
86
+ ack: 'dedicated:terminal:ack',
87
+ };
88
+ /** `skrr-term-<name>`'s suffix — the pattern the relay and daemon both enforce. */
89
+ exports.DEDICATED_TERMINAL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
90
+ /** The wire capability a live daemon must announce for persistent terminals. */
91
+ exports.DEDICATED_PERSISTENT_TERMINAL_CAPABILITY = 'persistent_terminal_v1';
75
92
  /** How long a verb has to answer before the machine is declared unreachable. */
76
93
  exports.SERVICE_CALL_TIMEOUT_MS = 30_000;
77
94
  /** §5.3 — the scope name `skrr-svc-<name>` derives from it. */
@@ -106,6 +123,7 @@ exports.DedicatedServiceError = DedicatedServiceError;
106
123
  function isServiceUnsupportedCode(code) {
107
124
  const c = (code || '').toLowerCase();
108
125
  return (c === 'dedicated_runtime_services_unsupported' ||
126
+ c === 'dedicated_runtime_terminal_unsupported' ||
109
127
  c === 'services_unsupported' ||
110
128
  c === 'unsupported' ||
111
129
  c === 'not_implemented' ||
@@ -152,6 +170,48 @@ function describeServiceRefusal(ack, context = {}) {
152
170
  }
153
171
  return ack.error ? `The service request failed: ${ack.error}` : 'The service request failed.';
154
172
  }
173
+ /**
174
+ * A persistent-terminal refusal in the same register `describeServiceRefusal`
175
+ * gives the service lane — a named condition and the next step, never a bare
176
+ * code. `dedicated:terminal:*` acks share this channel's requestId shape, so
177
+ * the same refusal codes arrive; the words name terminal sessions, not specs.
178
+ */
179
+ function describeTerminalRefusal(ack, context = {}) {
180
+ const code = ack.code || '';
181
+ if (isServiceUnsupportedCode(code)) {
182
+ return (ack.error ||
183
+ `This machine's image does not support persistent terminals (persistent_terminal_v1). ` +
184
+ `Update it with \`skrr machines dedicated update-image ${context.leaseId || '<lease-id>'} --wait\`; \`skrr machines dedicated terminal\` opens a plain shell in the meantime.`);
185
+ }
186
+ if (code === 'DEDICATED_RUNTIME_NOT_ASSIGNABLE') {
187
+ return ('The machine is not ready to take terminal commands (it must be running with a healthy workspace). ' +
188
+ `Check it with \`skrr machines dedicated show ${context.leaseId || '<lease-id>'}\`.`);
189
+ }
190
+ if (code === 'DEDICATED_RUNTIME_GENERATION_STALE') {
191
+ return 'The machine was replaced since its daemon registered. Wait a minute and try again.';
192
+ }
193
+ if (code === 'dedicated_runtime_quiesced') {
194
+ return 'The machine is checkpointing its workspace right now; try again in a moment.';
195
+ }
196
+ if (code === 'offline' ||
197
+ code === 'daemon_offline' ||
198
+ code === 'daemon-gone' ||
199
+ code === 'daemon-timeout') {
200
+ return 'The machine’s daemon is not answering. Wait for it to come online and try again.';
201
+ }
202
+ if (code === 'not_found') {
203
+ return context.name
204
+ ? `No terminal session named ${context.name} on this machine — \`skrr machines dedicated terminal ls ${context.leaseId || '<lease-id>'}\` lists them.`
205
+ : 'The machine or the named session was not found.';
206
+ }
207
+ if (code === 'invalid' || code === 'validation') {
208
+ return `The terminal request was refused: ${ack.error || 'invalid request'}`;
209
+ }
210
+ if (code === 'rate-limited' || code === 'cap') {
211
+ return 'Too many terminal requests in flight — wait a moment and retry.';
212
+ }
213
+ return ack.error ? `The terminal request failed: ${ack.error}` : 'The terminal request failed.';
214
+ }
155
215
  /* ---------------------------------------------------------------- spec --- */
156
216
  /** Validation that costs the daemon nothing to re-check — §5.3's table, enforced client-side first. */
157
217
  function validateServiceSpec(doc, options = {}) {
@@ -383,18 +443,22 @@ class DedicatedServiceChannel {
383
443
  this.options = options;
384
444
  const connect = options.connect ?? socket_io_client_1.io;
385
445
  this.socket = connect((0, agentic_stream_1.toWebSocketOrigin)(options.baseURL), socketOptions(options.token));
386
- const onAck = (ack) => {
446
+ // An ack resolves a request only when it arrives on THAT request's own
447
+ // ack event — a requestId echoed back on the wrong family is not an
448
+ // answer, however well-formed it looks.
449
+ const onAck = (ackEvent) => (ack) => {
387
450
  if (!ack?.requestId)
388
451
  return;
389
452
  const waiter = this.pending.get(ack.requestId);
390
- if (!waiter)
453
+ if (!waiter || waiter.ackEvent !== ackEvent)
391
454
  return;
392
455
  this.pending.delete(ack.requestId);
393
456
  clearTimeout(waiter.timer);
394
457
  waiter.resolve(ack);
395
458
  };
396
- this.socket.on(exports.DEDICATED_SERVICE_EVENTS.ack, onAck);
397
- this.socket.on(exports.DEDICATED_SERVICE_EVENTS.templatesAck, onAck);
459
+ this.socket.on(exports.DEDICATED_SERVICE_EVENTS.ack, onAck(exports.DEDICATED_SERVICE_EVENTS.ack));
460
+ this.socket.on(exports.DEDICATED_SERVICE_EVENTS.templatesAck, onAck(exports.DEDICATED_SERVICE_EVENTS.templatesAck));
461
+ this.socket.on(exports.DEDICATED_TERMINAL_EVENTS.ack, onAck(exports.DEDICATED_TERMINAL_EVENTS.ack));
398
462
  }
399
463
  /** Resolves once the transport is up; rejects on connect failure. */
400
464
  open() {
@@ -455,8 +519,21 @@ class DedicatedServiceChannel {
455
519
  verb: `templates:${verb}`,
456
520
  }, timeoutMs);
457
521
  }
522
+ /**
523
+ * A persistent-terminal verb — `dedicated:terminal:list|kill|rename`,
524
+ * answered on `dedicated:terminal:ack`. Same requestId/ack discipline as
525
+ * the service verbs; the event family and the refusal's wording are the
526
+ * only differences.
527
+ */
528
+ callTerminal(verb, payload, timeoutMs = exports.SERVICE_CALL_TIMEOUT_MS) {
529
+ return this.callEvent(`dedicated:terminal:${verb}`, exports.DEDICATED_TERMINAL_EVENTS.ack, payload, {
530
+ leaseId: typeof payload.leaseId === 'string' ? payload.leaseId : undefined,
531
+ verb: `terminal:${verb}`,
532
+ name: typeof payload.name === 'string' ? payload.name : undefined,
533
+ }, timeoutMs, describeTerminalRefusal);
534
+ }
458
535
  /** The shared request/ack round-trip. */
459
- callEvent(event, ackEvent, payload, context, timeoutMs) {
536
+ callEvent(event, ackEvent, payload, context, timeoutMs, describe = describeServiceRefusal) {
460
537
  const requestId = `svc-${(0, node_crypto_1.randomUUID)()}`;
461
538
  return new Promise((resolve, reject) => {
462
539
  if (this.dead) {
@@ -469,9 +546,10 @@ class DedicatedServiceChannel {
469
546
  }, timeoutMs);
470
547
  this.pending.set(requestId, {
471
548
  timer,
549
+ ackEvent,
472
550
  resolve: (ack) => {
473
551
  if (!ack.ok) {
474
- reject(new DedicatedServiceError(describeServiceRefusal(ack, context), ack.code || 'SERVICE_REFUSED'));
552
+ reject(new DedicatedServiceError(describe(ack, context), ack.code || 'SERVICE_REFUSED'));
475
553
  return;
476
554
  }
477
555
  resolve({ result: ack.result, ack });
@@ -34,6 +34,8 @@ export interface TerminalStartAck {
34
34
  ok?: boolean;
35
35
  error?: string;
36
36
  code?: string;
37
+ /** The tmux session a persistent start attached (`skrr-term-<name>`'s suffix). */
38
+ persistentName?: string;
37
39
  }
38
40
  /** Bytes of one output frame, without splitting a UTF-8 or ANSI sequence. */
39
41
  export declare function decodeTerminalFrame(frame: TerminalOutputFrame): Buffer;
@@ -56,6 +58,25 @@ export interface DedicatedTerminalOptions {
56
58
  daemonId: string;
57
59
  cwd?: string;
58
60
  initialCommand?: string;
61
+ /**
62
+ * A tmux-backed session (always-on §6.4): the machine keeps it alive across
63
+ * attaches, disconnects and daemon restarts, and a reconnect re-attaches it
64
+ * by name instead of opening a new shell. A daemon that predates
65
+ * `persistent_terminal_v1` silently ignores it — callers gate on the
66
+ * capability before asking.
67
+ */
68
+ persistent?: boolean;
69
+ /**
70
+ * The session suffix (`skrr-term-<name>`). The same name re-attaches the
71
+ * same session; omitted under `persistent`, the machine names the session
72
+ * `term-N` and the start ack reports it as `persistentName`.
73
+ */
74
+ sessionName?: string;
75
+ /**
76
+ * The lease this terminal claims — the relay refuses a start that names a
77
+ * lease but resolves to another lease's daemon.
78
+ */
79
+ leaseId?: string;
59
80
  input: NodeJS.ReadStream;
60
81
  output: NodeJS.WriteStream;
61
82
  errorOutput: NodeJS.WritableStream;
@@ -78,13 +78,22 @@ function describeTerminalStartRefusal(ack) {
78
78
  */
79
79
  function runDedicatedTerminal(options) {
80
80
  const events = data_provider_1.HARNESS_TERMINAL_EVENTS;
81
- const sessionId = (0, node_crypto_1.randomUUID)();
81
+ const persistent = options.persistent === true;
82
82
  const connect = options.connect ?? socket_io_client_1.io;
83
83
  const { input, output, errorOutput } = options;
84
84
  return new Promise((resolve, reject) => {
85
+ // The daemon keys a terminal by the sessionId in its start — one attach.
86
+ // A persistent session re-attaches across socket reconnects, and each
87
+ // attach is a fresh id over the same tmux name.
88
+ let sessionId = '';
85
89
  let started = false;
86
90
  let finished = false;
87
91
  let rawMode = false;
92
+ // First attach armed the tty; a re-attach needs only the ack, not the
93
+ // setup again.
94
+ let sessionReady = false;
95
+ let reattaching = false;
96
+ let persistentName = options.sessionName;
88
97
  const socket = connect((0, agentic_stream_1.toWebSocketOrigin)(options.baseURL), {
89
98
  path: '/ws/agentic',
90
99
  auth: {
@@ -94,10 +103,15 @@ function runDedicatedTerminal(options) {
94
103
  clientOs: process.platform === 'darwin' ? 'macos' : process.platform,
95
104
  },
96
105
  transports: ['websocket'],
97
- // A reconnect cannot resume a PTY: the relay forgets the session with the
106
+ // A bare PTY cannot be resumed: the relay forgets the session with the
98
107
  // socket, and silently opening a NEW shell would drop the user into a
99
- // different process tree mid-command. Say it ended instead.
100
- reconnection: false,
108
+ // different process tree mid-command — say it ended instead. A
109
+ // persistent session is the opposite by design: tmux keeps it, and the
110
+ // reconnect re-attaches it BY NAME, which is the same shell.
111
+ reconnection: persistent,
112
+ reconnectionAttempts: 10,
113
+ reconnectionDelay: 1_000,
114
+ reconnectionDelayMax: 5_000,
101
115
  });
102
116
  // Typed only once the shell's line editor says it is reading a command line
103
117
  // (`createPendingShellCommand`, the rule the web terminal reads too). A
@@ -106,7 +120,10 @@ function runDedicatedTerminal(options) {
106
120
  const pending = initialCommand
107
121
  ? (0, data_provider_1.createPendingShellCommand)({
108
122
  command: initialCommand,
109
- send: (data) => socket.emit(events.input, { sessionId, data }),
123
+ send: (data) => {
124
+ if (started)
125
+ socket.emit(events.input, { sessionId, data });
126
+ },
110
127
  onStatusChange: (status) => {
111
128
  const notice = describePendingCommand(status, initialCommand);
112
129
  if (notice && !finished)
@@ -129,13 +146,20 @@ function runDedicatedTerminal(options) {
129
146
  if (detachAt > 0) {
130
147
  const typed = buffer.subarray(0, detachAt);
131
148
  pending?.observeUserInput(typed);
132
- socket.emit(events.input, { sessionId, data: typed.toString('utf8') });
149
+ if (started)
150
+ socket.emit(events.input, { sessionId, data: typed.toString('utf8') });
133
151
  }
134
- errorOutput.write('\r\n[closed the terminal]\r\n');
152
+ errorOutput.write(persistent && persistentName
153
+ ? `\r\n[detached — session '${persistentName}' keeps running on the machine]\r\n`
154
+ : '\r\n[closed the terminal]\r\n');
135
155
  finish(0, true);
136
156
  return;
137
157
  }
138
158
  pending?.observeUserInput(buffer);
159
+ // While a reconnect is in flight the attach is gone: the frame would be
160
+ // dropped daemon-side anyway, so it is not sent.
161
+ if (!started)
162
+ return;
139
163
  socket.emit(events.input, { sessionId, data: buffer.toString('utf8') });
140
164
  };
141
165
  // A terminal that closes (SIGHUP) or a supervisor that stops us (SIGTERM):
@@ -145,9 +169,11 @@ function runDedicatedTerminal(options) {
145
169
  // SIGINT's status, for both.
146
170
  const onSigterm = () => finish(143, true);
147
171
  const onSighup = () => finish(129, true);
148
- const startTimer = setTimeout(() => {
172
+ const onStartTimeout = () => {
149
173
  fail(new DedicatedTerminalError('The machine did not answer the terminal start in time. Its daemon may be busy or offline.', 'TERMINAL_START_TIMEOUT'));
150
- }, exports.TERMINAL_START_TIMEOUT_MS);
174
+ };
175
+ let startTimer = setTimeout(onStartTimeout, exports.TERMINAL_START_TIMEOUT_MS);
176
+ startTimer.unref();
151
177
  function restoreTty() {
152
178
  input.off('data', onInput);
153
179
  output.off('resize', onResize);
@@ -166,6 +192,7 @@ function runDedicatedTerminal(options) {
166
192
  clearTimeout(startTimer);
167
193
  pending?.close();
168
194
  restoreTty();
195
+ detachReconnectFailed();
169
196
  if (stopRemote && started)
170
197
  socket.emit(events.stop, { sessionId });
171
198
  socket.disconnect();
@@ -178,17 +205,34 @@ function runDedicatedTerminal(options) {
178
205
  clearTimeout(startTimer);
179
206
  pending?.close();
180
207
  restoreTty();
208
+ detachReconnectFailed();
181
209
  socket.disconnect();
182
210
  reject(error);
183
211
  }
184
212
  socket.on('connect_error', (err) => {
213
+ // A failed retry while reattaching is not fatal — the attempt countdown
214
+ // keeps running and `reconnect_failed` ends it if they all miss.
215
+ if (persistent && reattaching)
216
+ return;
185
217
  fail(new DedicatedTerminalError(`Could not connect to skrr: ${err.message}`, err.data?.code || 'TERMINAL_CONNECT_FAILED'));
186
218
  });
187
219
  socket.on('connect', () => {
220
+ if (finished)
221
+ return;
222
+ sessionId = (0, node_crypto_1.randomUUID)();
223
+ clearTimeout(startTimer);
224
+ startTimer = setTimeout(onStartTimeout, exports.TERMINAL_START_TIMEOUT_MS);
225
+ startTimer.unref();
188
226
  socket.emit(events.start, {
189
227
  runtimeId: options.runtimeId,
190
228
  sessionId,
191
229
  ...(options.cwd ? { cwd: options.cwd } : {}),
230
+ ...(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.
234
+ ...(persistentName ? { sessionName: persistentName } : {}),
235
+ ...(options.leaseId ? { leaseId: options.leaseId } : {}),
192
236
  ...size(),
193
237
  });
194
238
  });
@@ -201,8 +245,20 @@ function runDedicatedTerminal(options) {
201
245
  fail(new DedicatedTerminalError(describeTerminalStartRefusal(ack), ack.code || 'TERMINAL_START_REFUSED'));
202
246
  return;
203
247
  }
248
+ const reattached = reattaching;
204
249
  started = true;
250
+ reattaching = false;
205
251
  clearTimeout(startTimer);
252
+ if (typeof ack.persistentName === 'string' && ack.persistentName) {
253
+ persistentName = ack.persistentName;
254
+ }
255
+ if (sessionReady) {
256
+ if (reattached) {
257
+ errorOutput.write(`\r\n[reattached to session '${persistentName}']\r\n`);
258
+ }
259
+ return;
260
+ }
261
+ sessionReady = true;
206
262
  if (typeof input.setRawMode === 'function') {
207
263
  input.setRawMode(true);
208
264
  rawMode = true;
@@ -213,6 +269,9 @@ function runDedicatedTerminal(options) {
213
269
  process.on('SIGTERM', onSigterm);
214
270
  process.on('SIGHUP', onSighup);
215
271
  pending?.beginSession();
272
+ if (persistent && persistentName) {
273
+ errorOutput.write(`\r\n[session '${persistentName}' is kept alive on the machine — Ctrl-] detaches, the same command re-attaches]\r\n`);
274
+ }
216
275
  });
217
276
  socket.on(events.output, (frame) => {
218
277
  if (!frame || frame.sessionId !== sessionId || finished)
@@ -233,9 +292,45 @@ function runDedicatedTerminal(options) {
233
292
  output.write(bytes);
234
293
  pending?.observeOutput(bytes);
235
294
  });
295
+ // socket.io reports a given-up reconnect on the manager; a bare EventEmitter
296
+ // test socket has none, so the socket itself carries the listener then.
297
+ const onReconnectFailed = () => {
298
+ if (finished || !persistent)
299
+ return;
300
+ const named = persistentName ? ` '${persistentName}'` : '';
301
+ errorOutput.write(`\r\n[could not reconnect to skrr — session${named} still runs on the machine; rerun the same command to reattach]\r\n`);
302
+ finish(1, false);
303
+ };
304
+ const manager = socket.io;
305
+ const onManager = typeof manager?.on === 'function';
306
+ if (onManager)
307
+ manager.on('reconnect_failed', onReconnectFailed);
308
+ else
309
+ socket.on('reconnect_failed', onReconnectFailed);
310
+ // The manager is SHARED per url+path (socket.io multiplexing), so the
311
+ // listener outlives this terminal unless it is removed on every exit.
312
+ function detachReconnectFailed() {
313
+ if (onManager && typeof manager?.off === 'function') {
314
+ manager.off('reconnect_failed', onReconnectFailed);
315
+ }
316
+ else if (!onManager) {
317
+ socket.off('reconnect_failed', onReconnectFailed);
318
+ }
319
+ }
236
320
  socket.on('disconnect', () => {
237
321
  if (finished)
238
322
  return;
323
+ 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
+ }
332
+ return;
333
+ }
239
334
  if (!started) {
240
335
  fail(new DedicatedTerminalError('The connection to skrr closed before the terminal started.', 'TERMINAL_DISCONNECTED'));
241
336
  return;
@@ -223,6 +223,16 @@ export interface ManagedEngineHooks {
223
223
  * never from the env this hook returned.
224
224
  */
225
225
  credentialMode: () => 'managed' | 'byok';
226
+ /**
227
+ * `prepare` fell back to BYOK AND no provider credential will reach the
228
+ * engine — the exact case where the fallback line already warned that the
229
+ * first request will fail. The caller uses this AFTER a non-zero engine
230
+ * exit to tie an opaque engine failure (an `Unexpected server error` that
231
+ * says nothing about auth) back to the missing credential, without
232
+ * refusing the launch up front: the engine can carry a stored credential
233
+ * `hasProviderCredential` cannot see (OSK-11949).
234
+ */
235
+ unprovisionedByok: () => boolean;
226
236
  }
227
237
  export interface ManagedEngineHooksInput {
228
238
  subcommand: string | null;
@@ -999,8 +999,15 @@ function createManagedEngineHooks(input) {
999
999
  * is not reported to the user as a decision about their account (OSK-3961).
1000
1000
  */
1001
1001
  let tearingDown = false;
1002
+ /**
1003
+ * `prepare` answered `{}` with no credential the engine could inherit —
1004
+ * latched, so a post-exit caller can reconnect an opaque engine failure to
1005
+ * the auth cause the fallback line already named (OSK-11949).
1006
+ */
1007
+ let unprovisioned = false;
1002
1008
  return {
1003
1009
  session: () => handle,
1010
+ unprovisionedByok: () => unprovisioned,
1004
1011
  // A live handle means a broker is running and the engine's base URLs point at
1005
1012
  // it, which is exactly when re-admitting an ambient provider key would let the
1006
1013
  // engine bypass the relay (or 401 preferring a stale `x-api-key`). No handle
@@ -1012,6 +1019,7 @@ function createManagedEngineHooks(input) {
1012
1019
  if (skip) {
1013
1020
  if (skip.announce)
1014
1021
  log(`Managed inference not used: ${skip.detail}. ${fallbackTail(env)}`);
1022
+ unprovisioned = !(0, first_party_harness_1.hasProviderCredential)(env);
1015
1023
  return {};
1016
1024
  }
1017
1025
  try {
@@ -1063,6 +1071,7 @@ function createManagedEngineHooks(input) {
1063
1071
  if (failure.action === 'refuse') {
1064
1072
  throw new first_party_harness_1.EngineStartRefusedError(failure.code, failure.line);
1065
1073
  }
1074
+ unprovisioned = !(0, first_party_harness_1.hasProviderCredential)(env);
1066
1075
  return {};
1067
1076
  }
1068
1077
  },
@@ -42,6 +42,7 @@ const first_party_harness_1 = require("./first-party-harness");
42
42
  const auth_core_1 = require("@skrr-ai/auth-core");
43
43
  const harness_tiers_1 = require("./harness-tiers");
44
44
  const first_party_harness_managed_1 = require("./first-party-harness-managed");
45
+ const first_party_harness_project_trust_1 = require("./first-party-harness-project-trust");
45
46
  /**
46
47
  * Project-level state dir — the ENGINE's own project marker
47
48
  * (`FIRST_PARTY_HARNESS_ENGINE.projectDirectory`). Basename deliberately differs
@@ -490,6 +491,42 @@ function permissionAuthorityCheck(env) {
490
491
  detail: 'daemon-managed sessions adjudicate every tool call; no trust tier auto-approves',
491
492
  };
492
493
  }
494
+ /**
495
+ * OSK-11958 — whether `skrr code` will load THIS directory's project configuration.
496
+ *
497
+ * Reported because the answer changes what the engine can do before any tool
498
+ * call exists: a project's plugins and custom tools run on load, and its
499
+ * permission block can loosen what the agent does without asking. Never a
500
+ * `warn`: an untrusted project with config is the safe default working as
501
+ * designed, and one with none has nothing to decide.
502
+ */
503
+ function projectConfigCheck(cwd, env) {
504
+ const root = (0, first_party_harness_project_trust_1.findProjectRoot)(cwd);
505
+ const config = (0, first_party_harness_project_trust_1.findProjectConfig)(cwd, root, env);
506
+ if (config.length === 0) {
507
+ return {
508
+ name: 'project-config',
509
+ status: 'ok',
510
+ detail: 'this project ships no agent configuration — nothing to trust',
511
+ };
512
+ }
513
+ const listed = config.map((file) => node_path_1.default.relative(root, file) || '.').join(', ');
514
+ if ((0, first_party_harness_project_trust_1.isProjectTrusted)(root, env)) {
515
+ return {
516
+ name: 'project-config',
517
+ status: 'ok',
518
+ detail: `trusted — ${listed} load for \`skrr code\` and \`skrr code run\``,
519
+ remedy: `Recorded in ${(0, first_party_harness_project_trust_1.trustRecordPath)(env)}; delete this project's entry there to stop trusting it.`,
520
+ };
521
+ }
522
+ return {
523
+ name: 'project-config',
524
+ status: 'ok',
525
+ detail: `not trusted — ${listed} will NOT load (instructions files still do)`,
526
+ remedy: `Project configuration can run code on this machine and loosen permissions. Pass ` +
527
+ `${first_party_harness_project_trust_1.TRUST_PROJECT_FLAG} once, or answer "always" when the TUI asks, to load it.`,
528
+ };
529
+ }
493
530
  /**
494
531
  * Hosts the engine's WL-2.7 egress policy denies, and the one it still permits.
495
532
  *
@@ -759,7 +796,9 @@ function sessionAuthorityCheck(env) {
759
796
  name: 'session-authority',
760
797
  status: 'ok',
761
798
  detail: 'standalone — the engine decides. No daemon permission adjudication, no run fence, no ' +
762
- 'scoped platform credential, no durable task context, no worktree isolation. ' +
799
+ 'scoped platform credential, no durable task context, no worktree isolation. The TUI ' +
800
+ 'asks before edits, commands and web access unless OPENCODE_PERMISSION or ' + // brand-leak-ok:compat upstream env var name
801
+ '--engine-permissions says otherwise; `skrr code run` keeps the engine config. ' +
763
802
  'Daemon-supervised sessions enter through `skrr code acp`, not through this command.',
764
803
  remedy: 'Intended for local use, and nothing to fix. Worth knowing because it is NOT the same ' +
765
804
  'trust plane as a daemon-dispatched run — and neither plane is an OS-level sandbox ' +
@@ -828,6 +867,7 @@ function runDoctor(cwd = process.cwd(), env = process.env) {
828
867
  stateDirCheck(cwd, env),
829
868
  compatCheck(cwd, env),
830
869
  opencodeCheck(cwd),
870
+ projectConfigCheck(cwd, env),
831
871
  // Before the trust/permission/credential trio, because it frames all three:
832
872
  // what a tier grants and who enforces it both depend on which plane you are on.
833
873
  sessionAuthorityCheck(env),
@@ -122,9 +122,10 @@ export declare const MANAGED_SESSION_ENV = "OVERSKY_MANAGED_SESSION";
122
122
  * by `skrr` itself. Keying an AUTHORITY answer on it therefore tells a user with
123
123
  * no daemon at all that "a daemon adjudicates every tool call", which is the
124
124
  * exact conflation `sessionAuthorityCheck`'s own docstring was written to warn
125
- * about. It is not a subtle misread: bare `skrr code` never sets
126
- * `OPENCODE_PERMISSION={"*":"ask"}` (only the daemon's backend does), so the
127
- * engine consults its own allow-by-default config and nothing is adjudicated.
125
+ * about. It is not a subtle misread: nothing on this plane routes a tool call
126
+ * to the daemon. Since OSK-11958 the bare TUI asks the PERSON at the terminal
127
+ * before edits and commands (`INTERACTIVE_PERMISSION_DEFAULT`), and `skrr code
128
+ * run` keeps the engine's own config — neither is daemon adjudication.
128
129
  *
129
130
  * Ordered deliberately: DAEMON IDENTITY FIRST. Only the daemon can set
130
131
  * `OVERSKY_AUTONOMOUS_SESSION` or the runtime context a daemon session carries,