@nowcrew/daemon 0.1.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/LICENSE +201 -0
- package/dist/config.js +33 -0
- package/dist/machine-info.js +55 -0
- package/dist/main.js +73 -0
- package/dist/normalize.js +66 -0
- package/dist/prompt.js +102 -0
- package/dist/runner.js +101 -0
- package/dist/runtimes/claude.js +27 -0
- package/dist/serve.js +223 -0
- package/dist/skills.js +56 -0
- package/dist/token.js +16 -0
- package/dist/workspace-fs.js +80 -0
- package/dist/workspace-import.js +127 -0
- package/dist/workspace.js +89 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 OpenSlock contributors
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** daemon 配置:从 env 读取,解析 crew CLI 路径 (注入给 agent 的 wrapper 用)。 */
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
export class ConfigError extends Error {
|
|
7
|
+
}
|
|
8
|
+
function defaultCliPath() {
|
|
9
|
+
// 已发布场景(npx):crew CLI 是 daemon 的依赖,从 node_modules 解析 @nowcrew/cli。
|
|
10
|
+
try {
|
|
11
|
+
return createRequire(import.meta.url).resolve("@nowcrew/cli/dist/main.js");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// 开发场景(monorepo):daemon/src → 上两级到 crew/,再进 cli/dist/main.js
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
return resolve(here, "../../cli/dist/main.js");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function loadConfig(env = process.env) {
|
|
20
|
+
const serverUrl = (env.CREW_SERVER_URL ?? "http://127.0.0.1:3000").replace(/\/+$/, "");
|
|
21
|
+
const machineToken = env.CREW_MACHINE_TOKEN ?? "";
|
|
22
|
+
if (!machineToken) {
|
|
23
|
+
throw new ConfigError("缺少 CREW_MACHINE_TOKEN (sk_machine_*,由 seed 打印)");
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
serverUrl,
|
|
27
|
+
machineToken,
|
|
28
|
+
agentsRoot: env.CREW_AGENTS_ROOT ?? resolve(homedir(), ".crew/agents"),
|
|
29
|
+
cliPath: env.CREW_CLI_PATH ?? defaultCliPath(),
|
|
30
|
+
runtimeBin: env.CREW_RUNTIME ?? "claude",
|
|
31
|
+
dangerous: env.CREW_RUNTIME_SAFE !== "1", // 默认开启 (headless agent 在自有 workspace 内运行)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 采集本机信息上报给控制面 (machine:hello):hostname / os / daemon 版本 / 已装 runtimes。
|
|
3
|
+
* runtimes 探测靠 `which <bin>`,只报真实可执行的 CLI(用于展示 Detected Runtimes)。
|
|
4
|
+
*/
|
|
5
|
+
import { hostname, arch, platform } from "node:os";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
const execFileP = promisify(execFile);
|
|
12
|
+
/** 候选 runtime CLI:展示名 → 可执行文件名。 */
|
|
13
|
+
const RUNTIME_BINS = [
|
|
14
|
+
["claude", "claude"],
|
|
15
|
+
["codex", "codex"],
|
|
16
|
+
["cursor", "cursor-agent"],
|
|
17
|
+
["gemini", "gemini"],
|
|
18
|
+
["opencode", "opencode"],
|
|
19
|
+
["copilot", "copilot"],
|
|
20
|
+
["kimi", "kimi"],
|
|
21
|
+
["pi", "pi"],
|
|
22
|
+
];
|
|
23
|
+
async function isInstalled(bin) {
|
|
24
|
+
try {
|
|
25
|
+
await execFileP("which", [bin]);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** 并发探测所有候选 runtime,返回已安装的展示名列表。 */
|
|
33
|
+
export async function detectRuntimes() {
|
|
34
|
+
const checks = await Promise.all(RUNTIME_BINS.map(async ([name, bin]) => ((await isInstalled(bin)) ? name : null)));
|
|
35
|
+
return checks.filter((x) => x !== null);
|
|
36
|
+
}
|
|
37
|
+
function daemonVersion() {
|
|
38
|
+
try {
|
|
39
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const pkg = JSON.parse(readFileSync(resolve(here, "../package.json"), "utf8"));
|
|
41
|
+
return pkg.version ?? "0.0.0";
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "0.0.0";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function collectMachineHello() {
|
|
48
|
+
return {
|
|
49
|
+
type: "machine:hello",
|
|
50
|
+
hostname: hostname(),
|
|
51
|
+
os: `${platform()} ${arch()}`,
|
|
52
|
+
daemonVersion: daemonVersion(),
|
|
53
|
+
runtimes: await detectRuntimes(),
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* crew-daemon 入口 (M3b 当前形态:本地手动唤醒一个 agent 跑一轮)。
|
|
4
|
+
* crew-daemon run --agent <handle> --channel <id> [--wake "<text>"] [--display "<name>"]
|
|
5
|
+
*
|
|
6
|
+
* 后续 (M3c):常驻 + 连 server 控制面 WS,由 agent:start 自动唤醒。
|
|
7
|
+
*/
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { loadConfig, ConfigError } from "./config.js";
|
|
10
|
+
import { runAgent } from "./runner.js";
|
|
11
|
+
import { serve } from "./serve.js";
|
|
12
|
+
async function main() {
|
|
13
|
+
const { values, positionals } = parseArgs({
|
|
14
|
+
args: process.argv.slice(2),
|
|
15
|
+
allowPositionals: true,
|
|
16
|
+
options: {
|
|
17
|
+
agent: { type: "string" },
|
|
18
|
+
channel: { type: "string" },
|
|
19
|
+
wake: { type: "string" },
|
|
20
|
+
display: { type: "string" },
|
|
21
|
+
// 连接参数(也可用 CREW_SERVER_URL / CREW_MACHINE_TOKEN 环境变量)
|
|
22
|
+
"server-url": { type: "string" },
|
|
23
|
+
"api-key": { type: "string" },
|
|
24
|
+
token: { type: "string" }, // --api-key 的别名
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
// 无子命令时默认 serve(对齐 `npx @nowcrew/daemon@latest --server-url ... --api-key ...`)
|
|
28
|
+
const cmd = positionals[0] ?? "serve";
|
|
29
|
+
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");
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
// 命令行参数优先于环境变量,填回 env 供 loadConfig 读取
|
|
34
|
+
if (values["server-url"])
|
|
35
|
+
process.env.CREW_SERVER_URL = values["server-url"];
|
|
36
|
+
const apiKey = values["api-key"] ?? values.token;
|
|
37
|
+
if (apiKey)
|
|
38
|
+
process.env.CREW_MACHINE_TOKEN = apiKey;
|
|
39
|
+
let config;
|
|
40
|
+
try {
|
|
41
|
+
config = loadConfig();
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (e instanceof ConfigError) {
|
|
45
|
+
process.stderr.write(e.message + "\n");
|
|
46
|
+
process.exit(3);
|
|
47
|
+
}
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
if (cmd === "serve") {
|
|
51
|
+
process.stdout.write(`\n🛰️ crew-daemon 常驻,连接 ${config.serverUrl} 控制面...\n`);
|
|
52
|
+
serve(config);
|
|
53
|
+
await new Promise(() => { }); // 常驻,直到被 kill
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!values.agent || !values.channel) {
|
|
57
|
+
process.stderr.write("用法: crew-daemon run --agent <handle> --channel <id> [--wake ...]\n");
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
process.stdout.write(`\n🚀 唤醒 agent "${values.agent}" 处理频道 ${values.channel}\n\n`);
|
|
61
|
+
const result = await runAgent(config, {
|
|
62
|
+
handle: values.agent,
|
|
63
|
+
channelId: values.channel,
|
|
64
|
+
...(values.wake ? { wake: values.wake } : {}),
|
|
65
|
+
...(values.display ? { displayName: values.display } : {}),
|
|
66
|
+
});
|
|
67
|
+
process.stdout.write(`\n— agent 退出 (code ${result.exitCode}),共 ${result.activities.length} 个活动 —\n`);
|
|
68
|
+
process.exit(result.exitCode);
|
|
69
|
+
}
|
|
70
|
+
main().catch((e) => {
|
|
71
|
+
process.stderr.write(`crew-daemon: ${e.message}\n`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把 coding-agent runtime 的 stream-json 事件归一化为**语义活动**。
|
|
3
|
+
*
|
|
4
|
+
* 这是 daemon 的关键职责:前端/server 看到的是"在读历史/领任务/发消息",而非裸 Bash。
|
|
5
|
+
* 纯函数,无 IO,完整单测。
|
|
6
|
+
*/
|
|
7
|
+
/** 把一条 Bash 命令归类为语义活动 (仅看 crew 子命令)。 */
|
|
8
|
+
export function classifyCommand(command) {
|
|
9
|
+
const c = command.trim();
|
|
10
|
+
if (/^crew\s+message\s+read\b/.test(c))
|
|
11
|
+
return { kind: "reading", label: "读历史", detail: c };
|
|
12
|
+
if (/^crew\s+message\s+send\b/.test(c))
|
|
13
|
+
return { kind: "sending", label: "发消息", detail: c };
|
|
14
|
+
if (/^crew\s+message\s+check\b/.test(c))
|
|
15
|
+
return { kind: "checking", label: "查未读", detail: c };
|
|
16
|
+
if (/^crew\s+task\s+claim\b/.test(c))
|
|
17
|
+
return { kind: "claiming", label: "领任务", detail: c };
|
|
18
|
+
if (/^crew\b/.test(c))
|
|
19
|
+
return { kind: "crew", label: "crew 命令", detail: c };
|
|
20
|
+
return { kind: "tool", label: "执行命令", detail: c };
|
|
21
|
+
}
|
|
22
|
+
/** 把一个 stream-json 事件归一化为 0..N 个活动。 */
|
|
23
|
+
export function normalizeEvent(event) {
|
|
24
|
+
const e = (event ?? {});
|
|
25
|
+
if (e.type === "system" && e.subtype === "init") {
|
|
26
|
+
return [{ kind: "init", label: "agent 启动" }];
|
|
27
|
+
}
|
|
28
|
+
if (e.type === "result") {
|
|
29
|
+
return e.is_error
|
|
30
|
+
? [{ kind: "error", label: "运行出错", ...(e.result ? { detail: e.result } : {}) }]
|
|
31
|
+
: [{ kind: "done", label: "本轮结束", ...(e.result ? { detail: e.result } : {}) }];
|
|
32
|
+
}
|
|
33
|
+
if (e.type === "assistant" && Array.isArray(e.message?.content)) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const block of e.message.content) {
|
|
36
|
+
if (block.type === "text" && block.text?.trim()) {
|
|
37
|
+
out.push({ kind: "text", label: "思考/说明", detail: block.text.trim() });
|
|
38
|
+
}
|
|
39
|
+
else if (block.type === "tool_use" && block.name === "Bash" && block.input?.command) {
|
|
40
|
+
out.push(classifyCommand(block.input.command));
|
|
41
|
+
}
|
|
42
|
+
else if (block.type === "tool_use" && block.name) {
|
|
43
|
+
out.push({ kind: "tool", label: `工具:${block.name}` });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
if (e.type === "user" && Array.isArray(e.message?.content)) {
|
|
49
|
+
const hasResult = e.message.content.some((b) => b.type === "tool_result");
|
|
50
|
+
if (hasResult)
|
|
51
|
+
return [{ kind: "tool_result", label: "工具返回" }];
|
|
52
|
+
}
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
/** 解析一行 ndjson;非法行返回 null。 */
|
|
56
|
+
export function parseLine(line) {
|
|
57
|
+
const t = line.trim();
|
|
58
|
+
if (!t)
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(t);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统提示词与唤醒提示词构造(OpenSlock 自有设计)。
|
|
3
|
+
*
|
|
4
|
+
* 描述的是 OpenSlock 自身的 crew CLI 命令面与运行约定:crew-only 通信、一命令一调用、
|
|
5
|
+
* claim-before-work、freshness/draft、"做完所有事再停"、inbox notice 语义、
|
|
6
|
+
* 分层记忆与压缩安全、协作礼仪。措辞为本项目原创。
|
|
7
|
+
*/
|
|
8
|
+
export function buildSystemPrompt(ctx) {
|
|
9
|
+
return `你是 "${ctx.handle}",OpenSlock(一个让人类与 AI agent 协作的共享工作区)中的 AI 成员。OpenSlock 为可能运行在不同机器上的人与 agent 提供共享的消息服务。
|
|
10
|
+
|
|
11
|
+
## 你是谁
|
|
12
|
+
你的 workspace 和 MEMORY.md 跨会话保留,被唤醒时可恢复上下文。你会被启动、空闲时休眠、有人给你发消息时再次唤醒。把自己当成一位始终在线、随时间积累知识、通过交互形成专长的同事——而不是一次性聊天机器人。
|
|
13
|
+
|
|
14
|
+
## 当前运行时上下文(由 OpenSlock 注入,权威)
|
|
15
|
+
- Handle: ${ctx.handle}${ctx.agentId ? `\n- Agent ID: ${ctx.agentId}` : ""}
|
|
16
|
+
- 你被唤醒处理的频道: ${ctx.channelId}
|
|
17
|
+
- **你的 cwd 是本任务的隔离工作目录**(代码检出/构建/草稿都放这里;你可能同时有多个并行运行,各自 cwd 独立,互不干扰)。
|
|
18
|
+
- **共享持久记忆 home: \`$CREW_HOME\`**${ctx.homeDir ? `(${ctx.homeDir})` : ""}——MEMORY.md 与 notes/ 在这里,跨你的所有任务共享。
|
|
19
|
+
|
|
20
|
+
## 并行与记忆(CRITICAL — 防冲突 + 防上下文膨胀)
|
|
21
|
+
你**可能同时在处理多个任务**(你自己的多个并行进程)。为避免互相覆盖、并保持上下文精简,严格遵守:
|
|
22
|
+
- **只写两处**:① 本任务的隔离 cwd(代码/草稿);② 本任务的工作日志 **\`$CREW_TASK_LOG\`**(每任务一份,别人不会碰)。在工作日志里记:当前阶段 / 下一步 / 卡在谁 / 关键结论(每条尽量 ≤3 行)。
|
|
23
|
+
- **任务进行中不要改 \`$CREW_HOME/MEMORY.md\` 或 notes/**(那是共享文件,多个并行运行同时写会冲突)。这些只读参考;沉淀经验留到任务关闭时由系统触发的收尾来做。
|
|
24
|
+
- **跨任务协调看实时清单**:用 \`crew task list --mine\` 查你当前所有任务及其状态(谁在排队、谁在评审)。例:你一次只能测一个页面时,据此判断"正在测 #1、#2 排队",在 #2 的工作日志里标注"等 #1 完成"。
|
|
25
|
+
- 需要更多背景时再 \`Read $CREW_HOME/notes/<topic>.md\`;不要一次把所有笔记读进上下文。
|
|
26
|
+
|
|
27
|
+
## 通信 —— 只能用 crew CLI
|
|
28
|
+
所有 chat / task 操作必须经 \`crew\` CLI(daemon 已把它注入你的 PATH)。仅可使用以下命令:
|
|
29
|
+
1. **\`crew whoami\`** —— 查看你自己的身份。
|
|
30
|
+
2. **\`crew message read --channel <id>\`** —— 读频道历史(读取即自动推进你的已读/新鲜度游标)。支持 \`--after <seq>\` / \`--limit <n>\`。
|
|
31
|
+
3. **\`crew message check --channel <id>\`** —— 非阻塞查看未读数。工作中可在自然断点随时用。
|
|
32
|
+
4. **\`crew message send --channel <id>\`** —— 发消息。正文从 stdin 读,用 heredoc 避免 shell 解释引号/反引号/代码块:
|
|
33
|
+
\`\`\`bash
|
|
34
|
+
crew message send --channel <id> <<'CREWMSG'
|
|
35
|
+
你的消息正文,可含 "引号"、\\\`反引号\\\`、代码块。
|
|
36
|
+
CREWMSG
|
|
37
|
+
\`\`\`
|
|
38
|
+
也可用 \`--content "<短正文>"\`。线程内回复:加 \`--thread <msg码>\`(msg 码来自 read/search 输出里的 \`msg=\` 前 8 位)。
|
|
39
|
+
5. **\`crew task list --channel <id>\`** —— 看任务板。支持 \`--status <s>\` / \`--mine\`。
|
|
40
|
+
6. **\`crew task create --channel <id> --title "<标题>"\`** —— 新建任务(把一件事登记成可追踪的工作项)。
|
|
41
|
+
7. **\`crew task claim <taskId>\`** —— 认领任务(动手前必做)。
|
|
42
|
+
8. **\`crew task update <taskId> --status <in_progress|in_review|done>\`** —— 推进任务状态。
|
|
43
|
+
9. **\`crew task unclaim <taskId>\`** —— 释放认领,把任务让给别人。
|
|
44
|
+
10. **\`crew task assign <taskId> --to <handle>\`** —— 把任务指派/交接给另一个 agent(用于交接,见下)。
|
|
45
|
+
|
|
46
|
+
CLI 成功时打印人类可读文本到 stdout;失败时 stderr 给出错误,并用**退出码**告诉你下一步:
|
|
47
|
+
- \`4\` = 任务已被他人认领 / 不可认领 → 停手,别抢,转做别的。
|
|
48
|
+
- \`6\` = freshness hold(目标有你没看过的新消息)→ 先 \`crew message read\` 再重试。
|
|
49
|
+
- \`3\` = 鉴权失败;\`5\` = 目标不存在;\`2\` = 参数错误。
|
|
50
|
+
|
|
51
|
+
CRITICAL 规则:
|
|
52
|
+
- **始终只通过 crew CLI 发声。在 crew 命令之外产生的任何文字都不会送达任何人。**
|
|
53
|
+
- **一个 shell 命令只跑一个 crew 命令**,读完它的输出,再决定下一条。不要把多个 crew 命令串到一行。
|
|
54
|
+
- **动手干活前必须先 \`crew task claim\`**;claim 失败就转做别的任务。
|
|
55
|
+
|
|
56
|
+
## 启动序列
|
|
57
|
+
1. 若本轮已带具体来信,先判断是否需要立即确认/提问/声明接手;需要就先用 \`crew message send\` 发出,再去深挖上下文。
|
|
58
|
+
2. 读 cwd 下的 MEMORY.md,以及处理本轮所需的其它笔记。
|
|
59
|
+
3. 若本轮只有"有未读"的 inbox notice、没有正文:notice 表示存在你尚未看到的消息(正文被暂时省略以免刷屏,不是没有内容)。是否读、何时读由你判断,可用 \`crew message check\` / \`crew message read\` 拉取。**绝不能仅凭一条 content-free notice 就断定"没有工作"**;若选择暂不读,要诚实当作 defer。
|
|
60
|
+
4. 收到消息就处理,并用 \`crew message send\` 回复。
|
|
61
|
+
5. **做完你负责的所有事再停。** 多步任务(调研/改码/测试)要全部完成、汇报结果后再停。新消息会在你存活期间自动送达,无需轮询等待。
|
|
62
|
+
|
|
63
|
+
## 消息与任务
|
|
64
|
+
- 你读到的消息形如 \`#<seq> [<type>] <sender>: <正文>\`,\`type\` 为 \`human\` / \`agent\` / \`system\`。
|
|
65
|
+
- **\`system\` 消息**通报频道状态变化(如新建任务),除非明确要求你行动(如刚给你指派了任务),否则不要回复。
|
|
66
|
+
- **判定规则**:若满足来信需要你"回复之外的动作"(跑工具/改代码/做变更),先 claim;若只是回答问题或闲聊,无需 claim。
|
|
67
|
+
- 任务状态流:\`todo → in_progress → in_review → done\`。claim 后用 \`crew task update\` 推进:开工→in_progress、完成待验收→in_review、人类确认后→done。只有 assignee 能改自己任务的状态。
|
|
68
|
+
- **交接(handoff)**:当你这一环干完、需要别的角色接手时(如开发完成 → 交给 QA 测试),用 \`crew task assign <taskId> --to <下家handle>\` 把任务交接出去,并在线程里给下家足够背景(分支名 / 改动摘要 / 测试建议)。交接后对方会被自动唤醒。**别让任务停在你手里无人跟进**。
|
|
69
|
+
- **分诊(若你是总管)**:若你收到「【分诊请求】」唤醒,说明频道里有一个无人认领的任务需要你按团队职责分派。唤醒内容里已附上团队成员及其职责:判断谁最合适,用 \`crew task assign <taskId> --to <handle>\` 指派给他(若该你自己做就 \`crew task claim\`);确实没人合适时,在频道里 @发起人 说明并给建议,**不要让任务悬空**。
|
|
70
|
+
- **freshness/draft**:发送若被保存为 draft(kind=held),要么重读后用普通 send 改写,要么用 \`crew message send --send-draft\` 原样发出(不要在改内容时用 --send-draft)。
|
|
71
|
+
|
|
72
|
+
## 协作礼仪
|
|
73
|
+
- **尊重正在进行的对话**:人类正与他人一来一回时,除非明确 @你或显然在叫你,否则不要插话。
|
|
74
|
+
- **只有真正干活的人来汇报**:别替别人总结或冒领他们的工作。
|
|
75
|
+
- **claim 后再动手**:claim 失败立即停手,换一个任务。
|
|
76
|
+
- **停止前检查你欠的具体阻塞项**:若你还欠某人一个 handoff/review/决定/回复且正卡着对方,先发一条最小可行动消息再停。
|
|
77
|
+
- **少发废话**:只在有可行动内容时发消息,不要播报"我在等/我空闲"。
|
|
78
|
+
|
|
79
|
+
## 沟通风格
|
|
80
|
+
用户看不到你的内部推理,所以:收到任务先确认并简述计划;多步工作发简短进度("正在做 2/3…");完成后总结结果。每条一两句,别刷屏。
|
|
81
|
+
|
|
82
|
+
## Workspace 与分层记忆(CRITICAL — 索引+按需,配合上面的并行规则)
|
|
83
|
+
你的持久记忆在 \`$CREW_HOME\`(跨你所有任务共享),分三层:
|
|
84
|
+
1. **MEMORY.md = 索引 + 角色**:指向你全部知识的目录。系统每次已把它(截断索引版)注入到本提示词末尾,**通常不必再去读整文件**;要某领域明细时再 \`Read $CREW_HOME/notes/<file>.md\`。别一次把所有 notes 读进上下文。
|
|
85
|
+
2. **notes/<domain>.md = 明细**:领域知识、用户偏好、频道说明、经验教训(\`notes/lessons.md\`)。**任务进行中只读不写**(多个并行运行同时写会冲突)。
|
|
86
|
+
3. **本任务 work-log = \`$CREW_TASK_LOG\`**:每任务一份(别的运行不会碰),记当前阶段/下一步/卡点/关键结论。系统已把它的当前内容注入末尾让你续上;有进展就更新它——这是你**唯一**该在任务中持续写的记忆文件。
|
|
87
|
+
|
|
88
|
+
**沉淀与瘦身由系统触发,不要平时做**:任务被改为 done/closed 时,系统会单独唤醒你收尾——那时才把可复用经验从 work-log 提炼进 \`notes/lessons.md\`、更新 MEMORY.md 索引、并清掉该任务的 work-log。平时别动 MEMORY.md/notes,既防并行冲突,也防 MEMORY.md 膨胀。
|
|
89
|
+
|
|
90
|
+
**为什么(CRITICAL)**:上下文会被周期压缩;你的恢复力来自——注入的 MEMORY.md 索引(始终精简)+ 本任务 work-log + 按需读 notes,而**不是**把一切堆进 MEMORY.md。
|
|
91
|
+
${ctx.memory ? `\n## [注入] 你的 MEMORY.md(索引,只读参考)\n${ctx.memory}` : ""}${ctx.workLog ? `\n## [注入] 本任务 work-log(续上进度;更新写到 $CREW_TASK_LOG)\n${ctx.workLog}` : ""}`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 唤醒提示词(OpenSlock 自有设计)。默认"只读补课"模式:先把频道读一遍补齐上下文,
|
|
95
|
+
* 只有发现确实指向自己的事才转为主动处理,否则读完即停、不发声。
|
|
96
|
+
*/
|
|
97
|
+
export function buildWakePrompt(channelId) {
|
|
98
|
+
return `先用 \`crew message read --channel ${channelId}\` 把这个频道读一遍,补齐上下文。
|
|
99
|
+
读完判断:其中是否有明确落到你头上的事——点名找你、@你、指派给你、请你评审,或交给你的任务。
|
|
100
|
+
- 有:转入主动处理。相关任务先 \`crew task claim <taskId>\` 认领再动手,完成后用 \`crew message send --channel ${channelId}\` 回复。
|
|
101
|
+
- 没有:本轮什么都不要发,读完即停。你存活期间有新消息会自动送来,无需轮询。`;
|
|
102
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent 运行编排:换令牌 → 备 workspace → spawn runtime → 归一化事件流。
|
|
3
|
+
*/
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { writeFile } from "node:fs/promises";
|
|
6
|
+
import { delimiter, join } from "node:path";
|
|
7
|
+
import { mintAgentToken } from "./token.js";
|
|
8
|
+
import { prepareWorkspace } from "./workspace.js";
|
|
9
|
+
import { buildSystemPrompt, buildWakePrompt } from "./prompt.js";
|
|
10
|
+
import { spawnClaude } from "./runtimes/claude.js";
|
|
11
|
+
import { normalizeEvent, parseLine } from "./normalize.js";
|
|
12
|
+
// 注入提示词的 MEMORY.md 上限:只喂索引/角色,避免把膨胀的记忆全塞进上下文。
|
|
13
|
+
const MEMORY_INJECT_CAP = 6000;
|
|
14
|
+
const ICON = {
|
|
15
|
+
init: "🟢", text: "💬", reading: "📖", sending: "📨", checking: "🔎",
|
|
16
|
+
claiming: "📌", crew: "⚙️", tool: "🛠️", tool_result: "↩️", done: "✅", error: "❌",
|
|
17
|
+
};
|
|
18
|
+
export async function runAgent(config, input, onActivity = defaultPrint) {
|
|
19
|
+
// 1) 用机器令牌换 per-launch agent 令牌
|
|
20
|
+
const cred = await mintAgentToken(config.serverUrl, config.machineToken, input.handle, input.displayName);
|
|
21
|
+
// 2) 准备 workspace(共享 home + 本任务隔离 cwd + per-task work-log)。
|
|
22
|
+
// 先 prepare 拿到 memory/workLog,再 build 系统提示词(注入记忆索引)写入。
|
|
23
|
+
const ws = await prepareWorkspace({
|
|
24
|
+
agentsRoot: config.agentsRoot,
|
|
25
|
+
handle: input.handle,
|
|
26
|
+
cliPath: config.cliPath,
|
|
27
|
+
...(input.taskKey ? { taskKey: input.taskKey } : {}),
|
|
28
|
+
});
|
|
29
|
+
const systemPrompt = buildSystemPrompt({
|
|
30
|
+
handle: input.handle,
|
|
31
|
+
channelId: input.channelId,
|
|
32
|
+
agentId: cred.agentId,
|
|
33
|
+
homeDir: ws.dir,
|
|
34
|
+
// 只注入 MEMORY.md 的索引/角色部分(截断),避免上下文膨胀;明细让 agent 按需读 notes/。
|
|
35
|
+
memory: ws.memory.length > MEMORY_INJECT_CAP
|
|
36
|
+
? ws.memory.slice(0, MEMORY_INJECT_CAP) + "\n…(MEMORY.md 过长已截断,详情用 Read 读 $CREW_HOME/MEMORY.md 或 notes/)"
|
|
37
|
+
: ws.memory,
|
|
38
|
+
workLog: ws.workLog,
|
|
39
|
+
});
|
|
40
|
+
await writeFile(ws.systemPromptPath, systemPrompt, "utf8");
|
|
41
|
+
// 3) spawn runtime,注入 PATH(crew wrapper)、凭证 env、以及 agent 运行时配置
|
|
42
|
+
// provider=custom → BYOC(ANTHROPIC_BASE_URL/API_KEY);reasoning → 思考预算;model → --model
|
|
43
|
+
const cfg = cred.config ?? {};
|
|
44
|
+
const REASONING_TOKENS = { low: "4000", medium: "10000", high: "31999" };
|
|
45
|
+
const child = spawnClaude({
|
|
46
|
+
bin: config.runtimeBin,
|
|
47
|
+
cwd: ws.runDir, // 本任务隔离工作目录(并行运行互不干扰)
|
|
48
|
+
systemPromptPath: ws.systemPromptPath,
|
|
49
|
+
wakePrompt: input.wake ?? buildWakePrompt(input.channelId),
|
|
50
|
+
dangerous: config.dangerous,
|
|
51
|
+
...(cfg.model ? { model: cfg.model } : {}),
|
|
52
|
+
env: {
|
|
53
|
+
...process.env,
|
|
54
|
+
PATH: `${ws.crewDir}${delimiter}${process.env.PATH ?? ""}`,
|
|
55
|
+
CREW_SERVER_URL: config.serverUrl,
|
|
56
|
+
CREW_TOKEN: cred.token,
|
|
57
|
+
CREW_CHANNEL: input.channelId,
|
|
58
|
+
// 共享持久记忆 home(MEMORY.md/notes 在此;cwd 是本任务隔离目录)+ 本任务 work-log 路径
|
|
59
|
+
CREW_HOME: ws.dir,
|
|
60
|
+
CREW_TASK_LOG: ws.workLogPath,
|
|
61
|
+
// per-agent 凭证隔离:XDG 指向本 agent 独立目录(gh/gcloud 等 CLI 的 token 不互相串)。
|
|
62
|
+
// 不覆盖 HOME(否则会破坏 claude 自身的 ~/.claude 鉴权);常用 CLI 也单独点名隔离。
|
|
63
|
+
XDG_CONFIG_HOME: join(ws.homeDir, ".config"),
|
|
64
|
+
XDG_DATA_HOME: join(ws.homeDir, ".local", "share"),
|
|
65
|
+
XDG_CACHE_HOME: join(ws.homeDir, ".cache"),
|
|
66
|
+
GH_CONFIG_DIR: join(ws.homeDir, ".config", "gh"),
|
|
67
|
+
CLOUDSDK_CONFIG: join(ws.homeDir, ".config", "gcloud"),
|
|
68
|
+
// provider custom = BYOC:为该 agent 单独设置 Anthropic 端点/密钥
|
|
69
|
+
...(cfg.provider === "custom" && cfg.providerBaseUrl ? { ANTHROPIC_BASE_URL: cfg.providerBaseUrl } : {}),
|
|
70
|
+
...(cfg.provider === "custom" && cfg.providerApiKey ? { ANTHROPIC_API_KEY: cfg.providerApiKey } : {}),
|
|
71
|
+
// reasoning → 思考预算 (claude 读 MAX_THINKING_TOKENS)
|
|
72
|
+
...(cfg.reasoning && cfg.reasoning !== "default" && REASONING_TOKENS[cfg.reasoning]
|
|
73
|
+
? { MAX_THINKING_TOKENS: REASONING_TOKENS[cfg.reasoning] }
|
|
74
|
+
: {}),
|
|
75
|
+
// fast 模式 → 透传给 runtime(best-effort,供 wrapper/runtime 读取)
|
|
76
|
+
...(cfg.fastMode ? { CREW_FAST_MODE: "1" } : {}),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
// 4) 逐行解析 stdout → 归一化 → 回调
|
|
80
|
+
const activities = [];
|
|
81
|
+
const rl = createInterface({ input: child.stdout });
|
|
82
|
+
rl.on("line", (line) => {
|
|
83
|
+
const evt = parseLine(line);
|
|
84
|
+
if (!evt)
|
|
85
|
+
return;
|
|
86
|
+
for (const a of normalizeEvent(evt)) {
|
|
87
|
+
activities.push(a);
|
|
88
|
+
onActivity(a);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
92
|
+
const exitCode = await new Promise((resolve) => {
|
|
93
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
94
|
+
});
|
|
95
|
+
return { exitCode, activities };
|
|
96
|
+
}
|
|
97
|
+
function defaultPrint(a) {
|
|
98
|
+
const icon = ICON[a.kind] ?? "·";
|
|
99
|
+
const detail = a.detail ? ` ${a.detail.replace(/\s+/g, " ").slice(0, 120)}` : "";
|
|
100
|
+
process.stdout.write(`${icon} ${a.label}${detail}\n`);
|
|
101
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code runtime 适配:print + stream-json 模式,headless 驱动。
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
export function buildClaudeArgs(input) {
|
|
6
|
+
const args = [
|
|
7
|
+
"--print",
|
|
8
|
+
"--verbose",
|
|
9
|
+
"--output-format",
|
|
10
|
+
"stream-json",
|
|
11
|
+
"--append-system-prompt-file",
|
|
12
|
+
input.systemPromptPath,
|
|
13
|
+
];
|
|
14
|
+
if (input.model)
|
|
15
|
+
args.push("--model", input.model);
|
|
16
|
+
if (input.dangerous)
|
|
17
|
+
args.push("--dangerously-skip-permissions");
|
|
18
|
+
args.push(input.wakePrompt);
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
export function spawnClaude(input) {
|
|
22
|
+
return spawn(input.bin, buildClaudeArgs(input), {
|
|
23
|
+
cwd: input.cwd,
|
|
24
|
+
env: input.env,
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
}
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 常驻模式 (M3c):连接 server 控制面 WS,注册机器,监听 agent:start 自动唤醒 agent。
|
|
3
|
+
* 断线指数退避重连。一个 agent:start 进来就跑一次现有的 runAgent。
|
|
4
|
+
*/
|
|
5
|
+
import { WebSocket } from "ws";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { runAgent } from "./runner.js";
|
|
8
|
+
import { collectMachineHello } from "./machine-info.js";
|
|
9
|
+
import { listWorkspace, readWorkspaceFile } from "./workspace-fs.js";
|
|
10
|
+
import { listSkills } from "./skills.js";
|
|
11
|
+
import { inspectRaftWorkspace, importRaftWorkspace } from "./workspace-import.js";
|
|
12
|
+
// normalize.ts 的活动种类 → activity 枚举
|
|
13
|
+
const ACTIVITY_MAP = {
|
|
14
|
+
init: "working", text: "thinking", reading: "reading", sending: "sending",
|
|
15
|
+
checking: "checking", claiming: "claiming", crew: "working", tool: "working",
|
|
16
|
+
tool_result: "working", done: "done", error: "error",
|
|
17
|
+
};
|
|
18
|
+
export function serve(config, opts = {}) {
|
|
19
|
+
const wsUrl = config.serverUrl.replace(/^http/, "ws") +
|
|
20
|
+
`/daemon/connect?key=${encodeURIComponent(config.machineToken)}`;
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let ws = null;
|
|
23
|
+
let backoff = 1000;
|
|
24
|
+
const maxBackoff = opts.maxBackoffMs ?? 30_000;
|
|
25
|
+
// 并行调度:同一 agent 可并行处理多个【不同任务】(线程/频道),每任务隔离 cwd+work-log。
|
|
26
|
+
// - running:正在跑的「agent:任务」去重键(同一任务重复唤醒才跳过)。
|
|
27
|
+
// - agentSlots:每 agent 当前并行数;超过 MAX_PARALLEL 的进 FIFO 队列(不丢)。
|
|
28
|
+
const MAX_PARALLEL = Number(process.env.CREW_MAX_PARALLEL) || 4;
|
|
29
|
+
const running = new Set();
|
|
30
|
+
const agentSlots = new Map();
|
|
31
|
+
const waitQueue = new Map();
|
|
32
|
+
const acquireSlot = async (handle) => {
|
|
33
|
+
const n = agentSlots.get(handle) ?? 0;
|
|
34
|
+
if (n < MAX_PARALLEL) {
|
|
35
|
+
agentSlots.set(handle, n + 1);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await new Promise((resolve) => {
|
|
39
|
+
const q = waitQueue.get(handle) ?? [];
|
|
40
|
+
q.push(resolve);
|
|
41
|
+
waitQueue.set(handle, q);
|
|
42
|
+
});
|
|
43
|
+
agentSlots.set(handle, (agentSlots.get(handle) ?? 0) + 1);
|
|
44
|
+
};
|
|
45
|
+
const releaseSlot = (handle) => {
|
|
46
|
+
agentSlots.set(handle, Math.max(0, (agentSlots.get(handle) ?? 1) - 1));
|
|
47
|
+
const q = waitQueue.get(handle);
|
|
48
|
+
if (q && q.length)
|
|
49
|
+
(q.shift())();
|
|
50
|
+
};
|
|
51
|
+
const log = (s) => process.stdout.write(s + "\n");
|
|
52
|
+
function connect() {
|
|
53
|
+
if (stopped)
|
|
54
|
+
return;
|
|
55
|
+
ws = new WebSocket(wsUrl);
|
|
56
|
+
ws.on("open", () => {
|
|
57
|
+
backoff = 1000;
|
|
58
|
+
log(`🔌 已连接控制面 ${config.serverUrl}`);
|
|
59
|
+
// 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
|
|
60
|
+
void collectMachineHello()
|
|
61
|
+
.then((hello) => {
|
|
62
|
+
try {
|
|
63
|
+
ws?.send(JSON.stringify(hello));
|
|
64
|
+
log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · runtimes=[${hello.runtimes.join(",")}]`);
|
|
65
|
+
}
|
|
66
|
+
catch { /* 非 OPEN,忽略 */ }
|
|
67
|
+
})
|
|
68
|
+
.catch(() => { });
|
|
69
|
+
opts.onOpen?.(ws);
|
|
70
|
+
});
|
|
71
|
+
ws.on("message", async (data) => {
|
|
72
|
+
let msg;
|
|
73
|
+
try {
|
|
74
|
+
msg = JSON.parse(data.toString());
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// 导入 raft agent 工作区:inspect 反填 name/description;import 复制用户内容
|
|
80
|
+
if (msg.type === "raft:inspect" || msg.type === "raft:import") {
|
|
81
|
+
const req = msg;
|
|
82
|
+
const reply = (r) => {
|
|
83
|
+
try {
|
|
84
|
+
ws?.send(JSON.stringify({ type: "fs:result", reqId: req.reqId, ...r }));
|
|
85
|
+
}
|
|
86
|
+
catch { /* 忽略 */ }
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
const data2 = req.type === "raft:inspect"
|
|
90
|
+
? await inspectRaftWorkspace(req.path)
|
|
91
|
+
: await importRaftWorkspace(req.path, join(config.agentsRoot, req.handle));
|
|
92
|
+
if (req.type === "raft:import") {
|
|
93
|
+
log(`📦 已导入 raft 工作区 → ${req.handle}: 复制 ${data2.copied.length} 项`);
|
|
94
|
+
}
|
|
95
|
+
reply({ ok: true, data: data2 });
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
reply({ ok: false, error: e.message });
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// workspace 文件浏览 / skills 枚举请求 (只读、沙箱;见 workspace-fs.ts / skills.ts)
|
|
103
|
+
if (msg.type === "fs:list" || msg.type === "fs:read" || msg.type === "skills:list") {
|
|
104
|
+
const req = msg;
|
|
105
|
+
const root = join(config.agentsRoot, req.handle);
|
|
106
|
+
const reply = (r) => {
|
|
107
|
+
try {
|
|
108
|
+
ws?.send(JSON.stringify({ type: "fs:result", reqId: req.reqId, ...r }));
|
|
109
|
+
}
|
|
110
|
+
catch { /* 忽略 */ }
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
const data2 = req.type === "fs:list" ? await listWorkspace(root, req.path)
|
|
114
|
+
: req.type === "fs:read" ? await readWorkspaceFile(root, req.path)
|
|
115
|
+
: await listSkills(config.agentsRoot, req.handle);
|
|
116
|
+
reply({ ok: true, data: data2 });
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
reply({ ok: false, error: e.message });
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (msg.type !== "agent:start")
|
|
124
|
+
return; // ready/error 等忽略
|
|
125
|
+
// 任务键 = 线程锚点(≈任务)优先,否则频道。同一 agent 的不同任务可并行;同一任务重复唤醒才跳过。
|
|
126
|
+
const threadId = msg.wake?.threadId;
|
|
127
|
+
const taskKey = threadId ?? msg.channelId;
|
|
128
|
+
const key = `${msg.agentHandle}:${taskKey}`;
|
|
129
|
+
if (running.has(key)) {
|
|
130
|
+
log(`↩︎ 跳过(该任务已在运行): ${key}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
running.add(key);
|
|
134
|
+
// 并行槽:同 agent 超过 MAX_PARALLEL 个任务时在此排队(不丢),有空位再跑。
|
|
135
|
+
await acquireSlot(msg.agentHandle);
|
|
136
|
+
const threadCode = threadId ? threadId.slice(0, 8) : null;
|
|
137
|
+
const from = msg.wake?.senderHandle ?? "?";
|
|
138
|
+
const incoming = msg.wake?.content ?? "";
|
|
139
|
+
log(`\n${"─".repeat(56)}`);
|
|
140
|
+
log(`🔔 唤醒 agent=${msg.agentHandle} reason=${msg.reason ?? "?"}`);
|
|
141
|
+
log(` channel = ${msg.channelId}`);
|
|
142
|
+
log(` thread = ${threadCode ? `${threadCode} (要求线程内回复)` : "(无,顶层回复)"}`);
|
|
143
|
+
if (incoming)
|
|
144
|
+
log(`📥 来信 @${from}: ${incoming.replace(/\s+/g, " ").slice(0, 200)}`);
|
|
145
|
+
let actSeq = 0;
|
|
146
|
+
const reportActivity = (a) => {
|
|
147
|
+
const det = a.detail ? a.detail.replace(/\s+/g, " ").trim() : "";
|
|
148
|
+
// 发消息时尽量打印回复正文/目标(--content "..." 或 heredoc 首行)
|
|
149
|
+
let line = ` · ${a.label}`;
|
|
150
|
+
if (a.kind === "sending") {
|
|
151
|
+
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)}`;
|
|
153
|
+
}
|
|
154
|
+
else if (det) {
|
|
155
|
+
line += ` ${det.slice(0, 80)}`;
|
|
156
|
+
}
|
|
157
|
+
log(line);
|
|
158
|
+
try {
|
|
159
|
+
ws?.send(JSON.stringify({
|
|
160
|
+
type: "agent:activity",
|
|
161
|
+
agentHandle: msg.agentHandle,
|
|
162
|
+
channelId: msg.channelId,
|
|
163
|
+
activity: ACTIVITY_MAP[a.kind] ?? "working",
|
|
164
|
+
detail: a.detail || a.label,
|
|
165
|
+
seq: actSeq++,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
catch { /* ws 非 OPEN,忽略 */ }
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
// 线程聚合:触发消息即任务线程根,你的确认+后续所有回复都要发到它的线程里,
|
|
172
|
+
// 不要发顶层——这样 task 讨论全部聚合在该 thread 下。
|
|
173
|
+
const threadHint = threadCode
|
|
174
|
+
? `\n**所有回复都必须发到这条消息的线程里**(任务线程):用 crew message send --channel ${msg.channelId} --thread ${threadCode} 发送,不要发频道顶层。`
|
|
175
|
+
: "";
|
|
176
|
+
// channel(广播投递):你是频道成员之一,自己判断是否与你职责相关——相关才行动(回复 /
|
|
177
|
+
// crew task create / claim / 交接给下一棒),不相关就不回(频道沉默不算失败,避免人人都答)。
|
|
178
|
+
const reasonHint = msg.reason === "channel"
|
|
179
|
+
? `\n这是频道里的新消息(广播给频道成员)。**先判断是否属于你的职责**:与你无关就直接结束、不要回复(频道沉默不算失败);相关才接手。`
|
|
180
|
+
: "";
|
|
181
|
+
// 关键协作礼仪:一旦决定接手,**第一步就先在频道发一句简短确认**
|
|
182
|
+
// (例:"收到,我接 task #N。先做 X / 排查 Y,有结论再同步"),别让频道空着干等;
|
|
183
|
+
// 然后再开始读日志/跑命令。干完用 @下一棒 或 crew task assign 交接。
|
|
184
|
+
const sendCmd = threadCode
|
|
185
|
+
? `crew message send --channel ${msg.channelId} --thread ${threadCode}`
|
|
186
|
+
: `crew message send --channel ${msg.channelId}`;
|
|
187
|
+
const ackHint = `\n**协作礼仪:决定接手后,务必先用 \`${sendCmd}\` 在该任务线程发一句简短确认**(收到 + 我接 task #N + 接下来要做什么),再开始干活——不要闷头工作把线程空着。`;
|
|
188
|
+
// 图片/文件附件:crew message read 会在消息下列出附件及其 id;图片需下载后用 Read 工具查看,才能真正"看到"内容。
|
|
189
|
+
const attHint = `\n若消息带图片/文件附件(read 会列出 id),用 \`crew attachment get <id>\` 下载到本地,图片再用 Read 工具打开查看后再处理。`;
|
|
190
|
+
await runAgent(config, {
|
|
191
|
+
handle: msg.agentHandle,
|
|
192
|
+
channelId: msg.channelId,
|
|
193
|
+
taskKey, // 每任务隔离 cwd + work-log(并行不冲突)
|
|
194
|
+
...(msg.wake?.content ? { wake: `你被唤醒(${msg.reason}): ${msg.wake.content}\n用 crew message read --channel ${msg.channelId} 读频道后按需处理。${reasonHint}${ackHint}${threadHint}${attHint}` } : {}),
|
|
195
|
+
}, reportActivity);
|
|
196
|
+
reportActivity({ kind: "done", label: "本轮结束" });
|
|
197
|
+
log(`✅ agent=${msg.agentHandle} 本轮完成`);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
log(`❌ runAgent 失败: ${e.message}`);
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
running.delete(key);
|
|
204
|
+
releaseSlot(msg.agentHandle);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
ws.on("close", () => {
|
|
208
|
+
if (stopped)
|
|
209
|
+
return;
|
|
210
|
+
log(`🔁 控制面断开,${Math.round(backoff / 1000)}s 后重连`);
|
|
211
|
+
setTimeout(connect, backoff);
|
|
212
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
213
|
+
});
|
|
214
|
+
ws.on("error", () => ws?.close());
|
|
215
|
+
}
|
|
216
|
+
connect();
|
|
217
|
+
return {
|
|
218
|
+
stop: () => {
|
|
219
|
+
stopped = true;
|
|
220
|
+
ws?.close();
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 枚举某 agent 可用的 skill,供 Profile 的 SKILLS 区展示。两个来源:
|
|
3
|
+
* - workspace 私有:<agentsRoot>/<handle>/skills/<name>/SKILL.md
|
|
4
|
+
* - 全局共享: ~/.claude/skills/<name>/SKILL.md (可经 CREW_GLOBAL_SKILLS_DIR 覆盖)
|
|
5
|
+
*
|
|
6
|
+
* 每个 skill = 一个目录,内含 SKILL.md;从其 YAML frontmatter 取 name/description,
|
|
7
|
+
* 缺失则回退到目录名。只读、容错(目录不存在/无 frontmatter 都不报错)。
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
const globalSkillsDir = () => process.env.CREW_GLOBAL_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
|
|
13
|
+
/** 从 SKILL.md 顶部 YAML frontmatter 取 name / description (简易解析,够用)。 */
|
|
14
|
+
function parseFrontmatter(md) {
|
|
15
|
+
const m = md.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
16
|
+
if (!m)
|
|
17
|
+
return {};
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const line of m[1].split("\n")) {
|
|
20
|
+
const kv = line.match(/^(name|description)\s*:\s*(.+?)\s*$/);
|
|
21
|
+
if (kv) {
|
|
22
|
+
const val = kv[2].replace(/^["']|["']$/g, "");
|
|
23
|
+
if (kv[1] === "name")
|
|
24
|
+
out.name = val;
|
|
25
|
+
else
|
|
26
|
+
out.description = val;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
async function readSkillsFrom(dir, scope) {
|
|
32
|
+
const names = await readdir(dir).catch(() => []);
|
|
33
|
+
const skills = [];
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
if (name.startsWith("."))
|
|
36
|
+
continue;
|
|
37
|
+
const skillMd = join(dir, name, "SKILL.md");
|
|
38
|
+
const s = await stat(skillMd).catch(() => null);
|
|
39
|
+
if (!s || !s.isFile())
|
|
40
|
+
continue;
|
|
41
|
+
const md = await readFile(skillMd, "utf8").catch(() => "");
|
|
42
|
+
const fm = parseFrontmatter(md);
|
|
43
|
+
skills.push({ scope, name: fm.name || name, description: fm.description ?? "" });
|
|
44
|
+
}
|
|
45
|
+
return skills;
|
|
46
|
+
}
|
|
47
|
+
/** 列出 agent 的 workspace 私有 + 全局 skill,按 name 排序。 */
|
|
48
|
+
export async function listSkills(agentsRoot, handle) {
|
|
49
|
+
const [ws, global] = await Promise.all([
|
|
50
|
+
readSkillsFrom(join(agentsRoot, handle, "skills"), "workspace"),
|
|
51
|
+
readSkillsFrom(globalSkillsDir(), "global"),
|
|
52
|
+
]);
|
|
53
|
+
const all = [...ws, ...global];
|
|
54
|
+
all.sort((a, b) => (a.scope !== b.scope ? (a.scope === "workspace" ? -1 : 1) : a.name.localeCompare(b.name)));
|
|
55
|
+
return all;
|
|
56
|
+
}
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** 用机器令牌为某 agent 换取 per-launch 的 sk_agent_*。 */
|
|
2
|
+
export async function mintAgentToken(serverUrl, machineToken, handle, displayName, fetchImpl = fetch) {
|
|
3
|
+
const res = await fetchImpl(`${serverUrl}/daemon/agents/token`, {
|
|
4
|
+
method: "POST",
|
|
5
|
+
headers: {
|
|
6
|
+
authorization: `Bearer ${machineToken}`,
|
|
7
|
+
"content-type": "application/json",
|
|
8
|
+
},
|
|
9
|
+
body: JSON.stringify(displayName ? { handle, displayName } : { handle }),
|
|
10
|
+
});
|
|
11
|
+
const body = (await res.json().catch(() => null));
|
|
12
|
+
if (!res.ok || !body?.success || !body.data) {
|
|
13
|
+
throw new Error(`换取 agent 令牌失败 (HTTP ${res.status}): ${body?.error?.message ?? ""}`);
|
|
14
|
+
}
|
|
15
|
+
return body.data;
|
|
16
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 只读、沙箱化地暴露某 agent 工作区 (<agentsRoot>/<handle>/) 给控制面查看。
|
|
3
|
+
*
|
|
4
|
+
* 安全铁律:
|
|
5
|
+
* - 所有路径都 resolve 后必须仍落在 agent 根内 (防 `..` / 符号链接逃逸)。
|
|
6
|
+
* - 隐藏内部目录与点文件 (.crew / .git / 任何 . 开头)。
|
|
7
|
+
* - 只回文本文件,二进制拒绝;单文件大小上限,超出截断。
|
|
8
|
+
* - 仅 list / read,无写删。
|
|
9
|
+
*/
|
|
10
|
+
import { realpath, readdir, readFile, stat } from "node:fs/promises";
|
|
11
|
+
import { join, resolve, sep } from "node:path";
|
|
12
|
+
export class FsError extends Error {
|
|
13
|
+
}
|
|
14
|
+
const MAX_FILE_BYTES = 256 * 1024; // 单文件查看上限 256KB
|
|
15
|
+
const HIDDEN = new Set([".crew", ".git", ".slock"]);
|
|
16
|
+
const isHidden = (name) => name.startsWith(".") || HIDDEN.has(name);
|
|
17
|
+
/** 把相对路径安全解析到 root 内的绝对路径;越界抛错。realpath 兜底符号链接逃逸。 */
|
|
18
|
+
async function safeResolve(root, rel) {
|
|
19
|
+
const rootResolved = await realpath(root);
|
|
20
|
+
const target = resolve(rootResolved, rel.replace(/^\/+/, ""));
|
|
21
|
+
const withSep = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
|
|
22
|
+
if (target !== rootResolved && !target.startsWith(withSep)) {
|
|
23
|
+
throw new FsError("path escapes workspace");
|
|
24
|
+
}
|
|
25
|
+
// 解析真实路径再校验一次 (符号链接)
|
|
26
|
+
let real;
|
|
27
|
+
try {
|
|
28
|
+
real = await realpath(target);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return target; // 不存在的路径交给后续 stat 报错
|
|
32
|
+
}
|
|
33
|
+
if (real !== rootResolved && !real.startsWith(withSep)) {
|
|
34
|
+
throw new FsError("path escapes workspace (symlink)");
|
|
35
|
+
}
|
|
36
|
+
return real;
|
|
37
|
+
}
|
|
38
|
+
/** 列出某目录 (相对 root)。隐藏点文件/内部目录;目录在前、按名排序。 */
|
|
39
|
+
export async function listWorkspace(root, rel) {
|
|
40
|
+
// 工作区尚未创建 (agent 还没跑过):返回空,而不是报错
|
|
41
|
+
const rootExists = await stat(root).then((s) => s.isDirectory()).catch(() => false);
|
|
42
|
+
if (!rootExists)
|
|
43
|
+
return { root: resolve(root), path: rel.replace(/^\/+/, ""), entries: [] };
|
|
44
|
+
const rootResolved = await realpath(root);
|
|
45
|
+
const dir = await safeResolve(root, rel);
|
|
46
|
+
const st = await stat(dir).catch(() => null);
|
|
47
|
+
if (!st || !st.isDirectory())
|
|
48
|
+
throw new FsError("not a directory");
|
|
49
|
+
const names = await readdir(dir);
|
|
50
|
+
const entries = [];
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
if (isHidden(name))
|
|
53
|
+
continue;
|
|
54
|
+
const full = join(dir, name);
|
|
55
|
+
const s = await stat(full).catch(() => null);
|
|
56
|
+
if (!s)
|
|
57
|
+
continue;
|
|
58
|
+
if (s.isDirectory())
|
|
59
|
+
entries.push({ name, type: "dir" });
|
|
60
|
+
else if (s.isFile())
|
|
61
|
+
entries.push({ name, type: "file", size: s.size });
|
|
62
|
+
}
|
|
63
|
+
entries.sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name));
|
|
64
|
+
return { root: rootResolved, path: rel.replace(/^\/+/, ""), entries };
|
|
65
|
+
}
|
|
66
|
+
/** 读取某文本文件 (相对 root)。二进制/超大拒绝或截断。 */
|
|
67
|
+
export async function readWorkspaceFile(root, rel) {
|
|
68
|
+
const file = await safeResolve(root, rel);
|
|
69
|
+
const s = await stat(file).catch(() => null);
|
|
70
|
+
if (!s || !s.isFile())
|
|
71
|
+
throw new FsError("not a file");
|
|
72
|
+
const buf = await readFile(file);
|
|
73
|
+
// 二进制探测:含 NUL 即视为二进制
|
|
74
|
+
const sample = buf.subarray(0, Math.min(buf.length, 8192));
|
|
75
|
+
if (sample.includes(0))
|
|
76
|
+
throw new FsError("binary file not viewable");
|
|
77
|
+
const truncated = buf.length > MAX_FILE_BYTES;
|
|
78
|
+
const content = buf.subarray(0, MAX_FILE_BYTES).toString("utf8");
|
|
79
|
+
return { path: rel.replace(/^\/+/, ""), content, truncated, size: buf.length };
|
|
80
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导入一个已有的 raft agent 工作区到本机的 crew 工作区。
|
|
3
|
+
*
|
|
4
|
+
* raft 工作区目录(~/.slock/agents/<uuid>/)里:
|
|
5
|
+
* - 用户内容(复制):MEMORY.md、notes/、artifacts/、incoming/、*.html 等非隐藏文件
|
|
6
|
+
* - raft 内部(不复制,crew 自己重建):.git/、.slock/(系统提示词/mcp/CLI wrapper)
|
|
7
|
+
*
|
|
8
|
+
* crew 侧的唯一 id、隐藏的 .crew/(system-prompt + crew wrapper)由 prepareWorkspace 在
|
|
9
|
+
* 首次运行时生成,DB 行另起新 uuid —— 所以这里只搬运用户内容,内部一律重建。
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, writeFile, readdir, stat, cp, mkdir } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
/** 一律跳过的隐藏/内部条目(其余 dotfile 也跳过)。 */
|
|
15
|
+
const SKIP = new Set([".git", ".slock", ".crew", ".DS_Store"]);
|
|
16
|
+
/** 展开开头的 `~` 为用户主目录(daemon 在被测机器上跑,路径里常带 ~)。 */
|
|
17
|
+
export function expandHome(p) {
|
|
18
|
+
if (p === "~")
|
|
19
|
+
return homedir();
|
|
20
|
+
if (p.startsWith("~/"))
|
|
21
|
+
return join(homedir(), p.slice(2));
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
/** 从 MEMORY.md 反填:H1 → name;`## Role` 段正文 → description(无 Role 则取 H1 后首段)。 */
|
|
25
|
+
export function parseMemory(md) {
|
|
26
|
+
const lines = md.split(/\r?\n/);
|
|
27
|
+
let name = "";
|
|
28
|
+
let h1Idx = -1;
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const m = lines[i].match(/^#\s+(.+?)\s*$/);
|
|
31
|
+
if (m) {
|
|
32
|
+
name = m[1].trim();
|
|
33
|
+
h1Idx = i;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const sectionBody = (startIdx) => {
|
|
38
|
+
const body = [];
|
|
39
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
40
|
+
if (/^#{1,6}\s+/.test(lines[i]))
|
|
41
|
+
break; // 下一个标题为止
|
|
42
|
+
body.push(lines[i]);
|
|
43
|
+
}
|
|
44
|
+
return body.join("\n").trim();
|
|
45
|
+
};
|
|
46
|
+
let description = "";
|
|
47
|
+
const roleIdx = lines.findIndex((l) => /^##\s+Role\b/i.test(l));
|
|
48
|
+
if (roleIdx >= 0)
|
|
49
|
+
description = sectionBody(roleIdx + 1);
|
|
50
|
+
if (!description && h1Idx >= 0)
|
|
51
|
+
description = sectionBody(h1Idx + 1); // 兜底:H1 后首段
|
|
52
|
+
return { name, description: description.slice(0, 3000) };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 导入时清空 MEMORY.md 的 `## Active Context` 段:其内容是 raft 里的在办任务进度(含任务 id),
|
|
56
|
+
* 这些 id 在新的 OpenSlock 里不一定存在,照搬会误导。保留标题,正文换成占位提示让 agent 重新记录。
|
|
57
|
+
* 其它段(Role/索引/领域知识)照常保留。
|
|
58
|
+
*/
|
|
59
|
+
export function stripActiveContext(md) {
|
|
60
|
+
const lines = md.split(/\r?\n/);
|
|
61
|
+
const start = lines.findIndex((l) => /^##\s+Active Context\b/i.test(l));
|
|
62
|
+
if (start < 0)
|
|
63
|
+
return md;
|
|
64
|
+
// 段落结束 = 下一个 # / ## 标题,或 `---` 分隔线,或文件末尾
|
|
65
|
+
let end = lines.length;
|
|
66
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
67
|
+
if (/^#{1,2}\s+/.test(lines[i]) || /^---\s*$/.test(lines[i])) {
|
|
68
|
+
end = i;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const replacement = [
|
|
73
|
+
lines[start], // 原 `## Active Context` 标题
|
|
74
|
+
"<!-- (导入时已清空:raft 的在办任务/任务 id 不属于本 OpenSlock。开工时在此重新记录当前进度。) -->",
|
|
75
|
+
"",
|
|
76
|
+
];
|
|
77
|
+
return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join("\n");
|
|
78
|
+
}
|
|
79
|
+
async function assertDir(srcPath) {
|
|
80
|
+
let st;
|
|
81
|
+
try {
|
|
82
|
+
st = await stat(srcPath);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error(`workspace path not found: ${srcPath}`);
|
|
86
|
+
}
|
|
87
|
+
if (!st.isDirectory())
|
|
88
|
+
throw new Error(`not a directory: ${srcPath}`);
|
|
89
|
+
}
|
|
90
|
+
/** 读取 raft 工作区元信息(name/description/可复制条目),不写入任何文件。 */
|
|
91
|
+
export async function inspectRaftWorkspace(rawPath) {
|
|
92
|
+
const srcPath = expandHome(rawPath);
|
|
93
|
+
await assertDir(srcPath);
|
|
94
|
+
let memory;
|
|
95
|
+
try {
|
|
96
|
+
memory = await readFile(join(srcPath, "MEMORY.md"), "utf8");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new Error("no MEMORY.md in this folder — is it a raft agent workspace?");
|
|
100
|
+
}
|
|
101
|
+
const { name, description } = parseMemory(memory);
|
|
102
|
+
const all = await readdir(srcPath);
|
|
103
|
+
const entries = all.filter((e) => !SKIP.has(e) && !e.startsWith("."));
|
|
104
|
+
return { name, description, fileCount: entries.length, entries };
|
|
105
|
+
}
|
|
106
|
+
/** 复制 raft 工作区的用户内容到目标目录(隐藏/内部条目不复制)。 */
|
|
107
|
+
export async function importRaftWorkspace(rawPath, destDir) {
|
|
108
|
+
const srcPath = expandHome(rawPath);
|
|
109
|
+
await assertDir(srcPath);
|
|
110
|
+
await mkdir(destDir, { recursive: true });
|
|
111
|
+
const all = await readdir(srcPath);
|
|
112
|
+
const copied = [];
|
|
113
|
+
for (const e of all) {
|
|
114
|
+
if (SKIP.has(e) || e.startsWith("."))
|
|
115
|
+
continue;
|
|
116
|
+
if (e === "MEMORY.md") {
|
|
117
|
+
// MEMORY.md 特殊处理:剥离 Active Context(陈旧任务 id)后再落地,其余原样保留。
|
|
118
|
+
const cleaned = stripActiveContext(await readFile(join(srcPath, e), "utf8"));
|
|
119
|
+
await writeFile(join(destDir, e), cleaned, "utf8");
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
await cp(join(srcPath, e), join(destDir, e), { recursive: true });
|
|
123
|
+
}
|
|
124
|
+
copied.push(e);
|
|
125
|
+
}
|
|
126
|
+
return { copied, dir: destDir };
|
|
127
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 准备 agent 在本机的工作区:
|
|
3
|
+
* <agentsRoot>/<handle>/
|
|
4
|
+
* ├── MEMORY.md 长期记忆/角色 (首次创建种子)
|
|
5
|
+
* ├── .crew/system-prompt.md 注入 runtime 的系统提示词
|
|
6
|
+
* └── .crew/crew 注入 PATH 的 CLI wrapper (0755)
|
|
7
|
+
*
|
|
8
|
+
* spawn runtime 时把 <ws>/.crew 置于 PATH 最前,于是 agent 的 `crew ...` 命中 wrapper。
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, writeFile, readFile, chmod, access } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
/** 文件系统安全的 taskKey:仅留 [\w.-],其余转 _,截断,避免路径穿越/超长。 */
|
|
13
|
+
export function safeKey(key) {
|
|
14
|
+
return key.replace(/[^\w.-]+/g, "_").replace(/^[-.]+/, "").slice(0, 80) || "default";
|
|
15
|
+
}
|
|
16
|
+
async function exists(p) {
|
|
17
|
+
try {
|
|
18
|
+
await access(p);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function prepareWorkspace(input) {
|
|
26
|
+
const dir = join(input.agentsRoot, input.handle);
|
|
27
|
+
const crewDir = join(dir, ".crew");
|
|
28
|
+
await mkdir(crewDir, { recursive: true });
|
|
29
|
+
// MEMORY.md:首次创建"索引 + Active Context"骨架,之后由 agent 自己维护
|
|
30
|
+
const memoryPath = join(dir, "MEMORY.md");
|
|
31
|
+
if (!(await exists(memoryPath))) {
|
|
32
|
+
await writeFile(memoryPath, memorySeed(input.handle), "utf8");
|
|
33
|
+
}
|
|
34
|
+
const memory = await readFile(memoryPath, "utf8");
|
|
35
|
+
// 系统提示词 (每次覆盖,保证最新);省略时由调用方在拿到 memory 后自行写入。
|
|
36
|
+
const systemPromptPath = join(crewDir, "system-prompt.md");
|
|
37
|
+
if (input.systemPrompt != null)
|
|
38
|
+
await writeFile(systemPromptPath, input.systemPrompt, "utf8");
|
|
39
|
+
// crew wrapper:exec 构建后的 CLI;凭证/地址经 env 注入,故 wrapper 极简
|
|
40
|
+
const wrapper = join(crewDir, "crew");
|
|
41
|
+
await writeFile(wrapper, `#!/bin/sh\nexec node ${JSON.stringify(input.cliPath)} "$@"\n`, "utf8");
|
|
42
|
+
await chmod(wrapper, 0o755);
|
|
43
|
+
// 每个 agent 独立的 XDG 配置根:隔离第三方 CLI 凭证,避免 agent 之间互相串号
|
|
44
|
+
const homeDir = join(dir, ".home");
|
|
45
|
+
await mkdir(join(homeDir, ".config"), { recursive: true });
|
|
46
|
+
await mkdir(join(homeDir, ".local", "share"), { recursive: true });
|
|
47
|
+
await mkdir(join(homeDir, ".cache"), { recursive: true });
|
|
48
|
+
// 每任务隔离的运行目录(并行运行互不干扰的 cwd)+ per-task work-log。
|
|
49
|
+
// 无 taskKey(罕见)时回退到共享 dir(单运行旧行为)。
|
|
50
|
+
let runDir = dir;
|
|
51
|
+
let workLogPath = join(dir, "tasks", "default", "work-log.md");
|
|
52
|
+
if (input.taskKey) {
|
|
53
|
+
runDir = join(dir, "tasks", safeKey(input.taskKey));
|
|
54
|
+
await mkdir(runDir, { recursive: true });
|
|
55
|
+
workLogPath = join(runDir, "work-log.md");
|
|
56
|
+
}
|
|
57
|
+
const workLog = (await exists(workLogPath)) ? await readFile(workLogPath, "utf8") : "";
|
|
58
|
+
return { dir, crewDir, systemPromptPath, memory, homeDir, runDir, workLogPath, workLog };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* MEMORY.md 种子骨架:分层记忆的"索引 + Active Context"结构。
|
|
62
|
+
* 第一次准备工作区时写入;之后 agent 自己维护(系统提示词里有协议)。
|
|
63
|
+
*/
|
|
64
|
+
function memorySeed(handle) {
|
|
65
|
+
return `# ${handle}
|
|
66
|
+
|
|
67
|
+
## Role
|
|
68
|
+
(Who you are and what you're responsible for. Keep it to a few lines.)
|
|
69
|
+
|
|
70
|
+
## Core Goals
|
|
71
|
+
(What you optimize for.)
|
|
72
|
+
|
|
73
|
+
## Knowledge Index
|
|
74
|
+
<!-- The map to your detailed notes. Add a line here whenever you create a note. -->
|
|
75
|
+
<!-- e.g. - [Channels](notes/channels.md) — what each channel is for -->
|
|
76
|
+
<!-- e.g. - [User Preferences](notes/user-preferences.md) — conventions to follow -->
|
|
77
|
+
|
|
78
|
+
## Active Context
|
|
79
|
+
<!-- Running log of what you're doing now: in-progress tasks, next steps, what you owe whom.
|
|
80
|
+
Write an entry here BEFORE a long task so you can resume after sleep/compaction. -->
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
How to maintain this file:
|
|
84
|
+
- This is the FIRST file read on every startup (including after context compaction).
|
|
85
|
+
- Keep it self-sufficient: reading only this file should tell you who you are, what you
|
|
86
|
+
know (and which note to read for detail), what you're working on, and what you owe.
|
|
87
|
+
- Put details in notes/<topic>.md; keep this file an index + active context.
|
|
88
|
+
`;
|
|
89
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nowcrew/daemon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"bin": {
|
|
8
|
+
"crew-daemon": "./dist/main.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"ws": "^8",
|
|
21
|
+
"@nowcrew/cli": "^0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"@types/ws": "^8",
|
|
26
|
+
"tsx": "^4.19.0",
|
|
27
|
+
"typescript": "^5.6.0",
|
|
28
|
+
"vitest": "^2.1.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"daemon": "tsx src/main.ts",
|
|
32
|
+
"build": "tsc -p tsconfig.json",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"typecheck": "tsc --noEmit"
|
|
35
|
+
}
|
|
36
|
+
}
|