@oh-my-pi/pi-coding-agent 16.0.2 → 16.0.3

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 (88) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +0 -1
  3. package/dist/cli.js +217 -276
  4. package/dist/types/advisor/advise-tool.d.ts +30 -1
  5. package/dist/types/commands/install.d.ts +1 -1
  6. package/dist/types/config/model-resolver.d.ts +8 -0
  7. package/dist/types/config/settings-schema.d.ts +0 -10
  8. package/dist/types/eval/js/shared/runtime.d.ts +1 -0
  9. package/dist/types/eval/js/worker-core.d.ts +1 -0
  10. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  11. package/dist/types/goals/runtime.d.ts +0 -1
  12. package/dist/types/mcp/tool-bridge.d.ts +3 -0
  13. package/dist/types/modes/components/custom-editor.d.ts +14 -4
  14. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  15. package/dist/types/modes/interactive-mode.d.ts +1 -1
  16. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +3 -2
  17. package/dist/types/modes/theme/mermaid-cache.d.ts +18 -1
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/registry/agent-lifecycle.d.ts +16 -1
  20. package/dist/types/sdk.d.ts +8 -0
  21. package/dist/types/session/agent-session.d.ts +20 -8
  22. package/dist/types/session/session-dump-format.d.ts +8 -2
  23. package/dist/types/session/session-entries.d.ts +4 -0
  24. package/dist/types/session/session-history-format.d.ts +2 -0
  25. package/dist/types/session/session-manager.d.ts +22 -0
  26. package/dist/types/stt/downloader.d.ts +5 -5
  27. package/dist/types/task/executor.d.ts +6 -0
  28. package/dist/types/task/persisted-revive.d.ts +36 -0
  29. package/dist/types/tiny/models.d.ts +8 -0
  30. package/dist/types/tools/builtin-names.d.ts +1 -1
  31. package/dist/types/tools/index.d.ts +0 -1
  32. package/package.json +12 -12
  33. package/src/advisor/__tests__/advisor.test.ts +150 -50
  34. package/src/advisor/advise-tool.ts +48 -6
  35. package/src/advisor/runtime.ts +10 -3
  36. package/src/auto-thinking/classifier.ts +12 -3
  37. package/src/cli.ts +2 -2
  38. package/src/commands/install.ts +3 -3
  39. package/src/config/model-resolver.ts +28 -11
  40. package/src/config/settings-schema.ts +0 -11
  41. package/src/eval/agent-bridge.ts +2 -0
  42. package/src/eval/js/context-manager.ts +2 -1
  43. package/src/eval/js/shared/runtime.ts +189 -15
  44. package/src/eval/js/worker-core.ts +19 -0
  45. package/src/export/html/index.ts +1 -1
  46. package/src/export/html/tool-views.generated.js +34 -35
  47. package/src/extensibility/extensions/loader.ts +21 -9
  48. package/src/goals/runtime.ts +1 -23
  49. package/src/internal-urls/docs-index.generated.ts +4 -6
  50. package/src/main.ts +20 -0
  51. package/src/mcp/render.ts +11 -1
  52. package/src/mcp/tool-bridge.ts +3 -0
  53. package/src/modes/components/custom-editor.test.ts +63 -18
  54. package/src/modes/components/custom-editor.ts +63 -15
  55. package/src/modes/controllers/command-controller.ts +2 -2
  56. package/src/modes/controllers/input-controller.ts +15 -9
  57. package/src/modes/controllers/selector-controller.ts +13 -8
  58. package/src/modes/controllers/tan-command-controller.ts +1 -0
  59. package/src/modes/interactive-mode.ts +4 -2
  60. package/src/modes/setup-wizard/wizard-overlay.ts +26 -4
  61. package/src/modes/theme/mermaid-cache.ts +74 -11
  62. package/src/modes/theme/theme.ts +14 -1
  63. package/src/modes/types.ts +1 -1
  64. package/src/prompts/system/system-prompt.md +2 -1
  65. package/src/registry/agent-lifecycle.ts +60 -8
  66. package/src/sdk.ts +20 -26
  67. package/src/session/agent-session.ts +246 -78
  68. package/src/session/artifacts.ts +19 -1
  69. package/src/session/session-dump-format.ts +167 -23
  70. package/src/session/session-entries.ts +4 -0
  71. package/src/session/session-history-format.ts +37 -3
  72. package/src/session/session-manager.ts +94 -4
  73. package/src/slash-commands/builtin-registry.ts +4 -7
  74. package/src/stt/asr-client.ts +6 -0
  75. package/src/stt/downloader.ts +13 -6
  76. package/src/stt/stt-controller.ts +52 -11
  77. package/src/task/executor.ts +18 -2
  78. package/src/task/index.ts +2 -2
  79. package/src/task/persisted-revive.ts +128 -0
  80. package/src/tiny/models.ts +10 -0
  81. package/src/tiny/worker.ts +4 -3
  82. package/src/tools/builtin-names.ts +0 -1
  83. package/src/tools/index.ts +0 -4
  84. package/src/tools/output-meta.ts +17 -3
  85. package/src/utils/title-generator.ts +4 -4
  86. package/dist/types/tools/render-mermaid.d.ts +0 -38
  87. package/src/prompts/tools/render-mermaid.md +0 -9
  88. package/src/tools/render-mermaid.ts +0 -69
@@ -126,7 +126,7 @@ import {
126
126
  AdvisorRuntime,
127
127
  type AdvisorSeverity,
128
128
  formatAdvisorBatchContent,
129
- isInterruptingSeverity,
129
+ resolveAdvisorDeliveryChannel,
130
130
  } from "../advisor";
131
131
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
132
132
  import { classifyDifficulty } from "../auto-thinking/classifier";
@@ -250,7 +250,7 @@ import {
250
250
  } from "../tool-discovery/tool-index";
251
251
  import { assertEditableFile } from "../tools/auto-generated-guard";
252
252
  import type { CheckpointState } from "../tools/checkpoint";
253
- import { outputMeta } from "../tools/output-meta";
253
+ import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
254
254
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
255
255
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
256
256
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
@@ -955,6 +955,44 @@ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
955
955
  return message.role === "custom" && message.customType === "advisor";
956
956
  }
957
957
 
958
+ /**
959
+ * A queued message the user can restore to the editor / pull back as a draft.
960
+ * Only genuinely user-authored messages qualify: plain user turns, or custom
961
+ * messages explicitly attributed to the user (e.g. `/skill` invocations).
962
+ * Agent-authored queued cards — advisor concern/blocker notes, IRC asides,
963
+ * extension notices, hidden goal/plan/budget steers — ride the same
964
+ * steer/follow-up queues but must never be dumped into the editor on Esc/Alt+Up.
965
+ */
966
+ function isUserQueuedMessage(message: AgentMessage): boolean {
967
+ if (message.role === "user") return true;
968
+ return message.role === "custom" && message.attribution === "user" && message.display !== false;
969
+ }
970
+
971
+ /** Custom-message types of the hidden magic-keyword notices that `#createMagicKeywordNotices`
972
+ * enqueues alongside a user prompt. Keep in sync with that method. */
973
+ const MAGIC_KEYWORD_NOTICE_TYPES: ReadonlySet<string> = new Set([
974
+ "ultrathink-notice",
975
+ "orchestrate-notice",
976
+ "workflow-notice",
977
+ ]);
978
+
979
+ /**
980
+ * A hidden, user-attributed companion of a queued user prompt: the magic-keyword
981
+ * notices (`ultrathink`/`orchestrate`/`workflow`) enqueued alongside the user
982
+ * message. They are `attribution: "user"` but `display: false`, so they are not
983
+ * editor-restorable; when the user pulls their prompt back out of the queue these
984
+ * must leave with it rather than linger as stale, companion-less steering. Scoped to
985
+ * the known notice types so an unrelated hidden user custom is never silently dropped.
986
+ */
987
+ function isHiddenUserCompanion(message: AgentMessage): boolean {
988
+ return (
989
+ message.role === "custom" &&
990
+ message.attribution === "user" &&
991
+ message.display === false &&
992
+ MAGIC_KEYWORD_NOTICE_TYPES.has(message.customType)
993
+ );
994
+ }
995
+
958
996
  function queueChipText(message: AgentMessage): string {
959
997
  if (message.role === "custom") {
960
998
  return readQueueChipText(message.details) ?? queuedTextContent(message) ?? "";
@@ -1260,7 +1298,70 @@ export class AgentSession {
1260
1298
  * queue was consumed normally or a new turn already started. */
1261
1299
  #drainStrandedQueuedMessages(): void {
1262
1300
  if (this.#abortInProgress) return;
1301
+ // A concern steered into a resumed streaming run after a user interrupt can
1302
+ // strand at the turn tail (steered past the loop's final boundary poll). While
1303
+ // that interrupt's suppression is still in effect, reclaim such advisor steers
1304
+ // as visible advice once idle — mirroring abort's #extractQueuedAdvisorCards —
1305
+ // so they neither auto-resume the run the user stopped (a non-empty steer queue
1306
+ // otherwise bypasses the latch in #canAutoContinueForFollowUp) nor linger to
1307
+ // flush at the next prompt. Real user steers/follow-ups are left untouched.
1308
+ if (this.#advisorAutoResumeSuppressed && !this.isStreaming) {
1309
+ for (const card of this.#extractQueuedAdvisorCards()) {
1310
+ this.#preserveAdvisorCard(card);
1311
+ }
1312
+ }
1263
1313
  this.#scheduleQueuedMessageDrain();
1314
+ this.#resumeStrandedIrcAsides();
1315
+ }
1316
+
1317
+ /** IRC asides that arrive after the loop's final aside poll — or while an abort skipped that
1318
+ * poll — land in #pendingIrcAsides with no loop left to drain them; the queued-message drain's
1319
+ * gate (agent.hasQueuedMessages()) does not count them. Once idle, wake a turn so the agent
1320
+ * responds to the peer. Skip only when a queued steer/follow-up will itself drive a resume turn
1321
+ * whose aside poll already consumes these (no double-wake). */
1322
+ #resumeStrandedIrcAsides(): void {
1323
+ if (this.#isDisposed || this.isStreaming) return;
1324
+ if (this.#pendingIrcAsides.length === 0) return;
1325
+ if (this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages()) return;
1326
+ const records = this.#pendingIrcAsides;
1327
+ this.#pendingIrcAsides = [];
1328
+ this.#wakeForIrc(records);
1329
+ }
1330
+
1331
+ /** Fire-and-forget wake turn for incoming IRC — idle delivery and stranded-aside resume both
1332
+ * route here. Wrapped in #beginInFlight/#endInFlight so the turn is tracked and its settle
1333
+ * re-drains anything that stranded during it. A user interrupt may have intentionally left a
1334
+ * follow-up queued behind an invalid tail (seam #5); the wake turn's loop would otherwise drain
1335
+ * it, so park the follow-up queue across the wake and restore it after. It stays queued post-wake
1336
+ * because #canAutoContinueForFollowUp suppresses follow-up auto-resume while a user interrupt is
1337
+ * in effect, even though the wake left a provider-valid tail. */
1338
+ #wakeForIrc(records: CustomMessage[]): void {
1339
+ // Park only a *blocked* follow-up (one a user interrupt is intentionally holding); an
1340
+ // already-resumable follow-up can ride the wake turn normally without reordering.
1341
+ const parkedFollowUps =
1342
+ this.agent.peekSteeringQueue().length === 0 &&
1343
+ this.agent.peekFollowUpQueue().length > 0 &&
1344
+ !this.#canAutoContinueForFollowUp()
1345
+ ? [...this.agent.peekFollowUpQueue()]
1346
+ : [];
1347
+ if (parkedFollowUps.length > 0) {
1348
+ this.agent.replaceQueues([...this.agent.peekSteeringQueue()], []);
1349
+ }
1350
+ this.#beginInFlight();
1351
+ void this.agent
1352
+ .prompt(records)
1353
+ .catch(error => {
1354
+ logger.warn("IRC wake turn failed", { error: String(error) });
1355
+ })
1356
+ .finally(() => {
1357
+ if (parkedFollowUps.length > 0) {
1358
+ this.agent.replaceQueues(
1359
+ [...this.agent.peekSteeringQueue()],
1360
+ [...parkedFollowUps, ...this.agent.peekFollowUpQueue()],
1361
+ );
1362
+ }
1363
+ this.#endInFlight();
1364
+ });
1264
1365
  }
1265
1366
 
1266
1367
  /** Remove advisor concern/blocker cards from the agent-core steer/follow-up
@@ -1281,14 +1382,14 @@ export class AgentSession {
1281
1382
  }
1282
1383
 
1283
1384
  /** Record a suppressed advisor concern as visible, persisted advice without
1284
- * triggering a turn. When the agent is idle (the normal post-interrupt case),
1285
- * emit message_start/message_end like #flushPendingIrcAsides so
1286
- * #handleAgentEvent renders it live (TUI/ACP) and persists it as a
1287
- * CustomMessageEntry. While a turn is still tearing down (mid-abort), park it
1288
- * hidden so abort's settle step replays it once idle never appended into a
1289
- * live streamMessage. */
1385
+ * triggering a turn. When the agent is idle (the normal post-interrupt case,
1386
+ * including the post-prompt unwind window where the core loop has ended), emit
1387
+ * message_start/message_end like #flushPendingIrcAsides so #handleAgentEvent
1388
+ * renders it live (TUI/ACP) and persists it as a CustomMessageEntry. Only while
1389
+ * an abort is still tearing a live turn down do we park it hidden, so abort's
1390
+ * settle step replays it once idle — never appended into a live streamMessage. */
1290
1391
  #preserveAdvisorCard(card: CustomMessage): void {
1291
- if (this.isStreaming) {
1392
+ if (this.#abortInProgress && this.isStreaming) {
1292
1393
  this.#pendingNextTurnMessages.push(card);
1293
1394
  return;
1294
1395
  }
@@ -1528,33 +1629,48 @@ export class AgentSession {
1528
1629
  // channel (aborting in-flight tools at the next steering boundary); when the
1529
1630
  // loop has already yielded, triggerTurn resumes it so the advice is acted on
1530
1631
  // immediately rather than waiting for the next user prompt. After a deliberate
1531
- // user interrupt that auto-resume is suppressed: the concern is recorded as
1532
- // visible advice and re-enters context only when the user resumes. A plain nit
1533
- // rides the non-interrupting YieldQueue aside.
1632
+ // user interrupt the auto-resume is suppressed — but only while the agent is
1633
+ // idle or still tearing the interrupted turn down: a concern is then recorded
1634
+ // as a visible card and re-enters context when the user resumes. Once a turn
1635
+ // is streaming again (a resume the user already drove) it is steered in live,
1636
+ // since steering an active run auto-resumes nothing; parking it there would
1637
+ // strand the advice and dump the backlog as one burst at the next prompt. A
1638
+ // plain nit always rides the non-interrupting YieldQueue aside.
1534
1639
  const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1535
- if (isInterruptingSeverity(severity)) {
1536
- const notes: AdvisorNote[] = [{ note, severity }];
1537
- const content = formatAdvisorBatchContent(notes);
1538
- const details = { notes } satisfies AdvisorMessageDetails;
1539
- if (this.#advisorAutoResumeSuppressed) {
1540
- this.#preserveAdvisorCard({
1541
- role: "custom",
1542
- customType: "advisor",
1543
- content,
1544
- display: true,
1545
- attribution: "agent",
1546
- details,
1547
- timestamp: Date.now(),
1548
- });
1549
- return;
1550
- }
1551
- void this.sendCustomMessage(
1552
- { customType: "advisor", content, display: true, attribution: "agent", details },
1553
- { deliverAs: "steer", triggerTurn: true },
1554
- ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1640
+ const channel = resolveAdvisorDeliveryChannel({
1641
+ severity,
1642
+ autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
1643
+ // Key on the live agent-core loop, not session `isStreaming` (which also
1644
+ // counts `#promptInFlightCount` during post-turn unwind). Only a running
1645
+ // loop will consume a steer at its next boundary; steering into the unwind
1646
+ // window would strand the card and let #drainStrandedQueuedMessages
1647
+ // auto-resume it despite the user's interrupt.
1648
+ streaming: this.agent.state.isStreaming,
1649
+ aborting: this.#abortInProgress,
1650
+ });
1651
+ if (channel === "aside") {
1652
+ this.yieldQueue.enqueue("advisor", { note, severity });
1555
1653
  return;
1556
1654
  }
1557
- this.yieldQueue.enqueue("advisor", { note, severity });
1655
+ const notes: AdvisorNote[] = [{ note, severity }];
1656
+ const content = formatAdvisorBatchContent(notes);
1657
+ const details = { notes } satisfies AdvisorMessageDetails;
1658
+ if (channel === "preserve") {
1659
+ this.#preserveAdvisorCard({
1660
+ role: "custom",
1661
+ customType: "advisor",
1662
+ content,
1663
+ display: true,
1664
+ attribution: "agent",
1665
+ details,
1666
+ timestamp: Date.now(),
1667
+ });
1668
+ return;
1669
+ }
1670
+ void this.sendCustomMessage(
1671
+ { customType: "advisor", content, display: true, attribution: "agent", details },
1672
+ { deliverAs: "steer", triggerTurn: true },
1673
+ ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1558
1674
  };
1559
1675
 
1560
1676
  const adviseTool = new AdviseTool(enqueueAdvice);
@@ -3644,7 +3760,7 @@ export class AgentSession {
3644
3760
  */
3645
3761
  beginDispose(): void {
3646
3762
  this.#isDisposed = true;
3647
- this.#pendingIrcAsides = [];
3763
+ this.#flushPendingIrcAsides();
3648
3764
  this.yieldQueue.clear();
3649
3765
  this.agent.setAsideMessageProvider(undefined);
3650
3766
  this.#stopAdvisorRuntime();
@@ -4495,7 +4611,7 @@ export class AgentSession {
4495
4611
  });
4496
4612
 
4497
4613
  for (const customTool of mcpTools) {
4498
- const wrapped = CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool;
4614
+ const wrapped = wrapToolWithMetaNotice(CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool);
4499
4615
  const finalTool = (
4500
4616
  this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped
4501
4617
  ) as AgentTool;
@@ -4555,8 +4671,9 @@ export class AgentSession {
4555
4671
  this.#rpcHostToolNames.clear();
4556
4672
 
4557
4673
  for (const tool of rpcTools) {
4674
+ const metaWrapped = wrapToolWithMetaNotice(tool);
4558
4675
  const finalTool = (
4559
- this.#extensionRunner ? new ExtensionToolWrapper(tool, this.#extensionRunner) : tool
4676
+ this.#extensionRunner ? new ExtensionToolWrapper(metaWrapped, this.#extensionRunner) : metaWrapped
4560
4677
  ) as AgentTool;
4561
4678
  this.#toolRegistry.set(finalTool.name, finalTool);
4562
4679
  this.#rpcHostToolNames.add(finalTool.name);
@@ -5122,15 +5239,16 @@ export class AgentSession {
5122
5239
  if (!options?.streamingBehavior) {
5123
5240
  throw new AgentBusyError();
5124
5241
  }
5242
+ // Steer/follow-up the keyword notices BEFORE the queued user message so the
5243
+ // model reads the steering notice ahead of the prompt it modifies.
5244
+ for (const notice of keywordNotices) {
5245
+ await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
5246
+ }
5125
5247
  if (options.streamingBehavior === "followUp") {
5126
5248
  await this.#queueUserMessage(expandedText, options?.images, "followUp");
5127
5249
  } else {
5128
5250
  await this.#queueUserMessage(expandedText, options?.images, "steer");
5129
5251
  }
5130
- // Steer/follow-up the keyword notices alongside the queued user message.
5131
- for (const notice of keywordNotices) {
5132
- await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
5133
- }
5134
5252
  return true;
5135
5253
  }
5136
5254
 
@@ -5169,8 +5287,10 @@ export class AgentSession {
5169
5287
  await this.#promptWithMessage(message, expandedText, {
5170
5288
  ...options,
5171
5289
  images: normalizedImages,
5172
- prependMessages: preludeMessages.length > 0 ? preludeMessages : undefined,
5173
- appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
5290
+ prependMessages:
5291
+ preludeMessages.length > 0 || keywordNotices.length > 0
5292
+ ? [...preludeMessages, ...keywordNotices]
5293
+ : undefined,
5174
5294
  });
5175
5295
  } finally {
5176
5296
  // Clean up residual eager-todo directive if the prompt never consumed it
@@ -5209,13 +5329,13 @@ export class AgentSession {
5209
5329
  if (!options?.streamingBehavior) {
5210
5330
  throw new AgentBusyError();
5211
5331
  }
5332
+ for (const notice of keywordNotices) {
5333
+ await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
5334
+ }
5212
5335
  await this.sendCustomMessage(message, {
5213
5336
  deliverAs: options.streamingBehavior,
5214
5337
  queueChipText: options.queueChipText,
5215
5338
  });
5216
- for (const notice of keywordNotices) {
5217
- await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
5218
- }
5219
5339
  return;
5220
5340
  }
5221
5341
 
@@ -5231,7 +5351,7 @@ export class AgentSession {
5231
5351
 
5232
5352
  await this.#promptWithMessage(customMessage, textContent, {
5233
5353
  ...options,
5234
- appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
5354
+ prependMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
5235
5355
  });
5236
5356
  }
5237
5357
 
@@ -5240,7 +5360,6 @@ export class AgentSession {
5240
5360
  expandedText: string,
5241
5361
  options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
5242
5362
  prependMessages?: AgentMessage[];
5243
- appendMessages?: AgentMessage[];
5244
5363
  skipPostPromptRecoveryWait?: boolean;
5245
5364
  },
5246
5365
  ): Promise<void> {
@@ -5307,12 +5426,6 @@ export class AgentSession {
5307
5426
 
5308
5427
  messages.push(message);
5309
5428
 
5310
- // Inject the ultrathink notice (and any other per-turn appends) right after the
5311
- // user message so the model reads it as part of the same turn.
5312
- if (options?.appendMessages) {
5313
- messages.push(...options.appendMessages);
5314
- }
5315
-
5316
5429
  // Early bail-out: if a newer abort/prompt cycle started during setup,
5317
5430
  // return before mutating shared state (nextTurn messages, system prompt).
5318
5431
  if (this.#promptGeneration !== generation) {
@@ -5634,12 +5747,24 @@ export class AgentSession {
5634
5747
  #canAutoContinueForFollowUp(): boolean {
5635
5748
  if (this.isStreaming) return false;
5636
5749
  if (this.isRetrying) return false;
5750
+ // A queued steer resumes from ANY tail: Agent.continue() runs #runLoop(undefined),
5751
+ // whose initial steering poll injects the steer before the first provider call, so the
5752
+ // request tail becomes the steer (valid) regardless of any injected custom / bashExecution
5753
+ // / pythonExecution record a user interrupt left as the literal transcript tail. This is
5754
+ // why a queued user steer stranded behind a preserved advisor card (or a flushed IRC aside
5755
+ // / eval execution record) still resumes — no tail-role enumeration needed.
5756
+ if (this.agent.peekSteeringQueue().length > 0) return true;
5757
+ // Follow-up-only auto-resume stays suppressed while a deliberate user interrupt is in effect
5758
+ // (#advisorAutoResumeSuppressed, cleared on the next user prompt): the user stopped, so their
5759
+ // queued follow-up waits for an explicit resume — even if an interleaving IRC wake turn has
5760
+ // since left a provider-valid tail.
5761
+ if (this.#advisorAutoResumeSuppressed) return false;
5762
+ // Follow-up-only resume has no steer to inject, so Agent.continue() continues from the
5763
+ // existing context tail — which must itself be a valid provider tail. An injected
5764
+ // non-conversational tail (advisor card → `developer`, bash/python execution) would make
5765
+ // the first model call invalid, so leave the follow-up queued for the next explicit resume.
5637
5766
  const messages = this.agent.state.messages;
5638
5767
  const last = messages[messages.length - 1];
5639
- // A user interrupt during tool execution can leave the transcript ending
5640
- // with the emitted tool result, not the aborted assistant message. Continuing
5641
- // from that state is still resumable: Agent.continue() first polls queued
5642
- // steering before making the next model call.
5643
5768
  return last?.role === "assistant" || last?.role === "toolResult";
5644
5769
  }
5645
5770
 
@@ -5872,15 +5997,32 @@ export class AgentSession {
5872
5997
  });
5873
5998
  }
5874
5999
 
5875
- /** Clear queued messages and return them (text plus any attached images). */
5876
- clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } {
5877
- const steering = this.agent.peekSteeringQueue().map(toRestoredQueuedMessage);
5878
- const followUp = this.agent.peekFollowUpQueue().map(toRestoredQueuedMessage);
5879
- this.agent.clearAllQueues();
6000
+ /** Clear queued messages and return the user-restorable ones (text plus any attached images).
6001
+ * Only user-authored messages (plain user turns, `attribution:"user"` custom like `/skill`) are
6002
+ * returned for editor restore. Other queued messages stay in the agent-core queues so a continuing
6003
+ * stream still delivers them — EXCEPT on `forInterrupt` (Esc+abort), where only advisor cards are
6004
+ * kept (abort()'s #extractQueuedAdvisorCards preserves them as visible advice) and every other
6005
+ * non-user steer (hidden goal/plan/budget, IRC/extension asides) is dropped, so abort()'s
6006
+ * #drainStrandedQueuedMessages can't auto-resume the run the user just interrupted (the drain only
6007
+ * fires while agent.hasQueuedMessages()). Plain Alt+Up dequeue preserves those non-user steers. */
6008
+ clearQueue(options?: { forInterrupt?: boolean }): {
6009
+ steering: RestoredQueuedMessage[];
6010
+ followUp: RestoredQueuedMessage[];
6011
+ } {
6012
+ const steeringAll = this.agent.peekSteeringQueue();
6013
+ const followUpAll = this.agent.peekFollowUpQueue();
6014
+ const steering = steeringAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage);
6015
+ const followUp = followUpAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage);
6016
+ const keep: (m: AgentMessage) => boolean = options?.forInterrupt
6017
+ ? isAdvisorCard
6018
+ : m => !isUserQueuedMessage(m) && !isHiddenUserCompanion(m);
6019
+ this.agent.replaceQueues(steeringAll.filter(keep), followUpAll.filter(keep));
5880
6020
  return { steering, followUp };
5881
6021
  }
5882
6022
 
5883
- /** Number of pending displayable messages (includes steering, follow-up, and next-turn messages) */
6023
+ /** Number of pending displayable messages (includes steering, follow-up, and next-turn messages).
6024
+ * Reflects actual queued work (advisor cards included) — feeds hasPendingMessages()/RPC and the
6025
+ * empty-submit abort gate. The user-restorable subset is surfaced by getQueuedMessages()/clearQueue(). */
5884
6026
  get queuedMessageCount(): number {
5885
6027
  return (
5886
6028
  this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).length +
@@ -5891,18 +6033,48 @@ export class AgentSession {
5891
6033
 
5892
6034
  getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
5893
6035
  return {
5894
- steering: this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).map(queueChipText),
5895
- followUp: this.agent.peekFollowUpQueue().filter(isDisplayableQueuedMessage).map(queueChipText),
6036
+ steering: this.agent.peekSteeringQueue().filter(isUserQueuedMessage).map(queueChipText),
6037
+ followUp: this.agent.peekFollowUpQueue().filter(isUserQueuedMessage).map(queueChipText),
5896
6038
  };
5897
6039
  }
5898
6040
 
5899
6041
  /**
5900
6042
  * Pop the last queued message (steering first, then follow-up).
5901
6043
  * Used by dequeue keybinding to restore messages to editor one at a time.
6044
+ * Steps over agent-authored queued messages (advisor cards, hidden/internal steers).
5902
6045
  */
5903
6046
  popLastQueuedMessage(): RestoredQueuedMessage | undefined {
5904
- const message = this.agent.popLastSteer() ?? this.agent.popLastFollowUp();
5905
- return message ? toRestoredQueuedMessage(message) : undefined;
6047
+ const steering = this.agent.peekSteeringQueue();
6048
+ const followUp = this.agent.peekFollowUpQueue();
6049
+ const lastUserIndex = (queue: readonly AgentMessage[]): number => {
6050
+ for (let i = queue.length - 1; i >= 0; i--) {
6051
+ if (isUserQueuedMessage(queue[i])) return i;
6052
+ }
6053
+ return -1;
6054
+ };
6055
+ // Notices queue immediately before their user message, so dropping the popped
6056
+ // prompt means also dropping the contiguous hidden-user companions right before
6057
+ // it — companions of other queued prompts stay put.
6058
+ const removeWithCompanions = (queue: readonly AgentMessage[], userIndex: number): AgentMessage[] => {
6059
+ let start = userIndex;
6060
+ while (start > 0 && isHiddenUserCompanion(queue[start - 1])) start--;
6061
+ const next = queue.slice();
6062
+ next.splice(start, userIndex - start + 1);
6063
+ return next;
6064
+ };
6065
+ const fromSteer = lastUserIndex(steering);
6066
+ if (fromSteer >= 0) {
6067
+ const removed = steering[fromSteer];
6068
+ this.agent.replaceQueues(removeWithCompanions(steering, fromSteer), followUp.slice());
6069
+ return toRestoredQueuedMessage(removed);
6070
+ }
6071
+ const fromFollowUp = lastUserIndex(followUp);
6072
+ if (fromFollowUp >= 0) {
6073
+ const removed = followUp[fromFollowUp];
6074
+ this.agent.replaceQueues(steering.slice(), removeWithCompanions(followUp, fromFollowUp));
6075
+ return toRestoredQueuedMessage(removed);
6076
+ }
6077
+ return undefined;
5906
6078
  }
5907
6079
 
5908
6080
  get skillsSettings(): SkillsSettings | undefined {
@@ -10192,11 +10364,8 @@ export class AgentSession {
10192
10364
  if (autoReply) void this.#runIrcAutoReply(msg);
10193
10365
  return "injected";
10194
10366
  }
10195
- // Idle: same wake primitive the yield queue uses for async-result
10196
- // delivery — prompt the agent directly so a real turn runs.
10197
- this.agent.prompt(record).catch(error => {
10198
- logger.warn("IRC wake turn failed", { from: msg.from, to: msg.to, error: String(error) });
10199
- });
10367
+ // Idle: wake a real turn so the recipient responds (shared with the stranded-aside resume).
10368
+ this.#wakeForIrc([record]);
10200
10369
  return "woken";
10201
10370
  }
10202
10371
 
@@ -11381,13 +11550,12 @@ export class AgentSession {
11381
11550
  }
11382
11551
 
11383
11552
  /**
11384
- * Format the entire session as plain text for clipboard export.
11385
- * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
11553
+ * Format the entire session as plain text for clipboard export: system
11554
+ * prompt, model/thinking config, tool inventory, and the full transcript
11555
+ * rendered with markdown role headings (`## User`, `## Assistant`,
11556
+ * `### Tool Call`/`### Tool Result`).
11386
11557
  */
11387
- formatSessionAsText(options?: { compact?: boolean }): string {
11388
- if (options?.compact) {
11389
- return formatSessionHistoryMarkdown(this.messages);
11390
- }
11558
+ formatSessionAsText(): string {
11391
11559
  return formatSessionDumpText({
11392
11560
  messages: this.messages,
11393
11561
  systemPrompt: this.agent.state.systemPrompt,
@@ -7,6 +7,24 @@
7
7
  import * as fs from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
 
10
+ /**
11
+ * Sanitize a tool name for safe use as the middle segment of the artifact
12
+ * filename (`${id}.${toolType}.log`). Built-in tool names are fixed, but MCP,
13
+ * extension, and RPC-host tool names are arbitrary and may contain path
14
+ * separators (`/`, `\`) or traversal sequences (`..`) that would otherwise let
15
+ * a spilled artifact escape the artifacts directory. Collapse everything
16
+ * outside `[A-Za-z0-9_-]` to `_`, and cap the length so an arbitrarily long
17
+ * name cannot overflow the filesystem's filename limit (ENAMETOOLONG). Fall
18
+ * back to `tool` when nothing survives.
19
+ */
20
+ function sanitizeToolType(toolType: string): string {
21
+ const sanitized = toolType
22
+ .replace(/[^A-Za-z0-9_-]+/g, "_")
23
+ .slice(0, 64)
24
+ .replace(/^_+|_+$/g, "");
25
+ return sanitized.length > 0 ? sanitized : "tool";
26
+ }
27
+
10
28
  /**
11
29
  * Manages artifact storage for a session.
12
30
  *
@@ -83,7 +101,7 @@ export class ArtifactManager {
83
101
  async allocatePath(toolType: string): Promise<{ id: string; path: string }> {
84
102
  await this.#ensureDir();
85
103
  const id = String(this.allocateId());
86
- const filename = `${id}.${toolType}.log`;
104
+ const filename = `${id}.${sanitizeToolType(toolType)}.log`;
87
105
  return { id, path: path.join(this.#dir, filename) };
88
106
  }
89
107