@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 +370 -0
- package/fs-daemon-mount.ts +133 -0
- package/fs-daemon.ts +21 -35
- package/package.json +3 -1
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
//
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
+
"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",
|