@orkestrel/terminal 0.0.1 → 0.0.2

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.
@@ -3,6 +3,7 @@ let _orkestrel_contract = require("@orkestrel/contract");
3
3
  let _orkestrel_console = require("@orkestrel/console");
4
4
  let _orkestrel_emitter = require("@orkestrel/emitter");
5
5
  let _orkestrel_sse = require("@orkestrel/sse");
6
+ let _orkestrel_database = require("@orkestrel/database");
6
7
  //#region src/core/constants.ts
7
8
  /** Carriage return (`\r`, U+000D) — Enter on most terminals. */
8
9
  var RETURN = String.fromCharCode(13);
@@ -1034,8 +1035,10 @@ var isPendingPrompt = (0, _orkestrel_contract.recordOf)({
1034
1035
  message: _orkestrel_contract.isString,
1035
1036
  options: _orkestrel_contract.isRecord,
1036
1037
  status: isPendingPromptStatus,
1037
- time: _orkestrel_contract.isNumber
1038
- });
1038
+ time: _orkestrel_contract.isNumber,
1039
+ from: _orkestrel_contract.isString,
1040
+ to: _orkestrel_contract.isString
1041
+ }, ["from", "to"]);
1039
1042
  /**
1040
1043
  * Rebuild a wire-decoded `validate` payload into a {@link ValidationRules} bag — the inverse of
1041
1044
  * {@link serializeValidationRules}. Keeps only the primitive rule values (`boolean` / `number` /
@@ -1233,6 +1236,37 @@ function isInsecureRemote(url) {
1233
1236
  const hostname = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0] ?? "";
1234
1237
  return hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "[::1]";
1235
1238
  }
1239
+ /** Serialize a parked {@link PendingPrompt} into a {@link WireEvent} — event `'pending'`, `data` the JSON-stringified prompt, `id` the prompt's own id (an SSE bridge sets its frame `id:` from this). */
1240
+ function serializePending(prompt) {
1241
+ return {
1242
+ event: "pending",
1243
+ data: JSON.stringify(prompt),
1244
+ id: prompt.id
1245
+ };
1246
+ }
1247
+ /** Serialize a parked prompt's expiry into a {@link WireEvent} — event `'expire'`, `data` the JSON-stringified `{ id }` payload. */
1248
+ function serializeExpire(id) {
1249
+ return {
1250
+ event: "expire",
1251
+ data: JSON.stringify({ id })
1252
+ };
1253
+ }
1254
+ /** The {@link WireEvent} a broker/manager sends when it is going away — event `'shutdown'`, no payload. */
1255
+ function serializeShutdown() {
1256
+ return {
1257
+ event: "shutdown",
1258
+ data: ""
1259
+ };
1260
+ }
1261
+ /** Narrow an unknown wire payload to an answer POST body — a non-empty `id` string plus a `value` key (of any shape) present. */
1262
+ function isAnswerPayload(value) {
1263
+ return (0, _orkestrel_contract.isRecord)(value) && (0, _orkestrel_contract.isNonEmptyString)(value.id) && "value" in value;
1264
+ }
1265
+ /** Narrow an unknown value to a {@link TerminalSnapshot} — a non-empty `id` plus an optional numeric `timeout`. */
1266
+ var isTerminalSnapshot = (0, _orkestrel_contract.recordOf)({
1267
+ id: _orkestrel_contract.isNonEmptyString,
1268
+ timeout: _orkestrel_contract.isNumber
1269
+ }, ["timeout"]);
1236
1270
  //#endregion
1237
1271
  //#region src/core/Prompt.ts
1238
1272
  /**
@@ -1272,12 +1306,14 @@ function isInsecureRemote(url) {
1272
1306
  var Prompt = class {
1273
1307
  #timeout;
1274
1308
  #timer;
1309
+ #cap;
1275
1310
  #parked = /* @__PURE__ */ new Map();
1276
1311
  #emitter;
1277
1312
  #destroyed = false;
1278
1313
  constructor(options) {
1279
1314
  this.#timeout = options?.timeout ?? 3e5;
1280
1315
  this.#timer = options?.timer ?? defaultTimer;
1316
+ this.#cap = options?.cap;
1281
1317
  this.#emitter = new _orkestrel_emitter.Emitter({
1282
1318
  on: options?.on,
1283
1319
  error: options?.error
@@ -1297,43 +1333,44 @@ var Prompt = class {
1297
1333
  }
1298
1334
  answer(id, value) {
1299
1335
  const parked = this.#parked.get(id);
1300
- if (parked === void 0) return false;
1336
+ if (parked === void 0) return {
1337
+ success: false,
1338
+ error: "unknown"
1339
+ };
1301
1340
  const accepted = parked.respond(value);
1302
- if (accepted === void 0) return false;
1341
+ if (accepted === void 0) return {
1342
+ success: false,
1343
+ error: "rejected"
1344
+ };
1303
1345
  parked.cancel();
1304
1346
  this.#emitter.emit("answer", id, accepted);
1305
1347
  this.#parked.delete(id);
1306
- return true;
1348
+ return {
1349
+ success: true,
1350
+ value: accepted
1351
+ };
1352
+ }
1353
+ park(request) {
1354
+ const gate = this.#gate(request.form, request.options);
1355
+ return this.#park(request.form, request.options.message, request.options, gate, request.from, request.to);
1307
1356
  }
1308
1357
  input(options) {
1309
- const validator = resolveValidation(options.validate);
1310
- return this.#park("input", options.message, options, (value) => (0, _orkestrel_contract.isString)(value) && validator(value) === true ? value : void 0);
1358
+ return this.#park("input", options.message, options, this.#gate("input", options)).value;
1311
1359
  }
1312
1360
  password(options) {
1313
- const validator = resolveValidation(options.validate);
1314
- return this.#park("password", options.message, options, (value) => (0, _orkestrel_contract.isString)(value) && validator(value) === true ? value : void 0);
1361
+ return this.#park("password", options.message, options, this.#gate("password", options)).value;
1315
1362
  }
1316
1363
  confirm(options) {
1317
- return this.#park("confirm", options.message, options, (value) => (0, _orkestrel_contract.isBoolean)(value) ? value : void 0);
1364
+ return this.#park("confirm", options.message, options, this.#gate("confirm", options)).value;
1318
1365
  }
1319
1366
  select(options) {
1320
- const values = new Set(options.choices.map(normalizeChoice).map((choice) => choice.value));
1321
- return this.#park("select", options.message, options, (value) => (0, _orkestrel_contract.isString)(value) && values.has(value) ? value : void 0);
1367
+ return this.#park("select", options.message, options, this.#gate("select", options)).value;
1322
1368
  }
1323
1369
  checkbox(options) {
1324
- const values = new Set(options.choices.map(normalizeCheckboxChoice).map((choice) => choice.value));
1325
- const { min, max } = options;
1326
- return this.#park("checkbox", options.message, options, (value) => {
1327
- if (!(0, _orkestrel_contract.isArray)(value) || !value.every(_orkestrel_contract.isString)) return void 0;
1328
- if (!value.every((item) => values.has(item))) return void 0;
1329
- if (min !== void 0 && value.length < min) return void 0;
1330
- if (max !== void 0 && value.length > max) return void 0;
1331
- return value;
1332
- });
1370
+ return this.#park("checkbox", options.message, options, this.#gate("checkbox", options)).value;
1333
1371
  }
1334
1372
  editor(options) {
1335
- const validator = resolveValidation(options.validate);
1336
- return this.#park("editor", options.message, options, (value) => (0, _orkestrel_contract.isString)(value) && validator(value) === true ? value : void 0);
1373
+ return this.#park("editor", options.message, options, this.#gate("editor", options)).value;
1337
1374
  }
1338
1375
  destroy() {
1339
1376
  if (this.#destroyed) return;
@@ -1342,8 +1379,15 @@ var Prompt = class {
1342
1379
  this.#parked.clear();
1343
1380
  this.#emitter.destroy();
1344
1381
  }
1345
- #park(form, message, options, gate) {
1346
- if (this.#destroyed) return Promise.reject(new TerminalError("EXPIRE", "broker destroyed"));
1382
+ #park(form, message, options, gate, from, to) {
1383
+ if (this.#destroyed) return {
1384
+ id: crypto.randomUUID(),
1385
+ value: Promise.reject(new TerminalError("EXPIRE", "broker destroyed"))
1386
+ };
1387
+ if (this.#cap !== void 0 && this.#parked.size >= this.#cap) return {
1388
+ id: crypto.randomUUID(),
1389
+ value: Promise.reject(new TerminalError("LIMIT", `parked-prompt cap (${this.#cap}) reached`, { cap: this.#cap }))
1390
+ };
1347
1391
  const id = crypto.randomUUID();
1348
1392
  const prompt = {
1349
1393
  id,
@@ -1351,35 +1395,83 @@ var Prompt = class {
1351
1395
  message,
1352
1396
  options: serializePromptOptions(options),
1353
1397
  status: "pending",
1354
- time: Date.now()
1398
+ time: Date.now(),
1399
+ ...from !== void 0 ? { from } : {},
1400
+ ...to !== void 0 ? { to } : {}
1355
1401
  };
1356
- return new Promise((resolve, reject) => {
1357
- const cancel = this.#timer(() => this.#expire(id), this.#timeout);
1358
- const respond = (value) => {
1359
- const accepted = gate(value);
1360
- if (accepted === void 0) return void 0;
1361
- const current = this.#parked.get(id);
1362
- if (current !== void 0) this.#parked.set(id, {
1363
- ...current,
1364
- prompt: {
1365
- ...current.prompt,
1366
- status: "answered"
1367
- }
1402
+ return {
1403
+ id,
1404
+ value: new Promise((resolve, reject) => {
1405
+ const cancel = this.#timer(() => this.#expire(id), this.#timeout);
1406
+ const respond = (answer) => {
1407
+ const accepted = gate(answer);
1408
+ if (accepted === void 0) return void 0;
1409
+ const current = this.#parked.get(id);
1410
+ if (current !== void 0) this.#parked.set(id, {
1411
+ ...current,
1412
+ prompt: {
1413
+ ...current.prompt,
1414
+ status: "answered"
1415
+ }
1416
+ });
1417
+ resolve(accepted);
1418
+ return accepted;
1419
+ };
1420
+ const expire = () => {
1421
+ reject(new TerminalError("EXPIRE", `prompt ${id} expired`, { id }));
1422
+ };
1423
+ this.#parked.set(id, {
1424
+ prompt,
1425
+ respond,
1426
+ expire,
1427
+ cancel
1368
1428
  });
1369
- resolve(accepted);
1370
- return accepted;
1371
- };
1372
- const expire = () => {
1373
- reject(new TerminalError("EXPIRE", `prompt ${id} expired`, { id }));
1374
- };
1375
- this.#parked.set(id, {
1376
- prompt,
1377
- respond,
1378
- expire,
1379
- cancel
1380
- });
1381
- this.#emitter.emit("pending", prompt);
1382
- });
1429
+ this.#emitter.emit("pending", prompt);
1430
+ })
1431
+ };
1432
+ }
1433
+ #gate(form, options) {
1434
+ switch (form) {
1435
+ case "input":
1436
+ case "password":
1437
+ case "editor": {
1438
+ if (!this.#isTextOptions(form, options)) return () => void 0;
1439
+ const validator = resolveValidation(options.validate);
1440
+ return (value) => (0, _orkestrel_contract.isString)(value) && validator(value) === true ? value : void 0;
1441
+ }
1442
+ case "confirm":
1443
+ if (!this.#isConfirmOptions(form, options)) return () => void 0;
1444
+ return (value) => (0, _orkestrel_contract.isBoolean)(value) ? value : void 0;
1445
+ case "select": {
1446
+ if (!this.#isSelectOptions(form, options)) return () => void 0;
1447
+ const values = new Set(options.choices.map(normalizeChoice).map((choice) => choice.value));
1448
+ return (value) => (0, _orkestrel_contract.isString)(value) && values.has(value) ? value : void 0;
1449
+ }
1450
+ case "checkbox": {
1451
+ if (!this.#isCheckboxOptions(form, options)) return () => void 0;
1452
+ const values = new Set(options.choices.map(normalizeCheckboxChoice).map((choice) => choice.value));
1453
+ const { min, max } = options;
1454
+ return (value) => {
1455
+ if (!(0, _orkestrel_contract.isArray)(value) || !value.every(_orkestrel_contract.isString)) return void 0;
1456
+ if (!value.every((item) => values.has(item))) return void 0;
1457
+ if (min !== void 0 && value.length < min) return void 0;
1458
+ if (max !== void 0 && value.length > max) return void 0;
1459
+ return value;
1460
+ };
1461
+ }
1462
+ }
1463
+ }
1464
+ #isTextOptions(form, _options) {
1465
+ return form === "input" || form === "password" || form === "editor";
1466
+ }
1467
+ #isConfirmOptions(form, _options) {
1468
+ return form === "confirm";
1469
+ }
1470
+ #isSelectOptions(form, _options) {
1471
+ return form === "select";
1472
+ }
1473
+ #isCheckboxOptions(form, _options) {
1474
+ return form === "checkbox";
1383
1475
  }
1384
1476
  #expire(id) {
1385
1477
  const parked = this.#parked.get(id);
@@ -1416,8 +1508,10 @@ var Prompt = class {
1416
1508
  * never an `as`), dispatched to `terminal`, and its resolved value POSTed back to `url`.
1417
1509
  * Dispatch is strictly SERIAL: the read loop drives the terminal for ONE prompt at a time and
1418
1510
  * only reads/dispatches the next event after the current prompt fully settles (its answer
1419
- * POSTed). A prompt id redelivered by the broker AFTER its prior dispatch has settled is
1420
- * dispatched again — the client does not dedupe across completion.
1511
+ * POSTed). A prompt id redelivered (e.g. after a reconnect) WHILE its prior dispatch is still
1512
+ * in flight is skipped (a `#seen` set tracks in-flight ids); once that dispatch settles (or the
1513
+ * broker sends `expire` for the id) the id is dropped from `#seen`, so a later redelivery of the
1514
+ * SAME id after completion is dispatched again — the client does not dedupe across completion.
1421
1515
  * - **Server signals.** An `expire` event (the broker dropped a parked prompt) emits `expire`; a
1422
1516
  * `shutdown` event calls {@link disconnect} (not {@link destroy}) — the client stops streaming
1423
1517
  * without auto-reconnect but stays reusable; a later {@link connect} recovers it.
@@ -1451,6 +1545,7 @@ var PromptClient = class {
1451
1545
  #connected = false;
1452
1546
  #destroyed = false;
1453
1547
  #warnedInsecureToken = false;
1548
+ #seen = /* @__PURE__ */ new Set();
1454
1549
  constructor(options) {
1455
1550
  this.url = options.url;
1456
1551
  this.#terminal = options.terminal;
@@ -1501,6 +1596,7 @@ var PromptClient = class {
1501
1596
  if (this.#destroyed) return;
1502
1597
  this.#destroyed = true;
1503
1598
  this.disconnect();
1599
+ this.#seen.clear();
1504
1600
  this.#emitter.destroy();
1505
1601
  }
1506
1602
  async #stream() {
@@ -1536,22 +1632,28 @@ var PromptClient = class {
1536
1632
  async #handle(event) {
1537
1633
  if (event.event === SSE_EVENTS.pending) {
1538
1634
  const parsed = parseWireJSON(event.data);
1539
- if (isPendingPrompt(parsed)) await this.#dispatch(parsed.id, parsed);
1635
+ if (isPendingPrompt(parsed) && !this.#seen.has(parsed.id)) await this.#dispatch(parsed.id, parsed);
1540
1636
  return;
1541
1637
  }
1542
1638
  if (event.event === SSE_EVENTS.expire) {
1543
1639
  const parsed = parseWireJSON(event.data);
1544
- if ((0, _orkestrel_contract.isRecord)(parsed) && (0, _orkestrel_contract.isString)(parsed.id)) this.#emitter.emit("expire", parsed.id);
1640
+ if ((0, _orkestrel_contract.isRecord)(parsed) && (0, _orkestrel_contract.isString)(parsed.id)) {
1641
+ this.#seen.delete(parsed.id);
1642
+ this.#emitter.emit("expire", parsed.id);
1643
+ }
1545
1644
  return;
1546
1645
  }
1547
1646
  if (event.event === SSE_EVENTS.shutdown) this.disconnect();
1548
1647
  }
1549
1648
  async #dispatch(id, pending) {
1649
+ this.#seen.add(id);
1550
1650
  try {
1551
1651
  const value = await dispatchPendingPrompt(this.#terminal, pending);
1552
1652
  await this.#post(id, value);
1553
1653
  } catch (error) {
1554
1654
  this.#emitter.emit("error", error);
1655
+ } finally {
1656
+ this.#seen.delete(id);
1555
1657
  }
1556
1658
  }
1557
1659
  async #post(id, value) {
@@ -1570,8 +1672,11 @@ var PromptClient = class {
1570
1672
  this.#emitter.emit("disconnect");
1571
1673
  }
1572
1674
  #headers(base) {
1573
- if (this.#token !== void 0) base[HEADER_TOKEN] = this.#token;
1574
- return base;
1675
+ if (this.#token !== void 0) return {
1676
+ ...base,
1677
+ [HEADER_TOKEN]: this.#token
1678
+ };
1679
+ return { ...base };
1575
1680
  }
1576
1681
  #wait(ms) {
1577
1682
  return new Promise((resolve) => {
@@ -1588,6 +1693,345 @@ var PromptClient = class {
1588
1693
  }
1589
1694
  };
1590
1695
  //#endregion
1696
+ //#region src/core/TerminalManager.ts
1697
+ /**
1698
+ * The multi-endpoint terminal MANAGER — a registry of named {@link PromptInterface} brokers (one
1699
+ * per endpoint), so several parties can `ask` prompts of each other by NAME with a `from` → `to`
1700
+ * attribution edge on every parked prompt, and a transitive DEADLOCK check across all in-flight
1701
+ * asks.
1702
+ *
1703
+ * @remarks
1704
+ * - **Registry.** `add(name, options?)` mints (or, if `name` is already mounted, returns the
1705
+ * EXISTING broker UNCHANGED — idempotent, never clobbers a live/parked endpoint). Every mounted
1706
+ * broker's `pending` / `answer` / `expire` events are re-emitted on the manager, attributed by
1707
+ * `name`.
1708
+ * - **`ask`.** The target must already be mounted via {@link add} — `ask` never auto-adds it;
1709
+ * rejects `TARGET` for an unknown `to` (listing the known names). Rejects `DEADLOCK` when parking
1710
+ * `from → to` would close a cycle over the CURRENT in-flight edge set (walked transitively);
1711
+ * otherwise parks through the target's broker and resolves with the ORIGINAL ticket Promise (edge
1712
+ * cleanup is attached via `.then`, never altering the value/rejection the caller observes).
1713
+ * - **Durable open / save.** `open(name)` restores an EMPTY broker from the `store` (parked
1714
+ * Promises are process-bound and never resurrected); `save(name)` persists the endpoint's
1715
+ * configured `timeout`.
1716
+ * - **Removal.** `remove` drops one endpoint or a batch (§9.2, array overload FIRST), destroying
1717
+ * each broker (which expires every prompt still parked on it). `clear` drops all; `destroy` is
1718
+ * idempotent.
1719
+ *
1720
+ * @example
1721
+ * ```ts
1722
+ * const manager = new TerminalManager()
1723
+ * manager.add('agent')
1724
+ * const name = manager.ask('user', 'agent', 'input', { message: 'Your name' })
1725
+ * manager.answer('agent', manager.pending('agent')[0].id, 'Ada')
1726
+ * await name // 'Ada'
1727
+ * ```
1728
+ */
1729
+ var TerminalManager = class {
1730
+ #terminals = /* @__PURE__ */ new Map();
1731
+ #config = /* @__PURE__ */ new Map();
1732
+ #listeners = /* @__PURE__ */ new Map();
1733
+ #edges = /* @__PURE__ */ new Map();
1734
+ #store;
1735
+ #timeout;
1736
+ #timer;
1737
+ #cap;
1738
+ #emitter;
1739
+ #destroyed = false;
1740
+ constructor(options) {
1741
+ this.#store = options?.store;
1742
+ this.#timeout = options?.timeout;
1743
+ this.#timer = options?.timer;
1744
+ this.#cap = options?.cap;
1745
+ this.#emitter = new _orkestrel_emitter.Emitter({
1746
+ on: options?.on,
1747
+ error: options?.error
1748
+ });
1749
+ }
1750
+ get emitter() {
1751
+ return this.#emitter;
1752
+ }
1753
+ get count() {
1754
+ return this.#terminals.size;
1755
+ }
1756
+ terminal(name) {
1757
+ return this.#terminals.get(name);
1758
+ }
1759
+ terminals() {
1760
+ return [...this.#terminals.keys()];
1761
+ }
1762
+ add(name, options) {
1763
+ if (this.#destroyed) throw new TerminalError("DESTROYED", "manager destroyed");
1764
+ const existing = this.#terminals.get(name);
1765
+ if (existing !== void 0) return existing;
1766
+ const timeout = options?.timeout ?? this.#timeout;
1767
+ const timer = options?.timer ?? this.#timer;
1768
+ const cap = options?.cap ?? this.#cap;
1769
+ const broker = createPrompt({
1770
+ ...timeout !== void 0 ? { timeout } : {},
1771
+ ...timer !== void 0 ? { timer } : {},
1772
+ ...cap !== void 0 ? { cap } : {}
1773
+ });
1774
+ const listeners = {
1775
+ pending: (prompt) => this.#emitter.emit("pending", prompt),
1776
+ answer: (id, value) => this.#emitter.emit("answer", name, id, value),
1777
+ expire: (id) => this.#emitter.emit("expire", name, id)
1778
+ };
1779
+ broker.emitter.on("pending", listeners.pending);
1780
+ broker.emitter.on("answer", listeners.answer);
1781
+ broker.emitter.on("expire", listeners.expire);
1782
+ this.#terminals.set(name, broker);
1783
+ this.#config.set(name, options ?? {});
1784
+ this.#listeners.set(name, listeners);
1785
+ return broker;
1786
+ }
1787
+ ask(from, to, form, options) {
1788
+ const broker = this.#terminals.get(to);
1789
+ if (broker === void 0) {
1790
+ const known = this.terminals();
1791
+ return Promise.reject(new TerminalError("TARGET", `unknown terminal '${to}' (known: ${known.length > 0 ? known.join(", ") : "none"})`, {
1792
+ to,
1793
+ known
1794
+ }));
1795
+ }
1796
+ const cycle = this.#findCycle(from, to);
1797
+ if (cycle !== void 0) return Promise.reject(new TerminalError("DEADLOCK", `ask ${from} -> ${to} would deadlock: ${cycle.join(" -> ")}`, {
1798
+ from,
1799
+ to,
1800
+ path: cycle
1801
+ }));
1802
+ const ticket = broker.park({
1803
+ form,
1804
+ options,
1805
+ from,
1806
+ to
1807
+ });
1808
+ if (broker.pending(ticket.id) !== void 0) {
1809
+ this.#edges.set(ticket.id, {
1810
+ from,
1811
+ to
1812
+ });
1813
+ const clear = () => {
1814
+ this.#edges.delete(ticket.id);
1815
+ };
1816
+ ticket.value.then(clear, clear);
1817
+ }
1818
+ return ticket.value;
1819
+ }
1820
+ pending(to) {
1821
+ if (to !== void 0) {
1822
+ const broker = this.#terminals.get(to);
1823
+ return broker === void 0 ? [] : broker.pending();
1824
+ }
1825
+ const result = [];
1826
+ for (const broker of this.#terminals.values()) result.push(...broker.pending());
1827
+ return result;
1828
+ }
1829
+ answer(to, id, value) {
1830
+ const broker = this.#terminals.get(to);
1831
+ if (broker === void 0) return {
1832
+ success: false,
1833
+ error: "terminal"
1834
+ };
1835
+ return broker.answer(id, value);
1836
+ }
1837
+ async open(name) {
1838
+ if (this.#destroyed) throw new TerminalError("DESTROYED", "manager destroyed");
1839
+ const existing = this.#terminals.get(name);
1840
+ if (existing !== void 0) return existing;
1841
+ if (this.#store === void 0) return void 0;
1842
+ const snapshot = await this.#store.get(name);
1843
+ if (this.#destroyed) throw new TerminalError("DESTROYED", "manager destroyed");
1844
+ if (snapshot === void 0) return void 0;
1845
+ return this.add(name, snapshot.timeout !== void 0 ? { timeout: snapshot.timeout } : {});
1846
+ }
1847
+ async save(name) {
1848
+ const broker = this.#terminals.get(name);
1849
+ if (this.#store === void 0 || broker === void 0) return false;
1850
+ const config = this.#config.get(name);
1851
+ const snapshot = {
1852
+ id: name,
1853
+ ...config?.timeout !== void 0 ? { timeout: config.timeout } : {}
1854
+ };
1855
+ await this.#store.set(snapshot);
1856
+ return true;
1857
+ }
1858
+ remove(names) {
1859
+ if ((0, _orkestrel_contract.isArray)(names)) {
1860
+ let removed = false;
1861
+ for (const name of names) if (this.#removeOne(name)) removed = true;
1862
+ return removed;
1863
+ }
1864
+ return this.#removeOne(names);
1865
+ }
1866
+ clear() {
1867
+ for (const name of [...this.#terminals.keys()]) this.#removeOne(name);
1868
+ }
1869
+ destroy() {
1870
+ if (this.#destroyed) return;
1871
+ this.#destroyed = true;
1872
+ this.clear();
1873
+ this.#emitter.destroy();
1874
+ }
1875
+ #removeOne(name) {
1876
+ const broker = this.#terminals.get(name);
1877
+ if (broker === void 0) return false;
1878
+ broker.destroy();
1879
+ const listeners = this.#listeners.get(name);
1880
+ if (listeners !== void 0) {
1881
+ broker.emitter.off("pending", listeners.pending);
1882
+ broker.emitter.off("answer", listeners.answer);
1883
+ broker.emitter.off("expire", listeners.expire);
1884
+ }
1885
+ this.#terminals.delete(name);
1886
+ this.#config.delete(name);
1887
+ this.#listeners.delete(name);
1888
+ return true;
1889
+ }
1890
+ #findCycle(from, to) {
1891
+ if (from === to) return [from, to];
1892
+ const visited = /* @__PURE__ */ new Set([to]);
1893
+ const queue = [[to]];
1894
+ while (queue.length > 0) {
1895
+ const path = queue.shift();
1896
+ if (path === void 0) break;
1897
+ const last = path[path.length - 1];
1898
+ if (last === void 0) continue;
1899
+ for (const edge of this.#edges.values()) {
1900
+ if (edge.from !== last) continue;
1901
+ if (edge.to === from) return [
1902
+ from,
1903
+ ...path,
1904
+ from
1905
+ ];
1906
+ if (visited.has(edge.to)) continue;
1907
+ visited.add(edge.to);
1908
+ queue.push([...path, edge.to]);
1909
+ }
1910
+ }
1911
+ }
1912
+ };
1913
+ //#endregion
1914
+ //#region src/core/MemoryTerminalStore.ts
1915
+ /**
1916
+ * The in-memory {@link TerminalStoreInterface} — a process-lifetime `Map` of
1917
+ * {@link TerminalSnapshot}s keyed by endpoint id, the DEFAULT store
1918
+ * {@link import('./factories.js').createMemoryTerminalStore} builds. The EXACT twin of
1919
+ * {@link import('./DatabaseTerminalStore.js').DatabaseTerminalStore}.
1920
+ *
1921
+ * @remarks
1922
+ * A plain `Map<string, TerminalSnapshot>` (AGENTS §21 — the snapshot is already pure, self-contained
1923
+ * CONFIG-only JSON, so no encoding is needed for the memory tier). There is NO idle-TTL and NO
1924
+ * eviction: a persisted config lives until an explicit `delete`. A durable backend (JSON / SQLite /
1925
+ * IndexedDB) swaps in through the SAME interface without touching the manager — its
1926
+ * driver-pluggable twin is {@link import('./DatabaseTerminalStore.js').DatabaseTerminalStore} (the
1927
+ * snapshot as one opaque JSON column).
1928
+ *
1929
+ * - **`get` resolves the persisted snapshot for an id**, or `undefined` if none is stored.
1930
+ * - **`set` inserts / replaces under the snapshot's OWN `id`** (no separate id param).
1931
+ * - **`delete` drops a snapshot by id**; an absent id is a no-op (no throw).
1932
+ *
1933
+ * The public surface is EXACTLY `get` / `set` / `delete` — no extra members (the §22 method
1934
+ * bijection with {@link TerminalStoreInterface}). Hydration is a caller concern: `open` always
1935
+ * restores an EMPTY broker — parked Promises are process-bound and never resurrected.
1936
+ *
1937
+ * @example
1938
+ * ```ts
1939
+ * import { createMemoryTerminalStore } from '@src/core'
1940
+ *
1941
+ * const store = createMemoryTerminalStore()
1942
+ * await store.set({ id: 'shell', timeout: 5000 }) // persist a config
1943
+ * const snapshot = await store.get('shell')
1944
+ * await store.delete('shell') // drop it
1945
+ * ```
1946
+ */
1947
+ var MemoryTerminalStore = class {
1948
+ #snapshots = /* @__PURE__ */ new Map();
1949
+ get(id) {
1950
+ return Promise.resolve(this.#snapshots.get(id));
1951
+ }
1952
+ set(snapshot) {
1953
+ this.#snapshots.set(snapshot.id, snapshot);
1954
+ return Promise.resolve();
1955
+ }
1956
+ delete(id) {
1957
+ this.#snapshots.delete(id);
1958
+ return Promise.resolve();
1959
+ }
1960
+ };
1961
+ //#endregion
1962
+ //#region src/core/DatabaseTerminalStore.ts
1963
+ /**
1964
+ * A {@link TerminalStoreInterface} backed by one table of the `databases` layer — an endpoint's
1965
+ * durable CONFIG state IS a row, so persistence reduces to keyed point-access (`get` / `set` /
1966
+ * `delete`) over a {@link TableInterface}, the driver-pluggable twin of the plain-`Map`
1967
+ * {@link import('./MemoryTerminalStore.js').MemoryTerminalStore}.
1968
+ *
1969
+ * @remarks
1970
+ * The store is driver-agnostic: it holds a single {@link TableInterface} whose backend (memory,
1971
+ * JSON, SQLite, IndexedDB) is chosen by whoever builds it (the factories), so a JSON / SQLite /
1972
+ * IndexedDB backend swaps in WITHOUT touching the manager — the same seam as
1973
+ * {@link import('./MemoryTerminalStore.js').MemoryTerminalStore}. The driver defaults to memory
1974
+ * ({@link import('./factories.js').createDatabaseTerminalStore} passes `createMemoryDriver()`), so
1975
+ * it ALSO works in memory out of the box; you opt into the durable plumbing by passing a JSON /
1976
+ * SQLite / IndexedDB driver.
1977
+ *
1978
+ * The {@link TerminalSnapshot} is stored as ONE OPAQUE JSON COLUMN — the table is a row of
1979
+ * `{ id; snapshot }` ({@link TerminalSnapshotRow}). The snapshot is already a COMPLETE,
1980
+ * self-contained, pure-JSON CONFIG payload (no live broker state), so storing it whole is lossless
1981
+ * AND keeps the row type flat (`snapshot` reads back as `unknown`).
1982
+ *
1983
+ * - **`set(snapshot)` upserts under the snapshot's OWN `id`** (no separate id param) — it writes
1984
+ * the row `{ id: snapshot.id, snapshot }`.
1985
+ * - **`get(id)` resolves the stored snapshot for an id**, narrowing the opaque JSON column back to
1986
+ * a {@link TerminalSnapshot} ({@link import('./helpers.js').isTerminalSnapshot} — the AGENTS §14
1987
+ * boundary narrow for an untrusted storage read), or `undefined` if none is stored.
1988
+ * - **`delete(id)` drops a snapshot by id**; an absent id is a no-op (no throw).
1989
+ *
1990
+ * There is NO idle-TTL / eviction — a persisted config lives until an explicit `delete`. The public
1991
+ * surface is EXACTLY `get` / `set` / `delete` — no extra members (the §22 method bijection with
1992
+ * {@link TerminalStoreInterface}). Hydration stays a caller concern: `open` always restores an EMPTY
1993
+ * broker — parked Promises are process-bound and never resurrected.
1994
+ *
1995
+ * @example
1996
+ * ```ts
1997
+ * import { createDatabaseTerminalStore, createMemoryDriver } from '@src/core'
1998
+ *
1999
+ * const store = createDatabaseTerminalStore(createMemoryDriver()) // a durable driver swaps in here
2000
+ * await store.set({ id: 'shell', timeout: 5000 }) // persist the config (one JSON column)
2001
+ * const snapshot = await store.get('shell')
2002
+ * await store.delete('shell') // drop it
2003
+ * ```
2004
+ */
2005
+ var DatabaseTerminalStore = class {
2006
+ #table;
2007
+ /**
2008
+ * Wrap a table as a terminal store.
2009
+ *
2010
+ * @param table - The {@link TableInterface} holding the snapshots — its row is the
2011
+ * {@link TerminalSnapshotRow} `{ id; snapshot }` shape (the snapshot one opaque JSON column)
2012
+ */
2013
+ constructor(table) {
2014
+ this.#table = table;
2015
+ }
2016
+ /** Resolve the persisted snapshot for `id`, narrowing the opaque JSON column back to a `TerminalSnapshot`. */
2017
+ async get(id) {
2018
+ const row = await this.#table.get(id);
2019
+ if (row === void 0) return void 0;
2020
+ return isTerminalSnapshot(row.snapshot) ? row.snapshot : void 0;
2021
+ }
2022
+ /** Insert or replace under the snapshot's OWN `id` (no separate id param) — the row is `{ id, snapshot }`. */
2023
+ async set(snapshot) {
2024
+ await this.#table.set({
2025
+ id: snapshot.id,
2026
+ snapshot
2027
+ });
2028
+ }
2029
+ /** Drop a snapshot by id; an absent id is a no-op (no throw). */
2030
+ async delete(id) {
2031
+ await this.#table.remove(id);
2032
+ }
2033
+ };
2034
+ //#endregion
1591
2035
  //#region src/core/factories.ts
1592
2036
  /**
1593
2037
  * Create the headless prompt {@link PromptInterface} BROKER — it parks each prompt call as a
@@ -1646,6 +2090,65 @@ function createPrompt(options) {
1646
2090
  function createPromptClient(options) {
1647
2091
  return new PromptClient(options);
1648
2092
  }
2093
+ /**
2094
+ * Create the multi-endpoint {@link TerminalManagerInterface} — a named registry of
2095
+ * {@link PromptInterface} brokers so several parties can `ask` prompts of each other by name,
2096
+ * with a transitive DEADLOCK check across every in-flight ask.
2097
+ *
2098
+ * @param options - See {@link TerminalManagerOptions}
2099
+ * @returns A {@link TerminalManagerInterface}
2100
+ *
2101
+ * @example
2102
+ * ```ts
2103
+ * import { createTerminalManager } from '@src/core'
2104
+ *
2105
+ * const manager = createTerminalManager()
2106
+ * manager.add('agent')
2107
+ * ```
2108
+ */
2109
+ function createTerminalManager(options) {
2110
+ return new TerminalManager(options);
2111
+ }
2112
+ /**
2113
+ * Create the in-memory {@link TerminalStoreInterface} — a process-lifetime `Map` of endpoint
2114
+ * config snapshots, the default store backing a {@link TerminalManagerInterface}'s `open` / `save`.
2115
+ *
2116
+ * @returns A {@link TerminalStoreInterface}
2117
+ *
2118
+ * @example
2119
+ * ```ts
2120
+ * import { createMemoryTerminalStore } from '@src/core'
2121
+ *
2122
+ * const store = createMemoryTerminalStore()
2123
+ * ```
2124
+ */
2125
+ function createMemoryTerminalStore() {
2126
+ return new MemoryTerminalStore();
2127
+ }
2128
+ /**
2129
+ * Create a {@link TerminalStoreInterface} backed by one table of the `databases` layer — the
2130
+ * driver-pluggable twin of {@link createMemoryTerminalStore}, storing each endpoint's config
2131
+ * snapshot as one opaque JSON column.
2132
+ *
2133
+ * @param driver - The {@link DriverInterface} backing the table (default an in-memory driver)
2134
+ * @returns A {@link TerminalStoreInterface}
2135
+ *
2136
+ * @example
2137
+ * ```ts
2138
+ * import { createDatabaseTerminalStore } from '@src/core'
2139
+ *
2140
+ * const store = createDatabaseTerminalStore() // in-memory by default
2141
+ * ```
2142
+ */
2143
+ function createDatabaseTerminalStore(driver = (0, _orkestrel_database.createMemoryDriver)()) {
2144
+ return new DatabaseTerminalStore((0, _orkestrel_database.createDatabase)({
2145
+ driver,
2146
+ tables: { terminals: {
2147
+ id: (0, _orkestrel_contract.stringShape)(),
2148
+ snapshot: (0, _orkestrel_contract.rawShape)({})
2149
+ } }
2150
+ }).table("terminals"));
2151
+ }
1649
2152
  //#endregion
1650
2153
  exports.ACCEPT_EVENT_STREAM = ACCEPT_EVENT_STREAM;
1651
2154
  exports.ALPHANUMERIC_PATTERN = ALPHANUMERIC_PATTERN;
@@ -1660,12 +2163,14 @@ exports.DEFAULT_MASK = DEFAULT_MASK;
1660
2163
  exports.DEFAULT_PROMPT_TIMEOUT_MS = DEFAULT_PROMPT_TIMEOUT_MS;
1661
2164
  exports.DEFAULT_RECONNECT_DELAY_MS = DEFAULT_RECONNECT_DELAY_MS;
1662
2165
  exports.DELETE = DELETE;
2166
+ exports.DatabaseTerminalStore = DatabaseTerminalStore;
1663
2167
  exports.EMAIL_PATTERN = EMAIL_PATTERN;
1664
2168
  exports.ESCAPE = ESCAPE;
1665
2169
  exports.HEADER_TOKEN = HEADER_TOKEN;
1666
2170
  exports.INTEGER_PATTERN = INTEGER_PATTERN;
1667
2171
  exports.KEY_CSI = KEY_CSI;
1668
2172
  exports.KEY_SS3 = KEY_SS3;
2173
+ exports.MemoryTerminalStore = MemoryTerminalStore;
1669
2174
  exports.NEWLINE = NEWLINE;
1670
2175
  exports.NUMERIC_PATTERN = NUMERIC_PATTERN;
1671
2176
  exports.PROMPT_ICONS = PROMPT_ICONS;
@@ -1679,6 +2184,7 @@ exports.SSE_BUFFER_LIMIT = SSE_BUFFER_LIMIT;
1679
2184
  exports.SSE_EVENTS = SSE_EVENTS;
1680
2185
  exports.TAB = TAB;
1681
2186
  exports.TerminalError = TerminalError;
2187
+ exports.TerminalManager = TerminalManager;
1682
2188
  exports.URL_PATTERN = URL_PATTERN;
1683
2189
  exports.appendRule = appendRule;
1684
2190
  exports.buildRuleValidator = buildRuleValidator;
@@ -1689,12 +2195,15 @@ exports.confirmReduce = confirmReduce;
1689
2195
  exports.confirmView = confirmView;
1690
2196
  exports.createCheckboxState = createCheckboxState;
1691
2197
  exports.createConfirmState = createConfirmState;
2198
+ exports.createDatabaseTerminalStore = createDatabaseTerminalStore;
1692
2199
  exports.createEditorState = createEditorState;
1693
2200
  exports.createInputState = createInputState;
2201
+ exports.createMemoryTerminalStore = createMemoryTerminalStore;
1694
2202
  exports.createPasswordState = createPasswordState;
1695
2203
  exports.createPrompt = createPrompt;
1696
2204
  exports.createPromptClient = createPromptClient;
1697
2205
  exports.createSelectState = createSelectState;
2206
+ exports.createTerminalManager = createTerminalManager;
1698
2207
  exports.defaultTimer = defaultTimer;
1699
2208
  exports.dispatchPendingPrompt = dispatchPendingPrompt;
1700
2209
  exports.editLine = editLine;
@@ -1707,6 +2216,7 @@ exports.globalFetch = globalFetch;
1707
2216
  exports.inputReduce = inputReduce;
1708
2217
  exports.inputView = inputView;
1709
2218
  exports.isAbortError = isAbortError;
2219
+ exports.isAnswerPayload = isAnswerPayload;
1710
2220
  exports.isCheckboxChoice = isCheckboxChoice;
1711
2221
  exports.isInsecureRemote = isInsecureRemote;
1712
2222
  exports.isPendingPrompt = isPendingPrompt;
@@ -1715,6 +2225,7 @@ exports.isPrintable = isPrintable;
1715
2225
  exports.isPromptChoice = isPromptChoice;
1716
2226
  exports.isPromptType = isPromptType;
1717
2227
  exports.isTerminalError = isTerminalError;
2228
+ exports.isTerminalSnapshot = isTerminalSnapshot;
1718
2229
  exports.normalizeCheckboxChoice = normalizeCheckboxChoice;
1719
2230
  exports.normalizeChoice = normalizeChoice;
1720
2231
  exports.parseKey = parseKey;
@@ -1731,7 +2242,10 @@ exports.sanitizeChoiceLabels = sanitizeChoiceLabels;
1731
2242
  exports.selectReduce = selectReduce;
1732
2243
  exports.selectView = selectView;
1733
2244
  exports.serializeChoices = serializeChoices;
2245
+ exports.serializeExpire = serializeExpire;
2246
+ exports.serializePending = serializePending;
1734
2247
  exports.serializePromptOptions = serializePromptOptions;
2248
+ exports.serializeShutdown = serializeShutdown;
1735
2249
  exports.serializeValidationRules = serializeValidationRules;
1736
2250
  exports.submitHeader = submitHeader;
1737
2251
  exports.toggleIndex = toggleIndex;