@oh-my-pi/pi-coding-agent 6.8.0 → 6.8.1
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 +6 -0
- package/examples/extensions/plan-mode.ts +8 -8
- package/examples/extensions/tools.ts +7 -7
- package/package.json +6 -6
- package/src/cli/session-picker.ts +5 -2
- package/src/core/agent-session.ts +5 -1
- package/src/core/auth-storage.ts +8 -0
- package/src/core/bash-executor.ts +2 -2
- package/src/core/exec.ts +4 -2
- package/src/core/extensions/types.ts +1 -1
- package/src/core/hooks/types.ts +4 -3
- package/src/core/mcp/transports/http.ts +35 -27
- package/src/core/python-gateway-coordinator.ts +5 -4
- package/src/core/ssh/ssh-executor.ts +1 -1
- package/src/core/tools/python.ts +1 -0
- package/src/core/tools/task/executor.ts +79 -59
- package/src/core/tools/task/worker.ts +42 -13
- package/src/core/tools/web-fetch.ts +47 -7
- package/src/main.ts +7 -5
- package/src/modes/interactive/components/login-dialog.ts +6 -2
- package/src/modes/interactive/components/tool-execution.ts +4 -0
- package/src/modes/interactive/interactive-mode.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [6.8.1] - 2026-01-20
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed unhandled promise rejection when tool execution fails by adding missing `.catch()` to floating `.finally()` chain in `createAbortablePromise`
|
|
10
|
+
|
|
5
11
|
## [6.8.0] - 2026-01-20
|
|
6
12
|
|
|
7
13
|
### Added
|
|
@@ -247,16 +247,16 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
function togglePlanMode(ctx: ExtensionContext) {
|
|
250
|
+
async function togglePlanMode(ctx: ExtensionContext) {
|
|
251
251
|
planModeEnabled = !planModeEnabled;
|
|
252
252
|
executionMode = false;
|
|
253
253
|
todoItems = [];
|
|
254
254
|
|
|
255
255
|
if (planModeEnabled) {
|
|
256
|
-
pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
256
|
+
await pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
257
257
|
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
|
|
258
258
|
} else {
|
|
259
|
-
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
259
|
+
await pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
260
260
|
ctx.ui.notify("Plan mode disabled. Full access restored.");
|
|
261
261
|
}
|
|
262
262
|
updateStatus(ctx);
|
|
@@ -266,7 +266,7 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|
|
266
266
|
pi.registerCommand("plan", {
|
|
267
267
|
description: "Toggle plan mode (read-only exploration)",
|
|
268
268
|
handler: async (_args, ctx) => {
|
|
269
|
-
togglePlanMode(ctx);
|
|
269
|
+
await togglePlanMode(ctx);
|
|
270
270
|
},
|
|
271
271
|
});
|
|
272
272
|
|
|
@@ -294,7 +294,7 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|
|
294
294
|
pi.registerShortcut(Key.shift("p"), {
|
|
295
295
|
description: "Toggle plan mode",
|
|
296
296
|
handler: async (ctx) => {
|
|
297
|
-
togglePlanMode(ctx);
|
|
297
|
+
await togglePlanMode(ctx);
|
|
298
298
|
},
|
|
299
299
|
});
|
|
300
300
|
|
|
@@ -417,7 +417,7 @@ Execute each step in order.`,
|
|
|
417
417
|
|
|
418
418
|
executionMode = false;
|
|
419
419
|
todoItems = [];
|
|
420
|
-
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
420
|
+
await pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
421
421
|
updateStatus(ctx);
|
|
422
422
|
}
|
|
423
423
|
return;
|
|
@@ -470,7 +470,7 @@ Execute each step in order.`,
|
|
|
470
470
|
if (choice?.startsWith("Execute")) {
|
|
471
471
|
planModeEnabled = false;
|
|
472
472
|
executionMode = hasTodos;
|
|
473
|
-
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
473
|
+
await pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
474
474
|
updateStatus(ctx);
|
|
475
475
|
|
|
476
476
|
// Simple execution message - context event filters old plan mode messages
|
|
@@ -519,7 +519,7 @@ Execute each step in order.`,
|
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
if (planModeEnabled) {
|
|
522
|
-
pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
522
|
+
await pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
523
523
|
}
|
|
524
524
|
updateStatus(ctx);
|
|
525
525
|
});
|
|
@@ -31,12 +31,12 @@ export default function toolsExtension(pi: ExtensionAPI) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// Apply current tool selection
|
|
34
|
-
function applyTools() {
|
|
35
|
-
pi.setActiveTools(Array.from(enabledTools));
|
|
34
|
+
async function applyTools() {
|
|
35
|
+
await pi.setActiveTools(Array.from(enabledTools));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Find the last tools-config entry in the current branch
|
|
39
|
-
function restoreFromBranch(ctx: ExtensionContext) {
|
|
39
|
+
async function restoreFromBranch(ctx: ExtensionContext) {
|
|
40
40
|
allTools = pi.getAllTools();
|
|
41
41
|
|
|
42
42
|
// Get entries in current branch only
|
|
@@ -55,7 +55,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
|
|
|
55
55
|
if (savedTools) {
|
|
56
56
|
// Restore saved tool selection (filter to only tools that still exist)
|
|
57
57
|
enabledTools = new Set(savedTools.filter((t: string) => allTools.includes(t)));
|
|
58
|
-
applyTools();
|
|
58
|
+
await applyTools();
|
|
59
59
|
} else {
|
|
60
60
|
// No saved state - sync with currently active tools
|
|
61
61
|
enabledTools = new Set(pi.getActiveTools());
|
|
@@ -130,16 +130,16 @@ export default function toolsExtension(pi: ExtensionAPI) {
|
|
|
130
130
|
|
|
131
131
|
// Restore state on session start
|
|
132
132
|
pi.on("session_start", async (_event, ctx) => {
|
|
133
|
-
restoreFromBranch(ctx);
|
|
133
|
+
await restoreFromBranch(ctx);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
// Restore state when navigating the session tree
|
|
137
137
|
pi.on("session_tree", async (_event, ctx) => {
|
|
138
|
-
restoreFromBranch(ctx);
|
|
138
|
+
await restoreFromBranch(ctx);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
// Restore state after branching
|
|
142
142
|
pi.on("session_branch", async (_event, ctx) => {
|
|
143
|
-
restoreFromBranch(ctx);
|
|
143
|
+
await restoreFromBranch(ctx);
|
|
144
144
|
});
|
|
145
145
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "6.8.
|
|
3
|
+
"version": "6.8.1",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"ompConfig": {
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"prepublishOnly": "bun run generate-template && bun run clean && bun run build"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@oh-my-pi/pi-agent-core": "6.8.
|
|
44
|
-
"@oh-my-pi/pi-ai": "6.8.
|
|
45
|
-
"@oh-my-pi/pi-git-tool": "6.8.
|
|
46
|
-
"@oh-my-pi/pi-tui": "6.8.
|
|
47
|
-
"@oh-my-pi/pi-utils": "6.8.
|
|
43
|
+
"@oh-my-pi/pi-agent-core": "6.8.1",
|
|
44
|
+
"@oh-my-pi/pi-ai": "6.8.1",
|
|
45
|
+
"@oh-my-pi/pi-git-tool": "6.8.1",
|
|
46
|
+
"@oh-my-pi/pi-tui": "6.8.1",
|
|
47
|
+
"@oh-my-pi/pi-utils": "6.8.1",
|
|
48
48
|
"@openai/agents": "^0.3.7",
|
|
49
49
|
"@sinclair/typebox": "^0.34.46",
|
|
50
50
|
"ajv": "^8.17.1",
|
|
@@ -2226,7 +2226,7 @@ export class AgentSession {
|
|
|
2226
2226
|
error: message,
|
|
2227
2227
|
model: `${candidate.provider}/${candidate.id}`,
|
|
2228
2228
|
});
|
|
2229
|
-
await
|
|
2229
|
+
await abortableSleep(delayMs, this._autoCompactionAbortController.signal);
|
|
2230
2230
|
}
|
|
2231
2231
|
}
|
|
2232
2232
|
|
|
@@ -2291,6 +2291,10 @@ export class AgentSession {
|
|
|
2291
2291
|
}, 100);
|
|
2292
2292
|
}
|
|
2293
2293
|
} catch (error) {
|
|
2294
|
+
if (this._autoCompactionAbortController?.signal.aborted) {
|
|
2295
|
+
this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2294
2298
|
const errorMessage = error instanceof Error ? error.message : "compaction failed";
|
|
2295
2299
|
this._emit({
|
|
2296
2300
|
type: "auto_compaction_end",
|
package/src/core/auth-storage.ts
CHANGED
|
@@ -107,10 +107,12 @@ function toBoolean(value: unknown): boolean | undefined {
|
|
|
107
107
|
export class AuthStorage {
|
|
108
108
|
private static readonly codexUsageCacheTtlMs = 60_000; // Cache usage data for 1 minute
|
|
109
109
|
private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available
|
|
110
|
+
private static readonly cacheCleanupIntervalMs = 300_000; // Clean expired cache every 5 minutes
|
|
110
111
|
|
|
111
112
|
/** Provider -> credentials cache, populated from agent.db on reload(). */
|
|
112
113
|
private data: Map<string, StoredCredential[]> = new Map();
|
|
113
114
|
private storage: AgentStorage;
|
|
115
|
+
private lastCacheCleanup = 0;
|
|
114
116
|
/** Resolved path to agent.db (derived from authPath or used directly if .db). */
|
|
115
117
|
private dbPath: string;
|
|
116
118
|
private runtimeOverrides: Map<string, string> = new Map();
|
|
@@ -153,6 +155,7 @@ export class AuthStorage {
|
|
|
153
155
|
instance.sessionLastCredential = new Map();
|
|
154
156
|
instance.credentialBackoff = new Map();
|
|
155
157
|
instance.codexUsageCache = new Map();
|
|
158
|
+
instance.lastCacheCleanup = 0;
|
|
156
159
|
|
|
157
160
|
for (const [provider, creds] of Object.entries(data.credentials)) {
|
|
158
161
|
instance.data.set(
|
|
@@ -748,6 +751,11 @@ export class AuthStorage {
|
|
|
748
751
|
const cacheKey = this.getCodexUsageCacheKey(accountId, normalizedBase);
|
|
749
752
|
const now = Date.now();
|
|
750
753
|
|
|
754
|
+
if (now - this.lastCacheCleanup > AuthStorage.cacheCleanupIntervalMs) {
|
|
755
|
+
this.lastCacheCleanup = now;
|
|
756
|
+
this.storage.cleanExpiredCache();
|
|
757
|
+
}
|
|
758
|
+
|
|
751
759
|
// Check in-memory cache first (fastest)
|
|
752
760
|
const memCached = this.codexUsageCache.get(cacheKey);
|
|
753
761
|
if (memCached && memCached.expiresAt > now) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Provides unified bash execution for AgentSession.executeBash() and direct calls.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { cspawn, Exception } from "@oh-my-pi/pi-utils";
|
|
7
|
+
import { cspawn, Exception, ptree } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import { getShellConfig } from "../utils/shell";
|
|
9
9
|
import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
|
|
10
10
|
import { OutputSink } from "./streaming-output";
|
|
@@ -63,7 +63,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
63
63
|
// Exception covers NonZeroExitError, AbortError, TimeoutError
|
|
64
64
|
if (err instanceof Exception) {
|
|
65
65
|
if (err.aborted) {
|
|
66
|
-
const isTimeout = err.message.includes("timed out");
|
|
66
|
+
const isTimeout = err instanceof ptree.TimeoutError || err.message.toLowerCase().includes("timed out");
|
|
67
67
|
const annotation = isTimeout
|
|
68
68
|
? `Command timed out after ${Math.round((options?.timeout ?? 0) / 1000)} seconds`
|
|
69
69
|
: undefined;
|
package/src/core/exec.ts
CHANGED
|
@@ -41,14 +41,16 @@ export async function execCommand(
|
|
|
41
41
|
signal: options?.signal,
|
|
42
42
|
timeout: options?.timeout,
|
|
43
43
|
});
|
|
44
|
+
// Read streams before awaiting exit to avoid data loss if streams close
|
|
45
|
+
const [stdoutText, stderrText] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
|
|
44
46
|
try {
|
|
45
47
|
await proc.exited;
|
|
46
48
|
} catch {
|
|
47
49
|
// ChildProcess rejects on non-zero exit; we handle it below
|
|
48
50
|
}
|
|
49
51
|
return {
|
|
50
|
-
stdout:
|
|
51
|
-
stderr:
|
|
52
|
+
stdout: stdoutText,
|
|
53
|
+
stderr: stderrText,
|
|
52
54
|
code: proc.exitCode ?? 0,
|
|
53
55
|
killed: proc.exitReason instanceof ptree.AbortError,
|
|
54
56
|
};
|
|
@@ -757,7 +757,7 @@ export interface ExtensionAPI {
|
|
|
757
757
|
getAllTools(): string[];
|
|
758
758
|
|
|
759
759
|
/** Set the active tools by name. */
|
|
760
|
-
setActiveTools(toolNames: string[]): void
|
|
760
|
+
setActiveTools(toolNames: string[]): Promise<void>;
|
|
761
761
|
|
|
762
762
|
/** Set the current model. Returns false if no API key available. */
|
|
763
763
|
setModel(model: Model<any>): Promise<boolean>;
|
package/src/core/hooks/types.ts
CHANGED
|
@@ -685,12 +685,13 @@ export interface HookAPI {
|
|
|
685
685
|
* @param message.content - Message content (string or TextContent/ImageContent array)
|
|
686
686
|
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
|
|
687
687
|
* @param message.details - Optional hook-specific metadata (not sent to LLM)
|
|
688
|
-
* @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
|
|
689
|
-
*
|
|
688
|
+
* @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
|
|
689
|
+
* If agent is streaming, message is queued and triggerTurn is ignored.
|
|
690
|
+
* @param options.deliverAs - How to deliver the message: "steer" or "followUp".
|
|
690
691
|
*/
|
|
691
692
|
sendMessage<T = unknown>(
|
|
692
693
|
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
|
693
|
-
triggerTurn?: boolean,
|
|
694
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
|
|
694
695
|
): void;
|
|
695
696
|
|
|
696
697
|
/**
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Based on MCP spec 2025-03-26.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { JsonRpcResponse, MCPHttpServerConfig, MCPSseServerConfig, MCPTransport } from "../types";
|
|
8
|
+
import type { JsonRpcMessage, JsonRpcResponse, MCPHttpServerConfig, MCPSseServerConfig, MCPTransport } from "../types";
|
|
9
9
|
|
|
10
10
|
/** Generate unique request ID */
|
|
11
11
|
function generateId(): string {
|
|
@@ -167,39 +167,47 @@ export class HttpTransport implements MCPTransport {
|
|
|
167
167
|
throw new Error("No response body");
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
const timeout = this.config.timeout ?? 30000;
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const message = JSON.parse(data) as JsonRpcResponse;
|
|
172
|
+
const parse = async (): Promise<T> => {
|
|
173
|
+
for await (const event of readSseEvents(response.body!)) {
|
|
174
|
+
const data = event.data?.trim();
|
|
175
|
+
if (!data || data === "[DONE]") continue;
|
|
177
176
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
try {
|
|
178
|
+
const message = JSON.parse(data) as JsonRpcMessage;
|
|
179
|
+
|
|
180
|
+
if (
|
|
181
|
+
"id" in message &&
|
|
182
|
+
(message as JsonRpcResponse).id === expectedId &&
|
|
183
|
+
("result" in message || "error" in message)
|
|
184
|
+
) {
|
|
185
|
+
const response = message as JsonRpcResponse;
|
|
186
|
+
if (response.error) {
|
|
187
|
+
throw new Error(`MCP error ${response.error.code}: ${response.error.message}`);
|
|
188
|
+
}
|
|
189
|
+
return response.result as T;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if ("method" in message && !("id" in message)) {
|
|
193
|
+
this.onNotification?.(message.method, message.params);
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error instanceof Error && error.message.startsWith("MCP error")) {
|
|
197
|
+
throw error;
|
|
182
198
|
}
|
|
183
|
-
result = message.result as T;
|
|
184
|
-
}
|
|
185
|
-
// Handle notifications
|
|
186
|
-
else if ("method" in message && !("id" in message)) {
|
|
187
|
-
const notification = message as { method: string; params?: unknown };
|
|
188
|
-
this.onNotification?.(notification.method, notification.params);
|
|
189
|
-
}
|
|
190
|
-
} catch (error) {
|
|
191
|
-
if (error instanceof Error && error.message.startsWith("MCP error")) {
|
|
192
|
-
throw error;
|
|
193
199
|
}
|
|
194
|
-
// Ignore other parse errors
|
|
195
200
|
}
|
|
196
|
-
}
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
202
|
+
throw new Error(`No response received for request ID ${expectedId}`);
|
|
203
|
+
};
|
|
201
204
|
|
|
202
|
-
return
|
|
205
|
+
return Promise.race([
|
|
206
|
+
parse(),
|
|
207
|
+
new Promise<never>((_, reject) =>
|
|
208
|
+
setTimeout(() => reject(new Error(`SSE response timeout after ${timeout}ms`)), timeout),
|
|
209
|
+
),
|
|
210
|
+
]);
|
|
203
211
|
}
|
|
204
212
|
|
|
205
213
|
async notify(method: string, params?: Record<string, unknown>): Promise<void> {
|
|
@@ -116,8 +116,8 @@ const DEFAULT_ENV_DENYLIST = new Set([
|
|
|
116
116
|
const CASE_INSENSITIVE_ENV = process.platform === "win32";
|
|
117
117
|
const ACTIVE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV ? WINDOWS_ENV_ALLOWLIST : DEFAULT_ENV_ALLOWLIST;
|
|
118
118
|
|
|
119
|
-
const NORMALIZED_ALLOWLIST = new
|
|
120
|
-
Array.from(ACTIVE_ENV_ALLOWLIST, (key) =>
|
|
119
|
+
const NORMALIZED_ALLOWLIST = new Map(
|
|
120
|
+
Array.from(ACTIVE_ENV_ALLOWLIST, (key) => [CASE_INSENSITIVE_ENV ? key.toUpperCase() : key, key] as const),
|
|
121
121
|
);
|
|
122
122
|
const NORMALIZED_DENYLIST = new Set(
|
|
123
123
|
Array.from(DEFAULT_ENV_DENYLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
|
|
@@ -168,8 +168,9 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
|
|
|
168
168
|
if (value === undefined) continue;
|
|
169
169
|
const normalizedKey = normalizeEnvKey(key);
|
|
170
170
|
if (NORMALIZED_DENYLIST.has(normalizedKey)) continue;
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
const canonicalKey = NORMALIZED_ALLOWLIST.get(normalizedKey);
|
|
172
|
+
if (canonicalKey !== undefined) {
|
|
173
|
+
filtered[canonicalKey] = value;
|
|
173
174
|
continue;
|
|
174
175
|
}
|
|
175
176
|
if (NORMALIZED_ALLOW_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix))) {
|
|
@@ -95,7 +95,7 @@ export async function executeSSH(
|
|
|
95
95
|
return {
|
|
96
96
|
exitCode: undefined,
|
|
97
97
|
cancelled: true,
|
|
98
|
-
...sink.dump(`SSH
|
|
98
|
+
...sink.dump(`SSH: ${err.message}`),
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
if (err.aborted) {
|
package/src/core/tools/python.ts
CHANGED
|
@@ -343,7 +343,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
343
343
|
let abortSent = false;
|
|
344
344
|
let abortReason: AbortReason | undefined;
|
|
345
345
|
let terminationScheduled = false;
|
|
346
|
-
let
|
|
346
|
+
let terminated = false;
|
|
347
|
+
let terminationTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
348
|
+
let pendingTerminationTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
347
349
|
let finalize: ((message: Extract<SubagentWorkerResponse, { type: "done" }>) => void) | null = null;
|
|
348
350
|
const listenerController = new AbortController();
|
|
349
351
|
const listenerSignal = listenerController.signal;
|
|
@@ -416,28 +418,25 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
416
418
|
const scheduleTermination = () => {
|
|
417
419
|
if (terminationScheduled) return;
|
|
418
420
|
terminationScheduled = true;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
},
|
|
439
|
-
{ once: true, signal: listenerSignal },
|
|
440
|
-
);
|
|
421
|
+
terminationTimeoutId = setTimeout(() => {
|
|
422
|
+
terminationTimeoutId = null;
|
|
423
|
+
if (resolved || terminated) return;
|
|
424
|
+
terminated = true;
|
|
425
|
+
try {
|
|
426
|
+
worker.terminate();
|
|
427
|
+
} catch {
|
|
428
|
+
// Ignore termination errors
|
|
429
|
+
}
|
|
430
|
+
if (finalize && !resolved) {
|
|
431
|
+
finalize({
|
|
432
|
+
type: "done",
|
|
433
|
+
exitCode: 1,
|
|
434
|
+
durationMs: Date.now() - startTime,
|
|
435
|
+
error: abortReason === "signal" ? "Aborted" : "Worker terminated after tool completion",
|
|
436
|
+
aborted: abortReason === "signal",
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}, 2000);
|
|
441
440
|
};
|
|
442
441
|
|
|
443
442
|
const requestAbort = (reason: AbortReason) => {
|
|
@@ -461,28 +460,25 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
461
460
|
// Worker already terminated, nothing to do
|
|
462
461
|
}
|
|
463
462
|
// Cancel pending termination if it exists
|
|
464
|
-
|
|
465
|
-
pendingTerminationController.abort();
|
|
466
|
-
pendingTerminationController = null;
|
|
467
|
-
}
|
|
463
|
+
cancelPendingTermination();
|
|
468
464
|
scheduleTermination();
|
|
469
465
|
};
|
|
470
466
|
|
|
471
467
|
const schedulePendingTermination = () => {
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
468
|
+
if (pendingTerminationTimeoutId || abortSent || terminationScheduled || resolved) return;
|
|
469
|
+
pendingTerminationTimeoutId = setTimeout(() => {
|
|
470
|
+
pendingTerminationTimeoutId = null;
|
|
471
|
+
if (!resolved) {
|
|
472
|
+
requestAbort("terminate");
|
|
473
|
+
}
|
|
474
|
+
}, 2000);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const cancelPendingTermination = () => {
|
|
478
|
+
if (pendingTerminationTimeoutId) {
|
|
479
|
+
clearTimeout(pendingTerminationTimeoutId);
|
|
480
|
+
pendingTerminationTimeoutId = null;
|
|
481
|
+
}
|
|
486
482
|
};
|
|
487
483
|
|
|
488
484
|
// Handle abort signal
|
|
@@ -655,9 +651,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
655
651
|
// Accumulate tokens for progress display
|
|
656
652
|
progress.tokens += getUsageTokens(messageUsage);
|
|
657
653
|
}
|
|
658
|
-
// If pending termination, now we have tokens - terminate
|
|
659
|
-
if (
|
|
660
|
-
|
|
654
|
+
// If pending termination, now we have tokens - terminate immediately
|
|
655
|
+
if (pendingTerminationTimeoutId) {
|
|
656
|
+
cancelPendingTermination();
|
|
657
|
+
requestAbort("terminate");
|
|
661
658
|
}
|
|
662
659
|
break;
|
|
663
660
|
}
|
|
@@ -714,7 +711,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
714
711
|
|
|
715
712
|
const done = await new Promise<Extract<SubagentWorkerResponse, { type: "done" }>>((resolve) => {
|
|
716
713
|
const cleanup = () => {
|
|
717
|
-
pendingTerminationController = null;
|
|
718
714
|
listenerController.abort();
|
|
719
715
|
};
|
|
720
716
|
finalize = (message) => {
|
|
@@ -723,10 +719,18 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
723
719
|
cleanup();
|
|
724
720
|
resolve(message);
|
|
725
721
|
};
|
|
722
|
+
const postMessageSafe = (message: unknown) => {
|
|
723
|
+
if (resolved || terminated) return;
|
|
724
|
+
try {
|
|
725
|
+
worker.postMessage(message);
|
|
726
|
+
} catch {
|
|
727
|
+
// Worker already terminated
|
|
728
|
+
}
|
|
729
|
+
};
|
|
726
730
|
const handleMCPCall = async (request: MCPToolCallRequest) => {
|
|
727
731
|
const mcpManager = options.mcpManager;
|
|
728
732
|
if (!mcpManager) {
|
|
729
|
-
|
|
733
|
+
postMessageSafe({
|
|
730
734
|
type: "mcp_tool_result",
|
|
731
735
|
callId: request.callId,
|
|
732
736
|
error: "MCP not available",
|
|
@@ -743,13 +747,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
743
747
|
})(),
|
|
744
748
|
request.timeoutMs,
|
|
745
749
|
);
|
|
746
|
-
|
|
750
|
+
postMessageSafe({
|
|
747
751
|
type: "mcp_tool_result",
|
|
748
752
|
callId: request.callId,
|
|
749
753
|
result: { content: result.content ?? [], isError: result.isError },
|
|
750
754
|
});
|
|
751
755
|
} catch (error) {
|
|
752
|
-
|
|
756
|
+
postMessageSafe({
|
|
753
757
|
type: "mcp_tool_result",
|
|
754
758
|
callId: request.callId,
|
|
755
759
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -767,7 +771,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
767
771
|
|
|
768
772
|
const handlePythonCall = async (request: PythonToolCallRequest) => {
|
|
769
773
|
if (!pythonTool) {
|
|
770
|
-
|
|
774
|
+
postMessageSafe({
|
|
771
775
|
type: "python_tool_result",
|
|
772
776
|
callId: request.callId,
|
|
773
777
|
error: "Python proxy not available",
|
|
@@ -785,7 +789,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
785
789
|
request.params as { code: string; timeout?: number; workdir?: string; reset?: boolean },
|
|
786
790
|
combinedSignal,
|
|
787
791
|
);
|
|
788
|
-
|
|
792
|
+
postMessageSafe({
|
|
789
793
|
type: "python_tool_result",
|
|
790
794
|
callId: request.callId,
|
|
791
795
|
result: { content: result.content ?? [], details: result.details },
|
|
@@ -797,7 +801,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
797
801
|
: error instanceof Error
|
|
798
802
|
? error.message
|
|
799
803
|
: String(error);
|
|
800
|
-
|
|
804
|
+
postMessageSafe({
|
|
801
805
|
type: "python_tool_result",
|
|
802
806
|
callId: request.callId,
|
|
803
807
|
error: message,
|
|
@@ -816,7 +820,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
816
820
|
|
|
817
821
|
const handleLspCall = async (request: LspToolCallRequest) => {
|
|
818
822
|
if (!lspTool) {
|
|
819
|
-
|
|
823
|
+
postMessageSafe({
|
|
820
824
|
type: "lsp_tool_result",
|
|
821
825
|
callId: request.callId,
|
|
822
826
|
error: "LSP proxy not available",
|
|
@@ -828,7 +832,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
828
832
|
lspTool.execute(request.callId, request.params as LspParams, signal),
|
|
829
833
|
request.timeoutMs,
|
|
830
834
|
);
|
|
831
|
-
|
|
835
|
+
postMessageSafe({
|
|
832
836
|
type: "lsp_tool_result",
|
|
833
837
|
callId: request.callId,
|
|
834
838
|
result: { content: result.content ?? [], details: result.details },
|
|
@@ -840,7 +844,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
840
844
|
: error instanceof Error
|
|
841
845
|
? error.message
|
|
842
846
|
: String(error);
|
|
843
|
-
|
|
847
|
+
postMessageSafe({
|
|
844
848
|
type: "lsp_tool_result",
|
|
845
849
|
callId: request.callId,
|
|
846
850
|
error: message,
|
|
@@ -881,10 +885,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
881
885
|
return;
|
|
882
886
|
}
|
|
883
887
|
if (message.type === "done") {
|
|
888
|
+
// Worker is exiting - mark as terminated to prevent calling terminate() on dead worker
|
|
889
|
+
terminated = true;
|
|
884
890
|
finalize?.(message);
|
|
885
891
|
}
|
|
886
892
|
};
|
|
887
893
|
const onError = (event: WorkerErrorEvent) => {
|
|
894
|
+
// Worker error likely means it's dead or dying
|
|
895
|
+
terminated = true;
|
|
888
896
|
finalize?.({
|
|
889
897
|
type: "done",
|
|
890
898
|
exitCode: 1,
|
|
@@ -893,6 +901,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
893
901
|
});
|
|
894
902
|
};
|
|
895
903
|
const onMessageError = () => {
|
|
904
|
+
// Message error may indicate worker is in bad state
|
|
905
|
+
terminated = true;
|
|
896
906
|
finalize?.({
|
|
897
907
|
type: "done",
|
|
898
908
|
exitCode: 1,
|
|
@@ -902,6 +912,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
902
912
|
};
|
|
903
913
|
const onClose = () => {
|
|
904
914
|
// Worker terminated unexpectedly (crashed or was killed without sending done)
|
|
915
|
+
// Mark as terminated since the worker is already dead - calling terminate() again would crash
|
|
916
|
+
terminated = true;
|
|
905
917
|
const abortMessage =
|
|
906
918
|
abortSent && abortReason === "signal"
|
|
907
919
|
? "Worker terminated after abort"
|
|
@@ -932,11 +944,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
932
944
|
}
|
|
933
945
|
});
|
|
934
946
|
|
|
935
|
-
// Cleanup
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
947
|
+
// Cleanup - cancel any pending timeouts first
|
|
948
|
+
if (terminationTimeoutId) {
|
|
949
|
+
clearTimeout(terminationTimeoutId);
|
|
950
|
+
terminationTimeoutId = null;
|
|
951
|
+
}
|
|
952
|
+
cancelPendingTermination();
|
|
953
|
+
if (!terminated) {
|
|
954
|
+
terminated = true;
|
|
955
|
+
try {
|
|
956
|
+
worker.terminate();
|
|
957
|
+
} catch {
|
|
958
|
+
// Ignore termination errors
|
|
959
|
+
}
|
|
940
960
|
}
|
|
941
961
|
|
|
942
962
|
let exitCode = done.exitCode;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
17
17
|
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
|
18
|
-
import { logger, untilAborted } from "@oh-my-pi/pi-utils";
|
|
18
|
+
import { logger, postmortem, untilAborted } from "@oh-my-pi/pi-utils";
|
|
19
19
|
import type { TSchema } from "@sinclair/typebox";
|
|
20
20
|
import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" };
|
|
21
21
|
import type { AgentSessionEvent } from "../../agent-session";
|
|
@@ -377,17 +377,29 @@ function createPythonProxyTool(): CustomTool<typeof pythonSchema> {
|
|
|
377
377
|
description: getPythonToolDescription(),
|
|
378
378
|
parameters: pythonSchema,
|
|
379
379
|
execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
c
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
380
|
+
try {
|
|
381
|
+
const timeoutMs = getPythonCallTimeoutMs(params as PythonToolParams);
|
|
382
|
+
const result = await callPythonToolViaParent(params as PythonToolParams, signal, timeoutMs);
|
|
383
|
+
return {
|
|
384
|
+
content:
|
|
385
|
+
result?.content?.map((c) =>
|
|
386
|
+
c.type === "text"
|
|
387
|
+
? { type: "text" as const, text: c.text ?? "" }
|
|
388
|
+
: { type: "text" as const, text: JSON.stringify(c) },
|
|
389
|
+
) ?? [],
|
|
390
|
+
details: result?.details as PythonToolDetails | undefined,
|
|
391
|
+
};
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return {
|
|
394
|
+
content: [
|
|
395
|
+
{
|
|
396
|
+
type: "text" as const,
|
|
397
|
+
text: `Python error: ${error instanceof Error ? error.message : String(error)}`,
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
details: { isError: true } as PythonToolDetails,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
391
403
|
},
|
|
392
404
|
};
|
|
393
405
|
}
|
|
@@ -780,7 +792,14 @@ function handleAbort(): void {
|
|
|
780
792
|
}
|
|
781
793
|
}
|
|
782
794
|
|
|
783
|
-
const reportFatal = (message: string): void => {
|
|
795
|
+
const reportFatal = async (message: string): Promise<void> => {
|
|
796
|
+
// Run postmortem cleanup first to ensure child processes are killed
|
|
797
|
+
try {
|
|
798
|
+
await postmortem.cleanup();
|
|
799
|
+
} catch {
|
|
800
|
+
// Ignore cleanup errors
|
|
801
|
+
}
|
|
802
|
+
|
|
784
803
|
const runState = activeRun;
|
|
785
804
|
if (runState) {
|
|
786
805
|
runState.abortController.abort();
|
|
@@ -821,6 +840,16 @@ self.addEventListener("error", (event) => {
|
|
|
821
840
|
self.addEventListener("unhandledrejection", (event) => {
|
|
822
841
|
const reason = event.reason;
|
|
823
842
|
const message = reason instanceof Error ? reason.stack || reason.message : String(reason);
|
|
843
|
+
|
|
844
|
+
// Avoid terminating active runs on tool-level errors that bubble as rejections.
|
|
845
|
+
if (activeRun) {
|
|
846
|
+
logger.error("Unhandled rejection in subagent worker", { error: message });
|
|
847
|
+
if ("preventDefault" in event && typeof event.preventDefault === "function") {
|
|
848
|
+
event.preventDefault();
|
|
849
|
+
}
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
824
853
|
reportFatal(`Unhandled rejection: ${message}`);
|
|
825
854
|
});
|
|
826
855
|
|
|
@@ -4,8 +4,8 @@ import * as path from "node:path";
|
|
|
4
4
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
5
5
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
6
6
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
7
|
+
import { ptree } from "@oh-my-pi/pi-utils";
|
|
7
8
|
import { type Static, Type } from "@sinclair/typebox";
|
|
8
|
-
import { $ } from "bun";
|
|
9
9
|
import { nanoid } from "nanoid";
|
|
10
10
|
import { parse as parseHtml } from "node-html-parser";
|
|
11
11
|
import { type Theme, theme } from "../../modes/interactive/theme/theme";
|
|
@@ -75,18 +75,58 @@ const CONVERTIBLE_EXTENSIONS = new Set([
|
|
|
75
75
|
* Execute a command and return stdout
|
|
76
76
|
*/
|
|
77
77
|
|
|
78
|
+
type WritableLike = {
|
|
79
|
+
write: (chunk: string | Uint8Array) => unknown;
|
|
80
|
+
flush?: () => unknown;
|
|
81
|
+
end?: () => unknown;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const textEncoder = new TextEncoder();
|
|
85
|
+
|
|
86
|
+
async function writeStdin(handle: unknown, input: string | Buffer): Promise<void> {
|
|
87
|
+
if (!handle || typeof handle === "number") return;
|
|
88
|
+
if (typeof (handle as WritableStream<Uint8Array>).getWriter === "function") {
|
|
89
|
+
const writer = (handle as WritableStream<Uint8Array>).getWriter();
|
|
90
|
+
try {
|
|
91
|
+
const chunk = typeof input === "string" ? textEncoder.encode(input) : new Uint8Array(input);
|
|
92
|
+
await writer.write(chunk);
|
|
93
|
+
} finally {
|
|
94
|
+
await writer.close();
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sink = handle as WritableLike;
|
|
100
|
+
sink.write(input);
|
|
101
|
+
if (sink.flush) sink.flush();
|
|
102
|
+
if (sink.end) sink.end();
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
async function exec(
|
|
79
106
|
cmd: string,
|
|
80
107
|
args: string[],
|
|
81
108
|
options?: { timeout?: number; input?: string | Buffer },
|
|
82
109
|
): Promise<{ stdout: string; stderr: string; ok: boolean }> {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
const proc = ptree.cspawn([cmd, ...args], {
|
|
111
|
+
stdin: options?.input ? "pipe" : null,
|
|
112
|
+
timeout: options?.timeout,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (options?.input) {
|
|
116
|
+
await writeStdin(proc.stdin, options.input);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
|
|
120
|
+
try {
|
|
121
|
+
await proc.exited;
|
|
122
|
+
} catch {
|
|
123
|
+
// Handle non-zero exit or timeout
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
return {
|
|
87
|
-
stdout
|
|
88
|
-
stderr
|
|
89
|
-
ok:
|
|
127
|
+
stdout,
|
|
128
|
+
stderr,
|
|
129
|
+
ok: proc.exitCode === 0,
|
|
90
130
|
};
|
|
91
131
|
}
|
|
92
132
|
|
package/src/main.ts
CHANGED
|
@@ -92,11 +92,13 @@ async function runInteractiveMode(
|
|
|
92
92
|
|
|
93
93
|
await mode.init();
|
|
94
94
|
|
|
95
|
-
versionCheckPromise
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
versionCheckPromise
|
|
96
|
+
.then((newVersion) => {
|
|
97
|
+
if (newVersion) {
|
|
98
|
+
mode.showNewVersionNotification(newVersion);
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
.catch(() => {});
|
|
100
102
|
|
|
101
103
|
mode.renderInitialMessages();
|
|
102
104
|
|
|
@@ -109,7 +109,9 @@ export class LoginDialogComponent extends Container {
|
|
|
109
109
|
showManualInput(prompt: string): Promise<string> {
|
|
110
110
|
this.contentContainer.addChild(new Spacer(1));
|
|
111
111
|
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
|
|
112
|
-
this.contentContainer.
|
|
112
|
+
if (!this.contentContainer.children.includes(this.input)) {
|
|
113
|
+
this.contentContainer.addChild(this.input);
|
|
114
|
+
}
|
|
113
115
|
this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
|
|
114
116
|
this.tui.requestRender();
|
|
115
117
|
|
|
@@ -129,7 +131,9 @@ export class LoginDialogComponent extends Container {
|
|
|
129
131
|
if (placeholder) {
|
|
130
132
|
this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0));
|
|
131
133
|
}
|
|
132
|
-
this.contentContainer.
|
|
134
|
+
if (!this.contentContainer.children.includes(this.input)) {
|
|
135
|
+
this.contentContainer.addChild(this.input);
|
|
136
|
+
}
|
|
133
137
|
this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0));
|
|
134
138
|
|
|
135
139
|
this.input.setValue("");
|
|
@@ -260,6 +260,10 @@ export class ToolExecutionComponent extends Container {
|
|
|
260
260
|
): void {
|
|
261
261
|
this.result = result;
|
|
262
262
|
this.isPartial = isPartial;
|
|
263
|
+
// When tool is complete, ensure args are marked complete so spinner stops
|
|
264
|
+
if (!isPartial) {
|
|
265
|
+
this.argsComplete = true;
|
|
266
|
+
}
|
|
263
267
|
this.updateSpinnerAnimation();
|
|
264
268
|
this.updateDisplay();
|
|
265
269
|
// Convert non-PNG images to PNG for Kitty protocol (async)
|
|
@@ -168,6 +168,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
168
168
|
this.editor.onAutocompleteCancel = () => {
|
|
169
169
|
this.ui.requestRender(true);
|
|
170
170
|
};
|
|
171
|
+
this.editor.onAutocompleteUpdate = () => {
|
|
172
|
+
this.ui.requestRender(true);
|
|
173
|
+
};
|
|
171
174
|
try {
|
|
172
175
|
this.historyStorage = HistoryStorage.open();
|
|
173
176
|
this.editor.setHistoryStorage(this.historyStorage);
|