@gamaze/hicortex 0.12.0 → 0.13.0

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.
package/dist/index.js CHANGED
@@ -19,9 +19,12 @@
19
19
  * canonical nightly-from-logs, same as CC JSONL and Hermes state.db.
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
+ const paths_js_1 = require("./paths.js");
22
23
  const features_js_1 = require("./features.js");
23
24
  const extensions_js_1 = require("./extensions.js");
24
25
  const state_js_1 = require("./state.js");
26
+ const context_store_js_1 = require("./context-store.js");
27
+ const lessons_context_js_1 = require("./lessons-context.js");
25
28
  const node_fs_1 = require("node:fs");
26
29
  const node_path_1 = require("node:path");
27
30
  const node_os_1 = require("node:os");
@@ -30,7 +33,10 @@ const node_os_1 = require("node:os");
30
33
  // ---------------------------------------------------------------------------
31
34
  const DEFAULT_SERVER_URL = "http://127.0.0.1:8787";
32
35
  const LESSONS_TIMEOUT_MS = 3000;
33
- const HICORTEX_HOME = (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
36
+ const CONTEXT_TIMEOUT_MS = 3000;
37
+ const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
38
+ /** Harness name this plugin injects for — used to self-gate on GET /context `clients`. */
39
+ const THIS_HARNESS = "oc";
34
40
  // ---------------------------------------------------------------------------
35
41
  // Module state — initialized in registerService.start()
36
42
  // ---------------------------------------------------------------------------
@@ -94,6 +100,54 @@ async function serverPost(path, body, timeoutMs) {
94
100
  }
95
101
  }
96
102
  // ---------------------------------------------------------------------------
103
+ // Context layer (L2) — per-agent standing context (0.13)
104
+ // ---------------------------------------------------------------------------
105
+ /**
106
+ * Fetch GET /context (per-agent when an id is supplied) and build the
107
+ * `## Context` block via the shared gate (gateAndRenderContext), or null when
108
+ * nothing should be injected. The old-server guard is required only when an
109
+ * agent id was actually sent (amendment A2 — a bare fetch skips it). The server
110
+ * does the merge; the plugin stays dumb (no client-side mode logic).
111
+ */
112
+ async function fetchOcContextBlock(agentId) {
113
+ const path = agentId ? `/context?agent=${encodeURIComponent(agentId)}` : "/context";
114
+ const { data } = await serverGet(path, CONTEXT_TIMEOUT_MS);
115
+ if (!data)
116
+ return null;
117
+ return (0, lessons_context_js_1.gateAndRenderContext)(data, THIS_HARNESS, { requireAgentEcho: agentId !== null });
118
+ }
119
+ /**
120
+ * Fetch /lessons and build the `## Hicortex Lessons` block, or null on any
121
+ * failure or when no lessons survive selection. Preserves the pre-0.13 lesson
122
+ * output; the caller prepends the `## Context` block and adds separators.
123
+ */
124
+ async function buildLessonsBlock(project) {
125
+ const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
126
+ if (!data || !data.lessons || data.lessons.length === 0)
127
+ return null;
128
+ const maxLessons = (0, features_js_1.lessonsLimit)();
129
+ const state = (0, state_js_1.loadState)(hicortexHome);
130
+ const moduleIndex = data.moduleIndex ?? state.moduleIndex;
131
+ const selected = await (0, extensions_js_1.getLessonSelector)().select(data.lessons, {
132
+ maxLessons,
133
+ project,
134
+ moduleIndex,
135
+ });
136
+ if (selected.length === 0)
137
+ return null;
138
+ const formatted = selected.map((l) => {
139
+ const titleMatch = l.content.match(/## Lesson: (.+)/);
140
+ const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
141
+ const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
142
+ const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
143
+ const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
144
+ return `- ${title}${meta ? ` (${meta})` : ""}`;
145
+ });
146
+ return (`## Hicortex Lessons (auto-injected from long-term memory)\n` +
147
+ `These are actionable lessons learned from past sessions:\n\n` +
148
+ formatted.join("\n"));
149
+ }
150
+ // ---------------------------------------------------------------------------
97
151
  // Tool result formatter
98
152
  // ---------------------------------------------------------------------------
99
153
  function formatToolResults(results) {
@@ -157,40 +211,32 @@ exports.default = {
157
211
  },
158
212
  });
159
213
  // -----------------------------------------------------------------------
160
- // Hook: before_agent_start — fetch lessons from server (fail-soft)
214
+ // Hook: before_agent_start — fetch context + lessons from server (fail-soft)
161
215
  // -----------------------------------------------------------------------
162
216
  api.on("before_agent_start", async (_event, ctx) => {
217
+ // Outer guard: the hook must NEVER throw (a rejection could block the
218
+ // agent). `ctx` itself can be nullish on some gateway variants, and the
219
+ // synchronous sanitize below runs before any per-fetch .catch — so the
220
+ // whole body is wrapped, not just the fetches.
163
221
  try {
164
- // One fetch build context and check cap from the same response.
165
- const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
166
- if (!data || !data.lessons || data.lessons.length === 0)
167
- return {};
168
- const maxLessons = (0, features_js_1.lessonsLimit)();
169
- const state = (0, state_js_1.loadState)(hicortexHome);
170
- const moduleIndex = data.moduleIndex ?? state.moduleIndex;
171
- const selected = await (0, extensions_js_1.getLessonSelector)().select(data.lessons, {
172
- maxLessons,
173
- project: ctx.project,
174
- moduleIndex,
175
- });
176
- if (selected.length === 0)
222
+ // Per-agent context id: sanitize the OC agent id (a symbols-only id
223
+ // sanitizes to null bare /context → global set). Null id never sends
224
+ // ?agent=, so an old server behaves exactly as before.
225
+ const agentId = (0, context_store_js_1.sanitizeAgentId)(ctx?.agentId ?? "");
226
+ // Fetch both concurrently with INDEPENDENT fail-soft: a /context
227
+ // failure must never cost the lessons block, and vice versa. The
228
+ // `## Context` block (standing context, 0.13) is prepended before
229
+ // `## Hicortex Lessons`, mirroring the CC hook.
230
+ const [contextBlock, lessonsBlock] = await Promise.all([
231
+ fetchOcContextBlock(agentId).catch(() => null),
232
+ buildLessonsBlock(ctx?.project).catch(() => null),
233
+ ]);
234
+ const blocks = [contextBlock, lessonsBlock].filter((b) => b !== null && b !== "");
235
+ if (blocks.length === 0)
177
236
  return {};
178
- const formatted = selected.map((l) => {
179
- const titleMatch = l.content.match(/## Lesson: (.+)/);
180
- const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
181
- const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
182
- const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
183
- const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
184
- return `- ${title}${meta ? ` (${meta})` : ""}`;
185
- });
186
- const context = `\n\n## Hicortex Lessons (auto-injected from long-term memory)\n` +
187
- `These are actionable lessons learned from past sessions:\n\n` +
188
- formatted.join("\n") +
189
- "\n";
190
- return { appendSystemContext: context };
237
+ return { appendSystemContext: `\n\n${blocks.join("\n\n")}\n` };
191
238
  }
192
239
  catch {
193
- // Fail-soft — a broken lessons fetch must not block the agent
194
240
  return {};
195
241
  }
196
242
  });
package/dist/init.d.ts CHANGED
@@ -16,6 +16,13 @@
16
16
  * - Install CC custom commands (/learn, /hicortex-activate)
17
17
  */
18
18
  import type { DomainDef } from "./types.js";
19
+ /**
20
+ * Classify `claude mcp list` output for the hicortex entry. Pure (testable):
21
+ * - "missing" — hicortex not listed → registration didn't take
22
+ * - "connected" — listed AND reachable (✓/✔/Connected shown)
23
+ * - "registered" — listed but connection not confirmed (server down / not restarted)
24
+ */
25
+ export declare function parseMcpListStatus(mcpListOutput: string): "connected" | "registered" | "missing";
19
26
  /**
20
27
  * Parse a KEY=VALUE env file (e.g. ~/.hermes/.env or ~/.claude/settings.json env block).
21
28
  * Handles: comments (#), quoted values, empty lines.
@@ -37,6 +44,29 @@ export declare function persistAuthToken(configPath: string): {
37
44
  token: string;
38
45
  generated: boolean;
39
46
  };
47
+ /**
48
+ * Decide the per-agent context id to persist at init (#179; CC default = global,
49
+ * owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
50
+ * NO hostname default, so an install with no `--agent-name` sends no `?agent=`
51
+ * and shares the global context (one user = one identity across machines).
52
+ *
53
+ * Empty string == unset everywhere: `--agent-name ""` (or whitespace-only) is
54
+ * the explicit way to opt BACK OUT — it CLEARS any existing `agentName` key and
55
+ * returns to global, rather than erroring as an invalid id.
56
+ * - explicit `--agent-name <non-empty>` → the sanitized flag (error if it
57
+ * sanitizes to null, so a bad flag is loud rather than silently ignored);
58
+ * - explicit `--agent-name ""` / whitespace-only → `clear` (remove the key);
59
+ * - no flag but an existing non-empty `agentName` → keep it untouched
60
+ * (non-clobber like persistAuthToken / scaffoldDefaultDomains);
61
+ * - no flag, no (non-empty) existing value → do not write (global by default).
62
+ * Pure + exported for testability; never touches disk.
63
+ */
64
+ export declare function decideAgentName(existing: unknown, flag: string | undefined): {
65
+ write: boolean;
66
+ value: string | null;
67
+ clear?: boolean;
68
+ error?: string;
69
+ };
40
70
  /**
41
71
  * Generic default memory domains scaffolded by server-mode init (issue #150).
42
72
  * Deliberately broad, high-level spheres — an editable STARTING POINT, not a
@@ -61,6 +91,15 @@ export declare const GENERIC_DEFAULT_DOMAINS: DomainDef[];
61
91
  export declare function scaffoldDefaultDomains(configPath: string): {
62
92
  scaffolded: boolean;
63
93
  };
94
+ /**
95
+ * True if a resolved binary path lives in npm's ephemeral npx cache
96
+ * (`~/.npm/_npx/<hash>/node_modules/.bin/…`). When `hicortex init` is itself
97
+ * run via `npx -y @gamaze/hicortex init`, npx prepends that cache dir to PATH,
98
+ * so `which hicortex` resolves there. npm garbage-collects `_npx`, so any
99
+ * SessionStart hook or nightly timer wired to such a path breaks silently
100
+ * later — the "looks configured but isn't" trap (#176). Never persist it.
101
+ */
102
+ export declare function isEphemeralNpxPath(binPath: string): boolean;
64
103
  /**
65
104
  * Install (or verify) the CC SessionStart hook that runs `hicortex lessons-context`.
66
105
  * The hook fetches lessons from the configured server at session start and injects
@@ -74,6 +113,7 @@ export declare function scaffoldDefaultDomains(configPath: string): {
74
113
  export declare function installSessionStartHook(settingsPath?: string): void;
75
114
  export declare function runInit(options?: {
76
115
  serverUrl?: string;
116
+ agentName?: string;
77
117
  }): Promise<void>;
78
118
  /**
79
119
  * Resolve the nightly hour (0–23, local time) for the generated schedule.
package/dist/init.js CHANGED
@@ -18,13 +18,17 @@
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.GENERIC_DEFAULT_DOMAINS = void 0;
21
+ exports.parseMcpListStatus = parseMcpListStatus;
21
22
  exports.parseEnvFile = parseEnvFile;
22
23
  exports.generateAuthToken = generateAuthToken;
23
24
  exports.persistAuthToken = persistAuthToken;
25
+ exports.decideAgentName = decideAgentName;
24
26
  exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
27
+ exports.isEphemeralNpxPath = isEphemeralNpxPath;
25
28
  exports.installSessionStartHook = installSessionStartHook;
26
29
  exports.runInit = runInit;
27
30
  exports.resolveNightlyHour = resolveNightlyHour;
31
+ const paths_js_1 = require("./paths.js");
28
32
  const node_fs_1 = require("node:fs");
29
33
  const node_path_1 = require("node:path");
30
34
  const node_os_1 = require("node:os");
@@ -32,7 +36,8 @@ const node_child_process_1 = require("node:child_process");
32
36
  const node_readline_1 = require("node:readline");
33
37
  const node_crypto_1 = require("node:crypto");
34
38
  const claude_md_js_1 = require("./claude-md.js");
35
- const HICORTEX_HOME = (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
39
+ const context_store_js_1 = require("./context-store.js");
40
+ const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
36
41
  const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "settings.json");
37
42
  const CC_COMMANDS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "commands");
38
43
  const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
@@ -155,6 +160,54 @@ function registerCcMcp(serverUrl) {
155
160
  }
156
161
  // Add MCP tool permissions to settings.json so users don't get prompted
157
162
  allowHicortexTools();
163
+ // Post-install verification (finding #5): a registration that wrote files
164
+ // but didn't actually take can still look "done". Best-effort confirm and,
165
+ // either way, tell the user exactly how to check.
166
+ verifyCcMcp();
167
+ }
168
+ /**
169
+ * Classify `claude mcp list` output for the hicortex entry. Pure (testable):
170
+ * - "missing" — hicortex not listed → registration didn't take
171
+ * - "connected" — listed AND reachable (✓/✔/Connected shown)
172
+ * - "registered" — listed but connection not confirmed (server down / not restarted)
173
+ */
174
+ function parseMcpListStatus(mcpListOutput) {
175
+ // `claude mcp list` prints one line per server: "hicortex: <url> (SSE) - <status>".
176
+ // Anchor on the "hicortex:" line prefix (not a bare word match) so a
177
+ // differently-named MCP like "hicortex-foo" can't be mistaken for our entry,
178
+ // and read status from THAT line only.
179
+ const line = mcpListOutput.split(/\r?\n/).find((l) => /^\s*hicortex:/.test(l));
180
+ if (!line)
181
+ return "missing";
182
+ return /(✓|✔|connected)/i.test(line) ? "connected" : "registered";
183
+ }
184
+ /**
185
+ * Best-effort post-install check that the hicortex MCP is actually registered
186
+ * (and, when reachable, connected). NEVER fails init — if the claude CLI is
187
+ * absent (e.g. registration went via the ~/.claude.json fallback), we can't
188
+ * query it, so we just tell the user how to confirm. Closes the "looks
189
+ * configured but isn't, with no verification" gap.
190
+ */
191
+ function verifyCcMcp() {
192
+ let out;
193
+ try {
194
+ out = (0, node_child_process_1.execSync)("claude mcp list", { encoding: "utf-8", stdio: "pipe" });
195
+ }
196
+ catch {
197
+ console.log(" ℹ After restarting Claude Code, confirm with `claude mcp list` (hicortex should show ✓ Connected).");
198
+ return;
199
+ }
200
+ switch (parseMcpListStatus(out)) {
201
+ case "connected":
202
+ console.log(" ✓ Verified: hicortex MCP registered and connected");
203
+ break;
204
+ case "registered":
205
+ console.log(" ✓ Verified: hicortex MCP registered — restart Claude Code, then `claude mcp list` should show it Connected");
206
+ break;
207
+ case "missing":
208
+ console.log(" ⚠ Could NOT confirm the hicortex MCP registration — run `claude mcp list`; if it's absent, re-run init.");
209
+ break;
210
+ }
158
211
  }
159
212
  function allowHicortexTools() {
160
213
  let settings = {};
@@ -699,6 +752,68 @@ function persistAuthToken(configPath) {
699
752
  (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
700
753
  return { token, generated: true };
701
754
  }
755
+ /**
756
+ * Decide the per-agent context id to persist at init (#179; CC default = global,
757
+ * owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
758
+ * NO hostname default, so an install with no `--agent-name` sends no `?agent=`
759
+ * and shares the global context (one user = one identity across machines).
760
+ *
761
+ * Empty string == unset everywhere: `--agent-name ""` (or whitespace-only) is
762
+ * the explicit way to opt BACK OUT — it CLEARS any existing `agentName` key and
763
+ * returns to global, rather than erroring as an invalid id.
764
+ * - explicit `--agent-name <non-empty>` → the sanitized flag (error if it
765
+ * sanitizes to null, so a bad flag is loud rather than silently ignored);
766
+ * - explicit `--agent-name ""` / whitespace-only → `clear` (remove the key);
767
+ * - no flag but an existing non-empty `agentName` → keep it untouched
768
+ * (non-clobber like persistAuthToken / scaffoldDefaultDomains);
769
+ * - no flag, no (non-empty) existing value → do not write (global by default).
770
+ * Pure + exported for testability; never touches disk.
771
+ */
772
+ function decideAgentName(existing, flag) {
773
+ if (flag !== undefined) {
774
+ // Explicit empty / whitespace-only value → clear back to global (unset).
775
+ if (flag.trim() === "")
776
+ return { write: false, value: null, clear: true };
777
+ const s = (0, context_store_js_1.sanitizeAgentId)(flag);
778
+ if (s === null) {
779
+ return {
780
+ write: false,
781
+ value: null,
782
+ error: `Invalid --agent-name '${flag}'. Must contain letters or digits and sanitize to ^[a-z0-9][a-z0-9_-]*$ (max 64 chars). Pass --agent-name "" to clear it (global context).`,
783
+ };
784
+ }
785
+ return { write: true, value: s };
786
+ }
787
+ if (typeof existing === "string" && existing.trim().length > 0)
788
+ return { write: false, value: existing };
789
+ return { write: false, value: null };
790
+ }
791
+ /** Read config.json, set agentName, write it back. Used by the server path. */
792
+ function writeAgentNameConfig(configPath, value) {
793
+ let config = {};
794
+ try {
795
+ config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
796
+ }
797
+ catch { /* new / unreadable → start fresh */ }
798
+ config.agentName = value;
799
+ (0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
800
+ (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
801
+ }
802
+ /** Read config.json, delete any `agentName` key, write it back (server path). */
803
+ function clearAgentNameConfig(configPath) {
804
+ let config = {};
805
+ try {
806
+ config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
807
+ }
808
+ catch {
809
+ return; /* new / unreadable → nothing to clear */
810
+ }
811
+ if (!("agentName" in config))
812
+ return;
813
+ delete config.agentName;
814
+ (0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
815
+ (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
816
+ }
702
817
  /**
703
818
  * Generic default memory domains scaffolded by server-mode init (issue #150).
704
819
  * Deliberately broad, high-level spheres — an editable STARTING POINT, not a
@@ -789,16 +904,31 @@ function findNpxPath() {
789
904
  return "/usr/local/bin/npx";
790
905
  }
791
906
  }
907
+ /**
908
+ * True if a resolved binary path lives in npm's ephemeral npx cache
909
+ * (`~/.npm/_npx/<hash>/node_modules/.bin/…`). When `hicortex init` is itself
910
+ * run via `npx -y @gamaze/hicortex init`, npx prepends that cache dir to PATH,
911
+ * so `which hicortex` resolves there. npm garbage-collects `_npx`, so any
912
+ * SessionStart hook or nightly timer wired to such a path breaks silently
913
+ * later — the "looks configured but isn't" trap (#176). Never persist it.
914
+ */
915
+ function isEphemeralNpxPath(binPath) {
916
+ return binPath.includes("/_npx/");
917
+ }
792
918
  /**
793
919
  * Resolve the absolute path of the hicortex binary.
794
920
  * For global npm installs (e.g. /usr/bin/hicortex) this is the binary itself.
795
921
  * For dev/npx installs, falls back to `npx <packageSpec> <command>` form.
796
922
  * Returns an array: [binaryPath] for global, or [npxPath, "-y", packageSpec] for npx.
923
+ *
924
+ * A `which hicortex` hit inside the npx cache (#176) is REJECTED — it is
925
+ * ephemeral, so we emit the durable `npx -y <spec>` form instead. This is the
926
+ * standard client path (`npx … init`), where the fix matters most.
797
927
  */
798
928
  function resolveBinaryArgs() {
799
929
  try {
800
930
  const bin = (0, node_child_process_1.execSync)("which hicortex", { encoding: "utf-8" }).trim();
801
- if (bin)
931
+ if (bin && !isEphemeralNpxPath(bin))
802
932
  return [bin];
803
933
  }
804
934
  catch { /* not in PATH as a global binary */ }
@@ -977,7 +1107,7 @@ async function ask(question) {
977
1107
  // ---------------------------------------------------------------------------
978
1108
  async function runInit(options = {}) {
979
1109
  if (options.serverUrl) {
980
- await runClientInit(options.serverUrl);
1110
+ await runClientInit(options.serverUrl, options.agentName);
981
1111
  return;
982
1112
  }
983
1113
  console.log("Hicortex — Setup for Claude Code\n");
@@ -1059,6 +1189,25 @@ async function runInit(options = {}) {
1059
1189
  // Classification activates automatically once an LLM is configured; until
1060
1190
  // then domains sit inert (strict-skip path).
1061
1191
  scaffoldDefaultDomains(configPath);
1192
+ // Per-agent context id (#179): server mode writes it ONLY when the operator
1193
+ // passes --agent-name. Without the flag no agentName is written and the
1194
+ // co-located CC shares the global context (global by default). Explicit flag
1195
+ // overwrites on re-init; `--agent-name ""` clears it back to global.
1196
+ if (options.agentName !== undefined) {
1197
+ const decision = decideAgentName(undefined, options.agentName);
1198
+ if (decision.error) {
1199
+ console.error(` ✗ ${decision.error}`);
1200
+ process.exit(1);
1201
+ }
1202
+ if (decision.clear) {
1203
+ clearAgentNameConfig(configPath);
1204
+ console.log(" ✓ Agent name cleared — global context");
1205
+ }
1206
+ else if (decision.write && decision.value) {
1207
+ writeAgentNameConfig(configPath, decision.value);
1208
+ console.log(` ✓ Agent name set to '${decision.value}'`);
1209
+ }
1210
+ }
1062
1211
  // Install the nightly job (capture via localhost /distill + consolidation).
1063
1212
  // Without it a server-mode install never captures or consolidates — the
1064
1213
  // daemon only serves recall + /distill. Skips if a schedule already exists.
@@ -1120,7 +1269,7 @@ async function runInit(options = {}) {
1120
1269
  // ---------------------------------------------------------------------------
1121
1270
  // Client Mode Init
1122
1271
  // ---------------------------------------------------------------------------
1123
- async function runClientInit(serverUrl) {
1272
+ async function runClientInit(serverUrl, agentName) {
1124
1273
  console.log("Hicortex — Client Mode Setup\n");
1125
1274
  serverUrl = serverUrl.replace(/\/+$/, "");
1126
1275
  // Step 1: Verify server is reachable
@@ -1199,8 +1348,30 @@ async function runClientInit(serverUrl) {
1199
1348
  config.serverUrl = serverUrl;
1200
1349
  if (authToken)
1201
1350
  config.authToken = authToken;
1351
+ // Per-agent context id (#179): explicit opt-in only. Written ONLY when
1352
+ // --agent-name is passed; otherwise no agentName is set and the client shares
1353
+ // the global context (global by default). Re-init keeps an existing value
1354
+ // unless --agent-name is explicit; `--agent-name ""` clears it back to global.
1355
+ const nameDecision = decideAgentName(config.agentName, agentName);
1356
+ if (nameDecision.error) {
1357
+ console.error(` ✗ ${nameDecision.error}`);
1358
+ process.exit(1);
1359
+ }
1360
+ if (nameDecision.clear) {
1361
+ delete config.agentName;
1362
+ console.log(" ✓ Agent name cleared — global context");
1363
+ }
1364
+ else if (nameDecision.write && nameDecision.value) {
1365
+ config.agentName = nameDecision.value;
1366
+ if (agentName && nameDecision.value !== agentName) {
1367
+ console.log(` ℹ Agent name sanitized to '${nameDecision.value}'`);
1368
+ }
1369
+ }
1202
1370
  saveConfig(configPath, config);
1203
1371
  console.log(` ✓ Client config saved to ${configPath}`);
1372
+ if (typeof config.agentName === "string") {
1373
+ console.log(` ✓ Agent name: ${config.agentName}`);
1374
+ }
1204
1375
  // Step 4: Register CC MCP pointing to remote server
1205
1376
  if (authToken) {
1206
1377
  // Write directly with auth header
@@ -21,6 +21,62 @@
21
21
  * parse error) results in silent exit-0. A broken hook must never block a
22
22
  * CC session, and a broken /context fetch must never blank the whole output.
23
23
  */
24
+ /**
25
+ * The GET /context response shape, shared by the CC hook and the OC plugin so
26
+ * their gating cannot drift. `agent`/`mode` are echoed by a 0.13 server whenever
27
+ * `?agent=` was sent (in EVERY mode); a pre-0.13 server omits them.
28
+ */
29
+ export interface ContextResponse {
30
+ sections?: Record<string, string>;
31
+ updated_at?: string;
32
+ clients?: string[];
33
+ agent?: string;
34
+ mode?: string;
35
+ }
36
+ /**
37
+ * Title-case a section name for its heading: split on `-`/`_`, capitalize each
38
+ * word ("user" → "User", "my_notes" → "My Notes").
39
+ * Exported so the OC plugin (index.ts) renders the `## Context` block
40
+ * identically to the CC hook rather than duplicating the logic.
41
+ */
42
+ export declare function titleCaseSection(name: string): string;
43
+ /**
44
+ * Stable section ordering: `user` first, then `rules` (the seeded primary
45
+ * sections, spec §8), then every other section alphabetically. Server-side
46
+ * enumeration order (readdirSync) is FS-dependent, so we sort here for a
47
+ * deterministic injection block. Exported for reuse by the OC plugin.
48
+ */
49
+ export declare function orderSectionNames(names: string[]): string[];
50
+ /**
51
+ * Render the `## Context` block from a resolved section map, or null when there
52
+ * is nothing to inject (no sections, or every section blank after trimming).
53
+ * Pure — no gating, no I/O. Shared verbatim by the CC hook and the OC plugin so
54
+ * both harnesses emit an identical block. Sections are ordered (user, rules,
55
+ * then alphabetical) and rendered under title-cased `###` headings.
56
+ */
57
+ export declare function renderContextBlock(sections: Record<string, string>): string | null;
58
+ /**
59
+ * Gate a GET /context response and render the `## Context` block, or null when
60
+ * nothing should be injected: `harness` not in the server-resolved `clients`,
61
+ * an empty/blank section set, or — when `requireAgentEcho` — a response that
62
+ * does not echo `agent`. The SINGLE gate used by both CC and OC so the two can
63
+ * never drift (the Python Hermes plugin `provider.py::_context_block` mirrors
64
+ * this logic — keep them in sync).
65
+ *
66
+ * `requireAgentEcho` is the old-server guard, and it is the CALLER's decision:
67
+ * - OC passes `agentId !== null` — when it actually sent an id, a 0.12 server
68
+ * that ignores `?agent=` (200 global, no echo) must NOT leak global context
69
+ * into every persona; on a bare fetch (no id) the guard is off (amendment
70
+ * A2).
71
+ * - CC passes `false` ALWAYS and deliberately (see the call site): a thin CC
72
+ * client auto-upgrades via npx BEFORE bedrock does, so during the upgrade
73
+ * window it talks to a 0.12 server that cannot hold ANY per-agent config —
74
+ * global IS the operator's intended state there, and a guard would instead
75
+ * blank ALL context for every CC session in that window.
76
+ */
77
+ export declare function gateAndRenderContext(data: ContextResponse, harness: string, opts: {
78
+ requireAgentEcho: boolean;
79
+ }): string | null;
24
80
  /**
25
81
  * Fetch context + lessons concurrently and return the combined Markdown block,
26
82
  * or null when neither yields anything (nothing to inject; caller prints