viewable-one 0.0.0-stage → 0.12.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 (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
package/lib/spawn.mjs ADDED
@@ -0,0 +1,330 @@
1
+ // What a spawn REQUEST is, and the pure rules a request has to pass -
2
+ // unchanged in shape since the file contract retired (brief 30 Move 2b,
3
+ // 2026-09-11): `run.request` on a row carries exactly what `<id>.json`
4
+ // carried, and `checkRequest`/`titleFor`/`wallClockFor`/`kindOf` are the
5
+ // same functions either way, so `bridge/lib/runner.mjs` imports them
6
+ // unchanged.
7
+ //
8
+ // listRequests remains for the sweep's historical request cleanup.
9
+ // Runner presence is stored in rows; RUNNER_STALE_MS and RUNNER_BEAT_MS
10
+ // still govern the live heartbeat and its diagnostic staleness window.
11
+
12
+ import { readdir } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { problems } from "./refusal.mjs";
15
+ import { SPAWN_DIR as SPAWN_PATH, segmentsOf } from "./paths.mjs";
16
+ import { SIGN_IN_AGENTS } from "./signIn.mjs";
17
+
18
+ // Re-exported from paths.mjs so there is one definition of where the
19
+ // runner's local scratch lives.
20
+ export const SPAWN_DIR = join(...segmentsOf(SPAWN_PATH));
21
+
22
+ /** THE SAME PLACE, SPELLED FOR A PERSON. `SPAWN_DIR` above is `join`ed, so
23
+ * on Windows it is `.glytch\inbox\spawn`; this is the `/`-spelled form for
24
+ * anything that prints the path to a human. */
25
+ export const SPAWN_DIR_IN_WORDS = SPAWN_PATH;
26
+
27
+ // A session left running all night bills all night, so there is a default and
28
+ // a hard cap, and the cap is what a request may ask for.
29
+ //
30
+ // 90 minutes is still well under the 2-hour cap, still ends the night, and
31
+ // still notices a hang before the invoice does. The board cannot override it:
32
+ // buildSpawnRequest leaves maxMs out deliberately (src/folder/spawnRequest.ts:202),
33
+ // so this constant IS the wall clock for everything filed from the canvas.
34
+ export const DEFAULT_WALL_MS = 90 * 60_000;
35
+ export const MAX_WALL_MS = 2 * 60 * 60_000;
36
+
37
+ /** The terminal receipt's suffix - still meaningful locally: it is the
38
+ * window `bridge/lib/sweep.mjs` uses to decide a finished request's age on
39
+ * disk before this pass, and the file it names is written by nothing any
40
+ * more (a row's `result` lives in Postgres) - so a folder swept after Move
41
+ * 2 finds none, which is the correct, quiet answer. */
42
+ export const RECEIPT_SUFFIX = ".result.txt";
43
+
44
+ /** Historical request ids in this folder's local scratch, sorted by name.
45
+ * The sweep uses these to find old receipts and server records. Dotfiles,
46
+ * including a heartbeat left by an old runner, are never requests. */
47
+ export async function listRequests(root) {
48
+ let names;
49
+ try {
50
+ names = await readdir(join(root, SPAWN_DIR));
51
+ } catch {
52
+ return [];
53
+ }
54
+ return names
55
+ .filter((n) => n.endsWith(".json") && !n.startsWith("."))
56
+ .map((n) => n.slice(0, -5))
57
+ .sort();
58
+ }
59
+
60
+ // Presence lives in runner_projects rows; the watcher beats every ten seconds
61
+ // and a timestamp older than thirty seconds is stale.
62
+ export const RUNNER_STALE_MS = 30_000;
63
+ export const RUNNER_BEAT_MS = RUNNER_STALE_MS / 3;
64
+ // A 0.12 runner joined to its push topic beats every 5 minutes (#813), and
65
+ // the app gives it 6. Doctor runs beside a runner of its own version, so it
66
+ // uses this window too.
67
+ export const RUNNER_PUSH_STALE_MS = 6 * 60_000;
68
+
69
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
70
+
71
+ // ---------- WHICH KIND OF REQUEST (brief 25, session 1, 2026-09-07) ----------
72
+ //
73
+ // A request carries one field, `kind`, and it has exactly two values.
74
+ // ABSENT MEANS "session", which is the whole compatibility story: every
75
+ // request written before today - and every request the idea path
76
+ // writes after it - has no `kind` and runs down the same code it always did,
77
+ // byte for byte. This is the same rule `agent` and `resumeSessionId` already
78
+ // follow.
79
+ //
80
+ // A `chat-turn` is a DM with a coding agent answered by the daemon (his
81
+ // ruling 1A). It differs from a session in five ways and no others: no
82
+ // idea on the board, no worktree minted per message, no repo primer and no
83
+ // attempts log, a per-ROOM lock instead of the per-idea one, and (over
84
+ // rows, brief 30 Move 2b) no other marker at all - the row's own
85
+ // `request.kind` says which it is, where the file contract needed a `chat`
86
+ // word on the receipt's own first line for a person reading it by eye. See
87
+ // bridge/SPAWN.md, "Chat turns".
88
+ export const SESSION_KIND = "session";
89
+ export const CHAT_TURN_KIND = "chat-turn";
90
+ // Sign in to an agent on this computer (#482, lib/signIn.mjs): no prompt,
91
+ // no worktree, no model turn.
92
+ export const SIGN_IN_KIND = "sign-in";
93
+ export const REQUEST_KINDS = [SESSION_KIND, CHAT_TURN_KIND, SIGN_IN_KIND];
94
+
95
+ /** The kind this request is, with the absent field read as "session". Spelled
96
+ * once, here, because "absent means session" is a compatibility promise and
97
+ * two spellings of it is how a promise like that stops being kept. */
98
+ export const kindOf = (request) => {
99
+ const raw = typeof request?.kind === "string" ? request.kind.trim() : "";
100
+ return raw === "" ? SESSION_KIND : raw;
101
+ };
102
+
103
+ export const isChatTurn = (request) => kindOf(request) === CHAT_TURN_KIND;
104
+ export const isSignIn = (request) => kindOf(request) === SIGN_IN_KIND;
105
+
106
+ // ---------- PLAN MODE: which engines ----------
107
+ //
108
+ // briefs/plan-mode-beyond-claude.md. An engine belongs on this list only
109
+ // once its draft path has been checked to enforce the required contract
110
+ // (block project writes, board mutation, network escape and permission
111
+ // escalation) rather than merely being told not to - see the note beside
112
+ // its check in checkRequest below for what was verified for each name here.
113
+ //
114
+ // SINGLE-USE APPROVAL (contract point 4) is not this file's job any more:
115
+ // there is no exclusive-create-on-disk to bind an approval to at most one
116
+ // implementation the way `<id>.plan-consumed` did before the file contract
117
+ // retired (brief 30 Move 2). `bridge/lib/runner.mjs`'s own execute branch
118
+ // scans `runnerApi.runs` for another execute row already claimed against
119
+ // the same `approvedPlanFrom` instead - see the comment there.
120
+ export const PLAN_MODE_AGENTS = ["claude", "codex"];
121
+
122
+ // One folder name, safe on every platform a runner runs on: no separators,
123
+ // nothing Windows refuses (CON, NUL and the like included), no leading dot (hidden, and "." and ".."), no
124
+ // trailing dot or space (Windows drops them). The app's boardFolderName
125
+ // (src/runs/chatFolder.ts) writes names that pass.
126
+ export const isBoardFolderName = (name) =>
127
+ typeof name === "string" && name.length >= 1 && name.length <= 80 &&
128
+ !/[/\\<>:"|?*]|^\.|[. ]$/.test(name) && ![...name].some((c) => c.charCodeAt(0) < 32) &&
129
+ !/^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$/i.test(name);
130
+
131
+ /** Every problem with a request, in one pass, in the bridge's refusal voice.
132
+ *
133
+ * `approved` is checked here rather than at the point of spawn on purpose: a
134
+ * request nobody approved must be refused by whatever first looks at it, not
135
+ * by the one code path that happens to run last. */
136
+ export function checkRequest(request) {
137
+ const found = problems();
138
+ if (!isRecord(request)) {
139
+ found.note(
140
+ `A spawn request must be a JSON object like {"prompt": "...", "approved": true}, got ${Array.isArray(request) ? "an array" : typeof request}.`,
141
+ );
142
+ return found.list;
143
+ }
144
+ if (isSignIn(request)) {
145
+ if (!SIGN_IN_AGENTS.includes(request.agent)) found.note(`A sign-in request names agent ${SIGN_IN_AGENTS.join(" or ")}.`);
146
+ } else if (typeof request.prompt !== "string" || request.prompt.trim() === "") {
147
+ found.note('Pass prompt: what the session is being asked to do. It is the whole point of the request.');
148
+ }
149
+ if (request.approved !== true) {
150
+ found.note(
151
+ 'This request is not approved, so it will never be run. A spawned session uses your own Claude Code or Codex plan, so "approved": true has to be set by a human gesture - never by the actor writing the request.',
152
+ );
153
+ }
154
+ if (request.planMode !== undefined) {
155
+ if (!["draft", "execute"].includes(request.planMode)) found.note("Unknown planMode. Use draft or execute.");
156
+ if (request.kind !== undefined) found.note("Plan mode is only available for prompt sessions.");
157
+ // WHICH ENGINES CAN ENFORCE READ-ONLY DRAFTING, checked against each
158
+ // engine's own CLI rather than assumed (briefs/plan-mode-beyond-claude.md).
159
+ // Claude Code: a restricted --allowedTools list plus --strict-mcp-config
160
+ // with an empty server map (commandFor's draft branch, agents.mjs).
161
+ // Codex: the native app-server's `sandbox: "read-only"` thread policy,
162
+ // which the generated protocol schema (`codex app-server
163
+ // generate-json-schema`) shows defaults `networkAccess: false` on top of
164
+ // the filesystem restriction, combined with never configuring the board
165
+ // MCP server for that thread's process at all (codex.mjs). opencode is
166
+ // deliberately absent: its shipped `plan` agent denies the structured
167
+ // `edit` tool but leaves `bash` on its default wildcard allow rule
168
+ // (`opencode agent list`), so a plan-mode session there could still
169
+ // write files or reach the network through a shell call - not
170
+ // enforceable from this bridge's side of the interface.
171
+ if (!PLAN_MODE_AGENTS.includes(request.agent ?? "claude")) {
172
+ found.note("Read-only plan approval currently requires Claude Code or Codex.");
173
+ }
174
+ if (request.planMode === "draft" && request.resumeSessionId) found.note("Plan mode must start a fresh session; resuming could carry implementation permissions.");
175
+ if (request.planMode === "execute" && (
176
+ typeof request.approvedPlan !== "string" || !request.approvedPlan.trim() ||
177
+ typeof request.approvedPlanFrom !== "string" || !/^spawn-[a-z0-9-]+$/.test(request.approvedPlanFrom) ||
178
+ request.approvedBy !== "board" || typeof request.approvedAt !== "string"
179
+ )) found.note("Implementation requires the human-approved plan, its completed draft request, and a board approval.");
180
+ } else if (request.approvedPlan !== undefined || request.approvedPlanFrom !== undefined) {
181
+ found.note("An approved plan must use planMode execute.");
182
+ }
183
+ if (request.title !== undefined && (typeof request.title !== "string" || request.title.trim() === "")) {
184
+ found.note("title, when present, names the session's frame on the board and must be a non-empty string.");
185
+ }
186
+ if (request.cwd !== undefined && (typeof request.cwd !== "string" || request.cwd.trim() === "")) {
187
+ found.note("cwd, when present, is the absolute directory to run in and must be a non-empty string.");
188
+ }
189
+ // WHICH KIND. Absent means "session" (kindOf above), so nothing written
190
+ // before today can fail this. A kind this runner has never heard of IS
191
+ // refused, and that is deliberate and the opposite of the `agent` rule
192
+ // below: an unknown engine name falls back to the default because running
193
+ // on the wrong engine beats dying while holding a claim, whereas an unknown
194
+ // KIND names a code path that does not exist here - there is nothing to
195
+ // fall back to, and guessing "session" would put a circle on his board for
196
+ // a request that asked for something else.
197
+ if (request.kind !== undefined) {
198
+ const kind = typeof request.kind === "string" ? request.kind.trim() : "";
199
+ if (!REQUEST_KINDS.includes(kind)) {
200
+ found.note(
201
+ `kind, when present, is ${REQUEST_KINDS.map((k) => `"${k}"`).join(" or ")}, got ${JSON.stringify(request.kind)}. Leave it out for an ordinary session: absent means "${SESSION_KIND}".`,
202
+ );
203
+ }
204
+ }
205
+ // A CHAT TURN NAMES WHERE IT RUNS AND WHICH ROOM IT IS, and neither is
206
+ // optional. It mints no worktree (brief 25, difference 2), so `cwd` is the
207
+ // only directory it could possibly run in and a fallback to the linked
208
+ // checkout would put a chat agent in his working tree. And its lock is
209
+ // per-ROOM rather than per-prompt (difference 4), so without `roomId` there
210
+ // is nothing to lock and two messages typed quickly become two live turns
211
+ // in one conversation.
212
+ //
213
+ // That the directory EXISTS is checked by the runner rather than here: this
214
+ // function is pure and synchronous by design, and every caller of it is a
215
+ // caller that has not touched the disk yet.
216
+ if (isChatTurn(request)) {
217
+ if (typeof request.cwd !== "string" || request.cwd.trim() === "") {
218
+ found.note(
219
+ "A chat-turn request must name cwd: the directory the conversation lives in. A chat turn never mints a worktree, and Claude Code keeps conversations per working directory, so the directory IS which conversation this is.",
220
+ );
221
+ }
222
+ if (typeof request.roomId !== "string" || request.roomId.trim() === "") {
223
+ found.note(
224
+ "A chat-turn request must name roomId: which DM this turn belongs to. It is what the per-room lock locks, and one live turn per room is the only thing stopping two messages typed a second apart from running at once.",
225
+ );
226
+ }
227
+ }
228
+ // MAY THE RUNNER CUT THE CHAT WORKTREE? A boolean, and only `true` means
229
+ // yes - a string "true" buys nothing, the same rule `approved` follows. It
230
+ // is validated for every kind rather than only a chat turn so that the
231
+ // field cannot mean one thing on one file and something else on another:
232
+ // nothing but a chat turn reads it, and a session request carrying it is a
233
+ // request that has misunderstood something.
234
+ if (request.mintIfMissing !== undefined && typeof request.mintIfMissing !== "boolean") {
235
+ found.note(
236
+ "mintIfMissing, when present, is a boolean: true asks the runner to create a chat turn's cwd as this folder's chat worktree if it is not there. Anything else is refused rather than read as true.",
237
+ );
238
+ }
239
+ // THE BOARD'S SUBFOLDER (2026-09-30): a chat turn in the Glytch folder runs
240
+ // in one folder per board inside its chat worktree. One name, never a path.
241
+ if (request.boardFolder !== undefined && (!isChatTurn(request) || !isBoardFolderName(request.boardFolder))) {
242
+ found.note(
243
+ "boardFolder, when present, is on a chat turn and is one folder name: at most 80 characters, no slashes, no leading dot, none of < > : \" | ? *.",
244
+ );
245
+ }
246
+ // WHICH IDEAS THIS CAME FROM. Optional, because a hand-written request
247
+ // from the CLI has no ideas behind it and refusing one would make the
248
+ // terminal path unusable. Validated when present, because a malformed id
249
+ // reaching the board is a proposal pointing at nothing.
250
+ if (request.nodeIds !== undefined) {
251
+ if (!Array.isArray(request.nodeIds) || request.nodeIds.some((n) => typeof n !== "string" || n.trim() === "")) {
252
+ found.note(
253
+ "nodeIds, when present, records which board ideas the run came from and must be an array of non-empty strings. It is how a session comes home to the idea that asked for it.",
254
+ );
255
+ }
256
+ }
257
+ // WHICH ENGINE. Optional - absent means the runner's default, so every
258
+ // request filed before engines were plural still runs. Validated as a
259
+ // string when present; an unknown NAME is not refused here, because the
260
+ // runner falls back to the default and says which ran rather than dying
261
+ // while holding a claim on a request nobody will retry.
262
+ if (request.agent !== undefined && (typeof request.agent !== "string" || request.agent.trim() === "")) {
263
+ found.note(
264
+ "agent, when present, names which engine runs this session and must be a non-empty string.",
265
+ );
266
+ }
267
+ // WHICH MODEL, and HOW HARD TO THINK. Both optional, both free-form: the
268
+ // vocabulary is the engine's, not ours - Claude Code takes aliases or full
269
+ // names, opencode takes provider/model and its own variant names - and a
270
+ // list here would go stale the week either of them adds one.
271
+ for (const field of ["model", "effort"]) {
272
+ if (request[field] !== undefined && (typeof request[field] !== "string" || request[field].trim() === "")) {
273
+ found.note(
274
+ `${field}, when present, must be a non-empty string. Its vocabulary belongs to the engine that runs the session, so it is passed through rather than checked against a list.`,
275
+ );
276
+ }
277
+ }
278
+ if (request.useWorktree !== undefined && typeof request.useWorktree !== "boolean") {
279
+ found.note(
280
+ "useWorktree, when present, must be a boolean. Set to false to run in the linked checkout instead of an isolated worktree.",
281
+ );
282
+ }
283
+ if (request.webSearch !== undefined && typeof request.webSearch !== "boolean") {
284
+ found.note(
285
+ "webSearch, when present, must be a boolean. True lets a run on this computer search and read the web without asking first.",
286
+ );
287
+ }
288
+ if (request.computerApps !== undefined && typeof request.computerApps !== "boolean") {
289
+ found.note(
290
+ "computerApps, when present, must be a boolean. True lets this run use the apps the person set up in Claude Code or Codex.",
291
+ );
292
+ }
293
+ // PERSISTENT SESSIONS: session id fields are UUIDs, worktreePath is an
294
+ // absolute path. All are optional. Validated as strings when present.
295
+ for (const field of ["sessionId", "resumeSessionId"]) {
296
+ if (request[field] !== undefined && (typeof request[field] !== "string" || request[field].trim() === "")) {
297
+ found.note(
298
+ `${field}, when present, must be a non-empty string (a UUID). It controls whether the session resumes or starts fresh.`,
299
+ );
300
+ }
301
+ }
302
+ if (request.worktreePath !== undefined && (typeof request.worktreePath !== "string" || request.worktreePath.trim() === "")) {
303
+ found.note(
304
+ "worktreePath, when present, must be a non-empty string (an absolute path to a git worktree to reuse for session resume).",
305
+ );
306
+ }
307
+ if (request.maxMs !== undefined) {
308
+ if (typeof request.maxMs !== "number" || !Number.isFinite(request.maxMs) || request.maxMs <= 0) {
309
+ found.note(`maxMs, when present, is this session's wall clock in milliseconds and must be a positive number, at most ${MAX_WALL_MS}.`);
310
+ } else if (request.maxMs > MAX_WALL_MS) {
311
+ found.note(
312
+ `maxMs is ${request.maxMs}, and the longest a session runs is ${MAX_WALL_MS} (${MAX_WALL_MS / 3_600_000}h). A session that hangs bills until it is killed, so no request runs longer.`,
313
+ );
314
+ }
315
+ }
316
+ return found.list;
317
+ }
318
+
319
+ /** The wall clock this request actually gets, held to MAX_WALL_MS. */
320
+ export const wallClockFor = (request) =>
321
+ typeof request?.maxMs === "number" && request.maxMs > 0
322
+ ? Math.min(request.maxMs, MAX_WALL_MS)
323
+ : DEFAULT_WALL_MS;
324
+
325
+ /** The title this request's idea gets: its own, or the prompt's first line. */
326
+ export function titleFor(request) {
327
+ if (typeof request?.title === "string" && request.title.trim() !== "") return request.title.trim();
328
+ const first = String(request?.prompt ?? "").replace(/\s+/g, " ").trim();
329
+ return first.length > 60 ? `${first.slice(0, 57)}...` : first || "Coding session";
330
+ }
package/lib/sweep.mjs ADDED
@@ -0,0 +1,161 @@
1
+ // WHAT THE FILE TRANSPORT LEAVES BEHIND, and nobody needs any more.
2
+ //
3
+ // Ruling 5 (2026-09-06): files stay the transport, with two duties added to
4
+ // the daemon. This is the first - a sweep on start and once a day. The second
5
+ // is lib/sync.mjs.
6
+ //
7
+ // THE NEVER-TOUCH LIST IS THE POINT OF THIS FILE, so it is spelled first:
8
+ //
9
+ // - the mark file (.glytch/inbox/spawn/.runner.json) - the app reads it
10
+ // every tick to decide whether a runner is watching. It is a dotfile and
11
+ // no id-prefixed rule can reach it, and this module never lists it;
12
+ // - the mirror (.glytch/canvas/) - written by the browser, read by every
13
+ // session. Nothing here goes near that directory;
14
+ // - .glytch/inbox/ops/ and the top-level inbox files - ops the app has not
15
+ // ingested yet. A swept op is a proposal that never reached the user, so
16
+ // the sweep does not have a rule for them at all: it only ever reads
17
+ // .glytch/inbox/spawn/;
18
+ // - an UNFINISHED request. "Finished" here means a terminal receipt exists
19
+ // (<id>.result.txt, the one spawn.mjs writes exactly once). A request
20
+ // that is filed and waiting, claimed and running, or claimed by a runner
21
+ // that died, has no receipt and is never touched however old it is - a
22
+ // leaked claim is released by a person (`release`), by the ruling, and a
23
+ // sweep that guessed would be the reaper that ruling refused;
24
+ // - EVERY WORKTREE, and CHAT WORKTREES BY NAME (added 2026-09-07, brief 25,
25
+ // ruling 1: "one long-lived chat worktree per agent per folder, made
26
+ // once and kept"). This module has never removed a directory under
27
+ // `.claude/worktrees/` - it reads `.glytch/inbox/spawn/` and nothing else,
28
+ // so a 14-day-old chat turn loses its request FILES and the agent keeps
29
+ // the tree its conversation lives in. That is the correct behaviour and
30
+ // is now pinned by a test (`bridge/test/chatturn.test.mjs`, "the sweep
31
+ // takes a chat turn's old files and leaves its worktree alone"), because
32
+ // it is currently true by omission: a stale-worktree rule added here
33
+ // later must skip `<root>/.claude/worktrees/chat-*` (isChatWorktreePath,
34
+ // lib/worktree.mjs). A chat tree is removed when the AGENT is deleted,
35
+ // and by nothing else.
36
+ //
37
+ // THE AGE IS MEASURED FROM THE RECEIPT, not from the request: a request filed
38
+ // in June and finished yesterday is a session whose log the app may still be
39
+ // showing. 14 days by default, which is well past the point where a session
40
+ // frame is history rather than work in progress.
41
+ //
42
+ // THE DETACHED DEV SERVERS ARE NOT THIS FILE'S JOB ANY MORE. From 2026-09-06
43
+ // to brief 30 it read a `<id>.server` mirror beside the receipt and stopped
44
+ // the process it named; brief 30 moved that record onto the run row and the
45
+ // mirror stopped being written, which left this loop reading nothing for a
46
+ // week. lib/serverSweep.mjs stops them from rows, off the runner's own beat.
47
+
48
+ import { access, readdir, rm, stat } from "node:fs/promises";
49
+ import { join } from "node:path";
50
+ import { LOG_KEEP, staleRotations } from "./logs.mjs";
51
+ import { pruneMissing } from "./registry.mjs";
52
+ import { RECEIPT_SUFFIX, SPAWN_DIR, listRequests } from "./spawn.mjs";
53
+
54
+ export const SWEEP_AGE_MS = 14 * 86_400_000;
55
+
56
+ const mtimeOf = async (path) => {
57
+ try {
58
+ return (await stat(path)).mtimeMs;
59
+ } catch {
60
+ return null;
61
+ }
62
+ };
63
+
64
+ const exists = async (path) => {
65
+ try {
66
+ await access(path);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ };
72
+
73
+ /** Sweep one folder's spawn channel. Returns {removed, kept, skipped} - names,
74
+ * not paths, so a log line is readable. Never throws: a folder that vanished
75
+ * mid-sweep is one of the states this whole feature exists to survive.
76
+ *
77
+ * A stopped dev server is reported through `log` and not in the return value:
78
+ * the shape of this result is asserted whole by bridge/test/sweep.test.mjs
79
+ * ("a folder with no spawn channel at all sweeps to nothing, quietly"), and a
80
+ * new field would have meant editing a test to add a feature.
81
+ *
82
+ * `stopServer` is injected so the decision to stop can be tested without a
83
+ * process table under it; the default is the real one. */
84
+ export async function sweepFolder(root, { now = Date.now(), ageMs = SWEEP_AGE_MS, log = () => {} } = {}) {
85
+ const dir = join(root, SPAWN_DIR);
86
+ let names;
87
+ try {
88
+ names = await readdir(dir);
89
+ } catch {
90
+ return { removed: [], kept: [], skipped: [] };
91
+ }
92
+ const removed = [];
93
+ const kept = [];
94
+ const skipped = [];
95
+ const sweeping = new Map();
96
+ for (const id of await listRequests(root)) {
97
+ const finishedAt = await mtimeOf(join(dir, `${id}${RECEIPT_SUFFIX}`));
98
+ if (finishedAt === null) {
99
+ // No receipt: not finished. Never touched, whatever its age.
100
+ skipped.push(id);
101
+ continue;
102
+ }
103
+ if (now - finishedAt <= ageMs) {
104
+ kept.push(id);
105
+ continue;
106
+ }
107
+ sweeping.set(id, finishedAt);
108
+ }
109
+
110
+ for (const [id, finishedAt] of sweeping) {
111
+ // Everything this request owns, and nothing else. Request ids carry no
112
+ // dot, so `${id}.` cannot reach another request's files, and the leading
113
+ // dot of `.runner.json` puts the mark file out of reach by construction.
114
+ for (const name of names.filter((n) => n.startsWith(`${id}.`))) {
115
+ try {
116
+ await rm(join(dir, name), { force: true });
117
+ removed.push(name);
118
+ } catch {
119
+ /* a file someone else is holding is left alone */
120
+ }
121
+ }
122
+ log(`swept ${id}: finished ${Math.round((now - finishedAt) / 86_400_000)} days ago`);
123
+ }
124
+ return { removed, kept, skipped };
125
+ }
126
+
127
+ /** The whole sweep: every registered folder, the rotated logs past the keep
128
+ * count, and the registry entries whose folder is gone.
129
+ *
130
+ * A dropped registry entry gets ONE LOG LINE and never an exit - a project
131
+ * folder that was moved or deleted is an ordinary thing for a machine-wide
132
+ * daemon to meet, and taking the daemon down over it would stop every other
133
+ * folder too. */
134
+ export async function sweepAll({
135
+ env = process.env,
136
+ now = Date.now(),
137
+ ageMs = SWEEP_AGE_MS,
138
+ keep = LOG_KEEP,
139
+ log = () => {},
140
+ } = {}) {
141
+ const { kept: folders, dropped } = await pruneMissing(env);
142
+ for (const gone of dropped) log(`forgetting ${gone}: the folder no longer exists`);
143
+
144
+ const swept = [];
145
+ for (const folder of folders) {
146
+ const result = await sweepFolder(folder, { now, ageMs, log: (line) => log(`[${folder}] ${line}`) });
147
+ if (result.removed.length > 0) swept.push({ folder, removed: result.removed.length });
148
+ }
149
+
150
+ const logs = [];
151
+ for (const path of await staleRotations({ keep, env })) {
152
+ try {
153
+ await rm(path, { force: true });
154
+ logs.push(path);
155
+ log(`removed rotated log past keep ${keep}: ${path}`);
156
+ } catch {
157
+ /* leave it */
158
+ }
159
+ }
160
+ return { folders, dropped, swept, logs };
161
+ }