@gajae-code/coding-agent 0.5.2 → 0.5.4

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 (99) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/config/model-profiles.d.ts +10 -0
  4. package/dist/types/dap/client.d.ts +2 -1
  5. package/dist/types/edit/read-file.d.ts +6 -0
  6. package/dist/types/eval/js/context-manager.d.ts +3 -0
  7. package/dist/types/eval/js/executor.d.ts +1 -0
  8. package/dist/types/exec/bash-executor.d.ts +2 -0
  9. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  10. package/dist/types/lsp/types.d.ts +2 -0
  11. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  12. package/dist/types/modes/components/model-selector.d.ts +2 -0
  13. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  14. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  16. package/dist/types/modes/interactive-mode.d.ts +1 -0
  17. package/dist/types/modes/types.d.ts +1 -0
  18. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  19. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  20. package/dist/types/runtime-mcp/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +29 -1
  22. package/dist/types/session/artifacts.d.ts +4 -1
  23. package/dist/types/session/streaming-output.d.ts +12 -0
  24. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  25. package/dist/types/tools/bash.d.ts +1 -0
  26. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  27. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  28. package/dist/types/web/search/providers/codex.d.ts +4 -4
  29. package/package.json +7 -7
  30. package/src/async/job-manager.ts +181 -43
  31. package/src/config/file-lock.ts +9 -1
  32. package/src/config/model-profile-activation.ts +71 -3
  33. package/src/config/model-profiles.ts +39 -14
  34. package/src/dap/client.ts +105 -64
  35. package/src/dap/session.ts +44 -7
  36. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -2
  37. package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -2
  38. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -2
  39. package/src/edit/read-file.ts +19 -1
  40. package/src/eval/js/context-manager.ts +228 -65
  41. package/src/eval/js/executor.ts +2 -0
  42. package/src/eval/js/index.ts +1 -0
  43. package/src/eval/js/worker-core.ts +10 -6
  44. package/src/eval/py/executor.ts +68 -19
  45. package/src/eval/py/kernel.ts +46 -22
  46. package/src/eval/py/runner.py +68 -14
  47. package/src/exec/bash-executor.ts +49 -13
  48. package/src/gjc-runtime/deep-interview-runtime.ts +14 -13
  49. package/src/gjc-runtime/ralplan-runtime.ts +10 -0
  50. package/src/gjc-runtime/state-runtime.ts +73 -0
  51. package/src/gjc-runtime/tmux-gc.ts +86 -37
  52. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  53. package/src/gjc-runtime/ultragoal-runtime.ts +8 -4
  54. package/src/internal-urls/artifact-protocol.ts +10 -1
  55. package/src/internal-urls/docs-index.generated.ts +2 -2
  56. package/src/lsp/client.ts +64 -26
  57. package/src/lsp/index.ts +2 -1
  58. package/src/lsp/lspmux.ts +33 -9
  59. package/src/lsp/types.ts +2 -0
  60. package/src/modes/bridge/bridge-mode.ts +21 -0
  61. package/src/modes/components/assistant-message.ts +10 -2
  62. package/src/modes/components/bash-execution.ts +5 -1
  63. package/src/modes/components/eval-execution.ts +5 -1
  64. package/src/modes/components/model-selector.ts +34 -2
  65. package/src/modes/components/oauth-selector.ts +5 -0
  66. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  67. package/src/modes/components/skill-message.ts +24 -16
  68. package/src/modes/components/tool-execution.ts +6 -0
  69. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  70. package/src/modes/controllers/input-controller.ts +19 -0
  71. package/src/modes/controllers/selector-controller.ts +6 -1
  72. package/src/modes/interactive-mode.ts +13 -0
  73. package/src/modes/types.ts +1 -0
  74. package/src/modes/utils/ui-helpers.ts +5 -2
  75. package/src/prompts/agents/executor.md +1 -1
  76. package/src/runtime/process-lifecycle.ts +400 -0
  77. package/src/runtime-mcp/manager.ts +164 -50
  78. package/src/runtime-mcp/transports/http.ts +12 -11
  79. package/src/runtime-mcp/transports/stdio.ts +64 -38
  80. package/src/runtime-mcp/types.ts +3 -0
  81. package/src/sdk.ts +27 -0
  82. package/src/session/agent-session.ts +271 -25
  83. package/src/session/artifacts.ts +17 -2
  84. package/src/session/blob-store.ts +36 -2
  85. package/src/session/session-manager.ts +29 -13
  86. package/src/session/streaming-output.ts +95 -3
  87. package/src/setup/model-onboarding-guidance.ts +10 -3
  88. package/src/skill-state/active-state.ts +79 -7
  89. package/src/slash-commands/builtin-registry.ts +30 -3
  90. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  91. package/src/tools/archive-reader.ts +10 -1
  92. package/src/tools/bash.ts +11 -4
  93. package/src/tools/browser/registry.ts +17 -1
  94. package/src/tools/browser/tab-supervisor.ts +22 -0
  95. package/src/tools/browser.ts +38 -4
  96. package/src/tools/cron.ts +2 -6
  97. package/src/tools/read.ts +11 -12
  98. package/src/tools/sqlite-reader.ts +19 -5
  99. package/src/web/search/providers/codex.ts +6 -5
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.5.2",
4
+ "version": "0.5.4",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -51,12 +51,12 @@
51
51
  "@agentclientprotocol/sdk": "0.21.0",
52
52
  "@babel/parser": "^7.29.3",
53
53
  "@mozilla/readability": "^0.6.0",
54
- "@gajae-code/stats": "0.5.2",
55
- "@gajae-code/agent-core": "0.5.2",
56
- "@gajae-code/ai": "0.5.2",
57
- "@gajae-code/natives": "0.5.2",
58
- "@gajae-code/tui": "0.5.2",
59
- "@gajae-code/utils": "0.5.2",
54
+ "@gajae-code/stats": "0.5.4",
55
+ "@gajae-code/agent-core": "0.5.4",
56
+ "@gajae-code/ai": "0.5.4",
57
+ "@gajae-code/natives": "0.5.4",
58
+ "@gajae-code/tui": "0.5.4",
59
+ "@gajae-code/utils": "0.5.4",
60
60
  "@puppeteer/browsers": "^2.13.0",
61
61
  "@types/turndown": "5.0.6",
62
62
  "@xterm/headless": "^6.0.0",
@@ -7,6 +7,11 @@ const DELIVERY_RETRY_JITTER_MS = 200;
7
7
  const DEFAULT_RETENTION_MS = 5 * 60 * 1000;
8
8
  const DEFAULT_MAX_RUNNING_JOBS = 15;
9
9
  const MONITOR_TOMBSTONE_TTL_MS = 5 * 60_000;
10
+ const DEFAULT_MAX_DELIVERY_QUEUE = 100;
11
+ const DELIVERY_MAX_TEXT_BYTES = 64 * 1024;
12
+ const DELIVERY_PREVIEW_HEAD_BYTES = 32 * 1024;
13
+ const DELIVERY_PREVIEW_TAIL_BYTES = 32 * 1024;
14
+ const DELIVERY_MAX_ATTEMPTS = 3;
10
15
 
11
16
  export interface AsyncJob {
12
17
  id: string;
@@ -113,6 +118,12 @@ export interface ResumeDescriptor {
113
118
  data: unknown;
114
119
  }
115
120
 
121
+ function sessionFileFromResumeDescriptorData(data: unknown): string | null {
122
+ if (typeof data !== "object" || data === null) return null;
123
+ const sessionFile = (data as { sessionFile?: unknown }).sessionFile;
124
+ return typeof sessionFile === "string" && sessionFile.trim().length > 0 ? sessionFile : null;
125
+ }
126
+
116
127
  /** A pending resume awaiting a free concurrency slot. */
117
128
  interface ResumeQueueEntry {
118
129
  subagentId: string;
@@ -128,9 +139,16 @@ export interface AsyncJobManagerOptions {
128
139
  retentionMs?: number;
129
140
  }
130
141
 
142
+ export interface AsyncJobDisposeDiagnostics {
143
+ stuckJobIds: string[];
144
+ deliveriesDrained: boolean;
145
+ }
146
+
131
147
  interface AsyncJobDelivery {
132
148
  jobId: string;
133
149
  text: string;
150
+ originalBytes?: number;
151
+ truncated?: boolean;
134
152
  attempt: number;
135
153
  nextAttemptAt: number;
136
154
  lastError?: string;
@@ -143,6 +161,7 @@ export interface AsyncJobDeliveryState {
143
161
  delivering: boolean;
144
162
  nextRetryAt?: number;
145
163
  pendingJobIds: string[];
164
+ deadLettered: number;
146
165
  }
147
166
 
148
167
  export interface AsyncJobLifecycleCleanup {
@@ -198,6 +217,32 @@ function sliceTextFromUtf8ByteOffset(text: string, offsetBytes: number): string
198
217
  return text.slice(codeUnitIndex);
199
218
  }
200
219
 
220
+ function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string {
221
+ if (offsetBytes <= 0) return text;
222
+ let consumedBytes = 0;
223
+ let codeUnitIndex = 0;
224
+ for (const char of text) {
225
+ const charBytes = Buffer.byteLength(char, "utf8");
226
+ consumedBytes += charBytes;
227
+ codeUnitIndex += char.length;
228
+ if (consumedBytes >= offsetBytes) break;
229
+ }
230
+ return text.slice(codeUnitIndex);
231
+ }
232
+
233
+ function sliceTextToUtf8ByteLength(text: string, maxBytes: number): string {
234
+ if (maxBytes <= 0) return "";
235
+ let consumedBytes = 0;
236
+ let codeUnitIndex = 0;
237
+ for (const char of text) {
238
+ const charBytes = Buffer.byteLength(char, "utf8");
239
+ if (consumedBytes + charBytes > maxBytes) break;
240
+ consumedBytes += charBytes;
241
+ codeUnitIndex += char.length;
242
+ }
243
+ return text.slice(0, codeUnitIndex);
244
+ }
245
+
201
246
  /**
202
247
  * A slice of process-stream output for a background job, as recorded by
203
248
  * `appendOutput` / read by `readOutputSince`.
@@ -277,6 +322,8 @@ export class AsyncJobManager {
277
322
  #resumeSeq = 0;
278
323
  #resumeRunner?: (subagentId: string, message?: string, descriptor?: ResumeDescriptor) => string | undefined;
279
324
  readonly #resumeDescriptors = new Map<string, ResumeDescriptor>();
325
+ readonly #deadLetteredDeliveries = new Map<string, AsyncJobDelivery>();
326
+ #lastDisposeDiagnostics: AsyncJobDisposeDiagnostics = { stuckJobIds: [], deliveriesDrained: true };
280
327
  /**
281
328
  * Change listeners notified on any mutation that can alter the live job set
282
329
  * (register, terminal/eviction transitions, dispose). Used by the status-line
@@ -381,7 +428,7 @@ export class AsyncJobManager {
381
428
 
382
429
  if (job.status === "cancelled") {
383
430
  job.resultText = outcome.kind === "completed" ? outcome.text : outcome.note;
384
- this.#runLifecycle(id, "terminal");
431
+ this.#runLifecycle(id, "terminal", job);
385
432
  this.#scheduleEviction(id);
386
433
  this.#markRecordTerminal(id, "cancelled");
387
434
  this.#drainResumeQueue();
@@ -403,20 +450,20 @@ export class AsyncJobManager {
403
450
  this.#freezeEndTime(job);
404
451
  job.resultText = outcome.text;
405
452
  this.#enqueueDelivery(id, outcome.text);
406
- this.#runLifecycle(id, "terminal");
453
+ this.#runLifecycle(id, "terminal", job);
407
454
  this.#scheduleEviction(id);
408
455
  this.#markRecordTerminal(id, "completed");
409
456
  this.#drainResumeQueue();
410
457
  } catch (error) {
411
458
  if (job.status === "cancelled") {
412
459
  job.errorText = error instanceof Error ? error.message : String(error);
413
- this.#runLifecycle(id, "terminal");
460
+ this.#runLifecycle(id, "terminal", job);
414
461
  this.#scheduleEviction(id);
415
462
  this.#markRecordTerminal(id, "cancelled");
416
463
  this.#drainResumeQueue();
417
464
  return;
418
465
  }
419
- this.#runLifecycle(id, "terminal");
466
+ this.#runLifecycle(id, "terminal", job);
420
467
  const errorText = error instanceof Error ? error.message : String(error);
421
468
  job.status = "failed";
422
469
  this.#freezeEndTime(job);
@@ -471,14 +518,14 @@ export class AsyncJobManager {
471
518
  job.endTime ??= Date.now();
472
519
  }
473
520
 
474
- #runLifecycle(jobId: string, phase: "cancel" | "terminal" | "evict"): void {
521
+ #runLifecycle(jobId: string, phase: "cancel" | "terminal" | "evict", jobOverride?: AsyncJob): void {
522
+ const lifecycle = this.#lifecycles.get(jobId);
523
+ const job = jobOverride ?? this.#jobs.get(jobId);
524
+ if (!lifecycle || !job) return;
475
525
  const fired = this.#lifecyclePhases.get(jobId) ?? new Set<"cancel" | "terminal" | "evict">();
476
526
  if (fired.has(phase)) return;
477
527
  fired.add(phase);
478
528
  this.#lifecyclePhases.set(jobId, fired);
479
- const lifecycle = this.#lifecycles.get(jobId);
480
- const job = this.#jobs.get(jobId);
481
- if (!lifecycle || !job) return;
482
529
  try {
483
530
  if (phase === "cancel") lifecycle.onCancel?.(job);
484
531
  else if (phase === "terminal") lifecycle.onTerminal?.(job);
@@ -554,11 +601,31 @@ export class AsyncJobManager {
554
601
  record.modelFellBack = model.modelFellBack;
555
602
  }
556
603
 
604
+ #recordFromResumeDescriptor(subagentId: string, filter?: AsyncJobFilter): SubagentRecord | undefined {
605
+ const descriptor = this.getResumeDescriptor(subagentId, filter);
606
+ if (!descriptor) return undefined;
607
+ const sessionFile = sessionFileFromResumeDescriptorData(descriptor.data);
608
+ const record: SubagentRecord = {
609
+ subagentId: descriptor.subagentId,
610
+ ownerId: descriptor.ownerId,
611
+ currentJobId: null,
612
+ historicalJobIds: [],
613
+ status: "completed",
614
+ sessionFile,
615
+ resumable: sessionFile !== null,
616
+ };
617
+ this.#subagentRecords.set(record.subagentId, record);
618
+ return record;
619
+ }
620
+
557
621
  getSubagentRecord(subagentId: string, filter?: AsyncJobFilter): SubagentRecord | undefined {
558
- const rec = this.#subagentRecords.get(subagentId.trim());
559
- if (!rec) return undefined;
560
- if (filter?.ownerId && rec.ownerId !== filter.ownerId) return undefined;
561
- return rec;
622
+ const trimmed = subagentId.trim();
623
+ const rec = this.#subagentRecords.get(trimmed);
624
+ if (rec) {
625
+ if (filter?.ownerId && rec.ownerId !== filter.ownerId) return undefined;
626
+ return rec;
627
+ }
628
+ return this.#recordFromResumeDescriptor(trimmed, filter);
562
629
  }
563
630
 
564
631
  getSubagentRecords(filter?: AsyncJobFilter): SubagentRecord[] {
@@ -649,6 +716,14 @@ export class AsyncJobManager {
649
716
  }
650
717
  }
651
718
 
719
+ #purgeTerminalSubagentStateForJob(jobId: string): void {
720
+ const rec = this.#recordByJobId(jobId);
721
+ if (!rec) return;
722
+ if (rec.status === "paused" || rec.status === "queued") return;
723
+ this.#liveHandles.delete(rec.subagentId);
724
+ this.#subagentProgress.delete(rec.subagentId);
725
+ }
726
+
652
727
  #markRecordTerminal(jobId: string, status: "completed" | "failed" | "cancelled"): void {
653
728
  const rec = this.#recordByJobId(jobId);
654
729
  if (!rec) return;
@@ -967,6 +1042,10 @@ export class AsyncJobManager {
967
1042
  getDeliveryState(filter?: AsyncJobFilter): AsyncJobDeliveryState {
968
1043
  const deliveries = this.#filterDeliveries(filter);
969
1044
  const inFlightDeliveries = this.#filterInFlightDeliveries(filter);
1045
+ const ownerId = filter?.ownerId;
1046
+ const deadLettered = Array.from(this.#deadLetteredDeliveries.values()).filter(
1047
+ delivery => !ownerId || delivery.ownerId === ownerId,
1048
+ ).length;
970
1049
  const nextRetryAt = deliveries.reduce<number | undefined>((next, delivery) => {
971
1050
  if (next === undefined) return delivery.nextAttemptAt;
972
1051
  return Math.min(next, delivery.nextAttemptAt);
@@ -977,6 +1056,7 @@ export class AsyncJobManager {
977
1056
  delivering: inFlightDeliveries.length > 0 || (this.#deliveryLoop !== undefined && deliveries.length > 0),
978
1057
  nextRetryAt,
979
1058
  pendingJobIds: deliveries.concat(inFlightDeliveries).map(delivery => delivery.jobId),
1059
+ deadLettered,
980
1060
  };
981
1061
  }
982
1062
 
@@ -1035,6 +1115,29 @@ export class AsyncJobManager {
1035
1115
  }
1036
1116
  }
1037
1117
 
1118
+ getLastDisposeDiagnostics(): AsyncJobDisposeDiagnostics {
1119
+ return { ...this.#lastDisposeDiagnostics, stuckJobIds: [...this.#lastDisposeDiagnostics.stuckJobIds] };
1120
+ }
1121
+
1122
+ async #waitForAllWithDeadline(timeoutMs: number): Promise<{ completed: boolean; stuckJobIds: string[] }> {
1123
+ const jobs = Array.from(this.#jobs.values());
1124
+ if (jobs.length === 0) return { completed: true, stuckJobIds: [] };
1125
+ let timedOut = false;
1126
+ await Promise.race([
1127
+ Promise.allSettled(jobs.map(job => job.promise)),
1128
+ Bun.sleep(Math.max(0, timeoutMs)).then(() => {
1129
+ timedOut = true;
1130
+ }),
1131
+ ]);
1132
+ if (!timedOut) return { completed: true, stuckJobIds: [] };
1133
+ return {
1134
+ completed: false,
1135
+ stuckJobIds: Array.from(this.#jobs.values())
1136
+ .filter(job => job.status === "running" || job.status === "cancelled")
1137
+ .map(job => job.id),
1138
+ };
1139
+ }
1140
+
1038
1141
  async waitForAll(): Promise<void> {
1039
1142
  await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
1040
1143
  }
@@ -1102,12 +1205,18 @@ export class AsyncJobManager {
1102
1205
  }
1103
1206
  }
1104
1207
  this.#monitorTombstones.clear();
1105
- await this.waitForAll();
1106
- const drained = await this.drainDeliveries({ timeoutMs: options?.timeoutMs ?? 3_000 });
1208
+ const timeoutMs = options?.timeoutMs ?? 3_000;
1209
+ const waitResult = await this.#waitForAllWithDeadline(timeoutMs);
1210
+ const drained = waitResult.completed ? await this.drainDeliveries({ timeoutMs }) : false;
1211
+ this.#lastDisposeDiagnostics = { stuckJobIds: waitResult.stuckJobIds, deliveriesDrained: drained };
1212
+ if (waitResult.stuckJobIds.length > 0) {
1213
+ logger.warn("Async job manager dispose timed out waiting for jobs", { stuckJobIds: waitResult.stuckJobIds });
1214
+ }
1107
1215
  this.#clearEvictionTimers();
1108
1216
  this.#jobs.clear();
1109
1217
  this.#deliveries.length = 0;
1110
1218
  this.#inFlightDeliveries.length = 0;
1219
+ this.#deadLetteredDeliveries.clear();
1111
1220
  this.#suppressedDeliveries.clear();
1112
1221
  this.#watchedJobs.clear();
1113
1222
  this.#outputState.clear();
@@ -1119,7 +1228,7 @@ export class AsyncJobManager {
1119
1228
  this.#resumeQueue.length = 0;
1120
1229
  this.#notifyChange();
1121
1230
  this.#changeListeners.clear();
1122
- return drained;
1231
+ return drained && waitResult.completed;
1123
1232
  }
1124
1233
 
1125
1234
  #resolveJobId(preferredId?: string): string {
@@ -1148,16 +1257,10 @@ export class AsyncJobManager {
1148
1257
  }
1149
1258
 
1150
1259
  #scheduleEviction(jobId: string): void {
1260
+ if (this.#disposed) return;
1151
1261
  this.#notifyChange();
1152
1262
  if (this.#retentionMs <= 0) {
1153
- this.#recordMonitorTombstone(jobId);
1154
- this.#runLifecycle(jobId, "evict");
1155
- this.#jobs.delete(jobId);
1156
- this.#lifecycles.delete(jobId);
1157
- this.#lifecyclePhases.delete(jobId);
1158
- this.#suppressedDeliveries.delete(jobId);
1159
- this.#watchedJobs.delete(jobId);
1160
- this.#outputState.delete(jobId);
1263
+ this.#evictJob(jobId);
1161
1264
  return;
1162
1265
  }
1163
1266
  const existing = this.#evictionTimers.get(jobId);
@@ -1166,20 +1269,25 @@ export class AsyncJobManager {
1166
1269
  }
1167
1270
  const timer = setTimeout(() => {
1168
1271
  this.#evictionTimers.delete(jobId);
1169
- this.#recordMonitorTombstone(jobId);
1170
- this.#runLifecycle(jobId, "evict");
1171
- this.#jobs.delete(jobId);
1172
- this.#lifecycles.delete(jobId);
1173
- this.#lifecyclePhases.delete(jobId);
1174
- this.#suppressedDeliveries.delete(jobId);
1175
- this.#watchedJobs.delete(jobId);
1176
- this.#outputState.delete(jobId);
1272
+ this.#evictJob(jobId);
1177
1273
  this.#notifyChange();
1178
1274
  }, this.#retentionMs);
1179
1275
  timer.unref();
1180
1276
  this.#evictionTimers.set(jobId, timer);
1181
1277
  }
1182
1278
 
1279
+ #evictJob(jobId: string): void {
1280
+ this.#recordMonitorTombstone(jobId);
1281
+ this.#runLifecycle(jobId, "evict");
1282
+ this.#purgeTerminalSubagentStateForJob(jobId);
1283
+ this.#jobs.delete(jobId);
1284
+ this.#lifecycles.delete(jobId);
1285
+ this.#lifecyclePhases.delete(jobId);
1286
+ this.#suppressedDeliveries.delete(jobId);
1287
+ this.#watchedJobs.delete(jobId);
1288
+ this.#outputState.delete(jobId);
1289
+ }
1290
+
1183
1291
  #clearEvictionTimers(): void {
1184
1292
  for (const timer of this.#evictionTimers.values()) {
1185
1293
  clearTimeout(timer);
@@ -1245,17 +1353,38 @@ export class AsyncJobManager {
1245
1353
  if (this.isDeliverySuppressed(jobId)) {
1246
1354
  return;
1247
1355
  }
1356
+ const deliveryText = this.#boundedDeliveryText(text);
1248
1357
  this.#deliveries.push({
1249
1358
  jobId,
1250
- text,
1359
+ text: deliveryText.text,
1360
+ originalBytes: deliveryText.originalBytes,
1361
+ truncated: deliveryText.truncated,
1251
1362
  attempt: 0,
1252
1363
  nextAttemptAt: Date.now(),
1253
1364
  ownerId: this.#jobs.get(jobId)?.ownerId,
1254
1365
  });
1366
+ while (this.#deliveries.length > DEFAULT_MAX_DELIVERY_QUEUE) {
1367
+ const dropped = this.#deliveries.shift();
1368
+ if (dropped) this.#deadLetteredDeliveries.set(dropped.jobId, dropped);
1369
+ }
1255
1370
  this.#ensureDeliveryLoop();
1256
1371
  }
1257
1372
 
1373
+ #boundedDeliveryText(text: string): { text: string; originalBytes?: number; truncated?: boolean } {
1374
+ const bytes = Buffer.byteLength(text, "utf8");
1375
+ if (bytes <= DELIVERY_MAX_TEXT_BYTES) return { text };
1376
+ const head = sliceTextToUtf8ByteLength(text, DELIVERY_PREVIEW_HEAD_BYTES);
1377
+ const tailStart = Math.max(0, bytes - DELIVERY_PREVIEW_TAIL_BYTES);
1378
+ const tail = sliceTextAfterUtf8ByteOffset(text, tailStart);
1379
+ return {
1380
+ text: `${head}\n\n[async delivery output truncated from ${bytes} bytes]\n\n${tail}`,
1381
+ originalBytes: bytes,
1382
+ truncated: true,
1383
+ };
1384
+ }
1385
+
1258
1386
  #ensureDeliveryLoop(): void {
1387
+ if (this.#disposed) return;
1259
1388
  if (this.#deliveryLoop) {
1260
1389
  return;
1261
1390
  }
@@ -1266,7 +1395,7 @@ export class AsyncJobManager {
1266
1395
  })
1267
1396
  .finally(() => {
1268
1397
  this.#deliveryLoop = undefined;
1269
- if (this.#deliveries.length > 0) {
1398
+ if (!this.#disposed && this.#deliveries.length > 0) {
1270
1399
  this.#ensureDeliveryLoop();
1271
1400
  }
1272
1401
  });
@@ -1304,20 +1433,29 @@ export class AsyncJobManager {
1304
1433
  } catch (error) {
1305
1434
  delivery.attempt += 1;
1306
1435
  delivery.lastError = error instanceof Error ? error.message : String(error);
1307
- delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
1308
- if (!this.isDeliverySuppressed(delivery.jobId)) {
1309
- this.#deliveries.push(delivery);
1436
+ if (delivery.attempt >= DELIVERY_MAX_ATTEMPTS) {
1437
+ this.#deadLetteredDeliveries.set(delivery.jobId, delivery);
1438
+ logger.warn("Async job completion delivery reached retry cap", {
1439
+ jobId: delivery.jobId,
1440
+ attempt: delivery.attempt,
1441
+ error: delivery.lastError,
1442
+ });
1443
+ } else {
1444
+ delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
1445
+ if (!this.isDeliverySuppressed(delivery.jobId)) {
1446
+ this.#deliveries.push(delivery);
1447
+ }
1448
+ logger.warn("Async job completion delivery failed", {
1449
+ jobId: delivery.jobId,
1450
+ attempt: delivery.attempt,
1451
+ nextRetryAt: delivery.nextAttemptAt,
1452
+ error: delivery.lastError,
1453
+ });
1310
1454
  }
1311
- logger.warn("Async job completion delivery failed", {
1312
- jobId: delivery.jobId,
1313
- attempt: delivery.attempt,
1314
- nextRetryAt: delivery.nextAttemptAt,
1315
- error: delivery.lastError,
1316
- });
1317
1455
  } finally {
1318
1456
  const index = this.#inFlightDeliveries.indexOf(delivery);
1319
1457
  if (index !== -1) this.#inFlightDeliveries.splice(index, 1);
1320
- if (this.#deliveries.length > 0) this.#ensureDeliveryLoop();
1458
+ if (!this.#disposed && this.#deliveries.length > 0) this.#ensureDeliveryLoop();
1321
1459
  }
1322
1460
  })();
1323
1461
  delivery.promise = promise;
@@ -101,7 +101,15 @@ function ownerLiveness(pid: number): OwnerLiveness {
101
101
 
102
102
  async function isLockStale(lockPath: string, staleMs: number): Promise<boolean> {
103
103
  const info = await readLockInfo(lockPath);
104
- if (!info) return true;
104
+ if (!info) {
105
+ try {
106
+ const stats = await fs.stat(lockPath);
107
+ return Date.now() - stats.mtimeMs > staleMs;
108
+ } catch (err) {
109
+ if (isEnoent(err)) return false;
110
+ throw err;
111
+ }
112
+ }
105
113
 
106
114
  // Never reap a live owner by elapsed time: a long legitimate critical section must
107
115
  // not have its lock stolen (#652). Reclaim a dead owner immediately. Only when owner
@@ -61,6 +61,49 @@ function resolveModelProfileName(profileName: string, profiles: ReadonlyMap<stri
61
61
  return replacement && profiles.has(replacement) ? replacement : profileName;
62
62
  }
63
63
 
64
+ /**
65
+ * Rewrite a selector only within the selector provider's own alternative group.
66
+ * Strict providers are never rewritten, and authenticated alternative providers
67
+ * keep their original selectors.
68
+ */
69
+ function rewriteSelectorProvider(
70
+ selector: string,
71
+ authenticatedProviders: ReadonlySet<string>,
72
+ alternativeGroups: readonly (readonly string[])[],
73
+ ): string {
74
+ const slash = selector.indexOf("/");
75
+ if (slash < 0) return selector;
76
+
77
+ const provider = selector.substring(0, slash);
78
+ if (authenticatedProviders.has(provider)) return selector;
79
+
80
+ const group = alternativeGroups.find(candidates => candidates.includes(provider));
81
+ if (!group) return selector;
82
+
83
+ const replacement = group.find(candidate => authenticatedProviders.has(candidate));
84
+ if (!replacement) return selector;
85
+
86
+ return replacement + selector.substring(slash);
87
+ }
88
+
89
+ function rewriteBindingsProviders(
90
+ bindings: { defaultSelector?: string; agentModelOverrides: Record<string, string> },
91
+ authenticatedProviders: ReadonlySet<string>,
92
+ alternativeGroups: readonly (readonly string[])[],
93
+ ): { defaultSelector?: string; agentModelOverrides: Record<string, string> } {
94
+ return {
95
+ defaultSelector: bindings.defaultSelector
96
+ ? rewriteSelectorProvider(bindings.defaultSelector, authenticatedProviders, alternativeGroups)
97
+ : undefined,
98
+ agentModelOverrides: Object.fromEntries(
99
+ Object.entries(bindings.agentModelOverrides).map(([role, sel]) => [
100
+ role,
101
+ rewriteSelectorProvider(sel, authenticatedProviders, alternativeGroups),
102
+ ]),
103
+ ),
104
+ };
105
+ }
106
+
64
107
  export async function prepareModelProfileActivation(
65
108
  options: PrepareModelProfileActivationOptions,
66
109
  ): Promise<PreparedModelProfileActivation> {
@@ -72,19 +115,44 @@ export async function prepareModelProfileActivation(
72
115
  throw new Error(`Unknown model profile "${options.profileName}". Available profiles: ${available}`);
73
116
  }
74
117
 
118
+ const allProviders = aggregateModelProfileRequiredProviders(profile.requiredProviders, profile);
119
+ const alternativeGroups = profile.alternativeProviderGroups ?? [];
120
+ const alternativeSet = new Set(alternativeGroups.flat());
121
+
75
122
  const missingProviders: string[] = [];
76
- for (const provider of aggregateModelProfileRequiredProviders(profile.requiredProviders, profile)) {
123
+ const authenticatedProviders: string[] = [];
124
+ for (const provider of allProviders) {
77
125
  const apiKey = await options.modelRegistry.getApiKeyForProvider(provider, options.session.sessionId);
78
126
  if (!isAuthenticated(apiKey)) {
79
127
  missingProviders.push(provider);
128
+ } else {
129
+ authenticatedProviders.push(provider);
80
130
  }
81
131
  }
82
- if (missingProviders.length > 0) {
132
+
133
+ // Check strict (non-alternative) providers — all must be authenticated.
134
+ const strictMissing = missingProviders.filter(p => !alternativeSet.has(p));
135
+ if (strictMissing.length > 0) {
136
+ throw new Error(formatModelProfileCredentialError(options.profileName, strictMissing));
137
+ }
138
+
139
+ // Check alternative groups — at least one provider per group must be authenticated.
140
+ for (const group of alternativeGroups) {
141
+ const groupAuthenticated = group.some(p => authenticatedProviders.includes(p));
142
+ if (!groupAuthenticated) {
143
+ throw new Error(formatModelProfileCredentialError(options.profileName, [...group]));
144
+ }
145
+ }
146
+
147
+ if (authenticatedProviders.length === 0) {
83
148
  throw new Error(formatModelProfileCredentialError(options.profileName, missingProviders));
84
149
  }
85
150
 
86
151
  const availableModels = options.modelRegistry.getAll();
87
- const bindings = resolveProfileBindings(profile);
152
+ let bindings = resolveProfileBindings(profile);
153
+ if (missingProviders.length > 0 && alternativeGroups.length > 0) {
154
+ bindings = rewriteBindingsProviders(bindings, new Set(authenticatedProviders), alternativeGroups);
155
+ }
88
156
  const resolvedDefault = bindings.defaultSelector
89
157
  ? resolveModelRoleValue(bindings.defaultSelector, availableModels, {
90
158
  settings: options.settings as Settings,
@@ -6,6 +6,16 @@ export type ModelProfileRole = GjcModelAssignmentTargetId;
6
6
  export interface ModelProfileDefinition {
7
7
  name: string;
8
8
  requiredProviders: string[];
9
+ /**
10
+ * Optional groups of providers that are interchangeable fallbacks.
11
+ * Each group is an array of provider ids where at least one must be
12
+ * authenticated. Providers NOT in any group are treated as strict
13
+ * requirements (all must be authenticated).
14
+ *
15
+ * Example: `[["xiaomi", "xiaomi-token-plan-sgp", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn"]]`
16
+ * means any single xiaomi credential satisfies the group.
17
+ */
18
+ alternativeProviderGroups?: readonly (readonly string[])[];
9
19
  modelMapping: Partial<Record<ModelProfileRole, string>>;
10
20
  source: "builtin" | "user";
11
21
  }
@@ -46,9 +56,11 @@ const profile = (
46
56
  name: string,
47
57
  requiredProviders: string[],
48
58
  modelMapping: Record<ModelProfileRole, string>,
59
+ alternativeProviderGroups?: readonly (readonly string[])[],
49
60
  ): ModelProfileDefinition => ({
50
61
  name,
51
62
  requiredProviders: aggregateModelProfileRequiredProviders(requiredProviders, { modelMapping }),
63
+ alternativeProviderGroups,
52
64
  modelMapping,
53
65
  source: "builtin",
54
66
  });
@@ -138,20 +150,30 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
138
150
  critic: "xiaomi/mimo-v2.5-pro:medium",
139
151
  architect: "xiaomi/mimo-v2.5-pro:high",
140
152
  }),
141
- profile("mimo-medium", ["xiaomi"], {
142
- default: "xiaomi/mimo-v2.5-pro:medium",
143
- executor: "xiaomi/mimo-v2.5-pro:low",
144
- planner: "xiaomi/mimo-v2.5-pro:medium",
145
- critic: "xiaomi/mimo-v2.5-pro:high",
146
- architect: "xiaomi/mimo-v2.5-pro:xhigh",
147
- }),
148
- profile("mimo-pro", ["xiaomi"], {
149
- default: "xiaomi/mimo-v2.5-pro:xhigh",
150
- executor: "xiaomi/mimo-v2.5-pro:medium",
151
- planner: "xiaomi/mimo-v2.5-pro:high",
152
- critic: "xiaomi/mimo-v2.5-pro:xhigh",
153
- architect: "xiaomi/mimo-v2.5-pro:xhigh",
154
- }),
153
+ profile(
154
+ "mimo-medium",
155
+ ["xiaomi", "xiaomi-token-plan-sgp", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn"],
156
+ {
157
+ default: "xiaomi/mimo-v2.5-pro:medium",
158
+ executor: "xiaomi/mimo-v2.5-pro:low",
159
+ planner: "xiaomi/mimo-v2.5-pro:medium",
160
+ critic: "xiaomi/mimo-v2.5-pro:high",
161
+ architect: "xiaomi/mimo-v2.5-pro:xhigh",
162
+ },
163
+ [["xiaomi", "xiaomi-token-plan-sgp", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn"]],
164
+ ),
165
+ profile(
166
+ "mimo-pro",
167
+ ["xiaomi", "xiaomi-token-plan-sgp", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn"],
168
+ {
169
+ default: "xiaomi/mimo-v2.5-pro:xhigh",
170
+ executor: "xiaomi/mimo-v2.5-pro:medium",
171
+ planner: "xiaomi/mimo-v2.5-pro:high",
172
+ critic: "xiaomi/mimo-v2.5-pro:xhigh",
173
+ architect: "xiaomi/mimo-v2.5-pro:xhigh",
174
+ },
175
+ [["xiaomi", "xiaomi-token-plan-sgp", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn"]],
176
+ ),
155
177
  profile("grok-eco", ["xai"], {
156
178
  default: "xai/grok-4.3:low",
157
179
  executor: "xai/grok-4.3:minimal",
@@ -292,6 +314,9 @@ const PROFILE_RECOMMENDATIONS: Record<string, string> = {
292
314
  zai: "glm-medium",
293
315
  "kimi-code": "kimi-coding-plan-medium",
294
316
  xiaomi: "mimo-medium",
317
+ "xiaomi-token-plan-sgp": "mimo-medium",
318
+ "xiaomi-token-plan-ams": "mimo-medium",
319
+ "xiaomi-token-plan-cn": "mimo-medium",
295
320
  xai: "grok-medium",
296
321
  "grok-build": "grok-build-pro",
297
322
  cursor: "cursor-medium",