@gajae-code/coding-agent 0.7.3 → 0.7.4

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 (117) hide show
  1. package/CHANGELOG.md +48 -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 +20 -1
  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 +61 -7
  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 +27 -2
  82. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  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/hook-editor.ts +1 -1
  87. package/src/modes/components/hook-selector.ts +67 -43
  88. package/src/modes/components/model-selector.ts +44 -11
  89. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  90. package/src/modes/controllers/selector-controller.ts +50 -11
  91. package/src/modes/interactive-mode.ts +2 -0
  92. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  93. package/src/notifications/html-format.ts +38 -0
  94. package/src/notifications/index.ts +242 -12
  95. package/src/notifications/lifecycle-commands.ts +228 -0
  96. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  97. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  98. package/src/notifications/rate-limit-pool.ts +19 -0
  99. package/src/notifications/recent-activity.ts +132 -0
  100. package/src/notifications/telegram-daemon.ts +433 -8
  101. package/src/notifications/telegram-reference.ts +25 -7
  102. package/src/notifications/topic-registry.ts +18 -9
  103. package/src/prompts/agents/executor.md +2 -2
  104. package/src/runtime-mcp/transports/stdio.ts +38 -4
  105. package/src/runtime-mcp/types.ts +7 -0
  106. package/src/sdk.ts +157 -10
  107. package/src/session/agent-session.ts +166 -74
  108. package/src/session/blob-store.ts +196 -8
  109. package/src/session/session-manager.ts +678 -7
  110. package/src/slash-commands/builtin-registry.ts +23 -3
  111. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  112. package/src/system-prompt.ts +9 -0
  113. package/src/task/executor.ts +31 -7
  114. package/src/task/index.ts +2 -0
  115. package/src/tools/ask.ts +5 -1
  116. package/src/tools/index.ts +3 -1
  117. package/src/utils/changelog.ts +8 -0
@@ -0,0 +1,100 @@
1
+ // G004 real-tmux smoke: exercises forceCloseGjcTmuxSession against LIVE tmux
2
+ // sessions (tmux 3.6a). Proves the wrapper hard-kills GJC-managed live panes
3
+ // (where remove refuses), refuses non-GJC sessions, and enforces session-id
4
+ // matching. Produces durable evidence; not part of the unit suite.
5
+ import assert from "node:assert";
6
+
7
+ import {
8
+ buildGjcTmuxExactOptionTarget,
9
+ buildGjcTmuxProfileCommands,
10
+ resolveGjcTmuxCommand,
11
+ } from "../src/gjc-runtime/tmux-common";
12
+ import { forceCloseGjcTmuxSession, removeGjcTmuxSession, statusGjcTmuxSession } from "../src/gjc-runtime/tmux-sessions";
13
+
14
+ const tmux = resolveGjcTmuxCommand(process.env);
15
+
16
+ function sh(args: string[]): { code: number; err: string } {
17
+ const r = Bun.spawnSync([tmux, ...args], { stdout: "pipe", stderr: "pipe" });
18
+ return { code: r.exitCode, err: r.stderr.toString().trim() };
19
+ }
20
+
21
+ function makeRawSession(name: string): void {
22
+ const r = sh(["new-session", "-d", "-s", name, "sleep 600"]);
23
+ if (r.code !== 0) throw new Error(`failed to create tmux session ${name}: ${r.err}`);
24
+ }
25
+
26
+ function tagAsGjc(name: string, sessionId?: string): void {
27
+ const target = buildGjcTmuxExactOptionTarget(name);
28
+ for (const cmd of buildGjcTmuxProfileCommands(target, process.env, { sessionId })) {
29
+ const r = sh(cmd.args);
30
+ if (r.code !== 0) throw new Error(`failed to tag ${name} (${cmd.description}): ${r.err}`);
31
+ }
32
+ }
33
+
34
+ function exists(name: string): boolean {
35
+ return sh(["has-session", "-t", `=${name}`]).code === 0;
36
+ }
37
+
38
+ function killQuiet(name: string): void {
39
+ sh(["kill-session", "-t", `=${name}`]);
40
+ }
41
+
42
+ const suffix = `${process.pid}-${Date.now()}`;
43
+ const live = `gjc_g004live_${suffix}`;
44
+ const raw = `g004raw_${suffix}`;
45
+ const mism = `gjc_g004mism_${suffix}`;
46
+ const cleanup: string[] = [live, raw, mism];
47
+
48
+ try {
49
+ // 1. GJC-managed LIVE session: remove refuses, force-close hard-kills.
50
+ makeRawSession(live);
51
+ tagAsGjc(live, "sess-g004");
52
+ const status = statusGjcTmuxSession(live);
53
+ assert.equal(status.profile, "1", "session must be recognized as GJC-managed");
54
+ assert.ok(status.panePids.length > 0, "session must have a live pane (sleep)");
55
+ console.log(`[g004] live GJC session up: ${live} panePids=${status.panePids.length}`);
56
+
57
+ let removeRefused = false;
58
+ try {
59
+ removeGjcTmuxSession(live);
60
+ } catch (e) {
61
+ removeRefused = /gjc_tmux_session_live/.test(String(e));
62
+ }
63
+ assert.ok(removeRefused, "removeGjcTmuxSession must REFUSE a live pane");
64
+ console.log("[g004] remove refused live session (expected)");
65
+
66
+ const closed = forceCloseGjcTmuxSession(live, process.env, "sess-g004");
67
+ assert.equal(closed.name, live);
68
+ assert.ok(!exists(live), "force-close must hard-kill the live GJC session");
69
+ console.log("[g004] force-close hard-killed the live GJC session (id-matched)");
70
+
71
+ // 2. Non-GJC (untagged) session: force-close must refuse.
72
+ makeRawSession(raw);
73
+ let notManaged = false;
74
+ try {
75
+ forceCloseGjcTmuxSession(raw, process.env);
76
+ } catch (e) {
77
+ notManaged = /gjc_tmux_session_(not_managed|not_found)/.test(String(e));
78
+ }
79
+ assert.ok(notManaged, "force-close must refuse a non-GJC tmux session");
80
+ assert.ok(exists(raw), "non-GJC session must be left untouched");
81
+ killQuiet(raw);
82
+ console.log("[g004] force-close refused + preserved non-GJC session (expected)");
83
+
84
+ // 3. GJC session with a MISMATCHED expected session id: must refuse.
85
+ makeRawSession(mism);
86
+ tagAsGjc(mism, "sess-real");
87
+ let idMismatch = false;
88
+ try {
89
+ forceCloseGjcTmuxSession(mism, process.env, "sess-WRONG");
90
+ } catch (e) {
91
+ idMismatch = /gjc_tmux_session_id_mismatch/.test(String(e));
92
+ }
93
+ assert.ok(idMismatch, "force-close must refuse on session-id mismatch");
94
+ assert.ok(exists(mism), "mismatched session must be left untouched");
95
+ console.log("[g004] force-close refused on session-id mismatch (expected)");
96
+
97
+ console.log("[g004] PASS: forceCloseGjcTmuxSession verified against live tmux");
98
+ } finally {
99
+ for (const name of cleanup) killQuiet(name);
100
+ }
@@ -0,0 +1,181 @@
1
+ // G005 real end-to-end daemon-orchestration smoke.
2
+ //
3
+ // Drives the lifecycle orchestrator's create -> close path with REAL effects:
4
+ // a real fsynced file-backed idempotency ledger, a real audit JSONL, and a real
5
+ // tmux session spawned/closed via the actual tmux helpers. Proves the daemon
6
+ // orchestration turns a `session_create` frame into a genuinely-spawned,
7
+ // GJC-tagged tmux session and a `session_close` frame into a real hard-close,
8
+ // with idempotent re-ack. Not part of the unit suite.
9
+ import assert from "node:assert";
10
+ import * as crypto from "node:crypto";
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+
15
+ import {
16
+ buildGjcTmuxExactOptionTarget,
17
+ buildGjcTmuxProfileCommands,
18
+ resolveGjcTmuxCommand,
19
+ } from "../src/gjc-runtime/tmux-common";
20
+ import { forceCloseGjcTmuxSession, statusGjcTmuxSession } from "../src/gjc-runtime/tmux-sessions";
21
+ import type { SessionCloseFrame, SessionCreateFrame } from "../src/notifications/index";
22
+ import {
23
+ type AuditEvent,
24
+ handleLifecycleRequest,
25
+ type LedgerDoc,
26
+ type LedgerStore,
27
+ type OrchestratorDeps,
28
+ } from "../src/notifications/lifecycle-orchestrator";
29
+
30
+ const tmux = resolveGjcTmuxCommand(process.env);
31
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-g005-"));
32
+ const ledgerPath = path.join(tmpRoot, "idempotency.json");
33
+ const auditPath = path.join(tmpRoot, "audit.jsonl");
34
+ const created: string[] = [];
35
+
36
+ function sh(args: string[]): number {
37
+ return Bun.spawnSync([tmux, ...args], { stdout: "pipe", stderr: "pipe" }).exitCode;
38
+ }
39
+ function exists(name: string): boolean {
40
+ return sh(["has-session", "-t", `=${name}`]) === 0;
41
+ }
42
+
43
+ // Real fsynced + atomic file ledger store.
44
+ const store: LedgerStore = {
45
+ async read(): Promise<LedgerDoc> {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(ledgerPath, "utf8")) as LedgerDoc;
48
+ } catch {
49
+ return { version: 1, entries: {} };
50
+ }
51
+ },
52
+ async write(doc: LedgerDoc): Promise<void> {
53
+ const tmp = `${ledgerPath}.${process.pid}.tmp`;
54
+ const fd = fs.openSync(tmp, "w", 0o600);
55
+ fs.writeSync(fd, JSON.stringify(doc));
56
+ fs.fsyncSync(fd);
57
+ fs.closeSync(fd);
58
+ fs.renameSync(tmp, ledgerPath);
59
+ },
60
+ };
61
+
62
+ const auditLines: AuditEvent[] = [];
63
+ const deps: OrchestratorDeps = {
64
+ pairedChatId: "42",
65
+ now: () => Date.now(),
66
+ store,
67
+ audit: e => {
68
+ auditLines.push(e);
69
+ fs.appendFileSync(auditPath, `${JSON.stringify(e)}\n`, { mode: 0o600 });
70
+ },
71
+ allowCreate: () => true,
72
+ writeStartupPrompt: async (requestId, prompt) => {
73
+ if (prompt === undefined) return undefined;
74
+ const ref = path.join(tmpRoot, `prompt-${requestId}`);
75
+ const fd = fs.openSync(ref, "w", 0o600);
76
+ fs.writeSync(fd, prompt);
77
+ fs.fsyncSync(fd);
78
+ fs.closeSync(fd);
79
+ return ref;
80
+ },
81
+ // REAL spawn: create a GJC-tagged tmux session carrying the authoritative id.
82
+ spawnCreate: async (_frame, ids) => {
83
+ const name = `gjc_g005_${ids.intendedSessionId}`;
84
+ created.push(name);
85
+ if (sh(["new-session", "-d", "-s", name, "sleep 600"]) !== 0) {
86
+ throw new Error(`tmux spawn failed for ${name}`);
87
+ }
88
+ const target = buildGjcTmuxExactOptionTarget(name);
89
+ for (const cmd of buildGjcTmuxProfileCommands(target, process.env, {
90
+ sessionId: ids.intendedSessionId,
91
+ })) {
92
+ if (sh(cmd.args) !== 0) throw new Error(`tag failed for ${name}`);
93
+ }
94
+ return {
95
+ sessionId: ids.intendedSessionId,
96
+ tmuxSession: name,
97
+ endpointUrl: "ws://127.0.0.1:0",
98
+ topicThreadId: "1",
99
+ };
100
+ },
101
+ // REAL close: hard-close the GJC-managed tmux session, id-matched.
102
+ closeSession: async target => {
103
+ forceCloseGjcTmuxSession(target.tmuxSession ?? "", process.env, target.sessionId);
104
+ return { processGone: !exists(target.tmuxSession ?? "") };
105
+ },
106
+ resumeSession: async () => ({ ambiguous: [] }),
107
+ newLifecycleRequestId: () => `lc-${crypto.randomUUID()}`,
108
+ newSessionId: () => `s${crypto.randomUUID().slice(0, 8)}`,
109
+ };
110
+
111
+ const createFrame: SessionCreateFrame = {
112
+ type: "session_create",
113
+ requestId: "lc_g005",
114
+ lifecycleRequestId: "lc_g005",
115
+ intendedSessionId: `g005${Date.now().toString(36)}`,
116
+ updateId: 100,
117
+ chatId: "42",
118
+ token: "control-token",
119
+ target: { kind: "existing_path", path: tmpRoot },
120
+ };
121
+
122
+ async function main(): Promise<void> {
123
+ // 1. CREATE -> real tmux session spawned + GJC-tagged with the authoritative id.
124
+ const createOut = await handleLifecycleRequest(createFrame, deps);
125
+ assert.equal(createOut.status, "ok", "create must succeed");
126
+ const session = createOut.status === "ok" ? createOut.entry.tmuxSession! : "";
127
+ assert.ok(exists(session), "real tmux session must exist after create");
128
+ const status = statusGjcTmuxSession(session);
129
+ assert.equal(status.profile, "1", "spawned session must be GJC-managed");
130
+ assert.equal(status.sessionId, createFrame.intendedSessionId, "authoritative session id propagated to tmux tag");
131
+ console.log(`[g005] CREATE -> live tmux session ${session} (id=${status.sessionId})`);
132
+
133
+ // 2. Idempotent re-ack: same updateId+body must NOT spawn a second session.
134
+ const before = created.length;
135
+ const dupOut = await handleLifecycleRequest(createFrame, deps);
136
+ assert.equal(dupOut.status, "ok", "duplicate create re-acks ok");
137
+ assert.equal(created.length, before, "duplicate create must NOT spawn a second session");
138
+ console.log("[g005] DUPLICATE create re-acked, no second spawn (idempotent)");
139
+
140
+ // 3. CLOSE -> real hard-close of the GJC-managed session, id-matched.
141
+ const closeFrame: SessionCloseFrame = {
142
+ type: "session_close",
143
+ requestId: "lc_g005_close",
144
+ updateId: 101,
145
+ chatId: "42",
146
+ token: "control-token",
147
+ target: { sessionId: createFrame.intendedSessionId, tmuxSession: session },
148
+ force: true,
149
+ };
150
+ const closeOut = await handleLifecycleRequest(closeFrame, deps);
151
+ assert.equal(closeOut.status, "ok", "close must succeed");
152
+ assert.ok(!exists(session), "real tmux session must be gone after close");
153
+ console.log(`[g005] CLOSE -> hard-closed ${session} (id-matched)`);
154
+
155
+ // 4. Durable ledger + audit redaction.
156
+ const doc = JSON.parse(fs.readFileSync(ledgerPath, "utf8")) as LedgerDoc;
157
+ assert.equal(doc.entries["42:100"]?.state, "success");
158
+ assert.equal(doc.entries["42:101"]?.state, "success");
159
+ const auditBlob = fs.readFileSync(auditPath, "utf8");
160
+ assert.ok(!auditBlob.includes("control-token"), "audit must never contain the control token");
161
+ assert.ok(
162
+ auditLines.some(a => a.event === "spawn_started"),
163
+ "audit records spawn_started",
164
+ );
165
+ console.log("[g005] durable fsynced ledger + token-redacted audit verified");
166
+
167
+ console.log("[g005] PASS: real create->close daemon orchestration over live tmux");
168
+ }
169
+
170
+ main()
171
+ .then(() => {
172
+ for (const n of created) sh(["kill-session", "-t", `=${n}`]);
173
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
174
+ process.exit(0);
175
+ })
176
+ .catch(err => {
177
+ for (const n of created) sh(["kill-session", "-t", `=${n}`]);
178
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
179
+ console.error("[g005] FAIL", err);
180
+ process.exit(1);
181
+ });
@@ -0,0 +1,153 @@
1
+ // G011 real daemon-path smoke.
2
+ //
3
+ // Drives a PARSED /session_* command through the LIVE native
4
+ // NotificationControlServer + a real loopback WebSocket client (NO injected/fake
5
+ // seams), wired to the real orchestrator (with stubbed spawn/close effects so the
6
+ // focus is the authenticated wire path, not tmux). Asserts:
7
+ // - a wrong-token handshake is rejected (control token gates the endpoint);
8
+ // - a valid frame is forwarded with the control token STRIPPED from payloadJson;
9
+ // - the host response is routed back to the client by requestId;
10
+ // - control discovery exists while running and is removed after stop.
11
+ import assert from "node:assert";
12
+ import * as crypto from "node:crypto";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import WebSocket from "ws";
17
+
18
+ // Import the WORKTREE native build directly (not @gajae-code/natives, which can
19
+ // resolve to a different checkout in this dev environment).
20
+ import { NotificationControlServer } from "../../natives/native/index.js";
21
+ import { parseLifecycleCommand } from "../src/notifications/lifecycle-commands";
22
+ import { attachLifecycleControl, fileAudit, fileLedgerStore } from "../src/notifications/lifecycle-control-runtime";
23
+ import type { OrchestratorDeps } from "../src/notifications/lifecycle-orchestrator";
24
+
25
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-g011-"));
26
+ const token = crypto.randomBytes(32).toString("base64url");
27
+ const ownerId = "daemon-g011";
28
+ const closed: string[] = [];
29
+
30
+ function deps(): OrchestratorDeps {
31
+ return {
32
+ pairedChatId: "42",
33
+ now: () => Date.now(),
34
+ store: fileLedgerStore(path.join(tmp, "idempotency.json")),
35
+ audit: fileAudit(path.join(tmp, "audit.jsonl")),
36
+ allowCreate: () => true,
37
+ writeStartupPrompt: async () => undefined,
38
+ // Stubbed effects: this smoke proves the WIRE path, not tmux (covered by g005).
39
+ spawnCreate: async (_f, ids) => ({
40
+ sessionId: ids.intendedSessionId,
41
+ tmuxSession: `gjc-${ids.intendedSessionId}`,
42
+ endpointUrl: "ws://127.0.0.1:0",
43
+ topicThreadId: "1",
44
+ }),
45
+ closeSession: async t => {
46
+ closed.push(t.sessionId);
47
+ return { processGone: true };
48
+ },
49
+ resumeSession: async () => ({ ambiguous: [] }),
50
+ newLifecycleRequestId: () => `lc-${crypto.randomUUID()}`,
51
+ newSessionId: () => `s${crypto.randomUUID().slice(0, 8)}`,
52
+ };
53
+ }
54
+
55
+ function send(ws: WebSocket, frame: unknown): void {
56
+ ws.send(JSON.stringify(frame));
57
+ }
58
+
59
+ async function main(): Promise<void> {
60
+ const server = new NotificationControlServer(token, ownerId, tmp);
61
+
62
+ // Wire the real orchestrator to the real native control server.
63
+ const d = deps();
64
+ attachLifecycleControl(server as never, d);
65
+
66
+ const ep = (await server.start()) as { url: string };
67
+ assert.ok(ep.url.startsWith("ws://127.0.0.1:"), `loopback url, got ${ep.url}`);
68
+ const controlJson = path.join(tmp, "notifications", "control.json");
69
+ assert.ok(fs.existsSync(controlJson), "control discovery must exist while running");
70
+ // The control discovery file MUST NOT persist the privileged control token:
71
+ // the daemon holds it in memory and is the only client.
72
+ const controlRaw = fs.readFileSync(controlJson, "utf8");
73
+ assert.ok(!controlRaw.includes(token), "control token MUST NOT be persisted in control.json");
74
+ assert.ok(
75
+ !("token" in (JSON.parse(controlRaw) as Record<string, unknown>)),
76
+ "control.json must omit any token field",
77
+ );
78
+ console.log(`[g011] live control endpoint ${ep.url}; discovery present, no persisted token`);
79
+
80
+ // 1. Wrong-token handshake must be rejected.
81
+ await new Promise<void>(resolve => {
82
+ const bad = new WebSocket(`${ep.url}/?token=wrong`);
83
+ bad.on("open", () => {
84
+ throw new Error("wrong-token handshake must NOT open");
85
+ });
86
+ bad.on("error", () => {
87
+ console.log("[g011] wrong-token handshake rejected (expected)");
88
+ resolve();
89
+ });
90
+ });
91
+
92
+ // 2. Valid client: send a PARSED /session_close command through the real wire.
93
+ const parsed = parseLifecycleCommand("/session_close sess-g011");
94
+ assert.equal(parsed.kind, "close", "parser must yield a close command");
95
+ const requestId = "lc-g011-1";
96
+ const frame = {
97
+ type: "session_close",
98
+ requestId,
99
+ updateId: 1,
100
+ chatId: "42",
101
+ token,
102
+ target: parsed.kind === "close" ? parsed.target : { sessionId: "sess-g011" },
103
+ force: true,
104
+ };
105
+
106
+ const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
107
+ const ws = new WebSocket(`${ep.url}/?token=${token}`);
108
+ const timer = setTimeout(() => reject(new Error("timed out")), 5000);
109
+ ws.on("open", () => send(ws, frame));
110
+ ws.on("message", data => {
111
+ clearTimeout(timer);
112
+ ws.close();
113
+ resolve(JSON.parse(String(data)) as Record<string, unknown>);
114
+ });
115
+ ws.on("error", reject);
116
+ });
117
+
118
+ assert.equal(response.type, "session_close_response", "response routed back to client");
119
+ assert.equal(response.requestId, requestId, "response correlated by requestId");
120
+ assert.equal(response.sessionId, "sess-g011");
121
+ assert.deepEqual(closed, ["sess-g011"], "orchestrator close effect ran exactly once");
122
+ console.log("[g011] parsed command -> real wire -> orchestrator -> routed response OK");
123
+
124
+ // 3. The real orchestrator path must NOT leak the control token into its
125
+ // durable audit log or idempotency ledger (g002 separately proves the native
126
+ // payloadJson boundary is token-stripped).
127
+ for (const f of ["audit.jsonl", "idempotency.json"]) {
128
+ const p = path.join(tmp, f);
129
+ if (fs.existsSync(p)) {
130
+ assert.ok(!fs.readFileSync(p, "utf8").includes(token), `control token leaked into ${f}`);
131
+ }
132
+ }
133
+ console.log("[g011] no control-token leak in audit/ledger on the real path");
134
+
135
+ // 4. Stop removes control discovery.
136
+ server.stop();
137
+ await new Promise(r => setTimeout(r, 50));
138
+ assert.ok(!fs.existsSync(controlJson), "control discovery removed after stop");
139
+ console.log("[g011] control discovery removed after stop");
140
+
141
+ console.log("[g011] PASS: real daemon-path (parse -> native control endpoint -> orchestrator -> reply)");
142
+ }
143
+
144
+ main()
145
+ .then(() => {
146
+ fs.rmSync(tmp, { recursive: true, force: true });
147
+ process.exit(0);
148
+ })
149
+ .catch(err => {
150
+ fs.rmSync(tmp, { recursive: true, force: true });
151
+ console.error("[g011] FAIL", err);
152
+ process.exit(1);
153
+ });
@@ -7,6 +7,7 @@
7
7
  import { APP_NAME, getProjectDir } from "@gajae-code/utils";
8
8
  import chalk from "chalk";
9
9
  import { resolveOrDefaultProjectRegistryPath } from "../discovery/helpers";
10
+ import { installGjcPluginBundle, isGjcPluginBundleSource, readRegistry } from "../extensibility/gjc-plugins";
10
11
  import { PluginManager, parseSettingValue, validateSetting } from "../extensibility/plugins";
11
12
  import {
12
13
  getInstalledPluginsRegistryPath,
@@ -48,6 +49,8 @@ export interface PluginCommandArgs {
48
49
  disable?: string;
49
50
  set?: string;
50
51
  scope?: "user" | "project";
52
+ user?: boolean;
53
+ project?: boolean;
51
54
  };
52
55
  }
53
56
 
@@ -109,6 +112,10 @@ export function parsePluginArgs(args: string[]): PluginCommandArgs | undefined {
109
112
  result.flags.dryRun = true;
110
113
  } else if (arg === "-l" || arg === "--local") {
111
114
  result.flags.local = true;
115
+ } else if (arg === "--user") {
116
+ result.flags.user = true;
117
+ } else if (arg === "--project") {
118
+ result.flags.project = true;
112
119
  } else if (arg === "--enable" && i + 1 < args.length) {
113
120
  result.flags.enable = args[++i];
114
121
  } else if (arg === "--disable" && i + 1 < args.length) {
@@ -345,7 +352,14 @@ async function handleUpgrade(args: string[], flags: PluginCommandArgs["flags"]):
345
352
  async function handleInstall(
346
353
  manager: PluginManager,
347
354
  packages: string[],
348
- flags: { json?: boolean; force?: boolean; dryRun?: boolean; scope?: "user" | "project" },
355
+ flags: {
356
+ json?: boolean;
357
+ force?: boolean;
358
+ dryRun?: boolean;
359
+ scope?: "user" | "project";
360
+ user?: boolean;
361
+ project?: boolean;
362
+ },
349
363
  ): Promise<void> {
350
364
  if (packages.length === 0) {
351
365
  console.error(chalk.red(`Usage: ${APP_NAME} plugin install <package[@version]>[features] ...`));
@@ -360,6 +374,32 @@ async function handleInstall(
360
374
  const knownMarketplaces = new Set((await mktMgr.listMarketplaces()).map(m => m.name));
361
375
 
362
376
  for (const spec of packages) {
377
+ // GJC plugin bundle classifier: a source containing gajae-plugin.json (or a
378
+ // git/tarball source) routes to the bundle installer BEFORE marketplace/npm.
379
+ if (await isGjcPluginBundleSource(spec)) {
380
+ if (flags.user === flags.project) {
381
+ console.error(
382
+ chalk.red(`GJC plugin bundle install requires exactly one of --user or --project for "${spec}".`),
383
+ );
384
+ process.exit(1);
385
+ }
386
+ const scope: "user" | "project" = flags.user ? "user" : "project";
387
+ try {
388
+ const res = await installGjcPluginBundle(spec, { scope, cwd: process.cwd(), force: flags.force });
389
+ if (flags.json) {
390
+ console.log(JSON.stringify({ name: res.entry.name, status: res.status, scope }, null, 2));
391
+ } else {
392
+ console.log(
393
+ chalk.green(`${theme.status.success} ${res.status} GJC plugin ${res.entry.name} (${scope})`),
394
+ );
395
+ }
396
+ } catch (err) {
397
+ console.error(chalk.red(`${theme.status.error} Failed to install GJC plugin ${spec}: ${err}`));
398
+ process.exit(1);
399
+ }
400
+ continue;
401
+ }
402
+
363
403
  const target = classifyInstallTarget(spec, knownMarketplaces);
364
404
 
365
405
  if (target.type === "marketplace") {
@@ -462,13 +502,16 @@ async function handleList(manager: PluginManager, flags: { json?: boolean }): Pr
462
502
  const npmPlugins = await manager.list();
463
503
  const mktMgr = await makeMarketplaceManager();
464
504
  const mktPlugins = await mktMgr.listInstalledPlugins();
505
+ const cwd = getProjectDir();
506
+ const [gjcUser, gjcProject] = await Promise.all([readRegistry("user", cwd), readRegistry("project", cwd)]);
507
+ const gjcBundles = [...gjcUser.plugins, ...gjcProject.plugins];
465
508
 
466
509
  if (flags.json) {
467
- console.log(JSON.stringify({ npm: npmPlugins, marketplace: mktPlugins }, null, 2));
510
+ console.log(JSON.stringify({ npm: npmPlugins, marketplace: mktPlugins, gjc: gjcBundles }, null, 2));
468
511
  return;
469
512
  }
470
513
 
471
- if (npmPlugins.length === 0 && mktPlugins.length === 0) {
514
+ if (npmPlugins.length === 0 && mktPlugins.length === 0 && gjcBundles.length === 0) {
472
515
  console.log(chalk.dim("No plugins installed"));
473
516
  console.log(chalk.dim(`\nInstall plugins with: ${APP_NAME} plugin install <package>`));
474
517
  return;
@@ -510,6 +553,26 @@ async function handleList(manager: PluginManager, flags: { json?: boolean }): Pr
510
553
  console.log(` ${plugin.id} (${version})${scopeLabel}${shadowLabel}`);
511
554
  }
512
555
  }
556
+
557
+ if (gjcBundles.length > 0) {
558
+ if (npmPlugins.length > 0 || mktPlugins.length > 0) console.log();
559
+ console.log(chalk.bold("GJC Plugin Bundles:\n"));
560
+ for (const plugin of gjcBundles) {
561
+ const status = plugin.enabled ? chalk.green(theme.status.enabled) : chalk.dim(theme.status.disabled);
562
+ const scopeLabel = chalk.dim(` (${plugin.scope})`);
563
+ const disabledCount = plugin.disabledSurfaceIds.length;
564
+ const quarantineCount = plugin.quarantine?.length ?? 0;
565
+ const detail = [
566
+ disabledCount > 0 ? `${disabledCount} disabled` : null,
567
+ quarantineCount > 0 ? `${quarantineCount} quarantined` : null,
568
+ ]
569
+ .filter((v): v is string => Boolean(v))
570
+ .join(", ");
571
+ console.log(
572
+ `${status} ${plugin.name}@${plugin.version}${scopeLabel}${detail ? chalk.dim(` — ${detail}`) : ""}`,
573
+ );
574
+ }
575
+ }
513
576
  }
514
577
 
515
578
  async function handleLink(manager: PluginManager, paths: string[], flags: { json?: boolean }): Promise<void> {
package/src/cli.ts CHANGED
@@ -64,7 +64,7 @@ async function showHelp(config: CliConfig): Promise<void> {
64
64
  }
65
65
 
66
66
  async function installRuntimeGlobals(): Promise<void> {
67
- const [{ installH2Fetch }, { procmgr }] = await Promise.all([import("@gajae-code/ai"), import("@gajae-code/utils")]);
67
+ const { installH2Fetch } = await import("@gajae-code/ai/utils/h2-fetch");
68
68
  // Activate HTTP/2 for all `fetch()` calls (provider streams, OAuth, model
69
69
  // discovery, web tools). Bun's HTTP/2 client is gated on a startup flag we
70
70
  // can't toggle from JS, so we patch globalThis.fetch to pass
@@ -75,7 +75,24 @@ async function installRuntimeGlobals(): Promise<void> {
75
75
  // Strip macOS malloc-stack-logging env vars before any subprocess is spawned.
76
76
  // Otherwise every child bun process (subagents, plugin installs, ptree spawns,
77
77
  // etc.) prints a `MallocStackLogging: can't turn off …` warning to stderr.
78
- procmgr.scrubProcessEnv();
78
+ delete process.env.MallocStackLogging;
79
+ delete process.env.MallocStackLoggingNoCompact;
80
+ }
81
+
82
+ function hasRootFastFlag(argv: string[], flags: readonly string[]): boolean {
83
+ for (const arg of argv) {
84
+ if (isSubcommand(arg)) return false;
85
+ if (flags.includes(arg)) return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ function hasRootHelpFlag(argv: string[]): boolean {
91
+ return hasRootFastFlag(argv, rootHelpFlags);
92
+ }
93
+
94
+ function hasRootVersionFlag(argv: string[]): boolean {
95
+ return hasRootFastFlag(argv, versionFlags);
79
96
  }
80
97
 
81
98
  class RootHelpCommand extends Command {
@@ -195,7 +212,7 @@ export async function runCli(argv: string[]): Promise<void> {
195
212
  await runSmokeTest();
196
213
  return;
197
214
  }
198
- if (rootHelpFlags.includes(argv[0] ?? "")) {
215
+ if (hasRootHelpFlag(argv)) {
199
216
  const { renderRootHelp } = await import("@gajae-code/utils/cli");
200
217
  const { getExtraHelpText } = await import("./cli/fast-help");
201
218
  renderRootHelp({ bin: APP_NAME, version: VERSION, commands: new Map([["launch", RootHelpCommand]]) });
@@ -205,7 +222,7 @@ export async function runCli(argv: string[]): Promise<void> {
205
222
  }
206
223
  return;
207
224
  }
208
- if (versionFlags.includes(argv[0] ?? "")) {
225
+ if (hasRootVersionFlag(argv)) {
209
226
  process.stdout.write(`${APP_NAME}/${VERSION}\n`);
210
227
  return;
211
228
  }
@@ -49,6 +49,8 @@ export default class Plugin extends Command {
49
49
  description: 'Install scope: "user" (default) or "project"',
50
50
  options: ["user", "project"],
51
51
  }),
52
+ user: Flags.boolean({ description: "Install GJC plugin bundle into the user root" }),
53
+ project: Flags.boolean({ description: "Install GJC plugin bundle into the project root" }),
52
54
  };
53
55
 
54
56
  async run(): Promise<void> {
@@ -69,6 +71,8 @@ export default class Plugin extends Command {
69
71
  disable: flags.disable,
70
72
  set: flags.set,
71
73
  scope: flags.scope as "user" | "project" | undefined,
74
+ user: flags.user,
75
+ project: flags.project,
72
76
  },
73
77
  };
74
78
 
@@ -2,6 +2,7 @@ import { Args, Command, Flags } from "@gajae-code/utils/cli";
2
2
  import {
3
3
  attachGjcTmuxSession,
4
4
  createGjcTmuxSession,
5
+ forceCloseGjcTmuxSession,
5
6
  listGjcTmuxSessions,
6
7
  removeGjcTmuxSession,
7
8
  statusGjcTmuxSession,
@@ -60,6 +61,12 @@ export default class Session extends Command {
60
61
 
61
62
  static flags = {
62
63
  json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON", default: false }),
64
+ "session-id": Flags.string({
65
+ description: "Expected @gjc-session-id tag for force-close (defense-in-depth match)",
66
+ }),
67
+ "state-file": Flags.string({
68
+ description: "Expected @gjc-session-state-file tag for force-close (defense-in-depth match)",
69
+ }),
63
70
  };
64
71
 
65
72
  static examples = [
@@ -68,6 +75,7 @@ export default class Session extends Command {
68
75
  "gjc session status <session>",
69
76
  "gjc session attach <session>",
70
77
  "gjc session remove <session>",
78
+ "gjc session force-close <session> --session-id <id>",
71
79
  ];
72
80
 
73
81
  async run(): Promise<void> {
@@ -136,6 +144,16 @@ export default class Session extends Command {
136
144
  return;
137
145
  }
138
146
 
147
+ if (action === "force-close" || action === "force-remove") {
148
+ const closed = forceCloseGjcTmuxSession(sessionName, process.env, flags["session-id"], flags["state-file"]);
149
+ if (json) {
150
+ writeJson({ ok: true, session: sessionJson(closed) });
151
+ return;
152
+ }
153
+ writeText([`force-closed: ${closed.name}`]);
154
+ return;
155
+ }
156
+
139
157
  if (action === "attach") {
140
158
  attachGjcTmuxSession(sessionName);
141
159
  return;