@skrr-ai/cli 0.1.125 → 0.1.126

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
  }
@@ -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;
@@ -34,7 +34,7 @@ const TOKEN_CHAR = "(?:[A-Za-z0-9._-]|%(?:25)*(?:2[DdEe]|3[0-9]|4[1-9A-Fa-f]|5[0
34
34
  const PREVIEW_TOKEN = new RegExp(`pvw(?:_|%(?:25)*5[Ff])${TOKEN_CHAR}+`, "g");
35
35
  const UNDERSCORE = "(?:_|%(?:25)*5[Ff])";
36
36
  const DOT = "(?:\\.|%(?:25)*2[Ee])";
37
- const SIGNED_CREDENTIAL = new RegExp(`(wsi|pinv|usr|adm2)${UNDERSCORE}(?=[0-9a-f]{32}${DOT})${TOKEN_CHAR}+`, "g");
37
+ const SIGNED_CREDENTIAL = new RegExp(`(wsi|pinv|iinv|usr|adm2)${UNDERSCORE}(?=[0-9a-f]{32}${DOT})${TOKEN_CHAR}+`, "g");
38
38
  const LEGACY_OPERATOR_CODE = new RegExp(`(adm)${UNDERSCORE}(?=[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-]))[A-Za-z0-9_-]{43}`, "g");
39
39
  const QR_SIGN_IN_REDACTION = "[redacted]";
40
40
  const escapedLiteral = (value) => [...value].map((char) => {
@@ -72,7 +72,7 @@ function redactQrSignInText(value) {
72
72
  const standaloneQuery = /(?:^|[?&])direction=(?:mobile_approves_web|web_approves_mobile)(?:&|$)/.test(value) ? value.replace(QR_REQUEST_QUERY, `$1$2${QR_SIGN_IN_REDACTION}`) : value;
73
73
  return standaloneQuery.replace(QR_REQUEST_PATH, `$1${QR_SIGN_IN_REDACTION}`).replace(QR_LINK_QUERY, (_match, prefix, query) => prefix + query.replace(QR_REQUEST_QUERY, `$1$2${QR_SIGN_IN_REDACTION}`)).replace(QR_SECRET_ASSIGNMENT, `$1${QR_SIGN_IN_REDACTION}`);
74
74
  }
75
- const CREDENTIAL_HINTS = ["pvw", "wsi", "pinv", "usr", "adm"];
75
+ const CREDENTIAL_HINTS = ["pvw", "wsi", "pinv", "iinv", "usr", "adm"];
76
76
  const credentialMarker = (_match, prefix) => `${prefix}_[redacted]`;
77
77
  function redactPreviewTokens(value) {
78
78
  if (typeof value !== "string")
@@ -82,7 +82,7 @@ const DOT = '(?:\\.|%(?:25)*2[Ee])';
82
82
  * prefix is captured so the marker can say which kind it was. `usr_<40 hex>`, `usr_00000001` and `/usr_profile` have no
83
83
  * dot after exactly 32 hex characters, so they match nothing.
84
84
  */
85
- const SIGNED_CREDENTIAL = new RegExp(`(wsi|pinv|usr|adm2)${UNDERSCORE}(?=[0-9a-f]{32}${DOT})${TOKEN_CHAR}+`, 'g');
85
+ const SIGNED_CREDENTIAL = new RegExp(`(wsi|pinv|iinv|usr|adm2)${UNDERSCORE}(?=[0-9a-f]{32}${DOT})${TOKEN_CHAR}+`, 'g');
86
86
  /**
87
87
  * The older operator code: `adm_` and exactly 43 base64url characters (32 random bytes), not followed by another one.
88
88
  * A shorter or longer run of letters is a name, not this credential. The characters are the plain alphabet and not
@@ -134,7 +134,7 @@ function redactQrSignInText(value) {
134
134
  .replace(QR_SECRET_ASSIGNMENT, `$1${QR_SIGN_IN_REDACTION}`);
135
135
  }
136
136
  /** Cheap test before the older prefix-based credential expressions. */
137
- const CREDENTIAL_HINTS = ['pvw', 'wsi', 'pinv', 'usr', 'adm'];
137
+ const CREDENTIAL_HINTS = ['pvw', 'wsi', 'pinv', 'iinv', 'usr', 'adm'];
138
138
  const credentialMarker = (_match, prefix) => `${prefix}_[redacted]`;
139
139
  export function redactPreviewTokens(value) {
140
140
  if (typeof value !== 'string')
@@ -4,6 +4,10 @@
4
4
  "main": "dist/cjs/index.js",
5
5
  "types": "dist/esm/index.d.ts",
6
6
  "exports": {
7
+ "./scheduled-stream-wire": {
8
+ "types": "./dist/cjs/scheduled-stream-wire.d.cts",
9
+ "default": "./dist/cjs/scheduled-stream-wire.cjs"
10
+ },
7
11
  "./desktop-daemon-proof": {
8
12
  "import": {
9
13
  "types": "./dist/esm/desktopDaemonProof.d.ts",