@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
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tests for the vcs-regime-aware session-diff dispatcher.
3
+ *
4
+ * Per spec scenarios:
5
+ * - "Diff in plain git repo is unchanged" — plain-git path is byte-equivalent
6
+ * - "Diff in a workspace shows all agent commits, not just the last" —
7
+ * non-default workspace selects fork_point(@, trunk()) base
8
+ * - "Untracked file in jj path uses native jj diff output" — no synthetic fallback
9
+ *
10
+ * See change: add-jj-workspace-plugin.
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import type { JjState } from "@blackbelt-technology/pi-dashboard-shared/types.js";
14
+ import { selectJjDiffBase } from "../session-diff.js";
15
+
16
+ describe("selectJjDiffBase", () => {
17
+ it("returns @- for the default workspace", () => {
18
+ const state: JjState = {
19
+ isJjRepo: true,
20
+ isColocated: true,
21
+ workspaceName: "default",
22
+ };
23
+ expect(selectJjDiffBase(state)).toEqual({ diffBase: "@-", baseLabel: "@-" });
24
+ });
25
+
26
+ it("returns @- when workspaceName is undefined (probe mid-flight)", () => {
27
+ const state: JjState = { isJjRepo: true, isColocated: true };
28
+ expect(selectJjDiffBase(state)).toEqual({ diffBase: "@-", baseLabel: "@-" });
29
+ });
30
+
31
+ it("returns trunk() for non-default workspaces", () => {
32
+ const state: JjState = {
33
+ isJjRepo: true,
34
+ isColocated: true,
35
+ workspaceName: "agent-1",
36
+ };
37
+ // Uses the `..` range form on jj-side (--from <base> --to @) — base
38
+ // is `trunk()` so the diff materializes every agent commit in this
39
+ // workspace. `fork_point()` was avoided because its signature varies
40
+ // across jj versions (single-arg in 0.40+, two-arg in older docs).
41
+ expect(selectJjDiffBase(state)).toEqual({
42
+ diffBase: "trunk()",
43
+ baseLabel: "trunk()",
44
+ });
45
+ });
46
+
47
+ it("returns trunk() for any workspace name except 'default'", () => {
48
+ for (const name of ["feat-x", "experiment", "ws-2", "shadow-7"]) {
49
+ const result = selectJjDiffBase({
50
+ isJjRepo: true,
51
+ isColocated: true,
52
+ workspaceName: name,
53
+ });
54
+ expect(result.diffBase).toBe("trunk()");
55
+ }
56
+ });
57
+
58
+ it("returns @- when called with undefined jjState (defensive)", () => {
59
+ expect(selectJjDiffBase(undefined)).toEqual({ diffBase: "@-", baseLabel: "@-" });
60
+ });
61
+ });
@@ -25,7 +25,7 @@ function makeNoopDeps() {
25
25
  function makeFakeGateway(): { gateway: PiGateway; broadcasts: ServerToExtensionMessage[] } {
26
26
  const broadcasts: ServerToExtensionMessage[] = [];
27
27
  const gateway: PiGateway = {
28
- broadcast(msg) { broadcasts.push(msg); },
28
+ broadcast(msg: ServerToExtensionMessage) { broadcasts.push(msg); },
29
29
  sendToSession() { return false; },
30
30
  isSessionConnected() { return false; },
31
31
  connectionCount() { return 0; },
@@ -46,7 +46,7 @@ describe("POST /api/restart broadcasts server_restarting", () => {
46
46
  const fake = makeFakeGateway();
47
47
  broadcasts = fake.broadcasts;
48
48
  // process.exit is deferred via setTimeout(...,200); silence it for the test
49
- exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
49
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
50
50
  registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
51
51
  });
52
52
 
@@ -82,7 +82,7 @@ describe("POST /api/shutdown broadcasts server_restarting", () => {
82
82
  fastify = Fastify();
83
83
  const fake = makeFakeGateway();
84
84
  broadcasts = fake.broadcasts;
85
- exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
85
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
86
86
  registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
87
87
  });
88
88
 
@@ -111,7 +111,7 @@ describe("/api/restart works without piGateway (no-op broadcast)", () => {
111
111
 
112
112
  beforeEach(() => {
113
113
  fastify = Fastify();
114
- exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
114
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
115
115
  registerSystemRoutes(fastify, makeNoopDeps()); // no piGateway
116
116
  });
117
117
 
@@ -145,6 +145,7 @@ export function buildConfig(flags: Partial<ServerConfig>): ServerConfig {
145
145
  maxWsBufferBytes: fileConfig.memoryLimits.maxWsBufferBytes,
146
146
  editor: fileConfig.editor,
147
147
  openspec: fileConfig.openspec,
148
+ reattachPlacement: fileConfig.reattachPlacement,
148
149
  resolvedTrustedNetworks: fileConfig.resolvedTrustedNetworks,
149
150
  corsAllowedOrigins: fileConfig.cors.allowedOrigins,
150
151
  };
@@ -582,6 +582,15 @@ export function wireEvents(deps: EventWiringDeps): void {
582
582
  browserGateway.broadcastSessionUpdated(sessionId, gitUpdates);
583
583
  }
584
584
 
585
+ if (msg.type === "jj_state_update") {
586
+ // jjState is intentionally allowed to be `undefined` (no jj) when
587
+ // the bridge sends `null`; the session-manager update applies the
588
+ // value verbatim. See change: add-jj-workspace-plugin.
589
+ const jjUpdates = { jjState: msg.jjState ?? undefined };
590
+ sessionManager.update(sessionId, jjUpdates);
591
+ browserGateway.broadcastSessionUpdated(sessionId, jjUpdates);
592
+ }
593
+
585
594
  if (msg.type === "files_list") {
586
595
  browserGateway.sendToSubscribers(sessionId, {
587
596
  type: "files_list",
@@ -1,16 +1,23 @@
1
1
  /**
2
2
  * Parser + writer for an OpenSpec change's `tasks.md` file.
3
3
  *
4
- * `tasks.md` uses a rigid line-level format:
5
- * ## 1. Group heading
6
- * - [ ] 1.1 Task text
7
- * - [x] 1.2 Done task
4
+ * Accepted shapes (top-level only — leading whitespace is rejected):
5
+ * ## 1. Group heading (group context)
6
+ * - [ ] 1.1 Task text (id-ed: numeric `1.1`-style id)
7
+ * - [x] 1.2 Done task (id-ed, ticked)
8
+ * - [ ] Verify runner image (id-less: parser synthesizes `L<line>`)
9
+ * - [x] Add matrix row (id-less, ticked)
8
10
  *
9
- * We parse top-level `- [ ]` / `- [x]` lines only; anything else is ignored
10
- * (indented sublists, free-form prose, etc.).
11
+ * Indented sublists and free-form prose are ignored.
11
12
  *
12
- * Writes rewrite exactly one line's checkbox marker and preserve everything
13
- * else byte-for-byte; atomic via write-then-rename.
13
+ * The synthesized `L<line>` id (e.g. `L17` for the 7th line of the file) is a
14
+ * stable opaque token — it round-trips through the toggle endpoint as the
15
+ * `id` param but is NEVER written to disk. The `line` field is the actual
16
+ * byte-level optimistic-concurrency token; the id is just a cross-check.
17
+ *
18
+ * Writes rewrite exactly one line's checkbox marker character and preserve
19
+ * everything else byte-for-byte (including the original spacing between `]`
20
+ * and the id/text); atomic via write-then-rename.
14
21
  */
15
22
  import fs from "node:fs/promises";
16
23
  import path from "node:path";
@@ -46,11 +53,21 @@ export class NotACheckboxError extends Error {
46
53
  }
47
54
  }
48
55
 
49
- // Top-level checkbox: allow a single leading `- ` with optional `[ ]`/`[x]`/`[X]`,
50
- // followed by an id-like token (digits and dots) and remaining text.
51
- const CHECKBOX_RE = /^- \[([ xX])\] +([0-9]+(?:\.[0-9]+)*)\s+(.*)$/;
56
+ // Top-level checkbox with positional groups so the writer can rebuild the line
57
+ // byte-for-byte. Groups (1-indexed):
58
+ // 1: "- [" (literal prefix)
59
+ // 2: " " | "x" | "X" (the marker char — the only thing the writer flips)
60
+ // 3: "] " plus any extra spaces (literal separator, preserved verbatim)
61
+ // 4: "1.1 " (numeric id + its trailing whitespace) OR "" (id-less)
62
+ // 5: the remainder of the line (the task text)
63
+ const CHECKBOX_RE = /^(- \[)([ xX])(\] +)((?:[0-9]+(?:\.[0-9]+)* +)?)(.*)$/;
52
64
  const HEADING_RE = /^##\s+(.*)$/;
53
65
 
66
+ /** Synthesize the canonical id for an id-less line: `L<1-indexed-line>`. */
67
+ function synthesizeId(line1Indexed: number): string {
68
+ return `L${line1Indexed}`;
69
+ }
70
+
54
71
  export function parseTasksMarkdown(content: string): OpenSpecTask[] {
55
72
  // Split on \n only; trailing \r is trimmed so we handle CRLF inputs too.
56
73
  const lines = content.split("\n");
@@ -66,12 +83,15 @@ export function parseTasksMarkdown(content: string): OpenSpecTask[] {
66
83
  }
67
84
  const m = CHECKBOX_RE.exec(line);
68
85
  if (!m) continue;
69
- const done = m[1] === "x" || m[1] === "X";
86
+ const done = m[2] === "x" || m[2] === "X";
87
+ const lineNo = i + 1;
88
+ // m[4] is "" when no numeric id present, "1.1 " otherwise.
89
+ const id = m[4] ? m[4].trimEnd() : synthesizeId(lineNo);
70
90
  out.push({
71
- id: m[2],
72
- text: m[3].trim(),
91
+ id,
92
+ text: m[5].trim(),
73
93
  done,
74
- line: i + 1,
94
+ line: lineNo,
75
95
  group: currentGroup,
76
96
  });
77
97
  }
@@ -121,16 +141,27 @@ export async function toggleTask(
121
141
 
122
142
  const m = CHECKBOX_RE.exec(bare);
123
143
  if (!m) throw new NotACheckboxError();
124
- if (m[2] !== id) throw new LineMismatchError();
125
144
 
126
- const currentDone = m[1] === "x" || m[1] === "X";
145
+ // Resolve the parsed id from the source line (numeric if present, else
146
+ // synthesized `L<line>`). The caller's `id` MUST match this exactly — a
147
+ // mismatch (numeric-vs-synthetic, wrong synthetic line number, or genuinely
148
+ // wrong id) is a line-mismatch.
149
+ const parsedId = m[4] ? m[4].trimEnd() : synthesizeId(line);
150
+ if (parsedId !== id) throw new LineMismatchError();
151
+
152
+ const currentDone = m[2] === "x" || m[2] === "X";
127
153
  // Optimistic concurrency: the caller's `done` is the *target* state; the line
128
154
  // must currently hold the opposite state. If it already matches, we treat
129
155
  // that as a line-mismatch — the file changed under us.
130
156
  if (currentDone === done) throw new LineMismatchError();
131
157
 
132
158
  const marker = done ? "x" : " ";
133
- const rewritten = bare.replace(CHECKBOX_RE, `- [${marker}] ${m[2]} ${m[3]}`);
159
+ // Byte-for-byte rewrite: swap ONLY the marker char in group 2; preserve
160
+ // group 1 (prefix), group 3 (separator + any extra spaces), group 4 (id +
161
+ // trailing space, possibly empty for id-less lines), and group 5 (text).
162
+ // This guarantees id-less lines do not acquire a synthetic id in the file,
163
+ // and id-ed lines retain their exact spacing.
164
+ const rewritten = m[1] + marker + m[3] + m[4] + m[5];
134
165
  lines[idx] = hadCR ? rewritten + "\r" : rewritten;
135
166
 
136
167
  const newContent = lines.join("\n");
@@ -140,7 +171,7 @@ export async function toggleTask(
140
171
 
141
172
  return {
142
173
  id,
143
- text: m[3].trim(),
174
+ text: m[5].trim(),
144
175
  done,
145
176
  line,
146
177
  group: findGroupForLine(lines, idx),
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Jujutsu (jj) REST API routes (localhost-only).
3
+ *
4
+ * Endpoints:
5
+ * POST /api/jj/workspace/add — create workspace + spawn session
6
+ * POST /api/jj/workspace/forget — refuses on unfolded work; force escape
7
+ * POST /api/jj/init-colocated — refuses on dirty git index
8
+ * GET /api/jj/workspace/list — enumerate workspaces under cwd
9
+ *
10
+ * All endpoints are network-guarded. Workspace add reuses the same
11
+ * pending-attach + spawnPiSession lever as the OpenSpec attach-and-spawn
12
+ * flow. See changes: add-jj-workspace-plugin, add-folder-task-checker-and-spawn-attach.
13
+ */
14
+ import path from "node:path";
15
+ import fs from "node:fs/promises";
16
+ import { existsSync } from "node:fs";
17
+ import type { FastifyInstance } from "fastify";
18
+ import * as jj from "@blackbelt-technology/pi-dashboard-shared/platform/jj.js";
19
+ import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
20
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
21
+ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
22
+ import type { BrowserGateway } from "../browser-gateway.js";
23
+ import type { PendingAttachRegistry } from "../pending-attach-registry.js";
24
+ import { spawnPiSession } from "../process-manager.js";
25
+ import type { NetworkGuard } from "./route-deps.js";
26
+ import { safeRealpathSync } from "../resolve-path.js";
27
+
28
+ /** Workspace name regex per spec (filesystem + bookmark safety). */
29
+ const NAME_RE = /^[a-z0-9-]+$/;
30
+
31
+ export interface JjRoutesDeps {
32
+ browserGateway: BrowserGateway;
33
+ pendingAttachRegistry: PendingAttachRegistry;
34
+ networkGuard: NetworkGuard;
35
+ /** Optional plugin config accessor (defaults to current dashboard config). */
36
+ getWorkspaceRoot?: () => string;
37
+ }
38
+
39
+ /**
40
+ * Resolve the workspace-root setting for a given repo. Currently global
41
+ * via the plugin config; per-repo override is explicitly out of scope
42
+ * (Decision 14). Falls back to `.shadow` when config is absent.
43
+ */
44
+ function resolveWorkspaceRoot(deps: JjRoutesDeps): string {
45
+ if (deps.getWorkspaceRoot) return deps.getWorkspaceRoot();
46
+ // The plugin config is read from the dashboard config blob's `plugins.jj`
47
+ // namespace. Until the runtime config-validator wires that path here, we
48
+ // fall back to the documented default.
49
+ try {
50
+ const cfg = loadConfig() as unknown as { plugins?: { jj?: { workspaceRoot?: string } } };
51
+ return cfg.plugins?.jj?.workspaceRoot ?? ".shadow";
52
+ } catch {
53
+ return ".shadow";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Pure preflight checks for `init-colocated`. Returns `null` on OK,
59
+ * else a `{ code, message }` object the caller can shape into 4xx.
60
+ */
61
+ export function checkInitColocatedPreconditions(cwd: string):
62
+ | null
63
+ | { code: "INVALID_CWD" | "ALREADY_JJ" | "DIRTY_INDEX" | "NOT_GIT_REPO"; message: string } {
64
+ if (!cwd) return { code: "INVALID_CWD", message: "cwd is required" };
65
+ if (!existsSync(cwd)) return { code: "INVALID_CWD", message: `cwd does not exist: ${cwd}` };
66
+ if (existsSync(path.join(cwd, ".jj"))) {
67
+ return { code: "ALREADY_JJ", message: "cwd is already a jj repo" };
68
+ }
69
+ if (!existsSync(path.join(cwd, ".git"))) {
70
+ return { code: "NOT_GIT_REPO", message: "cwd is not a git repo" };
71
+ }
72
+ // git diff --cached --quiet exits 1 when index is dirty. Recipe-based
73
+ // helper for clarity and consistency with the rest of the codebase.
74
+ const indexResult = git.statusPorcelain({ cwd });
75
+ if (indexResult.ok) {
76
+ // Lines beginning with M, A, D, R, C, U in column 1 indicate INDEX
77
+ // changes (column 2 is the working tree). We refuse on any column-1
78
+ // mutation.
79
+ const dirty = indexResult.value
80
+ .split("\n")
81
+ .filter((l) => l.length >= 2 && /[MADRCU]/.test(l[0]!));
82
+ if (dirty.length > 0) {
83
+ return {
84
+ code: "DIRTY_INDEX",
85
+ message:
86
+ `git index has staged changes (${dirty.length} entr${dirty.length === 1 ? "y" : "ies"}); ` +
87
+ `commit or 'git reset' first. See spec scenario "Init refused on dirty index".`,
88
+ };
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export function registerJjRoutes(fastify: FastifyInstance, deps: JjRoutesDeps) {
95
+ const { browserGateway, pendingAttachRegistry, networkGuard } = deps;
96
+
97
+ // ── GET /api/jj/workspace/list?cwd=… ────────────────────────────────────
98
+ fastify.get<{ Querystring: { cwd?: string } }>(
99
+ "/api/jj/workspace/list",
100
+ { preHandler: networkGuard },
101
+ async (request, reply) => {
102
+ const cwd = request.query.cwd;
103
+ if (!cwd) {
104
+ reply.code(400);
105
+ return { success: false, error: "cwd is required" } satisfies ApiResponse;
106
+ }
107
+ if (!existsSync(path.join(cwd, ".jj"))) {
108
+ return { success: true, data: { workspaces: [] } } satisfies ApiResponse;
109
+ }
110
+ const result = jj.workspaceList({ cwd });
111
+ if (!result.ok) {
112
+ reply.code(500);
113
+ return {
114
+ success: false,
115
+ error: `jj workspace list failed: ${describeError(result.error)}`,
116
+ } satisfies ApiResponse;
117
+ }
118
+ const workspaces = jj.parseWorkspaceList(result.value);
119
+ return { success: true, data: { workspaces } } satisfies ApiResponse;
120
+ },
121
+ );
122
+
123
+ // ── POST /api/jj/workspace/add ──────────────────────────────────────────
124
+ fastify.post<{
125
+ Body: { fromCwd?: string; name?: string; baseRev?: string; taskDescription?: string };
126
+ }>(
127
+ "/api/jj/workspace/add",
128
+ { preHandler: networkGuard },
129
+ async (request, reply) => {
130
+ const { fromCwd, name, baseRev, taskDescription } = request.body ?? {};
131
+
132
+ if (!fromCwd) {
133
+ reply.code(400);
134
+ return { success: false, error: "fromCwd is required" } satisfies ApiResponse;
135
+ }
136
+ if (!name || !NAME_RE.test(name)) {
137
+ reply.code(400);
138
+ return {
139
+ success: false,
140
+ error: "INVALID_NAME: name must match /^[a-z0-9-]+$/",
141
+ } satisfies ApiResponse;
142
+ }
143
+ if (!existsSync(path.join(fromCwd, ".jj"))) {
144
+ reply.code(400);
145
+ return {
146
+ success: false,
147
+ error: "fromCwd is not a jj repo",
148
+ } satisfies ApiResponse;
149
+ }
150
+
151
+ const workspaceRoot = resolveWorkspaceRoot(deps);
152
+ const destPath = path.join(fromCwd, workspaceRoot, name);
153
+ if (existsSync(destPath)) {
154
+ reply.code(409);
155
+ return {
156
+ success: false,
157
+ error: `destination already exists: ${destPath}`,
158
+ } satisfies ApiResponse;
159
+ }
160
+ // Ensure the workspace-root parent directory exists. `jj workspace
161
+ // add` does NOT create intermediate dirs and fails with
162
+ // "Cannot access <path>" on a missing parent. mkdir -p is safe and
163
+ // idempotent. The .shadow root should be in .gitignore (the spec's
164
+ // FolderOpenSpecSection-style hint is tracked as follow-up).
165
+ const parentDir = path.dirname(destPath);
166
+ try {
167
+ await fs.mkdir(parentDir, { recursive: true });
168
+ } catch (err) {
169
+ reply.code(500);
170
+ return {
171
+ success: false,
172
+ error: `failed to create workspace parent dir ${parentDir}: ${err instanceof Error ? err.message : String(err)}`,
173
+ } satisfies ApiResponse;
174
+ }
175
+
176
+ // Resolve the base revision when omitted: current bookmark of fromCwd's
177
+ // working copy, falling back to `trunk()` revset.
178
+ let resolvedBase = baseRev;
179
+ if (!resolvedBase) {
180
+ const bookmarksResult = jj.logRevset({
181
+ cwd: fromCwd,
182
+ revset: "@",
183
+ template: 'bookmarks ++ "\\n"',
184
+ });
185
+ if (bookmarksResult.ok) {
186
+ const first = bookmarksResult.value.trim().split("\n")[0]?.trim();
187
+ if (first) resolvedBase = first;
188
+ }
189
+ if (!resolvedBase) resolvedBase = "trunk()";
190
+ }
191
+
192
+ const addResult = jj.workspaceAdd({
193
+ cwd: fromCwd,
194
+ destPath,
195
+ baseRev: resolvedBase,
196
+ });
197
+ if (!addResult.ok) {
198
+ reply.code(500);
199
+ return {
200
+ success: false,
201
+ error: `jj workspace add failed: ${describeError(addResult.error)}`,
202
+ } satisfies ApiResponse;
203
+ }
204
+
205
+ const realDestPath = safeRealpathSync(destPath);
206
+ pendingAttachRegistry.enqueue(realDestPath, name);
207
+
208
+ // Spawn a session in the new workspace. Mirrors the OpenSpec
209
+ // attach-and-spawn flow; the bridge's `session_register` will
210
+ // consume the pending-attach intent and apply the auto-rename.
211
+ try {
212
+ const config = loadConfig();
213
+ const spawnResult = await spawnPiSession(realDestPath, {
214
+ strategy: config.spawnStrategy,
215
+ });
216
+ if (spawnResult.process && spawnResult.pid) {
217
+ browserGateway.headlessPidRegistry.register(
218
+ spawnResult.pid,
219
+ realDestPath,
220
+ spawnResult.process,
221
+ );
222
+ }
223
+ if (!spawnResult.success) {
224
+ reply.code(202);
225
+ return {
226
+ success: true,
227
+ data: {
228
+ workspacePath: realDestPath,
229
+ spawned: false,
230
+ spawnMessage: spawnResult.message,
231
+ },
232
+ } satisfies ApiResponse;
233
+ }
234
+ return {
235
+ success: true,
236
+ data: {
237
+ workspacePath: realDestPath,
238
+ spawned: true,
239
+ taskDescription: taskDescription ?? null,
240
+ },
241
+ } satisfies ApiResponse;
242
+ } catch (err) {
243
+ reply.code(202);
244
+ return {
245
+ success: true,
246
+ data: {
247
+ workspacePath: realDestPath,
248
+ spawned: false,
249
+ spawnMessage: err instanceof Error ? err.message : String(err),
250
+ },
251
+ } satisfies ApiResponse;
252
+ }
253
+ },
254
+ );
255
+
256
+ // ── POST /api/jj/workspace/forget ───────────────────────────────────────
257
+ fastify.post<{
258
+ Body: { cwd?: string; name?: string; force?: boolean };
259
+ }>(
260
+ "/api/jj/workspace/forget",
261
+ { preHandler: networkGuard },
262
+ async (request, reply) => {
263
+ const { cwd, name, force } = request.body ?? {};
264
+
265
+ if (!cwd) {
266
+ reply.code(400);
267
+ return { success: false, error: "cwd is required" } satisfies ApiResponse;
268
+ }
269
+ if (!name || !NAME_RE.test(name)) {
270
+ reply.code(400);
271
+ return {
272
+ success: false,
273
+ error: "INVALID_NAME: name must match /^[a-z0-9-]+$/",
274
+ } satisfies ApiResponse;
275
+ }
276
+ if (!existsSync(path.join(cwd, ".jj"))) {
277
+ reply.code(400);
278
+ return {
279
+ success: false,
280
+ error: "cwd is not a jj repo",
281
+ } satisfies ApiResponse;
282
+ }
283
+
284
+ // Inspect for unfolded commits: anything in the workspace's `@`
285
+ // that isn't an ancestor of trunk. `trunk()..<name>@` is the
286
+ // straight-line revset for that; we filter out empty changes
287
+ // (`~empty()`) so the empty `@` of a freshly-created workspace
288
+ // doesn't trigger the unfolded-work refusal.
289
+ // Note: jj 0.40's `fork_point()` takes a single revset; we use
290
+ // the simpler `..` range form which works on every supported jj.
291
+ let unfolded: string[] = [];
292
+ const logResult = jj.logRevset({
293
+ cwd,
294
+ revset: `trunk()..${name}@ & ~empty()`,
295
+ template: 'change_id.short() ++ " " ++ description.first_line() ++ "\\n"',
296
+ });
297
+ if (logResult.ok) {
298
+ unfolded = logResult.value
299
+ .split("\n")
300
+ .map((l) => l.trim())
301
+ .filter(Boolean);
302
+ }
303
+ // A failed revset (e.g. unknown bookmark / fork_point unsupported) is
304
+ // *not* sufficient to skip the safety check — refuse with a generic
305
+ // error so the user sees the underlying jj message.
306
+ if (!logResult.ok) {
307
+ reply.code(500);
308
+ return {
309
+ success: false,
310
+ error: `jj log probe failed: ${describeError(logResult.error)}`,
311
+ } satisfies ApiResponse;
312
+ }
313
+
314
+ if (unfolded.length > 0 && !force) {
315
+ reply.code(409);
316
+ return {
317
+ success: false,
318
+ error: "UNFOLDED_WORK",
319
+ data: { unfolded },
320
+ } as unknown as ApiResponse;
321
+ }
322
+
323
+ // Forget + remove directory.
324
+ const forgetResult = jj.workspaceForget({ cwd, name });
325
+ if (!forgetResult.ok) {
326
+ reply.code(500);
327
+ return {
328
+ success: false,
329
+ error: `jj workspace forget failed: ${describeError(forgetResult.error)}`,
330
+ } satisfies ApiResponse;
331
+ }
332
+
333
+ const workspaceRoot = resolveWorkspaceRoot(deps);
334
+ const dirPath = path.join(cwd, workspaceRoot, name);
335
+ try {
336
+ await fs.rm(dirPath, { recursive: true, force: true });
337
+ } catch (err) {
338
+ // Forget already succeeded; surface the rm error but don't fail
339
+ // the operation overall — the workspace is gone from jj's view.
340
+ request.log.warn(
341
+ `jj workspace dir cleanup failed (${dirPath}): ${err instanceof Error ? err.message : String(err)}`,
342
+ );
343
+ }
344
+
345
+ return { success: true, data: { name, force: Boolean(force) } } satisfies ApiResponse;
346
+ },
347
+ );
348
+
349
+ // ── POST /api/jj/init-colocated ─────────────────────────────────────────
350
+ fastify.post<{ Body: { cwd?: string } }>(
351
+ "/api/jj/init-colocated",
352
+ { preHandler: networkGuard },
353
+ async (request, reply) => {
354
+ const { cwd } = request.body ?? {};
355
+ const precheck = checkInitColocatedPreconditions(cwd ?? "");
356
+ if (precheck) {
357
+ reply.code(precheck.code === "DIRTY_INDEX" ? 409 : 400);
358
+ return {
359
+ success: false,
360
+ error: precheck.code,
361
+ data: { message: precheck.message },
362
+ } as unknown as ApiResponse;
363
+ }
364
+ const result = jj.gitInitColocate({ cwd: cwd! });
365
+ if (!result.ok) {
366
+ reply.code(500);
367
+ return {
368
+ success: false,
369
+ error: `jj git init --colocate failed: ${describeError(result.error)}`,
370
+ } satisfies ApiResponse;
371
+ }
372
+ return { success: true, data: { cwd } } satisfies ApiResponse;
373
+ },
374
+ );
375
+ }
376
+
377
+ function describeError(error: { kind: string; [k: string]: unknown }): string {
378
+ if (error.kind === "not-found") return `binary not found: ${String(error.binary ?? "jj")}`;
379
+ if (error.kind === "timeout") return `timed out after ${String(error.timeoutMs)}ms`;
380
+ if (error.kind === "exit") {
381
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
382
+ return stderr.split("\n")[0] || `exited ${String(error.code)}`;
383
+ }
384
+ if (error.kind === "spawn-failure") return String(error.message ?? "spawn failed");
385
+ return error.kind;
386
+ }
@@ -8,7 +8,7 @@ import type { SessionManager } from "../memory-session-manager.js";
8
8
  import type { EventStore } from "../memory-event-store.js";
9
9
  import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
10
10
  import type { NetworkGuard } from "./route-deps.js";
11
- import { extractFileChanges, enrichWithGitDiff } from "../session-diff.js";
11
+ import { extractFileChanges, enrichWithVcsDiff } from "../session-diff.js";
12
12
 
13
13
  export function registerSessionRoutes(
14
14
  fastify: FastifyInstance,
@@ -52,8 +52,17 @@ export function registerSessionRoutes(
52
52
  }
53
53
  const events = eventStore.getEvents(sessionId, 0).map((e) => e.event);
54
54
  const files = extractFileChanges(events, session.cwd);
55
- const { enrichedFiles, isGitRepo: isGit } = enrichWithGitDiff(session.cwd, files);
56
- return { success: true, data: { files: enrichedFiles, isGitRepo: isGit } } satisfies ApiResponse;
55
+ const result = enrichWithVcsDiff(session.cwd, files, session.jjState);
56
+ return {
57
+ success: true,
58
+ data: {
59
+ files: result.enrichedFiles,
60
+ isGitRepo: result.isGitRepo,
61
+ vcsKind: result.vcsKind,
62
+ diffBase: result.diffBase,
63
+ baseLabel: result.baseLabel,
64
+ },
65
+ } satisfies ApiResponse;
57
66
  },
58
67
  );
59
68