farming-code 2.2.19 → 2.2.22

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 (53) hide show
  1. package/README.md +5 -5
  2. package/README.zh_cn.md +5 -5
  3. package/THIRD_PARTY_NOTICES.md +5 -8
  4. package/backend/acp/client-services.js +37 -5
  5. package/backend/acp/patch-decisions.js +19 -3
  6. package/backend/acp-runtime.js +1201 -198
  7. package/backend/acp-transcript.js +66 -0
  8. package/backend/agent-lifecycle-journal.js +133 -0
  9. package/backend/agent-manager.js +2715 -354
  10. package/backend/agent-order.js +8 -4
  11. package/backend/agent-runtime-binding.js +1 -0
  12. package/backend/bundle-update-helper.js +5 -1
  13. package/backend/config-manager.js +16 -0
  14. package/backend/control-api.js +146 -20
  15. package/backend/farming-app-cli.js +335 -10
  16. package/backend/farming-session-store.js +283 -36
  17. package/backend/json-cli-runtime.js +120 -6
  18. package/backend/npm-update-helper.js +97 -18
  19. package/backend/provider-adapters.js +18 -6
  20. package/backend/review-session-service.js +31 -17
  21. package/backend/server.js +337 -103
  22. package/backend/storage-layout.js +10 -0
  23. package/backend/terminal-screen-worker-pool.js +15 -6
  24. package/backend/theme-manager.js +23 -5
  25. package/backend/update-service.js +239 -27
  26. package/backend/usage-monitor.js +11 -0
  27. package/backend/workspace-file-router.js +9 -3
  28. package/backend/workspace-file-service.js +292 -62
  29. package/dist/acp/codex-acp-1.1.4.js +32 -1
  30. package/dist/assets/App-CmZdNavW.js +210 -0
  31. package/dist/assets/{FileEditorMarkdownPreview-R0jwwLME.js → FileEditorMarkdownPreview-KbnE8VC8.js} +1 -1
  32. package/dist/assets/FileEditorPane-5_HUejjd.js +2 -0
  33. package/dist/assets/IconGlyphs-C2WFcoJS.js +1 -0
  34. package/dist/assets/ProjectFilesSection-C1RAJWOE.js +12 -0
  35. package/dist/assets/{ReviewPage-DBKNOWsJ.js → ReviewPage-L1vh1Ggs.js} +3 -3
  36. package/dist/assets/code-dark-BKtvf0Xc.css +1 -0
  37. package/dist/assets/file-icons-CHWt5nSy.js +1 -0
  38. package/dist/assets/{index-DQdtPfCk.js → index-RvUiMpGP.js} +2 -2
  39. package/dist/assets/main-XMXgyJJt.css +1 -0
  40. package/dist/assets/workspace-view-state-BqB4HM7r.js +7 -0
  41. package/dist/index.html +1 -1
  42. package/frontend/skins/crt/app.js +365 -107
  43. package/frontend/skins/crt/index.html +5 -10
  44. package/frontend/skins/crt/styles/billing.css +6 -1
  45. package/package.json +9 -3
  46. package/dist/assets/App-CbXYBccJ.js +0 -210
  47. package/dist/assets/FileEditorPane-CJs6kfZO.js +0 -2
  48. package/dist/assets/IconGlyphs-CZzDr5o2.js +0 -1
  49. package/dist/assets/ProjectFilesSection-DWCV07lA.js +0 -12
  50. package/dist/assets/code-dark-qIBiLBWV.css +0 -1
  51. package/dist/assets/file-icons-DpbrD5ZP.js +0 -1
  52. package/dist/assets/main-yzZEfgft.css +0 -1
  53. package/dist/assets/workspace-view-state-BpPINu84.js +0 -7
@@ -1,11 +1,41 @@
1
1
  const crypto = require('crypto');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
+ const { isDeepStrictEqual } = require('util');
4
5
  const { legacyRuntimeMetadata } = require('./agent-runtime-binding');
6
+ const { lifecycleJournal } = require('./agent-lifecycle-journal');
5
7
  const storageLayout = require('./storage-layout');
6
8
 
7
- const SESSION_ID_PREFIX = 'fsess';
9
+ const AGENT_RECORD_ID_PREFIX = 'agent';
10
+ const AGENT_RECORD_VERSION = 1;
11
+ const AGENT_STATE_VERSION = 1;
8
12
  const MAX_MAIN_PAGE_SESSION_KEYS = 50;
13
+ const AGENT_STATE_FIELDS = [
14
+ 'acpState',
15
+ 'acpError',
16
+ 'acpStopReason',
17
+ 'acpPendingPermission',
18
+ 'acpPendingPermissions',
19
+ 'acpPendingElicitation',
20
+ 'acpPendingElicitations',
21
+ 'acpActiveElicitations',
22
+ 'acpSessionUpdatedAt',
23
+ 'acpSessionRevision',
24
+ 'jsonCliState',
25
+ 'jsonCliError',
26
+ 'jsonCliTranscriptUpdatedAt',
27
+ 'attentionSeq',
28
+ 'readAttentionSeq',
29
+ 'attentionUpdatedAt',
30
+ 'readAttentionAt',
31
+ 'attentionReason',
32
+ 'attentionOutputEpoch',
33
+ 'attentionOutputSeq',
34
+ 'readOutputEpoch',
35
+ 'readOutputSeq',
36
+ 'unread',
37
+ ];
38
+ const AGENT_STATE_FIELD_SET = new Set(AGENT_STATE_FIELDS);
9
39
  const PRODUCT_STATE_FIELDS = [
10
40
  'projectWorkspace',
11
41
  'task',
@@ -29,15 +59,45 @@ function now() {
29
59
  return Date.now();
30
60
  }
31
61
 
32
- function createSessionId() {
62
+ function createAgentRecordId() {
33
63
  const stamp = now().toString(36);
34
64
  const random = crypto.randomBytes(6).toString('hex');
35
- return `${SESSION_ID_PREFIX}_${stamp}_${random}`;
65
+ return `${AGENT_RECORD_ID_PREFIX}_${stamp}_${random}`;
36
66
  }
37
67
 
38
68
  function safeSessionFileName(id) {
39
69
  const value = String(id || '').trim();
40
- return /^fsess_[A-Za-z0-9_-]+$/.test(value) ? `${value}.json` : '';
70
+ return /^(?:agent|fsess)_[A-Za-z0-9_-]+$/.test(value) ? `${value}.json` : '';
71
+ }
72
+
73
+ function isLegacySessionId(id) {
74
+ return /^fsess_[A-Za-z0-9_-]+$/.test(String(id || '').trim());
75
+ }
76
+
77
+ function isAgentRecordId(id) {
78
+ return /^agent_[A-Za-z0-9_-]+$/.test(String(id || '').trim());
79
+ }
80
+
81
+ function agentRecordIdFor(agent) {
82
+ if (agent && Object.prototype.hasOwnProperty.call(agent, 'persistentSessionId')) {
83
+ return typeof agent.persistentSessionId === 'string' ? agent.persistentSessionId : '';
84
+ }
85
+ return typeof agent?.agentRecordId === 'string' ? agent.agentRecordId : '';
86
+ }
87
+
88
+ function cloneJson(value) {
89
+ if (value === undefined) return undefined;
90
+ return JSON.parse(JSON.stringify(value));
91
+ }
92
+
93
+ function sameJson(left, right) {
94
+ return isDeepStrictEqual(left, right);
95
+ }
96
+
97
+ function withoutUpdatedAt(value) {
98
+ const copy = { ...(value || {}) };
99
+ delete copy.updatedAt;
100
+ return copy;
41
101
  }
42
102
 
43
103
  function atomicWriteJson(file, value) {
@@ -77,17 +137,62 @@ class FarmingSessionStore {
77
137
  init({ legacyMainPageSessionKeys = [] } = {}) {
78
138
  fs.mkdirSync(this.sessionsDir, { recursive: true });
79
139
  this.index = this.readIndex();
140
+ this.reconcileProviderSessionIndex();
80
141
  if (!Array.isArray(this.index.mainPageSessionKeys) || this.index.mainPageSessionKeys.length === 0) {
81
- const migrated = this.normalizeMainPageSessionKeys(legacyMainPageSessionKeys);
142
+ const migrated = this.filterRecoverableMainPageSessionKeys(legacyMainPageSessionKeys);
82
143
  if (migrated.length > 0) {
83
144
  this.setMainPageSessionKeys(migrated);
84
145
  return;
85
146
  }
86
147
  }
87
- this.index.mainPageSessionKeys = this.normalizeMainPageSessionKeys(this.index.mainPageSessionKeys);
148
+ this.index.mainPageSessionKeys = this.filterRecoverableMainPageSessionKeys(
149
+ this.index.mainPageSessionKeys,
150
+ );
88
151
  this.writeIndex();
89
152
  }
90
153
 
154
+ reconcileProviderSessionIndex() {
155
+ const index = this.ensureIndex();
156
+ const recordsByProviderKey = new Map();
157
+ const records = this.listStoredAgentRecords();
158
+ const successors = this.legacySuccessors(records);
159
+ for (const record of records) {
160
+ if (successors.has(record.id)) continue;
161
+ const sessionKey = String(record?.providerSessionKey || '');
162
+ if (!record || record.kind !== 'agent' || !parseProviderSessionKey(sessionKey)) continue;
163
+ const matches = recordsByProviderKey.get(sessionKey) || [];
164
+ matches.push(record);
165
+ recordsByProviderKey.set(sessionKey, matches);
166
+ }
167
+
168
+ for (const [sessionKey, records] of recordsByProviderKey) {
169
+ const indexedId = index.providerSessionRecords[sessionKey];
170
+ const indexedRecord = indexedId ? records.find(record => record.id === indexedId) : null;
171
+ if (records.length > 1) {
172
+ const ids = records.map(record => record.id).sort().join(', ');
173
+ throw new Error(`Conflicting Farming session records for ${sessionKey}: ${ids}`);
174
+ }
175
+ if (!indexedRecord || isLegacySessionId(indexedId)) {
176
+ index.providerSessionRecords[sessionKey] = records[0].id;
177
+ }
178
+ }
179
+ }
180
+
181
+ filterRecoverableMainPageSessionKeys(keys) {
182
+ return this.normalizeMainPageSessionKeys(keys).filter(sessionKey => {
183
+ const record = this.getRecordForProviderSessionKey(sessionKey);
184
+ if (!record) return true;
185
+ if (record.archived === true) return false;
186
+ const entries = lifecycleJournal(record).entries;
187
+ const latest = entries.length > 0 ? entries[entries.length - 1] : null;
188
+ return !(
189
+ latest
190
+ && ['delete', 'archive'].includes(latest.type)
191
+ && latest.state === 'succeeded'
192
+ );
193
+ });
194
+ }
195
+
91
196
  readIndex() {
92
197
  try {
93
198
  if (fs.existsSync(this.indexFile)) {
@@ -135,7 +240,11 @@ class FarmingSessionStore {
135
240
  return fileName ? path.join(this.sessionsDir, fileName) : '';
136
241
  }
137
242
 
138
- readRecord(id) {
243
+ agentStateFile(id) {
244
+ return isAgentRecordId(id) ? storageLayout.agentStateFile(this.configDir, id) : '';
245
+ }
246
+
247
+ readMetadataRecord(id) {
139
248
  const file = this.sessionFile(id);
140
249
  if (!file) return null;
141
250
  try {
@@ -147,11 +256,122 @@ class FarmingSessionStore {
147
256
  }
148
257
  }
149
258
 
259
+ readAgentState(id) {
260
+ const file = this.agentStateFile(id);
261
+ if (!file) return null;
262
+ try {
263
+ if (!fs.existsSync(file)) return null;
264
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
265
+ if (
266
+ !parsed
267
+ || typeof parsed !== 'object'
268
+ || parsed.agentRecordId !== id
269
+ || parsed.agentStateVersion !== AGENT_STATE_VERSION
270
+ ) {
271
+ return null;
272
+ }
273
+ return parsed;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ readRecord(id) {
280
+ const metadata = this.readMetadataRecord(id);
281
+ if (!metadata) return null;
282
+ if (!isAgentRecordId(id)) return metadata;
283
+ const state = this.readAgentState(id);
284
+ if (!state) return { ...metadata, agentRecordId: id };
285
+ const merged = { ...metadata, agentRecordId: id };
286
+ AGENT_STATE_FIELDS.forEach(field => {
287
+ if (Object.prototype.hasOwnProperty.call(state, field)) merged[field] = cloneJson(state[field]);
288
+ });
289
+ return merged;
290
+ }
291
+
292
+ splitAgentRecord(record, id) {
293
+ const metadata = {};
294
+ const state = {
295
+ agentStateVersion: AGENT_STATE_VERSION,
296
+ agentRecordId: id,
297
+ kind: 'agent-state',
298
+ };
299
+ Object.entries(record || {}).forEach(([field, value]) => {
300
+ if (AGENT_STATE_FIELD_SET.has(field)) state[field] = cloneJson(value);
301
+ else metadata[field] = cloneJson(value);
302
+ });
303
+ metadata.id = id;
304
+ metadata.agentRecordId = id;
305
+ metadata.kind = 'agent';
306
+ metadata.recordVersion = AGENT_RECORD_VERSION;
307
+ return { metadata, state };
308
+ }
309
+
310
+ promoteIndexRecordId(previousId, nextId) {
311
+ if (!this.index || !previousId || previousId === nextId) return;
312
+ Object.entries(this.index.providerSessionRecords).forEach(([key, id]) => {
313
+ if (id === previousId) this.index.providerSessionRecords[key] = nextId;
314
+ });
315
+ }
316
+
150
317
  writeRecord(record) {
151
318
  if (!record || !safeSessionFileName(record.id)) return '';
152
- const file = this.sessionFile(record.id);
153
- atomicWriteJson(file, record);
154
- return record.id;
319
+ const previousId = record.id;
320
+ const id = isLegacySessionId(previousId) ? createAgentRecordId() : previousId;
321
+ if (!isAgentRecordId(id)) return '';
322
+ const promoted = id !== previousId;
323
+ const nextRecord = {
324
+ ...record,
325
+ id,
326
+ agentRecordId: id,
327
+ ...(promoted ? { legacyRecordId: previousId } : {}),
328
+ };
329
+ const { metadata, state } = this.splitAgentRecord(nextRecord, id);
330
+ const existingMetadata = this.readMetadataRecord(id);
331
+ const existingState = this.readAgentState(id);
332
+ const metadataChanged = !existingMetadata
333
+ || !sameJson(withoutUpdatedAt(existingMetadata), withoutUpdatedAt(metadata));
334
+ const stateChanged = !existingState
335
+ || !sameJson(withoutUpdatedAt(existingState), withoutUpdatedAt(state));
336
+ if (stateChanged) {
337
+ state.updatedAt = now();
338
+ atomicWriteJson(this.agentStateFile(id), state);
339
+ }
340
+ if (metadataChanged) {
341
+ metadata.updatedAt = now();
342
+ atomicWriteJson(this.sessionFile(id), metadata);
343
+ }
344
+ if (promoted) this.promoteIndexRecordId(previousId, id);
345
+ return id;
346
+ }
347
+
348
+ listStoredAgentRecords() {
349
+ let names = [];
350
+ try {
351
+ names = fs.readdirSync(this.sessionsDir);
352
+ } catch {
353
+ return [];
354
+ }
355
+ return names
356
+ .filter(name => name.endsWith('.json') && !name.endsWith('.state.json'))
357
+ .map(name => name.slice(0, -5))
358
+ .filter(id => safeSessionFileName(id))
359
+ .map(id => this.readRecord(id))
360
+ .filter(record => record && record.kind === 'agent');
361
+ }
362
+
363
+ legacySuccessors(records) {
364
+ const successors = new Map();
365
+ for (const record of Array.isArray(records) ? records : []) {
366
+ const legacyId = String(record?.legacyRecordId || '').trim();
367
+ if (!isLegacySessionId(legacyId)) continue;
368
+ const previous = successors.get(legacyId);
369
+ if (previous && previous !== record.id) {
370
+ throw new Error(`Conflicting Agent record successors for ${legacyId}: ${previous}, ${record.id}`);
371
+ }
372
+ successors.set(legacyId, record.id);
373
+ }
374
+ return successors;
155
375
  }
156
376
 
157
377
  providerSessionKeyForAgent(agent) {
@@ -188,6 +408,15 @@ class FarmingSessionStore {
188
408
  providerSessionTitle: typeof agent.providerSessionTitle === 'string' ? agent.providerSessionTitle : '',
189
409
  providerSessionWorkspace: typeof agent.providerSessionWorkspace === 'string' ? agent.providerSessionWorkspace : '',
190
410
  terminalInputReceived: agent.terminalInputReceived === true,
411
+ structuredRuntimeProcess: agent.structuredRuntimeProcess
412
+ && typeof agent.structuredRuntimeProcess === 'object'
413
+ ? JSON.parse(JSON.stringify(agent.structuredRuntimeProcess))
414
+ : null,
415
+ legacyAcpProcessExitAcknowledgedAt:
416
+ typeof agent.legacyAcpProcessExitAcknowledgedAt === 'number'
417
+ ? agent.legacyAcpProcessExitAcknowledgedAt
418
+ : null,
419
+ ...(agent.lifecycleJournal ? { lifecycleJournal: lifecycleJournal(agent) } : {}),
191
420
  ...legacyRuntimeMetadata(agent),
192
421
  engine: typeof agent.engineName === 'string' ? agent.engineName : '',
193
422
  category: typeof agent.category === 'string' ? agent.category : '',
@@ -217,7 +446,6 @@ class FarmingSessionStore {
217
446
  ? agent.providerSessionTitle
218
447
  : (typeof agent.sessionTitle === 'string' ? agent.sessionTitle : '')),
219
448
  startedAt: typeof agent.startedAt === 'number' ? agent.startedAt : null,
220
- lastSeenAt: now(),
221
449
  };
222
450
  }
223
451
 
@@ -226,9 +454,21 @@ class FarmingSessionStore {
226
454
  if (!parsed) return '';
227
455
  const index = this.ensureIndex();
228
456
  const existingId = index.providerSessionRecords[sessionKey];
457
+ const normalizedPreferredId = safeSessionFileName(preferredId) ? preferredId : '';
458
+ const preferredRecord = normalizedPreferredId ? this.readRecord(normalizedPreferredId) : null;
459
+ if (
460
+ !safeSessionFileName(existingId)
461
+ && preferredRecord?.providerSessionTemporary !== true
462
+ && preferredRecord?.providerSessionKey
463
+ && preferredRecord.providerSessionKey !== sessionKey
464
+ ) {
465
+ throw new Error(
466
+ `Farming session ${normalizedPreferredId} is already bound to ${preferredRecord.providerSessionKey}`,
467
+ );
468
+ }
229
469
  const id = safeSessionFileName(existingId)
230
470
  ? existingId
231
- : (safeSessionFileName(preferredId) ? preferredId : createSessionId());
471
+ : (normalizedPreferredId || createAgentRecordId());
232
472
  const existing = this.readRecord(id) || {};
233
473
  const record = {
234
474
  id,
@@ -248,17 +488,21 @@ class FarmingSessionStore {
248
488
  if (typeof record.customTitle === 'string' && record.customTitle) {
249
489
  record.title = record.customTitle;
250
490
  }
251
- index.providerSessionRecords[sessionKey] = id;
252
- this.writeRecord(record);
491
+ Object.entries(index.providerSessionRecords).forEach(([key, recordId]) => {
492
+ if (key !== sessionKey && recordId === id) delete index.providerSessionRecords[key];
493
+ });
494
+ const writtenId = this.writeRecord(record);
495
+ index.providerSessionRecords[sessionKey] = writtenId;
253
496
  this.writeIndex();
254
- return id;
497
+ return writtenId;
255
498
  }
256
499
 
257
500
  ensureRecordForAgent(agent, patch = {}) {
258
501
  const providerSessionKey = this.providerSessionKeyForAgent(agent);
259
502
  if (providerSessionKey) {
260
- const previousId = safeSessionFileName(agent?.persistentSessionId)
261
- ? agent.persistentSessionId
503
+ const preferredId = agentRecordIdFor(agent);
504
+ const previousId = safeSessionFileName(preferredId)
505
+ ? preferredId
262
506
  : '';
263
507
  const previous = previousId ? this.readRecord(previousId) : null;
264
508
  const canonical = this.getRecordForProviderSessionKey(providerSessionKey);
@@ -275,10 +519,15 @@ class FarmingSessionStore {
275
519
  const id = this.ensureRecordForProviderSessionKey(providerSessionKey, {
276
520
  ...agentPatch,
277
521
  ...patch,
278
- }, agent?.persistentSessionId || '');
522
+ }, preferredId || '');
279
523
  if (previousId && previousId !== id) {
280
- if (previous && (previous.providerSessionTemporary === true || !previous.providerSessionKey)) {
281
- this.writeRecord({
524
+ const promotedFromPrevious = this.readRecord(id)?.legacyRecordId === previousId;
525
+ if (
526
+ !promotedFromPrevious
527
+ && previous
528
+ && (previous.providerSessionTemporary === true || !previous.providerSessionKey)
529
+ ) {
530
+ const mergedId = this.writeRecord({
282
531
  ...previous,
283
532
  runtimeAgentId: '',
284
533
  visibleOnMainPage: false,
@@ -287,13 +536,15 @@ class FarmingSessionStore {
287
536
  mergedInto: id,
288
537
  updatedAt: now(),
289
538
  });
539
+ if (mergedId && mergedId !== previousId) this.writeIndex();
290
540
  }
291
541
  }
292
542
  return id;
293
543
  }
294
544
 
295
- const existingId = safeSessionFileName(agent?.persistentSessionId) ? agent.persistentSessionId : '';
296
- const id = existingId || createSessionId();
545
+ const preferredId = agentRecordIdFor(agent);
546
+ const existingId = safeSessionFileName(preferredId) ? preferredId : '';
547
+ const id = existingId || createAgentRecordId();
297
548
  const existing = this.readRecord(id) || {};
298
549
  const record = {
299
550
  id,
@@ -306,8 +557,7 @@ class FarmingSessionStore {
306
557
  ...patch,
307
558
  updatedAt: now(),
308
559
  };
309
- this.writeRecord(record);
310
- return id;
560
+ return this.writeRecord(record);
311
561
  }
312
562
 
313
563
  setProviderSessionDisplayState(sessionKey, patch = {}) {
@@ -358,11 +608,12 @@ class FarmingSessionStore {
358
608
  const id = index.providerSessionRecords[key];
359
609
  const existing = this.readRecord(id);
360
610
  if (existing) {
361
- this.writeRecord({
611
+ const writtenId = this.writeRecord({
362
612
  ...existing,
363
613
  visibleOnMainPage: false,
364
614
  updatedAt: now(),
365
615
  });
616
+ if (writtenId) index.providerSessionRecords[key] = writtenId;
366
617
  }
367
618
  });
368
619
  index.mainPageSessionKeys = normalized;
@@ -377,11 +628,12 @@ class FarmingSessionStore {
377
628
  const id = index.providerSessionRecords[sessionKey];
378
629
  const existing = this.readRecord(id);
379
630
  if (existing) {
380
- this.writeRecord({
631
+ const writtenId = this.writeRecord({
381
632
  ...existing,
382
633
  visibleOnMainPage: false,
383
634
  updatedAt: now(),
384
635
  });
636
+ if (writtenId) index.providerSessionRecords[sessionKey] = writtenId;
385
637
  }
386
638
  this.writeIndex();
387
639
  return true;
@@ -407,20 +659,15 @@ class FarmingSessionStore {
407
659
 
408
660
  listAgentRecords() {
409
661
  this.ensureIndex();
410
- let names = [];
411
- try {
412
- names = fs.readdirSync(this.sessionsDir);
413
- } catch {
414
- return [];
415
- }
416
- return names
417
- .filter(name => name.endsWith('.json') && safeSessionFileName(name.slice(0, -5)))
418
- .map(name => this.readRecord(name.slice(0, -5)))
419
- .filter(record => record && record.kind === 'agent');
662
+ const records = this.listStoredAgentRecords();
663
+ const successors = this.legacySuccessors(records);
664
+ return records.filter(record => !successors.has(record.id));
420
665
  }
421
666
  }
422
667
 
423
668
  module.exports = {
669
+ AGENT_RECORD_VERSION,
670
+ AGENT_STATE_VERSION,
424
671
  FarmingSessionStore,
425
672
  MAX_MAIN_PAGE_SESSION_KEYS,
426
673
  parseProviderSessionKey,
@@ -3,6 +3,48 @@ const { spawn } = require('child_process');
3
3
  const { AgentJsonStreamParser } = require('./agent-json-stream');
4
4
 
5
5
  const MAX_EVENTS = 12_000;
6
+ const PROCESS_EXIT_TIMEOUT_MS = 1500;
7
+ const PROCESS_KILL_TIMEOUT_MS = 1000;
8
+
9
+ function childHasExited(child) {
10
+ return !child
11
+ || (child.exitCode !== null && child.exitCode !== undefined)
12
+ || (child.signalCode !== null && child.signalCode !== undefined);
13
+ }
14
+
15
+ function processTreeHasExited(child, ownsProcessGroup) {
16
+ if (!ownsProcessGroup || !child?.pid || process.platform === 'win32') {
17
+ return childHasExited(child);
18
+ }
19
+ try {
20
+ process.kill(-child.pid, 0);
21
+ return false;
22
+ } catch (error) {
23
+ return error?.code === 'ESRCH';
24
+ }
25
+ }
26
+
27
+ async function waitForProcessTreeExit(child, ownsProcessGroup, timeoutMs) {
28
+ const deadline = Date.now() + timeoutMs;
29
+ while (Date.now() <= deadline) {
30
+ if (processTreeHasExited(child, ownsProcessGroup)) return true;
31
+ await new Promise(resolve => setTimeout(resolve, 25));
32
+ }
33
+ return processTreeHasExited(child, ownsProcessGroup);
34
+ }
35
+
36
+ function signalProcessTree(child, ownsProcessGroup, signal) {
37
+ if (!child || processTreeHasExited(child, ownsProcessGroup)) return;
38
+ if (ownsProcessGroup && child.pid && process.platform !== 'win32') {
39
+ try {
40
+ process.kill(-child.pid, signal);
41
+ return;
42
+ } catch (error) {
43
+ if (error?.code === 'ESRCH') return;
44
+ }
45
+ }
46
+ child.kill(signal);
47
+ }
6
48
 
7
49
  function codexPermissionArgs(mode) {
8
50
  if (mode === 'full') return ['--dangerously-bypass-approvals-and-sandbox'];
@@ -35,13 +77,23 @@ class JsonCliRuntime extends EventEmitter {
35
77
  constructor(options = {}) {
36
78
  super();
37
79
  this.spawn = options.spawn || spawn;
80
+ this.processExitTimeoutMs = options.processExitTimeoutMs ?? PROCESS_EXIT_TIMEOUT_MS;
81
+ this.processKillTimeoutMs = options.processKillTimeoutMs ?? PROCESS_KILL_TIMEOUT_MS;
82
+ this.ownsProcessGroups = options.ownsProcessGroups ?? process.platform !== 'win32';
38
83
  this.bindings = new Map();
84
+ this.disposing = false;
85
+ this.disposePromise = null;
86
+ this.disposed = false;
39
87
  }
40
88
 
41
89
  registerAgent(options) {
90
+ if (this.disposing || this.disposed) {
91
+ throw new Error('JSON CLI runtime is shutting down');
92
+ }
42
93
  const binding = {
43
94
  ...options,
44
95
  events: Array.isArray(options.initialEvents) ? [...options.initialEvents].slice(-MAX_EVENTS) : [],
96
+ ownsProcessGroup: this.ownsProcessGroups,
45
97
  child: null,
46
98
  operationSeq: 0,
47
99
  state: 'idle',
@@ -53,11 +105,31 @@ class JsonCliRuntime extends EventEmitter {
53
105
 
54
106
  unregisterAgent(agentId) {
55
107
  const binding = this.bindings.get(agentId);
56
- if (binding?.child && !binding.child.killed) binding.child.kill('SIGTERM');
108
+ if (binding?.child) signalProcessTree(binding.child, binding.ownsProcessGroup, 'SIGTERM');
57
109
  this.bindings.delete(agentId);
58
110
  }
59
111
 
112
+ async unregisterAgentAndWait(agentId) {
113
+ const binding = this.bindings.get(agentId);
114
+ if (!binding) return false;
115
+ const child = binding.child;
116
+ if (child && !processTreeHasExited(child, binding.ownsProcessGroup)) {
117
+ signalProcessTree(child, binding.ownsProcessGroup, 'SIGTERM');
118
+ if (!await waitForProcessTreeExit(child, binding.ownsProcessGroup, this.processExitTimeoutMs)) {
119
+ signalProcessTree(child, binding.ownsProcessGroup, 'SIGKILL');
120
+ if (!await waitForProcessTreeExit(child, binding.ownsProcessGroup, this.processKillTimeoutMs)) {
121
+ throw new Error(`JSON Agent process tree ${child.pid || ''} did not exit`);
122
+ }
123
+ }
124
+ }
125
+ if (this.bindings.get(agentId) === binding) this.bindings.delete(agentId);
126
+ return true;
127
+ }
128
+
60
129
  async submitComposerMessage(agentId, message, patch = {}) {
130
+ if (this.disposing || this.disposed) {
131
+ throw new Error('JSON CLI runtime is shutting down');
132
+ }
61
133
  const binding = this.bindings.get(agentId);
62
134
  if (!binding) throw new Error('JSON CLI Agent is not registered');
63
135
  if (binding.child) throw new Error('Agent is already working');
@@ -75,6 +147,7 @@ class JsonCliRuntime extends EventEmitter {
75
147
  cwd: binding.cwd,
76
148
  env: binding.env,
77
149
  stdio: ['pipe', 'pipe', 'pipe'],
150
+ detached: binding.ownsProcessGroup,
78
151
  });
79
152
  binding.child = child;
80
153
  this.emitRuntime(binding);
@@ -89,7 +162,7 @@ class JsonCliRuntime extends EventEmitter {
89
162
  stderr = `${stderr}${chunk.toString('utf8')}`.slice(-8000);
90
163
  });
91
164
  child.on('error', error => {
92
- binding.child = null;
165
+ if (!child.pid) binding.child = null;
93
166
  binding.state = 'error';
94
167
  binding.error = error.message;
95
168
  this.emitRuntime(binding);
@@ -100,12 +173,19 @@ class JsonCliRuntime extends EventEmitter {
100
173
  binding.events.push(...parser.events);
101
174
  if (binding.events.length > MAX_EVENTS) binding.events.splice(0, binding.events.length - MAX_EVENTS);
102
175
  if (parser.sessionId) binding.sessionId = parser.sessionId;
103
- binding.child = null;
104
- binding.state = code === 0 ? 'idle' : 'error';
105
- binding.error = code === 0 ? '' : (stderr.trim() || `JSON CLI exited with code ${code}${signal ? ` (${signal})` : ''}`);
176
+ const processTreeExited = processTreeHasExited(child, binding.ownsProcessGroup);
177
+ if (processTreeExited) binding.child = null;
178
+ binding.state = code === 0 && processTreeExited ? 'idle' : 'error';
179
+ binding.error = code === 0 && processTreeExited
180
+ ? ''
181
+ : (
182
+ !processTreeExited
183
+ ? 'JSON CLI root exited while its process tree remained live'
184
+ : (stderr.trim() || `JSON CLI exited with code ${code}${signal ? ` (${signal})` : ''}`)
185
+ );
106
186
  this.emit('transcript', { agentId, transcript: this.getTranscript(agentId) });
107
187
  this.emitRuntime(binding);
108
- if (code === 0) resolve({ sessionId: binding.sessionId });
188
+ if (code === 0 && processTreeExited) resolve({ sessionId: binding.sessionId });
109
189
  else reject(new Error(binding.error));
110
190
  });
111
191
  if (launch.stdin) child.stdin.end(launch.stdin);
@@ -148,6 +228,40 @@ class JsonCliRuntime extends EventEmitter {
148
228
  };
149
229
  }
150
230
 
231
+ dispose() {
232
+ if (this.disposed) return Promise.resolve();
233
+ if (this.disposePromise) return this.disposePromise;
234
+
235
+ this.disposing = true;
236
+ const disposePromise = this.performDispose();
237
+ this.disposePromise = disposePromise;
238
+ void disposePromise.finally(() => {
239
+ if (this.disposePromise === disposePromise) this.disposePromise = null;
240
+ if (!this.disposed) this.disposing = false;
241
+ }).catch(() => {});
242
+ return disposePromise;
243
+ }
244
+
245
+ resumeAfterDisposeAbort() {
246
+ this.disposed = false;
247
+ this.disposing = false;
248
+ }
249
+
250
+ async performDispose() {
251
+ const failures = [];
252
+ for (const agentId of [...this.bindings.keys()]) {
253
+ try {
254
+ await this.unregisterAgentAndWait(agentId);
255
+ } catch (error) {
256
+ failures.push(error);
257
+ }
258
+ }
259
+ if (failures.length > 0) {
260
+ throw new AggregateError(failures, 'One or more JSON Agent process trees did not exit');
261
+ }
262
+ this.disposed = true;
263
+ }
264
+
151
265
  emitRuntime(binding) {
152
266
  this.emit('agent-runtime', {
153
267
  agentId: binding.agentId,