funolio-agent 1.0.53 → 1.0.75

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 (115) hide show
  1. package/dist/approval.d.ts +1 -6
  2. package/dist/approval.d.ts.map +1 -1
  3. package/dist/approval.js +2 -7
  4. package/dist/approval.js.map +1 -1
  5. package/dist/bot-manager.d.ts +5 -1
  6. package/dist/bot-manager.d.ts.map +1 -1
  7. package/dist/bot-manager.js +23 -13
  8. package/dist/bot-manager.js.map +1 -1
  9. package/dist/cli-session-epoch.d.ts +1 -1
  10. package/dist/cli-session-epoch.d.ts.map +1 -1
  11. package/dist/cli-session-epoch.js +1 -1
  12. package/dist/cli-session-epoch.js.map +1 -1
  13. package/dist/cli-session-registry.d.ts +35 -0
  14. package/dist/cli-session-registry.d.ts.map +1 -0
  15. package/dist/cli-session-registry.js +177 -0
  16. package/dist/cli-session-registry.js.map +1 -0
  17. package/dist/cli.js +62 -0
  18. package/dist/cli.js.map +1 -1
  19. package/dist/codex-app-server-manager.d.ts +129 -0
  20. package/dist/codex-app-server-manager.d.ts.map +1 -0
  21. package/dist/codex-app-server-manager.js +768 -0
  22. package/dist/codex-app-server-manager.js.map +1 -0
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +8 -30
  25. package/dist/commands/init.js.map +1 -1
  26. package/dist/commands/setup.d.ts +4 -1
  27. package/dist/commands/setup.d.ts.map +1 -1
  28. package/dist/commands/setup.js +9 -25
  29. package/dist/commands/setup.js.map +1 -1
  30. package/dist/commands/start.d.ts.map +1 -1
  31. package/dist/commands/start.js +77 -2
  32. package/dist/commands/start.js.map +1 -1
  33. package/dist/completion-marker.d.ts +7 -0
  34. package/dist/completion-marker.d.ts.map +1 -0
  35. package/dist/completion-marker.js +28 -0
  36. package/dist/completion-marker.js.map +1 -0
  37. package/dist/config.d.ts +6 -2
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +15 -3
  40. package/dist/config.js.map +1 -1
  41. package/dist/context-window.d.ts.map +1 -1
  42. package/dist/context-window.js +8 -1
  43. package/dist/context-window.js.map +1 -1
  44. package/dist/live-activity.d.ts +29 -0
  45. package/dist/live-activity.d.ts.map +1 -0
  46. package/dist/live-activity.js +36 -0
  47. package/dist/live-activity.js.map +1 -0
  48. package/dist/local-cli-pty-manager.d.ts +51 -0
  49. package/dist/local-cli-pty-manager.d.ts.map +1 -1
  50. package/dist/local-cli-pty-manager.js +1227 -114
  51. package/dist/local-cli-pty-manager.js.map +1 -1
  52. package/dist/local-data.d.ts +41 -0
  53. package/dist/local-data.d.ts.map +1 -1
  54. package/dist/local-data.js +140 -4
  55. package/dist/local-data.js.map +1 -1
  56. package/dist/local-db.d.ts.map +1 -1
  57. package/dist/local-db.js +55 -1
  58. package/dist/local-db.js.map +1 -1
  59. package/dist/local-server.d.ts +25 -0
  60. package/dist/local-server.d.ts.map +1 -1
  61. package/dist/local-server.js +528 -267
  62. package/dist/local-server.js.map +1 -1
  63. package/dist/message-loop.d.ts +6 -0
  64. package/dist/message-loop.d.ts.map +1 -1
  65. package/dist/message-loop.js +239 -89
  66. package/dist/message-loop.js.map +1 -1
  67. package/dist/mqtt-client.d.ts +10 -1
  68. package/dist/mqtt-client.d.ts.map +1 -1
  69. package/dist/mqtt-client.js +14 -1
  70. package/dist/mqtt-client.js.map +1 -1
  71. package/dist/oauth.d.ts.map +1 -1
  72. package/dist/oauth.js +69 -29
  73. package/dist/oauth.js.map +1 -1
  74. package/dist/orchestration/orchestrator-operating-prompt.d.ts +1 -0
  75. package/dist/orchestration/orchestrator-operating-prompt.d.ts.map +1 -1
  76. package/dist/orchestration/orchestrator-operating-prompt.js +60 -0
  77. package/dist/orchestration/orchestrator-operating-prompt.js.map +1 -1
  78. package/dist/orchestration/validation.d.ts +40 -0
  79. package/dist/orchestration/validation.d.ts.map +1 -0
  80. package/dist/orchestration/validation.js +203 -0
  81. package/dist/orchestration/validation.js.map +1 -0
  82. package/dist/orchestrator.d.ts +21 -32
  83. package/dist/orchestrator.d.ts.map +1 -1
  84. package/dist/orchestrator.js +287 -725
  85. package/dist/orchestrator.js.map +1 -1
  86. package/dist/providers/claude-cli-prompt.d.ts.map +1 -1
  87. package/dist/providers/claude-cli-prompt.js +49 -5
  88. package/dist/providers/claude-cli-prompt.js.map +1 -1
  89. package/dist/providers/claude-cli.d.ts.map +1 -1
  90. package/dist/providers/claude-cli.js +56 -5
  91. package/dist/providers/claude-cli.js.map +1 -1
  92. package/dist/providers/codex-cli.d.ts.map +1 -1
  93. package/dist/providers/codex-cli.js +15 -10
  94. package/dist/providers/codex-cli.js.map +1 -1
  95. package/dist/response-guard.js +1 -1
  96. package/dist/response-guard.js.map +1 -1
  97. package/dist/tools/admin-tools.d.ts.map +1 -1
  98. package/dist/tools/admin-tools.js +8 -2
  99. package/dist/tools/admin-tools.js.map +1 -1
  100. package/dist/tools/index.d.ts.map +1 -1
  101. package/dist/tools/index.js +2 -1
  102. package/dist/tools/index.js.map +1 -1
  103. package/dist/tools/search-conversation-history.d.ts +16 -0
  104. package/dist/tools/search-conversation-history.d.ts.map +1 -0
  105. package/dist/tools/search-conversation-history.js +324 -0
  106. package/dist/tools/search-conversation-history.js.map +1 -0
  107. package/dist/wizard-state.d.ts +7 -0
  108. package/dist/wizard-state.d.ts.map +1 -1
  109. package/dist/wizard-state.js +31 -2
  110. package/dist/wizard-state.js.map +1 -1
  111. package/dist/workflow-engine.d.ts +4 -1
  112. package/dist/workflow-engine.d.ts.map +1 -1
  113. package/dist/workflow-engine.js +190 -29
  114. package/dist/workflow-engine.js.map +1 -1
  115. package/package.json +1 -1
@@ -34,18 +34,66 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.LocalCliPtySessionManager = void 0;
37
+ exports.stripAnsi = stripAnsi;
38
+ exports.resolveConptyOverwrites = resolveConptyOverwrites;
37
39
  exports.parseClaudeSessionRecord = parseClaudeSessionRecord;
38
40
  exports.parseCodexSessionRecord = parseCodexSessionRecord;
39
41
  exports.getLocalCliPtySessionManager = getLocalCliPtySessionManager;
42
+ exports.runLocalCliPtyHealthCheck = runLocalCliPtyHealthCheck;
43
+ exports.runLocalCliPtyTurnHealthCheck = runLocalCliPtyTurnHealthCheck;
44
+ exports.runLocalCliPtyProbe = runLocalCliPtyProbe;
40
45
  const fs = __importStar(require("fs"));
41
46
  const os = __importStar(require("os"));
42
47
  const path = __importStar(require("path"));
48
+ const module_1 = require("module");
43
49
  const claude_cli_prompt_1 = require("./providers/claude-cli-prompt");
50
+ const live_activity_1 = require("./live-activity");
51
+ const completion_marker_1 = require("./completion-marker");
52
+ const CLAUDE_FRESH_SESSION_STARTUP_TIMEOUT_MS = 7_000;
53
+ const CLAUDE_PTY_INACTIVITY_FAIL_TIMEOUT_MS = 60_000;
54
+ function getPtyInactivityFailTimeoutMs(provider) {
55
+ if (provider === 'claude-cli')
56
+ return CLAUDE_PTY_INACTIVITY_FAIL_TIMEOUT_MS;
57
+ return null;
58
+ }
44
59
  let _ptyModule = null;
45
60
  let _manager = null;
46
61
  function delay(ms) {
47
62
  return new Promise((resolve) => setTimeout(resolve, ms));
48
63
  }
64
+ function buildAbortError() {
65
+ const abortErr = new Error('PTY turn aborted');
66
+ abortErr.name = 'AbortError';
67
+ return abortErr;
68
+ }
69
+ function buildClaudeFreshSessionStartupError(sessionId) {
70
+ const err = new Error(`claude-cli fresh session startup timed out after ${Math.floor(CLAUDE_FRESH_SESSION_STARTUP_TIMEOUT_MS / 1000)}s waiting for transcript file`
71
+ + (sessionId ? ` (${sessionId})` : ''));
72
+ err.name = 'ClaudeFreshSessionStartupTimeoutError';
73
+ err.code = 'CLAUDE_FRESH_SESSION_STARTUP_TIMEOUT';
74
+ return err;
75
+ }
76
+ function throwIfAborted(signal) {
77
+ if (!signal?.aborted)
78
+ return;
79
+ throw buildAbortError();
80
+ }
81
+ async function delayWithAbort(ms, signal) {
82
+ if (!signal) {
83
+ await delay(ms);
84
+ return;
85
+ }
86
+ await Promise.race([
87
+ delay(ms),
88
+ new Promise((_, reject) => {
89
+ const onAbort = () => {
90
+ signal.removeEventListener('abort', onAbort);
91
+ reject(buildAbortError());
92
+ };
93
+ signal.addEventListener('abort', onAbort, { once: true });
94
+ }),
95
+ ]);
96
+ }
49
97
  function sessionKey(conversationId, botId) {
50
98
  return `${conversationId}::${botId}`;
51
99
  }
@@ -81,6 +129,7 @@ function loadNodePtyModule() {
81
129
  if (_ptyModule)
82
130
  return _ptyModule;
83
131
  const dynamicRequire = eval('require');
132
+ const fileRequire = (0, module_1.createRequire)(path.join(process.cwd(), 'sea-entry.js'));
84
133
  try {
85
134
  _ptyModule = dynamicRequire('@homebridge/node-pty-prebuilt-multiarch');
86
135
  return _ptyModule;
@@ -95,16 +144,89 @@ function loadNodePtyModule() {
95
144
  for (const candidate of candidates) {
96
145
  if (!fs.existsSync(candidate))
97
146
  continue;
98
- _ptyModule = dynamicRequire(candidate);
147
+ _ptyModule = fileRequire(candidate);
99
148
  return _ptyModule;
100
149
  }
101
150
  throw new Error(`Failed to load PTY runtime. Tried package import and packaged resource candidates. Original error: ${firstErr instanceof Error ? firstErr.message : String(firstErr)}`);
102
151
  }
103
152
  }
153
+ function parseBracketedSections(text) {
154
+ const lines = text.split('\n');
155
+ const sections = [];
156
+ let currentHeading = null;
157
+ let bodyLines = [];
158
+ const flush = () => {
159
+ if (!currentHeading)
160
+ return;
161
+ sections.push({
162
+ heading: currentHeading,
163
+ body: bodyLines.join('\n').trim(),
164
+ });
165
+ };
166
+ for (const line of lines) {
167
+ const trimmed = line.trim();
168
+ const match = /^\[(.+?)\]$/.exec(trimmed);
169
+ if (match) {
170
+ flush();
171
+ currentHeading = match[1];
172
+ bodyLines = [];
173
+ continue;
174
+ }
175
+ if (currentHeading) {
176
+ bodyLines.push(line);
177
+ }
178
+ }
179
+ flush();
180
+ return sections;
181
+ }
182
+ function trimSectionBody(body, maxChars) {
183
+ const trimmed = body.trim();
184
+ if (trimmed.length <= maxChars)
185
+ return trimmed;
186
+ return `${trimmed.slice(0, Math.max(0, maxChars - 32)).trim()}\n[Context trimmed for direct Codex PTY]`;
187
+ }
188
+ function compactCodexDirectSystemPrompt(systemPrompt) {
189
+ const trimmed = systemPrompt.trim();
190
+ if (!trimmed)
191
+ return '';
192
+ const sections = parseBracketedSections(trimmed);
193
+ if (sections.length === 0) {
194
+ return trimmed.length <= 900
195
+ ? trimmed
196
+ : `${trimmed.slice(0, 820).trim()}\n\n[Context trimmed for direct Codex PTY]`;
197
+ }
198
+ const priorities = [
199
+ { heading: 'Bot Identity', maxChars: 360 },
200
+ { heading: 'Project Overview', maxChars: 140 },
201
+ { heading: 'Recent Messages (Last 2 Turns)', maxChars: 120 },
202
+ { heading: 'Recent Messages (Last 3 Turns)', maxChars: 140 },
203
+ { heading: 'Recent Messages (Last 4 Turns)', maxChars: 160 },
204
+ { heading: 'Recent Messages (Last 5 Turns)', maxChars: 180 },
205
+ ];
206
+ const selected = [];
207
+ for (const priority of priorities) {
208
+ const section = sections.find((candidate) => candidate.heading === priority.heading);
209
+ if (!section)
210
+ continue;
211
+ const body = trimSectionBody(section.body, priority.maxChars);
212
+ if (!body)
213
+ continue;
214
+ selected.push(`[${section.heading}]\n${body}`);
215
+ }
216
+ const compacted = selected.join('\n\n').trim();
217
+ if (!compacted) {
218
+ return trimmed.length <= 900
219
+ ? trimmed
220
+ : `${trimmed.slice(0, 820).trim()}\n\n[Context trimmed for direct Codex PTY]`;
221
+ }
222
+ return compacted.length <= 900
223
+ ? compacted
224
+ : `${compacted.slice(0, 820).trim()}\n\n[Context trimmed for direct Codex PTY]`;
225
+ }
104
226
  function buildCodexSeedPrompt(systemPrompt, messages) {
105
227
  let fullPrompt = '';
106
228
  if (systemPrompt) {
107
- fullPrompt += `[System Instructions]\n${systemPrompt}\n\n`;
229
+ fullPrompt += `[System Instructions]\n${compactCodexDirectSystemPrompt(systemPrompt)}\n\n`;
108
230
  }
109
231
  const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
110
232
  if (messages.length > 0) {
@@ -123,14 +245,14 @@ function buildCodexSeedPrompt(systemPrompt, messages) {
123
245
  }
124
246
  if (lastMessage?.role === 'user') {
125
247
  const prompt = typeof lastMessage.content === 'string' ? lastMessage.content : JSON.stringify(lastMessage.content);
126
- fullPrompt += `[User Request]\n${prompt}`;
248
+ fullPrompt += `[User Request]\n${prompt}\n\n${completion_marker_1.CLI_COMPLETION_INSTRUCTION}`;
127
249
  }
128
250
  else if (lastMessage) {
129
251
  const content = typeof lastMessage.content === 'string' ? lastMessage.content : JSON.stringify(lastMessage.content);
130
- fullPrompt += `[Latest Message]\n${content}\n\nContinue from the transcript above and respond appropriately.`;
252
+ fullPrompt += `[Latest Message]\n${content}\n\nContinue from the transcript above and respond appropriately. ${completion_marker_1.CLI_COMPLETION_INSTRUCTION}`;
131
253
  }
132
254
  else {
133
- fullPrompt += '[User Request]\n';
255
+ fullPrompt += `[User Request]\n\n${completion_marker_1.CLI_COMPLETION_INSTRUCTION}`;
134
256
  }
135
257
  return fullPrompt;
136
258
  }
@@ -138,9 +260,10 @@ function buildTurnPrompt(provider, systemPrompt, messages, freshSession) {
138
260
  if (!freshSession) {
139
261
  const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
140
262
  if (lastMessage?.role === 'user') {
141
- return typeof lastMessage.content === 'string'
263
+ const prompt = typeof lastMessage.content === 'string'
142
264
  ? lastMessage.content
143
265
  : JSON.stringify(lastMessage.content);
266
+ return `${prompt}\n\nRequired final line when the response is fully complete: ${completion_marker_1.CLI_COMPLETION_SENTINEL}`;
144
267
  }
145
268
  }
146
269
  if (provider === 'claude-cli') {
@@ -152,6 +275,246 @@ function buildTurnPrompt(provider, systemPrompt, messages, freshSession) {
152
275
  }
153
276
  return buildCodexSeedPrompt(systemPrompt, messages);
154
277
  }
278
+ function stripAnsi(text) {
279
+ if (!text)
280
+ return '';
281
+ return text
282
+ .replace(/\x00/g, '')
283
+ .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
284
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
285
+ }
286
+ function resolveConptyOverwrites(text) {
287
+ if (!text)
288
+ return '';
289
+ const normalized = text.replace(/\r\n/g, '\n');
290
+ const output = [];
291
+ let line = '';
292
+ for (const ch of normalized) {
293
+ if (ch === '\r') {
294
+ line = '';
295
+ continue;
296
+ }
297
+ if (ch === '\b') {
298
+ line = line.slice(0, -1);
299
+ continue;
300
+ }
301
+ if (ch === '\n') {
302
+ output.push(line);
303
+ line = '';
304
+ continue;
305
+ }
306
+ line += ch;
307
+ }
308
+ output.push(line);
309
+ return output.join('\n');
310
+ }
311
+ function normalizeTerminalChunk(text) {
312
+ return resolveConptyOverwrites(stripAnsi(text))
313
+ .replace(/\u00a0/g, ' ')
314
+ .replace(/[^\S\n]+/g, ' ');
315
+ }
316
+ function isSyntheticPromptEchoLine(line) {
317
+ const trimmed = line.trim();
318
+ if (!trimmed)
319
+ return false;
320
+ return (/^\[(System Instructions|Recent Transcript|User Request|Latest Message|Bot Identity|Project Overview|Workflow State|Response Style|Input from Previous Step|Context trimmed.*)\]$/i.test(trimmed)
321
+ || /^Current user request:$/i.test(trimmed)
322
+ || /^Recent transcript \(for continuity\):$/i.test(trimmed)
323
+ || /^(Bot Name|Role Label|Project|Workspace):/i.test(trimmed)
324
+ || /^Responsibilities:?$/i.test(trimmed)
325
+ || /^(USER|ASSISTANT|TOOL(?:\s+\(.+\))?):$/i.test(trimmed));
326
+ }
327
+ function isAutomationNoiseLine(provider, line) {
328
+ const trimmed = line.trim();
329
+ if (!trimmed)
330
+ return false;
331
+ if (/^Pasting text/i.test(trimmed))
332
+ return true;
333
+ if (/^\[Pasted.*lines?\]/i.test(trimmed))
334
+ return true;
335
+ if (/^ctrl\+g to edit in notepad$/i.test(trimmed))
336
+ return true;
337
+ if (/^\? for shortcuts$/i.test(trimmed))
338
+ return true;
339
+ if (/^Use \/skills to list available skills$/i.test(trimmed))
340
+ return true;
341
+ if (/^Improve documentation in @filename$/i.test(trimmed))
342
+ return true;
343
+ if (/^permissions on \(shift-tab to cycle\)$/i.test(trimmed))
344
+ return true;
345
+ if (/^[-_=]{4,}$/.test(trimmed))
346
+ return true;
347
+ if (/^[•◦·*✢✶✻✽●]+$/.test(trimmed))
348
+ return true;
349
+ if (/^(Wo|or|rk|ki|in|ng|Wng|Wog)$/.test(trimmed))
350
+ return true;
351
+ if (trimmed.length <= 3 && /^[A-Za-z0-9]+$/.test(trimmed))
352
+ return true;
353
+ if (provider === 'claude-cli' && /^>\s*$/.test(trimmed))
354
+ return true;
355
+ if (provider === 'codex-cli' && /^›\s*$/.test(trimmed))
356
+ return true;
357
+ return false;
358
+ }
359
+ function isRepeatedChromeLine(line) {
360
+ const trimmed = line.trim();
361
+ if (!trimmed)
362
+ return false;
363
+ return (/(?:gpt-\d|claude|% left|esc to interrupt|for shortcuts|Use \/skills|Quantumizing|Working)/i.test(trimmed)
364
+ || /^› /.test(trimmed)
365
+ || /^> /.test(trimmed));
366
+ }
367
+ function isLikelyAssistantAnswerLine(line) {
368
+ const trimmed = line.trim();
369
+ if (!trimmed)
370
+ return false;
371
+ if (/^(?:>|›|\$|\/)/.test(trimmed))
372
+ return false;
373
+ if (/^(Running|Working|Thinking|Brewing|Analyzing|Searching|Reading|Preparing|Loading|Using|Tool|Status|Task|Step)\b/i.test(trimmed))
374
+ return false;
375
+ if (/^[-*]\s+/.test(trimmed) && trimmed.length >= 48)
376
+ return true;
377
+ if (/^\d+\.\s+/.test(trimmed) && trimmed.length >= 48)
378
+ return true;
379
+ const words = trimmed.split(/\s+/).filter(Boolean);
380
+ if (words.length >= 8 && /[a-z]/.test(trimmed) && /[.:!?]$/.test(trimmed))
381
+ return true;
382
+ if (trimmed.length >= 72 && /[a-z]/.test(trimmed))
383
+ return true;
384
+ return false;
385
+ }
386
+ function extractTerminalFacingText(text, assistantOutputDetected) {
387
+ if (!text || assistantOutputDetected) {
388
+ return { terminalText: '', assistantOutputDetected };
389
+ }
390
+ const terminalLines = [];
391
+ let detected = assistantOutputDetected;
392
+ for (const rawLine of text.split('\n')) {
393
+ const trimmed = rawLine.trim();
394
+ if (!trimmed) {
395
+ if (terminalLines.length > 0 && terminalLines[terminalLines.length - 1] !== '') {
396
+ terminalLines.push('');
397
+ }
398
+ continue;
399
+ }
400
+ if (isLikelyAssistantAnswerLine(trimmed)) {
401
+ detected = true;
402
+ break;
403
+ }
404
+ terminalLines.push(rawLine);
405
+ }
406
+ return {
407
+ terminalText: terminalLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
408
+ assistantOutputDetected: detected,
409
+ };
410
+ }
411
+ function sanitizeVisibleChunk(provider, text, recentChromeLines) {
412
+ const sanitizedLines = [];
413
+ for (const rawLine of text.split('\n')) {
414
+ const trimmedRight = rawLine.replace(/\s+$/g, '');
415
+ const trimmed = trimmedRight.trim();
416
+ if (!trimmed) {
417
+ if (sanitizedLines.length > 0 && sanitizedLines[sanitizedLines.length - 1] !== '') {
418
+ sanitizedLines.push('');
419
+ }
420
+ continue;
421
+ }
422
+ if (isSyntheticPromptEchoLine(trimmedRight))
423
+ continue;
424
+ if (isAutomationNoiseLine(provider, trimmedRight))
425
+ continue;
426
+ if (isRepeatedChromeLine(trimmedRight) && recentChromeLines.includes(trimmed))
427
+ continue;
428
+ sanitizedLines.push(trimmedRight);
429
+ if (isRepeatedChromeLine(trimmedRight)) {
430
+ recentChromeLines.push(trimmed);
431
+ if (recentChromeLines.length > 12) {
432
+ recentChromeLines.splice(0, recentChromeLines.length - 12);
433
+ }
434
+ }
435
+ }
436
+ return sanitizedLines.join('\n').replace(/\n{3,}/g, '\n\n');
437
+ }
438
+ function trimPromptEcho(text, promptEchoRemainder) {
439
+ if (!text || !promptEchoRemainder) {
440
+ return { text, promptEchoRemainder };
441
+ }
442
+ let output = '';
443
+ let remainingPrompt = promptEchoRemainder;
444
+ let index = 0;
445
+ const maxIterations = (text.length + promptEchoRemainder.length) * 2;
446
+ let iterations = 0;
447
+ while (index < text.length) {
448
+ if (++iterations > maxIterations) {
449
+ output += text.slice(index);
450
+ break;
451
+ }
452
+ if (!remainingPrompt) {
453
+ output += text.slice(index);
454
+ break;
455
+ }
456
+ const current = text.slice(index);
457
+ if (remainingPrompt.startsWith(current)) {
458
+ return { text: output, promptEchoRemainder: remainingPrompt.slice(current.length) };
459
+ }
460
+ if (current.startsWith(remainingPrompt)) {
461
+ index += remainingPrompt.length;
462
+ remainingPrompt = '';
463
+ continue;
464
+ }
465
+ const maxPrefix = Math.min(current.length, remainingPrompt.length);
466
+ let matched = 0;
467
+ while (matched < maxPrefix && current[matched] === remainingPrompt[matched]) {
468
+ matched++;
469
+ }
470
+ if (matched > 0) {
471
+ index += matched;
472
+ remainingPrompt = remainingPrompt.slice(matched);
473
+ continue;
474
+ }
475
+ output += text[index];
476
+ index += 1;
477
+ }
478
+ return { text: output, promptEchoRemainder: remainingPrompt };
479
+ }
480
+ async function emitPtyChunk(session, chunk) {
481
+ const activeTurn = session.activeTurn;
482
+ if (!activeTurn)
483
+ return;
484
+ activeTurn.rawOutput += chunk;
485
+ activeTurn.lastDataAtMs = Date.now();
486
+ // Fix 13: Extract terminal title updates (OSC sequences) for live activity
487
+ // Format: \x1b]0;<title>\x07 or \x1b]0;<title>\x1b\\
488
+ const titleMatch = chunk.match(/\x1b\]0;([^\x07\x1b]*?)(?:\x07|\x1b\\)/);
489
+ if (titleMatch && titleMatch[1]) {
490
+ const title = titleMatch[1].replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✳✺✹✸✷✶✵✴✻✽⏵⏸●◐◑◒◓]/g, '').trim();
491
+ if (title && title !== session._lastPtyTitle && title.length > 3) {
492
+ session._lastPtyTitle = title;
493
+ activeTurn.lastMeaningfulPtyDataAtMs = Date.now();
494
+ // Store on session for the polling loop to emit via onDetail
495
+ session._pendingTitle = title;
496
+ }
497
+ }
498
+ const cleaned = normalizeTerminalChunk(chunk);
499
+ if (!cleaned.trim())
500
+ return;
501
+ const echoTrimmed = trimPromptEcho(cleaned, activeTurn.promptEchoRemainder);
502
+ activeTurn.promptEchoRemainder = echoTrimmed.promptEchoRemainder;
503
+ const visibleText = sanitizeVisibleChunk(session.provider, echoTrimmed.text, activeTurn.recentChromeLines);
504
+ if (!visibleText.trim())
505
+ return;
506
+ const terminalView = extractTerminalFacingText(visibleText, activeTurn.assistantOutputDetected);
507
+ activeTurn.assistantOutputDetected = terminalView.assistantOutputDetected;
508
+ if (terminalView.terminalText) {
509
+ activeTurn.lastMeaningfulPtyDataAtMs = Date.now();
510
+ const terminalText = terminalView.terminalText.replace(/\n/g, '\r\n');
511
+ activeTurn.callbackChain = activeTurn.callbackChain
512
+ .catch(() => undefined)
513
+ .then(() => activeTurn.onRawChunk?.(terminalText))
514
+ .then(() => undefined);
515
+ await activeTurn.callbackChain;
516
+ }
517
+ }
155
518
  function getProviderSessionRoot(provider) {
156
519
  if (provider === 'claude-cli') {
157
520
  return path.join(os.homedir(), '.claude', 'projects');
@@ -198,8 +561,24 @@ function extractUserPromptFromRecord(provider, record) {
198
561
  }
199
562
  return '';
200
563
  }
201
- function recentFileContainsPrompt(provider, filePath, promptText) {
202
- const expected = promptText.trim();
564
+ function normalizePromptMatchText(text) {
565
+ return text.replace(/\r/g, '').trim();
566
+ }
567
+ function recordTimestampMs(record) {
568
+ const raw = typeof record?.timestamp === 'string'
569
+ ? record.timestamp
570
+ : typeof record?.ts === 'number'
571
+ ? record.ts * 1000
572
+ : null;
573
+ if (typeof raw === 'number')
574
+ return Number.isFinite(raw) ? raw : null;
575
+ if (!raw)
576
+ return null;
577
+ const parsed = Date.parse(raw);
578
+ return Number.isFinite(parsed) ? parsed : null;
579
+ }
580
+ function recentFileContainsPrompt(provider, filePath, promptText, startedAtMs) {
581
+ const expected = normalizePromptMatchText(promptText);
203
582
  if (!expected)
204
583
  return false;
205
584
  let stat;
@@ -224,7 +603,13 @@ function recentFileContainsPrompt(provider, filePath, promptText) {
224
603
  continue;
225
604
  try {
226
605
  const record = JSON.parse(line);
227
- if (extractUserPromptFromRecord(provider, record) === expected) {
606
+ if (provider === 'codex-cli' && startedAtMs) {
607
+ const ts = recordTimestampMs(record);
608
+ if (ts !== null && ts < (startedAtMs - 2000)) {
609
+ continue;
610
+ }
611
+ }
612
+ if (normalizePromptMatchText(extractUserPromptFromRecord(provider, record)) === expected) {
228
613
  return true;
229
614
  }
230
615
  }
@@ -251,19 +636,106 @@ function discoverSessionFile(provider, launchSnapshot, startedAtMs, promptLocato
251
636
  return { candidate, mtimeMs, isNew: !launchSnapshot.has(candidate) };
252
637
  })
253
638
  .filter((item) => !!item)
254
- .filter((item) => item.mtimeMs >= (startedAtMs - 5000))
255
- .sort((a, b) => {
256
- if (a.isNew !== b.isNew)
257
- return a.isNew ? -1 : 1;
258
- return b.mtimeMs - a.mtimeMs;
259
- });
639
+ .filter((item) => item.isNew && item.mtimeMs >= (startedAtMs - 5000))
640
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
260
641
  if (promptLocator?.trim()) {
261
- const matched = candidates.find((item) => recentFileContainsPrompt(provider, item.candidate, promptLocator));
642
+ const matched = candidates.find((item) => recentFileContainsPrompt(provider, item.candidate, promptLocator, startedAtMs));
262
643
  if (matched)
263
644
  return matched.candidate;
645
+ if (provider === 'codex-cli') {
646
+ return null;
647
+ }
264
648
  }
265
649
  return candidates[0]?.candidate || null;
266
650
  }
651
+ function findCodexSessionFileBySessionId(sessionId) {
652
+ if (!sessionId)
653
+ return null;
654
+ const candidates = listSessionFiles('codex-cli')
655
+ .map((candidate) => {
656
+ try {
657
+ return { candidate, mtimeMs: fs.statSync(candidate).mtimeMs };
658
+ }
659
+ catch {
660
+ return null;
661
+ }
662
+ })
663
+ .filter((item) => !!item)
664
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
665
+ for (const item of candidates) {
666
+ let stat;
667
+ try {
668
+ stat = fs.statSync(item.candidate);
669
+ }
670
+ catch {
671
+ continue;
672
+ }
673
+ const readLength = Math.min(stat.size, 64 * 1024);
674
+ if (readLength <= 0)
675
+ continue;
676
+ const fd = fs.openSync(item.candidate, 'r');
677
+ try {
678
+ const buffer = Buffer.alloc(readLength);
679
+ fs.readSync(fd, buffer, 0, readLength, 0);
680
+ const text = buffer.toString('utf8');
681
+ for (const rawLine of text.split('\n')) {
682
+ const line = rawLine.trim();
683
+ if (!line)
684
+ continue;
685
+ try {
686
+ const record = JSON.parse(line);
687
+ if (record?.type === 'session_meta' && record?.payload?.id === sessionId) {
688
+ return item.candidate;
689
+ }
690
+ }
691
+ catch {
692
+ continue;
693
+ }
694
+ }
695
+ }
696
+ finally {
697
+ fs.closeSync(fd);
698
+ }
699
+ }
700
+ return null;
701
+ }
702
+ function formatToolUseDetail(toolName, input) {
703
+ if (!input || typeof input !== 'object')
704
+ return `🔧 ${toolName}`;
705
+ // Format common tools nicely
706
+ switch (toolName) {
707
+ case 'Agent':
708
+ return `🤖 Agent: ${input.description || input.subagent_type || 'working'}`;
709
+ case 'Read':
710
+ return `📄 Read: ${input.file_path || ''}`;
711
+ case 'Write':
712
+ return `✏️ Write: ${input.file_path || ''}`;
713
+ case 'Edit':
714
+ return `✏️ Edit: ${input.file_path || ''}`;
715
+ case 'Bash':
716
+ return `💻 Bash: ${String(input.command || '').slice(0, 200)}`;
717
+ case 'Grep':
718
+ return `🔍 Grep: "${input.pattern || ''}" ${input.path ? 'in ' + input.path : ''}`;
719
+ case 'Glob':
720
+ return `📁 Glob: ${input.pattern || ''}${input.path ? ' in ' + input.path : ''}`;
721
+ case 'WebSearch':
722
+ return `🌐 Search: ${input.query || ''}`;
723
+ case 'WebFetch':
724
+ return `🌐 Fetch: ${input.url || ''}`;
725
+ default:
726
+ return `🔧 ${toolName}: ${JSON.stringify(input).slice(0, 200)}`;
727
+ }
728
+ }
729
+ function formatToolResultDetail(toolUseId, content) {
730
+ const trimmedId = toolUseId.slice(-8);
731
+ const lines = content.split('\n').length;
732
+ const chars = content.length;
733
+ if (chars <= 200) {
734
+ return `✅ Result [${trimmedId}]: ${content}`;
735
+ }
736
+ const firstLine = content.split('\n')[0].slice(0, 120);
737
+ return `✅ Result [${trimmedId}]: ${firstLine}... (${lines} lines, ${chars} chars)`;
738
+ }
267
739
  function extractClaudeText(record) {
268
740
  const blocks = Array.isArray(record?.message?.content) ? record.message.content : [];
269
741
  return blocks
@@ -310,15 +782,44 @@ function extractCodexAssistantText(record) {
310
782
  function normalizeAssistantContent(text) {
311
783
  return text.replace(/^\s+/, '').replace(/\r/g, '');
312
784
  }
785
+ function normalizeCompletionState(text) {
786
+ const normalized = normalizeAssistantContent(text);
787
+ if (!normalized)
788
+ return { text: '', hasSentinel: false };
789
+ const stripped = (0, completion_marker_1.stripCompletionSentinel)(normalized);
790
+ return { text: stripped.text, hasSentinel: stripped.found };
791
+ }
313
792
  async function emitDetail(tracker, detail, cb) {
314
793
  const trimmed = detail.trim();
315
794
  if (!trimmed)
316
795
  return;
317
- if (tracker.detailFingerprints.has(trimmed))
796
+ // Only deduplicate consecutive identical details, not across the whole turn
797
+ if (tracker.lastDetail === trimmed)
318
798
  return;
319
- tracker.detailFingerprints.add(trimmed);
799
+ tracker.lastDetail = trimmed;
320
800
  await cb?.(trimmed);
321
801
  }
802
+ async function emitAssistantChunk(tracker, nextText, cb) {
803
+ const normalized = normalizeCompletionState(nextText).text;
804
+ if (!normalized)
805
+ return;
806
+ const previous = tracker.lastAssistantText;
807
+ if (normalized === previous)
808
+ return;
809
+ tracker.lastAssistantText = normalized;
810
+ if (!cb)
811
+ return;
812
+ if (!previous) {
813
+ await cb(normalized);
814
+ return;
815
+ }
816
+ if (normalized.startsWith(previous)) {
817
+ const delta = normalized.slice(previous.length);
818
+ if (delta) {
819
+ await cb(delta);
820
+ }
821
+ }
822
+ }
322
823
  function parseClaudeSessionRecord(record) {
323
824
  const sessionId = typeof record?.sessionId === 'string' ? record.sessionId : undefined;
324
825
  if (record?.type === 'assistant' && record?.message) {
@@ -330,43 +831,112 @@ function parseClaudeSessionRecord(record) {
330
831
  outputTokens: record.message.usage.output_tokens || 0,
331
832
  }
332
833
  : undefined;
333
- const toolNames = extractClaudeToolNames(record);
334
- if (toolNames.length > 0) {
335
- return {
336
- sessionId,
337
- detail: `Running ${toolNames.join(', ')}...`,
338
- usage,
339
- };
834
+ // Build detail lines and live activity events from ALL content blocks
835
+ const blocks = Array.isArray(record.message.content) ? record.message.content : [];
836
+ const details = [];
837
+ const activities = [];
838
+ const openedToolUseIds = [];
839
+ for (const block of blocks) {
840
+ if (!block || typeof block !== 'object')
841
+ continue;
842
+ if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.trim()) {
843
+ details.push(`💭 Thinking`);
844
+ }
845
+ else if (block.type === 'tool_use') {
846
+ const toolName = typeof block.name === 'string' ? block.name : 'unknown';
847
+ if (typeof block.id === 'string' && block.id.trim()) {
848
+ openedToolUseIds.push(block.id);
849
+ }
850
+ // Emit structured live activity events
851
+ if (toolName === 'Agent') {
852
+ const activityLabel = (0, live_activity_1.agentToolUseToLabel)(block.input || {});
853
+ if (activityLabel) {
854
+ activities.push({
855
+ type: 'subagent_started',
856
+ label: activityLabel,
857
+ key: block.id || undefined,
858
+ timestamp: Date.now(),
859
+ });
860
+ }
861
+ }
862
+ else {
863
+ const activityLabel = (0, live_activity_1.toolUseToActivityLabel)(toolName);
864
+ if (activityLabel) {
865
+ activities.push({
866
+ type: 'working',
867
+ label: activityLabel,
868
+ key: block.id || undefined,
869
+ timestamp: Date.now(),
870
+ });
871
+ }
872
+ }
873
+ }
340
874
  }
341
- const text = normalizeAssistantContent(extractClaudeText(record));
875
+ const completionState = normalizeCompletionState(extractClaudeText(record));
876
+ const text = completionState.text;
877
+ const detailStr = details.length > 0 ? details.join('\n') : undefined;
878
+ // Completion detection:
879
+ // 1. Explicit stop_reason (end_turn, max_tokens, stop_sequence) = done immediately
880
+ // 2. stop_reason: null or tool_use = NOT done (wait for system/turn_duration fallback)
881
+ // Why not "text + no tool_use = done"? Because CLI writes text and tool_use as
882
+ // separate records — text arrives first with stop_reason: null, then tool_use follows.
883
+ // We'd mark done prematurely before the tool_use record arrives.
884
+ const stopReason = record.message?.stop_reason;
885
+ const isDone = !!stopReason && stopReason !== 'tool_use';
886
+ const activityResult = activities.length > 0 ? { activities } : {};
887
+ const toolUseResult = openedToolUseIds.length > 0 ? { openedToolUseIds } : {};
342
888
  if (text) {
889
+ const shouldFinalize = isDone || completionState.hasSentinel;
343
890
  return {
344
891
  sessionId,
345
- ...(record.message.stop_reason === 'end_turn'
892
+ assistantText: text,
893
+ ...(detailStr ? { detail: detailStr } : {}),
894
+ ...(shouldFinalize
346
895
  ? { finalContent: text, done: true, usage }
347
- : { detail: text, usage }),
896
+ : { usage }),
897
+ ...activityResult,
898
+ ...toolUseResult,
348
899
  };
349
900
  }
350
- return { sessionId, usage };
901
+ return {
902
+ sessionId,
903
+ ...(detailStr ? { detail: detailStr } : {}),
904
+ ...(completionState.hasSentinel ? { done: true } : {}),
905
+ usage,
906
+ ...activityResult,
907
+ ...toolUseResult,
908
+ };
351
909
  }
352
910
  if (record?.type === 'user' && Array.isArray(record?.message?.content)) {
353
- const toolResultText = record.message.content
354
- .map((block) => {
355
- if (!block || typeof block !== 'object')
356
- return '';
357
- if (typeof block.tool_use_id === 'string' && typeof block.content === 'string') {
358
- return `Tool result (${block.tool_use_id}): ${String(block.content).slice(0, 240)}`;
359
- }
360
- return '';
361
- })
362
- .filter(Boolean)
363
- .join('\n');
364
- if (toolResultText) {
365
- return { sessionId, detail: toolResultText };
911
+ const resolvedToolUseIds = record.message.content
912
+ .map((block) => (typeof block?.tool_use_id === 'string' ? block.tool_use_id : ''))
913
+ .filter((toolUseId) => !!toolUseId);
914
+ if (resolvedToolUseIds.length > 0) {
915
+ return { sessionId, resolvedToolUseIds };
366
916
  }
367
917
  }
918
+ // system/turn_duration is a fallback completion signal from the CLI
919
+ if (record?.type === 'system' && record?.subtype === 'turn_duration') {
920
+ return { sessionId, done: true };
921
+ }
922
+ // Skip internal bookkeeping records, surface anything else
923
+ const skipTypes = ['permission-mode', 'file-history-snapshot', 'attachment', 'system'];
924
+ if (record?.type && !skipTypes.includes(record.type) && record.type !== 'assistant' && record.type !== 'user') {
925
+ return { sessionId, detail: `[${record.type}]` };
926
+ }
368
927
  return sessionId ? { sessionId } : {};
369
928
  }
929
+ function canFinalizeClaudeTurnOnSessionExit(session, tracker) {
930
+ if (session.provider !== 'claude-cli')
931
+ return false;
932
+ if (tracker.sawExplicitCompletion || tracker.done)
933
+ return false;
934
+ if (!tracker.lastAssistantText.trim())
935
+ return false;
936
+ if (tracker.pendingToolUseIds.size > 0)
937
+ return false;
938
+ return true;
939
+ }
370
940
  function parseCodexSessionRecord(record) {
371
941
  if (!record || typeof record !== 'object')
372
942
  return {};
@@ -374,16 +944,27 @@ function parseCodexSessionRecord(record) {
374
944
  return { sessionId: record.payload.id };
375
945
  }
376
946
  if (record.type === 'event_msg' && record.payload?.type === 'task_started') {
377
- return { detail: 'Task started' };
947
+ return {};
378
948
  }
379
949
  if (record.type === 'event_msg' && record.payload?.type === 'task_complete') {
380
- const finalContent = normalizeAssistantContent(typeof record.payload?.last_agent_message === 'string' ? record.payload.last_agent_message : '');
950
+ const completionState = normalizeCompletionState(typeof record.payload?.last_agent_message === 'string' ? record.payload.last_agent_message : '');
381
951
  return {
382
- finalContent,
952
+ assistantText: completionState.text,
953
+ finalContent: completionState.text,
383
954
  done: true,
384
955
  };
385
956
  }
386
957
  if (record.type === 'event_msg' && record.payload?.type === 'token_count' && record.payload?.info?.total_token_usage) {
958
+ const lastUsage = record.payload.info.last_token_usage;
959
+ if (lastUsage) {
960
+ return {
961
+ usage: {
962
+ inputTokens: (lastUsage.input_tokens || 0)
963
+ + (lastUsage.cached_input_tokens || 0),
964
+ outputTokens: lastUsage.output_tokens || 0,
965
+ },
966
+ };
967
+ }
387
968
  return {
388
969
  usage: {
389
970
  inputTokens: (record.payload.info.total_token_usage.input_tokens || 0)
@@ -392,14 +973,19 @@ function parseCodexSessionRecord(record) {
392
973
  },
393
974
  };
394
975
  }
395
- const assistantText = normalizeAssistantContent(extractCodexAssistantText(record));
976
+ const completionState = normalizeCompletionState(extractCodexAssistantText(record));
977
+ const assistantText = completionState.text;
396
978
  if (assistantText) {
397
- return { finalContent: assistantText };
979
+ return {
980
+ assistantText,
981
+ finalContent: assistantText,
982
+ ...(completionState.hasSentinel ? { done: true } : {}),
983
+ };
398
984
  }
399
985
  if (record.type === 'event_msg' && typeof record.payload?.type === 'string') {
400
986
  const eventType = record.payload.type;
401
987
  if (!['user_message', 'agent_message', 'token_count', 'task_complete'].includes(eventType)) {
402
- return { detail: eventType.replace(/_/g, ' ') };
988
+ return {};
403
989
  }
404
990
  }
405
991
  return {};
@@ -408,18 +994,56 @@ async function waitForFirstTerminalData(session) {
408
994
  await Promise.race([session.readyPromise, delay(5000)]);
409
995
  await delay(session.startupDelayMs);
410
996
  }
997
+ async function writePromptInChunks(pty, promptText, chunkSize = 512, chunkDelayMs = 15) {
998
+ for (let offset = 0; offset < promptText.length; offset += chunkSize) {
999
+ pty.write(promptText.slice(offset, offset + chunkSize));
1000
+ if (offset + chunkSize < promptText.length) {
1001
+ await delay(chunkDelayMs);
1002
+ }
1003
+ }
1004
+ }
1005
+ async function writeBracketedPaste(pty, promptText, chunkSize, chunkDelayMs) {
1006
+ pty.write('\x1b[200~');
1007
+ await writePromptInChunks(pty, promptText, chunkSize, chunkDelayMs);
1008
+ pty.write('\x1b[201~');
1009
+ }
411
1010
  async function writeInteractivePrompt(session, promptText) {
412
1011
  if (session.provider === 'codex-cli') {
413
- session.pty.write(promptText);
1012
+ const multiline = promptText.includes('\n');
1013
+ if (multiline) {
1014
+ await writeBracketedPaste(session.pty, promptText, 512, 15);
1015
+ }
1016
+ else {
1017
+ await writePromptInChunks(session.pty, promptText);
1018
+ }
414
1019
  await delay(session.submitDelayMs);
415
1020
  session.pty.write('\r');
416
1021
  return;
417
1022
  }
418
- session.pty.write(promptText);
1023
+ // 512-byte chunks with 10ms delays — proven safe for Windows ConPTY pipe buffer.
1024
+ await writeBracketedPaste(session.pty, promptText, 512, 10);
1025
+ await delay(session.submitDelayMs);
419
1026
  session.pty.write('\r');
420
1027
  }
1028
+ const SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
1029
+ const SESSION_REAPER_INTERVAL_MS = 60 * 1000; // check every 60 seconds
421
1030
  class LocalCliPtySessionManager {
422
1031
  sessions = new Map();
1032
+ reaperTimer = null;
1033
+ constructor() {
1034
+ this.reaperTimer = setInterval(() => this.reapIdleSessions(), SESSION_REAPER_INTERVAL_MS);
1035
+ }
1036
+ reapIdleSessions() {
1037
+ const now = Date.now();
1038
+ for (const [key, session] of this.sessions) {
1039
+ if (session.activeTurn)
1040
+ continue; // don't kill active sessions
1041
+ if (now - session.lastUsedAtMs > SESSION_IDLE_TIMEOUT_MS) {
1042
+ console.log(`[pty-reaper] Closing idle session: ${key} (idle ${Math.floor((now - session.lastUsedAtMs) / 1000)}s)`);
1043
+ this.closeSession(key);
1044
+ }
1045
+ }
1046
+ }
423
1047
  async runTurn(opts) {
424
1048
  const key = sessionKey(opts.conversationId, opts.botId);
425
1049
  let session = this.sessions.get(key);
@@ -429,7 +1053,14 @@ class LocalCliPtySessionManager {
429
1053
  session = undefined;
430
1054
  }
431
1055
  if (!session) {
432
- session = this.createSession(key, opts.provider, opts.cwd);
1056
+ try {
1057
+ session = this.createSession(key, opts.provider, opts.cwd, opts.resumeSessionId, opts.newSessionId);
1058
+ }
1059
+ catch (firstErr) {
1060
+ console.warn(`[pty] Session creation failed, retrying in 1s: ${firstErr instanceof Error ? firstErr.message : String(firstErr)}`);
1061
+ await delay(1000);
1062
+ session = this.createSession(key, opts.provider, opts.cwd, opts.resumeSessionId, opts.newSessionId);
1063
+ }
433
1064
  this.sessions.set(key, session);
434
1065
  }
435
1066
  const run = async () => this.runTurnInternal(session, opts);
@@ -458,18 +1089,78 @@ class LocalCliPtySessionManager {
458
1089
  }
459
1090
  this.sessions.delete(key);
460
1091
  }
461
- createSession(key, provider, cwd) {
1092
+ createSession(key, provider, cwd, resumeSessionId, newSessionId) {
462
1093
  const ptyModule = loadNodePtyModule();
463
1094
  const cleanEnv = { ...process.env };
464
- delete cleanEnv.CLAUDECODE;
465
- delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
466
- delete cleanEnv.CLAUDE_CODE_SESSION_ID;
1095
+ for (const envKey of Object.keys(cleanEnv)) {
1096
+ if (envKey === 'CLAUDECODE' || envKey.startsWith('CLAUDE_CODE_')) {
1097
+ delete cleanEnv[envKey];
1098
+ }
1099
+ }
1100
+ // Snapshot existing session files BEFORE spawning the PTY.
1101
+ // Only needed for Pattern B providers (no --session-id support).
1102
+ // Pattern A providers know their file path from the generated ID.
1103
+ const preSpawnSnapshot = new Set(listSessionFiles(provider));
467
1104
  let readyResolve = null;
468
1105
  const readyPromise = new Promise((resolve) => {
469
1106
  readyResolve = resolve;
470
1107
  });
1108
+ // Build Claude CLI args with session control:
1109
+ // Pattern A: --session-id for new, --resume for existing
1110
+ // Pattern B (Codex): no session flags on new, codex resume <id> for existing
1111
+ const claudeArgs = ['--dangerously-skip-permissions'];
1112
+ if (provider === 'claude-cli') {
1113
+ if (resumeSessionId) {
1114
+ claudeArgs.push('--resume', resumeSessionId);
1115
+ }
1116
+ else if (newSessionId) {
1117
+ claudeArgs.push('--session-id', newSessionId);
1118
+ }
1119
+ }
1120
+ // Determine known session info for Pattern A
1121
+ const knownSessionId = resumeSessionId || newSessionId || null;
1122
+ let knownSessionFilePath = null;
1123
+ if (knownSessionId && provider === 'claude-cli') {
1124
+ // Search all project directories for the session file.
1125
+ // Don't try to derive the directory name — Claude's naming convention
1126
+ // (e.g., C--Projects-Funolio) doesn't match simple path normalization.
1127
+ const projectRoot = getProviderSessionRoot(provider);
1128
+ if (fs.existsSync(projectRoot)) {
1129
+ try {
1130
+ const projectDirs = fs.readdirSync(projectRoot);
1131
+ for (const dir of projectDirs) {
1132
+ const candidate = path.join(projectRoot, dir, `${knownSessionId}.jsonl`);
1133
+ if (fs.existsSync(candidate)) {
1134
+ knownSessionFilePath = candidate;
1135
+ break;
1136
+ }
1137
+ }
1138
+ }
1139
+ catch { }
1140
+ }
1141
+ // For new sessions, file doesn't exist yet. We'll find it after Claude creates it
1142
+ // by searching again in the polling loop (or it'll be discovered via discoverSessionFile).
1143
+ }
1144
+ else if (knownSessionId && provider === 'codex-cli') {
1145
+ knownSessionFilePath = findCodexSessionFileBySessionId(knownSessionId);
1146
+ }
1147
+ const codexArgs = resumeSessionId
1148
+ ? [
1149
+ 'resume',
1150
+ resumeSessionId,
1151
+ '--no-alt-screen',
1152
+ '--dangerously-bypass-approvals-and-sandbox',
1153
+ '-c',
1154
+ 'shell_environment_policy.inherit=all',
1155
+ ]
1156
+ : [
1157
+ '--no-alt-screen',
1158
+ '--dangerously-bypass-approvals-and-sandbox',
1159
+ '-c',
1160
+ 'shell_environment_policy.inherit=all',
1161
+ ];
471
1162
  const pty = provider === 'codex-cli'
472
- ? ptyModule.spawn(findExecutableOnPath('codex.cmd') || 'codex.cmd', ['--no-alt-screen'], {
1163
+ ? ptyModule.spawn(findExecutableOnPath('codex.cmd') || 'codex.cmd', codexArgs, {
473
1164
  cwd,
474
1165
  cols: 160,
475
1166
  rows: 48,
@@ -477,7 +1168,7 @@ class LocalCliPtySessionManager {
477
1168
  useConpty: true,
478
1169
  name: 'xterm-color',
479
1170
  })
480
- : ptyModule.spawn('cmd.exe', [], {
1171
+ : ptyModule.spawn(findExecutableOnPath('claude.exe') || findExecutableOnPath('claude.cmd') || 'claude', claudeArgs, {
481
1172
  cwd,
482
1173
  cols: 160,
483
1174
  rows: 48,
@@ -492,105 +1183,243 @@ class LocalCliPtySessionManager {
492
1183
  pty,
493
1184
  createdAtMs: Date.now(),
494
1185
  lastUsedAtMs: Date.now(),
495
- launchSnapshot: new Set(listSessionFiles(provider)),
496
- sessionId: null,
497
- sessionFilePath: null,
1186
+ launchSnapshot: preSpawnSnapshot,
1187
+ sessionId: knownSessionId,
1188
+ sessionFilePath: knownSessionFilePath,
498
1189
  sessionFileOffset: 0,
499
1190
  sessionFileCarry: '',
500
1191
  readyPromise,
501
1192
  readyResolved: false,
502
1193
  waitForNextSendMs: 250,
503
- startupDelayMs: provider === 'codex-cli' ? 9000 : 1200,
504
- submitDelayMs: provider === 'codex-cli' ? 350 : 0,
1194
+ startupDelayMs: 1200,
1195
+ submitDelayMs: provider === 'codex-cli' ? 350 : 400,
505
1196
  currentPromptLocator: null,
506
1197
  currentPromptStartedAtMs: 0,
1198
+ activeTurn: null,
507
1199
  closed: false,
508
1200
  chain: Promise.resolve(),
1201
+ childFollowers: new Map(),
1202
+ childSnapshot: new Set(),
509
1203
  };
510
1204
  pty.on('data', (chunk) => {
511
1205
  if (!session.readyResolved && chunk && chunk.trim()) {
512
1206
  session.readyResolved = true;
513
1207
  readyResolve?.();
514
1208
  }
1209
+ if (session.activeTurn && chunk) {
1210
+ void emitPtyChunk(session, chunk);
1211
+ }
515
1212
  });
516
1213
  pty.on('exit', () => {
517
1214
  session.closed = true;
518
1215
  this.sessions.delete(key);
519
1216
  });
520
- if (provider === 'claude-cli') {
521
- pty.write('claude\r');
522
- }
523
1217
  return session;
524
1218
  }
525
1219
  async runTurnInternal(session, opts) {
526
- session.lastUsedAtMs = Date.now();
527
- await waitForFirstTerminalData(session);
528
- if (session.closed) {
529
- throw new Error(`${session.provider} PTY session closed before prompt was sent`);
530
- }
531
- if (session.waitForNextSendMs > 0) {
532
- await delay(session.waitForNextSendMs);
533
- }
534
- const tracker = {
535
- done: false,
536
- finalContent: '',
537
- usage: undefined,
538
- lastAssistantText: '',
539
- detailFingerprints: new Set(),
1220
+ const abortSignal = opts.abortSignal;
1221
+ const abortHandler = () => {
1222
+ this.closeSession(session.key);
540
1223
  };
541
- const timeoutMs = opts.timeoutMs ?? 10 * 60 * 1000;
542
- const startedAtMs = Date.now();
543
- const promptText = buildTurnPrompt(opts.provider, opts.systemPrompt, opts.messages, opts.forceFreshSession || !session.sessionFilePath);
544
- session.currentPromptLocator = promptText.trim();
545
- session.currentPromptStartedAtMs = startedAtMs;
546
- await writeInteractivePrompt(session, promptText);
547
- while (!tracker.done) {
548
- if (Date.now() - startedAtMs > timeoutMs) {
549
- throw new Error(`${opts.provider} PTY session timed out waiting for a response`);
550
- }
1224
+ abortSignal?.addEventListener('abort', abortHandler, { once: true });
1225
+ try {
1226
+ session.lastUsedAtMs = Date.now();
1227
+ throwIfAborted(abortSignal);
1228
+ await waitForFirstTerminalData(session);
1229
+ throwIfAborted(abortSignal);
551
1230
  if (session.closed) {
552
- throw new Error(`${opts.provider} PTY session exited while waiting for a response`);
1231
+ throw new Error(`${session.provider} PTY session closed before prompt was sent`);
553
1232
  }
554
- if (!session.sessionFilePath) {
555
- const discovered = discoverSessionFile(session.provider, session.launchSnapshot, session.currentPromptStartedAtMs || session.createdAtMs, session.currentPromptLocator);
556
- if (discovered) {
557
- session.sessionFilePath = discovered;
558
- session.sessionFileOffset = 0;
559
- session.sessionFileCarry = '';
1233
+ // For resumed sessions with a known file, set the read offset NOW (after CLI is ready)
1234
+ // so we only read new content appended after this point. Setting it at spawn time is
1235
+ // too early — CLI may still be loading session history into the file.
1236
+ if (session.sessionFilePath && session.sessionFileOffset === 0 && session.sessionId) {
1237
+ try {
1238
+ const currentSize = fs.statSync(session.sessionFilePath).size;
1239
+ session.sessionFileOffset = currentSize;
1240
+ }
1241
+ catch {
1242
+ // File may not exist yet for new sessions — that's fine
560
1243
  }
561
1244
  }
562
- if (session.sessionFilePath && fs.existsSync(session.sessionFilePath)) {
563
- await this.consumeSessionFile(session, tracker, opts.onDetail);
1245
+ if (session.waitForNextSendMs > 0) {
1246
+ await delayWithAbort(session.waitForNextSendMs, abortSignal);
1247
+ }
1248
+ const tracker = {
1249
+ done: false,
1250
+ sawExplicitCompletion: false,
1251
+ finalContent: '',
1252
+ usage: undefined,
1253
+ lastAssistantText: '',
1254
+ detailFingerprints: new Set(),
1255
+ pendingToolUseIds: new Set(),
1256
+ lastRecordAtMs: Date.now(),
1257
+ sawCompletionSentinel: false,
1258
+ };
1259
+ const timeoutMs = opts.timeoutMs ?? 10 * 60 * 1000;
1260
+ const startedAtMs = Date.now();
1261
+ const freshClaudeStartupDeadlineMs = opts.forceFreshSession && session.provider === 'claude-cli'
1262
+ ? startedAtMs + CLAUDE_FRESH_SESSION_STARTUP_TIMEOUT_MS
1263
+ : null;
1264
+ const promptText = buildTurnPrompt(opts.provider, opts.systemPrompt, opts.messages, opts.forceFreshSession || !session.sessionFilePath);
1265
+ session.currentPromptLocator = promptText.trim();
1266
+ session.currentPromptStartedAtMs = startedAtMs;
1267
+ const activeTurn = {
1268
+ promptEchoRemainder: normalizeTerminalChunk(promptText),
1269
+ rawOutput: '',
1270
+ lastDataAtMs: startedAtMs,
1271
+ lastMeaningfulPtyDataAtMs: startedAtMs,
1272
+ callbackChain: Promise.resolve(),
1273
+ onChunk: opts.onChunk,
1274
+ onRawChunk: opts.onRawChunk,
1275
+ recentChromeLines: [],
1276
+ assistantOutputDetected: false,
1277
+ };
1278
+ session.activeTurn = activeTurn;
1279
+ // Snapshot existing child subagent files before this turn
1280
+ session.childFollowers.clear();
1281
+ if (session.sessionFilePath && session.provider === 'claude-cli') {
1282
+ const sessionDir = session.sessionFilePath.replace(/\.jsonl$/, '');
1283
+ const subagentsDir = path.join(sessionDir, 'subagents');
1284
+ if (fs.existsSync(subagentsDir)) {
1285
+ try {
1286
+ const existing = fs.readdirSync(subagentsDir).filter((f) => f.endsWith('.jsonl'));
1287
+ session.childSnapshot = new Set(existing.map((f) => path.join(subagentsDir, f)));
1288
+ }
1289
+ catch {
1290
+ session.childSnapshot = new Set();
1291
+ }
1292
+ }
1293
+ else {
1294
+ session.childSnapshot = new Set();
1295
+ }
564
1296
  }
565
- if (!tracker.done) {
566
- await delay(250);
1297
+ throwIfAborted(abortSignal);
1298
+ await writeInteractivePrompt(session, promptText);
1299
+ let lastHeartbeatAtMs = Date.now();
1300
+ const HEARTBEAT_INTERVAL_MS = 120_000; // 2 minutes
1301
+ while (!tracker.done) {
1302
+ throwIfAborted(abortSignal);
1303
+ if (Date.now() - startedAtMs > timeoutMs) {
1304
+ throw new Error(`${opts.provider} PTY session timed out waiting for a response`);
1305
+ }
1306
+ const inactivityFailTimeoutMs = getPtyInactivityFailTimeoutMs(session.provider);
1307
+ if (inactivityFailTimeoutMs != null
1308
+ && Date.now() - activeTurn.lastMeaningfulPtyDataAtMs > inactivityFailTimeoutMs) {
1309
+ if (session.sessionFilePath && fs.existsSync(session.sessionFilePath)) {
1310
+ await this.consumeSessionFile(session, tracker, opts.onChunk, opts.onDetail);
1311
+ }
1312
+ if (tracker.done) {
1313
+ break;
1314
+ }
1315
+ throw new Error(`${opts.provider} PTY session had no meaningful PTY activity for ${Math.floor(inactivityFailTimeoutMs / 1000)}s`);
1316
+ }
1317
+ // Heartbeat: if no detail emitted for 2+ minutes, send a status pulse
1318
+ if (opts.onDetail && Date.now() - lastHeartbeatAtMs > HEARTBEAT_INTERVAL_MS) {
1319
+ const elapsed = Math.floor((Date.now() - startedAtMs) / 1000);
1320
+ await opts.onDetail(`⏳ Still working... (${elapsed}s elapsed)`);
1321
+ lastHeartbeatAtMs = Date.now();
1322
+ }
1323
+ if (!session.sessionFilePath) {
1324
+ // Pattern A (known session ID): search for our specific file by ID
1325
+ if (session.sessionId && session.provider === 'claude-cli') {
1326
+ const projectRoot = getProviderSessionRoot(session.provider);
1327
+ if (fs.existsSync(projectRoot)) {
1328
+ try {
1329
+ for (const dir of fs.readdirSync(projectRoot)) {
1330
+ const candidate = path.join(projectRoot, dir, `${session.sessionId}.jsonl`);
1331
+ if (fs.existsSync(candidate)) {
1332
+ session.sessionFilePath = candidate;
1333
+ session.sessionFileOffset = 0;
1334
+ session.sessionFileCarry = '';
1335
+ break;
1336
+ }
1337
+ }
1338
+ }
1339
+ catch { }
1340
+ }
1341
+ }
1342
+ // Pattern B (unknown ID): discover by snapshot diff
1343
+ if (!session.sessionFilePath) {
1344
+ const discovered = discoverSessionFile(session.provider, session.launchSnapshot, session.currentPromptStartedAtMs || session.createdAtMs, session.currentPromptLocator);
1345
+ if (discovered) {
1346
+ session.sessionFilePath = discovered;
1347
+ session.sessionFileOffset = 0;
1348
+ session.sessionFileCarry = '';
1349
+ }
1350
+ }
1351
+ }
1352
+ if (session.sessionFilePath && fs.existsSync(session.sessionFilePath)) {
1353
+ await this.consumeSessionFile(session, tracker, opts.onChunk, opts.onDetail);
1354
+ }
1355
+ if (freshClaudeStartupDeadlineMs
1356
+ && Date.now() >= freshClaudeStartupDeadlineMs
1357
+ && !(session.sessionFilePath && fs.existsSync(session.sessionFilePath))) {
1358
+ this.closeSession(session.key);
1359
+ throw buildClaudeFreshSessionStartupError(session.sessionId);
1360
+ }
1361
+ if (session._pendingTitle) {
1362
+ session._pendingTitle = null;
1363
+ }
1364
+ // Fix 12: Follow child sub-agent JSON files for live progress
1365
+ if (session.sessionFilePath && session.provider === 'claude-cli' && opts.onDetail) {
1366
+ await this.consumeChildSubagentFiles(session, tracker, opts.onDetail);
1367
+ }
1368
+ if (session.closed) {
1369
+ if (canFinalizeClaudeTurnOnSessionExit(session, tracker)) {
1370
+ tracker.done = true;
1371
+ tracker.finalContent = tracker.finalContent || tracker.lastAssistantText;
1372
+ break;
1373
+ }
1374
+ throw new Error(`${opts.provider} PTY session exited while waiting for a response`);
1375
+ }
1376
+ if (!tracker.done) {
1377
+ await delayWithAbort(1000, abortSignal);
1378
+ }
1379
+ }
1380
+ const settleStartedAt = Date.now();
1381
+ while (Date.now() - Math.max(activeTurn.lastDataAtMs, settleStartedAt) < 250) {
1382
+ throwIfAborted(abortSignal);
1383
+ if (Date.now() - settleStartedAt > 1500)
1384
+ break;
1385
+ await delayWithAbort(50, abortSignal);
567
1386
  }
1387
+ if (session.sessionFilePath && fs.existsSync(session.sessionFilePath)) {
1388
+ await this.consumeSessionFile(session, tracker, opts.onChunk, opts.onDetail);
1389
+ }
1390
+ await activeTurn.callbackChain;
1391
+ session.lastUsedAtMs = Date.now();
1392
+ session.waitForNextSendMs = 400;
1393
+ return {
1394
+ content: (tracker.finalContent || tracker.lastAssistantText).trim(),
1395
+ sessionId: session.sessionId,
1396
+ usage: tracker.usage,
1397
+ rawOutput: activeTurn.rawOutput,
1398
+ };
1399
+ }
1400
+ finally {
1401
+ session.activeTurn = null;
1402
+ abortSignal?.removeEventListener('abort', abortHandler);
568
1403
  }
569
- session.lastUsedAtMs = Date.now();
570
- session.waitForNextSendMs = 400;
571
- return {
572
- content: tracker.finalContent.trim(),
573
- sessionId: session.sessionId,
574
- usage: tracker.usage,
575
- };
576
1404
  }
577
- async consumeSessionFile(session, tracker, onDetail) {
1405
+ async consumeSessionFile(session, tracker, onChunk, onDetail) {
578
1406
  if (!session.sessionFilePath)
579
1407
  return;
580
1408
  let stat;
581
1409
  try {
582
- stat = fs.statSync(session.sessionFilePath);
1410
+ stat = await fs.promises.stat(session.sessionFilePath);
583
1411
  }
584
1412
  catch {
585
1413
  return;
586
1414
  }
587
1415
  if (stat.size <= session.sessionFileOffset)
588
1416
  return;
589
- const fd = fs.openSync(session.sessionFilePath, 'r');
1417
+ let fh = null;
590
1418
  try {
1419
+ fh = await fs.promises.open(session.sessionFilePath, 'r');
591
1420
  const length = stat.size - session.sessionFileOffset;
592
1421
  const buffer = Buffer.alloc(length);
593
- fs.readSync(fd, buffer, 0, length, session.sessionFileOffset);
1422
+ await fh.read(buffer, 0, length, session.sessionFileOffset);
594
1423
  session.sessionFileOffset = stat.size;
595
1424
  const text = session.sessionFileCarry + buffer.toString('utf8');
596
1425
  const lines = text.split('\n');
@@ -606,14 +1435,115 @@ class LocalCliPtySessionManager {
606
1435
  catch {
607
1436
  continue;
608
1437
  }
609
- await this.applyRecord(session, tracker, record, onDetail);
1438
+ await this.applyRecord(session, tracker, record, onChunk, onDetail);
610
1439
  }
611
1440
  }
612
1441
  finally {
613
- fs.closeSync(fd);
1442
+ await fh?.close();
1443
+ }
1444
+ }
1445
+ /**
1446
+ * Fix 12: Watch for child sub-agent .jsonl files and emit curated progress.
1447
+ * Claude sub-agents write to: <parent-session-folder>/subagents/agent-<id>.jsonl
1448
+ * We discover new files by watching the directory (not by deriving from agentId,
1449
+ * which isn't available until the sub-agent completes).
1450
+ */
1451
+ async consumeChildSubagentFiles(session, tracker, onDetail) {
1452
+ if (!session.sessionFilePath)
1453
+ return;
1454
+ // Derive the subagents directory from the session file path
1455
+ const sessionDir = session.sessionFilePath.replace(/\.jsonl$/, '');
1456
+ const subagentsDir = path.join(sessionDir, 'subagents');
1457
+ if (!fs.existsSync(subagentsDir))
1458
+ return;
1459
+ // Scan for new child files
1460
+ try {
1461
+ const entries = fs.readdirSync(subagentsDir).filter((f) => f.endsWith('.jsonl'));
1462
+ for (const entry of entries) {
1463
+ const childPath = path.join(subagentsDir, entry);
1464
+ if (session.childSnapshot.has(childPath))
1465
+ continue; // Already known from before this turn
1466
+ // Start following this child file if not already
1467
+ if (!session.childFollowers.has(childPath)) {
1468
+ session.childFollowers.set(childPath, { offset: 0, carry: '' });
1469
+ }
1470
+ const follower = session.childFollowers.get(childPath);
1471
+ let stat;
1472
+ try {
1473
+ stat = fs.statSync(childPath);
1474
+ }
1475
+ catch {
1476
+ continue;
1477
+ }
1478
+ if (stat.size <= follower.offset)
1479
+ continue;
1480
+ // Read new content
1481
+ let fh = null;
1482
+ try {
1483
+ fh = await fs.promises.open(childPath, 'r');
1484
+ const length = stat.size - follower.offset;
1485
+ const buffer = Buffer.alloc(length);
1486
+ await fh.read(buffer, 0, length, follower.offset);
1487
+ follower.offset = stat.size;
1488
+ const text = follower.carry + buffer.toString('utf8');
1489
+ const lines = text.split('\n');
1490
+ follower.carry = lines.pop() || '';
1491
+ for (const line of lines) {
1492
+ const trimmed = line.trim();
1493
+ if (!trimmed)
1494
+ continue;
1495
+ let record;
1496
+ try {
1497
+ record = JSON.parse(trimmed);
1498
+ }
1499
+ catch {
1500
+ continue;
1501
+ }
1502
+ // Extract curated child progress
1503
+ if (record?.type === 'assistant' && record?.message?.content) {
1504
+ const blocks = Array.isArray(record.message.content) ? record.message.content : [];
1505
+ for (const block of blocks) {
1506
+ if (!block || typeof block !== 'object')
1507
+ continue;
1508
+ if (block.type === 'text' && typeof block.text === 'string') {
1509
+ const shortText = block.text.trim().slice(0, 150);
1510
+ if (shortText) {
1511
+ const activity = {
1512
+ type: 'subagent_working',
1513
+ label: shortText,
1514
+ key: childPath,
1515
+ timestamp: Date.now(),
1516
+ };
1517
+ await onDetail(`__ACTIVITY__${JSON.stringify(activity)}`);
1518
+ }
1519
+ }
1520
+ else if (block.type === 'tool_use') {
1521
+ const toolName = typeof block.name === 'string' ? block.name : 'tool';
1522
+ const activityLabel = (0, live_activity_1.toolUseToActivityLabel)(toolName);
1523
+ if (activityLabel) {
1524
+ const activity = {
1525
+ type: 'subagent_working',
1526
+ label: activityLabel,
1527
+ key: childPath,
1528
+ timestamp: Date.now(),
1529
+ };
1530
+ await onDetail(`__ACTIVITY__${JSON.stringify(activity)}`);
1531
+ }
1532
+ }
1533
+ }
1534
+ }
1535
+ }
1536
+ }
1537
+ finally {
1538
+ await fh?.close();
1539
+ }
1540
+ }
1541
+ }
1542
+ catch {
1543
+ // subagents dir not readable — skip silently
614
1544
  }
615
1545
  }
616
- async applyRecord(session, tracker, record, onDetail) {
1546
+ async applyRecord(session, tracker, record, onChunk, onDetail) {
617
1547
  const parsed = session.provider === 'claude-cli'
618
1548
  ? parseClaudeSessionRecord(record)
619
1549
  : parseCodexSessionRecord(record);
@@ -623,14 +1553,40 @@ class LocalCliPtySessionManager {
623
1553
  if (parsed.usage) {
624
1554
  tracker.usage = parsed.usage;
625
1555
  }
1556
+ if (parsed.openedToolUseIds?.length) {
1557
+ for (const toolUseId of parsed.openedToolUseIds) {
1558
+ tracker.pendingToolUseIds.add(toolUseId);
1559
+ }
1560
+ tracker.lastRecordAtMs = Date.now();
1561
+ }
1562
+ if (parsed.resolvedToolUseIds?.length) {
1563
+ for (const toolUseId of parsed.resolvedToolUseIds) {
1564
+ tracker.pendingToolUseIds.delete(toolUseId);
1565
+ }
1566
+ tracker.lastRecordAtMs = Date.now();
1567
+ }
626
1568
  if (parsed.detail) {
1569
+ tracker.lastRecordAtMs = Date.now();
627
1570
  await emitDetail(tracker, parsed.detail, onDetail);
628
1571
  }
1572
+ // Emit structured live activity events as JSON-prefixed detail lines
1573
+ // Frontend can distinguish these from plain text details by the prefix
1574
+ if (parsed.activities && parsed.activities.length > 0 && onDetail) {
1575
+ for (const activity of parsed.activities) {
1576
+ const encoded = `__ACTIVITY__${JSON.stringify(activity)}`;
1577
+ await emitDetail(tracker, encoded, onDetail);
1578
+ }
1579
+ }
1580
+ if (parsed.assistantText) {
1581
+ tracker.lastRecordAtMs = Date.now();
1582
+ await emitAssistantChunk(tracker, parsed.assistantText, onChunk);
1583
+ }
629
1584
  if (parsed.finalContent) {
630
1585
  tracker.finalContent = parsed.finalContent;
631
1586
  tracker.lastAssistantText = parsed.finalContent;
632
1587
  }
633
1588
  if (parsed.done) {
1589
+ tracker.sawExplicitCompletion = true;
634
1590
  tracker.done = true;
635
1591
  }
636
1592
  }
@@ -642,4 +1598,161 @@ function getLocalCliPtySessionManager() {
642
1598
  }
643
1599
  return _manager;
644
1600
  }
1601
+ async function runLocalCliPtyHealthCheck() {
1602
+ const execDir = path.dirname(process.execPath);
1603
+ const appDir = getAppDirFromExec(execDir);
1604
+ const packagedIndexPath = path.join(appDir, 'resources', 'node-pty-prebuilt', 'lib', 'index.js');
1605
+ const outputChunks = [];
1606
+ try {
1607
+ const ptyModule = loadNodePtyModule();
1608
+ const pty = ptyModule.spawn('cmd.exe', [], {
1609
+ cwd: process.cwd(),
1610
+ cols: 80,
1611
+ rows: 24,
1612
+ env: { ...process.env },
1613
+ useConpty: true,
1614
+ name: 'xterm-color',
1615
+ });
1616
+ const result = await new Promise((resolve) => {
1617
+ let finished = false;
1618
+ const finish = (value) => {
1619
+ if (finished)
1620
+ return;
1621
+ finished = true;
1622
+ try {
1623
+ pty.kill();
1624
+ }
1625
+ catch { }
1626
+ resolve(value);
1627
+ };
1628
+ const timeout = setTimeout(() => {
1629
+ finish({
1630
+ ok: false,
1631
+ output: outputChunks.join(''),
1632
+ error: 'Timed out waiting for PTY echo response',
1633
+ });
1634
+ }, 5000);
1635
+ pty.on('data', (chunk) => {
1636
+ outputChunks.push(chunk);
1637
+ if (outputChunks.join('').includes('PTY_OK')) {
1638
+ clearTimeout(timeout);
1639
+ finish({ ok: true, output: outputChunks.join('') });
1640
+ }
1641
+ });
1642
+ pty.on('exit', () => {
1643
+ clearTimeout(timeout);
1644
+ if (!finished) {
1645
+ finish({
1646
+ ok: false,
1647
+ output: outputChunks.join(''),
1648
+ error: 'PTY session exited before echo response',
1649
+ });
1650
+ }
1651
+ });
1652
+ pty.write('echo PTY_OK\r');
1653
+ pty.write('exit\r');
1654
+ });
1655
+ return {
1656
+ ok: result.ok,
1657
+ execPath: process.execPath,
1658
+ packagedIndexPath,
1659
+ output: result.output,
1660
+ error: result.error,
1661
+ };
1662
+ }
1663
+ catch (error) {
1664
+ return {
1665
+ ok: false,
1666
+ execPath: process.execPath,
1667
+ packagedIndexPath,
1668
+ output: outputChunks.join(''),
1669
+ error: error instanceof Error ? error.message : String(error),
1670
+ };
1671
+ }
1672
+ }
1673
+ async function runLocalCliPtyTurnHealthCheck(provider, cwd) {
1674
+ const manager = getLocalCliPtySessionManager();
1675
+ const conversationId = `pty-health-${Date.now()}`;
1676
+ const botId = `${provider}-health`;
1677
+ const expected = provider === 'claude-cli' ? 'CLAUDE_PTY_TURN_OK' : 'CODEX_PTY_TURN_OK';
1678
+ try {
1679
+ const result = await manager.runTurn({
1680
+ conversationId,
1681
+ botId,
1682
+ provider,
1683
+ cwd,
1684
+ systemPrompt: '',
1685
+ messages: [
1686
+ {
1687
+ role: 'user',
1688
+ content: `Reply with exactly ${expected} and nothing else.`,
1689
+ },
1690
+ ],
1691
+ forceFreshSession: true,
1692
+ timeoutMs: 90_000,
1693
+ });
1694
+ manager.closeSessionByConversation(conversationId, botId);
1695
+ return {
1696
+ ok: result.content.trim() === expected,
1697
+ provider,
1698
+ cwd,
1699
+ content: result.content,
1700
+ error: result.content.trim() === expected
1701
+ ? undefined
1702
+ : `Unexpected response content: ${JSON.stringify(result.content)}`,
1703
+ };
1704
+ }
1705
+ catch (error) {
1706
+ manager.closeSessionByConversation(conversationId, botId);
1707
+ return {
1708
+ ok: false,
1709
+ provider,
1710
+ cwd,
1711
+ content: '',
1712
+ error: error instanceof Error ? error.message : String(error),
1713
+ };
1714
+ }
1715
+ }
1716
+ async function runLocalCliPtyProbe(provider, cwd, prompt, systemPrompt = '') {
1717
+ const manager = getLocalCliPtySessionManager();
1718
+ const conversationId = `pty-probe-${Date.now()}`;
1719
+ const botId = `${provider}-probe`;
1720
+ const startedAt = Date.now();
1721
+ try {
1722
+ const result = await manager.runTurn({
1723
+ conversationId,
1724
+ botId,
1725
+ provider,
1726
+ cwd,
1727
+ systemPrompt,
1728
+ messages: [
1729
+ {
1730
+ role: 'user',
1731
+ content: prompt,
1732
+ },
1733
+ ],
1734
+ forceFreshSession: true,
1735
+ timeoutMs: 180_000,
1736
+ });
1737
+ manager.closeSessionByConversation(conversationId, botId);
1738
+ return {
1739
+ ok: result.content.trim().length > 0,
1740
+ provider,
1741
+ cwd,
1742
+ content: result.content,
1743
+ elapsedMs: Date.now() - startedAt,
1744
+ };
1745
+ }
1746
+ catch (error) {
1747
+ manager.closeSessionByConversation(conversationId, botId);
1748
+ return {
1749
+ ok: false,
1750
+ provider,
1751
+ cwd,
1752
+ content: '',
1753
+ elapsedMs: Date.now() - startedAt,
1754
+ error: error instanceof Error ? error.message : String(error),
1755
+ };
1756
+ }
1757
+ }
645
1758
  //# sourceMappingURL=local-cli-pty-manager.js.map