@hklmtt/falinks
Advanced tools
| import { DEFAULT_OFFICE, assertOfficeName } from './core/office.js'; | ||
| /** falinks 的子命令(switch 分发的 9 个);其余 token 才可能是办公室名简写。 */ | ||
| export const CLI_SUBCOMMANDS = ['up', 'console', 'init', 'doctor', 'lang', 'say', 'broadcast', 'roster', 'log']; | ||
| /** 是否可用作"具名办公室"名:以 assertOfficeName 为单一真源(同时拦保留名 default + 非法字符)。 */ | ||
| function isUsableOfficeName(name) { | ||
| try { | ||
| assertOfficeName(name); | ||
| return true; | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| } | ||
| /** | ||
| * 纯判定:第一个参数 cmd 该当作子命令、`falinks <名字>` 办公室简写、还是回退 help。无副作用,供单测。 | ||
| * - cmd ∈ 子命令 → 'subcommand'(子命令优先;办公室名撞这 9 个词时按子命令处理)。 | ||
| * - 否则:未带 --office(office===DEFAULT_OFFICE)、cmd 是可用具名办公室名(assertOfficeName 通过 → 非保留名 default、非非法字符)、且无多余参数(rest 空)→ 'office-shorthand'。 | ||
| * - 其余 → 'help'(非法名/保留名 default/带额外参数/已带 --office 等歧义)。 | ||
| */ | ||
| export function resolveCliAction(cmd, office, rest) { | ||
| if (CLI_SUBCOMMANDS.includes(cmd)) | ||
| return 'subcommand'; | ||
| if (office === DEFAULT_OFFICE && isUsableOfficeName(cmd) && rest.length === 0) | ||
| return 'office-shorthand'; | ||
| return 'help'; | ||
| } |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { useState } from 'react'; | ||
| import { Box, Text, render, useInput } from 'ink'; | ||
| import { t } from '../i18n/index.js'; | ||
| import { DEFAULT_OFFICE, isValidOfficeName } from '../core/office.js'; | ||
| function officeLabel(e) { | ||
| if (e.office === DEFAULT_OFFICE) | ||
| return e.running ? t().officeItemDefaultRunning : t().officeItemDefaultStopped; | ||
| return e.running ? t().officeItemRunning(e.office) : t().officeItemStopped(e.office); | ||
| } | ||
| /** 办公室列表选择器:已有办公室(标运行/停)+ 末尾「+ 新建办公室」。↑↓ 选 / Enter 确认 / Esc 取消。 */ | ||
| function PickApp({ entries, onDone }) { | ||
| const [sel, setSel] = useState(0); | ||
| const total = entries.length + 1; // 末尾「新建」 | ||
| useInput((char, key) => { | ||
| if (key.escape || (key.ctrl && char === 'c')) { | ||
| onDone(null); | ||
| return; | ||
| } | ||
| if (key.upArrow) { | ||
| setSel((s) => Math.max(0, s - 1)); | ||
| return; | ||
| } | ||
| if (key.downArrow) { | ||
| setSel((s) => Math.min(total - 1, s + 1)); | ||
| return; | ||
| } | ||
| if (key.return) { | ||
| if (sel === entries.length) | ||
| onDone({ kind: 'new' }); | ||
| else | ||
| onDone({ kind: 'open', entry: entries[sel] }); | ||
| } | ||
| }); | ||
| return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: t().officePickHeader }), entries.map((e, i) => (_jsxs(Text, { inverse: i === sel, children: [i === sel ? '▶ ' : ' ', officeLabel(e)] }, e.office))), _jsxs(Text, { inverse: sel === entries.length, children: [sel === entries.length ? '▶ ' : ' ', t().officeItemNew] })] })); | ||
| } | ||
| export function runOfficePicker(entries) { | ||
| return new Promise((resolve) => { | ||
| const app = render(_jsx(PickApp, { entries: entries, onDone: (c) => { app.unmount(); resolve(c); } })); | ||
| }); | ||
| } | ||
| /** 新办公室名字输入。Enter 提交合法名;Esc/Ctrl+C 取消返回 null。非法名实时标红、不允许提交。 */ | ||
| function NameApp({ onDone }) { | ||
| const [val, setVal] = useState(''); | ||
| const valid = val.length > 0 && val !== DEFAULT_OFFICE && isValidOfficeName(val); | ||
| useInput((char, key) => { | ||
| if (key.escape || (key.ctrl && char === 'c')) { | ||
| onDone(null); | ||
| return; | ||
| } | ||
| if (key.return) { | ||
| if (valid) | ||
| onDone(val); | ||
| return; | ||
| } | ||
| if (key.backspace || key.delete) { | ||
| setVal((v) => v.slice(0, -1)); | ||
| return; | ||
| } | ||
| if (char && !key.ctrl && !key.meta) | ||
| setVal((v) => v + char); | ||
| }); | ||
| return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: t().officeNamePrompt }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { color: val.length === 0 || valid ? undefined : 'red', children: val }), _jsx(Text, { inverse: true, children: " " })] })] })); | ||
| } | ||
| export function runOfficeNamePrompt() { | ||
| return new Promise((resolve) => { | ||
| const app = render(_jsx(NameApp, { onDone: (n) => { app.unmount(); resolve(n); } })); | ||
| }); | ||
| } |
| import { t } from '../i18n/index.js'; | ||
| import { zh } from '../i18n/zh.js'; | ||
| import { en } from '../i18n/en.js'; | ||
| /** | ||
| * 组装最终 bootstrap —— **纯函数,可单测、无副作用、无磁盘 IO**: | ||
| * houseRules + identity + roleBootstrap | ||
| * + (agent.assistant → assistantRules) | ||
| * + (agent.lead → coordinatorRules + (团队有 assistant → coordinatorAssistAddendum) + (leadStateDoc → 项目状态段)) | ||
| * 磁盘读取(loadLeadState)留在 caller(index.ts),读出的 doc 经 opts.leadStateDoc 传入。 | ||
| * | ||
| * @param agent 目标 agent | ||
| * @param cfg 本团队配置(仅用 cfg.agents 的 assistant 标记判断是否给 lead 追加 addendum) | ||
| * @param opts leadStateDoc / locale(均可选) | ||
| */ | ||
| export function composeBootstrap(agent, cfg, opts) { | ||
| const T = opts?.locale === 'en' ? en : opts?.locale === 'zh' ? zh : t(); | ||
| let s = `${T.houseRules}\n${T.identityLine(agent.name, agent.role)}${agent.bootstrap ?? ''}`; | ||
| if (agent.assistant) { | ||
| // 助理:执行不决策(与 coordinatorRules 互斥位——助理不会是 lead)。 | ||
| s += `\n${T.assistantRules}`; | ||
| } | ||
| if (agent.lead) { | ||
| s += `\n${T.coordinatorRules}`; | ||
| // 本团队配有助理 → 追加"把体力活分给助理"的话;无助理团队(如 fullstack)的 lead 不加。 | ||
| if (cfg.agents.some((x) => x.assistant)) | ||
| s += `\n${T.coordinatorAssistAddendum}`; | ||
| // 项目状态档续接(caller 已从磁盘读出并传入);只有 lead 且非空时拼接。 | ||
| if (opts?.leadStateDoc) | ||
| s += `\n【项目状态(续接用,这是你上一段会话沉淀的记忆)】\n${opts.leadStateDoc}`; | ||
| } | ||
| return s; | ||
| } |
| import { join } from 'node:path'; | ||
| import { existsSync, readdirSync } from 'node:fs'; | ||
| import { runtimeDir, projectKey, readInstance } from '../runtime.js'; | ||
| import { t } from '../i18n/index.js'; | ||
| /** | ||
| * 多办公室(multi-office)助手:同一项目目录下并行多间独立办公室。 | ||
| * 默认办公室(DEFAULT_OFFICE)沿用旧路径,逐字节兼容、零迁移;具名办公室在 key/config 路径上加 `--<office>` / `.falinks/<office>`。 | ||
| */ | ||
| /** 默认办公室 id(即"不填名"那间)。其 key/路径与旧版逐字节相同——勿改此常量。 */ | ||
| export const DEFAULT_OFFICE = 'default'; | ||
| /** 合法 office 名:ascii,首字符字母数字,其后可含 . _ -,总长 1–32。不含路径分隔符 / `..` / 空格。 */ | ||
| const OFFICE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/; | ||
| /** 仅校验字符规则(不含保留名 default 判定)。 */ | ||
| export function isValidOfficeName(name) { | ||
| return OFFICE_NAME_RE.test(name); | ||
| } | ||
| /** | ||
| * 断言用户输入的 office 名可用作"具名办公室":非法字符 / 空 / 超长 / 含 `/`、`..` → throw; | ||
| * 保留名 `default` 也 throw(默认办公室不用 --office,直接 falinks / falinks up)。 | ||
| */ | ||
| export function assertOfficeName(name) { | ||
| if (name === DEFAULT_OFFICE) { | ||
| throw new Error(t().officeNameReserved); | ||
| } | ||
| if (!isValidOfficeName(name)) { | ||
| throw new Error(t().officeNameInvalid(name)); | ||
| } | ||
| } | ||
| /** 运行态 key 的 office 后缀:默认办公室无后缀(保证逐字节兼容),具名为 `--<office>`。 */ | ||
| export function officeSuffix(office = DEFAULT_OFFICE) { | ||
| return office === DEFAULT_OFFICE ? '' : `--${office}`; | ||
| } | ||
| /** | ||
| * 运行态 key:base(= projectKey,sha1(realpath(cwd)) 前16位) + office 后缀。 | ||
| * 默认办公室 == projectKey(cwd)(无后缀);具名 == `${projectKey}--${office}`。 | ||
| * 注:message-log 历史上用 sha1(cwd)(未 realpath)作 base,为兼容它**保留各自 base**,共享 officeSuffix。 | ||
| */ | ||
| export function keyFor(cwd, office = DEFAULT_OFFICE) { | ||
| return projectKey(cwd) + officeSuffix(office); | ||
| } | ||
| /** | ||
| * config 路径:默认办公室 <cwd>/falinks.config.json(不变);具名 <cwd>/.falinks/<office>.config.json。 | ||
| * (.falinks/ 目录在首次写入具名 config 时由调用方建。) | ||
| */ | ||
| export function resolveConfigPath(cwd, office = DEFAULT_OFFICE) { | ||
| return office === DEFAULT_OFFICE | ||
| ? join(cwd, 'falinks.config.json') | ||
| : join(cwd, '.falinks', `${office}.config.json`); | ||
| } | ||
| /** | ||
| * 枚举本项目所有办公室:默认(若 <cwd>/falinks.config.json 存在)+ 每个 <cwd>/.falinks/*.config.json。 | ||
| * 各自查实例档案 (cwd, office) 标注运行中/已停(仅按档案是否存在,不探活——探活留给调用方/discovery)。 | ||
| */ | ||
| export function listOffices(cwd, root = runtimeDir()) { | ||
| const out = []; | ||
| const mark = (office, configPath) => { | ||
| const inst = readInstance(cwd, root, office); | ||
| out.push({ office, configPath, running: !!inst, port: inst?.port }); | ||
| }; | ||
| const defConfig = resolveConfigPath(cwd, DEFAULT_OFFICE); | ||
| if (existsSync(defConfig)) | ||
| mark(DEFAULT_OFFICE, defConfig); | ||
| let named = []; | ||
| try { | ||
| named = readdirSync(join(cwd, '.falinks')) | ||
| .filter((n) => n.endsWith('.config.json')) | ||
| .map((n) => n.slice(0, -'.config.json'.length)) | ||
| .filter((office) => office !== DEFAULT_OFFICE && isValidOfficeName(office)) | ||
| .sort(); | ||
| } | ||
| catch { /* 无 .falinks/ 目录 → 没有具名办公室 */ } | ||
| for (const office of named) | ||
| mark(office, resolveConfigPath(cwd, office)); | ||
| return out; | ||
| } |
+17
-0
| # Changelog | ||
| ## 0.15.0 | ||
| - **多办公室(multi-office)**:同一项目目录下可并行开多个独立办公室,各自独立 config / bus 端口 / roster / 消息 / leadstate / `/office` 页。默认办公室(不填名)沿用旧路径,老项目**零迁移、逐字节兼容**。具名办公室 `falinks up --office <name>`(config 存 `.falinks/<name>.config.json`),裸 `falinks` 进交互列表(列出本项目所有办公室、标 running/stopped,选一个或新建),`falinks console --office <name>` 连控制台。办公室名小写 `a-z 0-9 . _ -`、1–32 字符;`default` 为保留名(直接用 `falinks` / `falinks up`)。 | ||
| - **`falinks <name>` 裸名快捷方式**:等价 `falinks up --office <name>`,省去 `up --office`。 | ||
| - **「组长 + 助理组」预设 `assisted`**:1 组长 + 3 助理(调研员 / 资料梳理 / 草拟汇总)。新增 `assistant` 一等标记(对称 `lead`),助理「只执行不决策」——决策类工具本就组长专属,叠加 bootstrap 行为约束(岔路口给组长列选项、不直接找 boss 要决策);组长在有助理时 bootstrap 追加「把体力活并行分给助理」。`assistant && lead` 互斥。 | ||
| - **收件箱合并投递(inbox coalescing)**:员工空闲轮到时,把收件箱里所有排队消息**合并成一轮**送达(编号 + from 归属),使其针对「当前全貌」作答,消除「逐条回最旧消息」的交叉错位。单条消息行为零变化;guards(loop/turn-cap/rate-limit)仍在 send 时生效。 | ||
| - **像素办公室细节打磨**:idle 打盹闭眼(reduced-motion 兼容)、猫狗自由漫游(避开沙发、修正移动朝向)。 | ||
| - **助理角标 + 默认面板团队对话(/office)**:助理头顶画冷青单层 V(chevron),区别于 lead 金冠;未选中成员时右栏图例下方展示全局消息流(from→to + 时间 + 正文,最新在底、自动跟随),选中成员仍显示其相关消息。 | ||
| ## 0.14.0 | ||
| - **像素办公室 live view(`/office`)**:新增浏览器实时俯视图——`falinks office` 起 `/office` 页 + `/office/state` 聚合接口(roster + 最近消息 + 待答问题),自研像素美术(暖色 cozy 俯视),按队伍规模 fit-to-viewport 自适应布局。 | ||
| - **6 态可读性编码**:idle / busy / waiting / stuck / done / offline 各有固定形状 + 主色 + 脚下地台表现 + 右栏图例,三处单一事实源(sprites.json),缩略图/色弱也能分;busy 按队列深度分 L0–L3 强度;done 瞬态可感知。 | ||
| - **游戏式聊天气泡**:说话时员工头顶弹对话气泡,尾巴指向说话人头部、贴边自动收拢。 | ||
| - **办公室布局与陈设**:家具分区、名字上桌、boss 行政位 + 身后书架背景,沙发休息区(不重叠)、宠物、地毯等 cozy 装饰;idle 员工随机打盹动画。 | ||
| - **引导员工空闲即等待**,减少空转打转(stuck-spinning);控制台接 `/office` 命令;跨平台 openBrowser;i18n 与文档同步。 | ||
| ## 0.13.3 | ||
@@ -4,0 +21,0 @@ |
@@ -7,2 +7,3 @@ import http from 'node:http'; | ||
| import { handleOfficeRequest } from '../office/serve.js'; | ||
| import { DEFAULT_OFFICE } from '../core/office.js'; | ||
| const PATH_RE = /^\/agent\/([^/]+)\/mcp$/; | ||
@@ -154,2 +155,3 @@ /** 进程内的待答问题存储(ask 工具写入,控制台轮询 /admin/questions 读,/admin/answer 取走)。 */ | ||
| startedAt: opts?.identity?.startedAt ?? Date.now(), | ||
| office: opts?.identity?.office ?? DEFAULT_OFFICE, | ||
| }; | ||
@@ -160,3 +162,3 @@ const questions = new QuestionStore(); | ||
| // ---- /office 像素办公室彩蛋(只读静态页 + state 聚合)---- | ||
| if (handleOfficeRequest(req, res, { router: deps.router, questions })) | ||
| if (handleOfficeRequest(req, res, { router: deps.router, questions, office: identity.office })) | ||
| return; | ||
@@ -163,0 +165,0 @@ // ---- admin 路由(人/老板入口)---- |
+146
-39
| #!/usr/bin/env node | ||
| import { readFileSync, writeFileSync, existsSync } from 'node:fs'; | ||
| import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; | ||
| import { dirname } from 'node:path'; | ||
| import { execSync } from 'node:child_process'; | ||
@@ -9,2 +10,4 @@ import { up } from './index.js'; | ||
| import { loadSettings, saveSettings } from './settings.js'; | ||
| import { DEFAULT_OFFICE, assertOfficeName, resolveConfigPath, listOffices } from './core/office.js'; | ||
| import { resolveCliAction } from './cli-route.js'; | ||
| const PKG = (() => { | ||
@@ -19,6 +22,6 @@ try { | ||
| let cachedPort = null; | ||
| async function busPort() { | ||
| async function busPort(office = DEFAULT_OFFICE) { | ||
| if (cachedPort) | ||
| return cachedPort; | ||
| const r = await resolveBus(process.cwd()); | ||
| const r = await resolveBus(process.cwd(), { office }); | ||
| if (!r.ok) { | ||
@@ -30,4 +33,4 @@ console.error(r.error); | ||
| } | ||
| async function admin(method, path, body) { | ||
| const res = await fetch(`http://127.0.0.1:${await busPort()}${path}`, { | ||
| async function admin(method, path, body, office = DEFAULT_OFFICE) { | ||
| const res = await fetch(`http://127.0.0.1:${await busPort(office)}${path}`, { | ||
| method, | ||
@@ -40,6 +43,34 @@ headers: { 'content-type': 'application/json' }, | ||
| const DEFAULT_CONFIG_PATH = 'falinks.config.json'; | ||
| /** 当前目录已有配置的员工名简述(如 "alice/bob"),无则 null。 */ | ||
| function currentTeamLabel() { | ||
| /** 从参数里抽出 `--office <name>`(校验合法、拒 default/非法),返回去掉它后的剩余参数。 */ | ||
| function extractOffice(args) { | ||
| const i = args.indexOf('--office'); | ||
| if (i < 0) | ||
| return { office: DEFAULT_OFFICE, rest: args }; | ||
| const name = args[i + 1]; | ||
| if (!name) { | ||
| console.error('--office 需要一个办公室名字'); | ||
| process.exit(1); | ||
| } | ||
| try { | ||
| const c = JSON.parse(readFileSync(DEFAULT_CONFIG_PATH, 'utf8')); | ||
| assertOfficeName(name); | ||
| } | ||
| catch (e) { | ||
| console.error(e.message); | ||
| process.exit(1); | ||
| } | ||
| return { office: name, rest: args.slice(0, i).concat(args.slice(i + 2)) }; | ||
| } | ||
| /** 有新版时返回更新提示数据(仅 TTY);否则 null。 */ | ||
| async function maybeUpdate() { | ||
| if (process.stdin.isTTY && PKG.version) { | ||
| const latest = await fetchLatest(PKG.name); | ||
| if (latest && isNewer(latest, PKG.version)) | ||
| return { latest, current: PKG.version, pkg: PKG.name }; | ||
| } | ||
| return null; | ||
| } | ||
| /** 指定配置文件已有配置的员工名简述(如 "alice/bob"),无则 null。 */ | ||
| function currentTeamLabel(configPath = DEFAULT_CONFIG_PATH) { | ||
| try { | ||
| const c = JSON.parse(readFileSync(configPath, 'utf8')); | ||
| const names = (c.agents ?? []).map((a) => a.name); | ||
@@ -58,6 +89,6 @@ return names.length ? names.join('/') : null; | ||
| */ | ||
| async function chooseTeam(update = null) { | ||
| async function chooseTeam(update = null, configPath = DEFAULT_CONFIG_PATH) { | ||
| if (process.stdin.isTTY) { | ||
| const { runSetup, QUIT_FOR_UPDATE } = await import('./setup/run.js'); | ||
| const cfg = await runSetup(process.cwd(), currentTeamLabel(), update); | ||
| const cfg = await runSetup(process.cwd(), currentTeamLabel(configPath), update); | ||
| if (cfg === QUIT_FOR_UPDATE) { | ||
@@ -67,11 +98,13 @@ console.log(t().exitUpdateHint(upgradeCommand(PKG.name))); | ||
| } | ||
| if (cfg !== null) | ||
| writeFileSync(DEFAULT_CONFIG_PATH, JSON.stringify(cfg, null, 2)); // null=继续当前,不覆盖 | ||
| if (cfg !== null) { // null=继续当前,不覆盖 | ||
| mkdirSync(dirname(configPath) || '.', { recursive: true }); // 具名办公室:首次自动建 .falinks/ | ||
| writeFileSync(configPath, JSON.stringify(cfg, null, 2)); | ||
| } | ||
| } | ||
| else if (!existsSync(DEFAULT_CONFIG_PATH)) { | ||
| writeDefaultConfig(); | ||
| else if (!existsSync(configPath)) { | ||
| writeDefaultConfig(configPath); | ||
| } | ||
| } | ||
| /** 在当前目录写一份默认配置(一个 claude 员工,工作目录=当前目录;其余用 /add 自行添加)。 */ | ||
| function writeDefaultConfig() { | ||
| /** 在指定路径写一份默认配置(一个 claude 员工,工作目录=当前目录;其余用 /add 自行添加)。 */ | ||
| function writeDefaultConfig(configPath = DEFAULT_CONFIG_PATH) { | ||
| const cwd = process.cwd(); | ||
@@ -84,3 +117,4 @@ const config = { | ||
| }; | ||
| writeFileSync(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2)); | ||
| mkdirSync(dirname(configPath) || '.', { recursive: true }); | ||
| writeFileSync(configPath, JSON.stringify(config, null, 2)); | ||
| } | ||
@@ -91,12 +125,58 @@ async function init() { | ||
| } | ||
| /** 裸 `falinks` 的一键运行:先查更新(有则问继续/退出去更新),再选团队(默认沿用当前),再起。 */ | ||
| /** | ||
| * 裸 `falinks`: | ||
| * - 非 TTY:旧行为(默认办公室,无配置则写默认后启动)。 | ||
| * - TTY 且本项目一个办公室都没有:旧行为(查更新 → 向导建默认办公室 → 启动)。 | ||
| * - TTY 且已有办公室:列出(默认 + .falinks/*)让用户选——运行中→连其控制台;已停→启动;或「+新建」(问名→向导→启动)。 | ||
| */ | ||
| async function runHere() { | ||
| let update = null; | ||
| if (process.stdin.isTTY && PKG.version) { | ||
| const latest = await fetchLatest(PKG.name); | ||
| if (latest && isNewer(latest, PKG.version)) | ||
| update = { latest, current: PKG.version, pkg: PKG.name }; | ||
| if (!process.stdin.isTTY) { | ||
| if (!existsSync(DEFAULT_CONFIG_PATH)) | ||
| writeDefaultConfig(DEFAULT_CONFIG_PATH); | ||
| await up(DEFAULT_CONFIG_PATH); | ||
| return; | ||
| } | ||
| await chooseTeam(update); | ||
| await up(DEFAULT_CONFIG_PATH); | ||
| // 查更新照旧:放在 picker 之前每次都跑(别因为进 picker 把它丢了)。 | ||
| const update = await maybeUpdate(); | ||
| const offices = listOffices(process.cwd()); | ||
| if (offices.length === 0) { | ||
| // 一个办公室都没有 → 旧行为:(查更新提示由向导首屏承载)团队向导建默认办公室 → 启动。 | ||
| await chooseTeam(update, DEFAULT_CONFIG_PATH); | ||
| await up(DEFAULT_CONFIG_PATH); | ||
| return; | ||
| } | ||
| // 已有办公室:进 picker 之前先把"有新版"提示打出来(picker 本身不退出去更新,提示即可)。 | ||
| if (update) | ||
| console.log(`${t().setupUpdateFound(update.latest, update.current)} — \`${upgradeCommand(update.pkg)}\``); | ||
| const { runOfficePicker, runOfficeNamePrompt } = await import('./console/office-pick.js'); | ||
| const choice = await runOfficePicker(offices); | ||
| if (!choice) | ||
| return; // 取消 | ||
| if (choice.kind === 'open') { | ||
| const e = choice.entry; | ||
| if (e.running) { | ||
| console.log(t().officeOpening(e.office)); | ||
| const r = await resolveBus(process.cwd(), { office: e.office }); | ||
| if (!r.ok) { | ||
| console.error(r.error); | ||
| process.exit(1); | ||
| } | ||
| const { renderConsole } = await import('./console/run.js'); | ||
| renderConsole(r.port); | ||
| } | ||
| else { | ||
| console.log(t().officeStarting(e.office)); | ||
| await up(e.configPath, e.office); | ||
| } | ||
| return; | ||
| } | ||
| // + 新建办公室:问名 → 向导写到 .falinks/<name>.config.json → 启动。 | ||
| const name = await runOfficeNamePrompt(); | ||
| if (!name) | ||
| return; | ||
| const cfgPath = resolveConfigPath(process.cwd(), name); | ||
| await chooseTeam(update, cfgPath); | ||
| if (!existsSync(cfgPath)) | ||
| return; // 向导取消、未写配置 → 不启动 | ||
| await up(cfgPath, name); | ||
| } | ||
@@ -126,7 +206,23 @@ function has(cmd) { | ||
| } | ||
| /** 启动具名办公室:config = .falinks/<office>.config.json;不存在则 TTY 走向导 / 非 TTY 写默认,再启动。 | ||
| * `falinks up --office <名字>` 与 `falinks <名字>` 简写共用,避免复制粘贴。 */ | ||
| async function startNamedOffice(office) { | ||
| const cfgPath = resolveConfigPath(process.cwd(), office); | ||
| if (!existsSync(cfgPath)) { | ||
| if (process.stdin.isTTY) { | ||
| await chooseTeam(null, cfgPath); | ||
| if (!existsSync(cfgPath)) | ||
| return; | ||
| } // 向导取消未写 → 不启动 | ||
| else | ||
| writeDefaultConfig(cfgPath); | ||
| } | ||
| await up(cfgPath, office); | ||
| } | ||
| async function main() { | ||
| initLocale(); | ||
| const [cmd, ...rest] = process.argv.slice(2); | ||
| const { office, rest: argv } = extractOffice(process.argv.slice(2)); | ||
| const [cmd, ...rest] = argv; | ||
| if (!cmd) { | ||
| // 裸 falinks = 在当前目录一键运行 | ||
| // 裸 falinks = 在当前目录交互(选/建办公室)或一键运行 | ||
| await runHere(); | ||
@@ -137,12 +233,17 @@ return; | ||
| case 'up': { | ||
| const cfgPath = rest[0] ?? 'falinks.config.json'; | ||
| if (!existsSync(cfgPath)) { | ||
| console.error(t().upConfigNotFound(cfgPath)); | ||
| process.exit(1); | ||
| if (office !== DEFAULT_OFFICE) { | ||
| await startNamedOffice(office); | ||
| } | ||
| await up(cfgPath); | ||
| else { | ||
| const cfgPath = rest[0] ?? 'falinks.config.json'; | ||
| if (!existsSync(cfgPath)) { | ||
| console.error(t().upConfigNotFound(cfgPath)); | ||
| process.exit(1); | ||
| } | ||
| await up(cfgPath); | ||
| } | ||
| break; | ||
| } | ||
| case 'console': | ||
| await import('./console/main.js'); | ||
| await import('./console/main.js'); // console/main 自行解析 --office / --port | ||
| break; | ||
@@ -167,3 +268,3 @@ case 'init': | ||
| // 若有运行中的实例,顺带切它(失败静默) | ||
| const r = await resolveBus(process.cwd()); | ||
| const r = await resolveBus(process.cwd(), { office }); | ||
| if (r.ok) { | ||
@@ -185,16 +286,22 @@ try { | ||
| const [to, ...msg] = rest; | ||
| console.log(await admin('POST', '/admin/say', { to, message: msg.join(' ') })); | ||
| console.log(await admin('POST', '/admin/say', { to, message: msg.join(' ') }, office)); | ||
| break; | ||
| } | ||
| case 'broadcast': | ||
| console.log(await admin('POST', '/admin/broadcast', { message: rest.join(' ') })); | ||
| console.log(await admin('POST', '/admin/broadcast', { message: rest.join(' ') }, office)); | ||
| break; | ||
| case 'roster': | ||
| console.log(JSON.stringify(await admin('GET', '/admin/roster'), null, 2)); | ||
| console.log(JSON.stringify(await admin('GET', '/admin/roster', undefined, office), null, 2)); | ||
| break; | ||
| case 'log': | ||
| console.log(JSON.stringify(await admin('GET', '/admin/log'), null, 2)); | ||
| console.log(JSON.stringify(await admin('GET', '/admin/log', undefined, office), null, 2)); | ||
| break; | ||
| default: | ||
| console.log(t().defaultHelp); | ||
| // `falinks <名字>` = `falinks up --office <名字>` 简写(未带 --office、合法办公室名、无多余参数)。 | ||
| if (resolveCliAction(cmd, office, rest) === 'office-shorthand') { | ||
| await startNamedOffice(cmd); | ||
| break; | ||
| } | ||
| console.error(t().cliUnknownHint); | ||
| console.log(t().defaultHelp + '\n' + t().optOffice); | ||
| process.exit(1); | ||
@@ -201,0 +308,0 @@ } |
+34
-3
| import { renderConsole } from './run.js'; | ||
| import { existsSync } from 'node:fs'; | ||
| import { resolveBus } from '../discovery.js'; | ||
| import { initLocale } from '../i18n/index.js'; | ||
| import { initLocale, t } from '../i18n/index.js'; | ||
| import { DEFAULT_OFFICE, resolveConfigPath } from '../core/office.js'; | ||
| initLocale(); | ||
| // 优先 --port(up 直传,免发现);手动调用回退按 cwd 寻址。 | ||
| // --office:连指定办公室(具名);缺省=默认办公室。--port 优先(up 直传,免发现)。 | ||
| const oi = process.argv.indexOf('--office'); | ||
| const office = oi >= 0 && process.argv[oi + 1] ? process.argv[oi + 1] : DEFAULT_OFFICE; | ||
| const i = process.argv.indexOf('--port'); | ||
| const argPort = i >= 0 ? Number(process.argv[i + 1]) : NaN; | ||
| /** 具名办公室:连上后核对实例 office 匹配,防连错(--port 直传或档案被复用)。默认办公室沿用旧借用兼容,不强校验。 */ | ||
| async function validateOffice(port) { | ||
| if (office === DEFAULT_OFFICE) | ||
| return; | ||
| try { | ||
| const res = await fetch(`http://127.0.0.1:${port}/admin/info`); | ||
| const info = await res.json(); | ||
| const got = info?.office ?? DEFAULT_OFFICE; | ||
| if (got !== office) { | ||
| console.error(t().officeMismatch(office, got)); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| catch { /* 探活失败:交给后续连接报错,不在此拦 */ } | ||
| } | ||
| /** 具名办公室没找到运行实例时,按"有配置=未启动 / 无配置=不存在"给出可执行提示。 */ | ||
| function explainNamedMiss() { | ||
| if (existsSync(resolveConfigPath(process.cwd(), office))) | ||
| console.error(t().officeNotRunning(office)); | ||
| else | ||
| console.error(t().officeConfigNotFound(office)); | ||
| process.exit(1); | ||
| } | ||
| if (Number.isFinite(argPort) && argPort > 0) { | ||
| await validateOffice(argPort); | ||
| renderConsole(argPort); | ||
| } | ||
| else { | ||
| const r = await resolveBus(process.cwd()); | ||
| const r = await resolveBus(process.cwd(), { office }); | ||
| if (!r.ok) { | ||
| if (office !== DEFAULT_OFFICE) | ||
| explainNamedMiss(); | ||
| console.error(r.error); | ||
| process.exit(1); | ||
| } | ||
| await validateOffice(r.port); | ||
| renderConsole(r.port); | ||
| } |
@@ -33,3 +33,6 @@ /** 校验并归一化原始配置对象。抛错即配置非法。 */ | ||
| throw new Error(`config.agents[${i}].model must be a string`); | ||
| return { name: a.name, cli: a.cli, cwd: a.cwd, role: a.role, lead: a.lead === true, bootstrap: a.bootstrap, model: a.model || undefined }; | ||
| // assistant 与 lead 互斥:一个 agent 不能既是组长又是助理。 | ||
| if (a.assistant === true && a.lead === true) | ||
| throw new Error(`config.agents[${i}] cannot be both lead and assistant`); | ||
| return { name: a.name, cli: a.cli, cwd: a.cwd, role: a.role, lead: a.lead === true, assistant: a.assistant === true, bootstrap: a.bootstrap, model: a.model || undefined }; | ||
| }); | ||
@@ -36,0 +39,0 @@ const routes = raw.routes ?? {}; |
+11
-9
@@ -27,4 +27,4 @@ export class Router { | ||
| } | ||
| addAgent(name, role, lead) { | ||
| this.agents.set(name, { name, role, status: 'launching', inbox: [], lead: !!lead }); | ||
| addAgent(name, role, lead, assistant) { | ||
| this.agents.set(name, { name, role, status: 'launching', inbox: [], lead: !!lead, assistant: !!assistant }); | ||
| } | ||
@@ -243,13 +243,15 @@ /** 指定组长(协调者):该 agent lead=true、其余全部清零(强制全队唯一)。未知名抛错。 */ | ||
| } | ||
| /** 若 agent 空闲且 inbox 非空,取出一条投递并标 busy。 */ | ||
| /** 若 agent 空闲且 inbox 非空,取走**当前全部**排队消息合并成一批投递并标 busy(P1 收件箱合并)。 */ | ||
| pump(a) { | ||
| if (a.status !== 'idle') | ||
| return; | ||
| const msg = a.inbox.shift(); | ||
| if (!msg) | ||
| const batch = a.inbox.splice(0); // 取走当前全部排队消息(含队首被 unshift 的 urgent,顺序保留) | ||
| if (batch.length === 0) | ||
| return; | ||
| a.status = 'busy'; | ||
| a.handling = msg.thread; | ||
| a.handlingFrom = msg.from; | ||
| this.deliverer.deliver(a, msg); | ||
| // 线程续接/turn-cap 记账(启发式):同 sender 自然=最后一条;多 sender 用最后一条。 | ||
| const last = batch[batch.length - 1]; | ||
| a.handling = last.thread; | ||
| a.handlingFrom = last.from; | ||
| this.deliverer.deliver(a, batch); | ||
| } | ||
@@ -264,3 +266,3 @@ /** 插队直送:闲时插到队首走 pump(置 busy、记 handling——"闲时等价 pump"由复用保证); | ||
| } | ||
| this.deliverer.deliver(a, msg); | ||
| this.deliverer.deliver(a, [msg]); // 忙/卡:单元素数组=立即直送,绕过普通批次(urgent 红线) | ||
| } | ||
@@ -267,0 +269,0 @@ /** 从花名册移除一个 agent(运行时删员工)。未知名为 no-op。 */ |
+12
-10
@@ -5,2 +5,3 @@ import { appendFileSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'; | ||
| import { runtimeDir } from './runtime.js'; | ||
| import { DEFAULT_OFFICE, officeSuffix } from './core/office.js'; | ||
| /** | ||
@@ -15,10 +16,11 @@ * 诊断事件流水:记录"会悄悄打断协作流程"的事件,便于反复卡死后回溯定位。 | ||
| export const DIAG_CAP = 1000; | ||
| /** 每个项目目录一份诊断流水:~/.falinks/diag/<cwd 的 sha1 前16位>.jsonl。root 可注入便于测试。 */ | ||
| export function diagPath(cwd, root = runtimeDir()) { | ||
| const key = createHash('sha1').update(cwd).digest('hex').slice(0, 16); | ||
| /** 每个 (项目目录, 办公室) 一份诊断流水:~/.falinks/diag/<sha1(cwd)前16位[--office]>.jsonl。root 可注入便于测试。 | ||
| * 注:base 沿用 sha1(cwd)(与 message-log 同款,未 realpath),为默认办公室逐字节兼容而保留;office 后缀规则与全局一致。 */ | ||
| export function diagPath(cwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const key = createHash('sha1').update(cwd).digest('hex').slice(0, 16) + officeSuffix(office); | ||
| return join(root, 'diag', `${key}.jsonl`); | ||
| } | ||
| /** 追加一条诊断事件(O(1) 追加)。 */ | ||
| export function appendDiag(cwd, ev, root = runtimeDir()) { | ||
| const p = diagPath(cwd, root); | ||
| export function appendDiag(cwd, ev, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = diagPath(cwd, root, office); | ||
| mkdirSync(dirname(p), { recursive: true }); | ||
@@ -28,4 +30,4 @@ appendFileSync(p, JSON.stringify(ev) + '\n'); | ||
| /** 读最近 cap 条;坏行跳过;文件超过 2×cap 行时顺手压缩重写。 */ | ||
| export function loadDiag(cwd, cap = DIAG_CAP, root = runtimeDir()) { | ||
| const p = diagPath(cwd, root); | ||
| export function loadDiag(cwd, cap = DIAG_CAP, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = diagPath(cwd, root, office); | ||
| if (!existsSync(p)) | ||
@@ -57,5 +59,5 @@ return []; | ||
| } | ||
| /** 清空某项目的诊断流水(删除文件)。不存在为 no-op。 */ | ||
| export function clearDiag(cwd, root = runtimeDir()) { | ||
| rmSync(diagPath(cwd, root), { force: true }); | ||
| /** 清空某 (项目, 办公室) 的诊断流水(删除文件)。不存在为 no-op。 */ | ||
| export function clearDiag(cwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| rmSync(diagPath(cwd, root, office), { force: true }); | ||
| } |
+16
-8
| import { runtimeDir, realCwd, readInstance, removeInstanceFile, instancePath, listInstances } from './runtime.js'; | ||
| import { DEFAULT_OFFICE } from './core/office.js'; | ||
| import { t } from './i18n/index.js'; | ||
@@ -39,6 +40,7 @@ function isConnRefused(e) { | ||
| /** | ||
| * 按 cwd 找运行中的总线: | ||
| * ① 本项目档案(探活+核对 cwd,防端口复用劫持)→ 命中; | ||
| * ② 扫全部档案,恰好一个活的 → 借用(保住"任意目录 falinks roster"的旧体验); | ||
| * 多个 → 列出 cwd 报错;零个 → "找不到"。扫描顺手删确认死亡/张冠李戴的 stale 档案(自愈)。 | ||
| * 按 (cwd, office) 找运行中的总线: | ||
| * ① 本 (项目,办公室) 档案(探活+核对 cwd 与 office,防端口复用劫持)→ 命中; | ||
| * ② 仅默认办公室:扫全部档案,恰好一个**默认办公室**活实例 → 借用(保住"任意目录 falinks roster"的旧体验); | ||
| * 多个 → 列出 cwd 报错;零个 → "找不到"。具名办公室不借用,必须 (cwd,office) 命中。 | ||
| * 扫描顺手删确认死亡/张冠李戴的 stale 档案(自愈)。 | ||
| */ | ||
@@ -49,16 +51,22 @@ export async function resolveBus(cwd, opts) { | ||
| const timeoutMs = opts?.timeoutMs; | ||
| const office = opts?.office ?? DEFAULT_OFFICE; | ||
| const me = realCwd(cwd); | ||
| const mine = readInstance(cwd, root); | ||
| const officeOf = (info) => info.office ?? DEFAULT_OFFICE; | ||
| const mine = readInstance(cwd, root, office); | ||
| if (mine) { | ||
| const p = await probeBus(mine.port, fetchFn, timeoutMs ?? 500); | ||
| if (p.state === 'alive' && p.info.cwd === me) | ||
| if (p.state === 'alive' && p.info.cwd === me && officeOf(p.info) === office) | ||
| return { ok: true, port: mine.port }; | ||
| if (p.state === 'dead' || p.state === 'alive') | ||
| removeInstanceFile(instancePath(cwd, root)); // dead 或 cwd 不符=stale | ||
| removeInstanceFile(instancePath(cwd, root, office)); // dead 或 cwd/office 不符=stale | ||
| // unknown:不删,落到扫描(还会再探一次,仍 unknown 则跳过) | ||
| } | ||
| // 借用全局唯一存活实例:仅默认办公室保留此旧兼容;具名办公室必须命中本档案。 | ||
| if (office !== DEFAULT_OFFICE) | ||
| return { ok: false, error: t().busNotFound }; | ||
| const alive = []; | ||
| for (const e of listInstances(root)) { | ||
| const p = await probeBus(e.info.port, fetchFn, timeoutMs ?? 500); | ||
| if (p.state === 'alive' && p.info.cwd === e.info.cwd) | ||
| // 只借用默认办公室的存活实例(具名实例不借,且 console 会校验 office,借了也会被拒)。 | ||
| if (p.state === 'alive' && p.info.cwd === e.info.cwd && officeOf(p.info) === DEFAULT_OFFICE) | ||
| alive.push({ port: e.info.port, cwd: e.info.cwd }); | ||
@@ -65,0 +73,0 @@ else if (p.state === 'dead' || p.state === 'alive') |
+34
-0
@@ -150,2 +150,22 @@ /** 英文词典:typeof zh 钉死 key 与签名——漏译/签名不符直接编译失败。 */ | ||
| busInstanceLine: (cwd, port) => ` ${cwd} (port ${port})`, | ||
| // —— multi-office · see docs/MULTI-OFFICE-DESIGN.md Appendix A —— | ||
| // A1 --office help (shared by up/console etc.) | ||
| optOffice: '--office <name> Target a named office (config: .falinks/<name>.config.json). Omit for the default office.', | ||
| // A2 bare falinks interactive pick | ||
| officePickHeader: 'falinks — pick an office (↑↓ select · Enter confirm)', | ||
| officeItemDefaultRunning: '(default office) · running — open its console', | ||
| officeItemDefaultStopped: '(default office) · stopped — start it', | ||
| officeItemRunning: (name) => `${name} · running — open its console`, | ||
| officeItemStopped: (name) => `${name} · stopped — start it`, | ||
| officeItemNew: '+ New office…', | ||
| officeNamePrompt: 'Office name (a-z 0-9 . _ - , 1–32 chars):', | ||
| // A3 errors | ||
| officeNameInvalid: (x) => `Invalid office name "${x}". Use 1–32 chars: lowercase letters, digits, . _ - (no / or ..).`, | ||
| officeNameReserved: '"default" is reserved. For the default office just run `falinks` or `falinks up` (no --office).', | ||
| officeNotRunning: (name) => `Office "${name}" isn't running in this directory. Start it: \`falinks up --office ${name}\`.`, | ||
| officeConfigNotFound: (name) => `No office "${name}" here (looked for .falinks/${name}.config.json). Create it: \`falinks up --office ${name}\`.`, | ||
| // supplementary (not in appendix; needed by impl): status lines + console office-match guard | ||
| officeOpening: (office) => `Connecting to office "${office}"…`, | ||
| officeStarting: (office) => `Starting office "${office}"…`, | ||
| officeMismatch: (want, got) => `Office mismatch: you asked for "${want}" but this instance is "${got}". Use --office ${got} or go to the matching office.`, | ||
| // —— cli.ts —— | ||
@@ -162,3 +182,5 @@ exitUpdateHint: (cmd) => `Exited. Update command: ${cmd}`, | ||
| 'Run directly: falinks (generates config and starts on first run)\n' + | ||
| 'Office shortcut: falinks <name> (= falinks up --office <name>, start/create a named office)\n' + | ||
| 'Subcommands: falinks init | doctor | lang | up [config] | say <agent> <msg> | broadcast <msg> | roster | log', | ||
| cliUnknownHint: 'Invalid name or unknown command.', | ||
| // —— setup/app.tsx —— | ||
@@ -184,2 +206,4 @@ setupUpdateFound: (latest, current) => `🆕 New version ${latest} available (current v${current})`, | ||
| incomingMsg: (from, body) => `[From ${from}] ${body}\n(Reply via sendmsg(to="${from}", message="..."))`, | ||
| /** Inbox coalescing: header shown when ≥2 queued messages are delivered together (single message skips this). */ | ||
| inboxBatchHeader: (n) => `You have ${n} new messages (coalesced); handle them together with the latest state in mind, don't just reply to the oldest:`, | ||
| // —— index.ts: collaboration rules injected into workers + startup logs —— | ||
@@ -246,2 +270,12 @@ houseRules: '[falinks collaboration rules] You are an AI worker in this office, collaborating through falinks MCP tools. ' + | ||
| tplResearchEditor: "Editor, reviews and polishes the writer's output", | ||
| // —— preset "lead + assistants" + assistant working rules (ux final; see design Appendix A) —— | ||
| tplAssistedName: 'Lead + assistants (researcher + curator + drafter)', | ||
| tplAssistedLead: 'Lead, coordinates and decides: breaks work down, assigns to assistants, decides and reviews — hands-off on the doing', | ||
| tplAssistedResearcher: 'Researcher, investigation and fact-checking: reads code / looks things up / runs commands, produces structured findings', | ||
| tplAssistedCurator: 'Curator, organizes research output into structured, usable material/checklists', | ||
| tplAssistedDrafter: "Drafter, drafts docs/summaries per the lead's intent and hands first drafts back to the lead for finalizing", | ||
| assistantRules: "[Assistant playbook] You are the team lead's assistant. Your job: carry out the concrete work the lead hands you — investigation, reading code / looking things up, running commands, organizing & drafting, aggregating output — and hand the conclusions and results back to the lead. " + | ||
| "You do NOT make decisions: do not settle approach/architecture/priorities, do not assign work to other members, and do not answer the boss's decision questions on the lead's behalf. At any fork that needs a call, write up \"the options + each one's pros/cons + your recommendation\" and send it to the lead to decide. " + | ||
| 'For clarification go to the lead, not directly to the boss for decisions. When done, report to the lead and idle.', | ||
| coordinatorAssistAddendum: '[When you have assistants] This team has assistants: push the hands-on legwork (investigation, reading & verifying, organizing material, drafting, aggregating) out to them in parallel as much as possible, and focus yourself on decisions, design, task breakdown, review, and communicating with the boss — do not get heads-down doing it yourself. Assistants only execute, not decide; after they report, the call is yours.', | ||
| // —— todolist message templates (dispatch/nudge sent as boss; summary sent as falinks) —— | ||
@@ -248,0 +282,0 @@ todoDispatchMsg: (seq, pos, total, body, isResend) => `[Task #${seq} · ${pos}/${total}]${isResend ? ' (resend)' : ''} ${body}\n` + |
+34
-0
@@ -158,2 +158,22 @@ /** 中文基准词典:全部用户可见/注入员工的文案都从这里出。纯文本=字符串,带变量=箭头函数。 */ | ||
| busInstanceLine: (cwd, port) => ` ${cwd}(端口 ${port})`, | ||
| // —— 多办公室(multi-office)·文案见 docs/MULTI-OFFICE-DESIGN.md 附录 A —— | ||
| // A1 --office help(up/console 等通用) | ||
| optOffice: '--office <名字> 指定具名办公室(配置:.falinks/<名字>.config.json);不填即默认办公室。', | ||
| // A2 裸 falinks 交互选择 | ||
| officePickHeader: 'falinks — 选择办公室(↑↓ 选 · Enter 确认)', | ||
| officeItemDefaultRunning: '(默认办公室) · 运行中 — 打开控制台', | ||
| officeItemDefaultStopped: '(默认办公室) · 已停 — 启动', | ||
| officeItemRunning: (name) => `${name} · 运行中 — 打开控制台`, | ||
| officeItemStopped: (name) => `${name} · 已停 — 启动`, | ||
| officeItemNew: '+ 新建办公室…', | ||
| officeNamePrompt: '办公室名字(a-z 0-9 . _ - ,1–32 字符):', | ||
| // A3 错误提示 | ||
| officeNameInvalid: (x) => `办公室名 "${x}" 非法。请用 1–32 个字符:小写字母、数字、. _ -(不能含 / 或 ..)。`, | ||
| officeNameReserved: '"default" 是保留名。要用默认办公室,直接 `falinks` / `falinks up`(不要加 --office)。', | ||
| officeNotRunning: (name) => `办公室 "${name}" 在本目录没有运行。启动它:\`falinks up --office ${name}\`。`, | ||
| officeConfigNotFound: (name) => `本目录没有办公室 "${name}"(找不到 .falinks/${name}.config.json)。创建:\`falinks up --office ${name}\`。`, | ||
| // 补充(非附录,实现需要):状态行 + console 连错办公室校验 | ||
| officeOpening: (office) => `连接办公室「${office}」…`, | ||
| officeStarting: (office) => `启动办公室「${office}」…`, | ||
| officeMismatch: (want, got) => `办公室不匹配:你要连「${want}」,但该实例是「${got}」。用 --office ${got} 或到对应办公室。`, | ||
| // —— cli.ts —— | ||
@@ -170,3 +190,5 @@ exitUpdateHint: (cmd) => `已退出。更新命令:${cmd}`, | ||
| '直接运行: falinks (首次自动生成配置并启动)\n' + | ||
| '办公室简写:falinks <名字> (= falinks up --office <名字>,起/建具名办公室)\n' + | ||
| '子命令: falinks init | doctor | lang | up [config] | say <agent> <msg> | broadcast <msg> | roster | log', | ||
| cliUnknownHint: '名字非法或未知命令。', | ||
| // —— setup/app.tsx —— | ||
@@ -192,2 +214,4 @@ setupUpdateFound: (latest, current) => `🆕 发现新版 ${latest}(当前 v${current})`, | ||
| incomingMsg: (from, body) => `【来自 ${from}】${body}\n(回复请调用 sendmsg(to="${from}", message="..."))`, | ||
| /** 收件箱合并投递:≥2 条排队消息一并送达时的头部提示(单条不走此分支)。 */ | ||
| inboxBatchHeader: (n) => `你有 ${n} 条新消息(已合并),请结合最新情况一并处理、不要只回最早那条:`, | ||
| // —— index.ts:注入员工的协作规则 + 启动日志 —— | ||
@@ -254,2 +278,12 @@ houseRules: '【falinks 协作规则】你是办公室里的 AI 员工,通过 falinks 的 MCP 工具协作。' + | ||
| tplResearchEditor: '审校,审查并润色 writer 的产出', | ||
| // —— 预设「组长+助理组」assisted + 助理工作法(ux 终稿,见设计稿附录 A)—— | ||
| tplAssistedName: '组长 + 助理组(调研员+资料梳理+草拟汇总)', | ||
| tplAssistedLead: '组长,统筹决策:拆解任务、给助理派活、决策与验收,少动手', | ||
| tplAssistedResearcher: '调研员,调研与事实查证:读代码/查资料/跑命令,产出结构化发现', | ||
| tplAssistedCurator: '资料梳理,把调研产出整理归纳成结构化、可用的资料/清单', | ||
| tplAssistedDrafter: '草拟汇总,据组长意图起草文档/汇总,产出初稿交组长定稿', | ||
| assistantRules: '【助理工作法】你是组长(lead)的助理。职责:执行组长交办的具体活儿——调研、读代码/查资料、跑命令、整理与起草、汇总产出——把结论与成果交回组长。' + | ||
| '你不做决策:不定方案/架构/优先级,不给其他成员派活,不替组长回答 boss 的决策问题。遇到需要拍板的岔路口,把「可选项 + 各自利弊 + 你的建议」整理清楚发给组长,由组长定夺。' + | ||
| '需要澄清找组长,不要直接找 boss 要决策。干完向组长汇报并 idle。', | ||
| coordinatorAssistAddendum: '【有助理时】本团队配有助理(assistant):把动手的体力活(调研、读取查证、资料梳理、起草、汇总)尽量拆解并行分给助理,你专注决策、设计、任务拆解、验收与对 boss 沟通,别自己埋头干。助理只执行不决策,他们汇报后由你判断定夺。', | ||
| // —— todolist 消息模板(下发/巡查以 boss 名义、自包含;汇总以 falinks 名义入流水)—— | ||
@@ -256,0 +290,0 @@ todoDispatchMsg: (seq, pos, total, body, isResend) => `【任务 #${seq}·第 ${pos}/${total} 条】${isResend ? '(重发)' : ''}${body}\n` + |
+54
-56
@@ -22,2 +22,3 @@ import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, realpathSync, rmSync } from 'node:fs'; | ||
| import { bootstrapForRole } from './templates.js'; | ||
| import { composeBootstrap } from './core/bootstrap.js'; | ||
| import { loadMessageLog, appendMessageLog, clearMessageLog, MESSAGE_LOG_CAP } from './message-log.js'; | ||
@@ -30,4 +31,5 @@ import { appendDiag, clearDiag, loadDiag } from './diag.js'; | ||
| import { loadLeadState, saveLeadState, clearLeadState } from './leadstate-store.js'; | ||
| import { DEFAULT_OFFICE } from './core/office.js'; | ||
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | ||
| export async function up(configPath) { | ||
| export async function up(configPath, office = DEFAULT_OFFICE) { | ||
| const cfg = parseConfig(JSON.parse(readFileSync(configPath, 'utf8'))); | ||
@@ -49,6 +51,6 @@ const driver = new ITerm2Driver(); | ||
| const lastDeliverAt = new Map(); | ||
| // 注入失败 = 消息已出队却没进 pane(永久丢失,发件人却以为发出去了)→ 落盘诊断,让其可见。 | ||
| const baseDeliverer = makeDeliverer(driver, (agent, msg, err) => { | ||
| // 注入失败 = 这批消息已出队却没进 pane(永久丢失,发件人却以为发出去了)→ 落盘诊断,让其可见。 | ||
| const baseDeliverer = makeDeliverer(driver, (agent, msgs, err) => { | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'inject-fail', to: agent.name, error: String(err?.message ?? err), msgId: msg.id, ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'inject-fail', to: agent.name, error: String(err?.message ?? err), msgId: msgs.map((m) => m.id).join(','), ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -58,5 +60,5 @@ catch { /* 诊断落盘失败不致命 */ } | ||
| const deliverer = { | ||
| deliver(agent, msg) { | ||
| deliver(agent, msgs) { | ||
| lastDeliverAt.set(agent.name, Date.now()); | ||
| baseDeliverer.deliver(agent, msg); | ||
| baseDeliverer.deliver(agent, msgs); | ||
| }, | ||
@@ -91,3 +93,3 @@ }; | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'agent-unresponsive', name, rule, ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'agent-unresponsive', name, rule, ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -101,3 +103,3 @@ catch { /* 诊断落盘失败不致命 */ } | ||
| onLog: (msg) => { try { | ||
| appendMessageLog(launchCwd, msg); | ||
| appendMessageLog(launchCwd, msg, runtimeDir(), office); | ||
| } | ||
@@ -107,3 +109,3 @@ catch { /* 持久化失败不致命 */ } }, | ||
| onDrop: (d) => { try { | ||
| appendDiag(launchCwd, { kind: 'guard-drop', from: d.from, to: d.to, reason: d.reason, thread: d.thread, ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'guard-drop', from: d.from, to: d.to, reason: d.reason, thread: d.thread, ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -113,3 +115,3 @@ catch { /* 诊断落盘失败不致命 */ } }, | ||
| router.addVirtual('boss'); | ||
| const seeded = loadMessageLog(launchCwd, historyCap); | ||
| const seeded = loadMessageLog(launchCwd, historyCap, runtimeDir(), office); | ||
| router.seedLog(seeded); // 恢复历史消息流水 | ||
@@ -123,3 +125,3 @@ // genId 形如 m${n};新会话 n 从 0 起会与持久化恢复的旧消息(也 m${k})撞号 → 控制台同 key 警告/漏渲染。 | ||
| } | ||
| const store = loadStore(launchCwd); | ||
| const store = loadStore(launchCwd, runtimeDir(), office); | ||
| pruneToAgents(store, cfg.agents.map((a) => a.name)); | ||
@@ -156,3 +158,3 @@ // todolist 引擎:回调全在此拼装(模板/落盘),引擎纯逻辑。下发/巡查以 boss 名义(lead 回 boss 无害); | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'todo-workers-timeout', ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'todo-workers-timeout', ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -164,3 +166,3 @@ catch { /* 诊断落盘失败不致命 */ } | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'todo-send-failing', ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'todo-send-failing', ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -175,3 +177,3 @@ catch { /* 诊断落盘失败不致命 */ } | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'todo-stalled', seq: task.seq, n, ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'todo-stalled', seq: task.seq, n, ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -201,3 +203,3 @@ catch { /* 诊断落盘失败不致命 */ } | ||
| return; | ||
| if (!loadLeadState(launchCwd)) { // 无文档:重置会致失忆,跳过 + 边沿提醒 | ||
| if (!loadLeadState(launchCwd, runtimeDir(), office)) { // 无文档:重置会致失忆,跳过 + 边沿提醒 | ||
| router.send('falinks', 'boss', t().leadResetSkippedNoDoc); | ||
@@ -209,3 +211,3 @@ return; | ||
| wipeLeadMemory: () => { | ||
| clearLeadState(launchCwd); | ||
| clearLeadState(launchCwd, runtimeDir(), office); | ||
| const lead = currentLead(); | ||
@@ -215,3 +217,3 @@ if (lead) { | ||
| if (spec) | ||
| bootstraps.set(lead, composeBootstrap(spec)); | ||
| bootstraps.set(lead, bootstrapWithState(spec)); | ||
| } | ||
@@ -222,6 +224,6 @@ }, | ||
| persist: (st) => { try { | ||
| saveTodo(launchCwd, st); | ||
| saveTodo(launchCwd, st, runtimeDir(), office); | ||
| } | ||
| catch { /* 落盘失败不致命,内存继续 */ } }, | ||
| }, loadTodo(launchCwd)); | ||
| }, loadTodo(launchCwd, runtimeDir(), office)); | ||
| const tmp = mkdtempSync(join(tmpdir(), 'falinks-')); | ||
@@ -233,11 +235,6 @@ let bus; | ||
| // launchInto 与运行时 /lead(onSetLead)共用,保证 /clear 重注入、换 lead 后内容一致。 | ||
| const composeBootstrap = (a) => { | ||
| let s = `${t().houseRules}\n${t().identityLine(a.name, a.role)}${a.bootstrap ?? ''}`; | ||
| if (a.lead) { | ||
| s += `\n${t().coordinatorRules}`; | ||
| const doc = cfg.todo?.leadReset.enabled ? loadLeadState(launchCwd) : ''; | ||
| if (doc) | ||
| s += `\n【项目状态(续接用,这是你上一段会话沉淀的记忆)】\n${doc}`; | ||
| } | ||
| return s; | ||
| // 运行期 bootstrap:纯组装(可单测的 composeBootstrap)+ lead 的「项目状态档」磁盘读取(IO 留在 caller,作为参数传入)。 | ||
| const bootstrapWithState = (a) => { | ||
| const leadStateDoc = a.lead && cfg.todo?.leadReset.enabled ? (loadLeadState(launchCwd, runtimeDir(), office) || undefined) : undefined; | ||
| return composeBootstrap(a, cfg, { leadStateDoc }); | ||
| }; | ||
@@ -247,3 +244,3 @@ async function launchInto(anchor, dir, a) { | ||
| writeFileSync(cfgPath, JSON.stringify(mcpConfigFor(a.name, bus.port))); | ||
| const fullBootstrap = composeBootstrap(a); | ||
| const fullBootstrap = bootstrapWithState(a); | ||
| bootstraps.set(a.name, fullBootstrap); // 供 /clear 后重注入 | ||
@@ -292,3 +289,3 @@ // codex 首启把 bootstrap 作为命令行参数——长 bootstrap 内联会被 write-text 截断,故写临时文件、命令里 $(cat) 读取。 | ||
| else | ||
| router.addAgent(a.name, a.role, a.lead); | ||
| router.addAgent(a.name, a.role, a.lead, a.assistant); | ||
| await driver.setName(sid, a.name).catch(() => { }); // pane 标题=员工名 | ||
@@ -359,3 +356,3 @@ if (themed) | ||
| delete store.agents[a.name]; | ||
| saveStore(launchCwd, store); | ||
| saveStore(launchCwd, store, runtimeDir(), office); | ||
| } | ||
@@ -365,3 +362,3 @@ catch (e) { | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'bootstrap-fail', name: a.name, error: String(e?.message ?? e), ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'bootstrap-fail', name: a.name, error: String(e?.message ?? e), ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -373,7 +370,8 @@ catch { /* 诊断落盘失败不致命 */ } | ||
| } | ||
| // 防双开:同目录的 sessions/messages 是共享的,双开必然互相污染。 | ||
| const existing = readInstance(launchCwd); | ||
| // 防双开:同 (目录,办公室) 的 sessions/messages 是共享的,双开必然互相污染。不同办公室同目录放行(各自独立端口与实例)。 | ||
| const existing = readInstance(launchCwd, undefined, office); | ||
| if (existing) { | ||
| const p = await probeBus(existing.port); | ||
| if (p.state === 'alive' && p.info.cwd === launchCwd) { | ||
| const sameOffice = p.state === 'alive' && (p.info.office ?? DEFAULT_OFFICE) === office; | ||
| if (p.state === 'alive' && p.info.cwd === launchCwd && sameOffice) { | ||
| console.error(t().instanceAlreadyRunning(existing.port)); | ||
@@ -383,6 +381,6 @@ process.exit(1); | ||
| if (p.state === 'unknown') { | ||
| console.error(t().instanceMaybeRunning(existing.port, instancePath(launchCwd))); | ||
| console.error(t().instanceMaybeRunning(existing.port, instancePath(launchCwd, undefined, office))); | ||
| process.exit(1); | ||
| } | ||
| removeInstanceFile(instancePath(launchCwd)); // 确认死了/张冠李戴:清掉尸体 | ||
| removeInstanceFile(instancePath(launchCwd, undefined, office)); // 确认死了/张冠李戴/办公室不符:清掉尸体 | ||
| } | ||
@@ -409,3 +407,3 @@ // 单员工清空序列:/clear → 等待 → 重注入 bootstrap(恢复身份+重新 register)。onClear 与 todo resetWorkers 共用,避免逻辑漂移。 | ||
| delete store.agents[nm]; | ||
| saveStore(launchCwd, store); | ||
| saveStore(launchCwd, store, runtimeDir(), office); | ||
| return true; | ||
@@ -429,3 +427,3 @@ } | ||
| delete store.agents[name]; | ||
| saveStore(launchCwd, store); | ||
| saveStore(launchCwd, store, runtimeDir(), office); | ||
| } | ||
@@ -519,3 +517,3 @@ router.markLaunching(name); // inbox 保留,重新 register 后照常投递 | ||
| if (lead && targets.includes(lead)) | ||
| clearLeadState(launchCwd); | ||
| clearLeadState(launchCwd, runtimeDir(), office); | ||
| // /clear 改为「重启换新 session id」:重启 falinks 时能 --resume 到 clear 后的会话(而非 clear 前的旧会话)。 | ||
@@ -533,7 +531,7 @@ // 重启关旧 pane+切新 pane,不可并行(锚点竞态),故串行;比注入 /clear 慢,但可恢复。 | ||
| try { | ||
| clearMessageLog(launchCwd); | ||
| clearMessageLog(launchCwd, runtimeDir(), office); | ||
| } | ||
| catch { /* 持久化清理失败不致命 */ } | ||
| try { | ||
| clearDiag(launchCwd); | ||
| clearDiag(launchCwd, runtimeDir(), office); | ||
| } | ||
@@ -590,3 +588,3 @@ catch { /* 同上 */ } | ||
| if (ag.name === name || ag.name === cur) | ||
| bootstraps.set(ag.name, composeBootstrap(ag)); | ||
| bootstraps.set(ag.name, bootstrapWithState(ag)); | ||
| } | ||
@@ -631,3 +629,3 @@ // 当场生效(不必 /clear):新 lead 收到工作法、旧 lead 收到卸任(走正常投递,忙则排队) | ||
| return { ok: false, error: t().leadMemoryOff }; | ||
| saveLeadState(launchCwd, content); | ||
| saveLeadState(launchCwd, content, runtimeDir(), office); | ||
| const lead = currentLead(); | ||
@@ -637,3 +635,3 @@ if (lead) { | ||
| if (spec) | ||
| bootstraps.set(lead, composeBootstrap(spec)); | ||
| bootstraps.set(lead, bootstrapWithState(spec)); | ||
| } | ||
@@ -644,3 +642,3 @@ return { ok: true }; | ||
| getDiag: () => { try { | ||
| return loadDiag(launchCwd); | ||
| return loadDiag(launchCwd, undefined, runtimeDir(), office); | ||
| } | ||
@@ -669,3 +667,3 @@ catch { | ||
| }, cfg.busPort ?? 0, { | ||
| identity: { cwd: launchCwd, startedAt: Date.now() }, | ||
| identity: { cwd: launchCwd, startedAt: Date.now(), office }, | ||
| onPortFallback: (wanted, got) => { portWarning = t().portFallback(wanted, got); }, | ||
@@ -675,3 +673,3 @@ }); | ||
| rmSync(runtimePath(), { force: true }); // 旧版全局 runtime.json:一次性迁移清理 | ||
| const inst = { port: bus.port, pid: process.pid, cwd: launchCwd, startedAt: Date.now() }; | ||
| const inst = { port: bus.port, pid: process.pid, cwd: launchCwd, startedAt: Date.now(), office }; | ||
| // 安全前提:writeInstance 必须在 pane 创建循环之前执行。 | ||
@@ -682,5 +680,5 @@ // 若败者在 exit(1) 时尚未建任何 pane,可安全退出而不留残局。 | ||
| // wx 失败=毫秒级竞态里别人先写了:再探一次,确认死亡才覆盖,unknown 保守拒绝。 | ||
| const race = readInstance(launchCwd); | ||
| const race = readInstance(launchCwd, undefined, office); | ||
| const p = race ? await probeBus(race.port) : null; | ||
| if (p?.state === 'alive' && p.info.cwd === launchCwd) { | ||
| if (p?.state === 'alive' && p.info.cwd === launchCwd && (p.info.office ?? DEFAULT_OFFICE) === office) { | ||
| console.error(t().instanceAlreadyRunningShort(race.port)); | ||
@@ -690,9 +688,9 @@ process.exit(1); | ||
| if (p?.state === 'unknown') { | ||
| console.error(t().instanceMaybeRunning(race.port, instancePath(launchCwd))); | ||
| console.error(t().instanceMaybeRunning(race.port, instancePath(launchCwd, undefined, office))); | ||
| process.exit(1); | ||
| } | ||
| // p === null(档案读不到)、p.state === 'dead'、或 alive 但 cwd 不符(张冠李戴):确认死亡,安全覆盖。 | ||
| // p === null(档案读不到)、p.state === 'dead'、或 alive 但 cwd/office 不符(张冠李戴):确认死亡,安全覆盖。 | ||
| writeInstance(inst, undefined, { force: true }); | ||
| } | ||
| process.on('exit', () => removeInstanceIfOwner(launchCwd, process.pid)); | ||
| process.on('exit', () => removeInstanceIfOwner(launchCwd, process.pid, undefined, office)); | ||
| process.on('SIGTERM', () => process.exit(0)); // 默认 SIGTERM 不走 exit 钩子,显式转一下 | ||
@@ -765,3 +763,3 @@ process.on('SIGINT', () => process.exit(0)); // 非控制台模式 Ctrl-C(控制台模式 raw mode 自行处理) | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'poll-frozen', streak: pollFailStreak, error: String(e?.message ?? e), ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'poll-frozen', streak: pollFailStreak, error: String(e?.message ?? e), ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -835,3 +833,3 @@ catch { /* 诊断落盘失败不致命 */ } | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'poll', name, status: a.status, proc, scrape: scrapeBusy, paneBusy, grace, streak, action, bottom, ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'poll', name, status: a.status, proc, scrape: scrapeBusy, paneBusy, grace, streak, action, bottom, ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -849,3 +847,3 @@ catch { /* ignore */ } | ||
| try { | ||
| appendDiag(launchCwd, { kind: 'auto-idle', name, sinceDeliverMs: since, ts: Date.now() }); | ||
| appendDiag(launchCwd, { kind: 'auto-idle', name, sinceDeliverMs: since, ts: Date.now() }, runtimeDir(), office); | ||
| } | ||
@@ -852,0 +850,0 @@ catch { /* 诊断落盘失败不致命 */ } |
+11
-10
| import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'; | ||
| import { join, dirname } from 'node:path'; | ||
| import { runtimeDir, projectKey } from './runtime.js'; | ||
| /** 每个项目一份 lead 项目状态档:~/.falinks/leadstate/<projectKey>.md。root 可注入便于测试。 */ | ||
| export function leadStatePath(launchCwd, root = runtimeDir()) { | ||
| return join(root, 'leadstate', `${projectKey(launchCwd)}.md`); | ||
| import { runtimeDir } from './runtime.js'; | ||
| import { DEFAULT_OFFICE, keyFor } from './core/office.js'; | ||
| /** 每个 (项目, 办公室) 一份 lead 项目状态档:~/.falinks/leadstate/<key>.md。root 可注入便于测试。 */ | ||
| export function leadStatePath(launchCwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| return join(root, 'leadstate', `${keyFor(launchCwd, office)}.md`); | ||
| } | ||
| /** 读档;不存在/损坏返回空串。 */ | ||
| export function loadLeadState(launchCwd, root = runtimeDir()) { | ||
| const p = leadStatePath(launchCwd, root); | ||
| export function loadLeadState(launchCwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = leadStatePath(launchCwd, root, office); | ||
| if (!existsSync(p)) | ||
@@ -20,4 +21,4 @@ return ''; | ||
| } | ||
| export function saveLeadState(launchCwd, content, root = runtimeDir()) { | ||
| const p = leadStatePath(launchCwd, root); | ||
| export function saveLeadState(launchCwd, content, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = leadStatePath(launchCwd, root, office); | ||
| mkdirSync(dirname(p), { recursive: true }); | ||
@@ -27,7 +28,7 @@ writeFileSync(p, content); | ||
| /** 删档(白纸语义);不存在静默。 */ | ||
| export function clearLeadState(launchCwd, root = runtimeDir()) { | ||
| export function clearLeadState(launchCwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| try { | ||
| rmSync(leadStatePath(launchCwd, root)); | ||
| rmSync(leadStatePath(launchCwd, root, office)); | ||
| } | ||
| catch { /* 不存在/删除失败不致命 */ } | ||
| } |
+12
-10
@@ -5,12 +5,14 @@ import { appendFileSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'; | ||
| import { runtimeDir } from './runtime.js'; | ||
| import { DEFAULT_OFFICE, officeSuffix } from './core/office.js'; | ||
| /** 消息流水的滚动上限:内存与磁盘都只保留最近这么多条(可被 config.historyCap 覆盖)。 */ | ||
| export const MESSAGE_LOG_CAP = 5000; | ||
| /** 每个项目目录一份消息流水:~/.falinks/messages/<cwd 的 sha1 前16位>.jsonl。root 可注入便于测试。 */ | ||
| export function messageLogPath(cwd, root = runtimeDir()) { | ||
| const key = createHash('sha1').update(cwd).digest('hex').slice(0, 16); | ||
| /** 每个 (项目目录, 办公室) 一份消息流水:~/.falinks/messages/<sha1(cwd)前16位[--office]>.jsonl。root 可注入便于测试。 | ||
| * 注:base 沿用历史的 sha1(cwd)(未 realpath),与 keyFor 的 projectKey 基准不同——为默认办公室逐字节兼容而保留;office 后缀规则与全局一致。 */ | ||
| export function messageLogPath(cwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const key = createHash('sha1').update(cwd).digest('hex').slice(0, 16) + officeSuffix(office); | ||
| return join(root, 'messages', `${key}.jsonl`); | ||
| } | ||
| /** 追加一条消息(O(1) 追加,不重写整文件)。 */ | ||
| export function appendMessageLog(cwd, msg, root = runtimeDir()) { | ||
| const p = messageLogPath(cwd, root); | ||
| export function appendMessageLog(cwd, msg, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = messageLogPath(cwd, root, office); | ||
| mkdirSync(dirname(p), { recursive: true }); | ||
@@ -20,4 +22,4 @@ appendFileSync(p, JSON.stringify(msg) + '\n'); | ||
| /** 读最近 cap 条;坏行跳过;文件超过 2×cap 行时顺手压缩重写(避免无限增长)。 */ | ||
| export function loadMessageLog(cwd, cap = MESSAGE_LOG_CAP, root = runtimeDir()) { | ||
| const p = messageLogPath(cwd, root); | ||
| export function loadMessageLog(cwd, cap = MESSAGE_LOG_CAP, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = messageLogPath(cwd, root, office); | ||
| if (!existsSync(p)) | ||
@@ -49,5 +51,5 @@ return []; | ||
| } | ||
| /** 清空某项目的消息流水(boss 历史对话):删除持久化文件。不存在为 no-op。 */ | ||
| export function clearMessageLog(cwd, root = runtimeDir()) { | ||
| rmSync(messageLogPath(cwd, root), { force: true }); | ||
| /** 清空某 (项目, 办公室) 的消息流水(boss 历史对话):删除持久化文件。不存在为 no-op。 */ | ||
| export function clearMessageLog(cwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| rmSync(messageLogPath(cwd, root, office), { force: true }); | ||
| } |
| import { createReadStream, existsSync, statSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { DEFAULT_OFFICE } from '../core/office.js'; | ||
| const MAX_LOG = 200; | ||
@@ -14,2 +15,3 @@ /** 聚合 roster + 最近 200 条消息(时间升序) + 待答问题,供前端单次轮询。 */ | ||
| lead: !!a.lead, | ||
| assistant: !!a.assistant, | ||
| unresponsive: !!a.unresponsive, | ||
@@ -23,3 +25,3 @@ // 队列深度 = 该 agent 仍在 inbox 排队、尚未投出的消息数,用于表现繁忙程度。 | ||
| const questions = deps.questions.list().map((q) => ({ id: q.id, from: q.from, question: q.question, options: q.options, ts: q.ts })); | ||
| return { ts: now(), roster, log, questions }; | ||
| return { ts: now(), office: deps.office ?? DEFAULT_OFFICE, roster, log, questions }; | ||
| } | ||
@@ -26,0 +28,0 @@ const CONTENT_TYPES = { |
@@ -234,3 +234,4 @@ { | ||
| "rest": 0, | ||
| "type": 1 | ||
| "type": 1, | ||
| "sleep": 2 | ||
| }, | ||
@@ -245,3 +246,3 @@ "order": [ | ||
| "leadSuggest": "p3_glasses", | ||
| "note": "统一 16-骨架坐姿半身(头+肩); 全员同头/眼骨架, 只换发色+衫色, lead 加眼镜. 眼固定行→露出锚点齐. 置于显示器上沿之后(下层)." | ||
| "note": "统一 16-骨架坐姿半身(头+肩); 全员同头/眼骨架, 只换发色+衫色, lead 加眼镜. 眼固定行→露出锚点齐. 置于显示器上沿之后(下层). sleep 行=闭眼(横线/镜片后), 打盹时由 office.js 切, 点头 bob 走 CSS." | ||
| }, | ||
@@ -248,0 +249,0 @@ "floor": { |
@@ -36,3 +36,5 @@ <!DOCTYPE html> | ||
| <ul class="ov-legend" id="ov-legend"></ul> | ||
| <div id="panel-hint">点击工位查看详情</div> | ||
| <div class="ov-chat-title" id="ov-chat-title">团队对话</div> | ||
| <ul class="ov-chat" id="ov-chat"></ul> | ||
| <div id="panel-hint">点击工位查看成员详情</div> | ||
| </div> | ||
@@ -39,0 +41,0 @@ <div id="panel-body" class="hidden"> |
@@ -163,2 +163,13 @@ /* falinks 像素办公室 — 暖色 cozy 俯视微透视。 | ||
| } | ||
| .station .assist { /* 助理角标:头顶冷青单层 V(chevron),区别于 lead 金冠 */ | ||
| position: absolute; | ||
| left: 50%; transform: translateX(-50%); | ||
| top: calc(-3 * var(--s) * 1px); | ||
| width: calc(8 * var(--s) * 1px); | ||
| height: calc(4.5 * var(--s) * 1px); | ||
| z-index: 5; pointer-events: none; | ||
| background: var(--code); | ||
| clip-path: polygon(0% 0%, 22% 0%, 50% 52%, 78% 0%, 100% 0%, 50% 100%); | ||
| filter: drop-shadow(0 1px 0 rgba(0,0,0,.7)) drop-shadow(0 0 1px rgba(0,0,0,.7)); | ||
| } | ||
| .station .name { /* 名字上桌:落在 .ws 桌前下部木立面, 收进 station 盒(不再 bottom 负值外溢→消跨排遮挡) */ | ||
@@ -343,5 +354,8 @@ position: absolute; left: 50%; transform: translateX(-50%); | ||
| } | ||
| .station.dozing .person { animation: doze-nod var(--doze-dur, 16s) steps(1, end) var(--doze-delay, 0s) infinite; } | ||
| .station.dozing .person { /* 点头(transform) + 闭眼(切 sleep 帧), 同 dur/delay 同相位 */ | ||
| animation: doze-nod var(--doze-dur, 16s) steps(1, end) var(--doze-delay, 0s) infinite, | ||
| doze-eyes var(--doze-dur, 16s) steps(1, end) var(--doze-delay, 0s) infinite; | ||
| } | ||
| .station.dozing .zzz { animation: doze-zzz var(--doze-dur, 16s) ease-in-out var(--doze-delay, 0s) infinite; } | ||
| /* 气泡优先: 说话(=醒着)挂起打盹 */ | ||
| /* 气泡优先: 说话(=醒着)挂起打盹 → 取消全部打盹动画 → 回 rest 睁眼 */ | ||
| .station.talking .person { animation: none; } | ||
@@ -354,2 +368,8 @@ .station.talking .zzz { animation: none; opacity: 0; } | ||
| } | ||
| /* 打盹段闭眼: 离散切 --by 到 sleep 帧(frames.sleep=2 × ph=15 = 30); 与点头同相位. | ||
| 未注册自定义属性→离散动画(中点跳变), 清醒段恒 rest(0)、打盹段恒 sleep(30), 无中间帧糊. */ | ||
| @keyframes doze-eyes { | ||
| 0%, 71% { --by: 0; } | ||
| 72%, 100% { --by: 30; } | ||
| } | ||
| @keyframes doze-zzz { /* 打盹段冒 2 次 Zzz, 飘升淡出 ~-10px */ | ||
@@ -363,4 +383,4 @@ 0%, 73% { opacity: 0; transform: translateX(-50%) translateY(0); } | ||
| } | ||
| @media (prefers-reduced-motion: reduce) { /* 去点头; Zzz 不显 */ | ||
| .station.dozing .person { animation: none; } | ||
| @media (prefers-reduced-motion: reduce) { /* 去点头/Zzz/眨眼循环; 打盹者静态保持 sleep 闭眼帧(安静睡着), 说话者除外(睁眼) */ | ||
| .station.dozing:not(.talking) .person { animation: none; --by: 30 !important; } | ||
| .station.dozing .zzz { animation: none; opacity: 0; } | ||
@@ -455,17 +475,21 @@ } | ||
| /* 猫狗 idle 微动(纯 CSS transform; 平时静止, 偶尔 ~0.5s 微动; ≤2px 整数 step; 无 rotate; 错峰) */ | ||
| @keyframes pet-hop { /* 猫: 小跳 */ | ||
| 0%, 88%, 100% { transform: translateY(0); } | ||
| 92% { transform: translateY(-2px); } | ||
| 96% { transform: translateY(-1px); } | ||
| /* 猫狗自由游走: #pets 持久层; wrap=位置+朝向翻转, .inner=步态/原地动作(CSS), 二者解耦 */ | ||
| #pets { position: absolute; inset: 0; pointer-events: none; } | ||
| .pet { position: absolute; z-index: 3; transform: scaleX(var(--face, 1)); transform-origin: center bottom; will-change: left, top; } | ||
| .pet .inner { transform-origin: center bottom; } /* 脚底锚定: squash 朝上压 */ | ||
| .pet.moving .inner { animation: pet-gait .5s steps(1, end) infinite; } /* 移动: 8fps 步态 */ | ||
| .pet.acting .inner { animation: pet-act .85s steps(1, end); } /* 停顿偶发: 伸懒腰/坐 bob */ | ||
| @keyframes pet-gait { /* 上下 1-2px + 轻 squash, 整数像素 */ | ||
| 0% { transform: translateY(0) scaleY(1); } | ||
| 25% { transform: translateY(-2px) scaleY(1.03); } | ||
| 50% { transform: translateY(0) scaleY(1); } | ||
| 75% { transform: translateY(-1px) scaleY(.97); } | ||
| } | ||
| @keyframes pet-shift { /* 狗: 小挪 */ | ||
| 0%, 90%, 100% { transform: translateX(0); } | ||
| 94% { transform: translateX(2px); } | ||
| 97% { transform: translateX(1px); } | ||
| @keyframes pet-act { /* 原地小动作: 克制, 不喧宾夺主 */ | ||
| 0%, 100% { transform: translateY(0) scaleY(1) scaleX(1); } | ||
| 35% { transform: translateY(-1px) scaleY(1.06) scaleX(.98); } | ||
| 70% { transform: translateY(0) scaleY(.96) scaleX(1.02); } | ||
| } | ||
| .decor-cat { animation: pet-hop 6.5s steps(1, end) infinite; } | ||
| .decor-corgi { animation: pet-shift 8.3s steps(1, end) infinite; animation-delay: 2.5s; } | ||
| @media (prefers-reduced-motion: reduce) { | ||
| .decor-cat, .decor-corgi { animation: none; } | ||
| @media (prefers-reduced-motion: reduce) { /* 停游走(JS)+ 去步态/动作 → 静态单帧 */ | ||
| .pet .inner { animation: none !important; } | ||
| } | ||
@@ -479,5 +503,11 @@ | ||
| padding: 16px; | ||
| overflow-y: auto; | ||
| overflow: hidden; | ||
| display: flex; | ||
| flex-direction: column; | ||
| min-height: 0; | ||
| } | ||
| #panel-hint { color: #6e6048; font-size: 12px; text-align: center; margin-top: 22px; } | ||
| /* 概览/详情各自填满面板高度,内部消息流独立滚动(面板本身不再整体滚动) */ | ||
| #panel-overview, #panel-body { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } | ||
| #panel-overview.hidden, #panel-body.hidden { display: none; } | ||
| #panel-hint { color: #6e6048; font-size: 12px; text-align: center; margin-top: 14px; flex: none; } | ||
| #panel.empty #panel-hint { display: block; } | ||
@@ -487,5 +517,6 @@ #panel:not(.empty) #panel-hint { display: none; } | ||
| /* ---- 未选中态:团队概览 + 状态图例 ---- */ | ||
| .ov-title, .ov-legend-title { | ||
| .ov-title, .ov-legend-title, .ov-chat-title { | ||
| font-size: 12px; color: #8a7a5e; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 10px; | ||
| } | ||
| .ov-chat-title { margin-top: 18px; flex: none; } | ||
| .ov-legend-title { margin-top: 6px; } | ||
@@ -536,12 +567,15 @@ .ov-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 5px; margin-bottom: 20px; font-size: 12px; color: #c8b896; } | ||
| #panel-q .q-opt { color: #d8c8a8; padding-left: 8px; } | ||
| #panel-msgs-label { font-size: 12px; color: #8a7a5e; margin-bottom: 8px; text-transform: uppercase; letter-spacing: .5px; } | ||
| #panel-msgs { list-style: none; display: flex; flex-direction: column; gap: 8px; } | ||
| #panel-msgs li { | ||
| #panel-msgs-label { font-size: 12px; color: #8a7a5e; margin-bottom: 8px; text-transform: uppercase; letter-spacing: .5px; flex: none; } | ||
| #panel-msgs, .ov-chat { | ||
| list-style: none; display: flex; flex-direction: column; gap: 8px; | ||
| flex: 1 1 auto; min-height: 0; overflow-y: auto; | ||
| } | ||
| #panel-msgs li, .ov-chat li { | ||
| background: #242019; border-radius: 6px; padding: 7px 9px; font-size: 12px; line-height: 1.4; | ||
| } | ||
| #panel-msgs .m-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 3px; } | ||
| #panel-msgs .m-route { color: var(--warmGlow); font-weight: 600; } | ||
| #panel-msgs .m-time { color: #6e6048; font-variant-numeric: tabular-nums; } | ||
| #panel-msgs .m-body { color: #d8ccb4; word-break: break-word; } | ||
| #panel-msgs .m-empty { color: #6e6048; font-style: italic; } | ||
| .m-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 3px; } | ||
| .m-route { color: var(--warmGlow); font-weight: 600; } | ||
| .m-time { color: #6e6048; font-variant-numeric: tabular-nums; } | ||
| .m-body { color: #d8ccb4; word-break: break-word; } | ||
| .m-empty { color: #6e6048; font-style: italic; } | ||
@@ -548,0 +582,0 @@ /* ---- 断连横幅 ---- */ |
@@ -17,10 +17,12 @@ /* falinks 像素办公室 — 渲染与轮询(只读)。 | ||
| const T = { | ||
| zh: { subtitle: '像素办公室', empty: '暂无成员', hint: '点击工位查看详情', disconnected: '连接已断开,正在重试…', | ||
| zh: { subtitle: '像素办公室', empty: '暂无成员', hint: '点击工位查看成员详情', disconnected: '连接已断开,正在重试…', | ||
| role: '角色', messages: '相关消息', waiting: '待回答', noMessages: '暂无相关消息', | ||
| overview: '团队概览', legend: '状态图例', lastDone: '上次完成', resting: '离座', boss: '坐镇', | ||
| chat: '团队对话', noChat: '暂无对话', | ||
| st: { idle:'空闲', busy:'忙碌', waiting:'等待', stuck:'卡住', done:'完成', offline:'离线' }, | ||
| stat: { idle:'在岗', busy:'忙', waiting:'等待', stuck:'卡住', offline:'离线' } }, | ||
| en: { subtitle: 'Pixel Office', empty: 'No members', hint: 'Click a desk for details', disconnected: 'Disconnected — retrying…', | ||
| en: { subtitle: 'Pixel Office', empty: 'No members', hint: 'Click a desk for member details', disconnected: 'Disconnected — retrying…', | ||
| role: 'Role', messages: 'Related messages', waiting: 'Awaiting answer', noMessages: 'No related messages', | ||
| overview: 'Team', legend: 'Legend', lastDone: 'Last done', resting: 'Away', boss: 'Overseeing', | ||
| chat: 'Team chat', noChat: 'No messages yet', | ||
| st: { idle:'Idle', busy:'Busy', waiting:'Waiting', stuck:'Stuck', done:'Done', offline:'Offline' }, | ||
@@ -36,2 +38,3 @@ stat: { idle:'Idle', busy:'Busy', waiting:'Wait', stuck:'Stuck', offline:'Offline' } }, | ||
| document.getElementById('ov-legend-title').textContent = T.legend; | ||
| document.getElementById('ov-chat-title').textContent = T.chat; | ||
| document.getElementById('panel-msgs-label').textContent = T.messages; | ||
@@ -55,2 +58,3 @@ document.getElementById('banner').textContent = T.disconnected; | ||
| const $ovLegend = document.getElementById('ov-legend'); | ||
| const $ovChat = document.getElementById('ov-chat'); | ||
@@ -276,5 +280,14 @@ // ---- 几何(与 SPRITE-SPEC 一致) ---- | ||
| add('sofa_red', aX + 37 * s, gB - 30 * s, 2); // 红沙发(右, 与灰留 8px 缝、底对齐) | ||
| add('cat', aX + 3 * s, gB - 13 * s, 3); // 猫狗趴毯前缘, 间隙 12px 不相交 | ||
| add('corgi', aX + 22 * s, gB - 11 * s, 3); | ||
| // 猫狗自由游走(独立 #pets 持久层): 活动区整块落在沙发底沿下方 → 构造性避沙发(整身 bbox 在区内即不碰任一沙发) | ||
| const sofaBottom = gB - 14 * s; // gray/red 沙发底沿(两者对齐) | ||
| const zoneBLeft = deskRight - sw('bench_sm') - sw('plant_tall') - 2 * s; // zoneB 绿植左缘(右边界避让) | ||
| petArea = { | ||
| minX: PAD + 1 * s, | ||
| minY: sofaBottom + 2, // 比沙发底低 2px → 整区在沙发之下 | ||
| maxX: Math.min(Math.round(0.5 * L.roomW), zoneBLeft - 1 * s), // 右不过中轴且避开 zoneB 绿植 | ||
| maxY: L.roomH - 1 * s, // 贴舞台底(留 4px); 区高恒 58 容 cat52/dog44 | ||
| }; | ||
| mountPets(); | ||
| // ── B 茶水间(底右角): counter 已删 / vending2 移作书架排; 绿植+小长椅成组填脚印(8px 相邻, 非孤件) ── | ||
@@ -303,2 +316,84 @@ // 长椅右缘对齐 desk 块右沿 → 整组落在 zone C(deskRight+CLEAR)左侧, 两区互不相撞 | ||
| // ---------- 猫狗自由游走(独立 #pets 层: rAF 推进 + 逐帧 clamp + 朝向翻转; reduced-motion 静止) ---------- | ||
| // 解耦(同气泡/打盹): 外层 wrap 走"位置+翻转", 内层 .inner 走"步态/原地动作"(CSS), 互不拖拽。 | ||
| // 避沙发: 活动区整块构造在沙发底沿之下 → 整身 bbox 在区内即不与任一沙发相交(无需逐帧判沙发)。 | ||
| // base: 基准精灵朝向(+1 朝右 / -1 朝左)。猫基准朝左(头/眼在左)、柯基朝右。有效 scaleX = face × base → 脸恒朝行进方向。 | ||
| const PET_DEFS = [{ key: 'cat', spd: [8, 12], base: -1 }, { key: 'corgi', spd: [10, 14], base: 1 }]; | ||
| const petState = {}; // key -> {wrap, inner, w, h, x, y, tx, ty, mode, until, spd, face, acting, actUntil, nextAct} | ||
| let petArea = null, petLayer = null, petRAFid = 0, petLastTs = 0; | ||
| const petMql = (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)')) || { matches: false }; | ||
| const prnd = (a, b) => a + Math.random() * (b - a); | ||
| function petClamp(p, x, y) { | ||
| return [Math.max(petArea.minX, Math.min(petArea.maxX - p.w, x)), | ||
| Math.max(petArea.minY, Math.min(petArea.maxY - p.h, y))]; | ||
| } | ||
| function petPickTarget(p) { // 区内随机目标(整区已在沙发下方, 无需判沙发) | ||
| p.tx = prnd(petArea.minX, petArea.maxX - p.w); | ||
| p.ty = prnd(petArea.minY, petArea.maxY - p.h); | ||
| } | ||
| function petApply(p) { | ||
| p.wrap.style.left = Math.round(p.x) + 'px'; | ||
| p.wrap.style.top = Math.round(p.y) + 'px'; | ||
| p.wrap.style.setProperty('--face', p.face * p.baseFace); // 有效翻转 = 行进方向 × 基准朝向 → 脸朝行进方向 | ||
| } | ||
| function petStep(p, dt, ts) { | ||
| if (petMql.matches) { // reduced-motion: 实时冻结(去游走/步态/原地动作), 静止当前点 | ||
| if (p.mode === 'move') { p.mode = 'pause'; p.until = 0; } | ||
| p.acting = false; p.wrap.classList.remove('moving', 'acting'); | ||
| petApply(p); return; | ||
| } | ||
| if (p.mode === 'move') { | ||
| const dx = p.tx - p.x, dy = p.ty - p.y, dist = Math.hypot(dx, dy), step = p.spd * dt; | ||
| if (dist <= step || dist < 0.5) { // 到点 → 停顿 | ||
| p.x = p.tx; p.y = p.ty; p.mode = 'pause'; p.until = ts + prnd(2000, 6000); | ||
| p.wrap.classList.remove('moving'); | ||
| } else { | ||
| const nx = p.x + dx / dist * step, ny = p.y + dy / dist * step; | ||
| if (Math.abs(dx) > 0.4) p.face = dx < 0 ? -1 : 1; // 按 dx 水平翻转面向 | ||
| [p.x, p.y] = petClamp(p, nx, ny); | ||
| } | ||
| } else { // pause: 偶发原地动作 + 到时再走 | ||
| if (!p.acting && ts >= p.nextAct) { p.acting = true; p.wrap.classList.add('acting'); p.actUntil = ts + 850; p.nextAct = ts + prnd(5000, 10000); } | ||
| if (p.acting && ts >= p.actUntil) { p.acting = false; p.wrap.classList.remove('acting'); } | ||
| if (ts >= p.until) { | ||
| petPickTarget(p); | ||
| if (Math.abs(p.tx - p.x) + Math.abs(p.ty - p.y) > 1) { p.mode = 'move'; p.acting = false; p.wrap.classList.remove('acting'); p.wrap.classList.add('moving'); } | ||
| else { p.until = ts + prnd(1500, 3000); } | ||
| } | ||
| } | ||
| petApply(p); | ||
| } | ||
| function petLoop(ts) { | ||
| const dt = petLastTs ? Math.min(0.05, (ts - petLastTs) / 1000) : 0; | ||
| petLastTs = ts; | ||
| for (const d of PET_DEFS) { const p = petState[d.key]; if (p) petStep(p, dt, ts); } | ||
| petRAFid = requestAnimationFrame(petLoop); | ||
| } | ||
| function mountPets() { | ||
| if (!petLayer) { petLayer = document.createElement('div'); petLayer.id = 'pets'; $decor.insertAdjacentElement('afterend', petLayer); } | ||
| let stagger = 0; | ||
| for (const d of PET_DEFS) { | ||
| const sp = S.atlas.sprites[d.key]; | ||
| let p = petState[d.key]; | ||
| if (!p) { | ||
| const wrap = document.createElement('div'); wrap.className = 'pet pet-' + d.key; | ||
| const inner = mkSpr('decor-' + d.key, imgs.atlas, sp); inner.classList.add('inner'); | ||
| inner.style.position = 'absolute'; inner.style.left = '0'; inner.style.top = '0'; | ||
| wrap.appendChild(inner); | ||
| p = { wrap, inner, w: sp[2] * SCALE, h: sp[3] * SCALE, spd: prnd(d.spd[0], d.spd[1]), baseFace: d.base, | ||
| x: 0, y: 0, tx: 0, ty: 0, mode: 'pause', until: 0, face: 1, acting: false, actUntil: 0, nextAct: 0 }; | ||
| p.x = petArea.minX + 10 * SCALE + stagger; p.y = petArea.maxY - p.h; // 初始落沙发前方(下沿), 错峰 | ||
| [p.x, p.y] = petClamp(p, p.x, p.y); | ||
| petState[d.key] = p; | ||
| } | ||
| p.wrap.style.width = p.w + 'px'; p.wrap.style.height = p.h + 'px'; | ||
| petLayer.appendChild(p.wrap); | ||
| [p.x, p.y] = petClamp(p, p.x, p.y); // 布局变化 → 重 clamp 进新活动区 | ||
| petApply(p); | ||
| stagger += 30 * SCALE; | ||
| } | ||
| if (!petRAFid) { petLastTs = 0; petRAFid = requestAnimationFrame(petLoop); } // rAF 常驻; reduced-motion 在 petStep 内实时冻结 | ||
| } | ||
| // ---------- boss 主位(独立渲染分支: 不经 makeStation/updateStation, 无状态/无浮标/无强度) ---------- | ||
@@ -392,2 +487,7 @@ function renderBoss(boss, L) { | ||
| el.appendChild(crown); | ||
| } else if (agent.assistant) { | ||
| // 助理:头顶冷青单层 V 角标(区别于 lead 金冠);lead/assistant 互斥,不会同时出现。 | ||
| const chev = document.createElement('div'); | ||
| chev.className = 'assist'; | ||
| el.appendChild(chev); | ||
| } | ||
@@ -556,2 +656,15 @@ | ||
| ).join(''); | ||
| // 团队对话: 全局消息流(最新在底), 默认面板的主内容; 贴底时跟随新消息, 用户上滚则不打断。 | ||
| const msgs = state.log.slice(-80); | ||
| const nearBottom = $ovChat.scrollHeight - $ovChat.scrollTop - $ovChat.clientHeight < 48; | ||
| if (!msgs.length) { | ||
| $ovChat.innerHTML = '<li class="m-empty">' + esc(T.noChat) + '</li>'; | ||
| } else { | ||
| $ovChat.innerHTML = msgs.map((m) => | ||
| '<li><div class="m-head"><span class="m-route">' + esc(m.from) + ' → ' + esc(m.to) + | ||
| '</span><span class="m-time">' + fmtTime(m.ts) + '</span></div>' + | ||
| '<div class="m-body">' + esc(m.body) + '</div></li>').join(''); | ||
| if (nearBottom) $ovChat.scrollTop = $ovChat.scrollHeight; | ||
| } | ||
| } | ||
@@ -636,4 +749,17 @@ | ||
| // ---------- 渲染一帧状态 ---------- | ||
| // §7 多办公室: 非默认办公室在页眉/标题挂 ` · <office>` 后缀; 默认('default'/falsy)零变化。 | ||
| function applyOfficeName(office) { | ||
| const named = office && office !== 'default'; | ||
| const sub = document.getElementById('subtitle'); | ||
| if (named) { | ||
| sub.textContent = T.subtitle + ' · ' + office; | ||
| document.title = 'falinks · ' + T.subtitle + ' · ' + office; | ||
| } else { | ||
| sub.textContent = T.subtitle; // 默认: 页眉保持原样; 标题保留 index.html 静态值(零变化) | ||
| } | ||
| } | ||
| function render() { | ||
| if (!state || !S) return; | ||
| applyOfficeName(state.office); | ||
| const roster = state.roster; | ||
@@ -752,3 +878,3 @@ const bossAgent = roster.find((a) => isBoss(a)); | ||
| const map = { tileA:'tileA', tileB:'tileB', seam:'seam', wood:'wood', woodHi:'woodHi', woodLo:'woodLo', base:'base', | ||
| busy:'busy', waiting:'waiting', stuck:'stuck', done:'done', offline:'offline', idle:'idle', warmGlow:'warmGlow' }; | ||
| busy:'busy', waiting:'waiting', stuck:'stuck', done:'done', offline:'offline', idle:'idle', warmGlow:'warmGlow', code:'code' }; | ||
| for (const k in map) if (p[k]) root.setProperty('--' + map[k], p[k]); | ||
@@ -755,0 +881,0 @@ root.setProperty('--s', SCALE); |
+25
-6
@@ -7,4 +7,21 @@ import { t } from './i18n/index.js'; | ||
| /** | ||
| * 用 driver 构造一个 Deliverer:注入格式化文本并提交(提交的可靠性由 driver 负责)。 | ||
| * onFail:注入失败时回调——此时消息已被 pump 出 inbox、却没进 pane(消息丢失,发件人却以为发出去了), | ||
| * 合并投递的文本: | ||
| * - 1 条 → 等同 formatMessage(单条),**行为零变化、无任何包装**(单条不加噪声)。 | ||
| * - ≥2 条 → 头部提示 inboxBatchHeader(n) + 编号列表,每条保留 from 归属(复用 formatMessage 的 `【来自 X】body`, | ||
| * 去掉每条末尾的逐条"(回复…)"行,避免 N 行回复提示刷屏;回复语义由头部统一承载)。多行 body 安全(只剥最后一行)。 | ||
| */ | ||
| export function formatBatch(msgs) { | ||
| if (msgs.length === 1) | ||
| return formatMessage(msgs[0]); | ||
| const header = t().inboxBatchHeader(msgs.length); | ||
| const items = msgs.map((m, i) => { | ||
| const full = formatMessage(m); | ||
| const cut = full.lastIndexOf('\n'); // formatMessage 末行恒为单行"(回复…)"提示;剥掉它,保留 `【来自 X】<多行 body>` | ||
| return `[${i + 1}] ${cut >= 0 ? full.slice(0, cut) : full}`; | ||
| }); | ||
| return `${header}\n${items.join('\n')}`; | ||
| } | ||
| /** | ||
| * 用 driver 构造一个 Deliverer:把一批排队消息合并成一次注入并提交(提交的可靠性由 driver 负责)。 | ||
| * onFail:注入失败时回调——此时这批消息已被 pump 出 inbox、却没进 pane(消息丢失,发件人却以为发出去了), | ||
| * 上层据此落盘诊断,让"悄悄丢消息"可见。 | ||
@@ -14,10 +31,12 @@ */ | ||
| return { | ||
| deliver(agent, msg) { | ||
| deliver(agent, msgs) { | ||
| if (!agent.sessionId) | ||
| throw new Error(`deliver: agent ${agent.name} has no sessionId`); | ||
| const text = formatMessage(msg); | ||
| // 注入是异步 I/O;Router 不等待。失败时打日志 + 回调(消息此刻已丢失)。 | ||
| if (msgs.length === 0) | ||
| return; | ||
| const text = formatBatch(msgs); | ||
| // 注入是异步 I/O;Router 不等待。失败时打日志 + 回调(这批消息此刻已丢失)。 | ||
| void driver.inject(agent.sessionId, text, true).catch((e) => { | ||
| console.error(`[deliver] inject to ${agent.name} failed:`, e); | ||
| onFail?.(agent, msg, e); | ||
| onFail?.(agent, msgs, e); | ||
| }); | ||
@@ -24,0 +43,0 @@ }, |
+11
-10
@@ -5,2 +5,3 @@ import { homedir } from 'node:os'; | ||
| import { realpathSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, readdirSync } from 'node:fs'; | ||
| import { DEFAULT_OFFICE, officeSuffix } from './core/office.js'; | ||
| /** falinks 的运行时目录 ~/.falinks。 */ | ||
@@ -37,9 +38,9 @@ export function runtimeDir() { | ||
| } | ||
| /** 实例档案路径:<root>/runtime/<projectKey>.json。 */ | ||
| export function instancePath(cwd, root = runtimeDir()) { | ||
| return join(root, 'runtime', `${projectKey(cwd)}.json`); | ||
| /** 实例档案路径:<root>/runtime/<projectKey[--office]>.json。office 缺省=默认办公室(无后缀,逐字节兼容旧路径)。 */ | ||
| export function instancePath(cwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| return join(root, 'runtime', `${projectKey(cwd)}${officeSuffix(office)}.json`); | ||
| } | ||
| /** 写实例档案。默认 wx 排他创建(挡同目录双开竞态),已存在返回 false;force 覆盖。 */ | ||
| /** 写实例档案。默认 wx 排他创建(挡同 (cwd,office) 双开竞态),已存在返回 false;force 覆盖。office 取自 info.office。 */ | ||
| export function writeInstance(info, root = runtimeDir(), opts) { | ||
| const p = instancePath(info.cwd, root); | ||
| const p = instancePath(info.cwd, root, info.office ?? DEFAULT_OFFICE); | ||
| mkdirSync(join(root, 'runtime'), { recursive: true }); | ||
@@ -57,5 +58,5 @@ try { | ||
| /** 读实例档案;不存在或损坏返回 null。 */ | ||
| export function readInstance(cwd, root = runtimeDir()) { | ||
| export function readInstance(cwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| try { | ||
| const d = JSON.parse(readFileSync(instancePath(cwd, root), 'utf8')); | ||
| const d = JSON.parse(readFileSync(instancePath(cwd, root, office), 'utf8')); | ||
| return typeof d?.port === 'number' && typeof d?.cwd === 'string' ? d : null; | ||
@@ -68,8 +69,8 @@ } | ||
| /** 删除实例档案,但只删自己的(pid 匹配)——退出清理用,防误删后启实例的档案。 */ | ||
| export function removeInstanceIfOwner(cwd, pid, root = runtimeDir()) { | ||
| const i = readInstance(cwd, root); | ||
| export function removeInstanceIfOwner(cwd, pid, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const i = readInstance(cwd, root, office); | ||
| if (i?.pid !== pid) | ||
| return; | ||
| try { | ||
| unlinkSync(instancePath(cwd, root)); | ||
| unlinkSync(instancePath(cwd, root, office)); | ||
| } | ||
@@ -76,0 +77,0 @@ catch { /* 已不在,无所谓 */ } |
| import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; | ||
| import { join, dirname } from 'node:path'; | ||
| import { runtimeDir, projectKey } from '../runtime.js'; | ||
| /** 每个项目目录一份存档:~/.falinks/sessions/<projectKey>.json。root 可注入便于测试。 | ||
| * key 走 projectKey(realpath + sha1)——与 runtime 实例档案同一规范化,符号链接路径不会对不上。 */ | ||
| export function sessionStorePath(launchCwd, root = runtimeDir()) { | ||
| return join(root, 'sessions', `${projectKey(launchCwd)}.json`); | ||
| import { runtimeDir } from '../runtime.js'; | ||
| import { DEFAULT_OFFICE, keyFor } from '../core/office.js'; | ||
| /** 每个 (项目目录, 办公室) 一份存档:~/.falinks/sessions/<key>.json。root 可注入便于测试。 | ||
| * key 走 keyFor(realpath + sha1 [+ --office])——与 runtime 实例档案同一规范化,符号链接路径不会对不上。 */ | ||
| export function sessionStorePath(launchCwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| return join(root, 'sessions', `${keyFor(launchCwd, office)}.json`); | ||
| } | ||
| /** 读存档;不存在或损坏都返回空壳。 */ | ||
| export function loadStore(launchCwd, root = runtimeDir()) { | ||
| const p = sessionStorePath(launchCwd, root); | ||
| export function loadStore(launchCwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = sessionStorePath(launchCwd, root, office); | ||
| if (!existsSync(p)) | ||
@@ -23,4 +24,4 @@ return { cwd: launchCwd, agents: {} }; | ||
| /** 写存档(自动建目录)。 */ | ||
| export function saveStore(launchCwd, store, root = runtimeDir()) { | ||
| const p = sessionStorePath(launchCwd, root); | ||
| export function saveStore(launchCwd, store, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = sessionStorePath(launchCwd, root, office); | ||
| mkdirSync(dirname(p), { recursive: true }); | ||
@@ -27,0 +28,0 @@ writeFileSync(p, JSON.stringify(store, null, 2)); |
+11
-1
@@ -41,2 +41,12 @@ import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; | ||
| }, | ||
| { | ||
| id: 'assisted', | ||
| name: t().tplAssistedName, | ||
| members: [ | ||
| { name: 'lead', cli: 'claude', role: t().tplAssistedLead, lead: true }, | ||
| { name: 'researcher', cli: 'claude', role: t().tplAssistedResearcher, assistant: true }, | ||
| { name: 'curator', cli: 'claude', role: t().tplAssistedCurator, assistant: true }, | ||
| { name: 'drafter', cli: 'claude', role: t().tplAssistedDrafter, assistant: true }, | ||
| ], | ||
| }, | ||
| ]; | ||
@@ -47,3 +57,3 @@ } | ||
| return { | ||
| agents: members.map((m) => ({ name: m.name, cli: m.cli, cwd: m.cwd || cwd, role: m.role, ...(m.lead ? { lead: true } : {}), ...(m.model ? { model: m.model } : {}), bootstrap: bootstrapForRole(m.role) })), | ||
| agents: members.map((m) => ({ name: m.name, cli: m.cli, cwd: m.cwd || cwd, role: m.role, ...(m.lead ? { lead: true } : {}), ...(m.assistant ? { assistant: true } : {}), ...(m.model ? { model: m.model } : {}), bootstrap: bootstrapForRole(m.role) })), | ||
| routes: {}, | ||
@@ -50,0 +60,0 @@ }; |
| import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; | ||
| import { join, dirname } from 'node:path'; | ||
| import { runtimeDir, projectKey } from './runtime.js'; | ||
| import { runtimeDir } from './runtime.js'; | ||
| import { DEFAULT_OFFICE, keyFor } from './core/office.js'; | ||
| const EMPTY = () => ({ state: 'idle', nudgeMinutes: 10, tasks: [], completedSinceLeadReset: 0 }); | ||
| /** 每个项目一份:~/.falinks/todos/<projectKey>.json。root 可注入便于测试。 */ | ||
| export function todoPath(launchCwd, root = runtimeDir()) { | ||
| return join(root, 'todos', `${projectKey(launchCwd)}.json`); | ||
| /** 每个 (项目, 办公室) 一份:~/.falinks/todos/<key>.json。root 可注入便于测试。默认办公室==projectKey(逐字节兼容)。 */ | ||
| export function todoPath(launchCwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| return join(root, 'todos', `${keyFor(launchCwd, office)}.json`); | ||
| } | ||
| /** 读档;不存在/损坏返回空壳。文件说 running 一律降 paused——进程死过,由 boss /todo resume 决定续跑。 */ | ||
| export function loadTodo(launchCwd, root = runtimeDir()) { | ||
| const p = todoPath(launchCwd, root); | ||
| export function loadTodo(launchCwd, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = todoPath(launchCwd, root, office); | ||
| if (!existsSync(p)) | ||
@@ -29,6 +30,6 @@ return EMPTY(); | ||
| } | ||
| export function saveTodo(launchCwd, st, root = runtimeDir()) { | ||
| const p = todoPath(launchCwd, root); | ||
| export function saveTodo(launchCwd, st, root = runtimeDir(), office = DEFAULT_OFFICE) { | ||
| const p = todoPath(launchCwd, root, office); | ||
| mkdirSync(dirname(p), { recursive: true }); | ||
| writeFileSync(p, JSON.stringify(st, null, 2)); | ||
| } |
+1
-1
| { | ||
| "name": "@hklmtt/falinks", | ||
| "version": "0.14.0", | ||
| "version": "0.15.0", | ||
| "description": "把多个终端 AI CLI(Claude Code、Codex…)编排成一间办公室:分屏真实窗口里的员工,通过 MCP 总线互相对话协作,外加一个控制台。", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+10
-0
@@ -116,2 +116,12 @@ ``` | ||
| ## Multiple offices | ||
| By default one project directory = one office. You can also run **several independent offices in the same directory** — each with its own config, port, roster, messages, lead memory, and `/office` page. | ||
| - The **default office** (no name) keeps the old paths and behavior — existing projects need **zero changes**. | ||
| - A **named office**: `falinks up --office <name>` (config saved to `.falinks/<name>.config.json`). Run bare `falinks` in a terminal to list this project's offices (each marked running/stopped) and open one or create a new one. Connect a console with `falinks console --office <name>`. | ||
| - Office names are lowercase `a-z 0-9 . _ -`, 1–32 chars; **`default` is reserved** (just use `falinks` / `falinks up` for it). `.falinks/` appears only when you first create a named office, and whether to commit configs is up to you. | ||
| No migration: the default office is byte-for-byte the old behavior. See [docs/OFFICE.md](docs/OFFICE.md) for details. | ||
| ## Config `falinks.config.json` | ||
@@ -118,0 +128,0 @@ |
+10
-0
@@ -111,2 +111,12 @@ ``` | ||
| ## 多办公室 | ||
| 默认「一个项目目录 = 一间办公室」。你也可以在**同一目录下并行开多间独立办公室**——每间各自独立:配置、端口、roster、消息、lead 记忆、`/office` 页面。 | ||
| - **默认办公室**(不填名)沿用旧路径与旧行为——老项目**零改动**。 | ||
| - **具名办公室**:`falinks up --office <名字>`(配置存 `.falinks/<名字>.config.json`)。在终端裸跑 `falinks` 会列出本项目所有办公室(标注运行中/已停),可打开某间或新建。连控制台用 `falinks console --office <名字>`。 | ||
| - 办公室名为小写 `a-z 0-9 . _ -`、1–32 字符;**`default` 为保留名**(默认办公室直接用 `falinks` / `falinks up`)。`.falinks/` 仅在首次创建具名办公室时出现,配置是否提交由你决定。 | ||
| 无需迁移:默认办公室与旧版逐字节一致。详见 [docs/OFFICE.md](docs/OFFICE.md)。 | ||
| ## 配置 `falinks.config.json` | ||
@@ -113,0 +123,0 @@ |
Sorry, the diff of this file is not supported yet
529462
9.2%72
5.88%8028
8.4%169
6.29%9
12.5%