codeden 0.4.4 → 0.5.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.
Files changed (31) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +1 -1
  3. package/dist/drizzle/20261110154932_notice_not_before/migration.sql +1 -0
  4. package/dist/drizzle/20261110154932_notice_not_before/snapshot.json +3484 -0
  5. package/dist/{main-CKLHi4sz.js → main-BQwSt6Az.js} +2783 -691
  6. package/dist/main-BQwSt6Az.js.map +1 -0
  7. package/dist/{main-D335_24e.js → main-Drh58zq6.js} +2 -2
  8. package/dist/{main-D335_24e.js.map → main-Drh58zq6.js.map} +1 -1
  9. package/dist/{runner-DjavcMnG.js → runner-WAbCC8k2.js} +3321 -513
  10. package/dist/runner-WAbCC8k2.js.map +1 -0
  11. package/dist/runner.js +1 -1
  12. package/dist/templates/automations/issue-triage.md +1 -1
  13. package/dist/web/assets/{core-BFbrQR07.js → core-aeJ3srYS.js} +1 -1
  14. package/dist/web/assets/engine-oniguruma-CyGcXvAw.js +1 -0
  15. package/dist/web/assets/{highlighted-body-JLMTFACH-C9Ii0196.js → highlighted-body-JLMTFACH-NhDHKomc.js} +1 -1
  16. package/dist/web/assets/{highlights-C4aNCgJw.js → highlights-af6Fohu4.js} +1 -1
  17. package/dist/web/assets/index-BlGTOYYh.css +1 -0
  18. package/dist/web/assets/{index-WLihS360.js → index-iad71iug.js} +194 -194
  19. package/dist/web/assets/langs-DEIJuibn.js +1 -0
  20. package/dist/web/assets/mermaid-WFAXDIZX-RdnNjs5C.js +1 -0
  21. package/dist/web/assets/{shiki-MNiRnTih.js → shiki-Do52T6bJ.js} +2 -2
  22. package/dist/web/assets/{stream-tokenizer-BOiamYv-.js → stream-tokenizer-DFPcXHG7.js} +1 -1
  23. package/dist/web/assets/{themes-DPD-gG-5.js → themes-B76yFk4c.js} +2 -2
  24. package/dist/web/index.html +2 -2
  25. package/package.json +1 -1
  26. package/dist/main-CKLHi4sz.js.map +0 -1
  27. package/dist/runner-DjavcMnG.js.map +0 -1
  28. package/dist/web/assets/engine-oniguruma-Dod-24Yp.js +0 -1
  29. package/dist/web/assets/index-BIBpPpjX.css +0 -1
  30. package/dist/web/assets/langs-X885YKmd.js +0 -1
  31. package/dist/web/assets/mermaid-WFAXDIZX-Dh9Bmb5R.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { n as __require, r as __toESM, t as __commonJSMin } from "./rolldown-runtime-DegdyBIC.js";
2
- import { $ as BrowserNavigateRequest, $n as InboxItemKind, $t as isNewerVersion, A as SessionFileQuery, An as TriagePolicy, Ar as unknown, At as RespondRequest, B as NetworkStatus, Bn as TerminalClientMessage, Bt as HealthResponse, C as isElectronRuntime, Cn as isFactoryMode, Cr as number, Ct as CreateProjectRequest, D as CODEDEN_VERSION, Dn as AutomationGuards, Dr as record, Dt as ListSessionsQuery, E as wrapper_default, En as AutomationAction, Er as preprocess, Et as ListInboxQuery, F as ListConflictsQuery, Fn as GitCommitRequest, Ft as StartSessionRequest, G as SetNetworkPasswordResponse, Gn as ConflictDecision, Gt as LOCAL_STT_SAMPLE_RATE, H as RemoteAccessFile, Hn as RunnerCommandResults, Ht as READ_ALOUD_FULL_MAX_CHARS, I as ListNoticesQuery, In as GitPushRequest, It as SteerRequest, J as ImageTestRequest, Jn as Project, Jt as TtsRequest, K as SignInRequest, Kn as StartedBy, Kt as LocalSttModelParams, L as ResolveConflictRequest, Ln as RestoreFilesRequest, Lt as ThreadItemsQuery, M as RunnerToServerMessage, Mn as SessionStatus, Mr as toJSONSchema, Mt as SendMessageRequest, N as ServerLock, Nn as DiffScope, Nr as safeParse$1, Nt as SessionStartMeta, O as UsageQuery, On as AutomationRun, Or as string, Ot as ReorderProjectsRequest, P as ArchiveSessionRequest, Pn as EditQueuedMessageRequest, Pr as prettifyError, Pt as SetSecretRequest, Q as BrowserHistoryRequest, Qn as InboxItem, Qt as compareVersions, R as RoleMessageRequest, Rn as SessionResult, Rt as UiClientMessage, S as acquireInstallLock, Sn as SetAutonomyModeRequest, Sr as looseObject, St as UpdateAutomationRequest, T as import_websocket_server, Tn as Automation, Tr as optional, Tt as FileWriteRequest, U as SetNetworkAccessRequest, Un as SessionOptions, Ut as Settings, V as PASSWORD_RULE_TEXT, Vn as TerminalQuery, Vt as agentEventToNewEvent, W as SetNetworkPasswordRequest, Wn as ProjectConfigFile, Wt as speechExcerpt, X as BrowserClientMessage, Xn as DEFAULT_VISION_PATH, Xt as UpdateInstallRequest, Y as summarizeActivity, Yn as sortProjects, Yt as AppPointer, Z as BrowserDevServerRequest, Zn as FactoryLimits, Zt as UpdateStatus, _ as importClaudeSdkFrom, _n as FactoryTuning, _r as custom, _t as StarterSetRequest, a as Hono, an as GithubLabelRequest, ar as AgentConfigWriteRequest, at as AutomationImportSource, b as runNpm, bn as PutVisionRequest, br as intersection, bt as UpcomingRunsQuery, c as createAdaptorServer, cn as MergePrRequest, cr as McpServerUpsertRequest, ct as ImportAutomationsRequest, d as createImageEngineRegistry, dn as parseGithubPrId, dr as ReasoningEffort, dt as AutomationProjectRequest, en as ConflictRecord, er as messageFrom, et as BrowserNewTabRequest, f as IMAGE_ASPECT_RATIOS, fn as DiscoveryArea, fr as datetime, ft as CreateAutomationRequest, g as CLAUDE_SDK_VERSION, gn as FactoryTriageRequest, gr as boolean, gt as SetAutomationEnabledRequest, h as claudeSdkSource, hn as FactoryDecisionRequest, hr as array, ht as RunAutomationRequest, i as killTree$1, in as GithubIssue, ir as AgentConfigLocator, it as AUTOMATION_IMPORT_LIMITS, j as RunnerLock, jn as Session, jr as ZodError, jt as SecretKey, k as UsageSettings, kn as AutomationTrigger, kr as union$1, kt as ResolveInboxRequest, l as upgradeWebSocket, ln as TriageIssueRequest, lr as AutonomyLevel, lt as AutomationFileFrontmatter, m as ImageGenerationError, mn as FactoryCandidate, mr as _null, mt as ListAutomationsQuery, n as readLock, nn as CreateGithubIssueRequest, nr as AgentConfigDiffRequest, nt as BrowserViewportRequest, o as tryDecodeURIComponent, on as GithubListQuery, or as McpServerDiffRequest, ot as DuplicateAutomationRequest, p as resolveImageOutput, pn as FactoryAllowLargeChangeRequest, pr as _enum, pt as ListAutomationRunsQuery, q as passwordProblem, qn as WorkKey, qt as SttQuery, r as isAlive, rn as GithubCommentRequest, rr as AgentConfigListQuery, rt as BrowserWsQuery, s as bufferToFormData, sn as GithubPr, sr as McpServerTarget, st as ExportAutomationsQuery, tn as NOTICE_BATCH_CODE, tr as AgentConfigDeleteQuery, tt as BrowserOpenRequest, u as ImageEngineRegistry, un as githubPrId, ur as ProviderId, ut as AutomationGuardsPatch, v as installClaudeSdk, vn as KillSwitchRequest, vr as discriminatedUnion, vt as TriageDecisionRequest, w as ownNodeEnv, wn as Artifact, wr as object, wt as FileReadQuery, x as resolveNpm, xn as ReviewVerdict, xr as literal, xt as UpdateAutomationProjectSettingsRequest, y as isClaudeSdkInstalled, yn as LEGACY_SIZE_LIMITS, yr as int, yt as TriageReport, z as UpdateSessionRequest, zn as SpeechSummaryRequest, zt as UpdateProjectRequest } from "./runner-DjavcMnG.js";
2
+ import { $ as SetNetworkPasswordResponse, $n as SessionOptions, $t as speechExcerpt, A as findExecutable, An as PutVisionRequest, Ar as intersection, At as UpdateAutomationProjectSettingsRequest, B as RunnerToServerMessage, Bn as TriagePolicy, Br as unknown, Bt as SecretKey, C as importClaudeSdkFrom, Cn as FactoryAllowLargeChangeRequest, Cr as _enum, Ct as ListAutomationsQuery, D as resolveNpm, Dn as FactoryTuning, Dr as custom, Dt as TriageDecisionRequest, E as runNpm, En as FactoryTriageRequest, Er as boolean, Et as StarterSetRequest, F as CODEDEN_VERSION, Fn as Automation, Fr as optional, Ft as ListInboxQuery, G as ResolveConflictRequest, Gn as GitCommitRequest, Gt as SteerRequest, H as ArchiveSessionRequest, Hn as SessionStatus, Hr as toJSONSchema, Ht as SessionStartMeta, I as UsageQuery, In as AutomationAction, Ir as preprocess, It as ListSessionsQuery, J as NetworkStatus, Jn as SessionResult, Jt as UpdateProjectRequest, K as RoleMessageRequest, Kn as GitPushRequest, Kt as ThreadItemsQuery, L as UsageSettings, Ln as AutomationGuards, Lr as record, Lt as ReorderProjectsRequest, M as ownNodeEnv, Mn as SetAutonomyModeRequest, Mr as looseObject, Mt as CreateProjectRequest, N as import_websocket_server, Nn as isFactoryMode, Nr as number, Nt as FileReadQuery, O as acquireInstallLock, On as KillSwitchRequest, Or as discriminatedUnion, Ot as TriageReport, P as wrapper_default, Pn as Artifact, Pr as object, Pt as FileWriteRequest, Q as SetNetworkPasswordRequest, Qn as RunnerCommandResults, Qt as Settings, R as SessionFileQuery, Rn as AutomationRun, Rr as string, Rt as ResolveInboxRequest, S as CLAUDE_SDK_VERSION, Sn as DiscoveryArea, Sr as datetime, St as ListAutomationRunsQuery, T as isClaudeSdkInstalled, Tn as FactoryDecisionRequest, Tr as array, Tt as SetAutomationEnabledRequest, U as ListConflictsQuery, Un as DiffScope, Ur as safeParse$1, Ut as SetSecretRequest, V as ServerLock, Vn as Session, Vr as ZodError, Vt as SendMessageRequest, W as ListNoticesQuery, Wn as EditQueuedMessageRequest, Wr as prettifyError, Wt as StartSessionRequest, X as RemoteAccessFile, Xn as TerminalClientMessage, Xt as agentEventToNewEvent, Y as PASSWORD_RULE_TEXT, Yn as SpeechSummaryRequest, Yt as HealthResponse, Z as SetNetworkAccessRequest, Zn as TerminalQuery, Zt as READ_ALOUD_FULL_MAX_CHARS, _ as ImageGenerationError, _n as GithubPr, _r as McpServerTarget, _t as ImportAutomationsRequest, a as Hono, an as UpdateInstallRequest, ar as sortProjects, at as BrowserClientMessage, b as mainRepoRoot, bn as githubPrId, br as ProviderId, bt as AutomationProjectRequest, c as createAdaptorServer, cn as isNewerVersion, cr as InboxItem, ct as BrowserNavigateRequest, d as defaultShell, dn as NoticeBatchData, dr as AgentConfigDeleteQuery, dt as BrowserViewportRequest, en as LOCAL_STT_SAMPLE_RATE, er as ProjectConfigFile, et as SignInRequest, f as shellCommandLine, fn as CreateGithubIssueRequest, fr as AgentConfigDiffRequest, ft as BrowserWsQuery, g as resolveImageOutput, gn as GithubListQuery, gr as McpServerDiffRequest, gt as ExportAutomationsQuery, h as IMAGE_ASPECT_RATIOS, hn as GithubLabelRequest, hr as AgentConfigWriteRequest, ht as DuplicateAutomationRequest, i as killTree$1, in as AppPointer, ir as Project, it as summarizeActivity, j as isElectronRuntime, jn as ReviewVerdict, jr as literal, jt as UpdateAutomationRequest, k as brokenInstallMessage, kn as LEGACY_SIZE_LIMITS, kr as int, kt as UpcomingRunsQuery, l as upgradeWebSocket, ln as ConflictRecord, lr as InboxItemKind, lt as BrowserNewTabRequest, m as createImageEngineRegistry, mn as GithubIssue, mr as AgentConfigLocator, mt as AutomationImportSource, n as readLock, nn as SttQuery, nr as StartedBy, nt as ImageTestRequest, o as tryDecodeURIComponent, on as UpdateStatus, or as DEFAULT_VISION_PATH, ot as BrowserDevServerRequest, p as ImageEngineRegistry, pn as GithubCommentRequest, pr as AgentConfigListQuery, pt as AUTOMATION_IMPORT_LIMITS, q as UpdateSessionRequest, qn as RestoreFilesRequest, qt as UiClientMessage, r as isAlive, rn as TtsRequest, rr as WorkKey, rt as BrowserHostClientMessage, s as bufferToFormData, sn as compareVersions, sr as FactoryLimits, st as BrowserHistoryRequest, tn as LocalSttModelParams, tr as ConflictDecision, tt as passwordProblem, u as refreshProcessPath, un as NOTICE_BATCH_CODE, ur as messageFrom, ut as BrowserOpenRequest, v as claudeMemoryDir, vn as MergePrRequest, vr as McpServerUpsertRequest, vt as AutomationFileFrontmatter, w as installClaudeSdk, wn as FactoryCandidate, wr as _null, wt as RunAutomationRequest, x as claudeSdkSource, xn as parseGithubPrId, xr as ReasoningEffort, xt as CreateAutomationRequest, y as findGitRoot, yn as TriageIssueRequest, yr as AutonomyLevel, yt as AutomationGuardsPatch, z as RunnerLock, zn as AutomationTrigger, zr as union$1, zt as RespondRequest } from "./runner-WAbCC8k2.js";
3
3
  import { createRequire } from "node:module";
4
4
  import fs, { accessSync, appendFileSync, closeSync, constants, copyFileSync, cpSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
5
5
  import { homedir, networkInterfaces, tmpdir } from "node:os";
@@ -7856,7 +7856,9 @@ const notices = sqliteTable("notices", {
7856
7856
  sessionId: text("session_id"),
7857
7857
  notice: json("notice").notNull(),
7858
7858
  createdAt: text("created_at").notNull(),
7859
- deliveredAt: text("delivered_at")
7859
+ deliveredAt: text("delivered_at"),
7860
+ /** Not delivered before this time (a scheduled wake-up); null: at once. */
7861
+ notBefore: text("not_before")
7860
7862
  }, (t) => [index("notices_target").on(t.target, t.projectId, t.deliveredAt), index("notices_created").on(t.createdAt)]);
7861
7863
  /** Conflict details; the status lives on the `conflict` inbox item with the same id. */
7862
7864
  const conflicts = sqliteTable("conflicts", {
@@ -8194,7 +8196,8 @@ function requestResolution(item) {
8194
8196
  };
8195
8197
  return {
8196
8198
  status: item.status,
8197
- ...item.answers ? { answers: item.answers } : {}
8199
+ ...item.answers ? { answers: item.answers } : {},
8200
+ ...item.decidedBy ? { decidedBy: item.decidedBy } : {}
8198
8201
  };
8199
8202
  }
8200
8203
  /** Close an open inbox item; `completed`/`declined` resolve it, anything else dismisses it. */
@@ -9539,7 +9542,8 @@ function applyOrchestrationProjection(tx, e) {
9539
9542
  sessionId: notice.sessionId,
9540
9543
  notice,
9541
9544
  createdAt: notice.createdAt,
9542
- deliveredAt: notice.deliveredAt
9545
+ deliveredAt: notice.deliveredAt,
9546
+ notBefore: notice.notBefore ?? null
9543
9547
  };
9544
9548
  tx.insert(notices).values(row).onConflictDoUpdate({
9545
9549
  target: notices.id,
@@ -9562,6 +9566,21 @@ function applyOrchestrationProjection(tx, e) {
9562
9566
  }
9563
9567
  return;
9564
9568
  }
9569
+ case "notice.returned": {
9570
+ if (e.payload.noticeIds.length === 0) return;
9571
+ const rows = tx.select().from(notices).where(inArray(notices.id, e.payload.noticeIds)).all();
9572
+ for (const row of rows) {
9573
+ if (!row.deliveredAt) continue;
9574
+ tx.update(notices).set({
9575
+ deliveredAt: null,
9576
+ notice: {
9577
+ ...row.notice,
9578
+ deliveredAt: null
9579
+ }
9580
+ }).where(eq(notices.id, row.id)).run();
9581
+ }
9582
+ return;
9583
+ }
9565
9584
  case "conflict.created": {
9566
9585
  const { conflict } = e.payload;
9567
9586
  tx.insert(conflicts).values({
@@ -9669,6 +9688,17 @@ function applyUsageReport(tx, report) {
9669
9688
  }
9670
9689
  }
9671
9690
  }
9691
+ // ../server/src/events/reducer/retry.ts
9692
+ /**
9693
+ * Whether a failed root turn is left for automatic retry to decide (`AutoRetry`): an agent
9694
+ * session's turn that failed for a passing reason (`transient`, or a `usage_limit`). The
9695
+ * reducer leaves such a session idle with no inbox item; the auto-retry service then either
9696
+ * schedules the retry or fails the session the usual way. Orchestrator and PM sessions, and
9697
+ * every other failure, fail at once. Pure.
9698
+ */
9699
+ function awaitsRetryDecision(session, error) {
9700
+ return session.role === "agent" && (error.kind === "transient" || error.kind === "usage_limit");
9701
+ }
9672
9702
  // ../server/src/events/reducer/usage.ts
9673
9703
  /** Fields that add up across turns. */
9674
9704
  const ADDITIVE$1 = [
@@ -9824,6 +9854,11 @@ function onTurnEnded(d, sessionId, event, ts) {
9824
9854
  if (event.type === "turn.completed") setStatus(d, sessionId, "idle", ts);
9825
9855
  else if (event.type === "turn.interrupted") setStatus(d, sessionId, event.reason === "runner_restart" ? "interrupted" : "idle", ts);
9826
9856
  else {
9857
+ const current = d.getSession(sessionId)?.session;
9858
+ if (current && awaitsRetryDecision(current, event.error)) {
9859
+ setStatus(d, sessionId, "idle", ts);
9860
+ return;
9861
+ }
9827
9862
  setStatus(d, sessionId, "failed", ts);
9828
9863
  const session = d.getSession(sessionId)?.session;
9829
9864
  if (session) {
@@ -10088,6 +10123,7 @@ function applyProjection(tx, e) {
10088
10123
  return [];
10089
10124
  case "notice.created":
10090
10125
  case "notice.delivered":
10126
+ case "notice.returned":
10091
10127
  case "conflict.created":
10092
10128
  applyOrchestrationProjection(tx, e);
10093
10129
  return [];
@@ -21265,7 +21301,7 @@ function createMcpHandler(deps) {
21265
21301
  const FREE_FAILURES = 3;
21266
21302
  /** First wait once the free failures are used up; it doubles with each failure. */
21267
21303
  const BASE_DELAY_MS = 1e3;
21268
- const MAX_DELAY_MS = 3e4;
21304
+ const MAX_DELAY_MS$1 = 3e4;
21269
21305
  /** This many failures inside the window lock the address out. */
21270
21306
  const LOCKOUT_FAILURES = 10;
21271
21307
  const WINDOW_MS = 9e5;
@@ -21285,7 +21321,7 @@ var SignInLimiter = class SignInLimiter {
21285
21321
  /** Wait required before the `failures`-th failure may be followed by another attempt. */
21286
21322
  static delayAfter(failures) {
21287
21323
  if (failures < FREE_FAILURES) return 0;
21288
- return Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (failures - FREE_FAILURES));
21324
+ return Math.min(MAX_DELAY_MS$1, BASE_DELAY_MS * 2 ** (failures - FREE_FAILURES));
21289
21325
  }
21290
21326
  /** May `address` try a password now? */
21291
21327
  check(address) {
@@ -21593,6 +21629,26 @@ function redetectProviders(runner, log) {
21593
21629
  log?.(`runner: provider re-detect failed: ${error instanceof Error ? error.message : error}`);
21594
21630
  });
21595
21631
  }
21632
+ /** Model discovery starts a provider CLI (up to 30 s); detection runs first. */
21633
+ const REFRESH_MODELS_TIMEOUT_MS = 9e4;
21634
+ /**
21635
+ * "Refresh models": re-detect one provider and wait until it has listed its models again.
21636
+ * Resolves with every provider's list after the refresh (the runner also broadcasts it, so
21637
+ * other clients update as well).
21638
+ */
21639
+ async function refreshProviderModels(runner, providerId) {
21640
+ if (!runner.isConnected()) throw new ServiceError(503, "runner_unavailable", "The runner is not connected");
21641
+ if (!runner.providers().some((p) => p.id === providerId)) throw new ServiceError(404, "unknown_provider", `No provider "${providerId}" is loaded`);
21642
+ try {
21643
+ return { providers: (await runner.request({
21644
+ name: "provider.detect",
21645
+ providerId,
21646
+ waitForModels: true
21647
+ }, REFRESH_MODELS_TIMEOUT_MS)).providers ?? runner.providers() };
21648
+ } catch (error) {
21649
+ throw toServiceError$1(error);
21650
+ }
21651
+ }
21596
21652
  /** `RunnerPort` over the server's (possibly absent) `RunnerClient`. */
21597
21653
  function runnerPort(get) {
21598
21654
  return {
@@ -21619,6 +21675,175 @@ function runnerPort(get) {
21619
21675
  }
21620
21676
  };
21621
21677
  }
21678
+ // ../server/src/services/browser/agent-activity.ts
21679
+ /** How long the "Agent is acting" signal lingers after the last agent browser command. */
21680
+ const AGENT_ACTIVITY_DECAY_MS = 2500;
21681
+ /**
21682
+ * Tracks when agents drive a session's browser (§12.2): a session is "acting" while an agent
21683
+ * `browser.*` command runs and for `decayMs` after the last one ends, so a burst of tool calls
21684
+ * reads as one stretch of activity. Listeners hear only transitions.
21685
+ */
21686
+ var AgentActivity = class {
21687
+ decayMs;
21688
+ sessions = /* @__PURE__ */ new Map();
21689
+ listeners = /* @__PURE__ */ new Set();
21690
+ constructor(decayMs = AGENT_ACTIVITY_DECAY_MS) {
21691
+ this.decayMs = decayMs;
21692
+ }
21693
+ onChange(listener) {
21694
+ this.listeners.add(listener);
21695
+ return () => this.listeners.delete(listener);
21696
+ }
21697
+ isActing(sessionId) {
21698
+ return this.sessions.get(sessionId)?.acting ?? false;
21699
+ }
21700
+ /** Run an agent command, marking the session acting around it. */
21701
+ async track(sessionId, run) {
21702
+ this.begin(sessionId);
21703
+ try {
21704
+ return await run();
21705
+ } finally {
21706
+ this.end(sessionId);
21707
+ }
21708
+ }
21709
+ begin(sessionId) {
21710
+ const s = this.sessions.get(sessionId) ?? {
21711
+ inFlight: 0,
21712
+ acting: false
21713
+ };
21714
+ this.sessions.set(sessionId, s);
21715
+ s.inFlight += 1;
21716
+ clearTimeout(s.timer);
21717
+ s.timer = void 0;
21718
+ this.set(sessionId, s, true);
21719
+ }
21720
+ end(sessionId) {
21721
+ const s = this.sessions.get(sessionId);
21722
+ if (!s) return;
21723
+ s.inFlight = Math.max(0, s.inFlight - 1);
21724
+ if (s.inFlight > 0) return;
21725
+ clearTimeout(s.timer);
21726
+ s.timer = setTimeout(() => {
21727
+ s.timer = void 0;
21728
+ this.set(sessionId, s, false);
21729
+ this.sessions.delete(sessionId);
21730
+ }, this.decayMs);
21731
+ s.timer.unref?.();
21732
+ }
21733
+ set(sessionId, s, acting) {
21734
+ if (s.acting === acting) return;
21735
+ s.acting = acting;
21736
+ for (const l of this.listeners) l(sessionId, acting);
21737
+ }
21738
+ };
21739
+ // ../server/src/services/browser/browser-host.ts
21740
+ const ATTACH_TIMEOUT_MS = 1e4;
21741
+ /**
21742
+ * The desktop app's offer of native browser views (§17 Phase 2), over `/ws/browser-host`
21743
+ * (loopback only, install token). One app runs per home, so the latest app to connect is the
21744
+ * host. The server asks it to `attach` a session and gets that session's relay back; when the
21745
+ * app quits it hands its open sessions to the runner through here.
21746
+ */
21747
+ var BrowserHostLink = class {
21748
+ deps;
21749
+ host = null;
21750
+ waiting = /* @__PURE__ */ new Map();
21751
+ constructor(deps) {
21752
+ this.deps = deps;
21753
+ }
21754
+ /** A desktop app is connected and can host native views. */
21755
+ get available() {
21756
+ return this.host !== null && this.host.ws.readyState === 1;
21757
+ }
21758
+ /** Ask the app for a session's native browser; rejects when no app answers in time. */
21759
+ attach(sessionId, timeoutMs = ATTACH_TIMEOUT_MS) {
21760
+ const ws = this.host?.ws;
21761
+ if (ws?.readyState !== 1) return Promise.reject(/* @__PURE__ */ new Error("No desktop app is connected"));
21762
+ const id = randomUUID();
21763
+ return new Promise((resolve, reject) => {
21764
+ const timer = setTimeout(() => {
21765
+ this.waiting.delete(id);
21766
+ reject(/* @__PURE__ */ new Error("The desktop app did not answer"));
21767
+ }, timeoutMs);
21768
+ timer.unref?.();
21769
+ this.waiting.set(id, {
21770
+ resolve,
21771
+ reject,
21772
+ timer
21773
+ });
21774
+ send$2(ws, {
21775
+ type: "attach",
21776
+ id,
21777
+ sessionId
21778
+ });
21779
+ });
21780
+ }
21781
+ handlers() {
21782
+ let ws;
21783
+ return {
21784
+ onOpen: (_event, socket) => {
21785
+ ws = socket;
21786
+ },
21787
+ onMessage: (event) => {
21788
+ if (!ws) return;
21789
+ let msg;
21790
+ try {
21791
+ msg = BrowserHostClientMessage.parse(JSON.parse(String(event.data)));
21792
+ } catch {
21793
+ return;
21794
+ }
21795
+ this.handle(ws, msg);
21796
+ },
21797
+ onClose: () => {
21798
+ if (ws && this.host?.ws === ws) {
21799
+ this.host = null;
21800
+ this.deps.log?.("browser host: the desktop app disconnected");
21801
+ for (const [id, w] of this.waiting) {
21802
+ clearTimeout(w.timer);
21803
+ w.reject(/* @__PURE__ */ new Error("The desktop app disconnected"));
21804
+ this.waiting.delete(id);
21805
+ }
21806
+ }
21807
+ }
21808
+ };
21809
+ }
21810
+ async handle(ws, msg) {
21811
+ switch (msg.type) {
21812
+ case "hello":
21813
+ if (this.host && this.host.ws !== ws) this.host.ws.close(1e3, "replaced");
21814
+ this.host = {
21815
+ ws,
21816
+ version: msg.version
21817
+ };
21818
+ this.deps.log?.(`browser host: desktop app ${msg.version} offers native views`);
21819
+ return;
21820
+ case "attached":
21821
+ case "failed": {
21822
+ const w = this.waiting.get(msg.id);
21823
+ if (!w) return;
21824
+ this.waiting.delete(msg.id);
21825
+ clearTimeout(w.timer);
21826
+ if (msg.type === "attached") w.resolve(msg.relay);
21827
+ else w.reject(new Error(msg.message));
21828
+ return;
21829
+ }
21830
+ case "handoff": {
21831
+ const failed = await this.deps.handoff(msg.sessions).catch((error) => {
21832
+ this.deps.log?.(`browser host: hand-off failed: ${String(error)}`);
21833
+ return msg.sessions.map((s) => s.sessionId);
21834
+ });
21835
+ send$2(ws, {
21836
+ type: "handoff.done",
21837
+ id: msg.id,
21838
+ failed
21839
+ });
21840
+ }
21841
+ }
21842
+ }
21843
+ };
21844
+ function send$2(ws, msg) {
21845
+ if (ws.readyState === 1) ws.send(JSON.stringify(msg));
21846
+ }
21622
21847
  // ../server/src/services/browser/browser-service.ts
21623
21848
  /** Navigation and agent actions can wait on slow pages; leave headroom over the runner's 30s. */
21624
21849
  const BROWSER_TIMEOUT_MS = 6e4;
@@ -21630,11 +21855,49 @@ function isBrowserNotOpen(error) {
21630
21855
  * The session browser (§12.2) for the panel routes, the `/ws/browser` channel and the agent
21631
21856
  * tools. Every call names the session it acts for, so nobody can reach another session's
21632
21857
  * context; the runner enforces the same isolation.
21858
+ *
21859
+ * Engines: a session's browser opens on native views in the desktop app when the app is
21860
+ * connected, the setting is on and the desktop window or an agent opens it; otherwise on the
21861
+ * runner's Chromium. An open browser keeps its engine until it closes, or until the app quits
21862
+ * and hands it to the runner.
21633
21863
  */
21634
21864
  var BrowserService = class {
21635
21865
  deps;
21866
+ /** When agents drive each session's browser, for the panel's "Agent is acting" chip. */
21867
+ agentActivity = new AgentActivity();
21868
+ /** The desktop app, when one offers native views. */
21869
+ host;
21636
21870
  constructor(deps) {
21637
21871
  this.deps = deps;
21872
+ this.host = new BrowserHostLink({
21873
+ handoff: (s) => this.handoff(s),
21874
+ log: deps.log
21875
+ });
21876
+ }
21877
+ /** A `browser.*` command an agent tool sends: like `command`, and marks the agent acting. */
21878
+ agentCommand(command, timeoutMs = BROWSER_TIMEOUT_MS) {
21879
+ const { sessionId } = command;
21880
+ return this.agentActivity.track(sessionId, () => this.command(command, timeoutMs));
21881
+ }
21882
+ /** `navigate` on behalf of an agent tool (marks the agent acting). */
21883
+ agentNavigate(sessionId, url, tabId) {
21884
+ return this.agentActivity.track(sessionId, () => this.navigate(sessionId, url, tabId, { native: true }));
21885
+ }
21886
+ /** Open a dev server for an agent: a new tab, or the first tab of a browser it opens. */
21887
+ agentOpenDevServer(sessionId, port, path) {
21888
+ return this.agentActivity.track(sessionId, async () => {
21889
+ if ((await this.tabs(sessionId)).open) return this.command({
21890
+ name: "browser.devServer",
21891
+ sessionId,
21892
+ port,
21893
+ path
21894
+ });
21895
+ const url = `http://localhost:${port}${path ?? "/"}`;
21896
+ const { tabs } = await this.open(sessionId, url, { native: true });
21897
+ const tab = tabs.find((t) => t.active) ?? tabs[0];
21898
+ if (!tab) throw new ServiceError(500, "browser_error", "The browser opened without a tab");
21899
+ return { tab };
21900
+ });
21638
21901
  }
21639
21902
  /** Send a `browser.*` command for an existing session, mapping runner failures. */
21640
21903
  async command(command, timeoutMs = BROWSER_TIMEOUT_MS) {
@@ -21646,27 +21909,31 @@ var BrowserService = class {
21646
21909
  throw toServiceError$1(error);
21647
21910
  }
21648
21911
  }
21649
- async open(sessionId, url) {
21912
+ async open(sessionId, url, opts = {}) {
21913
+ const relay = opts.native ? await this.nativeRelay(sessionId) : void 0;
21650
21914
  const result = await this.command({
21651
21915
  name: "browser.open",
21652
21916
  sessionId,
21653
- url
21917
+ url,
21918
+ relay
21654
21919
  });
21655
21920
  return {
21656
21921
  cdpUrl: result.cdpUrl,
21657
- tabs: result.tabs ?? []
21922
+ tabs: result.tabs ?? [],
21923
+ engine: result.engine ?? "runner"
21658
21924
  };
21659
21925
  }
21660
21926
  /** The session's tabs; `open: false` when it has no browser yet (never throws for that). */
21661
21927
  async tabs(sessionId) {
21662
21928
  try {
21663
- const { tabs } = await this.command({
21929
+ const { tabs, engine } = await this.command({
21664
21930
  name: "browser.tabs.list",
21665
21931
  sessionId
21666
21932
  });
21667
21933
  return {
21668
21934
  open: true,
21669
- tabs
21935
+ tabs,
21936
+ engine: engine ?? "runner"
21670
21937
  };
21671
21938
  } catch (error) {
21672
21939
  if (isBrowserNotOpen(error)) return {
@@ -21677,8 +21944,8 @@ var BrowserService = class {
21677
21944
  }
21678
21945
  }
21679
21946
  /** Open the browser if needed, then navigate the active tab. */
21680
- async navigate(sessionId, url, tabId) {
21681
- if (!(await this.tabs(sessionId)).open) await this.open(sessionId);
21947
+ async navigate(sessionId, url, tabId, opts = {}) {
21948
+ if (!(await this.tabs(sessionId)).open) await this.open(sessionId, void 0, opts);
21682
21949
  return this.command({
21683
21950
  name: "browser.navigate",
21684
21951
  sessionId,
@@ -21698,7 +21965,92 @@ var BrowserService = class {
21698
21965
  throw toServiceError$1(error);
21699
21966
  }
21700
21967
  }
21968
+ /**
21969
+ * The desktop app's relay for a session that has no browser yet, when native views are on
21970
+ * offer; undefined means the runner's Chromium (an open browser keeps its engine).
21971
+ */
21972
+ async nativeRelay(sessionId) {
21973
+ if (!this.host.available) return void 0;
21974
+ if (this.deps.settings && !this.deps.settings().desktop.nativeBrowser) return void 0;
21975
+ if ((await this.tabs(sessionId)).open) return void 0;
21976
+ try {
21977
+ return await this.host.attach(sessionId);
21978
+ } catch (error) {
21979
+ this.deps.log?.(`browser: native views unavailable, using the runner: ${errorMessage$1(error)}`);
21980
+ return;
21981
+ }
21982
+ }
21983
+ /** The desktop app quits: its open sessions move to the runner's Chromium. */
21984
+ async handoff(sessions) {
21985
+ const failed = [];
21986
+ await Promise.all(sessions.map(async ({ sessionId, tabs, cookies }) => {
21987
+ try {
21988
+ if (!this.deps.repo.find(sessionId)) throw new Error("no such session");
21989
+ await this.deps.runner.request({
21990
+ name: "browser.handoff",
21991
+ sessionId,
21992
+ tabs,
21993
+ cookies
21994
+ }, BROWSER_TIMEOUT_MS);
21995
+ } catch (error) {
21996
+ this.deps.log?.(`browser: hand-off of ${sessionId}: ${errorMessage$1(error)}`);
21997
+ failed.push(sessionId);
21998
+ }
21999
+ }));
22000
+ return failed;
22001
+ }
21701
22002
  };
22003
+ // ../server/src/realtime/browser-viewport.ts
22004
+ /**
22005
+ * Decides which viewer's panel size a session's page follows (§12.2), so a phone opening the
22006
+ * panel doesn't resize the page under a desktop viewer:
22007
+ *
22008
+ * - The first viewer to report a size owns the viewport.
22009
+ * - A viewer that claims (it was focused, clicked, or picked a size) becomes the owner.
22010
+ * - The page follows the owner's reports; other viewers' reports are only remembered.
22011
+ * - When the owner leaves, the remaining viewer that claimed most recently takes over
22012
+ * (viewers that never claimed rank by when they first reported).
22013
+ */
22014
+ var ViewportArbiter = class {
22015
+ reports = /* @__PURE__ */ new Map();
22016
+ owner;
22017
+ clock = 0;
22018
+ get ownerId() {
22019
+ return this.owner;
22020
+ }
22021
+ /** The size the page should have, or undefined when no viewer reported one. */
22022
+ get size() {
22023
+ return this.owner ? this.reports.get(this.owner)?.size : void 0;
22024
+ }
22025
+ get viewers() {
22026
+ return this.reports.size;
22027
+ }
22028
+ /** Record a viewer's size. Returns true when the owner's size (what the page follows) changed. */
22029
+ report(viewerId, size, claim = false) {
22030
+ const before = this.size;
22031
+ const previous = this.reports.get(viewerId);
22032
+ const rank = claim || !previous ? ++this.clock : previous.rank;
22033
+ this.reports.set(viewerId, {
22034
+ size: { ...size },
22035
+ rank
22036
+ });
22037
+ if (claim || this.owner === void 0) this.owner = viewerId;
22038
+ return !sameSize(before, this.size);
22039
+ }
22040
+ /** Forget a viewer. Returns true when the owner's size changed (ownership passed on). */
22041
+ remove(viewerId) {
22042
+ const before = this.size;
22043
+ if (!this.reports.delete(viewerId)) return false;
22044
+ if (this.owner !== viewerId) return false;
22045
+ let next;
22046
+ for (const entry of this.reports) if (!next || entry[1].rank > next[1].rank) next = entry;
22047
+ this.owner = next?.[0];
22048
+ return next !== void 0 && !sameSize(before, this.size);
22049
+ }
22050
+ };
22051
+ function sameSize(a, b) {
22052
+ return a?.width === b?.width && a?.height === b?.height && a?.deviceScaleFactor === b?.deviceScaleFactor;
22053
+ }
21702
22054
  // ../server/src/realtime/browser.ts
21703
22055
  /** Skip frames for a socket that has this much unsent data (slow client). */
21704
22056
  const MAX_BUFFERED_BYTES = 4194304;
@@ -21711,19 +22063,34 @@ const MAX_BUFFERED_BYTES = 4194304;
21711
22063
  var BrowserBridge = class {
21712
22064
  deps;
21713
22065
  viewers = /* @__PURE__ */ new Map();
22066
+ /** Per session: whose panel size the page follows. */
22067
+ arbiters = /* @__PURE__ */ new Map();
22068
+ /** Per session: the viewport the runner last applied (cleared when the browser closes). */
22069
+ applied = /* @__PURE__ */ new Map();
22070
+ applying = /* @__PURE__ */ new Map();
22071
+ /** Per session: what backs its open browser. */
22072
+ engines = /* @__PURE__ */ new Map();
21714
22073
  constructor(deps) {
21715
22074
  this.deps = deps;
22075
+ deps.browser.agentActivity?.onChange((sessionId, acting) => {
22076
+ for (const v of this.viewers.get(sessionId) ?? []) send$1(v.ws, {
22077
+ type: "agent",
22078
+ acting
22079
+ });
22080
+ });
21716
22081
  }
21717
22082
  handlers(query) {
21718
22083
  let viewer;
21719
22084
  return {
21720
22085
  onOpen: (_event, ws) => {
21721
- const { sessionId, open: _open, ...settings } = query;
22086
+ const { sessionId, open: _open, native: nativeQuery, ...settings } = query;
22087
+ const native = nativeQuery === "1";
21722
22088
  viewer = {
21723
22089
  id: `ui-${randomUUID()}`,
21724
22090
  ws,
21725
22091
  sessionId,
21726
- wantsWatch: true,
22092
+ wantsWatch: !native,
22093
+ native,
21727
22094
  watching: false,
21728
22095
  open: false,
21729
22096
  settings,
@@ -21753,6 +22120,14 @@ var BrowserBridge = class {
21753
22120
  viewer.closed = true;
21754
22121
  this.remove(viewer);
21755
22122
  if (viewer.watching) this.setWatching(viewer, false).catch(() => void 0);
22123
+ const arbiter = this.arbiters.get(viewer.sessionId);
22124
+ if (arbiter) {
22125
+ const owner = arbiter.ownerId;
22126
+ const resized = arbiter.remove(viewer.id);
22127
+ if (arbiter.viewers === 0) this.arbiters.delete(viewer.sessionId);
22128
+ else if (resized) this.applyViewport(viewer.sessionId);
22129
+ else if (owner !== arbiter.ownerId) this.announceViewport(viewer.sessionId);
22130
+ }
21756
22131
  }
21757
22132
  };
21758
22133
  }
@@ -21769,18 +22144,27 @@ var BrowserBridge = class {
21769
22144
  }
21770
22145
  return;
21771
22146
  }
21772
- if (stream.kind === "browser.tabs") for (const v of this.viewers.get(stream.sessionId) ?? []) {
21773
- if (stream.tabs.length === 0) {
21774
- if (v.open) this.detach(v);
21775
- continue;
22147
+ if (stream.kind === "browser.tabs") {
22148
+ const engineChanged = stream.engine !== void 0 && this.engines.get(stream.sessionId) !== stream.engine;
22149
+ if (stream.engine) this.engines.set(stream.sessionId, stream.engine);
22150
+ if (stream.tabs.length === 0 || engineChanged) this.applied.delete(stream.sessionId);
22151
+ for (const v of this.viewers.get(stream.sessionId) ?? []) {
22152
+ if (stream.tabs.length === 0) {
22153
+ if (v.open) this.detach(v);
22154
+ continue;
22155
+ }
22156
+ if (!v.open) {
22157
+ v.open = true;
22158
+ this.watchIfWanted(v, stream.tabs).catch((error) => this.fail(v, error));
22159
+ } else {
22160
+ send$1(v.ws, {
22161
+ type: "tabs",
22162
+ tabs: stream.tabs,
22163
+ engine: this.engines.get(v.sessionId)
22164
+ });
22165
+ if (engineChanged && stream.engine === "runner") this.applyViewport(v.sessionId);
22166
+ }
21776
22167
  }
21777
- if (!v.open) {
21778
- v.open = true;
21779
- this.watchIfWanted(v, stream.tabs).catch((error) => this.fail(v, error));
21780
- } else send$1(v.ws, {
21781
- type: "tabs",
21782
- tabs: stream.tabs
21783
- });
21784
22168
  }
21785
22169
  }
21786
22170
  /** Number of viewers per session (diagnostics and tests). */
@@ -21789,11 +22173,13 @@ var BrowserBridge = class {
21789
22173
  }
21790
22174
  async start(v, openNow) {
21791
22175
  if (openNow) {
21792
- const opened = await this.deps.browser.open(v.sessionId);
22176
+ const opened = await this.deps.browser.open(v.sessionId, void 0, { native: v.native });
22177
+ this.noteEngine(v.sessionId, opened.engine);
21793
22178
  v.open = true;
21794
22179
  return this.watchIfWanted(v, opened.tabs);
21795
22180
  }
21796
- const { open, tabs } = await this.deps.browser.tabs(v.sessionId);
22181
+ const { open, tabs, engine } = await this.deps.browser.tabs(v.sessionId);
22182
+ this.noteEngine(v.sessionId, engine);
21797
22183
  v.open = v.open || open;
21798
22184
  if (!v.open) return this.sendState(v, []);
21799
22185
  return this.watchIfWanted(v, tabs);
@@ -21821,17 +22207,89 @@ var BrowserBridge = class {
21821
22207
  if (v.watching) await this.setWatching(v, false);
21822
22208
  return this.sendState(v, (await browser.tabs(v.sessionId)).tabs);
21823
22209
  case "open": {
21824
- const opened = await browser.open(v.sessionId, msg.url);
22210
+ const opened = await browser.open(v.sessionId, msg.url, { native: v.native });
22211
+ this.noteEngine(v.sessionId, opened.engine);
21825
22212
  v.open = true;
21826
22213
  return this.watchIfWanted(v, opened.tabs);
21827
22214
  }
22215
+ case "viewport": {
22216
+ const { type: _t, claim, ...size } = msg;
22217
+ let arbiter = this.arbiters.get(v.sessionId);
22218
+ if (!arbiter) {
22219
+ arbiter = new ViewportArbiter();
22220
+ this.arbiters.set(v.sessionId, arbiter);
22221
+ }
22222
+ const owner = arbiter.ownerId;
22223
+ if (arbiter.report(v.id, size, claim)) this.applyViewport(v.sessionId);
22224
+ else if (owner !== arbiter.ownerId) this.announceViewport(v.sessionId);
22225
+ return;
22226
+ }
21828
22227
  }
21829
22228
  }
21830
22229
  async watchIfWanted(v, tabs) {
21831
22230
  if (v.wantsWatch && !v.watching) await this.setWatching(v, true);
21832
22231
  this.sendState(v, tabs);
22232
+ if (v.open) {
22233
+ if (this.applied.has(v.sessionId)) this.announceViewport(v.sessionId, v);
22234
+ else this.applyViewport(v.sessionId);
22235
+ }
22236
+ if (this.deps.browser.agentActivity?.isActing(v.sessionId)) send$1(v.ws, {
22237
+ type: "agent",
22238
+ acting: true
22239
+ });
22240
+ }
22241
+ /**
22242
+ * Bring the runner's viewport in line with the owning viewer's panel. Serialized per session
22243
+ * and always applies the latest size, so a burst of resizes ends at the final one.
22244
+ */
22245
+ applyViewport(sessionId) {
22246
+ const run = async () => {
22247
+ const size = this.arbiters.get(sessionId)?.size;
22248
+ if (!size) return;
22249
+ const key = `${size.width}x${size.height}@${size.deviceScaleFactor}`;
22250
+ const known = this.applied.get(sessionId);
22251
+ if (known?.key === key && !known.fixed) return this.announceViewport(sessionId);
22252
+ try {
22253
+ const result = await this.deps.browser.command({
22254
+ name: "browser.viewport",
22255
+ sessionId,
22256
+ width: Math.min(size.width, 7680),
22257
+ height: Math.min(size.height, 4320),
22258
+ deviceScaleFactor: size.deviceScaleFactor
22259
+ });
22260
+ this.applied.set(sessionId, {
22261
+ key,
22262
+ width: result.width ?? size.width,
22263
+ height: result.height ?? size.height,
22264
+ deviceScaleFactor: result.deviceScaleFactor ?? size.deviceScaleFactor,
22265
+ fixed: result.fixed === true
22266
+ });
22267
+ this.announceViewport(sessionId);
22268
+ } catch (error) {
22269
+ if (!isBrowserNotOpen(error)) this.deps.log?.(`browser viewport: ${errorMessage$1(error)}`);
22270
+ }
22271
+ };
22272
+ const next = (this.applying.get(sessionId) ?? Promise.resolve()).then(run, run);
22273
+ this.applying.set(sessionId, next);
22274
+ next.finally(() => {
22275
+ if (this.applying.get(sessionId) === next) this.applying.delete(sessionId);
22276
+ });
22277
+ }
22278
+ /** Tell viewers (or one) the applied viewport and whether it follows their panel. */
22279
+ announceViewport(sessionId, only) {
22280
+ const applied = this.applied.get(sessionId);
22281
+ if (!applied) return;
22282
+ const owner = this.arbiters.get(sessionId)?.ownerId;
22283
+ const { key: _k, fixed, ...viewport } = applied;
22284
+ for (const v of only ? [only] : this.viewers.get(sessionId) ?? []) if (v.open) send$1(v.ws, {
22285
+ type: "viewport",
22286
+ ...viewport,
22287
+ mine: !fixed && v.id === owner
22288
+ });
21833
22289
  }
21834
22290
  async setWatching(v, watching) {
22291
+ const before = v.watching;
22292
+ v.watching = watching && !v.closed;
21835
22293
  try {
21836
22294
  await this.deps.browser.command({
21837
22295
  name: "browser.screencast",
@@ -21840,8 +22298,8 @@ var BrowserBridge = class {
21840
22298
  watching: watching && !v.closed,
21841
22299
  ...v.settings
21842
22300
  });
21843
- v.watching = watching && !v.closed;
21844
22301
  } catch (error) {
22302
+ v.watching = before;
21845
22303
  if (!isBrowserNotOpen(error)) throw error;
21846
22304
  v.open = false;
21847
22305
  v.watching = false;
@@ -21859,14 +22317,19 @@ var BrowserBridge = class {
21859
22317
  this.sendState(v, []);
21860
22318
  }
21861
22319
  sendState(v, tabs) {
22320
+ const engine = v.open ? this.engines.get(v.sessionId) : void 0;
21862
22321
  send$1(v.ws, {
21863
22322
  type: "state",
21864
22323
  viewerId: v.id,
21865
22324
  open: v.open,
21866
22325
  watching: v.watching,
21867
- tabs
22326
+ tabs,
22327
+ engine
21868
22328
  });
21869
22329
  }
22330
+ noteEngine(sessionId, engine) {
22331
+ if (engine) this.engines.set(sessionId, engine);
22332
+ }
21870
22333
  fail(v, error) {
21871
22334
  const code = error instanceof ServiceError ? error.code : "browser_error";
21872
22335
  send$1(v.ws, {
@@ -21892,6 +22355,35 @@ function buffered(ws) {
21892
22355
  function send$1(ws, msg) {
21893
22356
  if (ws.readyState === 1) ws.send(JSON.stringify(msg));
21894
22357
  }
22358
+ // ../server/src/realtime/browser-routes.ts
22359
+ /** Paths only the machine itself may use, even with network access on (§15.1). */
22360
+ const BROWSER_LOOPBACK_ONLY_PATHS = ["/ws/browser-host"];
22361
+ /**
22362
+ * The browser sockets (§12.2, §17 Phase 2):
22363
+ * - `/ws/browser`: one viewer of one session's browser (frames, tabs, input, viewport);
22364
+ * - `/ws/browser-host`: the desktop app offering native views (loopback only).
22365
+ * Returns the bridge, which also takes the runner's browser stream data.
22366
+ */
22367
+ function mountBrowserSockets(app, services, log) {
22368
+ const bridge = new BrowserBridge({
22369
+ browser: services.browser,
22370
+ log
22371
+ });
22372
+ app.get("/ws/browser", async (c, next) => {
22373
+ const query = BrowserWsQuery.safeParse(c.req.query());
22374
+ if (!query.success) return c.json({ error: {
22375
+ code: "bad_request",
22376
+ message: "Invalid browser query"
22377
+ } }, 400);
22378
+ if (!services.sessionRepo.find(query.data.sessionId)) return c.json({ error: {
22379
+ code: "session_not_found",
22380
+ message: "No such session"
22381
+ } }, 404);
22382
+ return upgradeWebSocket(() => bridge.handlers(query.data))(c, next);
22383
+ });
22384
+ app.get("/ws/browser-host", upgradeWebSocket(() => services.browser.host.handlers()));
22385
+ return bridge;
22386
+ }
21895
22387
  // ../server/src/services/sessions/preview.ts
21896
22388
  /** Tool output kept in transcript pages and live updates; the rest is fetched on demand (§6). */
21897
22389
  const OUTPUT_PREVIEW_CHARS = 16e3;
@@ -31819,7 +32311,10 @@ function projectAutomationRoutes(s) {
31819
32311
  */
31820
32312
  function sessionBrowserRoutes(s) {
31821
32313
  const b = s.browser;
31822
- return new Hono().post("/:id/browser/open", jsonBody(BrowserOpenRequest), async (c) => c.json(await b.open(c.req.param("id"), c.req.valid("json").url))).post("/:id/browser/close", async (c) => {
32314
+ return new Hono().post("/:id/browser/open", jsonBody(BrowserOpenRequest), async (c) => {
32315
+ const { url, native } = c.req.valid("json");
32316
+ return c.json(await b.open(c.req.param("id"), url, { native }));
32317
+ }).post("/:id/browser/close", async (c) => {
31823
32318
  await b.command({
31824
32319
  name: "browser.close",
31825
32320
  sessionId: c.req.param("id")
@@ -32712,6 +33207,13 @@ function setupRoutes(s) {
32712
33207
  return c.json({ setupCompleted });
32713
33208
  });
32714
33209
  }
33210
+ /** `/api/providers`: the runner's providers. Their model lists arrive with the runner status. */
33211
+ function providerRoutes(s) {
33212
+ return new Hono().post("/:id/refresh-models", async (c) => {
33213
+ const body = await refreshProviderModels(s.runner, c.req.param("id"));
33214
+ return c.json(body);
33215
+ });
33216
+ }
32715
33217
  /** `/api/fs` (folder picker) and `/api/files` (config editors). */
32716
33218
  function fileRoutes(s) {
32717
33219
  return new Hono().get("/fs/list", queryOf(object({
@@ -33166,7 +33668,7 @@ function createApi(ctx) {
33166
33668
  uptimeSec: (Date.now() - ctx.startedAt) / 1e3
33167
33669
  };
33168
33670
  return c.json(body);
33169
- }).get("/snapshot", (c) => c.json(buildSnapshot(ctx))).get("/activity", (c) => c.json(buildActivity(s))).get("/sessions/:id/snapshot", (c) => c.json(buildSessionSnapshot(s, c.req.param("id")))).route("/projects", projectRoutes(s)).route("/projects", projectGithubRoutes(s)).route("/projects", projectFactoryRoutes(s)).route("/factory", factoryRoutes(s)).route("/prs", prRoutes(s)).route("/github", githubRoutes(s)).route("/sessions", sessionGithubRoutes(s)).route("/sessions", sessionRoutes(s)).route("/sessions", workbenchRoutes(s)).route("/sessions", sessionBrowserRoutes(s)).route("/browser", browserRoutes(s)).route("/images", imageRoutes(s)).route("/agent-config", agentConfigRoutes(s)).route("/voice", voiceRoutes(s)).route("/", speechRoutes(s)).route("/sessions", sessionAgentConfigRoutes(s)).route("/sessions", sessionFileRoutes(s)).route("/utility", utilityRoutes(s)).route("/inbox", inboxRoutes(s)).route("/orchestration", orchestrationRoutes(s)).route("/conflicts", conflictRoutes(s)).route("/automations", automationRoutes(s)).route("/projects", projectAutomationRoutes(s)).route("/usage", usageRoutes(s)).route("/setup", setupRoutes(s)).route("/updates", updateRoutes(s)).route("/artifacts", artifactRoutes(s)).route("/secrets", secretRoutes(s)).route("/network", networkRoutes(ctx.network)).route("/auth", authRoutes(ctx.network)).route("/", fileRoutes(s)).get("/settings", (c) => c.json(s.settings.get())).patch("/settings", async (c) => {
33671
+ }).get("/snapshot", (c) => c.json(buildSnapshot(ctx))).get("/activity", (c) => c.json(buildActivity(s))).get("/sessions/:id/snapshot", (c) => c.json(buildSessionSnapshot(s, c.req.param("id")))).route("/projects", projectRoutes(s)).route("/projects", projectGithubRoutes(s)).route("/projects", projectFactoryRoutes(s)).route("/factory", factoryRoutes(s)).route("/prs", prRoutes(s)).route("/github", githubRoutes(s)).route("/sessions", sessionGithubRoutes(s)).route("/sessions", sessionRoutes(s)).route("/sessions", workbenchRoutes(s)).route("/sessions", sessionBrowserRoutes(s)).route("/browser", browserRoutes(s)).route("/images", imageRoutes(s)).route("/agent-config", agentConfigRoutes(s)).route("/voice", voiceRoutes(s)).route("/", speechRoutes(s)).route("/sessions", sessionAgentConfigRoutes(s)).route("/sessions", sessionFileRoutes(s)).route("/utility", utilityRoutes(s)).route("/inbox", inboxRoutes(s)).route("/orchestration", orchestrationRoutes(s)).route("/conflicts", conflictRoutes(s)).route("/automations", automationRoutes(s)).route("/projects", projectAutomationRoutes(s)).route("/usage", usageRoutes(s)).route("/setup", setupRoutes(s)).route("/providers", providerRoutes(s)).route("/updates", updateRoutes(s)).route("/artifacts", artifactRoutes(s)).route("/secrets", secretRoutes(s)).route("/network", networkRoutes(ctx.network)).route("/auth", authRoutes(ctx.network)).route("/", fileRoutes(s)).get("/settings", (c) => c.json(s.settings.get())).patch("/settings", async (c) => {
33170
33672
  const patch = await c.req.json().catch(() => void 0);
33171
33673
  if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw badRequest("Expected a JSON object");
33172
33674
  let settings;
@@ -34201,6 +34703,15 @@ const UNATTENDED_ANSWER = [
34201
34703
  "If the user is talking to you directly in this conversation, ask them in your reply instead."
34202
34704
  ].join("\n");
34203
34705
  /**
34706
+ * The denial an unattended factory session gets instead of waiting for an approval. Nothing is
34707
+ * ever approved on the user's behalf: the agent finds a way that needs no approval, or flags it.
34708
+ */
34709
+ const UNATTENDED_APPROVAL = [
34710
+ "Not approved: this project runs as a dark factory, so nobody is watching to approve it. Don't wait for it or try the same thing again.",
34711
+ "- Find a way that stays inside your worktree and the factory's limits. To publish your work, commit it and call `create_pull_request`: it pushes your own branch (with a lease after a rebase).",
34712
+ "- If the work can't be done without this, call `factory_record_decision` with `needsHuman: true`, saying what you needed and why, and carry on with whatever doesn't depend on it."
34713
+ ].join("\n");
34714
+ /**
34204
34715
  * True when nobody is expected to answer this session's questions: its project is a dark factory
34205
34716
  * and the session is the PM or one of the factory's own agent sessions. Sessions the user
34206
34717
  * started themselves still ask as usual.
@@ -34329,7 +34840,7 @@ function postSharedArtifact(services, sessionId, artifact, caption) {
34329
34840
  */
34330
34841
  const askUser = defineTool({
34331
34842
  name: "ask_user",
34332
- description: "Ask the user a question when you cannot proceed without their decision. Offer options when there are clear choices. The call waits a few minutes for an answer. If it returns 'pending', do not wait or ask again: finish whatever does not depend on the answer, then end your turn; the answer will arrive as your next message.",
34843
+ description: "Ask the user for a decision only they can make: destructive or irreversible steps, spending money or creating accounts or credentials, outward-facing actions nobody asked for (publishing, releases, deploys, messages to other people), protected areas, or a request with two readings that lead to very different work. For anything else, pick a sensible default, say what you assumed, and carry on. Put your recommended option first. The call waits a few minutes for an answer. If it returns 'pending', do not wait or ask again: finish whatever does not depend on the answer, then end your turn; the answer will arrive as your next message.",
34333
34844
  roles: ["agent", "pm"],
34334
34845
  scope: "session",
34335
34846
  alwaysLoad: true,
@@ -34352,7 +34863,7 @@ const askUser = defineTool({
34352
34863
  multiSelect: input.multiSelect ?? false,
34353
34864
  allowFreeText: true
34354
34865
  }, signal);
34355
- if (outcome.status === "answered") return `The user answered:\n${outcome.text}`;
34866
+ if (outcome.status === "answered") return `${outcome.by === "pm" ? "Your project manager" : "The user"} answered:\n${outcome.text}`;
34356
34867
  if (outcome.status === "dismissed") return "The user dismissed the question without answering.";
34357
34868
  return "pending: the question is waiting in the user's inbox. Do not wait for it. Finish what you can without the answer, then end your turn; the answer will arrive as your next message.";
34358
34869
  }
@@ -34378,7 +34889,7 @@ const browserNavigate = defineTool({
34378
34889
  tabId
34379
34890
  }),
34380
34891
  handler: async (input, { services, sessionId }) => {
34381
- const result = await services.browser.navigate(sessionId, input.url, input.tabId);
34892
+ const result = await services.browser.agentNavigate(sessionId, input.url, input.tabId);
34382
34893
  const { tabs } = await services.browser.tabs(sessionId);
34383
34894
  const status = result.status ? ` (HTTP ${result.status})` : "";
34384
34895
  return `Navigated to ${result.url ?? input.url}${status}.\nTabs:\n${tabLine(tabs)}`;
@@ -34391,7 +34902,7 @@ const browserSnapshot = defineTool({
34391
34902
  scope: "session",
34392
34903
  input: object({ tabId }),
34393
34904
  handler: async (input, { services, sessionId }) => {
34394
- const snap = await services.browser.command({
34905
+ const snap = await services.browser.agentCommand({
34395
34906
  name: "browser.snapshot",
34396
34907
  sessionId,
34397
34908
  tabId: input.tabId
@@ -34416,7 +34927,7 @@ const browserClick = defineTool({
34416
34927
  tabId
34417
34928
  }),
34418
34929
  handler: async (input, { services, sessionId }) => {
34419
- await services.browser.command({
34930
+ await services.browser.agentCommand({
34420
34931
  name: "browser.click",
34421
34932
  sessionId,
34422
34933
  ...input
@@ -34436,7 +34947,7 @@ const browserType = defineTool({
34436
34947
  tabId
34437
34948
  }),
34438
34949
  handler: async (input, { services, sessionId }) => {
34439
- await services.browser.command({
34950
+ await services.browser.agentCommand({
34440
34951
  name: "browser.type",
34441
34952
  sessionId,
34442
34953
  ...input
@@ -34454,7 +34965,7 @@ const browserPress = defineTool({
34454
34965
  tabId
34455
34966
  }),
34456
34967
  handler: async (input, { services, sessionId }) => {
34457
- await services.browser.command({
34968
+ await services.browser.agentCommand({
34458
34969
  name: "browser.press",
34459
34970
  sessionId,
34460
34971
  ...input
@@ -34519,7 +35030,7 @@ const browserWait = defineTool({
34519
35030
  v.networkIdle || void 0
34520
35031
  ].filter(Boolean).length === 1, { message: "Pass exactly one of selector, text or networkIdle" }),
34521
35032
  handler: async (input, { services, sessionId }) => {
34522
- const { matched } = await services.browser.command({
35033
+ const { matched } = await services.browser.agentCommand({
34523
35034
  name: "browser.wait",
34524
35035
  sessionId,
34525
35036
  ...input
@@ -34537,7 +35048,7 @@ const browserEvaluate = defineTool({
34537
35048
  tabId
34538
35049
  }),
34539
35050
  handler: async (input, { services, sessionId }) => {
34540
- const result = await services.browser.command({
35051
+ const result = await services.browser.agentCommand({
34541
35052
  name: "browser.evaluate",
34542
35053
  sessionId,
34543
35054
  ...input
@@ -34562,12 +35073,7 @@ const browserOpenDevServer = defineTool({
34562
35073
  if (servers.length > 1) return `Several dev servers are running; call again with a port:\n${servers.map((s) => `- ${s.url}${s.pid ? ` (pid ${s.pid})` : ""}`).join("\n")}`;
34563
35074
  port = servers[0]?.port;
34564
35075
  }
34565
- const { tab } = await services.browser.command({
34566
- name: "browser.devServer",
34567
- sessionId,
34568
- port,
34569
- path: input.path
34570
- });
35076
+ const { tab } = await services.browser.agentOpenDevServer(sessionId, port, input.path);
34571
35077
  return `Opened ${tab.url} in tab ${tab.tabId}. Use browser_snapshot to read it.`;
34572
35078
  }
34573
35079
  });
@@ -34598,7 +35104,7 @@ const browserScreenshot = defineTool({
34598
35104
  mkdirSync(incoming, { recursive: true });
34599
35105
  const tmp = join(incoming, `${randomUUID()}.png`);
34600
35106
  try {
34601
- const shot = await services.browser.command({
35107
+ const shot = await services.browser.agentCommand({
34602
35108
  name: "browser.screenshot",
34603
35109
  sessionId,
34604
35110
  path: tmp,
@@ -35011,7 +35517,8 @@ function buildProjectStatus(input) {
35011
35517
  project: {
35012
35518
  id: input.project.id,
35013
35519
  name: input.project.name,
35014
- kind: input.project.kind
35520
+ kind: input.project.kind,
35521
+ mode: input.project.autonomyMode
35015
35522
  },
35016
35523
  running: input.work.filter((w) => w.status !== "queued"),
35017
35524
  queued: input.work.filter((w) => w.status === "queued"),
@@ -35049,6 +35556,22 @@ function age(from, now) {
35049
35556
  if (sec < 86400) return `${Math.round(sec / 3600)}h`;
35050
35557
  return `${Math.round(sec / 86400)}d`;
35051
35558
  }
35559
+ /**
35560
+ * Shorten `text` to at most `max` characters, keeping its start and its end: agents put the
35561
+ * question or the next step they need in their last paragraph, so cutting only the end would
35562
+ * hide it. Keeps the whole last paragraph when it fits in about half of `max`.
35563
+ */
35564
+ function clipKeepingEnd(text, max = 500) {
35565
+ const trimmed = text.trim();
35566
+ if (trimmed.length <= max) return trimmed;
35567
+ const gap = " [...] ";
35568
+ const room = max - 7;
35569
+ const paragraphs = trimmed.split(/\n\s*\n/);
35570
+ const last = paragraphs.length > 1 ? (paragraphs.at(-1) ?? "").trim() : "";
35571
+ const tailMax = Math.floor(room / 2);
35572
+ const tail = last && last.length <= tailMax ? last : trimmed.slice(-tailMax).trimStart();
35573
+ return `${trimmed.slice(0, room - tail.length).trimEnd()}${gap}${tail}`;
35574
+ }
35052
35575
  const short = (id) => id.slice(0, 8);
35053
35576
  const quote = (text, max = 100) => `"${text.length > max ? `${text.slice(0, max - 3)}...` : text}"`;
35054
35577
  function workLine(w, now) {
@@ -35099,7 +35622,7 @@ function block(open, close, lines) {
35099
35622
  /** The PM's digest for its project. */
35100
35623
  function renderProjectDigest(s, now = Date.now()) {
35101
35624
  const at = new Date(now).toISOString();
35102
- return block(`<codeden_status project=${quote(neutralizeTags(s.project.name))} at="${at}">`, "</codeden_status>", projectLines(s, now));
35625
+ return block(`<codeden_status project=${quote(neutralizeTags(s.project.name))} mode="${s.project.mode}" at="${at}">`, "</codeden_status>", projectLines(s, now));
35103
35626
  }
35104
35627
  /** The orchestrator's digest: every project rolled up. */
35105
35628
  function renderOverviewDigest(o, now = Date.now()) {
@@ -35131,8 +35654,8 @@ function renderNotices(list, projectName) {
35131
35654
  }
35132
35655
  function noticeDetail(n) {
35133
35656
  switch (n.type) {
35134
- case "session.finished": return ` · ${n.data.workKey} · session ${short(n.data.sessionId)}${n.data.summary ? ` · ${quote(n.data.summary, 200)}` : ""}`;
35135
- case "session.needs_input": return ` · ${n.data.kind}: ${quote(n.data.question, 200)} (waiting in the user's inbox)`;
35657
+ case "session.finished": return ` · ${n.data.workKey} · session ${short(n.data.sessionId)}${n.data.summary ? ` · "${clipKeepingEnd(n.data.summary)}"` : ""}`;
35658
+ case "session.needs_input": return ` · ${n.data.kind}: ${quote(n.data.question, 200)} (${n.data.kind !== "question" ? "waiting for the user's approval" : n.data.startedBy.kind === "user" ? `inbox item ${n.data.inboxItemId}: the user started this session and answers its questions` : `inbox item ${n.data.inboxItemId}: answer it with answer_question when the brief, the notes or the vision settle it`})`;
35136
35659
  case "session.failed": return ` · ${n.data.workKey} · ${quote(n.data.error, 200)}`;
35137
35660
  case "pr.opened": return ` · ${n.data.pr.url}`;
35138
35661
  case "pr.checks_failed": return ` · ${n.data.pr.url} · failed: ${n.data.failedChecks.join(", ") || "unknown"}`;
@@ -35143,6 +35666,8 @@ function noticeDetail(n) {
35143
35666
  case "automation.skipped": return ` · ${n.data.workKey} · reason: ${n.data.reason}`;
35144
35667
  case "conflict": return ` · ${n.data.workKey} requested by ${n.data.requestedBy.kind}, matches ${n.data.existing.map((w) => w.workKey).join(", ")} (conflict ${short(n.data.conflictId)} waits in the user's inbox)`;
35145
35668
  case "pm.message": return ` · ${quote(n.data.text, 2e3)}`;
35669
+ case "orchestrator.message": return ` · ${quote(n.data.text, Number.POSITIVE_INFINITY)}`;
35670
+ case "reminder": return ` · ${quote(n.data.note, 2e3)} (scheduled for ${n.notBefore ?? n.createdAt})`;
35146
35671
  case "factory.update": return ` · ${n.data.event}\n${quote(n.data.text, 4e3)}`;
35147
35672
  }
35148
35673
  }
@@ -35168,12 +35693,23 @@ function githubProject(services, projectId) {
35168
35693
  if (project.kind !== "github") throw new ToolError(`${project.name} is a local project without a GitHub remote: no issues or PRs.`);
35169
35694
  return project;
35170
35695
  }
35171
- /** An agent session of the caller's project, or a tool error. */
35696
+ /**
35697
+ * An agent session of the caller's project, or a tool error. Takes the full id or a unique
35698
+ * prefix of at least six characters, as digests and updates show ids.
35699
+ */
35172
35700
  function agentSessionOf(services, sessionId, projectId) {
35173
- const found = services.sessionRepo.find(sessionId)?.session;
35701
+ const id = services.sessionRepo.find(sessionId) ? sessionId : byPrefix(services, sessionId, projectId);
35702
+ const found = id ? services.sessionRepo.find(id)?.session : void 0;
35174
35703
  if (found?.role !== "agent" || found.projectId !== projectId) throw new ToolError(`No agent session ${sessionId} in this project. Call list_sessions for the ids.`);
35175
35704
  return found;
35176
35705
  }
35706
+ /** The one agent session of a project whose id starts with `prefix`, if exactly one does. */
35707
+ function byPrefix(services, prefix, projectId) {
35708
+ if (!/^[0-9a-f-]{6,}$/i.test(prefix)) return void 0;
35709
+ const rows = services.db.select({ id: sessions.id }).from(sessions).where(and(eq(sessions.projectId, projectId), eq(sessions.role, "agent"), like(sessions.id, `${prefix.toLowerCase()}%`))).limit(5).all();
35710
+ if (rows.length > 1) throw new ToolError(`More than one session starts with ${prefix}: ${rows.map((r) => r.id).join(", ")}. Use the full id.`);
35711
+ return rows[0]?.id;
35712
+ }
35177
35713
  /** A session as tools report it: enough to identify and judge the work. */
35178
35714
  function compactSession(s) {
35179
35715
  return {
@@ -35268,12 +35804,32 @@ const messagePm = defineTool({
35268
35804
  projectId: string().min(1),
35269
35805
  text: string().min(1).max(2e4)
35270
35806
  }),
35271
- handler: async ({ projectId, text }, { services }) => {
35807
+ handler: async ({ projectId, text }, { services, caller, sessionId }) => {
35272
35808
  const project = projectFor(services, projectId);
35273
- const res = await services.orchestration.messagePm(projectId, text);
35809
+ const res = await services.orchestration.messagePm(projectId, text, {
35810
+ caller,
35811
+ sessionId
35812
+ });
35813
+ if (res.status === "held") return `${heldNote(project.name, res.until)} Its reply arrives as a later message.`;
35274
35814
  return `Message ${res.status} to the PM of ${project.name} (session ${res.sessionId}). Its reply arrives as a later message.`;
35275
35815
  }
35276
35816
  });
35817
+ /**
35818
+ * What the orchestrator is told about a message the loop guard held back (§8.2): it is
35819
+ * queued and delivered automatically, so there is nothing to wait for or retry.
35820
+ */
35821
+ function heldNote(projectName, until, now = Date.now()) {
35822
+ return [
35823
+ `Queued: CodeDen delivers this to the PM of ${projectName} automatically ${until - now <= 6e4 ? "within a minute" : `at about ${clock(until)} (local time)`}, or sooner if the PM wakes up for something else.`,
35824
+ "Nothing else to do: do not send it again or plan a retry.",
35825
+ "(CodeDen paces automatic back-and-forth between you and a PM while the user has not stepped in.)"
35826
+ ].join(" ");
35827
+ }
35828
+ /** `HH:MM` in the server's (the user's) local time. */
35829
+ const clock = (at) => {
35830
+ const d = new Date(at);
35831
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
35832
+ };
35277
35833
  /** `ask_pm` (§8.3): ask a PM and wait for its answer. */
35278
35834
  const askPm = defineTool({
35279
35835
  name: "ask_pm",
@@ -35286,13 +35842,17 @@ const askPm = defineTool({
35286
35842
  question: string().min(1).max(2e4),
35287
35843
  timeoutSec: int().min(10).max(540).optional()
35288
35844
  }),
35289
- handler: async ({ projectId, question, timeoutSec }, { services, sessionId, signal }) => {
35845
+ handler: async ({ projectId, question, timeoutSec }, { services, caller, sessionId, signal }) => {
35290
35846
  const project = projectFor(services, projectId);
35291
35847
  const outcome = await services.orchestration.askPm(projectId, question, {
35292
- fromSessionId: sessionId,
35848
+ origin: {
35849
+ caller,
35850
+ sessionId
35851
+ },
35293
35852
  timeoutMs: timeoutSec ? timeoutSec * 1e3 : void 0,
35294
35853
  signal
35295
35854
  });
35855
+ if (outcome.status === "held") return `pending: ${heldNote(project.name, outcome.until)} Do not ask again; the PM's answer arrives as a message.`;
35296
35856
  if (outcome.status === "answered") return `The PM of ${project.name} answered:\n${outcome.text}`;
35297
35857
  return `pending: the PM of ${project.name} is still working on it. Do not ask again; its answer will arrive as a message.`;
35298
35858
  }
@@ -35479,7 +36039,7 @@ const listAutomations = defineTool({
35479
36039
  /** `run_automation` (§8.3, §8.4): run now, with the conflict check. */
35480
36040
  const runAutomation = defineTool({
35481
36041
  name: "run_automation",
35482
- description: "Run one of this project's automations now (even if it is disabled or paused). If matching work is already running or queued, nothing starts and the result is 'conflict' with the existing work: call again with conflict = {resolution: 'wait'}, {resolution: 'skip'}, {resolution: 'join', sessionId} or {resolution: 'force', reason}.",
36042
+ description: "Run one of this project's automations now (even if it is disabled or paused). If matching work is already running or queued, nothing starts and the result is 'conflict' with the existing work: choose the resolution yourself and call again with conflict = {resolution: 'wait'}, {resolution: 'skip'}, {resolution: 'join', sessionId} or {resolution: 'force', reason}.",
35483
36043
  roles: ["pm"],
35484
36044
  scope: "project",
35485
36045
  input: object({
@@ -35539,7 +36099,7 @@ const getIssue = defineTool({
35539
36099
  /** `triage_issue` (§8.3, §10 issue triage): labels, comment, accept or decline. */
35540
36100
  const triageIssue$1 = defineTool({
35541
36101
  name: "triage_issue",
35542
- description: "Record a triage decision on an issue: ready, needs_info, unsafe, out_of_scope or duplicate, with labels to add or remove and an optional comment (close: true for out of scope or duplicates). Follow the project's policy; ask the user when unsure.",
36102
+ description: "Record a triage decision on an issue: ready, needs_info, unsafe, out_of_scope or duplicate, with labels to add or remove and an optional comment (close: true for out of scope or duplicates). Follow the project's policy. When you can't decide, choose needs_info (ask the author the specific questions in the comment) or unsafe (it needs a human decision); don't wait on the user.",
35543
36103
  roles: ["pm"],
35544
36104
  scope: "project",
35545
36105
  isAvailable: inGithubProject,
@@ -35659,6 +36219,319 @@ const messageOrchestrator = defineTool({
35659
36219
  return "Sent: the orchestrator will be woken with it shortly.";
35660
36220
  }
35661
36221
  });
36222
+ // ../server/src/services/orchestration/notices.ts
36223
+ const clip$4 = (text, max = 300) => text.length > max ? `${text.slice(0, max - 3)}...` : text;
36224
+ /**
36225
+ * Work started by the PM, the orchestrator or an automation wakes the PM; sessions the user
36226
+ * started (and is watching) only produce notices that show up in the PM's next digest.
36227
+ */
36228
+ function wakesPm(session) {
36229
+ return session.startedBy.kind !== "user" || session.parentSessionId !== null;
36230
+ }
36231
+ /** Work a PM started for a request the orchestrator passed on (`startedBy.reportTo`). */
36232
+ function reportsToOrchestrator(session) {
36233
+ return session.startedBy.reportTo === "orchestrator";
36234
+ }
36235
+ /** A session notice, copied for the orchestrator so the result reaches whoever asked (§8.2). */
36236
+ function forOrchestrator(notice, id) {
36237
+ return {
36238
+ wake: true,
36239
+ notice: {
36240
+ ...notice,
36241
+ id,
36242
+ target: "orchestrator"
36243
+ }
36244
+ };
36245
+ }
36246
+ /** The id of an opened PR's notice: the same whether it came from the session or GitHub sync. */
36247
+ function prOpenedNoticeId(projectId, number) {
36248
+ return `pr.opened:${projectId}:${number}`;
36249
+ }
36250
+ function workRef(s) {
36251
+ return {
36252
+ sessionId: s.id,
36253
+ workKey: s.workKey,
36254
+ purpose: s.purpose,
36255
+ startedBy: s.startedBy
36256
+ };
36257
+ }
36258
+ /**
36259
+ * Typed notices for a batch of committed events (§8.2 step 4). Pure apart from the lookups.
36260
+ * Only agent sessions inside a project produce notices; ids are derived from the source so
36261
+ * replays and repeats never create duplicates.
36262
+ */
36263
+ function deriveNotices(events, lookup) {
36264
+ const out = [];
36265
+ const agentSession = (id) => {
36266
+ const s = id ? lookup.session(id) : void 0;
36267
+ return s && s.role === "agent" && s.projectId ? s : void 0;
36268
+ };
36269
+ for (const e of events) if (e.type === "agent.turn.completed") {
36270
+ const event = e.payload;
36271
+ const s = agentSession(e.sessionId);
36272
+ if (!s || s.rootThreadId !== event.threadId) continue;
36273
+ const final = lookup.finalMessage(s.id, event.turnId);
36274
+ const finished = {
36275
+ id: `session.finished:${s.id}:${event.turnId}`,
36276
+ type: "session.finished",
36277
+ projectId: s.projectId,
36278
+ target: "pm",
36279
+ sessionId: s.id,
36280
+ title: `Finished: ${s.title ?? s.purpose}`,
36281
+ data: {
36282
+ ...workRef(s),
36283
+ summary: s.result?.summary ?? (final ? clipKeepingEnd(final) : null),
36284
+ outcome: s.result?.outcome ?? null
36285
+ }
36286
+ };
36287
+ out.push({
36288
+ wake: wakesPm(s),
36289
+ notice: finished
36290
+ });
36291
+ if (reportsToOrchestrator(s) && s.result) out.push(forOrchestrator(finished, `session.finished:orchestrator:${s.id}:${s.result.at}`));
36292
+ } else if (e.type === "inbox.created") {
36293
+ const { item } = e.payload;
36294
+ const s = agentSession(item.sessionId);
36295
+ if (!s) continue;
36296
+ if (item.kind === "question" || item.kind === "approval") out.push({
36297
+ wake: wakesPm(s),
36298
+ notice: {
36299
+ id: `session.needs_input:${item.id}`,
36300
+ type: "session.needs_input",
36301
+ projectId: s.projectId,
36302
+ target: "pm",
36303
+ sessionId: s.id,
36304
+ title: `Needs input: ${s.title ?? s.purpose}`,
36305
+ data: {
36306
+ ...workRef(s),
36307
+ inboxItemId: item.id,
36308
+ kind: item.kind,
36309
+ question: item.title
36310
+ }
36311
+ }
36312
+ });
36313
+ else if (item.kind === "run_failed") {
36314
+ const failed = {
36315
+ id: `session.failed:${item.id}`,
36316
+ type: "session.failed",
36317
+ projectId: s.projectId,
36318
+ target: "pm",
36319
+ sessionId: s.id,
36320
+ title: `Failed: ${s.title ?? s.purpose}`,
36321
+ data: {
36322
+ ...workRef(s),
36323
+ error: clip$4(item.body ?? item.title)
36324
+ }
36325
+ };
36326
+ out.push({
36327
+ wake: wakesPm(s),
36328
+ notice: failed
36329
+ });
36330
+ if (reportsToOrchestrator(s)) out.push(forOrchestrator(failed, `session.failed:orchestrator:${item.id}`));
36331
+ }
36332
+ } else if (e.type === "session.updated") {
36333
+ const { session } = e.payload;
36334
+ if (session.role !== "agent" || !session.projectId || !session.pr) continue;
36335
+ out.push({
36336
+ wake: wakesPm(session),
36337
+ notice: {
36338
+ id: prOpenedNoticeId(session.projectId, session.pr.number),
36339
+ type: "pr.opened",
36340
+ projectId: session.projectId,
36341
+ target: "pm",
36342
+ sessionId: session.id,
36343
+ title: `PR #${session.pr.number} opened: ${session.pr.title ?? session.purpose}`,
36344
+ data: {
36345
+ sessionId: session.id,
36346
+ pr: session.pr
36347
+ }
36348
+ }
36349
+ });
36350
+ } else if (e.type === "automation_run.updated") {
36351
+ const { run } = e.payload;
36352
+ if (run.status !== "skipped" || !run.projectId) continue;
36353
+ out.push({
36354
+ wake: true,
36355
+ notice: {
36356
+ id: `automation.skipped:${run.id}`,
36357
+ type: "automation.skipped",
36358
+ projectId: run.projectId,
36359
+ target: "pm",
36360
+ sessionId: run.sessionId,
36361
+ title: `Automation skipped: ${run.purpose}`,
36362
+ data: {
36363
+ automationRunId: run.id,
36364
+ automationId: run.automationId,
36365
+ workKey: run.workKey,
36366
+ purpose: run.purpose,
36367
+ reason: run.skipReason ?? "skipped"
36368
+ }
36369
+ }
36370
+ });
36371
+ }
36372
+ return out;
36373
+ }
36374
+ /** Stored notices (§8.2): created through `notice.created`, delivered through `notice.delivered`. */
36375
+ var NoticeStore = class {
36376
+ db;
36377
+ store;
36378
+ constructor(db, store) {
36379
+ this.db = db;
36380
+ this.store = store;
36381
+ }
36382
+ /** Store a notice unless one with the same id exists. Returns the stored notice, if new. */
36383
+ create(draft) {
36384
+ if (this.db.select({ id: notices.id }).from(notices).where(eq(notices.id, draft.id)).get()) return;
36385
+ const notice = {
36386
+ ...draft,
36387
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
36388
+ deliveredAt: null
36389
+ };
36390
+ this.store.append([{
36391
+ type: "notice.created",
36392
+ sessionId: null,
36393
+ payload: { notice }
36394
+ }]);
36395
+ return notice;
36396
+ }
36397
+ list(query = {}) {
36398
+ const conditions = [];
36399
+ if (query.projectId) conditions.push(eq(notices.projectId, query.projectId));
36400
+ if (query.target) conditions.push(eq(notices.target, query.target));
36401
+ return this.db.select().from(notices).where(and(...conditions)).orderBy(desc(notices.createdAt)).limit(query.limit ?? 100).all().map((r) => r.notice);
36402
+ }
36403
+ /**
36404
+ * Undelivered notices for a target, oldest first. `projectId` null = the orchestrator. A
36405
+ * scheduled wake-up counts once it is due.
36406
+ */
36407
+ undelivered(target, projectId) {
36408
+ return this.db.select().from(notices).where(and(eq(notices.target, target), isNull(notices.deliveredAt), projectId === null ? void 0 : eq(notices.projectId, projectId), or$1(isNull(notices.notBefore), lte(notices.notBefore, (/* @__PURE__ */ new Date()).toISOString())))).orderBy(notices.createdAt).limit(50).all().map((r) => r.notice);
36409
+ }
36410
+ /** A target's scheduled wake-ups still to deliver (due or not), soonest first. */
36411
+ scheduled(target, projectId) {
36412
+ return this.db.select().from(notices).where(and(eq(notices.type, "reminder"), eq(notices.target, target), isNull(notices.deliveredAt), projectId === null ? isNull(notices.projectId) : eq(notices.projectId, projectId))).orderBy(asc(notices.notBefore)).all().map((r) => r.notice);
36413
+ }
36414
+ /** Undelivered notices held back until a time: the due ones, or the soonest still ahead. */
36415
+ heldBack(due) {
36416
+ const now = (/* @__PURE__ */ new Date()).toISOString();
36417
+ return this.db.select().from(notices).where(and(isNull(notices.deliveredAt), due ? lte(notices.notBefore, now) : gt$1(notices.notBefore, now))).orderBy(asc(notices.notBefore)).limit(due ? 200 : 1).all().map((r) => r.notice);
36418
+ }
36419
+ /** Notices of the given types for a project since `since`, newest first. */
36420
+ recent(projectId, types, since, limit = 5) {
36421
+ return this.db.select().from(notices).where(and(eq(notices.projectId, projectId), inArray(notices.type, types), gte(notices.createdAt, since))).orderBy(desc(notices.createdAt)).limit(limit).all().map((r) => r.notice);
36422
+ }
36423
+ markDelivered(ids) {
36424
+ if (ids.length === 0) return;
36425
+ this.store.append([{
36426
+ type: "notice.delivered",
36427
+ sessionId: null,
36428
+ payload: {
36429
+ noticeIds: ids,
36430
+ at: (/* @__PURE__ */ new Date()).toISOString()
36431
+ }
36432
+ }]);
36433
+ }
36434
+ /** Back to undelivered: the turn that carried them failed, so the next wake carries them. */
36435
+ markReturned(ids) {
36436
+ if (ids.length === 0) return;
36437
+ this.store.append([{
36438
+ type: "notice.returned",
36439
+ sessionId: null,
36440
+ payload: {
36441
+ noticeIds: ids,
36442
+ at: (/* @__PURE__ */ new Date()).toISOString()
36443
+ }
36444
+ }]);
36445
+ }
36446
+ };
36447
+ // ../server/src/mcp/tools/shared/session-items.ts
36448
+ /** A session's question item (`ask_user` or the provider's own) by its request id. */
36449
+ function questionItem(db, sessionId, requestId) {
36450
+ const rows = db.select().from(items).where(and(eq(items.sessionId, sessionId), eq(items.kind, "user_question"))).all();
36451
+ for (const row of rows) if (row.data.kind === "user_question" && row.data.requestId === requestId) return row.data;
36452
+ }
36453
+ /** The latest non-empty assistant message on a session's root thread. */
36454
+ function lastAssistantMessage(db, session) {
36455
+ if (!session.rootThreadId) return null;
36456
+ const rows = db.select().from(items).where(and(eq(items.sessionId, session.id), eq(items.threadId, session.rootThreadId), eq(items.kind, "assistant_message"))).orderBy(desc(items.position)).limit(5).all();
36457
+ for (const row of rows) if (row.data.kind === "assistant_message" && row.data.text.trim()) return row.data.text.trim();
36458
+ return null;
36459
+ }
36460
+ // ../server/src/mcp/tools/pm/questions.ts
36461
+ /**
36462
+ * `answer_question` (§8.2 step 5): the PM answers a question one of its project's agent
36463
+ * sessions asked, so the agent carries on without waiting for the user. Only questions from
36464
+ * work the PM, the orchestrator or an automation started; approvals and the questions of
36465
+ * sessions the user started stay with the user.
36466
+ */
36467
+ const answerQuestion = defineTool({
36468
+ name: "answer_question",
36469
+ description: "Answer a question one of this project's agent sessions asked (an open inbox item of kind 'question', named in a 'needs input' update), when its brief, your notes, the vision or a sensible default settles it. The agent gets the answer at once (or as its next message) and the inbox item closes as answered by you. Approvals, and questions from sessions the user started, stay with the user.",
36470
+ roles: ["pm"],
36471
+ scope: "project",
36472
+ alwaysLoad: true,
36473
+ input: object({
36474
+ inboxItemId: string().min(1),
36475
+ answer: string().min(1).max(1e4).describe("Your answer: an option's label or free text, with a short reason")
36476
+ }),
36477
+ handler: async ({ inboxItemId, answer }, { services, projectId }) => {
36478
+ const item = services.inbox.find(inboxItemId);
36479
+ if (!item) throw new ToolError(`No inbox item ${inboxItemId}`);
36480
+ if (item.status !== "open") throw new ToolError(`That question was already ${item.status}`);
36481
+ if (item.kind !== "question") throw new ToolError(item.kind === "approval" ? "Approvals stay with the user; you can only answer questions." : `Only questions can be answered here (this item is a ${item.kind}).`);
36482
+ if (!item.sessionId || !item.requestId) throw new ToolError("This question belongs to no agent session");
36483
+ const session = agentSessionOf(services, item.sessionId, projectId);
36484
+ if (!wakesPm(session)) throw new ToolError("The user started that session and answers its questions themselves.");
36485
+ const ids = questionItem(services.db, session.id, item.requestId)?.questions.map((q) => q.id) ?? ["q1"];
36486
+ const answers = Object.fromEntries(ids.map((id) => [id, [answer]]));
36487
+ await services.sessions.respond(session.id, {
36488
+ requestId: item.requestId,
36489
+ response: {
36490
+ kind: "answer",
36491
+ answers
36492
+ }
36493
+ }, "pm");
36494
+ return `Answered. ${session.title ?? session.purpose} (session ${session.id}) has your answer.`;
36495
+ }
36496
+ });
36497
+ // ../server/src/mcp/tools/pm/session-detail.ts
36498
+ /** Longest last message returned; longer ones keep their end, where questions usually are. */
36499
+ const MESSAGE_LIMIT = 8e3;
36500
+ /**
36501
+ * `get_session` (§8.4): one agent session in detail, so the PM can act on what it said or asked
36502
+ * without the user relaying it: its last message in full, what it waits on (with inbox ids, for
36503
+ * `answer_question`) and its result.
36504
+ */
36505
+ const getSession = defineTool({
36506
+ name: "get_session",
36507
+ description: "One of this project's agent sessions in detail: status, progress and result, its last message in full, and the questions and approvals it is waiting on with their inbox ids (answer questions with answer_question). Takes the full session id or a unique prefix of at least 6 characters, as updates and the status digest show them.",
36508
+ roles: ["pm"],
36509
+ scope: "project",
36510
+ alwaysLoad: true,
36511
+ input: object({ sessionId: string().min(1) }),
36512
+ handler: async ({ sessionId }, { services, projectId }) => {
36513
+ const session = agentSessionOf(services, sessionId, projectId);
36514
+ const last = lastAssistantMessage(services.db, session);
36515
+ const waitingOn = services.inbox.list({ sessionId: session.id }).filter((i) => i.kind === "question" || i.kind === "approval").map((i) => {
36516
+ const asked = i.requestId ? questionItem(services.db, session.id, i.requestId) : void 0;
36517
+ return {
36518
+ inboxItemId: i.id,
36519
+ kind: i.kind,
36520
+ title: i.title,
36521
+ body: i.body,
36522
+ ...asked ? { questions: asked.questions.map((q) => ({
36523
+ question: q.question,
36524
+ options: q.options.map((o) => o.label)
36525
+ })) } : {}
36526
+ };
36527
+ });
36528
+ return {
36529
+ session: compactSession(session),
36530
+ lastMessage: last && last.length > MESSAGE_LIMIT ? `…${last.slice(-8e3)}` : last ?? null,
36531
+ waitingOn
36532
+ };
36533
+ }
36534
+ });
35662
36535
  // ../server/src/mcp/tools/pm/sessions.ts
35663
36536
  const ENDED$3 = /* @__PURE__ */ new Set([
35664
36537
  "completed",
@@ -35695,12 +36568,12 @@ const listSessions = defineTool({
35695
36568
  /** `start_session` (§8.2, §8.4): start an agent session, with the conflict check built in. */
35696
36569
  const startSession = defineTool({
35697
36570
  name: "start_session",
35698
- description: "Start an agent session in its own worktree to do one piece of work. Always pass a workKey (issue:<n>, pr:<n>, automation:<name> or request:<slug>) and a one-line purpose; add scope (paths or area) when known. If matching work is already running or queued, nothing starts and the result is 'conflict' with the existing work: then call again with conflict = {resolution: 'wait'} (queue behind it), {resolution: 'skip'}, {resolution: 'join', sessionId} (send the prompt to the existing session) or {resolution: 'force', reason}.",
36571
+ description: "Start an agent session in its own worktree to do one piece of work. Always pass a workKey (issue:<n>, pr:<n>, automation:<name> or request:<slug>) and a one-line purpose; add scope (paths or area) when known. If matching work is already running or queued, nothing starts and the result is 'conflict' with the existing work: choose the resolution yourself and call again with conflict = {resolution: 'wait'} (queue behind it), {resolution: 'skip'} (it is the same work), {resolution: 'join', sessionId} (send the prompt to the existing session) or {resolution: 'force', reason}.",
35699
36572
  roles: ["pm"],
35700
36573
  scope: "project",
35701
36574
  alwaysLoad: true,
35702
36575
  input: object({
35703
- prompt: string().min(1).max(5e4).describe("The brief for the agent: goal, context, acceptance criteria"),
36576
+ prompt: string().min(1).max(5e4).describe("The brief for the agent: goal, context, acceptance criteria, and the decisions you already made (defaults, scope, whether to open a pull request), so the agent doesn't have to ask"),
35704
36577
  purpose: string().trim().min(1).max(300),
35705
36578
  workKey: WorkKey.optional(),
35706
36579
  scope: object({
@@ -35712,7 +36585,8 @@ const startSession = defineTool({
35712
36585
  provider: ProviderId.optional(),
35713
36586
  model: string().min(1).optional(),
35714
36587
  effort: ReasoningEffort.optional(),
35715
- conflict: ConflictDecision.optional()
36588
+ conflict: ConflictDecision.optional(),
36589
+ reportTo: _enum(["orchestrator"]).optional().describe("'orchestrator' when the orchestrator passed this work on: its finish or failure also reaches the orchestrator, so it can tell the user")
35716
36590
  }),
35717
36591
  handler: async (input, { services, projectId, sessionId }) => {
35718
36592
  const project = projectFor(services, projectId);
@@ -35733,7 +36607,8 @@ const startSession = defineTool({
35733
36607
  issue,
35734
36608
  startedBy: {
35735
36609
  kind: "pm",
35736
- id: sessionId ?? void 0
36610
+ id: sessionId ?? void 0,
36611
+ reportTo: input.reportTo
35737
36612
  },
35738
36613
  parentSessionId: sessionId ?? void 0,
35739
36614
  input: { text: input.prompt },
@@ -35761,7 +36636,7 @@ const startSession = defineTool({
35761
36636
  };
35762
36637
  case "conflict": return {
35763
36638
  status: "conflict",
35764
- message: "Matching work is already running or queued; nothing was started. Report it, or call start_session again with a conflict resolution (wait, skip, join with sessionId, force with reason).",
36639
+ message: "Matching work is already running or queued; nothing was started. Choose a resolution yourself and call start_session again with it: wait (queue behind it), skip (it is the same work), join with sessionId (it adds to that work) or force with a reason. Then say in one line what you chose.",
35765
36640
  existing: result.existing.map(compactWork)
35766
36641
  };
35767
36642
  }
@@ -35780,8 +36655,8 @@ const messageSession = defineTool({
35780
36655
  whenBusy: _enum(["queue", "steer"]).optional()
35781
36656
  }),
35782
36657
  handler: async ({ sessionId, text, whenBusy }, { services, projectId }) => {
35783
- agentSessionOf(services, sessionId, projectId);
35784
- return `Message ${(await services.sessions.send(sessionId, {
36658
+ const session = agentSessionOf(services, sessionId, projectId);
36659
+ return `Message ${(await services.sessions.send(session.id, {
35785
36660
  input: messageFrom("pm", text),
35786
36661
  whenBusy: whenBusy ?? "queue"
35787
36662
  }, "pm")).status}.`;
@@ -35800,10 +36675,10 @@ const stopSession = defineTool({
35800
36675
  }),
35801
36676
  handler: async ({ sessionId }, { services, projectId }) => {
35802
36677
  const session = agentSessionOf(services, sessionId, projectId);
35803
- if (ENDED$3.has(session.status)) throw new ToolError(`Session ${sessionId} already ${session.status}`);
36678
+ if (ENDED$3.has(session.status)) throw new ToolError(`Session ${session.id} already ${session.status}`);
35804
36679
  return {
35805
- status: (await services.sessions.stop(sessionId)).status,
35806
- sessionId
36680
+ status: (await services.sessions.stop(session.id)).status,
36681
+ sessionId: session.id
35807
36682
  };
35808
36683
  }
35809
36684
  });
@@ -35823,9 +36698,11 @@ const pmTools = [
35823
36698
  handler: async (_input, { services, projectId }) => renderProjectDigest(services.orchestration.projectStatus(projectId))
35824
36699
  }),
35825
36700
  listSessions,
36701
+ getSession,
35826
36702
  startSession,
35827
36703
  messageSession,
35828
36704
  stopSession,
36705
+ answerQuestion,
35829
36706
  defineTool({
35830
36707
  name: "list_scheduled_runs",
35831
36708
  description: "Upcoming scheduled automation runs of this project, soonest first.",
@@ -36139,31 +37016,96 @@ const shareToChat = defineTool({
36139
37016
  function extensionFor(mediaType) {
36140
37017
  return Object.entries(MEDIA_TYPES$1).find(([, type]) => type === mediaType)?.[0] ?? ".bin";
36141
37018
  }
37019
+ // ../server/src/mcp/tools/suggest-followups.ts
37020
+ /** `suggest_followups` (§7.5): next prompts shown as chips when the turn completes. */
37021
+ const suggestFollowups = defineTool({
37022
+ name: "suggest_followups",
37023
+ description: "Suggest 2 to 4 short next prompts the user might send after this turn, written as the user would type them (for example 'Add tests for the empty-input case'). Call it as the last action of every turn. Calling it again in the same turn replaces the earlier suggestions.",
37024
+ roles: ["agent"],
37025
+ scope: "session",
37026
+ alwaysLoad: true,
37027
+ input: object({ suggestions: array(string().min(1).max(300)).min(2).max(4).describe("2-4 follow-up prompts, most useful first") }),
37028
+ handler: async ({ suggestions }, { services, sessionId }) => {
37029
+ const turn = currentTurn(services, sessionId);
37030
+ putTurnItem(services, sessionId, turn, {
37031
+ id: `followups-${turn.turnId}`,
37032
+ kind: "followups",
37033
+ status: "completed",
37034
+ source: "agent",
37035
+ suggestions: suggestions.map((prompt) => ({ prompt }))
37036
+ });
37037
+ return "Follow-ups saved; they are shown to the user when the turn ends.";
37038
+ }
37039
+ });
37040
+ // ../server/src/mcp/tools/wakeups.ts
37041
+ /** The orchestrator, or the PM of the caller's project. */
37042
+ function ownerOf(ctx) {
37043
+ if (ctx.role === "orchestrator") return {
37044
+ target: "orchestrator",
37045
+ projectId: null,
37046
+ sessionId: ctx.sessionId
37047
+ };
37048
+ if (!ctx.projectId) throw new ToolError("Only the orchestrator or a project's PM has wake-ups");
37049
+ return {
37050
+ target: "pm",
37051
+ projectId: ctx.projectId,
37052
+ sessionId: ctx.sessionId
37053
+ };
37054
+ }
37055
+ /** The wake-up tools of the orchestrator and PMs. */
37056
+ const wakeupTools = [
37057
+ defineTool({
37058
+ name: "schedule_wakeup",
37059
+ description: "Schedule a wake-up for yourself: at that time CodeDen wakes you with your note (and the usual status digest), so you can follow up later (check on running work, retry something, report back) without waiting for the user. Give inMinutes (1 to 10080) or an ISO 8601 time in at, no more than 7 days ahead. At most 20 pending at once.",
37060
+ roles: ["orchestrator", "pm"],
37061
+ scope: "session",
37062
+ alwaysLoad: true,
37063
+ input: object({
37064
+ inMinutes: int().min(1).max(10080).optional(),
37065
+ at: datetime({ offset: true }).optional().describe("When, e.g. 2026-11-11T09:00:00Z"),
37066
+ note: string().trim().min(1).max(2e3).describe("What to do when you wake up, for your future self")
37067
+ }).refine((v) => v.inMinutes === void 0 !== (v.at === void 0), { message: "Give exactly one of inMinutes or at" }),
37068
+ handler: async ({ inMinutes, at, note }, ctx) => {
37069
+ const when = at ? new Date(at) : new Date(Date.now() + (inMinutes ?? 0) * 6e4);
37070
+ const reminder = ctx.services.orchestration.reminders.schedule(ownerOf(ctx), when, note);
37071
+ return `Wake-up ${reminder.id} scheduled for ${reminder.notBefore}. You will be woken with your note then; nothing more to do now.`;
37072
+ }
37073
+ }),
37074
+ defineTool({
37075
+ name: "list_wakeups",
37076
+ description: "Your pending wake-ups (from schedule_wakeup), soonest first.",
37077
+ roles: ["orchestrator", "pm"],
37078
+ scope: "session",
37079
+ input: object({}),
37080
+ handler: async (_input, ctx) => {
37081
+ const owner = ownerOf(ctx);
37082
+ return { wakeups: ctx.services.orchestration.reminders.pending(owner.target, owner.projectId).flatMap((n) => n.type === "reminder" ? [{
37083
+ id: n.id,
37084
+ at: n.notBefore,
37085
+ note: n.data.note
37086
+ }] : []) };
37087
+ }
37088
+ }),
37089
+ defineTool({
37090
+ name: "cancel_wakeup",
37091
+ description: "Cancel one of your pending wake-ups by its id (from list_wakeups).",
37092
+ roles: ["orchestrator", "pm"],
37093
+ scope: "session",
37094
+ input: object({ id: string().min(1) }),
37095
+ handler: async ({ id }, ctx) => {
37096
+ const owner = ownerOf(ctx);
37097
+ if (!ctx.services.orchestration.reminders.cancel(owner.target, owner.projectId, id)) throw new ToolError(`You have no pending wake-up ${id}. Call list_wakeups for the ids.`);
37098
+ return `Wake-up ${id} cancelled.`;
37099
+ }
37100
+ })
37101
+ ];
36142
37102
  // ../server/src/mcp/tools/index.ts
36143
37103
  /** Every CodeDen tool, in the order agents see them. Add new tools here (one file each). */
36144
37104
  const allTools = [
36145
37105
  reportProgress,
36146
37106
  askUser,
36147
37107
  shareToChat,
36148
- defineTool({
36149
- name: "suggest_followups",
36150
- description: "Suggest 2 to 4 short next prompts the user might send after this turn, written as the user would type them (for example 'Add tests for the empty-input case'). Call it as the last action of every turn. Calling it again in the same turn replaces the earlier suggestions.",
36151
- roles: ["agent"],
36152
- scope: "session",
36153
- alwaysLoad: true,
36154
- input: object({ suggestions: array(string().min(1).max(300)).min(2).max(4).describe("2-4 follow-up prompts, most useful first") }),
36155
- handler: async ({ suggestions }, { services, sessionId }) => {
36156
- const turn = currentTurn(services, sessionId);
36157
- putTurnItem(services, sessionId, turn, {
36158
- id: `followups-${turn.turnId}`,
36159
- kind: "followups",
36160
- status: "completed",
36161
- source: "agent",
36162
- suggestions: suggestions.map((prompt) => ({ prompt }))
36163
- });
36164
- return "Follow-ups saved; they are shown to the user when the turn ends.";
36165
- }
36166
- }),
37108
+ suggestFollowups,
36167
37109
  finish,
36168
37110
  browserNavigate,
36169
37111
  browserSnapshot,
@@ -36178,65 +37120,18 @@ const allTools = [
36178
37120
  generateImage,
36179
37121
  ...orchestratorTools,
36180
37122
  ...pmTools,
37123
+ ...wakeupTools,
36181
37124
  ...githubTools,
36182
37125
  ...factoryTools
36183
37126
  ];
36184
37127
  // ../server/src/services/agent-config/paths.ts
36185
37128
  /**
36186
- * Claude Code's folder name for a project under `~/.claude/projects/`: every character that
36187
- * is not an ASCII letter or digit becomes `-` (`C:\Projects\codeden` -> `C--Projects-codeden`,
36188
- * `C:\Users\me\.cfg` -> `C--Users-me--cfg`). Verified against real folder names on Windows.
36189
- */
36190
- function claudeProjectSlug(projectPath) {
36191
- return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
36192
- }
36193
- /**
36194
37129
  * The project's Claude auto-memory folder (§12.4): `autoMemoryDirectory` from the user's
36195
37130
  * `~/.claude/settings.json` when set, else `~/.claude/projects/<slug>/memory`. The slug comes
36196
- * from the git repository root, so every worktree of a repo shares one folder. Very long
36197
- * slugs are shortened by Claude Code; an existing folder with the same prefix is used then.
37131
+ * from the git repository root, so every worktree of a repo shares one folder.
36198
37132
  */
36199
37133
  function claudeAutoMemoryDir(home, repoRoot) {
36200
- const custom = readJson(join(home, ".claude", "settings.json"))?.autoMemoryDirectory;
36201
- if (typeof custom === "string" && custom.trim()) {
36202
- const value = custom.trim();
36203
- return value.startsWith("~/") || value.startsWith("~\\") ? join(home, value.slice(2)) : resolve(value);
36204
- }
36205
- const projects = join(home, ".claude", "projects");
36206
- const slug = claudeProjectSlug(repoRoot);
36207
- const direct = join(projects, slug);
36208
- if (slug.length > 200 && !existsSync(direct) && existsSync(projects)) {
36209
- const prefix = slug.slice(0, 200).toLowerCase();
36210
- const match = readdirSync(projects).find((name) => name.toLowerCase().startsWith(prefix));
36211
- if (match) return join(projects, match, "memory");
36212
- }
36213
- return join(direct, "memory");
36214
- }
36215
- /** Nearest ancestor (or `dir` itself) containing `.git` (a folder, or a file in worktrees). */
36216
- function findGitRoot(dir) {
36217
- let current = resolve(dir);
36218
- for (;;) {
36219
- if (existsSync(join(current, ".git"))) return current;
36220
- const parent = dirname(current);
36221
- if (parent === current) return null;
36222
- current = parent;
36223
- }
36224
- }
36225
- /** The main checkout of a worktree (`.git` file -> `gitdir: <main>/.git/worktrees/x`). */
36226
- function mainRepoRoot(gitRoot) {
36227
- const marker = join(gitRoot, ".git");
36228
- try {
36229
- if (!statSync(marker).isFile()) return gitRoot;
36230
- const line = readFileSync(marker, "utf8").trim();
36231
- const gitdir = /^gitdir:\s*(.+)$/m.exec(line)?.[1];
36232
- if (!gitdir) return gitRoot;
36233
- const absolute = resolve(gitRoot, gitdir);
36234
- const at = absolute.split(sep).lastIndexOf("worktrees");
36235
- if (at < 1) return gitRoot;
36236
- return dirname(absolute.split(sep).slice(0, at).join(sep));
36237
- } catch {
36238
- return gitRoot;
36239
- }
37134
+ return claudeMemoryDir(join(home, ".claude"), repoRoot, home);
36240
37135
  }
36241
37136
  /** `dir` and its ancestors, nearest first, up to the filesystem root. */
36242
37137
  function ancestors(dir) {
@@ -45130,7 +46025,7 @@ const NOTICE_EVENTS = {
45130
46025
  "session.failed": "session.failed",
45131
46026
  "session.needs_input": "session.needs_input"
45132
46027
  };
45133
- const PR_EVENTS = /* @__PURE__ */ new Set([
46028
+ const PR_EVENTS$1 = /* @__PURE__ */ new Set([
45134
46029
  "pr.opened",
45135
46030
  "pr.merged",
45136
46031
  "pr.checks_failed"
@@ -45189,7 +46084,7 @@ function triggerEvents(events, session) {
45189
46084
  author: pr.author,
45190
46085
  sessionId: pr.sessionId
45191
46086
  });
45192
- } else if (PR_EVENTS.has(e.type)) {
46087
+ } else if (PR_EVENTS$1.has(e.type)) {
45193
46088
  const { pr } = e.payload;
45194
46089
  out.push({
45195
46090
  event: e.type,
@@ -45885,6 +46780,43 @@ var CronScheduler = class {
45885
46780
  this.timer.unref();
45886
46781
  }
45887
46782
  };
46783
+ // ../server/src/services/automations/triage-prompts.ts
46784
+ /**
46785
+ * The texts of issue triage (§10): the request a triage run sends to the PM, and the brief of
46786
+ * the agent session that resolves a ready issue. Pure, so tests can check the wording.
46787
+ */
46788
+ /** The PM's instructions for one triage run. `canStart`: ready issues start a session. */
46789
+ function triageRequestText(opts) {
46790
+ const { automation, run, issue, policy } = opts;
46791
+ const labels = Object.entries(policy.labels).map(([decision, names]) => ` - ${decision}: ${names.length ? names.join(", ") : "(none)"}`).join("\n");
46792
+ return [
46793
+ `Issue triage (automation "${automation.name}", run ${run.id}).`,
46794
+ `New issue #${issue.number}: ${issue.title ?? ""}`,
46795
+ issue.url,
46796
+ "",
46797
+ "1. Read it with get_issue (and list_issues to look for duplicates). Compare it with the project notes, the vision and the work already running.",
46798
+ "2. Classify it as exactly one of: ready (clear, in scope, safe to implement), needs_info (unclear or missing reproduction), unsafe (touches protected areas or needs a human decision), out_of_scope, duplicate.",
46799
+ `3. Apply it with triage_issue using these labels:\n${labels}`,
46800
+ policy.comment ? " Include a short, friendly comment explaining the decision (for needs_info, ask the specific questions)." : " Do not comment on the issue.",
46801
+ policy.closeRejected ? " Close duplicates and out-of-scope issues (close: true)." : " Do not close the issue.",
46802
+ "4. Then call report_triage with the issue number, the decision and a two-sentence summary.",
46803
+ opts.canStart ? " For ready issues CodeDen starts an agent session automatically; do not call start_session yourself." : " For ready issues CodeDen asks the user whether to take it on; do not call start_session yourself.",
46804
+ "Decide the classification yourself and don't call ask_user: nobody answers during a triage run, and a run that waits never reports. When you can't tell, choose needs_info or unsafe and say why in the summary.",
46805
+ automation.action.type === "triage" && automation.action.instructions ? `\nProject guidance:\n${automation.action.instructions}` : ""
46806
+ ].filter((l) => l !== "").join("\n");
46807
+ }
46808
+ /** The brief of the agent session that resolves a triaged issue. */
46809
+ function issueSessionPrompt(issue, summary) {
46810
+ const title = issue.title ?? `Issue #${issue.number}`;
46811
+ return [
46812
+ `Resolve GitHub issue #${issue.number}: ${title}`,
46813
+ issue.url,
46814
+ summary ? `\nTriage summary: ${summary}` : "",
46815
+ "",
46816
+ `Read the full issue and its comments first (\`gh issue view ${issue.number} --comments\`). Implement the change in your worktree following the project's conventions, add or update tests that prove it, and run the test suite.`,
46817
+ `Then open a pull request with create_pull_request whose body includes "Fixes #${issue.number}", attach evidence (test output, and screenshots for UI changes) with attach_evidence, and call finish. If a detail is unclear, make the reasonable call within the issue's scope and state the assumption in the pull request description. Ask with ask_user only if the change needs a protected area or a destructive or outward-facing step.`
46818
+ ].filter((l) => l !== "").join("\n");
46819
+ }
45888
46820
  // ../server/src/services/automations/triage.ts
45889
46821
  /** Inbox id of the "Should I take this on?" decision for an issue. */
45890
46822
  function triageItemId(projectId, issueNumber) {
@@ -45910,22 +46842,13 @@ var TriageService = class {
45910
46842
  async request(project, automation, run, issue) {
45911
46843
  if (project.kind !== "github") throw badRequest("Triage needs a GitHub project", "not_github");
45912
46844
  const policy = policyOf(automation);
45913
- const labels = Object.entries(policy.labels).map(([decision, names]) => ` - ${decision}: ${names.length ? names.join(", ") : "(none)"}`).join("\n");
45914
- const canStart = policy.autoStart && project.autonomyMode !== "manual";
45915
- const text = [
45916
- `Issue triage (automation "${automation.name}", run ${run.id}).`,
45917
- `New issue #${issue.number}: ${issue.title ?? ""}`,
45918
- issue.url,
45919
- "",
45920
- "1. Read it with get_issue (and list_issues to look for duplicates). Compare it with the project notes, the vision and the work already running.",
45921
- "2. Classify it as exactly one of: ready (clear, in scope, safe to implement), needs_info (unclear or missing reproduction), unsafe (touches protected areas or needs a human decision), out_of_scope, duplicate.",
45922
- `3. Apply it with triage_issue using these labels:\n${labels}`,
45923
- policy.comment ? " Include a short, friendly comment explaining the decision (for needs_info, ask the specific questions)." : " Do not comment on the issue.",
45924
- policy.closeRejected ? " Close duplicates and out-of-scope issues (close: true)." : " Do not close the issue.",
45925
- "4. Then call report_triage with the issue number, the decision and a two-sentence summary.",
45926
- canStart ? " For ready issues CodeDen starts an agent session automatically; do not call start_session yourself." : " For ready issues CodeDen asks the user whether to take it on; do not call start_session yourself.",
45927
- automation.action.type === "triage" && automation.action.instructions ? `\nProject guidance:\n${automation.action.instructions}` : ""
45928
- ].filter((l) => l !== "").join("\n");
46845
+ const text = triageRequestText({
46846
+ automation,
46847
+ run,
46848
+ issue,
46849
+ policy,
46850
+ canStart: policy.autoStart && project.autonomyMode !== "manual"
46851
+ });
45929
46852
  return (await this.deps.orchestration().sendToRole("pm", project.id, {
45930
46853
  text,
45931
46854
  attachments: []
@@ -46025,14 +46948,7 @@ var TriageService = class {
46025
46948
  }
46026
46949
  async startForIssue(project, issue, summary, policy, startedBy, decision) {
46027
46950
  const title = issue.title ?? `Issue #${issue.number}`;
46028
- const prompt = [
46029
- `Resolve GitHub issue #${issue.number}: ${title}`,
46030
- issue.url,
46031
- summary ? `\nTriage summary: ${summary}` : "",
46032
- "",
46033
- `Read the full issue and its comments first (\`gh issue view ${issue.number} --comments\`). Implement the change in your worktree following the project's conventions, add or update tests that prove it, and run the test suite.`,
46034
- `Then open a pull request with create_pull_request whose body includes "Fixes #${issue.number}", attach evidence (test output, and screenshots for UI changes) with attach_evidence, and call finish. If the issue turns out to be unclear or unsafe, ask with ask_user instead of guessing.`
46035
- ].filter((l) => l !== "").join("\n");
46951
+ const prompt = issueSessionPrompt(issue, summary);
46036
46952
  return this.deps.sessions.start(StartSessionRequest.parse({
46037
46953
  projectId: project.id,
46038
46954
  role: "agent",
@@ -46501,7 +47417,8 @@ const runTool = (command, args, opts = {}) => {
46501
47417
  env: mergeEnv(env, {
46502
47418
  NO_COLOR: "1",
46503
47419
  FORCE_COLOR: "0",
46504
- GIT_TERMINAL_PROMPT: "0"
47420
+ GIT_TERMINAL_PROMPT: "0",
47421
+ GCM_INTERACTIVE: "never"
46505
47422
  }),
46506
47423
  windowsHide: true,
46507
47424
  windowsVerbatimArguments: isShim,
@@ -52470,6 +53387,21 @@ var PrFollowUp = class {
52470
53387
  for (const number of issues) await commentOnIssue({ client: this.deps.client }, repo, number, `Resolved by #${pr.number}, merged${pr.mergedAt ? ` at ${pr.mergedAt}` : ""}.\n\n<sub>CodeDen session ${label}</sub>`);
52471
53388
  }
52472
53389
  };
53390
+ // ../server/src/services/sessions/own-branch.ts
53391
+ /**
53392
+ * The branch a session owns: its own worktree's branch, never its base branch or a usual
53393
+ * default branch. The agent may force-push it with a lease (§11.2).
53394
+ */
53395
+ function ownBranch(session) {
53396
+ const { branch, worktreePath, baseBranch } = session;
53397
+ if (!branch || !worktreePath || branch === baseBranch) return void 0;
53398
+ return [
53399
+ "main",
53400
+ "master",
53401
+ "develop",
53402
+ "trunk"
53403
+ ].includes(branch) ? void 0 : branch;
53404
+ }
52473
53405
  // ../server/src/services/github/graphql.ts
52474
53406
  /** Batched GraphQL reads of pull requests: state, checks, reviews, mergeability, commits. */
52475
53407
  const PR_FIELDS = `
@@ -52646,6 +53578,31 @@ function pullRequestBody(text, issue, session) {
52646
53578
  parts.push(`---\n<sub>Opened by CodeDen session ${label}</sub>\n${SESSION_MARKER}${session.id} -->`);
52647
53579
  return parts.filter(Boolean).join("\n\n");
52648
53580
  }
53581
+ /** git's answer when a push would rewrite the remote branch's history. */
53582
+ const NOT_FAST_FORWARD = /\((non-fast-forward|fetch first)\)|updates were rejected/i;
53583
+ /**
53584
+ * Push the session's branch. When the session owns it (its own worktree's branch) and the push
53585
+ * is rejected as not a fast-forward, as after a rebase, push again with a lease: that rewrites
53586
+ * only the session's own work, and refuses if someone else pushed there since.
53587
+ */
53588
+ async function pushBranch(deps, session, cwd, branch) {
53589
+ const push = {
53590
+ name: "git.push",
53591
+ cwd,
53592
+ branch,
53593
+ setUpstream: true
53594
+ };
53595
+ try {
53596
+ await deps.runner.request(push, 3e5);
53597
+ } catch (error) {
53598
+ const message = error instanceof Error ? error.message : String(error);
53599
+ if (ownBranch(session) !== branch || !NOT_FAST_FORWARD.test(message)) throw error;
53600
+ await deps.runner.request({
53601
+ ...push,
53602
+ forceWithLease: true
53603
+ }, 3e5);
53604
+ }
53605
+ }
52649
53606
  /**
52650
53607
  * Push the session's branch through the runner and open (or update) its pull request (§8.2,
52651
53608
  * §9). The PR is projected right away (`pr.opened` / `pr.updated`) and linked to the session.
@@ -52670,12 +53627,7 @@ async function createPullRequest(deps, session, repo, input) {
52670
53627
  message: input.commitMessage
52671
53628
  }, 3e5)).commit;
52672
53629
  }
52673
- await deps.runner.request({
52674
- name: "git.push",
52675
- cwd,
52676
- branch,
52677
- setUpstream: true
52678
- }, 3e5);
53630
+ await pushBranch(deps, session, cwd, branch);
52679
53631
  } catch (error) {
52680
53632
  throw toServiceError$1(error);
52681
53633
  }
@@ -53891,28 +54843,37 @@ var GithubServicePort = class {
53891
54843
  // ../server/src/services/orchestration/asks.ts
53892
54844
  /** The marker that ties a PM turn to an `ask_pm` call. */
53893
54845
  const askMarker = (askId) => `(ask ${askId})`;
54846
+ const MARKER = /\(ask ([0-9a-f]{8})\)/g;
54847
+ /** Asks answered recently (kept so a turn's answer is never sent twice). */
54848
+ const SETTLED_LIMIT = 500;
53894
54849
  const MAX_AGE_MS = 216e5;
53895
54850
  /**
53896
54851
  * `ask_pm` replies (§8.3). The question goes to the PM as a message carrying a marker; the
53897
54852
  * PM's turn that handles it is found by the marker in its user message, and the final
53898
54853
  * assistant message of that turn is the answer (or a `message_orchestrator` call with
53899
54854
  * `replyTo`, whichever comes first). If the call times out, the answer still reaches the
53900
- * orchestrator later, as a message.
54855
+ * orchestrator later, as a message. So does the answer to an ask nothing tracks any more (a
54856
+ * restart, an ask older than six hours): the marker in the turn's input is enough.
53901
54857
  */
53902
54858
  var PmAsks = class {
53903
54859
  deps;
53904
54860
  waiters = /* @__PURE__ */ new Map();
54861
+ settled = /* @__PURE__ */ new Set();
53905
54862
  constructor(deps) {
53906
54863
  this.deps = deps;
53907
54864
  }
53908
- register(askId, target) {
54865
+ /**
54866
+ * Track an ask. A `detached` ask has no call waiting for it (the loop guard held the
54867
+ * question back): its answer reaches the orchestrator as a message.
54868
+ */
54869
+ register(askId, target, opts = {}) {
53909
54870
  this.prune();
53910
54871
  this.waiters.set(askId, {
53911
54872
  askId,
53912
54873
  ...target,
53913
54874
  turnId: void 0,
53914
54875
  resolve: void 0,
53915
- late: false,
54876
+ late: opts.detached ?? false,
53916
54877
  createdAt: Date.now()
53917
54878
  });
53918
54879
  }
@@ -53949,9 +54910,10 @@ var PmAsks = class {
53949
54910
  }
53950
54911
  /** Follow committed events: find the answering turn, then its final message. */
53951
54912
  onEvents(events) {
53952
- if (this.waiters.size === 0) return;
53953
54913
  for (const e of events) {
53954
54914
  if (!e.sessionId) continue;
54915
+ if (e.type === "agent.turn.completed") this.answerUntracked(e.sessionId, e.payload.turnId);
54916
+ if (this.waiters.size === 0) continue;
53955
54917
  if (e.type === "agent.item.started" || e.type === "agent.item.completed") {
53956
54918
  const event = e.payload;
53957
54919
  const item = event.item;
@@ -53973,16 +54935,50 @@ var PmAsks = class {
53973
54935
  }
53974
54936
  finish(waiter, text) {
53975
54937
  this.waiters.delete(waiter.askId);
54938
+ this.settle(waiter.askId);
53976
54939
  if (waiter.resolve) waiter.resolve(text);
53977
54940
  else if (waiter.late) {
53978
- const reply = {
53979
- projectId: waiter.projectId,
53980
- pmSessionId: waiter.pmSessionId,
54941
+ const { askId, projectId, pmSessionId } = waiter;
54942
+ setImmediate(() => this.deps.onLateReply({
54943
+ askId,
54944
+ projectId,
54945
+ pmSessionId,
53981
54946
  text
53982
- };
53983
- setImmediate(() => this.deps.onLateReply(reply));
54947
+ }));
53984
54948
  }
53985
54949
  }
54950
+ /**
54951
+ * A PM's turn completed: answer the asks in its input that no waiter tracks (registered
54952
+ * before a restart, or pruned). Tracked ones are answered by their waiter instead.
54953
+ */
54954
+ answerUntracked(sessionId, turnId) {
54955
+ const session = this.deps.session(sessionId);
54956
+ if (session?.role !== "pm" || !session.projectId) return;
54957
+ const projectId = session.projectId;
54958
+ const askIds = /* @__PURE__ */ new Set();
54959
+ for (const row of this.deps.db.select().from(items).where(and(eq(items.sessionId, sessionId), eq(items.turnId, turnId), eq(items.kind, "user_message"))).all()) {
54960
+ if (row.data.kind !== "user_message") continue;
54961
+ for (const m of row.data.input.text.matchAll(MARKER)) if (m[1]) askIds.add(m[1]);
54962
+ }
54963
+ const untracked = [...askIds].filter((id) => !this.waiters.has(id) && !this.settled.has(id));
54964
+ if (untracked.length === 0) return;
54965
+ const text = this.finalMessage(sessionId, turnId) ?? "(The PM finished without a reply.)";
54966
+ for (const askId of untracked) {
54967
+ this.settle(askId);
54968
+ setImmediate(() => this.deps.onLateReply({
54969
+ askId,
54970
+ projectId,
54971
+ pmSessionId: sessionId,
54972
+ text
54973
+ }));
54974
+ }
54975
+ }
54976
+ settle(askId) {
54977
+ this.settled.add(askId);
54978
+ if (this.settled.size <= SETTLED_LIMIT) return;
54979
+ const oldest = this.settled.values().next().value;
54980
+ if (oldest !== void 0) this.settled.delete(oldest);
54981
+ }
53986
54982
  finalMessage(sessionId, turnId) {
53987
54983
  return finalAssistantMessage(this.deps.db, sessionId, turnId);
53988
54984
  }
@@ -53997,6 +54993,52 @@ function finalAssistantMessage(db, sessionId, turnId) {
53997
54993
  for (const row of rows) if (row.data.kind === "assistant_message" && row.data.text.trim()) return row.data.text.trim();
53998
54994
  return null;
53999
54995
  }
54996
+ // ../server/src/services/orchestration/cards.ts
54997
+ /**
54998
+ * Notices a wake (or any message) delivered to a role session, shown as one batch of cards at
54999
+ * the start of the turn that carries them (§8.2: notices stay structured data up to the UI).
55000
+ */
55001
+ var NoticeCards = class {
55002
+ store;
55003
+ pending = /* @__PURE__ */ new Map();
55004
+ constructor(store) {
55005
+ this.store = store;
55006
+ }
55007
+ /** Notices delivered with the next turn of `sessionId`. */
55008
+ add(sessionId, notices) {
55009
+ this.pending.set(sessionId, [...this.pending.get(sessionId) ?? [], ...notices]);
55010
+ }
55011
+ /** Forget notices a turn was going to carry: its send failed, they wait for the next one. */
55012
+ remove(sessionId, ids) {
55013
+ const left = (this.pending.get(sessionId) ?? []).filter((n) => !ids.includes(n.id));
55014
+ if (left.length > 0) this.pending.set(sessionId, left);
55015
+ else this.pending.delete(sessionId);
55016
+ }
55017
+ /** Append the waiting cards to each role session whose turn just started. */
55018
+ attach(events) {
55019
+ for (const e of events) {
55020
+ if (e.type !== "agent.turn.started" || !e.sessionId) continue;
55021
+ const cards = this.pending.get(e.sessionId);
55022
+ if (!cards) continue;
55023
+ this.pending.delete(e.sessionId);
55024
+ const event = e.payload;
55025
+ this.store.append([agentEventToNewEvent(e.sessionId, {
55026
+ type: "item.completed",
55027
+ threadId: event.threadId,
55028
+ turnId: event.turnId,
55029
+ item: {
55030
+ id: `notices-${event.turnId}`,
55031
+ kind: "notice",
55032
+ status: "completed",
55033
+ level: "info",
55034
+ code: NOTICE_BATCH_CODE,
55035
+ message: `${cards.length} update${cards.length === 1 ? "" : "s"} since the last turn`,
55036
+ data: JSON.parse(JSON.stringify({ notices: cards }))
55037
+ }
55038
+ })]);
55039
+ }
55040
+ }
55041
+ };
54000
55042
  // ../server/src/services/orchestration/conflicts.ts
54001
55043
  /**
54002
55044
  * Persisted conflicts (§8.4): a start request that matched running work becomes an inbox
@@ -54115,184 +55157,116 @@ var ConflictService = class {
54115
55157
  });
54116
55158
  }
54117
55159
  };
54118
- // ../server/src/services/orchestration/notices.ts
54119
- const clip$2 = (text, max = 300) => text.length > max ? `${text.slice(0, max - 3)}...` : text;
54120
- /**
54121
- * Work started by the PM, the orchestrator or an automation wakes the PM; sessions the user
54122
- * started (and is watching) only produce notices that show up in the PM's next digest.
54123
- */
54124
- function wakesPm(session) {
54125
- return session.startedBy.kind !== "user" || session.parentSessionId !== null;
54126
- }
54127
- /** The id of an opened PR's notice: the same whether it came from the session or GitHub sync. */
54128
- function prOpenedNoticeId(projectId, number) {
54129
- return `pr.opened:${projectId}:${number}`;
54130
- }
54131
- function workRef(s) {
54132
- return {
54133
- sessionId: s.id,
54134
- workKey: s.workKey,
54135
- purpose: s.purpose,
54136
- startedBy: s.startedBy
54137
- };
55160
+ // ../server/src/services/orchestration/delivery.ts
55161
+ /** Whether a stored notice should wake its target (older notices didn't record it). */
55162
+ function wakesTarget(n) {
55163
+ if (n.wake !== void 0) return n.wake;
55164
+ return !(n.type === "pm.message" && n.data.informational);
54138
55165
  }
55166
+ const keyOf$2 = (session) => session.role === "pm" ? `pm:${session.projectId}` : "orchestrator";
54139
55167
  /**
54140
- * Typed notices for a batch of committed events (§8.2 step 4). Pure apart from the lookups.
54141
- * Only agent sessions inside a project produce notices; ids are derived from the source so
54142
- * replays and repeats never create duplicates.
54143
- */
54144
- function deriveNotices(events, lookup) {
54145
- const out = [];
54146
- const agentSession = (id) => {
54147
- const s = id ? lookup.session(id) : void 0;
54148
- return s && s.role === "agent" && s.projectId ? s : void 0;
54149
- };
54150
- for (const e of events) if (e.type === "agent.turn.completed") {
54151
- const event = e.payload;
54152
- const s = agentSession(e.sessionId);
54153
- if (!s || s.rootThreadId !== event.threadId) continue;
54154
- const final = lookup.finalMessage(s.id, event.turnId);
54155
- out.push({
54156
- wake: wakesPm(s),
54157
- notice: {
54158
- id: `session.finished:${s.id}:${event.turnId}`,
54159
- type: "session.finished",
54160
- projectId: s.projectId,
54161
- target: "pm",
54162
- sessionId: s.id,
54163
- title: `Finished: ${s.title ?? s.purpose}`,
54164
- data: {
54165
- ...workRef(s),
54166
- summary: s.result?.summary ?? (final ? clip$2(final) : null),
54167
- outcome: s.result?.outcome ?? null
54168
- }
54169
- }
54170
- });
54171
- } else if (e.type === "inbox.created") {
54172
- const { item } = e.payload;
54173
- const s = agentSession(item.sessionId);
54174
- if (!s) continue;
54175
- if (item.kind === "question" || item.kind === "approval") out.push({
54176
- wake: wakesPm(s),
54177
- notice: {
54178
- id: `session.needs_input:${item.id}`,
54179
- type: "session.needs_input",
54180
- projectId: s.projectId,
54181
- target: "pm",
54182
- sessionId: s.id,
54183
- title: `Needs input: ${s.title ?? s.purpose}`,
54184
- data: {
54185
- ...workRef(s),
54186
- inboxItemId: item.id,
54187
- kind: item.kind,
54188
- question: item.title
54189
- }
54190
- }
54191
- });
54192
- else if (item.kind === "run_failed") out.push({
54193
- wake: wakesPm(s),
54194
- notice: {
54195
- id: `session.failed:${item.id}`,
54196
- type: "session.failed",
54197
- projectId: s.projectId,
54198
- target: "pm",
54199
- sessionId: s.id,
54200
- title: `Failed: ${s.title ?? s.purpose}`,
54201
- data: {
54202
- ...workRef(s),
54203
- error: clip$2(item.body ?? item.title)
54204
- }
54205
- }
54206
- });
54207
- } else if (e.type === "session.updated") {
54208
- const { session } = e.payload;
54209
- if (session.role !== "agent" || !session.projectId || !session.pr) continue;
54210
- out.push({
54211
- wake: wakesPm(session),
54212
- notice: {
54213
- id: prOpenedNoticeId(session.projectId, session.pr.number),
54214
- type: "pr.opened",
54215
- projectId: session.projectId,
54216
- target: "pm",
54217
- sessionId: session.id,
54218
- title: `PR #${session.pr.number} opened: ${session.pr.title ?? session.purpose}`,
54219
- data: {
54220
- sessionId: session.id,
54221
- pr: session.pr
54222
- }
54223
- }
54224
- });
54225
- } else if (e.type === "automation_run.updated") {
54226
- const { run } = e.payload;
54227
- if (run.status !== "skipped" || !run.projectId) continue;
54228
- out.push({
54229
- wake: true,
54230
- notice: {
54231
- id: `automation.skipped:${run.id}`,
54232
- type: "automation.skipped",
54233
- projectId: run.projectId,
54234
- target: "pm",
54235
- sessionId: run.sessionId,
54236
- title: `Automation skipped: ${run.purpose}`,
54237
- data: {
54238
- automationRunId: run.id,
54239
- automationId: run.automationId,
54240
- workKey: run.workKey,
54241
- purpose: run.purpose,
54242
- reason: run.skipReason ?? "skipped"
54243
- }
54244
- }
54245
- });
55168
+ * Getting notices to the orchestrator and the PMs (§8.2) reliably:
55169
+ *
55170
+ * - a turn's notices count as delivered only once the runner took the turn; until then they
55171
+ * are claimed, so a message sent meanwhile doesn't carry them twice;
55172
+ * - on start, targets with wake-worthy notices still undelivered (a restart cut the wake off)
55173
+ * are woken again;
55174
+ * - when a role session's turn that carried notices fails (an overloaded model, say), the
55175
+ * notices go back to undelivered and the session is woken again a few times, backing off.
55176
+ */
55177
+ var NoticeDelivery = class {
55178
+ deps;
55179
+ claimed = /* @__PURE__ */ new Set();
55180
+ failures = /* @__PURE__ */ new Map();
55181
+ timers = /* @__PURE__ */ new Set();
55182
+ constructor(deps) {
55183
+ this.deps = deps;
54246
55184
  }
54247
- return out;
54248
- }
54249
- /** Stored notices (§8.2): created through `notice.created`, delivered through `notice.delivered`. */
54250
- var NoticeStore = class {
54251
- db;
54252
- store;
54253
- constructor(db, store) {
54254
- this.db = db;
54255
- this.store = store;
55185
+ /** Prepend the status digest and the undelivered notices to a role session's input (§8.4). */
55186
+ decorate(session, input) {
55187
+ if (session.role === "agent") return { input };
55188
+ try {
55189
+ const parts = [this.deps.digestFor(session)];
55190
+ const pending = this.deps.notices.undelivered(session.role === "pm" ? "pm" : "orchestrator", session.role === "pm" ? session.projectId : null).filter((n) => !this.claimed.has(n.id));
55191
+ parts.push(...pending.length > 0 ? [renderNotices(pending, this.deps.projectName)] : []);
55192
+ parts.push(input.text);
55193
+ const ids = pending.map((n) => n.id);
55194
+ for (const id of ids) this.claimed.add(id);
55195
+ if (pending.length > 0) this.deps.cards.add(session.id, pending);
55196
+ const release = () => {
55197
+ for (const id of ids) this.claimed.delete(id);
55198
+ };
55199
+ return {
55200
+ input: {
55201
+ ...input,
55202
+ text: parts.join("\n\n")
55203
+ },
55204
+ delivered: () => {
55205
+ release();
55206
+ this.deps.notices.markDelivered(ids);
55207
+ },
55208
+ failed: () => {
55209
+ release();
55210
+ this.deps.cards.remove(session.id, ids);
55211
+ }
55212
+ };
55213
+ } catch (error) {
55214
+ this.deps.log?.(`orchestration digest: ${errorMessage$1(error)}`);
55215
+ return { input };
55216
+ }
54256
55217
  }
54257
- /** Store a notice unless one with the same id exists. Returns the stored notice, if new. */
54258
- create(draft) {
54259
- if (this.db.select({ id: notices.id }).from(notices).where(eq(notices.id, draft.id)).get()) return;
54260
- const notice = {
54261
- ...draft,
54262
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
54263
- deliveredAt: null
54264
- };
54265
- this.store.append([{
54266
- type: "notice.created",
54267
- sessionId: null,
54268
- payload: { notice }
54269
- }]);
54270
- return notice;
55218
+ /** Wake every target whose wake-worthy notices are still undelivered (server start). */
55219
+ rearm() {
55220
+ if (this.deps.notices.undelivered("orchestrator", null).some(wakesTarget)) this.deps.wakes.request("orchestrator");
55221
+ for (const id of this.deps.projectIds()) if (this.deps.notices.undelivered("pm", id).some(wakesTarget)) this.deps.wakes.request(`pm:${id}`);
54271
55222
  }
54272
- list(query = {}) {
54273
- const conditions = [];
54274
- if (query.projectId) conditions.push(eq(notices.projectId, query.projectId));
54275
- if (query.target) conditions.push(eq(notices.target, query.target));
54276
- return this.db.select().from(notices).where(and(...conditions)).orderBy(desc(notices.createdAt)).limit(query.limit ?? 100).all().map((r) => r.notice);
55223
+ /** Follow role sessions' root turns: a failed one returns its notices and retries. */
55224
+ onEvents(events) {
55225
+ for (const e of events) {
55226
+ if (e.type !== "agent.turn.completed" && e.type !== "agent.turn.failed") continue;
55227
+ const session = e.sessionId ? this.deps.session(e.sessionId) : void 0;
55228
+ if (!session || session.role === "agent") continue;
55229
+ const event = e.payload;
55230
+ if (session.rootThreadId && session.rootThreadId !== event.threadId) continue;
55231
+ if (e.type === "agent.turn.completed") {
55232
+ this.failures.delete(session.id);
55233
+ continue;
55234
+ }
55235
+ this.turnFailed(session, event.turnId);
55236
+ }
54277
55237
  }
54278
- /** Undelivered notices for a target, oldest first. `projectId` null = the orchestrator. */
54279
- undelivered(target, projectId) {
54280
- return this.db.select().from(notices).where(and(eq(notices.target, target), isNull(notices.deliveredAt), projectId === null ? void 0 : eq(notices.projectId, projectId))).orderBy(notices.createdAt).limit(50).all().map((r) => r.notice);
55238
+ dispose() {
55239
+ for (const timer of this.timers) clearTimeout(timer);
55240
+ this.timers.clear();
54281
55241
  }
54282
- /** Notices of the given types for a project since `since`, newest first. */
54283
- recent(projectId, types, since, limit = 5) {
54284
- return this.db.select().from(notices).where(and(eq(notices.projectId, projectId), inArray(notices.type, types), gte(notices.createdAt, since))).orderBy(desc(notices.createdAt)).limit(limit).all().map((r) => r.notice);
55242
+ turnFailed(session, turnId) {
55243
+ const carried = this.carried(session.id, turnId);
55244
+ if (carried.length === 0) return;
55245
+ this.deps.notices.markReturned(carried);
55246
+ const attempt = (this.failures.get(session.id) ?? 0) + 1;
55247
+ this.failures.set(session.id, attempt);
55248
+ const delay = (this.deps.retryDelaysMs ?? [
55249
+ 3e4,
55250
+ 12e4,
55251
+ 6e5
55252
+ ])[attempt - 1];
55253
+ if (delay === void 0) {
55254
+ this.deps.log?.(`orchestration: ${keyOf$2(session)} keeps failing; its updates wait for its next message`);
55255
+ return;
55256
+ }
55257
+ const timer = setTimeout(() => {
55258
+ this.timers.delete(timer);
55259
+ this.deps.wakes.request(keyOf$2(session));
55260
+ }, delay);
55261
+ timer.unref?.();
55262
+ this.timers.add(timer);
54285
55263
  }
54286
- markDelivered(ids) {
54287
- if (ids.length === 0) return;
54288
- this.store.append([{
54289
- type: "notice.delivered",
54290
- sessionId: null,
54291
- payload: {
54292
- noticeIds: ids,
54293
- at: (/* @__PURE__ */ new Date()).toISOString()
54294
- }
54295
- }]);
55264
+ /** Ids of the notices a turn carried (its batch of cards). */
55265
+ carried(sessionId, turnId) {
55266
+ const row = this.deps.db.select().from(items).where(and(eq(items.sessionId, sessionId), eq(items.turnId, turnId), eq(items.id, `notices-${turnId}`))).get();
55267
+ if (row?.data.kind !== "notice") return [];
55268
+ const batch = NoticeBatchData.safeParse(row.data.data);
55269
+ return batch.success ? batch.data.notices.map((n) => n.id) : [];
54296
55270
  }
54297
55271
  };
54298
55272
  // ../server/src/services/orchestration/github-notices.ts
@@ -54305,7 +55279,7 @@ const GITHUB_NOTICE_EVENTS = /* @__PURE__ */ new Set([
54305
55279
  "issue.opened",
54306
55280
  "review.submitted"
54307
55281
  ]);
54308
- const clip$1 = (text, max = 300) => text.length > max ? `${text.slice(0, max - 3)}...` : text;
55282
+ const clip$3 = (text, max = 300) => text.length > max ? `${text.slice(0, max - 3)}...` : text;
54309
55283
  function prRef(pr) {
54310
55284
  return {
54311
55285
  number: pr.number,
@@ -54441,7 +55415,7 @@ function deriveGithubNotices(events, session) {
54441
55415
  pr: prRef(pr),
54442
55416
  reviewer: review.author,
54443
55417
  state: review.state,
54444
- body: clip$1(review.body)
55418
+ body: clip$3(review.body)
54445
55419
  }
54446
55420
  }
54447
55421
  });
@@ -54449,6 +55423,243 @@ function deriveGithubNotices(events, session) {
54449
55423
  }
54450
55424
  return out;
54451
55425
  }
55426
+ // ../server/src/services/orchestration/wake-scheduler.ts
55427
+ /**
55428
+ * Batches notices into debounced wakes per target (`pm:<projectId>`, `orchestrator`) with a
55429
+ * loop guard (§8.2): notices arriving close together become one "updates since your last
55430
+ * turn" wake, and no target is woken more than `maxWakes` times per window. Held-back wakes
55431
+ * fire as soon as the window allows, or right after `reset` when a person steps in; nothing
55432
+ * is dropped. The orchestration service runs one for notice wakes and one for the
55433
+ * orchestrator's messages to PMs (`LoopGuard`), so each conversation is counted on its own.
55434
+ */
55435
+ var WakeScheduler = class {
55436
+ fire;
55437
+ pending = /* @__PURE__ */ new Map();
55438
+ history = /* @__PURE__ */ new Map();
55439
+ opts;
55440
+ constructor(fire, opts = {}) {
55441
+ this.fire = fire;
55442
+ this.opts = {
55443
+ debounceMs: opts.debounceMs ?? 5e3,
55444
+ maxWaitMs: opts.maxWaitMs ?? 3e4,
55445
+ maxWakes: opts.maxWakes ?? 6,
55446
+ windowMs: opts.windowMs ?? 6e5,
55447
+ maxRetryMs: opts.maxRetryMs ?? 12e4,
55448
+ maxBusyRetries: opts.maxBusyRetries ?? 20,
55449
+ onGaveUp: opts.onGaveUp,
55450
+ now: opts.now ?? Date.now,
55451
+ setTimer: opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms)),
55452
+ clearTimer: opts.clearTimer ?? ((h) => clearTimeout(h)),
55453
+ onSuppressed: opts.onSuppressed
55454
+ };
55455
+ }
55456
+ /** A wake-worthy notice arrived for `key`: (re)start its debounce. */
55457
+ request(key) {
55458
+ const now = this.opts.now();
55459
+ const entry = this.pending.get(key);
55460
+ if (entry) {
55461
+ if (entry.firing) {
55462
+ entry.again = true;
55463
+ return;
55464
+ }
55465
+ if (entry.suppressed) return;
55466
+ entry.retries = 0;
55467
+ this.opts.clearTimer(entry.timer);
55468
+ const at = Math.min(now + this.opts.debounceMs, entry.firstAt + this.opts.maxWaitMs);
55469
+ entry.timer = this.arm(key, at - now);
55470
+ return;
55471
+ }
55472
+ this.pending.set(key, {
55473
+ firstAt: now,
55474
+ timer: this.arm(key, this.opts.debounceMs),
55475
+ suppressed: false,
55476
+ retries: 0,
55477
+ firing: false,
55478
+ again: false
55479
+ });
55480
+ }
55481
+ /** Whether `key` may be woken now (loop guard). */
55482
+ allowed(key) {
55483
+ return this.recent(key).length < this.opts.maxWakes;
55484
+ }
55485
+ /** When the loop guard lets `key` be woken again (now, while it is under the limit). */
55486
+ freeAt(key) {
55487
+ const recent = this.recent(key);
55488
+ const over = recent.length - this.opts.maxWakes;
55489
+ return over < 0 ? this.opts.now() : (recent[over] ?? this.opts.now()) + this.opts.windowMs;
55490
+ }
55491
+ /** Count a wake that happened outside the scheduler (a direct message). */
55492
+ recordWake(key) {
55493
+ this.history.set(key, [...this.recent(key), this.opts.now()]);
55494
+ }
55495
+ /**
55496
+ * A person stepped in: forget `key`'s counted wakes, so whatever follows is not part of a
55497
+ * loop, and let a wake the guard was holding back go out after the usual quiet period.
55498
+ */
55499
+ reset(key) {
55500
+ this.history.delete(key);
55501
+ const entry = this.pending.get(key);
55502
+ if (!entry?.suppressed || entry.firing) return;
55503
+ entry.suppressed = false;
55504
+ this.opts.clearTimer(entry.timer);
55505
+ entry.timer = this.arm(key, this.opts.debounceMs);
55506
+ }
55507
+ /** Whether a wake is waiting for `key`. */
55508
+ isPending(key) {
55509
+ return this.pending.has(key);
55510
+ }
55511
+ dispose() {
55512
+ for (const entry of this.pending.values()) this.opts.clearTimer(entry.timer);
55513
+ this.pending.clear();
55514
+ }
55515
+ recent(key) {
55516
+ const since = this.opts.now() - this.opts.windowMs;
55517
+ const kept = (this.history.get(key) ?? []).filter((t) => t > since);
55518
+ this.history.set(key, kept);
55519
+ return kept;
55520
+ }
55521
+ arm(key, ms) {
55522
+ return this.opts.setTimer(() => void this.run(key), Math.max(0, ms));
55523
+ }
55524
+ async run(key) {
55525
+ const entry = this.pending.get(key);
55526
+ if (!entry || entry.firing) return;
55527
+ if (!this.allowed(key)) {
55528
+ const until = this.freeAt(key);
55529
+ if (!entry.suppressed) this.opts.onSuppressed?.(key, until);
55530
+ entry.suppressed = true;
55531
+ entry.timer = this.arm(key, until - this.opts.now() + 1);
55532
+ return;
55533
+ }
55534
+ let result;
55535
+ entry.firing = true;
55536
+ entry.again = false;
55537
+ try {
55538
+ result = await this.fire(key);
55539
+ } catch {
55540
+ result = "busy";
55541
+ } finally {
55542
+ entry.firing = false;
55543
+ }
55544
+ if (this.pending.get(key) !== entry) return;
55545
+ if (entry.again) {
55546
+ entry.again = false;
55547
+ entry.retries = 0;
55548
+ if (result === "sent") this.recordWake(key);
55549
+ if (result !== "busy") {
55550
+ entry.firstAt = this.opts.now();
55551
+ entry.timer = this.arm(key, this.opts.debounceMs);
55552
+ return;
55553
+ }
55554
+ }
55555
+ if (result === "busy") {
55556
+ entry.suppressed = false;
55557
+ entry.retries++;
55558
+ if (entry.retries > this.opts.maxBusyRetries) {
55559
+ this.pending.delete(key);
55560
+ this.opts.onGaveUp?.(key);
55561
+ return;
55562
+ }
55563
+ const backoff = this.opts.debounceMs * 2 ** (entry.retries - 1);
55564
+ entry.timer = this.arm(key, Math.min(backoff, this.opts.maxRetryMs));
55565
+ return;
55566
+ }
55567
+ this.pending.delete(key);
55568
+ if (result === "sent") this.recordWake(key);
55569
+ }
55570
+ };
55571
+ // ../server/src/services/orchestration/loop-guard.ts
55572
+ const pmKey = (projectId) => `pm:${projectId ?? ""}`;
55573
+ /**
55574
+ * Loop protection between role sessions (§8.2). A loop is agents messaging each other with no
55575
+ * person in between: the orchestrator and a PM, or a PM and its sessions. So the guard:
55576
+ *
55577
+ * - counts each conversation on its own: the orchestrator's messages to a PM here, notice
55578
+ * wakes (a PM's sessions reporting, PM messages to the orchestrator) in the `wakes`
55579
+ * scheduler. A PM busy with its sessions' reports never blocks the orchestrator, or the
55580
+ * other way round;
55581
+ * - never holds back what the user asked for: a message sent through the UI, or by the
55582
+ * orchestrator in a turn the user started, always goes straight through, and like any
55583
+ * message the user writes to a role session it clears that session's counts;
55584
+ * - holds back, never refuses, the rest: past the limit an orchestrator message is stored as
55585
+ * an `orchestrator.message` notice and reaches the PM with its next wake, at the latest when
55586
+ * the window allows another message. Nobody has to wait and retry.
55587
+ */
55588
+ var LoopGuard = class {
55589
+ deps;
55590
+ /** The orchestrator's messages to each PM (`pm:<projectId>`) from turns it started itself. */
55591
+ relays;
55592
+ /** Who sent the input that started each role session's latest turn. */
55593
+ turnFrom = /* @__PURE__ */ new Map();
55594
+ constructor(deps) {
55595
+ this.deps = deps;
55596
+ this.relays = new WakeScheduler((key) => this.deliverHeld(key.slice(3)), {
55597
+ ...deps.options,
55598
+ onSuppressed: (key, until) => deps.log?.(`orchestration: loop guard holds the orchestrator's messages to ${key} until ${new Date(until).toISOString()}`),
55599
+ onGaveUp: (key) => deps.log?.(`orchestration: ${key} stayed busy; held messages wait for its next wake`)
55600
+ });
55601
+ }
55602
+ /** A role session's turn starts with input from `from`; the user's input clears its counts. */
55603
+ turnStarted(session, from) {
55604
+ if (session.role === "agent") return;
55605
+ this.turnFrom.set(session.id, from);
55606
+ if (from !== "user") return;
55607
+ this.clear(session.role === "pm" ? pmKey(session.projectId) : "orchestrator");
55608
+ }
55609
+ /** Whether a message carries the user's own request (the UI, or a turn the user started). */
55610
+ humanLed(origin) {
55611
+ if (origin.caller === "ui") return true;
55612
+ return origin.sessionId !== null && this.turnFrom.get(origin.sessionId) === "user";
55613
+ }
55614
+ /**
55615
+ * Whether an orchestrator message may go to the PM of `projectId` now. The user's requests
55616
+ * always may (and clear the PM's counts); others count against the limit, and wait behind
55617
+ * messages already held so none overtakes another. When this is false, `hold` the message.
55618
+ */
55619
+ mayDeliver(projectId, origin) {
55620
+ const key = pmKey(projectId);
55621
+ if (this.humanLed(origin)) {
55622
+ this.clear(key);
55623
+ return true;
55624
+ }
55625
+ if (!this.relays.allowed(key) || this.relays.isPending(key)) return false;
55626
+ this.relays.recordWake(key);
55627
+ return true;
55628
+ }
55629
+ /**
55630
+ * Keep an orchestrator message for the PM of `projectId` and deliver it with the PM's next
55631
+ * wake. Returns when the guard lets it go (any earlier wake of the PM takes it along, and a
55632
+ * PM in the middle of a turn gets it once the turn ends).
55633
+ */
55634
+ hold(projectId, fromSessionId, text) {
55635
+ this.deps.notices.create({
55636
+ id: `orchestrator.message:${randomUUID()}`,
55637
+ type: "orchestrator.message",
55638
+ projectId,
55639
+ target: "pm",
55640
+ sessionId: fromSessionId,
55641
+ title: "Message from the orchestrator",
55642
+ data: {
55643
+ fromSessionId,
55644
+ text
55645
+ }
55646
+ });
55647
+ const key = pmKey(projectId);
55648
+ this.relays.request(key);
55649
+ return this.relays.freeAt(key);
55650
+ }
55651
+ dispose() {
55652
+ this.relays.dispose();
55653
+ }
55654
+ /** Wake the PM for its held messages; another wake may have delivered them already. */
55655
+ async deliverHeld(projectId) {
55656
+ return this.deps.notices.undelivered("pm", projectId).some((n) => n.type === "orchestrator.message") ? this.deps.wakePm(projectId) : "empty";
55657
+ }
55658
+ clear(key) {
55659
+ this.deps.wakes.reset(key);
55660
+ this.relays.reset(key);
55661
+ }
55662
+ };
54452
55663
  // ../server/src/services/orchestration/ports.ts
54453
55664
  const unavailable = (what, code) => new ServiceError(503, code, `${what} is not available in this CodeDen build yet.`);
54454
55665
  /** GitHub stub: every call fails with `github_unavailable`. */
@@ -54484,6 +55695,94 @@ var UnavailableAutomationPort = class {
54484
55695
  throw unavailable("Automation scheduling", "automations_unavailable");
54485
55696
  }
54486
55697
  };
55698
+ // ../server/src/services/orchestration/reminders.ts
55699
+ const MAX_DELAY_MS = 6048e5;
55700
+ /** Longest timer wait, so clock changes and sleep are noticed. */
55701
+ const MAX_TIMER_MS$1 = 6e4;
55702
+ const keyOf$1 = (n) => n.target === "orchestrator" ? "orchestrator" : `pm:${n.projectId ?? ""}`;
55703
+ /**
55704
+ * Wake-ups the orchestrator and PMs schedule for themselves (`schedule_wakeup`, §8.2), so they
55705
+ * can follow up later (check on a running audit, retry tomorrow) without the user prompting
55706
+ * them. Each is a `reminder` notice held back until `notBefore`: stored in the event log, so
55707
+ * it survives restarts, and delivered like any other notice, with a wake once it is due.
55708
+ */
55709
+ var Reminders = class {
55710
+ deps;
55711
+ timer;
55712
+ requested = /* @__PURE__ */ new Set();
55713
+ stopped = true;
55714
+ constructor(deps) {
55715
+ this.deps = deps;
55716
+ }
55717
+ start() {
55718
+ this.stopped = false;
55719
+ this.tick();
55720
+ }
55721
+ dispose() {
55722
+ this.stopped = true;
55723
+ if (this.timer) clearTimeout(this.timer);
55724
+ this.timer = void 0;
55725
+ }
55726
+ /** Schedule a wake-up for `owner` at `at` with `note`. */
55727
+ schedule(owner, at, note) {
55728
+ const delay = at.getTime() - Date.now();
55729
+ const min = this.deps.minDelayMs ?? 6e4;
55730
+ if (!Number.isFinite(delay) || delay < min) throw badRequest(`A wake-up must be at least ${Math.ceil(min / 6e4)} minute(s) ahead`, "wakeup_too_soon");
55731
+ if (delay > MAX_DELAY_MS) throw badRequest("A wake-up can be at most 7 days ahead", "wakeup_too_late");
55732
+ if (this.pending(owner.target, owner.projectId).length >= 20) throw conflict(`You already have 20 wake-ups scheduled; cancel one first`, "too_many_wakeups");
55733
+ const notice = this.deps.notices.create({
55734
+ id: `reminder:${randomUUID()}`,
55735
+ type: "reminder",
55736
+ projectId: owner.projectId,
55737
+ target: owner.target,
55738
+ sessionId: null,
55739
+ title: "Wake-up you scheduled",
55740
+ wake: true,
55741
+ notBefore: at.toISOString(),
55742
+ data: {
55743
+ note,
55744
+ requestedBy: owner.sessionId
55745
+ }
55746
+ });
55747
+ if (!notice) throw conflict("That wake-up already exists", "wakeup_exists");
55748
+ this.tick();
55749
+ return notice;
55750
+ }
55751
+ /** An owner's wake-ups still to come (or due and not yet delivered), soonest first. */
55752
+ pending(target, projectId) {
55753
+ return this.deps.notices.scheduled(target, projectId);
55754
+ }
55755
+ /** Cancel one of an owner's pending wake-ups; false when it has none with that id. */
55756
+ cancel(target, projectId, id) {
55757
+ if (!this.pending(target, projectId).some((n) => n.id === id)) return false;
55758
+ this.deps.notices.markDelivered([id]);
55759
+ return true;
55760
+ }
55761
+ /** Wake the owners of wake-ups that just came due, then wait for the next one. */
55762
+ tick() {
55763
+ if (this.stopped) return;
55764
+ try {
55765
+ for (const n of this.deps.notices.heldBack(true)) {
55766
+ if (this.requested.has(n.id)) continue;
55767
+ this.requested.add(n.id);
55768
+ this.deps.wakes.request(keyOf$1(n));
55769
+ }
55770
+ } catch (error) {
55771
+ this.deps.log?.(`orchestration wake-ups: ${error instanceof Error ? error.message : error}`);
55772
+ }
55773
+ this.arm();
55774
+ }
55775
+ arm() {
55776
+ if (this.timer) clearTimeout(this.timer);
55777
+ this.timer = void 0;
55778
+ if (this.stopped) return;
55779
+ const next = this.deps.notices.heldBack(false)[0]?.notBefore;
55780
+ if (!next) return;
55781
+ const wait = Math.min(Math.max(Date.parse(next) - Date.now(), 0), MAX_TIMER_MS$1);
55782
+ this.timer = setTimeout(() => this.tick(), wait + 5);
55783
+ this.timer.unref?.();
55784
+ }
55785
+ };
54487
55786
  // ../server/src/services/orchestration/role-sessions.ts
54488
55787
  /**
54489
55788
  * The long-lived role sessions (§8.1): one orchestrator (cwd `<home>/orchestrator/`) and one
@@ -54550,132 +55849,6 @@ var RoleSessions = class {
54550
55849
  return session;
54551
55850
  }
54552
55851
  };
54553
- // ../server/src/services/orchestration/wake-scheduler.ts
54554
- /**
54555
- * Batches notices into debounced wakes per target (`pm:<projectId>`, `orchestrator`) with a
54556
- * loop guard (§8.2): notices arriving close together become one "updates since your last
54557
- * turn" wake, and no target is woken more than `maxWakes` times per window. Held-back wakes
54558
- * fire as soon as the window allows; nothing is dropped.
54559
- */
54560
- var WakeScheduler = class {
54561
- fire;
54562
- pending = /* @__PURE__ */ new Map();
54563
- history = /* @__PURE__ */ new Map();
54564
- opts;
54565
- constructor(fire, opts = {}) {
54566
- this.fire = fire;
54567
- this.opts = {
54568
- debounceMs: opts.debounceMs ?? 5e3,
54569
- maxWaitMs: opts.maxWaitMs ?? 3e4,
54570
- maxWakes: opts.maxWakes ?? 6,
54571
- windowMs: opts.windowMs ?? 6e5,
54572
- maxRetryMs: opts.maxRetryMs ?? 12e4,
54573
- maxBusyRetries: opts.maxBusyRetries ?? 20,
54574
- onGaveUp: opts.onGaveUp,
54575
- now: opts.now ?? Date.now,
54576
- setTimer: opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms)),
54577
- clearTimer: opts.clearTimer ?? ((h) => clearTimeout(h)),
54578
- onSuppressed: opts.onSuppressed
54579
- };
54580
- }
54581
- /** A wake-worthy notice arrived for `key`: (re)start its debounce. */
54582
- request(key) {
54583
- const now = this.opts.now();
54584
- const entry = this.pending.get(key);
54585
- if (entry) {
54586
- if (entry.firing) {
54587
- entry.again = true;
54588
- return;
54589
- }
54590
- if (entry.suppressed) return;
54591
- entry.retries = 0;
54592
- this.opts.clearTimer(entry.timer);
54593
- const at = Math.min(now + this.opts.debounceMs, entry.firstAt + this.opts.maxWaitMs);
54594
- entry.timer = this.arm(key, at - now);
54595
- return;
54596
- }
54597
- this.pending.set(key, {
54598
- firstAt: now,
54599
- timer: this.arm(key, this.opts.debounceMs),
54600
- suppressed: false,
54601
- retries: 0,
54602
- firing: false,
54603
- again: false
54604
- });
54605
- }
54606
- /** Whether `key` may be woken now (loop guard). */
54607
- allowed(key) {
54608
- return this.recent(key).length < this.opts.maxWakes;
54609
- }
54610
- /** Count a wake that happened outside the scheduler (a direct message). */
54611
- recordWake(key) {
54612
- this.history.set(key, [...this.recent(key), this.opts.now()]);
54613
- }
54614
- /** Whether a wake is waiting for `key`. */
54615
- isPending(key) {
54616
- return this.pending.has(key);
54617
- }
54618
- dispose() {
54619
- for (const entry of this.pending.values()) this.opts.clearTimer(entry.timer);
54620
- this.pending.clear();
54621
- }
54622
- recent(key) {
54623
- const since = this.opts.now() - this.opts.windowMs;
54624
- const kept = (this.history.get(key) ?? []).filter((t) => t > since);
54625
- this.history.set(key, kept);
54626
- return kept;
54627
- }
54628
- arm(key, ms) {
54629
- return this.opts.setTimer(() => void this.run(key), Math.max(0, ms));
54630
- }
54631
- async run(key) {
54632
- const entry = this.pending.get(key);
54633
- if (!entry || entry.firing) return;
54634
- const recent = this.recent(key);
54635
- if (recent.length >= this.opts.maxWakes) {
54636
- const until = (recent[0] ?? this.opts.now()) + this.opts.windowMs;
54637
- if (!entry.suppressed) this.opts.onSuppressed?.(key, until);
54638
- entry.suppressed = true;
54639
- entry.timer = this.arm(key, until - this.opts.now() + 1);
54640
- return;
54641
- }
54642
- let result;
54643
- entry.firing = true;
54644
- entry.again = false;
54645
- try {
54646
- result = await this.fire(key);
54647
- } catch {
54648
- result = "busy";
54649
- } finally {
54650
- entry.firing = false;
54651
- }
54652
- if (this.pending.get(key) !== entry) return;
54653
- if (entry.again) {
54654
- entry.again = false;
54655
- entry.retries = 0;
54656
- if (result === "sent") this.recordWake(key);
54657
- if (result !== "busy") {
54658
- entry.firstAt = this.opts.now();
54659
- entry.timer = this.arm(key, this.opts.debounceMs);
54660
- return;
54661
- }
54662
- }
54663
- if (result === "busy") {
54664
- entry.suppressed = false;
54665
- entry.retries++;
54666
- if (entry.retries > this.opts.maxBusyRetries) {
54667
- this.pending.delete(key);
54668
- this.opts.onGaveUp?.(key);
54669
- return;
54670
- }
54671
- const backoff = this.opts.debounceMs * 2 ** (entry.retries - 1);
54672
- entry.timer = this.arm(key, Math.min(backoff, this.opts.maxRetryMs));
54673
- return;
54674
- }
54675
- this.pending.delete(key);
54676
- if (result === "sent") this.recordWake(key);
54677
- }
54678
- };
54679
55852
  // ../server/src/services/orchestration/orchestration-service.ts
54680
55853
  const NOT_READY = /* @__PURE__ */ new Set([
54681
55854
  "created",
@@ -54700,13 +55873,16 @@ var OrchestrationService = class {
54700
55873
  github;
54701
55874
  automation;
54702
55875
  wakes;
54703
- /** Notices to show as cards in the next turn of a role session. */
54704
- pendingCards = /* @__PURE__ */ new Map();
55876
+ loops;
55877
+ reminders;
55878
+ cards;
55879
+ delivery;
54705
55880
  unsubscribe;
54706
55881
  constructor(deps) {
54707
55882
  this.deps = deps;
54708
55883
  this.roles = new RoleSessions(deps);
54709
55884
  this.notices = new NoticeStore(deps.db, deps.store);
55885
+ this.cards = new NoticeCards(deps.store);
54710
55886
  this.notes = new ProjectNotes(deps.projects);
54711
55887
  this.github = deps.github ?? new UnavailableGithubPort();
54712
55888
  this.automation = deps.automation ?? new UnavailableAutomationPort();
@@ -54716,21 +55892,55 @@ var OrchestrationService = class {
54716
55892
  });
54717
55893
  this.asks = new PmAsks({
54718
55894
  db: deps.db,
54719
- onLateReply: (reply) => this.postToOrchestrator(reply.projectId, reply.pmSessionId, `Answer to your earlier ask_pm question:\n${reply.text}`, false)
55895
+ session: (id) => deps.repo.find(id)?.session,
55896
+ onLateReply: (reply) => this.postToOrchestrator(reply.projectId, reply.pmSessionId, `Answer to your earlier ask_pm question:\n${reply.text}`, false, `pm.message:ask:${reply.askId}`)
54720
55897
  });
54721
55898
  this.wakes = new WakeScheduler((key) => this.fire(key), {
54722
55899
  ...deps.options?.wake,
54723
55900
  onSuppressed: (key, until) => deps.log?.(`orchestration: loop guard holds wakes for ${key} until ${new Date(until).toISOString()}`),
54724
55901
  onGaveUp: (key) => deps.log?.(`orchestration: ${key} stayed busy; its notices wait for its next message or notice`)
54725
55902
  });
55903
+ this.loops = new LoopGuard({
55904
+ wakes: this.wakes,
55905
+ notices: this.notices,
55906
+ wakePm: (projectId) => this.fire(wakeKey("pm", projectId)),
55907
+ options: deps.options?.wake,
55908
+ log: deps.log
55909
+ });
55910
+ this.delivery = new NoticeDelivery({
55911
+ db: deps.db,
55912
+ notices: this.notices,
55913
+ cards: this.cards,
55914
+ wakes: this.wakes,
55915
+ digestFor: (session) => this.digestFor(session),
55916
+ projectName: (id) => this.projectName(id),
55917
+ session: (id) => deps.repo.find(id)?.session,
55918
+ projectIds: () => deps.projects.list().flatMap((p) => p.archivedAt ? [] : [p.id]),
55919
+ retryDelaysMs: deps.options?.failedTurnRetryMs,
55920
+ log: deps.log
55921
+ });
55922
+ const minDelayMs = deps.options?.wakeupMinDelayMs;
55923
+ this.reminders = new Reminders({
55924
+ notices: this.notices,
55925
+ wakes: this.wakes,
55926
+ minDelayMs
55927
+ });
54726
55928
  }
54727
55929
  start() {
54728
55930
  this.unsubscribe = this.deps.store.subscribe((events) => this.onEvents(events));
54729
- this.deps.sessions.setInputDecorator((session, input) => this.decorate(session, input));
55931
+ this.deps.sessions.setInputDecorator((session, input, from) => {
55932
+ this.loops.turnStarted(session, from);
55933
+ return this.delivery.decorate(session, input);
55934
+ });
55935
+ this.delivery.rearm();
55936
+ this.reminders.start();
54730
55937
  }
54731
55938
  stop() {
54732
55939
  this.unsubscribe?.();
54733
55940
  this.wakes.dispose();
55941
+ this.loops.dispose();
55942
+ this.delivery.dispose();
55943
+ this.reminders.dispose();
54734
55944
  }
54735
55945
  projectStatus(projectId) {
54736
55946
  const project = this.deps.projects.get(projectId);
@@ -54766,31 +55976,6 @@ var OrchestrationService = class {
54766
55976
  });
54767
55977
  }
54768
55978
  /**
54769
- * Prepend the status digest and any undelivered notices to a role session's input (§8.4).
54770
- * Runs at send time, so queued messages get a fresh digest too.
54771
- */
54772
- decorate(session, input) {
54773
- if (session.role === "agent") return input;
54774
- try {
54775
- const parts = [this.digestFor(session)];
54776
- const target = session.role === "pm" ? "pm" : "orchestrator";
54777
- const pending = this.notices.undelivered(target, session.role === "pm" ? session.projectId : null);
54778
- if (pending.length > 0) {
54779
- parts.push(renderNotices(pending, (id) => this.projectName(id)));
54780
- this.notices.markDelivered(pending.map((n) => n.id));
54781
- this.pendingCards.set(session.id, [...this.pendingCards.get(session.id) ?? [], ...pending]);
54782
- }
54783
- parts.push(input.text);
54784
- return {
54785
- ...input,
54786
- text: parts.join("\n\n")
54787
- };
54788
- } catch (error) {
54789
- this.deps.log?.(`orchestration digest: ${errorMessage$1(error)}`);
54790
- return input;
54791
- }
54792
- }
54793
- /**
54794
55979
  * Send a message to the orchestrator or a project's PM, creating the session if needed.
54795
55980
  * `from` labels the message in the queue tray when it has to wait for a running turn.
54796
55981
  */
@@ -54805,28 +55990,45 @@ var OrchestrationService = class {
54805
55990
  status: res.status
54806
55991
  };
54807
55992
  }
54808
- /** `message_pm`: hand a PM a task without waiting. */
54809
- async messagePm(projectId, text) {
54810
- this.guard(`pm:${projectId}`);
55993
+ /**
55994
+ * `message_pm`: hand a PM a task without waiting. Past the loop guard's limit (§8.2) the
55995
+ * message is held and delivered automatically; the user's own requests are never held.
55996
+ */
55997
+ async messagePm(projectId, text, origin) {
55998
+ await this.roles.ensure("pm", projectId);
55999
+ if (!this.loops.mayDeliver(projectId, origin)) return {
56000
+ status: "held",
56001
+ until: this.loops.hold(projectId, origin.sessionId, text)
56002
+ };
54811
56003
  return this.sendToRole("pm", projectId, messageFrom("orchestrator", text), "queue", "orchestrator");
54812
56004
  }
54813
- /** `ask_pm`: ask a PM and wait (up to `timeoutMs`) for the answer. */
56005
+ /**
56006
+ * `ask_pm`: ask a PM and wait (up to `timeoutMs`) for the answer. A question the loop guard
56007
+ * holds back (§8.2) is answered later, as a message to the orchestrator.
56008
+ */
54814
56009
  async askPm(projectId, question, opts) {
54815
- this.guard(`pm:${projectId}`);
54816
56010
  const pm = await this.roles.ensure("pm", projectId);
54817
56011
  const askId = randomUUID().slice(0, 8);
54818
- this.asks.register(askId, {
56012
+ const target = {
54819
56013
  pmSessionId: pm.id,
54820
56014
  projectId,
54821
- fromSessionId: opts.fromSessionId
54822
- });
54823
- const answer = this.asks.wait(askId, opts.timeoutMs ?? this.deps.options?.askTimeoutMs ?? 18e4, opts.signal);
56015
+ fromSessionId: opts.origin.sessionId
56016
+ };
54824
56017
  const text = [
54825
56018
  `Question from the orchestrator ${askMarker(askId)}:`,
54826
56019
  question,
54827
56020
  "",
54828
56021
  "Answer in your final message of this turn: it is returned to the orchestrator directly."
54829
56022
  ].join("\n");
56023
+ if (!this.loops.mayDeliver(projectId, opts.origin)) {
56024
+ this.asks.register(askId, target, { detached: true });
56025
+ return {
56026
+ status: "held",
56027
+ until: this.loops.hold(projectId, opts.origin.sessionId, text)
56028
+ };
56029
+ }
56030
+ this.asks.register(askId, target);
56031
+ const answer = this.asks.wait(askId, opts.timeoutMs ?? this.deps.options?.askTimeoutMs ?? 18e4, opts.signal);
54830
56032
  await this.deps.sessions.send(pm.id, {
54831
56033
  input: {
54832
56034
  text,
@@ -54847,14 +56049,17 @@ var OrchestrationService = class {
54847
56049
  }
54848
56050
  /** Record a notice (other services: GitHub checks, automations); wakes its target when asked. */
54849
56051
  notify(draft, wake = true) {
54850
- const created = this.notices.create(draft);
56052
+ const created = this.notices.create({
56053
+ ...draft,
56054
+ wake
56055
+ });
54851
56056
  if (created && wake) this.wakes.request(wakeKey(created.target, created.projectId));
54852
56057
  return created;
54853
56058
  }
54854
- postToOrchestrator(projectId, fromSessionId, text, informational) {
56059
+ postToOrchestrator(projectId, fromSessionId, text, informational, id = `pm.message:${randomUUID()}`) {
54855
56060
  const name = projectId ? this.projectName(projectId) : "a project";
54856
56061
  this.notify({
54857
- id: `pm.message:${randomUUID()}`,
56062
+ id,
54858
56063
  type: "pm.message",
54859
56064
  projectId,
54860
56065
  target: "orchestrator",
@@ -54885,16 +56090,13 @@ var OrchestrationService = class {
54885
56090
  }
54886
56091
  });
54887
56092
  }
54888
- guard(key) {
54889
- if (!this.wakes.allowed(key)) throw new ServiceError(409, "loop_guard", "Too many messages to this session recently (loop guard). Wait a few minutes or ask the user.");
54890
- this.wakes.recordWake(key);
54891
- }
54892
56093
  onEvents(events) {
54893
56094
  this.asks.onEvents(events);
54894
- if (!events.some((e) => e.type === "agent.turn.completed" || e.type === "agent.turn.started" || e.type === "inbox.created" || e.type === "session.updated" || e.type === "automation_run.updated" || GITHUB_NOTICE_EVENTS.has(e.type))) return;
56095
+ if (!events.some((e) => e.type === "agent.turn.completed" || e.type === "agent.turn.failed" || e.type === "agent.turn.started" || e.type === "inbox.created" || e.type === "session.updated" || e.type === "automation_run.updated" || GITHUB_NOTICE_EVENTS.has(e.type))) return;
54895
56096
  setImmediate(() => {
54896
56097
  try {
54897
- this.attachCards(events);
56098
+ this.cards.attach(events);
56099
+ this.delivery.onEvents(events);
54898
56100
  const session = (id) => this.deps.repo.find(id)?.session;
54899
56101
  const derived = [...deriveNotices(events, {
54900
56102
  session,
@@ -54906,30 +56108,6 @@ var OrchestrationService = class {
54906
56108
  }
54907
56109
  });
54908
56110
  }
54909
- /** Show the notices a wake delivered as cards at the start of the role session's turn. */
54910
- attachCards(events) {
54911
- for (const e of events) {
54912
- if (e.type !== "agent.turn.started" || !e.sessionId) continue;
54913
- const cards = this.pendingCards.get(e.sessionId);
54914
- if (!cards) continue;
54915
- this.pendingCards.delete(e.sessionId);
54916
- const event = e.payload;
54917
- this.deps.store.append([agentEventToNewEvent(e.sessionId, {
54918
- type: "item.completed",
54919
- threadId: event.threadId,
54920
- turnId: event.turnId,
54921
- item: {
54922
- id: `notices-${event.turnId}`,
54923
- kind: "notice",
54924
- status: "completed",
54925
- level: "info",
54926
- code: NOTICE_BATCH_CODE,
54927
- message: `${cards.length} update${cards.length === 1 ? "" : "s"} since the last turn`,
54928
- data: JSON.parse(JSON.stringify({ notices: cards }))
54929
- }
54930
- })]);
54931
- }
54932
- }
54933
56111
  /** Deliver pending notices to their target with one wake (called by the scheduler). */
54934
56112
  async fire(key) {
54935
56113
  const role = key === "orchestrator" ? "orchestrator" : "pm";
@@ -55454,19 +56632,260 @@ var ProjectRedetector = class {
55454
56632
  this.refresh(projectId).catch((error) => this.deps.log?.(`projects: checking ${projectId}: ${errorMessage$1(error)}`));
55455
56633
  }
55456
56634
  };
56635
+ // ../server/src/services/sessions/auto-retry.ts
56636
+ /** Waits before the 1st, 2nd and 3rd automatic retry after a transient failure. */
56637
+ const RETRY_DELAYS_MS = [
56638
+ 2e4,
56639
+ 9e4,
56640
+ 3e5
56641
+ ];
56642
+ /** Longest single timer (setTimeout's limit); longer waits re-arm. */
56643
+ const MAX_TIMER_MS = 2 ** 31 - 1;
56644
+ /**
56645
+ * Decide, from a session and its root turns (newest first), whether its last turn is retried
56646
+ * and when. Only an idle agent session whose last root turn failed for a passing reason
56647
+ * qualifies (`awaitsRetryDecision`); `attempt` counts the consecutive such failures. A transient
56648
+ * failure waits `delaysMs[attempt - 1]` after the turn ended, a usage limit until `retryAt`
56649
+ * plus the margin. Past the last delay, without a reset time, or switched off: give up. Pure.
56650
+ */
56651
+ function planRetry(session, rootTurns, opts) {
56652
+ const turn = rootTurns[0];
56653
+ if (!turn?.error || turn.status !== "failed" || session.status !== "idle") return { action: "none" };
56654
+ const error = turn.error;
56655
+ if (!awaitsRetryDecision(session, error)) return { action: "none" };
56656
+ let attempt = 0;
56657
+ for (const t of rootTurns) {
56658
+ if (t.status !== "failed" || !t.error || !awaitsRetryDecision(session, t.error)) break;
56659
+ attempt += 1;
56660
+ }
56661
+ const giveUp = (exhausted) => ({
56662
+ action: "give_up",
56663
+ turn,
56664
+ error,
56665
+ attempt,
56666
+ exhausted
56667
+ });
56668
+ if (!opts.enabled) return giveUp(false);
56669
+ if (attempt > opts.delaysMs.length) return giveUp(true);
56670
+ const endedAt = Date.parse(turn.completedAt ?? turn.startedAt);
56671
+ if (error.kind === "usage_limit") {
56672
+ const resetsAt = error.retryAt ? Date.parse(error.retryAt) : NaN;
56673
+ if (!Number.isFinite(resetsAt)) return giveUp(false);
56674
+ return {
56675
+ action: "retry",
56676
+ turn,
56677
+ error,
56678
+ attempt,
56679
+ at: resetsAt + opts.usageMarginMs
56680
+ };
56681
+ }
56682
+ const delay = opts.delaysMs[attempt - 1] ?? 0;
56683
+ return {
56684
+ action: "retry",
56685
+ turn,
56686
+ error,
56687
+ attempt,
56688
+ at: endedAt + delay
56689
+ };
56690
+ }
56691
+ /** "in 20s", "in 3 min", "in about 2 h 15 min". */
56692
+ function inAbout(ms) {
56693
+ const s = Math.max(1, Math.round(ms / 1e3));
56694
+ if (s < 60) return `in ${s}s`;
56695
+ const m = Math.round(s / 60);
56696
+ if (m < 60) return `in ${m} min`;
56697
+ return `in about ${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ""}`;
56698
+ }
56699
+ /** The `codeden` message that continues the session. */
56700
+ function continueMessage(error) {
56701
+ return `Previous turn stopped (${error.kind === "usage_limit" ? "usage limit reached" : error.message.length > 160 ? `${error.message.slice(0, 157)}...` : error.message}); continue where you left off.`;
56702
+ }
56703
+ /**
56704
+ * Automatic retries for agent sessions (§7.3 `ErrorInfo.kind`). A root turn that failed for a
56705
+ * passing reason leaves the session idle with no inbox item (the reducer, `awaitsRetryDecision`);
56706
+ * this service then continues it with a `codeden` message after a backoff (transient: 20 s,
56707
+ * 90 s, 5 min, at most three in a row) or a minute after a usage limit resets, and says so in
56708
+ * the transcript. A user message or Stop cancels a pending retry. When the retries run out, or
56709
+ * `sessions.autoRetry` is off, the session fails as it always did: `failed` with a `run_failed`
56710
+ * inbox item (and the PM notices that come from it). Pending retries are derived from the turns
56711
+ * and sessions tables, so a server restart picks them up again. Orchestrator and PM sessions
56712
+ * are never retried here.
56713
+ */
56714
+ var AutoRetry = class {
56715
+ deps;
56716
+ timers = /* @__PURE__ */ new Map();
56717
+ unsubscribe;
56718
+ stopped = false;
56719
+ constructor(deps) {
56720
+ this.deps = deps;
56721
+ }
56722
+ start() {
56723
+ this.stopped = false;
56724
+ this.unsubscribe = this.deps.store.subscribe((events) => this.onEvents(events));
56725
+ setImmediate(() => {
56726
+ if (this.stopped) return;
56727
+ for (const session of this.deps.repo.list({
56728
+ role: "agent",
56729
+ status: ["idle"]
56730
+ })) this.evaluate(session.id);
56731
+ });
56732
+ }
56733
+ stop() {
56734
+ this.stopped = true;
56735
+ this.unsubscribe?.();
56736
+ for (const { timer } of this.timers.values()) clearTimeout(timer);
56737
+ this.timers.clear();
56738
+ }
56739
+ /** Sessions with a retry waiting (tests). */
56740
+ pending() {
56741
+ return [...this.timers.keys()];
56742
+ }
56743
+ onEvents(events) {
56744
+ for (const e of events) {
56745
+ if (!e.sessionId) continue;
56746
+ const sessionId = e.sessionId;
56747
+ if (e.type === "agent.turn.failed") queueMicrotask(() => this.evaluate(sessionId));
56748
+ else if (e.type === "agent.turn.started" && this.timers.has(sessionId)) this.cancel(sessionId, "A new message was sent, so the automatic retry was cancelled.");
56749
+ else if (e.type === "session.updated" && this.timers.has(sessionId)) {
56750
+ const { session } = e.payload;
56751
+ if (session.status !== "idle" || session.archivedAt) this.cancel(sessionId, "The automatic retry was cancelled.");
56752
+ }
56753
+ }
56754
+ }
56755
+ /** Decide about a session's last root turn now: schedule, give up, or nothing. */
56756
+ evaluate(sessionId) {
56757
+ try {
56758
+ const decision = this.decide(sessionId);
56759
+ if (decision.action === "retry") this.schedule(sessionId, decision);
56760
+ else if (decision.action === "give_up") this.giveUp(sessionId, decision);
56761
+ } catch (error) {
56762
+ this.deps.log?.(`auto-retry for ${sessionId}: ${errorMessage$1(error)}`);
56763
+ }
56764
+ }
56765
+ decide(sessionId) {
56766
+ const session = this.deps.repo.find(sessionId)?.session;
56767
+ if (!session?.rootThreadId) return { action: "none" };
56768
+ return planRetry(session, this.deps.db.select().from(turns).where(and(eq(turns.sessionId, sessionId), eq(turns.threadId, session.rootThreadId))).orderBy(desc(turns.startedAt)).limit(10).all().map(rowToTurnRecord), {
56769
+ enabled: this.deps.settings().sessions.autoRetry,
56770
+ delaysMs: this.deps.delaysMs ?? RETRY_DELAYS_MS,
56771
+ usageMarginMs: this.deps.usageMarginMs ?? 6e4
56772
+ });
56773
+ }
56774
+ schedule(sessionId, plan) {
56775
+ const existing = this.timers.get(sessionId);
56776
+ if (existing) clearTimeout(existing.timer);
56777
+ const wait = Math.max(0, plan.at - this.now());
56778
+ const max = (this.deps.delaysMs ?? RETRY_DELAYS_MS).length;
56779
+ this.notice(sessionId, plan.turn, {
56780
+ message: plan.error.kind === "usage_limit" ? `Usage limit reached. CodeDen continues the session ${inAbout(wait)}, a minute after it resets.` : `Retrying automatically ${inAbout(wait)} (attempt ${plan.attempt} of ${max}).`,
56781
+ data: {
56782
+ attempt: plan.attempt,
56783
+ maxAttempts: max,
56784
+ retryAt: new Date(plan.at).toISOString()
56785
+ }
56786
+ });
56787
+ const timer = setTimeout(() => void this.fire(sessionId, plan.turn.id), Math.min(wait, MAX_TIMER_MS));
56788
+ timer.unref?.();
56789
+ this.timers.set(sessionId, {
56790
+ turnId: plan.turn.id,
56791
+ timer
56792
+ });
56793
+ }
56794
+ async fire(sessionId, turnId) {
56795
+ this.timers.delete(sessionId);
56796
+ if (this.stopped) return;
56797
+ let decision;
56798
+ try {
56799
+ decision = this.decide(sessionId);
56800
+ } catch (error) {
56801
+ this.deps.log?.(`auto-retry for ${sessionId}: ${errorMessage$1(error)}`);
56802
+ return;
56803
+ }
56804
+ if (decision.action === "give_up") return this.giveUp(sessionId, decision);
56805
+ if (decision.action !== "retry" || decision.turn.id !== turnId) return;
56806
+ if (decision.at > this.now()) return this.schedule(sessionId, decision);
56807
+ this.deps.log?.(`auto-retry: continuing session ${sessionId} (attempt ${decision.attempt})`);
56808
+ try {
56809
+ const text = continueMessage(decision.error);
56810
+ await this.deps.send(sessionId, {
56811
+ text,
56812
+ attachments: [],
56813
+ from: "codeden"
56814
+ });
56815
+ } catch (error) {
56816
+ this.deps.log?.(`auto-retry for ${sessionId}: ${errorMessage$1(error)}`);
56817
+ this.giveUp(sessionId, {
56818
+ ...decision,
56819
+ action: "give_up",
56820
+ exhausted: false
56821
+ });
56822
+ }
56823
+ }
56824
+ /** Fail the session the usual way, with the `run_failed` item the reducer would have raised. */
56825
+ giveUp(sessionId, decision) {
56826
+ const pending = this.timers.get(sessionId);
56827
+ if (pending) clearTimeout(pending.timer);
56828
+ this.timers.delete(sessionId);
56829
+ if (decision.exhausted) this.notice(sessionId, decision.turn, {
56830
+ message: `Stopped retrying after ${decision.attempt - 1} automatic ${decision.attempt === 2 ? "retry" : "retries"}.`,
56831
+ data: { attempt: decision.attempt }
56832
+ });
56833
+ const session = this.deps.repo.update(sessionId, (s) => s.status === "idle" ? {
56834
+ ...s,
56835
+ status: "failed"
56836
+ } : s);
56837
+ if (session.status !== "failed") return;
56838
+ this.deps.inbox.raiseRunFailed(session, `${decision.turn.id}:failed`, decision.error.message);
56839
+ }
56840
+ cancel(sessionId, message) {
56841
+ const pending = this.timers.get(sessionId);
56842
+ if (!pending) return;
56843
+ clearTimeout(pending.timer);
56844
+ this.timers.delete(sessionId);
56845
+ const turn = this.deps.db.select().from(turns).where(and(eq(turns.sessionId, sessionId), eq(turns.id, pending.turnId))).get();
56846
+ if (turn) this.notice(sessionId, rowToTurnRecord(turn), {
56847
+ message,
56848
+ data: { cancelled: true }
56849
+ });
56850
+ }
56851
+ /** The retry's notice in the failed turn, replaced as the retry's state changes. */
56852
+ notice(sessionId, turn, body) {
56853
+ this.deps.store.append([agentEventToNewEvent(sessionId, {
56854
+ type: "item.completed",
56855
+ threadId: turn.threadId,
56856
+ turnId: turn.id,
56857
+ item: {
56858
+ id: `auto-retry-${turn.id}`,
56859
+ kind: "notice",
56860
+ status: "completed",
56861
+ level: "info",
56862
+ code: "auto_retry",
56863
+ message: body.message,
56864
+ data: body.data
56865
+ }
56866
+ })]);
56867
+ }
56868
+ now() {
56869
+ return this.deps.now?.() ?? Date.now();
56870
+ }
56871
+ };
55457
56872
  // ../server/src/services/orchestration/prompts.ts
55458
56873
  /**
55459
56874
  * System prompt sections for the orchestrator and the project managers (§8.1). Kept short:
55460
56875
  * the live state arrives with every wake in the status digest, so the prompts only describe
55461
- * responsibilities, tools, escalation and conflict behaviour.
56876
+ * responsibilities, tools, when to involve the user, and conflict behaviour. Both roles decide
56877
+ * routine questions with sensible defaults and keep the user for the decisions that are theirs.
55462
56878
  */
56879
+ /** The decisions that go to the user; everything else the PM or the orchestrator decides. */
56880
+ const USER_DECISIONS$1 = "destructive or irreversible actions; spending money or new accounts or credentials; outward-facing actions (releases, publishing, deploys, messages to people outside the project); protected areas; conflicting priorities";
55463
56881
  const SHARED = `## How CodeDen keeps you informed
55464
56882
 
55465
56883
  - Every message you receive starts with a \`<codeden_status>\` block: a fresh digest of running and queued work (work key, purpose, status, progress, age), the next scheduled automation runs, open inbox items and recent finishes and failures. Trust it; you do not need a tool call to learn the current state.
55466
56884
  - After it may come a \`<codeden_updates>\` block, "Updates since your last turn": typed notices (session finished, needs input, failed, PR opened, checks failed, automation skipped, conflict, PM messages). React to the ones that need action and ignore routine ones.
55467
56885
  - Everything after the last of these blocks is what the sender actually wrote.
55468
56886
  - You have no write access to project code. You act only through CodeDen tools on the \`codeden\` MCP server; reading files and read-only git commands are fine.
55469
- - Never start work that duplicates running or queued work. Check the digest first; starting tools return \`conflict\` with the existing work instead of starting. Then choose one resolution and say why: \`wait\` (queue behind it), \`skip\`, \`join\` (send your request to the existing session as a follow-up) or \`force\` (only with a concrete reason, which is recorded).`;
56887
+ - Never start work that duplicates running or queued work. Check the digest first; starting tools return \`conflict\` with the existing work instead of starting. Then choose one resolution yourself and say why: \`wait\` (queue behind it), \`skip\`, \`join\` (send your request to the existing session as a follow-up) or \`force\` (only with a concrete reason, which is recorded).
56888
+ - To follow up later, schedule it with \`schedule_wakeup\`; never promise a follow-up you haven't scheduled.`;
55470
56889
  const ORCHESTRATOR_PROMPT = `# You are the CodeDen orchestrator
55471
56890
 
55472
56891
  You are the user's front door to every project in CodeDen. You create and configure projects, route requests to each project's project manager (PM), and summarise status across projects. You never do project work yourself and never start agent sessions directly: the PM of the project does that.
@@ -55478,34 +56897,51 @@ You are the user's front door to every project in CodeDen. You create and config
55478
56897
  - \`ask_pm\`: ask a project's PM and wait for its answer. Use it whenever a request concerns a project's work (for example "run a security audit on X"): the PM knows what is already running there and decides how to proceed.
55479
56898
  - \`message_pm\`: hand a PM a task or information without waiting; its reply arrives later as a message.
55480
56899
  - \`list_inbox\`: what is waiting on the user.
56900
+ - \`schedule_wakeup\`, \`list_wakeups\`, \`cancel_wakeup\`: follow-ups you want to come back to later.
55481
56901
 
55482
56902
  ## Rules
55483
56903
 
55484
56904
  - Route project work through the PM. For any request to do something in a project (run, start, fix, audit, review, update), call \`ask_pm\` first and pass the request on, even when the digest already shows related work: the PM owns the project's sessions and schedules and decides how to proceed. Never answer a work request from the digest alone.
55485
- - If the PM reports that matching work is already running, tell the user (with its progress and the options) instead of asking for a duplicate.
55486
- - Escalate to the user only for decisions the PMs cannot make: conflicting priorities across projects, protected areas, anything irreversible.
56905
+ - If the PM reports that matching work is already running, tell the user in one line what is running and what the PM did about it (joined, queued or skipped).
56906
+ - Decide with sensible defaults. Don't pass the PM's open questions on to the user as a menu: when a sensible default exists, tell the PM to go ahead with it (\`message_pm\`) and tell the user the default in one line so they can override it.
56907
+ - Involve the user only for decisions the PMs cannot make: ${USER_DECISIONS$1} across projects. Then ask one question with your recommendation; never end a reply with a list of options.
56908
+ - When a PM reports on work you passed on, tell the user in one or two lines.
55487
56909
  - PM messages marked informational do not wake you; they appear in your next digest. Do not reply to them unless they need a decision.
55488
56910
  - Be brief with the user: lead with the answer, then the detail that matters.
55489
56911
 
55490
56912
  ${SHARED}`;
55491
56913
  const PM_PROMPT = `# You are a CodeDen project manager (PM)
55492
56914
 
55493
- You manage one project. You triage its GitHub issues, plan work, start and steer agent sessions, follow their progress, review results, and escalate when unsure. Agent sessions do the coding, each in its own worktree; you never edit code yourself.
56915
+ You manage one project. You triage its GitHub issues, plan work, start and steer agent sessions, follow their progress and review results. Agent sessions do the coding, each in its own worktree; you never edit code yourself. You make the routine calls yourself and keep work moving; the user only gets the decisions listed under "When to involve the user".
55494
56916
 
55495
56917
  ## Tools
55496
56918
 
55497
56919
  - Awareness: \`get_project_status\`, \`list_sessions\` (filter by status, work key, startedBy), \`list_scheduled_runs\`.
55498
- - Sessions: \`start_session\` (always give a \`workKey\` such as \`issue:142\`, \`automation:security-audit\` or \`request:<slug>\`, a one-line \`purpose\` and a \`scope\` when you know the area), \`message_session\`, \`stop_session\`.
55499
- - Automations: \`skip_next_run\`, \`pause_automation\`, \`resume_automation\`, \`cancel_queued_run\`.
55500
- - GitHub: \`list_issues\`, \`get_issue\`, \`triage_issue\`, \`create_issue\`, \`get_pr_status\`.
56920
+ - Sessions: \`start_session\` (always give a \`workKey\` such as \`issue:142\`, \`automation:security-audit\` or \`request:<slug>\`, a one-line \`purpose\`, a \`scope\` when you know the area, and a brief that includes the decisions you already made), \`message_session\`, \`stop_session\`.
56921
+ - Automations: \`list_automations\`, \`run_automation\`, \`create_automation\`, \`update_automation\`, \`skip_next_run\`, \`pause_automation\`, \`resume_automation\`, \`cancel_queued_run\`.
56922
+ - GitHub: \`list_issues\`, \`get_issue\`, \`triage_issue\`, \`report_triage\`, \`create_issue\`, \`get_pr_status\`.
55501
56923
  - Notes: \`read_project_notes\`, \`update_project_notes\` (your durable memory for this project: decisions, conventions, follow-ups).
55502
- - People: \`ask_user\` for decisions only the user can make; \`message_orchestrator\` to report to the orchestrator.
56924
+ - People: \`answer_question\` for your agents' questions, \`ask_user\` for decisions only the user can make, \`message_orchestrator\` to report to the orchestrator.
56925
+ - Follow-ups: \`schedule_wakeup\`, \`list_wakeups\`, \`cancel_wakeup\`.
55503
56926
 
55504
- ## Rules
56927
+ ## Decide, don't ask
56928
+
56929
+ - When a request leaves details open (provider, model, scope, approach, naming, order of work), choose sensible defaults from the project notes, the vision, the code and its conventions. Write them into the agent's brief and mention them in one line so the user can override them. Never end a turn with a menu of options.
56930
+ - Before starting work, check the digest and \`list_sessions\` for the same work key or area. If matching work is already running or queued (including scheduled automation runs), don't start a duplicate: choose the resolution yourself (\`join\` when the request adds detail or scope to the running work, \`wait\` when it has to follow it, \`skip\` when it is the same work, \`force\` only with a concrete reason) and say in one line what you chose.
56931
+ - Answer your agents' questions with \`answer_question\` when the brief, the notes or the vision settle them; otherwise leave them for the user.
56932
+ - You can't merge pull requests. The \`mode\` in your status digest says who does: in manual and assisted the user reviews and merges them; in autopilot and dark factory the factory loop merges when every check passes. Report a ready pull request once; don't ask whether to merge it.
56933
+
56934
+ ## When to involve the user
55505
56935
 
55506
- - When the orchestrator asks you something (a message marked "Question from the orchestrator"), answer in your final message of that turn; it is returned to the orchestrator directly.
55507
- - Before starting work, check the digest and \`list_sessions\` for the same work key or area. If matching work is already running or queued (including scheduled automation runs), do not start a duplicate: report it with its progress and the options (wait, skip, join, force).
55508
- - Merge nothing on your own unless the project's policy allows it. Escalate to the user (\`ask_user\`) or the orchestrator when unsure, when a protected area is involved, or when agents keep failing.
56936
+ - Only for ${USER_DECISIONS$1}; agents that keep failing at the same thing.
56937
+ - Ask one specific question with your default ("I'll go with X unless you say otherwise") and keep doing the work that doesn't depend on the answer.
56938
+ - When the user writes to you directly, ask in your reply. Use \`ask_user\` only on an automatic wake-up, when nobody is in the conversation.
56939
+ - Never call \`ask_user\` while the orchestrator waits for your answer or during an issue triage run (a triage run that waits on the user never reports). Classify an issue you can't decide as \`needs_info\` (ask its author in the comment) or \`unsafe\`, and say why in \`report_triage\`.
56940
+
56941
+ ## The orchestrator
56942
+
56943
+ - When the orchestrator asks you something (a message marked "Question from the orchestrator"), answer in your final message of that turn; it is returned to the orchestrator directly, and the orchestrator is talking to the user. Answer with what you did or will do and the defaults you chose.
56944
+ - Work the orchestrator handed you (a message or a question) is yours to close: when it finishes, fails or needs a decision only the user can make, report it with \`message_orchestrator\` (informational: false), with the session, the PR and the outcome.
55509
56945
  - Use \`message_orchestrator\` with \`informational: true\` for routine updates (progress, a finished run that needs nothing); leave it false only when the orchestrator must act or answer.
55510
56946
  - After an automatic wake-up with nothing to act on, reply in one short line.
55511
56947
 
@@ -55517,21 +56953,104 @@ function rolePrompt(role) {
55517
56953
  // ../server/src/mcp/prompt.ts
55518
56954
  /** Name of CodeDen's MCP server inside agent sessions (tools appear as `codeden` tools). */
55519
56955
  const MCP_SERVER_NAME = "codeden";
55520
- const AGENT_PROMPT = `# Working inside CodeDen
56956
+ /**
56957
+ * The decisions that are the user's to make. Everything else an agent decides itself, states
56958
+ * what it assumed, and carries on.
56959
+ */
56960
+ const USER_DECISIONS = "destructive or irreversible steps (deleting data or someone else's work, force-pushing, rewriting shared history), spending money or creating accounts or credentials, outward-facing actions your brief didn't ask for (publishing, releases, deploys, messages or comments to other people), protected areas, or a request with two readings that lead to substantially different work";
56961
+ const TOOLS = `## CodeDen tools
55521
56962
 
55522
- You are an agent session in CodeDen, a local harness that shows your work to the user live. Your working directory is your own git worktree and branch. CodeDen tools are on the \`${MCP_SERVER_NAME}\` MCP server:
56963
+ CodeDen tools are on the \`${MCP_SERVER_NAME}\` MCP server:
55523
56964
 
55524
56965
  - \`report_progress\`: one line about what you are doing now, at the start of each phase.
55525
- - \`ask_user\`: only when you cannot proceed without a decision. If it returns "pending", finish what you can and end your turn; the answer arrives as your next message.
56966
+ - \`ask_user\`: only for a decision that is the user's to make (see above). If it returns "pending", finish what you can and end your turn; the answer arrives as your next message.
55526
56967
  - \`share_to_chat\`: show the user a file or image instead of describing it.
55527
56968
  - Browser (\`browser_navigate\`, \`browser_snapshot\`, \`browser_click\`, \`browser_type\`, \`browser_press\`, \`browser_screenshot\`, \`browser_logs\`, \`browser_wait\`, \`browser_evaluate\`, \`browser_open_dev_server\`): the same browser the user watches. Use it to check UI work: open the dev server, read the page with \`browser_snapshot\`, act on element refs, check \`browser_logs\` for errors. Call \`browser_screenshot\` with \`share: true\` for screenshots that show your result.
55528
56969
  - \`generate_image\` (when listed): create images into your worktree, e.g. \`public/hero.png\`.
55529
56970
  - GitHub projects: \`create_pull_request\` pushes your branch and opens the PR (linked to your issue and this session); \`attach_evidence\` posts screenshots, files and test output on it; \`comment_on_issue\` and \`get_pr_status\` (checks, reviews, mergeability).
55530
56971
  - \`finish\`: record the outcome and a short summary once your assigned task is done.
55531
- - \`suggest_followups\`: 2 to 4 short next prompts the user might send. Call it as the last action of every turn.`;
56972
+ - \`suggest_followups\`: 2 to 4 short next prompts the user might send, including optional next steps you would otherwise ask about. Call it as the last action of every turn.`;
56973
+ /** Where the agent works: its own worktree, or a folder it shares with the user. */
56974
+ function workspaceLine(session) {
56975
+ return session.worktreePath ? "Your working directory is your own git worktree and branch." : "Your working directory is not a worktree of your own: it is the project's checkout or a folder the user chose, and the user may be working in it too.";
56976
+ }
56977
+ /**
56978
+ * The session's permission level (§11.2) in the agent's terms: what is already approved, and
56979
+ * that approvals ask the user by themselves, so the agent doesn't ask first.
56980
+ */
56981
+ function permissionLine(session) {
56982
+ const where = session.worktreePath ? "your worktree" : "your working directory";
56983
+ switch (session.autonomy) {
56984
+ case "read_only": return "- Your permission level is Read-only: you can read and run the project's checks, but not edit files, commit or push. Report what you found instead of asking to change things.";
56985
+ case "supervised": return "- Your permission level is Supervised: the user approves edits and commands as you make them, so go ahead and make them; don't ask before each one.";
56986
+ case "auto": return `- Your permission level is Auto: edits and commands inside ${where} are pre-approved, so go ahead. Risky actions ask the user for approval automatically, so you don't need to ask first.`;
56987
+ case "full": return `- Your permission level is Full: edits and commands inside ${where} are pre-approved, so go ahead. Anything outside it is refused or goes to the user for approval automatically, so you don't need to ask first.`;
56988
+ }
56989
+ }
56990
+ /** Committing and pull requests: free in the agent's own worktree, opt-in elsewhere. */
56991
+ function gitLines(session, project) {
56992
+ if (!session.worktreePath) return ["- Others may be working in this directory: don't commit, switch branches, stash or discard changes unless you're asked to."];
56993
+ if (session.autonomy === "read_only") return [];
56994
+ const lines = ["- This worktree and its branch are yours: edit files, run the project's checks and commit as you go without asking, unless your brief says otherwise."];
56995
+ if (project?.kind !== "github") return lines;
56996
+ lines.push(session.startedBy.kind === "user" ? "- Open a pull request with `create_pull_request` when the user asks for one; otherwise offer it with `suggest_followups` instead of asking." : "- When the work is done, open the pull request with `create_pull_request` (it links your issue), unless your brief says otherwise.");
56997
+ return lines;
56998
+ }
56999
+ /** Who started the session, when it wasn't the user: the PM sees the agent's questions first. */
57000
+ function starterLine(session) {
57001
+ switch (session.startedBy.kind) {
57002
+ case "user": return null;
57003
+ case "pm": return "- Your project manager started this session and sees your questions first; it answers what it can. Its brief is your first message, its later messages start with \"Message from your project manager:\", and nobody may be watching live.";
57004
+ case "automation": return "- An automation started this session, not the user, and nobody may be watching live. Your project manager sees your questions first; it answers what it can.";
57005
+ case "orchestrator": return "- The orchestrator started this session, not the user. Your project manager sees your questions first; it answers what it can.";
57006
+ }
57007
+ }
57008
+ /** The agent's section of the system prompt (§8.3): how to work, then CodeDen's tools. */
57009
+ function agentPrompt(session, project) {
57010
+ const lines = [
57011
+ "- Keep going until the task is done. When a detail is open (naming, structure, approach, test strategy, a choice between reasonable designs), pick what fits the brief and the project's conventions, note the assumption, and carry on. Don't ask about things you can decide yourself, check in the code, or easily change later.",
57012
+ permissionLine(session),
57013
+ ...gitLines(session, project),
57014
+ `- Ask with \`ask_user\` only for a decision that is the user's to make: ${USER_DECISIONS}. First do everything that doesn't depend on the answer, then ask one question with your recommended option first.`,
57015
+ starterLine(session),
57016
+ "- End each turn with what you did and what you assumed, not with a question. Put optional next steps in `suggest_followups` instead of asking \"Want me to ...?\"."
57017
+ ].filter((l) => l !== null);
57018
+ return [
57019
+ "# Working inside CodeDen",
57020
+ "",
57021
+ `You are an agent session in CodeDen, a local harness that shows your work to the user live. ${workspaceLine(session)}`,
57022
+ "",
57023
+ "## How to work",
57024
+ "",
57025
+ ...lines,
57026
+ "",
57027
+ TOOLS
57028
+ ].join("\n");
57029
+ }
55532
57030
  /** The CodeDen section appended to a session's system prompt, by role (§7.5, §8.3). */
55533
- function codedenSystemPrompt(role) {
55534
- return role === "agent" ? AGENT_PROMPT : rolePrompt(role);
57031
+ function codedenSystemPrompt(session, project) {
57032
+ return session.role === "agent" ? agentPrompt(session, project) : rolePrompt(session.role);
57033
+ }
57034
+ // ../server/src/services/sessions/setup-failure.ts
57035
+ /**
57036
+ * What the agent is told when a worktree setup command failed (§9 setup scripts): the command,
57037
+ * its exit code and the end of its output. Null when every command succeeded. Setup stops at
57038
+ * the first failure, so there is at most one.
57039
+ */
57040
+ function setupFailureNote(setup) {
57041
+ const failed = setup?.find((step) => !step.ok);
57042
+ if (!failed) return null;
57043
+ const exit = failed.exitCode === null ? "timed out" : `exit ${failed.exitCode}`;
57044
+ const head = `Setup command \`${failed.command}\` failed (${exit})`;
57045
+ return failed.outputTail ? `${head}:\n\`\`\`\n${failed.outputTail}\n\`\`\`` : `${head}.`;
57046
+ }
57047
+ /** The agent's input with a setup failure note in front, so its first turn knows. */
57048
+ function withSetupNote(input, note) {
57049
+ if (!input || !note) return input;
57050
+ return {
57051
+ ...input,
57052
+ text: `${note}\n\n${input.text}`
57053
+ };
55535
57054
  }
55536
57055
  // ../server/src/services/sessions/launcher.ts
55537
57056
  const PREPARE_TIMEOUT_MS = 6e5;
@@ -55574,9 +57093,12 @@ var SessionLauncher = class {
55574
57093
  providerId: session.provider,
55575
57094
  options: this.sessionOptions({
55576
57095
  ...session,
55577
- cwd: ws.cwd
57096
+ cwd: ws.cwd,
57097
+ worktreePath: ws.worktreePath,
57098
+ branch: ws.branch,
57099
+ baseBranch: ws.baseBranch
55578
57100
  }),
55579
- input: opts.input
57101
+ input: withSetupNote(opts.input, ws.setupNote)
55580
57102
  }, START_TIMEOUT_MS);
55581
57103
  repo.update(sessionId, (s) => ({
55582
57104
  ...s,
@@ -55594,13 +57116,13 @@ var SessionLauncher = class {
55594
57116
  */
55595
57117
  async resume(session, input) {
55596
57118
  await this.requireRunner();
55597
- const current = await this.restoreWorktree(session);
57119
+ const { session: current, setupNote } = await this.restoreWorktree(session);
55598
57120
  const result = await this.deps.runner.request({
55599
57121
  name: "session.start",
55600
57122
  sessionId: current.id,
55601
57123
  providerId: current.provider,
55602
57124
  options: this.sessionOptions(current),
55603
- input
57125
+ input: withSetupNote(input, setupNote)
55604
57126
  }, START_TIMEOUT_MS);
55605
57127
  this.deps.repo.update(session.id, (s) => ({
55606
57128
  ...s,
@@ -55613,7 +57135,10 @@ var SessionLauncher = class {
55613
57135
  * running the project's setup again. A no-op for sessions that still have their worktree.
55614
57136
  */
55615
57137
  async restoreWorktree(session) {
55616
- if (!session.worktreeRemovedAt || !session.worktreePath || !session.projectId) return session;
57138
+ if (!session.worktreeRemovedAt || !session.worktreePath || !session.projectId) return {
57139
+ session,
57140
+ setupNote: null
57141
+ };
55617
57142
  const project = this.deps.projects.get(session.projectId);
55618
57143
  const result = await this.deps.runner.request({
55619
57144
  name: "workspace.prepare",
@@ -55626,13 +57151,16 @@ var SessionLauncher = class {
55626
57151
  setupCommands: []
55627
57152
  }, PREPARE_TIMEOUT_MS);
55628
57153
  this.deps.log?.(`session ${session.id}: recreated worktree ${result.cwd}`);
55629
- return this.deps.repo.update(session.id, (s) => ({
55630
- ...s,
55631
- cwd: result.cwd,
55632
- worktreePath: result.cwd,
55633
- branch: result.branch ?? s.branch,
55634
- worktreeRemovedAt: null
55635
- }));
57154
+ return {
57155
+ session: this.deps.repo.update(session.id, (s) => ({
57156
+ ...s,
57157
+ cwd: result.cwd,
57158
+ worktreePath: result.cwd,
57159
+ branch: result.branch ?? s.branch,
57160
+ worktreeRemovedAt: null
57161
+ })),
57162
+ setupNote: setupFailureNote(result.setup)
57163
+ };
55636
57164
  }
55637
57165
  /** Mark a session failed with an inbox item explaining why. */
55638
57166
  fail(sessionId, key, error) {
@@ -55676,11 +57204,21 @@ var SessionLauncher = class {
55676
57204
  effort: session.effort ?? void 0,
55677
57205
  autonomy: session.autonomy,
55678
57206
  resumeToken: session.resumeToken ?? void 0,
55679
- systemPrompt: codedenSystemPrompt(session.role),
57207
+ systemPrompt: codedenSystemPrompt(session, this.projectOf(session)),
55680
57208
  readOnly: session.role !== "agent",
55681
- mcpServers
57209
+ mcpServers,
57210
+ ...ownBranch(session) ? { ownBranch: ownBranch(session) } : {}
55682
57211
  });
55683
57212
  }
57213
+ /** The session's project for its system prompt; null without one (or once it is gone). */
57214
+ projectOf(session) {
57215
+ if (!session.projectId) return null;
57216
+ try {
57217
+ return this.deps.projects.get(session.projectId);
57218
+ } catch {
57219
+ return null;
57220
+ }
57221
+ }
55684
57222
  async requireRunner() {
55685
57223
  if (!await this.deps.runner.waitForConnection(this.deps.connectTimeoutMs ?? 15e3)) throw new ServiceError(503, "runner_unavailable", "The runner is not connected");
55686
57224
  }
@@ -55696,6 +57234,7 @@ var SessionLauncher = class {
55696
57234
  const path = req.mode === "existing" ? req.path : join(this.deps.worktreesDir, project.slug, session.id.slice(0, 8));
55697
57235
  const branch = req.branch ?? (req.mode === "worktree" ? `codeden/${slugWords(session.title ?? session.purpose, 5, 40)}-${session.id.slice(0, 4)}` : void 0);
55698
57236
  const baseRef = req.baseRef ?? project.defaultBranch ?? void 0;
57237
+ const fetch = req.fetch ?? (project.kind === "github" && req.mode === "worktree" && !req.baseRef);
55699
57238
  try {
55700
57239
  const result = await this.deps.runner.request({
55701
57240
  name: "workspace.prepare",
@@ -55706,14 +57245,15 @@ var SessionLauncher = class {
55706
57245
  branch,
55707
57246
  baseRef,
55708
57247
  setupCommands: req.setupCommands ?? [],
55709
- ...req.fetch ? { fetch: true } : {}
57248
+ ...fetch ? { fetch: true } : {}
55710
57249
  }, PREPARE_TIMEOUT_MS);
55711
57250
  return {
55712
57251
  cwd: result.cwd,
55713
57252
  worktreePath: req.mode === "worktree" ? result.cwd : null,
55714
57253
  branch: result.branch,
55715
57254
  baseBranch: baseRef ?? null,
55716
- fallback: null
57255
+ fallback: null,
57256
+ setupNote: setupFailureNote(result.setup)
55717
57257
  };
55718
57258
  } catch (error) {
55719
57259
  if (runnerErrorCode(error) !== "not_implemented") throw error;
@@ -55731,7 +57271,8 @@ function plain(cwd) {
55731
57271
  worktreePath: null,
55732
57272
  branch: null,
55733
57273
  baseBranch: null,
55734
- fallback: null
57274
+ fallback: null,
57275
+ setupNote: null
55735
57276
  };
55736
57277
  }
55737
57278
  function defaultMeta(workspace) {
@@ -55743,12 +57284,13 @@ function defaultMeta(workspace) {
55743
57284
  };
55744
57285
  }
55745
57286
  // ../server/src/services/sessions/lifecycle.ts
55746
- /** Session statuses after which its MCP tokens are revoked (§8.3). */
55747
- const ENDED = /* @__PURE__ */ new Set([
55748
- "stopped",
55749
- "completed",
55750
- "failed"
55751
- ]);
57287
+ /**
57288
+ * Session statuses after which its MCP tokens are revoked (§8.3). Not `failed`: a failed turn
57289
+ * leaves the provider process running with its token, and the next message reaches that same
57290
+ * process; a session whose process is gone is resumed with a freshly minted token, which
57291
+ * revokes the old one.
57292
+ */
57293
+ const ENDED = /* @__PURE__ */ new Set(["stopped", "completed"]);
55752
57294
  /**
55753
57295
  * Reacts to committed events: starts queued sessions whose blockers finished (§8.4 `wait`),
55754
57296
  * sends queued messages when a session goes idle, and runs utility-model hooks after a root
@@ -55788,7 +57330,7 @@ var SessionLifecycle = class {
55788
57330
  sessionsChanged = true;
55789
57331
  const { session } = e.payload;
55790
57332
  if (ENDED.has(session.status)) this.deps.tokens.revokeSession(session.id);
55791
- if (session.status === "idle" && this.deps.sessions.hasOutbox(session.id)) setImmediate(() => void this.deps.sessions.flushOutbox(session.id));
57333
+ if ((session.status === "idle" || session.status === "failed") && this.deps.sessions.hasOutbox(session.id)) setImmediate(() => void this.deps.sessions.flushOutbox(session.id));
55792
57334
  } else if ((e.type === "agent.item.started" || e.type === "agent.item.completed") && e.sessionId) {
55793
57335
  const { item } = e.payload;
55794
57336
  if (item.kind === "user_message" && item.input.text.trim()) {
@@ -56000,6 +57542,151 @@ var Outbox = class {
56000
57542
  return (this.db.select({ payload: events.payload }).from(events).where(and(eq(events.sessionId, sessionId), eq(events.type, "message.dequeued"), sql`json_extract(${events.payload}, '$.messageId') = ${messageId}`)).orderBy(desc(events.seq)).limit(1).get()?.payload)?.reason;
56001
57543
  }
56002
57544
  };
57545
+ /** Sessions that can take a message now or after their turn. */
57546
+ const OPEN = /* @__PURE__ */ new Set([
57547
+ "idle",
57548
+ "running",
57549
+ "waiting",
57550
+ "interrupted"
57551
+ ]);
57552
+ const PR_EVENTS = /* @__PURE__ */ new Set([
57553
+ "pr.opened",
57554
+ "pr.updated",
57555
+ "pr.checks_failed",
57556
+ "pr.checks_passed",
57557
+ "review.submitted"
57558
+ ]);
57559
+ const clip$2 = (text, max = 1500) => text.length > max ? `${text.slice(0, max - 3)}...` : text;
57560
+ function failingChecks(pr) {
57561
+ return pr.checks.checks.filter((c) => c.conclusion && ![
57562
+ "success",
57563
+ "neutral",
57564
+ "skipped"
57565
+ ].includes(c.conclusion));
57566
+ }
57567
+ /**
57568
+ * Whether PR feedback goes to this session: an agent session the user started themselves
57569
+ * (the PM follows up on work it, the orchestrator or an automation started), not archived and
57570
+ * able to take a message.
57571
+ */
57572
+ function receivesPrFeedback(session) {
57573
+ return session.role === "agent" && session.startedBy.kind === "user" && session.parentSessionId === null && !session.archivedAt && OPEN.has(session.status);
57574
+ }
57575
+ /** The feedback one GitHub event carries for the PR's session, if any. Pure. */
57576
+ function prFeedbackFor(event) {
57577
+ if (!PR_EVENTS.has(event.type)) return [];
57578
+ const { pr, review } = event.payload;
57579
+ if (pr.state !== "open" && pr.state !== "draft") return [];
57580
+ const head = pr.headSha ?? "unknown";
57581
+ const short = head.slice(0, 7);
57582
+ const out = [];
57583
+ if (event.type === "pr.checks_failed") {
57584
+ const failed = failingChecks(pr);
57585
+ const list = failed.length ? failed.map((c) => `- ${c.name} (${c.conclusion})${c.url ? `: ${c.url}` : ""}`).join("\n") : "- (GitHub did not say which)";
57586
+ out.push({
57587
+ kind: "checks_failed",
57588
+ key: `checks_failed:${head}`,
57589
+ text: [
57590
+ `CI failed on your pull request #${pr.number} (commit ${short}). Failing checks:`,
57591
+ list,
57592
+ "Read the failure logs (for GitHub Actions: `gh run view <run-id> --log-failed`), fix the cause, and push."
57593
+ ].join("\n")
57594
+ });
57595
+ }
57596
+ if (event.type === "review.submitted" && review?.state === "changes_requested") {
57597
+ const who = review.author ? `@${review.author}` : "A reviewer";
57598
+ const body = review.body.trim() ? `\n\n${clip$2(review.body.trim())}` : "";
57599
+ out.push({
57600
+ kind: "changes_requested",
57601
+ key: `changes_requested:${head}`,
57602
+ text: `${who} requested changes on your pull request #${pr.number}.${body}\n\nRead the review and its comments (\`gh pr view ${pr.number} --comments\`), address them, and push.`
57603
+ });
57604
+ }
57605
+ if (pr.mergeable === "conflicting") out.push({
57606
+ kind: "conflicts",
57607
+ key: `conflicts:${head}`,
57608
+ text: `Your pull request #${pr.number} has merge conflicts with \`${pr.baseBranch}\`. Fetch, merge \`origin/${pr.baseBranch}\` into your branch, resolve the conflicts, run the checks, and push.`
57609
+ });
57610
+ return out;
57611
+ }
57612
+ /**
57613
+ * PR feedback for sessions the user started (§9): when the pull request a session owns fails
57614
+ * its checks, gets a review requesting changes or runs into merge conflicts, the session is
57615
+ * sent a message from CodeDen (queued behind a running turn) so the agent can fix it without
57616
+ * the user relaying it. Each problem is sent once per head commit, and at most
57617
+ * `PR_FEEDBACK_MAX_ROUNDS` messages go out per PR. Off with `sessions.prFeedback`.
57618
+ */
57619
+ var PrFeedbackService = class {
57620
+ deps;
57621
+ unsubscribe;
57622
+ chain = Promise.resolve();
57623
+ constructor(deps) {
57624
+ this.deps = deps;
57625
+ }
57626
+ start() {
57627
+ this.unsubscribe = this.deps.store.subscribe((events) => {
57628
+ this.chain = this.chain.then(() => this.onEvents(events));
57629
+ });
57630
+ }
57631
+ stop() {
57632
+ this.unsubscribe?.();
57633
+ }
57634
+ /** Resolves once the events seen so far are handled (tests). */
57635
+ idle() {
57636
+ return this.chain;
57637
+ }
57638
+ async onEvents(events) {
57639
+ if (!this.deps.settings().sessions.prFeedback) return;
57640
+ for (const event of events) {
57641
+ const feedback = prFeedbackFor(event);
57642
+ if (feedback.length === 0) continue;
57643
+ const { pr } = event.payload;
57644
+ const found = pr.sessionId ? this.deps.repo.find(pr.sessionId) : void 0;
57645
+ if (!found || !receivesPrFeedback(found.session)) continue;
57646
+ for (const item of feedback) await this.deliver(found.session, pr, item);
57647
+ }
57648
+ }
57649
+ async deliver(session, pr, item) {
57650
+ const record = (this.deps.repo.find(session.id)?.startMeta ?? null)?.prFeedback?.[String(pr.number)] ?? {
57651
+ rounds: 0,
57652
+ keys: []
57653
+ };
57654
+ if (record.keys.includes(item.key)) return;
57655
+ if (record.rounds >= 3) {
57656
+ this.deps.log?.(`pr feedback: PR #${pr.number} reached ${record.rounds} rounds; not sent`);
57657
+ return;
57658
+ }
57659
+ const next = {
57660
+ rounds: record.rounds + 1,
57661
+ keys: [...record.keys, item.key].slice(-20)
57662
+ };
57663
+ this.deps.repo.setStartMeta(session.id, (m) => withRecord(m, pr.number, next));
57664
+ try {
57665
+ await this.deps.send(session.id, {
57666
+ text: item.text,
57667
+ attachments: [],
57668
+ from: "codeden"
57669
+ });
57670
+ } catch (error) {
57671
+ this.deps.log?.(`pr feedback for session ${session.id}: ${errorMessage$1(error)}`);
57672
+ }
57673
+ }
57674
+ };
57675
+ function withRecord(meta, number, record) {
57676
+ const base = meta ?? {
57677
+ workspaceMode: "worktree",
57678
+ workspaceFallback: null,
57679
+ forced: null,
57680
+ queuedBehind: []
57681
+ };
57682
+ return {
57683
+ ...base,
57684
+ prFeedback: {
57685
+ ...base.prFeedback,
57686
+ [String(number)]: record
57687
+ }
57688
+ };
57689
+ }
56003
57690
  // ../server/src/services/sessions/questions.ts
56004
57691
  /**
56005
57692
  * Questions agents ask through CodeDen's `ask_user` tool (§8.3). The question becomes a
@@ -56053,7 +57740,7 @@ var QuestionBroker = class {
56053
57740
  * Handle a response if `requestId` belongs to an `ask_user` question. Returns false for
56054
57741
  * provider-native requests (the caller forwards those to the runner).
56055
57742
  */
56056
- async respond(sessionId, requestId, response) {
57743
+ async respond(sessionId, requestId, response, by = "user") {
56057
57744
  const record = this.find(sessionId, requestId);
56058
57745
  if (!record) return false;
56059
57746
  const item = record.item;
@@ -56062,7 +57749,8 @@ var QuestionBroker = class {
56062
57749
  const done = answers ? {
56063
57750
  ...item,
56064
57751
  status: "completed",
56065
- answers
57752
+ answers,
57753
+ decidedBy: by
56066
57754
  } : {
56067
57755
  ...item,
56068
57756
  status: "cancelled"
@@ -56071,11 +57759,12 @@ var QuestionBroker = class {
56071
57759
  const outcome = answers ? {
56072
57760
  status: "answered",
56073
57761
  answers,
56074
- text: formatAnswers(item, answers)
57762
+ text: formatAnswers(item, answers),
57763
+ by
56075
57764
  } : { status: "dismissed" };
56076
57765
  const waiter = this.waiters.get(requestId);
56077
57766
  if (waiter) waiter(outcome);
56078
- else if (outcome.status === "answered") await this.deps.sendMessage()(sessionId, `Answer to your earlier question:\n${outcome.text}`);
57767
+ else if (outcome.status === "answered") await this.deps.sendMessage()(sessionId, `Answer to your earlier question:\n${outcome.text}`, by);
56079
57768
  return true;
56080
57769
  }
56081
57770
  get waitMs() {
@@ -56200,12 +57889,15 @@ var SessionRecovery = class {
56200
57889
  return;
56201
57890
  }
56202
57891
  for (const id of candidates) {
57892
+ const found = repo.find(id)?.session;
57893
+ if (!found || hosted.has(id) || sessions.launching.has(id)) continue;
57894
+ if (!CUT_OFF.includes(found.status)) continue;
57895
+ repo.closeOrphanedTurns(id);
56203
57896
  const session = repo.find(id)?.session;
56204
- if (!session || hosted.has(id) || sessions.launching.has(id)) continue;
56205
- if (!CUT_OFF.includes(session.status)) continue;
56206
- await this.resume(session);
57897
+ if (session && CUT_OFF.includes(session.status)) await this.resume(session);
56207
57898
  }
56208
- for (const id of sessions.sessionsWithQueuedMessages()) if ((repo.find(id)?.session)?.status === "idle") await sessions.flushOutbox(id).catch(() => void 0);
57899
+ sessions.releaseOutboxes();
57900
+ for (const id of sessions.sessionsWithQueuedMessages()) await sessions.flushOutbox(id).catch(() => void 0);
56209
57901
  }
56210
57902
  async resume(session) {
56211
57903
  const provider = this.deps.runner.providers().find((p) => p.id === session.provider);
@@ -56323,6 +58015,37 @@ var SessionRepo = class {
56323
58015
  startedAt: r.startedAt
56324
58016
  }))];
56325
58017
  }
58018
+ /**
58019
+ * Close what a runner that went away without a word (crash, kill, power loss) left open in a
58020
+ * session it no longer hosts (§4.2). Turns still `running` end as interrupted by a restart,
58021
+ * which cancels their approvals and provider questions and dismisses their inbox items; any
58022
+ * other provider request still open is cancelled too, so none of them can keep the session
58023
+ * `waiting`. CodeDen's deferred `ask_user` questions outlive their turn and stay open.
58024
+ * Returns how many turns and requests it closed.
58025
+ */
58026
+ closeOrphanedTurns(sessionId) {
58027
+ const running = this.db.select().from(turns).where(and(eq(turns.sessionId, sessionId), eq(turns.status, "running"))).orderBy(asc(turns.startedAt)).all();
58028
+ const ended = new Set(running.map((t) => t.id));
58029
+ const requests = this.db.select().from(items).where(and(eq(items.sessionId, sessionId), inArray(items.kind, ["approval_request", "user_question"]), eq(items.status, "in_progress"))).all().map(rowToItemRecord).filter((r) => !ended.has(r.turnId)).filter((r) => !(r.item.kind === "user_question" && r.item.deferred));
58030
+ const root = this.find(sessionId)?.session.rootThreadId;
58031
+ const isRoot = (t) => Number(t.threadId === root);
58032
+ const events = [...requests.map((r) => agentEventToNewEvent(sessionId, {
58033
+ type: "item.completed",
58034
+ threadId: r.threadId,
58035
+ turnId: r.turnId,
58036
+ item: {
58037
+ ...r.item,
58038
+ status: "cancelled"
58039
+ }
58040
+ })), ...running.sort((a, b) => isRoot(a) - isRoot(b)).map((t) => agentEventToNewEvent(sessionId, {
58041
+ type: "turn.interrupted",
58042
+ threadId: t.threadId,
58043
+ turnId: t.id,
58044
+ reason: "runner_restart"
58045
+ }))];
58046
+ if (events.length > 0) this.store.append(events);
58047
+ return events.length;
58048
+ }
56326
58049
  item(sessionId, itemId) {
56327
58050
  const row = this.db.select().from(items).where(and(eq(items.sessionId, sessionId), eq(items.id, itemId))).get();
56328
58051
  return row ? rowToItemRecord(row) : void 0;
@@ -56374,6 +58097,46 @@ function readProjectConfig(projectPath) {
56374
58097
  function projectDefaultProvider(project) {
56375
58098
  return project.defaultProvider ?? readProjectConfig(project.path)?.defaultProvider ?? null;
56376
58099
  }
58100
+ // ../server/src/services/sessions/outbox-holds.ts
58101
+ /** Runner failures worth retrying: the runner is away or slow, not refusing the message. */
58102
+ function isTransientRunnerError(error) {
58103
+ const code = runnerErrorCode(error);
58104
+ return code === "runner_unavailable" || code === "timeout";
58105
+ }
58106
+ /**
58107
+ * Sessions whose queued-message delivery failed. The message went back in its place and
58108
+ * delivery pauses, so a failing send never loops on the status change it causes. It resumes
58109
+ * after `retryMs` when the failure was transient, when the runner is ready again
58110
+ * (`releaseAll`), or once any message reaches the session (`release`).
58111
+ */
58112
+ var OutboxHolds = class {
58113
+ opts;
58114
+ held = /* @__PURE__ */ new Map();
58115
+ constructor(opts) {
58116
+ this.opts = opts;
58117
+ }
58118
+ isHeld(sessionId) {
58119
+ return this.held.has(sessionId);
58120
+ }
58121
+ /** Pause delivery; `retry` resumes it on its own after the retry delay. */
58122
+ hold(sessionId, retry) {
58123
+ this.release(sessionId);
58124
+ const timer = retry ? setTimeout(() => {
58125
+ this.held.delete(sessionId);
58126
+ this.opts.onRetry(sessionId);
58127
+ }, this.opts.retryMs ?? 3e4) : void 0;
58128
+ timer?.unref?.();
58129
+ this.held.set(sessionId, timer);
58130
+ }
58131
+ release(sessionId) {
58132
+ const timer = this.held.get(sessionId);
58133
+ if (timer) clearTimeout(timer);
58134
+ this.held.delete(sessionId);
58135
+ }
58136
+ releaseAll() {
58137
+ for (const id of [...this.held.keys()]) this.release(id);
58138
+ }
58139
+ };
56377
58140
  // ../server/src/services/sessions/permissions.ts
56378
58141
  /**
56379
58142
  * Why a session cannot use a permission level on a provider, or null when it can. The
@@ -56492,6 +58255,12 @@ const NOT_STARTED = /* @__PURE__ */ new Set([
56492
58255
  "queued",
56493
58256
  "starting"
56494
58257
  ]);
58258
+ /** Statuses in which the next queued message is delivered. */
58259
+ const FLUSHABLE = /* @__PURE__ */ new Set([
58260
+ "idle",
58261
+ "interrupted",
58262
+ "failed"
58263
+ ]);
56495
58264
  /**
56496
58265
  * Agent sessions: start (with the §8.4 conflict check), messages, steering, interrupts,
56497
58266
  * approvals and stop. Starts return immediately; the runner work happens in the background
@@ -56503,8 +58272,13 @@ var SessionService = class {
56503
58272
  launching = /* @__PURE__ */ new Map();
56504
58273
  /** Rewrites a turn's input at send time (the orchestration status digest, §8.4). */
56505
58274
  inputDecorator;
58275
+ holds;
56506
58276
  constructor(deps) {
56507
58277
  this.deps = deps;
58278
+ this.holds = new OutboxHolds({
58279
+ retryMs: deps.outboxRetryMs,
58280
+ onRetry: (id) => void this.flushOutbox(id).catch(() => void 0)
58281
+ });
56508
58282
  }
56509
58283
  setInputDecorator(fn) {
56510
58284
  this.inputDecorator = fn;
@@ -56637,7 +58411,14 @@ var SessionService = class {
56637
58411
  }
56638
58412
  return this.enqueue(id, req.input, from);
56639
58413
  }
56640
- await this.sendNow(session, req.input);
58414
+ try {
58415
+ await this.sendNow(session, req.input, from);
58416
+ } catch (error) {
58417
+ if (from === "user" || req.whenBusy === "reject" || !isTransientRunnerError(error)) throw error;
58418
+ this.holds.hold(id, true);
58419
+ return this.enqueue(id, req.input, from);
58420
+ }
58421
+ this.holds.release(id);
56641
58422
  return { status: "sent" };
56642
58423
  }
56643
58424
  async steer(id, raw) {
@@ -56663,9 +58444,10 @@ var SessionService = class {
56663
58444
  throw toServiceError$1(e);
56664
58445
  });
56665
58446
  }
56666
- async respond(id, req) {
58447
+ /** Answer a question or approval; `by` is who answered a question (the user or the PM). */
58448
+ async respond(id, req, by = "user") {
56667
58449
  this.deps.repo.get(id);
56668
- if (await this.deps.questions.respond(id, req.requestId, req.response)) return;
58450
+ if (await this.deps.questions.respond(id, req.requestId, req.response, by)) return;
56669
58451
  await this.deps.runner.request({
56670
58452
  name: "session.respond",
56671
58453
  sessionId: id,
@@ -56676,13 +58458,17 @@ var SessionService = class {
56676
58458
  });
56677
58459
  const resolution = req.response.kind === "approval" ? {
56678
58460
  decision: req.response.decision,
56679
- decidedBy: "user"
56680
- } : req.response.kind === "answer" ? { answers: req.response.answers } : { status: "cancelled" };
58461
+ decidedBy: req.response.decidedBy ?? "user"
58462
+ } : req.response.kind === "answer" ? {
58463
+ answers: req.response.answers,
58464
+ decidedBy: by
58465
+ } : { status: "cancelled" };
56681
58466
  this.deps.inbox.closeForRequest(id, req.requestId, resolution);
56682
58467
  }
56683
58468
  async stop(id) {
56684
58469
  const session = this.deps.repo.get(id);
56685
58470
  this.deps.outbox.clear(id);
58471
+ this.holds.release(id);
56686
58472
  if (session.status !== "queued" && session.status !== "created") try {
56687
58473
  await this.deps.runner.request({
56688
58474
  name: "session.stop",
@@ -56699,19 +58485,31 @@ var SessionService = class {
56699
58485
  endedAt: (/* @__PURE__ */ new Date()).toISOString()
56700
58486
  });
56701
58487
  }
56702
- /** Send the next queued message once a session is idle again. */
58488
+ /**
58489
+ * Send the next queued message once no turn is running (idle, or after a failed or
58490
+ * interrupted turn). If delivery fails the message goes back in its place and delivery
58491
+ * pauses (`OutboxHolds`); only an error that is not a runner restart fails the session.
58492
+ */
56703
58493
  async flushOutbox(id) {
58494
+ if (this.holds.isHeld(id)) return;
56704
58495
  const next = this.deps.outbox.peek(id);
56705
58496
  if (!next) return;
56706
58497
  const session = this.deps.repo.get(id);
56707
- if (session.status !== "idle" && session.status !== "interrupted") return;
58498
+ if (!FLUSHABLE.has(session.status)) return;
56708
58499
  this.deps.outbox.remove(next, "sent");
56709
58500
  try {
56710
- await this.sendNow(session, next.input);
58501
+ await this.sendNow(session, next.input, next.from);
56711
58502
  } catch (error) {
56712
- this.deps.launcher.fail(id, `send-${Date.now()}`, error);
58503
+ const transient = isTransientRunnerError(error);
58504
+ this.holds.hold(id, transient);
58505
+ this.deps.outbox.restore(next);
58506
+ if (!transient && session.status !== "failed") this.deps.launcher.fail(id, `send-${Date.now()}`, error);
56713
58507
  }
56714
58508
  }
58509
+ /** The runner is ready again: retry every paused delivery (§4.2). */
58510
+ releaseOutboxes() {
58511
+ this.holds.releaseAll();
58512
+ }
56715
58513
  /**
56716
58514
  * Re-open a session whose turn was cut off by a runner restart and tell the agent to carry
56717
58515
  * on (§4.2). Resumes through the provider's resume token.
@@ -56721,7 +58519,7 @@ var SessionService = class {
56721
58519
  await this.sendNow(session, {
56722
58520
  text,
56723
58521
  attachments: []
56724
- });
58522
+ }, "codeden");
56725
58523
  }
56726
58524
  sessionsWithQueuedMessages() {
56727
58525
  return this.deps.outbox.sessionsWithMessages();
@@ -56729,8 +58527,18 @@ var SessionService = class {
56729
58527
  hasOutbox(id) {
56730
58528
  return this.deps.outbox.count(id) > 0;
56731
58529
  }
56732
- async sendNow(session, raw) {
56733
- const input = this.forRunner(session, this.inputDecorator?.(session, raw) ?? raw);
58530
+ async sendNow(session, raw, from) {
58531
+ const decorated = this.inputDecorator?.(session, raw, from) ?? { input: raw };
58532
+ try {
58533
+ await this.deliver(session, this.forRunner(session, decorated.input));
58534
+ } catch (error) {
58535
+ decorated.failed?.();
58536
+ throw error;
58537
+ }
58538
+ decorated.delivered?.();
58539
+ }
58540
+ /** Hand a turn's input to the runner, resuming the session when it no longer hosts it. */
58541
+ async deliver(session, input) {
56734
58542
  await applyPendingConfig(this.deps.repo, this.deps.runner, session);
56735
58543
  const previous = session.status;
56736
58544
  this.deps.repo.update(session.id, (s) => ({
@@ -56851,6 +58659,98 @@ function fallbackMeta() {
56851
58659
  queuedBehind: []
56852
58660
  };
56853
58661
  }
58662
+ /** The denial an unwatched session gets when nobody answered in time. */
58663
+ function noAnswerDenial(waitMs) {
58664
+ const minutes = Math.max(1, Math.round(waitMs / 6e4));
58665
+ return [
58666
+ `Not approved: nobody answered within ${minutes} minute${minutes === 1 ? "" : "s"}, and this session runs unattended (started by an automation, a project manager or the factory). Don't wait for it or try the same thing again.`,
58667
+ "- Find a way that stays inside your worktree, or skip this step. To publish your work, commit it and call `create_pull_request`.",
58668
+ "- If the task can't be finished without it, say what you needed and why in your final message, or call `finish` with outcome `blocked`, so the user sees it."
58669
+ ].join("\n");
58670
+ }
58671
+ /**
58672
+ * True for sessions the user is unlikely to be watching: started by an automation, a PM or the
58673
+ * factory rather than by the user or the orchestrator they talk to.
58674
+ */
58675
+ function isUnwatched(session) {
58676
+ return session.automationRunId !== null || session.startedBy.kind === "automation" || session.startedBy.kind === "pm";
58677
+ }
58678
+ /**
58679
+ * Approvals nobody is there to answer (§10.1, §11.2). Nothing here ever approves: it denies, with
58680
+ * guidance, so the agent carries on instead of waiting forever (fail closed).
58681
+ * - The PM and the factory's sessions in a dark factory are denied at once, as their questions
58682
+ * are answered at once.
58683
+ * - Sessions started by an automation, a PM or the factory otherwise wait
58684
+ * {@link UNWATCHED_APPROVAL_WAIT_MS} for the user, then are denied. A scheduled run then
58685
+ * finishes instead of blocking the next ones.
58686
+ * Sessions the user started wait for the user, however long.
58687
+ */
58688
+ var UnwatchedApprovals = class {
58689
+ deps;
58690
+ unsubscribe;
58691
+ timers = /* @__PURE__ */ new Map();
58692
+ constructor(deps) {
58693
+ this.deps = deps;
58694
+ }
58695
+ /** Watch new approvals, and pick up the ones already open (a restart keeps their age). */
58696
+ start() {
58697
+ this.unsubscribe ??= this.deps.store.subscribe((events) => this.onEvents(events));
58698
+ for (const item of this.deps.inbox.list()) if (item.kind === "approval") this.track(item);
58699
+ }
58700
+ stop() {
58701
+ this.unsubscribe?.();
58702
+ this.unsubscribe = void 0;
58703
+ for (const timer of this.timers.values()) clearTimeout(timer);
58704
+ this.timers.clear();
58705
+ }
58706
+ onEvents(events) {
58707
+ for (const e of events) {
58708
+ if (e.type !== "inbox.created" && e.type !== "inbox.updated") continue;
58709
+ const { item } = e.payload;
58710
+ if (item.kind !== "approval") continue;
58711
+ if (item.status !== "open") {
58712
+ clearTimeout(this.timers.get(item.id));
58713
+ this.timers.delete(item.id);
58714
+ } else if (e.type === "inbox.created") setImmediate(() => this.track(item));
58715
+ }
58716
+ }
58717
+ track(item) {
58718
+ const { sessionId, requestId } = item;
58719
+ if (item.status !== "open" || !sessionId || !requestId || this.timers.has(item.id)) return;
58720
+ const session = this.deps.repo.find(sessionId)?.session;
58721
+ if (!session) return;
58722
+ if (unattendedSession(this.deps, sessionId)) {
58723
+ this.deny(item, UNATTENDED_APPROVAL);
58724
+ return;
58725
+ }
58726
+ if (!isUnwatched(session)) return;
58727
+ const waitMs = this.deps.waitMs ?? 18e5;
58728
+ const age = (this.deps.now?.() ?? Date.now()) - Date.parse(item.createdAt);
58729
+ const timer = setTimeout(() => {
58730
+ this.timers.delete(item.id);
58731
+ this.deny(item, noAnswerDenial(waitMs));
58732
+ }, Math.max(0, waitMs - (Number.isFinite(age) ? age : 0)));
58733
+ timer.unref?.();
58734
+ this.timers.set(item.id, timer);
58735
+ }
58736
+ async deny(item, message) {
58737
+ if (this.deps.inbox.find(item.id)?.status !== "open") return;
58738
+ if (!item.sessionId || !item.requestId) return;
58739
+ try {
58740
+ await this.deps.respond(item.sessionId, {
58741
+ requestId: item.requestId,
58742
+ response: {
58743
+ kind: "approval",
58744
+ decision: "deny",
58745
+ message,
58746
+ decidedBy: "policy"
58747
+ }
58748
+ });
58749
+ } catch (error) {
58750
+ this.deps.log?.(`approvals: deny ${item.id}: ${errorMessage$1(error)}`);
58751
+ }
58752
+ }
58753
+ };
56854
58754
  // ../server/src/services/sessions/worktree-cleanup.ts
56855
58755
  const DAY_MS = 864e5;
56856
58756
  const REMOVE_TIMEOUT_MS = 12e4;
@@ -56986,6 +58886,7 @@ var WorktreeCleanup = class {
56986
58886
  const message = errorMessage$1(error);
56987
58887
  if (runnerErrorCode(error) === "worktree_dirty" || /modified or untracked/.test(message)) return outcome("kept_changes", "The worktree has uncommitted changes, so it was kept");
56988
58888
  this.deps.log?.(`worktree cleanup: could not remove ${session.worktreePath}: ${message}`);
58889
+ this.reportFailure(session, message);
56989
58890
  return outcome("failed", message);
56990
58891
  }
56991
58892
  this.deps.repo.update(id, (s) => ({
@@ -56994,6 +58895,7 @@ var WorktreeCleanup = class {
56994
58895
  }));
56995
58896
  this.kept.delete(id);
56996
58897
  this.forced.delete(id);
58898
+ this.clearFailure(id);
56997
58899
  this.deps.log?.(`worktree cleanup: removed ${session.worktreePath}`);
56998
58900
  return outcome("removed", discarded ? "Its uncommitted changes were discarded" : null);
56999
58901
  }
@@ -57045,10 +58947,53 @@ var WorktreeCleanup = class {
57045
58947
  if (typeof projectId === "string") this.cleanProject(projectId).catch((error) => this.deps.log?.(`worktree cleanup for project ${projectId}: ${errorMessage$1(error)}`));
57046
58948
  }
57047
58949
  }
58950
+ /**
58951
+ * Put a failed removal in the inbox (`cleanup_failed`), so it can't go unnoticed while the
58952
+ * sweep keeps retrying. One item per session: updated when the reason changes, reopened
58953
+ * after an earlier one was resolved, and left alone once the user dismissed it.
58954
+ */
58955
+ reportFailure(session, message) {
58956
+ const inbox = this.deps.inbox;
58957
+ if (!inbox) return;
58958
+ const id = cleanupInboxId(session.id);
58959
+ const existing = inbox.find(id);
58960
+ const body = `${message}\n\nCodeDen tries again every hour; archive the session again to retry now.`;
58961
+ if (existing?.status === "dismissed" || existing?.status === "open" && existing.body === body) return;
58962
+ const now = this.now().toISOString();
58963
+ inbox.put({
58964
+ id,
58965
+ kind: "cleanup_failed",
58966
+ status: "open",
58967
+ priority: "normal",
58968
+ title: `Couldn't remove the worktree: ${session.title ?? session.purpose}`,
58969
+ body,
58970
+ projectId: session.projectId,
58971
+ sessionId: session.id,
58972
+ threadId: session.rootThreadId,
58973
+ itemId: null,
58974
+ requestId: null,
58975
+ issue: session.issue,
58976
+ pr: session.pr,
58977
+ automationRunId: session.automationRunId,
58978
+ createdAt: existing?.status === "open" ? existing.createdAt : now,
58979
+ updatedAt: now,
58980
+ resolvedAt: null,
58981
+ resolution: null
58982
+ });
58983
+ }
58984
+ /** The worktree is gone: resolve its open `cleanup_failed` item. */
58985
+ clearFailure(sessionId) {
58986
+ const item = this.deps.inbox?.find(cleanupInboxId(sessionId));
58987
+ if (item?.status === "open") this.deps.inbox?.close(item, "resolved", { removed: true });
58988
+ }
57048
58989
  now() {
57049
58990
  return this.deps.now?.() ?? /* @__PURE__ */ new Date();
57050
58991
  }
57051
58992
  };
58993
+ /** The inbox item reporting that a session's worktree could not be removed. */
58994
+ function cleanupInboxId(sessionId) {
58995
+ return `inbox:${sessionId}:worktree-cleanup`;
58996
+ }
57052
58997
  // ../server/src/services/setup/claude-sdk.ts
57053
58998
  /**
57054
58999
  * Claude Code support (§14): reports and installs the Claude Agent SDK into `<home>/deps`.
@@ -57256,8 +59201,30 @@ function expiresWithin(iso, days, now = Date.now()) {
57256
59201
  if (!iso) return false;
57257
59202
  return Date.parse(iso) - now < days * 24 * 60 * 60 * 1e3;
57258
59203
  }
57259
- // ../server/src/services/setup/checks.ts
57260
- /** Commands that sign a tool in, run in the UI's terminal (§14 step 3). */
59204
+ // ../server/src/services/setup/tool-lookup.ts
59205
+ /**
59206
+ * A look-up through an `Exec` only (`<name> --version`): used when a server is given its own
59207
+ * `exec` (tests with fake CLIs) and no `locate`, so no real tool is ever looked for or run.
59208
+ */
59209
+ function execLocate(exec) {
59210
+ return async (name) => {
59211
+ const run = await exec(name, ["--version"], { timeoutMs: 1e4 });
59212
+ if (run.notFound || run.code !== 0) return {
59213
+ version: null,
59214
+ broken: []
59215
+ };
59216
+ const version = /\d+\.\d+\.\d+(?:-[\w.]+)?/.exec(run.stdout)?.[0] ?? null;
59217
+ return {
59218
+ command: {
59219
+ kind: "native",
59220
+ path: run.path ?? name
59221
+ },
59222
+ version,
59223
+ broken: []
59224
+ };
59225
+ };
59226
+ }
59227
+ /** Tools whose sign-in runs in the setup terminal, with the arguments that sign them in. */
57261
59228
  const LOGIN_COMMANDS = {
57262
59229
  claude: [
57263
59230
  "claude",
@@ -57274,6 +59241,68 @@ const LOGIN_COMMANDS = {
57274
59241
  "https"
57275
59242
  ]
57276
59243
  };
59244
+ /**
59245
+ * The sign-in command typed into the setup terminal (§14 step 3). With a resolved `program`
59246
+ * it runs that exact file, quoted for `shell`, so a broken shim or an outdated PATH in the
59247
+ * terminal cannot get in the way: `& 'C:\Users\x\.local\bin\claude.exe' auth login`.
59248
+ */
59249
+ function loginCommandLine(tool, shell, program) {
59250
+ const [name, ...args] = LOGIN_COMMANDS[tool];
59251
+ return shellCommandLine(shell, program || name, args);
59252
+ }
59253
+ function loginFix(tool, shell, program) {
59254
+ return {
59255
+ kind: "login",
59256
+ command: loginCommandLine(tool, shell, program)
59257
+ };
59258
+ }
59259
+ /**
59260
+ * Look a tool up and turn "nothing found" and "found, but nothing runs" into honest checks:
59261
+ * not installed (with the install fix) or installed but broken (with the paths).
59262
+ */
59263
+ async function lookupTool(locate, name, check, notInstalled) {
59264
+ const lookup = await locate(name);
59265
+ const extra = lookup.broken.length > 0 ? { broken: lookup.broken } : {};
59266
+ if (lookup.command) return {
59267
+ found: true,
59268
+ path: lookup.command.path,
59269
+ version: lookup.version,
59270
+ extra
59271
+ };
59272
+ if (lookup.broken.length === 0) return {
59273
+ found: false,
59274
+ result: {
59275
+ ...check,
59276
+ state: "missing",
59277
+ ...notInstalled
59278
+ }
59279
+ };
59280
+ return {
59281
+ found: false,
59282
+ result: {
59283
+ ...check,
59284
+ ...extra,
59285
+ state: "missing",
59286
+ message: brokenInstallMessage(check.label, lookup.broken),
59287
+ fix: notInstalled.fix
59288
+ }
59289
+ };
59290
+ }
59291
+ /** What went wrong running a command, for a check's details (never includes tokens). */
59292
+ function runDetail(command, run) {
59293
+ if (run.timedOut) return `\`${command}\` did not finish in time.`;
59294
+ const parts = [`\`${command}\` exited with code ${run.code ?? "none"}.`];
59295
+ const stderr = clip$1(run.stderr);
59296
+ const stdout = clip$1(run.stdout);
59297
+ if (stderr) parts.push(`stderr: ${stderr}`);
59298
+ if (stdout) parts.push(`stdout: ${stdout}`);
59299
+ if (!stderr && !stdout) parts.push("It printed nothing.");
59300
+ return parts.join("\n");
59301
+ }
59302
+ function clip$1(text) {
59303
+ const t = text.trim();
59304
+ return t.length > 300 ? `${t.slice(0, 297)}...` : t;
59305
+ }
57277
59306
  const LABELS = {
57278
59307
  claude: "Claude Code",
57279
59308
  codex: "Codex",
@@ -57314,10 +59343,6 @@ function installFix(id, platform) {
57314
59343
  };
57315
59344
  }
57316
59345
  }
57317
- const loginFix = (tool) => ({
57318
- kind: "login",
57319
- command: LOGIN_COMMANDS[tool].join(" ")
57320
- });
57321
59346
  function base(id) {
57322
59347
  return {
57323
59348
  id,
@@ -57332,27 +59357,28 @@ function base(id) {
57332
59357
  fix: null
57333
59358
  };
57334
59359
  }
57335
- async function checkClaude({ exec, platform, now }) {
57336
- const check = base("claude");
57337
- const version = await exec("claude", ["--version"], { timeoutMs: 1e4 });
57338
- if (version.notFound || version.code !== 0) return {
57339
- ...check,
57340
- state: "missing",
59360
+ async function checkClaude({ exec, locate, shell, platform, now }) {
59361
+ const tool = await lookupTool(locate, "claude", base("claude"), {
57341
59362
  message: "Claude Code is not installed",
57342
59363
  fix: installFix("claude", platform)
57343
- };
59364
+ });
59365
+ if (!tool.found) return tool.result;
57344
59366
  const installed = {
57345
- ...check,
59367
+ ...base("claude"),
59368
+ ...tool.extra,
57346
59369
  installed: true,
57347
- version: parseVersion(version.stdout),
57348
- executablePath: version.path
59370
+ version: tool.version,
59371
+ executablePath: tool.path
57349
59372
  };
57350
- const auth = parseClaudeAuth((await exec("claude", ["auth", "status"], { timeoutMs: 1e4 })).stdout);
59373
+ const login = loginFix("claude", shell, tool.path);
59374
+ const status = await exec(tool.path, ["auth", "status"], { timeoutMs: 1e4 });
59375
+ const auth = parseClaudeAuth(status.stdout);
57351
59376
  if (!auth) return {
57352
59377
  ...installed,
57353
59378
  state: "warning",
57354
59379
  message: "Could not read the Claude login state",
57355
- fix: loginFix("claude")
59380
+ detail: runDetail("claude auth status", status),
59381
+ fix: login
57356
59382
  };
57357
59383
  const withAuth = {
57358
59384
  ...installed,
@@ -57362,13 +59388,13 @@ async function checkClaude({ exec, platform, now }) {
57362
59388
  ...withAuth,
57363
59389
  state: "missing",
57364
59390
  message: "Not signed in to Claude",
57365
- fix: loginFix("claude")
59391
+ fix: login
57366
59392
  };
57367
59393
  if (expiresWithin(auth.loginExpiresAt, 3, now)) return {
57368
59394
  ...withAuth,
57369
59395
  state: "warning",
57370
59396
  message: "Your Claude login expires soon; sign in again",
57371
- fix: loginFix("claude")
59397
+ fix: login
57372
59398
  };
57373
59399
  return {
57374
59400
  ...withAuth,
@@ -57376,29 +59402,29 @@ async function checkClaude({ exec, platform, now }) {
57376
59402
  message: "Signed in"
57377
59403
  };
57378
59404
  }
57379
- async function checkCodex({ exec, platform }) {
57380
- const check = base("codex");
57381
- const version = await exec("codex", ["--version"], { timeoutMs: 1e4 });
57382
- if (version.notFound || version.code !== 0) return {
57383
- ...check,
57384
- state: "missing",
59405
+ async function checkCodex({ exec, locate, shell, platform }) {
59406
+ const tool = await lookupTool(locate, "codex", base("codex"), {
57385
59407
  message: "Codex is not installed",
57386
59408
  fix: installFix("codex", platform)
57387
- };
59409
+ });
59410
+ if (!tool.found) return tool.result;
57388
59411
  const installed = {
57389
- ...check,
59412
+ ...base("codex"),
59413
+ ...tool.extra,
57390
59414
  installed: true,
57391
- version: parseVersion(version.stdout),
57392
- executablePath: version.path
59415
+ version: tool.version,
59416
+ executablePath: tool.path
57393
59417
  };
57394
- const status = await exec("codex", ["login", "status"], { timeoutMs: 1e4 });
57395
- const login = parseCodexLogin(status.code, `${status.stdout}\n${status.stderr}`);
59418
+ const status = await exec(tool.path, ["login", "status"], { timeoutMs: 1e4 });
59419
+ const login = parseCodexLogin(status.code, `${status.stdout}
59420
+ ${status.stderr}`);
57396
59421
  if (!login.loggedIn) return {
57397
59422
  ...installed,
57398
59423
  loggedIn: false,
57399
59424
  state: "missing",
57400
59425
  message: "Not signed in to Codex",
57401
- fix: loginFix("codex")
59426
+ ...status.code !== 0 && !/not logged in/i.test(`${status.stdout}${status.stderr}`) ? { detail: runDetail("codex login status", status) } : {},
59427
+ fix: loginFix("codex", shell, tool.path)
57402
59428
  };
57403
59429
  return {
57404
59430
  ...installed,
@@ -57408,37 +59434,36 @@ async function checkCodex({ exec, platform }) {
57408
59434
  message: "Signed in"
57409
59435
  };
57410
59436
  }
57411
- async function checkGh({ exec, platform }) {
57412
- const check = base("gh");
57413
- const version = await exec("gh", ["--version"], { timeoutMs: 1e4 });
57414
- if (version.notFound || version.code !== 0) return {
57415
- ...check,
57416
- state: "missing",
59437
+ async function checkGh({ exec, locate, shell, platform }) {
59438
+ const tool = await lookupTool(locate, "gh", base("gh"), {
57417
59439
  message: "GitHub CLI is not installed (optional)",
57418
59440
  fix: installFix("gh", platform)
57419
- };
59441
+ });
59442
+ if (!tool.found) return tool.result;
57420
59443
  const installed = {
57421
- ...check,
59444
+ ...base("gh"),
59445
+ ...tool.extra,
57422
59446
  installed: true,
57423
- version: parseVersion(version.stdout),
57424
- executablePath: version.path
59447
+ version: tool.version,
59448
+ executablePath: tool.path
57425
59449
  };
57426
- let auth = parseGhAuthJson((await exec("gh", [
59450
+ let auth = parseGhAuthJson((await exec(tool.path, [
57427
59451
  "auth",
57428
59452
  "status",
57429
59453
  "--json",
57430
59454
  "hosts"
57431
59455
  ], { timeoutMs: 1e4 })).stdout);
57432
59456
  if (!auth) {
57433
- const text = await exec("gh", ["auth", "status"], { timeoutMs: 1e4 });
57434
- auth = parseGhAuthText(text.code, `${text.stdout}\n${text.stderr}`);
59457
+ const text = await exec(tool.path, ["auth", "status"], { timeoutMs: 1e4 });
59458
+ auth = parseGhAuthText(text.code, `${text.stdout}
59459
+ ${text.stderr}`);
57435
59460
  }
57436
59461
  if (!auth.loggedIn) return {
57437
59462
  ...installed,
57438
59463
  loggedIn: false,
57439
59464
  state: "missing",
57440
59465
  message: "Not signed in to GitHub",
57441
- fix: loginFix("gh")
59466
+ fix: loginFix("gh", shell, tool.path)
57442
59467
  };
57443
59468
  return {
57444
59469
  ...installed,
@@ -57576,7 +59601,7 @@ var SetupService = class {
57576
59601
  ...this.cached,
57577
59602
  setupCompleted: this.deps.settings.get().setup.completed
57578
59603
  };
57579
- this.running ??= this.runChecks().finally(() => {
59604
+ this.running ??= this.refreshThenCheck(opts.refresh === true).finally(() => {
57580
59605
  this.running = void 0;
57581
59606
  });
57582
59607
  return this.running;
@@ -57589,18 +59614,35 @@ var SetupService = class {
57589
59614
  } });
57590
59615
  return true;
57591
59616
  }
57592
- /** The command to run in a terminal to sign `tool` in. */
59617
+ /**
59618
+ * The command to run in a terminal to sign `tool` in: the copy the last check found, by
59619
+ * its full path, so the terminal's own PATH lookup is not involved.
59620
+ */
57593
59621
  loginCommand(tool) {
57594
- const [command, ...args] = LOGIN_COMMANDS[tool];
59622
+ const check = this.cached?.checks.find((c) => c.id === tool);
59623
+ const program = check?.installed ? check.executablePath : null;
59624
+ const args = LOGIN_COMMANDS[tool].slice(1);
57595
59625
  return {
57596
59626
  tool,
57597
- command: [command, ...args].join(" "),
59627
+ command: loginCommandLine(tool, this.shell(), program),
57598
59628
  args
57599
59629
  };
57600
59630
  }
59631
+ async refreshThenCheck(refresh) {
59632
+ if (refresh) try {
59633
+ await (this.deps.refreshPath ?? refreshProcessPath)();
59634
+ } catch {}
59635
+ return this.runChecks();
59636
+ }
59637
+ shell() {
59638
+ return this.deps.shell ?? defaultShell();
59639
+ }
57601
59640
  async runChecks() {
59641
+ const userHome = this.deps.userHome;
57602
59642
  const env = {
57603
59643
  exec: this.deps.exec,
59644
+ locate: this.deps.locate ?? ((name) => findExecutable(name, { home: userHome })),
59645
+ shell: this.shell(),
57604
59646
  platform: this.deps.platform ?? process.platform,
57605
59647
  nodeVersion: this.deps.nodeVersion ?? process.version,
57606
59648
  now: this.now(),
@@ -58300,7 +60342,7 @@ function billingFromSetup(status) {
58300
60342
  // ../server/src/services/usage/prices.ts
58301
60343
  const ANTHROPIC = "https://platform.claude.com/docs/en/about-claude/pricing";
58302
60344
  const OPENAI = "https://developers.openai.com/api/docs/pricing";
58303
- const AS_OF = "2026-11-07";
60345
+ const AS_OF = "2026-11-10";
58304
60346
  function table(provider, sourceUrl, rows) {
58305
60347
  return rows.map(([model, label, input, output, read, write]) => ({
58306
60348
  provider,
@@ -58321,6 +60363,14 @@ function table(provider, sourceUrl, rows) {
58321
60363
  * override or add models in the usage settings.
58322
60364
  */
58323
60365
  const BUNDLED_PRICES = [...table("claude", ANTHROPIC, [
60366
+ [
60367
+ "claude-fable-5-2",
60368
+ "Fable 5.2",
60369
+ 8,
60370
+ 40,
60371
+ .2,
60372
+ 10
60373
+ ],
58324
60374
  [
58325
60375
  "claude-fable-5-1",
58326
60376
  "Fable 5.1",
@@ -58449,6 +60499,14 @@ const BUNDLED_PRICES = [...table("claude", ANTHROPIC, [
58449
60499
  .1,
58450
60500
  1.25
58451
60501
  ],
60502
+ [
60503
+ "fable",
60504
+ "Fable (latest)",
60505
+ 8,
60506
+ 40,
60507
+ .2,
60508
+ 10
60509
+ ],
58452
60510
  [
58453
60511
  "opus",
58454
60512
  "Opus (latest)",
@@ -60159,14 +62217,15 @@ function createServices(opts) {
60159
62217
  db: opts.db,
60160
62218
  store: opts.store,
60161
62219
  waitMs: opts.askUserWaitMs,
60162
- sendMessage: () => async (sessionId, text) => {
62220
+ sendMessage: () => async (sessionId, text, by) => {
62221
+ const input = by === "pm" ? messageFrom("pm", text) : {
62222
+ text,
62223
+ attachments: []
62224
+ };
60163
62225
  await sessions?.send(sessionId, {
60164
- input: {
60165
- text,
60166
- attachments: []
60167
- },
62226
+ input,
60168
62227
  whenBusy: "queue"
60169
- });
62228
+ }, by);
60170
62229
  }
60171
62230
  });
60172
62231
  let sessions;
@@ -60238,6 +62297,9 @@ function createServices(opts) {
60238
62297
  const setup = new SetupService({
60239
62298
  exec,
60240
62299
  settings: opts.settings,
62300
+ locate: opts.locate ?? (opts.exec ? execLocate(opts.exec) : void 0),
62301
+ refreshPath: opts.refreshPath,
62302
+ userHome,
60241
62303
  onChecked: () => redetectProviders(opts.runner, opts.log)
60242
62304
  });
60243
62305
  const claudeSdk = new ClaudeSdkService({
@@ -60306,9 +62368,19 @@ function createServices(opts) {
60306
62368
  store: opts.store,
60307
62369
  settings: () => opts.settings.get(),
60308
62370
  stopSession: (id) => sessions.stop(id),
62371
+ inbox,
60309
62372
  ...opts.worktreeCleanup,
60310
62373
  log: opts.log
60311
62374
  });
62375
+ const unwatchedApprovals = new UnwatchedApprovals({
62376
+ store: opts.store,
62377
+ repo: sessionRepo,
62378
+ projects,
62379
+ inbox,
62380
+ respond: (id, req) => sessions.respond(id, req),
62381
+ ...opts.unwatchedApprovals,
62382
+ log: opts.log
62383
+ });
60312
62384
  const services = {
60313
62385
  db: opts.db,
60314
62386
  store: opts.store,
@@ -60334,7 +62406,9 @@ function createServices(opts) {
60334
62406
  artifacts,
60335
62407
  browser: new BrowserService({
60336
62408
  runner: opts.runner,
60337
- repo: sessionRepo
62409
+ repo: sessionRepo,
62410
+ settings: () => opts.settings.get(),
62411
+ log: opts.log
60338
62412
  }),
60339
62413
  updates: createUpdates(opts),
60340
62414
  images: new ImageService({
@@ -60358,6 +62432,30 @@ function createServices(opts) {
60358
62432
  usage,
60359
62433
  factory,
60360
62434
  worktreeCleanup,
62435
+ unwatchedApprovals,
62436
+ prFeedback: new PrFeedbackService({
62437
+ store: opts.store,
62438
+ repo: sessionRepo,
62439
+ settings: () => opts.settings.get(),
62440
+ send: (id, input) => sessions.send(id, {
62441
+ input,
62442
+ whenBusy: "queue"
62443
+ }, "codeden"),
62444
+ log: opts.log
62445
+ }),
62446
+ autoRetry: new AutoRetry({
62447
+ db: opts.db,
62448
+ store: opts.store,
62449
+ repo: sessionRepo,
62450
+ inbox,
62451
+ settings: () => opts.settings.get(),
62452
+ send: (id, input) => sessions.send(id, {
62453
+ input,
62454
+ whenBusy: "queue"
62455
+ }, "codeden"),
62456
+ ...opts.autoRetry,
62457
+ log: opts.log
62458
+ }),
60361
62459
  configFiles: new ConfigFiles({
60362
62460
  projects,
60363
62461
  home: userHome,
@@ -60555,6 +62653,8 @@ async function startServer(opts) {
60555
62653
  runner: runnerPort(() => runner),
60556
62654
  secrets,
60557
62655
  exec: opts.exec,
62656
+ locate: opts.locate,
62657
+ refreshPath: opts.refreshPath,
60558
62658
  utility: opts.utility,
60559
62659
  userHome: opts.userHome,
60560
62660
  projectsDir: opts.projectsDir,
@@ -60569,6 +62669,7 @@ async function startServer(opts) {
60569
62669
  selfUpdate: opts.selfUpdate,
60570
62670
  updates: opts.updates,
60571
62671
  worktreeCleanup: opts.worktreeCleanup,
62672
+ autoRetry: opts.autoRetry,
60572
62673
  log
60573
62674
  });
60574
62675
  services.images.refresh();
@@ -60580,6 +62681,9 @@ async function startServer(opts) {
60580
62681
  services.automations.start();
60581
62682
  services.factory.start();
60582
62683
  services.worktreeCleanup.start();
62684
+ services.unwatchedApprovals.start();
62685
+ services.prFeedback.start();
62686
+ services.autoRetry.start();
60583
62687
  const stopUpdateEvents = services.updates.onChange((status) => hub.broadcastUpdate(status));
60584
62688
  services.updates.start();
60585
62689
  let baseOrigins = /* @__PURE__ */ new Set();
@@ -60608,7 +62712,7 @@ async function startServer(opts) {
60608
62712
  cookieName: tokenCookie,
60609
62713
  allowedOrigins,
60610
62714
  publicPaths: /* @__PURE__ */ new Set(["/api/health", "/mcp"]),
60611
- loopbackOnlyPaths: /* @__PURE__ */ new Set(["/mcp"]),
62715
+ loopbackOnlyPaths: /* @__PURE__ */ new Set(["/mcp", ...BROWSER_LOOPBACK_ONLY_PATHS]),
60612
62716
  remote: {
60613
62717
  enabled: () => network.enabled,
60614
62718
  ownHosts: () => network.ownHosts(),
@@ -60659,22 +62763,7 @@ async function startServer(opts) {
60659
62763
  } }, 400);
60660
62764
  return upgradeWebSocket(() => terminals.handlers(query.data))(c, next);
60661
62765
  });
60662
- const browserBridge = new BrowserBridge({
60663
- browser: services.browser,
60664
- log
60665
- });
60666
- app.get("/ws/browser", async (c, next) => {
60667
- const query = BrowserWsQuery.safeParse(c.req.query());
60668
- if (!query.success) return c.json({ error: {
60669
- code: "bad_request",
60670
- message: "Invalid browser query"
60671
- } }, 400);
60672
- if (!services.sessionRepo.find(query.data.sessionId)) return c.json({ error: {
60673
- code: "session_not_found",
60674
- message: "No such session"
60675
- } }, 404);
60676
- return upgradeWebSocket(() => browserBridge.handlers(query.data))(c, next);
60677
- });
62766
+ const browserBridge = mountBrowserSockets(app, services, log);
60678
62767
  mountWeb(app, findWebDir(opts.webDir));
60679
62768
  if (network.store.enabled && !network.store.passwordSet) log("network access is on but no password is set: listening on 127.0.0.1 only");
60680
62769
  port = await listener.listen(opts.port, network.desiredHost());
@@ -60723,6 +62812,9 @@ async function startServer(opts) {
60723
62812
  services.automations.stop();
60724
62813
  services.factory.stop();
60725
62814
  services.worktreeCleanup.stop();
62815
+ services.unwatchedApprovals.stop();
62816
+ services.prFeedback.stop();
62817
+ services.autoRetry.stop();
60726
62818
  services.updates.stop();
60727
62819
  await services.localStt.stop();
60728
62820
  stopUpdateEvents();
@@ -61612,4 +63704,4 @@ main().then((code) => process.exit(code), (error) => {
61612
63704
  });
61613
63705
  export {};
61614
63706
 
61615
- //# sourceMappingURL=main-CKLHi4sz.js.map
63707
+ //# sourceMappingURL=main-BQwSt6Az.js.map