@hklmtt/falinks
Advanced tools
+9
-0
| # Changelog | ||
| ## 0.12.0 | ||
| - **todo 模式协作流(lead 自主建单)**:与组长沟通需求时明确说"用 todo 模式执行",组长拆解定稿后会自己用新 MCP 工具 `todoplan(tasks, replace?)` 把任务批量建成清单(原子:任一条不合法整单拒绝),再用 ask 选择题向 boss 确认(选项含巡查间隔),boss 点选同意后组长调 `todostart(nudgeMinutes?)` 启动——之后进入 0.11.0 的既有执行流(taskdone 上报/空闲巡查/汇总)。 | ||
| - **控制权与留痕**:两个新工具仅组长可调;建单成功系统消息留痕(`falinks → boss`,带建单人名字);控制台进度行新增「📋 N 条待开跑」(清单建好未启动时常驻);rm/clear/stop/resume 仍为 boss 专属命令,boss 也可直接 `/todo start`。"先 ask 征得同意才可 todostart"写入组长工作法与工具描述,boss 随时 `/todo stop` 兜底。 | ||
| - **修订正路**:boss 看完清单要求调整时,组长用 `todoplan(…, replace:true)` 重建(非运行态);默认拒绝覆盖已有清单,防误删 boss 手动建的单。 | ||
| - 组长工作法(coordinatorRules)追加第 ④ 条:todoplan → ask 确认 → todostart → taskdone 循环的完整协议(中英)。 | ||
| - **修复:窗口/办公室开多后 iTerm 未响应(轮询风暴)**。根因实测:健康轮询每员工每 1.5s 发 3-4 次"全遍历"AppleScript(每次都在 iTerm 内扫全部 pane),无重入护栏、无超时,多办公室叠加把 iTerm 主线程的 Apple Event 队列灌满(2 办公室 14 pane 实测 23-26 个并发 osascript、iTerm 空闲 CPU 26%)。修复五件套:**每办公室每轮 1 个批量脚本**(单次遍历采集存在性+is processing+顺带钉名)、重入护栏(上一轮未归本轮跳过)、读屏只给"busy 且不在生成"的员工做降闲裁决、标题钉名降为每 10 轮一次、osascript 15s 超时(批量脚本随 pane 数伸缩至 60s)。修后实测:并发 osascript 23-26 → **1**。 | ||
| - **失联检测增强**(修复过程中发现的盲点):员工 fresh 启动**一开始就布防** A-1 报到期限(以前注入成功才布防,bootstrap 交付失败=无告警永久 launching);`/clear` 重注入同理先布防;ready 检测单次读屏失败不再杀死整个准备流程;后台准备失败、轮询连续整轮失败均落诊断(`bootstrap-fail` / `poll-frozen`),拥堵导致的异常从"静默"变"可见"。 | ||
| ## 0.11.0 | ||
@@ -4,0 +13,0 @@ |
+22
-0
@@ -95,2 +95,24 @@ import http from 'node:http'; | ||
| }); | ||
| server.registerTool('todoplan', { | ||
| description: t().toolDescTodoplan, | ||
| inputSchema: { tasks: z.array(z.string()).min(1), replace: z.boolean().optional() }, | ||
| }, async ({ tasks, replace }) => { | ||
| touch(); | ||
| if (!deps.todo) | ||
| return ok({ ok: false, error: 'todolist not available' }); | ||
| if (!router.get(agentName)?.lead) | ||
| return ok({ ok: false, error: 'only the lead can call todoplan' }); | ||
| return ok(deps.todo.plan(tasks, replace === true, agentName)); | ||
| }); | ||
| server.registerTool('todostart', { | ||
| description: t().toolDescTodostart, | ||
| inputSchema: { nudgeMinutes: z.number().optional() }, | ||
| }, async ({ nudgeMinutes }) => { | ||
| touch(); | ||
| if (!deps.todo) | ||
| return ok({ ok: false, error: 'todolist not available' }); | ||
| if (!router.get(agentName)?.lead) | ||
| return ok({ ok: false, error: 'only the lead can call todostart' }); | ||
| return ok(deps.todo.op('start', { n: nudgeMinutes })); | ||
| }); | ||
| return server; | ||
@@ -97,0 +119,0 @@ } |
@@ -824,3 +824,3 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; | ||
| return _jsx(Text, { color: "cyan", children: t().todoProgressLine(k, todoState.tasks.length, cur ? String(cur.body).split('\n')[0].slice(0, 60) : '-', todoState.state === 'paused') }); | ||
| })() : null, confirmExit ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().exitConfirmTitle }), _jsx(Text, { bold: true, children: t().exitConfirmKeys })] })) : langPick !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().langPickTitle }), LANG_OPTS.map((o, i) => (_jsxs(Text, { inverse: i === langPick, children: [" ", o.label] }, o.v)))] })) : leadPick !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().leadCmdPickTitle }), leadOpts.length === 0 ? (_jsxs(Text, { dimColor: true, children: [" ", t().leadPickEmpty] })) : leadOpts.map((nm, i) => (_jsxs(Text, { inverse: i === leadPick, children: [" ", _jsx(Text, { color: colorFor(nm), bold: true, children: nm })] }, nm)))] })) : qCancel !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().qcancelTitle(queuedMsgs.length) }), queuedMsgs.map((m, i) => (_jsxs(Text, { inverse: i === Math.min(qCancel, Math.max(0, queuedMsgs.length - 1)), wrap: "truncate-end", children: [' → ', _jsx(Text, { color: colorFor(m.to), bold: true, children: m.to }), ' ', String(m.body).replace(/\s+/g, ' ').slice(0, 60)] }, m.id)))] })) : todoView ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().todoListTitle }), !todoState || todoState.tasks.length === 0 ? (_jsxs(Text, { dimColor: true, children: [" ", t().todoListEmpty] })) : todoState.tasks.map((x) => (_jsxs(Text, { children: [" ", x.status === 'done' ? '✅' : x.status === 'failed' ? '❌' : x.status === 'current' ? '▶' : '·', " #", x.seq, " ", String(x.body).split('\n')[0].slice(0, 70), x.result ? ` — ${String(x.result).slice(0, 40)}` : ''] }, x.seq)))] })) : wizard ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: wizard.step === 'cli' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), t().wizardCliSuffix] }), CLIS.map((c, i) => (_jsxs(Text, { inverse: i === wizard.sel, children: [" ", c, c === 'codex' ? t().wizardExperimental : ''] }, c)))] })) : wizard.step === 'model' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "]", t().wizardModelSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.modelText }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: t().wizardModelHint })] })) : wizard.step === 'role' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, wizard.model ? '·' + wizard.model : '', "]", t().wizardRoleSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.roleText }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: t().wizardRoleExample })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "\u00B7", wizard.role, "]", t().wizardCwdSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.path }), _jsx(Text, { inverse: true, children: " " })] }), dirSuggestions(wizard.path, fsListDirs).map((d, i) => (_jsxs(Text, { inverse: i === wizard.sel, children: [" ", d] }, d))), _jsx(Text, { dimColor: true, children: t().wizardCwdDefault })] })) })) : (_jsxs(_Fragment, { children: [answering && pendingQ ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().questionAsk(pendingQ.from, pendingQ.question) }), pendingQ.options.map((o, i) => (_jsxs(Text, { inverse: i === qSelClamped, children: [" ", i + 1, ". ", o] }, i))), _jsxs(Text, { inverse: onCustomSlot, dimColor: !onCustomSlot, children: [" ", t().answerCustom] }), customAns !== null ? (_jsxs(Text, { children: [t().answerCustomPrompt, _jsx(Text, { color: "green", children: customAns }), _jsx(Text, { inverse: true, children: " " })] })) : (_jsxs(Text, { dimColor: true, children: [t().answerKeys, questions.length > 1 ? t().answerMore(questions.length - 1) : '', t().answerOrType] }))] })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [inputDest.mode === 'cmd' ? _jsx(Text, { color: "magenta", bold: true, children: "\u2318 " }) | ||
| })() : todoState && todoState.state === 'idle' && todoState.tasks.length > 0 ? (_jsx(Text, { color: "cyan", children: t().todoPendingLine(todoState.tasks.length) })) : null, confirmExit ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().exitConfirmTitle }), _jsx(Text, { bold: true, children: t().exitConfirmKeys })] })) : langPick !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().langPickTitle }), LANG_OPTS.map((o, i) => (_jsxs(Text, { inverse: i === langPick, children: [" ", o.label] }, o.v)))] })) : leadPick !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().leadCmdPickTitle }), leadOpts.length === 0 ? (_jsxs(Text, { dimColor: true, children: [" ", t().leadPickEmpty] })) : leadOpts.map((nm, i) => (_jsxs(Text, { inverse: i === leadPick, children: [" ", _jsx(Text, { color: colorFor(nm), bold: true, children: nm })] }, nm)))] })) : qCancel !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().qcancelTitle(queuedMsgs.length) }), queuedMsgs.map((m, i) => (_jsxs(Text, { inverse: i === Math.min(qCancel, Math.max(0, queuedMsgs.length - 1)), wrap: "truncate-end", children: [' → ', _jsx(Text, { color: colorFor(m.to), bold: true, children: m.to }), ' ', String(m.body).replace(/\s+/g, ' ').slice(0, 60)] }, m.id)))] })) : todoView ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().todoListTitle }), !todoState || todoState.tasks.length === 0 ? (_jsxs(Text, { dimColor: true, children: [" ", t().todoListEmpty] })) : todoState.tasks.map((x) => (_jsxs(Text, { children: [" ", x.status === 'done' ? '✅' : x.status === 'failed' ? '❌' : x.status === 'current' ? '▶' : '·', " #", x.seq, " ", String(x.body).split('\n')[0].slice(0, 70), x.result ? ` — ${String(x.result).slice(0, 40)}` : ''] }, x.seq)))] })) : wizard ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: wizard.step === 'cli' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), t().wizardCliSuffix] }), CLIS.map((c, i) => (_jsxs(Text, { inverse: i === wizard.sel, children: [" ", c, c === 'codex' ? t().wizardExperimental : ''] }, c)))] })) : wizard.step === 'model' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "]", t().wizardModelSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.modelText }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: t().wizardModelHint })] })) : wizard.step === 'role' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, wizard.model ? '·' + wizard.model : '', "]", t().wizardRoleSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.roleText }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: t().wizardRoleExample })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "\u00B7", wizard.role, "]", t().wizardCwdSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.path }), _jsx(Text, { inverse: true, children: " " })] }), dirSuggestions(wizard.path, fsListDirs).map((d, i) => (_jsxs(Text, { inverse: i === wizard.sel, children: [" ", d] }, d))), _jsx(Text, { dimColor: true, children: t().wizardCwdDefault })] })) })) : (_jsxs(_Fragment, { children: [answering && pendingQ ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().questionAsk(pendingQ.from, pendingQ.question) }), pendingQ.options.map((o, i) => (_jsxs(Text, { inverse: i === qSelClamped, children: [" ", i + 1, ". ", o] }, i))), _jsxs(Text, { inverse: onCustomSlot, dimColor: !onCustomSlot, children: [" ", t().answerCustom] }), customAns !== null ? (_jsxs(Text, { children: [t().answerCustomPrompt, _jsx(Text, { color: "green", children: customAns }), _jsx(Text, { inverse: true, children: " " })] })) : (_jsxs(Text, { dimColor: true, children: [t().answerKeys, questions.length > 1 ? t().answerMore(questions.length - 1) : '', t().answerOrType] }))] })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [inputDest.mode === 'cmd' ? _jsx(Text, { color: "magenta", bold: true, children: "\u2318 " }) | ||
| : inputDest.mode === 'broadcast' ? _jsxs(Text, { color: "yellow", bold: true, children: ["\uD83D\uDCE2 ", t().clearAll, " "] }) | ||
@@ -827,0 +827,0 @@ : inputDest.mode === 'to' ? _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2192 " }), _jsx(Text, { color: colorFor(inputDest.to), bold: true, children: inputDest.to }), _jsx(Text, { children: " " })] }) |
+25
-0
@@ -29,2 +29,27 @@ const MIN_MS = 60_000; | ||
| } | ||
| /** 批量建单(lead 经 MCP todoplan 调用):整单原子——任一条不合法则整体拒绝,不部分写入。 | ||
| * 冲突矩阵:running/paused 拒绝;finished 清旧账(同 add);idle 非空默认拒绝(防覆盖 boss 手动单), | ||
| * replace=true 时清空后建(lead 修订自己刚建的清单的正路)。 */ | ||
| plan(tasks, replace) { | ||
| if (this.st.state === 'running' || this.st.state === 'paused') | ||
| return { ok: false, error: 'todolist is running/paused — cannot replan now' }; | ||
| if (tasks.length === 0 || tasks.some((b) => !b.trim())) | ||
| return { ok: false, error: 'tasks must be a non-empty list of non-blank strings' }; | ||
| if (this.st.state === 'finished') { // 跑完续单:清旧账(汇总已入消息流) | ||
| this.st.tasks = []; | ||
| this.st.state = 'idle'; | ||
| } | ||
| else if (this.st.tasks.length > 0) { | ||
| if (!replace) | ||
| return { ok: false, error: 'todolist already has tasks — pass replace:true to rebuild, or ask boss to /todo clear' }; | ||
| this.st.tasks = []; | ||
| } | ||
| const seqs = tasks.map((body) => { | ||
| const task = { seq: ++this.seqCounter, body, status: 'pending' }; | ||
| this.st.tasks.push(task); | ||
| return task.seq; | ||
| }); | ||
| this.cb.persist(this.st); | ||
| return { ok: true, seqs }; | ||
| } | ||
| rm(seq) { | ||
@@ -31,0 +56,0 @@ const t = this.st.tasks.find((x) => x.seq === seq); |
+5
-0
@@ -174,2 +174,3 @@ /** 英文词典:typeof zh 钉死 key 与签名——漏译/签名不符直接编译失败。 */ | ||
| '③ Once the plan is finalized, decompose it into concrete tasks and dispatch them one by one via sendmsg to the right workers (frontend/backend/qa…), then keep managing: track progress, coordinate dependencies, aggregate results. ' + | ||
| '④ When boss explicitly asks for todo-mode execution: call todoplan(tasks:[one per task]) to build the list after the plan is finalized → use ask(to:"boss") to confirm whether to start (include nudge intervals in the options, e.g. "start (nudge every 10 min) / start (nudge every 30 min) / not yet") → once boss agrees, call todostart(nudgeMinutes) to launch; after that report each completed task with taskdone(seq, status, result) and the system will dispatch the next one automatically. Never call todostart without boss approval; to revise a list you just built, pass todoplan(…, replace:true). ' + | ||
| 'In short: align requirements → design fully (may dispatch helpers) → finalize the plan → only then decompose, dispatch, and manage.', | ||
@@ -194,2 +195,4 @@ preparingWorkers: (n, names) => `⏳ falinks is preparing ${n} workers (${names})…`, | ||
| toolDescTaskdone: '[todolist only · lead only] report the current task finished: taskdone(seq, status:"done"|"failed", result). The system records it and dispatches the next task; report failures too — the list never stops.', | ||
| toolDescTodoplan: '[todo mode · lead only] when boss explicitly asks for todo-mode execution, batch-create the finalized task breakdown: todoplan(tasks:[one per task], replace?). You MUST get boss approval via ask(to:"boss") before todostart; pass replace:true to revise a list you just created.', | ||
| toolDescTodostart: '[todo mode · lead only] start the prepared task list: todostart(nudgeMinutes?). Requires explicit boss approval via ask first; resuming a paused list is the boss\'s call (/todo resume), not this tool.', | ||
| // —— templates.ts —— | ||
@@ -217,5 +220,7 @@ roleBootstrap: (role) => `Your duties: ${role}. Keep it concise, no fluff.`, | ||
| todoSummaryLine: (seq, ok, body, result) => `${ok ? '✅' : '❌'} #${seq} ${body} — ${result}`, | ||
| todoPlannedMsg: (from, n) => `[todo mode] lead ${from} created a ${n}-task list — /todo list to review; the lead will start it after your approval (or run /todo start yourself).`, | ||
| todoSuspendedMsg: '[todolist suspended] No lead assigned — task dispatch paused. Assign a lead with /lead and it will resume automatically.', | ||
| todoSendFailingMsg: '[todolist warning] Message delivery has failed repeatedly (guardrail or lead unreachable) — the list may be stalled, please investigate.', | ||
| todoProgressLine: (k, total, body, paused) => `📋 ${k}/${total} current: ${body}${paused ? ' [⏸ paused]' : ''}`, | ||
| todoPendingLine: (n) => `📋 ${n} task(s) queued, not started (/todo list to review)`, | ||
| todoResumeHint: (left, total) => `Unfinished todolist detected (${left}/${total} tasks remaining) — /todo resume to continue`, | ||
@@ -222,0 +227,0 @@ todoListTitle: 'Task list (Esc to close)', |
+5
-0
@@ -181,2 +181,3 @@ /** 中文基准词典:全部用户可见/注入员工的文案都从这里出。纯文本=字符串,带变量=箭头函数。 */ | ||
| '③ 方案敲定后,再把它拆解成具体任务,用 sendmsg 逐一分派给对应员工(前端/后端/测试…),然后持续管理:跟进进度、协调依赖、汇总结果、必要时复盘。' + | ||
| '④ 当 boss 明确要求用 todo 模式执行时:拆解定稿后调用 todoplan(tasks:[每条一个任务]) 建成清单 → 用 ask(to:"boss") 确认是否开始执行(选项里给巡查间隔,如「开始(巡查10分钟)/开始(巡查30分钟)/暂不」)→ boss 同意后调 todostart(nudgeMinutes) 启动;之后每完成一条用 taskdone(seq, status, result) 上报,系统会自动下发下一条。未经 boss 同意绝不 todostart;要修订刚建的清单用 todoplan(…, replace:true)。' + | ||
| '一句话:对齐需求 → 完整设计(可调度协助) → 方案定稿 → 才拆解、分派、管理。', | ||
@@ -201,2 +202,4 @@ preparingWorkers: (n, names) => `⏳ falinks 正在准备 ${n} 名员工(${names})…`, | ||
| toolDescTaskdone: '【todolist 专用·仅组长】上报当前任务完结:taskdone(seq, status:"done"|"failed", result)。系统记录后才会下发下一条;失败也要报,不会中断清单。', | ||
| toolDescTodoplan: '【todo 模式·仅组长】boss 明确要求用 todo 模式执行时,把拆解定稿的任务批量建成清单:todoplan(tasks:[每条一个任务], replace?)。建完必须用 ask(to:"boss") 征得 boss 同意才可 todostart;修订自己刚建的清单传 replace:true。', | ||
| toolDescTodostart: '【todo 模式·仅组长】启动已建好的任务清单:todostart(nudgeMinutes?)。必须先经 ask 获得 boss 明确同意;paused 状态的恢复属 boss 干预权(/todo resume),本工具不可用。', | ||
| // —— templates.ts —— | ||
@@ -224,5 +227,7 @@ roleBootstrap: (role) => `你的职责:${role}。风格简练,少废话。`, | ||
| todoSummaryLine: (seq, ok, body, result) => `${ok ? '✅' : '❌'} #${seq} ${body} — ${result}`, | ||
| todoPlannedMsg: (from, n) => `【todo 模式】组长 ${from} 已建 ${n} 条任务清单,/todo list 查看;待 boss 确认后由组长启动(或你直接 /todo start)。`, | ||
| todoSuspendedMsg: '【todolist 挂起】当前没有组长,任务暂停下发;/lead 指定组长后自动继续。', | ||
| todoSendFailingMsg: '【todolist 告警】连续多次消息发送失败(守卫拦截或组长不可达),清单可能停滞,请检查。', | ||
| todoProgressLine: (k, total, body, paused) => `📋 ${k}/${total} 当前:${body}${paused ? ' [⏸ 已暂停]' : ''}`, | ||
| todoPendingLine: (n) => `📋 ${n} 条待开跑(/todo list 查看)`, | ||
| todoResumeHint: (left, total) => `检测到未完成的 todolist(剩 ${left}/${total} 条),/todo resume 继续`, | ||
@@ -229,0 +234,0 @@ todoListTitle: '任务清单(Esc 关闭)', |
+145
-99
@@ -215,2 +215,6 @@ import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, realpathSync, rmSync } from 'node:fs'; | ||
| try { | ||
| // fresh 启动即布防 A-1:bootstrap 交付失败(ready 检测超时/注入失败)也要在 90s 内亮 ⚠, | ||
| // 不能让"准备流程死了"变成无告警的永久 launching。注入成功员工照常 register 自愈。 | ||
| if (!resuming) | ||
| armRegisterExpectation(a.name); | ||
| let sid2 = sessionId; | ||
@@ -221,3 +225,9 @@ if (needsBootstrapInject) { | ||
| await sleep(700); | ||
| const state = detectScreenState(await driver.readScreen(sid)); | ||
| let state; | ||
| try { | ||
| state = detectScreenState(await driver.readScreen(sid)); | ||
| } | ||
| catch { | ||
| continue; // 读屏失败(iTerm 拥堵/超时):本次跳过,下一轮再探——绝不能让单次抖动杀死整个准备流程 | ||
| } | ||
| if (state === 'trust-dialog') { | ||
@@ -230,3 +240,2 @@ await driver.inject(sid, '', true); | ||
| await driver.inject(sid, fullBootstrap, true); | ||
| armRegisterExpectation(a.name); | ||
| } | ||
@@ -240,4 +249,2 @@ break; | ||
| // codex:bootstrap 是启动参数,进程一起即可能调工具——期限从启动起算,防快手 register 落在盲窗里。 | ||
| if (!resuming) | ||
| armRegisterExpectation(a.name); | ||
| await sleep(2500); | ||
@@ -272,3 +279,9 @@ await driver.inject(sid, '', true); | ||
| } | ||
| catch { /* 后台准备失败忽略,员工仍可手动用 */ } | ||
| catch (e) { | ||
| // 后台准备失败(pane 没了/注入炸了):落盘诊断;A-1 已布防,90s 内会亮 ⚠ | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'bootstrap-fail', name: a.name, error: String(e?.message ?? e), ts: Date.now() }); | ||
| } | ||
| catch { /* 诊断落盘失败不致命 */ } | ||
| } | ||
| })(); | ||
@@ -366,5 +379,5 @@ return sid; | ||
| if (bs) | ||
| armRegisterExpectation(nm); // 先布防再注入:重注入失败(拥堵超时)也要 90s 亮 ⚠,不能无告警裸奔 | ||
| if (bs) | ||
| await driver.inject(sid, bs, true); // 重注入 bootstrap:恢复身份+重新 register(→idle→投出排队消息) | ||
| if (bs) | ||
| armRegisterExpectation(nm); // /clear 重注入 bootstrap 后同样限期报到 | ||
| cleared.push(nm); | ||
@@ -481,2 +494,8 @@ } | ||
| taskdone: (seq, status, result) => todo.taskdone(seq, status, result), | ||
| plan: (tasks, replace, from) => { | ||
| const r = todo.plan(tasks, replace); | ||
| if (r.ok) | ||
| router.send('falinks', 'boss', t().todoPlannedMsg(from, tasks.length)); // 系统留痕:boss 在消息流可见 | ||
| return r; | ||
| }, | ||
| op: (op, args) => { | ||
@@ -569,109 +588,136 @@ switch (op) { | ||
| const DEBUG_BUSY = !!process.env.FALINKS_DEBUG_BUSY; // 开则逐轮询把判忙信号+决策落盘(排查闪烁) | ||
| // 健康轮询(≥1.5s):批量采集 + 重入护栏。改造动机见 docs/superpowers/specs/2026-06-11-iterm-poll-storm-fix-design.md: | ||
| // 旧实现每员工每轮 3-4 次全遍历 AppleScript,多办公室叠加把 iTerm 主线程灌死(实测 23+ 并发 osascript)。 | ||
| // 现在:每轮一个批量脚本(存在性+is processing+顺带钉名),读屏只给"busy 且不在生成"的员工做降闲裁决。 | ||
| const NAME_PIN_EVERY = 10; // 每 10 轮随批量脚本钉一次标题(≈15s),代替逐轮无条件写 | ||
| let pollInFlight = false; // 重入护栏:上一轮未归本轮跳过——pane 极多时轮询自动变慢,而不是叠加冻死 iTerm | ||
| let pollRound = 0; | ||
| let pollFailStreak = 0; // 批量探测连续整轮失败计数:≥10(≈15s+ 状态完全没更新)落一次诊断,冻结可见而非无声 | ||
| let pollFailAnnounced = false; | ||
| setInterval(() => { | ||
| if (pollInFlight) | ||
| return; | ||
| pollInFlight = true; | ||
| void (async () => { | ||
| for (const [name, sid] of [...sessions]) { | ||
| try { | ||
| pollRound++; | ||
| const pin = pollRound % NAME_PIN_EVERY === 0; | ||
| const targets = [...sessions].filter(([nm]) => !restarting.has(nm)); // 重启中:旧 pane 已关属预期,跳过 | ||
| let statuses; | ||
| try { | ||
| if (restarting.has(name)) | ||
| continue; // 重启中:旧 pane 已关属预期,跳过下线判定与状态校准 | ||
| if (!(await driver.paneExists(sid))) { | ||
| const n = (missStreak.get(name) ?? 0) + 1; | ||
| missStreak.set(name, n); | ||
| if (n < 3) | ||
| continue; // 还没连续 3 次,可能是瞬时误报,先不下线 | ||
| sessions.delete(name); | ||
| if (sid === lastRight) | ||
| lastRight = consoleSid; // 下线的正是当前锚点 → 复位,别留野指针 | ||
| router.removeAgent(name); | ||
| forgetAgentState(name); | ||
| if (!inProcessConsole) | ||
| console.log(t().workerWindowClosed(name)); | ||
| continue; | ||
| } | ||
| missStreak.delete(name); // 探到了,清零 | ||
| await driver.setName(sid, name); // 持续把 pane 标题钉成员工名(覆盖 CLI 自改的标题) | ||
| // 清空中(/clear)跳过状态校准:否则会把排队消息投进正在清空的 pane。 | ||
| if (clearing.has(name)) | ||
| continue; | ||
| // A-1 报到超时:bootstrap 交付后限期内必须出现任意 MCP 调用,否则告警(工具没挂/会话瘫痪)。 | ||
| const exp = expectRegister.get(name); | ||
| if (exp) { | ||
| const verdict = checkRegisterTimeout({ now: Date.now(), by: exp.by, since: exp.since, lastMcpAt: router.get(name)?.lastMcpAt }); | ||
| if (verdict === 'satisfied') | ||
| expectRegister.delete(name); | ||
| else if (verdict === 'timeout') { | ||
| expectRegister.delete(name); | ||
| alarmUnresponsive(name, 'register-timeout'); | ||
| statuses = await driver.pollPanes(targets.map(([nm, sid]) => ({ sessionId: sid, pinName: pin ? nm : undefined }))); | ||
| pollFailStreak = 0; | ||
| pollFailAnnounced = false; | ||
| } | ||
| catch (e) { | ||
| // 批量探测整体失败(超时/iTerm 忙):本轮全员维持现状,护栏顺延下一轮再试。 | ||
| // 连续失败 ≥10 轮(≈15s+ 状态完全没更新)落一次诊断——状态冻结要可见,不能和"全员安静"无法区分。 | ||
| pollFailStreak++; | ||
| if (pollFailStreak >= 10 && !pollFailAnnounced) { | ||
| pollFailAnnounced = true; | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'poll-frozen', streak: pollFailStreak, error: String(e?.message ?? e), ts: Date.now() }); | ||
| } | ||
| catch { /* 诊断落盘失败不致命 */ } | ||
| } | ||
| // 按 pane 实况校准花名册:pane 在生成却显示 idle → 升 busy(修"干活却显示空闲"); | ||
| // busy 但 pane 已空闲、过宽限、连续 IDLE_STREAK 次不忙 → 降 idle 并 pump 排队消息。 | ||
| // 判忙主信号 = iTerm2 `is processing`(最近~2s 有输出;干活的 CLI 持续刷 spinner=持续有输出), | ||
| // 远比截屏正则可靠(新版 Claude 底部常驻 `bypass permissions on` 会让旧截屏永远判不忙→工作中被误降)。 | ||
| // 截屏 isPaneBusy 作廉价兜底(给非 Claude/缓冲输出的 CLI);proc 为真时短路、不读屏。读失败保守判忙。 | ||
| const a = router.get(name); | ||
| if (a && (a.status === 'busy' || a.status === 'idle')) { | ||
| let proc = false; | ||
| let scrapeBusy = false; | ||
| let paneBusy; | ||
| let bottom = ''; | ||
| try { | ||
| proc = await driver.isProcessing(sid); | ||
| if (!proc || DEBUG_BUSY) { | ||
| const screen = await driver.readScreen(sid); | ||
| scrapeBusy = isPaneBusy(screen); | ||
| if (DEBUG_BUSY) | ||
| bottom = screen.split('\n').map((l) => l.replace(/\s+$/, '')).filter(Boolean).slice(-2).join(' ⏎ '); | ||
| } | ||
| paneBusy = proc || scrapeBusy; | ||
| return; | ||
| } | ||
| for (const [name, sid] of targets) { | ||
| try { | ||
| const st = statuses.get(sid); | ||
| if (!st) { | ||
| const n = (missStreak.get(name) ?? 0) + 1; | ||
| missStreak.set(name, n); | ||
| if (n < 3) | ||
| continue; // 连续 3 次缺席才下线(瞬时误报去抖) | ||
| sessions.delete(name); | ||
| if (sid === lastRight) | ||
| lastRight = consoleSid; | ||
| router.removeAgent(name); | ||
| forgetAgentState(name); | ||
| missStreak.delete(name); | ||
| if (!inProcessConsole) | ||
| console.log(t().workerWindowClosed(name)); | ||
| continue; | ||
| } | ||
| catch { | ||
| paneBusy = true; // 探测失败按"忙"处理,别误降 idle | ||
| } | ||
| const grace = Date.now() - (lastDeliverAt.get(name) ?? 0) > IDLE_GRACE_MS; | ||
| const streak = paneBusy ? 0 : (idleStreak.get(name) ?? 0) + 1; | ||
| idleStreak.set(name, streak); | ||
| const action = reconcilePaneStatus({ | ||
| status: a.status, | ||
| paneBusy, | ||
| gracePassed: grace, | ||
| idleStreak: streak, | ||
| idleThreshold: IDLE_STREAK, | ||
| }); | ||
| // 逐轮询调试(FALINKS_DEBUG_BUSY=1 开):落盘每次的信号与决策,定位"工作中一瞬切空闲又切回"。 | ||
| if (DEBUG_BUSY) { | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'poll', name, status: a.status, proc, scrape: scrapeBusy, paneBusy, grace, streak, action, bottom, ts: Date.now() }); | ||
| missStreak.delete(name); | ||
| if (clearing.has(name)) | ||
| continue; | ||
| // —— 以下与旧实现逐行同语义:A-1 报到超时 → 状态校准(reconcile)→ A-2 有活无声 —— | ||
| const exp = expectRegister.get(name); | ||
| if (exp) { | ||
| const verdict = checkRegisterTimeout({ now: Date.now(), by: exp.by, since: exp.since, lastMcpAt: router.get(name)?.lastMcpAt }); | ||
| if (verdict === 'satisfied') | ||
| expectRegister.delete(name); | ||
| else if (verdict === 'timeout') { | ||
| expectRegister.delete(name); | ||
| alarmUnresponsive(name, 'register-timeout'); | ||
| } | ||
| catch { /* ignore */ } | ||
| } | ||
| if (action === 'mark-idle') { | ||
| // 只记"投递后异常快就被降 idle"的可疑情形(接近宽限下限):正常长任务结束的 since 很大、不记,避免刷屏。 | ||
| // 若反复卡死时这里频繁出现,佐证"还没回完就被降 idle、下条排队消息叠注入打断上一轮"。 | ||
| const since = Date.now() - (lastDeliverAt.get(name) ?? 0); | ||
| if (since < SUSPECT_FAST_IDLE_MS) { | ||
| const a = router.get(name); | ||
| if (a && (a.status === 'busy' || a.status === 'idle')) { | ||
| const proc = st.processing; | ||
| let scrapeBusy = false; | ||
| let bottom = ''; | ||
| // 读屏收窄:只给"busy 且不在生成"的员工做降闲裁决(空闲员工逐轮全屏读是批量化后最大残余流量; | ||
| // 判忙主信号 is processing 已覆盖一切有输出的活动,读屏对空闲员工几乎无增量)。DEBUG 时维持旧行为。 | ||
| if ((!proc && a.status === 'busy') || DEBUG_BUSY) { | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'auto-idle', name, sinceDeliverMs: since, ts: Date.now() }); | ||
| const screen = await driver.readScreen(sid); | ||
| scrapeBusy = isPaneBusy(screen); | ||
| if (DEBUG_BUSY) | ||
| bottom = screen.split('\n').map((l) => l.replace(/\s+$/, '')).filter(Boolean).slice(-2).join(' ⏎ '); | ||
| } | ||
| catch { /* 诊断落盘失败不致命 */ } | ||
| catch { | ||
| scrapeBusy = true; // 探测失败按"忙"处理,别误降 idle | ||
| } | ||
| } | ||
| // A-2 有活无声:走到这=自动降闲(不是它自己调 idle 工具)。投递过却全程零 MCP 调用 → 哑巴嫌疑。 | ||
| const mv = judgeAutoIdleSilence({ deliveredAt: lastDeliverAt.get(name), countedAt: muteCountedAt.get(name) ?? 0, lastMcpAt: a.lastMcpAt }); | ||
| muteCountedAt.set(name, mv.countedAt); | ||
| if (mv.reset) | ||
| router.clearMute(name); | ||
| if (mv.count && router.bumpMute(name) >= MUTE_THRESHOLD) | ||
| alarmUnresponsive(name, 'mute'); | ||
| router.onIdle(name); | ||
| const paneBusy = proc || scrapeBusy; | ||
| const grace = Date.now() - (lastDeliverAt.get(name) ?? 0) > IDLE_GRACE_MS; | ||
| const streak = paneBusy ? 0 : (idleStreak.get(name) ?? 0) + 1; | ||
| idleStreak.set(name, streak); | ||
| const action = reconcilePaneStatus({ | ||
| status: a.status, | ||
| paneBusy, | ||
| gracePassed: grace, | ||
| idleStreak: streak, | ||
| idleThreshold: IDLE_STREAK, | ||
| }); | ||
| if (DEBUG_BUSY) { | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'poll', name, status: a.status, proc, scrape: scrapeBusy, paneBusy, grace, streak, action, bottom, ts: Date.now() }); | ||
| } | ||
| catch { /* ignore */ } | ||
| } | ||
| if (action === 'mark-idle') { | ||
| const since = Date.now() - (lastDeliverAt.get(name) ?? 0); | ||
| if (since < SUSPECT_FAST_IDLE_MS) { | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'auto-idle', name, sinceDeliverMs: since, ts: Date.now() }); | ||
| } | ||
| catch { /* 诊断落盘失败不致命 */ } | ||
| } | ||
| const mv = judgeAutoIdleSilence({ deliveredAt: lastDeliverAt.get(name), countedAt: muteCountedAt.get(name) ?? 0, lastMcpAt: a.lastMcpAt }); | ||
| muteCountedAt.set(name, mv.countedAt); | ||
| if (mv.reset) | ||
| router.clearMute(name); | ||
| if (mv.count && router.bumpMute(name) >= MUTE_THRESHOLD) | ||
| alarmUnresponsive(name, 'mute'); | ||
| router.onIdle(name); | ||
| } | ||
| else if (action === 'mark-busy') | ||
| router.observeBusy(name); | ||
| } | ||
| else if (action === 'mark-busy') | ||
| router.observeBusy(name); | ||
| } | ||
| catch { | ||
| /* 单员工处理失败忽略,下一轮再试 */ | ||
| } | ||
| } | ||
| catch { | ||
| /* 探测失败忽略,下一轮再试 */ | ||
| } | ||
| // todolist 巡查驱动:全员(非虚拟)无人 busy 视为空闲;lead 存活与否决定挂起/恢复。 | ||
| const rs = router.roster(); | ||
| todo.tick(rs.some((x) => !x.virtual && x.status === 'busy'), rs.some((x) => x.lead && x.status !== 'dead')); | ||
| } | ||
| // todolist 巡查驱动:全员(非虚拟)无人 busy 视为空闲;lead 存活与否决定挂起/恢复。 | ||
| const rs = router.roster(); | ||
| todo.tick(rs.some((x) => !x.virtual && x.status === 'busy'), rs.some((x) => x.lead && x.status !== 'dead')); | ||
| finally { | ||
| pollInFlight = false; | ||
| } | ||
| })(); | ||
@@ -678,0 +724,0 @@ }, 1500); |
@@ -52,2 +52,13 @@ /** 测试替身:记录所有 inject、可设定 readScreen 返回值。 */ | ||
| } | ||
| async pollPanes(targets) { | ||
| const m = new Map(); | ||
| for (const t of targets) { | ||
| if (!this.windows.has(t.sessionId)) | ||
| continue; | ||
| if (t.pinName !== undefined) | ||
| this.names.set(t.sessionId, t.pinName); | ||
| m.set(t.sessionId, { processing: this.processing.get(t.sessionId) ?? false }); | ||
| } | ||
| return m; | ||
| } | ||
| setProcessing(sessionId, v) { | ||
@@ -54,0 +65,0 @@ this.processing.set(sessionId, v); |
@@ -8,4 +8,7 @@ import { spawn } from 'node:child_process'; | ||
| } | ||
| /** 执行一段 AppleScript(经 osascript stdin),返回 trim 后的 stdout。 */ | ||
| function osascript(script) { | ||
| /** 执行一段 AppleScript(经 osascript stdin),返回 trim 后的 stdout。 | ||
| * 默认 15s 超时强杀:iTerm 主线程拥堵时挂死的调用不再永久占位(调用方按"探测失败"兜底)。 | ||
| * timeoutMs 可由调用方按需延长(如批量 pollPanes 随 pane 数伸缩)。 */ | ||
| const OSASCRIPT_TIMEOUT_MS = 15_000; | ||
| function osascript(script, timeoutMs = OSASCRIPT_TIMEOUT_MS) { | ||
| return new Promise((resolve, reject) => { | ||
@@ -15,6 +18,13 @@ const p = spawn('osascript', ['-']); | ||
| let err = ''; | ||
| const timer = setTimeout(() => { p.kill(); reject(new Error('osascript timeout')); }, timeoutMs); | ||
| p.stdout.on('data', (d) => (out += d.toString())); | ||
| p.stderr.on('data', (d) => (err += d.toString())); | ||
| p.on('error', reject); | ||
| p.on('close', (code) => code === 0 ? resolve(out.trim()) : reject(new Error(err.trim() || `osascript exit ${code}`))); | ||
| p.on('error', (e) => { clearTimeout(timer); reject(e); }); | ||
| p.on('close', (code) => { | ||
| clearTimeout(timer); | ||
| if (code === 0) | ||
| resolve(out.trim()); | ||
| else | ||
| reject(new Error(err.trim() || `osascript exit ${code}`)); | ||
| }); | ||
| p.stdin.write(script); | ||
@@ -137,2 +147,55 @@ p.stdin.end(); | ||
| } | ||
| async pollPanes(targets) { | ||
| if (targets.length === 0) | ||
| return new Map(); | ||
| // 超时随目标数伸缩(15s + 100ms/目标,上限 60s):pane 极多时批量脚本本身变慢, | ||
| // 固定 15s 会让每轮都超时 → 永久状态冻结;重入护栏已防叠加,等久一点没有代价。 | ||
| const timeoutMs = Math.min(60_000, 15_000 + targets.length * 100); | ||
| const out = await osascript(buildPollScript(targets), timeoutMs); | ||
| const m = parsePollOutput(out); | ||
| // 护栏:有输出却一条都解析不出 = 脚本输出格式坏了(如 AppleScript 常量被遮蔽), | ||
| // 宁可整轮失败(调用方跳过维持现状)也不能返回空 Map 把全员误判成 pane 消失。 | ||
| if (out.trim() !== '' && m.size === 0) | ||
| throw new Error(`pollPanes: unparseable output: ${out.slice(0, 80)}`); | ||
| return m; | ||
| } | ||
| } | ||
| /** 生成批量轮询脚本:单次遍历全部 sessions,对命中的目标收集 is processing(一行 `id<TAB>bool`), | ||
| * 带 pinName 的顺带 set name(写进同一脚本,不另起调用)。导出供单测。 */ | ||
| export function buildPollScript(targets) { | ||
| const branches = targets | ||
| .map((t) => { | ||
| const pin = t.pinName !== undefined ? `\n set name of s to "${escapeAppleScript(t.pinName)}"` : ''; | ||
| return ` if sid is "${t.sessionId}" then${pin} | ||
| set out to out & sid & tabchar & ((is processing of s) as string) & linefeed | ||
| end if`; | ||
| }) | ||
| .join('\n'); | ||
| return `set tabchar to tab | ||
| tell application "iTerm2" | ||
| set out to "" | ||
| repeat with w in windows | ||
| repeat with t in tabs of w | ||
| repeat with s in sessions of t | ||
| set sid to (id of s) | ||
| ${branches} | ||
| end repeat | ||
| end repeat | ||
| end repeat | ||
| return out | ||
| end tell`; | ||
| } | ||
| /** 解析批量轮询输出:每行 `id<TAB>true|false`;不合格式的行忽略(容错)。 */ | ||
| export function parsePollOutput(out) { | ||
| const m = new Map(); | ||
| for (const line of out.split('\n')) { | ||
| const i = line.indexOf('\t'); | ||
| if (i <= 0) | ||
| continue; | ||
| const flag = line.slice(i + 1).trim(); | ||
| if (flag !== 'true' && flag !== 'false') | ||
| continue; | ||
| m.set(line.slice(0, i), { processing: flag === 'true' }); | ||
| } | ||
| return m; | ||
| } |
+1
-1
| { | ||
| "name": "@hklmtt/falinks", | ||
| "version": "0.11.0", | ||
| "version": "0.12.0", | ||
| "description": "把多个终端 AI CLI(Claude Code、Codex…)编排成一间办公室:分屏真实窗口里的员工,通过 MCP 总线互相对话协作,外加一个控制台。", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+1
-0
@@ -35,2 +35,3 @@ ``` | ||
| - **`/todo` unattended task list**: queue tasks (`add` / `list` / `rm` / `clear`), then `start [N]` to let the office grind through them while you're away — the lead reports each task via the `taskdone` tool, an idle nudge fires every N minutes (default 10, never gives up), failures don't stop the list, and a summary lands at the end. The list is persisted: after a restart it's paused until `/todo resume`. | ||
| - **Todo mode (lead-driven planning)**: tell the lead to "execute this in todo mode" while discussing requirements — after the breakdown is finalized it creates the list itself via the `todoplan` tool, asks you for approval (an `ask` with nudge-interval options), and only starts via `todostart` once you confirm. You keep full control: the roster line shows `📋 N queued, not started`, `/todo list` reviews it, and `stop`/`resume`/`rm`/`clear` remain boss-only commands. The lead can revise its own draft with `todoplan(replace:true)`. | ||
| - **Run multiple projects at once**: ports are auto-assigned; each project keeps its own runtime record (`~/.falinks/runtime/<hash>.json`); `falinks say/roster/log` find the matching instance by current directory; same-directory double-start is blocked. | ||
@@ -37,0 +38,0 @@ - **Bilingual UI (i18n)**: UI text, CLI prompts, the startup wizard, and worker collaboration rules are fully bilingual. Defaults to your system language (locale starting with `zh` = Chinese, otherwise English); switch anytime with `/lang` in the console or `falinks lang`, persisted globally (`~/.falinks/settings.json`). |
302035
4.76%5040
3.77%153
0.66%