codeden 0.6.2 → 0.6.3

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 (36) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/drizzle/20261112140714_turn_fork_point/migration.sql +1 -0
  3. package/dist/drizzle/20261112140714_turn_fork_point/snapshot.json +3524 -0
  4. package/dist/{main-hdQfX9V6.js → main-Bx58CdV4.js} +1375 -559
  5. package/dist/main-Bx58CdV4.js.map +1 -0
  6. package/dist/{main-DZBmzxSH.js → main-CMt4z1Z9.js} +2 -2
  7. package/dist/{main-DZBmzxSH.js.map → main-CMt4z1Z9.js.map} +1 -1
  8. package/dist/{mediaType-nJs-4z5U.js → mediaType-BCz-hbZl.js} +2 -2
  9. package/dist/{mediaType-nJs-4z5U.js.map → mediaType-BCz-hbZl.js.map} +1 -1
  10. package/dist/{runner-DYf8dV43.js → runner-CiaiUWOE.js} +636 -71
  11. package/dist/runner-CiaiUWOE.js.map +1 -0
  12. package/dist/runner.js +1 -1
  13. package/dist/{server-test-COAiKxTV.js → server-test-b0hHPqXb.js} +3 -3
  14. package/dist/{server-test-COAiKxTV.js.map → server-test-b0hHPqXb.js.map} +1 -1
  15. package/dist/{tree-CF7Q8tXK.js → tree-31WAOm5k.js} +184 -23
  16. package/dist/tree-31WAOm5k.js.map +1 -0
  17. package/dist/web/assets/{core-Rhf-hNq6.js → core-FqX7NQzW.js} +1 -1
  18. package/dist/web/assets/engine-oniguruma-C5UP18m1.js +1 -0
  19. package/dist/web/assets/{highlighted-body-JLMTFACH-DcKwRTm5.js → highlighted-body-JLMTFACH-BMKFJRL-.js} +1 -1
  20. package/dist/web/assets/{highlights-DCtMC0YR.js → highlights-9yuGk1pg.js} +1 -1
  21. package/dist/web/assets/{index-BLggvVMT.js → index-C3QkKvUn.js} +233 -233
  22. package/dist/web/assets/index-CmllrYFx.css +1 -0
  23. package/dist/web/assets/langs-DfCgj44a.js +1 -0
  24. package/dist/web/assets/mermaid-WFAXDIZX-zTA5f8lD.js +1 -0
  25. package/dist/web/assets/{shiki-B3-ws3OK.js → shiki-Qa-y3xHo.js} +2 -2
  26. package/dist/web/assets/{stream-tokenizer-hKFrMxml.js → stream-tokenizer-VL43-ily.js} +1 -1
  27. package/dist/web/assets/{themes-Cvhh4Ovr.js → themes-Di4DO5zr.js} +2 -2
  28. package/dist/web/index.html +2 -2
  29. package/package.json +1 -1
  30. package/dist/main-hdQfX9V6.js.map +0 -1
  31. package/dist/runner-DYf8dV43.js.map +0 -1
  32. package/dist/tree-CF7Q8tXK.js.map +0 -1
  33. package/dist/web/assets/engine-oniguruma-BbpT993F.js +0 -1
  34. package/dist/web/assets/index-JjF9o00P.css +0 -1
  35. package/dist/web/assets/langs-B2ZGl3f_.js +0 -1
  36. package/dist/web/assets/mermaid-WFAXDIZX-C2_MUy_a.js +0 -1
@@ -1,7 +1,7 @@
1
1
  import { n as __require, t as __commonJSMin } from "./rolldown-runtime-DegdyBIC.js";
2
- import { $ as StarterSetRequest, $n as InboxItemKind, $t as FactoryAllowLargeChangeRequest, A as BrowserHostClientMessage, An as EditQueuedMessageRequest, At as LOCAL_STT_SAMPLE_RATE, B as AUTOMATION_IMPORT_LIMITS, Bt as NOTICE_BATCH_CODE, C as RemoteAccessFile, Cn as AutomationGuards, Cr as providersWith, Ct as UpdateProjectRequest, D as SignInRequest, Dn as Session, Dt as READ_ALOUD_FULL_MAX_CHARS, E as SetNetworkPasswordResponse, En as TriagePolicy, Er as ReasoningEffort, Et as agentEventToNewEvent, F as BrowserNavigateRequest, Fn as SpeechSummaryRequest, Ft as UpdateInstallRequest, G as AutomationFileFrontmatter, Gn as ConflictDecision, Gr as string, Gt as GithubIssue, H as DuplicateAutomationRequest, Ht as CreateGithubIssueRequest, I as BrowserNewTabRequest, In as TerminalClientMessage, Ir as int, It as UpdateStatus, J as CreateAutomationRequest, Jn as Project, Jt as GithubPr, K as AutomationGuardsPatch, Kn as StartedBy, Kt as GithubLabelRequest, L as BrowserOpenRequest, Ln as TerminalQuery, Lt as compareVersions$1, M as BrowserClientMessage, Mn as GitPushRequest, Mr as array, Mt as SttQuery, N as BrowserDevServerRequest, Nn as RestoreFilesRequest, Nr as boolean, Nt as TtsRequest, O as passwordProblem, On as SessionStatus, Or as datetime, Ot as Settings, P as BrowserHistoryRequest, Pn as SessionResult, Pt as AppPointer, Q as SetAutomationEnabledRequest, Qn as InboxItem, Qr as prettifyError, Qt as parseGithubPrId, R as BrowserViewportRequest, Rn as RunnerCommandResults, Rt as isNewerVersion, S as PASSWORD_RULE_TEXT, Sn as AutomationAction, Sr as agentConfigFeatures, St as UiClientMessage, T as SetNetworkPasswordRequest, Tn as AutomationTrigger, Tr as ProviderId, Tt as AGENT_EVENT_PREFIX, U as ExportAutomationsQuery, Un as SessionOptions, Ut as GithubAuthorAssociation, V as AutomationImportSource, Vr as object, Vt as NoticeBatchData, W as ImportAutomationsRequest, Wn as ProjectConfigFile, Wt as GithubCommentRequest, X as ListAutomationsQuery, Xn as DEFAULT_VISION_PATH, Xr as toJSONSchema, Xt as TriageIssueRequest, Y as ListAutomationRunsQuery, Yn as sortProjects, Yr as ZodError, Yt as MergePrRequest, Z as RunAutomationRequest, Zn as FactoryLimits, Zt as githubPrId, _ as ListNoticesQuery, _n as FactorySetTypeRequest, _r as AgentConfigProvider, _t as SessionStartMeta, an as REVIEW_SUMMARY_MAX, ar as messageFrom, at as CreateProjectRequest, b as UpdateSessionRequest, bn as Artifact, br as McpServerTarget, bt as SteerRequest, c as UsageSettings, cn as SetAutonomyModeRequest, ct as ForkSessionRequest, d as RunnerLock, dn as FactoryAdmitRequest, dr as HookHandler, dt as LoginToolId, en as FactoryDecisionRequest, et as TriageDecisionRequest, f as RunnerToServerMessage, fn as FactoryAnalysisReport, fr as ShelvedHook, ft as ReorderProjectsRequest, g as ListConflictsQuery, gn as FactoryReproductionReport, gr as AgentConfigLocator, gt as SendMessageRequest, h as ArchiveSessionRequest, hn as FactoryPlanRoundRequest, hr as AgentConfigListQuery, ht as SecretKey, in as PutVisionRequest, it as UpdateAutomationRequest, j as summarizeActivity, jn as GitCommitRequest, jt as LocalSttModelParams, k as ImageTestRequest, kn as DiffScope, kr as _enum, kt as speechExcerpt, l as SessionFileQuery, ln as isFactoryMode, lr as McpServerTestRequest, lt as ListInboxQuery, mn as FactoryFinding, mr as AgentConfigDiffRequest, mt as RespondRequest, n as killTree$1, nn as KillSwitchRequest, nr as oneLineDescription, nt as UpcomingRunsQuery, o as CODEDEN_VERSION, on as ReviewVerdictSubmission, or as MCP_TEST_DEFAULT_TIMEOUT_MS, ot as FileReadQuery, p as ServerLock, pn as FactoryFileIssueRequest, pr as AgentConfigDeleteQuery, pt as ResolveInboxRequest, q as AutomationProjectRequest, qn as WorkKey, qt as GithubListQuery, rn as LEGACY_SIZE_LIMITS, rr as winningCommands, rt as UpdateAutomationProjectSettingsRequest, s as UsageQuery, sn as SCOUT_UNAVAILABLE, st as FileWriteRequest, t as isAlive, tn as FactoryTuning, tr as SessionUpdatedEvent, tt as TriageReport, un as truncateReviewSummary, ur as HookChangeRequest, ut as ListSessionsQuery, v as ResolveConflictRequest, vn as ScoutState, vr as AgentConfigWriteRequest, vt as SetSecretRequest, w as SetNetworkAccessRequest, wn as AutomationRun, wr as AutonomyLevel, wt as HealthResponse, x as NetworkStatus, xn as Automation, xr as McpServerUpsertRequest, xt as ThreadItemsQuery, y as RoleMessageRequest, yn as pipelineStep, yr as McpServerDiffRequest, yt as StartSessionRequest, z as BrowserWsQuery, zt as ConflictRecord } from "./tree-CF7Q8tXK.js";
3
- import { A as isElectronRuntime, C as installClaudeSdk, D as acquireInstallLock, E as resolveNpm, M as import_websocket_server, N as wrapper_default, O as brokenInstallMessage, S as importClaudeSdkFrom, T as runNpm, _ as claudeMemoryDir, a as bufferToFormData, b as claudeSdkSource, c as refreshProcessPath, d as ImageEngineRegistry, f as createImageEngineRegistry, g as ImageGenerationError, h as resolveImageOutput, i as tryDecodeURIComponent, j as ownNodeEnv, k as findExecutable, l as defaultShell, m as imageInfo, n as readLock, o as createAdaptorServer, p as IMAGE_ASPECT_RATIOS, r as Hono, s as upgradeWebSocket, u as shellCommandLine, v as findGitRoot, w as isClaudeSdkInstalled, x as CLAUDE_SDK_VERSION, y as mainRepoRoot } from "./runner-DYf8dV43.js";
4
- import { A as ListRootsResultSchema, C as InitializedNotificationSchema, G as isJSONRPCResultResponse, J as safeParse, K as getLiteralValue, N as LoggingLevelSchema, P as McpError, R as SUPPORTED_PROTOCOL_VERSIONS, T as LATEST_PROTOCOL_VERSION, U as isJSONRPCErrorResponse, V as isInitializeRequest, W as isJSONRPCRequest, _ as ElicitResultSchema, a as AjvJsonSchemaValidator, c as CallToolRequestSchema, f as CreateMessageResultSchema, i as assertToolsCallTaskCapability, j as ListToolsRequestSchema, l as CallToolResultSchema, m as CreateTaskResultSchema, o as Protocol, p as CreateMessageResultWithToolsSchema, q as getObjectShape, r as assertClientRequestTaskCapability, s as mergeCapabilities, t as isJsonContentType, v as EmptyResultSchema, w as JSONRPCMessageSchema, x as InitializeRequestSchema, y as ErrorCode, z as SetLevelRequestSchema } from "./mediaType-nJs-4z5U.js";
2
+ import { $ as StarterSetRequest, $n as InboxItemKind, $r as ZodError, $t as FactoryAllowLargeChangeRequest, A as BrowserHostClientMessage, An as EditQueuedMessageRequest, Ar as ReasoningEffort, At as LOCAL_STT_SAMPLE_RATE, B as AUTOMATION_IMPORT_LIMITS, Br as int, Bt as NOTICE_BATCH_CODE, C as RemoteAccessFile, Cn as AutomationGuards, Cr as McpServerDiffRequest, Ct as UpdateProjectRequest, D as SignInRequest, Dn as Session, Dr as providersWith, Dt as READ_ALOUD_FULL_MAX_CHARS, E as SetNetworkPasswordResponse, En as TriagePolicy, Er as agentConfigFeatures, Et as agentEventToNewEvent, F as BrowserNavigateRequest, Fn as SpeechSummaryRequest, Ft as UpdateInstallRequest, G as AutomationFileFrontmatter, Gn as ConflictDecision, Gr as object, Gt as GithubIssue, H as DuplicateAutomationRequest, Ht as CreateGithubIssueRequest, I as BrowserNewTabRequest, In as TerminalClientMessage, Ir as array, It as UpdateStatus, J as CreateAutomationRequest, Jn as Project, Jt as GithubPr, K as AutomationGuardsPatch, Kn as StartedBy, Kt as GithubLabelRequest, L as BrowserOpenRequest, Ln as TerminalQuery, Lr as boolean, Lt as compareVersions$1, M as BrowserClientMessage, Mn as GitPushRequest, Mr as datetime, Mt as SttQuery, N as BrowserDevServerRequest, Nn as RestoreFilesRequest, Nr as _enum, Nt as TtsRequest, O as passwordProblem, On as SessionStatus, Or as AutonomyLevel, Ot as Settings, P as BrowserHistoryRequest, Pn as SessionResult, Pt as AppPointer, Q as SetAutomationEnabledRequest, Qn as InboxItem, Qt as parseGithubPrId, R as BrowserViewportRequest, Rn as RunnerCommandResults, Rt as isNewerVersion, S as PASSWORD_RULE_TEXT, Sn as AutomationAction, Sr as AgentConfigWriteRequest, St as UiClientMessage, T as SetNetworkPasswordRequest, Tn as AutomationTrigger, Tr as McpServerUpsertRequest, Tt as AGENT_EVENT_PREFIX, U as ExportAutomationsQuery, Un as SessionOptions, Ut as GithubAuthorAssociation, V as AutomationImportSource, Vt as NoticeBatchData, W as ImportAutomationsRequest, Wn as ProjectConfigFile, Wt as GithubCommentRequest, X as ListAutomationsQuery, Xn as DEFAULT_VISION_PATH, Xt as TriageIssueRequest, Y as ListAutomationRunsQuery, Yn as sortProjects, Yr as string, Yt as MergePrRequest, Z as RunAutomationRequest, Zn as FactoryLimits, Zt as githubPrId, _ as ListNoticesQuery, _n as FactorySetTypeRequest, _r as AgentConfigDeleteQuery, _t as SessionStartMeta, an as REVIEW_SUMMARY_MAX, ar as winningCommands, at as CreateProjectRequest, b as UpdateSessionRequest, bn as Artifact, br as AgentConfigLocator, bt as SteerRequest, c as UsageSettings, cn as SetAutonomyModeRequest, cr as messageFrom, ct as ForkSessionRequest, d as RunnerLock, dn as FactoryAdmitRequest, dt as LoginToolId, ei as toJSONSchema, en as FactoryDecisionRequest, et as TriageDecisionRequest, f as RunnerToServerMessage, fn as FactoryAnalysisReport, fr as McpServerTestRequest, ft as ReorderProjectsRequest, g as ListConflictsQuery, gn as FactoryReproductionReport, gr as ShelvedHook, gt as SendMessageRequest, h as ArchiveSessionRequest, hn as FactoryPlanRoundRequest, hr as HookHandler, ht as SecretKey, in as PutVisionRequest, ir as oneLineDescription, it as UpdateAutomationRequest, j as summarizeActivity, jn as GitCommitRequest, jt as LocalSttModelParams, k as ImageTestRequest, kn as DiffScope, kr as ProviderId, kt as speechExcerpt, l as SessionFileQuery, ln as isFactoryMode, lr as MCP_TEST_DEFAULT_TIMEOUT_MS, lt as ListInboxQuery, mn as FactoryFinding, mr as HookChangeRequest, mt as RespondRequest, n as killTree$1, ni as prettifyError, nn as KillSwitchRequest, nr as mergeItemDeltas, nt as UpcomingRunsQuery, o as CODEDEN_VERSION, on as ReviewVerdictSubmission, ot as FileReadQuery, p as ServerLock, pn as FactoryFileIssueRequest, pr as SkillSwitchRequest, pt as ResolveInboxRequest, q as AutomationProjectRequest, qn as WorkKey, qt as GithubListQuery, rn as LEGACY_SIZE_LIMITS, rr as ProjectCommandsQuery, rt as UpdateAutomationProjectSettingsRequest, s as UsageQuery, sn as SCOUT_UNAVAILABLE, st as FileWriteRequest, t as isAlive, tn as FactoryTuning, tr as SessionUpdatedEvent, tt as TriageReport, un as truncateReviewSummary, ut as ListSessionsQuery, v as ResolveConflictRequest, vn as ScoutState, vr as AgentConfigDiffRequest, vt as SetSecretRequest, w as SetNetworkAccessRequest, wn as AutomationRun, wr as McpServerTarget, wt as HealthResponse, x as NetworkStatus, xn as Automation, xr as AgentConfigProvider, xt as ThreadItemsQuery, y as RoleMessageRequest, yn as pipelineStep, yr as AgentConfigListQuery, yt as StartSessionRequest, z as BrowserWsQuery, zt as ConflictRecord } from "./tree-31WAOm5k.js";
3
+ import { A as isElectronRuntime, C as installClaudeSdk, D as acquireInstallLock, E as resolveNpm, M as import_websocket_server, N as wrapper_default, O as brokenInstallMessage, S as importClaudeSdkFrom, T as runNpm, _ as claudeMemoryDir, a as bufferToFormData, b as claudeSdkSource, c as refreshProcessPath, d as ImageEngineRegistry, f as createImageEngineRegistry, g as ImageGenerationError, h as resolveImageOutput, i as tryDecodeURIComponent, j as ownNodeEnv, k as findExecutable, l as defaultShell, m as imageInfo, n as readLock, o as createAdaptorServer, p as IMAGE_ASPECT_RATIOS, r as Hono, s as upgradeWebSocket, u as shellCommandLine, v as findGitRoot, w as isClaudeSdkInstalled, x as CLAUDE_SDK_VERSION, y as mainRepoRoot } from "./runner-CiaiUWOE.js";
4
+ import { A as ListRootsResultSchema, C as InitializedNotificationSchema, G as isJSONRPCResultResponse, J as safeParse, K as getLiteralValue, N as LoggingLevelSchema, P as McpError, R as SUPPORTED_PROTOCOL_VERSIONS, T as LATEST_PROTOCOL_VERSION, U as isJSONRPCErrorResponse, V as isInitializeRequest, W as isJSONRPCRequest, _ as ElicitResultSchema, a as AjvJsonSchemaValidator, c as CallToolRequestSchema, f as CreateMessageResultSchema, i as assertToolsCallTaskCapability, j as ListToolsRequestSchema, l as CallToolResultSchema, m as CreateTaskResultSchema, o as Protocol, p as CreateMessageResultWithToolsSchema, q as getObjectShape, r as assertClientRequestTaskCapability, s as mergeCapabilities, t as isJsonContentType, v as EmptyResultSchema, w as JSONRPCMessageSchema, x as InitializeRequestSchema, y as ErrorCode, z as SetLevelRequestSchema } from "./mediaType-BCz-hbZl.js";
5
5
  import { createRequire } from "node:module";
6
6
  import fs, { accessSync, appendFileSync, closeSync, constants, copyFileSync, cpSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
7
7
  import { homedir, networkInterfaces, tmpdir } from "node:os";
@@ -17,6 +17,57 @@ import { DatabaseSync } from "node:sqlite";
17
17
  import { createServer } from "node:net";
18
18
  import { createRequire as createRequire$1 } from "module";
19
19
  import { versions } from "node:process";
20
+ // ../../packages/contracts/src/tool-output.ts
21
+ /**
22
+ * Tool output kept in transcript pages, live updates and the browser's live transcript. The
23
+ * rest is fetched on demand (§6, `GET /api/sessions/:id/items/:itemId/output`).
24
+ */
25
+ const OUTPUT_PREVIEW_CHARS = 16e3;
26
+ /**
27
+ * Most tool output the server stores on an item while it streams. Older output is dropped past
28
+ * this, so every chunk rewrites a bounded row however long the command runs.
29
+ */
30
+ const STREAMED_OUTPUT_CHARS = 64e3;
31
+ /** Size of `text` in UTF-8 bytes, as Node's `Buffer.byteLength` counts it (works in browsers). */
32
+ function utf8ByteLength(text) {
33
+ let bytes = 0;
34
+ for (let i = 0; i < text.length; i++) {
35
+ const code = text.charCodeAt(i);
36
+ if (code < 128) bytes += 1;
37
+ else if (code < 2048) bytes += 2;
38
+ else if (code >= 55296 && code <= 56319 && isLowSurrogate(text.charCodeAt(i + 1))) {
39
+ bytes += 4;
40
+ i++;
41
+ } else bytes += 3;
42
+ }
43
+ return bytes;
44
+ }
45
+ const isLowSurrogate = (code) => code >= 56320 && code <= 57343;
46
+ /**
47
+ * Append a streamed output chunk, keeping only the last `max` characters (the end of command
48
+ * output is the useful part), marking the output `truncated` once anything is dropped and
49
+ * counting every byte seen in `totalBytes`. The work per chunk is bounded by `max` and the
50
+ * chunk, never by the output streamed so far. Pure.
51
+ */
52
+ function appendToolOutput(output, chunk, max) {
53
+ const prev = output?.text ?? "";
54
+ const totalBytes = (output?.totalBytes ?? utf8ByteLength(prev)) + utf8ByteLength(chunk);
55
+ if (prev.length + chunk.length <= max) return {
56
+ ...output,
57
+ text: prev + chunk,
58
+ truncated: output?.truncated ?? false,
59
+ totalBytes
60
+ };
61
+ const keep = max - chunk.length;
62
+ let text = keep > 0 ? prev.slice(prev.length - keep) + chunk : chunk.slice(chunk.length - max);
63
+ if (isLowSurrogate(text.charCodeAt(0))) text = text.slice(1);
64
+ return {
65
+ ...output,
66
+ text,
67
+ truncated: true,
68
+ totalBytes
69
+ };
70
+ }
20
71
  // ../../packages/contracts/src/vision.ts
21
72
  const SECTIONS = [
22
73
  [/^goals?\b/i, "goals"],
@@ -7627,7 +7678,9 @@ const turns = sqliteTable("turns", {
7627
7678
  completedAt: text("completed_at"),
7628
7679
  error: json$3("error"),
7629
7680
  /** Usage reported for this turn (latest `usage.updated` with this turnId). */
7630
- usage: json$3("usage")
7681
+ usage: json$3("usage"),
7682
+ /** The provider's fork point at the end of the turn (`forkPoint` on its end event, §9). */
7683
+ forkPoint: text("fork_point")
7631
7684
  }, (t) => [primaryKey({ columns: [t.sessionId, t.id] })]);
7632
7685
  const items = sqliteTable("items", {
7633
7686
  sessionId: text("session_id").notNull(),
@@ -8158,11 +8211,7 @@ function applyDelta(item, delta) {
8158
8211
  if (item.kind !== "tool_call") return item;
8159
8212
  return {
8160
8213
  ...item,
8161
- output: {
8162
- ...item.output,
8163
- text: (item.output?.text ?? "") + delta.text,
8164
- truncated: item.output?.truncated ?? false
8165
- }
8214
+ output: appendToolOutput(item.output, delta.text, STREAMED_OUTPUT_CHARS)
8166
8215
  };
8167
8216
  case "tool_input": return item;
8168
8217
  }
@@ -9811,7 +9860,8 @@ function reduceAgentEvent(reader, sessionId, event, ts) {
9811
9860
  sessionId,
9812
9861
  status: "running",
9813
9862
  startedAt: d.getTurn(sessionId, event.turnId)?.startedAt ?? ts,
9814
- completedAt: null
9863
+ completedAt: null,
9864
+ forkPoint: null
9815
9865
  });
9816
9866
  if (isRootThread(d, sessionId, event.threadId)) setStatus(d, sessionId, "running", ts);
9817
9867
  break;
@@ -9870,7 +9920,8 @@ function onTurnEnded(d, sessionId, event, ts) {
9870
9920
  sessionId,
9871
9921
  status,
9872
9922
  completedAt: ts,
9873
- error
9923
+ error,
9924
+ forkPoint: event.forkPoint ?? null
9874
9925
  });
9875
9926
  for (const record of d.openRequestItems(sessionId)) {
9876
9927
  if (record.turnId !== event.turnId || !isRequestItem(record.item)) continue;
@@ -12693,8 +12744,6 @@ function mountBrowserSockets(app, services, log) {
12693
12744
  return bridge;
12694
12745
  }
12695
12746
  // ../server/src/services/sessions/preview.ts
12696
- /** Tool output kept in transcript pages and live updates; the rest is fetched on demand (§6). */
12697
- const OUTPUT_PREVIEW_CHARS = 16e3;
12698
12747
  /**
12699
12748
  * Trim large tool outputs to their last `max` characters (the end of command output is the
12700
12749
  * useful part) and mark them truncated with the full size. Pure.
@@ -12787,24 +12836,6 @@ function clientEvent(event, artifacts) {
12787
12836
  function asDelta(e) {
12788
12837
  return e.type === "agent.item.delta" ? e.payload : void 0;
12789
12838
  }
12790
- function deltaText(d) {
12791
- return d.type === "tool_input" ? d.partialJson : d.text;
12792
- }
12793
- function mergeable(a, b) {
12794
- if (a.itemId !== b.itemId || a.turnId !== b.turnId || a.threadId !== b.threadId) return false;
12795
- if (a.delta.type !== b.delta.type) return false;
12796
- if (a.delta.type === "tool_output" && b.delta.type === "tool_output") return a.delta.stream === b.delta.stream;
12797
- return true;
12798
- }
12799
- function withText(d, text) {
12800
- return d.type === "tool_input" ? {
12801
- ...d,
12802
- partialJson: text
12803
- } : {
12804
- ...d,
12805
- text
12806
- };
12807
- }
12808
12839
  /**
12809
12840
  * Merge runs of consecutive text deltas for the same item into one event carrying the last
12810
12841
  * seq and timestamp, so a burst of streamed tokens reaches the UI as one update. Order is
@@ -12816,11 +12847,8 @@ function coalesceEvents(events) {
12816
12847
  const prev = out.at(-1);
12817
12848
  const a = prev ? asDelta(prev) : void 0;
12818
12849
  const b = asDelta(event);
12819
- if (prev && a && b && prev.sessionId === event.sessionId && mergeable(a, b)) {
12820
- const merged = {
12821
- ...b,
12822
- delta: withText(b.delta, deltaText(a.delta) + deltaText(b.delta))
12823
- };
12850
+ const merged = prev && a && b && prev.sessionId === event.sessionId ? mergeItemDeltas(a, b) : void 0;
12851
+ if (merged) {
12824
12852
  out[out.length - 1] = {
12825
12853
  ...event,
12826
12854
  payload: merged
@@ -13539,8 +13567,9 @@ function errorResponse(error, c, log) {
13539
13567
  }
13540
13568
  // ../server/src/routes/agent-config.ts
13541
13569
  /**
13542
- * `/api/agent-config` (§12.4): provider memory files, skills, subagents, MCP servers and hooks
13543
- * in their native files. Every overwrite and delete is backed up first; `diff` routes preview.
13570
+ * `/api/agent-config` (§12.4): provider memory files, skills (and their switches), subagents,
13571
+ * MCP servers and hooks in their native files. Every overwrite and delete is backed up first;
13572
+ * `diff` routes preview.
13544
13573
  */
13545
13574
  function agentConfigRoutes(s) {
13546
13575
  const ac = s.agentConfig;
@@ -13556,7 +13585,7 @@ function agentConfigRoutes(s) {
13556
13585
  }).post("/diff", jsonBody(AgentConfigDiffRequest), (c) => {
13557
13586
  const { content, ...locator } = c.req.valid("json");
13558
13587
  return c.json(ac.files.diff(locator, content));
13559
- }).get("/mcp", queryOf(AgentConfigListQuery.pick({ projectId: true })), (c) => c.json({ servers: ac.mcpList(c.req.valid("query").projectId) })).put("/mcp", jsonBody(McpServerUpsertRequest), (c) => c.json(ac.mcpUpsert(c.req.valid("json")))).delete("/mcp", queryOf(McpServerTarget), (c) => c.json(ac.mcpDelete(c.req.valid("query")))).post("/mcp/diff", jsonBody(McpServerDiffRequest), (c) => c.json(ac.mcpDiff(c.req.valid("json")))).post("/mcp/test", jsonBody(McpServerTestRequest), async (c) => c.json(await ac.mcpTest(c.req.valid("json")))).get("/hooks", queryOf(AgentConfigListQuery.pick({ projectId: true })), (c) => c.json(ac.hooks.list(c.req.valid("query").projectId))).post("/hooks/diff", jsonBody(HookChangeRequest), (c) => c.json(ac.hooks.diff(c.req.valid("json")))).post("/hooks", jsonBody(HookChangeRequest), (c) => c.json(ac.hooks.apply(c.req.valid("json"))));
13588
+ }).get("/mcp", queryOf(AgentConfigListQuery.pick({ projectId: true })), (c) => c.json({ servers: ac.mcpList(c.req.valid("query").projectId) })).put("/mcp", jsonBody(McpServerUpsertRequest), (c) => c.json(ac.mcpUpsert(c.req.valid("json")))).delete("/mcp", queryOf(McpServerTarget), (c) => c.json(ac.mcpDelete(c.req.valid("query")))).post("/mcp/diff", jsonBody(McpServerDiffRequest), (c) => c.json(ac.mcpDiff(c.req.valid("json")))).post("/mcp/test", jsonBody(McpServerTestRequest), async (c) => c.json(await ac.mcpTest(c.req.valid("json")))).get("/hooks", queryOf(AgentConfigListQuery.pick({ projectId: true })), (c) => c.json(ac.hooks.list(c.req.valid("query").projectId))).post("/hooks/diff", jsonBody(HookChangeRequest), (c) => c.json(ac.hooks.diff(c.req.valid("json")))).post("/hooks", jsonBody(HookChangeRequest), (c) => c.json(ac.hooks.apply(c.req.valid("json")))).get("/skills", queryOf(AgentConfigListQuery.pick({ projectId: true })), (c) => c.json({ skills: ac.skills.list(c.req.valid("query").projectId) })).post("/skills/diff", jsonBody(SkillSwitchRequest), (c) => c.json(ac.skills.diff(c.req.valid("json")))).post("/skills", jsonBody(SkillSwitchRequest), (c) => c.json(ac.skills.apply(c.req.valid("json"))));
13560
13589
  }
13561
13590
  /**
13562
13591
  * `GET /api/sessions/:id/agent-config/loaded` and `GET /api/sessions/:id/commands` (the
@@ -23054,17 +23083,6 @@ function conflictRoutes(s) {
23054
23083
  const conflicts = s.orchestration.conflicts;
23055
23084
  return new Hono().get("/", queryOf(ListConflictsQuery), (c) => c.json(conflicts.list(c.req.valid("query")))).get("/:id", (c) => c.json(conflicts.get(c.req.param("id")))).post("/:id/resolve", jsonBody(ResolveConflictRequest), async (c) => c.json(await conflicts.resolve(c.req.param("id"), c.req.valid("json"))));
23056
23085
  }
23057
- // ../server/src/routes/projects.ts
23058
- /**
23059
- * `/api/projects`: add from a folder or clone, list, reorder, update (and pin), archive, and
23060
- * re-detect the folder's git checkout and GitHub remote (`refresh`, `disconnect-github`).
23061
- */
23062
- function projectRoutes(s) {
23063
- return new Hono().get("/", queryOf(object({ archived: _enum(["true", "false"]).optional() })), (c) => c.json(s.projects.list({ includeArchived: c.req.valid("query").archived === "true" }))).post("/", jsonBody(CreateProjectRequest), async (c) => c.json(await s.projects.create(c.req.valid("json")), 201)).put("/order", jsonBody(ReorderProjectsRequest), (c) => c.json({ projects: s.projects.reorder(c.req.valid("json").projectIds) })).get("/:id", (c) => c.json(s.projects.get(c.req.param("id")))).patch("/:id", jsonBody(UpdateProjectRequest), (c) => c.json(s.projects.update(c.req.param("id"), c.req.valid("json")))).post("/:id/refresh", async (c) => c.json(await s.redetector.refresh(c.req.param("id")))).post("/:id/disconnect-github", async (c) => c.json(await s.projects.disconnectGithub(c.req.param("id")))).delete("/:id", (c) => {
23064
- s.projects.archive(c.req.param("id"));
23065
- return c.json({ ok: true });
23066
- });
23067
- }
23068
23086
  // ../server/src/services/files/path-guard.ts
23069
23087
  const caseInsensitive$1 = process.platform === "win32" || process.platform === "darwin";
23070
23088
  /** Real path of `p`, or of its nearest existing ancestor with the rest appended. */
@@ -23180,6 +23198,19 @@ function readSessionFile(root, path) {
23180
23198
  truncated: stat.size > MAX_READ_BYTES$2
23181
23199
  };
23182
23200
  }
23201
+ // ../server/src/routes/projects.ts
23202
+ /**
23203
+ * `/api/projects`: add from a folder or clone, list, reorder, update (and pin), archive, and
23204
+ * re-detect the folder's git checkout and GitHub remote (`refresh`, `disconnect-github`).
23205
+ * `commands` and `files` feed the composer's `/` and `@` menus in the New session draft, before
23206
+ * the session and its worktree exist: they read the project folder.
23207
+ */
23208
+ function projectRoutes(s) {
23209
+ return new Hono().get("/", queryOf(object({ archived: _enum(["true", "false"]).optional() })), (c) => c.json(s.projects.list({ includeArchived: c.req.valid("query").archived === "true" }))).post("/", jsonBody(CreateProjectRequest), async (c) => c.json(await s.projects.create(c.req.valid("json")), 201)).put("/order", jsonBody(ReorderProjectsRequest), (c) => c.json({ projects: s.projects.reorder(c.req.valid("json").projectIds) })).get("/:id", (c) => c.json(s.projects.get(c.req.param("id")))).patch("/:id", jsonBody(UpdateProjectRequest), (c) => c.json(s.projects.update(c.req.param("id"), c.req.valid("json")))).get("/:id/commands", queryOf(ProjectCommandsQuery), (c) => c.json(s.agentConfig.projectSlashCommands(c.req.param("id"), c.req.valid("query").provider))).get("/:id/files", (c) => c.json(listSessionFiles(s.projects.get(c.req.param("id")).path))).post("/:id/refresh", async (c) => c.json(await s.redetector.refresh(c.req.param("id")))).post("/:id/disconnect-github", async (c) => c.json(await s.projects.disconnectGithub(c.req.param("id")))).delete("/:id", (c) => {
23210
+ s.projects.archive(c.req.param("id"));
23211
+ return c.json({ ok: true });
23212
+ });
23213
+ }
23183
23214
  // ../server/src/services/sessions/workdir.ts
23184
23215
  /** Shown wherever a cleaned-up worktree is needed before the session is resumed. */
23185
23216
  const WORKTREE_REMOVED_MESSAGE = "This session's worktree was cleaned up. Send the session a message to recreate it from its branch.";
@@ -23428,10 +23459,13 @@ function sessionRoutes(s) {
23428
23459
  const { item } = record;
23429
23460
  if (item.kind !== "tool_call") throw badRequest("Only tool calls have output", "no_output");
23430
23461
  const text = item.output?.text ?? "";
23462
+ const totalBytes = Buffer.byteLength(text, "utf8");
23431
23463
  const body = {
23432
23464
  itemId: item.id,
23433
23465
  text,
23434
- totalBytes: Buffer.byteLength(text, "utf8")
23466
+ totalBytes,
23467
+ truncated: item.output?.truncated ?? false,
23468
+ outputBytes: Math.max(totalBytes, item.output?.totalBytes ?? totalBytes)
23435
23469
  };
23436
23470
  return c.json(body);
23437
23471
  });
@@ -25215,13 +25249,15 @@ function unattendedAnswers(item) {
25215
25249
  }
25216
25250
  /**
25217
25251
  * A question the provider asked through its own tool (not `ask_user`) in an unattended
25218
- * session: answer it straight away with {@link UNATTENDED_ANSWER}, so the turn never waits.
25252
+ * session: answer it straight away with {@link UNATTENDED_ANSWER}, so the turn never waits. An
25253
+ * MCP server's form is left alone: its answers would go to the server as data, so
25254
+ * `UnwatchedApprovals` withdraws it instead, as it denies approvals.
25219
25255
  */
25220
25256
  async function answerProviderQuestion(ctx, respond, e) {
25221
25257
  if (e.type !== "agent.item.started" || !e.sessionId) return false;
25222
25258
  const item = e.payload.item;
25223
25259
  if (item?.kind !== "user_question" || item.deferred || item.status !== "in_progress") return false;
25224
- if (!unattendedSession(ctx, e.sessionId)) return false;
25260
+ if (item.mcpServer || !unattendedSession(ctx, e.sessionId)) return false;
25225
25261
  try {
25226
25262
  await respond(e.sessionId, {
25227
25263
  requestId: item.requestId,
@@ -26337,7 +26373,7 @@ function projectLines(s, now, indent = "") {
26337
26373
  const OWN_TAG = /<(\/?codeden_(?:status|updates))/gi;
26338
26374
  const neutralizeTags = (line) => line.replace(OWN_TAG, "‹$1");
26339
26375
  /** A tagged block: the opening tag, the escaped lines, the closing tag. */
26340
- function block(open, close, lines) {
26376
+ function block$1(open, close, lines) {
26341
26377
  return [
26342
26378
  open,
26343
26379
  ...lines.map(neutralizeTags),
@@ -26347,7 +26383,7 @@ function block(open, close, lines) {
26347
26383
  /** The PM's digest for its project. */
26348
26384
  function renderProjectDigest(s, now = Date.now()) {
26349
26385
  const at = new Date(now).toISOString();
26350
- return block(`<codeden_status project=${quote(neutralizeTags(s.project.name))} mode="${s.project.mode}" at="${at}">`, "</codeden_status>", projectLines(s, now));
26386
+ return block$1(`<codeden_status project=${quote(neutralizeTags(s.project.name))} mode="${s.project.mode}" at="${at}">`, "</codeden_status>", projectLines(s, now));
26351
26387
  }
26352
26388
  /** The orchestrator's digest: every project rolled up. */
26353
26389
  function renderOverviewDigest(o, now = Date.now()) {
@@ -26362,7 +26398,7 @@ function renderOverviewDigest(o, now = Date.now()) {
26362
26398
  lines.push("Informational PM messages:");
26363
26399
  for (const m of o.pmMessages) lines.push(`- ${m.projectId ?? "?"}: ${quote(m.text, 300)}`);
26364
26400
  }
26365
- return block(`<codeden_status scope="all projects" at="${new Date(now).toISOString()}">`, "</codeden_status>", lines);
26401
+ return block$1(`<codeden_status scope="all projects" at="${new Date(now).toISOString()}">`, "</codeden_status>", lines);
26366
26402
  }
26367
26403
  /**
26368
26404
  * "Updates since your last turn": one entry per notice, for the model, inside
@@ -26375,7 +26411,7 @@ function renderNotices(list, projectName) {
26375
26411
  const where = n.projectId && projectName ? ` [${projectName(n.projectId)}]` : "";
26376
26412
  lines.push(`- (${n.type})${where} ${n.title}${noticeDetail(n)}`);
26377
26413
  }
26378
- return block("<codeden_updates>", "</codeden_updates>", lines);
26414
+ return block$1("<codeden_updates>", "</codeden_updates>", lines);
26379
26415
  }
26380
26416
  function noticeDetail(n) {
26381
26417
  switch (n.type) {
@@ -26666,6 +26702,489 @@ const orchestratorTools = [
26666
26702
  askPm,
26667
26703
  listInbox
26668
26704
  ];
26705
+ // ../server/src/services/automations/sources.ts
26706
+ const FILE_SOURCES = ["global_file", "project_file"];
26707
+ /** Repo-relative folder of project automation files. */
26708
+ const PROJECT_AUTOMATIONS_DIR = join(".codeden", "automations");
26709
+ /** The definition fields that, when changed in a file, update the stored automation. */
26710
+ function definitionKey(a) {
26711
+ return JSON.stringify([
26712
+ a.name,
26713
+ a.description,
26714
+ a.templateId,
26715
+ a.trigger,
26716
+ a.action,
26717
+ a.target,
26718
+ a.guards,
26719
+ a.workKey ?? null
26720
+ ]);
26721
+ }
26722
+ /**
26723
+ * Automation definitions from Markdown files (§10): global ones in `~/.codeden/automations/`
26724
+ * (they target all projects) and each project's `.codeden/automations/` (that project only).
26725
+ * They are mirrored into the `automations` projection through `automation.saved` /
26726
+ * `automation.deleted`, keeping the enabled flag (files never enable anything). Folders are
26727
+ * watched, with a periodic rescan for folders created later. Built-in templates are read once.
26728
+ */
26729
+ var AutomationSources = class {
26730
+ deps;
26731
+ paths = /* @__PURE__ */ new Map();
26732
+ errors = [];
26733
+ builtin = [];
26734
+ globalTemplates = [];
26735
+ watchers = /* @__PURE__ */ new Map();
26736
+ timer;
26737
+ rescan;
26738
+ constructor(deps) {
26739
+ this.deps = deps;
26740
+ const dir = findTemplatesDir(deps.templatesDir);
26741
+ if (dir) {
26742
+ const { files, errors } = readAutomationDir(dir);
26743
+ this.builtin = files.map((f) => toTemplate(f, "builtin"));
26744
+ for (const e of errors) deps.log?.(`automations: built-in template ${e.path}: ${e.message}`);
26745
+ } else deps.log?.("automations: built-in templates folder not found");
26746
+ }
26747
+ /** Built-in templates, then global automation files. */
26748
+ templates() {
26749
+ const builtinIds = new Set(this.builtin.map((t) => t.id));
26750
+ return [...this.builtin, ...this.globalTemplates.filter((t) => !builtinIds.has(t.id))];
26751
+ }
26752
+ template(id) {
26753
+ return this.templates().find((t) => t.id === id);
26754
+ }
26755
+ fileErrors() {
26756
+ return this.errors;
26757
+ }
26758
+ pathOf(automationId) {
26759
+ return this.paths.get(automationId) ?? null;
26760
+ }
26761
+ start() {
26762
+ this.reload();
26763
+ const every = this.deps.rescanMs ?? 6e4;
26764
+ if (every > 0) {
26765
+ this.rescan = setInterval(() => this.reload(), every);
26766
+ this.rescan.unref();
26767
+ }
26768
+ }
26769
+ stop() {
26770
+ if (this.timer) clearTimeout(this.timer);
26771
+ if (this.rescan) clearInterval(this.rescan);
26772
+ for (const w of this.watchers.values()) w.close();
26773
+ this.watchers.clear();
26774
+ }
26775
+ /** Re-read every folder and sync the projection. Returns true when anything changed. */
26776
+ reload() {
26777
+ const now = (/* @__PURE__ */ new Date()).toISOString();
26778
+ const errors = [];
26779
+ const found = [];
26780
+ const paths = /* @__PURE__ */ new Map();
26781
+ const add = (file, base) => {
26782
+ const { parsed } = file;
26783
+ found.push({
26784
+ ...base,
26785
+ name: parsed.frontmatter.name,
26786
+ description: parsed.frontmatter.description ?? null,
26787
+ enabled: false,
26788
+ templateId: parsed.id,
26789
+ trigger: parsed.frontmatter.trigger,
26790
+ action: parsed.action,
26791
+ guards: parsed.guards,
26792
+ workKey: parsed.workKey,
26793
+ createdAt: now,
26794
+ updatedAt: now
26795
+ });
26796
+ paths.set(base.id, file.path);
26797
+ };
26798
+ const global = readAutomationDir(this.deps.globalDir);
26799
+ errors.push(...global.errors);
26800
+ this.globalTemplates = global.files.map((f) => toTemplate(f, "global_file"));
26801
+ for (const file of global.files) add(file, {
26802
+ id: `global:${file.parsed.id}`,
26803
+ source: "global_file",
26804
+ target: { type: "all_projects" }
26805
+ });
26806
+ const dirs = [this.deps.globalDir];
26807
+ for (const project of this.deps.projects.list()) {
26808
+ if (project.archivedAt) continue;
26809
+ const dir = join(project.path, PROJECT_AUTOMATIONS_DIR);
26810
+ dirs.push(dir);
26811
+ const local = readAutomationDir(dir);
26812
+ errors.push(...local.errors);
26813
+ for (const file of local.files) add(file, {
26814
+ id: `project:${project.id}:${file.parsed.id}`,
26815
+ source: "project_file",
26816
+ target: {
26817
+ type: "project",
26818
+ projectId: project.id
26819
+ }
26820
+ });
26821
+ }
26822
+ this.errors = errors;
26823
+ this.paths.clear();
26824
+ for (const [k, v] of paths) this.paths.set(k, v);
26825
+ this.watchDirs(dirs);
26826
+ return this.sync(found);
26827
+ }
26828
+ /** Store new or changed file definitions and delete those whose file is gone. */
26829
+ sync(found) {
26830
+ const existing = new Map(this.deps.db.select().from(automations).where(inArray(automations.source, [...FILE_SOURCES])).all().map((r) => {
26831
+ const a = rowToAutomation(r);
26832
+ return [a.id, a];
26833
+ }));
26834
+ const events = [];
26835
+ for (const next of found) {
26836
+ const prev = existing.get(next.id);
26837
+ existing.delete(next.id);
26838
+ if (prev && definitionKey(prev) === definitionKey(next)) continue;
26839
+ const automation = prev ? {
26840
+ ...next,
26841
+ enabled: prev.enabled,
26842
+ createdAt: prev.createdAt
26843
+ } : next;
26844
+ events.push({
26845
+ type: "automation.saved",
26846
+ sessionId: null,
26847
+ payload: { automation }
26848
+ });
26849
+ }
26850
+ for (const gone of existing.keys()) events.push({
26851
+ type: "automation.deleted",
26852
+ sessionId: null,
26853
+ payload: { automationId: gone }
26854
+ });
26855
+ if (events.length === 0) return false;
26856
+ this.deps.store.append(events);
26857
+ this.deps.onChange?.();
26858
+ return true;
26859
+ }
26860
+ watchDirs(dirs) {
26861
+ const wanted = new Set(dirs.filter((d) => existsSync(d)));
26862
+ for (const [dir, watcher] of this.watchers) if (!wanted.has(dir)) {
26863
+ watcher.close();
26864
+ this.watchers.delete(dir);
26865
+ }
26866
+ for (const dir of wanted) {
26867
+ if (this.watchers.has(dir)) continue;
26868
+ try {
26869
+ const watcher = watch(dir, () => this.scheduleReload());
26870
+ watcher.on("error", () => {
26871
+ watcher.close();
26872
+ this.watchers.delete(dir);
26873
+ });
26874
+ this.watchers.set(dir, watcher);
26875
+ } catch (error) {
26876
+ this.deps.log?.(`automations: cannot watch ${dir}: ${errorMessage$1(error)}`);
26877
+ }
26878
+ }
26879
+ }
26880
+ scheduleReload() {
26881
+ if (this.timer) clearTimeout(this.timer);
26882
+ this.timer = setTimeout(() => {
26883
+ this.timer = void 0;
26884
+ try {
26885
+ this.reload();
26886
+ } catch (error) {
26887
+ this.deps.log?.(`automations: reload failed: ${errorMessage$1(error)}`);
26888
+ }
26889
+ }, 200);
26890
+ this.timer.unref();
26891
+ }
26892
+ };
26893
+ /** True when a project can run a template (GitHub-only templates need a GitHub project). */
26894
+ function templateFits(template, project) {
26895
+ return !template.requiresGithub || project.kind === "github";
26896
+ }
26897
+ // ../server/src/services/automations/definitions.ts
26898
+ /** Id of a project's starter automation created from a template. */
26899
+ function starterId(projectId, templateId) {
26900
+ return `starter:${projectId}:${templateId}`;
26901
+ }
26902
+ /** The fields that decide what an automation does, when and how often. */
26903
+ const BEHAVIOUR_FIELDS = [
26904
+ "trigger",
26905
+ "action",
26906
+ "guards"
26907
+ ];
26908
+ /**
26909
+ * Database-stored automations (§10): create (from a template or from scratch), update,
26910
+ * delete, enable and disable, and the starter set offered to new projects. File-based
26911
+ * automations are read-only here except for their enabled switch.
26912
+ */
26913
+ var AutomationDefinitions = class {
26914
+ deps;
26915
+ constructor(deps) {
26916
+ this.deps = deps;
26917
+ }
26918
+ all() {
26919
+ return this.deps.db.select().from(automations).all().map(rowToAutomation);
26920
+ }
26921
+ find(id) {
26922
+ const row = this.deps.db.select().from(automations).where(eq(automations.id, id)).get();
26923
+ return row ? rowToAutomation(row) : void 0;
26924
+ }
26925
+ get(id) {
26926
+ const a = this.find(id);
26927
+ if (!a) throw notFound(`No automation ${id}`, "automation_not_found");
26928
+ return a;
26929
+ }
26930
+ create(req, opts = {}) {
26931
+ const id = opts.id ?? randomUUID();
26932
+ const template = req.templateId ? this.template(req.templateId) : void 0;
26933
+ if (req.projectId) this.deps.projects.get(req.projectId);
26934
+ const now = (/* @__PURE__ */ new Date()).toISOString();
26935
+ const trigger = req.trigger ?? template?.trigger;
26936
+ const action = req.action ?? template?.action;
26937
+ const name = req.name ?? template?.name;
26938
+ if (!trigger || !action || !name) throw badRequest("name, trigger and action are required");
26939
+ const automation = Automation.parse({
26940
+ id,
26941
+ name,
26942
+ description: req.description !== void 0 ? req.description : template?.description ?? null,
26943
+ enabled: req.enabled,
26944
+ source: "database",
26945
+ templateId: template?.id ?? null,
26946
+ trigger,
26947
+ action,
26948
+ target: req.projectId ? {
26949
+ type: "project",
26950
+ projectId: req.projectId
26951
+ } : { type: "all_projects" },
26952
+ guards: AutomationGuards.parse({
26953
+ ...template?.guards,
26954
+ ...req.guards
26955
+ }),
26956
+ workKey: req.workKey ?? template?.workKey,
26957
+ ...opts.createdBy && opts.createdBy !== "user" ? { createdBy: opts.createdBy } : {},
26958
+ createdAt: now,
26959
+ updatedAt: now
26960
+ });
26961
+ validateAutomation(automation);
26962
+ return this.save(automation);
26963
+ }
26964
+ update(id, req, opts = {}) {
26965
+ const current = this.editable(id);
26966
+ if (req.target?.type === "project") this.deps.projects.get(req.target.projectId);
26967
+ const { workKey: _drop, ...rest } = current;
26968
+ const workKey = req.workKey === null ? void 0 : req.workKey ?? current.workKey;
26969
+ const next = Automation.parse({
26970
+ ...rest,
26971
+ ...req.name !== void 0 ? { name: req.name } : {},
26972
+ ...req.description !== void 0 ? { description: req.description } : {},
26973
+ ...req.trigger ? { trigger: req.trigger } : {},
26974
+ ...req.action ? { action: req.action } : {},
26975
+ ...req.target ? { target: req.target } : {},
26976
+ guards: AutomationGuards.parse({
26977
+ ...current.guards,
26978
+ ...req.guards
26979
+ }),
26980
+ ...workKey ? { workKey } : {},
26981
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
26982
+ });
26983
+ validateAutomation(next);
26984
+ const switchOff = opts.switchOffOnChange && next.enabled && BEHAVIOUR_FIELDS.some((key) => req[key] !== void 0 && !isDeepStrictEqual(current[key], next[key]));
26985
+ return this.save(switchOff ? {
26986
+ ...next,
26987
+ enabled: false
26988
+ } : next);
26989
+ }
26990
+ remove(id) {
26991
+ this.editable(id);
26992
+ this.deps.store.append([{
26993
+ type: "automation.deleted",
26994
+ sessionId: null,
26995
+ payload: { automationId: id }
26996
+ }]);
26997
+ this.deps.schedules.removeAutomation(id);
26998
+ }
26999
+ /**
27000
+ * Enable or disable. With `projectId` on an automation that targets all projects, only that
27001
+ * project's override changes; otherwise the automation's own switch.
27002
+ */
27003
+ setEnabled(id, enabled, projectId) {
27004
+ const a = this.get(id);
27005
+ if (projectId) this.deps.projects.get(projectId);
27006
+ if (projectId && a.target.type === "all_projects") {
27007
+ this.deps.schedules.upsert(id, projectId, { enabledOverride: enabled });
27008
+ return a;
27009
+ }
27010
+ if (projectId && a.target.type === "project" && a.target.projectId !== projectId) throw notFound(`Automation ${id} does not belong to that project`, "automation_not_found");
27011
+ if (a.enabled === enabled) return a;
27012
+ return this.save({
27013
+ ...a,
27014
+ enabled,
27015
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
27016
+ });
27017
+ }
27018
+ /** Drop a project's override of an all-projects automation; it follows its own switch again. */
27019
+ clearOverride(id, projectId) {
27020
+ const a = this.get(id);
27021
+ this.deps.projects.get(projectId);
27022
+ if (a.target.type !== "all_projects") throw conflict("Only automations for all projects have per-project overrides", "no_override");
27023
+ this.deps.schedules.upsert(id, projectId, { enabledOverride: null });
27024
+ return a;
27025
+ }
27026
+ /**
27027
+ * Create the project's starter set (§10) from the starter templates that fit the project,
27028
+ * keeping existing ones; with `enable`, switch them all on.
27029
+ */
27030
+ starterSet(projectId, enable) {
27031
+ const project = this.deps.projects.get(projectId);
27032
+ const out = [];
27033
+ for (const template of this.deps.sources.templates()) {
27034
+ if (!template.starter || template.source !== "builtin" || !templateFits(template, project)) continue;
27035
+ const id = starterId(projectId, template.id);
27036
+ const existing = this.find(id);
27037
+ if (existing) {
27038
+ out.push(enable ? this.setEnabled(id, true) : existing);
27039
+ continue;
27040
+ }
27041
+ out.push(this.create({
27042
+ templateId: template.id,
27043
+ projectId,
27044
+ enabled: enable
27045
+ }, { id }));
27046
+ }
27047
+ return out;
27048
+ }
27049
+ /**
27050
+ * An editable database copy of any automation (also file-based and built-in ones), off,
27051
+ * named "<name> (copy)" unless a name is given. Targets `projectId`, else the original's
27052
+ * target. The copy keeps the original's work key, so the two never run the same work at once.
27053
+ */
27054
+ duplicate(id, req) {
27055
+ const a = this.get(id);
27056
+ const projectId = req.projectId ?? (a.target.type === "project" ? a.target.projectId : void 0);
27057
+ return this.create({
27058
+ projectId,
27059
+ name: req.name ?? `${a.name} (copy)`.slice(0, 200),
27060
+ description: a.description,
27061
+ trigger: a.trigger,
27062
+ action: a.action,
27063
+ guards: a.guards,
27064
+ workKey: a.workKey,
27065
+ enabled: false
27066
+ });
27067
+ }
27068
+ /**
27069
+ * Import shared automation files (§10): re-parse the source with the file parser, then
27070
+ * create each selected valid file as a database automation, always off and marked
27071
+ * `createdBy: import`. Nothing runs.
27072
+ */
27073
+ importFrom(req) {
27074
+ if (req.projectId) this.deps.projects.get(req.projectId);
27075
+ const select = req.select ? new Set(req.select) : null;
27076
+ const items = previewImport(req.source).filter((i) => !select || select.has(i.fileName));
27077
+ const created = [];
27078
+ const failed = [];
27079
+ for (const item of items) {
27080
+ const draft = item.automation;
27081
+ if (!draft) {
27082
+ failed.push({
27083
+ fileName: item.fileName,
27084
+ error: item.error ?? "Invalid file"
27085
+ });
27086
+ continue;
27087
+ }
27088
+ created.push(this.create({
27089
+ projectId: req.projectId,
27090
+ name: draft.name,
27091
+ description: draft.description,
27092
+ trigger: draft.trigger,
27093
+ action: draft.action,
27094
+ guards: draft.guards,
27095
+ workKey: draft.workKey,
27096
+ enabled: false
27097
+ }, { createdBy: "import" }));
27098
+ }
27099
+ return {
27100
+ created,
27101
+ failed
27102
+ };
27103
+ }
27104
+ /** Parse an import without saving anything. */
27105
+ previewImport(req) {
27106
+ return previewImport(req);
27107
+ }
27108
+ template(id) {
27109
+ const template = this.deps.sources.template(id);
27110
+ if (!template) throw notFound(`No automation template ${id}`, "template_not_found");
27111
+ return template;
27112
+ }
27113
+ editable(id) {
27114
+ const a = this.get(id);
27115
+ if (a.source !== "database") throw conflict("This automation is defined in a file; edit the file instead", "automation_read_only");
27116
+ return a;
27117
+ }
27118
+ save(automation) {
27119
+ this.deps.store.append([{
27120
+ type: "automation.saved",
27121
+ sessionId: null,
27122
+ payload: { automation }
27123
+ }]);
27124
+ return automation;
27125
+ }
27126
+ };
27127
+ // ../server/src/services/automations/pm-change-notice.ts
27128
+ const MAX_VALUE = 120;
27129
+ /** Inbox id of the notice about a PM's change to an automation; the next change replaces it. */
27130
+ function pmChangeInboxId(automationId) {
27131
+ return `inbox:automation-changed:${automationId}`;
27132
+ }
27133
+ /** `path: before → after` for each trigger, action and guard setting that differs. */
27134
+ function automationChanges(before, after) {
27135
+ const was = /* @__PURE__ */ new Map();
27136
+ const now = /* @__PURE__ */ new Map();
27137
+ for (const key of BEHAVIOUR_FIELDS) {
27138
+ flatten(before[key], key, was);
27139
+ flatten(after[key], key, now);
27140
+ }
27141
+ const out = [];
27142
+ for (const path of /* @__PURE__ */ new Set([...was.keys(), ...now.keys()])) {
27143
+ if (was.get(path) === now.get(path)) continue;
27144
+ out.push(`${path}: ${shorten(was.get(path))} → ${shorten(now.get(path))}`);
27145
+ }
27146
+ return out;
27147
+ }
27148
+ function flatten(value, path, out) {
27149
+ if (value === void 0) return;
27150
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
27151
+ for (const [key, v] of Object.entries(value)) flatten(v, `${path}.${key}`, out);
27152
+ return;
27153
+ }
27154
+ out.set(path, JSON.stringify(value));
27155
+ }
27156
+ function shorten(text) {
27157
+ if (text === void 0) return "none";
27158
+ return text.length > MAX_VALUE ? `${text.slice(0, 119)}…` : text;
27159
+ }
27160
+ /**
27161
+ * The inbox item telling the user that the PM changed what an automation that was on does, so
27162
+ * it was switched off (§10): what changed, and that it runs again only once they switch it on.
27163
+ */
27164
+ function pmChangeInbox(before, after, by, now) {
27165
+ const name = after.name.length > 80 ? `${after.name.slice(0, 79)}…` : after.name;
27166
+ return {
27167
+ id: pmChangeInboxId(after.id),
27168
+ kind: "message",
27169
+ status: "open",
27170
+ priority: "normal",
27171
+ notify: "needs_you",
27172
+ title: `Automation switched off: the project manager changed "${name}"`,
27173
+ body: ["The project manager changed what this automation does, so it was switched off. It runs again only once you switch it on in Automations.", `Changed: ${automationChanges(before, after).join("; ")}.`].join("\n"),
27174
+ projectId: by.projectId,
27175
+ sessionId: by.sessionId,
27176
+ threadId: null,
27177
+ itemId: null,
27178
+ requestId: null,
27179
+ issue: null,
27180
+ pr: null,
27181
+ automationRunId: null,
27182
+ createdAt: now,
27183
+ updatedAt: now,
27184
+ resolvedAt: null,
27185
+ resolution: null
27186
+ };
27187
+ }
26669
27188
  // ../server/src/mcp/tools/pm/automation-edit.ts
26670
27189
  const fields = {
26671
27190
  name: string().trim().min(1).max(200),
@@ -26709,10 +27228,14 @@ const createAutomation = defineTool({
26709
27228
  };
26710
27229
  }
26711
27230
  });
26712
- /** `update_automation` (§10): change an automation created in CodeDen (not a file). */
27231
+ /**
27232
+ * `update_automation` (§10): change an automation of this project created in CodeDen (not a
27233
+ * file). A change to what an automation that is on does (trigger, action, guards) switches it
27234
+ * off and tells the user, who switches it on again; automations for all projects are the user's.
27235
+ */
26713
27236
  const updateAutomation = defineTool({
26714
27237
  name: "update_automation",
26715
- description: "Change an automation of this project that lives in CodeDen (not a .md file): any of name, description, trigger, action, guards or work key. Validated like automation files. Does not switch it on or off. File-based automations are edited in their file.",
27238
+ description: "Change an automation of this project that lives in CodeDen (not a .md file): any of name, description, trigger, action, guards or work key. Validated like automation files. Changing the trigger, action or guards of an automation that is on switches it OFF: tell the user, who reviews the change (it is in their inbox) and switches it on again in Automations. Renaming or a new description keeps it as it is. File-based automations are edited in their file; automations for all projects only by the user.",
26716
27239
  roles: ["pm"],
26717
27240
  scope: "project",
26718
27241
  input: object({
@@ -26724,12 +27247,21 @@ const updateAutomation = defineTool({
26724
27247
  guards: fields.guards.optional(),
26725
27248
  workKey: fields.workKey.optional()
26726
27249
  }),
26727
- handler: async ({ automationId, ...patch }, { services, projectId }) => {
27250
+ handler: async ({ automationId, ...patch }, { services, projectId, sessionId }) => {
26728
27251
  const current = services.automations.definitions.get(automationId);
26729
- if (current.target.type === "project" && current.target.projectId !== projectId) throw new Error(`Automation ${automationId} belongs to another project`);
26730
- const updated = services.automations.definitions.update(automationId, patch);
27252
+ if (current.target.type === "all_projects") throw new ToolError(`Automation ${automationId} runs in all projects; only the user changes it, in Automations`);
27253
+ if (current.target.projectId !== projectId) throw new ToolError(`Automation ${automationId} belongs to another project`);
27254
+ const updated = services.automations.definitions.update(automationId, patch, { switchOffOnChange: true });
26731
27255
  services.automations.refresh();
26732
- return { updated: summary(updated) };
27256
+ if (!current.enabled || updated.enabled) return { updated: summary(updated) };
27257
+ services.inbox.put(pmChangeInbox(current, updated, {
27258
+ projectId,
27259
+ sessionId
27260
+ }, (/* @__PURE__ */ new Date()).toISOString()));
27261
+ return {
27262
+ updated: summary(updated),
27263
+ note: "Switched off because it was on and you changed what it does. The user reviews the change in their inbox and switches it on again in Automations."
27264
+ };
26733
27265
  }
26734
27266
  });
26735
27267
  // ../server/src/mcp/tools/pm/automations.ts
@@ -28045,6 +28577,15 @@ function codexUserSkills(home) {
28045
28577
  const r = homeRoot(home);
28046
28578
  return [...skillsIn(r, join(home, ".codex", "skills"), "codex", "user"), ...skillsIn(r, join(home, ".agents", "skills"), "codex", "user")];
28047
28579
  }
28580
+ /** Every provider's user skills, plus the project's (the Skills tab's switches). */
28581
+ function listSkills(home, project) {
28582
+ const r = project ? projectRoot(project) : null;
28583
+ return [
28584
+ ...skillsIn(homeRoot(home), join(home, ".claude", "skills"), "claude", "user"),
28585
+ ...codexUserSkills(home),
28586
+ ...project && r ? [...skillsIn(r, join(project.path, ".claude", "skills"), "claude", "project"), ...skillsIn(r, join(project.path, ".agents", "skills"), "codex", "project")] : []
28587
+ ];
28588
+ }
28048
28589
  /** Dedupe by absolute path (case-insensitively on Windows and macOS), keeping the first. */
28049
28590
  function unique(items, key) {
28050
28591
  const seen = /* @__PURE__ */ new Set();
@@ -28969,7 +29510,7 @@ function skipSpace(text, at) {
28969
29510
  return i;
28970
29511
  }
28971
29512
  /** End offset (exclusive) of the string starting at `at` (an opening quote). */
28972
- function skipString(text, at) {
29513
+ function skipString$1(text, at) {
28973
29514
  let i = at + 1;
28974
29515
  while (i < text.length) {
28975
29516
  const ch = text[i];
@@ -28982,14 +29523,14 @@ function skipString(text, at) {
28982
29523
  /** End offset (exclusive) of the JSON value starting at `at`. */
28983
29524
  function skipValue(text, at) {
28984
29525
  const ch = text[at];
28985
- if (ch === "\"") return skipString(text, at);
29526
+ if (ch === "\"") return skipString$1(text, at);
28986
29527
  if (ch === "{" || ch === "[") {
28987
29528
  let depth = 0;
28988
29529
  let i = at;
28989
29530
  while (i < text.length) {
28990
29531
  const c = text[i];
28991
29532
  if (c === "\"") {
28992
- i = skipString(text, i);
29533
+ i = skipString$1(text, i);
28993
29534
  continue;
28994
29535
  }
28995
29536
  if (c === "{" || c === "[") depth++;
@@ -29014,7 +29555,7 @@ function scanTopLevel(text) {
29014
29555
  while (text[i] !== "}") {
29015
29556
  if (text[i] !== "\"") throw new Error(`Unexpected character at ${i}`);
29016
29557
  const keyStart = i;
29017
- const keyEnd = skipString(text, i);
29558
+ const keyEnd = skipString$1(text, i);
29018
29559
  const key = JSON.parse(text.slice(keyStart, keyEnd));
29019
29560
  i = skipSpace(text, keyEnd);
29020
29561
  if (text[i] !== ":") throw new Error(`Expected ':' at ${i}`);
@@ -29080,14 +29621,14 @@ function setTopLevelMember(text, key, value) {
29080
29621
  return text.slice(0, last.valueEnd) + comma + member + body + text.slice(last.valueEnd);
29081
29622
  }
29082
29623
  // ../server/src/services/agent-config/hooks.ts
29083
- const isRecord$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
29084
- const isGroup = (v) => isRecord$1(v) && Array.isArray(v.hooks);
29624
+ const isRecord$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
29625
+ const isGroup = (v) => isRecord$2(v) && Array.isArray(v.hooks);
29085
29626
  /**
29086
29627
  * A group's matcher exactly as written (whitespace can matter in a regex); missing or empty
29087
29628
  * means "match everything".
29088
29629
  */
29089
29630
  const matcherOf = (m) => typeof m === "string" && m !== "" ? m : null;
29090
- const unparsable = (file, why) => new ServiceError(422, "config_unparsable", `${file} could not be parsed: ${why}`);
29631
+ const unparsable$2 = (file, why) => new ServiceError(422, "config_unparsable", `${file} could not be parsed: ${why}`);
29091
29632
  /** Claude Code's settings file for a scope. */
29092
29633
  function claudeHookFile(scope, home, project) {
29093
29634
  if (scope === "user" || !project) return {
@@ -29149,7 +29690,7 @@ function unusableSource(f, error) {
29149
29690
  error
29150
29691
  };
29151
29692
  }
29152
- function load$1(file) {
29693
+ function load$3(file) {
29153
29694
  if (!existsSync(file)) return {
29154
29695
  text: null,
29155
29696
  doc: {}
@@ -29163,10 +29704,10 @@ function load$1(file) {
29163
29704
  try {
29164
29705
  doc = JSON.parse(text.replace(/^/, ""));
29165
29706
  } catch (error) {
29166
- throw unparsable(file, error instanceof Error ? error.message : String(error));
29707
+ throw unparsable$2(file, error instanceof Error ? error.message : String(error));
29167
29708
  }
29168
- if (!isRecord$1(doc)) throw unparsable(file, "not a JSON object");
29169
- if (doc.hooks !== void 0 && !isRecord$1(doc.hooks)) throw unparsable(file, "`hooks` is not an object");
29709
+ if (!isRecord$2(doc)) throw unparsable$2(file, "not a JSON object");
29710
+ if (doc.hooks !== void 0 && !isRecord$2(doc.hooks)) throw unparsable$2(file, "`hooks` is not an object");
29170
29711
  return {
29171
29712
  text,
29172
29713
  doc
@@ -29182,7 +29723,7 @@ function readHookFile(f) {
29182
29723
  };
29183
29724
  let loaded;
29184
29725
  try {
29185
- loaded = load$1(f.file);
29726
+ loaded = load$3(f.file);
29186
29727
  } catch (error) {
29187
29728
  return {
29188
29729
  source: unusableSource(f, error instanceof Error ? error.message : String(error)),
@@ -29197,7 +29738,7 @@ function readHookFile(f) {
29197
29738
  };
29198
29739
  const disableAllHooks = typeof doc.disableAllHooks === "boolean" ? { disableAllHooks: doc.disableAllHooks } : {};
29199
29740
  const hooks = [];
29200
- for (const [event, groups] of Object.entries(isRecord$1(doc.hooks) ? doc.hooks : {})) {
29741
+ for (const [event, groups] of Object.entries(isRecord$2(doc.hooks) ? doc.hooks : {})) {
29201
29742
  if (!Array.isArray(groups)) continue;
29202
29743
  groups.forEach((group, groupIndex) => {
29203
29744
  if (!isGroup(group)) return;
@@ -29225,8 +29766,8 @@ function readHookFile(f) {
29225
29766
  }
29226
29767
  /** The file text before and after applying `mutate` to its `hooks` object. */
29227
29768
  function renderHookChange(file, mutate) {
29228
- const { text, doc } = load$1(file);
29229
- const current = isRecord$1(doc.hooks) ? doc.hooks : {};
29769
+ const { text, doc } = load$3(file);
29770
+ const current = isRecord$2(doc.hooks) ? doc.hooks : {};
29230
29771
  const hooks = structuredClone(current);
29231
29772
  mutate(hooks);
29232
29773
  const before = text ?? "";
@@ -29251,7 +29792,7 @@ function groupsOf(hooks, event, file) {
29251
29792
  hooks[event] = [];
29252
29793
  return hooks[event];
29253
29794
  }
29254
- if (!Array.isArray(existing)) throw unparsable(file, `\`hooks.${event}\` is not a list`);
29795
+ if (!Array.isArray(existing)) throw unparsable$2(file, `\`hooks.${event}\` is not a list`);
29255
29796
  return existing;
29256
29797
  }
29257
29798
  /** Append a handler to the first group of `event` with the same matcher, or to a new group. */
@@ -29270,7 +29811,7 @@ function locate(hooks, file, t) {
29270
29811
  const groups = hooks[t.event];
29271
29812
  const group = Array.isArray(groups) ? groups[t.groupIndex] : void 0;
29272
29813
  const current = isGroup(group) ? group.hooks[t.handlerIndex] : void 0;
29273
- if (!Array.isArray(groups) || !isGroup(group) || !isRecord$1(current)) throw notFound(`No such ${t.event} hook in ${file}; reload the list`, "hook_not_found");
29814
+ if (!Array.isArray(groups) || !isGroup(group) || !isRecord$2(current)) throw notFound(`No such ${t.event} hook in ${file}; reload the list`, "hook_not_found");
29274
29815
  if (!isDeepStrictEqual(current, t.expected)) throw conflict(`The hook changed in ${file} since it was listed; reload the list`, "hook_changed");
29275
29816
  return {
29276
29817
  groups,
@@ -29478,7 +30019,7 @@ var AgentConfigHooks = class {
29478
30019
  };
29479
30020
  /**
29480
30021
  * Refuse (403 `path_outside_root`) a settings file whose real path, following links on the file
29481
- * and on every existing ancestor, leaves its scope's folder.
30022
+ * and on every existing ancestor, leaves its scope's folder. Also used for skill switches.
29482
30023
  */
29483
30024
  function guard(f) {
29484
30025
  try {
@@ -36175,7 +36716,7 @@ function qo(e, t) {
36175
36716
  return n.leadingBom ? `${i}` : i;
36176
36717
  }
36177
36718
  // ../server/src/services/agent-config/toml-edit.ts
36178
- const isTable = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
36719
+ const isTable$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
36179
36720
  const bare = (key) => /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key);
36180
36721
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36181
36722
  /** `[mcp_servers.<name>]` with scalars and arrays, then one sub-table per flat map (env, headers). */
@@ -36184,11 +36725,11 @@ function serverBlock(name, server) {
36184
36725
  const lines = [`[${head}]`];
36185
36726
  const tables = [];
36186
36727
  for (const [key, value] of Object.entries(server)) {
36187
- if (!isTable(value)) {
36728
+ if (!isTable$1(value)) {
36188
36729
  lines.push(qo({ [key]: value }).trim());
36189
36730
  continue;
36190
36731
  }
36191
- if (Object.values(value).some(isTable)) return null;
36732
+ if (Object.values(value).some(isTable$1)) return null;
36192
36733
  tables.push("", `[${head}.${bare(key)}]`);
36193
36734
  for (const [k, v] of Object.entries(value)) tables.push(qo({ [k]: v }).trim());
36194
36735
  }
@@ -36269,7 +36810,7 @@ function trimBlankRuns(lines) {
36269
36810
  */
36270
36811
  function editTomlServer(text, name, server) {
36271
36812
  const doc = Ko(text);
36272
- const servers = isTable(doc.mcp_servers) ? { ...doc.mcp_servers } : {};
36813
+ const servers = isTable$1(doc.mcp_servers) ? { ...doc.mcp_servers } : {};
36273
36814
  if (server) servers[name] = server;
36274
36815
  else delete servers[name];
36275
36816
  const expected = {
@@ -36279,14 +36820,14 @@ function editTomlServer(text, name, server) {
36279
36820
  if (!server && Object.keys(servers).length === 0) delete expected.mcp_servers;
36280
36821
  const edited = blockEdit(text, name, server);
36281
36822
  if (edited !== null) try {
36282
- if (isDeepStrictEqual(normalize(Ko(edited)), normalize(expected))) return edited;
36823
+ if (isDeepStrictEqual(normalize$1(Ko(edited)), normalize$1(expected))) return edited;
36283
36824
  } catch {}
36284
36825
  return Ja(text, expected);
36285
36826
  }
36286
36827
  /** Plain JSON view for comparisons (dates become strings, empty `mcp_servers` dropped). */
36287
- function normalize(value) {
36828
+ function normalize$1(value) {
36288
36829
  const plain = JSON.parse(JSON.stringify(value));
36289
- if (isTable(plain.mcp_servers) && Object.keys(plain.mcp_servers).length === 0) delete plain.mcp_servers;
36830
+ if (isTable$1(plain.mcp_servers) && Object.keys(plain.mcp_servers).length === 0) delete plain.mcp_servers;
36290
36831
  return plain;
36291
36832
  }
36292
36833
  // ../server/src/services/agent-config/mcp.ts
@@ -36308,8 +36849,8 @@ const CODEX_TRANSPORT = [
36308
36849
  "bearer_token_env_var",
36309
36850
  "enabled"
36310
36851
  ];
36311
- const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
36312
- const strings = (v) => isRecord(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)])) : void 0;
36852
+ const isRecord$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
36853
+ const strings = (v) => isRecord$1(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)])) : void 0;
36313
36854
  const stringList = (v) => Array.isArray(v) ? v.map((x) => String(x)) : void 0;
36314
36855
  const omit$1 = (o, keys) => Object.fromEntries(Object.entries(o).filter(([k]) => !keys.includes(k)));
36315
36856
  const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
@@ -36376,11 +36917,11 @@ function toNative(provider, spec, existing = {}) {
36376
36917
  /** `~/.claude.json` keys projects by path with forward slashes on Windows. */
36377
36918
  function claudeProjectKey(doc, projectPath) {
36378
36919
  const key = process.platform === "win32" ? projectPath.replace(/\\/g, "/") : projectPath;
36379
- const projects = isRecord(doc.projects) ? doc.projects : {};
36920
+ const projects = isRecord$1(doc.projects) ? doc.projects : {};
36380
36921
  const fold = (s) => process.platform === "linux" ? s : s.toLowerCase();
36381
36922
  return Object.keys(projects).find((k) => fold(k) === fold(key)) ?? key;
36382
36923
  }
36383
- function load(source) {
36924
+ function load$2(source) {
36384
36925
  if (!existsSync(source.file)) return {
36385
36926
  text: null,
36386
36927
  doc: {}
@@ -36388,7 +36929,7 @@ function load(source) {
36388
36929
  const text = readFileSync(source.file, "utf8");
36389
36930
  try {
36390
36931
  const doc = source.format === "json" ? JSON.parse(text) : Ko(text);
36391
- if (!isRecord(doc)) throw new Error("not an object");
36932
+ if (!isRecord$1(doc)) throw new Error("not an object");
36392
36933
  return {
36393
36934
  text,
36394
36935
  doc
@@ -36401,7 +36942,7 @@ function tableAt(doc, keyPath, create) {
36401
36942
  let node = doc;
36402
36943
  for (const key of keyPath) {
36403
36944
  const next = node[key];
36404
- if (!isRecord(next)) {
36945
+ if (!isRecord$1(next)) {
36405
36946
  if (!create) return void 0;
36406
36947
  node[key] = {};
36407
36948
  }
@@ -36411,8 +36952,8 @@ function tableAt(doc, keyPath, create) {
36411
36952
  }
36412
36953
  /** The servers defined in one source. */
36413
36954
  function readServers(source) {
36414
- const table = tableAt(load(source).doc, source.keyPath, false) ?? {};
36415
- return Object.entries(table).filter(([, native]) => isRecord(native)).map(([name, native]) => ({
36955
+ const table = tableAt(load$2(source).doc, source.keyPath, false) ?? {};
36956
+ return Object.entries(table).filter(([, native]) => isRecord$1(native)).map(([name, native]) => ({
36416
36957
  provider: source.provider,
36417
36958
  scope: source.scope,
36418
36959
  name,
@@ -36423,12 +36964,12 @@ function readServers(source) {
36423
36964
  }
36424
36965
  /** The new file text for an upsert (`spec`) or removal (no `spec`). Pure apart from reading. */
36425
36966
  function renderChange(source, name, spec) {
36426
- const { text, doc } = load(source);
36967
+ const { text, doc } = load$2(source);
36427
36968
  const table = tableAt(doc, source.keyPath, spec !== void 0);
36428
36969
  const current = table?.[name];
36429
36970
  if (!spec && !(table && name in table)) throw new ServiceError(404, "server_not_found", `No MCP server named ${name} in ${source.file}`);
36430
- const native = spec ? toNative(source.provider, spec, isRecord(current) ? current : {}) : void 0;
36431
- if (native && isRecord(current) && isDeepStrictEqual(native, current)) return {
36971
+ const native = spec ? toNative(source.provider, spec, isRecord$1(current) ? current : {}) : void 0;
36972
+ if (native && isRecord$1(current) && isDeepStrictEqual(native, current)) return {
36432
36973
  before: text ?? "",
36433
36974
  after: text ?? "",
36434
36975
  existed: text !== null
@@ -36498,7 +37039,7 @@ function sourceFor(target, home, project) {
36498
37039
  format: "json",
36499
37040
  keyPath: [
36500
37041
  "projects",
36501
- claudeProjectKey(existsSync(claudeJson) ? load({
37042
+ claudeProjectKey(existsSync(claudeJson) ? load$2({
36502
37043
  file: claudeJson,
36503
37044
  format: "json"
36504
37045
  }).doc : {}, project.path),
@@ -36507,6 +37048,540 @@ function sourceFor(target, home, project) {
36507
37048
  projectId: project.id
36508
37049
  };
36509
37050
  }
37051
+ // ../server/src/services/agent-config/skill-overrides.ts
37052
+ const VALUES = /* @__PURE__ */ new Set([
37053
+ "on",
37054
+ "name-only",
37055
+ "user-invocable-only",
37056
+ "off"
37057
+ ]);
37058
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
37059
+ const unparsable$1 = (file, why) => new ServiceError(422, "config_unparsable", `${file} could not be parsed: ${why}`);
37060
+ function load$1(file) {
37061
+ if (!existsSync(file)) return {
37062
+ text: null,
37063
+ doc: {},
37064
+ overrides: {}
37065
+ };
37066
+ const text = readFileSync(file, "utf8");
37067
+ if (!text.trim()) return {
37068
+ text,
37069
+ doc: {},
37070
+ overrides: {}
37071
+ };
37072
+ let doc;
37073
+ try {
37074
+ doc = JSON.parse(text.replace(/^/, ""));
37075
+ } catch (error) {
37076
+ throw unparsable$1(file, errorMessage$1(error));
37077
+ }
37078
+ if (!isRecord(doc)) throw unparsable$1(file, "not a JSON object");
37079
+ const overrides = isRecord(doc.skillOverrides) ? doc.skillOverrides : {};
37080
+ return {
37081
+ text,
37082
+ doc,
37083
+ overrides
37084
+ };
37085
+ }
37086
+ /**
37087
+ * Read a settings file's overrides. For listing, a file that can't be used sets nothing. With
37088
+ * `strict` (planning a switch) its error is thrown instead: 403 `path_outside_root` for a link
37089
+ * leading outside its folder, 422 `config_unparsable` for a file that doesn't parse. A switch
37090
+ * must never pick its target while a file that applies can't be read, or it could write the
37091
+ * wrong file.
37092
+ */
37093
+ function readOverrides(source, strict = false) {
37094
+ try {
37095
+ guard(source);
37096
+ const { overrides } = load$1(source.file);
37097
+ const known = Object.entries(overrides).filter((e) => typeof e[1] === "string" && VALUES.has(e[1]));
37098
+ return {
37099
+ source,
37100
+ overrides: new Map(known)
37101
+ };
37102
+ } catch (error) {
37103
+ if (strict) throw error;
37104
+ return {
37105
+ source,
37106
+ overrides: /* @__PURE__ */ new Map(),
37107
+ error: errorMessage$1(error)
37108
+ };
37109
+ }
37110
+ }
37111
+ /**
37112
+ * Settings files from highest precedence to lowest, given them lowest first (user, project,
37113
+ * local…): local files keep their order (the repository-root one wins over an older one in the
37114
+ * working folder), then project, then user.
37115
+ */
37116
+ function highestFirst(files) {
37117
+ return [...files.filter((f) => f.source.scope === "local"), ...files.filter((f) => f.source.scope !== "local").reverse()];
37118
+ }
37119
+ /** The file (highest precedence first) that decides a skill name, and its value. */
37120
+ function decidingOverride(highest, name) {
37121
+ for (const file of highest) {
37122
+ const value = file.overrides.get(name);
37123
+ if (value) return {
37124
+ file,
37125
+ value
37126
+ };
37127
+ }
37128
+ return null;
37129
+ }
37130
+ /** The value to write for a switch, and where (`userFile` when no file decides the name). */
37131
+ function planOverride(files, userFile, name, enabled) {
37132
+ const highest = highestFirst(files);
37133
+ const deciding = decidingOverride(highest, name);
37134
+ const target = deciding?.file.source ?? userFile;
37135
+ if (!enabled) return {
37136
+ target,
37137
+ value: "off"
37138
+ };
37139
+ const lower = decidingOverride(deciding ? highest.slice(highest.indexOf(deciding.file) + 1) : [], name)?.value;
37140
+ return {
37141
+ target,
37142
+ value: lower && lower !== "on" ? "on" : void 0
37143
+ };
37144
+ }
37145
+ /**
37146
+ * The file text before and after setting (or, with `value` undefined, removing) one skill's
37147
+ * entry in `skillOverrides`. Everything outside that value is kept byte for byte.
37148
+ */
37149
+ function renderOverride(file, name, value) {
37150
+ const { text, overrides } = load$1(file);
37151
+ const entries = Object.entries(overrides);
37152
+ const at = entries.findIndex(([key]) => key === name);
37153
+ if (value === void 0) {
37154
+ if (at >= 0) entries.splice(at, 1);
37155
+ } else if (at >= 0) entries[at] = [name, value];
37156
+ else entries.push([name, value]);
37157
+ const next = Object.fromEntries(entries);
37158
+ const before = text ?? "";
37159
+ const existed = text !== null;
37160
+ if (isDeepStrictEqual(next, overrides)) return {
37161
+ before,
37162
+ after: before,
37163
+ existed,
37164
+ changed: false
37165
+ };
37166
+ return {
37167
+ before,
37168
+ after: text?.trim() ? setTopLevelMember(text, "skillOverrides", next) : `${JSON.stringify({ skillOverrides: next }, null, 2)}\n`,
37169
+ existed,
37170
+ changed: true
37171
+ };
37172
+ }
37173
+ // ../server/src/services/agent-config/toml-comments.ts
37174
+ /** End offset (exclusive) of a multi-line string whose body starts at `at`. */
37175
+ function skipMultiline(text, at, quote) {
37176
+ let i = at;
37177
+ while (i < text.length) {
37178
+ if (quote === "\"\"\"" && text[i] === "\\") {
37179
+ i += 2;
37180
+ continue;
37181
+ }
37182
+ if (text.startsWith(quote, i)) {
37183
+ let end = i + 3;
37184
+ while (end < text.length && end < i + 5 && text[end] === quote[0]) end++;
37185
+ return end;
37186
+ }
37187
+ i++;
37188
+ }
37189
+ return text.length;
37190
+ }
37191
+ /** End offset (exclusive) of a one-line string starting at the quote at `at`. */
37192
+ function skipString(text, at) {
37193
+ const quote = text[at];
37194
+ let i = at + 1;
37195
+ while (i < text.length && text[i] !== "\n") {
37196
+ if (quote === "\"" && text[i] === "\\") {
37197
+ i += 2;
37198
+ continue;
37199
+ }
37200
+ if (text[i] === quote) return i + 1;
37201
+ i++;
37202
+ }
37203
+ return i;
37204
+ }
37205
+ /**
37206
+ * Every comment in a TOML text, whole-line or after a value, in order. A `#` inside a basic,
37207
+ * literal or multi-line string is not a comment.
37208
+ */
37209
+ function tomlComments(text) {
37210
+ const out = [];
37211
+ let i = 0;
37212
+ while (i < text.length) if (text.startsWith("\"\"\"", i)) i = skipMultiline(text, i + 3, "\"\"\"");
37213
+ else if (text.startsWith("'''", i)) i = skipMultiline(text, i + 3, "'''");
37214
+ else if (text[i] === "\"" || text[i] === "'") i = skipString(text, i);
37215
+ else if (text[i] === "#") {
37216
+ const newline = text.indexOf("\n", i);
37217
+ const end = newline < 0 ? text.length : newline;
37218
+ out.push({
37219
+ start: i,
37220
+ text: text.slice(i, end).replace(/\r$/, "").trimEnd()
37221
+ });
37222
+ i = end;
37223
+ } else i++;
37224
+ return out;
37225
+ }
37226
+ /** The comment that ends one line, if any (the line read on its own). */
37227
+ function lineComment(line) {
37228
+ return tomlComments(line).at(-1)?.text ?? null;
37229
+ }
37230
+ /** True when both texts hold the same comments, each as often (order and position aside). */
37231
+ function sameComments(before, after) {
37232
+ const list = (text) => tomlComments(text).map((c) => c.text).sort();
37233
+ const a = list(before);
37234
+ const b = list(after);
37235
+ return a.length === b.length && a.every((c, i) => c === b[i]);
37236
+ }
37237
+ // ../server/src/services/agent-config/skill-rules.ts
37238
+ const isTable = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
37239
+ const fold$1 = (p) => {
37240
+ const abs = resolve(p);
37241
+ return process.platform === "linux" ? abs : abs.toLowerCase();
37242
+ };
37243
+ /** True when a rule entry selects the skill: by path when it has one, else by name. */
37244
+ function selects(entry, skill) {
37245
+ if (!isTable(entry) || typeof entry.enabled !== "boolean") return false;
37246
+ if (typeof entry.path === "string") return fold$1(entry.path) === fold$1(skill.path);
37247
+ return typeof entry.name === "string" && entry.name === skill.name;
37248
+ }
37249
+ const unparsable = (file, why) => new ServiceError(422, "config_unparsable", `${file} could not be parsed: ${why}`);
37250
+ function load(file) {
37251
+ if (!existsSync(file)) return {
37252
+ text: null,
37253
+ doc: {},
37254
+ rules: []
37255
+ };
37256
+ const text = readFileSync(file, "utf8");
37257
+ let doc;
37258
+ try {
37259
+ doc = Ko(text);
37260
+ } catch (error) {
37261
+ throw unparsable(file, errorMessage$1(error));
37262
+ }
37263
+ if (!isTable(doc)) throw unparsable(file, "not a table");
37264
+ if (doc.skills !== void 0 && !isTable(doc.skills)) throw unparsable(file, "`skills` is not a table");
37265
+ const config = isTable(doc.skills) ? doc.skills.config : void 0;
37266
+ if (config !== void 0 && !Array.isArray(config)) throw unparsable(file, "`skills.config` is not a list");
37267
+ return {
37268
+ text,
37269
+ doc,
37270
+ rules: config ?? []
37271
+ };
37272
+ }
37273
+ /** The rules in a config file; none when it is missing or can't be parsed. */
37274
+ function readRules(file) {
37275
+ try {
37276
+ return load(file).rules;
37277
+ } catch {
37278
+ return [];
37279
+ }
37280
+ }
37281
+ /** Whether the rules leave a skill on, and whether any of them selects it. */
37282
+ function ruleState(rules, skill) {
37283
+ let enabled = true;
37284
+ let set = false;
37285
+ for (const entry of rules) {
37286
+ if (!selects(entry, skill)) continue;
37287
+ enabled = entry.enabled === true;
37288
+ set = true;
37289
+ }
37290
+ return {
37291
+ enabled,
37292
+ set
37293
+ };
37294
+ }
37295
+ /**
37296
+ * What a switch does to the rules. Off: drop this skill's own entries of the selector used and
37297
+ * append one with `enabled = false`, by path for a user skill and by name for a project skill
37298
+ * (a project skill's path differs in every worktree). On: drop every entry that selects it.
37299
+ */
37300
+ function ruleChange(rules, skill, byPath, enabled) {
37301
+ const indices = (pick) => new Set(rules.flatMap((entry, i) => pick(entry) ? [i] : []));
37302
+ if (enabled) return {
37303
+ drop: indices((entry) => selects(entry, skill)),
37304
+ append: null
37305
+ };
37306
+ const same = (entry) => isTable(entry) && (byPath ? typeof entry.path === "string" && fold$1(entry.path) === fold$1(skill.path) : entry.path === void 0 && entry.name === skill.name);
37307
+ const append = byPath ? {
37308
+ path: skill.path,
37309
+ enabled: false
37310
+ } : {
37311
+ name: skill.name,
37312
+ enabled: false
37313
+ };
37314
+ return {
37315
+ drop: indices(same),
37316
+ append
37317
+ };
37318
+ }
37319
+ /** The rule list after a change. */
37320
+ function applyRuleChange(rules, change) {
37321
+ const kept = rules.filter((_, i) => !change.drop.has(i));
37322
+ return change.append ? [...kept, change.append] : kept;
37323
+ }
37324
+ /** One `[[skills.config]]` block, as lines. */
37325
+ function block(entry) {
37326
+ return ["[[skills.config]]", ...Object.entries(entry).map(([k, v]) => qo({ [k]: v }).trim())];
37327
+ }
37328
+ const TABLE_HEADER = /^\s*\[/;
37329
+ const RULE_HEADER = /^\s*\[\[\s*skills\s*\.\s*config\s*\]\]\s*(#.*)?$/;
37330
+ /** Whole-line comment and blank lines: never removed by a text edit. */
37331
+ const KEPT = /^\s*(#.*)?$/;
37332
+ const isBlank = (line) => line !== void 0 && line.trim() === "";
37333
+ /**
37334
+ * Edit the `[[skills.config]]` blocks as text: a dropped entry loses its header and setting
37335
+ * lines, while every comment and blank line stays. A comment at the end of a dropped line stays
37336
+ * on a line of its own, indented like it (blank lines left doubled by the removal are
37337
+ * collapsed). An appended entry goes after the last remaining block, or at the end of the file.
37338
+ * Returns null when the entries aren't all written as such blocks (an inline array, say).
37339
+ */
37340
+ function textEdit(text, rules, change) {
37341
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
37342
+ const lines = text === "" ? [] : text.split(/\r?\n/);
37343
+ const starts = lines.flatMap((line, i) => RULE_HEADER.test(line) ? [i] : []);
37344
+ if (starts.length !== rules.length) return null;
37345
+ /** Dropped lines, each with what replaces it: its own comment, or nothing. */
37346
+ const dropped = /* @__PURE__ */ new Map();
37347
+ const drop = (i) => {
37348
+ const line = lines[i] ?? "";
37349
+ const comment = lineComment(line);
37350
+ dropped.set(i, comment === null ? null : `${/^\s*/.exec(line)?.[0] ?? ""}${comment}`);
37351
+ };
37352
+ starts.forEach((start, k) => {
37353
+ if (!change.drop.has(k)) return;
37354
+ drop(start);
37355
+ for (let i = start + 1; i < lines.length && !TABLE_HEADER.test(lines[i] ?? ""); i++) if (!KEPT.test(lines[i] ?? "")) drop(i);
37356
+ });
37357
+ const out = [];
37358
+ let removedSinceText = false;
37359
+ lines.forEach((line, i) => {
37360
+ const replacement = dropped.get(i);
37361
+ if (replacement !== void 0) {
37362
+ removedSinceText = replacement === null;
37363
+ if (replacement !== null) out.push(replacement);
37364
+ return;
37365
+ }
37366
+ if (isBlank(line) && removedSinceText && (out.length === 0 || isBlank(out.at(-1)))) return;
37367
+ out.push(line);
37368
+ if (!isBlank(line)) removedSinceText = false;
37369
+ });
37370
+ if (!change.append) return out.join(eol);
37371
+ const added = block(change.append);
37372
+ const lastRule = out.findLastIndex((line) => RULE_HEADER.test(line));
37373
+ if (lastRule < 0) {
37374
+ while (out.length && isBlank(out.at(-1))) out.pop();
37375
+ return [
37376
+ ...out,
37377
+ ...out.length ? [""] : [],
37378
+ ...added,
37379
+ ""
37380
+ ].join(eol);
37381
+ }
37382
+ let at = lastRule;
37383
+ for (let i = lastRule + 1; i < out.length && !TABLE_HEADER.test(out[i] ?? ""); i++) if (!KEPT.test(out[i] ?? "")) at = i;
37384
+ const next = out[at + 1];
37385
+ out.splice(at + 1, 0, "", ...added, ...next !== void 0 && !isBlank(next) ? [""] : []);
37386
+ return out.join(eol);
37387
+ }
37388
+ /** Plain JSON view for comparisons (dates become strings). */
37389
+ const plain$1 = (value) => JSON.parse(JSON.stringify(value));
37390
+ /** A document for comparisons: plain JSON, and an empty `[skills]` table counts as none. */
37391
+ function normalize(value) {
37392
+ const doc = plain$1(value);
37393
+ if (isTable(doc.skills) && Object.keys(doc.skills).length === 0) delete doc.skills;
37394
+ return doc;
37395
+ }
37396
+ /**
37397
+ * The file text before and after a switch. The `[[skills.config]]` blocks are edited as text
37398
+ * (`textEdit`); when the entries are written some other way, the TOML library patches the
37399
+ * document. Either way the result must parse to the expected document and hold exactly the
37400
+ * same comments, whole-line and after values alike (`sameComments`), or the switch is refused
37401
+ * (422 `config_edit_unsafe`) rather than lose part of the file.
37402
+ */
37403
+ function renderRuleSwitch(file, skill, byPath, on) {
37404
+ const { text, doc, rules } = load(file);
37405
+ const before = text ?? "";
37406
+ const existed = text !== null;
37407
+ const change = ruleChange(rules, skill, byPath, on);
37408
+ const next = applyRuleChange(rules, change);
37409
+ if (isDeepStrictEqual(plain$1(next), plain$1(rules))) return {
37410
+ before,
37411
+ after: before,
37412
+ existed,
37413
+ changed: false
37414
+ };
37415
+ const skills = isTable(doc.skills) ? { ...doc.skills } : {};
37416
+ if (next.length) skills.config = next;
37417
+ else delete skills.config;
37418
+ const expected = normalize({
37419
+ ...doc,
37420
+ skills
37421
+ });
37422
+ const safe = (candidate) => {
37423
+ if (candidate === null || !sameComments(before, candidate)) return false;
37424
+ try {
37425
+ return isDeepStrictEqual(normalize(Ko(candidate)), expected);
37426
+ } catch {
37427
+ return false;
37428
+ }
37429
+ };
37430
+ const edited = textEdit(before, rules, change);
37431
+ if (safe(edited)) return {
37432
+ before,
37433
+ after: edited,
37434
+ existed,
37435
+ changed: true
37436
+ };
37437
+ const patched = Ja(before, expected);
37438
+ if (safe(patched)) return {
37439
+ before,
37440
+ after: patched,
37441
+ existed,
37442
+ changed: true
37443
+ };
37444
+ throw new ServiceError(422, "config_edit_unsafe", `CodeDen can't change the skill settings in ${file} without losing other settings or comments; edit it by hand`);
37445
+ }
37446
+ // ../server/src/services/agent-config/skills-service.ts
37447
+ const fold = (p) => process.platform === "linux" ? p : p.toLowerCase();
37448
+ /**
37449
+ * Skill switches (§12.4): whether each skill is on, read from its provider's native setting,
37450
+ * and switching one on or off by writing that setting. Every write is previewed by `diff`,
37451
+ * backed up first and atomic. See `agent-config-skills.ts` in contracts for the rules.
37452
+ */
37453
+ var AgentConfigSkills = class {
37454
+ deps;
37455
+ constructor(deps) {
37456
+ this.deps = deps;
37457
+ }
37458
+ /** The user's skills, plus the project's, with their state for the project's checkout. */
37459
+ list(projectId) {
37460
+ const project = projectId ? this.deps.project(projectId) : null;
37461
+ return this.withState(listSkills(this.deps.home, project), this.pageContext(project));
37462
+ }
37463
+ /** The state of the skills a session in `cwd` finds (What's loaded, the `/` menu). */
37464
+ forSession(skills, cwd, gitRoot, projectId) {
37465
+ const context = claudeSessionHookFiles(this.deps.home, cwd, gitRoot, projectId);
37466
+ return this.withState(skills, context);
37467
+ }
37468
+ diff(req) {
37469
+ const plan = this.plan(req);
37470
+ const { before, after, existed } = plan.render();
37471
+ return {
37472
+ ...unifiedDiff(plan.file.file, existed, before, after),
37473
+ scope: plan.scope
37474
+ };
37475
+ }
37476
+ apply(req) {
37477
+ const plan = this.plan(req);
37478
+ const { after, existed, changed } = plan.render();
37479
+ if (!changed) return {
37480
+ file: null,
37481
+ backupPath: null,
37482
+ skills: this.list(req.listProjectId)
37483
+ };
37484
+ const backupPath = existed ? this.deps.backup(plan.file.file) : null;
37485
+ mkdirSync(dirname(plan.file.file), { recursive: true });
37486
+ guard(plan.file);
37487
+ atomicWrite(plan.file.file, after);
37488
+ return {
37489
+ file: plan.file.file,
37490
+ backupPath,
37491
+ skills: this.list(req.listProjectId)
37492
+ };
37493
+ }
37494
+ withState(skills, context) {
37495
+ const claude = highestFirst(context.map((f) => readOverrides(f)));
37496
+ const codexRules = this.codexRules();
37497
+ return skills.map((skill) => {
37498
+ if (skill.provider === "codex") {
37499
+ const state = ruleState(codexRules, {
37500
+ name: skill.name,
37501
+ path: skill.file.absolutePath
37502
+ });
37503
+ return {
37504
+ ...skill,
37505
+ enabled: state.enabled,
37506
+ setIn: state.set ? this.codexConfig() : null
37507
+ };
37508
+ }
37509
+ const deciding = decidingOverride(claude, skill.name);
37510
+ const value = deciding?.value;
37511
+ return {
37512
+ ...skill,
37513
+ enabled: value !== "off",
37514
+ ...value === "name-only" || value === "user-invocable-only" ? { visibility: value } : {},
37515
+ setIn: deciding?.file.source.file ?? null
37516
+ };
37517
+ });
37518
+ }
37519
+ /** Claude's settings files for the Agents page: the user's, plus the project's. */
37520
+ pageContext(project) {
37521
+ const home = this.deps.home;
37522
+ const user = claudeHookFile("user", home, null);
37523
+ if (!project) return [user];
37524
+ return [
37525
+ user,
37526
+ claudeHookFile("project", home, project),
37527
+ claudeHookFile("local", home, project)
37528
+ ];
37529
+ }
37530
+ codexConfig() {
37531
+ return join(this.deps.home, ".codex", "config.toml");
37532
+ }
37533
+ /** Codex's rules for listing; none when config.toml leads outside home or doesn't parse. */
37534
+ codexRules() {
37535
+ try {
37536
+ guard({
37537
+ root: this.deps.home,
37538
+ file: this.codexConfig()
37539
+ });
37540
+ } catch {
37541
+ return [];
37542
+ }
37543
+ return readRules(this.codexConfig());
37544
+ }
37545
+ /**
37546
+ * What a switch will do, with every input checked first: the skill must be one listed for
37547
+ * `listProjectId` (404 `skill_not_found`); every settings file that applies must be readable
37548
+ * and inside its folder (422 `config_unparsable`, 403 `path_outside_root`), so the target is
37549
+ * never chosen around a file that can't be read; and the file written must stay inside its
37550
+ * folder.
37551
+ */
37552
+ plan(req) {
37553
+ if (!agentConfigFeatures[req.provider].skillSwitches) throw badRequest(`CodeDen does not switch ${req.provider} skills`, "skills_unsupported");
37554
+ const project = req.listProjectId ? this.deps.project(req.listProjectId) : null;
37555
+ const { provider, enabled, listProjectId: _, ...locator } = req;
37556
+ const absolutePath = fold(this.deps.resolve(locator).absolutePath);
37557
+ const skill = listSkills(this.deps.home, project).find((s) => s.provider === provider && fold(s.file.absolutePath) === absolutePath);
37558
+ if (!skill) throw notFound(`No ${provider} skill at ${req.path}; reload the list`, "skill_not_found");
37559
+ if (provider === "codex") {
37560
+ const file = {
37561
+ root: this.deps.home,
37562
+ file: this.codexConfig()
37563
+ };
37564
+ guard(file);
37565
+ const target = {
37566
+ name: skill.name,
37567
+ path: skill.file.absolutePath
37568
+ };
37569
+ const byPath = skill.scope === "user";
37570
+ return {
37571
+ file,
37572
+ scope: "user",
37573
+ render: () => renderRuleSwitch(file.file, target, byPath, enabled)
37574
+ };
37575
+ }
37576
+ const { target, value } = planOverride(this.pageContext(project).map((f) => readOverrides(f, true)), claudeHookFile("user", this.deps.home, null), skill.name, enabled);
37577
+ guard(target);
37578
+ return {
37579
+ file: target,
37580
+ scope: target.scope,
37581
+ render: () => renderOverride(target.file, skill.name, value)
37582
+ };
37583
+ }
37584
+ };
36510
37585
  // ../server/src/services/agent-config/slash-commands.ts
36511
37586
  /**
36512
37587
  * The composer's `/` menu (§13): the skills and custom commands that apply to a session's
@@ -36620,13 +37695,14 @@ function reportedCommands(db, sessionId) {
36620
37695
  const MCP_TEST_SLACK_MS = 5e3;
36621
37696
  /**
36622
37697
  * Agent configuration (§12.4): discovery of each provider's memory files, skills, subagents,
36623
- * MCP servers and hooks; edits in the native files with backups and diff previews; and the
36624
- * "what's loaded" view of a session.
37698
+ * MCP servers and hooks; edits in the native files with backups and diff previews; skill
37699
+ * switches; and the "what's loaded" view of a session.
36625
37700
  */
36626
37701
  var AgentConfigService = class {
36627
37702
  deps;
36628
37703
  files;
36629
37704
  hooks;
37705
+ skills;
36630
37706
  constructor(deps) {
36631
37707
  this.deps = deps;
36632
37708
  this.files = new AgentConfigFiles((l) => this.rootDir(l), deps.backupDir);
@@ -36636,6 +37712,12 @@ var AgentConfigService = class {
36636
37712
  backup: (file) => this.files.backup(file),
36637
37713
  shelf: new HookShelf(deps.disabledHooksFile)
36638
37714
  });
37715
+ this.skills = new AgentConfigSkills({
37716
+ home: deps.home,
37717
+ project: (id) => this.project(id),
37718
+ backup: (file) => this.files.backup(file),
37719
+ resolve: (locator) => this.files.resolve(locator)
37720
+ });
36639
37721
  }
36640
37722
  list(query) {
36641
37723
  const project = query.projectId ? this.project(query.projectId) : null;
@@ -36741,31 +37823,58 @@ var AgentConfigService = class {
36741
37823
  }
36742
37824
  /**
36743
37825
  * The composer's `/` menu for a session (§13): the skills that apply to its working folder
36744
- * (as What's loaded finds them) and its provider's custom command files; once the provider
36745
- * has reported a non-empty list of what it runs for the session (built-in ones included),
36746
- * that list (`mergeSlashCommands`).
37826
+ * (as What's loaded finds them, without those switched off) and its provider's custom
37827
+ * command files; once the provider has reported a non-empty list of what it runs for the
37828
+ * session (built-in ones included), that list (`mergeSlashCommands`).
36747
37829
  * Role sessions get what applies to their own folder: the orchestrator, outside any
36748
37830
  * project, only user-level entries.
36749
37831
  */
36750
37832
  slashCommands(sessionId) {
36751
37833
  const { session, gitRoot, files } = this.discover(sessionId);
36752
- const custom = session.provider === "claude" ? loadedClaudeCommands(this.deps.home, session.cwd, gitRoot) : [];
36753
- return mergeSlashCommands([...files.skills.map(skillCommand), ...custom], this.deps.reportedCommands(sessionId), session.provider);
37834
+ return mergeSlashCommands(this.discoveredCommands(session.provider, session.cwd, gitRoot, files.skills), this.deps.reportedCommands(sessionId), session.provider);
36754
37835
  }
36755
- /** A session's working folder, project and the native files its provider loads there. */
37836
+ /**
37837
+ * The `/` menu of a session that does not exist yet (the New session draft): what `provider`
37838
+ * would load in the project folder. No provider has reported a list, so the files are the list.
37839
+ */
37840
+ projectSlashCommands(projectId, provider) {
37841
+ const project = this.project(projectId);
37842
+ const { gitRoot, files } = this.discoverIn(provider, project.path, project);
37843
+ return mergeSlashCommands(this.discoveredCommands(provider, project.path, gitRoot, files.skills), null, provider);
37844
+ }
37845
+ /** Skills that are switched on, then the provider's custom command files, in precedence order. */
37846
+ discoveredCommands(provider, cwd, gitRoot, skills) {
37847
+ const custom = provider === "claude" ? loadedClaudeCommands(this.deps.home, cwd, gitRoot) : [];
37848
+ return [...skills.filter((s) => s.enabled).map(skillCommand), ...custom];
37849
+ }
37850
+ /**
37851
+ * A session's working folder, project and the native files its provider loads there, with
37852
+ * each skill's switch state for that folder.
37853
+ */
36756
37854
  discover(sessionId) {
36757
37855
  const session = this.deps.repo.get(sessionId);
36758
37856
  const project = session.projectId ? this.project(session.projectId) : null;
36759
- const gitRoot = findGitRoot(session.cwd);
36760
- const home = this.deps.home;
36761
37857
  return {
36762
37858
  session,
36763
37859
  project,
37860
+ ...this.discoverIn(session.provider, session.cwd, project)
37861
+ };
37862
+ }
37863
+ /** The native files `provider` loads in `cwd` (skills with their switch state), and the git root. */
37864
+ discoverIn(provider, cwd, project) {
37865
+ const gitRoot = findGitRoot(cwd);
37866
+ const home = this.deps.home;
37867
+ const found = provider === "codex" ? loadedCodex(home, cwd, gitRoot, project) : provider === "claude" ? loadedClaude(home, cwd, gitRoot, project) : {
37868
+ memory: [],
37869
+ skills: [],
37870
+ subagents: []
37871
+ };
37872
+ const skills = this.skills.forSession(found.skills, cwd, gitRoot, project?.id ?? null);
37873
+ return {
36764
37874
  gitRoot,
36765
- files: session.provider === "codex" ? loadedCodex(home, session.cwd, gitRoot, project) : session.provider === "claude" ? loadedClaude(home, session.cwd, gitRoot, project) : {
36766
- memory: [],
36767
- skills: [],
36768
- subagents: []
37875
+ files: {
37876
+ ...found,
37877
+ skills
36769
37878
  }
36770
37879
  };
36771
37880
  }
@@ -36834,418 +37943,6 @@ var AgentConfigService = class {
36834
37943
  ];
36835
37944
  }
36836
37945
  };
36837
- // ../server/src/services/automations/sources.ts
36838
- const FILE_SOURCES = ["global_file", "project_file"];
36839
- /** Repo-relative folder of project automation files. */
36840
- const PROJECT_AUTOMATIONS_DIR = join(".codeden", "automations");
36841
- /** The definition fields that, when changed in a file, update the stored automation. */
36842
- function definitionKey(a) {
36843
- return JSON.stringify([
36844
- a.name,
36845
- a.description,
36846
- a.templateId,
36847
- a.trigger,
36848
- a.action,
36849
- a.target,
36850
- a.guards,
36851
- a.workKey ?? null
36852
- ]);
36853
- }
36854
- /**
36855
- * Automation definitions from Markdown files (§10): global ones in `~/.codeden/automations/`
36856
- * (they target all projects) and each project's `.codeden/automations/` (that project only).
36857
- * They are mirrored into the `automations` projection through `automation.saved` /
36858
- * `automation.deleted`, keeping the enabled flag (files never enable anything). Folders are
36859
- * watched, with a periodic rescan for folders created later. Built-in templates are read once.
36860
- */
36861
- var AutomationSources = class {
36862
- deps;
36863
- paths = /* @__PURE__ */ new Map();
36864
- errors = [];
36865
- builtin = [];
36866
- globalTemplates = [];
36867
- watchers = /* @__PURE__ */ new Map();
36868
- timer;
36869
- rescan;
36870
- constructor(deps) {
36871
- this.deps = deps;
36872
- const dir = findTemplatesDir(deps.templatesDir);
36873
- if (dir) {
36874
- const { files, errors } = readAutomationDir(dir);
36875
- this.builtin = files.map((f) => toTemplate(f, "builtin"));
36876
- for (const e of errors) deps.log?.(`automations: built-in template ${e.path}: ${e.message}`);
36877
- } else deps.log?.("automations: built-in templates folder not found");
36878
- }
36879
- /** Built-in templates, then global automation files. */
36880
- templates() {
36881
- const builtinIds = new Set(this.builtin.map((t) => t.id));
36882
- return [...this.builtin, ...this.globalTemplates.filter((t) => !builtinIds.has(t.id))];
36883
- }
36884
- template(id) {
36885
- return this.templates().find((t) => t.id === id);
36886
- }
36887
- fileErrors() {
36888
- return this.errors;
36889
- }
36890
- pathOf(automationId) {
36891
- return this.paths.get(automationId) ?? null;
36892
- }
36893
- start() {
36894
- this.reload();
36895
- const every = this.deps.rescanMs ?? 6e4;
36896
- if (every > 0) {
36897
- this.rescan = setInterval(() => this.reload(), every);
36898
- this.rescan.unref();
36899
- }
36900
- }
36901
- stop() {
36902
- if (this.timer) clearTimeout(this.timer);
36903
- if (this.rescan) clearInterval(this.rescan);
36904
- for (const w of this.watchers.values()) w.close();
36905
- this.watchers.clear();
36906
- }
36907
- /** Re-read every folder and sync the projection. Returns true when anything changed. */
36908
- reload() {
36909
- const now = (/* @__PURE__ */ new Date()).toISOString();
36910
- const errors = [];
36911
- const found = [];
36912
- const paths = /* @__PURE__ */ new Map();
36913
- const add = (file, base) => {
36914
- const { parsed } = file;
36915
- found.push({
36916
- ...base,
36917
- name: parsed.frontmatter.name,
36918
- description: parsed.frontmatter.description ?? null,
36919
- enabled: false,
36920
- templateId: parsed.id,
36921
- trigger: parsed.frontmatter.trigger,
36922
- action: parsed.action,
36923
- guards: parsed.guards,
36924
- workKey: parsed.workKey,
36925
- createdAt: now,
36926
- updatedAt: now
36927
- });
36928
- paths.set(base.id, file.path);
36929
- };
36930
- const global = readAutomationDir(this.deps.globalDir);
36931
- errors.push(...global.errors);
36932
- this.globalTemplates = global.files.map((f) => toTemplate(f, "global_file"));
36933
- for (const file of global.files) add(file, {
36934
- id: `global:${file.parsed.id}`,
36935
- source: "global_file",
36936
- target: { type: "all_projects" }
36937
- });
36938
- const dirs = [this.deps.globalDir];
36939
- for (const project of this.deps.projects.list()) {
36940
- if (project.archivedAt) continue;
36941
- const dir = join(project.path, PROJECT_AUTOMATIONS_DIR);
36942
- dirs.push(dir);
36943
- const local = readAutomationDir(dir);
36944
- errors.push(...local.errors);
36945
- for (const file of local.files) add(file, {
36946
- id: `project:${project.id}:${file.parsed.id}`,
36947
- source: "project_file",
36948
- target: {
36949
- type: "project",
36950
- projectId: project.id
36951
- }
36952
- });
36953
- }
36954
- this.errors = errors;
36955
- this.paths.clear();
36956
- for (const [k, v] of paths) this.paths.set(k, v);
36957
- this.watchDirs(dirs);
36958
- return this.sync(found);
36959
- }
36960
- /** Store new or changed file definitions and delete those whose file is gone. */
36961
- sync(found) {
36962
- const existing = new Map(this.deps.db.select().from(automations).where(inArray(automations.source, [...FILE_SOURCES])).all().map((r) => {
36963
- const a = rowToAutomation(r);
36964
- return [a.id, a];
36965
- }));
36966
- const events = [];
36967
- for (const next of found) {
36968
- const prev = existing.get(next.id);
36969
- existing.delete(next.id);
36970
- if (prev && definitionKey(prev) === definitionKey(next)) continue;
36971
- const automation = prev ? {
36972
- ...next,
36973
- enabled: prev.enabled,
36974
- createdAt: prev.createdAt
36975
- } : next;
36976
- events.push({
36977
- type: "automation.saved",
36978
- sessionId: null,
36979
- payload: { automation }
36980
- });
36981
- }
36982
- for (const gone of existing.keys()) events.push({
36983
- type: "automation.deleted",
36984
- sessionId: null,
36985
- payload: { automationId: gone }
36986
- });
36987
- if (events.length === 0) return false;
36988
- this.deps.store.append(events);
36989
- this.deps.onChange?.();
36990
- return true;
36991
- }
36992
- watchDirs(dirs) {
36993
- const wanted = new Set(dirs.filter((d) => existsSync(d)));
36994
- for (const [dir, watcher] of this.watchers) if (!wanted.has(dir)) {
36995
- watcher.close();
36996
- this.watchers.delete(dir);
36997
- }
36998
- for (const dir of wanted) {
36999
- if (this.watchers.has(dir)) continue;
37000
- try {
37001
- const watcher = watch(dir, () => this.scheduleReload());
37002
- watcher.on("error", () => {
37003
- watcher.close();
37004
- this.watchers.delete(dir);
37005
- });
37006
- this.watchers.set(dir, watcher);
37007
- } catch (error) {
37008
- this.deps.log?.(`automations: cannot watch ${dir}: ${errorMessage$1(error)}`);
37009
- }
37010
- }
37011
- }
37012
- scheduleReload() {
37013
- if (this.timer) clearTimeout(this.timer);
37014
- this.timer = setTimeout(() => {
37015
- this.timer = void 0;
37016
- try {
37017
- this.reload();
37018
- } catch (error) {
37019
- this.deps.log?.(`automations: reload failed: ${errorMessage$1(error)}`);
37020
- }
37021
- }, 200);
37022
- this.timer.unref();
37023
- }
37024
- };
37025
- /** True when a project can run a template (GitHub-only templates need a GitHub project). */
37026
- function templateFits(template, project) {
37027
- return !template.requiresGithub || project.kind === "github";
37028
- }
37029
- // ../server/src/services/automations/definitions.ts
37030
- /** Id of a project's starter automation created from a template. */
37031
- function starterId(projectId, templateId) {
37032
- return `starter:${projectId}:${templateId}`;
37033
- }
37034
- /**
37035
- * Database-stored automations (§10): create (from a template or from scratch), update,
37036
- * delete, enable and disable, and the starter set offered to new projects. File-based
37037
- * automations are read-only here except for their enabled switch.
37038
- */
37039
- var AutomationDefinitions = class {
37040
- deps;
37041
- constructor(deps) {
37042
- this.deps = deps;
37043
- }
37044
- all() {
37045
- return this.deps.db.select().from(automations).all().map(rowToAutomation);
37046
- }
37047
- find(id) {
37048
- const row = this.deps.db.select().from(automations).where(eq(automations.id, id)).get();
37049
- return row ? rowToAutomation(row) : void 0;
37050
- }
37051
- get(id) {
37052
- const a = this.find(id);
37053
- if (!a) throw notFound(`No automation ${id}`, "automation_not_found");
37054
- return a;
37055
- }
37056
- create(req, opts = {}) {
37057
- const id = opts.id ?? randomUUID();
37058
- const template = req.templateId ? this.template(req.templateId) : void 0;
37059
- if (req.projectId) this.deps.projects.get(req.projectId);
37060
- const now = (/* @__PURE__ */ new Date()).toISOString();
37061
- const trigger = req.trigger ?? template?.trigger;
37062
- const action = req.action ?? template?.action;
37063
- const name = req.name ?? template?.name;
37064
- if (!trigger || !action || !name) throw badRequest("name, trigger and action are required");
37065
- const automation = Automation.parse({
37066
- id,
37067
- name,
37068
- description: req.description !== void 0 ? req.description : template?.description ?? null,
37069
- enabled: req.enabled,
37070
- source: "database",
37071
- templateId: template?.id ?? null,
37072
- trigger,
37073
- action,
37074
- target: req.projectId ? {
37075
- type: "project",
37076
- projectId: req.projectId
37077
- } : { type: "all_projects" },
37078
- guards: AutomationGuards.parse({
37079
- ...template?.guards,
37080
- ...req.guards
37081
- }),
37082
- workKey: req.workKey ?? template?.workKey,
37083
- ...opts.createdBy && opts.createdBy !== "user" ? { createdBy: opts.createdBy } : {},
37084
- createdAt: now,
37085
- updatedAt: now
37086
- });
37087
- validateAutomation(automation);
37088
- return this.save(automation);
37089
- }
37090
- update(id, req) {
37091
- const current = this.editable(id);
37092
- if (req.target?.type === "project") this.deps.projects.get(req.target.projectId);
37093
- const { workKey: _drop, ...rest } = current;
37094
- const workKey = req.workKey === null ? void 0 : req.workKey ?? current.workKey;
37095
- const next = Automation.parse({
37096
- ...rest,
37097
- ...req.name !== void 0 ? { name: req.name } : {},
37098
- ...req.description !== void 0 ? { description: req.description } : {},
37099
- ...req.trigger ? { trigger: req.trigger } : {},
37100
- ...req.action ? { action: req.action } : {},
37101
- ...req.target ? { target: req.target } : {},
37102
- guards: AutomationGuards.parse({
37103
- ...current.guards,
37104
- ...req.guards
37105
- }),
37106
- ...workKey ? { workKey } : {},
37107
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
37108
- });
37109
- validateAutomation(next);
37110
- return this.save(next);
37111
- }
37112
- remove(id) {
37113
- this.editable(id);
37114
- this.deps.store.append([{
37115
- type: "automation.deleted",
37116
- sessionId: null,
37117
- payload: { automationId: id }
37118
- }]);
37119
- this.deps.schedules.removeAutomation(id);
37120
- }
37121
- /**
37122
- * Enable or disable. With `projectId` on an automation that targets all projects, only that
37123
- * project's override changes; otherwise the automation's own switch.
37124
- */
37125
- setEnabled(id, enabled, projectId) {
37126
- const a = this.get(id);
37127
- if (projectId) this.deps.projects.get(projectId);
37128
- if (projectId && a.target.type === "all_projects") {
37129
- this.deps.schedules.upsert(id, projectId, { enabledOverride: enabled });
37130
- return a;
37131
- }
37132
- if (projectId && a.target.type === "project" && a.target.projectId !== projectId) throw notFound(`Automation ${id} does not belong to that project`, "automation_not_found");
37133
- if (a.enabled === enabled) return a;
37134
- return this.save({
37135
- ...a,
37136
- enabled,
37137
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
37138
- });
37139
- }
37140
- /** Drop a project's override of an all-projects automation; it follows its own switch again. */
37141
- clearOverride(id, projectId) {
37142
- const a = this.get(id);
37143
- this.deps.projects.get(projectId);
37144
- if (a.target.type !== "all_projects") throw conflict("Only automations for all projects have per-project overrides", "no_override");
37145
- this.deps.schedules.upsert(id, projectId, { enabledOverride: null });
37146
- return a;
37147
- }
37148
- /**
37149
- * Create the project's starter set (§10) from the starter templates that fit the project,
37150
- * keeping existing ones; with `enable`, switch them all on.
37151
- */
37152
- starterSet(projectId, enable) {
37153
- const project = this.deps.projects.get(projectId);
37154
- const out = [];
37155
- for (const template of this.deps.sources.templates()) {
37156
- if (!template.starter || template.source !== "builtin" || !templateFits(template, project)) continue;
37157
- const id = starterId(projectId, template.id);
37158
- const existing = this.find(id);
37159
- if (existing) {
37160
- out.push(enable ? this.setEnabled(id, true) : existing);
37161
- continue;
37162
- }
37163
- out.push(this.create({
37164
- templateId: template.id,
37165
- projectId,
37166
- enabled: enable
37167
- }, { id }));
37168
- }
37169
- return out;
37170
- }
37171
- /**
37172
- * An editable database copy of any automation (also file-based and built-in ones), off,
37173
- * named "<name> (copy)" unless a name is given. Targets `projectId`, else the original's
37174
- * target. The copy keeps the original's work key, so the two never run the same work at once.
37175
- */
37176
- duplicate(id, req) {
37177
- const a = this.get(id);
37178
- const projectId = req.projectId ?? (a.target.type === "project" ? a.target.projectId : void 0);
37179
- return this.create({
37180
- projectId,
37181
- name: req.name ?? `${a.name} (copy)`.slice(0, 200),
37182
- description: a.description,
37183
- trigger: a.trigger,
37184
- action: a.action,
37185
- guards: a.guards,
37186
- workKey: a.workKey,
37187
- enabled: false
37188
- });
37189
- }
37190
- /**
37191
- * Import shared automation files (§10): re-parse the source with the file parser, then
37192
- * create each selected valid file as a database automation, always off and marked
37193
- * `createdBy: import`. Nothing runs.
37194
- */
37195
- importFrom(req) {
37196
- if (req.projectId) this.deps.projects.get(req.projectId);
37197
- const select = req.select ? new Set(req.select) : null;
37198
- const items = previewImport(req.source).filter((i) => !select || select.has(i.fileName));
37199
- const created = [];
37200
- const failed = [];
37201
- for (const item of items) {
37202
- const draft = item.automation;
37203
- if (!draft) {
37204
- failed.push({
37205
- fileName: item.fileName,
37206
- error: item.error ?? "Invalid file"
37207
- });
37208
- continue;
37209
- }
37210
- created.push(this.create({
37211
- projectId: req.projectId,
37212
- name: draft.name,
37213
- description: draft.description,
37214
- trigger: draft.trigger,
37215
- action: draft.action,
37216
- guards: draft.guards,
37217
- workKey: draft.workKey,
37218
- enabled: false
37219
- }, { createdBy: "import" }));
37220
- }
37221
- return {
37222
- created,
37223
- failed
37224
- };
37225
- }
37226
- /** Parse an import without saving anything. */
37227
- previewImport(req) {
37228
- return previewImport(req);
37229
- }
37230
- template(id) {
37231
- const template = this.deps.sources.template(id);
37232
- if (!template) throw notFound(`No automation template ${id}`, "template_not_found");
37233
- return template;
37234
- }
37235
- editable(id) {
37236
- const a = this.get(id);
37237
- if (a.source !== "database") throw conflict("This automation is defined in a file; edit the file instead", "automation_read_only");
37238
- return a;
37239
- }
37240
- save(automation) {
37241
- this.deps.store.append([{
37242
- type: "automation.saved",
37243
- sessionId: null,
37244
- payload: { automation }
37245
- }]);
37246
- return automation;
37247
- }
37248
- };
37249
37946
  // ../server/src/services/sessions/conflicts.ts
37250
37947
  /** Session statuses that count as running or queued work for conflict detection (§8.4). */
37251
37948
  const ACTIVE_SESSION_STATUSES = [
@@ -39105,6 +39802,8 @@ const DONE_STAGES = [
39105
39802
  var FactoryStore = class {
39106
39803
  db;
39107
39804
  events;
39805
+ /** When each project's loop last ticked (see `saveTick`); not persisted. */
39806
+ lastTicks = /* @__PURE__ */ new Map();
39108
39807
  constructor(db, events) {
39109
39808
  this.db = db;
39110
39809
  this.events = events;
@@ -39134,7 +39833,17 @@ var FactoryStore = class {
39134
39833
  updatedAt: now.toISOString()
39135
39834
  };
39136
39835
  }
39836
+ /**
39837
+ * Save the loop; writes only when something besides its stamps (`lastTickAt`, `updatedAt`)
39838
+ * changed. The log is never pruned (§6) and every loop event refreshes the open factory
39839
+ * panels, while an idle factory ticks every 30 s.
39840
+ */
39137
39841
  saveLoop(loop) {
39842
+ const current = this.loop(loop.projectId);
39843
+ if (current && sameLoop(current, loop)) return {
39844
+ ...loop,
39845
+ updatedAt: current.updatedAt
39846
+ };
39138
39847
  const next = {
39139
39848
  ...loop,
39140
39849
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -39146,6 +39855,21 @@ var FactoryStore = class {
39146
39855
  }]);
39147
39856
  return next;
39148
39857
  }
39858
+ /**
39859
+ * End a tick: remember when it ran and save the loop if the tick changed it. A tick that
39860
+ * changed nothing saves nothing, so the time is kept in memory for the panel's "last check".
39861
+ */
39862
+ saveTick(loop, at) {
39863
+ this.lastTicks.set(loop.projectId, at);
39864
+ return this.saveLoop({
39865
+ ...loop,
39866
+ lastTickAt: at
39867
+ });
39868
+ }
39869
+ /** When the project's loop last ticked in this process (none yet after a restart). */
39870
+ lastTick(projectId) {
39871
+ return this.lastTicks.get(projectId);
39872
+ }
39149
39873
  item(id) {
39150
39874
  const item = this.db.select().from(factoryItems).where(eq(factoryItems.id, id)).get()?.item;
39151
39875
  return item ? withItemDefaults(item) : void 0;
@@ -39258,6 +39982,11 @@ function withItemDefaults(item) {
39258
39982
  ...item
39259
39983
  };
39260
39984
  }
39985
+ /** Same loop apart from the stamps a save sets; key order and `undefined` fields don't count. */
39986
+ function sameLoop(a, b) {
39987
+ const content = ({ lastTickAt: _tick, updatedAt: _saved, ...rest }) => JSON.parse(JSON.stringify(rest));
39988
+ return isDeepStrictEqual(content(a), content(b));
39989
+ }
39261
39990
  const TUNING_DEFAULTS = FactoryTuning.parse({});
39262
39991
  /** A loop as read from the projection: newer tuning knobs and the scout filled in. */
39263
39992
  function upgradeLoop(loop) {
@@ -41526,11 +42255,9 @@ async function runTick(ctx, projectId, hooks) {
41526
42255
  state: deriveState(ctx, projectId, loop)
41527
42256
  }, now);
41528
42257
  }
42258
+ /** Ticks that change nothing append nothing (the status shows the tick time from memory). */
41529
42259
  function save(ctx, loop, now) {
41530
- return ctx.store.saveLoop({
41531
- ...loop,
41532
- lastTickAt: now.toISOString()
41533
- });
42260
+ return ctx.store.saveTick(loop, now.toISOString());
41534
42261
  }
41535
42262
  /** The loop state the panel shows: the most advanced stage with work in it. */
41536
42263
  function deriveState(ctx, projectId, loop) {
@@ -42087,7 +42814,7 @@ async function buildStatus(ctx, projectId) {
42087
42814
  scout: project.autonomyMode === "dark_factory" ? scoutStatus(ctx, project, loop, spend) : null,
42088
42815
  visionExists: visionOf(project).exists,
42089
42816
  lastDigest: ctx.store.digests(projectId, 1)[0] ?? null,
42090
- lastTickAt: loop.lastTickAt
42817
+ lastTickAt: ctx.store.lastTick(projectId) ?? loop.lastTickAt
42091
42818
  };
42092
42819
  }
42093
42820
  // ../server/src/services/factory/factory-service.ts
@@ -51470,6 +52197,20 @@ var SessionRepo = class {
51470
52197
  turnCount(sessionId) {
51471
52198
  return this.db.select({ n: sql`count(*)` }).from(turns).where(eq(turns.sessionId, sessionId)).get()?.n ?? 0;
51472
52199
  }
52200
+ /** One of the session's turns, or null when it has none with that id. */
52201
+ turn(sessionId, turnId) {
52202
+ const row = this.db.select().from(turns).where(and(eq(turns.sessionId, sessionId), eq(turns.id, turnId))).get();
52203
+ return row ? rowToTurn(row) : null;
52204
+ }
52205
+ /** The index of the checkpoint written when a turn ended (§9), or null when there is none. */
52206
+ turnCheckpoint(sessionId, turnId) {
52207
+ return this.db.select({ index: checkpoints.index }).from(checkpoints).where(and(eq(checkpoints.sessionId, sessionId), eq(checkpoints.turnId, turnId))).orderBy(desc(checkpoints.index)).limit(1).get()?.index ?? null;
52208
+ }
52209
+ /** The ids of the session's root-thread turns, oldest first (by start); turn N is at N - 1. */
52210
+ rootTurnIds(session) {
52211
+ if (!session.rootThreadId) return [];
52212
+ return this.db.select({ id: turns.id }).from(turns).where(and(eq(turns.sessionId, session.id), eq(turns.threadId, session.rootThreadId))).orderBy(asc(turns.startedAt), asc(sql`rowid`)).all().map((r) => r.id);
52213
+ }
51473
52214
  /** Follow-ups of the latest root-thread turn that produced some (§7.5). */
51474
52215
  latestFollowups(session) {
51475
52216
  if (!session.rootThreadId) return null;
@@ -51499,6 +52240,55 @@ function readProjectConfig(projectPath) {
51499
52240
  function projectDefaultProvider(project) {
51500
52241
  return project.defaultProvider ?? readProjectConfig(project.path)?.defaultProvider ?? null;
51501
52242
  }
52243
+ // ../server/src/services/sessions/fork-turn.ts
52244
+ /** Runner error code (from the provider): the conversation no longer holds the fork point. */
52245
+ const FORK_POINT_MISSING = "fork_point_missing";
52246
+ /**
52247
+ * Check that `source` can be forked at `turnId`, before asking the runner anything: a turn of
52248
+ * its own root thread that has ended, and (unless it is the latest) one whose end recorded a
52249
+ * fork point and a checkpoint. Throws `ServiceError`s.
52250
+ */
52251
+ function forkTurn(repo, source, turnId) {
52252
+ const turn = repo.turn(source.id, turnId);
52253
+ if (!turn) throw notFound("The session has no such turn", "turn_not_found");
52254
+ if (turn.threadId !== source.rootThreadId) throw badRequest("Only the session's own turns can be forked from", "fork_unsupported");
52255
+ if (turn.status === "running") throw conflict("Wait for the turn to finish before forking from it", "turn_running");
52256
+ const rootTurns = repo.rootTurnIds(source);
52257
+ const turnNumber = rootTurns.indexOf(turnId) + 1;
52258
+ if (rootTurns.at(-1) === turnId) return {
52259
+ turnId,
52260
+ turnNumber,
52261
+ latest: true
52262
+ };
52263
+ if (!turn.forkPoint) throw conflict("This turn can't be forked from: the provider didn't record where its conversation ended", "fork_point_unavailable");
52264
+ const checkpoint = repo.turnCheckpoint(source.id, turnId);
52265
+ if (checkpoint === null) throw conflict("CodeDen has no snapshot of the code at the end of this turn", "no_checkpoint");
52266
+ return {
52267
+ turnId,
52268
+ turnNumber,
52269
+ latest: false,
52270
+ upTo: turn.forkPoint,
52271
+ checkpoint
52272
+ };
52273
+ }
52274
+ /**
52275
+ * The checkpoint of an earlier turn, once the runner confirms it still has it (`indexes` from
52276
+ * its settled list). Throws 409 `no_checkpoint` when it doesn't.
52277
+ */
52278
+ function confirmedCheckpoint(at, indexes) {
52279
+ if (!indexes.includes(at.checkpoint)) throw conflict("CodeDen has no snapshot of the code at the end of this turn", "no_checkpoint");
52280
+ return at.checkpoint;
52281
+ }
52282
+ /**
52283
+ * A provider that no longer finds the fork point in the conversation (it was replaced or
52284
+ * deleted since the turn ended) answers 409 `fork_point_missing`; anything else is left to
52285
+ * the usual runner error mapping.
52286
+ */
52287
+ function forkPointMissing(error) {
52288
+ if (runnerErrorCode(error) !== FORK_POINT_MISSING) return null;
52289
+ const detail = error instanceof Error ? ` (${error.message})` : "";
52290
+ return conflict(`This turn can't be forked from any more: its conversation changed since it ended${detail}`, FORK_POINT_MISSING);
52291
+ }
51502
52292
  // ../server/src/services/sessions/fork.ts
51503
52293
  const BUSY$1 = /* @__PURE__ */ new Set(["running", "waiting"]);
51504
52294
  const NOT_STARTED$1 = /* @__PURE__ */ new Set([
@@ -51542,13 +52332,19 @@ function forkTitle(source) {
51542
52332
  * 2. a copy of its conversation, made now by the provider (`provider.fork`).
51543
52333
  * The source is only read. A turn that started meanwhile, even one that has already finished,
51544
52334
  * would make the two disagree, so that fails the fork (`sourceMoved`; the unused copy is left
51545
- * behind, never resumed). Throws `ServiceError`s.
52335
+ * behind, never resumed).
52336
+ *
52337
+ * With `req.turnId` of an earlier turn ("Fork from here"), the code is that turn's checkpoint
52338
+ * and the copy ends with that turn (`upTo`, its fork point). Neither changes once the turn has
52339
+ * ended, so later turns don't affect it. The latest turn forks as above. Throws `ServiceError`s.
51546
52340
  */
51547
52341
  async function planFork(deps, sourceId, req) {
51548
52342
  const { repo, runner } = deps;
51549
52343
  const source = repo.get(sourceId);
51550
52344
  const provider = runner.providers().find((p) => p.id === source.provider);
51551
52345
  const project = forkProject(deps.projects, source, provider);
52346
+ const at = req.turnId ? forkTurn(repo, source, req.turnId) : null;
52347
+ const earlier = at && !at.latest ? at : null;
51552
52348
  const turns = repo.turnCount(source.id);
51553
52349
  const { checkpoints } = await runner.request({
51554
52350
  name: "checkpoint.list",
@@ -51556,18 +52352,25 @@ async function planFork(deps, sourceId, req) {
51556
52352
  sessionId: source.id,
51557
52353
  settled: true
51558
52354
  }, SETTLE_TIMEOUT_MS).catch(rethrow);
51559
- const checkpoint = checkpoints.at(-1)?.index ?? null;
52355
+ const checkpoint = earlier ? confirmedCheckpoint(earlier, checkpoints.map((c) => c.index)) : checkpoints.at(-1)?.index ?? null;
51560
52356
  const { resumeToken } = await runner.request({
51561
52357
  name: "provider.fork",
51562
52358
  providerId: source.provider,
51563
52359
  resumeToken: source.resumeToken,
51564
- cwd: project.path
51565
- }, FORK_TIMEOUT_MS).catch(rethrow);
51566
- const late = sourceMoved(repo, source, turns, provider);
51567
- if (late) throw late;
52360
+ cwd: project.path,
52361
+ ...earlier ? { upTo: earlier.upTo } : {}
52362
+ }, FORK_TIMEOUT_MS).catch((error) => {
52363
+ throw forkPointMissing(error) ?? toServiceError$1(error);
52364
+ });
52365
+ if (!earlier) {
52366
+ const late = sourceMoved(repo, source, turns, provider);
52367
+ if (late) throw late;
52368
+ }
51568
52369
  return forkPlan(source, project, {
51569
52370
  checkpoint,
51570
- resumeToken
52371
+ resumeToken,
52372
+ turnId: at?.turnId,
52373
+ turnNumber: at?.turnNumber
51571
52374
  }, req);
51572
52375
  }
51573
52376
  /**
@@ -51596,10 +52399,11 @@ function forkProject(projects, source, provider) {
51596
52399
  * effort, permissions, plan mode, work and issue, started by the user, with its own copy of the
51597
52400
  * conversation (`resumeToken`). Its worktree branches from the source's branch and starts from
51598
52401
  * `checkpoint`; its pull request targets the source's base branch. There is no §8.4 conflict
51599
- * check: the fork is a deliberate, visible duplicate (`forkedFrom`).
52402
+ * check: the fork is a deliberate, visible duplicate (`forkedFrom`, with the turn it was taken
52403
+ * at when one was named).
51600
52404
  */
51601
52405
  function forkPlan(source, project, origin, req) {
51602
- const { checkpoint } = origin;
52406
+ const { checkpoint, turnId, turnNumber } = origin;
51603
52407
  const now = (/* @__PURE__ */ new Date()).toISOString();
51604
52408
  const session = {
51605
52409
  id: randomUUID(),
@@ -51630,7 +52434,9 @@ function forkPlan(source, project, origin, req) {
51630
52434
  rootThreadId: null,
51631
52435
  forkedFrom: {
51632
52436
  sessionId: source.id,
51633
- checkpoint
52437
+ checkpoint,
52438
+ ...turnId ? { turnId } : {},
52439
+ ...turnId && turnNumber ? { turnNumber } : {}
51634
52440
  },
51635
52441
  createdAt: now,
51636
52442
  updatedAt: now,
@@ -51988,8 +52794,8 @@ var SessionService = class {
51988
52794
  }
51989
52795
  /**
51990
52796
  * Fork an agent session (§9): a new session with a copy of its conversation, in a new
51991
- * worktree started from its latest checkpoint (`fork.ts`). The worktree is made in the
51992
- * background.
52797
+ * worktree started from its latest checkpoint, or from a named turn's ("Fork from here",
52798
+ * `fork.ts`). The worktree is made in the background.
51993
52799
  */
51994
52800
  async fork(id, req) {
51995
52801
  const { repo, projects, runner } = this.deps;
@@ -52303,14 +53109,16 @@ function isUnwatched(session) {
52303
53109
  return session.automationRunId !== null || session.startedBy.kind === "automation" || session.startedBy.kind === "pm";
52304
53110
  }
52305
53111
  /**
52306
- * Approvals nobody is there to answer (§10.1, §11.2). Nothing here ever approves: it denies, with
52307
- * guidance, so the agent carries on instead of waiting forever (fail closed).
52308
- * - The PM and the factory's sessions in a dark factory are denied at once, as their questions
53112
+ * Approvals, and MCP servers' forms, nobody is there to answer (§10.1, §11.2). Nothing here ever
53113
+ * approves or fills in a form: an approval is denied, with guidance, and a form is withdrawn
53114
+ * (`cancel`, no data sent), so the agent carries on instead of waiting forever (fail closed).
53115
+ * - The PM and the factory's sessions in a dark factory are answered at once, as their questions
52309
53116
  * are answered at once.
52310
53117
  * - Sessions started by an automation, a PM or the factory otherwise wait
52311
- * {@link UNWATCHED_APPROVAL_WAIT_MS} for the user, then are denied. A scheduled run then
52312
- * finishes instead of blocking the next ones.
52313
- * Sessions the user started wait for the user, however long.
53118
+ * {@link UNWATCHED_APPROVAL_WAIT_MS} for the user. A scheduled run then finishes instead of
53119
+ * blocking the next ones.
53120
+ * Sessions the user started wait for the user, however long. The agent's own questions are left
53121
+ * to the factory's rules (`answerProviderQuestion`).
52314
53122
  */
52315
53123
  var UnwatchedApprovals = class {
52316
53124
  deps;
@@ -52319,10 +53127,10 @@ var UnwatchedApprovals = class {
52319
53127
  constructor(deps) {
52320
53128
  this.deps = deps;
52321
53129
  }
52322
- /** Watch new approvals, and pick up the ones already open (a restart keeps their age). */
53130
+ /** Watch new requests, and pick up the ones already open (a restart keeps their age). */
52323
53131
  start() {
52324
53132
  this.unsubscribe ??= this.deps.store.subscribe((events) => this.onEvents(events));
52325
- for (const item of this.deps.inbox.list()) if (item.kind === "approval") this.track(item);
53133
+ for (const item of this.deps.inbox.list()) if (item.kind === "approval" || item.kind === "question") this.track(item);
52326
53134
  }
52327
53135
  stop() {
52328
53136
  this.unsubscribe?.();
@@ -52334,7 +53142,7 @@ var UnwatchedApprovals = class {
52334
53142
  for (const e of events) {
52335
53143
  if (e.type !== "inbox.created" && e.type !== "inbox.updated") continue;
52336
53144
  const { item } = e.payload;
52337
- if (item.kind !== "approval") continue;
53145
+ if (item.kind !== "approval" && item.kind !== "question") continue;
52338
53146
  if (item.status !== "open") {
52339
53147
  clearTimeout(this.timers.get(item.id));
52340
53148
  this.timers.delete(item.id);
@@ -52344,10 +53152,11 @@ var UnwatchedApprovals = class {
52344
53152
  track(item) {
52345
53153
  const { sessionId, requestId } = item;
52346
53154
  if (item.status !== "open" || !sessionId || !requestId || this.timers.has(item.id)) return;
53155
+ if (item.kind === "question" && !this.isMcpForm(item)) return;
52347
53156
  const session = this.deps.repo.find(sessionId)?.session;
52348
53157
  if (!session) return;
52349
53158
  if (unattendedSession(this.deps, sessionId)) {
52350
- this.deny(item, UNATTENDED_APPROVAL);
53159
+ this.close(item, UNATTENDED_APPROVAL);
52351
53160
  return;
52352
53161
  }
52353
53162
  if (!isUnwatched(session)) return;
@@ -52355,26 +53164,33 @@ var UnwatchedApprovals = class {
52355
53164
  const age = (this.deps.now?.() ?? Date.now()) - Date.parse(item.createdAt);
52356
53165
  const timer = setTimeout(() => {
52357
53166
  this.timers.delete(item.id);
52358
- this.deny(item, noAnswerDenial(waitMs));
53167
+ this.close(item, noAnswerDenial(waitMs));
52359
53168
  }, Math.max(0, waitMs - (Number.isFinite(age) ? age : 0)));
52360
53169
  timer.unref?.();
52361
53170
  this.timers.set(item.id, timer);
52362
53171
  }
52363
- async deny(item, message) {
53172
+ /** A question that is an MCP server's form (its transcript item names the server). */
53173
+ isMcpForm(item) {
53174
+ if (!item.sessionId || !item.itemId) return false;
53175
+ const mirrored = this.deps.repo.item(item.sessionId, item.itemId)?.item;
53176
+ return mirrored?.kind === "user_question" && mirrored.mcpServer !== void 0;
53177
+ }
53178
+ /** Deny an approval with `message`, or withdraw a form: never approve, never invent data. */
53179
+ async close(item, message) {
52364
53180
  if (this.deps.inbox.find(item.id)?.status !== "open") return;
52365
53181
  if (!item.sessionId || !item.requestId) return;
52366
53182
  try {
52367
53183
  await this.deps.respond(item.sessionId, {
52368
53184
  requestId: item.requestId,
52369
- response: {
53185
+ response: item.kind === "approval" ? {
52370
53186
  kind: "approval",
52371
53187
  decision: "deny",
52372
53188
  message,
52373
53189
  decidedBy: "policy"
52374
- }
53190
+ } : { kind: "cancel" }
52375
53191
  });
52376
53192
  } catch (error) {
52377
- this.deps.log?.(`approvals: deny ${item.id}: ${errorMessage$1(error)}`);
53193
+ this.deps.log?.(`approvals: close ${item.id}: ${errorMessage$1(error)}`);
52378
53194
  }
52379
53195
  }
52380
53196
  };
@@ -57544,4 +58360,4 @@ main().then((code) => process.exit(code), (error) => {
57544
58360
  });
57545
58361
  export {};
57546
58362
 
57547
- //# sourceMappingURL=main-hdQfX9V6.js.map
58363
+ //# sourceMappingURL=main-Bx58CdV4.js.map