@dench.com/cli 0.3.7 → 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
+ }
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 {};
@@ -461,8 +496,12 @@ Past chat threads (own + shared in this workspace):
461
496
  Spawn a new chat-turn workflow (same as the web UI):
462
497
  dench chat new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
463
498
  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)
499
+ dench chat follow <thread-id> [--json] (tail a live thread by id)
500
+ dench chat tree [<root-thread-id>] [--limit N] [--json]
501
+ (parent-child thread tree)
502
+ dench agent new ... (alias for dench chat new)
503
+ dench agent send ... (alias for dench chat send)
504
+ dench agent follow ... (alias for dench chat follow)
466
505
 
467
506
  Help: dench chat help
468
507
 
@@ -1755,6 +1794,30 @@ async function getRuntime() {
1755
1794
  });
1756
1795
  }
1757
1796
 
1797
+ // Sandbox session-mode path: workflow minting provisions a real
1798
+ // dch_agent_* token at sandbox-create time. Prefer it over apiKey so
1799
+ // agentWorkspace.* commands (Composio tools) work inside sandboxes.
1800
+ const sandboxAgentToken = process.env.DENCH_AGENT_SESSION_TOKEN?.trim();
1801
+ const sandboxConvexUrl = resolveApiKeyConvexUrl();
1802
+ if (sandboxAgentToken && sandboxConvexUrl) {
1803
+ const orgId = process.env.DENCH_ORG_ID?.trim();
1804
+ const orgSlug = process.env.DENCH_ORG_SLUG?.trim();
1805
+ const apiHost =
1806
+ process.env.DENCH_API_URL?.trim() ||
1807
+ process.env.DENCH_INTERNAL_BASE?.trim() ||
1808
+ host;
1809
+ return {
1810
+ mode: "session" as const,
1811
+ host: apiHost,
1812
+ client: new ConvexHttpClient(sandboxConvexUrl),
1813
+ sessionToken: sandboxAgentToken,
1814
+ organization:
1815
+ orgId && orgSlug
1816
+ ? { id: orgId, name: orgSlug, slug: orgSlug }
1817
+ : undefined,
1818
+ };
1819
+ }
1820
+
1758
1821
  // Sandbox / automation path: the unified Dench API key + Convex URL
1759
1822
  // baked into sandbox envVars (see
1760
1823
  // src/lib/secrets/sandbox-tokens.ts) is enough to drive every
@@ -1781,10 +1844,12 @@ async function getRuntime() {
1781
1844
  : undefined,
1782
1845
  };
1783
1846
  }
1847
+
1848
+ throw loginRequiredError();
1784
1849
  }
1785
1850
 
1786
1851
  if (!hasDevEnv()) {
1787
- throw hasFlag("--dev") ? missingDevEnvError() : loginRequiredError();
1852
+ throw missingDevEnvError();
1788
1853
  }
1789
1854
 
1790
1855
  return {
@@ -1799,6 +1864,22 @@ async function getRuntime() {
1799
1864
 
1800
1865
  type Runtime = Awaited<ReturnType<typeof getRuntime>>;
1801
1866
 
1867
+ function stripRuntimeFlags(args: string[]) {
1868
+ const booleanFlags = new Set(["--dev", "--prod", "--staging"]);
1869
+ const valueFlags = new Set(["--convex-url", "--host"]);
1870
+ const stripped: string[] = [];
1871
+ for (let i = 0; i < args.length; i++) {
1872
+ const arg = args[i];
1873
+ if (booleanFlags.has(arg)) continue;
1874
+ if (valueFlags.has(arg)) {
1875
+ i++;
1876
+ continue;
1877
+ }
1878
+ stripped.push(arg);
1879
+ }
1880
+ return stripped;
1881
+ }
1882
+
1802
1883
  /**
1803
1884
  * `session` mode is required for `agentWorkspace.*` calls because they
1804
1885
  * gate on `validateAgentSession` server-side, which only accepts the
@@ -1837,6 +1918,13 @@ function requireAuthenticatedRuntime(
1837
1918
  throw loginRequiredError(command);
1838
1919
  }
1839
1920
 
1921
+ function encodeDevCrmSessionToken(args: {
1922
+ workspaceSlug: string;
1923
+ devKey: string;
1924
+ }) {
1925
+ return `dch_dev_${encodeURIComponent(args.workspaceSlug)}:${encodeURIComponent(args.devKey)}`;
1926
+ }
1927
+
1840
1928
  function agentSessionRequiredError(command: string) {
1841
1929
  return new CliError(
1842
1930
  `${command} requires an agent session — DENCH_API_KEY is org-scoped and cannot impersonate a specific agent`,
@@ -3458,7 +3546,7 @@ async function main() {
3458
3546
  }
3459
3547
 
3460
3548
  if (command === "crm") {
3461
- const subArgs = args.slice(args.indexOf("crm") + 1);
3549
+ const subArgs = stripRuntimeFlags(args.slice(args.indexOf("crm") + 1));
3462
3550
  const sub = subArgs[0];
3463
3551
  // Short-circuit help so users don't need a session to read the
3464
3552
  // surface. The crm dispatcher's own help resolves --json + the
@@ -3478,6 +3566,14 @@ async function main() {
3478
3566
  // (`DENCH_API_KEY` baked into sandbox envVars). Server-side
3479
3567
  // `requireCrmAccess` (convex/lib/crm/access.ts) dispatches on the
3480
3568
  // bearer prefix and resolves the org for both.
3569
+ if (runtime.mode === "dev") {
3570
+ await runCrmCommand({
3571
+ convex: runtime.client,
3572
+ args: subArgs,
3573
+ sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs),
3574
+ });
3575
+ return;
3576
+ }
3481
3577
  const authedRuntime = requireAuthenticatedRuntime(runtime, "dench crm");
3482
3578
  await runCrmCommand({
3483
3579
  convex: authedRuntime.client,
@@ -3534,12 +3630,13 @@ async function main() {
3534
3630
  await runChatCommand({ convex: {} as any, args: subArgs });
3535
3631
  return;
3536
3632
  }
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") {
3633
+ // `chat new` / `chat send` / `chat follow` use HTTP routes
3634
+ // (/api/chat/cli for spawn, /api/chat/[runId]/stream and
3635
+ // /api/chat/stream for follow) so they need an authenticated host
3636
+ // + bearer token, not a Convex client. The remaining subcommands
3637
+ // (list / search / read / tree) are Convex queries and keep going
3638
+ // through `runChatCommand`.
3639
+ if (sub === "new" || sub === "send" || sub === "follow") {
3543
3640
  const { runChatSpawnCommand } = await import("./chat-spawn");
3544
3641
  const runtime = await getRuntime();
3545
3642
  const authedRuntime = requireAuthenticatedRuntime(
@@ -3583,11 +3680,12 @@ async function main() {
3583
3680
  await runAgentCommand({ convex: {} as any, args: subArgs });
3584
3681
  return;
3585
3682
  }
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") {
3683
+ // Aliases: `dench agent new` / `dench agent send` / `dench agent
3684
+ // follow` -> the chat-spawn implementation. Surfaced under
3685
+ // `agent` so users who associate chat threads with the agent
3686
+ // surface still find them; the actual workflow is identical to
3687
+ // `dench chat new` / `dench chat send` / `dench chat follow`.
3688
+ if (sub === "new" || sub === "send" || sub === "follow") {
3591
3689
  const { runChatSpawnCommand } = await import("./chat-spawn");
3592
3690
  const runtime = await getRuntime();
3593
3691
  const authedRuntime = requireAuthenticatedRuntime(
@@ -3609,6 +3707,8 @@ async function main() {
3609
3707
  await runAgentCommand({
3610
3708
  convex: runtime.client,
3611
3709
  args: subArgs,
3710
+ sessionToken: runtime.sessionToken,
3711
+ callerRunId: process.env.DENCH_RUN_ID?.trim() || undefined,
3612
3712
  });
3613
3713
  return;
3614
3714
  }
@@ -4237,11 +4337,12 @@ async function main() {
4237
4337
  }
4238
4338
 
4239
4339
  main().catch((error) => {
4240
- const extra = error instanceof CliError ? error.payload : {};
4340
+ const normalized = normalizeCliError(error);
4341
+ const extra = normalized.payload;
4241
4342
  const payload: JsonRecord = {
4242
4343
  ok: false,
4243
4344
  ...extra,
4244
- error: error instanceof Error ? error.message : String(error),
4345
+ error: normalized.message,
4245
4346
  };
4246
4347
  if (json) {
4247
4348
  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.3.8",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {