@skrr-ai/cli 0.1.56 → 0.1.57

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.
@@ -109,6 +109,17 @@ export declare abstract class BaseCommand extends Command {
109
109
  * silently matched nothing reaches the search.
110
110
  */
111
111
  protected resolveSpaceReference(value: string): Promise<string>;
112
+ /**
113
+ * A 24-hex value: almost always the internal `_id` copied from `--json`,
114
+ * which prints it first (OSK-12205). No space route accepts it, so passing it
115
+ * through produced a not-found or a raw 404 that never said to use `id`.
116
+ *
117
+ * One listing tells the cases apart: a space whose `id` really is this value
118
+ * passes through unchanged; one whose `_id` is this value fails naming the
119
+ * exact `id` to use. A listing that fails, or finds neither, is not proof of
120
+ * anything, so it keeps the old pass-through and lets the route answer.
121
+ */
122
+ private resolveObjectIdShapedSpace;
112
123
  /**
113
124
  * Turn a project reference into `{ projectRef, spaceId }`, or fail the
114
125
  * command.
@@ -20,6 +20,7 @@ const core_1 = require("@oclif/core");
20
20
  Object.defineProperty(exports, "Flags", { enumerable: true, get: function () { return core_1.Flags; } });
21
21
  const option_hint_1 = require("./lib/option-hint");
22
22
  const failed_lookup_1 = require("./lib/failed-lookup");
23
+ const internal_id_1 = require("./lib/internal-id");
23
24
  const http_error_1 = require("./lib/http-error");
24
25
  // Re-exported from its home in `lib/keychain`, beside the other profile
25
26
  // functions. It lived here while `init()` was its only caller; a lib module
@@ -260,6 +261,27 @@ class BaseCommand extends core_1.Command {
260
261
  // can't wedge the CLI; on timeout we fall through to legacy
261
262
  // plaintext and the resolver still finds the token.
262
263
  await (0, auth_core_init_1.prefetchCliAuth)();
264
+ // On a brokered Dedicated guest a stored cli-auth.json predates broker
265
+ // mode, and the broker's success path retires it (revoke + delete). That
266
+ // could never run when the file carried an access token: the resolver
267
+ // below adopted it first, and the auto-broker only asks when the resolver
268
+ // found nothing — so a live pre-broker family was used and kept forever
269
+ // (OSK-12194). Broker FIRST in exactly that case. The file is retired only
270
+ // after a redeem succeeds; if the broker cannot answer, the resolver still
271
+ // falls back to the file, so a working login is never lost to an outage.
272
+ // `maybeAutoBroker` applies the same skip rules as below (bare, env token,
273
+ // login/logout/pair), and a laptop never has the guest descriptor.
274
+ let earlyAutoBroker = null;
275
+ if (!this.bareMode && !parsed.token && (0, daemonBroker_1.shouldBrokerBeforeStoredCredential)()) {
276
+ earlyAutoBroker = await (0, daemonBroker_1.maybeAutoBroker)({
277
+ cliConfig: this.cliConfig,
278
+ bareMode: this.bareMode,
279
+ commandId: this.id,
280
+ });
281
+ if (!earlyAutoBroker.triggered)
282
+ earlyAutoBroker = null;
283
+ }
284
+ const brokeredFirst = earlyAutoBroker?.outcome?.ok === true;
263
285
  // Resolve credential via the Phase H.1 precedence chain. The
264
286
  // adapter then cherry-picks the token for the Authorization
265
287
  // header; we hand it a pre-resolved override so it doesn't
@@ -296,12 +318,14 @@ class BaseCommand extends core_1.Command {
296
318
  // repair that reason implies — "Not signed in. Run `skrr login`" was true
297
319
  // and pointed at the wrong fix (OSK-12149). Rate limiting and a mismatched
298
320
  // baseURL keep their own one-line hints below.
299
- if (!this.resolvedCredential.token) {
300
- const autoResult = await (0, daemonBroker_1.maybeAutoBroker)({
301
- cliConfig: this.cliConfig,
302
- bareMode: this.bareMode,
303
- commandId: this.id,
304
- });
321
+ if (!this.resolvedCredential.token || brokeredFirst) {
322
+ // The guest's early broker above already asked; its answer stands.
323
+ const autoResult = earlyAutoBroker ??
324
+ (await (0, daemonBroker_1.maybeAutoBroker)({
325
+ cliConfig: this.cliConfig,
326
+ bareMode: this.bareMode,
327
+ commandId: this.id,
328
+ }));
305
329
  if (autoResult.triggered && autoResult.outcome) {
306
330
  if (autoResult.outcome.ok) {
307
331
  // Persist the cliId now so a sibling skrr process spawned
@@ -449,6 +473,8 @@ class BaseCommand extends core_1.Command {
449
473
  // Only canonical ids short-circuit. Human-readable refs are accepted by
450
474
  // some read endpoints, but space-scoped mutation endpoints require the
451
475
  // UUID, so refs go through the same exact-match resolution as titles.
476
+ if ((0, internal_id_1.looksLikeInternalObjectId)(query))
477
+ return this.resolveObjectIdShapedSpace(query);
452
478
  if (!query || (0, space_resolver_1.looksLikeCanonicalSpaceId)(query))
453
479
  return query;
454
480
  if ((0, failed_lookup_1.isFailedLookupValue)(query)) {
@@ -493,6 +519,43 @@ class BaseCommand extends core_1.Command {
493
519
  },
494
520
  });
495
521
  }
522
+ /**
523
+ * A 24-hex value: almost always the internal `_id` copied from `--json`,
524
+ * which prints it first (OSK-12205). No space route accepts it, so passing it
525
+ * through produced a not-found or a raw 404 that never said to use `id`.
526
+ *
527
+ * One listing tells the cases apart: a space whose `id` really is this value
528
+ * passes through unchanged; one whose `_id` is this value fails naming the
529
+ * exact `id` to use. A listing that fails, or finds neither, is not proof of
530
+ * anything, so it keeps the old pass-through and lets the route answer.
531
+ */
532
+ async resolveObjectIdShapedSpace(query) {
533
+ let rows = [];
534
+ try {
535
+ const response = (await data_provider_1.dataService.listSpaces({ limit: 200, includeArchived: true }));
536
+ rows = response?.data ?? [];
537
+ }
538
+ catch {
539
+ return query;
540
+ }
541
+ if (rows.some((row) => row.id === query))
542
+ return query;
543
+ const byInternalId = rows.find((row) => String(row._id ?? '') === query);
544
+ if (!byInternalId)
545
+ return query;
546
+ this.failWithCliError({
547
+ message: (0, internal_id_1.describeInternalIdMisuse)({
548
+ value: query,
549
+ noun: 'space',
550
+ canonicalId: typeof byInternalId.id === 'string' ? byInternalId.id : null,
551
+ bin: this.config.bin,
552
+ }),
553
+ code: 'SPACE_INTERNAL_ID',
554
+ exit: 2,
555
+ retryable: false,
556
+ details: { context: { query, id: byInternalId.id } },
557
+ });
558
+ }
496
559
  async resolveProjectReference(value, spaceFlag, opts = {}) {
497
560
  const spaceRequired = opts.spaceRequired !== false;
498
561
  const { projectRef, spaceRef } = (0, projects_1.splitQualifiedProjectRef)(value, spaceFlag);
@@ -557,6 +620,17 @@ class BaseCommand extends core_1.Command {
557
620
  const query = value.trim();
558
621
  if (!query)
559
622
  return query;
623
+ // A task's `id` is a UUID, so a 24-hex value is the internal `_id` that
624
+ // `--json` prints first. The title search it used to reach can only say
625
+ // "no task matching", which never names the field to use (OSK-12205).
626
+ if ((0, internal_id_1.looksLikeInternalObjectId)(query)) {
627
+ this.failWithCliError({
628
+ message: (0, internal_id_1.describeInternalIdMisuse)({ value: query, noun: 'task', bin: this.config.bin }),
629
+ code: 'TASK_INTERNAL_ID',
630
+ exit: 2,
631
+ retryable: false,
632
+ });
633
+ }
560
634
  // Before the search, not after it: `null` from a failed `jq -r` must never
561
635
  // reach the title tiers, and it should not cost a request to say so.
562
636
  if ((0, failed_lookup_1.isFailedLookupValue)(query)) {
@@ -287,9 +287,11 @@ class AgentsChat extends base_command_1.BaseCommand {
287
287
  description: 'Permission mode for THIS turn only, on a local-harness agent. It beats the agent’s and ' +
288
288
  'the harness’s stored defaults for this turn and changes nothing else — pass it again ' +
289
289
  'on each turn that needs it (`agents update --default-permission-mode` changes every ' +
290
- 'conversation of the agent). A mode the harness cannot honour runs as the one it can, ' +
291
- 'and run.started says which. --approve-tools is separate: it answers the approval ' +
292
- 'prompts a mode asks for.',
290
+ 'conversation of the agent). A mode the harness cannot honour runs as the closest ' +
291
+ 'STRICTER mode it can, and run.started says which; when the only mode it can run would ' +
292
+ 'let more run without asking, the turn is refused before it starts (run.failed, ' +
293
+ 'PERMISSION_MODE_UNSUPPORTED_BY_HARNESS). --approve-tools is separate: it answers the ' +
294
+ 'approval prompts a mode asks for.',
293
295
  options: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
294
296
  }),
295
297
  'local-skill': core_1.Flags.string({
@@ -9,6 +9,7 @@ const core_1 = require("@oclif/core");
9
9
  const data_provider_1 = require("@skrr-ai/data-provider");
10
10
  const agent_visual_refs_1 = require("../../lib/agent-visual-refs");
11
11
  const runtime_tool_support_1 = require("../../lib/runtime-tool-support");
12
+ const agent_model_check_1 = require("../../lib/agent-model-check");
12
13
  const base_command_1 = require("../../base-command");
13
14
  const execution_target_1 = require("../../lib/execution-target");
14
15
  const prompt_1 = require("../../lib/prompt");
@@ -407,6 +408,12 @@ class AgentsCreate extends base_command_1.BaseCommand {
407
408
  this.warn(unappliedPayloadKeysWarning(unapplied));
408
409
  }
409
410
  await (0, runtime_tool_support_1.warnPluginsNotLoaded)(payload.tools, (message) => this.warn(message));
411
+ await (0, agent_model_check_1.warnIfModelNotListed)({
412
+ model: payload.model,
413
+ agent: created,
414
+ bin: this.config.bin,
415
+ warn: (message) => this.warn(message),
416
+ });
410
417
  if (flags.json) {
411
418
  this.log(JSON.stringify((0, web_url_1.withAgentUrl)(this.cliConfig.baseURL, created), null, 2));
412
419
  return;
@@ -10,6 +10,7 @@ const data_provider_1 = require("@skrr-ai/data-provider");
10
10
  const agent_visual_refs_1 = require("../../lib/agent-visual-refs");
11
11
  const create_1 = require("./create");
12
12
  const runtime_tool_support_1 = require("../../lib/runtime-tool-support");
13
+ const agent_model_check_1 = require("../../lib/agent-model-check");
13
14
  const base_command_1 = require("../../base-command");
14
15
  const execution_target_1 = require("../../lib/execution-target");
15
16
  const prompt_1 = require("../../lib/prompt");
@@ -221,6 +222,12 @@ class AgentsUpdate extends base_command_1.BaseCommand {
221
222
  for (const warning of agentUpdateWarnings(payload, updated))
222
223
  this.warn(warning);
223
224
  await (0, runtime_tool_support_1.warnPluginsNotLoaded)(payload.tools, (message) => this.warn(message));
225
+ await (0, agent_model_check_1.warnIfModelNotListed)({
226
+ model: payload.model,
227
+ agent: updated,
228
+ bin: this.config.bin,
229
+ warn: (message) => this.warn(message),
230
+ });
224
231
  if (flags.json) {
225
232
  this.log(JSON.stringify((0, web_url_1.withAgentUrl)(this.cliConfig.baseURL, updated), null, 2));
226
233
  return;
@@ -234,6 +234,20 @@ class Login extends base_command_1.BaseCommand {
234
234
  if (handoff.status === 'delivered') {
235
235
  this.log(`The local daemon was signed in too — ${handoff.detail}.`);
236
236
  }
237
+ else if (handoff.reason === 'foreign-root') {
238
+ // Not a failure and not a hint to re-login anything: the daemon is
239
+ // the default root's, and this is a second root (OSK-12189).
240
+ this.log(`The local daemon was left as it was — ${handoff.detail}.`);
241
+ }
242
+ else if (handoff.reason === 'mint-refused') {
243
+ // The server refused before the daemon was touched, so its own
244
+ // credential is exactly what it was. Saying "not signed in" here sent
245
+ // people to re-login a healthy daemon (OSK-12189).
246
+ this.log(`This login was not handed to the local daemon (${handoff.detail}). ` +
247
+ `The daemon keeps whatever sign-in it already had — check it with ` +
248
+ `\`${this.config.bin} daemon status\`, and run \`${this.config.bin} daemon login\` ` +
249
+ `only if that says it is signed out.`);
250
+ }
237
251
  else if (handoff.status === 'failed') {
238
252
  this.log(`The local daemon was not signed in (${handoff.detail}). ` +
239
253
  `Run \`${this.config.bin} daemon login\` to finish.`);
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
4
  const data_provider_1 = require("@skrr-ai/data-provider");
5
5
  const api_fetch_1 = require("../../lib/api-fetch");
6
+ const http_error_1 = require("../../lib/http-error");
7
+ const workflow_state_hint_1 = require("../../lib/workflow-state-hint");
6
8
  const session_task_endpoints_1 = require("../../lib/session-task-endpoints");
7
9
  const base_command_1 = require("../../base-command");
8
10
  const task_closure_1 = require("../../lib/task-closure");
@@ -129,6 +131,15 @@ class TasksMove extends base_command_1.BaseCommand {
129
131
  }
130
132
  }
131
133
  catch (err) {
134
+ const unknownState = (0, workflow_state_hint_1.describeUnknownWorkflowState)((0, http_error_1.httpBodyOf)(err), this.config.bin);
135
+ if (unknownState) {
136
+ this.failWithCliError({
137
+ message: unknownState,
138
+ code: 'WORKFLOW_STATE_UNKNOWN',
139
+ exit: 2,
140
+ retryable: false,
141
+ });
142
+ }
132
143
  this.handleApiError(err);
133
144
  }
134
145
  if (flags.json) {
@@ -0,0 +1,19 @@
1
+ interface ModelsReport {
2
+ provider?: string | null;
3
+ source?: string;
4
+ sourceReason?: string;
5
+ models?: Array<{
6
+ value?: string;
7
+ }>;
8
+ }
9
+ /** The harness the saved agent runs on first, as the write response names it. */
10
+ export declare function primaryHarnessId(agent: unknown): string | null;
11
+ export declare function describeUnlistedModel(model: string | undefined, report: ModelsReport | undefined, harnessId: string, bin: string): string | null;
12
+ /** Best effort: a check that cannot run says nothing rather than failing the save. */
13
+ export declare function warnIfModelNotListed(opts: {
14
+ model: string | undefined;
15
+ agent: unknown;
16
+ bin: string;
17
+ warn: (message: string) => void;
18
+ }): Promise<void>;
19
+ export {};
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.primaryHarnessId = primaryHarnessId;
4
+ exports.describeUnlistedModel = describeUnlistedModel;
5
+ exports.warnIfModelNotListed = warnIfModelNotListed;
6
+ const harnesses_1 = require("./harnesses");
7
+ /**
8
+ * A model id the agent's harness does not list, said at save time (OSK-12205).
9
+ *
10
+ * `agents create/update --model <id>` stored any well-formed id without a
11
+ * word; the first anyone heard of a typo was a failed turn, whose remedy then
12
+ * sent a local agent to "publish the agent". The server only validates the
13
+ * id's SHAPE on write — asking the daemon for its live list on every PATCH
14
+ * would put a machine round-trip on the write path — so the check lives here,
15
+ * after the write, and never fails the command.
16
+ *
17
+ * Only a list that speaks for the harness can say "not there": `live` (the
18
+ * installed harness enumerated it) or `managed` (the admission policy). A
19
+ * `curated` list is our suggestion, so a miss against it is reported as
20
+ * unverified, with the reason the live list was unavailable.
21
+ */
22
+ /** Not model names: "no selection", which runs the harness default. */
23
+ const SENTINELS = new Set(['', 'default', 'auto']);
24
+ /** The harness the saved agent runs on first, as the write response names it. */
25
+ function primaryHarnessId(agent) {
26
+ const record = (agent ?? {});
27
+ if (typeof record.harnessId === 'string' && record.harnessId)
28
+ return record.harnessId;
29
+ const policy = record.executionPolicy;
30
+ const target = (policy?.targets ?? []).find((t) => t?.kind === 'harness' && t.enabled !== false && typeof t.harnessId === 'string');
31
+ return target?.harnessId ?? null;
32
+ }
33
+ function describeUnlistedModel(model, report, harnessId, bin) {
34
+ const id = typeof model === 'string' ? model.trim() : '';
35
+ if (SENTINELS.has(id.toLowerCase()))
36
+ return null;
37
+ const listed = (report?.models ?? []).some((entry) => entry?.value === id);
38
+ if (listed)
39
+ return null;
40
+ const provider = report?.provider || 'this';
41
+ const remedy = `\`${bin} harnesses models ${harnessId}\` lists what it can run.`;
42
+ if (report?.source === 'live' || report?.source === 'managed') {
43
+ return (`Model '${id}' is not in the ${provider} harness's ${report.source} model list ` +
44
+ `(${harnessId}). It was saved, but a turn fails when the harness refuses it. ${remedy}`);
45
+ }
46
+ const why = report?.sourceReason ? ` (${report.sourceReason})` : '';
47
+ return (`Model '${id}' is not in the suggested list for the ${provider} harness, and the ` +
48
+ `harness's live list was unavailable${why}, so it could not be checked. It was saved. ` +
49
+ remedy);
50
+ }
51
+ /** Best effort: a check that cannot run says nothing rather than failing the save. */
52
+ async function warnIfModelNotListed(opts) {
53
+ const id = typeof opts.model === 'string' ? opts.model.trim() : '';
54
+ if (SENTINELS.has(id.toLowerCase()))
55
+ return;
56
+ const harnessId = primaryHarnessId(opts.agent);
57
+ if (!harnessId)
58
+ return;
59
+ try {
60
+ const report = (await (0, harnesses_1.getHarnessModels)(harnessId));
61
+ const message = describeUnlistedModel(id, report, harnessId, opts.bin);
62
+ if (message)
63
+ opts.warn(message);
64
+ }
65
+ catch {
66
+ /* the save stands; the harness list is a hint */
67
+ }
68
+ }
@@ -365,6 +365,12 @@ export declare class AgenticStreamClient {
365
365
  private readonly toolNamesByCall;
366
366
  /** `permission-ask` requestId → tool name, to name its resolution (OSK-12113). */
367
367
  private readonly toolNamesByPermission;
368
+ /**
369
+ * Permission prompts on the terminal still waiting for an answer, by
370
+ * toolUseId, so a server `permission:cancel` can close the one it names
371
+ * (OSK-12197) instead of leaving `[y/N]` open after the run stopped asking.
372
+ */
373
+ private readonly openPermissionPrompts;
368
374
  /** Text already written to the terminal, for suffix-only rendering. */
369
375
  private renderedText;
370
376
  private idleTimer;
@@ -443,6 +449,12 @@ export declare class AgenticStreamClient {
443
449
  private installHandlers;
444
450
  private handlePermission;
445
451
  private handleQuestion;
452
+ /**
453
+ * The server closed a permission request (OSK-12197): it expired unanswered,
454
+ * the pre-execution step gave up, or another surface answered it. Say so,
455
+ * and close the terminal prompt if this client is still showing it.
456
+ */
457
+ private handlePermissionCancel;
446
458
  private ask;
447
459
  /**
448
460
  * True the first time this (kind, identity) pair is seen; false afterwards.
@@ -557,6 +557,12 @@ class AgenticStreamClient {
557
557
  toolNamesByCall = new Map();
558
558
  /** `permission-ask` requestId → tool name, to name its resolution (OSK-12113). */
559
559
  toolNamesByPermission = new Map();
560
+ /**
561
+ * Permission prompts on the terminal still waiting for an answer, by
562
+ * toolUseId, so a server `permission:cancel` can close the one it names
563
+ * (OSK-12197) instead of leaving `[y/N]` open after the run stopped asking.
564
+ */
565
+ openPermissionPrompts = new Map();
560
566
  /** Text already written to the terminal, for suffix-only rendering. */
561
567
  renderedText = '';
562
568
  idleTimer = null;
@@ -946,6 +952,11 @@ class AgenticStreamClient {
946
952
  return;
947
953
  void this.handlePermission(event);
948
954
  });
955
+ socket.on('permission:cancel', (event) => {
956
+ if (!match(event))
957
+ return;
958
+ this.handlePermissionCancel(event);
959
+ });
949
960
  socket.on('question:ask', (event) => {
950
961
  if (match(event))
951
962
  void this.handleQuestion(event);
@@ -1008,7 +1019,16 @@ class AgenticStreamClient {
1008
1019
  });
1009
1020
  return;
1010
1021
  }
1011
- const answer = await this.ask(`Allow ${describePermission(event)}? [y/N] `);
1022
+ const prompt = new AbortController();
1023
+ if (event.toolUseId)
1024
+ this.openPermissionPrompts.set(event.toolUseId, prompt);
1025
+ const answer = await this.ask(`Allow ${describePermission(event)}? [y/N] `, prompt.signal);
1026
+ if (event.toolUseId)
1027
+ this.openPermissionPrompts.delete(event.toolUseId);
1028
+ // Closed by the server (it stopped waiting, or another surface answered):
1029
+ // there is nothing left to answer, and a late "no" would read as a person's.
1030
+ if (prompt.signal.aborted)
1031
+ return;
1012
1032
  const approved = /^y(es)?$/i.test(answer.trim());
1013
1033
  this.socket.emit(approved ? 'permission:approve' : 'permission:deny', {
1014
1034
  sessionId: this.options.sessionId,
@@ -1064,10 +1084,36 @@ class AgenticStreamClient {
1064
1084
  ...(interactive ? {} : { nonInteractive: true }),
1065
1085
  });
1066
1086
  }
1067
- ask(prompt) {
1087
+ /**
1088
+ * The server closed a permission request (OSK-12197): it expired unanswered,
1089
+ * the pre-execution step gave up, or another surface answered it. Say so,
1090
+ * and close the terminal prompt if this client is still showing it.
1091
+ */
1092
+ handlePermissionCancel(event) {
1093
+ const toolUseId = typeof event.toolUseId === 'string' ? event.toolUseId : undefined;
1094
+ const open = toolUseId ? this.openPermissionPrompts.get(toolUseId) : undefined;
1095
+ // Every answer is echoed to the whole session as `answered` — including
1096
+ // this client's own. Only one that closes a prompt still open HERE is news.
1097
+ if (event.reason === 'answered' && !open)
1098
+ return;
1099
+ if (!this.firstTimeSeen('permission-cancel', toolUseId))
1100
+ return;
1101
+ if (toolUseId)
1102
+ this.openPermissionPrompts.delete(toolUseId);
1103
+ open?.abort();
1104
+ this.emit({
1105
+ ...event,
1106
+ type: 'permission.cancelled',
1107
+ promptClosed: Boolean(open),
1108
+ ...(event.reason === 'answered' && !event.message
1109
+ ? { message: 'answered on another surface' }
1110
+ : {}),
1111
+ });
1112
+ }
1113
+ ask(prompt, signal) {
1068
1114
  // A prompt raised after close has nobody to answer it, and re-creating the
1069
1115
  // interface here would resurrect stdin for a run that is already over.
1070
- if (this.closed)
1116
+ if (this.closed || signal?.aborted)
1071
1117
  return Promise.resolve('');
1072
1118
  if (!this.readline) {
1073
1119
  this.readline = (0, node_readline_1.createInterface)({
@@ -1075,7 +1121,15 @@ class AgenticStreamClient {
1075
1121
  output: process.stderr,
1076
1122
  });
1077
1123
  }
1078
- return new Promise((resolve) => this.readline.question(prompt, resolve));
1124
+ if (!signal) {
1125
+ return new Promise((resolve) => this.readline.question(prompt, resolve));
1126
+ }
1127
+ return new Promise((resolve) => {
1128
+ // readline does not call back for a question aborted by its signal, so
1129
+ // the abort settles the prompt itself.
1130
+ signal.addEventListener('abort', () => resolve(''), { once: true });
1131
+ this.readline.question(prompt, { signal }, resolve);
1132
+ });
1079
1133
  }
1080
1134
  /**
1081
1135
  * True the first time this (kind, identity) pair is seen; false afterwards.
@@ -1176,6 +1230,15 @@ class AgenticStreamClient {
1176
1230
  output.write(`\n[${event.type}] ${describePermission(event)}\n`);
1177
1231
  this.renderedText = this.accumulated;
1178
1232
  }
1233
+ else if (event.type === 'permission.cancelled') {
1234
+ // A request the run stopped waiting for (OSK-12197). Without this line
1235
+ // an expired approval looked exactly like one still being asked.
1236
+ const why = typeof event.message === 'string' && event.message
1237
+ ? event.message
1238
+ : `closed (${String(event.reason ?? 'cancelled')})`;
1239
+ output.write(`\n[permission] ${String(event.toolName ?? 'tool')}: ${why}\n`);
1240
+ this.renderedText = this.accumulated;
1241
+ }
1179
1242
  else if (event.type === 'question.requested') {
1180
1243
  output.write(`\n[${event.type}]\n`);
1181
1244
  this.renderedText = this.accumulated;
@@ -36,15 +36,6 @@ export declare function plainCredentialEnvelopeWarning(args: readonly unknown[])
36
36
  * own when a local daemon can hand a credential over.
37
37
  */
38
38
  export declare const CREDENTIAL_KEY_CHANGED_NOTICE: string;
39
- /**
40
- * What the CLI prints for one auth-core warning: a replacement line, nothing
41
- * (`''`), or null for "print it as auth-core wrote it".
42
- *
43
- * Stateful by design — the process that detected a key change has already
44
- * told the person why every old credential is unreadable, so the per-value
45
- * decrypt failures that follow add nothing. A fresh process starts clean, and
46
- * a later genuinely unreadable credential still gets its own OSK-12180 line.
47
- */
48
39
  export declare function createCredentialEnvelopeWarningPresenter(): (args: readonly unknown[]) => string | null;
49
40
  type DedicatedGenerationPreparationOptions = {
50
41
  machineUuid?: string;
@@ -106,6 +106,8 @@ exports.CREDENTIAL_KEY_CHANGED_NOTICE = "note: this machine's credential key cha
106
106
  * decrypt failures that follow add nothing. A fresh process starts clean, and
107
107
  * a later genuinely unreadable credential still gets its own OSK-12180 line.
108
108
  */
109
+ /** auth-core's device-identity warning for a private key it could not open. */
110
+ const DEVICE_KEY_DECRYPT_FAILED = '[deviceIdentity] private_key_decrypt_failed';
109
111
  function createCredentialEnvelopeWarningPresenter() {
110
112
  let keyChanged = false;
111
113
  return (args) => {
@@ -130,6 +132,13 @@ function createCredentialEnvelopeWarningPresenter() {
130
132
  // already said why. A MALFORMED envelope is a different fact and still prints.
131
133
  if (keyChanged && event === 'cred_envelope.read.decrypt_failed')
132
134
  return '';
135
+ // The device private key is one of those values: auth-core's device
136
+ // identity loads it next, cannot open it, and mints a new keypair. It
137
+ // logs that through its own `[deviceIdentity]` prefix, which reached the
138
+ // terminal as a raw `[auth-core warn]` line right under the notice. The
139
+ // notice covers it; without a key change it is a real fault and prints.
140
+ if (keyChanged && message === DEVICE_KEY_DECRYPT_FAILED)
141
+ return '';
133
142
  return plainCredentialEnvelopeWarning(args);
134
143
  };
135
144
  }
@@ -48,6 +48,21 @@ export interface WriteOptions {
48
48
  * retiring it. Everything else should keep using `readFromBackend()`.
49
49
  */
50
50
  export declare function readFromFile(): AuthBundle | null;
51
+ /**
52
+ * The refresh family a stored `cli-auth.json` carries, whether or not it also
53
+ * carries an access token — for the Dedicated guest's retirement of a
54
+ * pre-broker file only (OSK-12194).
55
+ *
56
+ * `readFromFile()` treats a file with an empty `token` as no credential, which
57
+ * is right for signing in and wrong here: the realistic pre-broker shape is
58
+ * `{"token":"", "refreshToken":…, "serverOrigin":…}` once its access token
59
+ * lapsed, and a reader that returns nothing for it retires nothing. Same
60
+ * ownership rules as `readFromFile()`; never rewrites the file.
61
+ */
62
+ export declare function readRefreshFamilyFromFile(): {
63
+ refreshToken: string;
64
+ serverOrigin?: string;
65
+ } | null;
51
66
  /**
52
67
  * Delete `~/.skrr/cli-auth.json` only (the keychain is untouched). Exported
53
68
  * for the brokered-hand-off migration cleanup; `skrr logout` continues to
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.backends = void 0;
37
37
  exports.readFromFile = readFromFile;
38
+ exports.readRefreshFamilyFromFile = readRefreshFamilyFromFile;
38
39
  exports.deleteFileBackend = deleteFileBackend;
39
40
  exports.readFromBackend = readFromBackend;
40
41
  exports.describeForeignCredential = describeForeignCredential;
@@ -234,13 +235,33 @@ function judgeOwnership(bundle, me, ownSlot) {
234
235
  function readFromFile() {
235
236
  return readFileAssessed().bundle;
236
237
  }
238
+ /**
239
+ * The refresh family a stored `cli-auth.json` carries, whether or not it also
240
+ * carries an access token — for the Dedicated guest's retirement of a
241
+ * pre-broker file only (OSK-12194).
242
+ *
243
+ * `readFromFile()` treats a file with an empty `token` as no credential, which
244
+ * is right for signing in and wrong here: the realistic pre-broker shape is
245
+ * `{"token":"", "refreshToken":…, "serverOrigin":…}` once its access token
246
+ * lapsed, and a reader that returns nothing for it retires nothing. Same
247
+ * ownership rules as `readFromFile()`; never rewrites the file.
248
+ */
249
+ function readRefreshFamilyFromFile() {
250
+ const bundle = readFileAssessed({ requireAccessToken: false }).bundle;
251
+ if (!bundle?.refreshToken)
252
+ return null;
253
+ return {
254
+ refreshToken: bundle.refreshToken,
255
+ ...(bundle.serverOrigin ? { serverOrigin: bundle.serverOrigin } : {}),
256
+ };
257
+ }
237
258
  /**
238
259
  * This root's file, refused when it belongs to another config; else, under a
239
260
  * root override, the pre-override `$HOME/.skrr` file when it is positively ours.
240
261
  */
241
- function readFileAssessed() {
262
+ function readFileAssessed(opts = {}) {
242
263
  const me = currentOwner();
243
- const own = readFileAt(authFilePath(), true);
264
+ const own = readFileAt(authFilePath(), true, opts);
244
265
  if (own) {
245
266
  const judged = judgeOwnership(own, me, true);
246
267
  return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
@@ -248,7 +269,7 @@ function readFileAssessed() {
248
269
  const legacy = legacyAuthFilePath();
249
270
  if (path.resolve(legacy) === path.resolve(authFilePath()))
250
271
  return { bundle: null };
251
- const adopted = readFileAt(legacy, false);
272
+ const adopted = readFileAt(legacy, false, opts);
252
273
  if (adopted && judgeOwnership(adopted, me, false).ours)
253
274
  return { bundle: adopted };
254
275
  return { bundle: null };
@@ -265,7 +286,7 @@ function legacyFileIsOurs() {
265
286
  * Parse one credential file. `upgradeInPlace` lets the envelope migration
266
287
  * rewrite it — true only for this root's own file, never a migration source.
267
288
  */
268
- function readFileAt(p, upgradeInPlace) {
289
+ function readFileAt(p, upgradeInPlace, opts = {}) {
269
290
  if (!fs.existsSync(p))
270
291
  return null;
271
292
  let raw;
@@ -282,7 +303,9 @@ function readFileAt(p, upgradeInPlace) {
282
303
  catch {
283
304
  return null;
284
305
  }
285
- if (typeof parsed.token !== 'string' || parsed.token.length === 0) {
306
+ const requireAccessToken = opts.requireAccessToken !== false;
307
+ const hasAccessToken = typeof parsed.token === 'string' && parsed.token.length > 0;
308
+ if (!hasAccessToken && requireAccessToken) {
286
309
  return null;
287
310
  }
288
311
  // L12 — route the secret string fields through the envelope. Mixed
@@ -293,13 +316,16 @@ function readFileAt(p, upgradeInPlace) {
293
316
  const owner = parseOwner(parsed.owner);
294
317
  const ownerRoot = owner?.configRoot ? path.resolve(owner.configRoot) : undefined;
295
318
  const decryptOptions = ownerRoot && ownerRoot !== path.resolve((0, config_1.configRoot)()) ? { foreignOwnerRoot: ownerRoot } : {};
296
- const tokenResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions);
319
+ const tokenResult = hasAccessToken
320
+ ? (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions)
321
+ : { plaintext: '', needsMigration: false };
297
322
  if (tokenResult.plaintext === null) {
298
323
  // Envelope on disk but no DEK to unwrap → treat as missing so the
299
324
  // caller falls through to a clean `skrr login`.
300
325
  return null;
301
326
  }
302
- let needsMigration = tokenResult.needsMigration;
327
+ // A token-less read is never written back: there is no access token to seal.
328
+ let needsMigration = hasAccessToken && tokenResult.needsMigration;
303
329
  const bundle = { token: tokenResult.plaintext };
304
330
  if (typeof parsed.expiresAt === 'number' && Number.isFinite(parsed.expiresAt)) {
305
331
  bundle.expiresAt = parsed.expiresAt;
@@ -1334,6 +1334,9 @@ function formatCommitmentExplanation(explanation) {
1334
1334
  : explanation.verdict === 'quiet'
1335
1335
  ? `Chose to stay quiet at: ${explanation.stoppedAt}`
1336
1336
  : `Stopped at: ${explanation.stoppedAt}`);
1337
+ // What the next Run may do without asking — an Ask-mode Agent can ask
1338
+ // nobody on an unattended Run (OSK-12201). Same lines as show/preflight.
1339
+ lines.push(...permissionLines(explanation.permissions));
1337
1340
  return lines;
1338
1341
  }
1339
1342
  /**