@mrciphersmith/keryx 0.3.110 → 0.3.111

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.
Files changed (3) hide show
  1. package/README.md +15 -3
  2. package/dist/cli.js +581 -48
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1060,13 +1060,25 @@ runs. At most 500 lines wait per topic, and turning it off tells the topic which
1060
1060
  queued lines will not run. From the topic you can also run a fixed list of slash
1061
1061
  commands (`/status`, `/model` and `/connect` as buttons, `/new`, `/clear`, `/resume`
1062
1062
  in the same topic); `/mode trust`, `/plan off`, `/delegate` and `/external*` run only
1063
- after a Yes press, and commands such as `/mcp`, `/guard`, `/route`, `/provider`,
1064
- `/schedule`, `/rewind`, `/conform` and `/ci` stay local. A topic is deleted when the
1065
- shell exits, so `/history [N]` (1 to 20, 10 by default) posts the session's last messages
1063
+ after a Yes press (so do `/route on` and `/route off`; bare `/route` shows at once),
1064
+ and commands such as `/mcp`, `/guard`, `/provider`, `/schedule`, `/rewind`,
1065
+ `/conform` and `/ci` stay local. When main is busy, `/route`, `/external`,
1066
+ `/external-agents` and `/model` are queued with a notice in the topic and run when
1067
+ it goes idle; `/mode` takes effect at once. A topic is archived when the shell
1068
+ exits (a closing note, the topic and its history stay readable), but a resumed
1069
+ session gets a new one, so `/history [N]` (1 to 20, 10 by default) posts the session's last messages
1066
1070
  back into the topic, yours and the agent's final answers, one message each; a resumed
1067
1071
  session whose topic was just created gets the last 10 by itself, once (the first time remote control is turned on in that shell); a turn that looks like it holds a secret is replaced by a placeholder. Each message shows its state as a reaction and the topic shows typing
1068
1072
  while a turn runs; see
1069
1073
  [commands from the topic](docs/docs/guides/drive-keryx-remotely.md#commands-from-the-topic).
1074
+ Every approval, `ask_user` question and queue dialog the shell raises is posted to
1075
+ the topic with buttons, whoever started the turn; the first answer wins, from the
1076
+ terminal or the topic, and an unanswered one gets a reminder every 2 minutes (at most 3).
1077
+ While a turn runs, the topic gets at most one progress note a minute (at wave
1078
+ boundaries and after tool-heavy stretches: counts and tool names, never tool output),
1079
+ and a short status question such as "how is it going?" is answered from session state
1080
+ without waiting for the turn; see
1081
+ [What the topic hears](docs/docs/guides/drive-keryx-remotely.md#what-the-topic-hears).
1070
1082
  Without a connection `serve` starts as before, with remote control off, and only
1071
1083
  offers the local `/channels` routes. Replies are rendered in one of four modes,
1072
1084
  `remote.rendering` = `auto` (the default), `rich`, `html` or `plain`, set from the
package/dist/cli.js CHANGED
@@ -21147,7 +21147,7 @@ var package_default;
21147
21147
  var init_package = __esm(() => {
21148
21148
  package_default = {
21149
21149
  name: "@mrciphersmith/keryx",
21150
- version: "0.3.110",
21150
+ version: "0.3.111",
21151
21151
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
21152
21152
  private: false,
21153
21153
  publishConfig: {
@@ -123364,7 +123364,7 @@ class SseParser {
123364
123364
  return frames;
123365
123365
  }
123366
123366
  }
123367
- var REMOTE_API_PREFIX = "/v1/remote", REMOTE_SCHEMA_VERSION = "1.0.0", REMOTE_ROUTE_METHODS, CHANNELS_ROUTE_METHODS, MAX_REMOTE_BODY_BYTES, MAX_PROJECT_CHARS = 256, MAX_APPROVAL_PROMPT_CHARS = 3000, MAX_KEYBOARD_ROWS = 8, MAX_KEYBOARD_BUTTONS_PER_ROW = 8, MAX_BUTTON_TEXT_CHARS = 64, MAX_CALLBACK_DATA_BYTES = 64, DEFAULT_APPROVAL_TIMEOUT_MS, MAX_APPROVAL_TIMEOUT_MS, MIN_APPROVAL_TIMEOUT_MS = 100, DEFAULT_APPROVAL_ACK_MS = 5000, DEFAULT_KEEPALIVE_MS = 15000, DEFAULT_ACK_TIMEOUT_MS = 30000, SESSION_ID_PATTERN, APPROVAL_ID_PATTERN, APPROVAL_CALLBACK_PATTERN, RESERVED_CALLBACK_PREFIX = "ap:", PROMPT_ID_PATTERN, CHOICE_CALLBACK_PATTERN, OWN_ANSWER_BUTTON_LABEL = "\u270D \u0421\u0432\u043E\u0439 \u043E\u0442\u0432\u0435\u0442", OWN_ANSWER_WINDOW_MS, OWN_REPLY_PROMPT = "Reply to this message with your own answer. It is open for 5 minutes.", MAX_OWN_ANSWER_CHARS = 4096, OWN_CALLBACK_PATTERN, RESERVED_CHOICE_PREFIX = "pk:", MAX_PROMPT_BUTTONS = 24, INTAKE_SERVICE_TOPIC = "Intake", RESERVED_INTAKE_PREFIX = "in:", MAX_REMEMBER_PATTERN_CHARS = 300, APPROVAL_INPUT_PREVIEW_CHARS = 1500, MESSAGE_STATES, SSE_KEEPALIVE_FRAME = `: keepalive
123367
+ var REMOTE_API_PREFIX = "/v1/remote", REMOTE_SCHEMA_VERSION = "1.0.0", REMOTE_ROUTE_METHODS, CHANNELS_ROUTE_METHODS, MAX_REMOTE_BODY_BYTES, MAX_PROJECT_CHARS = 256, MAX_APPROVAL_PROMPT_CHARS = 3000, MAX_KEYBOARD_ROWS = 8, MAX_KEYBOARD_BUTTONS_PER_ROW = 8, MAX_BUTTON_TEXT_CHARS = 64, MAX_CALLBACK_DATA_BYTES = 64, DEFAULT_APPROVAL_TIMEOUT_MS, MAX_APPROVAL_TIMEOUT_MS, MIN_APPROVAL_TIMEOUT_MS = 100, DEFAULT_PROMPT_REMINDER_MS, MAX_PROMPT_REMINDERS = 3, DEFAULT_APPROVAL_ACK_MS = 5000, DEFAULT_KEEPALIVE_MS = 15000, DEFAULT_ACK_TIMEOUT_MS = 30000, SESSION_ID_PATTERN, APPROVAL_ID_PATTERN, APPROVAL_CALLBACK_PATTERN, RESERVED_CALLBACK_PREFIX = "ap:", PROMPT_ID_PATTERN, CHOICE_CALLBACK_PATTERN, OWN_ANSWER_BUTTON_LABEL = "\u270D \u0421\u0432\u043E\u0439 \u043E\u0442\u0432\u0435\u0442", OWN_ANSWER_WINDOW_MS, OWN_REPLY_PROMPT = "Reply to this message with your own answer. It is open for 5 minutes.", MAX_OWN_ANSWER_CHARS = 4096, OWN_CALLBACK_PATTERN, RESERVED_CHOICE_PREFIX = "pk:", MAX_PROMPT_BUTTONS = 24, INTAKE_SERVICE_TOPIC = "Intake", RESERVED_INTAKE_PREFIX = "in:", MAX_REMEMBER_PATTERN_CHARS = 300, APPROVAL_INPUT_PREVIEW_CHARS = 1500, MESSAGE_STATES, SSE_KEEPALIVE_FRAME = `: keepalive
123368
123368
 
123369
123369
  `;
123370
123370
  var init_protocol2 = __esm(() => {
@@ -123394,6 +123394,7 @@ var init_protocol2 = __esm(() => {
123394
123394
  MAX_REMOTE_BODY_BYTES = 64 * 1024;
123395
123395
  DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60000;
123396
123396
  MAX_APPROVAL_TIMEOUT_MS = 60 * 60000;
123397
+ DEFAULT_PROMPT_REMINDER_MS = 2 * 60000;
123397
123398
  SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
123398
123399
  APPROVAL_ID_PATTERN = /^ap[0-9a-f]{12}$/;
123399
123400
  APPROVAL_CALLBACK_PATTERN = /^ap:(ap[0-9a-f]{12}):(allow|deny|always)$/;
@@ -123707,6 +123708,9 @@ var init_shell_token = __esm(() => {
123707
123708
  });
123708
123709
 
123709
123710
  // src/remote/client.ts
123711
+ function isAborted(signal) {
123712
+ return signal?.aborted === true;
123713
+ }
123710
123714
  function closeByFor(signal) {
123711
123715
  return signal?.reason === "cancelled" ? "cancelled" : "shell";
123712
123716
  }
@@ -123931,6 +123935,10 @@ class RemoteClient {
123931
123935
  if (!this.connected) {
123932
123936
  return { decision: "deny" };
123933
123937
  }
123938
+ if (isAborted(options.signal)) {
123939
+ await this.closePrompt(approvalId, closeByFor(options.signal));
123940
+ return { decision: "deny", approvalId };
123941
+ }
123934
123942
  return new Promise((resolve) => {
123935
123943
  const signal = options.signal;
123936
123944
  const onAbort = () => {
@@ -123939,6 +123947,7 @@ class RemoteClient {
123939
123947
  clearTimeout(waiter.timer);
123940
123948
  this.waiters.delete(approvalId);
123941
123949
  resolve({ decision: "deny", approvalId });
123950
+ this.closePrompt(approvalId, closeByFor(signal));
123942
123951
  };
123943
123952
  const timer = setTimeout(() => {
123944
123953
  signal?.removeEventListener("abort", onAbort);
@@ -124520,7 +124529,7 @@ function classifyRemoteCommand(line) {
124520
124529
  const spec = BY_NAME.get(canonical) ?? BY_MENU.get(token);
124521
124530
  if (canonical === "remote-control") {
124522
124531
  const sub = args.split(/\s+/)[0]?.toLowerCase() ?? "";
124523
- return refuse3(canonical, sub === "off" ? "it would delete this topic; turn it off in the shell" : refusedReason ?? "its panel is local", true);
124532
+ return refuse3(canonical, sub === "off" ? "it would archive this topic; turn it off in the shell" : refusedReason ?? "its panel is local", true);
124524
124533
  }
124525
124534
  if (spec === undefined) {
124526
124535
  if (refusedReason !== undefined) {
@@ -124565,6 +124574,16 @@ function classifyRemoteCommand(line) {
124565
124574
  };
124566
124575
  }
124567
124576
  return first === "" ? { kind: "run", command: name, line: "/external" } : refuse3(name, "use /external, /external on or /external off", true);
124577
+ case "route":
124578
+ if (first === "on" || first === "off") {
124579
+ return {
124580
+ kind: "confirm",
124581
+ command: name,
124582
+ line: `/route ${first}`,
124583
+ summary: `Switch model routing ${first}? This changes which model answers each turn.`
124584
+ };
124585
+ }
124586
+ return first === "" ? { kind: "run", command: name, line: "/route" } : refuse3(name, "use /route, /route on or /route off", true);
124568
124587
  case "external-agents":
124569
124588
  if (first === "on" || first === "off") {
124570
124589
  return {
@@ -124619,7 +124638,10 @@ function remoteHelpText() {
124619
124638
  function isBusyDeferred(name) {
124620
124639
  return BUSY_DEFERRED_COMMANDS.includes(name);
124621
124640
  }
124622
- var REMOTE_COMMANDS, REMOTE_REFUSED, BUSY_DEFERRED_COMMANDS, BUSY_REASON = "main is busy: command deferred", BY_NAME, BY_MENU;
124641
+ function isQueuedWhenBusy(name) {
124642
+ return QUEUED_WHEN_BUSY_COMMANDS.includes(name);
124643
+ }
124644
+ var REMOTE_COMMANDS, REMOTE_REFUSED, BUSY_DEFERRED_COMMANDS, QUEUED_WHEN_BUSY_COMMANDS, BUSY_REASON = "main is busy: command deferred", BY_NAME, BY_MENU;
124623
124645
  var init_command_gateway = __esm(() => {
124624
124646
  REMOTE_COMMANDS = [
124625
124647
  { name: "help", kind: "builtin", description: "List the commands you can use from this topic" },
@@ -124650,7 +124672,8 @@ var init_command_gateway = __esm(() => {
124650
124672
  { name: "scenarios", kind: "text", description: "User scenarios the working diff likely changes" },
124651
124673
  { name: "delegate", kind: "confirm", description: "Hand a paid task to an external agent: /delegate <agent> <task>; asks first" },
124652
124674
  { name: "external", kind: "confirm", description: "Show or switch sending work to external providers; on and off ask first" },
124653
- { name: "external-agents", kind: "confirm", description: "Show or switch the external agent runtime; on and off ask first" }
124675
+ { name: "external-agents", kind: "confirm", description: "Show or switch the external agent runtime; on and off ask first" },
124676
+ { name: "route", kind: "confirm", description: "Show or switch model routing: /route [on|off]; on and off ask first" }
124654
124677
  ];
124655
124678
  REMOTE_REFUSED = {
124656
124679
  exit: "it would close the shell",
@@ -124659,14 +124682,13 @@ var init_command_gateway = __esm(() => {
124659
124682
  provider: "it asks for provider keys and addresses, which are never typed into a chat",
124660
124683
  "search-provider": "it asks for provider keys, which are never typed into a chat",
124661
124684
  "search-connect": "it changes where searches are sent",
124662
- "remote-control": "turning remote control off would delete this topic, and its panel is local",
124685
+ "remote-control": "turning remote control off would archive this topic, and its panel is local",
124663
124686
  integrate: "it edits other tools' configuration on this machine",
124664
124687
  copy: "it uses this machine's clipboard",
124665
124688
  game: "it needs the shell's screen",
124666
124689
  setup: "it asks for keys and changes the configuration",
124667
124690
  mcp: "it can trust tools and change server connections; use the shell",
124668
124691
  guard: "it changes a safety guard; use the shell",
124669
- route: "it changes where work is routed; use the shell",
124670
124692
  editguard: "it changes a safety guard; use the shell",
124671
124693
  settings: "it edits settings, some of them secret; use the shell",
124672
124694
  bus: "it talks to other agents on this machine",
@@ -124695,7 +124717,8 @@ var init_command_gateway = __esm(() => {
124695
124717
  ci: "it opens a panel in the shell",
124696
124718
  jevprofile: "it opens a panel with toggles in the shell"
124697
124719
  };
124698
- BUSY_DEFERRED_COMMANDS = ["new", "clear", "resume", "sessions", "compact", "model", "connect"];
124720
+ BUSY_DEFERRED_COMMANDS = ["new", "clear", "resume", "sessions", "compact", "model", "connect", "route", "external", "external-agents"];
124721
+ QUEUED_WHEN_BUSY_COMMANDS = ["model", "route", "external", "external-agents"];
124699
124722
  BY_NAME = new Map(REMOTE_COMMANDS.map((spec) => [spec.name, spec]));
124700
124723
  BY_MENU = new Map(REMOTE_COMMANDS.map((spec) => [menuName(spec.name), spec]));
124701
124724
  });
@@ -124873,10 +124896,42 @@ class RemoteCommandRouter {
124873
124896
  this.ctx.reply(refusalText(decision.command, decision.reason)).catch(() => false);
124874
124897
  }
124875
124898
  enqueue(line, command, end) {
124899
+ if (isQueuedWhenBusy(command) && this.busyReason(line, command) !== undefined) {
124900
+ this.detach(async () => {
124901
+ if (await this.awaitIdle(line, command)) {
124902
+ this.enqueueNow(line, command, end);
124903
+ } else {
124904
+ ended(end, "failed");
124905
+ }
124906
+ });
124907
+ return;
124908
+ }
124909
+ this.enqueueNow(line, command, end);
124910
+ }
124911
+ enqueueNow(line, command, end) {
124876
124912
  this.chain = this.chain.then(() => this.execute(command, line, async () => this.ctx.host.runCommand === undefined ? undefined : this.ctx.host.runCommand(line), end)).catch(() => {
124877
124913
  return;
124878
124914
  });
124879
124915
  }
124916
+ async awaitIdle(line, command) {
124917
+ this.ctx.record(`/${command} queued: main is busy`);
124918
+ await this.ctx.reply(this.ctx.compose(`${line} is queued: main is busy. It runs when main is idle.`)).catch(() => false);
124919
+ const { idlePollMs = 250, queuedCommandLimitMs = 30 * 60000 } = this.ctx.limits;
124920
+ const until = Date.now() + queuedCommandLimitMs;
124921
+ while (this.ctx.host.isBusy()) {
124922
+ if (this.ctx.live?.() === false) {
124923
+ this.ctx.record(`${line} dropped: remote control went off while it waited`);
124924
+ return false;
124925
+ }
124926
+ if (Date.now() >= until) {
124927
+ this.ctx.record(`${line} dropped: main stayed busy`);
124928
+ await this.ctx.reply(`${line} was not run: main stayed busy for ${Math.max(1, Math.round(queuedCommandLimitMs / 60000))} minutes. Send it again when the run ends.`).catch(() => false);
124929
+ return false;
124930
+ }
124931
+ await new Promise((resolve) => setTimeout(resolve, idlePollMs));
124932
+ }
124933
+ return true;
124934
+ }
124880
124935
  serial(work) {
124881
124936
  const run = this.chain.then(work);
124882
124937
  this.chain = run.then(() => {
@@ -125037,7 +125092,7 @@ Page ${page + 1} of ${pages}` : title;
125037
125092
  async picker(command, fromId, end) {
125038
125093
  const line = `/${command}`;
125039
125094
  const busy = this.busyReason(line, command);
125040
- if (busy !== undefined) {
125095
+ if (busy !== undefined && !isQueuedWhenBusy(command)) {
125041
125096
  this.ctx.record(`${line} not run: ${busy}`);
125042
125097
  await this.ctx.reply(`${line} was not run: ${busy}.`).catch(() => false);
125043
125098
  return "failed";
@@ -125079,6 +125134,9 @@ Now: ${current.label}` : ""}`;
125079
125134
  return "failed";
125080
125135
  }
125081
125136
  const switchModel = host.switchModel.bind(host);
125137
+ if (this.busyReason("/model", "model") !== undefined && !await this.awaitIdle(`/model ${chosen.label.replace(/^\* /, "")}`, "model")) {
125138
+ return "failed";
125139
+ }
125082
125140
  const switched = await this.serial(async () => {
125083
125141
  const busy = this.busyReason("/model", "model");
125084
125142
  return busy !== undefined ? { busy } : await switchModel(chosen.id, providerId);
@@ -125167,10 +125225,240 @@ var DEFAULT_ROUTER_LIMITS, PICKER_PAGE_SIZE = 7, LABEL_CHARS = 56, SESSION_COMMA
125167
125225
  var init_command_router = __esm(() => {
125168
125226
  init_command_gateway();
125169
125227
  init_history2();
125170
- DEFAULT_ROUTER_LIMITS = { runningNoticeMs: 4000, commandLimitMs: 120000, choiceTimeoutMs: 120000 };
125228
+ DEFAULT_ROUTER_LIMITS = {
125229
+ runningNoticeMs: 4000,
125230
+ commandLimitMs: 120000,
125231
+ choiceTimeoutMs: 120000,
125232
+ idlePollMs: 250,
125233
+ queuedCommandLimitMs: 30 * 60000
125234
+ };
125171
125235
  SESSION_COMMANDS = ["new", "clear", "resume"];
125172
125236
  });
125173
125237
 
125238
+ // src/remote/progress-notes.ts
125239
+ function formatElapsed(ms) {
125240
+ const total = Math.max(0, Math.round(ms / 1000));
125241
+ if (total < 60)
125242
+ return `${total}s`;
125243
+ const minutes = Math.floor(total / 60);
125244
+ const seconds = total % 60;
125245
+ if (minutes < 60)
125246
+ return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
125247
+ const hours = Math.floor(minutes / 60);
125248
+ return `${hours}h ${minutes % 60}m`;
125249
+ }
125250
+
125251
+ class ProgressNotes {
125252
+ intervalMs;
125253
+ threshold;
125254
+ now;
125255
+ send;
125256
+ setTimer;
125257
+ clearTimer;
125258
+ startedAt;
125259
+ toolCalls = 0;
125260
+ lastTool;
125261
+ sinceNote = 0;
125262
+ namesSinceNote = new Set;
125263
+ lastNoteAt;
125264
+ lastNoteWindowStart;
125265
+ pending;
125266
+ timer;
125267
+ wave = new Map;
125268
+ waveOpen = false;
125269
+ constructor(options) {
125270
+ this.intervalMs = options.intervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
125271
+ this.threshold = Math.max(1, options.toolThreshold ?? DEFAULT_PROGRESS_TOOL_THRESHOLD);
125272
+ this.now = options.now ?? Date.now;
125273
+ this.send = options.send;
125274
+ this.setTimer = options.setTimer ?? ((run, ms) => setTimeout(run, ms));
125275
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
125276
+ }
125277
+ get enabled() {
125278
+ return this.intervalMs > 0;
125279
+ }
125280
+ begin() {
125281
+ this.dropPending();
125282
+ this.startedAt = this.now();
125283
+ this.toolCalls = 0;
125284
+ this.lastTool = undefined;
125285
+ this.sinceNote = 0;
125286
+ this.namesSinceNote.clear();
125287
+ this.wave.clear();
125288
+ this.waveOpen = false;
125289
+ this.lastNoteWindowStart = this.startedAt;
125290
+ }
125291
+ end() {
125292
+ this.dropPending();
125293
+ this.startedAt = undefined;
125294
+ this.wave.clear();
125295
+ this.waveOpen = false;
125296
+ }
125297
+ reset() {
125298
+ this.end();
125299
+ this.lastNoteAt = undefined;
125300
+ }
125301
+ tool(name) {
125302
+ if (this.startedAt === undefined)
125303
+ return;
125304
+ this.toolCalls += 1;
125305
+ this.sinceNote += 1;
125306
+ if (name !== undefined && name.length > 0) {
125307
+ this.lastTool = name;
125308
+ this.namesSinceNote.add(name);
125309
+ }
125310
+ if (this.sinceNote >= this.threshold)
125311
+ this.post({ priority: 1, build: () => this.toolNote() });
125312
+ }
125313
+ subagent(event) {
125314
+ if (this.startedAt === undefined)
125315
+ return;
125316
+ if (event.kind === "upsert") {
125317
+ this.wave.set(event.id, { status: event.status, label: event.label ?? event.id });
125318
+ if (!this.waveOpen && ACTIVE.has(event.status)) {
125319
+ this.waveOpen = true;
125320
+ this.post({ priority: 2, build: () => this.waveStartNote() });
125321
+ return;
125322
+ }
125323
+ } else if (event.kind === "remove") {
125324
+ this.wave.delete(event.id);
125325
+ } else {
125326
+ return;
125327
+ }
125328
+ if (this.waveOpen && this.activeCount() === 0) {
125329
+ const done = [...this.wave.values()].filter((one) => one.status === "done").length;
125330
+ const failed = [...this.wave.values()].filter((one) => one.status === "failed").length;
125331
+ this.waveOpen = false;
125332
+ this.wave.clear();
125333
+ this.post({ priority: 3, build: () => this.waveEndNote(done, failed) });
125334
+ }
125335
+ }
125336
+ snapshot() {
125337
+ const running = this.startedAt !== undefined;
125338
+ return {
125339
+ running,
125340
+ elapsedMs: running ? this.now() - this.startedAt : 0,
125341
+ toolCalls: this.toolCalls,
125342
+ ...this.lastTool === undefined ? {} : { lastTool: this.lastTool },
125343
+ waveRunning: this.activeCount()
125344
+ };
125345
+ }
125346
+ activeCount() {
125347
+ let count = 0;
125348
+ for (const one of this.wave.values())
125349
+ if (ACTIVE.has(one.status))
125350
+ count += 1;
125351
+ return count;
125352
+ }
125353
+ elapsed() {
125354
+ return formatElapsed(this.startedAt === undefined ? 0 : this.now() - this.startedAt);
125355
+ }
125356
+ toolNote() {
125357
+ const names = [...this.namesSinceNote];
125358
+ const shown = names.slice(0, TOOL_NAMES_SHOWN).join(", ");
125359
+ const more = names.length > TOOL_NAMES_SHOWN ? ` and ${names.length - TOOL_NAMES_SHOWN} more` : "";
125360
+ const window = this.lastNoteWindowStart === undefined ? "" : ` in the last ${formatElapsed(this.now() - this.lastNoteWindowStart)}`;
125361
+ const used = shown.length > 0 ? ` (${shown}${more})` : "";
125362
+ return `Still working: ${this.sinceNote} tool calls${window}${used}. ${this.elapsed()} into this turn.`;
125363
+ }
125364
+ waveStartNote() {
125365
+ const labels = [...this.wave.values()].map((one) => one.label).slice(0, WAVE_LABELS_SHOWN);
125366
+ const total = this.activeCount();
125367
+ const more = total > labels.length ? ` and ${total - labels.length} more` : "";
125368
+ return `A subagent wave started: ${total} running${labels.length > 0 ? ` (${labels.join(", ")}${more})` : ""}. ${this.elapsed()} into this turn.`;
125369
+ }
125370
+ waveEndNote(done, failed) {
125371
+ const failedPart = failed > 0 ? `, ${failed} failed` : "";
125372
+ return `The wave finished: ${done} done${failedPart}. ${this.elapsed()} into this turn.`;
125373
+ }
125374
+ post(next) {
125375
+ if (!this.enabled)
125376
+ return;
125377
+ const at = this.now();
125378
+ if (this.lastNoteAt === undefined || at - this.lastNoteAt >= this.intervalMs) {
125379
+ this.emit(next);
125380
+ return;
125381
+ }
125382
+ if (this.pending === undefined || next.priority >= this.pending.priority)
125383
+ this.pending = next;
125384
+ if (this.timer === undefined) {
125385
+ this.timer = this.setTimer(() => {
125386
+ this.timer = undefined;
125387
+ const held = this.pending;
125388
+ this.pending = undefined;
125389
+ if (held !== undefined && this.startedAt !== undefined)
125390
+ this.emit(held);
125391
+ }, this.lastNoteAt + this.intervalMs - at);
125392
+ }
125393
+ }
125394
+ emit(note) {
125395
+ const text = note.build();
125396
+ this.lastNoteAt = this.now();
125397
+ this.lastNoteWindowStart = this.lastNoteAt;
125398
+ this.sinceNote = 0;
125399
+ this.namesSinceNote.clear();
125400
+ this.pending = undefined;
125401
+ try {
125402
+ this.send(text);
125403
+ } catch {}
125404
+ }
125405
+ dropPending() {
125406
+ this.pending = undefined;
125407
+ if (this.timer !== undefined) {
125408
+ this.clearTimer(this.timer);
125409
+ this.timer = undefined;
125410
+ }
125411
+ }
125412
+ }
125413
+ function isStatusQuestion(line) {
125414
+ const text = line.trim().toLowerCase().replace(/[?!.\u2026]+$/u, "").replace(/\s+/g, " ").trim();
125415
+ if (text.length === 0 || text.length > 60)
125416
+ return false;
125417
+ return STATUS_PATTERNS.some((pattern) => pattern.test(text));
125418
+ }
125419
+ function describeStatus(facts) {
125420
+ const lines = [];
125421
+ const { snapshot } = facts;
125422
+ if (facts.turnRunning && snapshot.running) {
125423
+ const tools = snapshot.toolCalls === 0 ? "no tool calls yet" : `${snapshot.toolCalls} tool call${snapshot.toolCalls === 1 ? "" : "s"}${snapshot.lastTool === undefined ? "" : `, last: ${snapshot.lastTool}`}`;
125424
+ lines.push(`Working: this turn has run for ${formatElapsed(snapshot.elapsedMs)}, ${tools}.`);
125425
+ } else if (facts.turnRunning) {
125426
+ lines.push("Working: a turn is running.");
125427
+ } else {
125428
+ lines.push("Busy: the shell is occupied with a command or its queue; no model turn is writing right now.");
125429
+ }
125430
+ if (snapshot.waveRunning > 0)
125431
+ lines.push(`Subagents: ${snapshot.waveRunning} still running.`);
125432
+ const queued = Math.max(facts.queued ?? 0, facts.topicWaiting);
125433
+ if (queued > 0) {
125434
+ lines.push(`Queue: ${queued} message${queued === 1 ? "" : "s"} waiting${facts.topicWaiting > 0 ? ` (${facts.topicWaiting} from this topic)` : ""}.`);
125435
+ }
125436
+ if (facts.openPrompts > 0) {
125437
+ lines.push(`Waiting for you: ${facts.openPrompts} approval or question open in this topic.`);
125438
+ }
125439
+ if (facts.mode !== undefined)
125440
+ lines.push(`Mode: ${facts.mode}.`);
125441
+ return lines.join(`
125442
+ `);
125443
+ }
125444
+ var DEFAULT_PROGRESS_INTERVAL_MS = 60000, DEFAULT_PROGRESS_TOOL_THRESHOLD = 8, TOOL_NAMES_SHOWN = 4, WAVE_LABELS_SHOWN = 3, ACTIVE, STATUS_PATTERNS;
125445
+ var init_progress_notes = __esm(() => {
125446
+ ACTIVE = new Set(["queued", "running", "blocked"]);
125447
+ STATUS_PATTERNS = [
125448
+ /^(status|progress|update)( (update|report|check))?( please| pls)?$/,
125449
+ /^(what'?s|what is|whats) (the )?(current )?(status|progress|going on|happening|up)( (now|right now|here))?$/,
125450
+ /^how('?s| is) (it|that|this|the (run|review|task|work|build)) (going|progressing|doing)( so far| now)?$/,
125451
+ /^(are you|r u) (still )?(working|running|busy|alive|there|done|finished|stuck)( yet| now)?$/,
125452
+ /^(is it|are we) (done|finished|ready|stuck)( yet)?$/,
125453
+ /^what are you (doing|working on)( now| right now)?$/,
125454
+ /^where are (you|we)( now| at)?$/,
125455
+ /^(any )?(news|progress|updates?)$/,
125456
+ /^still (working|running|there|alive)$/,
125457
+ /^(\u0441\u0442\u0430\u0442\u0443\u0441|\u043F\u0440\u043E\u0433\u0440\u0435\u0441\u0441|\u043A\u0430\u043A \u0434\u0435\u043B\u0430|\u043A\u0430\u043A \u0443\u0441\u043F\u0435\u0445\u0438|\u043A\u0430\u043A \u0438\u0434\u0451\u0442|\u043A\u0430\u043A \u0438\u0434\u0435\u0442|\u043A\u0430\u043A \u043F\u0440\u043E\u0434\u0432\u0438\u0433\u0430\u0435\u0442\u0441\u044F( \u0440\u0430\u0431\u043E\u0442\u0430)?|\u0447\u0442\u043E \u0442\u0430\u043C|\u0447\u0442\u043E \u043D\u043E\u0432\u043E\u0433\u043E|\u0447\u0442\u043E \u0434\u0435\u043B\u0430\u0435\u0448\u044C|\u0447\u0442\u043E \u043F\u0440\u043E\u0438\u0441\u0445\u043E\u0434\u0438\u0442|\u0447\u0442\u043E \u0441\u0435\u0439\u0447\u0430\u0441 \u043F\u0440\u043E\u0438\u0441\u0445\u043E\u0434\u0438\u0442)( (\u0441\u0435\u0439\u0447\u0430\u0441|\u0442\u0430\u043C|\u0443\u0436\u0435))*$/,
125458
+ /^(\u0442\u044B )?(\u0442\u0443\u0442|\u0436\u0438\u0432|\u0435\u0449\u0451 \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0448\u044C|\u0435\u0449\u0435 \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0448\u044C|\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0448\u044C|\u0437\u0430\u0432\u0438\u0441|\u0437\u0430\u0432\u0438\u0441(\u043B\u0430)?|\u0437\u0430\u043A\u043E\u043D\u0447\u0438\u043B|\u0437\u0430\u043A\u043E\u043D\u0447\u0438\u043B\u0430|\u0433\u043E\u0442\u043E\u0432|\u0433\u043E\u0442\u043E\u0432\u043E)( (\u0442\u0430\u043C|\u0443\u0436\u0435|\u0435\u0449\u0451|\u0435\u0449\u0435|\u0441\u0435\u0439\u0447\u0430\u0441))*$/
125459
+ ];
125460
+ });
125461
+
125174
125462
  // src/remote/shell-bridge.ts
125175
125463
  function preview2(text) {
125176
125464
  const one = redactSensitiveText(text).replace(/\s+/g, " ").trim();
@@ -125273,11 +125561,25 @@ class RemoteBridge {
125273
125561
  lastHistory;
125274
125562
  postingClient;
125275
125563
  enabledOnce = false;
125564
+ progress;
125565
+ openPrompts = 0;
125276
125566
  constructor(options) {
125277
125567
  this.options = options;
125278
125568
  this.host = options.host;
125279
125569
  this.now = options.now ?? Date.now;
125280
125570
  this.approvalOverrideMs = options.approvalTimeoutMs;
125571
+ this.progress = new ProgressNotes({
125572
+ ...options.progressIntervalMs === undefined ? {} : { intervalMs: options.progressIntervalMs },
125573
+ ...options.progressToolThreshold === undefined ? {} : { toolThreshold: options.progressToolThreshold },
125574
+ now: this.now,
125575
+ send: (text) => {
125576
+ const client = this.client;
125577
+ if (client === undefined || !client.connected)
125578
+ return;
125579
+ this.push("reply", "progress note");
125580
+ client.reply(composeReply(text)).catch(() => false);
125581
+ }
125582
+ });
125281
125583
  this.router = new RemoteCommandRouter({
125282
125584
  host: {
125283
125585
  isBusy: () => this.host.isBusy(),
@@ -125292,6 +125594,7 @@ class RemoteBridge {
125292
125594
  ...this.host.resumeSession !== undefined ? { resumeSession: (id) => this.host.resumeSession(id) } : {}
125293
125595
  },
125294
125596
  reply: async (text) => this.client !== undefined ? await this.client.reply(text) : false,
125597
+ live: () => this.client !== undefined,
125295
125598
  history: async (args) => await this.historyForCommand(args),
125296
125599
  record: (text) => this.push("command", text),
125297
125600
  compose: composeReply,
@@ -125326,7 +125629,9 @@ class RemoteBridge {
125326
125629
  limits: {
125327
125630
  runningNoticeMs: options.commandRunningNoticeMs ?? DEFAULT_ROUTER_LIMITS.runningNoticeMs,
125328
125631
  commandLimitMs: options.commandLimitMs ?? DEFAULT_ROUTER_LIMITS.commandLimitMs,
125329
- choiceTimeoutMs: options.choiceTimeoutMs ?? DEFAULT_ROUTER_LIMITS.choiceTimeoutMs
125632
+ choiceTimeoutMs: options.choiceTimeoutMs ?? DEFAULT_ROUTER_LIMITS.choiceTimeoutMs,
125633
+ ...options.idlePollMs !== undefined ? { idlePollMs: options.idlePollMs } : {},
125634
+ ...options.queuedCommandLimitMs !== undefined ? { queuedCommandLimitMs: options.queuedCommandLimitMs } : {}
125330
125635
  }
125331
125636
  });
125332
125637
  }
@@ -125441,6 +125746,7 @@ class RemoteBridge {
125441
125746
  this.clientConnected = false;
125442
125747
  this.lastHistory = undefined;
125443
125748
  this.endTurnState();
125749
+ this.progress.reset();
125444
125750
  this.waiting.length = 0;
125445
125751
  this.host.recordOff();
125446
125752
  const dropped = this.host.dropQueuedTelegramLines?.() ?? [];
@@ -125491,6 +125797,10 @@ class RemoteBridge {
125491
125797
  return;
125492
125798
  }
125493
125799
  this.push("line", `Telegram: ${preview2(line)}`);
125800
+ if (this.host.isBusy() && isStatusQuestion(line)) {
125801
+ this.answerStatus(meta.updateId);
125802
+ return;
125803
+ }
125494
125804
  this.waiting.push({ updateId: meta.updateId, line, ...meta.fromId === undefined ? {} : { fromId: meta.fromId } });
125495
125805
  if (this.host.isBusy()) {
125496
125806
  this.host.enqueue(line);
@@ -125499,6 +125809,22 @@ class RemoteBridge {
125499
125809
  this.host.runLine(line);
125500
125810
  }
125501
125811
  }
125812
+ answerStatus(updateId) {
125813
+ const client = this.client;
125814
+ if (client === undefined)
125815
+ return;
125816
+ this.push("command", "status question answered from session state");
125817
+ const state = this.host.sessionState?.();
125818
+ const text = describeStatus({
125819
+ turnRunning: this.host.turnRunning !== undefined ? this.host.turnRunning() : true,
125820
+ snapshot: this.progress.snapshot(),
125821
+ topicWaiting: this.waiting.length,
125822
+ ...state === undefined ? {} : { queued: state.queued },
125823
+ openPrompts: this.openPrompts,
125824
+ ...state?.mode === undefined ? {} : { mode: state.mode }
125825
+ });
125826
+ client.reply(composeReply(text)).then((sent) => this.report(updateId, sent ? "done" : "failed")).catch(() => this.report(updateId, "failed"));
125827
+ }
125502
125828
  report(updateId, state) {
125503
125829
  const client = this.client;
125504
125830
  if (client?.reportState === undefined)
@@ -125608,6 +125934,8 @@ class RemoteBridge {
125608
125934
  }
125609
125935
  turnStarted(source) {
125610
125936
  this.endTurnState();
125937
+ if (this.client !== undefined)
125938
+ this.progress.begin();
125611
125939
  if (source !== TG_SOURCE || this.client === undefined)
125612
125940
  return;
125613
125941
  const next = this.waiting.shift();
@@ -125625,7 +125953,7 @@ class RemoteBridge {
125625
125953
  this.runTimer = setTimeout(() => {
125626
125954
  this.timedOut = true;
125627
125955
  this.push("error", "run time limit reached; stopping the turn");
125628
- this.turnAbort?.abort();
125956
+ this.turnAbort?.abort("cancelled");
125629
125957
  this.host.cancelTurn();
125630
125958
  }, limit);
125631
125959
  }
@@ -125639,7 +125967,7 @@ class RemoteBridge {
125639
125967
  return "stopped";
125640
125968
  this.stoppedByUser = true;
125641
125969
  this.push("command", "/stop: stopping the turn");
125642
- this.turnAbort?.abort();
125970
+ this.turnAbort?.abort("cancelled");
125643
125971
  this.host.cancelTurn();
125644
125972
  return "stopped";
125645
125973
  }
@@ -125649,7 +125977,8 @@ class RemoteBridge {
125649
125977
  if (text.trim().length > 0)
125650
125978
  this.lastAssistantText = text;
125651
125979
  }
125652
- toolCall() {
125980
+ toolCall(name) {
125981
+ this.progress.tool(name);
125653
125982
  if (!this.tgTurn)
125654
125983
  return;
125655
125984
  this.lastAssistantText = "";
@@ -125668,6 +125997,7 @@ class RemoteBridge {
125668
125997
  client.reply(`Your message "${preview2(line)}" was removed from the queue in the shell, so it will not run.`).catch(() => false);
125669
125998
  }
125670
125999
  async turnSettled(outcome) {
126000
+ this.progress.end();
125671
126001
  if (!this.tgTurn)
125672
126002
  return;
125673
126003
  const client = this.client;
@@ -125695,14 +126025,20 @@ class RemoteBridge {
125695
126025
  const sent = await client.reply(body).catch(() => false);
125696
126026
  this.push(sent ? "reply" : "error", sent ? `reply sent (${body.length} chars)` : "the reply could not be sent");
125697
126027
  }
126028
+ subagentEvent(event) {
126029
+ if (this.client === undefined)
126030
+ return;
126031
+ this.progress.subagent(event);
126032
+ }
125698
126033
  endTurnState() {
126034
+ this.progress.end();
125699
126035
  this.tgTurn = false;
125700
126036
  this.currentUpdate = undefined;
125701
126037
  this.currentFromId = undefined;
125702
126038
  this.lastAssistantText = "";
125703
126039
  this.timedOut = false;
125704
126040
  this.stoppedByUser = false;
125705
- this.turnAbort?.abort();
126041
+ this.turnAbort?.abort("cancelled");
125706
126042
  this.turnAbort = undefined;
125707
126043
  if (this.runTimer !== undefined) {
125708
126044
  clearTimeout(this.runTimer);
@@ -125720,11 +126056,13 @@ class RemoteBridge {
125720
126056
  }
125721
126057
  this.push("approval", "asked in the topic");
125722
126058
  let answer;
126059
+ this.openPrompts += 1;
125723
126060
  try {
125724
126061
  const text = composeApprovalPrompt(prompt);
125725
- const timeoutMs = this.approvalTimeoutMs;
126062
+ const timeoutMs = options.timeoutMs ?? this.approvalTimeoutMs;
125726
126063
  if (client.askApproval !== undefined) {
125727
- const signal = this.turnAbort?.signal;
126064
+ const signals = [this.turnAbort?.signal, options.signal].filter((one) => one !== undefined);
126065
+ const signal = signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
125728
126066
  const got = await client.askApproval(text, timeoutMs, {
125729
126067
  ...options.remember === undefined ? {} : { remember: options.remember },
125730
126068
  ...signal === undefined ? {} : { signal }
@@ -125736,6 +126074,8 @@ class RemoteBridge {
125736
126074
  }
125737
126075
  } catch {
125738
126076
  answer = { decision: "deny", always: false };
126077
+ } finally {
126078
+ this.openPrompts -= 1;
125739
126079
  }
125740
126080
  const by = answer.fromId === undefined ? "" : ` by user ${answer.fromId}`;
125741
126081
  this.push("approval", answer.decision === "allow" ? `${answer.always ? "allowed (always)" : "allowed"} in the topic${by}` : `denied${by === "" ? " (or no answer)" : by}`);
@@ -125756,6 +126096,7 @@ class RemoteBridge {
125756
126096
  const timeoutMs = this.options.choiceTimeoutMs ?? DEFAULT_ROUTER_LIMITS.choiceTimeoutMs;
125757
126097
  const forUserId = this.telegramTurnUserId;
125758
126098
  this.push("command", "ask_user question asked in the topic");
126099
+ this.openPrompts += 1;
125759
126100
  try {
125760
126101
  if (client.askChoice === undefined) {
125761
126102
  const index = await client.requestChoice(text, rows, timeoutMs, forUserId);
@@ -125773,6 +126114,8 @@ class RemoteBridge {
125773
126114
  return;
125774
126115
  } catch {
125775
126116
  return;
126117
+ } finally {
126118
+ this.openPrompts -= 1;
125776
126119
  }
125777
126120
  }
125778
126121
  async reportRemembered(approvalId, remembered) {
@@ -125788,9 +126131,49 @@ var init_shell_bridge = __esm(() => {
125788
126131
  init_client3();
125789
126132
  init_command_router();
125790
126133
  init_protocol2();
126134
+ init_progress_notes();
125791
126135
  init_history2();
125792
126136
  });
125793
126137
 
126138
+ // src/tui/approval-mirror.ts
126139
+ function isTopicPress(answer) {
126140
+ return answer.decision === "allow" || answer.fromId !== undefined;
126141
+ }
126142
+ async function raceApproval(options) {
126143
+ const dockAbort = new AbortController;
126144
+ const topicAbort = new AbortController;
126145
+ let topicAnswer;
126146
+ let settled = false;
126147
+ let started = false;
126148
+ const race = {
126149
+ signal: AbortSignal.any([options.turnSignal, dockAbort.signal]),
126150
+ opened: () => {
126151
+ if (started || settled)
126152
+ return;
126153
+ started = true;
126154
+ options.topic(topicAbort.signal).then((answer) => {
126155
+ if (settled || !isTopicPress(answer))
126156
+ return;
126157
+ topicAnswer = answer;
126158
+ dockAbort.abort("topic");
126159
+ }).catch(() => {
126160
+ return;
126161
+ });
126162
+ },
126163
+ topicWon: () => topicAnswer !== undefined
126164
+ };
126165
+ try {
126166
+ const result = await options.dock(race);
126167
+ if (topicAnswer !== undefined)
126168
+ return { from: "topic", answer: topicAnswer };
126169
+ return { from: "dock", result };
126170
+ } finally {
126171
+ settled = true;
126172
+ if (topicAnswer === undefined)
126173
+ topicAbort.abort(options.turnSignal.aborted ? "cancelled" : "shell");
126174
+ }
126175
+ }
126176
+
125794
126177
  // src/remote/history-host.ts
125795
126178
  function sessionEntryOf(resumed) {
125796
126179
  return { resumed, kind: resumed ? "resumed" : "new" };
@@ -157197,9 +157580,9 @@ function createRemoteQueueWiring(deps) {
157197
157580
  if (bridge?.active !== true)
157198
157581
  return;
157199
157582
  const closing = bridge.disable();
157200
- deps.say(`\u25C7 remote control is turning off: the session changed, and its Telegram topic is being deleted. Turn it on again with /remote-control <name>.
157583
+ deps.say(`\u25C7 remote control is turning off: the session changed, and its Telegram topic is being archived. Turn it on again with /remote-control <name>.
157201
157584
  `);
157202
- closing.then(() => deps.say(`\u25C7 remote control off: the topic is deleted.
157585
+ closing.then(() => deps.say(`\u25C7 remote control off: the topic is archived.
157203
157586
  `));
157204
157587
  },
157205
157588
  dropQueuedTelegramLines() {
@@ -157231,7 +157614,7 @@ function createRemoteQueueWiring(deps) {
157231
157614
  },
157232
157615
  wrapOnToolCall(base) {
157233
157616
  return (...args) => {
157234
- deps.getBridge()?.toolCall();
157617
+ deps.getBridge()?.toolCall(typeof args[0] === "string" ? args[0] : undefined);
157235
157618
  base?.(...args);
157236
157619
  };
157237
157620
  },
@@ -157513,7 +157896,7 @@ function clip6(s, max) {
157513
157896
  }
157514
157897
  return `${s.slice(0, Math.max(1, max - 1))}\u2026`;
157515
157898
  }
157516
- function formatElapsed(ms) {
157899
+ function formatElapsed2(ms) {
157517
157900
  if (!Number.isFinite(ms) || ms < 0) {
157518
157901
  return "\u2014";
157519
157902
  }
@@ -157588,7 +157971,7 @@ function formatSubagentMeta(session, now = Date.now()) {
157588
157971
  session.runtime === "external" ? `external${session.agentId === undefined ? "" : ` (${session.agentId})`}` : "keryx"
157589
157972
  ],
157590
157973
  ["Model", session.model ?? "\u2014"],
157591
- ["Elapsed", formatElapsed(end - session.startedAt)],
157974
+ ["Elapsed", formatElapsed2(end - session.startedAt)],
157592
157975
  ["Task", session.task ?? "\u2014"]
157593
157976
  ];
157594
157977
  const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
@@ -166189,6 +166572,7 @@ async function launchTuiAgentShell(opts) {
166189
166572
  jobs.subscribe(paintJobs);
166190
166573
  setSubagentFleetListener((ev) => {
166191
166574
  sessions.apply(ev);
166575
+ remoteBridge?.subagentEvent(ev);
166192
166576
  });
166193
166577
  setBackgroundJobListener((ev) => {
166194
166578
  jobs.apply(ev);
@@ -166608,7 +166992,8 @@ async function launchTuiAgentShell(opts) {
166608
166992
  }));
166609
166993
  };
166610
166994
  const approvalContext = createApprovalContextLoader(opts.session?.cwd ?? process.cwd());
166611
- const confirmScheduleCard = async (card) => {
166995
+ const confirmScheduleCard = async (card, race) => {
166996
+ const dockSignal = race?.signal ?? foregroundOperation.signal;
166612
166997
  for (const line of card) {
166613
166998
  transcript.add(new otui.TextRenderable(r, { id: `ap${uid++}`, content: otui.t`${roleChunk(otui, "attention", line)}` }));
166614
166999
  }
@@ -166618,14 +167003,19 @@ async function launchTuiAgentShell(opts) {
166618
167003
  title: "Create this schedule and install its background timer?",
166619
167004
  subtitle: card[0] ?? "",
166620
167005
  cancelId: "cancel",
166621
- onOpen: () => chrome.blurComposer(),
166622
- signal: foregroundOperation.signal,
167006
+ onOpen: () => {
167007
+ chrome.blurComposer();
167008
+ race?.opened();
167009
+ },
167010
+ signal: dockSignal,
166623
167011
  options: [
166624
167012
  { id: "create", label: "Create and install", description: "Store it and install the timer shown above" },
166625
167013
  { id: "cancel", label: "Cancel", description: "Nothing is written or installed" }
166626
167014
  ]
166627
167015
  }));
166628
167016
  input.focus();
167017
+ if (race?.topicWon() === true)
167018
+ return false;
166629
167019
  setMainAgent("running", id === "create" ? "schedule" : "denied");
166630
167020
  transcript.add(new otui.TextRenderable(r, {
166631
167021
  id: `ap${uid++}`,
@@ -166633,7 +167023,8 @@ async function launchTuiAgentShell(opts) {
166633
167023
  }));
166634
167024
  return id === "create";
166635
167025
  };
166636
- const confirmUserPromptCard = async (card) => {
167026
+ const confirmUserPromptCard = async (card, race) => {
167027
+ const dockSignal = race?.signal ?? foregroundOperation.signal;
166637
167028
  for (const line of card) {
166638
167029
  transcript.add(new otui.TextRenderable(r, { id: `ap${uid++}`, content: otui.t`${roleChunk(otui, "attention", line)}` }));
166639
167030
  }
@@ -166643,14 +167034,19 @@ async function launchTuiAgentShell(opts) {
166643
167034
  title: "Send this message to the model?",
166644
167035
  subtitle: card[0] ?? "",
166645
167036
  cancelId: "cancel",
166646
- onOpen: () => chrome.blurComposer(),
166647
- signal: foregroundOperation.signal,
167037
+ onOpen: () => {
167038
+ chrome.blurComposer();
167039
+ race?.opened();
167040
+ },
167041
+ signal: dockSignal,
166648
167042
  options: [
166649
167043
  { id: "send", label: "Send", description: "Let the message reach the model as written" },
166650
167044
  { id: "cancel", label: "Cancel", description: "The message is not sent" }
166651
167045
  ]
166652
167046
  }));
166653
167047
  input.focus();
167048
+ if (race?.topicWon() === true)
167049
+ return false;
166654
167050
  setMainAgent("running", id === "send" ? "prompt" : "denied");
166655
167051
  transcript.add(new otui.TextRenderable(r, {
166656
167052
  id: `ap${uid++}`,
@@ -166682,7 +167078,8 @@ async function launchTuiAgentShell(opts) {
166682
167078
  content: otui.t`${dimChunk(otui, approvalCauseLine(causeOfMeta(tool, meta)))}`
166683
167079
  }));
166684
167080
  };
166685
- const requestApprovalLocally = async (tool, inputJson, meta) => {
167081
+ const requestApprovalLocally = async (tool, inputJson, meta, race) => {
167082
+ const dockSignal = race?.signal ?? foregroundOperation.signal;
166686
167083
  if (meta?.untrustedOrigin === true) {
166687
167084
  transcript.add(new otui.TextRenderable(r, {
166688
167085
  id: `ap${uid++}`,
@@ -166691,11 +167088,11 @@ async function launchTuiAgentShell(opts) {
166691
167088
  }
166692
167089
  if (isUserPromptApprovalCard(tool) && meta?.alwaysAsk === true && meta.card !== undefined) {
166693
167090
  addCauseLine(tool, meta);
166694
- return confirmUserPromptCard(meta.card);
167091
+ return confirmUserPromptCard(meta.card, race);
166695
167092
  }
166696
167093
  if (meta?.alwaysAsk === true && meta.card !== undefined) {
166697
167094
  addCauseLine(tool, meta);
166698
- return confirmScheduleCard(meta.card);
167095
+ return confirmScheduleCard(meta.card, race);
166699
167096
  }
166700
167097
  if (tool === "spawn_subagent") {
166701
167098
  let mode = "read_only";
@@ -166726,8 +167123,11 @@ async function launchTuiAgentShell(opts) {
166726
167123
  title: mode === "read_only" ? meta?.hookAsk === true ? "Spawn read-only subagent? (policy hook asked)" : "Spawn read-only subagent? (follows untrusted content)" : "Spawn general subagent?",
166727
167124
  subtitle: taskPreview,
166728
167125
  cancelId: "deny",
166729
- onOpen: () => chrome.blurComposer(),
166730
- signal: foregroundOperation.signal,
167126
+ onOpen: () => {
167127
+ chrome.blurComposer();
167128
+ race?.opened();
167129
+ },
167130
+ signal: dockSignal,
166731
167131
  options: [
166732
167132
  {
166733
167133
  id: "allow",
@@ -166739,6 +167139,8 @@ async function launchTuiAgentShell(opts) {
166739
167139
  ]
166740
167140
  }));
166741
167141
  input.focus();
167142
+ if (race?.topicWon() === true)
167143
+ return false;
166742
167144
  setMainAgent("running", id === "allow" ? "subagent" : "denied");
166743
167145
  transcript.add(new otui.TextRenderable(r, {
166744
167146
  id: `ap${uid++}`,
@@ -166771,14 +167173,19 @@ async function launchTuiAgentShell(opts) {
166771
167173
  title: "Approve apply_patch?",
166772
167174
  subtitle: "Diff shown above \xB7 Esc denies",
166773
167175
  cancelId: "deny",
166774
- onOpen: () => chrome.blurComposer(),
166775
- signal: foregroundOperation.signal,
167176
+ onOpen: () => {
167177
+ chrome.blurComposer();
167178
+ race?.opened();
167179
+ },
167180
+ signal: dockSignal,
166776
167181
  options: [
166777
167182
  { id: "allow", label: "Approve", description: "Write the patch to disk", recommended: true },
166778
167183
  { id: "deny", label: "Deny", description: "Do not write anything" }
166779
167184
  ]
166780
167185
  }));
166781
167186
  input.focus();
167187
+ if (race?.topicWon() === true)
167188
+ return false;
166782
167189
  setMainAgent("running", id === "allow" ? "write" : "denied");
166783
167190
  transcript.add(new otui.TextRenderable(r, {
166784
167191
  id: `ap${uid++}`,
@@ -166813,8 +167220,11 @@ async function launchTuiAgentShell(opts) {
166813
167220
  title: described.title,
166814
167221
  subtitle: summariseUseToolApproval(described),
166815
167222
  cancelId: "deny",
166816
- onOpen: () => chrome.blurComposer(),
166817
- signal: foregroundOperation.signal,
167223
+ onOpen: () => {
167224
+ chrome.blurComposer();
167225
+ race?.opened();
167226
+ },
167227
+ signal: dockSignal,
166818
167228
  options: [
166819
167229
  { id: APPROVAL_ALLOW_ID, label: "Approve", description: `run ${described.tool} on ${described.server}` },
166820
167230
  ...canTrustMcp ? [{ id: "trust-mcp", label: "Trust this tool (this session)", description: `Skip future approvals for ${described.fqn} in trust mode` }] : [],
@@ -166822,6 +167232,8 @@ async function launchTuiAgentShell(opts) {
166822
167232
  ]
166823
167233
  }));
166824
167234
  input.focus();
167235
+ if (race?.topicWon() === true)
167236
+ return false;
166825
167237
  const verdict = mcpDockVerdict(id, canTrustMcp, meta?.fingerprint);
166826
167238
  const allowed = verdict !== false;
166827
167239
  setMainAgent("running", allowed ? "write" : "denied");
@@ -166864,14 +167276,19 @@ async function launchTuiAgentShell(opts) {
166864
167276
  title: "Approve codex elicitation?",
166865
167277
  subtitle: elicitationSubtitle,
166866
167278
  cancelId: "deny",
166867
- onOpen: () => chrome.blurComposer(),
166868
- signal: foregroundOperation.signal,
167279
+ onOpen: () => {
167280
+ chrome.blurComposer();
167281
+ race?.opened();
167282
+ },
167283
+ signal: dockSignal,
166869
167284
  options: [
166870
167285
  { id: "allow", label: "Approve", description: "codex proceeds with this action", recommended: true },
166871
167286
  { id: "deny", label: "Deny", description: "codex's action is refused" }
166872
167287
  ]
166873
167288
  }));
166874
167289
  input.focus();
167290
+ if (race?.topicWon() === true)
167291
+ return false;
166875
167292
  setMainAgent("running", id === "allow" ? "write" : "denied");
166876
167293
  transcript.add(new otui.TextRenderable(r, {
166877
167294
  id: `ap${uid++}`,
@@ -166927,10 +167344,15 @@ async function launchTuiAgentShell(opts) {
166927
167344
  chrome.hideMenu();
166928
167345
  addCauseLine(tool, meta);
166929
167346
  const choice = await chrome.withOverlay(() => pickShellApproval(otui, r, chrome.dock, cmd, approvalContext, destructive, meta?.credentials === true, {
166930
- onOpen: () => chrome.blurComposer(),
166931
- signal: foregroundOperation.signal
167347
+ onOpen: () => {
167348
+ chrome.blurComposer();
167349
+ race?.opened();
167350
+ },
167351
+ signal: dockSignal
166932
167352
  }, ev.publishLease, ev.publishLeaseDetail, ev.hookAsk || ev.untrustedOrigin, causeOfMeta(tool, meta)));
166933
167353
  input.focus();
167354
+ if (race?.topicWon() === true)
167355
+ return false;
166934
167356
  if (choice === "deny") {
166935
167357
  transcript.add(new otui.TextRenderable(r, {
166936
167358
  id: `av${uid++}`,
@@ -167017,7 +167439,30 @@ async function launchTuiAgentShell(opts) {
167017
167439
  return false;
167018
167440
  return approvedAnswer();
167019
167441
  };
167020
- io.requestApproval = trackApproval(requestApprovalLocally);
167442
+ const mirrorApprovalToTopic = (inner) => async (tool, inputJson, meta) => {
167443
+ const bridge = remoteBridge;
167444
+ if (bridge === undefined || !bridge.active) {
167445
+ return inner(tool, inputJson, meta);
167446
+ }
167447
+ const brief = tool.length > 60 ? `${tool.slice(0, 59)}\u2026` : tool;
167448
+ const outcome = await raceApproval({
167449
+ turnSignal: foregroundOperation.signal,
167450
+ dock: (race) => inner(tool, inputJson, meta, race),
167451
+ topic: (signal) => bridge.askApproval(describeApprovalForTopic(tool, inputJson, meta), { signal, timeoutMs: MAX_APPROVAL_TIMEOUT_MS })
167452
+ });
167453
+ if (outcome.from === "dock")
167454
+ return outcome.result;
167455
+ const allowed = outcome.answer.decision === "allow";
167456
+ input.focus();
167457
+ setMainAgent("running", allowed ? "approved (telegram)" : "denied");
167458
+ io.onSystem?.(allowed ? `\u25C7 ${brief} approved in Telegram.
167459
+ ` : `\u25C7 ${brief} denied in Telegram.
167460
+ `);
167461
+ if (!allowed)
167462
+ return false;
167463
+ return meta?.fingerprint !== undefined ? { approved: true, fingerprint: meta.fingerprint } : true;
167464
+ };
167465
+ io.requestApproval = trackApproval(mirrorApprovalToTopic(requestApprovalLocally));
167021
167466
  const askUserInteractive = async (req) => {
167022
167467
  chrome.hideMenu();
167023
167468
  setMainAgent("blocked", "ask");
@@ -167027,7 +167472,7 @@ async function launchTuiAgentShell(opts) {
167027
167472
  id: `ask${uid++}`,
167028
167473
  content: otui.t`${roleChunk(otui, "attention", "? ")} ${dimChunk(otui, qShort)}`
167029
167474
  }));
167030
- const topicBridge = remoteBridge?.telegramTurnActive === true ? remoteBridge : undefined;
167475
+ const topicBridge = remoteBridge?.active === true ? remoteBridge : undefined;
167031
167476
  const outcome = await raceAskUser({
167032
167477
  turnSignal: foregroundOperation.signal,
167033
167478
  ...topicBridge === undefined ? {} : { topic: (signal) => topicBridge.askUser(req, signal) },
@@ -170182,6 +170627,7 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${mainQueue.length} wa
170182
170627
  project: () => sessionCwd,
170183
170628
  isBusy: () => chrome.isBusy() || foregroundOperation.isActive || forceHandoff.isAwaitingSettlement || mainQueue.length > 0 || leaseView()?.held() === true,
170184
170629
  turnRunning: () => chrome.isBusy() || foregroundOperation.isActive,
170630
+ sessionState: () => ({ queued: mainQueue.length, mode: formatModeInForce(modeForTurn(true)) }),
170185
170631
  runLine: (text) => remoteQueue.runTelegramLine(text),
170186
170632
  enqueue: (text) => remoteQueue.enqueueTelegramLine(text),
170187
170633
  notice: (text) => io.onSystem?.(`${text}
@@ -170341,6 +170787,7 @@ var init_tui_shell = __esm(() => {
170341
170787
  init_permissions_inspector();
170342
170788
  init_approval_cause();
170343
170789
  init_shell_bridge();
170790
+ init_protocol2();
170344
170791
  init_command_gateway();
170345
170792
  init_history_host();
170346
170793
  init_channels_client();
@@ -210019,6 +210466,12 @@ function createHttpBotApi(options) {
210019
210466
  message_thread_id: params.messageThreadId
210020
210467
  });
210021
210468
  },
210469
+ async closeForumTopic(params) {
210470
+ await call("closeForumTopic", {
210471
+ chat_id: params.chatId,
210472
+ message_thread_id: params.messageThreadId
210473
+ });
210474
+ },
210022
210475
  async editForumTopic(params) {
210023
210476
  await call("editForumTopic", {
210024
210477
  chat_id: params.chatId,
@@ -212585,7 +213038,7 @@ class RemoteHub {
212585
213038
  if (record === undefined) {
212586
213039
  return { ok: true, existed: false };
212587
213040
  }
212588
- await this.retire(record, "closed by the session");
213041
+ await this.archive(record, "The session was closed.");
212589
213042
  return { ok: true, existed: true };
212590
213043
  });
212591
213044
  }
@@ -212644,11 +213097,11 @@ class RemoteHub {
212644
213097
  this.event("session-unavailable", record.name);
212645
213098
  this.endActivity(record.sessionId);
212646
213099
  if (now - record.unavailableSince < this.config.orphanMs) {
212647
- this.queueStatus(record, `Session unavailable: the shell stopped responding. This topic is deleted in ${formatDuration(this.config.orphanMs)} unless the session returns.`);
213100
+ this.queueStatus(record, `Session unavailable: the shell stopped responding. This topic is archived in ${formatDuration(this.config.orphanMs)} unless the session returns.`);
212648
213101
  }
212649
213102
  }
212650
213103
  if (record.status === "unavailable" && record.unavailableSince !== undefined && now - record.unavailableSince >= this.config.orphanMs) {
212651
- await this.retire(record, "orphan timeout");
213104
+ await this.archive(record, "The shell stopped responding and did not return.");
212652
213105
  }
212653
213106
  }
212654
213107
  await this.flushOutbound();
@@ -212912,6 +213365,7 @@ class RemoteHub {
212912
213365
  markLive(record) {
212913
213366
  record.status = "live";
212914
213367
  delete record.unavailableSince;
213368
+ delete record.closing;
212915
213369
  }
212916
213370
  queueStatus(record, text) {
212917
213371
  this.outbound.enqueue({ chatId: record.chatId, threadId: record.threadId, text });
@@ -212991,6 +213445,31 @@ class RemoteHub {
212991
213445
  await this.flushOutbound();
212992
213446
  return { ok: true, name, threadId: record.threadId, reused: false };
212993
213447
  }
213448
+ async archive(record, reason) {
213449
+ this.stopTyping(record);
213450
+ if (record.closing !== true) {
213451
+ record.closing = true;
213452
+ this.queueStatus(record, `${reason} This topic is archived: the history stays here and nothing more will be posted. A new session opens a new topic.`);
213453
+ this.registry.save();
213454
+ }
213455
+ await this.flushOutbound();
213456
+ try {
213457
+ await this.api.closeForumTopic({ chatId: record.chatId, messageThreadId: record.threadId });
213458
+ } catch (error) {
213459
+ if (isBotApiError(error) && isRetryable(error)) {
213460
+ record.sessionId = "";
213461
+ record.status = "unavailable";
213462
+ record.unavailableSince = this.now() - this.config.orphanMs;
213463
+ this.registry.save();
213464
+ this.event("topic-close-failed", `${record.name}: ${describeError4(error)}`);
213465
+ return;
213466
+ }
213467
+ }
213468
+ this.inbound.remove(nameKey(record.name));
213469
+ this.registry.remove(record.name);
213470
+ this.registry.save();
213471
+ this.event("topic-archived", `${record.name}: ${reason}`);
213472
+ }
212994
213473
  async retire(record, why, strict = false) {
212995
213474
  this.outbound.discardForThread(record.chatId, record.threadId);
212996
213475
  this.stopTyping(record);
@@ -213463,6 +213942,7 @@ class RemoteHttpSurface {
213463
213942
  keepaliveMs;
213464
213943
  ackTimeoutMs;
213465
213944
  approvalAckMs;
213945
+ reminderMs;
213466
213946
  newApprovalId;
213467
213947
  newPromptId;
213468
213948
  streams = new Map;
@@ -213483,6 +213963,7 @@ class RemoteHttpSurface {
213483
213963
  this.keepaliveMs = options.keepaliveMs ?? DEFAULT_KEEPALIVE_MS;
213484
213964
  this.ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
213485
213965
  this.approvalAckMs = options.approvalAckMs ?? DEFAULT_APPROVAL_ACK_MS;
213966
+ this.reminderMs = options.reminderMs ?? DEFAULT_PROMPT_REMINDER_MS;
213486
213967
  this.newApprovalId = options.approvalIds ?? (() => `ap${randomBytes16(6).toString("hex")}`);
213487
213968
  this.newPromptId = options.promptIds ?? (() => `pk${randomBytes16(6).toString("hex")}`);
213488
213969
  this.consumer = {
@@ -213779,6 +214260,7 @@ class RemoteHttpSurface {
213779
214260
  timer: this.timers.setTimeout(() => this.expireApproval(approvalId), timeoutMs),
213780
214261
  ...remember === undefined ? {} : { remember }
213781
214262
  });
214263
+ this.scheduleReminder("approval", approvalId);
213782
214264
  const offer = remember === undefined ? "" : `
213783
214265
  Always would remember:
213784
214266
  ${asCodeBlock(remember)}`;
@@ -213908,6 +214390,7 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}${offer}`, {
213908
214390
  ...own ? { own: true } : {},
213909
214391
  timer: this.timers.setTimeout(() => this.expireChoice(promptId), timeoutMs)
213910
214392
  });
214393
+ this.scheduleReminder("question", promptId);
213911
214394
  const keyboard = [];
213912
214395
  let position = 0;
213913
214396
  for (const width of layout) {
@@ -213940,12 +214423,15 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}${offer}`, {
213940
214423
  if (!isSessionId(value.sessionId)) {
213941
214424
  return invalid2("sessionId must be 1 to 64 characters of letters, digits, '-' and '_'.");
213942
214425
  }
213943
- if (typeof value.promptId !== "string" || !isPromptId(value.promptId)) {
213944
- return invalid2("promptId must be a prompt id this serve issued.");
214426
+ if (typeof value.promptId !== "string" || !(isPromptId(value.promptId) || isApprovalId(value.promptId))) {
214427
+ return invalid2("promptId must be a prompt or approval id this serve issued.");
213945
214428
  }
213946
214429
  if (value.by !== undefined && value.by !== "shell" && value.by !== "cancelled") {
213947
214430
  return invalid2("by must be 'shell' or 'cancelled'.");
213948
214431
  }
214432
+ if (isApprovalId(value.promptId)) {
214433
+ return this.closeApproval(value.promptId, value.sessionId, value.by);
214434
+ }
213949
214435
  const entry = this.choices.get(value.promptId);
213950
214436
  if (entry === undefined || entry.sessionId !== value.sessionId) {
213951
214437
  const done = this.finished.get(value.promptId);
@@ -213967,6 +214453,21 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}${offer}`, {
213967
214453
  });
213968
214454
  return ok2({ closed: true });
213969
214455
  }
214456
+ closeApproval(approvalId, sessionId, by) {
214457
+ const entry = this.approvals.get(approvalId);
214458
+ if (entry === undefined || entry.sessionId !== sessionId) {
214459
+ return ok2({ closed: false });
214460
+ }
214461
+ this.dropApproval(approvalId);
214462
+ this.rememberDecided(approvalId, sessionId);
214463
+ const when = clockText(this.now());
214464
+ const answered = by !== "cancelled";
214465
+ this.finishApproval(approvalId, sessionId, entry.messageId, entry.text, {
214466
+ result: answered ? `Answered in the shell at ${when}.` : `Cancelled at ${when}: the request is no longer open.`,
214467
+ shortReply: answered ? "Answered in the shell." : "That approval is no longer open."
214468
+ });
214469
+ return ok2({ closed: true });
214470
+ }
213970
214471
  async messageState(hub, request) {
213971
214472
  const body = await readJsonBody(request);
213972
214473
  if (!body.ok) {
@@ -214415,6 +214916,7 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}${offer}`, {
214415
214916
  return;
214416
214917
  }
214417
214918
  this.timers.clearTimeout(entry.timer);
214919
+ this.timers.clearTimeout(entry.reminder);
214418
214920
  this.choices.delete(promptId);
214419
214921
  if (entry.armedReplyId !== undefined) {
214420
214922
  const armed = this.armedOwn.get(entry.armedReplyId);
@@ -214491,6 +214993,7 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}${offer}`, {
214491
214993
  return;
214492
214994
  }
214493
214995
  this.timers.clearTimeout(entry.timer);
214996
+ this.timers.clearTimeout(entry.reminder);
214494
214997
  this.approvals.delete(approvalId);
214495
214998
  }
214496
214999
  dropAwaiting(approvalId) {
@@ -214524,12 +215027,36 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}${offer}`, {
214524
215027
  shortReply: "Sent to the shell, not confirmed; it denies by itself if it did not receive it."
214525
215028
  });
214526
215029
  }
215030
+ scheduleReminder(kind, id) {
215031
+ const entry = kind === "approval" ? this.approvals.get(id) : this.choices.get(id);
215032
+ if (entry === undefined || (entry.reminded ?? 0) >= MAX_PROMPT_REMINDERS || this.reminderMs <= 0) {
215033
+ return;
215034
+ }
215035
+ entry.reminder = this.timers.setTimeout(() => this.remind(kind, id), this.reminderMs);
215036
+ }
215037
+ remind(kind, id) {
215038
+ const entry = kind === "approval" ? this.approvals.get(id) : this.choices.get(id);
215039
+ const hub = this.hub;
215040
+ if (entry === undefined || hub === undefined || this.closed) {
215041
+ return;
215042
+ }
215043
+ entry.reminded = (entry.reminded ?? 0) + 1;
215044
+ const left = entry.expiresAt - this.now();
215045
+ const what = kind === "approval" ? "An approval is" : "A question is";
215046
+ const topic = reminderExcerpt(entry.text);
215047
+ const ends = left > 0 ? ` It expires at ${clockText(entry.expiresAt)}.` : "";
215048
+ hub.send(entry.sessionId, `Reminder: ${what} still waiting for an answer here${topic === "" ? "" : `: ${topic}`}.${ends} The buttons are on the message above.`).catch(() => {
215049
+ return;
215050
+ });
215051
+ this.scheduleReminder(kind, id);
215052
+ }
214527
215053
  expireApproval(approvalId) {
214528
215054
  const entry = this.approvals.get(approvalId);
214529
215055
  if (entry === undefined) {
214530
215056
  return;
214531
215057
  }
214532
215058
  this.timers.clearTimeout(entry.timer);
215059
+ this.timers.clearTimeout(entry.reminder);
214533
215060
  this.approvals.delete(approvalId);
214534
215061
  this.finishApproval(approvalId, entry.sessionId, entry.messageId, entry.text, {
214535
215062
  result: `Expired at ${clockText(this.now())}: no answer, so it was denied.`,
@@ -214581,6 +215108,12 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}${offer}`, {
214581
215108
  }
214582
215109
  }
214583
215110
  }
215111
+ function reminderExcerpt(text) {
215112
+ const line = (text ?? "").split(`
215113
+ `).map((part) => part.replace(/^`{3,}.*$/, "").replace(/^Approval needed:\s*/, "").trim()).find((part) => part.length > 0) ?? "";
215114
+ const flat = line.replace(/\s+/g, " ");
215115
+ return flat.length <= 80 ? flat : `${flat.slice(0, 79)}\u2026`;
215116
+ }
214584
215117
  function alwaysButtonText(pattern) {
214585
215118
  const text = `Always: ${pattern.replace(/\s+/g, " ")}`;
214586
215119
  return text.length <= MAX_BUTTON_TEXT_CHARS ? text : `${text.slice(0, MAX_BUTTON_TEXT_CHARS - 1)}\u2026`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.3.110",
3
+ "version": "0.3.111",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {