@kky42/pievo-server 3.0.0 → 4.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 (45) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/MarkdownView-C9-AzIkl.js +66 -0
  3. package/.output/public/assets/{actionUi-BZrLpgHT.js → actionUi-D47jwMfh.js} +1 -1
  4. package/.output/public/assets/app-C5xfpeJe.css +2 -0
  5. package/.output/public/assets/index-BgNjFXWE.js +10 -0
  6. package/.output/public/assets/loops._loopId-Bo63FXPQ.js +2 -0
  7. package/.output/public/assets/loops._loopId_.runs._runId-DW2HPjtj.js +3 -0
  8. package/.output/public/assets/{preload-helper-hRTYB_ju.js → preload-helper-CbYqBpY9.js} +2 -2
  9. package/.output/public/assets/routes-C88oSApK.js +1 -0
  10. package/.output/public/assets/routes-CTCh8xYm.js +1 -0
  11. package/.output/server/_ssr/{MachinesModal-CmVMec6B.mjs → MachinesModal-DwHy2cis.mjs} +44 -25
  12. package/.output/server/_ssr/MarkdownView-BOe75tli.mjs +67 -0
  13. package/.output/server/_ssr/{actionUi-DE-Pb2tv.mjs → actionUi-CszQXDSN.mjs} +20 -2
  14. package/.output/server/_ssr/artifactFiles-DJiK99sF.mjs +67 -0
  15. package/.output/server/_ssr/{artifactKind-DqpmtxDU.mjs → artifactKind-DYMU0q3N.mjs} +2 -7
  16. package/.output/server/_ssr/{boot-CYRi-wez.mjs → boot-CecCDRoH.mjs} +950 -968
  17. package/.output/server/_ssr/{lifecycleUi-BsbUsi5K.mjs → lifecycleUi-Bd7_sthp.mjs} +12 -19
  18. package/.output/server/_ssr/{loopApi-Bez1rHV0.mjs → loopApi-uEiBQ5YX.mjs} +17 -23
  19. package/.output/server/_ssr/{loopProjection-esCjyZJg.mjs → loopProjection-Bo54jjGd.mjs} +5 -3
  20. package/.output/server/_ssr/{loops._loopId-KUezNY69.mjs → loops._loopId-c1oRP7cr.mjs} +37 -117
  21. package/.output/server/_ssr/{loops._loopId_.runs._runId-DOkUEesG.mjs → loops._loopId_.runs._runId-Dl0SwOlC.mjs} +43 -23
  22. package/.output/server/_ssr/loops._loopId_.runs._runId-czMVvNsk.mjs +11 -0
  23. package/.output/server/_ssr/{machineFns-CniGcY8s.mjs → machineFns-aw9xe5oz.mjs} +6 -20
  24. package/.output/server/_ssr/{router-BFIH2UAE.mjs → router-3OQ849sN.mjs} +96 -112
  25. package/.output/server/_ssr/{routes-BSVBANI3.mjs → routes-CL4y6AEv.mjs} +2 -2
  26. package/.output/server/_ssr/{routes-DnE7XDe_.mjs → routes-CT5IdoiP.mjs} +6 -6
  27. package/.output/server/_ssr/{routes-ngpX7h8D.mjs → routes-nU2PDK3W.mjs} +2 -2
  28. package/.output/server/_ssr/ssr.mjs +22 -22
  29. package/.output/server/{_tanstack-start-manifest_v-DnwAupbZ.mjs → _tanstack-start-manifest_v-_nMS2ozp.mjs} +9 -10
  30. package/.output/server/drizzle/0001_loop_files.sql +9 -0
  31. package/.output/server/drizzle/0002_run_attention.sql +1 -0
  32. package/.output/server/drizzle/meta/_journal.json +15 -1
  33. package/.output/server/index.mjs +52 -45
  34. package/drizzle/0001_loop_files.sql +9 -0
  35. package/drizzle/0002_run_attention.sql +1 -0
  36. package/drizzle/meta/_journal.json +15 -1
  37. package/package.json +1 -1
  38. package/.output/public/assets/app-DXLuQNb9.css +0 -2
  39. package/.output/public/assets/index-Di_wpAtL.js +0 -10
  40. package/.output/public/assets/loops._loopId-X3ClNPMY.js +0 -67
  41. package/.output/public/assets/loops._loopId_.runs._runId-ByU2BSyF.js +0 -3
  42. package/.output/public/assets/routes-BIOJZsig.js +0 -1
  43. package/.output/public/assets/routes-K8D5uBiV.js +0 -1
  44. package/.output/server/_ssr/artifactFiles-Di1xDKvX.mjs +0 -57
  45. package/.output/server/_ssr/loops._loopId_.runs._runId-BFP9oMrp.mjs +0 -16
@@ -11,114 +11,118 @@ import { fileURLToPath } from "node:url";
11
11
  import path from "node:path";
12
12
  import os from "node:os";
13
13
  import fs$1 from "node:fs/promises";
14
- //#region node_modules/.nitro/vite/services/ssr/assets/loopTags-C6vATFCp.js
15
- var loopTags_C6vATFCp_exports = /* @__PURE__ */ __exportAll$1({
16
- $: () => lastRun,
17
- A: () => buildLoopManifest,
18
- At: () => runningRunForLoop,
19
- B: () => forceDeleteLoop,
20
- Bt: () => upsertConfiguredArtifactFile,
21
- C: () => acknowledgeRetiredReport,
22
- Ct: () => recordRunReportOnce,
23
- D: () => blobExists,
24
- Dt: () => repairTerminalRunLeases,
25
- E: () => artifactFileReferencesHash,
26
- Et: () => releaseAbsentReconciliations,
27
- F: () => createMachine,
28
- Ft: () => stopLoop,
29
- G: () => getMachine,
30
- Gt: () => CODING_AGENTS,
31
- H: () => getArtifactFile,
32
- Ht: () => db,
33
- I: () => deleteBlob,
34
- It: () => store_exports,
35
- J: () => getRunSnapshot,
36
- Jt: () => logger_exports,
37
- K: () => getReportReceipt,
38
- Kt: () => coerceCodingAgent,
39
- L: () => enqueueRun,
40
- Lt: () => tryDeleteLoop,
41
- M: () => countRunSnapshots,
42
- Mt: () => setMachineOnline,
43
- N: () => countRuns,
44
- Nt: () => snapshotBlobRefs,
45
- O: () => blobHashesOlderThan,
46
- Ot: () => requestDeleteLoop,
47
- P: () => createLoop,
48
- Pt: () => startLoop,
49
- Q: () => initializeCronCadence,
50
- Qt: () => __exportAll,
51
- R: () => expirePendingRun,
52
- Rt: () => updateLoop,
53
- S: () => sha256Buf,
54
- St: () => recordBlob,
55
- T: () => advanceDueSchedules,
56
- Tt: () => rejectTerminalReport,
57
- U: () => getExactTerminalReportIncident,
58
- Ut: () => runMigrations,
59
- V: () => forceDeleteMachine,
60
- Vt: () => closeClient,
61
- W: () => getLoop,
62
- Wt: () => runs,
63
- X: () => hasPendingRun,
64
- Xt: () => GC_INTERVAL_MS,
65
- Y: () => getTerminalReportIncidents,
66
- Yt: () => BLOB_GC_GRACE_MS,
67
- Z: () => hasRunningRun,
68
- Zt: () => dataDir,
69
- _: () => BLOB_CAP,
70
- _t: () => recentLoopUsage,
14
+ //#region node_modules/.nitro/vite/services/ssr/assets/loopTags-Bi7Bm7v8.js
15
+ var loopTags_Bi7Bm7v8_exports = /* @__PURE__ */ __exportAll$1({
16
+ $: () => loopIdsWithSnapshots,
17
+ $t: () => logger_exports,
18
+ A: () => enqueueRun,
19
+ At: () => updateMachine,
20
+ B: () => getRun,
21
+ Bt: () => loopFileIssue,
22
+ C: () => buildLoopManifest,
23
+ Ct: () => setMachineOnline,
24
+ D: () => createLoop,
25
+ Dt: () => store_exports,
26
+ E: () => countRuns,
27
+ Et: () => stopLoop,
28
+ F: () => getArtifactFile,
29
+ Ft: () => LoopFiles,
30
+ G: () => initializeCronCadence,
31
+ Gt: () => createBlobStore,
32
+ H: () => getTerminalReportIncidents,
33
+ Ht: () => loopFiles_exports,
34
+ I: () => getExactTerminalReportIncident,
35
+ It: () => MEMORY_CHAR_CAP,
36
+ J: () => listLoops,
37
+ Jt: () => runMigrations,
38
+ K: () => lastRun,
39
+ Kt: () => closeClient,
40
+ L: () => getLoop,
41
+ Lt: () => MEMORY_FILE,
42
+ M: () => finalizeRunningRun,
43
+ Mt: () => ARTIFACT_EXTENSIONS,
44
+ N: () => forceDeleteLoop,
45
+ Nt: () => EMPTY_SHA256,
46
+ O: () => createMachine,
47
+ Ot: () => tryDeleteLoop,
48
+ P: () => forceDeleteMachine,
49
+ Pt: () => LOOP_FILE_BYTE_CAP,
50
+ Q: () => liveBlobRefs,
51
+ Qt: () => logger,
52
+ R: () => getMachine,
53
+ Rt: () => fileLimitLabel,
54
+ S: () => blobSizes,
55
+ St: () => runningRunsForMachine,
56
+ T: () => countRunSnapshots,
57
+ Tt: () => startLoop,
58
+ U: () => hasPendingRun,
59
+ Ut: () => sha256Buf,
60
+ V: () => getRunSnapshot,
61
+ Vt: () => loopFileNames,
62
+ W: () => hasRunningRun,
63
+ Wt: () => validArtifactName,
64
+ X: () => listRuns,
65
+ Xt: () => CODING_AGENTS,
66
+ Y: () => listMachines,
67
+ Yt: () => runs,
68
+ Z: () => listRunsBefore,
69
+ Zt: () => coerceCodingAgent,
70
+ _: () => acknowledgeRetiredReport,
71
+ _t: () => releaseAbsentReconciliations,
71
72
  a: () => TERMINAL_GRACE_MS,
72
- at: () => liveBlobRefs,
73
- b: () => parseArtifactSyncBody,
74
- bt: () => reconcileReclaimedRun,
73
+ at: () => previousRunFileNotices,
74
+ b: () => artifactFileReferencesHash,
75
+ bt: () => requestRunCancel,
75
76
  c: () => machineIdFromToken,
76
- ct: () => machineReferencesBlob,
77
+ ct: () => putRunSnapshot,
77
78
  d: () => readNewIdempotency,
78
- dt: () => pauseLoopState,
79
- et: () => listArtifacts,
79
+ dt: () => reclaimRun,
80
+ en: () => BLOB_GC_GRACE_MS,
81
+ et: () => loopsForMachine,
80
82
  f: () => recordNewIdempotency,
81
- ft: () => prevRunSnapshot,
83
+ ft: () => reconcileReclaimedRun,
82
84
  g: () => tokens_exports,
83
- gt: () => putTerminalReportIncidentIfAbsent,
85
+ gt: () => rejectTerminalReport,
84
86
  h: () => sha256,
85
- ht: () => putRunSnapshot,
87
+ ht: () => refreshRunHeartbeats,
86
88
  i: () => machinePresence,
87
- it: () => listRunsBefore,
88
- j: () => claimReadyRunForMachine,
89
- jt: () => runningRunsForMachine,
90
- k: () => blobSizes,
91
- kt: () => requestRunCancel,
89
+ it: () => prevRunSnapshot,
90
+ j: () => expirePendingRun,
91
+ jt: () => writeLoopFile,
92
+ k: () => deleteBlob,
93
+ kt: () => updateLoop,
92
94
  l: () => mintDeviceToken,
93
- lt: () => openRuns,
95
+ lt: () => putTerminalReportIncidentIfAbsent,
94
96
  m: () => retireLease,
95
- mt: () => putReportReceiptIfAbsent,
97
+ mt: () => recordBlob,
96
98
  n: () => isOutdated,
97
- nt: () => listMachines,
99
+ nn: () => __exportAll,
100
+ nt: () => pauseLoop,
98
101
  o: () => connectKeyValid,
99
- ot: () => loopIdsWithSnapshots,
102
+ ot: () => pruneRunSnapshots,
100
103
  p: () => resolveLease,
101
- pt: () => pruneRunSnapshots,
102
- q: () => getRun,
103
- qt: () => logger,
104
+ pt: () => reconciliationStatesForRuns,
105
+ q: () => listArtifacts,
106
+ qt: () => db,
104
107
  r: () => isValidSemver,
105
- rt: () => listRuns,
108
+ rt: () => pauseLoopState,
106
109
  s: () => isDeviceTokenShape,
107
- st: () => loopsForMachine,
110
+ st: () => putReportReceiptIfAbsent,
108
111
  t: () => validateLoopTags,
109
- tt: () => listLoops,
112
+ tn: () => GC_INTERVAL_MS,
113
+ tt: () => openRuns,
110
114
  u: () => pruneExpiredLeases,
111
- ut: () => pauseLoop,
112
- v: () => artifacts_exports,
113
- vt: () => reclaimRun,
114
- w: () => acknowledgeRetiredTerminalIncident,
115
- wt: () => refreshRunHeartbeats,
116
- x: () => safeRelPath,
117
- xt: () => reconciliationStatesForRuns,
118
- y: () => isValidHash,
119
- yt: () => reconcileConfiguredArtifacts,
120
- z: () => finalizeRunningRun,
121
- zt: () => updateMachine
115
+ ut: () => recentLoopUsage,
116
+ v: () => acknowledgeRetiredTerminalIncident,
117
+ vt: () => repairTerminalRunLeases,
118
+ w: () => claimReadyRunForMachine,
119
+ wt: () => snapshotBlobRefs,
120
+ x: () => blobHashesOlderThan,
121
+ xt: () => runningRunForLoop,
122
+ y: () => advanceDueSchedules,
123
+ yt: () => requestDeleteLoop,
124
+ z: () => getReportReceipt,
125
+ zt: () => isValidHash
122
126
  });
123
127
  var import_pino = /* @__PURE__ */ __toESM(require_pino());
124
128
  var import_pino_pretty = /* @__PURE__ */ __toESM(require_pino_pretty());
@@ -133,7 +137,7 @@ var __exportAll = (all, no_symbols) => {
133
137
  return target;
134
138
  };
135
139
  /**
136
- * Server data directory: the embedded database (`pgdata/`), artifact bytes
140
+ * Server data directory: the embedded database (`pgdata/`), loop-file bytes
137
141
  * (`blobs/`), and the launcher's pid/lock/log files. Kept apart from the
138
142
  * daemon's `~/.pievo` so a device running both never mixes their state.
139
143
  * Override with `PIEVO_DATA_DIR` (absolute; `~` is not expanded).
@@ -150,9 +154,9 @@ var BLOB_GC_GRACE_MS = 3600 * 1e3;
150
154
  /** Storage-maintenance cadence (prune snapshots, then GC blobs). 15 minutes. */
151
155
  var GC_INTERVAL_MS = 900 * 1e3;
152
156
  /**
153
- * Uses pino-pretty as a *synchronous* destination stream (not a worker-thread
154
- * transport) so logs flush before process.exit() in the one-shot CLIs. Writes to
155
- * stderr (fd 2) to keep stdout clean for QR codes and human CLI output.
157
+ * pino-pretty as a synchronous destination stream (not a worker-thread transport)
158
+ * so logs flush before process.exit() in the one-shot CLIs. Writes to stderr so
159
+ * stdout carries only CLI output.
156
160
  */
157
161
  var logger_exports = /* @__PURE__ */ __exportAll({ logger: () => logger });
158
162
  var level = (process.env.PIEVO_LOG_LEVEL || "info").toLowerCase();
@@ -163,17 +167,9 @@ var stream = (0, import_pino_pretty.default)({
163
167
  ignore: "pid,hostname"
164
168
  });
165
169
  var logger = (0, import_pino.default)({ level }, stream);
166
- /** The coding agent a loop is bound to AND executed with (BYOA on the owner's
167
- * machine): `claude-code` → Claude Code, `codex` → `codex exec`, `pi` → Pi.
168
- * Non-Claude agents may still have thinner daemon telemetry until stream
169
- * integration lands; execution itself is real for every value.
170
- *
171
- * Runtime SINGLE SOURCE (anti-drift): every server consumer DERIVES from this
172
- * array — the `CodingAgent` type here, the `db/schema.ts` `CodingAgent` type AND
173
- * the `loops.agent` column enum, the edit validator (`coerceCodingAgent`), and the
174
- * web agent `<select>` (LoopForm). So widening the set is a one-line edit HERE with
175
- * no other server change (the daemon's own enum in `packages/daemon/src/create.ts`
176
- * is a separate package, widened alongside). */
170
+ /** Single source for coding agents: server types, the `loops.agent` column enum,
171
+ * validators, and the dashboard select all derive from this array. The daemon
172
+ * keeps its own copy in `packages/daemon/src/create.ts`; widen both together. */
177
173
  var CODING_AGENTS = [
178
174
  "claude-code",
179
175
  "codex",
@@ -193,10 +189,6 @@ var RESERVED_LOOP_TAGS = [
193
189
  * Pievo is single-user: no row carries an owner. Timestamp values are ISO
194
190
  * strings; JSON columns use `jsonb().$type<>()`.
195
191
  */
196
- /** `skipped`: a pending run retired without executing (pause/retention cleanup,
197
- * queue coalescing, or the offline catch-up window elapsed).
198
- * Neither success nor failure — excluded from the failure streak (it rides
199
- * phase `canceled`, and the streak counts only phase `error`). */
200
192
  var machines = pgTable("machines", {
201
193
  id: text("id").primaryKey(),
202
194
  name: text("name").notNull(),
@@ -228,7 +220,8 @@ var loops = pgTable("loops", {
228
220
  statusKeep: text("status_keep").notNull(),
229
221
  statusNoChange: text("status_no_change").notNull(),
230
222
  statusBlock: text("status_block").notNull(),
231
- /** Exact workdir-relative artifact paths collected by the daemon after provider exit. */
223
+ /** Configured artifact names, in addition to the always-present MEMORY.md.
224
+ * See `gateway/loopFiles.ts`. */
232
225
  artifacts: jsonb("artifacts").$type().notNull().default([]),
233
226
  cron: text("cron").notNull(),
234
227
  scheduleMode: text("schedule_mode", { enum: ["cron", "continuous"] }).notNull(),
@@ -245,11 +238,8 @@ var loops = pgTable("loops", {
245
238
  pauseCause: jsonb("pause_cause").$type(),
246
239
  model: text("model"),
247
240
  reasoningEffort: text("reasoning_effort"),
248
- /** Coding agent this loop is BOUND TO and EXECUTED with: the daemon's
249
- * `buildAgentSpawn` branches on this value (`claude-code` → claude,
250
- * `codex` → `codex exec`, `pi` → Pi print mode). Measured from the creating CLI's env when
251
- * detectable, else the declared/selected value. The text column carries
252
- * both a TypeScript enum and a database CHECK constraint. */
241
+ /** The daemon launches this agent for every run (`buildAgentSpawn`). Enforced by
242
+ * both the TypeScript enum and a database CHECK constraint. */
253
243
  agent: text("agent", { enum: CODING_AGENTS }).notNull(),
254
244
  enabled: boolean("enabled").notNull().default(true),
255
245
  nextRunAt: text("next_run_at"),
@@ -277,6 +267,9 @@ var runs = pgTable("runs", {
277
267
  model: text("model"),
278
268
  reasoningEffort: text("reasoning_effort"),
279
269
  runIndex: integer("run_index"),
270
+ /** A pending run retired without executing (pause, retention cleanup, queue
271
+ * coalescing, or an elapsed offline catch-up window) is `canceled`, not `error`,
272
+ * so it never counts toward the failure streak. */
280
273
  phase: text("phase", { enum: [
281
274
  "pending",
282
275
  "running",
@@ -296,6 +289,9 @@ var runs = pgTable("runs", {
296
289
  "block"
297
290
  ] }),
298
291
  message: text("message"),
292
+ /** Owner-facing reason from the final response's ATTENTION line. Independent
293
+ * of status: it never pauses the loop. */
294
+ attention: text("attention"),
299
295
  durationMs: integer("duration_ms"),
300
296
  exitCode: integer("exit_code"),
301
297
  finalText: text("final_text"),
@@ -308,7 +304,11 @@ var runs = pgTable("runs", {
308
304
  mode: "string"
309
305
  }),
310
306
  heartbeatAt: text("heartbeat_at"),
311
- reportIncident: jsonb("report_incident").$type()
307
+ reportIncident: jsonb("report_incident").$type(),
308
+ /** Loop-file versions this run produced but that were not kept (over limit,
309
+ * owner edited during the run, upload lost). Shown on the run and repeated in
310
+ * the next run's prompt so the agent can correct course. */
311
+ fileNotices: jsonb("file_notices").$type()
312
312
  }, (t) => [
313
313
  index("runs_loop_idx").on(t.loopId),
314
314
  index("runs_phase_idx").on(t.phase),
@@ -387,21 +387,22 @@ var blobs = pgTable("blobs", {
387
387
  createdAt: text("created_at").notNull()
388
388
  });
389
389
  /**
390
- * The current configured file set of each loop — one row per live or tombstoned
391
- * relative path. `hash` → `blobs.hash`; null when the file is deleted or oversize
392
- * (metadata-only, no bytes synced). The
393
- * unique (loopId, path) index is the upsert key the sync reconciliation drives.
390
+ * The current file set of each loop — one row per live or tombstoned name.
391
+ * `hash` → `blobs.hash`; null for a tombstoned (deleted or unconfigured) file.
392
+ * The unique (loopId, path) index is the upsert key owner writes and run
393
+ * finalization share.
394
394
  */
395
395
  var artifactFiles = pgTable("artifact_files", {
396
396
  id: text("id").primaryKey(),
397
397
  loopId: text("loop_id").notNull(),
398
- /** Normalized workdir-relative path (never absolute or escaping the workdir). */
399
398
  path: text("path").notNull(),
400
399
  hash: text("hash"),
401
400
  size: integer("size"),
402
- /** Bytes contain a NUL (set even for oversize metadata-only files). */
401
+ /** Legacy snapshot/diff metadata. Loop-file ingress rejects NUL, so new rows
402
+ * always store false. */
403
403
  binary: boolean("binary").notNull().default(false),
404
- /** File exceeds the per-file byte cap → metadata-only (path + size), no blob. */
404
+ /** Legacy snapshot/diff metadata. Loop-file ingress rejects over-limit versions,
405
+ * so new rows always store false. */
405
406
  oversize: boolean("oversize").notNull().default(false),
406
407
  deleted: boolean("deleted").notNull().default(false),
407
408
  updatedAt: text("updated_at").notNull()
@@ -411,7 +412,7 @@ var artifactFiles = pgTable("artifact_files", {
411
412
  index("artifact_files_hash_idx").on(t.hash)
412
413
  ]);
413
414
  /**
414
- * The loop's full artifact manifest captured at each run's finalize — the input
415
+ * The loop's full loop-file manifest captured at each run's finalize — the input
415
416
  * to the per-run diff. Written cheaply on report (no diff computed on write);
416
417
  * `getRunDiff` lazily compares run N's snapshot with the preceding snapshot.
417
418
  * One row per run (runId primary key).
@@ -476,7 +477,6 @@ function migrationsDir() {
476
477
  if (fs.existsSync(builtDir)) return builtDir;
477
478
  return devDir;
478
479
  }
479
- /** Apply generated migrations in-process (idempotent). */
480
480
  async function runMigrations() {
481
481
  const dir = migrationsDir();
482
482
  if (!fs.existsSync(dir)) return;
@@ -496,16 +496,310 @@ async function closeClient() {
496
496
  } catch {}
497
497
  }
498
498
  /**
499
- * Data-access layer over Drizzle. Loop-locked state transitions live here; every
500
- * DB-touching function is async, while the pure helpers stay sync.
499
+ * Content-addressed blob byte store for loop files.
500
+ *
501
+ * Bytes are keyed by their sha256 hash (content-addressed ⇒ deduped across every
502
+ * loop/run). The store ONLY writes/reads bytes — it never executes or interprets
503
+ * them — preserving the server's zero-exec invariant.
504
+ *
505
+ * `LocalBlobStore` is the durable store under `<dataDir>/blobs`;
506
+ * `MemoryBlobStore` is the injected test fake.
507
+ */
508
+ var log$3 = logger.child({ mod: "blobstore" });
509
+ var SHA256_HEX = /^[a-f0-9]{64}$/;
510
+ /** Durable filesystem adapter. Hash sharding bounds entries per directory; atomic
511
+ * temp-file renames ensure readers see either complete bytes or no object. */
512
+ var LocalBlobStore = class {
513
+ root;
514
+ initialized;
515
+ constructor(root = path.join(dataDir(), "blobs")) {
516
+ this.root = root;
517
+ }
518
+ file(hash) {
519
+ if (!SHA256_HEX.test(hash)) throw new Error("invalid sha256 blob key");
520
+ return path.join(this.root, hash.slice(0, 2), hash);
521
+ }
522
+ async has(hash) {
523
+ const target = this.file(hash);
524
+ await this.ensureInitialized();
525
+ try {
526
+ return (await fs$1.stat(target)).isFile();
527
+ } catch (err) {
528
+ if (err.code === "ENOENT") return false;
529
+ throw err;
530
+ }
531
+ }
532
+ async syncDir(dir) {
533
+ let handle;
534
+ try {
535
+ handle = await fs$1.open(dir, "r");
536
+ await handle.sync();
537
+ } catch (err) {
538
+ const code = err.code;
539
+ if (![
540
+ "EINVAL",
541
+ "ENOTSUP",
542
+ "EBADF",
543
+ "EPERM",
544
+ "ENOENT"
545
+ ].includes(code)) throw err;
546
+ } finally {
547
+ await handle?.close().catch(() => {});
548
+ }
549
+ }
550
+ ensureInitialized() {
551
+ return this.initialized ??= (async () => {
552
+ await fs$1.mkdir(this.root, { recursive: true });
553
+ await this.syncDir(path.dirname(path.dirname(this.root)));
554
+ await this.syncDir(path.dirname(this.root));
555
+ await this.syncDir(this.root);
556
+ for (const shard of await fs$1.readdir(this.root, { withFileTypes: true })) {
557
+ if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
558
+ const dir = path.join(this.root, shard.name);
559
+ let removed = false;
560
+ for (const name of await fs$1.readdir(dir)) {
561
+ if (!/^\.[a-f0-9]{64}\.\d+\.[0-9a-f-]+\.tmp$/.test(name)) continue;
562
+ await fs$1.rm(path.join(dir, name), { force: true });
563
+ removed = true;
564
+ }
565
+ if (removed) await this.syncDir(dir);
566
+ }
567
+ })();
568
+ }
569
+ async put(hash, bytes) {
570
+ const target = this.file(hash);
571
+ await this.ensureInitialized();
572
+ const dir = path.dirname(target);
573
+ await fs$1.mkdir(dir, { recursive: true });
574
+ await this.syncDir(this.root);
575
+ const temp = path.join(dir, `.${hash}.${process.pid}.${randomUUID()}.tmp`);
576
+ let handle;
577
+ try {
578
+ handle = await fs$1.open(temp, "wx", 384);
579
+ await handle.writeFile(bytes);
580
+ await handle.sync();
581
+ await handle.close();
582
+ handle = void 0;
583
+ try {
584
+ await fs$1.rename(temp, target);
585
+ await this.syncDir(dir);
586
+ } catch (err) {
587
+ if ([
588
+ "EEXIST",
589
+ "EACCES",
590
+ "EPERM"
591
+ ].includes(err.code ?? "") && await this.has(hash)) {
592
+ await fs$1.rm(temp, { force: true });
593
+ return;
594
+ }
595
+ throw err;
596
+ }
597
+ } catch (err) {
598
+ await handle?.close().catch(() => {});
599
+ await fs$1.rm(temp, { force: true }).catch(() => {});
600
+ throw err;
601
+ }
602
+ }
603
+ async get(hash) {
604
+ const target = this.file(hash);
605
+ await this.ensureInitialized();
606
+ try {
607
+ return await fs$1.readFile(target);
608
+ } catch (err) {
609
+ if (err.code === "ENOENT") return null;
610
+ throw err;
611
+ }
612
+ }
613
+ async delete(hash) {
614
+ const target = this.file(hash);
615
+ await this.ensureInitialized();
616
+ await fs$1.rm(target, { force: true });
617
+ await this.syncDir(path.dirname(target));
618
+ }
619
+ };
620
+ /** The one local blob store shared by loop-file writes/reads and GC. */
621
+ function createBlobStore() {
622
+ const local = new LocalBlobStore();
623
+ log$3.info({ root: local.root }, "blob store: local filesystem");
624
+ return local;
625
+ }
626
+ /**
627
+ * Loop files: a loop's server-owned memory and display artifacts.
628
+ *
629
+ * Every loop has the required `MEMORY.md` plus up to MAX_ARTIFACTS configured
630
+ * text/code artifact names. The server is authoritative: bytes live in the
631
+ * content-addressed BlobStore and the current version of each name is an
632
+ * `artifact_files` row. For each run the daemon downloads the current files into
633
+ * a private run directory, and the terminal report carries the changed versions
634
+ * back with the hash each edit started from, so an owner edit made during the run
635
+ * wins over the agent's (base-hash check under the loop lock).
636
+ *
637
+ * Limits are part of the agent-facing prompt contract and are mirrored in the
638
+ * daemon (`packages/daemon/src/loop-files.ts`); change both together. An over-limit
639
+ * version is rejected and the previous version kept, never truncated, because a cut
640
+ * HTML file renders broken and a cut memory file silently loses its newest notes.
501
641
  */
642
+ var loopFiles_exports = /* @__PURE__ */ __exportAll({
643
+ ARTIFACT_BYTE_CAP: () => ARTIFACT_BYTE_CAP,
644
+ ARTIFACT_EXTENSIONS: () => ARTIFACT_EXTENSIONS,
645
+ EMPTY_SHA256: () => EMPTY_SHA256,
646
+ LOOP_FILE_BYTE_CAP: () => LOOP_FILE_BYTE_CAP,
647
+ LoopFiles: () => LoopFiles,
648
+ MAX_ARTIFACTS: () => 4,
649
+ MEMORY_CHAR_CAP: () => MEMORY_CHAR_CAP,
650
+ MEMORY_FILE: () => MEMORY_FILE,
651
+ fileLimitLabel: () => fileLimitLabel,
652
+ isValidHash: () => isValidHash,
653
+ loopFileIssue: () => loopFileIssue,
654
+ loopFileNames: () => loopFileNames,
655
+ sha256Buf: () => sha256Buf,
656
+ validArtifactName: () => validArtifactName
657
+ });
658
+ var MEMORY_FILE = "MEMORY.md";
659
+ /** Memory is re-read by every run, so it is bounded in characters (Unicode code
660
+ * points), roughly 2k tokens of prompt context per run. */
661
+ var MEMORY_CHAR_CAP = 8e3;
662
+ /** 256 KB bounds a run's upload and keeps dashboard rendering smooth. */
663
+ var ARTIFACT_BYTE_CAP = 256 * 1024;
664
+ var ARTIFACT_LIMIT_LABEL = `${ARTIFACT_BYTE_CAP / 1024} KB`;
665
+ /** sha256 of zero bytes: the version of a loop file that has never been written. */
666
+ var EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
667
+ /** Text/code formats accepted as artifacts. Loop files are NUL-free text, so
668
+ * binary formats (images, PDFs, archives) are deliberately absent; `.svg` is
669
+ * text and renders through the hardened inline image route. */
670
+ var ARTIFACT_EXTENSIONS = [
671
+ "md",
672
+ "markdown",
673
+ "mdx",
674
+ "html",
675
+ "htm",
676
+ "txt",
677
+ "text",
678
+ "rst",
679
+ "adoc",
680
+ "org",
681
+ "sh",
682
+ "bash",
683
+ "zsh",
684
+ "fish",
685
+ "ps1",
686
+ "bat",
687
+ "cmd",
688
+ "py",
689
+ "rb",
690
+ "pl",
691
+ "lua",
692
+ "r",
693
+ "js",
694
+ "mjs",
695
+ "cjs",
696
+ "ts",
697
+ "tsx",
698
+ "jsx",
699
+ "go",
700
+ "rs",
701
+ "java",
702
+ "kt",
703
+ "kts",
704
+ "scala",
705
+ "c",
706
+ "h",
707
+ "cc",
708
+ "cpp",
709
+ "hpp",
710
+ "cs",
711
+ "php",
712
+ "swift",
713
+ "dart",
714
+ "ex",
715
+ "exs",
716
+ "erl",
717
+ "hs",
718
+ "clj",
719
+ "cljs",
720
+ "json",
721
+ "jsonl",
722
+ "yaml",
723
+ "yml",
724
+ "toml",
725
+ "xml",
726
+ "csv",
727
+ "tsv",
728
+ "svg",
729
+ "css",
730
+ "scss",
731
+ "sass",
732
+ "less",
733
+ "sql",
734
+ "ini",
735
+ "conf",
736
+ "cfg",
737
+ "env",
738
+ "properties",
739
+ "diff",
740
+ "patch",
741
+ "tex",
742
+ "log"
743
+ ];
744
+ var ARTIFACT_EXTENSION_SET = new Set(ARTIFACT_EXTENSIONS);
745
+ var ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\.([A-Za-z0-9]{1,16})$/;
746
+ function sha256Buf(bytes) {
747
+ return createHash("sha256").update(bytes).digest("hex");
748
+ }
749
+ function isValidHash(hash) {
750
+ return typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash);
751
+ }
752
+ /** A plain text/code file name (no directories) other than MEMORY.md. */
753
+ function validArtifactName(name) {
754
+ if (typeof name !== "string" || name.toLowerCase() === "MEMORY.md".toLowerCase()) return false;
755
+ const match = ARTIFACT_NAME_RE.exec(name);
756
+ return match !== null && ARTIFACT_EXTENSION_SET.has(match[1].toLowerCase());
757
+ }
758
+ function loopFileNames(loop) {
759
+ return [MEMORY_FILE, ...loop.artifacts];
760
+ }
761
+ function fileLimitLabel(name) {
762
+ return name === "MEMORY.md" ? `${MEMORY_CHAR_CAP} characters` : ARTIFACT_LIMIT_LABEL;
763
+ }
764
+ /** Why `bytes` cannot become the next version of `name`, or undefined if it can. */
765
+ function loopFileIssue(name, bytes) {
766
+ if (bytes.includes(0)) return `${name}: binary content is not allowed`;
767
+ if (name === "MEMORY.md") {
768
+ const chars = Array.from(bytes.toString("utf8")).length;
769
+ if (chars > 8e3) return `${name}: ${chars} characters exceeds the ${MEMORY_CHAR_CAP}-character limit`;
770
+ return;
771
+ }
772
+ if (bytes.length > 262144) return `${name}: ${(bytes.length / 1024).toFixed(1)} KB exceeds the ${ARTIFACT_LIMIT_LABEL} limit`;
773
+ }
774
+ /** The largest byte size any loop file can have (a code point is at most 4 bytes). */
775
+ var LOOP_FILE_BYTE_CAP = Math.max(ARTIFACT_BYTE_CAP, MEMORY_CHAR_CAP * 4);
776
+ var LoopFiles = class {
777
+ blobStore;
778
+ constructor(blobStore = createBlobStore()) {
779
+ this.blobStore = blobStore;
780
+ }
781
+ readBlob(hash) {
782
+ if (!isValidHash(hash)) return Promise.resolve(null);
783
+ if (hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") return Promise.resolve(Buffer.alloc(0));
784
+ return this.blobStore.get(hash);
785
+ }
786
+ /** Persist bytes under their own hash. Idempotent; callers record the metadata row. */
787
+ async putBlob(bytes) {
788
+ const hash = sha256Buf(bytes);
789
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" && !await this.blobStore.has(hash)) await this.blobStore.put(hash, bytes);
790
+ return hash;
791
+ }
792
+ async hasBlob(hash) {
793
+ return hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" || this.blobStore.has(hash);
794
+ }
795
+ };
796
+ /** Data-access layer over Drizzle. Loop-locked state transitions live here. */
502
797
  var store_exports = /* @__PURE__ */ __exportAll({
503
798
  TERMINAL_REPORT_GRACE_MS: () => TERMINAL_REPORT_GRACE_MS,
504
799
  acknowledgeRetiredReport: () => acknowledgeRetiredReport,
505
800
  acknowledgeRetiredTerminalIncident: () => acknowledgeRetiredTerminalIncident,
506
801
  advanceDueSchedules: () => advanceDueSchedules,
507
802
  artifactFileReferencesHash: () => artifactFileReferencesHash,
508
- blobExists: () => blobExists,
509
803
  blobHashesOlderThan: () => blobHashesOlderThan,
510
804
  blobSizes: () => blobSizes,
511
805
  buildLoopManifest: () => buildLoopManifest,
@@ -540,24 +834,22 @@ var store_exports = /* @__PURE__ */ __exportAll({
540
834
  liveBlobRefs: () => liveBlobRefs,
541
835
  loopIdsWithSnapshots: () => loopIdsWithSnapshots,
542
836
  loopsForMachine: () => loopsForMachine,
543
- machineReferencesBlob: () => machineReferencesBlob,
544
837
  newLoopId: () => newLoopId,
545
838
  nextCronAt: () => nextCronAt,
546
839
  openRuns: () => openRuns,
547
840
  pauseLoop: () => pauseLoop,
548
841
  pauseLoopState: () => pauseLoopState,
549
842
  prevRunSnapshot: () => prevRunSnapshot,
843
+ previousRunFileNotices: () => previousRunFileNotices,
550
844
  pruneRunSnapshots: () => pruneRunSnapshots,
551
845
  putReportReceiptIfAbsent: () => putReportReceiptIfAbsent,
552
846
  putRunSnapshot: () => putRunSnapshot,
553
847
  putTerminalReportIncidentIfAbsent: () => putTerminalReportIncidentIfAbsent,
554
848
  recentLoopUsage: () => recentLoopUsage,
555
849
  reclaimRun: () => reclaimRun,
556
- reconcileConfiguredArtifacts: () => reconcileConfiguredArtifacts,
557
850
  reconcileReclaimedRun: () => reconcileReclaimedRun,
558
851
  reconciliationStatesForRuns: () => reconciliationStatesForRuns,
559
852
  recordBlob: () => recordBlob,
560
- recordRunReportOnce: () => recordRunReportOnce,
561
853
  refreshRunHeartbeats: () => refreshRunHeartbeats,
562
854
  rejectTerminalReport: () => rejectTerminalReport,
563
855
  releaseAbsentReconciliations: () => releaseAbsentReconciliations,
@@ -574,7 +866,7 @@ var store_exports = /* @__PURE__ */ __exportAll({
574
866
  tryDeleteLoop: () => tryDeleteLoop,
575
867
  updateLoop: () => updateLoop,
576
868
  updateMachine: () => updateMachine,
577
- upsertConfiguredArtifactFile: () => upsertConfiguredArtifactFile
869
+ writeLoopFile: () => writeLoopFile
578
870
  });
579
871
  function newLoopId() {
580
872
  return `loop-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
@@ -697,7 +989,7 @@ async function updateLoopTx(tx, current, patch, at) {
697
989
  }).where(eq(loops.id, current.id)).returning())[0];
698
990
  if (patch.enabled === false) await cancelPendingTx(tx, and(eq(runs.loopId, current.id), eq(runs.phase, "pending"), eq(runs.requestedBy, "system")), "canceled - loop paused before this system run was claimed", at);
699
991
  if (patch.artifacts !== void 0) {
700
- const allowed = new Set(patch.artifacts ?? []);
992
+ const allowed = new Set([MEMORY_FILE, ...patch.artifacts ?? []]);
701
993
  const removed = (await tx.select({
702
994
  id: artifactFiles.id,
703
995
  path: artifactFiles.path
@@ -1040,26 +1332,32 @@ async function activeRunForReportTx(tx, loopId, runId, leaseTokenHash) {
1040
1332
  run
1041
1333
  } : { state: "run-not-running" };
1042
1334
  }
1043
- /** Record the agent-authored status/message exactly once. The loop row is the
1044
- * serialization point, so sequential and concurrent duplicate callbacks both
1045
- * lose before any field can be overwritten. */
1046
- async function recordRunReportOnce(input) {
1047
- return db.transaction(async (tx) => {
1048
- if (!(await tx.select({ id: loops.id }).from(loops).where(eq(loops.id, input.loopId)).for("update"))[0]) return { state: "missing-loop" };
1049
- const active = await activeRunForReportTx(tx, input.loopId, input.runId, input.leaseTokenHash);
1050
- if (active.state !== "active") return active;
1051
- if (active.run.status != null || active.run.message != null) return { state: "already-reported" };
1052
- const at = nowIso$1();
1053
- const run = (await tx.update(runs).set({
1054
- status: input.status,
1055
- message: input.message,
1056
- updatedAt: at
1057
- }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, "running"), isNull(runs.status), isNull(runs.message))).returning())[0];
1058
- return run ? {
1059
- state: "applied",
1060
- run
1061
- } : { state: "already-reported" };
1062
- });
1335
+ /** Apply a run's loop-file versions under the loop lock. A file whose current
1336
+ * version is no longer the one the run started from was changed by the owner (or a
1337
+ * newer run) meanwhile; that version wins and the agent's edit becomes a notice. */
1338
+ async function applyLoopFileUpdatesTx(tx, loop, updates, at) {
1339
+ const notices = [];
1340
+ const configured = new Set(loopFileNames(loop));
1341
+ for (const update of updates) {
1342
+ if (!configured.has(update.name)) {
1343
+ notices.push(`${update.name}: no longer configured on this loop; this run's version was discarded`);
1344
+ continue;
1345
+ }
1346
+ const row = (await tx.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loop.id), eq(artifactFiles.path, update.name))).limit(1).for("update"))[0];
1347
+ if ((row && !row.deleted && row.hash ? row.hash : "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") !== update.baseHash) {
1348
+ notices.push(`${update.name}: changed outside this run while it was running; that version was kept and this run's edit was discarded`);
1349
+ continue;
1350
+ }
1351
+ await writeConfiguredArtifactTx(tx, {
1352
+ loopId: loop.id,
1353
+ path: update.name,
1354
+ hash: update.hash,
1355
+ size: update.size,
1356
+ binary: false,
1357
+ oversize: false
1358
+ }, at);
1359
+ }
1360
+ return notices;
1063
1361
  }
1064
1362
  async function finalizeTerminalRunTx(tx, input) {
1065
1363
  if (input.receipt) await lockReportIdTx(tx, input.receipt.reportId);
@@ -1074,8 +1372,10 @@ async function finalizeTerminalRunTx(tx, input) {
1074
1372
  if (!target) return void 0;
1075
1373
  const runIndex = await ensureRunIndexTx(tx, target);
1076
1374
  const at = typeof input.runPatch.ts === "string" ? input.runPatch.ts : nowIso$1();
1375
+ const fileNotices = [...input.runPatch.fileNotices ?? [], ...input.fileUpdates?.length ? await applyLoopFileUpdatesTx(tx, current, input.fileUpdates, at) : []];
1077
1376
  const run = (await tx.update(runs).set({
1078
1377
  ...input.runPatch,
1378
+ fileNotices: fileNotices.length ? fileNotices : null,
1079
1379
  runIndex,
1080
1380
  updatedAt: at
1081
1381
  }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, input.runPhase))).returning())[0];
@@ -1207,12 +1507,13 @@ async function rejectTerminalReport(input) {
1207
1507
  }
1208
1508
  /** Atomically CAS running→terminal and apply the report's loop patch.
1209
1509
  * The loop patch is unreachable when cancel/reclaim/another report won. */
1210
- async function finalizeRunningRun(loopId, runId, runPatch, loopPatch = {}, leaseTokenHash, failureAutopauseStreak = 0, receipt) {
1510
+ async function finalizeRunningRun(loopId, runId, runPatch, loopPatch = {}, leaseTokenHash, failureAutopauseStreak = 0, receipt, fileUpdates) {
1211
1511
  return db.transaction(async (tx) => {
1212
1512
  return finalizeTerminalRunTx(tx, {
1213
1513
  loopId,
1214
1514
  runId,
1215
1515
  runPhase: "running",
1516
+ fileUpdates,
1216
1517
  runPatch,
1217
1518
  loopPatch,
1218
1519
  leaseTokenHash,
@@ -1271,12 +1572,13 @@ async function releaseAbsentReconciliations(machineId, recoverableRunIds, at = n
1271
1572
  * exactly once. Keeping the lease check/delete beside the run + loop writes closes the
1272
1573
  * error→error double-report hole where a phase CAS alone cannot distinguish the
1273
1574
  * first real failure from a second concurrent one. */
1274
- async function reconcileReclaimedRun(loopId, runId, leaseTokenHash, runPatch, loopPatch = {}, failureAutopauseStreak = 0, receipt) {
1575
+ async function reconcileReclaimedRun(loopId, runId, leaseTokenHash, runPatch, loopPatch = {}, failureAutopauseStreak = 0, receipt, fileUpdates) {
1275
1576
  return db.transaction(async (tx) => {
1276
1577
  return finalizeTerminalRunTx(tx, {
1277
1578
  loopId,
1278
1579
  runId,
1279
1580
  runPhase: "error",
1581
+ fileUpdates,
1280
1582
  runPatch,
1281
1583
  loopPatch,
1282
1584
  leaseTokenHash,
@@ -1521,9 +1823,6 @@ async function setMachineOnline(id, online) {
1521
1823
  lastSeen: nowIso$1()
1522
1824
  }).where(eq(machines.id, id));
1523
1825
  }
1524
- async function blobExists(hash) {
1525
- return !!(await db.select({ hash: blobs.hash }).from(blobs).where(eq(blobs.hash, hash)))[0];
1526
- }
1527
1826
  /** Verified byte lengths recorded at blob ingress, used to budget reads before
1528
1827
  * touching the byte store. Missing metadata is intentionally not estimated. */
1529
1828
  async function blobSizes(hashes) {
@@ -1541,13 +1840,6 @@ async function recordBlob(hash, size) {
1541
1840
  createdAt: nowIso$1()
1542
1841
  }).onConflictDoNothing();
1543
1842
  }
1544
- /** Does any LIVE artifact_files row on a loop bound to `machineId` point at `hash`?
1545
- * Gates putBlob: a device may only upload bytes the sync handshake actually asked
1546
- * it for (a row a prior sync wrote for one of ITS loops), never arbitrary
1547
- * self-hashed blobs — otherwise any device token is an uncapped blob write channel. */
1548
- async function machineReferencesBlob(machineId, hash) {
1549
- return !!(await db.select({ id: artifactFiles.id }).from(artifactFiles).innerJoin(loops, eq(artifactFiles.loopId, loops.id)).where(and(eq(loops.machineId, machineId), eq(artifactFiles.hash, hash), eq(artifactFiles.deleted, false), sql`${loops.artifacts} @> jsonb_build_array(${artifactFiles.path})`)).limit(1))[0];
1550
- }
1551
1843
  async function writeConfiguredArtifactTx(tx, input, ts) {
1552
1844
  await tx.insert(artifactFiles).values({
1553
1845
  id: randomUUID(),
@@ -1571,36 +1863,32 @@ async function writeConfiguredArtifactTx(tx, input, ts) {
1571
1863
  }
1572
1864
  });
1573
1865
  }
1574
- /** Upsert only while the exact literal path remains configured, serialized with
1575
- * owner edits by the loop row lock. */
1576
- async function upsertConfiguredArtifactFile(input) {
1866
+ /** Owner replacement of one loop file. Serialized with run finalization by the
1867
+ * loop lock, so a run that started from the previous version cannot overwrite it. */
1868
+ async function writeLoopFile(loopId, name, hash, size) {
1577
1869
  return db.transaction(async (tx) => {
1578
- const loop = (await tx.select({ artifacts: loops.artifacts }).from(loops).where(eq(loops.id, input.loopId)).for("update"))[0];
1579
- if (!loop || !loop.artifacts.includes(input.path)) return false;
1580
- await writeConfiguredArtifactTx(tx, input, nowIso$1());
1870
+ const loop = (await tx.select().from(loops).where(eq(loops.id, loopId)).for("update"))[0];
1871
+ if (!loop || !loopFileNames(loop).includes(name)) return false;
1872
+ await writeConfiguredArtifactTx(tx, {
1873
+ loopId,
1874
+ path: name,
1875
+ hash,
1876
+ size,
1877
+ binary: false,
1878
+ oversize: false
1879
+ }, nowIso$1());
1581
1880
  return true;
1582
1881
  });
1583
1882
  }
1584
- /** Reconcile deletions against both the request's starting allowlist and the
1585
- * current locked loop config. Newly-added paths are never tombstoned by an older
1586
- * in-flight manifest; removed paths can never remain live. */
1587
- async function reconcileConfiguredArtifacts(loopId, configuredAtStart, keepPaths) {
1588
- return db.transaction(async (tx) => {
1589
- const loop = (await tx.select({ artifacts: loops.artifacts }).from(loops).where(eq(loops.id, loopId)).for("update"))[0];
1590
- if (!loop) return 0;
1591
- const current = new Set(loop.artifacts);
1592
- const started = new Set(configuredAtStart);
1593
- const keep = new Set(keepPaths);
1594
- const stale = (await tx.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.deleted, false)))).filter((file) => !current.has(file.path) || started.has(file.path) && !keep.has(file.path));
1595
- if (!stale.length) return 0;
1596
- const at = nowIso$1();
1597
- await tx.update(artifactFiles).set({
1598
- hash: null,
1599
- deleted: true,
1600
- updatedAt: at
1601
- }).where(inArray(artifactFiles.id, stale.map((file) => file.id)));
1602
- return stale.length;
1603
- });
1883
+ /** File notices of the immediately previous terminal run, for the next prompt.
1884
+ * Canceled runs are terminal too: their notices (edits discarded) are repeated,
1885
+ * and an older run's notice must not masquerade as the previous run's. */
1886
+ async function previousRunFileNotices(loopId, beforeRunIndex) {
1887
+ return (await db.select({ fileNotices: runs.fileNotices }).from(runs).where(and(eq(runs.loopId, loopId), isNotNull(runs.runIndex), lt(runs.runIndex, beforeRunIndex), inArray(runs.phase, [
1888
+ "done",
1889
+ "error",
1890
+ "canceled"
1891
+ ]))).orderBy(desc(runs.runIndex)).limit(1))[0]?.fileNotices ?? [];
1604
1892
  }
1605
1893
  async function listArtifacts(loopId) {
1606
1894
  return db.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.deleted, false))).orderBy(artifactFiles.path);
@@ -1618,8 +1906,8 @@ async function buildLoopManifest(loopId) {
1618
1906
  };
1619
1907
  return manifest;
1620
1908
  }
1621
- /** Write/overwrite a run's snapshot (path → file metadata). Idempotent on runId
1622
- * so a re-report of the same run just refreshes the captured artifact state. */
1909
+ /** Write/overwrite a run's snapshot (name → file metadata). Idempotent on runId
1910
+ * so a re-report of the same run just refreshes the captured loop-file state. */
1623
1911
  async function putRunSnapshot(runId, loopId, manifest) {
1624
1912
  await db.insert(runSnapshots).values({
1625
1913
  runId,
@@ -1708,103 +1996,16 @@ async function countRunSnapshots() {
1708
1996
  const r = (await db.select({ n: sql`count(*)` }).from(runSnapshots))[0];
1709
1997
  return Number(r?.n ?? 0);
1710
1998
  }
1711
- var artifacts_exports = /* @__PURE__ */ __exportAll({
1712
- BLOB_CAP: () => BLOB_CAP,
1713
- SYNC_BODY_CAP: () => SYNC_BODY_CAP,
1714
- isValidHash: () => isValidHash,
1715
- parseArtifactSyncBody: () => parseArtifactSyncBody,
1716
- safeRelPath: () => safeRelPath,
1717
- sha256Buf: () => sha256Buf
1718
- });
1719
- /** Per-file byte cap. At or under ⇒ bytes sync; over ⇒ metadata-only (no bytes). */
1720
- var BLOB_CAP = 10 * 1024 * 1024;
1721
- /** Hard ceiling on the exact-manifest sync POST body. */
1722
- var SYNC_BODY_CAP = 32 * 1024 * 1024;
1723
- var SYNC_FIELDS = new Set(["loopId", "manifest"]);
1724
- var MANIFEST_FIELDS = new Set([
1725
- "path",
1726
- "hash",
1727
- "size",
1728
- "binary",
1729
- "oversize"
1730
- ]);
1731
- var MAX_STORED_SIZE = 2147483647;
1732
- function exactRecord(value, fields) {
1733
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1734
- const proto = Object.getPrototypeOf(value);
1735
- if (proto !== Object.prototype && proto !== null) return false;
1736
- const keys = Object.keys(value);
1737
- return keys.length === fields.size && keys.every((key) => fields.has(key));
1738
- }
1739
- function sha256Buf(bytes) {
1740
- return createHash("sha256").update(bytes).digest("hex");
1741
- }
1742
- function isValidHash(hash) {
1743
- return typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash);
1744
- }
1745
1999
  /**
1746
- * Validate and preserve an untrusted, loop-folder-relative literal path. Returns
1747
- * null if it is absolute, contains an empty/dot/traversal segment, is empty, or
1748
- * carries a NUL (no real filesystem produces one, and Postgres rejects it).
1749
- */
1750
- function safeRelPath(raw) {
1751
- if (typeof raw !== "string" || raw.length === 0 || raw.includes("\0")) return null;
1752
- if (raw.startsWith("/") || raw.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(raw)) return null;
1753
- if (raw.split(/[\\/]/).some((part) => part === "" || part === "." || part === "..")) return null;
1754
- return raw;
1755
- }
1756
- /** Parse the one canonical sync envelope without coercion or omitted-field defaults. */
1757
- function parseArtifactSyncBody(value) {
1758
- if (!exactRecord(value, SYNC_FIELDS)) return null;
1759
- const { loopId, manifest } = value;
1760
- if (typeof loopId !== "string" || loopId.length === 0 || loopId.length > 200 || loopId.includes("\0") || !Array.isArray(manifest)) return null;
1761
- const entries = [];
1762
- const paths = /* @__PURE__ */ new Set();
1763
- for (const raw of manifest) {
1764
- if (!exactRecord(raw, MANIFEST_FIELDS)) return null;
1765
- const { path, hash, size, binary, oversize } = raw;
1766
- const rel = safeRelPath(path);
1767
- if (rel === null || paths.has(rel) || typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || size > MAX_STORED_SIZE || typeof binary !== "boolean" || typeof oversize !== "boolean") return null;
1768
- if (oversize) {
1769
- if (size <= 10485760 || hash !== null) return null;
1770
- entries.push({
1771
- path: rel,
1772
- hash: null,
1773
- size,
1774
- binary,
1775
- oversize: true
1776
- });
1777
- } else {
1778
- if (size > 10485760 || !isValidHash(hash)) return null;
1779
- entries.push({
1780
- path: rel,
1781
- hash,
1782
- size,
1783
- binary,
1784
- oversize: false
1785
- });
1786
- }
1787
- paths.add(rel);
1788
- }
1789
- return {
1790
- loopId,
1791
- manifest: entries
1792
- };
1793
- }
1794
- /**
1795
- * Machine + run credential helpers. Device tokens (`dk_…`) identify a machine
1796
- * (its id is derived from the token: `m-sha256(token)[:16]`). A run
1797
- * LEASE (`rk_…`) is minted per delivery, bound to one run, and carries the
1798
- * run's least-privilege caps — the CLI dispatch authorizes the `pievo` shim
1799
- * against it. Its lifecycle is a small state machine (`active` →
1800
- * `terminal-grace` → `reconciliation-only`/`retired`), not a mint→revoke pair;
1801
- * see `RunLease` below.
2000
+ * Machine and run credentials. A device token (`dk_…`) identifies a machine whose
2001
+ * id is derived from it (`m-sha256(token)[:16]`). A run lease (`rk_…`) is minted per
2002
+ * delivery and authorizes only that run's loop-file transfer and terminal report;
2003
+ * its lifecycle is the state machine on `RunLease`.
1802
2004
  *
1803
- * Leases and connect-key bindings are DURABLE (run_leases / connect_keys
1804
- * tables): they must survive a deploy, or every restart 401s the in-flight
1805
- * runs' callbacks/finalize and prevents a freshly connected machine from
1806
- * enrolling. The 15-minute `new` idempotency window stays in-process;
1807
- * losing it only degrades retry deduplication, never persisted data.
2005
+ * Leases and connect-key bindings are durable so a restart neither rejects
2006
+ * in-flight reports nor blocks a just-connected machine from enrolling. The
2007
+ * 15-minute `new` idempotency window stays in memory; losing it on restart only
2008
+ * weakens retry deduplication.
1808
2009
  */
1809
2010
  var tokens_exports = /* @__PURE__ */ __exportAll({
1810
2011
  CONNECT_KEY_TTL_MS: () => CONNECT_KEY_TTL_MS,
@@ -2067,8 +2268,8 @@ function validateLoopTags(value) {
2067
2268
  };
2068
2269
  }
2069
2270
  //#endregion
2070
- //#region node_modules/.nitro/vite/services/ssr/assets/boot-CYRi-wez.js
2071
- var boot_CYRi_wez_exports = /* @__PURE__ */ __exportAll$1({
2271
+ //#region node_modules/.nitro/vite/services/ssr/assets/boot-CecCDRoH.js
2272
+ var boot_CecCDRoH_exports = /* @__PURE__ */ __exportAll$1({
2072
2273
  _: () => validOptionalPollString,
2073
2274
  a: () => POLL_HTTP_BODY_FIELDS,
2074
2275
  c: () => MIN_DAEMON_VERSION,
@@ -2082,24 +2283,11 @@ var boot_CYRi_wez_exports = /* @__PURE__ */ __exportAll$1({
2082
2283
  n: () => ensureServer,
2083
2284
  o: () => validPollCurrentRun,
2084
2285
  p: () => MACHINE_BODY_CAP,
2085
- r: () => getArtifactSync,
2286
+ r: () => getLoopFiles,
2086
2287
  s: () => validPollWireId,
2087
2288
  t: () => boot_exports,
2088
2289
  u: () => canonicalLoopEnvelope
2089
2290
  });
2090
- /**
2091
- * Machine-route ingress: read + parse a JSON request body under a hard size cap.
2092
- * The gateway's per-field wire caps (WIRE_TEXT_CAP etc.) clip individual strings
2093
- * AFTER parse — without this, an unbounded `request.json()` still buffers an
2094
- * arbitrarily large body first. Framework-free (plain Request) so any machine
2095
- * route can share it.
2096
- */
2097
- /**
2098
- * Body cap for standard machine routes (poll / report / CLI).
2099
- * 2MB — generously above the largest legitimate terminal report or loop edit.
2100
- * The exact-manifest sync route has its own 32 MB JSON-body cap.
2101
- */
2102
- var MACHINE_BODY_CAP = 2 * 1024 * 1024;
2103
2291
  /** Read raw request bytes under a hard cap. Declared oversize requests fail
2104
2292
  * before reading; chunked/lying streams are canceled on the first chunk that
2105
2293
  * takes the observed count over the cap. */
@@ -2164,208 +2352,116 @@ async function readJsonBody(request, maxBytes) {
2164
2352
  } catch {
2165
2353
  return { kind: "invalid" };
2166
2354
  }
2167
- }
2168
- var WIRE_TEXT_CAP = 512 * 1024;
2169
- /** Validate an optional poll string without coercion, clipping, or NUL removal. */
2170
- function validOptionalPollString(value, cap) {
2171
- return value === void 0 || typeof value === "string" && value.length <= cap && !value.includes("\0");
2172
- }
2173
- function nowIso() {
2174
- return (/* @__PURE__ */ new Date()).toISOString();
2175
- }
2176
- /** Strip NUL (U+0000) from a wire string: Postgres text/jsonb columns REJECT the
2177
- * NUL byte (SQLite tolerated it), so a daemon-supplied string carrying one would
2178
- * throw mid-finalize on the DB write. The single sanitizing primitive behind
2179
- * `clipText` - and used directly by `cli.ts`
2180
- * (parseFlags/validateState, the same one-chokepoint discipline). */
2181
- function stripNul(s) {
2182
- return s.replace(/\u0000/g, "");
2183
- }
2184
- function clipText(s, cap) {
2185
- return stripNul(s.slice(0, cap));
2186
- }
2187
- /**
2188
- * Deliver the user's prompt unchanged, followed only by the complete report
2189
- * contract. The run index intentionally remains an internal history fact.
2190
- */
2191
- function buildRunTask(loop) {
2192
- return `${loop.prompt}\n\nStatus definitions:\n- keep: ${loop.statusKeep}\n- no-change: ${loop.statusNoChange}\n- block: ${loop.statusBlock}\n\nBefore finishing, call exactly once:\npievo report --message "<summary>" --status <keep|no-change|block>`;
2193
- }
2194
- async function buildDelivery(loop, queuedRun, runToken, roots) {
2195
- if (queuedRun.runIndex == null) throw new Error(`claimed run ${queuedRun.id} has no history index`);
2196
- return {
2197
- runId: queuedRun.id,
2198
- runIndex: queuedRun.runIndex,
2199
- runToken,
2200
- roots,
2201
- loop: {
2202
- id: loop.id,
2203
- name: loop.name,
2204
- workdir: loop.workdir,
2205
- model: loop.model ?? null,
2206
- reasoningEffort: loop.reasoningEffort ?? null,
2207
- agent: loop.agent
2208
- },
2209
- task: buildRunTask(loop),
2210
- artifacts: loop.artifacts
2211
- };
2212
- }
2213
- /**
2214
- * Content-addressed blob byte store for live-synced loop artifacts.
2215
- *
2216
- * Bytes are keyed by their sha256 hash (content-addressed ⇒ deduped across every
2217
- * loop/run). The store ONLY writes/reads bytes — it never executes or interprets
2218
- * them — preserving the server's zero-exec invariant.
2219
- *
2220
- * `LocalBlobStore` is the durable store under `<dataDir>/blobs`;
2221
- * `MemoryBlobStore` is the injected test fake.
2222
- */
2223
- var log$4 = logger.child({ mod: "blobstore" });
2224
- var SHA256_HEX = /^[a-f0-9]{64}$/;
2225
- /** Durable filesystem adapter. Hash sharding bounds entries per directory; atomic
2226
- * temp-file renames ensure readers see either complete bytes or no object. */
2227
- var LocalBlobStore = class {
2228
- root;
2229
- initialized;
2230
- constructor(root = path.join(dataDir(), "blobs")) {
2231
- this.root = root;
2232
- }
2233
- file(hash) {
2234
- if (!SHA256_HEX.test(hash)) throw new Error("invalid sha256 blob key");
2235
- return path.join(this.root, hash.slice(0, 2), hash);
2236
- }
2237
- async has(hash) {
2238
- const target = this.file(hash);
2239
- await this.ensureInitialized();
2240
- try {
2241
- return (await fs$1.stat(target)).isFile();
2242
- } catch (err) {
2243
- if (err.code === "ENOENT") return false;
2244
- throw err;
2245
- }
2246
- }
2247
- async syncDir(dir) {
2248
- let handle;
2249
- try {
2250
- handle = await fs$1.open(dir, "r");
2251
- await handle.sync();
2252
- } catch (err) {
2253
- const code = err.code;
2254
- if (![
2255
- "EINVAL",
2256
- "ENOTSUP",
2257
- "EBADF",
2258
- "EPERM",
2259
- "ENOENT"
2260
- ].includes(code)) throw err;
2261
- } finally {
2262
- await handle?.close().catch(() => {});
2263
- }
2264
- }
2265
- ensureInitialized() {
2266
- return this.initialized ??= (async () => {
2267
- await fs$1.mkdir(this.root, { recursive: true });
2268
- await this.syncDir(path.dirname(path.dirname(this.root)));
2269
- await this.syncDir(path.dirname(this.root));
2270
- await this.syncDir(this.root);
2271
- for (const shard of await fs$1.readdir(this.root, { withFileTypes: true })) {
2272
- if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
2273
- const dir = path.join(this.root, shard.name);
2274
- let removed = false;
2275
- for (const name of await fs$1.readdir(dir)) {
2276
- if (!/^\.[a-f0-9]{64}\.\d+\.[0-9a-f-]+\.tmp$/.test(name)) continue;
2277
- await fs$1.rm(path.join(dir, name), { force: true });
2278
- removed = true;
2279
- }
2280
- if (removed) await this.syncDir(dir);
2281
- }
2282
- })();
2283
- }
2284
- async put(hash, bytes) {
2285
- const target = this.file(hash);
2286
- await this.ensureInitialized();
2287
- const dir = path.dirname(target);
2288
- await fs$1.mkdir(dir, { recursive: true });
2289
- await this.syncDir(this.root);
2290
- const temp = path.join(dir, `.${hash}.${process.pid}.${randomUUID()}.tmp`);
2291
- let handle;
2292
- try {
2293
- handle = await fs$1.open(temp, "wx", 384);
2294
- await handle.writeFile(bytes);
2295
- await handle.sync();
2296
- await handle.close();
2297
- handle = void 0;
2298
- try {
2299
- await fs$1.rename(temp, target);
2300
- await this.syncDir(dir);
2301
- } catch (err) {
2302
- if ([
2303
- "EEXIST",
2304
- "EACCES",
2305
- "EPERM"
2306
- ].includes(err.code ?? "") && await this.has(hash)) {
2307
- await fs$1.rm(temp, { force: true });
2308
- return;
2309
- }
2310
- throw err;
2311
- }
2312
- } catch (err) {
2313
- await handle?.close().catch(() => {});
2314
- await fs$1.rm(temp, { force: true }).catch(() => {});
2315
- throw err;
2316
- }
2317
- }
2318
- async get(hash) {
2319
- const target = this.file(hash);
2320
- await this.ensureInitialized();
2321
- try {
2322
- return await fs$1.readFile(target);
2323
- } catch (err) {
2324
- if (err.code === "ENOENT") return null;
2325
- throw err;
2326
- }
2327
- }
2328
- async delete(hash) {
2329
- const target = this.file(hash);
2330
- await this.ensureInitialized();
2331
- await fs$1.rm(target, { force: true });
2332
- await this.syncDir(path.dirname(target));
2355
+ }
2356
+ var WIRE_TEXT_CAP = 512 * 1024;
2357
+ /** Validate an optional poll string without coercion, clipping, or NUL removal. */
2358
+ function validOptionalPollString(value, cap) {
2359
+ return value === void 0 || typeof value === "string" && value.length <= cap && !value.includes("\0");
2360
+ }
2361
+ function nowIso() {
2362
+ return (/* @__PURE__ */ new Date()).toISOString();
2363
+ }
2364
+ /** Postgres text/jsonb columns reject NUL, so a daemon-supplied string carrying
2365
+ * one would fail mid-finalize. The single sanitizer behind `clipText` and `cli.ts`. */
2366
+ function stripNul(s) {
2367
+ return s.replace(/\u0000/g, "");
2368
+ }
2369
+ function clipText(s, cap) {
2370
+ return stripNul(s.slice(0, cap));
2371
+ }
2372
+ /** Literal placeholder the daemon replaces with the run's absolute loop-file
2373
+ * directory before launch (and exports as an env var of the same name). */
2374
+ var LOOP_DIR_PLACEHOLDER = "$PIEVO_LOOP_DIR";
2375
+ var STATUS_LINE_RE = /^STATUS:\s*(keep|no-change|block)\.?$/i;
2376
+ var ATTENTION_LINE_RE = /^ATTENTION:\s*(.*)$/i;
2377
+ /**
2378
+ * Deliver the user's prompt unchanged, followed by the status definitions, the
2379
+ * loop-file contract, and the final-line status contract. The run index
2380
+ * intentionally remains an internal history fact.
2381
+ */
2382
+ function buildRunTask(loop, previousNotices = []) {
2383
+ const files = [`- ${MEMORY_FILE}: this loop's long-term experience (max ${MEMORY_CHAR_CAP} characters). Read it first and follow it. Before finishing, record only lessons this run learned the hard way that make later runs faster or safer: a pitfall hit, a workaround found, an environment fact discovered. One line each; correct or delete lessons that proved wrong. Never restate the task or these instructions, and never store state, results, baselines, trends, or run logs (those go in an artifact). Most runs add nothing.`, ...loop.artifacts.map((name) => `- ${name}: artifact shown on the Pievo dashboard (max ${fileLimitLabel(name)}). Update it only as the task above asks.`)];
2384
+ const notices = previousNotices.length ? `\n\nNotices from the previous run:\n${previousNotices.map((notice) => `- ${notice}`).join("\n")}` : "";
2385
+ return `${loop.prompt}
2386
+
2387
+ Status definitions:
2388
+ - keep: ${loop.statusKeep}
2389
+ - no-change: ${loop.statusNoChange}
2390
+ - block: ${loop.statusBlock}
2391
+ A block pauses the loop until the owner restarts it.
2392
+
2393
+ Loop files in ${LOOP_DIR_PLACEHOLDER}:
2394
+ ${files.join("\n")}
2395
+ A file over its limit is rejected and its previous version kept. Other files in that directory are discarded.${notices}
2396
+
2397
+ Be concise in everything you write: lead with what changed or needs action; prefer tables and short lists; no filler or restated instructions.
2398
+
2399
+ Your final response is only: a summary of at most five short lines (no headings, no repeated tables or file contents); if the owner should look at something, one line \`ATTENTION: <reason>\`; then exactly one last line:
2400
+ STATUS: keep|no-change|block`;
2401
+ }
2402
+ var bareLine = (line) => line.trim().replace(/^[*_`>\s]+|[*_`\s]+$/g, "");
2403
+ /**
2404
+ * Split a provider's final response into the reported status, an optional
2405
+ * attention reason from the ATTENTION lines just above it, and the summary. The final
2406
+ * non-empty line must be the status line; markdown emphasis or code ticks around
2407
+ * either line are tolerated because models add them unprompted.
2408
+ */
2409
+ function parseFinalStatus(finalText) {
2410
+ if (!finalText) return void 0;
2411
+ const lines = finalText.split(/\r?\n/);
2412
+ let index = lines.length - 1;
2413
+ while (index >= 0 && lines[index].trim() === "") index--;
2414
+ if (index < 0) return void 0;
2415
+ const match = STATUS_LINE_RE.exec(bareLine(lines[index]));
2416
+ if (!match) return void 0;
2417
+ const status = match[1].toLowerCase();
2418
+ const reasons = [];
2419
+ let end = index;
2420
+ for (;;) {
2421
+ let previous = end;
2422
+ while (previous > 0 && lines[previous - 1].trim() === "") previous--;
2423
+ const attentionMatch = previous > 0 ? ATTENTION_LINE_RE.exec(bareLine(lines[previous - 1])) : null;
2424
+ if (!attentionMatch) break;
2425
+ end = previous - 1;
2426
+ const reason = attentionMatch[1].trim();
2427
+ if (reason && !/^(none|n\/a|-)$/i.test(reason)) reasons.unshift(reason);
2333
2428
  }
2334
- };
2335
- /** The one local blob store shared by sync writes, artifact reads, and GC. */
2336
- function createBlobStore() {
2337
- const local = new LocalBlobStore();
2338
- log$4.info({ root: local.root }, "blob store: local filesystem");
2339
- return local;
2429
+ const joined = reasons.join("; ");
2430
+ const attention = joined ? joined.length > 200 ? `${joined.slice(0, 199)}…` : joined : null;
2431
+ return {
2432
+ status,
2433
+ message: lines.slice(0, end).join("\n").trim(),
2434
+ attention
2435
+ };
2436
+ }
2437
+ async function buildDelivery(loop, queuedRun, runToken, roots) {
2438
+ if (queuedRun.runIndex == null) throw new Error(`claimed run ${queuedRun.id} has no history index`);
2439
+ const notices = await previousRunFileNotices(loop.id, queuedRun.runIndex);
2440
+ return {
2441
+ runId: queuedRun.id,
2442
+ runIndex: queuedRun.runIndex,
2443
+ runToken,
2444
+ roots,
2445
+ loop: {
2446
+ id: loop.id,
2447
+ name: loop.name,
2448
+ workdir: loop.workdir,
2449
+ model: loop.model ?? null,
2450
+ reasoningEffort: loop.reasoningEffort ?? null,
2451
+ agent: loop.agent
2452
+ },
2453
+ task: buildRunTask(loop, notices),
2454
+ files: loopFileNames(loop)
2455
+ };
2340
2456
  }
2341
2457
  /**
2342
- * Artifact-storage retention + garbage collection — the policy that keeps the
2343
- * local byte store from growing monotonically.
2344
- *
2345
- * The artifact pipeline is content-addressed: blob BYTES are keyed by sha256 and
2346
- * deduped across every loop/run. When a path is deleted or its content changes,
2347
- * the OLD blob is no longer pointed at by the current file row — but nothing was
2348
- * reclaiming it, so the byte store grew forever. This module closes that gap with two pieces:
2349
- *
2350
- * 1. pruneSnapshots() — bound the per-loop run-snapshot history to a window, so
2351
- * old snapshots stop pinning the blobs they referenced for diffs.
2352
- * 2. gcBlobs() — delete blob bytes no LIVE row needs: a blob is collected
2353
- * only when NO artifact_files row AND NO retained run_snapshot references its
2354
- * hash. CORRECTNESS over aggressiveness — a still-referenced blob is never
2355
- * deleted; a leaked blob is only a cost bug the next pass reclaims.
2356
- *
2357
- * Concurrency: gcBlobs is safe to run alongside active syncs. Three guards combine —
2358
- * a grace window (a blob whose metadata row is younger than the window is never
2359
- * collected, so a blob a sync just wrote/referenced is untouchable), a point
2360
- * re-check of each candidate immediately before its bytes are deleted (so a blob
2361
- * re-referenced before we touch it keeps both its bytes and metadata), and a
2362
- * bytes-before-metadata delete ordering: each candidate's bytes are reclaimed
2363
- * FIRST, then its metadata row is dropped unconditionally — so blobExists() goes
2364
- * false and any sync that re-references the hash (even one that raced the byte
2365
- * delete) re-uploads the bytes (self-healing). We never leave a live blobs row
2366
- * pointing at deleted bytes.
2458
+ * Loop-file storage retention. Blob bytes are content-addressed and shared, so
2459
+ * replacing a file never frees its old blob by itself. pruneSnapshots() bounds
2460
+ * per-loop snapshot history; gcBlobs() then deletes bytes no file row and no
2461
+ * retained snapshot references. Correctness wins over aggressiveness: a leaked
2462
+ * blob is a storage cost the next pass reclaims, a deleted live blob is data loss.
2367
2463
  */
2368
- var log$3 = logger.child({ mod: "retention" });
2464
+ var log$2 = logger.child({ mod: "retention" });
2369
2465
  /**
2370
2466
  * Prune every loop's run snapshots down to the configured retention window
2371
2467
  * (most-recent-N). Returns the total number pruned. This is what makes an old
@@ -2377,28 +2473,14 @@ async function pruneSnapshots(keep = 20) {
2377
2473
  return pruned;
2378
2474
  }
2379
2475
  /**
2380
- * Reclaim unreferenced blob bytes. Returns the number of blobs collected.
2381
- *
2382
- * Algorithm:
2383
- * 1. Compute the live keep-set (all hashes any artifact_files row / retained
2384
- * snapshot references) and the grace-windowed candidate list — both reads are
2385
- * synchronous (interleave-free under the single-threaded event loop).
2386
- * 2. Garbage = candidates not in the keep-set.
2387
- * 3. For each garbage hash, in order:
2388
- * a. Pre-delete guard: if a sync re-referenced it since the keep-set was
2389
- * computed, skip it — leaving both its bytes AND metadata row intact
2390
- * (a consistent live blob, nothing to heal). The guard is two O(1) checks:
2391
- * an indexed artifact_files point query (always fresh) plus membership in a
2392
- * snapshot-hash Set deserialized ONCE per pass and rebuilt only when the
2393
- * snapshot row count changes (a report() raced us) — never the old
2394
- * per-candidate full snapshot scan (which was O(garbage × snapshots), since
2395
- * genuine garbage always misses the file check and fell through to it).
2396
- * b. Otherwise delete the BYTES (the await), then drop the metadata row
2397
- * UNCONDITIONALLY. Bytes-before-metadata is the data-loss fix: if a sync
2398
- * raced the byte delete (re-referencing the hash + recreating the blobs
2399
- * row mid-await), dropping the metadata still leaves blobExists()=false,
2400
- * so that file re-uploads its bytes on the next sync. We never leave a
2401
- * live blobs row pointing at deleted bytes.
2476
+ * Reclaim unreferenced blob bytes; returns the number collected. Safe alongside
2477
+ * owner writes and run uploads because of three guards:
2478
+ * - grace window: blobs whose metadata row is younger than `graceMs` are skipped;
2479
+ * - per-candidate re-check right before deletion: an indexed file-row lookup plus
2480
+ * a snapshot-hash set rebuilt only when the snapshot count changes (a concurrent
2481
+ * report), instead of an O(garbage x snapshots) scan;
2482
+ * - bytes before metadata: the metadata row is dropped after the bytes even if a
2483
+ * writer raced the delete, so blobExists() is false and that writer re-uploads.
2402
2484
  */
2403
2485
  async function gcBlobs(blobStore, graceMs = BLOB_GC_GRACE_MS) {
2404
2486
  const refs = await liveBlobRefs();
@@ -2418,7 +2500,7 @@ async function gcBlobs(blobStore, graceMs = BLOB_GC_GRACE_MS) {
2418
2500
  await deleteBlob(hash);
2419
2501
  reclaimed++;
2420
2502
  } catch (err) {
2421
- log$3.warn({
2503
+ log$2.warn({
2422
2504
  hash,
2423
2505
  err: err instanceof Error ? err.message : String(err)
2424
2506
  }, "gc: blob byte-delete failed");
@@ -2429,7 +2511,7 @@ async function gcBlobs(blobStore, graceMs = BLOB_GC_GRACE_MS) {
2429
2511
  async function maintainStorage(blobStore, options = {}) {
2430
2512
  const snapshotsPruned = await pruneSnapshots(options.keep);
2431
2513
  const blobsReclaimed = await gcBlobs(blobStore, options.graceMs);
2432
- if (snapshotsPruned || blobsReclaimed) log$3.info({
2514
+ if (snapshotsPruned || blobsReclaimed) log$2.info({
2433
2515
  snapshotsPruned,
2434
2516
  blobsReclaimed
2435
2517
  }, "storage maintenance");
@@ -2764,44 +2846,37 @@ function validateStatusDefinitions(value) {
2764
2846
  }
2765
2847
  };
2766
2848
  }
2767
- function validateArtifactPaths(value) {
2849
+ function validateArtifactNames(value) {
2768
2850
  if (value === void 0 || value === null) return {
2769
2851
  ok: true,
2770
2852
  value: []
2771
2853
  };
2772
2854
  if (!Array.isArray(value)) return {
2773
2855
  ok: false,
2774
- detail: "artifacts must be an array of exact relative file paths"
2856
+ detail: "artifacts must be an array of file names"
2857
+ };
2858
+ if (value.length > 4) return {
2859
+ ok: false,
2860
+ detail: `at most 4 artifacts are allowed`
2775
2861
  };
2776
- const paths = [];
2862
+ const names = [];
2777
2863
  const seen = /* @__PURE__ */ new Set();
2778
2864
  for (const entry of value) {
2779
- if (typeof entry !== "string" || entry.length === 0) return {
2780
- ok: false,
2781
- detail: "artifact paths must be non-empty strings"
2782
- };
2783
- if (entry.includes("\0")) return {
2784
- ok: false,
2785
- detail: "artifact path must not contain NUL"
2786
- };
2787
- if (entry.startsWith("/") || entry.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(entry)) return {
2788
- ok: false,
2789
- detail: `artifact path must be relative to workdir: ${entry}`
2790
- };
2791
- if (entry.split(/[\\/]/).some((part) => part === "" || part === "." || part === "..")) return {
2865
+ if (!validArtifactName(entry)) return {
2792
2866
  ok: false,
2793
- detail: `artifact path must not traverse or alias another path: ${entry}`
2867
+ detail: `artifact must be a plain text/code file name other than ${MEMORY_FILE} (supported extensions: ${ARTIFACT_EXTENSIONS.join(", ")}; letters, digits, . _ -; at most 64 characters before the extension): ${JSON.stringify(entry)}`
2794
2868
  };
2795
- if (seen.has(entry)) return {
2869
+ const key = entry.toLowerCase();
2870
+ if (seen.has(key)) return {
2796
2871
  ok: false,
2797
- detail: `duplicate artifact path: ${entry}`
2872
+ detail: `duplicate artifact name: ${entry}`
2798
2873
  };
2799
- seen.add(entry);
2800
- paths.push(entry);
2874
+ seen.add(key);
2875
+ names.push(entry);
2801
2876
  }
2802
2877
  return {
2803
2878
  ok: true,
2804
- value: paths
2879
+ value: names
2805
2880
  };
2806
2881
  }
2807
2882
  function rowSchedule(schedule, retainedCron = SAFE_RETAINED_CRON) {
@@ -2886,7 +2961,7 @@ function validateLoopCreate(value) {
2886
2961
  if (!schedule.ok) return schedule;
2887
2962
  const statusDefinitions = validateStatusDefinitions(raw.statusDefinitions);
2888
2963
  if (!statusDefinitions.ok) return statusDefinitions;
2889
- const artifacts = validateArtifactPaths(raw.artifacts);
2964
+ const artifacts = validateArtifactNames(raw.artifacts);
2890
2965
  if (!artifacts.ok) return artifacts;
2891
2966
  const model = optionalProvider(raw.model, "model");
2892
2967
  if (!model.ok) return model;
@@ -2981,7 +3056,7 @@ function validateLoopEdit(loop, value) {
2981
3056
  update.statusBlock = result.value.block;
2982
3057
  }
2983
3058
  if (raw.artifacts !== void 0) {
2984
- const result = validateArtifactPaths(raw.artifacts);
3059
+ const result = validateArtifactNames(raw.artifacts);
2985
3060
  if (!result.ok) return result;
2986
3061
  update.artifacts = result.value;
2987
3062
  }
@@ -3017,7 +3092,7 @@ function validateLoopEdit(loop, value) {
3017
3092
  value: update
3018
3093
  };
3019
3094
  }
3020
- var MIN_DAEMON_VERSION = "2.4.0";
3095
+ var MIN_DAEMON_VERSION = "4.0.0";
3021
3096
  function daemonNeedsUpdate(current, required = MIN_DAEMON_VERSION) {
3022
3097
  if (!current || !isValidSemver(current) || !isValidSemver(required)) return true;
3023
3098
  if (current === required) return false;
@@ -3050,23 +3125,18 @@ var validPollWireId = (value) => typeof value === "string" && value.length <= 20
3050
3125
  var validPollCurrentRun = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 2 && Object.prototype.hasOwnProperty.call(value, "runId") && Object.prototype.hasOwnProperty.call(value, "stage") && validPollWireId(value.runId) && (value.stage === "executing" || value.stage === "reporting");
3051
3126
  var validPollInfo = (value) => value === void 0 || value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => POLL_INFO_FIELDS.has(key)) && validOptionalPollString(value.host, 524288) && validOptionalPollString(value.platform, 524288) && validOptionalPollString(value.arch, 524288) && validOptionalPollString(value.version, 64);
3052
3127
  /**
3053
- * Machine gateway - the run-lifecycle core of the HTTP surface the daemon talks
3054
- * to (poll transport: short-poll while a run is in flight, opt-in server-held
3055
- * long-poll while idle). Framework-agnostic like the rest of the gateway
3056
- * (return `{ status, body }` so the methods can be mounted on a plain http
3057
- * server or TanStack server routes):
3128
+ * Machine gateway: the run-lifecycle core the daemon talks to. Methods return
3129
+ * transport-free `{ status, body }` results for the thin route shells:
3058
3130
  *
3059
- * POST /api/machine/poll (Bearer device token) → claim pending runs, deliver
3060
- * POST /machine/report (Bearer run token) → finalize a run
3131
+ * POST /api/machine/poll (device token) claim and deliver runs
3132
+ * POST /machine/report (run token) finalize a run
3133
+ * GET|PUT /api/machine/run-files/:name (run token) loop-file transfer
3061
3134
  *
3062
- * plus owner create/list/edit methods and retention.
3063
- * Also exposes `dispatcher` (a `Dispatcher` for the Scheduler: "is the machine
3064
- * online?") and `sweep()` (mark stale machines offline, reclaim stuck runs).
3065
- * CLI verb dispatch at `/api/machine/cli` lives in `gateway/cli.ts`
3066
- * (`CliGateway`), which reuses this class's owner methods;
3067
- * canonical loop configuration validation lives in `gateway/loopConfig.ts`.
3135
+ * Idle polls may be held open (long-poll) until the scheduler's dispatcher wakes
3136
+ * them. `sweep()` marks stale machines offline and reclaims silent runs. CLI verbs
3137
+ * live in `cli.ts`; loop config validation lives in `loopConfig.ts`.
3068
3138
  */
3069
- var log$2 = logger.child({ mod: "gateway" });
3139
+ var log$1 = logger.child({ mod: "gateway" });
3070
3140
  var ONLINE_TTL_MS = 3e4;
3071
3141
  /** Auto-pause after this many consecutive errors. Canceled rows are transparent;
3072
3142
  * zero disables the breaker. */
@@ -3081,10 +3151,15 @@ var RUN_TIMEOUT_MS = Number.isFinite(configuredRunTimeoutMs) && configuredRunTim
3081
3151
  * patched over the device-token edit surface. Exported for `cli.ts`
3082
3152
  * (the `new`/`edit` verb help lists these keys). */
3083
3153
  var EDITABLE_LOOP_FIELDS = LOOP_EDIT_FIELDS;
3084
- /** Formal `report --message` text. Provider finalText is stored separately;
3085
- * it never satisfies the successful-run reporting protocol. Run errors share
3086
- * this cap. Exported for `cli.ts` so the report verb uses the same budget. */
3154
+ /** Run summary (the final response minus its STATUS line) and run errors share
3155
+ * this cap; the full final response is stored separately as finalText. */
3087
3156
  var MESSAGE_CAP = 2e3;
3157
+ /** A run reports at most one version per loop file (MEMORY.md + 4 artifacts). */
3158
+ var REPORT_FILES_MAX = 5;
3159
+ /** One notice per file plus a few upload/rejection reasons; bounds the prompt echo. */
3160
+ var REPORT_NOTICES_MAX = 10;
3161
+ /** One short sentence (name + reason); 500 chars keeps the next prompt small. */
3162
+ var NOTICE_CAP = 500;
3088
3163
  /** Provider session IDs are opaque short tokens; this rejects malformed payloads
3089
3164
  * without constraining known provider formats. */
3090
3165
  var SESSION_ID_CAP = 200;
@@ -3099,7 +3174,7 @@ var HEARTBEAT_STAMP_REFRESH_MS = heartbeatRefreshMs(RUN_TIMEOUT_MS);
3099
3174
  * and an idle poll becomes read-only, with worst-case staleness well inside
3100
3175
  * the 30s TTL (max stamp gap = refresh + one poll interval). */
3101
3176
  var LAST_SEEN_REFRESH_MS = 1e4;
3102
- var MACHINE_REPORT_FIELD_SET = new Set([
3177
+ var MACHINE_REPORT_FIELDS = [
3103
3178
  "reportId",
3104
3179
  "runId",
3105
3180
  "result",
@@ -3108,8 +3183,17 @@ var MACHINE_REPORT_FIELD_SET = new Set([
3108
3183
  "sessionId",
3109
3184
  "usage",
3110
3185
  "error",
3111
- "finalText"
3112
- ]);
3186
+ "finalText",
3187
+ "files",
3188
+ "fileNotices"
3189
+ ];
3190
+ var REPORT_FILE_FIELDS = [
3191
+ "baseHash",
3192
+ "hash",
3193
+ "name",
3194
+ "size"
3195
+ ];
3196
+ var MACHINE_REPORT_FIELD_SET = new Set(MACHINE_REPORT_FIELDS);
3113
3197
  var MACHINE_REPORT_USAGE_FIELDS = new Set([
3114
3198
  "inputTokens",
3115
3199
  "outputTokens",
@@ -3138,7 +3222,7 @@ function receiptFor(body, runId, ackStatus = 200, ackBody) {
3138
3222
  }
3139
3223
  function receiptResponse(receipt, expected) {
3140
3224
  if (!receipt || receipt.runId !== expected.runId) return void 0;
3141
- if (receipt.payloadDigest !== expected.payloadDigest) log$2.warn({
3225
+ if (receipt.payloadDigest !== expected.payloadDigest) log$1.warn({
3142
3226
  reportId: expected.reportId,
3143
3227
  runId: expected.runId
3144
3228
  }, "report: same-run payload changed after commit; replaying authoritative ACK");
@@ -3166,7 +3250,7 @@ async function committedReportEvidence(reportId, payloadDigest, authoritativeRun
3166
3250
  foreignRun: false
3167
3251
  };
3168
3252
  if (normal?.runId === authoritativeRunId) {
3169
- if (normal.payloadDigest !== payloadDigest) log$2.warn({
3253
+ if (normal.payloadDigest !== payloadDigest) log$1.warn({
3170
3254
  reportId,
3171
3255
  runId: authoritativeRunId
3172
3256
  }, "report: same-run payload changed after commit; replaying authoritative ACK");
@@ -3200,14 +3284,37 @@ function incidentDiagnosis(code, issues, reportId, payloadDigest) {
3200
3284
  recommendedAction
3201
3285
  };
3202
3286
  }
3203
- /** Validate the durable report facts before a successful provider process may
3204
- * become a successful run. The CLI validates first so an agent can retry; this
3205
- * terminal seam prevents an ignored 400 from silently producing an empty run. */
3206
- function runProtocolMissing(run) {
3207
- const missing = [];
3208
- if (run.status !== "keep" && run.status !== "no-change" && run.status !== "block") missing.push("status");
3209
- if (!run.message?.trim()) missing.push("message");
3210
- return missing;
3287
+ var MISSING_STATUS_ERROR = "run protocol incomplete: the final response has no \"STATUS: keep|no-change|block\" line";
3288
+ /** The run outcome a terminal report establishes. Only a successful provider
3289
+ * exit is read for a status; a successful exit without one is a protocol error. */
3290
+ function reportOutcome(body) {
3291
+ const canceled = body.result === "canceled";
3292
+ if (canceled) return {
3293
+ ok: false,
3294
+ canceled,
3295
+ patch: { error: "stopped by user" }
3296
+ };
3297
+ if (body.result !== "success") return {
3298
+ ok: false,
3299
+ canceled,
3300
+ patch: { error: typeof body.error === "string" ? clipText(body.error, MESSAGE_CAP) : "run failed on machine" }
3301
+ };
3302
+ const parsed = parseFinalStatus(body.finalText);
3303
+ if (!parsed) return {
3304
+ ok: false,
3305
+ canceled,
3306
+ patch: { error: MISSING_STATUS_ERROR }
3307
+ };
3308
+ return {
3309
+ ok: true,
3310
+ canceled,
3311
+ patch: {
3312
+ status: parsed.status,
3313
+ message: clipText(parsed.message, 2e3) || null,
3314
+ attention: parsed.attention,
3315
+ error: null
3316
+ }
3317
+ };
3211
3318
  }
3212
3319
  function validateTerminalReport(body) {
3213
3320
  const issues = [];
@@ -3227,6 +3334,25 @@ function validateTerminalReport(body) {
3227
3334
  "error",
3228
3335
  "finalText"
3229
3336
  ]) if (has(key) && typeof body[key] !== "string") issues.push(`${key} must be a string`);
3337
+ if (has("files")) {
3338
+ const files = body.files;
3339
+ if (!Array.isArray(files) || files.length > REPORT_FILES_MAX) issues.push(`files must be an array of at most ${REPORT_FILES_MAX} entries`);
3340
+ else {
3341
+ const names = /* @__PURE__ */ new Set();
3342
+ for (const entry of files) {
3343
+ const file = entry;
3344
+ if (!(file !== null && typeof file === "object" && !Array.isArray(file) && Object.keys(file).sort().join(",") === REPORT_FILE_FIELDS.join(",") && typeof file.name === "string" && file.name.length > 0 && file.name.length <= 100 && !names.has(file.name) && isValidHash(file.baseHash) && isValidHash(file.hash) && typeof file.size === "number" && Number.isInteger(file.size) && file.size >= 0 && file.size <= LOOP_FILE_BYTE_CAP)) {
3345
+ issues.push("files entries must be exactly {name, baseHash, hash, size} with unique names, sha256 hashes, and an in-cap size");
3346
+ break;
3347
+ }
3348
+ names.add(file.name);
3349
+ }
3350
+ }
3351
+ }
3352
+ if (has("fileNotices")) {
3353
+ const notices = body.fileNotices;
3354
+ if (!Array.isArray(notices) || notices.length > REPORT_NOTICES_MAX || notices.some((notice) => typeof notice !== "string" || notice.length > NOTICE_CAP)) issues.push(`fileNotices must be an array of at most ${REPORT_NOTICES_MAX} strings of at most ${NOTICE_CAP} characters`);
3355
+ }
3230
3356
  if (has("usage")) if (body.usage === null || typeof body.usage !== "object" || Array.isArray(body.usage)) issues.push("usage must be an object");
3231
3357
  else {
3232
3358
  const usage = body.usage;
@@ -3267,7 +3393,9 @@ var MachineGateway = class {
3267
3393
  constructor(scheduler, blobStore = createBlobStore()) {
3268
3394
  this.scheduler = scheduler;
3269
3395
  this.blobStore = blobStore;
3396
+ this.loopFiles = new LoopFiles(blobStore);
3270
3397
  }
3398
+ loopFiles;
3271
3399
  /** In-flight latch: the maintenance pass is sequential and the first post-deploy
3272
3400
  * backlog reclamation can overrun the interval, so a fresh tick skips rather than
3273
3401
  * running a second pass concurrently (idempotent but wasteful + double-counts). */
@@ -3275,7 +3403,7 @@ var MachineGateway = class {
3275
3403
  applyAutopauseTimer(loopId, terminal) {
3276
3404
  if (!terminal.autoPaused) return;
3277
3405
  this.scheduler.removeLoop(loopId);
3278
- log$2.warn({
3406
+ log$1.warn({
3279
3407
  loopId,
3280
3408
  streak: terminal.failureStreak
3281
3409
  }, "loop auto-paused after block or consecutive errors");
@@ -3344,20 +3472,12 @@ var MachineGateway = class {
3344
3472
  await pruneExpiredLeases(now);
3345
3473
  for (const loop of await listLoops()) if (loop.deleteRequestedAt) await tryDeleteLoop(loop.id);
3346
3474
  }
3347
- /** Finalize one stuck run as an error (the sweep's reclaim path): persist the
3348
- * failure, TERMINALIZE its run lease (flip it to `terminal-grace` rather than
3349
- * retiring it outright), while preserving a bounded reconciliation window.
3350
- *
3351
- * Why terminalize, not retire: the usual cause is a laptop that merely fell
3352
- * ASLEEP mid-run. When it wakes, claude finishes and the daemon delivers the real
3353
- * (often SUCCESSFUL) result. Retiring the lease here would 401 that late report
3354
- * and strand the run as a permanent false failure with its message lost (the
3355
- * investigated bug). So the lease survives a bounded grace window
3356
- * (`TERMINAL_GRACE_MS`) during which exactly ONE late wake-report may reconcile
3357
- * the run — see `report()`'s terminal-grace branch. The credential is still
3358
- * bounded: CLI mutations are refused while terminal-grace, and the
3359
- * reconciliation retires the lease single-shot. Pending rows are durable inbox
3360
- * entries and are never reclaimed by this path. */
3475
+ /** Reclaim one silent running run as an error, but terminalize its lease instead
3476
+ * of retiring it. The usual cause is a laptop that fell asleep mid-run; on wake
3477
+ * the daemon delivers the real (often successful) result, and a retired lease
3478
+ * would 401 it and leave a false failure. During TERMINAL_GRACE_MS exactly one
3479
+ * late report may reconcile the run (see `report()`); CLI mutations stay refused.
3480
+ * Pending rows are durable queue entries and never reach this path. */
3361
3481
  async reclaimRun(run, reason) {
3362
3482
  if (run.phase !== "running") return;
3363
3483
  const at = nowIso();
@@ -3371,13 +3491,14 @@ var MachineGateway = class {
3371
3491
  * Periodic storage maintenance: prune each loop's run snapshots to the
3372
3492
  * retention window, then GC blob bytes no live row needs. Wired to its own
3373
3493
  * interval in boot (independent of the faster offline-sweep) and exposed for
3374
- * tests / on-demand triggers. Safe to run concurrently with active syncs (a
3375
- * grace window + final re-check protect freshly-written/referenced blobs) and
3376
- * idempotent with no garbage. Best-effort — never throws into the caller.
3494
+ * tests / on-demand triggers. Safe to run concurrently with owner writes and
3495
+ * run uploads (a grace window + final re-check protect freshly-written or
3496
+ * referenced blobs) and idempotent with no garbage. Best-effort — never throws
3497
+ * into the caller.
3377
3498
  */
3378
3499
  async maintainStorage(options) {
3379
3500
  if (this.maintenanceRunning) {
3380
- log$2.info("storage maintenance already in progress — skipping this tick");
3501
+ log$1.info("storage maintenance already in progress — skipping this tick");
3381
3502
  return {
3382
3503
  snapshotsPruned: 0,
3383
3504
  blobsReclaimed: 0
@@ -3387,7 +3508,7 @@ var MachineGateway = class {
3387
3508
  try {
3388
3509
  return await maintainStorage(this.blobStore, options);
3389
3510
  } catch (err) {
3390
- log$2.warn({ err: err instanceof Error ? err.message : String(err) }, "storage maintenance failed");
3511
+ log$1.warn({ err: err instanceof Error ? err.message : String(err) }, "storage maintenance failed");
3391
3512
  return {
3392
3513
  snapshotsPruned: 0,
3393
3514
  blobsReclaimed: 0
@@ -3413,7 +3534,7 @@ var MachineGateway = class {
3413
3534
  token: deviceToken,
3414
3535
  online: true
3415
3536
  });
3416
- log$2.info({
3537
+ log$1.info({
3417
3538
  machineId,
3418
3539
  host: info?.host
3419
3540
  }, "poll: self-registered machine");
@@ -3445,7 +3566,7 @@ var MachineGateway = class {
3445
3566
  const claimed = await claimReadyRunForMachine(machineId, void 0, currentRunIds ?? []);
3446
3567
  if (claimed) delivery = await buildDelivery(claimed.loop, claimed.run, claimed.runToken, machine.roots ?? []);
3447
3568
  }
3448
- if (delivery) log$2.info({
3569
+ if (delivery) log$1.info({
3449
3570
  machineId,
3450
3571
  runId: delivery.runId
3451
3572
  }, "poll: delivered");
@@ -3463,7 +3584,7 @@ var MachineGateway = class {
3463
3584
  code: "VALIDATION_ERROR"
3464
3585
  }
3465
3586
  };
3466
- if (request.protocolVersion !== 4) {
3587
+ if (request.protocolVersion !== 5) {
3467
3588
  const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
3468
3589
  if (!auth.ok && auth.reason === "mismatch") return auth.response;
3469
3590
  if (auth.ok && auth.machine) {
@@ -3475,7 +3596,7 @@ var MachineGateway = class {
3475
3596
  body: {
3476
3597
  error: "daemon upgrade required; run `npm install -g @kky42/pievo@latest`, then `pievo daemon restart`",
3477
3598
  code: "UPGRADE_REQUIRED",
3478
- requiredProtocol: 4
3599
+ requiredProtocol: 5
3479
3600
  }
3480
3601
  };
3481
3602
  }
@@ -3507,7 +3628,7 @@ var MachineGateway = class {
3507
3628
  const needsUpdate = daemonNeedsUpdate(reportedVersion);
3508
3629
  const base = await this.pollCore(deviceToken, request.info, currentIds, !needsUpdate, true);
3509
3630
  if (base.status !== 200) return base;
3510
- if ((await getMachine(machineId))?.daemonProtocol !== 4) await updateMachine(machineId, { daemonProtocol: 4 });
3631
+ if ((await getMachine(machineId))?.daemonProtocol !== 5) await updateMachine(machineId, { daemonProtocol: 5 });
3511
3632
  const running = await runningRunsForMachine(machineId);
3512
3633
  const currentSet = new Set(currentIds);
3513
3634
  return {
@@ -3524,7 +3645,7 @@ var MachineGateway = class {
3524
3645
  };
3525
3646
  }
3526
3647
  async pollV4Wait(deviceToken, request, waitMs = LONG_POLL_WAIT_MS) {
3527
- if (request.currentRuns?.length || request.protocolVersion !== 4) return this.pollV4(deviceToken, request);
3648
+ if (request.currentRuns?.length || request.protocolVersion !== 5) return this.pollV4(deviceToken, request);
3528
3649
  const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
3529
3650
  if (!auth.ok) return auth.response;
3530
3651
  const machineId = auth.machineId;
@@ -3542,11 +3663,8 @@ var MachineGateway = class {
3542
3663
  waiter.cancel();
3543
3664
  }
3544
3665
  }
3545
- /**
3546
- * Whether this machine (by device token) currently has a live daemon — so
3547
- * Claude Code can avoid starting a duplicate. `online` is fresh-checked against
3548
- * the poll TTL, not just the stored flag.
3549
- */
3666
+ /** Whether this machine has a live daemon. `online` is fresh-checked against the
3667
+ * poll TTL, not just the stored flag. */
3550
3668
  async status(deviceToken) {
3551
3669
  const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
3552
3670
  if (!auth.ok) return auth.response;
@@ -3632,7 +3750,7 @@ var MachineGateway = class {
3632
3750
  });
3633
3751
  this.scheduler.addLoop(loop);
3634
3752
  recordNewIdempotency(idempotencyKey, machineId, loop.id);
3635
- log$2.info({
3753
+ log$1.info({
3636
3754
  machineId,
3637
3755
  loopId: loop.id,
3638
3756
  agent: loop.agent
@@ -3846,7 +3964,7 @@ var MachineGateway = class {
3846
3964
  }
3847
3965
  const response = receiptResponse(stored, expected);
3848
3966
  if (response.status < 300) await retireLease(runToken);
3849
- log$2.info({ runId: lease.runId }, "report: ignored (run was canceled)");
3967
+ log$1.info({ runId: lease.runId }, "report: ignored (run was canceled)");
3850
3968
  return response;
3851
3969
  }
3852
3970
  async reportRaceResponse(runToken, lease, body, payloadDigest) {
@@ -3870,7 +3988,7 @@ var MachineGateway = class {
3870
3988
  const refreshedLease = await resolveLease(runToken);
3871
3989
  if (refreshedLease?.state === "retired") return this.retiredReport(runToken, body, lease.runId);
3872
3990
  if (retryReconcile && fresh?.phase === "error" && refreshedLease?.state === "terminal-grace") return this.reconcileReclaimedReport(runToken, refreshedLease, fresh, body);
3873
- log$2.info({
3991
+ log$1.info({
3874
3992
  runId: lease.runId,
3875
3993
  phase: fresh?.phase
3876
3994
  }, logMessage);
@@ -3883,12 +4001,108 @@ var MachineGateway = class {
3883
4001
  }
3884
4002
  };
3885
4003
  }
4004
+ /** Reported loop-file versions whose uploaded bytes are present. A canceled run
4005
+ * was killed mid-edit, so none of its files are applied. */
4006
+ async terminalFiles(body) {
4007
+ const notices = (body.fileNotices ?? []).map((notice) => clipText(notice, NOTICE_CAP));
4008
+ const updates = [];
4009
+ if (body.result === "canceled") return {
4010
+ updates,
4011
+ notices
4012
+ };
4013
+ const files = body.files ?? [];
4014
+ const sizes = await blobSizes(files.map((file) => file.hash).filter((hash) => hash !== EMPTY_SHA256));
4015
+ for (const file of files) {
4016
+ if (!await this.loopFiles.hasBlob(file.hash)) {
4017
+ notices.push(`${file.name}: the upload was lost; the previous version was kept`);
4018
+ continue;
4019
+ }
4020
+ const size = file.hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ? 0 : sizes.get(file.hash);
4021
+ if (size === void 0 || size !== file.size) {
4022
+ notices.push(`${file.name}: reported size did not match the stored version; the previous version was kept`);
4023
+ continue;
4024
+ }
4025
+ updates.push({
4026
+ name: file.name,
4027
+ baseHash: file.baseHash,
4028
+ hash: file.hash,
4029
+ size
4030
+ });
4031
+ }
4032
+ return {
4033
+ updates,
4034
+ notices
4035
+ };
4036
+ }
4037
+ /** Current bytes of one loop file for an executing run (the run's starting version). */
4038
+ async readRunFile(runToken, name) {
4039
+ const lease = await resolveLease(runToken);
4040
+ if (!lease) return {
4041
+ status: 401,
4042
+ body: { error: "invalid or expired token" }
4043
+ };
4044
+ if (lease.state !== "active") return {
4045
+ status: 409,
4046
+ body: { error: "run is no longer executing" }
4047
+ };
4048
+ const loop = await getLoop(lease.loopId);
4049
+ if (!loop || !loopFileNames(loop).includes(name)) return {
4050
+ status: 404,
4051
+ body: { error: "no such loop file" }
4052
+ };
4053
+ const row = await getArtifactFile(loop.id, name);
4054
+ const hash = row && !row.deleted && row.hash ? row.hash : EMPTY_SHA256;
4055
+ const bytes = await this.loopFiles.readBlob(hash);
4056
+ if (!bytes) return {
4057
+ status: 500,
4058
+ body: { error: "loop file bytes are missing" }
4059
+ };
4060
+ return {
4061
+ status: 200,
4062
+ body: {},
4063
+ bytes,
4064
+ hash
4065
+ };
4066
+ }
4067
+ /** Store one loop-file version produced by a run. Accepted until the run's
4068
+ * authority retires, so the durable report outbox can retry after a crash.
4069
+ * Applying it to the loop happens only in the terminal report transaction. */
4070
+ async storeRunFile(runToken, name, bytes, expectedHash) {
4071
+ const lease = await resolveLease(runToken);
4072
+ if (!lease || lease.state === "retired") return {
4073
+ status: 401,
4074
+ body: { error: "invalid or expired token" }
4075
+ };
4076
+ const loop = await getLoop(lease.loopId);
4077
+ if (!loop || !loopFileNames(loop).includes(name)) return {
4078
+ status: 404,
4079
+ body: { error: "no such loop file" }
4080
+ };
4081
+ const issue = loopFileIssue(name, bytes);
4082
+ if (issue) return {
4083
+ status: 413,
4084
+ body: { error: issue }
4085
+ };
4086
+ if (expectedHash !== null && expectedHash !== sha256Buf(bytes)) return {
4087
+ status: 400,
4088
+ body: { error: "hash mismatch" }
4089
+ };
4090
+ const hash = await this.loopFiles.putBlob(bytes);
4091
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") await recordBlob(hash, bytes.length);
4092
+ return {
4093
+ status: 200,
4094
+ body: {
4095
+ ok: true,
4096
+ hash
4097
+ }
4098
+ };
4099
+ }
3886
4100
  async captureRunSnapshot(runId, loopId) {
3887
4101
  try {
3888
4102
  await putRunSnapshot(runId, loopId, await buildLoopManifest(loopId));
3889
4103
  await pruneRunSnapshots(loopId, 20);
3890
4104
  } catch (err) {
3891
- log$2.warn({
4105
+ log$1.warn({
3892
4106
  runId,
3893
4107
  err: err instanceof Error ? err.message : String(err)
3894
4108
  }, "snapshot capture failed");
@@ -3898,11 +4112,8 @@ var MachineGateway = class {
3898
4112
  * same loop-lock transaction as the error→done/error patch and loop state, so
3899
4113
  * concurrent late reports cannot both win (including error→error). */
3900
4114
  async reconcileReclaimedReport(runToken, lease, run, body) {
3901
- const providerOk = body.result === "success";
3902
- const canceled = body.result === "canceled";
3903
- const protocolMissing = providerOk ? runProtocolMissing(run) : [];
3904
- const ok = providerOk && protocolMissing.length === 0;
3905
- const message = run.message ?? void 0;
4115
+ const { ok, canceled, patch } = reportOutcome(body);
4116
+ const files = await this.terminalFiles(body);
3906
4117
  const loopPatch = {};
3907
4118
  const receipt = receiptFor(body, lease.runId);
3908
4119
  const payloadDigest = sha256(JSON.stringify(body));
@@ -3911,10 +4122,10 @@ var MachineGateway = class {
3911
4122
  reconciled = await reconcileReclaimedRun(lease.loopId, lease.runId, sha256(runToken), {
3912
4123
  phase: canceled ? "canceled" : ok ? "done" : "error",
3913
4124
  ...coerceTelemetry(body),
3914
- ...message !== void 0 ? { message } : {},
3915
- ...canceled ? { error: "stopped by user" } : protocolMissing.length ? { error: `run protocol incomplete: missing ${protocolMissing.join(", ")}` } : ok ? { error: null } : { error: typeof body.error === "string" ? clipText(body.error, MESSAGE_CAP) : run.error },
4125
+ ...patch,
4126
+ fileNotices: files.notices,
3916
4127
  ts: nowIso()
3917
- }, loopPatch, AUTOPAUSE_STREAK, receipt);
4128
+ }, loopPatch, AUTOPAUSE_STREAK, receipt, files.updates);
3918
4129
  } catch (error) {
3919
4130
  const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
3920
4131
  if (raced) return raced;
@@ -3927,7 +4138,7 @@ var MachineGateway = class {
3927
4138
  if (!reportOnly) this.scheduler.addLoop(reconciled.loop);
3928
4139
  if (!reportOnly) this.applyAutopauseTimer(lease.loopId, reconciled);
3929
4140
  if (deleting) await tryDeleteLoop(lease.loopId);
3930
- log$2.info({
4141
+ log$1.info({
3931
4142
  runId: lease.runId,
3932
4143
  ok,
3933
4144
  reclaimed: true
@@ -3980,7 +4191,7 @@ var MachineGateway = class {
3980
4191
  this.scheduler.addLoop(rejected.loop);
3981
4192
  if (rejected.state === "run-error") this.applyAutopauseTimer(lease.loopId, rejected);
3982
4193
  if (rejected.loop.deleteRequestedAt) await tryDeleteLoop(lease.loopId);
3983
- log$2.warn({
4194
+ log$1.warn({
3984
4195
  runId: lease.runId,
3985
4196
  reportId,
3986
4197
  code,
@@ -4034,8 +4245,6 @@ var MachineGateway = class {
4034
4245
  if (evidence.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
4035
4246
  if (issues.length) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_INVALID", issues);
4036
4247
  const expected = receiptFor(body, lease.runId);
4037
- const ok = body.result === "success";
4038
- const canceled = body.result === "canceled";
4039
4248
  const run = await getRun(lease.runId);
4040
4249
  if (run?.phase === "canceled") return this.ignoreCanceledReport(runToken, lease, body);
4041
4250
  if (run?.phase === "done") return {
@@ -4047,17 +4256,18 @@ var MachineGateway = class {
4047
4256
  }
4048
4257
  };
4049
4258
  if (run?.phase === "error" && (lease.state === "terminal-grace" || lease.state === "reconciliation-only")) return this.reconcileReclaimedReport(runToken, lease, run, body);
4050
- const protocolMissing = ok && run ? runProtocolMissing(run) : [];
4051
- const effectiveOk = ok && protocolMissing.length === 0;
4259
+ const { ok: effectiveOk, canceled, patch } = reportOutcome(body);
4260
+ const files = await this.terminalFiles(body);
4052
4261
  const loopPatch = {};
4053
4262
  let terminal;
4054
4263
  try {
4055
4264
  terminal = await finalizeRunningRun(lease.loopId, lease.runId, {
4056
4265
  phase: canceled ? "canceled" : effectiveOk ? "done" : "error",
4057
4266
  ...coerceTelemetry(body),
4058
- ...canceled ? { error: "stopped by user" } : protocolMissing.length ? { error: `run protocol incomplete: missing ${protocolMissing.join(", ")}` } : effectiveOk ? {} : { error: typeof body.error === "string" ? clipText(body.error, MESSAGE_CAP) : "run failed on machine" },
4267
+ ...patch,
4268
+ fileNotices: files.notices,
4059
4269
  ts: nowIso()
4060
- }, loopPatch, sha256(runToken), AUTOPAUSE_STREAK, expected);
4270
+ }, loopPatch, sha256(runToken), AUTOPAUSE_STREAK, expected, files.updates);
4061
4271
  } catch (error) {
4062
4272
  const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
4063
4273
  if (raced) return raced;
@@ -4068,7 +4278,7 @@ var MachineGateway = class {
4068
4278
  if (!deleting) await this.captureRunSnapshot(lease.runId, lease.loopId);
4069
4279
  this.scheduler.addLoop(terminal.loop);
4070
4280
  this.applyAutopauseTimer(lease.loopId, terminal);
4071
- log$2.info({
4281
+ log$1.info({
4072
4282
  runId: lease.runId,
4073
4283
  ok: effectiveOk
4074
4284
  }, "report: finalized");
@@ -4182,157 +4392,9 @@ function nextFires(cron, timezone, n) {
4182
4392
  }
4183
4393
  }
4184
4394
  /**
4185
- * Artifact sync - the byte-ingress half of the machine gateway, split out of
4186
- * `MachineGateway` (which keeps poll/report/CLI/retention). Same wire surface,
4187
- * framework-agnostic like the rest of the gateway (`{ status, body }` results):
4188
- *
4189
- * POST /api/machine/sync (Bearer device token) → manifest reconcile
4190
- * PUT /api/machine/blob/:hash (Bearer device token) → negotiated blob upload
4191
- *
4192
- * plus `readBlob` (the download seam the artifact/diff readers resolve bytes
4193
- * through). Boot constructs ONE BlobStore and hands the SAME instance to this
4194
- * class and to `MachineGateway` (whose `maintainStorage` GC deletes the bytes
4195
- * written here).
4196
- */
4197
- var log$1 = logger.child({ mod: "gateway" });
4198
- var BLOB_PRESENCE_CONCURRENCY = 16;
4199
- async function mapConcurrent(items, limit, visit) {
4200
- let next = 0;
4201
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
4202
- while (next < items.length) {
4203
- const item = items[next++];
4204
- await visit(item);
4205
- }
4206
- }));
4207
- }
4208
- var ArtifactSync = class {
4209
- blobStore;
4210
- constructor(blobStore = createBlobStore()) {
4211
- this.blobStore = blobStore;
4212
- }
4213
- /**
4214
- * Reconcile one complete configured-path manifest after a run. Missing hashes
4215
- * are returned for upload through the raw blob PUT endpoint.
4216
- */
4217
- async sync(deviceToken, rawBody) {
4218
- const auth = await authenticateDeviceToken(deviceToken);
4219
- if (!auth.ok) return auth.response;
4220
- const { machineId } = auth;
4221
- const body = parseArtifactSyncBody(rawBody);
4222
- if (!body) return {
4223
- status: 400,
4224
- body: { error: "invalid sync body" }
4225
- };
4226
- const { loopId, manifest } = body;
4227
- const loop = await getLoop(loopId);
4228
- if (!loop || loop.machineId !== machineId) return {
4229
- status: 404,
4230
- body: { error: "no such loop on this machine" }
4231
- };
4232
- const allowedPaths = new Set(loop.artifacts);
4233
- if (manifest.some((entry) => !allowedPaths.has(entry.path))) return {
4234
- status: 400,
4235
- body: { error: "manifest path is not configured on this loop" }
4236
- };
4237
- const keepPaths = [];
4238
- const needHashes = /* @__PURE__ */ new Set();
4239
- const storedState = /* @__PURE__ */ new Map();
4240
- const stateFor = async (hash) => {
4241
- const cached = storedState.get(hash);
4242
- if (cached) return cached;
4243
- const metadata = await blobExists(hash);
4244
- const state = {
4245
- metadata,
4246
- bytes: metadata && await this.blobStore.has(hash)
4247
- };
4248
- storedState.set(hash, state);
4249
- return state;
4250
- };
4251
- await mapConcurrent([...new Set(manifest.flatMap((entry) => entry.oversize ? [] : [entry.hash]))], BLOB_PRESENCE_CONCURRENCY, async (hash) => {
4252
- await stateFor(hash);
4253
- });
4254
- for (const entry of manifest) {
4255
- if (entry.oversize) {
4256
- if (await upsertConfiguredArtifactFile({
4257
- loopId,
4258
- path: entry.path,
4259
- hash: null,
4260
- size: entry.size,
4261
- binary: entry.binary,
4262
- oversize: true
4263
- })) keepPaths.push(entry.path);
4264
- continue;
4265
- }
4266
- const hash = entry.hash;
4267
- const stored = await stateFor(hash);
4268
- if (!await upsertConfiguredArtifactFile({
4269
- loopId,
4270
- path: entry.path,
4271
- hash,
4272
- size: entry.size,
4273
- binary: entry.binary,
4274
- oversize: false
4275
- })) continue;
4276
- if (!stored.bytes) needHashes.add(hash);
4277
- keepPaths.push(entry.path);
4278
- }
4279
- const tombstoned = await reconcileConfiguredArtifacts(loopId, [...allowedPaths], keepPaths);
4280
- log$1.info({
4281
- machineId,
4282
- loopId,
4283
- files: keepPaths.length,
4284
- need: needHashes.size,
4285
- tombstoned
4286
- }, "sync: reconciled");
4287
- return {
4288
- status: 200,
4289
- body: {
4290
- ok: true,
4291
- needHashes: [...needHashes]
4292
- }
4293
- };
4294
- }
4295
- /**
4296
- * Upload one content-addressed blob's raw bytes (Bearer device token). The
4297
- * server recomputes sha256(body) and rejects any mismatch before storing —
4298
- * integrity + anti-poisoning, so a blob's bytes always match its key.
4299
- */
4300
- async putBlob(deviceToken, hash, bytes) {
4301
- const auth = await authenticateDeviceToken(deviceToken);
4302
- if (!auth.ok) return auth.response;
4303
- const { machineId } = auth;
4304
- if (!isValidHash(hash)) return {
4305
- status: 400,
4306
- body: { error: "invalid hash (expect sha256 hex)" }
4307
- };
4308
- if (bytes.length > 10485760) return {
4309
- status: 413,
4310
- body: { error: "blob exceeds size cap" }
4311
- };
4312
- if (sha256Buf(bytes) !== hash) return {
4313
- status: 400,
4314
- body: { error: "hash mismatch (sha256(body) !== :hash)" }
4315
- };
4316
- if (!await machineReferencesBlob(machineId, hash)) return {
4317
- status: 403,
4318
- body: { error: "hash was not requested for this machine (sync a manifest first)" }
4319
- };
4320
- await this.blobStore.put(hash, bytes);
4321
- await recordBlob(hash, bytes.length);
4322
- return {
4323
- status: 200,
4324
- body: { ok: true }
4325
- };
4326
- }
4327
- readBlob(hash) {
4328
- if (!isValidHash(hash)) return Promise.resolve(null);
4329
- return this.blobStore.get(hash);
4330
- }
4331
- };
4332
- /**
4333
- * Per-run artifact diff. Computed lazily on the server at read time
4395
+ * Per-run loop-file diff. Computed lazily on the server at read time
4334
4396
  * (no stored diffs): load run N's snapshot + the previous run's snapshot and diff
4335
- * the two path → metadata maps. For changed TEXT files we load both blobs and
4397
+ * the two name → metadata maps. For changed TEXT files we load both blobs and
4336
4398
  * compute a unified line diff with `diff` (jsdiff) — a pure-string library, no
4337
4399
  * process execution, so the server's zero-exec invariant holds. Binary/oversize
4338
4400
  * files emit a size-delta marker only (no inline diff).
@@ -4353,7 +4415,7 @@ function withinDiffCap(e) {
4353
4415
  }
4354
4416
  async function bytesOf(e) {
4355
4417
  if (!e.hash) return null;
4356
- return await (await getArtifactSync()).readBlob(e.hash) ?? null;
4418
+ return await (await getLoopFiles()).readBlob(e.hash) ?? null;
4357
4419
  }
4358
4420
  function unified(path, oldText, newText, maxEditLength) {
4359
4421
  return maxEditLength === void 0 ? createTwoFilesPatch(path, path, oldText, newText, "previous run", "this run") : createTwoFilesPatch(path, path, oldText, newText, "previous run", "this run", { maxEditLength });
@@ -4366,7 +4428,7 @@ function expectedInputBytes(change, verifiedSizes) {
4366
4428
  if (sizes.some((size) => size === void 0)) return null;
4367
4429
  return sizes.reduce((sum, size) => sum + size, 0);
4368
4430
  }
4369
- /** Compute one run's artifact diff. The default remains the web UI's complete
4431
+ /** Compute one run's loop-file diff. The default remains the web UI's complete
4370
4432
  * view. Budgeted callers get explicit totals and stop before blob reads/jsdiff
4371
4433
  * once any work limit is spent. */
4372
4434
  async function computeRunDiff(runId, budget) {
@@ -4663,6 +4725,7 @@ function normalizedListRun(run) {
4663
4725
  terminalAt: run.ts,
4664
4726
  phase: run.phase,
4665
4727
  status: run.status ?? null,
4728
+ attention: run.attention ?? null,
4666
4729
  durationMs: run.durationMs ?? null,
4667
4730
  tokenUsage: totalTokenUsage(run.usage ?? null),
4668
4731
  message: message.value,
@@ -4717,6 +4780,7 @@ async function listHistory(loop, q) {
4717
4780
  "terminal",
4718
4781
  "phase",
4719
4782
  "status",
4783
+ "attention",
4720
4784
  "durationMs",
4721
4785
  "tokenUsage",
4722
4786
  "message",
@@ -4726,6 +4790,7 @@ async function listHistory(loop, q) {
4726
4790
  fmt(run.ts),
4727
4791
  run.phase,
4728
4792
  run.status ?? null,
4793
+ run.attention ?? null,
4729
4794
  run.durationMs ?? null,
4730
4795
  totalTokenUsage(run.usage ?? null),
4731
4796
  run.message ? truncate(run.message, 500, "use --run for detail").value : null,
@@ -4773,6 +4838,7 @@ async function detailHistory(loop, q) {
4773
4838
  terminalAt: TERMINAL_PHASES.includes(run.phase) ? run.ts : null,
4774
4839
  phase: run.phase,
4775
4840
  status: run.status ?? null,
4841
+ attention: run.attention ?? null,
4776
4842
  durationMs: run.durationMs ?? null,
4777
4843
  tokenUsage: totalTokenUsage(run.usage ?? null),
4778
4844
  message: message.value,
@@ -4781,6 +4847,7 @@ async function detailHistory(loop, q) {
4781
4847
  errorTruncated: error.truncated,
4782
4848
  finalText: finalText.value,
4783
4849
  finalTextTruncated: finalText.truncated,
4850
+ fileNotices: run.fileNotices ?? [],
4784
4851
  diffAvailable: !!snapshot,
4785
4852
  diff: q.diff ? await boundedDiff(run.id) : {
4786
4853
  included: false,
@@ -4798,6 +4865,7 @@ async function detailHistory(loop, q) {
4798
4865
  ["terminalAt", detail.terminalAt],
4799
4866
  ["phase", detail.phase],
4800
4867
  ["status", detail.status],
4868
+ ["attention", detail.attention],
4801
4869
  ["durationMs", detail.durationMs],
4802
4870
  ["tokenUsage", detail.tokenUsage],
4803
4871
  ["message", detail.message],
@@ -4806,6 +4874,7 @@ async function detailHistory(loop, q) {
4806
4874
  ["errorTruncated", detail.errorTruncated],
4807
4875
  ["finalText", detail.finalText],
4808
4876
  ["finalTextTruncated", detail.finalTextTruncated],
4877
+ ["fileNotices", { raw: JSON.stringify(detail.fileNotices) }],
4809
4878
  ["diffAvailable", detail.diffAvailable],
4810
4879
  ["diff", { raw: JSON.stringify(detail.diff) }]
4811
4880
  ]));
@@ -4820,18 +4889,10 @@ async function readLoopHistory(loop, flags) {
4820
4889
  return parsed.value.mode === "detail" ? detailHistory(loop, parsed.value) : listHistory(loop, parsed.value);
4821
4890
  }
4822
4891
  /**
4823
- * CLI dispatch - the credential-keyed verb router half of the machine gateway,
4824
- * split out of `MachineGateway` (which keeps poll/report/sweep/owner verbs).
4825
- * Same wire surface, framework-agnostic like the rest of the gateway
4826
- * (`{ status, body }` results):
4827
- *
4828
- * POST /api/machine/cli (Bearer device OR run credential) → unified dispatch
4829
- *
4830
- * The verb router keys authority on credential type first (`dk_` device prefix
4831
- * vs `rk_` run lease) and reuses the core gateway
4832
- * methods (`createLoop`/`editLoop`) through the injected `MachineGateway`,
4833
- * plus the credential-neutral history module. Run credentials expose only the
4834
- * exactly-once report callback.
4892
+ * `/api/machine/cli` verb router. Authority keys on credential type: a device
4893
+ * (`dk_`) token gets owner verbs over loops bound to its machine, reusing
4894
+ * `MachineGateway`'s owner methods; a run (`rk_`) token gets no verb, because a
4895
+ * run reports only through its final response.
4835
4896
  */
4836
4897
  var cliLog = logger.child({ mod: "cli" });
4837
4898
  var CliGateway = class {
@@ -4845,15 +4906,6 @@ var CliGateway = class {
4845
4906
  this.forceDeleteLoop = deps.forceDeleteLoop ?? forceDeleteLoop;
4846
4907
  this.destructiveLog = deps.destructiveLog ?? ((event) => cliLog.warn(event, "force-delete: destructive server authority removal"));
4847
4908
  }
4848
- /**
4849
- * The unified CLI endpoint. It is a ROUTER in front of the gateway logic that
4850
- * already exists — never a rewrite — that keys authority on the credential type:
4851
- * · DEVICE credential (`dk_`-prefixed) → owner authority over any loop bound to
4852
- * the machine: `new`→createLoop, `loops`→listLoops, `edit`→editLoop,
4853
- * `log`→readLoopHistory, `show`→describe. `report` is RUN-only (403).
4854
- * · RUN credential (`rk_` run lease) → report and report help only.
4855
- * Every owner verb is rejected.
4856
- */
4857
4909
  async cli(token, argv) {
4858
4910
  return finalizeCli(token.startsWith("dk_") ? await this.deviceCli(token, argv) : await this.runCli(token, argv));
4859
4911
  }
@@ -4943,13 +4995,10 @@ var CliGateway = class {
4943
4995
  }
4944
4996
  };
4945
4997
  }
4946
- case "report": return {
4947
- status: 403,
4948
- body: { error: "pievo: report is a run-only verb; the owner edits via edit" }
4949
- };
4998
+ case "file": return this.ownerFile(machineId, argv);
4950
4999
  default: return {
4951
5000
  status: 400,
4952
- body: { error: `pievo: unknown command "${verb}" for the device credential (try: new, loops, pause, start, stop, delete, run stop, edit, log, show)` }
5001
+ body: { error: `pievo: unknown command "${verb}" for the device credential (try: new, loops, pause, start, stop, delete, run stop, edit, log, show, file)` }
4953
5002
  };
4954
5003
  }
4955
5004
  }
@@ -4998,7 +5047,7 @@ var CliGateway = class {
4998
5047
  }
4999
5048
  async requireStopProtocol(machineId, running) {
5000
5049
  if (!running) return void 0;
5001
- if ((await getMachine(machineId))?.daemonProtocol === 4) return void 0;
5050
+ if ((await getMachine(machineId))?.daemonProtocol === 5) return void 0;
5002
5051
  return {
5003
5052
  status: 426,
5004
5053
  body: {
@@ -5109,121 +5158,90 @@ var CliGateway = class {
5109
5158
  body: { text: `run already finished: ${runResultToken(stopped)}` }
5110
5159
  };
5111
5160
  }
5112
- async runCli(runToken, argv) {
5113
- const lease = await resolveLease(runToken);
5114
- if (!lease) return {
5161
+ async runCli(runToken, _argv) {
5162
+ if (!await resolveLease(runToken)) return {
5115
5163
  status: 401,
5116
5164
  body: {
5117
5165
  text: errorBlock("invalid or expired token", "UNAUTHORIZED"),
5118
5166
  exitCode: 1
5119
5167
  }
5120
5168
  };
5121
- if (lease.state === "terminal-grace" || lease.state === "reconciliation-only") return {
5122
- status: 409,
5123
- body: {
5124
- text: errorBlock(TERMINAL_GRACE_MSG, "CONFLICT"),
5125
- exitCode: 1
5126
- }
5127
- };
5128
- const verb = argv[0] ?? "";
5129
- const flags = parseFlags(argv.slice(1));
5130
- if (DEVICE_ONLY_VERBS.has(verb)) return {
5131
- status: 403,
5132
- body: {
5133
- text: errorBlock(`"${verb}" needs the device credential (owner authority); a run may only report`, "FORBIDDEN"),
5134
- exitCode: 1
5135
- }
5136
- };
5137
- if (flags.help === true && verb === "report") return {
5138
- status: 200,
5139
- body: {
5140
- text: verbHelpText("report", lease),
5141
- exitCode: 0
5142
- }
5143
- };
5144
- if (verb !== "report" && verb !== "help" && verb !== "--help" && verb !== "-h" && verb !== "") return {
5169
+ return {
5145
5170
  status: 403,
5146
5171
  body: {
5147
- text: errorBlock("run credentials authorize only pievo report", "FORBIDDEN"),
5172
+ text: errorBlock(RUN_HAS_NO_CLI, "FORBIDDEN"),
5148
5173
  exitCode: 1
5149
5174
  }
5150
5175
  };
5151
- const out = await this.dispatch(lease, createHash("sha256").update(runToken).digest("hex"), argv);
5152
- return {
5153
- status: out.code,
5154
- body: {
5155
- text: out.text,
5156
- exitCode: out.code === 200 ? 0 : 1
5157
- }
5158
- };
5159
5176
  }
5160
- async dispatch(lease, leaseTokenHash, argv) {
5161
- const verb = argv[0];
5162
- const flags = parseFlags(argv.slice(1));
5163
- const str = (key) => typeof flags[key] === "string" ? flags[key] : void 0;
5164
- if (verb === void 0 || verb === "" || verb === "-h" || verb === "--help" || verb === "help") return {
5165
- code: 200,
5166
- text: this.helpText(lease)
5177
+ /** `file list|get|set` over one loop's files. `set` takes base64 content so
5178
+ * arbitrary text (including leading `--`) survives argv transport. */
5179
+ async ownerFile(machineId, argv) {
5180
+ const [, sub, loopId, name] = argv;
5181
+ const loop = loopId ? await this.ownedLoop(machineId, loopId) : void 0;
5182
+ if (sub !== "list" && sub !== "get" && sub !== "set") return {
5183
+ status: 400,
5184
+ body: { error: `usage: ${FILE_USAGE}` }
5167
5185
  };
5168
- if (verb !== "report") return derr(403, "run credentials authorize only pievo report", "FORBIDDEN");
5169
- if (flags.help === true) return {
5170
- code: 200,
5171
- text: verbHelpText("report", lease)
5186
+ if (!loop) return {
5187
+ status: 404,
5188
+ body: { error: "no such loop on this machine" }
5172
5189
  };
5173
- const rawStatus = str("status");
5174
- const status = reportStatus(rawStatus);
5175
- if (!status) return derr(400, `status must be keep|no-change|block${rawStatus === void 0 ? "" : ` (got "${rawStatus}")`}`, "VALIDATION_ERROR");
5176
- const message = str("message")?.trim();
5177
- if (!message) return derr(400, "report requires a non-empty --message", "VALIDATION_ERROR");
5178
- const acceptedFlags = [
5179
- "status",
5180
- "message",
5181
- "help"
5182
- ];
5183
- const unknown = Object.keys(flags).filter((key) => !acceptedFlags.includes(key));
5184
- if (unknown.length) return derr(400, `report does not accept --${unknown[0]}`, "VALIDATION_ERROR");
5185
- if (lease.state === "retired") return {
5186
- code: 200,
5187
- text: "reported: run retired · result discarded"
5190
+ const names = loopFileNames(loop);
5191
+ if (sub === "list") return {
5192
+ status: 200,
5193
+ body: { text: doc(listBlock("files", [
5194
+ "name",
5195
+ "size",
5196
+ "limit",
5197
+ "updatedAt"
5198
+ ], await Promise.all(names.map(async (file) => {
5199
+ const row = await getArtifactFile(loop.id, file);
5200
+ const live = row && !row.deleted && row.hash ? row : void 0;
5201
+ return [
5202
+ file,
5203
+ live?.size ?? 0,
5204
+ fileLimitLabel(file),
5205
+ live ? fmtTime(live.updatedAt) : null
5206
+ ];
5207
+ }))), helpBlock([`Run \`pievo file get ${loop.id} <name>\` to print a file`, `Run \`pievo file set ${loop.id} <name> --from <path>\` to replace a file`])) }
5188
5208
  };
5189
- const applied = await this.applyReportMutation(lease, leaseTokenHash, status, message.slice(0, MESSAGE_CAP));
5190
- return applied.ok ? {
5191
- code: 200,
5192
- text: renderReportedText(status, true)
5193
- } : derr(applied.status ?? 409, applied.detail ?? "this run is no longer active", applied.code);
5194
- }
5195
- helpText(_lease) {
5196
- return doc(listBlock("verbs", ["verb", "syntax"], [["report", "--status keep|no-change|block --message <summary>"]]), helpBlock(["Call exactly one pievo report before finishing", "No other command is accepted from a run credential"]));
5197
- }
5198
- async applyReportMutation(lease, leaseTokenHash, status, message) {
5199
- const result = await recordRunReportOnce({
5200
- loopId: lease.loopId,
5201
- runId: lease.runId,
5202
- leaseTokenHash,
5203
- status,
5204
- message
5205
- });
5206
- if (result.state === "applied") return {
5207
- ok: true,
5208
- detail: "reported"
5209
+ if (!name || !names.includes(name)) return {
5210
+ status: 404,
5211
+ body: { error: `no such loop file: ${name ?? ""} (files: ${names.join(", ")})` }
5209
5212
  };
5210
- if (result.state === "missing-loop") return {
5211
- ok: false,
5212
- detail: "loop not found",
5213
- code: "NOT_FOUND",
5214
- status: 404
5213
+ if (sub === "get") {
5214
+ const row = await getArtifactFile(loop.id, name);
5215
+ const bytes = await this.gateway.loopFiles.readBlob(row && !row.deleted && row.hash ? row.hash : EMPTY_SHA256);
5216
+ if (!bytes) return {
5217
+ status: 500,
5218
+ body: { error: "loop file bytes are missing" }
5219
+ };
5220
+ return {
5221
+ status: 200,
5222
+ body: { text: bytes.length ? bytes.toString("utf8") : "\n" }
5223
+ };
5224
+ }
5225
+ const encoded = parseFlags(argv.slice(4))["content-base64"];
5226
+ if (typeof encoded !== "string") return {
5227
+ status: 400,
5228
+ body: { error: `usage: ${FILE_USAGE}` }
5215
5229
  };
5216
- if (result.state === "already-reported") return {
5217
- ok: false,
5218
- detail: "this run already reported",
5219
- code: "CONFLICT",
5220
- status: 409
5230
+ const bytes = Buffer.from(encoded, "base64");
5231
+ const issue = loopFileIssue(name, bytes);
5232
+ if (issue) return {
5233
+ status: 413,
5234
+ body: { error: `${issue}; nothing was changed` }
5235
+ };
5236
+ const hash = await this.gateway.loopFiles.putBlob(bytes);
5237
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") await recordBlob(hash, bytes.length);
5238
+ if (!await writeLoopFile(loop.id, name, hash, bytes.length)) return {
5239
+ status: 404,
5240
+ body: { error: "no such loop file" }
5221
5241
  };
5222
5242
  return {
5223
- ok: false,
5224
- detail: "this run is no longer active",
5225
- code: "CONFLICT",
5226
- status: 409
5243
+ status: 200,
5244
+ body: { text: `file: ${name}\nsize: ${bytes.length}\nupdated: true` }
5227
5245
  };
5228
5246
  }
5229
5247
  async describe(loopId, opts = {}) {
@@ -5268,17 +5286,8 @@ var CliGateway = class {
5268
5286
  })), scoped.elsewhere, await recentMachineRuns(loops, 3));
5269
5287
  }
5270
5288
  };
5271
- var TERMINAL_GRACE_MSG = "this run is terminal and no longer accepts commands; its final result is delivered via the terminal report";
5272
- /** Verbs that require device authority. `report` is run-only and rejected there. */
5273
- var DEVICE_ONLY_VERBS = new Set([
5274
- "new",
5275
- "edit",
5276
- "loops",
5277
- "start",
5278
- "stop",
5279
- "delete",
5280
- "run"
5281
- ]);
5289
+ var RUN_HAS_NO_CLI = "pievo commands are not available inside a run; end your final response with a summary and a last line `STATUS: keep|no-change|block`";
5290
+ var FILE_USAGE = "pievo file list <loop> | file get <loop> <name> | file set <loop> <name> --from <path>";
5282
5291
  var PAUSED_FINISHING = "loop paused; current run is finishing";
5283
5292
  var STOP_UPGRADE_REQUIRED = "Daemon upgrade required to stop a running process. Run `npm install -g @kky42/pievo@latest`, then `pievo daemon restart`.";
5284
5293
  var FORCE_DELETE_CONFIRMATION = "delete-server-data-anyway";
@@ -5316,15 +5325,6 @@ function parseJsonFlag(raw) {
5316
5325
  value: obj
5317
5326
  };
5318
5327
  }
5319
- /** A structured `error:`/`code:` TOON result for stdout.
5320
- * Mirrors the `{code, text}` shape `dispatch` returns; the slug defaults from the
5321
- * HTTP status but a caller may pin it (e.g. CONFLICT). */
5322
- function derr(code, message, slug) {
5323
- return {
5324
- code,
5325
- text: errorBlock(message, slug ?? codeForStatus(code))
5326
- };
5327
- }
5328
5328
  /** The structured keys a `/api/machine/cli` body may carry. The daemon renders
5329
5329
  * `text` and exits with `exitCode`; two structured channels remain because the
5330
5330
  * current daemon reads them as data:
@@ -5352,18 +5352,6 @@ function finalizeCli(res) {
5352
5352
  }
5353
5353
  return res;
5354
5354
  }
5355
- function renderReportedText(status, hasMessage) {
5356
- const parts = [];
5357
- if (status) parts.push(`status=${status}`);
5358
- if (hasMessage) parts.push("message recorded");
5359
- return `reported: ${parts.length ? parts.join(" · ") : "recorded"}`;
5360
- }
5361
- var RUN_VERB_HELP = { report: {
5362
- syntax: "report --status keep|no-change|block --message <text>",
5363
- summary: "record this run's required status and non-empty summary",
5364
- avail: () => "always available",
5365
- help: ["Run `pievo report --status no-change --message \"no actionable change\"` to close this run", "Run `pievo report --status keep --message \"completed the requested work\"` to retain a result"]
5366
- } };
5367
5355
  var DEVICE_VERB_HELP = {
5368
5356
  new: {
5369
5357
  syntax: "new --json '<config>' [--dry-run]",
@@ -5402,24 +5390,29 @@ var DEVICE_VERB_HELP = {
5402
5390
  },
5403
5391
  delete: {
5404
5392
  syntax: "delete <id> [--force]",
5405
- summary: "stop first, then delete server history and synced artifact metadata",
5406
- help: ["Stop this loop and delete its Pievo history and synced artifacts? Local project files are not deleted.", "--force requires a prior Delete request and explicit local confirmation; the local process may still be running"]
5393
+ summary: "stop first, then delete server history, memory, and artifacts",
5394
+ help: ["Stop this loop and delete its Pievo history, memory, and artifacts? Local project files are not deleted.", "--force requires a prior Delete request and explicit local confirmation; the local process may still be running"]
5407
5395
  },
5408
5396
  run: {
5409
5397
  syntax: "run stop <run-id>",
5410
5398
  summary: "stop one pending or running run without pausing its loop",
5411
5399
  help: ["A running run remains running until the daemon confirms cancellation"]
5412
5400
  },
5401
+ file: {
5402
+ syntax: "file list <id> | file get <id> <name> | file set <id> <name> --from <path>",
5403
+ summary: "inspect or replace a loop's MEMORY.md and artifact files",
5404
+ help: ["Run `pievo file get <id> MEMORY.md` to read the loop's memory", "An edit made while a run is executing wins over that run's version of the file"]
5405
+ },
5413
5406
  log: {
5414
5407
  syntax: "log [<id>] [--limit 1..20] [--since ISO] [--until ISO] [--status keep|no-change|block] [--phase done|error|canceled] [--run <index|UUID> [--diff]] [--json]",
5415
5408
  summary: "bounded terminal history or one detailed run",
5416
5409
  help: ["Run `pievo log <id> --json` for structured history", "Run `pievo log <id> --run 12 --diff` for bounded run detail and artifact diff"]
5417
5410
  }
5418
5411
  };
5419
- function verbHelpText(verb, lease) {
5420
- const spec = lease ? RUN_VERB_HELP[verb] : DEVICE_VERB_HELP[verb];
5412
+ function verbHelpText(verb) {
5413
+ const spec = DEVICE_VERB_HELP[verb];
5421
5414
  if (!spec) return void 0;
5422
- return doc(kvLine("verb", verb), kvLine("syntax", spec.syntax), kvLine("summary", spec.summary), lease && spec.avail ? kvLine("availability", spec.avail(lease)) : null, helpBlock(spec.help));
5415
+ return doc(kvLine("verb", verb), kvLine("syntax", spec.syntax), kvLine("summary", spec.summary), helpBlock(spec.help));
5423
5416
  }
5424
5417
  function loopEnvelope(loop) {
5425
5418
  return { ...canonicalLoopEnvelope(loop) };
@@ -5456,7 +5449,8 @@ function renderShowText(loop, env, totalRuns, lastRun, opts = {}) {
5456
5449
  const help = [
5457
5450
  `Run \`pievo show ${loop.id} --full\` to see the complete prompt`,
5458
5451
  `Run \`pievo edit ${loop.id} --json '{"schedule":{"mode":"continuous","delayMinutes":5}}'\` to change the schedule`,
5459
- `Run \`pievo log ${loop.id}\` to see recent run results`
5452
+ `Run \`pievo log ${loop.id}\` to see recent run results`,
5453
+ `Run \`pievo file list ${loop.id}\` to see the loop's memory and artifact files`
5460
5454
  ];
5461
5455
  return doc(block, kvLine("nextFire", nextFireDisplay(loop)), `lifecycle: ${loop.enabled ? "active" : "paused"}`, `runs: ${runsTally}`, !loop.enabled ? kvLine("pauseCause", loop.pauseCause?.kind === "failure-streak" ? `failure-streak (run ${loop.pauseCause.runId}, count ${loop.pauseCause.count})` : loop.pauseCause?.kind === "blocked" ? `blocked (run ${loop.pauseCause.runId})` : loop.pauseCause?.kind === "owner" ? "owner" : "unknown") : null, lastRun?.reportIncident ? kvLine("reportIncident", `${lastRun.reportIncident.code} · ${lastRun.reportIncident.faultDomain} · ${lastRun.reportIncident.reason}`) : null, helpBlock(help));
5462
5456
  }
@@ -5544,9 +5538,6 @@ function renderHomeText(ctx, presence, here, elsewhere, recent) {
5544
5538
  "Run `pievo new --json '{...}'` to create a loop"
5545
5539
  ]));
5546
5540
  }
5547
- function reportStatus(s) {
5548
- return s === "keep" || s === "no-change" || s === "block" ? s : void 0;
5549
- }
5550
5541
  /** Tiny flag parser: `--k v` pairs, bare `--flag` → true, first positional under `_`.
5551
5542
  * Every key/value is NUL-stripped at this wire boundary before validation or
5552
5543
  * persistence because Postgres text rejects NUL. */
@@ -5677,19 +5668,13 @@ var Scheduler = class {
5677
5668
  function msg(err) {
5678
5669
  return err instanceof Error ? err.message : String(err);
5679
5670
  }
5680
- /**
5681
- * Server boot — idempotent one-time init of the in-process backend: apply
5682
- * migrations, wire the machine gateway's dispatcher into the Scheduler, start
5683
- * the Scheduler + an offline-sweep interval. Guarded on `globalThis` so it
5684
- * survives dev HMR and runs at most once per process. Call `ensureServer()`
5685
- * from any server-side entry (the standalone machine server / TanStack server
5686
- * fns); the first call boots, the rest share the same instance.
5687
- */
5671
+ /** One-time, idempotent in-process backend boot (migrations, scheduler, sweep and
5672
+ * storage-maintenance intervals). Guarded on `globalThis` to survive dev HMR. */
5688
5673
  var boot_exports = /* @__PURE__ */ __exportAll({
5689
5674
  ensureServer: () => ensureServer,
5690
- getArtifactSync: () => getArtifactSync,
5691
5675
  getCliGateway: () => getCliGateway,
5692
- getGateway: () => getGateway
5676
+ getGateway: () => getGateway,
5677
+ getLoopFiles: () => getLoopFiles
5693
5678
  });
5694
5679
  var g = globalThis;
5695
5680
  function ensureServer() {
@@ -5714,9 +5699,7 @@ async function boot() {
5714
5699
  };
5715
5700
  let gateway;
5716
5701
  const scheduler = new Scheduler({ dispatch: (loop) => gateway.dispatcher.dispatch(loop) });
5717
- const blobStore = createBlobStore();
5718
- gateway = new MachineGateway(scheduler, blobStore);
5719
- const artifactSync = new ArtifactSync(blobStore);
5702
+ gateway = new MachineGateway(scheduler, createBlobStore());
5720
5703
  const cliGateway = new CliGateway(gateway);
5721
5704
  await scheduler.start(abort.signal);
5722
5705
  const sweep = setInterval(() => void gateway.sweep().catch((err) => logger.error({ err: String(err) }, "sweep tick failed")), ONLINE_TTL_MS);
@@ -5729,7 +5712,6 @@ async function boot() {
5729
5712
  return {
5730
5713
  scheduler,
5731
5714
  gateway,
5732
- artifactSync,
5733
5715
  cliGateway,
5734
5716
  abort,
5735
5717
  shutdown
@@ -5738,11 +5720,11 @@ async function boot() {
5738
5720
  async function getGateway() {
5739
5721
  return (await ensureServer()).gateway;
5740
5722
  }
5741
- async function getArtifactSync() {
5742
- return (await ensureServer()).artifactSync;
5723
+ async function getLoopFiles() {
5724
+ return (await ensureServer()).gateway.loopFiles;
5743
5725
  }
5744
5726
  async function getCliGateway() {
5745
5727
  return (await ensureServer()).cliGateway;
5746
5728
  }
5747
5729
  //#endregion
5748
- export { listMachines as A, reconciliationStatesForRuns as B, getMachine as C, isOutdated as D, hasRunningRun as E, machineIdFromToken as F, startLoop as G, requestRunCancel as H, machinePresence as I, updateLoop as J, stopLoop as K, mintDeviceToken as L, listRunsBefore as M, loopTags_C6vATFCp_exports as N, listArtifacts as O, loopsForMachine as P, pauseLoop as R, getLoop as S, hasPendingRun as T, safeRelPath as U, requestDeleteLoop as V, sha256 as W, validateLoopTags as X, updateMachine as Y, countRuns as _, canonicalLoopEnvelope as a, forceDeleteMachine as b, ensureServer as c, readJsonBody as d, validOptionalPollString as f, CODING_AGENTS as g, validateLoopEdit as h, boot_CYRi_wez_exports as i, listRuns as j, listLoops as k, getArtifactSync as l, validPollWireId as m, MIN_DAEMON_VERSION as n, canonicalLoopSummary as o, validPollCurrentRun as p, tryDeleteLoop as q, POLL_HTTP_BODY_FIELDS as r, daemonNeedsUpdate as s, MACHINE_BODY_CAP as t, readByteBody as u, createMachine as v, getRun as w, getArtifactFile as x, forceDeleteLoop as y, recentLoopUsage as z };
5730
+ export { listRuns as A, reconciliationStatesForRuns as B, getRun as C, listArtifacts as D, isOutdated as E, machineIdFromToken as F, stopLoop as G, requestRunCancel as H, machinePresence as I, updateMachine as J, tryDeleteLoop as K, mintDeviceToken as L, loopFileNames as M, loopTags_Bi7Bm7v8_exports as N, listLoops as O, loopsForMachine as P, pauseLoop as R, getMachine as S, hasRunningRun as T, sha256 as U, requestDeleteLoop as V, startLoop as W, validateLoopTags as Y, createMachine as _, canonicalLoopSummary as a, getArtifactFile as b, getLoopFiles as c, validOptionalPollString as d, validPollCurrentRun as f, countRuns as g, CODING_AGENTS as h, canonicalLoopEnvelope as i, listRunsBefore as j, listMachines as k, readByteBody as l, validateLoopEdit as m, POLL_HTTP_BODY_FIELDS as n, daemonNeedsUpdate as o, validPollWireId as p, updateLoop as q, boot_CecCDRoH_exports as r, ensureServer as s, MIN_DAEMON_VERSION as t, readJsonBody as u, forceDeleteLoop as v, hasPendingRun as w, getLoop as x, forceDeleteMachine as y, recentLoopUsage as z };