@dench.com/cli 0.3.3 → 0.3.5

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/chat-spawn.ts ADDED
@@ -0,0 +1,370 @@
1
+ /**
2
+ * `dench chat new` / `dench chat send` — spawn a chat-turn workflow
3
+ * from the CLI, exactly the same workflow the web UI uses. Threads
4
+ * created here are visible in `dench chat list` and the UI's chat
5
+ * thread list immediately, since they go through the same Convex
6
+ * tables (`chatThreads` + `chatMessages`).
7
+ *
8
+ * Aliased as `dench agent new` / `dench agent send` so users who
9
+ * grep for "agent" find them.
10
+ *
11
+ * Both subcommands POST to `/api/chat/cli` (auth: same `dch_agent_*`
12
+ * session token or Dench API key as the rest of the CLI). The
13
+ * server creates the thread + queued run, kicks off
14
+ * `chatTurnWorkflow`, and returns ids + URLs. With `--follow` the
15
+ * CLI then opens the durable resumable stream
16
+ * (`/api/chat/[runId]/stream`) and pretty-prints text deltas + tool
17
+ * calls until the workflow finishes.
18
+ */
19
+
20
+ import { hasFlag } from "./lib/cli-args";
21
+
22
+ class ChatSpawnError extends Error {}
23
+
24
+ type RuntimeBundle = {
25
+ host: string;
26
+ bearerToken: string;
27
+ };
28
+
29
+ type ChatSpawnContext = {
30
+ runtime: RuntimeBundle;
31
+ args: string[];
32
+ jsonOutput: boolean;
33
+ };
34
+
35
+ function consumeFlagValue(args: string[], name: string): string | undefined {
36
+ const idx = args.indexOf(name);
37
+ if (idx === -1) return undefined;
38
+ const value = args[idx + 1];
39
+ args.splice(idx, 2);
40
+ return value;
41
+ }
42
+
43
+ function consumeFlag(args: string[], name: string): boolean {
44
+ const idx = args.indexOf(name);
45
+ if (idx === -1) return false;
46
+ args.splice(idx, 1);
47
+ return true;
48
+ }
49
+
50
+ function out(ctx: ChatSpawnContext, value: unknown): void {
51
+ if (ctx.jsonOutput) {
52
+ console.log(JSON.stringify(value, null, 2));
53
+ return;
54
+ }
55
+ if (value === null || value === undefined) {
56
+ console.log("(empty)");
57
+ return;
58
+ }
59
+ console.log(JSON.stringify(value, null, 2));
60
+ }
61
+
62
+ async function postJson(
63
+ url: string,
64
+ bearer: string,
65
+ body: unknown,
66
+ ): Promise<{ ok: boolean; status: number; payload: unknown }> {
67
+ const response = await fetch(url, {
68
+ method: "POST",
69
+ headers: {
70
+ accept: "application/json",
71
+ "content-type": "application/json",
72
+ authorization: `Bearer ${bearer}`,
73
+ },
74
+ body: JSON.stringify(body),
75
+ });
76
+ const text = await response.text();
77
+ let payload: unknown;
78
+ try {
79
+ payload = text ? JSON.parse(text) : null;
80
+ } catch {
81
+ payload = { raw: text };
82
+ }
83
+ return { ok: response.ok, status: response.status, payload };
84
+ }
85
+
86
+ function buildSpawnUrl(host: string): string {
87
+ return `${host.replace(/\/+$/, "")}/api/chat/cli`;
88
+ }
89
+
90
+ function buildStreamUrl(host: string, runId: string): string {
91
+ return `${host.replace(/\/+$/, "")}/api/chat/${encodeURIComponent(runId)}/stream`;
92
+ }
93
+
94
+ /**
95
+ * Subscribe to the durable chat stream and pretty-print useful chunks
96
+ * until the stream closes. Mirrors the chunk types the chat panel
97
+ * cares about (text deltas, reasoning, tool input/output, finish).
98
+ */
99
+ async function followStream(args: {
100
+ host: string;
101
+ bearerToken: string;
102
+ runId: string;
103
+ }): Promise<void> {
104
+ const url = buildStreamUrl(args.host, args.runId);
105
+ const response = await fetch(url, {
106
+ method: "GET",
107
+ headers: {
108
+ accept: "text/event-stream",
109
+ authorization: `Bearer ${args.bearerToken}`,
110
+ },
111
+ });
112
+ if (!response.ok || !response.body) {
113
+ throw new ChatSpawnError(
114
+ `Failed to open stream: ${response.status} ${response.statusText}`,
115
+ );
116
+ }
117
+
118
+ const reader = response.body.getReader();
119
+ const decoder = new TextDecoder();
120
+ let buffer = "";
121
+ // Tool calls in flight, keyed by toolCallId, so we can render
122
+ // a single line per call ("→ tool_name … done") instead of
123
+ // dumping every chunk verbatim.
124
+ const inFlightTools = new Map<string, { toolName: string }>();
125
+ let textBuffer = "";
126
+
127
+ const flushText = () => {
128
+ if (textBuffer) {
129
+ process.stdout.write(textBuffer);
130
+ textBuffer = "";
131
+ }
132
+ };
133
+
134
+ const handleChunk = (raw: string) => {
135
+ if (!raw.startsWith("data:")) return;
136
+ const data = raw.slice("data:".length).trim();
137
+ if (!data || data === "[DONE]") return;
138
+ let parsed: unknown;
139
+ try {
140
+ parsed = JSON.parse(data);
141
+ } catch {
142
+ return;
143
+ }
144
+ if (!parsed || typeof parsed !== "object") return;
145
+ const c = parsed as {
146
+ type?: unknown;
147
+ delta?: unknown;
148
+ text?: unknown;
149
+ toolName?: unknown;
150
+ toolCallId?: unknown;
151
+ input?: unknown;
152
+ output?: unknown;
153
+ errorText?: unknown;
154
+ };
155
+ switch (c.type) {
156
+ case "text-delta":
157
+ case "reasoning-delta": {
158
+ if (typeof c.delta === "string") {
159
+ textBuffer += c.delta;
160
+ }
161
+ break;
162
+ }
163
+ case "tool-input-start": {
164
+ flushText();
165
+ if (
166
+ typeof c.toolCallId === "string" &&
167
+ typeof c.toolName === "string"
168
+ ) {
169
+ inFlightTools.set(c.toolCallId, { toolName: c.toolName });
170
+ process.stdout.write(`\n→ ${c.toolName} …`);
171
+ }
172
+ break;
173
+ }
174
+ case "tool-output-available": {
175
+ flushText();
176
+ if (typeof c.toolCallId === "string") {
177
+ const meta = inFlightTools.get(c.toolCallId);
178
+ inFlightTools.delete(c.toolCallId);
179
+ process.stdout.write(` done (${meta?.toolName ?? "tool"})\n`);
180
+ }
181
+ break;
182
+ }
183
+ case "tool-output-error": {
184
+ flushText();
185
+ if (typeof c.toolCallId === "string") {
186
+ const meta = inFlightTools.get(c.toolCallId);
187
+ inFlightTools.delete(c.toolCallId);
188
+ process.stdout.write(
189
+ ` error (${meta?.toolName ?? "tool"}): ${
190
+ typeof c.errorText === "string" ? c.errorText : "unknown"
191
+ }\n`,
192
+ );
193
+ }
194
+ break;
195
+ }
196
+ case "finish": {
197
+ flushText();
198
+ process.stdout.write("\n");
199
+ break;
200
+ }
201
+ case "error": {
202
+ flushText();
203
+ const msg =
204
+ typeof c.errorText === "string" ? c.errorText : "stream error";
205
+ process.stderr.write(`\n[error] ${msg}\n`);
206
+ break;
207
+ }
208
+ default:
209
+ break;
210
+ }
211
+ };
212
+
213
+ // Read SSE-style chunks. The route uses Vercel AI SDK's
214
+ // `createUIMessageStreamResponse` which emits `data: {…}\n\n`
215
+ // lines, so we split on double newlines.
216
+ while (true) {
217
+ const { value, done } = await reader.read();
218
+ if (done) break;
219
+ buffer += decoder.decode(value, { stream: true });
220
+ let nextBreak = buffer.indexOf("\n\n");
221
+ while (nextBreak !== -1) {
222
+ const event = buffer.slice(0, nextBreak);
223
+ buffer = buffer.slice(nextBreak + 2);
224
+ // Each event may contain multiple `data:` lines.
225
+ for (const line of event.split("\n")) {
226
+ if (line.trim().length > 0) handleChunk(line);
227
+ }
228
+ nextBreak = buffer.indexOf("\n\n");
229
+ }
230
+ }
231
+ flushText();
232
+ }
233
+
234
+ function joinPositionalsExceptFlags(args: string[]): string {
235
+ return args.filter((arg) => !arg.startsWith("--")).join(" ").trim();
236
+ }
237
+
238
+ async function runChatNew(ctx: ChatSpawnContext): Promise<void> {
239
+ const model = consumeFlagValue(ctx.args, "--model");
240
+ const fileContextPath = consumeFlagValue(ctx.args, "--file-context");
241
+ const visibility = consumeFlagValue(ctx.args, "--visibility");
242
+ const title = consumeFlagValue(ctx.args, "--title");
243
+ const yolo = consumeFlag(ctx.args, "--yolo");
244
+ const follow = consumeFlag(ctx.args, "--follow");
245
+
246
+ const prompt = joinPositionalsExceptFlags(ctx.args);
247
+ if (!prompt) {
248
+ throw new ChatSpawnError(
249
+ 'Usage: dench chat new "<prompt>" [--model M] [--file-context PATH] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]',
250
+ );
251
+ }
252
+
253
+ const result = await postJson(
254
+ buildSpawnUrl(ctx.runtime.host),
255
+ ctx.runtime.bearerToken,
256
+ {
257
+ prompt,
258
+ model,
259
+ fileContextPath,
260
+ visibility,
261
+ title,
262
+ yolo,
263
+ },
264
+ );
265
+ if (!result.ok) {
266
+ const errorMessage =
267
+ (result.payload as { error?: string } | null)?.error ??
268
+ `Spawn failed (${result.status})`;
269
+ throw new ChatSpawnError(errorMessage);
270
+ }
271
+
272
+ const payload = result.payload as {
273
+ threadId?: string;
274
+ runId?: string;
275
+ threadUrl?: string;
276
+ streamUrl?: string;
277
+ } | null;
278
+ out(ctx, payload);
279
+
280
+ if (follow && payload?.runId) {
281
+ if (!ctx.jsonOutput) console.error("\n--- following stream ---");
282
+ await followStream({
283
+ host: ctx.runtime.host,
284
+ bearerToken: ctx.runtime.bearerToken,
285
+ runId: payload.runId,
286
+ });
287
+ }
288
+ }
289
+
290
+ async function runChatSend(ctx: ChatSpawnContext): Promise<void> {
291
+ const yolo = consumeFlag(ctx.args, "--yolo");
292
+ const follow = consumeFlag(ctx.args, "--follow");
293
+ const model = consumeFlagValue(ctx.args, "--model");
294
+
295
+ // First positional = threadId, the rest joined = prompt.
296
+ const positionals = ctx.args.filter((arg) => !arg.startsWith("--"));
297
+ const threadId = positionals.shift();
298
+ if (!threadId) {
299
+ throw new ChatSpawnError(
300
+ 'Usage: dench chat send <threadId> "<message>" [--model M] [--yolo] [--follow] [--json]',
301
+ );
302
+ }
303
+ const prompt = positionals.join(" ").trim();
304
+ if (!prompt) {
305
+ throw new ChatSpawnError(
306
+ 'Missing message body. Usage: dench chat send <threadId> "<message>"',
307
+ );
308
+ }
309
+
310
+ const result = await postJson(
311
+ buildSpawnUrl(ctx.runtime.host),
312
+ ctx.runtime.bearerToken,
313
+ {
314
+ threadId,
315
+ prompt,
316
+ model,
317
+ yolo,
318
+ },
319
+ );
320
+ if (!result.ok) {
321
+ const errorMessage =
322
+ (result.payload as { error?: string } | null)?.error ??
323
+ `Send failed (${result.status})`;
324
+ throw new ChatSpawnError(errorMessage);
325
+ }
326
+
327
+ const payload = result.payload as {
328
+ threadId?: string;
329
+ runId?: string;
330
+ threadUrl?: string;
331
+ streamUrl?: string;
332
+ } | null;
333
+ out(ctx, payload);
334
+
335
+ if (follow && payload?.runId) {
336
+ if (!ctx.jsonOutput) console.error("\n--- following stream ---");
337
+ await followStream({
338
+ host: ctx.runtime.host,
339
+ bearerToken: ctx.runtime.bearerToken,
340
+ runId: payload.runId,
341
+ });
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Public entry point invoked by the chat dispatcher in cli/dench.ts
347
+ * (and the agent dispatcher when the user runs `dench agent new` /
348
+ * `dench agent send` aliases). The CLI dispatcher resolves the
349
+ * runtime + bearer token once and hands them in.
350
+ */
351
+ export async function runChatSpawnCommand(opts: {
352
+ runtime: RuntimeBundle;
353
+ args: string[];
354
+ subcommand: "new" | "send";
355
+ }): Promise<void> {
356
+ const args = [...opts.args];
357
+ const jsonOutput = hasFlag(args, "--json");
358
+ const ctx: ChatSpawnContext = {
359
+ runtime: opts.runtime,
360
+ args,
361
+ jsonOutput,
362
+ };
363
+ if (opts.subcommand === "new") {
364
+ await runChatNew(ctx);
365
+ return;
366
+ }
367
+ await runChatSend(ctx);
368
+ }
369
+
370
+ export { ChatSpawnError };
@@ -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
+ }
package/fs-daemon.ts CHANGED
@@ -1451,38 +1451,24 @@ async function runInitialSync(argv: string[]): Promise<void> {
1451
1451
  );
1452
1452
  }
1453
1453
 
1454
- /**
1455
- * Only auto-run when this file is the entry point. Tests import named
1456
- * exports (`readMountInfo`, `findMountForPath`, `isFuseFsType`,
1457
- * etc.) and must not trigger the daemon's main loop. The check below
1458
- * tolerates Bun (`process.argv[1]`), the npm-installed wrapper
1459
- * (`require.main === module`), and the dench CLI's `tsx`-based
1460
- * fallback (`import.meta.main`).
1461
- */
1462
- const isDirectEntrypoint = (() => {
1463
- try {
1464
- if (typeof require !== "undefined" && require.main === module) return true;
1465
- } catch {
1466
- // require / module aren't defined in pure ESM mode; fall through.
1467
- }
1468
- // biome-ignore lint/suspicious/noExplicitAny: Bun-specific import.meta property
1469
- if ((import.meta as any).main === true) return true;
1470
- const entry = process.argv[1] ?? "";
1471
- return entry.endsWith("fs-daemon.ts") || entry.endsWith("/fs-daemon");
1472
- })();
1473
-
1474
- if (isDirectEntrypoint) {
1475
- main().catch((error) => {
1476
- console.error(
1477
- `[dench-fs-daemon] fatal: ${
1478
- error instanceof Error ? (error.stack ?? error.message) : String(error)
1479
- }`,
1480
- );
1481
- // Always exit with a non-zero code so the supervisor (in
1482
- // src/lib/daytona/sandbox-bootstrap.ts) can decide whether to
1483
- // restart. Previously we parked the process on a noop interval,
1484
- // which made `pgrep dench-fs-daemon` report "alive" for a daemon
1485
- // that had silently given up syncing.
1486
- process.exit(1);
1487
- });
1488
- }
1454
+ // Always run main(). Earlier versions guarded this on an `isDirectEntrypoint`
1455
+ // heuristic (`process.argv[1].endsWith("/fs-daemon")` etc.), but bun's global
1456
+ // install symlinks the launcher as `/root/.bun/bin/dench-fs-daemon` — that
1457
+ // path ends with `-fs-daemon`, not `/fs-daemon`, so the guard returned false
1458
+ // and the script exited 0 silently. Nothing imports this file as a module any
1459
+ // more (the test suite imports `fs-daemon-mount.ts` for the FUSE helpers), so
1460
+ // the guard is unnecessary and matches the unconditional `main()` call in
1461
+ // `cli/dench.ts`.
1462
+ main().catch((error) => {
1463
+ console.error(
1464
+ `[dench-fs-daemon] fatal: ${
1465
+ error instanceof Error ? (error.stack ?? error.message) : String(error)
1466
+ }`,
1467
+ );
1468
+ // Always exit with a non-zero code so the supervisor (in
1469
+ // src/lib/daytona/sandbox-bootstrap.ts) can decide whether to restart.
1470
+ // Previously we parked the process on a noop interval, which made
1471
+ // `pgrep dench-fs-daemon` report "alive" for a daemon that had silently
1472
+ // given up syncing.
1473
+ process.exit(1);
1474
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,8 +13,10 @@
13
13
  "dench.ts",
14
14
  "fs-daemon",
15
15
  "fs-daemon.ts",
16
+ "fs-daemon-mount.ts",
16
17
  "agent.ts",
17
18
  "chat.ts",
19
+ "chat-spawn.ts",
18
20
  "crm.ts",
19
21
  "agentKind.ts",
20
22
  "host.ts",