@dench.com/cli 0.3.6 → 0.3.8

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
@@ -111,7 +111,12 @@ Read messages from a specific past chat thread:
111
111
  Spawn a new chat-turn workflow (same agent loop as the web UI):
112
112
  dench chat new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
113
113
  dench chat send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
114
- Aliased as: dench agent new / dench agent send.
114
+ dench chat follow <thread-id> [--json]
115
+ dench chat tree [<root-thread-id>] [--limit N] [--json]
116
+ Aliased as: dench agent new / dench agent send / dench agent follow.
117
+ Use \`follow\` to tail any live thread by id (e.g. a subagent
118
+ spawned by another chat). Use \`tree\` to print the parent-child
119
+ thread hierarchy from a root thread.
115
120
 
116
121
  Approval policy:
117
122
  Without --yolo the workflow uses the workspace policy from
@@ -156,6 +161,8 @@ export async function runChatCommand(opts: {
156
161
  return await runChatSearchCommand(ctx);
157
162
  case "read":
158
163
  return await runChatReadCommand(ctx);
164
+ case "tree":
165
+ return await runChatTreeCommand(ctx);
159
166
  default:
160
167
  throw new ChatCliError(`Unknown chat subcommand: ${subcommand}`);
161
168
  }
@@ -214,3 +221,129 @@ async function runChatReadCommand(ctx: ChatCliContext): Promise<void> {
214
221
  }),
215
222
  );
216
223
  }
224
+
225
+ /**
226
+ * `dench chat tree [<root-thread-id>] [--limit N] [--json]` — print the
227
+ * parent-child chat thread hierarchy rooted at the given thread (or
228
+ * roots = top-level chats if no id is passed).
229
+ *
230
+ * Uses `listPastChats` (which already includes nested threads in its
231
+ * results — `topLevelOnly` is a `listThreads`-only knob) and groups
232
+ * client-side by `parentThreadId`. With `--json`, prints the raw flat
233
+ * array; otherwise prints an indented tree with status + a short
234
+ * summary excerpt for each row.
235
+ */
236
+ async function runChatTreeCommand(ctx: ChatCliContext): Promise<void> {
237
+ // Strip flag values from positionals so e.g. `tree --limit 30` doesn't
238
+ // misinterpret "30" as the rootThreadId. We treat "--key value" as a
239
+ // pair iff value doesn't itself start with "--".
240
+ const positionals: string[] = [];
241
+ for (let i = 0; i < ctx.args.length; i++) {
242
+ const arg = ctx.args[i];
243
+ if (arg.startsWith("--")) {
244
+ const next = ctx.args[i + 1];
245
+ if (next !== undefined && !next.startsWith("--")) {
246
+ i += 1; // skip the flag value
247
+ }
248
+ continue;
249
+ }
250
+ positionals.push(arg);
251
+ }
252
+ const rootThreadId = positionals[0]?.trim() || undefined;
253
+ const limit = parseLimit(getFlag(ctx.args, "--limit")) ?? 200;
254
+
255
+ const flat = (await callQuery(ctx, api.listPastChats, {
256
+ limit,
257
+ includeArchived: false,
258
+ })) as Array<{
259
+ threadId: string;
260
+ title: string;
261
+ status: string;
262
+ parentThreadId: string | null;
263
+ nestingDepth: number | null;
264
+ summary: string | null;
265
+ lastMessageAt: number;
266
+ createdAt: number;
267
+ activeRunId: string | null;
268
+ }>;
269
+
270
+ if (ctx.jsonOutput) {
271
+ out(ctx, flat);
272
+ return;
273
+ }
274
+
275
+ // Group by parent. Top-level rows = either no parent, or parent
276
+ // outside the loaded set (so we don't silently swallow children
277
+ // whose parent fell off the limit window).
278
+ const ids = new Set(flat.map((t) => t.threadId));
279
+ const childrenByParent = new Map<string, typeof flat>();
280
+ const roots: typeof flat = [];
281
+ for (const thread of flat) {
282
+ if (thread.parentThreadId && ids.has(thread.parentThreadId)) {
283
+ let list = childrenByParent.get(thread.parentThreadId);
284
+ if (!list) {
285
+ list = [];
286
+ childrenByParent.set(thread.parentThreadId, list);
287
+ }
288
+ list.push(thread);
289
+ continue;
290
+ }
291
+ if (rootThreadId) {
292
+ if (thread.threadId === rootThreadId) roots.push(thread);
293
+ } else {
294
+ roots.push(thread);
295
+ }
296
+ }
297
+ // When the user passed an explicit `rootThreadId` but it didn't
298
+ // appear in the loaded set (e.g. it sits beyond `--limit`), pull
299
+ // it on its own so we still surface it as the single root.
300
+ if (rootThreadId && roots.length === 0) {
301
+ const explicit = flat.find((t) => t.threadId === rootThreadId);
302
+ if (explicit) roots.push(explicit);
303
+ }
304
+ // Sort children oldest-first within each parent (spawn order),
305
+ // roots newest-first (most recent root at the top).
306
+ for (const list of childrenByParent.values()) {
307
+ list.sort((a, b) => (a.createdAt || a.lastMessageAt) - (b.createdAt || b.lastMessageAt));
308
+ }
309
+ roots.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
310
+
311
+ if (roots.length === 0) {
312
+ if (rootThreadId) {
313
+ console.error(
314
+ `No thread found with id ${rootThreadId} (or it's outside the limit ` +
315
+ `${limit} window — increase --limit).`,
316
+ );
317
+ } else {
318
+ console.error("No chat threads in this workspace yet.");
319
+ }
320
+ return;
321
+ }
322
+
323
+ const stripe = (s: string, n: number) =>
324
+ s.length > n ? `${s.slice(0, n - 1)}…` : s;
325
+ const print = (thread: (typeof flat)[number], depth: number): void => {
326
+ const indent = " ".repeat(depth);
327
+ const branch = depth > 0 ? "└─ " : "";
328
+ const status =
329
+ thread.status === "streaming"
330
+ ? "▸ running"
331
+ : thread.status === "error"
332
+ ? "✗ error"
333
+ : thread.summary
334
+ ? "✓ done"
335
+ : "·";
336
+ process.stdout.write(
337
+ `${indent}${branch}[${status}] ${stripe(thread.title || "Untitled", 60)} ` +
338
+ `(${thread.threadId})\n`,
339
+ );
340
+ if (thread.summary) {
341
+ process.stdout.write(
342
+ `${indent}${depth > 0 ? " " : ""}${stripe(thread.summary.replace(/\s+/g, " "), 80)}\n`,
343
+ );
344
+ }
345
+ const direct = childrenByParent.get(thread.threadId) ?? [];
346
+ for (const child of direct) print(child, depth + 1);
347
+ };
348
+ for (const root of roots) print(root, 0);
349
+ }