openteam 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/openteam.js CHANGED
@@ -8,7 +8,7 @@ import { createRequire } from 'module';
8
8
  import { program } from 'commander';
9
9
  import {
10
10
  cmdStart, cmdAttach, cmdList, cmdStop,
11
- cmdStatus, cmdMonitor, cmdDashboard,
11
+ cmdStatus, cmdMonitor, cmdDashboard, cmdAgentAttach,
12
12
  } from '../src/interfaces/cli.js';
13
13
 
14
14
  const require = createRequire(import.meta.url);
@@ -64,7 +64,13 @@ program
64
64
  .option('--dir <directory>', '项目目录')
65
65
  .action(cmdDashboard);
66
66
 
67
- // 内部命令(不在帮助中显示)
67
+ // 内部命令(不在帮助中显示,由 layout / daemon 自动调用)
68
+ program
69
+ .command('agent-attach <team> <agent>', { hidden: true })
70
+ .description('等待 agent 会话就绪后 attach')
71
+ .option('--dir <directory>', '项目目录')
72
+ .action(cmdAgentAttach);
73
+
68
74
  program
69
75
  .command('daemon <team>', { hidden: true })
70
76
  .option('--port <port>', 'serve 端口', parseInt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openteam",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Agent-centric team collaboration for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -130,7 +130,7 @@ export function saveRuntime(teamName, projectDir, runtimeData) {
130
130
  }
131
131
 
132
132
  /**
133
- * Clear runtime configuration(保留 projectDir/team/sessions)
133
+ * Clear runtime configuration(保留 projectDir/team/sessions/started
134
134
  */
135
135
  export function clearRuntime(teamName, projectDir) {
136
136
  const state = loadState(teamName, projectDir);
@@ -138,6 +138,7 @@ export function clearRuntime(teamName, projectDir) {
138
138
  saveState(teamName, projectDir, {
139
139
  projectDir: state.projectDir,
140
140
  team: state.team,
141
+ started: state.started,
141
142
  sessions: state.sessions || {},
142
143
  });
143
144
  }
@@ -292,7 +293,7 @@ export function removeInstance(teamName, projectDir, agentName, { cwd, alias })
292
293
 
293
294
  /**
294
295
  * 扫描团队的所有实例(含运行中和已停止的)
295
- * @returns {Array<{ projectDir: string, runtime: object|null, hash: string, alive: boolean }>}
296
+ * @returns {Array<{ projectDir: string, runtime: object|null, hash: string, alive: boolean, started: string|null }>}
296
297
  */
297
298
  export function listInstances(teamName) {
298
299
  const teamDir = getTeamDir(teamName);
@@ -326,6 +327,7 @@ export function listInstances(teamName) {
326
327
  runtime: alive ? runtime : null,
327
328
  hash: entry.name,
328
329
  alive,
330
+ started: state.started ?? null,
329
331
  });
330
332
  } catch {}
331
333
  }
@@ -245,36 +245,149 @@ function writeZellijTabLayout(sessionName, tabName, cmd) {
245
245
  }
246
246
 
247
247
  /**
248
- * 创建 zellij session,并在默认 tab 中启动 daemon
248
+ * 生成 zellij stacked tab layout,所有 panes 折叠在一个 tab 中
249
+ *
250
+ * stacked pane:只有一个 pane 展开全屏,其余折叠为标题栏。
251
+ * expanded=true 的 pane 启动时默认展开(通常是 leader)。
252
+ *
253
+ * @param {string} sessionName - session 名
254
+ * @param {string} tabName - tab 名
255
+ * @param {Array<{name: string, cmd: string, expanded?: boolean}>} panes - pane 定义
256
+ * @returns {string} layout 文件路径
257
+ */
258
+ function writeZellijStackedTabLayout(sessionName, tabName, panes) {
259
+ const escapedTabName = tabName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
260
+ const paneEntries = panes.map(p => {
261
+ const eName = p.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
262
+ const eCmd = p.cmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
263
+ const attrs = p.expanded ? ' expanded=true' : '';
264
+ return ` pane command="bash" name="${eName}"${attrs} {\n args "-lc" "exec ${eCmd}"\n }`;
265
+ }).join('\n');
266
+
267
+ const layout = `layout {
268
+ tab name="${escapedTabName}" {
269
+ pane size=1 borderless=true {
270
+ plugin location="zellij:tab-bar"
271
+ }
272
+ pane stacked=true {
273
+ ${paneEntries}
274
+ }
275
+ pane size=1 borderless=true {
276
+ plugin location="zellij:status-bar"
277
+ }
278
+ }
279
+ }`;
280
+ const layoutPath = path.join('/tmp', `openteam-zellij-${sessionName}-stacked.kdl`);
281
+ fs.writeFileSync(layoutPath, layout);
282
+ return layoutPath;
283
+ }
284
+
285
+ /**
286
+ * 生成 zellij session layout(daemon only,含 default_tab_template)
287
+ */
288
+ function writeZellijSessionLayout(sessionName, daemonTabName, cmd) {
289
+ const escapedTabName = daemonTabName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
290
+ const escapedCmd = cmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
291
+ const layout = `layout {
292
+ default_tab_template {
293
+ pane size=1 borderless=true {
294
+ plugin location="zellij:tab-bar"
295
+ }
296
+ children
297
+ pane size=1 borderless=true {
298
+ plugin location="zellij:status-bar"
299
+ }
300
+ }
301
+
302
+ tab name="${escapedTabName}" focus=true {
303
+ pane command="bash" name="${escapedTabName}" {
304
+ args "-lc" "exec ${escapedCmd}"
305
+ }
306
+ }
307
+ }`;
308
+ const layoutPath = path.join('/tmp', `openteam-zellij-${sessionName}-session.kdl`);
309
+ fs.writeFileSync(layoutPath, layout);
310
+ return layoutPath;
311
+ }
312
+
313
+ /**
314
+ * 生成 zellij team layout — 同屏显示 dashboard + stacked agents
315
+ *
316
+ * 左侧 30%: daemon(嵌入式 dashboard)
317
+ * 右侧 70%: stacked agents(leader 默认展开,其余折叠为标题栏)
318
+ *
319
+ * @param {string} sessionName
320
+ * @param {string} daemonCmd - daemon 启动命令
321
+ * @param {Array<{name: string, cmd: string, expanded?: boolean}>} agents
322
+ */
323
+ function writeZellijTeamLayout(sessionName, daemonCmd, agents) {
324
+ const eDaemonCmd = daemonCmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
325
+ const paneEntries = agents.map(a => {
326
+ const eName = a.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
327
+ const eCmd = a.cmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
328
+ const attrs = a.expanded ? ' expanded=true' : '';
329
+ return ` pane command="bash" name="${eName}"${attrs} {\n args "-lc" "exec ${eCmd}"\n }`;
330
+ }).join('\n');
331
+
332
+ const layout = `layout {
333
+ default_tab_template {
334
+ pane size=1 borderless=true {
335
+ plugin location="zellij:tab-bar"
336
+ }
337
+ children
338
+ pane size=1 borderless=true {
339
+ plugin location="zellij:status-bar"
340
+ }
341
+ }
342
+
343
+ tab name="team" focus=true {
344
+ pane split_direction="vertical" {
345
+ pane command="bash" name="daemon" size="30%" {
346
+ args "-lc" "exec ${eDaemonCmd}"
347
+ }
348
+ pane stacked=true {
349
+ ${paneEntries}
350
+ }
351
+ }
352
+ }
353
+ }`;
354
+ const layoutPath = path.join('/tmp', `openteam-zellij-${sessionName}-team.kdl`);
355
+ fs.writeFileSync(layoutPath, layout);
356
+ return layoutPath;
357
+ }
358
+
359
+ /**
360
+ * 创建 zellij session,daemon 作为 command pane 运行
361
+ *
362
+ * 用 -l layout 一步创建 session + daemon tab。
363
+ * background session 没有 focus 状态,rename-tab/close-pane 等依赖 focus 的 action 全部不可靠,
364
+ * 只有 layout 方式能可靠地定义 tab 名称和 command pane。
249
365
  */
250
366
  function bootstrapZellijSession(sessionName, cmd) {
251
367
  const env = cleanMuxEnv();
252
- execSync(`zellij attach "${sessionName}" --create-background`, { stdio: 'ignore', env });
368
+ const layoutPath = writeZellijSessionLayout(sessionName, 'daemon', cmd);
369
+ execSync(`zellij -l "${layoutPath}" attach "${sessionName}" --create-background`, { stdio: 'ignore', env });
253
370
  if (!waitForZellijSession(sessionName)) {
254
371
  throw new Error(`zellij session not ready: ${sessionName}`);
255
372
  }
256
-
257
- const sessionEnv = { ...env, ZELLIJ_SESSION_NAME: sessionName };
258
- execSync('zellij action rename-tab daemon', { stdio: 'ignore', env: sessionEnv });
259
- execSync(`zellij run --in-place -- bash -c 'exec ${cmd.replace(/'/g, "'\\''")}'`, {
260
- stdio: 'ignore',
261
- env: sessionEnv,
262
- });
263
373
  }
264
374
 
265
375
  /**
266
376
  * 统一启动 mux session — 正确处理 tmux/zellij 的根本差异
267
377
  *
268
- * tmux: 先 new-session -d(detached),foreground 时再 attach
269
- * zellij: 先起默认 session,再把 daemon 注入首个 tab,避免覆盖用户自定义 UI
378
+ * tmux: 先 new-session -d(detached),foreground 时再 attach;agent panes 由 daemon 动态创建
379
+ * zellij: layout 一步创建 session(background session focus,不能用 action 操作 pane)
380
+ * - 有 agents: team layout — daemon 左侧 30% + stacked agents 右侧 70%,agent 自行 attach
381
+ * - 无 agents: daemon-only layout(兼容非团队模式)
270
382
  *
271
383
  * @param {'tmux'|'zellij'} mux
272
384
  * @param {string} sessionName
273
- * @param {string} cmd - 首个 pane 运行的命令
385
+ * @param {string} cmd - daemon 命令
274
386
  * @param {object} options
275
387
  * @param {boolean} options.foreground - true = 阻塞直到用户退出
388
+ * @param {Array<{name: string, cmd: string, expanded?: boolean}>} options.agents - agent pane 定义(仅 zellij)
276
389
  */
277
- export function startSession(mux, sessionName, cmd, { foreground = false } = {}) {
390
+ export function startSession(mux, sessionName, cmd, { foreground = false, agents = [] } = {}) {
278
391
  if (mux === 'tmux') {
279
392
  const env = cleanMuxEnv();
280
393
  execSync(`tmux new-session -d -s "${sessionName}" "${cmd}"`, { stdio: 'ignore', env });
@@ -282,7 +395,17 @@ export function startSession(mux, sessionName, cmd, { foreground = false } = {})
282
395
  execSync(`tmux attach -t "${sessionName}"`, { stdio: 'inherit', env });
283
396
  }
284
397
  } else if (mux === 'zellij') {
285
- bootstrapZellijSession(sessionName, cmd);
398
+ if (agents.length > 0) {
399
+ // team layout: daemon + stacked agents 同屏
400
+ const env = cleanMuxEnv();
401
+ const layoutPath = writeZellijTeamLayout(sessionName, cmd, agents);
402
+ execSync(`zellij -l "${layoutPath}" attach "${sessionName}" --create-background`, { stdio: 'ignore', env });
403
+ if (!waitForZellijSession(sessionName)) {
404
+ throw new Error(`zellij session not ready: ${sessionName}`);
405
+ }
406
+ } else {
407
+ bootstrapZellijSession(sessionName, cmd);
408
+ }
286
409
  if (foreground) {
287
410
  attachSession(mux, sessionName);
288
411
  }
@@ -306,9 +429,9 @@ export function addAgentPane(mux, sessionName, cmd, paneName) {
306
429
  }
307
430
  return paneName;
308
431
  } else if (mux === 'zellij') {
309
- const env = { ...cleanMuxEnv(), ZELLIJ_SESSION_NAME: sessionName };
432
+ const env = cleanMuxEnv();
310
433
  const layoutPath = writeZellijTabLayout(sessionName, paneName, cmd);
311
- execSync(`zellij action new-tab --name "${paneName}" --layout "${layoutPath}"`, { stdio: 'ignore', env });
434
+ execSync(`zellij --session "${sessionName}" action new-tab --name "${paneName}" --layout "${layoutPath}"`, { stdio: 'ignore', env });
312
435
  return paneName;
313
436
  }
314
437
  } catch (err) {
@@ -317,6 +440,26 @@ export function addAgentPane(mux, sessionName, cmd, paneName) {
317
440
  }
318
441
  }
319
442
 
443
+ /**
444
+ * 创建 zellij stacked tab(所有 agent panes 折叠在一个 tab 中)
445
+ *
446
+ * @param {string} sessionName - session 名
447
+ * @param {string} tabName - tab 名
448
+ * @param {Array<{name: string, cmd: string, expanded?: boolean}>} panes
449
+ * @returns {boolean}
450
+ */
451
+ export function addStackedTab(sessionName, tabName, panes) {
452
+ try {
453
+ const env = cleanMuxEnv();
454
+ const layoutPath = writeZellijStackedTabLayout(sessionName, tabName, panes);
455
+ execSync(`zellij --session "${sessionName}" action new-tab --layout "${layoutPath}"`, { stdio: 'ignore', env });
456
+ return true;
457
+ } catch (err) {
458
+ log.warn('addStackedTab failed', { sessionName, tabName, error: err.message });
459
+ return false;
460
+ }
461
+ }
462
+
320
463
  /**
321
464
  * 列出 session 中所有 pane 的状态
322
465
  * @returns {Array<{id: string, name: string, alive: boolean, cmd: string}>}
@@ -335,8 +478,8 @@ export function listPanes(mux, sessionName) {
335
478
  return { id, name: name || '', alive: dead !== '1', cmd: cmd || '' };
336
479
  });
337
480
  } else if (mux === 'zellij') {
338
- const env = { ...cleanMuxEnv(), ZELLIJ_SESSION_NAME: sessionName };
339
- const layout = execSync('zellij action dump-layout', { encoding: 'utf8', env });
481
+ const env = cleanMuxEnv();
482
+ const layout = execSync(`zellij --session "${sessionName}" action dump-layout`, { encoding: 'utf8', env });
340
483
  const panes = [];
341
484
  const matches = layout.matchAll(/pane.*?name="([^"]+)"/g);
342
485
  for (const m of matches) {
@@ -360,9 +503,9 @@ export function respawnPane(mux, sessionName, paneId, cmd) {
360
503
  execSync(`tmux respawn-pane -t "${paneId}" -k "${cmd}"`, { stdio: 'ignore', env });
361
504
  return true;
362
505
  } else if (mux === 'zellij') {
363
- const env = { ...cleanMuxEnv(), ZELLIJ_SESSION_NAME: sessionName };
506
+ const env = cleanMuxEnv();
364
507
  const layoutPath = writeZellijTabLayout(sessionName, paneId, cmd);
365
- execSync(`zellij action new-tab --name "${paneId}" --layout "${layoutPath}"`, { stdio: 'ignore', env });
508
+ execSync(`zellij --session "${sessionName}" action new-tab --name "${paneId}" --layout "${layoutPath}"`, { stdio: 'ignore', env });
366
509
  return true;
367
510
  }
368
511
  } catch (err) {
@@ -11,6 +11,7 @@ import {
11
11
  getServeUrl,
12
12
  findAvailablePort,
13
13
  loadActiveSessions,
14
+ getAgentInstances,
14
15
  listRunningInstances,
15
16
  listAllRunningInstances,
16
17
  listAllInstances,
@@ -43,6 +44,23 @@ function padV(str, width) {
43
44
  return str + ' '.repeat(Math.max(0, width - visibleLength(str)));
44
45
  }
45
46
 
47
+ /**
48
+ * ISO 时间戳 → 相对时间("3m ago", "2h ago", "5d ago")
49
+ */
50
+ function relativeTime(isoString) {
51
+ if (!isoString) return '—';
52
+ const diff = Date.now() - new Date(isoString).getTime();
53
+ if (diff < 0) return '—';
54
+ const sec = Math.floor(diff / 1000);
55
+ if (sec < 60) return `${sec}s ago`;
56
+ const min = Math.floor(sec / 60);
57
+ if (min < 60) return `${min}m ago`;
58
+ const hrs = Math.floor(min / 60);
59
+ if (hrs < 24) return `${hrs}h ago`;
60
+ const days = Math.floor(hrs / 24);
61
+ return `${days}d ago`;
62
+ }
63
+
46
64
  function error(msg) {
47
65
  console.error(`${RED}错误:${NC} ${msg}`);
48
66
  process.exit(1);
@@ -138,6 +156,13 @@ export async function cmdStart(teamName, options) {
138
156
 
139
157
  const daemonCmd = `openteam daemon ${teamName} --port ${port} --dir "${projectDir}" --mux ${mux}`;
140
158
 
159
+ // zellij: 构建 agent-attach 命令,由 team layout 的 stacked panes 自动调用
160
+ const agentCmds = mux === 'zellij' ? teamConfig.agents.map(agent => ({
161
+ name: agent,
162
+ cmd: `openteam agent-attach ${teamName} ${agent} --dir "${projectDir}"`,
163
+ expanded: agent === teamConfig.leader,
164
+ })) : [];
165
+
141
166
  info(`启动 ${teamName} 团队...`);
142
167
  console.log(` 复用器: ${mux}`);
143
168
  console.log(` 端口: ${port}`);
@@ -145,9 +170,10 @@ export async function cmdStart(teamName, options) {
145
170
  console.log(` Leader: ${teamConfig.leader}`);
146
171
  console.log('');
147
172
 
148
- // 创建 mux session,pane 0 运行 daemon
149
- // foreground: 前台模式阻塞直到用户退出;detach: 后台创建后立即返回
150
- startSession(mux, sessionName, daemonCmd, { foreground: !options.detach });
173
+ // 创建 mux session
174
+ // zellij: team layout(daemon + stacked agents 同屏)
175
+ // tmux: daemon only,agent panes daemon 动态创建
176
+ startSession(mux, sessionName, daemonCmd, { foreground: !options.detach, agents: agentCmds });
151
177
 
152
178
  if (options.detach) {
153
179
  success('团队已在后台启动');
@@ -214,10 +240,12 @@ export function cmdList(options = {}) {
214
240
  rows.push({
215
241
  id: inst.hash,
216
242
  team: inst.teamName,
243
+ status: `${GREEN}Running${NC}`,
217
244
  project: inst.projectDir,
218
245
  leader,
219
246
  members: others,
220
247
  port: String(inst.runtime.servePort || '—'),
248
+ created: relativeTime(inst.started),
221
249
  });
222
250
  }
223
251
 
@@ -228,8 +256,11 @@ export function cmdList(options = {}) {
228
256
  const { leader, others } = getTeamMembers(inst.teamName);
229
257
  rows.push({
230
258
  id: inst.hash, team: inst.teamName,
259
+ status: 'Stopped',
231
260
  project: inst.projectDir || '—',
232
- leader, members: others, port: '—', stopped: true,
261
+ leader, members: others, port: '—',
262
+ created: relativeTime(inst.started),
263
+ stopped: true,
233
264
  });
234
265
  }
235
266
 
@@ -239,8 +270,12 @@ export function cmdList(options = {}) {
239
270
  for (const teamName of neverRunTeams) {
240
271
  const { leader, others } = getTeamMembers(teamName);
241
272
  rows.push({
242
- id: '—', team: teamName, project: '—',
243
- leader, members: others, port: '', stopped: true,
273
+ id: '—', team: teamName,
274
+ status: 'Created',
275
+ project: '—',
276
+ leader, members: others, port: '—',
277
+ created: '—',
278
+ stopped: true,
244
279
  });
245
280
  }
246
281
  }
@@ -251,8 +286,8 @@ export function cmdList(options = {}) {
251
286
  }
252
287
 
253
288
  // 动态计算列宽(基于可见长度)
254
- const cols = ['id', 'team', 'project', 'leader', 'members', 'port'];
255
- const headers = { id: 'ID', team: 'TEAM', project: 'PROJECT', leader: 'LEADER', members: 'MEMBERS', port: 'PORT' };
289
+ const cols = ['id', 'team', 'status', 'project', 'leader', 'members', 'port', 'created'];
290
+ const headers = { id: 'ID', team: 'TEAM', status: 'STATUS', project: 'PROJECT', leader: 'LEADER', members: 'MEMBERS', port: 'PORT', created: 'CREATED' };
256
291
  const widths = {};
257
292
  for (const col of cols) {
258
293
  widths[col] = Math.max(
@@ -402,3 +437,33 @@ export async function cmdDashboard(teamName, options = {}) {
402
437
  const { dashboard } = await import('./dashboard/index.js');
403
438
  await dashboard(teamName, projectDir);
404
439
  }
440
+
441
+ /**
442
+ * 等待 agent 会话就绪后 attach(由 zellij team layout 自动调用)
443
+ *
444
+ * 轮询 state 文件,直到 serve URL + session ID 可用,然后 exec 到 opencode attach。
445
+ * 超时 120s — 考虑 serve 启动 + session 创建耗时。
446
+ */
447
+ export async function cmdAgentAttach(teamName, agentName, options = {}) {
448
+ const projectDir = options.dir || process.cwd();
449
+ const timeout = 120000;
450
+ const start = Date.now();
451
+
452
+ console.log(`等待 ${agentName} 会话就绪...`);
453
+
454
+ while (Date.now() - start < timeout) {
455
+ const serveUrl = getServeUrl(teamName, projectDir);
456
+ if (serveUrl) {
457
+ const instances = getAgentInstances(teamName, projectDir, agentName);
458
+ if (instances.length > 0) {
459
+ console.log(`连接到 ${agentName}...`);
460
+ execSync(`opencode attach "${serveUrl}" -s "${instances[0].sessionId}"`, { stdio: 'inherit' });
461
+ return;
462
+ }
463
+ }
464
+ await new Promise(r => setTimeout(r, 500));
465
+ }
466
+
467
+ console.error(`${RED}超时:${NC} ${agentName} 会话未在 ${timeout / 1000}s 内就绪`);
468
+ process.exit(1);
469
+ }
@@ -7,10 +7,12 @@
7
7
  * - 嵌入式 dashboard
8
8
  */
9
9
 
10
+ import { execSync } from 'child_process';
10
11
  import { loadTeamConfig, validateTeamConfig } from '../../foundation/config.js';
11
12
  import { saveRuntime, clearRuntime, findAvailablePort } from '../../foundation/state.js';
12
13
  import { DEFAULTS, getSessionName } from '../../foundation/constants.js';
13
14
  import { createLogger } from '../../foundation/logger.js';
15
+ import { cleanMuxEnv } from '../../foundation/terminal.js';
14
16
  import { ensureAgent, recoverSessions } from '../../capabilities/lifecycle.js';
15
17
  import { startServe, stopServe, onServeCrash } from './serve.js';
16
18
  import { createAllAgentPanes, checkAndRespawn } from './panes.js';
@@ -86,8 +88,19 @@ export async function runDaemon(teamName, projectDir, options = {}) {
86
88
  }
87
89
 
88
90
  // ── 3. 创建 agent panes ──
89
- console.log('创建 agent panes...');
90
- createAllAgentPanes(muxType, sessionName, agents, serve.url, sessionMap);
91
+ // zellij: layout 已在 startSession 时创建好 stacked agents,agent-attach 自动连接
92
+ // tmux: 需要动态创建 agent window
93
+ if (muxType !== 'zellij') {
94
+ console.log('创建 agent panes...');
95
+ const leader = teamConfig.leader;
96
+ createAllAgentPanes(muxType, sessionName, agents, serve.url, sessionMap, { leaderName: leader });
97
+ try {
98
+ const env = cleanMuxEnv();
99
+ execSync(`tmux select-window -t "${sessionName}:0"`, { stdio: 'ignore', env });
100
+ } catch {
101
+ // ignore
102
+ }
103
+ }
91
104
 
92
105
  // ── 4. serve 崩溃重启 ──
93
106
  let restarting = false;
@@ -6,6 +6,7 @@
6
6
  import { execSync } from 'child_process';
7
7
  import {
8
8
  addAgentPane,
9
+ addStackedTab,
9
10
  cleanMuxEnv,
10
11
  listPanes,
11
12
  respawnPane,
@@ -17,13 +18,48 @@ const log = createLogger('daemon:panes');
17
18
 
18
19
  /**
19
20
  * 为所有 agent 创建 pane
21
+ *
22
+ * zellij: 一个 stacked tab,所有 agent 折叠在一起,leader 默认展开
23
+ * tmux: 每个 agent 一个 window(无 stacked 概念)
24
+ *
20
25
  * @param {string} mux - 复用器类型
21
26
  * @param {string} sessionName - mux session 名
22
27
  * @param {string[]} agents - agent 列表
23
28
  * @param {string} serveUrl - serve URL
24
29
  * @param {Map<string, string>} sessionMap - agentName → sessionId
30
+ * @param {object} [options]
31
+ * @param {string} [options.leaderName] - leader 名称(zellij stacked 模式下默认展开)
25
32
  */
26
- export function createAllAgentPanes(mux, sessionName, agents, serveUrl, sessionMap) {
33
+ export function createAllAgentPanes(mux, sessionName, agents, serveUrl, sessionMap, options = {}) {
34
+ const { leaderName } = options;
35
+
36
+ if (mux === 'zellij') {
37
+ // zellij: 所有 agent 放入一个 stacked tab,leader 默认展开
38
+ const panes = [];
39
+ for (const agent of agents) {
40
+ const sessionId = sessionMap.get(agent);
41
+ if (!sessionId) {
42
+ log.warn(`skip pane for ${agent}: no session`);
43
+ continue;
44
+ }
45
+ panes.push({
46
+ name: agent,
47
+ cmd: buildAttachCmd(serveUrl, sessionId),
48
+ expanded: agent === leaderName,
49
+ });
50
+ }
51
+ if (panes.length > 0) {
52
+ const ok = addStackedTab(sessionName, 'team', panes);
53
+ if (ok) {
54
+ log.info(`stacked tab created with ${panes.length} agents`);
55
+ } else {
56
+ log.error('failed to create stacked tab');
57
+ }
58
+ }
59
+ return;
60
+ }
61
+
62
+ // tmux: 保持现有逻辑 — 每个 agent 一个 window
27
63
  let first = true;
28
64
  for (const agent of agents) {
29
65
  const sessionId = sessionMap.get(agent);
@@ -34,7 +70,7 @@ export function createAllAgentPanes(mux, sessionName, agents, serveUrl, sessionM
34
70
  const cmd = buildAttachCmd(serveUrl, sessionId);
35
71
 
36
72
  // 第一个 agent 开新 window,与 daemon pane 0 隔离
37
- if (first && mux === 'tmux') {
73
+ if (first) {
38
74
  try {
39
75
  const env = cleanMuxEnv();
40
76
  execSync(`tmux new-window -t "${sessionName}" -n "${agent}" "${cmd}"`, { stdio: 'ignore', env });
@@ -44,6 +44,7 @@ export async function fetchAgentStatus(teamName, projectDir, serveUrl) {
44
44
  try {
45
45
  const exists = await sessionExists(serveUrl, inst.sessionId);
46
46
  const session = exists ? await fetchSession(serveUrl, inst.sessionId) : null;
47
+ const activity = exists ? await detectAgentActivity(serveUrl, inst.sessionId) : 'idle';
47
48
 
48
49
  agentStatuses.push({
49
50
  name: agent,
@@ -51,6 +52,7 @@ export async function fetchAgentStatus(teamName, projectDir, serveUrl) {
51
52
  cwd: inst.cwd || 'N/A',
52
53
  online: exists,
53
54
  title: session?.title || 'Unknown',
55
+ activity,
54
56
  });
55
57
  } catch (err) {
56
58
  agentStatuses.push({
@@ -59,6 +61,7 @@ export async function fetchAgentStatus(teamName, projectDir, serveUrl) {
59
61
  cwd: inst.cwd || 'N/A',
60
62
  online: false,
61
63
  title: 'Error',
64
+ activity: 'idle',
62
65
  error: err.message,
63
66
  });
64
67
  }
@@ -155,6 +158,33 @@ export async function fetchMessageStream(teamName, projectDir, serveUrl, limit =
155
158
  }
156
159
  }
157
160
 
161
+ /**
162
+ * 检测 agent 活动状态
163
+ *
164
+ * 规则(基于最后一条消息):
165
+ * - 无消息 → idle(刚创建)
166
+ * - assistant + finish → idle(待机,已完成回复)
167
+ * - assistant 无 finish → outputting(输出中,正在生成)
168
+ * - user → thinking(思考中,等待模型响应)
169
+ */
170
+ async function detectAgentActivity(serveUrl, sessionId) {
171
+ try {
172
+ const messages = await fetchMessages(serveUrl, sessionId);
173
+ if (!messages || messages.length === 0) return 'idle';
174
+
175
+ const last = messages[messages.length - 1];
176
+ const role = last.info?.role;
177
+
178
+ if (role === 'user') return 'thinking';
179
+ if (role === 'assistant') {
180
+ return last.info?.finish ? 'idle' : 'outputting';
181
+ }
182
+ return 'idle';
183
+ } catch {
184
+ return 'idle';
185
+ }
186
+ }
187
+
158
188
  /**
159
189
  * 从消息对象中提取第一段文本
160
190
  */
@@ -208,6 +208,12 @@ export function updateTeamStatus(box, teamStatus) {
208
208
 
209
209
  /**
210
210
  * 更新 Agent 状态
211
+ *
212
+ * 活动指示器:
213
+ * ● 待机 (green) — idle,已完成回复或刚创建
214
+ * ● 思考中 (yellow) — thinking,收到消息等待模型响应
215
+ * ● 输出中 (cyan) — outputting,模型正在生成回复
216
+ * ○ 离线 (red) — offline,会话不存在
211
217
  */
212
218
  export function updateAgentStatus(box, agentStatuses) {
213
219
  if (agentStatuses.length === 0) {
@@ -216,16 +222,33 @@ export function updateAgentStatus(box, agentStatuses) {
216
222
  }
217
223
 
218
224
  const lines = agentStatuses.map((agent) => {
219
- const status = agent.online ? '{green-fg}●{/green-fg}' : '{red-fg}○{/red-fg}';
220
- const name = agent.name.padEnd(15);
221
- const sessionId = agent.sessionId.slice(0, 12).padEnd(12);
225
+ const indicator = formatActivity(agent);
226
+ const name = agent.name.padEnd(12);
227
+ const sessionId = agent.sessionId.slice(0, 8);
222
228
  const cwd = agent.cwd.length > 40 ? '...' + agent.cwd.slice(-37) : agent.cwd;
223
229
 
224
- return `${status} ${name} ${sessionId} ${cwd}`;
230
+ return `${indicator} ${name} ${sessionId} ${cwd}`;
225
231
  });
226
232
 
227
- const header = '{bold}状态 Agent 会话ID 工作目录{/bold}';
228
- box.setContent([header, ...lines].join('\n'));
233
+ box.setContent(lines.join('\n'));
234
+ }
235
+
236
+ /**
237
+ * 格式化 agent 活动状态指示器
238
+ *
239
+ * 所有变体对齐到 8 个可见列宽(CJK 字符占 2 列):
240
+ * "● 待机" = 6 列 + 2 空格 = 8
241
+ * "● 思考中" = 8 列 = 8
242
+ * "● 输出中" = 8 列 = 8
243
+ * "○ 离线" = 6 列 + 2 空格 = 8
244
+ */
245
+ function formatActivity(agent) {
246
+ if (!agent.online) return '{red-fg}○ 离线{/red-fg} ';
247
+ switch (agent.activity) {
248
+ case 'thinking': return '{yellow-fg}● 思考中{/yellow-fg}';
249
+ case 'outputting': return '{cyan-fg}● 输出中{/cyan-fg}';
250
+ default: return '{green-fg}● 待机{/green-fg} ';
251
+ }
229
252
  }
230
253
 
231
254
  /**