@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4
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.
- package/CHANGELOG.md +60 -0
- package/dist/cli.js +3682 -3615
- package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +8 -4
- package/dist/types/advisor/config.d.ts +88 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/collab/replication-shrink.d.ts +39 -0
- package/dist/types/config/provider-globals.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +81 -0
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/memories/index.d.ts +20 -1
- package/dist/types/modes/components/advisor-config.d.ts +59 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +35 -1
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/command-controller.d.ts +3 -3
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +11 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +9 -3
- package/dist/types/session/agent-session.d.ts +58 -10
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +26 -0
- package/dist/types/session/messages.test.d.ts +1 -0
- package/dist/types/session/session-entries.d.ts +31 -3
- package/dist/types/session/session-history-format.d.ts +6 -0
- package/dist/types/session/session-loader.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +8 -7
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -0
- package/dist/types/ssh/connection-manager.d.ts +47 -0
- package/dist/types/ssh/utils.d.ts +16 -0
- package/dist/types/task/executor.d.ts +3 -16
- package/dist/types/task/render.d.ts +0 -5
- package/dist/types/task/renderer.d.ts +13 -0
- package/dist/types/task/types.d.ts +16 -0
- package/dist/types/task/yield-assembly.d.ts +28 -0
- package/dist/types/tiny/text.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/review.d.ts +6 -4
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +6 -0
- package/dist/types/tools/yield.d.ts +8 -3
- package/dist/types/utils/thinking-display.d.ts +4 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +242 -10
- package/src/advisor/__tests__/config.test.ts +173 -0
- package/src/advisor/advise-tool.ts +11 -6
- package/src/advisor/config.ts +256 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +12 -2
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/autolearn/controller.ts +13 -22
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/cli/update-cli.ts +254 -0
- package/src/collab/guest.ts +43 -7
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/model-registry.ts +80 -18
- package/src/config/provider-globals.ts +25 -0
- package/src/config/settings-schema.ts +77 -0
- package/src/debug/index.ts +32 -7
- package/src/debug/log-viewer.ts +111 -53
- package/src/debug/raw-sse.ts +68 -48
- package/src/discovery/codex.ts +13 -5
- package/src/edit/hashline/diff.ts +57 -4
- package/src/eval/__tests__/julia-prelude.test.ts +2 -2
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- package/src/extensibility/extensions/loader.ts +5 -3
- package/src/extensibility/extensions/wrapper.ts +9 -3
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/tool-wrapper.ts +13 -4
- package/src/extensibility/plugins/manager.ts +2 -1
- package/src/extensibility/tool-event-input.ts +23 -0
- package/src/extensibility/utils.ts +74 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/client.ts +3 -1
- package/src/mcp/manager.ts +12 -5
- package/src/mcp/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +130 -15
- package/src/memory-backend/local-backend.ts +5 -3
- package/src/modes/components/advisor-config.ts +555 -0
- package/src/modes/components/advisor-message.ts +9 -2
- package/src/modes/components/agent-hub.ts +9 -4
- package/src/modes/components/index.ts +2 -0
- package/src/modes/components/model-selector.ts +79 -48
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/component.ts +150 -5
- package/src/modes/components/status-line/segments.ts +46 -21
- package/src/modes/components/status-line/types.ts +13 -1
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/controllers/command-controller.ts +27 -36
- package/src/modes/controllers/event-controller.ts +113 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +61 -61
- package/src/modes/controllers/selector-controller.ts +100 -9
- package/src/modes/interactive-mode.ts +74 -25
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +9 -3
- package/src/modes/utils/ui-helpers.ts +41 -23
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/goals/goal-todo-context.md +12 -0
- package/src/prompts/review-custom-request.md +1 -2
- package/src/prompts/review-request.md +1 -2
- package/src/prompts/system/interrupted-thinking.md +7 -0
- package/src/prompts/system/recap-user.md +9 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -5
- package/src/prompts/system/subagent-yield-reminder.md +6 -5
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/irc.md +2 -2
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +28 -24
- package/src/session/agent-session.ts +899 -429
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +172 -9
- package/src/session/redis-session-storage.ts +49 -2
- package/src/session/session-entries.ts +39 -2
- package/src/session/session-history-format.ts +29 -2
- package/src/session/session-listing.ts +54 -24
- package/src/session/session-loader.ts +66 -3
- package/src/session/session-manager.ts +115 -22
- package/src/session/session-persistence.ts +95 -1
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/slash-commands/builtin-registry.ts +23 -24
- package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
- package/src/ssh/connection-manager.ts +139 -12
- package/src/ssh/file-transfer.ts +23 -18
- package/src/ssh/ssh-executor.ts +2 -13
- package/src/ssh/utils.ts +19 -0
- package/src/task/executor.ts +21 -23
- package/src/task/render.ts +162 -20
- package/src/task/renderer.ts +14 -0
- package/src/task/types.ts +17 -0
- package/src/task/yield-assembly.ts +207 -0
- package/src/tiny/text.ts +23 -0
- package/src/tools/ask.ts +55 -4
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/renderers.ts +8 -2
- package/src/tools/review.ts +17 -7
- package/src/tools/ssh.ts +8 -4
- package/src/tools/todo.ts +17 -1
- package/src/tools/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- package/src/prompts/system/autolearn-nudge.md +0 -5
package/src/discovery/codex.ts
CHANGED
|
@@ -342,12 +342,20 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
|
|
|
342
342
|
const codexDir = getProjectCodexDir(ctx);
|
|
343
343
|
const projectHooksDir = path.join(codexDir, "hooks");
|
|
344
344
|
|
|
345
|
+
// OMP hooks must be named `pre-<tool>.<ts|js>` or `post-<tool>.<ts|js>`.
|
|
346
|
+
// Files without that prefix are not OMP hooks (e.g. the standalone Codex
|
|
347
|
+
// hook scripts users keep alongside) — silently dropping the prefix and
|
|
348
|
+
// defaulting to `pre:<basename>` caused those scripts to be imported as
|
|
349
|
+
// extension factories and any top-level `process.exit()` killed startup
|
|
350
|
+
// (#3680).
|
|
345
351
|
const transformHook =
|
|
346
|
-
(level: "user" | "project") =>
|
|
352
|
+
(level: "user" | "project") =>
|
|
353
|
+
(name: string, _content: string, path: string, source: SourceMeta): Hook | null => {
|
|
347
354
|
const baseName = name.replace(/\.(ts|js)$/, "");
|
|
348
355
|
const match = baseName.match(/^(pre|post)-(.+)$/);
|
|
349
|
-
|
|
350
|
-
const
|
|
356
|
+
if (!match) return null;
|
|
357
|
+
const hookType = match[1] as "pre" | "post";
|
|
358
|
+
const toolName = match[2];
|
|
351
359
|
return {
|
|
352
360
|
name,
|
|
353
361
|
path,
|
|
@@ -359,11 +367,11 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
|
|
|
359
367
|
};
|
|
360
368
|
|
|
361
369
|
const results = await Promise.all([
|
|
362
|
-
loadFilesFromDir(ctx, userHooksDir, PROVIDER_ID, "user", {
|
|
370
|
+
loadFilesFromDir<Hook>(ctx, userHooksDir, PROVIDER_ID, "user", {
|
|
363
371
|
extensions: ["ts", "js"],
|
|
364
372
|
transform: transformHook("user"),
|
|
365
373
|
}),
|
|
366
|
-
loadFilesFromDir(ctx, projectHooksDir, PROVIDER_ID, "project", {
|
|
374
|
+
loadFilesFromDir<Hook>(ctx, projectHooksDir, PROVIDER_ID, "project", {
|
|
367
375
|
extensions: ["ts", "js"],
|
|
368
376
|
transform: transformHook("project"),
|
|
369
377
|
}),
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* match is accepted even when the tag was minted by a source that did not keep
|
|
10
10
|
* history, and stale tags recover through the session snapshot store when possible.
|
|
11
11
|
*/
|
|
12
|
+
import * as path from "node:path";
|
|
12
13
|
import {
|
|
13
14
|
type ApplyResult,
|
|
14
15
|
applyEdits,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
} from "@oh-my-pi/hashline";
|
|
31
32
|
import { resolveToCwd } from "../../tools/path-utils";
|
|
32
33
|
import { generateDiffString } from "../diff";
|
|
34
|
+
import { canonicalSnapshotKey } from "../file-snapshot-store";
|
|
33
35
|
import { readEditFileText } from "../read-file";
|
|
34
36
|
import { nativeBlockResolver } from "./block-resolver";
|
|
35
37
|
|
|
@@ -90,6 +92,54 @@ async function readSectionTextCached(absolutePath: string, sectionPath: string):
|
|
|
90
92
|
return rawContent;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a missing authored path to a file read this session by matching its
|
|
97
|
+
* basename and snapshot tag, mirroring {@link Patcher}'s apply-time recovery so
|
|
98
|
+
* a bare/wrong-directory `[basename#tag]` header previews against the same file
|
|
99
|
+
* the edit will land on. Returns `undefined` when no unique basename+tag match
|
|
100
|
+
* exists, leaving the caller to surface the original read error.
|
|
101
|
+
*/
|
|
102
|
+
function recoverSectionPathFromTag(
|
|
103
|
+
section: PatchSection,
|
|
104
|
+
authoredAbsolutePath: string,
|
|
105
|
+
snapshots: SnapshotStore,
|
|
106
|
+
): string | undefined {
|
|
107
|
+
if (section.fileHash === undefined) return undefined;
|
|
108
|
+
const authoredName = path.basename(section.path);
|
|
109
|
+
const authoredKey = canonicalSnapshotKey(authoredAbsolutePath);
|
|
110
|
+
const candidates = [
|
|
111
|
+
...new Set(
|
|
112
|
+
snapshots
|
|
113
|
+
.findByHash(section.fileHash)
|
|
114
|
+
.filter(snapshot => path.basename(snapshot.path) === authoredName)
|
|
115
|
+
.map(snapshot => snapshot.path),
|
|
116
|
+
),
|
|
117
|
+
].filter(candidate => candidate !== authoredKey);
|
|
118
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read the section's target file for a preview, recovering a bare/mis-typed
|
|
123
|
+
* `[basename#tag]` path onto the file its tag uniquely names. Recovery fires
|
|
124
|
+
* only when the authored path is absent — matching {@link Patcher}'s apply-time
|
|
125
|
+
* order — so a permission/parse error on an existing file surfaces against the
|
|
126
|
+
* authored path instead of silently previewing a different tagged file. Returns
|
|
127
|
+
* the path actually read so callers key snapshot lookups off the same file.
|
|
128
|
+
*/
|
|
129
|
+
async function readSectionForPreview(
|
|
130
|
+
section: PatchSection,
|
|
131
|
+
authoredAbsolutePath: string,
|
|
132
|
+
snapshots: SnapshotStore,
|
|
133
|
+
streaming: boolean | undefined,
|
|
134
|
+
): Promise<{ absolutePath: string; rawContent: string }> {
|
|
135
|
+
const read = streaming ? readSectionTextCached : readSectionText;
|
|
136
|
+
const recovered = (await Bun.file(authoredAbsolutePath).exists())
|
|
137
|
+
? undefined
|
|
138
|
+
: recoverSectionPathFromTag(section, authoredAbsolutePath, snapshots);
|
|
139
|
+
const target = recovered ?? authoredAbsolutePath;
|
|
140
|
+
return { absolutePath: target, rawContent: await read(target, section.path) };
|
|
141
|
+
}
|
|
142
|
+
|
|
93
143
|
function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
|
94
144
|
return edits.some(edit => {
|
|
95
145
|
if (edit.kind === "delete") return true;
|
|
@@ -258,10 +308,13 @@ export async function computeHashlineSectionDiff(
|
|
|
258
308
|
options: HashlineDiffOptions = {},
|
|
259
309
|
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
260
310
|
try {
|
|
261
|
-
const
|
|
262
|
-
const rawContent =
|
|
263
|
-
|
|
264
|
-
|
|
311
|
+
const authoredPath = resolveToCwd(section.path, cwd);
|
|
312
|
+
const { absolutePath, rawContent } = await readSectionForPreview(
|
|
313
|
+
section,
|
|
314
|
+
authoredPath,
|
|
315
|
+
snapshots,
|
|
316
|
+
options.streaming,
|
|
317
|
+
);
|
|
265
318
|
const { text: content } = stripBom(rawContent);
|
|
266
319
|
const normalized = normalizeToLF(content);
|
|
267
320
|
// Streaming favors a stable, monotonic preview over an exact unified
|
|
@@ -9,7 +9,7 @@ const OWNER_ID = "julia-prelude-tests";
|
|
|
9
9
|
describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => {
|
|
10
10
|
afterEach(async () => {
|
|
11
11
|
await disposeJuliaKernelSessionsByOwner(OWNER_ID);
|
|
12
|
-
});
|
|
12
|
+
}, 30_000);
|
|
13
13
|
|
|
14
14
|
it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => {
|
|
15
15
|
using tempDir = TempDir.createSync("@omp-eval-julia-output-");
|
|
@@ -44,7 +44,7 @@ nothing
|
|
|
44
44
|
expect(result.output).toContain("STRIPPED=red");
|
|
45
45
|
expect(result.output).toContain("META=alpha:true");
|
|
46
46
|
expect(result.output).toContain("MULTI=2:alpha:json");
|
|
47
|
-
},
|
|
47
|
+
}, 60_000);
|
|
48
48
|
|
|
49
49
|
it("surfaces the exception type and message in the error output, not just stack frames", async () => {
|
|
50
50
|
using tempDir = TempDir.createSync("@omp-eval-julia-error-");
|
|
@@ -29,6 +29,7 @@ export class LocalModuleLoader {
|
|
|
29
29
|
#externalModules = new Map<string, Promise<vm.Module>>();
|
|
30
30
|
#requireCache = new Map<string, NodeJS.Require>();
|
|
31
31
|
#modulePaths = new WeakMap<vm.Module, string>();
|
|
32
|
+
#linkChain: Promise<void> = Promise.resolve();
|
|
32
33
|
|
|
33
34
|
constructor(sessionId: string) {
|
|
34
35
|
this.#context = vm.createContext(globalThis);
|
|
@@ -147,7 +148,17 @@ export class LocalModuleLoader {
|
|
|
147
148
|
async #linkAndEvaluate(entry: LocalModuleEntry, modulePath: string): Promise<void> {
|
|
148
149
|
const { module } = entry;
|
|
149
150
|
try {
|
|
150
|
-
|
|
151
|
+
// Serialize the link phase across every graph root. Bun's node:vm linker
|
|
152
|
+
// segfaults (getImportedModule on a null record) when two link passes
|
|
153
|
+
// instantiate overlapping module instances concurrently — e.g.
|
|
154
|
+
// Promise.all([import("./a"), import("./b")]) over a graph that shares
|
|
155
|
+
// dependencies. Holding the lock for the whole module.link() (including its
|
|
156
|
+
// async resolver callbacks) guarantees the linker is never re-entered
|
|
157
|
+
// mid-instantiation. The lock is released before evaluate(), so a dynamic
|
|
158
|
+
// import during evaluation can re-acquire it without deadlock.
|
|
159
|
+
await this.#serializeLink(async () => {
|
|
160
|
+
if (module.status === "unlinked") await module.link(this.#linkResolve);
|
|
161
|
+
});
|
|
151
162
|
if (module.status === "linked") await module.evaluate();
|
|
152
163
|
} catch (error) {
|
|
153
164
|
this.#invalidateFailedLoad(modulePath);
|
|
@@ -159,6 +170,17 @@ export class LocalModuleLoader {
|
|
|
159
170
|
}
|
|
160
171
|
}
|
|
161
172
|
|
|
173
|
+
// Promise-chain mutex serializing node:vm link passes (see #linkAndEvaluate).
|
|
174
|
+
// #linkChain is kept non-rejecting so a failed link never wedges the queue.
|
|
175
|
+
#serializeLink<T>(run: () => Promise<T>): Promise<T> {
|
|
176
|
+
const result = this.#linkChain.then(run);
|
|
177
|
+
this.#linkChain = result.then(
|
|
178
|
+
() => undefined,
|
|
179
|
+
() => undefined,
|
|
180
|
+
);
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
162
184
|
// Shared static-link resolver for `module.link()`. node:vm passes the referencing
|
|
163
185
|
// module and reuses this one resolver for the entire graph, so the referrer path is
|
|
164
186
|
// recovered from `#modulePaths`. Local dependencies are constructed but NOT linked or
|
|
@@ -1236,7 +1236,7 @@
|
|
|
1236
1236
|
let html = `
|
|
1237
1237
|
<div class="header">
|
|
1238
1238
|
<h1>Session: ${escapeHtml(header?.id || 'unknown')}</h1>
|
|
1239
|
-
<div class="help-bar">
|
|
1239
|
+
<div class="help-bar">T toggle thinking · O toggle tools</div>
|
|
1240
1240
|
<div class="header-info">
|
|
1241
1241
|
<div class="info-item"><span class="info-label">Date:</span><span class="info-value">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}</span></div>
|
|
1242
1242
|
<div class="info-item"><span class="info-label">Models:</span><span class="info-value">${globalStats.models.join(', ') || 'unknown'}</span></div>
|
|
@@ -1580,13 +1580,19 @@
|
|
|
1580
1580
|
searchQuery = '';
|
|
1581
1581
|
navigateTo(leafId, 'bottom');
|
|
1582
1582
|
}
|
|
1583
|
-
if (e.
|
|
1583
|
+
if (e.key === 't' || e.key === 'T' || e.key === 'o' || e.key === 'O') {
|
|
1584
|
+
// Skip when typing in the sidebar search (or any other editable target)
|
|
1585
|
+
// so the chord can't fire on a user's letter input. Avoid Ctrl/Cmd-based
|
|
1586
|
+
// chords entirely — every major browser reserves Ctrl+T (new tab) and
|
|
1587
|
+
// Ctrl+O (open file), so the shortcut would never reach the page.
|
|
1588
|
+
const t = e.target;
|
|
1589
|
+
const editable =
|
|
1590
|
+
t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
|
|
1591
|
+
if (editable) return;
|
|
1592
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
1584
1593
|
e.preventDefault();
|
|
1585
|
-
toggleThinking();
|
|
1586
|
-
|
|
1587
|
-
if (e.ctrlKey && e.key === 'o') {
|
|
1588
|
-
e.preventDefault();
|
|
1589
|
-
toggleToolOutputs();
|
|
1594
|
+
if (e.key === 't' || e.key === 'T') toggleThinking();
|
|
1595
|
+
else toggleToolOutputs();
|
|
1590
1596
|
}
|
|
1591
1597
|
});
|
|
1592
1598
|
|
|
@@ -24,7 +24,7 @@ import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/leg
|
|
|
24
24
|
import { getAllPluginExtensionPaths } from "../plugins/loader";
|
|
25
25
|
import * as TypeBox from "../typebox";
|
|
26
26
|
|
|
27
|
-
import { resolvePath } from "../utils";
|
|
27
|
+
import { resolvePath, withExitGuard } from "../utils";
|
|
28
28
|
import type {
|
|
29
29
|
AssistantThinkingRenderer,
|
|
30
30
|
Extension,
|
|
@@ -290,7 +290,7 @@ async function loadExtension(
|
|
|
290
290
|
): Promise<{ extension: Extension | null; error: string | null }> {
|
|
291
291
|
const resolvedPath = resolvePath(extensionPath, cwd);
|
|
292
292
|
try {
|
|
293
|
-
const module = (await loadLegacyPiModule(resolvedPath)) as LoadedExtensionModule;
|
|
293
|
+
const module = (await withExitGuard(() => loadLegacyPiModule(resolvedPath))) as LoadedExtensionModule;
|
|
294
294
|
const factory = getExtensionFactory(module);
|
|
295
295
|
|
|
296
296
|
if (typeof factory !== "function") {
|
|
@@ -302,7 +302,9 @@ async function loadExtension(
|
|
|
302
302
|
|
|
303
303
|
const extension = createExtension(extensionPath, resolvedPath);
|
|
304
304
|
const api = new ConcreteExtensionAPI(PiCodingAgent, extension, runtime, cwd, eventBus);
|
|
305
|
-
await
|
|
305
|
+
await withExitGuard(async () => {
|
|
306
|
+
await factory(api);
|
|
307
|
+
});
|
|
306
308
|
|
|
307
309
|
return { extension, error: null };
|
|
308
310
|
} catch (err) {
|
|
@@ -6,7 +6,7 @@ import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai
|
|
|
6
6
|
import type { Settings } from "../../config/settings";
|
|
7
7
|
import type { Theme } from "../../modes/theme/theme";
|
|
8
8
|
import { type ApprovalMode, formatApprovalPrompt, requiresApproval } from "../../tools/approval";
|
|
9
|
-
import { normalizeToolEventInput } from "../tool-event-input";
|
|
9
|
+
import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
|
|
10
10
|
import { applyToolProxy } from "../tool-proxy";
|
|
11
11
|
import type { ExtensionRunner } from "./runner";
|
|
12
12
|
import type { RegisteredTool, ToolCallEventResult } from "./types";
|
|
@@ -186,7 +186,10 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
186
186
|
type: "tool_call",
|
|
187
187
|
toolName: this.tool.name,
|
|
188
188
|
toolCallId,
|
|
189
|
-
input: normalizeToolEventInput(
|
|
189
|
+
input: normalizeToolEventInput(
|
|
190
|
+
this.tool.name,
|
|
191
|
+
resolveToolEventInput(this.tool, params as Record<string, unknown>),
|
|
192
|
+
),
|
|
190
193
|
})) as ToolCallEventResult | undefined;
|
|
191
194
|
|
|
192
195
|
if (callResult?.block) {
|
|
@@ -221,7 +224,10 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
221
224
|
type: "tool_result",
|
|
222
225
|
toolName: this.tool.name,
|
|
223
226
|
toolCallId,
|
|
224
|
-
input: normalizeToolEventInput(
|
|
227
|
+
input: normalizeToolEventInput(
|
|
228
|
+
this.tool.name,
|
|
229
|
+
resolveToolEventInput(this.tool, params as Record<string, unknown>),
|
|
230
|
+
),
|
|
225
231
|
content: result.content,
|
|
226
232
|
details: result.details,
|
|
227
233
|
isError: !!executionError,
|
|
@@ -12,7 +12,7 @@ import { loadCapability } from "../../discovery";
|
|
|
12
12
|
import * as PiCodingAgent from "../../index";
|
|
13
13
|
import type { HookMessage } from "../../session/messages";
|
|
14
14
|
import * as typebox from "../typebox";
|
|
15
|
-
import { resolvePath } from "../utils";
|
|
15
|
+
import { resolvePath, withExitGuard } from "../utils";
|
|
16
16
|
import { execCommand } from "./runner";
|
|
17
17
|
import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types";
|
|
18
18
|
|
|
@@ -149,7 +149,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
|
|
|
149
149
|
|
|
150
150
|
try {
|
|
151
151
|
// Import the module using native Bun import
|
|
152
|
-
const module = await import(resolvedPath);
|
|
152
|
+
const module = await withExitGuard(() => import(resolvedPath));
|
|
153
153
|
const factory = module.default as HookFactory;
|
|
154
154
|
|
|
155
155
|
if (typeof factory !== "function") {
|
|
@@ -164,7 +164,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
|
|
|
164
164
|
);
|
|
165
165
|
|
|
166
166
|
// Call factory to register handlers
|
|
167
|
-
factory(api);
|
|
167
|
+
await withExitGuard(async () => factory(api));
|
|
168
168
|
|
|
169
169
|
return {
|
|
170
170
|
hook: {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
5
5
|
import type { Static, TSchema } from "@oh-my-pi/pi-ai";
|
|
6
|
-
import { normalizeToolEventInput } from "../tool-event-input";
|
|
6
|
+
import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
|
|
7
7
|
import { applyToolProxy } from "../tool-proxy";
|
|
8
8
|
import type { HookRunner } from "./runner";
|
|
9
9
|
import type { ToolCallEventResult, ToolResultEventResult } from "./types";
|
|
@@ -47,7 +47,10 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
|
|
|
47
47
|
type: "tool_call",
|
|
48
48
|
toolName: this.tool.name,
|
|
49
49
|
toolCallId,
|
|
50
|
-
input: normalizeToolEventInput(
|
|
50
|
+
input: normalizeToolEventInput(
|
|
51
|
+
this.tool.name,
|
|
52
|
+
resolveToolEventInput(this.tool, params as Record<string, unknown>),
|
|
53
|
+
),
|
|
51
54
|
})) as ToolCallEventResult | undefined;
|
|
52
55
|
|
|
53
56
|
if (callResult?.block) {
|
|
@@ -73,7 +76,10 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
|
|
|
73
76
|
type: "tool_result",
|
|
74
77
|
toolName: this.tool.name,
|
|
75
78
|
toolCallId,
|
|
76
|
-
input: normalizeToolEventInput(
|
|
79
|
+
input: normalizeToolEventInput(
|
|
80
|
+
this.tool.name,
|
|
81
|
+
resolveToolEventInput(this.tool, params as Record<string, unknown>),
|
|
82
|
+
),
|
|
77
83
|
content: result.content,
|
|
78
84
|
details: result.details,
|
|
79
85
|
isError: false,
|
|
@@ -96,7 +102,10 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
|
|
|
96
102
|
type: "tool_result",
|
|
97
103
|
toolName: this.tool.name,
|
|
98
104
|
toolCallId,
|
|
99
|
-
input: normalizeToolEventInput(
|
|
105
|
+
input: normalizeToolEventInput(
|
|
106
|
+
this.tool.name,
|
|
107
|
+
resolveToolEventInput(this.tool, params as Record<string, unknown>),
|
|
108
|
+
),
|
|
100
109
|
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
|
101
110
|
details: undefined,
|
|
102
111
|
isError: true,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isEnoent,
|
|
12
12
|
logger,
|
|
13
13
|
} from "@oh-my-pi/pi-utils";
|
|
14
|
+
import { withExitGuard } from "../utils";
|
|
14
15
|
import { type GitSource, parseGitUrl } from "./git-url";
|
|
15
16
|
import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "./legacy-pi-compat";
|
|
16
17
|
import { resolvePluginManifestEntries } from "./loader";
|
|
@@ -363,7 +364,7 @@ export class PluginManager {
|
|
|
363
364
|
installLegacyPiSpecifierShim();
|
|
364
365
|
for (const extensionPath of loadable) {
|
|
365
366
|
try {
|
|
366
|
-
const module = await loadLegacyPiModule(extensionPath);
|
|
367
|
+
const module = await withExitGuard(() => loadLegacyPiModule(extensionPath));
|
|
367
368
|
if (!hasExtensionFactoryExport(module)) {
|
|
368
369
|
errors.push(`${extensionPath}: extension does not export a valid factory function`);
|
|
369
370
|
}
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
const HASHLINE_FILE_PREFIX = "¶";
|
|
2
2
|
const HASHLINE_FILE_TAG_RE = /#[0-9a-fA-F]{4}$/u;
|
|
3
3
|
|
|
4
|
+
interface ToolEventInputResolver {
|
|
5
|
+
name: string;
|
|
6
|
+
resolveEventInput?: (input: string) => string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Resolves mode-specific textual tool input before extension/hook event normalization. */
|
|
10
|
+
export function resolveToolEventInput(
|
|
11
|
+
tool: ToolEventInputResolver,
|
|
12
|
+
input: Record<string, unknown>,
|
|
13
|
+
): Record<string, unknown> {
|
|
14
|
+
if (tool.name !== "edit" || typeof tool.resolveEventInput !== "function") return input;
|
|
15
|
+
let resolved = input;
|
|
16
|
+
for (const key of ["input", "_input"] as const) {
|
|
17
|
+
const value = stringField(resolved, key);
|
|
18
|
+
if (value === undefined) continue;
|
|
19
|
+
const nextValue = tool.resolveEventInput(value);
|
|
20
|
+
if (nextValue === value) continue;
|
|
21
|
+
if (resolved === input) resolved = { ...input };
|
|
22
|
+
resolved[key] = nextValue;
|
|
23
|
+
}
|
|
24
|
+
return resolved;
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
function stringField(input: Record<string, unknown>, key: string): string | undefined {
|
|
5
28
|
const value = input[key];
|
|
6
29
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
@@ -42,3 +42,77 @@ export function createNoOpUIContext(): HookUIContext {
|
|
|
42
42
|
},
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Raised by {@link withExitGuard} when a guarded callback synchronously
|
|
48
|
+
* attempts to terminate the host process. Callers catch this like any other
|
|
49
|
+
* load-time failure so the extension/hook is skipped with a logged error
|
|
50
|
+
* instead of taking the CLI down with it.
|
|
51
|
+
*/
|
|
52
|
+
export class ExtensionExitError extends Error {
|
|
53
|
+
readonly code: number | string | undefined;
|
|
54
|
+
constructor(
|
|
55
|
+
code: number | string | undefined,
|
|
56
|
+
readonly alias = "process.exit",
|
|
57
|
+
) {
|
|
58
|
+
super(
|
|
59
|
+
`Module called ${alias}(${code === undefined ? "" : String(code)}) during guarded extension/hook loading; ` +
|
|
60
|
+
`OMP extension/hook modules must not terminate the host process.`,
|
|
61
|
+
);
|
|
62
|
+
this.name = "ExtensionExitError";
|
|
63
|
+
this.code = code;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type ExitAliasName = "process.exit" | "process.reallyExit";
|
|
68
|
+
|
|
69
|
+
let exitGuardDepth = 0;
|
|
70
|
+
let exitGuardOriginalProcessExit: typeof process.exit | null = null;
|
|
71
|
+
let exitGuardOriginalReallyExit: typeof process.reallyExit | null = null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run `fn` with hard-exit APIs patched so any synchronous attempt to terminate
|
|
75
|
+
* the host raises {@link ExtensionExitError} instead. Restored in `finally`.
|
|
76
|
+
*
|
|
77
|
+
* Guards the dynamic-import and factory-invocation sites that load third-party
|
|
78
|
+
* extension / hook modules — a `process.exit(0)` or `process.reallyExit(0)` in
|
|
79
|
+
* a stranger's script (e.g. a Codex hook script that happens to live next to
|
|
80
|
+
* OMP-shaped modules) would otherwise kill OMP during startup with no error
|
|
81
|
+
* surface, since `try/catch` cannot intercept a synchronous exit.
|
|
82
|
+
*
|
|
83
|
+
* Nested and concurrent guard windows are safe: only the outermost guard
|
|
84
|
+
* restores the real hard-exit APIs.
|
|
85
|
+
*/
|
|
86
|
+
function guardedExit(alias: ExitAliasName): (code?: number | string) => never {
|
|
87
|
+
return (code?: number | string): never => {
|
|
88
|
+
throw new ExtensionExitError(code, alias);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function withExitGuard<T>(fn: () => Promise<T>): Promise<T> {
|
|
93
|
+
if (exitGuardDepth === 0) {
|
|
94
|
+
exitGuardOriginalProcessExit = process.exit;
|
|
95
|
+
process.exit = guardedExit("process.exit") as typeof process.exit;
|
|
96
|
+
|
|
97
|
+
if (typeof process.reallyExit === "function") {
|
|
98
|
+
exitGuardOriginalReallyExit = process.reallyExit;
|
|
99
|
+
process.reallyExit = guardedExit("process.reallyExit") as typeof process.reallyExit;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exitGuardDepth++;
|
|
103
|
+
try {
|
|
104
|
+
return await fn();
|
|
105
|
+
} finally {
|
|
106
|
+
exitGuardDepth--;
|
|
107
|
+
if (exitGuardDepth === 0) {
|
|
108
|
+
if (exitGuardOriginalProcessExit) {
|
|
109
|
+
process.exit = exitGuardOriginalProcessExit;
|
|
110
|
+
exitGuardOriginalProcessExit = null;
|
|
111
|
+
}
|
|
112
|
+
if (exitGuardOriginalReallyExit) {
|
|
113
|
+
process.reallyExit = exitGuardOriginalReallyExit;
|
|
114
|
+
exitGuardOriginalReallyExit = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|