@dench.com/cli 0.3.7 → 0.4.0

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
@@ -195,11 +195,14 @@ dench crm entries list people
195
195
 
196
196
  Auth precedence:
197
197
 
198
- 1. `--dev` flag → use the dev workspace env (`NEXT_PUBLIC_CONVEX_URL`,
198
+ 1. Explicit `--dev` flag → use the dev workspace env (`NEXT_PUBLIC_CONVEX_URL`,
199
199
  `DENCH_WORKSPACE`, `DENCH_DEV_AGENT_KEY`).
200
200
  2. A locally-stored `dench login` session for the current host.
201
201
  3. `DENCH_API_KEY` + `CONVEX_URL` (sandbox / automation path).
202
- 4. `--dev`-style env vars (without the flag).
202
+
203
+ Plain terminal usage does not silently fall back to dev env vars. Without
204
+ `--dev`, a saved session, or `DENCH_API_KEY` + `CONVEX_URL`, live commands
205
+ return `login_required`.
203
206
 
204
207
  Commands that need to impersonate a specific agent (`dench memory`,
205
208
  `dench task`, `dench claim`, `dench log`, `dench approval request`,
package/agent.ts CHANGED
@@ -34,6 +34,20 @@ type CliCtx = {
34
34
  convex: ConvexHttpClient;
35
35
  args: string[];
36
36
  jsonOutput: boolean;
37
+ /**
38
+ * Bearer token forwarded by the dispatcher in `cli/dench.ts`. Either a
39
+ * `dch_agent_*` agent session minted by `dench login` or a unified
40
+ * Dench API key (`DENCH_API_KEY`) when the CLI is running inside a
41
+ * sandbox. Used by `runAwait` to authenticate against
42
+ * `runs:getChildRunStatusForCli`.
43
+ */
44
+ sessionToken?: string;
45
+ /**
46
+ * Convex `runs._id` of the caller's own run, when authenticated via
47
+ * an API key (sandbox path). Required by `getChildRunStatusForCli`
48
+ * to scope the API key's reach to a specific organization.
49
+ */
50
+ callerRunId?: string;
37
51
  };
38
52
 
39
53
  // Args helpers moved to cli/lib/cli-args.ts; we adapt shift to throw
@@ -137,10 +151,18 @@ async function postJson(
137
151
  export async function runAgentCommand(opts: {
138
152
  convex: ConvexHttpClient;
139
153
  args: string[];
154
+ sessionToken?: string;
155
+ callerRunId?: string;
140
156
  }): Promise<void> {
141
157
  const args = [...opts.args];
142
158
  const jsonOutput = hasFlag(args, "--json");
143
- const ctx: CliCtx = { convex: opts.convex, args, jsonOutput };
159
+ const ctx: CliCtx = {
160
+ convex: opts.convex,
161
+ args,
162
+ jsonOutput,
163
+ sessionToken: opts.sessionToken,
164
+ callerRunId: opts.callerRunId,
165
+ };
144
166
  const subcommand = args.shift();
145
167
  if (!subcommand || subcommand === "help" || subcommand === "--help") {
146
168
  agentHelp();
@@ -243,27 +265,81 @@ async function runAwait(ctx: CliCtx): Promise<void> {
243
265
  "dench agent await requires --hook <token> or --children <csv>",
244
266
  );
245
267
  }
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> = {};
268
+ if (!ctx.sessionToken) {
269
+ throw new AgentCliError(
270
+ "dench agent await --children needs an authenticated session " +
271
+ "(run `dench login`, or set DENCH_API_KEY in a sandbox)",
272
+ );
273
+ }
274
+
275
+ const childIds = childIdsCsv
276
+ .split(",")
277
+ .map((id) => id.trim())
278
+ .filter(Boolean);
279
+ if (childIds.length === 0) {
280
+ throw new AgentCliError("--children requires at least one run id");
281
+ }
250
282
  const start = Date.now();
251
283
  const timeoutMs = parseInt(
252
284
  getFlag(ctx.args, "--timeout-ms") ?? "3600000",
253
285
  10,
254
286
  );
287
+ const intervalMs = parseInt(
288
+ getFlag(ctx.args, "--interval-ms") ?? "2000",
289
+ 10,
290
+ );
291
+
292
+ type ChildSnapshot = {
293
+ runId: string;
294
+ status: string;
295
+ summary: string | null;
296
+ threadId: string | null;
297
+ terminal: boolean;
298
+ };
299
+ const getStatus = makeFunctionReference<"query">(
300
+ "functions/runs:getChildRunStatusForCli",
301
+ );
302
+
303
+ // Poll each child's status until it reaches a terminal state. The
304
+ // server uses sessionToken auth (mirrors `dench chat list`) so this
305
+ // works both for `dch_agent_*` sessions (local dev) and for unified
306
+ // `DENCH_API_KEY`s (sandbox, must include `--caller-run-id`).
307
+ const completed: Record<string, ChildSnapshot> = {};
255
308
  while (Object.keys(completed).length < childIds.length) {
256
309
  if (Date.now() - start > timeoutMs) {
257
- throw new AgentCliError("dench agent await timed out");
310
+ throw new AgentCliError(
311
+ `dench agent await timed out after ${timeoutMs}ms with ${
312
+ Object.keys(completed).length
313
+ }/${childIds.length} children completed`,
314
+ );
258
315
  }
259
316
  for (const childId of childIds) {
260
317
  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";
318
+ try {
319
+ const snap = (await ctx.convex.query(getStatus, {
320
+ sessionToken: ctx.sessionToken,
321
+ runId: ctx.callerRunId ?? undefined,
322
+ childRunId: childId as never,
323
+ })) as ChildSnapshot | null;
324
+ if (snap?.terminal) {
325
+ completed[childId] = snap;
326
+ }
327
+ } catch (error) {
328
+ const msg = error instanceof Error ? error.message : String(error);
329
+ // Treat "not found" / access errors as terminal-with-error so
330
+ // we don't poll forever on a typo'd id.
331
+ completed[childId] = {
332
+ runId: childId,
333
+ status: "error",
334
+ summary: msg,
335
+ threadId: null,
336
+ terminal: true,
337
+ };
338
+ }
339
+ }
340
+ if (Object.keys(completed).length < childIds.length) {
341
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
264
342
  }
265
- await new Promise((resolve) => setTimeout(resolve, 2000));
266
- break; // v1 polling stub — placeholder
267
343
  }
268
344
  out(ctx, { children: completed });
269
345
  }
@@ -367,12 +443,18 @@ function agentHelp(): void {
367
443
  Spawn a chat-turn workflow (same as the web UI's chat panel):
368
444
  dench agent new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
369
445
  dench agent send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
370
- These are aliases for \`dench chat new\` / \`dench chat send\`.
446
+ dench agent follow <thread-id> [--json]
447
+ These are aliases for \`dench chat new\` / \`dench chat send\` / \`dench chat follow\`.
448
+ Use \`follow\` to tail the live stream of an existing thread (e.g.
449
+ a subagent spawned by \`spawn_agent\`) without sending a new message.
371
450
 
372
451
  Subagents (autonomous Long Sessions, NOT chat threads):
373
452
  dench agent spawn '<goal>' [--parent-run-id <id>] [--sandbox own|share_parent] [--time-budget-ms N]
374
453
  dench agent await --hook <token>
375
- dench agent await --children id1,id2,id3 [--timeout-ms N]
454
+ dench agent await --children id1,id2,id3 [--timeout-ms N] [--interval-ms N]
455
+ (polls runs:getChildRunStatusForCli
456
+ with the active session token until
457
+ every child is terminal)
376
458
 
377
459
  Peer messaging:
378
460
  dench agent message <toRunId> '<text...>' [--from-run-id <id>]
package/chat-spawn.ts CHANGED
@@ -87,21 +87,47 @@ function buildSpawnUrl(host: string): string {
87
87
  return `${host.replace(/\/+$/, "")}/api/chat/cli`;
88
88
  }
89
89
 
90
- function buildStreamUrl(host: string, runId: string): string {
90
+ function buildStreamUrlByRunId(host: string, runId: string): string {
91
91
  return `${host.replace(/\/+$/, "")}/api/chat/${encodeURIComponent(runId)}/stream`;
92
92
  }
93
93
 
94
+ function buildStreamUrlByThreadId(host: string, threadId: string): string {
95
+ return `${host.replace(/\/+$/, "")}/api/chat/stream?sessionId=${encodeURIComponent(threadId)}`;
96
+ }
97
+
94
98
  /**
95
99
  * Subscribe to the durable chat stream and pretty-print useful chunks
96
100
  * until the stream closes. Mirrors the chunk types the chat panel
97
101
  * cares about (text deltas, reasoning, tool input/output, finish).
102
+ *
103
+ * Resolves the stream URL from EITHER:
104
+ * - `runId` (Workflow SDK run id, returned in `x-workflow-run-id`
105
+ * by `POST /api/chat/cli`), via `/api/chat/[runId]/stream`, OR
106
+ * - `threadId` (Convex `chatThreads._id`), via
107
+ * `/api/chat/stream?sessionId=...` which resolves the active run
108
+ * by following `chatThreads.activeRunId → runs.workflowRunId`.
109
+ *
110
+ * The thread-id path is what `dench chat follow <threadId>` uses, so
111
+ * we can tail any live thread (including a subagent created by
112
+ * `spawn_agent`) without needing to know its workflow run id ahead of
113
+ * time.
98
114
  */
99
115
  async function followStream(args: {
100
116
  host: string;
101
117
  bearerToken: string;
102
- runId: string;
118
+ runId?: string;
119
+ threadId?: string;
103
120
  }): Promise<void> {
104
- const url = buildStreamUrl(args.host, args.runId);
121
+ const url = args.runId
122
+ ? buildStreamUrlByRunId(args.host, args.runId)
123
+ : args.threadId
124
+ ? buildStreamUrlByThreadId(args.host, args.threadId)
125
+ : null;
126
+ if (!url) {
127
+ throw new ChatSpawnError(
128
+ "followStream requires either { runId } or { threadId }",
129
+ );
130
+ }
105
131
  const response = await fetch(url, {
106
132
  method: "GET",
107
133
  headers: {
@@ -342,6 +368,37 @@ async function runChatSend(ctx: ChatSpawnContext): Promise<void> {
342
368
  }
343
369
  }
344
370
 
371
+ /**
372
+ * `dench chat follow <threadId>` — tail the live stream of an
373
+ * existing chat thread by Convex thread id.
374
+ *
375
+ * Useful for watching a subagent (a child thread spawned by a parent
376
+ * via `spawn_agent`) live, or for resuming the stream of any chat
377
+ * thread that's currently in flight. Resolves the active workflow run
378
+ * server-side via `chatThreads.activeRunId → runs.workflowRunId`, so
379
+ * the caller doesn't need to know any workflow ids.
380
+ *
381
+ * Exits when the stream closes (workflow finishes) or when the user
382
+ * hits Ctrl-C.
383
+ */
384
+ async function runChatFollow(ctx: ChatSpawnContext): Promise<void> {
385
+ const positionals = ctx.args.filter((arg) => !arg.startsWith("--"));
386
+ const threadId = positionals.shift();
387
+ if (!threadId) {
388
+ throw new ChatSpawnError(
389
+ "Usage: dench chat follow <threadId> [--json]",
390
+ );
391
+ }
392
+ if (!ctx.jsonOutput) {
393
+ console.error(`--- following stream for thread ${threadId} ---`);
394
+ }
395
+ await followStream({
396
+ host: ctx.runtime.host,
397
+ bearerToken: ctx.runtime.bearerToken,
398
+ threadId,
399
+ });
400
+ }
401
+
345
402
  /**
346
403
  * Public entry point invoked by the chat dispatcher in cli/dench.ts
347
404
  * (and the agent dispatcher when the user runs `dench agent new` /
@@ -351,7 +408,7 @@ async function runChatSend(ctx: ChatSpawnContext): Promise<void> {
351
408
  export async function runChatSpawnCommand(opts: {
352
409
  runtime: RuntimeBundle;
353
410
  args: string[];
354
- subcommand: "new" | "send";
411
+ subcommand: "new" | "send" | "follow";
355
412
  }): Promise<void> {
356
413
  const args = [...opts.args];
357
414
  const jsonOutput = hasFlag(args, "--json");
@@ -364,6 +421,10 @@ export async function runChatSpawnCommand(opts: {
364
421
  await runChatNew(ctx);
365
422
  return;
366
423
  }
424
+ if (opts.subcommand === "follow") {
425
+ await runChatFollow(ctx);
426
+ return;
427
+ }
367
428
  await runChatSend(ctx);
368
429
  }
369
430
 
package/chat.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * `dench chat <subcommand>` — CLI surface for browsing past chat threads
3
- * in the user's workspace.
2
+ * `dench chat <subcommand>` — CLI surface for browsing and managing
3
+ * past chat threads in the user's workspace.
4
4
  *
5
5
  * Mirrors the `dench crm` pattern: a thin command dispatcher on top of
6
- * Convex public queries (`functions/chat:listPastChats`,
7
- * `searchPastChats`, `readPastChat`) which themselves enforce the
8
- * "own + shared in this org" visibility rule.
6
+ * Convex public queries/mutations (`functions/chat:listPastChats`,
7
+ * `searchPastChats`, `readPastChat`, `renameThreads`, `deleteThreads`)
8
+ * which themselves enforce the "own + shared in this org" visibility
9
+ * rule (`requireThreadAccess`).
9
10
  *
10
11
  * Auth: every call forwards a single bearer token via `sessionToken`
11
12
  * (either a `dch_agent_*` agent session minted by `dench login` or a
@@ -16,9 +17,13 @@
16
17
  * inside a sandbox without extra flags.
17
18
  *
18
19
  * Subcommands:
19
- * chat list [--query <substr>] [--limit N] [--include-archived]
20
- * chat search <query> [--limit N] [--include-current]
21
- * chat read <thread-id> [--limit N]
20
+ * chat list [--query <substr>] [--limit N] [--include-archived]
21
+ * chat search <query> [--limit N] [--include-current]
22
+ * chat read <thread-id> [--limit N]
23
+ * chat rename <thread-id> <new-title> # single rename
24
+ * chat rename --from-stdin # batch via JSON {items:[...]}
25
+ * chat delete <thread-id> [<thread-id>...] # 1–25 ids, --yes to skip prompt
26
+ * chat delete --ids id1,id2,id3 # comma-separated form
22
27
  */
23
28
  import type { ConvexHttpClient } from "convex/browser";
24
29
  import { makeFunctionReference } from "convex/server";
@@ -67,6 +72,12 @@ const api = {
67
72
  readPastChat: makeFunctionReference<"query">(
68
73
  "functions/chat:readPastChat",
69
74
  ),
75
+ renameThreads: makeFunctionReference<"mutation">(
76
+ "functions/chat:renameThreads",
77
+ ),
78
+ deleteThreads: makeFunctionReference<"mutation">(
79
+ "functions/chat:deleteThreads",
80
+ ),
70
81
  };
71
82
 
72
83
  function commonArgs(ctx: ChatCliContext) {
@@ -84,6 +95,14 @@ async function callQuery(
84
95
  return ctx.convex.query(fn, { ...args, ...commonArgs(ctx) } as never);
85
96
  }
86
97
 
98
+ async function callMutation(
99
+ ctx: ChatCliContext,
100
+ fn: Parameters<ConvexHttpClient["mutation"]>[0],
101
+ args: Record<string, unknown> = {},
102
+ ): Promise<unknown> {
103
+ return ctx.convex.mutation(fn, { ...args, ...commonArgs(ctx) } as never);
104
+ }
105
+
87
106
  function out(ctx: ChatCliContext, value: unknown): void {
88
107
  if (ctx.jsonOutput) {
89
108
  console.log(JSON.stringify(value, null, 2));
@@ -108,10 +127,28 @@ Full-text search across past chat messages (own + shared):
108
127
  Read messages from a specific past chat thread:
109
128
  dench chat read <thread-id> [--limit N] [--json]
110
129
 
130
+ Rename one or more past chat threads (own + shared):
131
+ dench chat rename <thread-id> <new-title> [--json]
132
+ dench chat rename --from-stdin [--json]
133
+ Reads JSON {"items":[{"threadId":"...","title":"..."}, ...]} from stdin.
134
+ Up to 50 items per call. Titles are trimmed and clamped to 200 chars.
135
+
136
+ PERMANENTLY delete one or more past chat threads (and all their messages):
137
+ dench chat delete <thread-id> [<thread-id>...] [--yes] [--json]
138
+ dench chat delete --ids id1,id2,id3 [--yes] [--json]
139
+ Up to 25 ids per call. IRREVERSIBLE — the messages are removed too.
140
+ Without --yes, the CLI prompts for confirmation when stdin is a TTY.
141
+ In non-interactive contexts (sandboxes, scripts), --yes is required.
142
+
111
143
  Spawn a new chat-turn workflow (same agent loop as the web UI):
112
144
  dench chat new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
113
145
  dench chat send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
114
- Aliased as: dench agent new / dench agent send.
146
+ dench chat follow <thread-id> [--json]
147
+ dench chat tree [<root-thread-id>] [--limit N] [--json]
148
+ Aliased as: dench agent new / dench agent send / dench agent follow.
149
+ Use \`follow\` to tail any live thread by id (e.g. a subagent
150
+ spawned by another chat). Use \`tree\` to print the parent-child
151
+ thread hierarchy from a root thread.
115
152
 
116
153
  Approval policy:
117
154
  Without --yolo the workflow uses the workspace policy from
@@ -156,6 +193,13 @@ export async function runChatCommand(opts: {
156
193
  return await runChatSearchCommand(ctx);
157
194
  case "read":
158
195
  return await runChatReadCommand(ctx);
196
+ case "tree":
197
+ return await runChatTreeCommand(ctx);
198
+ case "rename":
199
+ return await runChatRenameCommand(ctx);
200
+ case "delete":
201
+ case "rm":
202
+ return await runChatDeleteCommand(ctx);
159
203
  default:
160
204
  throw new ChatCliError(`Unknown chat subcommand: ${subcommand}`);
161
205
  }
@@ -214,3 +258,299 @@ async function runChatReadCommand(ctx: ChatCliContext): Promise<void> {
214
258
  }),
215
259
  );
216
260
  }
261
+
262
+ async function readStdin(): Promise<string> {
263
+ if (process.stdin.isTTY) {
264
+ throw new ChatCliError(
265
+ "--from-stdin requires JSON piped on stdin (got TTY).",
266
+ );
267
+ }
268
+ const chunks: Buffer[] = [];
269
+ for await (const chunk of process.stdin as AsyncIterable<Buffer>) {
270
+ chunks.push(chunk);
271
+ }
272
+ return Buffer.concat(chunks).toString("utf8");
273
+ }
274
+
275
+ async function runChatRenameCommand(ctx: ChatCliContext): Promise<void> {
276
+ const fromStdin = hasFlag(ctx.args, "--from-stdin");
277
+ let items: Array<{ threadId: string; title: string }>;
278
+ if (fromStdin) {
279
+ const raw = (await readStdin()).trim();
280
+ if (!raw) {
281
+ throw new ChatCliError(
282
+ 'Empty --from-stdin payload; expected {"items":[...]}.',
283
+ );
284
+ }
285
+ let parsed: unknown;
286
+ try {
287
+ parsed = JSON.parse(raw);
288
+ } catch (error) {
289
+ throw new ChatCliError(
290
+ `Invalid JSON on stdin: ${
291
+ error instanceof Error ? error.message : String(error)
292
+ }`,
293
+ );
294
+ }
295
+ if (
296
+ !parsed ||
297
+ typeof parsed !== "object" ||
298
+ !Array.isArray((parsed as { items?: unknown }).items)
299
+ ) {
300
+ throw new ChatCliError(
301
+ 'Stdin JSON must be {"items":[{"threadId":"...","title":"..."}]}.',
302
+ );
303
+ }
304
+ const rawItems = (parsed as { items: unknown[] }).items;
305
+ items = rawItems.map((entry, index) => {
306
+ if (
307
+ !entry ||
308
+ typeof entry !== "object" ||
309
+ typeof (entry as { threadId?: unknown }).threadId !== "string" ||
310
+ typeof (entry as { title?: unknown }).title !== "string"
311
+ ) {
312
+ throw new ChatCliError(
313
+ `items[${index}] must be { threadId: string, title: string }.`,
314
+ );
315
+ }
316
+ const item = entry as { threadId: string; title: string };
317
+ if (!item.threadId.trim()) {
318
+ throw new ChatCliError(`items[${index}].threadId is empty.`);
319
+ }
320
+ if (!item.title.trim()) {
321
+ throw new ChatCliError(`items[${index}].title is empty.`);
322
+ }
323
+ return { threadId: item.threadId.trim(), title: item.title };
324
+ });
325
+ } else {
326
+ const threadId = shift(ctx.args, "thread id").trim();
327
+ const titleParts: string[] = [];
328
+ while (ctx.args.length > 0) {
329
+ const next = ctx.args.shift();
330
+ if (next === undefined) break;
331
+ if (next.startsWith("--")) {
332
+ ctx.args.unshift(next);
333
+ break;
334
+ }
335
+ titleParts.push(next);
336
+ }
337
+ const title = titleParts.join(" ").trim();
338
+ if (!title) {
339
+ throw new ChatCliError(
340
+ "Usage: dench chat rename <thread-id> <new-title>",
341
+ );
342
+ }
343
+ items = [{ threadId, title }];
344
+ }
345
+ if (items.length === 0) {
346
+ throw new ChatCliError("Nothing to rename.");
347
+ }
348
+ if (items.length > 50) {
349
+ throw new ChatCliError(
350
+ `Too many items (${items.length}); the server caps each call at 50.`,
351
+ );
352
+ }
353
+ out(ctx, await callMutation(ctx, api.renameThreads, { items }));
354
+ }
355
+
356
+ async function promptYesNo(question: string): Promise<boolean> {
357
+ if (!process.stdin.isTTY) return false;
358
+ process.stdout.write(`${question} [y/N] `);
359
+ return new Promise<boolean>((resolve) => {
360
+ const onData = (chunk: Buffer) => {
361
+ const answer = chunk.toString("utf8").trim().toLowerCase();
362
+ cleanup();
363
+ resolve(answer === "y" || answer === "yes");
364
+ };
365
+ const cleanup = () => {
366
+ process.stdin.off("data", onData);
367
+ try {
368
+ process.stdin.pause();
369
+ } catch {
370
+ // ignore: stdin may already be closed.
371
+ }
372
+ };
373
+ process.stdin.resume();
374
+ process.stdin.once("data", onData);
375
+ });
376
+ }
377
+
378
+ async function runChatDeleteCommand(ctx: ChatCliContext): Promise<void> {
379
+ const idsFlag = getFlag(ctx.args, "--ids");
380
+ const skipConfirm = hasFlag(ctx.args, "--yes") || hasFlag(ctx.args, "-y");
381
+ const collected: string[] = [];
382
+ if (idsFlag) {
383
+ for (const part of idsFlag.split(",")) {
384
+ const trimmed = part.trim();
385
+ if (trimmed) collected.push(trimmed);
386
+ }
387
+ }
388
+ while (ctx.args.length > 0) {
389
+ const next = ctx.args.shift();
390
+ if (next === undefined) break;
391
+ if (next.startsWith("--")) {
392
+ ctx.args.unshift(next);
393
+ break;
394
+ }
395
+ if (next.trim()) collected.push(next.trim());
396
+ }
397
+ const seen = new Set<string>();
398
+ const threadIds = collected.filter((id) => {
399
+ if (seen.has(id)) return false;
400
+ seen.add(id);
401
+ return true;
402
+ });
403
+ if (threadIds.length === 0) {
404
+ throw new ChatCliError(
405
+ "Usage: dench chat delete <thread-id> [<thread-id>...] (or --ids id1,id2,...)",
406
+ );
407
+ }
408
+ if (threadIds.length > 25) {
409
+ throw new ChatCliError(
410
+ `Too many thread ids (${threadIds.length}); the server caps each call at 25. Run multiple invocations.`,
411
+ );
412
+ }
413
+ if (!skipConfirm) {
414
+ if (!process.stdin.isTTY) {
415
+ throw new ChatCliError(
416
+ "Refusing to delete without --yes in non-interactive mode (no TTY).",
417
+ );
418
+ }
419
+ const ok = await promptYesNo(
420
+ `PERMANENTLY delete ${threadIds.length} chat${
421
+ threadIds.length === 1 ? "" : "s"
422
+ } and all their messages?`,
423
+ );
424
+ if (!ok) {
425
+ console.error("Aborted.");
426
+ return;
427
+ }
428
+ }
429
+ out(ctx, await callMutation(ctx, api.deleteThreads, { threadIds }));
430
+ }
431
+
432
+ /**
433
+ * `dench chat tree [<root-thread-id>] [--limit N] [--json]` — print the
434
+ * parent-child chat thread hierarchy rooted at the given thread (or
435
+ * roots = top-level chats if no id is passed).
436
+ *
437
+ * Uses `listPastChats` (which already includes nested threads in its
438
+ * results — `topLevelOnly` is a `listThreads`-only knob) and groups
439
+ * client-side by `parentThreadId`. With `--json`, prints the raw flat
440
+ * array; otherwise prints an indented tree with status + a short
441
+ * summary excerpt for each row.
442
+ */
443
+ async function runChatTreeCommand(ctx: ChatCliContext): Promise<void> {
444
+ // Strip flag values from positionals so e.g. `tree --limit 30` doesn't
445
+ // misinterpret "30" as the rootThreadId. We treat "--key value" as a
446
+ // pair iff value doesn't itself start with "--".
447
+ const positionals: string[] = [];
448
+ for (let i = 0; i < ctx.args.length; i++) {
449
+ const arg = ctx.args[i];
450
+ if (arg.startsWith("--")) {
451
+ const next = ctx.args[i + 1];
452
+ if (next !== undefined && !next.startsWith("--")) {
453
+ i += 1; // skip the flag value
454
+ }
455
+ continue;
456
+ }
457
+ positionals.push(arg);
458
+ }
459
+ const rootThreadId = positionals[0]?.trim() || undefined;
460
+ const limit = parseLimit(getFlag(ctx.args, "--limit")) ?? 200;
461
+
462
+ const flat = (await callQuery(ctx, api.listPastChats, {
463
+ limit,
464
+ includeArchived: false,
465
+ })) as Array<{
466
+ threadId: string;
467
+ title: string;
468
+ status: string;
469
+ parentThreadId: string | null;
470
+ nestingDepth: number | null;
471
+ summary: string | null;
472
+ lastMessageAt: number;
473
+ createdAt: number;
474
+ activeRunId: string | null;
475
+ }>;
476
+
477
+ if (ctx.jsonOutput) {
478
+ out(ctx, flat);
479
+ return;
480
+ }
481
+
482
+ // Group by parent. Top-level rows = either no parent, or parent
483
+ // outside the loaded set (so we don't silently swallow children
484
+ // whose parent fell off the limit window).
485
+ const ids = new Set(flat.map((t) => t.threadId));
486
+ const childrenByParent = new Map<string, typeof flat>();
487
+ const roots: typeof flat = [];
488
+ for (const thread of flat) {
489
+ if (thread.parentThreadId && ids.has(thread.parentThreadId)) {
490
+ let list = childrenByParent.get(thread.parentThreadId);
491
+ if (!list) {
492
+ list = [];
493
+ childrenByParent.set(thread.parentThreadId, list);
494
+ }
495
+ list.push(thread);
496
+ continue;
497
+ }
498
+ if (rootThreadId) {
499
+ if (thread.threadId === rootThreadId) roots.push(thread);
500
+ } else {
501
+ roots.push(thread);
502
+ }
503
+ }
504
+ // When the user passed an explicit `rootThreadId` but it didn't
505
+ // appear in the loaded set (e.g. it sits beyond `--limit`), pull
506
+ // it on its own so we still surface it as the single root.
507
+ if (rootThreadId && roots.length === 0) {
508
+ const explicit = flat.find((t) => t.threadId === rootThreadId);
509
+ if (explicit) roots.push(explicit);
510
+ }
511
+ // Sort children oldest-first within each parent (spawn order),
512
+ // roots newest-first (most recent root at the top).
513
+ for (const list of childrenByParent.values()) {
514
+ list.sort((a, b) => (a.createdAt || a.lastMessageAt) - (b.createdAt || b.lastMessageAt));
515
+ }
516
+ roots.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
517
+
518
+ if (roots.length === 0) {
519
+ if (rootThreadId) {
520
+ console.error(
521
+ `No thread found with id ${rootThreadId} (or it's outside the limit ` +
522
+ `${limit} window — increase --limit).`,
523
+ );
524
+ } else {
525
+ console.error("No chat threads in this workspace yet.");
526
+ }
527
+ return;
528
+ }
529
+
530
+ const stripe = (s: string, n: number) =>
531
+ s.length > n ? `${s.slice(0, n - 1)}…` : s;
532
+ const print = (thread: (typeof flat)[number], depth: number): void => {
533
+ const indent = " ".repeat(depth);
534
+ const branch = depth > 0 ? "└─ " : "";
535
+ const status =
536
+ thread.status === "streaming"
537
+ ? "▸ running"
538
+ : thread.status === "error"
539
+ ? "✗ error"
540
+ : thread.summary
541
+ ? "✓ done"
542
+ : "·";
543
+ process.stdout.write(
544
+ `${indent}${branch}[${status}] ${stripe(thread.title || "Untitled", 60)} ` +
545
+ `(${thread.threadId})\n`,
546
+ );
547
+ if (thread.summary) {
548
+ process.stdout.write(
549
+ `${indent}${depth > 0 ? " " : ""}${stripe(thread.summary.replace(/\s+/g, " "), 80)}\n`,
550
+ );
551
+ }
552
+ const direct = childrenByParent.get(thread.threadId) ?? [];
553
+ for (const child of direct) print(child, depth + 1);
554
+ };
555
+ for (const root of roots) print(root, 0);
556
+ }
package/cron.ts CHANGED
@@ -5,10 +5,12 @@
5
5
  * Each cron is a row in the Convex `triggers` table that, when due,
6
6
  * spawns a fresh chat thread (the `target: "chat_turn"` path — new
7
7
  * default) running in YOLO mode and seeded with the cron's prompt as
8
- * the first user message. The Vercel cron dispatcher (`/api/cron/dispatch`,
9
- * scheduled via `vercel.json` at every minute) walks the
10
- * `by_enabled_nextFireAt` index, claims due rows, and fires the
11
- * workflow.
8
+ * the first user message. Scheduling is owned by the
9
+ * `@convex-dev/crons` component: each create/update/enable mutation
10
+ * registers a component cron under name=triggerId; the component fires
11
+ * `internal.functions.triggersNode.fireDenchTrigger` at the precise
12
+ * scheduled time, which then POSTs to `/api/runs/spawn-{chat,child}`
13
+ * to start the workflow.
12
14
  *
13
15
  * Auth: prefers the unified Dench API key from `DENCH_API_KEY`
14
16
  * (sandbox envVar baked at create time). Falls back to `dench login`
@@ -504,6 +506,8 @@ async function runHistory(ctx: CronCliContext): Promise<void> {
504
506
 
505
507
  function cronHelp(): void {
506
508
  console.log(`Usage: dench cron <subcommand>
509
+ dench routine <subcommand> (alias)
510
+ dench routines <subcommand> (alias)
507
511
 
508
512
  Browse:
509
513
  dench cron list [--enabled-only] [--json]
package/dench.ts CHANGED
@@ -327,6 +327,41 @@ function throwIfCliError(value: unknown) {
327
327
  }
328
328
  }
329
329
 
330
+ function normalizeCliError(error: unknown): CliError {
331
+ if (error instanceof CliError) return error;
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ if (message.includes("Invalid dev agent key")) {
334
+ return new CliError("Invalid dev agent key", {
335
+ code: "invalid_dev_agent_key",
336
+ nextActions: [
337
+ "Check that DENCH_DEV_AGENT_KEY in your shell matches the Convex deployment env.",
338
+ "Use --dev only for a local/dev Convex deployment configured with that key.",
339
+ "For normal terminal usage, run dench login or set DENCH_API_KEY + CONVEX_URL.",
340
+ ],
341
+ });
342
+ }
343
+ if (message.includes("Dev CRM access is disabled in production")) {
344
+ return new CliError("Dev CRM access is disabled in production", {
345
+ code: "dev_access_disabled",
346
+ nextActions: [
347
+ "Remove --dev and run dench login for terminal access.",
348
+ "Or use DENCH_API_KEY + CONVEX_URL for sandbox/automation access.",
349
+ ],
350
+ });
351
+ }
352
+ if (message.includes("Invalid Dench API key")) {
353
+ return new CliError("Invalid Dench API key", {
354
+ code: "invalid_api_key",
355
+ nextActions: [
356
+ "Check DENCH_API_KEY and CONVEX_URL point to the same organization/deployment.",
357
+ "Unset DENCH_API_KEY to use a saved dench login session instead.",
358
+ "Or run dench login to create a new terminal session.",
359
+ ],
360
+ });
361
+ }
362
+ return new CliError(message);
363
+ }
364
+
330
365
  function parseJsonObjectOption(name: string) {
331
366
  const raw = option(name);
332
367
  if (!raw) return {};
@@ -457,12 +492,20 @@ Past chat threads (own + shared in this workspace):
457
492
  dench chat list [--query <substr>] [--limit N] [--include-archived] [--json]
458
493
  dench chat search "<query>" [--limit N] [--include-current] [--json]
459
494
  dench chat read <thread-id> [--limit N] [--json]
495
+ dench chat rename <thread-id> <new-title> [--json]
496
+ dench chat rename --from-stdin [--json] (batch via JSON {items:[...]})
497
+ dench chat delete <thread-id> [<thread-id>...] [--yes] [--json]
498
+ dench chat delete --ids id1,id2 [--yes] [--json] (PERMANENTLY deletes threads)
460
499
 
461
500
  Spawn a new chat-turn workflow (same as the web UI):
462
501
  dench chat new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
463
502
  dench chat send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
464
- dench agent new ... (alias for dench chat new)
465
- dench agent send ... (alias for dench chat send)
503
+ dench chat follow <thread-id> [--json] (tail a live thread by id)
504
+ dench chat tree [<root-thread-id>] [--limit N] [--json]
505
+ (parent-child thread tree)
506
+ dench agent new ... (alias for dench chat new)
507
+ dench agent send ... (alias for dench chat send)
508
+ dench agent follow ... (alias for dench chat follow)
466
509
 
467
510
  Help: dench chat help
468
511
 
@@ -495,6 +538,20 @@ Subagents (Daytona-native rewrite):
495
538
 
496
539
  dench --version
497
540
 
541
+ Self-updating agent harness (OpenClaw parity):
542
+ dench identity show | edit IDENTITY.md (org-wide)
543
+ dench organisation show | edit ORGANISATION.md
544
+ dench user show | edit USER.md (per-member)
545
+ dench tools show | edit TOOLS.md notes section
546
+ dench mem show | append "<text>" | set MEMORY.md aggregate
547
+ dench heartbeat status | enable | disable
548
+ | interval <30m|1h|…> | instructions
549
+ dench bootstrap status | complete | reopen | template
550
+ dench model default <id|clear>
551
+ dench model thread <threadId> <id|clear>
552
+ dench daily today | show <YYYY-MM-DD>
553
+ Help: dench identity help
554
+
498
555
  External tools:
499
556
  dench apps is an alias for dench tool status -- it lists connected apps.
500
557
  dench tool with no subcommand prints tool help.
@@ -1781,10 +1838,12 @@ async function getRuntime() {
1781
1838
  : undefined,
1782
1839
  };
1783
1840
  }
1841
+
1842
+ throw loginRequiredError();
1784
1843
  }
1785
1844
 
1786
1845
  if (!hasDevEnv()) {
1787
- throw hasFlag("--dev") ? missingDevEnvError() : loginRequiredError();
1846
+ throw missingDevEnvError();
1788
1847
  }
1789
1848
 
1790
1849
  return {
@@ -1799,6 +1858,22 @@ async function getRuntime() {
1799
1858
 
1800
1859
  type Runtime = Awaited<ReturnType<typeof getRuntime>>;
1801
1860
 
1861
+ function stripRuntimeFlags(args: string[]) {
1862
+ const booleanFlags = new Set(["--dev", "--prod", "--staging"]);
1863
+ const valueFlags = new Set(["--convex-url", "--host"]);
1864
+ const stripped: string[] = [];
1865
+ for (let i = 0; i < args.length; i++) {
1866
+ const arg = args[i];
1867
+ if (booleanFlags.has(arg)) continue;
1868
+ if (valueFlags.has(arg)) {
1869
+ i++;
1870
+ continue;
1871
+ }
1872
+ stripped.push(arg);
1873
+ }
1874
+ return stripped;
1875
+ }
1876
+
1802
1877
  /**
1803
1878
  * `session` mode is required for `agentWorkspace.*` calls because they
1804
1879
  * gate on `validateAgentSession` server-side, which only accepts the
@@ -1837,6 +1912,13 @@ function requireAuthenticatedRuntime(
1837
1912
  throw loginRequiredError(command);
1838
1913
  }
1839
1914
 
1915
+ function encodeDevCrmSessionToken(args: {
1916
+ workspaceSlug: string;
1917
+ devKey: string;
1918
+ }) {
1919
+ return `dch_dev_${encodeURIComponent(args.workspaceSlug)}:${encodeURIComponent(args.devKey)}`;
1920
+ }
1921
+
1840
1922
  function agentSessionRequiredError(command: string) {
1841
1923
  return new CliError(
1842
1924
  `${command} requires an agent session — DENCH_API_KEY is org-scoped and cannot impersonate a specific agent`,
@@ -3458,7 +3540,7 @@ async function main() {
3458
3540
  }
3459
3541
 
3460
3542
  if (command === "crm") {
3461
- const subArgs = args.slice(args.indexOf("crm") + 1);
3543
+ const subArgs = stripRuntimeFlags(args.slice(args.indexOf("crm") + 1));
3462
3544
  const sub = subArgs[0];
3463
3545
  // Short-circuit help so users don't need a session to read the
3464
3546
  // surface. The crm dispatcher's own help resolves --json + the
@@ -3478,6 +3560,14 @@ async function main() {
3478
3560
  // (`DENCH_API_KEY` baked into sandbox envVars). Server-side
3479
3561
  // `requireCrmAccess` (convex/lib/crm/access.ts) dispatches on the
3480
3562
  // bearer prefix and resolves the org for both.
3563
+ if (runtime.mode === "dev") {
3564
+ await runCrmCommand({
3565
+ convex: runtime.client,
3566
+ args: subArgs,
3567
+ sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs),
3568
+ });
3569
+ return;
3570
+ }
3481
3571
  const authedRuntime = requireAuthenticatedRuntime(runtime, "dench crm");
3482
3572
  await runCrmCommand({
3483
3573
  convex: authedRuntime.client,
@@ -3534,12 +3624,13 @@ async function main() {
3534
3624
  await runChatCommand({ convex: {} as any, args: subArgs });
3535
3625
  return;
3536
3626
  }
3537
- // `chat new` / `chat send` spawn a chat-turn workflow via
3538
- // /api/chat/cli (the same workflow the web UI uses). They need
3539
- // an HTTP host + bearer token, not a Convex client. The
3540
- // remaining subcommands (list / search / read) are Convex
3541
- // queries and keep going through `runChatCommand`.
3542
- if (sub === "new" || sub === "send") {
3627
+ // `chat new` / `chat send` / `chat follow` use HTTP routes
3628
+ // (/api/chat/cli for spawn, /api/chat/[runId]/stream and
3629
+ // /api/chat/stream for follow) so they need an authenticated host
3630
+ // + bearer token, not a Convex client. The remaining subcommands
3631
+ // (list / search / read / tree) are Convex queries and keep going
3632
+ // through `runChatCommand`.
3633
+ if (sub === "new" || sub === "send" || sub === "follow") {
3543
3634
  const { runChatSpawnCommand } = await import("./chat-spawn");
3544
3635
  const runtime = await getRuntime();
3545
3636
  const authedRuntime = requireAuthenticatedRuntime(
@@ -3583,11 +3674,12 @@ async function main() {
3583
3674
  await runAgentCommand({ convex: {} as any, args: subArgs });
3584
3675
  return;
3585
3676
  }
3586
- // Aliases: `dench agent new` / `dench agent send` -> the chat-spawn
3587
- // implementation. Surfaced under `agent` so users who associate
3588
- // chat threads with the agent surface still find them; the actual
3589
- // workflow is identical to `dench chat new` / `dench chat send`.
3590
- if (sub === "new" || sub === "send") {
3677
+ // Aliases: `dench agent new` / `dench agent send` / `dench agent
3678
+ // follow` -> the chat-spawn implementation. Surfaced under
3679
+ // `agent` so users who associate chat threads with the agent
3680
+ // surface still find them; the actual workflow is identical to
3681
+ // `dench chat new` / `dench chat send` / `dench chat follow`.
3682
+ if (sub === "new" || sub === "send" || sub === "follow") {
3591
3683
  const { runChatSpawnCommand } = await import("./chat-spawn");
3592
3684
  const runtime = await getRuntime();
3593
3685
  const authedRuntime = requireAuthenticatedRuntime(
@@ -3609,12 +3701,17 @@ async function main() {
3609
3701
  await runAgentCommand({
3610
3702
  convex: runtime.client,
3611
3703
  args: subArgs,
3704
+ sessionToken: runtime.sessionToken,
3705
+ callerRunId: process.env.DENCH_RUN_ID?.trim() || undefined,
3612
3706
  });
3613
3707
  return;
3614
3708
  }
3615
3709
 
3616
- if (command === "cron") {
3617
- const subArgs = args.slice(args.indexOf("cron") + 1);
3710
+ // `dench routine` / `dench routines` are UI-friendly aliases for
3711
+ // `dench cron`. Same dispatch, same auth, same handler — only the
3712
+ // top-level command name differs to match the workspace UI rename.
3713
+ if (command === "cron" || command === "routine" || command === "routines") {
3714
+ const subArgs = args.slice(args.indexOf(command) + 1);
3618
3715
  const sub = subArgs[0];
3619
3716
  // Help is a no-auth path so users can read the surface without a
3620
3717
  // session — mirrors `dench crm help`.
@@ -3633,7 +3730,10 @@ async function main() {
3633
3730
  // bearer prefix and resolves the org for both. The Bearer token
3634
3731
  // must carry `crons:read` (queries) or `crons:write` (mutations)
3635
3732
  // scope, or the wildcard `*`.
3636
- const authedRuntime = requireAuthenticatedRuntime(runtime, "dench cron");
3733
+ const authedRuntime = requireAuthenticatedRuntime(
3734
+ runtime,
3735
+ `dench ${command}`,
3736
+ );
3637
3737
  await runCronCommand({
3638
3738
  convex: authedRuntime.client,
3639
3739
  args: subArgs,
@@ -3642,6 +3742,85 @@ async function main() {
3642
3742
  return;
3643
3743
  }
3644
3744
 
3745
+ // ── OpenClaw-parity self-update CLI surface ──────────────────────────
3746
+ //
3747
+ // Dispatch all `dench identity / organisation / user / tools / mem /
3748
+ // heartbeat / bootstrap / model / daily` traffic through the shared
3749
+ // agent-config CLI module. Each command requires the same auth shape
3750
+ // as `dench cron` (Bearer DENCH_API_KEY or `dench login` session
3751
+ // token) so server-side `requireCronAccess` accepts both.
3752
+ const AGENT_CONFIG_COMMANDS = new Set([
3753
+ "identity",
3754
+ "organisation",
3755
+ "user",
3756
+ "tools",
3757
+ "mem",
3758
+ "heartbeat",
3759
+ "bootstrap",
3760
+ "model",
3761
+ "daily",
3762
+ ]);
3763
+ if (AGENT_CONFIG_COMMANDS.has(command)) {
3764
+ const subArgs = args.slice(args.indexOf(command) + 1);
3765
+ if (
3766
+ subArgs[0] === "help" ||
3767
+ subArgs.includes("--help") ||
3768
+ subArgs.includes("-h")
3769
+ ) {
3770
+ const { printAgentConfigCliHelp } = await import("./agent-config");
3771
+ printAgentConfigCliHelp();
3772
+ return;
3773
+ }
3774
+ const runtime = await getRuntime();
3775
+ const authedRuntime = requireAuthenticatedRuntime(
3776
+ runtime,
3777
+ `dench ${command}`,
3778
+ );
3779
+ const ctxBase = {
3780
+ convex: authedRuntime.client,
3781
+ args: subArgs,
3782
+ jsonOutput: json,
3783
+ sessionToken: authedRuntime.sessionToken,
3784
+ };
3785
+ const mod = await import("./agent-config");
3786
+ if (command === "identity") {
3787
+ await mod.runIdentityCommand(ctxBase);
3788
+ return;
3789
+ }
3790
+ if (command === "organisation") {
3791
+ await mod.runOrganisationCommand(ctxBase);
3792
+ return;
3793
+ }
3794
+ if (command === "user") {
3795
+ await mod.runUserCommand(ctxBase);
3796
+ return;
3797
+ }
3798
+ if (command === "tools") {
3799
+ await mod.runToolsCommand(ctxBase);
3800
+ return;
3801
+ }
3802
+ if (command === "mem") {
3803
+ await mod.runMemoryAggregateCommand(ctxBase);
3804
+ return;
3805
+ }
3806
+ if (command === "heartbeat") {
3807
+ await mod.runHeartbeatCommand(ctxBase);
3808
+ return;
3809
+ }
3810
+ if (command === "bootstrap") {
3811
+ await mod.runBootstrapCommand(ctxBase);
3812
+ return;
3813
+ }
3814
+ if (command === "model") {
3815
+ await mod.runModelCommand(ctxBase);
3816
+ return;
3817
+ }
3818
+ if (command === "daily") {
3819
+ await mod.runDailyCommand(ctxBase);
3820
+ return;
3821
+ }
3822
+ }
3823
+
3645
3824
  if (command === "login") {
3646
3825
  await login();
3647
3826
  return;
@@ -4237,11 +4416,12 @@ async function main() {
4237
4416
  }
4238
4417
 
4239
4418
  main().catch((error) => {
4240
- const extra = error instanceof CliError ? error.payload : {};
4419
+ const normalized = normalizeCliError(error);
4420
+ const extra = normalized.payload;
4241
4421
  const payload: JsonRecord = {
4242
4422
  ok: false,
4243
4423
  ...extra,
4244
- error: error instanceof Error ? error.message : String(error),
4424
+ error: normalized.message,
4245
4425
  };
4246
4426
  if (json) {
4247
4427
  console.error(JSON.stringify(payload, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {