@kky42/pievo-server 3.0.0 → 4.1.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 (35) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/MarkdownView-BuFKr9Jg.js +66 -0
  3. package/.output/public/assets/app-x1CeeY7N.css +2 -0
  4. package/.output/public/assets/{index-Di_wpAtL.js → index-D0FIKiiW.js} +3 -3
  5. package/.output/public/assets/loops._loopId-DTkIz4C0.js +2 -0
  6. package/.output/public/assets/loops._loopId_.runs._runId-CsV2Ih4Z.js +3 -0
  7. package/.output/public/assets/{preload-helper-hRTYB_ju.js → preload-helper-BSxGu4d0.js} +1 -1
  8. package/.output/public/assets/routes-m4Z90DTR.js +1 -0
  9. package/.output/server/_ssr/{MachinesModal-CmVMec6B.mjs → MachinesModal-CLBZxZkq.mjs} +4 -4
  10. package/.output/server/_ssr/MarkdownView-D1J7ImYF.mjs +70 -0
  11. package/.output/server/_ssr/{artifactFiles-Di1xDKvX.mjs → artifactFiles-B0NPRDOR.mjs} +14 -19
  12. package/.output/server/_ssr/{artifactKind-DqpmtxDU.mjs → artifactKind-a2oxT2wQ.mjs} +4 -4
  13. package/.output/server/_ssr/{boot-CYRi-wez.mjs → boot-DmpKtb0A.mjs} +810 -842
  14. package/.output/server/_ssr/{lifecycleUi-BsbUsi5K.mjs → lifecycleUi-DEjM8Etm.mjs} +5 -5
  15. package/.output/server/_ssr/{loopApi-Bez1rHV0.mjs → loopApi-DK1lQPrx.mjs} +13 -13
  16. package/.output/server/_ssr/{loopProjection-esCjyZJg.mjs → loopProjection-DdFivf7-.mjs} +4 -3
  17. package/.output/server/_ssr/{loops._loopId-KUezNY69.mjs → loops._loopId-DVCvGSEc.mjs} +34 -99
  18. package/.output/server/_ssr/{loops._loopId_.runs._runId-DOkUEesG.mjs → loops._loopId_.runs._runId-BXbTfLlC.mjs} +20 -9
  19. package/.output/server/_ssr/{loops._loopId_.runs._runId-BFP9oMrp.mjs → loops._loopId_.runs._runId-Dr6E-FA5.mjs} +3 -3
  20. package/.output/server/_ssr/{machineFns-CniGcY8s.mjs → machineFns-DLRHgDdS.mjs} +2 -2
  21. package/.output/server/_ssr/{router-BFIH2UAE.mjs → router-BhHsTGqu.mjs} +95 -102
  22. package/.output/server/_ssr/{routes-BSVBANI3.mjs → routes-CCallyCT.mjs} +2 -2
  23. package/.output/server/_ssr/{routes-DnE7XDe_.mjs → routes-CL2Kef-Q.mjs} +4 -4
  24. package/.output/server/_ssr/ssr.mjs +22 -22
  25. package/.output/server/{_tanstack-start-manifest_v-DnwAupbZ.mjs → _tanstack-start-manifest_v-BPiJ4yWT.mjs} +8 -9
  26. package/.output/server/drizzle/0001_loop_files.sql +9 -0
  27. package/.output/server/drizzle/meta/_journal.json +8 -1
  28. package/.output/server/index.mjs +49 -42
  29. package/drizzle/0001_loop_files.sql +9 -0
  30. package/drizzle/meta/_journal.json +8 -1
  31. package/package.json +1 -1
  32. package/.output/public/assets/app-DXLuQNb9.css +0 -2
  33. package/.output/public/assets/loops._loopId-X3ClNPMY.js +0 -67
  34. package/.output/public/assets/loops._loopId_.runs._runId-ByU2BSyF.js +0 -3
  35. package/.output/public/assets/routes-K8D5uBiV.js +0 -1
@@ -11,114 +11,117 @@ 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-CnVLi70h.js
15
+ var loopTags_CnVLi70h_exports = /* @__PURE__ */ __exportAll$1({
16
+ $: () => loopIdsWithSnapshots,
17
+ $t: () => BLOB_GC_GRACE_MS,
18
+ A: () => enqueueRun,
19
+ At: () => updateMachine,
20
+ B: () => getRun,
21
+ Bt: () => loopFileNames,
22
+ C: () => buildLoopManifest,
23
+ Ct: () => setMachineOnline,
24
+ D: () => createLoop,
25
+ Dt: () => store_exports,
26
+ E: () => countRuns,
27
+ Et: () => stopLoop,
28
+ F: () => getArtifactFile,
29
+ Ft: () => MEMORY_CHAR_CAP,
30
+ G: () => initializeCronCadence,
31
+ Gt: () => closeClient,
32
+ H: () => getTerminalReportIncidents,
33
+ Ht: () => sha256Buf,
34
+ I: () => getExactTerminalReportIncident,
35
+ It: () => MEMORY_FILE,
36
+ J: () => listLoops,
37
+ Jt: () => runs,
38
+ K: () => lastRun,
39
+ Kt: () => db,
40
+ L: () => getLoop,
41
+ Lt: () => fileLimitLabel,
42
+ M: () => finalizeRunningRun,
43
+ Mt: () => EMPTY_SHA256,
44
+ N: () => forceDeleteLoop,
45
+ Nt: () => LOOP_FILE_BYTE_CAP,
46
+ O: () => createMachine,
47
+ Ot: () => tryDeleteLoop,
48
+ P: () => forceDeleteMachine,
49
+ Pt: () => LoopFiles,
50
+ Q: () => liveBlobRefs,
51
+ Qt: () => logger_exports,
52
+ R: () => getMachine,
53
+ Rt: () => isValidHash,
54
+ S: () => blobSizes,
55
+ St: () => runningRunsForMachine,
56
+ T: () => countRunSnapshots,
57
+ Tt: () => startLoop,
58
+ U: () => hasPendingRun,
59
+ Ut: () => validArtifactName,
60
+ V: () => getRunSnapshot,
61
+ Vt: () => loopFiles_exports,
62
+ W: () => hasRunningRun,
63
+ Wt: () => createBlobStore,
64
+ X: () => listRuns,
65
+ Xt: () => coerceCodingAgent,
66
+ Y: () => listMachines,
67
+ Yt: () => CODING_AGENTS,
68
+ Z: () => listRunsBefore,
69
+ Zt: () => logger,
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: () => GC_INTERVAL_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
+ nt: () => pauseLoop,
98
100
  o: () => connectKeyValid,
99
- ot: () => loopIdsWithSnapshots,
101
+ ot: () => pruneRunSnapshots,
100
102
  p: () => resolveLease,
101
- pt: () => pruneRunSnapshots,
102
- q: () => getRun,
103
- qt: () => logger,
103
+ pt: () => reconciliationStatesForRuns,
104
+ q: () => listArtifacts,
105
+ qt: () => runMigrations,
104
106
  r: () => isValidSemver,
105
- rt: () => listRuns,
107
+ rt: () => pauseLoopState,
106
108
  s: () => isDeviceTokenShape,
107
- st: () => loopsForMachine,
109
+ st: () => putReportReceiptIfAbsent,
108
110
  t: () => validateLoopTags,
109
- tt: () => listLoops,
111
+ tn: () => __exportAll,
112
+ tt: () => openRuns,
110
113
  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
114
+ ut: () => recentLoopUsage,
115
+ v: () => acknowledgeRetiredTerminalIncident,
116
+ vt: () => repairTerminalRunLeases,
117
+ w: () => claimReadyRunForMachine,
118
+ wt: () => snapshotBlobRefs,
119
+ x: () => blobHashesOlderThan,
120
+ xt: () => runningRunForLoop,
121
+ y: () => advanceDueSchedules,
122
+ yt: () => requestDeleteLoop,
123
+ z: () => getReportReceipt,
124
+ zt: () => loopFileIssue
122
125
  });
123
126
  var import_pino = /* @__PURE__ */ __toESM(require_pino());
124
127
  var import_pino_pretty = /* @__PURE__ */ __toESM(require_pino_pretty());
@@ -133,7 +136,7 @@ var __exportAll = (all, no_symbols) => {
133
136
  return target;
134
137
  };
135
138
  /**
136
- * Server data directory: the embedded database (`pgdata/`), artifact bytes
139
+ * Server data directory: the embedded database (`pgdata/`), loop-file bytes
137
140
  * (`blobs/`), and the launcher's pid/lock/log files. Kept apart from the
138
141
  * daemon's `~/.pievo` so a device running both never mixes their state.
139
142
  * Override with `PIEVO_DATA_DIR` (absolute; `~` is not expanded).
@@ -228,7 +231,8 @@ var loops = pgTable("loops", {
228
231
  statusKeep: text("status_keep").notNull(),
229
232
  statusNoChange: text("status_no_change").notNull(),
230
233
  statusBlock: text("status_block").notNull(),
231
- /** Exact workdir-relative artifact paths collected by the daemon after provider exit. */
234
+ /** Configured artifact file names (`.md`/`.html`), in addition to the
235
+ * always-present MEMORY.md. See `gateway/loopFiles.ts`. */
232
236
  artifacts: jsonb("artifacts").$type().notNull().default([]),
233
237
  cron: text("cron").notNull(),
234
238
  scheduleMode: text("schedule_mode", { enum: ["cron", "continuous"] }).notNull(),
@@ -308,7 +312,11 @@ var runs = pgTable("runs", {
308
312
  mode: "string"
309
313
  }),
310
314
  heartbeatAt: text("heartbeat_at"),
311
- reportIncident: jsonb("report_incident").$type()
315
+ reportIncident: jsonb("report_incident").$type(),
316
+ /** Loop-file versions this run produced but that were not kept (over limit,
317
+ * owner edited during the run, upload lost). Shown on the run and repeated in
318
+ * the next run's prompt so the agent can correct course. */
319
+ fileNotices: jsonb("file_notices").$type()
312
320
  }, (t) => [
313
321
  index("runs_loop_idx").on(t.loopId),
314
322
  index("runs_phase_idx").on(t.phase),
@@ -387,21 +395,23 @@ var blobs = pgTable("blobs", {
387
395
  createdAt: text("created_at").notNull()
388
396
  });
389
397
  /**
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.
398
+ * The current file set of each loop — one row per live or tombstoned name.
399
+ * `hash` → `blobs.hash`; null for a tombstoned (deleted or unconfigured) file.
400
+ * The unique (loopId, path) index is the upsert key owner writes and run
401
+ * finalization share.
394
402
  */
395
403
  var artifactFiles = pgTable("artifact_files", {
396
404
  id: text("id").primaryKey(),
397
405
  loopId: text("loop_id").notNull(),
398
- /** Normalized workdir-relative path (never absolute or escaping the workdir). */
406
+ /** Loop file name: MEMORY.md or a configured artifact name. */
399
407
  path: text("path").notNull(),
400
408
  hash: text("hash"),
401
409
  size: integer("size"),
402
- /** Bytes contain a NUL (set even for oversize metadata-only files). */
410
+ /** Legacy snapshot/diff metadata. Loop-file ingress rejects NUL, so new rows
411
+ * always store false. */
403
412
  binary: boolean("binary").notNull().default(false),
404
- /** File exceeds the per-file byte cap → metadata-only (path + size), no blob. */
413
+ /** Legacy snapshot/diff metadata. Loop-file ingress rejects over-limit versions,
414
+ * so new rows always store false. */
405
415
  oversize: boolean("oversize").notNull().default(false),
406
416
  deleted: boolean("deleted").notNull().default(false),
407
417
  updatedAt: text("updated_at").notNull()
@@ -411,7 +421,7 @@ var artifactFiles = pgTable("artifact_files", {
411
421
  index("artifact_files_hash_idx").on(t.hash)
412
422
  ]);
413
423
  /**
414
- * The loop's full artifact manifest captured at each run's finalize — the input
424
+ * The loop's full loop-file manifest captured at each run's finalize — the input
415
425
  * to the per-run diff. Written cheaply on report (no diff computed on write);
416
426
  * `getRunDiff` lazily compares run N's snapshot with the preceding snapshot.
417
427
  * One row per run (runId primary key).
@@ -496,6 +506,222 @@ async function closeClient() {
496
506
  } catch {}
497
507
  }
498
508
  /**
509
+ * Content-addressed blob byte store for loop files.
510
+ *
511
+ * Bytes are keyed by their sha256 hash (content-addressed ⇒ deduped across every
512
+ * loop/run). The store ONLY writes/reads bytes — it never executes or interprets
513
+ * them — preserving the server's zero-exec invariant.
514
+ *
515
+ * `LocalBlobStore` is the durable store under `<dataDir>/blobs`;
516
+ * `MemoryBlobStore` is the injected test fake.
517
+ */
518
+ var log$3 = logger.child({ mod: "blobstore" });
519
+ var SHA256_HEX = /^[a-f0-9]{64}$/;
520
+ /** Durable filesystem adapter. Hash sharding bounds entries per directory; atomic
521
+ * temp-file renames ensure readers see either complete bytes or no object. */
522
+ var LocalBlobStore = class {
523
+ root;
524
+ initialized;
525
+ constructor(root = path.join(dataDir(), "blobs")) {
526
+ this.root = root;
527
+ }
528
+ file(hash) {
529
+ if (!SHA256_HEX.test(hash)) throw new Error("invalid sha256 blob key");
530
+ return path.join(this.root, hash.slice(0, 2), hash);
531
+ }
532
+ async has(hash) {
533
+ const target = this.file(hash);
534
+ await this.ensureInitialized();
535
+ try {
536
+ return (await fs$1.stat(target)).isFile();
537
+ } catch (err) {
538
+ if (err.code === "ENOENT") return false;
539
+ throw err;
540
+ }
541
+ }
542
+ async syncDir(dir) {
543
+ let handle;
544
+ try {
545
+ handle = await fs$1.open(dir, "r");
546
+ await handle.sync();
547
+ } catch (err) {
548
+ const code = err.code;
549
+ if (![
550
+ "EINVAL",
551
+ "ENOTSUP",
552
+ "EBADF",
553
+ "EPERM",
554
+ "ENOENT"
555
+ ].includes(code)) throw err;
556
+ } finally {
557
+ await handle?.close().catch(() => {});
558
+ }
559
+ }
560
+ ensureInitialized() {
561
+ return this.initialized ??= (async () => {
562
+ await fs$1.mkdir(this.root, { recursive: true });
563
+ await this.syncDir(path.dirname(path.dirname(this.root)));
564
+ await this.syncDir(path.dirname(this.root));
565
+ await this.syncDir(this.root);
566
+ for (const shard of await fs$1.readdir(this.root, { withFileTypes: true })) {
567
+ if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
568
+ const dir = path.join(this.root, shard.name);
569
+ let removed = false;
570
+ for (const name of await fs$1.readdir(dir)) {
571
+ if (!/^\.[a-f0-9]{64}\.\d+\.[0-9a-f-]+\.tmp$/.test(name)) continue;
572
+ await fs$1.rm(path.join(dir, name), { force: true });
573
+ removed = true;
574
+ }
575
+ if (removed) await this.syncDir(dir);
576
+ }
577
+ })();
578
+ }
579
+ async put(hash, bytes) {
580
+ const target = this.file(hash);
581
+ await this.ensureInitialized();
582
+ const dir = path.dirname(target);
583
+ await fs$1.mkdir(dir, { recursive: true });
584
+ await this.syncDir(this.root);
585
+ const temp = path.join(dir, `.${hash}.${process.pid}.${randomUUID()}.tmp`);
586
+ let handle;
587
+ try {
588
+ handle = await fs$1.open(temp, "wx", 384);
589
+ await handle.writeFile(bytes);
590
+ await handle.sync();
591
+ await handle.close();
592
+ handle = void 0;
593
+ try {
594
+ await fs$1.rename(temp, target);
595
+ await this.syncDir(dir);
596
+ } catch (err) {
597
+ if ([
598
+ "EEXIST",
599
+ "EACCES",
600
+ "EPERM"
601
+ ].includes(err.code ?? "") && await this.has(hash)) {
602
+ await fs$1.rm(temp, { force: true });
603
+ return;
604
+ }
605
+ throw err;
606
+ }
607
+ } catch (err) {
608
+ await handle?.close().catch(() => {});
609
+ await fs$1.rm(temp, { force: true }).catch(() => {});
610
+ throw err;
611
+ }
612
+ }
613
+ async get(hash) {
614
+ const target = this.file(hash);
615
+ await this.ensureInitialized();
616
+ try {
617
+ return await fs$1.readFile(target);
618
+ } catch (err) {
619
+ if (err.code === "ENOENT") return null;
620
+ throw err;
621
+ }
622
+ }
623
+ async delete(hash) {
624
+ const target = this.file(hash);
625
+ await this.ensureInitialized();
626
+ await fs$1.rm(target, { force: true });
627
+ await this.syncDir(path.dirname(target));
628
+ }
629
+ };
630
+ /** The one local blob store shared by loop-file writes/reads and GC. */
631
+ function createBlobStore() {
632
+ const local = new LocalBlobStore();
633
+ log$3.info({ root: local.root }, "blob store: local filesystem");
634
+ return local;
635
+ }
636
+ /**
637
+ * Loop files: a loop's server-owned memory and display artifacts.
638
+ *
639
+ * Every loop has the required `MEMORY.md` plus up to MAX_ARTIFACTS configured
640
+ * `.md`/`.html` artifact names. The server is authoritative: bytes live in the
641
+ * content-addressed BlobStore and the current version of each name is an
642
+ * `artifact_files` row. For each run the daemon downloads the current files into
643
+ * a private run directory, and the terminal report carries the changed versions
644
+ * back with the hash each edit started from, so an owner edit made during the run
645
+ * wins over the agent's (base-hash check under the loop lock).
646
+ *
647
+ * Limits are part of the agent-facing prompt contract and are mirrored in the
648
+ * daemon (`packages/daemon/src/loop-files.ts`); change both together. An over-limit
649
+ * version is rejected and the previous version kept, never truncated, because a cut
650
+ * HTML file renders broken and a cut memory file silently loses its newest notes.
651
+ */
652
+ var loopFiles_exports = /* @__PURE__ */ __exportAll({
653
+ ARTIFACT_BYTE_CAP: () => ARTIFACT_BYTE_CAP,
654
+ EMPTY_SHA256: () => EMPTY_SHA256,
655
+ LOOP_FILE_BYTE_CAP: () => LOOP_FILE_BYTE_CAP,
656
+ LoopFiles: () => LoopFiles,
657
+ MAX_ARTIFACTS: () => 4,
658
+ MEMORY_CHAR_CAP: () => MEMORY_CHAR_CAP,
659
+ MEMORY_FILE: () => MEMORY_FILE,
660
+ fileLimitLabel: () => fileLimitLabel,
661
+ isValidHash: () => isValidHash,
662
+ loopFileIssue: () => loopFileIssue,
663
+ loopFileNames: () => loopFileNames,
664
+ sha256Buf: () => sha256Buf,
665
+ validArtifactName: () => validArtifactName
666
+ });
667
+ var MEMORY_FILE = "MEMORY.md";
668
+ /** Memory is re-read by every run, so it is bounded in characters (Unicode code
669
+ * points), roughly 2k tokens of prompt context per run. */
670
+ var MEMORY_CHAR_CAP = 8e3;
671
+ /** HTML reports are bulky; 2 MB keeps a run's upload and the dashboard iframe cheap. */
672
+ var ARTIFACT_BYTE_CAP = 2 * 1024 * 1024;
673
+ /** sha256 of zero bytes: the version of a loop file that has never been written. */
674
+ var EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
675
+ var ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\.(md|html)$/;
676
+ function sha256Buf(bytes) {
677
+ return createHash("sha256").update(bytes).digest("hex");
678
+ }
679
+ function isValidHash(hash) {
680
+ return typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash);
681
+ }
682
+ /** A plain file name (no directories) ending in .md or .html, other than MEMORY.md. */
683
+ function validArtifactName(name) {
684
+ return typeof name === "string" && ARTIFACT_NAME_RE.test(name) && name.toLowerCase() !== "MEMORY.md".toLowerCase();
685
+ }
686
+ function loopFileNames(loop) {
687
+ return [MEMORY_FILE, ...loop.artifacts];
688
+ }
689
+ function fileLimitLabel(name) {
690
+ return name === "MEMORY.md" ? `${MEMORY_CHAR_CAP} characters` : "2 MB";
691
+ }
692
+ /** Why `bytes` cannot become the next version of `name`, or undefined if it can. */
693
+ function loopFileIssue(name, bytes) {
694
+ if (bytes.includes(0)) return `${name}: binary content is not allowed`;
695
+ if (name === "MEMORY.md") {
696
+ const chars = Array.from(bytes.toString("utf8")).length;
697
+ if (chars > 8e3) return `${name}: ${chars} characters exceeds the ${MEMORY_CHAR_CAP}-character limit`;
698
+ return;
699
+ }
700
+ if (bytes.length > 2097152) return `${name}: ${(bytes.length / 1024 / 1024).toFixed(1)} MB exceeds the 2 MB limit`;
701
+ }
702
+ /** The largest byte size any loop file can have (a code point is at most 4 bytes). */
703
+ var LOOP_FILE_BYTE_CAP = Math.max(ARTIFACT_BYTE_CAP, MEMORY_CHAR_CAP * 4);
704
+ var LoopFiles = class {
705
+ blobStore;
706
+ constructor(blobStore = createBlobStore()) {
707
+ this.blobStore = blobStore;
708
+ }
709
+ readBlob(hash) {
710
+ if (!isValidHash(hash)) return Promise.resolve(null);
711
+ if (hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") return Promise.resolve(Buffer.alloc(0));
712
+ return this.blobStore.get(hash);
713
+ }
714
+ /** Persist bytes under their own hash. Idempotent; callers record the metadata row. */
715
+ async putBlob(bytes) {
716
+ const hash = sha256Buf(bytes);
717
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" && !await this.blobStore.has(hash)) await this.blobStore.put(hash, bytes);
718
+ return hash;
719
+ }
720
+ async hasBlob(hash) {
721
+ return hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" || this.blobStore.has(hash);
722
+ }
723
+ };
724
+ /**
499
725
  * Data-access layer over Drizzle. Loop-locked state transitions live here; every
500
726
  * DB-touching function is async, while the pure helpers stay sync.
501
727
  */
@@ -505,7 +731,6 @@ var store_exports = /* @__PURE__ */ __exportAll({
505
731
  acknowledgeRetiredTerminalIncident: () => acknowledgeRetiredTerminalIncident,
506
732
  advanceDueSchedules: () => advanceDueSchedules,
507
733
  artifactFileReferencesHash: () => artifactFileReferencesHash,
508
- blobExists: () => blobExists,
509
734
  blobHashesOlderThan: () => blobHashesOlderThan,
510
735
  blobSizes: () => blobSizes,
511
736
  buildLoopManifest: () => buildLoopManifest,
@@ -540,24 +765,22 @@ var store_exports = /* @__PURE__ */ __exportAll({
540
765
  liveBlobRefs: () => liveBlobRefs,
541
766
  loopIdsWithSnapshots: () => loopIdsWithSnapshots,
542
767
  loopsForMachine: () => loopsForMachine,
543
- machineReferencesBlob: () => machineReferencesBlob,
544
768
  newLoopId: () => newLoopId,
545
769
  nextCronAt: () => nextCronAt,
546
770
  openRuns: () => openRuns,
547
771
  pauseLoop: () => pauseLoop,
548
772
  pauseLoopState: () => pauseLoopState,
549
773
  prevRunSnapshot: () => prevRunSnapshot,
774
+ previousRunFileNotices: () => previousRunFileNotices,
550
775
  pruneRunSnapshots: () => pruneRunSnapshots,
551
776
  putReportReceiptIfAbsent: () => putReportReceiptIfAbsent,
552
777
  putRunSnapshot: () => putRunSnapshot,
553
778
  putTerminalReportIncidentIfAbsent: () => putTerminalReportIncidentIfAbsent,
554
779
  recentLoopUsage: () => recentLoopUsage,
555
780
  reclaimRun: () => reclaimRun,
556
- reconcileConfiguredArtifacts: () => reconcileConfiguredArtifacts,
557
781
  reconcileReclaimedRun: () => reconcileReclaimedRun,
558
782
  reconciliationStatesForRuns: () => reconciliationStatesForRuns,
559
783
  recordBlob: () => recordBlob,
560
- recordRunReportOnce: () => recordRunReportOnce,
561
784
  refreshRunHeartbeats: () => refreshRunHeartbeats,
562
785
  rejectTerminalReport: () => rejectTerminalReport,
563
786
  releaseAbsentReconciliations: () => releaseAbsentReconciliations,
@@ -574,7 +797,7 @@ var store_exports = /* @__PURE__ */ __exportAll({
574
797
  tryDeleteLoop: () => tryDeleteLoop,
575
798
  updateLoop: () => updateLoop,
576
799
  updateMachine: () => updateMachine,
577
- upsertConfiguredArtifactFile: () => upsertConfiguredArtifactFile
800
+ writeLoopFile: () => writeLoopFile
578
801
  });
579
802
  function newLoopId() {
580
803
  return `loop-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
@@ -697,7 +920,7 @@ async function updateLoopTx(tx, current, patch, at) {
697
920
  }).where(eq(loops.id, current.id)).returning())[0];
698
921
  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
922
  if (patch.artifacts !== void 0) {
700
- const allowed = new Set(patch.artifacts ?? []);
923
+ const allowed = new Set([MEMORY_FILE, ...patch.artifacts ?? []]);
701
924
  const removed = (await tx.select({
702
925
  id: artifactFiles.id,
703
926
  path: artifactFiles.path
@@ -1040,26 +1263,32 @@ async function activeRunForReportTx(tx, loopId, runId, leaseTokenHash) {
1040
1263
  run
1041
1264
  } : { state: "run-not-running" };
1042
1265
  }
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
- });
1266
+ /** Apply a run's loop-file versions under the loop lock. A file whose current
1267
+ * version is no longer the one the run started from was changed by the owner (or a
1268
+ * newer run) meanwhile; that version wins and the agent's edit becomes a notice. */
1269
+ async function applyLoopFileUpdatesTx(tx, loop, updates, at) {
1270
+ const notices = [];
1271
+ const configured = new Set(loopFileNames(loop));
1272
+ for (const update of updates) {
1273
+ if (!configured.has(update.name)) {
1274
+ notices.push(`${update.name}: no longer configured on this loop; this run's version was discarded`);
1275
+ continue;
1276
+ }
1277
+ const row = (await tx.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loop.id), eq(artifactFiles.path, update.name))).limit(1).for("update"))[0];
1278
+ if ((row && !row.deleted && row.hash ? row.hash : "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") !== update.baseHash) {
1279
+ notices.push(`${update.name}: changed outside this run while it was running; that version was kept and this run's edit was discarded`);
1280
+ continue;
1281
+ }
1282
+ await writeConfiguredArtifactTx(tx, {
1283
+ loopId: loop.id,
1284
+ path: update.name,
1285
+ hash: update.hash,
1286
+ size: update.size,
1287
+ binary: false,
1288
+ oversize: false
1289
+ }, at);
1290
+ }
1291
+ return notices;
1063
1292
  }
1064
1293
  async function finalizeTerminalRunTx(tx, input) {
1065
1294
  if (input.receipt) await lockReportIdTx(tx, input.receipt.reportId);
@@ -1074,8 +1303,10 @@ async function finalizeTerminalRunTx(tx, input) {
1074
1303
  if (!target) return void 0;
1075
1304
  const runIndex = await ensureRunIndexTx(tx, target);
1076
1305
  const at = typeof input.runPatch.ts === "string" ? input.runPatch.ts : nowIso$1();
1306
+ const fileNotices = [...input.runPatch.fileNotices ?? [], ...input.fileUpdates?.length ? await applyLoopFileUpdatesTx(tx, current, input.fileUpdates, at) : []];
1077
1307
  const run = (await tx.update(runs).set({
1078
1308
  ...input.runPatch,
1309
+ fileNotices: fileNotices.length ? fileNotices : null,
1079
1310
  runIndex,
1080
1311
  updatedAt: at
1081
1312
  }).where(and(eq(runs.id, input.runId), eq(runs.loopId, input.loopId), eq(runs.phase, input.runPhase))).returning())[0];
@@ -1207,12 +1438,13 @@ async function rejectTerminalReport(input) {
1207
1438
  }
1208
1439
  /** Atomically CAS running→terminal and apply the report's loop patch.
1209
1440
  * The loop patch is unreachable when cancel/reclaim/another report won. */
1210
- async function finalizeRunningRun(loopId, runId, runPatch, loopPatch = {}, leaseTokenHash, failureAutopauseStreak = 0, receipt) {
1441
+ async function finalizeRunningRun(loopId, runId, runPatch, loopPatch = {}, leaseTokenHash, failureAutopauseStreak = 0, receipt, fileUpdates) {
1211
1442
  return db.transaction(async (tx) => {
1212
1443
  return finalizeTerminalRunTx(tx, {
1213
1444
  loopId,
1214
1445
  runId,
1215
1446
  runPhase: "running",
1447
+ fileUpdates,
1216
1448
  runPatch,
1217
1449
  loopPatch,
1218
1450
  leaseTokenHash,
@@ -1271,12 +1503,13 @@ async function releaseAbsentReconciliations(machineId, recoverableRunIds, at = n
1271
1503
  * exactly once. Keeping the lease check/delete beside the run + loop writes closes the
1272
1504
  * error→error double-report hole where a phase CAS alone cannot distinguish the
1273
1505
  * first real failure from a second concurrent one. */
1274
- async function reconcileReclaimedRun(loopId, runId, leaseTokenHash, runPatch, loopPatch = {}, failureAutopauseStreak = 0, receipt) {
1506
+ async function reconcileReclaimedRun(loopId, runId, leaseTokenHash, runPatch, loopPatch = {}, failureAutopauseStreak = 0, receipt, fileUpdates) {
1275
1507
  return db.transaction(async (tx) => {
1276
1508
  return finalizeTerminalRunTx(tx, {
1277
1509
  loopId,
1278
1510
  runId,
1279
1511
  runPhase: "error",
1512
+ fileUpdates,
1280
1513
  runPatch,
1281
1514
  loopPatch,
1282
1515
  leaseTokenHash,
@@ -1521,9 +1754,6 @@ async function setMachineOnline(id, online) {
1521
1754
  lastSeen: nowIso$1()
1522
1755
  }).where(eq(machines.id, id));
1523
1756
  }
1524
- async function blobExists(hash) {
1525
- return !!(await db.select({ hash: blobs.hash }).from(blobs).where(eq(blobs.hash, hash)))[0];
1526
- }
1527
1757
  /** Verified byte lengths recorded at blob ingress, used to budget reads before
1528
1758
  * touching the byte store. Missing metadata is intentionally not estimated. */
1529
1759
  async function blobSizes(hashes) {
@@ -1541,13 +1771,6 @@ async function recordBlob(hash, size) {
1541
1771
  createdAt: nowIso$1()
1542
1772
  }).onConflictDoNothing();
1543
1773
  }
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
1774
  async function writeConfiguredArtifactTx(tx, input, ts) {
1552
1775
  await tx.insert(artifactFiles).values({
1553
1776
  id: randomUUID(),
@@ -1571,36 +1794,32 @@ async function writeConfiguredArtifactTx(tx, input, ts) {
1571
1794
  }
1572
1795
  });
1573
1796
  }
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) {
1797
+ /** Owner replacement of one loop file. Serialized with run finalization by the
1798
+ * loop lock, so a run that started from the previous version cannot overwrite it. */
1799
+ async function writeLoopFile(loopId, name, hash, size) {
1577
1800
  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());
1801
+ const loop = (await tx.select().from(loops).where(eq(loops.id, loopId)).for("update"))[0];
1802
+ if (!loop || !loopFileNames(loop).includes(name)) return false;
1803
+ await writeConfiguredArtifactTx(tx, {
1804
+ loopId,
1805
+ path: name,
1806
+ hash,
1807
+ size,
1808
+ binary: false,
1809
+ oversize: false
1810
+ }, nowIso$1());
1581
1811
  return true;
1582
1812
  });
1583
1813
  }
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
- });
1814
+ /** File notices of the immediately previous terminal run, for the next prompt.
1815
+ * Canceled runs are terminal too: their notices (edits discarded) are repeated,
1816
+ * and an older run's notice must not masquerade as the previous run's. */
1817
+ async function previousRunFileNotices(loopId, beforeRunIndex) {
1818
+ 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, [
1819
+ "done",
1820
+ "error",
1821
+ "canceled"
1822
+ ]))).orderBy(desc(runs.runIndex)).limit(1))[0]?.fileNotices ?? [];
1604
1823
  }
1605
1824
  async function listArtifacts(loopId) {
1606
1825
  return db.select().from(artifactFiles).where(and(eq(artifactFiles.loopId, loopId), eq(artifactFiles.deleted, false))).orderBy(artifactFiles.path);
@@ -1618,8 +1837,8 @@ async function buildLoopManifest(loopId) {
1618
1837
  };
1619
1838
  return manifest;
1620
1839
  }
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. */
1840
+ /** Write/overwrite a run's snapshot (name → file metadata). Idempotent on runId
1841
+ * so a re-report of the same run just refreshes the captured loop-file state. */
1623
1842
  async function putRunSnapshot(runId, loopId, manifest) {
1624
1843
  await db.insert(runSnapshots).values({
1625
1844
  runId,
@@ -1708,89 +1927,6 @@ async function countRunSnapshots() {
1708
1927
  const r = (await db.select({ n: sql`count(*)` }).from(runSnapshots))[0];
1709
1928
  return Number(r?.n ?? 0);
1710
1929
  }
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
- /**
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
1930
  /**
1795
1931
  * Machine + run credential helpers. Device tokens (`dk_…`) identify a machine
1796
1932
  * (its id is derived from the token: `m-sha256(token)[:16]`). A run
@@ -2067,8 +2203,8 @@ function validateLoopTags(value) {
2067
2203
  };
2068
2204
  }
2069
2205
  //#endregion
2070
- //#region node_modules/.nitro/vite/services/ssr/assets/boot-CYRi-wez.js
2071
- var boot_CYRi_wez_exports = /* @__PURE__ */ __exportAll$1({
2206
+ //#region node_modules/.nitro/vite/services/ssr/assets/boot-DmpKtb0A.js
2207
+ var boot_DmpKtb0A_exports = /* @__PURE__ */ __exportAll$1({
2072
2208
  _: () => validOptionalPollString,
2073
2209
  a: () => POLL_HTTP_BODY_FIELDS,
2074
2210
  c: () => MIN_DAEMON_VERSION,
@@ -2082,24 +2218,11 @@ var boot_CYRi_wez_exports = /* @__PURE__ */ __exportAll$1({
2082
2218
  n: () => ensureServer,
2083
2219
  o: () => validPollCurrentRun,
2084
2220
  p: () => MACHINE_BODY_CAP,
2085
- r: () => getArtifactSync,
2221
+ r: () => getLoopFiles,
2086
2222
  s: () => validPollWireId,
2087
2223
  t: () => boot_exports,
2088
2224
  u: () => canonicalLoopEnvelope
2089
2225
  });
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
2226
  /** Read raw request bytes under a hard cap. Declared oversize requests fail
2104
2227
  * before reading; chunked/lying streams are canceled on the first chunk that
2105
2228
  * takes the observed count over the cap. */
@@ -2157,193 +2280,104 @@ async function readJsonBody(request, maxBytes) {
2157
2280
  body: {}
2158
2281
  };
2159
2282
  try {
2160
- return {
2161
- kind: "ok",
2162
- body: JSON.parse(text)
2163
- };
2164
- } catch {
2165
- return { kind: "invalid" };
2166
- }
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));
2283
+ return {
2284
+ kind: "ok",
2285
+ body: JSON.parse(text)
2286
+ };
2287
+ } catch {
2288
+ return { kind: "invalid" };
2333
2289
  }
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;
2290
+ }
2291
+ var WIRE_TEXT_CAP = 512 * 1024;
2292
+ /** Validate an optional poll string without coercion, clipping, or NUL removal. */
2293
+ function validOptionalPollString(value, cap) {
2294
+ return value === void 0 || typeof value === "string" && value.length <= cap && !value.includes("\0");
2295
+ }
2296
+ function nowIso() {
2297
+ return (/* @__PURE__ */ new Date()).toISOString();
2298
+ }
2299
+ /** Strip NUL (U+0000) from a wire string: Postgres text/jsonb columns REJECT the
2300
+ * NUL byte (SQLite tolerated it), so a daemon-supplied string carrying one would
2301
+ * throw mid-finalize on the DB write. The single sanitizing primitive behind
2302
+ * `clipText` - and used directly by `cli.ts`
2303
+ * (parseFlags/validateState, the same one-chokepoint discipline). */
2304
+ function stripNul(s) {
2305
+ return s.replace(/\u0000/g, "");
2306
+ }
2307
+ function clipText(s, cap) {
2308
+ return stripNul(s.slice(0, cap));
2309
+ }
2310
+ /** Literal placeholder the daemon replaces with the run's absolute loop-file
2311
+ * directory before launch (and exports as an env var of the same name). */
2312
+ var LOOP_DIR_PLACEHOLDER = "$PIEVO_LOOP_DIR";
2313
+ var STATUS_LINE_RE = /^STATUS:\s*(keep|no-change|block)\.?$/i;
2314
+ /**
2315
+ * Deliver the user's prompt unchanged, followed by the status definitions, the
2316
+ * loop-file contract, and the final-line status contract. The run index
2317
+ * intentionally remains an internal history fact.
2318
+ */
2319
+ function buildRunTask(loop, previousNotices = []) {
2320
+ const files = [`- ${MEMORY_FILE}: this loop's memory, kept across runs (max ${MEMORY_CHAR_CAP} characters). Read it before you start. Before finishing, update it with anything that would help future runs avoid mistakes or work faster. Keep it concise: merge similar notes, delete stale or wrong ones, no run logs.`, ...loop.artifacts.map((name) => `- ${name}: artifact shown on the Pievo dashboard (max 2 MB). Update it only as the task above asks.`)];
2321
+ const notices = previousNotices.length ? `\n\nNotices from the previous run:\n${previousNotices.map((notice) => `- ${notice}`).join("\n")}` : "";
2322
+ return `${loop.prompt}
2323
+
2324
+ Status definitions:
2325
+ - keep: ${loop.statusKeep}
2326
+ - no-change: ${loop.statusNoChange}
2327
+ - block: ${loop.statusBlock}
2328
+
2329
+ Loop files in ${LOOP_DIR_PLACEHOLDER}:
2330
+ ${files.join("\n")}
2331
+ A file over its limit is rejected and its previous version kept. Other files in that directory are discarded.${notices}
2332
+
2333
+ End your final response with a short summary, then exactly one last line:
2334
+ STATUS: keep|no-change|block`;
2340
2335
  }
2341
2336
  /**
2342
- * Artifact-storage retention + garbage collection — the policy that keeps the
2337
+ * Split a provider's final response into the reported status and summary. The
2338
+ * final non-empty line must be the status line; markdown emphasis or code ticks
2339
+ * around it are tolerated because models add them unprompted.
2340
+ */
2341
+ function parseFinalStatus(finalText) {
2342
+ if (!finalText) return void 0;
2343
+ const lines = finalText.split(/\r?\n/);
2344
+ let index = lines.length - 1;
2345
+ while (index >= 0 && lines[index].trim() === "") index--;
2346
+ if (index < 0) return void 0;
2347
+ const bare = lines[index].trim().replace(/^[*_`>\s]+|[*_`\s]+$/g, "");
2348
+ const match = STATUS_LINE_RE.exec(bare);
2349
+ if (!match) return void 0;
2350
+ return {
2351
+ status: match[1].toLowerCase(),
2352
+ message: lines.slice(0, index).join("\n").trim()
2353
+ };
2354
+ }
2355
+ async function buildDelivery(loop, queuedRun, runToken, roots) {
2356
+ if (queuedRun.runIndex == null) throw new Error(`claimed run ${queuedRun.id} has no history index`);
2357
+ const notices = await previousRunFileNotices(loop.id, queuedRun.runIndex);
2358
+ return {
2359
+ runId: queuedRun.id,
2360
+ runIndex: queuedRun.runIndex,
2361
+ runToken,
2362
+ roots,
2363
+ loop: {
2364
+ id: loop.id,
2365
+ name: loop.name,
2366
+ workdir: loop.workdir,
2367
+ model: loop.model ?? null,
2368
+ reasoningEffort: loop.reasoningEffort ?? null,
2369
+ agent: loop.agent
2370
+ },
2371
+ task: buildRunTask(loop, notices),
2372
+ files: loopFileNames(loop)
2373
+ };
2374
+ }
2375
+ /**
2376
+ * Loop-file storage retention + garbage collection — the policy that keeps the
2343
2377
  * local byte store from growing monotonically.
2344
2378
  *
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,
2379
+ * The loop-file pipeline is content-addressed: blob BYTES are keyed by sha256 and
2380
+ * deduped across every loop/run. When a file is deleted or its content changes,
2347
2381
  * the OLD blob is no longer pointed at by the current file row — but nothing was
2348
2382
  * reclaiming it, so the byte store grew forever. This module closes that gap with two pieces:
2349
2383
  *
@@ -2354,18 +2388,18 @@ function createBlobStore() {
2354
2388
  * hash. CORRECTNESS over aggressiveness — a still-referenced blob is never
2355
2389
  * deleted; a leaked blob is only a cost bug the next pass reclaims.
2356
2390
  *
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
2391
+ * Concurrency: gcBlobs is safe alongside owner writes and run uploads. Three guards
2392
+ * combine — a grace window (a blob whose metadata row is younger than the window is
2393
+ * never collected, so a blob a run just uploaded is untouchable), a point re-check
2394
+ * of each candidate immediately before its bytes are deleted (so a blob
2361
2395
  * re-referenced before we touch it keeps both its bytes and metadata), and a
2362
2396
  * bytes-before-metadata delete ordering: each candidate's bytes are reclaimed
2363
2397
  * 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
2398
+ * false and any writer that re-references the hash (even one that raced the byte
2365
2399
  * delete) re-uploads the bytes (self-healing). We never leave a live blobs row
2366
2400
  * pointing at deleted bytes.
2367
2401
  */
2368
- var log$3 = logger.child({ mod: "retention" });
2402
+ var log$2 = logger.child({ mod: "retention" });
2369
2403
  /**
2370
2404
  * Prune every loop's run snapshots down to the configured retention window
2371
2405
  * (most-recent-N). Returns the total number pruned. This is what makes an old
@@ -2418,7 +2452,7 @@ async function gcBlobs(blobStore, graceMs = BLOB_GC_GRACE_MS) {
2418
2452
  await deleteBlob(hash);
2419
2453
  reclaimed++;
2420
2454
  } catch (err) {
2421
- log$3.warn({
2455
+ log$2.warn({
2422
2456
  hash,
2423
2457
  err: err instanceof Error ? err.message : String(err)
2424
2458
  }, "gc: blob byte-delete failed");
@@ -2429,7 +2463,7 @@ async function gcBlobs(blobStore, graceMs = BLOB_GC_GRACE_MS) {
2429
2463
  async function maintainStorage(blobStore, options = {}) {
2430
2464
  const snapshotsPruned = await pruneSnapshots(options.keep);
2431
2465
  const blobsReclaimed = await gcBlobs(blobStore, options.graceMs);
2432
- if (snapshotsPruned || blobsReclaimed) log$3.info({
2466
+ if (snapshotsPruned || blobsReclaimed) log$2.info({
2433
2467
  snapshotsPruned,
2434
2468
  blobsReclaimed
2435
2469
  }, "storage maintenance");
@@ -2764,44 +2798,37 @@ function validateStatusDefinitions(value) {
2764
2798
  }
2765
2799
  };
2766
2800
  }
2767
- function validateArtifactPaths(value) {
2801
+ function validateArtifactNames(value) {
2768
2802
  if (value === void 0 || value === null) return {
2769
2803
  ok: true,
2770
2804
  value: []
2771
2805
  };
2772
2806
  if (!Array.isArray(value)) return {
2773
2807
  ok: false,
2774
- detail: "artifacts must be an array of exact relative file paths"
2808
+ detail: "artifacts must be an array of file names"
2809
+ };
2810
+ if (value.length > 4) return {
2811
+ ok: false,
2812
+ detail: `at most 4 artifacts are allowed`
2775
2813
  };
2776
- const paths = [];
2814
+ const names = [];
2777
2815
  const seen = /* @__PURE__ */ new Set();
2778
2816
  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 {
2817
+ if (!validArtifactName(entry)) return {
2792
2818
  ok: false,
2793
- detail: `artifact path must not traverse or alias another path: ${entry}`
2819
+ detail: `artifact must be a plain .md or .html file name other than ${MEMORY_FILE} (letters, digits, . _ -; at most 64 characters before the extension): ${JSON.stringify(entry)}`
2794
2820
  };
2795
- if (seen.has(entry)) return {
2821
+ const key = entry.toLowerCase();
2822
+ if (seen.has(key)) return {
2796
2823
  ok: false,
2797
- detail: `duplicate artifact path: ${entry}`
2824
+ detail: `duplicate artifact name: ${entry}`
2798
2825
  };
2799
- seen.add(entry);
2800
- paths.push(entry);
2826
+ seen.add(key);
2827
+ names.push(entry);
2801
2828
  }
2802
2829
  return {
2803
2830
  ok: true,
2804
- value: paths
2831
+ value: names
2805
2832
  };
2806
2833
  }
2807
2834
  function rowSchedule(schedule, retainedCron = SAFE_RETAINED_CRON) {
@@ -2886,7 +2913,7 @@ function validateLoopCreate(value) {
2886
2913
  if (!schedule.ok) return schedule;
2887
2914
  const statusDefinitions = validateStatusDefinitions(raw.statusDefinitions);
2888
2915
  if (!statusDefinitions.ok) return statusDefinitions;
2889
- const artifacts = validateArtifactPaths(raw.artifacts);
2916
+ const artifacts = validateArtifactNames(raw.artifacts);
2890
2917
  if (!artifacts.ok) return artifacts;
2891
2918
  const model = optionalProvider(raw.model, "model");
2892
2919
  if (!model.ok) return model;
@@ -2981,7 +3008,7 @@ function validateLoopEdit(loop, value) {
2981
3008
  update.statusBlock = result.value.block;
2982
3009
  }
2983
3010
  if (raw.artifacts !== void 0) {
2984
- const result = validateArtifactPaths(raw.artifacts);
3011
+ const result = validateArtifactNames(raw.artifacts);
2985
3012
  if (!result.ok) return result;
2986
3013
  update.artifacts = result.value;
2987
3014
  }
@@ -3017,7 +3044,7 @@ function validateLoopEdit(loop, value) {
3017
3044
  value: update
3018
3045
  };
3019
3046
  }
3020
- var MIN_DAEMON_VERSION = "2.4.0";
3047
+ var MIN_DAEMON_VERSION = "4.0.0";
3021
3048
  function daemonNeedsUpdate(current, required = MIN_DAEMON_VERSION) {
3022
3049
  if (!current || !isValidSemver(current) || !isValidSemver(required)) return true;
3023
3050
  if (current === required) return false;
@@ -3058,6 +3085,7 @@ var validPollInfo = (value) => value === void 0 || value !== null && typeof valu
3058
3085
  *
3059
3086
  * POST /api/machine/poll (Bearer device token) → claim pending runs, deliver
3060
3087
  * POST /machine/report (Bearer run token) → finalize a run
3088
+ * GET|PUT /api/machine/run-files/:name (Bearer run token) → loop-file transfer
3061
3089
  *
3062
3090
  * plus owner create/list/edit methods and retention.
3063
3091
  * Also exposes `dispatcher` (a `Dispatcher` for the Scheduler: "is the machine
@@ -3066,7 +3094,7 @@ var validPollInfo = (value) => value === void 0 || value !== null && typeof valu
3066
3094
  * (`CliGateway`), which reuses this class's owner methods;
3067
3095
  * canonical loop configuration validation lives in `gateway/loopConfig.ts`.
3068
3096
  */
3069
- var log$2 = logger.child({ mod: "gateway" });
3097
+ var log$1 = logger.child({ mod: "gateway" });
3070
3098
  var ONLINE_TTL_MS = 3e4;
3071
3099
  /** Auto-pause after this many consecutive errors. Canceled rows are transparent;
3072
3100
  * zero disables the breaker. */
@@ -3081,10 +3109,15 @@ var RUN_TIMEOUT_MS = Number.isFinite(configuredRunTimeoutMs) && configuredRunTim
3081
3109
  * patched over the device-token edit surface. Exported for `cli.ts`
3082
3110
  * (the `new`/`edit` verb help lists these keys). */
3083
3111
  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. */
3112
+ /** Run summary (the final response minus its STATUS line) and run errors share
3113
+ * this cap; the full final response is stored separately as finalText. */
3087
3114
  var MESSAGE_CAP = 2e3;
3115
+ /** A run reports at most one version per loop file (MEMORY.md + 4 artifacts). */
3116
+ var REPORT_FILES_MAX = 5;
3117
+ /** One notice per file plus a few upload/rejection reasons; bounds the prompt echo. */
3118
+ var REPORT_NOTICES_MAX = 10;
3119
+ /** One short sentence (name + reason); 500 chars keeps the next prompt small. */
3120
+ var NOTICE_CAP = 500;
3088
3121
  /** Provider session IDs are opaque short tokens; this rejects malformed payloads
3089
3122
  * without constraining known provider formats. */
3090
3123
  var SESSION_ID_CAP = 200;
@@ -3099,7 +3132,7 @@ var HEARTBEAT_STAMP_REFRESH_MS = heartbeatRefreshMs(RUN_TIMEOUT_MS);
3099
3132
  * and an idle poll becomes read-only, with worst-case staleness well inside
3100
3133
  * the 30s TTL (max stamp gap = refresh + one poll interval). */
3101
3134
  var LAST_SEEN_REFRESH_MS = 1e4;
3102
- var MACHINE_REPORT_FIELD_SET = new Set([
3135
+ var MACHINE_REPORT_FIELDS = [
3103
3136
  "reportId",
3104
3137
  "runId",
3105
3138
  "result",
@@ -3108,8 +3141,17 @@ var MACHINE_REPORT_FIELD_SET = new Set([
3108
3141
  "sessionId",
3109
3142
  "usage",
3110
3143
  "error",
3111
- "finalText"
3112
- ]);
3144
+ "finalText",
3145
+ "files",
3146
+ "fileNotices"
3147
+ ];
3148
+ var REPORT_FILE_FIELDS = [
3149
+ "baseHash",
3150
+ "hash",
3151
+ "name",
3152
+ "size"
3153
+ ];
3154
+ var MACHINE_REPORT_FIELD_SET = new Set(MACHINE_REPORT_FIELDS);
3113
3155
  var MACHINE_REPORT_USAGE_FIELDS = new Set([
3114
3156
  "inputTokens",
3115
3157
  "outputTokens",
@@ -3138,7 +3180,7 @@ function receiptFor(body, runId, ackStatus = 200, ackBody) {
3138
3180
  }
3139
3181
  function receiptResponse(receipt, expected) {
3140
3182
  if (!receipt || receipt.runId !== expected.runId) return void 0;
3141
- if (receipt.payloadDigest !== expected.payloadDigest) log$2.warn({
3183
+ if (receipt.payloadDigest !== expected.payloadDigest) log$1.warn({
3142
3184
  reportId: expected.reportId,
3143
3185
  runId: expected.runId
3144
3186
  }, "report: same-run payload changed after commit; replaying authoritative ACK");
@@ -3166,7 +3208,7 @@ async function committedReportEvidence(reportId, payloadDigest, authoritativeRun
3166
3208
  foreignRun: false
3167
3209
  };
3168
3210
  if (normal?.runId === authoritativeRunId) {
3169
- if (normal.payloadDigest !== payloadDigest) log$2.warn({
3211
+ if (normal.payloadDigest !== payloadDigest) log$1.warn({
3170
3212
  reportId,
3171
3213
  runId: authoritativeRunId
3172
3214
  }, "report: same-run payload changed after commit; replaying authoritative ACK");
@@ -3200,14 +3242,36 @@ function incidentDiagnosis(code, issues, reportId, payloadDigest) {
3200
3242
  recommendedAction
3201
3243
  };
3202
3244
  }
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;
3245
+ var MISSING_STATUS_ERROR = "run protocol incomplete: the final response has no \"STATUS: keep|no-change|block\" line";
3246
+ /** The run outcome a terminal report establishes. Only a successful provider
3247
+ * exit is read for a status; a successful exit without one is a protocol error. */
3248
+ function reportOutcome(body) {
3249
+ const canceled = body.result === "canceled";
3250
+ if (canceled) return {
3251
+ ok: false,
3252
+ canceled,
3253
+ patch: { error: "stopped by user" }
3254
+ };
3255
+ if (body.result !== "success") return {
3256
+ ok: false,
3257
+ canceled,
3258
+ patch: { error: typeof body.error === "string" ? clipText(body.error, MESSAGE_CAP) : "run failed on machine" }
3259
+ };
3260
+ const parsed = parseFinalStatus(body.finalText);
3261
+ if (!parsed) return {
3262
+ ok: false,
3263
+ canceled,
3264
+ patch: { error: MISSING_STATUS_ERROR }
3265
+ };
3266
+ return {
3267
+ ok: true,
3268
+ canceled,
3269
+ patch: {
3270
+ status: parsed.status,
3271
+ message: clipText(parsed.message, 2e3) || null,
3272
+ error: null
3273
+ }
3274
+ };
3211
3275
  }
3212
3276
  function validateTerminalReport(body) {
3213
3277
  const issues = [];
@@ -3227,6 +3291,25 @@ function validateTerminalReport(body) {
3227
3291
  "error",
3228
3292
  "finalText"
3229
3293
  ]) if (has(key) && typeof body[key] !== "string") issues.push(`${key} must be a string`);
3294
+ if (has("files")) {
3295
+ const files = body.files;
3296
+ if (!Array.isArray(files) || files.length > REPORT_FILES_MAX) issues.push(`files must be an array of at most ${REPORT_FILES_MAX} entries`);
3297
+ else {
3298
+ const names = /* @__PURE__ */ new Set();
3299
+ for (const entry of files) {
3300
+ const file = entry;
3301
+ 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)) {
3302
+ issues.push("files entries must be exactly {name, baseHash, hash, size} with unique names, sha256 hashes, and an in-cap size");
3303
+ break;
3304
+ }
3305
+ names.add(file.name);
3306
+ }
3307
+ }
3308
+ }
3309
+ if (has("fileNotices")) {
3310
+ const notices = body.fileNotices;
3311
+ 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`);
3312
+ }
3230
3313
  if (has("usage")) if (body.usage === null || typeof body.usage !== "object" || Array.isArray(body.usage)) issues.push("usage must be an object");
3231
3314
  else {
3232
3315
  const usage = body.usage;
@@ -3267,7 +3350,10 @@ var MachineGateway = class {
3267
3350
  constructor(scheduler, blobStore = createBlobStore()) {
3268
3351
  this.scheduler = scheduler;
3269
3352
  this.blobStore = blobStore;
3353
+ this.loopFiles = new LoopFiles(blobStore);
3270
3354
  }
3355
+ /** Loop-file bytes over the same blob store that maintenance garbage-collects. */
3356
+ loopFiles;
3271
3357
  /** In-flight latch: the maintenance pass is sequential and the first post-deploy
3272
3358
  * backlog reclamation can overrun the interval, so a fresh tick skips rather than
3273
3359
  * running a second pass concurrently (idempotent but wasteful + double-counts). */
@@ -3275,7 +3361,7 @@ var MachineGateway = class {
3275
3361
  applyAutopauseTimer(loopId, terminal) {
3276
3362
  if (!terminal.autoPaused) return;
3277
3363
  this.scheduler.removeLoop(loopId);
3278
- log$2.warn({
3364
+ log$1.warn({
3279
3365
  loopId,
3280
3366
  streak: terminal.failureStreak
3281
3367
  }, "loop auto-paused after block or consecutive errors");
@@ -3371,13 +3457,14 @@ var MachineGateway = class {
3371
3457
  * Periodic storage maintenance: prune each loop's run snapshots to the
3372
3458
  * retention window, then GC blob bytes no live row needs. Wired to its own
3373
3459
  * 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.
3460
+ * tests / on-demand triggers. Safe to run concurrently with owner writes and
3461
+ * run uploads (a grace window + final re-check protect freshly-written or
3462
+ * referenced blobs) and idempotent with no garbage. Best-effort — never throws
3463
+ * into the caller.
3377
3464
  */
3378
3465
  async maintainStorage(options) {
3379
3466
  if (this.maintenanceRunning) {
3380
- log$2.info("storage maintenance already in progress — skipping this tick");
3467
+ log$1.info("storage maintenance already in progress — skipping this tick");
3381
3468
  return {
3382
3469
  snapshotsPruned: 0,
3383
3470
  blobsReclaimed: 0
@@ -3387,7 +3474,7 @@ var MachineGateway = class {
3387
3474
  try {
3388
3475
  return await maintainStorage(this.blobStore, options);
3389
3476
  } catch (err) {
3390
- log$2.warn({ err: err instanceof Error ? err.message : String(err) }, "storage maintenance failed");
3477
+ log$1.warn({ err: err instanceof Error ? err.message : String(err) }, "storage maintenance failed");
3391
3478
  return {
3392
3479
  snapshotsPruned: 0,
3393
3480
  blobsReclaimed: 0
@@ -3413,7 +3500,7 @@ var MachineGateway = class {
3413
3500
  token: deviceToken,
3414
3501
  online: true
3415
3502
  });
3416
- log$2.info({
3503
+ log$1.info({
3417
3504
  machineId,
3418
3505
  host: info?.host
3419
3506
  }, "poll: self-registered machine");
@@ -3445,7 +3532,7 @@ var MachineGateway = class {
3445
3532
  const claimed = await claimReadyRunForMachine(machineId, void 0, currentRunIds ?? []);
3446
3533
  if (claimed) delivery = await buildDelivery(claimed.loop, claimed.run, claimed.runToken, machine.roots ?? []);
3447
3534
  }
3448
- if (delivery) log$2.info({
3535
+ if (delivery) log$1.info({
3449
3536
  machineId,
3450
3537
  runId: delivery.runId
3451
3538
  }, "poll: delivered");
@@ -3463,7 +3550,7 @@ var MachineGateway = class {
3463
3550
  code: "VALIDATION_ERROR"
3464
3551
  }
3465
3552
  };
3466
- if (request.protocolVersion !== 4) {
3553
+ if (request.protocolVersion !== 5) {
3467
3554
  const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
3468
3555
  if (!auth.ok && auth.reason === "mismatch") return auth.response;
3469
3556
  if (auth.ok && auth.machine) {
@@ -3475,7 +3562,7 @@ var MachineGateway = class {
3475
3562
  body: {
3476
3563
  error: "daemon upgrade required; run `npm install -g @kky42/pievo@latest`, then `pievo daemon restart`",
3477
3564
  code: "UPGRADE_REQUIRED",
3478
- requiredProtocol: 4
3565
+ requiredProtocol: 5
3479
3566
  }
3480
3567
  };
3481
3568
  }
@@ -3507,7 +3594,7 @@ var MachineGateway = class {
3507
3594
  const needsUpdate = daemonNeedsUpdate(reportedVersion);
3508
3595
  const base = await this.pollCore(deviceToken, request.info, currentIds, !needsUpdate, true);
3509
3596
  if (base.status !== 200) return base;
3510
- if ((await getMachine(machineId))?.daemonProtocol !== 4) await updateMachine(machineId, { daemonProtocol: 4 });
3597
+ if ((await getMachine(machineId))?.daemonProtocol !== 5) await updateMachine(machineId, { daemonProtocol: 5 });
3511
3598
  const running = await runningRunsForMachine(machineId);
3512
3599
  const currentSet = new Set(currentIds);
3513
3600
  return {
@@ -3524,7 +3611,7 @@ var MachineGateway = class {
3524
3611
  };
3525
3612
  }
3526
3613
  async pollV4Wait(deviceToken, request, waitMs = LONG_POLL_WAIT_MS) {
3527
- if (request.currentRuns?.length || request.protocolVersion !== 4) return this.pollV4(deviceToken, request);
3614
+ if (request.currentRuns?.length || request.protocolVersion !== 5) return this.pollV4(deviceToken, request);
3528
3615
  const auth = await authenticateDeviceToken(deviceToken, { allowUnknown: true });
3529
3616
  if (!auth.ok) return auth.response;
3530
3617
  const machineId = auth.machineId;
@@ -3632,7 +3719,7 @@ var MachineGateway = class {
3632
3719
  });
3633
3720
  this.scheduler.addLoop(loop);
3634
3721
  recordNewIdempotency(idempotencyKey, machineId, loop.id);
3635
- log$2.info({
3722
+ log$1.info({
3636
3723
  machineId,
3637
3724
  loopId: loop.id,
3638
3725
  agent: loop.agent
@@ -3846,7 +3933,7 @@ var MachineGateway = class {
3846
3933
  }
3847
3934
  const response = receiptResponse(stored, expected);
3848
3935
  if (response.status < 300) await retireLease(runToken);
3849
- log$2.info({ runId: lease.runId }, "report: ignored (run was canceled)");
3936
+ log$1.info({ runId: lease.runId }, "report: ignored (run was canceled)");
3850
3937
  return response;
3851
3938
  }
3852
3939
  async reportRaceResponse(runToken, lease, body, payloadDigest) {
@@ -3870,7 +3957,7 @@ var MachineGateway = class {
3870
3957
  const refreshedLease = await resolveLease(runToken);
3871
3958
  if (refreshedLease?.state === "retired") return this.retiredReport(runToken, body, lease.runId);
3872
3959
  if (retryReconcile && fresh?.phase === "error" && refreshedLease?.state === "terminal-grace") return this.reconcileReclaimedReport(runToken, refreshedLease, fresh, body);
3873
- log$2.info({
3960
+ log$1.info({
3874
3961
  runId: lease.runId,
3875
3962
  phase: fresh?.phase
3876
3963
  }, logMessage);
@@ -3883,12 +3970,108 @@ var MachineGateway = class {
3883
3970
  }
3884
3971
  };
3885
3972
  }
3973
+ /** Reported loop-file versions whose uploaded bytes are present. A canceled run
3974
+ * was killed mid-edit, so none of its files are applied. */
3975
+ async terminalFiles(body) {
3976
+ const notices = (body.fileNotices ?? []).map((notice) => clipText(notice, NOTICE_CAP));
3977
+ const updates = [];
3978
+ if (body.result === "canceled") return {
3979
+ updates,
3980
+ notices
3981
+ };
3982
+ const files = body.files ?? [];
3983
+ const sizes = await blobSizes(files.map((file) => file.hash).filter((hash) => hash !== EMPTY_SHA256));
3984
+ for (const file of files) {
3985
+ if (!await this.loopFiles.hasBlob(file.hash)) {
3986
+ notices.push(`${file.name}: the upload was lost; the previous version was kept`);
3987
+ continue;
3988
+ }
3989
+ const size = file.hash === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ? 0 : sizes.get(file.hash);
3990
+ if (size === void 0 || size !== file.size) {
3991
+ notices.push(`${file.name}: reported size did not match the stored version; the previous version was kept`);
3992
+ continue;
3993
+ }
3994
+ updates.push({
3995
+ name: file.name,
3996
+ baseHash: file.baseHash,
3997
+ hash: file.hash,
3998
+ size
3999
+ });
4000
+ }
4001
+ return {
4002
+ updates,
4003
+ notices
4004
+ };
4005
+ }
4006
+ /** Current bytes of one loop file for an executing run (the run's starting version). */
4007
+ async readRunFile(runToken, name) {
4008
+ const lease = await resolveLease(runToken);
4009
+ if (!lease) return {
4010
+ status: 401,
4011
+ body: { error: "invalid or expired token" }
4012
+ };
4013
+ if (lease.state !== "active") return {
4014
+ status: 409,
4015
+ body: { error: "run is no longer executing" }
4016
+ };
4017
+ const loop = await getLoop(lease.loopId);
4018
+ if (!loop || !loopFileNames(loop).includes(name)) return {
4019
+ status: 404,
4020
+ body: { error: "no such loop file" }
4021
+ };
4022
+ const row = await getArtifactFile(loop.id, name);
4023
+ const hash = row && !row.deleted && row.hash ? row.hash : EMPTY_SHA256;
4024
+ const bytes = await this.loopFiles.readBlob(hash);
4025
+ if (!bytes) return {
4026
+ status: 500,
4027
+ body: { error: "loop file bytes are missing" }
4028
+ };
4029
+ return {
4030
+ status: 200,
4031
+ body: {},
4032
+ bytes,
4033
+ hash
4034
+ };
4035
+ }
4036
+ /** Store one loop-file version produced by a run. Accepted until the run's
4037
+ * authority retires, so the durable report outbox can retry after a crash.
4038
+ * Applying it to the loop happens only in the terminal report transaction. */
4039
+ async storeRunFile(runToken, name, bytes, expectedHash) {
4040
+ const lease = await resolveLease(runToken);
4041
+ if (!lease || lease.state === "retired") return {
4042
+ status: 401,
4043
+ body: { error: "invalid or expired token" }
4044
+ };
4045
+ const loop = await getLoop(lease.loopId);
4046
+ if (!loop || !loopFileNames(loop).includes(name)) return {
4047
+ status: 404,
4048
+ body: { error: "no such loop file" }
4049
+ };
4050
+ const issue = loopFileIssue(name, bytes);
4051
+ if (issue) return {
4052
+ status: 413,
4053
+ body: { error: issue }
4054
+ };
4055
+ if (expectedHash !== null && expectedHash !== sha256Buf(bytes)) return {
4056
+ status: 400,
4057
+ body: { error: "hash mismatch" }
4058
+ };
4059
+ const hash = await this.loopFiles.putBlob(bytes);
4060
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") await recordBlob(hash, bytes.length);
4061
+ return {
4062
+ status: 200,
4063
+ body: {
4064
+ ok: true,
4065
+ hash
4066
+ }
4067
+ };
4068
+ }
3886
4069
  async captureRunSnapshot(runId, loopId) {
3887
4070
  try {
3888
4071
  await putRunSnapshot(runId, loopId, await buildLoopManifest(loopId));
3889
4072
  await pruneRunSnapshots(loopId, 20);
3890
4073
  } catch (err) {
3891
- log$2.warn({
4074
+ log$1.warn({
3892
4075
  runId,
3893
4076
  err: err instanceof Error ? err.message : String(err)
3894
4077
  }, "snapshot capture failed");
@@ -3898,11 +4081,8 @@ var MachineGateway = class {
3898
4081
  * same loop-lock transaction as the error→done/error patch and loop state, so
3899
4082
  * concurrent late reports cannot both win (including error→error). */
3900
4083
  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;
4084
+ const { ok, canceled, patch } = reportOutcome(body);
4085
+ const files = await this.terminalFiles(body);
3906
4086
  const loopPatch = {};
3907
4087
  const receipt = receiptFor(body, lease.runId);
3908
4088
  const payloadDigest = sha256(JSON.stringify(body));
@@ -3911,10 +4091,10 @@ var MachineGateway = class {
3911
4091
  reconciled = await reconcileReclaimedRun(lease.loopId, lease.runId, sha256(runToken), {
3912
4092
  phase: canceled ? "canceled" : ok ? "done" : "error",
3913
4093
  ...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 },
4094
+ ...patch,
4095
+ fileNotices: files.notices,
3916
4096
  ts: nowIso()
3917
- }, loopPatch, AUTOPAUSE_STREAK, receipt);
4097
+ }, loopPatch, AUTOPAUSE_STREAK, receipt, files.updates);
3918
4098
  } catch (error) {
3919
4099
  const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
3920
4100
  if (raced) return raced;
@@ -3927,7 +4107,7 @@ var MachineGateway = class {
3927
4107
  if (!reportOnly) this.scheduler.addLoop(reconciled.loop);
3928
4108
  if (!reportOnly) this.applyAutopauseTimer(lease.loopId, reconciled);
3929
4109
  if (deleting) await tryDeleteLoop(lease.loopId);
3930
- log$2.info({
4110
+ log$1.info({
3931
4111
  runId: lease.runId,
3932
4112
  ok,
3933
4113
  reclaimed: true
@@ -3980,7 +4160,7 @@ var MachineGateway = class {
3980
4160
  this.scheduler.addLoop(rejected.loop);
3981
4161
  if (rejected.state === "run-error") this.applyAutopauseTimer(lease.loopId, rejected);
3982
4162
  if (rejected.loop.deleteRequestedAt) await tryDeleteLoop(lease.loopId);
3983
- log$2.warn({
4163
+ log$1.warn({
3984
4164
  runId: lease.runId,
3985
4165
  reportId,
3986
4166
  code,
@@ -4034,8 +4214,6 @@ var MachineGateway = class {
4034
4214
  if (evidence.foreignRun) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_CONFLICT", ["reportId was already committed for another run"]);
4035
4215
  if (issues.length) return this.rejectTerminalAttempt(runToken, lease, body, payloadDigest, "REPORT_INVALID", issues);
4036
4216
  const expected = receiptFor(body, lease.runId);
4037
- const ok = body.result === "success";
4038
- const canceled = body.result === "canceled";
4039
4217
  const run = await getRun(lease.runId);
4040
4218
  if (run?.phase === "canceled") return this.ignoreCanceledReport(runToken, lease, body);
4041
4219
  if (run?.phase === "done") return {
@@ -4047,17 +4225,18 @@ var MachineGateway = class {
4047
4225
  }
4048
4226
  };
4049
4227
  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;
4228
+ const { ok: effectiveOk, canceled, patch } = reportOutcome(body);
4229
+ const files = await this.terminalFiles(body);
4052
4230
  const loopPatch = {};
4053
4231
  let terminal;
4054
4232
  try {
4055
4233
  terminal = await finalizeRunningRun(lease.loopId, lease.runId, {
4056
4234
  phase: canceled ? "canceled" : effectiveOk ? "done" : "error",
4057
4235
  ...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" },
4236
+ ...patch,
4237
+ fileNotices: files.notices,
4059
4238
  ts: nowIso()
4060
- }, loopPatch, sha256(runToken), AUTOPAUSE_STREAK, expected);
4239
+ }, loopPatch, sha256(runToken), AUTOPAUSE_STREAK, expected, files.updates);
4061
4240
  } catch (error) {
4062
4241
  const raced = await this.reportRaceResponse(runToken, lease, body, payloadDigest);
4063
4242
  if (raced) return raced;
@@ -4068,7 +4247,7 @@ var MachineGateway = class {
4068
4247
  if (!deleting) await this.captureRunSnapshot(lease.runId, lease.loopId);
4069
4248
  this.scheduler.addLoop(terminal.loop);
4070
4249
  this.applyAutopauseTimer(lease.loopId, terminal);
4071
- log$2.info({
4250
+ log$1.info({
4072
4251
  runId: lease.runId,
4073
4252
  ok: effectiveOk
4074
4253
  }, "report: finalized");
@@ -4182,157 +4361,9 @@ function nextFires(cron, timezone, n) {
4182
4361
  }
4183
4362
  }
4184
4363
  /**
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
4364
+ * Per-run loop-file diff. Computed lazily on the server at read time
4334
4365
  * (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
4366
+ * the two name → metadata maps. For changed TEXT files we load both blobs and
4336
4367
  * compute a unified line diff with `diff` (jsdiff) — a pure-string library, no
4337
4368
  * process execution, so the server's zero-exec invariant holds. Binary/oversize
4338
4369
  * files emit a size-delta marker only (no inline diff).
@@ -4353,7 +4384,7 @@ function withinDiffCap(e) {
4353
4384
  }
4354
4385
  async function bytesOf(e) {
4355
4386
  if (!e.hash) return null;
4356
- return await (await getArtifactSync()).readBlob(e.hash) ?? null;
4387
+ return await (await getLoopFiles()).readBlob(e.hash) ?? null;
4357
4388
  }
4358
4389
  function unified(path, oldText, newText, maxEditLength) {
4359
4390
  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 +4397,7 @@ function expectedInputBytes(change, verifiedSizes) {
4366
4397
  if (sizes.some((size) => size === void 0)) return null;
4367
4398
  return sizes.reduce((sum, size) => sum + size, 0);
4368
4399
  }
4369
- /** Compute one run's artifact diff. The default remains the web UI's complete
4400
+ /** Compute one run's loop-file diff. The default remains the web UI's complete
4370
4401
  * view. Budgeted callers get explicit totals and stop before blob reads/jsdiff
4371
4402
  * once any work limit is spent. */
4372
4403
  async function computeRunDiff(runId, budget) {
@@ -4781,6 +4812,7 @@ async function detailHistory(loop, q) {
4781
4812
  errorTruncated: error.truncated,
4782
4813
  finalText: finalText.value,
4783
4814
  finalTextTruncated: finalText.truncated,
4815
+ fileNotices: run.fileNotices ?? [],
4784
4816
  diffAvailable: !!snapshot,
4785
4817
  diff: q.diff ? await boundedDiff(run.id) : {
4786
4818
  included: false,
@@ -4806,6 +4838,7 @@ async function detailHistory(loop, q) {
4806
4838
  ["errorTruncated", detail.errorTruncated],
4807
4839
  ["finalText", detail.finalText],
4808
4840
  ["finalTextTruncated", detail.finalTextTruncated],
4841
+ ["fileNotices", { raw: JSON.stringify(detail.fileNotices) }],
4809
4842
  ["diffAvailable", detail.diffAvailable],
4810
4843
  ["diff", { raw: JSON.stringify(detail.diff) }]
4811
4844
  ]));
@@ -4830,8 +4863,8 @@ async function readLoopHistory(loop, flags) {
4830
4863
  * The verb router keys authority on credential type first (`dk_` device prefix
4831
4864
  * vs `rk_` run lease) and reuses the core gateway
4832
4865
  * methods (`createLoop`/`editLoop`) through the injected `MachineGateway`,
4833
- * plus the credential-neutral history module. Run credentials expose only the
4834
- * exactly-once report callback.
4866
+ * plus the credential-neutral history module. Run credentials authorize no CLI
4867
+ * verb: a run reports through its final response, not a callback.
4835
4868
  */
4836
4869
  var cliLog = logger.child({ mod: "cli" });
4837
4870
  var CliGateway = class {
@@ -4850,9 +4883,8 @@ var CliGateway = class {
4850
4883
  * already exists — never a rewrite — that keys authority on the credential type:
4851
4884
  * · DEVICE credential (`dk_`-prefixed) → owner authority over any loop bound to
4852
4885
  * 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.
4886
+ * `log`→readLoopHistory, `show`→describe, `file`→loop files.
4887
+ * · RUN credential (`rk_` run lease) → rejected; runs have no CLI.
4856
4888
  */
4857
4889
  async cli(token, argv) {
4858
4890
  return finalizeCli(token.startsWith("dk_") ? await this.deviceCli(token, argv) : await this.runCli(token, argv));
@@ -4943,13 +4975,10 @@ var CliGateway = class {
4943
4975
  }
4944
4976
  };
4945
4977
  }
4946
- case "report": return {
4947
- status: 403,
4948
- body: { error: "pievo: report is a run-only verb; the owner edits via edit" }
4949
- };
4978
+ case "file": return this.ownerFile(machineId, argv);
4950
4979
  default: return {
4951
4980
  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)` }
4981
+ body: { error: `pievo: unknown command "${verb}" for the device credential (try: new, loops, pause, start, stop, delete, run stop, edit, log, show, file)` }
4953
4982
  };
4954
4983
  }
4955
4984
  }
@@ -4998,7 +5027,7 @@ var CliGateway = class {
4998
5027
  }
4999
5028
  async requireStopProtocol(machineId, running) {
5000
5029
  if (!running) return void 0;
5001
- if ((await getMachine(machineId))?.daemonProtocol === 4) return void 0;
5030
+ if ((await getMachine(machineId))?.daemonProtocol === 5) return void 0;
5002
5031
  return {
5003
5032
  status: 426,
5004
5033
  body: {
@@ -5109,121 +5138,90 @@ var CliGateway = class {
5109
5138
  body: { text: `run already finished: ${runResultToken(stopped)}` }
5110
5139
  };
5111
5140
  }
5112
- async runCli(runToken, argv) {
5113
- const lease = await resolveLease(runToken);
5114
- if (!lease) return {
5141
+ async runCli(runToken, _argv) {
5142
+ if (!await resolveLease(runToken)) return {
5115
5143
  status: 401,
5116
5144
  body: {
5117
5145
  text: errorBlock("invalid or expired token", "UNAUTHORIZED"),
5118
5146
  exitCode: 1
5119
5147
  }
5120
5148
  };
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 {
5149
+ return {
5145
5150
  status: 403,
5146
5151
  body: {
5147
- text: errorBlock("run credentials authorize only pievo report", "FORBIDDEN"),
5152
+ text: errorBlock(RUN_HAS_NO_CLI, "FORBIDDEN"),
5148
5153
  exitCode: 1
5149
5154
  }
5150
5155
  };
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
5156
  }
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)
5157
+ /** `file list|get|set` over one loop's files. `set` takes base64 content so
5158
+ * arbitrary text (including leading `--`) survives argv transport. */
5159
+ async ownerFile(machineId, argv) {
5160
+ const [, sub, loopId, name] = argv;
5161
+ const loop = loopId ? await this.ownedLoop(machineId, loopId) : void 0;
5162
+ if (sub !== "list" && sub !== "get" && sub !== "set") return {
5163
+ status: 400,
5164
+ body: { error: `usage: ${FILE_USAGE}` }
5165
+ };
5166
+ if (!loop) return {
5167
+ status: 404,
5168
+ body: { error: "no such loop on this machine" }
5167
5169
  };
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)
5170
+ const names = loopFileNames(loop);
5171
+ if (sub === "list") return {
5172
+ status: 200,
5173
+ body: { text: doc(listBlock("files", [
5174
+ "name",
5175
+ "size",
5176
+ "limit",
5177
+ "updatedAt"
5178
+ ], await Promise.all(names.map(async (file) => {
5179
+ const row = await getArtifactFile(loop.id, file);
5180
+ const live = row && !row.deleted && row.hash ? row : void 0;
5181
+ return [
5182
+ file,
5183
+ live?.size ?? 0,
5184
+ fileLimitLabel(file),
5185
+ live ? fmtTime(live.updatedAt) : null
5186
+ ];
5187
+ }))), 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`])) }
5172
5188
  };
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"
5189
+ if (!name || !names.includes(name)) return {
5190
+ status: 404,
5191
+ body: { error: `no such loop file: ${name ?? ""} (files: ${names.join(", ")})` }
5188
5192
  };
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"
5193
+ if (sub === "get") {
5194
+ const row = await getArtifactFile(loop.id, name);
5195
+ const bytes = await this.gateway.loopFiles.readBlob(row && !row.deleted && row.hash ? row.hash : EMPTY_SHA256);
5196
+ if (!bytes) return {
5197
+ status: 500,
5198
+ body: { error: "loop file bytes are missing" }
5199
+ };
5200
+ return {
5201
+ status: 200,
5202
+ body: { text: bytes.length ? bytes.toString("utf8") : "\n" }
5203
+ };
5204
+ }
5205
+ const encoded = parseFlags(argv.slice(4))["content-base64"];
5206
+ if (typeof encoded !== "string") return {
5207
+ status: 400,
5208
+ body: { error: `usage: ${FILE_USAGE}` }
5209
5209
  };
5210
- if (result.state === "missing-loop") return {
5211
- ok: false,
5212
- detail: "loop not found",
5213
- code: "NOT_FOUND",
5214
- status: 404
5210
+ const bytes = Buffer.from(encoded, "base64");
5211
+ const issue = loopFileIssue(name, bytes);
5212
+ if (issue) return {
5213
+ status: 413,
5214
+ body: { error: `${issue}; nothing was changed` }
5215
5215
  };
5216
- if (result.state === "already-reported") return {
5217
- ok: false,
5218
- detail: "this run already reported",
5219
- code: "CONFLICT",
5220
- status: 409
5216
+ const hash = await this.gateway.loopFiles.putBlob(bytes);
5217
+ if (hash !== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") await recordBlob(hash, bytes.length);
5218
+ if (!await writeLoopFile(loop.id, name, hash, bytes.length)) return {
5219
+ status: 404,
5220
+ body: { error: "no such loop file" }
5221
5221
  };
5222
5222
  return {
5223
- ok: false,
5224
- detail: "this run is no longer active",
5225
- code: "CONFLICT",
5226
- status: 409
5223
+ status: 200,
5224
+ body: { text: `file: ${name}\nsize: ${bytes.length}\nupdated: true` }
5227
5225
  };
5228
5226
  }
5229
5227
  async describe(loopId, opts = {}) {
@@ -5268,17 +5266,8 @@ var CliGateway = class {
5268
5266
  })), scoped.elsewhere, await recentMachineRuns(loops, 3));
5269
5267
  }
5270
5268
  };
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
- ]);
5269
+ 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`";
5270
+ var FILE_USAGE = "pievo file list <loop> | file get <loop> <name> | file set <loop> <name> --from <path>";
5282
5271
  var PAUSED_FINISHING = "loop paused; current run is finishing";
5283
5272
  var STOP_UPGRADE_REQUIRED = "Daemon upgrade required to stop a running process. Run `npm install -g @kky42/pievo@latest`, then `pievo daemon restart`.";
5284
5273
  var FORCE_DELETE_CONFIRMATION = "delete-server-data-anyway";
@@ -5316,15 +5305,6 @@ function parseJsonFlag(raw) {
5316
5305
  value: obj
5317
5306
  };
5318
5307
  }
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
5308
  /** The structured keys a `/api/machine/cli` body may carry. The daemon renders
5329
5309
  * `text` and exits with `exitCode`; two structured channels remain because the
5330
5310
  * current daemon reads them as data:
@@ -5352,18 +5332,6 @@ function finalizeCli(res) {
5352
5332
  }
5353
5333
  return res;
5354
5334
  }
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
5335
  var DEVICE_VERB_HELP = {
5368
5336
  new: {
5369
5337
  syntax: "new --json '<config>' [--dry-run]",
@@ -5402,24 +5370,29 @@ var DEVICE_VERB_HELP = {
5402
5370
  },
5403
5371
  delete: {
5404
5372
  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"]
5373
+ summary: "stop first, then delete server history, memory, and artifacts",
5374
+ 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
5375
  },
5408
5376
  run: {
5409
5377
  syntax: "run stop <run-id>",
5410
5378
  summary: "stop one pending or running run without pausing its loop",
5411
5379
  help: ["A running run remains running until the daemon confirms cancellation"]
5412
5380
  },
5381
+ file: {
5382
+ syntax: "file list <id> | file get <id> <name> | file set <id> <name> --from <path>",
5383
+ summary: "inspect or replace a loop's MEMORY.md and artifact files",
5384
+ 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"]
5385
+ },
5413
5386
  log: {
5414
5387
  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
5388
  summary: "bounded terminal history or one detailed run",
5416
5389
  help: ["Run `pievo log <id> --json` for structured history", "Run `pievo log <id> --run 12 --diff` for bounded run detail and artifact diff"]
5417
5390
  }
5418
5391
  };
5419
- function verbHelpText(verb, lease) {
5420
- const spec = lease ? RUN_VERB_HELP[verb] : DEVICE_VERB_HELP[verb];
5392
+ function verbHelpText(verb) {
5393
+ const spec = DEVICE_VERB_HELP[verb];
5421
5394
  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));
5395
+ return doc(kvLine("verb", verb), kvLine("syntax", spec.syntax), kvLine("summary", spec.summary), helpBlock(spec.help));
5423
5396
  }
5424
5397
  function loopEnvelope(loop) {
5425
5398
  return { ...canonicalLoopEnvelope(loop) };
@@ -5456,7 +5429,8 @@ function renderShowText(loop, env, totalRuns, lastRun, opts = {}) {
5456
5429
  const help = [
5457
5430
  `Run \`pievo show ${loop.id} --full\` to see the complete prompt`,
5458
5431
  `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`
5432
+ `Run \`pievo log ${loop.id}\` to see recent run results`,
5433
+ `Run \`pievo file list ${loop.id}\` to see the loop's memory and artifact files`
5460
5434
  ];
5461
5435
  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
5436
  }
@@ -5544,9 +5518,6 @@ function renderHomeText(ctx, presence, here, elsewhere, recent) {
5544
5518
  "Run `pievo new --json '{...}'` to create a loop"
5545
5519
  ]));
5546
5520
  }
5547
- function reportStatus(s) {
5548
- return s === "keep" || s === "no-change" || s === "block" ? s : void 0;
5549
- }
5550
5521
  /** Tiny flag parser: `--k v` pairs, bare `--flag` → true, first positional under `_`.
5551
5522
  * Every key/value is NUL-stripped at this wire boundary before validation or
5552
5523
  * persistence because Postgres text rejects NUL. */
@@ -5687,9 +5658,9 @@ function msg(err) {
5687
5658
  */
5688
5659
  var boot_exports = /* @__PURE__ */ __exportAll({
5689
5660
  ensureServer: () => ensureServer,
5690
- getArtifactSync: () => getArtifactSync,
5691
5661
  getCliGateway: () => getCliGateway,
5692
- getGateway: () => getGateway
5662
+ getGateway: () => getGateway,
5663
+ getLoopFiles: () => getLoopFiles
5693
5664
  });
5694
5665
  var g = globalThis;
5695
5666
  function ensureServer() {
@@ -5714,9 +5685,7 @@ async function boot() {
5714
5685
  };
5715
5686
  let gateway;
5716
5687
  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);
5688
+ gateway = new MachineGateway(scheduler, createBlobStore());
5720
5689
  const cliGateway = new CliGateway(gateway);
5721
5690
  await scheduler.start(abort.signal);
5722
5691
  const sweep = setInterval(() => void gateway.sweep().catch((err) => logger.error({ err: String(err) }, "sweep tick failed")), ONLINE_TTL_MS);
@@ -5729,7 +5698,6 @@ async function boot() {
5729
5698
  return {
5730
5699
  scheduler,
5731
5700
  gateway,
5732
- artifactSync,
5733
5701
  cliGateway,
5734
5702
  abort,
5735
5703
  shutdown
@@ -5738,11 +5706,11 @@ async function boot() {
5738
5706
  async function getGateway() {
5739
5707
  return (await ensureServer()).gateway;
5740
5708
  }
5741
- async function getArtifactSync() {
5742
- return (await ensureServer()).artifactSync;
5709
+ async function getLoopFiles() {
5710
+ return (await ensureServer()).gateway.loopFiles;
5743
5711
  }
5744
5712
  async function getCliGateway() {
5745
5713
  return (await ensureServer()).cliGateway;
5746
5714
  }
5747
5715
  //#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 };
5716
+ export { listRuns as A, requestDeleteLoop as B, getRun as C, listArtifacts as D, isOutdated as E, machinePresence as F, tryDeleteLoop as G, sha256 as H, mintDeviceToken as I, validateLoopTags as J, updateLoop as K, pauseLoop as L, loopTags_CnVLi70h_exports as M, loopsForMachine as N, listLoops as O, machineIdFromToken as P, recentLoopUsage as R, getMachine as S, hasRunningRun as T, startLoop as U, requestRunCancel as V, stopLoop as W, 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, updateMachine as q, boot_DmpKtb0A_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, reconciliationStatesForRuns as z };