@love-moon/conductor-cli 0.15.2 → 0.17.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 +148 -0
- package/bin/conductor-auth.js +162 -0
- package/bin/conductor-config.js +6 -9
- package/bin/conductor-daemon-query.js +13 -17
- package/bin/conductor-daemon.js +6 -1
- package/bin/conductor-fire.js +91 -2
- package/bin/conductor-issue.js +215 -13
- package/bin/conductor-project.js +840 -27
- package/bin/conductor-search.js +150 -0
- package/bin/conductor-settings.js +608 -0
- package/bin/conductor-task.js +344 -9
- package/bin/conductor-update.js +17 -0
- package/bin/conductor.js +29 -8
- package/package.json +5 -5
- package/src/backend-http.js +246 -0
- package/src/daemon-share-commands.js +528 -0
- package/src/daemon-update.js +3 -0
- package/src/daemon.js +51 -11
- package/src/remote/mcp-launch.js +3 -3
- package/src/remote-file-handlers.js +65 -6
- package/src/task-commands.js +813 -0
- package/src/version-check.js +10 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `conductor task` verbs that mirror the web task UI and have no SDK wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Every verb talks to the same `/api/tasks/...` route the frontend calls, with
|
|
5
|
+
* the same body shape (see web/src/features/tasks/store.ts and
|
|
6
|
+
* web/src/features/chat/components/ChatView.tsx). Kept out of
|
|
7
|
+
* bin/conductor-task.js so that file stays focused on the SDK-backed verbs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
|
|
14
|
+
import { apiPath, argsError, buildHttp, formatTable, projectLabels, sendOrPreview } from "./backend-http.js";
|
|
15
|
+
import { EXIT, buildApis, buildAuditMetadata, printJson, printPretty, resolveProject } from "./entity-helpers.js";
|
|
16
|
+
|
|
17
|
+
// Destructive mutations (delete / archive) can wait on a daemon round trip.
|
|
18
|
+
// restart can wait up to 60s for a daemon ack (refresh-session / reclaim in
|
|
19
|
+
// web/src/app/api/tasks/[taskId]/restart/route.ts) and then still spawn a
|
|
20
|
+
// successor, so stay well above that: a client-side timeout while the server
|
|
21
|
+
// carries on invites a retry that creates a duplicate task.
|
|
22
|
+
const TASK_MUTATION_TIMEOUT_MS = 150_000;
|
|
23
|
+
|
|
24
|
+
const MIME_BY_EXT = {
|
|
25
|
+
".gif": "image/gif", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".png": "image/png",
|
|
26
|
+
".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf",
|
|
27
|
+
".json": "application/json", ".txt": "text/plain", ".md": "text/markdown",
|
|
28
|
+
".csv": "text/csv", ".log": "text/plain", ".zip": "application/zip",
|
|
29
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".mp4": "video/mp4", ".mov": "video/quicktime",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function taskPath(id, ...rest) {
|
|
33
|
+
return apiPath("tasks", id, ...rest);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function taskLine(task) {
|
|
37
|
+
if (!task || typeof task !== "object") return "";
|
|
38
|
+
const status = task.status ? ` [${task.status}]` : "";
|
|
39
|
+
return `${task.id}${status}${task.title ? ` ${task.title}` : ""}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function print(deps, argv, data, text) {
|
|
43
|
+
if (argv.json) {
|
|
44
|
+
printJson(deps.stdout, data ?? {});
|
|
45
|
+
} else if (text) {
|
|
46
|
+
printPretty(deps.stdout, text);
|
|
47
|
+
}
|
|
48
|
+
return EXIT.OK;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requireYes(argv, what) {
|
|
52
|
+
if (!argv.yes && !argv.dryRun) {
|
|
53
|
+
throw argsError(`${what} is destructive; pass --yes to confirm (or --dry-run to preview)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function trimmed(value) {
|
|
58
|
+
if (value === undefined || value === null) return undefined;
|
|
59
|
+
const text = String(value).trim();
|
|
60
|
+
return text || undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- lifecycle ------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export async function handleStop(argv, deps) {
|
|
66
|
+
const http = await buildHttp(deps);
|
|
67
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "PATCH", taskPath(argv.id), { status: "killed" });
|
|
68
|
+
if (dryRun) return EXIT.OK;
|
|
69
|
+
return print(deps, argv, data, `Stopped task ${taskLine(data) || argv.id}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Latest user message id: the reply target of the in-flight turn (what the web Stop button targets). */
|
|
73
|
+
async function latestUserMessageId(deps, taskId) {
|
|
74
|
+
const apis = await buildApis(deps);
|
|
75
|
+
const list = await apis.tasks.listTaskMessages(taskId, { limit: 50 });
|
|
76
|
+
const users = (Array.isArray(list) ? list : []).filter((msg) => msg?.role === "user" && msg.id);
|
|
77
|
+
if (users.length === 0) return null;
|
|
78
|
+
const withTime = users.filter((msg) => msg.createdAt || msg.created_at);
|
|
79
|
+
if (withTime.length === users.length) {
|
|
80
|
+
withTime.sort((a, b) => new Date(a.createdAt ?? a.created_at) - new Date(b.createdAt ?? b.created_at));
|
|
81
|
+
return withTime[withTime.length - 1].id;
|
|
82
|
+
}
|
|
83
|
+
return users[users.length - 1].id;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function handleInterrupt(argv, deps) {
|
|
87
|
+
const http = await buildHttp(deps);
|
|
88
|
+
let target = trimmed(argv.targetReplyTo);
|
|
89
|
+
if (!target && !argv.dryRun) {
|
|
90
|
+
target = await latestUserMessageId(deps, argv.id);
|
|
91
|
+
if (!target) throw argsError("Task has no user message to interrupt; pass --target-reply-to");
|
|
92
|
+
}
|
|
93
|
+
const body = { target_reply_to: target ?? "<latest user message id>" };
|
|
94
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "interrupt"), body);
|
|
95
|
+
if (dryRun) return EXIT.OK;
|
|
96
|
+
return print(deps, argv, data, `Interrupt requested for task ${argv.id} (reply target ${target})`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function handleRestart(argv, deps) {
|
|
100
|
+
const body = {};
|
|
101
|
+
if (argv.strategy) body.strategy = argv.strategy;
|
|
102
|
+
if (trimmed(argv.backend)) body.backend_type = trimmed(argv.backend);
|
|
103
|
+
if (argv.refreshSession) body.restart_mode = "refresh_session";
|
|
104
|
+
if (trimmed(argv.daemonHost)) body.agent_host = trimmed(argv.daemonHost);
|
|
105
|
+
if (trimmed(argv.codeHost)) {
|
|
106
|
+
if (argv.strategy !== "new_task") throw argsError("--code-host requires --strategy new_task");
|
|
107
|
+
body.code_host = trimmed(argv.codeHost);
|
|
108
|
+
}
|
|
109
|
+
if (trimmed(argv.firstMessage)) {
|
|
110
|
+
if (argv.strategy !== "new_task") throw argsError("--first-message requires --strategy new_task");
|
|
111
|
+
body.first_message = trimmed(argv.firstMessage);
|
|
112
|
+
}
|
|
113
|
+
if (argv.refreshSession && argv.strategy) {
|
|
114
|
+
throw argsError("--refresh-session cannot be combined with --strategy");
|
|
115
|
+
}
|
|
116
|
+
const http = await buildHttp(deps);
|
|
117
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "restart"), body, {
|
|
118
|
+
timeoutMs: TASK_MUTATION_TIMEOUT_MS,
|
|
119
|
+
});
|
|
120
|
+
if (dryRun) return EXIT.OK;
|
|
121
|
+
const task = data?.task;
|
|
122
|
+
const mode = data?.mode ? ` (${data.mode})` : "";
|
|
123
|
+
const text = task && task.id !== argv.id
|
|
124
|
+
? `Restarted task ${argv.id} as new task ${taskLine(task)}${mode}`
|
|
125
|
+
: `Restarted task ${argv.id}${mode}`;
|
|
126
|
+
return print(deps, argv, data, text);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function handleDelete(argv, deps) {
|
|
130
|
+
requireYes(argv, "Deleting a task");
|
|
131
|
+
const http = await buildHttp(deps);
|
|
132
|
+
const query = argv.permanent ? { permanent: "1" } : undefined;
|
|
133
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "DELETE", taskPath(argv.id), undefined, {
|
|
134
|
+
query,
|
|
135
|
+
timeoutMs: TASK_MUTATION_TIMEOUT_MS,
|
|
136
|
+
});
|
|
137
|
+
if (dryRun) return EXIT.OK;
|
|
138
|
+
return print(deps, argv, data ?? { deleted: true, id: argv.id }, `Deleted task ${argv.id}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function handleArchive(argv, deps) {
|
|
142
|
+
const http = await buildHttp(deps);
|
|
143
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "achieve"), {}, {
|
|
144
|
+
timeoutMs: TASK_MUTATION_TIMEOUT_MS,
|
|
145
|
+
});
|
|
146
|
+
if (dryRun) return EXIT.OK;
|
|
147
|
+
return print(deps, argv, data, `Archived task ${argv.id}; restore it with \`conductor task unarchive ${argv.id}\``);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Restore an archived task. Same two steps as the web "Recover" button:
|
|
152
|
+
* `unachieve` only decides the daemon + strategy (409 `daemon_offline` with
|
|
153
|
+
* candidates when the original daemon is gone), then `restart` executes it.
|
|
154
|
+
*/
|
|
155
|
+
export async function handleUnarchive(argv, deps) {
|
|
156
|
+
const body = trimmed(argv.daemonHost) ? { agent_host: trimmed(argv.daemonHost) } : {};
|
|
157
|
+
const http = await buildHttp(deps);
|
|
158
|
+
if (argv.dryRun) {
|
|
159
|
+
await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "unachieve"), body);
|
|
160
|
+
return EXIT.OK;
|
|
161
|
+
}
|
|
162
|
+
let plan;
|
|
163
|
+
try {
|
|
164
|
+
plan = await http.post(taskPath(argv.id, "unachieve"), body, { timeoutMs: TASK_MUTATION_TIMEOUT_MS });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const candidates = error?.details?.candidates;
|
|
167
|
+
if (error?.statusCode === 409 && error?.details?.code === "daemon_offline" && Array.isArray(candidates)) {
|
|
168
|
+
const hosts = candidates.map((c) => (typeof c === "string" ? c : c?.host)).filter(Boolean);
|
|
169
|
+
if (hosts.length) {
|
|
170
|
+
error.details = { ...error.details, error: `${error.details.error} Retry with --daemon-host <${hosts.join("|")}>` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
const backend = trimmed(argv.backend);
|
|
176
|
+
// `unachieve` plans against the task's current backend, so an "inplace" plan
|
|
177
|
+
// becomes a 409 at `restart` once --backend switches it. Drop the strategy in
|
|
178
|
+
// that case and let `restart` pick: inplace on the same backend, new_task on a
|
|
179
|
+
// different one (what the web "create new task" recovery sends).
|
|
180
|
+
const keepStrategy = !(backend && plan?.strategy === "inplace");
|
|
181
|
+
const restartBody = {
|
|
182
|
+
...(keepStrategy && plan?.strategy ? { strategy: plan.strategy } : {}),
|
|
183
|
+
...(plan?.agentHost ? { agent_host: plan.agentHost } : {}),
|
|
184
|
+
...(backend ? { backend_type: backend } : {}),
|
|
185
|
+
};
|
|
186
|
+
const data = await http.post(taskPath(argv.id, "restart"), restartBody, { timeoutMs: TASK_MUTATION_TIMEOUT_MS });
|
|
187
|
+
const task = data?.task;
|
|
188
|
+
const text = task?.id && task.id !== argv.id
|
|
189
|
+
? `Restored task ${argv.id} as new task ${taskLine(task)} on ${plan?.agentHost ?? "its daemon"}`
|
|
190
|
+
: `Restored task ${argv.id} on ${plan?.agentHost ?? "its daemon"}`;
|
|
191
|
+
return print(deps, argv, { plan, ...data }, text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- attributes -----------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
export async function handleRename(argv, deps) {
|
|
197
|
+
const title = trimmed(argv.title);
|
|
198
|
+
if (!title) throw argsError("title must not be empty");
|
|
199
|
+
const http = await buildHttp(deps);
|
|
200
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "PATCH", taskPath(argv.id), { title });
|
|
201
|
+
if (dryRun) return EXIT.OK;
|
|
202
|
+
return print(deps, argv, data, `Renamed task ${argv.id} to "${title}"`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function handlePin(argv, deps, pinned) {
|
|
206
|
+
const http = await buildHttp(deps);
|
|
207
|
+
// The PATCH route merges a metadata object into the existing metadata.
|
|
208
|
+
const body = { metadata: { pinnedAt: pinned ? new Date().toISOString() : null } };
|
|
209
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "PATCH", taskPath(argv.id), body);
|
|
210
|
+
if (dryRun) return EXIT.OK;
|
|
211
|
+
return print(deps, argv, data, `${pinned ? "Pinned" : "Unpinned"} task ${argv.id}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function handleMove(argv, deps) {
|
|
215
|
+
let targetId = null;
|
|
216
|
+
if (!argv.back) {
|
|
217
|
+
if (!trimmed(argv.targetProject)) throw argsError("Pass a target project, or --back to file the task under its own project");
|
|
218
|
+
const apis = await buildApis(deps);
|
|
219
|
+
const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.targetProject });
|
|
220
|
+
targetId = project.id;
|
|
221
|
+
} else if (trimmed(argv.targetProject)) {
|
|
222
|
+
throw argsError("Pass either a target project or --back, not both");
|
|
223
|
+
}
|
|
224
|
+
const http = await buildHttp(deps);
|
|
225
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "PUT", taskPath(argv.id, "second-project"), {
|
|
226
|
+
second_project_id: targetId,
|
|
227
|
+
});
|
|
228
|
+
if (dryRun) return EXIT.OK;
|
|
229
|
+
return print(
|
|
230
|
+
deps,
|
|
231
|
+
argv,
|
|
232
|
+
data,
|
|
233
|
+
targetId ? `Moved task ${argv.id} to project ${targetId} (display only)` : `Moved task ${argv.id} back to its own project`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function handleLabels(argv, deps) {
|
|
238
|
+
const ids = (argv.labelIds || []).map((value) => String(value).trim()).filter(Boolean);
|
|
239
|
+
if (ids.length === 0 && !argv.clear) throw argsError("Pass one or more label ids, or --clear to remove all labels");
|
|
240
|
+
if (ids.length > 0 && argv.clear) throw argsError("Pass label ids or --clear, not both");
|
|
241
|
+
const http = await buildHttp(deps);
|
|
242
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "PUT", taskPath(argv.id, "labels"), { label_ids: ids });
|
|
243
|
+
if (dryRun) return EXIT.OK;
|
|
244
|
+
return print(deps, argv, data, ids.length ? `Set labels on task ${argv.id}: ${ids.join(", ")}` : `Cleared labels on task ${argv.id}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function handleShare(argv, deps) {
|
|
248
|
+
const http = await buildHttp(deps);
|
|
249
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "share"), {});
|
|
250
|
+
if (dryRun) return EXIT.OK;
|
|
251
|
+
const url = data?.token ? `${http.baseUrl}/share/${data.token}` : null;
|
|
252
|
+
return print(deps, argv, url ? { ...data, url } : data, url ? `Share link: ${url}` : `Shared task ${argv.id}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Open a temporary preview link for a file on the task's daemon (what clicking a file link in the chat does). */
|
|
256
|
+
export async function handlePreview(argv, deps) {
|
|
257
|
+
const http = await buildHttp(deps);
|
|
258
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "preview"), { path: argv.path });
|
|
259
|
+
if (dryRun) return EXIT.OK;
|
|
260
|
+
const url = data?.viewUrl ? `${http.baseUrl}${data.viewUrl}` : null;
|
|
261
|
+
return print(deps, argv, url ? { ...data, url } : data, url ? `Preview link (expires after 5 idle minutes): ${url}` : `No preview link for ${argv.path}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function handleUnshare(argv, deps) {
|
|
265
|
+
const http = await buildHttp(deps);
|
|
266
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "DELETE", taskPath(argv.id, "share"));
|
|
267
|
+
if (dryRun) return EXIT.OK;
|
|
268
|
+
return print(deps, argv, data ?? { unshared: true }, `Revoked the share link of task ${argv.id}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `abc`, `/share/abc` or `https://host/share/abc` → `abc`. */
|
|
272
|
+
export function extractShareToken(input) {
|
|
273
|
+
const raw = trimmed(input);
|
|
274
|
+
if (!raw) throw argsError("share token or link is required");
|
|
275
|
+
const match = raw.match(/\/share\/([^/?#]+)/);
|
|
276
|
+
if (match) return decodeURIComponent(match[1]);
|
|
277
|
+
if (/^[a-z]+:\/\//i.test(raw) || raw.includes("/")) throw argsError(`not a task share link: ${raw}`);
|
|
278
|
+
return raw;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Read a shared task (what the public /share/<token> page shows). */
|
|
282
|
+
export async function handleShared(argv, deps) {
|
|
283
|
+
const token = extractShareToken(argv.token);
|
|
284
|
+
const http = await buildHttp(deps);
|
|
285
|
+
const data = await http.get(apiPath("shared", token));
|
|
286
|
+
if (argv.json) {
|
|
287
|
+
printJson(deps.stdout, data);
|
|
288
|
+
return EXIT.OK;
|
|
289
|
+
}
|
|
290
|
+
const task = data?.task ?? {};
|
|
291
|
+
const header = [`${task.title ?? "(untitled)"}${task.status ? ` [${task.status}]` : ""}`];
|
|
292
|
+
if (task.expiresAt) header.push(`link expires ${task.expiresAt}`);
|
|
293
|
+
printPretty(deps.stdout, header.join(" · "));
|
|
294
|
+
for (const msg of Array.isArray(data?.messages) ? data.messages : []) {
|
|
295
|
+
printPretty(deps.stdout, `[${msg.role || "msg"}] ${String(msg.content ?? "")}`);
|
|
296
|
+
}
|
|
297
|
+
return EXIT.OK;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Mirrors web/src/lib/speech/transcribe.ts (MAX_AUDIO_BYTES, isSupportedAudioType).
|
|
301
|
+
const SPEECH_MAX_BYTES = 25 * 1024 * 1024;
|
|
302
|
+
const SPEECH_MIME_BY_EXT = { ".wav": "audio/wav", ".mp3": "audio/mpeg" };
|
|
303
|
+
|
|
304
|
+
/** Speech to text through the same route as the web composer's voice input. */
|
|
305
|
+
export async function handleTranscribe(argv, deps) {
|
|
306
|
+
const filePath = path.resolve(deps.cwd || process.cwd(), String(argv.file));
|
|
307
|
+
let stats;
|
|
308
|
+
try {
|
|
309
|
+
stats = fs.statSync(filePath);
|
|
310
|
+
} catch {
|
|
311
|
+
stats = null;
|
|
312
|
+
}
|
|
313
|
+
if (!stats || !stats.isFile()) throw argsError(`File not found: ${filePath}`);
|
|
314
|
+
if (stats.size === 0) throw argsError(`File is empty: ${filePath}`);
|
|
315
|
+
if (stats.size > SPEECH_MAX_BYTES) throw argsError(`File is larger than 25 MB: ${filePath}`);
|
|
316
|
+
const name = path.basename(filePath);
|
|
317
|
+
const type = SPEECH_MIME_BY_EXT[path.extname(name).toLowerCase()];
|
|
318
|
+
if (!type) throw argsError("Only .wav and .mp3 audio is supported");
|
|
319
|
+
const http = await buildHttp(deps);
|
|
320
|
+
if (argv.dryRun) {
|
|
321
|
+
return sendOrPreview(http, argv, deps, "POST", apiPath("speech", "transcribe"), {
|
|
322
|
+
file: `<upload ${name}>`,
|
|
323
|
+
...(trimmed(argv.language) ? { language: trimmed(argv.language) } : {}),
|
|
324
|
+
}).then(() => EXIT.OK);
|
|
325
|
+
}
|
|
326
|
+
const form = new FormData();
|
|
327
|
+
form.set("file", new Blob([fs.readFileSync(filePath)], { type }), name);
|
|
328
|
+
if (trimmed(argv.language)) form.set("language", trimmed(argv.language));
|
|
329
|
+
const data = await http.upload(apiPath("speech", "transcribe"), form);
|
|
330
|
+
return print(deps, argv, data, String(data?.text ?? ""));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Text to speech (the web voice mode's reply voice); saves an .mp3. */
|
|
334
|
+
export async function handleSpeak(argv, deps) {
|
|
335
|
+
const text = trimmed(argv.text);
|
|
336
|
+
if (!text) throw argsError("Text is required");
|
|
337
|
+
const http = await buildHttp(deps);
|
|
338
|
+
if (argv.dryRun) {
|
|
339
|
+
return sendOrPreview(http, argv, deps, "POST", apiPath("speech", "synthesize"), { text, format: "mp3" }).then(() => EXIT.OK);
|
|
340
|
+
}
|
|
341
|
+
const response = await http.download(apiPath("speech", "synthesize"), { body: { text, format: "mp3" } });
|
|
342
|
+
let bytes;
|
|
343
|
+
try {
|
|
344
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
345
|
+
} catch (error) {
|
|
346
|
+
// Synthesis failed mid-stream: never save a truncated file as success.
|
|
347
|
+
throw new Error(`Speech synthesis was interrupted: ${error?.message || String(error)}`);
|
|
348
|
+
}
|
|
349
|
+
if (argv.output === "-") {
|
|
350
|
+
deps.stdout.write(bytes);
|
|
351
|
+
return EXIT.OK;
|
|
352
|
+
}
|
|
353
|
+
const target = path.resolve(deps.cwd || process.cwd(), argv.output ? String(argv.output) : "speech.mp3");
|
|
354
|
+
fs.writeFileSync(target, bytes);
|
|
355
|
+
return print(deps, argv, { path: target, bytes: bytes.length }, `Saved ${bytes.length} bytes to ${target}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- persistent tasks (RFC 0039) -----------------------------------------
|
|
359
|
+
|
|
360
|
+
export async function handlePersistent(argv, deps) {
|
|
361
|
+
const body = {};
|
|
362
|
+
if (argv.enable && argv.disable) throw argsError("Pass --enable or --disable, not both");
|
|
363
|
+
if (argv.enable) body.enabled = true;
|
|
364
|
+
if (argv.disable) body.enabled = false;
|
|
365
|
+
if (argv.autoEndRound !== undefined) body.autoEndRound = Boolean(argv.autoEndRound);
|
|
366
|
+
if (argv.instructions !== undefined) body.instructions = String(argv.instructions);
|
|
367
|
+
if (argv.summary !== undefined) body.summary = String(argv.summary);
|
|
368
|
+
if (Object.keys(body).length === 0) throw argsError("Pass at least one of --enable, --disable, --auto-end-round, --instructions, --summary");
|
|
369
|
+
const http = await buildHttp(deps);
|
|
370
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "PATCH", taskPath(argv.id, "persistent"), body);
|
|
371
|
+
if (dryRun) return EXIT.OK;
|
|
372
|
+
return print(deps, argv, data, `Updated persistent settings of task ${argv.id}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function handleRoundEnd(argv, deps) {
|
|
376
|
+
const http = await buildHttp(deps);
|
|
377
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "rounds", "end"), {}, {
|
|
378
|
+
timeoutMs: TASK_MUTATION_TIMEOUT_MS,
|
|
379
|
+
});
|
|
380
|
+
if (dryRun) return EXIT.OK;
|
|
381
|
+
return print(deps, argv, data, `Ended the current round of task ${argv.id}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function handleRoundStart(argv, deps, readContent) {
|
|
385
|
+
const content = readContent();
|
|
386
|
+
const body = { content };
|
|
387
|
+
if (trimmed(argv.backend)) body.backend_type = trimmed(argv.backend);
|
|
388
|
+
if (trimmed(argv.daemonHost)) body.agent_host = trimmed(argv.daemonHost);
|
|
389
|
+
if (argv.worktree) body.worktree = argv.worktree;
|
|
390
|
+
const http = await buildHttp(deps);
|
|
391
|
+
if (argv.expectedRound !== undefined) {
|
|
392
|
+
body.expected_round = Number(argv.expectedRound);
|
|
393
|
+
} else if (argv.dryRun) {
|
|
394
|
+
body.expected_round = "<current round>";
|
|
395
|
+
} else {
|
|
396
|
+
// Like the web composer: pin the round we saw so a concurrent start fails
|
|
397
|
+
// instead of silently ending a round someone else just started.
|
|
398
|
+
const task = await http.get(taskPath(argv.id));
|
|
399
|
+
const round = task?.metadata?.persistent?.round;
|
|
400
|
+
body.expected_round = Number.isInteger(round) && round > 0 ? round : 1;
|
|
401
|
+
}
|
|
402
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "rounds"), body, {
|
|
403
|
+
timeoutMs: TASK_MUTATION_TIMEOUT_MS,
|
|
404
|
+
});
|
|
405
|
+
if (dryRun) return EXIT.OK;
|
|
406
|
+
return print(deps, argv, data, `Started a new round on task ${argv.id}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function handleCleanupWorktree(argv, deps) {
|
|
410
|
+
const http = await buildHttp(deps);
|
|
411
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "worktree"), undefined, {
|
|
412
|
+
timeoutMs: TASK_MUTATION_TIMEOUT_MS,
|
|
413
|
+
});
|
|
414
|
+
if (dryRun) return EXIT.OK;
|
|
415
|
+
const removed = data?.removed_path ? ` (${data.removed_path})` : "";
|
|
416
|
+
return print(deps, argv, data, `Removed the worktree of task ${argv.id}${removed}`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- attached PTY terminal ----------------------------------------------
|
|
420
|
+
|
|
421
|
+
export async function handleTerminal(argv, deps, action) {
|
|
422
|
+
const http = await buildHttp(deps);
|
|
423
|
+
if (action === "show") {
|
|
424
|
+
const data = await http.get(taskPath(argv.id, "terminal"));
|
|
425
|
+
const pty = data?.pty_task ?? null;
|
|
426
|
+
return print(deps, argv, data, `Terminal task ${pty ? taskLine(pty) : data?.pty_task_id ?? "(unknown)"}`);
|
|
427
|
+
}
|
|
428
|
+
const method = action === "open" ? "POST" : "DELETE";
|
|
429
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, method, taskPath(argv.id, "terminal"), action === "open" ? {} : undefined, {
|
|
430
|
+
timeoutMs: TASK_MUTATION_TIMEOUT_MS,
|
|
431
|
+
});
|
|
432
|
+
if (dryRun) return EXIT.OK;
|
|
433
|
+
if (action === "open") {
|
|
434
|
+
const ptyId = data?.pty_task_id ?? data?.pty_task?.id;
|
|
435
|
+
return print(deps, argv, data, `Opened terminal for task ${argv.id}${ptyId ? `: PTY task ${ptyId}` : ""}`);
|
|
436
|
+
}
|
|
437
|
+
return print(deps, argv, data ?? { closed: true }, `Closed the terminal of task ${argv.id}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- messages with attachments -------------------------------------------
|
|
441
|
+
|
|
442
|
+
function guessMime(fileName) {
|
|
443
|
+
return MIME_BY_EXT[path.extname(fileName).toLowerCase()] || "application/octet-stream";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Upload each file (phase 1, staged attachment) and return their ids so the
|
|
448
|
+
* message POST can bind them (phase 2) — the same two-step flow as the web
|
|
449
|
+
* composer and `conductor send-file`.
|
|
450
|
+
*/
|
|
451
|
+
export async function uploadAttachments(http, taskId, files, cwd) {
|
|
452
|
+
const ids = [];
|
|
453
|
+
for (const file of files) {
|
|
454
|
+
const filePath = path.resolve(cwd || process.cwd(), String(file));
|
|
455
|
+
let stats;
|
|
456
|
+
try {
|
|
457
|
+
stats = fs.statSync(filePath);
|
|
458
|
+
} catch {
|
|
459
|
+
stats = null;
|
|
460
|
+
}
|
|
461
|
+
if (!stats || !stats.isFile()) throw argsError(`File not found: ${filePath}`);
|
|
462
|
+
const name = path.basename(filePath);
|
|
463
|
+
const form = new FormData();
|
|
464
|
+
form.set("file", new Blob([fs.readFileSync(filePath)], { type: guessMime(name) }), name);
|
|
465
|
+
const result = await http.upload(taskPath(taskId, "attachments"), form);
|
|
466
|
+
const id = result?.attachment?.id;
|
|
467
|
+
if (!id) throw new Error(`Upload of ${name} succeeded but the server returned no attachment id`);
|
|
468
|
+
ids.push(id);
|
|
469
|
+
}
|
|
470
|
+
return ids;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const FIRE_OWNER_RETRY_WINDOW_MS = 10_000;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Retries the transient startup race (the task's fire has not bound yet, so
|
|
477
|
+
* nothing was persisted) for up to 10s, like the web composer.
|
|
478
|
+
*/
|
|
479
|
+
export async function retryWhileFireOwnerMissing(send, deps = {}) {
|
|
480
|
+
const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
481
|
+
const deadline = Date.now() + FIRE_OWNER_RETRY_WINDOW_MS;
|
|
482
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
483
|
+
try {
|
|
484
|
+
return await send();
|
|
485
|
+
} catch (error) {
|
|
486
|
+
const notReady = error?.statusCode === 409 && error?.details?.code === "task_missing_active_fire_owner";
|
|
487
|
+
if (!notReady || Date.now() >= deadline) throw error;
|
|
488
|
+
await sleep(Math.min(1_500, 500 * attempt));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export async function handleSendWithAttachments(argv, deps, content, metadata) {
|
|
494
|
+
const files = [].concat(argv.attach || []).filter(Boolean);
|
|
495
|
+
const http = await buildHttp(deps);
|
|
496
|
+
const body = { role: "user", content, metadata };
|
|
497
|
+
if (argv.dryRun) {
|
|
498
|
+
await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "messages"), {
|
|
499
|
+
...body,
|
|
500
|
+
attachmentIds: files.map((file) => `<upload ${file}>`),
|
|
501
|
+
});
|
|
502
|
+
return EXIT.OK;
|
|
503
|
+
}
|
|
504
|
+
const attachmentIds = await uploadAttachments(http, argv.id, files, deps.cwd);
|
|
505
|
+
const data = await retryWhileFireOwnerMissing(() => http.post(taskPath(argv.id, "messages"), { ...body, attachmentIds }), deps);
|
|
506
|
+
return print(deps, argv, data, `Sent message ${data?.id ? `${data.id} ` : ""}with ${attachmentIds.length} attachment(s) to task ${argv.id}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Filename from `Content-Disposition` (RFC 5987 `filename*` first). */
|
|
510
|
+
function dispositionFileName(header) {
|
|
511
|
+
const value = String(header || "");
|
|
512
|
+
const star = value.match(/filename\*=(?:UTF-8'')?([^;]+)/i);
|
|
513
|
+
if (star) {
|
|
514
|
+
try {
|
|
515
|
+
return decodeURIComponent(star[1].trim().replace(/^"|"$/g, ""));
|
|
516
|
+
} catch {
|
|
517
|
+
// fall through
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const plain = value.match(/filename="?([^";]+)"?/i);
|
|
521
|
+
return plain ? plain[1].trim() : null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Save a message attachment (ids are in `task messages --json` → attachments). */
|
|
525
|
+
export async function handleAttachmentDownload(argv, deps) {
|
|
526
|
+
const http = await buildHttp(deps);
|
|
527
|
+
const response = await http.download(taskPath(argv.id, "attachments", argv.attachmentId));
|
|
528
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
529
|
+
if (argv.output === "-") {
|
|
530
|
+
deps.stdout.write(bytes);
|
|
531
|
+
return EXIT.OK;
|
|
532
|
+
}
|
|
533
|
+
const headerName = dispositionFileName(response.headers?.get?.("content-disposition"));
|
|
534
|
+
// basename(): a server-supplied name must never write outside the target dir.
|
|
535
|
+
const fileName = path.basename(headerName || String(argv.attachmentId));
|
|
536
|
+
const cwd = deps.cwd || process.cwd();
|
|
537
|
+
let target = path.resolve(cwd, argv.output ? String(argv.output) : fileName);
|
|
538
|
+
if (argv.output && fs.existsSync(target) && fs.statSync(target).isDirectory()) target = path.join(target, fileName);
|
|
539
|
+
fs.writeFileSync(target, bytes);
|
|
540
|
+
return print(deps, argv, { path: target, bytes: bytes.length }, `Saved ${bytes.length} bytes to ${target}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---- follow ---------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
const TERMINAL_STATUSES = new Set(["completed", "killed", "failed", "stopped", "achieved"]);
|
|
546
|
+
const FOLLOW_PAGE_SIZE = 50;
|
|
547
|
+
const FOLLOW_MAX_PAGES = 20;
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Latest messages, oldest first, reaching back with `before` until a page
|
|
551
|
+
* overlaps what was already printed, so a burst of more than one page between
|
|
552
|
+
* polls is not dropped. Bounded so a first poll on a huge task stays cheap.
|
|
553
|
+
*/
|
|
554
|
+
async function fetchUnseenMessages(apis, taskId, seen) {
|
|
555
|
+
let messages = [];
|
|
556
|
+
for (let page = 0; page < FOLLOW_MAX_PAGES; page += 1) {
|
|
557
|
+
const before = messages[0]?.id;
|
|
558
|
+
const batch = await apis.tasks.listTaskMessages(taskId, { limit: FOLLOW_PAGE_SIZE, ...(before ? { before } : {}) });
|
|
559
|
+
const list = Array.isArray(batch) ? batch : [];
|
|
560
|
+
messages = [...list, ...messages];
|
|
561
|
+
if (list.length < FOLLOW_PAGE_SIZE || seen.size === 0 || list.some((msg) => seen.has(msg?.id))) break;
|
|
562
|
+
}
|
|
563
|
+
return messages;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Poll new messages until interrupted (or, with --until-idle, until the task
|
|
568
|
+
* leaves a running state). Prints each message once, oldest first.
|
|
569
|
+
*/
|
|
570
|
+
export async function followMessages(argv, deps, apis, initial) {
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
const emit = (msg) => {
|
|
573
|
+
if (!msg?.id || seen.has(msg.id)) return;
|
|
574
|
+
seen.add(msg.id);
|
|
575
|
+
if (argv.json) {
|
|
576
|
+
printJson(deps.stdout, msg);
|
|
577
|
+
} else {
|
|
578
|
+
printPretty(deps.stdout, `[${msg.role || "msg"}] ${String(msg.content ?? "")}`);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
(initial || []).forEach(emit);
|
|
582
|
+
const intervalMs = Math.max(250, Number(argv.interval ?? 2) * 1000);
|
|
583
|
+
const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
584
|
+
const maxPolls = Number.isFinite(deps.maxFollowPolls) ? deps.maxFollowPolls : Infinity;
|
|
585
|
+
for (let polls = 0; polls < maxPolls; polls += 1) {
|
|
586
|
+
await sleep(intervalMs);
|
|
587
|
+
(await fetchUnseenMessages(apis, argv.id, seen)).forEach(emit);
|
|
588
|
+
if (argv.untilIdle) {
|
|
589
|
+
const task = await apis.tasks.getTask(argv.id);
|
|
590
|
+
const status = (typeof task?.asObject === "function" ? task.asObject() : task)?.status;
|
|
591
|
+
if (status && TERMINAL_STATUSES.has(status)) break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return EXIT.OK;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---- create / resume extras ----------------------------------------------
|
|
598
|
+
|
|
599
|
+
/** `--agent worker --agent reviewer:codex` → `[{name:"worker"}, {name:"reviewer", backend:"codex"}]`. */
|
|
600
|
+
export function parseAgents(values) {
|
|
601
|
+
const list = [].concat(values || []).map((value) => String(value).trim()).filter(Boolean);
|
|
602
|
+
return list.map((entry) => {
|
|
603
|
+
const [name, backend] = entry.split(":").map((part) => part.trim());
|
|
604
|
+
if (!name) throw argsError(`Invalid --agent value: ${entry}`);
|
|
605
|
+
return backend ? { name, backend } : { name };
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** `claude@l20`, as the web UI and `issue start` write it; the older `l20:claude` still parses. */
|
|
610
|
+
export function parseGlobalBackend(value) {
|
|
611
|
+
const raw = trimmed(value);
|
|
612
|
+
if (!raw) return undefined;
|
|
613
|
+
const at = raw.indexOf("@");
|
|
614
|
+
const colon = raw.indexOf(":");
|
|
615
|
+
const [backend, host] = at > 0
|
|
616
|
+
? [raw.slice(0, at), raw.slice(at + 1)]
|
|
617
|
+
: colon > 0
|
|
618
|
+
? [raw.slice(colon + 1), raw.slice(0, colon)]
|
|
619
|
+
: ["", ""];
|
|
620
|
+
if (!host.trim() || !backend.trim()) {
|
|
621
|
+
throw argsError("--global-backend must look like <backend>@<host>, e.g. claude@l20");
|
|
622
|
+
}
|
|
623
|
+
return { host: host.trim(), backend: backend.trim().toLowerCase() };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** True when `task create` needs the full frontend payload rather than the SDK subset. */
|
|
627
|
+
export function hasExtendedCreateOptions(argv) {
|
|
628
|
+
return Boolean(
|
|
629
|
+
trimmed(argv.daemonHost)
|
|
630
|
+
|| (argv.agent && [].concat(argv.agent).length)
|
|
631
|
+
|| trimmed(argv.globalBackend)
|
|
632
|
+
|| argv.worktree
|
|
633
|
+
|| argv.persistent,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function buildExtendedCreateBody(argv, deps, projectId, title) {
|
|
638
|
+
const globalBackend = parseGlobalBackend(argv.globalBackend);
|
|
639
|
+
const agents = parseAgents(argv.agent);
|
|
640
|
+
if (globalBackend && (agents.length || trimmed(argv.daemonHost))) {
|
|
641
|
+
throw argsError("--global-backend cannot be combined with --agent or --daemon-host");
|
|
642
|
+
}
|
|
643
|
+
if (globalBackend && trimmed(argv.backend) && trimmed(argv.backend) !== globalBackend.backend) {
|
|
644
|
+
throw argsError(
|
|
645
|
+
`--backend ${trimmed(argv.backend)} does not match --global-backend backend ${globalBackend.backend}; drop --backend`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const metadata = buildAuditMetadata(deps.env, argv.persistent ? { persistent: { enabled: true } } : {});
|
|
649
|
+
const body = {
|
|
650
|
+
projectId,
|
|
651
|
+
title,
|
|
652
|
+
taskType: "ai_task",
|
|
653
|
+
...(argv.prompt !== undefined ? { initialContent: String(argv.prompt) } : {}),
|
|
654
|
+
...(argv.parentTaskId ? { parentTaskId: String(argv.parentTaskId) } : {}),
|
|
655
|
+
metadata,
|
|
656
|
+
};
|
|
657
|
+
if (globalBackend) {
|
|
658
|
+
body.globalBackend = globalBackend;
|
|
659
|
+
body.backendType = globalBackend.backend;
|
|
660
|
+
} else {
|
|
661
|
+
if (trimmed(argv.daemonHost)) body.agentHost = trimmed(argv.daemonHost);
|
|
662
|
+
if (trimmed(argv.backend)) body.backendType = trimmed(argv.backend);
|
|
663
|
+
}
|
|
664
|
+
if (agents.length) body.agents = agents;
|
|
665
|
+
if (argv.worktree) body.launchConfig = { worktree: true };
|
|
666
|
+
return body;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function printCreated(deps, argv, data) {
|
|
670
|
+
if (argv.json) {
|
|
671
|
+
printJson(deps.stdout, data);
|
|
672
|
+
return EXIT.OK;
|
|
673
|
+
}
|
|
674
|
+
printPretty(deps.stdout, `Created app task ${data?.id}: ${data?.title ?? ""}`);
|
|
675
|
+
const reviewers = data?.reviewer_task_ids ?? data?.reviewerTaskIds;
|
|
676
|
+
if (Array.isArray(reviewers) && reviewers.length) {
|
|
677
|
+
printPretty(deps.stdout, `Reviewer tasks: ${reviewers.join(", ")}`);
|
|
678
|
+
}
|
|
679
|
+
// Same warning as the SDK create path in bin/conductor-task.js.
|
|
680
|
+
const grouping = data?.grouping;
|
|
681
|
+
if (grouping?.grouped === false) {
|
|
682
|
+
const parent = grouping.parentTaskId ?? grouping.parent_task_id;
|
|
683
|
+
printPretty(
|
|
684
|
+
deps.stderr,
|
|
685
|
+
`Warning: ${grouping.warning || `task was not grouped with ${parent}`}. The task itself was created successfully.`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return EXIT.OK;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export async function handleExtendedCreate(argv, deps, projectId, title) {
|
|
692
|
+
const body = buildExtendedCreateBody(argv, deps, projectId, title);
|
|
693
|
+
const http = await buildHttp(deps);
|
|
694
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", apiPath("tasks"), body);
|
|
695
|
+
if (dryRun) return EXIT.OK;
|
|
696
|
+
return printCreated(deps, argv, data);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Resume an existing CLI session (listed by `conductor daemon sessions <host>`) as a new app task. */
|
|
700
|
+
export async function handleResume(argv, deps) {
|
|
701
|
+
const apis = await buildApis(deps);
|
|
702
|
+
const sessionId = trimmed(argv.session);
|
|
703
|
+
const host = trimmed(argv.daemonHost);
|
|
704
|
+
const backend = trimmed(argv.backend);
|
|
705
|
+
if (!sessionId || !host || !backend) throw argsError("--session, --daemon-host and --backend are required");
|
|
706
|
+
const http = await buildHttp(deps);
|
|
707
|
+
const projectId = trimmed(argv.project)
|
|
708
|
+
? (await resolveProject(apis, { project: argv.project })).id
|
|
709
|
+
: await sessionProjectId(http, apis, deps, host, backend, sessionId);
|
|
710
|
+
const body = {
|
|
711
|
+
projectId,
|
|
712
|
+
title: trimmed(argv.title) || `Resume ${backend} ${sessionId.slice(0, 8)}`,
|
|
713
|
+
taskType: "ai_task",
|
|
714
|
+
backendType: backend,
|
|
715
|
+
agentHost: host,
|
|
716
|
+
sessionId,
|
|
717
|
+
...(trimmed(argv.sessionFile) ? { sessionFilePath: trimmed(argv.sessionFile) } : {}),
|
|
718
|
+
...(argv.prompt !== undefined ? { initialContent: String(argv.prompt) } : {}),
|
|
719
|
+
metadata: buildAuditMetadata(deps.env),
|
|
720
|
+
};
|
|
721
|
+
let result;
|
|
722
|
+
try {
|
|
723
|
+
result = await sendOrPreview(http, argv, deps, "POST", apiPath("tasks"), body);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
const linkedId = error?.statusCode === 409 && error?.details?.error === "session_already_linked"
|
|
726
|
+
? error.details.task_id
|
|
727
|
+
: null;
|
|
728
|
+
if (!linkedId) throw error;
|
|
729
|
+
// The web resume panel opens the linked task instead of creating another.
|
|
730
|
+
return print(deps, argv, { id: linkedId, already_linked: true },
|
|
731
|
+
`Session ${sessionId} is already linked to task ${linkedId}; open it with \`conductor task show ${linkedId}\``);
|
|
732
|
+
}
|
|
733
|
+
if (result.dryRun) return EXIT.OK;
|
|
734
|
+
return printCreated(deps, argv, result.data);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* The project the web resume panel files a session under: the project whose
|
|
739
|
+
* workspace contains the session cwd (matched server-side), else the default.
|
|
740
|
+
* When the daemon cannot list the session, falls back to the project from the
|
|
741
|
+
* current directory / environment with a warning.
|
|
742
|
+
*/
|
|
743
|
+
const SESSION_LIST_UNAVAILABLE = new Set(["daemon_capability_missing", "daemon_timeout"]);
|
|
744
|
+
|
|
745
|
+
async function sessionProjectId(http, apis, deps, host, backend, sessionId) {
|
|
746
|
+
const fromCwd = async (reason) => {
|
|
747
|
+
printPretty(deps.stderr, `Warning: ${reason}; using the project from the current directory. Pass --project to choose one.`);
|
|
748
|
+
return (await resolveProject(apis, { env: deps.env, cwd: deps.cwd })).id;
|
|
749
|
+
};
|
|
750
|
+
let data;
|
|
751
|
+
try {
|
|
752
|
+
// The route has no session-id filter; 200 is its maximum page.
|
|
753
|
+
data = await http.get(apiPath("agents", host, "sessions"), { query: { backends: backend, limit: 200 } });
|
|
754
|
+
} catch (error) {
|
|
755
|
+
// Only "this daemon cannot list sessions" falls back (an older daemon, or
|
|
756
|
+
// one too slow to answer); auth, offline and server errors are real.
|
|
757
|
+
const code = error?.details?.error;
|
|
758
|
+
if (!SESSION_LIST_UNAVAILABLE.has(code)) throw error;
|
|
759
|
+
return fromCwd(`could not list sessions on ${host} (${code})`);
|
|
760
|
+
}
|
|
761
|
+
const session = (data?.sessions ?? []).find((s) => s?.session_id === sessionId);
|
|
762
|
+
if (!session) return fromCwd(`session ${sessionId} is not among the ${backend} sessions listed on ${host}`);
|
|
763
|
+
if (session.project_id) return session.project_id;
|
|
764
|
+
const fallback = (await apis.projects.listProjects({ includeHidden: true })).find((p) => p.isDefault);
|
|
765
|
+
if (!fallback) throw argsError("The session's directory matches no project and there is no default project; pass --project");
|
|
766
|
+
return fallback.id;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ---- list across projects -------------------------------------------------
|
|
770
|
+
|
|
771
|
+
export async function handleMultiProjectList(argv, deps) {
|
|
772
|
+
const http = await buildHttp(deps);
|
|
773
|
+
const query = {};
|
|
774
|
+
if (argv.projectIds) query.project_ids = String(argv.projectIds);
|
|
775
|
+
const data = await http.get(apiPath("tasks"), { query });
|
|
776
|
+
const tasks = Array.isArray(data) ? data : (Array.isArray(data?.tasks) ? data.tasks : []);
|
|
777
|
+
const statuses = argv.status ? new Set(String(argv.status).split(",").map((s) => s.trim()).filter(Boolean)) : null;
|
|
778
|
+
const filtered = statuses ? tasks.filter((task) => statuses.has(task.status)) : tasks;
|
|
779
|
+
if (argv.json) {
|
|
780
|
+
printJson(deps.stdout, filtered);
|
|
781
|
+
return EXIT.OK;
|
|
782
|
+
}
|
|
783
|
+
if (filtered.length === 0) {
|
|
784
|
+
printPretty(deps.stdout, "(no tasks)");
|
|
785
|
+
return EXIT.OK;
|
|
786
|
+
}
|
|
787
|
+
const names = await projectLabels(http);
|
|
788
|
+
const projectOf = (task) => {
|
|
789
|
+
const id = task.projectId ?? task.project_id ?? "";
|
|
790
|
+
return String(task.projectName ?? task.project?.name ?? names.get(id) ?? id);
|
|
791
|
+
};
|
|
792
|
+
const rows = filtered.map((task) => [task.id, task.status ?? "", projectOf(task), task.title ?? ""]);
|
|
793
|
+
for (const line of formatTable(["ID", "STATUS", "PROJECT", "TITLE"], rows)) {
|
|
794
|
+
printPretty(deps.stdout, line);
|
|
795
|
+
}
|
|
796
|
+
return EXIT.OK;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ---- scheduled message update -------------------------------------------
|
|
800
|
+
|
|
801
|
+
export async function handleScheduleUpdate(argv, deps, content, schedule) {
|
|
802
|
+
const body = {};
|
|
803
|
+
if (content !== undefined) body.content = content;
|
|
804
|
+
if (schedule !== undefined) body.schedule = schedule;
|
|
805
|
+
if (Object.keys(body).length === 0) throw argsError("Provide a new message and/or a schedule (--delay, --at, --every)");
|
|
806
|
+
const http = await buildHttp(deps);
|
|
807
|
+
const { dryRun, data } = await sendOrPreview(
|
|
808
|
+
http, argv, deps, "PATCH", taskPath(argv.id, "scheduled-messages", argv.scheduleId), body, { agentActor: true },
|
|
809
|
+
);
|
|
810
|
+
if (dryRun) return EXIT.OK;
|
|
811
|
+
const next = data?.nextRunAt ?? data?.next_run_at;
|
|
812
|
+
return print(deps, argv, data, `Updated scheduled message ${argv.scheduleId}${next ? `; next run ${next}` : ""}`);
|
|
813
|
+
}
|