cofluxd 2.3.0 → 2.4.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/cofluxd.mjs +12 -35
- package/executor/host.js +933 -0
- package/executor/runner.js +380 -0
- package/package.json +14 -3
- package/service-unit.mjs +106 -0
- package/skills/coflux/SKILL.md +31 -13
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// ../executor/src/runner.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
// ../executor/src/guard.ts
|
|
7
|
+
var PATH_FIELDS = {
|
|
8
|
+
read: ["path", "filePath", "file"],
|
|
9
|
+
write: ["path", "filePath", "file"],
|
|
10
|
+
edit: ["path", "filePath", "file"],
|
|
11
|
+
ls: ["path", "dir", "directory"],
|
|
12
|
+
grep: ["path", "dir", "directory"],
|
|
13
|
+
find: ["path", "dir", "directory"]
|
|
14
|
+
};
|
|
15
|
+
var MUTATING_TOOLS = /* @__PURE__ */ new Set(["write", "edit"]);
|
|
16
|
+
function isInsideWorkspace(workspaceRoot, candidate) {
|
|
17
|
+
if (!candidate.startsWith("/")) return false;
|
|
18
|
+
const root = normalizeSegments(workspaceRoot);
|
|
19
|
+
const target = normalizeSegments(candidate);
|
|
20
|
+
if (target.length < root.length) return false;
|
|
21
|
+
return root.every((segment, index) => target[index] === segment);
|
|
22
|
+
}
|
|
23
|
+
function normalizeSegments(path) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const segment of path.split("/")) {
|
|
26
|
+
if (segment === "" || segment === ".") continue;
|
|
27
|
+
if (segment === "..") {
|
|
28
|
+
out.pop();
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
out.push(segment);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function collectPaths(toolName, input) {
|
|
36
|
+
if (!input || typeof input !== "object") return [];
|
|
37
|
+
const fields = PATH_FIELDS[toolName];
|
|
38
|
+
if (!fields) return [];
|
|
39
|
+
const record = input;
|
|
40
|
+
const paths = [];
|
|
41
|
+
for (const field of fields) {
|
|
42
|
+
const value = record[field];
|
|
43
|
+
if (typeof value === "string" && value.length > 0) paths.push(value);
|
|
44
|
+
}
|
|
45
|
+
return paths;
|
|
46
|
+
}
|
|
47
|
+
function guardToolCall({ toolName, input, workspaceRoot, writable }) {
|
|
48
|
+
if (!writable && MUTATING_TOOLS.has(toolName)) {
|
|
49
|
+
return {
|
|
50
|
+
block: true,
|
|
51
|
+
reason: `\u8FD9\u662F\u53EA\u8BFB\u6A21\u5F0F\u7684 executor \u4EFB\u52A1\uFF0C\u4E0D\u80FD\u7528 ${toolName} \u6539\u6587\u4EF6\u3002\u53EA\u505A\u8C03\u67E5\u4E0E\u62A5\u544A\uFF0C\u628A\u7ED3\u8BBA\u5199\u8FDB\u6700\u7EC8\u56DE\u590D\u3002`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
for (const path of collectPaths(toolName, input)) {
|
|
55
|
+
if (!path.startsWith("/")) {
|
|
56
|
+
return {
|
|
57
|
+
block: true,
|
|
58
|
+
reason: `${toolName} \u7684\u8DEF\u5F84\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u6536\u5230 ${path}\uFF09\uFF1B\u5DE5\u4F5C\u533A\u6839\u662F ${workspaceRoot}`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (!isInsideWorkspace(workspaceRoot, path)) {
|
|
62
|
+
return {
|
|
63
|
+
block: true,
|
|
64
|
+
reason: `${path} \u5728\u5DE5\u4F5C\u533A\u5916\u3002\u8FD9\u4E2A executor \u4EFB\u52A1\u88AB\u9650\u5236\u5728 ${workspaceRoot} \u4E4B\u5185\uFF0C\u5DE5\u4F5C\u533A\u5916\u7684\u6587\u4EF6\u8BFB\u4E0D\u5230\u4E5F\u6539\u4E0D\u4E86\u3002`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ../executor/src/sandbox.ts
|
|
72
|
+
function sandboxArgv(profilePath, shell, command) {
|
|
73
|
+
return ["/usr/bin/sandbox-exec", "-f", profilePath, shell, "-c", command];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ../executor/src/runner-protocol.ts
|
|
77
|
+
var EXECUTOR_RUNNER_EXIT = {
|
|
78
|
+
ok: 0,
|
|
79
|
+
/** Could not start (pi failed to load, the model configuration is unusable, ...). */
|
|
80
|
+
startupFailed: 10,
|
|
81
|
+
/** Wound itself down after receiving an abort. */
|
|
82
|
+
aborted: 11
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ../executor/src/runner.ts
|
|
86
|
+
var parentPort = process.parentPort;
|
|
87
|
+
function send(message) {
|
|
88
|
+
if (parentPort) parentPort.postMessage(message);
|
|
89
|
+
else process.send?.(message);
|
|
90
|
+
}
|
|
91
|
+
function onHostMessage(listener) {
|
|
92
|
+
if (parentPort) parentPort.on("message", (event) => listener(event.data));
|
|
93
|
+
else process.on("message", (value) => listener(value));
|
|
94
|
+
}
|
|
95
|
+
var transcriptSeq = 0;
|
|
96
|
+
function transcript(kind, text) {
|
|
97
|
+
if (!text) return;
|
|
98
|
+
send({ type: "transcript", seq: ++transcriptSeq, kind, text });
|
|
99
|
+
}
|
|
100
|
+
var liveGroups = /* @__PURE__ */ new Set();
|
|
101
|
+
function killGroup(pgid, signal) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(-pgid, signal);
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function stopAllGroups() {
|
|
108
|
+
for (const pgid of liveGroups) killGroup(pgid, "SIGTERM");
|
|
109
|
+
if (liveGroups.size === 0) return;
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
111
|
+
for (const pgid of liveGroups) killGroup(pgid, "SIGKILL");
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
113
|
+
}
|
|
114
|
+
function sandboxedBashOperations(start) {
|
|
115
|
+
return {
|
|
116
|
+
exec: (command, cwd, options) => {
|
|
117
|
+
const [file, ...args] = sandboxArgv(start.sandboxProfilePath, start.shell, command);
|
|
118
|
+
const child = spawn(file, args, {
|
|
119
|
+
cwd: start.workspaceRoot,
|
|
120
|
+
detached: true,
|
|
121
|
+
// Its own process group, so the whole group can be killed.
|
|
122
|
+
env: {
|
|
123
|
+
...options.env,
|
|
124
|
+
// The scratch dir, not the system /tmp: that holds other tasks and shared sockets, and the
|
|
125
|
+
// profile does not allow it anyway.
|
|
126
|
+
TMPDIR: start.scratchDir,
|
|
127
|
+
// The credential is never handed down to a tool process.
|
|
128
|
+
ANTHROPIC_API_KEY: void 0,
|
|
129
|
+
OPENAI_API_KEY: void 0,
|
|
130
|
+
GEMINI_API_KEY: void 0,
|
|
131
|
+
COFLUX_EXECUTOR_RUN_ID: start.runId
|
|
132
|
+
},
|
|
133
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
134
|
+
});
|
|
135
|
+
const pgid = child.pid;
|
|
136
|
+
if (typeof pgid === "number") liveGroups.add(pgid);
|
|
137
|
+
child.stdout?.on("data", (chunk) => options.onData(chunk));
|
|
138
|
+
child.stderr?.on("data", (chunk) => options.onData(chunk));
|
|
139
|
+
const onAbort = () => {
|
|
140
|
+
if (typeof pgid === "number") killGroup(pgid, "SIGTERM");
|
|
141
|
+
};
|
|
142
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
143
|
+
const timer = options.timeout && options.timeout > 0 ? setTimeout(() => {
|
|
144
|
+
if (typeof pgid === "number") killGroup(pgid, "SIGKILL");
|
|
145
|
+
}, options.timeout) : void 0;
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
child.on("close", (code) => {
|
|
148
|
+
if (timer) clearTimeout(timer);
|
|
149
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
150
|
+
if (typeof pgid === "number") liveGroups.delete(pgid);
|
|
151
|
+
resolve({ exitCode: code });
|
|
152
|
+
});
|
|
153
|
+
child.on("error", () => {
|
|
154
|
+
if (timer) clearTimeout(timer);
|
|
155
|
+
if (typeof pgid === "number") liveGroups.delete(pgid);
|
|
156
|
+
resolve({ exitCode: null });
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function memoryCredentialStore() {
|
|
163
|
+
const entries = /* @__PURE__ */ new Map();
|
|
164
|
+
return {
|
|
165
|
+
async read(providerId) {
|
|
166
|
+
return entries.get(providerId);
|
|
167
|
+
},
|
|
168
|
+
async list() {
|
|
169
|
+
return [...entries.keys()].map((providerId) => ({ providerId, type: "api_key" }));
|
|
170
|
+
},
|
|
171
|
+
async modify(providerId, fn) {
|
|
172
|
+
const next = await fn(entries.get(providerId));
|
|
173
|
+
if (next) entries.set(providerId, next);
|
|
174
|
+
else entries.delete(providerId);
|
|
175
|
+
return next;
|
|
176
|
+
},
|
|
177
|
+
async delete(providerId) {
|
|
178
|
+
entries.delete(providerId);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
var changedFiles = /* @__PURE__ */ new Set();
|
|
183
|
+
function relativeToWorkspace(root, absolute) {
|
|
184
|
+
return absolute.startsWith(`${root}/`) ? absolute.slice(root.length + 1) : absolute;
|
|
185
|
+
}
|
|
186
|
+
async function run(start) {
|
|
187
|
+
const agentDir = `${start.scratchDir}/pi-agent`;
|
|
188
|
+
mkdirSync(agentDir, { recursive: true });
|
|
189
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
190
|
+
process.env.PI_CODING_AGENT_SESSION_DIR = `${agentDir}/sessions`;
|
|
191
|
+
const pi = await import("@earendil-works/pi-coding-agent");
|
|
192
|
+
const settingsManager = pi.SettingsManager.inMemory();
|
|
193
|
+
const runtime = await pi.ModelRuntime.create({
|
|
194
|
+
allowModelNetwork: false,
|
|
195
|
+
// An in-memory credential store, not the default file at authPath: persistence is the daemon
|
|
196
|
+
// cache file's job, and a second copy beside it would buy nothing. `setRuntimeApiKey` is already
|
|
197
|
+
// a memory overlay, so this only makes "nothing is written" structural rather than incidental.
|
|
198
|
+
credentials: memoryCredentialStore(),
|
|
199
|
+
authPath: `${agentDir}/auth.json`,
|
|
200
|
+
modelsPath: `${agentDir}/models.json`,
|
|
201
|
+
modelsStorePath: `${agentDir}/models-store.json`
|
|
202
|
+
});
|
|
203
|
+
for (const provider of start.customProviders) {
|
|
204
|
+
runtime.registerProvider(provider.id, {
|
|
205
|
+
name: provider.name || provider.id,
|
|
206
|
+
baseUrl: provider.baseUrl,
|
|
207
|
+
api: provider.api,
|
|
208
|
+
authHeader: provider.authHeader,
|
|
209
|
+
models: provider.models.map((entry) => ({
|
|
210
|
+
id: entry.id,
|
|
211
|
+
name: entry.name || entry.id,
|
|
212
|
+
reasoning: false,
|
|
213
|
+
input: ["text"],
|
|
214
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
215
|
+
contextWindow: 128e3,
|
|
216
|
+
maxTokens: 8192
|
|
217
|
+
}))
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (start.apiKey) await runtime.setRuntimeApiKey(start.model.provider, start.apiKey);
|
|
221
|
+
const model = runtime.getModel(start.model.provider, start.model.id);
|
|
222
|
+
if (!model) {
|
|
223
|
+
throw new Error(`\u8D26\u53F7\u914D\u7F6E\u91CC\u7684\u6A21\u578B\u4E0D\u53EF\u7528\uFF1A${start.model.provider}/${start.model.id}`);
|
|
224
|
+
}
|
|
225
|
+
const guardExtension = {
|
|
226
|
+
name: "coflux-workspace-guard",
|
|
227
|
+
hidden: true,
|
|
228
|
+
factory: (api) => {
|
|
229
|
+
api.on("tool_call", (event) => {
|
|
230
|
+
const verdict = guardToolCall({
|
|
231
|
+
toolName: event.toolName,
|
|
232
|
+
input: event.input,
|
|
233
|
+
workspaceRoot: start.workspaceRoot,
|
|
234
|
+
writable: start.write
|
|
235
|
+
});
|
|
236
|
+
if (verdict) {
|
|
237
|
+
transcript("error", `\u5DF2\u62E6\u4E0B ${event.toolName}\uFF1A${verdict.reason}`);
|
|
238
|
+
return { block: true, reason: verdict.reason };
|
|
239
|
+
}
|
|
240
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
241
|
+
const path = event.input?.path;
|
|
242
|
+
if (typeof path === "string") changedFiles.add(relativeToWorkspace(start.workspaceRoot, path));
|
|
243
|
+
}
|
|
244
|
+
return void 0;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const bashTool = pi.createBashToolDefinition(start.workspaceRoot, {
|
|
249
|
+
operations: sandboxedBashOperations(start),
|
|
250
|
+
shellPath: start.shell,
|
|
251
|
+
// PI_* variables push session metadata into the tools' environment; the executor does not need
|
|
252
|
+
// them, and that is one information outlet fewer.
|
|
253
|
+
exposeSessionEnvironment: false
|
|
254
|
+
});
|
|
255
|
+
const resourceLoader = new pi.DefaultResourceLoader({
|
|
256
|
+
cwd: start.workspaceRoot,
|
|
257
|
+
// Points at this task's scratch dir rather than the user's ~/.pi: their existing state is
|
|
258
|
+
// neither read nor written.
|
|
259
|
+
agentDir,
|
|
260
|
+
settingsManager,
|
|
261
|
+
noExtensions: true,
|
|
262
|
+
noSkills: true,
|
|
263
|
+
noPromptTemplates: true,
|
|
264
|
+
noThemes: true,
|
|
265
|
+
noContextFiles: true,
|
|
266
|
+
systemPrompt: start.systemPrompt,
|
|
267
|
+
extensionFactories: [guardExtension]
|
|
268
|
+
});
|
|
269
|
+
await resourceLoader.reload();
|
|
270
|
+
const { session } = await pi.createAgentSession({
|
|
271
|
+
cwd: start.workspaceRoot,
|
|
272
|
+
agentDir,
|
|
273
|
+
settingsManager,
|
|
274
|
+
modelRuntime: runtime,
|
|
275
|
+
model,
|
|
276
|
+
sessionManager: pi.SessionManager.inMemory(start.workspaceRoot),
|
|
277
|
+
// Only the read-oriented built-in tools; bash is ours (same name, arriving through customTools).
|
|
278
|
+
tools: start.write ? ["read", "edit", "write", "grep", "find", "ls"] : ["read", "grep", "find", "ls"],
|
|
279
|
+
// This one assertion works around a variance problem in pi's own types: createBashToolDefinition
|
|
280
|
+
// returns a ToolDefinition<TObject<...>> with a concrete schema, while customTools takes
|
|
281
|
+
// ToolDefinition<TSchema>, and the two are not covariant. Let this single known point through and
|
|
282
|
+
// **do not** hoist the assertion onto the whole options object — that would swallow genuinely
|
|
283
|
+
// structural errors such as resourceLoader (this file once shipped a closed loader that silently
|
|
284
|
+
// had no effect for exactly that reason).
|
|
285
|
+
customTools: [bashTool],
|
|
286
|
+
resourceLoader
|
|
287
|
+
});
|
|
288
|
+
send({ type: "running" });
|
|
289
|
+
let lastAssistantText = "";
|
|
290
|
+
let lastStopReason = "";
|
|
291
|
+
let lastErrorMessage = "";
|
|
292
|
+
session.subscribe((event) => {
|
|
293
|
+
if (event.type === "message_update") {
|
|
294
|
+
const inner = event.assistantMessageEvent;
|
|
295
|
+
if (inner?.type === "text_delta" && inner.delta) lastAssistantText += inner.delta;
|
|
296
|
+
} else if (event.type === "tool_execution_start") {
|
|
297
|
+
const name = event.toolName ?? "tool";
|
|
298
|
+
send({ type: "progress", note: `\u6B63\u5728\u6267\u884C ${name}` });
|
|
299
|
+
transcript("tool", `\u2192 ${name}`);
|
|
300
|
+
} else if (event.type === "message_end" || event.type === "turn_end") {
|
|
301
|
+
const message = event.message ?? {};
|
|
302
|
+
if (message.stopReason) lastStopReason = message.stopReason;
|
|
303
|
+
if (message.errorMessage) lastErrorMessage = message.errorMessage;
|
|
304
|
+
if (lastAssistantText) transcript("assistant", lastAssistantText);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
const timeout = setTimeout(() => void session.abort(), start.timeoutMs);
|
|
308
|
+
try {
|
|
309
|
+
await session.prompt(start.prompt);
|
|
310
|
+
} finally {
|
|
311
|
+
clearTimeout(timeout);
|
|
312
|
+
}
|
|
313
|
+
await stopAllGroups();
|
|
314
|
+
if (lastStopReason === "error") {
|
|
315
|
+
transcript("error", lastErrorMessage || "\u6A21\u578B\u8C03\u7528\u5931\u8D25");
|
|
316
|
+
send({
|
|
317
|
+
type: "done",
|
|
318
|
+
outcome: "model_error",
|
|
319
|
+
summary: lastAssistantText.trim(),
|
|
320
|
+
changedFiles: [...changedFiles],
|
|
321
|
+
error: lastErrorMessage || "\u6A21\u578B\u8C03\u7528\u5931\u8D25\uFF0C\u4E14\u672A\u7ED9\u51FA\u539F\u56E0"
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (lastStopReason === "aborted") {
|
|
326
|
+
send({
|
|
327
|
+
type: "done",
|
|
328
|
+
outcome: "cancelled",
|
|
329
|
+
summary: lastAssistantText.trim(),
|
|
330
|
+
changedFiles: [...changedFiles],
|
|
331
|
+
error: lastErrorMessage || "\u4EFB\u52A1\u88AB\u4E2D\u65AD"
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!lastStopReason) {
|
|
336
|
+
send({
|
|
337
|
+
type: "done",
|
|
338
|
+
outcome: "model_error",
|
|
339
|
+
summary: "",
|
|
340
|
+
changedFiles: [...changedFiles],
|
|
341
|
+
error: "executor \u6CA1\u6709\u4EA7\u51FA\u4EFB\u4F55\u6A21\u578B\u56DE\u590D\uFF1B\u68C0\u67E5\u684C\u9762\u91CC\u914D\u7684 provider \u4E0E\u6A21\u578B\u662F\u5426\u53EF\u7528"
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
send({
|
|
346
|
+
type: "done",
|
|
347
|
+
outcome: "succeeded",
|
|
348
|
+
summary: lastAssistantText.trim(),
|
|
349
|
+
changedFiles: [...changedFiles]
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
var aborting = false;
|
|
353
|
+
onHostMessage((message) => {
|
|
354
|
+
if (message?.type === "abort") {
|
|
355
|
+
aborting = true;
|
|
356
|
+
void stopAllGroups().then(() => {
|
|
357
|
+
send({ type: "done", outcome: "cancelled", summary: "", changedFiles: [...changedFiles], error: "\u5DF2\u88AB\u505C\u6B62" });
|
|
358
|
+
process.exit(EXECUTOR_RUNNER_EXIT.aborted);
|
|
359
|
+
});
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (message?.type !== "start") return;
|
|
363
|
+
if (!message.scratchDir) message.scratchDir = mkdtempSync(`${tmpdir()}/coflux-executor-`);
|
|
364
|
+
run(message).catch(async (error) => {
|
|
365
|
+
if (aborting) return;
|
|
366
|
+
await stopAllGroups();
|
|
367
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
368
|
+
transcript("error", text);
|
|
369
|
+
send({
|
|
370
|
+
type: "done",
|
|
371
|
+
outcome: text.includes("\u6A21\u578B") || text.includes("model") ? "model_error" : "tool_failed",
|
|
372
|
+
summary: "",
|
|
373
|
+
changedFiles: [...changedFiles],
|
|
374
|
+
error: text
|
|
375
|
+
});
|
|
376
|
+
}).finally(() => {
|
|
377
|
+
if (!aborting) process.exit(EXECUTOR_RUNNER_EXIT.ok);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
send({ type: "ready" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cofluxd",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Coflux 无界面宿主(cofluxd)与统一操作工具(coflux)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,9 +13,14 @@
|
|
|
13
13
|
"account-client.mjs",
|
|
14
14
|
"release-pubkey.hex",
|
|
15
15
|
"release-trust.mjs",
|
|
16
|
+
"service-unit.mjs",
|
|
16
17
|
"README.md",
|
|
17
|
-
"skills"
|
|
18
|
+
"skills",
|
|
19
|
+
"executor"
|
|
18
20
|
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"prepack": "esbuild ../executor/src/host.ts ../executor/src/runner.ts --bundle --platform=node --target=node20 --format=esm --external:@earendil-works/pi-coding-agent --outdir=executor --log-level=warning"
|
|
23
|
+
},
|
|
19
24
|
"engines": {
|
|
20
25
|
"node": ">=20"
|
|
21
26
|
},
|
|
@@ -33,5 +38,11 @@
|
|
|
33
38
|
"cli",
|
|
34
39
|
"remote-terminal",
|
|
35
40
|
"pty"
|
|
36
|
-
]
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "0.85.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"esbuild": "^0.25.0"
|
|
47
|
+
}
|
|
37
48
|
}
|
package/service-unit.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The launchd / systemd units cofluxd writes, as pure functions of the paths that go into them.
|
|
2
|
+
//
|
|
3
|
+
// Split out of cofluxd.mjs so the one thing that cannot be noticed by using the product can be
|
|
4
|
+
// asserted: whether the executor runtime made it into the unit. A missing variable there is silent
|
|
5
|
+
// — the daemon starts, everything works, and the only symptom is `coflux executor run` reporting
|
|
6
|
+
// much later that this machine has no executor host.
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
/** The two variables the worker reads; kept identical to `packages/executor/src/env.ts` and
|
|
11
|
+
* `crates/worker/src/executor_host.rs`. */
|
|
12
|
+
export const EXECUTOR_NODE_ENV = "COFLUX_EXECUTOR_NODE";
|
|
13
|
+
export const EXECUTOR_ENTRY_ENV = "COFLUX_EXECUTOR_ENTRY";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The runtime pair to record in the unit, or `null` when this installation cannot host an executor.
|
|
17
|
+
*
|
|
18
|
+
* It is `process.execPath` — the node that is running `cofluxd up` right now — plus the resolved
|
|
19
|
+
* package entry, both absolute. Deliberately **not** a `PATH` lookup deferred to the daemon: a
|
|
20
|
+
* launchd job's `PATH` is not the user's, and resolving `node` at start time would mean the
|
|
21
|
+
* executor silently follows whatever version is installed later.
|
|
22
|
+
*
|
|
23
|
+
* `null` is an ordinary outcome, not an error: the desktop-bundled daemon ships Rust and Go
|
|
24
|
+
* binaries and no JS runtime, and there Coflux.app hosts the executor instead.
|
|
25
|
+
*/
|
|
26
|
+
export function executorRuntime() {
|
|
27
|
+
const node = process.execPath;
|
|
28
|
+
if (!node?.startsWith("/")) return null;
|
|
29
|
+
// Two known layouts, in the order they occur. Published: `prepack` bundles the executor next to
|
|
30
|
+
// this file with esbuild (see package.json), because `@coflux/executor` is workspace-internal and
|
|
31
|
+
// never published — the tarball carries the build output, not a registry dependency. Source
|
|
32
|
+
// checkout: the workspace package's own `tsc` output. Neither present means this installation
|
|
33
|
+
// cannot host an executor, which is an ordinary outcome.
|
|
34
|
+
for (const candidate of ["./executor/host.js", "../executor/dist/host.js"]) {
|
|
35
|
+
const entry = fileURLToPath(new URL(candidate, import.meta.url));
|
|
36
|
+
if (entry.startsWith("/") && existsSync(entry)) return { node, entry };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Values interpolated into a plist are XML text; a path may legally contain `&` or `<`. */
|
|
42
|
+
function xml(value) {
|
|
43
|
+
return String(value).replace(/[&<>]/g, (char) => (char === "&" ? "&" : char === "<" ? "<" : ">"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A systemd `Environment=` value must be one line. A path containing a newline would otherwise turn
|
|
48
|
+
* the rest of it into a directive of its own, so such a value is dropped rather than escaped —
|
|
49
|
+
* a real install path never looks like that, and one that does means something is already wrong.
|
|
50
|
+
*/
|
|
51
|
+
function oneLine(value) {
|
|
52
|
+
return !/[\n\r]/.test(String(value));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function plistXml({ supervisorBin, home, logFile, executor }) {
|
|
56
|
+
const variables = [["COFLUX_HOME", home]];
|
|
57
|
+
if (executor) {
|
|
58
|
+
variables.push([EXECUTOR_NODE_ENV, executor.node], [EXECUTOR_ENTRY_ENV, executor.entry]);
|
|
59
|
+
}
|
|
60
|
+
const entries = variables
|
|
61
|
+
.map(([key, value]) => ` <key>${xml(key)}</key><string>${xml(value)}</string>`)
|
|
62
|
+
.join("\n");
|
|
63
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
64
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
65
|
+
<plist version="1.0">
|
|
66
|
+
<dict>
|
|
67
|
+
<key>Label</key><string>com.coflux.daemon</string>
|
|
68
|
+
<key>ProgramArguments</key>
|
|
69
|
+
<array><string>${xml(supervisorBin)}</string></array>
|
|
70
|
+
<key>EnvironmentVariables</key>
|
|
71
|
+
<dict>
|
|
72
|
+
${entries}
|
|
73
|
+
</dict>
|
|
74
|
+
<key>RunAtLoad</key><true/>
|
|
75
|
+
<key>KeepAlive</key><true/>
|
|
76
|
+
<key>StandardOutPath</key><string>${xml(logFile)}</string>
|
|
77
|
+
<key>StandardErrorPath</key><string>${xml(logFile)}</string>
|
|
78
|
+
</dict>
|
|
79
|
+
</plist>
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function systemdUnit({ supervisorBin, home, executor }) {
|
|
84
|
+
const variables = [["COFLUX_HOME", home]];
|
|
85
|
+
if (executor) {
|
|
86
|
+
variables.push([EXECUTOR_NODE_ENV, executor.node], [EXECUTOR_ENTRY_ENV, executor.entry]);
|
|
87
|
+
}
|
|
88
|
+
const environment = variables
|
|
89
|
+
.filter(([, value]) => oneLine(value))
|
|
90
|
+
.map(([key, value]) => `Environment=${key}=${value}`)
|
|
91
|
+
.join("\n");
|
|
92
|
+
return `[Unit]
|
|
93
|
+
Description=coflux daemon (supervisor)
|
|
94
|
+
After=network-online.target
|
|
95
|
+
Wants=network-online.target
|
|
96
|
+
|
|
97
|
+
[Service]
|
|
98
|
+
${environment}
|
|
99
|
+
ExecStart=${supervisorBin}
|
|
100
|
+
Restart=always
|
|
101
|
+
RestartSec=2
|
|
102
|
+
|
|
103
|
+
[Install]
|
|
104
|
+
WantedBy=default.target
|
|
105
|
+
`;
|
|
106
|
+
}
|
package/skills/coflux/SKILL.md
CHANGED
|
@@ -206,8 +206,9 @@ and terminal tabs, long-press on iOS rows and terminal chips) and show no handle
|
|
|
206
206
|
|
|
207
207
|
### When a handle does not resolve
|
|
208
208
|
|
|
209
|
-
A handle is a prefix, so resolving one can fail in three distinct ways
|
|
210
|
-
sentence; what matters is which of the
|
|
209
|
+
A handle is a prefix, so resolving one can fail in three distinct ways — and there is a fourth case
|
|
210
|
+
that is not a resolution failure at all. Each is one readable sentence; what matters is which of the
|
|
211
|
+
four you got:
|
|
211
212
|
|
|
212
213
|
- **Nothing matches** — no entity of that kind, within the scope that command can see, starts with
|
|
213
214
|
that short id. The handle belongs to another account, to a workspace you are not in, or to
|
|
@@ -218,6 +219,12 @@ sentence; what matters is which of the three you got:
|
|
|
218
219
|
- **Wrong kind** — the handle is well-formed but names a different kind than the command expects
|
|
219
220
|
(a workspace handle where a terminal id goes). The command fails rather than act on an adjacent
|
|
220
221
|
entity; the fix is a handle of the expected kind, never a retry.
|
|
222
|
+
- **It was never a handle** — the `coflux:<kind>:` prefix is **mandatory**. `b6767697` on its own is
|
|
223
|
+
not a handle, however faithfully it copies the first 8 characters of a real UUID. Nothing rejects
|
|
224
|
+
it as malformed: it is forwarded untouched as a literal id and fails wherever that id is looked
|
|
225
|
+
up, **with a message that says nothing about id syntax** — for a device it reads
|
|
226
|
+
`设备 b6767697 不存在或不属于当前账号`. The three errors above cannot warn you here, because the
|
|
227
|
+
string never reached the resolver. Write `coflux:device:b6767697`, never `b6767697`.
|
|
221
228
|
|
|
222
229
|
The scopes differ on purpose: account CLI commands resolve a handle among **the requesting
|
|
223
230
|
account's** entities, while local terminal commands resolve it among the terminals of **the
|
|
@@ -433,7 +440,12 @@ to change something, send a new run with a new prompt. So **write the prompt as
|
|
|
433
440
|
hint**: the executor gets that one string and nothing else — no conversation history, no way to ask
|
|
434
441
|
you what you meant. Say what done looks like and how to check it.
|
|
435
442
|
|
|
436
|
-
Its boundaries
|
|
443
|
+
**Where it exists: macOS only.** Its boundaries are enforced by the macOS kernel sandbox, and there
|
|
444
|
+
is no equivalent on Linux yet, so a Linux machine has no executor and the command says so. On macOS
|
|
445
|
+
it is hosted either by the daemon the user installed from npm or by the desktop app, whichever that
|
|
446
|
+
machine has — you do not choose, and nothing about the command changes either way.
|
|
447
|
+
|
|
448
|
+
Its boundaries, enforced by that kernel sandbox — count on them, and tell it what it needs up front:
|
|
437
449
|
|
|
438
450
|
- **Only the originating workspace is writable.** Writes anywhere outside it are refused by the
|
|
439
451
|
kernel. Reads are not restricted, so it can still see system files, toolchains and the rest of the
|
|
@@ -446,12 +458,14 @@ Its boundaries, enforced by a kernel sandbox — count on them, and tell it what
|
|
|
446
458
|
- **One writing executor per workspace at a time.** A second `--write` in the same workspace is
|
|
447
459
|
refused outright rather than queued; read-only runs may go in parallel up to a small cap.
|
|
448
460
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
461
|
+
A run ends if the host it is on goes away — the user quits the desktop app, signs out, or stops the
|
|
462
|
+
machine's terminals — and you get a definite failure, never a hang. `--timeout <seconds>` caps how
|
|
463
|
+
long you wait; the default is 30 minutes and a timeout cancels the run before failing. The model
|
|
464
|
+
comes from the user's account-wide executor settings; if they have not configured one, the command
|
|
465
|
+
says so in one line — relay that to the user instead of retrying.
|
|
466
|
+
|
|
467
|
+
**Images and other files are not an input.** The prompt is a task description with a size cap, not a
|
|
468
|
+
file channel: point at paths inside the workspace instead of trying to hand anything over.
|
|
455
469
|
|
|
456
470
|
### Errors from local commands
|
|
457
471
|
|
|
@@ -529,7 +543,9 @@ Success is one line of JSON: `projectId`, `name`, `repoPath`, `defaultBranch`, t
|
|
|
529
543
|
project, so importing the same repository twice returns the existing one with
|
|
530
544
|
`alreadyImported: true` and creates nothing — re-running it is safe. Failures are one sentence and a
|
|
531
545
|
non-zero exit: "不是 git 仓库" (the path is not inside a repository), "设备离线,无法执行该操作"
|
|
532
|
-
(that device is not connected),
|
|
546
|
+
(that device exists but is not connected), "设备 <id> 不存在或不属于当前账号" (no device of this
|
|
547
|
+
account has that id — fix what you typed rather than go looking at the machine; a bare `b6767697`
|
|
548
|
+
with no `coflux:device:` prefix lands here), and a submitted-but-unfinished import tells you to check
|
|
533
549
|
`coflux project list`.
|
|
534
550
|
|
|
535
551
|
### Run one command on another machine
|
|
@@ -548,9 +564,11 @@ work, and quoting is yours to get right.
|
|
|
548
564
|
Its output is not JSON: stdout goes to stdout, stderr goes to stderr (always separate, and each one
|
|
549
565
|
carries an explicit marker if it had to be truncated), and the last line is `# exit=<code>`. **The
|
|
550
566
|
CLI's exit code is the remote command's**, so an ordinary shell test around it works. The CLI's own
|
|
551
|
-
failures — device offline
|
|
552
|
-
|
|
553
|
-
|
|
567
|
+
failures — device offline (it exists and is not connected), no device of this account with that id
|
|
568
|
+
(`设备 <id> 不存在或不属于当前账号`: a mistyped or bare-prefix id, **not** an outage to investigate),
|
|
569
|
+
that device's daemon too old (`cofluxd update && cofluxd restart` there), `--cwd` missing or not a
|
|
570
|
+
directory, the timeout elapsing — are one readable sentence and exit **255**, so a remote exit 1 is
|
|
571
|
+
never confused with "it never ran".
|
|
554
572
|
|
|
555
573
|
`--cwd` is the only addressing: an absolute path or a `~` prefix, defaulting to the daemon user's
|
|
556
574
|
HOME. To run inside a workspace, pass the `path` from `coflux workspace list --device <deviceId>`.
|