@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 +176 -0
- package/bin/conductor-config.js +127 -12
- package/bin/conductor-daemon-update.js +55 -0
- package/bin/conductor-daemon.js +53 -29
- package/bin/conductor-project.js +3 -1
- package/package.json +5 -5
- package/src/daemon-lock.js +240 -0
- package/src/daemon-update.js +614 -0
- package/src/daemon.js +1162 -152
- package/src/fire-session-registry.js +136 -0
- package/src/guest-daemon.js +275 -0
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
|
package/bin/conductor-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"#
|
|
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
|
-
|
|
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
|
-
"#
|
|
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
|
-
"#
|
|
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);
|
package/bin/conductor-daemon.js
CHANGED
|
@@ -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(
|
|
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
|
|
120
|
-
const lockFile = path.join(workspaceRoot,
|
|
121
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
|
|
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");
|
package/bin/conductor-project.js
CHANGED
|
@@ -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.
|
|
4
|
-
"gitCommitId": "
|
|
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.
|
|
28
|
-
"@love-moon/conductor-sdk": "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.
|
|
41
|
+
"@love-moon/chat-web": "0.11.1"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|