@gajae-code/coding-agent 0.7.3 → 0.7.5

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 (118) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/plugin-cli.d.ts +2 -0
  4. package/dist/types/commands/plugin.d.ts +6 -0
  5. package/dist/types/commands/session.d.ts +6 -0
  6. package/dist/types/config/model-profile-activation.d.ts +8 -1
  7. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  8. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  9. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  10. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  11. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  13. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  14. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  15. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  16. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  17. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  18. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  19. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  20. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  21. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  23. package/dist/types/gjc-runtime/tmux-common.d.ts +30 -2
  24. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  25. package/dist/types/main.d.ts +2 -0
  26. package/dist/types/modes/components/model-selector.d.ts +6 -0
  27. package/dist/types/notifications/html-format.d.ts +11 -0
  28. package/dist/types/notifications/index.d.ts +149 -1
  29. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  30. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  31. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  32. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  33. package/dist/types/notifications/recent-activity.d.ts +35 -0
  34. package/dist/types/notifications/telegram-daemon.d.ts +60 -0
  35. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  36. package/dist/types/notifications/topic-registry.d.ts +10 -9
  37. package/dist/types/runtime-mcp/types.d.ts +7 -0
  38. package/dist/types/sdk.d.ts +2 -0
  39. package/dist/types/session/agent-session.d.ts +14 -4
  40. package/dist/types/session/blob-store.d.ts +25 -0
  41. package/dist/types/session/session-manager.d.ts +57 -0
  42. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +9 -1
  45. package/dist/types/tools/index.d.ts +3 -1
  46. package/dist/types/utils/changelog.d.ts +1 -0
  47. package/package.json +11 -9
  48. package/scripts/g004-tmux-smoke.ts +100 -0
  49. package/scripts/g005-daemon-smoke.ts +181 -0
  50. package/scripts/g011-daemon-path-smoke.ts +153 -0
  51. package/src/cli/plugin-cli.ts +66 -3
  52. package/src/cli.ts +21 -4
  53. package/src/commands/plugin.ts +4 -0
  54. package/src/commands/session.ts +18 -0
  55. package/src/config/model-profile-activation.ts +55 -7
  56. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  57. package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -3
  58. package/src/defaults/gjc/skills/team/SKILL.md +5 -4
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  60. package/src/export/html/index.ts +2 -2
  61. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  62. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  63. package/src/extensibility/gjc-plugins/index.ts +9 -0
  64. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  65. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  66. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  67. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  68. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  69. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  70. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  71. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  72. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  73. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  74. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  75. package/src/extensibility/gjc-plugins/types.ts +199 -3
  76. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  77. package/src/extensibility/skills.ts +15 -0
  78. package/src/gjc-runtime/launch-tmux.ts +58 -15
  79. package/src/gjc-runtime/psmux-detect.ts +239 -0
  80. package/src/gjc-runtime/team-runtime.ts +56 -23
  81. package/src/gjc-runtime/tmux-common.ts +85 -3
  82. package/src/gjc-runtime/tmux-sessions.ts +111 -9
  83. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  84. package/src/internal-urls/docs-index.generated.ts +5 -4
  85. package/src/main.ts +14 -3
  86. package/src/modes/components/assistant-message.ts +49 -1
  87. package/src/modes/components/hook-editor.ts +1 -1
  88. package/src/modes/components/hook-selector.ts +67 -43
  89. package/src/modes/components/model-selector.ts +44 -11
  90. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  91. package/src/modes/controllers/selector-controller.ts +50 -11
  92. package/src/modes/interactive-mode.ts +2 -0
  93. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  94. package/src/notifications/html-format.ts +38 -0
  95. package/src/notifications/index.ts +242 -12
  96. package/src/notifications/lifecycle-commands.ts +228 -0
  97. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  98. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  99. package/src/notifications/rate-limit-pool.ts +19 -0
  100. package/src/notifications/recent-activity.ts +132 -0
  101. package/src/notifications/telegram-daemon.ts +433 -8
  102. package/src/notifications/telegram-reference.ts +25 -7
  103. package/src/notifications/topic-registry.ts +18 -9
  104. package/src/prompts/agents/executor.md +2 -2
  105. package/src/runtime-mcp/transports/stdio.ts +38 -4
  106. package/src/runtime-mcp/types.ts +7 -0
  107. package/src/sdk.ts +157 -10
  108. package/src/session/agent-session.ts +166 -74
  109. package/src/session/blob-store.ts +196 -8
  110. package/src/session/session-manager.ts +739 -12
  111. package/src/slash-commands/builtin-registry.ts +23 -3
  112. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  113. package/src/system-prompt.ts +9 -0
  114. package/src/task/executor.ts +31 -7
  115. package/src/task/index.ts +2 -0
  116. package/src/tools/ask.ts +5 -1
  117. package/src/tools/index.ts +3 -1
  118. package/src/utils/changelog.ts +8 -0
@@ -0,0 +1,351 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { parseFrontmatter, pathIsWithin } from "@gajae-code/utils";
5
+ import { resolveWithinRoot } from "./paths";
6
+ import { parseManifest, parseSubskillFrontmatter } from "./schema";
7
+ import {
8
+ GJC_PLUGIN_MANIFEST_FILENAME,
9
+ type GjcPluginAppendixManifestEntry,
10
+ GjcPluginLoadError,
11
+ type GjcPluginMcpManifestEntry,
12
+ type NormalizedAgentAppendixSurface,
13
+ type NormalizedAppendixSurface,
14
+ type NormalizedGjcPluginBundle,
15
+ type NormalizedGjcPluginSurfaces,
16
+ type NormalizedHookSurface,
17
+ type NormalizedMcpSurface,
18
+ type NormalizedSubskillSurface,
19
+ type NormalizedToolSurface,
20
+ } from "./types";
21
+ import { validateBinding } from "./validation";
22
+
23
+ function sha256(bytes: Buffer | string): string {
24
+ return createHash("sha256").update(bytes).digest("hex");
25
+ }
26
+
27
+ /**
28
+ * Stable surface extension-id builders. Kept here so install, runtime, and
29
+ * observability all derive identical ids.
30
+ */
31
+ export const surfaceIds = {
32
+ tool: (name: string): string => `tool:${name}`,
33
+ hook: (event: string, phase: string | undefined, target: string | undefined, name: string): string =>
34
+ `hook:${event}:${phase ?? ""}:${target ?? ""}:${name}`,
35
+ mcp: (name: string): string => `mcp:${name}`,
36
+ systemAppendix: (plugin: string, name: string): string => `system-appendix:${plugin}:${name}`,
37
+ agentAppendix: (agent: string, plugin: string, name: string): string => `agent-appendix:${agent}:${plugin}:${name}`,
38
+ subskill: (parent: string, phase: string, activationArg: string): string =>
39
+ `subskill:${parent}:${phase}:${activationArg}`,
40
+ } as const;
41
+
42
+ async function readManifestJson(filePath: string): Promise<unknown> {
43
+ let text: string;
44
+ try {
45
+ text = await fs.readFile(filePath, "utf8");
46
+ } catch (error) {
47
+ throw new GjcPluginLoadError("missing_file", `Missing GJC plugin manifest at ${filePath}`, {
48
+ cause: error instanceof Error ? error : undefined,
49
+ });
50
+ }
51
+ try {
52
+ return JSON.parse(text) as unknown;
53
+ } catch (error) {
54
+ throw new GjcPluginLoadError("invalid_manifest", `Invalid GJC plugin manifest JSON at ${filePath}`, {
55
+ cause: error instanceof Error ? error : undefined,
56
+ });
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Resolve a declared relative path, rejecting lexical escapes AND symlink
62
+ * escapes out of the plugin root. Never imports the file.
63
+ */
64
+ async function resolveDeclaredFile(pluginRoot: string, rel: string): Promise<string> {
65
+ const resolved = resolveWithinRoot(pluginRoot, rel);
66
+ let real: string;
67
+ try {
68
+ real = await fs.realpath(resolved);
69
+ } catch (error) {
70
+ throw new GjcPluginLoadError("missing_file", `Missing GJC plugin file at ${resolved}`, {
71
+ cause: error instanceof Error ? error : undefined,
72
+ });
73
+ }
74
+ const realRoot = await fs.realpath(pluginRoot);
75
+ if (!pathIsWithin(realRoot, real)) {
76
+ throw new GjcPluginLoadError("security_policy", `GJC plugin file escapes root via symlink: ${rel}`);
77
+ }
78
+ return resolved;
79
+ }
80
+
81
+ async function hashFile(
82
+ absPath: string,
83
+ rel: string,
84
+ declaredSha?: string,
85
+ ): Promise<{ sha256: string; bytes: number }> {
86
+ let buf: Buffer;
87
+ try {
88
+ buf = await fs.readFile(absPath);
89
+ } catch (error) {
90
+ throw new GjcPluginLoadError("missing_file", `Missing GJC plugin file at ${absPath}`, {
91
+ cause: error instanceof Error ? error : undefined,
92
+ });
93
+ }
94
+ const digest = sha256(buf);
95
+ if (declaredSha !== undefined && declaredSha.toLowerCase() !== digest) {
96
+ throw new GjcPluginLoadError("hash_mismatch", `GJC plugin file hash mismatch for ${rel}`);
97
+ }
98
+ return { sha256: digest, bytes: buf.byteLength };
99
+ }
100
+
101
+ function mcpConfigHash(entry: GjcPluginMcpManifestEntry): string {
102
+ const canonical = JSON.stringify({
103
+ name: entry.name,
104
+ transport: entry.transport,
105
+ command: entry.command ?? null,
106
+ args: entry.args ?? null,
107
+ cwd: entry.cwd ?? null,
108
+ url: entry.url ?? null,
109
+ headers: entry.headers ?? null,
110
+ });
111
+ return sha256(canonical);
112
+ }
113
+
114
+ async function compileAppendix(
115
+ pluginRoot: string,
116
+ entry: GjcPluginAppendixManifestEntry,
117
+ field: string,
118
+ files: Map<string, { sha256: string; bytes: number }>,
119
+ ): Promise<{ contentHash: string; bytes: number; relativePath?: string; content?: string }> {
120
+ const hasPath = entry.path !== undefined;
121
+ const hasContent = entry.content !== undefined;
122
+ if (hasPath === hasContent) {
123
+ throw new GjcPluginLoadError(
124
+ "invalid_appendix",
125
+ `Invalid GJC plugin ${field}: exactly one of "path" or "content" is required`,
126
+ );
127
+ }
128
+ if (hasContent) {
129
+ const content = entry.content ?? "";
130
+ if (content.trim().length === 0) {
131
+ throw new GjcPluginLoadError("invalid_appendix", `Invalid GJC plugin ${field}: inline content is empty`);
132
+ }
133
+ const digest = sha256(content);
134
+ if (entry.sha256 !== undefined && entry.sha256.toLowerCase() !== digest) {
135
+ throw new GjcPluginLoadError("hash_mismatch", `GJC plugin ${field} content hash mismatch`);
136
+ }
137
+ return { contentHash: digest, bytes: Buffer.byteLength(content), content };
138
+ }
139
+ const rel = entry.path as string;
140
+ const abs = await resolveDeclaredFile(pluginRoot, rel);
141
+ const { sha256: digest, bytes } = await hashFile(abs, rel, entry.sha256);
142
+ if (bytes === 0) {
143
+ throw new GjcPluginLoadError("invalid_appendix", `Invalid GJC plugin ${field}: file is empty`);
144
+ }
145
+ files.set(rel, { sha256: digest, bytes });
146
+ return { contentHash: digest, bytes, relativePath: rel };
147
+ }
148
+
149
+ /**
150
+ * Pure compile step: reads only the manifest, subskill frontmatter, and
151
+ * declared files (as bytes for hashing/existence). It NEVER imports or executes
152
+ * plugin tool/hook code.
153
+ */
154
+ export async function compileGjcPluginBundle(root: string): Promise<NormalizedGjcPluginBundle> {
155
+ const pluginRoot = path.resolve(root);
156
+ const manifestPath = path.join(pluginRoot, GJC_PLUGIN_MANIFEST_FILENAME);
157
+ const manifest = parseManifest(await readManifestJson(manifestPath), manifestPath);
158
+
159
+ const files = new Map<string, { sha256: string; bytes: number }>();
160
+
161
+ const subskills: NormalizedSubskillSurface[] = [];
162
+ for (const rel of manifest.subskills) {
163
+ const abs = await resolveDeclaredFile(pluginRoot, rel);
164
+ const { sha256: digest, bytes } = await hashFile(abs, rel);
165
+ files.set(rel, { sha256: digest, bytes });
166
+ let content: string;
167
+ try {
168
+ content = await fs.readFile(abs, "utf8");
169
+ } catch (error) {
170
+ throw new GjcPluginLoadError("missing_file", `Missing GJC sub-skill file at ${abs}`, {
171
+ cause: error instanceof Error ? error : undefined,
172
+ });
173
+ }
174
+ let parsed: { frontmatter: Record<string, unknown>; body: string };
175
+ try {
176
+ parsed = parseFrontmatter(content, { source: abs, level: "fatal" });
177
+ } catch (error) {
178
+ throw new GjcPluginLoadError("invalid_frontmatter", `Invalid GJC sub-skill frontmatter at ${abs}`, {
179
+ cause: error instanceof Error ? error : undefined,
180
+ });
181
+ }
182
+ const fm = parseSubskillFrontmatter(parsed.frontmatter, abs);
183
+ validateBinding(fm);
184
+ // Subskill-scoped frontmatter tools are hashed for copy-ownership and
185
+ // escape checks (the loader resolves these at runtime).
186
+ const fmTools = parsed.frontmatter.tools;
187
+ const fmToolPaths =
188
+ typeof fmTools === "string"
189
+ ? [fmTools]
190
+ : Array.isArray(fmTools) && fmTools.every(t => typeof t === "string")
191
+ ? (fmTools as string[])
192
+ : [];
193
+ for (const toolRel of fmToolPaths) {
194
+ if (toolRel.trim().length === 0) continue;
195
+ const toolAbs = await resolveDeclaredFile(pluginRoot, toolRel);
196
+ const { sha256: toolDigest, bytes: toolBytes } = await hashFile(toolAbs, toolRel);
197
+ files.set(toolRel, { sha256: toolDigest, bytes: toolBytes });
198
+ }
199
+ subskills.push({
200
+ extensionId: surfaceIds.subskill(fm.binds_to, fm.phase, fm.activation_arg),
201
+ name: fm.name,
202
+ description: fm.description,
203
+ parent: fm.binds_to,
204
+ phase: fm.phase,
205
+ activationArg: fm.activation_arg,
206
+ relativePath: rel,
207
+ sha256: digest,
208
+ });
209
+ }
210
+
211
+ // Every declared tool file is resolved/hashed for copy-ownership and escape
212
+ // checks, but only object-form ("always-on") tools become a session tool
213
+ // surface; legacy string shorthand stays subskill-scoped (loader-handled).
214
+ const tools: NormalizedToolSurface[] = [];
215
+ for (const tool of manifest.tools) {
216
+ const abs = await resolveDeclaredFile(pluginRoot, tool.path);
217
+ const { sha256: digest, bytes } = await hashFile(abs, tool.path, tool.sha256);
218
+ files.set(tool.path, { sha256: digest, bytes });
219
+ if (tool.surface !== "always-on") continue;
220
+ tools.push({
221
+ extensionId: surfaceIds.tool(tool.name),
222
+ name: tool.name,
223
+ relativePath: tool.path,
224
+ sha256: digest,
225
+ description: tool.description,
226
+ });
227
+ }
228
+
229
+ const hooks: NormalizedHookSurface[] = [];
230
+ for (const hook of manifest.hooks) {
231
+ // Path safety first: resolve/hash before semantic checks so traversal and
232
+ // missing-file failures take precedence over contract validation.
233
+ const abs = await resolveDeclaredFile(pluginRoot, hook.path);
234
+ const { sha256: digest, bytes } = await hashFile(abs, hook.path, hook.sha256);
235
+ files.set(hook.path, { sha256: digest, bytes });
236
+ // Minimal compile-time hook contract: tool_call hooks must name a target
237
+ // and a before/after phase so the constrained runner (M3/M4) can bind them.
238
+ if (hook.event === "tool_call") {
239
+ if (!hook.target) {
240
+ throw new GjcPluginLoadError("invalid_hook", `GJC plugin hook "${hook.name}": tool_call requires a target`);
241
+ }
242
+ if (!hook.phase) {
243
+ throw new GjcPluginLoadError(
244
+ "invalid_hook",
245
+ `GJC plugin hook "${hook.name}": tool_call requires a "before"/"after" phase`,
246
+ );
247
+ }
248
+ }
249
+ hooks.push({
250
+ extensionId: surfaceIds.hook(hook.event, hook.phase, hook.target, hook.name),
251
+ name: hook.name,
252
+ event: hook.event,
253
+ target: hook.target,
254
+ phase: hook.phase,
255
+ relativePath: hook.path,
256
+ sha256: digest,
257
+ });
258
+ }
259
+
260
+ const mcps: NormalizedMcpSurface[] = [];
261
+ for (const entry of manifest.mcps) {
262
+ // Minimal compile-time MCP contract: transport-specific endpoint must exist.
263
+ if (entry.transport === "stdio") {
264
+ if (!entry.command) {
265
+ throw new GjcPluginLoadError("invalid_mcp", `GJC plugin MCP "${entry.name}": stdio requires a command`);
266
+ }
267
+ } else if (!entry.url) {
268
+ throw new GjcPluginLoadError(
269
+ "invalid_mcp",
270
+ `GJC plugin MCP "${entry.name}": ${entry.transport} requires a url`,
271
+ );
272
+ }
273
+ // Hash bundled stdio script args (relative file paths) so the registry owns
274
+ // their copied-file boundary. Path/security failures must propagate, not be
275
+ // swallowed, so traversal/symlink-escape/missing bundled files fail compile.
276
+ for (const arg of entry.args ?? []) {
277
+ // Skip flags (e.g. "--port", "-v"); only treat clearly path-like args as
278
+ // bundled files subject to root confinement.
279
+ if (arg.startsWith("-")) continue;
280
+ if (!arg.startsWith(".") && !arg.includes("/")) continue;
281
+ const abs = await resolveDeclaredFile(pluginRoot, arg);
282
+ const { sha256: digest, bytes } = await hashFile(abs, arg, undefined);
283
+ files.set(arg, { sha256: digest, bytes });
284
+ }
285
+ mcps.push({
286
+ extensionId: surfaceIds.mcp(entry.name),
287
+ name: entry.name,
288
+ transport: entry.transport,
289
+ configHash: mcpConfigHash(entry),
290
+ config: entry,
291
+ });
292
+ }
293
+
294
+ const systemAppendices: NormalizedAppendixSurface[] = [];
295
+ for (const entry of manifest.systemAppendix) {
296
+ const compiled = await compileAppendix(pluginRoot, entry, `system_appendix "${entry.name}"`, files);
297
+ systemAppendices.push({
298
+ extensionId: surfaceIds.systemAppendix(manifest.name, entry.name),
299
+ name: entry.name,
300
+ relativePath: compiled.relativePath,
301
+ content: compiled.content,
302
+ contentHash: compiled.contentHash,
303
+ bytes: compiled.bytes,
304
+ });
305
+ }
306
+
307
+ const agentAppendices: NormalizedAgentAppendixSurface[] = [];
308
+ for (const entry of manifest.agentAppendix) {
309
+ const compiled = await compileAppendix(pluginRoot, entry, `agent-appendix "${entry.agent}/${entry.name}"`, files);
310
+ agentAppendices.push({
311
+ extensionId: surfaceIds.agentAppendix(entry.agent, manifest.name, entry.name),
312
+ agent: entry.agent,
313
+ name: entry.name,
314
+ relativePath: compiled.relativePath,
315
+ content: compiled.content,
316
+ contentHash: compiled.contentHash,
317
+ bytes: compiled.bytes,
318
+ });
319
+ }
320
+
321
+ const surfaces: NormalizedGjcPluginSurfaces = {
322
+ subskills,
323
+ tools,
324
+ hooks,
325
+ mcps,
326
+ systemAppendices,
327
+ agentAppendices,
328
+ };
329
+
330
+ const manifestBytes = await fs.readFile(manifestPath);
331
+ const manifestHash = sha256(manifestBytes);
332
+
333
+ const copiedFiles = [...files.entries()]
334
+ .map(([relativePath, info]) => ({ relativePath, sha256: info.sha256, bytes: info.bytes }))
335
+ .sort((a, b) => a.relativePath.localeCompare(b.relativePath));
336
+ copiedFiles.unshift({
337
+ relativePath: GJC_PLUGIN_MANIFEST_FILENAME,
338
+ sha256: manifestHash,
339
+ bytes: manifestBytes.byteLength,
340
+ });
341
+
342
+ return {
343
+ name: manifest.name,
344
+ version: manifest.version,
345
+ root: pluginRoot,
346
+ manifestPath,
347
+ manifestHash,
348
+ surfaces,
349
+ files: copiedFiles,
350
+ };
351
+ }
@@ -0,0 +1,170 @@
1
+ import { logger } from "@gajae-code/utils";
2
+
3
+ import { loadEffectiveGjcPluginRegistry } from "./registry";
4
+ import { type SessionQuarantine, validateSessionBundles, verifyEntryHashes } from "./session-validation";
5
+ import { GjcPluginLoadError, type GjcPluginRegistryEntry } from "./types";
6
+
7
+ /**
8
+ * Constrained plugin-hook loader.
9
+ *
10
+ * Third-party plugin hooks are NOT given the broad first-party HookAPI. They
11
+ * receive a restricted API that can only register a handler for their declared
12
+ * event; every session-mutation / command / shell capability throws
13
+ * security_policy. After the factory runs we verify it registered exactly the
14
+ * declared event (and nothing else), or the hook is quarantined.
15
+ */
16
+
17
+ export interface ConstrainedPluginHook {
18
+ plugin: string;
19
+ event: string;
20
+ target?: string;
21
+ phase?: "before" | "after";
22
+ handler: (...args: any[]) => unknown;
23
+ }
24
+
25
+ export interface ConstrainedHookLoadResult {
26
+ hooks: ConstrainedPluginHook[];
27
+ quarantine: SessionQuarantine[];
28
+ }
29
+
30
+ const DENIED_API_METHODS = [
31
+ "sendMessage",
32
+ "appendEntry",
33
+ "registerMessageRenderer",
34
+ "registerCommand",
35
+ "exec",
36
+ ] as const;
37
+
38
+ interface DeclaredHook {
39
+ plugin: string;
40
+ event: string;
41
+ target?: string;
42
+ phase?: "before" | "after";
43
+ relativePath: string;
44
+ }
45
+
46
+ function collectDeclaredHooks(entries: readonly GjcPluginRegistryEntry[]): DeclaredHook[] {
47
+ const out: DeclaredHook[] = [];
48
+ for (const entry of entries) {
49
+ if (!entry.enabled) continue;
50
+ const disabled = new Set(entry.disabledSurfaceIds);
51
+ for (const h of entry.surfaces.hooks) {
52
+ if (disabled.has(h.extensionId)) continue;
53
+ out.push({
54
+ plugin: entry.name,
55
+ event: h.event,
56
+ target: h.target,
57
+ phase: h.phase,
58
+ relativePath: `${entry.pluginRoot}/${h.relativePath}`,
59
+ });
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ async function loadOneHook(
66
+ declared: DeclaredHook,
67
+ ): Promise<{ hook: ConstrainedPluginHook | null; quarantine: SessionQuarantine | null }> {
68
+ const registered: { event: string; handler: (...a: any[]) => unknown }[] = [];
69
+ const deny = (method: string) => () => {
70
+ throw new GjcPluginLoadError(
71
+ "security_policy",
72
+ `Plugin hook "${declared.plugin}" attempted denied API: ${method}`,
73
+ );
74
+ };
75
+ const constrainedApi: Record<string, unknown> = {
76
+ on(event: string, handler: (...a: any[]) => unknown): void {
77
+ registered.push({ event, handler });
78
+ },
79
+ logger,
80
+ };
81
+ for (const method of DENIED_API_METHODS) constrainedApi[method] = deny(method);
82
+
83
+ let factory: unknown;
84
+ try {
85
+ const mod = await import(declared.relativePath);
86
+ factory = mod.default ?? mod;
87
+ } catch (error) {
88
+ return {
89
+ hook: null,
90
+ quarantine: {
91
+ plugin: declared.plugin,
92
+ surfaceId: `hook:${declared.event}:${declared.target ?? ""}`,
93
+ code: "invalid_hook",
94
+ message: `Failed to import plugin hook: ${error instanceof Error ? error.message : String(error)}`,
95
+ },
96
+ };
97
+ }
98
+ if (typeof factory !== "function") {
99
+ return {
100
+ hook: null,
101
+ quarantine: {
102
+ plugin: declared.plugin,
103
+ surfaceId: `hook:${declared.event}`,
104
+ code: "invalid_hook",
105
+ message: "Plugin hook must export a default function",
106
+ },
107
+ };
108
+ }
109
+ try {
110
+ await (factory as (api: unknown) => unknown)(constrainedApi);
111
+ } catch (error) {
112
+ const code = error instanceof GjcPluginLoadError ? error.code : "security_policy";
113
+ return {
114
+ hook: null,
115
+ quarantine: {
116
+ plugin: declared.plugin,
117
+ surfaceId: `hook:${declared.event}`,
118
+ code,
119
+ message: error instanceof Error ? error.message : String(error),
120
+ },
121
+ };
122
+ }
123
+ // Exactly one handler, for the declared event only.
124
+ if (registered.length !== 1 || registered[0]?.event !== declared.event) {
125
+ return {
126
+ hook: null,
127
+ quarantine: {
128
+ plugin: declared.plugin,
129
+ surfaceId: `hook:${declared.event}`,
130
+ code: "runtime_mismatch",
131
+ message: `Plugin hook registered ${JSON.stringify(registered.map(r => r.event))}, expected exactly ["${declared.event}"]`,
132
+ },
133
+ };
134
+ }
135
+ return {
136
+ hook: {
137
+ plugin: declared.plugin,
138
+ event: declared.event,
139
+ target: declared.target,
140
+ phase: declared.phase,
141
+ handler: registered[0].handler,
142
+ },
143
+ quarantine: null,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Load all always-on constrained plugin hooks for the effective registry at
149
+ * `cwd`, applying hash-drift + collision quarantine first. Returns empty when
150
+ * no plugins are installed.
151
+ */
152
+ export async function loadConstrainedPluginHooks(input: { cwd: string }): Promise<ConstrainedHookLoadResult> {
153
+ const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
154
+ if (effective.length === 0) return { hooks: [], quarantine: [] };
155
+ const preQuarantine: SessionQuarantine[] = [];
156
+ for (const entry of effective) {
157
+ if (!entry.enabled) continue;
158
+ const drift = await verifyEntryHashes(entry);
159
+ if (drift) preQuarantine.push(drift);
160
+ }
161
+ const { active, quarantine } = validateSessionBundles(effective, {}, preQuarantine);
162
+ const declared = collectDeclaredHooks(active);
163
+ const hooks: ConstrainedPluginHook[] = [];
164
+ for (const d of declared) {
165
+ const { hook, quarantine: q } = await loadOneHook(d);
166
+ if (hook) hooks.push(hook);
167
+ if (q) quarantine.push(q);
168
+ }
169
+ return { hooks, quarantine };
170
+ }
@@ -1,8 +1,17 @@
1
1
  export * from "./activation";
2
+ export * from "./compiler";
3
+ export * from "./constrained-hooks";
2
4
  export * from "./injection";
5
+ export * from "./installer";
3
6
  export * from "./loader";
7
+ export * from "./mcp-policy";
8
+ export * from "./observability";
4
9
  export * from "./paths";
10
+ export * from "./prompt-appendix";
11
+ export * from "./registry";
12
+ export * from "./runtime-adapters";
5
13
  export * from "./schema";
14
+ export * from "./session-validation";
6
15
  export * from "./state";
7
16
  export * from "./tools";
8
17
  export * from "./types";
@@ -131,3 +131,112 @@ export async function buildAgentSubskillInjection(input: {
131
131
  );
132
132
  return blocks.join("");
133
133
  }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Tier-1 sub-skill advertisement (metadata-only, bounded, target-parent scoped)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ import type { GjcPluginRegistryEntry } from "./types";
140
+
141
+ const ADVERT_MAX_ITEMS = 12;
142
+ const ADVERT_MAX_DESC = 200;
143
+ const ADVERT_MAX_FIELD = 80;
144
+ const ADVERT_MAX_BYTES = 4 * 1024;
145
+
146
+ function escapeAdvertAttr(value: string): string {
147
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
148
+ }
149
+
150
+ interface AdvertItem {
151
+ plugin: string;
152
+ name: string;
153
+ description: string;
154
+ activationArg: string;
155
+ phase: string;
156
+ }
157
+
158
+ function clampField(value: string): string {
159
+ const v = value.length > ADVERT_MAX_FIELD ? `${value.slice(0, ADVERT_MAX_FIELD - 1)}\u2026` : value;
160
+ return escapeAdvertAttr(v);
161
+ }
162
+
163
+ function renderAdvertItem(it: AdvertItem): string {
164
+ const desc =
165
+ it.description.length > ADVERT_MAX_DESC
166
+ ? `${it.description.slice(0, ADVERT_MAX_DESC - 1)}\u2026`
167
+ : it.description;
168
+ return ` - plugin="${clampField(it.plugin)}" name="${clampField(it.name)}" activation_arg="${clampField(it.activationArg)}" phase="${clampField(it.phase)}": ${escapeAdvertAttr(desc)}`;
169
+ }
170
+
171
+ function wrapAdvert(kind: "skill" | "agent", parent: string, lines: string[]): string {
172
+ return `<gjc-plugin-subskill-advertisement parent="${clampField(parent)}" kind="${kind}">\n${lines.join("\n")}\n</gjc-plugin-subskill-advertisement>`;
173
+ }
174
+
175
+ function renderAdvertisement(items: AdvertItem[], kind: "skill" | "agent", parent: string): string {
176
+ if (items.length === 0) return "";
177
+ // Build iteratively against the byte budget so the block is HARD-capped even
178
+ // if individual items are large; every metadata field is also length-clamped.
179
+ const candidates = items.slice(0, ADVERT_MAX_ITEMS);
180
+ const lines: string[] = [];
181
+ let shownCount = 0;
182
+ for (const it of candidates) {
183
+ const next = [...lines, renderAdvertItem(it)];
184
+ const omittedNote = ` - ${items.length - (shownCount + 1)} additional plugin sub-skill(s) omitted; invoke explicitly with a known activation arg.`;
185
+ const probe = wrapAdvert(kind, parent, items.length - (shownCount + 1) > 0 ? [...next, omittedNote] : next);
186
+ if (Buffer.byteLength(probe) > ADVERT_MAX_BYTES) break;
187
+ lines.push(renderAdvertItem(it));
188
+ shownCount += 1;
189
+ }
190
+ const omitted = items.length - shownCount;
191
+ if (shownCount === 0) {
192
+ // Nothing fit: guaranteed-small overflow-only block.
193
+ return wrapAdvert(kind, parent, [
194
+ ` - ${items.length} plugin sub-skill(s) available; invoke explicitly with a known activation arg.`,
195
+ ]);
196
+ }
197
+ if (omitted > 0) {
198
+ lines.push(
199
+ ` - ${omitted} additional plugin sub-skill(s) omitted; invoke explicitly with a known activation arg.`,
200
+ );
201
+ }
202
+ return wrapAdvert(kind, parent, lines);
203
+ }
204
+
205
+ function collectAdverts(entries: readonly GjcPluginRegistryEntry[], parent: string, phase?: string): AdvertItem[] {
206
+ const items: AdvertItem[] = [];
207
+ for (const entry of entries) {
208
+ if (!entry.enabled) continue;
209
+ const disabled = new Set(entry.disabledSurfaceIds);
210
+ for (const s of entry.surfaces.subskills) {
211
+ if (disabled.has(s.extensionId)) continue;
212
+ if (s.parent !== parent) continue;
213
+ if (phase && s.phase !== phase) continue;
214
+ items.push({
215
+ plugin: entry.name,
216
+ name: s.name,
217
+ description: s.description,
218
+ activationArg: s.activationArg,
219
+ phase: s.phase,
220
+ });
221
+ }
222
+ }
223
+ return items;
224
+ }
225
+
226
+ /**
227
+ * Tier-1 advertisement for a workflow parent skill: bounded, metadata-only list
228
+ * of installed sub-skills bound to `parent`, rendered ONLY in that parent's
229
+ * prompt (never the global public-workflow-surface). No body content.
230
+ */
231
+ export function buildSubskillAdvertisement(
232
+ entries: readonly GjcPluginRegistryEntry[],
233
+ parent: string,
234
+ phase?: string,
235
+ ): string {
236
+ return renderAdvertisement(collectAdverts(entries, parent, phase), "skill", parent);
237
+ }
238
+
239
+ /** Tier-1 advertisement for a role-agent parent. */
240
+ export function buildAgentSubskillAdvertisement(entries: readonly GjcPluginRegistryEntry[], agentName: string): string {
241
+ return renderAdvertisement(collectAdverts(entries, agentName), "agent", agentName);
242
+ }