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 +8 -2
- package/package.json +1 -1
- package/src/foundation/state.js +4 -2
- package/src/foundation/terminal.js +163 -20
- package/src/interfaces/cli.js +73 -8
- package/src/interfaces/daemon/index.js +15 -2
- package/src/interfaces/daemon/panes.js +38 -2
- package/src/interfaces/dashboard/data.js +30 -0
- package/src/interfaces/dashboard/ui.js +29 -6
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
package/src/foundation/state.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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:
|
|
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 -
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
339
|
-
const layout = execSync(
|
|
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 =
|
|
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) {
|
package/src/interfaces/cli.js
CHANGED
|
@@ -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
|
|
149
|
-
//
|
|
150
|
-
|
|
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: '—',
|
|
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,
|
|
243
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
220
|
-
const name = agent.name.padEnd(
|
|
221
|
-
const sessionId = agent.sessionId.slice(0,
|
|
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 `${
|
|
230
|
+
return `${indicator} ${name} ${sessionId} ${cwd}`;
|
|
225
231
|
});
|
|
226
232
|
|
|
227
|
-
|
|
228
|
-
|
|
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
|
/**
|