@dolusoft/command-console-core 1.0.1 → 1.1.0

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.
@@ -1,4 +1,4 @@
1
- import { quoteArgument, tokenize } from "@dolusoft/command-protocol";
1
+ import { checkInputSchema, envelopeBytes, envelopeMaxBytes, formatCommandLine, parseEnvelope, parseOptions, quoteArgument, tokenize, validateInput } from "@dolusoft/command-protocol";
2
2
  // src/shared/registry.ts
3
3
  var createHooks = () => ({
4
4
  lineSubmitted: [],
@@ -316,8 +316,9 @@ var checkName = (name, where) => {
316
316
  /**
317
317
  * Validates the commands (core README: Options, C-56): names and aliases are
318
318
  * non-empty, without whitespace or quotes, unique across every name and alias;
319
- * `fromArg` is a non-negative integer; `run` is a function. Throws on the
320
- * first violation.
319
+ * `fromArg` is a non-negative integer; `run` is a function; `input` is an
320
+ * input schema, `danger` a known value and `normalize` a function (C-76).
321
+ * Throws on the first violation.
321
322
  */
322
323
  var validateCommands = (commands) => {
323
324
  if (!Array.isArray(commands)) throw new Error("commands must be an array of command definitions.");
@@ -332,8 +333,24 @@ var validateCommands = (commands) => {
332
333
  const { sensitive } = command;
333
334
  if (sensitive !== void 0 && typeof sensitive !== "boolean" && !(typeof sensitive === "object" && sensitive !== null && Number.isInteger(sensitive.fromArg) && sensitive.fromArg >= 0)) throw new Error(`command ${command.name}: sensitive must be a boolean or { fromArg } with a non-negative integer.`);
334
335
  if (typeof command.run !== "function") throw new Error(`command ${command.name}: run must be a function.`);
336
+ checkDescription(command);
335
337
  }
336
338
  };
339
+ var dangers = [
340
+ "read",
341
+ "change",
342
+ "irreversible"
343
+ ];
344
+ /** The checks of C-76: `input`, `danger` and `normalize`. */
345
+ var checkDescription = (command) => {
346
+ if (command.input !== void 0) try {
347
+ checkInputSchema(command.input);
348
+ } catch (error) {
349
+ throw new TypeError(`command ${command.name}: ${error.message}`, { cause: error });
350
+ }
351
+ if (command.danger !== void 0 && !dangers.includes(command.danger)) throw new Error(`command ${command.name}: danger must be 'read', 'change' or 'irreversible'.`);
352
+ if (command.normalize !== void 0 && typeof command.normalize !== "function") throw new Error(`command ${command.name}: normalize must be a function.`);
353
+ };
337
354
  /** The JSON spec of a command, without its functions (`getCommands`). */
338
355
  var specOf = (command) => {
339
356
  const spec = {
@@ -343,6 +360,8 @@ var specOf = (command) => {
343
360
  if (command.summary !== void 0) spec.summary = command.summary;
344
361
  if (command.usage !== void 0) spec.usage = command.usage;
345
362
  if (command.sensitive !== void 0) spec.sensitive = typeof command.sensitive === "object" ? { fromArg: command.sensitive.fromArg } : command.sensitive;
363
+ if (command.input !== void 0) spec.input = JSON.parse(JSON.stringify(command.input));
364
+ if (command.danger !== void 0) spec.danger = command.danger;
346
365
  return spec;
347
366
  };
348
367
  /** A command's return as a response (README: Running a line, End action). */
@@ -361,6 +380,73 @@ var blocksOf = (output) => {
361
380
  };
362
381
  /** Whether `value` is a promise or another thenable. */
363
382
  var isThenable = (value) => (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
383
+ // src/core/dispatch/operation.ts
384
+ /** How many closed envelope ids a console remembers (C-81). */
385
+ var closedLimit = 500;
386
+ /** The longest `summary` an outcome carries, in code points (C-82). */
387
+ var summaryLimit = 200;
388
+ /** Whether two JSON values are equal, objects compared by their keys. */
389
+ var sameJson = (a, b) => {
390
+ if (a === b) return true;
391
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null || Array.isArray(a) !== Array.isArray(b)) return false;
392
+ const left = Object.keys(a);
393
+ const right = b;
394
+ return left.length === Object.keys(right).length && left.every((key) => Object.hasOwn(right, key) && sameJson(a[key], right[key]));
395
+ };
396
+ /** Remembers `id` among the newest `closedLimit` closed ids. */
397
+ var rememberClosed = (closed, id) => {
398
+ closed.delete(id);
399
+ closed.add(id);
400
+ if (closed.size > closedLimit) closed.delete(closed.values().next().value);
401
+ };
402
+ /** `summary` cut to `summaryLimit` code points; `undefined` unless a string. */
403
+ var cutSummary = (summary) => typeof summary === "string" ? Array.from(summary).slice(0, summaryLimit).join("") : void 0;
404
+ /** An RFC 4122 version 4 UUID from 16 random bytes. */
405
+ var uuidV4 = (bytes) => {
406
+ bytes[6] = bytes[6] & 15 | 64;
407
+ bytes[8] = bytes[8] & 63 | 128;
408
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
409
+ return [
410
+ hex.slice(0, 4),
411
+ hex.slice(4, 6),
412
+ hex.slice(6, 8),
413
+ hex.slice(8, 10),
414
+ hex.slice(10, 16)
415
+ ].map((part) => part.join("")).join("-");
416
+ };
417
+ /**
418
+ * The page's UUID function: `crypto.randomUUID`, else a v4 UUID from
419
+ * `crypto.getRandomValues` (a page on an insecure origin has only the
420
+ * latter), else none.
421
+ */
422
+ var randomUUID = () => {
423
+ const crypto = globalThis.crypto;
424
+ if (typeof crypto?.randomUUID === "function") return () => crypto.randomUUID();
425
+ if (typeof crypto?.getRandomValues === "function") return () => uuidV4(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)));
426
+ };
427
+ /**
428
+ * What makes envelope ids: `createEnvelopeId`, else the page's UUID function.
429
+ * Throws when a gate is given and neither exists (C-78).
430
+ */
431
+ var envelopeIds = (options) => {
432
+ const create = options.createEnvelopeId ?? randomUUID();
433
+ if (options.gate !== void 0 && create === void 0) throw new Error("A console with a gate needs crypto.randomUUID, crypto.getRandomValues or a createEnvelopeId option.");
434
+ return create;
435
+ };
436
+ /**
437
+ * Checks the gate options at creation and on every `setOptions` (C-78):
438
+ * envelope ids can be made, and `echoTimeoutMs` is a finite number above 0.
439
+ */
440
+ var checkGateOptions = (options) => {
441
+ envelopeIds(options);
442
+ const ms = options.echoTimeoutMs;
443
+ if (ms !== void 0 && !(typeof ms === "number" && Number.isFinite(ms) && ms > 0)) throw new TypeError(`echoTimeoutMs must be a finite number above 0 (got ${typeof ms === "number" ? ms : typeof ms}).`);
444
+ };
445
+ /**
446
+ * Whether a command can travel in an envelope: it has `input` and `danger`
447
+ * and no `sensitive` (C-79).
448
+ */
449
+ var describedCommand = (command) => command.input !== void 0 && command.danger !== void 0 && (command.sensitive === void 0 || command.sensitive === false);
364
450
  // src/core/console/consoleFeature.ts
365
451
  var controllable$1 = [
366
452
  "entries",
@@ -420,7 +506,9 @@ var consoleFeature = {
420
506
  const previous = record.options;
421
507
  const given = resolveUpdater(updater, previous);
422
508
  if (!sameFeatures(previous.features, given.features)) throw new Error("setOptions cannot change the feature set.");
509
+ if (given.gate !== previous.gate) throw new Error("setOptions cannot change the gate.");
423
510
  validateCommands(given.commands);
511
+ checkGateOptions(given);
424
512
  store.action(() => {
425
513
  const before = { ...store.read() };
426
514
  record.options = mergeOptions(featureDefaults.get(console), given);
@@ -489,6 +577,37 @@ var consoleFeature = {
489
577
  });
490
578
  }
491
579
  };
580
+ // src/core/dispatch/commandError.ts
581
+ var commandCodes = [
582
+ "target-not-found",
583
+ "invalid-args",
584
+ "failed"
585
+ ];
586
+ /**
587
+ * Thrown (or rejected) by a command's `run` to end the run with `code`: the
588
+ * run fails as with any error (`onError`, the error entry), and the outcome a
589
+ * `request()` resolves is `{ status: 'error', code }`.
590
+ */
591
+ var CommandError = class extends Error {
592
+ /** The code the run's outcome carries. */
593
+ code;
594
+ constructor(code, message, options) {
595
+ super(message ?? code, options);
596
+ this.name = "CommandError";
597
+ this.code = code;
598
+ }
599
+ };
600
+ /**
601
+ * The outcome code of an error a command's `run` threw: a `CommandError`'s
602
+ * code when a command may give it, else `failed` (C-86). The error is known
603
+ * by its `name`, not by `instanceof`: a command built against a second copy
604
+ * of the core throws a `CommandError` of another class.
605
+ */
606
+ var commandErrorCode = (error) => {
607
+ if (!(error instanceof Error) || error.name !== "CommandError") return "failed";
608
+ const { code } = error;
609
+ return commandCodes.includes(code) ? code : "failed";
610
+ };
492
611
  // src/core/dispatch/sensitivity.ts
493
612
  /**
494
613
  * The first secret argument position of `command`: `0` for
@@ -726,14 +845,31 @@ var output = (tone, blocks, inputId, quiet) => quiet === true ? {
726
845
  blocks,
727
846
  inputId
728
847
  };
729
- /** Clears `running` when it still names `run` and resolves its promise. */
848
+ var failure = (code) => ({
849
+ status: "error",
850
+ code
851
+ });
852
+ /**
853
+ * Clears `running` when it still names `run`, cancels the echo timer,
854
+ * records the envelope id as closed and resolves the run's promises.
855
+ */
730
856
  var cleanup = (console, run) => {
731
857
  if (run.cleaned) return;
732
858
  run.cleaned = true;
859
+ run.waiting = false;
733
860
  const state = stateOf(console);
734
861
  if (state.run === run) state.run = null;
862
+ if (run.envelope) rememberClosed(state.closed, run.envelope.id);
863
+ const cancelTimer = run.cancelTimer;
864
+ run.cancelTimer = null;
865
+ if (cancelTimer) try {
866
+ cancelTimer();
867
+ } catch (error) {
868
+ reportCallbackError(console, error);
869
+ }
735
870
  recordOf(console)?.store.update("running", (running) => running?.runId === run.id ? null : running);
736
871
  run.resolve(true);
872
+ run.onOutcome?.(run.outcome);
737
873
  };
738
874
  /** Effects in order, then the output (README: Running a line, B-29). */
739
875
  var apply = (console, run, response, defaultTone) => {
@@ -742,16 +878,28 @@ var apply = (console, run, response, defaultTone) => {
742
878
  const blocks = blocksOf(response.output);
743
879
  if (blocks.length > 0) addEntry(console, output(response.tone ?? defaultTone, blocks, run.inputId, response.quiet));
744
880
  };
881
+ /** Ends `run` with what it returned; an `error` tone is `failed` (C-87). */
745
882
  var fulfil = (console, run, value) => {
746
883
  run.current = false;
747
884
  try {
748
- apply(console, run, normalizeReturn(value), "default");
885
+ const response = normalizeReturn(value);
886
+ const summary = cutSummary(response.summary);
887
+ run.outcome = response.tone === "error" ? failure("failed") : summary === void 0 ? { status: "ok" } : {
888
+ status: "ok",
889
+ summary
890
+ };
891
+ apply(console, run, response, "default");
749
892
  } finally {
750
893
  cleanup(console, run);
751
894
  }
752
895
  };
753
- var fail = (console, run, error) => {
896
+ /**
897
+ * Ends `run` as failed (C-18); `code` is the outcome's code, `failed` unless
898
+ * the command's own `run` threw a `CommandError` (C-86).
899
+ */
900
+ var fail = (console, run, error, code = "failed") => {
754
901
  run.current = false;
902
+ run.outcome = failure(code);
755
903
  try {
756
904
  const { store, options } = liveRecord(console);
757
905
  store.afterCommit(() => reportError(recordOf(console)?.options.onError, error, run.info));
@@ -768,11 +916,27 @@ var fail = (console, run, error) => {
768
916
  }
769
917
  };
770
918
  /** Ends `run` without output and without an entry (`false`, reset, dispose). */
771
- var stop = (console, run, abort) => {
919
+ var stop = (console, run, abort, code) => {
772
920
  run.current = false;
921
+ run.outcome = failure(code);
773
922
  if (abort) run.controller.abort();
774
923
  cleanup(console, run);
775
924
  };
925
+ /**
926
+ * Ends an operation that did not run its command with an `operation-failed`
927
+ * entry (C-79, C-80); `error` goes to `onError` with the run's info.
928
+ */
929
+ var close$1 = (console, run, code, error) => {
930
+ run.current = false;
931
+ run.outcome = failure(code);
932
+ run.controller.abort();
933
+ try {
934
+ if (error) liveRecord(console).store.afterCommit(() => reportError(recordOf(console)?.options.onError, error, run.info));
935
+ addEntry(console, output("error", [message("operation-failed", { code })], run.inputId));
936
+ } finally {
937
+ cleanup(console, run);
938
+ }
939
+ };
776
940
  /** Runs `fn` as its own action if `run` is still current (a settle). */
777
941
  var settle = (console, run, fn) => {
778
942
  if (!run.current || isDisposed(console)) return;
@@ -781,16 +945,21 @@ var settle = (console, run, fn) => {
781
945
  if (run.current) fn();
782
946
  });
783
947
  };
784
- /** Handles a return that may be a promise: now, or in its own action. */
785
- var whenSettled = (console, run, value, onValue) => {
786
- if (isThenable(value)) Promise.resolve(value).then((result) => settle(console, run, () => onValue(result)), (error) => settle(console, run, () => fail(console, run, error)));
948
+ /**
949
+ * Handles a return that may be a promise: now, or in its own action. A
950
+ * rejection fails the run with the code `codeOf` gives it.
951
+ */
952
+ var whenSettled = (console, run, value, onValue, codeOf = () => "failed") => {
953
+ if (isThenable(value)) Promise.resolve(value).then((result) => settle(console, run, () => onValue(result)), (error) => settle(console, run, () => fail(console, run, error, codeOf(error))));
787
954
  else onValue(value);
788
955
  };
956
+ var toRunning = (console, run) => liveRecord(console).store.update("running", (running) => running?.runId === run.id ? {
957
+ ...running,
958
+ phase: "running"
959
+ } : running);
960
+ /** Calls the command's `run`. */
789
961
  var go = (console, run) => {
790
- liveRecord(console).store.update("running", (running) => running?.runId === run.id ? {
791
- ...running,
792
- phase: "running"
793
- } : running);
962
+ toRunning(console, run);
794
963
  const context = {
795
964
  runId: run.id,
796
965
  inputId: run.inputId,
@@ -801,10 +970,10 @@ var go = (console, run) => {
801
970
  try {
802
971
  result = run.command.run(run.request, context);
803
972
  } catch (error) {
804
- fail(console, run, error);
973
+ fail(console, run, error, commandErrorCode(error));
805
974
  return;
806
975
  }
807
- whenSettled(console, run, result, (value) => fulfil(console, run, value));
976
+ whenSettled(console, run, result, (value) => fulfil(console, run, value), commandErrorCode);
808
977
  };
809
978
  var write$1 = (console, run, out, tone) => {
810
979
  settle(console, run, () => {
@@ -812,7 +981,106 @@ var write$1 = (console, run, out, tone) => {
812
981
  if (blocks.length > 0) addEntry(console, output(tone ?? "default", blocks, run.inputId));
813
982
  });
814
983
  };
815
- var start = (console, command, request, inputId, sensitive) => {
984
+ /** What the gate delivered: the echo of the pending envelope, or nothing. */
985
+ var receive = (console, raw) => {
986
+ const record = recordOf(console);
987
+ const gate = record?.options.gate;
988
+ if (!record || !gate) return;
989
+ const parsed = parseEnvelope(raw);
990
+ if (!parsed.ok) return;
991
+ const echo = parsed.envelope;
992
+ const state = stateOf(console);
993
+ const run = state.run;
994
+ if (echo.target.tab !== gate.tab || echo.from.tab !== echo.target.tab || state.closed.has(echo.id) || !run?.waiting || run.envelope?.id !== echo.id) return;
995
+ settle(console, run, () => {
996
+ if (!run.waiting) return;
997
+ const local = run.envelope;
998
+ run.waiting = false;
999
+ if (echo.command === local.command && sameJson(echo.args, local.args) && sameJson(echo.target, local.target)) go(console, run);
1000
+ else close$1(console, run, "id-conflict", /* @__PURE__ */ new Error(`The echo of operation ${local.id} differs from its record.`));
1001
+ });
1002
+ };
1003
+ /**
1004
+ * Sends the run's envelope and waits for its echo (C-79): the timer and the
1005
+ * send are the gate's calls, made after the action's commit.
1006
+ */
1007
+ var send = (console, run) => {
1008
+ const { store, options } = liveRecord(console);
1009
+ const gate = options.gate;
1010
+ toRunning(console, run);
1011
+ let envelope;
1012
+ try {
1013
+ const id = envelopeIds(options)();
1014
+ if (typeof id !== "string" || id === "" || stateOf(console).closed.has(id)) throw new Error(`createEnvelopeId returned ${id}, an id already used.`);
1015
+ const { view } = run.request;
1016
+ envelope = {
1017
+ v: 1,
1018
+ id,
1019
+ kind: "request",
1020
+ actor: run.actor,
1021
+ from: { tab: gate.tab },
1022
+ target: view === void 0 ? { tab: gate.tab } : {
1023
+ tab: gate.tab,
1024
+ view
1025
+ },
1026
+ command: run.command.name,
1027
+ args: run.request.input
1028
+ };
1029
+ } catch (error) {
1030
+ fail(console, run, error);
1031
+ return;
1032
+ }
1033
+ if (envelopeBytes(envelope) > envelopeMaxBytes) {
1034
+ close$1(console, run, "too-large");
1035
+ return;
1036
+ }
1037
+ run.envelope = envelope;
1038
+ run.waiting = true;
1039
+ const ended = (code) => () => settle(console, run, () => {
1040
+ if (run.waiting) close$1(console, run, code);
1041
+ });
1042
+ store.afterCommit(() => {
1043
+ const state = stateOf(console);
1044
+ if (!run.waiting) return;
1045
+ try {
1046
+ if (state.unsubscribe === null) state.unsubscribe = gate.subscribe((raw) => receive(console, raw));
1047
+ run.cancelTimer = gate.schedule(options.echoTimeoutMs ?? 5e3, ended("timeout"));
1048
+ const copy = JSON.parse(JSON.stringify(envelope));
1049
+ Promise.resolve(gate.send(copy)).then(void 0, ended("connection-lost"));
1050
+ } catch {
1051
+ ended("connection-lost")();
1052
+ }
1053
+ });
1054
+ };
1055
+ /** After `beforeRun` lets the run go: the gate, or the command at once. */
1056
+ var proceed = (console, run) => {
1057
+ if (run.gated) send(console, run);
1058
+ else go(console, run);
1059
+ };
1060
+ /**
1061
+ * Applies the plan of a command with `input` (C-77): normalizes and checks
1062
+ * the value, and completes the request from its canonical line. Throws on a
1063
+ * `normalize` or `activeView` that throws, or a value the schema refuses.
1064
+ */
1065
+ var prepare = (run, plan) => {
1066
+ const { command } = run;
1067
+ const schema = command.input;
1068
+ const view = plan.view();
1069
+ const mapped = command.normalize ? command.normalize(plan.value) : plan.value;
1070
+ const checked = validateInput(schema, mapped);
1071
+ if (!checked.ok) throw new TypeError(`command ${command.name}: normalize returned an input the schema refuses.`);
1072
+ const line = formatCommandLine(command.name, checked.value, schema);
1073
+ const tokens = tokenize(line).tokens;
1074
+ run.request = {
1075
+ ...run.request,
1076
+ args: tokens.slice(1).map((token) => token.value),
1077
+ rest: tokens.length > 1 ? line.slice(tokens[1].start) : "",
1078
+ line,
1079
+ input: checked.value
1080
+ };
1081
+ if (typeof view === "string" && view !== "") run.request.view = view;
1082
+ };
1083
+ var start = (console, command, request, inputId, sensitive, plan, onOutcome) => {
816
1084
  const { store, options } = liveRecord(console);
817
1085
  const provider = contextOf(console);
818
1086
  const frames = provider?.snapshot() ?? [];
@@ -839,9 +1107,16 @@ var start = (console, command, request, inputId, sensitive) => {
839
1107
  command,
840
1108
  controller: new AbortController(),
841
1109
  contextRevision: provider?.revision() ?? null,
1110
+ gated: plan !== null && options.gate !== void 0 && !sensitive && describedCommand(command),
1111
+ actor: plan?.actor ?? "user",
842
1112
  current: true,
843
1113
  cleaned: false,
844
- resolve
1114
+ envelope: null,
1115
+ waiting: false,
1116
+ cancelTimer: null,
1117
+ outcome: failure("cancelled"),
1118
+ resolve,
1119
+ onOutcome
845
1120
  };
846
1121
  stateOf(console).run = run;
847
1122
  const { beforeRun } = options;
@@ -851,8 +1126,14 @@ var start = (console, command, request, inputId, sensitive) => {
851
1126
  inputId,
852
1127
  phase: beforeRun ? "authorizing" : "running"
853
1128
  }));
1129
+ if (plan) try {
1130
+ prepare(run, plan);
1131
+ } catch (error) {
1132
+ fail(console, run, error);
1133
+ return done;
1134
+ }
854
1135
  if (!beforeRun) {
855
- go(console, run);
1136
+ proceed(console, run);
856
1137
  return done;
857
1138
  }
858
1139
  let answer;
@@ -863,8 +1144,8 @@ var start = (console, command, request, inputId, sensitive) => {
863
1144
  return done;
864
1145
  }
865
1146
  whenSettled(console, run, answer, (value) => {
866
- if (value === void 0) go(console, run);
867
- else if (value === false) stop(console, run, false);
1147
+ if (value === void 0) proceed(console, run);
1148
+ else if (value === false) stop(console, run, false, "not-allowed");
868
1149
  else fulfil(console, run, value);
869
1150
  });
870
1151
  return done;
@@ -912,13 +1193,87 @@ var submitLine = (console) => {
912
1193
  addEntry(console, output("error", [message("command-not-found", { name: sensitive ? null : first.value })], inputId));
913
1194
  return Promise.resolve(true);
914
1195
  }
1196
+ if (command.danger === "irreversible") {
1197
+ addEntry(console, output("error", [message("operation-failed", { code: "not-allowed" })], inputId));
1198
+ return Promise.resolve(true);
1199
+ }
1200
+ const args = parsed.tokens.slice(1).map((token) => token.value);
1201
+ let plan = null;
1202
+ if (command.input) {
1203
+ const read = parseOptions(args, command.input);
1204
+ if (!read.ok) {
1205
+ const [error] = read.errors;
1206
+ addEntry(console, output("error", [message("invalid-args", {
1207
+ path: sensitive ? null : error.path,
1208
+ reason: error.code
1209
+ })], inputId));
1210
+ return Promise.resolve(true);
1211
+ }
1212
+ plan = {
1213
+ value: read.value,
1214
+ view: () => liveRecord(console).options.activeView?.(),
1215
+ actor: "user"
1216
+ };
1217
+ }
915
1218
  return start(console, command, {
916
1219
  name: first.value,
917
1220
  command: command.name,
918
- args: parsed.tokens.slice(1).map((token) => token.value),
1221
+ args,
919
1222
  rest: second ? trimmed.slice(second.start) : "",
920
1223
  line: trimmed
921
- }, inputId, sensitive);
1224
+ }, inputId, sensitive, plan);
1225
+ };
1226
+ /**
1227
+ * `request()` in the open action (C-82): every refusal before the line is
1228
+ * accepted leaves no entry.
1229
+ */
1230
+ var requestLine = (console, input) => {
1231
+ const refuse = (code) => Promise.resolve(failure(code));
1232
+ const { store } = liveRecord(console);
1233
+ if (store.read().running !== null) return refuse("busy");
1234
+ const lookup = lookupOf(console);
1235
+ let command;
1236
+ let tokens = null;
1237
+ let view;
1238
+ if ("line" in input) {
1239
+ const parsed = tokenize(String(input.line).trim());
1240
+ if (!parsed.ok || parsed.tokens.length === 0) return refuse("invalid-args");
1241
+ command = lookup.find(parsed.tokens[0].value);
1242
+ tokens = parsed.tokens.slice(1).map((token) => token.value);
1243
+ view = () => liveRecord(console).options.activeView?.();
1244
+ } else {
1245
+ command = typeof input.command === "string" ? lookup.find(input.command) : void 0;
1246
+ const given = typeof input.view === "string" ? input.view : void 0;
1247
+ view = () => given;
1248
+ }
1249
+ if (!command || command.hidden === true) return refuse("unknown-command");
1250
+ if (!describedCommand(command) || command.danger !== "read" || input.actor !== "external-agent") return refuse("not-allowed");
1251
+ const schema = command.input;
1252
+ const read = tokens === null ? validateInput(schema, input.args) : parseOptions(tokens, schema);
1253
+ if (!read.ok) return refuse("invalid-args");
1254
+ const line = formatCommandLine(command.name, read.value, schema);
1255
+ if (lookup.isSensitive(line)) return refuse("not-allowed");
1256
+ const inputId = addEntry(console, {
1257
+ kind: "input",
1258
+ prompt: currentPrompt(console),
1259
+ text: line,
1260
+ redacted: false,
1261
+ status: "submitted",
1262
+ actor: input.actor
1263
+ });
1264
+ return new Promise((resolve) => {
1265
+ start(console, command, {
1266
+ name: command.name,
1267
+ command: command.name,
1268
+ args: [],
1269
+ rest: "",
1270
+ line
1271
+ }, inputId, false, {
1272
+ value: read.value,
1273
+ view,
1274
+ actor: input.actor
1275
+ }, resolve);
1276
+ });
922
1277
  };
923
1278
  /**
924
1279
  * Ctrl+C without a run: the line ends as cancelled (README: Cancelling and
@@ -940,6 +1295,7 @@ var cancelLine = (console) => {
940
1295
  /** Ctrl+C or `cancel()` during a run: invalidate, abort, `interrupted`. */
941
1296
  var interrupt = (console, run) => {
942
1297
  run.current = false;
1298
+ run.outcome = failure("cancelled");
943
1299
  run.controller.abort();
944
1300
  addEntry(console, output("notice", [message("interrupted")], run.inputId));
945
1301
  cleanup(console, run);
@@ -960,7 +1316,9 @@ var coreFeatures = [
960
1316
  constructConsoleAPIs(console) {
961
1317
  states.set(console, {
962
1318
  run: null,
963
- lookup: null
1319
+ lookup: null,
1320
+ unsubscribe: null,
1321
+ closed: /* @__PURE__ */ new Set()
964
1322
  });
965
1323
  const locked = (fn, otherwise) => {
966
1324
  const { store } = liveRecord(console);
@@ -975,11 +1333,20 @@ var coreFeatures = [
975
1333
  cancel() {
976
1334
  liveRecord(console).store.action(() => cancel(console));
977
1335
  },
1336
+ request(input) {
1337
+ try {
1338
+ return liveRecord(console).store.action(() => requestLine(console, input));
1339
+ } catch (error) {
1340
+ reportCallbackError(console, error);
1341
+ return Promise.resolve(failure("failed"));
1342
+ }
1343
+ },
978
1344
  isSensitive: (line) => lookupOf(console).isSensitive(line),
979
1345
  getCommands: () => lookupOf(console).visible().map(specOf)
980
1346
  }, {
981
1347
  submit: Promise.resolve(false),
982
1348
  run: Promise.resolve(false),
1349
+ request: Promise.resolve(failure("cancelled")),
983
1350
  isSensitive: false,
984
1351
  getCommands: []
985
1352
  });
@@ -1002,17 +1369,21 @@ var coreFeatures = [
1002
1369
  });
1003
1370
  onContextChange(console, () => {
1004
1371
  const run = stateOf(console).run;
1005
- if (run?.current && !run.controller.signal.aborted) run.controller.abort();
1372
+ if (run?.current && run.waiting) interrupt(console, run);
1373
+ else if (run?.current && !run.controller.signal.aborted) run.controller.abort();
1006
1374
  });
1007
1375
  onReset(console, () => {
1008
1376
  const run = stateOf(console).run;
1009
- if (run) stop(console, run, true);
1377
+ if (run) stop(console, run, true, "cancelled");
1010
1378
  });
1011
1379
  onDispose(console, () => {
1012
1380
  const state = stateOf(console);
1013
1381
  const run = state.run;
1382
+ const unsubscribe = state.unsubscribe;
1014
1383
  state.run = null;
1015
- if (run) stop(console, run, true);
1384
+ state.unsubscribe = null;
1385
+ if (run) stop(console, run, true, "cancelled");
1386
+ unsubscribe?.();
1016
1387
  });
1017
1388
  }
1018
1389
  }
@@ -1159,6 +1530,7 @@ function constructConsole(options, core) {
1159
1530
  const defaults = Object.assign({}, ...features.map((feature) => feature.getDefaultOptions?.() ?? {}));
1160
1531
  const merged = mergeOptions(defaults, options);
1161
1532
  validateCommands(merged.commands);
1533
+ checkGateOptions(merged);
1162
1534
  let initial = emptyState();
1163
1535
  for (const slice of controllable) {
1164
1536
  const value = merged.initialState?.[slice];
@@ -1208,6 +1580,123 @@ function consoleFeatures(features) {
1208
1580
  function createConsole(options) {
1209
1581
  return constructConsole(options, coreFeatures);
1210
1582
  }
1583
+ // src/helpers/agentTools.ts
1584
+ /**
1585
+ * A command an agent can see: one with `input` and `danger` whose definition
1586
+ * is not `sensitive` (what `request` could run, C-82).
1587
+ */
1588
+ var described = (spec) => spec.input !== void 0 && spec.danger !== void 0 && (spec.sensitive === void 0 || spec.sensitive === false);
1589
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1590
+ /** `value` without its `undefined` fields, as JSON. */
1591
+ var json = (value) => JSON.parse(JSON.stringify(value));
1592
+ var error = (code) => ({
1593
+ status: "error",
1594
+ code
1595
+ });
1596
+ var failed = {
1597
+ status: "error",
1598
+ code: "failed"
1599
+ };
1600
+ /**
1601
+ * The `context`, `help` and `run` tools of `console` (C-83). `run` calls
1602
+ * `console.request` as `external-agent`, so only `read` commands run.
1603
+ */
1604
+ function agentTools(console, options) {
1605
+ const commands = () => console.getCommands().filter(described);
1606
+ const help = (input) => {
1607
+ const name = isRecord(input) ? input.command : void 0;
1608
+ if (name === void 0) return commands().map((spec) => json({
1609
+ name: spec.name,
1610
+ summary: spec.summary,
1611
+ danger: spec.danger
1612
+ }));
1613
+ const found = commands().find((spec) => spec.name === name || spec.aliases?.includes(name));
1614
+ if (!found) return error("unknown-command");
1615
+ return json({
1616
+ name: found.name,
1617
+ summary: found.summary,
1618
+ usage: found.usage,
1619
+ danger: found.danger,
1620
+ input: found.input
1621
+ });
1622
+ };
1623
+ return [
1624
+ {
1625
+ name: "context",
1626
+ description: "The open tab, its views and the active view. Call it before run to pick a view; call help for the commands.",
1627
+ inputSchema: {
1628
+ type: "object",
1629
+ properties: {}
1630
+ },
1631
+ annotations: {
1632
+ readOnlyHint: true,
1633
+ untrustedContentHint: true
1634
+ },
1635
+ execute: async () => {
1636
+ try {
1637
+ return await options.context();
1638
+ } catch {
1639
+ return failed;
1640
+ }
1641
+ }
1642
+ },
1643
+ {
1644
+ name: "help",
1645
+ description: "Without input: the commands you can run (name, summary, danger). With a command name: its usage and input schema. Call it before run.",
1646
+ inputSchema: {
1647
+ type: "object",
1648
+ properties: { command: {
1649
+ type: "string",
1650
+ description: "A command name."
1651
+ } }
1652
+ },
1653
+ annotations: { readOnlyHint: true },
1654
+ execute: (input) => Promise.resolve(help(input))
1655
+ },
1656
+ {
1657
+ name: "run",
1658
+ description: "Runs a command: { line } as typed (name --field value), or { command, args, view? } with args matching its input schema. Returns { status, summary } or { status: \"error\", code }. Call help first.",
1659
+ inputSchema: {
1660
+ type: "object",
1661
+ properties: {
1662
+ line: {
1663
+ type: "string",
1664
+ description: "A whole command line."
1665
+ },
1666
+ command: {
1667
+ type: "string",
1668
+ description: "A command name."
1669
+ },
1670
+ args: {
1671
+ type: "object",
1672
+ description: "The command input."
1673
+ },
1674
+ view: {
1675
+ type: "string",
1676
+ description: "A view id from context."
1677
+ }
1678
+ }
1679
+ },
1680
+ annotations: {
1681
+ readOnlyHint: true,
1682
+ untrustedContentHint: true
1683
+ },
1684
+ execute: async (input) => {
1685
+ if (isRecord(input) && typeof input.line === "string") return console.request({
1686
+ line: input.line,
1687
+ actor: "external-agent"
1688
+ });
1689
+ if (isRecord(input) && typeof input.command === "string" && isRecord(input.args) && (input.view === void 0 || typeof input.view === "string")) return console.request({
1690
+ command: input.command,
1691
+ args: input.args,
1692
+ ...input.view === void 0 ? {} : { view: input.view },
1693
+ actor: "external-agent"
1694
+ });
1695
+ return error("invalid-args");
1696
+ }
1697
+ }
1698
+ ];
1699
+ }
1211
1700
  // src/features/context/context.utils.ts
1212
1701
  /**
1213
1702
  * The id sequence of `frames`, as one comparable string. Two stacks with the
@@ -1727,6 +2216,6 @@ var completionFeature = {
1727
2216
  onLineCleared(console, () => setSession(console, null));
1728
2217
  }
1729
2218
  };
1730
- export { completionFeature, consoleFeatures, contextFeature, createConsole, recallFeature };
2219
+ export { CommandError, agentTools, completionFeature, consoleFeatures, contextFeature, createConsole, recallFeature };
1731
2220
 
1732
2221
  //# sourceMappingURL=command-console-core.js.map