@love-moon/conductor-cli 0.10.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,181 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.11.1
4
+
5
+ ### Patch Changes
6
+
7
+ - cd5e428: Add a built-in "Update daemon" action to the web settings Danger zone.
8
+
9
+ This replaces the hand-rolled `update` custom command people were keeping in
10
+ `custom_commands` (upgrade the CLI, then restart the daemon in tmux) with a
11
+ first-class button on any daemon advertising the new `update_daemon` capability.
12
+
13
+ The update is designed so that a failure never leaves the machine without a
14
+ daemon:
15
+
16
+ - It runs in a **detached** updater process, not inside the daemon, so the
17
+ daemon it replaces can exit without taking the update with it — and a
18
+ crashing update cannot take the daemon down either.
19
+ - Install and verification happen **first**. The running daemon is only stopped
20
+ once the new version is installed, reports the expected version, and passes
21
+ the node-pty native check. Anything failing before that aborts with the old
22
+ daemon still running and still serving tasks.
23
+ - A half-removed global install (the `ENOTEMPTY` case) is uninstalled, its
24
+ package directory removed, and the install retried once.
25
+ - Progress is journaled to `~/.conductor/state/daemon-update.json` (full output
26
+ in `~/.conductor/logs/daemon-update.log`), so the outcome survives the restart
27
+ and the web UI can poll for it across the daemon's reconnect.
28
+
29
+ Homebrew installs, non-global installs, and shared guest daemons refuse the
30
+ action up front with the reason shown in the UI.
31
+
32
+ - 8ad51f3: Adopt the tmux-detached Fire processes a previous daemon left running, instead
33
+ of judging them stale and marking their tasks `killed`.
34
+
35
+ With `fire_tmux_mode`, daemon shutdown deliberately leaves Fires alive. But a
36
+ fresh daemon started with an empty `activeTaskProcesses` map, and both stale
37
+ sweeps — `recoverStaleTasks()` on startup and `reconcileAssignedTasks()` on
38
+ websocket reconnect — decided "this task is dead" from that map alone. Every
39
+ survivor of the hand-off was therefore PATCHed to `killed` seconds after the
40
+ new daemon connected, destroying the work the hand-off exists to preserve.
41
+ This regressed three times (2026-07-23, 2026-07-31, 2026-08-31).
42
+
43
+ The daemon now hands its Fires over to its successor:
44
+
45
+ - Each tmux spawn persists a hand-off record under
46
+ `$CONDUCTOR_HOME/daemon/fire-sessions/`. It carries the fields the liveness
47
+ reaper cannot recompute — most importantly `exitMarkerToken`, a per-spawn
48
+ nonce without which a successor could never read a Fire's exit code.
49
+ - Startup enumerates surviving `conductor-fire-*` sessions and re-registers
50
+ them, so they regain the same watcher a freshly spawned Fire has. Both stale
51
+ sweeps wait for that pass before judging anything, and also adopt in place
52
+ any live session they find without a record.
53
+ - A session that outlived the Fire inside it is detected via its exit marker,
54
+ killed, and reported — adopting it would strand the task at `running`.
55
+
56
+ Two failure modes that used to read as "nothing is running" are now handled
57
+ explicitly: `tmux list-sessions` failing (a wedged server, a timeout) is no
58
+ longer indistinguishable from "no sessions", and a `tmux -V` probe that fails
59
+ once at startup no longer makes the daemon blind to tmux for its whole life.
60
+ Neither authorizes a kill any more.
61
+
62
+ Also fixed along the way:
63
+
64
+ - `stop_task`, `restart_task`, `refresh_session_inplace` and duplicate
65
+ `create_task` gated on a record's child process, which an adopted record does
66
+ not have. Stopping an adopted task reported it killed while leaving the Fire
67
+ running; restarting one could spawn a second Fire into the same worktree.
68
+ - `Recovered N stale task(s) to killed` counted candidates rather than
69
+ successes, so it claimed recovery even after the PATCH returned 409 or 500.
70
+ - Tasks whose spawn is still in flight are no longer eligible for stale kills.
71
+
72
+ Behaviour outside `fire_tmux_mode` is unchanged.
73
+
74
+ - @love-moon/conductor-sdk@0.11.1
75
+ - @love-moon/ai-sdk@0.11.1
76
+
77
+ ## 0.11.0
78
+
79
+ ### Minor Changes
80
+
81
+ - c987457: Allow creating a project whose workspace path does not exist yet.
82
+
83
+ The daemon now accepts `create_if_missing` on `validate_project_path` and will
84
+ `mkdir -p` the workspace before snapshotting it, advertising the new
85
+ `project_path_create` capability. In the web Create Project dialog a missing
86
+ path no longer dead-ends: it offers "Create this directory and continue", so a
87
+ typo still fails loudly instead of silently creating the wrong folder.
88
+ `conductor project create` gains `--create-workspace` for the same behavior.
89
+
90
+ - ccf902d: Add daemon sharing so a host can invite other conductor accounts to run tasks on their machine.
91
+
92
+ A host daemon can create share invitations from the web settings page. Each invite is scoped to one guest user and carries a `allow_cli_list` whitelist. When the guest accepts, the host daemon spawns an isolated guest daemon with a dedicated workspace and token; the guest sees only their own tasks and cannot access the host's projects or files outside the shared workspace.
93
+
94
+ Guest daemons are supervised by the host daemon: if the host restarts, guest configs are regenerated from the backend on the next reconcile; revoking a share stops the guest daemon and removes its workspace.
95
+
96
+ This changes the agent-token schema (a new `scope` column is required on the backend) and adds `conductor_guest`, `allow_cli_list`, and `daemon_name` handling to the guest config path.
97
+
98
+ ### Patch Changes
99
+
100
+ - 30afccc: Make the claude backend usable when conductor runs as root.
101
+
102
+ Claude Code refuses `bypassPermissions` / `--dangerously-skip-permissions` under
103
+ root unless `IS_SANDBOX=1` or `CLAUDE_CODE_BUBBLEWRAP` is set, so every claude
104
+ turn died immediately on root installs (docker, CI, bare VPS). Both routes a
105
+ claude command takes are now root-aware, sharing one check
106
+ (`isClaudeRootPermissionRestricted`, which mirrors claude's own gate):
107
+
108
+ - The agent-SDK session downgrades the permission mode to `acceptEdits` (auto
109
+ mode) and stops passing `allowDangerouslySkipPermissions`.
110
+ - The daemon's tool-preset PTY path rewrites the configured `allow_cli_list`
111
+ command via `resolveClaudeCommandForRoot`, using the same env the spawned
112
+ child receives, so a per-task `IS_SANDBOX=1` opt-out is honored.
113
+ - `conductor config` no longer writes `--dangerously-skip-permissions` into a
114
+ config generated on a root machine.
115
+
116
+ The mode is also configurable now: `claude --permission-mode acceptEdits` in
117
+ `allow_cli_list` is lifted out of the command string. An unrecognized mode still
118
+ falls back to the default instead of failing, but logs a warning at session boot.
119
+
120
+ ai-sdk gains three exports: `isClaudeRootPermissionRestricted`,
121
+ `resolveClaudeCommandForRoot`, `resolveClaudePermissionPolicy`.
122
+
123
+ - a3c41dd: Document every supported key in the config file `conductor config` generates.
124
+
125
+ The generated `config.yaml` only ever contained six keys, so nothing told a new
126
+ user that the other fifteen existed. They are now all present as commented-out
127
+ entries carrying their default value and env-var override, grouped into
128
+ Connection / Coding CLIs / Environment / Daemon behaviour / Optional features:
129
+
130
+ - `websocket_url`, previously written only when device authorization returned one
131
+ - `pre_prompt`, `custom_commands`, `cdp_user_data_dir`
132
+ - `remote_exec`, `fire_tmux_mode`, `auto_update`, `auto_update_respawn`,
133
+ `update_window`
134
+ - `ai_manager.codex.auth_json`, `serve_ai.{host,port,backend,api_key}`,
135
+ `channels.feishu.*`
136
+ - `envs.no_proxy`, `envs.DEEPSEEK_API_KEY`, `envs.DEEPSEEK_BASE_URL` alongside
137
+ the proxy keys and `AISDK_PROVIDER_PATH` that were already shown
138
+
139
+ Three notes correct documentation that would otherwise mislead. The commented
140
+ `websocket_url` example is derived from this install's own `backend_url` (via
141
+ the same rule as `ConductorConfig.resolvedWebsocketUrl`) instead of naming the
142
+ official host, so a self-hosted user who uncomments it is not pointed at
143
+ someone else's server. `daemon_name` is flagged as the one key whose precedence
144
+ is inverted — the config value wins over `CONDUCTOR_DAEMON_NAME`. `log_level`
145
+ is marked as validated on load but not yet consumed by any component.
146
+
147
+ Behaviour is unchanged: the same six keys are still written uncommented, and
148
+ the daemon still derives its own WebSocket URL from `backend_url` regardless of
149
+ the `websocket_url` key.
150
+
151
+ - ccf902d: Refuse `--force` daemon restart when it would take over an unrelated daemon instance.
152
+
153
+ Previously `conductor daemon --force` blindly replaced any existing lock, which could kill a daemon belonging to a different conductor home, backend URL, or agent token. The lock now stores an identity fingerprint (home directory, backend URL, agent token prefix, and daemon name) and `--force` only overwrites when the fingerprint matches. A mismatch reports the running daemon's identity and exits with code 7.
154
+
155
+ This makes `--force` safe for "restart my own stuck daemon" while preventing accidental cross-instance takeovers.
156
+
157
+ - 884945c: Keep every task in a multi-agent group in one working directory. Creating a
158
+ task with several agents and `worktree` enabled put the worker in
159
+ `.conductor/worktrees/<branch>/` but pinned each reviewer to the project root,
160
+ so reviewers read the base branch instead of the worker's changes. Reviewers
161
+ now inherit the worker's worktree.
162
+
163
+ Reviewers join as reuse-only members: they carry the full worktree identity —
164
+ so archiving one member no longer deletes a directory its siblings are still
165
+ running in — but never run `git worktree add` themselves. The daemon
166
+ additionally serializes worktree preparation per on-disk root, so concurrent
167
+ group members can no longer race on creating the same branch, and treats a
168
+ worktree as ready only once preparation has fully completed rather than as soon
169
+ as `.git` appears.
170
+
171
+ Two new optional daemon environment variables:
172
+ `CONDUCTOR_WORKTREE_REUSE_WAIT_TIMEOUT_MS` (default `180000`) and
173
+ `CONDUCTOR_WORKTREE_REUSE_POLL_INTERVAL_MS` (default `250`).
174
+
175
+ - Updated dependencies [30afccc]
176
+ - @love-moon/ai-sdk@0.11.0
177
+ - @love-moon/conductor-sdk@0.11.0
178
+
3
179
  ## 0.10.0
4
180
 
5
181
  ### Minor Changes
@@ -10,6 +10,7 @@ import { createRequire } from "node:module";
10
10
  import yargs from "yargs/yargs";
11
11
  import { hideBin } from "yargs/helpers";
12
12
  import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
13
+ import { isClaudeRootPermissionRestricted } from "@love-moon/ai-sdk";
13
14
  import { resolveConductorConfigPath } from "../src/conductor-paths.js";
14
15
 
15
16
  const CONFIG_FILE = resolveConductorConfigPath();
@@ -21,7 +22,12 @@ const packageJson = JSON.parse(
21
22
  const DEFAULT_CLIs = {
22
23
  claude: {
23
24
  command: "claude",
24
- execArgs: "--dangerously-skip-permissions",
25
+ // claude exits 1 with `--dangerously-skip-permissions` under root, so never
26
+ // write a flag the user's own machine cannot run. Same root check the
27
+ // daemon and the ai-sdk session use.
28
+ execArgs: isClaudeRootPermissionRestricted()
29
+ ? "--permission-mode acceptEdits"
30
+ : "--dangerously-skip-permissions",
25
31
  description: "Anthropic Claude CLI"
26
32
  },
27
33
  codex: {
@@ -293,20 +299,44 @@ async function main() {
293
299
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
294
300
 
295
301
  const lines = [
302
+ "# Conductor CLI configuration.",
303
+ "# Lookup order: --config-file > $CONDUCTOR_CONFIG > $CONDUCTOR_HOME/config.yaml",
304
+ "# > ~/.conductor/config.yaml",
305
+ "# Commented-out keys are optional; the value shown is the default.",
306
+ "",
307
+ "# ---------------------------------------------------------------------------",
308
+ "# Connection",
309
+ "# ---------------------------------------------------------------------------",
310
+ "",
311
+ "# Agent token issued by `conductor config`. Env: CONDUCTOR_AGENT_TOKEN",
296
312
  `agent_token: ${yamlQuote(token)}`,
313
+ "# Conductor server this daemon talks to. Env: CONDUCTOR_BACKEND_URL",
297
314
  `backend_url: ${yamlQuote(resolvedBackendUrl)}`,
315
+ "# Realtime endpoint used by fire and the other CLI commands; derived from",
316
+ "# backend_url when unset. The daemon always derives its own WebSocket URL",
317
+ "# from backend_url and ignores this key. Env: CONDUCTOR_WS_URL",
318
+ resolvedWebsocketUrl
319
+ ? `websocket_url: ${yamlQuote(resolvedWebsocketUrl)}`
320
+ : `# websocket_url: ${deriveWebsocketUrlHint(resolvedBackendUrl)}`,
321
+ "# Name this machine shows up as in the app. Precedence is inverted here:",
322
+ "# --daemon-name > this key > CONDUCTOR_DAEMON_NAME > hostname, so this value",
323
+ "# wins over the env var.",
298
324
  `daemon_name: ${yamlQuote(defaultDaemonName)}`,
325
+ "# debug | info | warning | error | critical (default: info). Validated on",
326
+ "# load but not consumed by any component yet. Env: CONDUCTOR_LOG_LEVEL",
299
327
  "log_level: debug",
328
+ "# Root directory where the daemon creates task workspaces. Env: CONDUCTOR_WS",
300
329
  "workspace: '~/ws/fires'",
301
330
  "",
302
- "# Allowed coding CLIs",
331
+ "# ---------------------------------------------------------------------------",
332
+ "# Coding CLIs",
333
+ "# ---------------------------------------------------------------------------",
334
+ "",
335
+ "# Allowed coding CLIs. Key = backend name shown in the app, value = command",
336
+ "# line used to launch it. Only backends listed here can run tasks.",
303
337
  "allow_cli_list:"
304
338
  ];
305
339
 
306
- if (resolvedWebsocketUrl) {
307
- lines.splice(2, 0, `websocket_url: ${yamlQuote(resolvedWebsocketUrl)}`);
308
- }
309
-
310
340
  if (detectedCLIs.length > 0) {
311
341
  detectedCLIs.forEach((cli) => {
312
342
  const info = DEFAULT_CLIs[cli];
@@ -326,17 +356,88 @@ async function main() {
326
356
  "# daemon's supported backends without removing the shipped dependency.",
327
357
  "# disable_built_in_cli_list:",
328
358
  "# - dsh",
329
- "# - copilot"
330
- );
331
-
332
- lines.push(
359
+ "# - copilot",
360
+ "",
361
+ "# Text automatically sent as the first message of every new task, per",
362
+ "# backend. ${VAR} / $VAR are expanded from the environment.",
363
+ "# pre_prompt:",
364
+ "# claude: Always answer in English and run the tests before finishing.",
365
+ "# codex: Read AGENTS.md first.",
333
366
  "",
334
- "# Uncomment to use custom envs, such as proxy.",
367
+ "# ---------------------------------------------------------------------------",
368
+ "# Environment for the coding CLIs",
369
+ "# ---------------------------------------------------------------------------",
370
+ "",
371
+ "# Extra environment variables exported into every backend process. Common",
372
+ "# uses: proxies, API keys, and loading a private AI SDK provider.",
335
373
  "# envs:",
336
374
  "# http_proxy: http://127.0.0.1:7890",
337
375
  "# https_proxy: http://127.0.0.1:7890",
338
376
  "# all_proxy: socks5://127.0.0.1:7890",
339
- "# AISDK_PROVIDER_PATH: path-to-private-aisdk-provider.js"
377
+ "# no_proxy: localhost,127.0.0.1",
378
+ "# DEEPSEEK_API_KEY: sk-xxx # required by the built-in dsh backend",
379
+ "# DEEPSEEK_BASE_URL: https://api.deepseek.com",
380
+ "# AISDK_PROVIDER_PATH: path-to-private-aisdk-provider.js",
381
+ "",
382
+ "# ---------------------------------------------------------------------------",
383
+ "# Daemon behaviour",
384
+ "# ---------------------------------------------------------------------------",
385
+ "",
386
+ "# Accept `conductor remote-exec` requests for this host.",
387
+ "# Env: CONDUCTOR_REMOTE_EXEC",
388
+ "# remote_exec: true",
389
+ "",
390
+ "# Launch each Fire process inside a detached tmux session so it survives a",
391
+ "# daemon restart. Requires tmux on PATH. Env: CONDUCTOR_FIRE_TMUX_MODE",
392
+ "# fire_tmux_mode: false",
393
+ "",
394
+ "# Let the daemon upgrade the CLI in place (npm/pnpm installs only).",
395
+ "# Env: CONDUCTOR_AUTO_UPDATE",
396
+ "# auto_update: true",
397
+ "",
398
+ "# After an auto-update, restart the daemon into the new version.",
399
+ "# Env: CONDUCTOR_AUTO_UPDATE_RESPAWN",
400
+ "# auto_update_respawn: false",
401
+ "",
402
+ "# Local-time window in which auto-update may run. Env: CONDUCTOR_UPDATE_WINDOW",
403
+ "# update_window: '02:00-04:00'",
404
+ "",
405
+ "# Scripts exposed to the app as one-click commands. Key = command name,",
406
+ "# value = executable script path (relative paths resolve against this file).",
407
+ "# custom_commands:",
408
+ "# restart-service: ~/.conductor/scripts/restart-service.sh",
409
+ "",
410
+ "# Chrome profile directory used by `conductor chrome` and the web-* backends.",
411
+ "# Env: CDP_USER_DATA_DIR",
412
+ "# cdp_user_data_dir: ~/.conductor/chrome-profile",
413
+ "",
414
+ "# ---------------------------------------------------------------------------",
415
+ "# Optional features",
416
+ "# ---------------------------------------------------------------------------",
417
+ "",
418
+ "# Extra Codex auth.json files to switch between from the app's account",
419
+ "# picker. The file name (without .json) becomes the account name.",
420
+ "# ai_manager:",
421
+ "# codex:",
422
+ "# auth_json:",
423
+ "# - ~/.codex/auth-work.json",
424
+ "# - ~/.codex/auth-personal.json",
425
+ "",
426
+ "# Defaults for `conductor serve-ai`, the OpenAI-compatible local server.",
427
+ "# serve_ai:",
428
+ "# host: 127.0.0.1",
429
+ "# port: 8787",
430
+ "# backend: codex",
431
+ "# api_key: local-dev-key",
432
+ "",
433
+ "# Feishu (Lark) chat channel, uploaded with `conductor channel connect feishu`.",
434
+ "# channels:",
435
+ "# feishu:",
436
+ "# app_id: cli_xxx",
437
+ "# app_secret: xxx",
438
+ "# verification_token: xxx",
439
+ "# encrypt_key: xxx",
440
+ ""
340
441
  );
341
442
 
342
443
  fs.writeFileSync(CONFIG_FILE, lines.join("\n"), "utf-8");
@@ -691,6 +792,20 @@ function yamlQuote(value) {
691
792
  return JSON.stringify(value);
692
793
  }
693
794
 
795
+ // Mirror ConductorConfig.resolvedWebsocketUrl so the commented-out
796
+ // `websocket_url` example shows THIS install's derived default rather than a
797
+ // hard-coded official host — a self-hosted user who uncomments it would
798
+ // otherwise point their CLI at someone else's server.
799
+ function deriveWebsocketUrlHint(backendUrl) {
800
+ try {
801
+ const url = new URL(backendUrl);
802
+ const scheme = url.protocol === "https:" ? "wss" : "ws";
803
+ return `${scheme}://${url.host}/ws/agent`;
804
+ } catch {
805
+ return "wss://<your-backend-host>/ws/agent";
806
+ }
807
+ }
808
+
694
809
  main()
695
810
  .catch((error) => {
696
811
  process.stderr.write(`Failed to write config: ${error?.message || error}\n`);
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Internal entrypoint for the built-in "Update Daemon" action.
5
+ *
6
+ * The daemon spawns this file *detached* (see `createDaemonUpdateHandlers`) and
7
+ * then forgets about it: the updater has to outlive the very daemon it is about
8
+ * to replace. It is deliberately not registered as a `conductor` subcommand —
9
+ * users update interactively with `conductor update`.
10
+ *
11
+ * Usage: node bin/conductor-daemon-update.js '<params-json>'
12
+ */
13
+
14
+ import process from "node:process";
15
+
16
+ import { runDaemonUpdate, writeDaemonUpdateStatus } from "../src/daemon-update.js";
17
+
18
+ const raw = process.argv[2];
19
+ if (!raw) {
20
+ process.stderr.write("conductor-daemon-update: missing params JSON argument\n");
21
+ process.exit(2);
22
+ }
23
+
24
+ let params;
25
+ try {
26
+ params = JSON.parse(raw);
27
+ } catch (error) {
28
+ process.stderr.write(`conductor-daemon-update: invalid params JSON: ${error?.message || error}\n`);
29
+ process.exit(2);
30
+ }
31
+
32
+ let result;
33
+ try {
34
+ result = await runDaemonUpdate(params, {
35
+ writeLine: (line) => process.stdout.write(`[${new Date().toISOString()}] ${line}\n`),
36
+ });
37
+ } catch (error) {
38
+ // The status file is how the UI learns the outcome; an unexpected crash here
39
+ // must not leave it stuck on "running" forever.
40
+ const message = error?.stack || error?.message || String(error);
41
+ process.stderr.write(`conductor-daemon-update: ${message}\n`);
42
+ writeDaemonUpdateStatus(params.statusPath, {
43
+ runId: params.runId ?? null,
44
+ status: "failed",
45
+ phase: "done",
46
+ message: "Update failed",
47
+ error: message,
48
+ logPath: params.logPath ?? null,
49
+ daemonRestarted: false,
50
+ finishedAt: new Date().toISOString(),
51
+ });
52
+ process.exit(1);
53
+ }
54
+
55
+ process.exit(result.status === "completed" ? 0 : 1);
@@ -14,6 +14,12 @@ import {
14
14
  resolveConductorConfigPath,
15
15
  resolveConductorHome,
16
16
  } from "../src/conductor-paths.js";
17
+ import {
18
+ DAEMON_LOCK_FILE_NAME,
19
+ buildDaemonInstanceIdentity,
20
+ describeForceRestartRefusal,
21
+ parseDaemonLockState,
22
+ } from "../src/daemon-lock.js";
17
23
 
18
24
  const argv = hideBin(process.argv);
19
25
 
@@ -109,39 +115,33 @@ function expandHomePath(inputPath, homeDir) {
109
115
  return inputPath;
110
116
  }
111
117
 
112
- function resolveWorkspaceRoot(configFilePath) {
113
- const userConfig = loadUserConfig(configFilePath);
118
+ function resolveWorkspaceRoot(userConfig) {
114
119
  const home = process.env.HOME || os.homedir() || "/tmp";
115
120
  const workspaceRoot = process.env.CONDUCTOR_WS || userConfig.workspace || path.join(home, "ws");
116
121
  return expandHomePath(workspaceRoot, home);
117
122
  }
118
123
 
119
- function findRunningDaemonPid(workspaceRoot) {
120
- const lockFile = path.join(workspaceRoot, "daemon.pid");
121
- if (!fs.existsSync(lockFile)) {
124
+ function readDaemonLockState(workspaceRoot) {
125
+ const lockFile = path.join(workspaceRoot, DAEMON_LOCK_FILE_NAME);
126
+ try {
127
+ return parseDaemonLockState(fs.readFileSync(lockFile, "utf8"));
128
+ } catch (_err) {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function findRunningDaemonPid(lockState) {
134
+ const pid = lockState?.pid;
135
+ if (!pid) {
122
136
  return null;
123
137
  }
124
138
  try {
125
- const pid = parseInt(fs.readFileSync(lockFile, "utf8"), 10);
126
- if (Number.isNaN(pid)) {
127
- return null;
128
- }
129
139
  process.kill(pid, 0);
130
140
  return pid;
131
141
  } catch (err) {
132
- if (err && err.code === "ESRCH") {
133
- return null;
134
- }
135
- return pidFromLockFile(lockFile);
136
- }
137
- }
138
-
139
- function pidFromLockFile(lockFile) {
140
- try {
141
- const pid = parseInt(fs.readFileSync(lockFile, "utf8"), 10);
142
- return Number.isNaN(pid) ? null : pid;
143
- } catch (_err) {
144
- return null;
142
+ // EPERM means the process exists but is owned by another OS user, which is
143
+ // still "running" for our purposes.
144
+ return err && err.code === "ESRCH" ? null : pid;
145
145
  }
146
146
  }
147
147
 
@@ -181,13 +181,37 @@ const effectiveConfigFile = args.configFile
181
181
  : undefined;
182
182
 
183
183
  if (args.nohup) {
184
- const workspaceRoot = resolveWorkspaceRoot(effectiveConfigFile);
185
- const runningPid = findRunningDaemonPid(workspaceRoot);
186
- if (runningPid && !args.force) {
187
- process.stderr.write(
188
- `${CLI_NAME} detected an existing daemon (PID ${runningPid}). Use --force to restart.\n`,
189
- );
190
- process.exit(1);
184
+ const userConfig = loadUserConfig(effectiveConfigFile);
185
+ const workspaceRoot = resolveWorkspaceRoot(userConfig);
186
+ const lockState = readDaemonLockState(workspaceRoot);
187
+ const runningPid = findRunningDaemonPid(lockState);
188
+ if (runningPid) {
189
+ if (!args.force) {
190
+ process.stderr.write(
191
+ `${CLI_NAME} detected an existing daemon (PID ${runningPid}). Use --force to restart.\n`,
192
+ );
193
+ process.exit(1);
194
+ }
195
+ const identity = buildDaemonInstanceIdentity({
196
+ conductorHome: materializedConductorPathEnv.CONDUCTOR_HOME,
197
+ configPath: materializedConductorPathEnv.CONDUCTOR_CONFIG,
198
+ workspaceRoot,
199
+ daemonName:
200
+ (typeof userConfig.daemon_name === "string" && userConfig.daemon_name.trim()) ||
201
+ process.env.CONDUCTOR_DAEMON_NAME ||
202
+ os.hostname(),
203
+ });
204
+ // Mirrors the ownership gate in src/daemon.js so --nohup --force fails in
205
+ // the foreground (where the user sees it) instead of inside a detached log.
206
+ const ownershipError = describeForceRestartRefusal({
207
+ lockState,
208
+ identity,
209
+ lockFile: path.join(workspaceRoot, DAEMON_LOCK_FILE_NAME),
210
+ });
211
+ if (ownershipError) {
212
+ process.stderr.write(`${CLI_NAME} ${ownershipError}\n`);
213
+ process.exit(1);
214
+ }
191
215
  }
192
216
 
193
217
  const logsDir = path.join(resolveConductorHome(), "logs");
@@ -7,7 +7,7 @@
7
7
  * list [--include-hidden]
8
8
  * show [<id|name>]
9
9
  * current
10
- * create [--name <n>] [--workspace-path <p>] [--daemon-host <h>] [--default] [--client-request-id <key>]
10
+ * create [--name <n>] [--workspace-path <p>] [--daemon-host <h>] [--create-workspace] [--default] [--client-request-id <key>]
11
11
  * set-default <id|name>
12
12
  * hide <id|name>
13
13
  * unhide <id|name>
@@ -204,6 +204,7 @@ async function handleCreate(argv, deps) {
204
204
  ...(defaultName ? { name: defaultName } : {}),
205
205
  ...(workspacePath ? { workspacePath } : {}),
206
206
  ...(daemonHost ? { daemonHost } : {}),
207
+ ...(argv.createWorkspace ? { createWorkspaceIfMissing: true } : {}),
207
208
  metadata,
208
209
  ...(argv.clientRequestId ? { clientRequestId: String(argv.clientRequestId) } : {}),
209
210
  };
@@ -375,6 +376,7 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
375
376
  .option("name", { type: "string", describe: "Project name (defaults to basename of workspace path)" })
376
377
  .option("workspace-path", { type: "string", describe: "Workspace path (defaults to cwd)" })
377
378
  .option("daemon-host", { type: "string", describe: "Daemon hostname for binding" })
379
+ .option("create-workspace", { type: "boolean", default: false, describe: "Create the workspace path on the daemon if it does not exist" })
378
380
  .option("default", { type: "boolean", default: false, describe: "Create the user's default project" })
379
381
  .option("client-request-id", { type: "string", describe: "Idempotency key" }),
380
382
  async (argv) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.10.0",
4
- "gitCommitId": "bc7b3a5",
3
+ "version": "0.11.1",
4
+ "gitCommitId": "c174c7e",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -24,8 +24,8 @@
24
24
  "test": "node --test test/*.test.js"
25
25
  },
26
26
  "dependencies": {
27
- "@love-moon/ai-sdk": "0.10.0",
28
- "@love-moon/conductor-sdk": "0.10.0",
27
+ "@love-moon/ai-sdk": "0.11.1",
28
+ "@love-moon/conductor-sdk": "0.11.1",
29
29
  "@github/copilot-sdk": "^0.3.0",
30
30
  "chrome-launcher": "^1.2.1",
31
31
  "chrome-remote-interface": "^0.33.0",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "@roamhq/wrtc": "^0.10.0",
41
- "@love-moon/chat-web": "0.10.0"
41
+ "@love-moon/chat-web": "0.11.1"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [