jeo-code 0.12.13 → 0.12.16

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 (64) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.ja.md +33 -9
  3. package/README.ko.md +33 -9
  4. package/README.md +33 -9
  5. package/README.zh.md +33 -9
  6. package/package.json +4 -2
  7. package/src/agent/browser-tool.ts +9 -1
  8. package/src/agent/config-schema.ts +8 -0
  9. package/src/agent/context-files.ts +19 -0
  10. package/src/agent/engine.ts +25 -6
  11. package/src/agent/eval-tool.ts +6 -0
  12. package/src/agent/goal-verifier.ts +30 -6
  13. package/src/agent/hooks.ts +52 -4
  14. package/src/agent/job-registry.ts +110 -18
  15. package/src/agent/job-tool.ts +43 -9
  16. package/src/agent/loop-guards.ts +17 -2
  17. package/src/agent/memory.ts +151 -112
  18. package/src/agent/monitor-registry.ts +142 -30
  19. package/src/agent/monitor-tool.ts +34 -6
  20. package/src/agent/notify/discord-daemon.ts +135 -42
  21. package/src/agent/notify/session-endpoint.ts +27 -5
  22. package/src/agent/sandbox/assets.d.ts +4 -0
  23. package/src/agent/sandbox/authority.ts +358 -0
  24. package/src/agent/sandbox/bundle.ts +40 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +293 -0
  27. package/src/agent/sandbox/hands-source.text +14548 -0
  28. package/src/agent/sandbox/hands-worker.ts +168 -0
  29. package/src/agent/sandbox/policy.ts +172 -0
  30. package/src/agent/sandbox/rpc.ts +90 -0
  31. package/src/agent/sandbox/supervisor.ts +210 -0
  32. package/src/agent/sandbox/types.ts +77 -0
  33. package/src/agent/shell-lease.ts +190 -0
  34. package/src/agent/state.ts +167 -0
  35. package/src/agent/subagents.ts +4 -3
  36. package/src/agent/task-tool.ts +8 -0
  37. package/src/agent/todo-tool.ts +30 -1
  38. package/src/agent/tool-schemas.ts +13 -0
  39. package/src/ai/model-discovery.ts +12 -0
  40. package/src/ai/providers/anthropic.ts +27 -2
  41. package/src/ai/providers/antigravity.ts +10 -2
  42. package/src/ai/providers/gemini.ts +20 -16
  43. package/src/ai/providers/ollama.ts +14 -0
  44. package/src/ai/providers/openai-responses.ts +22 -5
  45. package/src/ai/providers/openai.ts +23 -40
  46. package/src/cli/runner.ts +3 -0
  47. package/src/commands/approve.ts +37 -13
  48. package/src/commands/launch/flags.ts +2 -1
  49. package/src/commands/launch/jobs-slash.ts +1 -1
  50. package/src/commands/launch.ts +275 -35
  51. package/src/commands/messenger.ts +107 -39
  52. package/src/commands/notify.ts +2 -2
  53. package/src/commands/team.ts +44 -12
  54. package/src/commands/ultragoal.ts +6 -16
  55. package/src/messenger/activity.ts +2 -1
  56. package/src/messenger/config.ts +78 -10
  57. package/src/messenger/discord-setup.ts +216 -34
  58. package/src/messenger/discord.ts +37 -43
  59. package/src/messenger/gateway.ts +85 -37
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +5 -2
  63. package/src/skills/catalog.ts +207 -0
  64. package/src/util/retry.ts +12 -0
@@ -1,4 +1,5 @@
1
1
  import { MessengerHttpError } from "./http";
2
+ import { DiscordModelSelectionError } from "./model-selection";
2
3
  import type {
3
4
  MessengerActivityReporter,
4
5
  MessengerAdapter,
@@ -173,7 +174,7 @@ export type MessengerSessionIdentity = readonly [
173
174
  channel: string,
174
175
  thread: OptionalIdentity,
175
176
  conversation: OptionalIdentity,
176
- user: string,
177
+ user: OptionalIdentity,
177
178
  ];
178
179
 
179
180
  function optionalIdentity(name: string, value: string | undefined): OptionalIdentity {
@@ -201,7 +202,7 @@ export function messengerSessionIdentity(message: MessengerMessage): MessengerSe
201
202
  message.parentChannelId ?? message.channelId,
202
203
  optionalIdentity("thread", message.threadId),
203
204
  optionalIdentity("conversation", message.conversationId),
204
- message.senderId,
205
+ message.platform === "discord" && message.threadId !== undefined ? ["absent"] : ["present", message.senderId],
205
206
  ];
206
207
  }
207
208
 
@@ -215,9 +216,14 @@ export function messengerSessionRoute(cwd: string, message: MessengerMessage): s
215
216
  return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
216
217
  }
217
218
 
218
- function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
219
+ /**
220
+ * Admission lane. A top-level message has no thread yet, so only its raw channel can order it against its
221
+ * neighbours; anything already resolved is ordered by its own conversation. A lane is held only until the request
222
+ * is queued, never while it runs, so it cannot serialize requests that end up in different threads.
223
+ */
224
+ function admissionKey(message: MessengerMessage, adapter: MessengerAdapter): string {
219
225
  if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
220
- return JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
226
+ return JSON.stringify([message.platform, message.accountId, message.guildId, message.channelId, message.platform === "discord" ? null : message.senderId]);
221
227
  }
222
228
  return messengerConversationKey(message);
223
229
  }
@@ -242,12 +248,15 @@ export function isMessengerMessageAllowed(message: MessengerMessage, config: Mes
242
248
  }
243
249
 
244
250
  interface PendingMessage {
251
+ /** Prepared destination: replies, deliveries and activity target it. */
245
252
  message: MessengerMessage;
246
253
  adapter: MessengerAdapter;
247
254
  signal: AbortSignal;
248
255
  resolve: () => void;
249
- /** Requests sharing a key never run at the same time. */
256
+ /** Destination conversation key; requests sharing it never run at the same time. */
250
257
  key: string;
258
+ /** Status reactions on the user's original message. */
259
+ status: (state: "processing" | "success" | "failure") => void;
251
260
  }
252
261
 
253
262
  export interface MessengerGateway {
@@ -259,10 +268,13 @@ export interface MessengerGatewayOptions {
259
268
  /** Minimum spacing between in-place activity edits for one request. */
260
269
  activityIntervalMs?: number;
261
270
  /**
262
- * Requests processed at once (default 1). Requests of one prepared conversation still answer and deliver in
263
- * order; above 1 the reply implementation must accept concurrent turns for different conversations.
271
+ * Requests processed at once (default 1). Requests of one conversation answer and deliver in arrival order and
272
+ * wait in the queue, not in a slot, behind their predecessor; above 1 the reply implementation must accept
273
+ * concurrent turns for different conversations.
264
274
  */
265
275
  concurrency?: number;
276
+ /** Authenticated human controls bypass model queues, including busy sessions. */
277
+ sandboxControl?: (message: MessengerMessage, signal: AbortSignal) => Promise<{ handled: boolean; output: string }>;
266
278
  }
267
279
 
268
280
  export function createMessengerGateway(
@@ -283,22 +295,22 @@ export function createMessengerGateway(
283
295
  const queue: PendingMessage[] = [];
284
296
  const activeKeys = new Set<string>();
285
297
  const turns = new Set<Promise<void>>();
286
- /** Settles when the latest request of a prepared conversation has delivered its reply. */
287
- const conversationTails = new Map<string, Promise<void>>();
298
+ /** Orders admission (sandbox control, capacity check, destination preparation) per raw lane. */
299
+ const admissionTails = new Map<string, Promise<void>>();
300
+ /** Requests holding a queue slot while the adapter prepares their destination; counted against the queue cap. */
301
+ let preparing = 0;
288
302
  const lifetime = new AbortController();
289
303
  let running = false;
290
304
  let fatalError: MessengerFatalError | undefined;
291
305
  let statusInFlight = 0;
292
306
 
293
- async function processRequest(pending: PendingMessage): Promise<void> {
294
- const { message, adapter, signal, resolve } = pending;
295
- let failed = false;
296
- let activity = NO_ACTIVITY;
297
- let release = (): void => {};
298
- let tail: Promise<void> | undefined;
299
- let conversation = "";
307
+ function createStatusReporter(
308
+ adapter: MessengerAdapter,
309
+ message: MessengerMessage,
310
+ signal: AbortSignal,
311
+ ): (state: "processing" | "success" | "failure") => void {
300
312
  let statusChain = Promise.resolve();
301
- const status = (state: "processing" | "success" | "failure"): void => {
313
+ return state => {
302
314
  if (!adapter.status) return;
303
315
  statusChain = statusChain.then(async () => {
304
316
  if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
@@ -328,22 +340,20 @@ export function createMessengerGateway(
328
340
  }
329
341
  });
330
342
  };
343
+ }
344
+
345
+ async function processRequest(pending: PendingMessage): Promise<void> {
346
+ const { message, adapter, signal, resolve, status } = pending;
347
+ let failed = false;
348
+ let activity = NO_ACTIVITY;
331
349
  try {
332
350
  if (signal.aborted) return;
333
351
  status("processing");
334
- const destination = (await adapter.prepare?.(message, signal)) ?? message;
335
- conversation = messengerConversationKey(destination);
336
- const previous = conversationTails.get(conversation);
337
- const done = Promise.withResolvers<void>();
338
- release = done.resolve;
339
- tail = done.promise;
340
- conversationTails.set(conversation, tail);
341
- await previous;
342
- activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
352
+ activity = createActivityReporter(adapter, message, signal, activityIntervalMs);
343
353
  activity.report("Starting request");
344
354
  let text: string;
345
355
  try {
346
- text = await reply(destination, signal, activity.report);
356
+ text = await reply(message, signal, activity.report);
347
357
  } catch (error) {
348
358
  failed = true;
349
359
  if (error instanceof MessengerFatalError) {
@@ -353,11 +363,13 @@ export function createMessengerGateway(
353
363
  return;
354
364
  }
355
365
  if (signal.aborted) return;
356
- text = "The request could not be completed. Please try again.";
366
+ text = error instanceof DiscordModelSelectionError
367
+ ? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
368
+ : "The request could not be completed. Please try again.";
357
369
  }
358
370
  if (!signal.aborted && text.trim()) {
359
371
  // A failed send may already have delivered a chunk: never retry the entire reply.
360
- await adapter.send(destination, text, signal);
372
+ await adapter.send(message, text, signal);
361
373
  }
362
374
  if (!signal.aborted) status(failed ? "failure" : "success");
363
375
  } catch {
@@ -365,8 +377,6 @@ export function createMessengerGateway(
365
377
  if (!signal.aborted) status("failure");
366
378
  } finally {
367
379
  await activity.close(failed ? "failure" : signal.aborted ? "cancelled" : "success");
368
- release();
369
- if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
370
380
  resolve();
371
381
  }
372
382
  }
@@ -390,21 +400,59 @@ export function createMessengerGateway(
390
400
  }
391
401
  }
392
402
 
393
- function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
403
+ async function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
394
404
  const adapter = byPlatform.get(message.platform);
395
405
  const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
396
406
  if (effectiveSignal.aborted || !adapter || !isMessengerMessageAllowed(message, config)) return Promise.resolve();
397
407
  const id = JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
398
- if (seen.has(id) || queue.length >= MAX_QUEUED_MESSAGES) return Promise.resolve();
408
+ if (seen.has(id)) return;
399
409
  seen.add(id);
400
410
  if (seen.size > MAX_SEEN_MESSAGES) {
401
411
  const oldest = seen.values().next().value;
402
412
  if (oldest !== undefined) seen.delete(oldest);
403
413
  }
404
- const { promise, resolve } = Promise.withResolvers<void>();
405
- queue.push({ message, adapter, signal: effectiveSignal, resolve, key: schedulingKey(message, adapter) });
406
- schedule();
407
- return promise;
414
+ const lane = admissionKey(message, adapter);
415
+ const previous = admissionTails.get(lane) ?? Promise.resolve();
416
+ const ready = Promise.withResolvers<void>();
417
+ const tail = previous.then(() => ready.promise);
418
+ admissionTails.set(lane, tail);
419
+ void tail.then(() => { if (admissionTails.get(lane) === tail) admissionTails.delete(lane); });
420
+ try {
421
+ if (options.sandboxControl) {
422
+ const control = await options.sandboxControl(message, effectiveSignal);
423
+ if (control.handled) {
424
+ if (!effectiveSignal.aborted && control.output.trim()) await adapter.send(message, control.output, effectiveSignal);
425
+ return;
426
+ }
427
+ }
428
+ await previous;
429
+ if (effectiveSignal.aborted) return;
430
+ const status = createStatusReporter(adapter, message, effectiveSignal);
431
+ // Capacity is claimed before preparing: a request that cannot be queued must never create a thread.
432
+ if (queue.length + preparing >= MAX_QUEUED_MESSAGES) {
433
+ // Never executed, so forget the id: a redelivery is accepted once capacity returns, replaying nothing.
434
+ seen.delete(id);
435
+ status("failure");
436
+ return;
437
+ }
438
+ let destination: MessengerMessage;
439
+ let key: string;
440
+ preparing++;
441
+ try {
442
+ destination = (await adapter.prepare?.(message, effectiveSignal)) ?? message;
443
+ key = messengerConversationKey(destination);
444
+ } catch {
445
+ if (!effectiveSignal.aborted) status("failure");
446
+ return;
447
+ } finally {
448
+ preparing--;
449
+ }
450
+ if (effectiveSignal.aborted) return;
451
+ const { promise, resolve } = Promise.withResolvers<void>();
452
+ queue.push({ message: destination, adapter, signal: effectiveSignal, resolve, key, status });
453
+ schedule();
454
+ return promise;
455
+ } finally { ready.resolve(); }
408
456
  }
409
457
 
410
458
  return {
@@ -0,0 +1,101 @@
1
+ import { readGlobalConfig, type Config } from "../agent/state";
2
+ import { listProviderModels } from "../ai/model-discovery";
3
+ import { credentialForCall, effectiveCredentialForProvider, qualifyModelId, resolveProvider } from "../ai/model-manager";
4
+ import { resolveModelId } from "../ai/model-registry";
5
+ import { resolveCredential, type AuthProvider, type Credential } from "../auth";
6
+
7
+ export class DiscordModelSelectionError extends Error {
8
+ constructor(cause: unknown) {
9
+ super(cause instanceof Error ? cause.message : "Discord model selection failed", { cause });
10
+ this.name = "DiscordModelSelectionError";
11
+ }
12
+ }
13
+
14
+ /** Select only from the account's live catalog; never substitute a static model. */
15
+ export async function selectDiscordModel(
16
+ explicit: string | undefined,
17
+ opts: { config?: Config; signal?: AbortSignal; list?: typeof listProviderModels; credential?: typeof resolveCredential } = {},
18
+ ): Promise<string> {
19
+ try {
20
+ return await selectDiscordModelFromCatalog(explicit, opts);
21
+ } catch (error) {
22
+ if (opts.signal?.aborted) throw error;
23
+ throw new DiscordModelSelectionError(error);
24
+ }
25
+ }
26
+
27
+ async function selectDiscordModelFromCatalog(
28
+ explicit: string | undefined,
29
+ opts: { config?: Config; signal?: AbortSignal; list?: typeof listProviderModels; credential?: typeof resolveCredential },
30
+ ): Promise<string> {
31
+ const config = opts.config ?? await readGlobalConfig();
32
+ const requested = explicit?.trim() ? await resolveModelId(explicit.trim(), config) : undefined;
33
+ const provider = resolveProvider(requested || await resolveModelId(config.defaultModel, config));
34
+ if (!requested && provider !== "anthropic" && provider !== "openai" && provider !== "antigravity") {
35
+ throw new Error(`Discord automatic model selection does not support provider ${provider}; configure an explicit model`);
36
+ }
37
+ const result = await (opts.list ?? listProviderModels)(provider, { config, signal: opts.signal, limit: Number.POSITIVE_INFINITY, preferOAuth: true });
38
+ if (opts.signal?.aborted) throw new Error("Messenger request aborted");
39
+ if (!result.ok || result.fallback || result.source === "none" || (result.source === "keyless" && (!requested || (provider !== "ollama" && provider !== "lmstudio"))) || !result.models.length) {
40
+ throw new Error(`Discord cannot verify available models for ${provider}; check login and live model discovery`);
41
+ }
42
+ const canonical = (model: string) => qualifyModelId(model, provider);
43
+ const verified = async (model: string): Promise<string> => {
44
+ const authProvider: AuthProvider | undefined = provider === "anthropic" ? "anthropic" : provider === "openai" ? "openai" : undefined;
45
+ if (authProvider && config.providers[authProvider]) {
46
+ const credential: Credential = await (opts.credential ?? resolveCredential)(authProvider);
47
+ const effective = effectiveCredentialForProvider(authProvider, credential, config, model);
48
+ const actual = credentialForCall(provider, effective, config, provider === "openai" ? config.openaiBaseUrl : undefined);
49
+ if (actual.kind !== result.source) {
50
+ throw new Error(`Discord cannot verify ${provider} model access for the credential used at runtime`);
51
+ }
52
+ }
53
+ return canonical(model);
54
+ };
55
+ if (requested) {
56
+ const prefix = `${provider}/`;
57
+ const requestedId = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
58
+ const match = result.models.find(model => (model.startsWith(prefix) ? model.slice(prefix.length) : model) === requestedId);
59
+ if (!match) throw new Error(`Discord configured model is not available for ${provider}: ${requested}`);
60
+ return verified(match);
61
+ }
62
+ const family = provider === "anthropic" ? /sonnet/i : provider === "openai" ? /terra/i : /gemini.*flash/i;
63
+ const variant = /(?:^|[^a-z0-9])(?:mini|lite|codex|thinking|image)(?=$|[^a-z0-9])/i;
64
+ const models = [...new Set(result.models.filter(model => {
65
+ const name = provider === "antigravity" ? (result.displayNames?.[model] ?? model) : model;
66
+ return family.test(name) && !variant.test(model) && !variant.test(name);
67
+ }))];
68
+ if (!models.length) throw new Error(`Discord found no available ${provider} model in the required family`);
69
+ if (models.length === 1) return verified(models[0]!);
70
+ // Antigravity wire aliases can advertise a different version than their IDs.
71
+ // Only the display name from the same live response establishes recency.
72
+ if (provider === "antigravity" && models.some(model => !result.displayNames?.[model])) {
73
+ throw new Error("Discord cannot determine the newest Antigravity Flash model: live catalog is missing display names; configure an explicit model from the live catalog");
74
+ }
75
+ const ranked = models.map(model => {
76
+ const name = provider === "antigravity" ? result.displayNames![model]! : model;
77
+ const snapshot = name.match(/[-_ ](\d{4}-\d{2}-\d{2}|\d{8})$/);
78
+ const versionName = snapshot ? name.slice(0, -snapshot[0].length) : name;
79
+ const tierName = provider === "antigravity" ? name.match(/\((high|medium|low)\)\s*$/i)?.[1]?.toLowerCase() : undefined;
80
+ const tier = tierName === "high" ? 3 : tierName === "medium" ? 2 : tierName === "low" ? 1 : 0;
81
+ return { model, version: (versionName.match(/\d+/g) ?? []).map(Number), snapshot: Number(snapshot?.[1]?.replaceAll("-", "") ?? 0), tier, ambiguousVersion: provider === "openai" && /terra.*\d/i.test(versionName) };
82
+ });
83
+ if (ranked.some(item => !item.version.length || item.ambiguousVersion)) {
84
+ throw new Error(`Discord cannot determine the newest ${provider} model; configure an explicit model`);
85
+ }
86
+ const compare = (a: number[], b: number[]) => {
87
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
88
+ const difference = (b[i] ?? 0) - (a[i] ?? 0);
89
+ if (difference) return difference;
90
+ }
91
+ return 0;
92
+ };
93
+ ranked.sort((a, b) => compare(a.version, b.version) || b.snapshot - a.snapshot || b.tier - a.tier);
94
+ if (provider === "antigravity" && ranked.some((item, index) => index > 0 && compare(ranked[0]!.version, item.version) === 0 && ranked[0]!.snapshot === item.snapshot && (!ranked[0]!.tier || !item.tier))) {
95
+ throw new Error("Discord cannot distinguish the newest antigravity models; configure an explicit model");
96
+ }
97
+ if (compare(ranked[0]!.version, ranked[1]!.version) === 0 && ranked[0]!.snapshot === ranked[1]!.snapshot && ranked[0]!.tier === ranked[1]!.tier) {
98
+ throw new Error(`Discord cannot distinguish the newest ${provider} models; configure an explicit model`);
99
+ }
100
+ return verified(ranked[0]!.model);
101
+ }
@@ -6,6 +6,8 @@ interface WorkerProcess {
6
6
  stderr: ReadableStream<Uint8Array>;
7
7
  exited: Promise<number>;
8
8
  kill(signal?: number | NodeJS.Signals): void;
9
+ /** Private IPC channel; present only on workers spawned with `ipc`. */
10
+ send?(message: unknown): unknown;
9
11
  }
10
12
 
11
13
  interface Pending {
@@ -18,35 +20,80 @@ interface Pending {
18
20
 
19
21
  interface Worker {
20
22
  child: WorkerProcess;
23
+ exit: Promise<number>;
24
+ policyKey?: string;
21
25
  pending?: Pending;
22
26
  lastUsed: number;
23
27
  dead: boolean;
28
+ forceSent?: boolean;
24
29
  exited?: boolean;
30
+ cleanupNonce?: string;
31
+ cleanupGone?: boolean;
32
+ cleanupError?: Error;
25
33
  idleTimer?: ReturnType<typeof setTimeout>;
26
34
  exitTimer?: ReturnType<typeof setTimeout>;
27
35
  }
28
36
 
29
- export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn, limit = 16, idleTimeoutMs = 5 * 60_000, shutdownTimeoutMs = 5_000) {
37
+ /** Resident sessions the pool keeps, and the number of turns it runs at once. */
38
+ export const MESSENGER_SESSION_LIMIT = 16;
39
+
40
+ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn, limit = MESSENGER_SESSION_LIMIT, idleTimeoutMs = 5 * 60_000, shutdownTimeoutMs = 5_000, options: { requireSandboxCleanup?: boolean } = {}) {
30
41
  const workers = new Map<string, Worker>();
31
42
  const allWorkers = new Map<Worker, string>();
32
- const exits = new Map<Promise<number>, WorkerProcess>();
43
+ const exits = new Map<Promise<number>, Worker>();
33
44
  const retiring = new Map<string, Promise<number>>();
45
+ const keyTails = new Map<string, Promise<void>>();
46
+ const requireSandboxCleanup = options.requireSandboxCleanup === true;
47
+ const incomplete = (detail: string) => new Error(`REVOKE_INCOMPLETE: ${detail}`);
34
48
 
35
49
  let closed = false;
36
50
  const closedSignal = new AbortController();
37
51
 
38
52
  let tick = 0;
39
53
 
54
+ // Admission: at most `limit` turns hold a worker at once. Later turns wait here in arrival order until a
55
+ // running turn settles, so a busy pool is backpressure on the waiting request, never a failure of it.
56
+ let admitted = 0;
57
+ const waiting: Array<() => void> = [];
58
+
59
+ function admit(signal: AbortSignal): Promise<void> {
60
+ return new Promise<void>((resolve, reject) => {
61
+ const grant = () => { cleanup(); resolve(); };
62
+ const abort = () => {
63
+ const index = waiting.indexOf(grant);
64
+ if (index >= 0) waiting.splice(index, 1);
65
+ cleanup();
66
+ reject(new Error("Messenger request aborted"));
67
+ };
68
+ const cleanup = () => { signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
69
+ signal.addEventListener("abort", abort, { once: true });
70
+ closedSignal.signal.addEventListener("abort", abort, { once: true });
71
+ waiting.push(grant);
72
+ });
73
+ }
74
+
75
+ /** Hand the finished turn's slot straight to the oldest waiter, or free it. */
76
+ function release() {
77
+ const next = waiting.shift();
78
+ if (next) next(); else admitted--;
79
+ }
80
+
81
+ function forceKill(worker: Worker) {
82
+ if (worker.cleanupNonce) return;
83
+ if (worker.forceSent) return;
84
+ try { worker.child.kill("SIGKILL"); worker.forceSent = true; } catch { /* Retry on shutdown if termination failed. */ }
85
+ }
86
+
40
87
  function retire(key: string, worker: Worker) {
41
88
  if (worker.dead) return;
42
89
  worker.dead = true;
43
90
  if (workers.get(key) === worker) workers.delete(key);
44
- allWorkers.delete(worker);
45
- if (!worker.exited) {
46
- retiring.set(key, worker.child.exited);
47
- void worker.child.exited.then(
48
- () => { if (retiring.get(key) === worker.child.exited) retiring.delete(key); },
49
- () => { if (retiring.get(key) === worker.child.exited) retiring.delete(key); },
91
+ if (!worker.cleanupNonce) allWorkers.delete(worker);
92
+ if (!worker.exited || worker.cleanupNonce) {
93
+ retiring.set(key, worker.exit);
94
+ void worker.exit.then(
95
+ () => { if (retiring.get(key) === worker.exit) retiring.delete(key); },
96
+ () => { /* Keep the key blocked when process exit cannot be confirmed. */ },
50
97
  );
51
98
  }
52
99
  if (worker.idleTimer) clearTimeout(worker.idleTimer);
@@ -55,7 +102,29 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
55
102
  worker.pending?.reject(new Error("Messenger worker stopped"));
56
103
  worker.pending = undefined;
57
104
  if (!worker.exited) {
58
- try { worker.child.kill(); } catch { /* Shutdown will attempt forced termination. */ }
105
+ if (worker.cleanupNonce) {
106
+ // Protected workers never receive a kill signal: Windows cannot deliver a catchable SIGTERM.
107
+ // Shutdown is a private IPC message; a missing or failing channel is visible, never success.
108
+ const channel = worker.child.send;
109
+ if (typeof channel !== "function") {
110
+ worker.cleanupError = incomplete("worker shutdown channel unavailable");
111
+ } else {
112
+ try {
113
+ const sent = channel.call(worker.child, { type: "jeo_messenger_shutdown", nonce: worker.cleanupNonce });
114
+ if (sent && typeof (sent as PromiseLike<unknown>).then === "function") {
115
+ void (sent as PromiseLike<unknown>).then(undefined, error => { worker.cleanupError = incomplete(`worker shutdown request failed: ${String(error)}`); });
116
+ }
117
+ } catch (error) { worker.cleanupError = incomplete(`worker shutdown request failed: ${String(error)}`); }
118
+ }
119
+ } else {
120
+ try { worker.child.kill(); }
121
+ catch (error) { worker.cleanupError = incomplete(`worker termination failed: ${String(error)}`); }
122
+ }
123
+ if (!worker.cleanupNonce) {
124
+ const force = setTimeout(() => forceKill(worker), shutdownTimeoutMs);
125
+ force.unref();
126
+ void worker.exit.then(() => clearTimeout(force), () => clearTimeout(force));
127
+ }
59
128
  }
60
129
  }
61
130
 
@@ -89,16 +158,45 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
89
158
  } finally { reader.releaseLock(); }
90
159
  }
91
160
 
92
- function start(key: string, command: string[], cwd: string, env: Record<string, string>): Worker {
93
- if (workers.size >= limit) {
161
+ function waitForExit(exit: Promise<number>, signal: AbortSignal, capacity = false): Promise<void> {
162
+ return new Promise<void>((resolve, reject) => {
163
+ const abort = () => { cleanup(); reject(new Error("Messenger request aborted")); };
164
+ const unavailable = () => new Error("Messenger worker capacity unavailable while another session is being cleaned up");
165
+ const timer = setTimeout(() => { cleanup(); reject(capacity ? unavailable() : requireSandboxCleanup ? incomplete("previous worker cleanup is still pending") : new Error("Messenger worker did not exit")); }, shutdownTimeoutMs * 2);
166
+ const cleanup = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
167
+ signal.addEventListener("abort", abort, { once: true });
168
+ closedSignal.signal.addEventListener("abort", abort, { once: true });
169
+ if (signal.aborted || closed) { abort(); return; }
170
+ void exit.then(() => { cleanup(); resolve(); }, error => { cleanup(); reject(capacity ? unavailable() : requireSandboxCleanup ? incomplete(`previous worker cleanup failed: ${String(error)}`) : new Error("Messenger worker exit could not be confirmed")); });
171
+ });
172
+ }
173
+
174
+ async function start(key: string, command: string[], cwd: string, env: Record<string, string>, signal: AbortSignal, policyKey?: string): Promise<Worker> {
175
+ while ((requireSandboxCleanup ? allWorkers.size : workers.size) >= limit) {
94
176
  const idle = [...workers.entries()].filter(([, worker]) => !worker.pending).sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
95
- if (!idle) throw new Error("Messenger worker capacity reached");
177
+ if (!idle) {
178
+ const retiringWorker = requireSandboxCleanup ? [...allWorkers.keys()].find(worker => worker.dead) : undefined;
179
+ if (!retiringWorker) throw new Error("Messenger worker capacity reached");
180
+ await waitForExit(retiringWorker.exit, signal, true);
181
+ continue;
182
+ }
96
183
  retire(idle[0], idle[1]);
184
+ if (requireSandboxCleanup) await waitForExit(idle[1].exit, signal, true);
97
185
  }
98
- const child = spawn(command, { cwd, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" }) as unknown as WorkerProcess;
99
- const worker: Worker = { child, lastUsed: ++tick, dead: false };
100
- exits.set(child.exited, child);
101
- void child.exited.then(() => exits.delete(child.exited), () => exits.delete(child.exited));
186
+ if (closed || signal.aborted) throw new Error("Messenger request aborted");
187
+ const cleanupNonce = requireSandboxCleanup ? crypto.randomUUID() : undefined;
188
+ const workerEnv = { ...env };
189
+ delete workerEnv.JEO_MESSENGER_SHUTDOWN_NONCE;
190
+ if (cleanupNonce) workerEnv.JEO_MESSENGER_SHUTDOWN_NONCE = cleanupNonce;
191
+ const spawnOptions = { cwd, env: workerEnv, stdin: "pipe", stdout: "pipe", stderr: "pipe", ...(cleanupNonce ? { ipc() {}, serialization: "json" } : {}) };
192
+ const child = spawn(command, spawnOptions as never) as unknown as WorkerProcess;
193
+ const cleanup = Promise.withResolvers<number>();
194
+ const worker: Worker = { child, exit: cleanupNonce ? cleanup.promise : child.exited, cleanupNonce, policyKey, lastUsed: ++tick, dead: false };
195
+ exits.set(worker.exit, worker);
196
+ void worker.exit.then(() => {
197
+ exits.delete(worker.exit);
198
+ if (cleanupNonce) allWorkers.delete(worker);
199
+ }, () => { if (!cleanupNonce) exits.delete(worker.exit); });
102
200
  workers.set(key, worker);
103
201
  allWorkers.set(worker, key);
104
202
  const stdoutDone = readLines(child.stdout, line => {
@@ -113,7 +211,17 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
113
211
  finishTurn(key, worker, pending);
114
212
  } catch { retire(key, worker); }
115
213
  }).catch(() => retire(key, worker));
116
- void readLines(child.stderr, line => {
214
+ const stderrDone = readLines(child.stderr, line => {
215
+ if (cleanupNonce && line.startsWith("JEO_MESSENGER_SHUTDOWN_ACK ")) {
216
+ try {
217
+ const ack: unknown = JSON.parse(line.slice("JEO_MESSENGER_SHUTDOWN_ACK ".length));
218
+ if (typeof ack === "object" && ack !== null && "nonce" in ack && ack.nonce === cleanupNonce && "gone" in ack) {
219
+ worker.cleanupGone = ack.gone === true;
220
+ worker.cleanupError = worker.cleanupGone ? undefined : incomplete("worker supervisor cleanup failed");
221
+ }
222
+ } catch { /* Unauthenticated or malformed output is never cleanup proof. */ }
223
+ return;
224
+ }
117
225
  if (line.startsWith("JEO_MESSENGER_ACTIVITY ")) {
118
226
  const activityText = line.slice("JEO_MESSENGER_ACTIVITY ".length).trim();
119
227
  if (activityText) worker.pending?.report?.(activityText);
@@ -121,9 +229,15 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
121
229
  worker.pending?.report?.("Working on your request");
122
230
  }
123
231
  }).catch(() => retire(key, worker));
124
- void child.exited.then(() => {
125
- if (worker.dead) return;
232
+ if (cleanupNonce) {
233
+ void Promise.all([child.exited, stderrDone, stdoutDone]).then(([code]) => {
234
+ if (worker.cleanupGone) cleanup.resolve(code);
235
+ else cleanup.reject(worker.cleanupError ?? incomplete("worker exited without supervisor cleanup acknowledgement"));
236
+ }, error => cleanup.reject(incomplete(`worker exit could not be verified: ${String(error)}`)));
237
+ }
238
+ void worker.exit.then(() => {
126
239
  worker.exited = true;
240
+ if (worker.dead) return;
127
241
  if (workers.get(key) === worker) workers.delete(key);
128
242
  worker.exitTimer = setTimeout(() => retire(key, worker), shutdownTimeoutMs);
129
243
  worker.exitTimer.unref();
@@ -134,24 +248,61 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
134
248
  return worker;
135
249
  }
136
250
 
137
- async function turn(key: string, command: string[], cwd: string, env: Record<string, string>, text: string, signal: AbortSignal, report?: MessengerActivityReporter): Promise<string> {
251
+ function turn(key: string, command: string[], cwd: string, env: Record<string, string>, text: string, signal: AbortSignal, report?: MessengerActivityReporter, policyKey?: string): Promise<string> {
252
+ if (closed || signal.aborted) return Promise.reject(new Error("Messenger request aborted"));
253
+ const run = () => runTurn(key, command, cwd, env, text, signal, report, policyKey).finally(release);
254
+ const admitRun = () => {
255
+ if (closed || signal.aborted) return Promise.reject(new Error("Messenger request aborted"));
256
+ if (admitted < limit && waiting.length === 0) {
257
+ admitted++;
258
+ return run();
259
+ }
260
+ return admit(signal).then(run);
261
+ };
262
+ // Same-key turns run strictly in arrival order. Queued turns hold no admission slot while they wait,
263
+ // so they never block other sessions; different keys are fully independent.
264
+ const previous = keyTails.get(key);
265
+ const result = previous ? waitForTurn(previous, signal).then(admitRun) : admitRun();
266
+ const settled = result.then(() => undefined, () => undefined);
267
+ // The tail also covers the predecessor, so an aborted queued turn never lets its successor overtake it.
268
+ const tail: Promise<void> = previous ? Promise.all([previous, settled]).then(() => undefined) : settled;
269
+ keyTails.set(key, tail);
270
+ void tail.then(() => { if (keyTails.get(key) === tail) keyTails.delete(key); });
271
+ return result;
272
+ }
273
+
274
+ function waitForTurn(previous: Promise<void>, signal: AbortSignal): Promise<void> {
275
+ return new Promise<void>((resolve, reject) => {
276
+ const abort = () => { cleanup(); reject(new Error("Messenger request aborted")); };
277
+ const cleanup = () => { signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
278
+ signal.addEventListener("abort", abort, { once: true });
279
+ closedSignal.signal.addEventListener("abort", abort, { once: true });
280
+ if (signal.aborted || closed) { abort(); return; }
281
+ void previous.then(() => { cleanup(); resolve(); });
282
+ });
283
+ }
284
+
285
+ async function runTurn(key: string, command: string[], cwd: string, env: Record<string, string>, text: string, signal: AbortSignal, report?: MessengerActivityReporter, policyKey?: string): Promise<string> {
138
286
  if (closed || signal.aborted) throw new Error("Messenger request aborted");
139
- const previousExit = retiring.get(key);
140
- if (previousExit) {
141
- await new Promise<void>((resolve, reject) => {
142
- const abort = () => { cleanup(); reject(new Error("Messenger request aborted")); };
143
- const cleanup = () => { signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
144
- signal.addEventListener("abort", abort, { once: true });
145
- closedSignal.signal.addEventListener("abort", abort, { once: true });
146
- if (signal.aborted || closed) { abort(); return; }
147
- void previousExit.then(() => { cleanup(); resolve(); }, () => { cleanup(); resolve(); });
148
- });
287
+ for (;;) {
288
+ const existing = workers.get(key);
289
+ if (existing && existing.policyKey !== policyKey) {
290
+ if (existing.pending) throw new Error("Messenger conversation is already active");
291
+ retire(key, existing);
292
+ }
293
+ const previousExit = retiring.get(key);
294
+ if (!previousExit) break;
295
+ await waitForExit(previousExit, signal);
149
296
  }
150
297
  if (closed || signal.aborted) throw new Error("Messenger request aborted");
151
298
  if ([...allWorkers].some(([worker, workerKey]) => workerKey === key && worker.pending)) {
152
299
  throw new Error("Messenger conversation is already active");
153
300
  }
154
- const worker = workers.get(key) ?? start(key, command, cwd, env);
301
+ const worker = workers.get(key) ?? await start(key, command, cwd, env, signal, policyKey);
302
+ if (closed || signal.aborted || worker.dead) {
303
+ retire(key, worker);
304
+ throw new Error("Messenger worker stopped");
305
+ }
155
306
  if (worker.pending) throw new Error("Messenger conversation is already active");
156
307
  if (worker.idleTimer) clearTimeout(worker.idleTimer);
157
308
  worker.idleTimer = undefined;
@@ -179,15 +330,18 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
179
330
  let timer: ReturnType<typeof setTimeout> | undefined;
180
331
  try {
181
332
  return await Promise.race([
182
- Promise.allSettled([...exits.keys()]).then(() => true),
333
+ Promise.allSettled([...exits.keys()]).then(results => {
334
+ const failure = results.find(result => result.status === "rejected");
335
+ if (failure?.status === "rejected" && requireSandboxCleanup) throw incomplete(`worker cleanup failed: ${String(failure.reason)}`);
336
+ return true;
337
+ }),
183
338
  new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), shutdownTimeoutMs); }),
184
339
  ]);
185
340
  } finally { if (timer) clearTimeout(timer); }
186
341
  };
187
342
  if (await waitForExits()) return;
188
- for (const child of exits.values()) {
189
- try { child.kill("SIGKILL"); } catch { /* The process may have exited concurrently. */ }
190
- }
343
+ if (requireSandboxCleanup) throw incomplete("worker cleanup is still pending; cleanup owner retained");
344
+ for (const worker of exits.values()) forceKill(worker);
191
345
  if (!(await waitForExits())) throw new Error("Messenger workers did not exit after forced termination");
192
346
  }
193
347