@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-gs76k6wc.md} +18 -0
  3. package/dist/cli.js +3378 -3333
  4. package/dist/types/cli/gc-cli.d.ts +1 -0
  5. package/dist/types/launch/client.d.ts +9 -1
  6. package/dist/types/launch/protocol.d.ts +17 -0
  7. package/dist/types/modes/components/btw-panel.d.ts +3 -0
  8. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  9. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  10. package/dist/types/modes/interactive-mode.d.ts +4 -1
  11. package/dist/types/modes/types.d.ts +3 -1
  12. package/dist/types/security/contracts/schemas.d.ts +405 -403
  13. package/dist/types/session/agent-session-types.d.ts +5 -0
  14. package/dist/types/session/agent-session.d.ts +26 -2
  15. package/dist/types/session/launch-completion.d.ts +10 -0
  16. package/dist/types/session/session-entries.d.ts +15 -1
  17. package/dist/types/session/session-manager.d.ts +7 -0
  18. package/dist/types/session/yield-queue.d.ts +6 -1
  19. package/dist/types/tools/index.d.ts +9 -0
  20. package/package.json +12 -12
  21. package/src/cli/gc-cli.ts +641 -21
  22. package/src/config/model-discovery.ts +14 -14
  23. package/src/config/model-registry.ts +10 -8
  24. package/src/config/settings.ts +1 -1
  25. package/src/export/html/index.ts +10 -3
  26. package/src/export/share.ts +4 -0
  27. package/src/launch/broker.ts +222 -6
  28. package/src/launch/client.ts +161 -9
  29. package/src/launch/protocol.ts +52 -0
  30. package/src/main.ts +13 -6
  31. package/src/mcp/config-writer.ts +1 -1
  32. package/src/modes/components/btw-panel.ts +41 -4
  33. package/src/modes/controllers/btw-controller.ts +55 -7
  34. package/src/modes/controllers/command-controller.ts +31 -0
  35. package/src/modes/controllers/input-controller.ts +24 -7
  36. package/src/modes/interactive-mode.ts +16 -2
  37. package/src/modes/types.ts +8 -1
  38. package/src/prompts/session/launch-completion.md +1 -0
  39. package/src/sdk.ts +15 -0
  40. package/src/security/contracts/schemas.ts +205 -183
  41. package/src/security/contracts/validation.ts +5 -1
  42. package/src/security/store.ts +2 -2
  43. package/src/session/agent-session-types.ts +6 -0
  44. package/src/session/agent-session.ts +191 -14
  45. package/src/session/launch-completion.ts +37 -0
  46. package/src/session/session-context.ts +26 -0
  47. package/src/session/session-entries.ts +17 -1
  48. package/src/session/session-manager.ts +21 -0
  49. package/src/session/yield-queue.ts +121 -16
  50. package/src/slash-commands/builtin-registry.ts +10 -0
  51. package/src/tools/browser/cmux/cmux-tab.ts +92 -4
  52. package/src/tools/hub/launch.ts +122 -6
  53. package/src/tools/index.ts +9 -0
  54. package/dist/types/config/file-lock.d.ts +0 -29
  55. package/src/config/file-lock.ts +0 -164
@@ -1,4 +1,4 @@
1
- import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
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, unknown[]>();
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: AgentMessage[] = [];
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 message = this.#build(kind, dispatcher, entries);
89
- if (!message) continue;
124
+ const built = this.#build(kind, dispatcher, entries);
125
+ if (!built) continue;
90
126
  if (mode === "streaming") {
91
127
  try {
92
- this.#options.injectStreaming?.(message);
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(message);
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(() => this.#build(kind, dispatcher, entries));
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): unknown[] {
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: unknown[]): AgentMessage | null {
161
- const survivors: unknown[] = [];
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) continue;
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
- return dispatcher.build(survivors);
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
- const returnValue = await Promise.race([
1382
- runtime.run(opts.code, `cmux-run-${runId}.js`, hooks, { runId, cwd: opts.snapshot.cwd }),
1383
- cancelRejection,
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
  }
@@ -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 result = await client.request(operationFor(params, session), signal);
325
- return {
326
- content: [{ type: "text", text: replaceTabs(toolContent(result, params)) }],
327
- details: await toolDetails(result, params),
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
  // =============================================================================
@@ -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 {};