tabminal 2.0.14 → 2.0.16

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.
@@ -7,6 +7,8 @@ const BASE_DIR = path.join(HOME_DIR, '.tabminal');
7
7
  const SESSIONS_DIR = path.join(BASE_DIR, 'sessions');
8
8
  const MEMORY_FILE = path.join(BASE_DIR, 'memory.json');
9
9
  const CLUSTER_FILE = path.join(BASE_DIR, 'cluster.json');
10
+ const AGENT_TABS_FILE = path.join(BASE_DIR, 'agent-tabs.json');
11
+ const AGENT_CONFIG_FILE = path.join(BASE_DIR, 'agent-config.json');
10
12
  const getSessionSnapshotPath = (id) => path.join(SESSIONS_DIR, `${id}.snapshot`);
11
13
 
12
14
  // Ensure directories exist
@@ -193,6 +195,157 @@ export const saveCluster = async (servers) => {
193
195
  }
194
196
  };
195
197
 
198
+ // --- ACP Agent Tab Persistence ---
199
+
200
+ function normalizeAgentTabs(tabs) {
201
+ if (!Array.isArray(tabs)) return [];
202
+ const normalized = [];
203
+ for (const entry of tabs) {
204
+ if (!entry || typeof entry !== 'object') continue;
205
+ const id = typeof entry.id === 'string' ? entry.id.trim() : '';
206
+ const agentId = typeof entry.agentId === 'string'
207
+ ? entry.agentId.trim()
208
+ : '';
209
+ const cwd = typeof entry.cwd === 'string' ? entry.cwd.trim() : '';
210
+ const acpSessionId = typeof entry.acpSessionId === 'string'
211
+ ? entry.acpSessionId.trim()
212
+ : '';
213
+ const terminalSessionId = typeof entry.terminalSessionId === 'string'
214
+ ? entry.terminalSessionId.trim()
215
+ : '';
216
+ const createdAt = typeof entry.createdAt === 'string'
217
+ ? entry.createdAt.trim()
218
+ : '';
219
+ const title = typeof entry.title === 'string'
220
+ ? entry.title
221
+ : '';
222
+ const currentModeId = typeof entry.currentModeId === 'string'
223
+ ? entry.currentModeId
224
+ : '';
225
+ const availableModes = Array.isArray(entry.availableModes)
226
+ ? entry.availableModes
227
+ : [];
228
+ const availableCommands = Array.isArray(entry.availableCommands)
229
+ ? entry.availableCommands
230
+ : [];
231
+ const configOptions = Array.isArray(entry.configOptions)
232
+ ? entry.configOptions
233
+ : [];
234
+ const messages = Array.isArray(entry.messages)
235
+ ? entry.messages
236
+ : [];
237
+ const toolCalls = Array.isArray(entry.toolCalls)
238
+ ? entry.toolCalls
239
+ : [];
240
+ const permissions = Array.isArray(entry.permissions)
241
+ ? entry.permissions
242
+ : [];
243
+ const plan = Array.isArray(entry.plan)
244
+ ? entry.plan
245
+ : [];
246
+ const usage = entry.usage && typeof entry.usage === 'object'
247
+ ? entry.usage
248
+ : null;
249
+ const terminals = Array.isArray(entry.terminals)
250
+ ? entry.terminals
251
+ : [];
252
+ if (!id || !agentId || !cwd || !acpSessionId) continue;
253
+ normalized.push({
254
+ id,
255
+ agentId,
256
+ cwd,
257
+ acpSessionId,
258
+ terminalSessionId,
259
+ createdAt,
260
+ title,
261
+ currentModeId,
262
+ availableModes,
263
+ availableCommands,
264
+ configOptions,
265
+ messages,
266
+ toolCalls,
267
+ permissions,
268
+ plan,
269
+ usage,
270
+ terminals
271
+ });
272
+ }
273
+ return normalized;
274
+ }
275
+
276
+ export const loadAgentTabs = async () => {
277
+ await init();
278
+ try {
279
+ const content = await fs.readFile(AGENT_TABS_FILE, 'utf-8');
280
+ const parsed = JSON.parse(content);
281
+ if (Array.isArray(parsed)) {
282
+ return normalizeAgentTabs(parsed);
283
+ }
284
+ return normalizeAgentTabs(parsed?.tabs);
285
+ } catch {
286
+ return [];
287
+ }
288
+ };
289
+
290
+ export const saveAgentTabs = async (tabs) => {
291
+ await init();
292
+ const normalized = normalizeAgentTabs(tabs);
293
+ const payload = { tabs: normalized };
294
+ try {
295
+ await fs.writeFile(AGENT_TABS_FILE, JSON.stringify(payload, null, 2));
296
+ } catch (e) {
297
+ console.error('[Persistence] Failed to save agent tabs:', e);
298
+ throw e;
299
+ }
300
+ };
301
+
302
+ // --- ACP Agent Config Persistence ---
303
+
304
+ function normalizeAgentEnv(env) {
305
+ if (!env || typeof env !== 'object') return {};
306
+ const normalized = {};
307
+ for (const [key, value] of Object.entries(env)) {
308
+ if (typeof key !== 'string' || !key.trim()) continue;
309
+ normalized[key.trim()] = typeof value === 'string' ? value : '';
310
+ }
311
+ return normalized;
312
+ }
313
+
314
+ function normalizeAgentConfigs(configs) {
315
+ if (!configs || typeof configs !== 'object') return {};
316
+ const normalized = {};
317
+ for (const [agentId, entry] of Object.entries(configs)) {
318
+ if (typeof agentId !== 'string' || !agentId.trim()) continue;
319
+ normalized[agentId.trim()] = {
320
+ env: normalizeAgentEnv(entry?.env)
321
+ };
322
+ }
323
+ return normalized;
324
+ }
325
+
326
+ export const loadAgentConfigs = async () => {
327
+ await init();
328
+ try {
329
+ const content = await fs.readFile(AGENT_CONFIG_FILE, 'utf-8');
330
+ const parsed = JSON.parse(content);
331
+ return normalizeAgentConfigs(parsed?.agents || parsed);
332
+ } catch {
333
+ return {};
334
+ }
335
+ };
336
+
337
+ export const saveAgentConfigs = async (configs) => {
338
+ await init();
339
+ const normalized = normalizeAgentConfigs(configs);
340
+ const payload = { agents: normalized };
341
+ try {
342
+ await fs.writeFile(AGENT_CONFIG_FILE, JSON.stringify(payload, null, 2));
343
+ } catch (e) {
344
+ console.error('[Persistence] Failed to save agent configs:', e);
345
+ throw e;
346
+ }
347
+ };
348
+
196
349
  // --- Raw Log Persistence ---
197
350
 
198
351
  export const appendSessionLog = async (id, chunk) => {
package/src/server.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import process from 'node:process';
4
5
  import { fileURLToPath } from 'node:url';
@@ -10,9 +11,11 @@ import Koa from 'koa';
10
11
  import serve from 'koa-static';
11
12
  import Router from '@koa/router';
12
13
  import bodyParser from 'koa-bodyparser';
14
+ import { formidable } from 'formidable';
13
15
  import { WebSocketServer } from 'ws';
14
16
 
15
17
  import { TerminalManager } from './terminal-manager.mjs';
18
+ import { AcpManager } from './acp-manager.mjs';
16
19
  import { SystemMonitor } from './system-monitor.mjs';
17
20
  import { config } from './config.mjs';
18
21
  import { authMiddleware, verifyClient } from './auth.mjs';
@@ -27,12 +30,61 @@ const publicDir = path.join(__dirname, '..', 'public');
27
30
  const app = new Koa();
28
31
  const router = new Router();
29
32
  const SERVER_BOOT_ID = `${Date.now()}`;
33
+ const AGENT_ATTACHMENT_FIELD = 'attachments';
34
+ const MAX_AGENT_ATTACHMENTS = 8;
35
+ const MAX_AGENT_ATTACHMENT_SIZE = 10 * 1024 * 1024;
36
+ const MAX_AGENT_ATTACHMENTS_TOTAL_SIZE = 25 * 1024 * 1024;
37
+
30
38
  function debugLog(...args) {
31
39
  if (config.debug) {
32
40
  console.log(...args);
33
41
  }
34
42
  }
35
43
 
44
+ function parseMultipartForm(req, options = {}) {
45
+ return new Promise((resolve, reject) => {
46
+ const form = formidable({
47
+ multiples: true,
48
+ allowEmptyFiles: false,
49
+ maxFiles: MAX_AGENT_ATTACHMENTS,
50
+ maxFileSize: MAX_AGENT_ATTACHMENT_SIZE,
51
+ maxTotalFileSize: MAX_AGENT_ATTACHMENTS_TOTAL_SIZE,
52
+ ...options
53
+ });
54
+ form.parse(req, (error, fields, files) => {
55
+ if (error) {
56
+ reject(error);
57
+ return;
58
+ }
59
+ resolve({ fields, files });
60
+ });
61
+ });
62
+ }
63
+
64
+ function firstFormFieldValue(value) {
65
+ if (Array.isArray(value)) {
66
+ return typeof value[0] === 'string' ? value[0] : '';
67
+ }
68
+ return typeof value === 'string' ? value : '';
69
+ }
70
+
71
+ function normalizePromptAttachments(files) {
72
+ const rawList = Array.isArray(files)
73
+ ? files
74
+ : (files ? [files] : []);
75
+ return rawList
76
+ .filter((file) => file && typeof file === 'object')
77
+ .map((file) => ({
78
+ id: crypto.randomUUID(),
79
+ name: String(file.originalFilename || 'attachment').trim()
80
+ || 'attachment',
81
+ mimeType: String(file.mimetype || '').trim(),
82
+ size: Number.isFinite(file.size) ? file.size : 0,
83
+ tempPath: String(file.filepath || '').trim()
84
+ }))
85
+ .filter((file) => file.tempPath);
86
+ }
87
+
36
88
  app.use(async (ctx, next) => {
37
89
  const origin = ctx.get('Origin');
38
90
  if (origin) {
@@ -130,15 +182,22 @@ app.use(authMiddleware);
130
182
 
131
183
  const systemMonitor = new SystemMonitor();
132
184
  const terminalManager = new TerminalManager();
185
+ const acpManager = new AcpManager({ terminalManager });
133
186
 
134
187
  // Restore sessions
135
188
  (async () => {
136
- const restoredSessions = await persistence.loadSessions();
137
- if (restoredSessions.length > 0) {
138
- console.log(`[Server] Restoring ${restoredSessions.length} sessions...`);
139
- for (const data of restoredSessions) {
140
- terminalManager.createSession(data);
189
+ acpManager.restoring = true;
190
+ try {
191
+ const restoredSessions = await persistence.loadSessions();
192
+ if (restoredSessions.length > 0) {
193
+ console.log(`[Server] Restoring ${restoredSessions.length} sessions...`);
194
+ for (const data of restoredSessions) {
195
+ terminalManager.createSession(data);
196
+ }
141
197
  }
198
+ await acpManager.restoreTabs(new Set(terminalManager.sessions.keys()));
199
+ } finally {
200
+ acpManager.restoring = false;
142
201
  }
143
202
  })();
144
203
 
@@ -201,6 +260,13 @@ router.post('/api/sessions', (ctx) => {
201
260
 
202
261
  router.delete('/api/sessions/:id', async (ctx) => {
203
262
  const { id } = ctx.params;
263
+ const session = terminalManager.getSession(id);
264
+ if (session?.managed?.kind === 'agent-terminal') {
265
+ await acpManager.releaseManagedTerminalSession(id, { destroy: true });
266
+ ctx.status = 204;
267
+ return;
268
+ }
269
+ await acpManager.closeTabsForTerminalSession(id);
204
270
  await terminalManager.removeSession(id);
205
271
  ctx.status = 204;
206
272
  });
@@ -269,6 +335,197 @@ router.put('/api/cluster', async (ctx) => {
269
335
  }
270
336
  });
271
337
 
338
+ router.get('/api/agents', async (ctx) => {
339
+ ctx.body = await acpManager.listState();
340
+ });
341
+
342
+ router.get('/api/agents/config', async (ctx) => {
343
+ ctx.body = {
344
+ configs: await acpManager.listAgentConfigs()
345
+ };
346
+ });
347
+
348
+ router.put('/api/agents/config/:agentId', async (ctx) => {
349
+ const { agentId } = ctx.params;
350
+ const { env, clearEnvKeys } = ctx.request.body || {};
351
+ try {
352
+ const configState = await acpManager.updateAgentConfig(agentId, {
353
+ env: typeof env === 'object' && env ? env : {},
354
+ clearEnvKeys: Array.isArray(clearEnvKeys) ? clearEnvKeys : []
355
+ });
356
+ ctx.body = {
357
+ config: configState,
358
+ definitions: await acpManager.listDefinitions()
359
+ };
360
+ } catch (error) {
361
+ ctx.status = 400;
362
+ ctx.body = {
363
+ error: error?.message || 'Failed to save agent config'
364
+ };
365
+ }
366
+ });
367
+
368
+ router.delete('/api/agents/config/:agentId', async (ctx) => {
369
+ const { agentId } = ctx.params;
370
+ try {
371
+ const configState = await acpManager.clearAgentConfig(agentId);
372
+ ctx.body = {
373
+ config: configState,
374
+ definitions: await acpManager.listDefinitions()
375
+ };
376
+ } catch (error) {
377
+ ctx.status = 400;
378
+ ctx.body = {
379
+ error: error?.message || 'Failed to clear agent config'
380
+ };
381
+ }
382
+ });
383
+
384
+ router.post('/api/agents/tabs', async (ctx) => {
385
+ const { agentId, cwd, terminalSessionId, modeId } = ctx.request.body || {};
386
+ if (!agentId || typeof agentId !== 'string') {
387
+ ctx.status = 400;
388
+ ctx.body = { error: 'agentId is required' };
389
+ return;
390
+ }
391
+ if (!cwd || typeof cwd !== 'string') {
392
+ ctx.status = 400;
393
+ ctx.body = { error: 'cwd is required' };
394
+ return;
395
+ }
396
+
397
+ try {
398
+ ctx.status = 201;
399
+ ctx.body = await acpManager.createTab({
400
+ agentId,
401
+ cwd,
402
+ terminalSessionId: typeof terminalSessionId === 'string'
403
+ ? terminalSessionId
404
+ : '',
405
+ modeId: typeof modeId === 'string' ? modeId : ''
406
+ });
407
+ } catch (error) {
408
+ ctx.status = 500;
409
+ ctx.body = { error: error?.message || 'Failed to create agent tab' };
410
+ }
411
+ });
412
+
413
+ router.post('/api/agents/tabs/:tabId/prompt', async (ctx) => {
414
+ const { tabId } = ctx.params;
415
+ let text = '';
416
+ let attachments = [];
417
+
418
+ if (ctx.is('multipart')) {
419
+ try {
420
+ const { fields, files } = await parseMultipartForm(ctx.req);
421
+ text = firstFormFieldValue(fields?.text);
422
+ attachments = normalizePromptAttachments(
423
+ files?.[AGENT_ATTACHMENT_FIELD]
424
+ );
425
+ } catch (error) {
426
+ ctx.status = 400;
427
+ ctx.body = {
428
+ error: error?.message || 'Failed to parse prompt attachments'
429
+ };
430
+ return;
431
+ }
432
+ } else {
433
+ const body = ctx.request.body || {};
434
+ text = typeof body.text === 'string' ? body.text : '';
435
+ }
436
+
437
+ if (!text.trim() && attachments.length === 0) {
438
+ ctx.status = 400;
439
+ ctx.body = { error: 'text or attachments are required' };
440
+ return;
441
+ }
442
+ try {
443
+ await acpManager.sendPrompt(tabId, text, attachments);
444
+ ctx.status = 202;
445
+ ctx.body = { ok: true };
446
+ } catch (error) {
447
+ ctx.status = 500;
448
+ ctx.body = { error: error?.message || 'Failed to send prompt' };
449
+ }
450
+ });
451
+
452
+ router.post('/api/agents/tabs/:tabId/cancel', async (ctx) => {
453
+ const { tabId } = ctx.params;
454
+ try {
455
+ await acpManager.cancel(tabId);
456
+ ctx.status = 202;
457
+ ctx.body = { ok: true };
458
+ } catch (error) {
459
+ ctx.status = 500;
460
+ ctx.body = { error: error?.message || 'Failed to cancel prompt' };
461
+ }
462
+ });
463
+
464
+ router.post(
465
+ '/api/agents/tabs/:tabId/permissions/:permissionId',
466
+ async (ctx) => {
467
+ const { tabId, permissionId } = ctx.params;
468
+ const { optionId } = ctx.request.body || {};
469
+ try {
470
+ await acpManager.resolvePermission(
471
+ tabId,
472
+ permissionId,
473
+ typeof optionId === 'string' ? optionId : ''
474
+ );
475
+ ctx.status = 200;
476
+ ctx.body = { ok: true };
477
+ } catch (error) {
478
+ ctx.status = 500;
479
+ ctx.body = {
480
+ error: error?.message || 'Failed to resolve permission'
481
+ };
482
+ }
483
+ }
484
+ );
485
+
486
+ router.post('/api/agents/tabs/:tabId/mode', async (ctx) => {
487
+ const { tabId } = ctx.params;
488
+ const { modeId } = ctx.request.body || {};
489
+ if (!modeId || typeof modeId !== 'string') {
490
+ ctx.status = 400;
491
+ ctx.body = { error: 'modeId is required' };
492
+ return;
493
+ }
494
+ try {
495
+ ctx.body = await acpManager.setMode(tabId, modeId);
496
+ } catch (error) {
497
+ ctx.status = 500;
498
+ ctx.body = { error: error?.message || 'Failed to switch mode' };
499
+ }
500
+ });
501
+
502
+ router.post('/api/agents/tabs/:tabId/config', async (ctx) => {
503
+ const { tabId } = ctx.params;
504
+ const { configId, valueId } = ctx.request.body || {};
505
+ if (!configId || typeof configId !== 'string') {
506
+ ctx.status = 400;
507
+ ctx.body = { error: 'configId is required' };
508
+ return;
509
+ }
510
+ if (!valueId || typeof valueId !== 'string') {
511
+ ctx.status = 400;
512
+ ctx.body = { error: 'valueId is required' };
513
+ return;
514
+ }
515
+ try {
516
+ ctx.body = await acpManager.setConfigOption(tabId, configId, valueId);
517
+ } catch (error) {
518
+ ctx.status = 500;
519
+ ctx.body = { error: error?.message || 'Failed to update agent setting' };
520
+ }
521
+ });
522
+
523
+ router.delete('/api/agents/tabs/:tabId', async (ctx) => {
524
+ const { tabId } = ctx.params;
525
+ await acpManager.closeTab(tabId);
526
+ ctx.status = 204;
527
+ });
528
+
272
529
  // Middleware
273
530
  app.use(router.routes());
274
531
  app.use(router.allowedMethods());
@@ -288,7 +545,21 @@ httpServer.on('upgrade', (request, socket, head) => {
288
545
  const url = new URL(request.url, `http://${request.headers.host}`);
289
546
  const pathname = url.pathname;
290
547
 
291
- if (pathname.startsWith('/ws/')) {
548
+ if (pathname.startsWith('/ws/agents/')) {
549
+ const match = pathname.match(/^\/ws\/agents\/([a-zA-Z0-9-]+)$/);
550
+ if (!match) {
551
+ socket.destroy();
552
+ return;
553
+ }
554
+
555
+ const tabId = match[1];
556
+ wss.handleUpgrade(request, socket, head, (ws) => {
557
+ wss.emit('connection', ws, {
558
+ kind: 'agent',
559
+ tabId
560
+ });
561
+ });
562
+ } else if (pathname.startsWith('/ws/')) {
292
563
  const match = pathname.match(/^\/ws\/([a-zA-Z0-9-]+)$/);
293
564
  if (!match) {
294
565
  socket.destroy();
@@ -305,20 +576,36 @@ httpServer.on('upgrade', (request, socket, head) => {
305
576
  return;
306
577
  }
307
578
  const ua = request.headers['user-agent'] || 'Unknown';
308
- wss.emit('connection', ws, session, ua);
579
+ wss.emit('connection', ws, {
580
+ kind: 'terminal',
581
+ session,
582
+ ua
583
+ });
309
584
  });
310
585
  } else {
311
586
  socket.destroy();
312
587
  }
313
588
  });
314
589
 
315
- wss.on('connection', (socket, session, ua) => {
590
+ wss.on('connection', (socket, target) => {
316
591
  socket.isAlive = true;
317
592
  socket.on('pong', () => {
318
593
  socket.isAlive = true;
319
594
  });
320
- debugLog(`[Server] WebSocket connected to session ${session.id} [${ua}]`);
321
- session.attach(socket);
595
+ if (target.kind === 'terminal') {
596
+ debugLog(
597
+ `[Server] WebSocket connected to session `
598
+ + `${target.session.id} [${target.ua}]`
599
+ );
600
+ target.session.attach(socket);
601
+ return;
602
+ }
603
+ if (target.kind === 'agent') {
604
+ debugLog(
605
+ `[Server] WebSocket connected to agent tab ${target.tabId}`
606
+ );
607
+ acpManager.attachSocket(target.tabId, socket);
608
+ }
322
609
  });
323
610
 
324
611
  const heartbeatInterval = setInterval(() => {
@@ -383,6 +670,7 @@ function shutdown(signal) {
383
670
  }
384
671
  wss.close();
385
672
  terminalManager.dispose();
673
+ void acpManager.dispose();
386
674
 
387
675
  const forceExitTimer = setTimeout(() => {
388
676
  console.warn('Forced shutdown after timeout.');