@gajae-code/coding-agent 0.5.2 → 0.5.3

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 (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/dap/client.d.ts +2 -1
  4. package/dist/types/edit/read-file.d.ts +6 -0
  5. package/dist/types/eval/js/context-manager.d.ts +3 -0
  6. package/dist/types/eval/js/executor.d.ts +1 -0
  7. package/dist/types/exec/bash-executor.d.ts +2 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  11. package/dist/types/modes/components/model-selector.d.ts +2 -0
  12. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  13. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  15. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  16. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  17. package/dist/types/runtime-mcp/types.d.ts +2 -0
  18. package/dist/types/session/agent-session.d.ts +17 -1
  19. package/dist/types/session/artifacts.d.ts +4 -1
  20. package/dist/types/session/streaming-output.d.ts +5 -0
  21. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  22. package/dist/types/tools/bash.d.ts +1 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  24. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  25. package/package.json +7 -7
  26. package/src/async/job-manager.ts +153 -39
  27. package/src/config/file-lock.ts +9 -1
  28. package/src/dap/client.ts +105 -64
  29. package/src/dap/session.ts +44 -7
  30. package/src/edit/read-file.ts +19 -1
  31. package/src/eval/js/context-manager.ts +228 -65
  32. package/src/eval/js/executor.ts +2 -0
  33. package/src/eval/js/index.ts +1 -0
  34. package/src/eval/js/worker-core.ts +10 -6
  35. package/src/eval/py/executor.ts +68 -19
  36. package/src/eval/py/kernel.ts +46 -22
  37. package/src/eval/py/runner.py +68 -14
  38. package/src/exec/bash-executor.ts +49 -13
  39. package/src/gjc-runtime/tmux-gc.ts +86 -37
  40. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  41. package/src/internal-urls/artifact-protocol.ts +10 -1
  42. package/src/internal-urls/docs-index.generated.ts +2 -2
  43. package/src/lsp/client.ts +64 -26
  44. package/src/lsp/index.ts +2 -1
  45. package/src/lsp/lspmux.ts +33 -9
  46. package/src/lsp/types.ts +2 -0
  47. package/src/modes/bridge/bridge-mode.ts +21 -0
  48. package/src/modes/components/assistant-message.ts +10 -2
  49. package/src/modes/components/bash-execution.ts +5 -1
  50. package/src/modes/components/eval-execution.ts +5 -1
  51. package/src/modes/components/model-selector.ts +34 -2
  52. package/src/modes/components/oauth-selector.ts +5 -0
  53. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  54. package/src/modes/components/skill-message.ts +24 -16
  55. package/src/modes/components/tool-execution.ts +6 -0
  56. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  57. package/src/modes/controllers/input-controller.ts +5 -0
  58. package/src/modes/controllers/selector-controller.ts +6 -1
  59. package/src/modes/utils/ui-helpers.ts +5 -2
  60. package/src/runtime/process-lifecycle.ts +400 -0
  61. package/src/runtime-mcp/manager.ts +164 -50
  62. package/src/runtime-mcp/transports/http.ts +12 -11
  63. package/src/runtime-mcp/transports/stdio.ts +64 -38
  64. package/src/runtime-mcp/types.ts +3 -0
  65. package/src/sdk.ts +27 -0
  66. package/src/session/agent-session.ts +168 -22
  67. package/src/session/artifacts.ts +17 -2
  68. package/src/session/blob-store.ts +36 -2
  69. package/src/session/session-manager.ts +29 -13
  70. package/src/session/streaming-output.ts +54 -3
  71. package/src/slash-commands/builtin-registry.ts +30 -3
  72. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  73. package/src/tools/archive-reader.ts +10 -1
  74. package/src/tools/bash.ts +11 -4
  75. package/src/tools/browser/tab-supervisor.ts +22 -0
  76. package/src/tools/browser.ts +38 -4
  77. package/src/tools/read.ts +11 -12
  78. package/src/tools/sqlite-reader.ts +19 -5
@@ -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;
@@ -128,9 +133,16 @@ export interface AsyncJobManagerOptions {
128
133
  retentionMs?: number;
129
134
  }
130
135
 
136
+ export interface AsyncJobDisposeDiagnostics {
137
+ stuckJobIds: string[];
138
+ deliveriesDrained: boolean;
139
+ }
140
+
131
141
  interface AsyncJobDelivery {
132
142
  jobId: string;
133
143
  text: string;
144
+ originalBytes?: number;
145
+ truncated?: boolean;
134
146
  attempt: number;
135
147
  nextAttemptAt: number;
136
148
  lastError?: string;
@@ -143,6 +155,7 @@ export interface AsyncJobDeliveryState {
143
155
  delivering: boolean;
144
156
  nextRetryAt?: number;
145
157
  pendingJobIds: string[];
158
+ deadLettered: number;
146
159
  }
147
160
 
148
161
  export interface AsyncJobLifecycleCleanup {
@@ -198,6 +211,32 @@ function sliceTextFromUtf8ByteOffset(text: string, offsetBytes: number): string
198
211
  return text.slice(codeUnitIndex);
199
212
  }
200
213
 
214
+ function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string {
215
+ if (offsetBytes <= 0) return text;
216
+ let consumedBytes = 0;
217
+ let codeUnitIndex = 0;
218
+ for (const char of text) {
219
+ const charBytes = Buffer.byteLength(char, "utf8");
220
+ consumedBytes += charBytes;
221
+ codeUnitIndex += char.length;
222
+ if (consumedBytes >= offsetBytes) break;
223
+ }
224
+ return text.slice(codeUnitIndex);
225
+ }
226
+
227
+ function sliceTextToUtf8ByteLength(text: string, maxBytes: number): string {
228
+ if (maxBytes <= 0) return "";
229
+ let consumedBytes = 0;
230
+ let codeUnitIndex = 0;
231
+ for (const char of text) {
232
+ const charBytes = Buffer.byteLength(char, "utf8");
233
+ if (consumedBytes + charBytes > maxBytes) break;
234
+ consumedBytes += charBytes;
235
+ codeUnitIndex += char.length;
236
+ }
237
+ return text.slice(0, codeUnitIndex);
238
+ }
239
+
201
240
  /**
202
241
  * A slice of process-stream output for a background job, as recorded by
203
242
  * `appendOutput` / read by `readOutputSince`.
@@ -277,6 +316,8 @@ export class AsyncJobManager {
277
316
  #resumeSeq = 0;
278
317
  #resumeRunner?: (subagentId: string, message?: string, descriptor?: ResumeDescriptor) => string | undefined;
279
318
  readonly #resumeDescriptors = new Map<string, ResumeDescriptor>();
319
+ readonly #deadLetteredDeliveries = new Map<string, AsyncJobDelivery>();
320
+ #lastDisposeDiagnostics: AsyncJobDisposeDiagnostics = { stuckJobIds: [], deliveriesDrained: true };
280
321
  /**
281
322
  * Change listeners notified on any mutation that can alter the live job set
282
323
  * (register, terminal/eviction transitions, dispose). Used by the status-line
@@ -381,7 +422,7 @@ export class AsyncJobManager {
381
422
 
382
423
  if (job.status === "cancelled") {
383
424
  job.resultText = outcome.kind === "completed" ? outcome.text : outcome.note;
384
- this.#runLifecycle(id, "terminal");
425
+ this.#runLifecycle(id, "terminal", job);
385
426
  this.#scheduleEviction(id);
386
427
  this.#markRecordTerminal(id, "cancelled");
387
428
  this.#drainResumeQueue();
@@ -403,20 +444,20 @@ export class AsyncJobManager {
403
444
  this.#freezeEndTime(job);
404
445
  job.resultText = outcome.text;
405
446
  this.#enqueueDelivery(id, outcome.text);
406
- this.#runLifecycle(id, "terminal");
447
+ this.#runLifecycle(id, "terminal", job);
407
448
  this.#scheduleEviction(id);
408
449
  this.#markRecordTerminal(id, "completed");
409
450
  this.#drainResumeQueue();
410
451
  } catch (error) {
411
452
  if (job.status === "cancelled") {
412
453
  job.errorText = error instanceof Error ? error.message : String(error);
413
- this.#runLifecycle(id, "terminal");
454
+ this.#runLifecycle(id, "terminal", job);
414
455
  this.#scheduleEviction(id);
415
456
  this.#markRecordTerminal(id, "cancelled");
416
457
  this.#drainResumeQueue();
417
458
  return;
418
459
  }
419
- this.#runLifecycle(id, "terminal");
460
+ this.#runLifecycle(id, "terminal", job);
420
461
  const errorText = error instanceof Error ? error.message : String(error);
421
462
  job.status = "failed";
422
463
  this.#freezeEndTime(job);
@@ -471,14 +512,14 @@ export class AsyncJobManager {
471
512
  job.endTime ??= Date.now();
472
513
  }
473
514
 
474
- #runLifecycle(jobId: string, phase: "cancel" | "terminal" | "evict"): void {
515
+ #runLifecycle(jobId: string, phase: "cancel" | "terminal" | "evict", jobOverride?: AsyncJob): void {
516
+ const lifecycle = this.#lifecycles.get(jobId);
517
+ const job = jobOverride ?? this.#jobs.get(jobId);
518
+ if (!lifecycle || !job) return;
475
519
  const fired = this.#lifecyclePhases.get(jobId) ?? new Set<"cancel" | "terminal" | "evict">();
476
520
  if (fired.has(phase)) return;
477
521
  fired.add(phase);
478
522
  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
523
  try {
483
524
  if (phase === "cancel") lifecycle.onCancel?.(job);
484
525
  else if (phase === "terminal") lifecycle.onTerminal?.(job);
@@ -649,6 +690,16 @@ export class AsyncJobManager {
649
690
  }
650
691
  }
651
692
 
693
+ #purgeTerminalSubagentStateForJob(jobId: string): void {
694
+ const rec = this.#recordByJobId(jobId);
695
+ if (!rec) return;
696
+ if (rec.status === "paused" || rec.status === "queued") return;
697
+ this.#liveHandles.delete(rec.subagentId);
698
+ this.#subagentProgress.delete(rec.subagentId);
699
+ this.#resumeDescriptors.delete(rec.subagentId);
700
+ this.#subagentRecords.delete(rec.subagentId);
701
+ }
702
+
652
703
  #markRecordTerminal(jobId: string, status: "completed" | "failed" | "cancelled"): void {
653
704
  const rec = this.#recordByJobId(jobId);
654
705
  if (!rec) return;
@@ -967,6 +1018,10 @@ export class AsyncJobManager {
967
1018
  getDeliveryState(filter?: AsyncJobFilter): AsyncJobDeliveryState {
968
1019
  const deliveries = this.#filterDeliveries(filter);
969
1020
  const inFlightDeliveries = this.#filterInFlightDeliveries(filter);
1021
+ const ownerId = filter?.ownerId;
1022
+ const deadLettered = Array.from(this.#deadLetteredDeliveries.values()).filter(
1023
+ delivery => !ownerId || delivery.ownerId === ownerId,
1024
+ ).length;
970
1025
  const nextRetryAt = deliveries.reduce<number | undefined>((next, delivery) => {
971
1026
  if (next === undefined) return delivery.nextAttemptAt;
972
1027
  return Math.min(next, delivery.nextAttemptAt);
@@ -977,6 +1032,7 @@ export class AsyncJobManager {
977
1032
  delivering: inFlightDeliveries.length > 0 || (this.#deliveryLoop !== undefined && deliveries.length > 0),
978
1033
  nextRetryAt,
979
1034
  pendingJobIds: deliveries.concat(inFlightDeliveries).map(delivery => delivery.jobId),
1035
+ deadLettered,
980
1036
  };
981
1037
  }
982
1038
 
@@ -1035,6 +1091,29 @@ export class AsyncJobManager {
1035
1091
  }
1036
1092
  }
1037
1093
 
1094
+ getLastDisposeDiagnostics(): AsyncJobDisposeDiagnostics {
1095
+ return { ...this.#lastDisposeDiagnostics, stuckJobIds: [...this.#lastDisposeDiagnostics.stuckJobIds] };
1096
+ }
1097
+
1098
+ async #waitForAllWithDeadline(timeoutMs: number): Promise<{ completed: boolean; stuckJobIds: string[] }> {
1099
+ const jobs = Array.from(this.#jobs.values());
1100
+ if (jobs.length === 0) return { completed: true, stuckJobIds: [] };
1101
+ let timedOut = false;
1102
+ await Promise.race([
1103
+ Promise.allSettled(jobs.map(job => job.promise)),
1104
+ Bun.sleep(Math.max(0, timeoutMs)).then(() => {
1105
+ timedOut = true;
1106
+ }),
1107
+ ]);
1108
+ if (!timedOut) return { completed: true, stuckJobIds: [] };
1109
+ return {
1110
+ completed: false,
1111
+ stuckJobIds: Array.from(this.#jobs.values())
1112
+ .filter(job => job.status === "running" || job.status === "cancelled")
1113
+ .map(job => job.id),
1114
+ };
1115
+ }
1116
+
1038
1117
  async waitForAll(): Promise<void> {
1039
1118
  await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
1040
1119
  }
@@ -1102,12 +1181,18 @@ export class AsyncJobManager {
1102
1181
  }
1103
1182
  }
1104
1183
  this.#monitorTombstones.clear();
1105
- await this.waitForAll();
1106
- const drained = await this.drainDeliveries({ timeoutMs: options?.timeoutMs ?? 3_000 });
1184
+ const timeoutMs = options?.timeoutMs ?? 3_000;
1185
+ const waitResult = await this.#waitForAllWithDeadline(timeoutMs);
1186
+ const drained = waitResult.completed ? await this.drainDeliveries({ timeoutMs }) : false;
1187
+ this.#lastDisposeDiagnostics = { stuckJobIds: waitResult.stuckJobIds, deliveriesDrained: drained };
1188
+ if (waitResult.stuckJobIds.length > 0) {
1189
+ logger.warn("Async job manager dispose timed out waiting for jobs", { stuckJobIds: waitResult.stuckJobIds });
1190
+ }
1107
1191
  this.#clearEvictionTimers();
1108
1192
  this.#jobs.clear();
1109
1193
  this.#deliveries.length = 0;
1110
1194
  this.#inFlightDeliveries.length = 0;
1195
+ this.#deadLetteredDeliveries.clear();
1111
1196
  this.#suppressedDeliveries.clear();
1112
1197
  this.#watchedJobs.clear();
1113
1198
  this.#outputState.clear();
@@ -1119,7 +1204,7 @@ export class AsyncJobManager {
1119
1204
  this.#resumeQueue.length = 0;
1120
1205
  this.#notifyChange();
1121
1206
  this.#changeListeners.clear();
1122
- return drained;
1207
+ return drained && waitResult.completed;
1123
1208
  }
1124
1209
 
1125
1210
  #resolveJobId(preferredId?: string): string {
@@ -1148,16 +1233,10 @@ export class AsyncJobManager {
1148
1233
  }
1149
1234
 
1150
1235
  #scheduleEviction(jobId: string): void {
1236
+ if (this.#disposed) return;
1151
1237
  this.#notifyChange();
1152
1238
  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);
1239
+ this.#evictJob(jobId);
1161
1240
  return;
1162
1241
  }
1163
1242
  const existing = this.#evictionTimers.get(jobId);
@@ -1166,20 +1245,25 @@ export class AsyncJobManager {
1166
1245
  }
1167
1246
  const timer = setTimeout(() => {
1168
1247
  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);
1248
+ this.#evictJob(jobId);
1177
1249
  this.#notifyChange();
1178
1250
  }, this.#retentionMs);
1179
1251
  timer.unref();
1180
1252
  this.#evictionTimers.set(jobId, timer);
1181
1253
  }
1182
1254
 
1255
+ #evictJob(jobId: string): void {
1256
+ this.#recordMonitorTombstone(jobId);
1257
+ this.#runLifecycle(jobId, "evict");
1258
+ this.#purgeTerminalSubagentStateForJob(jobId);
1259
+ this.#jobs.delete(jobId);
1260
+ this.#lifecycles.delete(jobId);
1261
+ this.#lifecyclePhases.delete(jobId);
1262
+ this.#suppressedDeliveries.delete(jobId);
1263
+ this.#watchedJobs.delete(jobId);
1264
+ this.#outputState.delete(jobId);
1265
+ }
1266
+
1183
1267
  #clearEvictionTimers(): void {
1184
1268
  for (const timer of this.#evictionTimers.values()) {
1185
1269
  clearTimeout(timer);
@@ -1245,17 +1329,38 @@ export class AsyncJobManager {
1245
1329
  if (this.isDeliverySuppressed(jobId)) {
1246
1330
  return;
1247
1331
  }
1332
+ const deliveryText = this.#boundedDeliveryText(text);
1248
1333
  this.#deliveries.push({
1249
1334
  jobId,
1250
- text,
1335
+ text: deliveryText.text,
1336
+ originalBytes: deliveryText.originalBytes,
1337
+ truncated: deliveryText.truncated,
1251
1338
  attempt: 0,
1252
1339
  nextAttemptAt: Date.now(),
1253
1340
  ownerId: this.#jobs.get(jobId)?.ownerId,
1254
1341
  });
1342
+ while (this.#deliveries.length > DEFAULT_MAX_DELIVERY_QUEUE) {
1343
+ const dropped = this.#deliveries.shift();
1344
+ if (dropped) this.#deadLetteredDeliveries.set(dropped.jobId, dropped);
1345
+ }
1255
1346
  this.#ensureDeliveryLoop();
1256
1347
  }
1257
1348
 
1349
+ #boundedDeliveryText(text: string): { text: string; originalBytes?: number; truncated?: boolean } {
1350
+ const bytes = Buffer.byteLength(text, "utf8");
1351
+ if (bytes <= DELIVERY_MAX_TEXT_BYTES) return { text };
1352
+ const head = sliceTextToUtf8ByteLength(text, DELIVERY_PREVIEW_HEAD_BYTES);
1353
+ const tailStart = Math.max(0, bytes - DELIVERY_PREVIEW_TAIL_BYTES);
1354
+ const tail = sliceTextAfterUtf8ByteOffset(text, tailStart);
1355
+ return {
1356
+ text: `${head}\n\n[async delivery output truncated from ${bytes} bytes]\n\n${tail}`,
1357
+ originalBytes: bytes,
1358
+ truncated: true,
1359
+ };
1360
+ }
1361
+
1258
1362
  #ensureDeliveryLoop(): void {
1363
+ if (this.#disposed) return;
1259
1364
  if (this.#deliveryLoop) {
1260
1365
  return;
1261
1366
  }
@@ -1266,7 +1371,7 @@ export class AsyncJobManager {
1266
1371
  })
1267
1372
  .finally(() => {
1268
1373
  this.#deliveryLoop = undefined;
1269
- if (this.#deliveries.length > 0) {
1374
+ if (!this.#disposed && this.#deliveries.length > 0) {
1270
1375
  this.#ensureDeliveryLoop();
1271
1376
  }
1272
1377
  });
@@ -1304,20 +1409,29 @@ export class AsyncJobManager {
1304
1409
  } catch (error) {
1305
1410
  delivery.attempt += 1;
1306
1411
  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);
1412
+ if (delivery.attempt >= DELIVERY_MAX_ATTEMPTS) {
1413
+ this.#deadLetteredDeliveries.set(delivery.jobId, delivery);
1414
+ logger.warn("Async job completion delivery reached retry cap", {
1415
+ jobId: delivery.jobId,
1416
+ attempt: delivery.attempt,
1417
+ error: delivery.lastError,
1418
+ });
1419
+ } else {
1420
+ delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
1421
+ if (!this.isDeliverySuppressed(delivery.jobId)) {
1422
+ this.#deliveries.push(delivery);
1423
+ }
1424
+ logger.warn("Async job completion delivery failed", {
1425
+ jobId: delivery.jobId,
1426
+ attempt: delivery.attempt,
1427
+ nextRetryAt: delivery.nextAttemptAt,
1428
+ error: delivery.lastError,
1429
+ });
1310
1430
  }
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
1431
  } finally {
1318
1432
  const index = this.#inFlightDeliveries.indexOf(delivery);
1319
1433
  if (index !== -1) this.#inFlightDeliveries.splice(index, 1);
1320
- if (this.#deliveries.length > 0) this.#ensureDeliveryLoop();
1434
+ if (!this.#disposed && this.#deliveries.length > 0) this.#ensureDeliveryLoop();
1321
1435
  }
1322
1436
  })();
1323
1437
  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
package/src/dap/client.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { logger, ptree } from "@gajae-code/utils";
1
+ import { existsSync } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import { logger } from "@gajae-code/utils";
2
4
  import { formatCrashDiagnosticNotice, writeCrashReport } from "../debug/crash-diagnostics";
3
5
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
6
+ import { type OwnedProcess, spawnOwnedProcess } from "../runtime/process-lifecycle";
4
7
  import { ToolAbortError } from "../tools/tool-errors";
5
8
  import type {
6
9
  DapCapabilities,
@@ -69,10 +72,21 @@ function toErrorMessage(value: unknown): string {
69
72
  return String(value);
70
73
  }
71
74
 
75
+ async function drainReadable(readable: ReadableStream<Uint8Array>): Promise<void> {
76
+ const reader = readable.getReader();
77
+ try {
78
+ while (!(await reader.read()).done) {}
79
+ } catch {
80
+ /* drain best-effort */
81
+ } finally {
82
+ reader.releaseLock();
83
+ }
84
+ }
72
85
  export class DapClient {
73
86
  readonly adapter: DapResolvedAdapter;
74
87
  readonly cwd: string;
75
88
  readonly proc: DapClientState["proc"];
89
+ readonly #owner: OwnedProcess;
76
90
  /** ReadableStream of DAP bytes — from proc.stdout (stdio) or a socket (socket mode). */
77
91
  readonly #readable: ReadableStream<Uint8Array>;
78
92
  /** Write sink — proc.stdin (stdio) or a socket (socket mode). */
@@ -93,14 +107,15 @@ export class DapClient {
93
107
  constructor(
94
108
  adapter: DapResolvedAdapter,
95
109
  cwd: string,
96
- proc: DapClientState["proc"],
110
+ owner: OwnedProcess,
97
111
  options?: { readable?: ReadableStream<Uint8Array>; writeSink?: DapWriteSink; socket?: { end(): void } },
98
112
  ) {
99
113
  this.adapter = adapter;
100
114
  this.cwd = cwd;
101
- this.proc = proc;
102
- this.#readable = options?.readable ?? (proc.stdout as ReadableStream<Uint8Array>);
103
- this.#writeSink = options?.writeSink ?? proc.stdin;
115
+ this.proc = owner.child as DapClientState["proc"];
116
+ this.#owner = owner;
117
+ this.#readable = options?.readable ?? (this.proc.stdout as ReadableStream<Uint8Array>);
118
+ this.#writeSink = options?.writeSink ?? this.proc.stdin;
104
119
  this.#socket = options?.socket;
105
120
  }
106
121
 
@@ -116,13 +131,14 @@ export class DapClient {
116
131
  ...Bun.env,
117
132
  ...NON_INTERACTIVE_ENV,
118
133
  };
119
- const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args], {
134
+ const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args], {
120
135
  cwd,
121
136
  stdin: "pipe",
122
137
  env,
123
- detached: true,
138
+ name: `dap:${adapter.name}`,
124
139
  });
125
- const client = new DapClient(adapter, cwd, proc);
140
+ const client = new DapClient(adapter, cwd, owner);
141
+ const proc = owner.child as DapClientState["proc"];
126
142
  proc.exited.then(() => {
127
143
  client.#handleProcessExit();
128
144
  });
@@ -159,32 +175,40 @@ export class DapClient {
159
175
  env: Record<string, string | undefined>;
160
176
  }): Promise<DapClient> {
161
177
  const socketPath = `/tmp/dap-${adapter.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`;
162
- const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args, `--listen=unix:${socketPath}`], {
178
+ const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args, `--listen=unix:${socketPath}`], {
163
179
  cwd,
164
180
  stdin: "pipe",
165
181
  env,
166
- detached: true,
182
+ name: `dap:${adapter.name}:unix-socket`,
167
183
  });
184
+ const proc = owner.child as DapClientState["proc"];
185
+ void drainReadable(proc.stdout);
186
+ let transport: SocketTransport | undefined;
168
187
 
169
- // Wait for the socket file to appear (dlv needs to start listening)
170
- await waitForCondition(
171
- () => {
172
- try {
173
- Bun.file(socketPath).size;
174
- return true;
175
- } catch {
176
- return false;
177
- }
178
- },
179
- 10_000,
180
- proc,
181
- );
188
+ try {
189
+ // Wait for the socket file to appear (dlv needs to start listening)
190
+ await waitForCondition(
191
+ // `Bun.file(path).size` returns 0 for a missing file instead of
192
+ // throwing, so it can't gate socket readiness. Use an existence
193
+ // check so the adapter has actually created the listener socket.
194
+ () => existsSync(socketPath),
195
+ 10_000,
196
+ proc,
197
+ );
182
198
 
183
- const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
184
- const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
185
- proc.exited.then(() => client.#handleProcessExit());
186
- void client.#startMessageReader();
187
- return client;
199
+ transport = await connectSocket({ unix: socketPath }, 10_000);
200
+ const client = new DapClient(adapter, cwd, owner, transport);
201
+ proc.exited.then(() => client.#handleProcessExit());
202
+ void client.#startMessageReader();
203
+ return client;
204
+ } catch (err) {
205
+ transport?.socket.end();
206
+ await owner.dispose();
207
+ await owner.awaitExit({ timeoutMs: 1_000 });
208
+ throw err;
209
+ } finally {
210
+ await fs.unlink(socketPath).catch(() => undefined);
211
+ }
188
212
  }
189
213
 
190
214
  /** macOS/other: listen on a random TCP port, spawn adapter with --client-addr, accept connection. */
@@ -214,12 +238,14 @@ export class DapClient {
214
238
  });
215
239
 
216
240
  const port = server.port;
217
- const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args, `--client-addr=127.0.0.1:${port}`], {
241
+ const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args, `--client-addr=127.0.0.1:${port}`], {
218
242
  cwd,
219
243
  stdin: "pipe",
220
244
  env,
221
- detached: true,
245
+ name: `dap:${adapter.name}:client-addr`,
222
246
  });
247
+ const proc = owner.child as DapClientState["proc"];
248
+ void drainReadable(proc.stdout);
223
249
 
224
250
  // Wait for dlv to connect (with timeout)
225
251
  let rawSocket: Bun.Socket<undefined>;
@@ -230,13 +256,17 @@ export class DapClient {
230
256
  );
231
257
  try {
232
258
  rawSocket = await Promise.race([connPromise, timeoutPromise]);
259
+ } catch (err) {
260
+ await owner.dispose();
261
+ await owner.awaitExit({ timeoutMs: 1_000 });
262
+ throw err;
233
263
  } finally {
234
264
  clearTimeout(connectTimeout);
235
265
  server.stop();
236
266
  }
237
267
 
238
268
  const { readable, writeSink, socket } = wrapBunSocket(rawSocket);
239
- const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
269
+ const client = new DapClient(adapter, cwd, owner, { readable, writeSink, socket });
240
270
  proc.exited.then(() => client.#handleProcessExit());
241
271
  void client.#startMessageReader();
242
272
  return client;
@@ -414,14 +444,14 @@ export class DapClient {
414
444
  /* socket may already be closed */
415
445
  }
416
446
  try {
417
- this.proc.kill();
447
+ await this.#owner.dispose();
448
+ await this.#owner.awaitExit({ timeoutMs: 1_000 });
418
449
  } catch (error) {
419
- logger.debug("Failed to kill DAP adapter", {
450
+ logger.debug("Failed to dispose DAP adapter", {
420
451
  adapter: this.adapter.name,
421
452
  error: toErrorMessage(error),
422
453
  });
423
454
  }
424
- await this.proc.exited.catch(() => {});
425
455
  }
426
456
 
427
457
  async #startMessageReader(): Promise<void> {
@@ -604,8 +634,8 @@ function socketToSink(socket: Bun.Socket<undefined>): DapWriteSink {
604
634
  }
605
635
 
606
636
  /** Connect to a unix domain socket and return DAP transport streams. */
607
- async function connectSocket(options: { unix: string }): Promise<SocketTransport> {
608
- const { promise, resolve } = Promise.withResolvers<SocketTransport>();
637
+ async function connectSocket(options: { unix: string }, timeoutMs = 10_000): Promise<SocketTransport> {
638
+ const { promise, resolve, reject } = Promise.withResolvers<SocketTransport>();
609
639
  let streamController: ReadableStreamDefaultController<Uint8Array>;
610
640
 
611
641
  const readable = new ReadableStream<Uint8Array>({
@@ -614,35 +644,46 @@ async function connectSocket(options: { unix: string }): Promise<SocketTransport
614
644
  },
615
645
  });
616
646
 
617
- Bun.connect({
618
- unix: options.unix,
619
- socket: {
620
- open(socket) {
621
- resolve({
622
- readable,
623
- writeSink: socketToSink(socket),
624
- socket,
625
- });
626
- },
627
- data(_socket, data) {
628
- streamController.enqueue(new Uint8Array(data));
629
- },
630
- close() {
631
- try {
632
- streamController.close();
633
- } catch {
634
- /* already closed */
635
- }
636
- },
637
- error(_socket, err) {
638
- try {
639
- streamController.error(err);
640
- } catch {
641
- /* already closed */
642
- }
647
+ const timeout = setTimeout(() => reject(new Error(`Socket connect timed out after ${timeoutMs}ms`)), timeoutMs);
648
+ let settled = false;
649
+ const settle = (fn: () => void) => {
650
+ if (settled) return;
651
+ settled = true;
652
+ clearTimeout(timeout);
653
+ fn();
654
+ };
655
+ try {
656
+ const socketPromise = Bun.connect({
657
+ unix: options.unix,
658
+ socket: {
659
+ open(socket) {
660
+ settle(() =>
661
+ resolve({
662
+ readable,
663
+ writeSink: socketToSink(socket),
664
+ socket,
665
+ }),
666
+ );
667
+ },
668
+ data(_socket, data) {
669
+ streamController.enqueue(new Uint8Array(data));
670
+ },
671
+ close() {
672
+ try {
673
+ streamController.close();
674
+ } catch {
675
+ /* already closed */
676
+ }
677
+ },
678
+ error(_socket, err) {
679
+ settle(() => reject(err));
680
+ },
643
681
  },
644
- },
645
- });
682
+ });
683
+ void socketPromise.catch(err => settle(() => reject(err)));
684
+ } catch (err) {
685
+ settle(() => reject(err));
686
+ }
646
687
 
647
688
  return promise;
648
689
  }