@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,84 @@
1
+ import { loadEffectiveGjcPluginRegistry } from "./registry";
2
+ import { type SessionQuarantine, validateSessionBundles, verifyEntryHashes } from "./session-validation";
3
+ import type { GjcPluginRegistryEntry, GjcPluginScope } from "./types";
4
+
5
+ /**
6
+ * Observability for GJC plugin bundle surfaces, consumable by the extension
7
+ * dashboard / state manager. Each surface row carries its stable extension id,
8
+ * owning plugin, scope, source kind, enabled/disabled/quarantined status, and a
9
+ * content hash. MCP auth/header values are NEVER included.
10
+ */
11
+
12
+ export type PluginSurfaceStatus = "enabled" | "disabled" | "quarantined";
13
+
14
+ export interface PluginSurfaceRow {
15
+ extensionId: string;
16
+ kind: "tool" | "hook" | "mcp" | "system-appendix" | "agent-appendix" | "subskill";
17
+ plugin: string;
18
+ scope: GjcPluginScope;
19
+ sourceKind: GjcPluginRegistryEntry["source"]["kind"];
20
+ status: PluginSurfaceStatus;
21
+ hash: string;
22
+ quarantineCode?: string;
23
+ }
24
+
25
+ export interface PluginObservabilitySummary {
26
+ plugins: number;
27
+ surfaces: PluginSurfaceRow[];
28
+ }
29
+
30
+ function statusFor(
31
+ entry: GjcPluginRegistryEntry,
32
+ extensionId: string,
33
+ quarantinedIds: Map<string, string>,
34
+ ): { status: PluginSurfaceStatus; quarantineCode?: string } {
35
+ const q =
36
+ quarantinedIds.get(`${entry.name}\u0000${extensionId}`) ??
37
+ quarantinedIds.get(`${entry.name}\u0000plugin:${entry.name}`);
38
+ if (q) return { status: "quarantined", quarantineCode: q };
39
+ if (!entry.enabled || entry.disabledSurfaceIds.includes(extensionId)) return { status: "disabled" };
40
+ return { status: "enabled" };
41
+ }
42
+
43
+ function rowsForEntry(entry: GjcPluginRegistryEntry, quarantinedIds: Map<string, string>): PluginSurfaceRow[] {
44
+ const base = (extensionId: string, hash: string): Omit<PluginSurfaceRow, "kind"> => ({
45
+ extensionId,
46
+ plugin: entry.name,
47
+ scope: entry.scope,
48
+ sourceKind: entry.source.kind,
49
+ hash,
50
+ ...statusFor(entry, extensionId, quarantinedIds),
51
+ });
52
+ const rows: PluginSurfaceRow[] = [];
53
+ for (const t of entry.surfaces.tools) rows.push({ kind: "tool", ...base(t.extensionId, t.sha256) });
54
+ for (const h of entry.surfaces.hooks) rows.push({ kind: "hook", ...base(h.extensionId, h.sha256) });
55
+ // MCP: only name/config hash, never command/url/headers/auth.
56
+ for (const m of entry.surfaces.mcps) rows.push({ kind: "mcp", ...base(m.extensionId, m.configHash) });
57
+ for (const a of entry.surfaces.systemAppendices)
58
+ rows.push({ kind: "system-appendix", ...base(a.extensionId, a.contentHash) });
59
+ for (const a of entry.surfaces.agentAppendices)
60
+ rows.push({ kind: "agent-appendix", ...base(a.extensionId, a.contentHash) });
61
+ for (const s of entry.surfaces.subskills) rows.push({ kind: "subskill", ...base(s.extensionId, s.sha256) });
62
+ return rows;
63
+ }
64
+
65
+ /**
66
+ * Build the observability summary for the effective registry at `cwd`, including
67
+ * hash-drift and session-collision quarantine status.
68
+ */
69
+ export async function summarizeGjcPluginObservability(cwd: string): Promise<PluginObservabilitySummary> {
70
+ const effective = await loadEffectiveGjcPluginRegistry(cwd);
71
+ const preQuarantine: SessionQuarantine[] = [];
72
+ for (const entry of effective) {
73
+ if (!entry.enabled) continue;
74
+ const drift = await verifyEntryHashes(entry);
75
+ if (drift) preQuarantine.push(drift);
76
+ }
77
+ const { quarantine } = validateSessionBundles(effective, {}, preQuarantine);
78
+ const quarantinedIds = new Map<string, string>();
79
+ for (const q of quarantine) quarantinedIds.set(`${q.plugin}\u0000${q.surfaceId}`, q.code);
80
+
81
+ const surfaces: PluginSurfaceRow[] = [];
82
+ for (const entry of effective) surfaces.push(...rowsForEntry(entry, quarantinedIds));
83
+ return { plugins: effective.length, surfaces };
84
+ }
@@ -45,7 +45,7 @@ async function discoverGjcPluginRootsIn(baseDir: string): Promise<string[]> {
45
45
  }),
46
46
  );
47
47
 
48
- return roots.filter((root): root is string => root !== null);
48
+ return roots.filter((root): root is string => root !== null).sort((a, b) => a.localeCompare(b));
49
49
  }
50
50
 
51
51
  export async function discoverGjcPluginRoots({ cwd }: { cwd: string; home?: string }): Promise<string[]> {
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import type { GjcPluginRegistryEntry, GjcSubskillParentAgent, NormalizedAppendixSurface } from "./types";
5
+
6
+ /**
7
+ * Renders plugin system/agent appendices as lower-authority, delimited blocks
8
+ * appended AFTER the base prompt. The base/developer instructions always retain
9
+ * higher authority; plugin appendices can never override them.
10
+ */
11
+
12
+ const MAX_APPENDIX_BYTES = 8 * 1024;
13
+ const MAX_TOTAL_APPENDIX_BYTES = 32 * 1024;
14
+ const MAX_APPENDIX_COUNT = 32;
15
+ const MAX_NAME_LEN = 128;
16
+
17
+ function escapeAttr(value: string): string {
18
+ const clamped = value.length > MAX_NAME_LEN ? `${value.slice(0, MAX_NAME_LEN - 1)}\u2026` : value;
19
+ return clamped.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
+ }
21
+
22
+ function sanitizeBody(text: string): string {
23
+ // Strip control chars (except tab/newline), then XML-escape &, <, > so a
24
+ // malicious body can NEVER emit a closing delimiter or fake <system>/
25
+ // <developer>/<gjc-subskill> tag that escapes the lower-authority block.
26
+ const stripped = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "");
27
+ return stripped.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28
+ }
29
+
30
+ async function readAppendixBody(entry: GjcPluginRegistryEntry, surface: NormalizedAppendixSurface): Promise<string> {
31
+ if (surface.content !== undefined) return surface.content; // inline-content appendix
32
+ if (!surface.relativePath) return "";
33
+ const abs = path.join(entry.pluginRoot, surface.relativePath);
34
+ try {
35
+ return await fs.readFile(abs, "utf8");
36
+ } catch {
37
+ return "";
38
+ }
39
+ }
40
+
41
+ export interface RenderedPluginAppendices {
42
+ /** Combined system-appendix block text (empty if none). */
43
+ system: string;
44
+ /** Per-agent appendix block text. */
45
+ byAgent: Map<GjcSubskillParentAgent, string>;
46
+ /** Stable digest of all rendered appendix content + identities (for cache/refresh). */
47
+ digest: string;
48
+ }
49
+
50
+ /**
51
+ * Build appendix blocks from the active, enabled registry entries in their
52
+ * deterministic order. Per-appendix and total size caps are enforced
53
+ * fail-closed (oversize content is dropped with a marker, never silently
54
+ * truncated into the prompt as authoritative text).
55
+ */
56
+ export async function renderPluginAppendices(
57
+ entries: readonly GjcPluginRegistryEntry[],
58
+ ): Promise<RenderedPluginAppendices> {
59
+ const systemBlocks: string[] = [];
60
+ const byAgent = new Map<GjcSubskillParentAgent, string[]>();
61
+ const digestParts: string[] = [];
62
+ let totalBytes = 0;
63
+ let count = 0;
64
+
65
+ // Admission is measured on the FULL rendered block (wrapper + escaped
66
+ // metadata + body), not just the body, and capped by per-block size, total
67
+ // size, and appendix count.
68
+ const admit = (block: string): boolean => {
69
+ const bytes = Buffer.byteLength(block);
70
+ if (bytes > MAX_APPENDIX_BYTES) return false;
71
+ if (count >= MAX_APPENDIX_COUNT) return false;
72
+ if (totalBytes + bytes > MAX_TOTAL_APPENDIX_BYTES) return false;
73
+ totalBytes += bytes;
74
+ count += 1;
75
+ return true;
76
+ };
77
+
78
+ for (const entry of entries) {
79
+ if (!entry.enabled) continue;
80
+ const disabled = new Set(entry.disabledSurfaceIds);
81
+ for (const sa of entry.surfaces.systemAppendices) {
82
+ if (disabled.has(sa.extensionId)) continue;
83
+ const body = sanitizeBody(await readAppendixBody(entry, sa));
84
+ digestParts.push(`${sa.extensionId}:${sa.contentHash}`);
85
+ if (!body) continue;
86
+ const block = `<gjc-plugin-system-appendix plugin="${escapeAttr(entry.name)}" name="${escapeAttr(sa.name)}" sha256="${sa.contentHash}" authority="appendix-lower-than-system">\n${body}\n</gjc-plugin-system-appendix>`;
87
+ if (!admit(block)) continue;
88
+ systemBlocks.push(block);
89
+ }
90
+ for (const aa of entry.surfaces.agentAppendices) {
91
+ if (disabled.has(aa.extensionId)) continue;
92
+ const body = sanitizeBody(await readAppendixBody(entry, aa));
93
+ digestParts.push(`${aa.extensionId}:${aa.contentHash}`);
94
+ if (!body) continue;
95
+ const block = `<gjc-plugin-agent-appendix plugin="${escapeAttr(entry.name)}" agent="${escapeAttr(aa.agent)}" name="${escapeAttr(aa.name)}" sha256="${aa.contentHash}" authority="appendix-lower-than-agent">\n${body}\n</gjc-plugin-agent-appendix>`;
96
+ if (!admit(block)) continue;
97
+ const list = byAgent.get(aa.agent) ?? [];
98
+ list.push(block);
99
+ byAgent.set(aa.agent, list);
100
+ }
101
+ }
102
+
103
+ const digest = createHash("sha256").update(digestParts.join("\u0000")).digest("hex");
104
+ return {
105
+ system: systemBlocks.join("\n\n"),
106
+ byAgent: new Map([...byAgent].map(([agent, blocks]) => [agent, blocks.join("\n\n")])),
107
+ digest,
108
+ };
109
+ }
@@ -0,0 +1,180 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { gjcPluginProjectRoot, gjcPluginUserRoot } from "./paths";
5
+ import { GjcPluginLoadError, type GjcPluginRegistry, type GjcPluginRegistryEntry, type GjcPluginScope } from "./types";
6
+
7
+ const REGISTRY_FILENAME = "registry.json";
8
+ const LOCK_FILENAME = "registry.lock";
9
+ const LOCK_TIMEOUT_MS = 5000;
10
+ const LOCK_RETRY_MS = 50;
11
+
12
+ export function registryRootForScope(scope: GjcPluginScope, cwd: string): string {
13
+ return scope === "user" ? gjcPluginUserRoot() : gjcPluginProjectRoot(cwd);
14
+ }
15
+
16
+ export function registryPathForScope(scope: GjcPluginScope, cwd: string): string {
17
+ return path.join(registryRootForScope(scope, cwd), REGISTRY_FILENAME);
18
+ }
19
+
20
+ function emptyRegistry(scope: GjcPluginScope): GjcPluginRegistry {
21
+ return { version: 1, scope, plugins: [] };
22
+ }
23
+
24
+ function isEnoent(error: unknown): boolean {
25
+ return (error as NodeJS.ErrnoException)?.code === "ENOENT";
26
+ }
27
+
28
+ /**
29
+ * Deterministic ordering: scope (user before project) -> normalized name ->
30
+ * resolved plugin root. Collisions are errors elsewhere; order only controls
31
+ * stable hook/appendix sequencing.
32
+ */
33
+ export function sortRegistryEntries(entries: GjcPluginRegistryEntry[]): GjcPluginRegistryEntry[] {
34
+ const scopeRank = (scope: GjcPluginScope): number => (scope === "user" ? 0 : 1);
35
+ return [...entries].sort((a, b) => {
36
+ if (a.scope !== b.scope) return scopeRank(a.scope) - scopeRank(b.scope);
37
+ if (a.name !== b.name) return a.name.localeCompare(b.name);
38
+ return a.pluginRoot.localeCompare(b.pluginRoot);
39
+ });
40
+ }
41
+
42
+ export async function readRegistry(scope: GjcPluginScope, cwd: string): Promise<GjcPluginRegistry> {
43
+ const registryPath = registryPathForScope(scope, cwd);
44
+ let text: string;
45
+ try {
46
+ text = await fs.readFile(registryPath, "utf8");
47
+ } catch (error) {
48
+ if (isEnoent(error)) return emptyRegistry(scope);
49
+ throw error;
50
+ }
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(text);
54
+ } catch (error) {
55
+ throw new GjcPluginLoadError("invalid_manifest", `Corrupt GJC plugin registry at ${registryPath}`, {
56
+ cause: error instanceof Error ? error : undefined,
57
+ });
58
+ }
59
+ if (typeof parsed !== "object" || parsed === null || (parsed as GjcPluginRegistry).version !== 1) {
60
+ throw new GjcPluginLoadError("invalid_manifest", `Unsupported GJC plugin registry shape at ${registryPath}`);
61
+ }
62
+ const registry = parsed as GjcPluginRegistry;
63
+ registry.plugins = sortRegistryEntries(registry.plugins ?? []);
64
+ return registry;
65
+ }
66
+
67
+ async function acquireLock(lockPath: string): Promise<() => Promise<void>> {
68
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
69
+ const token = `${process.pid}-${randomBytes(8).toString("hex")}`;
70
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
71
+ for (;;) {
72
+ try {
73
+ const handle = await fs.open(lockPath, "wx");
74
+ try {
75
+ await handle.writeFile(token);
76
+ } finally {
77
+ await handle.close();
78
+ }
79
+ let released = false;
80
+ return async () => {
81
+ if (released) return;
82
+ released = true;
83
+ // Owner-safe release: only remove the lock if it is still ours.
84
+ try {
85
+ const current = await fs.readFile(lockPath, "utf8");
86
+ if (current === token) await fs.rm(lockPath, { force: true });
87
+ } catch {
88
+ // Lock already gone; nothing to release.
89
+ }
90
+ };
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") throw error;
93
+ // Fail-closed: never auto-evict an existing lock (a live holder may run
94
+ // longer than the timeout). Time out instead and leave the lock for
95
+ // diagnostics/manual cleanup. A lease/heartbeat protocol can be added
96
+ // later if automatic stale recovery becomes necessary.
97
+ if (Date.now() > deadline) {
98
+ throw new GjcPluginLoadError(
99
+ "install_conflict",
100
+ `Timed out acquiring GJC plugin registry lock at ${lockPath}; remove it manually if no install is running`,
101
+ );
102
+ }
103
+ await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_MS));
104
+ }
105
+ }
106
+ }
107
+
108
+ export async function withRegistryLock<T>(scope: GjcPluginScope, cwd: string, fn: () => Promise<T>): Promise<T> {
109
+ const lockPath = path.join(registryRootForScope(scope, cwd), LOCK_FILENAME);
110
+ const release = await acquireLock(lockPath);
111
+ try {
112
+ return await fn();
113
+ } finally {
114
+ await release();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Lock-free atomic write (temp+fsync+rename). Only call while already holding
120
+ * the per-scope registry lock via withRegistryLock.
121
+ */
122
+ export async function writeRegistryUnlocked(registry: GjcPluginRegistry, cwd: string): Promise<void> {
123
+ const registryPath = registryPathForScope(registry.scope, cwd);
124
+ await fs.mkdir(path.dirname(registryPath), { recursive: true });
125
+ const sorted: GjcPluginRegistry = { ...registry, plugins: sortRegistryEntries(registry.plugins) };
126
+ const text = `${JSON.stringify(sorted, null, 2)}\n`;
127
+ const tmpPath = `${registryPath}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
128
+ const handle = await fs.open(tmpPath, "w");
129
+ try {
130
+ await handle.writeFile(text);
131
+ await handle.sync();
132
+ } finally {
133
+ await handle.close();
134
+ }
135
+ await fs.rename(tmpPath, registryPath);
136
+ }
137
+
138
+ /**
139
+ * Atomic registry write: write to a temp sibling, fsync, then rename. Guarded
140
+ * by an interprocess lockfile so concurrent installs cannot clobber each other.
141
+ */
142
+ export async function writeRegistry(registry: GjcPluginRegistry, cwd: string): Promise<void> {
143
+ await withRegistryLock(registry.scope, cwd, () => writeRegistryUnlocked(registry, cwd));
144
+ }
145
+
146
+ /**
147
+ * Mutate a scope's registry as a single locked read-modify-write transaction so
148
+ * concurrent installs cannot lose each other's updates. The mutator receives a
149
+ * sorted copy and returns the next entry list.
150
+ */
151
+ export async function updateRegistry(
152
+ scope: GjcPluginScope,
153
+ cwd: string,
154
+ mutator: (entries: GjcPluginRegistryEntry[]) => GjcPluginRegistryEntry[],
155
+ ): Promise<GjcPluginRegistry> {
156
+ return await withRegistryLock(scope, cwd, async () => {
157
+ const current = await readRegistry(scope, cwd);
158
+ const nextEntries = mutator([...current.plugins]);
159
+ const next: GjcPluginRegistry = { version: 1, scope, plugins: sortRegistryEntries(nextEntries) };
160
+ await writeRegistryUnlocked(next, cwd);
161
+ return next;
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Effective registry for a cwd: user + project entries in deterministic order.
167
+ */
168
+ export async function loadEffectiveGjcPluginRegistry(cwd: string): Promise<GjcPluginRegistryEntry[]> {
169
+ const [user, project] = await Promise.all([readRegistry("user", cwd), readRegistry("project", cwd)]);
170
+ return sortRegistryEntries([...user.plugins, ...project.plugins]);
171
+ }
172
+
173
+ export function registryEntryFingerprint(entry: GjcPluginRegistryEntry): string {
174
+ const canonical = JSON.stringify({
175
+ name: entry.name,
176
+ manifestHash: entry.manifestHash,
177
+ files: entry.copiedFiles.map(f => [f.relativePath, f.sha256]).sort(),
178
+ });
179
+ return createHash("sha256").update(canonical).digest("hex");
180
+ }
@@ -0,0 +1,234 @@
1
+ import * as path from "node:path";
2
+ import { loadCustomTools } from "../custom-tools/loader";
3
+ import type { CustomTool } from "../custom-tools/types";
4
+ import { loadEffectiveGjcPluginRegistry } from "./registry";
5
+ import { type SessionQuarantine, validateSessionBundles, verifyEntryHashes } from "./session-validation";
6
+
7
+ export interface AlwaysOnPluginTools {
8
+ tools: CustomTool[];
9
+ quarantine: SessionQuarantine[];
10
+ }
11
+
12
+ /**
13
+ * Load the always-on plugin tool surfaces for the effective registry at `cwd`.
14
+ *
15
+ * Safety properties:
16
+ * - Hash drift quarantines the plugin (runtime_mismatch) before any import.
17
+ * - Session-start collisions vs reserved/built-in names quarantine fail-closed.
18
+ * - Manifest-declared tool names are authoritative: a factory that returns a
19
+ * different/extra/missing name is rejected with runtime_mismatch and skipped.
20
+ * - Reserved tool names are never overwritten.
21
+ *
22
+ * Returns an empty result when no plugins are installed, so callers that always
23
+ * call this in `createAgentSession` incur no behavior change without plugins.
24
+ */
25
+ export async function loadAlwaysOnPluginTools(input: {
26
+ cwd: string;
27
+ reservedToolNames: string[];
28
+ }): Promise<AlwaysOnPluginTools> {
29
+ const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
30
+ if (effective.length === 0) return { tools: [], quarantine: [] };
31
+
32
+ // Hash-drift quarantine before importing any plugin code.
33
+ const preQuarantine: SessionQuarantine[] = [];
34
+ for (const entry of effective) {
35
+ if (!entry.enabled) continue;
36
+ const drift = await verifyEntryHashes(entry);
37
+ if (drift) preQuarantine.push(drift);
38
+ }
39
+
40
+ const reserved = new Set(input.reservedToolNames);
41
+ const { active, quarantine } = validateSessionBundles(
42
+ effective,
43
+ { toolNames: input.reservedToolNames },
44
+ preQuarantine,
45
+ );
46
+
47
+ // Map declared (path -> name) for every active always-on tool surface.
48
+ const declared = new Map<string, { name: string; plugin: string }>();
49
+ for (const entry of active) {
50
+ const disabled = new Set(entry.disabledSurfaceIds);
51
+ for (const t of entry.surfaces.tools) {
52
+ if (disabled.has(t.extensionId)) continue;
53
+ declared.set(path.join(entry.pluginRoot, t.relativePath), { name: t.name, plugin: entry.name });
54
+ }
55
+ }
56
+ if (declared.size === 0) return { tools: [], quarantine };
57
+
58
+ const loaded = await loadCustomTools(
59
+ [...declared.keys()].map(p => ({ path: p })),
60
+ input.cwd,
61
+ input.reservedToolNames,
62
+ );
63
+
64
+ // Group loaded tools by their source path for exact-name verification.
65
+ const byPath = new Map<string, string[]>();
66
+ for (const lt of loaded.tools) {
67
+ const key = path.resolve(lt.path);
68
+ const list = byPath.get(key) ?? [];
69
+ list.push(lt.tool.name);
70
+ byPath.set(key, list);
71
+ }
72
+
73
+ const tools: CustomTool[] = [];
74
+ const seenNames = new Set<string>(reserved);
75
+ for (const [declaredPath, info] of declared) {
76
+ const returned = byPath.get(path.resolve(declaredPath)) ?? [];
77
+ // Manifest is authoritative: exactly the one declared name must come back.
78
+ if (returned.length !== 1 || returned[0] !== info.name) {
79
+ quarantine.push({
80
+ plugin: info.plugin,
81
+ surfaceId: `tool:${info.name}`,
82
+ code: "runtime_mismatch",
83
+ message: `Tool factory returned ${JSON.stringify(returned)}, expected exactly ["${info.name}"]`,
84
+ });
85
+ continue;
86
+ }
87
+ if (seenNames.has(info.name)) {
88
+ // Defense in depth: never overwrite a reserved/earlier name.
89
+ quarantine.push({
90
+ plugin: info.plugin,
91
+ surfaceId: `tool:${info.name}`,
92
+ code: "session_collision",
93
+ message: `Tool name "${info.name}" already present; refusing to overwrite`,
94
+ });
95
+ continue;
96
+ }
97
+ const match = loaded.tools.find(lt => path.resolve(lt.path) === path.resolve(declaredPath));
98
+ if (match) {
99
+ tools.push(match.tool);
100
+ seenNames.add(info.name);
101
+ }
102
+ }
103
+ return { tools, quarantine };
104
+ }
105
+
106
+ /**
107
+ * Render the always-on system-appendix blocks for the effective registry at
108
+ * `cwd`, applying hash-drift + collision quarantine first. Returns "" when no
109
+ * plugins are installed/enabled. Safe to call unconditionally at session start.
110
+ */
111
+ export async function renderAlwaysOnSystemAppendices(input: { cwd: string }): Promise<string> {
112
+ const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
113
+ if (effective.length === 0) return "";
114
+ const preQuarantine: SessionQuarantine[] = [];
115
+ for (const entry of effective) {
116
+ if (!entry.enabled) continue;
117
+ const drift = await verifyEntryHashes(entry);
118
+ if (drift) preQuarantine.push(drift);
119
+ }
120
+ const { active } = validateSessionBundles(effective, {}, preQuarantine);
121
+ const { renderPluginAppendices } = await import("./prompt-appendix");
122
+ return (await renderPluginAppendices(active)).system;
123
+ }
124
+
125
+ /**
126
+ * Render the agent-appendix block and Tier-1 sub-skill advertisement for a role
127
+ * agent at session/spawn time. Hash-drift + collision quarantine applied first.
128
+ * Returns empty strings when nothing applies.
129
+ */
130
+ export async function renderAgentPromptAdditions(input: {
131
+ cwd: string;
132
+ agentName: string;
133
+ }): Promise<{ appendix: string; advertisement: string }> {
134
+ const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
135
+ if (effective.length === 0) return { appendix: "", advertisement: "" };
136
+ const preQuarantine: SessionQuarantine[] = [];
137
+ for (const entry of effective) {
138
+ if (!entry.enabled) continue;
139
+ const drift = await verifyEntryHashes(entry);
140
+ if (drift) preQuarantine.push(drift);
141
+ }
142
+ const { active } = validateSessionBundles(effective, {}, preQuarantine);
143
+ const { renderPluginAppendices } = await import("./prompt-appendix");
144
+ const { buildAgentSubskillAdvertisement } = await import("./injection");
145
+ const rendered = await renderPluginAppendices(active);
146
+ return {
147
+ appendix: rendered.byAgent.get(input.agentName as never) ?? "",
148
+ advertisement: buildAgentSubskillAdvertisement(active, input.agentName),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Render the Tier-1 sub-skill advertisement for a workflow parent skill.
154
+ * Returns "" when nothing applies. Quarantine applied first.
155
+ */
156
+ export async function renderSkillAdvertisement(input: {
157
+ cwd: string;
158
+ skillName: string;
159
+ phase?: string;
160
+ }): Promise<string> {
161
+ const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
162
+ if (effective.length === 0) return "";
163
+ const preQuarantine: SessionQuarantine[] = [];
164
+ for (const entry of effective) {
165
+ if (!entry.enabled) continue;
166
+ const drift = await verifyEntryHashes(entry);
167
+ if (drift) preQuarantine.push(drift);
168
+ }
169
+ const { active } = validateSessionBundles(effective, {}, preQuarantine);
170
+ const { buildSubskillAdvertisement } = await import("./injection");
171
+ return buildSubskillAdvertisement(active, input.skillName, input.phase);
172
+ }
173
+
174
+ /**
175
+ * Convert active plugin-bundle MCP surfaces into runtime MCPServerConfig entries,
176
+ * applying install + runtime MCP policy (URL scheme/private-range deny, DNS
177
+ * re-resolution for http/sse, stdio root-confinement) before connection. Servers
178
+ * failing policy are quarantined and excluded. Returns {} when none.
179
+ */
180
+ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
181
+ configs: Record<string, any>;
182
+ quarantine: SessionQuarantine[];
183
+ }> {
184
+ const effective = await loadEffectiveGjcPluginRegistry(input.cwd);
185
+ if (effective.length === 0) return { configs: {}, quarantine: [] };
186
+ const preQuarantine: SessionQuarantine[] = [];
187
+ for (const entry of effective) {
188
+ if (!entry.enabled) continue;
189
+ const drift = await verifyEntryHashes(entry);
190
+ if (drift) preQuarantine.push(drift);
191
+ }
192
+ const { active, quarantine } = validateSessionBundles(effective, {}, preQuarantine);
193
+ const { assertMcpInstallPolicy, assertDnsResolvesPublic, assertUrlAllowed } = await import("./mcp-policy");
194
+ const nodePath = await import("node:path");
195
+
196
+ const configs: Record<string, any> = {};
197
+ for (const entry of active) {
198
+ const disabled = new Set(entry.disabledSurfaceIds);
199
+ for (const m of entry.surfaces.mcps) {
200
+ if (disabled.has(m.extensionId)) continue;
201
+ const cfg = m.config;
202
+ try {
203
+ assertMcpInstallPolicy(cfg, { pluginRoot: entry.pluginRoot });
204
+ if (cfg.transport === "stdio") {
205
+ configs[m.name] = {
206
+ type: "stdio",
207
+ command: cfg.command,
208
+ args: cfg.args,
209
+ cwd: cfg.cwd ? nodePath.resolve(entry.pluginRoot, cfg.cwd) : entry.pluginRoot,
210
+ // Third-party plugin MCP processes must not inherit host secrets;
211
+ // only a minimal OS allowlist (PATH/HOME/temp/locale) is provided.
212
+ noInheritEnv: true,
213
+ };
214
+ } else {
215
+ const url = assertUrlAllowed(cfg.url ?? "", `MCP "${m.name}" url`);
216
+ await assertDnsResolvesPublic(url.hostname, `MCP "${m.name}" host`);
217
+ // Headers are intentionally NOT forwarded: the generic MCP config
218
+ // resolution path expands ${env:...}/shell templates, which would let
219
+ // a third-party bundle exfiltrate host secrets. Plugin-bundle MCP
220
+ // servers connect without bundle-declared headers.
221
+ configs[m.name] = { type: cfg.transport, url: cfg.url };
222
+ }
223
+ } catch (error) {
224
+ quarantine.push({
225
+ plugin: entry.name,
226
+ surfaceId: m.extensionId,
227
+ code: "security_policy",
228
+ message: error instanceof Error ? error.message : String(error),
229
+ });
230
+ }
231
+ }
232
+ }
233
+ return { configs, quarantine };
234
+ }