🎩 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.14.0
to
0.15.0
+26
dist/cli-route.js
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 @@

+3
-1

@@ -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 路由(人/老板入口)----

#!/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 @@ }

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 ?? {};

@@ -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。 */

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

@@ -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` +

@@ -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` +

@@ -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 { /* 诊断落盘失败不致命 */ }

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 { /* 不存在/删除失败不致命 */ }
}

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

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

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

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

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

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

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