@oh-my-pi/pi-coding-agent 17.2.5 → 17.2.6
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 +18 -0
- package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-gs76k6wc.md} +18 -0
- package/dist/cli.js +3378 -3333
- package/dist/types/cli/gc-cli.d.ts +1 -0
- package/dist/types/launch/client.d.ts +9 -1
- package/dist/types/launch/protocol.d.ts +17 -0
- package/dist/types/modes/components/btw-panel.d.ts +3 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +4 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/security/contracts/schemas.d.ts +405 -403
- package/dist/types/session/agent-session-types.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +26 -2
- package/dist/types/session/launch-completion.d.ts +10 -0
- package/dist/types/session/session-entries.d.ts +15 -1
- package/dist/types/session/session-manager.d.ts +7 -0
- package/dist/types/session/yield-queue.d.ts +6 -1
- package/dist/types/tools/index.d.ts +9 -0
- package/package.json +12 -12
- package/src/cli/gc-cli.ts +641 -21
- package/src/config/model-discovery.ts +14 -14
- package/src/config/model-registry.ts +10 -8
- package/src/config/settings.ts +1 -1
- package/src/export/html/index.ts +10 -3
- package/src/export/share.ts +4 -0
- package/src/launch/broker.ts +222 -6
- package/src/launch/client.ts +161 -9
- package/src/launch/protocol.ts +52 -0
- package/src/main.ts +13 -6
- package/src/mcp/config-writer.ts +1 -1
- package/src/modes/components/btw-panel.ts +41 -4
- package/src/modes/controllers/btw-controller.ts +55 -7
- package/src/modes/controllers/command-controller.ts +31 -0
- package/src/modes/controllers/input-controller.ts +24 -7
- package/src/modes/interactive-mode.ts +16 -2
- package/src/modes/types.ts +8 -1
- package/src/prompts/session/launch-completion.md +1 -0
- package/src/sdk.ts +15 -0
- package/src/security/contracts/schemas.ts +205 -183
- package/src/security/contracts/validation.ts +5 -1
- package/src/security/store.ts +2 -2
- package/src/session/agent-session-types.ts +6 -0
- package/src/session/agent-session.ts +191 -14
- package/src/session/launch-completion.ts +37 -0
- package/src/session/session-context.ts +26 -0
- package/src/session/session-entries.ts +17 -1
- package/src/session/session-manager.ts +21 -0
- package/src/session/yield-queue.ts +121 -16
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/tools/browser/cmux/cmux-tab.ts +92 -4
- package/src/tools/hub/launch.ts +122 -6
- package/src/tools/index.ts +9 -0
- package/dist/types/config/file-lock.d.ts +0 -29
- package/src/config/file-lock.ts +0 -164
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AgentMessage, ASIDE_MESSAGE_COMMIT, ASIDE_MESSAGE_DISCARD } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
3
3
|
|
|
4
4
|
export interface YieldDispatcher<P> {
|
|
@@ -25,6 +25,17 @@ interface StoredDispatcher {
|
|
|
25
25
|
skipIdleFlush?: boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
interface StoredEntry {
|
|
29
|
+
value: unknown;
|
|
30
|
+
resolve?: () => void;
|
|
31
|
+
reject?: (error: Error) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface BuiltMessage {
|
|
35
|
+
message: AgentMessage;
|
|
36
|
+
entries: StoredEntry[];
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
function formatError(error: unknown): string {
|
|
29
40
|
return error instanceof Error ? error.message : String(error);
|
|
30
41
|
}
|
|
@@ -32,7 +43,7 @@ function formatError(error: unknown): string {
|
|
|
32
43
|
export class YieldQueue {
|
|
33
44
|
readonly #options: YieldQueueOptions;
|
|
34
45
|
readonly #dispatchers = new Map<string, StoredDispatcher>();
|
|
35
|
-
readonly #entries = new Map<string,
|
|
46
|
+
readonly #entries = new Map<string, StoredEntry[]>();
|
|
36
47
|
#idleFlushPending = false;
|
|
37
48
|
|
|
38
49
|
constructor(options: YieldQueueOptions) {
|
|
@@ -49,14 +60,27 @@ export class YieldQueue {
|
|
|
49
60
|
return () => {
|
|
50
61
|
if (this.#dispatchers.get(kind) !== stored) return;
|
|
51
62
|
this.#dispatchers.delete(kind);
|
|
63
|
+
this.#rejectEntries(this.#entries.get(kind) ?? [], new Error(`Yield queue dispatcher removed: ${kind}`));
|
|
52
64
|
this.#entries.delete(kind);
|
|
53
65
|
};
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
enqueue<P>(kind: string, entry: P): void {
|
|
69
|
+
this.#enqueue(kind, { value: entry });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
enqueueWithReceipt<P>(kind: string, entry: P): Promise<void> {
|
|
73
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
74
|
+
if (!this.#enqueue(kind, { value: entry, resolve, reject })) {
|
|
75
|
+
reject(new Error(`Yield queue entry ignored for unregistered kind: ${kind}`));
|
|
76
|
+
}
|
|
77
|
+
return promise;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#enqueue(kind: string, entry: StoredEntry): boolean {
|
|
57
81
|
if (!this.#dispatchers.has(kind)) {
|
|
58
82
|
logger.warn("Yield queue entry ignored for unregistered kind", { kind });
|
|
59
|
-
return;
|
|
83
|
+
return false;
|
|
60
84
|
}
|
|
61
85
|
let entries = this.#entries.get(kind);
|
|
62
86
|
if (!entries) {
|
|
@@ -67,6 +91,7 @@ export class YieldQueue {
|
|
|
67
91
|
if (!this.#options.isStreaming() && !this.#dispatchers.get(kind)!.skipIdleFlush) {
|
|
68
92
|
this.#scheduleIdleFlush();
|
|
69
93
|
}
|
|
94
|
+
return true;
|
|
70
95
|
}
|
|
71
96
|
|
|
72
97
|
has(kind?: string): boolean {
|
|
@@ -77,30 +102,55 @@ export class YieldQueue {
|
|
|
77
102
|
return false;
|
|
78
103
|
}
|
|
79
104
|
|
|
105
|
+
/** Arrange an idle flush for entries queued near the end of a streaming run. */
|
|
106
|
+
requestIdleFlush(): void {
|
|
107
|
+
for (const [kind, dispatcher] of this.#dispatchers) {
|
|
108
|
+
if (!dispatcher.skipIdleFlush && this.has(kind)) {
|
|
109
|
+
this.#scheduleIdleFlush();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
async flush(mode: YieldFlushMode): Promise<void> {
|
|
81
116
|
if (mode === "idle") {
|
|
82
117
|
this.#idleFlushPending = false;
|
|
83
118
|
}
|
|
84
|
-
const idleMessages:
|
|
119
|
+
const idleMessages: BuiltMessage[] = [];
|
|
85
120
|
for (const [kind, dispatcher] of this.#dispatchers) {
|
|
121
|
+
if (mode === "idle" && dispatcher.skipIdleFlush) continue;
|
|
86
122
|
const entries = this.#drain(kind);
|
|
87
123
|
if (entries.length === 0) continue;
|
|
88
|
-
const
|
|
89
|
-
if (!
|
|
124
|
+
const built = this.#build(kind, dispatcher, entries);
|
|
125
|
+
if (!built) continue;
|
|
90
126
|
if (mode === "streaming") {
|
|
91
127
|
try {
|
|
92
|
-
this.#options.injectStreaming
|
|
128
|
+
if (!this.#options.injectStreaming) throw new Error("Streaming injection is unavailable");
|
|
129
|
+
this.#options.injectStreaming(built.message);
|
|
130
|
+
this.#resolveEntries(built.entries);
|
|
93
131
|
} catch (error) {
|
|
132
|
+
const dispatchError = error instanceof Error ? error : new Error(String(error));
|
|
133
|
+
this.#rejectEntries(built.entries, dispatchError);
|
|
94
134
|
logger.warn("Yield queue streaming dispatch failed", { kind, error: formatError(error) });
|
|
95
135
|
}
|
|
96
136
|
} else {
|
|
97
|
-
idleMessages.push(
|
|
137
|
+
idleMessages.push(built);
|
|
98
138
|
}
|
|
99
139
|
}
|
|
100
140
|
if (mode === "idle" && idleMessages.length > 0) {
|
|
141
|
+
for (const item of idleMessages) this.#attachEntrySettlement(item);
|
|
101
142
|
try {
|
|
102
|
-
await this.#options.injectIdle(idleMessages);
|
|
143
|
+
await this.#options.injectIdle(idleMessages.map(item => item.message));
|
|
144
|
+
for (const item of idleMessages) {
|
|
145
|
+
(item.message as AgentMessage & { [ASIDE_MESSAGE_COMMIT]?: () => void })[ASIDE_MESSAGE_COMMIT]?.();
|
|
146
|
+
}
|
|
103
147
|
} catch (error) {
|
|
148
|
+
const dispatchError = error instanceof Error ? error : new Error(String(error));
|
|
149
|
+
for (const item of idleMessages) {
|
|
150
|
+
(item.message as AgentMessage & { [ASIDE_MESSAGE_DISCARD]?: (error: Error) => void })[
|
|
151
|
+
ASIDE_MESSAGE_DISCARD
|
|
152
|
+
]?.(dispatchError);
|
|
153
|
+
}
|
|
104
154
|
logger.warn("Yield queue idle dispatch failed", { error: formatError(error) });
|
|
105
155
|
}
|
|
106
156
|
}
|
|
@@ -119,7 +169,12 @@ export class YieldQueue {
|
|
|
119
169
|
for (const [kind, dispatcher] of this.#dispatchers) {
|
|
120
170
|
const entries = this.#drain(kind);
|
|
121
171
|
if (entries.length === 0) continue;
|
|
122
|
-
thunks.push(() =>
|
|
172
|
+
thunks.push(() => {
|
|
173
|
+
const built = this.#build(kind, dispatcher, entries);
|
|
174
|
+
if (!built) return null;
|
|
175
|
+
this.#attachEntrySettlement(built);
|
|
176
|
+
return built.message;
|
|
177
|
+
});
|
|
123
178
|
}
|
|
124
179
|
return thunks;
|
|
125
180
|
}
|
|
@@ -127,14 +182,22 @@ export class YieldQueue {
|
|
|
127
182
|
/** Drop queued entries. With `kind`, drop only that kind's entries (leaving
|
|
128
183
|
* any pending idle-flush for other kinds intact); otherwise drop everything. */
|
|
129
184
|
clear(kind?: string): void {
|
|
185
|
+
const error = new Error("Yield queue entry cleared before dispatch");
|
|
130
186
|
if (kind !== undefined) {
|
|
187
|
+
this.#rejectEntries(this.#entries.get(kind) ?? [], error);
|
|
131
188
|
this.#entries.delete(kind);
|
|
132
189
|
return;
|
|
133
190
|
}
|
|
191
|
+
for (const entries of this.#entries.values()) this.#rejectEntries(entries, error);
|
|
134
192
|
this.#entries.clear();
|
|
135
193
|
this.#idleFlushPending = false;
|
|
136
194
|
}
|
|
137
195
|
|
|
196
|
+
/** Clear a scheduled-flush latch when its host task is cancelled before running. */
|
|
197
|
+
cancelIdleFlushScheduling(): void {
|
|
198
|
+
this.#idleFlushPending = false;
|
|
199
|
+
}
|
|
200
|
+
|
|
138
201
|
#scheduleIdleFlush(): void {
|
|
139
202
|
if (this.#idleFlushPending) return;
|
|
140
203
|
this.#idleFlushPending = true;
|
|
@@ -150,34 +213,76 @@ export class YieldQueue {
|
|
|
150
213
|
}
|
|
151
214
|
}
|
|
152
215
|
|
|
153
|
-
#drain(kind: string):
|
|
216
|
+
#drain(kind: string): StoredEntry[] {
|
|
154
217
|
const entries = this.#entries.get(kind);
|
|
155
218
|
if (!entries || entries.length === 0) return [];
|
|
156
219
|
this.#entries.delete(kind);
|
|
157
220
|
return entries;
|
|
158
221
|
}
|
|
159
222
|
|
|
160
|
-
#build(kind: string, dispatcher: StoredDispatcher, entries:
|
|
161
|
-
const survivors:
|
|
223
|
+
#build(kind: string, dispatcher: StoredDispatcher, entries: StoredEntry[]): BuiltMessage | null {
|
|
224
|
+
const survivors: StoredEntry[] = [];
|
|
162
225
|
for (const entry of entries) {
|
|
163
226
|
if (dispatcher.isStale) {
|
|
164
227
|
let stale: boolean;
|
|
165
228
|
try {
|
|
166
|
-
stale = dispatcher.isStale(entry);
|
|
229
|
+
stale = dispatcher.isStale(entry.value);
|
|
167
230
|
} catch (error) {
|
|
231
|
+
const staleError = error instanceof Error ? error : new Error(String(error));
|
|
232
|
+
entry.reject?.(staleError);
|
|
168
233
|
logger.warn("Yield queue stale check failed", { kind, error: formatError(error) });
|
|
169
234
|
continue;
|
|
170
235
|
}
|
|
171
|
-
if (stale)
|
|
236
|
+
if (stale) {
|
|
237
|
+
entry.reject?.(new Error(`Yield queue entry became stale: ${kind}`));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
172
240
|
}
|
|
173
241
|
survivors.push(entry);
|
|
174
242
|
}
|
|
175
243
|
if (survivors.length === 0) return null;
|
|
176
244
|
try {
|
|
177
|
-
|
|
245
|
+
const message = dispatcher.build(survivors.map(entry => entry.value));
|
|
246
|
+
if (!message) {
|
|
247
|
+
this.#rejectEntries(survivors, new Error(`Yield queue dispatcher skipped entry: ${kind}`));
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return { message, entries: survivors };
|
|
178
251
|
} catch (error) {
|
|
252
|
+
const buildError = error instanceof Error ? error : new Error(String(error));
|
|
253
|
+
this.#rejectEntries(survivors, buildError);
|
|
179
254
|
logger.warn("Yield queue build failed", { kind, error: formatError(error) });
|
|
180
255
|
return null;
|
|
181
256
|
}
|
|
182
257
|
}
|
|
258
|
+
|
|
259
|
+
#attachEntrySettlement(built: BuiltMessage): void {
|
|
260
|
+
let settled = false;
|
|
261
|
+
Object.defineProperties(built.message, {
|
|
262
|
+
[ASIDE_MESSAGE_COMMIT]: {
|
|
263
|
+
configurable: true,
|
|
264
|
+
value: () => {
|
|
265
|
+
if (settled) return;
|
|
266
|
+
settled = true;
|
|
267
|
+
this.#resolveEntries(built.entries);
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
[ASIDE_MESSAGE_DISCARD]: {
|
|
271
|
+
configurable: true,
|
|
272
|
+
value: (error: Error) => {
|
|
273
|
+
if (settled) return;
|
|
274
|
+
settled = true;
|
|
275
|
+
this.#rejectEntries(built.entries, error);
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#resolveEntries(entries: StoredEntry[]): void {
|
|
282
|
+
for (const entry of entries) entry.resolve?.();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#rejectEntries(entries: StoredEntry[], error: Error): void {
|
|
286
|
+
for (const entry of entries) entry.reject?.(error);
|
|
287
|
+
}
|
|
183
288
|
}
|
|
@@ -1719,6 +1719,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1719
1719
|
await runtime.ctx.handleFreshCommand();
|
|
1720
1720
|
},
|
|
1721
1721
|
},
|
|
1722
|
+
{
|
|
1723
|
+
name: "reset",
|
|
1724
|
+
description: "Reset the conversation context in place, keeping the session",
|
|
1725
|
+
getTuiAutocompleteDescription: runtime =>
|
|
1726
|
+
runtime.ctx.session.isStreaming ? "Reset: unavailable while streaming" : "Reset: drop context, keep session",
|
|
1727
|
+
handleTui: async (_command, runtime) => {
|
|
1728
|
+
runtime.ctx.editor.setText("");
|
|
1729
|
+
await runtime.ctx.handleResetContextCommand();
|
|
1730
|
+
},
|
|
1731
|
+
},
|
|
1722
1732
|
{
|
|
1723
1733
|
name: "drop",
|
|
1724
1734
|
description: "Delete the current session and start a new one",
|
|
@@ -254,6 +254,65 @@ export interface RunCmuxCodeOptions {
|
|
|
254
254
|
snapshot: SessionSnapshot;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
interface ActiveCmuxRun {
|
|
258
|
+
filename: string;
|
|
259
|
+
floatingRejections: unknown[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const RECENT_CMUX_RUN_FILES_MAX = 256;
|
|
263
|
+
const activeCmuxRuns = new Map<string, ActiveCmuxRun>();
|
|
264
|
+
const recentCmuxRunFiles = new Set<string>();
|
|
265
|
+
|
|
266
|
+
function consumeCmuxRunRejection(reason: unknown): boolean {
|
|
267
|
+
// cmux runs guest JS in the shared main-process realm (TTS/STT/MCP and other
|
|
268
|
+
// subsystems live here too), so — like the eval inline fallback — only a
|
|
269
|
+
// guest-file stack frame can safely attribute a rejection. A stackless or
|
|
270
|
+
// non-run-stack reason is indistinguishable from a subsystem failure and
|
|
271
|
+
// keeps the default fatal path; worker isolation is the long-term fix.
|
|
272
|
+
const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined;
|
|
273
|
+
if (!stack) return false;
|
|
274
|
+
|
|
275
|
+
let owner: ActiveCmuxRun | undefined;
|
|
276
|
+
let ownerIndex = -1;
|
|
277
|
+
for (const run of activeCmuxRuns.values()) {
|
|
278
|
+
const index = stack.lastIndexOf(run.filename);
|
|
279
|
+
if (index > ownerIndex) {
|
|
280
|
+
ownerIndex = index;
|
|
281
|
+
owner = run;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (owner) {
|
|
285
|
+
owner.floatingRejections.push(reason);
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let recent: string | undefined;
|
|
290
|
+
let recentIndex = -1;
|
|
291
|
+
for (const filename of recentCmuxRunFiles) {
|
|
292
|
+
const index = stack.lastIndexOf(filename);
|
|
293
|
+
if (index > recentIndex) {
|
|
294
|
+
recentIndex = index;
|
|
295
|
+
recent = filename;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (!recent) return false;
|
|
299
|
+
logger.warn("Unhandled rejection from a finished cmux browser run (missing await?)", {
|
|
300
|
+
filename: recent,
|
|
301
|
+
error: reason,
|
|
302
|
+
});
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function rememberCmuxRunFile(filename: string): void {
|
|
307
|
+
recentCmuxRunFiles.delete(filename);
|
|
308
|
+
recentCmuxRunFiles.add(filename);
|
|
309
|
+
if (recentCmuxRunFiles.size <= RECENT_CMUX_RUN_FILES_MAX) return;
|
|
310
|
+
const oldest = recentCmuxRunFiles.values().next().value;
|
|
311
|
+
if (oldest !== undefined) recentCmuxRunFiles.delete(oldest);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
postmortem.interceptUnhandledRejections(consumeCmuxRunRejection);
|
|
315
|
+
|
|
257
316
|
export class CmuxTab {
|
|
258
317
|
readonly #client: CmuxSocketClient;
|
|
259
318
|
readonly #surfaceId: string;
|
|
@@ -1312,6 +1371,9 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1312
1371
|
const output = new RunOutput();
|
|
1313
1372
|
const screenshots: ScreenshotResult[] = [];
|
|
1314
1373
|
const runId = crypto.randomUUID();
|
|
1374
|
+
const filename = `cmux-run-${runId}.js`;
|
|
1375
|
+
const activeRun: ActiveCmuxRun = { filename, floatingRejections: [] };
|
|
1376
|
+
activeCmuxRuns.set(filename, activeRun);
|
|
1315
1377
|
tab.setRunContext({ session: opts.snapshot, output, screenshots, signal, timeoutMs: opts.timeoutMs });
|
|
1316
1378
|
|
|
1317
1379
|
const { promise: cancelRejection, reject } = Promise.withResolvers<never>();
|
|
@@ -1378,14 +1440,40 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1378
1440
|
};
|
|
1379
1441
|
// Like the inline worker fallback, cmux runs user JS in-process: awaited cmux/tool calls
|
|
1380
1442
|
// observe this abort signal, but a synchronous infinite loop cannot be interrupted here.
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1443
|
+
let returnValue: unknown;
|
|
1444
|
+
let runError: unknown;
|
|
1445
|
+
let runFailed = false;
|
|
1446
|
+
try {
|
|
1447
|
+
returnValue = await Promise.race([
|
|
1448
|
+
runtime.run(opts.code, filename, hooks, { runId, cwd: opts.snapshot.cwd }),
|
|
1449
|
+
cancelRejection,
|
|
1450
|
+
]);
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
runFailed = true;
|
|
1453
|
+
runError = error;
|
|
1454
|
+
}
|
|
1455
|
+
// Let rejection callbacks run while this run can still own guest-created promises.
|
|
1456
|
+
await Bun.sleep(0);
|
|
1457
|
+
if (runFailed) {
|
|
1458
|
+
for (const reason of activeRun.floatingRejections) {
|
|
1459
|
+
logger.warn("Unhandled rejection accompanied a failed cmux browser run", { filename, error: reason });
|
|
1460
|
+
}
|
|
1461
|
+
throw runError;
|
|
1462
|
+
}
|
|
1463
|
+
if (activeRun.floatingRejections.length > 0) {
|
|
1464
|
+
const messages = activeRun.floatingRejections.map(reason =>
|
|
1465
|
+
reason instanceof Error ? reason.message : String(reason),
|
|
1466
|
+
);
|
|
1467
|
+
throw new ToolError(`Unhandled rejection (missing await?): ${messages.join("\n[unhandled rejection] ")}`, {
|
|
1468
|
+
rejections: activeRun.floatingRejections,
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1385
1471
|
return { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots };
|
|
1386
1472
|
} finally {
|
|
1387
1473
|
signal.removeEventListener("abort", onAbort);
|
|
1388
1474
|
runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended")));
|
|
1475
|
+
activeCmuxRuns.delete(filename);
|
|
1476
|
+
rememberCmuxRunFile(filename);
|
|
1389
1477
|
tab.clearRunContext();
|
|
1390
1478
|
}
|
|
1391
1479
|
}
|
package/src/tools/hub/launch.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
10
10
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
11
11
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
|
|
13
|
-
import { daemonClientForProject } from "../../launch/client";
|
|
13
|
+
import { type DaemonBrokerClient, DaemonBrokerRejectedError, daemonClientForProject } from "../../launch/client";
|
|
14
14
|
import type { DaemonOperation, DaemonRpcResult, DaemonSnapshot, DaemonSpec, DaemonState } from "../../launch/protocol";
|
|
15
15
|
import { renderTerminalOutputIsolated } from "../../launch/terminal-output-worker-client";
|
|
16
16
|
import type { Theme, ThemeColor } from "../../modes/theme/theme";
|
|
@@ -35,6 +35,81 @@ import {
|
|
|
35
35
|
import { styleTerminalRow } from "../terminal-output";
|
|
36
36
|
import { ToolError } from "../tool-errors";
|
|
37
37
|
|
|
38
|
+
interface CompletionRegistration {
|
|
39
|
+
inFlight: number;
|
|
40
|
+
retained: boolean;
|
|
41
|
+
active: boolean;
|
|
42
|
+
cleanup: (preservePending?: boolean) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CompletionLease {
|
|
46
|
+
retain: () => void;
|
|
47
|
+
reject: (preservePending?: boolean) => void;
|
|
48
|
+
hasConcurrentRequest: () => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const completionRegistrations = new WeakMap<
|
|
52
|
+
ToolSession,
|
|
53
|
+
Map<DaemonBrokerClient, Map<string, CompletionRegistration>>
|
|
54
|
+
>();
|
|
55
|
+
|
|
56
|
+
function registerCompletionSink(
|
|
57
|
+
session: ToolSession,
|
|
58
|
+
client: DaemonBrokerClient,
|
|
59
|
+
owner: string,
|
|
60
|
+
): CompletionLease | undefined {
|
|
61
|
+
if (!session.queueLaunchCompletion) return undefined;
|
|
62
|
+
let clients = completionRegistrations.get(session);
|
|
63
|
+
if (!clients) {
|
|
64
|
+
clients = new Map();
|
|
65
|
+
completionRegistrations.set(session, clients);
|
|
66
|
+
}
|
|
67
|
+
let owners = clients.get(client);
|
|
68
|
+
if (!owners) {
|
|
69
|
+
owners = new Map();
|
|
70
|
+
clients.set(client, owners);
|
|
71
|
+
}
|
|
72
|
+
let registration = owners.get(owner);
|
|
73
|
+
if (!registration) {
|
|
74
|
+
const unregister = client.onCompletion(owner, notification => {
|
|
75
|
+
if (session.isDisposed?.()) throw new Error("Session disposed before launch completion delivery");
|
|
76
|
+
const delivery = session.queueLaunchCompletion?.(notification);
|
|
77
|
+
if (!delivery) throw new Error("Session cannot accept launch completion delivery");
|
|
78
|
+
return delivery;
|
|
79
|
+
});
|
|
80
|
+
let unregisterDispose: (() => void) | void;
|
|
81
|
+
let unregisterSessionChange: (() => void) | void;
|
|
82
|
+
const cleanup = (preservePending = false): void => {
|
|
83
|
+
if (!registration?.active) return;
|
|
84
|
+
registration.active = false;
|
|
85
|
+
unregister({ preservePending });
|
|
86
|
+
unregisterDispose?.();
|
|
87
|
+
unregisterSessionChange?.();
|
|
88
|
+
owners.delete(owner);
|
|
89
|
+
if (owners.size === 0) clients.delete(client);
|
|
90
|
+
if (clients.size === 0) completionRegistrations.delete(session);
|
|
91
|
+
};
|
|
92
|
+
registration = { inFlight: 0, retained: false, active: true, cleanup };
|
|
93
|
+
owners.set(owner, registration);
|
|
94
|
+
unregisterDispose = session.registerDisposeCallback?.(() => cleanup(true));
|
|
95
|
+
unregisterSessionChange = session.registerSessionChangeCallback?.(() => cleanup(true));
|
|
96
|
+
}
|
|
97
|
+
registration.inFlight++;
|
|
98
|
+
let settled = false;
|
|
99
|
+
const settle = (retain: boolean, preservePending = false): void => {
|
|
100
|
+
if (settled || !registration.active) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
registration.inFlight--;
|
|
103
|
+
if (retain) registration.retained = true;
|
|
104
|
+
if (!registration.retained && registration.inFlight === 0) registration.cleanup(preservePending);
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
retain: () => settle(true),
|
|
108
|
+
hasConcurrentRequest: () => registration.active && registration.inFlight > 1,
|
|
109
|
+
reject: preservePending => settle(false, preservePending),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
38
113
|
/** Broker-facing launch parameters; the hub adapts its `ps` op to `list` before calling in. */
|
|
39
114
|
export interface LaunchParams {
|
|
40
115
|
op: "start" | "list" | "logs" | "wait" | "send" | "stop" | "restart" | "describe";
|
|
@@ -321,11 +396,52 @@ export async function executeLaunch(
|
|
|
321
396
|
signal?: AbortSignal,
|
|
322
397
|
): Promise<AgentToolResult<LaunchToolDetails>> {
|
|
323
398
|
const client = await daemonClientForProject(session.cwd);
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
399
|
+
const operation = operationFor(params, session);
|
|
400
|
+
const owner = operation.op === "start" ? operation.owner : undefined;
|
|
401
|
+
const resumedOwner = params.op !== "start" ? (session.getSessionId?.() ?? undefined) : undefined;
|
|
402
|
+
const completionLease = owner
|
|
403
|
+
? registerCompletionSink(session, client, owner)
|
|
404
|
+
: resumedOwner
|
|
405
|
+
? registerCompletionSink(session, client, resumedOwner)
|
|
406
|
+
: undefined;
|
|
407
|
+
try {
|
|
408
|
+
const result = await client.request(operation, signal);
|
|
409
|
+
const sessionOwner = session.getSessionId?.();
|
|
410
|
+
let resumedDaemonFound = false;
|
|
411
|
+
const daemons =
|
|
412
|
+
result.op === "list" ? result.daemons : "daemon" in result && result.daemon ? [result.daemon] : [];
|
|
413
|
+
for (const daemon of daemons) {
|
|
414
|
+
if (!daemon.owner || daemon.owner !== sessionOwner || TERMINAL_STATES[daemon.state]) continue;
|
|
415
|
+
resumedDaemonFound = true;
|
|
416
|
+
if (daemon.owner !== resumedOwner) registerCompletionSink(session, client, daemon.owner)?.retain();
|
|
417
|
+
}
|
|
418
|
+
if (params.op === "list" && resumedOwner && !resumedDaemonFound) completionLease?.reject(true);
|
|
419
|
+
else completionLease?.retain();
|
|
420
|
+
return {
|
|
421
|
+
content: [{ type: "text", text: replaceTabs(toolContent(result, params)) }],
|
|
422
|
+
details: await toolDetails(result, params),
|
|
423
|
+
};
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (error instanceof DaemonBrokerRejectedError && owner) {
|
|
426
|
+
if (completionLease?.hasConcurrentRequest()) {
|
|
427
|
+
completionLease.reject();
|
|
428
|
+
} else {
|
|
429
|
+
try {
|
|
430
|
+
const listed = await client.request({ op: "list" }, signal);
|
|
431
|
+
const ownerStillRunning =
|
|
432
|
+
listed.op === "list" &&
|
|
433
|
+
listed.daemons.some(daemon => daemon.owner === owner && !TERMINAL_STATES[daemon.state]);
|
|
434
|
+
if (ownerStillRunning) completionLease?.retain();
|
|
435
|
+
else completionLease?.reject(true);
|
|
436
|
+
} catch {
|
|
437
|
+
completionLease?.retain();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} else {
|
|
441
|
+
completionLease?.retain();
|
|
442
|
+
}
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
329
445
|
}
|
|
330
446
|
|
|
331
447
|
// =============================================================================
|
package/src/tools/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { GoalModeState, GoalRuntime } from "../goals";
|
|
|
16
16
|
import { GoalTool } from "../goals/tools/goal-tool";
|
|
17
17
|
import type { HindsightSessionState } from "../hindsight/state";
|
|
18
18
|
import type { LocalProtocolOptions } from "../internal-urls";
|
|
19
|
+
import type { DaemonCompletionNotification } from "../launch/protocol";
|
|
19
20
|
import { LspTool } from "../lsp";
|
|
20
21
|
import type { MCPManager } from "../mcp";
|
|
21
22
|
import type { MnemopiSessionState } from "../mnemopi/state";
|
|
@@ -155,6 +156,8 @@ export interface ToolSession {
|
|
|
155
156
|
additionalDirectories?: string[];
|
|
156
157
|
/** Whether UI is available */
|
|
157
158
|
hasUI: boolean;
|
|
159
|
+
/** Whether this session has begun disposal. */
|
|
160
|
+
isDisposed?: () => boolean;
|
|
158
161
|
/**
|
|
159
162
|
* Suppress the spawn specialization/coordination advisory appended to `task`
|
|
160
163
|
* results. Set by internal/programmatic callers (e.g. the commit agent's
|
|
@@ -380,6 +383,12 @@ export interface ToolSession {
|
|
|
380
383
|
|
|
381
384
|
/** Queue a hidden message to be injected at the next agent turn. */
|
|
382
385
|
queueDeferredMessage?(message: CustomMessage): void;
|
|
386
|
+
/** Queue a broker supervised-process completion for the owning session. */
|
|
387
|
+
queueLaunchCompletion?(notification: DaemonCompletionNotification): Promise<void>;
|
|
388
|
+
/** Register cleanup that runs when this session is disposed; returns a handle that removes the cleanup. */
|
|
389
|
+
registerDisposeCallback?(callback: () => void): (() => void) | void;
|
|
390
|
+
/** Register cleanup that runs when this ToolSession adopts a different session ID. */
|
|
391
|
+
registerSessionChangeCallback?(callback: () => void): (() => void) | void;
|
|
383
392
|
/** Queue late LSP diagnostics (arrived after an edit/write returned) to be shown
|
|
384
393
|
* in the transcript and delivered to the model at the next yield, like background
|
|
385
394
|
* job results. */
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
export interface FileLockOptions {
|
|
2
|
-
staleMs?: number;
|
|
3
|
-
retries?: number;
|
|
4
|
-
retryDelayMs?: number;
|
|
5
|
-
}
|
|
6
|
-
interface LockInfo {
|
|
7
|
-
pid: number;
|
|
8
|
-
timestamp: number;
|
|
9
|
-
token: string;
|
|
10
|
-
}
|
|
11
|
-
declare function getLockPath(filePath: string): string;
|
|
12
|
-
declare function readLockInfo(lockPath: string): Promise<LockInfo | null>;
|
|
13
|
-
declare function isLockStale(lockPath: string, staleMs: number): Promise<boolean>;
|
|
14
|
-
declare function tryAcquireLock(lockPath: string): Promise<string | null>;
|
|
15
|
-
declare function releaseLock(lockPath: string, expectedToken?: string): Promise<void>;
|
|
16
|
-
export declare function withFileLock<T>(filePath: string, fn: () => Promise<T>, options?: FileLockOptions): Promise<T>;
|
|
17
|
-
/**
|
|
18
|
-
* Test-only handles for the internal lock primitives. These are NOT part of
|
|
19
|
-
* the public API — they exist so the contract tests can validate token-keyed
|
|
20
|
-
* release semantics and the mkdir-race window without re-implementing them.
|
|
21
|
-
*/
|
|
22
|
-
export declare const __internalsForTesting: {
|
|
23
|
-
tryAcquireLock: typeof tryAcquireLock;
|
|
24
|
-
releaseLock: typeof releaseLock;
|
|
25
|
-
readLockInfo: typeof readLockInfo;
|
|
26
|
-
isLockStale: typeof isLockStale;
|
|
27
|
-
getLockPath: typeof getLockPath;
|
|
28
|
-
};
|
|
29
|
-
export {};
|