@remnic/plugin-claude-code 9.45.4 → 9.46.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.
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "name": "remnic",
3
3
  "description": "Universal memory for AI agents — automatic recall, observation, and cross-agent knowledge sharing",
4
- "version": "9.45.4",
5
- "author": "Joshua Warren",
4
+ "version": "9.46.0",
5
+ "author": {
6
+ "name": "Joshua Warren"
7
+ },
6
8
  "homepage": "https://github.com/joshuaswarren/remnic",
7
9
  "repository": "https://github.com/joshuaswarren/remnic"
8
10
  }
package/README.md CHANGED
@@ -68,6 +68,38 @@ The plugin expects a Remnic daemon reachable at `http://localhost:4318/mcp` with
68
68
 
69
69
  Replace `{{REMNIC_TOKEN}}` with a token minted via `remnic token generate <connector-id>`.
70
70
 
71
+ ### Hook credentials
72
+
73
+ The hook runner resolves its own bearer token independently of the MCP block
74
+ above, in this order:
75
+
76
+ 1. `~/.remnic/tokens.json`, then legacy `~/.engram/tokens.json` — the
77
+ `claude-code` connector entry first, then `openclaw`.
78
+ 2. `OPENCLAW_REMNIC_ACCESS_TOKEN`, then `REMNIC_AUTH_TOKEN`.
79
+ 3. Legacy aliases: `OPENCLAW_ENGRAM_ACCESS_TOKEN`, then `ENGRAM_AUTH_TOKEN`.
80
+
81
+ Current names outrank legacy ones, so a leftover pre-rename value cannot
82
+ shadow the credential the daemon is actually running with. `REMNIC_AUTH_TOKEN`
83
+ covers the standalone-server setup, which authenticates the daemon with that
84
+ variable and never mints a connector token. Against an auth-gated daemon the
85
+ hook needs one of these: every route, including `/engram/v1/health`, returns
86
+ 401 without a bearer, so an unauthenticated hook reports `daemon not running`
87
+ and silently skips auto-recall and auto-observe.
88
+
89
+ `REMNIC_HOOK_TOKEN` is not part of this chain — it is an internal channel the
90
+ foreground hook uses to hand its already-resolved token to the detached
91
+ observe worker, so the worker does not re-read the token store. Nothing in
92
+ the foreground path reads it.
93
+
94
+ ### Namespace targeting
95
+
96
+ `REMNIC_NAMESPACE`, then legacy `ENGRAM_NAMESPACE` — optional. When set, the
97
+ hook adds the namespace to the **request body** of recall and observe (the
98
+ REST surface reads it from the body, not a header). An explicit
99
+ `body.namespace` already on the request wins. Unset → the daemon resolves the
100
+ namespace for the `claude-code` client id, which on a namespaced daemon is the
101
+ adapter's own empty namespace, so recall returns nothing.
102
+
71
103
  ## Agent note
72
104
 
73
105
  If you're an AI agent scaffolding a Claude Code integration: **do not** hand-edit hook scripts in a user's `~/.claude/` tree. The full setup has two components:
@@ -169,6 +169,15 @@ function onPath(bin) {
169
169
  }
170
170
 
171
171
  // ── token resolution (per-plugin token store, then env) ────────────────────
172
+ // Env precedence is primary-before-legacy per AGENTS.md §9: the two current
173
+ // names (OPENCLAW_REMNIC_ACCESS_TOKEN, REMNIC_AUTH_TOKEN) before the two
174
+ // legacy aliases (OPENCLAW_ENGRAM_ACCESS_TOKEN, ENGRAM_AUTH_TOKEN), so a
175
+ // stale leftover from a pre-rename install cannot outrank the credential the
176
+ // daemon is actually running with. REMNIC_AUTH_TOKEN matters because the
177
+ // documented standalone-server setup authenticates the daemon with it alone
178
+ // and never mints a connector token — without it the health probe 401s and
179
+ // the hook wrongly reports "daemon not running", silently skipping
180
+ // recall/observe.
172
181
  function resolveToken() {
173
182
  for (const file of [
174
183
  path.join(HOME, ".remnic", "tokens.json"),
@@ -192,7 +201,9 @@ function resolveToken() {
192
201
  }
193
202
  return (
194
203
  process.env.OPENCLAW_REMNIC_ACCESS_TOKEN ||
204
+ process.env.REMNIC_AUTH_TOKEN ||
195
205
  process.env.OPENCLAW_ENGRAM_ACCESS_TOKEN ||
206
+ process.env.ENGRAM_AUTH_TOKEN ||
196
207
  ""
197
208
  );
198
209
  }
@@ -203,7 +214,19 @@ function httpPost(urlPath, token, bodyObj, timeoutMs) {
203
214
  return new Promise((resolve) => {
204
215
  let data;
205
216
  try {
206
- data = Buffer.from(JSON.stringify(bodyObj), "utf8");
217
+ // Namespace targeting for namespaced daemons: when REMNIC_NAMESPACE (or
218
+ // ENGRAM_NAMESPACE) is set, include it in the request body. On the REST
219
+ // surface the namespace is read from the body, not a header, and the
220
+ // "claude-code" client id otherwise resolves to the adapter's own
221
+ // (empty) namespace — so recall/observe silently return nothing. Opt-in:
222
+ // when the env var is unset this is a no-op and behaviour is unchanged.
223
+ // An explicit bodyObj.namespace still takes precedence.
224
+ const ns = process.env.REMNIC_NAMESPACE || process.env.ENGRAM_NAMESPACE;
225
+ const outBody =
226
+ ns && bodyObj && typeof bodyObj === "object" && !Array.isArray(bodyObj)
227
+ ? { namespace: ns, ...bodyObj }
228
+ : bodyObj;
229
+ data = Buffer.from(JSON.stringify(outBody), "utf8");
207
230
  } catch {
208
231
  resolve({ ok: false, status: 0, body: "" });
209
232
  return;
@@ -246,10 +269,27 @@ function httpPost(urlPath, token, bodyObj, timeoutMs) {
246
269
  });
247
270
  }
248
271
 
249
- function httpHealthy(timeoutMs) {
272
+ // `token` is the caller's already-resolved credential, NOT a second
273
+ // resolveToken() call: the probe must authenticate with the exact bearer the
274
+ // operation it gates will send. Re-resolving could pick up a rotated
275
+ // tokens.json (or an inherited REMNIC_HOOK_TOKEN the foreground handlers
276
+ // ignore) and green-light a probe whose recall then 401s.
277
+ //
278
+ // When the daemon has an auth token configured (REMNIC_AUTH_TOKEN), every
279
+ // route — including /engram/v1/health — returns 401 to unauthenticated
280
+ // requests, so an unauthenticated probe makes the hook wrongly report
281
+ // "daemon not running" and skip recall/observe. Unauthenticated daemons
282
+ // ignore the header.
283
+ function httpHealthy(timeoutMs, token) {
250
284
  return new Promise((resolve) => {
251
285
  const req = http.request(
252
- { host: HOST, port: PORT, path: "/engram/v1/health", method: "GET" },
286
+ {
287
+ host: HOST,
288
+ port: PORT,
289
+ path: "/engram/v1/health",
290
+ method: "GET",
291
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
292
+ },
253
293
  (res) => {
254
294
  res.resume();
255
295
  resolve(res.statusCode >= 200 && res.statusCode < 300);
@@ -551,7 +591,7 @@ async function handleSessionStart(input, token, log) {
551
591
  log(`session=${sessionId} project=${projectName} coding-context=${codingContext ? "yes" : ""}`);
552
592
 
553
593
  // Health check — start daemon if not running.
554
- if (!(await httpHealthy(2000))) {
594
+ if (!(await httpHealthy(2000, token))) {
555
595
  log("daemon not responding, attempting start...");
556
596
  // Try `remnic` first, fall through to legacy `engram` when only the older
557
597
  // CLI is on PATH. spawn() emits ENOENT *asynchronously* via 'error', so we
@@ -575,7 +615,7 @@ async function handleSessionStart(input, token, log) {
575
615
  }
576
616
  }
577
617
  await new Promise((r) => setTimeout(r, 2000));
578
- if (!(await httpHealthy(2000))) {
618
+ if (!(await httpHealthy(2000, token))) {
579
619
  log("daemon still not responding after start attempt");
580
620
  emit({
581
621
  continue: true,
@@ -62,14 +62,20 @@ function runHook(event, input, { port, home, env = {} } = {}) {
62
62
  XDG_STATE_HOME: path.join(home, "state"),
63
63
  REMNIC_HOST: "127.0.0.1",
64
64
  REMNIC_PORT: String(port),
65
- // Default to an env token unless a test overrides it. When a test
66
- // asks for "no token", explicitly clear BOTH legacy env vars so a
67
- // value inherited from the parent shell (e.g.
68
- // OPENCLAW_ENGRAM_ACCESS_TOKEN in a developer's environment) cannot
69
- // leak through `...process.env` and make the no-token path take a
70
- // token (#1518 test isolation).
65
+ // Default to an env token unless a test overrides it. Every env name
66
+ // resolveToken() consults is pinned here including the canonical
67
+ // REMNIC_AUTH_TOKEN / ENGRAM_AUTH_TOKEN pair so a value inherited
68
+ // from the parent shell (a developer or CI runner that authenticates
69
+ // a local daemon) cannot leak through `...process.env` and make the
70
+ // no-token path take a token (#1518 test isolation). Tests that want
71
+ // a specific name set it through `env.extra`, which is spread last.
71
72
  OPENCLAW_REMNIC_ACCESS_TOKEN: env.token === null ? "" : env.token || "test-token",
72
- OPENCLAW_ENGRAM_ACCESS_TOKEN: env.token === null ? "" : "",
73
+ OPENCLAW_ENGRAM_ACCESS_TOKEN: "",
74
+ REMNIC_AUTH_TOKEN: "",
75
+ ENGRAM_AUTH_TOKEN: "",
76
+ // Internal worker-propagation channel, not a user credential. Pinned
77
+ // so an inherited value cannot reach the detached observe worker.
78
+ REMNIC_HOOK_TOKEN: "",
73
79
  ...env.extra,
74
80
  },
75
81
  stdio: ["pipe", "pipe", "pipe"],
@@ -135,6 +141,139 @@ test("session-start: healthy server returns recall context with codingContext cl
135
141
  }
136
142
  });
137
143
 
144
+ test("session-start: health probe carries the bearer token (auth-gated daemons)", async () => {
145
+ const home = mkHome();
146
+ let healthAuth = "unset";
147
+ const { server, port } = await startServer((req, res) => {
148
+ if (req.url === "/engram/v1/health") {
149
+ healthAuth = req.headers.authorization || null;
150
+ return res.writeHead(200).end("ok");
151
+ }
152
+ if (req.url === "/engram/v1/recall") {
153
+ return res
154
+ .writeHead(200, { "Content-Type": "application/json" })
155
+ .end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
156
+ }
157
+ res.writeHead(404).end();
158
+ });
159
+ try {
160
+ await runHook("session-start", { session_id: "s1", cwd: home }, { port, home });
161
+ // Without this header an auth-gated daemon 401s the probe and the hook
162
+ // reports "daemon not running", silently skipping recall/observe.
163
+ assert.equal(healthAuth, "Bearer test-token");
164
+ } finally {
165
+ server.close();
166
+ fs.rmSync(home, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test("recall body targets REMNIC_NAMESPACE when set, and omits namespace when unset", async () => {
171
+ const mk = () =>
172
+ startServer((req, res) => {
173
+ if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
174
+ if (req.url === "/engram/v1/recall") {
175
+ return res
176
+ .writeHead(200, { "Content-Type": "application/json" })
177
+ .end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
178
+ }
179
+ res.writeHead(404).end();
180
+ });
181
+
182
+ // set → namespace travels in the request body (REST reads it from the body,
183
+ // not a header; the "claude-code" client id otherwise resolves to the
184
+ // adapter's own empty namespace and recall returns nothing).
185
+ {
186
+ const home = mkHome();
187
+ const { server, port, calls } = await mk();
188
+ try {
189
+ await runHook(
190
+ "session-start",
191
+ { session_id: "s1", cwd: home },
192
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "team-shared" } } },
193
+ );
194
+ const recall = calls.find((c) => c.url === "/engram/v1/recall");
195
+ assert.ok(recall, "recall was called");
196
+ assert.equal(recall.body.namespace, "team-shared");
197
+ } finally {
198
+ server.close();
199
+ fs.rmSync(home, { recursive: true, force: true });
200
+ }
201
+ }
202
+
203
+ // unset → opt-in no-op, no namespace field (behaviour unchanged for existing users).
204
+ // Explicitly clear both env vars so a value inherited from the developer's
205
+ // shell (`...process.env`) can't leak in and make this path look "set".
206
+ {
207
+ const home = mkHome();
208
+ const { server, port, calls } = await mk();
209
+ try {
210
+ await runHook(
211
+ "session-start",
212
+ { session_id: "s1", cwd: home },
213
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "", ENGRAM_NAMESPACE: "" } } },
214
+ );
215
+ const recall = calls.find((c) => c.url === "/engram/v1/recall");
216
+ assert.ok(recall, "recall was called");
217
+ assert.equal("namespace" in recall.body, false);
218
+ } finally {
219
+ server.close();
220
+ fs.rmSync(home, { recursive: true, force: true });
221
+ }
222
+ }
223
+ });
224
+
225
+ test("namespace targeting: ENGRAM_NAMESPACE fallback (recall) and observe body both honor it", async () => {
226
+ // ENGRAM_NAMESPACE is the fallback when REMNIC_NAMESPACE is unset → recall body carries it.
227
+ {
228
+ const home = mkHome();
229
+ const { server, port, calls } = await startServer((req, res) => {
230
+ if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
231
+ if (req.url === "/engram/v1/recall") {
232
+ return res
233
+ .writeHead(200, { "Content-Type": "application/json" })
234
+ .end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
235
+ }
236
+ res.writeHead(404).end();
237
+ });
238
+ try {
239
+ await runHook(
240
+ "session-start",
241
+ { session_id: "s1", cwd: home },
242
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "", ENGRAM_NAMESPACE: "legacy-ns" } } },
243
+ );
244
+ const recall = calls.find((c) => c.url === "/engram/v1/recall");
245
+ assert.ok(recall, "recall was called");
246
+ assert.equal(recall.body.namespace, "legacy-ns");
247
+ } finally {
248
+ server.close();
249
+ fs.rmSync(home, { recursive: true, force: true });
250
+ }
251
+ }
252
+
253
+ // observe uses the same httpPost path, so its body carries the namespace too.
254
+ {
255
+ const home = mkHome();
256
+ const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
257
+ try {
258
+ const tpath = transcript(home, [
259
+ { role: "user", content: "first" },
260
+ { role: "assistant", content: "second" },
261
+ ]);
262
+ await runHook(
263
+ "__observe-worker__",
264
+ JSON.stringify({ session_id: "sObsNs", transcript_path: tpath }),
265
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "team-shared", ENGRAM_NAMESPACE: "" } } },
266
+ );
267
+ const observe = calls.find((c) => c.url === "/engram/v1/observe");
268
+ assert.ok(observe, "observe was called");
269
+ assert.equal(observe.body.namespace, "team-shared");
270
+ } finally {
271
+ server.close();
272
+ fs.rmSync(home, { recursive: true, force: true });
273
+ }
274
+ }
275
+ });
276
+
138
277
  test("session-start: falls back to minimal mode when full recall fails", async () => {
139
278
  const home = mkHome();
140
279
  let recallHits = 0;
@@ -476,6 +615,122 @@ test("token resolution: legacy ~/.engram/tokens.json is the read fallback", asyn
476
615
  }
477
616
  });
478
617
 
618
+ // The documented standalone-server setup (docs/guides/standalone-server.md,
619
+ // README) authenticates the daemon with REMNIC_AUTH_TOKEN and never mints a
620
+ // connector token, so nothing lands in ~/.remnic/tokens.json and none of the
621
+ // OPENCLAW_* names are set. Before the canonical pair was added to
622
+ // resolveToken(), that shape sent an unauthenticated health probe, took a 401,
623
+ // and reported "daemon not running" — silently skipping recall and observe.
624
+ function authEnvServer() {
625
+ const seen = { health: "unset", recall: "unset" };
626
+ return startServer((req, res) => {
627
+ if (req.url === "/engram/v1/health") {
628
+ seen.health = req.headers.authorization || null;
629
+ return res.writeHead(200).end("ok");
630
+ }
631
+ if (req.url === "/engram/v1/recall") {
632
+ seen.recall = req.headers.authorization || null;
633
+ return res
634
+ .writeHead(200, { "Content-Type": "application/json" })
635
+ .end(JSON.stringify({ context: "ctx", count: 1, mode: "auto" }));
636
+ }
637
+ res.writeHead(404).end();
638
+ }).then((started) => ({ ...started, seen }));
639
+ }
640
+
641
+ for (const name of ["REMNIC_AUTH_TOKEN", "ENGRAM_AUTH_TOKEN"]) {
642
+ test(`token resolution: ${name} authenticates health and recall (no token store)`, async () => {
643
+ const home = mkHome();
644
+ const { server, port, seen } = await authEnvServer();
645
+ try {
646
+ const res = await runHook(
647
+ "session-start",
648
+ { session_id: "sEnv", cwd: home },
649
+ { port, home, env: { token: null, extra: { [name]: "operator-secret" } } },
650
+ );
651
+ assert.equal(seen.health, "Bearer operator-secret");
652
+ assert.equal(seen.recall, "Bearer operator-secret");
653
+ assert.doesNotMatch(
654
+ res.json.hookSpecificOutput.additionalContext,
655
+ /daemon not running/,
656
+ "an authenticated daemon must not be reported as down",
657
+ );
658
+ } finally {
659
+ server.close();
660
+ fs.rmSync(home, { recursive: true, force: true });
661
+ }
662
+ });
663
+ }
664
+
665
+ test("token resolution: OPENCLAW_REMNIC_ACCESS_TOKEN outranks REMNIC_AUTH_TOKEN", async () => {
666
+ const home = mkHome();
667
+ const { server, port, seen } = await authEnvServer();
668
+ try {
669
+ await runHook(
670
+ "session-start",
671
+ { session_id: "sPrec", cwd: home },
672
+ { port, home, env: { token: "connector-tok", extra: { REMNIC_AUTH_TOKEN: "operator-secret" } } },
673
+ );
674
+ // Both are current names; the connector-scoped one stays first, so an
675
+ // install that already worked keeps its existing credential.
676
+ assert.equal(seen.health, "Bearer connector-tok");
677
+ } finally {
678
+ server.close();
679
+ fs.rmSync(home, { recursive: true, force: true });
680
+ }
681
+ });
682
+
683
+ test("token resolution: REMNIC_AUTH_TOKEN outranks the legacy OPENCLAW_ENGRAM_ACCESS_TOKEN", async () => {
684
+ const home = mkHome();
685
+ const { server, port, seen } = await authEnvServer();
686
+ try {
687
+ await runHook(
688
+ "session-start",
689
+ { session_id: "sLegacyPrec", cwd: home },
690
+ {
691
+ port,
692
+ home,
693
+ env: {
694
+ token: null,
695
+ extra: {
696
+ OPENCLAW_ENGRAM_ACCESS_TOKEN: "stale-legacy-tok",
697
+ REMNIC_AUTH_TOKEN: "operator-secret",
698
+ },
699
+ },
700
+ },
701
+ );
702
+ // Primary-before-legacy (AGENTS.md §9). A migrated deployment often still
703
+ // exports the pre-rename alias; if that stale value outranked the token
704
+ // the daemon actually runs with, the probe would 401 and land back on
705
+ // "daemon not running".
706
+ assert.equal(seen.health, "Bearer operator-secret");
707
+ } finally {
708
+ server.close();
709
+ fs.rmSync(home, { recursive: true, force: true });
710
+ }
711
+ });
712
+
713
+ test("token resolution: tokens.json still outranks REMNIC_AUTH_TOKEN", async () => {
714
+ const home = mkHome();
715
+ fs.mkdirSync(path.join(home, ".remnic"), { recursive: true });
716
+ fs.writeFileSync(
717
+ path.join(home, ".remnic", "tokens.json"),
718
+ JSON.stringify({ tokens: [{ connector: "claude-code", token: "cc-tok" }] }),
719
+ );
720
+ const { server, port, seen } = await authEnvServer();
721
+ try {
722
+ await runHook(
723
+ "session-start",
724
+ { session_id: "sStore", cwd: home },
725
+ { port, home, env: { token: null, extra: { REMNIC_AUTH_TOKEN: "operator-secret" } } },
726
+ );
727
+ assert.equal(seen.health, "Bearer cc-tok");
728
+ } finally {
729
+ server.close();
730
+ fs.rmSync(home, { recursive: true, force: true });
731
+ }
732
+ });
733
+
479
734
  // ── hooks.json Windows parity ─────────────────────────────────────────────
480
735
 
481
736
  test("hooks.json: every event uses cross-platform exec form with ${CLAUDE_PLUGIN_ROOT} (#1518)", () => {
@@ -531,6 +786,23 @@ test("hooks.json: every event uses cross-platform exec form with ${CLAUDE_PLUGIN
531
786
  }
532
787
  });
533
788
 
789
+ test("plugin.json: manifest conforms to the Claude Code plugin schema (author is an object)", () => {
790
+ const manifest = JSON.parse(
791
+ fs.readFileSync(path.join(__dirname, "..", "..", ".claude-plugin", "plugin.json"), "utf8"),
792
+ );
793
+ // Claude Code's plugin-manifest validator rejects a string `author`
794
+ // ("Invalid input: expected object, received string"), which blocks
795
+ // installing this plugin from a Claude Code marketplace. `author` must be an
796
+ // object carrying at least a `name`.
797
+ assert.equal(typeof manifest.author, "object", "author must be an object, not a string");
798
+ assert.ok(manifest.author !== null && !Array.isArray(manifest.author), "author must be a plain object");
799
+ assert.equal(typeof manifest.author.name, "string", "author.name must be a string");
800
+ assert.ok(manifest.author.name.length > 0, "author.name must be non-empty");
801
+ // Sanity-check the other required identity fields while we're here.
802
+ assert.equal(typeof manifest.name, "string", "name must be a string");
803
+ assert.equal(typeof manifest.version, "string", "version must be a string");
804
+ });
805
+
534
806
  test("runner source: payload fields never reach a shell — spawn uses fixed argv (#1518 guard shell interpolation)", () => {
535
807
  const src = fs.readFileSync(RUNNER, "utf8");
536
808
  // Every spawn/spawnSync must use a fixed literal argument array, never a
@@ -579,3 +851,49 @@ test("runner source: path inputs are type-validated before use (#1518 validate p
579
851
  "transcript_path must be defaulted to empty string",
580
852
  );
581
853
  });
854
+
855
+ test("token resolution: REMNIC_AUTH_TOKEN outranks ENGRAM_AUTH_TOKEN when both are set", async () => {
856
+ const home = mkHome();
857
+ const { server, port, seen } = await authEnvServer();
858
+ try {
859
+ await runHook(
860
+ "session-start",
861
+ { session_id: "sBothCanonical", cwd: home },
862
+ {
863
+ port,
864
+ home,
865
+ env: {
866
+ token: null,
867
+ extra: { REMNIC_AUTH_TOKEN: "current-tok", ENGRAM_AUTH_TOKEN: "legacy-tok" },
868
+ },
869
+ },
870
+ );
871
+ assert.equal(seen.health, "Bearer current-tok");
872
+ assert.equal(seen.recall, "Bearer current-tok");
873
+ } finally {
874
+ server.close();
875
+ fs.rmSync(home, { recursive: true, force: true });
876
+ }
877
+ });
878
+
879
+ test("health probe uses the same bearer as the operation it gates", async () => {
880
+ const home = mkHome();
881
+ const { server, port, seen } = await authEnvServer();
882
+ try {
883
+ // REMNIC_HOOK_TOKEN is the detached observe worker's internal propagation
884
+ // channel; the foreground handlers never read it. If the probe consulted
885
+ // it, an inherited value would authenticate health with one bearer while
886
+ // recall sent another — a false "healthy" followed by a 401, or the
887
+ // reverse. Probe and operation must carry one snapshot.
888
+ await runHook(
889
+ "session-start",
890
+ { session_id: "sSnapshot", cwd: home },
891
+ { port, home, env: { extra: { REMNIC_HOOK_TOKEN: "inherited-worker-tok" } } },
892
+ );
893
+ assert.equal(seen.health, "Bearer test-token");
894
+ assert.equal(seen.recall, seen.health);
895
+ } finally {
896
+ server.close();
897
+ fs.rmSync(home, { recursive: true, force: true });
898
+ }
899
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/plugin-claude-code",
3
- "version": "9.45.4",
3
+ "version": "9.46.0",
4
4
  "description": "Remnic memory plugin for Claude Code — hooks, skills, MCP integration",
5
5
  "type": "module",
6
6
  "license": "MIT",