@love-moon/conductor-cli 0.7.7 → 0.9.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/CHANGELOG.md +36 -0
- package/bin/conductor-config.js +24 -0
- package/bin/conductor-fire.js +30 -2
- package/bin/conductor-remote-exec.js +365 -0
- package/bin/conductor-task.js +124 -0
- package/bin/conductor.js +8 -2
- package/package.json +5 -5
- package/src/ai-manager-handlers.js +5 -2
- package/src/daemon.js +296 -5
- package/src/entity-helpers.js +27 -2
- package/src/remote-exec-handlers.js +421 -0
- package/src/runtime-backends.js +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3a499cc: Add `conductor remote-exec` for running a single command on another daemon's
|
|
8
|
+
host, over a new `remote_exec_request`/`remote_exec_response` daemon protocol
|
|
9
|
+
pair gated by a `remote_exec` capability. Supports `--workspace`, `--env`,
|
|
10
|
+
`--timeout` with automatic polling for long commands, `--kill-on-timeout`, and
|
|
11
|
+
ssh-style exit codes. Hosts can decline with `remote_exec: false` in the config.
|
|
12
|
+
- a15b55d: Add per-turn multi-image and local context-file inputs, plus authenticated attachment materialization from Conductor Web to the executing daemon.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- Updated dependencies [a15b55d]
|
|
17
|
+
- @love-moon/ai-sdk@0.9.0
|
|
18
|
+
- @love-moon/conductor-sdk@0.9.0
|
|
19
|
+
|
|
20
|
+
## 0.8.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 959dd1d: Add project-configured worker and reviewer task groups, task-group discovery in
|
|
25
|
+
the SDK and `conductor task group`, and a lightweight daemon protocol for
|
|
26
|
+
refreshing the project agent registry.
|
|
27
|
+
- fe76139: Add `conductor task create` for creating app tasks with title, prompt, backend,
|
|
28
|
+
project resolution, and optional parent task-card grouping. App tasks now
|
|
29
|
+
require an online compatible daemon, and grouping results are exposed to
|
|
30
|
+
callers so partial success is visible without retrying task creation.
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
- Updated dependencies [959dd1d]
|
|
35
|
+
- Updated dependencies [fe76139]
|
|
36
|
+
- @love-moon/conductor-sdk@0.8.0
|
|
37
|
+
- @love-moon/ai-sdk@0.8.0
|
|
38
|
+
|
|
3
39
|
## 0.7.7
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/bin/conductor-config.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import readline from "node:readline/promises";
|
|
8
8
|
import { execFileSync, execSync } from "node:child_process";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
9
10
|
import yargs from "yargs/yargs";
|
|
10
11
|
import { hideBin } from "yargs/helpers";
|
|
11
12
|
import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
|
|
@@ -43,6 +44,11 @@ const DEFAULT_CLIs = {
|
|
|
43
44
|
execArgs: "",
|
|
44
45
|
description: "GitHub Copilot (built in via SDK)"
|
|
45
46
|
},
|
|
47
|
+
dsh: {
|
|
48
|
+
command: "dsh",
|
|
49
|
+
execArgs: "",
|
|
50
|
+
description: "DeepSeek Harness agent (built in via SDK; needs DEEPSEEK_API_KEY)"
|
|
51
|
+
},
|
|
46
52
|
// chat-web is the runtime backend (an in-process Chromium driver, not a CLI
|
|
47
53
|
// binary). It has multiple sub-providers selected via --model. Each user-
|
|
48
54
|
// facing alias below resolves to the chat-web runtime; advertising the bare
|
|
@@ -102,6 +108,20 @@ function isBuiltInChatWebAvailable() {
|
|
|
102
108
|
);
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
function isBuiltInDshAvailable() {
|
|
112
|
+
// The dsh runtime ships as pinned dependencies of @love-moon/ai-sdk (not of
|
|
113
|
+
// the CLI itself), so resolve it THROUGH ai-sdk's resolution context — a
|
|
114
|
+
// direct resolve from cli/bin fails under pnpm's isolated node_modules.
|
|
115
|
+
try {
|
|
116
|
+
const require = createRequire(import.meta.url);
|
|
117
|
+
const aiSdkEntry = require.resolve("@love-moon/ai-sdk");
|
|
118
|
+
createRequire(aiSdkEntry).resolve("@deepseek-ai/dsh-sdk-jsonrpc-demo/bin");
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
function buildConfigEntryLines(cli, info, { commented = false } = {}) {
|
|
106
126
|
const fullCommand = info.execArgs
|
|
107
127
|
? `${info.command} ${info.execArgs}`
|
|
@@ -335,6 +355,10 @@ function detectInstalledCLIs() {
|
|
|
335
355
|
detected.push(key);
|
|
336
356
|
continue;
|
|
337
357
|
}
|
|
358
|
+
if (runtimeBackend === "dsh" && isBuiltInDshAvailable()) {
|
|
359
|
+
detected.push(key);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
338
362
|
if (isCommandAvailable(info.command)) {
|
|
339
363
|
detected.push(key);
|
|
340
364
|
}
|
package/bin/conductor-fire.js
CHANGED
|
@@ -798,7 +798,7 @@ async function main() {
|
|
|
798
798
|
extraEnv: env,
|
|
799
799
|
extraHeaders: buildConductorConnectHeaders(pkgJson.version, {
|
|
800
800
|
backends: [cliArgs.backend],
|
|
801
|
-
capabilities: ["refresh_session_inplace"],
|
|
801
|
+
capabilities: ["refresh_session_inplace", "task_attachments_v1"],
|
|
802
802
|
}),
|
|
803
803
|
configFile: cliArgs.configFile,
|
|
804
804
|
onConnected: (event) => {
|
|
@@ -3069,7 +3069,13 @@ export class BridgeRunner {
|
|
|
3069
3069
|
}
|
|
3070
3070
|
|
|
3071
3071
|
async respondToMessage(message) {
|
|
3072
|
-
const
|
|
3072
|
+
const localAttachments = Array.isArray(message.attachments)
|
|
3073
|
+
? message.attachments.filter((attachment) =>
|
|
3074
|
+
attachment && typeof attachment === "object" && typeof (attachment.path || attachment.localPath) === "string"
|
|
3075
|
+
)
|
|
3076
|
+
: [];
|
|
3077
|
+
const rawContent = String(message.content || "").trim();
|
|
3078
|
+
const content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
|
|
3073
3079
|
if (!content) {
|
|
3074
3080
|
this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
|
|
3075
3081
|
return;
|
|
@@ -3091,6 +3097,22 @@ export class BridgeRunner {
|
|
|
3091
3097
|
String(message.role || "").toLowerCase() === "user" &&
|
|
3092
3098
|
content === this.pendingInitialPrompt;
|
|
3093
3099
|
const useInitialImages = isQueuedInitialPromptMessage && this.includeInitialImages;
|
|
3100
|
+
const nativeImageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
3101
|
+
const media = localAttachments
|
|
3102
|
+
.filter((attachment) => attachment.kind === "image" && nativeImageMimes.has(String(attachment.mimeType || "").toLowerCase()))
|
|
3103
|
+
.map((attachment) => ({
|
|
3104
|
+
kind: "image",
|
|
3105
|
+
path: attachment.path || attachment.localPath,
|
|
3106
|
+
mimeType: attachment.mimeType,
|
|
3107
|
+
name: attachment.name,
|
|
3108
|
+
}));
|
|
3109
|
+
const contextFiles = localAttachments
|
|
3110
|
+
.filter((attachment) => !media.some((image) => image.path === (attachment.path || attachment.localPath)))
|
|
3111
|
+
.map((attachment) => ({
|
|
3112
|
+
path: attachment.path || attachment.localPath,
|
|
3113
|
+
mimeType: attachment.mimeType,
|
|
3114
|
+
name: attachment.name,
|
|
3115
|
+
}));
|
|
3094
3116
|
if (
|
|
3095
3117
|
this.useSessionFileReplyStream &&
|
|
3096
3118
|
typeof this.backendSession?.setSessionReplyTarget === "function"
|
|
@@ -3140,6 +3162,8 @@ export class BridgeRunner {
|
|
|
3140
3162
|
|
|
3141
3163
|
const turnPromise = this.dispatchBackendTurn(content, {
|
|
3142
3164
|
useInitialImages,
|
|
3165
|
+
media,
|
|
3166
|
+
contextFiles,
|
|
3143
3167
|
onProgress: (payload) => {
|
|
3144
3168
|
void this.reportRuntimeStatus(payload, replyTo);
|
|
3145
3169
|
},
|
|
@@ -3330,8 +3354,12 @@ export class BridgeRunner {
|
|
|
3330
3354
|
const goalCapable = Boolean(
|
|
3331
3355
|
snapshot && snapshot.capabilities && snapshot.capabilities.goal === true,
|
|
3332
3356
|
);
|
|
3357
|
+
const hasAttachmentInputs =
|
|
3358
|
+
(Array.isArray(options.media) && options.media.length > 0) ||
|
|
3359
|
+
(Array.isArray(options.contextFiles) && options.contextFiles.length > 0);
|
|
3333
3360
|
const willRunGoal =
|
|
3334
3361
|
goalDirective != null &&
|
|
3362
|
+
!hasAttachmentInputs &&
|
|
3335
3363
|
goalCapable &&
|
|
3336
3364
|
typeof this.backendSession?.runGoal === "function";
|
|
3337
3365
|
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conductor remote-exec — run one command on another daemon's host.
|
|
5
|
+
*
|
|
6
|
+
* conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
7
|
+
* conductor remote-exec --target ubuntu -- bash -lc "pnpm build 2>&1 | tail -20"
|
|
8
|
+
*
|
|
9
|
+
* The command is sent as argv and spawned without a shell on the target, so
|
|
10
|
+
* quoting is not re-interpreted remotely. Pass `-- bash -lc "..."` when pipes
|
|
11
|
+
* or globs are actually wanted.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { ConductorConfig, loadConfig } from "@love-moon/conductor-sdk";
|
|
20
|
+
import { envForExplicitConfigFile } from "../src/config-env.js";
|
|
21
|
+
import { resolveConductorConfigPath } from "../src/conductor-paths.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Following ssh: the remote command's exit code is passed through verbatim
|
|
25
|
+
* (0-254) and 255 is reserved for this CLI's own failures. Reusing 1/2/4 for
|
|
26
|
+
* local errors would make `grep` finding nothing (1) or `ls` on a missing path
|
|
27
|
+
* (2) indistinguishable from a network error or a usage mistake.
|
|
28
|
+
*/
|
|
29
|
+
const EXIT = { OK: 0, CLI_ERROR: 255 };
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
31
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
32
|
+
/** How long a single request may block server-side before we switch to polling. */
|
|
33
|
+
const POST_WAIT_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
const VALUE_FLAGS = new Map([
|
|
36
|
+
["--target", "target"],
|
|
37
|
+
["-t", "target"],
|
|
38
|
+
["--workspace", "workspace"],
|
|
39
|
+
["-w", "workspace"],
|
|
40
|
+
["--timeout", "timeout"],
|
|
41
|
+
["--config-file", "configFile"],
|
|
42
|
+
["--env", "env"],
|
|
43
|
+
["-e", "env"],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const BOOL_FLAGS = new Map([
|
|
47
|
+
["--json", "json"],
|
|
48
|
+
["--kill-on-timeout", "killOnTimeout"],
|
|
49
|
+
["--help", "help"],
|
|
50
|
+
["-h", "help"],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const isMainModule = (() => {
|
|
54
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
55
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
56
|
+
return entryFile === currentFile;
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Split argv into flags and the remote command.
|
|
61
|
+
*
|
|
62
|
+
* Both `... --workspace /p ls .` and `... --workspace /p -- ls .` are accepted:
|
|
63
|
+
* the first token that is not a recognized flag starts the remote command, and
|
|
64
|
+
* everything after it is passed through verbatim.
|
|
65
|
+
*/
|
|
66
|
+
export function parseArgs(argv) {
|
|
67
|
+
const options = { env: {}, json: false, help: false };
|
|
68
|
+
const command = [];
|
|
69
|
+
|
|
70
|
+
let index = 0;
|
|
71
|
+
while (index < argv.length) {
|
|
72
|
+
const token = argv[index];
|
|
73
|
+
|
|
74
|
+
if (token === "--") {
|
|
75
|
+
command.push(...argv.slice(index + 1));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let name = token;
|
|
80
|
+
let inlineValue = null;
|
|
81
|
+
if (token.startsWith("--") && token.includes("=")) {
|
|
82
|
+
const splitAt = token.indexOf("=");
|
|
83
|
+
name = token.slice(0, splitAt);
|
|
84
|
+
inlineValue = token.slice(splitAt + 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (BOOL_FLAGS.has(name) && inlineValue === null) {
|
|
88
|
+
options[BOOL_FLAGS.get(name)] = true;
|
|
89
|
+
index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (VALUE_FLAGS.has(name)) {
|
|
94
|
+
const key = VALUE_FLAGS.get(name);
|
|
95
|
+
const value = inlineValue !== null ? inlineValue : argv[index + 1];
|
|
96
|
+
if (value === undefined) {
|
|
97
|
+
throw new UsageError(`${name} requires a value`);
|
|
98
|
+
}
|
|
99
|
+
if (key === "env") {
|
|
100
|
+
const splitAt = value.indexOf("=");
|
|
101
|
+
if (splitAt <= 0) {
|
|
102
|
+
throw new UsageError(`--env expects KEY=VALUE, got: ${value}`);
|
|
103
|
+
}
|
|
104
|
+
options.env[value.slice(0, splitAt)] = value.slice(splitAt + 1);
|
|
105
|
+
} else {
|
|
106
|
+
options[key] = value;
|
|
107
|
+
}
|
|
108
|
+
index += inlineValue !== null ? 1 : 2;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
command.push(...argv.slice(index));
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { options, command };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class UsageError extends Error {}
|
|
120
|
+
|
|
121
|
+
/** Accepts `500ms`, `30s`, `2m`, or a bare number of seconds. */
|
|
122
|
+
export function parseTimeoutMs(value) {
|
|
123
|
+
if (value === undefined || value === null || value === "") {
|
|
124
|
+
return DEFAULT_TIMEOUT_MS;
|
|
125
|
+
}
|
|
126
|
+
const raw = String(value).trim().toLowerCase();
|
|
127
|
+
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
|
|
128
|
+
if (!match) {
|
|
129
|
+
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
130
|
+
}
|
|
131
|
+
const amount = Number.parseFloat(match[1]);
|
|
132
|
+
const unit = match[2] || "s";
|
|
133
|
+
const multiplier = unit === "ms" ? 1 : unit === "m" ? 60_000 : 1_000;
|
|
134
|
+
const ms = Math.round(amount * multiplier);
|
|
135
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
136
|
+
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
137
|
+
}
|
|
138
|
+
return ms;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadCliConfig(configFile, env = process.env) {
|
|
142
|
+
const configPath = resolveConductorConfigPath(configFile, env);
|
|
143
|
+
const configEnv = envForExplicitConfigFile(configFile, env);
|
|
144
|
+
if (fs.existsSync(configPath)) {
|
|
145
|
+
return loadConfig(configPath, { env: configEnv });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const agentToken = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
|
|
149
|
+
const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
|
|
150
|
+
if (agentToken && backendUrl) {
|
|
151
|
+
return new ConductorConfig({ agentToken, backendUrl });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return loadConfig(configPath, { env: configEnv });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function callApi(config, method, pathname, body, fetchImpl) {
|
|
158
|
+
const url = new URL(pathname, config.backendUrl);
|
|
159
|
+
const response = await fetchImpl(url.toString(), {
|
|
160
|
+
method,
|
|
161
|
+
headers: {
|
|
162
|
+
Authorization: `Bearer ${config.agentToken}`,
|
|
163
|
+
Accept: "application/json",
|
|
164
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
165
|
+
},
|
|
166
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const text = await response.text();
|
|
170
|
+
let payload = null;
|
|
171
|
+
try {
|
|
172
|
+
payload = text ? JSON.parse(text) : null;
|
|
173
|
+
} catch {
|
|
174
|
+
payload = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const message = payload?.error || text.trim() || `HTTP ${response.status}`;
|
|
179
|
+
const error = new Error(message);
|
|
180
|
+
error.status = response.status;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
return payload;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
187
|
+
|
|
188
|
+
export function showHelp(consoleImpl = console) {
|
|
189
|
+
consoleImpl.log(`conductor remote-exec - run a command on another daemon's host
|
|
190
|
+
|
|
191
|
+
Usage:
|
|
192
|
+
conductor remote-exec --target <daemon> [options] <command> [args...]
|
|
193
|
+
conductor remote-exec --target <daemon> [options] -- <command> [args...]
|
|
194
|
+
|
|
195
|
+
Options:
|
|
196
|
+
-t, --target <daemon> Daemon name to run on (required)
|
|
197
|
+
-w, --workspace <path> Working directory on the target (default: target's home)
|
|
198
|
+
--timeout <dur> Overall deadline, e.g. 30s, 2m, 500ms (default: 60s)
|
|
199
|
+
-e, --env KEY=VALUE Extra environment variable (repeatable)
|
|
200
|
+
--json Print the raw run result as JSON
|
|
201
|
+
--kill-on-timeout Stop the remote command when --timeout is reached
|
|
202
|
+
(default: it keeps running on the target)
|
|
203
|
+
--config-file <p> Conductor config file to authenticate with
|
|
204
|
+
-h, --help Show this help
|
|
205
|
+
|
|
206
|
+
Notes:
|
|
207
|
+
The command is spawned without a shell. For pipes, globs or redirection use:
|
|
208
|
+
conductor remote-exec -t ubuntu -- bash -lc "ls | wc -l"
|
|
209
|
+
|
|
210
|
+
Exit codes follow ssh: the remote command's own code is passed through, and
|
|
211
|
+
255 means this CLI failed (bad usage, daemon offline, network error).
|
|
212
|
+
|
|
213
|
+
Examples:
|
|
214
|
+
conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
215
|
+
conductor remote-exec -t ubuntu -w /srv/app -- git log --oneline -5
|
|
216
|
+
`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function runRemoteExec(argv, deps = {}) {
|
|
220
|
+
const consoleImpl = deps.console || console;
|
|
221
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
222
|
+
const env = deps.env || process.env;
|
|
223
|
+
const sleep = deps.sleep || delay;
|
|
224
|
+
const now = deps.now || (() => Date.now());
|
|
225
|
+
|
|
226
|
+
let parsed;
|
|
227
|
+
try {
|
|
228
|
+
parsed = parseArgs(argv);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
231
|
+
return EXIT.CLI_ERROR;
|
|
232
|
+
}
|
|
233
|
+
const { options, command } = parsed;
|
|
234
|
+
|
|
235
|
+
if (options.help) {
|
|
236
|
+
showHelp(consoleImpl);
|
|
237
|
+
return EXIT.OK;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const target = typeof options.target === "string" ? options.target.trim() : "";
|
|
241
|
+
if (!target) {
|
|
242
|
+
consoleImpl.error("Error: --target <daemon> is required");
|
|
243
|
+
showHelp(consoleImpl);
|
|
244
|
+
return EXIT.CLI_ERROR;
|
|
245
|
+
}
|
|
246
|
+
if (command.length === 0) {
|
|
247
|
+
consoleImpl.error("Error: no command given");
|
|
248
|
+
showHelp(consoleImpl);
|
|
249
|
+
return EXIT.CLI_ERROR;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let timeoutMs;
|
|
253
|
+
try {
|
|
254
|
+
timeoutMs = parseTimeoutMs(options.timeout);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
257
|
+
return EXIT.CLI_ERROR;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let config;
|
|
261
|
+
try {
|
|
262
|
+
config = deps.config || loadCliConfig(options.configFile, env);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
265
|
+
return EXIT.CLI_ERROR;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
269
|
+
const deadline = now() + timeoutMs;
|
|
270
|
+
|
|
271
|
+
let run;
|
|
272
|
+
try {
|
|
273
|
+
run = await callApi(config, "POST", basePath, {
|
|
274
|
+
command: command[0],
|
|
275
|
+
args: command.slice(1),
|
|
276
|
+
...(options.workspace ? { workspace: options.workspace } : {}),
|
|
277
|
+
...(Object.keys(options.env).length > 0 ? { env: options.env } : {}),
|
|
278
|
+
// Deliberately short, and independent of `--timeout`: the overall deadline
|
|
279
|
+
// is owned by the poll loop below. Handing the daemon the full deadline
|
|
280
|
+
// would make one HTTP request block for it, and would leave the loop
|
|
281
|
+
// unreachable because the POST alone would consume the whole budget.
|
|
282
|
+
timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
|
|
283
|
+
}, fetchImpl);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
286
|
+
return EXIT.CLI_ERROR;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let pollError = null;
|
|
290
|
+
while (run?.status === "running" && now() < deadline) {
|
|
291
|
+
if (!run.runId) {
|
|
292
|
+
pollError = new Error("daemon reported a running command but returned no runId");
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
await sleep(POLL_INTERVAL_MS);
|
|
296
|
+
try {
|
|
297
|
+
run = await callApi(
|
|
298
|
+
config,
|
|
299
|
+
"GET",
|
|
300
|
+
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
301
|
+
null,
|
|
302
|
+
fetchImpl,
|
|
303
|
+
);
|
|
304
|
+
pollError = null;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// A saturated or briefly unreachable daemon can fail one status poll.
|
|
307
|
+
// Keep waiting until the caller's own deadline rather than aborting a
|
|
308
|
+
// long-running command that is still perfectly healthy.
|
|
309
|
+
pollError = error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (run?.status === "running" && options.killOnTimeout && run.runId) {
|
|
314
|
+
try {
|
|
315
|
+
run = await callApi(
|
|
316
|
+
config,
|
|
317
|
+
"DELETE",
|
|
318
|
+
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
319
|
+
null,
|
|
320
|
+
fetchImpl,
|
|
321
|
+
);
|
|
322
|
+
consoleImpl.error(`[conductor] deadline reached; stopped the command on ${target}`);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${error.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (options.json) {
|
|
329
|
+
consoleImpl.log(JSON.stringify(run, null, 2));
|
|
330
|
+
} else {
|
|
331
|
+
if (run?.stdoutTail) process.stdout.write(run.stdoutTail);
|
|
332
|
+
if (run?.stderrTail) process.stderr.write(run.stderrTail);
|
|
333
|
+
if (run?.truncated) {
|
|
334
|
+
consoleImpl.error(`[conductor] output truncated; showing the tail only`);
|
|
335
|
+
}
|
|
336
|
+
if (run?.error) {
|
|
337
|
+
consoleImpl.error(`Error: ${run.error}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (run?.status === "running") {
|
|
342
|
+
if (pollError) {
|
|
343
|
+
consoleImpl.error(`[conductor] last status poll failed: ${pollError.message}`);
|
|
344
|
+
}
|
|
345
|
+
consoleImpl.error(
|
|
346
|
+
`[conductor] still running on ${target} after ${timeoutMs}ms; ` +
|
|
347
|
+
`it keeps going there — poll GET ${basePath}/runs/${run.runId}, ` +
|
|
348
|
+
`stop it with DELETE on the same path, or use --kill-on-timeout`,
|
|
349
|
+
);
|
|
350
|
+
return EXIT.CLI_ERROR;
|
|
351
|
+
}
|
|
352
|
+
if (run?.status === "cancelled") {
|
|
353
|
+
return EXIT.CLI_ERROR;
|
|
354
|
+
}
|
|
355
|
+
if (typeof run?.exitCode === "number") {
|
|
356
|
+
return run.exitCode;
|
|
357
|
+
}
|
|
358
|
+
return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (isMainModule) {
|
|
362
|
+
// `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
|
|
363
|
+
// and exiting outright truncates them. Let the loop drain and end naturally.
|
|
364
|
+
process.exitCode = await runRemoteExec(process.argv.slice(2));
|
|
365
|
+
}
|
package/bin/conductor-task.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Subcommands:
|
|
7
7
|
* list [--project ...] [--issue <id>] [--status ...]
|
|
8
|
+
* create --title <title> [--prompt <prompt>] [--backend <backend>]
|
|
9
|
+
* [--parent-task-id <id>] [--project ...]
|
|
8
10
|
* show <id>
|
|
11
|
+
* group [<id>]
|
|
9
12
|
* send <id> [<message>] [--stdin] [--from-file FILE] [--metadata-json '{...}']
|
|
10
13
|
* insert <id> [<message>] [--stdin] [--from-file FILE] [--target-reply-to <msg-id>]
|
|
11
14
|
* messages <id> [--limit N] [--before <msg-id>]
|
|
@@ -63,6 +66,7 @@ function taskAsObject(task) {
|
|
|
63
66
|
sessionId: task.sessionId,
|
|
64
67
|
createdAt: task.createdAt,
|
|
65
68
|
updatedAt: task.updatedAt,
|
|
69
|
+
grouping: task.grouping ?? undefined,
|
|
66
70
|
};
|
|
67
71
|
}
|
|
68
72
|
|
|
@@ -243,6 +247,54 @@ async function handleList(argv, deps) {
|
|
|
243
247
|
return EXIT.OK;
|
|
244
248
|
}
|
|
245
249
|
|
|
250
|
+
async function handleCreate(argv, deps) {
|
|
251
|
+
const apis = await buildApis(deps);
|
|
252
|
+
const project = await resolveProject(apis, {
|
|
253
|
+
env: deps.env,
|
|
254
|
+
cwd: deps.cwd,
|
|
255
|
+
project: argv.project,
|
|
256
|
+
});
|
|
257
|
+
const title = String(argv.title ?? "").trim();
|
|
258
|
+
if (!title) {
|
|
259
|
+
const err = new Error("--title must not be empty");
|
|
260
|
+
err.code = "ARGS";
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const body = {
|
|
265
|
+
projectId: project.id,
|
|
266
|
+
title,
|
|
267
|
+
taskType: "ai_task",
|
|
268
|
+
...(argv.prompt !== undefined ? { initialContent: String(argv.prompt) } : {}),
|
|
269
|
+
...(argv.backend ? { backendType: String(argv.backend) } : {}),
|
|
270
|
+
...(argv.parentTaskId ? { parentTaskId: String(argv.parentTaskId) } : {}),
|
|
271
|
+
metadata: buildAuditMetadata(deps.env),
|
|
272
|
+
};
|
|
273
|
+
if (argv.dryRun) {
|
|
274
|
+
emitDryRun(
|
|
275
|
+
deps.stdout,
|
|
276
|
+
argv.json,
|
|
277
|
+
makeDryRunPayload("POST", `${buildBaseUrl(apis.config)}/api/tasks`, body),
|
|
278
|
+
);
|
|
279
|
+
return EXIT.OK;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const created = await apis.tasks.createTask(body);
|
|
283
|
+
const obj = taskAsObject(created);
|
|
284
|
+
if (argv.json) {
|
|
285
|
+
printJson(deps.stdout, obj);
|
|
286
|
+
return EXIT.OK;
|
|
287
|
+
}
|
|
288
|
+
printPretty(deps.stdout, `Created app task ${obj.id}: ${obj.title}`);
|
|
289
|
+
if (obj.grouping?.grouped === false) {
|
|
290
|
+
printPretty(
|
|
291
|
+
deps.stderr,
|
|
292
|
+
`Warning: ${obj.grouping.warning || `task was not grouped with ${obj.grouping.parentTaskId}`}. The task itself was created successfully.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return EXIT.OK;
|
|
296
|
+
}
|
|
297
|
+
|
|
246
298
|
async function handleShow(argv, deps) {
|
|
247
299
|
const apis = await buildApis(deps);
|
|
248
300
|
const task = await apis.tasks.getTask(argv.id);
|
|
@@ -267,6 +319,45 @@ async function handleShow(argv, deps) {
|
|
|
267
319
|
return EXIT.OK;
|
|
268
320
|
}
|
|
269
321
|
|
|
322
|
+
async function handleGroup(argv, deps) {
|
|
323
|
+
const apis = await buildApis(deps);
|
|
324
|
+
const taskId =
|
|
325
|
+
(argv.id && String(argv.id).trim()) ||
|
|
326
|
+
(deps.env.CONDUCTOR_TASK_ID && String(deps.env.CONDUCTOR_TASK_ID).trim());
|
|
327
|
+
if (!taskId) {
|
|
328
|
+
const err = new Error(
|
|
329
|
+
"No task id: pass <id> or run inside a task (CONDUCTOR_TASK_ID)",
|
|
330
|
+
);
|
|
331
|
+
err.statusCode = 400;
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
const group = await apis.tasks.getTaskGroup(taskId);
|
|
335
|
+
if (argv.json) {
|
|
336
|
+
printJson(deps.stdout, group);
|
|
337
|
+
return EXIT.OK;
|
|
338
|
+
}
|
|
339
|
+
if (!group.groupId) {
|
|
340
|
+
printPretty(deps.stdout, "(task is not in a group)");
|
|
341
|
+
return EXIT.OK;
|
|
342
|
+
}
|
|
343
|
+
printPretty(deps.stdout, `group ${group.groupId}`);
|
|
344
|
+
printPretty(
|
|
345
|
+
deps.stdout,
|
|
346
|
+
`${pad("ROLE", 10)} ${pad("TASK ID", 24)} ${pad("AGENT", 20)} STATUS`,
|
|
347
|
+
);
|
|
348
|
+
for (const member of group.members) {
|
|
349
|
+
const selfMark = member.isSelf ? " (you)" : "";
|
|
350
|
+
printPretty(
|
|
351
|
+
deps.stdout,
|
|
352
|
+
`${pad(member.role || "?", 10)} ${pad(member.taskId, 24)} ${pad(
|
|
353
|
+
member.agent || "-",
|
|
354
|
+
20,
|
|
355
|
+
)} ${member.status || ""}${selfMark}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return EXIT.OK;
|
|
359
|
+
}
|
|
360
|
+
|
|
270
361
|
async function handleSend(argv, deps) {
|
|
271
362
|
const apis = await buildApis(deps);
|
|
272
363
|
const content = readMessageInput({
|
|
@@ -485,6 +576,31 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
485
576
|
exitCode = await handleList(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
486
577
|
},
|
|
487
578
|
)
|
|
579
|
+
.command(
|
|
580
|
+
"create",
|
|
581
|
+
"Create a new app task through the frontend task pipeline",
|
|
582
|
+
(cmd) => cmd
|
|
583
|
+
.option("title", {
|
|
584
|
+
type: "string",
|
|
585
|
+
demandOption: true,
|
|
586
|
+
describe: "Task title",
|
|
587
|
+
})
|
|
588
|
+
.option("prompt", {
|
|
589
|
+
type: "string",
|
|
590
|
+
describe: "Initial user prompt",
|
|
591
|
+
})
|
|
592
|
+
.option("backend", {
|
|
593
|
+
type: "string",
|
|
594
|
+
describe: "AI backend type, for example codex or claude",
|
|
595
|
+
})
|
|
596
|
+
.option("parent-task-id", {
|
|
597
|
+
type: "string",
|
|
598
|
+
describe: "Display the new task in the same task-card group as this task",
|
|
599
|
+
}),
|
|
600
|
+
async (argv) => {
|
|
601
|
+
exitCode = await handleCreate(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
602
|
+
},
|
|
603
|
+
)
|
|
488
604
|
.command(
|
|
489
605
|
"show <id>",
|
|
490
606
|
"Show one task's detail",
|
|
@@ -493,6 +609,14 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
493
609
|
exitCode = await handleShow(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
494
610
|
},
|
|
495
611
|
)
|
|
612
|
+
.command(
|
|
613
|
+
"group [id]",
|
|
614
|
+
"Show the multi-agent group a task belongs to (defaults to $CONDUCTOR_TASK_ID)",
|
|
615
|
+
(cmd) => cmd.positional("id", { type: "string" }),
|
|
616
|
+
async (argv) => {
|
|
617
|
+
exitCode = await handleGroup(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
618
|
+
},
|
|
619
|
+
)
|
|
496
620
|
.command(
|
|
497
621
|
"send <id> [message]",
|
|
498
622
|
"Send a user message into a running task",
|