@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/README.md CHANGED
@@ -81,6 +81,30 @@ Use `dench context` when you need to know who you are, which workspace you are
81
81
  using, assigned tasks, pending approvals you requested, connected apps, and the
82
82
  best next commands. Add `--json` for structured output.
83
83
 
84
+ ## Past chat threads
85
+
86
+ Browse and search the user's past chat threads in this workspace (own +
87
+ shared). Useful for pulling in earlier context when the user refers to
88
+ something they discussed before. The chat agent has the equivalent
89
+ in-process tools (`list_past_chats`, `search_past_chats`,
90
+ `read_past_chat`); this CLI surface is the same data accessed via bash.
91
+
92
+ ```bash
93
+ dench chat list [--query <substr>] [--limit N] [--include-archived] [--json]
94
+ dench chat search "<query>" [--limit N] [--json]
95
+ dench chat read <thread-id> [--limit N] [--json]
96
+ ```
97
+
98
+ Visibility scope: only threads created by the current user, plus threads
99
+ in the same organization marked as `shared`.
100
+
101
+ `dench chat search` is a keyword full-text search across past message
102
+ text — pick concrete tokens that the prior chat would have used (file
103
+ paths, product names, error strings) rather than paraphrases.
104
+
105
+ Inside a sandbox, `dench chat` uses `DENCH_API_KEY` + `DENCH_RUN_ID`
106
+ automatically; locally, run `dench login` first.
107
+
84
108
  ## Billing
85
109
 
86
110
  Fresh workspaces verify a card for abuse prevention before starter credits are
package/agent.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * `dench agent <subcommand>` — CLI surface for the subagent + peer
3
+ * messaging system.
4
+ *
5
+ * Used inside sandboxes (where DENCH_API_KEY + DENCH_INTERNAL_BASE +
6
+ * DENCH_INTERNAL_TOKEN are already present in env) to:
7
+ * - spawn parallel subagents
8
+ * - await their completion
9
+ * - send / wait on peer messages
10
+ * - pause / resume / continue runs
11
+ * - list run trees
12
+ *
13
+ * Routes hit:
14
+ * POST /api/runs/spawn-child (internal token)
15
+ * POST /api/runs/notify-parent (internal token)
16
+ * POST /api/runs/send-message (internal token)
17
+ * POST /api/runs/continue (Bearer Dench API key)
18
+ * POST /api/runs (existing legacy route — kept for
19
+ * compatibility; new runs prefer /start)
20
+ * POST /api/runs/start (Bearer Dench API key, the new flow)
21
+ */
22
+ import type { ConvexHttpClient } from "convex/browser";
23
+ import { makeFunctionReference } from "convex/server";
24
+ import {
25
+ CliArgError,
26
+ getFlag,
27
+ hasFlag,
28
+ shift as shiftRaw,
29
+ } from "./lib/cli-args";
30
+
31
+ class AgentCliError extends Error {}
32
+
33
+ type CliCtx = {
34
+ convex: ConvexHttpClient;
35
+ args: string[];
36
+ jsonOutput: boolean;
37
+ };
38
+
39
+ // Args helpers moved to cli/lib/cli-args.ts; we adapt shift to throw
40
+ // AgentCliError so existing catch blocks don't need updating.
41
+ function shift(args: string[], expected: string): string {
42
+ try {
43
+ return shiftRaw(args, expected);
44
+ } catch (error) {
45
+ if (error instanceof CliArgError) throw new AgentCliError(error.message);
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ function out(ctx: CliCtx, 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
+ function getInternalBase(): string {
63
+ const base = (
64
+ process.env.DENCH_INTERNAL_BASE ??
65
+ process.env.DENCH_API_URL ??
66
+ ""
67
+ ).replace(/\/+$/, "");
68
+ if (!base) {
69
+ throw new AgentCliError(
70
+ "DENCH_INTERNAL_BASE (or DENCH_API_URL) is not configured in this sandbox",
71
+ );
72
+ }
73
+ return base;
74
+ }
75
+
76
+ function getApiBase(): string {
77
+ const base = (process.env.DENCH_API_URL ?? "").replace(/\/+$/, "");
78
+ if (!base) {
79
+ throw new AgentCliError("DENCH_API_URL is not configured in this sandbox");
80
+ }
81
+ return base;
82
+ }
83
+
84
+ function internalHeaders(): Record<string, string> {
85
+ const token = process.env.DENCH_INTERNAL_TOKEN?.trim();
86
+ if (!token) {
87
+ throw new AgentCliError(
88
+ "DENCH_INTERNAL_TOKEN is not configured in this sandbox",
89
+ );
90
+ }
91
+ return {
92
+ "content-type": "application/json",
93
+ "x-dench-internal-token": token,
94
+ };
95
+ }
96
+
97
+ function apiKeyHeaders(): Record<string, string> {
98
+ const key = process.env.DENCH_API_KEY?.trim();
99
+ if (!key) {
100
+ throw new AgentCliError(
101
+ "DENCH_API_KEY is not configured in this sandbox",
102
+ );
103
+ }
104
+ return {
105
+ "content-type": "application/json",
106
+ authorization: `Bearer ${key}`,
107
+ };
108
+ }
109
+
110
+ async function postJson(
111
+ url: string,
112
+ headers: Record<string, string>,
113
+ body: unknown,
114
+ ): Promise<unknown> {
115
+ const response = await fetch(url, {
116
+ method: "POST",
117
+ headers,
118
+ body: JSON.stringify(body ?? {}),
119
+ });
120
+ const text = await response.text();
121
+ let json: unknown;
122
+ try {
123
+ json = text ? JSON.parse(text) : null;
124
+ } catch {
125
+ json = { raw: text };
126
+ }
127
+ if (!response.ok) {
128
+ throw new AgentCliError(
129
+ `${url} failed with ${response.status}: ${
130
+ typeof json === "object" ? JSON.stringify(json) : String(json)
131
+ }`,
132
+ );
133
+ }
134
+ return json;
135
+ }
136
+
137
+ export async function runAgentCommand(opts: {
138
+ convex: ConvexHttpClient;
139
+ args: string[];
140
+ }): Promise<void> {
141
+ const args = [...opts.args];
142
+ const jsonOutput = hasFlag(args, "--json");
143
+ const ctx: CliCtx = { convex: opts.convex, args, jsonOutput };
144
+ const subcommand = args.shift();
145
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
146
+ agentHelp();
147
+ return;
148
+ }
149
+ switch (subcommand) {
150
+ case "spawn":
151
+ return await runSpawn(ctx);
152
+ case "await":
153
+ return await runAwait(ctx);
154
+ case "message":
155
+ return await runMessage(ctx);
156
+ case "wait-message":
157
+ return await runWaitMessage(ctx);
158
+ case "pause":
159
+ return await runPause(ctx);
160
+ case "resume":
161
+ return await runResume(ctx);
162
+ case "continue":
163
+ return await runContinue(ctx);
164
+ case "tree":
165
+ return await runTree(ctx);
166
+ default:
167
+ throw new AgentCliError(
168
+ `Unknown agent subcommand: ${subcommand}. Run 'dench agent help'.`,
169
+ );
170
+ }
171
+ }
172
+
173
+ async function runPause(ctx: CliCtx): Promise<void> {
174
+ const runId = shift(ctx.args, "run id");
175
+ const result = await postJson(
176
+ `${getApiBase()}/api/runs/pause`,
177
+ apiKeyHeaders(),
178
+ { runId },
179
+ );
180
+ out(ctx, result);
181
+ }
182
+
183
+ async function runResume(ctx: CliCtx): Promise<void> {
184
+ const runId = shift(ctx.args, "run id");
185
+ const prompt = ctx.args.length > 0 ? ctx.args.join(" ").trim() : undefined;
186
+ const result = await postJson(
187
+ `${getApiBase()}/api/runs/resume`,
188
+ apiKeyHeaders(),
189
+ { runId, prompt },
190
+ );
191
+ out(ctx, result);
192
+ }
193
+
194
+ async function runSpawn(ctx: CliCtx): Promise<void> {
195
+ const goal = ctx.args.filter((arg) => !arg.startsWith("--")).join(" ").trim();
196
+ if (!goal) throw new AgentCliError("Goal required: dench agent spawn '...'");
197
+ const parentRunId =
198
+ getFlag(ctx.args, "--parent-run-id") ?? process.env.DENCH_RUN_ID;
199
+ if (!parentRunId) {
200
+ throw new AgentCliError(
201
+ "--parent-run-id required (or set DENCH_RUN_ID inside a sandbox)",
202
+ );
203
+ }
204
+ const sandboxStrategy = getFlag(ctx.args, "--sandbox") as
205
+ | "own"
206
+ | "share_parent"
207
+ | undefined;
208
+ const timeBudgetMs = parseInt(
209
+ getFlag(ctx.args, "--time-budget-ms") ?? "0",
210
+ 10,
211
+ );
212
+
213
+ // Step 1: createChildRun via Convex (mutation requires admin auth, so we
214
+ // hop through the spawn-child route which has CONVEX_DEPLOY_KEY).
215
+ // For the v1 plan, we ship a single internal-route hop: the route owns
216
+ // the createChildRun + setRunLinkAwaitToken + start() sequence.
217
+ const result = await postJson(
218
+ `${getInternalBase()}/api/runs/spawn-child`,
219
+ internalHeaders(),
220
+ {
221
+ // The route currently expects a pre-created childRunId. To support
222
+ // the CLI flow we'll pass the raw goal and let the route create
223
+ // the child row + start the workflow. (Route extension lands in
224
+ // the next commit.)
225
+ parentRunId,
226
+ goal,
227
+ sandboxStrategy,
228
+ timeBudgetMs: timeBudgetMs > 0 ? timeBudgetMs : undefined,
229
+ },
230
+ );
231
+ out(ctx, result);
232
+ }
233
+
234
+ async function runAwait(ctx: CliCtx): Promise<void> {
235
+ const hookToken = getFlag(ctx.args, "--hook");
236
+ if (hookToken) {
237
+ out(ctx, { hookToken, status: "registered" });
238
+ return;
239
+ }
240
+ const childIdsCsv = getFlag(ctx.args, "--children");
241
+ if (!childIdsCsv) {
242
+ throw new AgentCliError(
243
+ "dench agent await requires --hook <token> or --children <csv>",
244
+ );
245
+ }
246
+ // Polling fallback: subscribe to runs via Convex realtime would be the
247
+ // proper implementation; for v1 we poll status fields every 2s.
248
+ const childIds = childIdsCsv.split(",").map((id) => id.trim()).filter(Boolean);
249
+ const completed: Record<string, string> = {};
250
+ const start = Date.now();
251
+ const timeoutMs = parseInt(
252
+ getFlag(ctx.args, "--timeout-ms") ?? "3600000",
253
+ 10,
254
+ );
255
+ while (Object.keys(completed).length < childIds.length) {
256
+ if (Date.now() - start > timeoutMs) {
257
+ throw new AgentCliError("dench agent await timed out");
258
+ }
259
+ for (const childId of childIds) {
260
+ if (completed[childId]) continue;
261
+ // Reading status requires a query; for v1 use a tight loop on the
262
+ // public runs.getRun. (Wired in P5 once DenchClient lands.)
263
+ completed[childId] = "pending";
264
+ }
265
+ await new Promise((resolve) => setTimeout(resolve, 2000));
266
+ break; // v1 polling stub — placeholder
267
+ }
268
+ out(ctx, { children: completed });
269
+ }
270
+
271
+ async function runMessage(ctx: CliCtx): Promise<void> {
272
+ const toRunId = shift(ctx.args, "target run id");
273
+ const fromRunId =
274
+ getFlag(ctx.args, "--from-run-id") ?? process.env.DENCH_RUN_ID;
275
+ if (!fromRunId) {
276
+ throw new AgentCliError(
277
+ "--from-run-id required (or set DENCH_RUN_ID inside a sandbox)",
278
+ );
279
+ }
280
+ const text = ctx.args.join(" ").trim();
281
+ if (!text) {
282
+ throw new AgentCliError(
283
+ "Message body required: dench agent message <toRunId> 'text...'",
284
+ );
285
+ }
286
+ const result = await postJson(
287
+ `${getInternalBase()}/api/runs/send-message`,
288
+ internalHeaders(),
289
+ { fromRunId, toRunId, payload: { text } },
290
+ );
291
+ out(ctx, result);
292
+ }
293
+
294
+ async function runWaitMessage(ctx: CliCtx): Promise<void> {
295
+ // v1 stub: print a message + exit. The real implementation lives in
296
+ // the agent loop's `wait_for_message` tool, which calls the workflow
297
+ // step that registers an await-message hook and durably sleeps.
298
+ out(ctx, {
299
+ note:
300
+ "wait-message is intended to be called by the agent loop, not from a one-shot CLI. Use the agent's wait_for_message tool inside a Long Session.",
301
+ });
302
+ }
303
+
304
+ async function runContinue(ctx: CliCtx): Promise<void> {
305
+ const prevRunId = shift(ctx.args, "previous run id");
306
+ const prompt = ctx.args.join(" ").trim();
307
+ if (!prompt) {
308
+ throw new AgentCliError(
309
+ "Continuation prompt required: dench agent continue <prevRunId> '...'",
310
+ );
311
+ }
312
+ const result = await postJson(
313
+ `${getApiBase()}/api/runs/continue`,
314
+ apiKeyHeaders(),
315
+ { prevRunId, prompt },
316
+ );
317
+ out(ctx, result);
318
+ }
319
+
320
+ async function runTree(ctx: CliCtx): Promise<void> {
321
+ const rootRunId =
322
+ ctx.args[0] ??
323
+ process.env.DENCH_ROOT_RUN_ID ??
324
+ process.env.DENCH_RUN_ID;
325
+ if (!rootRunId) {
326
+ throw new AgentCliError(
327
+ "dench agent tree <rootRunId> (or set DENCH_ROOT_RUN_ID inside a sandbox)",
328
+ );
329
+ }
330
+ const getRunTree = makeFunctionReference<"query">(
331
+ "functions/runs:getRunTree",
332
+ );
333
+ const flat = (await ctx.convex.query(getRunTree, {
334
+ rootRunId: rootRunId as never,
335
+ })) as Array<{
336
+ _id: string;
337
+ parentRunId?: string;
338
+ goal: string;
339
+ status: string;
340
+ }>;
341
+ if (ctx.jsonOutput) {
342
+ out(ctx, flat);
343
+ return;
344
+ }
345
+ // Tree-pretty-print, depth-first.
346
+ const byParent = new Map<string | null, typeof flat>();
347
+ for (const node of flat) {
348
+ const key = node.parentRunId ?? null;
349
+ const list = byParent.get(key) ?? [];
350
+ list.push(node);
351
+ byParent.set(key, list);
352
+ }
353
+ function print(node: (typeof flat)[number], depth: number): void {
354
+ const indent = " ".repeat(depth);
355
+ const goalLine = node.goal.split("\n")[0].slice(0, 60);
356
+ process.stdout.write(
357
+ `${indent}- [${node.status}] ${node._id} ${goalLine}\n`,
358
+ );
359
+ for (const child of byParent.get(node._id) ?? []) print(child, depth + 1);
360
+ }
361
+ for (const root of byParent.get(null) ?? []) print(root, 0);
362
+ }
363
+
364
+ function agentHelp(): void {
365
+ console.log(`Usage: dench agent <subcommand>
366
+
367
+ Spawn a chat-turn workflow (same as the web UI's chat panel):
368
+ dench agent new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
369
+ dench agent send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
370
+ These are aliases for \`dench chat new\` / \`dench chat send\`.
371
+
372
+ Subagents (autonomous Long Sessions, NOT chat threads):
373
+ dench agent spawn '<goal>' [--parent-run-id <id>] [--sandbox own|share_parent] [--time-budget-ms N]
374
+ dench agent await --hook <token>
375
+ dench agent await --children id1,id2,id3 [--timeout-ms N]
376
+
377
+ Peer messaging:
378
+ dench agent message <toRunId> '<text...>' [--from-run-id <id>]
379
+ dench agent wait-message (intended for use inside the agent loop)
380
+
381
+ Lifecycle:
382
+ dench agent pause <runId>
383
+ dench agent resume <runId> ['optional prompt to inject on resume']
384
+ dench agent continue <prevRunId> '<prompt>' (creates a new run from a completed one)
385
+ dench agent tree (see <RunTreeView> in dench.com UI)
386
+
387
+ Global flags:
388
+ --json Emit raw JSON instead of pretty-printed text.
389
+ --follow After spawning a chat (new/send), stream the response to stdout.
390
+ `);
391
+ }