@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.4.6

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.
Files changed (37) hide show
  1. package/AGENTS.md +10 -84
  2. package/README.md +20 -2
  3. package/docs/architecture.md +28 -2
  4. package/package.json +4 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
  7. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  8. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  9. package/packages/extension/src/bridge-context.ts +7 -0
  10. package/packages/extension/src/bridge.ts +32 -3
  11. package/packages/extension/src/model-tracker.ts +35 -1
  12. package/packages/extension/src/prompt-bus.ts +4 -3
  13. package/packages/extension/src/session-sync.ts +1 -1
  14. package/packages/extension/src/vcs-info.ts +184 -0
  15. package/packages/server/package.json +4 -4
  16. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  17. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  18. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  19. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  20. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  21. package/packages/server/src/cli.ts +1 -0
  22. package/packages/server/src/event-wiring.ts +9 -0
  23. package/packages/server/src/openspec-tasks.ts +50 -19
  24. package/packages/server/src/routes/jj-routes.ts +386 -0
  25. package/packages/server/src/routes/session-routes.ts +12 -3
  26. package/packages/server/src/server.ts +8 -2
  27. package/packages/server/src/session-diff.ts +118 -1
  28. package/packages/shared/package.json +1 -1
  29. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  30. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  31. package/packages/shared/src/config.ts +14 -0
  32. package/packages/shared/src/diff-types.ts +17 -0
  33. package/packages/shared/src/platform/jj.ts +405 -0
  34. package/packages/shared/src/protocol.ts +14 -0
  35. package/packages/shared/src/tool-registry/definitions.ts +1 -0
  36. package/packages/shared/src/types.ts +34 -0
  37. package/packages/extension/src/git-info.ts +0 -55
@@ -52,6 +52,7 @@ import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
52
52
  import { PiCoreChecker } from "./pi-core-checker.js";
53
53
  import { PiCoreUpdater } from "./pi-core-updater.js";
54
54
  import { registerToolRoutes } from "./routes/tool-routes.js";
55
+ import { registerJjRoutes } from "./routes/jj-routes.js";
55
56
  import { registerBootstrapRoutes } from "./routes/bootstrap-routes.js";
56
57
  import { createBootstrapState, type BootstrapStateStore } from "./bootstrap-state.js";
57
58
  import { createBootstrapQueue } from "./bootstrap-queue.js";
@@ -90,6 +91,10 @@ export interface ServerConfig {
90
91
  editor: import("@blackbelt-technology/pi-dashboard-shared/config.js").EditorConfig;
91
92
  /** OpenSpec polling config (interval, concurrency, change detection, jitter) */
92
93
  openspec?: import("@blackbelt-technology/pi-dashboard-shared/config.js").OpenSpecPollConfig;
94
+ /** Reattach-placement policy applied when a bridge re-registers after
95
+ * a dashboard restart. Defaults to `"always"`.
96
+ * See change: reattach-move-to-front. */
97
+ reattachPlacement?: import("@blackbelt-technology/pi-dashboard-shared/config.js").ReattachPlacement;
93
98
  /** Merged trusted networks from config */
94
99
  resolvedTrustedNetworks?: string[];
95
100
  /** CORS allowed origins from config */
@@ -366,7 +371,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
366
371
  applyReattachPolicy(
367
372
  sessionId,
368
373
  session.cwd,
369
- config.reattachPlacement,
374
+ config.reattachPlacement ?? "always",
370
375
  { sessionManager, sessionOrderManager, browserGateway },
371
376
  ctx.priorStatus,
372
377
  );
@@ -416,7 +421,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
416
421
  applyReattachPolicy(
417
422
  sessionId,
418
423
  session.cwd,
419
- config.reattachPlacement,
424
+ config.reattachPlacement ?? "always",
420
425
  { sessionManager, sessionOrderManager, browserGateway },
421
426
  ctx.priorStatus,
422
427
  );
@@ -706,6 +711,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
706
711
  });
707
712
  registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway });
708
713
  registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
714
+ registerJjRoutes(fastify, { browserGateway, pendingAttachRegistry, networkGuard });
709
715
 
710
716
  // ── Bootstrap REST routes ────────────────────────────────────────
711
717
  // The routes module is registered here; state + queue are declared
@@ -5,7 +5,8 @@
5
5
  import { readFileSync, existsSync } from "node:fs";
6
6
  import { resolve, relative, isAbsolute, sep as pathSep } from "node:path";
7
7
  import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
8
- import type { DashboardEvent } from "@blackbelt-technology/pi-dashboard-shared/types.js";
8
+ import * as jj from "@blackbelt-technology/pi-dashboard-shared/platform/jj.js";
9
+ import type { DashboardEvent, JjState } from "@blackbelt-technology/pi-dashboard-shared/types.js";
9
10
  import type { FileChangeEvent, FileDiffEntry, EditOperation } from "@blackbelt-technology/pi-dashboard-shared/diff-types.js";
10
11
  import { isGitRepo } from "./git-operations.js";
11
12
 
@@ -176,3 +177,119 @@ export function enrichWithGitDiff(
176
177
 
177
178
  return { enrichedFiles: enriched, isGitRepo: true };
178
179
  }
180
+
181
+ // ── jj enrichment (regime-aware) ─────────────────────────────────────────
182
+
183
+ /**
184
+ * Pure helper: pick the right diff base for a given jj state.
185
+ * - default workspace → `@-` (equivalent to `git diff HEAD`)
186
+ * - non-default → `fork_point(@, trunk())`
187
+ *
188
+ * Exported for unit testing without spawning jj.
189
+ */
190
+ export function selectJjDiffBase(jjState: JjState | undefined): {
191
+ diffBase: string;
192
+ baseLabel: string;
193
+ } {
194
+ const workspace = jjState?.workspaceName;
195
+ if (!workspace || workspace === "default") {
196
+ return { diffBase: "@-", baseLabel: "@-" };
197
+ }
198
+ // Use the `..` range form (always-supported) instead of `fork_point()`
199
+ // (which changed signature across jj versions). `trunk()` returns the
200
+ // most-recent ancestor on main/master/trunk; the diff base is the
201
+ // single tip of trunk so that `--from <base> --to @` materializes the
202
+ // cumulative diff across every agent commit in this workspace.
203
+ return { diffBase: "trunk()", baseLabel: "trunk()" };
204
+ }
205
+
206
+ /**
207
+ * Enrich file entries with `jj diff` output, regime-aware. Runs
208
+ * `jj diff --from <baseRev> --to @ -- <path>` per file. Handles new
209
+ * files natively (no synthetic `/dev/null` fallback needed — jj
210
+ * reports new files in unified diff format directly).
211
+ */
212
+ export function enrichWithJjDiff(
213
+ cwd: string,
214
+ files: FileDiffEntry[],
215
+ jjState: JjState | undefined,
216
+ ): { enrichedFiles: FileDiffEntry[]; vcsKind: "jj"; diffBase: string; baseLabel: string } {
217
+ const { diffBase, baseLabel } = selectJjDiffBase(jjState);
218
+ const labelOverride = resolveBaseLabel(cwd, diffBase, baseLabel);
219
+ const enriched = files.map((file) => {
220
+ try {
221
+ const diff = jj.diffOr({
222
+ cwd,
223
+ fromRev: diffBase,
224
+ toRev: "@",
225
+ path: file.path,
226
+ }).trim();
227
+ if (diff) return { ...file, gitDiff: diff };
228
+ return file;
229
+ } catch {
230
+ return file;
231
+ }
232
+ });
233
+ return { enrichedFiles: enriched, vcsKind: "jj", diffBase, baseLabel: labelOverride };
234
+ }
235
+
236
+ /**
237
+ * Promote the abstract revset (e.g. `@-` or `fork_point(@, trunk())`) to
238
+ * a human-friendly bookmark name when one exists. Best effort — falls
239
+ * back to the abstract label if jj can't resolve it.
240
+ */
241
+ function resolveBaseLabel(cwd: string, diffBase: string, fallback: string): string {
242
+ const result = jj.logRevset({
243
+ cwd,
244
+ revset: diffBase,
245
+ template: 'bookmarks ++ "\\n"',
246
+ });
247
+ if (!result.ok) return fallback;
248
+ const first = result.value.trim().split("\n")[0]?.trim();
249
+ if (first && first.length > 0 && first.length < 100) return first;
250
+ return fallback;
251
+ }
252
+
253
+ // ── Unified dispatcher ──────────────────────────────────────────────────
254
+
255
+ export interface VcsEnrichmentResult {
256
+ enrichedFiles: FileDiffEntry[];
257
+ isGitRepo: boolean;
258
+ vcsKind?: "git" | "jj";
259
+ diffBase?: string;
260
+ baseLabel?: string;
261
+ }
262
+
263
+ /**
264
+ * Regime-aware dispatcher. When the session has `jjState.isJjRepo`,
265
+ * route through `enrichWithJjDiff` (which produces the cumulative diff
266
+ * for non-default workspaces). Otherwise fall back to the existing
267
+ * `enrichWithGitDiff` behavior unchanged — plain-git regime is byte-
268
+ * equivalent to the pre-change response shape (modulo the now-optional
269
+ * `vcsKind` field that older clients ignore).
270
+ *
271
+ * See change: add-jj-workspace-plugin.
272
+ */
273
+ export function enrichWithVcsDiff(
274
+ cwd: string,
275
+ files: FileDiffEntry[],
276
+ jjState: JjState | undefined,
277
+ ): VcsEnrichmentResult {
278
+ if (jjState?.isJjRepo) {
279
+ const result = enrichWithJjDiff(cwd, files, jjState);
280
+ return {
281
+ enrichedFiles: result.enrichedFiles,
282
+ isGitRepo: jjState.isColocated === true,
283
+ vcsKind: "jj",
284
+ diffBase: result.diffBase,
285
+ baseLabel: result.baseLabel,
286
+ };
287
+ }
288
+ const result = enrichWithGitDiff(cwd, files);
289
+ return {
290
+ ...result,
291
+ vcsKind: result.isGitRepo ? "git" : undefined,
292
+ diffBase: result.isGitRepo ? "HEAD" : undefined,
293
+ baseLabel: result.isGitRepo ? "HEAD" : undefined,
294
+ };
295
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/jj.ts — Recipe argv shapes
3
+ * and the pure `parseWorkspaceList` helper.
4
+ *
5
+ * Live integration tests (running real `jj` against a temp repo) are
6
+ * deferred to the integration-test phase; argv shape coverage here
7
+ * catches the most common refactor mistakes without requiring `jj`
8
+ * on the test runner's PATH.
9
+ *
10
+ * See change: add-jj-workspace-plugin.
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import {
14
+ JJ_VERSION,
15
+ JJ_WORKSPACE_ROOT,
16
+ JJ_WORKSPACE_LIST,
17
+ JJ_WORKSPACE_ADD,
18
+ JJ_WORKSPACE_FORGET,
19
+ JJ_BOOKMARK_CREATE,
20
+ JJ_BOOKMARK_LIST,
21
+ JJ_GIT_INIT_COLOCATE,
22
+ JJ_GIT_PUSH,
23
+ JJ_DIFF,
24
+ JJ_RESOLVE_LIST,
25
+ JJ_OP_LOG_HEAD,
26
+ JJ_OP_RESTORE,
27
+ JJ_REBASE,
28
+ JJ_LOG_REVSET,
29
+ JJ_RECIPES,
30
+ parseWorkspaceList,
31
+ findWorkspaceByName,
32
+ } from "../platform/jj.js";
33
+
34
+ // ── Argv shapes ─────────────────────────────────────────────────────────────
35
+
36
+ describe("JJ_VERSION.argv", () => {
37
+ it("is `jj --version`", () => {
38
+ expect(JJ_VERSION.argv({})).toEqual(["jj", "--version"]);
39
+ });
40
+
41
+ it("parses `jj 0.18.0` into `0.18.0`", () => {
42
+ expect(JJ_VERSION.parse("jj 0.18.0\n", {})).toBe("0.18.0");
43
+ });
44
+
45
+ it("falls back to trimmed string when version regex fails", () => {
46
+ expect(JJ_VERSION.parse("unknown-format\n", {})).toBe("unknown-format");
47
+ });
48
+ });
49
+
50
+ describe("JJ_WORKSPACE_ROOT.argv", () => {
51
+ it("is `jj workspace root`", () => {
52
+ expect(JJ_WORKSPACE_ROOT.argv({ cwd: "/tmp" })).toEqual([
53
+ "jj", "workspace", "root",
54
+ ]);
55
+ });
56
+ });
57
+
58
+ describe("JJ_WORKSPACE_LIST.argv", () => {
59
+ it("includes --no-pager", () => {
60
+ expect(JJ_WORKSPACE_LIST.argv({ cwd: "/tmp" })).toEqual([
61
+ "jj", "workspace", "list", "--no-pager",
62
+ ]);
63
+ });
64
+ });
65
+
66
+ describe("JJ_WORKSPACE_ADD.argv", () => {
67
+ it("without baseRev", () => {
68
+ expect(JJ_WORKSPACE_ADD.argv({ cwd: "/repo", destPath: "/repo/.shadow/agent-1" })).toEqual([
69
+ "jj", "workspace", "add", "/repo/.shadow/agent-1",
70
+ ]);
71
+ });
72
+
73
+ it("with baseRev", () => {
74
+ expect(JJ_WORKSPACE_ADD.argv({
75
+ cwd: "/repo",
76
+ destPath: "/repo/.shadow/agent-1",
77
+ baseRev: "develop",
78
+ })).toEqual([
79
+ "jj", "workspace", "add", "/repo/.shadow/agent-1", "-r", "develop",
80
+ ]);
81
+ });
82
+
83
+ it("path with spaces is passed verbatim (argv-array, no shell)", () => {
84
+ expect(JJ_WORKSPACE_ADD.argv({ cwd: "/repo", destPath: "/repo/my workspace" })).toEqual([
85
+ "jj", "workspace", "add", "/repo/my workspace",
86
+ ]);
87
+ });
88
+ });
89
+
90
+ describe("JJ_WORKSPACE_FORGET.argv", () => {
91
+ it("is `jj workspace forget <name>`", () => {
92
+ expect(JJ_WORKSPACE_FORGET.argv({ cwd: "/repo", name: "agent-1" })).toEqual([
93
+ "jj", "workspace", "forget", "agent-1",
94
+ ]);
95
+ });
96
+ });
97
+
98
+ describe("JJ_BOOKMARK_CREATE.argv", () => {
99
+ it("is `jj bookmark create <name> -r <rev>`", () => {
100
+ expect(JJ_BOOKMARK_CREATE.argv({ cwd: "/repo", name: "feat", rev: "@" })).toEqual([
101
+ "jj", "bookmark", "create", "feat", "-r", "@",
102
+ ]);
103
+ });
104
+ });
105
+
106
+ describe("JJ_BOOKMARK_LIST.argv", () => {
107
+ it("includes name template and --no-pager", () => {
108
+ const argv = JJ_BOOKMARK_LIST.argv({ cwd: "/repo" });
109
+ expect(argv[0]).toBe("jj");
110
+ expect(argv).toContain("bookmark");
111
+ expect(argv).toContain("list");
112
+ expect(argv).toContain("-T");
113
+ expect(argv).toContain("--no-pager");
114
+ });
115
+ });
116
+
117
+ describe("JJ_GIT_INIT_COLOCATE.argv", () => {
118
+ it("is `jj git init --colocate`", () => {
119
+ expect(JJ_GIT_INIT_COLOCATE.argv({ cwd: "/repo" })).toEqual([
120
+ "jj", "git", "init", "--colocate",
121
+ ]);
122
+ });
123
+ });
124
+
125
+ describe("JJ_GIT_PUSH.argv", () => {
126
+ it("includes --bookmark <name>", () => {
127
+ expect(JJ_GIT_PUSH.argv({ cwd: "/repo", bookmark: "feat/agent-1" })).toEqual([
128
+ "jj", "git", "push", "--bookmark", "feat/agent-1",
129
+ ]);
130
+ });
131
+ });
132
+
133
+ describe("JJ_DIFF.argv", () => {
134
+ it("default invocation has no --from/--to", () => {
135
+ expect(JJ_DIFF.argv({ cwd: "/repo" })).toEqual([
136
+ "jj", "diff", "--no-pager",
137
+ ]);
138
+ });
139
+
140
+ it("with --from and --to", () => {
141
+ expect(JJ_DIFF.argv({ cwd: "/repo", fromRev: "develop", toRev: "@" })).toEqual([
142
+ "jj", "diff", "--no-pager", "--from", "develop", "--to", "@",
143
+ ]);
144
+ });
145
+
146
+ it("with path filter", () => {
147
+ expect(JJ_DIFF.argv({
148
+ cwd: "/repo",
149
+ fromRev: "develop",
150
+ toRev: "@",
151
+ path: "src/auth.ts",
152
+ })).toEqual([
153
+ "jj", "diff", "--no-pager",
154
+ "--from", "develop",
155
+ "--to", "@",
156
+ "--", "src/auth.ts",
157
+ ]);
158
+ });
159
+
160
+ it("path-only diff (working copy)", () => {
161
+ expect(JJ_DIFF.argv({ cwd: "/repo", path: "src/auth.ts" })).toEqual([
162
+ "jj", "diff", "--no-pager", "--", "src/auth.ts",
163
+ ]);
164
+ });
165
+ });
166
+
167
+ describe("JJ_RESOLVE_LIST.argv", () => {
168
+ it("is `jj resolve --list`", () => {
169
+ expect(JJ_RESOLVE_LIST.argv({ cwd: "/repo" })).toEqual([
170
+ "jj", "resolve", "--list", "--no-pager",
171
+ ]);
172
+ });
173
+
174
+ it("tolerates exit code 1 (no conflicts)", () => {
175
+ expect(JJ_RESOLVE_LIST.tolerate).toContain(1);
176
+ });
177
+ });
178
+
179
+ describe("JJ_OP_LOG_HEAD.argv", () => {
180
+ it("includes --limit 1 and id.short() template", () => {
181
+ const argv = JJ_OP_LOG_HEAD.argv({ cwd: "/repo" });
182
+ expect(argv).toContain("op");
183
+ expect(argv).toContain("log");
184
+ expect(argv).toContain("--limit");
185
+ expect(argv).toContain("1");
186
+ expect(argv).toContain("-T");
187
+ });
188
+
189
+ it("parses single-line short id output", () => {
190
+ expect(JJ_OP_LOG_HEAD.parse("abc1234\n", { cwd: "/repo" })).toBe("abc1234");
191
+ });
192
+
193
+ it("returns undefined for empty output", () => {
194
+ expect(JJ_OP_LOG_HEAD.parse("\n", { cwd: "/repo" })).toBeUndefined();
195
+ });
196
+ });
197
+
198
+ describe("JJ_OP_RESTORE.argv", () => {
199
+ it("is `jj op restore <op-id>`", () => {
200
+ expect(JJ_OP_RESTORE.argv({ cwd: "/repo", opId: "abc1234" })).toEqual([
201
+ "jj", "op", "restore", "abc1234",
202
+ ]);
203
+ });
204
+ });
205
+
206
+ describe("JJ_REBASE.argv", () => {
207
+ it("is `jj rebase -d <dest> -s <src>`", () => {
208
+ expect(JJ_REBASE.argv({ cwd: "/repo", dest: "main", src: "agent-1" })).toEqual([
209
+ "jj", "rebase", "-d", "main", "-s", "agent-1",
210
+ ]);
211
+ });
212
+ });
213
+
214
+ describe("JJ_LOG_REVSET.argv", () => {
215
+ it("uses default change_id template", () => {
216
+ const argv = JJ_LOG_REVSET.argv({ cwd: "/repo", revset: "trunk()..@" });
217
+ expect(argv).toContain("log");
218
+ expect(argv).toContain("-r");
219
+ expect(argv).toContain("trunk()..@");
220
+ expect(argv).toContain("--no-graph");
221
+ });
222
+
223
+ it("respects custom template", () => {
224
+ const argv = JJ_LOG_REVSET.argv({
225
+ cwd: "/repo",
226
+ revset: "@",
227
+ template: 'description ++ "\\n"',
228
+ });
229
+ expect(argv).toContain('description ++ "\\n"');
230
+ });
231
+ });
232
+
233
+ describe("JJ_RECIPES registry", () => {
234
+ it("enumerates all exported recipes", () => {
235
+ const keys = Object.keys(JJ_RECIPES).sort();
236
+ expect(keys).toEqual([
237
+ "JJ_BOOKMARK_CREATE",
238
+ "JJ_BOOKMARK_LIST",
239
+ "JJ_DIFF",
240
+ "JJ_GIT_INIT_COLOCATE",
241
+ "JJ_GIT_PUSH",
242
+ "JJ_LOG_REVSET",
243
+ "JJ_OP_LOG_HEAD",
244
+ "JJ_OP_RESTORE",
245
+ "JJ_REBASE",
246
+ "JJ_RESOLVE_LIST",
247
+ "JJ_VERSION",
248
+ "JJ_WORKSPACE_ADD",
249
+ "JJ_WORKSPACE_FORGET",
250
+ "JJ_WORKSPACE_LIST",
251
+ "JJ_WORKSPACE_ROOT",
252
+ ]);
253
+ });
254
+
255
+ it("every recipe has argv and parse functions", () => {
256
+ for (const [name, recipe] of Object.entries(JJ_RECIPES)) {
257
+ expect(typeof recipe.argv, `${name}.argv`).toBe("function");
258
+ expect(typeof recipe.parse, `${name}.parse`).toBe("function");
259
+ }
260
+ });
261
+
262
+ it("every recipe's argv starts with `jj`", () => {
263
+ for (const [name, recipe] of Object.entries(JJ_RECIPES)) {
264
+ // Use a forgiving input shape — we only care about the binary name.
265
+ const argv = (recipe.argv as (i: any) => readonly string[])({
266
+ cwd: "/tmp",
267
+ destPath: "/x",
268
+ baseRev: "@",
269
+ name: "x",
270
+ rev: "@",
271
+ bookmark: "x",
272
+ opId: "x",
273
+ dest: "x",
274
+ src: "x",
275
+ revset: "@",
276
+ path: "x",
277
+ });
278
+ expect(argv[0], `${name} first arg`).toBe("jj");
279
+ }
280
+ });
281
+ });
282
+
283
+ // ── parseWorkspaceList ──────────────────────────────────────────────────────
284
+
285
+ describe("parseWorkspaceList", () => {
286
+ it("parses standard two-workspace output", () => {
287
+ const out = `default: rxnxoqlk 4f2c1234 (no description set)
288
+ agent-1: tmysxysu 0c4b5678 (empty) (no description set)
289
+ `;
290
+ expect(parseWorkspaceList(out)).toEqual([
291
+ { name: "default", changeIdShort: "rxnxoqlk", commitIdShort: "4f2c1234" },
292
+ { name: "agent-1", changeIdShort: "tmysxysu", commitIdShort: "0c4b5678" },
293
+ ]);
294
+ });
295
+
296
+ it("captures non-default descriptions", () => {
297
+ const out = `default: rxnxoqlk 4f2c1234 work in progress on auth\n`;
298
+ expect(parseWorkspaceList(out)).toEqual([
299
+ {
300
+ name: "default",
301
+ changeIdShort: "rxnxoqlk",
302
+ commitIdShort: "4f2c1234",
303
+ description: "work in progress on auth",
304
+ },
305
+ ]);
306
+ });
307
+
308
+ it("ignores blank and malformed lines", () => {
309
+ const out = `\ndefault: rxnxoqlk 4f2c1234 (no description set)\nrandom garbage\n: missing-name 1234 5678\n`;
310
+ const entries = parseWorkspaceList(out);
311
+ expect(entries.map((e) => e.name)).toEqual(["default"]);
312
+ });
313
+
314
+ it("yields entry without ids when format is unexpected", () => {
315
+ const out = `weird-name: this is not an id pair\n`;
316
+ const entries = parseWorkspaceList(out);
317
+ expect(entries).toHaveLength(1);
318
+ expect(entries[0]?.name).toBe("weird-name");
319
+ expect(entries[0]?.changeIdShort).toBeUndefined();
320
+ });
321
+
322
+ it("returns empty array for empty input", () => {
323
+ expect(parseWorkspaceList("")).toEqual([]);
324
+ });
325
+ });
326
+
327
+ describe("findWorkspaceByName", () => {
328
+ const fixtures = parseWorkspaceList(
329
+ `default: aaaa 1111 (no description set)\nagent-1: bbbb 2222 (no description set)\n`,
330
+ );
331
+
332
+ it("returns the matching entry by name", () => {
333
+ expect(findWorkspaceByName(fixtures, "agent-1")?.changeIdShort).toBe("bbbb");
334
+ });
335
+
336
+ it("returns undefined for unknown name", () => {
337
+ expect(findWorkspaceByName(fixtures, "ghost")).toBeUndefined();
338
+ });
339
+ });
@@ -179,13 +179,29 @@ describe("openspec binary definition", () => {
179
179
  });
180
180
 
181
181
  describe("registered tool set", () => {
182
- it("registers pi, pi-coding-agent, openspec, npm, node, git, zrok, wt", () => {
182
+ it("registers pi, pi-coding-agent, openspec, npm, node, git, jj, zrok, wt", () => {
183
183
  const r = freshRegistry({});
184
- for (const name of ["pi", "pi-coding-agent", "openspec", "npm", "node", "git", "zrok", "wt"]) {
184
+ for (const name of ["pi", "pi-coding-agent", "openspec", "npm", "node", "git", "jj", "zrok", "wt"]) {
185
185
  expect(r.has(name)).toBe(true);
186
186
  }
187
187
  });
188
188
 
189
+ it("jj resolves via where when found", () => {
190
+ const r = freshRegistry({
191
+ which: (name) => (name === "jj" ? "/usr/local/bin/jj" : null),
192
+ });
193
+ const res = r.resolve("jj");
194
+ expect(res.ok).toBe(true);
195
+ expect(res.path).toBe("/usr/local/bin/jj");
196
+ expect(res.source).toBe("system");
197
+ });
198
+
199
+ it("jj unavailable returns ok:false without throwing", () => {
200
+ const r = freshRegistry({ which: () => null });
201
+ const res = r.resolve("jj");
202
+ expect(res.ok).toBe(false);
203
+ });
204
+
189
205
  it("wt resolves via where when found", () => {
190
206
  const r = freshRegistry({
191
207
  platform: "win32",
@@ -134,6 +134,13 @@ export interface DashboardConfig {
134
134
  editor: EditorConfig;
135
135
  /** OpenSpec background polling behavior (interval, concurrency, change detection, jitter) */
136
136
  openspec: OpenSpecPollConfig;
137
+ /**
138
+ * Timeout for ask_user prompts in seconds.
139
+ * Default: 300 (5 minutes).
140
+ * Set to -1 (or any value <= 0) for no timeout (waits indefinitely).
141
+ * If the key is absent from config.json the default of 300 s applies.
142
+ */
143
+ askUserPromptTimeoutSeconds: number;
137
144
  /** Networks trusted for full access without authentication (CIDR, wildcard, exact IP) */
138
145
  trustedNetworks: string[];
139
146
  /** Merged trustedNetworks + auth.bypassHosts (deduplicated). Computed at load time. */
@@ -168,6 +175,9 @@ export interface CorsConfig {
168
175
 
169
176
  const VALID_SPAWN_STRATEGIES: SpawnStrategy[] = ["tmux", "headless"];
170
177
 
178
+ /** Default ask_user prompt timeout: 300 seconds (5 minutes). */
179
+ export const DEFAULT_ASK_USER_PROMPT_TIMEOUT_SECONDS = 300;
180
+
171
181
  const DEFAULTS: DashboardConfig = {
172
182
  plugins: {},
173
183
  port: 8000,
@@ -187,6 +197,7 @@ const DEFAULTS: DashboardConfig = {
187
197
  cors: { allowedOrigins: [] },
188
198
  electronMode: false,
189
199
  knownServers: [],
200
+ askUserPromptTimeoutSeconds: DEFAULT_ASK_USER_PROMPT_TIMEOUT_SECONDS,
190
201
  reattachPlacement: DEFAULT_REATTACH_PLACEMENT,
191
202
  };
192
203
 
@@ -385,6 +396,9 @@ export function loadConfig(): DashboardConfig {
385
396
  knownServers: parseKnownServers(parsed.knownServers),
386
397
  reattachPlacement: parseReattachPlacement(parsed.reattachPlacement),
387
398
  plugins: parsePluginsConfig(parsed.plugins),
399
+ askUserPromptTimeoutSeconds: typeof parsed.askUserPromptTimeoutSeconds === "number"
400
+ ? parsed.askUserPromptTimeoutSeconds
401
+ : defaults.askUserPromptTimeoutSeconds,
388
402
  };
389
403
 
390
404
  // Compute resolvedTrustedNetworks: merge trustedNetworks + auth.bypassHosts
@@ -38,4 +38,21 @@ export interface SessionDiffResponse {
38
38
  files: FileDiffEntry[];
39
39
  /** Whether the session cwd is a git repository */
40
40
  isGitRepo: boolean;
41
+ /**
42
+ * VCS regime used to compute the per-file diffs. Optional for
43
+ * backwards compatibility — absent on responses produced before
44
+ * change `add-jj-workspace-plugin`.
45
+ */
46
+ vcsKind?: "git" | "jj";
47
+ /**
48
+ * The literal revset / ref used as the diff base (e.g. "HEAD",
49
+ * "@-", "fork_point(@, trunk())"). Optional.
50
+ */
51
+ diffBase?: string;
52
+ /**
53
+ * Human-readable label for `diffBase` (e.g. "develop", "trunk()",
54
+ * "HEAD"). Optional. Renders as "Diffing against \<baseLabel\>"
55
+ * in the client when `vcsKind === "jj"`.
56
+ */
57
+ baseLabel?: string;
41
58
  }