cofluxd 2.15.0 → 2.16.1

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/output.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // The one place cofluxd and coflux decide how a message looks: symbols, colour, the error shape.
2
+ //
3
+ // - `✓` success and plain lines go to stdout; `!` warnings and `✗ Error:` go to stderr.
4
+ // - Every error is one line saying what happened plus one indented line saying what to do next.
5
+ // - Colour only when the stream is a TTY and NO_COLOR is unset (https://no-color.org) and TERM is
6
+ // not "dumb". Agents call coflux without a TTY and always get plain text.
7
+ //
8
+ // Hand-rolled SGR on purpose: `util.styleText` does not exist before Node 20.12 and colours
9
+ // unconditionally before 22.13, and package.json promises Node 20.
10
+ import process from "node:process";
11
+
12
+ const SGR = { green: [32, 39], yellow: [33, 39], red: [31, 39], bold: [1, 22], dim: [2, 22] };
13
+
14
+ /** Whether `stream` may carry colour. */
15
+ export function colorEnabled(stream) {
16
+ if (!stream?.isTTY) return false;
17
+ const noColor = process.env.NO_COLOR;
18
+ if (noColor !== undefined && noColor !== "") return false;
19
+ return process.env.TERM !== "dumb";
20
+ }
21
+
22
+ function paint(stream, style, text) {
23
+ if (!colorEnabled(stream)) return text;
24
+ const [open, close] = SGR[style];
25
+ return `\u001b[${open}m${text}\u001b[${close}m`;
26
+ }
27
+
28
+ /** Bold for emphasis on stdout (a URL, a command to run). */
29
+ export const bold = (text, stream = process.stdout) => paint(stream, "bold", text);
30
+ export const dim = (text, stream = process.stdout) => paint(stream, "dim", text);
31
+
32
+ /** A plain line on stdout. */
33
+ export function info(message = "") {
34
+ process.stdout.write(`${message}\n`);
35
+ }
36
+
37
+ /** `✓ message` on stdout. */
38
+ export function success(message) {
39
+ process.stdout.write(`${paint(process.stdout, "green", "✓")} ${message}\n`);
40
+ }
41
+
42
+ /** `! message` on stderr, with an optional indented next step. */
43
+ export function warn(message, next) {
44
+ let text = `${paint(process.stderr, "yellow", "!")} ${message}\n`;
45
+ if (next) text += next.split("\n").map((line) => ` ${line}\n`).join("");
46
+ process.stderr.write(text);
47
+ }
48
+
49
+ /** `✗ Error: what` plus an indented next step, on stderr. */
50
+ export function error(what, next) {
51
+ let text = `${paint(process.stderr, "red", "✗ Error:")} ${what}\n`;
52
+ if (next) text += next.split("\n").map((line) => ` ${line}\n`).join("");
53
+ process.stderr.write(text);
54
+ }
55
+
56
+ /** Print an error and exit. The write completes before the exit, even into a pipe. */
57
+ export function fail(what, next, code = 1) {
58
+ error(what, next);
59
+ // A message this short is written in full before process.exit: libuv tries the write
60
+ // synchronously first and only queues what the pipe cannot take at once.
61
+ process.exit(code);
62
+ }
63
+
64
+ /** `Doing X... ` now, then `done` (or a failure word) when the step ends. */
65
+ export function step(label) {
66
+ process.stdout.write(`${label}... `);
67
+ return {
68
+ done(word = "done") { process.stdout.write(`${word}\n`); },
69
+ failed(word = "failed") { process.stdout.write(`${word}\n`); },
70
+ };
71
+ }
72
+
73
+ /** Aligned key/value rows on stdout; rows whose value is undefined or null are skipped. */
74
+ export function table(rows) {
75
+ const shown = rows.filter(([, value]) => value !== undefined && value !== null);
76
+ const width = Math.max(0, ...shown.map(([key]) => key.length));
77
+ for (const [key, value] of shown) {
78
+ const lines = String(value).split("\n");
79
+ process.stdout.write(`${key.padEnd(width)} ${lines[0]}\n`);
80
+ for (const line of lines.slice(1)) process.stdout.write(`${" ".repeat(width)} ${line}\n`);
81
+ }
82
+ }
83
+
84
+ /** A short human duration: `45s`, `12m`, `3h 12m`, `2d 4h`. */
85
+ export function duration(ms) {
86
+ const seconds = Math.max(0, Math.round(ms / 1000));
87
+ if (seconds < 60) return `${seconds}s`;
88
+ const minutes = Math.floor(seconds / 60);
89
+ if (minutes < 60) return `${minutes}m`;
90
+ const hours = Math.floor(minutes / 60);
91
+ if (hours < 24) return `${hours}h ${minutes % 60}m`;
92
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
93
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "2.15.0",
4
- "description": "Coflux 无界面宿主(cofluxd)与统一操作工具(coflux)",
3
+ "version": "2.16.1",
4
+ "description": "Run a headless Coflux device (cofluxd) and work with Coflux terminals, workspaces and your account (coflux)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cofluxd": "cofluxd.mjs",
@@ -11,6 +11,7 @@
11
11
  "cofluxd.mjs",
12
12
  "coflux.mjs",
13
13
  "account-client.mjs",
14
+ "output.mjs",
14
15
  "release-pubkey.hex",
15
16
  "release-trust.mjs",
16
17
  "service-unit.mjs",
package/release-trust.mjs CHANGED
@@ -14,6 +14,18 @@ export const SUPERVISOR_RELEASE_STATEMENT_DOMAIN = Buffer.from(
14
14
 
15
15
  export const CLI_RELEASE_STATEMENT_DOMAIN = Buffer.from("coflux-cli-release-v1\0", "utf8");
16
16
 
17
+ /**
18
+ * The runtime and the launcher (plan 20261002-runtime-launcher-merge) each have their own domain,
19
+ * distinct from `coflux-worker-release-v1` / `coflux-supervisor-release-v1`: a runtime statement
20
+ * never verifies against the worker transcript built from identical metadata, so no pre-plan
21
+ * supervisor can accept a runtime artifact as a worker. Runtime artifacts carry no legacy raw
22
+ * signature at all.
23
+ */
24
+ export const RUNTIME_RELEASE_STATEMENT_DOMAIN = Buffer.from("coflux-runtime-release-v1\0", "utf8");
25
+ export const LAUNCHER_RELEASE_STATEMENT_DOMAIN = Buffer.from("coflux-launcher-release-v1\0", "utf8");
26
+ export function runtimeReleaseStatement(metadata) { return artifactReleaseStatement(RUNTIME_RELEASE_STATEMENT_DOMAIN, metadata); }
27
+ export function launcherReleaseStatement(metadata) { return artifactReleaseStatement(LAUNCHER_RELEASE_STATEMENT_DOMAIN, metadata); }
28
+
17
29
  export const TRANSPORT_RELEASE_STATEMENT_DOMAIN = Buffer.from("coflux-transport-release-v1\0", "utf8");
18
30
  export function transportReleaseStatement(metadata) { return artifactReleaseStatement(TRANSPORT_RELEASE_STATEMENT_DOMAIN, metadata); }
19
31
 
@@ -21,15 +33,20 @@ export function transportReleaseStatement(metadata) { return artifactReleaseStat
21
33
  export const PTYD_RELEASE_STATEMENT_DOMAIN = Buffer.from("coflux-ptyd-release-v1\0", "utf8");
22
34
  export function ptydReleaseStatement(metadata) { return artifactReleaseStatement(PTYD_RELEASE_STATEMENT_DOMAIN, metadata); }
23
35
 
24
- const RELEASE_COMPONENTS = ["worker", "supervisor", "cli", "transport", "ptyd"];
36
+ /** Components of a schema 3 manifest. `worker` / `supervisor` are schema 2 only. */
37
+ export const RELEASE_COMPONENTS = ["runtime", "launcher", "cli", "transport", "ptyd"];
38
+ export const RELEASE_MANIFEST_SCHEMA_VERSION = 3;
25
39
 
26
40
  function releaseStatementFor(component, metadata) {
27
41
  switch (component) {
42
+ case "runtime": return runtimeReleaseStatement(metadata);
43
+ case "launcher": return launcherReleaseStatement(metadata);
28
44
  case "worker": return workerReleaseStatement(metadata);
45
+ case "supervisor": return supervisorReleaseStatement(metadata);
29
46
  case "cli": return cliReleaseStatement(metadata);
30
47
  case "transport": return transportReleaseStatement(metadata);
31
48
  case "ptyd": return ptydReleaseStatement(metadata);
32
- default: return supervisorReleaseStatement(metadata);
49
+ default: throw new Error(`Unknown release component: ${JSON.stringify(component)}`);
33
50
  }
34
51
  }
35
52
 
@@ -41,11 +58,11 @@ export const MAX_RELEASE_ARTIFACT_BYTES = 128 * 1024 * 1024;
41
58
 
42
59
  function parseReleaseVersion(version) {
43
60
  const match = typeof version === "string" ? STRICT_RELEASE_VERSION.exec(version) : null;
44
- if (!match) throw new Error(`release version 不是带 v 前缀的严格 SemVer: ${JSON.stringify(version)}`);
61
+ if (!match) throw new Error(`Release version must be strict SemVer with a v prefix: ${JSON.stringify(version)}`);
45
62
  const prerelease = match[4]
46
63
  ? match[4].split(".").map((identifier) => {
47
64
  if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) {
48
- throw new Error(`release version 的数字 prerelease 标识符含前导 0: ${JSON.stringify(version)}`);
65
+ throw new Error(`Release version has a numeric prerelease identifier with a leading zero: ${JSON.stringify(version)}`);
49
66
  }
50
67
  return /^\d+$/.test(identifier)
51
68
  ? { numeric: true, value: BigInt(identifier) }
@@ -104,13 +121,13 @@ function lenPrefixed(value) {
104
121
  function artifactReleaseStatement(domain, { version, target, sha256, size }) {
105
122
  assertReleaseVersion(version);
106
123
  if (typeof target !== "string" || !target || Buffer.byteLength(target) > 128) {
107
- throw new Error("release target 非法");
124
+ throw new Error("Release target is invalid");
108
125
  }
109
126
  if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) {
110
- throw new Error("release sha256 必须是 32 字节 hex");
127
+ throw new Error("Release sha256 must be 32 bytes of hex");
111
128
  }
112
129
  if (!Number.isSafeInteger(size) || size <= 0 || size > MAX_RELEASE_ARTIFACT_BYTES) {
113
- throw new Error("release size 必须是有效且有界的正整数");
130
+ throw new Error("Release size must be a positive integer within bounds");
114
131
  }
115
132
  const sizeBytes = Buffer.allocUnsafe(8);
116
133
  sizeBytes.writeBigUInt64BE(BigInt(size));
@@ -140,7 +157,7 @@ export function cliReleaseStatement(metadata) {
140
157
  export function createReleasePublicKey(publicKeyHex) {
141
158
  const normalized = typeof publicKeyHex === "string" ? publicKeyHex.trim() : "";
142
159
  if (!ED25519_PUBLIC_KEY_HEX.test(normalized)) {
143
- throw new Error("发布公钥必须是 32 字节 hex");
160
+ throw new Error("The release public key must be 32 bytes of hex");
144
161
  }
145
162
  return crypto.createPublicKey({
146
163
  format: "jwk",
@@ -157,21 +174,23 @@ function isRecord(value) {
157
174
  }
158
175
 
159
176
  /**
160
- * 从 schema 2 manifest 取指定 component/target。额外顶层字段允许滚动扩展;
177
+ * 从 schema 3 manifest 取指定 component/target。额外顶层字段允许滚动扩展;
161
178
  * 但参与信任裁决的 version/target/size/hash/signature 全部严格校验。
179
+ * A schema 2 manifest (worker/supervisor) is refused outright: this cofluxd installs a launcher
180
+ * and a runtime, and a `worker` lookup in a schema 3 manifest throws.
162
181
  */
163
182
  export function parseReleaseManifestEntry(manifest, component, version, target) {
164
183
  assertReleaseVersion(version);
165
184
  if (!RELEASE_COMPONENTS.includes(component)) {
166
- throw new Error(`未知 release component: ${JSON.stringify(component)}`);
185
+ throw new Error(`Unknown release component: ${JSON.stringify(component)}`);
167
186
  }
168
- if (!isRecord(manifest) || manifest.schemaVersion !== 2 || manifest.version !== version) {
169
- throw new Error("release manifest schema/version 与请求不一致");
187
+ if (!isRecord(manifest) || manifest.schemaVersion !== RELEASE_MANIFEST_SCHEMA_VERSION || manifest.version !== version) {
188
+ throw new Error("Release manifest schema/version does not match the request");
170
189
  }
171
190
  const entries = manifest[component];
172
191
  const entry = isRecord(entries) ? entries[target] : undefined;
173
192
  if (!isRecord(entry) || entry.target !== target) {
174
- throw new Error(`release manifest 缺少匹配的 ${component}/${target}`);
193
+ throw new Error(`Release manifest has no matching ${component}/${target}`);
175
194
  }
176
195
  if (
177
196
  typeof entry.sha256 !== "string" ||
@@ -182,43 +201,32 @@ export function parseReleaseManifestEntry(manifest, component, version, target)
182
201
  typeof entry.releaseSignature !== "string" ||
183
202
  !ED25519_SIGNATURE_HEX.test(entry.releaseSignature)
184
203
  ) {
185
- throw new Error(`release manifest 的 ${component}/${target} 元数据非法`);
186
- }
187
- if (
188
- component === "worker" &&
189
- (typeof entry.signature !== "string" || !ED25519_SIGNATURE_HEX.test(entry.signature))
190
- ) {
191
- throw new Error(`release manifest 的 worker/${target} 缺少 legacy 签名`);
204
+ throw new Error(`Release manifest metadata is invalid for ${component}/${target}`);
192
205
  }
206
+ // No component of schema 3 carries a legacy raw-binary signature (the worker's was the back
207
+ // door old supervisors still check); a stray `signature` field is ignored, never trusted.
193
208
  return {
194
209
  target,
195
210
  sha256: entry.sha256.toLowerCase(),
196
211
  size: entry.size,
197
- signature: component === "worker" ? entry.signature.toLowerCase() : undefined,
198
212
  releaseSignature: entry.releaseSignature.toLowerCase(),
199
213
  };
200
214
  }
201
215
 
202
- /** 校验实际 bytes 与 manifest 元数据、raw worker 签名及 component-separated release 签名。 */
216
+ /** 校验实际 bytes 与 manifest 元数据及 component-separated release 签名。 */
203
217
  export function verifyReleaseArtifact({ component, version, entry, data, publicKey }) {
204
- if (!Buffer.isBuffer(data)) throw new Error("release 产物必须是 Buffer");
218
+ if (!Buffer.isBuffer(data)) throw new Error("Release artifact must be a Buffer");
205
219
  if (data.byteLength !== entry.size) {
206
- throw new Error(`${component} 产物大小不匹配:期望 ${entry.size},实际 ${data.byteLength}`);
220
+ throw new Error(`${component} artifact size does not match: expected ${entry.size}, got ${data.byteLength}`);
207
221
  }
208
222
  const sha256 = crypto.createHash("sha256").update(data).digest("hex");
209
223
  if (sha256 !== entry.sha256) {
210
- throw new Error(`${component} 产物 sha256 不匹配`);
211
- }
212
- if (
213
- component === "worker" &&
214
- !crypto.verify(null, data, publicKey, Buffer.from(entry.signature, "hex"))
215
- ) {
216
- throw new Error("worker 产物 legacy Ed25519 签名无效");
224
+ throw new Error(`${component} artifact sha256 does not match`);
217
225
  }
218
226
  const metadata = { version, target: entry.target, sha256, size: data.byteLength };
219
227
  const statement = releaseStatementFor(component, metadata);
220
228
  if (!crypto.verify(null, statement, publicKey, Buffer.from(entry.releaseSignature, "hex"))) {
221
- throw new Error(`${component} 产物 release Ed25519 签名无效`);
229
+ throw new Error(`${component} artifact release Ed25519 signature is invalid`);
222
230
  }
223
231
  }
224
232
 
@@ -257,16 +265,16 @@ export function installStagedPair(staged) {
257
265
  catch (restoreError) { restoreFailures.push(restoreError); }
258
266
  }
259
267
  if (restoreFailures.length > 0) {
260
- throw new AggregateError([error, ...restoreFailures], "daemon 二进制替换失败且旧版本恢复不完整");
268
+ throw new AggregateError([error, ...restoreFailures], "Replacing the binaries failed and the previous version could not be fully restored");
261
269
  }
262
270
  throw error;
263
271
  }
264
272
  }
265
273
 
266
- /** Publish a complete native runtime before changing any executable entry point.
267
- * A worker reached through a bin symlink resolves current_exe into its immutable
274
+ /** Publish a complete native release before changing any executable entry point.
275
+ * A runtime reached through a bin symlink resolves current_exe into its immutable
268
276
  * release directory, so interruption between entry-point updates cannot pair
269
- * that worker with a helper from another release. */
277
+ * that runtime with a helper from another release. */
270
278
  export function installNativeRelease(staged) {
271
279
  if (!staged.some(({ destination }) => path.basename(destination) === "coflux-transport")) return installStagedPair(staged);
272
280
  const binDir = path.dirname(staged[0].destination);
package/service-unit.mjs CHANGED
@@ -7,8 +7,8 @@
7
7
  import { existsSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
- /** The two variables the worker reads; kept identical to `packages/executor/src/env.ts` and
11
- * `crates/worker/src/executor_host.rs`. */
10
+ /** The two variables the runtime reads; kept identical to `packages/executor/src/env.ts` and
11
+ * `crates/runtime/src/executor_host.rs`. */
12
12
  export const EXECUTOR_NODE_ENV = "COFLUX_EXECUTOR_NODE";
13
13
  export const EXECUTOR_ENTRY_ENV = "COFLUX_EXECUTOR_ENTRY";
14
14
 
@@ -52,7 +52,7 @@ function oneLine(value) {
52
52
  return !/[\n\r]/.test(String(value));
53
53
  }
54
54
 
55
- export function plistXml({ supervisorBin, home, logFile, executor }) {
55
+ export function plistXml({ launcherBin, home, logFile, executor }) {
56
56
  const variables = [["COFLUX_HOME", home]];
57
57
  if (executor) {
58
58
  variables.push([EXECUTOR_NODE_ENV, executor.node], [EXECUTOR_ENTRY_ENV, executor.entry]);
@@ -66,7 +66,7 @@ export function plistXml({ supervisorBin, home, logFile, executor }) {
66
66
  <dict>
67
67
  <key>Label</key><string>com.coflux.daemon</string>
68
68
  <key>ProgramArguments</key>
69
- <array><string>${xml(supervisorBin)}</string></array>
69
+ <array><string>${xml(launcherBin)}</string></array>
70
70
  <key>EnvironmentVariables</key>
71
71
  <dict>
72
72
  ${entries}
@@ -81,9 +81,9 @@ ${entries}
81
81
  }
82
82
 
83
83
  /**
84
- * ptyd (plan 20260918-ptyd-terminal-custody) runs under its own label: the supervisor's SIGTERM
85
- * leaves sessions in ptyd, so `launchctl unload/load com.coflux.daemon` touches only the supervisor,
86
- * and KeepAlive brings ptyd back on its own if it crashes.
84
+ * ptyd (plan 20260918-ptyd-terminal-custody) runs under its own label: the launcher's SIGTERM
85
+ * leaves sessions in ptyd, so `launchctl unload/load com.coflux.daemon` touches only the launcher
86
+ * and its runtime, and KeepAlive brings ptyd back on its own if it crashes.
87
87
  */
88
88
  export function ptydPlistXml({ ptydBin, home, logFile }) {
89
89
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -107,9 +107,9 @@ export function ptydPlistXml({ ptydBin, home, logFile }) {
107
107
  }
108
108
 
109
109
  /**
110
- * systemd: ptyd has its own unit and the supervisor's unit Requires/After it. `systemctl restart
111
- * coflux-daemon` restarts only the supervisor's cgroup; ptyd's is not part of it, so terminals are
112
- * untouched. Stopping ptyd (down) also stops the supervisor (Requires).
110
+ * systemd: ptyd has its own unit and the launcher's unit Requires/After it. `systemctl restart
111
+ * coflux-daemon` restarts only the launcher's cgroup (runtime included); ptyd's is not part of it,
112
+ * so terminals are untouched. Stopping ptyd (down) also stops the launcher (Requires).
113
113
  */
114
114
  export function ptydSystemdUnit({ ptydBin, home }) {
115
115
  const environment = oneLine(home) ? `Environment=COFLUX_HOME=${home}\n` : "";
@@ -127,7 +127,7 @@ WantedBy=default.target
127
127
  `;
128
128
  }
129
129
 
130
- export function systemdUnit({ supervisorBin, home, executor }) {
130
+ export function systemdUnit({ launcherBin, home, executor }) {
131
131
  const variables = [["COFLUX_HOME", home]];
132
132
  if (executor) {
133
133
  variables.push([EXECUTOR_NODE_ENV, executor.node], [EXECUTOR_ENTRY_ENV, executor.entry]);
@@ -137,14 +137,14 @@ export function systemdUnit({ supervisorBin, home, executor }) {
137
137
  .map(([key, value]) => `Environment=${key}=${value}`)
138
138
  .join("\n");
139
139
  return `[Unit]
140
- Description=coflux daemon (supervisor)
140
+ Description=coflux daemon (launcher + runtime)
141
141
  After=network-online.target coflux-ptyd.service
142
142
  Wants=network-online.target
143
143
  Requires=coflux-ptyd.service
144
144
 
145
145
  [Service]
146
146
  ${environment}
147
- ExecStart=${supervisorBin}
147
+ ExecStart=${launcherBin}
148
148
  Restart=always
149
149
  RestartSec=2
150
150
 
@@ -152,3 +152,42 @@ RestartSec=2
152
152
  WantedBy=default.target
153
153
  `;
154
154
  }
155
+
156
+ /**
157
+ * Operator and test overrides the self-managed service keeps from the environment of
158
+ * `cofluxd up` / `cofluxd run`: the release public key (tests and self-keyed releases), the
159
+ * runtime's probation window, and its connection timing knobs. Nothing a Coflux terminal sets.
160
+ */
161
+ export const WATCHER_ENV_ALLOWLIST = [
162
+ "COFLUX_WORKER_PUBKEY",
163
+ "COFLUX_RUNTIME_PROBATION_MS",
164
+ "COFLUX_CONNECT_TIMEOUT_MS",
165
+ "COFLUX_IDLE_PING_MS",
166
+ "COFLUX_IDLE_GRACE_MS",
167
+ ];
168
+
169
+ /**
170
+ * The environment of the self-managed service (`coflux-launcher watch`): the same `COFLUX_*`
171
+ * variables the launchd / systemd units carry, and only those.
172
+ *
173
+ * Not `{ ...process.env, COFLUX_HOME }`: `cofluxd up` is often run from inside a Coflux terminal,
174
+ * whose environment is a copy of the running service's own — `COFLUX_LAUNCHER_SOCK`,
175
+ * `COFLUX_LAUNCHER_NONCE`, `COFLUX_TRANSPORT_*`, `COFLUX_LOCAL_GATEWAY_PORT`, `COFLUX_SESSION_ID` and,
176
+ * under the desktop app, `COFLUX_RUNTIME_CMD`. The launcher and runtime read those and would bind
177
+ * to another service's sockets. Every other `COFLUX_*` variable is dropped except the allowlist;
178
+ * everything else (PATH, HOME, locale) passes through.
179
+ */
180
+ export function watcherEnv(base, { home, executor }) {
181
+ const env = {};
182
+ for (const [key, value] of Object.entries(base)) {
183
+ if (value === undefined) continue;
184
+ if (key.startsWith("COFLUX_") && !WATCHER_ENV_ALLOWLIST.includes(key)) continue;
185
+ env[key] = value;
186
+ }
187
+ env.COFLUX_HOME = home;
188
+ if (executor) {
189
+ env[EXECUTOR_NODE_ENV] = executor.node;
190
+ env[EXECUTOR_ENTRY_ENV] = executor.entry;
191
+ }
192
+ return env;
193
+ }
@@ -276,7 +276,7 @@ command line is capped at 64 KB. Always write `--cmd=<value>` and `--title=<valu
276
276
  never separated by a space: a value that starts with `-` is otherwise taken for another option.
277
277
 
278
278
  `new` prints the terminal id on its first line whatever happens next; when the command could not
279
- be typed it says so on the following line (see "Errors").
279
+ be typed it says so in an error on stderr (see "Errors").
280
280
 
281
281
  The new terminal has the same `COFLUX_*` variables (pointing at its own task/session ids, same
282
282
  workspace as you).
@@ -537,7 +537,9 @@ this machine, so they are there whether or not a desktop is open.
537
537
 
538
538
  ### Errors from local commands
539
539
 
540
- Errors are one readable sentence; do what they say: "not inside a coflux terminal" = you are not
540
+ Errors go to stderr as `✗ Error: <what happened>` followed by an indented line saying what to do
541
+ next; the sentences below come from the daemon and appear after `Error:`. Do what they say: "not
542
+ inside a coflux terminal" = you are not
541
543
  in a coflux session; "terminal is not in this workspace or does not exist" = check the id with
542
544
  `list`, and if you moved into another workspace that is exactly what a terminal of the other one
543
545
  looks like (`coflux workspace` to confirm, `cd` back to reach it); "predates the daemon upgrade" =
@@ -635,8 +637,8 @@ CLI's exit code is the remote command's**, so an ordinary shell test around it w
635
637
  failures — device offline (it exists and is not connected), no device of this account with that id
636
638
  (`设备 <id> 不存在或不属于当前账号`: a mistyped or bare-prefix id, **not** an outage to investigate),
637
639
  that device's daemon too old (`cofluxd update && cofluxd restart` there), `--cwd` missing or not a
638
- directory, the timeout elapsing — are one readable sentence and exit **255**, so a remote exit 1 is
639
- never confused with "it never ran".
640
+ directory, the timeout elapsing — are reported as `✗ Error:` with a next step and exit **255**, so a
641
+ remote exit 1 is never confused with "it never ran".
640
642
 
641
643
  `--cwd` is the only addressing: an absolute path or a `~` prefix, defaulting to the daemon user's
642
644
  HOME. To run inside a workspace, pass the `path` from `coflux workspace list --device <deviceId>`.