@dench.com/cli 0.4.0 → 0.4.2

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.
package/fs-daemon.ts CHANGED
@@ -50,16 +50,16 @@
50
50
  */
51
51
  import { createHash } from "node:crypto";
52
52
  import { mkdirSync } from "node:fs";
53
- import {
54
- mkdir,
55
- readdir,
56
- readFile,
57
- stat,
58
- writeFile,
59
- } from "node:fs/promises";
53
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
60
54
  import { dirname, relative } from "node:path";
61
55
  import { ConvexClient, ConvexHttpClient } from "convex/browser";
62
56
  import { makeFunctionReference } from "convex/server";
57
+ import { decodeInlineTextOrUndefined } from "./fs-daemon-binary";
58
+ import {
59
+ type DrainStatus,
60
+ FLUSH_DEFAULT_TIMEOUT_MS,
61
+ runFlushPolling,
62
+ } from "./fs-daemon-flush";
63
63
  import {
64
64
  detectWorkspaceFsType,
65
65
  findMountForPath,
@@ -156,12 +156,20 @@ type BreakerState = {
156
156
  syncsInWindow: number;
157
157
  };
158
158
 
159
+ // `DrainStatus` lives in cli/fs-daemon-flush.ts so the flush subcommand's
160
+ // polling helper can import it without dragging in the daemon's main
161
+ // entrypoint (which would crash test runners that import this file).
162
+
159
163
  class HashTracker {
160
164
  private hashes = new Map<string, string>();
161
165
  private timers = new Map<string, NodeJS.Timeout>();
162
166
  private recentSyncs = new Map<string, number[]>();
163
167
  private paused = new Map<string, BreakerState>();
164
168
  private onBreakerTrip?: (state: BreakerState) => void;
169
+ // Bumped right before each scheduled fn() actually starts; decremented
170
+ // in the fn()'s `.finally()`. The flush subcommand reads this through
171
+ // `drainStatus()` (mirrored into the status file once per reconcile).
172
+ private inFlightUploads = 0;
165
173
 
166
174
  get(absPath: string): string | undefined {
167
175
  return this.hashes.get(absPath);
@@ -237,16 +245,37 @@ class HashTracker {
237
245
  if (existing) clearTimeout(existing);
238
246
  const timer = setTimeout(() => {
239
247
  this.timers.delete(absPath);
240
- fn().catch((error) => {
241
- console.error(
242
- `[dench-fs-daemon] sync failed for ${absPath}: ${
243
- error instanceof Error ? error.message : String(error)
244
- }`,
245
- );
246
- });
248
+ // Track this work in-flight so `flush` knows to wait for the
249
+ // upload to actually finish, not just for the next reconcile
250
+ // tick to fire. The decrement runs in `finally` so even a
251
+ // throwing upload doesn't strand the counter at non-zero.
252
+ this.inFlightUploads += 1;
253
+ fn()
254
+ .catch((error) => {
255
+ console.error(
256
+ `[dench-fs-daemon] sync failed for ${absPath}: ${
257
+ error instanceof Error ? error.message : String(error)
258
+ }`,
259
+ );
260
+ })
261
+ .finally(() => {
262
+ this.inFlightUploads = Math.max(0, this.inFlightUploads - 1);
263
+ });
247
264
  }, ms);
248
265
  this.timers.set(absPath, timer);
249
266
  }
267
+
268
+ /**
269
+ * Snapshot of pending sync work. Mirrored into the daemon status
270
+ * file once per reconcile tick so `dench-fs-daemon flush` can wait
271
+ * on it without an IPC channel.
272
+ */
273
+ drainStatus(): DrainStatus {
274
+ return {
275
+ pendingPaths: this.timers.size,
276
+ inFlightUploads: this.inFlightUploads,
277
+ };
278
+ }
250
279
  }
251
280
 
252
281
  // Mount detection helpers live in fs-daemon-mount.ts so they can be
@@ -284,6 +313,9 @@ const api = {
284
313
  getFileSignedDownloadUrl: makeFunctionReference<"action">(
285
314
  "functions/files:getFileSignedDownloadUrl",
286
315
  ),
316
+ reportDaemonHealth: makeFunctionReference<"mutation">(
317
+ "functions/files:reportDaemonHealth",
318
+ ),
287
319
  },
288
320
  };
289
321
 
@@ -371,9 +403,9 @@ class DenchFileClient {
371
403
  * fs status` to recursively crawl the canonical tree and diff
372
404
  * against the local FS snapshot.
373
405
  */
374
- async listTree(prefix: string): Promise<
375
- Array<{ path: string; contentHash?: string; isDir: boolean }>
376
- > {
406
+ async listTree(
407
+ prefix: string,
408
+ ): Promise<Array<{ path: string; contentHash?: string; isDir: boolean }>> {
377
409
  const rows = (await this.http.query(api.files.listTree, {
378
410
  prefix,
379
411
  apiKey: this.apiKey,
@@ -407,6 +439,27 @@ class DenchFileClient {
407
439
  });
408
440
  }
409
441
 
442
+ /**
443
+ * Heartbeat the per-org `fileTreeHealth` row. Best-effort — a Convex
444
+ * blip should never block the reconcile path.
445
+ */
446
+ async reportHealth(args: {
447
+ pid: number;
448
+ fsType: string;
449
+ isFuse: boolean;
450
+ workspace: string;
451
+ lastReconcileFinishedAt: number;
452
+ lastFlushAt?: number;
453
+ onDiskCount: number;
454
+ activeBreakers: number;
455
+ pendingDrain: number;
456
+ }): Promise<void> {
457
+ await this.http.mutation(api.files.reportDaemonHealth, {
458
+ ...args,
459
+ apiKey: this.apiKey,
460
+ } as never);
461
+ }
462
+
410
463
  /**
411
464
  * Subscribe to fileTree updates for the org and invoke onChange whenever
412
465
  * a row's contentHash diverges from our local lastSyncedHash. The
@@ -505,6 +558,10 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
505
558
  fsType: mount.fsType,
506
559
  isFuse: mount.isFuse,
507
560
  });
561
+ // Hook the writer to the live tracker so every status flush
562
+ // captures the current pending/in-flight counts. The flush
563
+ // subcommand polls these via the status file.
564
+ status.setDrainSnapshot(() => tracker.drainStatus());
508
565
  tracker.setBreakerListener((breakerState) => {
509
566
  status.recordBreakerEvent(breakerState);
510
567
  void status.flush();
@@ -580,9 +637,15 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
580
637
  );
581
638
  const uploadUrl = await client.generateUploadUrl(posixPath);
582
639
  const storageId = await client.putBytes(uploadUrl, bytes);
640
+ // Only inline text for SMALL files that look like real UTF-8.
641
+ // Without the binary check, a 50KB PNG would get
642
+ // `TextDecoder({ fatal: false }).decode(bytes)`'d into a stream
643
+ // of U+FFFD replacement chars and persisted into
644
+ // `fileContents.text`, corrupting every viewer that hits the
645
+ // inline cache instead of the storage blob. See cli/fs-daemon-binary.ts.
583
646
  const inlineText =
584
647
  stats.size <= MAX_INLINE_TEXT_BYTES
585
- ? new TextDecoder("utf-8", { fatal: false }).decode(bytes)
648
+ ? decodeInlineTextOrUndefined(bytes)
586
649
  : undefined;
587
650
  const result = await client.commitFileUpload({
588
651
  path: posixPath,
@@ -645,8 +708,10 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
645
708
  // (any file write on a Mountpoint volume from a process other than
646
709
  // this sandbox, or from the Daytona SDK uploadFile path) and writes
647
710
  // a heartbeat to the status file so `dench fs status` can confirm
648
- // the daemon is alive and what it last did.
649
- const stopReconcile = startReconcileLoop({
711
+ // the daemon is alive and what it last did. Also mirrors the
712
+ // heartbeat to Convex `fileTreeHealth` so the UI sidebar + chat-
713
+ // turn end-of-turn check can surface drift loudly.
714
+ const reconciler = startReconcileLoop({
650
715
  workspace: daemonArgs.workspace,
651
716
  intervalMs: reconcileIntervalMs,
652
717
  tracker,
@@ -669,10 +734,50 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
669
734
  );
670
735
  }
671
736
  },
737
+ reportHealth: async (snapshot) => {
738
+ await client.reportHealth({
739
+ pid: process.pid,
740
+ fsType: mount.fsType,
741
+ isFuse: mount.isFuse,
742
+ workspace: daemonArgs.workspace,
743
+ lastReconcileFinishedAt: snapshot.finishedAt,
744
+ lastFlushAt: status.snapshot().lastFlushAt ?? undefined,
745
+ onDiskCount: snapshot.observed,
746
+ activeBreakers: snapshot.activeBreakers,
747
+ pendingDrain: snapshot.pendingDrain,
748
+ });
749
+ },
750
+ });
751
+
752
+ // SIGUSR1 = "flush requested" from `dench-fs-daemon flush` (chat-turn
753
+ // calls it after every execute_bash that touched /workspace). Run an
754
+ // immediate reconcile tick + record the flush completion timestamp so
755
+ // the polling caller can confirm the signal was honored. Errors are
756
+ // swallowed loudly into the daemon log; the flush command itself is
757
+ // responsible for surfacing timeouts.
758
+ process.on("SIGUSR1", () => {
759
+ const requestedAt = Date.now();
760
+ console.log(
761
+ `[dench-fs-daemon] SIGUSR1 received — running reconcile tick on demand`,
762
+ );
763
+ void reconciler
764
+ .runOneTick()
765
+ .catch((error) =>
766
+ console.error(
767
+ `[dench-fs-daemon] flush tick failed: ${
768
+ error instanceof Error ? error.message : String(error)
769
+ }`,
770
+ ),
771
+ )
772
+ .then(() => {
773
+ status.recordFlushCompleted(requestedAt);
774
+ return status.flush();
775
+ })
776
+ .catch(() => undefined);
672
777
  });
673
778
 
674
779
  process.on("SIGTERM", () => {
675
- stopReconcile();
780
+ reconciler.stop();
676
781
  unsubscribe();
677
782
  watcher.close().catch(() => undefined);
678
783
  process.exit(0);
@@ -742,6 +847,18 @@ async function snapshotWorkspace(
742
847
  return result;
743
848
  }
744
849
 
850
+ type ReconcileLoopHandle = {
851
+ /** Tear down the periodic timers (called on SIGTERM). */
852
+ stop: () => void;
853
+ /**
854
+ * Run the reconcile body once on demand. Returned so the SIGUSR1
855
+ * handler can drain disk-only writes immediately without waiting
856
+ * for the next 5–30s tick — that's how `dench-fs-daemon flush`
857
+ * achieves its sub-second turnaround.
858
+ */
859
+ runOneTick: () => Promise<void>;
860
+ };
861
+
745
862
  function startReconcileLoop(args: {
746
863
  workspace: string;
747
864
  intervalMs: number;
@@ -749,68 +866,129 @@ function startReconcileLoop(args: {
749
866
  status: DaemonStatusWriter;
750
867
  onLocalChange: (absPath: string) => void;
751
868
  onLocalDelete: (absPath: string) => Promise<void> | void;
752
- }): () => void {
869
+ /**
870
+ * Called once per tick (after the status file is flushed) to mirror
871
+ * the daemon health into Convex `fileTreeHealth`. Best-effort —
872
+ * Convex blips never block the reconcile path, but persistent
873
+ * failures get logged.
874
+ */
875
+ reportHealth?: (snapshot: {
876
+ finishedAt: number;
877
+ observed: number;
878
+ activeBreakers: number;
879
+ pendingDrain: number;
880
+ }) => Promise<void> | void;
881
+ }): ReconcileLoopHandle {
753
882
  const last = new Map<string, FileSnapshot>();
754
883
 
755
884
  let stopped = false;
885
+ // Serialize concurrent ticks (SIGUSR1 + scheduled timer can race).
886
+ // The flush-driven tick can arrive milliseconds before the scheduled
887
+ // one; without serialization we'd double-walk the volume and the
888
+ // status file's `lastReconcile.finishedAt` would jitter
889
+ // backwards if writes interleave.
890
+ let inFlightTick: Promise<void> | null = null;
756
891
  const tick = async () => {
757
892
  if (stopped) return;
893
+ if (inFlightTick) {
894
+ await inFlightTick;
895
+ return;
896
+ }
758
897
  const start = Date.now();
759
898
  let observed = 0;
760
899
  let scheduled = 0;
761
900
  let deleted = 0;
762
- try {
763
- const snapshot = await snapshotWorkspace(args.workspace);
764
- observed = snapshot.size;
765
- for (const [absPath, info] of snapshot) {
766
- const prev = last.get(absPath);
767
- if (!prev || prev.size !== info.size || prev.mtimeMs !== info.mtimeMs) {
768
- args.onLocalChange(absPath);
769
- scheduled++;
901
+ inFlightTick = (async () => {
902
+ try {
903
+ const snapshot = await snapshotWorkspace(args.workspace);
904
+ observed = snapshot.size;
905
+ for (const [absPath, info] of snapshot) {
906
+ const prev = last.get(absPath);
907
+ if (
908
+ !prev ||
909
+ prev.size !== info.size ||
910
+ prev.mtimeMs !== info.mtimeMs
911
+ ) {
912
+ args.onLocalChange(absPath);
913
+ scheduled++;
914
+ }
770
915
  }
771
- }
772
- for (const absPath of last.keys()) {
773
- if (!snapshot.has(absPath)) {
774
- await args.onLocalDelete(absPath);
775
- deleted++;
916
+ for (const absPath of last.keys()) {
917
+ if (!snapshot.has(absPath)) {
918
+ await args.onLocalDelete(absPath);
919
+ deleted++;
920
+ }
921
+ }
922
+ last.clear();
923
+ for (const [absPath, info] of snapshot) {
924
+ last.set(absPath, info);
925
+ }
926
+ } catch (error) {
927
+ console.error(
928
+ `[dench-fs-daemon] reconcile scan failed: ${
929
+ error instanceof Error ? error.message : String(error)
930
+ }`,
931
+ );
932
+ } finally {
933
+ const finishedAt = Date.now();
934
+ args.status.recordReconcile({
935
+ finishedAt,
936
+ durationMs: finishedAt - start,
937
+ observed,
938
+ scheduledForSync: scheduled,
939
+ deleted,
940
+ breakers: args.tracker.activeBreakers(),
941
+ });
942
+ await args.status.flush();
943
+ if (args.reportHealth) {
944
+ // Best-effort heartbeat into Convex `fileTreeHealth`. The
945
+ // chat-turn end-of-turn check + sidebar pill key off this
946
+ // row to surface "sync degraded" without polling the
947
+ // sandbox FS directly.
948
+ const drain = args.tracker.drainStatus();
949
+ try {
950
+ await args.reportHealth({
951
+ finishedAt,
952
+ observed,
953
+ activeBreakers: args.tracker.activeBreakers().length,
954
+ pendingDrain: drain.pendingPaths + drain.inFlightUploads,
955
+ });
956
+ } catch (error) {
957
+ console.error(
958
+ `[dench-fs-daemon] reportHealth failed: ${
959
+ error instanceof Error ? error.message : String(error)
960
+ }`,
961
+ );
962
+ }
776
963
  }
777
964
  }
778
- last.clear();
779
- for (const [absPath, info] of snapshot) {
780
- last.set(absPath, info);
781
- }
782
- } catch (error) {
783
- console.error(
784
- `[dench-fs-daemon] reconcile scan failed: ${
785
- error instanceof Error ? error.message : String(error)
786
- }`,
787
- );
965
+ })();
966
+ try {
967
+ await inFlightTick;
788
968
  } finally {
789
- args.status.recordReconcile({
790
- finishedAt: Date.now(),
791
- durationMs: Date.now() - start,
792
- observed,
793
- scheduledForSync: scheduled,
794
- deleted,
795
- breakers: args.tracker.activeBreakers(),
796
- });
797
- await args.status.flush();
969
+ inFlightTick = null;
798
970
  }
799
971
  };
800
972
 
801
973
  // Stagger the first tick so we don't race chokidar's initial add
802
974
  // floods on daemon startup.
803
- const initial = setTimeout(() => {
804
- void tick();
805
- }, Math.min(args.intervalMs, 5_000));
975
+ const initial = setTimeout(
976
+ () => {
977
+ void tick();
978
+ },
979
+ Math.min(args.intervalMs, 5_000),
980
+ );
806
981
  const interval = setInterval(() => {
807
982
  void tick();
808
983
  }, args.intervalMs);
809
984
 
810
- return () => {
811
- stopped = true;
812
- clearTimeout(initial);
813
- clearInterval(interval);
985
+ return {
986
+ stop: () => {
987
+ stopped = true;
988
+ clearTimeout(initial);
989
+ clearInterval(interval);
990
+ },
991
+ runOneTick: tick,
814
992
  };
815
993
  }
816
994
 
@@ -836,6 +1014,20 @@ type DaemonStatus = {
836
1014
  scheduledForSync: number;
837
1015
  deleted: number;
838
1016
  } | null;
1017
+ /**
1018
+ * Wall-clock of the most recent SIGUSR1-driven flush request that
1019
+ * the daemon completed. Used by `dench-fs-daemon flush` to confirm
1020
+ * the daemon actually picked up the signal (not just that the
1021
+ * scheduled reconcile tick happened to land afterwards).
1022
+ */
1023
+ lastFlushAt: number | null;
1024
+ /**
1025
+ * Mirror of `HashTracker.drainStatus()` taken at the same moment as
1026
+ * `lastReconcile.finishedAt`. The flush poller waits for both
1027
+ * counts to be zero AND `lastReconcile.finishedAt > t0` before
1028
+ * declaring success.
1029
+ */
1030
+ pendingDrain: DrainStatus;
839
1031
  activeBreakers: BreakerState[];
840
1032
  recentBreakerEvents: BreakerState[];
841
1033
  };
@@ -843,6 +1035,9 @@ type DaemonStatus = {
843
1035
  class DaemonStatusWriter {
844
1036
  private state: DaemonStatus;
845
1037
  private writing: Promise<void> | null = null;
1038
+ // Pulled at flush() time so the on-disk drain count tracks the live
1039
+ // tracker without forcing every recordX() caller to plumb it through.
1040
+ private drainSnapshotFn: (() => DrainStatus) | null = null;
846
1041
 
847
1042
  constructor(args: {
848
1043
  workspace: string;
@@ -860,11 +1055,23 @@ class DaemonStatusWriter {
860
1055
  fsType: args.fsType,
861
1056
  isFuse: args.isFuse,
862
1057
  lastReconcile: null,
1058
+ lastFlushAt: null,
1059
+ pendingDrain: { pendingPaths: 0, inFlightUploads: 0 },
863
1060
  activeBreakers: [],
864
1061
  recentBreakerEvents: [],
865
1062
  };
866
1063
  }
867
1064
 
1065
+ /**
1066
+ * Wire up a callback the writer invokes inside `flush()` to grab
1067
+ * the latest drain numbers off the live HashTracker. Kept as a
1068
+ * snapshot fn (not a stored counter) so recordReconcile/breaker
1069
+ * events don't have to thread the tracker through.
1070
+ */
1071
+ setDrainSnapshot(fn: () => DrainStatus): void {
1072
+ this.drainSnapshotFn = fn;
1073
+ }
1074
+
868
1075
  recordReconcile(args: {
869
1076
  finishedAt: number;
870
1077
  durationMs: number;
@@ -883,6 +1090,16 @@ class DaemonStatusWriter {
883
1090
  this.state.activeBreakers = args.breakers;
884
1091
  }
885
1092
 
1093
+ /**
1094
+ * Mark a SIGUSR1 flush request as completed. Independent of
1095
+ * recordReconcile because the same tick may be triggered by either
1096
+ * the timer OR a flush signal — `lastFlushAt` proves the signal
1097
+ * specifically was honored.
1098
+ */
1099
+ recordFlushCompleted(at: number): void {
1100
+ this.state.lastFlushAt = at;
1101
+ }
1102
+
886
1103
  recordBreakerEvent(state: BreakerState): void {
887
1104
  const recent = [...this.state.recentBreakerEvents, state];
888
1105
  // Cap at last 10 events so the file stays small.
@@ -892,6 +1109,9 @@ class DaemonStatusWriter {
892
1109
 
893
1110
  async flush(): Promise<void> {
894
1111
  this.state.lastUpdatedAt = Date.now();
1112
+ if (this.drainSnapshotFn) {
1113
+ this.state.pendingDrain = this.drainSnapshotFn();
1114
+ }
895
1115
  const body = `${JSON.stringify(this.state, null, 2)}\n`;
896
1116
  // Serialize so concurrent flushes don't interleave writes.
897
1117
  const previous = this.writing ?? Promise.resolve();
@@ -904,6 +1124,15 @@ class DaemonStatusWriter {
904
1124
  );
905
1125
  await this.writing;
906
1126
  }
1127
+
1128
+ /**
1129
+ * Snapshot the current status state. Read-only — used by the
1130
+ * reconcile loop's per-tick health heartbeat to Convex without
1131
+ * forking a stale copy back into the writer.
1132
+ */
1133
+ snapshot(): Readonly<DaemonStatus> {
1134
+ return this.state;
1135
+ }
907
1136
  }
908
1137
 
909
1138
  // ── Live streaming subcommands ─────────────────────────────────────────────
@@ -992,6 +1221,79 @@ async function runStreamClose(args: string[]): Promise<void> {
992
1221
  await writeTokenStore(store);
993
1222
  }
994
1223
 
1224
+ // ── Flush subcommand ──────────────────────────────────────────────────────
1225
+ //
1226
+ // `dench-fs-daemon flush [--timeout-ms 5000]` blocks until the live
1227
+ // daemon has finished a reconcile tick AND drained every pending sync
1228
+ // timer + in-flight upload. Used by chat-turn after each `execute_bash`
1229
+ // that touched /workspace so the model sees Convex tree state that
1230
+ // matches what the bash command produced — no more 5s polling jitter
1231
+ // where files appear in the sidebar after the chat turn already
1232
+ // finished.
1233
+ //
1234
+ // Protocol:
1235
+ // 1. Read pid from /tmp/dench-fs-daemon.status.json.
1236
+ // 2. Send SIGUSR1 (the daemon's runDaemon registers a handler).
1237
+ // 3. Poll the status file every POLL_INTERVAL_MS until both
1238
+ // a) `lastReconcile.finishedAt > t0`, AND
1239
+ // b) `pendingDrain.pendingPaths === 0 && pendingDrain.inFlightUploads === 0`.
1240
+ // 4. Time out cleanly with exit 2 + the last-seen drain count so
1241
+ // callers (chat-turn) can decide whether to surface drift.
1242
+ //
1243
+ // Exit codes: 0 ok, 1 no daemon, 2 timeout, 3 signal failed.
1244
+ //
1245
+ // The polling logic itself lives in `cli/fs-daemon-flush.ts` so the
1246
+ // unit tests can exercise it without booting the daemon.
1247
+
1248
+ async function runFlush(argv: string[]): Promise<void> {
1249
+ const timeoutIndex = argv.indexOf("--timeout-ms");
1250
+ const parsedTimeout =
1251
+ timeoutIndex === -1
1252
+ ? FLUSH_DEFAULT_TIMEOUT_MS
1253
+ : Number.parseInt(
1254
+ argv[timeoutIndex + 1] ?? `${FLUSH_DEFAULT_TIMEOUT_MS}`,
1255
+ 10,
1256
+ );
1257
+ const timeoutMs = Number.isFinite(parsedTimeout)
1258
+ ? parsedTimeout
1259
+ : FLUSH_DEFAULT_TIMEOUT_MS;
1260
+ const json = argv.includes("--json");
1261
+
1262
+ const outcome = await runFlushPolling({
1263
+ timeoutMs,
1264
+ readStatus: async () => (await readStatusFile()).state,
1265
+ isPidAlive,
1266
+ signal: (pid) => {
1267
+ // process.kill with signal 'SIGUSR1' on Linux. On Darwin/local-dev
1268
+ // the same call works for any node process; Windows lacks SIGUSR1
1269
+ // but the daemon never runs there.
1270
+ process.kill(pid, "SIGUSR1");
1271
+ },
1272
+ });
1273
+
1274
+ if (json) {
1275
+ process.stdout.write(`${JSON.stringify(outcome)}\n`);
1276
+ } else if (outcome.ok) {
1277
+ process.stdout.write(
1278
+ `DENCH_FS_FLUSH=ok pid=${outcome.pid} duration=${outcome.durationMs}ms\n`,
1279
+ );
1280
+ } else if (outcome.reason === "timeout") {
1281
+ process.stderr.write(
1282
+ `DENCH_FS_FLUSH=timeout pid=${outcome.pid} duration=${outcome.durationMs}ms pending=${outcome.drain.pendingPaths} inflight=${outcome.drain.inFlightUploads}\n`,
1283
+ );
1284
+ } else if (outcome.reason === "signal_failed") {
1285
+ process.stderr.write(
1286
+ `DENCH_FS_FLUSH=signal_failed pid=${outcome.pid} error=${outcome.error}\n`,
1287
+ );
1288
+ } else {
1289
+ process.stderr.write(`DENCH_FS_FLUSH=no_daemon\n`);
1290
+ }
1291
+
1292
+ if (outcome.ok) return;
1293
+ process.exitCode =
1294
+ outcome.reason === "no_daemon" ? 1 : outcome.reason === "timeout" ? 2 : 3;
1295
+ }
1296
+
995
1297
  async function main(): Promise<void> {
996
1298
  const argv = process.argv.slice(2);
997
1299
  const subcommand = argv[0];
@@ -1012,6 +1314,10 @@ async function main(): Promise<void> {
1012
1314
  await runInitialSync(argv.slice(1));
1013
1315
  return;
1014
1316
  }
1317
+ if (subcommand === "flush") {
1318
+ await runFlush(argv.slice(1));
1319
+ return;
1320
+ }
1015
1321
  if (subcommand === "status") {
1016
1322
  await runStatus(argv.slice(1));
1017
1323
  return;
@@ -1071,7 +1377,7 @@ async function runStatus(argv: string[]): Promise<void> {
1071
1377
  const workspaceIndex = argv.indexOf("--workspace");
1072
1378
  const workspace =
1073
1379
  workspaceIndex === -1
1074
- ? (process.env.DENCH_VOLUME_PATH?.trim() || "/workspace")
1380
+ ? process.env.DENCH_VOLUME_PATH?.trim() || "/workspace"
1075
1381
  : (argv[workspaceIndex + 1] ?? "/workspace");
1076
1382
  const driftLimitIndex = argv.indexOf("--drift-limit");
1077
1383
  const driftLimit =
@@ -1080,7 +1386,11 @@ async function runStatus(argv: string[]): Promise<void> {
1080
1386
  : Math.max(0, Number.parseInt(argv[driftLimitIndex + 1] ?? "50", 10));
1081
1387
  const skipHash = argv.includes("--no-hash");
1082
1388
 
1083
- const report = await collectStatus({ workspace, driftLimit, hash: !skipHash });
1389
+ const report = await collectStatus({
1390
+ workspace,
1391
+ driftLimit,
1392
+ hash: !skipHash,
1393
+ });
1084
1394
  if (json) {
1085
1395
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1086
1396
  return;
@@ -1107,8 +1417,11 @@ async function collectStatus(args: {
1107
1417
  const pidAlive = statusFile.state ? isPidAlive(statusFile.state.pid) : false;
1108
1418
 
1109
1419
  let onDiskList: Array<{ path: string; size: number; mtimeMs: number }> = [];
1110
- let convexRows: Array<{ path: string; contentHash?: string; isDir: boolean }> =
1111
- [];
1420
+ let convexRows: Array<{
1421
+ path: string;
1422
+ contentHash?: string;
1423
+ isDir: boolean;
1424
+ }> = [];
1112
1425
  try {
1113
1426
  const snapshot = await snapshotWorkspace(args.workspace);
1114
1427
  for (const [absPath, info] of snapshot) {
@@ -1260,12 +1573,11 @@ async function readLogTail(lines: number): Promise<string[]> {
1260
1573
  }
1261
1574
  }
1262
1575
 
1263
- async function collectConvexTree(client: DenchFileClient): Promise<
1264
- Array<{ path: string; contentHash?: string; isDir: boolean }>
1265
- > {
1576
+ async function collectConvexTree(
1577
+ client: DenchFileClient,
1578
+ ): Promise<Array<{ path: string; contentHash?: string; isDir: boolean }>> {
1266
1579
  // listTree returns one directory's children; recurse into subdirs.
1267
- const out: Array<{ path: string; contentHash?: string; isDir: boolean }> =
1268
- [];
1580
+ const out: Array<{ path: string; contentHash?: string; isDir: boolean }> = [];
1269
1581
  const queue: string[] = ["/"];
1270
1582
  const seen = new Set<string>();
1271
1583
  while (queue.length > 0) {
@@ -1432,9 +1744,12 @@ async function runInitialSync(argv: string[]): Promise<void> {
1432
1744
  storageId,
1433
1745
  contentHash: hash,
1434
1746
  size: bytesBuf.byteLength,
1747
+ // Same binary-aware inlining as scheduleSync — a fresh-volume
1748
+ // initial sync that includes screenshots / PDFs / .zip would
1749
+ // otherwise plant U+FFFD-corrupted text rows for every blob.
1435
1750
  text:
1436
1751
  bytesBuf.byteLength <= MAX_INLINE_TEXT_BYTES
1437
- ? new TextDecoder("utf-8", { fatal: false }).decode(bytesBuf)
1752
+ ? decodeInlineTextOrUndefined(bytesBuf)
1438
1753
  : undefined,
1439
1754
  lastModifiedBy: "daemon",
1440
1755
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "cron.ts",
22
22
  "search.ts",
23
23
  "image.ts",
24
+ "tools.ts",
24
25
  "agentKind.ts",
25
26
  "host.ts",
26
27
  "openUrl.ts",