🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@hklmtt/falinks

Package Overview
Dependencies
Maintainers
1
Versions
19
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@hklmtt/falinks - npm Package Compare versions

Comparing version
0.11.0
to
0.12.0
+9
-0
CHANGELOG.md
# 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 @@

@@ -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 @@ }

+1
-1

@@ -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: " " })] })

@@ -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);

@@ -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)',

@@ -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 关闭)',

@@ -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;
}
{
"name": "@hklmtt/falinks",
"version": "0.11.0",
"version": "0.12.0",
"description": "把多个终端 AI CLI(Claude Code、Codex…)编排成一间办公室:分屏真实窗口里的员工,通过 MCP 总线互相对话协作,外加一个控制台。",

@@ -5,0 +5,0 @@ "keywords": [

@@ -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`).