@nowcrew/daemon 0.2.0 → 0.4.2
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 +6 -1
- package/dist/console.js +42 -2
- package/dist/i18n.js +37 -0
- package/dist/list-models.js +53 -0
- package/dist/machine-info.js +21 -2
- package/dist/main.js +10 -5
- package/dist/normalize.js +69 -0
- package/dist/prompt.js +5 -4
- package/dist/runner.js +191 -45
- package/dist/runtimes/codex.js +20 -0
- package/dist/runtimes/kimi.js +25 -0
- package/dist/serve.js +62 -14
- package/dist/session.js +55 -0
- package/dist/workspace-import.js +1 -1
- package/dist/workspace.js +10 -0
- package/package.json +2 -2
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
|
-
|
|
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: "
|
|
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
|
+
}
|
package/dist/machine-info.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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🚀
|
|
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
|
|
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,51 @@ 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
|
+
// claude 的 system/init 事件自报实际模型(配置可能为空/别名,以 runtime 自报为准)
|
|
110
|
+
if (typeof e.model === "string" && e.model)
|
|
111
|
+
meta.model = e.model;
|
|
112
|
+
// 仅 result 事件携带本轮 usage 汇总
|
|
113
|
+
if ((e.type === "result" || e.type === "turn.completed") && e.usage) {
|
|
114
|
+
meta.usage = {
|
|
115
|
+
inputTokens: e.usage.input_tokens ?? 0,
|
|
116
|
+
outputTokens: e.usage.output_tokens ?? 0,
|
|
117
|
+
cacheReadTokens: e.usage.cache_read_input_tokens ?? e.usage.cached_input_tokens ?? 0,
|
|
118
|
+
cacheCreationTokens: e.usage.cache_creation_input_tokens ?? 0,
|
|
119
|
+
...(typeof e.total_cost_usd === "number" ? { costUsd: e.total_cost_usd } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return meta;
|
|
123
|
+
}
|
|
55
124
|
/** 解析一行 ndjson;非法行返回 null。 */
|
|
56
125
|
export function parseLine(line) {
|
|
57
126
|
const t = line.trim();
|
package/dist/prompt.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* 分层记忆与压缩安全、协作礼仪。措辞为本项目原创。
|
|
7
7
|
*/
|
|
8
8
|
export function buildSystemPrompt(ctx) {
|
|
9
|
-
|
|
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
|
-
## 当前运行时上下文(由
|
|
15
|
+
## 当前运行时上下文(由 ${product} 注入,权威)
|
|
15
16
|
- Handle: ${ctx.handle}${ctx.agentId ? `\n- Agent ID: ${ctx.agentId}` : ""}
|
|
16
17
|
- 你被唤醒处理的频道: ${ctx.channelId}
|
|
17
18
|
- **你的 cwd 是本任务的隔离工作目录**(代码检出/构建/草稿都放这里;你可能同时有多个并行运行,各自 cwd 独立,互不干扰)。
|
|
@@ -35,7 +36,7 @@ export function buildSystemPrompt(ctx) {
|
|
|
35
36
|
你的消息正文,可含 "引号"、\\\`反引号\\\`、代码块。
|
|
36
37
|
CREWMSG
|
|
37
38
|
\`\`\`
|
|
38
|
-
也可用 \`--content "<短正文>"\`。线程内回复:加 \`--thread
|
|
39
|
+
也可用 \`--content "<短正文>"\`。线程内回复:加 \`--thread <完整线程根消息 id>\`。被唤醒时优先使用环境变量 \`$CREW_WAKE_MESSAGE_ID\` 或唤醒提示里的完整 id,不要手动截短。
|
|
39
40
|
5. **\`crew task list --channel <id>\`** —— 看任务板。支持 \`--status <s>\` / \`--mine\`。
|
|
40
41
|
6. **\`crew task create --channel <id> --title "<标题>" --thread <当前线程根msgId>\`** —— 新建任务并**绑定到当前线程**。\`--thread\` 传你读到的那条**触发消息 id**(线程根),任务就和讨论同处一个线程。省略 \`--thread\` 会另起新线程,**几乎总是错的——务必带上**。
|
|
41
42
|
7. **\`crew task claim <taskId>\`** —— 认领任务(动手前必做)。
|
|
@@ -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 {
|
|
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;
|
|
@@ -21,6 +24,9 @@ export async function runAgent(config, input, onActivity = defaultPrint,
|
|
|
21
24
|
onConsole = () => { }) {
|
|
22
25
|
// 1) 用机器令牌换 per-launch agent 令牌
|
|
23
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,141 @@ 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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
...
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
// 用户自定义 env(表单 ENVIRONMENT VARIABLES):先合入,可覆盖继承的 shell 环境;
|
|
76
|
+
// 但 PATH/CREW_*/XDG_* 及下方由表单生成的值(ANTHROPIC_*/思考预算)在其后合入,始终以系统为准。
|
|
77
|
+
...sanitizeEnvVars(cfg.envVars),
|
|
78
|
+
PATH: `${ws.crewDir}${delimiter}${process.env.PATH ?? ""}`,
|
|
79
|
+
CREW_SERVER_URL: config.serverUrl,
|
|
80
|
+
CREW_TOKEN: cred.token,
|
|
81
|
+
CREW_CHANNEL: input.channelId,
|
|
82
|
+
// 共享持久记忆 home(MEMORY.md/notes 在此;cwd 是本任务隔离目录)+ 本任务 work-log 路径
|
|
83
|
+
CREW_HOME: ws.dir,
|
|
84
|
+
CREW_TASK_LOG: ws.workLogPath,
|
|
85
|
+
// 唤醒锚点消息 id:有则 `crew task create` 把任务锚定到这条触发消息(讨论与任务锚点统一),
|
|
86
|
+
// 而非另发一条标题消息当锚点(那会让点开 task 的 thread 永远为空)。
|
|
87
|
+
...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
|
|
88
|
+
// per-agent 凭证隔离:XDG 指向本 agent 独立目录(gh/gcloud 等 CLI 的 token 不互相串)。
|
|
89
|
+
// 不覆盖 HOME(否则会破坏 claude 自身的 ~/.claude 鉴权);常用 CLI 也单独点名隔离。
|
|
90
|
+
XDG_CONFIG_HOME: join(ws.homeDir, ".config"),
|
|
91
|
+
XDG_DATA_HOME: join(ws.homeDir, ".local", "share"),
|
|
92
|
+
XDG_CACHE_HOME: join(ws.homeDir, ".cache"),
|
|
93
|
+
GH_CONFIG_DIR: join(ws.homeDir, ".config", "gh"),
|
|
94
|
+
CLOUDSDK_CONFIG: join(ws.homeDir, ".config", "gcloud"),
|
|
95
|
+
// provider custom = BYOC:为该 agent 单独设置 Anthropic 端点/密钥
|
|
96
|
+
...(cfg.provider === "custom" && cfg.providerBaseUrl ? { ANTHROPIC_BASE_URL: cfg.providerBaseUrl } : {}),
|
|
97
|
+
...(cfg.provider === "custom" && cfg.providerApiKey ? { ANTHROPIC_API_KEY: cfg.providerApiKey } : {}),
|
|
98
|
+
// reasoning → 思考预算 (claude 读 MAX_THINKING_TOKENS;kimi 读 KIMI_MODEL_THINKING_EFFORT,
|
|
99
|
+
// 值域 low/medium/high/xhigh/max 与本配置兼容,对其它 runtime 无害)
|
|
100
|
+
...(cfg.reasoning && cfg.reasoning !== "default" && REASONING_TOKENS[cfg.reasoning]
|
|
101
|
+
? { MAX_THINKING_TOKENS: REASONING_TOKENS[cfg.reasoning], KIMI_MODEL_THINKING_EFFORT: cfg.reasoning }
|
|
102
|
+
: {}),
|
|
103
|
+
// fast 模式 → 透传给 runtime(best-effort,供 wrapper/runtime 读取)
|
|
104
|
+
...(cfg.fastMode ? { CREW_FAST_MODE: "1" } : {}),
|
|
105
|
+
};
|
|
106
|
+
const child = runtime === "claude"
|
|
107
|
+
? spawnClaude({
|
|
108
|
+
bin: runtime,
|
|
109
|
+
cwd: ws.runDir, // 本任务隔离工作目录(并行运行互不干扰)
|
|
110
|
+
systemPromptPath: ws.systemPromptPath,
|
|
111
|
+
wakePrompt,
|
|
112
|
+
dangerous: config.dangerous,
|
|
113
|
+
...(currentModel ? { model: currentModel } : {}),
|
|
114
|
+
// 一线程一会话(唯一会话机制):首轮/冷启动 --session-id 固定 uuid,warm 续轮 --resume 续上。
|
|
115
|
+
...(launchSessionId ? { sessionId: launchSessionId, resume: resuming } : {}),
|
|
116
|
+
env: childEnv,
|
|
117
|
+
})
|
|
118
|
+
: runtime === "codex"
|
|
119
|
+
? spawnCodex({
|
|
120
|
+
bin: runtime,
|
|
121
|
+
cwd: ws.runDir,
|
|
122
|
+
wakePrompt: `${systemPrompt}\n\n${wakePrompt}`,
|
|
123
|
+
dangerous: config.dangerous,
|
|
124
|
+
...(currentModel ? { model: currentModel } : {}),
|
|
125
|
+
env: childEnv,
|
|
126
|
+
})
|
|
127
|
+
: runtime === "kimi"
|
|
128
|
+
? spawnKimi({
|
|
129
|
+
bin: runtime,
|
|
130
|
+
cwd: ws.runDir,
|
|
131
|
+
// kimi 与 codex 一样没有 system prompt 参数,拼在 wake prompt 前;
|
|
132
|
+
// -p 模式固定 auto 权限,dangerous 无对应 flag(见 runtimes/kimi.ts)。
|
|
133
|
+
wakePrompt: `${systemPrompt}\n\n${wakePrompt}`,
|
|
134
|
+
...(currentModel ? { model: currentModel } : {}),
|
|
135
|
+
env: childEnv,
|
|
136
|
+
})
|
|
137
|
+
: (() => {
|
|
138
|
+
throw new Error(`unsupported runtime: ${runtime}`);
|
|
139
|
+
})();
|
|
140
|
+
// 4) 逐行解析 stdout → 归一化 → 回调;顺带抓 session_id(记 lastRunAt 供 warm-window)+ token usage(度量)
|
|
90
141
|
const activities = [];
|
|
142
|
+
// 身份是本轮下发的 launchSessionId;仍兜底采 claude 自报的 session_id(理应一致)。
|
|
143
|
+
let sessionId = launchSessionId;
|
|
144
|
+
let usage;
|
|
145
|
+
let observedModel = currentModel;
|
|
146
|
+
let finalText = null;
|
|
147
|
+
let sentViaCrew = false;
|
|
91
148
|
const rl = createInterface({ input: child.stdout });
|
|
92
149
|
rl.on("line", (line) => {
|
|
93
150
|
const evt = parseLine(line);
|
|
94
151
|
if (!evt)
|
|
95
152
|
return;
|
|
153
|
+
const meta = extractRunMeta(evt);
|
|
154
|
+
if (meta.sessionId)
|
|
155
|
+
sessionId = meta.sessionId;
|
|
156
|
+
if (meta.usage)
|
|
157
|
+
usage = meta.usage;
|
|
158
|
+
if (meta.model)
|
|
159
|
+
observedModel = meta.model;
|
|
96
160
|
for (const a of normalizeEvent(evt)) {
|
|
161
|
+
if (a.kind === "sending")
|
|
162
|
+
sentViaCrew = true;
|
|
97
163
|
activities.push(a);
|
|
98
164
|
onActivity(a);
|
|
99
165
|
}
|
|
166
|
+
const item = evt.item;
|
|
167
|
+
if (evt.type === "item.completed" && item?.type === "agent_message" && item.text?.trim()) {
|
|
168
|
+
finalText = item.text.trim();
|
|
169
|
+
}
|
|
170
|
+
// kimi:最后一条带正文的 assistant 行即最终回答(kimi 无 result/turn.completed 事件)
|
|
171
|
+
const kimiMsg = evt;
|
|
172
|
+
if (kimiMsg.role === "assistant" && !kimiMsg.type && typeof kimiMsg.content === "string" && kimiMsg.content.trim()) {
|
|
173
|
+
finalText = kimiMsg.content.trim();
|
|
174
|
+
}
|
|
100
175
|
// 同一事件再透传为终端 console 行(独立于状态活动,内容不压缩)。
|
|
101
176
|
for (const c of toConsoleLines(evt))
|
|
102
177
|
onConsole(c);
|
|
@@ -105,10 +180,81 @@ onConsole = () => { }) {
|
|
|
105
180
|
const exitCode = await new Promise((resolve) => {
|
|
106
181
|
child.on("close", (code) => resolve(code ?? 0));
|
|
107
182
|
});
|
|
108
|
-
|
|
183
|
+
// kimi 的 stream-json 没有轮次结束事件(进程退出即结束),补一个 done/error 活动对齐前端状态。
|
|
184
|
+
if (runtime === "kimi") {
|
|
185
|
+
const a = exitCode === 0
|
|
186
|
+
? { kind: "done", label: "本轮结束" }
|
|
187
|
+
: { kind: "error", label: "运行出错", detail: `kimi exited with code ${exitCode}` };
|
|
188
|
+
activities.push(a);
|
|
189
|
+
onActivity(a);
|
|
190
|
+
}
|
|
191
|
+
if ((runtime === "codex" || runtime === "kimi") && exitCode === 0 && !sentViaCrew && finalText) {
|
|
192
|
+
// force:兜底回帖锚定本轮触发消息的线程,语义上必须送达;不 force 时 agent(-p 单发不跑
|
|
193
|
+
// crew read)游标落后,回帖会被 freshness hold 成 draft(202)而永远不可见。
|
|
194
|
+
const sent = await sendAgentMessage(config.serverUrl, cred.token, input.channelId, {
|
|
195
|
+
content: finalText,
|
|
196
|
+
force: true,
|
|
197
|
+
...(input.wakeMessageId ? { thread: input.wakeMessageId } : {}),
|
|
198
|
+
});
|
|
199
|
+
if (sent.delivered) {
|
|
200
|
+
const a = { kind: "sending", label: "发消息", detail: `${runtime} final answer fallback` };
|
|
201
|
+
activities.push(a);
|
|
202
|
+
onActivity(a);
|
|
203
|
+
}
|
|
204
|
+
else if (sent.status === 202) {
|
|
205
|
+
process.stderr.write(`${runtime} fallback reply was held as draft (HTTP 202) — not visible in channel\n`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
process.stderr.write(`${runtime} fallback send failed (HTTP ${sent.status})\n`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// 5) 落盘 session(下次同任务可 --resume),并打印本轮 token 用量(度量 resume 真省与否)
|
|
212
|
+
if (config.resume && supportsNativeResume && sessionId) {
|
|
213
|
+
await writeSession(ws.runDir, {
|
|
214
|
+
sessionId,
|
|
215
|
+
lastRunAt: Date.now(),
|
|
216
|
+
turns: (prior?.turns ?? 0) + 1,
|
|
217
|
+
model: currentModel,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (usage) {
|
|
221
|
+
const u = usage;
|
|
222
|
+
process.stdout.write(`📊 tokens: in=${u.inputTokens} out=${u.outputTokens} cache_read=${u.cacheReadTokens} cache_create=${u.cacheCreationTokens}` +
|
|
223
|
+
`${u.costUsd != null ? ` cost=$${u.costUsd.toFixed(4)}` : ""} ${resuming ? "(resumed)" : "(fresh)"}\n`);
|
|
224
|
+
}
|
|
225
|
+
return { exitCode, activities, model: observedModel, runtime, resumed: resuming, ...(usage ? { usage } : {}) };
|
|
109
226
|
}
|
|
110
227
|
function defaultPrint(a) {
|
|
111
228
|
const icon = ICON[a.kind] ?? "·";
|
|
112
229
|
const detail = a.detail ? ` ${a.detail.replace(/\s+/g, " ").slice(0, 120)}` : "";
|
|
113
230
|
process.stdout.write(`${icon} ${a.label}${detail}\n`);
|
|
114
231
|
}
|
|
232
|
+
// daemon 侧兜底(server 已校验,这里防旧数据/绕过):只接受合法 key 的 string 值,CREW_ 前缀保留给系统。
|
|
233
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
234
|
+
export function sanitizeEnvVars(raw) {
|
|
235
|
+
if (!raw)
|
|
236
|
+
return {};
|
|
237
|
+
const out = {};
|
|
238
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
239
|
+
if (typeof v !== "string")
|
|
240
|
+
continue;
|
|
241
|
+
if (!ENV_KEY_RE.test(k) || k.toUpperCase().startsWith("CREW_"))
|
|
242
|
+
continue;
|
|
243
|
+
out[k] = v;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
/** exported for tests */
|
|
248
|
+
export async function sendAgentMessage(serverUrl, token, channelId, body) {
|
|
249
|
+
const res = await fetch(`${serverUrl}/agent/channels/${encodeURIComponent(channelId)}/messages`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: {
|
|
252
|
+
authorization: `Bearer ${token}`,
|
|
253
|
+
"content-type": "application/json",
|
|
254
|
+
},
|
|
255
|
+
body: JSON.stringify(body),
|
|
256
|
+
});
|
|
257
|
+
// 只有 201(sent)算送达;202 表示被 freshness hold 成 draft——消息没有出现在频道里,
|
|
258
|
+
// 不能当成功(把 202 当成功正是 kimi/codex fallback 回帖丢失的根因)。
|
|
259
|
+
return { delivered: res.status === 201, status: res.status };
|
|
260
|
+
}
|
|
@@ -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
|
|
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 = ${
|
|
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 = ` 💬 回复${
|
|
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 =
|
|
190
|
-
? `\n**所有回复都必须发到这条消息的线程里**(任务线程):用 crew message send --channel ${msg.channelId} --thread ${
|
|
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,20 +211,47 @@ export function serve(config, opts = {}) {
|
|
|
197
211
|
// 关键协作礼仪:一旦决定接手,**第一步就先在频道发一句简短确认**
|
|
198
212
|
// (例:"收到,我接 task #N。先做 X / 排查 Y,有结论再同步"),别让频道空着干等;
|
|
199
213
|
// 然后再开始读日志/跑命令。干完用 @下一棒 或 crew task assign 交接。
|
|
200
|
-
const sendCmd =
|
|
201
|
-
? `crew message send --channel ${msg.channelId} --thread ${
|
|
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 工具打开查看后再处理。`;
|
|
206
|
-
|
|
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}`;
|
|
225
|
+
const result = 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用
|
|
231
|
+
...(msg.wake?.content ? { wake: `你被唤醒(${msg.reason}): ${msg.wake.content}\n用 ${readCmd} 读${threadId ? "本线程" : "频道"}后按需处理。${reasonHint}${ackHint}${threadHint}${attHint}` } : {}),
|
|
213
232
|
}, reportActivity, reportConsole);
|
|
233
|
+
// 本轮 token 用量上报:runner 已从 result 事件提取(含缓存读/写细分),
|
|
234
|
+
// 连同模型/runtime 一起上送控制面落库 → 支撑每 agent / 每任务(线程)的用量监控与成本核算。
|
|
235
|
+
if (result.usage) {
|
|
236
|
+
const u = result.usage;
|
|
237
|
+
try {
|
|
238
|
+
ws?.send(JSON.stringify({
|
|
239
|
+
type: "agent:usage",
|
|
240
|
+
agentHandle: msg.agentHandle,
|
|
241
|
+
channelId: msg.channelId,
|
|
242
|
+
threadId: threadId ?? null, // 线程根消息 id(= 任务锚点);null = 频道级唤醒
|
|
243
|
+
runtime: result.runtime,
|
|
244
|
+
model: result.model,
|
|
245
|
+
resumed: result.resumed,
|
|
246
|
+
inputTokens: u.inputTokens,
|
|
247
|
+
outputTokens: u.outputTokens,
|
|
248
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
249
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
250
|
+
...(u.costUsd != null ? { costUsd: u.costUsd } : {}),
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
catch { /* ws 非 OPEN,忽略(用量非关键路径,丢一轮不阻塞) */ }
|
|
254
|
+
}
|
|
214
255
|
reportActivity({ kind: "done", label: "本轮结束" });
|
|
215
256
|
reportConsole({ stream: "result", text: "● 本轮结束" });
|
|
216
257
|
log(`✅ agent=${msg.agentHandle} 本轮完成`);
|
|
@@ -223,9 +264,16 @@ export function serve(config, opts = {}) {
|
|
|
223
264
|
releaseSlot(msg.agentHandle);
|
|
224
265
|
}
|
|
225
266
|
});
|
|
226
|
-
ws.on("close", () => {
|
|
267
|
+
ws.on("close", (code) => {
|
|
227
268
|
if (stopped)
|
|
228
269
|
return;
|
|
270
|
+
// 4001 = 控制面应用级「鉴权失败」关闭码(见 server control-plane.ts)。即使上面的
|
|
271
|
+
// error 帧因 close 抢先而丢失,也能据关闭码识别这是凭证失效——退避拉满,不再每秒热循环。
|
|
272
|
+
if (code === 4001) {
|
|
273
|
+
backoff = maxBackoff;
|
|
274
|
+
log(`🛑 控制面以鉴权失败关闭连接 (code 4001):机器凭证无效或已吊销。`);
|
|
275
|
+
log(` 请在 NowCrew 重新 "Add Computer" 获取新连接命令再重跑(当前 --api-key 已失效)。`);
|
|
276
|
+
}
|
|
229
277
|
log(`🔁 控制面断开,${Math.round(backoff / 1000)}s 后重连`);
|
|
230
278
|
setTimeout(connect, backoff);
|
|
231
279
|
backoff = Math.min(backoff * 2, maxBackoff);
|
package/dist/session.js
ADDED
|
@@ -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/workspace-import.js
CHANGED
|
@@ -71,7 +71,7 @@ export function stripActiveContext(md) {
|
|
|
71
71
|
}
|
|
72
72
|
const replacement = [
|
|
73
73
|
lines[start], // 原 `## Active Context` 标题
|
|
74
|
-
"<!-- (导入时已清空:raft 的在办任务/任务 id
|
|
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.2
|
|
3
|
+
"version": "0.4.2",
|
|
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.
|
|
21
|
+
"@nowcrew/cli": "^0.3.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/node": "^22.0.0",
|