codeden 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +7 -1
  2. package/dist/cli.js +1 -1
  3. package/dist/drizzle/20261108041036_session_worktree_cleanup/migration.sql +2 -0
  4. package/dist/drizzle/20261108041036_session_worktree_cleanup/snapshot.json +3424 -0
  5. package/dist/drizzle/20261108045102_project_pin_order/migration.sql +4 -0
  6. package/dist/drizzle/20261108045102_project_pin_order/snapshot.json +3444 -0
  7. package/dist/drizzle/20261108052259_queued_message_sender/migration.sql +1 -0
  8. package/dist/drizzle/20261108052259_queued_message_sender/snapshot.json +3454 -0
  9. package/dist/{main-DaGFUbbW.js → main-CzmG0d8t.js} +2 -2
  10. package/dist/{main-DaGFUbbW.js.map → main-CzmG0d8t.js.map} +1 -1
  11. package/dist/{main-BF0aQ9U8.js → main-pXwL7GDQ.js} +1555 -149
  12. package/dist/main-pXwL7GDQ.js.map +1 -0
  13. package/dist/{runner-D8m3WdtS.js → runner-CJetH9jW.js} +1947 -389
  14. package/dist/runner-CJetH9jW.js.map +1 -0
  15. package/dist/runner.js +1 -1
  16. package/dist/web/assets/{core-BmMheb4i.js → core-BeOBW-Wr.js} +1 -1
  17. package/dist/web/assets/engine-oniguruma-BLpLufIT.js +1 -0
  18. package/dist/web/assets/{highlighted-body-JLMTFACH-nUCrIe8P.js → highlighted-body-JLMTFACH-D-L40tHI.js} +1 -1
  19. package/dist/web/assets/{highlights-CdGBlIcw.js → highlights-ZCqssNA3.js} +1 -1
  20. package/dist/web/assets/index-D8qaPF_E.css +1 -0
  21. package/dist/web/assets/index-DR-I5EC5.js +892 -0
  22. package/dist/web/assets/langs-DKjK0GUN.js +1 -0
  23. package/dist/web/assets/mermaid-WFAXDIZX-fZTzmfWq.js +1 -0
  24. package/dist/web/assets/{shiki-D8HIISPT.js → shiki-CWXnlX2q.js} +2 -2
  25. package/dist/web/assets/{stream-tokenizer-BmWmYTv_.js → stream-tokenizer-BpK1CZzW.js} +1 -1
  26. package/dist/web/assets/{themes-CKsIQs7_.js → themes-Bm7B3n10.js} +2 -2
  27. package/dist/web/favicon.svg +1 -1
  28. package/dist/web/index.html +2 -2
  29. package/package.json +1 -1
  30. package/dist/main-BF0aQ9U8.js.map +0 -1
  31. package/dist/runner-D8m3WdtS.js.map +0 -1
  32. package/dist/web/assets/engine-oniguruma-CVzqqUIi.js +0 -1
  33. package/dist/web/assets/index-DKWZjMF1.js +0 -885
  34. package/dist/web/assets/index-DfoaM4E6.css +0 -1
  35. package/dist/web/assets/langs-kfQh6fa2.js +0 -1
  36. package/dist/web/assets/mermaid-WFAXDIZX-BIq7_eYY.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { n as __require, r as __toESM, t as __commonJSMin } from "./rolldown-runtime-DegdyBIC.js";
2
- import { $ as RunAutomationRequest, $n as unknown, $t as Automation, A as SessionFileQuery, An as McpServerTarget, At as isNewerVersion, B as BrowserClientMessage, Bn as custom, Bt as TriageIssueRequest, C as import_websocket_server, Cn as InboxItemKind, Ct as HealthResponse, D as CODEDEN_VERSION, Dn as AgentConfigLocator, Dt as UpdateInstallRequest, E as TtsRequest, En as AgentConfigListQuery, Et as AppPointer, F as ListNoticesQuery, Fn as datetime, Ft as GithubIssue, G as BrowserOpenRequest, Gn as looseObject, Gt as FactoryTriageRequest, H as BrowserHistoryRequest, Hn as int, Ht as parseGithubPrId, I as ResolveConflictRequest, In as _enum, It as GithubLabelRequest, J as AutomationFileFrontmatter, Jn as optional, Jt as PutVisionRequest, K as BrowserViewportRequest, Kn as number, Kt as FactoryTuning, L as RoleMessageRequest, Ln as _null, Lt as GithubListQuery, M as RunnerToServerMessage, Mn as AutonomyLevel, Mt as NOTICE_BATCH_CODE, N as ServerLock, Nn as ProviderId, Nt as CreateGithubIssueRequest, O as UsageQuery, On as AgentConfigWriteRequest, Ot as UpdateStatus, P as ListConflictsQuery, Pn as ReasoningEffort, Pt as GithubCommentRequest, Q as ListAutomationsQuery, Qn as union$1, Qt as Artifact, R as UpdateSessionRequest, Rn as array, Rt as GithubPr, S as acquireInstallLock, Sn as InboxItem, St as UpdateProjectRequest, T as SttQuery, Tn as AgentConfigDiffRequest, Tt as Settings, U as BrowserNavigateRequest, Un as intersection, Ut as DiscoveryArea, V as BrowserDevServerRequest, Vn as discriminatedUnion, Vt as githubPrId, W as BrowserNewTabRequest, Wn as literal, Wt as FactoryCandidate, X as CreateAutomationRequest, Xn as record, Xt as SetAutonomyModeRequest, Y as AutomationProjectRequest, Yn as preprocess, Yt as ReviewVerdict, Z as ListAutomationRunsQuery, Zn as string, Zt as isFactoryMode, _ as importClaudeSdkFrom, _n as StartedBy, _t as SetSecretRequest, a as Hono, an as DiffScope, at as UpdateAutomationProjectSettingsRequest, b as runNpm, bn as DEFAULT_VISION_PATH, bt as ThreadItemsQuery, c as serve, cn as RestoreFilesRequest, ct as FileReadQuery, d as createImageEngineRegistry, dn as TerminalClientMessage, dt as ListSessionsQuery, en as AutomationGuards, er as ZodError, et as SetAutomationEnabledRequest, f as IMAGE_ASPECT_RATIOS, fn as TerminalQuery, ft as ResolveInboxRequest, g as CLAUDE_SDK_VERSION, gn as ConflictDecision, gt as SessionStartMeta, h as claudeSdkSource, hn as ProjectConfigFile, ht as SendMessageRequest, i as killTree$1, in as SessionStatus, it as UpcomingRunsQuery, j as RunnerLock, jn as McpServerUpsertRequest, jt as ConflictRecord, k as UsageSettings, kn as McpServerDiffRequest, kt as compareVersions, l as upgradeWebSocket, ln as SessionResult, lt as FileWriteRequest, m as ImageGenerationError, mn as SessionOptions, mt as SecretKey, n as readLock, nn as TriagePolicy, nr as safeParse$1, nt as TriageDecisionRequest, o as tryDecodeURIComponent, on as GitCommitRequest, ot as UpdateAutomationRequest, p as resolveImageOutput, pn as RunnerCommandResults, pt as RespondRequest, q as BrowserWsQuery, qn as object, qt as KillSwitchRequest, r as isAlive, rn as Session, rr as prettifyError, rt as TriageReport, s as bufferToFormData, sn as GitPushRequest, st as CreateProjectRequest, tn as AutomationRun, tr as toJSONSchema, tt as StarterSetRequest, u as ImageEngineRegistry, un as SpeechSummaryRequest, ut as ListInboxQuery, v as installClaudeSdk, vn as WorkKey, vt as StartSessionRequest, w as wrapper_default, wn as AgentConfigDeleteQuery, wt as agentEventToNewEvent, x as resolveNpm, xn as FactoryLimits, xt as UiClientMessage, y as isClaudeSdkInstalled, yn as Project, yt as SteerRequest, z as ImageTestRequest, zn as boolean, zt as MergePrRequest } from "./runner-D8m3WdtS.js";
2
+ import { $ as ListAutomationRunsQuery, $n as literal, $t as FactoryTuning, A as SessionFileQuery, An as FactoryLimits, At as LocalSttModelParams, B as ImageTestRequest, Bn as McpServerUpsertRequest, Bt as CreateGithubIssueRequest, C as isElectronRuntime, Cn as ProjectConfigFile, Ct as ThreadItemsQuery, D as CODEDEN_VERSION, Dn as Project, Dt as agentEventToNewEvent, E as wrapper_default, En as WorkKey, Et as HealthResponse, F as ListConflictsQuery, Fn as AgentConfigListQuery, Ft as UpdateStatus, G as BrowserNavigateRequest, Gn as _enum, Gt as GithubPr, H as BrowserClientMessage, Hn as ProviderId, Ht as GithubIssue, I as ListNoticesQuery, In as AgentConfigLocator, It as compareVersions, J as BrowserViewportRequest, Jn as boolean, Jt as githubPrId, K as BrowserNewTabRequest, Kn as _null, Kt as MergePrRequest, L as ResolveConflictRequest, Ln as AgentConfigWriteRequest, Lt as isNewerVersion, M as RunnerToServerMessage, Mn as InboxItemKind, Mt as TtsRequest, N as ServerLock, Nn as AgentConfigDeleteQuery, Nt as AppPointer, O as UsageQuery, On as sortProjects, Ot as Settings, P as ArchiveSessionRequest, Pn as AgentConfigDiffRequest, Pt as UpdateInstallRequest, Q as CreateAutomationRequest, Qn as intersection, Qt as FactoryTriageRequest, R as RoleMessageRequest, Rn as McpServerDiffRequest, Rt as ConflictRecord, S as acquireInstallLock, Sn as SessionOptions, St as SteerRequest, T as import_websocket_server, Tn as StartedBy, Tt as UpdateProjectRequest, U as BrowserDevServerRequest, Un as ReasoningEffort, Ut as GithubLabelRequest, V as summarizeActivity, Vn as AutonomyLevel, Vt as GithubCommentRequest, W as BrowserHistoryRequest, Wn as datetime, Wt as GithubListQuery, X as AutomationFileFrontmatter, Xn as discriminatedUnion, Xt as DiscoveryArea, Y as BrowserWsQuery, Yn as custom, Yt as parseGithubPrId, Z as AutomationProjectRequest, Zn as int, Zt as FactoryCandidate, _ as importClaudeSdkFrom, _n as SessionResult, _t as SecretKey, a as Hono, an as Artifact, ar as record, at as TriageReport, b as runNpm, bn as TerminalQuery, bt as SetSecretRequest, c as serve, cn as AutomationRun, cr as unknown, ct as UpdateAutomationRequest, d as createImageEngineRegistry, dn as SessionStatus, dr as safeParse$1, dt as FileWriteRequest, en as KillSwitchRequest, er as looseObject, et as ListAutomationsQuery, f as IMAGE_ASPECT_RATIOS, fn as DiffScope, fr as prettifyError, ft as ListInboxQuery, g as CLAUDE_SDK_VERSION, gn as RestoreFilesRequest, gt as RespondRequest, h as claudeSdkSource, hn as GitPushRequest, ht as ResolveInboxRequest, i as killTree$1, in as isFactoryMode, ir as preprocess, it as TriageDecisionRequest, j as RunnerLock, jn as InboxItem, jt as SttQuery, k as UsageSettings, kn as DEFAULT_VISION_PATH, kt as LOCAL_STT_SAMPLE_RATE, l as upgradeWebSocket, ln as TriagePolicy, lr as ZodError, lt as CreateProjectRequest, m as ImageGenerationError, mn as GitCommitRequest, mt as ReorderProjectsRequest, n as readLock, nn as ReviewVerdict, nr as object, nt as SetAutomationEnabledRequest, o as tryDecodeURIComponent, on as Automation, or as string, ot as UpcomingRunsQuery, p as resolveImageOutput, pn as EditQueuedMessageRequest, pt as ListSessionsQuery, q as BrowserOpenRequest, qn as array, qt as TriageIssueRequest, r as isAlive, rn as SetAutonomyModeRequest, rr as optional, rt as StarterSetRequest, s as bufferToFormData, sn as AutomationGuards, sr as union$1, st as UpdateAutomationProjectSettingsRequest, tn as PutVisionRequest, tr as number, tt as RunAutomationRequest, u as ImageEngineRegistry, un as Session, ur as toJSONSchema, ut as FileReadQuery, v as installClaudeSdk, vn as SpeechSummaryRequest, vt as SendMessageRequest, w as ownNodeEnv, wn as ConflictDecision, wt as UiClientMessage, x as resolveNpm, xn as RunnerCommandResults, xt as StartSessionRequest, y as isClaudeSdkInstalled, yn as TerminalClientMessage, yt as SessionStartMeta, z as UpdateSessionRequest, zn as McpServerTarget, zt as NOTICE_BATCH_CODE } from "./runner-CJetH9jW.js";
3
3
  import { createRequire } from "node:module";
4
4
  import fs, { accessSync, closeSync, constants, copyFileSync, cpSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
@@ -7,6 +7,8 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, parse, relativ
7
7
  import { isDeepStrictEqual, parseArgs } from "node:util";
8
8
  import crypto$1, { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
9
9
  import { execFile, spawn } from "node:child_process";
10
+ import { gunzipSync } from "node:zlib";
11
+ import { open, rename } from "node:fs/promises";
10
12
  import { Readable } from "node:stream";
11
13
  import { DatabaseSync } from "node:sqlite";
12
14
  import { versions } from "node:process";
@@ -146,7 +148,9 @@ function homePaths(home) {
146
148
  /** Copies of config files taken before the editors overwrite them (§12.4). */
147
149
  backups: join(home, "backups"),
148
150
  /** Dependencies installed on demand, e.g. Claude Code support (§14). */
149
- deps: join(home, "deps")
151
+ deps: join(home, "deps"),
152
+ /** Models downloaded on demand, e.g. local Whisper for dictation (§11.3). */
153
+ models: join(home, "models")
150
154
  };
151
155
  }
152
156
  /** Create the data directory layout if missing. */
@@ -7177,6 +7181,9 @@ const projects = sqliteTable("projects", {
7177
7181
  factoryLimits: json$3("factory_limits"),
7178
7182
  killSwitch: integer("kill_switch", { mode: "boolean" }).notNull().default(false),
7179
7183
  timezone: text("timezone"),
7184
+ pinned: integer("pinned", { mode: "boolean" }).notNull().default(false),
7185
+ /** Manual list position (§13); the migration backfills it from the alphabetical order. */
7186
+ sortOrder: integer("sort_order").notNull().default(0),
7180
7187
  github: json$3("github"),
7181
7188
  createdAt: text("created_at").notNull(),
7182
7189
  updatedAt: text("updated_at").notNull(),
@@ -7211,6 +7218,8 @@ const sessions = sqliteTable("sessions", {
7211
7218
  createdAt: text("created_at").notNull(),
7212
7219
  updatedAt: text("updated_at").notNull(),
7213
7220
  endedAt: text("ended_at"),
7221
+ archivedAt: text("archived_at"),
7222
+ worktreeRemovedAt: text("worktree_removed_at"),
7214
7223
  /** Accumulated usage (server-side projection; not part of the `Session` entity). */
7215
7224
  usage: json$3("usage"),
7216
7225
  /** How the session was started (workspace fallback, forced conflict reason, queue). */
@@ -7342,6 +7351,8 @@ const queuedMessages = sqliteTable("queued_messages", {
7342
7351
  id: text("id").primaryKey(),
7343
7352
  sessionId: text("session_id").notNull(),
7344
7353
  input: json$3("input").notNull(),
7354
+ /** Who queued it; null for rows written before senders were recorded (the user). */
7355
+ sender: text("sender").$type(),
7345
7356
  createdAt: text("created_at").notNull()
7346
7357
  }, (t) => [index("queued_messages_session").on(t.sessionId, t.createdAt)]);
7347
7358
  /**
@@ -7541,6 +7552,8 @@ function projectToRow(p) {
7541
7552
  factoryLimits: p.factoryLimits,
7542
7553
  killSwitch: p.killSwitch,
7543
7554
  timezone: p.timezone,
7555
+ pinned: p.pinned,
7556
+ sortOrder: p.sortOrder,
7544
7557
  github: p.kind === "github" ? p.github : null,
7545
7558
  createdAt: p.createdAt,
7546
7559
  updatedAt: p.updatedAt,
@@ -7559,7 +7572,9 @@ function rowToProject(r) {
7559
7572
  function sessionToRow(s) {
7560
7573
  return {
7561
7574
  ...s,
7562
- result: s.result ?? null
7575
+ result: s.result ?? null,
7576
+ archivedAt: s.archivedAt ?? null,
7577
+ worktreeRemovedAt: s.worktreeRemovedAt ?? null
7563
7578
  };
7564
7579
  }
7565
7580
  function rowToSession(r) {
@@ -8185,6 +8200,11 @@ function spawnRunner(config, home) {
8185
8200
  const logs = join(home, "logs");
8186
8201
  mkdirSync(logs, { recursive: true });
8187
8202
  const log = openSync(join(logs, "runner.log"), "a");
8203
+ const env = {
8204
+ ...process.env,
8205
+ ...config.env,
8206
+ CODEDEN_HOME: home
8207
+ };
8188
8208
  const child = spawn(config.command, [
8189
8209
  ...config.args,
8190
8210
  "--home",
@@ -8198,11 +8218,7 @@ function spawnRunner(config, home) {
8198
8218
  ],
8199
8219
  windowsHide: true,
8200
8220
  cwd: home,
8201
- env: {
8202
- ...process.env,
8203
- ...config.env,
8204
- CODEDEN_HOME: home
8205
- }
8221
+ env: config.command === process.execPath ? ownNodeEnv(env) : env
8206
8222
  });
8207
8223
  child.unref();
8208
8224
  return child.pid;
@@ -8438,6 +8454,12 @@ var RunnerClient = class {
8438
8454
  case "stream":
8439
8455
  this.opts.onStream?.(msg.stream);
8440
8456
  return;
8457
+ case "providers":
8458
+ this.setStatus({
8459
+ ...this.current,
8460
+ providers: msg.providers
8461
+ });
8462
+ return;
8441
8463
  case "ping":
8442
8464
  this.send({
8443
8465
  type: "pong",
@@ -9411,6 +9433,7 @@ function applyWorkbenchProjection(tx, e) {
9411
9433
  id: m.id,
9412
9434
  sessionId: m.sessionId,
9413
9435
  input: m.input,
9436
+ sender: m.from,
9414
9437
  createdAt: m.createdAt
9415
9438
  }).onConflictDoNothing().run();
9416
9439
  return;
@@ -9418,6 +9441,15 @@ function applyWorkbenchProjection(tx, e) {
9418
9441
  case "message.dequeued":
9419
9442
  tx.delete(queuedMessages).where(eq(queuedMessages.id, e.payload.messageId)).run();
9420
9443
  return;
9444
+ case "message.edited": {
9445
+ const row = tx.select({ input: queuedMessages.input }).from(queuedMessages).where(eq(queuedMessages.id, e.payload.messageId)).get();
9446
+ if (!row) return;
9447
+ tx.update(queuedMessages).set({ input: {
9448
+ ...row.input,
9449
+ text: e.payload.text
9450
+ } }).where(eq(queuedMessages.id, e.payload.messageId)).run();
9451
+ return;
9452
+ }
9421
9453
  }
9422
9454
  }
9423
9455
  /**
@@ -9467,6 +9499,11 @@ function applyProjection(tx, e) {
9467
9499
  updatedAt: e.ts
9468
9500
  }).where(eq(projects.id, e.payload.projectId)).run();
9469
9501
  return [];
9502
+ case "projects.reordered":
9503
+ e.payload.projectIds.forEach((id, index) => {
9504
+ tx.update(projects).set({ sortOrder: index }).where(eq(projects.id, id)).run();
9505
+ });
9506
+ return [];
9470
9507
  case "session.created":
9471
9508
  case "session.updated": {
9472
9509
  const row = sessionToRow(e.payload.session);
@@ -9511,6 +9548,7 @@ function applyProjection(tx, e) {
9511
9548
  case "checkpoint.created":
9512
9549
  case "message.queued":
9513
9550
  case "message.dequeued":
9551
+ case "message.edited":
9514
9552
  applyWorkbenchProjection(tx, e);
9515
9553
  return [];
9516
9554
  case "notice.created":
@@ -20727,6 +20765,16 @@ function toServiceError$1(error) {
20727
20765
  if (code === "unknown_session" || code === "unsupported" || code === "no_turn") return new ServiceError(409, code, message);
20728
20766
  return new ServiceError(502, code, message);
20729
20767
  }
20768
+ /**
20769
+ * Ask a connected runner to re-detect its providers, which also refreshes their model lists
20770
+ * (they come back as a runner status update). Fire and forget; failures are only logged.
20771
+ */
20772
+ function redetectProviders(runner, log) {
20773
+ if (!runner.isConnected()) return;
20774
+ runner.request({ name: "provider.detect" }, 6e4).catch((error) => {
20775
+ log?.(`runner: provider re-detect failed: ${error instanceof Error ? error.message : error}`);
20776
+ });
20777
+ }
20730
20778
  /** `RunnerPort` over the server's (possibly absent) `RunnerClient`. */
20731
20779
  function runnerPort(get) {
20732
20780
  return {
@@ -21026,6 +21074,97 @@ function buffered(ws) {
21026
21074
  function send$1(ws, msg) {
21027
21075
  if (ws.readyState === 1) ws.send(JSON.stringify(msg));
21028
21076
  }
21077
+ // ../server/src/services/sessions/preview.ts
21078
+ /** Tool output kept in transcript pages and live updates; the rest is fetched on demand (§6). */
21079
+ const OUTPUT_PREVIEW_CHARS = 16e3;
21080
+ /**
21081
+ * Trim large tool outputs to their last `max` characters (the end of command output is the
21082
+ * useful part) and mark them truncated with the full size. Pure.
21083
+ */
21084
+ function previewRecord(record, max = OUTPUT_PREVIEW_CHARS) {
21085
+ const { item } = record;
21086
+ if (item.kind !== "tool_call" || !item.output || item.output.text.length <= max) return record;
21087
+ const text = item.output.text;
21088
+ return {
21089
+ ...record,
21090
+ item: {
21091
+ ...item,
21092
+ output: {
21093
+ ...item.output,
21094
+ text: text.slice(text.length - max),
21095
+ truncated: true,
21096
+ totalBytes: item.output.totalBytes ?? Buffer.byteLength(text, "utf8")
21097
+ }
21098
+ }
21099
+ };
21100
+ }
21101
+ // ../server/src/services/sessions/client-items.ts
21102
+ const isPathImage = (a) => a.type === "image" && a.source.type === "path";
21103
+ /** The last segment of a path, whichever separator it uses. */
21104
+ const fileName = (path) => path.split(/[\\/]/).pop() || "image";
21105
+ /**
21106
+ * Input as the browser sees it. Image attachments are handed to providers as file paths and
21107
+ * providers echo that input back, so a user message can hold a path into the artifacts folder
21108
+ * (older transcripts stored it that way too). Such a path becomes its artifact, which the UI
21109
+ * loads from `/api/artifacts/:id`; any other image path keeps only its file name, so no
21110
+ * filesystem path reaches the browser. Pure apart from the lookup.
21111
+ */
21112
+ function clientInput(input, artifacts) {
21113
+ if (!input.attachments.some(isPathImage)) return input;
21114
+ const attachments = input.attachments.map((a) => {
21115
+ if (a.type !== "image" || a.source.type !== "path") return a;
21116
+ const artifact = artifacts.artifactForPath(a.source.path);
21117
+ if (artifact) return {
21118
+ ...a,
21119
+ source: {
21120
+ type: "artifact",
21121
+ artifactId: artifact.id
21122
+ }
21123
+ };
21124
+ return {
21125
+ ...a,
21126
+ source: {
21127
+ type: "path",
21128
+ path: fileName(a.source.path)
21129
+ }
21130
+ };
21131
+ });
21132
+ return {
21133
+ ...input,
21134
+ attachments
21135
+ };
21136
+ }
21137
+ /** An item record as served to the browser: large outputs previewed, attachments mapped. */
21138
+ function clientItemRecord(record, artifacts) {
21139
+ const previewed = previewRecord(record);
21140
+ const { item } = previewed;
21141
+ if (item.kind !== "user_message" || !item.input.attachments.some(isPathImage)) return previewed;
21142
+ return {
21143
+ ...previewed,
21144
+ item: {
21145
+ ...item,
21146
+ input: clientInput(item.input, artifacts)
21147
+ }
21148
+ };
21149
+ }
21150
+ /** A stored event as streamed to the browser: agent items carry mapped attachments too. */
21151
+ function clientEvent(event, artifacts) {
21152
+ if (event.type !== "agent.item.started" && event.type !== "agent.item.updated" && event.type !== "agent.item.completed") return event;
21153
+ const payload = event.payload;
21154
+ if (!("item" in payload) || payload.item.kind !== "user_message") return event;
21155
+ const { item } = payload;
21156
+ if (!item.input.attachments.some(isPathImage)) return event;
21157
+ return {
21158
+ ...event,
21159
+ payload: {
21160
+ ...payload,
21161
+ item: {
21162
+ ...item,
21163
+ input: clientInput(item.input, artifacts)
21164
+ }
21165
+ }
21166
+ };
21167
+ }
21029
21168
  // ../server/src/realtime/coalesce.ts
21030
21169
  function asDelta(e) {
21031
21170
  return e.type === "agent.item.delta" ? e.payload : void 0;
@@ -21074,30 +21213,6 @@ function coalesceEvents(events) {
21074
21213
  }
21075
21214
  return out;
21076
21215
  }
21077
- // ../server/src/services/sessions/preview.ts
21078
- /** Tool output kept in transcript pages and live updates; the rest is fetched on demand (§6). */
21079
- const OUTPUT_PREVIEW_CHARS = 16e3;
21080
- /**
21081
- * Trim large tool outputs to their last `max` characters (the end of command output is the
21082
- * useful part) and mark them truncated with the full size. Pure.
21083
- */
21084
- function previewRecord(record, max = OUTPUT_PREVIEW_CHARS) {
21085
- const { item } = record;
21086
- if (item.kind !== "tool_call" || !item.output || item.output.text.length <= max) return record;
21087
- const text = item.output.text;
21088
- return {
21089
- ...record,
21090
- item: {
21091
- ...item,
21092
- output: {
21093
- ...item.output,
21094
- text: text.slice(text.length - max),
21095
- truncated: true,
21096
- totalBytes: item.output.totalBytes ?? Buffer.byteLength(text, "utf8")
21097
- }
21098
- }
21099
- };
21100
- }
21101
21216
  // ../server/src/realtime/transcript.ts
21102
21217
  function emptyDirty() {
21103
21218
  return {
@@ -21148,8 +21263,11 @@ function collectDirty(dirty, events, sessionId, threadIds) {
21148
21263
  dirty.seq = Math.max(dirty.seq, e.seq);
21149
21264
  }
21150
21265
  }
21151
- /** Load the latest state of dirty rows as a `transcript` message (outputs previewed). */
21152
- function loadTranscript(db, sessionId, dirty) {
21266
+ /**
21267
+ * Load the latest state of dirty rows as a `transcript` message (outputs previewed, image
21268
+ * paths mapped to artifacts, see `clientItemRecord`).
21269
+ */
21270
+ function loadTranscript(db, sessionId, dirty, artifacts) {
21153
21271
  const ids = (set) => [...set];
21154
21272
  const msg = {
21155
21273
  type: "transcript",
@@ -21157,7 +21275,7 @@ function loadTranscript(db, sessionId, dirty) {
21157
21275
  seq: dirty.seq,
21158
21276
  threads: dirty.threads.size === 0 ? [] : db.select().from(threads).where(and(eq(threads.sessionId, sessionId), inArray(threads.id, ids(dirty.threads)))).all().map(rowToThread),
21159
21277
  turns: dirty.turns.size === 0 ? [] : db.select().from(turns).where(and(eq(turns.sessionId, sessionId), inArray(turns.id, ids(dirty.turns)))).all().map(rowToTurn),
21160
- items: dirty.items.size === 0 ? [] : db.select().from(items).where(and(eq(items.sessionId, sessionId), inArray(items.id, ids(dirty.items)))).all().map((r) => previewRecord(rowToItemRecord(r))).sort((a, b) => a.position - b.position)
21278
+ items: dirty.items.size === 0 ? [] : db.select().from(items).where(and(eq(items.sessionId, sessionId), inArray(items.id, ids(dirty.items)))).all().map((r) => clientItemRecord(rowToItemRecord(r), artifacts)).sort((a, b) => a.position - b.position)
21161
21279
  };
21162
21280
  if (dirty.usage) {
21163
21281
  const usage = db.select({ usage: sessions.usage }).from(sessions).where(eq(sessions.id, sessionId)).get()?.usage;
@@ -21173,6 +21291,7 @@ const MAX_WATCH_REPLAY = 2e4;
21173
21291
  /** A client with this many undelivered events is told to resync (it cannot keep up). */
21174
21292
  const MAX_PENDING = 1e4;
21175
21293
  const BATCH = 500;
21294
+ const noArtifacts = { artifactForPath: () => void 0 };
21176
21295
  /**
21177
21296
  * UI realtime hub (§6). Global stream: snapshot seq -> `subscribe { fromSeq }` -> catch-up ->
21178
21297
  * live `events`, batched every ~40ms with text deltas merged. Transcripts: `watch_session`
@@ -21187,12 +21306,14 @@ var WsHub = class {
21187
21306
  clients = /* @__PURE__ */ new Set();
21188
21307
  unsubscribe;
21189
21308
  flushMs;
21309
+ artifacts;
21190
21310
  constructor(store, db, runnerStatus, opts = {}) {
21191
21311
  this.store = store;
21192
21312
  this.db = db;
21193
21313
  this.runnerStatus = runnerStatus;
21194
21314
  this.opts = opts;
21195
21315
  this.flushMs = opts.flushMs ?? 40;
21316
+ this.artifacts = opts.artifacts ?? noArtifacts;
21196
21317
  this.unsubscribe = store.subscribe((events) => this.onEvents(events));
21197
21318
  }
21198
21319
  /** Handlers for `upgradeWebSocket`. */
@@ -21317,7 +21438,7 @@ var WsHub = class {
21317
21438
  const visible = batch.filter((e) => this.visible(client, e));
21318
21439
  if (visible.length > 0) send(client.ws, {
21319
21440
  type: "events",
21320
- events: coalesceEvents(visible)
21441
+ events: this.present(visible)
21321
21442
  });
21322
21443
  cursor = batch.at(-1)?.seq ?? cursor;
21323
21444
  if (batch.length < BATCH) break;
@@ -21388,7 +21509,7 @@ var WsHub = class {
21388
21509
  client.timer = void 0;
21389
21510
  if (!this.clients.has(client)) return;
21390
21511
  if (client.pending.length > 0) {
21391
- const events = coalesceEvents(client.pending);
21512
+ const events = this.present(client.pending);
21392
21513
  client.pending = [];
21393
21514
  send(client.ws, {
21394
21515
  type: "events",
@@ -21398,9 +21519,13 @@ var WsHub = class {
21398
21519
  for (const [sessionId, watch] of client.watches) if (isDirty(watch.dirty)) this.sendTranscript(client, sessionId, watch);
21399
21520
  }
21400
21521
  sendTranscript(client, sessionId, watch) {
21401
- send(client.ws, loadTranscript(this.db, sessionId, watch.dirty));
21522
+ send(client.ws, loadTranscript(this.db, sessionId, watch.dirty, this.artifacts));
21402
21523
  watch.dirty = emptyDirty();
21403
21524
  }
21525
+ /** Events as sent to a client: deltas merged, attachment paths mapped. */
21526
+ present(events) {
21527
+ return coalesceEvents(events).map((e) => clientEvent(e, this.artifacts));
21528
+ }
21404
21529
  };
21405
21530
  function send(ws, msg) {
21406
21531
  if (ws.readyState === 1) ws.send(JSON.stringify(msg));
@@ -22069,15 +22194,15 @@ function conflictRoutes(s) {
22069
22194
  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"))));
22070
22195
  }
22071
22196
  // ../server/src/routes/projects.ts
22072
- /** `/api/projects`: add from a folder or clone, list, update, archive. */
22197
+ /** `/api/projects`: add from a folder or clone, list, reorder, update (and pin), archive. */
22073
22198
  function projectRoutes(s) {
22074
- 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)).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")))).delete("/:id", (c) => {
22199
+ 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")))).delete("/:id", (c) => {
22075
22200
  s.projects.archive(c.req.param("id"));
22076
22201
  return c.json({ ok: true });
22077
22202
  });
22078
22203
  }
22079
22204
  // ../server/src/services/files/path-guard.ts
22080
- const caseInsensitive = process.platform === "win32" || process.platform === "darwin";
22205
+ const caseInsensitive$1 = process.platform === "win32" || process.platform === "darwin";
22081
22206
  /** Real path of `p`, or of its nearest existing ancestor with the rest appended. */
22082
22207
  function realish(p) {
22083
22208
  let current = p;
@@ -22093,8 +22218,8 @@ function realish(p) {
22093
22218
  }
22094
22219
  /** True when `child` is `root` or inside it (no `..` escape, no other drive). */
22095
22220
  function isInside$3(root, child) {
22096
- const a = caseInsensitive ? root.toLowerCase() : root;
22097
- const b = caseInsensitive ? child.toLowerCase() : child;
22221
+ const a = caseInsensitive$1 ? root.toLowerCase() : root;
22222
+ const b = caseInsensitive$1 ? child.toLowerCase() : child;
22098
22223
  const rel = relative(a, b);
22099
22224
  return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
22100
22225
  }
@@ -22191,6 +22316,18 @@ function readSessionFile(root, path) {
22191
22316
  truncated: stat.size > MAX_READ_BYTES$2
22192
22317
  };
22193
22318
  }
22319
+ // ../server/src/services/sessions/workdir.ts
22320
+ /** Shown wherever a cleaned-up worktree is needed before the session is resumed. */
22321
+ const WORKTREE_REMOVED_MESSAGE = "This session's worktree was cleaned up. Send the session a message to recreate it from its branch.";
22322
+ /**
22323
+ * Directory a session's files, git commands and terminals use: its worktree, else its cwd.
22324
+ * Throws `worktree_removed` (409) once cleanup removed the worktree; the next message to the
22325
+ * session recreates it (§9).
22326
+ */
22327
+ function sessionWorkdir(session) {
22328
+ if (session.worktreePath && session.worktreeRemovedAt) throw conflict(WORKTREE_REMOVED_MESSAGE, "worktree_removed");
22329
+ return session.worktreePath ?? session.cwd;
22330
+ }
22194
22331
  // ../server/src/services/workbench/diff-files.ts
22195
22332
  const CHANGE = {
22196
22333
  added: "add",
@@ -22373,7 +22510,7 @@ var WorkbenchService = class {
22373
22510
  };
22374
22511
  /** Directory git commands run in: the session's worktree, else its cwd. */
22375
22512
  function workdir(session) {
22376
- return session.worktreePath ?? session.cwd;
22513
+ return sessionWorkdir(session);
22377
22514
  }
22378
22515
  // ../server/src/routes/session-files.ts
22379
22516
  /** `/api/sessions/:id/files`: read-only file tree and viewer for the session's worktree (§12.3). */
@@ -22381,7 +22518,10 @@ function sessionFileRoutes(s) {
22381
22518
  return new Hono().get("/:id/files", (c) => c.json(listSessionFiles(workdir(s.sessionRepo.get(c.req.param("id")))))).get("/:id/files/content", queryOf(SessionFileQuery), (c) => c.json(readSessionFile(workdir(s.sessionRepo.get(c.req.param("id"))), c.req.valid("query").path)));
22382
22519
  }
22383
22520
  // ../server/src/routes/sessions.ts
22384
- /** `/api/sessions`: start (with conflict handling), message, steer, interrupt, respond, stop. */
22521
+ /**
22522
+ * `/api/sessions`: start (with conflict handling), message, steer, interrupt, respond, stop,
22523
+ * archive (removes the worktree, §9), unarchive and the queued-message tray.
22524
+ */
22385
22525
  function sessionRoutes(s) {
22386
22526
  return new Hono().get("/", queryOf(ListSessionsQuery), (c) => c.json(s.sessionRepo.list(c.req.valid("query")))).post("/", jsonBody(StartSessionRequest), async (c) => {
22387
22527
  const req = c.req.valid("json");
@@ -22392,7 +22532,7 @@ function sessionRoutes(s) {
22392
22532
  ...result,
22393
22533
  conflictId: record.id
22394
22534
  }, 409);
22395
- }).get("/:id", (c) => c.json(s.sessions.detail(c.req.param("id")))).patch("/:id", jsonBody(UpdateSessionRequest), (c) => c.json(s.sessions.update(c.req.param("id"), c.req.valid("json")))).post("/:id/messages", jsonBody(SendMessageRequest), async (c) => c.json(await s.sessions.send(c.req.param("id"), c.req.valid("json")))).post("/:id/steer", jsonBody(SteerRequest), async (c) => {
22535
+ }).get("/:id", (c) => c.json(s.sessions.detail(c.req.param("id")))).patch("/:id", jsonBody(UpdateSessionRequest), async (c) => c.json(await s.sessions.update(c.req.param("id"), c.req.valid("json")))).post("/:id/messages", jsonBody(SendMessageRequest), async (c) => c.json(await s.sessions.send(c.req.param("id"), c.req.valid("json")))).post("/:id/steer", jsonBody(SteerRequest), async (c) => {
22396
22536
  await s.sessions.steer(c.req.param("id"), c.req.valid("json").input);
22397
22537
  return c.json({ ok: true });
22398
22538
  }).post("/:id/interrupt", async (c) => {
@@ -22401,13 +22541,19 @@ function sessionRoutes(s) {
22401
22541
  }).post("/:id/respond", jsonBody(RespondRequest), async (c) => {
22402
22542
  await s.sessions.respond(c.req.param("id"), c.req.valid("json"));
22403
22543
  return c.json({ ok: true });
22404
- }).post("/:id/stop", async (c) => c.json(await s.sessions.stop(c.req.param("id")))).get("/:id/threads/:threadId/items", queryOf(ThreadItemsQuery), (c) => {
22544
+ }).post("/:id/stop", async (c) => c.json(await s.sessions.stop(c.req.param("id")))).post("/:id/archive", jsonBody(ArchiveSessionRequest), async (c) => c.json(await s.worktreeCleanup.archive(c.req.param("id"), c.req.valid("json")))).post("/:id/unarchive", (c) => c.json(s.worktreeCleanup.unarchive(c.req.param("id")))).get("/:id/queue", (c) => {
22545
+ const body = { messages: s.queue.list(c.req.param("id")) };
22546
+ return c.json(body);
22547
+ }).patch("/:id/queue/:messageId", jsonBody(EditQueuedMessageRequest), (c) => c.json(s.queue.edit(c.req.param("id"), c.req.param("messageId"), c.req.valid("json").text))).delete("/:id/queue/:messageId", (c) => {
22548
+ s.queue.delete(c.req.param("id"), c.req.param("messageId"));
22549
+ return c.json({ ok: true });
22550
+ }).post("/:id/queue/:messageId/send-now", async (c) => c.json(await s.queue.sendNow(c.req.param("id"), c.req.param("messageId")))).get("/:id/threads/:threadId/items", queryOf(ThreadItemsQuery), (c) => {
22405
22551
  const id = c.req.param("id");
22406
22552
  s.sessionRepo.get(id);
22407
22553
  const page = s.sessionRepo.threadPage(id, c.req.param("threadId"), c.req.valid("query"));
22408
22554
  return c.json({
22409
22555
  ...page,
22410
- items: page.items.map((r) => previewRecord(r))
22556
+ items: page.items.map((r) => clientItemRecord(r, s.artifacts))
22411
22557
  });
22412
22558
  }).get("/:id/items/:itemId/output", (c) => {
22413
22559
  const record = s.sessionRepo.item(c.req.param("id"), c.req.param("itemId"));
@@ -22801,13 +22947,20 @@ var VoiceService = class {
22801
22947
  signal
22802
22948
  });
22803
22949
  }
22804
- /** Transcribe recorded audio with the chosen (or configured) cloud engine. */
22950
+ /** Transcribe recorded audio with the chosen (or configured) engine. */
22805
22951
  async transcribe(audio, opts) {
22806
22952
  if (audio.bytes.length === 0) throw new ServiceError(400, "empty_audio", "No audio was sent");
22807
22953
  if (audio.bytes.length > 26214400) throw new ServiceError(413, "audio_too_large", "Audio is larger than 25 MB");
22808
22954
  const configured = this.deps.settings.get().voice.dictation;
22809
- const engine = opts.engine ?? (configured.engine === "openai" ? "openai" : "elevenlabs");
22955
+ const engine = opts.engine ?? (configured.engine === "openai" || configured.engine === "local" ? configured.engine : "elevenlabs");
22810
22956
  const language = opts.language ?? configured.language ?? void 0;
22957
+ if (engine === "local") {
22958
+ if (!this.deps.local) throw new ServiceError(503, "local_unavailable", "Local Whisper is not available");
22959
+ return this.deps.local.transcribe(audio.bytes, {
22960
+ language,
22961
+ signal: opts.signal
22962
+ });
22963
+ }
22811
22964
  const form = new FormData();
22812
22965
  form.append("file", new Blob([audio.bytes], { type: audio.mediaType }), audio.fileName);
22813
22966
  if (engine === "elevenlabs") {
@@ -22874,14 +23027,25 @@ const AUDIO_EXTENSIONS = [
22874
23027
  [/mp4|m4a|aac/, ".m4a"],
22875
23028
  [/mpeg|mp3/, ".mp3"]
22876
23029
  ];
22877
- /** `/api/voice/voices`: the ElevenLabs voice picker (§11.3). */
23030
+ function modelId(raw) {
23031
+ const parsed = LocalSttModelParams.safeParse({ id: raw });
23032
+ if (!parsed.success) throw badRequest(`Unknown Whisper model: ${raw}`, "unknown_model");
23033
+ return parsed.data.id;
23034
+ }
23035
+ /**
23036
+ * `/api/voice/*` (§11.3): the ElevenLabs voice picker, and local Whisper's status, downloads
23037
+ * and deletes.
23038
+ */
22878
23039
  function voiceRoutes(s) {
22879
23040
  return new Hono().get("/voices", async (c) => {
22880
23041
  const body = { voices: await s.voice.voices() };
22881
23042
  return c.json(body);
22882
- });
23043
+ }).get("/local", (c) => c.json(s.localStt.status())).post("/local/models/:id/download", (c) => c.json(s.localStt.download(modelId(c.req.param("id"))))).delete("/local/models/:id", async (c) => c.json(await s.localStt.remove(modelId(c.req.param("id")))));
22883
23044
  }
22884
- /** `POST /api/tts` (streamed MP3) and `POST /api/stt` (transcription), both opt-in cloud engines. */
23045
+ /**
23046
+ * `POST /api/tts` (streamed MP3) and `POST /api/stt` (transcription with a cloud engine or
23047
+ * local Whisper).
23048
+ */
22885
23049
  function speechRoutes(s) {
22886
23050
  return new Hono().post("/tts", jsonBody(TtsRequest), async (c) => {
22887
23051
  const upstream = await s.voice.speak(c.req.valid("json"), c.req.raw.signal);
@@ -22948,7 +23112,7 @@ function createApi(ctx) {
22948
23112
  uptimeSec: (Date.now() - ctx.startedAt) / 1e3
22949
23113
  };
22950
23114
  return c.json(body);
22951
- }).get("/snapshot", (c) => c.json(buildSnapshot(ctx))).get("/sessions/:id/snapshot", (c) => c.json(buildSessionSnapshot(s, c.req.param("id")))).route("/projects", projectRoutes(s)).route("/projects", projectGithubRoutes(s)).route("/projects", projectFactoryRoutes(s)).route("/factory", factoryRoutes(s)).route("/prs", prRoutes(s)).route("/github", githubRoutes(s)).route("/sessions", sessionGithubRoutes(s)).route("/sessions", sessionRoutes(s)).route("/sessions", workbenchRoutes(s)).route("/sessions", sessionBrowserRoutes(s)).route("/browser", browserRoutes(s)).route("/images", imageRoutes(s)).route("/agent-config", agentConfigRoutes(s)).route("/voice", voiceRoutes(s)).route("/", speechRoutes(s)).route("/sessions", sessionAgentConfigRoutes(s)).route("/sessions", sessionFileRoutes(s)).route("/utility", utilityRoutes(s)).route("/inbox", inboxRoutes(s)).route("/orchestration", orchestrationRoutes(s)).route("/conflicts", conflictRoutes(s)).route("/automations", automationRoutes(s)).route("/projects", projectAutomationRoutes(s)).route("/usage", usageRoutes(s)).route("/setup", setupRoutes(s)).route("/updates", updateRoutes(s)).route("/artifacts", artifactRoutes(s)).route("/secrets", secretRoutes(s)).route("/", fileRoutes(s)).get("/settings", (c) => c.json(s.settings.get())).patch("/settings", async (c) => {
23115
+ }).get("/snapshot", (c) => c.json(buildSnapshot(ctx))).get("/activity", (c) => c.json(buildActivity(s))).get("/sessions/:id/snapshot", (c) => c.json(buildSessionSnapshot(s, c.req.param("id")))).route("/projects", projectRoutes(s)).route("/projects", projectGithubRoutes(s)).route("/projects", projectFactoryRoutes(s)).route("/factory", factoryRoutes(s)).route("/prs", prRoutes(s)).route("/github", githubRoutes(s)).route("/sessions", sessionGithubRoutes(s)).route("/sessions", sessionRoutes(s)).route("/sessions", workbenchRoutes(s)).route("/sessions", sessionBrowserRoutes(s)).route("/browser", browserRoutes(s)).route("/images", imageRoutes(s)).route("/agent-config", agentConfigRoutes(s)).route("/voice", voiceRoutes(s)).route("/", speechRoutes(s)).route("/sessions", sessionAgentConfigRoutes(s)).route("/sessions", sessionFileRoutes(s)).route("/utility", utilityRoutes(s)).route("/inbox", inboxRoutes(s)).route("/orchestration", orchestrationRoutes(s)).route("/conflicts", conflictRoutes(s)).route("/automations", automationRoutes(s)).route("/projects", projectAutomationRoutes(s)).route("/usage", usageRoutes(s)).route("/setup", setupRoutes(s)).route("/updates", updateRoutes(s)).route("/artifacts", artifactRoutes(s)).route("/secrets", secretRoutes(s)).route("/", fileRoutes(s)).get("/settings", (c) => c.json(s.settings.get())).patch("/settings", async (c) => {
22952
23116
  const patch = await c.req.json().catch(() => void 0);
22953
23117
  if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw badRequest("Expected a JSON object");
22954
23118
  let settings;
@@ -22989,6 +23153,16 @@ function buildSnapshot(ctx) {
22989
23153
  runner: ctx.runnerStatus()
22990
23154
  };
22991
23155
  }
23156
+ /** Running and waiting sessions and open inbox items, for the desktop tray and badge (§17). */
23157
+ function buildActivity(s) {
23158
+ const active = s.db.select({ status: sessions.status }).from(sessions).where(inArray(sessions.status, [
23159
+ "starting",
23160
+ "running",
23161
+ "waiting"
23162
+ ])).all();
23163
+ const open = s.db.select({ status: inboxItems.status }).from(inboxItems).where(eq(inboxItems.status, "open")).all();
23164
+ return summarizeActivity(active, open);
23165
+ }
22992
23166
  /** A whole transcript in one response (prefer the paginated thread items route). */
22993
23167
  function buildSessionSnapshot(s, sessionId) {
22994
23168
  const seq = s.store.headSeq();
@@ -22999,7 +23173,7 @@ function buildSessionSnapshot(s, sessionId) {
22999
23173
  session: rowToSession(row),
23000
23174
  threads: s.db.select().from(threads).where(eq(threads.sessionId, sessionId)).all().map(rowToThread),
23001
23175
  turns: s.db.select().from(turns).where(eq(turns.sessionId, sessionId)).all().map(rowToTurn),
23002
- items: s.db.select().from(items).where(eq(items.sessionId, sessionId)).orderBy(asc(items.threadId), asc(items.position)).all().map((r) => previewRecord(rowToItemRecord(r)))
23176
+ items: s.db.select().from(items).where(eq(items.sessionId, sessionId)).orderBy(asc(items.threadId), asc(items.position)).all().map((r) => clientItemRecord(rowToItemRecord(r), s.artifacts))
23003
23177
  };
23004
23178
  }
23005
23179
  // ../../node_modules/.pnpm/@hono+node-server@2.1.6_hono@4.13.17/node_modules/@hono/node-server/dist/utils/stream.mjs
@@ -24229,7 +24403,7 @@ const githubTools = [
24229
24403
  * text form, prepended to every orchestrator and PM wake so they know the current state
24230
24404
  * without spending a tool call.
24231
24405
  */
24232
- const DAY_MS = 864e5;
24406
+ const DAY_MS$1 = 864e5;
24233
24407
  function buildProjectStatus(input) {
24234
24408
  const recent = input.outcomes.flatMap((n) => {
24235
24409
  if (n.type === "session.finished") return [{
@@ -24324,18 +24498,29 @@ function projectLines(s, now, indent = "") {
24324
24498
  }
24325
24499
  return lines;
24326
24500
  }
24501
+ /**
24502
+ * CodeDen's own tags (`<codeden_status>`, `<codeden_updates>`) inside free text (a purpose, a
24503
+ * quoted PM message) get a look-alike bracket, so only CodeDen's real tags open and close a
24504
+ * block and the UI can fold exactly the injected part away.
24505
+ */
24506
+ const OWN_TAG = /<(\/?codeden_(?:status|updates))/gi;
24507
+ const neutralizeTags = (line) => line.replace(OWN_TAG, "‹$1");
24508
+ /** A tagged block: the opening tag, the escaped lines, the closing tag. */
24509
+ function block(open, close, lines) {
24510
+ return [
24511
+ open,
24512
+ ...lines.map(neutralizeTags),
24513
+ close
24514
+ ].join("\n");
24515
+ }
24327
24516
  /** The PM's digest for its project. */
24328
24517
  function renderProjectDigest(s, now = Date.now()) {
24329
24518
  const at = new Date(now).toISOString();
24330
- return [
24331
- `<codeden_status project=${quote(s.project.name)} at="${at}">`,
24332
- ...projectLines(s, now),
24333
- "</codeden_status>"
24334
- ].join("\n");
24519
+ return block(`<codeden_status project=${quote(neutralizeTags(s.project.name))} at="${at}">`, "</codeden_status>", projectLines(s, now));
24335
24520
  }
24336
24521
  /** The orchestrator's digest: every project rolled up. */
24337
24522
  function renderOverviewDigest(o, now = Date.now()) {
24338
- const lines = [`<codeden_status scope="all projects" at="${new Date(now).toISOString()}">`];
24523
+ const lines = [];
24339
24524
  if (o.projects.length === 0) lines.push("No projects yet.");
24340
24525
  for (const p of o.projects) {
24341
24526
  lines.push(`## ${p.project.name} (${p.project.id})`);
@@ -24346,17 +24531,20 @@ function renderOverviewDigest(o, now = Date.now()) {
24346
24531
  lines.push("Informational PM messages:");
24347
24532
  for (const m of o.pmMessages) lines.push(`- ${m.projectId ?? "?"}: ${quote(m.text, 300)}`);
24348
24533
  }
24349
- lines.push("</codeden_status>");
24350
- return lines.join("\n");
24534
+ return block(`<codeden_status scope="all projects" at="${new Date(now).toISOString()}">`, "</codeden_status>", lines);
24351
24535
  }
24352
- /** "Updates since your last turn": one line per notice, for the model. */
24536
+ /**
24537
+ * "Updates since your last turn": one entry per notice, for the model, inside
24538
+ * `<codeden_updates>`. A notice can quote several paragraphs (a PM message), so the tags,
24539
+ * not a blank line, mark where the injected part ends and the sender's own text begins.
24540
+ */
24353
24541
  function renderNotices(list, projectName) {
24354
24542
  const lines = ["Updates since your last turn:"];
24355
24543
  for (const n of list) {
24356
24544
  const where = n.projectId && projectName ? ` [${projectName(n.projectId)}]` : "";
24357
24545
  lines.push(`- (${n.type})${where} ${n.title}${noticeDetail(n)}`);
24358
24546
  }
24359
- return lines.join("\n");
24547
+ return block("<codeden_updates>", "</codeden_updates>", lines);
24360
24548
  }
24361
24549
  function noticeDetail(n) {
24362
24550
  switch (n.type) {
@@ -24377,7 +24565,7 @@ function noticeDetail(n) {
24377
24565
  }
24378
24566
  /** The day window for recent outcomes, from `now`. */
24379
24567
  function dayAgo(now = Date.now()) {
24380
- return (/* @__PURE__ */ new Date(now - DAY_MS)).toISOString();
24568
+ return (/* @__PURE__ */ new Date(now - DAY_MS$1)).toISOString();
24381
24569
  }
24382
24570
  // ../server/src/mcp/tools/shared/helpers.ts
24383
24571
  /** A project by id, as a tool error when it does not exist or is archived. */
@@ -24950,7 +25138,7 @@ const messageSession = defineTool({
24950
25138
  attachments: []
24951
25139
  },
24952
25140
  whenBusy: whenBusy ?? "queue"
24953
- })).status}.`;
25141
+ }, "pm")).status}.`;
24954
25142
  }
24955
25143
  });
24956
25144
  /** `stop_session` (§8.4, always loaded): stop one of the project's agents. */
@@ -25139,9 +25327,9 @@ var ArtifactService = class {
25139
25327
  return this.dir;
25140
25328
  }
25141
25329
  get(id) {
25142
- const row = this.db.select().from(artifacts).where(eq(artifacts.id, id)).get();
25143
- if (!row) throw notFound(`No artifact ${id}`, "artifact_not_found");
25144
- return rowToArtifact(row);
25330
+ const artifact = this.find(id);
25331
+ if (!artifact) throw notFound(`No artifact ${id}`, "artifact_not_found");
25332
+ return artifact;
25145
25333
  }
25146
25334
  list(filter = {}) {
25147
25335
  const conditions = [];
@@ -25154,10 +25342,13 @@ var ArtifactService = class {
25154
25342
  return resolveInside(this.dir, artifact.path);
25155
25343
  }
25156
25344
  /**
25157
- * Store pasted (base64) images as artifacts and point the attachment at the file, so large
25158
- * payloads never land in the event log. The runner reads the file by path.
25345
+ * Store pasted (base64) images as artifacts and point the attachment at the artifact, so
25346
+ * large payloads never land in the event log and stored input (queued messages, pending
25347
+ * starts) never carries a filesystem path. `resolveAttachments` turns the reference into a
25348
+ * file path when the input is handed to the runner.
25159
25349
  */
25160
25350
  externalizeAttachments(input, owner) {
25351
+ if (!input.attachments.some((a) => a.type === "image" && a.source.type === "base64")) return input;
25161
25352
  const attachments = input.attachments.map((a) => {
25162
25353
  if (a.type !== "image" || a.source.type !== "base64") return a;
25163
25354
  const bytes = Buffer.from(a.source.data, "base64");
@@ -25169,6 +25360,29 @@ var ArtifactService = class {
25169
25360
  kind: "image",
25170
25361
  ...owner
25171
25362
  });
25363
+ return {
25364
+ ...a,
25365
+ source: {
25366
+ type: "artifact",
25367
+ artifactId: artifact.id
25368
+ }
25369
+ };
25370
+ });
25371
+ return {
25372
+ ...input,
25373
+ attachments
25374
+ };
25375
+ }
25376
+ /**
25377
+ * Point artifact image attachments at their file, for the runner: providers read images by
25378
+ * path. Only call this on input that goes to the runner, never on what the browser sees.
25379
+ */
25380
+ resolveAttachments(input) {
25381
+ if (!input.attachments.some((a) => a.type === "image" && a.source.type === "artifact")) return input;
25382
+ const attachments = input.attachments.map((a) => {
25383
+ if (a.type !== "image" || a.source.type !== "artifact") return a;
25384
+ const artifact = this.find(a.source.artifactId);
25385
+ if (!artifact) throw badRequest(`No artifact ${a.source.artifactId}`, "unknown_artifact");
25172
25386
  return {
25173
25387
  ...a,
25174
25388
  source: {
@@ -25182,7 +25396,27 @@ var ArtifactService = class {
25182
25396
  attachments
25183
25397
  };
25184
25398
  }
25399
+ /**
25400
+ * The artifact whose own file is `path`, or `undefined`. Providers echo the input they were
25401
+ * given, so a user message's image comes back as the resolved path (and older transcripts
25402
+ * stored it that way); this maps it back to the artifact the browser can load. Only an exact
25403
+ * match with an artifact's file inside the artifacts folder counts; nothing is read from disk.
25404
+ */
25405
+ artifactForPath(path) {
25406
+ const id = (path.split(/[\\/]/).pop() ?? "").replace(/\.[^.]*$/, "");
25407
+ if (!id) return void 0;
25408
+ const artifact = this.find(id);
25409
+ if (!artifact) return void 0;
25410
+ const expected = resolve(this.dir, artifact.path);
25411
+ return comparable(resolve(path)) === comparable(expected) ? artifact : void 0;
25412
+ }
25413
+ find(id) {
25414
+ const row = this.db.select().from(artifacts).where(eq(artifacts.id, id)).get();
25415
+ return row ? rowToArtifact(row) : void 0;
25416
+ }
25185
25417
  };
25418
+ const caseInsensitive = process.platform === "win32" || process.platform === "darwin";
25419
+ const comparable = (p) => caseInsensitive ? p.toLowerCase() : p;
25186
25420
  // ../server/src/mcp/tools/share-to-chat.ts
25187
25421
  const MEDIA_TYPES$1 = {
25188
25422
  ".png": "image/png",
@@ -42247,7 +42481,7 @@ var AutomationExecutor = class {
42247
42481
  const res = await this.deps.orchestration().sendToRole("pm", project.id, {
42248
42482
  text: `Automation "${automation.name}" (${TRIGGER_TEXT[triggeredBy]}):\n\n${text}`,
42249
42483
  attachments: []
42250
- });
42484
+ }, "queue", "automation");
42251
42485
  return this.finished(run, res.sessionId);
42252
42486
  }
42253
42487
  case "triage": {
@@ -42736,7 +42970,7 @@ var TriageService = class {
42736
42970
  return (await this.deps.orchestration().sendToRole("pm", project.id, {
42737
42971
  text,
42738
42972
  attachments: []
42739
- })).sessionId;
42973
+ }, "queue", "automation")).sessionId;
42740
42974
  }
42741
42975
  /** The PM's `report_triage`. */
42742
42976
  async report(project, report) {
@@ -43909,7 +44143,7 @@ function triageInstructions(items) {
43909
44143
  ].join("\n");
43910
44144
  }
43911
44145
  // ../server/src/services/factory/progress.ts
43912
- const BUSY$1 = /* @__PURE__ */ new Set([
44146
+ const BUSY$2 = /* @__PURE__ */ new Set([
43913
44147
  "created",
43914
44148
  "queued",
43915
44149
  "starting",
@@ -43923,7 +44157,7 @@ const BUSY$1 = /* @__PURE__ */ new Set([
43923
44157
  async function advanceBuilding(ctx, project, loop, item) {
43924
44158
  const s = item.builderSessionId ? ctx.repo.find(item.builderSessionId)?.session : void 0;
43925
44159
  if (!s) return failItem(ctx, loop, item, "The builder session is gone");
43926
- if (BUSY$1.has(s.status)) return loop;
44160
+ if (BUSY$2.has(s.status)) return loop;
43927
44161
  const fixAt = item.attempts > 0 ? item.nudges[`fix_${item.attempts}`] : void 0;
43928
44162
  if (fixAt && Date.parse(s.updatedAt) <= Date.parse(fixAt) && s.status === "idle") return loop;
43929
44163
  const pr = s.pr ?? ctx.github.prForSession(project.id, s.id, s.branch);
@@ -45151,7 +45385,7 @@ var FactorySessions = class {
45151
45385
  attachments: []
45152
45386
  },
45153
45387
  whenBusy: "queue"
45154
- });
45388
+ }, "codeden");
45155
45389
  }
45156
45390
  /** Stop every live factory session of a project (kill switch). Returns the stopped ids. */
45157
45391
  async stopAll(projectId) {
@@ -46441,7 +46675,7 @@ function isInside$1(root, path) {
46441
46675
  /** Read evidence sources, confined to the session's worktree, the temp folder and its artifacts. */
46442
46676
  function readEvidence(session, artifacts, sources) {
46443
46677
  if (sources.length > 10) throw badRequest(`Attach at most 10 files at once`);
46444
- const cwd = session.worktreePath ?? session.cwd;
46678
+ const cwd = sessionWorkdir(session);
46445
46679
  const roots = [cwd, tmpdir()].filter(existsSync).map((p) => realpathSync(p));
46446
46680
  return sources.map((source) => {
46447
46681
  let file;
@@ -49709,7 +49943,7 @@ function pullRequestBody(text, issue, session) {
49709
49943
  * §9). The PR is projected right away (`pr.opened` / `pr.updated`) and linked to the session.
49710
49944
  */
49711
49945
  async function createPullRequest(deps, session, repo, input) {
49712
- const cwd = session.worktreePath ?? session.cwd;
49946
+ const cwd = sessionWorkdir(session);
49713
49947
  const branch = session.branch;
49714
49948
  if (!branch) throw conflict("This session has no branch to open a pull request from", "no_branch");
49715
49949
  const base = input.base ?? session.baseBranch ?? repo.defaultBranch;
@@ -50659,7 +50893,7 @@ var ImageService = class {
50659
50893
  const eff = this.effective(session.projectId);
50660
50894
  const pick = await this.pick(eff.engine);
50661
50895
  if (!pick.engine) throw new ServiceError(409, "no_image_engine", pick.reason);
50662
- const cwd = realpathSync(session.worktreePath ?? session.cwd);
50896
+ const cwd = realpathSync(sessionWorkdir(session));
50663
50897
  const root = req.role === "agent" ? cwd : this.ensureDir(join(this.deps.generatedDir, session.id));
50664
50898
  let target;
50665
50899
  try {
@@ -51822,13 +52056,16 @@ var OrchestrationService = class {
51822
52056
  return input;
51823
52057
  }
51824
52058
  }
51825
- /** Send a message to the orchestrator or a project's PM, creating the session if needed. */
51826
- async sendToRole(role, projectId, input, whenBusy = "queue") {
52059
+ /**
52060
+ * Send a message to the orchestrator or a project's PM, creating the session if needed.
52061
+ * `from` labels the message in the queue tray when it has to wait for a running turn.
52062
+ */
52063
+ async sendToRole(role, projectId, input, whenBusy = "queue", from = "user") {
51827
52064
  const session = await this.roles.ensure(role, projectId);
51828
52065
  const res = await this.deps.sessions.send(session.id, {
51829
52066
  input,
51830
52067
  whenBusy
51831
- });
52068
+ }, from);
51832
52069
  return {
51833
52070
  sessionId: session.id,
51834
52071
  status: res.status
@@ -51840,7 +52077,7 @@ var OrchestrationService = class {
51840
52077
  return this.sendToRole("pm", projectId, {
51841
52078
  text: `Message from the orchestrator:\n${text}`,
51842
52079
  attachments: []
51843
- });
52080
+ }, "queue", "orchestrator");
51844
52081
  }
51845
52082
  /** `ask_pm`: ask a PM and wait (up to `timeoutMs`) for the answer. */
51846
52083
  async askPm(projectId, question, opts) {
@@ -51865,7 +52102,7 @@ var OrchestrationService = class {
51865
52102
  attachments: []
51866
52103
  },
51867
52104
  whenBusy: "queue"
51868
- });
52105
+ }, "orchestrator");
51869
52106
  return {
51870
52107
  ...await answer,
51871
52108
  pmSessionId: pm.id
@@ -51975,7 +52212,7 @@ var OrchestrationService = class {
51975
52212
  attachments: []
51976
52213
  },
51977
52214
  whenBusy: "reject"
51978
- });
52215
+ }, "codeden");
51979
52216
  return "sent";
51980
52217
  }
51981
52218
  projectName(id) {
@@ -52122,8 +52359,10 @@ var ProjectService = class {
52122
52359
  constructor(deps) {
52123
52360
  this.deps = deps;
52124
52361
  }
52362
+ /** Projects in the canonical order (§13): pinned first, then manual order, then name. */
52125
52363
  list(opts = {}) {
52126
- return this.deps.db.select().from(projects).orderBy(asc(projects.name)).all().map(rowToProject).filter((p) => opts.includeArchived || p.archivedAt === null);
52364
+ const all = this.deps.db.select().from(projects).all().map(rowToProject);
52365
+ return sortProjects(all.filter((p) => opts.includeArchived || p.archivedAt === null));
52127
52366
  }
52128
52367
  get(id) {
52129
52368
  const row = this.deps.db.select().from(projects).where(eq(projects.id, id)).get();
@@ -52181,6 +52420,7 @@ var ProjectService = class {
52181
52420
  if (patch.autonomy !== void 0) next.autonomy = patch.autonomy;
52182
52421
  if (patch.timezone !== void 0) next.timezone = patch.timezone;
52183
52422
  if (patch.defaultBranch !== void 0) next.defaultBranch = patch.defaultBranch;
52423
+ if (patch.pinned !== void 0) next.pinned = patch.pinned;
52184
52424
  this.deps.store.append([{
52185
52425
  type: "project.updated",
52186
52426
  sessionId: null,
@@ -52188,6 +52428,37 @@ var ProjectService = class {
52188
52428
  }]);
52189
52429
  return next;
52190
52430
  }
52431
+ /**
52432
+ * Set the manual order of the project list (§13). `projectIds` must name every non-archived
52433
+ * project exactly once. Pinned projects still list first; within each group the order given
52434
+ * here applies.
52435
+ */
52436
+ reorder(projectIds) {
52437
+ const active = this.list();
52438
+ const known = new Set(active.map((p) => p.id));
52439
+ const seen = /* @__PURE__ */ new Set();
52440
+ const duplicates = /* @__PURE__ */ new Set();
52441
+ for (const id of projectIds) {
52442
+ if (seen.has(id)) duplicates.add(id);
52443
+ seen.add(id);
52444
+ }
52445
+ const unknown = [...seen].filter((id) => !known.has(id));
52446
+ const missing = active.filter((p) => !seen.has(p.id)).map((p) => p.id);
52447
+ if (duplicates.size > 0 || unknown.length > 0 || missing.length > 0) {
52448
+ const problems = [
52449
+ duplicates.size > 0 && `listed more than once: ${[...duplicates].join(", ")}`,
52450
+ unknown.length > 0 && `not active projects: ${unknown.join(", ")}`,
52451
+ missing.length > 0 && `missing: ${missing.join(", ")}`
52452
+ ].filter(Boolean);
52453
+ throw badRequest(`The order must list every active project exactly once (${problems.join("; ")})`, "invalid_order");
52454
+ }
52455
+ this.deps.store.append([{
52456
+ type: "projects.reordered",
52457
+ sessionId: null,
52458
+ payload: { projectIds }
52459
+ }]);
52460
+ return this.list();
52461
+ }
52191
52462
  archive(id) {
52192
52463
  this.get(id);
52193
52464
  this.deps.store.append([{
@@ -52211,6 +52482,8 @@ var ProjectService = class {
52211
52482
  factoryLimits: FactoryLimits.parse({}),
52212
52483
  killSwitch: false,
52213
52484
  timezone: null,
52485
+ pinned: false,
52486
+ sortOrder: this.nextSortOrder(),
52214
52487
  createdAt: now,
52215
52488
  updatedAt: now,
52216
52489
  archivedAt: null
@@ -52224,6 +52497,11 @@ var ProjectService = class {
52224
52497
  kind: "local"
52225
52498
  };
52226
52499
  }
52500
+ /** New projects go to the end of the unpinned group, after every slot (archived included). */
52501
+ nextSortOrder() {
52502
+ const row = this.deps.db.select({ last: max(projects.sortOrder) }).from(projects).get();
52503
+ return typeof row?.last === "number" ? row.last + 1 : 0;
52504
+ }
52227
52505
  uniqueSlug(base) {
52228
52506
  const taken = new Set(this.list({ includeArchived: true }).map((p) => p.slug));
52229
52507
  if (!taken.has(base)) return base;
@@ -52265,7 +52543,8 @@ function redactUrls(text) {
52265
52543
  const SHARED = `## How CodeDen keeps you informed
52266
52544
 
52267
52545
  - Every message you receive starts with a \`<codeden_status>\` block: a fresh digest of running and queued work (work key, purpose, status, progress, age), the next scheduled automation runs, open inbox items and recent finishes and failures. Trust it; you do not need a tool call to learn the current state.
52268
- - Messages may also start with "Updates since your last turn": typed notices (session finished, needs input, failed, PR opened, checks failed, automation skipped, conflict). React to the ones that need action and ignore routine ones.
52546
+ - After it may come a \`<codeden_updates>\` block, "Updates since your last turn": typed notices (session finished, needs input, failed, PR opened, checks failed, automation skipped, conflict, PM messages). React to the ones that need action and ignore routine ones.
52547
+ - Everything after the last of these blocks is what the sender actually wrote.
52269
52548
  - You have no write access to project code. You act only through CodeDen tools on the \`codeden\` MCP server; reading files and read-only git commands are fine.
52270
52549
  - Never start work that duplicates running or queued work. Check the digest first; starting tools return \`conflict\` with the existing work instead of starting. Then choose one resolution and say why: \`wait\` (queue behind it), \`skip\`, \`join\` (send your request to the existing session as a follow-up) or \`force\` (only with a concrete reason, which is recorded).`;
52271
52550
  const ORCHESTRATOR_PROMPT = `# You are the CodeDen orchestrator
@@ -52395,11 +52674,12 @@ var SessionLauncher = class {
52395
52674
  */
52396
52675
  async resume(session, input) {
52397
52676
  await this.requireRunner();
52677
+ const current = await this.restoreWorktree(session);
52398
52678
  const result = await this.deps.runner.request({
52399
52679
  name: "session.start",
52400
- sessionId: session.id,
52401
- providerId: session.provider,
52402
- options: this.sessionOptions(session),
52680
+ sessionId: current.id,
52681
+ providerId: current.provider,
52682
+ options: this.sessionOptions(current),
52403
52683
  input
52404
52684
  }, START_TIMEOUT_MS);
52405
52685
  this.deps.repo.update(session.id, (s) => ({
@@ -52408,6 +52688,32 @@ var SessionLauncher = class {
52408
52688
  runnerId: this.deps.runner.runnerId()
52409
52689
  }));
52410
52690
  }
52691
+ /**
52692
+ * Recreate a worktree that cleanup removed (§9), at the same path from the session's branch,
52693
+ * running the project's setup again. A no-op for sessions that still have their worktree.
52694
+ */
52695
+ async restoreWorktree(session) {
52696
+ if (!session.worktreeRemovedAt || !session.worktreePath || !session.projectId) return session;
52697
+ const project = this.deps.projects.get(session.projectId);
52698
+ const result = await this.deps.runner.request({
52699
+ name: "workspace.prepare",
52700
+ sessionId: session.id,
52701
+ repoPath: project.path,
52702
+ strategy: "worktree",
52703
+ path: session.worktreePath,
52704
+ branch: session.branch ?? void 0,
52705
+ baseRef: session.baseBranch ?? project.defaultBranch ?? void 0,
52706
+ setupCommands: []
52707
+ }, PREPARE_TIMEOUT_MS);
52708
+ this.deps.log?.(`session ${session.id}: recreated worktree ${result.cwd}`);
52709
+ return this.deps.repo.update(session.id, (s) => ({
52710
+ ...s,
52711
+ cwd: result.cwd,
52712
+ worktreePath: result.cwd,
52713
+ branch: result.branch ?? s.branch,
52714
+ worktreeRemovedAt: null
52715
+ }));
52716
+ }
52411
52717
  /** Mark a session failed with an inbox item explaining why. */
52412
52718
  fail(sessionId, key, error) {
52413
52719
  const message = errorMessage(error);
@@ -52655,24 +52961,39 @@ var SessionLifecycle = class {
52655
52961
  }
52656
52962
  };
52657
52963
  // ../server/src/services/sessions/outbox.ts
52964
+ const toMessage = (r) => ({
52965
+ id: r.id,
52966
+ sessionId: r.sessionId,
52967
+ input: r.input,
52968
+ from: r.sender ?? "user",
52969
+ createdAt: r.createdAt
52970
+ });
52658
52971
  /**
52659
52972
  * Messages sent while a turn runs, delivered one at a time when the session goes idle.
52660
- * Stored through `message.queued` / `message.dequeued` events, so the queue survives server
52661
- * restarts and can be rebuilt from the log.
52973
+ * Stored through `message.queued` / `message.dequeued` / `message.edited` events, so the queue
52974
+ * survives server restarts and can be rebuilt from the log.
52975
+ *
52976
+ * Every method is synchronous. Delivery takes a message out with `remove(…, "sent")` before
52977
+ * it awaits anything, so an edit or delete either lands before delivery (and is what gets
52978
+ * sent) or finds the message gone; a half-edited message can never be sent.
52662
52979
  */
52663
52980
  var Outbox = class {
52664
52981
  db;
52665
52982
  store;
52983
+ /** Last `createdAt` handed out (ms), so timestamps strictly increase. */
52984
+ lastAt = 0;
52666
52985
  constructor(db, store) {
52667
52986
  this.db = db;
52668
52987
  this.store = store;
52669
52988
  }
52670
- push(sessionId, input) {
52989
+ push(sessionId, input, from = "user") {
52990
+ this.lastAt = Math.max(Date.now(), this.lastAt + 1);
52671
52991
  const message = {
52672
52992
  id: randomUUID(),
52673
52993
  sessionId,
52674
52994
  input,
52675
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
52995
+ from,
52996
+ createdAt: new Date(this.lastAt).toISOString()
52676
52997
  };
52677
52998
  this.store.append([{
52678
52999
  type: "message.queued",
@@ -52681,9 +53002,17 @@ var Outbox = class {
52681
53002
  }]);
52682
53003
  return message;
52683
53004
  }
53005
+ /** A session's queued messages, next to be delivered first. */
53006
+ list(sessionId) {
53007
+ return this.db.select().from(queuedMessages).where(eq(queuedMessages.sessionId, sessionId)).orderBy(asc(queuedMessages.createdAt), asc(sql`rowid`)).all().map(toMessage);
53008
+ }
52684
53009
  /** Oldest queued message of a session, if any. */
52685
53010
  peek(sessionId) {
52686
- return this.db.select().from(queuedMessages).where(eq(queuedMessages.sessionId, sessionId)).orderBy(asc(queuedMessages.createdAt)).limit(1).get() ?? void 0;
53011
+ return this.list(sessionId)[0];
53012
+ }
53013
+ get(sessionId, messageId) {
53014
+ const row = this.db.select().from(queuedMessages).where(and(eq(queuedMessages.sessionId, sessionId), eq(queuedMessages.id, messageId))).get();
53015
+ return row ? toMessage(row) : void 0;
52687
53016
  }
52688
53017
  count(sessionId) {
52689
53018
  return this.db.select({ id: queuedMessages.id }).from(queuedMessages).where(eq(queuedMessages.sessionId, sessionId)).all().length;
@@ -52692,6 +53021,26 @@ var Outbox = class {
52692
53021
  sessionsWithMessages() {
52693
53022
  return this.db.selectDistinct({ id: queuedMessages.sessionId }).from(queuedMessages).all().map((r) => r.id);
52694
53023
  }
53024
+ /** Replace a queued message's text; its attachments stay. */
53025
+ edit(message, text) {
53026
+ this.store.append([{
53027
+ type: "message.edited",
53028
+ sessionId: message.sessionId,
53029
+ payload: {
53030
+ sessionId: message.sessionId,
53031
+ messageId: message.id,
53032
+ text,
53033
+ at: (/* @__PURE__ */ new Date()).toISOString()
53034
+ }
53035
+ }]);
53036
+ return {
53037
+ ...message,
53038
+ input: {
53039
+ ...message.input,
53040
+ text
53041
+ }
53042
+ };
53043
+ }
52695
53044
  remove(message, reason) {
52696
53045
  this.store.append([{
52697
53046
  type: "message.dequeued",
@@ -52703,11 +53052,19 @@ var Outbox = class {
52703
53052
  }
52704
53053
  }]);
52705
53054
  }
52706
- /** Drop every queued message of a session (interrupt, stop). */
53055
+ /** Put a message taken out for delivery back in its place (the delivery failed). */
53056
+ restore(message) {
53057
+ this.store.append([{
53058
+ type: "message.queued",
53059
+ sessionId: message.sessionId,
53060
+ payload: { message }
53061
+ }]);
53062
+ }
53063
+ /** Drop every queued message of a session (interrupt, stop). Returns what was dropped. */
52707
53064
  clear(sessionId) {
52708
- const rows = this.db.select().from(queuedMessages).where(eq(queuedMessages.sessionId, sessionId)).all();
52709
- if (rows.length === 0) return;
52710
- this.store.append(rows.map((m) => ({
53065
+ const dropped = this.list(sessionId);
53066
+ if (dropped.length === 0) return [];
53067
+ this.store.append(dropped.map((m) => ({
52711
53068
  type: "message.dequeued",
52712
53069
  sessionId,
52713
53070
  payload: {
@@ -52716,6 +53073,11 @@ var Outbox = class {
52716
53073
  reason: "dropped"
52717
53074
  }
52718
53075
  })));
53076
+ return dropped;
53077
+ }
53078
+ /** Why a message is no longer queued, from the log; `undefined` if it never was. */
53079
+ dequeuedReason(sessionId, messageId) {
53080
+ return (this.db.select({ payload: events.payload }).from(events).where(and(eq(events.sessionId, sessionId), eq(events.type, "message.dequeued"), sql`json_extract(${events.payload}, '$.messageId') = ${messageId}`)).orderBy(desc(events.seq)).limit(1).get()?.payload)?.reason;
52719
53081
  }
52720
53082
  };
52721
53083
  // ../server/src/services/sessions/questions.ts
@@ -52815,6 +53177,71 @@ var QuestionBroker = class {
52815
53177
  function formatAnswers(item, answers) {
52816
53178
  return item.questions.map((q) => `Q: ${q.question}\nA: ${(answers[q.id] ?? []).join(", ") || "(no answer)"}`).join("\n\n");
52817
53179
  }
53180
+ // ../server/src/services/sessions/queue.ts
53181
+ const BUSY$1 = /* @__PURE__ */ new Set(["running", "waiting"]);
53182
+ /**
53183
+ * The queued-message tray (`/api/sessions/:id/queue`): list, edit, delete and send-now.
53184
+ * Messages are taken from the `Outbox` synchronously, so a request racing delivery either
53185
+ * wins or answers 409 `already_sent`; it never acts on a message that is being delivered.
53186
+ */
53187
+ var QueueService = class {
53188
+ deps;
53189
+ constructor(deps) {
53190
+ this.deps = deps;
53191
+ }
53192
+ /** A session's queued messages as the browser sees them, next first. */
53193
+ list(sessionId) {
53194
+ this.deps.repo.get(sessionId);
53195
+ return this.deps.outbox.list(sessionId).map((m) => this.present(m));
53196
+ }
53197
+ edit(sessionId, messageId, text) {
53198
+ const message = this.require(sessionId, messageId);
53199
+ if (!text.trim() && message.input.attachments.length === 0) throw badRequest("A queued message needs text or an attachment", "empty_message");
53200
+ return this.present(this.deps.outbox.edit(message, text));
53201
+ }
53202
+ delete(sessionId, messageId) {
53203
+ this.deps.outbox.remove(this.require(sessionId, messageId), "deleted");
53204
+ }
53205
+ /**
53206
+ * Steer the running turn with a queued message, ahead of its place. Only a running turn
53207
+ * can take it (409 `no_turn` otherwise; the queue sends it next, in order), and only on a
53208
+ * provider that can steer. If the steer fails the message goes back in its place.
53209
+ */
53210
+ async sendNow(sessionId, messageId) {
53211
+ const message = this.require(sessionId, messageId);
53212
+ const session = this.deps.repo.get(sessionId);
53213
+ if (!BUSY$1.has(session.status)) {
53214
+ this.deps.sessions.flushOutbox(sessionId).catch(() => void 0);
53215
+ throw conflict("No turn is running: the queue sends this message next", "no_turn");
53216
+ }
53217
+ if (!this.deps.providers().find((p) => p.id === session.provider)?.capabilities.steer) throw conflict("This provider cannot steer a running turn", "unsupported");
53218
+ this.deps.outbox.remove(message, "sent");
53219
+ try {
53220
+ await this.deps.sessions.steer(sessionId, message.input);
53221
+ return { status: "steered" };
53222
+ } catch (error) {
53223
+ this.deps.outbox.restore(message);
53224
+ this.deps.sessions.flushOutbox(sessionId).catch(() => void 0);
53225
+ throw error;
53226
+ }
53227
+ }
53228
+ /** The queued message, or 409 `already_sent` / 404 when it is no longer queued. */
53229
+ require(sessionId, messageId) {
53230
+ this.deps.repo.get(sessionId);
53231
+ const message = this.deps.outbox.get(sessionId, messageId);
53232
+ if (message) return message;
53233
+ const reason = this.deps.outbox.dequeuedReason(sessionId, messageId);
53234
+ if (reason === "sent") throw conflict("This message was already sent", "already_sent");
53235
+ if (reason) throw conflict("This message is no longer queued", "no_longer_queued");
53236
+ throw notFound("No such queued message", "queued_message_not_found");
53237
+ }
53238
+ present(message) {
53239
+ return {
53240
+ ...message,
53241
+ input: clientInput(message.input, this.deps.artifacts)
53242
+ };
53243
+ }
53244
+ };
52818
53245
  // ../server/src/services/sessions/recovery.ts
52819
53246
  /** The message sent to an agent whose turn was cut off by a restart (§4.2). */
52820
53247
  const RESTART_CONTINUE_MESSAGE = "You were interrupted by a CodeDen restart; continue where you left off.";
@@ -53023,6 +53450,8 @@ function projectDefaultProvider(project) {
53023
53450
  return project.defaultProvider ?? readProjectConfig(project.path)?.defaultProvider ?? null;
53024
53451
  }
53025
53452
  // ../server/src/services/sessions/reconfigure.ts
53453
+ /** How long `PATCH` waits for the runner to take an autonomy change. */
53454
+ const AUTONOMY_TIMEOUT_MS = 1e4;
53026
53455
  function fallbackMeta$1() {
53027
53456
  return {
53028
53457
  workspaceMode: "worktree",
@@ -53031,12 +53460,32 @@ function fallbackMeta$1() {
53031
53460
  queuedBehind: []
53032
53461
  };
53033
53462
  }
53463
+ /** Merge `change` into the pending config (keys set to undefined are removed). */
53464
+ function setPending(repo, id, change) {
53465
+ repo.setStartMeta(id, (m) => {
53466
+ const { pendingConfig, ...meta } = m ?? fallbackMeta$1();
53467
+ const next = {
53468
+ ...pendingConfig,
53469
+ ...change
53470
+ };
53471
+ for (const key of Object.keys(next)) if (next[key] === void 0) delete next[key];
53472
+ return Object.keys(next).length > 0 ? {
53473
+ ...meta,
53474
+ pendingConfig: next
53475
+ } : meta;
53476
+ });
53477
+ }
53034
53478
  /**
53035
- * `PATCH /api/sessions/:id`: update model, effort or title. Model and effort changes are
53036
- * remembered as pending and applied right before the next turn (`applyPendingConfig`), so a
53037
- * running turn keeps its settings.
53479
+ * `PATCH /api/sessions/:id`: update model, effort, autonomy or title. Every change is saved
53480
+ * on the session (`session.updated`), so it survives a restart and later starts and resumes
53481
+ * use it.
53482
+ * - Model and effort are remembered as pending and applied right before the next turn
53483
+ * (`applyPendingConfig`), so a running turn keeps its settings.
53484
+ * - Autonomy is pushed to the live provider session at once: from the next tool call where
53485
+ * the provider supports it, otherwise from the next turn. If the runner cannot take it now,
53486
+ * it is retried before the next turn.
53038
53487
  */
53039
- function updateSession(repo, id, req) {
53488
+ async function updateSession(repo, runner, id, req) {
53040
53489
  const change = {};
53041
53490
  if (req.model !== void 0) change.model = req.model;
53042
53491
  if (req.effort !== void 0) change.effort = req.effort;
@@ -53044,24 +53493,34 @@ function updateSession(repo, id, req) {
53044
53493
  ...s,
53045
53494
  ...change.model !== void 0 ? { model: change.model } : {},
53046
53495
  ...change.effort !== void 0 ? { effort: change.effort } : {},
53496
+ ...req.autonomy !== void 0 ? { autonomy: req.autonomy } : {},
53047
53497
  ...req.title !== void 0 ? { title: req.title } : {}
53048
53498
  }));
53049
- if (Object.keys(change).length > 0) repo.setStartMeta(id, (m) => {
53050
- const meta = m ?? fallbackMeta$1();
53051
- return {
53052
- ...meta,
53053
- pendingConfig: {
53054
- ...meta.pendingConfig,
53055
- ...change
53056
- }
53057
- };
53058
- });
53499
+ if (Object.keys(change).length > 0) setPending(repo, id, change);
53500
+ if (req.autonomy !== void 0) await applyAutonomy(repo, runner, session);
53059
53501
  return session;
53060
53502
  }
53061
53503
  /**
53062
- * Apply a pending model/effort change on the runner before the next turn. When the runner no
53063
- * longer hosts the session, the resume that follows opens it with the new settings anyway.
53064
- * Returns a warning when the provider cannot change them live (they apply on the next resume).
53504
+ * Tell the runner about the session's autonomy. When the runner does not host the session,
53505
+ * or cannot be reached, the level stays pending; a later start or resume reads it from the
53506
+ * session anyway.
53507
+ */
53508
+ async function applyAutonomy(repo, runner, session) {
53509
+ try {
53510
+ await runner.request({
53511
+ name: "session.configure",
53512
+ sessionId: session.id,
53513
+ autonomy: session.autonomy
53514
+ }, AUTONOMY_TIMEOUT_MS);
53515
+ setPending(repo, session.id, { autonomy: void 0 });
53516
+ } catch {
53517
+ setPending(repo, session.id, { autonomy: session.autonomy });
53518
+ }
53519
+ }
53520
+ /**
53521
+ * Apply pending settings on the runner before the next turn. When the runner no longer hosts
53522
+ * the session, the resume that follows opens it with the new settings anyway. Returns a
53523
+ * warning when the provider cannot change them live (they apply on the next resume).
53065
53524
  */
53066
53525
  async function applyPendingConfig(repo, runner, session) {
53067
53526
  const pending = (repo.find(session.id)?.startMeta)?.pendingConfig;
@@ -53084,7 +53543,7 @@ async function applyPendingConfig(repo, runner, session) {
53084
53543
  clear();
53085
53544
  return;
53086
53545
  }
53087
- return `Model/effort change not applied live (${code ?? "error"}); it applies when the session is resumed.`;
53546
+ return `Session settings not applied live (${code ?? "error"}); they apply when the session is resumed.`;
53088
53547
  }
53089
53548
  }
53090
53549
  // ../server/src/services/sessions/session-service.ts
@@ -53113,9 +53572,9 @@ var SessionService = class {
53113
53572
  setInputDecorator(fn) {
53114
53573
  this.inputDecorator = fn;
53115
53574
  }
53116
- /** Model, effort (applied on the next turn) or title. */
53575
+ /** Model and effort (applied on the next turn), autonomy (pushed to the runner) or title. */
53117
53576
  update(id, req) {
53118
- return updateSession(this.deps.repo, id, req);
53577
+ return updateSession(this.deps.repo, this.deps.runner, id, req);
53119
53578
  }
53120
53579
  detail(id) {
53121
53580
  const found = this.deps.repo.find(id);
@@ -53163,7 +53622,7 @@ var SessionService = class {
53163
53622
  if (req.input) await this.send(target.id, {
53164
53623
  input: req.input,
53165
53624
  whenBusy: "queue"
53166
- });
53625
+ }, req.startedBy.kind);
53167
53626
  return {
53168
53627
  status: "joined",
53169
53628
  sessionId: target.id
@@ -53220,16 +53679,17 @@ var SessionService = class {
53220
53679
  } : s);
53221
53680
  this.launchInBackground(session.id, pending.input, pending.workspace);
53222
53681
  }
53223
- async send(id, req) {
53682
+ /** Send now, steer or queue (`whenBusy`); `from` labels the message if it is queued. */
53683
+ async send(id, req, from = "user") {
53224
53684
  const session = this.deps.repo.get(id);
53225
- if (NOT_STARTED.has(session.status)) return this.enqueue(id, req.input);
53685
+ if (NOT_STARTED.has(session.status)) return this.enqueue(id, req.input, from);
53226
53686
  if (BUSY.has(session.status)) {
53227
53687
  if (req.whenBusy === "reject") throw conflict("A turn is running", "turn_running");
53228
53688
  if (req.whenBusy === "steer") {
53229
53689
  await this.steer(id, req.input);
53230
53690
  return { status: "steered" };
53231
53691
  }
53232
- return this.enqueue(id, req.input);
53692
+ return this.enqueue(id, req.input, from);
53233
53693
  }
53234
53694
  await this.sendNow(session, req.input);
53235
53695
  return { status: "sent" };
@@ -53238,7 +53698,7 @@ var SessionService = class {
53238
53698
  const session = this.deps.repo.get(id);
53239
53699
  if (!BUSY.has(session.status)) throw conflict("No turn is running", "no_turn");
53240
53700
  if (!this.provider(session.provider)?.capabilities.steer) throw conflict("This provider cannot steer a running turn", "unsupported");
53241
- const input = this.externalize(session, raw);
53701
+ const input = this.forRunner(session, raw);
53242
53702
  await this.deps.runner.request({
53243
53703
  name: "session.steer",
53244
53704
  sessionId: id,
@@ -53324,15 +53784,20 @@ var SessionService = class {
53324
53784
  return this.deps.outbox.count(id) > 0;
53325
53785
  }
53326
53786
  async sendNow(session, raw) {
53327
- const input = this.externalize(session, this.inputDecorator?.(session, raw) ?? raw);
53787
+ const input = this.forRunner(session, this.inputDecorator?.(session, raw) ?? raw);
53328
53788
  await applyPendingConfig(this.deps.repo, this.deps.runner, session);
53329
53789
  const previous = session.status;
53330
53790
  this.deps.repo.update(session.id, (s) => ({
53331
53791
  ...s,
53332
53792
  status: "running",
53333
- endedAt: null
53793
+ endedAt: null,
53794
+ archivedAt: null
53334
53795
  }));
53335
53796
  try {
53797
+ if (session.worktreeRemovedAt) {
53798
+ await this.deps.launcher.resume(this.deps.repo.get(session.id), input);
53799
+ return;
53800
+ }
53336
53801
  try {
53337
53802
  await this.deps.runner.request({
53338
53803
  name: "session.send",
@@ -53351,20 +53816,25 @@ var SessionService = class {
53351
53816
  throw toServiceError$1(error);
53352
53817
  }
53353
53818
  }
53354
- enqueue(id, input) {
53819
+ enqueue(id, input, from) {
53355
53820
  const session = this.deps.repo.get(id);
53356
- this.deps.outbox.push(id, this.externalize(session, input));
53821
+ this.deps.outbox.push(id, this.externalize(session, input), from);
53357
53822
  return { status: "queued" };
53358
53823
  }
53824
+ /** Pasted images saved as artifacts: the form CodeDen stores (queued messages). */
53359
53825
  externalize(session, input) {
53360
53826
  return this.deps.artifacts.externalizeAttachments(input, {
53361
53827
  sessionId: session.id,
53362
53828
  projectId: session.projectId
53363
53829
  });
53364
53830
  }
53831
+ /** Input handed to the runner: artifacts resolved to the file paths providers read. */
53832
+ forRunner(session, input) {
53833
+ return this.deps.artifacts.resolveAttachments(this.externalize(session, input));
53834
+ }
53365
53835
  launchInBackground(id, input, workspace) {
53366
53836
  const session = this.deps.repo.get(id);
53367
- const prepared = input ? this.externalize(session, input) : void 0;
53837
+ const prepared = input ? this.forRunner(session, input) : void 0;
53368
53838
  const promise = this.deps.launcher.launch(id, {
53369
53839
  input: prepared,
53370
53840
  workspace
@@ -53432,6 +53902,176 @@ function fallbackMeta() {
53432
53902
  queuedBehind: []
53433
53903
  };
53434
53904
  }
53905
+ // ../server/src/services/sessions/worktree-cleanup.ts
53906
+ const DAY_MS = 864e5;
53907
+ const REMOVE_TIMEOUT_MS = 12e4;
53908
+ /** Sessions that may be using their worktree right now; never cleaned up. */
53909
+ const IN_USE = /* @__PURE__ */ new Set([
53910
+ "created",
53911
+ "queued",
53912
+ "starting",
53913
+ "running",
53914
+ "waiting"
53915
+ ]);
53916
+ const outcome = (status, message = null) => ({
53917
+ status,
53918
+ message
53919
+ });
53920
+ /**
53921
+ * Whether the sweep should remove a session's worktree now: an agent session that owns a
53922
+ * worktree it is not using, and is either archived or has been inactive for `afterDays`
53923
+ * without an open pull request. Archived sessions are retried even with cleanup off.
53924
+ */
53925
+ function cleanupDue(session, now, settings) {
53926
+ if (session.role !== "agent" || !session.projectId) return false;
53927
+ if (!session.worktreePath || session.worktreeRemovedAt) return false;
53928
+ if (IN_USE.has(session.status)) return false;
53929
+ if (session.archivedAt) return true;
53930
+ if (!settings.cleanup) return false;
53931
+ if (session.pr && (session.pr.state === "open" || session.pr.state === "draft")) return false;
53932
+ return now.getTime() - Date.parse(session.updatedAt) >= settings.afterDays * DAY_MS;
53933
+ }
53934
+ /**
53935
+ * Worktree cleanup (§9). Archiving a session removes its worktree right away; an hourly sweep
53936
+ * removes worktrees of sessions inactive for the configured number of days; archiving a project
53937
+ * cleans up its sessions. Everything goes through the runner's workspace commands, so a remote
53938
+ * runner cleans up its own storage. Uncommitted changes always keep a worktree unless forced.
53939
+ * The branch and checkpoints stay, and the next message recreates the worktree.
53940
+ */
53941
+ var WorktreeCleanup = class {
53942
+ deps;
53943
+ timer;
53944
+ unsubscribe;
53945
+ sweeping = false;
53946
+ /** Sessions the sweep could not clean (dirty, locked), keyed to the `updatedAt` it saw. */
53947
+ kept = /* @__PURE__ */ new Map();
53948
+ constructor(deps) {
53949
+ this.deps = deps;
53950
+ }
53951
+ start() {
53952
+ this.unsubscribe = this.deps.store.subscribe((events) => {
53953
+ setImmediate(() => this.onEvents(events));
53954
+ });
53955
+ const interval = this.deps.intervalMs ?? 36e5;
53956
+ const first = setTimeout(() => {
53957
+ this.sweep();
53958
+ this.timer = setInterval(() => void this.sweep(), interval);
53959
+ this.timer.unref?.();
53960
+ }, this.deps.initialDelayMs ?? 6e4);
53961
+ first.unref?.();
53962
+ this.timer = first;
53963
+ }
53964
+ stop() {
53965
+ this.unsubscribe?.();
53966
+ clearTimeout(this.timer);
53967
+ clearInterval(this.timer);
53968
+ }
53969
+ /** Stop the session if needed, hide it from the sidebar and remove its worktree. */
53970
+ async archive(id, opts = {}) {
53971
+ const current = this.deps.repo.get(id);
53972
+ if (current.role !== "agent") throw badRequest("Only agent sessions can be archived", "not_archivable");
53973
+ if (IN_USE.has(current.status) || current.status === "idle") await this.deps.stopSession(id);
53974
+ this.deps.repo.update(id, (s) => s.archivedAt ? s : {
53975
+ ...s,
53976
+ archivedAt: this.now().toISOString()
53977
+ });
53978
+ const worktree = await this.removeWorktree(id, { force: opts.force === true });
53979
+ return {
53980
+ session: this.deps.repo.get(id),
53981
+ worktree
53982
+ };
53983
+ }
53984
+ /** Show an archived session again. Its worktree comes back with the next message. */
53985
+ unarchive(id) {
53986
+ return this.deps.repo.update(id, (s) => s.archivedAt ? {
53987
+ ...s,
53988
+ archivedAt: null
53989
+ } : s);
53990
+ }
53991
+ /**
53992
+ * Remove a session's worktree through the runner. Refuses while the session is in use, and
53993
+ * keeps a worktree with uncommitted changes unless `force`.
53994
+ */
53995
+ async removeWorktree(id, opts) {
53996
+ const session = this.deps.repo.get(id);
53997
+ if (!session.worktreePath || session.worktreeRemovedAt || !session.projectId) return outcome("none");
53998
+ if (IN_USE.has(session.status)) return outcome("failed", "The session is still working; stop it first");
53999
+ let repoPath;
54000
+ try {
54001
+ repoPath = this.deps.projects.get(session.projectId).path;
54002
+ } catch {
54003
+ return outcome("none");
54004
+ }
54005
+ if (!this.deps.runner.isConnected()) return outcome("failed", "The runner is not connected; cleanup will retry later");
54006
+ await this.deps.runner.request({
54007
+ name: "session.stop",
54008
+ sessionId: id
54009
+ }, 15e3).catch(() => void 0);
54010
+ try {
54011
+ await this.deps.runner.request({
54012
+ name: "workspace.remove",
54013
+ repoPath,
54014
+ path: session.worktreePath,
54015
+ force: opts.force
54016
+ }, REMOVE_TIMEOUT_MS);
54017
+ } catch (error) {
54018
+ const message = errorMessage(error);
54019
+ if (runnerErrorCode(error) === "worktree_dirty" || /modified or untracked/.test(message)) return outcome("kept_changes", "The worktree has uncommitted changes, so it was kept");
54020
+ return outcome("failed", message);
54021
+ }
54022
+ this.deps.repo.update(id, (s) => ({
54023
+ ...s,
54024
+ worktreeRemovedAt: this.now().toISOString()
54025
+ }));
54026
+ this.kept.delete(id);
54027
+ this.deps.log?.(`worktree cleanup: removed ${session.worktreePath}`);
54028
+ return outcome("removed");
54029
+ }
54030
+ /** Remove every worktree that is due (see `cleanupDue`). Returns the cleaned session ids. */
54031
+ async sweep() {
54032
+ if (this.sweeping || !this.deps.runner.isConnected()) return [];
54033
+ this.sweeping = true;
54034
+ const removed = [];
54035
+ try {
54036
+ const now = this.now();
54037
+ const settings = this.deps.settings().worktrees;
54038
+ for (const session of this.deps.repo.list({ role: "agent" })) {
54039
+ if (!cleanupDue(session, now, settings)) continue;
54040
+ if (this.kept.get(session.id) === session.updatedAt) continue;
54041
+ const result = await this.removeWorktree(session.id, { force: false });
54042
+ if (result.status === "removed") removed.push(session.id);
54043
+ else if (result.status !== "none") this.kept.set(session.id, this.deps.repo.get(session.id).updatedAt);
54044
+ }
54045
+ } catch (error) {
54046
+ this.deps.log?.(`worktree cleanup failed: ${errorMessage(error)}`);
54047
+ } finally {
54048
+ this.sweeping = false;
54049
+ }
54050
+ return removed;
54051
+ }
54052
+ /** Clean up the worktrees of an archived project's sessions that are not in use. */
54053
+ async cleanProject(projectId) {
54054
+ const removed = [];
54055
+ for (const session of this.deps.repo.list({
54056
+ projectId,
54057
+ role: "agent"
54058
+ })) {
54059
+ if (!session.worktreePath || session.worktreeRemovedAt || IN_USE.has(session.status)) continue;
54060
+ if ((await this.removeWorktree(session.id, { force: false })).status === "removed") removed.push(session.id);
54061
+ }
54062
+ return removed;
54063
+ }
54064
+ onEvents(events) {
54065
+ for (const e of events) {
54066
+ if (e.type !== "project.archived") continue;
54067
+ const projectId = e.payload.projectId;
54068
+ if (typeof projectId === "string") this.cleanProject(projectId).catch((error) => this.deps.log?.(`worktree cleanup for project ${projectId}: ${errorMessage(error)}`));
54069
+ }
54070
+ }
54071
+ now() {
54072
+ return this.deps.now?.() ?? /* @__PURE__ */ new Date();
54073
+ }
54074
+ };
53435
54075
  // ../server/src/services/setup/claude-sdk.ts
53436
54076
  /**
53437
54077
  * Claude Code support (§14): reports and installs the Claude Agent SDK into `<home>/deps`.
@@ -53907,6 +54547,11 @@ async function checkNode({ nodeVersion, platform }) {
53907
54547
  message: `Node.js 24+ is required`,
53908
54548
  fix: installFix("node", platform)
53909
54549
  };
54550
+ if (isElectronRuntime()) return {
54551
+ ...check,
54552
+ state: "ok",
54553
+ message: `Node.js ${check.version} (built into the app)`
54554
+ };
53910
54555
  return {
53911
54556
  ...check,
53912
54557
  state: "ok",
@@ -53998,6 +54643,9 @@ var SetupService = class {
53998
54643
  warnings
53999
54644
  };
54000
54645
  this.cached = status;
54646
+ try {
54647
+ this.deps.onChecked?.(status);
54648
+ } catch {}
54001
54649
  return status;
54002
54650
  }
54003
54651
  regressed(previous, check) {
@@ -54119,6 +54767,178 @@ function changelogSection(markdown, version) {
54119
54767
  const end = rest.findIndex((line) => /^##\s/.test(line));
54120
54768
  return (end === -1 ? rest : rest.slice(0, end)).join("\n").trim() || null;
54121
54769
  }
54770
+ // ../server/src/services/updates/desktop.ts
54771
+ const SIX_HOURS$1 = 216e5;
54772
+ const DESKTOP_RELEASES_URL = "https://github.com/leonvanzyl/codeden/releases/latest";
54773
+ /**
54774
+ * Update checks for the desktop app (§17). Same contract and policy as the npm self-update
54775
+ * (`UpdateService`): check on start and every 6 hours unless the mode is `off`, offer the
54776
+ * update in `notify` mode, download it in the background in `auto` mode (it installs when
54777
+ * the app quits). The work itself (GitHub Releases, download, installer) happens in the
54778
+ * desktop main process; this class drives it and maps its state onto `UpdateStatus`, so the
54779
+ * existing update banner and About page work unchanged.
54780
+ */
54781
+ var DesktopUpdateService = class {
54782
+ deps;
54783
+ timer;
54784
+ restartWhenDownloaded = false;
54785
+ /** The last command that failed was a download or install (an "update failed", not a check). */
54786
+ installFailed = false;
54787
+ listeners = /* @__PURE__ */ new Set();
54788
+ notesCache = /* @__PURE__ */ new Map();
54789
+ stopDesktopEvents;
54790
+ constructor(deps) {
54791
+ this.deps = deps;
54792
+ this.stopDesktopEvents = deps.desktop.onChange((state) => this.onDesktopState(state));
54793
+ }
54794
+ get current() {
54795
+ return this.deps.current ?? CODEDEN_VERSION;
54796
+ }
54797
+ get enabled() {
54798
+ return this.deps.mode() !== "off" && !this.deps.checksDisabled;
54799
+ }
54800
+ start() {
54801
+ this.checkAndMaybeDownload();
54802
+ this.timer = setInterval(() => void this.checkAndMaybeDownload(), this.deps.intervalMs ?? SIX_HOURS$1);
54803
+ this.timer.unref();
54804
+ }
54805
+ stop() {
54806
+ clearInterval(this.timer);
54807
+ this.timer = void 0;
54808
+ this.stopDesktopEvents();
54809
+ }
54810
+ onChange(listener) {
54811
+ this.listeners.add(listener);
54812
+ return () => this.listeners.delete(listener);
54813
+ }
54814
+ async status(opts = {}) {
54815
+ if (this.enabled) {
54816
+ const { checkedAt, phase } = this.deps.desktop.state();
54817
+ const last = checkedAt ? Date.parse(checkedAt) : null;
54818
+ const stale = last === null || this.now() - last > SIX_HOURS$1;
54819
+ if ((opts.refresh || stale) && isQuiet(phase)) await this.check();
54820
+ }
54821
+ return this.snapshot();
54822
+ }
54823
+ snapshot() {
54824
+ const d = this.deps.desktop.state();
54825
+ const enabled = this.enabled;
54826
+ const latestIsNewer = d.latest !== null && isNewerVersion(d.latest, this.current);
54827
+ let state = "idle";
54828
+ let progress = null;
54829
+ if (d.phase === "downloading") {
54830
+ state = "installing";
54831
+ progress = d.percent === null ? "Downloading" : `Downloading ${Math.floor(d.percent)}%`;
54832
+ } else if (d.phase === "downloaded") state = "installed";
54833
+ else if (d.phase === "installing") state = "restarting";
54834
+ else if (d.phase === "error" && this.installFailed) state = "failed";
54835
+ const checkError = d.phase === "error" && !this.installFailed ? d.error : null;
54836
+ return {
54837
+ current: this.current,
54838
+ latest: d.latest,
54839
+ updateAvailable: enabled && latestIsNewer && d.unsupported === null,
54840
+ enabled,
54841
+ mode: this.deps.mode(),
54842
+ checkedAt: d.checkedAt,
54843
+ error: enabled ? d.unsupported ?? checkError : null,
54844
+ command: DESKTOP_RELEASES_URL,
54845
+ source: "desktop",
54846
+ state,
54847
+ target: state === "idle" ? null : d.latest,
54848
+ progress,
54849
+ installError: state === "failed" ? d.error ?? "The update could not be downloaded" : null,
54850
+ canRestart: d.unsupported === null,
54851
+ restartCommand: "Quit CodeDen from the tray and open it again",
54852
+ installed: [],
54853
+ rollbackTo: null,
54854
+ canRollback: false,
54855
+ warning: null
54856
+ };
54857
+ }
54858
+ /**
54859
+ * Download the latest release and, unless `restart` is false, install it and restart the
54860
+ * app once downloaded. Returns at once; progress follows through `onChange`.
54861
+ */
54862
+ install(opts = {}) {
54863
+ const d = this.deps.desktop.state();
54864
+ if (d.unsupported) throw conflict(d.unsupported, "updates_unsupported");
54865
+ if (opts.version && d.latest && opts.version !== d.latest) throw conflict(`The desktop app installs the latest release (${d.latest}); download older versions from GitHub.`, "version_unavailable");
54866
+ if (d.phase === "downloading" || d.phase === "installing") return this.snapshot();
54867
+ const restart = opts.restart !== false;
54868
+ if (d.phase === "downloaded") {
54869
+ if (restart) this.runCommand("install");
54870
+ return this.snapshot();
54871
+ }
54872
+ this.restartWhenDownloaded = restart;
54873
+ this.runCommand("download");
54874
+ return this.snapshot();
54875
+ }
54876
+ /** Install the downloaded update and restart the app. */
54877
+ restartNow() {
54878
+ if (this.deps.desktop.state().phase !== "downloaded") throw conflict("No downloaded update is waiting for a restart", "nothing_to_restart");
54879
+ this.runCommand("install");
54880
+ return this.snapshot();
54881
+ }
54882
+ rollback() {
54883
+ throw conflict("The desktop app can't roll back by itself; install an older release from GitHub.", "nothing_to_roll_back");
54884
+ }
54885
+ notes(version) {
54886
+ let cached = this.notesCache.get(version);
54887
+ if (!cached) {
54888
+ cached = fetchReleaseNotes(version, this.deps.notes, this.deps.fetch);
54889
+ cached.catch(() => this.notesCache.delete(version));
54890
+ this.notesCache.set(version, cached);
54891
+ }
54892
+ return cached;
54893
+ }
54894
+ async checkAndMaybeDownload() {
54895
+ if (!this.enabled || !isQuiet(this.deps.desktop.state().phase)) return;
54896
+ await this.check();
54897
+ const d = this.deps.desktop.state();
54898
+ if (this.deps.mode() === "auto" && d.phase === "available" && d.unsupported === null) {
54899
+ this.deps.log?.(`updates: downloading ${d.latest} in the background (auto mode)`);
54900
+ this.restartWhenDownloaded = false;
54901
+ this.runCommand("download");
54902
+ }
54903
+ }
54904
+ async check() {
54905
+ try {
54906
+ await this.deps.desktop.run("check");
54907
+ this.installFailed = false;
54908
+ } catch (error) {
54909
+ this.deps.log?.(`updates: check failed: ${errorMessage(error)}`);
54910
+ }
54911
+ this.emit();
54912
+ }
54913
+ runCommand(command) {
54914
+ this.installFailed = false;
54915
+ this.deps.desktop.run(command).catch((error) => {
54916
+ this.installFailed = true;
54917
+ this.restartWhenDownloaded = false;
54918
+ this.deps.log?.(`updates: ${command} failed: ${errorMessage(error)}`);
54919
+ this.emit();
54920
+ });
54921
+ }
54922
+ onDesktopState(state) {
54923
+ if (state.phase === "downloaded" && this.restartWhenDownloaded) {
54924
+ this.restartWhenDownloaded = false;
54925
+ this.runCommand("install");
54926
+ }
54927
+ this.emit();
54928
+ }
54929
+ emit() {
54930
+ if (this.listeners.size === 0) return;
54931
+ const status = this.snapshot();
54932
+ for (const listener of this.listeners) listener(status);
54933
+ }
54934
+ now() {
54935
+ return this.deps.now?.() ?? Date.now();
54936
+ }
54937
+ };
54938
+ /** No check, download or install is in flight. */
54939
+ function isQuiet(phase) {
54940
+ return phase === "idle" || phase === "available" || phase === "error";
54941
+ }
54122
54942
  // ../server/src/services/updates/installer.ts
54123
54943
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
54124
54944
  /**
@@ -54197,10 +55017,10 @@ async function installAppVersion(opts) {
54197
55017
  function reportedVersion(entry) {
54198
55018
  return new Promise((resolve, reject) => {
54199
55019
  execFile(process.execPath, [entry, "--version"], {
54200
- env: {
55020
+ env: ownNodeEnv({
54201
55021
  ...process.env,
54202
55022
  CODEDEN_NO_SWITCH: "1"
54203
- },
55023
+ }),
54204
55024
  timeout: 6e4,
54205
55025
  windowsHide: true
54206
55026
  }, (error, stdout, stderr) => {
@@ -54357,7 +55177,7 @@ var UpdateService = class {
54357
55177
  * Returns at once with the `installing` status; progress follows through `onChange`.
54358
55178
  */
54359
55179
  install(opts = {}) {
54360
- if (this.host.source === "source") throw conflict("You're running CodeDen from source: pull and rebuild to update.", "running_from_source");
55180
+ if (this.host.source !== "packaged") throw conflict("You're running CodeDen from source: pull and rebuild to update.", "running_from_source");
54361
55181
  if (this.state === "installing" || this.state === "restarting") return this.snapshot();
54362
55182
  const restart = opts.restart !== false;
54363
55183
  this.state = "installing";
@@ -55783,6 +56603,532 @@ var ProviderUtilityService = class {
55783
56603
  function clip(text, max) {
55784
56604
  return text.length > max ? `${text.slice(0, max)}...` : text;
55785
56605
  }
56606
+ // ../server/src/services/voice/local/catalog.ts
56607
+ const WHISPER_RUNTIME_VERSION = "1.1.3";
56608
+ const runtime = (target, sha256, bytes) => ({
56609
+ version: WHISPER_RUNTIME_VERSION,
56610
+ url: `https://registry.npmjs.org/@fugood/node-whisper-${target}/-/node-whisper-${target}-${WHISPER_RUNTIME_VERSION}.tgz`,
56611
+ sha256,
56612
+ bytes,
56613
+ entry: "package/index.node"
56614
+ });
56615
+ /** Prebuilt runtimes by `process.platform-process.arch`. */
56616
+ const RUNTIME_PINS = {
56617
+ "win32-x64": runtime("win32-x64", "25d54aff191d51833a4941ed77d54beeef034fad585c485938fb34e59f32cb4a", 1205460),
56618
+ "win32-arm64": runtime("win32-arm64", "c6e36608d32aaec62e6f07bdfd7a29576c12b0eb2cf396bed6f6b5ee236a1342", 1226903),
56619
+ "darwin-x64": runtime("darwin-x64", "8f9af3930e94f7b4123cc39ae8fea2554477404e65d4d5ad6a0ca142a6add591", 1642873),
56620
+ "darwin-arm64": runtime("darwin-arm64", "cb54d7e9050a896d794193367769b0c93e77c51e34861d521140c2dd8cc501dc", 1516419),
56621
+ "linux-x64": runtime("linux-x64", "d847231487492e4bcaef2d6d47e06e4b4ea872ee621fc988d01b7f0733006224", 1279405),
56622
+ "linux-arm64": runtime("linux-arm64", "f392eacef6c773442e5c427a6dca5ab6cd3f4f104ca8d609c27c7cf97e6e2a4a", 1199206)
56623
+ };
56624
+ /** Hugging Face revision of `ggerganov/whisper.cpp` the checksums below belong to. */
56625
+ const MODEL_REVISION = "5359861c739e955e79d9a303bcbc70fb988958b1";
56626
+ const model = (id, sha256, bytes, info) => {
56627
+ const file = `ggml-${id}-q5_1.bin`;
56628
+ return {
56629
+ id,
56630
+ file,
56631
+ url: `https://huggingface.co/ggerganov/whisper.cpp/resolve/${MODEL_REVISION}/${file}`,
56632
+ sha256,
56633
+ bytes,
56634
+ englishOnly: id.endsWith(".en"),
56635
+ ...info
56636
+ };
56637
+ };
56638
+ /** The models offered in Settings, smallest first. */
56639
+ const MODEL_PINS = [
56640
+ model("tiny.en", "c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b", 32166155, {
56641
+ label: "Tiny (English)",
56642
+ hint: "Fastest, lower accuracy",
56643
+ recommended: false
56644
+ }),
56645
+ model("tiny", "818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7", 32152673, {
56646
+ label: "Tiny",
56647
+ hint: "Fastest, lower accuracy, many languages",
56648
+ recommended: false
56649
+ }),
56650
+ model("base.en", "4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f", 59721011, {
56651
+ label: "Base (English)",
56652
+ hint: "Fast, good accuracy",
56653
+ recommended: true
56654
+ }),
56655
+ model("base", "422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898", 59707625, {
56656
+ label: "Base",
56657
+ hint: "Fast, good accuracy, many languages",
56658
+ recommended: false
56659
+ }),
56660
+ model("small.en", "bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30", 190098681, {
56661
+ label: "Small (English)",
56662
+ hint: "Slower, best accuracy",
56663
+ recommended: false
56664
+ }),
56665
+ model("small", "ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb", 190085487, {
56666
+ label: "Small",
56667
+ hint: "Slower, best accuracy, many languages",
56668
+ recommended: false
56669
+ })
56670
+ ];
56671
+ /** `win32-x64` style key for the current process. */
56672
+ function currentPlatform() {
56673
+ return `${process.platform}-${process.arch}`;
56674
+ }
56675
+ // ../server/src/services/voice/local/download.ts
56676
+ /** Why a download failed; `incomplete` leaves the partial file so the next try resumes. */
56677
+ var DownloadError = class extends Error {
56678
+ code;
56679
+ constructor(code, message) {
56680
+ super(message);
56681
+ this.code = code;
56682
+ this.name = "DownloadError";
56683
+ }
56684
+ };
56685
+ /** The partial file a download writes to. */
56686
+ const partialPath = (dest) => `${dest}.partial`;
56687
+ async function hashExisting(path, hash) {
56688
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
56689
+ }
56690
+ /**
56691
+ * Download a pinned file to `dest`, resuming a previous partial download with an HTTP range
56692
+ * request when the server allows it (and starting over when it does not). The file only
56693
+ * becomes `dest` once its size and SHA-256 match the pin; a mismatch deletes it.
56694
+ */
56695
+ async function downloadVerified(opts) {
56696
+ const { file, dest } = opts;
56697
+ const fetchImpl = opts.fetch ?? ((input, init) => fetch(input, init));
56698
+ const partial = partialPath(dest);
56699
+ mkdirSync(dirname(dest), { recursive: true });
56700
+ let hash = createHash("sha256");
56701
+ let received = 0;
56702
+ if (existsSync(partial)) {
56703
+ const size = statSync(partial).size;
56704
+ if (size > file.bytes) rmSync(partial, { force: true });
56705
+ else if (size > 0) {
56706
+ await hashExisting(partial, hash);
56707
+ received = size;
56708
+ }
56709
+ }
56710
+ if (received < file.bytes) {
56711
+ const res = await fetchImpl(file.url, {
56712
+ headers: received > 0 ? { range: `bytes=${received}-` } : {},
56713
+ signal: opts.signal
56714
+ });
56715
+ if (!(res.status === 206 && (res.headers.get("content-range") ?? "").startsWith(`bytes ${received}-`))) {
56716
+ if (res.status === 206 || res.status === 416) {
56717
+ await res.body?.cancel();
56718
+ rmSync(partial, { force: true });
56719
+ return downloadVerified(opts);
56720
+ }
56721
+ if (!res.ok) {
56722
+ await res.body?.cancel();
56723
+ throw new DownloadError("http", `Download failed: ${res.status} ${res.statusText}`);
56724
+ }
56725
+ hash = createHash("sha256");
56726
+ received = 0;
56727
+ }
56728
+ if (!res.body) throw new DownloadError("http", "Download failed: empty response");
56729
+ const out = await open(partial, received > 0 ? "a" : "w");
56730
+ let failure;
56731
+ try {
56732
+ opts.onProgress?.(received, file.bytes);
56733
+ for await (const chunk of res.body) {
56734
+ received += chunk.length;
56735
+ if (received > file.bytes) throw new DownloadError("size_mismatch", "The download is larger than expected");
56736
+ hash.update(chunk);
56737
+ await out.write(chunk);
56738
+ opts.onProgress?.(received, file.bytes);
56739
+ }
56740
+ } catch (error) {
56741
+ failure = error;
56742
+ } finally {
56743
+ await out.close();
56744
+ }
56745
+ if (failure) {
56746
+ if (failure instanceof DownloadError) rmSync(partial, { force: true });
56747
+ throw failure;
56748
+ }
56749
+ }
56750
+ if (received !== file.bytes) throw new DownloadError("incomplete", `The download stopped at ${received} of ${file.bytes} bytes; try again to resume`);
56751
+ if (hash.digest("hex") !== file.sha256) {
56752
+ rmSync(partial, { force: true });
56753
+ throw new DownloadError("checksum_mismatch", "The download did not match its pinned SHA-256 checksum and was deleted");
56754
+ }
56755
+ await rename(partial, dest);
56756
+ }
56757
+ // ../server/src/services/voice/local/runtime.ts
56758
+ /** An addon is loaded once per process; loading the same file twice is not allowed. */
56759
+ const loaded = /* @__PURE__ */ new Map();
56760
+ function loadAddon(addonPath) {
56761
+ const cached = loaded.get(addonPath);
56762
+ if (cached) return cached;
56763
+ const mod = { exports: {} };
56764
+ process.dlopen(mod, addonPath);
56765
+ const ctor = mod.exports.WhisperContext;
56766
+ if (typeof ctor !== "function") throw new Error(`${addonPath} is not a Whisper runtime`);
56767
+ loaded.set(addonPath, ctor);
56768
+ return ctor;
56769
+ }
56770
+ /** The real runtime: whisper.cpp's Node-API addon, loaded in the server process. */
56771
+ const loadNativeRuntime = async (addonPath) => {
56772
+ const Context = loadAddon(addonPath);
56773
+ return { async loadModel(modelPath) {
56774
+ const ctx = new Context({
56775
+ filePath: modelPath,
56776
+ useGpu: true
56777
+ });
56778
+ return {
56779
+ async transcribe(pcm, { language, signal }) {
56780
+ const job = ctx.transcribeData(pcm, { language });
56781
+ const stop = () => void job.stop();
56782
+ signal?.addEventListener("abort", stop, { once: true });
56783
+ try {
56784
+ const result = await job.promise;
56785
+ if (result.isAborted) throw new Error("Transcription was cancelled");
56786
+ return {
56787
+ text: result.result,
56788
+ language: result.language || null
56789
+ };
56790
+ } finally {
56791
+ signal?.removeEventListener("abort", stop);
56792
+ }
56793
+ },
56794
+ release: () => ctx.release()
56795
+ };
56796
+ } };
56797
+ };
56798
+ // ../server/src/services/voice/local/tgz.ts
56799
+ const BLOCK = 512;
56800
+ function field(header, start, length) {
56801
+ const raw = header.subarray(start, start + length).toString("utf8");
56802
+ const nul = raw.indexOf("\0");
56803
+ return nul === -1 ? raw : raw.slice(0, nul);
56804
+ }
56805
+ /**
56806
+ * Read one regular file out of a gzipped tar archive (an npm package tarball). Only the
56807
+ * named entry is returned, so nothing in the archive decides where files are written.
56808
+ */
56809
+ function extractTgzEntry(archive, entry) {
56810
+ const tar = gunzipSync(archive);
56811
+ let offset = 0;
56812
+ while (offset + BLOCK <= tar.length) {
56813
+ const header = tar.subarray(offset, offset + BLOCK);
56814
+ const name = field(header, 0, 100);
56815
+ if (!name) break;
56816
+ const size = Number.parseInt(field(header, 124, 12).trim() || "0", 8);
56817
+ if (!Number.isFinite(size) || size < 0) throw new Error("Corrupt archive header");
56818
+ const type = String.fromCharCode(header[156] ?? 0);
56819
+ const prefix = field(header, 257, 6).startsWith("ustar") ? field(header, 345, 155) : "";
56820
+ const path = prefix ? `${prefix}/${name}` : name;
56821
+ const start = offset + BLOCK;
56822
+ if ((type === "0" || type === "\0") && path === entry) {
56823
+ if (start + size > tar.length) throw new Error("Archive is truncated");
56824
+ return Buffer.from(tar.subarray(start, start + size));
56825
+ }
56826
+ offset = start + Math.ceil(size / BLOCK) * BLOCK;
56827
+ }
56828
+ throw new Error(`${entry} is not in the archive`);
56829
+ }
56830
+ // ../server/src/services/voice/local/wav.ts
56831
+ /** Shortest clip worth transcribing. */
56832
+ const MIN_SECONDS = .1;
56833
+ const BYTES_PER_SECOND = LOCAL_STT_SAMPLE_RATE * 2;
56834
+ const FORMAT_HELP = `The local engine takes WAV audio: 16-bit PCM, mono, ${LOCAL_STT_SAMPLE_RATE / 1e3} kHz`;
56835
+ const unsupported = (detail) => new ServiceError(415, "unsupported_audio", `${FORMAT_HELP} (${detail}).`);
56836
+ /**
56837
+ * Validate an uploaded WAV file for the local engine and return its samples. The browser
56838
+ * converts recordings to this format, so the server needs no audio decoder.
56839
+ */
56840
+ function parseWav(bytes) {
56841
+ if (bytes.length === 0) throw new ServiceError(400, "empty_audio", "No audio was sent");
56842
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
56843
+ const ascii = (at) => at + 4 <= bytes.length ? String.fromCharCode(...bytes.subarray(at, at + 4)) : "";
56844
+ if (ascii(0) !== "RIFF" || ascii(8) !== "WAVE") throw unsupported("not a WAV file");
56845
+ let format = null;
56846
+ let offset = 12;
56847
+ while (offset + 8 <= bytes.length) {
56848
+ const id = ascii(offset);
56849
+ const declared = view.getUint32(offset + 4, true);
56850
+ const body = offset + 8;
56851
+ if (id === "fmt " && body + 16 <= bytes.length) format = {
56852
+ tag: view.getUint16(body, true),
56853
+ channels: view.getUint16(body + 2, true),
56854
+ rate: view.getUint32(body + 4, true),
56855
+ bits: view.getUint16(body + 14, true)
56856
+ };
56857
+ else if (id === "data") {
56858
+ if (!format) throw unsupported("the format chunk is missing");
56859
+ if (format.tag !== 1 && format.tag !== 65534) throw unsupported("not PCM");
56860
+ if (format.bits !== 16) throw unsupported(`${format.bits}-bit samples`);
56861
+ if (format.channels !== 1) throw unsupported(`${format.channels} channels`);
56862
+ if (format.rate !== 16e3) throw unsupported(`${format.rate} Hz`);
56863
+ let length = Math.min(declared, bytes.length - body);
56864
+ length -= length % 2;
56865
+ const durationSec = length / BYTES_PER_SECOND;
56866
+ if (durationSec > 120) throw new ServiceError(413, "audio_too_long", `Audio is longer than 120 seconds`);
56867
+ if (durationSec < MIN_SECONDS) throw new ServiceError(400, "empty_audio", "The recording is too short to transcribe");
56868
+ const pcm = new Uint8Array(length);
56869
+ pcm.set(bytes.subarray(body, body + length));
56870
+ return {
56871
+ pcm: pcm.buffer,
56872
+ durationSec
56873
+ };
56874
+ }
56875
+ offset = body + declared + declared % 2;
56876
+ }
56877
+ throw unsupported("no audio data");
56878
+ }
56879
+ // ../server/src/services/voice/local/local-stt-service.ts
56880
+ const RUNTIME = "runtime";
56881
+ /** Strip Whisper's non-speech markers such as `[BLANK_AUDIO]` or `(silence)`. */
56882
+ function cleanTranscript(text) {
56883
+ return text.replace(/\[[^\]]*\]|\((?:silence|blank[ _]audio|music|inaudible|noise)[^)]*\)/gi, " ").replace(/\s+/g, " ").trim();
56884
+ }
56885
+ /** Stop waiting for `promise` when `signal` aborts (the work itself carries on). */
56886
+ function abortable(promise, signal) {
56887
+ if (signal.aborted) return Promise.reject(signal.reason);
56888
+ return new Promise((resolve, reject) => {
56889
+ const onAbort = () => reject(signal.reason);
56890
+ signal.addEventListener("abort", onAbort, { once: true });
56891
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
56892
+ });
56893
+ }
56894
+ /** `en-US` -> `en`. */
56895
+ const isoLanguage = (language) => language?.split("-")[0]?.toLowerCase() || null;
56896
+ function fileSize(path) {
56897
+ try {
56898
+ return statSync(path).size;
56899
+ } catch {
56900
+ return null;
56901
+ }
56902
+ }
56903
+ /** Why the prebuilt runtime cannot load on this Linux (it needs glibc 2.34 or newer). */
56904
+ function linuxProblem() {
56905
+ const glibc = (process.report?.getReport())?.header?.glibcVersionRuntime;
56906
+ if (!glibc) return "Local Whisper needs a glibc-based Linux (musl is not supported)";
56907
+ const [major = 0, minor = 0] = glibc.split(".").map(Number);
56908
+ return major > 2 || major === 2 && minor >= 34 ? null : `Local Whisper needs glibc 2.34 or newer (this system has ${glibc})`;
56909
+ }
56910
+ /**
56911
+ * Local Whisper (§11.3): downloads the runtime and models into the CodeDen home on demand,
56912
+ * with progress, resume and checksum checks, and transcribes uploaded WAV audio in the
56913
+ * server process. Downloads run in the background; the UI polls `status()`.
56914
+ */
56915
+ var LocalSttService = class {
56916
+ deps;
56917
+ platform;
56918
+ runtimePin;
56919
+ models;
56920
+ problem;
56921
+ jobs = /* @__PURE__ */ new Map();
56922
+ errors = /* @__PURE__ */ new Map();
56923
+ loaded = null;
56924
+ queue = Promise.resolve();
56925
+ idleTimer;
56926
+ constructor(deps) {
56927
+ this.deps = deps;
56928
+ this.platform = deps.platform ?? currentPlatform();
56929
+ this.runtimePin = (deps.runtimePins ?? RUNTIME_PINS)[this.platform];
56930
+ this.models = deps.models ?? MODEL_PINS;
56931
+ this.problem = !this.runtimePin ? `No prebuilt Whisper runtime for ${this.platform}` : !deps.platform && process.platform === "linux" ? linuxProblem() : null;
56932
+ }
56933
+ get addonPath() {
56934
+ const version = this.runtimePin?.version ?? "1.1.3";
56935
+ return join(this.deps.runtimeDir, `${version}-${this.platform}`, "whisper.node");
56936
+ }
56937
+ modelPath(pin) {
56938
+ return join(this.deps.modelsDir, pin.file);
56939
+ }
56940
+ pin(id) {
56941
+ const pin = this.models.find((m) => m.id === id);
56942
+ if (!pin) throw new ServiceError(404, "unknown_model", `Unknown Whisper model: ${id}`);
56943
+ return pin;
56944
+ }
56945
+ runtimeInstalled() {
56946
+ return existsSync(this.addonPath);
56947
+ }
56948
+ modelInstalled(pin) {
56949
+ return fileSize(this.modelPath(pin)) === pin.bytes;
56950
+ }
56951
+ status() {
56952
+ const entry = (key, total, installedPath, partial) => {
56953
+ const job = this.jobs.get(key);
56954
+ const sizeOnDisk = installedPath ? fileSize(installedPath) : null;
56955
+ const state = job ? "downloading" : sizeOnDisk !== null ? "installed" : this.errors.has(key) ? "failed" : "missing";
56956
+ return {
56957
+ state,
56958
+ receivedBytes: job?.received ?? (state === "installed" ? total : fileSize(partial) ?? 0),
56959
+ totalBytes: total,
56960
+ sizeOnDisk,
56961
+ error: state === "failed" ? this.errors.get(key) ?? null : null
56962
+ };
56963
+ };
56964
+ const runtime = this.runtimePin;
56965
+ return {
56966
+ supported: this.problem === null,
56967
+ platform: this.platform,
56968
+ unsupportedReason: this.problem,
56969
+ runtime: {
56970
+ version: runtime?.version ?? "1.1.3",
56971
+ ...entry(RUNTIME, runtime?.bytes ?? 0, this.runtimeInstalled() ? this.addonPath : null, partialPath(this.archivePath()))
56972
+ },
56973
+ models: this.models.map((m) => ({
56974
+ id: m.id,
56975
+ label: m.label,
56976
+ hint: m.hint,
56977
+ englishOnly: m.englishOnly,
56978
+ recommended: m.recommended,
56979
+ ...entry(m.id, m.bytes, this.modelInstalled(m) ? this.modelPath(m) : null, partialPath(this.modelPath(m)))
56980
+ })),
56981
+ location: this.deps.modelsDir
56982
+ };
56983
+ }
56984
+ /** Start (or join) downloading the runtime and a model; returns right away. */
56985
+ download(id) {
56986
+ if (this.problem) throw new ServiceError(409, "unsupported_platform", this.problem);
56987
+ const pin = this.pin(id);
56988
+ if (!this.jobs.has(id) && !(this.runtimeInstalled() && this.modelInstalled(pin))) this.track(id, async (signal, progress) => {
56989
+ await abortable(this.ensureRuntime(), signal);
56990
+ if (this.modelInstalled(pin)) return;
56991
+ await downloadVerified({
56992
+ file: pin,
56993
+ dest: this.modelPath(pin),
56994
+ fetch: this.deps.fetch,
56995
+ signal,
56996
+ onProgress: progress
56997
+ });
56998
+ this.deps.log?.(`Whisper model ${pin.id} downloaded`);
56999
+ }).catch(() => {});
57000
+ return this.status();
57001
+ }
57002
+ /** Cancel a running download or delete a downloaded model (and its partial file). */
57003
+ async remove(id) {
57004
+ const pin = this.pin(id);
57005
+ const job = this.jobs.get(id);
57006
+ if (job) {
57007
+ job.abort.abort();
57008
+ await job.promise.catch(() => {});
57009
+ }
57010
+ if (this.loaded?.id === id) await this.serially(() => this.unload());
57011
+ rmSync(this.modelPath(pin), { force: true });
57012
+ rmSync(partialPath(this.modelPath(pin)), { force: true });
57013
+ this.errors.delete(id);
57014
+ return this.status();
57015
+ }
57016
+ /** Transcribe a WAV upload with the model chosen in Settings. */
57017
+ async transcribe(audio, opts = {}) {
57018
+ if (this.problem) throw new ServiceError(409, "unsupported_platform", this.problem);
57019
+ const { pcm } = parseWav(audio);
57020
+ const pin = this.pin(this.deps.settings.get().voice.dictation.local.model);
57021
+ if (!this.runtimeInstalled() || !this.modelInstalled(pin)) throw new ServiceError(409, "model_not_installed", `The ${pin.label} Whisper model isn't downloaded yet. Download it in Settings > Voice.`);
57022
+ const language = pin.englishOnly ? "en" : isoLanguage(opts.language) ?? "auto";
57023
+ return this.serially(async () => {
57024
+ const model = await this.model(pin);
57025
+ const started = performance.now();
57026
+ try {
57027
+ const result = await model.transcribe(pcm, {
57028
+ language,
57029
+ signal: opts.signal
57030
+ });
57031
+ return {
57032
+ text: cleanTranscript(result.text),
57033
+ language: result.language ?? (language === "auto" ? null : language),
57034
+ engine: "local",
57035
+ elapsedMs: Math.round(performance.now() - started)
57036
+ };
57037
+ } catch (error) {
57038
+ if (opts.signal?.aborted) throw new ServiceError(400, "aborted", "Cancelled");
57039
+ throw new ServiceError(500, "transcription_failed", errorMessage(error));
57040
+ } finally {
57041
+ this.armIdleUnload();
57042
+ }
57043
+ });
57044
+ }
57045
+ /** Resolves when no download is running (tests). */
57046
+ async settled() {
57047
+ while (this.jobs.size > 0) await Promise.allSettled([...this.jobs.values()].map((j) => j.promise));
57048
+ }
57049
+ /** Cancel downloads and free the model (server shutdown). */
57050
+ async stop() {
57051
+ for (const job of this.jobs.values()) job.abort.abort();
57052
+ await this.settled();
57053
+ await this.unload();
57054
+ }
57055
+ archivePath() {
57056
+ const name = this.runtimePin?.url.split("/").pop() ?? "runtime.tgz";
57057
+ return join(this.deps.runtimeDir, ".downloads", name);
57058
+ }
57059
+ ensureRuntime() {
57060
+ const pin = this.runtimePin;
57061
+ if (!pin) return Promise.reject(new Error(this.problem ?? "No runtime"));
57062
+ if (this.runtimeInstalled()) return Promise.resolve();
57063
+ return this.track(RUNTIME, async (signal, progress) => {
57064
+ const archive = this.archivePath();
57065
+ await downloadVerified({
57066
+ file: pin,
57067
+ dest: archive,
57068
+ fetch: this.deps.fetch,
57069
+ signal,
57070
+ onProgress: progress
57071
+ });
57072
+ const addon = extractTgzEntry(readFileSync(archive), pin.entry);
57073
+ mkdirSync(join(this.addonPath, ".."), { recursive: true });
57074
+ const staging = `${this.addonPath}.tmp`;
57075
+ writeFileSync(staging, addon, { mode: 493 });
57076
+ renameSync(staging, this.addonPath);
57077
+ rmSync(archive, { force: true });
57078
+ this.deps.log?.(`Whisper runtime ${pin.version} installed for ${this.platform}`);
57079
+ });
57080
+ }
57081
+ /** Run one download at a time per key; callers join a running one. */
57082
+ track(key, work) {
57083
+ const running = this.jobs.get(key);
57084
+ if (running) return running.promise;
57085
+ const abort = new AbortController();
57086
+ const job = {
57087
+ received: 0,
57088
+ abort,
57089
+ promise: Promise.resolve()
57090
+ };
57091
+ this.errors.delete(key);
57092
+ job.promise = work(abort.signal, (received) => {
57093
+ job.received = received;
57094
+ }).catch((error) => {
57095
+ if (!abort.signal.aborted) {
57096
+ this.errors.set(key, errorMessage(error));
57097
+ this.deps.log?.(`Whisper download (${key}) failed: ${errorMessage(error)}`);
57098
+ }
57099
+ throw error;
57100
+ }).finally(() => this.jobs.delete(key));
57101
+ this.jobs.set(key, job);
57102
+ return job.promise;
57103
+ }
57104
+ serially(fn) {
57105
+ const next = this.queue.then(fn, fn);
57106
+ this.queue = next.catch(() => {});
57107
+ return next;
57108
+ }
57109
+ async model(pin) {
57110
+ clearTimeout(this.idleTimer);
57111
+ if (this.loaded?.id === pin.id) return this.loaded.model;
57112
+ await this.unload();
57113
+ const model = await (await (this.deps.loadRuntime ?? loadNativeRuntime)(this.addonPath)).loadModel(this.modelPath(pin));
57114
+ this.loaded = {
57115
+ id: pin.id,
57116
+ model
57117
+ };
57118
+ return model;
57119
+ }
57120
+ armIdleUnload() {
57121
+ clearTimeout(this.idleTimer);
57122
+ this.idleTimer = setTimeout(() => void this.serially(() => this.unload()), this.deps.idleUnloadMs ?? 6e5);
57123
+ this.idleTimer.unref();
57124
+ }
57125
+ async unload() {
57126
+ clearTimeout(this.idleTimer);
57127
+ const current = this.loaded;
57128
+ this.loaded = null;
57129
+ await current?.model.release().catch(() => {});
57130
+ }
57131
+ };
55786
57132
  // ../server/src/services/container.ts
55787
57133
  function createServices(opts) {
55788
57134
  const exec = opts.exec ?? runTool;
@@ -55845,6 +57191,13 @@ function createServices(opts) {
55845
57191
  outbox,
55846
57192
  questions
55847
57193
  });
57194
+ const queue = new QueueService({
57195
+ outbox,
57196
+ repo: sessionRepo,
57197
+ sessions,
57198
+ artifacts,
57199
+ providers: () => opts.runner.providers()
57200
+ });
55848
57201
  const lifecycle = new SessionLifecycle({
55849
57202
  db: opts.db,
55850
57203
  store: opts.store,
@@ -55870,12 +57223,20 @@ function createServices(opts) {
55870
57223
  });
55871
57224
  const setup = new SetupService({
55872
57225
  exec,
55873
- settings: opts.settings
57226
+ settings: opts.settings,
57227
+ onChecked: () => redetectProviders(opts.runner, opts.log)
55874
57228
  });
55875
57229
  const claudeSdk = new ClaudeSdkService({
55876
57230
  depsDir: opts.paths.deps,
55877
57231
  log: opts.log
55878
57232
  });
57233
+ const localStt = new LocalSttService({
57234
+ runtimeDir: join(opts.paths.deps, "whisper"),
57235
+ modelsDir: join(opts.paths.models, "whisper"),
57236
+ settings: opts.settings,
57237
+ ...opts.localStt,
57238
+ log: opts.log
57239
+ });
55879
57240
  const automations = new AutomationService({
55880
57241
  db: opts.db,
55881
57242
  store: opts.store,
@@ -55924,6 +57285,16 @@ function createServices(opts) {
55924
57285
  options: opts.factory,
55925
57286
  log: opts.log
55926
57287
  });
57288
+ const worktreeCleanup = new WorktreeCleanup({
57289
+ repo: sessionRepo,
57290
+ projects,
57291
+ runner: opts.runner,
57292
+ store: opts.store,
57293
+ settings: () => opts.settings.get(),
57294
+ stopSession: (id) => sessions.stop(id),
57295
+ ...opts.worktreeCleanup,
57296
+ log: opts.log
57297
+ });
55927
57298
  const services = {
55928
57299
  db: opts.db,
55929
57300
  store: opts.store,
@@ -55935,6 +57306,7 @@ function createServices(opts) {
55935
57306
  projects,
55936
57307
  sessionRepo,
55937
57308
  sessions,
57309
+ queue,
55938
57310
  lifecycle,
55939
57311
  recovery: new SessionRecovery({
55940
57312
  repo: sessionRepo,
@@ -55949,17 +57321,7 @@ function createServices(opts) {
55949
57321
  runner: opts.runner,
55950
57322
  repo: sessionRepo
55951
57323
  }),
55952
- updates: new UpdateService({
55953
- home: opts.paths.home,
55954
- mode: () => opts.settings.get().updates.mode,
55955
- checksDisabled: process.env.CODEDEN_UPDATE_CHECK === "0",
55956
- host: opts.selfUpdate,
55957
- registry: updateRegistry(),
55958
- notes: notesSources(),
55959
- fetch: opts.updates?.fetch,
55960
- intervalMs: opts.updates?.intervalMs,
55961
- log: opts.log
55962
- }),
57324
+ updates: createUpdates(opts),
55963
57325
  images: new ImageService({
55964
57326
  registry: opts.imageEngines ?? defaultImageEngines(opts.secrets),
55965
57327
  settings: opts.settings,
@@ -55972,12 +57334,15 @@ function createServices(opts) {
55972
57334
  voice: new VoiceService({
55973
57335
  secrets: opts.secrets,
55974
57336
  settings: opts.settings,
57337
+ local: localStt,
55975
57338
  ...opts.voice
55976
57339
  }),
57340
+ localStt,
55977
57341
  setup,
55978
57342
  claudeSdk,
55979
57343
  usage,
55980
57344
  factory,
57345
+ worktreeCleanup,
55981
57346
  configFiles: new ConfigFiles({
55982
57347
  projects,
55983
57348
  home: userHome,
@@ -56014,6 +57379,28 @@ function defaultImageEngines(secrets) {
56014
57379
  openai: { apiKey: key("openai.apiKey") }
56015
57380
  });
56016
57381
  }
57382
+ /** npm self-update for `codeden`, or the desktop app's own updater when it hosts the server. */
57383
+ function createUpdates(opts) {
57384
+ const common = {
57385
+ mode: () => opts.settings.get().updates.mode,
57386
+ checksDisabled: process.env.CODEDEN_UPDATE_CHECK === "0",
57387
+ notes: notesSources(),
57388
+ fetch: opts.updates?.fetch,
57389
+ intervalMs: opts.updates?.intervalMs,
57390
+ log: opts.log
57391
+ };
57392
+ const desktop = opts.selfUpdate?.desktop;
57393
+ if (desktop) return new DesktopUpdateService({
57394
+ ...common,
57395
+ desktop
57396
+ });
57397
+ return new UpdateService({
57398
+ ...common,
57399
+ home: opts.paths.home,
57400
+ host: opts.selfUpdate,
57401
+ registry: updateRegistry()
57402
+ });
57403
+ }
56017
57404
  // ../server/src/services/secrets.ts
56018
57405
  /** In-memory store for tests and machines without a usable keychain. */
56019
57406
  var MemorySecretStore = class {
@@ -56140,7 +57527,8 @@ async function startServer(opts) {
56140
57527
  const runnerStatus = () => runner?.status ?? disabledStatus;
56141
57528
  const hub = new WsHub(store, database.db, runnerStatus, {
56142
57529
  flushMs: opts.realtimeFlushMs,
56143
- updateStatus: () => services.updates.snapshot()
57530
+ updateStatus: () => services.updates.snapshot(),
57531
+ artifacts: { artifactForPath: (path) => services.artifacts.artifactForPath(path) }
56144
57532
  });
56145
57533
  const secrets = opts.secrets === "memory" ? new MemorySecretStore() : opts.secrets ?? await createSecretStore(log);
56146
57534
  const builtin = opts.builtinProviders ?? process.env.CODEDEN_BUILTIN_PROVIDERS !== "0";
@@ -56158,12 +57546,14 @@ async function startServer(opts) {
56158
57546
  askUserWaitMs: opts.askUserWaitMs,
56159
57547
  imageEngines: opts.imageEngines ?? (builtin ? void 0 : new ImageEngineRegistry([])),
56160
57548
  voice: opts.voice,
57549
+ localStt: opts.localStt,
56161
57550
  orchestration: opts.orchestration,
56162
57551
  github: opts.github,
56163
57552
  automations: opts.automations,
56164
57553
  factory: opts.factory,
56165
57554
  selfUpdate: opts.selfUpdate,
56166
57555
  updates: opts.updates,
57556
+ worktreeCleanup: opts.worktreeCleanup,
56167
57557
  log
56168
57558
  });
56169
57559
  services.images.refresh();
@@ -56173,6 +57563,7 @@ async function startServer(opts) {
56173
57563
  services.usage.start();
56174
57564
  services.automations.start();
56175
57565
  services.factory.start();
57566
+ services.worktreeCleanup.start();
56176
57567
  const stopUpdateEvents = services.updates.onChange((status) => hub.broadcastUpdate(status));
56177
57568
  services.updates.start();
56178
57569
  let origins = /* @__PURE__ */ new Set();
@@ -56206,7 +57597,7 @@ async function startServer(opts) {
56206
57597
  runner: services.runner,
56207
57598
  cwdFor: (sessionId) => {
56208
57599
  const session = sessionId ? services.sessionRepo.find(sessionId)?.session : void 0;
56209
- return session ? session.worktreePath ?? session.cwd : paths.home;
57600
+ return session ? sessionWorkdir(session) : paths.home;
56210
57601
  },
56211
57602
  log
56212
57603
  });
@@ -56288,7 +57679,9 @@ async function startServer(opts) {
56288
57679
  services.usage.stop();
56289
57680
  services.automations.stop();
56290
57681
  services.factory.stop();
57682
+ services.worktreeCleanup.stop();
56291
57683
  services.updates.stop();
57684
+ await services.localStt.stop();
56292
57685
  stopUpdateEvents();
56293
57686
  stopTerminalEvents();
56294
57687
  if (runner) {
@@ -56706,6 +58099,8 @@ async function stopRunner(lockFile, timeoutMs = 1e4) {
56706
58099
  return lock.pid;
56707
58100
  }
56708
58101
  // src/stop.ts
58102
+ /** How long to keep watching for a runner the server was starting as it shut down. */
58103
+ const LATE_RUNNER_MS = 2e3;
56709
58104
  async function waitForExit(pid, timeoutMs) {
56710
58105
  const deadline = Date.now() + timeoutMs;
56711
58106
  while (Date.now() < deadline) {
@@ -56740,6 +58135,17 @@ async function stop(home) {
56740
58135
  if (!await waitForExit(runner.pid, 5e3)) await stopRunner(paths.runnerLock);
56741
58136
  console.log(`Stopped runner (pid ${runner.pid}).`);
56742
58137
  }
58138
+ const deadline = Date.now() + LATE_RUNNER_MS;
58139
+ while (Date.now() < deadline) {
58140
+ const late = readLiveRunnerLock(paths.runnerLock);
58141
+ if (late && late.pid !== runner?.pid) {
58142
+ await stopRunner(paths.runnerLock);
58143
+ console.log(`Stopped runner (pid ${late.pid}).`);
58144
+ break;
58145
+ }
58146
+ if (!late && !server) break;
58147
+ await new Promise((r) => setTimeout(r, 250));
58148
+ }
56743
58149
  return 0;
56744
58150
  }
56745
58151
  // src/update-command.ts
@@ -57009,4 +58415,4 @@ main().then((code) => process.exit(code), (error) => {
57009
58415
  });
57010
58416
  export {};
57011
58417
 
57012
- //# sourceMappingURL=main-BF0aQ9U8.js.map
58418
+ //# sourceMappingURL=main-pXwL7GDQ.js.map