agentworkshop 0.7.4 → 0.7.6
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/cli/commands/stop.mjs +108 -0
- package/cli/infra.mjs +108 -109
- package/config.yml +1 -1
- package/package.json +1 -1
- package/scripts/e2e-aw-tui-global.mjs +277 -0
- package/scripts/start.mjs +31 -8
- package/scripts/test-tui-commands.mjs +17 -0
- package/scripts/tui-smoke.mjs +13 -4
- package/shared/config/single-instance.mjs +129 -86
- package/tui/README.md +11 -2
- package/tui/aw-tui.mjs +64 -14
- package/tui/commands/index.mjs +43 -6
- package/tui/components/status-bar.mjs +4 -0
- package/tui/lib/config.mjs +62 -58
- package/tui/lib/pickers.mjs +102 -0
- package/tui/lib/state.mjs +6 -0
package/scripts/start.mjs
CHANGED
|
@@ -53,21 +53,44 @@ const prodPort = eff.effective['server.prod.port'] ?? 3000
|
|
|
53
53
|
const portSource = eff.sources['server.prod.port']
|
|
54
54
|
|
|
55
55
|
// ---- 单实例互斥(hardening ST-1):同配置根双开直接退出码 2,防 SQLite 锁崩溃 ----
|
|
56
|
-
|
|
57
|
-
const
|
|
56
|
+
// ---- 单实例互斥 + 自动顶替:同配置根已有实例 → 自动停止后重启新实例(防 SQLite 锁崩溃) ----
|
|
57
|
+
const { acquireLock, checkPort, terminatePid } = await import('../shared/config/single-instance.mjs')
|
|
58
|
+
let requestedPort = argPort ? Number(argPort) : prodPort
|
|
59
|
+
let lock = acquireLock(rm.configRoot, { mode: `prod:${rm.mode}`, port: requestedPort })
|
|
58
60
|
if (!lock.ok) {
|
|
59
61
|
const h = lock.holder
|
|
60
|
-
console.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
console.log(`› 发现已运行实例(pid=${h.pid}${h.port ? `,端口=${h.port}` : ''},启动于 ${h.startedAt ?? '未知'})— 自动停止后重启 ...`)
|
|
63
|
+
const stopped = await terminatePid(h.pid).catch(() => false)
|
|
64
|
+
if (!stopped) {
|
|
65
|
+
console.error(`✖ 旧实例(pid=${h.pid})自动停止失败,请手动执行 aw stop 或 taskkill /PID ${h.pid} /T /F`)
|
|
66
|
+
process.exit(2)
|
|
67
|
+
}
|
|
68
|
+
lock = acquireLock(rm.configRoot, { mode: `prod:${rm.mode}`, port: requestedPort })
|
|
69
|
+
if (!lock.ok) {
|
|
70
|
+
console.error(`✖ 旧实例已停止但锁仍被占用(可能并发启动):pid=${lock.holder?.pid ?? '?'}`)
|
|
71
|
+
process.exit(2)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- 端口顺延:配置端口被任意进程占用(含其他配置根的实例)→ 逐个 +1(最多 10 次) ----
|
|
76
|
+
let port = requestedPort
|
|
77
|
+
let bumped = 0
|
|
78
|
+
while (bumped < 10 && await checkPort(host, port)) {
|
|
79
|
+
console.log(`› 端口 ${port} 被占用,顺延至 ${port + 1} ...`)
|
|
80
|
+
port += 1
|
|
81
|
+
bumped += 1
|
|
82
|
+
}
|
|
83
|
+
if (bumped >= 10) {
|
|
84
|
+
console.error(`✖ ${host} 上 ${requestedPort}-${port} 全部被占用,无法启动`)
|
|
85
|
+
process.exit(1)
|
|
64
86
|
}
|
|
87
|
+
if (bumped > 0) console.log(`✔ 使用顺延端口 ${port}(配置端口 ${requestedPort} 被占用)`)
|
|
65
88
|
|
|
66
89
|
process.env.HOST = process.env.HOST || process.env.NITRO_HOST || String(host)
|
|
67
|
-
process.env.PORT = process.env.PORT || process.env.NITRO_PORT || String(
|
|
90
|
+
process.env.PORT = process.env.PORT || process.env.NITRO_PORT || String(port)
|
|
68
91
|
process.env.NITRO_HOST = process.env.HOST
|
|
69
92
|
process.env.NITRO_PORT = process.env.PORT
|
|
70
93
|
|
|
71
|
-
console.log(`[config] 生产服务启动 -> http://${process.env.HOST}:${process.env.PORT} (模式: ${rm.mode}, port source: ${portSource})`)
|
|
94
|
+
console.log(`[config] 生产服务启动 -> http://${process.env.HOST}:${process.env.PORT} (模式: ${rm.mode}, port source: ${portSource}${bumped > 0 ? ' + 顺延' : ''})`)
|
|
72
95
|
|
|
73
96
|
await import('../.output/server/index.mjs')
|
|
@@ -39,6 +39,7 @@ function mockCtx() {
|
|
|
39
39
|
tasks: [],
|
|
40
40
|
hitl: [],
|
|
41
41
|
hitlAnswering: null,
|
|
42
|
+
target: null,
|
|
42
43
|
log: [],
|
|
43
44
|
userName: '张伟',
|
|
44
45
|
}
|
|
@@ -123,6 +124,22 @@ ctx.state.hitl = [{ kind: 'omp-dialog', id: 'd1', method: 'confirm', title: '确
|
|
|
123
124
|
await dispatchCommand(ctx, '/hitl 1')
|
|
124
125
|
check('/hitl 进入作答模式', ctx.state.hitlAnswering?.id === 'd1')
|
|
125
126
|
|
|
127
|
+
ctx = mockCtx()
|
|
128
|
+
await dispatchCommand(ctx, '/msg 心跳检查,请确认')
|
|
129
|
+
const msg = ctx.calls.find(c => c.send)
|
|
130
|
+
check('/msg 缺省路由 lead(immediate)', msg?.send?.toAgentId === 'agt-1' && msg?.send?.priority === 'immediate')
|
|
131
|
+
|
|
132
|
+
ctx = mockCtx()
|
|
133
|
+
await dispatchCommand(ctx, '/msg 调度长 收到请回答')
|
|
134
|
+
const msg2 = ctx.calls.find(c => c.send)
|
|
135
|
+
check('/msg 指定成员', msg2?.send?.toAgentId === 'agt-1')
|
|
136
|
+
|
|
137
|
+
ctx = mockCtx()
|
|
138
|
+
ctx.state.target = { agentId: 'agt-1', name: '调度长' }
|
|
139
|
+
await dispatchCommand(ctx, '/task 检查设备')
|
|
140
|
+
const tsk = ctx.calls.find(c => c.task)
|
|
141
|
+
check('/task 目标成员自动成为 assignee', tsk?.task?.assigneeId === 'agt-1')
|
|
142
|
+
|
|
126
143
|
ctx = mockCtx()
|
|
127
144
|
await dispatchCommand(ctx, '/quit')
|
|
128
145
|
check('/quit 触发退出', ctx.calls.some(c => c.quit))
|
package/scripts/tui-smoke.mjs
CHANGED
|
@@ -46,7 +46,14 @@ const type = async (text) => {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
check('TUI 启动就绪横幅', await waitText('TUI 已就绪'))
|
|
49
|
-
|
|
49
|
+
// 启动频道选择器(新交互):有频道 → Esc 自动进第一个;空账户 → 提示创建
|
|
50
|
+
if (await waitText('选择要进入的频道', 8000)) {
|
|
51
|
+
vt.emitInput('\x1b')
|
|
52
|
+
check('启动自动接入首个频道', await waitText('已切换到频道「', 12_000))
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
check('空账户提示创建频道', await waitText('尚无频道', 12_000))
|
|
56
|
+
}
|
|
50
57
|
|
|
51
58
|
await type('/channels')
|
|
52
59
|
check('/channels 列出频道(计数行)', await waitText('个:'))
|
|
@@ -61,7 +68,7 @@ await type('/agents')
|
|
|
61
68
|
check('/agents 成员面渲染(lead 成员行)', await waitText('smoke-调度长(lead)'))
|
|
62
69
|
|
|
63
70
|
await type('你好,这是一条 smoke 测试任务')
|
|
64
|
-
check('
|
|
71
|
+
check('普通文本 = 发布正式任务(回执)', await waitText('✔ 任务已发布', 12_000))
|
|
65
72
|
|
|
66
73
|
await type('/hitl')
|
|
67
74
|
check('/hitl 无待办提示', await waitText('没有待人工处理'))
|
|
@@ -83,9 +90,11 @@ try {
|
|
|
83
90
|
}
|
|
84
91
|
catch { /* 清理失败不判失败 */ }
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
// 退出码必须反映断言结果:失败时直接非零退出(不走 /quit 的 process.exit(0))
|
|
87
94
|
const ok = failures === 0
|
|
95
|
+
console.log(ok ? '\n[tui-smoke] 全部通过,/quit 退出' : `\n[tui-smoke] ${failures} 项失败,直接退出`)
|
|
96
|
+
if (!ok) process.exit(1)
|
|
88
97
|
await type('/quit')
|
|
89
98
|
// /quit 内部 process.exit(0);若未退出(异常)则在此兜底收口
|
|
90
99
|
console.log('[tui-smoke] /quit 未按预期退出进程(异常),兜底退出')
|
|
91
|
-
process.exit(
|
|
100
|
+
process.exit(0)
|
|
@@ -1,86 +1,129 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// 单实例互斥(hardening ST-1)—— 零依赖,CLI / 启动脚本通用。
|
|
3
|
-
// 锁文件 <configRoot>/.runtime/aw.lock 原子创建(wx):
|
|
4
|
-
// { pid, startedAt, mode, port }
|
|
5
|
-
// 已存在且 PID 存活 → 视为已有实例(返回冲突信息,调用方以退出码 2 退出);
|
|
6
|
-
// PID 已死(崩溃/强杀残留) → 自动接管重写。进程退出时 releaseLock 兜底清理。
|
|
7
|
-
// ============================================================
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
-
import { join } from 'node:path'
|
|
10
|
-
|
|
11
|
-
function pidAlive(pid) {
|
|
12
|
-
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
13
|
-
try {
|
|
14
|
-
// signal 0 探活:进程存在但不发信号;ESRCH=不存在,EPERM=存在(他人进程)
|
|
15
|
-
process.kill(pid, 0)
|
|
16
|
-
return true
|
|
17
|
-
}
|
|
18
|
-
catch (err) {
|
|
19
|
-
return err?.code === 'EPERM'
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 单实例互斥(hardening ST-1)—— 零依赖,CLI / 启动脚本通用。
|
|
3
|
+
// 锁文件 <configRoot>/.runtime/aw.lock 原子创建(wx):
|
|
4
|
+
// { pid, startedAt, mode, port }
|
|
5
|
+
// 已存在且 PID 存活 → 视为已有实例(返回冲突信息,调用方以退出码 2 退出);
|
|
6
|
+
// PID 已死(崩溃/强杀残留) → 自动接管重写。进程退出时 releaseLock 兜底清理。
|
|
7
|
+
// ============================================================
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
function pidAlive(pid) {
|
|
12
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
13
|
+
try {
|
|
14
|
+
// signal 0 探活:进程存在但不发信号;ESRCH=不存在,EPERM=存在(他人进程)
|
|
15
|
+
process.kill(pid, 0)
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
return err?.code === 'EPERM'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const waitSync = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 终止进程树并等待其死亡(供 `aw stop` 与 start 的"顶掉旧实例"共用)。
|
|
27
|
+
* Windows:taskkill /T /F;POSIX:SIGTERM 宽限 5s → SIGKILL。
|
|
28
|
+
* @returns {Promise<boolean>} 是否已确认死亡
|
|
29
|
+
*/
|
|
30
|
+
export async function terminatePid(pid) {
|
|
31
|
+
if (!pidAlive(pid)) return true
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
const { spawnSync } = await import('node:child_process')
|
|
34
|
+
const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8' })
|
|
35
|
+
if (r.status !== 0) return !pidAlive(pid)
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 'SIGTERM')
|
|
40
|
+
}
|
|
41
|
+
catch { /* 已死 */ }
|
|
42
|
+
const deadline = Date.now() + 5000
|
|
43
|
+
while (Date.now() < deadline && pidAlive(pid)) waitSync(200)
|
|
44
|
+
if (pidAlive(pid)) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 'SIGKILL')
|
|
47
|
+
}
|
|
48
|
+
catch { /* 已死 */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const deadline = Date.now() + 5000
|
|
52
|
+
while (Date.now() < deadline && pidAlive(pid)) waitSync(200)
|
|
53
|
+
return !pidAlive(pid)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 尝试获取单实例锁。
|
|
58
|
+
* @returns {{ ok: true, lockPath: string, release: () => void }
|
|
59
|
+
* | { ok: false, lockPath: string, holder: { pid: number, startedAt: string, mode: string, port: number | null } }}
|
|
60
|
+
*/
|
|
61
|
+
export function acquireLock(configRoot, { mode = 'prod', port = null } = {}) {
|
|
62
|
+
const runtimeDir = join(configRoot, '.runtime')
|
|
63
|
+
const lockPath = join(runtimeDir, 'aw.lock')
|
|
64
|
+
mkdirSync(runtimeDir, { recursive: true })
|
|
65
|
+
if (existsSync(lockPath)) {
|
|
66
|
+
let holder = null
|
|
67
|
+
try {
|
|
68
|
+
holder = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
69
|
+
}
|
|
70
|
+
catch { /* 损坏锁文件按残留处理 */ }
|
|
71
|
+
if (holder && pidAlive(holder.pid)) {
|
|
72
|
+
return { ok: false, lockPath, holder: { ...holder, port: holder.port ?? null } }
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
rmSync(lockPath)
|
|
76
|
+
}
|
|
77
|
+
catch { /* 抢不掉则下方 wx 再失败 */ }
|
|
78
|
+
}
|
|
79
|
+
const me = { pid: process.pid, startedAt: new Date().toISOString(), mode, port }
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(lockPath, JSON.stringify(me, null, 2), { flag: 'wx' })
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
// 与并发启动者竞态:按冲突处理
|
|
85
|
+
let holder = null
|
|
86
|
+
try {
|
|
87
|
+
holder = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
88
|
+
}
|
|
89
|
+
catch { /* ignore */ }
|
|
90
|
+
if (holder && pidAlive(holder.pid)) {
|
|
91
|
+
return { ok: false, lockPath, holder: { ...holder, port: holder.port ?? null } }
|
|
92
|
+
}
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
let released = false
|
|
96
|
+
const release = () => {
|
|
97
|
+
if (released) return
|
|
98
|
+
released = true
|
|
99
|
+
try {
|
|
100
|
+
const cur = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
101
|
+
if (cur?.pid !== process.pid) return // 锁已被他人接管,不误删
|
|
102
|
+
rmSync(lockPath)
|
|
103
|
+
}
|
|
104
|
+
catch { /* 已不存在/损坏,忽略 */ }
|
|
105
|
+
}
|
|
106
|
+
const releaseAndExit = (code) => {
|
|
107
|
+
release()
|
|
108
|
+
process.exit(code)
|
|
109
|
+
}
|
|
110
|
+
process.once('exit', release)
|
|
111
|
+
process.once('SIGINT', () => releaseAndExit(130))
|
|
112
|
+
process.once('SIGTERM', () => releaseAndExit(143))
|
|
113
|
+
return { ok: true, lockPath, release }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 端口占用探测:返回占用进程 PID(未占用返回 null)。TCP try-listen 后立刻释放。 */
|
|
117
|
+
export async function checkPort(host, port) {
|
|
118
|
+
const net = await import('node:net')
|
|
119
|
+
return new Promise((resolveProbe) => {
|
|
120
|
+
const srv = net.createServer()
|
|
121
|
+
srv.once('error', (err) => {
|
|
122
|
+
resolveProbe(err?.code === 'EADDRINUSE' ? port : null)
|
|
123
|
+
srv.close()
|
|
124
|
+
})
|
|
125
|
+
srv.listen(port, host, () => {
|
|
126
|
+
srv.close(() => resolveProbe(null))
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
}
|
package/tui/README.md
CHANGED
|
@@ -21,10 +21,18 @@ aw tui --url http://127.0.0.1:3000 --token ut-xxxx --channel <名>
|
|
|
21
21
|
┌ 会话时间线(左) ┌ 监控面板(右,/monitor 开启)
|
|
22
22
|
│ ◆ 你 消息 · ◆ Agent 回复 · 任务状态行 │ ┌─ 监控 <agent> · 流式中
|
|
23
23
|
├ HITL 作答卡(/hitl <n> 进入作答时出现)
|
|
24
|
-
├ 状态条:频道 · 成员(忙 n) · HITL 待处理 n · 连接态
|
|
24
|
+
├ 状态条:频道 · 输入目标 · 成员(忙 n) · HITL 待处理 n · 连接态
|
|
25
25
|
└ 输入框(/ 命令补全 · ↑↓ 历史)
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
## 交互
|
|
29
|
+
|
|
30
|
+
- **启动频道选择器**:TUI 就绪后弹出频道列表(↑↓ 移动 · Enter 进入 · Esc 自动进第一个);
|
|
31
|
+
随时可用 `/channel use`(无参)再次呼出。
|
|
32
|
+
- **Tab 选择对话目标**:呼出成员选择器 —— 选「频道(发布任务)」= 普通文本发布正式任务;
|
|
33
|
+
选某成员 = 切换为**通信对话**并自动打开其独立监控面板。状态条实时显示当前输入目标。
|
|
34
|
+
- **输入框语义**:普通文本按目标分流(频道 → 任务 / 成员 → 通信);`/task`、`/msg` 可显式指定。
|
|
35
|
+
|
|
28
36
|
## 命令
|
|
29
37
|
|
|
30
38
|
| 命令 | 说明 |
|
|
@@ -32,9 +40,10 @@ aw tui --url http://127.0.0.1:3000 --token ut-xxxx --channel <名>
|
|
|
32
40
|
| `/help` | 命令列表 |
|
|
33
41
|
| `/channels` | 列出我的频道 |
|
|
34
42
|
| `/channel new <名> [--desc …] [--lead <名>]` | 创建频道(可选内联建 lead) |
|
|
35
|
-
| `/channel use
|
|
43
|
+
| `/channel use [名\|序号]` | 切换频道(无参 = 交互选择器;WS 断点续传) |
|
|
36
44
|
| `/channel add <模板\|名> [--role lead\|worker] [--harness omp] [--config JSON]` | 放置 Agent 实例 |
|
|
37
45
|
| `/agents` | 当前频道成员与实时状态 |
|
|
46
|
+
| `/msg [<agent>] <文本…>` | 发布通信消息(缺省收件人 = 当前目标或 lead) |
|
|
38
47
|
| `/send <agent\|序号> <文本…>` | 向指定 Agent 直发(忙碌时 steer 注入) |
|
|
39
48
|
| `/task <标题…> [--mode goal\|loop\|pipeline] [--assignee <agent>]` | 提交正式任务 |
|
|
40
49
|
| `/tasks` | 任务列表与七态/进度 |
|
package/tui/aw-tui.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { createApi } from './lib/api.mjs'
|
|
|
16
16
|
import { connectAep, connectTerm } from './lib/ws.mjs'
|
|
17
17
|
import { createState, pushLog, withLog } from './lib/state.mjs'
|
|
18
18
|
import { reduceEnvelope, reduceTermFrame } from './lib/reducers.mjs'
|
|
19
|
+
import { openChannelPicker, openTargetPicker } from './lib/pickers.mjs'
|
|
19
20
|
import { dispatchCommand, slashCommandCompletions } from './commands/index.mjs'
|
|
20
21
|
import { ChatLog } from './components/chat-log.mjs'
|
|
21
22
|
import { StatusBar } from './components/status-bar.mjs'
|
|
@@ -244,10 +245,24 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
244
245
|
tui.stop()
|
|
245
246
|
process.exit(0)
|
|
246
247
|
},
|
|
248
|
+
/** 交互式频道选择(启动/`/channel use` 无参) */
|
|
249
|
+
pickChannel() {
|
|
250
|
+
openChannelPicker(tui, state, {
|
|
251
|
+
onSelect: async (id) => {
|
|
252
|
+
tui.setFocus(editor)
|
|
253
|
+
await actions.switchChannel(id)
|
|
254
|
+
},
|
|
255
|
+
onCancel: async () => {
|
|
256
|
+
tui.setFocus(editor)
|
|
257
|
+
// Esc 兜底:未进入任何频道时自动进第一个
|
|
258
|
+
if (!state.activeChannelId && state.channels[0]) await actions.switchChannel(state.channels[0].id)
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
},
|
|
247
262
|
}
|
|
248
263
|
const ctx = { state, store, api, echo, push, actions }
|
|
249
264
|
|
|
250
|
-
// ── 编辑器提交路由:HITL 作答 → 命令 →
|
|
265
|
+
// ── 编辑器提交路由:HITL 作答 → 命令 → 普通文本按目标分流 ──
|
|
251
266
|
editor.onSubmit = async (text) => {
|
|
252
267
|
const trimmed = text.trim()
|
|
253
268
|
editor.setText('')
|
|
@@ -263,25 +278,53 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
263
278
|
return
|
|
264
279
|
}
|
|
265
280
|
if (!state.activeChannelId) {
|
|
266
|
-
push('error', '
|
|
281
|
+
push('error', '未选择频道:Tab 或 /channel use 打开选择器')
|
|
267
282
|
return
|
|
268
283
|
}
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
284
|
+
// 普通文本按目标分流:选定了成员 = 通信消息(immediate);频道 = 发布正式任务
|
|
285
|
+
if (state.target) {
|
|
286
|
+
const to = state.agents.find(a => a.id === state.target.agentId)
|
|
287
|
+
if (!to) {
|
|
288
|
+
state.target = null
|
|
289
|
+
push('error', '对话目标已失效(成员被移除),已重置为频道模式')
|
|
290
|
+
store.notify()
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
await api.sendMessage(state.activeChannelId, { toAgentId: to.id, text: trimmed, priority: 'immediate', requireReply: true, fromLabel: state.userName || '用户' })
|
|
295
|
+
push('system', `✔ 通信消息已发 → @${to.name}`)
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
push('error', `发送失败:${err.message}`)
|
|
299
|
+
}
|
|
300
|
+
store.notify()
|
|
274
301
|
return
|
|
275
302
|
}
|
|
303
|
+
// 频道模式 = 发布正式任务(状态经 /tasks 跟踪)
|
|
276
304
|
try {
|
|
277
|
-
await api.
|
|
305
|
+
const task = await api.submitTask(state.activeChannelId, { title: trimmed, mode: 'goal' })
|
|
306
|
+
push('system', `✔ 任务已发布:${task?.id?.slice(0, 8) ?? ''}「${trimmed}」`)
|
|
278
307
|
}
|
|
279
308
|
catch (err) {
|
|
280
|
-
push('error',
|
|
309
|
+
push('error', `任务发布失败:${err.message}`)
|
|
281
310
|
}
|
|
282
311
|
store.notify()
|
|
283
312
|
}
|
|
284
313
|
|
|
314
|
+
// ── Tab:呼出对话目标选择器(成员 + 频道模式;选定成员自动开独立监控) ──
|
|
315
|
+
tui.addInputListener((data) => {
|
|
316
|
+
if (data !== '\t' || state.targetPickerOpen) return undefined
|
|
317
|
+
openTargetPicker(tui, state, {
|
|
318
|
+
onPick: (agent) => {
|
|
319
|
+
tui.setFocus(editor)
|
|
320
|
+
if (agent) actions.openMonitor(agent)
|
|
321
|
+
store.notify()
|
|
322
|
+
},
|
|
323
|
+
onCancel: () => tui.setFocus(editor),
|
|
324
|
+
})
|
|
325
|
+
return { consume: true }
|
|
326
|
+
})
|
|
327
|
+
|
|
285
328
|
// ── AEP 事件流 ──
|
|
286
329
|
const seqCursor = new Map()
|
|
287
330
|
const aep = connectAep({
|
|
@@ -311,14 +354,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
311
354
|
push('system', `AgentWorkShop TUI 已就绪 ${state.baseUrl}`)
|
|
312
355
|
push('system', '普通文本 = 发任务到当前频道;/help 命令列表;/hitl 处理待办。')
|
|
313
356
|
|
|
314
|
-
// ──
|
|
357
|
+
// ── 启动:显式 --channel 直接进入;否则 TUI 就绪后弹出频道选择器 ──
|
|
358
|
+
let needPick = false
|
|
315
359
|
try {
|
|
316
360
|
const channels = await api.listChannels()
|
|
317
361
|
state.channels = channels
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
362
|
+
if (opts.channel) {
|
|
363
|
+
const target = channels.find(c => c.name === opts.channel || c.id === opts.channel)
|
|
364
|
+
if (target) await actions.switchChannel(target.id)
|
|
365
|
+
else push('warn', `频道不存在:${opts.channel}(/channels 查看)`)
|
|
366
|
+
}
|
|
367
|
+
else if (channels.length > 0) needPick = true
|
|
322
368
|
else push('warn', '尚无频道:/channel new <名称> 创建一个。')
|
|
323
369
|
const me = await api.get('/api/workshop/users/me').catch(() => null)
|
|
324
370
|
state.userName = me?.user?.name ?? me?.name ?? '用户'
|
|
@@ -328,6 +374,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
328
374
|
}
|
|
329
375
|
|
|
330
376
|
tui.start()
|
|
377
|
+
if (needPick) {
|
|
378
|
+
push('system', '↑↓ 选择要进入的频道,Enter 确认(Esc 自动进入第一个)。')
|
|
379
|
+
actions.pickChannel()
|
|
380
|
+
}
|
|
331
381
|
return tui
|
|
332
382
|
}
|
|
333
383
|
|
package/tui/commands/index.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export const commands = [
|
|
|
34
34
|
description: '命令列表',
|
|
35
35
|
handler(ctx) {
|
|
36
36
|
const rows = commands.map(c => ` /${c.name}${c.usage ? ` ${c.usage}` : ''} — ${c.description}`)
|
|
37
|
-
ctx.echo('system', `可用命令:\n${rows.join('\n')}\n
|
|
37
|
+
ctx.echo('system', `可用命令:\n${rows.join('\n')}\n普通文本按当前目标发送:频道 → 发布正式任务;@成员(Tab 选择) → 通信消息。/msg /task 可显式指定。`)
|
|
38
38
|
},
|
|
39
39
|
},
|
|
40
40
|
{
|
|
@@ -51,8 +51,8 @@ export const commands = [
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
name: 'channel',
|
|
54
|
-
usage: 'new <name> [--desc …] [--lead <名>] | use
|
|
55
|
-
description: '
|
|
54
|
+
usage: 'new <name> [--desc …] [--lead <名>] | use [名|序号] | add <模板|名> [--role lead] [--harness omp] [--config JSON]',
|
|
55
|
+
description: '创建/切换频道(use 不带参数 = 交互选择)、向频道放置 Agent',
|
|
56
56
|
async handler(ctx, args, flags) {
|
|
57
57
|
const sub = args.shift()
|
|
58
58
|
if (sub === 'new') {
|
|
@@ -69,7 +69,11 @@ export const commands = [
|
|
|
69
69
|
}
|
|
70
70
|
else if (sub === 'use') {
|
|
71
71
|
const key = args.shift()
|
|
72
|
-
if (!key)
|
|
72
|
+
if (!key) {
|
|
73
|
+
// 交互式选择(浮层)
|
|
74
|
+
ctx.actions.pickChannel?.()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
73
77
|
const ch = resolveChannel(ctx.state, key)
|
|
74
78
|
if (!ch) return ctx.echo('error', `频道不存在:${key}`)
|
|
75
79
|
await ctx.actions.switchChannel(ch.id)
|
|
@@ -126,10 +130,33 @@ export const commands = [
|
|
|
126
130
|
ctx.echo('system', `✔ 已直发 ${agent.name}`)
|
|
127
131
|
},
|
|
128
132
|
},
|
|
133
|
+
{
|
|
134
|
+
name: 'msg',
|
|
135
|
+
usage: '[<agent>] <文本…>',
|
|
136
|
+
description: '发布通信消息(即时送达,忙碌时 steer 注入;缺省收件人 = 当前对话目标或 lead)',
|
|
137
|
+
async handler(ctx, args) {
|
|
138
|
+
let target = ctx.state.target
|
|
139
|
+
const first = args[0]
|
|
140
|
+
if (first && !ctx.state.agents.some(a => a.name === first || a.id === first) && !/^\d+$/.test(first)) {
|
|
141
|
+
// 首参不是成员 → 整段是文本
|
|
142
|
+
}
|
|
143
|
+
else if (first) {
|
|
144
|
+
const key = args.shift()
|
|
145
|
+
target = resolveAgent(ctx.state, key)
|
|
146
|
+
if (!target) return ctx.echo('error', `成员不存在:${key}(/agents 查看)`)
|
|
147
|
+
}
|
|
148
|
+
const text = args.join(' ')
|
|
149
|
+
if (!text) return ctx.echo('error', '用法:/msg [<agent>] <文本…>')
|
|
150
|
+
const to = target ?? leadOf(ctx.state)
|
|
151
|
+
if (!to) return ctx.echo('error', '频道没有 lead,请指定收件人:/msg <agent> <文本…>')
|
|
152
|
+
await ctx.api.sendMessage(ctx.state.activeChannelId, { toAgentId: to.id, text, priority: 'immediate', requireReply: true, fromLabel: ctx.state.userName || '用户' })
|
|
153
|
+
ctx.echo('system', `✔ 通信消息已发 → @${to.name}`)
|
|
154
|
+
},
|
|
155
|
+
},
|
|
129
156
|
{
|
|
130
157
|
name: 'task',
|
|
131
158
|
usage: '<标题…> [--mode goal|loop|pipeline] [--assignee <agent>]',
|
|
132
|
-
description: '
|
|
159
|
+
description: '发布正式任务(缺省路由 lead,状态经 /tasks 跟踪)',
|
|
133
160
|
async handler(ctx, args, flags) {
|
|
134
161
|
const title = args.join(' ')
|
|
135
162
|
if (!title) return ctx.echo('error', '用法:/task <标题…> [--mode goal|loop|pipeline] [--assignee <agent>]')
|
|
@@ -139,8 +166,11 @@ export const commands = [
|
|
|
139
166
|
if (!a) return ctx.echo('error', `assignee 不存在:${flags.assignee}`)
|
|
140
167
|
body.assigneeId = a.id
|
|
141
168
|
}
|
|
169
|
+
else if (ctx.state.target) {
|
|
170
|
+
body.assigneeId = ctx.state.target.agentId
|
|
171
|
+
}
|
|
142
172
|
const task = await ctx.api.submitTask(ctx.state.activeChannelId, body)
|
|
143
|
-
ctx.echo('system', `✔
|
|
173
|
+
ctx.echo('system', `✔ 任务已发布:${task?.id?.slice(0, 8) ?? ''}「${title}」`)
|
|
144
174
|
},
|
|
145
175
|
},
|
|
146
176
|
{
|
|
@@ -215,6 +245,13 @@ function resolveAgent(state, key) {
|
|
|
215
245
|
return state.agents.find(a => a.name === key || a.id === key) ?? (Number.isInteger(Number.parseInt(key, 10)) ? byIdx : undefined)
|
|
216
246
|
}
|
|
217
247
|
|
|
248
|
+
/** 当前对话目标的收件人解析:目标成员 → lead */
|
|
249
|
+
function leadOf(state) {
|
|
250
|
+
const ch = state.channels.find(c => c.id === state.activeChannelId)
|
|
251
|
+
const leadId = ch?.leadAgentId ?? state.agents.find(a => a.role === 'lead')?.id
|
|
252
|
+
return state.agents.find(a => a.id === leadId) ?? null
|
|
253
|
+
}
|
|
254
|
+
|
|
218
255
|
function parseConfigFlag(raw) {
|
|
219
256
|
if (!raw || raw === true) return undefined
|
|
220
257
|
try {
|
|
@@ -16,8 +16,12 @@ export class StatusBar {
|
|
|
16
16
|
const channel = s.channels.find(c => c.id === s.activeChannelId)
|
|
17
17
|
const members = s.agents.length
|
|
18
18
|
const busy = Object.values(s.agentStates).filter(a => a.state === 'busy').length
|
|
19
|
+
const target = s.target
|
|
20
|
+
? theme.info(`@${s.target.name}(通信)`)
|
|
21
|
+
: theme.accent('频道(任务)')
|
|
19
22
|
const parts = [
|
|
20
23
|
theme.accent(`▣ ${channel?.name ?? '未选择频道'}`),
|
|
24
|
+
`${theme.faint('输入→')} ${target}`,
|
|
21
25
|
members > 0 ? theme.faint(`成员 ${members}(忙 ${busy})`) : theme.faint('成员 0'),
|
|
22
26
|
s.hitl.length > 0 ? theme.warn(`⏸ HITL 待处理 ${s.hitl.length}(/hitl)`) : theme.faint('HITL 0'),
|
|
23
27
|
s.monitor.agentId ? theme.info(`监控:${s.monitor.waiting ? '等待进程' : s.monitor.name}`) : null,
|