@ordewell/web 0.10.12 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,9 @@
4
4
  var hono = require('hono');
5
5
  var child_process = require('child_process');
6
6
  var crypto = require('crypto');
7
+ var path = require('path');
7
8
  var core = require('@ordewell/core');
8
9
  var fs = require('fs');
9
- var path = require('path');
10
10
  var ws = require('ws');
11
11
  var http = require('http');
12
12
 
@@ -28,8 +28,8 @@ function _interopNamespace(e) {
28
28
  return Object.freeze(n);
29
29
  }
30
30
 
31
- var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
32
31
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
32
+ var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
33
33
 
34
34
  var WebConfig = class extends core.BaseConfig {
35
35
  _enabledRunnersOverride;
@@ -144,16 +144,20 @@ function scanWorkspaces() {
144
144
  });
145
145
  }
146
146
  var MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
147
+ var SEALED_OUTPUT_GRACE_MS = 1e3;
147
148
  function run(file, args2, opts) {
148
- return new Promise((resolve2) => {
149
+ return new Promise((resolve3) => {
149
150
  try {
150
- child_process.execFile(file, args2, {
151
+ const child = child_process.execFile(file, args2, {
151
152
  cwd: opts.cwd,
152
153
  timeout: opts.timeout,
153
154
  maxBuffer: MAX_OUTPUT_BYTES,
154
155
  encoding: "utf8",
155
156
  shell: opts.shell,
156
157
  env: opts.env,
158
+ // A sealed run's wall-time limit is a kill, not a request it may trap
159
+ // (ADR-0036 S1); the sandbox takes the processes it started down with it.
160
+ ...opts.sealed ? { killSignal: "SIGKILL" } : {},
157
161
  // Node kills the child when this aborts, and calls back with an
158
162
  // ABORT_ERR — which lands on the `code: 1` branch below, i.e. a
159
163
  // failed tool call, exactly like any other command that did not
@@ -162,10 +166,14 @@ function run(file, args2, opts) {
162
166
  signal: opts.signal
163
167
  }, (err, stdout, stderr) => {
164
168
  const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
165
- resolve2({ stdout: stdout ?? "", stderr: stderr ?? "", code });
169
+ resolve3({ stdout: stdout ?? "", stderr: stderr ?? "", code, timedOut: err?.killed === true });
166
170
  });
171
+ if (opts.sealed) child.once("exit", () => setTimeout(() => {
172
+ child.stdout?.destroy();
173
+ child.stderr?.destroy();
174
+ }, SEALED_OUTPUT_GRACE_MS).unref());
167
175
  } catch (err) {
168
- resolve2({ stdout: "", stderr: err.message, code: null });
176
+ resolve3({ stdout: "", stderr: err.message, code: null, timedOut: false });
169
177
  }
170
178
  });
171
179
  }
@@ -321,16 +329,16 @@ var PoolFileSystem = class extends core.BaseFileSystem {
321
329
  return { success: false, output: `Error listing ${absPath}: ${err.message}`, truncated: false };
322
330
  }
323
331
  }
324
- async execBashImpl(command, signal) {
325
- const { file, args: args2 } = this.researchShell;
326
- const result = file === null ? await run(command, [], { cwd: this.workspaceRoot, timeout: BASH_TIMEOUT_MS, shell: true, signal }) : await run(file, [...args2, command], { cwd: this.workspaceRoot, timeout: BASH_TIMEOUT_MS, signal });
332
+ async execBashImpl(command, shell, signal, cwd) {
333
+ const { file, args: args2, env, wallTimeMs } = shell;
334
+ const result = file === null ? await run(command, [], { cwd, timeout: BASH_TIMEOUT_MS, shell: true, env, signal }) : await run(file, [...args2, command], { cwd, timeout: wallTimeMs ?? BASH_TIMEOUT_MS, env, signal, sealed: wallTimeMs !== void 0 });
327
335
  const out = (result.stdout || "").trim();
328
336
  const err = (result.stderr || "").trim();
329
337
  if (signal?.aborted) return { success: false, output: core.STOPPED_TOOL_RESULT, truncated: false };
330
338
  if (result.code !== 0) {
331
339
  const detail = err || out || `exited with code ${result.code}`;
332
340
  return { success: false, output: `Command exited ${result.code}:
333
- ${detail.slice(0, 2e4)}`, truncated: detail.length > 2e4 };
341
+ ${detail.slice(0, 2e4)}`, truncated: detail.length > 2e4, ...result.timedOut ? { timedOut: true } : {} };
334
342
  }
335
343
  const combined = err ? `${out}
336
344
  [stderr]
@@ -381,6 +389,12 @@ var PoolAwareRunner = class {
381
389
 
382
390
  // server/pool/orchestratorPool.ts
383
391
  var OrchestratorPool = class {
392
+ // A daemon serves several workspaces; login selection must never follow
393
+ // whichever session was opened most recently (ADR-0051).
394
+ sessionWorkspaces = /* @__PURE__ */ new Map();
395
+ accountLimits = /* @__PURE__ */ new Map();
396
+ accountLimitCollectors;
397
+ limitsRevision = 0;
384
398
  sessions = /* @__PURE__ */ new Map();
385
399
  clients = /* @__PURE__ */ new Map();
386
400
  /**
@@ -394,6 +408,13 @@ var OrchestratorPool = class {
394
408
  registry = new core.RunnerRegistry();
395
409
  modelResolver;
396
410
  runnerInstallation;
411
+ /**
412
+ * A host that supplied its own probe (a test, or a surface that owns one).
413
+ * Everything else asks {@link installationFor}, which keys a real probe by
414
+ * the workspace whose account it is judging (ADR-0016).
415
+ */
416
+ suppliedRunnerInstallation;
417
+ workspaceInstallations = /* @__PURE__ */ new Map();
397
418
  cachedProviderLists;
398
419
  settingsService = new core.SettingsService();
399
420
  plannerModelMemory = new core.PlannerModelMemory(this.settingsService);
@@ -406,13 +427,31 @@ var OrchestratorPool = class {
406
427
  runner;
407
428
  /** One probe per daemon, asked again whenever the runner list is refreshed (ADR-0033). */
408
429
  codexSandbox;
430
+ leaseHost;
431
+ isolationPreview;
409
432
  constructor(deps = {}) {
410
433
  const pluginNotice = core.removedPluginNotice();
411
434
  if (pluginNotice) console.warn(pluginNotice);
412
435
  this.runner = deps.runner ?? new core.StructuredRunner();
413
- this.runnerInstallation = deps.runnerInstallation ?? new core.RunnerInstallation(this.registry);
436
+ this.suppliedRunnerInstallation = deps.runnerInstallation;
437
+ this.runnerInstallation = deps.runnerInstallation ?? new core.RunnerInstallation(this.registry, void 0, void 0, {
438
+ env: () => core.workspaceEnvOf(process.cwd()),
439
+ workspace: () => process.cwd()
440
+ });
414
441
  this.modelResolver = deps.modelResolver ?? new core.ModelResolver(this.registry, new WebConfig());
415
442
  this.codexSandbox = deps.codexSandbox ?? new core.CodexSandboxAvailability(this.registry, { spawn: child_process.spawn, fetch: globalThis.fetch }, { cwd: process.cwd(), env: process.env });
443
+ this.leaseHost = deps.leaseHost;
444
+ this.isolationPreview = deps.isolationPreview ?? core.createIsolationPreview({ config: new WebConfig() });
445
+ this.accountLimitCollectors = deps.accountLimitCollectors ?? ((workspace) => {
446
+ const workspaceRoot = () => workspace;
447
+ const isInstalled = (runner) => async () => (await this.runnerInstallation.filterInstalled([runner])).includes(runner);
448
+ const server2 = this.registry.getManifest("codex")?.modelDiscovery.appServer;
449
+ return [
450
+ core.claudeAccountLimitCollector({ workspaceRoot, isInstalled: isInstalled("claude-code") }),
451
+ core.codexAccountLimitCollector({ workspaceRoot, isInstalled: isInstalled("codex"), command: server2?.command, args: server2?.args }),
452
+ core.openCodeGoAccountLimitCollector({ workspaceRoot, isInstalled: isInstalled("opencode") })
453
+ ];
454
+ });
416
455
  }
417
456
  /**
418
457
  * Not awaited: planning must start at once, and the daemon already warms the
@@ -431,6 +470,15 @@ var OrchestratorPool = class {
431
470
  detectInstalledPlanner() {
432
471
  return core.detectInstalledPlanner(this.runnerInstallation);
433
472
  }
473
+ /**
474
+ * A read-only look at the repositories and branches a run would isolate in
475
+ * `workspace`. Free of side effects on purpose: a first screen may ask before
476
+ * any plan or run exists, and asking must not create one.
477
+ */
478
+ previewIsolation(workspace) {
479
+ core.assertWorkspaceExists(workspace);
480
+ return this.isolationPreview.preview(workspace);
481
+ }
434
482
  /**
435
483
  * A new session's first planner turn. It waits for that probe at the
436
484
  * session's own planner gate, where a reply sent meanwhile queues behind
@@ -448,12 +496,59 @@ var OrchestratorPool = class {
448
496
  subscribe(sessionId, ws) {
449
497
  if (!this.clients.has(sessionId)) this.clients.set(sessionId, /* @__PURE__ */ new Set());
450
498
  this.clients.get(sessionId).add(ws);
499
+ this.connectAccountLimits(sessionId, ws);
451
500
  }
452
501
  unsubscribe(sessionId, ws) {
453
502
  this.clients.get(sessionId)?.delete(ws);
454
503
  if (this.clients.get(sessionId)?.size === 0) this.clients.delete(sessionId);
504
+ this.releaseUnusedAccountLimits();
505
+ }
506
+ connectAccountLimits(sessionId, ws) {
507
+ const workspace = this.sessionWorkspaces.get(sessionId);
508
+ if (!workspace) {
509
+ this.sendAccountLimits(sessionId, ws, []);
510
+ return;
511
+ }
512
+ let monitor = this.accountLimits.get(workspace);
513
+ if (!monitor) {
514
+ monitor = new core.AccountLimitMonitor({ collectors: this.accountLimitCollectors(workspace) });
515
+ this.accountLimits.set(workspace, monitor);
516
+ monitor.subscribe((limits) => {
517
+ for (const [id, clients] of this.clients) {
518
+ if (this.sessionWorkspaces.get(id) !== workspace) continue;
519
+ for (const client of clients) this.sendAccountLimits(id, client, limits);
520
+ }
521
+ });
522
+ }
523
+ this.sendAccountLimits(sessionId, ws, monitor.snapshots());
524
+ monitor.setForeground(true);
525
+ monitor.surfaceConnected();
526
+ }
527
+ sendAccountLimits(sessionId, ws, limits) {
528
+ if (ws.readyState !== ws.OPEN) return;
529
+ ws.send(JSON.stringify({ type: "account_limits", sessionId, limits, stream: this.stream, revision: ++this.limitsRevision }));
530
+ }
531
+ releaseUnusedAccountLimits() {
532
+ for (const [workspace, monitor] of this.accountLimits) {
533
+ if ([...this.clients.keys()].some((id) => this.sessionWorkspaces.get(id) === workspace)) continue;
534
+ monitor.dispose();
535
+ this.accountLimits.delete(workspace);
536
+ }
537
+ }
538
+ registerSession(sessionId, workspace, session) {
539
+ this.sessions.set(sessionId, session);
540
+ this.sessionWorkspaces.set(sessionId, workspace);
541
+ for (const ws of this.clients.get(sessionId) ?? []) this.connectAccountLimits(sessionId, ws);
455
542
  }
456
543
  broadcast(sessionId, msg) {
544
+ const workspace = this.sessionWorkspaces.get(sessionId);
545
+ const monitor = workspace ? this.accountLimits.get(workspace) : void 0;
546
+ if (msg.type === "planner_usage") monitor?.invalidate();
547
+ else if (msg.type === "task_log" && msg.events.some((event) => event.type === "turn_end")) {
548
+ const task = core.flattenTasks(this.sessions.get(sessionId)?.planTasks ?? []).find((task2) => task2.id === msg.taskId);
549
+ const runner = task && core.assignedRunnerOf(task);
550
+ if (runner) monitor?.invalidate(runner);
551
+ }
457
552
  const seq = (this.sequence.get(sessionId) ?? 0) + 1;
458
553
  this.sequence.set(sessionId, seq);
459
554
  const payload = JSON.stringify({ ...msg, stream: this.stream, seq });
@@ -495,6 +590,14 @@ var OrchestratorPool = class {
495
590
  runSnapshot(sessionId) {
496
591
  return this.sessions.get(sessionId)?.runSnapshot() ?? null;
497
592
  }
593
+ /** The planner turn answering in a session this process holds, if one is. */
594
+ plannerTurnId(sessionId) {
595
+ return this.sessions.get(sessionId)?.plannerTurnId;
596
+ }
597
+ /** The planner compaction running in a session this process holds, as its start was announced; it has no turn to name. */
598
+ plannerCompaction(sessionId) {
599
+ return this.sessions.get(sessionId)?.plannerCompaction();
600
+ }
498
601
  /** The planner's approval requests still waiting, as they were announced. */
499
602
  announcedApprovals(sessionId) {
500
603
  return this.sessions.get(sessionId)?.announcedApprovals() ?? [];
@@ -536,7 +639,8 @@ var OrchestratorPool = class {
536
639
  onNotice: (notice) => this.broadcast(sessionId, notice),
537
640
  modelResolver: this.modelResolver,
538
641
  settings: () => this.runtimeSettings(),
539
- sessionId
642
+ sessionId,
643
+ ...this.leaseHost ? { leaseHost: this.leaseHost } : {}
540
644
  });
541
645
  }
542
646
  /**
@@ -575,9 +679,10 @@ var OrchestratorPool = class {
575
679
  return enabled ? current.includes(id) ? current : [...current, id] : current.filter((r) => r !== id);
576
680
  });
577
681
  }
578
- getSettings() {
682
+ getSettings(sessionId) {
579
683
  const config = this.plannerConfig();
580
684
  const userSettings = this.settingsService.getAll();
685
+ const live = sessionId ? this.sessions.get(sessionId) : void 0;
581
686
  return {
582
687
  orchestratorModel: config.orchestratorModel,
583
688
  // Who plans (ADR-0009) — a vendor id, or one of the three harness
@@ -593,19 +698,21 @@ var OrchestratorPool = class {
593
698
  // The model remembered per planner backend (this task), so a surface can
594
699
  // render what's remembered without a second round-trip.
595
700
  plannerModels: userSettings.plannerModels,
596
- run: this.settingsService.getRunSettings()
701
+ // An open session shows what it was opened with; a closed or unknown id
702
+ // falls back to the file, which is what a new session starts from (ADR-0030).
703
+ run: live?.runSettings() ?? this.settingsService.getRunSettings()
597
704
  };
598
705
  }
599
706
  /**
600
707
  * Save the user's run settings, and apply them at once to `sessionId`'s run
601
- * when that session is open here (ADR-0030). Other sessions' runs keep
602
- * theirs. Throws a {@link RunSettingsError}, saving nothing, for a value
603
- * out of range.
708
+ * when that session is open here (ADR-0030). Other sessions keep the values
709
+ * they were opened with. Throws a {@link RunSettingsError}, saving nothing,
710
+ * for a value out of range.
604
711
  */
605
712
  updateRunSettings(changes, sessionId) {
606
713
  this.settingsService.updateRunSettings(changes);
607
714
  if (sessionId) this.sessions.get(sessionId)?.applyRunSettings(changes);
608
- return this.getSettings();
715
+ return this.getSettings(sessionId);
609
716
  }
610
717
  /**
611
718
  * The catalog `selectPlanner` should judge `provider`'s
@@ -629,7 +736,7 @@ var OrchestratorPool = class {
629
736
  plannerProviderInEffect() {
630
737
  return this.plannerConfig().aiProvider;
631
738
  }
632
- updateSettings(changes) {
739
+ updateSettings(changes, sessionId) {
633
740
  const current = this.plannerConfig().planner;
634
741
  const rawEnv = changes.env && typeof changes.env === "object" && !Array.isArray(changes.env) ? changes.env : void 0;
635
742
  const admission = rawEnv ? core.admitSettingsEnv(rawEnv) : void 0;
@@ -675,13 +782,14 @@ var OrchestratorPool = class {
675
782
  }
676
783
  }
677
784
  }
785
+ if (envChanges) for (const monitor of this.accountLimits.values()) monitor.refresh();
678
786
  if (selection?.remember) {
679
787
  const { provider, model, effort } = selection.remember;
680
788
  const switched = provider !== current.provider;
681
789
  this.plannerModelMemory.remember(switched ? provider : this.plannerProviderInEffect(), model, effort);
682
790
  }
683
791
  const rejectedEnvKeys = admission?.rejected ?? [];
684
- const settings = this.getSettings();
792
+ const settings = this.getSettings(sessionId);
685
793
  if (rejectedEnvKeys.length > 0) settings.rejectedEnvKeys = rejectedEnvKeys;
686
794
  if (selection?.recall) settings.switchRecall = selection.recall;
687
795
  return settings;
@@ -742,40 +850,89 @@ var OrchestratorPool = class {
742
850
  const config = this.plannerConfig();
743
851
  return { enabledRunners: config.enabledRunners, orchestratorModel: config.orchestratorModel };
744
852
  }
853
+ /**
854
+ * The probe that judges a login for `workspace`, cached so its install and
855
+ * auth verdicts survive between runner-list refreshes. Without a workspace
856
+ * the daemon's cwd is what the request would plan with anyway.
857
+ */
858
+ installationFor(workspace) {
859
+ if (this.suppliedRunnerInstallation) return this.suppliedRunnerInstallation;
860
+ if (!workspace) return this.runnerInstallation;
861
+ const key = path.resolve(workspace);
862
+ let installation = this.workspaceInstallations.get(key);
863
+ if (!installation) {
864
+ installation = new core.RunnerInstallation(this.registry, void 0, void 0, {
865
+ env: () => core.workspaceEnvOf(key),
866
+ workspace: () => key
867
+ });
868
+ this.workspaceInstallations.set(key, installation);
869
+ }
870
+ return installation;
871
+ }
745
872
  /**
746
873
  * Runners whose CLI is actually installed on the host, with display name and
747
874
  * enabled state. Only these should be offered for selection — an uninstalled
748
- * runner can't be spawned.
875
+ * runner can't be spawned. `workspace` names whose account the auth verdict
876
+ * is about; without it the daemon's cwd stands in.
749
877
  */
750
- async getInstalledRunners() {
878
+ async getInstalledRunners(workspace) {
751
879
  await this.codexSandbox.refresh();
752
- this.runnerInstallation.refresh?.();
880
+ const installation = this.installationFor(workspace);
881
+ installation.refresh?.();
753
882
  await this.detectInstalledPlanner();
754
883
  const config = new WebConfig({ enabledRunners: this.enabledRunnerOverride() });
755
884
  const enabled = new Set(config.enabledRunners);
756
885
  const runners = this.registry.list();
757
886
  const installedIds = new Set(
758
- await this.runnerInstallation.filterInstalled(runners.map((p) => p.manifest.name))
887
+ await installation.filterInstalled(runners.map((p) => p.manifest.name))
888
+ );
889
+ const rows = await Promise.all(
890
+ runners.filter((p) => installedIds.has(p.manifest.name)).map(async (p) => {
891
+ const { usable, auth } = await installation.plannerUsability(p.manifest.name);
892
+ if (!usable) return null;
893
+ return {
894
+ id: p.manifest.name,
895
+ name: p.manifest.displayName,
896
+ enabled: enabled.has(p.manifest.name),
897
+ ...auth ? { auth } : {}
898
+ };
899
+ })
759
900
  );
760
- return runners.filter((p) => installedIds.has(p.manifest.name)).map((p) => ({
761
- id: p.manifest.name,
762
- name: p.manifest.displayName,
763
- enabled: enabled.has(p.manifest.name)
764
- }));
901
+ return rows.filter((row) => row !== null);
765
902
  }
766
903
  async generatePlan(sessionId, goal, runners, workspace, modelOverride, options) {
767
- const session = this.openPlanningSession(sessionId, workspace, modelOverride, options);
904
+ const recheck = this.recheckSignedOutPlanner(workspace, this.plannerConfig(modelOverride).aiProvider);
905
+ if (recheck) await recheck;
906
+ const session = this.openPlanningSession(sessionId, runners, workspace, modelOverride, options);
768
907
  return core.migratePlanState(await this.firstTurn(sessionId, session, () => session.generatePlan(goal, runners)));
769
908
  }
909
+ /**
910
+ * Judge the planner as the workspace's account is now, not as it was when the
911
+ * list was last read: the signed-out refusal itself offers the runner's own
912
+ * `auth login` as a way back, and that happens in another process. Only a
913
+ * recorded signed-out verdict is asked again — unknown never blocks, and a
914
+ * signed-in one is the runner's own failure to report. Null when there is
915
+ * nothing to ask, so the turn registers at once as it always did.
916
+ */
917
+ recheckSignedOutPlanner(workspace, provider) {
918
+ const runner = core.runnerForProvider(provider);
919
+ if (!runner || core.recordedRunnerAuth(runner, workspace)?.state !== "signed-out") return null;
920
+ const installation = this.installationFor(workspace);
921
+ installation.refresh?.();
922
+ return installation.plannerUsability(runner).then(() => void 0);
923
+ }
770
924
  /**
771
925
  * Build the session a new planning conversation runs in and register it in
772
926
  * place of any live one, for {@link firstTurn} to run its first turn on.
773
927
  */
774
- openPlanningSession(sessionId, workspace, modelOverride, options) {
928
+ openPlanningSession(sessionId, runners, workspace, modelOverride, options) {
775
929
  const session = this.createSessionFor(sessionId, workspace, modelOverride, options);
776
- if (core.installedPlannerAnswered()) session.assertPlannerReady();
930
+ if (core.installedPlannerAnswered()) {
931
+ session.assertPlannerReady();
932
+ session.assertRunnersEnabled(runners);
933
+ }
777
934
  this.destroy(sessionId);
778
- this.sessions.set(sessionId, session);
935
+ this.registerSession(sessionId, workspace, session);
779
936
  this.warmCodexSandbox();
780
937
  return session;
781
938
  }
@@ -786,7 +943,9 @@ var OrchestratorPool = class {
786
943
  * assistant message lives in conversationHistory).
787
944
  */
788
945
  async startPlanning(sessionId, goal, runners, workspace, modelOverride, options) {
789
- const session = this.openPlanningSession(sessionId, workspace, modelOverride, options);
946
+ const recheck = this.recheckSignedOutPlanner(workspace, this.plannerConfig(modelOverride).aiProvider);
947
+ if (recheck) await recheck;
948
+ const session = this.openPlanningSession(sessionId, runners, workspace, modelOverride, options);
790
949
  return this.firstTurn(sessionId, session, () => session.startPlanning(goal, runners));
791
950
  }
792
951
  /**
@@ -797,14 +956,38 @@ var OrchestratorPool = class {
797
956
  * it does for every turn.
798
957
  */
799
958
  async continuePlanning(sessionId, message) {
800
- return this.session(sessionId).continueConversation(message);
959
+ const session = this.session(sessionId);
960
+ const recheck = this.recheckSignedOutPlanner(session.workspaceRoot, session.plannerProvider);
961
+ if (recheck) await recheck;
962
+ return session.continueConversation(message);
801
963
  }
802
964
  /** Condense a session's conversation. The summary turn is a planner turn, so `cancelPlanning` stops it. */
803
965
  async compactConversation(sessionId) {
804
966
  const session = this.session(sessionId);
967
+ const recheck = this.recheckSignedOutPlanner(session.workspaceRoot, session.plannerProvider);
968
+ if (recheck) await recheck;
805
969
  const compaction = await session.compactConversation();
806
970
  return { ...compaction, plan: session.planState };
807
971
  }
972
+ /**
973
+ * Send now (ADR-0023): the Session stops the planner turn in flight and
974
+ * answers this message in the turn that replaces it, ahead of anything
975
+ * waiting. The recheck is the one a reply does before the gate: a login done
976
+ * with the runner's own command is seen here too.
977
+ */
978
+ async forceSendPrompt(sessionId, message) {
979
+ const session = this.session(sessionId);
980
+ const recheck = this.recheckSignedOutPlanner(session.workspaceRoot, session.plannerProvider);
981
+ if (recheck) await recheck;
982
+ return session.forceSendPrompt(message);
983
+ }
984
+ /** Retry a compaction paused on the planner's context: a planner turn, and gated as one. */
985
+ async retryPlannerCompaction(sessionId) {
986
+ const session = this.session(sessionId);
987
+ const recheck = this.recheckSignedOutPlanner(session.workspaceRoot, session.plannerProvider);
988
+ if (recheck) await recheck;
989
+ return session.retryPlannerCompaction();
990
+ }
808
991
  /**
809
992
  * Abort the planner turn in flight for a session, if any. Answers whether
810
993
  * there was one to cancel — the route reports that rather than 404ing a
@@ -828,9 +1011,14 @@ var OrchestratorPool = class {
828
1011
  const saved = core.loadSession(sessionId, workspace);
829
1012
  if (!saved) throw new core.SessionNotFoundError();
830
1013
  const session = this.createSessionFor(sessionId, workspace);
831
- session.loadPlan(saved.plan, saved.meta.goal, workspace, { sessionId: saved.meta.id });
1014
+ try {
1015
+ session.loadPlan(saved.plan, saved.meta.goal, workspace, { sessionId: saved.meta.id });
1016
+ } catch (err) {
1017
+ session.destroy();
1018
+ throw err;
1019
+ }
832
1020
  this.destroy(sessionId);
833
- this.sessions.set(sessionId, session);
1021
+ this.registerSession(sessionId, workspace, session);
834
1022
  return session.planState ?? saved.plan;
835
1023
  }
836
1024
  /**
@@ -859,9 +1047,26 @@ var OrchestratorPool = class {
859
1047
  if (!session) throw new core.SessionNotFoundError();
860
1048
  return session;
861
1049
  }
862
- destroy(sessionId) {
1050
+ /**
1051
+ * Stop a session and drop it. A replacement under the same id (re-planning,
1052
+ * adopting) keeps its subscribers: they are watching the id, and the new
1053
+ * session's broadcasts continue on the same socket. An explicit delete or
1054
+ * close is different — the surface must stop drawing a session that no
1055
+ * longer exists — so those pass `notifyClients`.
1056
+ */
1057
+ destroy(sessionId, options) {
863
1058
  this.sessions.get(sessionId)?.destroy();
864
1059
  this.sessions.delete(sessionId);
1060
+ this.sessionWorkspaces.delete(sessionId);
1061
+ this.releaseUnusedAccountLimits();
1062
+ if (!options?.notifyClients) return;
1063
+ const clients = this.clients.get(sessionId);
1064
+ if (!clients) return;
1065
+ this.broadcast(sessionId, { type: "notice", level: "info", message: "Session closed." });
1066
+ for (const ws of clients) {
1067
+ if (ws.readyState === ws.OPEN) ws.close(1001, "Session closed");
1068
+ }
1069
+ this.clients.delete(sessionId);
865
1070
  }
866
1071
  destroyAll() {
867
1072
  for (const id of this.sessions.keys()) this.destroy(id);
@@ -877,8 +1082,28 @@ function removeSession(sessionId, workspace) {
877
1082
  function stringField(value) {
878
1083
  return typeof value === "string" ? value : void 0;
879
1084
  }
1085
+ var InvalidBodyError = class extends Error {
1086
+ constructor(message) {
1087
+ super(message);
1088
+ this.name = "InvalidBodyError";
1089
+ }
1090
+ };
1091
+ async function jsonObject(c) {
1092
+ let parsed;
1093
+ try {
1094
+ parsed = await c.req.json();
1095
+ } catch {
1096
+ throw new InvalidBodyError("The request body must be valid JSON.");
1097
+ }
1098
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1099
+ throw new InvalidBodyError("The request body must be a JSON object.");
1100
+ }
1101
+ return parsed;
1102
+ }
880
1103
  function classify(err) {
1104
+ if (err instanceof InvalidBodyError) return { status: 400, code: "refused" };
881
1105
  if (err instanceof core.SessionNotFoundError) return { status: 404, code: "session_not_found" };
1106
+ if (err instanceof core.SessionHeldError) return { status: 409, code: "session_held" };
882
1107
  if (err instanceof core.NoPlanError) return { status: 400, code: "no_plan" };
883
1108
  if (err instanceof core.AlreadyExecutingError) return { status: 409, code: "already_executing" };
884
1109
  if (err instanceof core.WorkspaceNotAProjectError) return { status: 400, code: "workspace_not_a_project" };
@@ -904,7 +1129,8 @@ function failure(c, err, label, { fallback = 500, message = "Internal error" } =
904
1129
  error: err instanceof Error && err.message || message,
905
1130
  ...classified ? { code: classified.code } : {},
906
1131
  ...err instanceof core.WorkspaceNotAProjectError ? { workspace: err.workspace } : {},
907
- ...err instanceof core.PlannerNotReadyError ? { plannerNotReady: err.reason } : {}
1132
+ ...err instanceof core.PlannerNotReadyError ? { plannerNotReady: err.reason } : {},
1133
+ ...err instanceof core.SessionHeldError ? { sessionHeld: err.held } : {}
908
1134
  };
909
1135
  return c.json(body, status);
910
1136
  }
@@ -923,7 +1149,13 @@ function sessionsRoute(pool2) {
923
1149
  const saved = core.loadSessionPlanState(id, ws);
924
1150
  if (!saved) return refuse(c, 404, "Session not found", "session_not_found");
925
1151
  const run2 = pool2.runSnapshot(id);
926
- return c.json({ meta: saved.meta, plan: core.surfacePlanState(pool2.getPlanState(id) ?? saved.plan), ...run2 ? { run: run2 } : {} });
1152
+ const plannerCompaction = pool2.plannerCompaction(id);
1153
+ return c.json({
1154
+ meta: saved.meta,
1155
+ plan: core.surfacePlanState(pool2.getPlanState(id) ?? saved.plan),
1156
+ ...run2 ? { run: run2 } : {},
1157
+ ...plannerCompaction ? { plannerCompaction } : {}
1158
+ });
927
1159
  });
928
1160
  router.post("/:id/load", (c) => {
929
1161
  const ws = c.req.query("workspace") || process.cwd();
@@ -931,7 +1163,16 @@ function sessionsRoute(pool2) {
931
1163
  const id = c.req.param("id");
932
1164
  const plan = pool2.adoptSavedSession(id, ws);
933
1165
  const run2 = pool2.runSnapshot(id);
934
- return c.json({ ok: true, plan: core.surfacePlan(plan), goal: pool2.getGoal(id), ...run2 ? { run: run2 } : {} });
1166
+ const plannerTurnId = pool2.plannerTurnId(id);
1167
+ const plannerCompaction = pool2.plannerCompaction(id);
1168
+ return c.json({
1169
+ ok: true,
1170
+ plan: core.surfacePlan(plan),
1171
+ goal: pool2.getGoal(id),
1172
+ ...run2 ? { run: run2 } : {},
1173
+ ...plannerTurnId ? { plannerTurnId } : {},
1174
+ ...plannerCompaction ? { plannerCompaction } : {}
1175
+ });
935
1176
  } catch (err) {
936
1177
  return failure(c, err, "load session", { message: "Failed to load session" });
937
1178
  }
@@ -949,12 +1190,17 @@ function sessionsRoute(pool2) {
949
1190
  router.delete("/:id", (c) => {
950
1191
  const ws = c.req.query("workspace") || process.cwd();
951
1192
  const id = c.req.param("id");
952
- pool2.destroy(id);
953
- const ok = removeSession(id, ws);
954
- return c.json({ ok });
1193
+ const held = pool2.hasSession(id);
1194
+ pool2.destroy(id, { notifyClients: true });
1195
+ const removed = removeSession(id, ws);
1196
+ const ok = held || removed;
1197
+ return c.json({
1198
+ ok,
1199
+ ...ok ? {} : { message: `No session "${id}" was found in ${ws}.` }
1200
+ });
955
1201
  });
956
1202
  router.post("/:id/close", (c) => {
957
- pool2.destroy(c.req.param("id"));
1203
+ pool2.destroy(c.req.param("id"), { notifyClients: true });
958
1204
  return c.json({ ok: true });
959
1205
  });
960
1206
  return router;
@@ -972,7 +1218,7 @@ function plansRoute(pool2) {
972
1218
  const router = new hono.Hono();
973
1219
  router.post("/:sessionId/generate", async (c) => {
974
1220
  try {
975
- const { goal, runners, workspace, model, allowInit } = await c.req.json();
1221
+ const { goal, runners, workspace, model, allowInit } = await jsonObject(c);
976
1222
  if (!goal) return c.json({ error: "goal is required" }, 400);
977
1223
  const ws = workspace || c.req.query("workspace") || process.cwd();
978
1224
  const queryRunners = c.req.query("runners");
@@ -1012,68 +1258,19 @@ function plansRoute(pool2) {
1012
1258
  const cancelled = pool2.cancelPlanning(c.req.param("sessionId"));
1013
1259
  return c.json({ cancelled });
1014
1260
  });
1015
- router.post("/:sessionId/tasks/:taskId/resume/cancel", async (c) => {
1261
+ router.post("/:sessionId/execute-command", async (c) => {
1016
1262
  try {
1017
- const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "cancel_resume", task: c.req.param("taskId") });
1018
- return c.json({ cancelled: true, message });
1019
- } catch (err) {
1020
- return failure(c, err, "cancel resume");
1021
- }
1022
- });
1023
- for (const [segment, command] of [
1024
- ["run", (id) => ({ command: "run_task", task: id })],
1025
- ["retry", (id) => ({ command: "retry_task", task: id, from: "fresh" })],
1026
- ["cancel", (id) => ({ command: "stop_task", task: id })],
1027
- ["complete", (id) => ({ command: "mark_complete", task: id })],
1028
- ["uncomplete", (id) => ({ command: "mark_not_done", task: id })],
1029
- // The user says the limit has reset early: the task, and every other one waiting on its runner's reset, resumes in its saved session now.
1030
- ["resume/now", (id) => ({ command: "retry_task", task: id, from: "session" })],
1031
- ["interrupt", (id) => ({ command: "interrupt_task", task: id })]
1032
- ]) {
1033
- router.post(`/:sessionId/tasks/:taskId/${segment}`, async (c) => {
1034
- try {
1035
- const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), command(c.req.param("taskId")));
1036
- return c.json({ ok: true, message });
1037
- } catch (err) {
1038
- return failure(c, err, segment);
1263
+ const parsed = core.parseExecutionCommand(await jsonObject(c));
1264
+ if (!parsed.ok) return refuse(c, 400, parsed.message, "refused");
1265
+ const outcome = await pool2.session(c.req.param("sessionId")).execute(parsed.command, { kind: "user" });
1266
+ if (!outcome.ok) {
1267
+ return c.json({ error: outcome.message, code: "refused", ...outcome.payload ? { payload: outcome.payload } : {} }, 400);
1039
1268
  }
1040
- });
1041
- }
1042
- router.post("/:sessionId/tasks/:taskId/skip", async (c) => {
1043
- try {
1044
- const body = await c.req.json().catch(() => ({}));
1045
- const reason = typeof body === "object" && body !== null && "reason" in body && typeof body.reason === "string" ? body.reason : void 0;
1046
- const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "skip_task", task: c.req.param("taskId"), ...reason ? { reason } : {} });
1047
- return c.json({ ok: true, message });
1048
- } catch (err) {
1049
- return failure(c, err, "skip");
1050
- }
1051
- });
1052
- router.post("/:sessionId/tasks/:taskId/force-start", async (c) => {
1053
- try {
1054
- const { message, payload } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "force_start", task: c.req.param("taskId") });
1055
- return c.json({ ok: true, ...payload?.kind === "queued" ? { queued: payload.line } : {}, message });
1056
- } catch (err) {
1057
- return failure(c, err, "force-start");
1058
- }
1059
- });
1060
- const messageHandler = (send) => async (c) => {
1061
- try {
1062
- const body = await c.req.json().catch(() => ({}));
1063
- const text = typeof body === "object" && body !== null && "text" in body ? body.text : void 0;
1064
- if (typeof text !== "string" || !text.trim()) return c.json({ error: "text is required" }, 400);
1065
- const id = await send(pool2.session(c.req.param("sessionId")), c.req.param("taskId"), text);
1066
- return c.json({ id });
1269
+ return c.json({ ok: true, message: outcome.message, ...outcome.payload ? { payload: outcome.payload } : {} });
1067
1270
  } catch (err) {
1068
- return failure(c, err, "task edit");
1271
+ return failure(c, err, "execute-command");
1069
1272
  }
1070
- };
1071
- const sendMessage = (force) => messageHandler(async (session, taskId, text) => {
1072
- const { payload } = await runUserCommand(session, { command: "message_task", task: taskId, text, ...force ? { force } : {} });
1073
- return payload?.kind === "message" ? payload.id : "";
1074
1273
  });
1075
- router.post("/:sessionId/tasks/:taskId/messages", sendMessage(false));
1076
- router.post("/:sessionId/tasks/:taskId/messages/now", sendMessage(true));
1077
1274
  router.post("/:sessionId/tasks/:taskId/commands", async (c) => {
1078
1275
  try {
1079
1276
  const body = await c.req.json().catch(() => ({}));
@@ -1086,33 +1283,6 @@ function plansRoute(pool2) {
1086
1283
  return failure(c, err, "task edit");
1087
1284
  }
1088
1285
  });
1089
- router.post("/:sessionId/tasks/:taskId/messages/:messageId/now", async (c) => {
1090
- try {
1091
- const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "force_send_queued", task: c.req.param("taskId"), id: c.req.param("messageId") });
1092
- return c.json({ sent: true, message });
1093
- } catch (err) {
1094
- return failure(c, err, "task edit");
1095
- }
1096
- });
1097
- router.post("/:sessionId/tasks/:taskId/continue", async (c) => {
1098
- try {
1099
- const body = await c.req.json().catch(() => ({}));
1100
- const text = typeof body === "object" && body !== null && "text" in body ? body.text : void 0;
1101
- if (typeof text !== "string" || !text.trim()) return c.json({ error: "text is required" }, 400);
1102
- const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "retry_task", task: c.req.param("taskId"), from: "session", message: text });
1103
- return c.json({ ok: true, message });
1104
- } catch (err) {
1105
- return failure(c, err, "task edit");
1106
- }
1107
- });
1108
- router.delete("/:sessionId/tasks/:taskId/messages/:messageId", async (c) => {
1109
- try {
1110
- const { message } = await runUserCommand(pool2.session(c.req.param("sessionId")), { command: "remove_queued", task: c.req.param("taskId"), id: c.req.param("messageId") });
1111
- return c.json({ removed: true, message });
1112
- } catch (err) {
1113
- return failure(c, err, "task edit");
1114
- }
1115
- });
1116
1286
  for (const answer of ["approve", "reject"]) {
1117
1287
  router.post(`/:sessionId/tasks/:taskId/checkpoint/${answer}`, async (c) => {
1118
1288
  try {
@@ -1183,7 +1353,7 @@ function plansRoute(pool2) {
1183
1353
  });
1184
1354
  router.put("/:sessionId/tasks/:taskId", async (c) => {
1185
1355
  try {
1186
- const changes = await c.req.json();
1356
+ const changes = await jsonObject(c);
1187
1357
  if (changes.assignedModel === null) changes.assignedModel = void 0;
1188
1358
  const result = await pool2.session(c.req.param("sessionId")).updateTask(c.req.param("taskId"), changes);
1189
1359
  if (!result) return refuse(c, 404, "Task not found", "task_not_found");
@@ -1203,7 +1373,7 @@ function plansRoute(pool2) {
1203
1373
  });
1204
1374
  router.post("/:sessionId/tasks", async (c) => {
1205
1375
  try {
1206
- const body = await c.req.json();
1376
+ const body = await jsonObject(c);
1207
1377
  const result = await pool2.session(c.req.param("sessionId")).addTask(body);
1208
1378
  if (!result) return refuse(c, 404, "Session not found", "session_not_found");
1209
1379
  return c.json({ ok: true });
@@ -1222,29 +1392,9 @@ function plansRoute(pool2) {
1222
1392
  return failure(c, err, "review approve");
1223
1393
  }
1224
1394
  });
1225
- router.post("/:sessionId/tasks/merge", async (c) => {
1226
- try {
1227
- const { taskIds } = await c.req.json();
1228
- if (!taskIds || !Array.isArray(taskIds) || taskIds.length < 2) {
1229
- return c.json({ error: "taskIds array with at least two ids is required" }, 400);
1230
- }
1231
- const plan = await pool2.session(c.req.param("sessionId")).requestMerge(taskIds);
1232
- return c.json({ plan: core.surfacePlan(plan) });
1233
- } catch (err) {
1234
- return failure(c, err, "merge", { fallback: 400 });
1235
- }
1236
- });
1237
- router.post("/:sessionId/tasks/:taskId/split", async (c) => {
1238
- try {
1239
- const plan = await pool2.session(c.req.param("sessionId")).requestSplit(c.req.param("taskId"));
1240
- return c.json({ plan: core.surfacePlan(plan) });
1241
- } catch (err) {
1242
- return failure(c, err, "split", { fallback: 400 });
1243
- }
1244
- });
1245
1395
  router.post("/:sessionId/converse/start", async (c) => {
1246
1396
  try {
1247
- const { goal, runners, workspace, model, allowInit } = await c.req.json();
1397
+ const { goal, runners, workspace, model, allowInit } = await jsonObject(c);
1248
1398
  if (!goal) return c.json({ error: "goal is required" }, 400);
1249
1399
  const ws = workspace || c.req.query("workspace") || process.cwd();
1250
1400
  const runnerList = Array.isArray(runners) ? runners : runners ? [runners] : void 0;
@@ -1256,7 +1406,7 @@ function plansRoute(pool2) {
1256
1406
  });
1257
1407
  router.post("/:sessionId/converse/message", async (c) => {
1258
1408
  try {
1259
- const { message } = await c.req.json();
1409
+ const { message } = await jsonObject(c);
1260
1410
  if (!message) return c.json({ error: "message is required" }, 400);
1261
1411
  const plan = await pool2.continuePlanning(c.req.param("sessionId"), message);
1262
1412
  return c.json({ plan: core.surfacePlan(plan) });
@@ -1266,9 +1416,9 @@ function plansRoute(pool2) {
1266
1416
  });
1267
1417
  router.post("/:sessionId/converse/message/now", async (c) => {
1268
1418
  try {
1269
- const { message } = await c.req.json();
1419
+ const { message } = await jsonObject(c);
1270
1420
  if (!message) return c.json({ error: "message is required" }, 400);
1271
- const plan = await pool2.session(c.req.param("sessionId")).forceSendPrompt(message);
1421
+ const plan = await pool2.forceSendPrompt(c.req.param("sessionId"), message);
1272
1422
  return c.json({ plan: core.surfacePlan(plan) });
1273
1423
  } catch (err) {
1274
1424
  return failure(c, err, "conversation message");
@@ -1299,8 +1449,8 @@ function plansRoute(pool2) {
1299
1449
  });
1300
1450
  router.post("/:sessionId/conversation/rewind", async (c) => {
1301
1451
  try {
1302
- const { index } = await c.req.json();
1303
- if (!Number.isInteger(index) || index < 0) return c.json({ error: "index must be a non-negative integer" }, 400);
1452
+ const { index } = await jsonObject(c);
1453
+ if (typeof index !== "number" || !Number.isInteger(index) || index < 0) return c.json({ error: "index must be a non-negative integer" }, 400);
1304
1454
  const rewound = pool2.rewindConversation(c.req.param("sessionId"), index);
1305
1455
  return c.json({ ...rewound, plan: core.surfacePlan(rewound.plan) });
1306
1456
  } catch (err) {
@@ -1315,6 +1465,14 @@ function plansRoute(pool2) {
1315
1465
  return failure(c, err, "conversation edit");
1316
1466
  }
1317
1467
  });
1468
+ router.post("/:sessionId/conversation/compact/retry", async (c) => {
1469
+ try {
1470
+ const plan = await pool2.retryPlannerCompaction(c.req.param("sessionId"));
1471
+ return c.json({ plan: core.surfacePlan(plan) });
1472
+ } catch (err) {
1473
+ return failure(c, err, "retry compaction");
1474
+ }
1475
+ });
1318
1476
  router.get("/:sessionId/prd", (c) => {
1319
1477
  const plan = pool2.getPlan(c.req.param("sessionId"));
1320
1478
  if (!plan?.prdMarkdown) return refuse(c, 404, "No PRD found");
@@ -1325,7 +1483,7 @@ function plansRoute(pool2) {
1325
1483
  function runnersRoute(pool2) {
1326
1484
  const router = new hono.Hono();
1327
1485
  router.get("/", async (c) => {
1328
- const runners = await pool2.getInstalledRunners();
1486
+ const runners = await pool2.getInstalledRunners(c.req.query("workspace"));
1329
1487
  const state = pool2.getRunnerState();
1330
1488
  return c.json({
1331
1489
  runners,
@@ -1333,11 +1491,16 @@ function runnersRoute(pool2) {
1333
1491
  });
1334
1492
  });
1335
1493
  router.put("/:id", async (c) => {
1336
- await core.installedPlannerDetected();
1337
- const id = c.req.param("id");
1338
- const body = await c.req.json();
1339
- pool2.setRunnerEnabled(id, body.enabled);
1340
- return c.json({ ok: true });
1494
+ try {
1495
+ await core.installedPlannerDetected();
1496
+ const id = c.req.param("id");
1497
+ const { enabled } = await jsonObject(c);
1498
+ if (typeof enabled !== "boolean") return refuse(c, 400, "enabled must be true or false");
1499
+ pool2.setRunnerEnabled(id, enabled);
1500
+ return c.json({ ok: true });
1501
+ } catch (err) {
1502
+ return failure(c, err, "runner toggle", { fallback: 400 });
1503
+ }
1341
1504
  });
1342
1505
  return router;
1343
1506
  }
@@ -1349,8 +1512,18 @@ function modelsRoute(pool2) {
1349
1512
  });
1350
1513
  return router;
1351
1514
  }
1352
- function workspacesRoute() {
1515
+ function workspacesRoute(pool2) {
1353
1516
  const router = new hono.Hono();
1517
+ router.get("/isolation-preview", async (c) => {
1518
+ try {
1519
+ const workspace = c.req.query("workspace") || process.cwd();
1520
+ core.assertWorkspaceExists(workspace);
1521
+ const preview = await pool2.previewIsolation(workspace);
1522
+ return c.json({ preview });
1523
+ } catch (err) {
1524
+ return failure(c, err, "read isolation preview", { message: "Could not read the repositories" });
1525
+ }
1526
+ });
1354
1527
  router.get("/", (c) => {
1355
1528
  const workspaces = scanWorkspaces2();
1356
1529
  return c.json({ workspaces });
@@ -1399,28 +1572,33 @@ function settingsRoute(pool2) {
1399
1572
  const router = new hono.Hono();
1400
1573
  router.get("/", async (c) => {
1401
1574
  await core.installedPlannerDetected();
1402
- const settings = pool2.getSettings();
1575
+ const settings = pool2.getSettings(c.req.query("sessionId") || void 0);
1403
1576
  return c.json(settings);
1404
1577
  });
1405
1578
  router.patch("/", async (c) => {
1406
- await core.installedPlannerDetected();
1407
- const body = await c.req.json();
1408
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length === 0) {
1409
- return refuse(c, 400, "At least one setting field is required");
1410
- }
1411
- const { run: run2, sessionId, ...rest } = body;
1412
- if (run2 !== void 0) {
1413
- if (!run2 || typeof run2 !== "object" || Array.isArray(run2)) return refuse(c, 400, "run must be an object of run settings");
1414
- if (sessionId !== void 0 && typeof sessionId !== "string") return refuse(c, 400, "sessionId must be a string");
1415
- try {
1416
- pool2.updateRunSettings(run2, sessionId);
1417
- } catch (err) {
1418
- if (err instanceof core.RunSettingsError) return refuse(c, 400, err.message);
1419
- throw err;
1579
+ try {
1580
+ await core.installedPlannerDetected();
1581
+ const body = await jsonObject(c);
1582
+ if (Object.keys(body).length === 0) {
1583
+ return refuse(c, 400, "At least one setting field is required");
1584
+ }
1585
+ const { run: run2, sessionId: namedSession, ...rest } = body;
1586
+ if (namedSession !== void 0 && typeof namedSession !== "string") return refuse(c, 400, "sessionId must be a string");
1587
+ const sessionId = namedSession || void 0;
1588
+ if (run2 !== void 0) {
1589
+ if (!run2 || typeof run2 !== "object" || Array.isArray(run2)) return refuse(c, 400, "run must be an object of run settings");
1590
+ try {
1591
+ pool2.updateRunSettings(run2, sessionId);
1592
+ } catch (err) {
1593
+ if (err instanceof core.RunSettingsError) return refuse(c, 400, err.message);
1594
+ throw err;
1595
+ }
1420
1596
  }
1597
+ const result = Object.keys(rest).length > 0 ? sessionId ? pool2.updateSettings(rest, sessionId) : pool2.updateSettings(rest) : pool2.getSettings(sessionId);
1598
+ return c.json(result);
1599
+ } catch (err) {
1600
+ return failure(c, err, "settings update", { fallback: 400 });
1421
1601
  }
1422
- const result = Object.keys(rest).length > 0 ? pool2.updateSettings(rest) : pool2.getSettings();
1423
- return c.json(result);
1424
1602
  });
1425
1603
  return router;
1426
1604
  }
@@ -1464,6 +1642,8 @@ function createWsHandler(pool2) {
1464
1642
  ...pool2.streamPosition(sessionId)
1465
1643
  }));
1466
1644
  }
1645
+ const compaction = pool2.plannerCompaction(sessionId);
1646
+ if (compaction) ws.send(JSON.stringify(compaction));
1467
1647
  pool2.subscribe(sessionId, ws);
1468
1648
  });
1469
1649
  return (request, socket, head) => {
@@ -1543,13 +1723,14 @@ function createApp(pool2, admission) {
1543
1723
  if (!decision.admit) return c.json({ error: decision.reason }, decision.status);
1544
1724
  await next();
1545
1725
  });
1726
+ app.get("/api/server", (c) => c.json({ pid: process.pid, port: admission.port }));
1546
1727
  app.route("/api/sessions", sessionsRoute(pool2));
1547
1728
  app.route("/api/plans", plansRoute(pool2));
1548
1729
  app.route("/api/runners", runnersRoute(pool2));
1549
1730
  app.route("/api/models", modelsRoute(pool2));
1550
1731
  app.route("/api/settings", settingsRoute(pool2));
1551
1732
  app.route("/api/commands", commandsRoute());
1552
- app.route("/api/workspaces", workspacesRoute());
1733
+ app.route("/api/workspaces", workspacesRoute(pool2));
1553
1734
  app.route("/api/approvals", approvalsRoute(pool2));
1554
1735
  return app;
1555
1736
  }
@@ -1589,11 +1770,77 @@ ${body}`
1589
1770
  }
1590
1771
 
1591
1772
  // server/nodeAdapter.ts
1592
- function createRequestListener(app) {
1773
+ var MAX_BODY_BYTES = 10 * 1024 * 1024;
1774
+ var BodyTooLargeError = class extends Error {
1775
+ };
1776
+ function refuse2(req, res, status, message) {
1777
+ const payload = JSON.stringify({ error: message });
1778
+ res.writeHead(status, {
1779
+ "Content-Type": "application/json",
1780
+ "Content-Length": Buffer.byteLength(payload),
1781
+ // Nothing more is read from this request, so its connection cannot be reused.
1782
+ Connection: "close"
1783
+ });
1784
+ res.end(payload, () => req.destroy());
1785
+ }
1786
+ function readBody(req, max) {
1787
+ return new Promise((resolve3, reject) => {
1788
+ const chunks = [];
1789
+ let size = 0;
1790
+ const cleanup = () => {
1791
+ req.off("data", onData);
1792
+ req.off("end", onEnd);
1793
+ req.off("error", onError);
1794
+ };
1795
+ const onData = (chunk) => {
1796
+ size += chunk.length;
1797
+ if (size > max) {
1798
+ cleanup();
1799
+ req.pause();
1800
+ reject(new BodyTooLargeError());
1801
+ return;
1802
+ }
1803
+ chunks.push(chunk);
1804
+ };
1805
+ const onEnd = () => {
1806
+ cleanup();
1807
+ resolve3(chunks.length > 0 ? Buffer.concat(chunks).toString() : void 0);
1808
+ };
1809
+ const onError = (err) => {
1810
+ cleanup();
1811
+ reject(err);
1812
+ };
1813
+ req.on("data", onData);
1814
+ req.on("end", onEnd);
1815
+ req.on("error", onError);
1816
+ });
1817
+ }
1818
+ function createRequestListener(app, admission, maxBodyBytes = MAX_BODY_BYTES) {
1593
1819
  return async (req, res) => {
1594
- const bufs = [];
1595
- for await (const chunk of req) bufs.push(chunk);
1596
- const body = bufs.length > 0 ? Buffer.concat(bufs).toString() : void 0;
1820
+ const decision = decideAdmission({
1821
+ origin: req.headers.origin ?? null,
1822
+ host: req.headers.host ?? null,
1823
+ authorization: req.headers.authorization ?? null
1824
+ }, admission);
1825
+ if (!decision.admit) {
1826
+ refuse2(req, res, decision.status, decision.reason);
1827
+ return;
1828
+ }
1829
+ const declared = Number(req.headers["content-length"]);
1830
+ if (Number.isFinite(declared) && declared > maxBodyBytes) {
1831
+ refuse2(req, res, 413, `Request body must be at most ${maxBodyBytes} bytes.`);
1832
+ return;
1833
+ }
1834
+ let body;
1835
+ try {
1836
+ body = await readBody(req, maxBodyBytes);
1837
+ } catch (err) {
1838
+ if (err instanceof BodyTooLargeError) {
1839
+ refuse2(req, res, 413, `Request body must be at most ${maxBodyBytes} bytes.`);
1840
+ return;
1841
+ }
1842
+ throw err;
1843
+ }
1597
1844
  const url = `http://${req.headers.host || "localhost"}${req.url}`;
1598
1845
  const headers = new Headers();
1599
1846
  for (const [k, v] of Object.entries(req.headers)) {
@@ -1636,6 +1883,10 @@ if (portIdx !== -1 && args[portIdx + 1]) {
1636
1883
  }
1637
1884
  var watchParentIdx = args.indexOf("--watch-parent");
1638
1885
  var watchParentPid = watchParentIdx !== -1 ? parseInt(args[watchParentIdx + 1], 10) : void 0;
1886
+ var pidFileIdx = args.indexOf("--pid-file");
1887
+ var pidFile = pidFileIdx !== -1 ? args[pidFileIdx + 1] : void 0;
1888
+ var ownsPidFile = false;
1889
+ core.processLeaseHost().describe({ kind: "daemon", port, ...watchParentPid ? { ownerPid: watchParentPid } : {} });
1639
1890
  var pool = new OrchestratorPool({ runner: new core.StructuredRunner() });
1640
1891
  void pool.detectInstalledPlanner();
1641
1892
  var server = http.createServer();
@@ -1643,10 +1894,15 @@ var ownsToken = false;
1643
1894
  server.listen(port, "127.0.0.1", () => {
1644
1895
  const { token, file: tokenFile } = core.mintDaemonToken(port);
1645
1896
  ownsToken = true;
1897
+ if (pidFile) {
1898
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
1899
+ fs.writeFileSync(pidFile, String(process.pid), { mode: 384 });
1900
+ ownsPidFile = true;
1901
+ }
1646
1902
  const admission = { port, token, tokenFile };
1647
1903
  const app = createApp(pool, admission);
1648
1904
  app.get("/", (c) => c.json({ message: "Ordewell API", docs: "/api/workspaces" }));
1649
- server.on("request", createRequestListener(app));
1905
+ server.on("request", createRequestListener(app, admission));
1650
1906
  attachWsHandler(server, pool, admission);
1651
1907
  pool.getProviderModels().catch((err) => {
1652
1908
  console.error(`[web] model discovery warmup failed: ${err?.message ?? err}`);
@@ -1655,6 +1911,12 @@ server.listen(port, "127.0.0.1", () => {
1655
1911
  });
1656
1912
  function shutdown() {
1657
1913
  if (ownsToken) core.clearDaemonToken(port);
1914
+ if (ownsPidFile && pidFile) {
1915
+ try {
1916
+ fs.unlinkSync(pidFile);
1917
+ } catch {
1918
+ }
1919
+ }
1658
1920
  pool.destroyAll();
1659
1921
  server.close();
1660
1922
  process.exit(0);
@@ -1663,7 +1925,7 @@ process.on("unhandledRejection", (reason) => {
1663
1925
  console.error(`[web] unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
1664
1926
  });
1665
1927
  process.on("SIGINT", () => {
1666
- shutdown();
1928
+ if (!watchParentPid) shutdown();
1667
1929
  });
1668
1930
  process.on("SIGTERM", () => {
1669
1931
  shutdown();