@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.6

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 (109) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +3799 -3729
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/cli/bench-cli.d.ts +1 -7
  5. package/dist/types/cli/usage-cli.d.ts +1 -0
  6. package/dist/types/commands/usage.d.ts +7 -0
  7. package/dist/types/config/settings-schema.d.ts +19 -9
  8. package/dist/types/config/settings.d.ts +3 -2
  9. package/dist/types/discovery/helpers.d.ts +2 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  11. package/dist/types/irc/bus.d.ts +4 -0
  12. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  13. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  15. package/dist/types/modes/components/index.d.ts +2 -1
  16. package/dist/types/modes/components/model-browser.d.ts +100 -0
  17. package/dist/types/modes/components/model-hub.d.ts +52 -0
  18. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  19. package/dist/types/modes/components/session-selector.d.ts +13 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  22. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  23. package/dist/types/modes/interactive-mode.d.ts +3 -0
  24. package/dist/types/modes/queue-input.d.ts +8 -0
  25. package/dist/types/modes/shared.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +1 -1
  28. package/dist/types/session/agent-storage.d.ts +57 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/task/executor.d.ts +26 -13
  31. package/dist/types/task/index.d.ts +12 -11
  32. package/dist/types/task/label.d.ts +4 -0
  33. package/dist/types/task/repair-args.d.ts +8 -8
  34. package/dist/types/task/types.d.ts +31 -56
  35. package/dist/types/tools/ask.d.ts +12 -0
  36. package/dist/types/tools/conflict-detect.d.ts +17 -1
  37. package/dist/types/tools/job.d.ts +16 -0
  38. package/package.json +12 -12
  39. package/scripts/build-binary.ts +0 -1
  40. package/scripts/compile-binary.ts +4 -3
  41. package/src/async/job-manager.ts +9 -0
  42. package/src/cli/bench-cli.ts +7 -26
  43. package/src/cli/usage-cli.ts +11 -0
  44. package/src/commands/usage.ts +13 -2
  45. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  46. package/src/config/settings-schema.ts +18 -7
  47. package/src/config/settings.ts +13 -4
  48. package/src/discovery/helpers.ts +3 -4
  49. package/src/extensibility/custom-tools/loader.ts +70 -37
  50. package/src/extensibility/extensions/types.ts +46 -0
  51. package/src/irc/bus.ts +61 -20
  52. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  53. package/src/modes/components/advisor-config.ts +32 -22
  54. package/src/modes/components/ask-dialog.ts +888 -0
  55. package/src/modes/components/custom-editor.test.ts +58 -1
  56. package/src/modes/components/custom-editor.ts +42 -11
  57. package/src/modes/components/index.ts +2 -1
  58. package/src/modes/components/model-browser.ts +769 -0
  59. package/src/modes/components/model-hub.ts +2002 -0
  60. package/src/modes/components/pause-screen.ts +208 -0
  61. package/src/modes/components/session-selector.ts +299 -42
  62. package/src/modes/components/tool-execution.ts +2 -0
  63. package/src/modes/components/usage-row.ts +5 -6
  64. package/src/modes/controllers/event-controller.ts +8 -2
  65. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  66. package/src/modes/controllers/input-controller.ts +140 -6
  67. package/src/modes/controllers/selector-controller.ts +160 -97
  68. package/src/modes/controllers/tan-command-controller.ts +1 -1
  69. package/src/modes/controllers/todo-command-controller.ts +1 -2
  70. package/src/modes/interactive-mode.ts +8 -0
  71. package/src/modes/queue-input.ts +132 -0
  72. package/src/modes/shared.ts +1 -1
  73. package/src/modes/theme/theme.ts +3 -3
  74. package/src/modes/types.ts +4 -0
  75. package/src/modes/utils/ui-helpers.ts +50 -24
  76. package/src/prompts/agents/scout.md +0 -1
  77. package/src/prompts/agents/task.md +1 -1
  78. package/src/prompts/system/subagent-system-prompt.md +1 -5
  79. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  80. package/src/prompts/system/task-label.md +23 -0
  81. package/src/prompts/tools/job.md +1 -1
  82. package/src/prompts/tools/task-summary.md +3 -0
  83. package/src/prompts/tools/task.md +17 -18
  84. package/src/session/agent-session.ts +186 -49
  85. package/src/session/agent-storage.ts +330 -3
  86. package/src/session/history-storage.ts +1 -34
  87. package/src/session/session-context.test.ts +73 -0
  88. package/src/session/session-context.ts +43 -26
  89. package/src/slash-commands/builtin-registry.ts +18 -0
  90. package/src/task/agents.ts +2 -0
  91. package/src/task/executor.ts +159 -46
  92. package/src/task/index.ts +377 -239
  93. package/src/task/label.ts +38 -0
  94. package/src/task/render.ts +74 -22
  95. package/src/task/repair-args.ts +20 -31
  96. package/src/task/spawn-policy.test.ts +4 -4
  97. package/src/task/types.ts +46 -66
  98. package/src/tools/ask.ts +233 -40
  99. package/src/tools/conflict-detect.ts +102 -5
  100. package/src/tools/index.ts +1 -0
  101. package/src/tools/irc.ts +20 -11
  102. package/src/tools/job.ts +158 -18
  103. package/src/tools/write.ts +70 -6
  104. package/src/vibe/runtime.ts +1 -1
  105. package/src/web/search/providers/browser-headers.ts +30 -13
  106. package/dist/types/modes/components/model-selector.d.ts +0 -37
  107. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  108. package/src/modes/components/model-selector.ts +0 -1291
  109. package/src/tools/bash-command-fixup.ts +0 -4
@@ -74,7 +74,7 @@ export interface BenchRunSuccess {
74
74
  ttftMs: number;
75
75
  durationMs: number;
76
76
  outputTokens: number;
77
- /** Generation throughput measured over the post-first-token window. */
77
+ /** Output tokens/sec over the total request duration. */
78
78
  tokensPerSecond: number;
79
79
  }
80
80
 
@@ -181,23 +181,6 @@ function hasVisibleFinalContent(message: AssistantMessage): boolean {
181
181
  });
182
182
  }
183
183
 
184
- /**
185
- * Tokens/s over the generation window (duration minus TTFT) so queue/prefill
186
- * latency does not dilute throughput. Falls back to total duration when the
187
- * response arrived as a single chunk (TTFT ~ duration).
188
- */
189
- export function computeTokensPerSecond(
190
- outputTokens: number,
191
- durationMs: number,
192
- ttftMs: number,
193
- deltaChunkCount: number,
194
- ): number {
195
- const decodeMs = durationMs - ttftMs;
196
- // Fall back to total duration when the response arrived as a single chunk/non-streaming.
197
- const windowMs = decodeMs > 0 && deltaChunkCount >= 2 ? decodeMs : durationMs;
198
- return windowMs > 0 ? (outputTokens * 1000) / windowMs : 0;
199
- }
200
-
201
184
  interface BenchRequestOptions {
202
185
  apiKey: ApiKeyResolver;
203
186
  sessionId: string;
@@ -247,17 +230,10 @@ async function runBenchRequest(
247
230
  headers: model.provider === "openrouter" ? { "X-OpenRouter-Cache": "false" } : undefined,
248
231
  });
249
232
  let message: AssistantMessage | undefined;
250
- let deltaChunkCount = 0;
251
233
  for await (const event of stream) {
252
234
  if (firstTokenAt === undefined && isFirstTokenEvent(event)) {
253
235
  firstTokenAt = now();
254
236
  }
255
- if (
256
- (event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") &&
257
- event.delta.length > 0
258
- ) {
259
- deltaChunkCount++;
260
- }
261
237
  if (event.type === "error") {
262
238
  return { ok: false, error: event.error.errorMessage ?? "request failed" };
263
239
  }
@@ -291,7 +267,12 @@ async function runBenchRequest(
291
267
  ttftMs,
292
268
  durationMs,
293
269
  outputTokens,
294
- tokensPerSecond: computeTokensPerSecond(outputTokens, durationMs, ttftMs, deltaChunkCount),
270
+ // TPS over the TOTAL request duration, deliberately not the post-TTFT
271
+ // decode window: reasoning models can spend seconds generating hidden
272
+ // thinking tokens (counted in usage.output) before the first visible
273
+ // byte, so "duration - TTFT" inflates TPS several-fold on providers
274
+ // that buffer or hide reasoning (e.g. google vs google-vertex).
275
+ tokensPerSecond: durationMs > 0 ? (outputTokens * 1000) / durationMs : 0,
295
276
  };
296
277
  } catch (error) {
297
278
  return { ok: false, error: getErrorMessage(error) };
@@ -23,6 +23,7 @@ import { discoverAuthStorage } from "../sdk";
23
23
  const BAR_WIDTH = 28;
24
24
 
25
25
  export interface UsageCommandArgs {
26
+ action?: string;
26
27
  json?: boolean;
27
28
  provider?: string;
28
29
  redact?: boolean;
@@ -755,6 +756,16 @@ function redactReportForJson(
755
756
  export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
756
757
  const authStorage = await discoverAuthStorage();
757
758
  try {
759
+ if (cmd.action === "invalidate") {
760
+ const provider = cmd.provider?.toLowerCase();
761
+ await authStorage.invalidateUsageCache(provider);
762
+ if (provider) {
763
+ process.stdout.write(`Invalidated cached usage reports for provider "${provider}".\n`);
764
+ } else {
765
+ process.stdout.write("Invalidated cached usage reports for all providers.\n");
766
+ }
767
+ return;
768
+ }
758
769
  if (cmd.history) {
759
770
  const days = cmd.days !== undefined && Number.isFinite(cmd.days) && cmd.days > 0 ? cmd.days : 7;
760
771
  const nowMs = Date.now();
@@ -1,12 +1,20 @@
1
1
  /**
2
2
  * Show provider usage limits for every authenticated account.
3
3
  */
4
- import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
4
+ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
5
5
  import { runUsageCommand } from "../cli/usage-cli";
6
6
 
7
7
  export default class Usage extends Command {
8
8
  static description = "Show provider usage limits for every authenticated account";
9
9
 
10
+ static args = {
11
+ action: Args.string({
12
+ description: "Optional subcommand to execute",
13
+ required: false,
14
+ options: ["invalidate"],
15
+ }),
16
+ };
17
+
10
18
  static flags = {
11
19
  json: Flags.boolean({ char: "j", description: "Output usage reports as JSON", default: false }),
12
20
  provider: Flags.string({ char: "p", description: "Only show usage for this provider id (e.g. anthropic)" }),
@@ -28,11 +36,14 @@ export default class Usage extends Command {
28
36
  "# Redact account identifiers for screenshots\n omp usage --redact",
29
37
  "# Machine-readable output\n omp usage --json",
30
38
  "# Usage-limit trend over the last 30 days\n omp usage --history --days 30",
39
+ "# Invalidate cached usage reports for all providers\n omp usage invalidate",
40
+ "# Invalidate cached usage reports for a specific provider\n omp usage invalidate --provider anthropic",
31
41
  ];
32
42
 
33
43
  async run(): Promise<void> {
34
- const { flags } = await this.parse(Usage);
44
+ const { args, flags } = await this.parse(Usage);
35
45
  await runUsageCommand({
46
+ action: args.action,
36
47
  json: flags.json,
37
48
  provider: flags.provider,
38
49
  redact: flags.redact,
@@ -83,10 +83,9 @@ export function createAnalyzeFileTool(options: {
83
83
  related_files: relatedFiles,
84
84
  });
85
85
  const taskParams: TaskParams = {
86
+ name: `AnalyzeFile${index + 1}`,
86
87
  agent: "sonic",
87
- id: `AnalyzeFile${index + 1}`,
88
- description: `Analyze ${file}`,
89
- assignment,
88
+ task: assignment,
90
89
  };
91
90
  return taskTool.execute(`${toolCallId}-${index + 1}`, taskParams, signal);
92
91
  }),
@@ -1375,7 +1375,7 @@ export const SETTINGS_SCHEMA = {
1375
1375
  group: "Retry & Fallback",
1376
1376
  label: "Retry Fallback Chains",
1377
1377
  description:
1378
- 'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.',
1378
+ 'JSON object mapping model roles, model selectors ("provider/model-id"), or provider wildcards ("provider/*") to ordered fallback selectors, e.g. {"default":["openai/gpt-4o-mini"],"google-antigravity/*":["google/*","google-vertex/*"]}. Model-oriented keys apply whenever that model/provider is active, regardless of role; a "provider/*" entry keeps the failing model\'s id and swaps the provider.',
1379
1379
  },
1380
1380
  },
1381
1381
  "retry.fallbackRevertPolicy": {
@@ -3633,6 +3633,17 @@ export const SETTINGS_SCHEMA = {
3633
3633
  },
3634
3634
  },
3635
3635
 
3636
+ "ask.enabled": {
3637
+ type: "boolean",
3638
+ default: true,
3639
+ ui: {
3640
+ tab: "tools",
3641
+ group: "Available Tools",
3642
+ label: "Ask",
3643
+ description: "Enable the ask tool for interactive user questions",
3644
+ },
3645
+ },
3646
+
3636
3647
  "browser.enabled": {
3637
3648
  type: "boolean",
3638
3649
  default: true,
@@ -4142,31 +4153,31 @@ export const SETTINGS_SCHEMA = {
4142
4153
 
4143
4154
  "task.softRequestBudget": {
4144
4155
  type: "number",
4145
- default: 90,
4156
+ default: 200,
4146
4157
  ui: {
4147
4158
  tab: "tasks",
4148
4159
  group: "Subagents",
4149
4160
  label: "Soft Subagent Request Budget",
4150
4161
  description:
4151
- "Soft per-subagent request budget (assistant requests per run). Crossing it can inject a steering notice when task.softRequestBudgetNotice is enabled; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled scout/sonic agents use a lower built-in budget.",
4162
+ "Soft per-subagent request budget (assistant requests per run). Crossing it injects a wrap-up steering notice (see task.softRequestBudgetNotice); at 1.5x the budget the run is force-stopped and the agent must yield its partial findings. 0 disables the guard. Bundled scout/sonic agents use a lower built-in budget.",
4152
4163
  options: [
4153
4164
  { value: "0", label: "Disabled" },
4154
- { value: "40", label: "40 requests" },
4155
- { value: "90", label: "90 requests", description: "Default" },
4165
+ { value: "90", label: "90 requests" },
4156
4166
  { value: "150", label: "150 requests" },
4167
+ { value: "200", label: "200 requests", description: "Default" },
4157
4168
  ],
4158
4169
  },
4159
4170
  },
4160
4171
 
4161
4172
  "task.softRequestBudgetNotice": {
4162
4173
  type: "boolean",
4163
- default: false,
4174
+ default: true,
4164
4175
  ui: {
4165
4176
  tab: "tasks",
4166
4177
  group: "Subagents",
4167
4178
  label: "Soft Request Budget Notice",
4168
4179
  description:
4169
- "Inject one steering notice when a subagent crosses its soft request budget. Off by default; enabling it asks the child to wrap up before the 1.5x graceful abort guard.",
4180
+ "Inject one steering notice when a subagent crosses its soft request budget, asking it to wrap up before the 1.5x forced-yield stop.",
4170
4181
  },
4171
4182
  },
4172
4183
 
@@ -604,9 +604,10 @@ export class Settings {
604
604
  }
605
605
 
606
606
  /**
607
- * Set a model role (helper for modelRoles record).
607
+ * Set a model role (helper for modelRoles record). Passing `undefined`
608
+ * clears the role from the persisted record and any runtime override.
608
609
  */
609
- setModelRole(role: ModelRole | string, modelId: string): void {
610
+ setModelRole(role: ModelRole | string, modelId: string | undefined): void {
610
611
  const current = this.#modelRolesFromLayer(this.#global);
611
612
  const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
612
613
  const updateRuntimeOverride =
@@ -615,12 +616,20 @@ export class Settings {
615
616
  !Array.isArray(runtimeOverrides) &&
616
617
  Object.hasOwn(runtimeOverrides, role);
617
618
 
618
- current[role] = modelId;
619
+ if (modelId === undefined) {
620
+ delete current[role];
621
+ } else {
622
+ current[role] = modelId;
623
+ }
619
624
  this.set("modelRoles", current);
620
625
 
621
626
  if (updateRuntimeOverride) {
622
627
  const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides);
623
- nextRuntimeOverride[role] = modelId;
628
+ if (modelId === undefined) {
629
+ delete nextRuntimeOverride[role];
630
+ } else {
631
+ nextRuntimeOverride[role] = modelId;
632
+ }
624
633
  this.override("modelRoles", nextRuntimeOverride);
625
634
  }
626
635
  }
@@ -1,7 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
4
  import { FileType, glob } from "@oh-my-pi/pi-natives";
6
5
  import {
7
6
  CONFIG_DIR_NAME,
@@ -17,7 +16,7 @@ import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../ca
17
16
  import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
18
17
  import type { Skill, SkillFrontmatter } from "../capability/skill";
19
18
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
20
- import { parseThinkingLevel } from "../thinking";
19
+ import { type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../thinking";
21
20
  import { normalizeToolNames } from "../tools/builtin-names";
22
21
 
23
22
  import { buildPluginDirRoot } from "./plugin-dir-roots";
@@ -229,7 +228,7 @@ export interface ParsedAgentFields {
229
228
  spawns?: string[] | "*";
230
229
  model?: string[];
231
230
  output?: unknown;
232
- thinkingLevel?: ThinkingLevel;
231
+ thinkingLevel?: ConfiguredThinkingLevel;
233
232
  autoloadSkills?: string[];
234
233
  readSummarize?: boolean;
235
234
  blocking?: boolean;
@@ -283,7 +282,7 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
283
282
  ? frontmatter.thinking
284
283
  : undefined;
285
284
 
286
- const thinkingLevel = parseThinkingLevel(rawThinkingLevel);
285
+ const thinkingLevel = parseConfiguredThinkingLevel(rawThinkingLevel);
287
286
  const model = parseModelList(frontmatter.model);
288
287
  const blocking = parseBoolean(frontmatter.blocking);
289
288
  const readSummarize = parseBoolean(frontmatter.readSummarize);
@@ -21,6 +21,34 @@ import * as typebox from "../typebox";
21
21
  import { createNoOpUIContext, resolvePath, withExitGuard } from "../utils";
22
22
  import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types";
23
23
 
24
+ interface LoadToolResult {
25
+ tools: LoadedCustomTool[];
26
+ errors: ToolLoadError[];
27
+ }
28
+
29
+ function isLoadableCustomTool(value: unknown): value is LoadedCustomTool["tool"] {
30
+ return (
31
+ typeof value === "object" &&
32
+ value !== null &&
33
+ "name" in value &&
34
+ typeof value.name === "string" &&
35
+ value.name.length > 0 &&
36
+ "description" in value &&
37
+ typeof value.description === "string" &&
38
+ "parameters" in value &&
39
+ "execute" in value &&
40
+ typeof value.execute === "function"
41
+ );
42
+ }
43
+
44
+ function invalidToolError(path: string, index: number, source: ToolLoadError["source"]): ToolLoadError {
45
+ return {
46
+ path,
47
+ error: `Tool factory returned invalid tool at index ${index}: expected object with string name, string description, parameters, and execute function`,
48
+ source,
49
+ };
50
+ }
51
+
24
52
  /**
25
53
  * Load a single tool module using native Bun import.
26
54
  */
@@ -29,18 +57,20 @@ async function loadTool(
29
57
  cwd: string,
30
58
  sharedApi: CustomToolAPI,
31
59
  source?: { provider: string; providerName: string; level: "user" | "project" },
32
- ): Promise<{ tools: LoadedCustomTool[] | null; error: ToolLoadError | null }> {
60
+ ): Promise<LoadToolResult> {
33
61
  const resolvedPath = resolvePath(toolPath, cwd);
34
62
 
35
63
  // Skip declarative tool files (.md, .json) - these are metadata only, not executable modules
36
64
  if (resolvedPath.endsWith(".md") || resolvedPath.endsWith(".json")) {
37
65
  return {
38
- tools: null,
39
- error: {
40
- path: toolPath,
41
- error: "Declarative tool files (.md, .json) cannot be loaded as executable modules",
42
- source,
43
- },
66
+ tools: [],
67
+ errors: [
68
+ {
69
+ path: toolPath,
70
+ error: "Declarative tool files (.md, .json) cannot be loaded as executable modules",
71
+ source,
72
+ },
73
+ ],
44
74
  };
45
75
  }
46
76
 
@@ -49,23 +79,32 @@ async function loadTool(
49
79
  const factory = (module.default ?? module) as CustomToolFactory;
50
80
 
51
81
  if (typeof factory !== "function") {
52
- return { tools: null, error: { path: toolPath, error: "Tool must export a default function", source } };
82
+ return { tools: [], errors: [{ path: toolPath, error: "Tool must export a default function", source }] };
53
83
  }
54
84
 
55
- const toolResult = await withExitGuard(async () => factory(sharedApi));
85
+ const toolResult: unknown = await withExitGuard(async () => factory(sharedApi));
56
86
  const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult];
57
87
 
58
- const loadedTools: LoadedCustomTool[] = toolsArray.map(tool => ({
59
- path: toolPath,
60
- resolvedPath,
61
- tool,
62
- source,
63
- }));
88
+ const loadedTools: LoadedCustomTool[] = [];
89
+ const errors: ToolLoadError[] = [];
90
+ for (const [index, tool] of toolsArray.entries()) {
91
+ if (!isLoadableCustomTool(tool)) {
92
+ errors.push(invalidToolError(toolPath, index, source));
93
+ continue;
94
+ }
95
+
96
+ loadedTools.push({
97
+ path: toolPath,
98
+ resolvedPath,
99
+ tool,
100
+ source,
101
+ });
102
+ }
64
103
 
65
- return { tools: loadedTools, error: null };
104
+ return { tools: loadedTools, errors };
66
105
  } catch (err) {
67
106
  const message = err instanceof Error ? err.message : String(err);
68
- return { tools: null, error: { path: toolPath, error: `Failed to load tool: ${message}`, source } };
107
+ return { tools: [], errors: [{ path: toolPath, error: `Failed to load tool: ${message}`, source }] };
69
108
  }
70
109
  }
71
110
 
@@ -129,28 +168,22 @@ export class CustomToolLoader {
129
168
 
130
169
  async load(pathsWithSources: ToolPathWithSource[]): Promise<void> {
131
170
  for (const { path: toolPath, source } of pathsWithSources) {
132
- const { tools: loadedTools, error } = await loadTool(toolPath, this.#sharedApi.cwd, this.#sharedApi, source);
171
+ const { tools: loadedTools, errors } = await loadTool(toolPath, this.#sharedApi.cwd, this.#sharedApi, source);
172
+ this.errors.push(...errors);
133
173
 
134
- if (error) {
135
- this.errors.push(error);
136
- continue;
137
- }
138
-
139
- if (loadedTools) {
140
- for (const loadedTool of loadedTools) {
141
- // Check for name conflicts
142
- if (this.#seenNames.has(loadedTool.tool.name)) {
143
- this.errors.push({
144
- path: toolPath,
145
- error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`,
146
- source,
147
- });
148
- continue;
149
- }
150
-
151
- this.#seenNames.add(loadedTool.tool.name);
152
- this.tools.push(loadedTool);
174
+ for (const loadedTool of loadedTools) {
175
+ // Check for name conflicts
176
+ if (this.#seenNames.has(loadedTool.tool.name)) {
177
+ this.errors.push({
178
+ path: toolPath,
179
+ error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`,
180
+ source,
181
+ });
182
+ continue;
153
183
  }
184
+
185
+ this.#seenNames.add(loadedTool.tool.name);
186
+ this.tools.push(loadedTool);
154
187
  }
155
188
  }
156
189
  }
@@ -113,6 +113,46 @@ export interface ExtensionUISelectOption {
113
113
 
114
114
  export type ExtensionUISelectItem = string | ExtensionUISelectOption;
115
115
 
116
+ export interface ExtensionAskDialogOption {
117
+ label: string;
118
+ description?: string;
119
+ preview?: string;
120
+ }
121
+
122
+ export interface ExtensionAskDialogQuestion {
123
+ id: string;
124
+ question: string;
125
+ header?: string;
126
+ options: ExtensionAskDialogOption[];
127
+ multi?: boolean;
128
+ recommended?: number;
129
+ }
130
+
131
+ export interface ExtensionAskDialogResultItem {
132
+ id: string;
133
+ question: string;
134
+ options: string[];
135
+ multi: boolean;
136
+ selectedOptions: string[];
137
+ customInput?: string;
138
+ note?: string;
139
+ timedOut?: boolean;
140
+ }
141
+
142
+ export interface ExtensionAskDialogSubmitResult {
143
+ kind: "submit";
144
+ results: ExtensionAskDialogResultItem[];
145
+ }
146
+
147
+ /** Chat-redirect result: the user chose "Chat about this" instead of
148
+ * answering. Distinct from `undefined` (cancel) so AskTool can hand off to
149
+ * the chat loop rather than aborting. */
150
+ export interface ExtensionAskDialogChatResult {
151
+ kind: "chat";
152
+ }
153
+
154
+ export type ExtensionAskDialogResult = ExtensionAskDialogSubmitResult | ExtensionAskDialogChatResult;
155
+
116
156
  export function getExtensionUISelectOptionLabel(option: ExtensionUISelectItem): string {
117
157
  return typeof option === "string" ? option : option.label;
118
158
  }
@@ -195,6 +235,12 @@ export interface ExtensionUIContext {
195
235
  /** Show a text input dialog. */
196
236
  input(title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
197
237
 
238
+ /** Show the rich ask dialog when the interactive TUI surface is available. */
239
+ askDialog?(
240
+ questions: ExtensionAskDialogQuestion[],
241
+ dialogOptions?: ExtensionUIDialogOptions,
242
+ ): Promise<ExtensionAskDialogResult | undefined>;
243
+
198
244
  /** Show a notification to the user. */
199
245
  notify(message: string, type?: "info" | "warning" | "error"): void;
200
246
 
package/src/irc/bus.ts CHANGED
@@ -104,8 +104,19 @@ export class IrcBus {
104
104
  ): Promise<IrcDeliveryReceipt> {
105
105
  const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() };
106
106
  const ref = this.#registry.get(message.to);
107
- if (!ref || ref.status === "aborted") {
108
- return { to: message.to, outcome: "failed", error: `Unknown or terminated agent "${message.to}".` };
107
+ if (!ref) {
108
+ return {
109
+ to: message.to,
110
+ outcome: "failed",
111
+ error: `Unknown agent "${message.to}" — check \`irc list\` for live peers.`,
112
+ };
113
+ }
114
+ if (ref.status === "aborted") {
115
+ return {
116
+ to: message.to,
117
+ outcome: "failed",
118
+ error: `Agent "${message.to}" was hard-aborted and cannot be messaged or revived. Its transcript remains readable at history://${message.to}.`,
119
+ };
109
120
  }
110
121
  // Advisor refs are observability-only transcripts, never messageable peers.
111
122
  if (ref.kind === "advisor") {
@@ -175,7 +186,7 @@ export class IrcBus {
175
186
  filter: { from?: string },
176
187
  timeoutMs: number,
177
188
  signal?: AbortSignal,
178
- options?: { drainPending?: boolean },
189
+ options?: { drainPending?: boolean; liveness?: { registry: AgentRegistry; senderId: string } },
179
190
  ): Promise<IrcMessage | null> {
180
191
  if (signal?.aborted) {
181
192
  throw signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted");
@@ -190,35 +201,49 @@ export class IrcBus {
190
201
  const { promise, resolve, reject } = Promise.withResolvers<IrcMessage | null>();
191
202
  let timer: NodeJS.Timeout | undefined;
192
203
  let onAbort: (() => void) | undefined;
204
+ let unsubscribeLiveness: (() => void) | undefined;
193
205
 
194
- const waiter: IrcWaiter = {
195
- from: filter.from,
196
- resolve: msg => {
197
- cleanup();
198
- resolve(msg);
199
- },
200
- cancel: () => {
201
- cleanup();
202
- },
206
+ const liveness = options?.liveness;
207
+ const livenessReason = filter.from
208
+ ? `IRC wait aborted: agent "${filter.from}" is not running`
209
+ : "IRC wait aborted: no running peers remain";
210
+
211
+ const settle = (
212
+ outcome: { kind: "message"; msg: IrcMessage } | { kind: "timeout" } | { kind: "abort"; error: Error },
213
+ ): void => {
214
+ cleanup();
215
+ if (outcome.kind === "message") {
216
+ resolve(outcome.msg);
217
+ } else if (outcome.kind === "timeout") {
218
+ resolve(null);
219
+ } else {
220
+ reject(outcome.error);
221
+ }
203
222
  };
223
+
204
224
  const cleanup = (): void => {
205
225
  this.#removeWaiter(agentId, waiter);
206
226
  clearTimeout(timer);
207
227
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
228
+ unsubscribeLiveness?.();
229
+ };
230
+
231
+ const waiter: IrcWaiter = {
232
+ from: filter.from,
233
+ resolve: msg => settle({ kind: "message", msg }),
234
+ cancel: () => cleanup(),
208
235
  };
209
236
 
210
237
  if (signal) {
211
- onAbort = () => {
212
- cleanup();
213
- reject(signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"));
214
- };
238
+ onAbort = () =>
239
+ settle({
240
+ kind: "abort",
241
+ error: signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"),
242
+ });
215
243
  signal.addEventListener("abort", onAbort, { once: true });
216
244
  }
217
245
  if (timeoutMs > 0) {
218
- timer = setTimeout(() => {
219
- cleanup();
220
- resolve(null);
221
- }, timeoutMs);
246
+ timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
222
247
  timer.unref?.();
223
248
  }
224
249
 
@@ -228,6 +253,22 @@ export class IrcBus {
228
253
  this.#waiters.set(agentId, waiters);
229
254
  }
230
255
  waiters.push(waiter);
256
+
257
+ if (liveness) {
258
+ const { registry, senderId } = liveness;
259
+ const hasRunningSender = (from?: string): boolean =>
260
+ registry.listVisibleTo(senderId).some(ref => ref.status === "running" && (!from || ref.id === from));
261
+ const check = filter.from ? () => hasRunningSender(filter.from) : () => hasRunningSender();
262
+ unsubscribeLiveness = registry.onChange(() => {
263
+ if (!check()) {
264
+ settle({ kind: "abort", error: new Error(livenessReason) });
265
+ }
266
+ });
267
+ if (!check()) {
268
+ settle({ kind: "abort", error: new Error(livenessReason) });
269
+ }
270
+ }
271
+
231
272
  return promise;
232
273
  }
233
274