@whyshu/argent 0.1.27 → 0.2.1

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,1102 @@
1
+ /**
2
+ * argent · ERP 机器连接器(自 @whyshu/argent-dsh@0.1.10 office-runtime fork)(Host 端,2026-10-21 P0)
3
+ *
4
+ * 目的:把**本机 dsh 的 AI 员工运行时**(实时 agent / 子代理 / 后台 jobs)以出站 HTTPS 上报到
5
+ * Argent ERP 的「办公室现场」——按租户隔离(问述科技 = whyshu-tech,六禾园 = ly-0001),
6
+ * 机器必须先由该租户管理员在 ERP 登记,令牌放 `~/.dsh/argent/agent-report.json`(600)。
7
+ *
8
+ * 设计红线:
9
+ * · 只出站,⛔ 不监听公网、⛔ 不开反向隧道;
10
+ * · 只上报**元数据**(agent id/父子/状态/当前任务标题/jobs 摘要/计数),
11
+ * ⛔ 不上报 prompt、消息正文、工具入参输出、文件路径、凭据;
12
+ * · 配置每个 tick 重读 ⇒ 改开关/换机器/换令牌**不需要重启 dsh**(仅本模块代码本身需要重启加载);
13
+ * · ERP 不可达 / 未配置时静默跳过(只在状态变化时打日志),绝不影响 dsh 与会话;
14
+ * · 本机 HTTP 调试端点仅允许回环(127.0.0.1 / ::1)。
15
+ *
16
+ * 配置(~/.dsh/argent/agent-report.json,600):
17
+ * {
18
+ * "enabled": true,
19
+ * "serverUrl": "https://whyshu.com",
20
+ * "tenantAppId": "whyshu-tech",
21
+ * "machineId": "mac-chester",
22
+ * "machineName": "Chester Mac mini",
23
+ * "runnerToken": "eyJ…", // ERP 管理员签发、绑定该租户;⛔ 不打印
24
+ * "intervalSec": 10,
25
+ * "includeSessionTitles": true, // 默认 true(问述自己租户);客户机建议 false
26
+ * "chatEnabled": false // P2 机器对话执行器(默认关;开启后本机执行 ERP 发来的会话消息)
27
+ * }
28
+ */
29
+ import { mkdirSync, readdirSync, renameSync, statSync, writeFileSync, existsSync } from 'node:fs'
30
+ import { spawn } from 'node:child_process'
31
+ import { readFile } from 'node:fs/promises'
32
+ import os from 'node:os'
33
+ import { basename, dirname, join } from 'node:path'
34
+ import { readConfig, REPORT_FILE } from './config.mjs'
35
+ import { extractFulfillmentResult, uploadFulfillmentFiles } from './fulfillment-result.mjs'
36
+ import { buildSkuFlowRecord, findSkuFlowArtifactsIn } from './skuflow-runner.mjs'
37
+ import { engineDirSync } from './kernel.mjs'
38
+ import { runEngineTask } from './actions.mjs'
39
+ import { collectHealth } from './health.mjs'
40
+
41
+ const MAX_AGENTS = 200
42
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
43
+
44
+ const trunc = (v, n) => {
45
+ const s = String(v ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim()
46
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s
47
+ }
48
+
49
+ /** 从 assistant/message 事件的 message.content 里取纯文本(忽略 tool-call 等块)。 */
50
+ function extractAssistantText(message) {
51
+ const blocks = Array.isArray(message?.content) ? message.content : []
52
+ const parts = []
53
+ for (const b of blocks) {
54
+ if (b && b.type === 'text' && typeof b.text === 'string' && b.text.trim()) parts.push(b.text)
55
+ }
56
+ return parts.join('\n').trim()
57
+ }
58
+
59
+ /** dsh 内置 preset 的展示名(与 dsh 客户端一致的英文口径;自定义 preset 用 id 兜底)。 */
60
+ const BUILT_IN_PRESET_LABELS = { standard: 'Standard', ptc: 'PTC', minimal: 'Minimal', cordis: 'Creator mode' }
61
+
62
+ /** 会话**当前** preset:header 只是创建时的值,会话可以改选(记录在 session/agent-preset/selected)。
63
+ * 优先读 sessionProjections 投影,其次扫日志最后一条 selected 事件,最后回退 header。 */
64
+ function currentPresetOf(ctx, session) {
65
+ try {
66
+ const proj = ctx.get('sessionProjections') || ctx.sessionProjections
67
+ const v = proj?.stateOf?.(session, 'agentPreset')
68
+ if (typeof v === 'string' && v) return v
69
+ } catch { /* 投影不可读则走日志 */ }
70
+ const log = Array.isArray(session?.log) ? session.log : []
71
+ for (let i = log.length - 1; i >= 0; i -= 1) {
72
+ const ev = log[i]
73
+ if (ev?.type === 'agent-preset/selected' && ev.data?.agentPreset) return String(ev.data.agentPreset)
74
+ }
75
+ return String(session?.header?.agentPreset || '')
76
+ }
77
+
78
+ const MAX_ATTACH_BYTES = 2 * 1024 * 1024
79
+ const IMAGE_MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }
80
+
81
+ /** 从消息 content 块里取纯文本(不含图片/工具块)。 */
82
+ function textOfContent(content) {
83
+ const blocks = Array.isArray(content) ? content : []
84
+ const parts = []
85
+ for (const b of blocks) if (b && b.type === 'text' && typeof b.text === 'string' && b.text.trim()) parts.push(b.text)
86
+ return parts.join('\n').trim()
87
+ }
88
+
89
+ /** 把 markdown 里的**本地图片**上传到 ERP 附件存储(生产=COS),替换为 `argent-attachment:<id>`。
90
+ * 只处理可读、MIME 白名单、≤2MB 的本地绝对路径;单张失败不影响文本。 */
91
+ async function rewriteLocalImages(cfg, text) {
92
+ const src = String(text || '')
93
+ if (!src || !src.includes('![')) return src
94
+ const re = /!\[([^\]]*)\]\(([^)]+)\)/g
95
+ const jobs = []
96
+ let m
97
+ while ((m = re.exec(src))) {
98
+ const target = String(m[2] || '').trim()
99
+ if (!target || /^https?:\/\//i.test(target) || target.startsWith('argent-attachment:')) continue
100
+ jobs.push({ raw: m[0], alt: m[1], target })
101
+ }
102
+ if (!jobs.length) return src
103
+ let out = src
104
+ for (const job of jobs) {
105
+ const path = job.target.replace(/^file:\/\//, '')
106
+ const ext = (path.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase()
107
+ const mime = IMAGE_MIME[ext]
108
+ if (!mime) continue
109
+ try {
110
+ const buf = await readFile(path)
111
+ if (!buf.length || buf.length > MAX_ATTACH_BYTES) continue
112
+ const res = await apiCall(cfg, 'POST', `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}/attachments`, {
113
+ filename: basename(path).slice(0, 200), mime, dataBase64: buf.toString('base64'), kind: 'machine-image',
114
+ }, 30_000)
115
+ const id = res?.data?.attachment?._id
116
+ if (id) out = out.split(job.raw).join(`![${job.alt}](argent-attachment:${id})`)
117
+ } catch { /* 单张失败:保留原文本 */ }
118
+ }
119
+ return out
120
+ }
121
+
122
+ /** dsh 注入给模型看的运行时上下文/时间戳消息(日志里 role=user,但不是用户真正发的话)。 */
123
+ const INTERNAL_USER_TEXT_RE = /^(?:Time sampled while preparing turn|Current runtime context\.|Browser time zone for this request:|Elapsed since (?:the )?(?:preceding|previous)|Current DSH file policy:|Approval prompts are disabled|Current working directory:|<system-reminder>|This is an automatically generated checkpoint|<goal_round>|<goal_blocked>|\[model changed:|Background subagent |Team message from )/
124
+ /** 只有 source.kind=user 的 user/message 才是真人输入;其余都是 dsh 运行时注入。 */
125
+ const INTERNAL_USER_SOURCE_KINDS = new Set(['agent-instructions','runtime-context','skill-catalog','compact-checkpoint','subagent-settled','goal','tool-goal','tool-jobs','time-context','model-selection','agent-message','team-message'])
126
+
127
+ /** 从 dsh 会话日志提取最近 N 条消息:每个 turn 只保留最后一条 assistant 文本(与 dsh 聊天一致,
128
+ * 不把中间“先查一下/正在处理”等过程消息带到 ERP 历史)。user 消息保留并过滤运行时注入。 */
129
+ function historyFromLog(log, limit) {
130
+ const items = []
131
+ let turnAssistant = ''
132
+ const pushAssistant = (at) => {
133
+ if (!turnAssistant) return
134
+ items.push({ role: 'assistant', text: turnAssistant, at })
135
+ turnAssistant = ''
136
+ }
137
+ for (const ev of (Array.isArray(log) ? log : [])) {
138
+ const at = ev?.time ? new Date(ev.time).toISOString() : ''
139
+ if (ev?.type === 'user/message') {
140
+ pushAssistant(at) // 防御:上个 turn 没有 end 事件时也要收口
141
+ const text = textOfContent(ev.data?.content)
142
+ if (!text) continue
143
+ const sourceKind = String(ev.data?.source?.kind || '')
144
+ if (sourceKind && sourceKind !== 'user') continue // 结构判据优先:合成消息不是用户输入
145
+ if (INTERNAL_USER_TEXT_RE.test(String(text).trim())) continue
146
+ items.push({ role: 'user', text, at })
147
+ } else if (ev?.type === 'assistant/message') {
148
+ const text = textOfContent(ev.data?.message?.content)
149
+ if (text) turnAssistant = text
150
+ } else if (ev?.type === 'turn/end') {
151
+ pushAssistant(at)
152
+ }
153
+ }
154
+ pushAssistant('')
155
+ const n = Math.min(Math.max(Number(limit) || 40, 1), 80)
156
+ return items.slice(-n)
157
+ }
158
+
159
+ /** 统一调用 ERP(出站;runner token)。非 2xx 抛错(错误文本截断,避免刷屏)。 */
160
+ async function apiCall(cfg, method, path, body, timeoutMs = 15_000) {
161
+ const res = await fetch(`${cfg.serverUrl}${path}`, {
162
+ method,
163
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.runnerToken}` },
164
+ body: body === undefined ? undefined : JSON.stringify(body),
165
+ signal: AbortSignal.timeout(timeoutMs),
166
+ })
167
+ const text = await res.text().catch(() => '')
168
+ let json = null
169
+ try { json = JSON.parse(text) } catch { /* 非 JSON */ }
170
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${trunc(text || res.statusText, 160)}`)
171
+ return json
172
+ }
173
+
174
+ /** 收集运行时快照(纯只读;服务缺失时给空结构,不抛错)。 */
175
+ async function collectSnapshot(ctx, cfg) {
176
+ const agentsSvc = ctx.get('agents')
177
+ const jobsSvc = ctx.get('jobs')
178
+ const subagentsSvc = ctx.get('subagents')
179
+ const titleSvc = ctx.get('sessionTitle')
180
+ const live = typeof agentsSvc?.list === 'function' ? agentsSvc.list() : []
181
+ const roots = typeof agentsSvc?.roots === 'function' ? agentsSvc.roots() : live
182
+
183
+ // 子代理目录:root → 后代(parentId/depth),用于标记父子与补全非活跃子会话
184
+ const lineage = new Map()
185
+ const catalogChildren = []
186
+ if (subagentsSvc && typeof subagentsSvc.listDescendants === 'function') {
187
+ for (const root of roots) {
188
+ try {
189
+ const rows = await subagentsSvc.listDescendants(String(root.id))
190
+ for (const row of rows || []) {
191
+ const childId = String(row.childSessionId || row.childId || row.id || '')
192
+ if (!childId) continue
193
+ const parentId = String(row.parentId || root.id)
194
+ lineage.set(childId, { parentId, depth: Number(row.depth) || 1 })
195
+ catalogChildren.push({ id: childId, parentId, depth: Number(row.depth) || 1 })
196
+ }
197
+ } catch { /* 目录不可读:不阻塞上报 */ }
198
+ }
199
+ }
200
+
201
+ const agentsOut = []
202
+ let running = 0
203
+ let idle = 0
204
+ let subagents = 0
205
+ let jobsRunning = 0
206
+ for (const a of live) {
207
+ if (agentsOut.length >= MAX_AGENTS) break
208
+ const id = String(a.id)
209
+ const session = a.session
210
+ const status = a.status === 'running' ? 'running' : 'idle'
211
+ if (status === 'running') running += 1
212
+ else idle += 1
213
+ const preset = currentPresetOf(ctx, session)
214
+ const presetLabel = BUILT_IN_PRESET_LABELS[preset] || ''
215
+ let title = ''
216
+ if (cfg.includeSessionTitles && session) {
217
+ try { title = trunc(titleSvc?.get?.(session)?.title || '', 80) } catch { /* noop */ }
218
+ }
219
+ let jobs = []
220
+ if (jobsSvc && typeof jobsSvc.list === 'function') {
221
+ try { jobs = jobsSvc.list(a.id) || [] } catch { jobs = [] }
222
+ }
223
+ const runningJob = jobs.find((j) => j.status === 'running') || jobs.find((j) => j.status === 'stopping')
224
+ // ⚠️ 只上报「kind + 进度」,⛔ 不上报 job label(bash 的 label 是整条命令,可能带路径/参数);
225
+ // 服务端还有一层兜底脱敏(长命令行式标题 → 「后台任务」)。
226
+ const jobKind = String(runningJob?.kind || '后台')
227
+ const current = runningJob
228
+ ? { kind: 'job', title: trunc(`${jobKind} 任务${runningJob.progress ? ` · ${runningJob.progress}` : ''}`, 60), progress: null }
229
+ : (status === 'running' ? { kind: 'turn', title: '处理中', progress: null } : null)
230
+ const line = lineage.get(id)
231
+ if (line) subagents += 1
232
+ jobsRunning += jobs.filter((j) => j.status === 'running' || j.status === 'stopping').length
233
+ agentsOut.push({
234
+ id,
235
+ parentId: line?.parentId || null,
236
+ depth: line?.depth || 0,
237
+ name: title || `${preset || '会话'} ${id.slice(0, 8)}`,
238
+ preset,
239
+ presetLabel,
240
+ roleLabel: preset ? `AI 员工 · ${presetLabel || preset}` : 'AI 员工',
241
+ status,
242
+ current,
243
+ jobs: jobs.slice(0, 10).map((j) => ({
244
+ id: String(j.id),
245
+ kind: String(j.kind || ''),
246
+ status: String(j.status || ''),
247
+ startedAt: j.startedAt ? new Date(j.startedAt).toISOString() : '',
248
+ })),
249
+ })
250
+ }
251
+ // 目录里有、但当前没有活跃 Agent 的子会话:作为离线/历史叶子补上(最多 50,避免刷屏)
252
+ for (const c of catalogChildren.slice(0, 50)) {
253
+ if (agentsOut.some((a) => a.id === c.id)) continue
254
+ subagents += 1
255
+ agentsOut.push({
256
+ id: c.id, parentId: c.parentId, depth: c.depth,
257
+ name: '子代理(非活跃)', preset: '', roleLabel: 'AI 员工 · 子代理',
258
+ status: 'idle', current: null, jobs: [],
259
+ })
260
+ }
261
+
262
+ // preset 花名册(供 ERP「选 preset 新建会话」;内置 preset 用展示名,自定义用 declaration name/id)
263
+ const presets = []
264
+ let defaultPreset = ''
265
+ try {
266
+ const presetSvc = ctx.get('agentPresets') || ctx.agentPresets
267
+ if (presetSvc && typeof presetSvc.list === 'function') {
268
+ const rows = await presetSvc.list()
269
+ for (const row of (Array.isArray(rows) ? rows : []).slice(0, 60)) {
270
+ if (!row?.id) continue
271
+ presets.push({
272
+ id: String(row.id).slice(0, 80),
273
+ name: String(row.name || BUILT_IN_PRESET_LABELS[row.id] || row.id).slice(0, 80),
274
+ description: row.description ? trunc(row.description, 200) : '',
275
+ broken: row.broken ? trunc(row.broken, 200) : '',
276
+ isDefault: false,
277
+ })
278
+ }
279
+ defaultPreset = String(presetSvc.defaultId || '')
280
+ for (const p of presets) if (p.id === defaultPreset) p.isDefault = true
281
+ }
282
+ } catch { /* 花名册不可读时不影响上报 */ }
283
+
284
+ return {
285
+ schemaVersion: 1,
286
+ adapter: { id: 'dsh', version: '' },
287
+ generatedAt: new Date().toISOString(),
288
+ ...(presets.length ? { presets } : {}),
289
+ ...(defaultPreset ? { defaultPreset } : {}),
290
+ machine: {
291
+ machineId: cfg.machineId,
292
+ name: cfg.machineName,
293
+ platform: `${os.platform()}-${os.arch()}`,
294
+ runtime: { name: 'dsh', version: '' },
295
+ },
296
+ agents: agentsOut,
297
+ kpis: { agents: agentsOut.length, running, idle, subagents, jobsRunning },
298
+ }
299
+ }
300
+
301
+ /**
302
+ * 启动上报循环 + 本机调试端点。
303
+ * @param ctx host 插件 context(需 webServer;agents/jobs/subagents/sessionTitle 可选)
304
+ * @param {{dataDir: string}} opts
305
+ */
306
+ export function startOfficeRuntime(ctx, { dataDir } = {}) {
307
+ const state = { lastOkAt: null, lastPushAt: null, lastError: '', lastReason: '', timer: null, busy: false }
308
+
309
+ const log = (level, msg) => {
310
+ if (level === 'warn') console.warn(`[argent] ${msg}`)
311
+ else console.log(`[argent] ${msg}`)
312
+ }
313
+
314
+ // 桌面端的 launch token 只在壳内部(外部 curl 拿不到)⇒ 把只读状态落到数据目录,
315
+ // 供验收/排障:`cat ~/.dsh/argent/argent-status.json`。
316
+ // ⛔ 不含 token、不含消息正文;写失败绝不影响 ERP 连接。
317
+ // 状态文件放在 **argent 数据目录的兄弟目录**(~/.dsh/argent/),
318
+ // 不放进 ~/.dsh/argent:后者是业务台账目录,agent-bundle-generation 门禁会盯它的零增量;
319
+ // 每 2s 的心跳文件放在那里会让该门禁在"插件正在运行"的机器上误报。
320
+ const STATUS_FILE = dataDir ? join(dirname(dataDir), 'argent', 'argent-status.json') : ''
321
+ function writeStatus() {
322
+ if (!STATUS_FILE) return
323
+ try {
324
+ const cfg = readConfig(dataDir)
325
+ const payload = {
326
+ at: new Date().toISOString(),
327
+ pid: process.pid,
328
+ dataDir,
329
+ enabled: cfg.ok,
330
+ reason: cfg.ok ? '' : cfg.reason,
331
+ machineId: cfg.ok ? cfg.machineId : '',
332
+ tenantAppId: cfg.ok ? cfg.tenantAppId : '',
333
+ serverUrl: cfg.ok ? cfg.serverUrl : '',
334
+ lastOkAt: state.lastOkAt,
335
+ lastPushAt: state.lastPushAt,
336
+ lastError: state.lastError,
337
+ chat: {
338
+ enabled: cfg.ok && cfg.chatEnabled,
339
+ started: chatState.started,
340
+ lastOkAt: chatState.lastOkAt,
341
+ lastCommandAt: chatState.lastCommandAt,
342
+ lastError: chatState.lastError,
343
+ },
344
+ }
345
+ mkdirSync(dirname(STATUS_FILE), { recursive: true, mode: 0o700 })
346
+ const tmp = `${STATUS_FILE}.tmp`
347
+ writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
348
+ renameSync(tmp, STATUS_FILE)
349
+ } catch { /* 状态文件失败不影响 ERP 连接 */ }
350
+ }
351
+
352
+ async function snapshotOrNull() {
353
+ const cfg = readConfig(dataDir)
354
+ if (!cfg.ok) return { cfg, snapshot: null }
355
+ try {
356
+ return { cfg, snapshot: await collectSnapshot(ctx, cfg) }
357
+ } catch (e) {
358
+ return { cfg, snapshot: null, error: String(e?.message || e) }
359
+ }
360
+ }
361
+
362
+ async function pushOnce() {
363
+ if (state.busy) return
364
+ state.busy = true
365
+ try {
366
+ const cfg = readConfig(dataDir)
367
+ if (!cfg.ok) {
368
+ if (state.lastReason !== cfg.reason) {
369
+ state.lastReason = cfg.reason
370
+ if (cfg.reason !== 'missing' && cfg.reason !== 'disabled') log('warn', `未启用上报(${cfg.reason})——见 ${join(dataDir || '~/.dsh/argent', REPORT_FILE)}`)
371
+ }
372
+ return
373
+ }
374
+ state.lastReason = ''
375
+ const snapshot = await collectSnapshot(ctx, cfg)
376
+ const url = `${cfg.serverUrl}/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}/snapshot`
377
+ const res = await fetch(url, {
378
+ method: 'PUT',
379
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.runnerToken}` },
380
+ body: JSON.stringify(snapshot),
381
+ signal: AbortSignal.timeout(10_000),
382
+ })
383
+ state.lastPushAt = new Date().toISOString()
384
+ if (!res.ok) {
385
+ const text = await res.text().catch(() => '')
386
+ throw new Error(`HTTP ${res.status} ${trunc(text, 200)}`)
387
+ }
388
+ if (state.lastError) log('info', '上报已恢复')
389
+ else if (!state.lastOkAt) log('info', `上报已启动:${cfg.tenantAppId} / ${cfg.machineId}(每 ${cfg.intervalSec}s)`)
390
+ state.lastError = ''
391
+ state.lastOkAt = state.lastPushAt
392
+ } catch (e) {
393
+ const msg = trunc(e?.message || e, 300)
394
+ if (msg !== state.lastError) {
395
+ state.lastError = msg
396
+ log('warn', `上报失败(dsh 不受影响,下轮重试):${msg}`)
397
+ }
398
+ } finally {
399
+ state.busy = false
400
+ writeStatus()
401
+ }
402
+ }
403
+
404
+ // ── P2:机器对话执行器(2026-10-21)────────────────────────────────────────
405
+ // ERP 入队命令 → 本机**出站轮询**领取 → sessionController.prompt 投给目标会话 →
406
+ // 监听 session/event 收助手回复 → 回传 ERP。默认关(配置 chatEnabled=true 才启用)。
407
+ // ⛔ 只出站;⛔ 不打印消息正文(日志只记命令/会话 id 前缀与状态)。
408
+ const chatState = { lastOkAt: null, lastError: '', started: false, busy: false, lastCommandAt: null, lastTickAt: 0, activeSessions: new Set(), activeTasks: new Set(), activeTaskCtx: new Map() }
409
+ const waiters = new Map() // sessionId -> waiter(同一 session 同一时刻最多一个)
410
+ // 2026-10-22:长回复不能按总时长一刀切。默认 15 分钟无任何 session 事件才判异常,
411
+ // 单轮最多 60 分钟;期间任何 assistant/tool/turn 事件都会续期。
412
+ const CHAT_IDLE_MS = 15 * 60 * 1000
413
+ const CHAT_MAX_MS = 60 * 60 * 1000
414
+ const CHAT_HEARTBEAT_MS = 30 * 1000
415
+ const TASK_MAX_MS = 45 * 60 * 1000
416
+
417
+ ctx.effect(() => {
418
+ const off = ctx.on('session/event', (session, event) => {
419
+ const sid = String(session?.id || '')
420
+ const w = sid ? waiters.get(sid) : null
421
+ if (!w || w.disposed) return
422
+ w.touch()
423
+ if (event.type === 'user/message') { w.active = true; w.lastText = ''; return }
424
+ if (!w.active) return
425
+ if (event.type === 'assistant/message') {
426
+ const text = extractAssistantText(event.data?.message)
427
+ if (text) w.lastText = text
428
+ return
429
+ }
430
+ if (event.type === 'turn/end') {
431
+ // 本轮显式失败(模型鉴权/网络/限流等)时把真实原因回传 ERP ——
432
+ // 不要把 "ACCOUNT_SIGN_IN_REQUIRED" 这类可行动错误吞成"没有文本回复"。
433
+ const reason = event.data?.reason
434
+ if (reason?.kind === 'error') {
435
+ const detail = reason.error?.message || reason.error?.code || 'agent 本轮失败'
436
+ w.reject(new Error(String(detail)))
437
+ return
438
+ }
439
+ const text = w.lastText || ''
440
+ if (text) w.resolve(text)
441
+ else w.reject(new Error('agent 本轮没有文本回复'))
442
+ }
443
+ })
444
+ return () => {
445
+ try { off?.() } catch { /* noop */ }
446
+ for (const w of [...waiters.values()]) { try { w.reject(new Error('office runtime disposed')) } catch { /* noop */ } }
447
+ }
448
+ }, 'argent: office chat session events')
449
+
450
+ function makeChatWaiter(sessionId, requestId) {
451
+ const settle = {}
452
+ const promise = new Promise((res, rej) => { settle.res = res; settle.rej = rej })
453
+ const w = {
454
+ sessionId, requestId, promise, active: false, lastText: '', disposed: false,
455
+ idleTimer: null, maxTimer: null, lastActivityAt: Date.now(),
456
+ touch() {
457
+ if (w.disposed) return
458
+ w.lastActivityAt = Date.now()
459
+ clearTimeout(w.idleTimer)
460
+ w.idleTimer = setTimeout(() => w.reject(Object.assign(
461
+ new Error(`AI 连续 ${Math.round(CHAT_IDLE_MS / 60000)} 分钟无活动`), { code: 'chat_idle_timeout' },
462
+ )), CHAT_IDLE_MS)
463
+ },
464
+ resolve(v) {
465
+ if (w.disposed) return
466
+ w.disposed = true
467
+ clearTimeout(w.idleTimer); clearTimeout(w.maxTimer)
468
+ waiters.delete(sessionId); settle.res(v)
469
+ },
470
+ reject(e) {
471
+ if (w.disposed) return
472
+ w.disposed = true
473
+ clearTimeout(w.idleTimer); clearTimeout(w.maxTimer)
474
+ waiters.delete(sessionId); settle.rej(e)
475
+ },
476
+ dispose() {
477
+ w.disposed = true
478
+ clearTimeout(w.idleTimer); clearTimeout(w.maxTimer)
479
+ if (waiters.get(sessionId) === w) waiters.delete(sessionId)
480
+ },
481
+ }
482
+ w.maxTimer = setTimeout(() => w.reject(Object.assign(
483
+ new Error(`AI 单轮超过 ${Math.round(CHAT_MAX_MS / 60000)} 分钟`), { code: 'chat_max_timeout' },
484
+ )), CHAT_MAX_MS)
485
+ w.touch()
486
+ waiters.set(sessionId, w)
487
+ return w
488
+ }
489
+
490
+ /** 执行期间定期续租;旧服务端没有 heartbeat 路由时静默失败,不中断本轮。 */
491
+ function startChatHeartbeat(cfg, cmdBase) {
492
+ let stopped = false
493
+ const beat = async () => {
494
+ if (stopped) return
495
+ try {
496
+ await apiCall(cfg, 'POST', `${cmdBase}/heartbeat`, { progressText: 'AI 正在处理中' })
497
+ } catch { /* 旧服务端/no-op */ }
498
+ }
499
+ void beat()
500
+ const timer = setInterval(() => { void beat() }, CHAT_HEARTBEAT_MS)
501
+ return () => { stopped = true; clearInterval(timer) }
502
+ }
503
+
504
+ /** 单独处理 kind=cancel:领取 → sessionController.cancel → 回执。 */
505
+ async function executeCancelCommand(cfg, command) {
506
+ const cid = String(command?.commandId || '')
507
+ if (!cid) return false
508
+ const cmdBase = `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}/commands/${encodeURIComponent(cid)}`
509
+ try {
510
+ await apiCall(cfg, 'POST', `${cmdBase}/claim`, {})
511
+ } catch { return false }
512
+ let status = 'done'
513
+ let error = ''
514
+ try {
515
+ const sc = ctx.get('sessionController') || ctx.sessionController
516
+ if (!sc || typeof sc.cancel !== 'function') throw new Error('sessionController.cancel 不可用(该 dsh 版本不支持)')
517
+ sc.cancel({ sessionId: command.sessionId })
518
+ } catch (e) {
519
+ status = 'failed'
520
+ error = trunc(e?.message || e, 300)
521
+ }
522
+ try {
523
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, { status, error })
524
+ log('info', `暂停命令 ${cid.slice(0, 8)} 已回传(${status}${error ? `:${error}` : ''})`)
525
+ } catch (e) {
526
+ log('warn', `暂停回执失败:${trunc(e?.message || e, 200)}`)
527
+ }
528
+ return status === 'done'
529
+ }
530
+
531
+ /** 任务 brief:只描述业务目标/验收/允许动作;⛔ 不拼接任意 shell。 */
532
+ function buildTaskPrompt(command) {
533
+ const task = command?.task && typeof command.task === 'object' ? command.task : {}
534
+ const lines = [
535
+ '# 多机器任务命令',
536
+ '',
537
+ `WorkItem: ${String(command.workItemId || '')}`,
538
+ `AgentNode: ${String(command.agentId || '')}`,
539
+ `任务标题: ${String(task.title || '')}`,
540
+ `类型/等级/风险: ${String(task.type || '')} / ${String(task.level || '')} / ${String(task.risk || '')}`,
541
+ `授权层级: ${String(task.authority || '')}`,
542
+ `仓库: ${String(task.repo || '')}`,
543
+ `基准分支: ${String(task.baseRef || '')}`,
544
+ `Worktree: ${String(task.worktree || '')}`,
545
+ `允许动作: ${Array.isArray(task.actions) ? task.actions.join(', ') : ''}`,
546
+ '',
547
+ '## 验收标准',
548
+ ...(Array.isArray(task.acceptance) ? task.acceptance.map((x) => `- ${x}`) : ['- 无']),
549
+ '',
550
+ '## 任务说明',
551
+ String(task.instructions || '按验收标准完成任务,并回报实际改动、验证命令/结果、风险与未决问题。'),
552
+ '',
553
+ '## 回传要求',
554
+ '- 实际改了哪些文件/仓库/分支',
555
+ '- 执行了哪些验证,结果如何',
556
+ '- 未能完成的部分与原因',
557
+ '- 不要伪造测试/提交/部署结果',
558
+ ]
559
+ if (task.skuFlowId || task.step) {
560
+ const skuStep = String(task.step || '')
561
+ const example = skuStep === 'productLabel'
562
+ ? '{"files":[{"path":"/abs/product-label.pdf","kind":"product_label"},{"path":"/abs/carton.pdf","kind":"carton"}]}'
563
+ : '{"files":[{"path":"/abs/bulk-label.pdf","kind":"bulk_label"}]}'
564
+ lines.push(
565
+ '',
566
+ '## 产物回传(SkuFlow)',
567
+ '- 执行完成后,把产物 PDF 的**绝对路径**放在最终回复最后一行:',
568
+ `FULFILLMENT_RESULT: ${example}`,
569
+ skuStep === 'productLabel'
570
+ ? '- 产品标任务必须同时回传 product_label 和 carton(同一个 FULL 货件产出),否则节点会被判失败。'
571
+ : '- kind 只能是 bulk_label;单文件 ≤2MB;不要上传无关文件。',
572
+ )
573
+ }
574
+ return lines.join('\n').slice(0, 30_000)
575
+ }
576
+
577
+ /** 引擎目录:**只认契约**(`ARGENT_ENGINE_DIR` → `<profile>/argent.json`),不再猜候选路径。 */
578
+ function resolveEngineDir() {
579
+ return engineDirSync()
580
+ }
581
+
582
+ /** SkuFlow 发票任务:复用既有鑫中航/文鳐流程;不走 LLM 会话;可 kill。 */
583
+ async function executeSkuInvoiceTask(cfg, command, cmdBase, task) {
584
+ const cid = String(command?.commandId || '')
585
+ const workItemId = String(command?.workItemId || '')
586
+ let stdout = ''
587
+ let stderr = ''
588
+ try {
589
+ const record = { ...(task.context || {}), ...task }
590
+ const ac = new AbortController()
591
+ const r = await runEngineTask({
592
+ kind: 'skuflow', record, confirm: true, timeoutMs: TASK_MAX_MS,
593
+ onStart: (h) => chatState.activeTaskCtx.set(cid, { ac, child: h }),
594
+ })
595
+ chatState.activeTaskCtx.delete(cid)
596
+ const result = r.result || {}
597
+ if (!r.ok || result.ok === false) {
598
+ throw new Error(result.error || r.error || `invoice runner exit=${r.code}: ${String(r.stderr || '').slice(-300)}`)
599
+ }
600
+ const files = []
601
+ if (result.invoicePath) files.push({ path: result.invoicePath, kind: 'invoice' })
602
+ if (result.bulkLabelPath) files.push({ path: result.bulkLabelPath, kind: 'bulk_label' })
603
+ const uploaded = await uploadFulfillmentFiles({
604
+ files, cmdBase: `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}`,
605
+ apiCall: (method, path, body, timeoutMs) => apiCall(cfg, method, path, body, timeoutMs),
606
+ })
607
+ if (uploaded.errors.length) throw new Error(`发票/大货标签上传失败: ${uploaded.errors.join('; ')}`)
608
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, {
609
+ status: 'done', replyText: `SkuFlow 发票完成:${result.orderNo || ''}`,
610
+ resultData: { workItemId, orderNo: result.orderNo || '', files: uploaded.files, summary: `发票/大货标签完成` },
611
+ })
612
+ log('info', `SkuFlow 发票任务 ${cid.slice(0, 8)} 完成 order=${result.orderNo || ''}`)
613
+ } catch (e) {
614
+ chatState.activeTaskCtx.delete(cid)
615
+ try { await apiCall(cfg, 'POST', `${cmdBase}/reply`, { status: 'failed', error: trunc(e?.message || e, 300), resultData: { workItemId, error: trunc(e?.message || e, 300) } }) } catch { /* noop */ }
616
+ }
617
+ }
618
+
619
+ /** SkuFlow FULL 任务:确定性 orchestrator,不走 LLM/会话;可被 task_cancel kill。 */
620
+ async function executeSkuFullTask(cfg, command, cmdBase, task) {
621
+ const cid = String(command?.commandId || '')
622
+ const workItemId = String(command?.workItemId || '')
623
+ let status = 'done'
624
+ let error = ''
625
+ let stdout = ''
626
+ let stderr = ''
627
+ const startedAt = Date.now()
628
+ try {
629
+ const record = buildSkuFlowRecord(task)
630
+ const ac = new AbortController()
631
+ const r = await runEngineTask({
632
+ kind: 'orchestrator', record, confirm: true, timeoutMs: TASK_MAX_MS,
633
+ onStart: (h) => chatState.activeTaskCtx.set(cid, { ac, child: h }),
634
+ })
635
+ chatState.activeTaskCtx.delete(cid)
636
+ if (!r.ok) throw new Error(`orchestrator exit=${r.code}: ${String(r.stderr || '').slice(-300)}`)
637
+ const result = r.result || {}
638
+ if (!['success', 'partial', 'already_completed'].includes(String(result.status || ''))) {
639
+ throw new Error(`orchestrator status=${result.status || 'unknown'} step=${result.failed_step ?? ''}`)
640
+ }
641
+ const shipmentNo = String(result.shipment_id || '')
642
+ // 紫鸟下载目录有两种形态:<ziniao browser>/<店铺名>/ 与直接 <ziniao browser>/(编排器默认)
643
+ // ⇒ 两处都找,避免「PDF 已生成却报未找到」(2026-11-03 实测:文件落在根目录)
644
+ const dlRoot = join(os.homedir(), 'Library', 'Application Support', 'ziniaobrowserdatas', 'ziniao browser')
645
+ const artifacts = findSkuFlowArtifactsIn({
646
+ dirs: [join(dlRoot, String(task.store || '')), dlRoot],
647
+ startedAt, shipmentNo, readdir: readdirSync, stat: statSync,
648
+ })
649
+ const { product, carton } = artifacts
650
+ if (!product || !carton) throw new Error(`未找到产品标/箱唛 PDF(shipment=${shipmentNo},files=${artifacts.files.map((x) => x.name).join('|')})`)
651
+ const uploaded = await uploadFulfillmentFiles({
652
+ cmdBase: `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}`,
653
+ apiCall: (method, path, body, timeoutMs) => apiCall(cfg, method, path, body, timeoutMs),
654
+ files: [
655
+ { path: product.path, kind: 'product_label', filename: product.name },
656
+ { path: carton.path, kind: 'carton', filename: carton.name },
657
+ ],
658
+ })
659
+ if (uploaded.errors.length) throw new Error(`产物上传失败: ${uploaded.errors.join('; ')}`)
660
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, {
661
+ status: 'done', replyText: `SkuFlow FULL 完成:${shipmentNo}`,
662
+ resultData: { workItemId, shipmentNo, files: uploaded.files, summary: `FULL 完成:${shipmentNo}` },
663
+ })
664
+ log('info', `SkuFlow FULL 任务 ${cid.slice(0, 8)} 完成 shipment=${shipmentNo} files=${uploaded.files.length}`)
665
+ } catch (e) {
666
+ status = 'failed'
667
+ error = trunc(e?.message || e, 300)
668
+ chatState.activeTaskCtx.delete(cid)
669
+ try {
670
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, { status, error, resultData: { workItemId, error } })
671
+ } catch (e2) {
672
+ log('warn', `SkuFlow FULL 回执失败:${trunc(e2?.message || e2, 200)}`)
673
+ }
674
+ }
675
+ }
676
+
677
+ /** kind=task:创建任务会话 → 执行 → 回传结果。 */
678
+ async function executeTaskCommand(cfg, command, cmdBase) {
679
+ const cid = String(command?.commandId || '')
680
+ const task = command?.task && typeof command.task === 'object' ? command.task : {}
681
+ if (String(task.kind || '') === 'sku.full') {
682
+ await executeSkuFullTask(cfg, command, cmdBase, { ...task, ...(task.context || {}) })
683
+ return
684
+ }
685
+ if (String(task.kind || '') === 'sku.invoice') {
686
+ await executeSkuInvoiceTask(cfg, command, cmdBase, task)
687
+ return
688
+ }
689
+ let status = 'done'
690
+ let replyText = ''
691
+ let error = ''
692
+ let resultSessionId = ''
693
+ const resultData = {}
694
+ try {
695
+ const sc = ctx.get('sessionController') || ctx.sessionController
696
+ if (!sc || typeof sc.create !== 'function' || typeof sc.prompt !== 'function') {
697
+ throw new Error('sessionController.create/prompt 不可用(该 dsh 版本不支持)')
698
+ }
699
+ const created = await sc.create({ agentPreset: String(command.preset || '') || undefined })
700
+ resultSessionId = String(created?.sessionId || '')
701
+ if (!resultSessionId) throw new Error('create 未返回 sessionId')
702
+ const waiter = makeChatWaiter(resultSessionId, cid)
703
+ const stopHeartbeat = startChatHeartbeat(cfg, cmdBase)
704
+ const ac = new AbortController()
705
+ const timer = setTimeout(() => ac.abort(), TASK_MAX_MS + 30_000)
706
+ chatState.activeTaskCtx.set(cid, { ac, sessionId: resultSessionId })
707
+ try {
708
+ await sc.prompt(
709
+ { requestId: cid, sessionId: resultSessionId, mode: 'queue', content: [{ type: 'text', text: buildTaskPrompt(command) }] },
710
+ ac.signal,
711
+ )
712
+ replyText = await waiter.promise
713
+ } finally {
714
+ clearTimeout(timer)
715
+ chatState.activeTaskCtx.delete(cid)
716
+ stopHeartbeat()
717
+ waiter.dispose()
718
+ }
719
+ if (status === 'done' && (task.skuFlowId || task.step)) {
720
+ const parsed = extractFulfillmentResult(replyText)
721
+ const errors = parsed.errors.slice()
722
+ let uploaded = { files: [], errors: [] }
723
+ if (parsed.files.length) {
724
+ const machineBase = `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}`
725
+ uploaded = await uploadFulfillmentFiles({
726
+ files: parsed.files, cmdBase: machineBase,
727
+ apiCall: (method, path, body, timeoutMs) => apiCall(cfg, method, path, body, timeoutMs),
728
+ })
729
+ errors.push(...uploaded.errors)
730
+ } else if (!errors.length) {
731
+ errors.push('未找到 FULFILLMENT_RESULT 产物回传')
732
+ }
733
+ resultData.files = uploaded.files
734
+ resultData.fileErrors = errors.slice(0, 20)
735
+ for (const x of errors) log('warn', `SkuFlow 产物回传: ${trunc(x, 200)}`)
736
+ }
737
+ } catch (e) {
738
+ status = 'failed'
739
+ error = e?.name === 'AbortError' || e?.code === 'ABORT_ERR' ? '任务已取消或超时' : trunc(e?.message || e, 300)
740
+ }
741
+ try {
742
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, {
743
+ status,
744
+ replyText: status === 'done' ? trunc(replyText, 7000) : '',
745
+ error,
746
+ resultSessionId,
747
+ resultData: {
748
+ ...resultData,
749
+ workItemId: String(command.workItemId || ''),
750
+ agentId: String(command.agentId || ''),
751
+ sessionId: resultSessionId,
752
+ summary: trunc(replyText, 2000),
753
+ taskTitle: String(task.title || ''),
754
+ },
755
+ })
756
+ chatState.lastCommandAt = new Date().toISOString()
757
+ log('info', `任务命令 ${cid.slice(0, 8)} 已回传(${status}${resultSessionId ? ` → ${resultSessionId.slice(0, 12)}` : ''})`)
758
+ } catch (e) {
759
+ const msg = trunc(e?.message || e, 200)
760
+ if (msg !== chatState.lastError) { chatState.lastError = msg; log('warn', `任务回执失败:${msg}`) }
761
+ }
762
+ }
763
+
764
+ /** kind=task_cancel:领取取消命令 → abort 对应任务会话 → 回执。 */
765
+ async function handleTaskCancelCommand(cfg, command) {
766
+ const cid = String(command?.commandId || '')
767
+ const target = String(command?.targetCommandId || '')
768
+ if (!cid) return
769
+ const cmdBase = `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}/commands/${encodeURIComponent(cid)}`
770
+ try { await apiCall(cfg, 'POST', `${cmdBase}/claim`, {}) } catch { return }
771
+ const active = target ? chatState.activeTaskCtx.get(target) : null
772
+ let status = 'done'
773
+ let error = ''
774
+ if (active) {
775
+ try { active.ac.abort() } catch { /* noop */ }
776
+ try { active.child?.kill('SIGTERM') } catch { /* noop */ }
777
+ try {
778
+ const sc = ctx.get('sessionController') || ctx.sessionController
779
+ if (sc && typeof sc.cancel === 'function' && active.sessionId) sc.cancel({ sessionId: active.sessionId })
780
+ } catch { /* 会话可能已结束 */ }
781
+ } else {
782
+ error = '任务未在执行(可能已结束)'
783
+ status = 'done'
784
+ }
785
+ try {
786
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, { status, error })
787
+ log('info', `任务取消命令 ${cid.slice(0, 8)} 已回传(${active ? 'aborted' : 'no-active-task'})`)
788
+ } catch (e) {
789
+ log('warn', `任务取消回执失败:${trunc(e?.message || e, 200)}`)
790
+ }
791
+ }
792
+
793
+ async function handleChatCommand(cfg, command) {
794
+ const cid = String(command?.commandId || '')
795
+ const sid = String(command?.sessionId || '')
796
+ if (!cid) return
797
+ if (command.kind !== 'create_session' && command.kind !== 'task' && !sid) return
798
+ const cmdBase = `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}/commands/${encodeURIComponent(cid)}`
799
+ try {
800
+ await apiCall(cfg, 'POST', `${cmdBase}/claim`, {})
801
+ } catch { return } // 404 已被处理 / 409 已被领取:跳过
802
+ log('info', `收到机器命令 ${cid.slice(0, 8)}${command.kind === 'create_session' ? `(新建会话 preset=${String(command.preset || '').slice(0, 32)})` : command.kind === 'task' ? `(任务 ${String(command.workItemId || '').slice(0, 24)})` : `(会话 ${sid.slice(0, 8)})`}`)
803
+ // ── 任务命令:创建任务会话 → 执行 → 回传(Phase 3)──
804
+ if (command.kind === 'task') {
805
+ await executeTaskCommand(cfg, command, cmdBase)
806
+ chatState.lastCommandAt = new Date().toISOString()
807
+ return
808
+ }
809
+ // ── 暂停本轮:调用 sessionController.cancel 中断 active turn(待办 inbox 不丢)──
810
+ if (command.kind === 'cancel') {
811
+ await executeCancelCommand(cfg, command)
812
+ chatState.lastCommandAt = new Date().toISOString()
813
+ return
814
+ }
815
+ // ── 读取会话历史:读 dsh 会话日志 → resultData.messages(本地图片顺带上传 COS)──
816
+ if (command.kind === 'history') {
817
+ let status = 'done'
818
+ let error = ''
819
+ let messages = []
820
+ try {
821
+ const sc = ctx.get('sessionController') || ctx.sessionController
822
+ if (!sc) throw new Error('sessionController 不可用')
823
+ // 优先 inspect(冷读、不激活 Agent,返回完整 events);失败再退回 resolveAgent().session.log
824
+ let events = []
825
+ if (typeof sc.inspect === 'function') {
826
+ try { const st = await sc.inspect(command.sessionId); events = Array.isArray(st?.events) ? st.events : [] } catch { events = [] }
827
+ }
828
+ if (!events.length) {
829
+ if (typeof sc.resolveAgent !== 'function') throw new Error('sessionController.inspect/resolveAgent 都不可用')
830
+ const agent = await sc.resolveAgent(command.sessionId)
831
+ events = Array.isArray(agent?.session?.log) ? agent.session.log : []
832
+ }
833
+ const raw = historyFromLog(events, command.historyLimit)
834
+ for (const it of raw) {
835
+ messages.push({
836
+ role: it.role,
837
+ text: it.role === 'assistant' ? await rewriteLocalImages(cfg, it.text) : it.text,
838
+ at: it.at,
839
+ })
840
+ }
841
+ } catch (e) {
842
+ status = 'failed'
843
+ error = trunc(e?.message || e, 300)
844
+ }
845
+ try {
846
+ const resultData = messages.length
847
+ ? { messages }
848
+ : { messages, debug: { events: Array.isArray(events) ? events.length : -1, types: [...new Set((Array.isArray(events) ? events : []).slice(-300).map((e) => e?.type).filter(Boolean))].slice(0, 15) } }
849
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, { status, resultData, error })
850
+ chatState.lastCommandAt = new Date().toISOString()
851
+ log('info', `会话历史命令 ${cid.slice(0, 8)} 已回传(${status},${messages.length} 条)`)
852
+ } catch (e) {
853
+ const msg = trunc(e?.message || e, 200)
854
+ if (msg !== chatState.lastError) { chatState.lastError = msg; log('warn', `历史回执失败:${msg}`) }
855
+ }
856
+ return
857
+ }
858
+
859
+ // ── 新建会话:选 preset 开一个新 dsh 会话,回传 resultSessionId,并立即刷新一次快照 ──
860
+ if (command.kind === 'create_session') {
861
+ let status = 'done'
862
+ let resultSessionId = ''
863
+ let error = ''
864
+ try {
865
+ const sc = ctx.get('sessionController') || ctx.sessionController
866
+ if (!sc || typeof sc.create !== 'function') throw new Error('sessionController.create 不可用(该 dsh 版本不支持)')
867
+ const created = await sc.create({ agentPreset: String(command.preset || '') || undefined })
868
+ resultSessionId = String(created?.sessionId || '')
869
+ if (!resultSessionId) throw new Error('create 未返回 sessionId')
870
+ } catch (e) {
871
+ status = 'failed'
872
+ error = trunc(e?.message || e, 300)
873
+ }
874
+ try {
875
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, {
876
+ status,
877
+ resultSessionId: status === 'done' ? resultSessionId : '',
878
+ error,
879
+ })
880
+ chatState.lastCommandAt = new Date().toISOString()
881
+ log('info', `新建会话命令 ${cid.slice(0, 8)} 已回传(${status}${resultSessionId ? ` → ${resultSessionId.slice(0, 12)}` : ''})`)
882
+ if (status === 'done') void pushOnce() // 让新会话尽快出现在办公室快照
883
+ } catch (e) {
884
+ const msg = trunc(e?.message || e, 200)
885
+ if (msg !== chatState.lastError) { chatState.lastError = msg; log('warn', `新建会话回执失败:${msg}`) }
886
+ }
887
+ return
888
+ }
889
+ let status = 'done'
890
+ let replyText = ''
891
+ let error = ''
892
+ let sc = null
893
+ try {
894
+ sc = ctx.get('sessionController') || ctx.sessionController
895
+ if (!sc || typeof sc.prompt !== 'function') throw new Error('sessionController 不可用(该 dsh 版本不支持)')
896
+ const waiter = makeChatWaiter(sid, cid)
897
+ const stopHeartbeat = startChatHeartbeat(cfg, cmdBase)
898
+ // ⚠️ 关键:本轮 prompt 的 await 会阻塞 chatTick,cancel 命令必须走独立小轮询,
899
+ // 否则暂停要等 AI 本轮结束才被领取(2026-10-22 实测“UI 暂停了但 dsh 还在跑”)。
900
+ let cancelSeen = false
901
+ const cancelWatch = setInterval(() => {
902
+ if (cancelSeen) return
903
+ void (async () => {
904
+ try {
905
+ const d = await apiCall(cfg, 'GET', `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}/commands?limit=5`)
906
+ const commands = Array.isArray(d?.data?.commands) ? d.data.commands : []
907
+ const c = commands.find((x) => x && x.kind === 'cancel' && String(x.sessionId || '') === sid)
908
+ if (!c) return
909
+ cancelSeen = true
910
+ await executeCancelCommand(cfg, c)
911
+ } catch { /* 下一轮重试 */ }
912
+ })()
913
+ }, 1500)
914
+ try {
915
+ await sc.prompt(
916
+ { requestId: cid, sessionId: sid, mode: 'queue', content: [{ type: 'text', text: String(command.text || '').slice(0, 4000) }] },
917
+ AbortSignal.timeout(CHAT_MAX_MS + 30_000),
918
+ )
919
+ replyText = await waiter.promise
920
+ } finally {
921
+ clearInterval(cancelWatch)
922
+ stopHeartbeat()
923
+ waiter.dispose()
924
+ }
925
+ } catch (e) {
926
+ status = 'failed'
927
+ error = trunc(e?.message || e, 300)
928
+ // 超时/无活动时主动中断本轮,避免 sc.prompt 一直挂在后台阻塞后续命令。
929
+ if (e && (e.code === 'chat_idle_timeout' || e.code === 'chat_max_timeout')) {
930
+ try { sc?.cancel?.({ sessionId: sid }) } catch { /* noop */ }
931
+ }
932
+ }
933
+ try {
934
+ if (status === 'done' && replyText) {
935
+ try { replyText = await rewriteLocalImages(cfg, replyText) } catch { /* 图片上传失败不影响回复 */ }
936
+ }
937
+ await apiCall(cfg, 'POST', `${cmdBase}/reply`, {
938
+ status,
939
+ replyText: status === 'done' ? trunc(replyText, 7000) : '',
940
+ error,
941
+ })
942
+ chatState.lastCommandAt = new Date().toISOString()
943
+ log('info', `命令 ${cid.slice(0, 8)} 已回传(${status})`)
944
+ } catch (e) {
945
+ const msg = trunc(e?.message || e, 200)
946
+ if (msg !== chatState.lastError) { chatState.lastError = msg; log('warn', `命令回执失败:${msg}`) }
947
+ }
948
+ }
949
+
950
+ async function chatTick() {
951
+ if (chatState.busy) return
952
+ const cfg = readConfig(dataDir)
953
+ if (!cfg.ok || !cfg.chatEnabled) {
954
+ if (chatState.started) { chatState.started = false; log('info', '机器对话已停止(配置关闭)') }
955
+ return
956
+ }
957
+ const now = Date.now()
958
+ if (now - chatState.lastTickAt < cfg.chatPollSec * 1000) return
959
+ chatState.lastTickAt = now
960
+ if (!chatState.started) {
961
+ chatState.started = true
962
+ log('info', `机器对话已启动:${cfg.tenantAppId} / ${cfg.machineId}(每 ${cfg.chatPollSec}s 轮询)`)
963
+ }
964
+ chatState.busy = true
965
+ try {
966
+ const data = await apiCall(cfg, 'GET', `/api/argent/v1/tenants/${encodeURIComponent(cfg.tenantAppId)}/machines/${encodeURIComponent(cfg.machineId)}/commands?limit=5`)
967
+ const commands = Array.isArray(data?.data?.commands) ? data.data.commands : []
968
+ for (const command of commands) {
969
+ const sid = String(command.sessionId || '')
970
+ if (command.kind === 'chat') {
971
+ // 同一 session 串行;不同 session 可并行,长任务不再堵住整台机器。
972
+ if (!sid || chatState.activeSessions.has(sid)) continue
973
+ chatState.activeSessions.add(sid)
974
+ void handleChatCommand(cfg, command)
975
+ .catch((e) => log('warn', `机器对话命令异常:${trunc(e?.message || e, 160)}`))
976
+ .finally(() => chatState.activeSessions.delete(sid))
977
+ } else if (command.kind === 'task_cancel') {
978
+ await handleTaskCancelCommand(cfg, command)
979
+ } else if (command.kind === 'task') {
980
+ // 任务可并行,但每台机器限制 2 个,避免抢占本地资源;不阻塞轮询 cancel/其他命令。
981
+ const tcid = String(command.commandId || '')
982
+ if (!tcid || chatState.activeTasks.has(tcid) || chatState.activeTasks.size >= 2) continue
983
+ chatState.activeTasks.add(tcid)
984
+ void handleChatCommand(cfg, command)
985
+ .catch((e) => log('warn', `任务命令异常:${trunc(e?.message || e, 160)}`))
986
+ .finally(() => chatState.activeTasks.delete(tcid))
987
+ } else {
988
+ await handleChatCommand(cfg, command)
989
+ }
990
+ }
991
+ chatState.lastOkAt = new Date().toISOString()
992
+ if (chatState.lastError) { chatState.lastError = ''; log('info', '机器对话轮询已恢复') }
993
+ } catch (e) {
994
+ const msg = trunc(e?.message || e, 300)
995
+ if (msg !== chatState.lastError) { chatState.lastError = msg; log('warn', `机器对话轮询失败(下轮重试):${msg}`) }
996
+ } finally {
997
+ chatState.busy = false
998
+ writeStatus()
999
+ }
1000
+ }
1001
+
1002
+ ctx.effect(() => {
1003
+ const timer = setInterval(() => { void chatTick() }, 2_000)
1004
+ const kick = setTimeout(() => { void chatTick() }, 5_000)
1005
+ return () => { clearInterval(timer); clearTimeout(kick) }
1006
+ }, 'argent: office chat executor')
1007
+
1008
+ // 定时器:每 5s 检查一次配置(配置里的 intervalSec 控制实际推送节奏)
1009
+ let lastTick = 0
1010
+ ctx.effect(() => {
1011
+ const timer = setInterval(() => {
1012
+ const cfg = readConfig(dataDir)
1013
+ const intervalMs = (cfg.ok ? cfg.intervalSec : 30) * 1000
1014
+ const now = Date.now()
1015
+ if (now - lastTick < intervalMs) return
1016
+ lastTick = now
1017
+ void pushOnce()
1018
+ }, 5_000)
1019
+ // 启动后 3s 先来一次(把 dsh 刚起来的状态尽快送到 ERP)
1020
+ const kick = setTimeout(() => { lastTick = Date.now(); void pushOnce() }, 3_000)
1021
+ return () => { clearInterval(timer); clearTimeout(kick) }
1022
+ }, 'argent: office runtime reporter')
1023
+
1024
+ // ── 本机只读调试端点(仅回环;给办公室页/排障用,不暴露任何令牌)──
1025
+ const loopbackOnly = (req) => LOOPBACK.has(String(req.socket?.remoteAddress || ''))
1026
+ ctx.effect(() => ctx.webServer.register({
1027
+ kind: 'prefix',
1028
+ path: '/api/argent-office',
1029
+ handler: async (req, res) => {
1030
+ const json = (code, body) => {
1031
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
1032
+ res.end(JSON.stringify(body))
1033
+ }
1034
+ if (!loopbackOnly(req)) return json(403, { ok: false, error: 'loopback-only' })
1035
+ try {
1036
+ const path = new URL(req.url || '/', 'http://localhost').pathname
1037
+ if (path === '/api/argent-office/status') {
1038
+ const cfg = readConfig(dataDir)
1039
+ return json(200, {
1040
+ ok: true,
1041
+ enabled: cfg.ok,
1042
+ reason: cfg.ok ? '' : cfg.reason,
1043
+ machineId: cfg.ok ? cfg.machineId : '',
1044
+ tenantAppId: cfg.ok ? cfg.tenantAppId : '',
1045
+ serverUrl: cfg.ok ? cfg.serverUrl : '',
1046
+ intervalSec: cfg.ok ? cfg.intervalSec : null,
1047
+ lastOkAt: state.lastOkAt,
1048
+ lastPushAt: state.lastPushAt,
1049
+ lastError: state.lastError,
1050
+ chat: {
1051
+ enabled: cfg.ok && cfg.chatEnabled,
1052
+ pollSec: cfg.ok ? cfg.chatPollSec : null,
1053
+ started: chatState.started,
1054
+ lastOkAt: chatState.lastOkAt,
1055
+ lastCommandAt: chatState.lastCommandAt,
1056
+ lastError: chatState.lastError,
1057
+ },
1058
+ configPath: join(dataDir || '~/.dsh/argent', REPORT_FILE),
1059
+ })
1060
+ }
1061
+ if (path === '/api/argent-office/snapshot') {
1062
+ const { cfg, snapshot, error } = await snapshotOrNull()
1063
+ if (!snapshot) return json(200, { ok: false, error: error || `not-enabled:${cfg.reason}`, snapshot: null })
1064
+ return json(200, { ok: true, snapshot })
1065
+ }
1066
+ return json(404, { ok: false, error: 'not-found' })
1067
+ } catch (e) {
1068
+ return json(500, { ok: false, error: String(e?.message || e) })
1069
+ }
1070
+ },
1071
+ }), 'argent: office local endpoints')
1072
+
1073
+ // ── 桌宠 / 验收共用的健康聚合(2026-10-29):宿主全局认证兜底,不返回 token/正文 ──
1074
+ ctx.effect(() => ctx.webServer.register({
1075
+ kind: 'prefix',
1076
+ path: '/api/argent/health',
1077
+ handler: async (req, res) => {
1078
+ const json = (code, body) => {
1079
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
1080
+ res.end(JSON.stringify(body))
1081
+ }
1082
+ try {
1083
+ const u = new URL(req.url || '/', 'http://localhost')
1084
+ if (u.pathname !== '/api/argent/health') return json(404, { ok: false, error: 'not-found' })
1085
+ if ((req.method || 'GET') !== 'GET') return json(405, { ok: false, error: 'method-not-allowed' })
1086
+ const health = await collectHealth({
1087
+ ctx, dataDir, state, chatState,
1088
+ refresh: u.searchParams.get('refresh') === '1',
1089
+ })
1090
+ return json(200, health)
1091
+ } catch (e) {
1092
+ return json(500, { ok: false, error: String(e?.message || e) })
1093
+ }
1094
+ },
1095
+ }), 'argent: health endpoint')
1096
+
1097
+ writeStatus() // 启动即落一份,验收不必等第一个 tick
1098
+ return state
1099
+ }
1100
+
1101
+ // 供回归用例直接验证配置契约(不发网络请求、不依赖宿主)
1102
+ export { readConfig }