@skrr-ai/cli 0.1.125 → 0.1.127

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.
@@ -25,6 +25,28 @@ export declare const DETACH_EXIT_CODE = 130;
25
25
  * answers true.
26
26
  */
27
27
  export declare const STREAM_LOST_EXIT_CODE = 75;
28
+ /**
29
+ * The signals that stop a running chat.
30
+ *
31
+ * Ctrl-C was the only one handled (OSK-2938). SIGTERM (a CI cancel, `timeout`, a process manager) and
32
+ * SIGHUP (the terminal closing) killed the CLI in under 100 ms with no cancel sent, so the agent kept
33
+ * running tools on the person's machine with nobody watching and held the folder, and the next message
34
+ * was refused as `managed_task_workspace_busy` (OSK-20677). `machines dedicated cp` already treats all
35
+ * three as cancel and exits as a shell would; this does the same.
36
+ */
37
+ export declare const INTERRUPT_SIGNALS: readonly ["SIGINT", "SIGTERM", "SIGHUP"];
38
+ export type InterruptSignal = (typeof INTERRUPT_SIGNALS)[number];
39
+ /** 128 plus the signal's number, as a shell reports a process the signal killed: 130, 143, 129. */
40
+ export declare function interruptExitCode(signal: InterruptSignal | null): number;
41
+ /**
42
+ * The cancel notice, in words that fit the signal. "Press Ctrl-C again" is wrong advice after a
43
+ * SIGTERM, and after SIGHUP nobody is looking: the terminal that would show it is gone.
44
+ */
45
+ export declare function cancelNotice(input: {
46
+ signal: InterruptSignal;
47
+ sessionId: string;
48
+ graceSeconds: number;
49
+ }): string | null;
28
50
  /**
29
51
  * Exit code for a streamed run that reached its terminal state: 1 when it
30
52
  * failed, 0 otherwise.
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MAX_HANDOVER_WAIT_SECONDS = exports.DEFAULT_HANDOVER_WAIT_SECONDS = exports.STREAM_LOST_EXIT_CODE = exports.DETACH_EXIT_CODE = void 0;
3
+ exports.MAX_HANDOVER_WAIT_SECONDS = exports.DEFAULT_HANDOVER_WAIT_SECONDS = exports.INTERRUPT_SIGNALS = exports.STREAM_LOST_EXIT_CODE = exports.DETACH_EXIT_CODE = void 0;
4
+ exports.interruptExitCode = interruptExitCode;
5
+ exports.cancelNotice = cancelNotice;
4
6
  exports.streamedResultExitCode = streamedResultExitCode;
5
7
  exports.interruptedDetachMessage = interruptedDetachMessage;
6
8
  exports.jsonlTextForm = jsonlTextForm;
@@ -49,6 +51,35 @@ exports.DETACH_EXIT_CODE = 130;
49
51
  * answers true.
50
52
  */
51
53
  exports.STREAM_LOST_EXIT_CODE = 75;
54
+ /**
55
+ * The signals that stop a running chat.
56
+ *
57
+ * Ctrl-C was the only one handled (OSK-2938). SIGTERM (a CI cancel, `timeout`, a process manager) and
58
+ * SIGHUP (the terminal closing) killed the CLI in under 100 ms with no cancel sent, so the agent kept
59
+ * running tools on the person's machine with nobody watching and held the folder, and the next message
60
+ * was refused as `managed_task_workspace_busy` (OSK-20677). `machines dedicated cp` already treats all
61
+ * three as cancel and exits as a shell would; this does the same.
62
+ */
63
+ exports.INTERRUPT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
64
+ /** 128 plus the signal's number, as a shell reports a process the signal killed: 130, 143, 129. */
65
+ function interruptExitCode(signal) {
66
+ if (signal === 'SIGTERM')
67
+ return 143;
68
+ if (signal === 'SIGHUP')
69
+ return 129;
70
+ return exports.DETACH_EXIT_CODE;
71
+ }
72
+ /**
73
+ * The cancel notice, in words that fit the signal. "Press Ctrl-C again" is wrong advice after a
74
+ * SIGTERM, and after SIGHUP nobody is looking: the terminal that would show it is gone.
75
+ */
76
+ function cancelNotice(input) {
77
+ if (input.signal === 'SIGHUP')
78
+ return null;
79
+ const again = input.signal === 'SIGINT' ? 'press Ctrl-C again' : `send ${input.signal} again`;
80
+ return (`\nCancelling run ${input.sessionId}… (waiting up to ${input.graceSeconds}s; ${again} to detach). ` +
81
+ 'A tool call already running on the runtime is not guaranteed to stop.');
82
+ }
52
83
  /**
53
84
  * Exit code for a streamed run that reached its terminal state: 1 when it
54
85
  * failed, 0 otherwise.
@@ -882,10 +913,22 @@ class AgentsChat extends base_command_1.BaseCommand {
882
913
  detach = () => resolve('detached');
883
914
  });
884
915
  let interrupts = 0;
916
+ // The signal that started the stop. Its exit status is what a script reads (130, 143 or 129).
917
+ let interruptedBy = null;
918
+ // After SIGHUP the terminal is gone and a write to it throws; the notice is lost, the stop is not.
919
+ const writeNotice = (fd, text) => {
920
+ try {
921
+ (0, node_fs_1.writeSync)(fd, text);
922
+ }
923
+ catch (_error) {
924
+ /* the terminal that would show this is closed */
925
+ }
926
+ };
885
927
  let forcedDetachAnnounced = false;
886
928
  let graceTimer = null;
887
- const onInterrupt = () => {
929
+ const onInterrupt = (signal = 'SIGINT') => {
888
930
  interrupts += 1;
931
+ interruptedBy ??= signal;
889
932
  const action = interruptAction({ interrupts, awaitingHandover: client.awaitingHandover() });
890
933
  if (action === 'stop-handover-wait') {
891
934
  // Nothing to cancel and nothing to grace-wait for: stop at once.
@@ -896,7 +939,7 @@ class AgentsChat extends base_command_1.BaseCommand {
896
939
  conversationId: started.conversationId,
897
940
  });
898
941
  if (flags.jsonl) {
899
- (0, node_fs_1.writeSync)(process.stdout.fd, `${JSON.stringify({
942
+ writeNotice(process.stdout.fd, `${JSON.stringify({
900
943
  type: 'run.detached',
901
944
  sessionId: started.sessionId,
902
945
  conversationId: started.conversationId,
@@ -906,9 +949,9 @@ class AgentsChat extends base_command_1.BaseCommand {
906
949
  })}\n`);
907
950
  }
908
951
  else {
909
- (0, node_fs_1.writeSync)(process.stderr.fd, `\n${message}\n`);
952
+ writeNotice(process.stderr.fd, `\n${message}\n`);
910
953
  }
911
- process.exitCode = exports.DETACH_EXIT_CODE;
954
+ process.exitCode = interruptExitCode(interruptedBy);
912
955
  }
913
956
  client.close();
914
957
  detach?.();
@@ -917,9 +960,13 @@ class AgentsChat extends base_command_1.BaseCommand {
917
960
  if (action === 'cancel') {
918
961
  client.cancel();
919
962
  if (!flags.jsonl) {
920
- this.log(`\nCancelling run ${started.sessionId}… (waiting up to ${CANCEL_GRACE_MS / 1000}s; ` +
921
- 'press Ctrl-C again to detach). A tool call already running on the runtime is ' +
922
- 'not guaranteed to stop.');
963
+ const notice = cancelNotice({
964
+ signal,
965
+ sessionId: started.sessionId,
966
+ graceSeconds: CANCEL_GRACE_MS / 1000,
967
+ });
968
+ if (notice)
969
+ writeNotice(process.stdout.fd, `${notice}\n`);
923
970
  }
924
971
  graceTimer = setTimeout(() => detach?.(), CANCEL_GRACE_MS);
925
972
  graceTimer.unref?.();
@@ -933,7 +980,7 @@ class AgentsChat extends base_command_1.BaseCommand {
933
980
  forced: true,
934
981
  });
935
982
  if (flags.jsonl) {
936
- (0, node_fs_1.writeSync)(process.stdout.fd, `${JSON.stringify({
983
+ writeNotice(process.stdout.fd, `${JSON.stringify({
937
984
  type: 'run.detached',
938
985
  sessionId: started.sessionId,
939
986
  conversationId: started.conversationId,
@@ -942,13 +989,14 @@ class AgentsChat extends base_command_1.BaseCommand {
942
989
  })}\n`);
943
990
  }
944
991
  else {
945
- (0, node_fs_1.writeSync)(process.stderr.fd, `\n${message}\n`);
992
+ writeNotice(process.stderr.fd, `\n${message}\n`);
946
993
  }
947
- process.exitCode = exports.DETACH_EXIT_CODE;
994
+ process.exitCode = interruptExitCode(interruptedBy);
948
995
  }
949
996
  detach?.();
950
997
  };
951
- process.on('SIGINT', onInterrupt);
998
+ for (const name of exports.INTERRUPT_SIGNALS)
999
+ process.on(name, onInterrupt);
952
1000
  try {
953
1001
  // OSK-2944 — the run was ACCEPTED before we ever tried to attach. If the
954
1002
  // stream cannot be attached, that is a lost view of a live run, not a
@@ -988,7 +1036,7 @@ class AgentsChat extends base_command_1.BaseCommand {
988
1036
  // 130 only when a signal actually caused this; otherwise the stream was
989
1037
  // lost and claiming SIGINT lies to every script reading the code
990
1038
  // (OSK-7660).
991
- const exit = interrupts > 0 ? exports.DETACH_EXIT_CODE : exports.STREAM_LOST_EXIT_CODE;
1039
+ const exit = interrupts > 0 ? interruptExitCode(interruptedBy) : exports.STREAM_LOST_EXIT_CODE;
992
1040
  if (flags.jsonl) {
993
1041
  (0, agentic_stream_1.writeJsonLine)(process.stdout, {
994
1042
  type: 'run.detached',
@@ -1039,15 +1087,23 @@ class AgentsChat extends base_command_1.BaseCommand {
1039
1087
  this.error(noOutput, { exit: 1 });
1040
1088
  this.log(result.text);
1041
1089
  }
1042
- else if (result.status === 'aborted') {
1090
+ else if (result.status === 'aborted' && interruptedBy !== 'SIGHUP') {
1043
1091
  // Streaming human mode: say the cancel took, rather than leave the keypress warning last.
1092
+ // (Not after SIGHUP: the terminal that would show it is gone.)
1044
1093
  this.log(`\n${cancelledRunMessage({ sessionId: started.sessionId, conversationId: started.conversationId })}`);
1045
1094
  }
1095
+ // A run stopped by SIGTERM or SIGHUP ends the way a shell reports a process the signal killed,
1096
+ // so a script can tell a cancelled run from one that finished. Ctrl-C keeps its existing status
1097
+ // (a cancel that took is a success, a detach is 130).
1098
+ if (interruptedBy === 'SIGTERM' || interruptedBy === 'SIGHUP') {
1099
+ process.exitCode = interruptExitCode(interruptedBy);
1100
+ }
1046
1101
  }
1047
1102
  finally {
1048
1103
  if (graceTimer)
1049
1104
  clearTimeout(graceTimer);
1050
- process.removeListener('SIGINT', onInterrupt);
1105
+ for (const name of exports.INTERRUPT_SIGNALS)
1106
+ process.removeListener(name, onInterrupt);
1051
1107
  client.close();
1052
1108
  }
1053
1109
  }
@@ -8,8 +8,8 @@ import { BaseCommand } from '../../../base-command';
8
8
  * Wraps `GET /api/agents/:agent_id/goals` (data-provider `listAgentGoals`),
9
9
  * which returns goals where the agent is owner, DRI, or a contributor, each
10
10
  * enriched with `_agentGoalRole` (which of the three) and `_completionPercent`.
11
- * The endpoint sorts by most-recently-updated and caps at 100 goals; there is
12
- * no pagination cursor, so neither is there a `--cursor` flag here.
11
+ * The endpoint sorts by most-recently-updated and supports cursor pagination.
12
+ * This command currently shows its first page of up to 100 goals.
13
13
  */
14
14
  export default class AgentsGoalsList extends BaseCommand {
15
15
  static description: string;
@@ -14,8 +14,8 @@ const format_1 = require("../../../lib/format");
14
14
  * Wraps `GET /api/agents/:agent_id/goals` (data-provider `listAgentGoals`),
15
15
  * which returns goals where the agent is owner, DRI, or a contributor, each
16
16
  * enriched with `_agentGoalRole` (which of the three) and `_completionPercent`.
17
- * The endpoint sorts by most-recently-updated and caps at 100 goals; there is
18
- * no pagination cursor, so neither is there a `--cursor` flag here.
17
+ * The endpoint sorts by most-recently-updated and supports cursor pagination.
18
+ * This command currently shows its first page of up to 100 goals.
19
19
  */
20
20
  class AgentsGoalsList extends base_command_1.BaseCommand {
21
21
  static description = 'List the goals an agent owns, is DRI for, or contributes to';
@@ -6,6 +6,7 @@ export default class GoalsCreate extends BaseCommand {
6
6
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
7
  'from-json': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
8
  title: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ 'idempotency-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
11
  level: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
12
  'target-date': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -24,6 +24,9 @@ class GoalsCreate extends base_command_1.BaseCommand {
24
24
  description: 'Path to a JSON file (or "-" for stdin) to use as the request body',
25
25
  }),
26
26
  title: core_1.Flags.string({ description: 'Goal title (required unless set via --from-json)' }),
27
+ 'idempotency-key': core_1.Flags.string({
28
+ description: 'Stable key for retries of this exact Goal creation request',
29
+ }),
27
30
  description: core_1.Flags.string({ description: 'Goal description' }),
28
31
  level: core_1.Flags.string({
29
32
  description: 'Goal level',
@@ -126,7 +129,7 @@ class GoalsCreate extends base_command_1.BaseCommand {
126
129
  payload.labelIds = resolvedLabelIds;
127
130
  let created;
128
131
  try {
129
- created = await data_provider_1.dataService.createGoal(payload);
132
+ created = await data_provider_1.dataService.createGoal(payload, flags['idempotency-key'] ? { idempotencyKey: flags['idempotency-key'] } : undefined);
130
133
  if (flags.space) {
131
134
  const goalId = created.id;
132
135
  if (!goalId || typeof goalId !== 'string') {
@@ -4,20 +4,7 @@ const core_1 = require("@oclif/core");
4
4
  const data_provider_1 = require("@skrr-ai/data-provider");
5
5
  const base_command_1 = require("../../base-command");
6
6
  const format_1 = require("../../lib/format");
7
- /**
8
- * The one class of goal change this log does not contain.
9
- *
10
- * `PATCH /api/goals/:goalId/planner-config` writes the goal through
11
- * `Goal.findOneAndUpdate` directly rather than `GoalModel.updateGoal`, which is
12
- * where `RevisionService` is called from — so arming, re-timing or disarming the
13
- * auto-planner (and any `maxActiveTasks` change made through that route) leaves
14
- * no revision. "Who turned the planner on for this goal, and when" is the audit
15
- * question a shared goal actually raises, and the table answers it with silence.
16
- *
17
- * Naming the gap is the honest thing the CLI can do; closing it is a server
18
- * change. DELETE THIS FOOTER once the planner-config route records a revision.
19
- */
20
- const PLANNER_GAP_NOTE = 'Note: auto-planner changes made with `goals planner-config` are not recorded as revisions.';
7
+ const revision_actor_1 = require("../../lib/revision-actor");
21
8
  class GoalsRevisions extends base_command_1.BaseCommand {
22
9
  static description = 'List revisions for a goal';
23
10
  static examples = [
@@ -47,25 +34,22 @@ class GoalsRevisions extends base_command_1.BaseCommand {
47
34
  const items = result?.data ?? [];
48
35
  if (items.length === 0) {
49
36
  this.log('No revisions.');
50
- this.log(PLANNER_GAP_NOTE);
51
37
  return;
52
38
  }
53
39
  const rows = items.map((rev) => ({
54
40
  version: String(rev.revisionNumber ?? '-'),
55
41
  changeType: String(rev.changeType ?? '-'),
56
- user: String(rev.userId ?? '-'),
42
+ user: (0, revision_actor_1.revisionActorLabel)(rev),
57
43
  changes: String(Array.isArray(rev.changes) ? rev.changes.length : 0),
58
44
  when: rev.createdAt ? String(rev.createdAt).slice(0, 19).replace('T', ' ') : '-',
59
45
  }));
60
46
  (0, format_1.renderTable)(rows, [
61
47
  { key: 'version', header: 'REV' },
62
48
  { key: 'changeType', header: 'CHANGE' },
63
- { key: 'user', header: 'USER' },
49
+ { key: 'user', header: 'ACTOR' },
64
50
  { key: 'changes', header: 'FIELDS' },
65
51
  { key: 'when', header: 'AT' },
66
52
  ], (line) => this.log(line));
67
- this.log('');
68
- this.log(PLANNER_GAP_NOTE);
69
53
  }
70
54
  }
71
55
  exports.default = GoalsRevisions;
@@ -5,6 +5,7 @@ const data_provider_1 = require("@skrr-ai/data-provider");
5
5
  const base_command_1 = require("../../base-command");
6
6
  const format_1 = require("../../lib/format");
7
7
  const space_output_1 = require("../../lib/space-output");
8
+ const revision_actor_1 = require("../../lib/revision-actor");
8
9
  class SpacesRevisions extends base_command_1.BaseCommand {
9
10
  static description = 'List revisions for a space';
10
11
  static examples = [
@@ -40,14 +41,14 @@ class SpacesRevisions extends base_command_1.BaseCommand {
40
41
  const rows = items.map((rev) => ({
41
42
  version: String(rev.revisionNumber ?? '-'),
42
43
  changeType: String(rev.changeType ?? '-'),
43
- user: String(rev.userId ?? '-'),
44
+ user: (0, revision_actor_1.revisionActorLabel)(rev),
44
45
  changes: String(Array.isArray(rev.changes) ? rev.changes.length : 0),
45
46
  when: rev.createdAt ? String(rev.createdAt).slice(0, 19).replace('T', ' ') : '-',
46
47
  }));
47
48
  (0, format_1.renderTable)(rows, [
48
49
  { key: 'version', header: 'REV' },
49
50
  { key: 'changeType', header: 'CHANGE' },
50
- { key: 'user', header: 'USER' },
51
+ { key: 'user', header: 'ACTOR' },
51
52
  { key: 'changes', header: 'FIELDS' },
52
53
  { key: 'when', header: 'AT' },
53
54
  ], (line) => this.log(line));
@@ -5,6 +5,7 @@ const core_1 = require("@oclif/core");
5
5
  const data_provider_1 = require("@skrr-ai/data-provider");
6
6
  const base_command_1 = require("../../base-command");
7
7
  const format_1 = require("../../lib/format");
8
+ const revision_actor_1 = require("../../lib/revision-actor");
8
9
  /** " (via CLI credential)" when the revision recorded its credential channel. */
9
10
  function revisionChannelSuffix(rev) {
10
11
  const via = rev.metadata?.via;
@@ -46,14 +47,14 @@ class TasksRevisions extends base_command_1.BaseCommand {
46
47
  version: String(rev.revisionNumber ?? '-'),
47
48
  changeType: String(rev.changeType ?? '-'),
48
49
  // The channel the person's credential came through, when recorded (OSK-16621).
49
- user: `${String(rev.userId ?? '-')}${revisionChannelSuffix(rev)}`,
50
+ user: `${(0, revision_actor_1.revisionActorLabel)(rev)}${rev.userId != null ? revisionChannelSuffix(rev) : ''}`,
50
51
  changes: String(Array.isArray(rev.changes) ? rev.changes.length : 0),
51
52
  when: rev.createdAt ? String(rev.createdAt).slice(0, 19).replace('T', ' ') : '-',
52
53
  }));
53
54
  (0, format_1.renderTable)(rows, [
54
55
  { key: 'version', header: 'REV' },
55
56
  { key: 'changeType', header: 'CHANGE' },
56
- { key: 'user', header: 'USER' },
57
+ { key: 'user', header: 'ACTOR' },
57
58
  { key: 'changes', header: 'FIELDS' },
58
59
  { key: 'when', header: 'AT' },
59
60
  ], (line) => this.log(line));
@@ -29,6 +29,8 @@ function describeFireAuditDetail(detail, fireId, triggerId) {
29
29
  lines.push(`Duration: ${String(detail.durationMs)}ms`);
30
30
  if (detail.conversationId)
31
31
  lines.push(`Conversation: ${String(detail.conversationId)}`);
32
+ // Tool calls refused during a turn that was delivered: `triggers show` points here for the facts.
33
+ lines.push(...(0, triggers_1.fireRefusalLines)(detail.metadata));
32
34
  return lines;
33
35
  }
34
36
  class TriggersFireDetail extends base_command_1.BaseCommand {
@@ -0,0 +1,2 @@
1
+ /** Render the actual actor without presenting a system or Agent as a user. */
2
+ export declare function revisionActorLabel(revision: Record<string, unknown>): string;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.revisionActorLabel = revisionActorLabel;
4
+ /** Render the actual actor without presenting a system or Agent as a user. */
5
+ function revisionActorLabel(revision) {
6
+ if (revision.userId != null)
7
+ return String(revision.userId);
8
+ const actor = revision.actor;
9
+ if (actor && typeof actor === 'object' && !Array.isArray(actor)) {
10
+ const { kind, id } = actor;
11
+ if ((kind === 'system' || kind === 'agent') && typeof id === 'string' && id) {
12
+ return `${kind}: ${id}`;
13
+ }
14
+ }
15
+ return '-';
16
+ }
@@ -158,6 +158,15 @@ export interface TriggerFireRecord {
158
158
  replayBlockedReason?: string;
159
159
  replayRequirements?: string[];
160
160
  replayContext?: unknown;
161
+ /** Tool calls refused during a turn that was delivered (OSK-20776). */
162
+ permissionRefusals?: {
163
+ count?: number;
164
+ tools?: string[];
165
+ unattended?: boolean;
166
+ layer?: string;
167
+ };
168
+ /** The refusal record could not be read for this fire, which is not "none were refused". */
169
+ permissionRefusalsUnread?: boolean;
161
170
  };
162
171
  }
163
172
  export interface TriggerListResponse {
@@ -516,6 +525,29 @@ export declare function writeModeLabel(writeMode: string): string;
516
525
  * the configured base URL rather than guessed.
517
526
  */
518
527
  export declare function webhookIngestUrl(baseURL: string, triggerId: string): string;
528
+ /**
529
+ * What `triggers show` adds when the last fire DELIVERED but had tool calls
530
+ * refused (OSK-20776). The outcome of such a fire is `succeeded`, so the
531
+ * `Fires:` line reads as success; this says what the string after `last` means
532
+ * and what a person can do about it.
533
+ *
534
+ * It states the mechanism and not this fire's cause, because `lastResult` names
535
+ * the refused kinds only. Whether a fire was unattended, and which layer
536
+ * refused, is on the fire record (`fire-detail`), and this points there.
537
+ *
538
+ * It does not recommend a mode. `bypassPermissions` is the only mode under which
539
+ * an unattended shell call runs, and it runs EVERY call without asking, which is
540
+ * a decision about what this agent may do on its own (a release job included),
541
+ * not a hint to paste.
542
+ */
543
+ export declare function toolsRefusedHintLines(trigger: TriggerDefinition, kinds: readonly string[], bin?: string): string[];
544
+ /**
545
+ * The refusal facts a fire record holds, as `fire-detail` lines. Nothing when
546
+ * the fire recorded none. A fire whose record could not be read says so: that
547
+ * is not the same as no refusal, and a reader must not be able to mistake it
548
+ * for one.
549
+ */
550
+ export declare function fireRefusalLines(metadata: unknown): string[];
519
551
  export declare function renderTriggerSummary(trigger: TriggerDefinition, log: (line: string) => void, options?: {
520
552
  baseURL?: string;
521
553
  }): void;
@@ -48,6 +48,8 @@ exports.formatDuplicateSubscriptionWarning = formatDuplicateSubscriptionWarning;
48
48
  exports.armedStateLabel = armedStateLabel;
49
49
  exports.writeModeLabel = writeModeLabel;
50
50
  exports.webhookIngestUrl = webhookIngestUrl;
51
+ exports.toolsRefusedHintLines = toolsRefusedHintLines;
52
+ exports.fireRefusalLines = fireRefusalLines;
51
53
  exports.renderTriggerSummary = renderTriggerSummary;
52
54
  exports.renderLegacyRuns = renderLegacyRuns;
53
55
  exports.renderFireAudit = renderFireAudit;
@@ -1276,6 +1278,65 @@ const WEBHOOK_INGEST_KINDS = new Set(['on_demand', 'form', 'chat_message']);
1276
1278
  function webhookIngestUrl(baseURL, triggerId) {
1277
1279
  return `${String(baseURL).replace(/\/+$/, '')}/api/triggers/webhook/${encodeURIComponent(triggerId)}`;
1278
1280
  }
1281
+ /** An id or token that is short and plain, so it is safe in a command a person will paste. */
1282
+ const PLAIN_TOKEN = /^[A-Za-z0-9_.-]{1,64}$/;
1283
+ /**
1284
+ * What `triggers show` adds when the last fire DELIVERED but had tool calls
1285
+ * refused (OSK-20776). The outcome of such a fire is `succeeded`, so the
1286
+ * `Fires:` line reads as success; this says what the string after `last` means
1287
+ * and what a person can do about it.
1288
+ *
1289
+ * It states the mechanism and not this fire's cause, because `lastResult` names
1290
+ * the refused kinds only. Whether a fire was unattended, and which layer
1291
+ * refused, is on the fire record (`fire-detail`), and this points there.
1292
+ *
1293
+ * It does not recommend a mode. `bypassPermissions` is the only mode under which
1294
+ * an unattended shell call runs, and it runs EVERY call without asking, which is
1295
+ * a decision about what this agent may do on its own (a release job included),
1296
+ * not a hint to paste.
1297
+ */
1298
+ function toolsRefusedHintLines(trigger, kinds, bin = 'skrr') {
1299
+ const scopedAgent = trigger.scope?.kind === 'agent' ? trigger.scope.refs?.agentId : undefined;
1300
+ const agent = typeof scopedAgent === 'string' && PLAIN_TOKEN.test(scopedAgent) ? scopedAgent : '<agent>';
1301
+ const triggerId = typeof trigger.id === 'string' && PLAIN_TOKEN.test(trigger.id) ? trigger.id : '<trigger-id>';
1302
+ const what = kinds.length > 0 ? kinds.join(', ') : 'some tool calls';
1303
+ return [
1304
+ `Refused: ${what} - the last fire delivered, but these tool calls were refused`,
1305
+ ' A scheduled fire runs unattended: a call that would need approval is refused, because no one is there to give it.',
1306
+ " To allow it, change the agent's permission mode (bypassPermissions runs every tool call without asking, so decide that on purpose):",
1307
+ ` ${bin} agents update ${agent} --default-permission-mode <mode> (modes: ${bin} agents update --help)`,
1308
+ ` What was refused, per fire: ${bin} triggers audit ${triggerId}, then ${bin} triggers fire-detail ${triggerId} <fire-id>`,
1309
+ ];
1310
+ }
1311
+ /**
1312
+ * The refusal facts a fire record holds, as `fire-detail` lines. Nothing when
1313
+ * the fire recorded none. A fire whose record could not be read says so: that
1314
+ * is not the same as no refusal, and a reader must not be able to mistake it
1315
+ * for one.
1316
+ */
1317
+ function fireRefusalLines(metadata) {
1318
+ if (!metadata || typeof metadata !== 'object')
1319
+ return [];
1320
+ const { permissionRefusals, permissionRefusalsUnread } = metadata;
1321
+ const lines = [];
1322
+ if (permissionRefusals && typeof permissionRefusals === 'object') {
1323
+ const { count, tools, unattended, layer } = permissionRefusals;
1324
+ const kinds = (Array.isArray(tools) ? tools : []).filter((tool) => typeof tool === 'string' && PLAIN_TOKEN.test(tool));
1325
+ const calls = typeof count === 'number' && Number.isFinite(count) && count > 0
1326
+ ? `${count} tool call${count === 1 ? '' : 's'}`
1327
+ : 'tool calls';
1328
+ const parts = [`${calls} refused${kinds.length > 0 ? ` (${kinds.join(', ')})` : ''}`];
1329
+ if (unattended === true)
1330
+ parts.push('unattended, so no one could grant them');
1331
+ if (typeof layer === 'string' && PLAIN_TOKEN.test(layer))
1332
+ parts.push(`refused by ${layer}`);
1333
+ lines.push(`Refused: ${parts.join('; ')}`);
1334
+ }
1335
+ if (permissionRefusalsUnread === true) {
1336
+ lines.push('Refused: unknown - the refusal record could not be read for this fire; that is not the same as none');
1337
+ }
1338
+ return lines;
1339
+ }
1279
1340
  function renderTriggerSummary(trigger, log, options = {}) {
1280
1341
  log(`ID: ${trigger.id ?? '-'}`);
1281
1342
  log(`Kind: ${trigger.kind ?? '-'}`);
@@ -1389,6 +1450,11 @@ function renderTriggerSummary(trigger, log, options = {}) {
1389
1450
  parts.push(`last ${audit.lastResult}`);
1390
1451
  log(`Fires: ${parts.join(', ')}`);
1391
1452
  }
1453
+ // `last tools_refused:...` above is a success that held a refusal. Say what that means.
1454
+ const refused = (0, data_provider_1.parseToolsRefusedResult)(audit.lastResult);
1455
+ if (refused)
1456
+ for (const line of toolsRefusedHintLines(trigger, refused))
1457
+ log(line);
1392
1458
  // The reason, at the surface a reader reaches for — not only in `--json`.
1393
1459
  if (audit.lastError)
1394
1460
  log(`Last error: ${audit.lastError}`);
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ // Content-addressed source-envelope manifest. A manifest is completeness
4
+ // evidence only after an authenticated native response binds it to its exact
5
+ // dispatch, and every listed envelope has been independently retained.
6
+ const { createHash } = require('node:crypto');
7
+ const MAX_EVENTS = 1024;
8
+ const MAX_EVENT_BYTES = 256 * 1024;
9
+ const MAX_TOTAL_BYTES = 8 * 1024 * 1024;
10
+ const HASH = /^[0-9a-f]{64}$/;
11
+ const id = value => typeof value === 'string' && value.length > 0 && value.length <= 240;
12
+ const digest = value => createHash('sha256').update(value, 'utf8').digest('hex');
13
+
14
+ function sorted(value) {
15
+ if (Array.isArray(value)) return value.map(sorted);
16
+ if (value && typeof value === 'object') return Object.fromEntries(
17
+ Object.keys(value).sort().map(key => [key, sorted(value[key])]));
18
+ return value;
19
+ }
20
+ function normalize(envelope) {
21
+ // JSON round-trip defines the wire representation, rejects cycles, and
22
+ // removes undefined properties in the same way as the uploader.
23
+ const value = JSON.parse(JSON.stringify(envelope));
24
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
25
+ !id(value.id) || !value.ev || typeof value.ev !== 'object' || Array.isArray(value.ev))
26
+ throw new TypeError('Invalid scheduled stream envelope');
27
+ // These fields are owned by the server or are independently authenticated
28
+ // native receipts, not part of the replayable provider stream.
29
+ for (const key of ['__seq', 'userSeq', '__degraded', 'messageId', 'managedEventType']) delete value[key];
30
+ for (const key of ['autonomousSourceExecutorTerminal', 'autonomousSourceExecutorNotStarted',
31
+ 'ordinaryExecutorTerminal', 'personalContextInvocationTerminal', 'personalContextInvocationNotStarted'])
32
+ delete value.ev[key];
33
+ return JSON.stringify(sorted(value));
34
+ }
35
+ function fingerprint(envelope) {
36
+ const json = normalize(envelope);
37
+ const bytes = Buffer.byteLength(json, 'utf8');
38
+ if (bytes > MAX_EVENT_BYTES) throw new RangeError('Scheduled stream envelope exceeds the limit');
39
+ return { hash: digest(json), bytes, json };
40
+ }
41
+ function create(binding) {
42
+ if (!binding || ![binding.runId, binding.sessionId, binding.requestId, binding.dispatchId].every(id))
43
+ throw new TypeError('Invalid scheduled stream binding');
44
+ let closed = false;
45
+ let unavailable = null;
46
+ let totalBytes = 0;
47
+ const hashes = [];
48
+ return {
49
+ add(envelopes) {
50
+ if (closed) throw new Error('Scheduled stream manifest is already closed');
51
+ if (unavailable || !Array.isArray(envelopes)) return;
52
+ for (const envelope of envelopes) {
53
+ if (hashes.length >= MAX_EVENTS) { unavailable = 'event_limit'; break; }
54
+ let entry;
55
+ try { entry = fingerprint(envelope); }
56
+ catch { unavailable = 'invalid_or_oversize_event'; break; }
57
+ if (totalBytes + entry.bytes > MAX_TOTAL_BYTES) { unavailable = 'byte_limit'; break; }
58
+ hashes.push(entry.hash);
59
+ totalBytes += entry.bytes;
60
+ }
61
+ // A partial prefix is never a manifest.
62
+ if (unavailable) { hashes.length = 0; totalBytes = 0; }
63
+ },
64
+ finish() {
65
+ if (closed) throw new Error('Scheduled stream manifest is already closed');
66
+ closed = true;
67
+ if (unavailable) return { unavailable };
68
+ return { manifest: { version: 1, algorithm: 'sha256', runId: binding.runId,
69
+ sessionId: binding.sessionId, requestId: binding.requestId, dispatchId: binding.dispatchId,
70
+ count: hashes.length, totalBytes, hashes: [...hashes] } };
71
+ },
72
+ };
73
+ }
74
+ function validate(manifest, binding) {
75
+ return Boolean(manifest && typeof manifest === 'object' && !Array.isArray(manifest) &&
76
+ Object.keys(manifest).sort().join(',') === 'algorithm,count,dispatchId,hashes,requestId,runId,sessionId,totalBytes,version' &&
77
+ manifest.version === 1 && manifest.algorithm === 'sha256' &&
78
+ ['runId', 'sessionId', 'requestId', 'dispatchId'].every(key => id(binding?.[key]) && manifest[key] === binding[key]) &&
79
+ Number.isSafeInteger(manifest.count) && manifest.count >= 0 && manifest.count <= MAX_EVENTS &&
80
+ Number.isSafeInteger(manifest.totalBytes) && manifest.totalBytes >= 0 && manifest.totalBytes <= MAX_TOTAL_BYTES &&
81
+ Array.isArray(manifest.hashes) && manifest.hashes.length === manifest.count &&
82
+ manifest.hashes.every(value => typeof value === 'string' && HASH.test(value)));
83
+ }
84
+ function verify(manifest, binding, envelopes) {
85
+ if (!validate(manifest, binding) || !Array.isArray(envelopes) || envelopes.length !== manifest.count)
86
+ return false;
87
+ let totalBytes = 0;
88
+ try {
89
+ for (let index = 0; index < envelopes.length; index += 1) {
90
+ const entry = fingerprint(envelopes[index]);
91
+ if (entry.hash !== manifest.hashes[index]) return false;
92
+ totalBytes += entry.bytes;
93
+ if (totalBytes > MAX_TOTAL_BYTES) return false;
94
+ }
95
+ } catch { return false; }
96
+ return totalBytes === manifest.totalBytes;
97
+ }
98
+ module.exports = { MAX_EVENTS, MAX_EVENT_BYTES, MAX_TOTAL_BYTES, normalize, fingerprint, create, validate, verify };
@@ -0,0 +1,15 @@
1
+ export interface ScheduledStreamBinding { runId: string; sessionId: string; requestId: string; dispatchId: string }
2
+ export interface ScheduledStreamManifest extends ScheduledStreamBinding {
3
+ version: 1; algorithm: 'sha256'; count: number; totalBytes: number; hashes: string[];
4
+ }
5
+ export const MAX_EVENTS: number;
6
+ export const MAX_EVENT_BYTES: number;
7
+ export const MAX_TOTAL_BYTES: number;
8
+ export function normalize(envelope: unknown): string;
9
+ export function fingerprint(envelope: unknown): { hash: string; bytes: number; json: string };
10
+ export function create(binding: ScheduledStreamBinding): {
11
+ add(envelopes: unknown): void;
12
+ finish(): { manifest: ScheduledStreamManifest } | { unavailable: string };
13
+ };
14
+ export function validate(manifest: unknown, binding: ScheduledStreamBinding): boolean;
15
+ export function verify(manifest: unknown, binding: ScheduledStreamBinding, envelopes: unknown): boolean;