@celestea/studio 2.7.1

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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/dist/app.d.ts +111 -0
  3. package/dist/app.js +219 -0
  4. package/dist/auth/api-token.d.ts +76 -0
  5. package/dist/auth/api-token.js +172 -0
  6. package/dist/auth/htpasswd.d.ts +22 -0
  7. package/dist/auth/htpasswd.js +61 -0
  8. package/dist/auth/index.d.ts +14 -0
  9. package/dist/auth/index.js +14 -0
  10. package/dist/auth/page.d.ts +19 -0
  11. package/dist/auth/page.js +73 -0
  12. package/dist/auth/rate-limit.d.ts +27 -0
  13. package/dist/auth/rate-limit.js +47 -0
  14. package/dist/auth/token.d.ts +49 -0
  15. package/dist/auth/token.js +112 -0
  16. package/dist/config.d.ts +78 -0
  17. package/dist/config.js +72 -0
  18. package/dist/deployment.d.ts +92 -0
  19. package/dist/deployment.js +154 -0
  20. package/dist/fake-runtime-adapter.d.ts +37 -0
  21. package/dist/fake-runtime-adapter.js +276 -0
  22. package/dist/handlers/auth.d.ts +24 -0
  23. package/dist/handlers/auth.js +155 -0
  24. package/dist/handlers/common.d.ts +76 -0
  25. package/dist/handlers/common.js +127 -0
  26. package/dist/handlers/config-shape.d.ts +76 -0
  27. package/dist/handlers/config-shape.js +227 -0
  28. package/dist/handlers/config.d.ts +26 -0
  29. package/dist/handlers/config.js +161 -0
  30. package/dist/handlers/context-shape.d.ts +33 -0
  31. package/dist/handlers/context-shape.js +59 -0
  32. package/dist/handlers/dialog.d.ts +22 -0
  33. package/dist/handlers/dialog.js +292 -0
  34. package/dist/handlers/exec.d.ts +22 -0
  35. package/dist/handlers/exec.js +131 -0
  36. package/dist/handlers/fs-read.d.ts +63 -0
  37. package/dist/handlers/fs-read.js +159 -0
  38. package/dist/handlers/fs.d.ts +57 -0
  39. package/dist/handlers/fs.js +153 -0
  40. package/dist/handlers/grants-shape.d.ts +45 -0
  41. package/dist/handlers/grants-shape.js +130 -0
  42. package/dist/handlers/grants.d.ts +21 -0
  43. package/dist/handlers/grants.js +267 -0
  44. package/dist/handlers/health.d.ts +37 -0
  45. package/dist/handlers/health.js +139 -0
  46. package/dist/handlers/index.d.ts +40 -0
  47. package/dist/handlers/index.js +85 -0
  48. package/dist/handlers/permissions.d.ts +9 -0
  49. package/dist/handlers/permissions.js +183 -0
  50. package/dist/handlers/plugins.d.ts +18 -0
  51. package/dist/handlers/plugins.js +23 -0
  52. package/dist/handlers/prompts.d.ts +12 -0
  53. package/dist/handlers/prompts.js +120 -0
  54. package/dist/handlers/providers.d.ts +12 -0
  55. package/dist/handlers/providers.js +178 -0
  56. package/dist/handlers/questions.d.ts +23 -0
  57. package/dist/handlers/questions.js +115 -0
  58. package/dist/handlers/session-model.d.ts +38 -0
  59. package/dist/handlers/session-model.js +105 -0
  60. package/dist/handlers/session-move.d.ts +22 -0
  61. package/dist/handlers/session-move.js +188 -0
  62. package/dist/handlers/session-tools.d.ts +19 -0
  63. package/dist/handlers/session-tools.js +75 -0
  64. package/dist/handlers/sessions.d.ts +32 -0
  65. package/dist/handlers/sessions.js +242 -0
  66. package/dist/handlers/usage.d.ts +26 -0
  67. package/dist/handlers/usage.js +77 -0
  68. package/dist/handlers/worker.d.ts +12 -0
  69. package/dist/handlers/worker.js +82 -0
  70. package/dist/handlers/workspaces.d.ts +12 -0
  71. package/dist/handlers/workspaces.js +88 -0
  72. package/dist/harness.test-util.d.ts +101 -0
  73. package/dist/harness.test-util.js +176 -0
  74. package/dist/index.d.ts +33 -0
  75. package/dist/index.js +33 -0
  76. package/dist/main.d.ts +13 -0
  77. package/dist/main.js +35 -0
  78. package/dist/plugins.d.ts +105 -0
  79. package/dist/plugins.js +97 -0
  80. package/dist/question-registry.d.ts +124 -0
  81. package/dist/question-registry.js +212 -0
  82. package/dist/question-rows.d.ts +32 -0
  83. package/dist/question-rows.js +43 -0
  84. package/dist/replay/compare.d.ts +47 -0
  85. package/dist/replay/compare.js +89 -0
  86. package/dist/replay/e2e-replay.d.ts +46 -0
  87. package/dist/replay/e2e-replay.js +91 -0
  88. package/dist/replay/expect-compact.d.ts +31 -0
  89. package/dist/replay/expect-compact.js +92 -0
  90. package/dist/replay/fixtures.d.ts +52 -0
  91. package/dist/replay/fixtures.js +53 -0
  92. package/dist/replay/host.d.ts +32 -0
  93. package/dist/replay/host.js +62 -0
  94. package/dist/replay/index.d.ts +16 -0
  95. package/dist/replay/index.js +16 -0
  96. package/dist/replay/probes.d.ts +55 -0
  97. package/dist/replay/probes.js +225 -0
  98. package/dist/replay/report.d.ts +13 -0
  99. package/dist/replay/report.js +103 -0
  100. package/dist/replay/session-e2e.d.ts +44 -0
  101. package/dist/replay/session-e2e.js +91 -0
  102. package/dist/routes.d.ts +47 -0
  103. package/dist/routes.js +59 -0
  104. package/dist/runtime/attachments-llm.d.ts +13 -0
  105. package/dist/runtime/attachments-llm.js +37 -0
  106. package/dist/runtime/boot-recovery.d.ts +32 -0
  107. package/dist/runtime/boot-recovery.js +71 -0
  108. package/dist/runtime/context-snapshot.d.ts +59 -0
  109. package/dist/runtime/context-snapshot.js +113 -0
  110. package/dist/runtime/engine-grants.d.ts +121 -0
  111. package/dist/runtime/engine-grants.js +338 -0
  112. package/dist/runtime/engine-permissions.d.ts +29 -0
  113. package/dist/runtime/engine-permissions.js +92 -0
  114. package/dist/runtime/engine-plugins.d.ts +189 -0
  115. package/dist/runtime/engine-plugins.js +292 -0
  116. package/dist/runtime/engine-profile.d.ts +28 -0
  117. package/dist/runtime/engine-profile.js +88 -0
  118. package/dist/runtime/engine-session.d.ts +9 -0
  119. package/dist/runtime/engine-session.js +9 -0
  120. package/dist/runtime/fallback-contract.d.ts +32 -0
  121. package/dist/runtime/fallback-contract.js +10 -0
  122. package/dist/runtime/fallback-host.d.ts +134 -0
  123. package/dist/runtime/fallback-host.js +299 -0
  124. package/dist/runtime/host-autowake.d.ts +79 -0
  125. package/dist/runtime/host-autowake.js +86 -0
  126. package/dist/runtime/image-downgrade.d.ts +49 -0
  127. package/dist/runtime/image-downgrade.js +90 -0
  128. package/dist/runtime/inbox-message.d.ts +11 -0
  129. package/dist/runtime/inbox-message.js +20 -0
  130. package/dist/runtime/index.d.ts +17 -0
  131. package/dist/runtime/index.js +17 -0
  132. package/dist/runtime/ledger-view.d.ts +40 -0
  133. package/dist/runtime/ledger-view.js +56 -0
  134. package/dist/runtime/llm-assembly.d.ts +66 -0
  135. package/dist/runtime/llm-assembly.js +114 -0
  136. package/dist/runtime/offline-llm.d.ts +71 -0
  137. package/dist/runtime/offline-llm.js +132 -0
  138. package/dist/runtime/provider-target.d.ts +10 -0
  139. package/dist/runtime/provider-target.js +10 -0
  140. package/dist/runtime/question-host.d.ts +33 -0
  141. package/dist/runtime/question-host.js +41 -0
  142. package/dist/runtime/question-view.d.ts +48 -0
  143. package/dist/runtime/question-view.js +76 -0
  144. package/dist/runtime/real-runtime-adapter.d.ts +95 -0
  145. package/dist/runtime/real-runtime-adapter.js +553 -0
  146. package/dist/runtime/recovery-audit.d.ts +81 -0
  147. package/dist/runtime/recovery-audit.js +95 -0
  148. package/dist/runtime/recovery-view.d.ts +36 -0
  149. package/dist/runtime/recovery-view.js +63 -0
  150. package/dist/runtime/session-compose.d.ts +235 -0
  151. package/dist/runtime/session-compose.js +350 -0
  152. package/dist/runtime/session-grants.d.ts +36 -0
  153. package/dist/runtime/session-grants.js +69 -0
  154. package/dist/runtime/session-lifecycle.d.ts +40 -0
  155. package/dist/runtime/session-lifecycle.js +86 -0
  156. package/dist/runtime/session-publisher.d.ts +40 -0
  157. package/dist/runtime/session-publisher.js +48 -0
  158. package/dist/runtime/session-release.d.ts +53 -0
  159. package/dist/runtime/session-release.js +80 -0
  160. package/dist/runtime/test-util.d.ts +78 -0
  161. package/dist/runtime/test-util.js +162 -0
  162. package/dist/runtime/watchdog-view.d.ts +33 -0
  163. package/dist/runtime/watchdog-view.js +45 -0
  164. package/dist/runtime/worker-bridge.d.ts +76 -0
  165. package/dist/runtime/worker-bridge.js +156 -0
  166. package/dist/runtime/worker-live.d.ts +22 -0
  167. package/dist/runtime/worker-live.js +25 -0
  168. package/dist/runtime/worker-recovery.d.ts +35 -0
  169. package/dist/runtime/worker-recovery.js +64 -0
  170. package/dist/runtime/worker-table.d.ts +58 -0
  171. package/dist/runtime/worker-table.js +70 -0
  172. package/dist/runtime-adapter.d.ts +421 -0
  173. package/dist/runtime-adapter.js +76 -0
  174. package/dist/serial-queue.d.ts +16 -0
  175. package/dist/serial-queue.js +22 -0
  176. package/dist/server.d.ts +44 -0
  177. package/dist/server.js +119 -0
  178. package/dist/settings.d.ts +23 -0
  179. package/dist/settings.js +31 -0
  180. package/dist/sse.d.ts +67 -0
  181. package/dist/sse.js +186 -0
  182. package/dist/static.d.ts +20 -0
  183. package/dist/static.js +120 -0
  184. package/dist/store/builtin-sections.d.ts +52 -0
  185. package/dist/store/builtin-sections.js +118 -0
  186. package/dist/store/celestea-home.d.ts +12 -0
  187. package/dist/store/celestea-home.js +11 -0
  188. package/dist/store/fs-json.d.ts +51 -0
  189. package/dist/store/fs-json.js +119 -0
  190. package/dist/store/grants-audit.d.ts +84 -0
  191. package/dist/store/grants-audit.js +109 -0
  192. package/dist/store/grants-service.d.ts +44 -0
  193. package/dist/store/grants-service.js +41 -0
  194. package/dist/store/grants-tokens.d.ts +62 -0
  195. package/dist/store/grants-tokens.js +105 -0
  196. package/dist/store/grants.d.ts +113 -0
  197. package/dist/store/grants.js +238 -0
  198. package/dist/store/index.d.ts +36 -0
  199. package/dist/store/index.js +36 -0
  200. package/dist/store/mode.d.ts +35 -0
  201. package/dist/store/mode.js +41 -0
  202. package/dist/store/permissions.d.ts +61 -0
  203. package/dist/store/permissions.js +139 -0
  204. package/dist/store/prompts-compose.d.ts +56 -0
  205. package/dist/store/prompts-compose.js +62 -0
  206. package/dist/store/prompts-template.d.ts +26 -0
  207. package/dist/store/prompts-template.js +99 -0
  208. package/dist/store/prompts.d.ts +142 -0
  209. package/dist/store/prompts.js +273 -0
  210. package/dist/store/provider-probe.d.ts +81 -0
  211. package/dist/store/provider-probe.js +154 -0
  212. package/dist/store/providers.d.ts +97 -0
  213. package/dist/store/providers.js +219 -0
  214. package/dist/store/result.d.ts +29 -0
  215. package/dist/store/result.js +30 -0
  216. package/dist/store/session-id.d.ts +119 -0
  217. package/dist/store/session-id.js +220 -0
  218. package/dist/store/session-meta.d.ts +41 -0
  219. package/dist/store/session-meta.js +66 -0
  220. package/dist/store/session-ops.d.ts +109 -0
  221. package/dist/store/session-ops.js +264 -0
  222. package/dist/store/session-tools.d.ts +42 -0
  223. package/dist/store/session-tools.js +69 -0
  224. package/dist/store/sessions.d.ts +162 -0
  225. package/dist/store/sessions.js +307 -0
  226. package/dist/store/validate.d.ts +13 -0
  227. package/dist/store/validate.js +38 -0
  228. package/dist/store/workspaces.d.ts +67 -0
  229. package/dist/store/workspaces.js +228 -0
  230. package/dist/user-questions.d.ts +58 -0
  231. package/dist/user-questions.js +157 -0
  232. package/dist/version.d.ts +14 -0
  233. package/dist/version.js +52 -0
  234. package/package.json +38 -0
  235. package/webdist/assets/index-Bngs7gUm.js +98 -0
  236. package/webdist/assets/index-Bu6ci_rN.css +1 -0
  237. package/webdist/assets/katex-CCbS2qAY.js +257 -0
  238. package/webdist/assets/mhchem-CnUN8HwY.js +1 -0
  239. package/webdist/build-meta.json +7 -0
  240. package/webdist/index.html +261 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Session listing / creation / transcript reader (`src/workspaces.rs:1092-1221,1459-1501`).
3
+ *
4
+ * A "session" is a directory holding `cli-main.jsonl`. The id is
5
+ * `<workspace-basename>/<dir>`; the `worker:<sid>` space is served by the engine
6
+ * (RuntimeAdapter) instead.
7
+ *
8
+ * W880: NEW sessions land in `<CELESTEA_HOME>/workspaces/<ws>/sessions/<dir>`
9
+ * (outside the workspace). `resolve()`/`list()` still read the two legacy
10
+ * layouts (slice-A `<ws>/.celestea/sessions/`, then `<ws>/`) with the canonical
11
+ * container winning. W791: the ARCHIVED sessions (canonical
12
+ * `<home>/.../archive/<dir>`, legacy `<ws>/.celestea-archived/<dir>`, see
13
+ * `session-ops.ts`) are a second source of the same row shape — `listArchived()`
14
+ * — because `list()` skips dot-directories by design.
15
+ *
16
+ * The transcript endpoint is a pure projection of the append-only log
17
+ * (`projectMessages` from `@celestea/session`), stopping at the first
18
+ * unparsable line so a torn tail is dropped rather than reported.
19
+ */
20
+ import type { StudioMessage } from "@celestea/core";
21
+ import type { SessionWorkspace } from "@celestea/runtime";
22
+ import { type StoreResult } from "./result.js";
23
+ import { type SessionMode } from "./mode.js";
24
+ import { type SessionMeta } from "./session-meta.js";
25
+ import { type WorkspacesStore } from "./workspaces.js";
26
+ export interface ResolvedSession {
27
+ workspace: string;
28
+ session: string;
29
+ /** Canonical `<workspace>/<session>`. */
30
+ id: string;
31
+ wsPath: string;
32
+ dir: string;
33
+ }
34
+ /**
35
+ * W768 — THE single resolution of a session's workspace.
36
+ *
37
+ * Everything that needs to know "which workspace is this session in" goes
38
+ * through here: the system prompt's `{{workspace}}` / `{{workspace_dir}}`
39
+ * variables AND the per-session sandbox cwd / path-guard root. They used to be
40
+ * two independent derivations (a store lookup for the prompt, a process-wide env
41
+ * knob for the tools), which is exactly how the prompt ended up naming one
42
+ * workspace while `pwd` reported another.
43
+ *
44
+ * Pure projection of the store's own record: no second lookup, no re-parsing of
45
+ * the session id, nothing that could disagree with `resolve()`.
46
+ */
47
+ export declare function sessionWorkspaceOf(resolved: ResolvedSession | null): SessionWorkspace | null;
48
+ /**
49
+ * W779 T2 — the session's DISPLAY name.
50
+ *
51
+ * `session.json.title` when the session declared one (the original, un-sanitized
52
+ * title), else the directory name WITHOUT its `-<secs>.<nanos>[-N]` creation
53
+ * suffix. ONE rule, used by `list()` and by `branch()`'s default title, so the
54
+ * GUI never has to know how a session directory is named.
55
+ */
56
+ export declare function displayTitle(meta: SessionMeta | null, dirName: string): string;
57
+ export interface SessionRow {
58
+ id: string;
59
+ workspace: string;
60
+ /**
61
+ * W779 T2: the display name — `session.json.title`, else the directory name
62
+ * with its creation suffix stripped (never `main-1789192174.492000000`).
63
+ */
64
+ title: string;
65
+ model: string | null;
66
+ /**
67
+ * W729: the session's working mode. A session without `session.json.mode`
68
+ * (and every worker row) reads as `standard` — the P0 default (K8/M3).
69
+ */
70
+ mode: SessionMode;
71
+ size: number;
72
+ modified: number;
73
+ active: boolean;
74
+ /**
75
+ * W791: present (and `true`) ONLY on rows produced by `listArchived()` — the
76
+ * default listing never carries the key, so the frozen response body of
77
+ * `GET /api/sessions` is unchanged for every pre-W791 client.
78
+ */
79
+ archived?: true;
80
+ /**
81
+ * W513 row kind: `session` = a filesystem session directory, `worker` = an
82
+ * engine-memory worker conversation (`workspace: "engine"`).
83
+ */
84
+ kind?: "session" | "worker";
85
+ /** W513: this session has an in-flight turn (its OWN slot, not the process's). */
86
+ busy?: boolean;
87
+ }
88
+ export interface SessionCreateRequest {
89
+ workspace?: string;
90
+ title: string;
91
+ model?: string;
92
+ prompt?: string;
93
+ /** W729: optional at creation; absent = `standard` and NOT written (K8). */
94
+ mode?: string;
95
+ }
96
+ export declare class SessionsStore {
97
+ private readonly ws;
98
+ private readonly now;
99
+ constructor(ws: WorkspacesStore, now?: () => number);
100
+ /**
101
+ * Every filesystem session plus the engine rows handed in by the caller.
102
+ *
103
+ * W877 (slice A): sessions are scanned in BOTH layouts — the new
104
+ * `<ws>/.celestea/sessions/` root first, then the legacy workspace root. A
105
+ * directory name present in both is emitted ONCE and the new-layout row wins.
106
+ * Dot-directories stay invisible in either root (that is what keeps
107
+ * `.celestea`, `.celestea-archived` and `.celestea-trash` out of the
108
+ * default listing).
109
+ */
110
+ list(extra?: readonly SessionRow[]): SessionRow[];
111
+ /**
112
+ * W791 (B1): every ARCHIVED session of every registered workspace.
113
+ *
114
+ * Archiving MOVES a session directory to `<ws>/.celestea-archived/<name>` and
115
+ * keeps its id (`<wsName>/<session>`) — it is the same session, parked in a
116
+ * hidden sibling. `list()` deliberately skips dot-directories, so before this
117
+ * method the ONLY endpoint that could name an archived session was
118
+ * `unarchive`; the GUI's archive panel (`?archived=1`) and the
119
+ * delete-an-archived-session path both need to enumerate them.
120
+ *
121
+ * The rows have EXACTLY the shape `list()` produces — same id/workspace/title/
122
+ * model/mode/size/modified derivation, same sort — plus `archived: true` and
123
+ * `active: false` (an archived session can never be the active one: archiving
124
+ * refuses the active session).
125
+ */
126
+ listArchived(): SessionRow[];
127
+ /** One row of the frozen shape (shared by `list` and `listArchived`). */
128
+ private rowOf;
129
+ /**
130
+ * Registry lookup + sanitization. The physical dir is probed in BOTH layouts
131
+ * (W877 slice A): the new `<ws>/.celestea/sessions/<session>` wins when it
132
+ * exists, the legacy `<ws>/<session>` is the fallback, and when NEITHER exists
133
+ * the new layout is returned so every write path stays consistent with
134
+ * `create()`. No registry entry is created here.
135
+ */
136
+ resolve(id: string): StoreResult<ResolvedSession>;
137
+ /** A real live session dir: a directory holding the session log. */
138
+ private isSessionDir;
139
+ /** Resolve + require the session directory to hold a log file. */
140
+ require(id: string): StoreResult<ResolvedSession>;
141
+ private pickWorkspace;
142
+ /** POST /api/sessions — create a session dir; NEVER activates it. */
143
+ create(req: SessionCreateRequest): StoreResult<string>;
144
+ /**
145
+ * `<base>`, then `<base>-1`, `<base>-2`, … until the name is free, returned
146
+ * inside `writeRoot`.
147
+ *
148
+ * `writeRoot` defaults to the canonical container (`create()`), while
149
+ * `rename`/`branch` pass the parent of the session's CURRENT dir so a session
150
+ * is renamed inside the layer it already lives in.
151
+ *
152
+ * Collision detection spans EVERY live-session layer: a name already taken by
153
+ * a legacy session must not be minted again in the canonical container (and
154
+ * vice versa), because `list()` and `resolve()` would then have two different
155
+ * physical dirs for one id.
156
+ */
157
+ uniqueDir(wsPath: string, base: string, writeRoot?: string): string;
158
+ /** Transcript projection: torn tail dropped, no pairing logic. */
159
+ messages(resolved: ResolvedSession): StudioMessage[];
160
+ /** POST /api/clear — truncate the log (no backup, no 409 guard). */
161
+ truncate(resolved: ResolvedSession): StoreResult<void>;
162
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Session listing / creation / transcript reader (`src/workspaces.rs:1092-1221,1459-1501`).
3
+ *
4
+ * A "session" is a directory holding `cli-main.jsonl`. The id is
5
+ * `<workspace-basename>/<dir>`; the `worker:<sid>` space is served by the engine
6
+ * (RuntimeAdapter) instead.
7
+ *
8
+ * W880: NEW sessions land in `<CELESTEA_HOME>/workspaces/<ws>/sessions/<dir>`
9
+ * (outside the workspace). `resolve()`/`list()` still read the two legacy
10
+ * layouts (slice-A `<ws>/.celestea/sessions/`, then `<ws>/`) with the canonical
11
+ * container winning. W791: the ARCHIVED sessions (canonical
12
+ * `<home>/.../archive/<dir>`, legacy `<ws>/.celestea-archived/<dir>`, see
13
+ * `session-ops.ts`) are a second source of the same row shape — `listArchived()`
14
+ * — because `list()` skips dot-directories by design.
15
+ *
16
+ * The transcript endpoint is a pure projection of the append-only log
17
+ * (`projectMessages` from `@celestea/session`), stopping at the first
18
+ * unparsable line so a torn tail is dropped rather than reported.
19
+ */
20
+ import { readFileSync } from "node:fs";
21
+ import { parseSessionJsonl, projectMessages } from "@celestea/session";
22
+ import { isDirectory, isFile, listEntries, statOf, writeFileRaw, removeDir, ensureDir } from "./fs-json.js";
23
+ import { badRequest, errText, fail, notFound, ok } from "./result.js";
24
+ import { DEFAULT_SESSION_MODE, parseMode, validateMode } from "./mode.js";
25
+ import { readSessionMeta, writeSessionMeta } from "./session-meta.js";
26
+ import { archiveRoots, baseName, liveDirCandidates, sessionDirName, sanitizeComponent, sessionRoots, sessionsRoot, stripCreationSuffix, workspaceBasename } from "./session-id.js";
27
+ import { validateModelName, validatePromptId } from "./validate.js";
28
+ import { SESSION_FILE } from "./workspaces.js";
29
+ /**
30
+ * W768 — THE single resolution of a session's workspace.
31
+ *
32
+ * Everything that needs to know "which workspace is this session in" goes
33
+ * through here: the system prompt's `{{workspace}}` / `{{workspace_dir}}`
34
+ * variables AND the per-session sandbox cwd / path-guard root. They used to be
35
+ * two independent derivations (a store lookup for the prompt, a process-wide env
36
+ * knob for the tools), which is exactly how the prompt ended up naming one
37
+ * workspace while `pwd` reported another.
38
+ *
39
+ * Pure projection of the store's own record: no second lookup, no re-parsing of
40
+ * the session id, nothing that could disagree with `resolve()`.
41
+ */
42
+ export function sessionWorkspaceOf(resolved) {
43
+ if (resolved === null)
44
+ return null;
45
+ return { name: resolved.workspace, path: resolved.wsPath };
46
+ }
47
+ /**
48
+ * W779 T2 — the session's DISPLAY name.
49
+ *
50
+ * `session.json.title` when the session declared one (the original, un-sanitized
51
+ * title), else the directory name WITHOUT its `-<secs>.<nanos>[-N]` creation
52
+ * suffix. ONE rule, used by `list()` and by `branch()`'s default title, so the
53
+ * GUI never has to know how a session directory is named.
54
+ */
55
+ export function displayTitle(meta, dirName) {
56
+ const declared = meta?.title ?? "";
57
+ return declared === "" ? stripCreationSuffix(dirName) : declared;
58
+ }
59
+ export class SessionsStore {
60
+ ws;
61
+ now;
62
+ constructor(ws, now = Date.now) {
63
+ this.ws = ws;
64
+ this.now = now;
65
+ }
66
+ /**
67
+ * Every filesystem session plus the engine rows handed in by the caller.
68
+ *
69
+ * W877 (slice A): sessions are scanned in BOTH layouts — the new
70
+ * `<ws>/.celestea/sessions/` root first, then the legacy workspace root. A
71
+ * directory name present in both is emitted ONCE and the new-layout row wins.
72
+ * Dot-directories stay invisible in either root (that is what keeps
73
+ * `.celestea`, `.celestea-archived` and `.celestea-trash` out of the
74
+ * default listing).
75
+ */
76
+ list(extra = []) {
77
+ const active = this.ws.activeSession();
78
+ const rows = [];
79
+ for (const w of this.ws.registry().workspaces) {
80
+ const name = workspaceBasename(w.path) ?? w.path;
81
+ const seen = new Set();
82
+ // Canonical container FIRST, then the slice-A transitional root, then the
83
+ // oldest workspace root. Only a REAL session (a dir holding the log)
84
+ // shadows a lower layer: a stray empty dir must not hide a live session.
85
+ for (const root of sessionRoots(w.path)) {
86
+ for (const e of listEntries(root)) {
87
+ if (!e.isDir || e.name.startsWith(".") || seen.has(e.name))
88
+ continue;
89
+ const log = `${root}/${e.name}/${SESSION_FILE}`;
90
+ if (!isFile(log))
91
+ continue;
92
+ seen.add(e.name);
93
+ const row = this.rowOf(name, e.name, readSessionMeta(`${root}/${e.name}`), statOf(log));
94
+ rows.push({ ...row, active: active === row.id });
95
+ }
96
+ }
97
+ }
98
+ rows.push(...extra);
99
+ rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
100
+ return rows;
101
+ }
102
+ /**
103
+ * W791 (B1): every ARCHIVED session of every registered workspace.
104
+ *
105
+ * Archiving MOVES a session directory to `<ws>/.celestea-archived/<name>` and
106
+ * keeps its id (`<wsName>/<session>`) — it is the same session, parked in a
107
+ * hidden sibling. `list()` deliberately skips dot-directories, so before this
108
+ * method the ONLY endpoint that could name an archived session was
109
+ * `unarchive`; the GUI's archive panel (`?archived=1`) and the
110
+ * delete-an-archived-session path both need to enumerate them.
111
+ *
112
+ * The rows have EXACTLY the shape `list()` produces — same id/workspace/title/
113
+ * model/mode/size/modified derivation, same sort — plus `archived: true` and
114
+ * `active: false` (an archived session can never be the active one: archiving
115
+ * refuses the active session).
116
+ */
117
+ listArchived() {
118
+ const rows = [];
119
+ for (const w of this.ws.registry().workspaces) {
120
+ const name = workspaceBasename(w.path) ?? w.path;
121
+ const seen = new Set();
122
+ for (const root of archiveRoots(w.path)) {
123
+ for (const e of listEntries(root)) {
124
+ if (!e.isDir || e.name.startsWith(".") || seen.has(e.name))
125
+ continue;
126
+ const log = `${root}/${e.name}/${SESSION_FILE}`;
127
+ if (!isFile(log))
128
+ continue;
129
+ seen.add(e.name);
130
+ rows.push({ ...this.rowOf(name, e.name, readSessionMeta(`${root}/${e.name}`), statOf(log)), archived: true });
131
+ }
132
+ }
133
+ }
134
+ rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
135
+ return rows;
136
+ }
137
+ /** One row of the frozen shape (shared by `list` and `listArchived`). */
138
+ rowOf(workspace, dirName, meta, st) {
139
+ return {
140
+ id: `${workspace}/${dirName}`,
141
+ workspace,
142
+ kind: "session",
143
+ title: displayTitle(meta, dirName),
144
+ model: meta?.model ?? null,
145
+ mode: meta?.mode ?? DEFAULT_SESSION_MODE,
146
+ size: st?.size ?? 0,
147
+ modified: st?.modified ?? 0,
148
+ active: false,
149
+ };
150
+ }
151
+ /**
152
+ * Registry lookup + sanitization. The physical dir is probed in BOTH layouts
153
+ * (W877 slice A): the new `<ws>/.celestea/sessions/<session>` wins when it
154
+ * exists, the legacy `<ws>/<session>` is the fallback, and when NEITHER exists
155
+ * the new layout is returned so every write path stays consistent with
156
+ * `create()`. No registry entry is created here.
157
+ */
158
+ resolve(id) {
159
+ const slash = id.trim().indexOf("/");
160
+ if (slash <= 0)
161
+ return badRequest(`invalid session id '${id}': expected '<workspace>/<session>'`);
162
+ const wsName = id.trim().slice(0, slash);
163
+ const wsPath = this.ws.workspacePath(wsName);
164
+ if (wsPath === undefined)
165
+ return notFound(`unknown workspace '${wsName}'`);
166
+ const session = sanitizeComponent(id.trim().slice(slash + 1));
167
+ if (session === "" || session === "." || session === ".." || session.startsWith(".")) {
168
+ return badRequest(`invalid session id '${id}'`);
169
+ }
170
+ // W880: the canonical candidate lives under CELESTEA_HOME, OUTSIDE the
171
+ // workspace, so the old `startsWith(wsPath/)` guard no longer applies. The
172
+ // session segment is already sanitized (no separators, never hidden) and
173
+ // every candidate is derived from the registered wsPath, so the result
174
+ // cannot escape either root.
175
+ const candidates = liveDirCandidates(wsPath, session);
176
+ const dir = candidates.find((c) => this.isSessionDir(c)) ?? candidates[0];
177
+ return ok({ workspace: wsName, session, id: `${wsName}/${session}`, wsPath, dir });
178
+ }
179
+ /** A real live session dir: a directory holding the session log. */
180
+ isSessionDir(dir) {
181
+ return isDirectory(dir) && isFile(`${dir}/${SESSION_FILE}`);
182
+ }
183
+ /** Resolve + require the session directory to hold a log file. */
184
+ require(id) {
185
+ const res = this.resolve(id);
186
+ if (!res.ok)
187
+ return res;
188
+ if (!isDirectory(res.value.dir) || !isFile(`${res.value.dir}/${SESSION_FILE}`)) {
189
+ return notFound(`unknown session '${id}'`);
190
+ }
191
+ return res;
192
+ }
193
+ pickWorkspace(requested) {
194
+ const asked = (requested ?? "").trim();
195
+ if (asked !== "")
196
+ return asked;
197
+ const active = this.ws.activeSession();
198
+ if (active !== null && active.includes("/"))
199
+ return active.slice(0, active.indexOf("/"));
200
+ const first = this.ws.registry().workspaces[0];
201
+ return first === undefined ? null : workspaceBasename(first.path);
202
+ }
203
+ /** POST /api/sessions — create a session dir; NEVER activates it. */
204
+ create(req) {
205
+ const wsName = this.pickWorkspace(req.workspace);
206
+ if (wsName === null)
207
+ return notFound(`unknown workspace '${(req.workspace ?? "").trim()}'`);
208
+ const wsPath = this.ws.workspacePath(wsName);
209
+ if (wsPath === undefined)
210
+ return notFound(`unknown workspace '${wsName}'`);
211
+ if (!isDirectory(wsPath))
212
+ return notFound(`workspace path '${wsPath}' is not accessible`);
213
+ if (req.title.trim() === "")
214
+ return badRequest("title must not be empty");
215
+ const base = sanitizeComponent(req.title);
216
+ if (base.startsWith(".") || base === "") {
217
+ return badRequest(`title '${req.title}' sanitizes to the hidden name '${base}'`);
218
+ }
219
+ const model = req.model ?? "";
220
+ if (model !== "") {
221
+ const bad = validateModelName(model);
222
+ if (bad !== null)
223
+ return badRequest(`invalid model: ${bad}`);
224
+ }
225
+ const prompt = req.prompt ?? "";
226
+ if (prompt !== "") {
227
+ const bad = validatePromptId(prompt);
228
+ if (bad !== null)
229
+ return badRequest(`invalid prompt: ${bad}`);
230
+ }
231
+ const mode = req.mode ?? "";
232
+ if (mode !== "") {
233
+ const bad = validateMode(mode);
234
+ if (bad !== null)
235
+ return badRequest(bad);
236
+ }
237
+ // W880: NEW sessions land in <CELESTEA_HOME>/workspaces/<ws>/sessions/
238
+ // (ensureDir below creates the container recursively); the id stays
239
+ // <wsName>/<dirName>. Nothing is created under the workspace root.
240
+ const dir = this.uniqueDir(wsPath, sessionDirName(req.title, this.now()));
241
+ try {
242
+ ensureDir(dir);
243
+ writeFileRaw(`${dir}/${SESSION_FILE}`, "");
244
+ }
245
+ catch (e) {
246
+ return fail(500, `create failed: ${errText(e)}`);
247
+ }
248
+ try {
249
+ // W779 T2: the ORIGINAL title (trimmed, CJK/spaces preserved) is what the
250
+ // GUI shows; the directory name stays the sanitized+timestamped form.
251
+ writeSessionMeta(dir, {
252
+ title: req.title.trim(),
253
+ model,
254
+ prompt,
255
+ ...(mode === "" ? {} : { mode: parseMode(mode) ?? DEFAULT_SESSION_MODE }),
256
+ });
257
+ }
258
+ catch (e) {
259
+ removeDir(dir);
260
+ return fail(500, `meta write failed: ${errText(e)}`);
261
+ }
262
+ // W885: the directory's own basename, per the platform (a win32 realpath
263
+ // returns backslashes, so `lastIndexOf("/")` produced the whole path —
264
+ // W883 E2).
265
+ return ok(`${wsName}/${baseName(dir)}`);
266
+ }
267
+ /**
268
+ * `<base>`, then `<base>-1`, `<base>-2`, … until the name is free, returned
269
+ * inside `writeRoot`.
270
+ *
271
+ * `writeRoot` defaults to the canonical container (`create()`), while
272
+ * `rename`/`branch` pass the parent of the session's CURRENT dir so a session
273
+ * is renamed inside the layer it already lives in.
274
+ *
275
+ * Collision detection spans EVERY live-session layer: a name already taken by
276
+ * a legacy session must not be minted again in the canonical container (and
277
+ * vice versa), because `list()` and `resolve()` would then have two different
278
+ * physical dirs for one id.
279
+ */
280
+ uniqueDir(wsPath, base, writeRoot = sessionsRoot(wsPath)) {
281
+ const roots = [writeRoot, ...sessionRoots(wsPath)];
282
+ let name = base;
283
+ let n = 1;
284
+ while (roots.some((root) => isFile(`${root}/${name}/${SESSION_FILE}`) || isDirectory(`${root}/${name}`))) {
285
+ name = `${base}-${n}`;
286
+ n += 1;
287
+ if (n > 1000)
288
+ break;
289
+ }
290
+ return `${writeRoot}/${name}`;
291
+ }
292
+ /** Transcript projection: torn tail dropped, no pairing logic. */
293
+ messages(resolved) {
294
+ const text = readFileSync(`${resolved.dir}/${SESSION_FILE}`, "utf8");
295
+ return projectMessages(parseSessionJsonl(text).events);
296
+ }
297
+ /** POST /api/clear — truncate the log (no backup, no 409 guard). */
298
+ truncate(resolved) {
299
+ try {
300
+ writeFileRaw(`${resolved.dir}/${SESSION_FILE}`, "");
301
+ return ok(undefined);
302
+ }
303
+ catch (e) {
304
+ return fail(500, errText(e));
305
+ }
306
+ }
307
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Host-side field validation shared by several endpoints.
3
+ *
4
+ * The host validates before it hands anything to the engine, and the
5
+ * error strings are contract text (`contracts/endpoints.json` errors[]), so
6
+ * they live here verbatim instead of being re-typed per handler.
7
+ */
8
+ export declare const MODEL_MAX_LEN = 128;
9
+ export declare function validateModelName(model: string): string | null;
10
+ export declare function validatePromptId(id: string): string | null;
11
+ export declare function isHttpUrl(url: string): boolean;
12
+ /** `parse_effort`: "" / "off" (case-insensitive) clears the effort. */
13
+ export declare function parseEffort(raw: string | null | undefined): string | null | undefined;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Host-side field validation shared by several endpoints.
3
+ *
4
+ * The host validates before it hands anything to the engine, and the
5
+ * error strings are contract text (`contracts/endpoints.json` errors[]), so
6
+ * they live here verbatim instead of being re-typed per handler.
7
+ */
8
+ /** Model ids are deliberately lax: `[A-Za-z0-9._-:/@]`, at most 128 chars. */
9
+ const MODEL_CHARS = /^[A-Za-z0-9._\-:/@]*$/;
10
+ export const MODEL_MAX_LEN = 128;
11
+ export function validateModelName(model) {
12
+ if (model.length > MODEL_MAX_LEN)
13
+ return `invalid model name: '${model}' exceeds ${MODEL_MAX_LEN} characters`;
14
+ for (const ch of model) {
15
+ if (!MODEL_CHARS.test(ch)) {
16
+ return `invalid model name '${model}': character ${JSON.stringify(ch)} is not allowed (only [A-Za-z0-9._-:/@]; no spaces, brackets or control characters)`;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ const PROMPT_ID = /^[A-Za-z0-9._-]{1,128}$/;
22
+ export function validatePromptId(id) {
23
+ return PROMPT_ID.test(id) ? null : "prompt id must be 1-128 chars of [A-Za-z0-9._-]";
24
+ }
25
+ export function isHttpUrl(url) {
26
+ return url.startsWith("http://") || url.startsWith("https://");
27
+ }
28
+ /** `parse_effort`: "" / "off" (case-insensitive) clears the effort. */
29
+ export function parseEffort(raw) {
30
+ if (raw === null)
31
+ return null;
32
+ if (raw === undefined)
33
+ return undefined;
34
+ const v = raw.trim();
35
+ if (v === "" || v.toLowerCase() === "off")
36
+ return null;
37
+ return v;
38
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `workspaces.json` (v2) registry store — `contracts/data-files/workspaces.schema.json`,
3
+ * `src/workspaces.rs:122-140,794-970`.
4
+ *
5
+ * Frozen format facts honored here:
6
+ * - NO version field; the workspace KEY is the registered path's folder
7
+ * basename and is never stored;
8
+ * - v1 `{"name":…}` rows are tolerated and ignored; `active_session` is
9
+ * rewritten to the basename on load (idempotent);
10
+ * - unknown fields are tolerated, a malformed file is a HARD error (the
11
+ * unreadable registry is never overwritten with an empty one);
12
+ * - write = pretty JSON -> `<file>.json.tmp` -> rename (atomic, no fsync).
13
+ */
14
+ import { type StoreResult } from "./result.js";
15
+ export declare const SESSION_FILE = "cli-main.jsonl";
16
+ export interface RegistryWorkspace {
17
+ path: string;
18
+ }
19
+ export interface RegistryData {
20
+ workspaces: RegistryWorkspace[];
21
+ active_session: string | null;
22
+ }
23
+ export interface WorkspaceRow {
24
+ name: string;
25
+ path: string;
26
+ /** LIVE session dirs only (archived / trashed dirs are not counted). */
27
+ sessions: number;
28
+ }
29
+ export interface WorkspacesView {
30
+ workspaces: WorkspaceRow[];
31
+ active_session: string | null;
32
+ }
33
+ export declare class WorkspacesStore {
34
+ private readonly file;
35
+ private data;
36
+ constructor(file: string);
37
+ /** Snapshot of the registry (callers must not mutate it). */
38
+ registry(): RegistryData;
39
+ activeSession(): string | null;
40
+ workspacePath(name: string): string | undefined;
41
+ private persist;
42
+ /** Persist `active_session`; the caller owns the 500 wording. */
43
+ setActiveSession(id: string | null): StoreResult<void>;
44
+ /**
45
+ * Count LIVE session dirs of a workspace across EVERY layer (W880): the
46
+ * canonical `<CELESTEA_HOME>/.../sessions`, the slice-A
47
+ * `<ws>/.celestea/sessions` and the oldest workspace root. A name present in
48
+ * more than one layer is counted ONCE (the canonical copy wins), matching
49
+ * `SessionsStore.list()`; missing a layer is how the GUI's workspace session
50
+ * count silently drops to 0.
51
+ */
52
+ countSessions(path: string): number;
53
+ view(): WorkspacesView;
54
+ /** POST /api/workspaces — register only; the folder is never touched. */
55
+ register(rawPath: string): StoreResult<string>;
56
+ /** POST /api/workspaces/{name}/delete — deregister only. */
57
+ deregister(name: string): StoreResult<void>;
58
+ batchDelete(names: readonly string[]): {
59
+ deleted: number;
60
+ failed: Array<{
61
+ name: string;
62
+ error: string;
63
+ }>;
64
+ };
65
+ /** POST /api/workspaces/{name}/rename — really renames the FOLDER. */
66
+ renameWorkspace(name: string, newName: string): StoreResult<void>;
67
+ }