create-walle 0.9.29 → 0.9.30

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.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/template/bin/ctm-launch.sh +66 -21
  3. package/template/bin/dev.sh +13 -0
  4. package/template/bin/ensure-stable-node.js +11 -0
  5. package/template/bin/node-bin.sh +9 -0
  6. package/template/claude-task-manager/api-prompts.js +182 -8
  7. package/template/claude-task-manager/db.js +168 -13
  8. package/template/claude-task-manager/docs/session-title-authority.md +8 -3
  9. package/template/claude-task-manager/lib/claude-desktop-sessions.js +63 -0
  10. package/template/claude-task-manager/lib/codex-config-guard.js +124 -0
  11. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +42 -2
  12. package/template/claude-task-manager/lib/coding-agent-models.js +5 -4
  13. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +114 -0
  14. package/template/claude-task-manager/lib/db-owner-task-queue.js +67 -0
  15. package/template/claude-task-manager/lib/db-owner-worker-client.js +5 -1
  16. package/template/claude-task-manager/lib/desktop-fork.js +81 -0
  17. package/template/claude-task-manager/lib/headless-term-service.js +7 -2
  18. package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
  19. package/template/claude-task-manager/lib/runtime-context-truth.js +16 -6
  20. package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
  21. package/template/claude-task-manager/lib/session-history.js +88 -4
  22. package/template/claude-task-manager/lib/session-messages-page.js +13 -0
  23. package/template/claude-task-manager/lib/session-messages-projection.js +11 -27
  24. package/template/claude-task-manager/lib/session-stream.js +61 -16
  25. package/template/claude-task-manager/lib/session-title-signals.js +54 -0
  26. package/template/claude-task-manager/lib/session-token-usage.js +13 -0
  27. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +43 -2
  28. package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
  29. package/template/claude-task-manager/lib/transcript-store.js +12 -1
  30. package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
  31. package/template/claude-task-manager/public/css/walle-session.css +4 -0
  32. package/template/claude-task-manager/public/css/walle.css +0 -66
  33. package/template/claude-task-manager/public/index.html +766 -89
  34. package/template/claude-task-manager/public/js/state-sync-client.js +40 -1
  35. package/template/claude-task-manager/public/js/walle-session.js +211 -19
  36. package/template/claude-task-manager/public/js/walle.js +6 -110
  37. package/template/claude-task-manager/server.js +564 -90
  38. package/template/claude-task-manager/workers/db-owner-worker.js +15 -6
  39. package/template/claude-task-manager/workers/read-pool-worker.js +37 -0
  40. package/template/claude-task-manager/workers/session-host-pool-process.js +6 -1
  41. package/template/claude-task-manager/workers/session-host-process.js +6 -1
  42. package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
  43. package/template/package.json +1 -1
  44. package/template/wall-e/agent.js +78 -16
  45. package/template/wall-e/api-walle.js +24 -43
  46. package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
  47. package/template/wall-e/brain.js +122 -1
  48. package/template/wall-e/chat.js +46 -1
  49. package/template/wall-e/http/model-admin.js +22 -0
  50. package/template/wall-e/lib/brain-owner-worker-client.js +20 -0
  51. package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
  52. package/template/wall-e/lib/runtime-worker-pool.js +15 -1
  53. package/template/wall-e/lib/scheduler-worker-jobs.js +30 -1
  54. package/template/wall-e/lib/scheduler.js +71 -2
  55. package/template/wall-e/lib/slack-identity.js +120 -0
  56. package/template/wall-e/lib/slack-permalink.js +107 -0
  57. package/template/wall-e/lib/slack-web.js +174 -0
  58. package/template/wall-e/lib/worker-thread-pool.js +49 -0
  59. package/template/wall-e/llm/cli-binary.js +17 -4
  60. package/template/wall-e/llm/codex-cli.js +105 -60
  61. package/template/wall-e/llm/model-catalog.js +129 -17
  62. package/template/wall-e/loops/backfill.js +32 -16
  63. package/template/wall-e/loops/ingest.js +50 -16
  64. package/template/wall-e/mcp-server.js +215 -6
  65. package/template/wall-e/skills/_bundled/gws-workspace/gws-router +61 -4
  66. package/template/wall-e/skills/_bundled/slack-mentions/run.js +167 -52
  67. package/template/wall-e/skills/skill-planner.js +5 -26
  68. package/template/wall-e/utils/dedup.js +165 -66
  69. package/template/wall-e/weather-runtime.js +12 -4
  70. package/template/wall-e/workers/brain-owner-worker.js +60 -0
  71. package/template/wall-e/workers/runtime-worker.js +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-walle",
3
- "version": "0.9.29",
3
+ "version": "0.9.30",
4
4
  "description": "CTM + Wall-E — AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini, Aider, OpenCode, and more, plus prompt editor, task queue, remote phone and tablet access, code/doc review, and an agent that learns from Slack, email & calendar.",
5
5
  "bin": {
6
6
  "create-walle": "bin/create-walle.js"
@@ -9,10 +9,35 @@
9
9
  # LaunchAgent plist never needs to change.
10
10
  set -euo pipefail
11
11
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
12
- NODE_BIN="$(bash "$ROOT/bin/node-bin.sh")"
12
+ # CTM_LAUNCH_NODE_BIN lets a test inject the pinned node without running node-bin.sh.
13
+ NODE_BIN="${CTM_LAUNCH_NODE_BIN:-"$(bash "$ROOT/bin/node-bin.sh")"}"
13
14
  SERVER="$ROOT/claude-task-manager/server.js"
14
15
  PINNED_V="$("$NODE_BIN" -v 2>/dev/null)"
15
16
 
17
+ # Exec the chosen runtime — or, under CTM_LAUNCH_PRINT_CHOICE (tests), print the path it WOULD exec
18
+ # and exit 0 without launching the server. Keeps the candidate ladder below assertable end-to-end.
19
+ _exec_choice() {
20
+ local bin="$1"; shift
21
+ if [ -n "${CTM_LAUNCH_PRINT_CHOICE:-}" ]; then printf '%s\n' "$bin"; exit 0; fi
22
+ exec "$bin" "$SERVER" "$@"
23
+ }
24
+
25
+ # Self-heal the native SQLite driver BEFORE exec'ing the server. A hard power-off can leave
26
+ # better_sqlite3.node torn on disk, and a Homebrew node upgrade can leave it built for the wrong
27
+ # ABI. The server DETECTS this but cannot hot-reload a native module mid-process, so it wedges
28
+ # the boot: DB-owner worker dies -> "Database not initialized" everywhere -> no port bound ->
29
+ # launchd respawns into the SAME broken state every ~5s (a respawn spiral that never converges).
30
+ # Running the preflight here rebuilds the driver if needed, so the fresh `exec` below loads a good
31
+ # binary. It is cheap on the happy path (a require + smoke-test that early-returns when the ABI
32
+ # already matches) and only does the heavy `npm rebuild` when the driver is genuinely broken.
33
+ # Best-effort: a failure here never blocks the boot — the server's own in-process repair still runs.
34
+ # Skipped under CTM_LAUNCH_PRINT_CHOICE: it is irrelevant to runtime selection and needs a real DB.
35
+ CHECK_DRIVER="$ROOT/claude-task-manager/bin/check-sqlite-driver.js"
36
+ if [ -z "${CTM_LAUNCH_PRINT_CHOICE:-}" ] && [ -f "$CHECK_DRIVER" ]; then
37
+ ( cd "$ROOT/claude-task-manager" && "$NODE_BIN" "$CHECK_DRIVER" ) >/tmp/ctm-boot-sqlite-check.log 2>&1 \
38
+ || echo "[ctm-launch] sqlite driver preflight reported a problem; see /tmp/ctm-boot-sqlite-check.log (boot continues; server self-repair still applies)" >&2
39
+ fi
40
+
16
41
  # Prefer a STABLE-IDENTITY node so macOS TCC grants (e.g. the "Coding Task Manager would like to
17
42
  # access data from other apps" prompt) PERSIST across restarts. macOS keys a TCC grant to the
18
43
  # binary's code-signing Designated Requirement: a notarized / Developer-ID-signed node keeps its
@@ -23,46 +48,66 @@ PINNED_V="$("$NODE_BIN" -v 2>/dev/null)"
23
48
  # bump can never select a mismatched-ABI runtime (preserves the "upgrade = edit .node-version"
24
49
  # guarantee, and the daemon's native modules stay ABI-correct).
25
50
  _ver_match() { [ -x "$1" ] && [ "$("$1" -v 2>/dev/null)" = "$PINNED_V" ]; }
51
+ # Does this binary carry a stable code-signing Team Identifier (Developer-ID signed)? Signal that a
52
+ # TCC/Full-Disk-Access grant will PERSIST and that prompts show a branded name. A fast local read.
53
+ # NB this alone CANNOT distinguish "our branded CTM bundle" from the bare notarized node — the
54
+ # notarized node is also Developer-ID signed (Team HX7739G8FX, Node.js Foundation). The discriminator
55
+ # is the code-signing IDENTIFIER (see _is_ctm_bundle_identity).
56
+ # NB capture codesign's output FIRST, then match the string — do NOT pipe `codesign | grep -q`.
57
+ # `grep -q` exits on the first match and closes the pipe; codesign then dies with SIGPIPE (141), and
58
+ # under `set -o pipefail` (above) the pipeline is reported as FAILED even though the pattern matched.
59
+ # That false-negative is exactly what made the daemon keep running the wrong node. Capturing to a
60
+ # var runs codesign to completion (exit 0), then a here-string grep has no upstream to kill.
61
+ _has_team_id() { local o; o="$(codesign -dv "$1" 2>&1)" || true; grep -q '^TeamIdentifier=[A-Z0-9]' <<<"$o"; }
62
+ # Is this binary OUR branded CTM bundle (Identifier=com.walle.ctm)? That is the EXACT identity the
63
+ # Full Disk Access banner tells the user to grant ("Coding Task Manager.app"). The notarized node's
64
+ # Identifier is "node", so this cleanly separates the two.
65
+ _is_ctm_bundle_identity() { local o; o="$(codesign -dv "$1" 2>&1)" || true; grep -q '^Identifier=com\.walle\.ctm$' <<<"$o"; }
66
+
67
+ CTM_BUNDLE="$HOME/.walle/bundles/Coding Task Manager.app/Contents/MacOS/Coding Task Manager"
26
68
 
27
- # 0) The stable daemon node chosen by bin/ensure-stable-node.js (run off the boot path from
28
- # restart-ctm.sh). On a machine WITH a Developer ID this is the branded, Dev-ID-signed CTM
29
- # bundle exec both grant-persisting AND shown as "Coding Task Manager" (not "node") in TCC
30
- # prompts; without a Developer ID it is the bare notarized node. Reading the marker keeps
31
- # codesign OFF this launchd boot path; the version check rejects a stale marker.
69
+ # 0) The branded CTM bundle (Identifier=com.walle.ctm) the EXACT identity the FDA banner asks the
70
+ # user to grant Full Disk Access to. Prefer it ABOVE the stable-node marker. WHY first: the marker
71
+ # written by ensure-stable-node.js can point at the bare notarized node ("node", Team HX7739G8FX);
72
+ # because that node IS Developer-ID signed it would satisfy a naive "has a Team Identifier" check
73
+ # and win the daemon then runs as "node", the user's grant to "Coding Task Manager.app" never
74
+ # applies, and the macOS "network volume"/FDA prompt recurs on EVERY restart. Checking THIS bundle
75
+ # path + its com.walle.ctm identity makes the grant-matching runtime authoritative. Gated on the
76
+ # version (ABI) match too. Costs one fast `codesign -dv` read per boot (boots are rare); on a
77
+ # no-Dev-ID machine the bundle is absent/self-signed so this falls through with no behavior change.
78
+ if _ver_match "$CTM_BUNDLE" && _is_ctm_bundle_identity "$CTM_BUNDLE" && _has_team_id "$CTM_BUNDLE"; then
79
+ _exec_choice "$CTM_BUNDLE" "$@"
80
+ fi
81
+ # 1) The stable daemon node chosen by bin/ensure-stable-node.js (run off the boot path from
82
+ # restart-ctm.sh). On a machine WITHOUT a Developer ID this is the bare notarized node — the best
83
+ # stable identity available there. Version-gated; the version check rejects a stale marker.
32
84
  MARKER="$HOME/.walle/.stable-daemon-node"
33
85
  if [ -f "$MARKER" ]; then
34
86
  STABLE_NODE="$(head -n1 "$MARKER" 2>/dev/null)"
35
87
  if [ -n "$STABLE_NODE" ] && _ver_match "$STABLE_NODE"; then
36
- exec "$STABLE_NODE" "$SERVER" "$@"
88
+ _exec_choice "$STABLE_NODE" "$@"
37
89
  fi
38
90
  fi
39
- # 1) Notarized node handed in by the downloadable Developer-ID Wall-E.app.
91
+ # 2) Notarized node handed in by the downloadable Developer-ID Wall-E.app.
40
92
  if [ -n "${WALLE_NOTARIZED_NODE:-}" ] && _ver_match "$WALLE_NOTARIZED_NODE"; then
41
- exec "$WALLE_NOTARIZED_NODE" "$SERVER" "$@"
42
- fi
43
- # 2) The branded .app bundle node when it carries a stable Team Identifier (Developer-ID-signed by
44
- # ensure-stable-node.js) — branded AND grant-persisting. `codesign -dv` is a fast local read; it
45
- # runs only on a cold boot where the marker is absent/stale.
46
- CTM_BUNDLE="$HOME/.walle/bundles/Coding Task Manager.app/Contents/MacOS/Coding Task Manager"
47
- if _ver_match "$CTM_BUNDLE" && codesign -dv "$CTM_BUNDLE" 2>&1 | grep -q '^TeamIdentifier=[A-Z0-9]'; then
48
- exec "$CTM_BUNDLE" "$SERVER" "$@"
93
+ _exec_choice "$WALLE_NOTARIZED_NODE" "$@"
49
94
  fi
50
95
  # 2.5) The adopted Developer-ID-notarized Wall-E.app's vendored node (create-walle
51
96
  # ensureNotarizedBrandedApp) — branded "Wall-E" AND grant-persisting, for no-Dev-ID machines.
52
97
  # Gated on the version match + a stable Team Identifier (skips a self-signed/wrong bundle).
53
98
  BRANDED_APP_NODE="$HOME/.walle/notarized-app/Wall-E.app/Contents/Resources/node"
54
- if _ver_match "$BRANDED_APP_NODE" && codesign -dv "$BRANDED_APP_NODE" 2>&1 | grep -q '^TeamIdentifier=[A-Z0-9]'; then
55
- exec "$BRANDED_APP_NODE" "$SERVER" "$@"
99
+ if _ver_match "$BRANDED_APP_NODE" && _has_team_id "$BRANDED_APP_NODE"; then
100
+ _exec_choice "$BRANDED_APP_NODE" "$@"
56
101
  fi
57
102
  # 3) Notarized node we provisioned ourselves (~/.walle/notarized-node) — stable but anonymous
58
103
  # ("node"); the fallback for machines without a Developer ID.
59
104
  NOTARIZED_NODE="$HOME/.walle/notarized-node/bin/node"
60
105
  if _ver_match "$NOTARIZED_NODE"; then
61
- exec "$NOTARIZED_NODE" "$SERVER" "$@"
106
+ _exec_choice "$NOTARIZED_NODE" "$@"
62
107
  fi
63
108
  # 4) The branded bundle node even if only self-signed (branded name, but may re-prompt).
64
109
  if _ver_match "$CTM_BUNDLE"; then
65
- exec "$CTM_BUNDLE" "$SERVER" "$@"
110
+ _exec_choice "$CTM_BUNDLE" "$@"
66
111
  fi
67
112
  # 5) Last resort: the pinned node (ABI-correct, but no stable TCC identity → may re-prompt).
68
- exec "$NODE_BIN" "$SERVER" "$@"
113
+ _exec_choice "$NODE_BIN" "$@"
@@ -164,6 +164,19 @@ mkdir -p "$DEV_DIR"
164
164
  # (corruption + contention). A throwaway CODEX_HOME keeps dev codex writes — and
165
165
  # CTM's own rollout reads, which resolve via the same CODEX_HOME — inside DEV_DIR.
166
166
  mkdir -p "$DEV_DIR/codex/sessions"
167
+ # Seed the throwaway CODEX_HOME with the user's existing Codex login. The isolation above
168
+ # relocates CODEX_HOME, which ALSO relocates ~/.codex/auth.json — so without this, dev codex
169
+ # sessions are logged out and prompt for `codex login` (the "codex login issue in staging").
170
+ # Copy only auth.json from the real ~/.codex so dev codex is authenticated while rollouts/sessions
171
+ # stay isolated in DEV_DIR. We deliberately do NOT copy config.toml — it can carry MCP entries that
172
+ # point at the user's primary (e.g. the node_repl missing-binary noise). Idempotent: never clobber
173
+ # an existing dev login.
174
+ _REAL_CODEX_HOME="$HOME/.codex"
175
+ if [[ -f "$_REAL_CODEX_HOME/auth.json" && ! -f "$DEV_DIR/codex/auth.json" ]]; then
176
+ cp "$_REAL_CODEX_HOME/auth.json" "$DEV_DIR/codex/auth.json" 2>/dev/null \
177
+ && chmod 600 "$DEV_DIR/codex/auth.json" 2>/dev/null \
178
+ && echo " seeded dev CODEX_HOME auth from $_REAL_CODEX_HOME/auth.json"
179
+ fi
167
180
 
168
181
  cleanup_processes() {
169
182
  local stale_args=()
@@ -65,6 +65,17 @@ function resolveStableNode(deps) {
65
65
  if (t.exec === ctmExec && team) ctmTeam = team;
66
66
  }
67
67
  if (ctmTeam) return ctmExec;
68
+ // Re-signing did not land a Team Identifier THIS run (e.g. a transient codesign failure), but
69
+ // the CTM bundle may ALREADY be Developer-ID-signed on disk from a prior run — that signature
70
+ // is self-contained and stays valid regardless. Prefer it over the anonymous notarized node so
71
+ // the marker records the BRANDED identity and the daemon keeps matching the user's Full Disk
72
+ // Access grant to "Coding Task Manager.app" (otherwise it runs as "node" → the FDA banner
73
+ // re-prompts every restart). Gated on existing + version-matching the pin (ABI safety).
74
+ if (ctmExec && existsSync(ctmExec) && (!pin || cw.nodeReportsVersion(ctmExec, pin))
75
+ && codesign.localCodeSignTeamId && codesign.localCodeSignTeamId(ctmExec)) {
76
+ log('CTM bundle already Developer-ID-signed on disk → using it (skip notarized fallback)');
77
+ return ctmExec;
78
+ }
68
79
  } catch (e) {
69
80
  log(`bundle signing failed: ${e && e.message ? e.message : e}`);
70
81
  }
@@ -44,6 +44,15 @@ if [[ -z "$PIN" ]]; then
44
44
  fi
45
45
 
46
46
  candidates=(
47
+ # Immutable, self-managed runtimes FIRST. A `brew upgrade node` (often dragged in by an
48
+ # unrelated `brew install`) repoints or deletes the Cellar dir below, which would otherwise
49
+ # leave this resolver unable to find the pin -> daemon won't start. These vendored nodes are
50
+ # provisioned by bin/ensure-stable-node.js / create-walle and are not touched by Homebrew, so
51
+ # resolution survives that churn. Every candidate is STILL gated on an exact version match with
52
+ # the pin in the loop below, so a stale vendored node is skipped (it can never select a wrong ABI).
53
+ "$HOME/.walle/notarized-node/bin/node"
54
+ "$HOME/.walle/notarized-app/Wall-E.app/Contents/Resources/node"
55
+ "$HOME/.walle/bundles/Coding Task Manager.app/Contents/MacOS/Coding Task Manager"
47
56
  "/opt/homebrew/Cellar/node/$PIN/bin/node"
48
57
  "/usr/local/Cellar/node/$PIN/bin/node"
49
58
  "$HOME/.fnm/node-versions/v$PIN/installation/bin/node"
@@ -13,6 +13,7 @@ const permissionMatch = require('./lib/permission-match');
13
13
  // CTM permissions are a standalone global config in the CTM DB.
14
14
  const walleClient = require('./lib/walle-client');
15
15
  const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
16
+ const desktopFork = require('./lib/desktop-fork');
16
17
  const skillAutocomplete = require('./lib/skill-autocomplete');
17
18
  const skillIntentResolver = require('./lib/skill-intent-resolver');
18
19
  const resourceLinks = require('./lib/resource-links');
@@ -48,6 +49,26 @@ const CONVERSATION_IMPORT_MIN_INTERVAL_MS = Math.max(0, Number(process.env.CTM_C
48
49
  const CONVERSATION_IMPORT_FORCE_BYTES = Math.max(0, Number(process.env.CTM_CONVERSATION_IMPORT_FORCE_BYTES ?? 256 * 1024));
49
50
  const _lastConversationImportAt = new Map(); // sessionId → Date.now() of last completed import
50
51
 
52
+ // Event-driven (near-real-time) durable import. The 8s floor above exists to stop a hot session's
53
+ // rapid small appends from re-processing the WHOLE conversation each tick (the giant-transcript
54
+ // write-lock thrash). With blob retirement the common case is now an O(Δ) append (a tiny write on
55
+ // the low-priority lane), so when this is on we track the live tail at a short coalescing window —
56
+ // but ONLY for the cheap append path. A session that recently fell back to a FULL rebuild keeps the
57
+ // long floor so a rewrite-in-progress session can't thrash. Off → exact 8s/batch behavior.
58
+ const CONVERSATION_IMPORT_EVENT_DRIVEN = process.env.CTM_CONVERSATION_IMPORT_EVENT_DRIVEN === '1';
59
+ const CONVERSATION_IMPORT_EVENT_DRIVEN_MIN_INTERVAL_MS = Math.max(0, Number(
60
+ process.env.CTM_CONVERSATION_IMPORT_EVENT_DRIVEN_MIN_INTERVAL_MS ?? 250));
61
+ const _lastConversationFullRebuildAt = new Map(); // sessionId → Date.now() of last FULL rebuild
62
+
63
+ // Pure decision: the debounce floor for a hot session's small-growth (append-eligible) re-import.
64
+ // Extracted so the rebuild-thrash guard is unit-testable without the import machinery.
65
+ function _conversationImportFloorMs({ eventDriven, baseFloorMs, eventFloorMs, lastRebuildAt, now }) {
66
+ if (!eventDriven) return baseFloorMs;
67
+ // Held back to the long floor only while a recent full rebuild is still "warm".
68
+ const rebuiltRecently = lastRebuildAt > 0 && (now - lastRebuildAt) < baseFloorMs;
69
+ return rebuiltRecently ? baseFloorMs : eventFloorMs;
70
+ }
71
+
51
72
  function setDbMaintenanceRunner(fn) {
52
73
  dbMaintenanceRunner = typeof fn === 'function' ? fn : null;
53
74
  }
@@ -192,6 +213,10 @@ function handlePromptApi(req, res, url) {
192
213
  const p = url.pathname;
193
214
  const m = req.method;
194
215
 
216
+ // --- Claude Desktop → Code conversion (snapshot-and-fork) ---
217
+ if (p === '/api/claude-desktop/forks' && m === 'GET') return handleListDesktopForks(req, res);
218
+ if (p === '/api/claude-desktop/convert' && m === 'POST') return handleConvertDesktopSession(req, res);
219
+
195
220
  // --- Prompts ---
196
221
  if (p === '/api/prompts' && m === 'GET') return handleListPrompts(req, res, url);
197
222
  if (p === '/api/prompts' && m === 'POST') return handleCreatePrompt(req, res);
@@ -392,6 +417,70 @@ function handleListPrompts(req, res, url) {
392
417
  jsonResponse(res, 200, db.listPrompts(opts));
393
418
  }
394
419
 
420
+ // Convert a read-only Claude Desktop conversation into a resumable Claude Code session.
421
+ // Thin HTTP wrapper around lib/desktop-fork.convertDesktopConversation; `deps` is injectable
422
+ // for unit tests (defaults to the real Desktop cache + fork orchestrator).
423
+ const DESKTOP_CACHE_ONLY_MESSAGE =
424
+ 'Claude Desktop has only cached this conversation in the recent list — the full transcript ' +
425
+ 'is not in the local cache yet. Open the conversation in Claude Desktop once, then rescan, ' +
426
+ 'before converting it to a Claude Code session.';
427
+
428
+ // List Desktop conversations that already have a live Claude Code fork, so the client can
429
+ // flip "Convert" → "Resume fork" per conversation. Excludes forks whose transcript was
430
+ // deleted (those should re-offer conversion). `deps` injectable for tests.
431
+ function handleListDesktopForks(req, res, deps = {}) {
432
+ const dbm = deps.db || db;
433
+ const fsx = deps.fs || fs;
434
+ let rows;
435
+ try {
436
+ rows = dbm.listDesktopForks() || [];
437
+ } catch (e) {
438
+ return jsonResponse(res, 500, { error: 'list_failed', message: e && e.message || 'failed' });
439
+ }
440
+ const forks = rows
441
+ .filter((f) => f.jsonlPath && fsx.existsSync(f.jsonlPath))
442
+ .map((f) => ({ desktopUuid: f.desktopUuid, forkSessionId: f.forkSessionId, cwd: f.projectPath || '' }));
443
+ return jsonResponse(res, 200, { forks });
444
+ }
445
+
446
+ async function handleConvertDesktopSession(req, res, deps = {}) {
447
+ const desktop = deps.desktop || claudeDesktopSessions;
448
+ const fork = deps.fork || desktopFork;
449
+
450
+ let body;
451
+ try {
452
+ body = await readBody(req);
453
+ } catch {
454
+ return jsonResponse(res, 400, { error: 'invalid_body' });
455
+ }
456
+
457
+ const desktopUuid = String((body && body.desktopUuid) || '').trim();
458
+ const cwd = String((body && body.cwd) || '').trim();
459
+ if (!desktopUuid || !cwd) {
460
+ return jsonResponse(res, 400, { error: 'desktopUuid and cwd are required' });
461
+ }
462
+
463
+ const session = desktop.getSession(desktopUuid);
464
+ if (!session) {
465
+ return jsonResponse(res, 404, { error: 'Claude Desktop conversation not found' });
466
+ }
467
+
468
+ const messages = desktop.getMessages(desktopUuid) || [];
469
+ if (messages.length === 0) {
470
+ return jsonResponse(res, 409, { error: 'cache_only', message: DESKTOP_CACHE_ONLY_MESSAGE });
471
+ }
472
+
473
+ try {
474
+ const result = fork.convertDesktopConversation({ session, cwd });
475
+ return jsonResponse(res, 200, { ok: true, ...result });
476
+ } catch (e) {
477
+ if (/no .*message/i.test(e && e.message || '')) {
478
+ return jsonResponse(res, 409, { error: 'cache_only', message: DESKTOP_CACHE_ONLY_MESSAGE });
479
+ }
480
+ return jsonResponse(res, 500, { error: 'convert_failed', message: e && e.message || 'conversion failed' });
481
+ }
482
+ }
483
+
395
484
  async function handleCreatePrompt(req, res) {
396
485
  try {
397
486
  const data = await readBody(req);
@@ -1555,6 +1644,8 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1555
1644
  model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
1556
1645
  model_id: parsed.modelId || (existing && existing.model_id) || '',
1557
1646
  import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
1647
+ // Chunked-import route (cold/large): defer rows to content-rows-backfill, keep the real blob.
1648
+ ..._chunkedImportFlags(parsed.sessionId, allMessages, totalSize, 'compact-pair'),
1558
1649
  });
1559
1650
  return true;
1560
1651
  }
@@ -1683,7 +1774,15 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1683
1774
  const lastAt = _lastConversationImportAt.get(sessionId) || 0;
1684
1775
  const sinceLast = Date.now() - lastAt;
1685
1776
  const grewBytes = Math.max(0, effectiveSize - existingSize);
1686
- if (lastAt > 0 && sinceLast < CONVERSATION_IMPORT_MIN_INTERVAL_MS && grewBytes < CONVERSATION_IMPORT_FORCE_BYTES) {
1777
+ // Near-real-time floor for the cheap append path; long floor right after a full rebuild.
1778
+ const floorMs = _conversationImportFloorMs({
1779
+ eventDriven: CONVERSATION_IMPORT_EVENT_DRIVEN,
1780
+ baseFloorMs: CONVERSATION_IMPORT_MIN_INTERVAL_MS,
1781
+ eventFloorMs: CONVERSATION_IMPORT_EVENT_DRIVEN_MIN_INTERVAL_MS,
1782
+ lastRebuildAt: _lastConversationFullRebuildAt.get(sessionId) || 0,
1783
+ now: Date.now(),
1784
+ });
1785
+ if (lastAt > 0 && sinceLast < floorMs && grewBytes < CONVERSATION_IMPORT_FORCE_BYTES) {
1687
1786
  continue; // imported very recently and only a little new data — let appends coalesce
1688
1787
  }
1689
1788
  }
@@ -1734,6 +1833,46 @@ function _backgroundTranscriptImportMaxBytes() {
1734
1833
  return Math.max(256 * 1024, Number.isFinite(raw) ? raw : BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES);
1735
1834
  }
1736
1835
 
1836
+ // Phase 1.5: a cold/large full-rebuild's inline session_message_rows write (delete-all + insert-all)
1837
+ // can block the db-owner worker for seconds — over the coop slice budget. Above a message-count
1838
+ // threshold, route the import through the CHUNKED path: importSessionConversation writes the blob +
1839
+ // stamps the source-len HWM now (forceBlobWrite + skipMessageRows), then content-rows-backfill fills
1840
+ // rows in windows across slices. Returns the two flags to spread into the importSessionConversation
1841
+ // options. Applied at every large cold-import call site (codex / claude-desktop / generic claude).
1842
+ // Over-cap sessions ignore forceBlobWrite downstream (empty blob, transcript-store source of truth).
1843
+ // Routing counters (per import run) — surfaced in the always-on [auto-import] summary so the
1844
+ // chunked-vs-inline distribution is visible WITHOUT depending on CTM_COOP_LOG reaching the worker
1845
+ // thread. Reset by runIncrementalConversationImport after it logs them.
1846
+ const _chunkedRouteStats = { chunked: 0, inline: 0 };
1847
+ function _readResetChunkedRouteStats() {
1848
+ const s = { ..._chunkedRouteStats };
1849
+ _chunkedRouteStats.chunked = 0; _chunkedRouteStats.inline = 0;
1850
+ return s;
1851
+ }
1852
+
1853
+ function _chunkedImportFlags(sessionId, messages, fileSize, reason) {
1854
+ // Gated on the coop scheduler: deferring rows only helps when the cooperative scheduler is driving
1855
+ // bounded slices (and the content-rows-backfill coop/legacy job fills the deferred rows). With the
1856
+ // flag OFF the import behaves byte-for-byte as legacy (rows written inline) — so merging this branch
1857
+ // is a true no-op on the primary until CTM_COOP_SCHEDULER=1 is set.
1858
+ if (process.env.CTM_COOP_SCHEDULER !== '1') return { skipMessageRows: false, forceBlobWrite: false };
1859
+ const minMsgs = Math.max(1, Number(process.env.CTM_IMPORT_CHUNK_MIN_MSGS || 1000));
1860
+ // ALSO trigger on file size: a giant rollout is tail-bounded at parse time (transcriptMaxBytes),
1861
+ // so `messages.length` here can be small even though the session is huge and its inline row write
1862
+ // would block the worker. Keying on the JSONL byte high-water-mark catches those. Default 2MB.
1863
+ const minBytes = Math.max(1, Number(process.env.CTM_IMPORT_CHUNK_MIN_BYTES || 2 * 1024 * 1024));
1864
+ const n = Array.isArray(messages) ? messages.length : 0;
1865
+ const bytes = Number(fileSize || 0);
1866
+ const useChunked = n > minMsgs || bytes > minBytes;
1867
+ _chunkedRouteStats[useChunked ? 'chunked' : 'inline'] += 1;
1868
+ if (process.env.CTM_COOP_LOG === '1') {
1869
+ const fileMB = (bytes / (1024 * 1024)).toFixed(1);
1870
+ const trig = useChunked ? (n > minMsgs ? 'msgs' : 'bytes') : 'none';
1871
+ console.log(`[coop-import] sid=${String(sessionId || '').slice(0, 8)} msgs=${n} fileMB=${fileMB} path=${useChunked ? 'chunked' : 'inline'} trigger=${trig} reason=${reason}`);
1872
+ }
1873
+ return { skipMessageRows: useChunked, forceBlobWrite: useChunked };
1874
+ }
1875
+
1737
1876
  function _codexImportedFileSize(parsedFileSize, prevFileSize, parsedTail) {
1738
1877
  const fileSize = Math.max(0, Number(parsedFileSize || 0));
1739
1878
  const prev = Math.max(0, Number(prevFileSize || 0));
@@ -1852,6 +1991,9 @@ async function _importCodexSessionFile(parsed, filePath, options = {}) {
1852
1991
  model_provider: modelProvider,
1853
1992
  model_id: model || (existing && existing.model_id) || '',
1854
1993
  import_parser_version: parserVersion,
1994
+ // Chunked-import route (cold/large codex rollout — the giant-session case): defer rows to
1995
+ // content-rows-backfill, keep the real blob. Over-cap rollouts ignore forceBlobWrite downstream.
1996
+ ..._chunkedImportFlags(sessionId, allMessages, importedFileSize, 'codex'),
1855
1997
  });
1856
1998
 
1857
1999
  try {
@@ -2050,6 +2192,8 @@ async function importSessionFile(filePath, projectPath, projectEntry, options =
2050
2192
  model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
2051
2193
  model_id: parsed.modelId || (existing && existing.model_id) || '',
2052
2194
  import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
2195
+ // Chunked-import route (cold/large): defer rows to content-rows-backfill, keep the real blob.
2196
+ ..._chunkedImportFlags(parsed.sessionId, messages, parsed.fileSize, 'claude-desktop'),
2053
2197
  });
2054
2198
  return true;
2055
2199
  }
@@ -2131,7 +2275,12 @@ async function importSessionFile(filePath, projectPath, projectEntry, options =
2131
2275
  rename_name: parsedRename || parsed.renameName || '',
2132
2276
  import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
2133
2277
  });
2134
- if (appended && appended.ok) return true;
2278
+ if (appended && appended.ok) {
2279
+ // Cheap O(Δ) append succeeded — clear any rebuild backoff so event-driven mode
2280
+ // tracks this session's live tail at the fast cadence.
2281
+ _lastConversationFullRebuildAt.delete(parsed.sessionId);
2282
+ return true;
2283
+ }
2135
2284
  }
2136
2285
 
2137
2286
  // Full rebuild: cold import, file shrank/rotated, or the append guard refused. Load the base
@@ -2173,6 +2322,11 @@ async function importSessionFile(filePath, projectPath, projectEntry, options =
2173
2322
  // but an empty new parse must not wipe an existing rename.
2174
2323
  const mergedRename = signals.renameName || parsedRename || parsed.renameName || (existing && existing.rename_name) || '';
2175
2324
 
2325
+ // This is the expensive full-rebuild path (cold import, file shrank/rotated, or the append
2326
+ // guard refused a non-clean-prefix). Stamp the rebuild clock so event-driven mode keeps the
2327
+ // long debounce floor for this session until a cheap append succeeds again (no rebuild thrash).
2328
+ try { _lastConversationFullRebuildAt.set(parsed.sessionId, Date.now()); } catch {}
2329
+
2176
2330
  db.importSessionConversation({
2177
2331
  session_id: parsed.sessionId,
2178
2332
  project_path: parsed.cwd || parsed.project,
@@ -2192,6 +2346,8 @@ async function importSessionFile(filePath, projectPath, projectEntry, options =
2192
2346
  model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
2193
2347
  model_id: parsed.modelId || (existing && existing.model_id) || '',
2194
2348
  import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
2349
+ // Chunked-import route (cold/large): defer rows to content-rows-backfill, keep the real blob.
2350
+ ..._chunkedImportFlags(parsed.sessionId, allMessages, parsed.fileSize, 'full-rebuild'),
2195
2351
  });
2196
2352
  return true;
2197
2353
  }
@@ -2209,19 +2365,27 @@ async function handleImportConversations(req, res) {
2209
2365
  // Incremental auto-import: only processes files modified since last scan
2210
2366
  // Uses async filesystem operations to avoid blocking the event loop
2211
2367
  // (critical when ~/.claude/projects is on Dropbox/network-synced FS)
2212
- async function runIncrementalConversationImport() {
2368
+ async function runIncrementalConversationImport(opts = {}) {
2369
+ const _now = typeof opts.now === 'function' ? opts.now : () => Date.now();
2213
2370
  // Capture scanStart BEFORE anything else — we advance lastScanAt even on
2214
2371
  // partial failure so a single bad file doesn't stall the whole job forever.
2215
2372
  // Previously, a top-level failure kept lastScanAt frozen, causing repeated
2216
2373
  // full rescans + repeated failures (stall bug observed in production).
2217
- const scanStart = Date.now();
2374
+ const scanStart = _now();
2218
2375
  let imported = 0;
2219
2376
  let scanned = 0;
2220
2377
  let total = 0;
2221
2378
  let failed = 0;
2222
2379
  let hitLimit = false;
2223
2380
  let hitLimitLabel = '';
2224
- const maxImportedPerRun = Math.max(1, Number(process.env.CTM_CONVERSATION_IMPORT_MAX_PER_RUN || 12));
2381
+ // Wall-clock slice budget: a single giant/cold full-rebuild can overrun the COUNT cap on TIME, so
2382
+ // also stop between files once the budget is spent. 0/absent = no time cap (legacy behavior).
2383
+ const budgetMs = Math.max(0, Number(opts.budgetMs ?? process.env.CTM_CONVERSATION_IMPORT_BUDGET_MS ?? 0));
2384
+ const deadline = budgetMs > 0 ? scanStart + budgetMs : Infinity;
2385
+ // Smaller cap (4) applies only when a slice budget is active (coop driver re-wakes immediately on
2386
+ // hasMore, so smaller slices drain just as fast while keeping each worker op short). Legacy path
2387
+ // (no budget) keeps the original default of 12 so flag-OFF import drain is unchanged.
2388
+ const maxImportedPerRun = Math.max(1, Number(process.env.CTM_CONVERSATION_IMPORT_MAX_PER_RUN || (budgetMs > 0 ? 4 : 12)));
2225
2389
  const maxProcessedPerRun = Math.max(1, Number(process.env.CTM_CONVERSATION_IMPORT_MAX_PROCESSED_PER_RUN || maxImportedPerRun));
2226
2390
  const retryAfterMsRaw = Number(process.env.CTM_CONVERSATION_IMPORT_RETRY_AFTER_MS || CONVERSATION_IMPORT_RETRY_AFTER_MS);
2227
2391
  const retryAfterMs = Number.isFinite(retryAfterMsRaw) && retryAfterMsRaw >= 1000
@@ -2262,6 +2426,11 @@ async function runIncrementalConversationImport() {
2262
2426
  hitLimitLabel = importLimited ? `${maxImportedPerRun} imports` : `${maxProcessedPerRun} files`;
2263
2427
  break;
2264
2428
  }
2429
+ if (_now() >= deadline && scanned < candidates.length) {
2430
+ hitLimit = true;
2431
+ hitLimitLabel = `${budgetMs}ms budget`;
2432
+ break;
2433
+ }
2265
2434
  await new Promise((resolve) => setImmediate(resolve));
2266
2435
  } catch (e) {
2267
2436
  failed++;
@@ -2271,9 +2440,11 @@ async function runIncrementalConversationImport() {
2271
2440
  }
2272
2441
  }
2273
2442
 
2443
+ const _rt = _readResetChunkedRouteStats();
2274
2444
  if (imported > 0 || failed > 0) {
2275
2445
  const suffix = hitLimit ? `, paused after ${hitLimitLabel}` : '';
2276
- console.log(`[auto-import] Imported ${imported}, failed ${failed} (scanned ${scanned}/${total}${suffix})`);
2446
+ const route = (_rt.chunked || _rt.inline) ? ` route=chunked:${_rt.chunked}/inline:${_rt.inline}` : '';
2447
+ console.log(`[auto-import] Imported ${imported}, failed ${failed} (scanned ${scanned}/${total}${suffix})${route}`);
2277
2448
  }
2278
2449
  return {
2279
2450
  imported,
@@ -2282,11 +2453,12 @@ async function runIncrementalConversationImport() {
2282
2453
  candidates: candidates.length,
2283
2454
  failed,
2284
2455
  remaining: hitLimit,
2456
+ hasMore: hitLimit,
2285
2457
  retry_after_ms: hitLimit ? retryAfterMs : undefined,
2286
2458
  };
2287
2459
  } catch (e) {
2288
2460
  console.error('[auto-import] Top-level error:', e.message);
2289
- return { imported, scanned, total, failed, error: e.message };
2461
+ return { imported, scanned, total, failed, error: e.message, hasMore: false };
2290
2462
  } finally {
2291
2463
  // Always advance the timestamp — even on error — so we don't re-scan the
2292
2464
  // same problematic files forever on every tick.
@@ -2379,6 +2551,8 @@ async function runCursorConversationImport({ cursorHome } = {}) {
2379
2551
  hostname: '',
2380
2552
  import_parser_version: CURSOR_IMPORT_PARSER_VERSION,
2381
2553
  rename_name: '',
2554
+ // Chunked-import route (cold/large cursor session): defer rows to content-rows-backfill.
2555
+ ..._chunkedImportFlags(ctmId, fields.messages, 0, 'cursor'),
2382
2556
  });
2383
2557
  if (sig) _cursorImportSignatures.set(ctmId, sig);
2384
2558
  imported += 1;
@@ -5149,4 +5323,4 @@ function safeParse(json, fallback) {
5149
5323
  try { return JSON.parse(json); } catch { return fallback; }
5150
5324
  }
5151
5325
 
5152
- module.exports = { handlePromptApi, queueEngine, runIncrementalConversationImport, runCursorConversationImport, importSessionFile, setUiPrefsBroadcaster, setPromptExecutionsOffThread, setDbMaintenanceRunner, setImageSaveRunner, ensureHotkeyDaemon, hotkeyEnsureAction, screenshotResponsibleContext, screenshotNodeCandidates, probeScreenshotNodeGranted, _ingestPathFromInput, _ingestSourceAllowed, _conversationImportCandidates, _ingestTranscriptStoreForParsedFile, _learnSignatureRules, _appendBlocklistExceptions, _finalizePermIntent, _denyHinted, _lastConversationImportAt };
5326
+ module.exports = { handlePromptApi, handleConvertDesktopSession, handleListDesktopForks, queueEngine, runIncrementalConversationImport, runCursorConversationImport, importSessionFile, _chunkedImportFlags, setUiPrefsBroadcaster, setPromptExecutionsOffThread, setDbMaintenanceRunner, setImageSaveRunner, ensureHotkeyDaemon, hotkeyEnsureAction, screenshotResponsibleContext, screenshotNodeCandidates, probeScreenshotNodeGranted, _ingestPathFromInput, _ingestSourceAllowed, _conversationImportCandidates, _ingestTranscriptStoreForParsedFile, _learnSignatureRules, _appendBlocklistExceptions, _finalizePermIntent, _denyHinted, _lastConversationImportAt, _conversationImportFloorMs, _lastConversationFullRebuildAt };