agentworkshop 0.7.4 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ // ============================================================
2
+ // 指令:stop —— 终止当前配置根下运行中的 aw 实例(读单实例锁 → 终止进程树)。
3
+ // 用法:aw stop [--home] (--home 强制以 home 配置根为目标;默认随 cwd 解析)
4
+ // ============================================================
5
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { color } from '../core/logger.mjs'
9
+
10
+ export const meta = {
11
+ name: 'stop',
12
+ aliases: [],
13
+ group: '服务',
14
+ summary: '终止运行中的 aw 服务实例(读单实例锁)',
15
+ usage: 'aw stop [--home]',
16
+ needsProject: false,
17
+ }
18
+
19
+ function pidAlive(pid) {
20
+ try {
21
+ process.kill(pid, 0)
22
+ return true
23
+ }
24
+ catch (err) {
25
+ return err?.code === 'EPERM'
26
+ }
27
+ }
28
+
29
+ /** 终止进程树:Windows taskkill /T /F;POSIX 先 SIGTERM 宽限 5s 再 SIGKILL */
30
+ function killTree(pid) {
31
+ if (process.platform === 'win32') {
32
+ const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8' })
33
+ if (r.status !== 0) throw new Error((r.stderr || r.stdout || 'taskkill 失败').trim().split(/\r?\n/)[0])
34
+ return
35
+ }
36
+ try {
37
+ process.kill(pid, 'SIGTERM')
38
+ }
39
+ catch { /* 已死 */ }
40
+ const deadline = Date.now() + 5000
41
+ while (Date.now() < deadline && pidAlive(pid)) {
42
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
43
+ }
44
+ if (pidAlive(pid)) {
45
+ try {
46
+ process.kill(pid, 'SIGKILL')
47
+ }
48
+ catch { /* 已死 */ }
49
+ }
50
+ }
51
+
52
+ export async function run(argv, ctx) {
53
+ // argv 为 parseArgs 结果({ flags, positionals, unknown });兼容裸数组调用
54
+ const flags = argv?.flags ?? {}
55
+ const positionals = Array.isArray(argv?.positionals) ? argv.positionals : (Array.isArray(argv) ? argv : [])
56
+ const forceHome = flags.home === true || positionals.includes('--home')
57
+
58
+ const configRoot = forceHome
59
+ ? ctx.home
60
+ : (ctx.mode === 'repo' ? join(ctx.root, '.AgentWorkShop') : ctx.home)
61
+ const lockPath = join(configRoot, '.runtime', 'aw.lock')
62
+
63
+ if (!existsSync(lockPath)) {
64
+ console.log(`${color.blue('ℹ')} 未发现运行中的实例(无锁文件:${lockPath})`)
65
+ return 0
66
+ }
67
+ let holder
68
+ try {
69
+ holder = JSON.parse(readFileSync(lockPath, 'utf8'))
70
+ }
71
+ catch {
72
+ rmSync(lockPath, { force: true })
73
+ console.log(`${color.blue('ℹ')} 锁文件损坏,已清除:${lockPath}`)
74
+ return 0
75
+ }
76
+
77
+ const pid = Number(holder?.pid)
78
+ if (!Number.isInteger(pid) || pid <= 0) {
79
+ rmSync(lockPath, { force: true })
80
+ console.log(`${color.blue('ℹ')} 锁内 PID 无效,已清除锁文件`)
81
+ return 0
82
+ }
83
+ if (!pidAlive(pid)) {
84
+ rmSync(lockPath, { force: true })
85
+ console.log(`${color.blue('ℹ')} 持锁进程已不存在(pid=${pid},残留锁),已清除锁文件`)
86
+ return 0
87
+ }
88
+
89
+ console.log(`› 终止实例 pid=${pid}(mode=${holder.mode ?? '?'}${holder.port ? `,端口=${holder.port}` : ''},启动于 ${holder.startedAt ?? '?'})`)
90
+ try {
91
+ killTree(pid)
92
+ }
93
+ catch (err) {
94
+ console.error(`${color.red('✖')} 终止失败:${err.message}`)
95
+ console.error(` 可手动执行:taskkill /PID ${pid} /T /F(Windows)`)
96
+ return 1
97
+ }
98
+ // 锁文件由退出钩子自清;残留则手动兜底
99
+ for (let i = 0; i < 20 && existsSync(lockPath); i++) {
100
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
101
+ }
102
+ try {
103
+ if (existsSync(lockPath)) rmSync(lockPath, { force: true })
104
+ }
105
+ catch { /* ignore */ }
106
+ console.log(`${color.green('✔')} 实例已停止(pid=${pid})`)
107
+ return 0
108
+ }
package/config.yml CHANGED
@@ -10,7 +10,7 @@
10
10
  app:
11
11
  name: AgentWorkShop
12
12
  title: AgentWorkShop
13
- version: 0.7.4
13
+ version: 0.7.5
14
14
  description: 基于 Nuxt 4 的配置驱动运行范式
15
15
 
16
16
  # 运行模式(dev/prod 实际由启动命令决定,此处仅作运行时展示)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentworkshop",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "type": "module",
5
5
  "description": "AgentWorkShop 软件开发系统 - 基于 Nuxt 4 的配置驱动运行范式",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -0,0 +1,277 @@
1
+ /**
2
+ * 全局 `aw tui` 完整 E2E(用户真实路径:npm i -g agentworkshop → aw tui):
3
+ * node scripts/e2e-aw-tui-global.mjs [--base http://127.0.0.1:3000]
4
+ *
5
+ * 前置:全局已安装 agentworkshop@0.7.4(npm root -g 可解析);dev/prod 服务运行。
6
+ * 场景:加载【全局包】的 tui 入口(非仓库源码)→ 建频道(内联 omp lead)→
7
+ * 成员列表 → 任务下发(触发 omp spawn)→ /monitor 独立监控接入 →
8
+ * /send 注入 ask → /hitl 定位作答 → REST 确认落定 → 终端帧证实答案
9
+ * 回传 agent → /task 正式任务 + /tasks 列表 → /monitor off → 清理。
10
+ */
11
+ import { execSync } from 'node:child_process'
12
+ import { existsSync } from 'node:fs'
13
+ import { pathToFileURL } from 'node:url'
14
+ import { join } from 'node:path'
15
+
16
+ const BASE = (() => {
17
+ const i = process.argv.indexOf('--base')
18
+ return i > 0 ? process.argv[i + 1] : 'http://127.0.0.1:3000'
19
+ })()
20
+ // --repo:加载仓库源码的 tui(开发验证新交互);默认加载全局安装包(用户真实路径)
21
+ const USE_REPO = process.argv.includes('--repo')
22
+ const WS_BASE = BASE.replace(/^http/, 'ws')
23
+ const TAG = Date.now().toString(36)
24
+ const ASK_INSTRUCTION = 'Use the ask tool NOW to ask me: Deploy? with options go and abort. After I answer, call complete_task with the deliverable set to my answer.'
25
+
26
+ let failures = 0
27
+ let passed = 0
28
+ const check = (name, ok, detail = '') => {
29
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
30
+ if (ok) passed += 1
31
+ else failures += 1
32
+ }
33
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
34
+
35
+ async function waitUntil(name, cond, timeoutMs = 300_000, intervalMs = 1000) {
36
+ const deadline = Date.now() + timeoutMs
37
+ let last = null
38
+ while (Date.now() < deadline) {
39
+ try {
40
+ last = await cond()
41
+ if (last) return last
42
+ }
43
+ catch (e) { last = e }
44
+ await sleep(intervalMs)
45
+ }
46
+ throw new Error(`waitUntil timeout: ${name} (last=${String(last).slice(0, 200)})`)
47
+ }
48
+
49
+ const api = async (method, path, { body, token } = {}) => {
50
+ const headers = { 'content-type': 'application/json' }
51
+ if (token) headers.authorization = `Bearer ${token}`
52
+ const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined })
53
+ return { status: res.status, ...(await res.json().catch(() => ({}))) }
54
+ }
55
+
56
+ /** 终端 WS(答案回传验证用) */
57
+ function openTerm(agentId, channelId, token) {
58
+ const messages = []
59
+ const ws = new WebSocket(`${WS_BASE}/api/system/monitor/terminal/ws?agentId=${agentId}&channelId=${channelId}&token=${encodeURIComponent(token)}`)
60
+ ws.addEventListener('message', (ev) => {
61
+ try {
62
+ messages.push(JSON.parse(ev.data))
63
+ }
64
+ catch { /* ignore */ }
65
+ })
66
+ return { ws, messages, frames: () => messages.filter(m => m.type === 'term.frames').flatMap(m => m.frames) }
67
+ }
68
+
69
+ /** 解析 TUI 入口:--repo = 仓库源码;默认 = 全局安装包(用户 `aw tui` 的真实加载路径) */
70
+ function tuiEntry() {
71
+ if (USE_REPO) {
72
+ const entry = join(process.cwd(), 'tui', 'aw-tui.mjs')
73
+ return { entry, ok: existsSync(entry), repo: true }
74
+ }
75
+ const root = execSync('npm root -g', { encoding: 'utf8' }).trim()
76
+ const entry = join(root, 'agentworkshop', 'tui', 'aw-tui.mjs')
77
+ return { entry, ok: existsSync(entry), repo: false }
78
+ }
79
+
80
+ async function main() {
81
+ console.log(`\n━━━ 全局 aw tui 完整 E2E @ ${BASE} ━━━`)
82
+
83
+ // 0. 入口存在(全局包 = 0.7.4 发布包含 tui/;repo = 源码)
84
+ const g = tuiEntry()
85
+ check(`0.1 TUI 入口存在(${g.repo ? '仓库源码' : '全局包'})`, g.ok, g.entry)
86
+ if (!g.ok) process.exit(1)
87
+
88
+ // 登录
89
+ const reg = await api('POST', '/api/users/register', {
90
+ body: { email: `awtui-${TAG}@test.local`, password: 'Passw0rd!123', name: `awtui-${TAG}` },
91
+ })
92
+ const token = reg.data?.token
93
+ if (!token) throw new Error(`注册失败: ${JSON.stringify(reg).slice(0, 160)}`)
94
+
95
+ // 0.2 预建一个频道(启动频道选择器需要至少一个可选项;全局包模式同样受益)
96
+ const bootCh = (await api('POST', '/api/workshop/channels', {
97
+ body: { name: `awtui-boot-${TAG}`, description: '启动选择器测试频道' },
98
+ token,
99
+ })).data
100
+ check('0.2 预建启动频道', Boolean(bootCh?.channelId), `id=${bootCh?.channelId?.slice(0, 8)}`)
101
+
102
+ // 1. 加载 TUI,headless 驱动
103
+ const mod = await import(pathToFileURL(g.entry).href)
104
+ await mod.main(['--headless', '--url', BASE, '--token', token, '--channel', ''])
105
+ const vt = globalThis.__tuiSmokeTerminal
106
+ const type = async (text) => {
107
+ for (const c of text) vt.emitInput(c)
108
+ await sleep(50)
109
+ vt.emitInput('\r')
110
+ await sleep(400)
111
+ }
112
+ const waitTui = async (needle, timeoutMs = 300_000) => {
113
+ const deadline = Date.now() + timeoutMs
114
+ while (Date.now() < deadline) {
115
+ if (vt.text().includes(needle)) return true
116
+ await sleep(500)
117
+ }
118
+ return false
119
+ }
120
+ check('1.1 TUI 启动就绪', await waitTui('TUI 已就绪', 30_000), g.entry.slice(-60))
121
+
122
+ // 1.2 [新交互] 启动频道选择器:↑↓ + Enter 选择预建频道;Esc 自动进第一个
123
+ if (USE_REPO) {
124
+ check('1.2 启动弹出频道选择器', await waitTui('选择要进入的频道', 20_000))
125
+ vt.emitInput('\x1b[B') // 下移一次,选中第二个(证明方向键导航)
126
+ await sleep(200)
127
+ vt.emitInput('\r')
128
+ check('1.3 Enter 进入所选频道', await waitTui('已切换到频道「', 30_000))
129
+ }
130
+ else {
131
+ check('1.2 (全局包)自动接入频道', await waitTui('已切换到频道「', 30_000))
132
+ }
133
+
134
+ // 2. Channel 操作:列表 + 创建(内联 omp lead)+ 自动切换
135
+ await type('/channels')
136
+ check('2.1 /channels 列出频道', await waitTui('个:', 15_000))
137
+ const channelName = `awtui-e2e-${TAG}`
138
+ await type(`/channel new ${channelName} --lead 领航员 全局tui端到端频道`)
139
+ check('2.2 /channel new 创建成功', await waitTui('✔ 频道已创建', 30_000))
140
+ check('2.3 自动切换到新频道', await waitTui(`已切换到频道「${channelName}」`, 30_000))
141
+
142
+ // 2.4 [新交互] /channel use 无参 → 频道选择器 → 方向键导航到目标频道 → Enter
143
+ // (SelectList 的可打印字符过滤不生效,导航用方向键;序号按 REST 顺序计算)
144
+ if (USE_REPO) {
145
+ await type('/channel use')
146
+ check('2.4 /channel use 弹出频道选择器', await waitTui('选择要进入的频道', 20_000))
147
+ const order = (await api('GET', '/api/workshop/channels', { token })).data ?? []
148
+ const downTimes = Math.max(0, order.findIndex(c => c.name === channelName))
149
+ for (let i = 0; i < downTimes; i++) {
150
+ vt.emitInput('\x1b[B')
151
+ await sleep(150)
152
+ }
153
+ await sleep(200)
154
+ vt.emitInput('\r')
155
+ await sleep(800)
156
+ check('2.5 方向键选择后频道正确', await waitTui(`已切换到频道「${channelName}」`, 20_000))
157
+ }
158
+
159
+ // 3. Agent 操作:成员列表出现内联建的 omp lead(选择器切换后刷新有竞态,轮询重发)
160
+ {
161
+ const ok = await waitUntil('3.1 /agents 出现 lead 成员', async () => {
162
+ await type('/agents')
163
+ return vt.text().includes('领航员(lead)') ? true : null
164
+ }, 30_000).catch(() => null)
165
+ check('3.1 /agents 出现 lead 成员', ok === true)
166
+ }
167
+
168
+ // 4. 任务下发:普通文本 → lead(触发 omp spawn)
169
+ await type('请保持待命,等待操作员通过终端下达进一步指令。')
170
+ console.log(' … 等待 omp spawn(冷启动可达 2-3 分钟)…')
171
+ const ch = (await api('GET', '/api/workshop/channels', { token })).data?.find?.(c => c.name === channelName)
172
+ ?? (await api('GET', '/api/workshop/channels', { token })).data?.at(-1)
173
+ const channelId = ch?.id
174
+ const members = channelId ? (await api('GET', `/api/workshop/channels/${channelId}/agents`, { token })).data ?? [] : []
175
+ const lead = members.find(a => a.role === 'lead')
176
+ check('4.1 频道/lead 实例可查(REST 交叉核对)', Boolean(lead?.id), `lead=${lead?.id?.slice(0, 8)}`)
177
+
178
+ const spawnOk = await waitUntil('4.2 omp 进程 spawn', async () => {
179
+ const mon = await api('GET', '/api/system/monitor', { token })
180
+ return (mon.data?.processes ?? []).find(p => p.harness === 'omp' && p.alive && p.agentId === lead?.id) ? true : null
181
+ }, 300_000).catch(() => null)
182
+ check('4.2 omp 进程 spawn(REST 监控)', spawnOk === true)
183
+
184
+ // 5. 独立 monitor:/monitor 领航员 → 终端面板接入
185
+ await type('/monitor 领航员')
186
+ check('5.1 /monitor 面板开启', await waitTui('监控已开启:领航员', 20_000))
187
+ const termConnected = await waitTui('已接入 领航员', 120_000)
188
+ check('5.2 监控面板接入 omp 会话(term.init)', termConnected)
189
+
190
+ // 6. HITL:等首回合结束 → /send 注入 ask → /hitl 定位作答
191
+ const idleOk = await waitUntil('6.1 lead 首回合结束(idle)', async () => {
192
+ const terms = await api('GET', `/api/workshop/channels/${channelId}/terminals`, { token })
193
+ const mine = (terms.data ?? []).find(t2 => t2.agentId === lead?.id)
194
+ return mine && mine.running === false ? true : null
195
+ }, 300_000).catch(() => null)
196
+ check('6.1 lead 空闲(可注入)', idleOk === true)
197
+ await sleep(1500)
198
+ await type(`/send 领航员 ${ASK_INSTRUCTION}`)
199
+ check('6.2 /send 直发回执', await waitTui('✔ 已直发 领航员', 30_000))
200
+
201
+ const item = await waitUntil('6.3 HITL 待办到达(REST Deploy?)', async () => {
202
+ const p = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
203
+ return (p.data?.items ?? []).find(i => String(i.title).includes('Deploy?')) ?? null
204
+ }, 300_000).catch(() => null)
205
+ check('6.3 omp ask → 全局待办(Deploy?)', Boolean(item), item ? `method=${item.method} options=${JSON.stringify(item.options)}` : 'timeout')
206
+
207
+ await type('/hitl')
208
+ check('6.4 /hitl 列表渲染', await waitTui('待人工处理', 20_000))
209
+ const row = await waitUntil('6.5 列表出现 Deploy? 行', () => {
210
+ const m = vt.text().match(/(\d+)\. \[omp-dialog\] Deploy\?/)
211
+ return m ? m[1] : null
212
+ }, 60_000).catch(() => null)
213
+ check('6.5 目标待办行定位', Boolean(row), `序号=${row}`)
214
+ await type(`/hitl ${row}`)
215
+ check('6.6 进入作答卡', await waitTui('HITL 作答', 20_000))
216
+ await type('1')
217
+ check('6.7 应答提交回执(选项 1 = go)', await waitTui('应答已提交', 30_000))
218
+ if (failures > 0) console.log('[diag 6.7 tail]', JSON.stringify(vt.text().slice(-700)))
219
+
220
+ const cleared = await waitUntil('6.8 REST 待办不再含 Deploy?', async () => {
221
+ const p = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
222
+ return ((p.data?.items ?? []).every(i => !String(i.title).includes('Deploy?'))) ? true : null
223
+ }, 60_000).catch(() => null)
224
+ check('6.8 应答写入服务端(待办落定)', cleared === true)
225
+
226
+ // 6.9 答案回传 agent 铁证:ask 工具结果含 go(独立终端 WS 帧)
227
+ const term = openTerm(lead.id, channelId, token)
228
+ const askFed = await waitUntil('6.9 ask 工具收到 go(终端帧)', () => {
229
+ const list = term.frames().filter(f => f.frame.type === 'tool_execution_end' && String(f.frame.toolName) === 'ask')
230
+ const lastAsk = list.at(-1)
231
+ return lastAsk ? String(lastAsk.frame.result ?? '').toLowerCase().includes('go') : null
232
+ }, 120_000).catch(() => null)
233
+ check('6.9 闭环:答案回传 agent(ask 结果含 go)', Boolean(askFed))
234
+
235
+ // 6.10-6.14 [新交互] Tab 目标选择器 + 通信/任务分流(仓库源码特性)
236
+ if (USE_REPO) {
237
+ vt.emitInput('\t')
238
+ check('6.10 Tab 呼出目标选择器', await waitTui('选择对话目标', 20_000))
239
+ vt.emitInput('\x1b[B')
240
+ await sleep(200)
241
+ vt.emitInput('\r')
242
+ check('6.11 选成员 → 通信模式 + 自动开监控', await waitTui('监控已开启:领航员', 20_000) && await waitTui('@领航员(通信)', 20_000))
243
+ await type('你在吗?看到请回应')
244
+ check('6.12 普通文本按通信直发目标', await waitTui('通信消息已发 → @领航员', 30_000))
245
+ vt.emitInput('\t')
246
+ await waitTui('选择对话目标', 20_000)
247
+ vt.emitInput('\r')
248
+ await sleep(400)
249
+ check('6.13 选频道 → 重置任务模式', await waitTui('频道(任务)', 20_000))
250
+ await type('/msg 心跳检查,请确认收到')
251
+ check('6.14 /msg 通信消息(目标重置后路由 lead)', await waitTui('通信消息已发 → @', 30_000))
252
+ }
253
+
254
+ // 7. 正式任务 + 任务列表
255
+ await type('/task 汇报当前待命状态')
256
+ check('7.1 /task 正式任务提交', await waitTui('✔ 任务已发布', 30_000))
257
+ await type('/tasks')
258
+ check('7.2 /tasks 列表出现任务', await waitTui('汇报当前待命状态', 30_000))
259
+
260
+ // 8. monitor 关闭 + 清理
261
+ await type('/monitor off')
262
+ check('8.1 /monitor off 关闭', await waitTui('监控面板已关闭', 20_000))
263
+ await api('DELETE', `/api/workshop/channels/${channelId}?purge=1`, { token }).catch(() => {})
264
+ await api('DELETE', `/api/workshop/channels/${bootCh.channelId}?purge=1`, { token }).catch(() => {})
265
+
266
+ console.log(`\n━━━ 结果: ${passed} passed / ${failures} failed ━━━`)
267
+ console.log('(经 /quit 退出全局 TUI 进程)')
268
+ if (failures > 0) process.exit(1)
269
+ await type('/quit')
270
+ console.log('[warn] /quit 未按预期退出,兜底退出')
271
+ process.exit(0)
272
+ }
273
+
274
+ main().catch((err) => {
275
+ console.error('FATAL', err.message)
276
+ process.exit(1)
277
+ })
@@ -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))
@@ -46,7 +46,14 @@ const type = async (text) => {
46
46
  }
47
47
 
48
48
  check('TUI 启动就绪横幅', await waitText('TUI 已就绪'))
49
- check('启动自动接入首个频道', await waitText('已切换到频道「', 12_000))
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('普通文本发送(服务端回显用户章)', await waitText('◆ 你', 12_000))
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
- console.log(failures === 0 ? '\n[tui-smoke] 全部通过,/quit 退出' : `\n[tui-smoke] ${failures} 项失败,/quit 退出`)
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(ok ? 0 : 1)
100
+ process.exit(0)
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 <名\|序号>` | 切换频道(WS 断点续传) |
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', '未选择频道:先 /channels + /channel use <名|序号>')
281
+ push('error', '未选择频道:Tab 或 /channel use 打开选择器')
267
282
  return
268
283
  }
269
- // messages 端点要求 toAgentId:普通文本缺省路由 lead
270
- const ch = state.channels.find(c => c.id === state.activeChannelId)
271
- const leadId = ch?.leadAgentId ?? state.agents.find(a => a.role === 'lead')?.id
272
- if (!leadId) {
273
- push('error', '当前频道没有 lead:用 /channel new --lead <名> 重建,或 /send <成员> 指定收件人。')
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.sendMessage(state.activeChannelId, { toAgentId: leadId, text: trimmed, priority: 'task', fromLabel: state.userName || '用户' })
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', `发送失败:${err.message}`)
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
- // 空字符串 --channel 视为未指定('' 非 nullish,?? 不会走右侧)
319
- const target = (opts.channel ? channels.find(c => c.name === opts.channel || c.id === opts.channel) : undefined)
320
- ?? channels[0]
321
- if (target) await actions.switchChannel(target.id)
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
 
@@ -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普通文本 = 发送任务到当前频道(lead);以 Agent 身份发信走 /send。`)
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 <名|序号> | add <模板|名> [--role lead] [--harness omp] [--config JSON]',
55
- description: '创建/切换频道、向频道放置 Agent',
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) return ctx.echo('error', '用法:/channel use <名|序号>(/channels 查看列表)')
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: '提交正式任务(缺省路由 lead,状态经 /tasks 跟踪)',
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', `✔ 任务已提交:${task?.id?.slice(0, 8) ?? ''}「${title}」`)
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,
@@ -0,0 +1,102 @@
1
+ // ============================================================
2
+ // 交互选择器(浮层)—— 频道选择 / Agent 选择。
3
+ // 基于 pi-tui showOverlay + SelectList:方向键移动、Enter 选择、Esc 取消。
4
+ // ============================================================
5
+ import { SelectList } from '@earendil-works/pi-tui'
6
+ import { theme } from '../theme.mjs'
7
+
8
+ /** 组合"标题 + SelectList"的浮层组件。返回 handle { hide, list, component, __bindHandle } */
9
+ function pickerComponent(title, items, maxVisible, onSelect, onCancel) {
10
+ const list = new SelectList(items, maxVisible, theme.editor.selectList)
11
+ const wrap = {
12
+ render(width) {
13
+ return [theme.accent(`┌─ ${title}`), ...list.render(Math.max(width - 2, 24))]
14
+ },
15
+ }
16
+ const handle = { hide() {}, list, component: wrap }
17
+ list.onSelect = (item) => {
18
+ handle.hide()
19
+ onSelect(item)
20
+ }
21
+ list.onCancel = () => {
22
+ handle.hide()
23
+ onCancel?.()
24
+ }
25
+ // 二次装配:showOverlay 返回真实 handle 后回填 hide(调用方调 picker.__bindHandle(real))
26
+ handle.__bindHandle = (h) => {
27
+ handle.hide = () => h.hide()
28
+ }
29
+ return handle
30
+ }
31
+
32
+ /**
33
+ * 频道选择浮层。Enter 选择并切换;Esc 取消(fallback 频道由调用方决定)。
34
+ * @returns handle { close }
35
+ */
36
+ export function openChannelPicker(tui, state, { onSelect, onCancel } = {}) {
37
+ const items = state.channels.map(c => ({
38
+ value: c.id,
39
+ label: c.name,
40
+ description: c.leadAgentId ? 'lead 已配置' : '无 lead(仅收发消息)',
41
+ }))
42
+ const picker = pickerComponent('选择要进入的频道(↑↓ 移动 · Enter 进入 · Esc 取消)', items, Math.min(10, items.length),
43
+ (item) => {
44
+ state.channelPickerOpen = false
45
+ onSelect?.(item.value)
46
+ },
47
+ () => {
48
+ state.channelPickerOpen = false
49
+ onCancel?.()
50
+ })
51
+ state.channelPickerOpen = true
52
+ const handle = tui.showOverlay(picker.component, { anchor: 'center' })
53
+ picker.__bindHandle(handle)
54
+ tui.setFocus(picker.list)
55
+ return handle
56
+ }
57
+
58
+ /**
59
+ * Agent/对话目标选择浮层(Tab 触发)。首项 = 频道(发布任务模式);
60
+ * 选具体成员 = 切换通信目标并自动打开其独立监控。
61
+ */
62
+ export function openTargetPicker(tui, state, { onPick, onCancel } = {}) {
63
+ const s = state.agents
64
+ .filter(a => a.enabled !== 0)
65
+ .map(a => ({
66
+ value: a.id,
67
+ label: `${a.name}${a.role === 'lead' ? '(lead)' : ''}`,
68
+ description: `通信对话 + 独立监控 · ${state.agentStates[a.id]?.state ?? 'idle'}`,
69
+ }))
70
+ const items = [{ value: '__channel__', label: '频道(发布任务)', description: '普通文本 = 向频道发布正式任务' }, ...s]
71
+ if (items.length === 1 && s.length === 0) {
72
+ onCancel?.()
73
+ return null
74
+ }
75
+ const picker = pickerComponent('选择对话目标(Tab 呼出 · ↑↓ 移动 · Enter 确认 · Esc 取消)', items, Math.min(10, items.length),
76
+ (item) => {
77
+ if (process.env.AW_TUI_DEBUG) console.error('[picker-debug] item=', JSON.stringify(item), 'agentIds=', JSON.stringify(s.map(x => x.value)))
78
+ state.targetPickerOpen = false
79
+ if (item.value === '__channel__') {
80
+ state.target = null
81
+ onPick?.(null)
82
+ }
83
+ else {
84
+ const a = state.agents.find(x => x.id === item.value)
85
+ if (!a) {
86
+ onCancel?.()
87
+ return
88
+ }
89
+ state.target = { agentId: a.id, name: a.name }
90
+ onPick?.(a)
91
+ }
92
+ },
93
+ () => {
94
+ state.targetPickerOpen = false
95
+ onCancel?.()
96
+ })
97
+ state.targetPickerOpen = true
98
+ const handle = tui.showOverlay(picker.component, { anchor: 'center' })
99
+ picker.__bindHandle(handle)
100
+ tui.setFocus(picker.list)
101
+ return handle
102
+ }
package/tui/lib/state.mjs CHANGED
@@ -28,6 +28,12 @@ export function createState() {
28
28
  /** 正在作答的条目(index → /hitl <n>;null = 常规输入) */
29
29
  hitlAnswering: null,
30
30
 
31
+ /** 对话目标:null = 频道(普通文本=发布任务);{agentId,name} = 成员(普通文本=通信消息) */
32
+ target: null,
33
+ /** 浮层开关(按键路由用:Tab 呼出目标选择;启动/`/channel` 呼出频道选择) */
34
+ channelPickerOpen: false,
35
+ targetPickerOpen: false,
36
+
31
37
  monitor: {
32
38
  agentId: null,
33
39
  name: null,