@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.0

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 (128) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +1 -1
  4. package/src/cli/args.ts +2 -2
  5. package/src/cli.ts +1 -0
  6. package/src/commands/acp.ts +24 -0
  7. package/src/commands/launch.ts +6 -4
  8. package/src/commit/agentic/prompts/system.md +1 -1
  9. package/src/config/model-resolver.ts +30 -0
  10. package/src/config/settings-schema.ts +31 -0
  11. package/src/edit/index.ts +22 -1
  12. package/src/edit/modes/patch.ts +10 -0
  13. package/src/edit/modes/replace.ts +3 -0
  14. package/src/edit/renderer.ts +10 -0
  15. package/src/eval/js/context-manager.ts +1 -1
  16. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  17. package/src/eval/js/shared/runtime.ts +31 -4
  18. package/src/eval/js/tool-bridge.ts +43 -21
  19. package/src/extensibility/extensions/runner.ts +54 -1
  20. package/src/extensibility/extensions/types.ts +11 -0
  21. package/src/extensibility/skills.ts +33 -1
  22. package/src/internal-urls/docs-index.generated.ts +6 -6
  23. package/src/internal-urls/index.ts +1 -0
  24. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  25. package/src/internal-urls/router.ts +6 -3
  26. package/src/internal-urls/types.ts +22 -1
  27. package/src/main.ts +13 -9
  28. package/src/modes/acp/acp-agent.ts +361 -54
  29. package/src/modes/acp/acp-client-bridge.ts +152 -0
  30. package/src/modes/acp/acp-event-mapper.ts +180 -15
  31. package/src/modes/acp/terminal-auth.ts +37 -0
  32. package/src/modes/components/read-tool-group.ts +29 -1
  33. package/src/modes/controllers/command-controller.ts +14 -6
  34. package/src/modes/controllers/event-controller.ts +24 -11
  35. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  36. package/src/modes/controllers/input-controller.ts +72 -39
  37. package/src/modes/interactive-mode.ts +71 -7
  38. package/src/modes/rpc/rpc-mode.ts +17 -2
  39. package/src/modes/types.ts +6 -2
  40. package/src/modes/utils/ui-helpers.ts +15 -3
  41. package/src/prompts/agents/designer.md +5 -5
  42. package/src/prompts/agents/explore.md +7 -7
  43. package/src/prompts/agents/init.md +9 -9
  44. package/src/prompts/agents/librarian.md +14 -14
  45. package/src/prompts/agents/plan.md +4 -4
  46. package/src/prompts/agents/reviewer.md +5 -5
  47. package/src/prompts/agents/task.md +10 -10
  48. package/src/prompts/commands/orchestrate.md +2 -2
  49. package/src/prompts/compaction/branch-summary.md +3 -3
  50. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  51. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  52. package/src/prompts/compaction/compaction-summary.md +5 -5
  53. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  54. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  55. package/src/prompts/memories/consolidation.md +2 -2
  56. package/src/prompts/memories/read-path.md +1 -1
  57. package/src/prompts/memories/stage_one_input.md +1 -1
  58. package/src/prompts/memories/stage_one_system.md +5 -5
  59. package/src/prompts/review-request.md +4 -4
  60. package/src/prompts/system/agent-creation-architect.md +17 -17
  61. package/src/prompts/system/agent-creation-user.md +2 -2
  62. package/src/prompts/system/commit-message-system.md +2 -2
  63. package/src/prompts/system/custom-system-prompt.md +2 -2
  64. package/src/prompts/system/eager-todo.md +6 -6
  65. package/src/prompts/system/handoff-document.md +1 -1
  66. package/src/prompts/system/plan-mode-active.md +22 -21
  67. package/src/prompts/system/plan-mode-approved.md +4 -4
  68. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  69. package/src/prompts/system/plan-mode-reference.md +2 -2
  70. package/src/prompts/system/plan-mode-subagent.md +8 -8
  71. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  72. package/src/prompts/system/project-prompt.md +4 -4
  73. package/src/prompts/system/subagent-system-prompt.md +7 -7
  74. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  75. package/src/prompts/system/system-prompt.md +72 -71
  76. package/src/prompts/system/ttsr-interrupt.md +1 -1
  77. package/src/prompts/tools/apply-patch.md +1 -1
  78. package/src/prompts/tools/ast-edit.md +3 -3
  79. package/src/prompts/tools/ast-grep.md +3 -3
  80. package/src/prompts/tools/browser.md +3 -3
  81. package/src/prompts/tools/checkpoint.md +3 -3
  82. package/src/prompts/tools/exit-plan-mode.md +2 -2
  83. package/src/prompts/tools/find.md +3 -3
  84. package/src/prompts/tools/github.md +2 -5
  85. package/src/prompts/tools/hashline.md +6 -6
  86. package/src/prompts/tools/image-gen.md +3 -3
  87. package/src/prompts/tools/irc.md +1 -1
  88. package/src/prompts/tools/lsp.md +2 -2
  89. package/src/prompts/tools/patch.md +6 -6
  90. package/src/prompts/tools/read.md +7 -7
  91. package/src/prompts/tools/replace.md +5 -5
  92. package/src/prompts/tools/retain.md +1 -1
  93. package/src/prompts/tools/rewind.md +2 -2
  94. package/src/prompts/tools/search.md +2 -2
  95. package/src/prompts/tools/ssh.md +2 -2
  96. package/src/prompts/tools/task.md +12 -6
  97. package/src/prompts/tools/web-search.md +2 -2
  98. package/src/prompts/tools/write.md +3 -3
  99. package/src/sdk.ts +69 -12
  100. package/src/session/agent-session.ts +231 -22
  101. package/src/session/client-bridge.ts +81 -0
  102. package/src/session/compaction/errors.ts +31 -0
  103. package/src/session/compaction/index.ts +1 -0
  104. package/src/slash-commands/acp-builtins.ts +46 -0
  105. package/src/slash-commands/builtin-registry.ts +699 -116
  106. package/src/slash-commands/helpers/context-report.ts +39 -0
  107. package/src/slash-commands/helpers/format.ts +23 -0
  108. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  109. package/src/slash-commands/helpers/mcp.ts +532 -0
  110. package/src/slash-commands/helpers/parse.ts +85 -0
  111. package/src/slash-commands/helpers/ssh.ts +193 -0
  112. package/src/slash-commands/helpers/todo.ts +279 -0
  113. package/src/slash-commands/helpers/usage-report.ts +91 -0
  114. package/src/slash-commands/types.ts +126 -0
  115. package/src/task/executor.ts +10 -3
  116. package/src/task/index.ts +17 -1
  117. package/src/task/render.ts +6 -3
  118. package/src/tools/bash.ts +176 -2
  119. package/src/tools/conflict-detect.ts +6 -6
  120. package/src/tools/fetch.ts +15 -4
  121. package/src/tools/find.ts +19 -1
  122. package/src/tools/gh-renderer.ts +0 -12
  123. package/src/tools/gh.ts +682 -176
  124. package/src/tools/github-cache.ts +548 -0
  125. package/src/tools/index.ts +3 -0
  126. package/src/tools/read.ts +110 -27
  127. package/src/tools/write.ts +23 -1
  128. package/src/tui/code-cell.ts +70 -2
@@ -1,9 +1,10 @@
1
1
  import { parse as babelParse } from "@babel/parser";
2
2
 
3
- // Static ESM `import` declarations are not valid inside vm.runInContext (script-mode parsing).
4
- // We rewrite top-level imports to dynamic-import expressions in the user-supplied source so
5
- // pasted ESM runs verbatim. A real parser keeps imports embedded in string literals, template
6
- // literals, or comments intact.
3
+ // Static ESM `import` declarations are not valid inside vm.runInContext (script-mode parsing),
4
+ // and dynamic `import(...)` would otherwise resolve specifiers against the worker module's URL
5
+ // instead of the session cwd. We rewrite both forms so they route through the worker-injected
6
+ // `__omp_import__` helper, which resolves the specifier against the active session cwd. A real
7
+ // parser keeps imports embedded in string literals, template literals, or comments intact.
7
8
 
8
9
  type BabelImportDeclaration = {
9
10
  type: "ImportDeclaration";
@@ -25,26 +26,78 @@ type BabelLexicalDecl =
25
26
  | { type: "VariableDeclaration"; kind: "const" | "let" | "var"; start: number; end: number }
26
27
  | { type: "ClassDeclaration"; start: number; end: number; id: { start: number; end: number; name: string } | null };
27
28
 
28
- function buildDynamicImportCall(sourceLiteral: string, withClause: string | undefined): string {
29
+ type BabelExpressionStatement = {
30
+ type: "ExpressionStatement";
31
+ start: number;
32
+ end: number;
33
+ expression?: { type?: string };
34
+ };
35
+
36
+ type BabelProgramNode = BabelImportDeclaration | BabelLexicalDecl | BabelExpressionStatement | { type: string };
37
+
38
+ type BabelNode = { type: string; start: number; end: number; [key: string]: unknown };
39
+
40
+ function parseProgram(code: string): { program: { body: ReadonlyArray<BabelProgramNode> } } | null {
41
+ try {
42
+ return babelParse(code, {
43
+ sourceType: "module",
44
+ allowAwaitOutsideFunction: true,
45
+ allowReturnOutsideFunction: true,
46
+ allowImportExportEverywhere: true,
47
+ allowNewTargetOutsideFunction: true,
48
+ allowSuperOutsideMethod: true,
49
+ allowUndeclaredExports: true,
50
+ errorRecovery: true,
51
+ }) as unknown as { program: { body: ReadonlyArray<BabelProgramNode> } };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function buildOmpImportCall(sourceLiteral: string, optionsLiteral: string | undefined): string {
29
58
  // Route every static import through the worker-injected `__omp_import__` helper so the
30
59
  // specifier resolves against the session cwd (and `with`-attribute imports keep working).
31
- return withClause ? `__omp_import__(${sourceLiteral}, ${withClause})` : `__omp_import__(${sourceLiteral})`;
60
+ return optionsLiteral ? `__omp_import__(${sourceLiteral}, ${optionsLiteral})` : `__omp_import__(${sourceLiteral})`;
32
61
  }
33
62
 
34
- function buildWithClause(node: BabelImportDeclaration): string | undefined {
63
+ // Walks every node in `root`, depth-first, invoking `visit` on each one. Skips Babel's
64
+ // non-AST bookkeeping fields so we don't recurse into source locations or comment arrays.
65
+ function walkNodes(root: unknown, visit: (node: BabelNode) => void): void {
66
+ const stack: unknown[] = [root];
67
+ while (stack.length > 0) {
68
+ const current = stack.pop();
69
+ if (!current || typeof current !== "object") continue;
70
+ if (Array.isArray(current)) {
71
+ for (let i = current.length - 1; i >= 0; i--) stack.push(current[i]);
72
+ continue;
73
+ }
74
+ const node = current as Record<string, unknown>;
75
+ if (typeof node.type === "string") visit(node as unknown as BabelNode);
76
+ for (const key in node) {
77
+ if (key === "loc" || key === "extra" || key === "range") continue;
78
+ if (key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
79
+ const value = node[key];
80
+ if (value && typeof value === "object") stack.push(value);
81
+ }
82
+ }
83
+ }
84
+
85
+ function buildOptionsLiteral(node: BabelImportDeclaration): string | undefined {
35
86
  const attrs = node.attributes;
36
87
  if (!attrs || attrs.length === 0) return undefined;
37
88
  const pairs = attrs.map(attr => {
38
89
  const key = attr.key.type === "Identifier" ? attr.key.name : JSON.stringify(attr.key.value);
39
90
  return `${key}: ${JSON.stringify(attr.value.value)}`;
40
91
  });
41
- return `{ ${pairs.join(", ")} }`;
92
+ // Native dynamic import takes options as `{ with: { ... } }`. `__omp_import__` forwards the
93
+ // options bag verbatim, so we wrap the attribute pairs accordingly.
94
+ return `{ with: { ${pairs.join(", ")} } }`;
42
95
  }
43
96
 
44
97
  function rewriteImportNode(node: BabelImportDeclaration): string {
45
98
  const sourceLiteral = JSON.stringify(node.source.value);
46
- const withClause = buildWithClause(node);
47
- const importCall = buildDynamicImportCall(sourceLiteral, withClause);
99
+ const optionsLiteral = buildOptionsLiteral(node);
100
+ const importCall = buildOmpImportCall(sourceLiteral, optionsLiteral);
48
101
 
49
102
  let defaultName: string | undefined;
50
103
  let namespaceName: string | undefined;
@@ -73,37 +126,43 @@ function rewriteImportNode(node: BabelImportDeclaration): string {
73
126
  return `await ${importCall};`;
74
127
  }
75
128
 
76
- export function rewriteStaticImports(code: string): string {
129
+ export function rewriteImports(code: string): string {
77
130
  if (!code.includes("import")) return code;
78
131
 
79
- let ast: { program: { body: ReadonlyArray<{ type: string }> } };
80
- try {
81
- ast = babelParse(code, {
82
- sourceType: "module",
83
- allowAwaitOutsideFunction: true,
84
- allowReturnOutsideFunction: true,
85
- allowImportExportEverywhere: true,
86
- allowNewTargetOutsideFunction: true,
87
- allowSuperOutsideMethod: true,
88
- allowUndeclaredExports: true,
89
- errorRecovery: true,
90
- }) as unknown as typeof ast;
91
- } catch {
132
+ const ast = parseProgram(code);
133
+ if (!ast) {
92
134
  // Parser bailed entirely — let the VM surface the real syntax error.
93
135
  return code;
94
136
  }
95
137
 
96
- const imports: BabelImportDeclaration[] = [];
138
+ type Edit = { start: number; end: number; text: string };
139
+ const edits: Edit[] = [];
140
+
141
+ // Top-level static `import` declarations become `await __omp_import__(...)` calls.
97
142
  for (const node of ast.program.body) {
98
- if (node.type === "ImportDeclaration") imports.push(node as unknown as BabelImportDeclaration);
143
+ if (node.type !== "ImportDeclaration") continue;
144
+ const decl = node as unknown as BabelImportDeclaration;
145
+ edits.push({ start: decl.start, end: decl.end, text: rewriteImportNode(decl) });
99
146
  }
100
- if (imports.length === 0) return code;
147
+
148
+ // Dynamic `import(...)` expressions (anywhere) get their callee swapped for `__omp_import__`
149
+ // so the specifier resolves against the session cwd instead of the worker module's URL.
150
+ walkNodes(ast, node => {
151
+ if (node.type !== "CallExpression") return;
152
+ const call = node as unknown as { callee?: { type?: string; start?: number; end?: number } };
153
+ const callee = call.callee;
154
+ if (!callee || callee.type !== "Import" || typeof callee.start !== "number" || typeof callee.end !== "number")
155
+ return;
156
+ edits.push({ start: callee.start, end: callee.end, text: "__omp_import__" });
157
+ });
158
+
159
+ if (edits.length === 0) return code;
101
160
 
102
161
  // Splice from the back so earlier offsets stay valid.
103
- imports.sort((a, b) => b.start - a.start);
162
+ edits.sort((a, b) => b.start - a.start);
104
163
  let result = code;
105
- for (const node of imports) {
106
- result = result.slice(0, node.start) + rewriteImportNode(node) + result.slice(node.end);
164
+ for (const edit of edits) {
165
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
107
166
  }
108
167
  return result;
109
168
  }
@@ -124,19 +183,8 @@ export function rewriteStaticImports(code: string): string {
124
183
  export function demoteTopLevelLexicals(code: string): string {
125
184
  if (!/\b(?:const|let|class)\b/.test(code)) return code;
126
185
 
127
- let ast: { program: { body: ReadonlyArray<{ type: string }> } };
128
- try {
129
- ast = babelParse(code, {
130
- sourceType: "module",
131
- allowAwaitOutsideFunction: true,
132
- allowReturnOutsideFunction: true,
133
- allowImportExportEverywhere: true,
134
- allowNewTargetOutsideFunction: true,
135
- allowSuperOutsideMethod: true,
136
- allowUndeclaredExports: true,
137
- errorRecovery: true,
138
- }) as unknown as typeof ast;
139
- } catch {
186
+ const ast = parseProgram(code);
187
+ if (!ast) {
140
188
  return code;
141
189
  }
142
190
 
@@ -172,6 +220,24 @@ export function demoteTopLevelLexicals(code: string): string {
172
220
  return result;
173
221
  }
174
222
 
223
+ function returnFinalExpression(code: string): { source: string; returned: boolean } {
224
+ const ast = parseProgram(code);
225
+ const body = ast?.program.body;
226
+ if (!body) return { source: code, returned: false };
227
+ let lastIndex = body.length - 1;
228
+ while (lastIndex >= 0 && body[lastIndex]?.type === "EmptyStatement") lastIndex--;
229
+ const last = lastIndex >= 0 ? body[lastIndex] : undefined;
230
+ if (last?.type !== "ExpressionStatement") return { source: code, returned: false };
231
+
232
+ const expression = last as BabelExpressionStatement;
233
+ const prefix = code.slice(0, expression.start);
234
+ const statement = code.slice(expression.start, expression.end);
235
+ const suffix = code.slice(expression.end);
236
+ const semicolonMatch = statement.match(/;\s*$/);
237
+ const trimmedStatement = semicolonMatch ? statement.slice(0, semicolonMatch.index) : statement;
238
+ return { source: `${prefix}__omp_set_final_expr__((${trimmedStatement}));${suffix}`, returned: true };
239
+ }
240
+
175
241
  /**
176
242
  * Strip TypeScript syntax (type annotations, `interface`, `as`, `satisfies`, generics in
177
243
  * call expressions, etc.) before the import/lexical rewriters parse the code. We use Bun's
@@ -198,14 +264,20 @@ export function stripTypeScript(code: string): string {
198
264
  const LOOKS_LIKE_TS =
199
265
  /(?:\binterface\s+\w|\btype\s+\w+\s*=|\b(?:as|satisfies)\s+(?:[A-Z]|\bconst\b)|:\s*(?:string|number|boolean|any|unknown|void|never|object|[A-Z]\w*)\b|<\s*[A-Z]\w*\s*[,>])/;
200
266
 
201
- export function wrapCode(code: string): { source: string; asyncWrapped: boolean } {
202
- const rewritten = demoteTopLevelLexicals(rewriteStaticImports(stripTypeScript(code)));
203
- const needsAsyncWrapper = /\bawait\b|\breturn\b/.test(rewritten);
267
+ export function wrapCode(code: string): { source: string; asyncWrapped: boolean; finalExpressionReturned: boolean } {
268
+ const stripped = stripTypeScript(code);
269
+ const finalExpression = returnFinalExpression(stripped);
270
+ const rewritten = {
271
+ source: demoteTopLevelLexicals(rewriteImports(finalExpression.source)),
272
+ returned: finalExpression.returned,
273
+ };
274
+ const needsAsyncWrapper = /\bawait\b|\breturn\b/.test(rewritten.source);
204
275
  if (!needsAsyncWrapper) {
205
- return { source: rewritten, asyncWrapped: false };
276
+ return { source: rewritten.source, asyncWrapped: false, finalExpressionReturned: rewritten.returned };
206
277
  }
207
278
  return {
208
- source: `(async () => {\n${rewritten}\n})()`,
279
+ source: `(async () => {\n${rewritten.source}\n})()`,
209
280
  asyncWrapped: true,
281
+ finalExpressionReturned: rewritten.returned,
210
282
  };
211
283
  }
@@ -4,6 +4,8 @@ import * as path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import * as util from "node:util";
6
6
 
7
+ import { logger } from "@oh-my-pi/pi-utils";
8
+
7
9
  import { ToolError } from "../../../tools/tool-errors";
8
10
  import { createHelpers, type HelperBundle } from "./helpers";
9
11
  import { awaitMaybePromise, indirectEval } from "./indirect-eval";
@@ -48,6 +50,8 @@ export class JsRuntime {
48
50
  readonly sessionId: string;
49
51
  #env: Map<string, string>;
50
52
  #getHooks: () => RuntimeHooks | null;
53
+ #finalExpressionSet = false;
54
+ #finalExpressionValue: unknown;
51
55
 
52
56
  constructor(opts: RuntimeOptions) {
53
57
  this.#cwd = opts.initialCwd;
@@ -82,8 +86,20 @@ export class JsRuntime {
82
86
  }
83
87
 
84
88
  async run(code: string, filename?: string): Promise<unknown> {
89
+ this.#finalExpressionSet = false;
90
+ this.#finalExpressionValue = undefined;
85
91
  const wrapped = wrapCode(code);
86
92
  const value = indirectEval(wrapped.source, filename);
93
+ if (wrapped.finalExpressionReturned) {
94
+ const awaited = await awaitMaybePromise(value);
95
+ if (this.#finalExpressionSet) {
96
+ const finalValue = this.#finalExpressionValue;
97
+ this.#finalExpressionSet = false;
98
+ this.#finalExpressionValue = undefined;
99
+ return await awaitMaybePromise(finalValue);
100
+ }
101
+ return awaited;
102
+ }
87
103
  return await awaitMaybePromise(value);
88
104
  }
89
105
 
@@ -97,7 +113,14 @@ export class JsRuntime {
97
113
  hooks.onDisplay({ type: "image", data: record.data, mimeType: record.mimeType });
98
114
  return;
99
115
  }
100
- hooks.onDisplay({ type: "json", data: structuredClone(value) });
116
+ try {
117
+ hooks.onDisplay({ type: "json", data: structuredClone(value) });
118
+ } catch (err) {
119
+ logger.debug("js displayValue: value is not structured-cloneable, falling back to text", {
120
+ error: err instanceof Error ? err.message : String(err),
121
+ });
122
+ hooks.onText(`${Object.prototype.toString.call(value)}\n`);
123
+ }
101
124
  return;
102
125
  }
103
126
  hooks.onText(`${String(value)}\n`);
@@ -112,9 +135,9 @@ export class JsRuntime {
112
135
  if (!hooks) throw new ToolError("Tool calls are only valid inside an active run");
113
136
  return await hooks.callTool(name, args);
114
137
  },
115
- __omp_import__: async (source: string, attrs?: Record<string, string>) => {
138
+ __omp_import__: async (source: string, options?: ImportCallOptions) => {
116
139
  const target = resolveImportSpecifier(this.#cwd, source);
117
- return attrs ? await import(target, { with: attrs }) : await import(target);
140
+ return options !== undefined ? await import(target, options) : await import(target);
118
141
  },
119
142
  __omp_emit_status__: (op: string, data: Record<string, unknown> = {}) => {
120
143
  const event: JsStatusEvent = { op, ...data };
@@ -126,6 +149,10 @@ export class JsRuntime {
126
149
  this.#getHooks()?.onText(text.endsWith("\n") ? text : `${text}\n`);
127
150
  },
128
151
  __omp_display__: (value: unknown) => this.displayValue(value),
152
+ __omp_set_final_expr__: (value: unknown) => {
153
+ this.#finalExpressionSet = true;
154
+ this.#finalExpressionValue = value;
155
+ },
129
156
  webcrypto: crypto,
130
157
  // `process` is intentionally not overridden — user code gets the host worker's real
131
158
  // `process` object. Subsetting it caused segfaults in workers that share state with
@@ -152,7 +179,7 @@ function buildRequire(cwd: string): NodeJS.Require {
152
179
  }
153
180
 
154
181
  /**
155
- * Resolve an import specifier emitted by `rewriteStaticImports` against the active session
182
+ * Resolve an import specifier emitted by `rewriteImports` against the active session
156
183
  * cwd. Relative paths (`./`, `../`, `/`) and bare specifiers (`pkg`, `@scope/pkg`) both go
157
184
  * through `Bun.resolveSync` rooted at the cwd so user-pasted ESM behaves as if it lived in
158
185
  * the project — not next to the worker module. URL-like specifiers (`file://`, `data:`,
@@ -17,7 +17,17 @@ type ToolValue =
17
17
  text: string;
18
18
  details?: unknown;
19
19
  images?: Array<{ mimeType: string; data: string }>;
20
+ hasError?: boolean;
20
21
  };
22
+ function toolResultHasError(result: AgentToolResult): boolean {
23
+ if ((result as { isError?: unknown }).isError === true) {
24
+ return true;
25
+ }
26
+ if (!(result.details && typeof result.details === "object")) {
27
+ return false;
28
+ }
29
+ return (result.details as { isError?: unknown }).isError === true;
30
+ }
21
31
 
22
32
  function getTool(session: ToolSession, name: string): AgentTool {
23
33
  const tool = session.getToolByName?.(name);
@@ -38,7 +48,13 @@ function normalizeArgs(args: unknown): unknown {
38
48
  return record;
39
49
  }
40
50
 
41
- function summarizeToolResult(name: string, args: unknown, result: AgentToolResult, text: string): JsStatusEvent {
51
+ function summarizeToolResult(
52
+ name: string,
53
+ args: unknown,
54
+ result: AgentToolResult,
55
+ text: string,
56
+ hasError: boolean,
57
+ ): JsStatusEvent {
42
58
  const record = (args && typeof args === "object" ? (args as Record<string, unknown>) : {}) as Record<
43
59
  string,
44
60
  unknown
@@ -46,39 +62,41 @@ function summarizeToolResult(name: string, args: unknown, result: AgentToolResul
46
62
  const details = (
47
63
  result.details && typeof result.details === "object" ? (result.details as Record<string, unknown>) : {}
48
64
  ) as Record<string, unknown>;
65
+ const withError = (event: JsStatusEvent): JsStatusEvent =>
66
+ hasError ? { ...event, hasError: true, error: text.slice(0, 500) } : event;
49
67
 
50
68
  switch (name) {
51
69
  case "read":
52
- return { op: "read", path: record.path, chars: text.length, preview: text.slice(0, 500) };
70
+ return withError({ op: "read", path: record.path, chars: text.length, preview: text.slice(0, 500) });
53
71
  case "write":
54
- return {
72
+ return withError({
55
73
  op: "write",
56
74
  path: record.path,
57
75
  chars: typeof record.content === "string" ? record.content.length : 0,
58
- };
76
+ });
59
77
  case "grep":
60
- return {
78
+ return withError({
61
79
  op: "grep",
62
80
  pattern: record.pattern,
63
81
  path: record.path,
64
82
  count: details.matchCount ?? undefined,
65
- };
83
+ });
66
84
  case "find":
67
- return {
85
+ return withError({
68
86
  op: "find",
69
87
  pattern: record.pattern,
70
88
  count: details.fileCount ?? undefined,
71
89
  matches: Array.isArray(details.files) ? details.files.slice(0, 20) : undefined,
72
- };
90
+ });
73
91
  case "bash":
74
- return {
92
+ return withError({
75
93
  op: "run",
76
94
  cmd: record.command,
77
95
  code: typeof details.exitCode === "number" ? details.exitCode : undefined,
78
96
  output: text.slice(0, 500),
79
- };
97
+ });
80
98
  default:
81
- return { op: name, chars: text.length };
99
+ return withError({ op: name, chars: text.length });
82
100
  }
83
101
  }
84
102
 
@@ -97,21 +115,25 @@ export async function callSessionTool(name: string, args: unknown, options: Tool
97
115
  content.type === "image" && typeof content.mimeType === "string" && typeof content.data === "string",
98
116
  );
99
117
  const text = textBlocks.map(block => block.text).join("");
100
- options.emitStatus?.(summarizeToolResult(name, normalizedArgs, result, text));
101
- if (result.details === undefined && imageBlocks.length === 0) {
118
+ const hasError = toolResultHasError(result);
119
+ options.emitStatus?.(summarizeToolResult(name, normalizedArgs, result, text, hasError));
120
+ if (result.details === undefined && imageBlocks.length === 0 && !hasError) {
102
121
  return text;
103
122
  }
104
- return {
123
+ const value: Exclude<ToolValue, string> = {
105
124
  text,
106
125
  details: result.details,
107
- images:
108
- imageBlocks.length > 0
109
- ? imageBlocks.map(block => ({
110
- mimeType: block.mimeType,
111
- data: block.data,
112
- }))
113
- : undefined,
114
126
  };
127
+ if (imageBlocks.length > 0) {
128
+ value.images = imageBlocks.map(block => ({
129
+ mimeType: block.mimeType,
130
+ data: block.data,
131
+ }));
132
+ }
133
+ if (hasError) {
134
+ value.hasError = true;
135
+ }
136
+ return value;
115
137
  } catch (error) {
116
138
  options.emitStatus?.({
117
139
  op: name,
@@ -2,7 +2,7 @@
2
2
  * Extension runner - executes extensions and manages their lifecycle.
3
3
  */
4
4
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
5
- import type { ImageContent, Model, ProviderResponseMetadata } from "@oh-my-pi/pi-ai";
5
+ import type { CredentialDisabledEvent, ImageContent, Model, ProviderResponseMetadata } from "@oh-my-pi/pi-ai";
6
6
  import type { KeyId } from "@oh-my-pi/pi-tui";
7
7
  import { logger } from "@oh-my-pi/pi-utils";
8
8
  import type { ModelRegistry } from "../../config/model-registry";
@@ -69,6 +69,8 @@ export function __test_setExtensionHandlerTimeoutMs(timeoutMs: number): void {
69
69
 
70
70
  const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
71
71
 
72
+ const MAX_PENDING_CREDENTIAL_DISABLED = 32;
73
+
72
74
  /**
73
75
  * Events handled by the generic emit() method.
74
76
  * Events with dedicated emitXxx() methods are excluded for stronger type safety.
@@ -185,6 +187,15 @@ export class ExtensionRunner {
185
187
  #reloadHandler: () => Promise<void> = async () => {};
186
188
  #shutdownHandler: ShutdownHandler = () => {};
187
189
  #commandDiagnostics: Array<{ type: string; message: string; path: string }> = [];
190
+ #initialized = false;
191
+ /**
192
+ * Buffer for `credential_disabled` events received via {@link emitCredentialDisabled}
193
+ * before {@link initialize} has run. Drained through {@link emit} once initialize sets
194
+ * up the runtime context, so extension handlers see a populated UI/runtime context
195
+ * rather than the constructor's no-op default. Bounded at
196
+ * {@link MAX_PENDING_CREDENTIAL_DISABLED}; oldest entries are dropped under pressure.
197
+ */
198
+ #pendingCredentialDisabled: CredentialDisabledEvent[] = [];
188
199
 
189
200
  constructor(
190
201
  private readonly extensions: Extension[],
@@ -237,6 +248,48 @@ export class ExtensionRunner {
237
248
  }
238
249
 
239
250
  this.#uiContext = uiContext ?? noOpUIContext;
251
+ this.#initialized = true;
252
+
253
+ // Drain events buffered by emitCredentialDisabled() before initialize ran. The
254
+ // spread adds the `type` discriminator — `event` is the pi-ai shape (no `type`).
255
+ // Deferred by one microtask so callers that register an onError listener
256
+ // synchronously after initialize() see handler errors routed through it.
257
+ const pending = this.#pendingCredentialDisabled.splice(0);
258
+ queueMicrotask(() => {
259
+ for (const event of pending) {
260
+ this.emit({ type: "credential_disabled", ...event }).catch((error: unknown) => {
261
+ logger.warn("credential_disabled handler threw during initialize flush", {
262
+ provider: event.provider,
263
+ error: error instanceof Error ? error.message : String(error),
264
+ });
265
+ });
266
+ }
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Forward a `credential_disabled` event from `AuthStorage` to extension handlers.
272
+ *
273
+ * If {@link initialize} has not yet run, the event is buffered and replayed once
274
+ * initialize wires the runtime/UI context. This matters because mode controllers
275
+ * (interactive, RPC, ACP, print, subagent) call `initialize()` AFTER `createAgentSession`
276
+ * returns, but `AuthStorage` can fire `credential_disabled` during startup model probes
277
+ * inside `createAgentSession()`. Without deferral, extension handlers would observe
278
+ * `hasUI=false`, an unset model, and no-op runtime actions on exactly the headline
279
+ * "OAuth invalid_grant during startup" path the event was designed to surface.
280
+ *
281
+ * Always returns; never throws. Errors from handlers are routed through
282
+ * {@link onError} via {@link emit}'s normal isolation.
283
+ */
284
+ async emitCredentialDisabled(event: CredentialDisabledEvent): Promise<void> {
285
+ if (!this.#initialized) {
286
+ if (this.#pendingCredentialDisabled.length >= MAX_PENDING_CREDENTIAL_DISABLED) {
287
+ this.#pendingCredentialDisabled.shift();
288
+ }
289
+ this.#pendingCredentialDisabled.push(event);
290
+ return;
291
+ }
292
+ await this.emit({ type: "credential_disabled", ...event });
240
293
  }
241
294
 
242
295
  getUIContext(): ExtensionUIContext {
@@ -624,6 +624,15 @@ export interface TodoReminderEvent {
624
624
  maxAttempts: number;
625
625
  }
626
626
 
627
+ /** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */
628
+ export interface CredentialDisabledEvent {
629
+ type: "credential_disabled";
630
+ /** Provider id whose credential was disabled (e.g. "anthropic"). */
631
+ provider: string;
632
+ /** Verbatim error captured for forensics (truncated upstream). */
633
+ disabledCause: string;
634
+ }
635
+
627
636
  // ============================================================================
628
637
  // User Bash Events
629
638
  // ============================================================================
@@ -831,6 +840,7 @@ export type ExtensionEvent =
831
840
  | AutoRetryEndEvent
832
841
  | TtsrTriggeredEvent
833
842
  | TodoReminderEvent
843
+ | CredentialDisabledEvent
834
844
  | UserBashEvent
835
845
  | UserPythonEvent
836
846
  | InputEvent
@@ -1012,6 +1022,7 @@ export interface ExtensionAPI {
1012
1022
  on(event: "auto_retry_end", handler: ExtensionHandler<AutoRetryEndEvent>): void;
1013
1023
  on(event: "ttsr_triggered", handler: ExtensionHandler<TtsrTriggeredEvent>): void;
1014
1024
  on(event: "todo_reminder", handler: ExtensionHandler<TodoReminderEvent>): void;
1025
+ on(event: "credential_disabled", handler: ExtensionHandler<CredentialDisabledEvent>): void;
1015
1026
  on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): void;
1016
1027
  on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
1017
1028
  on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
@@ -6,8 +6,8 @@ import type { SourceMeta } from "../capability/types";
6
6
  import type { SkillsSettings } from "../config/settings";
7
7
  import { type Skill as CapabilitySkill, loadCapability } from "../discovery";
8
8
  import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
9
+ import type { SkillPromptDetails } from "../session/messages";
9
10
  import { expandTilde } from "../tools/path-utils";
10
-
11
11
  export interface Skill {
12
12
  name: string;
13
13
  description: string;
@@ -270,3 +270,35 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
270
270
  warnings: [...(result.warnings ?? []).map(w => ({ skillPath: "", message: w })), ...collisionWarnings],
271
271
  };
272
272
  }
273
+
274
+ export interface BuiltSkillPromptMessage {
275
+ message: string;
276
+ details: SkillPromptDetails;
277
+ }
278
+
279
+ export function getSkillSlashCommandName(skill: Pick<Skill, "name">): string {
280
+ return `skill:${skill.name}`;
281
+ }
282
+
283
+ export async function buildSkillPromptMessage(
284
+ skill: Pick<Skill, "name" | "filePath">,
285
+ args: string,
286
+ ): Promise<BuiltSkillPromptMessage> {
287
+ const content = await Bun.file(skill.filePath).text();
288
+ const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
289
+ const metaLines = [`Skill: ${skill.filePath}`];
290
+ const trimmedArgs = args.trim();
291
+ if (trimmedArgs) {
292
+ metaLines.push(`User: ${trimmedArgs}`);
293
+ }
294
+ const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
295
+ return {
296
+ message,
297
+ details: {
298
+ name: skill.name,
299
+ path: skill.filePath,
300
+ args: trimmedArgs || undefined,
301
+ lineCount: body ? body.split("\n").length : 0,
302
+ },
303
+ };
304
+ }