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/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
- const { acquireLock } = await import('../shared/config/single-instance.mjs')
57
- const lock = acquireLock(rm.configRoot, { mode: `prod:${rm.mode}`, port: argPort ? Number(argPort) : prodPort })
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.error(`✖ 已有实例在运行(pid=${h.pid}${h.port ? `,端口=${h.port}` : ''},启动于 ${h.startedAt ?? '未知'},模式=${h.mode ?? '未知'})`)
61
- console.error(` › 配置根: ${rm.configRoot}`)
62
- console.error(` › 如确认是残留锁文件,可删除 ${lock.lockPath} 后重试`)
63
- process.exit(2)
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(prodPort)
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))
@@ -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)
@@ -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
- * @returns {{ ok: true, lockPath: string, release: () => void }
26
- * | { ok: false, lockPath: string, holder: { pid: number, startedAt: string, mode: string, port: number | null } }}
27
- */
28
- export function acquireLock(configRoot, { mode = 'prod', port = null } = {}) {
29
- const runtimeDir = join(configRoot, '.runtime')
30
- const lockPath = join(runtimeDir, 'aw.lock')
31
- mkdirSync(runtimeDir, { recursive: true })
32
- if (existsSync(lockPath)) {
33
- let holder = null
34
- try {
35
- holder = JSON.parse(readFileSync(lockPath, 'utf8'))
36
- }
37
- catch { /* 损坏锁文件按残留处理 */ }
38
- if (holder && pidAlive(holder.pid)) {
39
- return { ok: false, lockPath, holder: { ...holder, port: holder.port ?? null } }
40
- }
41
- try { rmSync(lockPath) } catch { /* 抢不掉则下方 wx 再失败 */ }
42
- }
43
- const me = { pid: process.pid, startedAt: new Date().toISOString(), mode, port }
44
- try {
45
- writeFileSync(lockPath, JSON.stringify(me, null, 2), { flag: 'wx' })
46
- }
47
- catch (err) {
48
- // 与并发启动者竞态:按冲突处理
49
- let holder = null
50
- try { holder = JSON.parse(readFileSync(lockPath, 'utf8')) } catch { /* ignore */ }
51
- if (holder && pidAlive(holder.pid)) {
52
- return { ok: false, lockPath, holder: { ...holder, port: holder.port ?? null } }
53
- }
54
- throw err
55
- }
56
- let released = false
57
- const release = () => {
58
- if (released) return
59
- released = true
60
- try {
61
- const cur = JSON.parse(readFileSync(lockPath, 'utf8'))
62
- if (cur?.pid !== process.pid) return // 锁已被他人接管,不误删
63
- rmSync(lockPath)
64
- }
65
- catch { /* 已不存在/损坏,忽略 */ }
66
- }
67
- process.once('exit', release)
68
- process.once('SIGINT', () => { release(); process.exit(130) })
69
- process.once('SIGTERM', () => { release(); process.exit(143) })
70
- return { ok: true, lockPath, release }
71
- }
72
-
73
- /** 端口占用探测:返回占用进程 PID(未占用返回 null)。TCP try-listen 后立刻释放。 */
74
- export async function checkPort(host, port) {
75
- const net = await import('node:net')
76
- return new Promise((resolveProbe) => {
77
- const srv = net.createServer()
78
- srv.once('error', (err) => {
79
- resolveProbe(err?.code === 'EADDRINUSE' ? port : null)
80
- srv.close()
81
- })
82
- srv.listen(port, host, () => {
83
- srv.close(() => resolveProbe(null))
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 <名\|序号>` | 切换频道(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,