@essentialai/cogent-plugin 3.20.7 → 3.21.1

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.
@@ -3,6 +3,7 @@
3
3
  // defaultCredentialPath. Kept separate from the runtime (check-on-stop.mjs) so it is
4
4
  // unit-tested (plugin/hooks/check-on-stop.lib.test.mjs, gate-counted via vitest include).
5
5
  import crypto from "node:crypto";
6
+ import os from "node:os";
6
7
  import path from "node:path";
7
8
 
8
9
  /** MUST equal src/services/auto-relay.ts:629 — human-origin broadcasts only. */
@@ -25,6 +26,64 @@ export function credHashForCwd(cwd) {
25
26
  return crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16);
26
27
  }
27
28
 
29
+ /**
30
+ * MUST equal src/services/agent-sessions.ts rootFromTranscriptPath.
31
+ *
32
+ * Recovers the agent's config ROOT from an absolute transcript path by STRUCTURE
33
+ * (`<root>/projects/<encoded-cwd>/<id>.jsonl`) rather than by matching a known
34
+ * prefix — which is the whole point: a root nobody has ever seen still resolves.
35
+ */
36
+ export function rootFromTranscriptPath(transcriptPath) {
37
+ if (!transcriptPath) return null;
38
+ const parts = path.resolve(transcriptPath).split(path.sep);
39
+ const i = parts.lastIndexOf("projects");
40
+ if (i <= 0) return null;
41
+ return parts.slice(0, i).join(path.sep) || path.sep;
42
+ }
43
+
44
+ /** MUST equal src/services/agent-sessions.ts agentSessionPath. */
45
+ export function agentSessionRecordPath(cwd, platform) {
46
+ return path.join(
47
+ os.homedir(),
48
+ ".cogent",
49
+ "agent-sessions",
50
+ `${credHashForCwd(cwd)}.${platform}.json`,
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Turn a hook payload into a learned session record — the AUTHORITATIVE answer to
56
+ * "where does this agent's transcript live". Pure; the caller does the write.
57
+ *
58
+ * The host hands every hook `cwd`, `session_id` and `transcript_path`. Persisting
59
+ * that trio removes both guesses the wake resolver used to make (which config root,
60
+ * and how the cwd was encoded into a directory name), so a relocated or otherwise
61
+ * unusual agent installation resolves without any configuration.
62
+ *
63
+ * `platform` is inferred STRUCTURALLY so this file can stay byte-identical across
64
+ * the Claude and Codex plugins: only Claude Code lays transcripts out under a
65
+ * `projects/` segment, so a recoverable root means Claude Code.
66
+ *
67
+ * Returns null when the payload lacks any of the three fields (some hosts/events
68
+ * omit them) — a partial record is worse than none, because it would look
69
+ * authoritative while pointing nowhere.
70
+ */
71
+ export function buildAgentSessionRecord(payload, nowIso) {
72
+ const cwd = payload?.cwd;
73
+ const sessionId = payload?.session_id;
74
+ const transcriptPath = payload?.transcript_path;
75
+ if (!cwd || !sessionId || !transcriptPath) return null;
76
+ const root = rootFromTranscriptPath(transcriptPath);
77
+ return {
78
+ cwd: path.resolve(cwd),
79
+ sessionId: String(sessionId),
80
+ transcriptPath: path.resolve(transcriptPath),
81
+ ...(root ? { root } : {}),
82
+ platform: root ? "cc" : "codex",
83
+ updatedAt: nowIso,
84
+ };
85
+ }
86
+
28
87
  function isForMe(msg, me, scope) {
29
88
  if (msg.toPeerId === me) return scope.directed;
30
89
  if (msg.toPeerId === "*") {
@@ -18,6 +18,8 @@ import {
18
18
  buildBlockReason,
19
19
  credHashForCwd,
20
20
  activeInFlightIds,
21
+ agentSessionRecordPath,
22
+ buildAgentSessionRecord,
21
23
  } from "./check-on-stop.lib.mjs";
22
24
 
23
25
  const HTTP_TIMEOUT_MS = 5000; // < the 8s hook timeout; only bites on a degraded relay
@@ -104,9 +106,36 @@ async function peerIdFromState(env, cwd) {
104
106
  return byCwd ? byCwd.peerId : undefined;
105
107
  }
106
108
 
109
+ /**
110
+ * Refresh the learned session record from this payload — the authoritative answer
111
+ * to "where does this agent's transcript live" (see record-session.mjs for the
112
+ * full rationale). Done here as well as on SessionStart because this hook already
113
+ * receives the payload on every turn, so the record stays correct for free even
114
+ * if the session was started before the plugin was installed. Best-effort and
115
+ * completely silent: it must never affect whether this hook blocks.
116
+ */
117
+ async function refreshSessionRecord(input) {
118
+ try {
119
+ const record = buildAgentSessionRecord(input, new Date().toISOString());
120
+ if (!record) return;
121
+ const target = agentSessionRecordPath(record.cwd, record.platform);
122
+ await fsp.mkdir(path.dirname(target), { recursive: true });
123
+ const tmp = `${target}.${process.pid}.tmp`;
124
+ await fsp.writeFile(tmp, JSON.stringify(record, null, 2), "utf-8");
125
+ await fsp.rename(tmp, target);
126
+ } catch {
127
+ // learning is an optimisation, never a reason to change this hook's outcome
128
+ }
129
+ }
130
+
107
131
  async function main() {
108
132
  const input = await readStdin();
109
133
 
134
+ // Record BEFORE the loop guard: a continuation turn still carries a valid
135
+ // transcript path, and skipping it would lose the refresh on exactly the turns
136
+ // where auto-wake is most active.
137
+ await refreshSessionRecord(input);
138
+
110
139
  // Loop guard: if we already caused this continuation, never block again.
111
140
  if (input && input.stop_hook_active === true) return process.exit(0);
112
141
 
package/hooks/hooks.json CHANGED
@@ -1,5 +1,17 @@
1
1
  {
2
2
  "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/record-session.mjs\"",
10
+ "timeout": 5
11
+ }
12
+ ]
13
+ }
14
+ ],
3
15
  "Stop": [
4
16
  {
5
17
  "matcher": "",
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // Cogent record-session. Runs on SessionStart as a SEPARATE short-lived process
3
+ // (the host spawns it; it is NOT the bridge). Writes one small file and exits 0.
4
+ // ALWAYS silent — it prints nothing on success or failure, so it can never block,
5
+ // delay, or annotate a turn.
6
+ //
7
+ // 🔴 WHY THIS EXISTS. Auto-wake has to find the agent's session transcript, and it
8
+ // used to DERIVE the location:
9
+ //
10
+ // <config root> / projects / <cwd with non-alphanumerics turned into dashes>
11
+ //
12
+ // Both halves are guesses. The root was `CLAUDE_CONFIG_DIR` or a hardcoded
13
+ // `~/.claude`; the cwd half assumed the exact canonical spelling of the directory.
14
+ // Users relocate agent state (small system disks, synced or encrypted homes,
15
+ // multi-account machines, containers) and the env var does not always reach the
16
+ // process that needs it. Either guess being wrong produced the same dead end:
17
+ // "no candidate sessions found for cwd", and a refused wake.
18
+ //
19
+ // The host already knows the answer and hands it to every hook: `cwd`,
20
+ // `session_id` and `transcript_path`. Recording that trio turns discovery from
21
+ // inference into a lookup, and works with ANY custom location without config.
22
+ //
23
+ // Node built-ins only (fs, os, path). Byte-identical between plugin/hooks/ and
24
+ // codex-plugin/hooks/ — enforced by scripts/wake-race-test.mjs assertContract().
25
+ import fs from "node:fs/promises";
26
+ import path from "node:path";
27
+ import {
28
+ agentSessionRecordPath,
29
+ buildAgentSessionRecord,
30
+ } from "./check-on-stop.lib.mjs";
31
+
32
+ async function readStdin() {
33
+ const chunks = [];
34
+ for await (const c of process.stdin) chunks.push(c);
35
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
36
+ if (!raw) return {};
37
+ try {
38
+ return JSON.parse(raw);
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Atomic temp+rename: the bridge may read this file at any moment, and a
46
+ * half-written record would look authoritative while pointing nowhere. The
47
+ * temp name carries the pid so two concurrent hooks cannot collide.
48
+ */
49
+ export async function persistRecord(record) {
50
+ const target = agentSessionRecordPath(record.cwd, record.platform);
51
+ await fs.mkdir(path.dirname(target), { recursive: true });
52
+ const tmp = `${target}.${process.pid}.tmp`;
53
+ await fs.writeFile(tmp, JSON.stringify(record, null, 2), "utf-8");
54
+ await fs.rename(tmp, target);
55
+ return target;
56
+ }
57
+
58
+ async function main() {
59
+ const input = await readStdin();
60
+ const record = buildAgentSessionRecord(input, new Date().toISOString());
61
+ // No transcript_path in this payload → nothing authoritative to record. The
62
+ // resolver's discovery layer still covers this case; staying silent is correct.
63
+ if (!record) return;
64
+ await persistRecord(record);
65
+ }
66
+
67
+ // Best-effort by construction: recording is an optimisation for a LATER wake, so
68
+ // nothing here may ever fail a turn. Any error exits 0 silently.
69
+ main().then(
70
+ () => process.exit(0),
71
+ () => process.exit(0),
72
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@essentialai/cogent-plugin",
3
- "version": "3.20.7",
3
+ "version": "3.21.1",
4
4
  "description": "Cogent — Claude Code plugin (skills + slash-commands + MCP server) for the cross-agent comms fabric.",
5
5
  "author": { "name": "Essential AI Solutions Ltd.", "url": "https://essentialai.uk" },
6
6
  "homepage": "https://cogent.tools",