@oh-my-pi/pi-coding-agent 15.13.3 → 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 (93) hide show
  1. package/CHANGELOG.md +155 -133
  2. package/dist/cli.js +621 -530
  3. package/dist/types/advisor/__tests__/advisor.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +58 -0
  5. package/dist/types/advisor/index.d.ts +3 -0
  6. package/dist/types/advisor/runtime.d.ts +52 -0
  7. package/dist/types/advisor/watchdog.d.ts +5 -0
  8. package/dist/types/config/model-roles.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +66 -5
  10. package/dist/types/discovery/helpers.d.ts +7 -0
  11. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  12. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  13. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  14. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  16. package/dist/types/modes/interactive-mode.d.ts +3 -1
  17. package/dist/types/modes/types.d.ts +8 -1
  18. package/dist/types/sdk.d.ts +3 -3
  19. package/dist/types/session/agent-session.d.ts +81 -2
  20. package/dist/types/session/session-history-format.d.ts +4 -0
  21. package/dist/types/session/session-manager.d.ts +4 -1
  22. package/dist/types/session/yield-queue.d.ts +2 -0
  23. package/dist/types/task/index.d.ts +21 -0
  24. package/dist/types/tools/github-cache.d.ts +5 -4
  25. package/dist/types/tools/job.d.ts +1 -0
  26. package/dist/types/tools/path-utils.d.ts +1 -0
  27. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  28. package/dist/types/web/search/index.d.ts +2 -2
  29. package/dist/types/web/search/provider.d.ts +2 -0
  30. package/package.json +13 -13
  31. package/src/advisor/__tests__/advisor.test.ts +586 -0
  32. package/src/advisor/advise-tool.ts +87 -0
  33. package/src/advisor/index.ts +3 -0
  34. package/src/advisor/runtime.ts +248 -0
  35. package/src/advisor/watchdog.ts +83 -0
  36. package/src/cli/args.ts +1 -0
  37. package/src/collab/host.ts +1 -1
  38. package/src/config/model-roles.ts +13 -1
  39. package/src/config/settings-schema.ts +65 -6
  40. package/src/discovery/claude-plugins.ts +3 -42
  41. package/src/discovery/github.ts +101 -6
  42. package/src/discovery/helpers.ts +11 -0
  43. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  44. package/src/eval/js/shared/prelude.txt +12 -3
  45. package/src/eval/py/prelude.py +26 -2
  46. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  47. package/src/extensibility/plugins/loader.ts +3 -2
  48. package/src/extensibility/plugins/manager.ts +4 -3
  49. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  50. package/src/extensibility/plugins/runtime-config.ts +9 -0
  51. package/src/internal-urls/docs-index.generated.ts +10 -9
  52. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  53. package/src/main.ts +9 -1
  54. package/src/modes/acp/acp-agent.ts +3 -3
  55. package/src/modes/components/advisor-message.ts +99 -0
  56. package/src/modes/components/agent-hub.ts +7 -0
  57. package/src/modes/components/assistant-message.ts +86 -0
  58. package/src/modes/components/settings-defs.ts +7 -0
  59. package/src/modes/components/status-line/segments.ts +20 -7
  60. package/src/modes/components/tips.txt +1 -1
  61. package/src/modes/controllers/command-controller.ts +69 -2
  62. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  63. package/src/modes/controllers/input-controller.ts +1 -0
  64. package/src/modes/controllers/selector-controller.ts +7 -0
  65. package/src/modes/interactive-mode.ts +59 -2
  66. package/src/modes/rpc/rpc-mode.ts +3 -3
  67. package/src/modes/runtime-init.ts +2 -1
  68. package/src/modes/types.ts +8 -1
  69. package/src/modes/utils/ui-helpers.ts +9 -0
  70. package/src/prompts/advisor/advise-tool.md +1 -0
  71. package/src/prompts/advisor/system.md +31 -0
  72. package/src/prompts/agents/designer.md +8 -0
  73. package/src/prompts/review-request.md +1 -1
  74. package/src/prompts/system/subagent-system-prompt.md +4 -1
  75. package/src/prompts/tools/eval.md +13 -3
  76. package/src/prompts/tools/irc.md +1 -1
  77. package/src/sdk.ts +61 -14
  78. package/src/session/agent-session.ts +667 -13
  79. package/src/session/session-dump-format.ts +15 -131
  80. package/src/session/session-history-format.ts +30 -11
  81. package/src/session/session-manager.ts +3 -1
  82. package/src/session/yield-queue.ts +5 -1
  83. package/src/slash-commands/builtin-registry.ts +105 -4
  84. package/src/system-prompt.ts +1 -1
  85. package/src/task/executor.ts +5 -4
  86. package/src/task/index.ts +70 -9
  87. package/src/tools/github-cache.ts +32 -7
  88. package/src/tools/job.ts +14 -1
  89. package/src/tools/path-utils.ts +33 -2
  90. package/src/tools/report-tool-issue.ts +2 -7
  91. package/src/web/scrapers/docs-rs.ts +2 -3
  92. package/src/web/search/index.ts +2 -2
  93. package/src/web/search/provider.ts +14 -2
@@ -2,22 +2,10 @@
2
2
  * Plain-text / markdown session formatting (same shape as /dump clipboard export).
3
3
  */
4
4
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
- import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
6
- import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
7
- import { getInbandGrammar, renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
8
- import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
9
- import { canonicalizeMessage } from "../utils/thinking-display";
10
- import {
11
- type BashExecutionMessage,
12
- type BranchSummaryMessage,
13
- bashExecutionToText,
14
- type CompactionSummaryMessage,
15
- type CustomMessage,
16
- type FileMentionMessage,
17
- type HookMessage,
18
- type PythonExecutionMessage,
19
- pythonExecutionToText,
20
- } from "./messages";
5
+ import type { Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
6
+ import { getDialectDefinition, renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
7
+ import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
8
+ import { convertToLlm } from "./messages";
21
9
 
22
10
  /** Minimal tool shape for dump output (matches AgentTool fields used by formatSessionDumpText). */
23
11
  export interface SessionDumpToolInfo {
@@ -40,7 +28,7 @@ export interface FormatSessionDumpTextOptions {
40
28
  */
41
29
  export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
42
30
  const lines: string[] = [];
43
- const grammar = getInbandGrammar(preferredToolSyntax(options.model?.id ?? ""));
31
+ const definition = getDialectDefinition(preferredDialect(options.model?.id ?? ""));
44
32
 
45
33
  const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
46
34
  if (systemPrompt.length > 0) {
@@ -62,125 +50,21 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
62
50
  lines.push("\n");
63
51
 
64
52
  const tools = options.tools ?? [];
65
- if (tools.length > 0) {
53
+ const inventoryTools = tools.map(tool => ({
54
+ name: tool.name,
55
+ description: tool.description,
56
+ parameters: tool.parameters as TSchema,
57
+ examples: tool.examples,
58
+ }));
59
+ if (inventoryTools.length > 0) {
66
60
  lines.push("## Available Tools\n");
67
- const inventoryTools = tools.map(tool => ({
68
- name: tool.name,
69
- description: tool.description,
70
- parameters: tool.parameters as TSchema,
71
- examples: tool.examples,
72
- }));
73
61
  lines.push(renderToolInventory(inventoryTools, options.model?.id ?? ""));
74
62
  lines.push("\n");
75
63
  }
76
64
 
77
- for (const msg of options.messages) {
78
- if (msg.role === "user" || msg.role === "developer") {
79
- lines.push(msg.role === "developer" ? "## Developer\n" : "## User\n");
80
- if (typeof msg.content === "string") {
81
- lines.push(msg.content);
82
- } else {
83
- for (const c of msg.content) {
84
- if (c.type === "text") {
85
- lines.push(c.text);
86
- } else if (c.type === "image") {
87
- lines.push("[Image]");
88
- }
89
- }
90
- }
91
- lines.push("\n");
92
- } else if (msg.role === "assistant") {
93
- const assistantMsg = msg as AssistantMessage;
94
- lines.push("## Assistant\n");
95
-
96
- for (const c of assistantMsg.content) {
97
- if (c.type === "text") {
98
- lines.push(c.text);
99
- } else if (c.type === "thinking") {
100
- const thinking = canonicalizeMessage(c.thinking);
101
- if (thinking.length === 0) continue;
102
- lines.push("<thinking>");
103
- lines.push(thinking);
104
- lines.push("</thinking>\n");
105
- } else if (c.type === "toolCall") {
106
- const args = { ...(c.arguments as Record<string, unknown>) };
107
- delete args[INTENT_FIELD];
108
- lines.push(grammar.renderToolCall({ ...c, arguments: args }));
109
- }
110
- }
111
- lines.push("");
112
- } else if (msg.role === "toolResult") {
113
- lines.push(`### Tool Result: ${msg.toolName}`);
114
- if (msg.isError) {
115
- lines.push("(error)");
116
- }
117
- for (const c of msg.content) {
118
- if (c.type === "text") {
119
- lines.push("```");
120
- lines.push(c.text);
121
- lines.push("```");
122
- } else if (c.type === "image") {
123
- lines.push("[Image output]");
124
- }
125
- }
126
- lines.push("");
127
- } else if (msg.role === "bashExecution") {
128
- const bashMsg = msg as BashExecutionMessage;
129
- if (!bashMsg.excludeFromContext) {
130
- lines.push("## Bash Execution\n");
131
- lines.push(bashExecutionToText(bashMsg));
132
- lines.push("\n");
133
- }
134
- } else if (msg.role === "pythonExecution") {
135
- const pythonMsg = msg as PythonExecutionMessage;
136
- if (!pythonMsg.excludeFromContext) {
137
- lines.push("## Python Execution\n");
138
- lines.push(pythonExecutionToText(pythonMsg));
139
- lines.push("\n");
140
- }
141
- } else if (msg.role === "custom" || msg.role === "hookMessage") {
142
- const customMsg = msg as CustomMessage | HookMessage;
143
- lines.push(`## ${customMsg.customType}\n`);
144
- if (typeof customMsg.content === "string") {
145
- lines.push(customMsg.content);
146
- } else {
147
- for (const c of customMsg.content) {
148
- if (c.type === "text") {
149
- lines.push(c.text);
150
- } else if (c.type === "image") {
151
- lines.push("[Image]");
152
- }
153
- }
154
- }
155
- lines.push("\n");
156
- } else if (msg.role === "branchSummary") {
157
- const branchMsg = msg as BranchSummaryMessage;
158
- lines.push("## Branch Summary\n");
159
- lines.push(`(from branch: ${branchMsg.fromId})\n`);
160
- lines.push(branchMsg.summary);
161
- lines.push("\n");
162
- } else if (msg.role === "compactionSummary") {
163
- const compactMsg = msg as CompactionSummaryMessage;
164
- lines.push("## Compaction Summary\n");
165
- lines.push(`(${compactMsg.tokensBefore} tokens before compaction)\n`);
166
- lines.push(compactMsg.summary);
167
- lines.push("\n");
168
- } else if (msg.role === "fileMention") {
169
- const fileMsg = msg as FileMentionMessage;
170
- lines.push("## File Mention\n");
171
- for (const file of fileMsg.files) {
172
- lines.push(`<file path="${file.path}">`);
173
- if (file.content) {
174
- lines.push(file.content);
175
- }
176
- if (file.image) {
177
- lines.push("[Image attached]");
178
- }
179
- lines.push("</file>\n");
180
- }
181
- lines.push("\n");
182
- }
183
- }
65
+ lines.push("## Transcript\n");
66
+ lines.push(definition.renderTranscript(convertToLlm([...options.messages]), { tools: inventoryTools }));
67
+ lines.push("\n");
184
68
 
185
69
  return lines.join("\n").trim();
186
70
  }
@@ -22,6 +22,10 @@ import type {
22
22
  export interface HistoryFormatOptions {
23
23
  /** Optional H1 prepended to the transcript. */
24
24
  title?: string;
25
+ /** Render assistant thinking blocks (default: elided). */
26
+ includeThinking?: boolean;
27
+ /** Render tool intent comment before tool call lines. */
28
+ includeToolIntent?: boolean;
25
29
  }
26
30
 
27
31
  /** Max length of the primary-arg summary inside `→ tool(...)` lines. */
@@ -100,17 +104,30 @@ function toolCallLine(
100
104
  name: string,
101
105
  args: Record<string, unknown> | undefined,
102
106
  result: ToolResultMessage | undefined,
107
+ includeToolIntent?: boolean,
103
108
  ): string {
104
109
  const head = `→ ${name}(${primaryArg(args)})`;
105
- if (!result) return `${head} ⇒ pending`;
106
- const text = contentToText(result.content);
107
- const lines = lineCount(text);
108
- const count = `${lines} ${lines === 1 ? "line" : "lines"}`;
109
- if (result.isError) {
110
- const firstLine = oneLine(text.split("\n", 1)[0] ?? "");
111
- return firstLine ? `${head} ⇒ error · ${count} ${firstLine}` : `${head} error · ${count}`;
110
+ let base: string;
111
+ if (!result) {
112
+ base = `${head} ⇒ pending`;
113
+ } else {
114
+ const text = contentToText(result.content);
115
+ const lines = lineCount(text);
116
+ const count = `${lines} ${lines === 1 ? "line" : "lines"}`;
117
+ if (result.isError) {
118
+ const firstLine = oneLine(text.split("\n", 1)[0] ?? "");
119
+ base = firstLine ? `${head} ⇒ error · ${count} — ${firstLine}` : `${head} ⇒ error · ${count}`;
120
+ } else {
121
+ base = `${head} ⇒ ok · ${count}`;
122
+ }
123
+ }
124
+
125
+ const intent = includeToolIntent ? args?.[INTENT_FIELD] : undefined;
126
+ if (typeof intent === "string" && intent.trim()) {
127
+ const formattedIntent = oneLine(intent, 80);
128
+ return `# ${formattedIntent}\n${base}`;
112
129
  }
113
- return `${head} ⇒ ok · ${count}`;
130
+ return base;
114
131
  }
115
132
 
116
133
  /** One line for a user-initiated `!`/`$` execution. */
@@ -193,9 +210,11 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
193
210
  } else if (block.type === "toolCall") {
194
211
  const result = resultsByCallId.get(block.id);
195
212
  if (result) consumed.add(block.id);
196
- body.push(toolCallLine(block.name, block.arguments, result));
213
+ body.push(toolCallLine(block.name, block.arguments, result, opts?.includeToolIntent));
214
+ } else if (opts?.includeThinking && block.type === "thinking" && block.thinking.trim()) {
215
+ body.push(`_thinking:_ ${block.thinking}`);
197
216
  }
198
- // thinking / redactedThinking elided entirely
217
+ // redactedThinking elided entirely (no readable text)
199
218
  }
200
219
  if (body.length === 0) break;
201
220
  lines.push("## assistant", "", ...body, "");
@@ -204,7 +223,7 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
204
223
  case "toolResult": {
205
224
  // Normally consumed by its toolCall; orphans (e.g. truncated history) get their own line.
206
225
  if (consumed.has(msg.toolCallId)) break;
207
- lines.push(toolCallLine(msg.toolName, undefined, msg), "");
226
+ lines.push(toolCallLine(msg.toolName, undefined, msg, opts?.includeToolIntent), "");
208
227
  break;
209
228
  }
210
229
  case "bashExecution": {
@@ -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);
@@ -6,6 +6,8 @@ export interface YieldDispatcher<P> {
6
6
  isStale?(entry: P): boolean;
7
7
  /** Produce one batched AgentMessage from non-stale entries. Return null to skip. */
8
8
  build(survivors: P[]): AgentMessage | null;
9
+ /** If true, entries for this kind are drained only by {@link drainLazy} and never trigger the idle flush. */
10
+ skipIdleFlush?: boolean;
9
11
  }
10
12
 
11
13
  export interface YieldQueueOptions {
@@ -20,6 +22,7 @@ type YieldFlushMode = "streaming" | "idle";
20
22
  interface StoredDispatcher {
21
23
  isStale?: (entry: unknown) => boolean;
22
24
  build: (survivors: unknown[]) => AgentMessage | null;
25
+ skipIdleFlush?: boolean;
23
26
  }
24
27
 
25
28
  function formatError(error: unknown): string {
@@ -40,6 +43,7 @@ export class YieldQueue {
40
43
  const stored: StoredDispatcher = {
41
44
  ...(dispatcher.isStale ? { isStale: entry => dispatcher.isStale?.(entry as P) ?? false } : {}),
42
45
  build: survivors => dispatcher.build(survivors as P[]),
46
+ ...(dispatcher.skipIdleFlush ? { skipIdleFlush: true } : {}),
43
47
  };
44
48
  this.#dispatchers.set(kind, stored);
45
49
  return () => {
@@ -60,7 +64,7 @@ export class YieldQueue {
60
64
  this.#entries.set(kind, entries);
61
65
  }
62
66
  entries.push(entry);
63
- if (!this.#options.isStreaming()) {
67
+ if (!this.#options.isStreaming() && !this.#dispatchers.get(kind)!.skipIdleFlush) {
64
68
  this.#scheduleIdleFlush();
65
69
  }
66
70
  }
@@ -419,6 +419,103 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
419
419
  runtime.ctx.editor.setText("");
420
420
  },
421
421
  },
422
+ {
423
+ name: "advisor",
424
+ description: "Toggle the advisor (a second model that reviews each turn and injects notes)",
425
+ acpDescription: "Toggle advisor",
426
+ acpInputHint: "[on|off|status|dump [raw]]",
427
+ subcommands: [
428
+ { name: "on", description: "Enable the advisor" },
429
+ { name: "off", description: "Disable the advisor" },
430
+ { name: "status", description: "Show advisor status" },
431
+ { name: "dump", description: "Copy the advisor's transcript to clipboard", usage: "[raw]" },
432
+ ],
433
+ allowArgs: true,
434
+ handle: async (command, runtime) => {
435
+ const { verb, rest } = parseSubcommand(command.args);
436
+ if (!verb || verb === "toggle") {
437
+ const active = runtime.session.toggleAdvisorEnabled();
438
+ const configured = runtime.session.isAdvisorEnabled();
439
+ if (active) {
440
+ await runtime.output("Advisor enabled.");
441
+ } else if (configured) {
442
+ await runtime.output("Advisor setting enabled, but no model is assigned to the 'advisor' role.");
443
+ } else {
444
+ await runtime.output("Advisor disabled.");
445
+ }
446
+ return commandConsumed();
447
+ }
448
+ if (verb === "on") {
449
+ const active = runtime.session.setAdvisorEnabled(true);
450
+ await runtime.output(
451
+ active ? "Advisor enabled." : "Advisor setting enabled, but no model is assigned to the 'advisor' role.",
452
+ );
453
+ return commandConsumed();
454
+ }
455
+ if (verb === "off") {
456
+ runtime.session.setAdvisorEnabled(false);
457
+ await runtime.output("Advisor disabled.");
458
+ return commandConsumed();
459
+ }
460
+ if (verb === "status") {
461
+ await runtime.output(runtime.session.formatAdvisorStatus());
462
+ return commandConsumed();
463
+ }
464
+ if (verb === "dump") {
465
+ const isRaw = rest.toLowerCase() === "raw";
466
+ const text = runtime.session.formatAdvisorHistoryAsText({ compact: !isRaw });
467
+ await runtime.output(text ?? "Advisor is not active for this session.");
468
+ return commandConsumed();
469
+ }
470
+ return usage("Usage: /advisor [on|off|status|dump [raw]]", runtime);
471
+ },
472
+ handleTui: async (command, runtime) => {
473
+ const { verb, rest } = parseSubcommand(command.args);
474
+ if (!verb || verb === "toggle") {
475
+ const active = runtime.ctx.session.toggleAdvisorEnabled();
476
+ const configured = runtime.ctx.session.isAdvisorEnabled();
477
+ if (active) {
478
+ runtime.ctx.showStatus("Advisor enabled.");
479
+ } else if (configured) {
480
+ runtime.ctx.showStatus("Advisor setting enabled, but no model is assigned to the 'advisor' role.");
481
+ } else {
482
+ runtime.ctx.showStatus("Advisor disabled.");
483
+ }
484
+ refreshStatusLine(runtime.ctx);
485
+ runtime.ctx.editor.setText("");
486
+ return;
487
+ }
488
+ if (verb === "on") {
489
+ const active = runtime.ctx.session.setAdvisorEnabled(true);
490
+ runtime.ctx.showStatus(
491
+ active ? "Advisor enabled." : "Advisor setting enabled, but no model is assigned to the 'advisor' role.",
492
+ );
493
+ refreshStatusLine(runtime.ctx);
494
+ runtime.ctx.editor.setText("");
495
+ return;
496
+ }
497
+ if (verb === "off") {
498
+ runtime.ctx.session.setAdvisorEnabled(false);
499
+ runtime.ctx.showStatus("Advisor disabled.");
500
+ refreshStatusLine(runtime.ctx);
501
+ runtime.ctx.editor.setText("");
502
+ return;
503
+ }
504
+ if (verb === "status") {
505
+ await runtime.ctx.handleAdvisorStatusCommand();
506
+ runtime.ctx.editor.setText("");
507
+ return;
508
+ }
509
+ if (verb === "dump") {
510
+ const isRaw = rest.toLowerCase() === "raw";
511
+ runtime.ctx.handleAdvisorDumpCommand(isRaw);
512
+ runtime.ctx.editor.setText("");
513
+ return;
514
+ }
515
+ runtime.ctx.showStatus("Usage: /advisor [on|off|status|dump [raw]]");
516
+ runtime.ctx.editor.setText("");
517
+ },
518
+ },
422
519
  {
423
520
  name: "export",
424
521
  description: "Export session to HTML file",
@@ -450,13 +547,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
450
547
  name: "dump",
451
548
  description: "Copy session transcript to clipboard",
452
549
  acpDescription: "Return full transcript as plain text",
453
- handle: async (_command, runtime) => {
454
- const text = runtime.session.formatSessionAsText();
550
+ inlineHint: "[raw]",
551
+ allowArgs: true,
552
+ handle: async (command, runtime) => {
553
+ const isRaw = command.args.trim().toLowerCase() === "raw";
554
+ const text = runtime.session.formatSessionAsText({ compact: !isRaw });
455
555
  await runtime.output(text || "No messages to dump yet.");
456
556
  return commandConsumed();
457
557
  },
458
- handleTui: async (_command, runtime) => {
459
- await runtime.ctx.handleDumpCommand();
558
+ handleTui: (command, runtime) => {
559
+ const isRaw = command.args.trim().toLowerCase() === "raw";
560
+ runtime.ctx.handleDumpCommand(isRaw);
460
561
  runtime.ctx.editor.setText("");
461
562
  },
462
563
  },
@@ -5,7 +5,7 @@
5
5
  import * as os from "node:os";
6
6
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
7
7
  import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai";
8
- import { renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
8
+ import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
9
9
  import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
10
10
  import { $ } from "bun";
11
11
  import { contextFileCapability } from "./capability/context-file";
@@ -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
  }