@oh-my-pi/pi-coding-agent 16.2.13 → 16.3.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.
- package/CHANGELOG.md +108 -7
- package/dist/cli.js +6033 -5982
- package/dist/types/advisor/config.d.ts +4 -2
- package/dist/types/collab/host.d.ts +16 -0
- package/dist/types/collab/protocol.d.ts +9 -3
- package/dist/types/commit/model-selection.d.ts +5 -0
- package/dist/types/config/model-resolver.d.ts +12 -10
- package/dist/types/config/settings-schema.d.ts +28 -5
- package/dist/types/edit/modes/patch.d.ts +11 -0
- package/dist/types/eval/agent-bridge.d.ts +5 -1
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
- package/dist/types/extensibility/skills.d.ts +2 -1
- package/dist/types/mnemopi/state.d.ts +12 -0
- package/dist/types/modes/components/agent-hub.d.ts +9 -5
- package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
- package/dist/types/modes/components/usage-row.d.ts +1 -1
- package/dist/types/modes/controllers/command-controller.d.ts +0 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
- package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
- package/dist/types/modes/interactive-mode.d.ts +12 -7
- package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
- package/dist/types/modes/session-teardown.d.ts +63 -0
- package/dist/types/modes/session-teardown.test.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +9 -4
- package/dist/types/modes/types.d.ts +2 -3
- package/dist/types/modes/utils/copy-targets.d.ts +2 -0
- package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
- package/dist/types/sdk.d.ts +9 -0
- package/dist/types/session/agent-session.d.ts +46 -13
- package/dist/types/session/exit-diagnostics.d.ts +48 -0
- package/dist/types/session/session-loader.d.ts +5 -0
- package/dist/types/task/executor.d.ts +12 -5
- package/dist/types/task/parallel.d.ts +1 -0
- package/dist/types/task/render.test.d.ts +1 -0
- package/dist/types/thinking.d.ts +2 -0
- package/dist/types/tools/checkpoint.d.ts +8 -0
- package/dist/types/tools/eval-render.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +11 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/tools/irc.d.ts +1 -0
- package/dist/types/tools/output-meta.d.ts +7 -0
- package/dist/types/tools/output-schema-validator.d.ts +7 -4
- package/dist/types/tools/path-utils.d.ts +16 -0
- package/dist/types/tools/render-utils.d.ts +4 -1
- package/dist/types/utils/git.d.ts +47 -2
- package/dist/types/web/search/provider.d.ts +7 -0
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +12 -12
- package/src/advisor/config.ts +4 -2
- package/src/cli/bench-cli.ts +7 -2
- package/src/collab/guest.ts +94 -5
- package/src/collab/host.ts +80 -3
- package/src/collab/protocol.ts +9 -2
- package/src/commit/model-selection.ts +8 -2
- package/src/config/keybindings.ts +3 -1
- package/src/config/model-discovery.ts +66 -2
- package/src/config/model-registry.ts +7 -3
- package/src/config/model-resolver.ts +51 -23
- package/src/config/settings-schema.ts +44 -14
- package/src/edit/hashline/diff.ts +13 -2
- package/src/edit/index.ts +58 -8
- package/src/edit/modes/patch.ts +53 -18
- package/src/edit/streaming.ts +7 -6
- package/src/eval/__tests__/agent-bridge.test.ts +57 -1
- package/src/eval/agent-bridge.ts +15 -5
- package/src/exec/bash-executor.ts +7 -12
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
- package/src/extensibility/skills.ts +29 -6
- package/src/goals/guided-setup.ts +4 -3
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/lsp/client.ts +11 -14
- package/src/main.ts +38 -10
- package/src/mnemopi/state.ts +43 -7
- package/src/modes/acp/acp-agent.ts +1 -1
- package/src/modes/components/agent-hub.ts +10 -2
- package/src/modes/components/agent-transcript-viewer.ts +39 -7
- package/src/modes/components/assistant-message.ts +16 -10
- package/src/modes/components/chat-transcript-builder.ts +11 -1
- package/src/modes/components/custom-editor.test.ts +20 -0
- package/src/modes/components/hook-selector.ts +44 -25
- package/src/modes/components/model-selector.ts +1 -1
- package/src/modes/components/status-line/component.test.ts +44 -0
- package/src/modes/components/status-line/component.ts +9 -1
- package/src/modes/components/status-line/segments.ts +3 -3
- package/src/modes/components/usage-row.ts +16 -2
- package/src/modes/controllers/command-controller.ts +6 -21
- package/src/modes/controllers/event-controller.ts +11 -9
- package/src/modes/controllers/extension-ui-controller.ts +84 -3
- package/src/modes/controllers/input-controller.ts +16 -13
- package/src/modes/controllers/selector-controller.ts +3 -8
- package/src/modes/controllers/streaming-reveal.ts +75 -53
- package/src/modes/controllers/tool-args-reveal.ts +340 -10
- package/src/modes/interactive-mode.ts +122 -79
- package/src/modes/rpc/rpc-client.ts +21 -0
- package/src/modes/rpc/rpc-mode.ts +197 -46
- package/src/modes/session-teardown.test.ts +219 -0
- package/src/modes/session-teardown.ts +82 -0
- package/src/modes/setup-wizard/scenes/theme.ts +2 -2
- package/src/modes/skill-command.ts +7 -20
- package/src/modes/theme/theme.ts +29 -15
- package/src/modes/types.ts +2 -3
- package/src/modes/utils/copy-targets.ts +12 -0
- package/src/modes/utils/ui-helpers.ts +19 -2
- package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
- package/src/prompts/advisor/system.md +1 -1
- package/src/prompts/agents/tester.md +6 -2
- package/src/prompts/skills/autoload.md +8 -0
- package/src/prompts/skills/user-invocation.md +11 -0
- package/src/prompts/steering/parent-irc.md +5 -0
- package/src/prompts/system/irc-incoming.md +2 -0
- package/src/prompts/system/mid-run-todo-nudge.md +3 -0
- package/src/prompts/system/plan-mode-approved.md +7 -10
- package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
- package/src/prompts/system/plan-mode-reference.md +3 -4
- package/src/prompts/system/rewind-report.md +6 -0
- package/src/prompts/system/subagent-system-prompt.md +3 -0
- package/src/prompts/tools/irc.md +2 -1
- package/src/prompts/tools/job.md +2 -2
- package/src/prompts/tools/rewind.md +3 -2
- package/src/prompts/tools/task.md +4 -0
- package/src/sdk.ts +18 -4
- package/src/session/agent-session.ts +660 -114
- package/src/session/exit-diagnostics.ts +202 -0
- package/src/session/session-context.ts +25 -13
- package/src/session/session-loader.ts +58 -25
- package/src/session/session-manager.ts +0 -1
- package/src/session/session-persistence.ts +30 -4
- package/src/session/settings-stream-fn.ts +14 -0
- package/src/slash-commands/builtin-registry.ts +35 -3
- package/src/system-prompt.ts +15 -1
- package/src/task/executor.ts +31 -8
- package/src/task/index.ts +31 -9
- package/src/task/isolation-runner.ts +18 -2
- package/src/task/parallel.ts +7 -2
- package/src/task/render.test.ts +121 -0
- package/src/task/render.ts +48 -2
- package/src/task/worktree.ts +12 -13
- package/src/task/yield-assembly.ts +8 -5
- package/src/thinking.ts +5 -0
- package/src/tools/ask.ts +188 -9
- package/src/tools/ast-edit.ts +7 -0
- package/src/tools/ast-grep.ts +11 -0
- package/src/tools/bash.ts +6 -30
- package/src/tools/checkpoint.ts +15 -1
- package/src/tools/eval-render.ts +15 -0
- package/src/tools/fetch.ts +82 -18
- package/src/tools/gh.ts +1 -1
- package/src/tools/grep.ts +45 -27
- package/src/tools/index.ts +3 -1
- package/src/tools/irc.ts +24 -9
- package/src/tools/output-meta.ts +50 -0
- package/src/tools/output-schema-validator.ts +152 -15
- package/src/tools/path-utils.ts +55 -10
- package/src/tools/read.ts +1 -1
- package/src/tools/render-utils.ts +5 -3
- package/src/tools/yield.ts +41 -1
- package/src/utils/commit-message-generator.ts +2 -2
- package/src/utils/git.ts +271 -29
- package/src/utils/thinking-display.ts +13 -0
- package/src/web/search/index.ts +9 -28
- package/src/web/search/provider.ts +26 -1
- package/src/web/search/providers/duckduckgo.ts +1 -1
- package/src/web/search/types.ts +5 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
14
14
|
import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
15
|
-
import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
|
|
15
|
+
import { $env, isRecord, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
|
|
16
16
|
import { reset as resetCapabilities } from "../../capability";
|
|
17
17
|
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
|
|
18
18
|
import {
|
|
@@ -41,8 +41,11 @@ import type {
|
|
|
41
41
|
RpcHostToolCallRequest,
|
|
42
42
|
RpcHostToolCancelRequest,
|
|
43
43
|
RpcHostToolDefinition,
|
|
44
|
+
RpcHostToolResult,
|
|
45
|
+
RpcHostToolUpdate,
|
|
44
46
|
RpcHostUriCancelRequest,
|
|
45
47
|
RpcHostUriRequest,
|
|
48
|
+
RpcHostUriResult,
|
|
46
49
|
RpcResponse,
|
|
47
50
|
RpcSessionState,
|
|
48
51
|
RpcSubagentSubscriptionLevel,
|
|
@@ -91,7 +94,7 @@ export async function tryRunRpcSkillCommand(
|
|
|
91
94
|
if (!parsed) return false;
|
|
92
95
|
const skill = session.skills.find(candidate => candidate.name === parsed.name);
|
|
93
96
|
if (!skill) return false;
|
|
94
|
-
const built = await buildSkillPromptMessage(skill, parsed.args);
|
|
97
|
+
const built = await buildSkillPromptMessage(skill, parsed.args, "user");
|
|
95
98
|
await session.promptCustomMessage({
|
|
96
99
|
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
|
97
100
|
content: built.message,
|
|
@@ -213,6 +216,158 @@ export function watchAndReportLocalOnlyPromptResult(input: {
|
|
|
213
216
|
});
|
|
214
217
|
}
|
|
215
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Dependencies for {@link dispatchRpcInputFrame}. Provided by the RPC mode
|
|
221
|
+
* entrypoint; broken out so tests can drive the input loop with stubs.
|
|
222
|
+
*/
|
|
223
|
+
export interface RpcInputFrameDeps {
|
|
224
|
+
handleCommand: (command: RpcCommand) => Promise<RpcResponse>;
|
|
225
|
+
output: RpcOutput;
|
|
226
|
+
errorResponse: (id: string | undefined, command: string, message: string) => RpcResponse;
|
|
227
|
+
trackBackgroundTask?: (task: Promise<void>) => void;
|
|
228
|
+
pendingExtensionRequests: Map<string, PendingExtensionRequest>;
|
|
229
|
+
onHostToolResult: (frame: RpcHostToolResult) => void;
|
|
230
|
+
onHostToolUpdate: (frame: RpcHostToolUpdate) => void;
|
|
231
|
+
onHostUriResult: (frame: RpcHostUriResult) => void;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Structural guard for a well-formed extension UI response frame. Mirrors the
|
|
236
|
+
* shape declared in {@link RpcExtensionUIResponse} — a truthy record with
|
|
237
|
+
* `type === "extension_ui_response"` and a string `id`. Payload variants (value,
|
|
238
|
+
* confirmed, cancelled) are validated at the read site.
|
|
239
|
+
*/
|
|
240
|
+
function isRpcExtensionUIResponse(value: unknown): value is RpcExtensionUIResponse {
|
|
241
|
+
if (!isRecord(value)) return false;
|
|
242
|
+
return value.type === "extension_ui_response" && typeof value.id === "string";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Dispatch a single parsed frame from the RPC input stream.
|
|
247
|
+
*
|
|
248
|
+
* Bash commands are dispatched in the background so the caller (the stdin loop
|
|
249
|
+
* in {@link runRpcMode}) can keep reading subsequent frames while a shell
|
|
250
|
+
* command is still running. This lets a client send `abort_bash` (or any other
|
|
251
|
+
* command) while a long-running `bash` is in flight. Response correlation is
|
|
252
|
+
* preserved via each command's `id`; ordering across concurrent commands is
|
|
253
|
+
* not guaranteed and clients MUST match on `id`.
|
|
254
|
+
*
|
|
255
|
+
* @returns `undefined` when the frame was routed to a side-channel handler
|
|
256
|
+
* (extension UI response, host tool/URI frames) or dispatched in the
|
|
257
|
+
* background (`bash`). Otherwise a promise that resolves once the response
|
|
258
|
+
* for the command has been emitted via `output`. Errors from `handleCommand`
|
|
259
|
+
* on non-`bash` commands propagate; the caller is expected to wrap them.
|
|
260
|
+
*/
|
|
261
|
+
export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise<void> | undefined {
|
|
262
|
+
// Side-channel: extension UI responses resolve a pending dialog promise.
|
|
263
|
+
if (isRpcExtensionUIResponse(parsed)) {
|
|
264
|
+
const pending = deps.pendingExtensionRequests.get(parsed.id);
|
|
265
|
+
if (pending) pending.resolve(parsed);
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (isRpcHostToolResult(parsed)) {
|
|
270
|
+
deps.onHostToolResult(parsed);
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isRpcHostToolUpdate(parsed)) {
|
|
275
|
+
deps.onHostToolUpdate(parsed);
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isRpcHostUriResult(parsed)) {
|
|
280
|
+
deps.onHostUriResult(parsed);
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Regular RPC command. The transport contract states each remaining frame
|
|
285
|
+
// is an {@link RpcCommand}; `handleCommand`'s `default` arm surfaces
|
|
286
|
+
// unknown discriminants as an error response, so we do not shape-check
|
|
287
|
+
// the union here.
|
|
288
|
+
const command = parsed as RpcCommand;
|
|
289
|
+
|
|
290
|
+
// `bash` can run for a long time. Dispatch it in the background so a
|
|
291
|
+
// subsequent `abort_bash` frame can be read and handled without waiting
|
|
292
|
+
// for the shell command to finish on its own. The response is emitted
|
|
293
|
+
// when `handleCommand` resolves; clients correlate via `command.id`.
|
|
294
|
+
if (command.type === "bash") {
|
|
295
|
+
const task = (async () => {
|
|
296
|
+
try {
|
|
297
|
+
deps.output(await deps.handleCommand(command));
|
|
298
|
+
} catch (err: unknown) {
|
|
299
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
300
|
+
deps.output(deps.errorResponse(command.id, "bash", message));
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
deps.trackBackgroundTask?.(task);
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return (async () => {
|
|
308
|
+
deps.output(await deps.handleCommand(command));
|
|
309
|
+
})();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Coordinates deferred shutdown with in-flight background input tasks.
|
|
314
|
+
*
|
|
315
|
+
* `pi.shutdown()` from an extension only *requests* shutdown; the process must
|
|
316
|
+
* not exit while a background-dispatched command (`bash`, see
|
|
317
|
+
* {@link dispatchRpcInputFrame}) still owes the client a response frame. The
|
|
318
|
+
* coordinator tracks those tasks, re-checks the shutdown request whenever one
|
|
319
|
+
* settles (covering a shutdown requested mid-bash with no follow-up client
|
|
320
|
+
* frame), and drains every tracked task before invoking `performShutdown`.
|
|
321
|
+
* The shutdown sequence is latched so concurrent triggers (input loop and
|
|
322
|
+
* settling tasks) run it exactly once.
|
|
323
|
+
*/
|
|
324
|
+
export class RpcShutdownCoordinator {
|
|
325
|
+
#tasks = new Set<Promise<void>>();
|
|
326
|
+
#shutdown: Promise<void> | undefined;
|
|
327
|
+
readonly #isShutdownRequested: () => boolean;
|
|
328
|
+
readonly #performShutdown: () => Promise<void>;
|
|
329
|
+
|
|
330
|
+
constructor(options: { isShutdownRequested: () => boolean; performShutdown: () => Promise<void> }) {
|
|
331
|
+
this.#isShutdownRequested = options.isShutdownRequested;
|
|
332
|
+
this.#performShutdown = options.performShutdown;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Track a background input task. When it settles it is untracked and the
|
|
337
|
+
* shutdown request is re-checked, so a deferred shutdown fires even when
|
|
338
|
+
* no further client frames arrive.
|
|
339
|
+
*/
|
|
340
|
+
track(task: Promise<void>): void {
|
|
341
|
+
this.#tasks.add(task);
|
|
342
|
+
void task.finally(() => {
|
|
343
|
+
this.#tasks.delete(task);
|
|
344
|
+
// Fire-and-forget: performShutdown ends the process. Rejections are
|
|
345
|
+
// not expected — hook errors are caught inside extensionRunner.emit,
|
|
346
|
+
// and background tasks catch their own dispatch errors.
|
|
347
|
+
void this.checkShutdownRequested();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Await every tracked task, including tasks tracked while draining. */
|
|
352
|
+
async drain(): Promise<void> {
|
|
353
|
+
while (this.#tasks.size > 0) {
|
|
354
|
+
await Promise.allSettled(Array.from(this.#tasks));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* If shutdown was requested, drain background tasks (so every owed
|
|
360
|
+
* response frame is written) before running the shutdown sequence.
|
|
361
|
+
*/
|
|
362
|
+
checkShutdownRequested(): Promise<void> {
|
|
363
|
+
if (!this.#shutdown) {
|
|
364
|
+
if (!this.#isShutdownRequested()) return Promise.resolve();
|
|
365
|
+
this.#shutdown = this.drain().then(() => this.#performShutdown());
|
|
366
|
+
}
|
|
367
|
+
return this.#shutdown;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
216
371
|
export type RpcSubagentResetRegistry = Pick<RpcSubagentRegistry, "clear">;
|
|
217
372
|
|
|
218
373
|
export async function handleRpcSessionChange(
|
|
@@ -1096,61 +1251,57 @@ export async function runRpcMode(
|
|
|
1096
1251
|
}
|
|
1097
1252
|
};
|
|
1098
1253
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1254
|
+
// Deferred shutdown (pi.shutdown() from an extension) must not kill the
|
|
1255
|
+
// process while a background-dispatched bash still owes the client its
|
|
1256
|
+
// response frame. The coordinator drains tracked tasks before exiting and
|
|
1257
|
+
// re-checks the request as each task settles.
|
|
1258
|
+
const shutdownCoordinator = new RpcShutdownCoordinator({
|
|
1259
|
+
isShutdownRequested: () => shutdownState.requested,
|
|
1260
|
+
performShutdown: async () => {
|
|
1261
|
+
if (session.extensionRunner?.hasHandlers("session_shutdown")) {
|
|
1262
|
+
await session.extensionRunner.emit({ type: "session_shutdown" });
|
|
1263
|
+
}
|
|
1264
|
+
process.exit(0);
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1109
1267
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1268
|
+
const dispatchFrameDeps: RpcInputFrameDeps = {
|
|
1269
|
+
handleCommand,
|
|
1270
|
+
output,
|
|
1271
|
+
errorResponse: error,
|
|
1272
|
+
trackBackgroundTask: task => shutdownCoordinator.track(task),
|
|
1273
|
+
pendingExtensionRequests,
|
|
1274
|
+
onHostToolResult: frame => hostToolBridge.handleResult(frame),
|
|
1275
|
+
onHostToolUpdate: frame => hostToolBridge.handleUpdate(frame),
|
|
1276
|
+
onHostUriResult: frame => hostUriBridge.handleResult(frame),
|
|
1277
|
+
};
|
|
1112
1278
|
|
|
1113
|
-
// Listen for JSON input using Bun's stdin
|
|
1279
|
+
// Listen for JSON input using Bun's stdin. Frame dispatch lives in
|
|
1280
|
+
// dispatchRpcInputFrame so it can be exercised directly by tests; see the
|
|
1281
|
+
// helper's docstring for the concurrency contract.
|
|
1114
1282
|
for await (const parsed of readJsonl(Bun.stdin.stream())) {
|
|
1115
1283
|
try {
|
|
1116
|
-
|
|
1117
|
-
if (
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1284
|
+
const awaited = dispatchRpcInputFrame(parsed, dispatchFrameDeps);
|
|
1285
|
+
if (awaited) {
|
|
1286
|
+
await awaited;
|
|
1287
|
+
// Check for deferred shutdown request (idle between commands).
|
|
1288
|
+
// Background-dispatched bash frames skip this check so a later
|
|
1289
|
+
// abort_bash can still be read; the coordinator re-checks when
|
|
1290
|
+
// each tracked task settles, so a shutdown requested mid-bash
|
|
1291
|
+
// fires once the response frame is written even if no further
|
|
1292
|
+
// client frames arrive.
|
|
1293
|
+
await shutdownCoordinator.checkShutdownRequested();
|
|
1124
1294
|
}
|
|
1125
|
-
|
|
1126
|
-
if (isRpcHostToolResult(parsed)) {
|
|
1127
|
-
hostToolBridge.handleResult(parsed);
|
|
1128
|
-
continue;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
if (isRpcHostToolUpdate(parsed)) {
|
|
1132
|
-
hostToolBridge.handleUpdate(parsed);
|
|
1133
|
-
continue;
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
if (isRpcHostUriResult(parsed)) {
|
|
1137
|
-
hostUriBridge.handleResult(parsed);
|
|
1138
|
-
continue;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// Handle regular commands
|
|
1142
|
-
const command = parsed as RpcCommand;
|
|
1143
|
-
const response = await handleCommand(command);
|
|
1144
|
-
output(response);
|
|
1145
|
-
|
|
1146
|
-
// Check for deferred shutdown request (idle between commands)
|
|
1147
|
-
await checkShutdownRequested();
|
|
1148
1295
|
} catch (e: unknown) {
|
|
1149
1296
|
const message = e instanceof Error ? e.message : String(e);
|
|
1150
1297
|
output(error(undefined, "parse", `Failed to parse command: ${message}`));
|
|
1151
1298
|
}
|
|
1152
1299
|
}
|
|
1153
1300
|
|
|
1301
|
+
// Background bash tasks may still owe response frames; drain them before
|
|
1302
|
+
// tearing down (stdin EOF ends the frame stream, not in-flight work).
|
|
1303
|
+
await shutdownCoordinator.drain();
|
|
1304
|
+
|
|
1154
1305
|
// stdin closed — RPC client is gone, exit cleanly
|
|
1155
1306
|
hostToolBridge.rejectAllPending("RPC client disconnected before host tool execution completed");
|
|
1156
1307
|
hostUriBridge.clear("RPC client disconnected before host URI request completed");
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { postmortem } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { createSessionTeardown } from "./session-teardown";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Signal-safe session teardown contract (issue #4080). The callback body
|
|
7
|
+
* registered on `postmortem` for `SIGINT`/`SIGTERM`/`SIGHUP`/`uncaughtException`
|
|
8
|
+
* must persist the in-progress editor draft and emit the extension
|
|
9
|
+
* `session_shutdown` event (via `session.dispose()`) — the same steps the TUI
|
|
10
|
+
* Ctrl+C keypress path performs. Both paths funnel through
|
|
11
|
+
* `createSessionTeardown`, so exercising it directly proves the acceptance
|
|
12
|
+
* criteria hold regardless of the trigger.
|
|
13
|
+
*/
|
|
14
|
+
describe("createSessionTeardown", () => {
|
|
15
|
+
it("persists the draft, then disposes the session, in that order", async () => {
|
|
16
|
+
const order: string[] = [];
|
|
17
|
+
const saved: string[] = [];
|
|
18
|
+
|
|
19
|
+
const teardown = createSessionTeardown({
|
|
20
|
+
getDraftText: () => "unsent draft",
|
|
21
|
+
beginDispose: () => {
|
|
22
|
+
order.push("beginDispose");
|
|
23
|
+
},
|
|
24
|
+
saveDraft: async text => {
|
|
25
|
+
order.push("saveDraft");
|
|
26
|
+
saved.push(text);
|
|
27
|
+
},
|
|
28
|
+
disposeSession: async () => {
|
|
29
|
+
order.push("disposeSession");
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await teardown();
|
|
34
|
+
|
|
35
|
+
expect(order).toEqual(["beginDispose", "saveDraft", "disposeSession"]);
|
|
36
|
+
expect(saved).toEqual(["unsent draft"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("marks the session disposing before awaiting draft persistence", async () => {
|
|
40
|
+
const order: string[] = [];
|
|
41
|
+
const release = Promise.withResolvers<void>();
|
|
42
|
+
|
|
43
|
+
const teardown = createSessionTeardown({
|
|
44
|
+
getDraftText: () => {
|
|
45
|
+
order.push("snapshot");
|
|
46
|
+
return "draft";
|
|
47
|
+
},
|
|
48
|
+
beginDispose: () => {
|
|
49
|
+
order.push("beginDispose");
|
|
50
|
+
},
|
|
51
|
+
saveDraft: async () => {
|
|
52
|
+
order.push("saveDraft:start");
|
|
53
|
+
await release.promise;
|
|
54
|
+
order.push("saveDraft:done");
|
|
55
|
+
},
|
|
56
|
+
disposeSession: async () => {
|
|
57
|
+
order.push("disposeSession");
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const running = teardown();
|
|
62
|
+
expect(order).toEqual(["snapshot", "beginDispose", "saveDraft:start"]);
|
|
63
|
+
release.resolve();
|
|
64
|
+
await running;
|
|
65
|
+
|
|
66
|
+
expect(order).toEqual(["snapshot", "beginDispose", "saveDraft:start", "saveDraft:done", "disposeSession"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("still disposes when saveDraft rejects — never leaves session_shutdown unemitted", async () => {
|
|
70
|
+
let disposed = false;
|
|
71
|
+
|
|
72
|
+
const teardown = createSessionTeardown({
|
|
73
|
+
getDraftText: () => "draft",
|
|
74
|
+
beginDispose: () => {},
|
|
75
|
+
saveDraft: async () => {
|
|
76
|
+
throw new Error("disk full");
|
|
77
|
+
},
|
|
78
|
+
disposeSession: async () => {
|
|
79
|
+
disposed = true;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await teardown();
|
|
84
|
+
|
|
85
|
+
expect(disposed).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("passes the empty snapshot through so a stale sidecar is cleared on clean exit", async () => {
|
|
89
|
+
const seen: string[] = [];
|
|
90
|
+
const teardown = createSessionTeardown({
|
|
91
|
+
getDraftText: () => "",
|
|
92
|
+
beginDispose: () => {},
|
|
93
|
+
saveDraft: async text => {
|
|
94
|
+
seen.push(text);
|
|
95
|
+
},
|
|
96
|
+
disposeSession: async () => {},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await teardown();
|
|
100
|
+
|
|
101
|
+
expect(seen).toEqual([""]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("memoizes: concurrent and repeat calls run the teardown exactly once", async () => {
|
|
105
|
+
let getDraftCalls = 0;
|
|
106
|
+
let saveDraftCalls = 0;
|
|
107
|
+
let disposeCalls = 0;
|
|
108
|
+
const release = Promise.withResolvers<void>();
|
|
109
|
+
|
|
110
|
+
const teardown = createSessionTeardown({
|
|
111
|
+
getDraftText: () => {
|
|
112
|
+
getDraftCalls++;
|
|
113
|
+
return `draft-${getDraftCalls}`;
|
|
114
|
+
},
|
|
115
|
+
beginDispose: () => {},
|
|
116
|
+
saveDraft: async () => {
|
|
117
|
+
saveDraftCalls++;
|
|
118
|
+
},
|
|
119
|
+
disposeSession: async () => {
|
|
120
|
+
disposeCalls++;
|
|
121
|
+
await release.promise;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Kick off two concurrent invocations while the first is still awaiting
|
|
126
|
+
// disposeSession — this is exactly what happens if a SIGTERM arrives
|
|
127
|
+
// mid-`InteractiveMode.shutdown()`.
|
|
128
|
+
const first = teardown();
|
|
129
|
+
const second = teardown();
|
|
130
|
+
release.resolve();
|
|
131
|
+
await Promise.all([first, second]);
|
|
132
|
+
|
|
133
|
+
// A third call after settlement must still be a no-op.
|
|
134
|
+
await teardown();
|
|
135
|
+
|
|
136
|
+
expect(getDraftCalls).toBe(1);
|
|
137
|
+
expect(saveDraftCalls).toBe(1);
|
|
138
|
+
expect(disposeCalls).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("snapshots the draft text at first call — later editor mutations do not leak in", async () => {
|
|
142
|
+
let editorText = "before";
|
|
143
|
+
let captured: string | undefined;
|
|
144
|
+
|
|
145
|
+
const teardown = createSessionTeardown({
|
|
146
|
+
getDraftText: () => editorText,
|
|
147
|
+
beginDispose: () => {},
|
|
148
|
+
saveDraft: async text => {
|
|
149
|
+
captured = text;
|
|
150
|
+
},
|
|
151
|
+
disposeSession: async () => {},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const running = teardown();
|
|
155
|
+
editorText = "after"; // A late edit must not overwrite the persisted draft.
|
|
156
|
+
await running;
|
|
157
|
+
|
|
158
|
+
expect(captured).toBe("before");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("forwards the postmortem reason into disposeSession so signal exits record the real trigger", async () => {
|
|
162
|
+
const received: Array<postmortem.Reason | undefined> = [];
|
|
163
|
+
|
|
164
|
+
const teardown = createSessionTeardown({
|
|
165
|
+
getDraftText: () => "",
|
|
166
|
+
beginDispose: () => {},
|
|
167
|
+
saveDraft: async () => {},
|
|
168
|
+
disposeSession: async reason => {
|
|
169
|
+
received.push(reason);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await teardown(postmortem.Reason.SIGTERM);
|
|
174
|
+
|
|
175
|
+
expect(received).toEqual([postmortem.Reason.SIGTERM]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keypress path passes no reason — dispose falls back to a normal exit record", async () => {
|
|
179
|
+
const received: Array<postmortem.Reason | undefined> = [];
|
|
180
|
+
|
|
181
|
+
const teardown = createSessionTeardown({
|
|
182
|
+
getDraftText: () => "",
|
|
183
|
+
beginDispose: () => {},
|
|
184
|
+
saveDraft: async () => {},
|
|
185
|
+
disposeSession: async reason => {
|
|
186
|
+
received.push(reason);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await teardown();
|
|
191
|
+
|
|
192
|
+
expect(received).toEqual([undefined]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("first call's reason wins: a later caller with a different reason awaits the same promise", async () => {
|
|
196
|
+
const received: Array<postmortem.Reason | undefined> = [];
|
|
197
|
+
const release = Promise.withResolvers<void>();
|
|
198
|
+
|
|
199
|
+
const teardown = createSessionTeardown({
|
|
200
|
+
getDraftText: () => "",
|
|
201
|
+
beginDispose: () => {},
|
|
202
|
+
saveDraft: async () => {},
|
|
203
|
+
disposeSession: async reason => {
|
|
204
|
+
received.push(reason);
|
|
205
|
+
await release.promise;
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// SIGTERM lands first; postmortem.quit(0)'s MANUAL pass arrives while the
|
|
210
|
+
// teardown is still draining — it must not restart the run or mutate the
|
|
211
|
+
// recorded reason.
|
|
212
|
+
const first = teardown(postmortem.Reason.SIGTERM);
|
|
213
|
+
const second = teardown(postmortem.Reason.MANUAL);
|
|
214
|
+
release.resolve();
|
|
215
|
+
await Promise.all([first, second]);
|
|
216
|
+
|
|
217
|
+
expect(received).toEqual([postmortem.Reason.SIGTERM]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal-safe session teardown: persists the in-progress editor draft, then
|
|
3
|
+
* disposes the session (which emits `session_shutdown`, cancels the session's
|
|
4
|
+
* background async jobs, and closes the session manager). Shared by the TUI
|
|
5
|
+
* Ctrl+C/Ctrl+D/`/exit` keypress path in `InteractiveMode.shutdown()` and by
|
|
6
|
+
* the postmortem `SIGINT`/`SIGTERM`/`SIGHUP`/`uncaughtException` handlers so a
|
|
7
|
+
* real kernel signal executes the exact same teardown as a keypress exit.
|
|
8
|
+
*
|
|
9
|
+
* Extracted (rather than inlined into `InteractiveMode`) so the callback body
|
|
10
|
+
* is directly unit-testable without instantiating the full TUI stack.
|
|
11
|
+
*/
|
|
12
|
+
import { logger, type postmortem } from "@oh-my-pi/pi-utils";
|
|
13
|
+
|
|
14
|
+
/** Dependencies the teardown captures at construction time. */
|
|
15
|
+
export interface SessionTeardownDeps {
|
|
16
|
+
/** Snapshot the current editor text; called once, before disposal touches session state. */
|
|
17
|
+
getDraftText: () => string;
|
|
18
|
+
/**
|
|
19
|
+
* Synchronously mark the session as disposing before any awaited teardown
|
|
20
|
+
* work. This closes the async gap where deferred jobs could otherwise start
|
|
21
|
+
* after a signal requested shutdown but before `disposeSession()` begins.
|
|
22
|
+
*/
|
|
23
|
+
beginDispose: () => void;
|
|
24
|
+
/**
|
|
25
|
+
* Persist the snapshotted draft. Called even for an empty string so a
|
|
26
|
+
* previously-persisted draft sidecar is cleared on a clean exit.
|
|
27
|
+
*/
|
|
28
|
+
saveDraft: (text: string) => Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Dispose the session — emits `session_shutdown`, drains async jobs, closes
|
|
31
|
+
* the manager. Receives the postmortem reason that triggered the teardown
|
|
32
|
+
* (undefined on the keypress/`/exit` path) so `AgentSession.dispose()` can
|
|
33
|
+
* persist the real exit reason instead of the generic `"dispose"`.
|
|
34
|
+
*/
|
|
35
|
+
disposeSession: (reason?: postmortem.Reason) => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Idempotent teardown: concurrent/repeat invocations share one settled
|
|
40
|
+
* promise. The optional `reason` is the postmortem reason that triggered the
|
|
41
|
+
* teardown (`sigterm`, `sighup`, `uncaught_exception`, …); only the FIRST
|
|
42
|
+
* call's reason is used — later callers await the same settled promise.
|
|
43
|
+
*/
|
|
44
|
+
export type SessionTeardown = (reason?: postmortem.Reason) => Promise<void>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a promise-memoized teardown function. The first call snapshots the
|
|
48
|
+
* draft text, marks the session disposing synchronously, runs `saveDraft`
|
|
49
|
+
* (draft-loss protection for `--resume`), then `disposeSession`; subsequent
|
|
50
|
+
* calls await the same settled promise, so the keypress
|
|
51
|
+
* `InteractiveMode.shutdown()` path and the postmortem signal callback cannot
|
|
52
|
+
* double-emit `session_shutdown`, double-dispose the session's async-job
|
|
53
|
+
* manager, or race each other.
|
|
54
|
+
*
|
|
55
|
+
* The postmortem callback forwards its `Reason` so the persisted
|
|
56
|
+
* `session_exit` diagnostic carries the real trigger (`sigterm`, `sighup`,
|
|
57
|
+
* `uncaught_exception`, …) instead of the generic `"dispose"` that plain
|
|
58
|
+
* programmatic disposal records. First call wins: a signal arriving after a
|
|
59
|
+
* keypress-initiated teardown awaits the in-flight promise and its reason is
|
|
60
|
+
* dropped — by then the exit entry is already being written as a normal exit.
|
|
61
|
+
*
|
|
62
|
+
* `saveDraft` failures are logged but never abort the disposal chain — a
|
|
63
|
+
* draft-write error must not leak background bash/task jobs or skip the
|
|
64
|
+
* extension `session_shutdown` event.
|
|
65
|
+
*/
|
|
66
|
+
export function createSessionTeardown(deps: SessionTeardownDeps): SessionTeardown {
|
|
67
|
+
let pending: Promise<void> | undefined;
|
|
68
|
+
const run = async (reason?: postmortem.Reason): Promise<void> => {
|
|
69
|
+
const draftText = deps.getDraftText();
|
|
70
|
+
deps.beginDispose();
|
|
71
|
+
try {
|
|
72
|
+
await deps.saveDraft(draftText);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.warn("Failed to save session draft during teardown", { error: String(err) });
|
|
75
|
+
}
|
|
76
|
+
await deps.disposeSession(reason);
|
|
77
|
+
};
|
|
78
|
+
return (reason?: postmortem.Reason) => {
|
|
79
|
+
if (!pending) pending = run(reason);
|
|
80
|
+
return pending;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -256,7 +256,7 @@ class ThemeSceneController implements SetupSceneController {
|
|
|
256
256
|
} else {
|
|
257
257
|
this.host.ctx.settings.set("theme.dark", themeName);
|
|
258
258
|
}
|
|
259
|
-
await previewTheme(themeName);
|
|
259
|
+
await previewTheme(themeName, { ephemeral: false });
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
async #preview(value: string): Promise<void> {
|
|
@@ -270,7 +270,7 @@ class ThemeSceneController implements SetupSceneController {
|
|
|
270
270
|
let result: { success: boolean; error?: string } = { success: true };
|
|
271
271
|
if (value === "auto") {
|
|
272
272
|
await this.#applyPreviewPresentation(this.#originalSymbolPreset, this.#originalColorBlindMode);
|
|
273
|
-
enableAutoTheme();
|
|
273
|
+
enableAutoTheme({ ephemeral: true });
|
|
274
274
|
} else if (value === "colorblind") {
|
|
275
275
|
await this.#applyPreviewPresentation(this.#originalSymbolPreset, true);
|
|
276
276
|
} else if (value === "ansi") {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
2
|
-
import { getSkillSlashCommandName, parseSkillInvocation } from "../extensibility/skills";
|
|
2
|
+
import { buildSkillPromptMessage, getSkillSlashCommandName, parseSkillInvocation } from "../extensibility/skills";
|
|
3
3
|
import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../session/messages";
|
|
4
4
|
import type { InteractiveModeContext } from "./types";
|
|
5
5
|
|
|
@@ -49,32 +49,19 @@ export async function buildSkillCommandPrompt(
|
|
|
49
49
|
): Promise<BuiltSkillCommandPrompt | undefined> {
|
|
50
50
|
const parsed = parseSkillInvocation(text);
|
|
51
51
|
if (!parsed) return undefined;
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
if (!skillPath) return undefined;
|
|
52
|
+
const skill = ctx.skillCommands.get(getSkillSlashCommandName({ name: parsed.name }));
|
|
53
|
+
if (!skill) return undefined;
|
|
55
54
|
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
if (parsed.args) {
|
|
60
|
-
metaLines.push(`User: ${parsed.args}`);
|
|
61
|
-
}
|
|
62
|
-
const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
|
|
63
|
-
const textBlock: TextContent = { type: "text", text: message };
|
|
64
|
-
const promptContent = images && images.length > 0 ? [textBlock, ...images] : message;
|
|
65
|
-
const details: SkillPromptDetails = {
|
|
66
|
-
name: parsed.name,
|
|
67
|
-
path: skillPath,
|
|
68
|
-
args: parsed.args || undefined,
|
|
69
|
-
lineCount: body ? body.split("\n").length : 0,
|
|
70
|
-
};
|
|
55
|
+
const built = await buildSkillPromptMessage(skill, parsed.args, "user");
|
|
56
|
+
const textBlock: TextContent = { type: "text", text: built.message };
|
|
57
|
+
const promptContent = images && images.length > 0 ? [textBlock, ...images] : built.message;
|
|
71
58
|
|
|
72
59
|
return {
|
|
73
60
|
message: {
|
|
74
61
|
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
|
75
62
|
content: promptContent,
|
|
76
63
|
display: true,
|
|
77
|
-
details,
|
|
64
|
+
details: built.details,
|
|
78
65
|
attribution: "user",
|
|
79
66
|
},
|
|
80
67
|
options: { streamingBehavior, queueChipText: text },
|