cofluxd 2.11.0 → 2.13.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/coflux.mjs CHANGED
@@ -517,11 +517,13 @@ function renderExecutorTimeout(timeoutSec, runId, phase) {
517
517
  return `等待超时(${timeoutSec}s):executor 任务 ${runId} 仍是 ${phase},已请求取消。可加大 --timeout 后重发`;
518
518
  }
519
519
 
520
- /** Submit. A transport failure retries with the same submissionId; a refusal is reported verbatim. */
521
- async function executorSubmit(prompt, write) {
520
+ /** Submit. A transport failure retries with the same submissionId; a refusal is reported verbatim.
521
+ * `title` names the run on the desktop's card (plan 20260929-executor-pip); empty = the daemon
522
+ * falls back to the prompt's first line. */
523
+ async function executorSubmit(prompt, write, title) {
522
524
  const submission = submissionId();
523
525
  for (let attempt = 0; ; attempt += 1) {
524
- const result = await agentPostResult({ action: "executor.submit", submissionId: submission, prompt, write });
526
+ const result = await agentPostResult({ action: "executor.submit", submissionId: submission, prompt, write, title });
525
527
  if (result.ok) {
526
528
  const runId = String(result.value?.runId ?? "");
527
529
  if (!runId) die("daemon 没有返回 runId(版本太旧?)");
@@ -533,15 +535,16 @@ async function executorSubmit(prompt, write) {
533
535
  }
534
536
 
535
537
  async function cmdExecutor(values) {
536
- if (positionals[1] !== "run") die(`executor 的子命令只有 run:coflux executor run --prompt="<任务>" [--write]`);
538
+ if (positionals[1] !== "run") die(`executor 的子命令只有 run:coflux executor run --prompt="<任务>" [--title="<标题>"] [--write]`);
537
539
  const prompt = String(values.prompt ?? "").trim();
538
540
  if (!prompt) {
539
541
  die(`executor run 需要 --prompt="<任务>"(一句把边界说清的任务描述,例如 --prompt="把 crates/worker 的 clippy 警告清掉")`);
540
542
  }
541
543
  const write = Boolean(values.write);
544
+ const title = String(values.title ?? "").trim();
542
545
  const timeoutSec = executorTimeoutSecs(values.timeout);
543
546
  const deadline = Date.now() + timeoutSec * 1000;
544
- const runId = await executorSubmit(prompt, write);
547
+ const runId = await executorSubmit(prompt, write, title);
545
548
  for (;;) {
546
549
  // The first poll does not sleep: a rejection (write lock taken, model not configured) has to
547
550
  // surface immediately instead of costing the caller a whole poll interval.
@@ -587,13 +590,14 @@ const HELP = `coflux —— 账号与终端操作
587
590
  coflux notify "<一句话>" 发送站内通知;服务器保存后确认送达
588
591
  coflux progress "<一句话>" 播报进度:显示在工作区卡片上,被下一条覆盖(不打扰用户)
589
592
  coflux ports 列出本工作区的监听端口及可直接打开的预览 URL
590
- coflux executor run --prompt="<任务>" [--write] [--timeout <秒>]
593
+ coflux executor run --prompt="<任务>" [--title="<标题>"] [--write] [--timeout <秒>]
591
594
  把一个边界清楚的子任务甩给内置的轻量 executor(由本机 Coflux.app
592
595
  执行),阻塞到跑完并打印它的最终回复与改动文件。一次性:没有会话、
593
596
  不续聊,要改就再发一次。入参只有任务描述与读写模式——模型由用户在
594
597
  Coflux.app 里全局配一次。默认只读;--write 才允许改文件(同一工作区
595
598
  同时只允许一个写任务)。它被内核级沙箱锁在本工作区目录内,**不联网**
596
599
  (先把依赖装好再甩),也**不会 git commit**(改动由你自己 review 提交)
600
+ --title 给这次运行起个短标题:用户在本终端上会看到一张进度小卡
597
601
  只有装了 Coflux.app 的这台机器能用
598
602
  coflux workspace 一行 JSON 报出「我在哪」:workspaceId(cwd 所在的有效工作区,本地命令
599
603
  都落在它上面)、path、owningWorkspaceId(本终端此刻归属哪个工作区)、
package/cofluxd.mjs CHANGED
@@ -28,9 +28,15 @@ import { executorRuntime, plistXml, ptydPlistXml, ptydSystemdUnit, systemdUnit }
28
28
  const DEFAULT_SERVER = "wss://api.coflux.dev/daemon";
29
29
 
30
30
  const REPO = "myWsq/coflux";
31
- const RELEASE_API_BASE = (process.env.COFLUX_RELEASE_API_BASE || "https://api.github.com").replace(/\/+$/, "");
32
- const RELEASE_DOWNLOAD_BASE = (
33
- process.env.COFLUX_RELEASE_DOWNLOAD_BASE || `https://github.com/${REPO}/releases/download`
31
+ // Release downloads route by version, never by failure (plan 20260930-r2-download-mirror): the mirror
32
+ // (R2 at dl.coflux.dev) holds only the latest stable release and its `latest.json` pointer; every other
33
+ // version, and every prerelease, comes from the GitHub Releases archive. No mirror-then-GitHub fallback:
34
+ // a mirror outage must stay visible.
35
+ const RELEASE_MIRROR_BASE = (
36
+ process.env.COFLUX_RELEASE_DOWNLOAD_BASE || "https://dl.coflux.dev/releases"
37
+ ).replace(/\/+$/, "");
38
+ const RELEASE_ARCHIVE_BASE = (
39
+ process.env.COFLUX_RELEASE_ARCHIVE_BASE || `https://github.com/${REPO}/releases/download`
34
40
  ).replace(/\/+$/, "");
35
41
  const MAX_RELEASE_METADATA_BYTES = 1024 * 1024;
36
42
  const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
@@ -258,16 +264,18 @@ function persistCliReleaseFloor(version) {
258
264
  }
259
265
  }
260
266
 
261
- // 取最新 release tag(含 prerelease;GitHub 的 /releases/latest 跳转不含 prerelease,故走 API)。
262
- async function resolveLatestTag() {
267
+ // The latest stable tag named by the mirror's `latest.json`, or null when it cannot be read or is invalid.
268
+ async function readMirrorLatestTag() {
263
269
  try {
264
270
  const body = await fetchBounded(
265
- `${RELEASE_API_BASE}/repos/${REPO}/releases?per_page=1`,
271
+ `${RELEASE_MIRROR_BASE}/latest.json`,
266
272
  MAX_RELEASE_METADATA_BYTES,
267
- "GitHub release 元数据",
273
+ "release 下载镜像 latest.json",
268
274
  );
269
- const arr = JSON.parse(body.toString("utf8"));
270
- return Array.isArray(arr) && typeof arr[0]?.tag_name === "string" ? arr[0].tag_name : null;
275
+ const tag = JSON.parse(body.toString("utf8"))?.version;
276
+ if (typeof tag !== "string") return null;
277
+ assertReleaseVersion(tag);
278
+ return tag;
271
279
  } catch {
272
280
  return null;
273
281
  }
@@ -322,13 +330,23 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
322
330
  }
323
331
  const target = rustTarget();
324
332
  let releaseVersion;
333
+ let releaseBase;
325
334
  if (!version || version === "latest") {
326
- const tag = await resolveLatestTag();
327
- if (!tag) die("无法取得最新 release 的精确版本,拒绝无版本身份的下载");
335
+ const tag = await readMirrorLatestTag();
336
+ if (!tag) {
337
+ die(
338
+ `无法从下载镜像读取最新版本(${RELEASE_MIRROR_BASE}/latest.json),拒绝无版本身份的下载。\n` +
339
+ " 用 --version vX.Y.Z 指定版本即可从 GitHub Releases 安装。",
340
+ );
341
+ }
328
342
  releaseVersion = tag;
343
+ releaseBase = RELEASE_MIRROR_BASE;
329
344
  console.log(`最新版本: ${releaseVersion}`);
330
345
  } else {
331
346
  releaseVersion = version;
347
+ // Only the tag the mirror's pointer names lives on the mirror; any other version (older, newer or a
348
+ // prerelease), and any version asked for while the pointer is unreadable, comes from the archive.
349
+ releaseBase = (await readMirrorLatestTag()) === releaseVersion ? RELEASE_MIRROR_BASE : RELEASE_ARCHIVE_BASE;
332
350
  }
333
351
  try {
334
352
  assertReleaseVersion(releaseVersion);
@@ -348,7 +366,8 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
348
366
  die(error instanceof Error ? error.message : String(error));
349
367
  }
350
368
 
351
- const base = `${RELEASE_DOWNLOAD_BASE}/${releaseVersion}`;
369
+ const base = `${releaseBase}/${releaseVersion}`;
370
+ console.log(`下载源: ${base}`);
352
371
  const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-release-install-"));
353
372
  let failure;
354
373
  try {
package/executor/host.js CHANGED
@@ -632,6 +632,8 @@ var ExecutorManager = class {
632
632
  const run = this.live.get(runId);
633
633
  if (run) run.settled = true;
634
634
  this.apply(this.table.finish(runId, outcome));
635
+ } else if (message.type === "transcript") {
636
+ if (this.live.has(runId)) this.deps.sendTranscript?.(runId, message.fragment);
635
637
  }
636
638
  }
637
639
  /**
@@ -683,6 +685,9 @@ function createExecutorHostCore(options) {
683
685
  };
684
686
  },
685
687
  sendReport: (report) => options.send({ type: "report", ...report }),
688
+ // The daemon assigns the seq and buffers per run; the same frame rides JSONL for the daemon's
689
+ // own host and the device channel for Coflux.app.
690
+ sendTranscript: (runId, fragment) => options.send({ type: "transcript", runId, fragment }),
686
691
  log: options.log
687
692
  });
688
693
  return {
@@ -82,6 +82,181 @@ var EXECUTOR_RUNNER_EXIT = {
82
82
  aborted: 11
83
83
  };
84
84
 
85
+ // ../executor/src/transcript.ts
86
+ var TOOL_OUTPUT_HEAD_CHARS = 4 * 1024;
87
+ var TOOL_OUTPUT_TAIL_CHARS = 2 * 1024;
88
+ var REDACTED = "***";
89
+ var CREDENTIAL_ENV_NAMES = [
90
+ "ANTHROPIC_API_KEY",
91
+ "ANTHROPIC_AUTH_TOKEN",
92
+ "OPENAI_API_KEY",
93
+ "GEMINI_API_KEY",
94
+ "GOOGLE_API_KEY",
95
+ "OPENROUTER_API_KEY",
96
+ "GROQ_API_KEY",
97
+ "MISTRAL_API_KEY",
98
+ "XAI_API_KEY",
99
+ "DEEPSEEK_API_KEY"
100
+ ];
101
+ function capToolOutput(text) {
102
+ if (text.length <= TOOL_OUTPUT_HEAD_CHARS + TOOL_OUTPUT_TAIL_CHARS) return text;
103
+ const head = text.slice(0, TOOL_OUTPUT_HEAD_CHARS);
104
+ const tail = text.slice(text.length - TOOL_OUTPUT_TAIL_CHARS);
105
+ const omitted = Buffer.byteLength(text.slice(TOOL_OUTPUT_HEAD_CHARS, text.length - TOOL_OUTPUT_TAIL_CHARS));
106
+ return `${head}
107
+ \u2026 ${omitted} bytes omitted \u2026
108
+ ${tail}`;
109
+ }
110
+ function salientArgument(toolName, args) {
111
+ const input = args ?? {};
112
+ const pick = (...keys) => {
113
+ for (const key of keys) {
114
+ const value = input[key];
115
+ if (typeof value === "string" && value.trim()) return value;
116
+ }
117
+ return "";
118
+ };
119
+ switch (toolName) {
120
+ case "bash":
121
+ return pick("command", "cmd");
122
+ case "read":
123
+ case "write":
124
+ case "edit":
125
+ case "ls":
126
+ return pick("path", "file_path", "filePath", "directory");
127
+ case "grep":
128
+ case "find": {
129
+ const pattern = pick("pattern", "query", "regex", "glob");
130
+ const path = pick("path", "directory");
131
+ return path ? `${pattern} ${path}`.trim() : pattern;
132
+ }
133
+ default: {
134
+ const direct = pick("command", "path", "pattern", "query");
135
+ if (direct) return direct;
136
+ try {
137
+ const json = JSON.stringify(args);
138
+ return json && json !== "{}" && json !== "null" ? json : "";
139
+ } catch {
140
+ return "";
141
+ }
142
+ }
143
+ }
144
+ }
145
+ function toolResultText(result) {
146
+ if (result === void 0 || result === null) return "";
147
+ if (typeof result === "string") return result;
148
+ if (typeof result !== "object") return String(result);
149
+ const record = result;
150
+ if (Array.isArray(record.content)) {
151
+ const parts = [];
152
+ for (const item of record.content) {
153
+ if (typeof item === "string") parts.push(item);
154
+ else if (item && typeof item === "object" && typeof item.text === "string") {
155
+ parts.push(item.text);
156
+ }
157
+ }
158
+ if (parts.length > 0) return parts.join("\n");
159
+ }
160
+ for (const key of ["output", "text", "stdout", "message", "error"]) {
161
+ if (typeof record[key] === "string") return record[key];
162
+ }
163
+ try {
164
+ return JSON.stringify(result);
165
+ } catch {
166
+ return "";
167
+ }
168
+ }
169
+ function redact(text, secrets) {
170
+ let out = text;
171
+ for (const secret of secrets) {
172
+ if (!secret) continue;
173
+ out = out.split(secret).join(REDACTED);
174
+ }
175
+ return out;
176
+ }
177
+ function toolEnvironment(base, extra, secrets) {
178
+ const env = {};
179
+ const stripped = new Set(CREDENTIAL_ENV_NAMES);
180
+ for (const [name, value] of Object.entries(base ?? {})) {
181
+ if (value === void 0 || stripped.has(name)) continue;
182
+ if (secrets.some((secret) => secret && value.includes(secret))) continue;
183
+ env[name] = value;
184
+ }
185
+ for (const [name, value] of Object.entries(extra)) env[name] = value;
186
+ return env;
187
+ }
188
+ function createTranscriptRecorder(options) {
189
+ const now = options.now ?? (() => Date.now());
190
+ const secrets = options.secrets.filter(Boolean);
191
+ const emit = (fragment) => {
192
+ const clean = Object.fromEntries(
193
+ Object.entries(fragment).map(([key, value]) => [key, typeof value === "string" ? redact(value, secrets) : value])
194
+ );
195
+ options.emit(clean);
196
+ };
197
+ let streaming = "";
198
+ let lastComplete = "";
199
+ let stopReason = "";
200
+ let errorMessage = "";
201
+ const open = /* @__PURE__ */ new Map();
202
+ return {
203
+ onEvent(event) {
204
+ switch (event.type) {
205
+ case "message_start": {
206
+ if (event.message?.role === "assistant") streaming = "";
207
+ break;
208
+ }
209
+ case "message_update": {
210
+ const inner = event.assistantMessageEvent;
211
+ if (inner?.type === "text_delta" && inner.delta) streaming += inner.delta;
212
+ break;
213
+ }
214
+ case "message_end": {
215
+ const message = event.message ?? {};
216
+ if (message.stopReason) stopReason = message.stopReason;
217
+ if (message.errorMessage) errorMessage = message.errorMessage;
218
+ if (message.role !== void 0 && message.role !== "assistant") break;
219
+ const text = streaming.trim();
220
+ streaming = "";
221
+ if (!text) break;
222
+ lastComplete = text;
223
+ emit({ kind: "assistant", text, at: now() });
224
+ break;
225
+ }
226
+ case "tool_execution_start": {
227
+ const tool = event.toolName || "tool";
228
+ const record = { tool, argument: salientArgument(tool, event.args), at: now() };
229
+ open.set(event.toolCallId || `${tool}-${open.size}`, record);
230
+ break;
231
+ }
232
+ case "tool_execution_end": {
233
+ const tool = event.toolName || "tool";
234
+ const key = event.toolCallId || "";
235
+ const started = open.get(key);
236
+ open.delete(key);
237
+ emit({
238
+ kind: "tool",
239
+ tool,
240
+ argument: started?.argument ?? salientArgument(tool, void 0),
241
+ output: capToolOutput(toolResultText(event.result)),
242
+ failed: event.isError === true,
243
+ at: now()
244
+ });
245
+ break;
246
+ }
247
+ default:
248
+ break;
249
+ }
250
+ },
251
+ error(text) {
252
+ if (!text) return;
253
+ emit({ kind: "error", text, at: now() });
254
+ },
255
+ lastAssistantText: () => streaming.trim() ? streaming.trim() : lastComplete,
256
+ lastStop: () => ({ reason: stopReason, errorMessage })
257
+ };
258
+ }
259
+
85
260
  // ../executor/src/runner.ts
86
261
  var parentPort = process.parentPort;
87
262
  function send(message) {
@@ -93,10 +268,10 @@ function onHostMessage(listener) {
93
268
  else process.on("message", (value) => listener(value));
94
269
  }
95
270
  var transcriptSeq = 0;
96
- function transcript(kind, text) {
97
- if (!text) return;
98
- send({ type: "transcript", seq: ++transcriptSeq, kind, text });
271
+ function sendFragment(fragment) {
272
+ send({ type: "transcript", seq: ++transcriptSeq, fragment });
99
273
  }
274
+ var recorder = createTranscriptRecorder({ emit: sendFragment, secrets: [] });
100
275
  var liveGroups = /* @__PURE__ */ new Set();
101
276
  function killGroup(pgid, signal) {
102
277
  try {
@@ -119,17 +294,16 @@ function sandboxedBashOperations(start) {
119
294
  cwd: start.workspaceRoot,
120
295
  detached: true,
121
296
  // 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
- },
297
+ // The credential is never handed down to a tool process: the well-known credential
298
+ // variables are stripped by name, and anything carrying the key's value by value, so a tool
299
+ // that dumps its environment cannot echo it into the transcript. TMPDIR is the scratch dir,
300
+ // not the system /tmp: that holds other tasks and shared sockets, and the profile does not
301
+ // allow it anyway.
302
+ env: toolEnvironment(
303
+ options.env,
304
+ { TMPDIR: start.scratchDir, COFLUX_EXECUTOR_RUN_ID: start.runId },
305
+ [start.apiKey]
306
+ ),
133
307
  stdio: ["ignore", "pipe", "pipe"]
134
308
  });
135
309
  const pgid = child.pid;
@@ -234,7 +408,7 @@ async function run(start) {
234
408
  writable: start.write
235
409
  });
236
410
  if (verdict) {
237
- transcript("error", `\u5DF2\u62E6\u4E0B ${event.toolName}\uFF1A${verdict.reason}`);
411
+ recorder.error(`\u5DF2\u62E6\u4E0B ${event.toolName}\uFF1A${verdict.reason}`);
238
412
  return { block: true, reason: verdict.reason };
239
413
  }
240
414
  if (event.toolName === "write" || event.toolName === "edit") {
@@ -286,23 +460,12 @@ async function run(start) {
286
460
  resourceLoader
287
461
  });
288
462
  send({ type: "running" });
289
- let lastAssistantText = "";
290
- let lastStopReason = "";
291
- let lastErrorMessage = "";
292
463
  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") {
464
+ if (event.type === "tool_execution_start") {
297
465
  const name = event.toolName ?? "tool";
298
466
  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
467
  }
468
+ recorder.onEvent(event);
306
469
  });
307
470
  const timeout = setTimeout(() => void session.abort(), start.timeoutMs);
308
471
  try {
@@ -311,12 +474,14 @@ async function run(start) {
311
474
  clearTimeout(timeout);
312
475
  }
313
476
  await stopAllGroups();
477
+ const { reason: lastStopReason, errorMessage: lastErrorMessage } = recorder.lastStop();
478
+ const lastAssistantText = recorder.lastAssistantText();
314
479
  if (lastStopReason === "error") {
315
- transcript("error", lastErrorMessage || "\u6A21\u578B\u8C03\u7528\u5931\u8D25");
480
+ recorder.error(lastErrorMessage || "\u6A21\u578B\u8C03\u7528\u5931\u8D25");
316
481
  send({
317
482
  type: "done",
318
483
  outcome: "model_error",
319
- summary: lastAssistantText.trim(),
484
+ summary: lastAssistantText,
320
485
  changedFiles: [...changedFiles],
321
486
  error: lastErrorMessage || "\u6A21\u578B\u8C03\u7528\u5931\u8D25\uFF0C\u4E14\u672A\u7ED9\u51FA\u539F\u56E0"
322
487
  });
@@ -326,7 +491,7 @@ async function run(start) {
326
491
  send({
327
492
  type: "done",
328
493
  outcome: "cancelled",
329
- summary: lastAssistantText.trim(),
494
+ summary: lastAssistantText,
330
495
  changedFiles: [...changedFiles],
331
496
  error: lastErrorMessage || "\u4EFB\u52A1\u88AB\u4E2D\u65AD"
332
497
  });
@@ -345,7 +510,7 @@ async function run(start) {
345
510
  send({
346
511
  type: "done",
347
512
  outcome: "succeeded",
348
- summary: lastAssistantText.trim(),
513
+ summary: lastAssistantText,
349
514
  changedFiles: [...changedFiles]
350
515
  });
351
516
  }
@@ -361,11 +526,12 @@ onHostMessage((message) => {
361
526
  }
362
527
  if (message?.type !== "start") return;
363
528
  if (!message.scratchDir) message.scratchDir = mkdtempSync(`${tmpdir()}/coflux-executor-`);
529
+ recorder = createTranscriptRecorder({ emit: sendFragment, secrets: [message.apiKey] });
364
530
  run(message).catch(async (error) => {
365
531
  if (aborting) return;
366
532
  await stopAllGroups();
367
533
  const text = error instanceof Error ? error.message : String(error);
368
- transcript("error", text);
534
+ recorder.error(text);
369
535
  send({
370
536
  type: "done",
371
537
  outcome: text.includes("\u6A21\u578B") || text.includes("model") ? "model_error" : "tool_failed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Coflux 无界面宿主(cofluxd)与统一操作工具(coflux)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -422,14 +422,20 @@ server, use it to get the URL and tell the user directly; they click it and nobo
422
422
  ### Hand a bounded sub-task to the executor
423
423
 
424
424
  ```sh
425
- coflux executor run --prompt="Fix every clippy warning in crates/worker" --write
426
- coflux executor run --prompt="Find why the relay reconnect test flakes and report back"
425
+ coflux executor run --title="Fix clippy in worker" --prompt="Fix every clippy warning in crates/worker" --write
426
+ coflux executor run --title="Why does the relay test flake" --prompt="Find why the relay reconnect test flakes and report back"
427
427
  ```
428
428
 
429
429
  The executor is a small agent built into coflux. Give it one self-contained job and it works in
430
430
  **the workspace your cwd is in** while you keep your own context for the main thread. The command
431
431
  blocks until the job ends, then prints the executor's final report and the files it changed.
432
432
 
433
+ **Always pass `--title`**: a few words naming the job. While the run is going, the user sees a
434
+ small card on this terminal (on every desktop of the account) with that title, the run's live
435
+ progress and a stop button; they can expand it to read everything the executor did. Without a
436
+ title the card shows the prompt's first line, which is rarely a good name. The card is read-only
437
+ and disappears when the run ends — the result comes back to you, not to the user.
438
+
433
439
  Reach for it when a job is mechanical, bounded and verbose — chasing a failing test suite, a
434
440
  repetitive refactor across many files, a search that would cost you many tool calls. Keep the work
435
441
  yourself when it needs the conversation's context, the user's judgment, or decisions the prompt
@@ -459,8 +465,9 @@ Its boundaries, enforced by that kernel sandbox — count on them, and tell it w
459
465
  refused outright rather than queued; read-only runs may go in parallel up to a small cap.
460
466
 
461
467
  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
468
+ machine's terminals — and you get a definite failure, never a hang. The user can also stop it from
469
+ the card, which comes back to you as `cancelled`. `--timeout <seconds>` caps how long you wait; the
470
+ default is 30 minutes and a timeout cancels the run before failing. The model
464
471
  comes from the user's account-wide executor settings; if they have not configured one, the command
465
472
  says so in one line — relay that to the user instead of retrying.
466
473
 
@@ -528,6 +535,12 @@ this machine, so they are there whether or not a desktop is open.
528
535
  the page exposed them, the element's selector, DOM path and key computed styles, and the paths of
529
536
  its images. It always names the workspace it resolved: an empty list in the wrong workspace means
530
537
  you moved (`coflux workspace`). Add `--json` for structured output.
538
+ - One annotation is not always one element. When the user selected several elements together, it
539
+ lists each one with its own component chain and context under "Element 1 of n": the comment
540
+ applies to all of them, so change them consistently and resolve the annotation once. When the user
541
+ dragged a **region**, it gives the region's size and offset, the *container* (the innermost
542
+ element holding it — usually the component to edit) and the components inside the region; the
543
+ comment is about that area as a whole, and the screenshot shows exactly the region.
531
544
  - Find the code from the most specific lead: a source location first, then the component names,
532
545
  then text and selectors (`rg` for them). Treat the computed styles as the current state, not as
533
546
  the target: map colors, sizes and spacing onto the project's design system (its tokens, theme and