@love-moon/conductor-cli 0.11.1 → 0.11.2

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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.11.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 938efe7: Fix "New task from this" when the successor runs on a different daemon.
8
+
9
+ Picking another daemon in the dialog created the successor row and then killed
10
+ it one second later with `new task failed: Could not resolve resume cwd`. The
11
+ server intentionally drops the source machine's paths for a cross-daemon fork,
12
+ and the target daemon had nothing left to resolve: no launch-config `cwd`, no
13
+ worktree, no local project path (the default project has none, and a project
14
+ bound to another daemon is refused by design), and no source session in its own
15
+ session store.
16
+
17
+ Fork modes now fall back to a fresh `<workspace>/<date>/<run>` directory — the
18
+ same fallback `create_task` already uses — so the successor starts like a
19
+ brand-new task on that machine and picks up the conversation through the
20
+ resume-context URL. In-place restarts still fail loudly, because they must reuse
21
+ the original working directory.
22
+
23
+ - @love-moon/conductor-sdk@0.11.2
24
+ - @love-moon/ai-sdk@0.11.2
25
+
3
26
  ## 0.11.1
4
27
 
5
28
  ### Patch Changes
@@ -415,8 +415,10 @@ async function main() {
415
415
  "# Optional features",
416
416
  "# ---------------------------------------------------------------------------",
417
417
  "",
418
- "# Extra Codex auth.json files to switch between from the app's account",
419
- "# picker. The file name (without .json) becomes the account name.",
418
+ "# Optional: extra Codex auth.json files to switch between from the app's",
419
+ "# account picker (shown only when more than one is listed). The file name",
420
+ "# (without .json) becomes the account name. Without this, quota comes from",
421
+ "# $CODEX_HOME/auth.json (default ~/.codex/auth.json).",
420
422
  "# ai_manager:",
421
423
  "# codex:",
422
424
  "# auth_json:",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.11.1",
4
- "gitCommitId": "c174c7e",
3
+ "version": "0.11.2",
4
+ "gitCommitId": "067735d",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -24,8 +24,8 @@
24
24
  "test": "node --test test/*.test.js"
25
25
  },
26
26
  "dependencies": {
27
- "@love-moon/ai-sdk": "0.11.1",
28
- "@love-moon/conductor-sdk": "0.11.1",
27
+ "@love-moon/ai-sdk": "0.11.2",
28
+ "@love-moon/conductor-sdk": "0.11.2",
29
29
  "@github/copilot-sdk": "^0.3.0",
30
30
  "chrome-launcher": "^1.2.1",
31
31
  "chrome-remote-interface": "^0.33.0",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "@roamhq/wrtc": "^0.10.0",
41
- "@love-moon/chat-web": "0.11.1"
41
+ "@love-moon/chat-web": "0.11.2"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -672,10 +672,102 @@ export function buildFireSpawnArgs({ selectedBackend, initialContent, launchConf
672
672
  if (effectivePrefill) {
673
673
  args.push("--prefill", effectivePrefill);
674
674
  }
675
+ const resumeSessionId =
676
+ normalizeOptionalString(launchConfig?.resumeSessionId) ||
677
+ normalizeOptionalString(launchConfig?.resume_session_id);
678
+ if (resumeSessionId) {
679
+ args.push("--resume", resumeSessionId);
680
+ }
675
681
  args.push("--");
676
682
  return args;
677
683
  }
678
684
 
685
+ // Backends whose local session stores the ai-sdk resume module can enumerate.
686
+ // Kept in sync with the server side of the `list_backend_sessions` RPC.
687
+ const LISTABLE_SESSION_BACKENDS = ["claude", "codex", "kimi"];
688
+ const DEFAULT_BACKEND_SESSION_LIMIT = 20;
689
+ const MAX_BACKEND_SESSION_LIMIT = 50;
690
+
691
+ // Lazy so a daemon running against an older ai-sdk build only fails when the
692
+ // RPC is actually invoked (surfaced as a per-backend error), not at load time.
693
+ async function listSessionsForBackendFromSdk(backendType, options) {
694
+ let sdk = await import("@love-moon/ai-sdk");
695
+ if (typeof sdk.listSessionsForBackend !== "function") {
696
+ // The root index can lag behind the resume module the function lives in;
697
+ // fall back to that entry point before declaring the sdk too old.
698
+ sdk = await import("@love-moon/ai-sdk/dist/resume/index.js").catch(() => sdk);
699
+ }
700
+ if (typeof sdk.listSessionsForBackend !== "function") {
701
+ throw new Error("Session listing is not supported by the installed ai-sdk");
702
+ }
703
+ return sdk.listSessionsForBackend(backendType, options);
704
+ }
705
+
706
+ /**
707
+ * Pure aggregation behind the `list_backend_sessions` RPC: intersect the
708
+ * requested backends with what this daemon supports, list each backend's
709
+ * sessions, and merge them newest-first. A single backend failing lands in
710
+ * `errors` without affecting the others. Exported so tests can drive it with
711
+ * a stubbed `listSessions`.
712
+ */
713
+ export async function collectBackendSessions({
714
+ requestedBackends = null,
715
+ supportedBackends = [],
716
+ limit,
717
+ listSessions = listSessionsForBackendFromSdk,
718
+ } = {}) {
719
+ const numericLimit = Number(limit);
720
+ const effectiveLimit =
721
+ Number.isFinite(numericLimit) && numericLimit > 0
722
+ ? Math.min(Math.floor(numericLimit), MAX_BACKEND_SESSION_LIMIT)
723
+ : DEFAULT_BACKEND_SESSION_LIMIT;
724
+ const listableBackends = LISTABLE_SESSION_BACKENDS.filter((backend) => supportedBackends.includes(backend));
725
+ const errors = [];
726
+ let backends;
727
+ if (Array.isArray(requestedBackends)) {
728
+ backends = [];
729
+ for (const raw of requestedBackends) {
730
+ const backend = typeof raw === "string" ? raw.trim().toLowerCase() : "";
731
+ if (!backend || backends.includes(backend)) {
732
+ continue;
733
+ }
734
+ if (!listableBackends.includes(backend)) {
735
+ errors.push({ backend, message: `Backend "${backend}" does not support session listing on this daemon` });
736
+ continue;
737
+ }
738
+ backends.push(backend);
739
+ }
740
+ } else {
741
+ backends = listableBackends;
742
+ }
743
+ const sessions = [];
744
+ for (const backend of backends) {
745
+ try {
746
+ const listed = await listSessions(backend, { limit: effectiveLimit });
747
+ for (const entry of Array.isArray(listed) ? listed : []) {
748
+ const sessionId = normalizeOptionalString(entry?.sessionId);
749
+ if (!sessionId) {
750
+ continue;
751
+ }
752
+ sessions.push({
753
+ backend,
754
+ session_id: sessionId,
755
+ session_file_path: normalizeOptionalString(entry?.sessionFilePath),
756
+ cwd: normalizeOptionalString(entry?.cwd),
757
+ title: normalizeOptionalString(entry?.title),
758
+ updated_at: Number.isFinite(entry?.updatedAt) ? new Date(entry.updatedAt).toISOString() : null,
759
+ });
760
+ }
761
+ } catch (error) {
762
+ errors.push({ backend, message: error?.message || String(error) });
763
+ }
764
+ }
765
+ // ISO 8601 strings compare lexicographically; entries without a timestamp
766
+ // sort last.
767
+ sessions.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
768
+ return { sessions, errors };
769
+ }
770
+
679
771
  function normalizeBooleanFlag(value) {
680
772
  if (typeof value === "boolean") {
681
773
  return value;
@@ -3663,6 +3755,7 @@ export function startDaemon(config = {}, deps = {}) {
3663
3755
  "restart_daemon",
3664
3756
  "refresh_session_inplace",
3665
3757
  "task_attachments_v1",
3758
+ "backend_session_list",
3666
3759
  CUSTOM_COMMANDS_CAPABILITY,
3667
3760
  UPDATE_DAEMON_CAPABILITY,
3668
3761
  ];
@@ -6173,6 +6266,10 @@ export function startDaemon(config = {}, deps = {}) {
6173
6266
  void handleGetProjectAgents(event.payload);
6174
6267
  return;
6175
6268
  }
6269
+ if (event.type === "list_backend_sessions") {
6270
+ void handleListBackendSessions(event.payload);
6271
+ return;
6272
+ }
6176
6273
  if (event.type === "ai_manager_request") {
6177
6274
  // RFC 0035: reads are fine and genuinely useful to a guest (it needs to
6178
6275
  // know how much quota the borrowed machine has left). `switch_account` is
@@ -6551,6 +6648,42 @@ export function startDaemon(config = {}, deps = {}) {
6551
6648
  }
6552
6649
  }
6553
6650
 
6651
+ async function handleListBackendSessions(payload) {
6652
+ const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
6653
+ if (!requestId) {
6654
+ logError(`Invalid list_backend_sessions payload: ${JSON.stringify(payload)}`);
6655
+ return;
6656
+ }
6657
+
6658
+ let result;
6659
+ try {
6660
+ result = await collectBackendSessions({
6661
+ requestedBackends: Array.isArray(payload?.backends) ? payload.backends : null,
6662
+ supportedBackends: SUPPORTED_BACKENDS,
6663
+ limit: payload?.limit,
6664
+ listSessions: deps.listBackendSessions,
6665
+ });
6666
+ } catch (error) {
6667
+ result = {
6668
+ sessions: [],
6669
+ errors: [{ backend: "*", message: `Failed to list backend sessions on daemon ${AGENT_NAME}: ${error?.message || error}` }],
6670
+ };
6671
+ }
6672
+
6673
+ try {
6674
+ await client.sendJson({
6675
+ type: "backend_sessions_listed",
6676
+ payload: {
6677
+ request_id: requestId,
6678
+ sessions: result.sessions,
6679
+ errors: result.errors,
6680
+ },
6681
+ });
6682
+ } catch (error) {
6683
+ logError(`Failed to report backend_sessions_listed for ${requestId}: ${error?.message || error}`);
6684
+ }
6685
+ }
6686
+
6554
6687
  function stopActiveTaskProcess(
6555
6688
  taskId,
6556
6689
  {
@@ -7940,6 +8073,31 @@ export function startDaemon(config = {}, deps = {}) {
7940
8073
  return;
7941
8074
  }
7942
8075
 
8076
+ if (!resolvedResumeCwd && isForkMode) {
8077
+ // A fork starts a brand-new session and receives its context as a URL, so
8078
+ // it does not need the source task's files. When nothing in the chain
8079
+ // resolves — the normal case for a cross-daemon fork, where the server
8080
+ // drops the source machine's paths and this daemon holds no local path
8081
+ // for the project — land in a fresh workspace dir exactly like
8082
+ // create_task does, instead of killing the successor on arrival.
8083
+ const now = new Date();
8084
+ // Keyed off the task id, not the daemon pid: the run timestamp only has
8085
+ // second granularity and this path never renames the dir afterwards
8086
+ // (create_task does), so two forks landing in the same second would
8087
+ // otherwise share one workspace AND one conductor.log.
8088
+ const taskSuffix =
8089
+ normalizedTargetTaskId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || String(process.pid);
8090
+ resolvedResumeCwd = path.join(
8091
+ WORKSPACE_ROOT,
8092
+ formatWorkspaceDate(now),
8093
+ `${formatWorkspaceRunTimestamp(now)}_fork_${taskSuffix}`,
8094
+ );
8095
+ log(
8096
+ `No local workspace for project ${normalizedProjectId} on this daemon; ` +
8097
+ `forking task ${normalizedTargetTaskId} into ${resolvedResumeCwd}`,
8098
+ );
8099
+ }
8100
+
7943
8101
  if (!resolvedResumeCwd) {
7944
8102
  reportRestartFailure({
7945
8103
  taskId: normalizedTargetTaskId,