@love-moon/conductor-cli 0.2.6 → 0.2.8

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.
@@ -4,24 +4,21 @@
4
4
  * conductor-fire - Conductor-aware AI coding agent runner.
5
5
  *
6
6
  * Supports configurable backends via allow_cli_list in config file.
7
- * This CLI bridges various AI coding agents with Conductor via MCP.
7
+ * This CLI bridges various AI coding agents with Conductor via the Conductor SDK.
8
8
  */
9
9
 
10
10
  import fs from "node:fs";
11
- import { createRequire } from "node:module";
12
11
  import os from "node:os";
13
12
  import path from "node:path";
14
13
  import process from "node:process";
15
14
  import { setTimeout as delay } from "node:timers/promises";
16
15
  import { fileURLToPath } from "node:url";
17
16
 
18
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
19
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
20
17
  import yargs from "yargs/yargs";
21
18
  import { hideBin } from "yargs/helpers";
22
19
  import yaml from "js-yaml";
23
20
  import { TuiDriver, claudeCodeProfile, codexProfile, copilotProfile } from "@love-moon/tui-driver";
24
- import { loadConfig } from "@love-moon/conductor-sdk";
21
+ import { ConductorClient, loadConfig } from "@love-moon/conductor-sdk";
25
22
  import {
26
23
  loadHistoryFromSpec,
27
24
  parseFromSpec,
@@ -31,21 +28,8 @@ import {
31
28
 
32
29
  const __filename = fileURLToPath(import.meta.url);
33
30
  const __dirname = path.dirname(__filename);
34
- const require = createRequire(import.meta.url);
35
31
  const PKG_ROOT = path.join(__dirname, "..");
36
32
  const CLI_PROJECT_PATH = process.cwd();
37
- const REPO_ROOT_FALLBACK = path.resolve(__dirname, "..", "..");
38
- const SDK_FALLBACK_PATH = path.join(REPO_ROOT_FALLBACK, "modules", "sdk");
39
- const SDK_ROOT =
40
- process.env.CONDUCTOR_SDK_PATH ||
41
- (() => {
42
- try {
43
- return path.dirname(require.resolve("@love-moon/conductor-sdk/package.json"));
44
- } catch {
45
- return SDK_FALLBACK_PATH;
46
- }
47
- })();
48
- const MCP_SERVER_SCRIPT = path.join(SDK_ROOT, "dist", "bin", "mcp-server.js");
49
33
 
50
34
  const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
51
35
  const CLI_NAME = (process.env.CONDUCTOR_CLI_NAME || path.basename(process.argv[1] || "conductor-fire")).replace(
@@ -122,12 +106,6 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
122
106
  10,
123
107
  );
124
108
 
125
- const MCP_SERVER_LAUNCH = {
126
- command: "node",
127
- args: [MCP_SERVER_SCRIPT],
128
- cwd: SDK_ROOT,
129
- };
130
-
131
109
  async function main() {
132
110
  const cliArgs = parseCliArgs();
133
111
 
@@ -204,6 +182,45 @@ async function main() {
204
182
  log(`Using backend: ${cliArgs.backend}`);
205
183
 
206
184
  const env = buildEnv();
185
+ const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
186
+ let reconnectRunner = null;
187
+ let reconnectTaskId = null;
188
+ let conductor = null;
189
+ let reconnectResumeInFlight = false;
190
+
191
+ const scheduleReconnectRecovery = ({ isReconnect }) => {
192
+ if (!isReconnect) {
193
+ return;
194
+ }
195
+ log("Conductor connection restored");
196
+ if (reconnectRunner && typeof reconnectRunner.noteReconnect === "function") {
197
+ reconnectRunner.noteReconnect();
198
+ }
199
+ if (!conductor || !reconnectTaskId || reconnectResumeInFlight) {
200
+ return;
201
+ }
202
+ reconnectResumeInFlight = true;
203
+ void (async () => {
204
+ try {
205
+ await conductor.sendAgentResume({
206
+ active_tasks: [reconnectTaskId],
207
+ source: "conductor-fire",
208
+ metadata: { reconnect: true },
209
+ });
210
+ if (!launchedByDaemon) {
211
+ await conductor.sendTaskStatus(reconnectTaskId, {
212
+ status: "RUNNING",
213
+ summary: "conductor fire reconnected",
214
+ });
215
+ }
216
+ } catch (error) {
217
+ log(`Failed to report reconnect resume: ${error?.message || error}`);
218
+ } finally {
219
+ reconnectResumeInFlight = false;
220
+ }
221
+ })();
222
+ };
223
+
207
224
  if (cliArgs.configFile) {
208
225
  env.CONDUCTOR_CONFIG = cliArgs.configFile;
209
226
  }
@@ -222,11 +239,11 @@ async function main() {
222
239
  // Ignore config loading errors, rely on env vars or defaults
223
240
  }
224
241
 
225
- const conductor = await ConductorClient.connect({
226
- launcher: MCP_SERVER_LAUNCH,
227
- workingDirectory: CLI_PROJECT_PATH,
242
+ conductor = await ConductorClient.connect({
228
243
  projectPath: CLI_PROJECT_PATH,
229
244
  extraEnv: env,
245
+ configFile: cliArgs.configFile,
246
+ onConnected: scheduleReconnectRecovery,
230
247
  });
231
248
 
232
249
  const taskContext = await ensureTaskContext(conductor, {
@@ -242,6 +259,17 @@ async function main() {
242
259
  taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
243
260
  }`,
244
261
  );
262
+ reconnectTaskId = taskContext.taskId;
263
+
264
+ try {
265
+ await conductor.sendAgentResume({
266
+ active_tasks: [taskContext.taskId],
267
+ source: "conductor-fire",
268
+ metadata: { reconnect: false },
269
+ });
270
+ } catch (error) {
271
+ log(`Failed to report agent resume: ${error?.message || error}`);
272
+ }
245
273
 
246
274
  const runner = new BridgeRunner({
247
275
  backendSession,
@@ -253,10 +281,10 @@ async function main() {
253
281
  cliArgs: cliArgs.rawBackendArgs,
254
282
  backendName: cliArgs.backend,
255
283
  });
284
+ reconnectRunner = runner;
256
285
 
257
286
  const signals = new AbortController();
258
287
  let shutdownSignal = null;
259
- const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
260
288
  const onSigint = () => {
261
289
  shutdownSignal = shutdownSignal || "SIGINT";
262
290
  signals.abort();
@@ -268,19 +296,44 @@ async function main() {
268
296
  process.on("SIGINT", onSigint);
269
297
  process.on("SIGTERM", onSigterm);
270
298
 
299
+ if (!launchedByDaemon) {
300
+ try {
301
+ await conductor.sendTaskStatus(taskContext.taskId, {
302
+ status: "RUNNING",
303
+ });
304
+ } catch (error) {
305
+ log(`Failed to report task status (RUNNING): ${error?.message || error}`);
306
+ }
307
+ }
308
+
309
+ let runnerError = null;
271
310
  try {
272
311
  await runner.start(signals.signal);
312
+ } catch (error) {
313
+ runnerError = error;
314
+ throw error;
273
315
  } finally {
274
316
  process.off("SIGINT", onSigint);
275
317
  process.off("SIGTERM", onSigterm);
276
- if (shutdownSignal && !launchedByDaemon) {
318
+ if (!launchedByDaemon) {
319
+ const finalStatus = shutdownSignal
320
+ ? {
321
+ status: "KILLED",
322
+ summary: `terminated by ${shutdownSignal}`,
323
+ }
324
+ : runnerError
325
+ ? {
326
+ status: "KILLED",
327
+ summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
328
+ }
329
+ : {
330
+ status: "COMPLETED",
331
+ summary: "conductor fire exited",
332
+ };
277
333
  try {
278
- await conductor.sendTaskStatus(taskContext.taskId, {
279
- status: "KILLED",
280
- summary: `terminated by ${shutdownSignal}`,
281
- });
334
+ await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
282
335
  } catch (error) {
283
- log(`Failed to report task status (KILLED): ${error?.message || error}`);
336
+ log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
284
337
  }
285
338
  }
286
339
  if (typeof backendSession.close === "function") {
@@ -537,7 +590,19 @@ async function resolveProjectId(conductor, explicit) {
537
590
  try {
538
591
  const record = await conductor.getLocalProjectRecord();
539
592
  if (record?.project_id) {
540
- return record.project_id;
593
+ try {
594
+ const listing = await conductor.listProjects();
595
+ const exists = Array.isArray(listing?.projects)
596
+ ? listing.projects.some((project) => String(project?.id || "") === String(record.project_id))
597
+ : false;
598
+ if (exists) {
599
+ return record.project_id;
600
+ }
601
+ log(`Local session project ${record.project_id} no longer exists; falling back to server project list`);
602
+ } catch (verifyError) {
603
+ log(`Unable to verify local project record; using cached project id: ${verifyError.message}`);
604
+ return record.project_id;
605
+ }
541
606
  }
542
607
  } catch (error) {
543
608
  log(`Unable to resolve project via local session: ${error.message}`);
@@ -973,174 +1038,6 @@ class TuiDriverSession {
973
1038
  }
974
1039
  }
975
1040
 
976
- class ConductorClient {
977
- constructor(client, transport, options = {}) {
978
- this.client = client;
979
- this.transport = transport;
980
- this.closed = false;
981
- this.projectPath = options.projectPath || process.cwd();
982
- }
983
-
984
- static async connect({ launcher, workingDirectory, extraEnv, projectPath }) {
985
- if (!fs.existsSync(launcher.args[0])) {
986
- throw new Error(`Conductor MCP server not found at ${launcher.args[0]}`);
987
- }
988
-
989
- const transport = new StdioClientTransport({
990
- command: launcher.command,
991
- args: launcher.args,
992
- env: extraEnv,
993
- cwd: launcher.cwd || workingDirectory,
994
- stderr: "pipe",
995
- });
996
-
997
- const client = new Client({
998
- name: CLI_NAME,
999
- version: pkgJson.version,
1000
- });
1001
-
1002
- client.onerror = (err) => {
1003
- log(`MCP client error: ${err.message}`);
1004
- };
1005
-
1006
- const stderr = transport.stderr;
1007
- if (stderr) {
1008
- stderr.setEncoding("utf-8");
1009
- stderr.on("data", (chunk) => {
1010
- const text = chunk.toString();
1011
- const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
1012
- text
1013
- .split(/\r?\n/)
1014
- .filter(Boolean)
1015
- .forEach((line) => process.stderr.write(`[conductor ${ts}] ${line}\n`));
1016
- });
1017
- }
1018
-
1019
- await client.connect(transport);
1020
- return new ConductorClient(client, transport, { projectPath });
1021
- }
1022
-
1023
- async close() {
1024
- if (this.closed) {
1025
- return;
1026
- }
1027
- this.closed = true;
1028
- await this.client.close();
1029
- await this.transport.close();
1030
- }
1031
-
1032
- injectProjectPath(payload = {}) {
1033
- if (!this.projectPath || payload.project_path) {
1034
- return payload;
1035
- }
1036
- return { ...payload, project_path: this.projectPath };
1037
- }
1038
-
1039
- async createTaskSession(payload) {
1040
- const args = payload ? { ...payload } : {};
1041
- return this.callTool("create_task_session", this.injectProjectPath(args));
1042
- }
1043
-
1044
- async sendMessage(taskId, content, metadata) {
1045
- return this.callTool("send_message", {
1046
- task_id: taskId,
1047
- content,
1048
- metadata,
1049
- });
1050
- }
1051
-
1052
- async sendTaskStatus(taskId, payload) {
1053
- return this.callTool("send_task_status", {
1054
- task_id: taskId,
1055
- ...(payload || {}),
1056
- });
1057
- }
1058
-
1059
- async sendRuntimeStatus(taskId, payload) {
1060
- return this.callTool("send_runtime_status", {
1061
- task_id: taskId,
1062
- ...(payload || {}),
1063
- });
1064
- }
1065
-
1066
- async receiveMessages(taskId, limit = 20) {
1067
- return this.callTool("receive_messages", {
1068
- task_id: taskId,
1069
- limit,
1070
- });
1071
- }
1072
-
1073
- async ackMessages(taskId, ackToken) {
1074
- if (!ackToken) {
1075
- return;
1076
- }
1077
- await this.callTool("ack_messages", { task_id: taskId, ack_token: ackToken });
1078
- }
1079
-
1080
- async listProjects() {
1081
- return this.callTool("list_projects", {});
1082
- }
1083
-
1084
- async createProject(name, description, metadata) {
1085
- return this.callTool("create_project", {
1086
- name,
1087
- description,
1088
- metadata,
1089
- });
1090
- }
1091
-
1092
- async getLocalProjectRecord() {
1093
- return this.callTool("get_local_project_id", this.injectProjectPath({}));
1094
- }
1095
-
1096
- async matchProjectByPath() {
1097
- return this.callTool("match_project_by_path", this.injectProjectPath({}));
1098
- }
1099
-
1100
- async bindProjectPath(projectId) {
1101
- return this.callTool("bind_project_path", this.injectProjectPath({ project_id: projectId }));
1102
- }
1103
-
1104
- async callTool(name, args) {
1105
- const result = await this.client.callTool({
1106
- name,
1107
- arguments: args || {},
1108
- });
1109
- if (result?.isError) {
1110
- const message = extractFirstText(result?.content) || `tool ${name} failed`;
1111
- throw new Error(message);
1112
- }
1113
- const structured = result?.structuredContent;
1114
- if (structured && Object.keys(structured).length > 0) {
1115
- return structured;
1116
- }
1117
- const textPayload = extractFirstText(result?.content);
1118
- if (!textPayload) {
1119
- return undefined;
1120
- }
1121
- try {
1122
- return JSON.parse(textPayload);
1123
- } catch {
1124
- return textPayload;
1125
- }
1126
- }
1127
- }
1128
-
1129
- function extractFirstText(blocks) {
1130
- if (!Array.isArray(blocks)) {
1131
- return "";
1132
- }
1133
- for (const block of blocks) {
1134
- if (block?.type === "text" && typeof block.text === "string") {
1135
- const trimmed = block.text.trim();
1136
- if (trimmed) {
1137
- return trimmed;
1138
- }
1139
- }
1140
- }
1141
- return "";
1142
- }
1143
-
1144
1041
  class BridgeRunner {
1145
1042
  constructor({
1146
1043
  backendSession,
@@ -1164,6 +1061,8 @@ class BridgeRunner {
1164
1061
  this.runningTurn = false;
1165
1062
  this.processedMessageIds = new Set();
1166
1063
  this.lastRuntimeStatusSignature = null;
1064
+ this.lastRuntimeStatusPayload = null;
1065
+ this.needsReconnectRecovery = false;
1167
1066
  }
1168
1067
 
1169
1068
  async start(abortSignal) {
@@ -1179,6 +1078,9 @@ class BridgeRunner {
1179
1078
  await this.backfillPendingUserMessages();
1180
1079
 
1181
1080
  while (!this.stopped) {
1081
+ if (this.needsReconnectRecovery && !this.runningTurn) {
1082
+ await this.recoverAfterReconnect();
1083
+ }
1182
1084
  let processed = false;
1183
1085
  try {
1184
1086
  processed = await this.processIncomingBatch();
@@ -1192,6 +1094,26 @@ class BridgeRunner {
1192
1094
  }
1193
1095
  }
1194
1096
 
1097
+ noteReconnect() {
1098
+ this.needsReconnectRecovery = true;
1099
+ }
1100
+
1101
+ async recoverAfterReconnect() {
1102
+ if (!this.needsReconnectRecovery) {
1103
+ return;
1104
+ }
1105
+ this.needsReconnectRecovery = false;
1106
+ log(`Recovering task ${this.taskId} after reconnect`);
1107
+ // With durable web outbox enabled, user messages are replayed by the server.
1108
+ // Re-running DB-history backfill here can re-drive stale prompts and confuse
1109
+ // the local TUI session after reconnect. Keep startup backfill, but disable
1110
+ // reconnect backfill by default (opt-in for debugging/legacy fallback).
1111
+ if (process.env.CONDUCTOR_FIRE_RECONNECT_BACKFILL === "1") {
1112
+ await this.backfillPendingUserMessages();
1113
+ }
1114
+ await this.replayLastRuntimeStatus();
1115
+ }
1116
+
1195
1117
  async processIncomingBatch() {
1196
1118
  const result = await this.conductor.receiveMessages(this.taskId, 20);
1197
1119
  const messages = Array.isArray(result?.messages) ? result.messages : [];
@@ -1313,6 +1235,9 @@ class BridgeRunner {
1313
1235
  return;
1314
1236
  }
1315
1237
  this.lastRuntimeStatusSignature = signature;
1238
+ this.lastRuntimeStatusPayload = {
1239
+ ...runtime,
1240
+ };
1316
1241
 
1317
1242
  try {
1318
1243
  await this.conductor.sendRuntimeStatus(this.taskId, {
@@ -1324,6 +1249,20 @@ class BridgeRunner {
1324
1249
  }
1325
1250
  }
1326
1251
 
1252
+ async replayLastRuntimeStatus() {
1253
+ if (!this.lastRuntimeStatusPayload) {
1254
+ return;
1255
+ }
1256
+ try {
1257
+ await this.conductor.sendRuntimeStatus(this.taskId, {
1258
+ ...this.lastRuntimeStatusPayload,
1259
+ created_at: new Date().toISOString(),
1260
+ });
1261
+ } catch (error) {
1262
+ log(`Failed to replay runtime status after reconnect: ${error?.message || error}`);
1263
+ }
1264
+ }
1265
+
1327
1266
  async respondToMessage(message) {
1328
1267
  const content = String(message.content || "").trim();
1329
1268
  if (!content) {
@@ -1334,6 +1273,7 @@ class BridgeRunner {
1334
1273
  return;
1335
1274
  }
1336
1275
  this.lastRuntimeStatusSignature = null;
1276
+ this.runningTurn = true;
1337
1277
  log(`Processing message ${replyTo} (${message.role})`);
1338
1278
  try {
1339
1279
  await this.reportRuntimeStatus(
@@ -1392,11 +1332,14 @@ class BridgeRunner {
1392
1332
  replyTo,
1393
1333
  );
1394
1334
  await this.reportError(`${this.backendName} 处理失败: ${errorMessage}`, replyTo);
1335
+ } finally {
1336
+ this.runningTurn = false;
1395
1337
  }
1396
1338
  }
1397
1339
 
1398
1340
  async handleSyntheticMessage(content, { includeImages }) {
1399
1341
  this.lastRuntimeStatusSignature = null;
1342
+ this.runningTurn = true;
1400
1343
  try {
1401
1344
  const result = await this.backendSession.runTurn(content, {
1402
1345
  useInitialImages: includeImages,
@@ -1423,6 +1366,8 @@ class BridgeRunner {
1423
1366
  });
1424
1367
  } catch (error) {
1425
1368
  await this.reportError(`初始提示执行失败: ${error.message}`);
1369
+ } finally {
1370
+ this.runningTurn = false;
1426
1371
  }
1427
1372
  }
1428
1373
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "conductor": "bin/conductor.js"
@@ -16,9 +16,8 @@
16
16
  "test": "node --test"
17
17
  },
18
18
  "dependencies": {
19
- "@love-moon/tui-driver": "0.2.6",
20
- "@love-moon/conductor-sdk": "0.2.6",
21
- "@modelcontextprotocol/sdk": "^1.20.2",
19
+ "@love-moon/tui-driver": "0.2.8",
20
+ "@love-moon/conductor-sdk": "0.2.8",
22
21
  "dotenv": "^16.4.5",
23
22
  "enquirer": "^2.4.1",
24
23
  "js-yaml": "^4.1.1",
package/src/daemon.js CHANGED
@@ -272,6 +272,7 @@ export function startDaemon(config = {}, deps = {}) {
272
272
  let didRecoverStaleTasks = false;
273
273
  const activeTaskProcesses = new Map();
274
274
  const suppressedExitStatusReports = new Set();
275
+ const seenCommandRequestIds = new Set();
275
276
  const client = createWebSocketClient(sdkConfig, {
276
277
  extraHeaders: {
277
278
  "x-conductor-host": AGENT_NAME,
@@ -282,11 +283,18 @@ export function startDaemon(config = {}, deps = {}) {
282
283
  log("Connected to backend");
283
284
  }
284
285
  disconnectedSinceLastConnectedLog = false;
286
+ sendAgentResume(isReconnect).catch((error) => {
287
+ logError(`sendAgentResume failed: ${error?.message || error}`);
288
+ });
285
289
  if (!didRecoverStaleTasks) {
286
290
  didRecoverStaleTasks = true;
287
291
  recoverStaleTasks().catch((error) => {
288
292
  logError(`recoverStaleTasks failed: ${error?.message || error}`);
289
293
  });
294
+ } else if (isReconnect) {
295
+ reconcileAssignedTasks().catch((error) => {
296
+ logError(`reconcileAssignedTasks failed: ${error?.message || error}`);
297
+ });
290
298
  }
291
299
  },
292
300
  onDisconnected: () => {
@@ -356,6 +364,102 @@ export function startDaemon(config = {}, deps = {}) {
356
364
  }
357
365
  }
358
366
 
367
+ async function reconcileAssignedTasks() {
368
+ try {
369
+ const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
370
+ method: "GET",
371
+ headers: {
372
+ Authorization: `Bearer ${AGENT_TOKEN}`,
373
+ Accept: "application/json",
374
+ },
375
+ });
376
+ if (!response.ok) {
377
+ logError(`Failed to reconcile tasks: HTTP ${response.status}`);
378
+ return;
379
+ }
380
+ const tasks = await response.json();
381
+ if (!Array.isArray(tasks)) {
382
+ return;
383
+ }
384
+ const localTaskIds = new Set(activeTaskProcesses.keys());
385
+ const assigned = tasks.filter((task) => {
386
+ const agentHost = String(task?.agent_host || "").trim();
387
+ const status = String(task?.status || "").trim().toLowerCase();
388
+ return agentHost === AGENT_NAME && (status === "unknown" || status === "running");
389
+ });
390
+
391
+ let killedCount = 0;
392
+ for (const task of assigned) {
393
+ const taskId = String(task?.id || "");
394
+ if (!taskId) continue;
395
+ if (localTaskIds.has(taskId)) {
396
+ continue;
397
+ }
398
+ const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
399
+ method: "PATCH",
400
+ headers: {
401
+ Authorization: `Bearer ${AGENT_TOKEN}`,
402
+ Accept: "application/json",
403
+ "Content-Type": "application/json",
404
+ },
405
+ body: JSON.stringify({ status: "killed" }),
406
+ });
407
+ if (patchResp.ok) {
408
+ killedCount += 1;
409
+ } else {
410
+ logError(`Failed to reconcile stale task ${taskId}: HTTP ${patchResp.status}`);
411
+ }
412
+ }
413
+
414
+ if (assigned.length || localTaskIds.size) {
415
+ log(
416
+ `Reconciled tasks after reconnect: backendAssigned=${assigned.length} localActive=${localTaskIds.size} markedKilled=${killedCount}`,
417
+ );
418
+ }
419
+ } catch (error) {
420
+ logError(`reconcileAssignedTasks error: ${error?.message || error}`);
421
+ }
422
+ }
423
+
424
+ async function sendAgentResume(isReconnect = false) {
425
+ await client.sendJson({
426
+ type: "agent_resume",
427
+ payload: {
428
+ active_tasks: [...activeTaskProcesses.keys()],
429
+ source: "conductor-daemon",
430
+ metadata: { is_reconnect: Boolean(isReconnect) },
431
+ },
432
+ });
433
+ }
434
+
435
+ function markRequestSeen(requestId) {
436
+ if (!requestId) return true;
437
+ if (seenCommandRequestIds.has(requestId)) {
438
+ return false;
439
+ }
440
+ seenCommandRequestIds.add(requestId);
441
+ if (seenCommandRequestIds.size > 2000) {
442
+ const first = seenCommandRequestIds.values().next();
443
+ if (!first.done) {
444
+ seenCommandRequestIds.delete(first.value);
445
+ }
446
+ }
447
+ return true;
448
+ }
449
+
450
+ function sendAgentCommandAck({ requestId, taskId, eventType, accepted = true }) {
451
+ if (!requestId) return Promise.resolve();
452
+ return client.sendJson({
453
+ type: "agent_command_ack",
454
+ payload: {
455
+ request_id: String(requestId),
456
+ task_id: taskId ? String(taskId) : undefined,
457
+ event_type: eventType,
458
+ accepted: Boolean(accepted),
459
+ },
460
+ });
461
+ }
462
+
359
463
  function handleEvent(event) {
360
464
  if (event.type === "create_task") {
361
465
  handleCreateTask(event.payload);
@@ -369,8 +473,18 @@ export function startDaemon(config = {}, deps = {}) {
369
473
  function handleStopTask(payload) {
370
474
  const taskId = payload?.task_id;
371
475
  if (!taskId) return;
372
-
373
476
  const requestId = payload?.request_id ? String(payload.request_id) : "";
477
+ if (requestId && !markRequestSeen(requestId)) {
478
+ log(`Duplicate stop_task ignored for ${taskId} (request_id=${requestId})`);
479
+ sendAgentCommandAck({
480
+ requestId,
481
+ taskId,
482
+ eventType: "stop_task",
483
+ accepted: true,
484
+ }).catch(() => {});
485
+ return;
486
+ }
487
+
374
488
  const sendStopAck = (accepted) => {
375
489
  if (!requestId) return;
376
490
  client
@@ -385,6 +499,14 @@ export function startDaemon(config = {}, deps = {}) {
385
499
  .catch((err) => {
386
500
  logError(`Failed to report task_stop_ack for ${taskId}: ${err?.message || err}`);
387
501
  });
502
+ sendAgentCommandAck({
503
+ requestId,
504
+ taskId,
505
+ eventType: "stop_task",
506
+ accepted,
507
+ }).catch((err) => {
508
+ logError(`Failed to report agent_command_ack(stop_task) for ${taskId}: ${err?.message || err}`);
509
+ });
388
510
  };
389
511
 
390
512
  const record = activeTaskProcesses.get(taskId);
@@ -485,11 +607,35 @@ export function startDaemon(config = {}, deps = {}) {
485
607
  }
486
608
 
487
609
  async function handleCreateTask(payload) {
488
- const { task_id: taskId, project_id: projectId, backend_type: backendType, initial_content: initialContent } =
610
+ const {
611
+ task_id: taskId,
612
+ project_id: projectId,
613
+ backend_type: backendType,
614
+ initial_content: initialContent,
615
+ request_id: requestIdRaw,
616
+ } =
489
617
  payload || {};
618
+ const requestId = requestIdRaw ? String(requestIdRaw) : "";
490
619
 
491
620
  if (!taskId || !projectId) {
492
621
  logError(`Invalid create_task payload: ${JSON.stringify(payload)}`);
622
+ sendAgentCommandAck({
623
+ requestId,
624
+ taskId,
625
+ eventType: "create_task",
626
+ accepted: false,
627
+ }).catch(() => {});
628
+ return;
629
+ }
630
+
631
+ if (requestId && !markRequestSeen(requestId)) {
632
+ log(`Duplicate create_task ignored for ${taskId} (request_id=${requestId})`);
633
+ sendAgentCommandAck({
634
+ requestId,
635
+ taskId,
636
+ eventType: "create_task",
637
+ accepted: true,
638
+ }).catch(() => {});
493
639
  return;
494
640
  }
495
641
 
@@ -497,6 +643,12 @@ export function startDaemon(config = {}, deps = {}) {
497
643
  const effectiveBackend = backendType || SUPPORTED_BACKENDS[0];
498
644
  if (!SUPPORTED_BACKENDS.includes(effectiveBackend)) {
499
645
  logError(`Unsupported backend: ${effectiveBackend}. Supported: ${SUPPORTED_BACKENDS.join(", ")}`);
646
+ sendAgentCommandAck({
647
+ requestId,
648
+ taskId,
649
+ eventType: "create_task",
650
+ accepted: false,
651
+ }).catch(() => {});
500
652
  client
501
653
  .sendJson({
502
654
  type: "task_status_update",
@@ -511,6 +663,15 @@ export function startDaemon(config = {}, deps = {}) {
511
663
  return;
512
664
  }
513
665
 
666
+ sendAgentCommandAck({
667
+ requestId,
668
+ taskId,
669
+ eventType: "create_task",
670
+ accepted: true,
671
+ }).catch((err) => {
672
+ logError(`Failed to report agent_command_ack(create_task) for ${taskId}: ${err?.message || err}`);
673
+ });
674
+
514
675
  const cliCommand = ALLOW_CLI_LIST[effectiveBackend];
515
676
 
516
677
  log("");