tabminal 2.0.14 → 2.0.15

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.
@@ -0,0 +1,3469 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { EventEmitter } from 'node:events';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { Readable, Writable } from 'node:stream';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+
10
+ import * as acp from '@agentclientprotocol/sdk';
11
+ import pkg from '../package.json' with { type: 'json' };
12
+ import * as persistence from './persistence.mjs';
13
+
14
+ const DEFAULT_IDLE_TIMEOUT_MS = 2 * 60 * 1000;
15
+ const DEFAULT_TERMINAL_OUTPUT_LIMIT = 256 * 1024;
16
+ const DEFAULT_AVAILABILITY_OVERRIDE_TTL_MS = 30 * 1000;
17
+ const DEFAULT_PROBE_CACHE_TTL_MS = 15 * 1000;
18
+ const DEFAULT_TRANSCRIPT_PERSIST_DELAY_MS = 250;
19
+ const TEXT_ATTACHMENT_EXTENSIONS = new Set([
20
+ 'txt', 'md', 'markdown', 'json', 'jsonl', 'yaml', 'yml', 'toml',
21
+ 'ini', 'env', 'xml', 'html', 'htm', 'css', 'scss', 'less', 'csv',
22
+ 'tsv', 'log', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb',
23
+ 'go', 'rs', 'java', 'kt', 'swift', 'c', 'cc', 'cpp', 'h', 'hpp',
24
+ 'sh', 'bash', 'zsh', 'fish', 'sql', 'graphql', 'gql', 'diff', 'patch'
25
+ ]);
26
+ const NPX_COMMAND = process.platform === 'win32' ? 'npx.cmd' : 'npx';
27
+ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
28
+ const TEST_AGENT_PATH = path.join(CURRENT_DIR, 'acp-test-agent.mjs');
29
+ const AGENT_CONFIG_ENV_KEYS = {
30
+ gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
31
+ claude: [
32
+ 'ANTHROPIC_API_KEY',
33
+ 'CLAUDE_CODE_USE_VERTEX',
34
+ 'ANTHROPIC_VERTEX_PROJECT_ID',
35
+ 'GCLOUD_PROJECT',
36
+ 'GOOGLE_CLOUD_PROJECT',
37
+ 'CLOUD_ML_REGION',
38
+ 'GOOGLE_APPLICATION_CREDENTIALS'
39
+ ],
40
+ copilot: [
41
+ 'COPILOT_GITHUB_TOKEN',
42
+ 'GH_TOKEN',
43
+ 'GITHUB_TOKEN'
44
+ ]
45
+ };
46
+
47
+ function getAllowedAgentEnvKeys(agentId) {
48
+ return AGENT_CONFIG_ENV_KEYS[agentId] || [];
49
+ }
50
+
51
+ function normalizeConfiguredEnv(agentId, env) {
52
+ const allowedKeys = new Set(getAllowedAgentEnvKeys(agentId));
53
+ if (!allowedKeys.size || !env || typeof env !== 'object') {
54
+ return {};
55
+ }
56
+ const normalized = {};
57
+ for (const [key, value] of Object.entries(env)) {
58
+ if (!allowedKeys.has(key)) continue;
59
+ normalized[key] = typeof value === 'string' ? value : '';
60
+ }
61
+ return normalized;
62
+ }
63
+
64
+ function hasConfiguredValue(value) {
65
+ return typeof value === 'string' && value.trim().length > 0;
66
+ }
67
+
68
+ function buildAgentConfigSummary(agentId, config = {}) {
69
+ const env = normalizeConfiguredEnv(agentId, config.env);
70
+ switch (agentId) {
71
+ case 'gemini':
72
+ return {
73
+ hasGeminiApiKey: hasConfiguredValue(env.GEMINI_API_KEY),
74
+ hasGoogleApiKey: hasConfiguredValue(env.GOOGLE_API_KEY)
75
+ };
76
+ case 'claude':
77
+ return {
78
+ hasAnthropicApiKey: hasConfiguredValue(env.ANTHROPIC_API_KEY),
79
+ useVertex: env.CLAUDE_CODE_USE_VERTEX === '1',
80
+ hasVertexProjectId: hasConfiguredValue(
81
+ env.ANTHROPIC_VERTEX_PROJECT_ID
82
+ ),
83
+ vertexProjectId:
84
+ env.ANTHROPIC_VERTEX_PROJECT_ID || '',
85
+ gcloudProject:
86
+ env.GCLOUD_PROJECT || env.GOOGLE_CLOUD_PROJECT || '',
87
+ cloudMlRegion: env.CLOUD_ML_REGION || '',
88
+ hasGoogleCredentials: hasConfiguredValue(
89
+ env.GOOGLE_APPLICATION_CREDENTIALS
90
+ )
91
+ };
92
+ case 'copilot':
93
+ return {
94
+ hasCopilotToken: hasConfiguredValue(
95
+ env.COPILOT_GITHUB_TOKEN
96
+ || env.GH_TOKEN
97
+ || env.GITHUB_TOKEN
98
+ )
99
+ };
100
+ default:
101
+ return {};
102
+ }
103
+ }
104
+
105
+ let ghCopilotCliInstalledCache = null;
106
+ let ghAuthTokenCache = null;
107
+ const availabilityProbeCache = new Map();
108
+
109
+ function getCachedProbeValue(key) {
110
+ if (!key) return null;
111
+ const entry = availabilityProbeCache.get(key);
112
+ if (!entry) return null;
113
+ if (entry.expiresAt <= Date.now()) {
114
+ availabilityProbeCache.delete(key);
115
+ return null;
116
+ }
117
+ return entry.value;
118
+ }
119
+
120
+ function setCachedProbeValue(key, value) {
121
+ if (!key) return value;
122
+ availabilityProbeCache.set(key, {
123
+ value,
124
+ expiresAt: Date.now() + DEFAULT_PROBE_CACHE_TTL_MS
125
+ });
126
+ return value;
127
+ }
128
+
129
+ function getAvailabilityCacheScopeKey(runtimeEnv = {}) {
130
+ return String(
131
+ runtimeEnv.HOME
132
+ || process.env.HOME
133
+ || process.cwd()
134
+ );
135
+ }
136
+
137
+ function probeCodexAuth(runtimeEnv = {}) {
138
+ if (!commandExists('codex', runtimeEnv)) {
139
+ return { available: true, reason: '' };
140
+ }
141
+
142
+ const cacheKey = `codex-auth:${getAvailabilityCacheScopeKey(runtimeEnv)}`;
143
+ const cached = getCachedProbeValue(cacheKey);
144
+ if (cached) {
145
+ return cached;
146
+ }
147
+
148
+ const result = spawnSync('codex', ['login', 'status'], {
149
+ encoding: 'utf8',
150
+ timeout: 1500,
151
+ env: withAgentPath(runtimeEnv)
152
+ });
153
+ if (result.status === 0) {
154
+ return setCachedProbeValue(cacheKey, {
155
+ available: true,
156
+ reason: ''
157
+ });
158
+ }
159
+ const output = [
160
+ result.stdout,
161
+ result.stderr,
162
+ result.error?.message
163
+ ].filter(Boolean).join('\n');
164
+ if (/not logged in|authentication required|login/i.test(output)) {
165
+ return setCachedProbeValue(cacheKey, {
166
+ available: false,
167
+ reason: 'Run `codex login` on this host'
168
+ });
169
+ }
170
+ return setCachedProbeValue(cacheKey, {
171
+ available: true,
172
+ reason: ''
173
+ });
174
+ }
175
+
176
+ function probeGhAuth(runtimeEnv = {}) {
177
+ const explicitToken = String(
178
+ runtimeEnv.COPILOT_GITHUB_TOKEN
179
+ || runtimeEnv.GH_TOKEN
180
+ || runtimeEnv.GITHUB_TOKEN
181
+ || ''
182
+ ).trim();
183
+ if (explicitToken) {
184
+ return { available: true, reason: '' };
185
+ }
186
+ if (!commandExists('gh', runtimeEnv)) {
187
+ return {
188
+ available: false,
189
+ reason: 'Run `gh auth login` or set `COPILOT_GITHUB_TOKEN`'
190
+ };
191
+ }
192
+
193
+ const cacheKey = `gh-auth:${getAvailabilityCacheScopeKey(runtimeEnv)}`;
194
+ const cached = getCachedProbeValue(cacheKey);
195
+ if (cached) {
196
+ return cached;
197
+ }
198
+
199
+ const result = spawnSync('gh', ['auth', 'status'], {
200
+ encoding: 'utf8',
201
+ timeout: 1500,
202
+ env: withAgentPath(runtimeEnv)
203
+ });
204
+ if (result.status === 0) {
205
+ return setCachedProbeValue(cacheKey, {
206
+ available: true,
207
+ reason: ''
208
+ });
209
+ }
210
+ const output = [
211
+ result.stdout,
212
+ result.stderr,
213
+ result.error?.message
214
+ ].filter(Boolean).join('\n');
215
+ if (/not logged|not logged into any github hosts/i.test(output)) {
216
+ return setCachedProbeValue(cacheKey, {
217
+ available: false,
218
+ reason: 'Run `gh auth login` or set `COPILOT_GITHUB_TOKEN`'
219
+ });
220
+ }
221
+ return setCachedProbeValue(cacheKey, {
222
+ available: true,
223
+ reason: ''
224
+ });
225
+ }
226
+
227
+ const DEFAULT_AVAILABILITY_PROBES = {
228
+ commandExists,
229
+ hasGhCopilotWrapper,
230
+ hasGhCopilotCliInstalled,
231
+ probeCodexAuth,
232
+ probeGhAuth
233
+ };
234
+
235
+ function hasGhCopilotCliInstalled() {
236
+ if (typeof ghCopilotCliInstalledCache === 'boolean') {
237
+ return ghCopilotCliInstalledCache;
238
+ }
239
+ if (!commandExists('gh')) {
240
+ ghCopilotCliInstalledCache = false;
241
+ return ghCopilotCliInstalledCache;
242
+ }
243
+ const result = spawnSync('gh', ['copilot', '--', '--version'], {
244
+ encoding: 'utf8',
245
+ env: withAgentPath(process.env)
246
+ });
247
+ ghCopilotCliInstalledCache = result.status === 0;
248
+ return ghCopilotCliInstalledCache;
249
+ }
250
+
251
+ function readGhAuthToken() {
252
+ if (typeof ghAuthTokenCache === 'string') {
253
+ return ghAuthTokenCache;
254
+ }
255
+ if (!commandExists('gh')) {
256
+ ghAuthTokenCache = '';
257
+ return ghAuthTokenCache;
258
+ }
259
+ const result = spawnSync('gh', ['auth', 'token'], {
260
+ encoding: 'utf8',
261
+ env: withAgentPath(process.env)
262
+ });
263
+ ghAuthTokenCache = result.status === 0
264
+ ? String(result.stdout || '').trim()
265
+ : '';
266
+ return ghAuthTokenCache;
267
+ }
268
+
269
+ function buildAugmentedPath(env = {}) {
270
+ const delimiter = path.delimiter;
271
+ const home = String(
272
+ env.HOME
273
+ || process.env.HOME
274
+ || ''
275
+ ).trim();
276
+ const existingEntries = String(
277
+ env.PATH
278
+ || process.env.PATH
279
+ || ''
280
+ )
281
+ .split(delimiter)
282
+ .map((entry) => entry.trim())
283
+ .filter(Boolean);
284
+ const extraEntries = [
285
+ home ? path.join(home, '.local', 'bin') : '',
286
+ home ? path.join(home, 'bin') : '',
287
+ '/opt/homebrew/bin',
288
+ '/opt/homebrew/sbin',
289
+ '/usr/local/bin'
290
+ ].filter(Boolean);
291
+ const seen = new Set();
292
+ return [...existingEntries, ...extraEntries]
293
+ .filter((entry) => {
294
+ if (seen.has(entry)) return false;
295
+ seen.add(entry);
296
+ return true;
297
+ })
298
+ .join(delimiter);
299
+ }
300
+
301
+ function withAgentPath(env = {}) {
302
+ return {
303
+ ...env,
304
+ PATH: buildAugmentedPath(env)
305
+ };
306
+ }
307
+
308
+ function mergeDefinitionEnv(definition, agentConfig = {}) {
309
+ const env = withAgentPath({
310
+ ...process.env,
311
+ ...normalizeConfiguredEnv(definition.id, agentConfig.env)
312
+ });
313
+ if (definition.id === 'copilot') {
314
+ const hasToken = Boolean(
315
+ env.COPILOT_GITHUB_TOKEN
316
+ || env.GH_TOKEN
317
+ || env.GITHUB_TOKEN
318
+ );
319
+ if (!hasToken) {
320
+ const ghToken = readGhAuthToken();
321
+ if (ghToken) {
322
+ env.GH_TOKEN = ghToken;
323
+ }
324
+ }
325
+ }
326
+ return env;
327
+ }
328
+
329
+ function hasGhCopilotWrapper() {
330
+ if (!commandExists('gh')) return false;
331
+ const result = spawnSync('gh', ['extension', 'list'], {
332
+ encoding: 'utf8',
333
+ env: withAgentPath(process.env)
334
+ });
335
+ return result.status === 0
336
+ && typeof result.stdout === 'string'
337
+ && result.stdout.includes('gh-copilot');
338
+ }
339
+
340
+ function commandExists(command, env = process.env) {
341
+ const checker = process.platform === 'win32' ? 'where' : 'which';
342
+ const result = spawnSync(checker, [command], {
343
+ stdio: 'ignore',
344
+ env: withAgentPath(env)
345
+ });
346
+ return result.status === 0;
347
+ }
348
+
349
+ function getAttachmentExtension(name = '') {
350
+ const extension = path.extname(String(name || '')).toLowerCase();
351
+ return extension.startsWith('.') ? extension.slice(1) : extension;
352
+ }
353
+
354
+ function normalizeAttachmentMimeType(mimeType = '') {
355
+ const value = String(mimeType || '').trim();
356
+ return value || 'application/octet-stream';
357
+ }
358
+
359
+ function getAttachmentKind(attachment = {}) {
360
+ const mimeType = normalizeAttachmentMimeType(attachment.mimeType);
361
+ if (mimeType.startsWith('image/')) {
362
+ return 'image';
363
+ }
364
+ if (
365
+ mimeType.startsWith('text/')
366
+ || mimeType.includes('json')
367
+ || mimeType.includes('xml')
368
+ || mimeType.includes('yaml')
369
+ || mimeType.includes('javascript')
370
+ || mimeType.includes('typescript')
371
+ ) {
372
+ return 'text';
373
+ }
374
+ const extension = getAttachmentExtension(attachment.name);
375
+ if (TEXT_ATTACHMENT_EXTENSIONS.has(extension)) {
376
+ return 'text';
377
+ }
378
+ return 'binary';
379
+ }
380
+
381
+ function normalizePromptAttachment(attachment = {}) {
382
+ const name = String(attachment.name || 'attachment').trim() || 'attachment';
383
+ const mimeType = normalizeAttachmentMimeType(attachment.mimeType);
384
+ const size = Number.isFinite(attachment.size) ? attachment.size : 0;
385
+ const tempPath = String(attachment.tempPath || '').trim();
386
+ return {
387
+ id: String(attachment.id || crypto.randomUUID()),
388
+ name,
389
+ mimeType,
390
+ size,
391
+ tempPath,
392
+ kind: getAttachmentKind({ name, mimeType })
393
+ };
394
+ }
395
+
396
+ function serializePromptAttachment(attachment = {}) {
397
+ const normalized = normalizePromptAttachment(attachment);
398
+ return {
399
+ id: normalized.id,
400
+ name: normalized.name,
401
+ mimeType: normalized.mimeType,
402
+ size: normalized.size,
403
+ kind: normalized.kind
404
+ };
405
+ }
406
+
407
+ function makeBuiltInDefinitions() {
408
+ const hasGeminiBinary = commandExists('gemini');
409
+ const hasCopilotBinary = commandExists('copilot');
410
+ const hasGhCopilot = hasGhCopilotWrapper();
411
+ const definitions = [
412
+ {
413
+ id: 'gemini',
414
+ label: 'Gemini CLI',
415
+ description: 'Google Gemini CLI over ACP',
416
+ websiteUrl: 'https://github.com/google-gemini/gemini-cli',
417
+ command: hasGeminiBinary ? 'gemini' : NPX_COMMAND,
418
+ args: hasGeminiBinary
419
+ ? ['--acp']
420
+ : ['@google/gemini-cli@latest', '--acp'],
421
+ commandLabel: hasGeminiBinary
422
+ ? 'gemini --acp'
423
+ : 'npx @google/gemini-cli@latest --acp'
424
+ },
425
+ {
426
+ id: 'codex',
427
+ label: 'Codex CLI',
428
+ description: 'Codex ACP adapter',
429
+ websiteUrl: 'https://openai.com/codex/',
430
+ command: NPX_COMMAND,
431
+ args: ['@zed-industries/codex-acp@latest'],
432
+ commandLabel: 'npx @zed-industries/codex-acp@latest'
433
+ },
434
+ {
435
+ id: 'claude',
436
+ label: 'Claude Agent',
437
+ description: 'Claude Code ACP adapter',
438
+ websiteUrl: 'https://www.anthropic.com/claude-code',
439
+ command: NPX_COMMAND,
440
+ args: ['@zed-industries/claude-code-acp@latest'],
441
+ commandLabel: 'npx @zed-industries/claude-code-acp@latest'
442
+ },
443
+ {
444
+ id: 'copilot',
445
+ label: 'GitHub Copilot',
446
+ description: 'GitHub Copilot CLI ACP server',
447
+ websiteUrl: 'https://docs.github.com/en/copilot/how-tos/copilot-cli',
448
+ command: hasCopilotBinary ? 'copilot' : 'gh',
449
+ args: hasCopilotBinary
450
+ ? ['--acp', '--stdio']
451
+ : ['copilot', '--', '--acp', '--stdio'],
452
+ commandLabel: hasCopilotBinary
453
+ ? 'copilot --acp --stdio'
454
+ : 'gh copilot -- --acp --stdio',
455
+ setupCommandLabel: hasGhCopilot
456
+ ? 'gh copilot'
457
+ : 'Install GitHub Copilot CLI'
458
+ }
459
+ ];
460
+ if (process.env.TABMINAL_ENABLE_TEST_AGENT === '1') {
461
+ definitions.unshift({
462
+ id: 'test-agent',
463
+ label: 'ACP Test Agent',
464
+ description: 'Local ACP smoke-test agent',
465
+ command: process.execPath,
466
+ args: [TEST_AGENT_PATH],
467
+ commandLabel: `${process.execPath} ${TEST_AGENT_PATH}`
468
+ });
469
+ }
470
+ return definitions;
471
+ }
472
+
473
+ function getDefinitionAvailability(
474
+ definition,
475
+ agentConfig = {},
476
+ probes = DEFAULT_AVAILABILITY_PROBES
477
+ ) {
478
+ const commandExistsFn = probes.commandExists || commandExists;
479
+ const runtimeEnv = mergeDefinitionEnv(definition, agentConfig);
480
+ if (!commandExistsFn(definition.command, runtimeEnv)) {
481
+ return {
482
+ available: false,
483
+ reason: 'not installed'
484
+ };
485
+ }
486
+
487
+ if (definition.id === 'gemini') {
488
+ const hasApiKey = Boolean(
489
+ runtimeEnv.GEMINI_API_KEY || runtimeEnv.GOOGLE_API_KEY
490
+ );
491
+ if (!hasApiKey) {
492
+ return {
493
+ available: false,
494
+ reason: 'API key missing'
495
+ };
496
+ }
497
+ }
498
+
499
+ if (definition.id === 'codex') {
500
+ const codexAvailability = (
501
+ probes.probeCodexAuth || probeCodexAuth
502
+ )(runtimeEnv);
503
+ if (!codexAvailability.available) {
504
+ return codexAvailability;
505
+ }
506
+ }
507
+
508
+ if (
509
+ definition.id === 'copilot'
510
+ && definition.command === 'gh'
511
+ ) {
512
+ const hasWrapper = (
513
+ probes.hasGhCopilotWrapper || hasGhCopilotWrapper
514
+ )();
515
+ if (!hasWrapper) {
516
+ return {
517
+ available: false,
518
+ reason: 'Install the gh-copilot extension first'
519
+ };
520
+ }
521
+ const hasCli = (
522
+ probes.hasGhCopilotCliInstalled || hasGhCopilotCliInstalled
523
+ )();
524
+ if (!hasCli) {
525
+ return {
526
+ available: false,
527
+ reason: 'Run gh copilot once to install Copilot CLI'
528
+ };
529
+ }
530
+ const ghAvailability = (probes.probeGhAuth || probeGhAuth)(runtimeEnv);
531
+ if (!ghAvailability.available) {
532
+ return ghAvailability;
533
+ }
534
+ return {
535
+ available: true,
536
+ reason: ''
537
+ };
538
+ }
539
+
540
+ return {
541
+ available: true,
542
+ reason: ''
543
+ };
544
+ }
545
+
546
+ function formatAgentStartupError(definition, error) {
547
+ const rawMessage = error?.message || 'Failed to start agent';
548
+ if (/ENOENT|not found/i.test(rawMessage)) {
549
+ const label = definition?.label || 'Agent';
550
+ const command = definition?.commandLabel || definition?.command || '';
551
+ return command
552
+ ? `${label} is not installed or not found on this host. `
553
+ + `Install \`${command}\` and retry.`
554
+ : `${label} is not installed or not found on this host.`;
555
+ }
556
+ if (
557
+ definition?.id === 'claude'
558
+ && /not servable in region|not available on your vertex deployment/i
559
+ .test(rawMessage)
560
+ ) {
561
+ return 'Claude Vertex is configured with a region that cannot serve '
562
+ + 'the current model. Use a supported region such as `global`, '
563
+ + '`us-east5`, or `europe-west1`, then retry.';
564
+ }
565
+ if (
566
+ definition?.id === 'codex'
567
+ && /authentication required/i.test(rawMessage)
568
+ ) {
569
+ return 'Codex is not authenticated on this host. Run `codex login` '
570
+ + 'for the user running Tabminal, or start Tabminal with a HOME '
571
+ + 'that already contains Codex auth.';
572
+ }
573
+ if (
574
+ definition?.id === 'claude'
575
+ && /auth|login|credential|api key|unauthorized/i.test(rawMessage)
576
+ ) {
577
+ return 'Claude is not authenticated on this host. Use an existing '
578
+ + 'Claude login, set ANTHROPIC_API_KEY, or configure Vertex '
579
+ + 'with CLAUDE_CODE_USE_VERTEX=1, ANTHROPIC_VERTEX_PROJECT_ID, '
580
+ + 'CLOUD_ML_REGION, and Google Cloud credentials before '
581
+ + 'starting Tabminal.';
582
+ }
583
+ if (definition?.id === 'copilot' && /not installed/i.test(rawMessage)) {
584
+ return 'GitHub Copilot CLI is not installed on this host yet. Run '
585
+ + '`gh copilot` once to download it, or install a standalone '
586
+ + '`copilot` binary and restart Tabminal.';
587
+ }
588
+ if (
589
+ definition?.id === 'copilot'
590
+ && /auth|login|token|unauthorized|forbidden/i.test(rawMessage)
591
+ ) {
592
+ return 'GitHub Copilot is not authenticated on this host. If this '
593
+ + 'backend can already see a `copilot login` or `gh auth` token '
594
+ + 'it may reuse them, but `COPILOT_GITHUB_TOKEN` is the reliable '
595
+ + 'headless fix in Tabminal setup.';
596
+ }
597
+ return rawMessage;
598
+ }
599
+
600
+ function makeRuntimeKey(agentId, cwd) {
601
+ return `${agentId}::${path.resolve(cwd)}`;
602
+ }
603
+
604
+ function makeRuntimeStoreKey(agentId, cwd, configVersion = 0) {
605
+ return `${makeRuntimeKey(agentId, cwd)}::cfg:${configVersion}`;
606
+ }
607
+
608
+ function normalizeEnvList(envList) {
609
+ if (!Array.isArray(envList)) return {};
610
+ const env = {};
611
+ for (const item of envList) {
612
+ if (!item || typeof item.name !== 'string') continue;
613
+ env[item.name] = typeof item.value === 'string' ? item.value : '';
614
+ }
615
+ return env;
616
+ }
617
+
618
+ function truncateUtf8(text, byteLimit) {
619
+ if (!text) return '';
620
+ const buffer = Buffer.from(text, 'utf8');
621
+ if (buffer.length <= byteLimit) return text;
622
+
623
+ let slice = buffer.subarray(buffer.length - byteLimit);
624
+ while (slice.length > 0) {
625
+ const decoded = slice.toString('utf8');
626
+ if (!decoded.includes('\uFFFD')) {
627
+ return decoded;
628
+ }
629
+ slice = slice.subarray(1);
630
+ }
631
+ return '';
632
+ }
633
+
634
+ export function buildTerminalSpawnRequest(request = {}) {
635
+ const command = String(request.command || '').trim();
636
+ const args = Array.isArray(request.args)
637
+ ? request.args.filter((value) => typeof value === 'string')
638
+ : [];
639
+ if (!command) {
640
+ throw new Error('Terminal command is required');
641
+ }
642
+ if (args.length > 0) {
643
+ return {
644
+ command,
645
+ args,
646
+ shell: false
647
+ };
648
+ }
649
+
650
+ const requiresShell = /[\s|&;<>()$`*?[\]{}~]/.test(command);
651
+ if (!requiresShell) {
652
+ return {
653
+ command,
654
+ args: [],
655
+ shell: false
656
+ };
657
+ }
658
+
659
+ const shell = process.platform === 'win32'
660
+ ? process.env.ComSpec || 'cmd.exe'
661
+ : process.env.SHELL || '/bin/sh';
662
+ const shellArgs = process.platform === 'win32'
663
+ ? ['/d', '/s', '/c', command]
664
+ : ['-lc', command];
665
+
666
+ return {
667
+ command: shell,
668
+ args: shellArgs,
669
+ shell: true
670
+ };
671
+ }
672
+
673
+ export function mergeAgentMessageText(previousText, chunkText) {
674
+ const previous = String(previousText || '');
675
+ const chunk = String(chunkText || '');
676
+ if (!previous) return chunk;
677
+ if (!chunk) return previous;
678
+ if (/\s$/.test(previous) || /^\s/.test(chunk)) {
679
+ return `${previous}${chunk}`;
680
+ }
681
+
682
+ const previousLast = previous.slice(-1);
683
+ const chunkFirst = chunk[0] || '';
684
+ if (
685
+ /[.!?`'")\]]/.test(previousLast)
686
+ && /[A-Z`"'[(]/.test(chunkFirst)
687
+ ) {
688
+ return `${previous}\n\n${chunk}`;
689
+ }
690
+
691
+ return `${previous}${chunk}`;
692
+ }
693
+
694
+ function formatTerminalDisplayCommand(request = {}, spawnRequest = {}) {
695
+ const explicitArgs = Array.isArray(request.args)
696
+ ? request.args.filter((value) => typeof value === 'string')
697
+ : [];
698
+ if (explicitArgs.length > 0) {
699
+ return [request.command, ...explicitArgs].join(' ').trim();
700
+ }
701
+ if (typeof request.command === 'string' && request.command.trim()) {
702
+ return request.command.trim();
703
+ }
704
+ return [spawnRequest.command, ...(spawnRequest.args || [])]
705
+ .filter(Boolean)
706
+ .join(' ')
707
+ .trim();
708
+ }
709
+
710
+ function normalizePlanEntries(entries = []) {
711
+ if (!Array.isArray(entries)) return [];
712
+ return entries
713
+ .filter((entry) => entry && typeof entry === 'object')
714
+ .map((entry) => ({
715
+ content: typeof entry.content === 'string' ? entry.content : '',
716
+ priority: typeof entry.priority === 'string'
717
+ ? entry.priority
718
+ : 'medium',
719
+ status: typeof entry.status === 'string'
720
+ ? entry.status
721
+ : 'pending'
722
+ }))
723
+ .filter((entry) => entry.content);
724
+ }
725
+
726
+ function normalizeUsageWindow(item = {}) {
727
+ if (!item || typeof item !== 'object') return null;
728
+ const label = String(
729
+ item.label
730
+ || item.name
731
+ || item.window
732
+ || item.bucket
733
+ || ''
734
+ ).trim();
735
+ const used = Number.isFinite(item.used)
736
+ ? item.used
737
+ : Number.isFinite(item.consumed)
738
+ ? item.consumed
739
+ : Number.isFinite(item.spent)
740
+ ? item.spent
741
+ : null;
742
+ const size = Number.isFinite(item.size)
743
+ ? item.size
744
+ : Number.isFinite(item.limit)
745
+ ? item.limit
746
+ : Number.isFinite(item.max)
747
+ ? item.max
748
+ : Number.isFinite(item.total)
749
+ ? item.total
750
+ : null;
751
+ const remaining = Number.isFinite(item.remaining)
752
+ ? item.remaining
753
+ : Number.isFinite(size) && Number.isFinite(used)
754
+ ? Math.max(size - used, 0)
755
+ : null;
756
+ const resetAt = [
757
+ item.resetAt,
758
+ item.resetsAt,
759
+ item.nextResetAt,
760
+ item.resetTime,
761
+ item.resetDate
762
+ ].find((value) => typeof value === 'string' && value.trim()) || '';
763
+ const resetDisplay = String(
764
+ item.resetDisplay
765
+ || item.resetLabel
766
+ || item.resetText
767
+ || ''
768
+ ).trim();
769
+ const subtitle = String(item.subtitle || item.description || '').trim();
770
+ if (!label && !resetAt && !resetDisplay && !subtitle) {
771
+ return null;
772
+ }
773
+ return {
774
+ label: label || 'Window',
775
+ used,
776
+ size,
777
+ remaining,
778
+ resetAt,
779
+ resetDisplay,
780
+ subtitle
781
+ };
782
+ }
783
+
784
+ function extractUsageResetHints(meta = {}) {
785
+ if (!meta || typeof meta !== 'object') {
786
+ return {
787
+ resetAt: '',
788
+ windows: [],
789
+ vendorLabel: '',
790
+ sessionId: '',
791
+ summary: ''
792
+ };
793
+ }
794
+ const rawWindows = Array.isArray(meta.windows)
795
+ ? meta.windows
796
+ : Array.isArray(meta.limits)
797
+ ? meta.limits
798
+ : Array.isArray(meta.quotas)
799
+ ? meta.quotas
800
+ : [];
801
+ const windows = rawWindows
802
+ .map((item) => normalizeUsageWindow(item))
803
+ .filter(Boolean);
804
+ const resetCandidates = [
805
+ meta.resetAt,
806
+ meta.resetsAt,
807
+ meta.nextResetAt,
808
+ meta.resetTime,
809
+ meta.resetDate
810
+ ];
811
+ const resetAt = resetCandidates.find(
812
+ (value) => typeof value === 'string' && value.trim()
813
+ ) || '';
814
+ return {
815
+ resetAt,
816
+ windows,
817
+ vendorLabel: String(
818
+ meta.vendorLabel
819
+ || meta.provider
820
+ || meta.providerLabel
821
+ || meta.agent
822
+ || ''
823
+ ).trim(),
824
+ sessionId: String(
825
+ meta.sessionId
826
+ || meta.session
827
+ || meta.conversationId
828
+ || ''
829
+ ).trim(),
830
+ summary: String(
831
+ meta.summary
832
+ || meta.status
833
+ || meta.contextLabel
834
+ || ''
835
+ ).trim()
836
+ };
837
+ }
838
+
839
+ function mergeUsageState(previous = {}, update = {}) {
840
+ const meta = extractUsageResetHints(update?._meta);
841
+ const next = {
842
+ used: Number.isFinite(update?.used)
843
+ ? update.used
844
+ : Number.isFinite(previous?.used)
845
+ ? previous.used
846
+ : null,
847
+ size: Number.isFinite(update?.size)
848
+ ? update.size
849
+ : Number.isFinite(previous?.size)
850
+ ? previous.size
851
+ : null,
852
+ cost: update?.cost || previous?.cost || null,
853
+ totals: update?.totals || previous?.totals || null,
854
+ updatedAt: new Date().toISOString(),
855
+ resetAt: meta.resetAt || previous?.resetAt || '',
856
+ windows: meta.windows.length > 0 ? meta.windows : previous?.windows || [],
857
+ vendorLabel: meta.vendorLabel || previous?.vendorLabel || '',
858
+ sessionId: meta.sessionId || previous?.sessionId || '',
859
+ summary: meta.summary || previous?.summary || ''
860
+ };
861
+ return next;
862
+ }
863
+
864
+ function serializeUsageState(usage) {
865
+ if (!usage) return null;
866
+ return {
867
+ used: Number.isFinite(usage.used) ? usage.used : null,
868
+ size: Number.isFinite(usage.size) ? usage.size : null,
869
+ cost: usage.cost || null,
870
+ totals: usage.totals || null,
871
+ updatedAt: typeof usage.updatedAt === 'string' ? usage.updatedAt : '',
872
+ resetAt: typeof usage.resetAt === 'string' ? usage.resetAt : '',
873
+ windows: Array.isArray(usage.windows) ? usage.windows : [],
874
+ vendorLabel: typeof usage.vendorLabel === 'string'
875
+ ? usage.vendorLabel
876
+ : '',
877
+ sessionId: typeof usage.sessionId === 'string'
878
+ ? usage.sessionId
879
+ : '',
880
+ summary: typeof usage.summary === 'string'
881
+ ? usage.summary
882
+ : ''
883
+ };
884
+ }
885
+
886
+ function normalizeToolStatusClass(status = '') {
887
+ const value = String(status || 'pending').toLowerCase();
888
+ if (value.includes('ready')) {
889
+ return 'ready';
890
+ }
891
+ if (value.includes('restore')) {
892
+ return 'running';
893
+ }
894
+ if (value.includes('disconnect')) {
895
+ return 'error';
896
+ }
897
+ if (
898
+ value.includes('complete')
899
+ || value.includes('success')
900
+ || value.includes('select')
901
+ || value.includes('approve')
902
+ ) {
903
+ return 'completed';
904
+ }
905
+ if (value.includes('cancel')) {
906
+ return 'cancelled';
907
+ }
908
+ if (value.includes('error') || value.includes('fail')) {
909
+ return 'error';
910
+ }
911
+ if (value.includes('run') || value.includes('progress')) {
912
+ return 'running';
913
+ }
914
+ return 'pending';
915
+ }
916
+
917
+ function getToolCallTerminalIds(toolCall) {
918
+ if (!Array.isArray(toolCall?.content)) {
919
+ return [];
920
+ }
921
+ const ids = new Set();
922
+ for (const item of toolCall.content) {
923
+ const terminalId = String(item?.terminalId || '').trim();
924
+ if (item?.type === 'terminal' && terminalId) {
925
+ ids.add(terminalId);
926
+ }
927
+ }
928
+ return Array.from(ids);
929
+ }
930
+
931
+ function cloneSerializable(value, fallback) {
932
+ try {
933
+ const cloned = structuredClone(value);
934
+ return cloned === undefined ? fallback : cloned;
935
+ } catch {
936
+ return fallback;
937
+ }
938
+ }
939
+
940
+ function normalizePersistedTimelineOrder(value, fallback = 0) {
941
+ return Number.isFinite(value) && value > 0 ? value : fallback;
942
+ }
943
+
944
+ function normalizePersistedMessage(message = {}, fallbackOrder = 0) {
945
+ const nextMessage = cloneSerializable(message, {}) || {};
946
+ nextMessage.id = typeof nextMessage.id === 'string'
947
+ ? nextMessage.id
948
+ : crypto.randomUUID();
949
+ nextMessage.streamKey = typeof nextMessage.streamKey === 'string'
950
+ ? nextMessage.streamKey
951
+ : nextMessage.id;
952
+ nextMessage.role = typeof nextMessage.role === 'string'
953
+ ? nextMessage.role
954
+ : 'assistant';
955
+ nextMessage.kind = typeof nextMessage.kind === 'string'
956
+ ? nextMessage.kind
957
+ : 'message';
958
+ nextMessage.text = typeof nextMessage.text === 'string'
959
+ ? nextMessage.text
960
+ : '';
961
+ nextMessage.createdAt = typeof nextMessage.createdAt === 'string'
962
+ ? nextMessage.createdAt
963
+ : '';
964
+ nextMessage.order = normalizePersistedTimelineOrder(
965
+ nextMessage.order,
966
+ fallbackOrder
967
+ );
968
+ nextMessage.attachments = Array.isArray(nextMessage.attachments)
969
+ ? cloneSerializable(nextMessage.attachments, [])
970
+ : [];
971
+ return nextMessage;
972
+ }
973
+
974
+ function normalizePersistedTimelineEntry(entry = {}, fallbackOrder = 0) {
975
+ const nextEntry = cloneSerializable(entry, {}) || {};
976
+ nextEntry.createdAt = typeof nextEntry.createdAt === 'string'
977
+ ? nextEntry.createdAt
978
+ : '';
979
+ nextEntry.order = normalizePersistedTimelineOrder(
980
+ nextEntry.order,
981
+ fallbackOrder
982
+ );
983
+ return nextEntry;
984
+ }
985
+
986
+ function normalizePersistedTerminalSummary(summary = {}) {
987
+ const nextSummary = cloneSerializable(summary, {}) || {};
988
+ return {
989
+ terminalId: typeof nextSummary.terminalId === 'string'
990
+ ? nextSummary.terminalId
991
+ : '',
992
+ terminalSessionId: typeof nextSummary.terminalSessionId === 'string'
993
+ ? nextSummary.terminalSessionId
994
+ : '',
995
+ command: typeof nextSummary.command === 'string'
996
+ ? nextSummary.command
997
+ : '',
998
+ cwd: typeof nextSummary.cwd === 'string' ? nextSummary.cwd : '',
999
+ output: typeof nextSummary.output === 'string'
1000
+ ? nextSummary.output
1001
+ : '',
1002
+ createdAt: typeof nextSummary.createdAt === 'string'
1003
+ ? nextSummary.createdAt
1004
+ : '',
1005
+ updatedAt: typeof nextSummary.updatedAt === 'string'
1006
+ ? nextSummary.updatedAt
1007
+ : '',
1008
+ released: !!nextSummary.released,
1009
+ running: !!nextSummary.running,
1010
+ exitStatus: nextSummary.exitStatus
1011
+ && typeof nextSummary.exitStatus === 'object'
1012
+ ? {
1013
+ exitCode: Number.isFinite(nextSummary.exitStatus.exitCode)
1014
+ ? nextSummary.exitStatus.exitCode
1015
+ : null,
1016
+ signal: typeof nextSummary.exitStatus.signal === 'string'
1017
+ ? nextSummary.exitStatus.signal
1018
+ : null
1019
+ }
1020
+ : null
1021
+ };
1022
+ }
1023
+
1024
+ function restorePersistedTabSnapshot(tab, snapshot = {}) {
1025
+ const messages = Array.isArray(snapshot.messages)
1026
+ ? snapshot.messages.map((message, index) =>
1027
+ normalizePersistedMessage(message, index + 1)
1028
+ )
1029
+ : [];
1030
+ const toolCalls = Array.isArray(snapshot.toolCalls)
1031
+ ? snapshot.toolCalls.map((entry, index) =>
1032
+ normalizePersistedTimelineEntry(
1033
+ entry,
1034
+ messages.length + index + 1
1035
+ )
1036
+ )
1037
+ : [];
1038
+ const permissions = Array.isArray(snapshot.permissions)
1039
+ ? snapshot.permissions.map((entry, index) =>
1040
+ normalizePersistedTimelineEntry(
1041
+ entry,
1042
+ messages.length + toolCalls.length + index + 1
1043
+ )
1044
+ )
1045
+ : [];
1046
+ const terminals = Array.isArray(snapshot.terminals)
1047
+ ? snapshot.terminals.map((entry) =>
1048
+ normalizePersistedTerminalSummary(entry)
1049
+ ).filter((entry) => entry.terminalId)
1050
+ : [];
1051
+
1052
+ tab.messages = messages;
1053
+ tab.toolCalls = new Map(
1054
+ toolCalls
1055
+ .filter((entry) => typeof entry.toolCallId === 'string')
1056
+ .map((entry) => [entry.toolCallId, entry])
1057
+ );
1058
+ tab.permissions = new Map(
1059
+ permissions
1060
+ .filter((entry) => typeof entry.id === 'string')
1061
+ .map((entry) => [
1062
+ entry.id,
1063
+ {
1064
+ ...entry,
1065
+ resolve: null
1066
+ }
1067
+ ])
1068
+ );
1069
+ tab.plan = normalizePlanEntries(snapshot.plan);
1070
+ tab.usage = serializeUsageState(snapshot.usage)
1071
+ ? mergeUsageState(null, snapshot.usage)
1072
+ : null;
1073
+ tab.terminals = new Map(
1074
+ terminals.map((entry) => [entry.terminalId, entry])
1075
+ );
1076
+ tab.title = typeof snapshot.title === 'string'
1077
+ ? snapshot.title
1078
+ : tab.title;
1079
+ tab.currentModeId = typeof snapshot.currentModeId === 'string'
1080
+ ? snapshot.currentModeId
1081
+ : tab.currentModeId;
1082
+ tab.availableModes = Array.isArray(snapshot.availableModes)
1083
+ ? cloneSerializable(snapshot.availableModes, [])
1084
+ : tab.availableModes;
1085
+ tab.availableCommands = Array.isArray(snapshot.availableCommands)
1086
+ ? cloneSerializable(snapshot.availableCommands, [])
1087
+ : tab.availableCommands;
1088
+ tab.configOptions = Array.isArray(snapshot.configOptions)
1089
+ ? cloneSerializable(snapshot.configOptions, [])
1090
+ : tab.configOptions;
1091
+
1092
+ const maxMessageOrder = messages.reduce(
1093
+ (maxOrder, entry) => Math.max(maxOrder, entry.order || 0),
1094
+ 0
1095
+ );
1096
+ const maxToolOrder = toolCalls.reduce(
1097
+ (maxOrder, entry) => Math.max(maxOrder, entry.order || 0),
1098
+ 0
1099
+ );
1100
+ const maxPermissionOrder = permissions.reduce(
1101
+ (maxOrder, entry) => Math.max(maxOrder, entry.order || 0),
1102
+ 0
1103
+ );
1104
+ tab.timelineCounter = Math.max(
1105
+ tab.timelineCounter,
1106
+ maxMessageOrder,
1107
+ maxToolOrder,
1108
+ maxPermissionOrder
1109
+ );
1110
+ tab.messageCounter = Math.max(tab.messageCounter, messages.length);
1111
+ }
1112
+
1113
+ class LocalExecTerminal extends EventEmitter {
1114
+ constructor(request) {
1115
+ super();
1116
+ this.id = crypto.randomUUID();
1117
+ this.sessionId = String(request.sessionId || '');
1118
+ this.cwd = request.cwd || process.cwd();
1119
+ this.output = '';
1120
+ this.outputByteLimit = Math.max(
1121
+ 1024,
1122
+ request.outputByteLimit || DEFAULT_TERMINAL_OUTPUT_LIMIT
1123
+ );
1124
+ this.exitStatus = null;
1125
+ this.closed = false;
1126
+ this.waiters = [];
1127
+ this.createdAt = new Date().toISOString();
1128
+ this.updatedAt = this.createdAt;
1129
+
1130
+ const env = {
1131
+ ...process.env,
1132
+ ...normalizeEnvList(request.env)
1133
+ };
1134
+
1135
+ const spawnRequest = buildTerminalSpawnRequest(request);
1136
+ this.command = formatTerminalDisplayCommand(request, spawnRequest);
1137
+
1138
+ this.child = spawn(spawnRequest.command, spawnRequest.args, {
1139
+ cwd: request.cwd || process.cwd(),
1140
+ env,
1141
+ stdio: ['ignore', 'pipe', 'pipe']
1142
+ });
1143
+
1144
+ const append = (chunk) => {
1145
+ const text = Buffer.isBuffer(chunk)
1146
+ ? chunk.toString('utf8')
1147
+ : String(chunk);
1148
+ this.output = truncateUtf8(
1149
+ `${this.output}${text}`,
1150
+ this.outputByteLimit
1151
+ );
1152
+ this.updatedAt = new Date().toISOString();
1153
+ this.emit('update', this.currentSummary());
1154
+ };
1155
+
1156
+ this.child.stdout?.on('data', append);
1157
+ this.child.stderr?.on('data', append);
1158
+ this.child.on('error', (error) => {
1159
+ if (this.closed) return;
1160
+ append(error?.message || 'Terminal command failed.');
1161
+ this.closed = true;
1162
+ this.exitStatus = {
1163
+ exitCode: null,
1164
+ signal: null
1165
+ };
1166
+ this.updatedAt = new Date().toISOString();
1167
+ this.emit('update', this.currentSummary());
1168
+ for (const waiter of this.waiters) {
1169
+ waiter(this.exitStatus);
1170
+ }
1171
+ this.waiters.length = 0;
1172
+ });
1173
+ this.child.on('exit', (code, signal) => {
1174
+ this.closed = true;
1175
+ this.exitStatus = {
1176
+ exitCode: typeof code === 'number' ? code : null,
1177
+ signal: signal || null
1178
+ };
1179
+ this.updatedAt = new Date().toISOString();
1180
+ this.emit('update', this.currentSummary());
1181
+ for (const waiter of this.waiters) {
1182
+ waiter(this.exitStatus);
1183
+ }
1184
+ this.waiters.length = 0;
1185
+ });
1186
+ }
1187
+
1188
+ currentOutput() {
1189
+ return {
1190
+ output: this.output,
1191
+ exitStatus: this.exitStatus
1192
+ };
1193
+ }
1194
+
1195
+ currentSummary() {
1196
+ return {
1197
+ terminalId: this.id,
1198
+ sessionId: this.sessionId,
1199
+ command: this.command,
1200
+ cwd: this.cwd,
1201
+ output: this.output,
1202
+ exitStatus: this.exitStatus,
1203
+ createdAt: this.createdAt,
1204
+ updatedAt: this.updatedAt,
1205
+ running: !this.exitStatus
1206
+ };
1207
+ }
1208
+
1209
+ waitForExit() {
1210
+ if (this.exitStatus) {
1211
+ return Promise.resolve(this.exitStatus);
1212
+ }
1213
+ return new Promise((resolve) => {
1214
+ this.waiters.push(resolve);
1215
+ });
1216
+ }
1217
+
1218
+ kill() {
1219
+ if (!this.closed) {
1220
+ this.child.kill('SIGTERM');
1221
+ }
1222
+ return {};
1223
+ }
1224
+
1225
+ async release() {
1226
+ if (!this.closed) {
1227
+ this.child.kill('SIGTERM');
1228
+ await this.waitForExit().catch(() => {});
1229
+ }
1230
+ }
1231
+ }
1232
+
1233
+ class ManagedTerminalSession extends EventEmitter {
1234
+ constructor(request, terminalManager, agentMeta = {}) {
1235
+ super();
1236
+ this.id = crypto.randomUUID();
1237
+ this.sessionId = String(request.sessionId || '');
1238
+ this.cwd = request.cwd || process.cwd();
1239
+ this.outputByteLimit = Math.max(
1240
+ 1024,
1241
+ request.outputByteLimit || DEFAULT_TERMINAL_OUTPUT_LIMIT
1242
+ );
1243
+ const env = normalizeEnvList(request.env);
1244
+ this.spawnRequest = buildTerminalSpawnRequest(request);
1245
+ this.command = formatTerminalDisplayCommand(request, this.spawnRequest);
1246
+ this.released = false;
1247
+ this.managedBy = {
1248
+ kind: 'agent-terminal',
1249
+ agentId: String(agentMeta.agentId || '').trim(),
1250
+ agentLabel: String(agentMeta.agentLabel || 'Agent').trim(),
1251
+ acpSessionId: this.sessionId,
1252
+ terminalId: this.id
1253
+ };
1254
+ this.terminalSession = terminalManager.createManagedSession({
1255
+ cwd: this.cwd,
1256
+ env,
1257
+ spawnRequest: this.spawnRequest,
1258
+ title: path.basename(this.spawnRequest.command || '') || 'Terminal',
1259
+ managed: this.managedBy
1260
+ });
1261
+ this.terminalSessionId = this.terminalSession.id;
1262
+ this.unsubscribe = this.terminalSession.onStateChange(() => {
1263
+ this.emit('update', this.currentSummary());
1264
+ });
1265
+ }
1266
+
1267
+ currentOutput() {
1268
+ return {
1269
+ output: truncateUtf8(
1270
+ this.terminalSession.history || '',
1271
+ this.outputByteLimit
1272
+ ),
1273
+ exitStatus: this.terminalSession.exitStatus
1274
+ };
1275
+ }
1276
+
1277
+ currentSummary() {
1278
+ return {
1279
+ terminalId: this.id,
1280
+ sessionId: this.sessionId,
1281
+ terminalSessionId: this.terminalSessionId,
1282
+ command: this.command,
1283
+ cwd: this.terminalSession.cwd || this.cwd,
1284
+ output: truncateUtf8(
1285
+ this.terminalSession.history || '',
1286
+ this.outputByteLimit
1287
+ ),
1288
+ exitStatus: this.terminalSession.exitStatus,
1289
+ createdAt: this.terminalSession.createdAt instanceof Date
1290
+ ? this.terminalSession.createdAt.toISOString()
1291
+ : new Date(this.terminalSession.createdAt || Date.now())
1292
+ .toISOString(),
1293
+ updatedAt: this.terminalSession.updatedAt instanceof Date
1294
+ ? this.terminalSession.updatedAt.toISOString()
1295
+ : new Date(this.terminalSession.updatedAt || Date.now())
1296
+ .toISOString(),
1297
+ running: !this.terminalSession.exitStatus,
1298
+ released: this.released
1299
+ };
1300
+ }
1301
+
1302
+ waitForExit() {
1303
+ return this.terminalSession.waitForExit();
1304
+ }
1305
+
1306
+ kill() {
1307
+ if (!this.terminalSession.closed) {
1308
+ this.terminalSession.pty.kill('SIGTERM');
1309
+ }
1310
+ return {};
1311
+ }
1312
+
1313
+ async release({ destroy = true } = {}) {
1314
+ this.released = true;
1315
+ this.unsubscribe?.();
1316
+ this.unsubscribe = null;
1317
+ if (destroy) {
1318
+ await this.terminalSession.manager?.removeSession?.(
1319
+ this.terminalSession.id
1320
+ );
1321
+ this.terminalSessionId = '';
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ class AcpRuntime extends EventEmitter {
1327
+ constructor(definition, options = {}) {
1328
+ super();
1329
+ this.definition = definition;
1330
+ this.cwd = path.resolve(options.cwd || process.cwd());
1331
+ this.runtimeId = options.runtimeId || crypto.randomUUID();
1332
+ this.runtimeKey = makeRuntimeKey(definition.id, this.cwd);
1333
+ this.runtimeStoreKey = options.runtimeStoreKey || this.runtimeKey;
1334
+ this.env = options.env || process.env;
1335
+ this.terminalManager = options.terminalManager || null;
1336
+ this.idleTimeoutMs = options.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
1337
+ this.connection = null;
1338
+ this.process = null;
1339
+ this.started = false;
1340
+ this.startPromise = null;
1341
+ this.idleTimer = null;
1342
+ this.agentInfo = null;
1343
+ this.agentCapabilities = null;
1344
+ this.authMethods = [];
1345
+ this.tabs = new Map();
1346
+ this.sessionToTabId = new Map();
1347
+ this.terminals = new Map();
1348
+ this.cachedAvailableModes = [];
1349
+ this.cachedAvailableCommands = [];
1350
+ this.cachedConfigOptions = [];
1351
+ this.cachedModelState = null;
1352
+ }
1353
+
1354
+ #resolveAvailableModes(availableModes, existingModes = []) {
1355
+ if (Array.isArray(availableModes) && availableModes.length > 0) {
1356
+ this.cachedAvailableModes = availableModes;
1357
+ return availableModes;
1358
+ }
1359
+ if (Array.isArray(existingModes) && existingModes.length > 0) {
1360
+ return existingModes;
1361
+ }
1362
+ return this.cachedAvailableModes;
1363
+ }
1364
+
1365
+ #resolveAvailableCommands(availableCommands, existingCommands = []) {
1366
+ if (
1367
+ Array.isArray(availableCommands)
1368
+ && availableCommands.length > 0
1369
+ ) {
1370
+ this.cachedAvailableCommands = availableCommands;
1371
+ return availableCommands;
1372
+ }
1373
+ if (
1374
+ Array.isArray(existingCommands)
1375
+ && existingCommands.length > 0
1376
+ ) {
1377
+ return existingCommands;
1378
+ }
1379
+ return this.cachedAvailableCommands;
1380
+ }
1381
+
1382
+ #buildSyntheticModelConfigOption(modelState) {
1383
+ const availableModels = Array.isArray(modelState?.availableModels)
1384
+ ? modelState.availableModels
1385
+ : [];
1386
+ const currentModelId = typeof modelState?.currentModelId === 'string'
1387
+ ? modelState.currentModelId
1388
+ : '';
1389
+ if (!currentModelId || availableModels.length === 0) {
1390
+ return null;
1391
+ }
1392
+ return {
1393
+ id: '__tabminal_model__',
1394
+ name: 'Model',
1395
+ category: 'model',
1396
+ type: 'select',
1397
+ currentValue: currentModelId,
1398
+ options: availableModels.map((model) => ({
1399
+ value: model?.modelId || model?.id || '',
1400
+ name: model?.name || model?.modelId || model?.id || '',
1401
+ description: model?.description || ''
1402
+ })).filter((option) => option.value && option.name)
1403
+ };
1404
+ }
1405
+
1406
+ #resolveConfigOptions(
1407
+ configOptions,
1408
+ existingConfigOptions = [],
1409
+ modelState = null
1410
+ ) {
1411
+ let nextOptions = Array.isArray(configOptions) && configOptions.length > 0
1412
+ ? configOptions
1413
+ : Array.isArray(existingConfigOptions)
1414
+ && existingConfigOptions.length > 0
1415
+ ? existingConfigOptions
1416
+ : this.cachedConfigOptions;
1417
+
1418
+ if (modelState?.currentModelId && Array.isArray(modelState.availableModels)) {
1419
+ this.cachedModelState = modelState;
1420
+ }
1421
+ const syntheticModel = this.#buildSyntheticModelConfigOption(
1422
+ modelState || this.cachedModelState
1423
+ );
1424
+ if (syntheticModel) {
1425
+ const hasModelOption = Array.isArray(nextOptions) && nextOptions.some(
1426
+ (option) => option?.category === 'model'
1427
+ );
1428
+ if (!hasModelOption) {
1429
+ nextOptions = [
1430
+ ...nextOptions.filter(
1431
+ (option) => option?.id !== syntheticModel.id
1432
+ ),
1433
+ syntheticModel
1434
+ ];
1435
+ }
1436
+ }
1437
+ if (Array.isArray(nextOptions) && nextOptions.length > 0) {
1438
+ this.cachedConfigOptions = nextOptions;
1439
+ }
1440
+ return nextOptions;
1441
+ }
1442
+
1443
+ #buildTab({
1444
+ id,
1445
+ acpSessionId,
1446
+ terminalSessionId,
1447
+ cwd,
1448
+ createdAt,
1449
+ title = '',
1450
+ currentModeId = '',
1451
+ availableModes = [],
1452
+ availableCommands = [],
1453
+ configOptions = [],
1454
+ messages = [],
1455
+ toolCalls = [],
1456
+ permissions = [],
1457
+ plan = [],
1458
+ usage = null,
1459
+ terminals = []
1460
+ }) {
1461
+ const tab = {
1462
+ id,
1463
+ runtimeId: this.runtimeId,
1464
+ runtimeKey: this.runtimeKey,
1465
+ agentId: this.definition.id,
1466
+ agentLabel: this.definition.label,
1467
+ commandLabel: this.definition.commandLabel,
1468
+ terminalSessionId: terminalSessionId || '',
1469
+ cwd,
1470
+ acpSessionId,
1471
+ createdAt: createdAt || new Date().toISOString(),
1472
+ title: typeof title === 'string' ? title : '',
1473
+ status: 'ready',
1474
+ busy: false,
1475
+ errorMessage: '',
1476
+ messages: [],
1477
+ toolCalls: new Map(),
1478
+ permissions: new Map(),
1479
+ syntheticStreams: new Map(),
1480
+ syntheticStreamTurn: 0,
1481
+ pendingUserEcho: null,
1482
+ currentModeId,
1483
+ availableModes,
1484
+ availableCommands,
1485
+ configOptions,
1486
+ plan: [],
1487
+ usage: null,
1488
+ terminals: new Map(),
1489
+ clients: new Set(),
1490
+ messageCounter: 0,
1491
+ timelineCounter: 0
1492
+ };
1493
+ restorePersistedTabSnapshot(tab, {
1494
+ title,
1495
+ currentModeId,
1496
+ availableModes,
1497
+ availableCommands,
1498
+ configOptions,
1499
+ messages,
1500
+ toolCalls,
1501
+ permissions,
1502
+ plan,
1503
+ usage,
1504
+ terminals
1505
+ });
1506
+ return tab;
1507
+ }
1508
+
1509
+ async start() {
1510
+ if (this.started) return;
1511
+ if (this.startPromise) return this.startPromise;
1512
+
1513
+ this.startPromise = this.#startInternal();
1514
+ try {
1515
+ await this.startPromise;
1516
+ } finally {
1517
+ this.startPromise = null;
1518
+ }
1519
+ }
1520
+
1521
+ async #startInternal() {
1522
+ const child = spawn(this.definition.command, this.definition.args, {
1523
+ cwd: this.cwd,
1524
+ env: this.env,
1525
+ stdio: ['pipe', 'pipe', 'pipe']
1526
+ });
1527
+
1528
+ this.process = child;
1529
+ let startupSettled = false;
1530
+ let rejectStartup = null;
1531
+ const startupError = new Promise((_, reject) => {
1532
+ rejectStartup = reject;
1533
+ });
1534
+ const spawned = new Promise((resolve) => {
1535
+ child.once('spawn', resolve);
1536
+ });
1537
+ child.stderr?.on('data', (chunk) => {
1538
+ const text = Buffer.isBuffer(chunk)
1539
+ ? chunk.toString('utf8')
1540
+ : String(chunk);
1541
+ this.emit('runtime_log', {
1542
+ runtimeId: this.runtimeId,
1543
+ level: 'warn',
1544
+ message: text.trim()
1545
+ });
1546
+ });
1547
+ child.on('error', (error) => {
1548
+ if (!startupSettled) {
1549
+ startupSettled = true;
1550
+ rejectStartup?.(error);
1551
+ return;
1552
+ }
1553
+ const detail = {
1554
+ runtimeId: this.runtimeId,
1555
+ code: null,
1556
+ signal: null,
1557
+ error: error?.message || String(error)
1558
+ };
1559
+ for (const tab of this.tabs.values()) {
1560
+ tab.status = 'disconnected';
1561
+ tab.busy = false;
1562
+ tab.errorMessage = formatAgentStartupError(
1563
+ this.definition,
1564
+ error
1565
+ );
1566
+ }
1567
+ this.emit('runtime_exit', detail);
1568
+ });
1569
+ child.on('exit', (code, signal) => {
1570
+ if (!startupSettled) {
1571
+ startupSettled = true;
1572
+ rejectStartup?.(
1573
+ new Error(
1574
+ signal
1575
+ ? `Agent runtime exited (${signal}) before `
1576
+ + 'initialization completed.'
1577
+ : `Agent runtime exited (${code ?? 'unknown'}) `
1578
+ + 'before initialization completed.'
1579
+ )
1580
+ );
1581
+ }
1582
+ startupSettled = true;
1583
+ const detail = {
1584
+ runtimeId: this.runtimeId,
1585
+ code: typeof code === 'number' ? code : null,
1586
+ signal: signal || null
1587
+ };
1588
+ for (const tab of this.tabs.values()) {
1589
+ tab.status = 'disconnected';
1590
+ tab.busy = false;
1591
+ tab.errorMessage = detail.signal
1592
+ ? `Agent runtime exited (${detail.signal}).`
1593
+ : `Agent runtime exited (${detail.code ?? 'unknown'}).`;
1594
+ }
1595
+ this.emit('runtime_exit', detail);
1596
+ });
1597
+
1598
+ await Promise.race([
1599
+ spawned,
1600
+ startupError
1601
+ ]);
1602
+
1603
+ const input = Writable.toWeb(child.stdin);
1604
+ const output = Readable.toWeb(child.stdout);
1605
+ const stream = acp.ndJsonStream(input, output);
1606
+ this.connection = new acp.ClientSideConnection(
1607
+ () => ({
1608
+ sessionUpdate: (params) => this.#handleSessionUpdate(params),
1609
+ requestPermission: (params) => this.#requestPermission(params),
1610
+ readTextFile: (params) => this.#readTextFile(params),
1611
+ writeTextFile: (params) => this.#writeTextFile(params),
1612
+ createTerminal: (params) => this.#createTerminal(params),
1613
+ terminalOutput: (params) => this.#terminalOutput(params),
1614
+ releaseTerminal: (params) => this.#releaseTerminal(params),
1615
+ waitForTerminalExit: (params) =>
1616
+ this.#waitForTerminalExit(params),
1617
+ killTerminal: (params) => this.#killTerminal(params)
1618
+ }),
1619
+ stream
1620
+ );
1621
+
1622
+ const result = await Promise.race([
1623
+ this.connection.initialize({
1624
+ protocolVersion: acp.PROTOCOL_VERSION,
1625
+ clientInfo: {
1626
+ name: 'Tabminal',
1627
+ version: pkg.version
1628
+ },
1629
+ clientCapabilities: {
1630
+ fs: {
1631
+ readTextFile: true,
1632
+ writeTextFile: true
1633
+ },
1634
+ terminal: true
1635
+ }
1636
+ }),
1637
+ startupError
1638
+ ]);
1639
+ startupSettled = true;
1640
+
1641
+ this.agentInfo = result.agentInfo || null;
1642
+ this.agentCapabilities = result.agentCapabilities || null;
1643
+ this.authMethods = result.authMethods || [];
1644
+ this.started = true;
1645
+ }
1646
+
1647
+ scheduleIdleShutdown(onIdle) {
1648
+ if (this.tabs.size > 0) return;
1649
+ clearTimeout(this.idleTimer);
1650
+ this.idleTimer = setTimeout(() => {
1651
+ this.idleTimer = null;
1652
+ void onIdle();
1653
+ }, this.idleTimeoutMs);
1654
+ }
1655
+
1656
+ clearIdleShutdown() {
1657
+ if (this.idleTimer) {
1658
+ clearTimeout(this.idleTimer);
1659
+ this.idleTimer = null;
1660
+ }
1661
+ }
1662
+
1663
+ async createTab(meta) {
1664
+ await this.start();
1665
+ this.clearIdleShutdown();
1666
+ const response = await this.connection.newSession({
1667
+ cwd: meta.cwd,
1668
+ mcpServers: []
1669
+ });
1670
+ const availableModes = this.#resolveAvailableModes(
1671
+ response.modes?.availableModes
1672
+ );
1673
+ const availableCommands = this.#resolveAvailableCommands(
1674
+ response.availableCommands
1675
+ );
1676
+ const configOptions = this.#resolveConfigOptions(
1677
+ response.configOptions,
1678
+ [],
1679
+ response.models
1680
+ );
1681
+ const tab = this.#buildTab({
1682
+ id: meta.id,
1683
+ acpSessionId: response.sessionId,
1684
+ terminalSessionId: meta.terminalSessionId,
1685
+ cwd: meta.cwd,
1686
+ title: response.title || '',
1687
+ currentModeId: response.modes?.currentModeId || '',
1688
+ availableModes,
1689
+ availableCommands,
1690
+ configOptions
1691
+ });
1692
+ if (meta.modeId && typeof this.connection.setSessionMode === 'function') {
1693
+ try {
1694
+ const modeResponse = await this.connection.setSessionMode({
1695
+ sessionId: tab.acpSessionId,
1696
+ modeId: meta.modeId
1697
+ });
1698
+ tab.currentModeId = modeResponse?.currentModeId
1699
+ || modeResponse?.modeId
1700
+ || meta.modeId;
1701
+ if (Array.isArray(modeResponse?.availableModes)) {
1702
+ tab.availableModes = modeResponse.availableModes;
1703
+ }
1704
+ } catch {
1705
+ // Ignore unsupported mode changes during initial tab creation.
1706
+ }
1707
+ }
1708
+ await this.#hydrateFreshSessionMetadata(tab);
1709
+ this.tabs.set(tab.id, tab);
1710
+ this.sessionToTabId.set(tab.acpSessionId, tab.id);
1711
+ return this.serializeTab(tab);
1712
+ }
1713
+
1714
+ async restoreTab(meta) {
1715
+ await this.start();
1716
+ this.clearIdleShutdown();
1717
+
1718
+ if (
1719
+ !this.agentCapabilities?.loadSession
1720
+ || typeof this.connection.loadSession !== 'function'
1721
+ ) {
1722
+ throw new Error(
1723
+ `${this.definition.label} does not support session restore`
1724
+ );
1725
+ }
1726
+
1727
+ const tab = this.#buildTab({
1728
+ id: meta.id,
1729
+ acpSessionId: meta.acpSessionId,
1730
+ terminalSessionId: meta.terminalSessionId,
1731
+ cwd: meta.cwd,
1732
+ createdAt: meta.createdAt,
1733
+ title: meta.title || '',
1734
+ currentModeId: meta.currentModeId || '',
1735
+ availableModes: meta.availableModes || [],
1736
+ availableCommands: meta.availableCommands || [],
1737
+ configOptions: meta.configOptions || [],
1738
+ messages: meta.messages || [],
1739
+ toolCalls: meta.toolCalls || [],
1740
+ permissions: meta.permissions || [],
1741
+ plan: meta.plan || [],
1742
+ usage: meta.usage || null,
1743
+ terminals: meta.terminals || []
1744
+ });
1745
+ tab.status = 'restoring';
1746
+ tab.busy = true;
1747
+
1748
+ this.tabs.set(tab.id, tab);
1749
+ this.sessionToTabId.set(tab.acpSessionId, tab.id);
1750
+
1751
+ try {
1752
+ const response = await this.connection.loadSession({
1753
+ cwd: meta.cwd,
1754
+ sessionId: meta.acpSessionId,
1755
+ mcpServers: []
1756
+ });
1757
+ const restoredSessionId = response?.sessionId || meta.acpSessionId;
1758
+ if (restoredSessionId !== tab.acpSessionId) {
1759
+ this.sessionToTabId.delete(tab.acpSessionId);
1760
+ tab.acpSessionId = restoredSessionId;
1761
+ this.sessionToTabId.set(tab.acpSessionId, tab.id);
1762
+ }
1763
+ if (typeof response?.title === 'string') {
1764
+ tab.title = response.title;
1765
+ }
1766
+ tab.currentModeId = response?.modes?.currentModeId || '';
1767
+ tab.availableModes = this.#resolveAvailableModes(
1768
+ response?.modes?.availableModes,
1769
+ tab.availableModes
1770
+ );
1771
+ tab.availableCommands = this.#resolveAvailableCommands(
1772
+ response?.availableCommands,
1773
+ tab.availableCommands
1774
+ );
1775
+ tab.configOptions = this.#resolveConfigOptions(
1776
+ response?.configOptions,
1777
+ tab.configOptions,
1778
+ response?.models
1779
+ );
1780
+ tab.status = 'ready';
1781
+ tab.busy = false;
1782
+ tab.errorMessage = '';
1783
+ return this.serializeTab(tab);
1784
+ } catch (error) {
1785
+ this.tabs.delete(tab.id);
1786
+ this.sessionToTabId.delete(tab.acpSessionId);
1787
+ throw error;
1788
+ }
1789
+ }
1790
+
1791
+ #markTabDirty(tab) {
1792
+ if (!tab) return;
1793
+ this.emit('tab_dirty', {
1794
+ tabId: tab.id,
1795
+ sessionId: tab.acpSessionId
1796
+ });
1797
+ }
1798
+
1799
+ serializeTab(tab) {
1800
+ return {
1801
+ id: tab.id,
1802
+ runtimeId: tab.runtimeId,
1803
+ runtimeKey: tab.runtimeKey,
1804
+ acpSessionId: tab.acpSessionId,
1805
+ agentId: tab.agentId,
1806
+ agentLabel: tab.agentLabel,
1807
+ commandLabel: tab.commandLabel,
1808
+ title: tab.title || '',
1809
+ terminalSessionId: tab.terminalSessionId,
1810
+ cwd: tab.cwd,
1811
+ createdAt: tab.createdAt,
1812
+ status: tab.status,
1813
+ busy: tab.busy,
1814
+ errorMessage: tab.errorMessage,
1815
+ currentModeId: tab.currentModeId,
1816
+ availableModes: tab.availableModes,
1817
+ availableCommands: tab.availableCommands,
1818
+ configOptions: tab.configOptions,
1819
+ messages: tab.messages,
1820
+ toolCalls: Array.from(tab.toolCalls.values()),
1821
+ permissions: Array.from(tab.permissions.values()).map((item) => ({
1822
+ id: item.id,
1823
+ sessionId: item.sessionId,
1824
+ toolCall: item.toolCall,
1825
+ options: item.options,
1826
+ status: item.status,
1827
+ createdAt: item.createdAt || '',
1828
+ order: item.order,
1829
+ selectedOptionId: item.selectedOptionId || ''
1830
+ })),
1831
+ plan: Array.isArray(tab.plan) ? tab.plan : [],
1832
+ usage: serializeUsageState(tab.usage),
1833
+ terminals: Array.from(tab.terminals.values())
1834
+ };
1835
+ }
1836
+
1837
+ #getPromptCapabilities() {
1838
+ return this.agentCapabilities?.promptCapabilities || {};
1839
+ }
1840
+
1841
+ async #buildAttachmentPromptBlock(attachment) {
1842
+ const normalized = normalizePromptAttachment(attachment);
1843
+ const fileUri = pathToFileURL(normalized.tempPath).toString();
1844
+ const capabilities = this.#getPromptCapabilities();
1845
+
1846
+ if (normalized.kind === 'image' && capabilities.image) {
1847
+ const data = await fs.readFile(normalized.tempPath);
1848
+ return {
1849
+ type: 'image',
1850
+ data: data.toString('base64'),
1851
+ mimeType: normalized.mimeType,
1852
+ uri: fileUri
1853
+ };
1854
+ }
1855
+
1856
+ if (capabilities.embeddedContext) {
1857
+ if (normalized.kind === 'text') {
1858
+ const text = await fs.readFile(normalized.tempPath, 'utf8');
1859
+ return {
1860
+ type: 'resource',
1861
+ resource: {
1862
+ text,
1863
+ uri: fileUri,
1864
+ mimeType: normalized.mimeType
1865
+ }
1866
+ };
1867
+ }
1868
+
1869
+ const data = await fs.readFile(normalized.tempPath);
1870
+ return {
1871
+ type: 'resource',
1872
+ resource: {
1873
+ blob: data.toString('base64'),
1874
+ uri: fileUri,
1875
+ mimeType: normalized.mimeType
1876
+ }
1877
+ };
1878
+ }
1879
+
1880
+ return {
1881
+ type: 'resource_link',
1882
+ name: normalized.name,
1883
+ title: normalized.name,
1884
+ uri: fileUri,
1885
+ mimeType: normalized.mimeType,
1886
+ size: normalized.size || null
1887
+ };
1888
+ }
1889
+
1890
+ async #buildPromptBlocks(text, attachments = []) {
1891
+ const blocks = [];
1892
+ for (const attachment of attachments) {
1893
+ blocks.push(await this.#buildAttachmentPromptBlock(attachment));
1894
+ }
1895
+ if (text) {
1896
+ blocks.push({
1897
+ type: 'text',
1898
+ text
1899
+ });
1900
+ }
1901
+ return blocks;
1902
+ }
1903
+
1904
+ async #cleanupPromptAttachments(attachments = []) {
1905
+ const paths = attachments
1906
+ .map((attachment) => String(attachment?.tempPath || '').trim())
1907
+ .filter(Boolean);
1908
+ await Promise.allSettled(paths.map((filePath) =>
1909
+ fs.rm(filePath, { force: true })
1910
+ ));
1911
+ }
1912
+
1913
+ attachSocket(tabId, socket) {
1914
+ const tab = this.tabs.get(tabId);
1915
+ if (!tab) {
1916
+ socket.close();
1917
+ return false;
1918
+ }
1919
+ tab.clients.add(socket);
1920
+ socket.send(JSON.stringify({
1921
+ type: 'snapshot',
1922
+ tab: this.serializeTab(tab)
1923
+ }));
1924
+ socket.on('close', () => {
1925
+ tab.clients.delete(socket);
1926
+ });
1927
+ return true;
1928
+ }
1929
+
1930
+ async sendPrompt(tabId, text, attachments = []) {
1931
+ const tab = this.tabs.get(tabId);
1932
+ if (!tab) {
1933
+ throw new Error('Agent tab not found');
1934
+ }
1935
+ if (tab.busy) {
1936
+ throw new Error('Agent tab is already running');
1937
+ }
1938
+ const promptText = typeof text === 'string' ? text : '';
1939
+ const promptAttachments = Array.isArray(attachments)
1940
+ ? attachments.map((attachment) => normalizePromptAttachment(attachment))
1941
+ : [];
1942
+ const promptBlocks = await this.#buildPromptBlocks(
1943
+ promptText,
1944
+ promptAttachments
1945
+ );
1946
+ tab.errorMessage = '';
1947
+ tab.busy = true;
1948
+ tab.status = 'running';
1949
+ this.#advanceSyntheticStreamTurn(tab);
1950
+ this.#appendMessage(tab, {
1951
+ role: 'user',
1952
+ kind: 'message',
1953
+ text: promptText,
1954
+ streamKey: crypto.randomUUID(),
1955
+ attachments: promptAttachments.map((attachment) =>
1956
+ serializePromptAttachment(attachment)
1957
+ )
1958
+ });
1959
+ tab.pendingUserEcho = promptText
1960
+ ? {
1961
+ text: promptText,
1962
+ matched: 0
1963
+ }
1964
+ : null;
1965
+ this.#broadcast(tab, {
1966
+ type: 'status',
1967
+ status: tab.status,
1968
+ busy: tab.busy,
1969
+ errorMessage: ''
1970
+ });
1971
+ this.#markTabDirty(tab);
1972
+
1973
+ const promptPromise = this.connection.prompt({
1974
+ sessionId: tab.acpSessionId,
1975
+ prompt: promptBlocks
1976
+ });
1977
+
1978
+ void promptPromise.then(async (response) => {
1979
+ if (!this.tabs.has(tabId)) return;
1980
+ tab.busy = false;
1981
+ tab.status = 'ready';
1982
+ this.#settleStaleToolCalls(
1983
+ tab,
1984
+ response?.stopReason === 'cancelled'
1985
+ ? 'cancelled'
1986
+ : 'completed'
1987
+ );
1988
+ if (response?.usage) {
1989
+ tab.usage = mergeUsageState(tab.usage, {
1990
+ totals: response.usage
1991
+ });
1992
+ this.#broadcast(tab, {
1993
+ type: 'usage_state',
1994
+ usage: serializeUsageState(tab.usage)
1995
+ });
1996
+ }
1997
+ tab.syntheticStreams.clear();
1998
+ tab.pendingUserEcho = null;
1999
+ const hydratedChanges = await this.#hydrateFreshSessionMetadata(tab);
2000
+ this.#broadcastHydratedSessionMetadata(tab, hydratedChanges);
2001
+ this.#broadcast(tab, {
2002
+ type: 'complete',
2003
+ stopReason: response.stopReason,
2004
+ status: tab.status,
2005
+ busy: false
2006
+ });
2007
+ this.#markTabDirty(tab);
2008
+ }).catch((error) => {
2009
+ if (!this.tabs.has(tabId)) return;
2010
+ tab.busy = false;
2011
+ tab.status = 'error';
2012
+ this.#settleStaleToolCalls(tab, 'error');
2013
+ tab.errorMessage = formatAgentStartupError(
2014
+ tab.definition,
2015
+ error
2016
+ );
2017
+ tab.syntheticStreams.clear();
2018
+ tab.pendingUserEcho = null;
2019
+ this.#broadcast(tab, {
2020
+ type: 'status',
2021
+ status: tab.status,
2022
+ busy: false,
2023
+ errorMessage: tab.errorMessage
2024
+ });
2025
+ this.#markTabDirty(tab);
2026
+ }).finally(() => {
2027
+ void this.#cleanupPromptAttachments(promptAttachments);
2028
+ });
2029
+ }
2030
+
2031
+ async cancel(tabId) {
2032
+ const tab = this.tabs.get(tabId);
2033
+ if (!tab) {
2034
+ throw new Error('Agent tab not found');
2035
+ }
2036
+ if (!tab.busy) return;
2037
+
2038
+ for (const permission of tab.permissions.values()) {
2039
+ if (permission.status !== 'pending' || !permission.resolve) {
2040
+ continue;
2041
+ }
2042
+ permission.status = 'cancelled';
2043
+ permission.resolve({
2044
+ outcome: {
2045
+ outcome: 'cancelled'
2046
+ }
2047
+ });
2048
+ }
2049
+ await this.connection.cancel({
2050
+ sessionId: tab.acpSessionId
2051
+ });
2052
+ this.#markTabDirty(tab);
2053
+ }
2054
+
2055
+ async resolvePermission(tabId, permissionId, optionId) {
2056
+ const tab = this.tabs.get(tabId);
2057
+ if (!tab) {
2058
+ throw new Error('Agent tab not found');
2059
+ }
2060
+ const permission = tab.permissions.get(permissionId);
2061
+ if (!permission) {
2062
+ throw new Error('Permission request not found');
2063
+ }
2064
+ permission.status = optionId ? 'selected' : 'cancelled';
2065
+ permission.selectedOptionId = optionId || '';
2066
+ if (permission.resolve) {
2067
+ permission.resolve({
2068
+ outcome: optionId
2069
+ ? { outcome: 'selected', optionId }
2070
+ : { outcome: 'cancelled' }
2071
+ });
2072
+ }
2073
+ permission.resolve = null;
2074
+ this.#broadcast(tab, {
2075
+ type: 'permission_resolved',
2076
+ permissionId,
2077
+ status: permission.status,
2078
+ selectedOptionId: permission.selectedOptionId
2079
+ });
2080
+ this.#markTabDirty(tab);
2081
+ }
2082
+
2083
+ async setMode(tabId, modeId) {
2084
+ const tab = this.tabs.get(tabId);
2085
+ if (!tab) {
2086
+ throw new Error('Agent tab not found');
2087
+ }
2088
+ if (!modeId || typeof modeId !== 'string') {
2089
+ throw new Error('Mode ID is required');
2090
+ }
2091
+ if (typeof this.connection.setSessionMode !== 'function') {
2092
+ throw new Error('Agent does not support mode switching');
2093
+ }
2094
+
2095
+ const response = await this.connection.setSessionMode({
2096
+ sessionId: tab.acpSessionId,
2097
+ modeId
2098
+ });
2099
+ tab.currentModeId = response?.currentModeId
2100
+ || response?.modeId
2101
+ || modeId;
2102
+ tab.availableModes = this.#resolveAvailableModes(
2103
+ response?.availableModes,
2104
+ tab.availableModes
2105
+ );
2106
+ this.#broadcast(tab, {
2107
+ type: 'session_update',
2108
+ update: {
2109
+ sessionUpdate: 'current_mode_update',
2110
+ currentModeId: tab.currentModeId
2111
+ },
2112
+ tab: {
2113
+ title: tab.title,
2114
+ currentModeId: tab.currentModeId,
2115
+ availableModes: tab.availableModes,
2116
+ availableCommands: tab.availableCommands,
2117
+ configOptions: tab.configOptions
2118
+ }
2119
+ });
2120
+ this.#markTabDirty(tab);
2121
+ return this.serializeTab(tab);
2122
+ }
2123
+
2124
+ async setConfigOption(tabId, configId, valueId) {
2125
+ const tab = this.tabs.get(tabId);
2126
+ if (!tab) {
2127
+ throw new Error('Agent tab not found');
2128
+ }
2129
+ if (!configId || typeof configId !== 'string') {
2130
+ throw new Error('Config ID is required');
2131
+ }
2132
+ if (!valueId || typeof valueId !== 'string') {
2133
+ throw new Error('Config value is required');
2134
+ }
2135
+
2136
+ if (
2137
+ configId === '__tabminal_model__'
2138
+ && typeof this.connection.unstable_setSessionModel === 'function'
2139
+ ) {
2140
+ await this.connection.unstable_setSessionModel({
2141
+ sessionId: tab.acpSessionId,
2142
+ modelId: valueId
2143
+ });
2144
+ tab.configOptions = this.#resolveConfigOptions(
2145
+ tab.configOptions.map((option) => (
2146
+ option?.id === configId
2147
+ ? { ...option, currentValue: valueId }
2148
+ : option
2149
+ )),
2150
+ tab.configOptions
2151
+ );
2152
+ } else {
2153
+ if (typeof this.connection.setSessionConfigOption !== 'function') {
2154
+ throw new Error(
2155
+ 'Agent does not support session configuration changes'
2156
+ );
2157
+ }
2158
+ const response = await this.connection.setSessionConfigOption({
2159
+ sessionId: tab.acpSessionId,
2160
+ configId,
2161
+ value: valueId
2162
+ });
2163
+ tab.configOptions = this.#resolveConfigOptions(
2164
+ response?.configOptions,
2165
+ tab.configOptions
2166
+ );
2167
+ }
2168
+
2169
+ this.#broadcast(tab, {
2170
+ type: 'session_update',
2171
+ update: {
2172
+ sessionUpdate: 'config_option_update',
2173
+ configOptions: tab.configOptions
2174
+ },
2175
+ tab: {
2176
+ title: tab.title,
2177
+ currentModeId: tab.currentModeId,
2178
+ availableModes: tab.availableModes,
2179
+ availableCommands: tab.availableCommands,
2180
+ configOptions: tab.configOptions
2181
+ }
2182
+ });
2183
+ this.#markTabDirty(tab);
2184
+ return this.serializeTab(tab);
2185
+ }
2186
+
2187
+ async closeTab(tabId) {
2188
+ const tab = this.tabs.get(tabId);
2189
+ if (!tab) return;
2190
+
2191
+ if (tab.busy) {
2192
+ try {
2193
+ await this.cancel(tabId);
2194
+ } catch {
2195
+ // Ignore cancellation failures during close.
2196
+ }
2197
+ }
2198
+
2199
+ if (this.connection.unstable_closeSession) {
2200
+ try {
2201
+ await this.connection.unstable_closeSession({
2202
+ sessionId: tab.acpSessionId
2203
+ });
2204
+ } catch {
2205
+ // Ignore unsupported or failing close-session behavior.
2206
+ }
2207
+ }
2208
+
2209
+ for (const permission of tab.permissions.values()) {
2210
+ if (permission.resolve) {
2211
+ permission.resolve({
2212
+ outcome: {
2213
+ outcome: 'cancelled'
2214
+ }
2215
+ });
2216
+ permission.resolve = null;
2217
+ }
2218
+ }
2219
+
2220
+ this.tabs.delete(tabId);
2221
+ this.sessionToTabId.delete(tab.acpSessionId);
2222
+ }
2223
+
2224
+ async dispose() {
2225
+ clearTimeout(this.idleTimer);
2226
+ this.idleTimer = null;
2227
+ for (const terminal of this.terminals.values()) {
2228
+ await terminal.release({ destroy: true }).catch(() => {});
2229
+ }
2230
+ this.terminals.clear();
2231
+ for (const tabId of Array.from(this.tabs.keys())) {
2232
+ await this.closeTab(tabId);
2233
+ }
2234
+ if (this.process && !this.process.killed) {
2235
+ this.process.kill('SIGTERM');
2236
+ }
2237
+ await this.connection?.closed.catch(() => {});
2238
+ }
2239
+
2240
+ async #handleSessionUpdate(params) {
2241
+ const tab = this.#getTabBySession(params.sessionId);
2242
+ if (!tab) return;
2243
+ const update = params.update;
2244
+ let broadcastUpdate = update;
2245
+ let didChange = false;
2246
+
2247
+ switch (update.sessionUpdate) {
2248
+ case 'agent_message_chunk':
2249
+ this.#appendContentChunk(tab, update, 'assistant', 'message');
2250
+ didChange = true;
2251
+ break;
2252
+ case 'agent_thought_chunk':
2253
+ this.#appendContentChunk(tab, update, 'assistant', 'thought');
2254
+ didChange = true;
2255
+ break;
2256
+ case 'user_message_chunk':
2257
+ this.#appendContentChunk(tab, update, 'user', 'message');
2258
+ didChange = true;
2259
+ break;
2260
+ case 'tool_call': {
2261
+ this.#advanceSyntheticStreamTurn(tab);
2262
+ const nextToolCall = {
2263
+ ...update,
2264
+ createdAt: new Date().toISOString(),
2265
+ order: this.#nextTimelineOrder(tab)
2266
+ };
2267
+ tab.toolCalls.set(update.toolCallId, nextToolCall);
2268
+ broadcastUpdate = nextToolCall;
2269
+ didChange = true;
2270
+ break;
2271
+ }
2272
+ case 'tool_call_update': {
2273
+ this.#advanceSyntheticStreamTurn(tab);
2274
+ const previous = tab.toolCalls.get(update.toolCallId) || {
2275
+ toolCallId: update.toolCallId,
2276
+ title: '',
2277
+ status: 'pending',
2278
+ createdAt: new Date().toISOString(),
2279
+ order: this.#nextTimelineOrder(tab)
2280
+ };
2281
+ const nextToolCall = {
2282
+ ...previous,
2283
+ ...update
2284
+ };
2285
+ tab.toolCalls.set(update.toolCallId, nextToolCall);
2286
+ broadcastUpdate = nextToolCall;
2287
+ didChange = true;
2288
+ break;
2289
+ }
2290
+ case 'current_mode_update':
2291
+ tab.currentModeId = update.currentModeId || update.modeId || '';
2292
+ didChange = true;
2293
+ break;
2294
+ case 'available_commands_update':
2295
+ tab.availableCommands = this.#resolveAvailableCommands(
2296
+ update.availableCommands,
2297
+ tab.availableCommands
2298
+ );
2299
+ didChange = true;
2300
+ break;
2301
+ case 'config_option_update':
2302
+ tab.configOptions = this.#resolveConfigOptions(
2303
+ update.configOptions,
2304
+ tab.configOptions
2305
+ );
2306
+ didChange = true;
2307
+ break;
2308
+ case 'session_info_update':
2309
+ if (typeof update.title === 'string') {
2310
+ tab.title = update.title;
2311
+ } else if (update.title === null) {
2312
+ tab.title = '';
2313
+ }
2314
+ didChange = true;
2315
+ break;
2316
+ case 'plan':
2317
+ tab.plan = normalizePlanEntries(update.entries);
2318
+ didChange = true;
2319
+ break;
2320
+ case 'usage_update':
2321
+ tab.usage = mergeUsageState(tab.usage, update);
2322
+ didChange = true;
2323
+ break;
2324
+ default:
2325
+ break;
2326
+ }
2327
+
2328
+ this.#broadcast(tab, {
2329
+ type: 'session_update',
2330
+ update: broadcastUpdate,
2331
+ tab: {
2332
+ title: tab.title,
2333
+ currentModeId: tab.currentModeId,
2334
+ availableModes: tab.availableModes,
2335
+ availableCommands: tab.availableCommands,
2336
+ configOptions: tab.configOptions
2337
+ }
2338
+ });
2339
+ if (didChange) {
2340
+ this.#markTabDirty(tab);
2341
+ }
2342
+ }
2343
+
2344
+ #appendContentChunk(tab, update, role, kind) {
2345
+ const content = update.content;
2346
+ const text = content.type === 'text'
2347
+ ? (content.text || '')
2348
+ : `[${content.type}]`;
2349
+ if (role === 'user' && kind === 'message' && this.#consumeUserEcho(tab, text)) {
2350
+ return;
2351
+ }
2352
+ const streamKey = this.#getStreamKey(tab, update, role, kind);
2353
+ const last = tab.messages[tab.messages.length - 1] || null;
2354
+
2355
+ if (
2356
+ last
2357
+ && last.streamKey === streamKey
2358
+ && last.role === role
2359
+ && last.kind === kind
2360
+ ) {
2361
+ const nextText = mergeAgentMessageText(last.text, text);
2362
+ const appendedText = nextText.slice(last.text.length);
2363
+ last.text = nextText;
2364
+ this.#broadcast(tab, {
2365
+ type: 'message_chunk',
2366
+ streamKey,
2367
+ role,
2368
+ kind,
2369
+ text: appendedText
2370
+ });
2371
+ return;
2372
+ }
2373
+
2374
+ if (!update.messageId) {
2375
+ tab.messageCounter += 1;
2376
+ }
2377
+ const message = {
2378
+ id: crypto.randomUUID(),
2379
+ streamKey,
2380
+ role,
2381
+ kind,
2382
+ text,
2383
+ createdAt: new Date().toISOString(),
2384
+ order: this.#nextTimelineOrder(tab)
2385
+ };
2386
+ tab.messages.push(message);
2387
+ this.#broadcast(tab, {
2388
+ type: 'message_open',
2389
+ message
2390
+ });
2391
+ }
2392
+
2393
+ #consumeUserEcho(tab, text) {
2394
+ const pending = tab.pendingUserEcho;
2395
+ if (!pending || !text) {
2396
+ return false;
2397
+ }
2398
+
2399
+ const remaining = pending.text.slice(pending.matched);
2400
+ if (!remaining.startsWith(text)) {
2401
+ tab.pendingUserEcho = null;
2402
+ return false;
2403
+ }
2404
+
2405
+ pending.matched += text.length;
2406
+ if (pending.matched >= pending.text.length) {
2407
+ tab.pendingUserEcho = null;
2408
+ }
2409
+ return true;
2410
+ }
2411
+
2412
+ #appendMessage(tab, message) {
2413
+ const entry = {
2414
+ id: crypto.randomUUID(),
2415
+ role: message.role,
2416
+ kind: message.kind,
2417
+ text: message.text,
2418
+ streamKey: message.streamKey || crypto.randomUUID(),
2419
+ createdAt: new Date().toISOString(),
2420
+ order: this.#nextTimelineOrder(tab),
2421
+ attachments: Array.isArray(message.attachments)
2422
+ ? message.attachments.map((attachment) =>
2423
+ serializePromptAttachment(attachment)
2424
+ )
2425
+ : []
2426
+ };
2427
+ tab.messages.push(entry);
2428
+ this.#broadcast(tab, {
2429
+ type: 'message_open',
2430
+ message: entry
2431
+ });
2432
+ }
2433
+
2434
+ async #requestPermission(params) {
2435
+ const tab = this.#getTabBySession(params.sessionId);
2436
+ if (!tab) {
2437
+ return {
2438
+ outcome: {
2439
+ outcome: 'cancelled'
2440
+ }
2441
+ };
2442
+ }
2443
+ this.#advanceSyntheticStreamTurn(tab);
2444
+
2445
+ const permissionId = crypto.randomUUID();
2446
+ const request = {
2447
+ id: permissionId,
2448
+ sessionId: params.sessionId,
2449
+ toolCall: params.toolCall,
2450
+ options: params.options,
2451
+ status: 'pending',
2452
+ createdAt: new Date().toISOString(),
2453
+ order: this.#nextTimelineOrder(tab),
2454
+ selectedOptionId: '',
2455
+ resolve: null
2456
+ };
2457
+ tab.permissions.set(permissionId, request);
2458
+
2459
+ this.#broadcast(tab, {
2460
+ type: 'permission_request',
2461
+ permission: {
2462
+ id: request.id,
2463
+ sessionId: request.sessionId,
2464
+ toolCall: request.toolCall,
2465
+ options: request.options,
2466
+ status: request.status,
2467
+ createdAt: request.createdAt,
2468
+ order: request.order,
2469
+ selectedOptionId: request.selectedOptionId
2470
+ }
2471
+ });
2472
+ this.#markTabDirty(tab);
2473
+
2474
+ return new Promise((resolve) => {
2475
+ request.resolve = resolve;
2476
+ });
2477
+ }
2478
+
2479
+ async #hydrateFreshSessionMetadata(tab) {
2480
+ const previous = {
2481
+ title: tab.title || '',
2482
+ currentModeId: tab.currentModeId || '',
2483
+ availableModes: JSON.stringify(tab.availableModes || []),
2484
+ availableCommands: JSON.stringify(tab.availableCommands || []),
2485
+ configOptions: JSON.stringify(tab.configOptions || [])
2486
+ };
2487
+ const needsHydration = (
2488
+ !Array.isArray(tab.availableCommands)
2489
+ || tab.availableCommands.length === 0
2490
+ || !Array.isArray(tab.configOptions)
2491
+ || tab.configOptions.length === 0
2492
+ );
2493
+ if (
2494
+ !needsHydration
2495
+ || !this.agentCapabilities?.loadSession
2496
+ || typeof this.connection.loadSession !== 'function'
2497
+ ) {
2498
+ return null;
2499
+ }
2500
+
2501
+ try {
2502
+ const response = await this.connection.loadSession({
2503
+ cwd: tab.cwd,
2504
+ sessionId: tab.acpSessionId,
2505
+ mcpServers: []
2506
+ });
2507
+ const restoredSessionId = response?.sessionId || tab.acpSessionId;
2508
+ if (restoredSessionId !== tab.acpSessionId) {
2509
+ this.sessionToTabId.delete(tab.acpSessionId);
2510
+ tab.acpSessionId = restoredSessionId;
2511
+ this.sessionToTabId.set(tab.acpSessionId, tab.id);
2512
+ }
2513
+ if (typeof response?.title === 'string') {
2514
+ tab.title = response.title;
2515
+ }
2516
+ if (response?.modes?.currentModeId) {
2517
+ tab.currentModeId = response.modes.currentModeId;
2518
+ }
2519
+ tab.availableModes = this.#resolveAvailableModes(
2520
+ response?.modes?.availableModes,
2521
+ tab.availableModes
2522
+ );
2523
+ tab.availableCommands = this.#resolveAvailableCommands(
2524
+ response?.availableCommands,
2525
+ tab.availableCommands
2526
+ );
2527
+ tab.configOptions = this.#resolveConfigOptions(
2528
+ response?.configOptions,
2529
+ tab.configOptions,
2530
+ response?.models
2531
+ );
2532
+ return {
2533
+ titleChanged: previous.title !== (tab.title || ''),
2534
+ modeChanged: previous.currentModeId !== (tab.currentModeId || ''),
2535
+ modesChanged: previous.availableModes
2536
+ !== JSON.stringify(tab.availableModes || []),
2537
+ commandsChanged: previous.availableCommands
2538
+ !== JSON.stringify(tab.availableCommands || []),
2539
+ configChanged: previous.configOptions
2540
+ !== JSON.stringify(tab.configOptions || [])
2541
+ };
2542
+ } catch {
2543
+ // Ignore metadata hydration failures for fresh sessions.
2544
+ return null;
2545
+ }
2546
+ }
2547
+
2548
+ #broadcastHydratedSessionMetadata(tab, changes) {
2549
+ if (!changes) return;
2550
+ const tabMeta = {
2551
+ title: tab.title,
2552
+ currentModeId: tab.currentModeId,
2553
+ availableModes: tab.availableModes,
2554
+ availableCommands: tab.availableCommands,
2555
+ configOptions: tab.configOptions
2556
+ };
2557
+ if (changes.titleChanged) {
2558
+ this.#broadcast(tab, {
2559
+ type: 'session_update',
2560
+ update: {
2561
+ sessionUpdate: 'session_info_update',
2562
+ title: tab.title || null
2563
+ },
2564
+ tab: tabMeta
2565
+ });
2566
+ }
2567
+ if (changes.modeChanged || changes.modesChanged) {
2568
+ this.#broadcast(tab, {
2569
+ type: 'session_update',
2570
+ update: {
2571
+ sessionUpdate: 'current_mode_update',
2572
+ currentModeId: tab.currentModeId || '',
2573
+ availableModes: tab.availableModes
2574
+ },
2575
+ tab: tabMeta
2576
+ });
2577
+ }
2578
+ if (changes.commandsChanged) {
2579
+ this.#broadcast(tab, {
2580
+ type: 'session_update',
2581
+ update: {
2582
+ sessionUpdate: 'available_commands_update',
2583
+ availableCommands: tab.availableCommands
2584
+ },
2585
+ tab: tabMeta
2586
+ });
2587
+ }
2588
+ if (changes.configChanged) {
2589
+ this.#broadcast(tab, {
2590
+ type: 'session_update',
2591
+ update: {
2592
+ sessionUpdate: 'config_option_update',
2593
+ configOptions: tab.configOptions
2594
+ },
2595
+ tab: tabMeta
2596
+ });
2597
+ }
2598
+ }
2599
+
2600
+ async #readTextFile(params) {
2601
+ const content = await fs.readFile(params.path, 'utf8');
2602
+ return { content };
2603
+ }
2604
+
2605
+ async #writeTextFile(params) {
2606
+ await fs.writeFile(params.path, params.content, 'utf8');
2607
+ return {};
2608
+ }
2609
+
2610
+ async #createTerminal(params) {
2611
+ const tab = this.#getTabBySession(params.sessionId);
2612
+ const terminal = this.terminalManager
2613
+ ? new ManagedTerminalSession(params, this.terminalManager, {
2614
+ agentId: tab?.agentId || this.definition.id,
2615
+ agentLabel: tab?.agentLabel || this.definition.label
2616
+ })
2617
+ : new LocalExecTerminal(params);
2618
+ this.terminals.set(terminal.id, terminal);
2619
+ if (tab) {
2620
+ const syncSummary = (summary) => {
2621
+ tab.terminals.set(terminal.id, {
2622
+ ...summary,
2623
+ terminalId: terminal.id
2624
+ });
2625
+ this.#broadcast(tab, {
2626
+ type: 'terminal_update',
2627
+ terminal: tab.terminals.get(terminal.id)
2628
+ });
2629
+ };
2630
+ syncSummary(terminal.currentSummary());
2631
+ terminal.on('update', syncSummary);
2632
+ }
2633
+ return { terminalId: terminal.id };
2634
+ }
2635
+
2636
+ async releaseManagedTerminalSession(
2637
+ terminalSessionId,
2638
+ options = {}
2639
+ ) {
2640
+ const targetSessionId = String(terminalSessionId || '').trim();
2641
+ if (!targetSessionId) return false;
2642
+ for (const [terminalId, terminal] of this.terminals.entries()) {
2643
+ if (terminal?.terminalSessionId !== targetSessionId) continue;
2644
+ await terminal.release({ destroy: options.destroy !== false });
2645
+ if (options.destroy !== false) {
2646
+ this.terminals.delete(terminalId);
2647
+ }
2648
+ this.#syncTerminalSummary(terminal.sessionId, terminal, {
2649
+ released: true
2650
+ });
2651
+ return true;
2652
+ }
2653
+ return false;
2654
+ }
2655
+
2656
+ async #terminalOutput(params) {
2657
+ const terminal = this.terminals.get(params.terminalId);
2658
+ if (!terminal) {
2659
+ throw new Error('Terminal not found');
2660
+ }
2661
+ const response = terminal.currentOutput();
2662
+ this.#syncTerminalSummary(params.sessionId, terminal);
2663
+ return response;
2664
+ }
2665
+
2666
+ async #waitForTerminalExit(params) {
2667
+ const terminal = this.terminals.get(params.terminalId);
2668
+ if (!terminal) {
2669
+ throw new Error('Terminal not found');
2670
+ }
2671
+ const response = await terminal.waitForExit();
2672
+ this.#syncTerminalSummary(params.sessionId, terminal);
2673
+ return response;
2674
+ }
2675
+
2676
+ async #killTerminal(params) {
2677
+ const terminal = this.terminals.get(params.terminalId);
2678
+ if (!terminal) {
2679
+ throw new Error('Terminal not found');
2680
+ }
2681
+ const response = await terminal.kill();
2682
+ this.#syncTerminalSummary(params.sessionId, terminal);
2683
+ return response;
2684
+ }
2685
+
2686
+ async #releaseTerminal(params) {
2687
+ const terminal = this.terminals.get(params.terminalId);
2688
+ if (!terminal) {
2689
+ return {};
2690
+ }
2691
+ await terminal.release({ destroy: true });
2692
+ this.terminals.delete(params.terminalId);
2693
+ this.#syncTerminalSummary(params.sessionId, terminal, {
2694
+ released: true,
2695
+ terminalSessionId: ''
2696
+ });
2697
+ return {};
2698
+ }
2699
+
2700
+ #syncTerminalSummary(sessionId, terminal, overrides = {}) {
2701
+ const tab = this.#getTabBySession(sessionId || terminal.sessionId);
2702
+ if (!tab) return;
2703
+ tab.terminals.set(terminal.id, {
2704
+ ...terminal.currentSummary(),
2705
+ ...overrides,
2706
+ terminalId: terminal.id
2707
+ });
2708
+ if (!tab.busy) {
2709
+ this.#settleStaleToolCalls(
2710
+ tab,
2711
+ tab.status === 'error' ? 'error' : 'completed'
2712
+ );
2713
+ }
2714
+ this.#broadcast(tab, {
2715
+ type: 'terminal_update',
2716
+ terminal: tab.terminals.get(terminal.id)
2717
+ });
2718
+ this.#markTabDirty(tab);
2719
+ }
2720
+
2721
+ #toolCallHasRunningTerminal(tab, toolCall) {
2722
+ for (const terminalId of getToolCallTerminalIds(toolCall)) {
2723
+ const terminal = tab.terminals.get(terminalId);
2724
+ if (terminal?.running) {
2725
+ return true;
2726
+ }
2727
+ }
2728
+ return false;
2729
+ }
2730
+
2731
+ #settleStaleToolCalls(tab, nextStatus = 'completed') {
2732
+ let didChange = false;
2733
+ for (const [toolCallId, toolCall] of tab.toolCalls.entries()) {
2734
+ const statusClass = normalizeToolStatusClass(toolCall?.status);
2735
+ if (
2736
+ statusClass !== 'pending'
2737
+ && statusClass !== 'running'
2738
+ ) {
2739
+ continue;
2740
+ }
2741
+ if (this.#toolCallHasRunningTerminal(tab, toolCall)) {
2742
+ continue;
2743
+ }
2744
+ const nextToolCall = {
2745
+ ...toolCall,
2746
+ status: nextStatus
2747
+ };
2748
+ tab.toolCalls.set(toolCallId, nextToolCall);
2749
+ this.#broadcast(tab, {
2750
+ type: 'session_update',
2751
+ update: {
2752
+ sessionUpdate: 'tool_call_update',
2753
+ ...nextToolCall
2754
+ },
2755
+ tab: {
2756
+ title: tab.title,
2757
+ currentModeId: tab.currentModeId,
2758
+ availableModes: tab.availableModes,
2759
+ availableCommands: tab.availableCommands,
2760
+ configOptions: tab.configOptions
2761
+ }
2762
+ });
2763
+ didChange = true;
2764
+ }
2765
+ if (didChange) {
2766
+ this.#markTabDirty(tab);
2767
+ }
2768
+ return didChange;
2769
+ }
2770
+
2771
+ #getTabBySession(sessionId) {
2772
+ const tabId = this.sessionToTabId.get(sessionId);
2773
+ return tabId ? this.tabs.get(tabId) || null : null;
2774
+ }
2775
+
2776
+ #broadcast(tab, payload) {
2777
+ const message = JSON.stringify(payload);
2778
+ for (const socket of tab.clients) {
2779
+ if (socket.readyState === 1) {
2780
+ socket.send(message);
2781
+ }
2782
+ }
2783
+ }
2784
+
2785
+ #getStreamKey(tab, update, role, kind) {
2786
+ if (update.messageId) {
2787
+ return update.messageId;
2788
+ }
2789
+
2790
+ const bucketKey = `${update.sessionUpdate}:${role}:${kind}`;
2791
+ let streamKey = tab.syntheticStreams.get(bucketKey) || '';
2792
+ if (!streamKey) {
2793
+ streamKey = [
2794
+ 'synthetic',
2795
+ tab.syntheticStreamTurn,
2796
+ update.sessionUpdate,
2797
+ role,
2798
+ kind
2799
+ ].join(':');
2800
+ tab.syntheticStreams.set(bucketKey, streamKey);
2801
+ }
2802
+ return streamKey;
2803
+ }
2804
+
2805
+ #advanceSyntheticStreamTurn(tab) {
2806
+ tab.syntheticStreamTurn += 1;
2807
+ tab.syntheticStreams.clear();
2808
+ }
2809
+
2810
+ #nextTimelineOrder(tab) {
2811
+ tab.timelineCounter += 1;
2812
+ return tab.timelineCounter;
2813
+ }
2814
+ }
2815
+
2816
+ export class AcpManager {
2817
+ constructor(options = {}) {
2818
+ this.idleTimeoutMs = options.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
2819
+ this.terminalManager = options.terminalManager || null;
2820
+ this.runtimeFactory = options.runtimeFactory || (
2821
+ (definition, runtimeOptions) =>
2822
+ new AcpRuntime(definition, runtimeOptions)
2823
+ );
2824
+ this.definitions = makeBuiltInDefinitions();
2825
+ this.runtimes = new Map();
2826
+ this.tabs = new Map();
2827
+ this.loadTabs = options.loadTabs || persistence.loadAgentTabs;
2828
+ this.saveTabs = options.saveTabs || persistence.saveAgentTabs;
2829
+ this.loadConfigs = options.loadConfigs || persistence.loadAgentConfigs;
2830
+ this.saveConfigs = options.saveConfigs || persistence.saveAgentConfigs;
2831
+ this.persistenceChain = Promise.resolve();
2832
+ this.transcriptPersistDelayMs = options.transcriptPersistDelayMs
2833
+ || DEFAULT_TRANSCRIPT_PERSIST_DELAY_MS;
2834
+ this.persistTabsTimer = null;
2835
+ this.disposing = false;
2836
+ this.restoring = false;
2837
+ this.agentConfigs = {};
2838
+ this.agentConfigVersions = new Map();
2839
+ this.definitionAvailabilityOverrides = new Map();
2840
+ this.availabilityOverrideTtlMs =
2841
+ options.availabilityOverrideTtlMs
2842
+ || DEFAULT_AVAILABILITY_OVERRIDE_TTL_MS;
2843
+ this.availabilityProbes = options.availabilityProbes
2844
+ || DEFAULT_AVAILABILITY_PROBES;
2845
+ this.configLoaded = false;
2846
+ }
2847
+
2848
+ #getDefinitionAvailabilityOverride(agentId) {
2849
+ const entry = this.definitionAvailabilityOverrides.get(agentId);
2850
+ if (!entry) return null;
2851
+ if (entry.expiresAt <= Date.now()) {
2852
+ this.definitionAvailabilityOverrides.delete(agentId);
2853
+ return null;
2854
+ }
2855
+ return entry;
2856
+ }
2857
+
2858
+ #setDefinitionAvailabilityOverride(agentId, availability = {}) {
2859
+ this.definitionAvailabilityOverrides.set(agentId, {
2860
+ available: Boolean(availability.available),
2861
+ reason: String(availability.reason || ''),
2862
+ expiresAt: Date.now() + this.availabilityOverrideTtlMs
2863
+ });
2864
+ }
2865
+
2866
+ #clearDefinitionAvailabilityOverride(agentId) {
2867
+ this.definitionAvailabilityOverrides.delete(agentId);
2868
+ }
2869
+
2870
+ #recordDefinitionStartupFailure(definition, error) {
2871
+ const reason = formatAgentStartupError(definition, error);
2872
+ if (!reason) return;
2873
+ this.#setDefinitionAvailabilityOverride(definition.id, {
2874
+ available: false,
2875
+ reason
2876
+ });
2877
+ }
2878
+
2879
+ getDefinitionAvailability(definition) {
2880
+ const baseAvailability = getDefinitionAvailability(
2881
+ definition,
2882
+ this.getAgentConfig(definition.id),
2883
+ this.availabilityProbes
2884
+ );
2885
+ if (!baseAvailability.available) {
2886
+ this.#clearDefinitionAvailabilityOverride(definition.id);
2887
+ return baseAvailability;
2888
+ }
2889
+ return this.#getDefinitionAvailabilityOverride(definition.id)
2890
+ || baseAvailability;
2891
+ }
2892
+
2893
+ getAgentConfigVersion(agentId) {
2894
+ return this.agentConfigVersions.get(agentId) || 0;
2895
+ }
2896
+
2897
+ async ensureConfigsLoaded() {
2898
+ if (this.configLoaded) {
2899
+ return this.agentConfigs;
2900
+ }
2901
+ this.agentConfigs = await this.loadConfigs();
2902
+ this.configLoaded = true;
2903
+ return this.agentConfigs;
2904
+ }
2905
+
2906
+ getAgentConfig(agentId) {
2907
+ return this.agentConfigs?.[agentId] || { env: {} };
2908
+ }
2909
+
2910
+ getSerializedAgentConfig(agentId) {
2911
+ return buildAgentConfigSummary(
2912
+ agentId,
2913
+ this.getAgentConfig(agentId)
2914
+ );
2915
+ }
2916
+
2917
+ async listAgentConfigs() {
2918
+ await this.ensureConfigsLoaded();
2919
+ const configs = {};
2920
+ for (const definition of this.definitions) {
2921
+ configs[definition.id] = this.getSerializedAgentConfig(
2922
+ definition.id
2923
+ );
2924
+ }
2925
+ return configs;
2926
+ }
2927
+
2928
+ async updateAgentConfig(agentId, nextConfig = {}) {
2929
+ await this.ensureConfigsLoaded();
2930
+ const definition = this.definitions.find((entry) => entry.id === agentId);
2931
+ if (!definition) {
2932
+ throw new Error('Unknown agent');
2933
+ }
2934
+ const currentConfig = this.getAgentConfig(agentId);
2935
+ const currentEnv = normalizeConfiguredEnv(agentId, currentConfig.env);
2936
+ const nextEnv = normalizeConfiguredEnv(agentId, nextConfig.env);
2937
+ const clearEnvKeys = Array.isArray(nextConfig.clearEnvKeys)
2938
+ ? nextConfig.clearEnvKeys.filter((key) =>
2939
+ getAllowedAgentEnvKeys(agentId).includes(key)
2940
+ )
2941
+ : [];
2942
+ const mergedEnv = {
2943
+ ...currentEnv,
2944
+ ...nextEnv
2945
+ };
2946
+ for (const key of clearEnvKeys) {
2947
+ delete mergedEnv[key];
2948
+ }
2949
+ this.agentConfigs = {
2950
+ ...this.agentConfigs,
2951
+ [agentId]: {
2952
+ env: mergedEnv
2953
+ }
2954
+ };
2955
+ this.#clearDefinitionAvailabilityOverride(agentId);
2956
+ this.agentConfigVersions.set(
2957
+ agentId,
2958
+ this.getAgentConfigVersion(agentId) + 1
2959
+ );
2960
+ await this.queuePersistence(() => this.saveConfigs(this.agentConfigs));
2961
+ return this.getSerializedAgentConfig(agentId);
2962
+ }
2963
+
2964
+ async clearAgentConfig(agentId) {
2965
+ await this.ensureConfigsLoaded();
2966
+ const nextConfigs = { ...this.agentConfigs };
2967
+ delete nextConfigs[agentId];
2968
+ this.agentConfigs = nextConfigs;
2969
+ this.#clearDefinitionAvailabilityOverride(agentId);
2970
+ this.agentConfigVersions.set(
2971
+ agentId,
2972
+ this.getAgentConfigVersion(agentId) + 1
2973
+ );
2974
+ await this.queuePersistence(() => this.saveConfigs(this.agentConfigs));
2975
+ return this.getSerializedAgentConfig(agentId);
2976
+ }
2977
+
2978
+ #applyRuntimeMetadataFallback(runtime, serialized) {
2979
+ if (!serialized || typeof serialized !== 'object') {
2980
+ return serialized;
2981
+ }
2982
+
2983
+ let availableModes = Array.isArray(serialized.availableModes)
2984
+ ? serialized.availableModes
2985
+ : [];
2986
+ let availableCommands = Array.isArray(serialized.availableCommands)
2987
+ ? serialized.availableCommands
2988
+ : [];
2989
+ let configOptions = Array.isArray(serialized.configOptions)
2990
+ ? serialized.configOptions
2991
+ : [];
2992
+
2993
+ if (
2994
+ (
2995
+ availableModes.length > 0
2996
+ && availableCommands.length > 0
2997
+ && configOptions.length > 0
2998
+ )
2999
+ || !(runtime?.tabs instanceof Map)
3000
+ ) {
3001
+ return serialized;
3002
+ }
3003
+
3004
+ for (const runtimeTab of runtime.tabs.values()) {
3005
+ if (!runtimeTab || typeof runtimeTab !== 'object') continue;
3006
+ if (
3007
+ availableModes.length === 0
3008
+ && Array.isArray(runtimeTab.availableModes)
3009
+ && runtimeTab.availableModes.length > 0
3010
+ ) {
3011
+ availableModes = runtimeTab.availableModes;
3012
+ }
3013
+ if (
3014
+ availableCommands.length === 0
3015
+ && Array.isArray(runtimeTab.availableCommands)
3016
+ && runtimeTab.availableCommands.length > 0
3017
+ ) {
3018
+ availableCommands = runtimeTab.availableCommands;
3019
+ }
3020
+ if (
3021
+ configOptions.length === 0
3022
+ && Array.isArray(runtimeTab.configOptions)
3023
+ && runtimeTab.configOptions.length > 0
3024
+ ) {
3025
+ configOptions = runtimeTab.configOptions;
3026
+ }
3027
+ if (
3028
+ availableModes.length > 0
3029
+ && availableCommands.length > 0
3030
+ && configOptions.length > 0
3031
+ ) {
3032
+ break;
3033
+ }
3034
+ }
3035
+
3036
+ if (
3037
+ availableModes === serialized.availableModes
3038
+ && availableCommands === serialized.availableCommands
3039
+ && configOptions === serialized.configOptions
3040
+ ) {
3041
+ return serialized;
3042
+ }
3043
+
3044
+ return {
3045
+ ...serialized,
3046
+ availableModes,
3047
+ availableCommands,
3048
+ configOptions
3049
+ };
3050
+ }
3051
+
3052
+ queuePersistence(operation) {
3053
+ this.persistenceChain = this.persistenceChain
3054
+ .catch(() => {})
3055
+ .then(operation);
3056
+ return this.persistenceChain;
3057
+ }
3058
+
3059
+ clearPendingTabPersistence() {
3060
+ if (this.persistTabsTimer) {
3061
+ clearTimeout(this.persistTabsTimer);
3062
+ this.persistTabsTimer = null;
3063
+ }
3064
+ }
3065
+
3066
+ schedulePersistTabs() {
3067
+ if (this.disposing || this.persistTabsTimer) {
3068
+ return;
3069
+ }
3070
+ this.persistTabsTimer = setTimeout(() => {
3071
+ this.persistTabsTimer = null;
3072
+ void this.persistTabs();
3073
+ }, this.transcriptPersistDelayMs);
3074
+ }
3075
+
3076
+ getPersistedTabs() {
3077
+ return Array.from(this.tabs.values()).map((entry) => {
3078
+ const tab = entry.serialize();
3079
+ return {
3080
+ id: tab.id,
3081
+ agentId: tab.agentId,
3082
+ cwd: tab.cwd,
3083
+ acpSessionId: tab.acpSessionId,
3084
+ terminalSessionId: tab.terminalSessionId,
3085
+ createdAt: tab.createdAt,
3086
+ title: tab.title || '',
3087
+ currentModeId: tab.currentModeId || '',
3088
+ availableModes: Array.isArray(tab.availableModes)
3089
+ ? cloneSerializable(tab.availableModes, [])
3090
+ : [],
3091
+ availableCommands: Array.isArray(tab.availableCommands)
3092
+ ? cloneSerializable(tab.availableCommands, [])
3093
+ : [],
3094
+ configOptions: Array.isArray(tab.configOptions)
3095
+ ? cloneSerializable(tab.configOptions, [])
3096
+ : [],
3097
+ messages: Array.isArray(tab.messages)
3098
+ ? cloneSerializable(tab.messages, [])
3099
+ : [],
3100
+ toolCalls: Array.isArray(tab.toolCalls)
3101
+ ? cloneSerializable(tab.toolCalls, [])
3102
+ : [],
3103
+ permissions: Array.isArray(tab.permissions)
3104
+ ? cloneSerializable(tab.permissions, [])
3105
+ : [],
3106
+ plan: Array.isArray(tab.plan)
3107
+ ? cloneSerializable(tab.plan, [])
3108
+ : [],
3109
+ usage: tab.usage ? cloneSerializable(tab.usage, null) : null,
3110
+ terminals: Array.isArray(tab.terminals)
3111
+ ? cloneSerializable(tab.terminals, [])
3112
+ : []
3113
+ };
3114
+ });
3115
+ }
3116
+
3117
+ persistTabs() {
3118
+ this.clearPendingTabPersistence();
3119
+ return this.queuePersistence(() => this.saveTabs(this.getPersistedTabs()));
3120
+ }
3121
+
3122
+ async listDefinitions() {
3123
+ await this.ensureConfigsLoaded();
3124
+ return this.definitions.map((definition) => {
3125
+ const availability = this.getDefinitionAvailability(definition);
3126
+ return {
3127
+ id: definition.id,
3128
+ label: definition.label,
3129
+ description: definition.description,
3130
+ websiteUrl: definition.websiteUrl || '',
3131
+ commandLabel: definition.commandLabel,
3132
+ setupCommandLabel: definition.setupCommandLabel || '',
3133
+ available: availability.available,
3134
+ reason: availability.reason,
3135
+ config: this.getSerializedAgentConfig(definition.id)
3136
+ };
3137
+ });
3138
+ }
3139
+
3140
+ async listState() {
3141
+ await this.ensureConfigsLoaded();
3142
+ return {
3143
+ restoring: this.restoring,
3144
+ definitions: await this.listDefinitions(),
3145
+ configs: await this.listAgentConfigs(),
3146
+ tabs: Array.from(this.tabs.values()).map((entry) => entry.serialize())
3147
+ };
3148
+ }
3149
+
3150
+ async createTab(options) {
3151
+ await this.ensureConfigsLoaded();
3152
+ const definition = this.definitions.find(
3153
+ (entry) => entry.id === options.agentId
3154
+ );
3155
+ if (!definition) {
3156
+ throw new Error('Unknown agent');
3157
+ }
3158
+ const availability = this.getDefinitionAvailability(definition);
3159
+ if (!availability.available) {
3160
+ throw new Error(availability.reason || 'Agent unavailable');
3161
+ }
3162
+
3163
+ const cwd = path.resolve(options.cwd || process.cwd());
3164
+ const runtimeKey = makeRuntimeKey(definition.id, cwd);
3165
+ const runtimeStoreKey = makeRuntimeStoreKey(
3166
+ definition.id,
3167
+ cwd,
3168
+ this.getAgentConfigVersion(definition.id)
3169
+ );
3170
+ let runtimeEntry = this.runtimes.get(runtimeStoreKey);
3171
+ let createdRuntime = false;
3172
+ if (!runtimeEntry) {
3173
+ const runtime = this.runtimeFactory(definition, {
3174
+ cwd,
3175
+ idleTimeoutMs: this.idleTimeoutMs,
3176
+ runtimeStoreKey,
3177
+ terminalManager: this.terminalManager,
3178
+ env: mergeDefinitionEnv(
3179
+ definition,
3180
+ this.getAgentConfig(definition.id)
3181
+ )
3182
+ });
3183
+ runtimeEntry = {
3184
+ runtime,
3185
+ definition,
3186
+ runtimeKey,
3187
+ runtimeStoreKey
3188
+ };
3189
+ this.runtimes.set(runtimeStoreKey, runtimeEntry);
3190
+ createdRuntime = true;
3191
+ runtime.on('tab_dirty', () => {
3192
+ this.schedulePersistTabs();
3193
+ });
3194
+ runtime.on('runtime_exit', () => {
3195
+ if (this.disposing) return;
3196
+ for (const [tabId, tabEntry] of this.tabs.entries()) {
3197
+ if (tabEntry.runtime !== runtime) continue;
3198
+ this.tabs.delete(tabId);
3199
+ }
3200
+ this.runtimes.delete(runtimeStoreKey);
3201
+ void this.persistTabs();
3202
+ });
3203
+ }
3204
+
3205
+ const tabId = crypto.randomUUID();
3206
+ try {
3207
+ const rawSerialized = await runtimeEntry.runtime.createTab({
3208
+ id: tabId,
3209
+ cwd,
3210
+ terminalSessionId: options.terminalSessionId || '',
3211
+ modeId: options.modeId || ''
3212
+ });
3213
+ const serialized = this.#applyRuntimeMetadataFallback(
3214
+ runtimeEntry.runtime,
3215
+ rawSerialized
3216
+ );
3217
+ const tabEntry = {
3218
+ runtime: runtimeEntry.runtime,
3219
+ serialize: () => {
3220
+ const tab = runtimeEntry.runtime.tabs.get(tabId);
3221
+ const nextSerialized = tab
3222
+ ? runtimeEntry.runtime.serializeTab(tab)
3223
+ : serialized;
3224
+ return this.#applyRuntimeMetadataFallback(
3225
+ runtimeEntry.runtime,
3226
+ nextSerialized
3227
+ );
3228
+ }
3229
+ };
3230
+ this.tabs.set(tabId, tabEntry);
3231
+ this.#clearDefinitionAvailabilityOverride(definition.id);
3232
+ await this.persistTabs();
3233
+ return tabEntry.serialize();
3234
+ } catch (error) {
3235
+ const shouldDisposeRuntime = createdRuntime
3236
+ || runtimeEntry.runtime.tabs.size === 0;
3237
+ if (shouldDisposeRuntime) {
3238
+ this.runtimes.delete(runtimeStoreKey);
3239
+ await runtimeEntry.runtime.dispose().catch(() => {});
3240
+ }
3241
+ this.#recordDefinitionStartupFailure(definition, error);
3242
+ throw new Error(formatAgentStartupError(definition, error));
3243
+ }
3244
+ }
3245
+
3246
+ async restoreTabs(validTerminalSessionIds = new Set()) {
3247
+ await this.ensureConfigsLoaded();
3248
+ const entries = await this.loadTabs();
3249
+ let changed = false;
3250
+
3251
+ for (const meta of entries) {
3252
+ if (
3253
+ meta.terminalSessionId
3254
+ && !validTerminalSessionIds.has(meta.terminalSessionId)
3255
+ ) {
3256
+ changed = true;
3257
+ continue;
3258
+ }
3259
+
3260
+ const definition = this.definitions.find(
3261
+ (entry) => entry.id === meta.agentId
3262
+ );
3263
+ if (!definition) {
3264
+ changed = true;
3265
+ continue;
3266
+ }
3267
+
3268
+ const agentConfig = this.getAgentConfig(definition.id);
3269
+ const availability = this.getDefinitionAvailability(definition);
3270
+ if (!availability.available) {
3271
+ changed = true;
3272
+ continue;
3273
+ }
3274
+
3275
+ const cwd = path.resolve(meta.cwd || process.cwd());
3276
+ const runtimeKey = makeRuntimeKey(definition.id, cwd);
3277
+ const runtimeStoreKey = makeRuntimeStoreKey(
3278
+ definition.id,
3279
+ cwd,
3280
+ this.getAgentConfigVersion(definition.id)
3281
+ );
3282
+ let runtimeEntry = this.runtimes.get(runtimeStoreKey);
3283
+ if (!runtimeEntry) {
3284
+ const runtime = this.runtimeFactory(definition, {
3285
+ cwd,
3286
+ idleTimeoutMs: this.idleTimeoutMs,
3287
+ runtimeStoreKey,
3288
+ terminalManager: this.terminalManager,
3289
+ env: mergeDefinitionEnv(definition, agentConfig)
3290
+ });
3291
+ runtimeEntry = {
3292
+ runtime,
3293
+ definition,
3294
+ runtimeKey,
3295
+ runtimeStoreKey
3296
+ };
3297
+ this.runtimes.set(runtimeStoreKey, runtimeEntry);
3298
+ runtime.on('tab_dirty', () => {
3299
+ this.schedulePersistTabs();
3300
+ });
3301
+ runtime.on('runtime_exit', () => {
3302
+ if (this.disposing) return;
3303
+ for (const [tabId, tabEntry] of this.tabs.entries()) {
3304
+ if (tabEntry.runtime !== runtime) continue;
3305
+ this.tabs.delete(tabId);
3306
+ }
3307
+ this.runtimes.delete(runtimeStoreKey);
3308
+ void this.persistTabs();
3309
+ });
3310
+ }
3311
+
3312
+ try {
3313
+ const rawSerialized = await runtimeEntry.runtime.restoreTab({
3314
+ ...meta,
3315
+ cwd
3316
+ });
3317
+ const serialized = this.#applyRuntimeMetadataFallback(
3318
+ runtimeEntry.runtime,
3319
+ rawSerialized
3320
+ );
3321
+ this.tabs.set(meta.id, {
3322
+ runtime: runtimeEntry.runtime,
3323
+ serialize: () => {
3324
+ const tab = runtimeEntry.runtime.tabs.get(meta.id);
3325
+ const nextSerialized = tab
3326
+ ? runtimeEntry.runtime.serializeTab(tab)
3327
+ : serialized;
3328
+ return this.#applyRuntimeMetadataFallback(
3329
+ runtimeEntry.runtime,
3330
+ nextSerialized
3331
+ );
3332
+ }
3333
+ });
3334
+ this.#clearDefinitionAvailabilityOverride(definition.id);
3335
+ } catch (error) {
3336
+ changed = true;
3337
+ console.warn(
3338
+ `[ACP] Failed to restore agent tab ${meta.id}:`,
3339
+ error?.message || error
3340
+ );
3341
+ }
3342
+ }
3343
+
3344
+ if (changed) {
3345
+ await this.persistTabs();
3346
+ }
3347
+ }
3348
+
3349
+ attachSocket(tabId, socket) {
3350
+ const tabEntry = this.tabs.get(tabId);
3351
+ if (!tabEntry) {
3352
+ socket.close();
3353
+ return false;
3354
+ }
3355
+ return tabEntry.runtime.attachSocket(tabId, socket);
3356
+ }
3357
+
3358
+ async sendPrompt(tabId, text, attachments = []) {
3359
+ const tabEntry = this.tabs.get(tabId);
3360
+ if (!tabEntry) {
3361
+ throw new Error('Agent tab not found');
3362
+ }
3363
+ await tabEntry.runtime.sendPrompt(tabId, text, attachments);
3364
+ }
3365
+
3366
+ async cancel(tabId) {
3367
+ const tabEntry = this.tabs.get(tabId);
3368
+ if (!tabEntry) {
3369
+ throw new Error('Agent tab not found');
3370
+ }
3371
+ await tabEntry.runtime.cancel(tabId);
3372
+ }
3373
+
3374
+ async setMode(tabId, modeId) {
3375
+ const tabEntry = this.tabs.get(tabId);
3376
+ if (!tabEntry) {
3377
+ throw new Error('Agent tab not found');
3378
+ }
3379
+ return await tabEntry.runtime.setMode(tabId, modeId);
3380
+ }
3381
+
3382
+ async setConfigOption(tabId, configId, valueId) {
3383
+ const tabEntry = this.tabs.get(tabId);
3384
+ if (!tabEntry) {
3385
+ throw new Error('Agent tab not found');
3386
+ }
3387
+ return await tabEntry.runtime.setConfigOption(tabId, configId, valueId);
3388
+ }
3389
+
3390
+ async resolvePermission(tabId, permissionId, optionId) {
3391
+ const tabEntry = this.tabs.get(tabId);
3392
+ if (!tabEntry) {
3393
+ throw new Error('Agent tab not found');
3394
+ }
3395
+ await tabEntry.runtime.resolvePermission(tabId, permissionId, optionId);
3396
+ }
3397
+
3398
+ async closeTab(tabId) {
3399
+ const tabEntry = this.tabs.get(tabId);
3400
+ if (!tabEntry) return;
3401
+ await tabEntry.runtime.closeTab(tabId);
3402
+ this.tabs.delete(tabId);
3403
+ await this.persistTabs();
3404
+ tabEntry.runtime.scheduleIdleShutdown(async () => {
3405
+ if (
3406
+ Array.from(this.tabs.values()).some(
3407
+ (entry) => entry.runtime === tabEntry.runtime
3408
+ )
3409
+ ) {
3410
+ return;
3411
+ }
3412
+ await tabEntry.runtime.dispose();
3413
+ this.runtimes.delete(
3414
+ tabEntry.runtime.runtimeStoreKey || tabEntry.runtime.runtimeKey
3415
+ );
3416
+ });
3417
+ }
3418
+
3419
+ async closeTabsForTerminalSession(terminalSessionId) {
3420
+ const ids = [];
3421
+ for (const [tabId, entry] of this.tabs.entries()) {
3422
+ const tab = entry.runtime.tabs.get(tabId);
3423
+ if (!tab) continue;
3424
+ if (tab.terminalSessionId === terminalSessionId) {
3425
+ ids.push(tabId);
3426
+ }
3427
+ }
3428
+ for (const id of ids) {
3429
+ await this.closeTab(id);
3430
+ }
3431
+ }
3432
+
3433
+ async releaseManagedTerminalSession(
3434
+ terminalSessionId,
3435
+ options = {}
3436
+ ) {
3437
+ const targetSessionId = String(terminalSessionId || '').trim();
3438
+ if (!targetSessionId) return false;
3439
+ for (const runtimeEntry of this.runtimes.values()) {
3440
+ if (
3441
+ typeof runtimeEntry.runtime.releaseManagedTerminalSession
3442
+ !== 'function'
3443
+ ) {
3444
+ continue;
3445
+ }
3446
+ const released = await runtimeEntry.runtime
3447
+ .releaseManagedTerminalSession(targetSessionId, options);
3448
+ if (released) {
3449
+ return true;
3450
+ }
3451
+ }
3452
+ return false;
3453
+ }
3454
+
3455
+ async dispose({ preserveTabs = true } = {}) {
3456
+ this.disposing = true;
3457
+ this.clearPendingTabPersistence();
3458
+ if (preserveTabs) {
3459
+ await this.persistTabs();
3460
+ } else {
3461
+ await this.saveTabs([]);
3462
+ }
3463
+ for (const runtimeEntry of this.runtimes.values()) {
3464
+ await runtimeEntry.runtime.dispose();
3465
+ }
3466
+ this.runtimes.clear();
3467
+ this.tabs.clear();
3468
+ }
3469
+ }