@dench.com/cli 0.3.2 → 0.3.4

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/dench.ts CHANGED
@@ -179,6 +179,21 @@ const api = {
179
179
  "functions/agentToolsNode:agentToolStatus",
180
180
  ),
181
181
  },
182
+ files: {
183
+ listTree: makeFunctionReference<"query">("functions/files:listTree"),
184
+ deleteFile: makeFunctionReference<"mutation">(
185
+ "functions/files:deleteFile",
186
+ ),
187
+ generateUploadUrl: makeFunctionReference<"mutation">(
188
+ "functions/files:generateUploadUrl",
189
+ ),
190
+ commitFileUpload: makeFunctionReference<"mutation">(
191
+ "functions/files:commitFileUpload",
192
+ ),
193
+ getFileSignedDownloadUrl: makeFunctionReference<"action">(
194
+ "functions/files:getFileSignedDownloadUrl",
195
+ ),
196
+ },
182
197
  },
183
198
  };
184
199
  const CONFIG_DIR = join(homedir(), ".dench");
@@ -233,6 +248,7 @@ const BOOLEAN_FLAGS = new Set([
233
248
  "--all",
234
249
  "--compact",
235
250
  "--dev",
251
+ "--follow",
236
252
  "--help",
237
253
  "--mine",
238
254
  "--self",
@@ -240,6 +256,7 @@ const BOOLEAN_FLAGS = new Set([
240
256
  "--prod",
241
257
  "--no-open",
242
258
  "--version",
259
+ "--yolo",
243
260
  "--ask-before-publishing",
244
261
  "--ask-before-spending",
245
262
  "--ask-before-external-messages",
@@ -391,7 +408,7 @@ Usage:
391
408
  dench tool connect <toolkit> [--json]
392
409
  dench tool search "create github issue" [--toolkit github] [--limit 20] [--compact] [--json]
393
410
  dench tool run <composio_tool_slug> --args '{"key":"value"}' [--account <connected_account_id>] [--approval <approvalId>] [--json]
394
- dench autonomous run "Inspect this repo and summarize the relevant feature. Do not edit files, publish, deploy, spend money, send external messages, access secrets, or change production data." [--duration 1h] [--model <model>] [--api-base http://localhost:3000] [--json]
411
+ dench autonomous run "Inspect this repo and summarize the relevant feature. Do not edit files, publish, deploy, spend money, send external messages, access secrets, or change production data." [--duration 1h] [--model <model>] [--api-base http://localhost:3000] [--yolo] [--json]
395
412
 
396
413
  CRM (Daytona-native rewrite):
397
414
  dench crm objects <list|get|create|update|rename|delete> ...
@@ -413,12 +430,32 @@ CRM (Daytona-native rewrite):
413
430
  dench crm batch --file ops.jsonl
414
431
  Help: dench crm help
415
432
 
433
+ Past chat threads (own + shared in this workspace):
434
+ dench chat list [--query <substr>] [--limit N] [--include-archived] [--json]
435
+ dench chat search "<query>" [--limit N] [--include-current] [--json]
436
+ dench chat read <thread-id> [--limit N] [--json]
437
+
438
+ Spawn a new chat-turn workflow (same as the web UI):
439
+ dench chat new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
440
+ dench chat send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
441
+ dench agent new ... (alias for dench chat new)
442
+ dench agent send ... (alias for dench chat send)
443
+
444
+ Help: dench chat help
445
+
416
446
  File sync (Daytona-native rewrite):
447
+ dench fs status [--json] [--workspace /workspace] [--no-hash] [--drift-limit N]
417
448
  dench fs sync --initial --org-id <orgId> [--workspace /workspace]
418
449
  dench fs stream-open <path>
419
450
  dench fs stream-append <token> <chunk>
420
451
  dench fs stream-close <token>
421
452
 
453
+ Workspace files (use these instead of mv/rm in /workspace; rename(2)
454
+ returns ENOSYS on the FUSE-backed volume mount):
455
+ dench files ls <path> [--json]
456
+ dench files mv <src> <dst> [--json]
457
+ dench files rm <path> [--recursive] [--json]
458
+
422
459
  Subagents (Daytona-native rewrite):
423
460
  dench agent spawn '<goal>' [--parent-run-id <id>] [--sandbox own|share_parent] [--time-budget-ms N]
424
461
  dench agent await --hook <token> | --children id1,id2,id3
@@ -491,7 +528,7 @@ function autonomousHelp() {
491
528
  console.log(`Dench Long Session commands
492
529
 
493
530
  Usage:
494
- dench autonomous run "Inspect this repo and summarize the relevant feature. Do not edit files, publish, deploy, spend money, send external messages, access secrets, or change production data." [--duration 1h] [--model <model>] [--api-base http://localhost:3000] [--json]
531
+ dench autonomous run "Inspect this repo and summarize the relevant feature. Do not edit files, publish, deploy, spend money, send external messages, access secrets, or change production data." [--duration 1h] [--model <model>] [--api-base http://localhost:3000] [--yolo] [--json]
495
532
 
496
533
  Notes:
497
534
  Starts a Long Session through /api/runs.
@@ -501,6 +538,8 @@ Notes:
501
538
  If needed, run dench login --host <api-base> once so the CLI can authenticate.
502
539
  Omit --model to use the server default.
503
540
  Approval flags: --ask-before-publishing --ask-before-spending --ask-before-external-messages --ask-before-production-changes --ask-before-secrets.
541
+ --yolo: skip every approval gate (overrides --ask-before-* / --approval-rules). Equivalent to the workspace's YOLO mode in /<slug>/settings -> Approvals.
542
+ Without any approval flags, the run inherits the workspace's Approvals policy from /<slug>/settings.
504
543
  Safe goals should explicitly say: ${SAFE_LONG_SESSION_CONSTRAINTS.join(", ")}.
505
544
  `);
506
545
  }
@@ -1417,6 +1456,9 @@ function durationPreset(rawDuration?: string) {
1417
1456
  }
1418
1457
 
1419
1458
  function parseApprovalRules() {
1459
+ // --yolo wins over everything else: every approval gate is off,
1460
+ // mirroring the org-level YOLO mode (settings -> Approvals).
1461
+ if (hasFlag("--yolo")) return [];
1420
1462
  const explicit = option("--approval-rules")
1421
1463
  ?.split(",")
1422
1464
  .map((rule) => rule.trim())
@@ -2663,6 +2705,354 @@ async function runFsCommand() {
2663
2705
  }
2664
2706
  }
2665
2707
 
2708
+ // ── dench files <ls|mv|rm> ────────────────────────────────────────────────
2709
+ //
2710
+ // Escape hatches the model needs every time something on /workspace
2711
+ // can't be done with `mv` / `rm` — Mountpoint-S3 doesn't implement
2712
+ // rename(2) and the file daemon needs a hint to prune Convex rows.
2713
+ // All three commands take + emit POSIX paths under /workspace.
2714
+
2715
+ function normalizeFilesPath(input: string): string {
2716
+ if (!input || input === "/") return "/";
2717
+ let cleaned = input.replace(/\\/g, "/").replace(/\/+/g, "/");
2718
+ if (cleaned.startsWith("/workspace/")) cleaned = cleaned.slice("/workspace".length);
2719
+ else if (cleaned === "/workspace") cleaned = "/";
2720
+ if (!cleaned.startsWith("/")) cleaned = `/${cleaned}`;
2721
+ if (cleaned.length > 1 && cleaned.endsWith("/")) cleaned = cleaned.slice(0, -1);
2722
+ return cleaned;
2723
+ }
2724
+
2725
+ function filesApiKey(runtime: Runtime): string | undefined {
2726
+ if (runtime.mode === "apiKey") return runtime.sessionToken;
2727
+ return process.env.DENCH_API_KEY?.trim() || undefined;
2728
+ }
2729
+
2730
+ function requireFilesApiKey(runtime: Runtime, command: string): string {
2731
+ const apiKey = filesApiKey(runtime);
2732
+ if (!apiKey) {
2733
+ throw new CliError(
2734
+ `${command} needs DENCH_API_KEY (or a sandbox-baked api-key session). It bypasses the user-session auth path.`,
2735
+ {
2736
+ code: "files_api_key_required",
2737
+ nextActions: [
2738
+ "Run inside a Dench sandbox where DENCH_API_KEY is baked in.",
2739
+ "Or export DENCH_API_KEY=<key> first (see dench login output).",
2740
+ ],
2741
+ },
2742
+ );
2743
+ }
2744
+ return apiKey;
2745
+ }
2746
+
2747
+ type FileTreeRow = {
2748
+ path: string;
2749
+ contentHash?: string;
2750
+ isDir: boolean;
2751
+ size?: number;
2752
+ mtime?: number;
2753
+ };
2754
+
2755
+ async function listConvexTreeAt(
2756
+ runtime: Runtime,
2757
+ apiKey: string,
2758
+ prefix: string,
2759
+ ): Promise<FileTreeRow[]> {
2760
+ return (await runtime.client.query(api.functions.files.listTree, {
2761
+ prefix,
2762
+ apiKey,
2763
+ } as never)) as FileTreeRow[];
2764
+ }
2765
+
2766
+ async function listConvexTreeRecursive(
2767
+ runtime: Runtime,
2768
+ apiKey: string,
2769
+ prefix: string,
2770
+ ): Promise<FileTreeRow[]> {
2771
+ const rows = await listConvexTreeAt(runtime, apiKey, prefix);
2772
+ const out: FileTreeRow[] = [...rows];
2773
+ for (const row of rows) {
2774
+ if (row.isDir) {
2775
+ const children = await listConvexTreeRecursive(runtime, apiKey, row.path);
2776
+ out.push(...children);
2777
+ }
2778
+ }
2779
+ return out;
2780
+ }
2781
+
2782
+ async function downloadConvexFileBytes(
2783
+ runtime: Runtime,
2784
+ apiKey: string,
2785
+ path: string,
2786
+ ): Promise<Uint8Array | null> {
2787
+ const url = (await runtime.client.action(
2788
+ api.functions.files.getFileSignedDownloadUrl,
2789
+ { path, apiKey } as never,
2790
+ )) as string | null;
2791
+ if (!url) return null;
2792
+ const response = await fetch(url);
2793
+ if (!response.ok) {
2794
+ throw new Error(
2795
+ `signed download failed (${response.status}): ${await response
2796
+ .text()
2797
+ .catch(() => "")}`,
2798
+ );
2799
+ }
2800
+ return new Uint8Array(await response.arrayBuffer());
2801
+ }
2802
+
2803
+ async function uploadConvexFileBytes(
2804
+ runtime: Runtime,
2805
+ apiKey: string,
2806
+ args: { path: string; bytes: Uint8Array },
2807
+ ): Promise<{ path: string; storageId: string; contentHash: string }> {
2808
+ const uploadUrl = (await runtime.client.mutation(
2809
+ api.functions.files.generateUploadUrl,
2810
+ { path: args.path, apiKey } as never,
2811
+ )) as string;
2812
+ const response = await fetch(uploadUrl, {
2813
+ method: "POST",
2814
+ headers: { "content-type": "application/octet-stream" },
2815
+ body: new Blob([args.bytes as BlobPart]),
2816
+ });
2817
+ if (!response.ok) {
2818
+ throw new Error(
2819
+ `convex storage upload failed (${response.status}): ${await response
2820
+ .text()
2821
+ .catch(() => "")}`,
2822
+ );
2823
+ }
2824
+ const { storageId } = (await response.json()) as { storageId: string };
2825
+ const hash = sha256HexSync(args.bytes);
2826
+ const inlineEligible = args.bytes.byteLength <= 100_000;
2827
+ const inlineText = inlineEligible
2828
+ ? new TextDecoder("utf-8", { fatal: false }).decode(args.bytes)
2829
+ : undefined;
2830
+ await runtime.client.mutation(api.functions.files.commitFileUpload, {
2831
+ path: args.path,
2832
+ storageId,
2833
+ contentHash: hash,
2834
+ size: args.bytes.byteLength,
2835
+ text: inlineText,
2836
+ lastModifiedBy: "cli",
2837
+ apiKey,
2838
+ } as never);
2839
+ return { path: args.path, storageId, contentHash: hash };
2840
+ }
2841
+
2842
+ async function runFilesLs() {
2843
+ const runtime = await getRuntime();
2844
+ const apiKey = requireFilesApiKey(runtime, "dench files ls");
2845
+ const target = normalizeFilesPath(positional(2) ?? "/");
2846
+ const recursive = hasFlag("--recursive") || hasFlag("-r");
2847
+ const rows = recursive
2848
+ ? await listConvexTreeRecursive(runtime, apiKey, target)
2849
+ : await listConvexTreeAt(runtime, apiKey, target);
2850
+ if (hasFlag("--json")) {
2851
+ print({
2852
+ ok: true,
2853
+ prefix: target,
2854
+ recursive,
2855
+ count: rows.length,
2856
+ entries: rows.map((row) => ({
2857
+ path: row.path,
2858
+ isDir: row.isDir,
2859
+ size: row.size,
2860
+ mtime: row.mtime,
2861
+ contentHash: row.contentHash,
2862
+ })),
2863
+ });
2864
+ return;
2865
+ }
2866
+ if (rows.length === 0) {
2867
+ process.stdout.write(`(no entries under ${target})\n`);
2868
+ return;
2869
+ }
2870
+ for (const row of rows) {
2871
+ const kind = row.isDir ? "DIR " : "FILE";
2872
+ const size = row.isDir
2873
+ ? " -"
2874
+ : (row.size ?? 0).toString().padStart(8, " ");
2875
+ process.stdout.write(`${kind} ${size} ${row.path}\n`);
2876
+ }
2877
+ }
2878
+
2879
+ async function runFilesMv() {
2880
+ const runtime = await getRuntime();
2881
+ const apiKey = requireFilesApiKey(runtime, "dench files mv");
2882
+ const fromRaw = positional(2);
2883
+ const toRaw = positional(3);
2884
+ if (!fromRaw || !toRaw) {
2885
+ throw new CliError("Usage: dench files mv <src> <dst>", {
2886
+ code: "files_mv_usage",
2887
+ nextActions: ["Pass both source and destination paths."],
2888
+ });
2889
+ }
2890
+ const from = normalizeFilesPath(fromRaw);
2891
+ const to = normalizeFilesPath(toRaw);
2892
+ if (from === to) {
2893
+ throw new CliError("Source and destination are the same path", {
2894
+ code: "files_mv_noop",
2895
+ });
2896
+ }
2897
+
2898
+ const sourceRows = await listConvexTreeRecursive(runtime, apiKey, from);
2899
+ const sourceSelf = (await listConvexTreeAt(runtime, apiKey, parentOfPath(from))).find(
2900
+ (row) => row.path === from,
2901
+ );
2902
+ if (!sourceSelf && sourceRows.length === 0) {
2903
+ throw new CliError(`Source not found in Convex: ${from}`, {
2904
+ code: "files_mv_not_found",
2905
+ nextActions: [
2906
+ "Run `dench files ls` on the parent directory to confirm.",
2907
+ "If the file only exists on the sandbox FS, run the daemon (or `dench fs sync --initial`) first.",
2908
+ ],
2909
+ });
2910
+ }
2911
+
2912
+ const moved: Array<{ from: string; to: string }> = [];
2913
+ if (sourceSelf && !sourceSelf.isDir) {
2914
+ const bytes = await downloadConvexFileBytes(runtime, apiKey, from);
2915
+ if (!bytes) {
2916
+ throw new CliError(`Could not download bytes for ${from}`, {
2917
+ code: "files_mv_download",
2918
+ });
2919
+ }
2920
+ await uploadConvexFileBytes(runtime, apiKey, { path: to, bytes });
2921
+ await runtime.client.mutation(api.functions.files.deleteFile, {
2922
+ path: from,
2923
+ apiKey,
2924
+ } as never);
2925
+ moved.push({ from, to });
2926
+ } else {
2927
+ for (const row of sourceRows) {
2928
+ if (row.isDir) continue;
2929
+ const rel = row.path.slice(from.length).replace(/^\/+/, "");
2930
+ const dest = rel ? `${to}/${rel}` : to;
2931
+ const bytes = await downloadConvexFileBytes(runtime, apiKey, row.path);
2932
+ if (!bytes) continue;
2933
+ await uploadConvexFileBytes(runtime, apiKey, { path: dest, bytes });
2934
+ await runtime.client.mutation(api.functions.files.deleteFile, {
2935
+ path: row.path,
2936
+ apiKey,
2937
+ } as never);
2938
+ moved.push({ from: row.path, to: dest });
2939
+ }
2940
+ if (sourceSelf?.isDir) {
2941
+ await runtime.client.mutation(api.functions.files.deleteFile, {
2942
+ path: from,
2943
+ apiKey,
2944
+ } as never);
2945
+ }
2946
+ }
2947
+
2948
+ print({ ok: true, from, to, moved });
2949
+ }
2950
+
2951
+ async function runFilesRm() {
2952
+ const runtime = await getRuntime();
2953
+ const apiKey = requireFilesApiKey(runtime, "dench files rm");
2954
+ const targetRaw = positional(2);
2955
+ if (!targetRaw) {
2956
+ throw new CliError("Usage: dench files rm <path> [--recursive]", {
2957
+ code: "files_rm_usage",
2958
+ });
2959
+ }
2960
+ const target = normalizeFilesPath(targetRaw);
2961
+ const recursive = hasFlag("--recursive") || hasFlag("-r");
2962
+
2963
+ const parentRows = await listConvexTreeAt(runtime, apiKey, parentOfPath(target));
2964
+ const self = parentRows.find((row) => row.path === target);
2965
+ if (!self) {
2966
+ if (hasFlag("--json")) {
2967
+ print({ ok: true, path: target, removedFiles: 0, removedDirs: 0 });
2968
+ } else {
2969
+ process.stdout.write(
2970
+ `dench files rm: ${target} not present in Convex (already absent)\n`,
2971
+ );
2972
+ }
2973
+ return;
2974
+ }
2975
+ if (self.isDir && !recursive) {
2976
+ throw new CliError(
2977
+ `${target} is a directory; pass --recursive to delete it.`,
2978
+ { code: "files_rm_recursive" },
2979
+ );
2980
+ }
2981
+
2982
+ const toDelete = self.isDir
2983
+ ? await listConvexTreeRecursive(runtime, apiKey, target)
2984
+ : [];
2985
+ const fileRows = toDelete.filter((row) => !row.isDir);
2986
+ const dirRows = toDelete.filter((row) => row.isDir);
2987
+ // Children first, parents last.
2988
+ dirRows.sort((a, b) => b.path.length - a.path.length);
2989
+
2990
+ for (const row of fileRows) {
2991
+ await runtime.client.mutation(api.functions.files.deleteFile, {
2992
+ path: row.path,
2993
+ apiKey,
2994
+ } as never);
2995
+ }
2996
+ for (const row of dirRows) {
2997
+ await runtime.client.mutation(api.functions.files.deleteFile, {
2998
+ path: row.path,
2999
+ apiKey,
3000
+ } as never);
3001
+ }
3002
+ await runtime.client.mutation(api.functions.files.deleteFile, {
3003
+ path: target,
3004
+ apiKey,
3005
+ } as never);
3006
+
3007
+ print({
3008
+ ok: true,
3009
+ path: target,
3010
+ removedFiles: fileRows.length + (self.isDir ? 0 : 1),
3011
+ removedDirs: dirRows.length + (self.isDir ? 1 : 0),
3012
+ });
3013
+ }
3014
+
3015
+ async function runFilesCommand() {
3016
+ const sub = positional(1);
3017
+ if (!sub || sub === "help" || hasFlag("--help")) {
3018
+ process.stdout.write(
3019
+ [
3020
+ "dench files <ls|mv|rm> — workspace file ops that update the UI tree",
3021
+ "",
3022
+ " dench files ls [<path>] [--recursive] [--json]",
3023
+ " dench files mv <src> <dst> [--json]",
3024
+ " dench files rm <path> [--recursive] [--json]",
3025
+ "",
3026
+ "Paths are POSIX paths under /workspace; you can pass either form.",
3027
+ "These mutate Convex directly (and the daemon syncs to the volume).",
3028
+ ].join("\n"),
3029
+ );
3030
+ return;
3031
+ }
3032
+ if (sub === "ls") return await runFilesLs();
3033
+ if (sub === "mv") return await runFilesMv();
3034
+ if (sub === "rm") return await runFilesRm();
3035
+ throw new CliError(`Unknown subcommand: dench files ${sub}`, {
3036
+ code: "files_unknown_subcommand",
3037
+ nextActions: ["Run dench files help for usage."],
3038
+ });
3039
+ }
3040
+
3041
+ function parentOfPath(input: string): string {
3042
+ const normalized = normalizeFilesPath(input);
3043
+ if (normalized === "/") return "/";
3044
+ const idx = normalized.lastIndexOf("/");
3045
+ return idx <= 0 ? "/" : normalized.slice(0, idx);
3046
+ }
3047
+
3048
+ function sha256HexSync(bytes: Uint8Array): string {
3049
+ // Used by the dench files commands so we can compute the hash
3050
+ // alongside the upload synchronously. Bun + Node both ship
3051
+ // node:crypto, so this is free.
3052
+ const { createHash } = require("node:crypto") as typeof import("node:crypto");
3053
+ return createHash("sha256").update(bytes).digest("hex");
3054
+ }
3055
+
2666
3056
  async function spawnInherited(binary: string, commandArgs: string[]) {
2667
3057
  return await new Promise<number>((resolve, reject) => {
2668
3058
  const child = spawn(binary, commandArgs, {
@@ -2815,6 +3205,60 @@ async function main() {
2815
3205
  return;
2816
3206
  }
2817
3207
 
3208
+ if (command === "files") {
3209
+ await runFilesCommand();
3210
+ return;
3211
+ }
3212
+
3213
+ if (command === "chat") {
3214
+ const subArgs = args.slice(args.indexOf("chat") + 1);
3215
+ const sub = subArgs[0];
3216
+ if (!sub || sub === "help" || sub === "--help") {
3217
+ const { runChatCommand } = await import("./chat");
3218
+ // biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub
3219
+ await runChatCommand({ convex: {} as any, args: subArgs });
3220
+ return;
3221
+ }
3222
+ // `chat new` / `chat send` spawn a chat-turn workflow via
3223
+ // /api/chat/cli (the same workflow the web UI uses). They need
3224
+ // an HTTP host + bearer token, not a Convex client. The
3225
+ // remaining subcommands (list / search / read) are Convex
3226
+ // queries and keep going through `runChatCommand`.
3227
+ if (sub === "new" || sub === "send") {
3228
+ const { runChatSpawnCommand } = await import("./chat-spawn");
3229
+ const runtime = await getRuntime();
3230
+ const authedRuntime = requireAuthenticatedRuntime(
3231
+ runtime,
3232
+ `dench chat ${sub}`,
3233
+ );
3234
+ // Drop the leading subcommand from subArgs — runChatSpawnCommand
3235
+ // expects the remaining tokens (prompt + flags).
3236
+ await runChatSpawnCommand({
3237
+ runtime: {
3238
+ host: authedRuntime.host,
3239
+ bearerToken: authedRuntime.sessionToken,
3240
+ },
3241
+ args: subArgs.slice(1),
3242
+ subcommand: sub,
3243
+ });
3244
+ return;
3245
+ }
3246
+ const { runChatCommand } = await import("./chat");
3247
+ const runtime = await getRuntime();
3248
+ // Past-chat queries are org+user-scoped: the server resolves the
3249
+ // user from the session token, OR from `runId` when authenticating
3250
+ // with a Dench API key. Pass DENCH_RUN_ID through automatically so
3251
+ // `dench chat …` works inside a sandbox without extra flags.
3252
+ const authedRuntime = requireAuthenticatedRuntime(runtime, "dench chat");
3253
+ await runChatCommand({
3254
+ convex: authedRuntime.client,
3255
+ args: subArgs,
3256
+ sessionToken: authedRuntime.sessionToken,
3257
+ runId: process.env.DENCH_RUN_ID?.trim() || undefined,
3258
+ });
3259
+ return;
3260
+ }
3261
+
2818
3262
  if (command === "agent") {
2819
3263
  const subArgs = args.slice(args.indexOf("agent") + 1);
2820
3264
  const sub = subArgs[0];
@@ -2824,6 +3268,27 @@ async function main() {
2824
3268
  await runAgentCommand({ convex: {} as any, args: subArgs });
2825
3269
  return;
2826
3270
  }
3271
+ // Aliases: `dench agent new` / `dench agent send` -> the chat-spawn
3272
+ // implementation. Surfaced under `agent` so users who associate
3273
+ // chat threads with the agent surface still find them; the actual
3274
+ // workflow is identical to `dench chat new` / `dench chat send`.
3275
+ if (sub === "new" || sub === "send") {
3276
+ const { runChatSpawnCommand } = await import("./chat-spawn");
3277
+ const runtime = await getRuntime();
3278
+ const authedRuntime = requireAuthenticatedRuntime(
3279
+ runtime,
3280
+ `dench agent ${sub}`,
3281
+ );
3282
+ await runChatSpawnCommand({
3283
+ runtime: {
3284
+ host: authedRuntime.host,
3285
+ bearerToken: authedRuntime.sessionToken,
3286
+ },
3287
+ args: subArgs.slice(1),
3288
+ subcommand: sub,
3289
+ });
3290
+ return;
3291
+ }
2827
3292
  const { runAgentCommand } = await import("./agent");
2828
3293
  const runtime = await getRuntime();
2829
3294
  await runAgentCommand({
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Mount-point detection helpers for `dench-fs-daemon`.
3
+ *
4
+ * `/workspace` in every Dench sandbox is a Daytona persistent volume
5
+ * mounted via AWS Mountpoint for S3 (a FUSE driver). Two upstream
6
+ * limitations matter for the file daemon:
7
+ *
8
+ * • inotify events never fire on the mount
9
+ * (https://github.com/awslabs/mountpoint-s3/issues/1290), so
10
+ * chokidar's default backend is useless. The daemon switches
11
+ * to polling mode when these helpers report a FUSE backing.
12
+ *
13
+ * • rename(2) returns ENOSYS on general-purpose buckets, so
14
+ * callers that need to detect "is this path on the volume?"
15
+ * can use `detectWorkspaceFsType` to choose the safe
16
+ * copy+unlink path instead of trusting `mv`.
17
+ *
18
+ * This file lives alongside fs-daemon.ts but contains no Convex /
19
+ * chokidar imports so unit tests can pull it directly.
20
+ */
21
+
22
+ import { readFile } from "node:fs/promises";
23
+
24
+ export type MountInfo = {
25
+ mountPoint: string;
26
+ fsType: string;
27
+ source: string;
28
+ };
29
+
30
+ /**
31
+ * Read /proc/self/mountinfo and return the parsed mount table.
32
+ * Returns an empty array on non-Linux hosts (e.g. local macOS dev,
33
+ * where /proc doesn't exist) so callers can fall through cleanly.
34
+ *
35
+ * The proc(5) line format is:
36
+ *
37
+ * 36 35 98:0 /mnt1 /mnt/parent rw,noatime master:1 - ext3 /dev/root rw
38
+ * ─┬ ─┬ ──┬─ ──┬── ──┬─────── ──┬───── ──┬────── ─ ──┬─ ────┬──── ──┬
39
+ * 1 2 3 4 5 6 7 8 9 10
40
+ *
41
+ * We only need fields 5 (mount point) and the fs type after the
42
+ * " - " separator (field 9, with optional source as field 10).
43
+ */
44
+ export async function readMountInfo(
45
+ procPath = "/proc/self/mountinfo",
46
+ ): Promise<MountInfo[]> {
47
+ let raw: string;
48
+ try {
49
+ raw = await readFile(procPath, "utf-8");
50
+ } catch {
51
+ return [];
52
+ }
53
+ const out: MountInfo[] = [];
54
+ for (const rawLine of raw.split("\n")) {
55
+ const line = rawLine.trim();
56
+ if (!line) continue;
57
+ const dashIdx = line.indexOf(" - ");
58
+ if (dashIdx === -1) continue;
59
+ const before = line.slice(0, dashIdx).split(/\s+/);
60
+ const after = line.slice(dashIdx + 3).split(/\s+/);
61
+ const mountPoint = before[4];
62
+ const fsType = after[0];
63
+ const source = after[1] ?? "";
64
+ if (!mountPoint || !fsType) continue;
65
+ out.push({ mountPoint, fsType, source });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Find the longest mount point that is a prefix of `absPath`. Used to
72
+ * answer "what filesystem is this path on?" from the parsed mount
73
+ * table. Important to use longest-prefix so a `/workspace` mount
74
+ * over `/` doesn't get shadowed by the root mount.
75
+ *
76
+ * Naïve `startsWith` would also match `/workspace` against
77
+ * `/worker`; we guard against that by requiring the next char to be
78
+ * `/` (or the strings to be equal).
79
+ */
80
+ export function findMountForPath(
81
+ mounts: MountInfo[],
82
+ absPath: string,
83
+ ): MountInfo | null {
84
+ let best: MountInfo | null = null;
85
+ for (const mount of mounts) {
86
+ if (mount.mountPoint === absPath) return mount;
87
+ const trimmed = mount.mountPoint.replace(/\/+$/, "");
88
+ if (
89
+ absPath === trimmed ||
90
+ absPath.startsWith(`${trimmed === "" ? "" : trimmed}/`)
91
+ ) {
92
+ if (!best || mount.mountPoint.length > best.mountPoint.length) {
93
+ best = mount;
94
+ }
95
+ }
96
+ }
97
+ return best;
98
+ }
99
+
100
+ /**
101
+ * Returns true for any FUSE-flavored fs type. mount-s3 reports
102
+ * `fuse` (no subtype on Linux); other FUSE drivers commonly use
103
+ * `fuse.<name>` (e.g. fuse.sshfs). `fuseblk` is the block-device
104
+ * flavor used by ntfs-3g and friends.
105
+ */
106
+ export function isFuseFsType(fsType: string): boolean {
107
+ if (!fsType) return false;
108
+ return (
109
+ fsType === "fuse" || fsType.startsWith("fuse.") || fsType === "fuseblk"
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Convenience: detect the filesystem backing a given workspace path
115
+ * in one call. The daemon uses this on startup to decide whether to
116
+ * enable chokidar's polling mode.
117
+ */
118
+ export async function detectWorkspaceFsType(workspace: string): Promise<{
119
+ fsType: string;
120
+ isFuse: boolean;
121
+ source: string;
122
+ }> {
123
+ const mounts = await readMountInfo();
124
+ const match = findMountForPath(mounts, workspace);
125
+ if (!match) {
126
+ return { fsType: "unknown", isFuse: false, source: "" };
127
+ }
128
+ return {
129
+ fsType: match.fsType,
130
+ isFuse: isFuseFsType(match.fsType),
131
+ source: match.source,
132
+ };
133
+ }