@love-moon/conductor-cli 0.2.25 → 0.2.26

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.
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
7
  import readline from "node:readline/promises";
8
- import { execSync } from "node:child_process";
8
+ import { execFileSync, execSync } from "node:child_process";
9
9
  import yargs from "yargs/yargs";
10
10
  import { hideBin } from "yargs/helpers";
11
11
  import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
@@ -27,6 +27,11 @@ const DEFAULT_CLIs = {
27
27
  execArgs: "--dangerously-bypass-approvals-and-sandbox --ask-for-approval never",
28
28
  description: "OpenAI Codex CLI"
29
29
  },
30
+ kimi: {
31
+ command: "kimi",
32
+ execArgs: "",
33
+ description: "Moonshot Kimi CLI"
34
+ },
30
35
  opencode: {
31
36
  command: "opencode",
32
37
  execArgs: "",
@@ -40,6 +45,8 @@ const backendUrl =
40
45
  "https://conductor-ai.top";
41
46
  const defaultDaemonName = os.hostname() || "my-daemon";
42
47
  const cliVersion = packageJson.version || "unknown";
48
+ const OPENCODE_INSTALL_URL = "https://opencode.ai/install";
49
+ const OPENCODE_NPM_PACKAGE = "opencode-ai";
43
50
 
44
51
  const COLORS = {
45
52
  yellow: "\x1b[33m",
@@ -50,6 +57,7 @@ const COLORS = {
50
57
  };
51
58
 
52
59
  let lastDeviceAuthConfig = null;
60
+ let promptInterface = null;
53
61
 
54
62
  function colorize(text, color) {
55
63
  return `${COLORS[color] || ""}${text}${COLORS.reset}`;
@@ -71,6 +79,29 @@ function buildConfigEntryLines(cli, info, { commented = false } = {}) {
71
79
  return lines;
72
80
  }
73
81
 
82
+ function getPromptInterface() {
83
+ if (!promptInterface) {
84
+ promptInterface = readline.createInterface({
85
+ input: process.stdin,
86
+ output: process.stdout,
87
+ });
88
+ }
89
+ return promptInterface;
90
+ }
91
+
92
+ function closePromptInterface() {
93
+ if (!promptInterface) {
94
+ return;
95
+ }
96
+ promptInterface.close();
97
+ promptInterface = null;
98
+ }
99
+
100
+ function exitWithCode(code) {
101
+ closePromptInterface();
102
+ process.exit(code);
103
+ }
104
+
74
105
  async function main() {
75
106
  const argv = yargs(hideBin(process.argv))
76
107
  .option("token", {
@@ -104,12 +135,12 @@ async function main() {
104
135
  process.stderr.write(
105
136
  colorize(`Config already exists at ${CONFIG_FILE}. Use --force to overwrite.\n`, "yellow")
106
137
  );
107
- process.exit(1);
138
+ exitWithCode(1);
108
139
  }
109
140
 
110
141
  let resolvedBackendUrl = backendUrl;
111
142
  let resolvedWebsocketUrl = null;
112
- const detectedCLIs = detectInstalledCLIs();
143
+ let detectedCLIs = detectInstalledCLIs();
113
144
 
114
145
  if (detectedCLIs.length === 0) {
115
146
  console.log("");
@@ -127,16 +158,43 @@ async function main() {
127
158
  });
128
159
 
129
160
  console.log("");
130
- console.log(colorize("After installing a CLI, run 'conductor config' again.", "yellow"));
131
- console.log(colorize("=".repeat(70), "yellow"));
132
- console.log("");
133
-
134
- const shouldContinue = await promptYesNo(
135
- "Do you want to continue creating the config anyway? (y/N): "
161
+ const shouldInstallOpencode = await promptYesNo(
162
+ "Do you want to install opencode now? (Y/n): ",
163
+ { defaultValue: true }
136
164
  );
137
165
 
138
- if (!shouldContinue) {
139
- process.exit(1);
166
+ if (shouldInstallOpencode) {
167
+ const installResult = installOpencode();
168
+ if (installResult.installed) {
169
+ detectedCLIs = detectInstalledCLIs();
170
+ if (!detectedCLIs.includes("opencode")) {
171
+ detectedCLIs = ["opencode", ...detectedCLIs];
172
+ }
173
+ console.log("");
174
+ if (installResult.message) {
175
+ console.log(colorize(installResult.message, "green"));
176
+ }
177
+ console.log(colorize("✓ OpenCode installed. Continuing with conductor config.", "green"));
178
+ console.log("");
179
+ } else {
180
+ console.log("");
181
+ console.log(colorize(installResult.message, "yellow"));
182
+ console.log("");
183
+ }
184
+ }
185
+
186
+ if (detectedCLIs.length === 0) {
187
+ console.log(colorize("After installing a CLI, run 'conductor config' again.", "yellow"));
188
+ console.log(colorize("=".repeat(70), "yellow"));
189
+ console.log("");
190
+
191
+ const shouldContinue = await promptYesNo(
192
+ "Do you want to continue creating the config anyway? (y/N): "
193
+ );
194
+
195
+ if (!shouldContinue) {
196
+ exitWithCode(1);
197
+ }
140
198
  }
141
199
  } else {
142
200
  console.log("");
@@ -154,7 +212,7 @@ async function main() {
154
212
  token = await promptForToken();
155
213
  if (!token) {
156
214
  process.stderr.write(colorize("No token provided. Aborting.\n", "yellow"));
157
- process.exit(1);
215
+ exitWithCode(1);
158
216
  }
159
217
  } else {
160
218
  const authResult = await authorizeDeviceAndGetToken();
@@ -227,6 +285,22 @@ function detectInstalledCLIs() {
227
285
  return detected;
228
286
  }
229
287
 
288
+ function resolveBundledCommandPath(command) {
289
+ const binDir = path.dirname(process.execPath);
290
+ const candidates = os.platform() === "win32"
291
+ ? [`${command}.cmd`, `${command}.exe`, command]
292
+ : [command];
293
+
294
+ for (const candidate of candidates) {
295
+ const fullPath = path.join(binDir, candidate);
296
+ if (fs.existsSync(fullPath)) {
297
+ return fullPath;
298
+ }
299
+ }
300
+
301
+ return null;
302
+ }
303
+
230
304
  function isCommandAvailable(command) {
231
305
  try {
232
306
  const platform = os.platform();
@@ -268,6 +342,108 @@ function checkAlternativeInstallations(command) {
268
342
  return commonPaths.some((checkPath) => fs.existsSync(checkPath));
269
343
  }
270
344
 
345
+ function resolveInstallCommand(command) {
346
+ const bundledPath = resolveBundledCommandPath(command);
347
+ if (bundledPath) {
348
+ return bundledPath;
349
+ }
350
+ return isCommandAvailable(command) ? command : null;
351
+ }
352
+
353
+ function resolveOpencodeInstaller() {
354
+ const overrideCommand = process.env.CONDUCTOR_OPENCODE_INSTALL_COMMAND?.trim();
355
+ if (overrideCommand) {
356
+ return {
357
+ command: overrideCommand,
358
+ args: [],
359
+ display: overrideCommand,
360
+ };
361
+ }
362
+
363
+ const npmCommand = resolveInstallCommand("npm");
364
+ if (npmCommand) {
365
+ return {
366
+ command: npmCommand,
367
+ args: ["install", "-g", OPENCODE_NPM_PACKAGE],
368
+ display: `${path.basename(npmCommand)} install -g ${OPENCODE_NPM_PACKAGE}`,
369
+ };
370
+ }
371
+
372
+ const pnpmCommand = resolveInstallCommand("pnpm");
373
+ if (pnpmCommand) {
374
+ return {
375
+ command: pnpmCommand,
376
+ args: ["install", "-g", OPENCODE_NPM_PACKAGE],
377
+ display: `${path.basename(pnpmCommand)} install -g ${OPENCODE_NPM_PACKAGE}`,
378
+ };
379
+ }
380
+
381
+ const bunCommand = resolveInstallCommand("bun");
382
+ if (bunCommand) {
383
+ return {
384
+ command: bunCommand,
385
+ args: ["install", "-g", OPENCODE_NPM_PACKAGE],
386
+ display: `${path.basename(bunCommand)} install -g ${OPENCODE_NPM_PACKAGE}`,
387
+ };
388
+ }
389
+
390
+ if (os.platform() !== "win32" && isCommandAvailable("bash") && isCommandAvailable("curl")) {
391
+ return {
392
+ command: "bash",
393
+ args: ["-lc", `curl -fsSL ${OPENCODE_INSTALL_URL} | bash`],
394
+ display: `curl -fsSL ${OPENCODE_INSTALL_URL} | bash`,
395
+ };
396
+ }
397
+
398
+ return null;
399
+ }
400
+
401
+ function printOpencodeInstallInstructions() {
402
+ console.log(colorize("You can install OpenCode manually with one of these commands:", "yellow"));
403
+ console.log(` curl -fsSL ${OPENCODE_INSTALL_URL} | bash`);
404
+ console.log(` npm install -g ${OPENCODE_NPM_PACKAGE}`);
405
+ }
406
+
407
+ function installOpencode() {
408
+ const installer = resolveOpencodeInstaller();
409
+ if (!installer) {
410
+ printOpencodeInstallInstructions();
411
+ return {
412
+ installed: false,
413
+ message: "Could not find an automatic installer for opencode in the current environment.",
414
+ };
415
+ }
416
+
417
+ console.log("");
418
+ console.log(colorize(`Installing opencode with: ${installer.display}`, "cyan"));
419
+ console.log("");
420
+
421
+ try {
422
+ execFileSync(installer.command, installer.args, {
423
+ stdio: "inherit",
424
+ env: process.env,
425
+ });
426
+ } catch (error) {
427
+ printOpencodeInstallInstructions();
428
+ return {
429
+ installed: false,
430
+ message: `opencode installation failed: ${error?.message || error}`,
431
+ };
432
+ }
433
+
434
+ if (isCommandAvailable("opencode")) {
435
+ return {
436
+ installed: true,
437
+ message: "OpenCode installed successfully.",
438
+ };
439
+ }
440
+
441
+ return {
442
+ installed: true,
443
+ message: "OpenCode installed, but the current shell may need a refreshed PATH before detection works.",
444
+ };
445
+ }
446
+
271
447
  async function authorizeDeviceAndGetToken() {
272
448
  const startResponse = await fetch(new URL("/api/auth/device/start", backendUrl), {
273
449
  method: "POST",
@@ -350,32 +526,21 @@ async function parseJsonResponse(response) {
350
526
  }
351
527
 
352
528
  async function promptForToken() {
353
- const rl = readline.createInterface({
354
- input: process.stdin,
355
- output: process.stdout,
356
- });
357
- try {
358
- let token = "";
359
- while (!token) {
360
- token = (await rl.question("Enter Conductor token: ")).trim();
361
- }
362
- return token;
363
- } finally {
364
- rl.close();
529
+ const rl = getPromptInterface();
530
+ let token = "";
531
+ while (!token) {
532
+ token = (await rl.question("Enter Conductor token: ")).trim();
365
533
  }
534
+ return token;
366
535
  }
367
536
 
368
- async function promptYesNo(question) {
369
- const rl = readline.createInterface({
370
- input: process.stdin,
371
- output: process.stdout,
372
- });
373
- try {
374
- const answer = (await rl.question(question)).trim().toLowerCase();
375
- return answer === "y" || answer === "yes";
376
- } finally {
377
- rl.close();
537
+ async function promptYesNo(question, { defaultValue = false } = {}) {
538
+ const rl = getPromptInterface();
539
+ const answer = (await rl.question(question)).trim().toLowerCase();
540
+ if (!answer) {
541
+ return defaultValue;
378
542
  }
543
+ return answer === "y" || answer === "yes";
379
544
  }
380
545
 
381
546
  function sleep(ms) {
@@ -388,7 +553,11 @@ function yamlQuote(value) {
388
553
  return JSON.stringify(value);
389
554
  }
390
555
 
391
- main().catch((error) => {
392
- process.stderr.write(`Failed to write config: ${error?.message || error}\n`);
393
- process.exit(1);
394
- });
556
+ main()
557
+ .catch((error) => {
558
+ process.stderr.write(`Failed to write config: ${error?.message || error}\n`);
559
+ exitWithCode(1);
560
+ })
561
+ .finally(() => {
562
+ closePromptInterface();
563
+ });
@@ -68,19 +68,23 @@ function loadAllowCliList(configFilePath) {
68
68
 
69
69
  export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env) {
70
70
  const normalizedBackend = normalizeRuntimeBackendName(backend);
71
- if (normalizedBackend !== "opencode") {
71
+ const envKeyByBackend = {
72
+ opencode: "CONDUCTOR_OPENCODE_COMMAND",
73
+ kimi: "CONDUCTOR_KIMI_COMMAND",
74
+ };
75
+ const envKey = envKeyByBackend[normalizedBackend];
76
+ if (!envKey) {
72
77
  return "";
73
78
  }
74
79
 
75
- const opencodeEnvCommand =
76
- typeof env?.CONDUCTOR_OPENCODE_COMMAND === "string" ? env.CONDUCTOR_OPENCODE_COMMAND.trim() : "";
77
- if (opencodeEnvCommand) {
78
- return opencodeEnvCommand;
80
+ const preferredEnvCommand = typeof env?.[envKey] === "string" ? env[envKey].trim() : "";
81
+ if (preferredEnvCommand) {
82
+ return preferredEnvCommand;
79
83
  }
80
84
 
81
85
  const configuredCommand =
82
- allowCliList && typeof allowCliList === "object" && typeof allowCliList.opencode === "string"
83
- ? allowCliList.opencode.trim()
86
+ allowCliList && typeof allowCliList === "object" && typeof allowCliList[normalizedBackend] === "string"
87
+ ? allowCliList[normalizedBackend].trim()
84
88
  : "";
85
89
  if (configuredCommand) {
86
90
  return configuredCommand;
@@ -410,7 +414,7 @@ async function main() {
410
414
 
411
415
  if (cliArgs.listBackends) {
412
416
  if (supportedBackends.length === 0) {
413
- process.stdout.write(`No supported backends configured.\n\nAdd allow_cli_list to your config file (~/.conductor/config.yaml):\n allow_cli_list:\n codex: codex --dangerously-bypass-approvals-and-sandbox\n claude: claude --dangerously-skip-permissions\n opencode: opencode\n`);
417
+ process.stdout.write(`No supported backends configured.\n\nAdd allow_cli_list to your config file (~/.conductor/config.yaml):\n allow_cli_list:\n codex: codex --dangerously-bypass-approvals-and-sandbox\n claude: claude --dangerously-skip-permissions\n kimi: kimi\n opencode: opencode\n`);
414
418
  } else {
415
419
  process.stdout.write(`Supported backends (from config):\n`);
416
420
  for (const [name, command] of Object.entries(allowCliList)) {
@@ -902,13 +906,16 @@ Config file format (~/.conductor/config.yaml):
902
906
  allow_cli_list:
903
907
  codex: codex --dangerously-bypass-approvals-and-sandbox
904
908
  claude: claude --dangerously-skip-permissions
909
+ kimi: kimi
905
910
  opencode: opencode
906
911
 
907
912
  Examples:
908
913
  ${CLI_NAME} -- "fix the bug" # Use default backend
909
914
  ${CLI_NAME} --backend claude -- "fix the bug" # Use Claude CLI backend
915
+ ${CLI_NAME} --backend kimi -- "fix the bug" # Use Kimi CLI backend
910
916
  ${CLI_NAME} --backend opencode -- "fix the bug" # Use OpenCode backend
911
917
  ${CLI_NAME} --backend codex --resume <id> # Resume Codex session
918
+ ${CLI_NAME} --backend kimi --resume <id> # Resume Kimi session
912
919
  ${CLI_NAME} --list-backends # Show configured backends
913
920
  ${CLI_NAME} --config-file ~/.conductor/config.yaml -- "fix the bug"
914
921
 
@@ -944,7 +951,7 @@ Environment:
944
951
  );
945
952
  }
946
953
  if (!backend && shouldRequireBackend) {
947
- throw new Error("No supported backends configured. Add codex, claude, or opencode to allow_cli_list.");
954
+ throw new Error("No supported backends configured. Add codex, claude, kimi, or opencode to allow_cli_list.");
948
955
  }
949
956
 
950
957
  const prompt = (backendArgs._ || []).map((part) => String(part)).join(" ").trim();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.25",
4
- "gitCommitId": "6d7b348",
3
+ "version": "0.2.26",
4
+ "gitCommitId": "fc0cb59",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conductor": "bin/conductor.js"
@@ -17,8 +17,8 @@
17
17
  "test": "node --test test/*.test.js"
18
18
  },
19
19
  "dependencies": {
20
- "@love-moon/ai-sdk": "0.2.25",
21
- "@love-moon/conductor-sdk": "0.2.25",
20
+ "@love-moon/ai-sdk": "0.2.26",
21
+ "@love-moon/conductor-sdk": "0.2.26",
22
22
  "dotenv": "^16.4.5",
23
23
  "enquirer": "^2.4.1",
24
24
  "js-yaml": "^4.1.1",
package/src/daemon.js CHANGED
@@ -49,6 +49,7 @@ const PLAN_LIMIT_MESSAGES = {
49
49
  const DEFAULT_TERMINAL_COLS = 120;
50
50
  const DEFAULT_TERMINAL_ROWS = 40;
51
51
  const DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES = 2 * 1024 * 1024;
52
+ const DEFAULT_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES = 128 * 1024;
52
53
  const DEFAULT_RTC_MODULE_CANDIDATES = ["@roamhq/wrtc", "wrtc"];
53
54
  let nodePtySpawnPromise = null;
54
55
 
@@ -282,6 +283,14 @@ function normalizeOptionalString(value) {
282
283
  return normalized || null;
283
284
  }
284
285
 
286
+ function normalizeTerminalResumeStrategy(value) {
287
+ const normalized = normalizeOptionalString(value);
288
+ if (!normalized) {
289
+ return null;
290
+ }
291
+ return normalized.toLowerCase() === "snapshot" ? "snapshot" : null;
292
+ }
293
+
285
294
  function normalizeStringArray(value) {
286
295
  if (!Array.isArray(value)) {
287
296
  return [];
@@ -529,6 +538,10 @@ export function startDaemon(config = {}, deps = {}) {
529
538
  config.TERMINAL_RING_BUFFER_MAX_BYTES || process.env.CONDUCTOR_TERMINAL_RING_BUFFER_MAX_BYTES,
530
539
  DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES,
531
540
  );
541
+ const TERMINAL_RESUME_SNAPSHOT_MAX_BYTES = parsePositiveInt(
542
+ config.TERMINAL_RESUME_SNAPSHOT_MAX_BYTES || process.env.CONDUCTOR_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES,
543
+ DEFAULT_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES,
544
+ );
532
545
 
533
546
  const readLockState = () => {
534
547
  const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
@@ -814,7 +827,7 @@ export function startDaemon(config = {}, deps = {}) {
814
827
  "x-conductor-version": cliVersion,
815
828
  };
816
829
  if (ptyTaskCapabilityEnabled) {
817
- extraHeaders["x-conductor-capabilities"] = "pty_task";
830
+ extraHeaders["x-conductor-capabilities"] = "pty_task,terminal_snapshot";
818
831
  }
819
832
  const client = createWebSocketClient(sdkConfig, {
820
833
  extraHeaders,
@@ -1914,6 +1927,23 @@ export function startDaemon(config = {}, deps = {}) {
1914
1927
  return record.outputSeq;
1915
1928
  }
1916
1929
 
1930
+ function buildTerminalResumeSnapshot(record) {
1931
+ if (!record || !Array.isArray(record.ringBuffer) || record.ringBuffer.length === 0) {
1932
+ return {
1933
+ lastSeq: normalizeNonNegativeInt(record?.outputSeq, 0),
1934
+ data: "",
1935
+ truncated: false,
1936
+ };
1937
+ }
1938
+ const joinedData = record.ringBuffer.map((chunk) => chunk?.data || "").join("");
1939
+ const trimmedData = trimTerminalChunkToTailBytes(joinedData, TERMINAL_RESUME_SNAPSHOT_MAX_BYTES);
1940
+ return {
1941
+ lastSeq: normalizeNonNegativeInt(record.outputSeq, 0),
1942
+ data: trimmedData,
1943
+ truncated: getTerminalChunkByteLength(trimmedData) < getTerminalChunkByteLength(joinedData),
1944
+ };
1945
+ }
1946
+
1917
1947
  function sendDirectPtyPayload(taskId, payload) {
1918
1948
  const transport = activePtyRtcTransports.get(taskId);
1919
1949
  const channel = transport?.channel;
@@ -2284,6 +2314,23 @@ export function startDaemon(config = {}, deps = {}) {
2284
2314
  });
2285
2315
 
2286
2316
  const lastSeq = normalizePositiveInt(payload?.last_seq ?? payload?.lastSeq, 0);
2317
+ const connectionId = normalizeOptionalString(payload?.connection_id ?? payload?.connectionId);
2318
+ const resumeStrategy = normalizeTerminalResumeStrategy(payload?.resume_strategy ?? payload?.resumeStrategy);
2319
+ if (lastSeq === 0 && resumeStrategy === "snapshot" && connectionId) {
2320
+ const snapshot = buildTerminalResumeSnapshot(record);
2321
+ await sendTerminalEvent("terminal_snapshot", {
2322
+ task_id: taskId,
2323
+ project_id: record.projectId,
2324
+ pty_session_id: record.ptySessionId,
2325
+ connection_id: connectionId,
2326
+ last_seq: snapshot.lastSeq,
2327
+ data: snapshot.data,
2328
+ truncated: snapshot.truncated,
2329
+ }).catch((err) => {
2330
+ logError(`Failed to report terminal_snapshot for ${taskId}: ${err?.message || err}`);
2331
+ });
2332
+ return;
2333
+ }
2287
2334
  for (const chunk of record.ringBuffer) {
2288
2335
  if (chunk.seq <= lastSeq) continue;
2289
2336
  await sendTerminalEvent("terminal_output", {
@@ -3,6 +3,7 @@ import { promises as fsp } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline";
6
+ import crypto from "node:crypto";
6
7
 
7
8
  import yaml from "js-yaml";
8
9
 
@@ -36,6 +37,9 @@ export function buildResumeArgsForBackend(backend, sessionId) {
36
37
  if (normalizedBackend === "copilot") {
37
38
  return [`--resume=${resumeSessionId}`];
38
39
  }
40
+ if (normalizedBackend === "kimi" || normalizedBackend === "kimi-cli" || normalizedBackend === "kimi-code") {
41
+ return ["--session", resumeSessionId];
42
+ }
39
43
  throw new Error(`--resume is not supported for backend "${backend}"`);
40
44
  }
41
45
 
@@ -50,6 +54,9 @@ export function resumeProviderForBackend(backend) {
50
54
  if (normalizedBackend === "copilot") {
51
55
  return "copilot";
52
56
  }
57
+ if (normalizedBackend === "kimi" || normalizedBackend === "kimi-cli" || normalizedBackend === "kimi-code") {
58
+ return "kimi";
59
+ }
53
60
  return null;
54
61
  }
55
62
 
@@ -64,6 +71,9 @@ export async function findSessionPath(provider, sessionId, options = {}) {
64
71
  if (normalizedProvider === "copilot") {
65
72
  return findCopilotSessionPath(sessionId, options);
66
73
  }
74
+ if (normalizedProvider === "kimi") {
75
+ return findKimiSessionPath(sessionId, options);
76
+ }
67
77
  throw new Error(`Unsupported provider: ${provider}`);
68
78
  }
69
79
 
@@ -120,6 +130,17 @@ export async function findCopilotSessionPath(sessionId, options = {}) {
120
130
  return findPathByName(sessionStateDir, normalizedSessionId);
121
131
  }
122
132
 
133
+ export async function findKimiSessionPath(sessionId, options = {}) {
134
+ const normalizedSessionId = normalizeSessionId(sessionId);
135
+ if (!normalizedSessionId) {
136
+ return null;
137
+ }
138
+
139
+ const homeDir = resolveHomeDir(options);
140
+ const sessionsDir = options.kimiSessionsDir || path.join(homeDir, ".kimi", "sessions");
141
+ return findKimiSessionDirectory(sessionsDir, normalizedSessionId);
142
+ }
143
+
123
144
  export async function resolveSessionRunDirectory(sessionPath) {
124
145
  const normalizedPath = typeof sessionPath === "string" ? sessionPath.trim() : "";
125
146
  if (!normalizedPath) {
@@ -153,9 +174,23 @@ export async function resolveResumeContext(backend, sessionId, options = {}) {
153
174
  throw new Error(`Invalid --resume session id for ${provider}: ${normalizedSessionId}`);
154
175
  }
155
176
 
156
- const cwdFromSession = await extractResumeCwdFromSession(provider, sessionPath, normalizedSessionId);
157
- const fallbackCwd = await resolveSessionRunDirectory(sessionPath);
177
+ const cwdFromSession = await extractResumeCwdFromSession(
178
+ provider,
179
+ sessionPath,
180
+ normalizedSessionId,
181
+ options,
182
+ );
183
+ const fallbackCwd =
184
+ provider === "kimi" ? null : await resolveSessionRunDirectory(sessionPath);
158
185
  const cwd = cwdFromSession || fallbackCwd;
186
+ if (!cwd) {
187
+ if (provider === "kimi") {
188
+ throw new Error(
189
+ `Could not resolve workspace for Kimi session ${normalizedSessionId}. Re-run from the original workspace or resume a session previously started by conductor fire.`,
190
+ );
191
+ }
192
+ throw new Error(`Could not resolve workspace for ${provider} session ${normalizedSessionId}`);
193
+ }
159
194
  if (!(await isExistingDirectory(cwd))) {
160
195
  throw new Error(`Resume workspace path does not exist: ${cwd}`);
161
196
  }
@@ -290,7 +325,167 @@ async function extractCopilotResumeCwd(sessionPath) {
290
325
  return null;
291
326
  }
292
327
 
293
- async function extractResumeCwdFromSession(provider, sessionPath, sessionId) {
328
+ function md5Hex(value) {
329
+ return crypto.createHash("md5").update(String(value ?? "")).digest("hex");
330
+ }
331
+
332
+ function listCandidateWorkingDirectories(options = {}) {
333
+ const candidates = [];
334
+ const push = (value) => {
335
+ const normalized = typeof value === "string" ? value.trim() : "";
336
+ if (!normalized) {
337
+ return;
338
+ }
339
+ if (!candidates.includes(normalized)) {
340
+ candidates.push(normalized);
341
+ }
342
+ };
343
+
344
+ push(options.cwd);
345
+ push(options.currentWorkingDirectory);
346
+ push(process.env.PWD);
347
+ push(process.cwd());
348
+
349
+ return candidates;
350
+ }
351
+
352
+ async function loadConductorSessionRecords(options = {}) {
353
+ const homeDir = resolveHomeDir(options);
354
+ const defaultPaths = [
355
+ path.join(homeDir, ".conductor", "session.yaml"),
356
+ path.join(homeDir, ".conductor", "sessions"),
357
+ ];
358
+ const recordFiles = [];
359
+ const pushFile = (filePath) => {
360
+ const normalized = typeof filePath === "string" ? filePath.trim() : "";
361
+ if (!normalized || recordFiles.includes(normalized)) {
362
+ return;
363
+ }
364
+ recordFiles.push(normalized);
365
+ };
366
+
367
+ if (Array.isArray(options.conductorSessionFiles)) {
368
+ for (const entry of options.conductorSessionFiles) {
369
+ pushFile(entry);
370
+ }
371
+ }
372
+
373
+ if (Array.isArray(options.conductorSessionDirs)) {
374
+ for (const entry of options.conductorSessionDirs) {
375
+ const normalizedDir = typeof entry === "string" ? entry.trim() : "";
376
+ if (!normalizedDir) {
377
+ continue;
378
+ }
379
+ let files = [];
380
+ try {
381
+ files = await fsp.readdir(normalizedDir, { withFileTypes: true });
382
+ } catch {
383
+ continue;
384
+ }
385
+ for (const file of files) {
386
+ if (!file.isFile() || !file.name.endsWith(".yaml")) {
387
+ continue;
388
+ }
389
+ pushFile(path.join(normalizedDir, file.name));
390
+ }
391
+ }
392
+ } else {
393
+ pushFile(defaultPaths[0]);
394
+ let files = [];
395
+ try {
396
+ files = await fsp.readdir(defaultPaths[1], { withFileTypes: true });
397
+ } catch {
398
+ files = [];
399
+ }
400
+ for (const file of files) {
401
+ if (!file.isFile() || !file.name.endsWith(".yaml")) {
402
+ continue;
403
+ }
404
+ pushFile(path.join(defaultPaths[1], file.name));
405
+ }
406
+ }
407
+
408
+ const records = [];
409
+ for (const filePath of recordFiles) {
410
+ let content = "";
411
+ try {
412
+ content = await fsp.readFile(filePath, "utf8");
413
+ } catch {
414
+ continue;
415
+ }
416
+ let parsed;
417
+ try {
418
+ parsed = yaml.load(content);
419
+ } catch {
420
+ continue;
421
+ }
422
+ const entries = Array.isArray(parsed?.sessions) ? parsed.sessions : Array.isArray(parsed) ? parsed : [];
423
+ for (const entry of entries) {
424
+ if (!entry || typeof entry !== "object") {
425
+ continue;
426
+ }
427
+ records.push(entry);
428
+ }
429
+ }
430
+ return records;
431
+ }
432
+
433
+ function normalizeProjectPathCandidate(value) {
434
+ return typeof value === "string" && value.trim() ? value.trim() : "";
435
+ }
436
+
437
+ async function resolveKimiResumeCwd(sessionPath, sessionId, options = {}) {
438
+ const sessionDirectory = typeof sessionPath === "string" ? sessionPath.trim() : "";
439
+ if (!sessionDirectory) {
440
+ return null;
441
+ }
442
+
443
+ const worktreeHash = path.basename(path.dirname(sessionDirectory));
444
+ if (!worktreeHash) {
445
+ return null;
446
+ }
447
+
448
+ for (const candidate of listCandidateWorkingDirectories(options)) {
449
+ if (md5Hex(candidate) === worktreeHash) {
450
+ return candidate;
451
+ }
452
+ }
453
+
454
+ const records = await loadConductorSessionRecords(options);
455
+ const bySessionId = [];
456
+ const byHash = [];
457
+
458
+ for (const record of records) {
459
+ const projectPath = normalizeProjectPathCandidate(record?.project_path);
460
+ if (!projectPath) {
461
+ continue;
462
+ }
463
+ const backendType = normalizeBackend(record?.backend_type);
464
+ const recordSessionId = normalizeSessionId(record?.session_id);
465
+ const projectHash = md5Hex(projectPath);
466
+ if (
467
+ recordSessionId === sessionId &&
468
+ (backendType === "kimi" || !backendType) &&
469
+ projectHash === worktreeHash &&
470
+ !bySessionId.includes(projectPath)
471
+ ) {
472
+ bySessionId.push(projectPath);
473
+ }
474
+ if (projectHash === worktreeHash && !byHash.includes(projectPath)) {
475
+ byHash.push(projectPath);
476
+ }
477
+ }
478
+
479
+ if (bySessionId.length > 0) {
480
+ return bySessionId[0];
481
+ }
482
+ if (byHash.length === 1) {
483
+ return byHash[0];
484
+ }
485
+ return null;
486
+ }
487
+
488
+ async function extractResumeCwdFromSession(provider, sessionPath, sessionId, options = {}) {
294
489
  if (provider === "codex") {
295
490
  return extractCodexResumeCwd(sessionPath);
296
491
  }
@@ -300,6 +495,9 @@ async function extractResumeCwdFromSession(provider, sessionPath, sessionId) {
300
495
  if (provider === "copilot") {
301
496
  return extractCopilotResumeCwd(sessionPath);
302
497
  }
498
+ if (provider === "kimi") {
499
+ return resolveKimiResumeCwd(sessionPath, sessionId, options);
500
+ }
303
501
  return null;
304
502
  }
305
503
 
@@ -404,6 +602,26 @@ async function findPathByName(rootDir, sessionId) {
404
602
  return null;
405
603
  }
406
604
 
605
+ async function findKimiSessionDirectory(rootDir, sessionId) {
606
+ let hashDirs = [];
607
+ try {
608
+ hashDirs = await fsp.readdir(rootDir, { withFileTypes: true });
609
+ } catch {
610
+ return null;
611
+ }
612
+
613
+ for (const hashDir of hashDirs) {
614
+ if (!hashDir.isDirectory()) {
615
+ continue;
616
+ }
617
+ const candidateDir = path.join(rootDir, hashDir.name, sessionId);
618
+ if (await pathExists(candidateDir, "directory")) {
619
+ return candidateDir;
620
+ }
621
+ }
622
+ return null;
623
+ }
624
+
407
625
  async function pathExists(targetPath, expectedType) {
408
626
  try {
409
627
  const stats = await fsp.stat(targetPath);
@@ -1,4 +1,4 @@
1
- export const RUNTIME_SUPPORTED_BACKENDS = ["codex", "claude", "opencode"];
1
+ export const RUNTIME_SUPPORTED_BACKENDS = ["codex", "claude", "kimi", "opencode"];
2
2
 
3
3
  export function normalizeRuntimeBackendName(backend) {
4
4
  return String(backend || "").trim().toLowerCase();