@blackbelt-technology/pi-agent-dashboard 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/AGENTS.md +114 -9
  2. package/README.md +218 -97
  3. package/docs/architecture.md +107 -7
  4. package/package.json +9 -4
  5. package/packages/extension/package.json +1 -1
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  8. package/packages/extension/src/ask-user-tool.ts +289 -20
  9. package/packages/extension/src/bridge.ts +38 -4
  10. package/packages/extension/src/command-handler.ts +34 -39
  11. package/packages/extension/src/prompt-expander.ts +25 -4
  12. package/packages/server/package.json +2 -1
  13. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  14. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  15. package/packages/server/src/__tests__/browse-endpoint.test.ts +229 -10
  16. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  17. package/packages/server/src/__tests__/cors.test.ts +34 -2
  18. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  19. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  20. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  21. package/packages/server/src/__tests__/editor-registry.test.ts +3 -2
  22. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  23. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  24. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  25. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  26. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  27. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +122 -0
  28. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  29. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  30. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  31. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  32. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  33. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  34. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  35. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  36. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  37. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  38. package/packages/server/src/__tests__/tunnel.test.ts +91 -0
  39. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  40. package/packages/server/src/browse.ts +100 -6
  41. package/packages/server/src/browser-gateway.ts +16 -3
  42. package/packages/server/src/editor-manager.ts +20 -1
  43. package/packages/server/src/editor-pid-registry.ts +198 -0
  44. package/packages/server/src/fix-pty-permissions.ts +44 -0
  45. package/packages/server/src/headless-pid-registry.ts +9 -0
  46. package/packages/server/src/npm-search-proxy.ts +71 -0
  47. package/packages/server/src/openspec-tasks.ts +158 -0
  48. package/packages/server/src/package-manager-wrapper.ts +31 -0
  49. package/packages/server/src/pi-core-checker.ts +290 -0
  50. package/packages/server/src/pi-core-updater.ts +166 -0
  51. package/packages/server/src/pi-gateway.ts +7 -0
  52. package/packages/server/src/process-manager.ts +1 -1
  53. package/packages/server/src/routes/file-routes.ts +30 -3
  54. package/packages/server/src/routes/openspec-routes.ts +83 -1
  55. package/packages/server/src/routes/pi-core-routes.ts +117 -0
  56. package/packages/server/src/routes/provider-auth-routes.ts +4 -2
  57. package/packages/server/src/routes/provider-routes.ts +12 -2
  58. package/packages/server/src/routes/recommended-routes.ts +227 -0
  59. package/packages/server/src/routes/system-routes.ts +10 -1
  60. package/packages/server/src/server.ts +151 -15
  61. package/packages/server/src/terminal-manager.ts +4 -0
  62. package/packages/server/src/test-env-guard.ts +26 -0
  63. package/packages/server/src/test-support/test-server.ts +63 -0
  64. package/packages/server/src/tunnel.ts +132 -8
  65. package/packages/shared/package.json +1 -1
  66. package/packages/shared/src/__tests__/config.test.ts +3 -3
  67. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  68. package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
  69. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  70. package/packages/shared/src/browser-protocol.ts +23 -1
  71. package/packages/shared/src/openspec-poller.ts +8 -3
  72. package/packages/shared/src/recommended-extensions.ts +180 -0
  73. package/packages/shared/src/rest-api.ts +71 -0
  74. package/packages/shared/src/source-matching.ts +126 -0
  75. package/packages/shared/src/test-support/setup-home.ts +74 -0
  76. package/packages/shared/src/types.ts +7 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Recommended pi extensions for pi-agent-dashboard.
3
+ *
4
+ * The dashboard has custom UI and wiring for a small set of pi extensions
5
+ * it was built to work with. This manifest enumerates them so the dashboard
6
+ * can surface installation status, offer one-click installs in the Packages
7
+ * tab, walk users through setup in the first-launch wizard, and warn when
8
+ * a `required` entry is missing.
9
+ *
10
+ * This list is intentionally curated (not auto-discovered from npm). Each
11
+ * entry lives and dies by explicit PR review — the dashboard team owns the
12
+ * decision of which extensions are promoted.
13
+ *
14
+ * Descriptions in `fallbackDescription` are shipped inline. At runtime the
15
+ * dashboard server optionally enriches them with live descriptions fetched
16
+ * from the npm registry or GitHub (see `/api/packages/recommended`).
17
+ */
18
+
19
+ /** Relative importance of a recommended extension. */
20
+ export type RecommendedExtensionStatus =
21
+ | "required" // dashboard features or provider paths break without it
22
+ | "strongly-suggested" // dashboard has UI that depends on this
23
+ | "optional"; // nice-to-have
24
+
25
+ /** Static manifest entry. Enriched at runtime via the recommended route. */
26
+ export interface RecommendedExtension {
27
+ /** Stable kebab-case identifier. Used for skip/persist state and IPC. */
28
+ id: string;
29
+
30
+ /**
31
+ * pi install source. Any form parseable by pi's DefaultPackageManager:
32
+ * - `npm:<name>`
33
+ * - `git:<host>/<path>`
34
+ * - `git@<host>:<path>.git`
35
+ * - `https://<host>/<path>.git`
36
+ * - local path
37
+ */
38
+ source: string;
39
+
40
+ /** Human-readable package name for the UI. */
41
+ displayName: string;
42
+
43
+ /**
44
+ * Fallback description. Used when npm/GitHub is unreachable. Kept
45
+ * short (one or two sentences).
46
+ */
47
+ fallbackDescription: string;
48
+
49
+ /** Relative importance. */
50
+ status: RecommendedExtensionStatus;
51
+
52
+ /** Which dashboard features light up when this is installed. */
53
+ unlocks: string[];
54
+
55
+ /** Tool names this extension registers (for diagnostics / UI hinting). */
56
+ toolsRegistered?: string[];
57
+
58
+ /**
59
+ * True when the extension self-wires into pi / dashboard without
60
+ * additional configuration — installing it is sufficient for it to
61
+ * start working.
62
+ */
63
+ autowired?: boolean;
64
+ }
65
+
66
+ /** Enriched manifest entry returned by GET /api/packages/recommended. */
67
+ export interface EnrichedRecommendedExtension extends RecommendedExtension {
68
+ /** Live description (falls back to `fallbackDescription` on fetch failure). */
69
+ description: string;
70
+ /** Current upstream version, if available. */
71
+ version?: string;
72
+ /**
73
+ * Install status by scope. `null` means not present on disk in any scope.
74
+ */
75
+ installed: { scope: "global" | "local" | null };
76
+ /** True iff the source is currently listed in `~/.pi/agent/settings.json` `packages[]`. */
77
+ activeInPi: boolean;
78
+ /** True iff a newer version is available upstream. */
79
+ updateAvailable: boolean;
80
+ }
81
+
82
+ export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
83
+ {
84
+ id: "pi-anthropic-messages",
85
+ source: "git@github.com:BlackBeltTechnology/pi-anthropic-messages.git",
86
+ displayName: "pi-anthropic-messages",
87
+ fallbackDescription:
88
+ "Protocol bridge that makes pi's custom tools work with any " +
89
+ "anthropic-messages endpoint for Claude models (direct Anthropic " +
90
+ "OAuth/API key, 9Router cc/claude-*, pi-model-proxy, any Claude " +
91
+ "Code-flavored proxy). Required whenever a provider has " +
92
+ 'api: "anthropic-messages" with a Claude model — without it, ' +
93
+ "tool calls fall back to Claude Code's built-in bash_ide sandbox.",
94
+ status: "required",
95
+ unlocks: ["Tool calls on Anthropic OAuth / 9Router cc/* / proxy providers"],
96
+ autowired: true,
97
+ },
98
+ {
99
+ id: "tintinweb-pi-subagents",
100
+ source: "npm:@tintinweb/pi-subagents",
101
+ displayName: "@tintinweb/pi-subagents",
102
+ fallbackDescription:
103
+ "Claude Code-style autonomous sub-agents for pi. Registers " +
104
+ "the Agent tool and its companions. The dashboard has custom " +
105
+ "card UI for it.",
106
+ status: "strongly-suggested",
107
+ unlocks: [
108
+ "Agent tool card UI",
109
+ "Subagent activity badge",
110
+ "get_subagent_result / steer_subagent renderers",
111
+ ],
112
+ toolsRegistered: ["Agent", "get_subagent_result", "steer_subagent"],
113
+ autowired: true,
114
+ },
115
+ {
116
+ id: "pi-flows",
117
+ source: "git@github.com:BlackBeltTechnology/pi-flows.git",
118
+ displayName: "pi-flows",
119
+ fallbackDescription:
120
+ "Flow engine, dashboard, and orchestration extensions for pi. " +
121
+ "Powers the dashboard's Flow view, role aliases, and multi-agent " +
122
+ "orchestration tools.",
123
+ status: "strongly-suggested",
124
+ unlocks: [
125
+ "Flow dashboard",
126
+ "Role aliases (@planning, @coding, …)",
127
+ "subagent / flow_write / flow_results / agent_write / ask_user / skill_read / finish tools",
128
+ ],
129
+ toolsRegistered: [
130
+ "subagent",
131
+ "agent_catalog",
132
+ "agent_write",
133
+ "flow_write",
134
+ "flow_results",
135
+ "skill_read",
136
+ "ask_user",
137
+ "finish",
138
+ ],
139
+ autowired: true,
140
+ },
141
+ {
142
+ id: "pi-web-access",
143
+ source: "npm:pi-web-access",
144
+ displayName: "pi-web-access",
145
+ fallbackDescription:
146
+ "Web search, URL fetching, GitHub repo cloning, PDF extraction, " +
147
+ "and YouTube / local video analysis for pi.",
148
+ status: "strongly-suggested",
149
+ unlocks: ["web_search", "code_search", "fetch_content", "get_search_content"],
150
+ toolsRegistered: [
151
+ "web_search",
152
+ "code_search",
153
+ "fetch_content",
154
+ "get_search_content",
155
+ ],
156
+ },
157
+ {
158
+ id: "pi-agent-browser",
159
+ source: "npm:pi-agent-browser",
160
+ displayName: "pi-agent-browser",
161
+ fallbackDescription:
162
+ "Browser automation (open, snapshot, click, fill, screenshot) " +
163
+ "via the agent-browser CLI.",
164
+ status: "optional",
165
+ unlocks: ["browser tool (open, snapshot, click, screenshot)"],
166
+ toolsRegistered: ["browser"],
167
+ },
168
+ ];
169
+
170
+ /** Retrieve a recommended entry by id, or `undefined`. */
171
+ export function getRecommendedExtension(id: string): RecommendedExtension | undefined {
172
+ return RECOMMENDED_EXTENSIONS.find((e) => e.id === id);
173
+ }
174
+
175
+ /** Retrieve all entries with the given status. */
176
+ export function getRecommendedByStatus(
177
+ status: RecommendedExtensionStatus,
178
+ ): readonly RecommendedExtension[] {
179
+ return RECOMMENDED_EXTENSIONS.filter((e) => e.status === status);
180
+ }
@@ -7,6 +7,11 @@ import type {
7
7
  ApiResponse,
8
8
  } from "./types.js";
9
9
 
10
+ export type { ApiResponse } from "./types.js";
11
+ import type { EnrichedRecommendedExtension } from "./recommended-extensions.js";
12
+
13
+ export type { EnrichedRecommendedExtension } from "./recommended-extensions.js";
14
+
10
15
  // ── Sessions ────────────────────────────────────────────────────────
11
16
 
12
17
  export interface ListSessionsQuery {
@@ -63,6 +68,14 @@ export interface BrowseEntry {
63
68
  isPi: boolean;
64
69
  }
65
70
 
71
+ /**
72
+ * Response shape for `GET /api/browse?path=<dir>&q=<query>`.
73
+ *
74
+ * The optional `q` query parameter, when present and non-empty, causes the
75
+ * server to filter entries by case-insensitive substring on `name` and rank
76
+ * them (exact → prefix → word-boundary → substring) before the 200-entry cap.
77
+ * When omitted or whitespace-only, entries are sorted alphabetically.
78
+ */
66
79
  export interface BrowseResult {
67
80
  entries: BrowseEntry[];
68
81
  parent: string | null;
@@ -71,6 +84,18 @@ export interface BrowseResult {
71
84
 
72
85
  export type BrowseResponse = ApiResponse<BrowseResult>;
73
86
 
87
+ /** Request body for `POST /api/browse/mkdir`. */
88
+ export interface MkdirRequest {
89
+ parent: string;
90
+ name: string;
91
+ }
92
+
93
+ export interface MkdirResult {
94
+ path: string;
95
+ }
96
+
97
+ export type MkdirResponse = ApiResponse<MkdirResult>;
98
+
74
99
  // ── Tunnel Status ───────────────────────────────────────────────────
75
100
 
76
101
  export type TunnelStatus =
@@ -246,6 +271,46 @@ export interface PackageUpdateInfo {
246
271
 
247
272
  export type CheckUpdatesResponse = ApiResponse<PackageUpdateInfo[]>;
248
273
 
274
+ // ── Pi core version check ────────────────────────────────────
275
+
276
+ /** A core pi ecosystem CLI package (not managed by pi's PackageManager). */
277
+ export interface PiCorePackage {
278
+ name: string;
279
+ displayName: string;
280
+ currentVersion: string;
281
+ latestVersion: string | null;
282
+ updateAvailable: boolean;
283
+ installSource: "global" | "managed";
284
+ }
285
+
286
+ export interface PiCoreStatus {
287
+ packages: PiCorePackage[];
288
+ updatesAvailable: number;
289
+ lastChecked: string;
290
+ }
291
+
292
+ export type PiCoreVersionsResponse = ApiResponse<PiCoreStatus>;
293
+
294
+ /** Request body for POST /api/pi-core/update. Empty packages = update all. */
295
+ export interface PiCoreUpdateRequest {
296
+ packages?: string[];
297
+ }
298
+
299
+ /** Result of a single package update. */
300
+ export interface PiCoreUpdateResult {
301
+ name: string;
302
+ success: boolean;
303
+ error?: string;
304
+ }
305
+
306
+ /** Response from POST /api/pi-core/update (completes synchronously). */
307
+ export interface PiCoreUpdateResponse {
308
+ results: PiCoreUpdateResult[];
309
+ sessionsReloaded: number;
310
+ }
311
+
312
+ export type PiCoreUpdateApiResponse = ApiResponse<PiCoreUpdateResponse>;
313
+
249
314
  // ── Known Servers ─────────────────────────────────────────────
250
315
 
251
316
  import type { KnownServer } from "./config.js";
@@ -281,3 +346,9 @@ export interface NetworkInterface {
281
346
  netmask: string;
282
347
  cidr: string;
283
348
  }
349
+
350
+ // ── Recommended extensions ───────────────────────────
351
+
352
+ export type ListRecommendedExtensionsResponse = ApiResponse<{
353
+ recommended: EnrichedRecommendedExtension[];
354
+ }>;
@@ -0,0 +1,126 @@
1
+ // ---------------------------------------------------------------------------
2
+ // source-matching — canonical "two source strings refer to the same package"
3
+ // predicate, shared between the server's /api/packages/recommended route
4
+ // and the Electron wizard's bootstrap enricher.
5
+ //
6
+ // Pure string logic, no fs / no pi SDK dependency. Safe to import from any
7
+ // package (shared, server, client, electron).
8
+ //
9
+ // Input sources take one of these forms:
10
+ //
11
+ // npm:<name>[@<version>]
12
+ // e.g. "npm:pi-web-access", "npm:@tintinweb/pi-subagents@0.5.2"
13
+ //
14
+ // git@<host>:<owner>/<repo>[.git]
15
+ // e.g. "git@github.com:BlackBeltTechnology/pi-flows.git"
16
+ //
17
+ // https://<host>/<owner>/<repo>[.git][#ref]
18
+ // e.g. "https://github.com/BlackBeltTechnology/pi-flows.git"
19
+ //
20
+ // git:<host>/<owner>/<repo>[#ref]
21
+ // e.g. "git:github.com/BlackBeltTechnology/pi-flows#main"
22
+ //
23
+ // any other string (absolute path, relative path, unrecognized URL)
24
+ // → parsed as kind:"raw" with the literal preserved
25
+ //
26
+ // Matching rules:
27
+ // - Same kind: exact comparison of the semantically-meaningful parts.
28
+ // - Cross-kind (git ↔ raw): the raw source's basename (last path
29
+ // segment, stripped of trailing slash and trailing .git) must equal
30
+ // the git repo name, case-insensitive. This handles the common case
31
+ // where a user registered the package via `pi install -l <path>`
32
+ // instead of by URL and the basename is the repo name.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export type SourceKey =
36
+ | { kind: "npm"; name: string }
37
+ | { kind: "git"; host: string; owner: string; repo: string }
38
+ | { kind: "raw"; source: string };
39
+
40
+ export function parseSourceKey(source: string): SourceKey {
41
+ const trimmed = source.trim();
42
+
43
+ if (trimmed.startsWith("npm:")) {
44
+ const spec = trimmed.slice(4).trim();
45
+ // Strip a trailing @version but preserve the scope @ in @scope/name.
46
+ // If spec starts with @, the SECOND @ (if any) delimits version.
47
+ let name = spec;
48
+ if (spec.startsWith("@")) {
49
+ const idx = spec.indexOf("@", 1);
50
+ if (idx > 0) name = spec.slice(0, idx);
51
+ } else {
52
+ const idx = spec.indexOf("@");
53
+ if (idx > 0) name = spec.slice(0, idx);
54
+ }
55
+ return { kind: "npm", name };
56
+ }
57
+
58
+ const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/.]+)(?:\.git)?$/);
59
+ if (sshMatch) {
60
+ return { kind: "git", host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] };
61
+ }
62
+
63
+ const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/#.]+)(?:\.git)?(?:#.+)?$/);
64
+ if (httpsMatch) {
65
+ return {
66
+ kind: "git",
67
+ host: httpsMatch[1],
68
+ owner: httpsMatch[2],
69
+ repo: httpsMatch[3],
70
+ };
71
+ }
72
+
73
+ const gitPrefixMatch = trimmed.match(/^git:([^/]+)\/([^/]+)\/([^/#]+?)(?:\.git)?(?:#.+)?$/);
74
+ if (gitPrefixMatch) {
75
+ return {
76
+ kind: "git",
77
+ host: gitPrefixMatch[1],
78
+ owner: gitPrefixMatch[2],
79
+ repo: gitPrefixMatch[3],
80
+ };
81
+ }
82
+
83
+ return { kind: "raw", source: trimmed };
84
+ }
85
+
86
+ /**
87
+ * Extract the basename (last path segment, .git-stripped) from a raw
88
+ * source string. Returns lowercase or null.
89
+ */
90
+ function localPathBasename(src: string): string | null {
91
+ const stripped = src.replace(/\/+$/, "").replace(/\.git$/, "");
92
+ const segments = stripped.split(/[\\/]/);
93
+ const tail = segments[segments.length - 1];
94
+ return tail ? tail.toLowerCase() : null;
95
+ }
96
+
97
+ /**
98
+ * True iff two source strings refer to the same package. See module
99
+ * header for the full matching rules and rationale.
100
+ */
101
+ export function sourcesMatch(a: string, b: string): boolean {
102
+ const ka = parseSourceKey(a);
103
+ const kb = parseSourceKey(b);
104
+
105
+ if (ka.kind === kb.kind) {
106
+ if (ka.kind === "npm" && kb.kind === "npm") return ka.name === kb.name;
107
+ if (ka.kind === "git" && kb.kind === "git") {
108
+ return (
109
+ ka.host.toLowerCase() === kb.host.toLowerCase() &&
110
+ ka.owner.toLowerCase() === kb.owner.toLowerCase() &&
111
+ ka.repo.toLowerCase() === kb.repo.toLowerCase()
112
+ );
113
+ }
114
+ if (ka.kind === "raw" && kb.kind === "raw") return ka.source === kb.source;
115
+ }
116
+
117
+ // Cross-kind: git ↔ raw (local path). Match on repo basename.
118
+ const gitKey = ka.kind === "git" ? ka : kb.kind === "git" ? kb : null;
119
+ const rawKey = ka.kind === "raw" ? ka : kb.kind === "raw" ? kb : null;
120
+ if (gitKey && rawKey) {
121
+ const basename = localPathBasename(rawKey.source);
122
+ if (basename && basename === gitKey.repo.toLowerCase()) return true;
123
+ }
124
+
125
+ return false;
126
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Vitest globalSetup: tripwire + directory bootstrap.
3
+ *
4
+ * Wired via `test.globalSetup` in each package's `vitest.config.ts`. Runs ONCE
5
+ * at vitest boot, before any test file is loaded.
6
+ *
7
+ * Responsibilities:
8
+ * 1. Tripwire — throws if `process.env.HOME` still points at the developer's
9
+ * real user home (meaning the root `npm test` script wasn't used and HOME
10
+ * wasn't overridden). Aborts the entire run before any destructive code
11
+ * can touch real ~/.pi/.
12
+ * 2. Pre-create `<HOME>/.pi/agent/sessions/` and `<HOME>/.pi/dashboard/` so
13
+ * production code that reads those paths finds empty but well-formed
14
+ * directories.
15
+ *
16
+ * Why globalSetup (not setupFiles):
17
+ * setupFiles' `beforeAll` can run AFTER a test file's top-level imports
18
+ * execute destructive module-level code. globalSetup runs strictly before
19
+ * ANY test file is loaded. Combined with the `npm test` process-level HOME
20
+ * override, there is zero window in which code can see real HOME.
21
+ *
22
+ * The process-level HOME override in package.json is the primary isolation
23
+ * layer; this module is the second-line tripwire that catches regressions.
24
+ */
25
+ import { mkdirSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import os from "node:os";
28
+
29
+ /** Vitest globalSetup default export. Returns a teardown function. */
30
+ export default function setup() {
31
+ const currentHome = process.env.HOME ?? "";
32
+ const realHome = os.userInfo().homedir;
33
+
34
+ if (!currentHome) {
35
+ throw new Error(
36
+ "[test-isolation] process.env.HOME is empty. " +
37
+ "Run tests via `npm test` (which sets HOME to a tmp dir) " +
38
+ "or prefix manually: `HOME=$(mktemp -d) npx vitest run`.",
39
+ );
40
+ }
41
+
42
+ if (currentHome === realHome) {
43
+ throw new Error(
44
+ `[test-isolation] process.env.HOME (${currentHome}) equals the real user home ` +
45
+ `(${realHome}). This would let tests read and mutate ~/.pi/, potentially killing ` +
46
+ `live pi sessions. Run tests via \`npm test\` — it sets HOME to an ephemeral tmp dir. ` +
47
+ `If you invoked vitest directly, prefix with \`HOME=$(mktemp -d)\`.`,
48
+ );
49
+ }
50
+
51
+ if (!currentHome.startsWith(os.tmpdir())) {
52
+ // Not strictly fatal — developer may have pointed HOME at a custom scratch dir —
53
+ // but warn loudly because it's unusual.
54
+ // eslint-disable-next-line no-console
55
+ console.warn(
56
+ `[test-isolation] HOME (${currentHome}) is not under os.tmpdir() (${os.tmpdir()}). ` +
57
+ `Tests will still run but this layout is unusual.`,
58
+ );
59
+ }
60
+
61
+ // Pre-create expected .pi subdirectories so code that reads them finds empty dirs.
62
+ mkdirSync(join(currentHome, ".pi", "agent", "sessions"), { recursive: true });
63
+ mkdirSync(join(currentHome, ".pi", "dashboard"), { recursive: true });
64
+
65
+ // eslint-disable-next-line no-console
66
+ console.log(`[test-isolation] HOME=${currentHome} (real=${realHome})`);
67
+
68
+ // Teardown: nothing to do. The tmp HOME was created by the npm script's
69
+ // `$(mktemp -d)`; leaving the dir on disk is fine (OS cleans tmpdir).
70
+ // Tests that need per-file isolation continue to use their own mkdtemp.
71
+ return () => {
72
+ /* no-op */
73
+ };
74
+ }
@@ -140,6 +140,13 @@ export interface OpenSpecChange {
140
140
  completedTasks: number;
141
141
  totalTasks: number;
142
142
  artifacts: OpenSpecArtifact[];
143
+ /**
144
+ * Artifact-authoring completeness reported by `openspec status --change <name> --json`.
145
+ * `true` when all required artifacts for the change's workflow are present/done.
146
+ * Orthogonal to task-tally completeness; used by the dashboard to surface an
147
+ * "Archive anyway" escape hatch when artifacts are authored but tasks remain unchecked.
148
+ */
149
+ isComplete?: boolean;
143
150
  }
144
151
 
145
152
  /** Lifecycle state of an OpenSpec change, derived from artifacts + task status */