pi-ui-extend 1.0.41 → 1.0.45

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 (236) hide show
  1. package/README.md +50 -13
  2. package/dist/app/app.d.ts +13 -0
  3. package/dist/app/app.js +158 -8
  4. package/dist/app/cli/install.js +2 -2
  5. package/dist/app/commands/command-controller.d.ts +4 -0
  6. package/dist/app/commands/command-controller.js +11 -0
  7. package/dist/app/commands/command-git-actions.d.ts +25 -0
  8. package/dist/app/commands/command-git-actions.js +381 -0
  9. package/dist/app/commands/command-host.d.ts +4 -0
  10. package/dist/app/commands/command-host.js +5 -2
  11. package/dist/app/commands/command-model-actions.d.ts +1 -0
  12. package/dist/app/commands/command-model-actions.js +57 -35
  13. package/dist/app/commands/command-navigation-actions.d.ts +1 -0
  14. package/dist/app/commands/command-navigation-actions.js +16 -3
  15. package/dist/app/commands/command-registry.d.ts +2 -0
  16. package/dist/app/commands/command-registry.js +15 -1
  17. package/dist/app/commands/command-session-actions.js +9 -3
  18. package/dist/app/commands/reload-context-inventory.d.ts +11 -0
  19. package/dist/app/commands/reload-context-inventory.js +70 -0
  20. package/dist/app/extensions/extension-actions-controller.d.ts +1 -0
  21. package/dist/app/extensions/extension-actions-controller.js +6 -0
  22. package/dist/app/extensions/subagent-catalog-state.d.ts +9 -0
  23. package/dist/app/extensions/subagent-catalog-state.js +23 -0
  24. package/dist/app/input/autocomplete-controller.js +61 -34
  25. package/dist/app/input/input-action-controller.d.ts +2 -0
  26. package/dist/app/input/input-action-controller.js +8 -1
  27. package/dist/app/input/input-controller.d.ts +2 -1
  28. package/dist/app/input/input-controller.js +7 -2
  29. package/dist/app/input/prompt-enhancer-controller.js +53 -35
  30. package/dist/app/input/voice-controller.d.ts +41 -45
  31. package/dist/app/input/voice-controller.js +351 -419
  32. package/dist/app/model/model-usage-controller.js +17 -7
  33. package/dist/app/model/model-usage-status.d.ts +4 -1
  34. package/dist/app/model/model-usage-status.js +270 -46
  35. package/dist/app/popup/menu-items-controller.d.ts +13 -3
  36. package/dist/app/popup/menu-items-controller.js +37 -21
  37. package/dist/app/popup/popup-action-controller.d.ts +12 -2
  38. package/dist/app/popup/popup-action-controller.js +77 -24
  39. package/dist/app/popup/popup-menu-controller.d.ts +36 -14
  40. package/dist/app/popup/popup-menu-controller.js +239 -69
  41. package/dist/app/rendering/dcp-stats.d.ts +9 -4
  42. package/dist/app/rendering/dcp-stats.js +40 -425
  43. package/dist/app/rendering/editor-panels.js +10 -4
  44. package/dist/app/rendering/popup-menu-renderer.d.ts +3 -5
  45. package/dist/app/rendering/popup-menu-renderer.js +42 -37
  46. package/dist/app/rendering/render-controller.js +23 -2
  47. package/dist/app/rendering/status-line-renderer.d.ts +5 -0
  48. package/dist/app/rendering/status-line-renderer.js +41 -5
  49. package/dist/app/rendering/tab-line-renderer.js +26 -20
  50. package/dist/app/runtime.d.ts +12 -1
  51. package/dist/app/runtime.js +120 -12
  52. package/dist/app/screen/mouse-controller.d.ts +2 -0
  53. package/dist/app/screen/mouse-controller.js +17 -7
  54. package/dist/app/screen/status-controller.d.ts +4 -0
  55. package/dist/app/screen/status-controller.js +5 -0
  56. package/dist/app/session/lazy-session-manager.js +34 -0
  57. package/dist/app/session/session-event-controller.d.ts +1 -0
  58. package/dist/app/session/session-event-controller.js +10 -1
  59. package/dist/app/session/session-history.d.ts +1 -0
  60. package/dist/app/session/session-history.js +12 -1
  61. package/dist/app/session/session-lifecycle-controller.d.ts +7 -1
  62. package/dist/app/session/session-lifecycle-controller.js +16 -1
  63. package/dist/app/session/tabs-controller.d.ts +26 -1
  64. package/dist/app/session/tabs-controller.js +454 -147
  65. package/dist/app/subagents/subagents-files.js +60 -1
  66. package/dist/app/subagents/subagents-model.d.ts +1 -0
  67. package/dist/app/subagents/subagents-model.js +18 -2
  68. package/dist/app/subagents/subagents-widget-controller.d.ts +1 -0
  69. package/dist/app/subagents/subagents-widget-controller.js +6 -0
  70. package/dist/app/types.d.ts +16 -1
  71. package/dist/app/workspace/workspace-actions-controller.js +10 -2
  72. package/dist/app/workspace/workspace-undo.d.ts +1 -0
  73. package/dist/app/workspace/workspace-undo.js +1 -0
  74. package/dist/bundled-extensions/question/index.js +9 -1
  75. package/dist/bundled-extensions/question/remote.d.ts +4 -0
  76. package/dist/bundled-extensions/question/remote.js +33 -0
  77. package/dist/bundled-extensions/telegram-connector/bot.d.ts +43 -0
  78. package/dist/bundled-extensions/telegram-connector/bot.js +166 -0
  79. package/dist/bundled-extensions/telegram-connector/config.d.ts +8 -0
  80. package/dist/bundled-extensions/telegram-connector/config.js +87 -0
  81. package/dist/bundled-extensions/telegram-connector/coordinator.d.ts +66 -0
  82. package/dist/bundled-extensions/telegram-connector/coordinator.js +413 -0
  83. package/dist/bundled-extensions/telegram-connector/index.d.ts +3 -0
  84. package/dist/bundled-extensions/telegram-connector/index.js +195 -0
  85. package/dist/bundled-extensions/terminal-bell/index.d.ts +0 -8
  86. package/dist/bundled-extensions/terminal-bell/index.js +0 -76
  87. package/dist/bundled-extensions/workspace-undo/index.d.ts +26 -0
  88. package/dist/bundled-extensions/workspace-undo/index.js +191 -0
  89. package/dist/config.d.ts +21 -2
  90. package/dist/config.js +159 -32
  91. package/dist/default-pix-config.js +25 -5
  92. package/dist/schemas/index.d.ts +1 -0
  93. package/dist/schemas/index.js +1 -0
  94. package/dist/schemas/pi-tools-suite-schema.d.ts +88 -62
  95. package/dist/schemas/pi-tools-suite-schema.js +54 -83
  96. package/dist/schemas/pix-schema.d.ts +19 -2
  97. package/dist/schemas/pix-schema.js +47 -5
  98. package/dist/schemas/tasks-schema.d.ts +18 -0
  99. package/dist/schemas/tasks-schema.js +41 -0
  100. package/docs/concurrency.md +9 -1
  101. package/docs/desktop-mvp.md +22 -6
  102. package/docs/desktop-task-manager.md +148 -84
  103. package/docs/release.md +30 -6
  104. package/external/pi-tools-suite/README.md +197 -84
  105. package/external/pi-tools-suite/docs/evals.md +1 -1
  106. package/external/pi-tools-suite/docs/session-recovery.md +47 -14
  107. package/external/pi-tools-suite/docs/subagent-model-pools.md +48 -30
  108. package/external/pi-tools-suite/docs/ui-qa-subagent.md +440 -0
  109. package/external/pi-tools-suite/package.json +6 -1
  110. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +2 -1
  111. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +9 -3
  112. package/external/pi-tools-suite/src/antigravity-auth/headers.ts +41 -3
  113. package/external/pi-tools-suite/src/antigravity-auth/models.ts +94 -21
  114. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +5 -17
  115. package/external/pi-tools-suite/src/antigravity-auth/payload.ts +72 -7
  116. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +13 -1
  117. package/external/pi-tools-suite/src/async-subagents/agents/frontier-review.md +23 -0
  118. package/external/pi-tools-suite/src/async-subagents/agents/implement.md +1 -1
  119. package/external/pi-tools-suite/src/async-subagents/agents/presets.jsonc +16 -0
  120. package/external/pi-tools-suite/src/async-subagents/agents/research.md +5 -3
  121. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/browser.mjs +346 -0
  122. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/desktop.mjs +1038 -0
  123. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/tui.mjs +759 -0
  124. package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/scripts/browser-qa-runner.mjs +32 -23
  125. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/chrome-devtools/chrome-devtools-provider.mjs +895 -0
  126. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/linux/linux-atspi.py +494 -0
  127. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/macos/macos-accessibility.swift +1085 -0
  128. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/bridge-client.mjs +50 -0
  129. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/native-terminal-host.mjs +501 -0
  130. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/windows/windows-uia.ps1 +454 -0
  131. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-auth.md +114 -0
  132. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-chrome-devtools.md +91 -0
  133. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-playwright.md +145 -0
  134. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser.md +82 -0
  135. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-linux.md +26 -0
  136. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-macos.md +29 -0
  137. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-windows.md +23 -0
  138. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop.md +54 -0
  139. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-native-terminal.md +59 -0
  140. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-pty.md +37 -0
  141. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui.md +60 -0
  142. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/scripts/ui-qa-runner.mjs +535 -0
  143. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa.md +75 -0
  144. package/external/pi-tools-suite/src/async-subagents/commands.ts +15 -71
  145. package/external/pi-tools-suite/src/async-subagents/core/activity.ts +33 -0
  146. package/external/pi-tools-suite/src/async-subagents/core/agent-catalog.ts +5 -4
  147. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
  148. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +8 -5
  149. package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +16 -2
  150. package/external/pi-tools-suite/src/async-subagents/core/config.ts +160 -327
  151. package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +2 -1
  152. package/external/pi-tools-suite/src/async-subagents/core/registry.ts +158 -33
  153. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +21 -5
  154. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +40 -26
  155. package/external/pi-tools-suite/src/async-subagents/core/state.ts +40 -0
  156. package/external/pi-tools-suite/src/async-subagents/core/types.ts +7 -0
  157. package/external/pi-tools-suite/src/async-subagents/core/ultrawork-auto.ts +51 -45
  158. package/external/pi-tools-suite/src/async-subagents/index.ts +69 -5
  159. package/external/pi-tools-suite/src/async-subagents/lib.ts +16 -9
  160. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +8 -5
  161. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +5 -4
  162. package/external/pi-tools-suite/src/async-subagents/types.ts +1 -0
  163. package/external/pi-tools-suite/src/coding-discipline/index.ts +90 -59
  164. package/external/pi-tools-suite/src/config.ts +55 -1
  165. package/external/pi-tools-suite/src/context-gateway/accounting-log.ts +282 -0
  166. package/external/pi-tools-suite/src/context-gateway/config.ts +77 -3
  167. package/external/pi-tools-suite/src/context-gateway/efficiency.ts +414 -0
  168. package/external/pi-tools-suite/src/context-gateway/enforcement.ts +222 -0
  169. package/external/pi-tools-suite/src/context-gateway/index.ts +206 -19
  170. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +7 -7
  171. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +101 -28
  172. package/external/pi-tools-suite/src/context-gateway/types.ts +16 -3
  173. package/external/pi-tools-suite/src/context-inventory.ts +99 -0
  174. package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +39 -4
  175. package/external/pi-tools-suite/src/dcp/auto-compress.ts +128 -49
  176. package/external/pi-tools-suite/src/dcp/commands.ts +32 -156
  177. package/external/pi-tools-suite/src/dcp/compress-tool.ts +177 -46
  178. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +6 -58
  179. package/external/pi-tools-suite/src/dcp/compression-preview.ts +9 -0
  180. package/external/pi-tools-suite/src/dcp/compression-progress.ts +22 -0
  181. package/external/pi-tools-suite/src/dcp/config.ts +114 -17
  182. package/external/pi-tools-suite/src/dcp/conversation-index.ts +36 -9
  183. package/external/pi-tools-suite/src/dcp/diagnostics.ts +41 -0
  184. package/external/pi-tools-suite/src/dcp/fresh-tool-results.ts +38 -0
  185. package/external/pi-tools-suite/src/dcp/index.ts +338 -66
  186. package/external/pi-tools-suite/src/dcp/journal.ts +126 -4
  187. package/external/pi-tools-suite/src/dcp/progress-controller.ts +2 -1
  188. package/external/pi-tools-suite/src/dcp/prompts.ts +83 -192
  189. package/external/pi-tools-suite/src/dcp/protected-continuity.ts +175 -0
  190. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +93 -3
  191. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +6 -10
  192. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +36 -33
  193. package/external/pi-tools-suite/src/dcp/pruner-tools.ts +6 -3
  194. package/external/pi-tools-suite/src/dcp/pruner.ts +1 -0
  195. package/external/pi-tools-suite/src/dcp/routine-pressure.ts +86 -0
  196. package/external/pi-tools-suite/src/dcp/state.ts +9 -0
  197. package/external/pi-tools-suite/src/dcp/statistics.d.ts +8 -0
  198. package/external/pi-tools-suite/src/dcp/statistics.js +156 -0
  199. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +78 -22
  200. package/external/pi-tools-suite/src/index.ts +7 -1
  201. package/external/pi-tools-suite/src/lib/project.ts +36 -1
  202. package/external/pi-tools-suite/src/model-tools/index.ts +10 -7
  203. package/external/pi-tools-suite/src/repo-discovery/index.ts +304 -4
  204. package/external/pi-tools-suite/src/resource-registry/index.ts +2551 -0
  205. package/external/pi-tools-suite/src/session-recovery/index.ts +17 -0
  206. package/external/pi-tools-suite/src/shell-command-policy.ts +219 -0
  207. package/external/pi-tools-suite/src/todo/index.ts +21 -0
  208. package/external/pi-tools-suite/src/todo/todo.ts +19 -1
  209. package/external/pi-tools-suite/src/tool-descriptions.ts +47 -19
  210. package/package.json +10 -9
  211. package/schemas/pi-tools-suite.json +466 -287
  212. package/schemas/pix.json +129 -11
  213. package/schemas/tasks.json +131 -0
  214. package/skills/simplify/SKILL.md +33 -5
  215. package/docs/desktop-markdown-media.md +0 -77
  216. package/external/pi-tools-suite/docs/browser-qa-subagent.md +0 -177
  217. package/external/pi-tools-suite/docs/dcp-emergency-current-turn.md +0 -102
  218. package/external/pi-tools-suite/src/async-subagents/agents/browser-qa.md +0 -598
  219. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +0 -54
  220. package/external/pi-tools-suite/src/skill-installer/index.ts +0 -333
  221. package/skills/playwright-cli/SKILL.md +0 -420
  222. package/skills/playwright-cli/references/element-attributes.md +0 -23
  223. package/skills/playwright-cli/references/playwright-tests.md +0 -50
  224. package/skills/playwright-cli/references/request-mocking.md +0 -87
  225. package/skills/playwright-cli/references/running-code.md +0 -241
  226. package/skills/playwright-cli/references/session-management.md +0 -273
  227. package/skills/playwright-cli/references/spec-driven-testing.md +0 -311
  228. package/skills/playwright-cli/references/storage-state.md +0 -290
  229. package/skills/playwright-cli/references/test-generation.md +0 -142
  230. package/skills/playwright-cli/references/tracing.md +0 -154
  231. package/skills/playwright-cli/references/video-recording.md +0 -147
  232. package/skills/spec-lite/SKILL.md +0 -140
  233. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-auth.example.jsonc +0 -0
  234. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-flow.example.jsonc +0 -0
  235. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.LICENSE +0 -0
  236. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.mjs +0 -0
@@ -0,0 +1,2551 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, promises as fs } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { applyEdits, modify } from "jsonc-parser";
8
+
9
+ import { parseAgentMarkdown } from "../async-subagents/core/agents-dir.js";
10
+ import { getPiToolsSuiteUserConfigPath, loadPiToolsSuiteConfig, type ResourceRegistryConfig } from "../config.js";
11
+ import { ignoreStaleExtensionContextError, isStaleExtensionContextError } from "../context-usage.js";
12
+ import { publishRpcSessionState, RPC_SESSION_STATE_ENV } from "../lib/rpc-session-state.js";
13
+
14
+ const COMMAND = "registry";
15
+ const PROJECT_DIR = ".pi";
16
+ const PROJECT_SKILLS_DIR = "skills";
17
+ const PROJECT_AGENTS_DIR = "agents";
18
+ const REGISTRY_SKILLS_DIR = "skills";
19
+ const REGISTRY_AGENTS_DIR = "agents";
20
+ const REGISTRY_PROJECTS_DIR = "projects";
21
+ const SKILL_FILE = "SKILL.md";
22
+ const PROJECT_TASKS_FILE = "tasks.jsonc";
23
+ const PROJECT_TASK_ATTACHMENTS_DIR = "task-attachments";
24
+ const PROJECT_PLANS_DIR = "plans";
25
+ const PROJECT_TODO_FILE = "TODO.md";
26
+ const REGISTRY_TASK_ATTACHMENT_SCHEME = "pix-task-attachment:";
27
+ const PROVENANCE_FILE = "registry.json";
28
+ const PROVENANCE_VERSION = 1;
29
+ const SYSTEM_CUSTOM_MESSAGE_TYPE = "pix-system";
30
+ const STATUS_MESSAGE_KIND = "resource-registry-status";
31
+ export const REGISTRY_STATE_EVENT = "pi-tools-suite:resource-registry:state";
32
+ const DESC_MAX = 90;
33
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
34
+ const SAFE_BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
35
+ const SKIP_NAMES = new Set([".DS_Store"]);
36
+
37
+ export type ResourceType = "skill" | "agent";
38
+ type ResourceScope = ResourceType | "all";
39
+ export type ProjectArtifact = "tasks" | "plans" | "todo";
40
+ type ProjectScope = ProjectArtifact | "project";
41
+ type RegistryAction = "install" | "push" | "pull" | "remove" | "uninstall" | "status" | "update" | "configure" | "remote" | "project-key";
42
+
43
+ type ResourceEntry = {
44
+ type: ResourceType;
45
+ name: string;
46
+ path: string;
47
+ description: string;
48
+ };
49
+
50
+ type ProvenanceEntry = {
51
+ type: ResourceType;
52
+ name: string;
53
+ remote: string;
54
+ branch: string;
55
+ revision: string;
56
+ hash: string;
57
+ };
58
+
59
+ type Provenance = {
60
+ version: 1;
61
+ resources: Record<string, ProvenanceEntry>;
62
+ projectResources: Partial<Record<ProjectArtifact, ProjectProvenanceEntry>>;
63
+ };
64
+
65
+ type ProjectProvenanceEntry = {
66
+ artifact: ProjectArtifact;
67
+ projectKey: string;
68
+ remote: string;
69
+ branch: string;
70
+ revision: string;
71
+ hash: string;
72
+ };
73
+
74
+ export type RegistryStatusKind =
75
+ | "up-to-date"
76
+ | "update-available"
77
+ | "local-changes"
78
+ | "diverged"
79
+ | "not-installed"
80
+ | "local-only"
81
+ | "untracked-local"
82
+ | "missing-local"
83
+ | "removed-remote"
84
+ | "registry-changed";
85
+
86
+ type RegistryStatus = {
87
+ type: ResourceType;
88
+ name: string;
89
+ kind: RegistryStatusKind;
90
+ local?: ResourceEntry;
91
+ remote?: ResourceEntry;
92
+ provenance?: ProvenanceEntry;
93
+ remoteRevision?: string;
94
+ };
95
+
96
+ type RegistryRuntime = {
97
+ remote: string;
98
+ branch: string;
99
+ cacheDir: string;
100
+ };
101
+
102
+ type ProjectArtifactStatus = {
103
+ artifact: ProjectArtifact;
104
+ kind: RegistryStatusKind;
105
+ localExists: boolean;
106
+ remoteExists: boolean;
107
+ provenance?: ProjectProvenanceEntry;
108
+ remoteRevision?: string;
109
+ };
110
+
111
+ type ProjectStatusBundle = {
112
+ projectKey?: string;
113
+ statuses: ProjectArtifactStatus[];
114
+ issue?: string;
115
+ };
116
+
117
+ export type RegistryUiAction = "install" | "update" | "push" | "pull" | "uninstall" | "remove";
118
+
119
+ export interface RegistryUiItem {
120
+ readonly id: string;
121
+ readonly type: ResourceType | "project";
122
+ readonly name: string;
123
+ readonly artifact?: ProjectArtifact;
124
+ readonly status: RegistryStatusKind;
125
+ readonly statusLabel: string;
126
+ readonly icon: string;
127
+ readonly description?: string;
128
+ readonly local: boolean;
129
+ readonly remote: boolean;
130
+ readonly actions: readonly RegistryUiAction[];
131
+ }
132
+
133
+ export interface RegistryUiSnapshot {
134
+ readonly version: 1;
135
+ readonly configured: boolean;
136
+ readonly remote?: string;
137
+ readonly branch: string;
138
+ readonly projectKey?: string;
139
+ readonly projectIssue?: string;
140
+ readonly items: readonly RegistryUiItem[];
141
+ readonly checkedAt: string;
142
+ readonly error?: string;
143
+ }
144
+
145
+ function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
146
+ if (ctx.hasUI) ctx.ui.notify(message, type);
147
+ else console.log(message);
148
+ }
149
+
150
+ function sendStatusSystemMessage(
151
+ pi: ExtensionAPI,
152
+ statuses: RegistryStatus[],
153
+ runtime: RegistryRuntime,
154
+ projectStatus?: ProjectStatusBundle,
155
+ ): void {
156
+ pi.sendMessage({
157
+ customType: SYSTEM_CUSTOM_MESSAGE_TYPE,
158
+ content: formatStatuses(statuses, runtime, projectStatus),
159
+ display: true,
160
+ details: {
161
+ kind: STATUS_MESSAGE_KIND,
162
+ userVisibleOnly: true,
163
+ remote: runtime.remote,
164
+ branch: runtime.branch,
165
+ generatedAt: new Date().toISOString(),
166
+ },
167
+ });
168
+ }
169
+
170
+ function truncate(value: string, maxLength: number): string {
171
+ const collapsed = value.replace(/\s+/g, " ").trim();
172
+ return collapsed.length <= maxLength ? collapsed : `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
173
+ }
174
+
175
+ function resourceKey(type: ResourceType, name: string): string {
176
+ return `${type}:${name}`;
177
+ }
178
+
179
+ function resourceType(value: string | undefined): ResourceType | undefined {
180
+ if (value === "skill" || value === "skills") return "skill";
181
+ if (value === "agent" || value === "agents") return "agent";
182
+ return undefined;
183
+ }
184
+
185
+ function matchesScope(type: ResourceType, scope: ResourceScope): boolean {
186
+ return scope === "all" || type === scope;
187
+ }
188
+
189
+ function projectScope(value: string | undefined): ProjectScope | undefined {
190
+ if (value === "tasks") return "tasks";
191
+ if (value === "plans") return "plans";
192
+ if (value === "todo") return "todo";
193
+ if (value === "project") return "project";
194
+ return undefined;
195
+ }
196
+
197
+ type ProjectContext = ExtensionContext | string;
198
+
199
+ function projectCwd(project: ProjectContext): string {
200
+ return typeof project === "string" ? project : project.cwd;
201
+ }
202
+
203
+ function validateName(name: string): void {
204
+ if (!SAFE_NAME.test(name) || name === "." || name === ".." || name.includes("..")) {
205
+ throw new Error(`Invalid resource name "${name}". Use letters, digits, dot, underscore, or dash.`);
206
+ }
207
+ }
208
+
209
+ function validateBranch(branch: string): void {
210
+ if (
211
+ !SAFE_BRANCH.test(branch)
212
+ || branch.startsWith("-")
213
+ || branch.endsWith("/")
214
+ || branch.includes("//")
215
+ || branch.includes("..")
216
+ || branch.includes("@{")
217
+ ) {
218
+ throw new Error(`Invalid registry branch "${branch}".`);
219
+ }
220
+ }
221
+
222
+ function projectResourcePath(ctx: ExtensionContext, type: ResourceType, name: string): string {
223
+ validateName(name);
224
+ return type === "skill"
225
+ ? join(ctx.cwd, PROJECT_DIR, PROJECT_SKILLS_DIR, name)
226
+ : join(ctx.cwd, PROJECT_DIR, PROJECT_AGENTS_DIR, `${name}.md`);
227
+ }
228
+
229
+ function registryResourceRelativePath(type: ResourceType, name: string): string {
230
+ validateName(name);
231
+ return type === "skill" ? `${REGISTRY_SKILLS_DIR}/${name}` : `${REGISTRY_AGENTS_DIR}/${name}.md`;
232
+ }
233
+
234
+ function registryResourcePath(cacheDir: string, type: ResourceType, name: string): string {
235
+ return join(cacheDir, ...registryResourceRelativePath(type, name).split("/"));
236
+ }
237
+
238
+ function projectArtifactLocalPath(cwd: string, artifact: ProjectArtifact): string {
239
+ switch (artifact) {
240
+ case "tasks": return join(cwd, PROJECT_DIR, PROJECT_TASKS_FILE);
241
+ case "plans": return join(cwd, PROJECT_DIR, PROJECT_PLANS_DIR);
242
+ case "todo": return join(cwd, PROJECT_DIR, PROJECT_TODO_FILE);
243
+ }
244
+ }
245
+
246
+ function projectTaskAttachmentsLocalPath(cwd: string): string {
247
+ return join(cwd, PROJECT_DIR, PROJECT_TASK_ATTACHMENTS_DIR);
248
+ }
249
+
250
+ function projectArtifactRelativePath(projectKey: string, artifact: ProjectArtifact): string {
251
+ validateName(projectKey);
252
+ switch (artifact) {
253
+ case "tasks": return `${REGISTRY_PROJECTS_DIR}/${projectKey}/${PROJECT_TASKS_FILE}`;
254
+ case "plans": return `${REGISTRY_PROJECTS_DIR}/${projectKey}/${PROJECT_PLANS_DIR}`;
255
+ case "todo": return `${REGISTRY_PROJECTS_DIR}/${projectKey}/${PROJECT_TODO_FILE}`;
256
+ }
257
+ }
258
+
259
+ function projectTaskAttachmentsRelativePath(projectKey: string): string {
260
+ validateName(projectKey);
261
+ return `${REGISTRY_PROJECTS_DIR}/${projectKey}/${PROJECT_TASK_ATTACHMENTS_DIR}`;
262
+ }
263
+
264
+ function projectArtifactRelativePaths(projectKey: string, artifact: ProjectArtifact): string[] {
265
+ const primary = projectArtifactRelativePath(projectKey, artifact);
266
+ return artifact === "tasks"
267
+ ? [primary, projectTaskAttachmentsRelativePath(projectKey)]
268
+ : [primary];
269
+ }
270
+
271
+ function projectArtifactDisplayName(artifact: ProjectArtifact): string {
272
+ switch (artifact) {
273
+ case "tasks": return PROJECT_TASKS_FILE;
274
+ case "plans": return `${PROJECT_PLANS_DIR}/`;
275
+ case "todo": return PROJECT_TODO_FILE;
276
+ }
277
+ }
278
+
279
+ function projectArtifactRegistryPath(cacheDir: string, projectKey: string, artifact: ProjectArtifact): string {
280
+ return join(cacheDir, ...projectArtifactRelativePath(projectKey, artifact).split("/"));
281
+ }
282
+
283
+ function projectTaskAttachmentsRegistryPath(cacheDir: string, projectKey: string): string {
284
+ return join(cacheDir, ...projectTaskAttachmentsRelativePath(projectKey).split("/"));
285
+ }
286
+
287
+ function canonicalGitRemote(remote: string): string | undefined {
288
+ const trimmed = remote.trim();
289
+ if (!trimmed) return undefined;
290
+ const stripPath = (value: string) => value.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
291
+ const scp = !trimmed.includes("://") ? trimmed.match(/^(?:[^@/]+@)?([^:]+):(.+)$/) : null;
292
+ if (scp) {
293
+ const host = scp[1]?.toLowerCase();
294
+ const path = stripPath(scp[2] ?? "");
295
+ if (host && path) return `${host}/${path}`;
296
+ }
297
+ try {
298
+ const url = new URL(trimmed);
299
+ if (url.protocol === "file:") {
300
+ const path = stripPath(url.pathname);
301
+ if (!path) return undefined;
302
+ const leaf = basename(path).replace(/\.git$/i, "") || "repo";
303
+ return `local/${leaf}--${createHash("sha256").update(trimmed).digest("hex").slice(0, 8)}`;
304
+ }
305
+ const host = url.hostname.toLowerCase();
306
+ const path = stripPath(url.pathname);
307
+ if (host && path) return `${host}/${path}`;
308
+ } catch {
309
+ if (trimmed.startsWith("/") || trimmed.startsWith(".")) {
310
+ const leaf = basename(trimmed).replace(/\.git$/i, "") || "repo";
311
+ return `local/${leaf}--${createHash("sha256").update(trimmed).digest("hex").slice(0, 8)}`;
312
+ }
313
+ }
314
+ return undefined;
315
+ }
316
+
317
+ function projectKeyFromGitRemote(remote: string): string | undefined {
318
+ const canonical = canonicalGitRemote(remote);
319
+ if (!canonical) return undefined;
320
+ const key = canonical
321
+ .split("/")
322
+ .map((part) => part.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+|\.+$/g, ""))
323
+ .filter(Boolean)
324
+ .join("__");
325
+ return key && SAFE_NAME.test(key) && !key.includes("..") ? key : undefined;
326
+ }
327
+
328
+ function provenancePath(project: ProjectContext): string {
329
+ return join(projectCwd(project), PROJECT_DIR, PROVENANCE_FILE);
330
+ }
331
+
332
+ function cacheRoot(): string {
333
+ const base = process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache");
334
+ return join(base, "pi", "resource-registry");
335
+ }
336
+
337
+ function startupCheckCacheRoot(): string {
338
+ return `${cacheRoot()}-startup-check`;
339
+ }
340
+
341
+ function registryUiCacheRoot(): string {
342
+ return `${cacheRoot()}-desktop-${process.pid}`;
343
+ }
344
+
345
+ const registryUiCacheQueues = new Map<string, Promise<void>>();
346
+ const registryUiSnapshotsByProject = new Map<string, RegistryUiSnapshot>();
347
+ const registryUiSnapshotGenerations = new Map<string, number>();
348
+ const registryUiSnapshotLoadsByProject = new Map<string, {
349
+ generation: number;
350
+ promise: Promise<RegistryUiSnapshot>;
351
+ }>();
352
+
353
+ function invalidateRegistryUiSnapshot(cwd: string): void {
354
+ registryUiSnapshotsByProject.delete(cwd);
355
+ registryUiSnapshotGenerations.set(cwd, (registryUiSnapshotGenerations.get(cwd) ?? 0) + 1);
356
+ }
357
+
358
+ function invalidateAllRegistryUiSnapshots(): void {
359
+ const projects = new Set([
360
+ ...registryUiSnapshotsByProject.keys(),
361
+ ...registryUiSnapshotLoadsByProject.keys(),
362
+ ...registryUiSnapshotGenerations.keys(),
363
+ ]);
364
+ registryUiSnapshotsByProject.clear();
365
+ for (const cwd of projects) {
366
+ registryUiSnapshotGenerations.set(cwd, (registryUiSnapshotGenerations.get(cwd) ?? 0) + 1);
367
+ }
368
+ }
369
+
370
+ async function withRegistryUiCache<T>(cacheDir: string, task: () => Promise<T>): Promise<T> {
371
+ const previous = registryUiCacheQueues.get(cacheDir) ?? Promise.resolve();
372
+ let release!: () => void;
373
+ const turn = new Promise<void>((resolve) => { release = resolve; });
374
+ const tail = previous.then(() => turn);
375
+ registryUiCacheQueues.set(cacheDir, tail);
376
+
377
+ await previous;
378
+ try {
379
+ return await task();
380
+ } finally {
381
+ release();
382
+ if (registryUiCacheQueues.get(cacheDir) === tail) registryUiCacheQueues.delete(cacheDir);
383
+ }
384
+ }
385
+
386
+ function loadRuntimeConfig(cwd: string): RegistryRuntime {
387
+ const config = loadPiToolsSuiteConfig([], { cwd }).resourceRegistry;
388
+ if (!config.remote) {
389
+ throw new Error(`Resource registry is not configured. Run /${COMMAND} configure <git-url> [branch].`);
390
+ }
391
+ if (isAbsolute(config.remote) && !existsSync(config.remote)) {
392
+ throw new Error(
393
+ `Configured resource registry remote does not exist: ${config.remote}. `
394
+ + `Run /${COMMAND} configure <git-url> [branch] to replace it.`,
395
+ );
396
+ }
397
+ validateBranch(config.branch);
398
+ return { remote: config.remote, branch: config.branch, cacheDir: cacheRoot() };
399
+ }
400
+
401
+ async function saveRegistryConfig(remote: string, branch: string): Promise<void> {
402
+ const trimmedRemote = remote.trim();
403
+ if (!trimmedRemote) throw new Error("Registry remote cannot be empty.");
404
+ validateBranch(branch);
405
+ const filePath = getPiToolsSuiteUserConfigPath();
406
+ await fs.mkdir(dirname(filePath), { recursive: true });
407
+ let source = "{}\n";
408
+ try {
409
+ source = await fs.readFile(filePath, "utf8");
410
+ } catch (error) {
411
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
412
+ }
413
+ let edits = modify(source, ["resourceRegistry", "remote"], trimmedRemote, {
414
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
415
+ });
416
+ source = applyEdits(source, edits);
417
+ edits = modify(source, ["resourceRegistry", "branch"], branch, {
418
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
419
+ });
420
+ source = applyEdits(source, edits);
421
+ await fs.writeFile(filePath, source.endsWith("\n") ? source : `${source}\n`, "utf8");
422
+ invalidateAllRegistryUiSnapshots();
423
+ }
424
+
425
+ async function saveProjectKeyConfig(cwd: string, projectKey: string | undefined): Promise<void> {
426
+ if (projectKey !== undefined) validateName(projectKey);
427
+ const filePath = join(cwd, PROJECT_DIR, "pi-tools-suite.jsonc");
428
+ await fs.mkdir(dirname(filePath), { recursive: true });
429
+ let source = "{}\n";
430
+ try {
431
+ source = await fs.readFile(filePath, "utf8");
432
+ } catch (error) {
433
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
434
+ }
435
+ const edits = modify(source, ["resourceRegistry", "projectKey"], projectKey, {
436
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
437
+ });
438
+ source = applyEdits(source, edits);
439
+ await fs.writeFile(filePath, source.endsWith("\n") ? source : `${source}\n`, "utf8");
440
+ invalidateRegistryUiSnapshot(cwd);
441
+ }
442
+
443
+ async function pathExists(path: string): Promise<boolean> {
444
+ try {
445
+ await fs.access(path);
446
+ return true;
447
+ } catch {
448
+ return false;
449
+ }
450
+ }
451
+
452
+ async function hasTrackableFiles(path: string): Promise<boolean> {
453
+ const stat = await fs.lstat(path);
454
+ if (stat.isFile()) return true;
455
+ if (!stat.isDirectory()) return true;
456
+ for (const entry of await fs.readdir(path, { withFileTypes: true })) {
457
+ if (SKIP_NAMES.has(entry.name)) continue;
458
+ if (entry.isFile() || entry.isSymbolicLink()) return true;
459
+ if (entry.isDirectory() && await hasTrackableFiles(join(path, entry.name))) return true;
460
+ }
461
+ return false;
462
+ }
463
+
464
+ async function runGit(
465
+ pi: ExtensionAPI,
466
+ cwd: string,
467
+ args: string[],
468
+ options: { allowFailure?: boolean; timeout?: number } = {},
469
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
470
+ const result = await pi.exec("git", args, { cwd, timeout: options.timeout ?? 120_000 });
471
+ const code = result.code ?? 0;
472
+ const stdout = result.stdout ?? "";
473
+ const stderr = result.stderr ?? "";
474
+ if (code !== 0 && !options.allowFailure) {
475
+ throw new Error(stderr.trim() || stdout.trim() || `git ${args[0] ?? "command"} failed with exit code ${code}`);
476
+ }
477
+ return { stdout, stderr, code };
478
+ }
479
+
480
+ async function resolveProjectKey(pi: ExtensionAPI, cwd: string): Promise<string> {
481
+ const configured = loadPiToolsSuiteConfig([], { cwd }).resourceRegistry.projectKey?.trim();
482
+ if (configured) {
483
+ validateName(configured);
484
+ return configured;
485
+ }
486
+ const origin = (await runGit(pi, cwd, ["remote", "get-url", "origin"], { allowFailure: true })).stdout.trim();
487
+ const derived = projectKeyFromGitRemote(origin);
488
+ if (derived) return derived;
489
+ throw new Error(`Cannot determine this project's registry key from Git origin. Run /${COMMAND} project-key <key> in this project.`);
490
+ }
491
+
492
+ async function gitRefExists(pi: ExtensionAPI, cwd: string, ref: string): Promise<boolean> {
493
+ return (await runGit(pi, cwd, ["rev-parse", "--verify", "--quiet", ref], { allowFailure: true })).code === 0;
494
+ }
495
+
496
+ async function ensureRegistryCache(pi: ExtensionAPI, runtime: RegistryRuntime): Promise<void> {
497
+ const gitDir = join(runtime.cacheDir, ".git");
498
+ if (await pathExists(gitDir)) {
499
+ const currentRemote = (await runGit(pi, runtime.cacheDir, ["remote", "get-url", "origin"], { allowFailure: true })).stdout.trim();
500
+ if (currentRemote !== runtime.remote) await fs.rm(runtime.cacheDir, { recursive: true, force: true });
501
+ } else if (await pathExists(runtime.cacheDir)) {
502
+ await fs.rm(runtime.cacheDir, { recursive: true, force: true });
503
+ }
504
+
505
+ if (!(await pathExists(gitDir))) {
506
+ await fs.mkdir(dirname(runtime.cacheDir), { recursive: true });
507
+ await runGit(pi, dirname(runtime.cacheDir), ["clone", "--origin", "origin", runtime.remote, runtime.cacheDir], { timeout: 180_000 });
508
+ }
509
+
510
+ await runGit(pi, runtime.cacheDir, ["reset", "--hard"], { allowFailure: true });
511
+ await runGit(pi, runtime.cacheDir, ["clean", "-fd"]);
512
+ await runGit(pi, runtime.cacheDir, ["fetch", "origin", "--prune"], { timeout: 180_000 });
513
+
514
+ const remoteRef = `refs/remotes/origin/${runtime.branch}`;
515
+ if (await gitRefExists(pi, runtime.cacheDir, remoteRef)) {
516
+ await runGit(pi, runtime.cacheDir, ["checkout", "-B", runtime.branch, `origin/${runtime.branch}`]);
517
+ return;
518
+ }
519
+
520
+ if (await gitRefExists(pi, runtime.cacheDir, "HEAD")) {
521
+ throw new Error(`Registry branch "${runtime.branch}" does not exist on ${runtime.remote}. Configure the correct branch.`);
522
+ }
523
+
524
+ const currentBranch = (await runGit(pi, runtime.cacheDir, ["symbolic-ref", "--quiet", "--short", "HEAD"], { allowFailure: true })).stdout.trim();
525
+ if (currentBranch !== runtime.branch) {
526
+ await runGit(pi, runtime.cacheDir, ["checkout", "--orphan", runtime.branch]);
527
+ }
528
+ }
529
+
530
+ async function pathRevision(pi: ExtensionAPI, runtime: RegistryRuntime, rel: string): Promise<string | undefined> {
531
+ const result = await runGit(pi, runtime.cacheDir, ["log", "-1", "--format=%H", "--", rel], { allowFailure: true });
532
+ const revision = result.stdout.trim();
533
+ return revision || undefined;
534
+ }
535
+
536
+ async function projectArtifactRevision(
537
+ pi: ExtensionAPI,
538
+ runtime: RegistryRuntime,
539
+ projectKey: string,
540
+ artifact: ProjectArtifact,
541
+ ): Promise<string | undefined> {
542
+ const result = await runGit(
543
+ pi,
544
+ runtime.cacheDir,
545
+ ["log", "-1", "--format=%H", "--", ...projectArtifactRelativePaths(projectKey, artifact)],
546
+ { allowFailure: true },
547
+ );
548
+ const revision = result.stdout.trim();
549
+ return revision || undefined;
550
+ }
551
+
552
+ async function resourceRevision(pi: ExtensionAPI, runtime: RegistryRuntime, type: ResourceType, name: string): Promise<string | undefined> {
553
+ return pathRevision(pi, runtime, registryResourceRelativePath(type, name));
554
+ }
555
+
556
+ function parseFrontmatterDescription(content: string): string {
557
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
558
+ if (!match) return "";
559
+ const lines = (match[1] ?? "").split(/\r?\n/);
560
+ const idx = lines.findIndex((line) => /^\s*description:\s*/.test(line));
561
+ if (idx === -1) return "";
562
+ const headerLine = lines[idx] ?? "";
563
+ const after = headerLine.replace(/^\s*description:\s*/, "");
564
+ if (/^[>|]/.test(after)) {
565
+ const block: string[] = [];
566
+ for (let i = idx + 1; i < lines.length; i += 1) {
567
+ const line = lines[i] ?? "";
568
+ if (line === "") {
569
+ block.push(" ");
570
+ continue;
571
+ }
572
+ if (/^\s+/.test(line)) {
573
+ block.push(line.replace(/^\s+/, ""));
574
+ continue;
575
+ }
576
+ break;
577
+ }
578
+ return block.join(" ").trim();
579
+ }
580
+ let value = after.trim();
581
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
582
+ return value;
583
+ }
584
+
585
+ async function readDescription(path: string): Promise<string> {
586
+ try {
587
+ return parseFrontmatterDescription(await fs.readFile(path, "utf8"));
588
+ } catch {
589
+ return "";
590
+ }
591
+ }
592
+
593
+ async function validAgentDefinition(path: string, name: string): Promise<boolean> {
594
+ try {
595
+ const parsed = parseAgentMarkdown(await fs.readFile(path, "utf8"), path);
596
+ if (!parsed) return false;
597
+ const declaredName = parsed.frontmatter.name;
598
+ return declaredName === undefined || declaredName === name;
599
+ } catch {
600
+ return false;
601
+ }
602
+ }
603
+
604
+ async function assertValidAgentDefinition(path: string, name: string): Promise<void> {
605
+ if (!(await validAgentDefinition(path, name))) {
606
+ throw new Error(`Agent "${name}" must be a valid .pi/agents Markdown definition with YAML frontmatter matching its filename.`);
607
+ }
608
+ }
609
+
610
+ async function scanSkills(dir: string): Promise<ResourceEntry[]> {
611
+ let entries: import("node:fs").Dirent[];
612
+ try {
613
+ entries = await fs.readdir(dir, { withFileTypes: true });
614
+ } catch (error) {
615
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
616
+ throw error;
617
+ }
618
+ const resources: ResourceEntry[] = [];
619
+ for (const entry of entries) {
620
+ if (!entry.isDirectory() || entry.isSymbolicLink() || SKIP_NAMES.has(entry.name)) continue;
621
+ if (!SAFE_NAME.test(entry.name) || entry.name.includes("..")) continue;
622
+ const skillPath = join(dir, entry.name);
623
+ const skillFile = join(skillPath, SKILL_FILE);
624
+ if (!(await pathExists(skillFile))) continue;
625
+ resources.push({ type: "skill", name: entry.name, path: skillPath, description: await readDescription(skillFile) });
626
+ }
627
+ return resources.sort((a, b) => a.name.localeCompare(b.name));
628
+ }
629
+
630
+ async function scanAgents(dir: string): Promise<ResourceEntry[]> {
631
+ let entries: import("node:fs").Dirent[];
632
+ try {
633
+ entries = await fs.readdir(dir, { withFileTypes: true });
634
+ } catch (error) {
635
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
636
+ throw error;
637
+ }
638
+ const resources: ResourceEntry[] = [];
639
+ for (const entry of entries) {
640
+ if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".md") || entry.name.startsWith(".")) continue;
641
+ const name = basename(entry.name, ".md");
642
+ if (!SAFE_NAME.test(name) || name.includes("..")) continue;
643
+ const filePath = join(dir, entry.name);
644
+ if (!(await validAgentDefinition(filePath, name))) continue;
645
+ resources.push({ type: "agent", name, path: filePath, description: await readDescription(filePath) });
646
+ }
647
+ return resources.sort((a, b) => a.name.localeCompare(b.name));
648
+ }
649
+
650
+ async function scanRegistry(runtime: RegistryRuntime): Promise<ResourceEntry[]> {
651
+ return [
652
+ ...(await scanSkills(join(runtime.cacheDir, REGISTRY_SKILLS_DIR))),
653
+ ...(await scanAgents(join(runtime.cacheDir, REGISTRY_AGENTS_DIR))),
654
+ ];
655
+ }
656
+
657
+ async function scanProject(project: ProjectContext): Promise<ResourceEntry[]> {
658
+ const cwd = projectCwd(project);
659
+ return [
660
+ ...(await scanSkills(join(cwd, PROJECT_DIR, PROJECT_SKILLS_DIR))),
661
+ ...(await scanAgents(join(cwd, PROJECT_DIR, PROJECT_AGENTS_DIR))),
662
+ ];
663
+ }
664
+
665
+ async function copyTree(source: string, destination: string, relativePath = ""): Promise<void> {
666
+ const stat = await fs.lstat(source);
667
+ if (stat.isSymbolicLink()) throw new Error(`Registry resources cannot contain symbolic links: ${relativePath || source}`);
668
+ if (stat.isFile()) {
669
+ await fs.mkdir(dirname(destination), { recursive: true });
670
+ await fs.copyFile(source, destination);
671
+ return;
672
+ }
673
+ if (!stat.isDirectory()) throw new Error(`Unsupported registry resource entry: ${relativePath || source}`);
674
+ await fs.mkdir(destination, { recursive: true });
675
+ const entries = (await fs.readdir(source, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
676
+ for (const entry of entries) {
677
+ if (SKIP_NAMES.has(entry.name)) continue;
678
+ const childRel = relativePath ? `${relativePath}/${entry.name}` : entry.name;
679
+ if (entry.isSymbolicLink()) throw new Error(`Registry resources cannot contain symbolic links: ${childRel}`);
680
+ await copyTree(join(source, entry.name), join(destination, entry.name), childRel);
681
+ }
682
+ }
683
+
684
+ async function replaceResource(source: string, destination: string): Promise<void> {
685
+ await fs.rm(destination, { recursive: true, force: true });
686
+ await copyTree(source, destination);
687
+ }
688
+
689
+ async function hashPath(path: string): Promise<string> {
690
+ const hash = createHash("sha256");
691
+ async function visit(current: string, rel: string): Promise<void> {
692
+ const stat = await fs.lstat(current);
693
+ if (stat.isSymbolicLink()) throw new Error(`Resource cannot contain symbolic links: ${rel || current}`);
694
+ if (stat.isFile()) {
695
+ hash.update(`file\0${rel}\0`);
696
+ hash.update(await fs.readFile(current));
697
+ return;
698
+ }
699
+ if (!stat.isDirectory()) throw new Error(`Unsupported resource entry: ${rel || current}`);
700
+ hash.update(`dir\0${rel}\0`);
701
+ const names = (await fs.readdir(current)).filter((name) => !SKIP_NAMES.has(name)).sort();
702
+ for (const name of names) await visit(join(current, name), rel ? `${rel}/${name}` : name);
703
+ }
704
+ await visit(path, "");
705
+ return hash.digest("hex");
706
+ }
707
+
708
+ type TaskBundle = {
709
+ source: string;
710
+ attachments: Map<string, string>;
711
+ };
712
+
713
+ function pathIsWithin(parent: string, child: string): boolean {
714
+ const rel = relative(parent, child);
715
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
716
+ }
717
+
718
+ function registryTaskAttachmentMarker(name: string): string {
719
+ return `[Pix attachment: ${REGISTRY_TASK_ATTACHMENT_SCHEME}${encodeURIComponent(name)}]`;
720
+ }
721
+
722
+ function registryTaskAttachmentName(encoded: string): string {
723
+ let name: string;
724
+ try {
725
+ name = decodeURIComponent(encoded);
726
+ } catch {
727
+ throw new Error(`Invalid registry task attachment name: ${encoded}`);
728
+ }
729
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") || basename(name) !== name) {
730
+ throw new Error(`Invalid registry task attachment name: ${encoded}`);
731
+ }
732
+ return name;
733
+ }
734
+
735
+ async function readLocalTaskBundle(cwd: string): Promise<TaskBundle> {
736
+ const tasksPath = projectArtifactLocalPath(cwd, "tasks");
737
+ const source = await fs.readFile(tasksPath, "utf8");
738
+ const attachmentsRoot = projectTaskAttachmentsLocalPath(cwd);
739
+ const attachments = new Map<string, string>();
740
+ const replacements = new Map<string, string>();
741
+ const rootExists = await pathExists(attachmentsRoot);
742
+ let canonicalRoot: string | undefined;
743
+ if (rootExists) {
744
+ const rootStat = await fs.lstat(attachmentsRoot);
745
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
746
+ throw new Error(`Project task attachment storage must be a regular directory: ${attachmentsRoot}`);
747
+ }
748
+ const [canonicalCwd, resolvedRoot] = await Promise.all([
749
+ fs.realpath(cwd),
750
+ fs.realpath(attachmentsRoot),
751
+ ]);
752
+ if (!pathIsWithin(canonicalCwd, resolvedRoot)) {
753
+ throw new Error(`Project task attachment storage resolves outside the project: ${attachmentsRoot}`);
754
+ }
755
+ canonicalRoot = resolvedRoot;
756
+ }
757
+
758
+ for (const match of source.matchAll(/\[Pix attachment: (file:\/\/[^\]]+)\]/g)) {
759
+ const marker = match[0];
760
+ const uri = match[1];
761
+ if (!uri || replacements.has(marker)) continue;
762
+ let candidate: string;
763
+ try {
764
+ candidate = fileURLToPath(uri);
765
+ } catch {
766
+ continue;
767
+ }
768
+ if (!canonicalRoot) continue;
769
+ const lexicalProjectAttachment = pathIsWithin(attachmentsRoot, candidate);
770
+
771
+ let canonicalFile: string;
772
+ try {
773
+ canonicalFile = await fs.realpath(candidate);
774
+ } catch (error) {
775
+ if (lexicalProjectAttachment) {
776
+ throw new Error(`Task attachment is missing: ${candidate}. ${error instanceof Error ? error.message : String(error)}`);
777
+ }
778
+ continue;
779
+ }
780
+ if (!pathIsWithin(canonicalRoot, canonicalFile)) {
781
+ if (lexicalProjectAttachment) throw new Error(`Task attachment resolves outside project task storage: ${candidate}`);
782
+ continue;
783
+ }
784
+ const stat = await fs.lstat(candidate);
785
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Task attachment is not a regular file: ${candidate}`);
786
+ const name = basename(canonicalFile);
787
+ const previous = attachments.get(name);
788
+ if (previous && previous !== canonicalFile) {
789
+ throw new Error(`Task attachments contain duplicate file name "${name}".`);
790
+ }
791
+ attachments.set(name, canonicalFile);
792
+ replacements.set(marker, registryTaskAttachmentMarker(name));
793
+ }
794
+
795
+ let normalizedSource = source;
796
+ for (const [marker, portableMarker] of replacements) {
797
+ normalizedSource = normalizedSource.split(marker).join(portableMarker);
798
+ }
799
+ return { source: normalizedSource, attachments };
800
+ }
801
+
802
+ async function readRemoteTaskBundle(runtime: RegistryRuntime, projectKey: string): Promise<TaskBundle> {
803
+ const tasksPath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, "tasks");
804
+ const source = await fs.readFile(tasksPath, "utf8");
805
+ const attachmentsRoot = projectTaskAttachmentsRegistryPath(runtime.cacheDir, projectKey);
806
+ const attachments = new Map<string, string>();
807
+ for (const match of source.matchAll(/\[Pix attachment: pix-task-attachment:([^\]]+)\]/g)) {
808
+ const encoded = match[1];
809
+ if (!encoded) continue;
810
+ const name = registryTaskAttachmentName(encoded);
811
+ if (attachments.has(name)) continue;
812
+ const filePath = join(attachmentsRoot, name);
813
+ let stat: import("node:fs").Stats;
814
+ try {
815
+ stat = await fs.lstat(filePath);
816
+ } catch (error) {
817
+ throw new Error(`Registry task attachment is missing: ${name}. ${error instanceof Error ? error.message : String(error)}`);
818
+ }
819
+ if (stat.isSymbolicLink() || !stat.isFile()) {
820
+ throw new Error(`Registry task attachment is not a regular file: ${name}`);
821
+ }
822
+ attachments.set(name, filePath);
823
+ }
824
+ return { source, attachments };
825
+ }
826
+
827
+ async function hashTaskBundle(bundle: TaskBundle): Promise<string> {
828
+ const hash = createHash("sha256");
829
+ hash.update("tasks-bundle\0");
830
+ hash.update(bundle.source);
831
+ for (const name of [...bundle.attachments.keys()].sort()) {
832
+ hash.update(`\0attachment\0${name}\0`);
833
+ hash.update(await fs.readFile(bundle.attachments.get(name)!));
834
+ }
835
+ return hash.digest("hex");
836
+ }
837
+
838
+ async function hashProjectArtifactLocal(cwd: string, artifact: ProjectArtifact): Promise<string> {
839
+ return artifact === "tasks"
840
+ ? hashTaskBundle(await readLocalTaskBundle(cwd))
841
+ : hashPath(projectArtifactLocalPath(cwd, artifact));
842
+ }
843
+
844
+ async function hashProjectArtifactRemote(
845
+ runtime: RegistryRuntime,
846
+ projectKey: string,
847
+ artifact: ProjectArtifact,
848
+ ): Promise<string> {
849
+ return artifact === "tasks"
850
+ ? hashTaskBundle(await readRemoteTaskBundle(runtime, projectKey))
851
+ : hashPath(projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact));
852
+ }
853
+
854
+ async function replaceRemoteTaskBundle(cwd: string, runtime: RegistryRuntime, projectKey: string): Promise<void> {
855
+ const bundle = await readLocalTaskBundle(cwd);
856
+ const tasksPath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, "tasks");
857
+ const attachmentsPath = projectTaskAttachmentsRegistryPath(runtime.cacheDir, projectKey);
858
+ await fs.rm(tasksPath, { recursive: true, force: true });
859
+ await fs.rm(attachmentsPath, { recursive: true, force: true });
860
+ await fs.mkdir(dirname(tasksPath), { recursive: true });
861
+ await fs.writeFile(tasksPath, bundle.source, "utf8");
862
+ if (bundle.attachments.size === 0) return;
863
+ await fs.mkdir(attachmentsPath, { recursive: true });
864
+ for (const name of [...bundle.attachments.keys()].sort()) {
865
+ await fs.copyFile(bundle.attachments.get(name)!, join(attachmentsPath, name));
866
+ }
867
+ }
868
+
869
+ async function replaceLocalTaskBundle(cwd: string, runtime: RegistryRuntime, projectKey: string): Promise<void> {
870
+ const bundle = await readRemoteTaskBundle(runtime, projectKey);
871
+ const tasksPath = projectArtifactLocalPath(cwd, "tasks");
872
+ const attachmentsPath = projectTaskAttachmentsLocalPath(cwd);
873
+ await fs.mkdir(dirname(tasksPath), { recursive: true });
874
+
875
+ let materializedSource = bundle.source;
876
+ for (const name of bundle.attachments.keys()) {
877
+ materializedSource = materializedSource
878
+ .split(registryTaskAttachmentMarker(name))
879
+ .join(`[Pix attachment: ${pathToFileURL(join(attachmentsPath, name)).href}]`);
880
+ }
881
+
882
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
883
+ const stagedTasks = join(dirname(tasksPath), `.tasks.registry-${stamp}.tmp`);
884
+ const stagedAttachments = join(dirname(tasksPath), `.task-attachments.registry-${stamp}.tmp`);
885
+ const backupTasks = join(dirname(tasksPath), `.tasks.registry-${stamp}.bak`);
886
+ const backupAttachments = join(dirname(tasksPath), `.task-attachments.registry-${stamp}.bak`);
887
+ let tasksBackedUp = false;
888
+ let attachmentsBackedUp = false;
889
+ let tasksInstalled = false;
890
+ let attachmentsInstalled = false;
891
+ try {
892
+ await fs.writeFile(stagedTasks, materializedSource, "utf8");
893
+ if (bundle.attachments.size > 0) {
894
+ await fs.mkdir(stagedAttachments, { recursive: true });
895
+ for (const name of [...bundle.attachments.keys()].sort()) {
896
+ await fs.copyFile(bundle.attachments.get(name)!, join(stagedAttachments, name));
897
+ }
898
+ }
899
+
900
+ if (await pathExists(tasksPath)) {
901
+ await fs.rename(tasksPath, backupTasks);
902
+ tasksBackedUp = true;
903
+ }
904
+ if (await pathExists(attachmentsPath)) {
905
+ await fs.rename(attachmentsPath, backupAttachments);
906
+ attachmentsBackedUp = true;
907
+ }
908
+ if (bundle.attachments.size > 0) {
909
+ await fs.rename(stagedAttachments, attachmentsPath);
910
+ attachmentsInstalled = true;
911
+ }
912
+ await fs.rename(stagedTasks, tasksPath);
913
+ tasksInstalled = true;
914
+ } catch (error) {
915
+ if (tasksInstalled) await fs.rm(tasksPath, { recursive: true, force: true }).catch(() => undefined);
916
+ if (attachmentsInstalled) await fs.rm(attachmentsPath, { recursive: true, force: true }).catch(() => undefined);
917
+ if (tasksBackedUp) await fs.rename(backupTasks, tasksPath).catch(() => undefined);
918
+ if (attachmentsBackedUp) await fs.rename(backupAttachments, attachmentsPath).catch(() => undefined);
919
+ throw error;
920
+ } finally {
921
+ await fs.rm(stagedTasks, { recursive: true, force: true }).catch(() => undefined);
922
+ await fs.rm(stagedAttachments, { recursive: true, force: true }).catch(() => undefined);
923
+ }
924
+ await fs.rm(backupTasks, { recursive: true, force: true }).catch(() => undefined);
925
+ await fs.rm(backupAttachments, { recursive: true, force: true }).catch(() => undefined);
926
+ }
927
+
928
+ async function readProvenance(project: ProjectContext): Promise<Provenance> {
929
+ try {
930
+ const parsed = JSON.parse(await fs.readFile(provenancePath(project), "utf8")) as Partial<Provenance>;
931
+ if (parsed.version !== PROVENANCE_VERSION || !parsed.resources || typeof parsed.resources !== "object") {
932
+ return { version: PROVENANCE_VERSION, resources: {}, projectResources: {} };
933
+ }
934
+ return {
935
+ version: PROVENANCE_VERSION,
936
+ resources: parsed.resources,
937
+ projectResources: parsed.projectResources && typeof parsed.projectResources === "object" ? parsed.projectResources : {},
938
+ };
939
+ } catch {
940
+ return { version: PROVENANCE_VERSION, resources: {}, projectResources: {} };
941
+ }
942
+ }
943
+
944
+ async function writeProvenance(ctx: ExtensionContext, provenance: Provenance): Promise<void> {
945
+ const filePath = provenancePath(ctx);
946
+ await fs.mkdir(dirname(filePath), { recursive: true });
947
+ await fs.writeFile(filePath, `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
948
+ }
949
+
950
+ async function recordProvenance(
951
+ ctx: ExtensionContext,
952
+ runtime: RegistryRuntime,
953
+ type: ResourceType,
954
+ name: string,
955
+ revision: string,
956
+ hash: string,
957
+ ): Promise<void> {
958
+ const provenance = await readProvenance(ctx);
959
+ provenance.resources[resourceKey(type, name)] = {
960
+ type,
961
+ name,
962
+ remote: runtime.remote,
963
+ branch: runtime.branch,
964
+ revision,
965
+ hash,
966
+ };
967
+ await writeProvenance(ctx, provenance);
968
+ }
969
+
970
+ async function clearResourceProvenance(ctx: ExtensionContext, type: ResourceType, name: string): Promise<void> {
971
+ const provenance = await readProvenance(ctx);
972
+ const key = resourceKey(type, name);
973
+ if (!(key in provenance.resources)) return;
974
+ delete provenance.resources[key];
975
+ await writeProvenance(ctx, provenance);
976
+ }
977
+
978
+ async function clearResourceProvenanceEntries(ctx: ExtensionContext, entries: ResourceEntry[]): Promise<void> {
979
+ const provenance = await readProvenance(ctx);
980
+ let changed = false;
981
+ for (const entry of entries) {
982
+ const key = resourceKey(entry.type, entry.name);
983
+ if (!(key in provenance.resources)) continue;
984
+ delete provenance.resources[key];
985
+ changed = true;
986
+ }
987
+ if (changed) await writeProvenance(ctx, provenance);
988
+ }
989
+
990
+ async function clearProjectProvenanceEntries(ctx: ExtensionContext, artifacts: ProjectArtifact[]): Promise<void> {
991
+ if (artifacts.length === 0) return;
992
+ const provenance = await readProvenance(ctx);
993
+ let changed = false;
994
+ for (const artifact of artifacts) {
995
+ if (!(artifact in provenance.projectResources)) continue;
996
+ delete provenance.projectResources[artifact];
997
+ changed = true;
998
+ }
999
+ if (changed) await writeProvenance(ctx, provenance);
1000
+ }
1001
+
1002
+ async function recordProjectProvenance(
1003
+ ctx: ExtensionContext,
1004
+ runtime: RegistryRuntime,
1005
+ projectKey: string,
1006
+ artifact: ProjectArtifact,
1007
+ revision: string,
1008
+ hash: string,
1009
+ ): Promise<void> {
1010
+ const provenance = await readProvenance(ctx);
1011
+ provenance.projectResources[artifact] = {
1012
+ artifact,
1013
+ projectKey,
1014
+ remote: runtime.remote,
1015
+ branch: runtime.branch,
1016
+ revision,
1017
+ hash,
1018
+ };
1019
+ await writeProvenance(ctx, provenance);
1020
+ }
1021
+
1022
+ async function collectStatuses(pi: ExtensionAPI, project: ProjectContext, runtime: RegistryRuntime): Promise<RegistryStatus[]> {
1023
+ const [remoteResources, localResources, provenance] = await Promise.all([
1024
+ scanRegistry(runtime),
1025
+ scanProject(project),
1026
+ readProvenance(project),
1027
+ ]);
1028
+ const remoteMap = new Map(remoteResources.map((entry) => [resourceKey(entry.type, entry.name), entry]));
1029
+ const localMap = new Map(localResources.map((entry) => [resourceKey(entry.type, entry.name), entry]));
1030
+ const keys = new Set([...remoteMap.keys(), ...localMap.keys(), ...Object.keys(provenance.resources)]);
1031
+ const statuses: RegistryStatus[] = [];
1032
+
1033
+ for (const key of [...keys].sort()) {
1034
+ const remote = remoteMap.get(key);
1035
+ const local = localMap.get(key);
1036
+ const tracked = provenance.resources[key];
1037
+ const type = remote?.type ?? local?.type ?? tracked?.type;
1038
+ const name = remote?.name ?? local?.name ?? tracked?.name;
1039
+ if (!type || !name) continue;
1040
+
1041
+ if (tracked && (tracked.remote !== runtime.remote || tracked.branch !== runtime.branch)) {
1042
+ statuses.push({ type, name, kind: "registry-changed", local, remote, provenance: tracked });
1043
+ continue;
1044
+ }
1045
+
1046
+ if (!local && !tracked) {
1047
+ statuses.push({ type, name, kind: "not-installed", remote });
1048
+ continue;
1049
+ }
1050
+ if (local && !tracked) {
1051
+ statuses.push({ type, name, kind: remote ? "untracked-local" : "local-only", local, remote });
1052
+ continue;
1053
+ }
1054
+ if (tracked && !local) {
1055
+ statuses.push({ type, name, kind: remote ? "missing-local" : "removed-remote", remote, provenance: tracked });
1056
+ continue;
1057
+ }
1058
+ if (!tracked || !local) continue;
1059
+ if (!remote) {
1060
+ statuses.push({ type, name, kind: "removed-remote", local, provenance: tracked });
1061
+ continue;
1062
+ }
1063
+
1064
+ const [localHash, remoteRevision] = await Promise.all([
1065
+ hashPath(local.path),
1066
+ resourceRevision(pi, runtime, type, name),
1067
+ ]);
1068
+ const localChanged = localHash !== tracked.hash;
1069
+ const remoteChanged = remoteRevision !== tracked.revision;
1070
+ const kind: RegistryStatusKind = localChanged && remoteChanged
1071
+ ? "diverged"
1072
+ : localChanged
1073
+ ? "local-changes"
1074
+ : remoteChanged
1075
+ ? "update-available"
1076
+ : "up-to-date";
1077
+ statuses.push({ type, name, kind, local, remote, provenance: tracked, remoteRevision });
1078
+ }
1079
+
1080
+ return statuses;
1081
+ }
1082
+
1083
+ function projectArtifacts(scope: ProjectScope): ProjectArtifact[] {
1084
+ return scope === "project" ? ["tasks", "plans", "todo"] : [scope];
1085
+ }
1086
+
1087
+ async function collectProjectStatuses(
1088
+ pi: ExtensionAPI,
1089
+ project: ProjectContext,
1090
+ runtime: RegistryRuntime,
1091
+ ): Promise<ProjectStatusBundle> {
1092
+ const cwd = projectCwd(project);
1093
+ let projectKey: string;
1094
+ try {
1095
+ projectKey = await resolveProjectKey(pi, cwd);
1096
+ } catch (error) {
1097
+ return { statuses: [], issue: error instanceof Error ? error.message : String(error) };
1098
+ }
1099
+ const provenance = await readProvenance(cwd);
1100
+ const statuses: ProjectArtifactStatus[] = [];
1101
+ for (const artifact of ["tasks", "plans", "todo"] as const) {
1102
+ const localPath = projectArtifactLocalPath(cwd, artifact);
1103
+ const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1104
+ const [localPathExists, remoteExists] = await Promise.all([pathExists(localPath), pathExists(remotePath)]);
1105
+ const tracked = provenance.projectResources[artifact];
1106
+ const localExists = localPathExists && (
1107
+ artifact !== "plans"
1108
+ || await hasTrackableFiles(localPath)
1109
+ || remoteExists
1110
+ || Boolean(tracked)
1111
+ );
1112
+ if (!localExists && !remoteExists && !tracked) continue;
1113
+ if (tracked && (tracked.remote !== runtime.remote || tracked.branch !== runtime.branch || tracked.projectKey !== projectKey)) {
1114
+ statuses.push({ artifact, kind: "registry-changed", localExists, remoteExists, provenance: tracked });
1115
+ continue;
1116
+ }
1117
+ if (!localExists && !tracked) {
1118
+ statuses.push({ artifact, kind: "not-installed", localExists, remoteExists });
1119
+ continue;
1120
+ }
1121
+ if (localExists && !tracked) {
1122
+ statuses.push({ artifact, kind: remoteExists ? "untracked-local" : "local-only", localExists, remoteExists });
1123
+ continue;
1124
+ }
1125
+ if (tracked && !localExists) {
1126
+ statuses.push({ artifact, kind: remoteExists ? "missing-local" : "removed-remote", localExists, remoteExists, provenance: tracked });
1127
+ continue;
1128
+ }
1129
+ if (!tracked || !localExists) continue;
1130
+ if (!remoteExists) {
1131
+ statuses.push({ artifact, kind: "removed-remote", localExists, remoteExists, provenance: tracked });
1132
+ continue;
1133
+ }
1134
+ const [localHash, remoteRevision] = await Promise.all([
1135
+ hashProjectArtifactLocal(cwd, artifact),
1136
+ projectArtifactRevision(pi, runtime, projectKey, artifact),
1137
+ ]);
1138
+ const localChanged = localHash !== tracked.hash;
1139
+ const remoteChanged = remoteRevision !== tracked.revision;
1140
+ const kind: RegistryStatusKind = localChanged && remoteChanged
1141
+ ? "diverged"
1142
+ : localChanged
1143
+ ? "local-changes"
1144
+ : remoteChanged
1145
+ ? "update-available"
1146
+ : "up-to-date";
1147
+ statuses.push({ artifact, kind, localExists, remoteExists, provenance: tracked, remoteRevision });
1148
+ }
1149
+ return { projectKey, statuses };
1150
+ }
1151
+
1152
+ function statusIcon(kind: RegistryStatusKind): string {
1153
+ switch (kind) {
1154
+ case "up-to-date": return "✓";
1155
+ case "update-available": return "↓";
1156
+ case "local-changes": return "↑";
1157
+ case "diverged": return "↕";
1158
+ case "not-installed": return "·";
1159
+ case "local-only": return "+";
1160
+ case "untracked-local": return "?";
1161
+ case "missing-local": return "!";
1162
+ case "removed-remote": return "×";
1163
+ case "registry-changed": return "!";
1164
+ }
1165
+ }
1166
+
1167
+ function statusLabel(kind: RegistryStatusKind): string {
1168
+ switch (kind) {
1169
+ case "up-to-date": return "UP TO DATE";
1170
+ case "update-available": return "OUTDATED";
1171
+ case "local-changes": return "LOCAL CHANGES";
1172
+ case "diverged": return "CONFLICT";
1173
+ case "not-installed": return "NOT INSTALLED";
1174
+ case "local-only": return "LOCAL ONLY";
1175
+ case "untracked-local": return "UNTRACKED LOCAL";
1176
+ case "missing-local": return "MISSING LOCALLY";
1177
+ case "removed-remote": return "REMOVED FROM REGISTRY";
1178
+ case "registry-changed": return "OTHER REGISTRY";
1179
+ }
1180
+ }
1181
+
1182
+ function resourceTypeBadge(type: ResourceType | "project"): string {
1183
+ switch (type) {
1184
+ case "skill": return "[SKILL]";
1185
+ case "agent": return "[AGENT]";
1186
+ case "project": return "[PROJECT]";
1187
+ }
1188
+ }
1189
+
1190
+ const STATUS_GROUP_ORDER: readonly RegistryStatusKind[] = [
1191
+ "diverged",
1192
+ "update-available",
1193
+ "local-changes",
1194
+ "missing-local",
1195
+ "registry-changed",
1196
+ "removed-remote",
1197
+ "untracked-local",
1198
+ "local-only",
1199
+ "not-installed",
1200
+ "up-to-date",
1201
+ ];
1202
+
1203
+ function registryStatusRank(kind: RegistryStatusKind): number {
1204
+ const index = STATUS_GROUP_ORDER.indexOf(kind);
1205
+ return index < 0 ? Number.MAX_SAFE_INTEGER : index;
1206
+ }
1207
+
1208
+ function reusableUiActions(status: RegistryStatus): RegistryUiAction[] {
1209
+ const actions: RegistryUiAction[] = [];
1210
+ switch (status.kind) {
1211
+ case "not-installed":
1212
+ actions.push("install");
1213
+ break;
1214
+ case "update-available":
1215
+ case "missing-local":
1216
+ actions.push("update");
1217
+ break;
1218
+ case "local-changes":
1219
+ case "local-only":
1220
+ case "untracked-local":
1221
+ case "removed-remote":
1222
+ actions.push("push");
1223
+ break;
1224
+ case "up-to-date":
1225
+ case "diverged":
1226
+ case "registry-changed":
1227
+ break;
1228
+ }
1229
+ if (status.local) actions.push("uninstall");
1230
+ if (status.remote) actions.push("remove");
1231
+ return actions;
1232
+ }
1233
+
1234
+ function projectUiActions(status: ProjectArtifactStatus): RegistryUiAction[] {
1235
+ switch (status.kind) {
1236
+ case "not-installed":
1237
+ case "update-available":
1238
+ case "missing-local":
1239
+ return ["pull"];
1240
+ case "local-changes":
1241
+ case "local-only":
1242
+ case "removed-remote":
1243
+ return ["push"];
1244
+ case "untracked-local":
1245
+ return status.remoteExists ? ["push", "pull"] : ["push"];
1246
+ case "up-to-date":
1247
+ case "diverged":
1248
+ case "registry-changed":
1249
+ return [];
1250
+ }
1251
+ }
1252
+
1253
+ function registryUiItems(statuses: RegistryStatus[], projectStatus?: ProjectStatusBundle): RegistryUiItem[] {
1254
+ const items: RegistryUiItem[] = [
1255
+ ...statuses.map((status): RegistryUiItem => ({
1256
+ id: `${status.type}:${status.name}`,
1257
+ type: status.type,
1258
+ name: status.name,
1259
+ status: status.kind,
1260
+ statusLabel: statusLabel(status.kind),
1261
+ icon: statusIcon(status.kind),
1262
+ ...(status.local?.description || status.remote?.description
1263
+ ? { description: status.local?.description || status.remote?.description }
1264
+ : {}),
1265
+ local: Boolean(status.local),
1266
+ remote: Boolean(status.remote),
1267
+ actions: reusableUiActions(status),
1268
+ })),
1269
+ ...(projectStatus?.statuses ?? []).map((status): RegistryUiItem => ({
1270
+ id: `project:${status.artifact}`,
1271
+ type: "project",
1272
+ name: projectArtifactDisplayName(status.artifact),
1273
+ artifact: status.artifact,
1274
+ status: status.kind,
1275
+ statusLabel: statusLabel(status.kind),
1276
+ icon: statusIcon(status.kind),
1277
+ local: status.localExists,
1278
+ remote: status.remoteExists,
1279
+ actions: projectUiActions(status),
1280
+ })),
1281
+ ];
1282
+ return items.sort((left, right) => {
1283
+ const byStatus = registryStatusRank(left.status) - registryStatusRank(right.status);
1284
+ if (byStatus !== 0) return byStatus;
1285
+ const byName = left.name.localeCompare(right.name);
1286
+ return byName !== 0 ? byName : left.id.localeCompare(right.id);
1287
+ });
1288
+ }
1289
+
1290
+ async function collectRegistryUiSnapshot(
1291
+ pi: ExtensionAPI,
1292
+ project: ProjectContext,
1293
+ error?: string,
1294
+ ): Promise<RegistryUiSnapshot> {
1295
+ const cwd = projectCwd(project);
1296
+ const config = loadPiToolsSuiteConfig([], { cwd }).resourceRegistry;
1297
+ const checkedAt = new Date().toISOString();
1298
+ if (!config.remote) {
1299
+ return {
1300
+ version: 1,
1301
+ configured: false,
1302
+ branch: config.branch,
1303
+ items: [],
1304
+ checkedAt,
1305
+ ...(error ? { error } : {}),
1306
+ };
1307
+ }
1308
+ const configuredRuntime = loadRuntimeConfig(cwd);
1309
+ const runtime: RegistryRuntime = { ...configuredRuntime, cacheDir: registryUiCacheRoot() };
1310
+ return withRegistryUiCache(runtime.cacheDir, async () => {
1311
+ await ensureRegistryCache(pi, runtime);
1312
+ const [statuses, projectStatus] = await Promise.all([
1313
+ collectStatuses(pi, cwd, runtime),
1314
+ collectProjectStatuses(pi, cwd, runtime),
1315
+ ]);
1316
+ return {
1317
+ version: 1,
1318
+ configured: true,
1319
+ remote: runtime.remote,
1320
+ branch: runtime.branch,
1321
+ ...(projectStatus.projectKey ? { projectKey: projectStatus.projectKey } : {}),
1322
+ ...(projectStatus.issue ? { projectIssue: projectStatus.issue } : {}),
1323
+ items: registryUiItems(statuses, projectStatus),
1324
+ checkedAt,
1325
+ ...(error ? { error } : {}),
1326
+ };
1327
+ });
1328
+ }
1329
+
1330
+ async function registryUiSnapshotForProject(
1331
+ pi: ExtensionAPI,
1332
+ cwd: string,
1333
+ error?: string,
1334
+ ): Promise<RegistryUiSnapshot> {
1335
+ const generation = registryUiSnapshotGenerations.get(cwd) ?? 0;
1336
+ if (!error) {
1337
+ const cached = registryUiSnapshotsByProject.get(cwd);
1338
+ if (cached) return cached;
1339
+ const existing = registryUiSnapshotLoadsByProject.get(cwd);
1340
+ if (existing?.generation === generation) return existing.promise;
1341
+ }
1342
+
1343
+ const pending = collectRegistryUiSnapshot(pi, cwd, error)
1344
+ .then((snapshot) => {
1345
+ if ((registryUiSnapshotGenerations.get(cwd) ?? 0) === generation) {
1346
+ registryUiSnapshotsByProject.set(cwd, snapshot);
1347
+ }
1348
+ return snapshot;
1349
+ })
1350
+ .finally(() => {
1351
+ if (registryUiSnapshotLoadsByProject.get(cwd)?.promise === pending) {
1352
+ registryUiSnapshotLoadsByProject.delete(cwd);
1353
+ }
1354
+ });
1355
+ registryUiSnapshotLoadsByProject.set(cwd, { generation, promise: pending });
1356
+ return pending;
1357
+ }
1358
+
1359
+ async function publishRegistryUiSnapshot(
1360
+ pi: ExtensionAPI,
1361
+ ctx: ExtensionContext,
1362
+ error?: string,
1363
+ ): Promise<void> {
1364
+ let config: ResourceRegistryConfig | undefined;
1365
+ let cwd: string | undefined;
1366
+ try {
1367
+ if (!registryRpcBridgeEnabled(ctx)) return;
1368
+ cwd = ctx.cwd;
1369
+ config = loadPiToolsSuiteConfig([], { cwd }).resourceRegistry;
1370
+ publishRpcSessionState(ctx, REGISTRY_STATE_EVENT, await registryUiSnapshotForProject(pi, cwd, error));
1371
+ } catch (snapshotError) {
1372
+ // Session replacement invalidates the old extension context while this
1373
+ // best-effort background snapshot may still be awaiting Git/filesystem
1374
+ // work. Never let that expected race escape the detached callback and
1375
+ // terminate the Pi RPC subprocess.
1376
+ if (isStaleExtensionContextError(snapshotError)) return;
1377
+ try {
1378
+ if (!registryRpcBridgeEnabled(ctx)) return;
1379
+ cwd ??= ctx.cwd;
1380
+ config ??= loadPiToolsSuiteConfig([], { cwd }).resourceRegistry;
1381
+ const fallback = {
1382
+ version: 1,
1383
+ configured: Boolean(config.remote),
1384
+ ...(config.remote ? { remote: config.remote } : {}),
1385
+ branch: config.branch,
1386
+ items: [],
1387
+ checkedAt: new Date().toISOString(),
1388
+ error: error ?? (snapshotError instanceof Error ? snapshotError.message : String(snapshotError)),
1389
+ } satisfies RegistryUiSnapshot;
1390
+ if (!registryUiSnapshotsByProject.has(cwd) && !registryUiSnapshotLoadsByProject.has(cwd)) {
1391
+ registryUiSnapshotsByProject.set(cwd, fallback);
1392
+ }
1393
+ publishRpcSessionState(ctx, REGISTRY_STATE_EVENT, fallback);
1394
+ } catch (fallbackError) {
1395
+ if (isStaleExtensionContextError(fallbackError)) return;
1396
+ // Structured Desktop state is advisory. A failed fallback publication
1397
+ // must not make session startup/replacement fatal.
1398
+ }
1399
+ }
1400
+ }
1401
+
1402
+ function registryRpcBridgeEnabled(ctx: ExtensionContext): boolean {
1403
+ return process.env[RPC_SESSION_STATE_ENV] === "1"
1404
+ && (ctx as ExtensionContext & { mode?: unknown }).mode === "rpc"
1405
+ && typeof (ctx.ui as unknown as { setWidget?: unknown }).setWidget === "function";
1406
+ }
1407
+
1408
+ function scheduleRegistryUiSnapshot(pi: ExtensionAPI, ctx: ExtensionContext): void {
1409
+ try {
1410
+ if (!registryRpcBridgeEnabled(ctx)) return;
1411
+ } catch (error) {
1412
+ if (isStaleExtensionContextError(error)) return;
1413
+ throw error;
1414
+ }
1415
+ const timer = setTimeout(() => {
1416
+ void publishRegistryUiSnapshot(pi, ctx).catch(() => undefined);
1417
+ }, 0);
1418
+ timer.unref?.();
1419
+ }
1420
+
1421
+ function formatStatuses(statuses: RegistryStatus[], runtime: RegistryRuntime, projectStatus?: ProjectStatusBundle): string {
1422
+ const lines = [`Registry: ${runtime.remote} (${runtime.branch})`];
1423
+ if (projectStatus) {
1424
+ if (projectStatus.projectKey) lines.push(`Project: ${projectStatus.projectKey}`);
1425
+ if (projectStatus.issue) lines.push(`Project state: ! ${projectStatus.issue}`);
1426
+ }
1427
+
1428
+ type StatusLine = {
1429
+ kind: RegistryStatusKind;
1430
+ name: string;
1431
+ type: ResourceType | "project";
1432
+ tieBreaker: string;
1433
+ };
1434
+ const rows: StatusLine[] = [
1435
+ ...statuses.map((status) => ({
1436
+ kind: status.kind,
1437
+ name: status.name,
1438
+ type: status.type,
1439
+ tieBreaker: status.type,
1440
+ })),
1441
+ ...(projectStatus?.statuses ?? []).map((status) => ({
1442
+ kind: status.kind,
1443
+ name: projectArtifactDisplayName(status.artifact),
1444
+ type: "project" as const,
1445
+ tieBreaker: `project:${status.artifact}`,
1446
+ })),
1447
+ ];
1448
+ const statusRank = new Map(STATUS_GROUP_ORDER.map((kind, index) => [kind, index]));
1449
+ rows.sort((left, right) => {
1450
+ const byStatus = (statusRank.get(left.kind) ?? Number.MAX_SAFE_INTEGER)
1451
+ - (statusRank.get(right.kind) ?? Number.MAX_SAFE_INTEGER);
1452
+ if (byStatus !== 0) return byStatus;
1453
+ const byName = left.name.localeCompare(right.name);
1454
+ return byName !== 0 ? byName : left.tieBreaker.localeCompare(right.tieBreaker);
1455
+ });
1456
+
1457
+ if (rows.length > 0) {
1458
+ lines.push("");
1459
+ for (const row of rows) {
1460
+ lines.push(`${statusIcon(row.kind)} ${row.name} ${resourceTypeBadge(row.type)} **${statusLabel(row.kind)}**`);
1461
+ }
1462
+ } else if (!projectStatus?.issue) {
1463
+ lines.push("", `No skills, agents, ${PROJECT_TASKS_FILE}, ${PROJECT_PLANS_DIR}/, or ${PROJECT_TODO_FILE} state found locally or remotely.`);
1464
+ }
1465
+ return lines.join("\n");
1466
+ }
1467
+
1468
+ function startupUpdateSummary(statuses: RegistryStatus[], projectStatuses: ProjectArtifactStatus[] = []): { updates: number; conflicts: number } {
1469
+ return {
1470
+ updates: statuses.filter((status) => status.kind === "update-available").length
1471
+ + projectStatuses.filter((status) => ["update-available", "missing-local", "not-installed"].includes(status.kind)).length,
1472
+ conflicts: statuses.filter((status) => status.kind === "diverged").length
1473
+ + projectStatuses.filter((status) => status.kind === "diverged").length,
1474
+ };
1475
+ }
1476
+
1477
+ function formatStartupUpdateToast(summary: { updates: number; conflicts: number }): string {
1478
+ const parts: string[] = [];
1479
+ if (summary.updates > 0) parts.push(`${summary.updates} update${summary.updates === 1 ? "" : "s"} available`);
1480
+ if (summary.conflicts > 0) parts.push(`${summary.conflicts} conflict${summary.conflicts === 1 ? "" : "s"}`);
1481
+ return `Resource registry: ${parts.join(", ")}. Run /${COMMAND} status.`;
1482
+ }
1483
+
1484
+ async function runStartupUpdateCheck(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
1485
+ try {
1486
+ const runtime = loadRuntimeConfig(ctx.cwd);
1487
+ const checkRuntime: RegistryRuntime = { ...runtime, cacheDir: startupCheckCacheRoot() };
1488
+ await ensureRegistryCache(pi, checkRuntime);
1489
+ const [statuses, projectStatus] = await Promise.all([
1490
+ collectStatuses(pi, ctx, checkRuntime),
1491
+ collectProjectStatuses(pi, ctx, checkRuntime),
1492
+ ]);
1493
+ const summary = startupUpdateSummary(statuses, projectStatus.statuses);
1494
+ if (summary.updates === 0 && summary.conflicts === 0) return;
1495
+ if (ctx.hasUI) ctx.ui.notify(formatStartupUpdateToast(summary), "warning");
1496
+ } catch {
1497
+ // Startup checks are best-effort and must never delay or fail application startup.
1498
+ }
1499
+ }
1500
+
1501
+ function scheduleStartupUpdateCheck(pi: ExtensionAPI, ctx: ExtensionContext): void {
1502
+ const config = loadPiToolsSuiteConfig([], { cwd: ctx.cwd }).resourceRegistry;
1503
+ if (!config.remote) return;
1504
+ const timer = setTimeout(() => {
1505
+ void runStartupUpdateCheck(pi, ctx);
1506
+ }, 0);
1507
+ timer.unref?.();
1508
+ }
1509
+
1510
+ async function confirmOverwrite(ctx: ExtensionCommandContext, title: string, message: string): Promise<boolean> {
1511
+ if (!ctx.hasUI) return false;
1512
+ try {
1513
+ return await ctx.ui.confirm(title, message);
1514
+ } catch (error) {
1515
+ ignoreStaleExtensionContextError(error);
1516
+ return false;
1517
+ }
1518
+ }
1519
+
1520
+ async function reloadAfterResourceChange(ctx: ExtensionCommandContext): Promise<void> {
1521
+ let cwd: string | undefined;
1522
+ try {
1523
+ cwd = ctx.cwd;
1524
+ invalidateRegistryUiSnapshot(cwd);
1525
+ } catch (error) {
1526
+ if (!isStaleExtensionContextError(error)) throw error;
1527
+ }
1528
+ try {
1529
+ await ctx.reload();
1530
+ } catch (error) {
1531
+ ignoreStaleExtensionContextError(error);
1532
+ }
1533
+ }
1534
+
1535
+ async function installResourceWithRuntime(
1536
+ pi: ExtensionAPI,
1537
+ ctx: ExtensionCommandContext,
1538
+ runtime: RegistryRuntime,
1539
+ type: ResourceType,
1540
+ name: string,
1541
+ ): Promise<void> {
1542
+ validateName(name);
1543
+ const source = registryResourcePath(runtime.cacheDir, type, name);
1544
+ if (!(await pathExists(source))) throw new Error(`${type} "${name}" does not exist in the registry.`);
1545
+ if (type === "skill" && !(await pathExists(join(source, SKILL_FILE)))) throw new Error(`Registry skill "${name}" is missing ${SKILL_FILE}.`);
1546
+ if (type === "agent") await assertValidAgentDefinition(source, name);
1547
+ const destination = projectResourcePath(ctx, type, name);
1548
+ if (await pathExists(destination)) {
1549
+ const confirmed = await confirmOverwrite(ctx, "Resource already exists", `Overwrite project ${type} "${name}" from the registry?`);
1550
+ if (!confirmed) throw new Error(`Project ${type} "${name}" already exists; install cancelled.`);
1551
+ }
1552
+ await replaceResource(source, destination);
1553
+ const revision = await resourceRevision(pi, runtime, type, name);
1554
+ if (!revision) throw new Error(`Cannot determine registry revision for ${type} "${name}".`);
1555
+ await recordProvenance(ctx, runtime, type, name, revision, await hashPath(destination));
1556
+ }
1557
+
1558
+ async function installResource(pi: ExtensionAPI, ctx: ExtensionCommandContext, type: ResourceType, name: string): Promise<void> {
1559
+ const runtime = loadRuntimeConfig(ctx.cwd);
1560
+ await ensureRegistryCache(pi, runtime);
1561
+ await installResourceWithRuntime(pi, ctx, runtime, type, name);
1562
+ const destination = projectResourcePath(ctx, type, name);
1563
+ notify(ctx, `Installed ${type} "${name}" → ${relative(ctx.cwd, destination)}. Reloading resources…`);
1564
+ await reloadAfterResourceChange(ctx);
1565
+ }
1566
+
1567
+ async function updateResourceWithRuntime(
1568
+ pi: ExtensionAPI,
1569
+ ctx: ExtensionCommandContext,
1570
+ runtime: RegistryRuntime,
1571
+ type: ResourceType,
1572
+ name: string,
1573
+ ): Promise<"updated" | "current"> {
1574
+ validateName(name);
1575
+ const provenance = await readProvenance(ctx);
1576
+ const tracked = provenance.resources[resourceKey(type, name)];
1577
+ if (!tracked) throw new Error(`${type} "${name}" is not tracked by the registry. Install it first.`);
1578
+ if (tracked.remote !== runtime.remote || tracked.branch !== runtime.branch) {
1579
+ throw new Error(`${type} "${name}" was installed from ${tracked.remote} (${tracked.branch}); current registry is ${runtime.remote} (${runtime.branch}).`);
1580
+ }
1581
+ const source = registryResourcePath(runtime.cacheDir, type, name);
1582
+ if (!(await pathExists(source))) throw new Error(`${type} "${name}" was removed from the registry.`);
1583
+ const remoteRevision = await resourceRevision(pi, runtime, type, name);
1584
+ if (!remoteRevision) throw new Error(`Cannot determine registry revision for ${type} "${name}".`);
1585
+ const destination = projectResourcePath(ctx, type, name);
1586
+ if (await pathExists(destination)) {
1587
+ const localHash = await hashPath(destination);
1588
+ if (localHash !== tracked.hash) {
1589
+ throw new Error(`${type} "${name}" has local changes. Push them or resolve the divergence before updating.`);
1590
+ }
1591
+ }
1592
+ if (remoteRevision === tracked.revision && await pathExists(destination)) return "current";
1593
+ await replaceResource(source, destination);
1594
+ await recordProvenance(ctx, runtime, type, name, remoteRevision, await hashPath(destination));
1595
+ return "updated";
1596
+ }
1597
+
1598
+ async function updateResource(pi: ExtensionAPI, ctx: ExtensionCommandContext, type: ResourceType, name: string): Promise<"updated" | "current"> {
1599
+ const runtime = loadRuntimeConfig(ctx.cwd);
1600
+ await ensureRegistryCache(pi, runtime);
1601
+ return updateResourceWithRuntime(pi, ctx, runtime, type, name);
1602
+ }
1603
+
1604
+ async function pushResourceWithRuntime(
1605
+ pi: ExtensionAPI,
1606
+ ctx: ExtensionCommandContext,
1607
+ runtime: RegistryRuntime,
1608
+ type: ResourceType,
1609
+ name: string,
1610
+ ): Promise<{ changed: boolean; revision?: string }> {
1611
+ validateName(name);
1612
+ const source = projectResourcePath(ctx, type, name);
1613
+ if (!(await pathExists(source))) throw new Error(`Project ${type} "${name}" does not exist.`);
1614
+ if (type === "skill" && !(await pathExists(join(source, SKILL_FILE)))) throw new Error(`Project skill "${name}" is missing ${SKILL_FILE}.`);
1615
+ if (type === "agent") await assertValidAgentDefinition(source, name);
1616
+ const destination = registryResourcePath(runtime.cacheDir, type, name);
1617
+ const provenance = await readProvenance(ctx);
1618
+ const tracked = provenance.resources[resourceKey(type, name)];
1619
+ const remoteExists = await pathExists(destination);
1620
+ const remoteRevision = remoteExists ? await resourceRevision(pi, runtime, type, name) : undefined;
1621
+
1622
+ if (tracked && tracked.remote === runtime.remote && tracked.branch === runtime.branch && remoteRevision && remoteRevision !== tracked.revision) {
1623
+ throw new Error(`${type} "${name}" changed in the registry since revision ${tracked.revision.slice(0, 8)}. Run /${COMMAND} status and update/resolve before pushing.`);
1624
+ }
1625
+
1626
+ const localHash = await hashPath(source);
1627
+ if (!tracked && remoteExists) {
1628
+ const remoteHash = await hashPath(destination);
1629
+ if (remoteHash !== localHash) {
1630
+ const confirmed = await confirmOverwrite(ctx, "Registry resource already exists", `Overwrite registry ${type} "${name}" with this project's untracked copy?`);
1631
+ if (!confirmed) throw new Error(`Registry ${type} "${name}" already exists; push cancelled.`);
1632
+ }
1633
+ }
1634
+
1635
+ await replaceResource(source, destination);
1636
+ const rel = registryResourceRelativePath(type, name);
1637
+ await runGit(pi, runtime.cacheDir, ["add", "--", rel]);
1638
+ const changed = (await runGit(pi, runtime.cacheDir, ["status", "--porcelain", "--", rel])).stdout.trim();
1639
+ if (!changed) {
1640
+ const revision = remoteRevision ?? await resourceRevision(pi, runtime, type, name);
1641
+ if (revision) await recordProvenance(ctx, runtime, type, name, revision, localHash);
1642
+ return { changed: false, revision };
1643
+ }
1644
+
1645
+ const verb = remoteExists ? "Update" : "Add";
1646
+ await runGit(pi, runtime.cacheDir, ["commit", "-m", `${verb} ${type} ${name}`, "--", rel]);
1647
+ try {
1648
+ await runGit(pi, runtime.cacheDir, ["push", "-u", "origin", runtime.branch], { timeout: 180_000 });
1649
+ } catch (error) {
1650
+ throw new Error(`Registry commit was created in the disposable cache but push failed; the project copy is unchanged. ${error instanceof Error ? error.message : String(error)}`);
1651
+ }
1652
+ const revision = (await runGit(pi, runtime.cacheDir, ["rev-parse", "HEAD"])).stdout.trim();
1653
+ await recordProvenance(ctx, runtime, type, name, revision, localHash);
1654
+ return { changed: true, revision };
1655
+ }
1656
+
1657
+ async function pushResource(pi: ExtensionAPI, ctx: ExtensionCommandContext, type: ResourceType, name: string): Promise<void> {
1658
+ const runtime = loadRuntimeConfig(ctx.cwd);
1659
+ await ensureRegistryCache(pi, runtime);
1660
+ const result = await pushResourceWithRuntime(pi, ctx, runtime, type, name);
1661
+ if (!result.changed) {
1662
+ notify(ctx, `${type} "${name}" already matches the registry.`);
1663
+ return;
1664
+ }
1665
+ const revision = result.revision;
1666
+ if (!revision) throw new Error(`Cannot determine pushed registry revision for ${type} "${name}".`);
1667
+ notify(ctx, `Pushed ${type} "${name}" to ${runtime.remote} (${runtime.branch}) at ${revision.slice(0, 8)}. Reloading resources…`);
1668
+ await reloadAfterResourceChange(ctx);
1669
+ }
1670
+
1671
+ async function removeResourceWithRuntime(
1672
+ pi: ExtensionAPI,
1673
+ ctx: ExtensionCommandContext,
1674
+ runtime: RegistryRuntime,
1675
+ type: ResourceType,
1676
+ name: string,
1677
+ options: { confirm?: boolean } = {},
1678
+ ): Promise<string> {
1679
+ validateName(name);
1680
+ const target = registryResourcePath(runtime.cacheDir, type, name);
1681
+ if (!(await pathExists(target))) throw new Error(`Registry ${type} "${name}" does not exist.`);
1682
+ if (options.confirm !== false && ctx.hasUI) {
1683
+ const confirmed = await confirmOverwrite(
1684
+ ctx,
1685
+ "Remove registry resource",
1686
+ `Remove ${type} "${name}" from the registry? The project copy, if any, will be kept.`,
1687
+ );
1688
+ if (!confirmed) throw new Error(`Registry ${type} "${name}" removal cancelled.`);
1689
+ }
1690
+
1691
+ const rel = registryResourceRelativePath(type, name);
1692
+ await fs.rm(target, { recursive: type === "skill", force: false });
1693
+ await runGit(pi, runtime.cacheDir, ["add", "-A", "--", rel]);
1694
+ const changed = (await runGit(pi, runtime.cacheDir, ["status", "--porcelain", "--", rel])).stdout.trim();
1695
+ if (!changed) throw new Error(`Registry ${type} "${name}" could not be staged for removal.`);
1696
+ await runGit(pi, runtime.cacheDir, ["commit", "-m", `Remove ${type} ${name}`, "--", rel]);
1697
+ try {
1698
+ await runGit(pi, runtime.cacheDir, ["push", "-u", "origin", runtime.branch], { timeout: 180_000 });
1699
+ } catch (error) {
1700
+ throw new Error(`Registry removal commit was created in the disposable cache but push failed; the remote registry is unchanged. ${error instanceof Error ? error.message : String(error)}`);
1701
+ }
1702
+ return (await runGit(pi, runtime.cacheDir, ["rev-parse", "HEAD"])).stdout.trim();
1703
+ }
1704
+
1705
+ async function removeResource(pi: ExtensionAPI, ctx: ExtensionCommandContext, type: ResourceType, name: string): Promise<void> {
1706
+ const runtime = loadRuntimeConfig(ctx.cwd);
1707
+ await ensureRegistryCache(pi, runtime);
1708
+ const revision = await removeResourceWithRuntime(pi, ctx, runtime, type, name);
1709
+ notify(ctx, `Removed ${type} "${name}" from ${runtime.remote} (${runtime.branch}) at ${revision.slice(0, 8)}. Project copies were kept. Reloading resources…`);
1710
+ await reloadAfterResourceChange(ctx);
1711
+ }
1712
+
1713
+ async function uninstallResource(
1714
+ ctx: ExtensionCommandContext,
1715
+ type: ResourceType,
1716
+ name: string,
1717
+ options: { confirm?: boolean } = {},
1718
+ ): Promise<void> {
1719
+ validateName(name);
1720
+ const target = projectResourcePath(ctx, type, name);
1721
+ if (!(await pathExists(target))) throw new Error(`Project ${type} "${name}" is not installed locally.`);
1722
+ if (options.confirm !== false && ctx.hasUI) {
1723
+ const confirmed = await confirmOverwrite(
1724
+ ctx,
1725
+ "Uninstall local resource",
1726
+ `Remove local ${type} "${name}" from ${relative(ctx.cwd, target)}? The registry copy will be kept.`,
1727
+ );
1728
+ if (!confirmed) throw new Error(`Local ${type} "${name}" uninstall cancelled.`);
1729
+ }
1730
+ await fs.rm(target, { recursive: type === "skill", force: false });
1731
+ await clearResourceProvenance(ctx, type, name);
1732
+ notify(ctx, `Uninstalled local ${type} "${name}". Registry copy was kept. Reloading resources…`);
1733
+ await reloadAfterResourceChange(ctx);
1734
+ }
1735
+
1736
+ async function pushProjectState(pi: ExtensionAPI, ctx: ExtensionCommandContext, scope: ProjectScope): Promise<void> {
1737
+ const runtime = loadRuntimeConfig(ctx.cwd);
1738
+ await ensureRegistryCache(pi, runtime);
1739
+ const projectKey = await resolveProjectKey(pi, ctx.cwd);
1740
+ const requested = projectArtifacts(scope);
1741
+ const provenance = await readProvenance(ctx);
1742
+ const targets: Array<{ artifact: ProjectArtifact; deleteRemote: boolean }> = [];
1743
+ const alreadyAbsent: ProjectArtifact[] = [];
1744
+ for (const artifact of requested) {
1745
+ const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1746
+ if (await pathExists(localPath)) {
1747
+ if (artifact === "plans" && !(await hasTrackableFiles(localPath))) {
1748
+ const tracked = provenance.projectResources[artifact];
1749
+ if (tracked && (tracked.remote !== runtime.remote || tracked.branch !== runtime.branch || tracked.projectKey !== projectKey)) {
1750
+ throw new Error(`${artifact} state is tracked under another registry/branch/project key. Run /${COMMAND} status before pushing.`);
1751
+ }
1752
+ if (await pathExists(projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact))) {
1753
+ targets.push({ artifact, deleteRemote: true });
1754
+ } else {
1755
+ alreadyAbsent.push(artifact);
1756
+ }
1757
+ continue;
1758
+ }
1759
+ targets.push({ artifact, deleteRemote: false });
1760
+ } else if (scope !== "project") {
1761
+ throw new Error(`Project .pi/${projectArtifactDisplayName(artifact)} does not exist.`);
1762
+ }
1763
+ }
1764
+ if (targets.length === 0) {
1765
+ await clearProjectProvenanceEntries(ctx, alreadyAbsent);
1766
+ if (scope === "plans" && alreadyAbsent.includes("plans")) {
1767
+ notify(ctx, `Project registry already has no ${PROJECT_PLANS_DIR}/ state for ${projectKey}.`);
1768
+ } else {
1769
+ notify(ctx, `No .pi/${PROJECT_TASKS_FILE}, .pi/${PROJECT_PLANS_DIR}/, or .pi/${PROJECT_TODO_FILE} state exists to push.`);
1770
+ }
1771
+ return;
1772
+ }
1773
+
1774
+ const localHashes = new Map<ProjectArtifact, string>();
1775
+ const overwriteConflicts: ProjectArtifact[] = [];
1776
+ for (const { artifact, deleteRemote } of targets) {
1777
+ const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1778
+ const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1779
+ const tracked = provenance.projectResources[artifact];
1780
+ if (tracked && (tracked.remote !== runtime.remote || tracked.branch !== runtime.branch || tracked.projectKey !== projectKey)) {
1781
+ throw new Error(`${artifact} state is tracked under another registry/branch/project key. Run /${COMMAND} status before pushing.`);
1782
+ }
1783
+ const remoteExists = await pathExists(remotePath);
1784
+ const remoteRevision = remoteExists
1785
+ ? await projectArtifactRevision(pi, runtime, projectKey, artifact)
1786
+ : undefined;
1787
+ if (tracked && remoteRevision && remoteRevision !== tracked.revision) {
1788
+ throw new Error(`${artifact} state changed in the registry since revision ${tracked.revision.slice(0, 8)}. Pull or resolve it before pushing.`);
1789
+ }
1790
+ const localHash = await hashProjectArtifactLocal(ctx.cwd, artifact);
1791
+ if (!tracked && remoteExists && (deleteRemote || await hashProjectArtifactRemote(runtime, projectKey, artifact) !== localHash)) {
1792
+ overwriteConflicts.push(artifact);
1793
+ }
1794
+ localHashes.set(artifact, localHash);
1795
+ }
1796
+
1797
+ if (overwriteConflicts.length > 0) {
1798
+ const names = overwriteConflicts.join(", ");
1799
+ const confirmed = await confirmOverwrite(
1800
+ ctx,
1801
+ "Project state already exists in registry",
1802
+ `Overwrite untracked remote project state for: ${names}?`,
1803
+ );
1804
+ if (!confirmed) throw new Error(`Project state push cancelled because remote state already exists for: ${names}.`);
1805
+ }
1806
+
1807
+ for (const { artifact, deleteRemote } of targets) {
1808
+ const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1809
+ const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1810
+ if (deleteRemote) await fs.rm(remotePath, { recursive: true, force: true });
1811
+ else if (artifact === "tasks") await replaceRemoteTaskBundle(ctx.cwd, runtime, projectKey);
1812
+ else await replaceResource(localPath, remotePath);
1813
+ }
1814
+ const projectRel = `${REGISTRY_PROJECTS_DIR}/${projectKey}`;
1815
+ await runGit(pi, runtime.cacheDir, ["add", "-A", "--", projectRel]);
1816
+ const changed = (await runGit(pi, runtime.cacheDir, ["status", "--porcelain", "--", projectRel])).stdout.trim();
1817
+ if (changed) {
1818
+ await runGit(pi, runtime.cacheDir, ["commit", "-m", `Sync project state ${projectKey}`, "--", projectRel]);
1819
+ try {
1820
+ await runGit(pi, runtime.cacheDir, ["push", "-u", "origin", runtime.branch], { timeout: 180_000 });
1821
+ } catch (error) {
1822
+ throw new Error(`Project-state commit was created in the disposable cache but push failed; local .pi state is unchanged. ${error instanceof Error ? error.message : String(error)}`);
1823
+ }
1824
+ }
1825
+
1826
+ const removedArtifacts = [...alreadyAbsent];
1827
+ for (const { artifact, deleteRemote } of targets) {
1828
+ if (deleteRemote) {
1829
+ removedArtifacts.push(artifact);
1830
+ continue;
1831
+ }
1832
+ const revision = await projectArtifactRevision(pi, runtime, projectKey, artifact);
1833
+ if (!revision) throw new Error(`Cannot determine registry revision for project ${artifact} state.`);
1834
+ await recordProjectProvenance(ctx, runtime, projectKey, artifact, revision, localHashes.get(artifact)!);
1835
+ }
1836
+ await clearProjectProvenanceEntries(ctx, removedArtifacts);
1837
+ const targetNames = targets.map(({ artifact }) => artifact);
1838
+ notify(ctx, `${changed ? "Pushed" : "Project registry already matches"} ${targetNames.join(" + ")} for ${projectKey}.`);
1839
+ }
1840
+
1841
+ async function pullProjectState(pi: ExtensionAPI, ctx: ExtensionCommandContext, scope: ProjectScope): Promise<void> {
1842
+ const runtime = loadRuntimeConfig(ctx.cwd);
1843
+ await ensureRegistryCache(pi, runtime);
1844
+ const projectKey = await resolveProjectKey(pi, ctx.cwd);
1845
+ const requested = projectArtifacts(scope);
1846
+ const targets: ProjectArtifact[] = [];
1847
+ for (const artifact of requested) {
1848
+ if (await pathExists(projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact))) targets.push(artifact);
1849
+ else if (scope !== "project") throw new Error(`Registry has no ${artifact} state for project ${projectKey}.`);
1850
+ }
1851
+ if (targets.length === 0) {
1852
+ notify(ctx, `Registry has no ${PROJECT_TASKS_FILE}, ${PROJECT_PLANS_DIR}/, or ${PROJECT_TODO_FILE} state for project ${projectKey}.`);
1853
+ return;
1854
+ }
1855
+
1856
+ const provenance = await readProvenance(ctx);
1857
+ const metadata = new Map<ProjectArtifact, { remoteRevision: string; remoteHash: string }>();
1858
+ const overwriteConflicts: ProjectArtifact[] = [];
1859
+ for (const artifact of targets) {
1860
+ const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1861
+ const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1862
+ const tracked = provenance.projectResources[artifact];
1863
+ if (tracked && (tracked.remote !== runtime.remote || tracked.branch !== runtime.branch || tracked.projectKey !== projectKey)) {
1864
+ throw new Error(`${artifact} state is tracked under another registry/branch/project key. Run /${COMMAND} status before pulling.`);
1865
+ }
1866
+ const remoteRevision = await projectArtifactRevision(pi, runtime, projectKey, artifact);
1867
+ if (!remoteRevision) throw new Error(`Cannot determine registry revision for project ${artifact} state.`);
1868
+ const remoteHash = await hashProjectArtifactRemote(runtime, projectKey, artifact);
1869
+ if (await pathExists(localPath)) {
1870
+ const localHash = await hashProjectArtifactLocal(ctx.cwd, artifact);
1871
+ if (tracked && localHash !== tracked.hash) {
1872
+ throw new Error(`Project ${artifact} state has local changes. Push or resolve them before pulling.`);
1873
+ }
1874
+ if (!tracked && localHash !== remoteHash) overwriteConflicts.push(artifact);
1875
+ }
1876
+ metadata.set(artifact, { remoteRevision, remoteHash });
1877
+ }
1878
+
1879
+ if (overwriteConflicts.length > 0) {
1880
+ const names = overwriteConflicts.join(", ");
1881
+ const confirmed = await confirmOverwrite(
1882
+ ctx,
1883
+ "Local project state already exists",
1884
+ `Overwrite untracked local project state from the registry for: ${names}?`,
1885
+ );
1886
+ if (!confirmed) throw new Error(`Project state pull cancelled because local state already exists for: ${names}.`);
1887
+ }
1888
+
1889
+ for (const artifact of targets) {
1890
+ const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1891
+ const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1892
+ if (artifact === "tasks") await replaceLocalTaskBundle(ctx.cwd, runtime, projectKey);
1893
+ else await replaceResource(remotePath, localPath);
1894
+ const item = metadata.get(artifact)!;
1895
+ const localHash = await hashProjectArtifactLocal(ctx.cwd, artifact);
1896
+ await recordProjectProvenance(ctx, runtime, projectKey, artifact, item.remoteRevision, localHash);
1897
+ }
1898
+ notify(ctx, `Pulled ${targets.join(" + ")} for ${projectKey}. Reloading project state…`);
1899
+ await reloadAfterResourceChange(ctx);
1900
+ }
1901
+
1902
+ async function installAll(pi: ExtensionAPI, ctx: ExtensionCommandContext, scope: ResourceScope): Promise<void> {
1903
+ const runtime = loadRuntimeConfig(ctx.cwd);
1904
+ await ensureRegistryCache(pi, runtime);
1905
+ const [remoteResources, localResources] = await Promise.all([scanRegistry(runtime), scanProject(ctx)]);
1906
+ const localKeys = new Set(localResources.map((entry) => resourceKey(entry.type, entry.name)));
1907
+ const targets = remoteResources.filter((entry) => matchesScope(entry.type, scope) && !localKeys.has(resourceKey(entry.type, entry.name)));
1908
+ if (targets.length === 0) {
1909
+ notify(ctx, `No ${scope === "all" ? "resources" : `${scope}s`} need installation.`);
1910
+ return;
1911
+ }
1912
+ for (const entry of targets) await installResourceWithRuntime(pi, ctx, runtime, entry.type, entry.name);
1913
+ notify(ctx, `Installed ${targets.length} registry resource${targets.length === 1 ? "" : "s"}. Reloading resources…`);
1914
+ await reloadAfterResourceChange(ctx);
1915
+ }
1916
+
1917
+ async function updateAll(pi: ExtensionAPI, ctx: ExtensionCommandContext, scope: ResourceScope): Promise<void> {
1918
+ const runtime = loadRuntimeConfig(ctx.cwd);
1919
+ await ensureRegistryCache(pi, runtime);
1920
+ const statuses = (await collectStatuses(pi, ctx, runtime)).filter((status) => matchesScope(status.type, scope));
1921
+ const targets = statuses.filter((status) => status.kind === "update-available" || status.kind === "missing-local");
1922
+ const blocked = statuses.filter((status) => ["local-changes", "diverged", "registry-changed"].includes(status.kind));
1923
+ if (targets.length === 0) {
1924
+ notify(ctx, blocked.length > 0
1925
+ ? `No safe updates available; ${blocked.length} resource${blocked.length === 1 ? " is" : "s are"} blocked by local/conflicting changes.`
1926
+ : `All tracked ${scope === "all" ? "resources are" : `${scope}s are`} up to date.`);
1927
+ return;
1928
+ }
1929
+ let updated = 0;
1930
+ for (const status of targets) {
1931
+ if (await updateResourceWithRuntime(pi, ctx, runtime, status.type, status.name) === "updated") updated += 1;
1932
+ }
1933
+ const suffix = blocked.length > 0 ? ` ${blocked.length} conflicting resource${blocked.length === 1 ? " was" : "s were"} skipped.` : "";
1934
+ notify(ctx, `Updated ${updated} registry resource${updated === 1 ? "" : "s"}.${suffix} Reloading resources…`);
1935
+ await reloadAfterResourceChange(ctx);
1936
+ }
1937
+
1938
+ async function pushAll(pi: ExtensionAPI, ctx: ExtensionCommandContext, scope: ResourceScope): Promise<void> {
1939
+ const runtime = loadRuntimeConfig(ctx.cwd);
1940
+ await ensureRegistryCache(pi, runtime);
1941
+ const targets = (await scanProject(ctx)).filter((entry) => matchesScope(entry.type, scope));
1942
+ if (targets.length === 0) {
1943
+ notify(ctx, `No project ${scope === "all" ? "resources" : `${scope}s`} found to push.`);
1944
+ return;
1945
+ }
1946
+ let changed = 0;
1947
+ let unchanged = 0;
1948
+ const failures: string[] = [];
1949
+ for (const entry of targets) {
1950
+ try {
1951
+ const result = await pushResourceWithRuntime(pi, ctx, runtime, entry.type, entry.name);
1952
+ if (result.changed) changed += 1;
1953
+ else unchanged += 1;
1954
+ } catch (error) {
1955
+ failures.push(`${entry.type} ${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
1956
+ }
1957
+ }
1958
+ const summary = `Registry push all: ${changed} pushed, ${unchanged} unchanged, ${failures.length} failed.`;
1959
+ if (failures.length === 0) {
1960
+ notify(ctx, changed > 0 ? `${summary} Reloading resources…` : summary);
1961
+ if (changed > 0) await reloadAfterResourceChange(ctx);
1962
+ return;
1963
+ }
1964
+ pi.sendMessage({
1965
+ customType: SYSTEM_CUSTOM_MESSAGE_TYPE,
1966
+ content: [summary, "", ...failures.map((failure) => `- ${failure}`)].join("\n"),
1967
+ display: true,
1968
+ details: { kind: "resource-registry-bulk-push", userVisibleOnly: true },
1969
+ });
1970
+ notify(ctx, summary, "warning");
1971
+ if (changed > 0) await reloadAfterResourceChange(ctx);
1972
+ }
1973
+
1974
+ async function removeAll(pi: ExtensionAPI, ctx: ExtensionCommandContext, scope: ResourceScope): Promise<void> {
1975
+ const runtime = loadRuntimeConfig(ctx.cwd);
1976
+ await ensureRegistryCache(pi, runtime);
1977
+ const targets = (await scanRegistry(runtime)).filter((entry) => matchesScope(entry.type, scope));
1978
+ if (targets.length === 0) {
1979
+ notify(ctx, `No registry ${scope === "all" ? "resources" : `${scope}s`} found to remove.`);
1980
+ return;
1981
+ }
1982
+ if (!ctx.hasUI) {
1983
+ throw new Error(`Bulk registry removal requires interactive confirmation. Run /${COMMAND} remove ${scope === "all" ? "all" : `${scope}s all`} in the UI.`);
1984
+ }
1985
+ const confirmed = await confirmOverwrite(
1986
+ ctx,
1987
+ "Remove registry resources",
1988
+ `Remove ${targets.length} ${scope === "all" ? "registry resources" : `registry ${scope}${targets.length === 1 ? "" : "s"}`}? Project copies will be kept.`,
1989
+ );
1990
+ if (!confirmed) {
1991
+ notify(ctx, "Bulk registry removal cancelled.");
1992
+ return;
1993
+ }
1994
+
1995
+ const paths = targets.map((entry) => registryResourceRelativePath(entry.type, entry.name));
1996
+ for (const entry of targets) {
1997
+ await fs.rm(registryResourcePath(runtime.cacheDir, entry.type, entry.name), {
1998
+ recursive: entry.type === "skill",
1999
+ force: false,
2000
+ });
2001
+ }
2002
+ await runGit(pi, runtime.cacheDir, ["add", "-A", "--", ...paths]);
2003
+ const changed = (await runGit(pi, runtime.cacheDir, ["status", "--porcelain", "--", ...paths])).stdout.trim();
2004
+ if (!changed) throw new Error("Registry resources could not be staged for removal.");
2005
+ await runGit(pi, runtime.cacheDir, ["commit", "-m", `Remove ${targets.length} registry resource${targets.length === 1 ? "" : "s"}`, "--", ...paths]);
2006
+ try {
2007
+ await runGit(pi, runtime.cacheDir, ["push", "-u", "origin", runtime.branch], { timeout: 180_000 });
2008
+ } catch (error) {
2009
+ throw new Error(`Bulk registry removal commit was created in the disposable cache but push failed; the remote registry is unchanged. ${error instanceof Error ? error.message : String(error)}`);
2010
+ }
2011
+ const revision = (await runGit(pi, runtime.cacheDir, ["rev-parse", "HEAD"])).stdout.trim();
2012
+ notify(ctx, `Removed ${targets.length} registry resource${targets.length === 1 ? "" : "s"} at ${revision.slice(0, 8)}. Project copies were kept. Reloading resources…`);
2013
+ await reloadAfterResourceChange(ctx);
2014
+ }
2015
+
2016
+ async function uninstallAll(ctx: ExtensionCommandContext, scope: ResourceScope): Promise<void> {
2017
+ const targets = (await scanProject(ctx)).filter((entry) => matchesScope(entry.type, scope));
2018
+ if (targets.length === 0) {
2019
+ notify(ctx, `No local project ${scope === "all" ? "resources" : `${scope}s`} found to uninstall.`);
2020
+ return;
2021
+ }
2022
+ if (!ctx.hasUI) {
2023
+ throw new Error(`Bulk local uninstall requires interactive confirmation. Run /${COMMAND} uninstall ${scope === "all" ? "all" : `${scope}s all`} in the UI.`);
2024
+ }
2025
+ const confirmed = await confirmOverwrite(
2026
+ ctx,
2027
+ "Uninstall local resources",
2028
+ `Remove ${targets.length} local ${scope === "all" ? "resources" : `${scope}${targets.length === 1 ? "" : "s"}`}? Registry copies will be kept.`,
2029
+ );
2030
+ if (!confirmed) {
2031
+ notify(ctx, "Bulk local uninstall cancelled.");
2032
+ return;
2033
+ }
2034
+ for (const entry of targets) {
2035
+ await fs.rm(entry.path, { recursive: entry.type === "skill", force: false });
2036
+ }
2037
+ await clearResourceProvenanceEntries(ctx, targets);
2038
+ notify(ctx, `Uninstalled ${targets.length} local resource${targets.length === 1 ? "" : "s"}. Registry copies were kept. Reloading resources…`);
2039
+ await reloadAfterResourceChange(ctx);
2040
+ }
2041
+
2042
+ function resourceLabel(entry: ResourceEntry): string {
2043
+ const description = truncate(entry.description, DESC_MAX);
2044
+ return description ? `${entry.name} — ${description}` : entry.name;
2045
+ }
2046
+
2047
+ async function selectResource(ctx: ExtensionCommandContext, title: string, entries: ResourceEntry[]): Promise<ResourceEntry | undefined> {
2048
+ if (entries.length === 0) {
2049
+ notify(ctx, "No matching resources found.", "warning");
2050
+ return undefined;
2051
+ }
2052
+ if (!ctx.hasUI) {
2053
+ notify(ctx, entries.map((entry) => `${entry.type} ${entry.name}`).join("\n"));
2054
+ return undefined;
2055
+ }
2056
+ const labels = entries.map(resourceLabel);
2057
+ let selected: string | undefined;
2058
+ try {
2059
+ selected = await ctx.ui.select(title, labels);
2060
+ } catch (error) {
2061
+ ignoreStaleExtensionContextError(error);
2062
+ return undefined;
2063
+ }
2064
+ const index = selected ? labels.indexOf(selected) : -1;
2065
+ return index >= 0 ? entries[index] : undefined;
2066
+ }
2067
+
2068
+ async function chooseType(ctx: ExtensionCommandContext, title: string): Promise<ResourceType | undefined> {
2069
+ if (!ctx.hasUI) return undefined;
2070
+ let selected: string | undefined;
2071
+ try {
2072
+ selected = await ctx.ui.select(title, ["Skills", "Agents"]);
2073
+ } catch (error) {
2074
+ ignoreStaleExtensionContextError(error);
2075
+ return undefined;
2076
+ }
2077
+ return selected === "Skills" ? "skill" : selected === "Agents" ? "agent" : undefined;
2078
+ }
2079
+
2080
+ async function interactiveInstall(pi: ExtensionAPI, ctx: ExtensionCommandContext, forcedType?: ResourceType): Promise<void> {
2081
+ const runtime = loadRuntimeConfig(ctx.cwd);
2082
+ await ensureRegistryCache(pi, runtime);
2083
+ const type = forcedType ?? await chooseType(ctx, "Install from registry");
2084
+ if (!type) return;
2085
+ const entries = (await scanRegistry(runtime)).filter((entry) => entry.type === type);
2086
+ const selected = await selectResource(ctx, `Install ${type}`, entries);
2087
+ if (selected) await installResource(pi, ctx, type, selected.name);
2088
+ }
2089
+
2090
+ async function interactivePush(pi: ExtensionAPI, ctx: ExtensionCommandContext, forcedType?: ResourceType): Promise<void> {
2091
+ const type = forcedType ?? await chooseType(ctx, "Push to registry");
2092
+ if (!type) return;
2093
+ const entries = (await scanProject(ctx)).filter((entry) => entry.type === type);
2094
+ const selected = await selectResource(ctx, `Push project ${type}`, entries);
2095
+ if (selected) await pushResource(pi, ctx, type, selected.name);
2096
+ }
2097
+
2098
+ async function chooseProjectScope(ctx: ExtensionCommandContext, title: string): Promise<ProjectScope | undefined> {
2099
+ if (!ctx.hasUI) return undefined;
2100
+ let selected: string | undefined;
2101
+ try {
2102
+ selected = await ctx.ui.select(title, ["Tasks + plans + TODO", "Tasks", "Plans", "TODO.md"]);
2103
+ } catch (error) {
2104
+ ignoreStaleExtensionContextError(error);
2105
+ return undefined;
2106
+ }
2107
+ if (selected === "Tasks + plans + TODO") return "project";
2108
+ if (selected === "Tasks") return "tasks";
2109
+ if (selected === "Plans") return "plans";
2110
+ if (selected === "TODO.md") return "todo";
2111
+ return undefined;
2112
+ }
2113
+
2114
+ async function interactiveProjectState(pi: ExtensionAPI, ctx: ExtensionCommandContext, action: "push" | "pull"): Promise<void> {
2115
+ const scope = await chooseProjectScope(ctx, action === "push" ? "Push project state" : "Pull project state");
2116
+ if (!scope) return;
2117
+ if (action === "push") await pushProjectState(pi, ctx, scope);
2118
+ else await pullProjectState(pi, ctx, scope);
2119
+ }
2120
+
2121
+ async function configureProjectKeyInteractive(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
2122
+ if (!ctx.hasUI) throw new Error(`Usage: /${COMMAND} project-key <key|auto>`);
2123
+ const configured = loadPiToolsSuiteConfig([], { cwd: ctx.cwd }).resourceRegistry.projectKey;
2124
+ let derived = "";
2125
+ if (!configured) {
2126
+ const origin = (await runGit(pi, ctx.cwd, ["remote", "get-url", "origin"], { allowFailure: true })).stdout.trim();
2127
+ derived = projectKeyFromGitRemote(origin) ?? "";
2128
+ }
2129
+ let value: string | undefined;
2130
+ try {
2131
+ value = await ctx.ui.input("Project registry key (blank = auto from Git origin)", configured ?? derived);
2132
+ } catch (error) {
2133
+ ignoreStaleExtensionContextError(error);
2134
+ return;
2135
+ }
2136
+ if (value === undefined) return;
2137
+ const trimmed = value.trim();
2138
+ await saveProjectKeyConfig(ctx.cwd, trimmed || undefined);
2139
+ if (trimmed) notify(ctx, `Project registry key set to ${trimmed}.`);
2140
+ else notify(ctx, `Project registry key override cleared; using ${await resolveProjectKey(pi, ctx.cwd)}.`);
2141
+ }
2142
+
2143
+ async function interactiveRemove(pi: ExtensionAPI, ctx: ExtensionCommandContext, forcedType?: ResourceType): Promise<void> {
2144
+ const runtime = loadRuntimeConfig(ctx.cwd);
2145
+ await ensureRegistryCache(pi, runtime);
2146
+ const type = forcedType ?? await chooseType(ctx, "Remove from registry");
2147
+ if (!type) return;
2148
+ const entries = (await scanRegistry(runtime)).filter((entry) => entry.type === type);
2149
+ const selected = await selectResource(ctx, `Remove registry ${type}`, entries);
2150
+ if (!selected) return;
2151
+ const revision = await removeResourceWithRuntime(pi, ctx, runtime, type, selected.name);
2152
+ notify(ctx, `Removed ${type} "${selected.name}" from the registry at ${revision.slice(0, 8)}. Project copies were kept. Reloading resources…`);
2153
+ await reloadAfterResourceChange(ctx);
2154
+ }
2155
+
2156
+ async function interactiveUninstall(ctx: ExtensionCommandContext, forcedType?: ResourceType): Promise<void> {
2157
+ const type = forcedType ?? await chooseType(ctx, "Uninstall local resource");
2158
+ if (!type) return;
2159
+ const entries = (await scanProject(ctx)).filter((entry) => entry.type === type);
2160
+ const selected = await selectResource(ctx, `Uninstall local ${type}`, entries);
2161
+ if (selected) await uninstallResource(ctx, type, selected.name);
2162
+ }
2163
+
2164
+ async function interactiveUpdate(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
2165
+ const runtime = loadRuntimeConfig(ctx.cwd);
2166
+ await ensureRegistryCache(pi, runtime);
2167
+ const statuses = await collectStatuses(pi, ctx, runtime);
2168
+ const candidates = statuses.filter((status) => status.kind === "update-available" || status.kind === "missing-local");
2169
+ if (candidates.length === 0) {
2170
+ notify(ctx, "No safe registry updates are available.");
2171
+ return;
2172
+ }
2173
+ const labels = candidates.map((status) => `${statusIcon(status.kind)} ${status.type} ${status.name} — ${statusLabel(status.kind)}`);
2174
+ if (!ctx.hasUI) {
2175
+ notify(ctx, labels.join("\n"));
2176
+ return;
2177
+ }
2178
+ let selected: string | undefined;
2179
+ try {
2180
+ selected = await ctx.ui.select("Update from registry", labels);
2181
+ } catch (error) {
2182
+ ignoreStaleExtensionContextError(error);
2183
+ return;
2184
+ }
2185
+ const index = selected ? labels.indexOf(selected) : -1;
2186
+ const status = index >= 0 ? candidates[index] : undefined;
2187
+ if (!status) return;
2188
+ const result = await updateResource(pi, ctx, status.type, status.name);
2189
+ if (result === "updated") {
2190
+ notify(ctx, `Updated ${status.type} "${status.name}". Reloading resources…`);
2191
+ await reloadAfterResourceChange(ctx);
2192
+ }
2193
+ }
2194
+
2195
+ async function configureInteractive(ctx: ExtensionCommandContext): Promise<void> {
2196
+ if (!ctx.hasUI) throw new Error(`Usage: /${COMMAND} configure <git-url> [branch]`);
2197
+ const current: ResourceRegistryConfig = loadPiToolsSuiteConfig([], { cwd: ctx.cwd }).resourceRegistry;
2198
+ let remote: string | undefined;
2199
+ let branch: string | undefined;
2200
+ try {
2201
+ remote = await ctx.ui.input("Resource registry Git remote", current.remote ?? "git@github.com:you/pix-resources.git");
2202
+ if (!remote?.trim()) return;
2203
+ branch = await ctx.ui.input("Registry branch", current.branch || "main");
2204
+ } catch (error) {
2205
+ ignoreStaleExtensionContextError(error);
2206
+ return;
2207
+ }
2208
+ await saveRegistryConfig(remote, branch?.trim() || "main");
2209
+ notify(ctx, `Registry configured: ${remote.trim()} (${branch?.trim() || "main"}).`);
2210
+ }
2211
+
2212
+ async function showMainMenu(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
2213
+ if (!ctx.hasUI) {
2214
+ notify(ctx, registryUsage());
2215
+ return;
2216
+ }
2217
+ let choice: string | undefined;
2218
+ try {
2219
+ choice = await ctx.ui.select("Resource registry", [
2220
+ "Status / check updates",
2221
+ "Install",
2222
+ "Update",
2223
+ "Push",
2224
+ "Uninstall local",
2225
+ "Push project state",
2226
+ "Pull project state",
2227
+ "Remove",
2228
+ "Configure project key",
2229
+ "Configure remote",
2230
+ ]);
2231
+ } catch (error) {
2232
+ ignoreStaleExtensionContextError(error);
2233
+ return;
2234
+ }
2235
+ if (!choice) return;
2236
+ if (choice === "Configure remote") {
2237
+ await configureInteractive(ctx);
2238
+ return;
2239
+ }
2240
+ if (choice === "Install") {
2241
+ await interactiveInstall(pi, ctx);
2242
+ return;
2243
+ }
2244
+ if (choice === "Push") {
2245
+ await interactivePush(pi, ctx);
2246
+ return;
2247
+ }
2248
+ if (choice === "Uninstall local") {
2249
+ await interactiveUninstall(ctx);
2250
+ return;
2251
+ }
2252
+ if (choice === "Push project state") {
2253
+ await interactiveProjectState(pi, ctx, "push");
2254
+ return;
2255
+ }
2256
+ if (choice === "Pull project state") {
2257
+ await interactiveProjectState(pi, ctx, "pull");
2258
+ return;
2259
+ }
2260
+ if (choice === "Remove") {
2261
+ await interactiveRemove(pi, ctx);
2262
+ return;
2263
+ }
2264
+ if (choice === "Configure project key") {
2265
+ await configureProjectKeyInteractive(pi, ctx);
2266
+ return;
2267
+ }
2268
+ if (choice === "Update") {
2269
+ await interactiveUpdate(pi, ctx);
2270
+ return;
2271
+ }
2272
+ const runtime = loadRuntimeConfig(ctx.cwd);
2273
+ await ensureRegistryCache(pi, runtime);
2274
+ const [statuses, projectStatus] = await Promise.all([
2275
+ collectStatuses(pi, ctx, runtime),
2276
+ collectProjectStatuses(pi, ctx, runtime),
2277
+ ]);
2278
+ sendStatusSystemMessage(pi, statuses, runtime, projectStatus);
2279
+ }
2280
+
2281
+ function registryUsage(): string {
2282
+ return [
2283
+ "Resource registry",
2284
+ `/${COMMAND} open TUI`,
2285
+ `/${COMMAND} status fetch and show grouped reusable + project state`,
2286
+ `/${COMMAND} install skill <name> install registry skill into .pi/skills`,
2287
+ `/${COMMAND} install agent <name> install registry agent into .pi/agents`,
2288
+ `/${COMMAND} install all install all missing skills and agents`,
2289
+ `/${COMMAND} install skills all install all missing skills`,
2290
+ `/${COMMAND} install agents all install all missing agents`,
2291
+ `/${COMMAND} update skill <name> safely update an installed skill`,
2292
+ `/${COMMAND} update agent <name> safely update an installed agent`,
2293
+ `/${COMMAND} update all safely update all non-conflicting resources`,
2294
+ `/${COMMAND} update skills all safely update all non-conflicting skills`,
2295
+ `/${COMMAND} update agents all safely update all non-conflicting agents`,
2296
+ `/${COMMAND} push skill <name> commit and push project skill to registry`,
2297
+ `/${COMMAND} push agent <name> commit and push project agent to registry`,
2298
+ `/${COMMAND} push all push all project skills and agents`,
2299
+ `/${COMMAND} push skills all push all project skills`,
2300
+ `/${COMMAND} push agents all push all project agents`,
2301
+ `/${COMMAND} push tasks push .pi/${PROJECT_TASKS_FILE} under this project's registry key`,
2302
+ `/${COMMAND} push plans push .pi/plans/ under this project's registry key`,
2303
+ `/${COMMAND} push todo push .pi/${PROJECT_TODO_FILE} under this project's registry key`,
2304
+ `/${COMMAND} push project push available ${PROJECT_TASKS_FILE} + plans + ${PROJECT_TODO_FILE} state`,
2305
+ `/${COMMAND} pull tasks pull this project's ${PROJECT_TASKS_FILE} from the registry`,
2306
+ `/${COMMAND} pull plans pull this project's plans/ from the registry`,
2307
+ `/${COMMAND} pull todo pull this project's ${PROJECT_TODO_FILE} from the registry`,
2308
+ `/${COMMAND} pull project pull available ${PROJECT_TASKS_FILE} + plans + ${PROJECT_TODO_FILE} state`,
2309
+ `/${COMMAND} remove skill <name> remove a skill from the registry only`,
2310
+ `/${COMMAND} remove agent <name> remove an agent from the registry only`,
2311
+ `/${COMMAND} remove all remove all registry skills and agents (confirmation required)`,
2312
+ `/${COMMAND} remove skills all remove all registry skills (confirmation required)`,
2313
+ `/${COMMAND} remove agents all remove all registry agents (confirmation required)`,
2314
+ `/${COMMAND} uninstall skill <name> remove a local .pi/skills copy only`,
2315
+ `/${COMMAND} uninstall agent <name> remove a local .pi/agents copy only`,
2316
+ `/${COMMAND} uninstall all remove all local skills and agents (confirmation required)`,
2317
+ `/${COMMAND} uninstall skills all remove all local skills (confirmation required)`,
2318
+ `/${COMMAND} uninstall agents all remove all local agents (confirmation required)`,
2319
+ `/${COMMAND} configure <url> [branch]`,
2320
+ `/${COMMAND} project-key [key|auto] show/set project-scoped registry key`,
2321
+ `/${COMMAND} remote show configured remote`,
2322
+ ].join("\n");
2323
+ }
2324
+
2325
+ function parseAction(value: string | undefined): RegistryAction | undefined {
2326
+ if (value === "check") return "status";
2327
+ if (value === "config") return "configure";
2328
+ if (value === "delete" || value === "rm") return "remove";
2329
+ if (value === "remove-local" || value === "local-remove") return "uninstall";
2330
+ if (["install", "push", "pull", "remove", "uninstall", "status", "update", "configure", "remote", "project-key"].includes(value ?? "")) return value as RegistryAction;
2331
+ return undefined;
2332
+ }
2333
+
2334
+ async function handleRpcCommand(
2335
+ pi: ExtensionAPI,
2336
+ parts: string[],
2337
+ ctx: ExtensionCommandContext,
2338
+ ): Promise<void> {
2339
+ const [action] = parts;
2340
+ if (!action || action === "refresh") {
2341
+ invalidateRegistryUiSnapshot(ctx.cwd);
2342
+ await publishRegistryUiSnapshot(pi, ctx);
2343
+ return;
2344
+ }
2345
+ if (!["install", "update", "push", "pull", "remove", "uninstall", "configure", "project-key"].includes(action)) {
2346
+ await publishRegistryUiSnapshot(pi, ctx, `Unsupported registry GUI action: ${action}`);
2347
+ return;
2348
+ }
2349
+ let error: string | undefined;
2350
+ try {
2351
+ if (action === "project-key" && parts.length === 1) await configureProjectKeyInteractive(pi, ctx);
2352
+ else await handleCommand(pi, parts.join(" "), ctx);
2353
+ } catch (actionError) {
2354
+ const message = actionError instanceof Error ? actionError.message : String(actionError);
2355
+ if (!/cancelled\.?$/i.test(message)) error = message;
2356
+ }
2357
+ await publishRegistryUiSnapshot(pi, ctx, error);
2358
+ }
2359
+
2360
+ async function handleCommand(pi: ExtensionAPI, args: string, ctx: ExtensionCommandContext): Promise<void> {
2361
+ const parts = args.trim().split(/\s+/).filter(Boolean);
2362
+ if (parts.length === 0) {
2363
+ await showMainMenu(pi, ctx);
2364
+ return;
2365
+ }
2366
+ if (parts[0] === "rpc") {
2367
+ await handleRpcCommand(pi, parts.slice(1), ctx);
2368
+ return;
2369
+ }
2370
+ if (parts[0] === "help" || parts[0] === "--help" || parts[0] === "-h") {
2371
+ notify(ctx, registryUsage());
2372
+ return;
2373
+ }
2374
+ const action = parseAction(parts[0]);
2375
+ if (!action) throw new Error(registryUsage());
2376
+ if (action === "configure") invalidateAllRegistryUiSnapshots();
2377
+ else if (!["status", "remote"].includes(action)) invalidateRegistryUiSnapshot(ctx.cwd);
2378
+
2379
+ if (action === "configure") {
2380
+ const remote = parts[1];
2381
+ if (!remote) {
2382
+ await configureInteractive(ctx);
2383
+ return;
2384
+ }
2385
+ const branch = parts[2] ?? "main";
2386
+ await saveRegistryConfig(remote, branch);
2387
+ notify(ctx, `Registry configured: ${remote} (${branch}).`);
2388
+ return;
2389
+ }
2390
+
2391
+ if (action === "remote") {
2392
+ const current = loadPiToolsSuiteConfig([], { cwd: ctx.cwd }).resourceRegistry;
2393
+ if (!current.remote) {
2394
+ notify(ctx, `Registry is not configured. Run /${COMMAND} configure <git-url> [branch].`);
2395
+ return;
2396
+ }
2397
+ let key = "unavailable";
2398
+ try { key = await resolveProjectKey(pi, ctx.cwd); } catch { /* project state is optional */ }
2399
+ notify(ctx, `${current.remote} (${current.branch})\nProject key: ${key}`);
2400
+ return;
2401
+ }
2402
+
2403
+ if (action === "project-key") {
2404
+ const value = parts[1];
2405
+ if (!value) {
2406
+ const configured = loadPiToolsSuiteConfig([], { cwd: ctx.cwd }).resourceRegistry.projectKey;
2407
+ const key = await resolveProjectKey(pi, ctx.cwd);
2408
+ notify(ctx, `${key}${configured ? " (configured)" : " (derived from Git origin)"}`);
2409
+ return;
2410
+ }
2411
+ if (value === "auto") {
2412
+ await saveProjectKeyConfig(ctx.cwd, undefined);
2413
+ notify(ctx, `Project registry key override cleared; using ${await resolveProjectKey(pi, ctx.cwd)}.`);
2414
+ return;
2415
+ }
2416
+ await saveProjectKeyConfig(ctx.cwd, value);
2417
+ notify(ctx, `Project registry key set to ${value}.`);
2418
+ return;
2419
+ }
2420
+
2421
+ if (action === "status") {
2422
+ const runtime = loadRuntimeConfig(ctx.cwd);
2423
+ await ensureRegistryCache(pi, runtime);
2424
+ const [statuses, projectStatus] = await Promise.all([
2425
+ collectStatuses(pi, ctx, runtime),
2426
+ collectProjectStatuses(pi, ctx, runtime),
2427
+ ]);
2428
+ sendStatusSystemMessage(pi, statuses, runtime, projectStatus);
2429
+ return;
2430
+ }
2431
+
2432
+ if (action === "pull") {
2433
+ const scope = projectScope(parts[1]);
2434
+ if (scope) {
2435
+ await pullProjectState(pi, ctx, scope);
2436
+ return;
2437
+ }
2438
+ await interactiveProjectState(pi, ctx, "pull");
2439
+ return;
2440
+ }
2441
+
2442
+ if (action === "push") {
2443
+ const scope = projectScope(parts[1]);
2444
+ if (scope) {
2445
+ await pushProjectState(pi, ctx, scope);
2446
+ return;
2447
+ }
2448
+ }
2449
+
2450
+ if (action === "install" || action === "update" || action === "push" || action === "remove" || action === "uninstall") {
2451
+ const scope: ResourceScope | undefined = parts[1] === "all"
2452
+ ? "all"
2453
+ : parts[2] === "all"
2454
+ ? resourceType(parts[1])
2455
+ : undefined;
2456
+ if (scope) {
2457
+ if (action === "install") await installAll(pi, ctx, scope);
2458
+ else if (action === "update") await updateAll(pi, ctx, scope);
2459
+ else if (action === "push") await pushAll(pi, ctx, scope);
2460
+ else if (action === "remove") await removeAll(pi, ctx, scope);
2461
+ else await uninstallAll(ctx, scope);
2462
+ return;
2463
+ }
2464
+ }
2465
+
2466
+ const type = resourceType(parts[1]);
2467
+ const name = parts[2];
2468
+ if (!type || !name) {
2469
+ if (action === "install") {
2470
+ await interactiveInstall(pi, ctx, type);
2471
+ return;
2472
+ }
2473
+ if (action === "push") {
2474
+ await interactivePush(pi, ctx, type);
2475
+ return;
2476
+ }
2477
+ if (action === "remove") {
2478
+ await interactiveRemove(pi, ctx, type);
2479
+ return;
2480
+ }
2481
+ if (action === "uninstall") {
2482
+ await interactiveUninstall(ctx, type);
2483
+ return;
2484
+ }
2485
+ if (action === "update") {
2486
+ await interactiveUpdate(pi, ctx);
2487
+ return;
2488
+ }
2489
+ throw new Error(registryUsage());
2490
+ }
2491
+
2492
+ if (action === "install") {
2493
+ await installResource(pi, ctx, type, name);
2494
+ return;
2495
+ }
2496
+ if (action === "push") {
2497
+ await pushResource(pi, ctx, type, name);
2498
+ return;
2499
+ }
2500
+ if (action === "remove") {
2501
+ await removeResource(pi, ctx, type, name);
2502
+ return;
2503
+ }
2504
+ if (action === "uninstall") {
2505
+ await uninstallResource(ctx, type, name);
2506
+ return;
2507
+ }
2508
+ const result = await updateResource(pi, ctx, type, name);
2509
+ if (result === "current") {
2510
+ notify(ctx, `${type} "${name}" is already up to date.`);
2511
+ return;
2512
+ }
2513
+ notify(ctx, `Updated ${type} "${name}". Reloading resources…`);
2514
+ await reloadAfterResourceChange(ctx);
2515
+ }
2516
+
2517
+ export default function resourceRegistry(pi: ExtensionAPI): void {
2518
+ pi.on("session_start", (event, ctx) => {
2519
+ scheduleRegistryUiSnapshot(pi, ctx);
2520
+ if (event.reason === "startup") scheduleStartupUpdateCheck(pi, ctx);
2521
+ });
2522
+
2523
+ pi.registerCommand(COMMAND, {
2524
+ description: "Install, update, push, uninstall locally, and remove remotely reusable skills/agents plus sync project-scoped tasks/plans/TODO through a private Git registry",
2525
+ handler: async (args: string, ctx) => {
2526
+ try {
2527
+ await handleCommand(pi, args, ctx);
2528
+ } catch (error) {
2529
+ try {
2530
+ ignoreStaleExtensionContextError(error);
2531
+ } catch (fatal) {
2532
+ notify(ctx, fatal instanceof Error ? fatal.message : String(fatal), "error");
2533
+ }
2534
+ }
2535
+ },
2536
+ });
2537
+ }
2538
+
2539
+ export const __test = {
2540
+ collectRegistryUiSnapshot,
2541
+ collectStatuses,
2542
+ hashPath,
2543
+ loadRuntimeConfig,
2544
+ parseFrontmatterDescription,
2545
+ projectKeyFromGitRemote,
2546
+ registryResourceRelativePath,
2547
+ resourceKey,
2548
+ runStartupUpdateCheck,
2549
+ saveRegistryConfig,
2550
+ startupUpdateSummary,
2551
+ };