agentworkshop 0.7.3 → 0.7.4

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.
Files changed (127) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/B9Yie5HN.js +2 -0
  3. package/.output/public/_nuxt/CN7Zh8GQ.js +1 -0
  4. package/.output/public/_nuxt/CdSgfcYR.js +1 -0
  5. package/.output/public/_nuxt/CdfAP_IH2.js +1 -0
  6. package/.output/public/_nuxt/ChnfyQHz.js +1 -0
  7. package/.output/public/_nuxt/{BkKYf8xY.js → CyygA0q4.js} +4 -4
  8. package/.output/public/_nuxt/{Bz40vyZS.js → DFnGFEav.js} +7 -7
  9. package/.output/public/_nuxt/Xaf_HIR3.js +1 -0
  10. package/.output/public/_nuxt/ZugRk0CM2.js +4 -0
  11. package/.output/public/_nuxt/builds/latest.json +1 -1
  12. package/.output/public/_nuxt/builds/meta/55c10854-ee0e-4be4-924b-a1b0e100fe11.json +1 -0
  13. package/.output/public/_nuxt/{default.iGJy9dP-.css → default.D17kXlD6.css} +1 -1
  14. package/.output/public/_nuxt/monitor.CmwpEUCI.css +1 -0
  15. package/.output/public/_nuxt/yIKY_V1P.js +1 -0
  16. package/.output/server/chunks/_/nitro.mjs +1131 -920
  17. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  18. package/.output/server/chunks/build/{TownView-BkHxMiwZ.mjs → TownView-Bl8Gkcxi.mjs} +3 -3
  19. package/.output/server/chunks/build/{TownView-BkHxMiwZ.mjs.map → TownView-Bl8Gkcxi.mjs.map} +1 -1
  20. package/.output/server/chunks/build/{_id_-Bna1_JuB.mjs → _id_-CWLFGZjV.mjs} +2 -2
  21. package/.output/server/chunks/build/{_id_-Bna1_JuB.mjs.map → _id_-CWLFGZjV.mjs.map} +1 -1
  22. package/.output/server/chunks/build/_id_-GnRJPykX.mjs +1 -1
  23. package/.output/server/chunks/build/{_wsId_-CzLDGT_V.mjs → _wsId_-BhQMjyHG.mjs} +3 -4
  24. package/.output/server/chunks/build/{_wsId_-CzLDGT_V.mjs.map → _wsId_-BhQMjyHG.mjs.map} +1 -1
  25. package/.output/server/chunks/build/{daq-CmeM4u5u.mjs → daq-BOgpn9sl.mjs} +4 -5
  26. package/.output/server/chunks/build/daq-BOgpn9sl.mjs.map +1 -0
  27. package/.output/server/chunks/build/dcw-BTM5YU-B.mjs +1 -1
  28. package/.output/server/chunks/build/{default-Dt6xLdpd.mjs → default-CKJWhkot.mjs} +60 -17
  29. package/.output/server/chunks/build/{default-Dt6xLdpd.mjs.map → default-CKJWhkot.mjs.map} +1 -1
  30. package/.output/server/chunks/build/{logs-CUQt1z7D.mjs → logs-Dyk0I2I9.mjs} +3 -4
  31. package/.output/server/chunks/build/logs-Dyk0I2I9.mjs.map +1 -0
  32. package/.output/server/chunks/build/{monitor-yNoV98rf.mjs → monitor-KW8WLOJd.mjs} +34 -33
  33. package/.output/server/chunks/build/{monitor-yNoV98rf.mjs.map → monitor-KW8WLOJd.mjs.map} +1 -1
  34. package/.output/server/chunks/build/{town-BN4vQPb8.mjs → town-_4tEEEVW.mjs} +3 -4
  35. package/.output/server/chunks/build/town-_4tEEEVW.mjs.map +1 -0
  36. package/.output/server/chunks/build/useDaqStream-hwkhJHFU.mjs +1 -1
  37. package/.output/server/chunks/build/useDcwStream-Bdew_yrr.mjs +1 -1
  38. package/.output/server/chunks/build/{useWorkshopWs-xcdeiSmJ.mjs → useWorkshopWs-B3j5wIAK.mjs} +177 -3
  39. package/.output/server/chunks/build/useWorkshopWs-B3j5wIAK.mjs.map +1 -0
  40. package/.output/server/chunks/build/{workshop-CLhwe5eo.mjs → workshop-k0XZTFKg.mjs} +2 -3
  41. package/.output/server/chunks/build/workshop-k0XZTFKg.mjs.map +1 -0
  42. package/.output/server/chunks/routes/api/workshop/channel-templates/_id/instantiate.post.mjs +1 -1
  43. package/.output/server/chunks/routes/api/workshop/channels.post.mjs +1 -1
  44. package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs +32 -0
  45. package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs.map +1 -0
  46. package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs +56 -0
  47. package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs.map +1 -0
  48. package/.output/server/chunks/routes/api/workshop/plugins/_name/disable.post.mjs +1 -1
  49. package/.output/server/chunks/routes/api/workshop/plugins/_name/enable.post.mjs +1 -1
  50. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.delete.mjs +1 -1
  51. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.put.mjs +1 -1
  52. package/.output/server/chunks/routes/api/workshop/workspaces/_id/channel-templates/_tplId_.post.mjs +1 -1
  53. package/.output/server/chunks/routes/renderer.mjs +2 -2
  54. package/.output/server/chunks/virtual/entry.mjs +17 -11
  55. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  56. package/.output/server/package.json +1 -1
  57. package/README.md +435 -434
  58. package/app/components/AppHeader.vue +138 -0
  59. package/app/composables/workshop/useWorkshopWs.ts +4 -0
  60. package/app/pages/monitor.vue +17 -1
  61. package/app/stores/workshop/hitl.ts +80 -0
  62. package/bin/aw.mjs +0 -0
  63. package/cli/commands/tui.mjs +19 -0
  64. package/config.yml +134 -134
  65. package/docs/tui.md +43 -0
  66. package/i18n/locales/en.ts +3 -0
  67. package/i18n/locales/zh-CN.ts +3 -0
  68. package/package.json +140 -144
  69. package/scripts/_spike-pitui.mjs +116 -0
  70. package/scripts/e2e-hitl-live.mjs +323 -0
  71. package/scripts/e2e-line-live.mjs +144 -0
  72. package/scripts/test-hitl-registry.ts +112 -0
  73. package/scripts/test-tui-commands.mjs +131 -0
  74. package/scripts/test-tui-reducers.mjs +72 -0
  75. package/scripts/tsconfig.json +14 -0
  76. package/scripts/tui-smoke.mjs +91 -0
  77. package/server/api/workshop/hitl/pending.get.ts +28 -0
  78. package/server/api/workshop/hitl/respond.post.ts +67 -0
  79. package/server/api/workshop/ws.ts +30 -1
  80. package/server/data/daqs.json +39 -17
  81. package/server/data/dcw-lines.json +14 -0
  82. package/server/data/dcw-products.json +35 -0
  83. package/server/data/dcw-recipes.json +140 -0
  84. package/server/data/dcw-rollback.json +111 -1
  85. package/server/data/dcw-runs.json +135 -90
  86. package/server/data/dcw-writes.json +120 -0
  87. package/server/data/dcws.json +400 -275
  88. package/server/data/line-runs.json +11 -1
  89. package/server/plugins/workshop.ts +7 -0
  90. package/server/services/workshop/agents/harness-terminal.ts +92 -25
  91. package/server/services/workshop/agents/hitl-registry.ts +181 -0
  92. package/server/services/workshop/agents/tool-approvals.ts +15 -0
  93. package/server/services/workshop/scene-events.ts +19 -9
  94. package/shared/workshop-protocol.ts +37 -0
  95. package/tui/README.md +65 -0
  96. package/tui/aw-tui.mjs +356 -0
  97. package/tui/commands/index.mjs +250 -0
  98. package/tui/components/chat-log.mjs +42 -0
  99. package/tui/components/hitl-card.mjs +36 -0
  100. package/tui/components/monitor-pane.mjs +39 -0
  101. package/tui/components/root.mjs +53 -0
  102. package/tui/components/status-bar.mjs +29 -0
  103. package/tui/lib/api.mjs +82 -0
  104. package/tui/lib/config.mjs +58 -0
  105. package/tui/lib/reducers.mjs +218 -0
  106. package/tui/lib/state.mjs +80 -0
  107. package/tui/lib/virtual-terminal.mjs +48 -0
  108. package/tui/lib/ws.mjs +124 -0
  109. package/tui/theme.mjs +38 -0
  110. package/.output/public/_nuxt/B7V9IfTL.js +0 -1
  111. package/.output/public/_nuxt/BE5QyG5J2.js +0 -1
  112. package/.output/public/_nuxt/BuKI8z-5.js +0 -1
  113. package/.output/public/_nuxt/D0C5Q0di.js +0 -1
  114. package/.output/public/_nuxt/DC-QMEk1.js +0 -2
  115. package/.output/public/_nuxt/DEkQ0qQ02.js +0 -1
  116. package/.output/public/_nuxt/builds/meta/c6c01cdd-762e-4fb8-86e7-753c5185b856.json +0 -1
  117. package/.output/public/_nuxt/dt2tKE95.js +0 -4
  118. package/.output/public/_nuxt/i_MKObHo.js +0 -1
  119. package/.output/public/_nuxt/monitor.41fCD6TB.css +0 -1
  120. package/.output/public/_nuxt/tpDMVY5w.js +0 -1
  121. package/.output/server/chunks/build/connection-Dc0fn6yd.mjs +0 -120
  122. package/.output/server/chunks/build/connection-Dc0fn6yd.mjs.map +0 -1
  123. package/.output/server/chunks/build/daq-CmeM4u5u.mjs.map +0 -1
  124. package/.output/server/chunks/build/logs-CUQt1z7D.mjs.map +0 -1
  125. package/.output/server/chunks/build/town-BN4vQPb8.mjs.map +0 -1
  126. package/.output/server/chunks/build/useWorkshopWs-xcdeiSmJ.mjs.map +0 -1
  127. package/.output/server/chunks/build/workshop-CLhwe5eo.mjs.map +0 -1
@@ -7,7 +7,7 @@
7
7
  "productId": "pd-6348a595",
8
8
  "productName": "0.8mm 光学膜",
9
9
  "startedAt": "2026-09-03T11:18:01.213Z",
10
- "taggedSamples": 0
10
+ "taggedSamples": 5708
11
11
  },
12
12
  {
13
13
  "lineId": "ln-9a60a0cd",
@@ -48,5 +48,15 @@
48
48
  "productName": "闭环验证产品",
49
49
  "startedAt": "2026-09-03T17:12:52.205Z",
50
50
  "taggedSamples": 0
51
+ },
52
+ {
53
+ "lineId": "ln-1b318320",
54
+ "runId": "rr-9e70a9dc",
55
+ "recipeId": "rc-ccee1b1b",
56
+ "recipeName": "探针配方",
57
+ "productId": "pd-db432902",
58
+ "productName": "探针产品",
59
+ "startedAt": "2026-09-04T05:08:10.103Z",
60
+ "taggedSamples": 470
51
61
  }
52
62
  ]
@@ -30,6 +30,7 @@ import { createUserRepo } from '../services/workshop/db/user.repo'
30
30
  import { createChannelEventRepo } from '../services/workshop/db/channel-event.repo'
31
31
  import { createApprovalHistoryRepo, createAlarmEventRepo, createAuditRepo, createApprovalRequestRepo } from '../services/workshop/db/ops.repo'
32
32
  import { bindOpsRepos } from '../services/workshop/ops/ops'
33
+ import { configureHitlResolver } from '../services/workshop/agents/hitl-registry'
33
34
  import { ensureAllEventRecorders } from '../api/workshop/ws'
34
35
  import { createAgentImpl } from '../services/workshop/agents/factory'
35
36
  import {
@@ -123,6 +124,12 @@ export default function workshopPlugin(nitroApp: {
123
124
  const manager = createAgentChannelManager(deps)
124
125
  globalThis.__workshopManager = manager
125
126
 
127
+ // HITL 全局待办:agentId → channelId/agentName 解析器(避免 registry 反向依赖 manager)
128
+ configureHitlResolver((agentId) => {
129
+ const row = repos.channelAgents.findById(agentId)
130
+ return row ? { channelId: row.channelId, agentName: row.name } : null
131
+ })
132
+
126
133
  // 懒加载恢复:仅激活有待办任务的 channel(装配 lead + 调度循环);其余纯持久化
127
134
  manager.restore()
128
135
 
@@ -9,13 +9,18 @@
9
9
  *
10
10
  * HITL 语义:extension_ui_request(select/confirm/input/editor)到达时
11
11
  * - 有 WS 订阅者(有人在看终端)→ 等待人类应答,无超时(真 HITL);
12
- * - 无订阅者 → 自动 cancelled(等价 TUI 按 Esc;agent 的 ask 回合中止),
13
- * 并落一条 notice 帧让事后接入的观众可见。
12
+ * - 无订阅者 → park 至 hitl-registry 并启动倒计时(security.hitl_timeout_ms,
13
+ * 默认 180s;倒计时仅在零订阅期间进行,任一客户端接入终端即暂停)——
14
+ * 到期自动 cancelled(等价 TUI 按 Esc;agent 的 ask 回合中止),并落一条
15
+ * notice 帧让事后接入的观众可见。TTL 配 0 恢复旧的"无人即秒取消"行为。
16
+ * 待办全局可见性/应答路由由 hitl-registry + /api/workshop/hitl/* 承接。
14
17
  *
15
18
  * 定位:模块级单例(globalThis 跨 HMR 存活,与 workshop ws hub 同风格);
16
19
  * omp-agent 装配时 attach,manager 监控层读取 hasTerminalSession 标记进程行。
17
20
  */
18
- import { createLogger } from '../logger'
21
+ import { createLogger } from '../logger'
22
+ import { securityHitlTimeoutMs } from '../settings'
23
+ import { getHitlRegistry } from './hitl-registry'
19
24
  import type { OmpRpcClient } from './adapters/omp-rpc-client'
20
25
  import {
21
26
  TERM_FRAME_TEXT_PREVIEW_MAX,
@@ -43,6 +48,8 @@ interface TerminalSession {
43
48
  alive: boolean
44
49
  exitCode: number | null
45
50
  pendingHitl: TerminalHitlDialog | null
51
+ /** 零订阅 park 倒计时(有人接入即清除;到期自动取消待答对话框) */
52
+ parkTimer: NodeJS.Timeout | null
46
53
  unsubRaw: (() => void) | null
47
54
  /** 微批缓冲 + 定时器(50ms 合并 delta 帧,防 WS 洪泛) */
48
55
  batch: TermFrame[]
@@ -234,29 +241,83 @@ function hitlViewOf(frame: Record<string, unknown> | null): TerminalHitlDialog |
234
241
  }
235
242
  }
236
243
 
237
- /** HITL 对话框到达:登记 pending;无订阅者 → 自动 cancelled */
244
+ /** HITL 对话框到达:登记 pending + hitl-registry;零订阅时进入 park 倒计时 */
238
245
  function handleUiRequest(session: TerminalSession, frame: Record<string, unknown>): void {
239
246
  const method = frame.method as string
240
247
  if (method === 'cancel') {
241
- // omp 主动撤销对话框(如回合中止):清 pending
248
+ // omp 主动撤销对话框(如回合中止):清 pending + 落定全局待办
242
249
  const targetId = String(frame.targetId ?? '')
243
- if (session.pendingHitl && session.pendingHitl.id === targetId) session.pendingHitl = null
250
+ if (session.pendingHitl && session.pendingHitl.id === targetId) {
251
+ session.pendingHitl = null
252
+ clearParkTimer(session)
253
+ getHitlRegistry().resolve('omp-dialog', targetId, 'cancelled')
254
+ }
244
255
  return
245
256
  }
246
257
  const view = hitlViewOf(frame)
247
258
  if (!view) return
248
259
  session.pendingHitl = view
249
- if (session.listeners.size === 0) {
250
- // 无人观看:自动取消(Esc 语义),事后观众通过 notice 帧可见
251
- respondUi(session, { id: view.id, cancelled: true })
252
- pushFrame(session, {
253
- type: '__terminal_notice',
254
- level: 'warning',
255
- message: `HITL 对话框(${view.method} "${view.title.slice(0, 40)}")无人接入,已自动取消`,
256
- })
260
+ getHitlRegistry().register({
261
+ kind: 'omp-dialog',
262
+ id: view.id,
263
+ agentId: session.meta.agentId,
264
+ agentName: session.meta.name,
265
+ channelId: session.meta.channelId,
266
+ pid: session.meta.pid,
267
+ method: view.method,
268
+ title: view.title,
269
+ options: view.options,
270
+ message: view.message,
271
+ })
272
+ if (session.listeners.size === 0) armParkTimer(session)
273
+ }
274
+
275
+ // ===== park 倒计时(无人观看时挂起等待;有人接入暂停) =====
276
+
277
+ /** 零订阅 park 窗口(security.hitl_timeout_ms;0 = 恢复旧的"无人即秒取消") */
278
+ const HITL_PARK_MS = (): number => securityHitlTimeoutMs()
279
+
280
+ function clearParkTimer(session: TerminalSession): void {
281
+ if (session.parkTimer) {
282
+ clearTimeout(session.parkTimer)
283
+ session.parkTimer = null
257
284
  }
258
285
  }
259
286
 
287
+ /** 进入 park(仅零订阅且有待答对话框时生效;TTL<=0 立即取消 = 旧行为) */
288
+ function armParkTimer(session: TerminalSession): void {
289
+ clearParkTimer(session)
290
+ if (!session.pendingHitl || session.listeners.size > 0) return
291
+ const ttl = HITL_PARK_MS()
292
+ if (ttl <= 0) {
293
+ expirePendingHitl(session, true)
294
+ return
295
+ }
296
+ getHitlRegistry().setParkDeadline('omp-dialog', session.pendingHitl.id, new Date(Date.now() + ttl).toISOString())
297
+ session.parkTimer = setTimeout(() => {
298
+ session.parkTimer = null
299
+ if (session.pendingHitl && session.listeners.size === 0) expirePendingHitl(session)
300
+ }, ttl)
301
+ session.parkTimer.unref?.()
302
+ }
303
+
304
+ /** park 到期(或 TTL=0 立即):超时收敛 —— 先落定 expired,再走 cancelled 应答写 omp stdin */
305
+ function expirePendingHitl(session: TerminalSession, immediate = false): void {
306
+ const hitl = session.pendingHitl
307
+ if (!hitl) return
308
+ session.pendingHitl = null
309
+ clearParkTimer(session)
310
+ getHitlRegistry().resolve('omp-dialog', hitl.id, 'expired')
311
+ respondUi(session, { id: hitl.id, cancelled: true })
312
+ pushFrame(session, {
313
+ type: '__terminal_notice',
314
+ level: 'warning',
315
+ message: immediate
316
+ ? `HITL 对话框(${hitl.method} "${hitl.title.slice(0, 40)}")无人接入,已自动取消`
317
+ : `HITL 对话框(${hitl.method} "${hitl.title.slice(0, 40)}")park 超时无人处理,已自动取消`,
318
+ })
319
+ }
320
+
260
321
  // ===== 对外 API =====
261
322
 
262
323
  /**
@@ -287,6 +348,7 @@ export function attachTerminalTap(
287
348
  alive: true,
288
349
  exitCode: null,
289
350
  pendingHitl: null,
351
+ parkTimer: null,
290
352
  unsubRaw: null,
291
353
  batch: [],
292
354
  batchTimer: null,
@@ -337,7 +399,11 @@ export function markTerminalSessionExit(pid: number, exitCode: number | null): v
337
399
  detachTerminalTap(pid)
338
400
  session.alive = false
339
401
  session.exitCode = exitCode
340
- session.pendingHitl = null
402
+ if (session.pendingHitl) {
403
+ getHitlRegistry().resolve('omp-dialog', session.pendingHitl.id, 'cancelled')
404
+ session.pendingHitl = null
405
+ }
406
+ clearParkTimer(session)
341
407
  session.running = false
342
408
  session.streaming = false
343
409
  flushNow(session)
@@ -442,19 +508,17 @@ export function subscribeTerminal(
442
508
  ): (() => void) | null {
443
509
  const session = sessions.get(pid)
444
510
  if (!session) return null
511
+ const wasEmpty = session.listeners.size === 0
445
512
  session.listeners.add(listener)
513
+ if (wasEmpty && session.pendingHitl) {
514
+ // 有人观看:暂停 park 倒计时(真 HITL 等待无上限;观看者离开后重新计时)
515
+ clearParkTimer(session)
516
+ getHitlRegistry().setParkDeadline('omp-dialog', session.pendingHitl.id, null)
517
+ }
446
518
  return () => {
447
519
  session.listeners.delete(listener)
448
- // 订阅者归零时若还有待应答对话框 → 自动取消(观看者离开 = 放弃应答权)
449
- if (session.listeners.size === 0 && session.pendingHitl) {
450
- const hitl = session.pendingHitl
451
- respondUi(session, { id: hitl.id, cancelled: true })
452
- pushFrame(session, {
453
- type: '__terminal_notice',
454
- level: 'warning',
455
- message: `观看者已全部离开,HITL 对话框(${hitl.method} "${hitl.title.slice(0, 40)}")自动取消`,
456
- })
457
- }
520
+ // 订阅者归零时若还有待应答对话框:park 倒计时(超时自动取消;不再是秒取消)
521
+ if (session.listeners.size === 0 && session.pendingHitl) armParkTimer(session)
458
522
  }
459
523
  }
460
524
 
@@ -526,6 +590,7 @@ function respondUi(
526
590
  response: { id: string, value?: string, confirmed?: boolean, cancelled?: boolean },
527
591
  ): void {
528
592
  if (session.pendingHitl && session.pendingHitl.id === response.id) session.pendingHitl = null
593
+ clearParkTimer(session)
529
594
  const frame: Record<string, unknown> = { type: 'extension_ui_response', id: response.id }
530
595
  if (response.cancelled) {
531
596
  frame.cancelled = true
@@ -542,4 +607,6 @@ function respondUi(
542
607
  catch (err) {
543
608
  log.error(`[harness-terminal] extension_ui_response 写入失败(pid=${session.meta.pid}):`, err)
544
609
  }
610
+ // 全局待办落定(expired 收敛路径已先行 resolve,此处幂等 no-op)
611
+ getHitlRegistry().resolve('omp-dialog', response.id, response.cancelled ? 'cancelled' : 'answered')
545
612
  }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * HitlRegistry —— 全局"待人工处理"登记处(进程内,globalThis 单例)。
3
+ *
4
+ * 把两条互相独立的 HITL 链路统一成一个可发现的视图:
5
+ * - omp-dialog omp `rpc-ui` 的 extension_ui_request 对话框(harness-terminal 登记;
6
+ * park/暂停计时语义由 terminal 订阅者状态驱动,本模块只存状态与广播)
7
+ * - dcw-approval 手动确认模式的工具执行审批(tool-approvals 登记/裁决)
8
+ *
9
+ * 职责:登记(hitl.request)/ 落定(hitl.resolved)事件经 per-channel 订阅扇出,
10
+ * 由 workshop ws hub 转接进 AEP 频道流(seq/落库);统一 REST 快照与应答路由
11
+ * (/api/workshop/hitl/pending、/respond)也读本模块。裁决留痕仍在各链路
12
+ * (approval_history / audit),这里不做持久化 —— omp 对话框本就随子进程消亡。
13
+ *
14
+ * 定位:模块级单例(globalThis 跨 HMR 存活,与 tool-approvals 同风格);
15
+ * 不 import runtime/manager(避免循环初始化),channelId 由插件注入 resolver 解析。
16
+ */
17
+ import type { AepHitlItem, AepHitlResolved } from '../../../../shared/workshop-protocol'
18
+
19
+ export type HitlKind = AepHitlItem['kind']
20
+ export type HitlOutcome = AepHitlResolved['outcome']
21
+
22
+ /** hitl 帧事件(ws hub 按 channelId 转接进频道流) */
23
+ export interface HitlEvent {
24
+ type: 'hitl.request' | 'hitl.resolved'
25
+ payload: AepHitlItem | AepHitlResolved
26
+ agentId?: string
27
+ }
28
+
29
+ type HitlListener = (e: HitlEvent) => void
30
+
31
+ /** 登记入参(channelId/agentName 可由 resolver 补全;缺 channelId 的条目仅入全局快照) */
32
+ export interface HitlRegisterInput {
33
+ kind: HitlKind
34
+ id: string
35
+ agentId: string
36
+ agentName?: string
37
+ channelId?: string
38
+ pid?: number
39
+ method?: AepHitlItem['method']
40
+ title: string
41
+ detail?: string
42
+ options?: string[]
43
+ message?: string
44
+ createdAt?: string
45
+ expiresAt?: string | null
46
+ }
47
+
48
+ class HitlRegistryService {
49
+ /** key = `${kind}:${id}`;value 为 AepHitlItem(parkDeadline 单独存,避免污染协议对象) */
50
+ private items = new Map<string, AepHitlItem>()
51
+ private parkDeadline = new Map<string, string | null>()
52
+ private listeners = new Map<string, Set<HitlListener>>()
53
+ /** agentId → { channelId, agentName }(插件装配 repos 后注入;避免反向依赖 manager) */
54
+ private resolver: ((agentId: string) => { channelId: string, agentName: string } | null) | null = null
55
+
56
+ configureResolver(fn: (agentId: string) => { channelId: string, agentName: string } | null): void {
57
+ this.resolver = fn
58
+ }
59
+
60
+ private key(kind: HitlKind, id: string): string {
61
+ return `${kind}:${id}`
62
+ }
63
+
64
+ /** 登记(幂等:同 kind+id 重复登记只更新 parkDeadline,不重发事件) */
65
+ register(input: HitlRegisterInput): void {
66
+ const key = this.key(input.kind, input.id)
67
+ const existing = this.items.get(key)
68
+ if (existing) return
69
+ let channelId = input.channelId ?? ''
70
+ let agentName = input.agentName ?? ''
71
+ if ((!channelId || !agentName) && this.resolver) {
72
+ try {
73
+ const resolved = this.resolver(input.agentId)
74
+ channelId = channelId || resolved?.channelId || ''
75
+ agentName = agentName || resolved?.agentName || ''
76
+ }
77
+ catch { /* resolver 异常不阻断登记 */ }
78
+ }
79
+ const item: AepHitlItem = {
80
+ kind: input.kind,
81
+ id: input.id,
82
+ channelId,
83
+ agentId: input.agentId,
84
+ agentName: agentName || input.agentId.slice(0, 8),
85
+ pid: input.pid,
86
+ method: input.method,
87
+ title: input.title,
88
+ detail: input.detail,
89
+ options: input.options,
90
+ message: input.message,
91
+ createdAt: input.createdAt ?? new Date().toISOString(),
92
+ expiresAt: input.expiresAt ?? null,
93
+ }
94
+ this.items.set(key, item)
95
+ this.parkDeadline.set(key, item.expiresAt ?? null)
96
+ this.emit({ type: 'hitl.request', payload: item, agentId: item.agentId })
97
+ }
98
+
99
+ /**
100
+ * 落定(幂等:未登记的 key 返回 false —— 上层据此实现 409 ALREADY_RESOLVED)。
101
+ * outcome:answered(人类已答复/批准/拒绝)/ cancelled(人工放弃或 omp 撤销)/ expired(超时)。
102
+ */
103
+ resolve(kind: HitlKind, id: string, outcome: HitlOutcome, by?: string): boolean {
104
+ const key = this.key(kind, id)
105
+ const item = this.items.get(key)
106
+ if (!item) return false
107
+ this.items.delete(key)
108
+ this.parkDeadline.delete(key)
109
+ const payload: AepHitlResolved = {
110
+ kind,
111
+ id,
112
+ channelId: item.channelId,
113
+ agentId: item.agentId,
114
+ outcome,
115
+ by,
116
+ }
117
+ this.emit({ type: 'hitl.resolved', payload, agentId: item.agentId })
118
+ return true
119
+ }
120
+
121
+ /** 查询单条(应答路由用:取 pid / channelId / method) */
122
+ find(kind: HitlKind, id: string): AepHitlItem | null {
123
+ return this.items.get(this.key(kind, id)) ?? null
124
+ }
125
+
126
+ /** park 截止更新(harness-terminal 订阅者增减时静默修正;null = 计时暂停) */
127
+ setParkDeadline(kind: HitlKind, id: string, expiresAt: string | null): void {
128
+ const key = this.key(kind, id)
129
+ if (!this.items.has(key)) return
130
+ this.parkDeadline.set(key, expiresAt)
131
+ const item = this.items.get(key)!
132
+ item.expiresAt = expiresAt
133
+ }
134
+
135
+ /** 全局(或指定 channel)待处理快照 */
136
+ snapshot(channelId?: string): AepHitlItem[] {
137
+ return [...this.items.values()].filter(i => !channelId || i.channelId === channelId)
138
+ }
139
+
140
+ /** per-channel 事件订阅(ws hub ensureStream 时接线;返回退订函数) */
141
+ subscribe(channelId: string, fn: HitlListener): () => void {
142
+ let set = this.listeners.get(channelId)
143
+ if (!set) {
144
+ set = new Set()
145
+ this.listeners.set(channelId, set)
146
+ }
147
+ set.add(fn)
148
+ return () => {
149
+ set.delete(fn)
150
+ if (set.size === 0) this.listeners.delete(channelId)
151
+ }
152
+ }
153
+
154
+ private emit(e: HitlEvent): void {
155
+ const channelId = (e.payload as AepHitlItem).channelId
156
+ const set = channelId ? this.listeners.get(channelId) : undefined
157
+ if (!set) return
158
+ for (const fn of set) {
159
+ try {
160
+ fn(e)
161
+ }
162
+ catch { /* 单个订阅者异常不影响扇出 */ }
163
+ }
164
+ }
165
+ }
166
+
167
+ const g = globalThis as typeof globalThis & { __hitlRegistry?: HitlRegistryService }
168
+
169
+ export function getHitlRegistry(): HitlRegistryService {
170
+ g.__hitlRegistry ??= new HitlRegistryService()
171
+ return g.__hitlRegistry
172
+ }
173
+
174
+ /** 插件接线用别名(语义直白) */
175
+ export const configureHitlResolver = (fn: (agentId: string) => { channelId: string, agentName: string } | null): void =>
176
+ getHitlRegistry().configureResolver(fn)
177
+
178
+ /** per-channel 事件订阅(ws hub 建流时挂接;hitl.request/hitl.resolved 扇出) */
179
+ export function subscribeHitlEvents(channelId: string, fn: HitlListener): () => void {
180
+ return getHitlRegistry().subscribe(channelId, fn)
181
+ }
@@ -9,6 +9,7 @@
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import { getOps } from '../ops/ops'
11
11
  import { securityHitlTimeoutMs } from '../settings'
12
+ import { getHitlRegistry } from './hitl-registry'
12
13
 
13
14
  export interface ToolApproval {
14
15
  id: string
@@ -57,6 +58,17 @@ class ToolApprovalService {
57
58
  decidedBy: '',
58
59
  decidedName: '',
59
60
  }
61
+ // 全局 HITL 待办登记(channelId/agentName 由插件注入的 resolver 补全;
62
+ // expiresAt 与审批超时窗同源,前端可显示倒计时)
63
+ getHitlRegistry().register({
64
+ kind: 'dcw-approval',
65
+ id: approval.id,
66
+ agentId: approval.agentId,
67
+ title: `${approval.kind.toUpperCase()} 下发审批`,
68
+ detail: approval.detail,
69
+ createdAt: approval.createdAt,
70
+ expiresAt: approval.expiresAt,
71
+ })
60
72
  return new Promise((resolve) => {
61
73
  const timer = setTimeout(() => {
62
74
  if (!this.pending.has(approval.id)) return
@@ -65,6 +77,7 @@ class ToolApprovalService {
65
77
  approval.decidedAt = new Date().toISOString()
66
78
  approval.comment = '审批超时未处理,默认拒绝,指令未执行'
67
79
  this.remember(approval)
80
+ getHitlRegistry().resolve('dcw-approval', approval.id, 'expired')
68
81
  resolve({ approved: false, comment: approval.comment, id: approval.id })
69
82
  }, TIMEOUT_MS())
70
83
  timer.unref?.()
@@ -83,6 +96,7 @@ class ToolApprovalService {
83
96
  entry.approval.decidedBy = decidedBy
84
97
  entry.approval.decidedName = decidedName
85
98
  this.remember(entry.approval)
99
+ getHitlRegistry().resolve('dcw-approval', id, 'answered', decidedBy || undefined)
86
100
  entry.resolve({ approved, comment: entry.approval.comment, id })
87
101
  return entry.approval
88
102
  }
@@ -105,6 +119,7 @@ class ToolApprovalService {
105
119
  entry.approval.comment = '绑定已解除,审批失效'
106
120
  entry.approval.decidedAt = new Date().toISOString()
107
121
  this.remember(entry.approval)
122
+ getHitlRegistry().resolve('dcw-approval', id, 'cancelled')
108
123
  entry.resolve({ approved: false, comment: entry.approval.comment, id })
109
124
  n++
110
125
  }
@@ -7,7 +7,6 @@
7
7
  * 路由改为从本模块导入广播函数 —— 避免"路由文件被当库静态导入"引发
8
8
  * nitro 打包的循环初始化(TDZ)问题。
9
9
  */
10
- import type { AepEnvelope } from '../../../shared/workshop-protocol'
11
10
  import { emitPluginEvent } from './plugins/host.mjs'
12
11
 
13
12
  const AEP_VERSION = 1
@@ -46,15 +45,26 @@ export function broadcastSceneEvent(type: string, payload: unknown): void {
46
45
  // 插件宿主事件桥(event:<type> 钩子;宿主未装载时 no-op)——先于 peers 短路,插件不依赖在线页面
47
46
  emitPluginEvent(type, payload)
48
47
  if (peers().size === 0) return
49
- const e: AepEnvelope = {
50
- v: AEP_VERSION,
51
- type,
52
- seq: 0,
53
- at: new Date().toISOString(),
54
- channelId: '',
55
- payload: payload as AepEnvelope['payload'],
48
+ const frame = JSON.stringify({ v: AEP_VERSION, type, seq: 0, at: new Date().toISOString(), channelId: '', payload })
49
+ for (const peer of peers()) {
50
+ try {
51
+ peer.send(frame)
52
+ }
53
+ catch {
54
+ peers().delete(peer)
55
+ }
56
56
  }
57
- const frame = JSON.stringify(e)
57
+ }
58
+
59
+ /**
60
+ * 全员直推(不触发插件宿主事件桥):给已有频道归属、但需要"全局主动提醒"
61
+ * 语义的低频事件用(hitl.request/hitl.resolved —— WebUI 徽标/TUI 状态条不依赖
62
+ * channel 订阅即达)。与 broadcastSceneEvent 的差别仅在不二次 emitPluginEvent
63
+ * (频道流 publish 路径已各发一次,避免插件钩子重复触发)。
64
+ */
65
+ export function broadcastPeerEvent(type: string, payload: unknown): void {
66
+ if (peers().size === 0) return
67
+ const frame = JSON.stringify({ v: AEP_VERSION, type, seq: 0, at: new Date().toISOString(), channelId: '', payload })
58
68
  for (const peer of peers()) {
59
69
  try {
60
70
  peer.send(frame)
@@ -118,6 +118,40 @@ export interface AepSceneLayout {
118
118
  rotationY: number
119
119
  }
120
120
 
121
+ /** hitl.request payload:待人工处理条目(omp ask 对话框与 dcw 工具审批的统一视图) */
122
+ export interface AepHitlItem {
123
+ kind: 'omp-dialog' | 'dcw-approval'
124
+ /** omp:对话框 id;dcw:审批 id(ap-*) */
125
+ id: string
126
+ channelId: string
127
+ agentId: string
128
+ agentName: string
129
+ /** omp 子进程 pid(应答路由 respondTerminalUi 用) */
130
+ pid?: number
131
+ /** omp-dialog 专属:对话框形态 */
132
+ method?: 'select' | 'confirm' | 'input' | 'editor'
133
+ title: string
134
+ /** dcw-approval 专属:人读摘要(节点/物理量/目标值) */
135
+ detail?: string
136
+ /** select 的选项列表 */
137
+ options?: string[]
138
+ message?: string
139
+ createdAt: string
140
+ /** park 截止时刻(omp 对话框零订阅倒计时;null = 有订阅者,计时暂停) */
141
+ expiresAt?: string | null
142
+ }
143
+
144
+ /** hitl.resolved payload:待办落定(answered=已答复/cancelled=放弃或撤销/expired=超时) */
145
+ export interface AepHitlResolved {
146
+ kind: AepHitlItem['kind']
147
+ id: string
148
+ channelId: string
149
+ agentId: string
150
+ outcome: 'answered' | 'cancelled' | 'expired'
151
+ /** 落定方(user id / system) */
152
+ by?: string
153
+ }
154
+
121
155
  export type AepEvent
122
156
  = | { type: 'channel.snapshot', payload: AepSnapshot }
123
157
  | { type: 'agent.status', payload: { agentId: string, state: 'idle' | 'busy' | 'stopped', currentTaskId?: string | null, queued?: number, completed?: number } }
@@ -135,6 +169,8 @@ export type AepEvent
135
169
  | { type: 'device.deleted', payload: { id: string, name: string } }
136
170
  | { type: 'scene.layout.saved', payload: AepSceneLayout }
137
171
  | { type: 'scene.layout.removed', payload: { channelId: string } }
172
+ | { type: 'hitl.request', payload: AepHitlItem }
173
+ | { type: 'hitl.resolved', payload: AepHitlResolved }
138
174
  | { type: 'daq.reading', payload: AepDaqReading }
139
175
  | { type: 'daq.node.changed', payload: AepDaqNodeChange }
140
176
  | { type: 'daq.controller', payload: AepDaqControllerState }
@@ -192,6 +228,7 @@ export const AEP_GROUPS: Record<string, string[]> = {
192
228
  team: ['agent.member'],
193
229
  devices: ['device.created', 'device.updated', 'device.deleted'],
194
230
  scene: ['scene.layout.saved', 'scene.layout.removed'],
231
+ hitl: ['hitl.request', 'hitl.resolved'],
195
232
  daq: ['daq.reading', 'daq.node.changed', 'daq.controller', 'daq.template.changed', 'daq.alarm', 'daq.alarm.changed'],
196
233
  dcw: ['dcw.node.changed', 'dcw.written', 'dcw.controller', 'dcw.optimization.changed'],
197
234
  ops: ['ops.log'],
package/tui/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # AgentWorkShop TUI —— 终端工作台
2
+
3
+ 与 AgentTeam 交互作业的终端客户端(pi-tui 组件化渲染,与 WebUI 平级的瘦客户端):
4
+ 创建/切换 Channel、放置 Agent、发送任务、实时查看执行过程,以及 **HITL 待办
5
+ (omp ask 对话框 / dcw 下发审批)的统一提醒与作答**。
6
+
7
+ ## 启动
8
+
9
+ ```bash
10
+ aw tui # 或:pnpm tui / node tui/aw-tui.mjs
11
+ aw tui --url http://127.0.0.1:3000 --token ut-xxxx --channel <名>
12
+ ```
13
+
14
+ - 首次使用交互式登录(邮箱+密码);凭据落 `<配置根>/tui-auth.json`(0600,
15
+ 已 gitignore)。也可用环境变量 `AW_TUI_EMAIL` / `AW_TUI_PASSWORD` 免交互。
16
+ - 需要服务端已运行(`pnpm dev` 或 `aw start`)。
17
+
18
+ ## 界面
19
+
20
+ ```
21
+ ┌ 会话时间线(左) ┌ 监控面板(右,/monitor 开启)
22
+ │ ◆ 你 消息 · ◆ Agent 回复 · 任务状态行 │ ┌─ 监控 <agent> · 流式中
23
+ ├ HITL 作答卡(/hitl <n> 进入作答时出现)
24
+ ├ 状态条:频道 · 成员(忙 n) · HITL 待处理 n · 连接态
25
+ └ 输入框(/ 命令补全 · ↑↓ 历史)
26
+ ```
27
+
28
+ ## 命令
29
+
30
+ | 命令 | 说明 |
31
+ |------|------|
32
+ | `/help` | 命令列表 |
33
+ | `/channels` | 列出我的频道 |
34
+ | `/channel new <名> [--desc …] [--lead <名>]` | 创建频道(可选内联建 lead) |
35
+ | `/channel use <名\|序号>` | 切换频道(WS 断点续传) |
36
+ | `/channel add <模板\|名> [--role lead\|worker] [--harness omp] [--config JSON]` | 放置 Agent 实例 |
37
+ | `/agents` | 当前频道成员与实时状态 |
38
+ | `/send <agent\|序号> <文本…>` | 向指定 Agent 直发(忙碌时 steer 注入) |
39
+ | `/task <标题…> [--mode goal\|loop\|pipeline] [--assignee <agent>]` | 提交正式任务 |
40
+ | `/tasks` | 任务列表与七态/进度 |
41
+ | `/monitor <agent\|序号\|off>` | 开/关右侧终端镜像(实时执行过程) |
42
+ | `/hitl [序号\|off]` | 待人工处理列表/进入作答/放弃作答 |
43
+ | `/quit` | 退出 |
44
+
45
+ 普通文本 = 向当前频道 lead 发送任务(`priority=task`)。
46
+
47
+ ## HITL 流程(与 WebUI 同一套服务端)
48
+
49
+ 1. Agent 需要人类确认/ask 时,服务端 hitl-registry 登记待办,所有已连接
50
+ 客户端(WebUI 徽标 / TUI 状态条)收到 `hitl.request` 帧 → 提醒亮起。
51
+ 2. TUI:`/hitl` 看列表 → `/hitl <序号>` 进入作答卡(自动可配合 `/monitor`
52
+ 观察现场);confirm 输 `y/n`,select 输序号,input/editor 直接输入。
53
+ 3. 回车提交 → `POST /api/workshop/hitl/respond` → extension_ui_response
54
+ 直写 omp stdin(或 dcw 审批落定)→ `hitl.resolved` 帧 → 双端提醒消隐。
55
+ 4. 无人观看时对话框 park `security.hitl_timeout_ms`(默认 180s,仅零订阅
56
+ 期间倒计时,有人接入终端即暂停;配 0 恢复旧的立即取消行为)。
57
+
58
+ ## 测试
59
+
60
+ ```bash
61
+ node scripts/test-hitl-registry.ts # 服务端 registry + park 语义(需 tsx)
62
+ node scripts/test-tui-reducers.mjs # AEP 帧归约
63
+ node scripts/test-tui-commands.mjs # 命令解析/分发
64
+ node scripts/tui-smoke.mjs # 无头 e2e(需 dev server 运行)
65
+ ```