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.
@@ -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.0",
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
  }
@@ -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 === "&" ? "&amp;" : char === "<" ? "&lt;" : "&gt;"));
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
+ }
@@ -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. Each is one readable
210
- sentence; what matters is which of the three you got:
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, enforced by a kernel sandbox — count on them, and tell it what it needs up front:
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
- It runs inside the user's desktop app, so it only exists on the machine that app is on, and a run
450
- ends if the user quits the app, signs out, or stops the machine's terminals (you get a definite
451
- failure, never a hang). `--timeout <seconds>` caps how long you wait; the default is 30 minutes and
452
- a timeout cancels the run before failing. The model comes from the user's desktop settings; if they
453
- have not configured one, the command says so in one line — relay that to the user instead of
454
- retrying.
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), and a submitted-but-unfinished import tells you to check
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, that device's daemon too old (`cofluxd update && cofluxd restart` there),
552
- `--cwd` missing or not a directory, the timeout elapsing — are one readable sentence and exit **255**,
553
- so a remote exit 1 is never confused with "it never ran".
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>`.