@nowcrew/daemon 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
+ import { detectDaemonLang, translateDaemon } from "./i18n.js";
6
7
  export class ConfigError extends Error {
7
8
  }
8
9
  function defaultCliPath() {
@@ -20,7 +21,8 @@ export function loadConfig(env = process.env) {
20
21
  const serverUrl = (env.CREW_SERVER_URL ?? "http://127.0.0.1:3000").replace(/\/+$/, "");
21
22
  const machineToken = env.CREW_MACHINE_TOKEN ?? "";
22
23
  if (!machineToken) {
23
- throw new ConfigError("缺少 CREW_MACHINE_TOKEN (sk_machine_*,由 seed 打印)");
24
+ const lang = detectDaemonLang(env);
25
+ throw new ConfigError(translateDaemon(lang, "Missing CREW_MACHINE_TOKEN (sk_machine_*, printed by seed)"));
24
26
  }
25
27
  return {
26
28
  serverUrl,
@@ -29,5 +31,8 @@ export function loadConfig(env = process.env) {
29
31
  cliPath: env.CREW_CLI_PATH ?? defaultCliPath(),
30
32
  runtimeBin: env.CREW_RUNTIME ?? "claude",
31
33
  dangerous: env.CREW_RUNTIME_SAFE !== "1", // 默认开启 (headless agent 在自有 workspace 内运行)
34
+ resume: env.CREW_RESUME !== "off" && env.CREW_RESUME !== "0", // 默认开启;一键回退现状用 CREW_RESUME=off
35
+ resumeWarmMs: env.CREW_RESUME_WARM_MS != null ? Number(env.CREW_RESUME_WARM_MS) : 3_600_000, // 默认 1h
36
+ productName: env.CREW_PRODUCT_NAME ?? "OpenSlock",
32
37
  };
33
38
  }
package/dist/console.js CHANGED
@@ -8,10 +8,23 @@
8
8
  * 注:本转换器针对 claude 的 stream-json。codex 的 stream 格式不同,后续按 runtime 分派扩展;
9
9
  * 前端/server 消费的 ConsoleChunk 形状是 runtime 无关的。
10
10
  */
11
+ import { detectDaemonLang, translateDaemon } from "./i18n.js";
11
12
  /** 单条工具返回正文上限(超出截断并标注),避免单条把终端/DB 撑爆。 */
12
13
  export const TOOL_RESULT_CAP = 4000;
13
14
  /** 工具输入摘要上限(标题行那一段)。 */
14
15
  const TOOL_INPUT_CAP = 160;
16
+ /** kimi 工具 arguments(JSON 字符串)容错解析为对象;失败返回 undefined。 */
17
+ function parseKimiToolArgs(args) {
18
+ if (!args)
19
+ return undefined;
20
+ try {
21
+ const parsed = JSON.parse(args);
22
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
15
28
  /** 把工具输入压成一行摘要:Bash 取 command,其它取首个字符串字段或紧凑 JSON。 */
16
29
  function summarizeToolInput(name, input) {
17
30
  if (!input)
@@ -43,11 +56,22 @@ function clip(s, cap) {
43
56
  /** 把一个 stream-json 事件转成 0..N 条 console 行(完全透传)。 */
44
57
  export function toConsoleLines(event) {
45
58
  const e = (event ?? {});
59
+ const lang = detectDaemonLang();
60
+ const td = (message) => translateDaemon(lang, message);
46
61
  if (e.type === "system" && e.subtype === "init") {
47
- return [{ stream: "system", text: " claude 会话启动" }];
62
+ return [{ stream: "system", text: `● ${td("Claude session started")}` }];
63
+ }
64
+ if (e.type === "thread.started") {
65
+ return [{ stream: "system", text: "● codex 会话启动" }];
66
+ }
67
+ if (e.type === "item.completed" && e.item?.type === "agent_message" && e.item.text?.trim()) {
68
+ return [{ stream: "text", text: e.item.text.trim() }];
69
+ }
70
+ if (e.type === "turn.completed") {
71
+ return [{ stream: "result", text: "本轮结束" }];
48
72
  }
49
73
  if (e.type === "result") {
50
- const text = e.result?.trim() || (e.is_error ? "运行出错" : "本轮结束");
74
+ const text = e.result?.trim() || (e.is_error ? td("Run failed") : td("Run finished"));
51
75
  return [{ stream: e.is_error ? "error" : "result", text }];
52
76
  }
53
77
  if (e.type === "assistant" && Array.isArray(e.message?.content)) {
@@ -76,5 +100,21 @@ export function toConsoleLines(event) {
76
100
  }
77
101
  return out;
78
102
  }
103
+ // kimi stream-json:assistant(正文 + tool_calls)与 tool 结果透传;meta 行(resume hint)不上屏。
104
+ if (e.role === "assistant" && !e.type) {
105
+ const out = [];
106
+ if (typeof e.content === "string" && e.content.trim()) {
107
+ out.push({ stream: "text", text: e.content.trim() });
108
+ }
109
+ for (const call of Array.isArray(e.tool_calls) ? e.tool_calls : []) {
110
+ const name = call.function?.name;
111
+ if (name)
112
+ out.push({ stream: "tool", text: summarizeToolInput(name, parseKimiToolArgs(call.function?.arguments)) });
113
+ }
114
+ return out;
115
+ }
116
+ if (e.role === "tool" && !e.type && typeof e.content === "string" && e.content.trim()) {
117
+ return [{ stream: "tool_result", text: clip(e.content.trim(), TOOL_RESULT_CAP) }];
118
+ }
79
119
  return [];
80
120
  }
package/dist/i18n.js ADDED
@@ -0,0 +1,37 @@
1
+ const normalizeLang = (value) => {
2
+ if (!value)
3
+ return null;
4
+ const lowered = value.toLowerCase();
5
+ if (lowered.startsWith("zh"))
6
+ return "zh";
7
+ if (lowered.startsWith("en"))
8
+ return "en";
9
+ return null;
10
+ };
11
+ export function detectDaemonLang(env = process.env) {
12
+ return normalizeLang(env.CREW_LANG)
13
+ ?? normalizeLang(env.LC_ALL)
14
+ ?? normalizeLang(env.LC_MESSAGES)
15
+ ?? normalizeLang(env.LANG)
16
+ ?? "en";
17
+ }
18
+ const zh = {
19
+ "Claude session started": "Claude 会话启动",
20
+ "Run failed": "运行出错",
21
+ "Run finished": "本轮结束",
22
+ "Missing CREW_MACHINE_TOKEN (sk_machine_*, printed by seed)": "缺少 CREW_MACHINE_TOKEN(sk_machine_*,由 seed 打印)",
23
+ "Usage:": "用法:",
24
+ "connect and stay resident": "连接并常驻",
25
+ "run once manually": "手动运行一次",
26
+ "crew-daemon resident, connecting to": "crew-daemon 常驻,连接到",
27
+ "control plane": "控制面",
28
+ "Waking agent": "唤醒 agent",
29
+ "for channel": "处理频道",
30
+ "agent exited": "agent 退出",
31
+ "activities": "活动数",
32
+ };
33
+ export function translateDaemon(lang, message) {
34
+ if (lang === "zh")
35
+ return zh[message] ?? message;
36
+ return message;
37
+ }
@@ -0,0 +1,53 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileP = promisify(execFile);
4
+ export async function listRuntimeModels(runtime) {
5
+ switch (runtime) {
6
+ case "codex":
7
+ return parseCodexModels((await execFileP("codex", ["debug", "models"])).stdout);
8
+ case "cursor":
9
+ return parseCursorModels((await execFileP("cursor-agent", ["--list-models"])).stdout);
10
+ case "opencode":
11
+ return parseOpencodeModels((await execFileP("opencode", ["models"])).stdout);
12
+ case "pi":
13
+ return parsePiModels((await execFileP("pi", ["--list-models"])).stdout);
14
+ default:
15
+ return null;
16
+ }
17
+ }
18
+ function parseCodexModels(stdout) {
19
+ try {
20
+ const parsed = JSON.parse(stdout);
21
+ const rows = Array.isArray(parsed.models) ? parsed.models : [];
22
+ return rows
23
+ .filter((m) => typeof m.id === "string" && (m.visibility == null || m.visibility === "list"))
24
+ .map((m, index) => ({ id: m.id, label: m.name || m.id, ...((m.default || index === 0) ? { default: true } : {}) }));
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ }
30
+ function parseCursorModels(stdout) {
31
+ return stdout
32
+ .split(/\r?\n/)
33
+ .map((line) => line.trim())
34
+ .filter((line) => line.includes(" - "))
35
+ .map((line, index) => {
36
+ const [id, label] = line.split(/\s+-\s+/, 2);
37
+ return { id: id.trim(), label: (label || id).trim(), ...(index === 0 ? { default: true } : {}) };
38
+ });
39
+ }
40
+ function parseOpencodeModels(stdout) {
41
+ return stdout
42
+ .split(/\r?\n/)
43
+ .map((line) => line.trim())
44
+ .filter((line) => line.length > 0 && !line.startsWith("warning:"))
45
+ .map((line, index) => ({ id: line, label: line, ...(index === 0 ? { default: true } : {}) }));
46
+ }
47
+ function parsePiModels(stdout) {
48
+ return stdout
49
+ .split(/\r?\n/)
50
+ .map((line) => line.trim())
51
+ .filter((line) => line.length > 0 && !line.toLowerCase().startsWith("warning"))
52
+ .map((line, index) => ({ id: line, label: line, ...(index === 0 ? { default: true } : {}) }));
53
+ }
@@ -6,6 +6,7 @@ import { hostname, arch, platform } from "node:os";
6
6
  import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { readFileSync } from "node:fs";
9
+ import { readdir } from "node:fs/promises";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { dirname, resolve } from "node:path";
11
12
  const execFileP = promisify(execFile);
@@ -44,12 +45,30 @@ function daemonVersion() {
44
45
  return "0.0.0";
45
46
  }
46
47
  }
47
- export async function collectMachineHello() {
48
+ /** 列出 agentsRoot 下的 agent handle(每个子目录 = 一个 agent),跳过隐藏目录;读不到则返回 []。 */
49
+ async function listAgentHandles(agentsRoot) {
50
+ try {
51
+ const entries = await readdir(agentsRoot, { withFileTypes: true });
52
+ return entries
53
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
54
+ .map((e) => e.name.trim().toLowerCase())
55
+ .filter((h) => h.length > 0);
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
61
+ export async function collectMachineHello(agentsRoot) {
62
+ const [runtimes, agentHandles] = await Promise.all([
63
+ detectRuntimes(),
64
+ listAgentHandles(agentsRoot),
65
+ ]);
48
66
  return {
49
67
  type: "machine:hello",
50
68
  hostname: hostname(),
51
69
  os: `${platform()} ${arch()}`,
52
70
  daemonVersion: daemonVersion(),
53
- runtimes: await detectRuntimes(),
71
+ runtimes,
72
+ agentHandles,
54
73
  };
55
74
  }
package/dist/main.js CHANGED
@@ -7,9 +7,12 @@
7
7
  */
8
8
  import { parseArgs } from "node:util";
9
9
  import { loadConfig, ConfigError } from "./config.js";
10
+ import { detectDaemonLang, translateDaemon } from "./i18n.js";
10
11
  import { runAgent } from "./runner.js";
11
12
  import { serve } from "./serve.js";
12
13
  async function main() {
14
+ const lang = detectDaemonLang();
15
+ const td = (message) => translateDaemon(lang, message);
13
16
  const { values, positionals } = parseArgs({
14
17
  args: process.argv.slice(2),
15
18
  allowPositionals: true,
@@ -27,7 +30,9 @@ async function main() {
27
30
  // 无子命令时默认 serve(对齐 `npx @nowcrew/daemon@latest --server-url ... --api-key ...`)
28
31
  const cmd = positionals[0] ?? "serve";
29
32
  if (cmd !== "run" && cmd !== "serve") {
30
- process.stderr.write("用法:\n npx @nowcrew/daemon@latest --server-url <url> --api-key <sk_machine_*> # 连接并常驻\n crew-daemon run --agent <h> --channel <id> [--wake ...] # 手动跑一次\n");
33
+ process.stderr.write(td("Usage:") + "\n" +
34
+ " npx @nowcrew/daemon@latest --server-url <url> --api-key <sk_machine_*> # " + td("connect and stay resident") + "\n" +
35
+ " crew-daemon run --agent <h> --channel <id> [--wake ...] # " + td("run once manually") + "\n");
31
36
  process.exit(2);
32
37
  }
33
38
  // 命令行参数优先于环境变量,填回 env 供 loadConfig 读取
@@ -48,23 +53,23 @@ async function main() {
48
53
  throw e;
49
54
  }
50
55
  if (cmd === "serve") {
51
- process.stdout.write(`\n🛰️ crew-daemon 常驻,连接 ${config.serverUrl} 控制面...\n`);
56
+ process.stdout.write(`\n🛰️ ${td("crew-daemon resident, connecting to")} ${config.serverUrl} ${td("control plane")}...\n`);
52
57
  serve(config);
53
58
  await new Promise(() => { }); // 常驻,直到被 kill
54
59
  return;
55
60
  }
56
61
  if (!values.agent || !values.channel) {
57
- process.stderr.write("用法: crew-daemon run --agent <handle> --channel <id> [--wake ...]\n");
62
+ process.stderr.write(`${td("Usage:")} crew-daemon run --agent <handle> --channel <id> [--wake ...]\n`);
58
63
  process.exit(2);
59
64
  }
60
- process.stdout.write(`\n🚀 唤醒 agent "${values.agent}" 处理频道 ${values.channel}\n\n`);
65
+ process.stdout.write(`\n🚀 ${td("Waking agent")} "${values.agent}" ${td("for channel")} ${values.channel}\n\n`);
61
66
  const result = await runAgent(config, {
62
67
  handle: values.agent,
63
68
  channelId: values.channel,
64
69
  ...(values.wake ? { wake: values.wake } : {}),
65
70
  ...(values.display ? { displayName: values.display } : {}),
66
71
  });
67
- process.stdout.write(`\n— agent 退出 (code ${result.exitCode}),共 ${result.activities.length} 个活动 —\n`);
72
+ process.stdout.write(`\n— ${td("agent exited")} (code ${result.exitCode}), ${td("activities")}: ${result.activities.length} —\n`);
68
73
  process.exit(result.exitCode);
69
74
  }
70
75
  main().catch((e) => {
package/dist/normalize.js CHANGED
@@ -19,12 +19,38 @@ export function classifyCommand(command) {
19
19
  return { kind: "crew", label: "crew 命令", detail: c };
20
20
  return { kind: "tool", label: "执行命令", detail: c };
21
21
  }
22
+ /** kimi 的 Bash 工具 arguments 是 JSON 字符串({"command": "..."}),容错解析出 command。 */
23
+ function parseKimiBashCommand(args) {
24
+ if (!args)
25
+ return null;
26
+ try {
27
+ const parsed = JSON.parse(args);
28
+ return typeof parsed.command === "string" && parsed.command.trim() ? parsed.command.trim() : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
22
34
  /** 把一个 stream-json 事件归一化为 0..N 个活动。 */
23
35
  export function normalizeEvent(event) {
24
36
  const e = (event ?? {});
25
37
  if (e.type === "system" && e.subtype === "init") {
26
38
  return [{ kind: "init", label: "agent 启动" }];
27
39
  }
40
+ if (e.type === "thread.started") {
41
+ return [{ kind: "init", label: "agent 启动" }];
42
+ }
43
+ if (e.type === "item.completed" && e.item?.type === "agent_message" && e.item.text?.trim()) {
44
+ return [{ kind: "text", label: "思考/说明", detail: e.item.text.trim() }];
45
+ }
46
+ if (e.type === "item.completed" && e.item?.type === "command_execution" && e.item.command?.trim()) {
47
+ const command = e.item.command.trim();
48
+ const shell = command.match(/^\/bin\/zsh -lc '(.+)'$/);
49
+ return [classifyCommand(shell?.[1] ?? command)];
50
+ }
51
+ if (e.type === "turn.completed") {
52
+ return [{ kind: "done", label: "本轮结束" }];
53
+ }
28
54
  if (e.type === "result") {
29
55
  return e.is_error
30
56
  ? [{ kind: "error", label: "运行出错", ...(e.result ? { detail: e.result } : {}) }]
@@ -50,8 +76,48 @@ export function normalizeEvent(event) {
50
76
  if (hasResult)
51
77
  return [{ kind: "tool_result", label: "工具返回" }];
52
78
  }
79
+ // kimi stream-json:{role:"assistant", content?, tool_calls?} / {role:"tool", ...}。
80
+ // kimi 行没有顶层 type(meta 行 role="meta",不产活动),与 claude/codex 分支互斥。
81
+ if (e.role === "assistant" && !e.type) {
82
+ const out = [];
83
+ if (typeof e.content === "string" && e.content.trim()) {
84
+ out.push({ kind: "text", label: "思考/说明", detail: e.content.trim() });
85
+ }
86
+ for (const call of Array.isArray(e.tool_calls) ? e.tool_calls : []) {
87
+ const name = call.function?.name ?? "";
88
+ const command = name === "Bash" ? parseKimiBashCommand(call.function?.arguments) : null;
89
+ if (command)
90
+ out.push(classifyCommand(command));
91
+ else if (name)
92
+ out.push({ kind: "tool", label: `工具:${name}` });
93
+ }
94
+ return out;
95
+ }
96
+ if (e.role === "tool" && !e.type) {
97
+ return [{ kind: "tool_result", label: "工具返回" }];
98
+ }
53
99
  return [];
54
100
  }
101
+ export function extractRunMeta(event) {
102
+ const e = (event ?? {});
103
+ const meta = {};
104
+ // 任何带 session_id 的事件(system/init、result …)都用来确认/更新 session id
105
+ if (typeof e.session_id === "string" && e.session_id)
106
+ meta.sessionId = e.session_id;
107
+ if (typeof e.thread_id === "string" && e.thread_id)
108
+ meta.sessionId = e.thread_id;
109
+ // 仅 result 事件携带本轮 usage 汇总
110
+ if ((e.type === "result" || e.type === "turn.completed") && e.usage) {
111
+ meta.usage = {
112
+ inputTokens: e.usage.input_tokens ?? 0,
113
+ outputTokens: e.usage.output_tokens ?? 0,
114
+ cacheReadTokens: e.usage.cache_read_input_tokens ?? e.usage.cached_input_tokens ?? 0,
115
+ cacheCreationTokens: e.usage.cache_creation_input_tokens ?? 0,
116
+ ...(typeof e.total_cost_usd === "number" ? { costUsd: e.total_cost_usd } : {}),
117
+ };
118
+ }
119
+ return meta;
120
+ }
55
121
  /** 解析一行 ndjson;非法行返回 null。 */
56
122
  export function parseLine(line) {
57
123
  const t = line.trim();
package/dist/prompt.js CHANGED
@@ -6,12 +6,13 @@
6
6
  * 分层记忆与压缩安全、协作礼仪。措辞为本项目原创。
7
7
  */
8
8
  export function buildSystemPrompt(ctx) {
9
- return `你是 "${ctx.handle}",OpenSlock(一个让人类与 AI agent 协作的共享工作区)中的 AI 成员。OpenSlock 为可能运行在不同机器上的人与 agent 提供共享的消息服务。
9
+ const product = ctx.productName ?? "OpenSlock";
10
+ return `你是 "${ctx.handle}",${product}(一个让人类与 AI agent 协作的共享工作区)中的 AI 成员。${product} 为可能运行在不同机器上的人与 agent 提供共享的消息服务。
10
11
 
11
12
  ## 你是谁
12
13
  你的 workspace 和 MEMORY.md 跨会话保留,被唤醒时可恢复上下文。你会被启动、空闲时休眠、有人给你发消息时再次唤醒。把自己当成一位始终在线、随时间积累知识、通过交互形成专长的同事——而不是一次性聊天机器人。
13
14
 
14
- ## 当前运行时上下文(由 OpenSlock 注入,权威)
15
+ ## 当前运行时上下文(由 ${product} 注入,权威)
15
16
  - Handle: ${ctx.handle}${ctx.agentId ? `\n- Agent ID: ${ctx.agentId}` : ""}
16
17
  - 你被唤醒处理的频道: ${ctx.channelId}
17
18
  - **你的 cwd 是本任务的隔离工作目录**(代码检出/构建/草稿都放这里;你可能同时有多个并行运行,各自 cwd 独立,互不干扰)。
@@ -35,9 +36,9 @@ export function buildSystemPrompt(ctx) {
35
36
  你的消息正文,可含 "引号"、\\\`反引号\\\`、代码块。
36
37
  CREWMSG
37
38
  \`\`\`
38
- 也可用 \`--content "<短正文>"\`。线程内回复:加 \`--thread <msg码>\`(msg 码来自 read/search 输出里的 \`msg=\` 8 位)。
39
+ 也可用 \`--content "<短正文>"\`。线程内回复:加 \`--thread <完整线程根消息 id>\`。被唤醒时优先使用环境变量 \`$CREW_WAKE_MESSAGE_ID\` 或唤醒提示里的完整 id,不要手动截短。
39
40
  5. **\`crew task list --channel <id>\`** —— 看任务板。支持 \`--status <s>\` / \`--mine\`。
40
- 6. **\`crew task create --channel <id> --title "<标题>"\`** —— 新建任务(把一件事登记成可追踪的工作项)
41
+ 6. **\`crew task create --channel <id> --title "<标题>" --thread <当前线程根msgId>\`** —— 新建任务并**绑定到当前线程**。\`--thread\` 传你读到的那条**触发消息 id**(线程根),任务就和讨论同处一个线程。省略 \`--thread\` 会另起新线程,**几乎总是错的——务必带上**。
41
42
  7. **\`crew task claim <taskId>\`** —— 认领任务(动手前必做)。
42
43
  8. **\`crew task update <taskId> --status <in_progress|in_review|done>\`** —— 推进任务状态。
43
44
  9. **\`crew task unclaim <taskId>\`** —— 释放认领,把任务让给别人。
@@ -64,7 +65,7 @@ CRITICAL 规则:
64
65
  - 你读到的消息形如 \`#<seq> [<type>] <sender>: <正文>\`,\`type\` 为 \`human\` / \`agent\` / \`system\`。
65
66
  - **\`system\` 消息**通报频道状态变化(如新建任务),除非明确要求你行动(如刚给你指派了任务),否则不要回复。
66
67
  - **判定规则**:若满足来信需要你"回复之外的动作"(跑工具/改代码/做变更),先 claim;若只是回答问题或闲聊,无需 claim。
67
- - **线程 = 工作单元 / 一个请求一个 task(CRITICAL)**:一条对话(thread)对应一件事,最多绑**一个** task。被唤醒处理来信时,对这件事**只建一个 task**:\`crew task create --title "…"\`(不带 --new-thread)会把它绑到**当前线程**(触发你的那条消息所在线程),不另发顶层消息。**别把一个请求拆成多个 task**(如"拉代码"+"读文档"+"写记忆"是同一件事 → 一个 task,用 todo/进度推进,不要建第二个)。当前线程已有 task 时再 \`crew task create\` 会被服务端拒绝(报错会提示你)。
68
+ - **线程 = 工作单元 / 一个请求一个 task(CRITICAL)**:一条对话(thread)对应一件事,最多绑**一个** task。被唤醒处理来信时,对这件事**只建一个 task,且必须把它绑到当前线程**:\`crew task create --title "…" --thread <触发你的那条消息 id>\`(那条消息就是线程根)。**一定要带 \`--thread\`**——不带会另起一条飘在顶层的新线程,task 就和你的讨论分家了(这正是要避免的)。**别把一个请求拆成多个 task**(如"拉代码"+"读文档"+"写记忆"是同一件事 → 一个 task,用 todo/进度推进,不要建第二个)。当前线程已有 task 时再建会被服务端拒绝(报错会提示你)。
68
69
  - **进度/产出回本线程**:这件事的认领/进度/完成汇报都用 \`crew message send --thread <当前线程根>\` 回复在**这个线程里**(线程根 = 触发你的那条消息 id,即 \$CREW_WAKE_MESSAGE_ID)。**严禁把进度发到别的线程或频道顶层。**
69
70
  - **毫不相关的新任务才另起线程**:只有要处理的事**和当前线程毫不相关**(或用户明确要求新建)时,才用 \`crew task create --new-thread --title "…"\`——系统另起一个子线程(parent=当前线程)绑新 task;之后这件事的回复要发到**这个新子线程**里。能不拆就不拆。
70
71
  - 任务状态流:\`todo → in_progress → in_review → done\`。claim 后用 \`crew task update\` 推进:开工→in_progress、完成待验收→in_review、人类确认后→done。只有 assignee 能改自己任务的状态。
@@ -98,7 +99,7 @@ ${ctx.memory ? `\n## [注入] 你的 MEMORY.md(索引,只读参考)\n${ctx.memor
98
99
  * 只有发现确实指向自己的事才转为主动处理,否则读完即停、不发声。
99
100
  */
100
101
  export function buildWakePrompt(channelId) {
101
- return `先补齐上下文:若本轮在某个线程里(被唤醒处理某 thread),用 \`crew thread read\` 读**当前线程 + 它的父线程**(聚焦上下文,最多向上一层);需要更全局再用 \`crew message read --channel ${channelId}\` 读整个频道。
102
+ return `先补齐上下文:若本轮在某个线程里(被唤醒处理某 thread),用 \`crew thread read\` 读**当前线程 + 它的父线程**(聚焦上下文,最多向上一层);需要更全局再用 \`crew message read --channel ${channelId} --limit 100\` 读最近 100 条(频道消息过多时全量加载会 prompt 爆炸,需要更早的历史用 \`--after <seq>\` 分段拉)。
102
103
  读完判断:其中是否有明确落到你头上的事——点名找你、@你、指派给你、请你评审,或交给你的任务。
103
104
  - 有:转入主动处理。相关任务先 \`crew task claim <taskId>\` 认领再动手,完成后用 \`crew message send --channel ${channelId}\` 回复。
104
105
  - 没有:本轮什么都不要发,读完即停。你存活期间有新消息会自动送来,无需轮询。`;
package/dist/runner.js CHANGED
@@ -5,10 +5,13 @@ import { createInterface } from "node:readline";
5
5
  import { writeFile } from "node:fs/promises";
6
6
  import { delimiter, join } from "node:path";
7
7
  import { mintAgentToken } from "./token.js";
8
- import { prepareWorkspace } from "./workspace.js";
8
+ import { prepareWorkspace, rotateAgentSession } from "./workspace.js";
9
9
  import { buildSystemPrompt, buildWakePrompt } from "./prompt.js";
10
10
  import { spawnClaude } from "./runtimes/claude.js";
11
- import { normalizeEvent, parseLine } from "./normalize.js";
11
+ import { spawnCodex } from "./runtimes/codex.js";
12
+ import { spawnKimi } from "./runtimes/kimi.js";
13
+ import { normalizeEvent, parseLine, extractRunMeta } from "./normalize.js";
14
+ import { readSession, writeSession, pickResumeId } from "./session.js";
12
15
  import { toConsoleLines } from "./console.js";
13
16
  // 注入提示词的 MEMORY.md 上限:只喂索引/角色,避免把膨胀的记忆全塞进上下文。
14
17
  const MEMORY_INJECT_CAP = 6000;
@@ -20,7 +23,10 @@ export async function runAgent(config, input, onActivity = defaultPrint,
20
23
  // 终端透传:每条 stream-json 事件除归一化为状态活动外,再产出 console 行供前端终端窗口渲染。
21
24
  onConsole = () => { }) {
22
25
  // 1) 用机器令牌换 per-launch agent 令牌
23
- const cred = await mintAgentToken(config.serverUrl, config.machineToken, input.handle, input.displayName);
26
+ const cred = await mintAgentToken(config.serverUrl, config.machineToken, input.handle, input.displayName, input.wakeMessageId);
27
+ const cfg = cred.config ?? {};
28
+ const runtime = cfg.runtime ?? config.runtimeBin;
29
+ const currentModel = cfg.model ?? null;
24
30
  // 2) 准备 workspace(共享 home + 本任务隔离 cwd + per-task work-log)。
25
31
  // 先 prepare 拿到 memory/workLog,再 build 系统提示词(注入记忆索引)写入。
26
32
  const ws = await prepareWorkspace({
@@ -31,72 +37,135 @@ onConsole = () => { }) {
31
37
  // 仅在首次创建 MEMORY.md 时,用 agent 的 description 种子化 ## Role
32
38
  ...(cred.config?.description ? { description: cred.config.description } : {}),
33
39
  });
40
+ // session resume:同任务有历史会话且仍在缓存窗口内 → spawn 时 --resume 复用上下文(省 token)。
41
+ // 关:CREW_RESUME=off;超 warm 窗口 → 冷启动(避免 cache miss 重写更贵);读取容错(损坏 → 当首轮)。
42
+ const supportsNativeResume = runtime === "claude";
43
+ const prior = config.resume && supportsNativeResume ? await readSession(ws.runDir) : null;
44
+ // 会话身份唯一来源:workspace 的确定性 uuid(ws.agentSessionId)。HEAD 的 warm-window + 开关
45
+ // 只决定「是否续用」:既有会话(sessionResume)且仍在缓存窗口内 → --resume;否则冷启动。
46
+ const resuming = supportsNativeResume && ws.sessionResume && pickResumeId(prior, Date.now(), config.resumeWarmMs, currentModel) != null;
47
+ // 既有会话但本轮不续用 → 轮换出新 uuid 冷启动(避免 --session-id 撞已存在会话)。
48
+ const launchSessionId = ws.agentSessionId && ws.sessionResume && !resuming
49
+ ? await rotateAgentSession(ws.runDir)
50
+ : ws.agentSessionId;
34
51
  const systemPrompt = buildSystemPrompt({
35
52
  handle: input.handle,
36
53
  channelId: input.channelId,
37
54
  agentId: cred.agentId,
38
55
  homeDir: ws.dir,
56
+ productName: config.productName,
39
57
  // 只注入 MEMORY.md 的索引/角色部分(截断),避免上下文膨胀;明细让 agent 按需读 notes/。
40
58
  memory: ws.memory.length > MEMORY_INJECT_CAP
41
59
  ? ws.memory.slice(0, MEMORY_INJECT_CAP) + "\n…(MEMORY.md 过长已截断,详情用 Read 读 $CREW_HOME/MEMORY.md 或 notes/)"
42
60
  : ws.memory,
43
- workLog: ws.workLog,
61
+ // resume 时进度已在对话历史里,省去重喂 work-log(resume 省 token 的主要来源);首轮才注入。
62
+ ...(resuming ? {} : { workLog: ws.workLog }),
44
63
  });
45
64
  await writeFile(ws.systemPromptPath, systemPrompt, "utf8");
46
65
  // 3) spawn runtime,注入 PATH(crew wrapper)、凭证 env、以及 agent 运行时配置
47
66
  // provider=custom → BYOC(ANTHROPIC_BASE_URL/API_KEY);reasoning → 思考预算;model → --model
48
- const cfg = cred.config ?? {};
49
67
  const REASONING_TOKENS = { low: "4000", medium: "10000", high: "31999" };
50
- const child = spawnClaude({
51
- bin: config.runtimeBin,
52
- cwd: ws.runDir, // 本任务隔离工作目录(并行运行互不干扰)
53
- systemPromptPath: ws.systemPromptPath,
54
- wakePrompt: input.wake ?? buildWakePrompt(input.channelId),
55
- dangerous: config.dangerous,
56
- ...(cfg.model ? { model: cfg.model } : {}),
57
- // 一线程一会话:首轮 --session-id 固定 id,之后 --resume 续上(更原生地复用 claude 记忆)
58
- ...(ws.agentSessionId ? { sessionId: ws.agentSessionId, resume: ws.sessionResume } : {}),
59
- env: {
60
- ...process.env,
61
- PATH: `${ws.crewDir}${delimiter}${process.env.PATH ?? ""}`,
62
- CREW_SERVER_URL: config.serverUrl,
63
- CREW_TOKEN: cred.token,
64
- CREW_CHANNEL: input.channelId,
65
- // 共享持久记忆 home(MEMORY.md/notes 在此;cwd 是本任务隔离目录)+ 本任务 work-log 路径
66
- CREW_HOME: ws.dir,
67
- CREW_TASK_LOG: ws.workLogPath,
68
- // 唤醒锚点消息 id:有则 `crew task create` 把任务锚定到这条触发消息(讨论与任务锚点统一),
69
- // 而非另发一条标题消息当锚点(那会让点开 task 的 thread 永远为空)
70
- ...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
71
- // per-agent 凭证隔离:XDG 指向本 agent 独立目录(gh/gcloud 等 CLI 的 token 不互相串)
72
- // 不覆盖 HOME(否则会破坏 claude 自身的 ~/.claude 鉴权);常用 CLI 也单独点名隔离。
73
- XDG_CONFIG_HOME: join(ws.homeDir, ".config"),
74
- XDG_DATA_HOME: join(ws.homeDir, ".local", "share"),
75
- XDG_CACHE_HOME: join(ws.homeDir, ".cache"),
76
- GH_CONFIG_DIR: join(ws.homeDir, ".config", "gh"),
77
- CLOUDSDK_CONFIG: join(ws.homeDir, ".config", "gcloud"),
78
- // provider custom = BYOC:为该 agent 单独设置 Anthropic 端点/密钥
79
- ...(cfg.provider === "custom" && cfg.providerBaseUrl ? { ANTHROPIC_BASE_URL: cfg.providerBaseUrl } : {}),
80
- ...(cfg.provider === "custom" && cfg.providerApiKey ? { ANTHROPIC_API_KEY: cfg.providerApiKey } : {}),
81
- // reasoning → 思考预算 (claude 读 MAX_THINKING_TOKENS)
82
- ...(cfg.reasoning && cfg.reasoning !== "default" && REASONING_TOKENS[cfg.reasoning]
83
- ? { MAX_THINKING_TOKENS: REASONING_TOKENS[cfg.reasoning] }
84
- : {}),
85
- // fast 模式 → 透传给 runtime(best-effort,供 wrapper/runtime 读取)
86
- ...(cfg.fastMode ? { CREW_FAST_MODE: "1" } : {}),
87
- },
88
- });
89
- // 4) 逐行解析 stdout → 归一化 → 回调
68
+ // resume 时提示 agent 上下文已在,无需从头重读频道(配合不注入 work-log,进一步省 token)。
69
+ const baseWake = input.wake ?? buildWakePrompt(input.channelId);
70
+ const wakePrompt = resuming
71
+ ? `(继续之前的会话:频道历史与你的进度已在上下文里,不必从头重读;要新消息用 \`crew message read --channel ${input.channelId}\` 增量拉即可。)\n\n${baseWake}`
72
+ : baseWake;
73
+ const childEnv = {
74
+ ...process.env,
75
+ PATH: `${ws.crewDir}${delimiter}${process.env.PATH ?? ""}`,
76
+ CREW_SERVER_URL: config.serverUrl,
77
+ CREW_TOKEN: cred.token,
78
+ CREW_CHANNEL: input.channelId,
79
+ // 共享持久记忆 home(MEMORY.md/notes 在此;cwd 是本任务隔离目录)+ 本任务 work-log 路径
80
+ CREW_HOME: ws.dir,
81
+ CREW_TASK_LOG: ws.workLogPath,
82
+ // 唤醒锚点消息 id:有则 `crew task create` 把任务锚定到这条触发消息(讨论与任务锚点统一),
83
+ // 而非另发一条标题消息当锚点(那会让点开 task thread 永远为空)。
84
+ ...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
85
+ // per-agent 凭证隔离:XDG 指向本 agent 独立目录(gh/gcloud 等 CLI 的 token 不互相串)。
86
+ // 不覆盖 HOME(否则会破坏 claude 自身的 ~/.claude 鉴权);常用 CLI 也单独点名隔离。
87
+ XDG_CONFIG_HOME: join(ws.homeDir, ".config"),
88
+ XDG_DATA_HOME: join(ws.homeDir, ".local", "share"),
89
+ XDG_CACHE_HOME: join(ws.homeDir, ".cache"),
90
+ GH_CONFIG_DIR: join(ws.homeDir, ".config", "gh"),
91
+ CLOUDSDK_CONFIG: join(ws.homeDir, ".config", "gcloud"),
92
+ // provider custom = BYOC:为该 agent 单独设置 Anthropic 端点/密钥
93
+ ...(cfg.provider === "custom" && cfg.providerBaseUrl ? { ANTHROPIC_BASE_URL: cfg.providerBaseUrl } : {}),
94
+ ...(cfg.provider === "custom" && cfg.providerApiKey ? { ANTHROPIC_API_KEY: cfg.providerApiKey } : {}),
95
+ // reasoning → 思考预算 (claude MAX_THINKING_TOKENS;kimi 读 KIMI_MODEL_THINKING_EFFORT,
96
+ // 值域 low/medium/high/xhigh/max 与本配置兼容,对其它 runtime 无害)
97
+ ...(cfg.reasoning && cfg.reasoning !== "default" && REASONING_TOKENS[cfg.reasoning]
98
+ ? { MAX_THINKING_TOKENS: REASONING_TOKENS[cfg.reasoning], KIMI_MODEL_THINKING_EFFORT: cfg.reasoning }
99
+ : {}),
100
+ // fast 模式 透传给 runtime(best-effort,供 wrapper/runtime 读取)
101
+ ...(cfg.fastMode ? { CREW_FAST_MODE: "1" } : {}),
102
+ };
103
+ const child = runtime === "claude"
104
+ ? spawnClaude({
105
+ bin: runtime,
106
+ cwd: ws.runDir, // 本任务隔离工作目录(并行运行互不干扰)
107
+ systemPromptPath: ws.systemPromptPath,
108
+ wakePrompt,
109
+ dangerous: config.dangerous,
110
+ ...(currentModel ? { model: currentModel } : {}),
111
+ // 一线程一会话(唯一会话机制):首轮/冷启动 --session-id 固定 uuid,warm 续轮 --resume 续上。
112
+ ...(launchSessionId ? { sessionId: launchSessionId, resume: resuming } : {}),
113
+ env: childEnv,
114
+ })
115
+ : runtime === "codex"
116
+ ? spawnCodex({
117
+ bin: runtime,
118
+ cwd: ws.runDir,
119
+ wakePrompt: `${systemPrompt}\n\n${wakePrompt}`,
120
+ dangerous: config.dangerous,
121
+ ...(currentModel ? { model: currentModel } : {}),
122
+ env: childEnv,
123
+ })
124
+ : runtime === "kimi"
125
+ ? spawnKimi({
126
+ bin: runtime,
127
+ cwd: ws.runDir,
128
+ // kimi 与 codex 一样没有 system prompt 参数,拼在 wake prompt 前;
129
+ // -p 模式固定 auto 权限,dangerous 无对应 flag(见 runtimes/kimi.ts)。
130
+ wakePrompt: `${systemPrompt}\n\n${wakePrompt}`,
131
+ ...(currentModel ? { model: currentModel } : {}),
132
+ env: childEnv,
133
+ })
134
+ : (() => {
135
+ throw new Error(`unsupported runtime: ${runtime}`);
136
+ })();
137
+ // 4) 逐行解析 stdout → 归一化 → 回调;顺带抓 session_id(记 lastRunAt 供 warm-window)+ token usage(度量)
90
138
  const activities = [];
139
+ // 身份是本轮下发的 launchSessionId;仍兜底采 claude 自报的 session_id(理应一致)。
140
+ let sessionId = launchSessionId;
141
+ let usage;
142
+ let finalText = null;
143
+ let sentViaCrew = false;
91
144
  const rl = createInterface({ input: child.stdout });
92
145
  rl.on("line", (line) => {
93
146
  const evt = parseLine(line);
94
147
  if (!evt)
95
148
  return;
149
+ const meta = extractRunMeta(evt);
150
+ if (meta.sessionId)
151
+ sessionId = meta.sessionId;
152
+ if (meta.usage)
153
+ usage = meta.usage;
96
154
  for (const a of normalizeEvent(evt)) {
155
+ if (a.kind === "sending")
156
+ sentViaCrew = true;
97
157
  activities.push(a);
98
158
  onActivity(a);
99
159
  }
160
+ const item = evt.item;
161
+ if (evt.type === "item.completed" && item?.type === "agent_message" && item.text?.trim()) {
162
+ finalText = item.text.trim();
163
+ }
164
+ // kimi:最后一条带正文的 assistant 行即最终回答(kimi 无 result/turn.completed 事件)
165
+ const kimiMsg = evt;
166
+ if (kimiMsg.role === "assistant" && !kimiMsg.type && typeof kimiMsg.content === "string" && kimiMsg.content.trim()) {
167
+ finalText = kimiMsg.content.trim();
168
+ }
100
169
  // 同一事件再透传为终端 console 行(独立于状态活动,内容不压缩)。
101
170
  for (const c of toConsoleLines(evt))
102
171
  onConsole(c);
@@ -105,10 +174,57 @@ onConsole = () => { }) {
105
174
  const exitCode = await new Promise((resolve) => {
106
175
  child.on("close", (code) => resolve(code ?? 0));
107
176
  });
108
- return { exitCode, activities };
177
+ // kimi stream-json 没有轮次结束事件(进程退出即结束),补一个 done/error 活动对齐前端状态。
178
+ if (runtime === "kimi") {
179
+ const a = exitCode === 0
180
+ ? { kind: "done", label: "本轮结束" }
181
+ : { kind: "error", label: "运行出错", detail: `kimi exited with code ${exitCode}` };
182
+ activities.push(a);
183
+ onActivity(a);
184
+ }
185
+ if ((runtime === "codex" || runtime === "kimi") && exitCode === 0 && !sentViaCrew && finalText) {
186
+ const sent = await sendAgentMessage(config.serverUrl, cred.token, input.channelId, {
187
+ content: finalText,
188
+ ...(input.wakeMessageId ? { thread: input.wakeMessageId } : {}),
189
+ });
190
+ if (sent.ok) {
191
+ const a = { kind: "sending", label: "发消息", detail: `${runtime} final answer fallback` };
192
+ activities.push(a);
193
+ onActivity(a);
194
+ }
195
+ else {
196
+ process.stderr.write(`${runtime} fallback send failed (HTTP ${sent.status})\n`);
197
+ }
198
+ }
199
+ // 5) 落盘 session(下次同任务可 --resume),并打印本轮 token 用量(度量 resume 真省与否)
200
+ if (config.resume && supportsNativeResume && sessionId) {
201
+ await writeSession(ws.runDir, {
202
+ sessionId,
203
+ lastRunAt: Date.now(),
204
+ turns: (prior?.turns ?? 0) + 1,
205
+ model: currentModel,
206
+ });
207
+ }
208
+ if (usage) {
209
+ const u = usage;
210
+ process.stdout.write(`📊 tokens: in=${u.inputTokens} out=${u.outputTokens} cache_read=${u.cacheReadTokens} cache_create=${u.cacheCreationTokens}` +
211
+ `${u.costUsd != null ? ` cost=$${u.costUsd.toFixed(4)}` : ""} ${resuming ? "(resumed)" : "(fresh)"}\n`);
212
+ }
213
+ return { exitCode, activities, ...(usage ? { usage } : {}) };
109
214
  }
110
215
  function defaultPrint(a) {
111
216
  const icon = ICON[a.kind] ?? "·";
112
217
  const detail = a.detail ? ` ${a.detail.replace(/\s+/g, " ").slice(0, 120)}` : "";
113
218
  process.stdout.write(`${icon} ${a.label}${detail}\n`);
114
219
  }
220
+ async function sendAgentMessage(serverUrl, token, channelId, body) {
221
+ const res = await fetch(`${serverUrl}/agent/channels/${encodeURIComponent(channelId)}/messages`, {
222
+ method: "POST",
223
+ headers: {
224
+ authorization: `Bearer ${token}`,
225
+ "content-type": "application/json",
226
+ },
227
+ body: JSON.stringify(body),
228
+ });
229
+ return { ok: res.ok, status: res.status };
230
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Codex CLI runtime adapter: non-interactive exec mode with JSONL output.
3
+ */
4
+ import { spawn } from "node:child_process";
5
+ export function buildCodexArgs(input) {
6
+ const args = ["exec", "--json"];
7
+ if (input.model)
8
+ args.push("--model", input.model);
9
+ if (input.dangerous)
10
+ args.push("--dangerously-bypass-approvals-and-sandbox");
11
+ args.push(input.wakePrompt);
12
+ return args;
13
+ }
14
+ export function spawnCodex(input) {
15
+ return spawn(input.bin, buildCodexArgs(input), {
16
+ cwd: input.cwd,
17
+ env: input.env,
18
+ stdio: ["ignore", "pipe", "pipe"],
19
+ });
20
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Kimi Code CLI runtime adapter: non-interactive prompt mode with stream-json output.
3
+ *
4
+ * 事实依据(kimi-code 0.23.0 本机实测 + 官方文档 www.kimi.com/code/docs):
5
+ * - `kimi -p <prompt> --output-format stream-json`:单次非交互执行,stdout 每行一个 JSON。
6
+ * - `-p` 固定 auto 权限(自动批准普通工具调用),且与 --yolo/--auto/--plan 互斥,
7
+ * 故 dangerous 无需(也不能)映射任何 flag。
8
+ * - 无 system prompt 注入参数 → 与 codex 同法:systemPrompt 拼在 wakePrompt 前。
9
+ * - 鉴权是机器级的(`kimi login` 或 ~/.kimi-code/config.toml),不读 shell 环境变量。
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ export function buildKimiArgs(input) {
13
+ const args = ["--output-format", "stream-json"];
14
+ if (input.model)
15
+ args.push("--model", input.model);
16
+ args.push("--prompt", input.wakePrompt);
17
+ return args;
18
+ }
19
+ export function spawnKimi(input) {
20
+ return spawn(input.bin, buildKimiArgs(input), {
21
+ cwd: input.cwd,
22
+ env: input.env,
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ });
25
+ }
package/dist/serve.js CHANGED
@@ -9,6 +9,7 @@ import { collectMachineHello } from "./machine-info.js";
9
9
  import { listWorkspace, readWorkspaceFile } from "./workspace-fs.js";
10
10
  import { listSkills } from "./skills.js";
11
11
  import { inspectRaftWorkspace, importRaftWorkspace } from "./workspace-import.js";
12
+ import { listRuntimeModels } from "./list-models.js";
12
13
  // normalize.ts 的活动种类 → activity 枚举
13
14
  const ACTIVITY_MAP = {
14
15
  init: "working", text: "thinking", reading: "reading", sending: "sending",
@@ -57,11 +58,11 @@ export function serve(config, opts = {}) {
57
58
  backoff = 1000;
58
59
  log(`🔌 已连接控制面 ${config.serverUrl}`);
59
60
  // 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
60
- void collectMachineHello()
61
+ void collectMachineHello(config.agentsRoot)
61
62
  .then((hello) => {
62
63
  try {
63
64
  ws?.send(JSON.stringify(hello));
64
- log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · runtimes=[${hello.runtimes.join(",")}]`);
65
+ log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · runtimes=[${hello.runtimes.join(",")}] · agents=[${hello.agentHandles.join(",")}]`);
65
66
  }
66
67
  catch { /* 非 OPEN,忽略 */ }
67
68
  })
@@ -76,6 +77,18 @@ export function serve(config, opts = {}) {
76
77
  catch {
77
78
  return;
78
79
  }
80
+ // 控制面鉴权拒绝:server 端 resolveToken 未命中有效的 machine 凭证(失效/被吊销/
81
+ // 库已重置)。不能静默丢弃这帧——否则只表现为神秘的「每 1s 重连」循环。打印可执行
82
+ // 提示,并把退避拉满,避免无意义高频重连刷屏 server(凭证失配不会靠重试自愈,
83
+ // 需在 NowCrew 重新 Add Computer 拿新连接命令)。
84
+ if (msg.type === "error") {
85
+ if (msg.code === "UNAUTHENTICATED") {
86
+ log(`🛑 控制面拒绝鉴权:机器凭证无效或已吊销 (UNAUTHENTICATED)。`);
87
+ log(` 请在 NowCrew 重新 "Add Computer" 获取新的连接命令,再到本机重跑(当前 --api-key 已失效)。`);
88
+ backoff = maxBackoff; // 退避到最大,停止每秒重连刷屏
89
+ }
90
+ return;
91
+ }
79
92
  // 导入 raft agent 工作区:inspect 反填 name/description;import 复制用户内容
80
93
  if (msg.type === "raft:inspect" || msg.type === "raft:import") {
81
94
  const req = msg;
@@ -100,7 +113,7 @@ export function serve(config, opts = {}) {
100
113
  return;
101
114
  }
102
115
  // workspace 文件浏览 / skills 枚举请求 (只读、沙箱;见 workspace-fs.ts / skills.ts)
103
- if (msg.type === "fs:list" || msg.type === "fs:read" || msg.type === "skills:list") {
116
+ if (msg.type === "fs:list" || msg.type === "fs:read" || msg.type === "skills:list" || msg.type === "probe-models") {
104
117
  const req = msg;
105
118
  const root = join(config.agentsRoot, req.handle);
106
119
  const reply = (r) => {
@@ -112,7 +125,8 @@ export function serve(config, opts = {}) {
112
125
  try {
113
126
  const data2 = req.type === "fs:list" ? await listWorkspace(root, req.path)
114
127
  : req.type === "fs:read" ? await readWorkspaceFile(root, req.path)
115
- : await listSkills(config.agentsRoot, req.handle);
128
+ : req.type === "skills:list" ? await listSkills(config.agentsRoot, req.handle)
129
+ : { models: await listRuntimeModels(req.type === "probe-models" ? (req.runtime ?? "") : "") };
116
130
  reply({ ok: true, data: data2 });
117
131
  }
118
132
  catch (e) {
@@ -133,13 +147,13 @@ export function serve(config, opts = {}) {
133
147
  running.add(key);
134
148
  // 并行槽:同 agent 超过 MAX_PARALLEL 个任务时在此排队(不丢),有空位再跑。
135
149
  await acquireSlot(msg.agentHandle);
136
- const threadCode = threadId ? threadId.slice(0, 8) : null;
150
+ const threadLabel = threadId ?? null;
137
151
  const from = msg.wake?.senderHandle ?? "?";
138
152
  const incoming = msg.wake?.content ?? "";
139
153
  log(`\n${"─".repeat(56)}`);
140
154
  log(`🔔 唤醒 agent=${msg.agentHandle} reason=${msg.reason ?? "?"}`);
141
155
  log(` channel = ${msg.channelId}`);
142
- log(` thread = ${threadCode ? `${threadCode} (要求线程内回复)` : "(无,顶层回复)"}`);
156
+ log(` thread = ${threadLabel ? `${threadLabel} (要求线程内回复)` : "(无,顶层回复)"}`);
143
157
  if (incoming)
144
158
  log(`📥 来信 @${from}: ${incoming.replace(/\s+/g, " ").slice(0, 200)}`);
145
159
  let actSeq = 0;
@@ -149,7 +163,7 @@ export function serve(config, opts = {}) {
149
163
  let line = ` · ${a.label}`;
150
164
  if (a.kind === "sending") {
151
165
  const m = det.match(/--content\s+"([^"]*)"/) || det.match(/<<'?\w+'?\s*(.*)/);
152
- line = ` 💬 回复${threadCode ? `(thread ${threadCode})` : ""}: ${m ? m[1].slice(0, 160) : det.slice(0, 120)}`;
166
+ line = ` 💬 回复${threadLabel ? `(thread ${threadLabel})` : ""}: ${m ? m[1].slice(0, 160) : det.slice(0, 120)}`;
153
167
  }
154
168
  else if (det) {
155
169
  line += ` ${det.slice(0, 80)}`;
@@ -186,8 +200,8 @@ export function serve(config, opts = {}) {
186
200
  try {
187
201
  // 线程聚合:触发消息即任务线程根,你的确认+后续所有回复都要发到它的线程里,
188
202
  // 不要发顶层——这样 task 讨论全部聚合在该 thread 下。
189
- const threadHint = threadCode
190
- ? `\n**所有回复都必须发到这条消息的线程里**(任务线程):用 crew message send --channel ${msg.channelId} --thread ${threadCode} 发送,不要发频道顶层。`
203
+ const threadHint = threadId
204
+ ? `\n**所有回复都必须发到这条消息的线程里**(任务线程):用 crew message send --channel ${msg.channelId} --thread ${threadId} 发送,不要发频道顶层。`
191
205
  : "";
192
206
  // channel(广播投递):你是频道成员之一,自己判断是否与你职责相关——相关才行动(回复 /
193
207
  // crew task create / claim / 交接给下一棒),不相关就不回(频道沉默不算失败,避免人人都答)。
@@ -197,19 +211,24 @@ export function serve(config, opts = {}) {
197
211
  // 关键协作礼仪:一旦决定接手,**第一步就先在频道发一句简短确认**
198
212
  // (例:"收到,我接 task #N。先做 X / 排查 Y,有结论再同步"),别让频道空着干等;
199
213
  // 然后再开始读日志/跑命令。干完用 @下一棒 或 crew task assign 交接。
200
- const sendCmd = threadCode
201
- ? `crew message send --channel ${msg.channelId} --thread ${threadCode}`
214
+ const sendCmd = threadId
215
+ ? `crew message send --channel ${msg.channelId} --thread ${threadId}`
202
216
  : `crew message send --channel ${msg.channelId}`;
203
217
  const ackHint = `\n**协作礼仪:决定接手后,务必先用 \`${sendCmd}\` 在该任务线程发一句简短确认**(收到 + 我接 task #N + 接下来要做什么),再开始干活——不要闷头工作把线程空着。`;
204
218
  // 图片/文件附件:crew message read 会在消息下列出附件及其 id;图片需下载后用 Read 工具查看,才能真正"看到"内容。
205
219
  const attHint = `\n若消息带图片/文件附件(read 会列出 id),用 \`crew attachment get <id>\` 下载到本地,图片再用 Read 工具打开查看后再处理。`;
220
+ // 线程隔离:有 threadId 时用 `crew thread read` 只读本线程(避免被其他线程消息干扰);
221
+ // 顶层消息(无 threadId)用 `crew message read` 读整个频道。
222
+ const readCmd = threadId
223
+ ? `crew thread read`
224
+ : `crew message read --channel ${msg.channelId}`;
206
225
  await runAgent(config, {
207
226
  handle: msg.agentHandle,
208
227
  channelId: msg.channelId,
209
228
  taskKey, // 每任务隔离 cwd + work-log(并行不冲突)
210
229
  // 唤醒锚点是具体消息(非纯频道唤醒)时,把它透传下去,供 `crew task create` 锚定到该消息。
211
230
  ...(threadId ? { wakeMessageId: threadId } : {}),
212
- ...(msg.wake?.content ? { wake: `你被唤醒(${msg.reason}): ${msg.wake.content}\n用 crew message read --channel ${msg.channelId} 读频道后按需处理。${reasonHint}${ackHint}${threadHint}${attHint}` } : {}),
231
+ ...(msg.wake?.content ? { wake: `你被唤醒(${msg.reason}): ${msg.wake.content}\n用 ${readCmd} 读${threadId ? "本线程" : "频道"}后按需处理。${reasonHint}${ackHint}${threadHint}${attHint}` } : {}),
213
232
  }, reportActivity, reportConsole);
214
233
  reportActivity({ kind: "done", label: "本轮结束" });
215
234
  reportConsole({ stream: "result", text: "● 本轮结束" });
@@ -223,9 +242,16 @@ export function serve(config, opts = {}) {
223
242
  releaseSlot(msg.agentHandle);
224
243
  }
225
244
  });
226
- ws.on("close", () => {
245
+ ws.on("close", (code) => {
227
246
  if (stopped)
228
247
  return;
248
+ // 4001 = 控制面应用级「鉴权失败」关闭码(见 server control-plane.ts)。即使上面的
249
+ // error 帧因 close 抢先而丢失,也能据关闭码识别这是凭证失效——退避拉满,不再每秒热循环。
250
+ if (code === 4001) {
251
+ backoff = maxBackoff;
252
+ log(`🛑 控制面以鉴权失败关闭连接 (code 4001):机器凭证无效或已吊销。`);
253
+ log(` 请在 NowCrew 重新 "Add Computer" 获取新连接命令再重跑(当前 --api-key 已失效)。`);
254
+ }
229
255
  log(`🔁 控制面断开,${Math.round(backoff / 1000)}s 后重连`);
230
256
  setTimeout(connect, backoff);
231
257
  backoff = Math.min(backoff * 2, maxBackoff);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * per-task claude 会话元数据 —— 让同一任务的重复唤醒用 `--resume` 复用上下文(省 token)。
3
+ *
4
+ * 存在本任务隔离运行目录下(<runDir>/.crew-session.json):天然按 taskKey 隔离、落盘防 daemon
5
+ * 重启丢失。读取一律容错 → 不存在/损坏/字段非法都回退 null(= 当作首轮冷启动),绝不阻断运行。
6
+ */
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ const FILE = ".crew-session.json";
10
+ export function sessionPath(runDir) {
11
+ return join(runDir, FILE);
12
+ }
13
+ /** 读会话元数据;不存在/坏 json/缺 sessionId → null(回退冷启动,绝不抛)。 */
14
+ export async function readSession(runDir) {
15
+ let raw;
16
+ try {
17
+ raw = await readFile(sessionPath(runDir), "utf8");
18
+ }
19
+ catch {
20
+ return null; // 文件不存在 = 首轮
21
+ }
22
+ try {
23
+ const o = JSON.parse(raw);
24
+ if (typeof o.sessionId !== "string" || !o.sessionId)
25
+ return null;
26
+ return {
27
+ sessionId: o.sessionId,
28
+ lastRunAt: typeof o.lastRunAt === "number" ? o.lastRunAt : 0,
29
+ turns: typeof o.turns === "number" ? o.turns : 0,
30
+ model: typeof o.model === "string" ? o.model : null,
31
+ };
32
+ }
33
+ catch {
34
+ return null; // 坏 json → 当首轮,不阻断运行
35
+ }
36
+ }
37
+ export async function writeSession(runDir, meta) {
38
+ await writeFile(sessionPath(runDir), JSON.stringify(meta, null, 2), "utf8");
39
+ }
40
+ /**
41
+ * 决定本轮是否 --resume 复用会话:返回 sessionId(resume)或 null(冷启动)。纯函数。
42
+ *
43
+ * 防负优化:claude 的 prompt cache 有寿命(默认到 1h),间隔超过 warmMs 后 resume 必然
44
+ * cache miss——既读不到旧缓存、又要把更大的历史重写进新缓存,反而比冷启动+work-log 更贵。
45
+ * 故只在缓存窗口内 resume(省),过期则回退冷启动(不亏)。warmMs<=0 关闭阈值(永远 resume)。
46
+ */
47
+ export function pickResumeId(prior, now, warmMs, currentModel = null) {
48
+ if (!prior)
49
+ return null;
50
+ if (prior.model !== currentModel)
51
+ return null;
52
+ if (warmMs > 0 && now - prior.lastRunAt > warmMs)
53
+ return null;
54
+ return prior.sessionId;
55
+ }
package/dist/token.js CHANGED
@@ -1,12 +1,17 @@
1
1
  /** 用机器令牌为某 agent 换取 per-launch 的 sk_agent_*。 */
2
- export async function mintAgentToken(serverUrl, machineToken, handle, displayName, fetchImpl = fetch) {
2
+ export async function mintAgentToken(serverUrl, machineToken, handle, displayName, wakeThreadRoot, fetchImpl = fetch) {
3
3
  const res = await fetchImpl(`${serverUrl}/daemon/agents/token`, {
4
4
  method: "POST",
5
5
  headers: {
6
6
  authorization: `Bearer ${machineToken}`,
7
7
  "content-type": "application/json",
8
8
  },
9
- body: JSON.stringify(displayName ? { handle, displayName } : { handle }),
9
+ // 把本轮唤醒的线程根钉到 per-launch 令牌:服务端据此让 task/消息默认绑/回当前线程。
10
+ body: JSON.stringify({
11
+ handle,
12
+ ...(displayName ? { displayName } : {}),
13
+ ...(wakeThreadRoot ? { wakeThreadRoot } : {}),
14
+ }),
10
15
  });
11
16
  const body = (await res.json().catch(() => null));
12
17
  if (!res.ok || !body?.success || !body.data) {
@@ -71,7 +71,7 @@ export function stripActiveContext(md) {
71
71
  }
72
72
  const replacement = [
73
73
  lines[start], // 原 `## Active Context` 标题
74
- "<!-- (导入时已清空:raft 的在办任务/任务 id 不属于本 OpenSlock。开工时在此重新记录当前进度。) -->",
74
+ "<!-- (导入时已清空:raft 的在办任务/任务 id 不属于本工作区。开工时在此重新记录当前进度。) -->",
75
75
  "",
76
76
  ];
77
77
  return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join("\n");
package/dist/workspace.js CHANGED
@@ -72,6 +72,16 @@ export async function prepareWorkspace(input) {
72
72
  }
73
73
  return { dir, crewDir, systemPromptPath, memory, homeDir, runDir, workLogPath, workLog, agentSessionId, sessionResume };
74
74
  }
75
+ /**
76
+ * 冷启动时轮换会话 id:写入新 uuid 到 <runDir>/.session 并返回。
77
+ * 已有会话但本轮决定不续用(warm 窗口过期 / CREW_RESUME=off)时调用——起一个全新会话,
78
+ * 避免拿已存在的 id 走 `--session-id`(claude 会判定 id 冲突),同时保持「一线程一(当前)会话」。
79
+ */
80
+ export async function rotateAgentSession(runDir) {
81
+ const id = randomUUID();
82
+ await writeFile(join(runDir, ".session"), id, "utf8");
83
+ return id;
84
+ }
75
85
  /**
76
86
  * MEMORY.md 种子骨架:分层记忆的"索引 + Active Context"结构。
77
87
  * 第一次准备工作区时写入;之后 agent 自己维护(系统提示词里有协议)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "ws": "^8",
21
- "@nowcrew/cli": "^0.1.0"
21
+ "@nowcrew/cli": "^0.3.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^22.0.0",