@gajae-code/coding-agent 0.16.3 → 0.16.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/args.d.ts +2 -2
  4. package/dist/types/cli/customize-doctor.d.ts +22 -0
  5. package/dist/types/cli/root-flags.d.ts +4 -2
  6. package/dist/types/cli.d.ts +1 -1
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +31 -3
  10. package/dist/types/config/model-registry.d.ts +4 -0
  11. package/dist/types/config/model-resolver.d.ts +9 -0
  12. package/dist/types/config/settings-schema.d.ts +29 -1
  13. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  15. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  16. package/dist/types/discovery/helpers.d.ts +35 -0
  17. package/dist/types/exec/bash-executor.d.ts +17 -1
  18. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  19. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  20. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  21. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  22. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  23. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  24. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  25. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  26. package/dist/types/exec/isolated-shell.d.ts +19 -0
  27. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  28. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  29. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  30. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  31. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  32. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  33. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  34. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  35. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  36. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  37. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  38. package/dist/types/extensibility/runtime-skill-discovery.d.ts +5 -2
  39. package/dist/types/extensibility/shared-events.d.ts +1 -1
  40. package/dist/types/extensibility/skill-management.d.ts +9 -4
  41. package/dist/types/extensibility/skills.d.ts +2 -0
  42. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  43. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  44. package/dist/types/gjc-runtime/session-state-lock.d.ts +14 -0
  45. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  46. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  47. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  48. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  49. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  50. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  51. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  52. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  53. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  54. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  55. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  56. package/dist/types/modes/interactive-mode.d.ts +3 -0
  57. package/dist/types/modes/types.d.ts +7 -0
  58. package/dist/types/runtime-mcp/client.d.ts +9 -1
  59. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  60. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  61. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  62. package/dist/types/runtime-mcp/types.d.ts +22 -0
  63. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  64. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  65. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  66. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  68. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  69. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  70. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  71. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  72. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  73. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  74. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  75. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  76. package/dist/types/sdk/client/client.d.ts +6 -0
  77. package/dist/types/sdk/host/host.d.ts +3 -1
  78. package/dist/types/sdk/host/session-runtime.d.ts +26 -1
  79. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  80. package/dist/types/sdk/router/session-router.d.ts +10 -2
  81. package/dist/types/sdk/session-api-key.d.ts +26 -0
  82. package/dist/types/sdk/session.d.ts +5 -1
  83. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  84. package/dist/types/session/agent-session.d.ts +11 -0
  85. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  86. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  87. package/dist/types/session/messages.d.ts +1 -0
  88. package/dist/types/session/session-manager.d.ts +3 -1
  89. package/dist/types/setup/paseo/announce.d.ts +112 -0
  90. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  91. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  92. package/dist/types/system-prompt.d.ts +10 -0
  93. package/dist/types/task/executor.d.ts +5 -1
  94. package/dist/types/tools/bash.d.ts +1 -0
  95. package/dist/types/tools/fetch.d.ts +7 -0
  96. package/dist/types/tools/github-cache.d.ts +2 -1
  97. package/dist/types/tools/index.d.ts +10 -0
  98. package/dist/types/tools/path-utils.d.ts +2 -0
  99. package/dist/types/tools/search.d.ts +6 -0
  100. package/package.json +9 -7
  101. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  102. package/scripts/generate-sdk-operation-inventory.ts +11 -0
  103. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  104. package/src/capability/index.ts +14 -2
  105. package/src/capability/types.ts +4 -0
  106. package/src/cli/args.ts +20 -9
  107. package/src/cli/customize-doctor.ts +155 -12
  108. package/src/cli/root-flags.ts +5 -3
  109. package/src/cli.ts +22 -0
  110. package/src/commands/sdk.ts +74 -31
  111. package/src/config/autorouting-tier-map.ts +1 -0
  112. package/src/config/file-lock-gc.ts +40 -2
  113. package/src/config/file-lock.ts +379 -31
  114. package/src/config/model-preset-registry.ts +120 -19
  115. package/src/config/model-profiles.ts +40 -0
  116. package/src/config/model-registry.ts +73 -23
  117. package/src/config/model-resolver.ts +48 -0
  118. package/src/config/settings-schema.ts +33 -1
  119. package/src/config/settings.ts +1 -0
  120. package/src/coordinator-mcp/question-state.ts +33 -2
  121. package/src/coordinator-mcp/server.ts +477 -101
  122. package/src/crash/sanitize.ts +1 -1
  123. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  124. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  125. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  126. package/src/discovery/builtin.ts +52 -55
  127. package/src/discovery/claude-plugins.ts +12 -4
  128. package/src/discovery/helpers.ts +307 -34
  129. package/src/exec/bash-executor.ts +330 -50
  130. package/src/exec/bash-shell-guardian.ts +388 -0
  131. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  132. package/src/exec/bash-shell-signals.ts +29 -0
  133. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  134. package/src/exec/bash-shell-supervisor.ts +359 -0
  135. package/src/exec/bash-shell-worker-entry.ts +5 -0
  136. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  137. package/src/exec/bash-shell-worker.ts +116 -0
  138. package/src/exec/isolated-shell.ts +393 -0
  139. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  140. package/src/extensibility/extensions/function-hooks.ts +959 -0
  141. package/src/extensibility/extensions/index.ts +22 -0
  142. package/src/extensibility/extensions/loader.ts +53 -2
  143. package/src/extensibility/extensions/runner.ts +895 -264
  144. package/src/extensibility/extensions/types.ts +42 -0
  145. package/src/extensibility/extensions/wrapper.ts +56 -15
  146. package/src/extensibility/gjc-plugins/compiler.ts +228 -60
  147. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  148. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  149. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  150. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  151. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  152. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  153. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  154. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  155. package/src/extensibility/gjc-plugins/types.ts +25 -0
  156. package/src/extensibility/hooks/loader.ts +133 -16
  157. package/src/extensibility/hooks/types.ts +28 -0
  158. package/src/extensibility/runtime-skill-discovery.ts +149 -190
  159. package/src/extensibility/shared-events.ts +1 -1
  160. package/src/extensibility/skill-management.ts +200 -52
  161. package/src/extensibility/skills.ts +73 -25
  162. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  163. package/src/gjc-runtime/gc-runtime.ts +29 -0
  164. package/src/gjc-runtime/linux-proc.ts +14 -0
  165. package/src/gjc-runtime/repository-binding.ts +1 -1
  166. package/src/gjc-runtime/session-state-lock.ts +143 -10
  167. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  168. package/src/gjc-runtime/state-writer.ts +30 -29
  169. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  170. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  171. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  172. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  173. package/src/harness-control-plane/session-lease.ts +2 -2
  174. package/src/internal-urls/docs-index.generated.ts +12 -12
  175. package/src/lsp/config.ts +10 -3
  176. package/src/lsp/index.ts +1 -1
  177. package/src/lsp/path-trust.ts +69 -14
  178. package/src/main.ts +78 -35
  179. package/src/modes/DESIGN.md +54 -0
  180. package/src/modes/acp/acp-agent.ts +16 -3
  181. package/src/modes/components/assistant-message.ts +22 -9
  182. package/src/modes/components/bash-execution.ts +4 -1
  183. package/src/modes/components/execution-shared.ts +4 -1
  184. package/src/modes/components/model-selector.ts +166 -33
  185. package/src/modes/components/session-observer-overlay.ts +1 -1
  186. package/src/modes/components/settings-selector.ts +1075 -235
  187. package/src/modes/components/status-line/command.ts +190 -0
  188. package/src/modes/components/status-line/index.ts +1 -0
  189. package/src/modes/components/status-line/segments.ts +32 -5
  190. package/src/modes/components/status-line/types.ts +5 -0
  191. package/src/modes/components/tool-status-header.ts +396 -22
  192. package/src/modes/controllers/command-controller.ts +3 -0
  193. package/src/modes/controllers/event-controller.ts +151 -7
  194. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  195. package/src/modes/controllers/input-controller.ts +6 -3
  196. package/src/modes/controllers/selector-controller.ts +21 -0
  197. package/src/modes/interactive-mode.ts +53 -6
  198. package/src/modes/print-mode.ts +5 -1
  199. package/src/modes/prompt-action-autocomplete.ts +54 -27
  200. package/src/modes/runtime-init.ts +2 -0
  201. package/src/modes/types.ts +17 -3
  202. package/src/modes/utils/ui-helpers.ts +2 -0
  203. package/src/prompts/system/system-prompt.md +8 -0
  204. package/src/prompts/tools/search.md +2 -0
  205. package/src/prompts/tools/subagent.md +1 -0
  206. package/src/runtime-mcp/client.ts +60 -17
  207. package/src/runtime-mcp/manager.ts +189 -14
  208. package/src/runtime-mcp/pool.test.ts +218 -0
  209. package/src/runtime-mcp/pool.ts +224 -23
  210. package/src/runtime-mcp/transports/stdio.ts +158 -24
  211. package/src/runtime-mcp/types.ts +24 -0
  212. package/src/sdk/broker/broker.ts +124 -32
  213. package/src/sdk/broker/ensure.ts +248 -41
  214. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  215. package/src/sdk/broker/lifecycle.ts +56 -6
  216. package/src/sdk/broker/startup-failure.ts +15 -6
  217. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  218. package/src/sdk/bus/chat-daemon-runtime.ts +72 -6
  219. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  220. package/src/sdk/bus/conversation-store.ts +29 -0
  221. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  222. package/src/sdk/bus/slack-conversation.ts +1 -0
  223. package/src/sdk/bus/slack-daemon.ts +478 -49
  224. package/src/sdk/bus/slack-live-provider.ts +12 -0
  225. package/src/sdk/bus/slack-provider.ts +5 -0
  226. package/src/sdk/bus/telegram-daemon.ts +16 -2
  227. package/src/sdk/bus/topic-registry.ts +19 -0
  228. package/src/sdk/cli/master-cli.ts +1 -1
  229. package/src/sdk/cli/session-cli.ts +11 -8
  230. package/src/sdk/client/client.ts +66 -17
  231. package/src/sdk/host/host.ts +29 -9
  232. package/src/sdk/host/query/revision-store.ts +19 -13
  233. package/src/sdk/host/reverse-leases.ts +7 -4
  234. package/src/sdk/host/session-runtime.test.ts +52 -10
  235. package/src/sdk/host/session-runtime.ts +383 -44
  236. package/src/sdk/lifecycle/service.ts +4 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +77 -0
  238. package/src/sdk/router/session-router.ts +89 -38
  239. package/src/sdk/session-api-key.ts +94 -0
  240. package/src/sdk/session.ts +228 -55
  241. package/src/sdk/transport/relay.ts +35 -13
  242. package/src/sdk/transport/serve-cli.ts +3 -3
  243. package/src/session/agent-session.ts +240 -90
  244. package/src/session/contribution-prep.ts +214 -21
  245. package/src/session/fallback-chain-controller.ts +38 -0
  246. package/src/session/internal/managed-session-storage.ts +19 -5
  247. package/src/session/messages.ts +5 -0
  248. package/src/session/session-manager.ts +29 -5
  249. package/src/session-import/redact.ts +5 -1
  250. package/src/setup/paseo/announce.ts +456 -0
  251. package/src/setup/paseo/setup-deps.ts +15 -3
  252. package/src/skill-state/canonical-skills.ts +12 -0
  253. package/src/skills/skills.test.ts +41 -21
  254. package/src/slash-commands/builtin-registry.ts +1 -0
  255. package/src/system-prompt.ts +29 -3
  256. package/src/task/executor.ts +7 -1
  257. package/src/task/index.ts +21 -4
  258. package/src/tools/bash.ts +63 -19
  259. package/src/tools/cron.ts +1 -1
  260. package/src/tools/fetch.ts +29 -1
  261. package/src/tools/gh.ts +26 -3
  262. package/src/tools/github-cache.ts +68 -16
  263. package/src/tools/index.ts +10 -0
  264. package/src/tools/job.ts +21 -4
  265. package/src/tools/monitor.ts +8 -2
  266. package/src/tools/path-utils.ts +7 -0
  267. package/src/tools/read.ts +14 -2
  268. package/src/tools/render-utils.ts +1 -3
  269. package/src/tools/resource-gc.ts +28 -8
  270. package/src/tools/search.ts +116 -58
  271. package/src/tools/skill-discovery.ts +5 -0
  272. package/src/tools/skill.ts +37 -2
  273. package/src/tools/subagent.ts +6 -3
  274. package/src/tools/tool-catalog.generated.ts +10 -3
  275. package/src/tools/ultragoal-ask-guard.ts +2 -2
  276. package/src/web/insane/bridge.ts +113 -23
  277. package/src/web/search/providers/perplexity.ts +102 -41
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import type { FileHandle } from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import type { ThinkingLevel } from "@gajae-code/agent-core";
4
5
  import type { FileType as FileTypeEnum, glob as globFn } from "@gajae-code/natives";
@@ -9,6 +10,7 @@ import {
9
10
  getProjectDir,
10
11
  getTrustedHomeDir,
11
12
  logger,
13
+ normalizePathForComparison,
12
14
  parseFrontmatter,
13
15
  tryParseJson,
14
16
  } from "@gajae-code/utils";
@@ -99,7 +101,32 @@ export const SOURCE_PATHS = {
99
101
  },
100
102
  } as const;
101
103
 
104
+ /** Ancestors from `cwd` through `stopAt`, excluding one normalized authority boundary. */
105
+ export function getAncestorDirs(
106
+ cwd: string,
107
+ stopAt?: string | null,
108
+ excludeDir?: string,
109
+ ): Array<{ dir: string; depth: number }> {
110
+ const ancestors: Array<{ dir: string; depth: number }> = [];
111
+ let current = path.resolve(cwd);
112
+ let depth = 0;
113
+ let normalizedCurrent = normalizePathForComparison(current);
114
+ const normalizedStop = stopAt ? normalizePathForComparison(path.resolve(stopAt)) : undefined;
115
+ const normalizedExclude = excludeDir ? normalizePathForComparison(path.resolve(excludeDir)) : undefined;
116
+ while (true) {
117
+ if (normalizedCurrent !== normalizedExclude) ancestors.push({ dir: current, depth });
118
+ if (normalizedStop && normalizedCurrent === normalizedStop) break;
119
+ const parent = path.dirname(current);
120
+ if (parent === current) break;
121
+ current = parent;
122
+ normalizedCurrent = normalizePathForComparison(current);
123
+ depth++;
124
+ }
125
+ return ancestors;
126
+ }
127
+
102
128
  export type SourceId = keyof typeof SOURCE_PATHS;
129
+ export type ProfileAuthority = "default" | "custom";
103
130
 
104
131
  /**
105
132
  * Get user-level path for a source.
@@ -110,6 +137,49 @@ export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string)
110
137
  return path.join(ctx.home, paths.userAgent, subpath);
111
138
  }
112
139
 
140
+ /**
141
+ * Deterministic user-scope skill scan directories, highest precedence first.
142
+ *
143
+ * The canonical root is the agent directory (`getAgentDir()`, moved by
144
+ * `--agent-dir` / `GJC_CODING_AGENT_DIR` / `setAgentDir()`) — the target of
145
+ * every user-scope skill writer (`gjc migrate`, `gjc skill`). An agent-directory
146
+ * profile is a *separate* user scope, the same contract as MCP user config
147
+ * (#4768): its legacy home-relative roots are not scanned, so a profile cannot
148
+ * pick up the default profile's skills (and vice versa). The resolver-owned
149
+ * `profileAuthority` classification is authoritative when supplied, so a
150
+ * custom profile remains isolated even if a later HOME/config-root refresh
151
+ * makes its path look like the current default. In the default profile the
152
+ * agent directory is `<home>/<configDir>/agent` and the configured legacy roots
153
+ * below it are still honored, exactly as before.
154
+ */
155
+ export function resolveUserAgentDir(home: string, userAgentDir?: string): string {
156
+ return path.resolve(userAgentDir ?? path.join(home, SOURCE_PATHS.native.userAgent));
157
+ }
158
+
159
+ export function getUserSkillScanDirs(
160
+ home: string,
161
+ userAgentDir?: string,
162
+ profileAuthority?: ProfileAuthority,
163
+ ): string[] {
164
+ const resolvedAgentDir = resolveUserAgentDir(home, userAgentDir);
165
+ const defaultAgentDir = path.join(home, SOURCE_PATHS.native.userAgent);
166
+ const authority =
167
+ profileAuthority ??
168
+ (normalizePathForComparison(resolvedAgentDir) === normalizePathForComparison(defaultAgentDir)
169
+ ? "default"
170
+ : "custom");
171
+ if (authority === "custom") {
172
+ return [path.join(resolvedAgentDir, "skills")];
173
+ }
174
+ return [
175
+ ...new Set([
176
+ path.join(resolvedAgentDir, "skills"),
177
+ path.join(home, SOURCE_PATHS.native.userBase, "skills"),
178
+ path.join(home, ".gjc", "skills"),
179
+ ]),
180
+ ];
181
+ }
182
+
113
183
  /**
114
184
  * Get project-level path for a source (cwd only).
115
185
  */
@@ -345,6 +415,8 @@ async function globIf(
345
415
 
346
416
  export interface ScanSkillsFromDirOptions {
347
417
  dir: string;
418
+ /** Selected authority root whose own identity must not be a symlink. */
419
+ authorityRoot?: string;
348
420
  providerId: string;
349
421
  level: "user" | "project";
350
422
  requireDescription?: boolean;
@@ -365,30 +437,40 @@ export const SKILL_FRONTMATTER_SCAN_BYTES = 4 * 1024;
365
437
  /** Maximum total bytes read while seeking the frontmatter closing delimiter. */
366
438
  export const SKILL_FRONTMATTER_SCAN_TOTAL_BYTES = 64 * 1024;
367
439
 
368
- async function readSkillFrontmatter(skillPath: string): Promise<SkillFrontmatter | null> {
369
- const file = Bun.file(skillPath);
370
- const size = (await fs.promises.stat(skillPath)).size;
371
- const scanLimit = Math.min(size, SKILL_FRONTMATTER_SCAN_TOTAL_BYTES);
372
- let offset = 0;
373
- let prefix = "";
374
- const decoder = new TextDecoder();
375
- while (offset < scanLimit) {
376
- const end = Math.min(offset + SKILL_FRONTMATTER_SCAN_BYTES, scanLimit);
377
- const bytes = new Uint8Array(await file.slice(offset, end).arrayBuffer());
378
- const chunk = decoder.decode(bytes, { stream: end < scanLimit });
379
- if (!chunk) break;
380
- prefix += chunk;
381
- offset = end;
382
-
383
- const opening = prefix.match(/^---[ \t]*(?:\r?\n|$)/);
384
- if (!opening) return null;
385
- const afterOpening = prefix.slice(opening[0].length);
386
- const closing = afterOpening.match(/\r?\n---[ \t]*(?:\r?\n|$)/);
387
- if (!closing || closing.index === undefined) continue;
388
- const bounded = prefix.slice(0, opening[0].length + closing.index + closing[0].length);
389
- return parseFrontmatter(bounded, { source: skillPath }).frontmatter as SkillFrontmatter;
440
+ export const SkillDiscoveryTestHooks: {
441
+ afterCandidateValidated?: (candidatePath: string) => void | Promise<void>;
442
+ afterAuthorityRootValidated?: (root: string) => void | Promise<void>;
443
+ afterAuthorityRootMissing?: (root: string) => void | Promise<void>;
444
+ afterContainedRootValidated?: (root: string) => void | Promise<void>;
445
+ } = {};
446
+
447
+ interface DirectoryIdentity {
448
+ dev: bigint;
449
+ ino: bigint;
450
+ realPath: string;
451
+ }
452
+
453
+ async function captureDirectoryIdentity(root: string): Promise<DirectoryIdentity | null> {
454
+ const observed = await fs.promises.lstat(root, { bigint: true });
455
+ if (observed.isSymbolicLink() || !observed.isDirectory()) return null;
456
+ const realPath = await fs.promises.realpath(root);
457
+ const resolved = await fs.promises.stat(realPath, { bigint: true });
458
+ if (!resolved.isDirectory() || observed.dev !== resolved.dev || observed.ino !== resolved.ino) return null;
459
+ return { dev: observed.dev, ino: observed.ino, realPath };
460
+ }
461
+
462
+ async function directoryIdentityIsCurrent(root: string, expected: DirectoryIdentity): Promise<boolean> {
463
+ try {
464
+ const current = await captureDirectoryIdentity(root);
465
+ return (
466
+ current !== null &&
467
+ current.dev === expected.dev &&
468
+ current.ino === expected.ino &&
469
+ normalizePathForComparison(current.realPath) === normalizePathForComparison(expected.realPath)
470
+ );
471
+ } catch {
472
+ return false;
390
473
  }
391
- return null;
392
474
  }
393
475
 
394
476
  export async function scanSkillsFromDir(
@@ -400,19 +482,150 @@ export async function scanSkillsFromDir(
400
482
  const { dir, level, providerId, requireDescription = false } = options;
401
483
 
402
484
  let entries: fs.Dirent[];
485
+ let realRoot: string;
486
+ let authorityIdentity: DirectoryIdentity | null = null;
487
+ let scanRootIdentity: DirectoryIdentity;
403
488
  try {
489
+ if (options.authorityRoot) {
490
+ try {
491
+ authorityIdentity = await captureDirectoryIdentity(options.authorityRoot);
492
+ if (!authorityIdentity) {
493
+ warnings.push(`Refusing unsafe skill authority root: ${options.authorityRoot}`);
494
+ return { items, warnings };
495
+ }
496
+ await SkillDiscoveryTestHooks.afterAuthorityRootValidated?.(options.authorityRoot);
497
+ if (!(await directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity))) {
498
+ warnings.push(`Refusing changed skill authority root: ${options.authorityRoot}`);
499
+ return { items, warnings };
500
+ }
501
+ } catch (error) {
502
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
503
+ await SkillDiscoveryTestHooks.afterAuthorityRootMissing?.(options.authorityRoot);
504
+ return { items, warnings };
505
+ }
506
+ }
507
+ const capturedScanRoot = await captureDirectoryIdentity(dir);
508
+ if (!capturedScanRoot) {
509
+ warnings.push(`Refusing unsafe skills directory: ${dir}`);
510
+ return { items, warnings };
511
+ }
512
+ scanRootIdentity = capturedScanRoot;
513
+ realRoot = scanRootIdentity.realPath;
404
514
  entries = await fs.promises.readdir(dir, { withFileTypes: true });
515
+ if (!(await directoryIdentityIsCurrent(dir, scanRootIdentity))) {
516
+ warnings.push(`Refusing changed skills directory: ${dir}`);
517
+ return { items, warnings };
518
+ }
405
519
  } catch (error) {
406
520
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
407
521
  warnings.push(`Failed to read skills directory: ${dir} (${String(error)})`);
408
522
  }
409
523
  return { items, warnings };
410
524
  }
411
- const loadSkill = async (skillPath: string) => {
525
+ const isWithinRoot = (candidate: string): boolean => {
526
+ const relative = path.relative(realRoot, candidate);
527
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
528
+ };
529
+ const openSkillFileSafely = async (skillPath: string): Promise<FileHandle> => {
530
+ if (
531
+ !(await directoryIdentityIsCurrent(dir, scanRootIdentity)) ||
532
+ (options.authorityRoot &&
533
+ authorityIdentity &&
534
+ !(await directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity)))
535
+ ) {
536
+ throw new Error(`Unsafe skill authority root for: ${skillPath}`);
537
+ }
538
+ const flags =
539
+ fs.constants.O_RDONLY |
540
+ (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
541
+ const handle = await fs.promises.open(skillPath, flags);
412
542
  try {
413
- const frontmatter = await readSkillFrontmatter(skillPath);
543
+ const [opened, observed, currentPath, currentScanRoot, currentAuthorityRoot] = await Promise.all([
544
+ handle.stat({ bigint: true }),
545
+ fs.promises.lstat(skillPath, { bigint: true }),
546
+ fs.promises.realpath(skillPath),
547
+ directoryIdentityIsCurrent(dir, scanRootIdentity),
548
+ options.authorityRoot && authorityIdentity
549
+ ? directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity)
550
+ : true,
551
+ ]);
552
+ if (
553
+ !opened.isFile() ||
554
+ opened.nlink !== 1n ||
555
+ observed.isSymbolicLink() ||
556
+ !observed.isFile() ||
557
+ opened.dev !== observed.dev ||
558
+ opened.ino !== observed.ino ||
559
+ !isWithinRoot(currentPath) ||
560
+ !currentScanRoot ||
561
+ !currentAuthorityRoot
562
+ ) {
563
+ throw new Error(`Unsafe skill file: ${skillPath}`);
564
+ }
565
+ return handle;
566
+ } catch (error) {
567
+ await handle.close();
568
+ throw error;
569
+ }
570
+ };
571
+ const readSkillContentSafely = async (skillPath: string): Promise<string> => {
572
+ const handle = await openSkillFileSafely(skillPath);
573
+ try {
574
+ return await handle.readFile({ encoding: "utf8" });
575
+ } finally {
576
+ await handle.close();
577
+ }
578
+ };
579
+ const readSkillFrontmatterSafely = async (
580
+ skillPath: string,
581
+ ): Promise<{ frontmatter: SkillFrontmatter | null; size: number }> => {
582
+ const handle = await openSkillFileSafely(skillPath);
583
+ try {
584
+ const size = Number((await handle.stat({ bigint: true })).size);
585
+ const scanLimit = Math.min(size, SKILL_FRONTMATTER_SCAN_TOTAL_BYTES);
586
+ let offset = 0;
587
+ let prefix = "";
588
+ const decoder = new TextDecoder();
589
+ while (offset < scanLimit) {
590
+ const length = Math.min(SKILL_FRONTMATTER_SCAN_BYTES, scanLimit - offset);
591
+ const bytes = new Uint8Array(length);
592
+ const { bytesRead } = await handle.read(bytes, 0, length, offset);
593
+ if (bytesRead === 0) break;
594
+ offset += bytesRead;
595
+ prefix += decoder.decode(bytes.subarray(0, bytesRead), { stream: offset < scanLimit });
596
+
597
+ const opening = prefix.match(/^---[ \t]*(?:\r?\n|$)/);
598
+ if (!opening) return { frontmatter: null, size };
599
+ const afterOpening = prefix.slice(opening[0].length);
600
+ const closing = afterOpening.match(/\r?\n---[ \t]*(?:\r?\n|$)/);
601
+ if (!closing || closing.index === undefined) continue;
602
+ const bounded = prefix.slice(0, opening[0].length + closing.index + closing[0].length);
603
+ return {
604
+ frontmatter: parseFrontmatter(bounded, { source: skillPath }).frontmatter as SkillFrontmatter,
605
+ size,
606
+ };
607
+ }
608
+ return { frontmatter: null, size };
609
+ } finally {
610
+ await handle.close();
611
+ }
612
+ };
613
+ const loadSkill = async (candidatePath: string) => {
614
+ try {
615
+ const skillPath = await fs.promises.realpath(candidatePath);
616
+ if (!isWithinRoot(skillPath)) {
617
+ warnings.push(`Refusing skill path outside scan root: ${candidatePath}`);
618
+ return;
619
+ }
620
+ const stat = await fs.promises.stat(skillPath);
621
+ if (!stat.isFile() || stat.nlink !== 1) {
622
+ warnings.push(`Skill path is not a regular file: ${candidatePath}`);
623
+ return;
624
+ }
625
+ await SkillDiscoveryTestHooks.afterCandidateValidated?.(candidatePath);
626
+ const { frontmatter, size } = await readSkillFrontmatterSafely(skillPath);
414
627
  if (!frontmatter) {
415
- if (fs.statSync(skillPath).size > SKILL_FRONTMATTER_SCAN_TOTAL_BYTES) {
628
+ if (size > SKILL_FRONTMATTER_SCAN_TOTAL_BYTES) {
416
629
  warnings.push(
417
630
  `Skill frontmatter exceeded ${SKILL_FRONTMATTER_SCAN_TOTAL_BYTES} byte scan cap: ${skillPath}`,
418
631
  );
@@ -435,15 +648,16 @@ export async function scanSkillsFromDir(
435
648
  name,
436
649
  path: skillPath,
437
650
  loadContent: async () => {
438
- const content = await Bun.file(skillPath).text();
651
+ const content = await readSkillContentSafely(skillPath);
439
652
  return parseFrontmatter(content, { source: skillPath }).body;
440
653
  },
441
654
  frontmatter: frontmatter as SkillFrontmatter,
442
655
  level,
443
656
  _source: createSourceMeta(providerId, skillPath, level),
444
657
  });
445
- } catch {
446
- warnings.push(`Failed to read skill file: ${skillPath}`);
658
+ } catch (error) {
659
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
660
+ warnings.push(`Failed to read skill file: ${candidatePath}`);
447
661
  }
448
662
  };
449
663
 
@@ -452,9 +666,7 @@ export async function scanSkillsFromDir(
452
666
  if (entry.name.startsWith(".")) continue;
453
667
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
454
668
  const skillPath = path.join(dir, entry.name, "SKILL.md");
455
- if (fs.existsSync(skillPath)) {
456
- work.push(loadSkill(skillPath));
457
- }
669
+ work.push(loadSkill(skillPath));
458
670
  }
459
671
  await Promise.all(work);
460
672
 
@@ -464,6 +676,59 @@ export async function scanSkillsFromDir(
464
676
  return { items, warnings };
465
677
  }
466
678
 
679
+ /** Read a regular single-link file that remains contained by its configured root. */
680
+ export async function readContainedFile(root: string, filePath: string): Promise<string | null> {
681
+ let rootIdentity: DirectoryIdentity;
682
+ try {
683
+ const capturedRoot = await captureDirectoryIdentity(root);
684
+ if (!capturedRoot) return null;
685
+ rootIdentity = capturedRoot;
686
+ await SkillDiscoveryTestHooks.afterContainedRootValidated?.(root);
687
+ if (!(await directoryIdentityIsCurrent(root, rootIdentity))) return null;
688
+ } catch (error) {
689
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
690
+ throw error;
691
+ }
692
+ const relative = path.relative(root, filePath);
693
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
694
+ const flags =
695
+ fs.constants.O_RDONLY |
696
+ (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
697
+ let handle: FileHandle;
698
+ try {
699
+ handle = await fs.promises.open(filePath, flags);
700
+ } catch (error) {
701
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
702
+ return null;
703
+ }
704
+ try {
705
+ const [opened, observed, currentPath, currentRoot] = await Promise.all([
706
+ handle.stat({ bigint: true }),
707
+ fs.promises.lstat(filePath, { bigint: true }),
708
+ fs.promises.realpath(filePath),
709
+ directoryIdentityIsCurrent(root, rootIdentity),
710
+ ]);
711
+ const currentRelative = path.relative(rootIdentity.realPath, currentPath);
712
+ if (
713
+ !opened.isFile() ||
714
+ opened.nlink !== 1n ||
715
+ observed.isSymbolicLink() ||
716
+ !observed.isFile() ||
717
+ opened.dev !== observed.dev ||
718
+ opened.ino !== observed.ino ||
719
+ !currentRoot ||
720
+ currentRelative.startsWith("..") ||
721
+ path.isAbsolute(currentRelative)
722
+ ) {
723
+ return null;
724
+ }
725
+ const content = await handle.readFile({ encoding: "utf8" });
726
+ return (await directoryIdentityIsCurrent(root, rootIdentity)) ? content : null;
727
+ } finally {
728
+ await handle.close();
729
+ }
730
+ }
731
+
467
732
  /**
468
733
  * Expand environment variables in a string.
469
734
  * Supports ${VAR} and ${VAR:-default} syntax.
@@ -852,6 +1117,14 @@ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise<
852
1117
 
853
1118
  const pluginRootsCache = new Map<string, { roots: ClaudePluginRoot[]; warnings: string[] }>();
854
1119
 
1120
+ /** Invalidate the exact user/project registry pair and their resolved-root cache entry. */
1121
+ export async function invalidateClaudePluginRoots(home: string, cwd?: string): Promise<void> {
1122
+ const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
1123
+ invalidateFsCache(path.join(getPluginsDir(home), "installed_plugins.json"));
1124
+ if (resolvedProjectPath) invalidateFsCache(resolvedProjectPath);
1125
+ pluginRootsCache.delete(`${home}:${resolvedProjectPath ?? ""}`);
1126
+ }
1127
+
855
1128
  /**
856
1129
  * List installed GJC plugin roots from the GJC plugin registry and, when present,
857
1130
  * the nearest project-scoped registry resolved from `cwd`.
@@ -885,7 +1158,7 @@ export async function listClaudePluginRoots(
885
1158
  if (!Array.isArray(entries) || entries.length === 0) continue;
886
1159
 
887
1160
  const atIndex = pluginId.lastIndexOf("@");
888
- if (atIndex === -1) {
1161
+ if (atIndex <= 0 || atIndex === pluginId.length - 1) {
889
1162
  warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`);
890
1163
  continue;
891
1164
  }
@@ -927,7 +1200,7 @@ export async function listClaudePluginRoots(
927
1200
  for (const [pluginId, entries] of Object.entries(projectRegistry.plugins)) {
928
1201
  if (!Array.isArray(entries) || entries.length === 0) continue;
929
1202
  const atIndex = pluginId.lastIndexOf("@");
930
- if (atIndex === -1) {
1203
+ if (atIndex <= 0 || atIndex === pluginId.length - 1) {
931
1204
  warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`);
932
1205
  continue;
933
1206
  }