@polygraph/codex-plugin 0.4.44 → 0.4.45

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,6 +1,6 @@
1
1
  {
2
2
  "name": "polygraph",
3
- "version": "0.4.44",
3
+ "version": "0.4.45",
4
4
  "description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
5
5
  "author": {
6
6
  "name": "Narwhal Technologies Inc",
package/hooks/hooks.json CHANGED
@@ -15,16 +15,6 @@
15
15
  "statusMessage": "Recording Polygraph agent capture mapping"
16
16
  }
17
17
  ]
18
- },
19
- {
20
- "matcher": "startup|resume",
21
- "hooks": [
22
- {
23
- "type": "command",
24
- "command": "node ${PLUGIN_ROOT}/hooks/check-plugin-version.mjs codex",
25
- "statusMessage": "Checking Polygraph plugin version"
26
- }
27
- ]
28
18
  }
29
19
  ]
30
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polygraph/codex-plugin",
3
- "version": "0.4.44",
3
+ "version": "0.4.45",
4
4
  "description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -1,294 +0,0 @@
1
- // SessionStart hook — checks whether the installed Polygraph plugin is
2
- // outdated and, when it is, emits a single stdout message so the agent
3
- // surfaces the problem to the user. Stale plugin versions have silently
4
- // caused incorrect Polygraph behavior in the past; this makes it visible
5
- // for agent launches that bypass the polygraph CLI (e.g. desktop apps).
6
- //
7
- // Unlike the sibling hooks, this one deliberately writes to stdout — but
8
- // ONLY when the plugin is outdated. When current, unknown, offline, or on
9
- // any error it prints nothing and exits 0.
10
- //
11
- // The harness ('claude' | 'codex') is passed as the first CLI argument so
12
- // the same script ships in both plugin artifacts.
13
-
14
- import {
15
- appendFileSync,
16
- mkdirSync,
17
- readFileSync,
18
- realpathSync,
19
- renameSync,
20
- statSync,
21
- writeFileSync,
22
- } from 'node:fs';
23
- import { homedir } from 'node:os';
24
- import { dirname, join } from 'node:path';
25
- import { fileURLToPath } from 'node:url';
26
-
27
- const HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
28
- const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
29
- const FETCH_TIMEOUT_MS = 3000;
30
- const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
31
-
32
- const PACKAGE_BY_HARNESS = {
33
- claude: '@polygraph/claude-plugin',
34
- codex: '@polygraph/codex-plugin',
35
- };
36
-
37
- const REMEDIATION_BY_HARNESS = {
38
- claude: 'run `claude plugins update polygraph@polygraph-plugins`',
39
- codex:
40
- 'run `npx --prefer-online @polygraph/codex-plugin@latest install` then `codex plugin add polygraph@polygraph-plugins`',
41
- };
42
-
43
- // Append a one-line JSON record of a hook failure to ~/.polygraph/logs/hooks.log.
44
- // This hook swallows its errors silently, so this on-disk log is the only
45
- // record that something went wrong. The logger is itself failure-proof.
46
- function logHookFailure(
47
- hook,
48
- error,
49
- meta = {},
50
- home = process.env.HOME?.trim() || homedir()
51
- ) {
52
- try {
53
- const logsDir = join(home, '.polygraph', 'logs');
54
- mkdirSync(logsDir, { recursive: true });
55
- const logFile = join(logsDir, 'hooks.log');
56
-
57
- try {
58
- if (statSync(logFile).size > HOOK_LOG_MAX_BYTES) {
59
- renameSync(logFile, `${logFile}.1`);
60
- }
61
- } catch {
62
- // no prior log, or rotation failed — ignore
63
- }
64
-
65
- const entry = {
66
- time: new Date().toISOString(),
67
- hook,
68
- pid: process.pid,
69
- ...meta,
70
- error: error instanceof Error ? error.message : String(error),
71
- ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
72
- };
73
- appendFileSync(logFile, JSON.stringify(entry) + '\n');
74
- } catch {
75
- // Logging must never throw — a failing logger must not break the hook.
76
- }
77
- }
78
-
79
- function tryParseJson(str) {
80
- try {
81
- return JSON.parse(str);
82
- } catch {
83
- return null;
84
- }
85
- }
86
-
87
- function parseSemver(version) {
88
- if (typeof version !== 'string') return null;
89
- const match = version
90
- .trim()
91
- .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
92
- if (!match) return null;
93
- return {
94
- major: Number(match[1]),
95
- minor: Number(match[2]),
96
- patch: Number(match[3]),
97
- prerelease: match[4] ? match[4].split('.') : [],
98
- };
99
- }
100
-
101
- // Returns -1, 0, or 1 when a is lower than, equal to, or higher than b.
102
- // Returns null when either version is unparseable.
103
- export function compareSemver(a, b) {
104
- const pa = parseSemver(a);
105
- const pb = parseSemver(b);
106
- if (!pa || !pb) return null;
107
-
108
- for (const key of ['major', 'minor', 'patch']) {
109
- if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
110
- }
111
-
112
- // Same core version: a prerelease sorts below a release.
113
- if (pa.prerelease.length && !pb.prerelease.length) return -1;
114
- if (!pa.prerelease.length && pb.prerelease.length) return 1;
115
-
116
- const len = Math.max(pa.prerelease.length, pb.prerelease.length);
117
- for (let i = 0; i < len; i++) {
118
- const ia = pa.prerelease[i];
119
- const ib = pb.prerelease[i];
120
- if (ia === undefined) return -1;
121
- if (ib === undefined) return 1;
122
- if (ia === ib) continue;
123
- const na = /^\d+$/.test(ia) ? Number(ia) : null;
124
- const nb = /^\d+$/.test(ib) ? Number(ib) : null;
125
- if (na !== null && nb !== null) return na < nb ? -1 : 1;
126
- if (na !== null) return -1; // numeric identifiers sort below alphanumeric
127
- if (nb !== null) return 1;
128
- return ia < ib ? -1 : 1;
129
- }
130
- return 0;
131
- }
132
-
133
- // Resolve the installed plugin version from the manifest shipped alongside
134
- // this script: <pluginRoot>/hooks/check-plugin-version.mjs sits next to
135
- // .claude-plugin/plugin.json (Claude), .codex-plugin/plugin.json (Codex),
136
- // or package.json.
137
- export function resolveInstalledVersion(pluginRoot) {
138
- const manifests = [
139
- join(pluginRoot, '.claude-plugin', 'plugin.json'),
140
- join(pluginRoot, '.codex-plugin', 'plugin.json'),
141
- join(pluginRoot, 'package.json'),
142
- ];
143
- for (const manifestPath of manifests) {
144
- let raw;
145
- try {
146
- raw = readFileSync(manifestPath, 'utf8');
147
- } catch {
148
- continue;
149
- }
150
- const parsed = tryParseJson(raw);
151
- if (parsed && parseSemver(parsed.version)) return parsed.version.trim();
152
- }
153
- return null;
154
- }
155
-
156
- function cachePath(harness, home) {
157
- return join(home, '.polygraph', 'logs', `plugin-version-check-${harness}.json`);
158
- }
159
-
160
- export function readCache(harness, home) {
161
- try {
162
- return tryParseJson(readFileSync(cachePath(harness, home), 'utf8'));
163
- } catch {
164
- return null;
165
- }
166
- }
167
-
168
- // A cache entry is only trusted when it is recent, was recorded for the
169
- // currently installed version (updating the plugin invalidates it), and holds
170
- // either a parseable latest version or null (a negatively-cached failed
171
- // fetch, so an offline machine does not re-stall on every session start).
172
- export function isCacheFresh(cache, installed, now) {
173
- return Boolean(
174
- cache &&
175
- Number.isFinite(cache.checkedAt) &&
176
- now - cache.checkedAt >= 0 &&
177
- now - cache.checkedAt < CACHE_MAX_AGE_MS &&
178
- cache.installed === installed &&
179
- (cache.latest === null || parseSemver(cache.latest))
180
- );
181
- }
182
-
183
- function writeCache(harness, home, entry) {
184
- const path = cachePath(harness, home);
185
- mkdirSync(dirname(path), { recursive: true });
186
- const tmpPath = `${path}.tmp-${process.pid}`;
187
- writeFileSync(tmpPath, JSON.stringify(entry) + '\n');
188
- renameSync(tmpPath, path);
189
- }
190
-
191
- async function fetchLatestVersion(packageName, fetchImpl) {
192
- const registry = (process.env.npm_config_registry?.trim() || DEFAULT_REGISTRY)
193
- .replace(/\/+$/, '');
194
- const url = `${registry}/-/package/${packageName.replace('/', '%2f')}/dist-tags`;
195
- const response = await fetchImpl(url, {
196
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
197
- });
198
- if (!response.ok) throw new Error(`registry responded ${response.status}`);
199
- const distTags = await response.json();
200
- return typeof distTags?.latest === 'string' ? distTags.latest : null;
201
- }
202
-
203
- export function buildOutdatedMessage(harness, installed, latest) {
204
- return (
205
- `The Polygraph plugin is outdated: ${installed} installed, ${latest} latest. ` +
206
- 'Stale plugin versions cause incorrect Polygraph behavior. ' +
207
- `Tell the user to update it now: ${REMEDIATION_BY_HARNESS[harness]} ` +
208
- '(or re-run `polygraph config`), then restart the agent session.'
209
- );
210
- }
211
-
212
- /**
213
- * Check whether the installed plugin is outdated.
214
- *
215
- * @param {object} opts
216
- * @param {string} opts.harness 'claude' | 'codex'
217
- * @param {string} opts.pluginRoot Directory containing the plugin manifest.
218
- * @param {string} [opts.home] Override HOME for testing.
219
- * @param {Function} [opts.fetchImpl] Override fetch for testing.
220
- * @param {number} [opts.now] Override the clock for testing.
221
- * @returns {Promise<string|null>} The message to emit, or null to stay silent.
222
- */
223
- export async function checkPluginVersion({
224
- harness,
225
- pluginRoot,
226
- home = process.env.HOME?.trim() || homedir(),
227
- fetchImpl = fetch,
228
- now = Date.now(),
229
- }) {
230
- const packageName = PACKAGE_BY_HARNESS[harness];
231
- if (!packageName) return null;
232
-
233
- const installed = resolveInstalledVersion(pluginRoot);
234
- if (!installed) return null;
235
-
236
- let latest;
237
- const cache = readCache(harness, home);
238
- if (isCacheFresh(cache, installed, now)) {
239
- if (cache.latest === null) return null;
240
- latest = cache.latest;
241
- } else {
242
- try {
243
- latest = await fetchLatestVersion(packageName, fetchImpl);
244
- } catch (error) {
245
- // Negative cache: remember the failed fetch so an offline machine
246
- // does not re-stall for the fetch timeout on every session start.
247
- writeCache(harness, home, { checkedAt: now, installed, latest: null });
248
- throw error;
249
- }
250
- if (!parseSemver(latest)) {
251
- writeCache(harness, home, { checkedAt: now, installed, latest: null });
252
- return null;
253
- }
254
- writeCache(harness, home, { checkedAt: now, installed, latest });
255
- }
256
-
257
- if (compareSemver(installed, latest) === -1) {
258
- return buildOutdatedMessage(harness, installed, latest);
259
- }
260
- return null;
261
- }
262
-
263
- export async function main() {
264
- const harness = process.argv[2];
265
- try {
266
- const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
267
- const message = await checkPluginVersion({ harness, pluginRoot });
268
- if (message) process.stdout.write(message + '\n');
269
- } catch (error) {
270
- // Offline or broken registry must never block or pollute the session,
271
- // but record it so failures are not invisible.
272
- logHookFailure(`${harness || 'unknown'}:check-plugin-version`, error);
273
- }
274
- process.exitCode = 0;
275
- }
276
-
277
- // Run only when executed directly as a hook, not when imported (e.g. by tests).
278
- // realpathSync both sides so the check holds when the plugin lives under a
279
- // symlinked path (e.g. macOS /tmp -> /private/tmp).
280
- function isMainModule() {
281
- if (!process.argv[1]) return false;
282
- try {
283
- return (
284
- realpathSync(process.argv[1]) ===
285
- realpathSync(fileURLToPath(import.meta.url))
286
- );
287
- } catch {
288
- return false;
289
- }
290
- }
291
-
292
- if (isMainModule()) {
293
- main();
294
- }