@oh-my-pi/pi-coding-agent 16.0.0 → 16.0.1

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 (57) hide show
  1. package/CHANGELOG.md +115 -133
  2. package/dist/cli.js +158 -130
  3. package/dist/types/config/settings-schema.d.ts +22 -0
  4. package/dist/types/discovery/helpers.d.ts +7 -0
  5. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  6. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  7. package/dist/types/modes/types.d.ts +5 -0
  8. package/dist/types/session/agent-session.d.ts +11 -1
  9. package/dist/types/session/session-manager.d.ts +4 -1
  10. package/dist/types/task/index.d.ts +21 -0
  11. package/dist/types/tools/github-cache.d.ts +5 -4
  12. package/dist/types/tools/job.d.ts +1 -0
  13. package/dist/types/web/search/index.d.ts +2 -2
  14. package/dist/types/web/search/provider.d.ts +2 -0
  15. package/package.json +12 -12
  16. package/src/cli/args.ts +1 -0
  17. package/src/collab/host.ts +1 -1
  18. package/src/config/settings-schema.ts +23 -1
  19. package/src/discovery/claude-plugins.ts +3 -42
  20. package/src/discovery/github.ts +101 -6
  21. package/src/discovery/helpers.ts +11 -0
  22. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  23. package/src/eval/js/shared/prelude.txt +12 -3
  24. package/src/eval/py/prelude.py +26 -2
  25. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  26. package/src/extensibility/plugins/loader.ts +3 -2
  27. package/src/extensibility/plugins/manager.ts +4 -3
  28. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  29. package/src/extensibility/plugins/runtime-config.ts +9 -0
  30. package/src/internal-urls/docs-index.generated.ts +5 -5
  31. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  32. package/src/main.ts +5 -1
  33. package/src/modes/acp/acp-agent.ts +3 -3
  34. package/src/modes/components/settings-defs.ts +7 -0
  35. package/src/modes/components/tips.txt +1 -1
  36. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  37. package/src/modes/controllers/input-controller.ts +1 -0
  38. package/src/modes/controllers/selector-controller.ts +7 -0
  39. package/src/modes/interactive-mode.ts +47 -0
  40. package/src/modes/rpc/rpc-mode.ts +3 -3
  41. package/src/modes/runtime-init.ts +2 -1
  42. package/src/modes/types.ts +5 -0
  43. package/src/prompts/agents/designer.md +8 -0
  44. package/src/prompts/review-request.md +1 -1
  45. package/src/prompts/system/subagent-system-prompt.md +4 -1
  46. package/src/prompts/tools/eval.md +13 -3
  47. package/src/prompts/tools/irc.md +1 -1
  48. package/src/sdk.ts +9 -1
  49. package/src/session/agent-session.ts +125 -18
  50. package/src/session/session-manager.ts +3 -1
  51. package/src/slash-commands/builtin-registry.ts +5 -2
  52. package/src/task/executor.ts +5 -4
  53. package/src/task/index.ts +70 -9
  54. package/src/tools/github-cache.ts +32 -7
  55. package/src/tools/job.ts +14 -1
  56. package/src/web/search/index.ts +2 -2
  57. package/src/web/search/provider.ts +14 -2
@@ -32,6 +32,7 @@ import {
32
32
  type AsideMessage,
33
33
  type CompactionSummaryMessage,
34
34
  resolveTelemetry,
35
+ STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL,
35
36
  ThinkingLevel,
36
37
  } from "@oh-my-pi/pi-agent-core";
37
38
 
@@ -277,6 +278,7 @@ import {
277
278
  SILENT_ABORT_MARKER,
278
279
  SKILL_PROMPT_MESSAGE_TYPE,
279
280
  stripImagesFromMessage,
281
+ USER_INTERRUPT_LABEL,
280
282
  } from "./messages";
281
283
  import type { SessionContext } from "./session-context";
282
284
  import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
@@ -496,6 +498,12 @@ export interface PromptOptions {
496
498
  toolChoice?: ToolChoice;
497
499
  /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
498
500
  synthetic?: boolean;
501
+ /** Marks this prompt as a deliberate user action (typed message, `.`/`c`
502
+ * continue). Clears advisor auto-resume suppression that a user interrupt set.
503
+ * Defaults to `!synthetic`; manual-continue is synthetic yet user-initiated, so
504
+ * it sets this explicitly. Agent-initiated synthetic prompts (auto-continue,
505
+ * plan re-prime, reminders) leave it unset and keep suppression latched. */
506
+ userInitiated?: boolean;
499
507
  /** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
500
508
  attribution?: MessageAttribution;
501
509
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
@@ -941,6 +949,10 @@ function isDisplayableQueuedMessage(message: AgentMessage): boolean {
941
949
  return !(message.role === "custom" && message.display === false);
942
950
  }
943
951
 
952
+ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
953
+ return message.role === "custom" && message.customType === "advisor";
954
+ }
955
+
944
956
  function queueChipText(message: AgentMessage): string {
945
957
  if (message.role === "custom") {
946
958
  return readQueueChipText(message.details) ?? queuedTextContent(message) ?? "";
@@ -988,10 +1000,15 @@ export class AgentSession {
988
1000
  #pendingNextTurnMessages: CustomMessage[] = [];
989
1001
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
990
1002
  #queuedMessageDrainScheduled = false;
1003
+ /** Latched true when the user deliberately interrupts (USER_INTERRUPT_LABEL);
1004
+ * suppresses advisor concern/blocker auto-resume until the user next resumes.
1005
+ * Advisor advice is still recorded into the transcript, just not auto-run. */
1006
+ #advisorAutoResumeSuppressed = false;
991
1007
  #planModeState: PlanModeState | undefined;
992
1008
  #goalModeState: GoalModeState | undefined;
993
1009
  #goalRuntime: GoalRuntime;
994
1010
  #advisorRuntime?: AdvisorRuntime;
1011
+ #advisorEnabled = false;
995
1012
  /** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
996
1013
  #advisorAgent?: Agent;
997
1014
  #advisorReadOnlyTools?: AgentTool[];
@@ -1244,6 +1261,39 @@ export class AgentSession {
1244
1261
  this.#scheduleQueuedMessageDrain();
1245
1262
  }
1246
1263
 
1264
+ /** Remove advisor concern/blocker cards from the agent-core steer/follow-up
1265
+ * queues and return them. Used on a deliberate user interrupt so the post-abort
1266
+ * stranded-message drain cannot auto-resume the run on an advisor card that was
1267
+ * steered in just before the user stopped; real user follow-ups stay queued.
1268
+ * Synchronous and await-free so it runs before the abort path polls the queue. */
1269
+ #extractQueuedAdvisorCards(): CustomMessage[] {
1270
+ const steering = this.agent.peekSteeringQueue();
1271
+ const followUp = this.agent.peekFollowUpQueue();
1272
+ const cards = [...steering, ...followUp].filter(isAdvisorCard);
1273
+ if (cards.length === 0) return [];
1274
+ this.agent.replaceQueues(
1275
+ steering.filter(m => !isAdvisorCard(m)),
1276
+ followUp.filter(m => !isAdvisorCard(m)),
1277
+ );
1278
+ return cards;
1279
+ }
1280
+
1281
+ /** Record a suppressed advisor concern as visible, persisted advice without
1282
+ * triggering a turn. When the agent is idle (the normal post-interrupt case),
1283
+ * emit message_start/message_end like #flushPendingIrcAsides so
1284
+ * #handleAgentEvent renders it live (TUI/ACP) and persists it as a
1285
+ * CustomMessageEntry. While a turn is still tearing down (mid-abort), park it
1286
+ * hidden so abort's settle step replays it once idle — never appended into a
1287
+ * live streamMessage. */
1288
+ #preserveAdvisorCard(card: CustomMessage): void {
1289
+ if (this.isStreaming) {
1290
+ this.#pendingNextTurnMessages.push(card);
1291
+ return;
1292
+ }
1293
+ this.agent.emitExternalEvent({ type: "message_start", message: card });
1294
+ this.agent.emitExternalEvent({ type: "message_end", message: card });
1295
+ }
1296
+
1247
1297
  #resetInFlight(): void {
1248
1298
  this.#promptInFlightCount = 0;
1249
1299
  this.#releasePowerAssertion();
@@ -1443,7 +1493,8 @@ export class AgentSession {
1443
1493
  },
1444
1494
  });
1445
1495
 
1446
- if (this.settings.get("advisor.enabled")) this.#buildAdvisorRuntime();
1496
+ this.#advisorEnabled = this.settings.get("advisor.enabled") as boolean;
1497
+ if (this.#advisorEnabled) this.#buildAdvisorRuntime();
1447
1498
 
1448
1499
  // Always subscribe to agent events for internal handling
1449
1500
  // (session persistence, hooks, auto-compaction, retry logic)
@@ -1457,7 +1508,7 @@ export class AgentSession {
1457
1508
  #buildAdvisorRuntime(seedToCurrent = false): boolean {
1458
1509
  if (this.#isDisposed) return false;
1459
1510
  if (this.#advisorRuntime) return true;
1460
- if (!this.settings.get("advisor.enabled")) return false;
1511
+ if (!this.#advisorEnabled) return false;
1461
1512
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
1462
1513
 
1463
1514
  const advisorSel = resolveRoleSelection(
@@ -1472,23 +1523,33 @@ export class AgentSession {
1472
1523
  }
1473
1524
 
1474
1525
  // Concern and blocker interrupt the running agent through the steering
1475
- // channel (aborting in-flight tools at the next steering boundary); when
1476
- // the loop has already yielded, triggerTurn resumes it so the advice is
1477
- // acted on immediately rather than waiting for the next user prompt. A
1478
- // plain nit rides the non-interrupting YieldQueue aside.
1526
+ // channel (aborting in-flight tools at the next steering boundary); when the
1527
+ // loop has already yielded, triggerTurn resumes it so the advice is acted on
1528
+ // immediately rather than waiting for the next user prompt. After a deliberate
1529
+ // user interrupt that auto-resume is suppressed: the concern is recorded as
1530
+ // visible advice and re-enters context only when the user resumes. A plain nit
1531
+ // rides the non-interrupting YieldQueue aside.
1479
1532
  const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1480
1533
  if (isInterruptingSeverity(severity)) {
1481
1534
  const notes: AdvisorNote[] = [{ note, severity }];
1482
- void this.sendCustomMessage(
1483
- {
1535
+ const content = formatAdvisorBatchContent(notes);
1536
+ const details = { notes } satisfies AdvisorMessageDetails;
1537
+ if (this.#advisorAutoResumeSuppressed) {
1538
+ this.#preserveAdvisorCard({
1539
+ role: "custom",
1484
1540
  customType: "advisor",
1485
- content: formatAdvisorBatchContent(notes),
1541
+ content,
1486
1542
  display: true,
1487
1543
  attribution: "agent",
1488
- details: { notes } satisfies AdvisorMessageDetails,
1489
- },
1544
+ details,
1545
+ timestamp: Date.now(),
1546
+ });
1547
+ return;
1548
+ }
1549
+ void this.sendCustomMessage(
1550
+ { customType: "advisor", content, display: true, attribution: "agent", details },
1490
1551
  { deliverAs: "steer", triggerTurn: true },
1491
- ).catch(err => logger.debug("advisor steer failed", { err: String(err) }));
1552
+ ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1492
1553
  return;
1493
1554
  }
1494
1555
  this.yieldQueue.enqueue("advisor", { note, severity });
@@ -5034,6 +5095,13 @@ export class AgentSession {
5034
5095
  // agent-initiated turns never trigger them.
5035
5096
  const keywordNotices = options?.synthetic ? [] : this.#createMagicKeywordNotices(expandedText);
5036
5097
 
5098
+ // A user-initiated prompt (typed message or the `.`/`c` continue shortcut)
5099
+ // re-enables advisor auto-resume that a prior user interrupt suppressed.
5100
+ // Agent-initiated synthetic prompts (auto-continue, plan, reminders) do not.
5101
+ if (options?.userInitiated ?? !options?.synthetic) {
5102
+ this.#advisorAutoResumeSuppressed = false;
5103
+ }
5104
+
5037
5105
  // If streaming, queue via steer() or followUp() based on option
5038
5106
  if (this.isStreaming) {
5039
5107
  if (!options?.streamingBehavior) {
@@ -5494,6 +5562,10 @@ export class AgentSession {
5494
5562
  images: ImageContent[] | undefined,
5495
5563
  mode: "steer" | "followUp",
5496
5564
  ): Promise<void> {
5565
+ // A queued user message (RPC/SDK/collab steer or follow-up, or a typed message
5566
+ // while streaming) is a deliberate resume; re-enable advisor auto-resume that
5567
+ // a user interrupt suppressed.
5568
+ this.#advisorAutoResumeSuppressed = false;
5497
5569
  const normalizedImages = await this.#normalizeImagesForModel(images);
5498
5570
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5499
5571
  if (normalizedImages?.length) {
@@ -5872,6 +5944,12 @@ export class AgentSession {
5872
5944
  * abort. Omit it for internal/lifecycle aborts.
5873
5945
  */
5874
5946
  async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string }): Promise<void> {
5947
+ const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
5948
+ if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
5949
+ // Pull advisor concerns out of the steer/follow-up queues before any await so
5950
+ // the post-abort stranded-message drain can't auto-resume the run on them.
5951
+ // They are re-recorded as visible advice once the agent settles (below).
5952
+ const strandedAdvisorCards = userInterrupt ? this.#extractQueuedAdvisorCards() : [];
5875
5953
  // Session switch/compact paths disconnect first; explicit aborts should
5876
5954
  // leave any queued steer/follow-up visible for the user rather than
5877
5955
  // auto-starting a fresh turn during cleanup.
@@ -5900,6 +5978,19 @@ export class AgentSession {
5900
5978
  if (this.#toolChoiceQueue.hasInFlight) {
5901
5979
  this.#toolChoiceQueue.reject("aborted");
5902
5980
  }
5981
+ // Re-record advisor concerns the interrupt would otherwise strand, as
5982
+ // visible/persisted advice without triggering a turn (the agent is idle
5983
+ // now): cards steered into the queue before the user stopped, plus any
5984
+ // that arrived via enqueueAdvice mid-abort and were parked hidden in
5985
+ // #pendingNextTurnMessages while the turn was still tearing down. Other
5986
+ // deferred next-turn context (non-advisor) stays queued, in order.
5987
+ const parkedAdvisorCards = this.#pendingNextTurnMessages.filter(isAdvisorCard);
5988
+ if (parkedAdvisorCards.length > 0) {
5989
+ this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(m => !isAdvisorCard(m));
5990
+ }
5991
+ for (const card of [...strandedAdvisorCards, ...parkedAdvisorCards]) {
5992
+ this.#preserveAdvisorCard(card);
5993
+ }
5903
5994
  } finally {
5904
5995
  this.#abortInProgress = false;
5905
5996
  this.#drainStrandedQueuedMessages();
@@ -9058,11 +9149,22 @@ export class AgentSession {
9058
9149
  if (isContextOverflow(message, contextWindow)) return false;
9059
9150
 
9060
9151
  if (this.#isClassifierRefusal(message)) return true;
9152
+ if (this.#streamInterruptedAfterObservableOutput(message)) return false;
9061
9153
  if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
9062
9154
 
9063
9155
  const err = message.errorMessage;
9064
9156
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
9065
9157
  }
9158
+ #streamInterruptedAfterObservableOutput(message: AssistantMessage): boolean {
9159
+ if (message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL) return true;
9160
+ for (const block of message.content) {
9161
+ if (block.type === "toolCall") return true;
9162
+ if (block.type === "text" && block.text.length > 0) return true;
9163
+ if (block.type === "thinking" && block.thinking.length > 0) return true;
9164
+ if (block.type === "redactedThinking" && block.data.length > 0) return true;
9165
+ }
9166
+ return false;
9167
+ }
9066
9168
 
9067
9169
  #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
9068
9170
  const currentApi = this.model?.api;
@@ -11193,18 +11295,16 @@ export class AgentSession {
11193
11295
  }
11194
11296
 
11195
11297
  /**
11196
- * Enable or disable the advisor for this session. The setting is persisted,
11298
+ * Enable or disable the advisor for this session. The setting is overridden for the session,
11197
11299
  * and the runtime is started or stopped to match.
11198
11300
  *
11199
11301
  * @returns true when the advisor is actively running after the call.
11200
11302
  */
11201
11303
  setAdvisorEnabled(enabled: boolean): boolean {
11304
+ this.#advisorEnabled = enabled;
11202
11305
  if (enabled) {
11203
- this.settings.clearOverride("advisor.enabled");
11204
- this.settings.set("advisor.enabled", true);
11205
11306
  return this.#buildAdvisorRuntime(true);
11206
11307
  }
11207
- this.settings.set("advisor.enabled", false);
11208
11308
  this.#stopAdvisorRuntime();
11209
11309
  return false;
11210
11310
  }
@@ -11215,7 +11315,14 @@ export class AgentSession {
11215
11315
  * @returns true when the advisor is actively running after the call.
11216
11316
  */
11217
11317
  toggleAdvisorEnabled(): boolean {
11218
- return this.setAdvisorEnabled(!this.settings.get("advisor.enabled"));
11318
+ return this.setAdvisorEnabled(!this.#advisorEnabled);
11319
+ }
11320
+
11321
+ /**
11322
+ * Whether the advisor setting is enabled for this session.
11323
+ */
11324
+ isAdvisorEnabled(): boolean {
11325
+ return this.#advisorEnabled;
11219
11326
  }
11220
11327
 
11221
11328
  /**
@@ -11232,7 +11339,7 @@ export class AgentSession {
11232
11339
  * Return structured advisor stats for the status command and TUI panel.
11233
11340
  */
11234
11341
  getAdvisorStats(): AdvisorStats {
11235
- const configured = this.settings.get("advisor.enabled") as boolean;
11342
+ const configured = this.#advisorEnabled;
11236
11343
  const advisor = this.#advisorAgent;
11237
11344
  if (!advisor) {
11238
11345
  return {
@@ -1522,15 +1522,17 @@ export class SessionManager {
1522
1522
  /**
1523
1523
  * Open a specific session file.
1524
1524
  * @param sessionDir Optional dir for /new or /branch; defaults to the file's parent.
1525
+ * @param options.initialCwd Cwd to use when the file is empty or missing.
1525
1526
  */
1526
1527
  static async open(
1527
1528
  filePath: string,
1528
1529
  sessionDir?: string,
1529
1530
  storage: SessionStorage = new FileSessionStorage(),
1531
+ options?: { initialCwd?: string },
1530
1532
  ): Promise<SessionManager> {
1531
1533
  const loaded = await loadEntriesFromFile(filePath, storage);
1532
1534
  const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
1533
- const cwd = header?.cwd ?? getProjectDir();
1535
+ const cwd = header?.cwd ?? options?.initialCwd ?? getProjectDir();
1534
1536
  const dir = sessionDir ?? path.dirname(path.resolve(filePath));
1535
1537
  const manager = new SessionManager(cwd, dir, true, storage);
1536
1538
  await manager.setSessionFile(filePath);
@@ -435,7 +435,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
435
435
  const { verb, rest } = parseSubcommand(command.args);
436
436
  if (!verb || verb === "toggle") {
437
437
  const active = runtime.session.toggleAdvisorEnabled();
438
- const configured = runtime.session.settings.get("advisor.enabled") as boolean;
438
+ const configured = runtime.session.isAdvisorEnabled();
439
439
  if (active) {
440
440
  await runtime.output("Advisor enabled.");
441
441
  } else if (configured) {
@@ -473,7 +473,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
473
473
  const { verb, rest } = parseSubcommand(command.args);
474
474
  if (!verb || verb === "toggle") {
475
475
  const active = runtime.ctx.session.toggleAdvisorEnabled();
476
- const configured = runtime.ctx.session.settings.get("advisor.enabled") as boolean;
476
+ const configured = runtime.ctx.session.isAdvisorEnabled();
477
477
  if (active) {
478
478
  runtime.ctx.showStatus("Advisor enabled.");
479
479
  } else if (configured) {
@@ -481,6 +481,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
481
481
  } else {
482
482
  runtime.ctx.showStatus("Advisor disabled.");
483
483
  }
484
+ refreshStatusLine(runtime.ctx);
484
485
  runtime.ctx.editor.setText("");
485
486
  return;
486
487
  }
@@ -489,12 +490,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
489
490
  runtime.ctx.showStatus(
490
491
  active ? "Advisor enabled." : "Advisor setting enabled, but no model is assigned to the 'advisor' role.",
491
492
  );
493
+ refreshStatusLine(runtime.ctx);
492
494
  runtime.ctx.editor.setText("");
493
495
  return;
494
496
  }
495
497
  if (verb === "off") {
496
498
  runtime.ctx.session.setAdvisorEnabled(false);
497
499
  runtime.ctx.showStatus("Advisor disabled.");
500
+ refreshStatusLine(runtime.ctx);
498
501
  runtime.ctx.editor.setText("");
499
502
  return;
500
503
  }
@@ -33,7 +33,7 @@ import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage
33
33
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
34
34
  import type { ArtifactManager } from "../session/artifacts";
35
35
  import type { AuthStorage } from "../session/auth-storage";
36
- import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
36
+ import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../session/messages";
37
37
  import { SessionManager } from "../session/session-manager";
38
38
  import { truncateTail } from "../session/streaming-output";
39
39
  import type { ContextFileEntry } from "../tools";
@@ -1829,9 +1829,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1829
1829
  ? resolvedThinkingLevel
1830
1830
  : (thinkingLevel ?? resolvedThinkingLevel);
1831
1831
 
1832
+ const effectiveCwd = worktree ?? cwd;
1832
1833
  const sessionManager = sessionFile
1833
- ? await awaitAbortable(SessionManager.open(sessionFile))
1834
- : SessionManager.inMemory(worktree ?? cwd);
1834
+ ? await awaitAbortable(SessionManager.open(sessionFile, undefined, undefined, { initialCwd: effectiveCwd }))
1835
+ : SessionManager.inMemory(effectiveCwd);
1835
1836
  if (options.parentArtifactManager) {
1836
1837
  sessionManager.adoptArtifactManager(options.parentArtifactManager);
1837
1838
  }
@@ -2047,7 +2048,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2047
2048
  {
2048
2049
  getModel: () => session.model,
2049
2050
  isIdle: () => !session.isStreaming,
2050
- abort: () => session.abort(),
2051
+ abort: () => session.abort({ reason: USER_INTERRUPT_LABEL }),
2051
2052
  hasPendingMessages: () => session.queuedMessageCount > 0,
2052
2053
  shutdown: () => {},
2053
2054
  getContextUsage: () => session.getContextUsage(),
package/src/task/index.ts CHANGED
@@ -366,6 +366,49 @@ export function buildSpecializationAdvisory(
366
366
  );
367
367
  }
368
368
 
369
+ /**
370
+ * Suggestion — never a rejection — nudging the spawner to coordinate via `irc`
371
+ * when one call creates ≥2 live siblings and it still holds spawn capacity.
372
+ * Returns undefined when there is nothing to coordinate or IRC is unavailable.
373
+ */
374
+ export function buildCoordinationAdvisory(
375
+ items: TaskItem[],
376
+ depthCapacity: boolean,
377
+ ircEnabled: boolean,
378
+ ): string | undefined {
379
+ if (!depthCapacity || !ircEnabled || items.length < 2) return undefined;
380
+ return (
381
+ `Coordinate: ${items.length} siblings are running together. If their work overlaps, have them ` +
382
+ `message each other via \`irc\` (by id, or "all" to broadcast) before editing shared files — ` +
383
+ `live coordination beats a serial handoff. Check \`irc\` op:"list" to see who is doing what.`
384
+ );
385
+ }
386
+
387
+ /**
388
+ * Compose the non-blocking advisory appended to a `task` result: the
389
+ * specialization nudge, plus — only when the siblings keep running after this
390
+ * call (`willRunAsync`) — the coordination suggestion. Coordination is gated on
391
+ * async because a sync fanout's siblings have already finished, so a
392
+ * "coordinate while they run" hint would misfire. Returns undefined when
393
+ * neither applies.
394
+ */
395
+ export function composeSpawnAdvisory(args: {
396
+ agentName: string | undefined;
397
+ items: TaskItem[];
398
+ depthCapacity: boolean;
399
+ ircEnabled: boolean;
400
+ willRunAsync: boolean;
401
+ }): string | undefined {
402
+ return (
403
+ [
404
+ buildSpecializationAdvisory(args.agentName, args.items, args.depthCapacity),
405
+ args.willRunAsync ? buildCoordinationAdvisory(args.items, args.depthCapacity, args.ircEnabled) : undefined,
406
+ ]
407
+ .filter(Boolean)
408
+ .join("\n\n") || undefined
409
+ );
410
+ }
411
+
369
412
  /** Sentinel for async jobs whose subagent finished with a failing result; progress is already updated. */
370
413
  class TaskJobError extends Error {}
371
414
 
@@ -539,16 +582,35 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
539
582
  this.session.settings.get("task.maxRecursionDepth") ?? 2,
540
583
  this.session.taskDepth ?? 0,
541
584
  );
542
- const advisory = buildSpecializationAdvisory(params.agent, spawnItems, depthCapacity);
585
+ const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
586
+ // Coordination only makes sense when the siblings keep running after this
587
+ // call returns (async). In the sync fallback they have already completed,
588
+ // so a "coordinate while they run" hint would misfire.
589
+ const willRunAsync = !!manager && selectedAgent?.blocking !== true;
590
+ const advisory = this.session.suppressSpawnAdvisory
591
+ ? undefined
592
+ : composeSpawnAdvisory({
593
+ agentName: params.agent,
594
+ items: spawnItems,
595
+ depthCapacity,
596
+ ircEnabled,
597
+ willRunAsync,
598
+ });
599
+ // Returns a fresh result (copied content array, copied text part) rather
600
+ // than mutating the caller's — task results are short-lived here, but an
601
+ // in-place edit on a shared/cached AgentToolResult would be a hidden trap.
543
602
  const withAdvisory = (result: AgentToolResult<TaskToolDetails>): AgentToolResult<TaskToolDetails> => {
544
603
  if (!advisory) return result;
545
- const textPart = result.content.find(part => part.type === "text");
546
- if (textPart && typeof textPart.text === "string") {
547
- textPart.text = `${textPart.text}\n\n${advisory}`;
548
- } else {
549
- result.content.push({ type: "text", text: advisory });
550
- }
551
- return result;
604
+ let appended = false;
605
+ const content = result.content.map(part => {
606
+ if (!appended && part.type === "text" && typeof part.text === "string") {
607
+ appended = true;
608
+ return { ...part, text: `${part.text}\n\n${advisory}` };
609
+ }
610
+ return part;
611
+ });
612
+ if (!appended) content.push({ type: "text", text: advisory });
613
+ return { ...result, content };
552
614
  };
553
615
  if (!asyncEnabled || !manager || selectedAgent?.blocking === true) {
554
616
  // Sync fallback: async execution disabled, orphaned host that never
@@ -614,7 +676,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
614
676
  },
615
677
  });
616
678
 
617
- const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
618
679
  const started: Array<{ agentId: string; jobId: string; description?: string }> = [];
619
680
  const failedSchedules: string[] = [];
620
681
  for (const spawn of spawns) {
@@ -8,10 +8,11 @@
8
8
  * helpers swallow open/IO failures and degrade to "no cache" so a corrupt or
9
9
  * unreadable DB never blocks a `gh` call.
10
10
  *
11
- * TTL:
12
11
  * Soft TTL → return cached row directly.
13
- * Past soft TTL but within hard TTL → return cached row AND schedule a
14
- * background refresh (errors logged, never thrown).
12
+ * Stateful issue/PR rows past soft TTL but within hard TTL → refresh
13
+ * synchronously, falling back to the cached row if the live fetch fails.
14
+ * Expensive PR diff rows past soft TTL but within hard TTL → return cached
15
+ * row AND schedule a background refresh (errors logged, never thrown).
15
16
  * Past hard TTL → treat as miss and fetch fresh.
16
17
  */
17
18
 
@@ -21,6 +22,7 @@ import * as os from "node:os";
21
22
  import * as path from "node:path";
22
23
  import { getGithubCacheDbPath, logger } from "@oh-my-pi/pi-utils";
23
24
  import type { Settings } from "../config/settings";
25
+ import { ToolAbortError } from "./tool-errors";
24
26
 
25
27
  // ────────────────────────────────────────────────────────────────────────────
26
28
  // Storage layer
@@ -449,7 +451,7 @@ export interface CacheLookupOptions<T> {
449
451
  now?: number;
450
452
  }
451
453
 
452
- export type CacheStatus = "miss" | "fresh" | "stale" | "disabled";
454
+ export type CacheStatus = "miss" | "fresh" | "refreshed" | "stale" | "disabled";
453
455
 
454
456
  export interface CacheLookupResult<T> {
455
457
  rendered: string;
@@ -595,7 +597,7 @@ export async function getOrFetchView<T>(options: CacheLookupOptions<T>): Promise
595
597
  status: "fresh",
596
598
  fetchedAt: cached.fetchedAt,
597
599
  };
598
- } else {
600
+ } else if (options.kind === "pr-diff") {
599
601
  scheduleBackgroundRefresh(
600
602
  authKey,
601
603
  options.repo,
@@ -611,6 +613,28 @@ export async function getOrFetchView<T>(options: CacheLookupOptions<T>): Promise
611
613
  status: "stale",
612
614
  fetchedAt: cached.fetchedAt,
613
615
  };
616
+ } else {
617
+ try {
618
+ const fresh = await options.fetchFresh();
619
+ const fetchedAt = Date.now();
620
+ storeResult(authKey, options.repo, options.kind, options.number, options.includeComments, fresh, fetchedAt);
621
+ return { ...fresh, status: "refreshed", fetchedAt };
622
+ } catch (err) {
623
+ if (err instanceof ToolAbortError) throw err;
624
+ logger.debug("github cache: synchronous refresh failed; returning stale view", {
625
+ err: String(err),
626
+ repo: options.repo,
627
+ kind: options.kind,
628
+ number: options.number,
629
+ });
630
+ return {
631
+ rendered: cached.rendered,
632
+ sourceUrl: cached.sourceUrl,
633
+ payload: cached.payload,
634
+ status: "stale",
635
+ fetchedAt: cached.fetchedAt,
636
+ };
637
+ }
614
638
  }
615
639
  }
616
640
 
@@ -624,7 +648,7 @@ export async function getOrFetchView<T>(options: CacheLookupOptions<T>): Promise
624
648
  * Human-friendly freshness note for protocol-handler `notes[]` rendering.
625
649
  */
626
650
  export function formatFreshnessNote(status: CacheStatus, fetchedAtMs: number, now: number = Date.now()): string {
627
- if (status === "miss") return "Fetched live";
651
+ if (status === "miss" || status === "refreshed") return "Fetched live";
628
652
  if (status === "disabled") return "Cache disabled; fetched live";
629
653
  const ageSec = Math.max(0, Math.round((now - fetchedAtMs) / 1000));
630
654
  const human =
@@ -633,6 +657,7 @@ export function formatFreshnessNote(status: CacheStatus, fetchedAtMs: number, no
633
657
  : ageSec < 3600
634
658
  ? `${Math.round(ageSec / 60)}m ago`
635
659
  : `${Math.round(ageSec / 3600)}h ago`;
636
- if (status === "stale") return `Cached: ${human} (refreshing in background)`;
660
+ if (status === "stale")
661
+ return `WARNING: showing cached content from ${human}; live refresh failed or is still running`;
637
662
  return `Cached: ${human}`;
638
663
  }
package/src/tools/job.ts CHANGED
@@ -372,6 +372,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
372
372
  interface JobRenderArgs {
373
373
  poll?: string[];
374
374
  cancel?: string[];
375
+ list?: boolean;
375
376
  }
376
377
 
377
378
  const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
@@ -433,6 +434,7 @@ function flattenStructuredPreview(text: string): string {
433
434
  }
434
435
 
435
436
  function describeTarget(args: JobRenderArgs | undefined): string {
437
+ if (args?.list) return "background jobs";
436
438
  const poll = args?.poll ?? [];
437
439
  const cancel = args?.cancel ?? [];
438
440
  const parts: string[] = [];
@@ -460,7 +462,7 @@ export const jobToolRenderer = {
460
462
  uiTheme: Theme,
461
463
  args?: JobRenderArgs,
462
464
  ): Component {
463
- const jobs = result.details?.jobs ?? [];
465
+ let jobs = result.details?.jobs ?? [];
464
466
 
465
467
  if (jobs.length === 0) {
466
468
  const fallback = result.content?.find(c => c.type === "text")?.text || "No jobs to process";
@@ -468,6 +470,17 @@ export const jobToolRenderer = {
468
470
  return new Text([header, formatEmptyMessage(fallback, uiTheme)].join("\n"), 0, 0);
469
471
  }
470
472
 
473
+ const isPollCall = args
474
+ ? !args.list && (!args.cancel || args.cancel.length === 0 || args.poll !== undefined)
475
+ : true;
476
+
477
+ if (!options.isPartial && isPollCall) {
478
+ jobs = jobs.filter(job => job.status !== "running");
479
+ if (jobs.length === 0) {
480
+ return new Text("", 0, 0);
481
+ }
482
+ }
483
+
471
484
  const counts = { completed: 0, failed: 0, cancelled: 0, running: 0 };
472
485
  for (const job of jobs) counts[job.status]++;
473
486
 
@@ -300,6 +300,6 @@ export function getSearchTools(): CustomTool<any, any>[] {
300
300
  return [webSearchCustomTool];
301
301
  }
302
302
 
303
- export { getSearchProvider, setPreferredSearchProvider } from "./provider";
303
+ export { getSearchProvider, setExcludedSearchProviders, setPreferredSearchProvider } from "./provider";
304
304
  export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
305
- export { isSearchProviderPreference } from "./types";
305
+ export { isSearchProviderId, isSearchProviderPreference } from "./types";
@@ -127,6 +127,18 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"):
127
127
  preferredProvId = provider;
128
128
  }
129
129
 
130
+ /** Providers excluded from web search resolution via settings. */
131
+ let excludedProvIds = new Set<SearchProviderId>();
132
+
133
+ /** Set providers that web search should never use, including fallbacks. */
134
+ export function setExcludedSearchProviders(providers: readonly SearchProviderId[]): void {
135
+ excludedProvIds = new Set(providers);
136
+ }
137
+
138
+ function isSearchProviderExcluded(id: SearchProviderId): boolean {
139
+ return excludedProvIds.has(id);
140
+ }
141
+
130
142
  /**
131
143
  * Determine which providers are configured and currently available.
132
144
  * Each candidate is loaded (and its `isAvailable()` called) only as the chain
@@ -138,7 +150,7 @@ export async function resolveProviderChain(
138
150
  ): Promise<SearchProvider[]> {
139
151
  const providers: SearchProvider[] = [];
140
152
 
141
- if (preferredProvider !== "auto") {
153
+ if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
142
154
  const provider = await getSearchProvider(preferredProvider);
143
155
  if (await provider.isExplicitlyAvailable(authStorage)) {
144
156
  providers.push(provider);
@@ -146,7 +158,7 @@ export async function resolveProviderChain(
146
158
  }
147
159
 
148
160
  for (const id of SEARCH_PROVIDER_ORDER) {
149
- if (id === preferredProvider) continue;
161
+ if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
150
162
  const provider = await getSearchProvider(id);
151
163
  if (await provider.isAvailable(authStorage)) {
152
164
  providers.push(provider);