@claudexor/cli 1.0.1 → 2.1.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.
Files changed (213) hide show
  1. package/dist/acp-surface-runner.d.ts +17 -0
  2. package/dist/acp-surface-runner.d.ts.map +1 -0
  3. package/dist/acp-surface-runner.js +187 -0
  4. package/dist/acp-surface-runner.js.map +1 -0
  5. package/dist/args.d.ts.map +1 -1
  6. package/dist/args.js.map +1 -1
  7. package/dist/bridge-serve.d.ts +3 -0
  8. package/dist/bridge-serve.d.ts.map +1 -0
  9. package/dist/bridge-serve.js +39 -0
  10. package/dist/bridge-serve.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +12 -3
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/claude-oauth-usage.d.ts +33 -0
  15. package/dist/claude-oauth-usage.d.ts.map +1 -0
  16. package/dist/claude-oauth-usage.js +190 -0
  17. package/dist/claude-oauth-usage.js.map +1 -0
  18. package/dist/claude-statusline.d.ts +25 -0
  19. package/dist/claude-statusline.d.ts.map +1 -0
  20. package/dist/claude-statusline.js +259 -0
  21. package/dist/claude-statusline.js.map +1 -0
  22. package/dist/claudexord.js +346 -543
  23. package/dist/claudexord.js.map +1 -1
  24. package/dist/cli-io.d.ts +8 -9
  25. package/dist/cli-io.d.ts.map +1 -1
  26. package/dist/cli-io.js +11 -7
  27. package/dist/cli-io.js.map +1 -1
  28. package/dist/cli.js +475 -516
  29. package/dist/cli.js.map +1 -1
  30. package/dist/codex-quota-source.d.ts +11 -0
  31. package/dist/codex-quota-source.d.ts.map +1 -0
  32. package/dist/codex-quota-source.js +181 -0
  33. package/dist/codex-quota-source.js.map +1 -0
  34. package/dist/command-flags.d.ts +18 -0
  35. package/dist/command-flags.d.ts.map +1 -0
  36. package/dist/command-flags.js +124 -0
  37. package/dist/command-flags.js.map +1 -0
  38. package/dist/command-registry.d.ts +2 -40
  39. package/dist/command-registry.d.ts.map +1 -1
  40. package/dist/command-registry.js +74 -164
  41. package/dist/command-registry.js.map +1 -1
  42. package/dist/control-services.d.ts +2483 -0
  43. package/dist/control-services.d.ts.map +1 -0
  44. package/dist/control-services.js +437 -0
  45. package/dist/control-services.js.map +1 -0
  46. package/dist/credential-commands.d.ts +9 -0
  47. package/dist/credential-commands.d.ts.map +1 -0
  48. package/dist/credential-commands.js +224 -0
  49. package/dist/credential-commands.js.map +1 -0
  50. package/dist/daemon-lifecycle.d.ts +7 -3
  51. package/dist/daemon-lifecycle.d.ts.map +1 -1
  52. package/dist/daemon-lifecycle.js +40 -12
  53. package/dist/daemon-lifecycle.js.map +1 -1
  54. package/dist/daemon-run.d.ts +2 -0
  55. package/dist/daemon-run.d.ts.map +1 -1
  56. package/dist/daemon-run.js +62 -14
  57. package/dist/daemon-run.js.map +1 -1
  58. package/dist/daemon-runtime-shutdown.d.ts +58 -0
  59. package/dist/daemon-runtime-shutdown.d.ts.map +1 -0
  60. package/dist/daemon-runtime-shutdown.js +142 -0
  61. package/dist/daemon-runtime-shutdown.js.map +1 -0
  62. package/dist/decision.d.ts.map +1 -1
  63. package/dist/decision.js +12 -3
  64. package/dist/decision.js.map +1 -1
  65. package/dist/live.d.ts +29 -0
  66. package/dist/live.d.ts.map +1 -1
  67. package/dist/live.js +117 -6
  68. package/dist/live.js.map +1 -1
  69. package/dist/local-attachment.d.ts +14 -0
  70. package/dist/local-attachment.d.ts.map +1 -0
  71. package/dist/local-attachment.js +67 -0
  72. package/dist/local-attachment.js.map +1 -0
  73. package/dist/mcp-runner.d.ts +4 -15
  74. package/dist/mcp-runner.d.ts.map +1 -1
  75. package/dist/mcp-runner.js +273 -145
  76. package/dist/mcp-runner.js.map +1 -1
  77. package/dist/native-login.d.ts +21 -0
  78. package/dist/native-login.d.ts.map +1 -0
  79. package/dist/native-login.js +111 -0
  80. package/dist/native-login.js.map +1 -0
  81. package/dist/ops-commands.d.ts +8 -6
  82. package/dist/ops-commands.d.ts.map +1 -1
  83. package/dist/ops-commands.js +250 -168
  84. package/dist/ops-commands.js.map +1 -1
  85. package/dist/orchestrate-options.d.ts.map +1 -1
  86. package/dist/orchestrate-options.js +1 -1
  87. package/dist/orchestrate-options.js.map +1 -1
  88. package/dist/orphan-reaper.d.ts +4 -5
  89. package/dist/orphan-reaper.d.ts.map +1 -1
  90. package/dist/orphan-reaper.js +29 -56
  91. package/dist/orphan-reaper.js.map +1 -1
  92. package/dist/orphan-sweeper.d.ts +1 -2
  93. package/dist/orphan-sweeper.d.ts.map +1 -1
  94. package/dist/orphan-sweeper.js +29 -48
  95. package/dist/orphan-sweeper.js.map +1 -1
  96. package/dist/plugins.d.ts.map +1 -1
  97. package/dist/plugins.js +196 -64
  98. package/dist/plugins.js.map +1 -1
  99. package/dist/primary-output.d.ts.map +1 -1
  100. package/dist/primary-output.js.map +1 -1
  101. package/dist/profile-registration.d.ts +15 -0
  102. package/dist/profile-registration.d.ts.map +1 -0
  103. package/dist/profile-registration.js +68 -0
  104. package/dist/profile-registration.js.map +1 -0
  105. package/dist/project-command.d.ts +3 -0
  106. package/dist/project-command.d.ts.map +1 -0
  107. package/dist/project-command.js +87 -0
  108. package/dist/project-command.js.map +1 -0
  109. package/dist/quota-command.d.ts +3 -0
  110. package/dist/quota-command.d.ts.map +1 -0
  111. package/dist/quota-command.js +59 -0
  112. package/dist/quota-command.js.map +1 -0
  113. package/dist/registry.d.ts +1 -1
  114. package/dist/registry.d.ts.map +1 -1
  115. package/dist/registry.js +16 -4
  116. package/dist/registry.js.map +1 -1
  117. package/dist/release.d.ts.map +1 -1
  118. package/dist/release.js +15 -3
  119. package/dist/release.js.map +1 -1
  120. package/dist/repl.d.ts +3 -8
  121. package/dist/repl.d.ts.map +1 -1
  122. package/dist/repl.js +23 -122
  123. package/dist/repl.js.map +1 -1
  124. package/dist/request-preflight.d.ts +13 -0
  125. package/dist/request-preflight.d.ts.map +1 -0
  126. package/dist/request-preflight.js +81 -0
  127. package/dist/request-preflight.js.map +1 -0
  128. package/dist/retention-service.d.ts +24 -0
  129. package/dist/retention-service.d.ts.map +1 -0
  130. package/dist/retention-service.js +100 -0
  131. package/dist/retention-service.js.map +1 -0
  132. package/dist/retry-command-specs.d.ts +3 -0
  133. package/dist/retry-command-specs.d.ts.map +1 -0
  134. package/dist/retry-command-specs.js +21 -0
  135. package/dist/retry-command-specs.js.map +1 -0
  136. package/dist/retry-command.d.ts +4 -0
  137. package/dist/retry-command.d.ts.map +1 -0
  138. package/dist/retry-command.js +57 -0
  139. package/dist/retry-command.js.map +1 -0
  140. package/dist/review-command.d.ts.map +1 -1
  141. package/dist/review-command.js +47 -12
  142. package/dist/review-command.js.map +1 -1
  143. package/dist/run-options.d.ts +2 -2
  144. package/dist/run-options.d.ts.map +1 -1
  145. package/dist/run-options.js +27 -5
  146. package/dist/run-options.js.map +1 -1
  147. package/dist/settings-command.d.ts.map +1 -1
  148. package/dist/settings-command.js +143 -188
  149. package/dist/settings-command.js.map +1 -1
  150. package/dist/settings-service.d.ts +50 -0
  151. package/dist/settings-service.d.ts.map +1 -1
  152. package/dist/settings-service.js +67 -1
  153. package/dist/settings-service.js.map +1 -1
  154. package/dist/setup-job-reducer.d.ts +9 -0
  155. package/dist/setup-job-reducer.d.ts.map +1 -0
  156. package/dist/setup-job-reducer.js +290 -0
  157. package/dist/setup-job-reducer.js.map +1 -0
  158. package/dist/setup-job-store.d.ts +250 -0
  159. package/dist/setup-job-store.d.ts.map +1 -0
  160. package/dist/setup-job-store.js +359 -0
  161. package/dist/setup-job-store.js.map +1 -0
  162. package/dist/setup-job-support.d.ts +27 -0
  163. package/dist/setup-job-support.d.ts.map +1 -0
  164. package/dist/setup-job-support.js +141 -0
  165. package/dist/setup-job-support.js.map +1 -0
  166. package/dist/setup-jobs.d.ts +437 -22
  167. package/dist/setup-jobs.d.ts.map +1 -1
  168. package/dist/setup-jobs.js +813 -423
  169. package/dist/setup-jobs.js.map +1 -1
  170. package/dist/setup-lifecycle-binding.d.ts +40 -0
  171. package/dist/setup-lifecycle-binding.d.ts.map +1 -0
  172. package/dist/setup-lifecycle-binding.js +159 -0
  173. package/dist/setup-lifecycle-binding.js.map +1 -0
  174. package/dist/setup-login-protocol.d.ts +13 -0
  175. package/dist/setup-login-protocol.d.ts.map +1 -0
  176. package/dist/setup-login-protocol.js +179 -0
  177. package/dist/setup-login-protocol.js.map +1 -0
  178. package/dist/setup-login-runner.d.ts +20 -0
  179. package/dist/setup-login-runner.d.ts.map +1 -0
  180. package/dist/setup-login-runner.js +225 -0
  181. package/dist/setup-login-runner.js.map +1 -0
  182. package/dist/setup-supervisor.d.ts +53 -0
  183. package/dist/setup-supervisor.d.ts.map +1 -0
  184. package/dist/setup-supervisor.js +177 -0
  185. package/dist/setup-supervisor.js.map +1 -0
  186. package/dist/spec.d.ts +9 -20
  187. package/dist/spec.d.ts.map +1 -1
  188. package/dist/spec.js +49 -21
  189. package/dist/spec.js.map +1 -1
  190. package/dist/thread-delivery.d.ts +18 -0
  191. package/dist/thread-delivery.d.ts.map +1 -0
  192. package/dist/thread-delivery.js +67 -0
  193. package/dist/thread-delivery.js.map +1 -0
  194. package/dist/thread-select.d.ts +9 -0
  195. package/dist/thread-select.d.ts.map +1 -0
  196. package/dist/thread-select.js +12 -0
  197. package/dist/thread-select.js.map +1 -0
  198. package/dist/trust-command.d.ts.map +1 -1
  199. package/dist/trust-command.js +69 -61
  200. package/dist/trust-command.js.map +1 -1
  201. package/dist/trust-services.d.ts +5 -6
  202. package/dist/trust-services.d.ts.map +1 -1
  203. package/dist/trust-services.js +42 -13
  204. package/dist/trust-services.js.map +1 -1
  205. package/package.json +24 -22
  206. package/dist/attachment-resolver.d.ts +0 -11
  207. package/dist/attachment-resolver.d.ts.map +0 -1
  208. package/dist/attachment-resolver.js +0 -61
  209. package/dist/attachment-resolver.js.map +0 -1
  210. package/dist/settings-display.d.ts +0 -3
  211. package/dist/settings-display.d.ts.map +0 -1
  212. package/dist/settings-display.js +0 -33
  213. package/dist/settings-display.js.map +0 -1
@@ -1,564 +1,367 @@
1
1
  #!/usr/bin/env node
2
- import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { isAbsolute, join } from "node:path";
4
- import { DaemonClient, DaemonServer, InteractionRegistry, RunEventBus, ThreadStore, daemonDir, defaultSocketPath, ensureToken, logPath, socketAlive, } from "@claudexor/daemon";
2
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DaemonClient, commandProjection, interactionProjection, operatorDecisionProjection, runEventProjection, JournalManager, DaemonServer, InteractionRegistry, ProjectPartitions, projectProjection, RunEventBus, ResourceStore, quotaProjection, threadHeadPingProjection, threadProjection, daemonDir, defaultSocketPath, acquireDaemonWriterLease, ensureToken, ensureDaemonRuntimeRoot, logPath, socketAlive, } from "@claudexor/daemon";
5
5
  import { DaemonControlApiServer, normalizeRunStartRequest } from "@claudexor/control-api";
6
- import { armDaemonLifecycle, runStartupCrashGc } from "./daemon-lifecycle.js";
6
+ import { armDaemonLifecycle, logLine, runStartupCrashGc } from "./daemon-lifecycle.js";
7
7
  import { Orchestrator } from "@claudexor/orchestrator";
8
- import { loadConfig, updateGlobalConfig } from "@claudexor/config";
9
- import { listTrustService, updateTrustService } from "./trust-services.js";
10
- import { SecretStore } from "@claudexor/secrets";
11
- import { ensureThreadWorktree, diffStaged, git, snapshotTree } from "@claudexor/workspace";
12
- import { deliver } from "@claudexor/delivery";
13
- import { containsSecretLikeToken, noProjectRepoRoot, readTextSafe, } from "@claudexor/util";
14
- import { ControlSettingsUpdateRequest, InterviewAnswer, } from "@claudexor/schema";
15
- import { invalidateDoctorCache, validateModel } from "@claudexor/core";
16
- import { resolveAttachments } from "./attachment-resolver.js";
17
- import { buildGateway, buildRegistry, harnessModels } from "./registry.js";
18
- import { buildAgentCapabilityCatalog } from "./capabilities.js";
19
- import { applyHarnessSettingsPatches, assertSettingsPatchValid } from "./settings-service.js";
8
+ import { loadConfig } from "@claudexor/config";
9
+ import { ensureThreadWorktree } from "@claudexor/workspace";
10
+ import { noProjectRepoRoot, redactSecrets } from "@claudexor/util";
11
+ import { scheduleStartupRetention } from "./retention-service.js";
12
+ import { controlServices } from "./control-services.js";
13
+ import { AuthReadinessService } from "@claudexor/gateway";
14
+ import { buildGateway, buildRegistry } from "./registry.js";
20
15
  import { createSetupJobManager } from "./setup-jobs.js";
21
- import { buildGroundingPrompt, extractQuestionsFromPlan, freezeSpecFromGrounding, persistSpec, } from "./spec.js";
16
+ import { SetupJobStore } from "./setup-job-store.js";
17
+ import { SetupLifecycleBinding } from "./setup-lifecycle-binding.js";
18
+ import { DaemonRuntimeShutdown } from "./daemon-runtime-shutdown.js";
19
+ import { refreshCodexQuota } from "./codex-quota-source.js";
20
+ import { refreshClaudeStatuslineQuota } from "./claude-statusline.js";
21
+ import { refreshClaudeOauthUsageQuota } from "./claude-oauth-usage.js";
22
22
  const NO_PROJECT_ROOT = noProjectRepoRoot();
23
23
  async function main() {
24
- // The daemon dir holds the auth token, jobs registry, and setup logs: it must
25
- // never be group/world readable (mkdir mode only applies on creation).
26
- mkdirSync(daemonDir(), { recursive: true, mode: 0o700 });
27
- chmodSync(daemonDir(), 0o700);
28
- const token = ensureToken();
24
+ ensureDaemonRuntimeRoot();
29
25
  const socketPath = defaultSocketPath();
30
- // Live observation plane: every RunEvent is pushed onto the in-process bus
31
- // (SSE latency drops to immediate; events.jsonl stays the canonical log) and
32
- // harness questions park in the interaction registry until answered.
33
- const bus = new RunEventBus();
34
- const interactions = new InteractionRegistry();
35
- // Thread/session SSOT: durable conversation registry; vendor CLI session
36
- // ids are the re-hostable cache that lets later turns resume natively.
37
- const threads = new ThreadStore(join(daemonDir(), "threads.json"));
38
- const server = new DaemonServer({
39
- socketPath,
40
- token,
41
- // Durable run registry so the run list survives a daemon/Mac restart.
42
- persistPath: join(daemonDir(), "jobs.json"),
43
- // A terminal run must stop advertising waiting_on_user immediately —
44
- // cancelled/failed runs otherwise park their questions in the registry
45
- // until the interaction timeout.
46
- onRunTerminal: (runId) => interactions.dropForRun(runId),
47
- // A turn whose job died BEFORE a run bound (trust gate, preflight throw)
48
- // records the refusal on itself — the chat renders it inline instead of
49
- // an eternally-empty bubble.
50
- onTurnEnqueueFailed: (turnId, error, code) => threads.setTurnEnqueueError(turnId, error, code),
51
- runner: async (params, ctx) => {
52
- const p = normalizeDaemonRunStart(params);
53
- const mode = p.mode;
54
- const noProjectAsk = mode === "ask" && p.scope.kind === "none";
55
- const repoRoot = p.scope.kind === "project" ? p.scope.root : NO_PROJECT_ROOT;
56
- if (noProjectAsk)
57
- mkdirSync(NO_PROJECT_ROOT, { recursive: true, mode: 0o700 });
58
- const orchestrator = new Orchestrator({
59
- registry: buildRegistry(),
60
- portfolio: p.portfolio,
61
- reviewerPanel: p.reviewerPanel,
62
- reviewerModels: p.reviewerModels && typeof p.reviewerModels === "object" ? p.reviewerModels : undefined,
63
- reviewerEfforts: p.reviewerEfforts && typeof p.reviewerEfforts === "object"
64
- ? p.reviewerEfforts
65
- : undefined,
66
- });
67
- // Fail loud on bogus socket-caller thread/turn ids (job settles failed).
68
- const { threadId, turnId } = threads.assertKnownIds(p.threadId, p.turnId);
69
- // Resolve the execution tree: an ISOLATED thread runs in its persistent
70
- // worktree (lazily created); in-place threads and ordinary runs use the
71
- // project root. Config/artifacts stay anchored to repoRoot either way.
72
- let executionRoot;
73
- let inPlace = p.execution.isolation === "live";
74
- if (threadId && repoRoot !== NO_PROJECT_ROOT) {
75
- const thread = threads.getThread(threadId);
76
- if (thread?.workspace.mode === "isolated") {
77
- const wt = await ensureThreadWorktree(repoRoot, threadId);
78
- executionRoot = wt.path;
79
- // Persist the base ONLY on creation: `apply` advances base_sha, and a
80
- // later turn must not clobber it back to the worktree HEAD (which never
81
- // moves, since turns don't commit) — that would re-apply old work.
82
- if (wt.created)
83
- threads.setThreadWorktree(threadId, wt.path, wt.baseSha);
84
- inPlace = true; // isolated turns run in-place WITHIN the worktree
85
- }
26
+ const writerLease = acquireDaemonWriterLease(socketPath);
27
+ let shutdownRuntime = null;
28
+ // Release wave round-12 BLOCK: the single-writer lease may only be released
29
+ // after a CLEAN shutdown — a failed/partial shutdown keeps components that
30
+ // can still write, and releasing would let a successor acquire ownership
31
+ // beside them. On failure the lease dies with the process instead.
32
+ let releaseWriterLease = true;
33
+ let lifecycle = null;
34
+ let quotaPollTimer = null;
35
+ try {
36
+ const token = ensureToken();
37
+ if (await socketAlive(socketPath)) {
38
+ throw new Error(`a claudexor daemon is already listening on ${socketPath}; stop it first`);
39
+ }
40
+ await runStartupCrashGc({ daemonDir: daemonDir(), logPath: logPath() });
41
+ const bus = new RunEventBus();
42
+ const journalManager = new JournalManager(daemonDir());
43
+ const commandStoreSlot = journalManager.registerProjection(commandProjection());
44
+ const interactionStoreSlot = journalManager.registerProjection(interactionProjection());
45
+ const operatorDecisionStoreSlot = journalManager.registerProjection(operatorDecisionProjection());
46
+ const runEventStoreSlot = journalManager.registerProjection(runEventProjection());
47
+ const projectStoreSlot = journalManager.registerProjection(projectProjection());
48
+ const quotaStoreSlot = journalManager.registerProjection(quotaProjection([
49
+ refreshCodexQuota,
50
+ refreshClaudeStatuslineQuota,
51
+ () => refreshClaudeOauthUsageQuota(),
52
+ ]));
53
+ // Sidebar invalidation ping (W12): a GLOBAL-partition emitter every
54
+ // ThreadStore (global + per-project) writes through, so any thread
55
+ // mutation reaches the app's single global stream. The ping is auxiliary
56
+ // invalidation — it must never fail the mutation that triggered it
57
+ // (mirrors the runner's turn-binding policy).
58
+ const threadHeadPingSlot = journalManager.registerProjection(threadHeadPingProjection());
59
+ const threadHeadPing = (ping) => {
60
+ try {
61
+ threadHeadPingSlot.current().ping(ping);
86
62
  }
87
- // Single-writer turn binding: control-api pre-creates the turn and passes
88
- // its id, which we bind when the run starts. A thread run WITHOUT a
89
- // pre-created turn (a direct POST /runs with threadId) gets its turn
90
- // created and bound here, so "a run is always recorded on its thread".
91
- const onRunStart = (info) => {
92
- ctx.onRunStart?.(info);
93
- if (!threadId)
94
- return;
95
- try {
96
- if (turnId) {
97
- threads.bindTurnRun(turnId, info.runId);
98
- }
99
- else {
100
- const turn = threads.createTurn(threadId, String(p.prompt ?? ""), {
101
- parentRunId: typeof p.parentRunId === "string" ? p.parentRunId : null,
102
- });
103
- threads.bindTurnRun(turn.id, info.runId);
63
+ catch {
64
+ /* invalidation ping must never fail the thread mutation */
65
+ }
66
+ };
67
+ const threadStoreSlot = journalManager.registerProjection(threadProjection(threadHeadPing));
68
+ const setupStoreSlot = journalManager.registerProjection({
69
+ name: "setup",
70
+ create: (journal) => new SetupJobStore(daemonDir(), { journal }),
71
+ validate: (store) => store.validateProjection(),
72
+ });
73
+ journalManager.start();
74
+ const pollQuota = () => {
75
+ try {
76
+ void quotaStoreSlot.current().pollStale();
77
+ }
78
+ catch { }
79
+ };
80
+ quotaPollTimer = setInterval(pollQuota, 60_000).unref();
81
+ pollQuota();
82
+ const threads = new ProjectPartitions(daemonDir(), projectStoreSlot, commandStoreSlot, interactionStoreSlot, operatorDecisionStoreSlot, runEventStoreSlot, threadStoreSlot, threadHeadPing);
83
+ const interactions = new InteractionRegistry({
84
+ forRequest: (params) => threads.interactionsForRequest(params),
85
+ all: () => threads.interactionStores(),
86
+ });
87
+ const resources = new ResourceStore(join(daemonDir(), "resource-store"));
88
+ const server = new DaemonServer({
89
+ socketPath,
90
+ token,
91
+ commands: threads,
92
+ onRunTerminal: (runId, threadId) => {
93
+ interactions.dropForRun(runId);
94
+ // Run-terminal is the one W12 path with no thread-store mutation to
95
+ // ride — the terminal changes the thread's presented state, so ping.
96
+ if (threadId)
97
+ threads.pingThreadHead(threadId);
98
+ },
99
+ onTurnEnqueueFailed: (turnId, error, code) => threads.setTurnEnqueueError(turnId, error, code),
100
+ onShutdownRequested: () => shutdownRuntime?.beginShutdown("socket-rpc stop") ??
101
+ Promise.reject(new Error("daemon shutdown coordinator is not initialized")),
102
+ runner: async (params, ctx) => {
103
+ const p = normalizeRunStartRequest(params);
104
+ const mode = p.mode;
105
+ const noProjectAsk = mode === "ask" && p.scope.kind === "none";
106
+ const repoRoot = p.scope.kind === "project" ? p.scope.root : NO_PROJECT_ROOT;
107
+ if (noProjectAsk)
108
+ mkdirSync(NO_PROJECT_ROOT, { recursive: true, mode: 0o700 });
109
+ const orchestrator = new Orchestrator({
110
+ registry: buildRegistry(),
111
+ routingGoal: p.routingGoal,
112
+ quotaSnapshots: () => quotaStoreSlot.current().read().snapshots,
113
+ quotaEventSink: (harnessId, event) => quotaStoreSlot.current().ingest(harnessId, event),
114
+ reviewerPanel: p.reviewerPanel,
115
+ reviewerModels: p.reviewerModels && typeof p.reviewerModels === "object" ? p.reviewerModels : undefined,
116
+ reviewerEfforts: p.reviewerEfforts && typeof p.reviewerEfforts === "object"
117
+ ? p.reviewerEfforts
118
+ : undefined,
119
+ });
120
+ const { threadId, turnId } = threads.assertKnownIds(p.threadId, p.turnId);
121
+ let executionRoot;
122
+ let inPlace = p.execution.isolation === "live";
123
+ if (threadId && repoRoot !== NO_PROJECT_ROOT) {
124
+ const thread = threads.getThread(threadId);
125
+ if (thread?.workspace.mode === "isolated") {
126
+ const wt = await ensureThreadWorktree(repoRoot, threadId);
127
+ executionRoot = wt.path;
128
+ // Only creation owns the base; apply advances it independently.
129
+ if (wt.created)
130
+ threads.setThreadWorktree(threadId, wt.path, wt.baseSha);
131
+ inPlace = true; // isolated turns run in-place WITHIN the worktree
104
132
  }
105
133
  }
106
- catch {
107
- /* turn binding must never fail the run */
134
+ const onRunStart = (info) => {
135
+ ctx.onRunStart?.(info);
136
+ if (!threadId)
137
+ return;
138
+ try {
139
+ if (turnId) {
140
+ threads.bindTurnRun(turnId, info.runId);
141
+ }
142
+ else {
143
+ const turn = threads.createTurn(threadId, String(p.prompt ?? ""), {
144
+ parentRunId: typeof p.parentRunId === "string" ? p.parentRunId : null,
145
+ });
146
+ threads.bindTurnRun(turn.id, info.runId);
147
+ }
148
+ }
149
+ catch {
150
+ /* turn binding must never fail the run */
151
+ }
152
+ };
153
+ // maxSeconds: a hard wall-clock deadline for the WHOLE run (run-scoped,
154
+ // never per-attempt). Combine the daemon's per-run cancel signal with a
155
+ // deadline that aborts with a typed STRING reason so the terminal is
156
+ // `cancelled` + wall_clock_exceeded rather than a bare user cancel.
157
+ const maxSeconds = typeof p.maxSeconds === "number" && p.maxSeconds > 0
158
+ ? // Defense in depth against a setTimeout 32-bit-ms overflow (the
159
+ // schema already caps at 7 days for the control-API path).
160
+ Math.min(p.maxSeconds, 604_800)
161
+ : null;
162
+ // INV-135 selection precedence: explicit per-turn profile beats the
163
+ // thread's sticky profile beats the engine default — and an explicit
164
+ // NULL forces the default ladder past the sticky profile (release
165
+ // wave round-11). The winner scopes BOTH the run spec and the
166
+ // resume-session lookup (resume never crosses profiles).
167
+ const requestedProfileId = p.credentialProfileId === null
168
+ ? null
169
+ : typeof p.credentialProfileId === "string" && p.credentialProfileId
170
+ ? p.credentialProfileId
171
+ : threadId
172
+ ? (threads.getThread(threadId)?.credential_profile_id ?? null)
173
+ : null;
174
+ let deadlineTimer;
175
+ let runSignal = ctx.signal;
176
+ if (maxSeconds !== null) {
177
+ const deadline = new AbortController();
178
+ deadlineTimer = setTimeout(() => deadline.abort("wall_clock_exceeded"), maxSeconds * 1000);
179
+ deadlineTimer.unref?.();
180
+ runSignal = ctx.signal ? AbortSignal.any([ctx.signal, deadline.signal]) : deadline.signal;
108
181
  }
109
- };
110
- return orchestrator.run({
111
- onEvent: (event) => bus.publish(event),
112
- onInteraction: (ctx2) => interactions.register(ctx2),
113
- interactionTimeoutMs: loadConfig(repoRoot).global.interaction_timeout_ms,
114
- threadId,
115
- executionRoot,
116
- // Native session continuity: resume each routed harness's own prior
117
- // conversation in this thread; record new native ids for future turns.
118
- resumeSessions: threadId ? threads.resumeMap(threadId) : undefined,
119
- onSessionObserved: threadId
120
- ? (harnessId, nativeSessionId, observedModel) => threads.recordSession(threadId, harnessId, nativeSessionId, observedModel)
121
- : undefined,
122
- authPreference: p.authPreference,
123
- // Orchestrate autonomy (suggest/auto_safe/auto_full): consumed by the
124
- // executor in runOrchestrate. The daemon also lends the executor a live
125
- // answer-delivery service for answer_question steps (the engine does not
126
- // own the interaction registry).
127
- autonomy: p.autonomy,
128
- answerInteraction: async (subRunId, interactionId, answers) => interactions.answer(subRunId, interactionId, answers).status === "delivered",
129
- repoRoot,
130
- prompt: String(p.prompt ?? ""),
131
- // Attachments ride on the turn (resolved to scoped paths); a direct
132
- // POST /runs without a turn resolves them here. Never base64 in jobs.json.
133
- attachments: turnId
134
- ? (threads.getTurn(turnId)?.attachments ?? [])
135
- : resolveAttachments(p.attachments),
136
- // Agent-driven browser opt-in (Playwright MCP). The orchestrator gates it
137
- // on the harness's browser_tool capability + web policy != off.
138
- browser: p.browser === true,
139
- mode: p.mode,
140
- contextMode: noProjectAsk
141
- ? "off"
142
- : p.scope.kind === "project"
143
- ? p.scope.context
182
+ return orchestrator
183
+ .run({
184
+ onEvent: (event) => {
185
+ if (event.type === "harness.event") {
186
+ const payload = event.payload;
187
+ const harnessId = typeof payload["harness_id"] === "string" ? payload["harness_id"] : "";
188
+ if (harnessId)
189
+ quotaStoreSlot.current().ingest(harnessId, payload);
190
+ }
191
+ threads.recordRunEvent(p, event);
192
+ bus.publish(event);
193
+ },
194
+ onInteraction: (ctx2) => interactions.register(ctx2, p),
195
+ interactionTimeoutMs: loadConfig(repoRoot).global.interaction_timeout_ms,
196
+ threadId,
197
+ executionRoot,
198
+ resumeSessions: threadId ? threads.resumeMap(threadId, requestedProfileId) : undefined,
199
+ onSessionObserved: threadId
200
+ ? (harnessId, nativeSessionId, observedModel, profileId) =>
201
+ // The EVENT's profile is the cache truth (INV-135): rotation
202
+ // makes the effective profile differ from the requested one,
203
+ // and a mislabeled session would resume under the wrong
204
+ // account on the next turn.
205
+ threads.recordSession(threadId, harnessId, nativeSessionId, observedModel, profileId ?? null)
144
206
  : undefined,
145
- harnesses: p.harnesses,
146
- primaryHarness: p.primaryHarness,
147
- portfolio: p.portfolio,
148
- n: p.n,
149
- attempts: p.attempts ?? null,
150
- untilClean: p.untilClean === true,
151
- swarm: p.swarm === true,
152
- create: p.create === true,
153
- synthesis: p.synthesis,
154
- // Policy from the GUI composer / API client (applied, not just displayed).
155
- maxUsd: p.maxUsd ?? null,
156
- maxToolCalls: p.maxToolCalls ?? null,
157
- access: p.access,
158
- web: p.web ?? p.externalContextPolicy,
159
- externalContextPolicy: p.externalContextPolicy ?? p.web,
160
- model: p.model,
161
- models: p.models,
162
- effort: p.effort,
163
- tests: Array.isArray(p.tests) ? p.tests : undefined,
164
- protectedPathApprovals: Array.isArray(p.protectedPathApprovals)
165
- ? p.protectedPathApprovals
166
- : undefined,
167
- specId: typeof p.specId === "string" ? p.specId : undefined,
168
- specHash: typeof p.specHash === "string" ? p.specHash : undefined,
169
- specPath: typeof p.specPath === "string" ? p.specPath : undefined,
170
- inPlace,
171
- signal: ctx.signal,
172
- onRunStart,
173
- });
174
- },
175
- });
176
- // SINGLETON GUARD FIRST: a second claudexord must refuse BEFORE crash GC —
177
- // otherwise it would reap the LIVE daemon's recorded children and sweep
178
- // envelopes its running jobs still own. server.start() re-checks (race-safe
179
- // enough: the window between the probe and listen is milliseconds, and GC
180
- // only runs when the probe found no live daemon).
181
- if (await socketAlive(socketPath)) {
182
- throw new Error(`a claudexor daemon is already listening on ${socketPath}; stop it first`);
183
- }
184
- // Crash GC before any new work (reap surviving children, sweep orphaned
185
- // envelopes/branches/tmp-homes), then arm live-children bookkeeping and
186
- // graceful SIGTERM/SIGINT shutdown.
187
- await runStartupCrashGc({ daemonDir: daemonDir(), logPath: logPath() });
188
- await server.start();
189
- appendFileSync(logPath(), `[${new Date().toISOString()}] claudexord listening on ${socketPath}\n`);
190
- const lifecycle = armDaemonLifecycle({
191
- daemonDir: daemonDir(),
192
- logPath: logPath(),
193
- stop: () => server.stop(),
194
- });
195
- const control = process.env.CLAUDEXOR_NO_CONTROL_API === "1"
196
- ? null
197
- : new DaemonControlApiServer({
198
- token,
199
- daemon: new DaemonClient(socketPath, token),
200
- port: Number(process.env.CLAUDEXOR_CONTROL_PORT ?? 0),
201
- bus,
202
- services: controlServices(interactions, threads),
207
+ authPreference: p.authPreference,
208
+ credentialProfileId: requestedProfileId,
209
+ autonomy: p.autonomy,
210
+ answerInteraction: async (subRunId, interactionId, answers) => interactions.answer(subRunId, interactionId, answers).status === "delivered",
211
+ repoRoot,
212
+ prompt: String(p.prompt ?? ""),
213
+ instructions: typeof p.instructions === "string" ? p.instructions : undefined,
214
+ denyPaths: Array.isArray(p.denyPaths) ? p.denyPaths : undefined,
215
+ maxTurns: typeof p.maxTurns === "number" && p.maxTurns > 0 ? p.maxTurns : undefined,
216
+ outputSchema: p.outputSchema && typeof p.outputSchema === "object" && !Array.isArray(p.outputSchema)
217
+ ? p.outputSchema
218
+ : undefined,
219
+ attachments: turnId
220
+ ? (threads.getTurn(turnId)?.attachments ?? [])
221
+ : resources.resolve(p.attachments),
222
+ browser: p.browser === true,
223
+ mode: p.mode,
224
+ contextMode: noProjectAsk
225
+ ? "off"
226
+ : p.scope.kind === "project"
227
+ ? p.scope.context
228
+ : undefined,
229
+ harnesses: p.harnesses,
230
+ primaryHarness: p.primaryHarness,
231
+ routingGoal: p.routingGoal,
232
+ n: p.n,
233
+ attempts: p.attempts ?? null,
234
+ untilClean: p.untilClean === true,
235
+ swarm: p.swarm === true,
236
+ create: p.create === true,
237
+ synthesis: p.synthesis,
238
+ paidBudget: p.paidBudget,
239
+ maxToolCalls: p.maxToolCalls ?? null,
240
+ access: p.access,
241
+ web: p.web ?? p.externalContextPolicy,
242
+ externalContextPolicy: p.externalContextPolicy ?? p.web,
243
+ model: p.model,
244
+ models: p.models,
245
+ effort: p.effort,
246
+ tests: Array.isArray(p.tests) ? p.tests : undefined,
247
+ protectedPathApprovals: Array.isArray(p.protectedPathApprovals)
248
+ ? p.protectedPathApprovals
249
+ : undefined,
250
+ specId: typeof p.specId === "string" ? p.specId : undefined,
251
+ specHash: typeof p.specHash === "string" ? p.specHash : undefined,
252
+ specPath: typeof p.specPath === "string" ? p.specPath : undefined,
253
+ inPlace,
254
+ signal: runSignal,
255
+ onRunStart,
256
+ })
257
+ .finally(() => {
258
+ if (deadlineTimer)
259
+ clearTimeout(deadlineTimer);
260
+ });
261
+ },
203
262
  });
204
- if (control) {
205
- const controlAddr = await control.start();
206
- writeFileSync(join(daemonDir(), "control-api.json"), JSON.stringify({ ...controlAddr, tokenPath: join(daemonDir(), "token") }, null, 2) + "\n", { mode: 0o600 });
207
- appendFileSync(logPath(), `[${new Date().toISOString()}] claudexor control-api listening on http://${controlAddr.host}:${controlAddr.port}\n`);
208
- }
209
- else {
210
- appendFileSync(logPath(), `[${new Date().toISOString()}] claudexor control-api disabled by CLAUDEXOR_NO_CONTROL_API=1\n`);
211
- }
212
- await server.waitForShutdown();
213
- await control?.stop();
214
- lifecycle.finalize();
215
- appendFileSync(logPath(), `[${new Date().toISOString()}] claudexord shut down\n`);
216
- process.exit(0);
217
- }
218
- // Run-start normalization has exactly one owner (control-api); the socket
219
- // runner path delegates so scope/secret/absolute-root rules cannot drift.
220
- const normalizeDaemonRunStart = (raw) => normalizeRunStartRequest(raw);
221
- function projectRootFromScopedInput(p, purpose) {
222
- if ("repoRoot" in p)
223
- throw new Error("legacy repoRoot field is not accepted; use scope.kind=project with scope.root");
224
- const scope = p["scope"];
225
- if (!scope || typeof scope !== "object" || Array.isArray(scope))
226
- throw new Error(`project scope is required for ${purpose}`);
227
- const s = scope;
228
- if (s["kind"] !== "project")
229
- throw new Error(`project scope is required for ${purpose}`);
230
- const root = typeof s["root"] === "string" ? s["root"].trim() : "";
231
- if (!root)
232
- throw new Error(`project scope root is required for ${purpose}`);
233
- if (!isAbsolute(root))
234
- throw new Error("project root must be an absolute path");
235
- return root;
236
- }
237
- /** Deliver an isolated thread's accumulated worktree diff to its project. */
238
- async function applyThreadDiff(threads, id, opts) {
239
- const thread = threads.getThread(id);
240
- if (!thread)
241
- throw Object.assign(new Error(`no such thread: ${id}`), { status: 404 });
242
- const ws = thread.workspace;
243
- if (ws.mode !== "isolated" || !ws.worktree_path || !thread.repo) {
244
- throw Object.assign(new Error("thread has no isolated worktree to apply (in-place threads write the project directly)"), { status: 400 });
245
- }
246
- const projectRoot = thread.repo.root;
247
- const base = ws.base_sha ?? "HEAD";
248
- const patch = await diffStaged(ws.worktree_path, base);
249
- if (!patch.trim())
250
- return { applied: false, status: "empty", headMoved: false, detail: "no changes to apply" };
251
- if (containsSecretLikeToken(patch))
252
- return {
253
- applied: false,
254
- status: "rejected",
255
- headMoved: false,
256
- detail: "patch contains a secret-like token; refusing apply",
257
- };
258
- // Best-effort: did the PROJECT advance past where this thread branched? (a
259
- // warning, not a blocker — git apply --3way still merges or fails loudly.)
260
- let headMoved = false;
261
- try {
262
- const head = (await git(projectRoot, ["rev-parse", "HEAD"])).stdout.trim();
263
- const mb = (await git(projectRoot, ["merge-base", "HEAD", base])).stdout.trim();
264
- headMoved = mb !== "" && head !== "" && mb !== head;
265
- }
266
- catch {
267
- /* best-effort */
268
- }
269
- const mode = (["apply", "branch", "commit", "pr"].includes(opts.mode) ? opts.mode : "apply");
270
- const delivered = await deliver(projectRoot, patch, {
271
- mode,
272
- branch: opts.branch,
273
- message: opts.message,
274
- });
275
- if (delivered.applied) {
276
- // Re-base the thread on the new project state so the next apply diffs only new work.
277
- threads.setThreadWorktree(id, ws.worktree_path, await snapshotTree(ws.worktree_path));
278
- }
279
- const status = !delivered.applied
280
- ? "conflict"
281
- : mode === "branch"
282
- ? "branched"
283
- : mode === "commit"
284
- ? "committed"
285
- : mode === "pr"
286
- ? "pr_opened"
287
- : "applied";
288
- return { applied: delivered.applied, status, headMoved, detail: delivered.detail ?? null };
289
- }
290
- function controlServices(interactions, threads) {
291
- const secretStore = new SecretStore();
292
- const setupJobs = createSetupJobManager();
293
- mkdirSync(NO_PROJECT_ROOT, { recursive: true, mode: 0o700 });
294
- return {
295
- createThread: async (input) => threads.createThread((input ?? {})),
296
- listThreads: async () => ({ threads: threads.listThreads() }),
297
- threadDetail: async (id) => {
298
- const thread = threads.getThread(id);
299
- if (!thread)
300
- throw Object.assign(new Error(`no such thread: ${id}`), { status: 404 });
301
- return {
302
- thread: thread,
303
- sessions: threads.sessionsForThread(id),
304
- turns: threads.turnsFor(id),
305
- };
306
- },
307
- createThreadTurn: async (id, prompt, opts) => threads.createTurn(id, prompt, {
308
- kind: opts.kind,
309
- parentRunId: opts.parentRunId,
310
- planRunId: opts.planRunId,
311
- attachments: resolveAttachments(opts.attachments),
312
- }),
313
- updateThread: async (id, patch) => threads.updateThread(id, {
314
- title: patch.title,
315
- state: patch.state,
316
- primaryHarness: patch.primaryHarness,
317
- eligibleHarnesses: patch.eligibleHarnesses,
318
- }),
319
- applyThread: async (id, opts) => applyThreadDiff(threads, id, opts),
320
- setTurnEnqueueError: (turnId, message, code, retryable) => threads.setTurnEnqueueError(turnId, message, code, retryable ?? true),
321
- // User-level trust surface (narrow by design): list per-repo trust files
322
- // and grant/revoke ONE flag — the same file/writer `claudexor trust` owns.
323
- listTrust: listTrustService,
324
- updateTrust: updateTrustService,
325
- pendingInteractions: (runId) => interactions.pendingForRun(runId),
326
- answerInteraction: (runId, interactionId, answers) => interactions.answer(runId, interactionId, answers),
327
- harnesses: async () => {
328
- const statuses = await buildGateway({ includeFakes: false }).statusAll({
329
- cwd: NO_PROJECT_ROOT,
330
- });
331
- // The doctor's configured-model truth check rides the status DTO
332
- // so the UI renders the same honesty the CLI prints (never a green
333
- // harness with a doomed configured model).
334
- const cfg = loadConfig(NO_PROJECT_ROOT);
335
- return {
336
- harnesses: await Promise.all(statuses.map(async (s) => {
337
- const configured = cfg.global.harnesses[s.id]?.default_model ?? null;
338
- if (!configured)
339
- return { ...s, configuredModel: null, configuredModelCheck: null };
340
- const truth = await harnessModels(s.id, NO_PROJECT_ROOT, true);
341
- const check = validateModel(configured, truth.models.map((m) => m.id), truth.source === "api" ? "api" : "manifest");
342
- return { ...s, configuredModel: configured, configuredModelCheck: check };
343
- })),
344
- };
345
- },
346
- harnessModels: async (input) => harnessModels(input.harnessId, NO_PROJECT_ROOT),
347
- // Derived catalog for external agents; same composer the CLI verb and the
348
- // MCP tool use, so all three surfaces answer identically.
349
- agentCapabilities: async () => buildAgentCapabilityCatalog(),
350
- createSetupJob: async (input) => setupJobs.create(input),
351
- listSetupJobs: async () => ({ jobs: setupJobs.list() }),
352
- setupJobStatus: async (input) => setupJobs.status(input),
353
- cancelSetupJob: async (input) => setupJobs.cancel(input),
354
- confirmSetupJob: async (input) => setupJobs.confirm(input),
355
- settings: async () => {
356
- const cfg = loadConfig(NO_PROJECT_ROOT);
357
- return {
358
- sources: cfg.sources,
359
- defaultPortfolio: cfg.global.default_portfolio,
360
- interactionTimeoutMs: cfg.global.interaction_timeout_ms,
361
- routing: {
362
- defaultPolicy: cfg.global.routing.default_policy,
363
- primaryHarness: cfg.global.routing.primary_harness,
364
- eligibleHarnesses: cfg.global.routing.eligible_harnesses,
365
- envInheritance: cfg.global.routing.env_inheritance,
366
- authPreference: cfg.global.routing.auth_preference,
367
- },
368
- budget: {
369
- maxUsdPerRun: cfg.global.budget.max_usd_per_run,
370
- },
371
- runtime: {
372
- reviewerTimeoutMs: cfg.global.runtime.reviewer_timeout_ms,
373
- harnessInactivityTimeoutMs: cfg.global.runtime.harness_inactivity_timeout_ms,
374
- transientRetry: {
375
- maxRetries: cfg.global.runtime.transient_retry.max_retries,
376
- initialDelayMs: cfg.global.runtime.transient_retry.initial_delay_ms,
377
- maxDelayMs: cfg.global.runtime.transient_retry.max_delay_ms,
378
- },
379
- },
380
- harnesses: Object.fromEntries(Object.entries(cfg.global.harnesses).map(([id, h]) => [
381
- id,
382
- {
383
- enabled: h.enabled,
384
- defaultModel: h.default_model,
385
- effort: h.effort,
386
- maxTurns: h.max_turns,
387
- maxRounds: h.max_rounds,
388
- maxUsd: h.max_usd,
389
- toolsAllow: h.tools_allow,
390
- toolsDeny: h.tools_deny,
391
- fallbackModel: h.fallback_model,
392
- web: h.web,
393
- authPreference: h.auth_preference,
394
- },
395
- ])),
396
- };
397
- },
398
- updateSettings: async (patch) => {
399
- // FAIL LOUDLY on malformed patches: a typo'd field name or bad enum must
400
- // surface as a 4xx, never be silently dropped.
401
- const p = ControlSettingsUpdateRequest.parse(patch ?? {});
402
- await assertSettingsPatchValid(p);
403
- const nullableName = (value, current) => {
404
- if (value === undefined)
405
- return current;
406
- if (value === null)
407
- return null;
408
- return value;
409
- };
410
- const updated = updateGlobalConfig((cfg) => ({
411
- ...cfg,
412
- default_portfolio: p.defaultPortfolio ?? cfg.default_portfolio,
413
- interaction_timeout_ms: p.interactionTimeoutMs ?? cfg.interaction_timeout_ms,
414
- routing: {
415
- ...cfg.routing,
416
- primary_harness: nullableName(p.primaryHarness, cfg.routing.primary_harness),
417
- default_policy: p.routingPolicy ?? cfg.routing.default_policy,
418
- env_inheritance: p.envInheritance ?? cfg.routing.env_inheritance,
419
- eligible_harnesses: p.eligibleHarnesses ?? cfg.routing.eligible_harnesses,
420
- auth_preference: p.authPreference ?? cfg.routing.auth_preference,
421
- },
422
- budget: {
423
- ...cfg.budget,
424
- max_usd_per_run: p.clearMaxUsdPerRun === true ? null : (p.maxUsdPerRun ?? cfg.budget.max_usd_per_run),
263
+ const authReadiness = new AuthReadinessService(buildGateway({ includeFakes: false }), {
264
+ cwd: NO_PROJECT_ROOT,
265
+ });
266
+ const setupBinding = new SetupLifecycleBinding(setupStoreSlot, (store) => createSetupJobManager({
267
+ rootDir: daemonDir(),
268
+ store,
269
+ onCredentialStateMayHaveChanged: (harness) => authReadiness.invalidate(harness),
270
+ }));
271
+ let control = null;
272
+ shutdownRuntime = new DaemonRuntimeShutdown({
273
+ daemon: server,
274
+ setup: setupBinding,
275
+ control: () => control,
276
+ journal: {
277
+ close: () => {
278
+ if (quotaPollTimer)
279
+ clearInterval(quotaPollTimer);
280
+ threads.close();
281
+ journalManager.close();
425
282
  },
426
- harnesses: applyHarnessSettingsPatches(cfg.harnesses, p.harnesses),
427
- }));
428
- // Routing/auth settings change harness readiness semantics: drop the
429
- // doctor TTL cache so the next /harnesses reflects the new truth.
430
- invalidateDoctorCache();
431
- return updated;
432
- },
433
- listSecrets: async () => ({
434
- backend: secretStore.resolvedBackend(),
435
- secrets: secretStore.list(),
436
- }),
437
- setSecret: async (input) => {
438
- const p = (input ?? {});
439
- const name = typeof p["name"] === "string" ? p["name"] : "";
440
- const value = typeof p["value"] === "string" ? p["value"] : "";
441
- if (!name || !value)
442
- throw new Error("name and value are required");
443
- const backend = secretStore.set(name, value);
444
- // A new key changes auth readiness immediately: drop the doctor TTL cache.
445
- invalidateDoctorCache();
446
- // Keychain->file degradation is disclosed, not silent (UI shows it).
447
- return {
448
- name,
449
- backend,
450
- stored: true,
451
- ...(secretStore.lastFallbackReason ? { warning: secretStore.lastFallbackReason } : {}),
452
- };
453
- },
454
- deleteSecret: async (name) => {
455
- secretStore.delete(name);
456
- // A removed key changes auth readiness immediately: drop the doctor cache.
457
- invalidateDoctorCache();
458
- return { name, deleted: true };
459
- },
460
- specQuestions: async (input) => {
461
- const p = (input ?? {});
462
- const prompt = typeof p["prompt"] === "string" ? p["prompt"] : "";
463
- if (!prompt.trim())
464
- throw new Error("prompt is required");
465
- const repoRoot = projectRootFromScopedInput(p, "spec questions");
466
- // Drive an interactive, CHOICE-BASED interview: the read-only grounding plan
467
- // must end with a structured "## Open Questions" section that
468
- // extractQuestionsFromPlan parses into single/multi/text questions with options.
469
- // The instruction + its parser are a co-located contract pair in spec.ts.
470
- // Prior tiers' answers: carried so each round goes DEEPER (adaptive interview).
471
- const priorDecisions = Array.isArray(p["priorDecisions"])
472
- ? p["priorDecisions"].filter((d) => !!d &&
473
- typeof d === "object" &&
474
- typeof d.question === "string" &&
475
- typeof d.answer === "string")
476
- : [];
477
- const plan = await new Orchestrator({ registry: buildRegistry() }).run({
478
- repoRoot,
479
- prompt: buildGroundingPrompt(prompt, priorDecisions),
480
- mode: "plan",
481
- harnesses: Array.isArray(p["harnesses"])
482
- ? p["harnesses"].filter((x) => typeof x === "string")
483
- : undefined,
484
- access: "readonly",
283
+ },
284
+ log: (message) => logLine(logPath(), message),
285
+ });
286
+ // The daemon owns its services whether or not the HTTP surface is up —
287
+ // the startup retention pass below consumes them directly.
288
+ const selfClient = new DaemonClient(socketPath, token);
289
+ const services = controlServices(interactions, () => projectStoreSlot.current(), threads, setupBinding, journalManager, authReadiness, resources, () => quotaStoreSlot.current(), () => selfClient.list());
290
+ control =
291
+ process.env.CLAUDEXOR_NO_CONTROL_API === "1"
292
+ ? null
293
+ : new DaemonControlApiServer({
294
+ token,
295
+ daemon: new DaemonClient(socketPath, token),
296
+ port: Number(process.env.CLAUDEXOR_CONTROL_PORT ?? 0),
297
+ bus,
298
+ services,
299
+ });
300
+ lifecycle = armDaemonLifecycle({
301
+ daemonDir: daemonDir(),
302
+ logPath: logPath(),
303
+ beginShutdown: (reason) => shutdownRuntime.beginShutdown(reason),
304
+ });
305
+ await setupBinding.start();
306
+ if (!shutdownRuntime.requested())
307
+ await server.start();
308
+ if (!shutdownRuntime.requested()) {
309
+ appendFileSync(logPath(), `[${new Date().toISOString()}] claudexord listening on ${socketPath}\n`);
310
+ }
311
+ if (control && !shutdownRuntime.requested()) {
312
+ const controlAddr = await control.start();
313
+ if (!shutdownRuntime.requested()) {
314
+ writeFileSync(join(daemonDir(), "control-api.json"), `${JSON.stringify({ ...controlAddr, tokenPath: join(daemonDir(), "token") }, null, 2)}\n`, { mode: 0o600 });
315
+ appendFileSync(logPath(), `[${new Date().toISOString()}] claudexor control-api listening on http://${controlAddr.host}:${controlAddr.port}\n`);
316
+ }
317
+ }
318
+ else if (!control && !shutdownRuntime.requested()) {
319
+ appendFileSync(logPath(), `[${new Date().toISOString()}] claudexor control-api disabled by CLAUDEXOR_NO_CONTROL_API=1\n`);
320
+ }
321
+ if (!shutdownRuntime.requested()) {
322
+ scheduleStartupRetention(services.runRetention, {
323
+ logPath: logPath(),
324
+ shuttingDown: () => shutdownRuntime.requested(),
485
325
  });
486
- const planText = readTextSafe(join(plan.runDir, "final", "plan.md")) ?? plan.summary;
487
- return {
488
- planRunId: plan.runId,
489
- planDir: plan.runDir,
490
- questions: extractQuestionsFromPlan(planText),
491
- };
492
- },
493
- specFreeze: async (input) => {
494
- const p = (input ?? {});
495
- const prompt = typeof p["prompt"] === "string" ? p["prompt"] : "";
496
- const planDir = typeof p["planDir"] === "string" ? p["planDir"] : "";
497
- const plan = typeof p["plan"] === "string"
498
- ? p["plan"]
499
- : (readTextSafe(join(planDir, "final", "plan.md")) ?? "");
500
- if (!prompt.trim() || !plan.trim())
501
- throw new Error("prompt and plan/planDir are required");
502
- const repoRoot = projectRootFromScopedInput(p, "spec freeze");
503
- // Forward the full SpecAnswersFile shape — not just answers — so a client that
504
- // edited the draft's summary / criteria / non-goals / gates doesn't have them
505
- // silently dropped from the frozen SpecPack. Fail LOUDLY on a malformed field
506
- // (e.g. tests:[123]) rather than silently filtering it.
507
- const strArr = (v, field) => {
508
- if (v === undefined)
509
- return undefined;
510
- if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) {
511
- throw new Error(`spec freeze: "${field}" must be an array of strings`);
326
+ }
327
+ await shutdownRuntime.wait();
328
+ lifecycle.finalize();
329
+ appendFileSync(logPath(), `[${new Date().toISOString()}] claudexord shut down\n`);
330
+ }
331
+ catch (error) {
332
+ try {
333
+ appendFileSync(logPath(), `[${new Date().toISOString()}] daemon lifecycle FAILED: ${redactSecrets(error instanceof Error ? `${error.name}: ${error.message}` : String(error))}\n`);
334
+ }
335
+ catch {
336
+ /* preserve the lifecycle failure */
337
+ }
338
+ if (shutdownRuntime) {
339
+ try {
340
+ await shutdownRuntime.beginShutdown("startup failure");
341
+ lifecycle?.finalize();
342
+ }
343
+ catch (shutdownError) {
344
+ try {
345
+ appendFileSync(logPath(), `[${new Date().toISOString()}] shutdown FAILED: ${redactSecrets(shutdownError instanceof Error ? shutdownError.message : String(shutdownError))}\n`);
512
346
  }
513
- return v;
514
- };
515
- const strOpt = (v, field) => {
516
- if (v === undefined)
517
- return undefined;
518
- if (typeof v !== "string")
519
- throw new Error(`spec freeze: "${field}" must be a string`);
520
- return v;
521
- };
522
- // Schema-parse the answers at the wire boundary (Bible §3): a malformed item
523
- // (missing/typed question_id, bad option_ids) fails loudly with a typed error,
524
- // rather than slipping through an `as never[]` cast. Absent => no answers.
525
- const answers = p["answers"] === undefined ? [] : InterviewAnswer.array().parse(p["answers"]);
526
- // Multi-tier interview: prior-tier decisions are folded into decided_tradeoffs
527
- // so the frozen SpecPack carries EVERY tier, not just the last one the client
528
- // sent as `answers` (the v0.13 freeze-drops-prior-tiers bug, #8/#9).
529
- const priorDecisions = Array.isArray(p["priorDecisions"])
530
- ? p["priorDecisions"].filter((d) => !!d &&
531
- typeof d.question === "string" &&
532
- typeof d.answer === "string")
533
- : [];
534
- const priorLines = priorDecisions.map((d) => `Interview (prior tier) — ${d.question} → ${d.answer}`);
535
- const explicitTradeoffs = strArr(p["decided_tradeoffs"], "decided_tradeoffs") ?? [];
536
- const mergedTradeoffs = [...priorLines, ...explicitTradeoffs];
537
- const spec = await freezeSpecFromGrounding(prompt, plan, {
538
- answers,
539
- summary: strOpt(p["summary"], "summary"),
540
- success_criteria: strArr(p["success_criteria"], "success_criteria"),
541
- non_goals: strArr(p["non_goals"], "non_goals"),
542
- forbidden_approaches: strArr(p["forbidden_approaches"], "forbidden_approaches"),
543
- // undefined-when-empty keeps single-tier behavior byte-identical.
544
- decided_tradeoffs: mergedTradeoffs.length ? mergedTradeoffs : undefined,
545
- tests: strArr(p["tests"], "tests"),
546
- });
547
- const persisted = persistSpec(repoRoot, spec, plan);
548
- // specPath = the frozen SpecPack file an Implement run reads (a bare specId
549
- // does not load content). Single producer; the layout matches persistSpec.
550
- return {
551
- specId: spec.id,
552
- specDir: persisted.specDir,
553
- specPath: join(persisted.specDir, "spec.json"),
554
- specHash: persisted.specHash,
555
- changes: persisted.changes,
556
- };
557
- },
558
- };
347
+ catch {
348
+ /* preserve the shutdown failure */
349
+ }
350
+ releaseWriterLease = false;
351
+ throw new AggregateError([error, shutdownError], "claudexord failed and could not complete shutdown");
352
+ }
353
+ }
354
+ throw error;
355
+ }
356
+ finally {
357
+ if (quotaPollTimer)
358
+ clearInterval(quotaPollTimer);
359
+ if (releaseWriterLease)
360
+ writerLease.release();
361
+ }
559
362
  }
560
363
  main().catch((err) => {
561
364
  process.stderr.write(`claudexord: ${err instanceof Error ? err.message : String(err)}\n`);
562
- process.exit(1);
365
+ process.exitCode = 1;
563
366
  });
564
367
  //# sourceMappingURL=claudexord.js.map