@oh-my-pi/pi-coding-agent 6.9.0 → 7.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.
- package/CHANGELOG.md +173 -51
- package/examples/sdk/04-skills.ts +1 -1
- package/package.json +6 -5
- package/src/cli/stats-cli.ts +191 -0
- package/src/core/agent-session.ts +214 -4
- package/src/core/auth-storage.ts +524 -202
- package/src/core/bash-executor.ts +1 -1
- package/src/core/extensions/index.ts +2 -0
- package/src/core/extensions/runner.ts +31 -0
- package/src/core/extensions/types.ts +24 -0
- package/src/core/messages.ts +48 -0
- package/src/core/model-registry.ts +7 -0
- package/src/core/python-executor.ts +29 -8
- package/src/core/python-gateway-coordinator.ts +55 -1
- package/src/core/python-prelude.py +201 -8
- package/src/core/session-manager.ts +10 -1
- package/src/core/tools/bash.ts +5 -7
- package/src/core/tools/find.ts +18 -5
- package/src/core/tools/index.ts +1 -1
- package/src/core/tools/lsp/index.ts +13 -2
- package/src/core/tools/patch/applicator.ts +115 -17
- package/src/core/tools/patch/index.ts +1 -1
- package/src/core/tools/patch/normalize.ts +185 -10
- package/src/core/tools/python.ts +445 -86
- package/src/core/tools/read.ts +4 -4
- package/src/core/tools/task/executor.ts +2 -6
- package/src/core/tools/task/index.ts +30 -12
- package/src/core/tools/task/render.ts +163 -30
- package/src/core/tools/task/template.ts +37 -0
- package/src/core/tools/task/types.ts +6 -2
- package/src/core/tools/task/worker.ts +1 -1
- package/src/index.ts +2 -0
- package/src/main.ts +12 -0
- package/src/modes/interactive/components/python-execution.ts +180 -0
- package/src/modes/interactive/components/welcome.ts +1 -0
- package/src/modes/interactive/controllers/command-controller.ts +395 -0
- package/src/modes/interactive/controllers/input-controller.ts +83 -8
- package/src/modes/interactive/interactive-mode.ts +16 -1
- package/src/modes/interactive/theme/dark.json +2 -9
- package/src/modes/interactive/theme/defaults/alabaster.json +2 -8
- package/src/modes/interactive/theme/defaults/amethyst.json +2 -9
- package/src/modes/interactive/theme/defaults/anthracite.json +2 -9
- package/src/modes/interactive/theme/defaults/basalt.json +89 -88
- package/src/modes/interactive/theme/defaults/birch.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-abyss.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-arctic.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-aurora.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-catppuccin.json +2 -1
- package/src/modes/interactive/theme/defaults/dark-cavern.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-copper.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-cosmos.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-dracula.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-eclipse.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-ember.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-equinox.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-forest.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-github.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-gruvbox.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-lavender.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-lunar.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-midnight.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-monochrome.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-monokai.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-nebula.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-nord.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-ocean.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-one.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-rainforest.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-reef.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-retro.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-rose-pine.json +2 -1
- package/src/modes/interactive/theme/defaults/dark-sakura.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-slate.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-solarized.json +2 -1
- package/src/modes/interactive/theme/defaults/dark-solstice.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-starfall.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-sunset.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-swamp.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-synthwave.json +2 -1
- package/src/modes/interactive/theme/defaults/dark-taiga.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-terminal.json +3 -2
- package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +2 -9
- package/src/modes/interactive/theme/defaults/dark-tundra.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-twilight.json +2 -8
- package/src/modes/interactive/theme/defaults/dark-volcanic.json +2 -8
- package/src/modes/interactive/theme/defaults/graphite.json +2 -9
- package/src/modes/interactive/theme/defaults/light-arctic.json +2 -1
- package/src/modes/interactive/theme/defaults/light-aurora-day.json +2 -8
- package/src/modes/interactive/theme/defaults/light-canyon.json +2 -8
- package/src/modes/interactive/theme/defaults/light-catppuccin.json +2 -1
- package/src/modes/interactive/theme/defaults/light-cirrus.json +2 -8
- package/src/modes/interactive/theme/defaults/light-coral.json +3 -2
- package/src/modes/interactive/theme/defaults/light-cyberpunk.json +2 -9
- package/src/modes/interactive/theme/defaults/light-dawn.json +2 -8
- package/src/modes/interactive/theme/defaults/light-dunes.json +2 -8
- package/src/modes/interactive/theme/defaults/light-eucalyptus.json +3 -2
- package/src/modes/interactive/theme/defaults/light-forest.json +2 -9
- package/src/modes/interactive/theme/defaults/light-frost.json +3 -2
- package/src/modes/interactive/theme/defaults/light-github.json +2 -1
- package/src/modes/interactive/theme/defaults/light-glacier.json +2 -8
- package/src/modes/interactive/theme/defaults/light-gruvbox.json +2 -9
- package/src/modes/interactive/theme/defaults/light-haze.json +2 -8
- package/src/modes/interactive/theme/defaults/light-honeycomb.json +3 -2
- package/src/modes/interactive/theme/defaults/light-lagoon.json +2 -8
- package/src/modes/interactive/theme/defaults/light-lavender.json +3 -2
- package/src/modes/interactive/theme/defaults/light-meadow.json +2 -8
- package/src/modes/interactive/theme/defaults/light-mint.json +3 -2
- package/src/modes/interactive/theme/defaults/light-monochrome.json +2 -1
- package/src/modes/interactive/theme/defaults/light-ocean.json +2 -9
- package/src/modes/interactive/theme/defaults/light-one.json +2 -8
- package/src/modes/interactive/theme/defaults/light-opal.json +2 -8
- package/src/modes/interactive/theme/defaults/light-orchard.json +2 -8
- package/src/modes/interactive/theme/defaults/light-paper.json +3 -2
- package/src/modes/interactive/theme/defaults/light-prism.json +2 -8
- package/src/modes/interactive/theme/defaults/light-retro.json +2 -9
- package/src/modes/interactive/theme/defaults/light-sand.json +3 -2
- package/src/modes/interactive/theme/defaults/light-savanna.json +2 -8
- package/src/modes/interactive/theme/defaults/light-solarized.json +2 -1
- package/src/modes/interactive/theme/defaults/light-soleil.json +2 -8
- package/src/modes/interactive/theme/defaults/light-sunset.json +2 -9
- package/src/modes/interactive/theme/defaults/light-synthwave.json +2 -9
- package/src/modes/interactive/theme/defaults/light-tokyo-night.json +2 -9
- package/src/modes/interactive/theme/defaults/light-wetland.json +2 -8
- package/src/modes/interactive/theme/defaults/light-zenith.json +2 -8
- package/src/modes/interactive/theme/defaults/limestone.json +2 -8
- package/src/modes/interactive/theme/defaults/mahogany.json +2 -9
- package/src/modes/interactive/theme/defaults/marble.json +2 -8
- package/src/modes/interactive/theme/defaults/obsidian.json +89 -88
- package/src/modes/interactive/theme/defaults/onyx.json +89 -88
- package/src/modes/interactive/theme/defaults/pearl.json +2 -8
- package/src/modes/interactive/theme/defaults/porcelain.json +89 -88
- package/src/modes/interactive/theme/defaults/quartz.json +2 -8
- package/src/modes/interactive/theme/defaults/sandstone.json +2 -8
- package/src/modes/interactive/theme/defaults/titanium.json +88 -87
- package/src/modes/interactive/theme/light.json +2 -8
- package/src/modes/interactive/theme/theme-schema.json +5 -0
- package/src/modes/interactive/theme/theme.ts +7 -0
- package/src/modes/interactive/types.ts +7 -1
- package/src/modes/interactive/utils/ui-helpers.ts +20 -0
- package/src/prompts/system/system-prompt.md +88 -78
- package/src/prompts/tools/python.md +39 -2
- package/src/prompts/tools/task.md +8 -13
|
@@ -56,7 +56,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
56
56
|
cancelled: false,
|
|
57
57
|
...(await sink.dump()),
|
|
58
58
|
};
|
|
59
|
-
} catch (err) {
|
|
59
|
+
} catch (err: unknown) {
|
|
60
60
|
// Exception covers NonZeroExitError, AbortError, TimeoutError
|
|
61
61
|
if (err instanceof Exception) {
|
|
62
62
|
if (err.aborted) {
|
|
@@ -40,6 +40,8 @@ import type {
|
|
|
40
40
|
ToolResultEventResult,
|
|
41
41
|
UserBashEvent,
|
|
42
42
|
UserBashEventResult,
|
|
43
|
+
UserPythonEvent,
|
|
44
|
+
UserPythonEventResult,
|
|
43
45
|
} from "./types";
|
|
44
46
|
|
|
45
47
|
/** Combined result from all before_agent_start handlers */
|
|
@@ -461,6 +463,35 @@ export class ExtensionRunner {
|
|
|
461
463
|
return undefined;
|
|
462
464
|
}
|
|
463
465
|
|
|
466
|
+
async emitUserPython(event: UserPythonEvent): Promise<UserPythonEventResult | undefined> {
|
|
467
|
+
const ctx = this.createContext();
|
|
468
|
+
|
|
469
|
+
for (const ext of this.extensions) {
|
|
470
|
+
const handlers = ext.handlers.get("user_python");
|
|
471
|
+
if (!handlers || handlers.length === 0) continue;
|
|
472
|
+
|
|
473
|
+
for (const handler of handlers) {
|
|
474
|
+
try {
|
|
475
|
+
const handlerResult = await handler(event, ctx);
|
|
476
|
+
if (handlerResult) {
|
|
477
|
+
return handlerResult as UserPythonEventResult;
|
|
478
|
+
}
|
|
479
|
+
} catch (err) {
|
|
480
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
481
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
482
|
+
this.emitError({
|
|
483
|
+
extensionPath: ext.path,
|
|
484
|
+
event: "user_python",
|
|
485
|
+
error: message,
|
|
486
|
+
stack,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
464
495
|
/** Emit input event. Transforms chain, "handled" short-circuits. */
|
|
465
496
|
async emitInput(
|
|
466
497
|
text: string,
|
|
@@ -21,6 +21,7 @@ import type { ExecOptions, ExecResult } from "../exec";
|
|
|
21
21
|
import type { KeybindingsManager } from "../keybindings";
|
|
22
22
|
import type { CustomMessage } from "../messages";
|
|
23
23
|
import type { ModelRegistry } from "../model-registry";
|
|
24
|
+
import type { PythonResult } from "../python-executor";
|
|
24
25
|
import type {
|
|
25
26
|
BranchSummaryEntry,
|
|
26
27
|
CompactionEntry,
|
|
@@ -405,6 +406,21 @@ export interface UserBashEvent {
|
|
|
405
406
|
cwd: string;
|
|
406
407
|
}
|
|
407
408
|
|
|
409
|
+
// ============================================================================
|
|
410
|
+
// User Python Events
|
|
411
|
+
// ============================================================================
|
|
412
|
+
|
|
413
|
+
/** Fired when user executes Python code via $ or $$ prefix */
|
|
414
|
+
export interface UserPythonEvent {
|
|
415
|
+
type: "user_python";
|
|
416
|
+
/** The Python code to execute */
|
|
417
|
+
code: string;
|
|
418
|
+
/** True if $$ prefix was used (excluded from LLM context) */
|
|
419
|
+
excludeFromContext: boolean;
|
|
420
|
+
/** Current working directory */
|
|
421
|
+
cwd: string;
|
|
422
|
+
}
|
|
423
|
+
|
|
408
424
|
// ============================================================================
|
|
409
425
|
// Input Events
|
|
410
426
|
// ============================================================================
|
|
@@ -521,6 +537,7 @@ export type ExtensionEvent =
|
|
|
521
537
|
| TurnStartEvent
|
|
522
538
|
| TurnEndEvent
|
|
523
539
|
| UserBashEvent
|
|
540
|
+
| UserPythonEvent
|
|
524
541
|
| InputEvent
|
|
525
542
|
| ToolCallEvent
|
|
526
543
|
| ToolResultEvent;
|
|
@@ -554,6 +571,12 @@ export interface UserBashEventResult {
|
|
|
554
571
|
result?: BashResult;
|
|
555
572
|
}
|
|
556
573
|
|
|
574
|
+
/** Result from user_python event handler */
|
|
575
|
+
export interface UserPythonEventResult {
|
|
576
|
+
/** Full replacement: extension handled execution, use this result */
|
|
577
|
+
result?: PythonResult;
|
|
578
|
+
}
|
|
579
|
+
|
|
557
580
|
export interface ToolResultEventResult {
|
|
558
581
|
content?: (TextContent | ImageContent)[];
|
|
559
582
|
details?: unknown;
|
|
@@ -671,6 +694,7 @@ export interface ExtensionAPI {
|
|
|
671
694
|
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
|
|
672
695
|
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
|
|
673
696
|
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
|
|
697
|
+
on(event: "user_python", handler: ExtensionHandler<UserPythonEvent, UserPythonEventResult>): void;
|
|
674
698
|
|
|
675
699
|
// =========================================================================
|
|
676
700
|
// Tool Registration
|
package/src/core/messages.ts
CHANGED
|
@@ -39,6 +39,23 @@ export interface BashExecutionMessage {
|
|
|
39
39
|
excludeFromContext?: boolean;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Message type for user-initiated Python executions via the $ command.
|
|
44
|
+
* Shares the same kernel session as the agent's Python tool.
|
|
45
|
+
*/
|
|
46
|
+
export interface PythonExecutionMessage {
|
|
47
|
+
role: "pythonExecution";
|
|
48
|
+
code: string;
|
|
49
|
+
output: string;
|
|
50
|
+
exitCode: number | undefined;
|
|
51
|
+
cancelled: boolean;
|
|
52
|
+
truncated: boolean;
|
|
53
|
+
fullOutputPath?: string;
|
|
54
|
+
timestamp: number;
|
|
55
|
+
/** If true, this message is excluded from LLM context ($$ prefix) */
|
|
56
|
+
excludeFromContext?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
/**
|
|
43
60
|
* Message type for extension-injected messages via sendMessage().
|
|
44
61
|
*/
|
|
@@ -95,6 +112,7 @@ export interface FileMentionMessage {
|
|
|
95
112
|
declare module "@oh-my-pi/pi-agent-core" {
|
|
96
113
|
interface CustomAgentMessages {
|
|
97
114
|
bashExecution: BashExecutionMessage;
|
|
115
|
+
pythonExecution: PythonExecutionMessage;
|
|
98
116
|
custom: CustomMessage;
|
|
99
117
|
hookMessage: HookMessage;
|
|
100
118
|
branchSummary: BranchSummaryMessage;
|
|
@@ -124,6 +142,27 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
|
|
|
124
142
|
return text;
|
|
125
143
|
}
|
|
126
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Convert a PythonExecutionMessage to user message text for LLM context.
|
|
147
|
+
*/
|
|
148
|
+
export function pythonExecutionToText(msg: PythonExecutionMessage): string {
|
|
149
|
+
let text = `Ran Python:\n\`\`\`python\n${msg.code}\n\`\`\`\n`;
|
|
150
|
+
if (msg.output) {
|
|
151
|
+
text += `Output:\n\`\`\`\n${msg.output}\n\`\`\``;
|
|
152
|
+
} else {
|
|
153
|
+
text += "(no output)";
|
|
154
|
+
}
|
|
155
|
+
if (msg.cancelled) {
|
|
156
|
+
text += "\n\n(execution cancelled)";
|
|
157
|
+
} else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
|
|
158
|
+
text += `\n\nExecution failed with code ${msg.exitCode}`;
|
|
159
|
+
}
|
|
160
|
+
if (msg.truncated && msg.fullOutputPath) {
|
|
161
|
+
text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`;
|
|
162
|
+
}
|
|
163
|
+
return text;
|
|
164
|
+
}
|
|
165
|
+
|
|
127
166
|
export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage {
|
|
128
167
|
return {
|
|
129
168
|
role: "branchSummary",
|
|
@@ -185,6 +224,15 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
|
185
224
|
content: [{ type: "text", text: bashExecutionToText(m) }],
|
|
186
225
|
timestamp: m.timestamp,
|
|
187
226
|
};
|
|
227
|
+
case "pythonExecution":
|
|
228
|
+
if (m.excludeFromContext) {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
role: "user",
|
|
233
|
+
content: [{ type: "text", text: pythonExecutionToText(m) }],
|
|
234
|
+
timestamp: m.timestamp,
|
|
235
|
+
};
|
|
188
236
|
case "custom":
|
|
189
237
|
case "hookMessage": {
|
|
190
238
|
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
|
|
@@ -432,6 +432,13 @@ export class ModelRegistry {
|
|
|
432
432
|
return this.models.find((m) => m.provider === provider && m.id === modelId);
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Get the base URL associated with a provider, if any model defines one.
|
|
437
|
+
*/
|
|
438
|
+
getProviderBaseUrl(provider: string): string | undefined {
|
|
439
|
+
return this.models.find((m) => m.provider === provider && m.baseUrl)?.baseUrl;
|
|
440
|
+
}
|
|
441
|
+
|
|
435
442
|
/**
|
|
436
443
|
* Get API key for a model.
|
|
437
444
|
*/
|
|
@@ -27,6 +27,8 @@ export interface PythonExecutorOptions {
|
|
|
27
27
|
reset?: boolean;
|
|
28
28
|
/** Use shared gateway across pi instances (default: true) */
|
|
29
29
|
useSharedGateway?: boolean;
|
|
30
|
+
/** Session file path for accessing task outputs */
|
|
31
|
+
sessionFile?: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface PythonKernelExecutor {
|
|
@@ -79,6 +81,7 @@ export async function warmPythonEnvironment(
|
|
|
79
81
|
cwd: string,
|
|
80
82
|
sessionId?: string,
|
|
81
83
|
useSharedGateway?: boolean,
|
|
84
|
+
sessionFile?: string,
|
|
82
85
|
): Promise<{ ok: boolean; reason?: string; docs: PreludeHelper[] }> {
|
|
83
86
|
try {
|
|
84
87
|
await ensureKernelAvailable(cwd);
|
|
@@ -97,6 +100,7 @@ export async function warmPythonEnvironment(
|
|
|
97
100
|
cwd,
|
|
98
101
|
async (kernel) => kernel.introspectPrelude(),
|
|
99
102
|
useSharedGateway,
|
|
103
|
+
sessionFile,
|
|
100
104
|
);
|
|
101
105
|
cachedPreludeDocs = docs;
|
|
102
106
|
return { ok: true, docs };
|
|
@@ -115,8 +119,14 @@ export function resetPreludeDocsCache(): void {
|
|
|
115
119
|
cachedPreludeDocs = null;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
async function createKernelSession(
|
|
119
|
-
|
|
122
|
+
async function createKernelSession(
|
|
123
|
+
sessionId: string,
|
|
124
|
+
cwd: string,
|
|
125
|
+
useSharedGateway?: boolean,
|
|
126
|
+
sessionFile?: string,
|
|
127
|
+
): Promise<KernelSession> {
|
|
128
|
+
const env = sessionFile ? { OMP_SESSION_FILE: sessionFile } : undefined;
|
|
129
|
+
const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
|
|
120
130
|
const session: KernelSession = {
|
|
121
131
|
id: sessionId,
|
|
122
132
|
kernel,
|
|
@@ -137,7 +147,12 @@ async function createKernelSession(sessionId: string, cwd: string, useSharedGate
|
|
|
137
147
|
return session;
|
|
138
148
|
}
|
|
139
149
|
|
|
140
|
-
async function restartKernelSession(
|
|
150
|
+
async function restartKernelSession(
|
|
151
|
+
session: KernelSession,
|
|
152
|
+
cwd: string,
|
|
153
|
+
useSharedGateway?: boolean,
|
|
154
|
+
sessionFile?: string,
|
|
155
|
+
): Promise<void> {
|
|
141
156
|
session.restartCount += 1;
|
|
142
157
|
if (session.restartCount > 1) {
|
|
143
158
|
throw new Error("Python kernel restarted too many times in this session");
|
|
@@ -147,7 +162,8 @@ async function restartKernelSession(session: KernelSession, cwd: string, useShar
|
|
|
147
162
|
} catch (err) {
|
|
148
163
|
logger.warn("Failed to shutdown crashed kernel", { error: err instanceof Error ? err.message : String(err) });
|
|
149
164
|
}
|
|
150
|
-
const
|
|
165
|
+
const env = sessionFile ? { OMP_SESSION_FILE: sessionFile } : undefined;
|
|
166
|
+
const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
|
|
151
167
|
session.kernel = kernel;
|
|
152
168
|
session.dead = false;
|
|
153
169
|
session.lastUsedAt = Date.now();
|
|
@@ -170,17 +186,18 @@ async function withKernelSession<T>(
|
|
|
170
186
|
cwd: string,
|
|
171
187
|
handler: (kernel: PythonKernel) => Promise<T>,
|
|
172
188
|
useSharedGateway?: boolean,
|
|
189
|
+
sessionFile?: string,
|
|
173
190
|
): Promise<T> {
|
|
174
191
|
let session = kernelSessions.get(sessionId);
|
|
175
192
|
if (!session) {
|
|
176
|
-
session = await createKernelSession(sessionId, cwd, useSharedGateway);
|
|
193
|
+
session = await createKernelSession(sessionId, cwd, useSharedGateway, sessionFile);
|
|
177
194
|
kernelSessions.set(sessionId, session);
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
const run = async (): Promise<T> => {
|
|
181
198
|
session!.lastUsedAt = Date.now();
|
|
182
199
|
if (session!.dead || !session!.kernel.isAlive()) {
|
|
183
|
-
await restartKernelSession(session!, cwd, useSharedGateway);
|
|
200
|
+
await restartKernelSession(session!, cwd, useSharedGateway, sessionFile);
|
|
184
201
|
}
|
|
185
202
|
try {
|
|
186
203
|
const result = await handler(session!.kernel);
|
|
@@ -190,7 +207,7 @@ async function withKernelSession<T>(
|
|
|
190
207
|
if (!session!.dead && session!.kernel.isAlive()) {
|
|
191
208
|
throw err;
|
|
192
209
|
}
|
|
193
|
-
await restartKernelSession(session!, cwd, useSharedGateway);
|
|
210
|
+
await restartKernelSession(session!, cwd, useSharedGateway, sessionFile);
|
|
194
211
|
const result = await handler(session!.kernel);
|
|
195
212
|
session!.restartCount = 0;
|
|
196
213
|
return result;
|
|
@@ -273,8 +290,11 @@ export async function executePython(code: string, options?: PythonExecutorOption
|
|
|
273
290
|
|
|
274
291
|
const kernelMode = options?.kernelMode ?? "session";
|
|
275
292
|
const useSharedGateway = options?.useSharedGateway;
|
|
293
|
+
const sessionFile = options?.sessionFile;
|
|
294
|
+
|
|
276
295
|
if (kernelMode === "per-call") {
|
|
277
|
-
const
|
|
296
|
+
const env = sessionFile ? { OMP_SESSION_FILE: sessionFile } : undefined;
|
|
297
|
+
const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
|
|
278
298
|
try {
|
|
279
299
|
return await executeWithKernel(kernel, code, options);
|
|
280
300
|
} finally {
|
|
@@ -294,5 +314,6 @@ export async function executePython(code: string, options?: PythonExecutorOption
|
|
|
294
314
|
cwd,
|
|
295
315
|
async (kernel) => executeWithKernel(kernel, code, options),
|
|
296
316
|
useSharedGateway,
|
|
317
|
+
sessionFile,
|
|
297
318
|
);
|
|
298
319
|
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { createServer } from "node:net";
|
|
15
15
|
import { delimiter, join } from "node:path";
|
|
16
|
-
import { logger } from "@oh-my-pi/pi-utils";
|
|
16
|
+
import { logger, postmortem } from "@oh-my-pi/pi-utils";
|
|
17
17
|
import type { Subprocess } from "bun";
|
|
18
18
|
import { getAgentDir } from "../config";
|
|
19
19
|
import { getShellConfig, killProcessTree } from "../utils/shell";
|
|
@@ -161,6 +161,58 @@ let localGatewayUrl: string | null = null;
|
|
|
161
161
|
let idleShutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
|
162
162
|
let isCoordinatorInitialized = false;
|
|
163
163
|
let localClientFile: string | null = null;
|
|
164
|
+
let postmortemRegistered = false;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Register cleanup handler for process exit. Called lazily on first gateway acquisition.
|
|
168
|
+
* Ensures the gateway process we spawned is killed when omp exits, preventing orphaned processes.
|
|
169
|
+
*/
|
|
170
|
+
function ensurePostmortemCleanup(): void {
|
|
171
|
+
if (postmortemRegistered) return;
|
|
172
|
+
postmortemRegistered = true;
|
|
173
|
+
|
|
174
|
+
postmortem.register("shared-gateway", async () => {
|
|
175
|
+
cancelIdleShutdown();
|
|
176
|
+
|
|
177
|
+
// Clean up our client file first so refcount is accurate
|
|
178
|
+
if (localClientFile) {
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(localClientFile);
|
|
181
|
+
} catch {
|
|
182
|
+
// Ignore cleanup errors
|
|
183
|
+
}
|
|
184
|
+
localClientFile = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// If we spawned the gateway, kill it only if no other clients remain
|
|
188
|
+
if (localGatewayProcess) {
|
|
189
|
+
const clients = pruneStaleClientInfos(listClientInfos());
|
|
190
|
+
const remainingRefs = clients.reduce((sum, c) => sum + c.info.refCount, 0);
|
|
191
|
+
|
|
192
|
+
if (remainingRefs === 0) {
|
|
193
|
+
logger.debug("Cleaning up shared gateway on process exit", { pid: localGatewayProcess.pid });
|
|
194
|
+
try {
|
|
195
|
+
await killProcessTree(localGatewayProcess.pid);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.warn("Failed to kill shared gateway on exit", {
|
|
198
|
+
error: err instanceof Error ? err.message : String(err),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
clearGatewayInfo();
|
|
202
|
+
} else {
|
|
203
|
+
logger.debug("Leaving shared gateway running for other clients", {
|
|
204
|
+
pid: localGatewayProcess.pid,
|
|
205
|
+
remainingRefs,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
localGatewayProcess = null;
|
|
210
|
+
localGatewayUrl = null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
isCoordinatorInitialized = false;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
164
216
|
|
|
165
217
|
function filterEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
166
218
|
const filtered: Record<string, string | undefined> = {};
|
|
@@ -665,6 +717,8 @@ export async function acquireSharedGateway(cwd: string): Promise<AcquireResult |
|
|
|
665
717
|
return null;
|
|
666
718
|
}
|
|
667
719
|
|
|
720
|
+
ensurePostmortemCleanup();
|
|
721
|
+
|
|
668
722
|
try {
|
|
669
723
|
return await withGatewayLock(async () => {
|
|
670
724
|
const existingInfo = readGatewayInfo();
|
|
@@ -24,14 +24,6 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
24
24
|
_emit_status("pwd", path=str(p))
|
|
25
25
|
return p
|
|
26
26
|
|
|
27
|
-
@_category("Navigation")
|
|
28
|
-
def cd(path: str | Path) -> Path:
|
|
29
|
-
"""Change directory."""
|
|
30
|
-
p = Path(path).expanduser().resolve()
|
|
31
|
-
os.chdir(p)
|
|
32
|
-
_emit_status("cd", path=str(p))
|
|
33
|
-
return p
|
|
34
|
-
|
|
35
27
|
@_category("Shell")
|
|
36
28
|
def env(key: str | None = None, value: str | None = None):
|
|
37
29
|
"""Get/set environment variables."""
|
|
@@ -914,6 +906,207 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
914
906
|
_emit_status("git_has_changes", has_changes=has_changes)
|
|
915
907
|
return has_changes
|
|
916
908
|
|
|
909
|
+
@_category("Agent")
|
|
910
|
+
def output(
|
|
911
|
+
*ids: str,
|
|
912
|
+
format: str = "raw",
|
|
913
|
+
query: str | None = None,
|
|
914
|
+
offset: int | None = None,
|
|
915
|
+
limit: int | None = None,
|
|
916
|
+
) -> str | dict | list[dict]:
|
|
917
|
+
"""Read task/agent output by ID. Returns text or JSON depending on format.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
*ids: Output IDs to read (e.g., 'explore_0', 'reviewer_1')
|
|
921
|
+
format: 'raw' (default), 'json' (dict with metadata), 'stripped' (no ANSI)
|
|
922
|
+
query: jq-like query for JSON outputs (e.g., '.endpoints[0].file')
|
|
923
|
+
offset: Line number to start reading from (1-indexed)
|
|
924
|
+
limit: Maximum number of lines to read
|
|
925
|
+
|
|
926
|
+
Returns:
|
|
927
|
+
Single ID: str (format='raw'/'stripped') or dict (format='json')
|
|
928
|
+
Multiple IDs: list of dict with 'id' and 'content'/'data' keys
|
|
929
|
+
|
|
930
|
+
Examples:
|
|
931
|
+
output('explore_0') # Read as raw text
|
|
932
|
+
output('reviewer_0', format='json') # Read with metadata
|
|
933
|
+
output('explore_0', query='.files[0]') # Extract JSON field
|
|
934
|
+
output('explore_0', offset=10, limit=20) # Lines 10-29
|
|
935
|
+
output('explore_0', 'reviewer_1') # Read multiple outputs
|
|
936
|
+
"""
|
|
937
|
+
session_file = os.environ.get("OMP_SESSION_FILE")
|
|
938
|
+
if not session_file:
|
|
939
|
+
_emit_status("output", error="No session file available")
|
|
940
|
+
raise RuntimeError("No session - output artifacts unavailable")
|
|
941
|
+
|
|
942
|
+
artifacts_dir = session_file.rsplit(".", 1)[0] # Strip .jsonl extension
|
|
943
|
+
if not Path(artifacts_dir).exists():
|
|
944
|
+
_emit_status("output", error="Artifacts directory not found", path=artifacts_dir)
|
|
945
|
+
raise RuntimeError(f"No artifacts directory found: {artifacts_dir}")
|
|
946
|
+
|
|
947
|
+
if not ids:
|
|
948
|
+
_emit_status("output", error="No IDs provided")
|
|
949
|
+
raise ValueError("At least one output ID is required")
|
|
950
|
+
|
|
951
|
+
if query and (offset is not None or limit is not None):
|
|
952
|
+
_emit_status("output", error="query cannot be combined with offset/limit")
|
|
953
|
+
raise ValueError("query cannot be combined with offset/limit")
|
|
954
|
+
|
|
955
|
+
results: list[dict] = []
|
|
956
|
+
not_found: list[str] = []
|
|
957
|
+
|
|
958
|
+
for output_id in ids:
|
|
959
|
+
output_path = Path(artifacts_dir) / f"{output_id}.md"
|
|
960
|
+
if not output_path.exists():
|
|
961
|
+
not_found.append(output_id)
|
|
962
|
+
continue
|
|
963
|
+
|
|
964
|
+
raw_content = output_path.read_text(encoding="utf-8")
|
|
965
|
+
raw_lines = raw_content.splitlines()
|
|
966
|
+
total_lines = len(raw_lines)
|
|
967
|
+
|
|
968
|
+
selected_content = raw_content
|
|
969
|
+
range_info: dict | None = None
|
|
970
|
+
|
|
971
|
+
# Handle query
|
|
972
|
+
if query:
|
|
973
|
+
try:
|
|
974
|
+
json_value = json.loads(raw_content)
|
|
975
|
+
except json.JSONDecodeError as e:
|
|
976
|
+
_emit_status("output", id=output_id, error=f"Not valid JSON: {e}")
|
|
977
|
+
raise ValueError(f"Output {output_id} is not valid JSON: {e}")
|
|
978
|
+
|
|
979
|
+
# Apply jq-like query
|
|
980
|
+
result_value = _apply_query(json_value, query)
|
|
981
|
+
try:
|
|
982
|
+
selected_content = json.dumps(result_value, indent=2) if result_value is not None else "null"
|
|
983
|
+
except (TypeError, ValueError):
|
|
984
|
+
selected_content = str(result_value)
|
|
985
|
+
|
|
986
|
+
# Handle offset/limit
|
|
987
|
+
elif offset is not None or limit is not None:
|
|
988
|
+
start_line = max(1, offset or 1)
|
|
989
|
+
if start_line > total_lines:
|
|
990
|
+
_emit_status("output", id=output_id, error=f"Offset {start_line} beyond end ({total_lines} lines)")
|
|
991
|
+
raise ValueError(f"Offset {start_line} is beyond end of output ({total_lines} lines) for {output_id}")
|
|
992
|
+
|
|
993
|
+
effective_limit = limit if limit is not None else total_lines - start_line + 1
|
|
994
|
+
end_line = min(total_lines, start_line + effective_limit - 1)
|
|
995
|
+
selected_lines = raw_lines[start_line - 1 : end_line]
|
|
996
|
+
selected_content = "\n".join(selected_lines)
|
|
997
|
+
range_info = {"start_line": start_line, "end_line": end_line, "total_lines": total_lines}
|
|
998
|
+
|
|
999
|
+
# Strip ANSI codes if requested
|
|
1000
|
+
if format == "stripped":
|
|
1001
|
+
import re
|
|
1002
|
+
selected_content = re.sub(r"\x1b\[[0-9;]*m", "", selected_content)
|
|
1003
|
+
|
|
1004
|
+
# Build result
|
|
1005
|
+
if format == "json":
|
|
1006
|
+
result_data = {
|
|
1007
|
+
"id": output_id,
|
|
1008
|
+
"path": str(output_path),
|
|
1009
|
+
"line_count": total_lines if not query else len(selected_content.splitlines()),
|
|
1010
|
+
"char_count": len(raw_content) if not query else len(selected_content),
|
|
1011
|
+
"content": selected_content,
|
|
1012
|
+
}
|
|
1013
|
+
if range_info:
|
|
1014
|
+
result_data["range"] = range_info
|
|
1015
|
+
if query:
|
|
1016
|
+
result_data["query"] = query
|
|
1017
|
+
results.append(result_data)
|
|
1018
|
+
else:
|
|
1019
|
+
results.append({"id": output_id, "content": selected_content})
|
|
1020
|
+
|
|
1021
|
+
# Handle not found
|
|
1022
|
+
if not_found:
|
|
1023
|
+
available = sorted(
|
|
1024
|
+
[f.stem for f in Path(artifacts_dir).glob("*.md")]
|
|
1025
|
+
)
|
|
1026
|
+
error_msg = f"Output not found: {', '.join(not_found)}"
|
|
1027
|
+
if available:
|
|
1028
|
+
error_msg += f"\n\nAvailable outputs: {', '.join(available[:20])}"
|
|
1029
|
+
if len(available) > 20:
|
|
1030
|
+
error_msg += f" (and {len(available) - 20} more)"
|
|
1031
|
+
_emit_status("output", not_found=not_found, available_count=len(available))
|
|
1032
|
+
raise FileNotFoundError(error_msg)
|
|
1033
|
+
|
|
1034
|
+
# Return format
|
|
1035
|
+
if len(ids) == 1:
|
|
1036
|
+
if format == "json":
|
|
1037
|
+
_emit_status("output", id=ids[0], chars=results[0]["char_count"])
|
|
1038
|
+
return results[0]
|
|
1039
|
+
_emit_status("output", id=ids[0], chars=len(results[0]["content"]))
|
|
1040
|
+
return results[0]["content"]
|
|
1041
|
+
|
|
1042
|
+
# Multiple IDs
|
|
1043
|
+
if format == "json":
|
|
1044
|
+
total_chars = sum(r["char_count"] for r in results)
|
|
1045
|
+
_emit_status("output", count=len(results), total_chars=total_chars)
|
|
1046
|
+
return results
|
|
1047
|
+
|
|
1048
|
+
combined_output: list[dict] = []
|
|
1049
|
+
for r in results:
|
|
1050
|
+
combined_output.append({"id": r["id"], "content": r["content"]})
|
|
1051
|
+
total_chars = sum(len(r["content"]) for r in combined_output)
|
|
1052
|
+
_emit_status("output", count=len(combined_output), total_chars=total_chars)
|
|
1053
|
+
return combined_output
|
|
1054
|
+
|
|
1055
|
+
def _apply_query(data: any, query: str) -> any:
|
|
1056
|
+
"""Apply jq-like query to data. Supports .key, [index], and chaining."""
|
|
1057
|
+
if not query:
|
|
1058
|
+
return data
|
|
1059
|
+
|
|
1060
|
+
query = query.strip()
|
|
1061
|
+
if query.startswith("."):
|
|
1062
|
+
query = query[1:]
|
|
1063
|
+
if not query:
|
|
1064
|
+
return data
|
|
1065
|
+
|
|
1066
|
+
# Parse query into tokens
|
|
1067
|
+
tokens = []
|
|
1068
|
+
current_token = ""
|
|
1069
|
+
i = 0
|
|
1070
|
+
while i < len(query):
|
|
1071
|
+
ch = query[i]
|
|
1072
|
+
if ch == ".":
|
|
1073
|
+
if current_token:
|
|
1074
|
+
tokens.append(("key", current_token))
|
|
1075
|
+
current_token = ""
|
|
1076
|
+
elif ch == "[":
|
|
1077
|
+
if current_token:
|
|
1078
|
+
tokens.append(("key", current_token))
|
|
1079
|
+
current_token = ""
|
|
1080
|
+
# Find matching ]
|
|
1081
|
+
j = i + 1
|
|
1082
|
+
while j < len(query) and query[j] != "]":
|
|
1083
|
+
j += 1
|
|
1084
|
+
bracket_content = query[i+1:j]
|
|
1085
|
+
if bracket_content.startswith('"') and bracket_content.endswith('"'):
|
|
1086
|
+
tokens.append(("key", bracket_content[1:-1]))
|
|
1087
|
+
else:
|
|
1088
|
+
tokens.append(("index", int(bracket_content)))
|
|
1089
|
+
i = j
|
|
1090
|
+
else:
|
|
1091
|
+
current_token += ch
|
|
1092
|
+
i += 1
|
|
1093
|
+
if current_token:
|
|
1094
|
+
tokens.append(("key", current_token))
|
|
1095
|
+
|
|
1096
|
+
# Apply tokens
|
|
1097
|
+
current = data
|
|
1098
|
+
for token_type, value in tokens:
|
|
1099
|
+
if token_type == "index":
|
|
1100
|
+
if not isinstance(current, list) or value >= len(current):
|
|
1101
|
+
return None
|
|
1102
|
+
current = current[value]
|
|
1103
|
+
elif token_type == "key":
|
|
1104
|
+
if not isinstance(current, dict) or value not in current:
|
|
1105
|
+
return None
|
|
1106
|
+
current = current[value]
|
|
1107
|
+
|
|
1108
|
+
return current
|
|
1109
|
+
|
|
917
1110
|
def __omp_prelude_docs__() -> list[dict[str, str]]:
|
|
918
1111
|
"""Return prelude helper docs for templating. Discovers functions by _omp_category attribute."""
|
|
919
1112
|
helpers: list[dict[str, str]] = []
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
createCustomMessage,
|
|
14
14
|
type FileMentionMessage,
|
|
15
15
|
type HookMessage,
|
|
16
|
+
type PythonExecutionMessage,
|
|
16
17
|
} from "./messages";
|
|
17
18
|
import type { SessionStorage, SessionStorageWriter } from "./session-storage";
|
|
18
19
|
import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
|
|
@@ -1306,7 +1307,15 @@ export class SessionManager {
|
|
|
1306
1307
|
* so it is easier to find them.
|
|
1307
1308
|
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
|
1308
1309
|
*/
|
|
1309
|
-
appendMessage(
|
|
1310
|
+
appendMessage(
|
|
1311
|
+
message:
|
|
1312
|
+
| Message
|
|
1313
|
+
| CustomMessage
|
|
1314
|
+
| HookMessage
|
|
1315
|
+
| BashExecutionMessage
|
|
1316
|
+
| PythonExecutionMessage
|
|
1317
|
+
| FileMentionMessage,
|
|
1318
|
+
): string {
|
|
1310
1319
|
const entry: SessionMessageEntry = {
|
|
1311
1320
|
type: "message",
|
|
1312
1321
|
id: generateId(this.byId),
|