llm-cli-gateway 2.13.2 → 2.14.0

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 (79) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +68 -29
  3. package/dist/acp/client.d.ts +29 -1
  4. package/dist/acp/client.js +78 -4
  5. package/dist/acp/errors.d.ts +9 -1
  6. package/dist/acp/errors.js +19 -0
  7. package/dist/acp/event-normalizer.d.ts +12 -0
  8. package/dist/acp/event-normalizer.js +16 -0
  9. package/dist/acp/flight-redaction.d.ts +3 -0
  10. package/dist/acp/flight-redaction.js +3 -0
  11. package/dist/acp/permission-bridge.js +11 -5
  12. package/dist/acp/process-manager.d.ts +2 -1
  13. package/dist/acp/process-manager.js +43 -4
  14. package/dist/acp/provider-registry.js +19 -11
  15. package/dist/acp/runtime.d.ts +8 -0
  16. package/dist/acp/runtime.js +47 -4
  17. package/dist/acp/types.d.ts +3083 -55
  18. package/dist/acp/types.js +242 -5
  19. package/dist/async-job-manager.d.ts +38 -1
  20. package/dist/async-job-manager.js +287 -20
  21. package/dist/codex-json-parser.d.ts +1 -0
  22. package/dist/codex-json-parser.js +6 -0
  23. package/dist/config.d.ts +19 -0
  24. package/dist/config.js +84 -1
  25. package/dist/flight-recorder.d.ts +2 -0
  26. package/dist/flight-recorder.js +20 -0
  27. package/dist/gemini-json-parser.d.ts +2 -0
  28. package/dist/gemini-json-parser.js +45 -8
  29. package/dist/grok-json-parser.d.ts +14 -0
  30. package/dist/grok-json-parser.js +156 -0
  31. package/dist/index.d.ts +47 -2
  32. package/dist/index.js +504 -122
  33. package/dist/job-store.d.ts +119 -11
  34. package/dist/job-store.js +372 -42
  35. package/dist/model-registry.d.ts +1 -0
  36. package/dist/model-registry.js +46 -0
  37. package/dist/oauth.js +2 -2
  38. package/dist/postgres-job-store-worker.d.ts +1 -0
  39. package/dist/postgres-job-store-worker.js +444 -0
  40. package/dist/pricing.d.ts +3 -2
  41. package/dist/provider-acp-capabilities.d.ts +52 -0
  42. package/dist/provider-acp-capabilities.js +101 -0
  43. package/dist/provider-admin-tools.d.ts +100 -0
  44. package/dist/provider-admin-tools.js +572 -0
  45. package/dist/provider-capability-cache.d.ts +46 -0
  46. package/dist/provider-capability-cache.js +248 -0
  47. package/dist/provider-capability-discovery.d.ts +85 -0
  48. package/dist/provider-capability-discovery.js +461 -0
  49. package/dist/provider-capability-resolver.d.ts +29 -0
  50. package/dist/provider-capability-resolver.js +92 -0
  51. package/dist/provider-definition-assertions.d.ts +9 -0
  52. package/dist/provider-definition-assertions.js +147 -0
  53. package/dist/provider-definitions.d.ts +127 -0
  54. package/dist/provider-definitions.js +758 -0
  55. package/dist/provider-help-parser.d.ts +34 -0
  56. package/dist/provider-help-parser.js +203 -0
  57. package/dist/provider-model-discovery.d.ts +30 -0
  58. package/dist/provider-model-discovery.js +229 -0
  59. package/dist/provider-output-metadata.d.ts +7 -0
  60. package/dist/provider-output-metadata.js +68 -0
  61. package/dist/provider-schema-builder.d.ts +22 -0
  62. package/dist/provider-schema-builder.js +55 -0
  63. package/dist/provider-surface-generator.d.ts +98 -0
  64. package/dist/provider-surface-generator.js +140 -0
  65. package/dist/provider-tool-capabilities.d.ts +3 -2
  66. package/dist/provider-tool-capabilities.js +77 -62
  67. package/dist/request-helpers.d.ts +37 -4
  68. package/dist/request-helpers.js +134 -0
  69. package/dist/resources.d.ts +6 -1
  70. package/dist/resources.js +64 -192
  71. package/dist/session-manager.js +18 -11
  72. package/dist/sqlite-driver.d.ts +1 -1
  73. package/dist/sqlite-driver.js +2 -1
  74. package/dist/stream-json-parser.d.ts +1 -0
  75. package/dist/stream-json-parser.js +3 -0
  76. package/dist/upstream-contracts.d.ts +17 -1
  77. package/dist/upstream-contracts.js +209 -36
  78. package/npm-shrinkwrap.json +2 -2
  79. package/package.json +8 -3
@@ -1,12 +1,25 @@
1
1
  import { randomUUID } from "crypto";
2
+ import os from "os";
3
+ import { hrtime } from "process";
2
4
  import { envWithExtendedPath, getExtendedPath, killProcessGroup, providerCommandName, spawnCliProcess, unregisterProcessGroup, } from "./executor.js";
3
5
  import { noopLogger, logWarn } from "./logger.js";
4
6
  import { ProcessMonitor } from "./process-monitor.js";
5
7
  import { computeRequestKey, isValidationRunStore } from "./job-store.js";
6
8
  import { NoopFlightRecorder, } from "./flight-recorder.js";
7
9
  import { codexFrResponse } from "./codex-json-parser.js";
10
+ import { extractProviderOutputMetadata } from "./provider-output-metadata.js";
8
11
  import { getRequestContext, resolveOwnerPrincipal, principalCanAccess } from "./request-context.js";
9
12
  import { runApiRequest, ApiHttpError, } from "./api-provider.js";
13
+ import { DEFAULT_INSTANCE_HEARTBEAT_MS, DEFAULT_INSTANCE_LEASE_TTL_MS, DEFAULT_HTTP_JOB_GRACE_MS, DEFAULT_ORPHAN_SWEEP_INTERVAL_MS, DEFAULT_INSTANCE_GC_MS, } from "./config.js";
14
+ const DEFAULT_LEASE_RUNTIME_CONFIG = {
15
+ instanceHeartbeatMs: DEFAULT_INSTANCE_HEARTBEAT_MS,
16
+ instanceLeaseTtlMs: DEFAULT_INSTANCE_LEASE_TTL_MS,
17
+ httpJobGraceMs: DEFAULT_HTTP_JOB_GRACE_MS,
18
+ orphanSweepIntervalMs: DEFAULT_ORPHAN_SWEEP_INTERVAL_MS,
19
+ instanceGcMs: DEFAULT_INSTANCE_GC_MS,
20
+ role: "gateway",
21
+ };
22
+ const MAX_CONSECUTIVE_HEARTBEAT_FAILURES = 3;
10
23
  export function extractApiHttpStatus(error) {
11
24
  for (const candidate of [error, error?.cause]) {
12
25
  if (candidate instanceof ApiHttpError && typeof candidate.status === "number") {
@@ -237,13 +250,26 @@ export class AsyncJobManager {
237
250
  limiter;
238
251
  completedJobMemoryTtlMs;
239
252
  maxJobOutputBytes;
240
- constructor(logger = noopLogger, onJobComplete, store = null, flightRecorder = new NoopFlightRecorder(), limits = DEFAULT_MANAGER_JOB_LIMITS) {
253
+ instanceId = randomUUID();
254
+ hostname = os.hostname();
255
+ instancePid = process.pid;
256
+ lease;
257
+ heartbeatTimer = null;
258
+ sweepTimer = null;
259
+ durableAdmission;
260
+ consecutiveHeartbeatFailures = 0;
261
+ skipSweepThisCycle = false;
262
+ nextHeartbeatExpectedAt = 0;
263
+ disposed = false;
264
+ pendingWrites = new Set();
265
+ constructor(logger = noopLogger, onJobComplete, store = null, flightRecorder = new NoopFlightRecorder(), limits = DEFAULT_MANAGER_JOB_LIMITS, _deprecatedOwnsOrphanRecovery = true, leaseConfig = DEFAULT_LEASE_RUNTIME_CONFIG) {
241
266
  this.logger = logger;
242
267
  this.onJobComplete = onJobComplete;
243
268
  this.processMonitor = new ProcessMonitor(logger);
244
269
  this.store = store;
245
270
  this.flightRecorder = flightRecorder;
246
271
  this.limits = limits;
272
+ this.lease = leaseConfig;
247
273
  this.completedJobMemoryTtlMs = limits.completedJobMemoryTtlMs;
248
274
  this.maxJobOutputBytes = limits.maxJobOutputBytes;
249
275
  this.limiter = new JobLimiter({
@@ -252,23 +278,24 @@ export class AsyncJobManager {
252
278
  maxQueuedJobs: limits.maxQueuedJobs,
253
279
  queueTimeoutMs: limits.queueTimeoutMs,
254
280
  }, logger);
281
+ this.durableAdmission = store !== null;
255
282
  if (this.store) {
256
283
  try {
257
- const { count, orphaned } = this.store.markOrphanedOnStartup();
258
- if (count > 0) {
259
- this.logger.info(`Marked ${count} in-flight job(s) as orphaned after gateway restart`);
260
- }
261
- for (const orphan of orphaned) {
262
- try {
263
- this.flightRecorder.logComplete(orphan.correlationId, this.buildOrphanFlightResult(orphan));
264
- }
265
- catch (err) {
266
- this.logger.error(`Async-path FR logComplete for orphaned job ${orphan.id} failed`, err);
267
- }
268
- }
284
+ this.store.registerInstance({
285
+ instanceId: this.instanceId,
286
+ role: this.lease.role ?? "gateway",
287
+ hostname: this.hostname,
288
+ pid: this.instancePid,
289
+ });
269
290
  }
270
291
  catch (err) {
271
- this.logger.error("markOrphanedOnStartup failed", err);
292
+ this.durableAdmission = false;
293
+ this.logger.error("#139 registerInstance failed; durable async admission is disabled for this instance", err);
294
+ }
295
+ if (this.durableAdmission) {
296
+ this.runOrphanSweep();
297
+ this.startHeartbeat();
298
+ this.startReaper();
272
299
  }
273
300
  }
274
301
  this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
@@ -280,6 +307,212 @@ export class AsyncJobManager {
280
307
  this.stallTimer.unref();
281
308
  }
282
309
  }
310
+ canAdmitDurableJobs() {
311
+ return this.store !== null && this.durableAdmission;
312
+ }
313
+ assertDurableAdmission(provider) {
314
+ if (this.store && !this.durableAdmission) {
315
+ throw new Error(`Durable async admission is disabled for ${provider}: this gateway instance could not register or lost its heartbeat lease. Retry after the gateway recovers.`);
316
+ }
317
+ }
318
+ trackPendingWrite(p) {
319
+ this.pendingWrites.add(p);
320
+ void p.finally(() => this.pendingWrites.delete(p));
321
+ }
322
+ async dispose(opts = {}) {
323
+ const timeoutMs = opts.timeoutMs ?? 5000;
324
+ if (this.disposed)
325
+ return;
326
+ this.disposed = true;
327
+ this.durableAdmission = false;
328
+ for (const t of [this.heartbeatTimer, this.sweepTimer, this.evictionTimer, this.stallTimer]) {
329
+ if (t)
330
+ clearInterval(t);
331
+ }
332
+ this.heartbeatTimer = null;
333
+ this.sweepTimer = null;
334
+ this.evictionTimer = null;
335
+ this.stallTimer = null;
336
+ if (!this.store)
337
+ return;
338
+ const active = [...this.jobs.values()].filter(job => isAsyncJobInProgress(job.status));
339
+ for (const job of active) {
340
+ try {
341
+ if (job.transport === "http") {
342
+ job.abort?.abort();
343
+ }
344
+ else if (job.process) {
345
+ killProcessGroup(job.process, "SIGTERM");
346
+ }
347
+ }
348
+ catch (err) {
349
+ this.logger.error(`#139 dispose: failed to signal job ${job.id}`, err);
350
+ }
351
+ }
352
+ const deadline = Date.now() + timeoutMs;
353
+ while (Date.now() < deadline) {
354
+ const stillActive = [...this.jobs.values()].some(job => isAsyncJobInProgress(job.status));
355
+ if (!stillActive && this.pendingWrites.size === 0)
356
+ break;
357
+ await Promise.race([
358
+ Promise.allSettled([...this.pendingWrites]),
359
+ new Promise(resolve => setTimeout(resolve, 50)),
360
+ ]);
361
+ }
362
+ const stillActive = [...this.jobs.values()].some(job => isAsyncJobInProgress(job.status));
363
+ if (stillActive) {
364
+ logWarn(this.logger, "#139 dispose timed out with active owned jobs; skipping deregister and letting the lease expire");
365
+ return;
366
+ }
367
+ try {
368
+ this.store.deregisterInstance(this.instanceId);
369
+ }
370
+ catch (err) {
371
+ this.logger.error("#139 dispose: deregisterInstance failed", err);
372
+ }
373
+ }
374
+ getInstanceId() {
375
+ return this.instanceId;
376
+ }
377
+ startHeartbeat() {
378
+ const intervalMs = this.lease.instanceHeartbeatMs;
379
+ this.nextHeartbeatExpectedAt = Number(hrtime.bigint() / 1000000n) + intervalMs;
380
+ this.heartbeatTimer = setInterval(() => this.onHeartbeatTick(intervalMs), intervalMs);
381
+ if (this.heartbeatTimer.unref)
382
+ this.heartbeatTimer.unref();
383
+ }
384
+ onHeartbeatTick(intervalMs) {
385
+ if (this.disposed || !this.store)
386
+ return;
387
+ const nowMonoMs = Number(hrtime.bigint() / 1000000n);
388
+ const driftMs = nowMonoMs - this.nextHeartbeatExpectedAt;
389
+ this.nextHeartbeatExpectedAt = nowMonoMs + intervalMs;
390
+ if (driftMs > intervalMs) {
391
+ this.skipSweepThisCycle = true;
392
+ logWarn(this.logger, `#139 heartbeat scheduling drift ${Math.round(driftMs)}ms exceeded interval ${intervalMs}ms; skipping the next orphan sweep`);
393
+ }
394
+ try {
395
+ this.store.heartbeat(this.instanceId);
396
+ this.consecutiveHeartbeatFailures = 0;
397
+ }
398
+ catch (err) {
399
+ this.consecutiveHeartbeatFailures++;
400
+ this.logger.error(`#139 heartbeat failed (${this.consecutiveHeartbeatFailures}/${MAX_CONSECUTIVE_HEARTBEAT_FAILURES})`, err);
401
+ if (this.consecutiveHeartbeatFailures >= MAX_CONSECUTIVE_HEARTBEAT_FAILURES) {
402
+ if (this.durableAdmission) {
403
+ this.durableAdmission = false;
404
+ this.logger.error("#139 sustained heartbeat failure; disabling durable admission and orphan sweeping on this instance");
405
+ }
406
+ }
407
+ }
408
+ }
409
+ runOrphanSweepNow() {
410
+ this.runOrphanSweep();
411
+ }
412
+ startReaper() {
413
+ this.sweepTimer = setInterval(() => {
414
+ this.runOrphanSweep();
415
+ try {
416
+ this.store?.gcInstances(this.lease.instanceGcMs);
417
+ }
418
+ catch (err) {
419
+ this.logger.error("#139 gateway_instances GC failed", err);
420
+ }
421
+ }, this.lease.orphanSweepIntervalMs);
422
+ if (this.sweepTimer.unref)
423
+ this.sweepTimer.unref();
424
+ }
425
+ runOrphanSweep() {
426
+ if (!this.store || this.disposed || !this.durableAdmission)
427
+ return;
428
+ if (this.skipSweepThisCycle) {
429
+ this.skipSweepThisCycle = false;
430
+ return;
431
+ }
432
+ const leaseTtl = this.lease.instanceLeaseTtlMs;
433
+ const httpGrace = this.lease.httpJobGraceMs;
434
+ let liveConfirmedIds = [];
435
+ try {
436
+ const candidates = this.store.selectStaleProcessCandidates(leaseTtl, httpGrace);
437
+ liveConfirmedIds = this.confirmLiveProcessCandidates(candidates);
438
+ }
439
+ catch (err) {
440
+ this.logger.error("#139 selecting stale process candidates failed", err);
441
+ }
442
+ let orphaned;
443
+ try {
444
+ orphaned = this.store.recoverStaleJobs(leaseTtl, httpGrace, liveConfirmedIds);
445
+ }
446
+ catch (err) {
447
+ this.logger.error("#139 recoverStaleJobs failed", err);
448
+ return;
449
+ }
450
+ if (orphaned.length > 0) {
451
+ this.logger.info(`#139 orphaned ${orphaned.length} stale job(s) whose owning instance is gone`);
452
+ }
453
+ for (const orphan of orphaned) {
454
+ try {
455
+ this.flightRecorder.logComplete(orphan.correlationId, this.buildOrphanFlightResult(orphan));
456
+ }
457
+ catch (err) {
458
+ this.logger.error(`#139 FR logComplete for orphaned job ${orphan.id} failed`, err);
459
+ }
460
+ }
461
+ }
462
+ confirmLiveProcessCandidates(candidates) {
463
+ const live = [];
464
+ for (const c of candidates) {
465
+ if (c.transport !== "process" || c.pid == null)
466
+ continue;
467
+ if (c.hostname !== null && c.hostname !== this.hostname)
468
+ continue;
469
+ if (c.hostname === null)
470
+ continue;
471
+ try {
472
+ process.kill(c.pid, 0);
473
+ live.push(c.id);
474
+ }
475
+ catch (err) {
476
+ const code = err.code;
477
+ if (code === "EPERM")
478
+ live.push(c.id);
479
+ }
480
+ }
481
+ return live;
482
+ }
483
+ recordStartOrFailClosed(job, acq, input) {
484
+ if (!this.store)
485
+ return;
486
+ try {
487
+ this.store.recordStart(input);
488
+ }
489
+ catch (err) {
490
+ if (acq.state === "granted")
491
+ acq.permit.release();
492
+ else
493
+ acq.cancel();
494
+ this.jobs.delete(job.id);
495
+ this.logger.error(`#139 durable recordStart failed for job ${job.id}; failing the request (fail-closed)`, err);
496
+ throw new Error(`Durable job admission failed for ${job.cli}: the job store rejected recordStart`);
497
+ }
498
+ }
499
+ markRunningDurable(job, pid, failClosed = false) {
500
+ if (!this.store)
501
+ return;
502
+ let transitioned = false;
503
+ try {
504
+ transitioned = this.store.markRunning(job.id, { pid });
505
+ }
506
+ catch (err) {
507
+ if (failClosed)
508
+ throw err;
509
+ this.logger.error(`#139 markRunning (best-effort) failed for job ${job.id}`, err);
510
+ return;
511
+ }
512
+ if (!transitioned && failClosed) {
513
+ throw new Error(`#139 markRunning matched no queued row for job ${job.id} (already recovered or terminal); refusing to run a child against a stale durable row`);
514
+ }
515
+ }
283
516
  buildOrphanFlightResult(orphan) {
284
517
  const durationMs = Math.max(0, Date.now() - new Date(orphan.startedAt).getTime());
285
518
  const hasCapturedStdout = orphan.stdout.length > 0;
@@ -499,6 +732,7 @@ export class AsyncJobManager {
499
732
  if (reused)
500
733
  return reused;
501
734
  }
735
+ this.assertDurableAdmission(provider.name);
502
736
  const id = randomUUID();
503
737
  const startedAt = new Date().toISOString();
504
738
  const abort = new AbortController();
@@ -554,16 +788,18 @@ export class AsyncJobManager {
554
788
  return;
555
789
  }
556
790
  job.status = "running";
557
- runApiRequest(provider, apiRequest, this.logger, { signal: abort.signal })
791
+ this.markRunningDurable(job, null);
792
+ const settle = runApiRequest(provider, apiRequest, this.logger, { signal: abort.signal })
558
793
  .then(result => this.finalizeHttpJob(job, result, null))
559
794
  .catch(error => this.finalizeHttpJob(job, null, error));
795
+ this.trackPendingWrite(settle);
560
796
  };
561
797
  const acq = this.limiter.acquire(provider.name, permit => launch(permit), () => this.failQueuedJob(job, "queue wait timed out before a run slot was free"));
562
798
  if (acq.state === "rejected") {
563
799
  this.jobs.delete(id);
564
800
  throw new JobSaturationError(provider.name, `running limit (${this.limits.maxRunningJobs} global / ${this.limits.maxRunningJobsPerProvider} per provider) reached and the queue is full (max ${this.limits.maxQueuedJobs}); retry shortly`);
565
801
  }
566
- this.safeStoreCall("recordStart", () => this.store.recordStart({
802
+ this.recordStartOrFailClosed(job, acq, {
567
803
  id,
568
804
  correlationId,
569
805
  requestKey,
@@ -572,9 +808,10 @@ export class AsyncJobManager {
572
808
  startedAt,
573
809
  pid: null,
574
810
  ownerPrincipal,
811
+ ownerInstance: this.instanceId,
575
812
  transport: "http",
576
813
  payloadJson,
577
- }));
814
+ });
578
815
  if (writeFlightStart && flightRecorderEntry) {
579
816
  try {
580
817
  this.flightRecorder.logStart({
@@ -681,6 +918,9 @@ export class AsyncJobManager {
681
918
  const errorMessage = isFailure
682
919
  ? (overrideErrorMessage ?? job.error ?? job.stderr ?? `Exit code ${exitCode}`)
683
920
  : undefined;
921
+ const providerMeta = finalStatus === "completed" && job.transport === "process"
922
+ ? extractProviderOutputMetadata(job.cli, job.stdout, job.outputFormat)
923
+ : undefined;
684
924
  try {
685
925
  this.flightRecorder.logComplete(job.correlationId, {
686
926
  response,
@@ -697,6 +937,8 @@ export class AsyncJobManager {
697
937
  cacheReadTokens: usage.cacheReadTokens,
698
938
  cacheCreationTokens: usage.cacheCreationTokens,
699
939
  costUsd: usage.costUsd,
940
+ providerSessionId: providerMeta?.sessionId,
941
+ stopReason: providerMeta?.stopReason,
700
942
  });
701
943
  job.flightRecorderComplete = true;
702
944
  job.flightRecorderEntry = undefined;
@@ -823,7 +1065,7 @@ export class AsyncJobManager {
823
1065
  abort: null,
824
1066
  httpStatus: row.httpStatus,
825
1067
  payloadJson: row.payloadJson,
826
- exited: row.status !== "running",
1068
+ exited: row.status !== "running" && row.status !== "queued",
827
1069
  metricsRecorded: true,
828
1070
  outputFormat: row.outputFormat ?? undefined,
829
1071
  ownerPrincipal: row.ownerPrincipal,
@@ -861,6 +1103,7 @@ export class AsyncJobManager {
861
1103
  if (reused)
862
1104
  return reused;
863
1105
  }
1106
+ this.assertDurableAdmission(cli);
864
1107
  const id = randomUUID();
865
1108
  const startedAt = new Date().toISOString();
866
1109
  const ownerPrincipal = resolveOwnerPrincipal(getRequestContext());
@@ -928,7 +1171,7 @@ export class AsyncJobManager {
928
1171
  this.jobs.delete(id);
929
1172
  throw new JobSaturationError(cli, `running limit (${this.limits.maxRunningJobs} global / ${this.limits.maxRunningJobsPerProvider} per provider) reached and the queue is full (max ${this.limits.maxQueuedJobs}); retry shortly`);
930
1173
  }
931
- this.safeStoreCall("recordStart", () => this.store.recordStart({
1174
+ this.recordStartOrFailClosed(job, acq, {
932
1175
  id,
933
1176
  correlationId,
934
1177
  requestKey,
@@ -938,7 +1181,8 @@ export class AsyncJobManager {
938
1181
  startedAt,
939
1182
  pid: null,
940
1183
  ownerPrincipal,
941
- }));
1184
+ ownerInstance: this.instanceId,
1185
+ });
942
1186
  if (writeFlightStart && flightRecorderEntry) {
943
1187
  try {
944
1188
  this.flightRecorder.logStart({
@@ -980,6 +1224,24 @@ export class AsyncJobManager {
980
1224
  env: { ...baseEnv, ...(extraEnv ?? {}) },
981
1225
  });
982
1226
  job.process = child;
1227
+ try {
1228
+ this.markRunningDurable(job, child.pid ?? null, true);
1229
+ }
1230
+ catch (err) {
1231
+ try {
1232
+ killProcessGroup(child, "SIGKILL");
1233
+ }
1234
+ catch {
1235
+ }
1236
+ if (child.pid) {
1237
+ try {
1238
+ unregisterProcessGroup(child.pid);
1239
+ }
1240
+ catch {
1241
+ }
1242
+ }
1243
+ throw err;
1244
+ }
983
1245
  if (stdin !== undefined && child.stdin) {
984
1246
  try {
985
1247
  child.stdin.write(stdin);
@@ -1136,12 +1398,17 @@ export class AsyncJobManager {
1136
1398
  }
1137
1399
  const stdout = truncateText(job.stdout, maxChars);
1138
1400
  const stderr = truncateText(job.stderr, maxChars);
1401
+ const providerMeta = job.transport === "process" && job.status === "completed"
1402
+ ? extractProviderOutputMetadata(job.cli, job.stdout, job.outputFormat)
1403
+ : undefined;
1139
1404
  return {
1140
1405
  ...this.snapshot(job),
1141
1406
  stdout: stdout.text,
1142
1407
  stderr: stderr.text,
1143
1408
  stdoutTruncated: stdout.truncated,
1144
1409
  stderrTruncated: stderr.truncated,
1410
+ ...(providerMeta?.sessionId ? { providerSessionId: providerMeta.sessionId } : {}),
1411
+ ...(providerMeta?.stopReason ? { stopReason: providerMeta.stopReason } : {}),
1145
1412
  ...(job.transport === "http"
1146
1413
  ? {
1147
1414
  apiUsage: job.apiUsage,
@@ -9,6 +9,7 @@ export interface CodexJsonParseResult {
9
9
  usage?: CodexUsage;
10
10
  error?: string;
11
11
  threadId?: string;
12
+ stopReason?: string;
12
13
  finalMessage?: string;
13
14
  sawEvent?: boolean;
14
15
  }
@@ -47,6 +47,12 @@ export function parseCodexJsonStream(stdout) {
47
47
  }
48
48
  result.usage = usage;
49
49
  }
50
+ if (typeof parsed.stop_reason === "string") {
51
+ result.stopReason = parsed.stop_reason;
52
+ }
53
+ else if (typeof parsed.reason === "string") {
54
+ result.stopReason = parsed.reason;
55
+ }
50
56
  break;
51
57
  }
52
58
  case "turn.failed": {
package/dist/config.d.ts CHANGED
@@ -20,6 +20,11 @@ export declare const PERSISTENCE_BACKENDS: readonly ["sqlite", "postgres", "memo
20
20
  export type PersistenceBackend = (typeof PERSISTENCE_BACKENDS)[number];
21
21
  export declare const DEFAULT_JOB_RETENTION_DAYS = 30;
22
22
  export declare const DEFAULT_DEDUP_WINDOW_MS: number;
23
+ export declare const DEFAULT_INSTANCE_HEARTBEAT_MS = 15000;
24
+ export declare const DEFAULT_INSTANCE_LEASE_TTL_MS = 90000;
25
+ export declare const DEFAULT_HTTP_JOB_GRACE_MS = 300000;
26
+ export declare const DEFAULT_ORPHAN_SWEEP_INTERVAL_MS = 30000;
27
+ export declare const DEFAULT_INSTANCE_GC_MS = 3600000;
23
28
  export interface PersistenceConfig {
24
29
  backend: PersistenceBackend;
25
30
  path: string | null;
@@ -27,6 +32,12 @@ export interface PersistenceConfig {
27
32
  retentionDays: number;
28
33
  dedupWindowMs: number;
29
34
  acknowledgeEphemeral: boolean;
35
+ ownsOrphanRecovery: boolean;
36
+ instanceHeartbeatMs: number;
37
+ instanceLeaseTtlMs: number;
38
+ httpJobGraceMs: number;
39
+ orphanSweepIntervalMs: number;
40
+ instanceGcMs: number;
30
41
  asyncJobsEnabled: boolean;
31
42
  sources: PersistenceConfigSources;
32
43
  }
@@ -155,6 +166,7 @@ export interface AcpConfig {
155
166
  promptTimeoutMs: number;
156
167
  allowWriteHostServices: boolean;
157
168
  allowTerminalHostServices: boolean;
169
+ allowMutatingSessionOps: boolean;
158
170
  fallbackToCliWhenUnhealthy: boolean;
159
171
  providers: Record<string, AcpProviderConfig>;
160
172
  sources: {
@@ -162,6 +174,13 @@ export interface AcpConfig {
162
174
  };
163
175
  }
164
176
  export declare function loadAcpConfig(logger?: Logger): AcpConfig;
177
+ export interface AdminConfig {
178
+ allowMutatingCliAdminOps: boolean;
179
+ sources: {
180
+ configFile: string | null;
181
+ };
182
+ }
183
+ export declare function loadAdminConfig(logger?: Logger): AdminConfig;
165
184
  export type RemoteOAuthConfigStatus = "absent" | "disabled" | "enabled" | "malformed";
166
185
  export interface RemoteOAuthConfigDiagnostics {
167
186
  config: RemoteOAuthConfig;
package/dist/config.js CHANGED
@@ -45,6 +45,11 @@ export function loadConfig() {
45
45
  export const PERSISTENCE_BACKENDS = ["sqlite", "postgres", "memory", "none"];
46
46
  export const DEFAULT_JOB_RETENTION_DAYS = 30;
47
47
  export const DEFAULT_DEDUP_WINDOW_MS = 60 * 60 * 1000;
48
+ export const DEFAULT_INSTANCE_HEARTBEAT_MS = 15000;
49
+ export const DEFAULT_INSTANCE_LEASE_TTL_MS = 90000;
50
+ export const DEFAULT_HTTP_JOB_GRACE_MS = 300000;
51
+ export const DEFAULT_ORPHAN_SWEEP_INTERVAL_MS = 30000;
52
+ export const DEFAULT_INSTANCE_GC_MS = 3600000;
48
53
  const PersistenceSchema = z
49
54
  .object({
50
55
  backend: z.enum(PERSISTENCE_BACKENDS).default("sqlite"),
@@ -53,8 +58,30 @@ const PersistenceSchema = z
53
58
  retentionDays: z.number().positive().default(DEFAULT_JOB_RETENTION_DAYS),
54
59
  dedupWindowMs: z.number().int().nonnegative().default(DEFAULT_DEDUP_WINDOW_MS),
55
60
  acknowledgeEphemeral: z.boolean().default(false),
61
+ ownsOrphanRecovery: z.boolean().default(false),
62
+ instanceHeartbeatMs: z.number().int().positive().default(DEFAULT_INSTANCE_HEARTBEAT_MS),
63
+ instanceLeaseTtlMs: z.number().int().positive().default(DEFAULT_INSTANCE_LEASE_TTL_MS),
64
+ httpJobGraceMs: z.number().int().positive().default(DEFAULT_HTTP_JOB_GRACE_MS),
65
+ orphanSweepIntervalMs: z.number().int().positive().default(DEFAULT_ORPHAN_SWEEP_INTERVAL_MS),
66
+ instanceGcMs: z.number().int().positive().default(DEFAULT_INSTANCE_GC_MS),
56
67
  })
57
- .strict();
68
+ .strict()
69
+ .superRefine((cfg, ctx) => {
70
+ if (cfg.instanceLeaseTtlMs < 2 * cfg.instanceHeartbeatMs) {
71
+ ctx.addIssue({
72
+ code: z.ZodIssueCode.custom,
73
+ path: ["instanceLeaseTtlMs"],
74
+ message: `instanceLeaseTtlMs (${cfg.instanceLeaseTtlMs}) must be >= 2 * instanceHeartbeatMs (${2 * cfg.instanceHeartbeatMs})`,
75
+ });
76
+ }
77
+ if (cfg.httpJobGraceMs < cfg.instanceLeaseTtlMs) {
78
+ ctx.addIssue({
79
+ code: z.ZodIssueCode.custom,
80
+ path: ["httpJobGraceMs"],
81
+ message: `httpJobGraceMs (${cfg.httpJobGraceMs}) must be >= instanceLeaseTtlMs (${cfg.instanceLeaseTtlMs})`,
82
+ });
83
+ }
84
+ });
58
85
  const DEFAULT_SQLITE_PATH = path.join(os.homedir(), ".llm-cli-gateway", "logs.db");
59
86
  function defaultPersistenceConfigPath() {
60
87
  return (process.env.LLM_GATEWAY_CONFIG ?? path.join(os.homedir(), ".llm-cli-gateway", "config.toml"));
@@ -153,6 +180,9 @@ export function loadPersistenceConfig(logger = noopLogger) {
153
180
  envOverrides: [],
154
181
  };
155
182
  const merged = applyEnvOverrides(raw ?? {}, logger, sources);
183
+ if (merged.ownsOrphanRecovery !== undefined) {
184
+ logWarn(logger, "[persistence].ownsOrphanRecovery is deprecated and no longer used; the durable per-job lease recovery (#139) runs safely from every instance. Remove it from config.toml.");
185
+ }
156
186
  let parsed;
157
187
  try {
158
188
  parsed = PersistenceSchema.parse(merged);
@@ -182,6 +212,12 @@ export function loadPersistenceConfig(logger = noopLogger) {
182
212
  retentionDays: parsed.retentionDays,
183
213
  dedupWindowMs: parsed.dedupWindowMs,
184
214
  acknowledgeEphemeral: parsed.acknowledgeEphemeral,
215
+ ownsOrphanRecovery: parsed.ownsOrphanRecovery,
216
+ instanceHeartbeatMs: parsed.instanceHeartbeatMs,
217
+ instanceLeaseTtlMs: parsed.instanceLeaseTtlMs,
218
+ httpJobGraceMs: parsed.httpJobGraceMs,
219
+ orphanSweepIntervalMs: parsed.orphanSweepIntervalMs,
220
+ instanceGcMs: parsed.instanceGcMs,
185
221
  asyncJobsEnabled,
186
222
  sources,
187
223
  };
@@ -537,6 +573,7 @@ const AcpConfigSchema = z
537
573
  prompt_timeout_ms: z.number().int().positive().default(DEFAULT_ACP_PROMPT_TIMEOUT_MS),
538
574
  allow_write_host_services: z.boolean().default(false),
539
575
  allow_terminal_host_services: z.boolean().default(false),
576
+ allow_mutating_session_ops: z.boolean().default(false),
540
577
  fallback_to_cli_when_unhealthy: z.boolean().default(true),
541
578
  providers: z.record(z.string(), AcpProviderSchema).default({}),
542
579
  })
@@ -552,6 +589,7 @@ function defaultAcpConfig(sourcePath) {
552
589
  promptTimeoutMs: DEFAULT_ACP_PROMPT_TIMEOUT_MS,
553
590
  allowWriteHostServices: false,
554
591
  allowTerminalHostServices: false,
592
+ allowMutatingSessionOps: false,
555
593
  fallbackToCliWhenUnhealthy: true,
556
594
  providers: {},
557
595
  sources: { configFile: sourcePath },
@@ -608,11 +646,56 @@ export function loadAcpConfig(logger = noopLogger) {
608
646
  promptTimeoutMs: parsed.prompt_timeout_ms,
609
647
  allowWriteHostServices: parsed.allow_write_host_services,
610
648
  allowTerminalHostServices: parsed.allow_terminal_host_services,
649
+ allowMutatingSessionOps: parsed.allow_mutating_session_ops,
611
650
  fallbackToCliWhenUnhealthy: parsed.fallback_to_cli_when_unhealthy,
612
651
  providers,
613
652
  sources: { configFile: sourcePath },
614
653
  };
615
654
  }
655
+ const AdminConfigSchema = z
656
+ .object({
657
+ allow_mutating_cli_admin_ops: z.boolean().default(false),
658
+ })
659
+ .strict();
660
+ function defaultAdminConfig(sourcePath) {
661
+ return { allowMutatingCliAdminOps: false, sources: { configFile: sourcePath } };
662
+ }
663
+ function readAdminFile(configPath, logger) {
664
+ if (!existsSync(configPath)) {
665
+ return { raw: undefined, sourcePath: null };
666
+ }
667
+ try {
668
+ const require = createRequire(import.meta.url);
669
+ const TOML = require("smol-toml");
670
+ const text = readFileSync(configPath, "utf-8");
671
+ const parsed = TOML.parse(text);
672
+ return { raw: parsed?.admin, sourcePath: configPath };
673
+ }
674
+ catch (err) {
675
+ logger.error(`Failed to parse gateway config at ${configPath}; using admin defaults`, err);
676
+ return { raw: undefined, sourcePath: null };
677
+ }
678
+ }
679
+ export function loadAdminConfig(logger = noopLogger) {
680
+ const configPath = defaultGatewayConfigPath();
681
+ const { raw, sourcePath } = readAdminFile(configPath, logger);
682
+ if (raw === undefined) {
683
+ return defaultAdminConfig(sourcePath);
684
+ }
685
+ let parsed;
686
+ try {
687
+ parsed = AdminConfigSchema.parse(raw);
688
+ }
689
+ catch (err) {
690
+ throw new Error(`Invalid [admin] config: ${err instanceof Error ? err.message : String(err)}`, {
691
+ cause: err,
692
+ });
693
+ }
694
+ return {
695
+ allowMutatingCliAdminOps: parsed.allow_mutating_cli_admin_ops,
696
+ sources: { configFile: sourcePath },
697
+ };
698
+ }
616
699
  const OAuthRegistrationPolicySchema = z.enum(["static_clients", "shared_secret", "open_dev"]);
617
700
  const OAuthClientSchema = z
618
701
  .object({
@@ -30,6 +30,8 @@ export interface FlightLogResult {
30
30
  httpStatus?: number;
31
31
  errorMessage?: string;
32
32
  status: "completed" | "failed";
33
+ providerSessionId?: string;
34
+ stopReason?: string;
33
35
  }
34
36
  interface LoggerLike {
35
37
  info: (message: string, ...args: any[]) => void;