@heretyc/subagent-mcp 2.10.4 → 2.12.2

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,10 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { closeSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
5
  import * as marker from "./marker.js";
6
6
  import * as reminder from "./reminder.js";
7
7
  import { cullStaleSlots, slotDir, ZOMBIE_FORCE_GRACE_MS, } from "../concurrency.js";
8
+ import { appendUpdateNotice, readInstalledPackageInfo, } from "./update-check.js";
8
9
  /**
9
10
  * Provider-agnostic core of the UserPromptSubmit / SessionStart hook.
10
11
  *
@@ -37,9 +38,18 @@ export const REMINDER_PERIOD = 5;
37
38
  * === <repoRoot>/directives.
38
39
  */
39
40
  export function resolveDirectivesDir(env) {
40
- const root = env.CLAUDE_PLUGIN_ROOT || env.PLUGIN_ROOT;
41
- if (root) {
42
- return join(root, "directives");
41
+ const rootEnvName = env.CLAUDE_PLUGIN_ROOT !== undefined
42
+ ? "CLAUDE_PLUGIN_ROOT"
43
+ : env.PLUGIN_ROOT !== undefined
44
+ ? "PLUGIN_ROOT"
45
+ : undefined;
46
+ if (rootEnvName) {
47
+ const root = env[rootEnvName] ?? "";
48
+ const directivesDir = join(root, "directives");
49
+ if (!isAbsolute(root) || !existsSync(directivesDir)) {
50
+ throw new Error(`${rootEnvName} must be an absolute path with an existing directives directory: ${root}`);
51
+ }
52
+ return directivesDir;
43
53
  }
44
54
  const here = dirname(fileURLToPath(import.meta.url));
45
55
  // Compiled location is dist/orchestration/hook-core.js, so ../../directives
@@ -50,8 +60,9 @@ export function resolveDirectivesDir(env) {
50
60
  }
51
61
  /** Read a directive asset by filename. On ANY failure return '' (fail-safe). */
52
62
  export function readDirective(env, fileName) {
63
+ const directivesDir = resolveDirectivesDir(env);
53
64
  try {
54
- return readFileSync(join(resolveDirectivesDir(env), fileName), "utf8");
65
+ return readFileSync(join(directivesDir, fileName), "utf8");
55
66
  }
56
67
  catch {
57
68
  return "";
@@ -230,15 +241,13 @@ export function cullHookZombies(deps = hookCullDeps()) {
230
241
  return [];
231
242
  }
232
243
  }
233
- export function hookZombieReportText(records) {
234
- const ids = Array.from(new Set(records.map((r) => r.agent_id))).filter(Boolean);
235
- return ids.length > 0 ? `zombies: ${ids.join(",")}` : "";
236
- }
237
- export function appendHookZombieReport(out, records) {
238
- const report = hookZombieReportText(records);
239
- if (!report)
244
+ function appendHookUpdateNotice(out, current, env) {
245
+ try {
246
+ return appendUpdateNotice(out, readInstalledPackageInfo().version, current, env);
247
+ }
248
+ catch {
240
249
  return out;
241
- return out ? `${out}\n${report}` : report;
250
+ }
242
251
  }
243
252
  /**
244
253
  * Core hook logic. Returns the string to inject, or '' to inject nothing.
@@ -259,28 +268,29 @@ export function appendHookZombieReport(out, records) {
259
268
  */
260
269
  export function runHook(payload, env, adapter) {
261
270
  try {
262
- const zombieRecords = cullHookZombies();
271
+ cullHookZombies();
263
272
  if (adapter.isSubagent(payload, env)) {
264
- return appendHookZombieReport("", zombieRecords);
273
+ return "";
265
274
  }
266
275
  const cwd = payload.cwd || process.cwd();
267
276
  const current = sessionKey(payload);
277
+ const updateNoticeSessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
268
278
  if (current)
269
279
  marker.writeCurrentSession(cwd, current);
270
280
  if (!marker.isActive(cwd, current)) {
271
281
  // OFF: no claim machinery — just the per-prompt reminder cadence.
272
282
  const r = reminder.advance(cwd, current);
273
- return appendHookZombieReport(cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted), zombieRecords);
283
+ return appendHookUpdateNotice(cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted), updateNoticeSessionId, env);
274
284
  }
275
285
  const m = marker.readMarker(cwd);
276
286
  const kind = classifyClaim(m.owner_session, m.baseline_turn, current);
277
287
  if (kind === "fresh" || kind === "carryover") {
278
288
  const turn = adapter.currentTurn(payload.transcript_path);
279
- return appendHookZombieReport(claimAndEmit(cwd, current, turn, m, kind, env, adapter), zombieRecords);
289
+ return appendHookUpdateNotice(claimAndEmit(cwd, current, turn, m, kind, env, adapter), updateNoticeSessionId, env);
280
290
  }
281
291
  // SAME-SESSION: per-prompt reminder cadence, ON variant.
282
292
  const r = reminder.advance(cwd, current);
283
- return appendHookZombieReport(cadenceEmit(env, adapter, adapter.reminderOnFile, adapter.shortOnFile, r.count, r.persisted), zombieRecords);
293
+ return appendHookUpdateNotice(cadenceEmit(env, adapter, adapter.reminderOnFile, adapter.shortOnFile, r.count, r.persisted), updateNoticeSessionId, env);
284
294
  }
285
295
  catch {
286
296
  // Any failure -> inject nothing. Never crash or stall the host turn.
@@ -1,5 +1,5 @@
1
1
  import { serverAlive } from "./liveness.js";
2
- import { cullHookZombies, hookZombieReportText } from "./hook-core.js";
2
+ import { cullHookZombies } from "./hook-core.js";
3
3
  const NATIVE_SUBAGENT_TOOLS = new Set(["Task", "Agent", "Explore"]);
4
4
  function decision(permissionDecision, permissionDecisionReason, additionalContext) {
5
5
  return {
@@ -22,21 +22,21 @@ function decision(permissionDecision, permissionDecisionReason, additionalContex
22
22
  */
23
23
  export function runClaudePreTool(payload, env, now = Date.now()) {
24
24
  try {
25
- const zombieReport = hookZombieReportText(cullHookZombies());
25
+ const zombieRecords = cullHookZombies();
26
26
  if (env.SUBAGENT_MCP_SUBAGENT === "1")
27
27
  return null;
28
- const zombieAllowedDecision = zombieReport
29
- ? decision("allow", "zombies culled; allowing requested tool.", zombieReport)
28
+ const maintenanceAllowedDecision = zombieRecords.length > 0
29
+ ? decision("allow", "maintenance completed; allowing requested tool.")
30
30
  : null;
31
31
  if (!serverAlive(now))
32
- return zombieAllowedDecision;
32
+ return maintenanceAllowedDecision;
33
33
  const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
34
34
  if (!tool)
35
- return zombieAllowedDecision;
35
+ return maintenanceAllowedDecision;
36
36
  if (NATIVE_SUBAGENT_TOOLS.has(tool)) {
37
- return decision("deny", "subagent-mcp is alive; harness-native Task/Agent/Explore is not the sanctioned sub-agent channel. Use the subagent-mcp launch_agent MCP tool with the parent-process sentinel as prompt line 1.", zombieReport || undefined);
37
+ return decision("deny", "subagent-mcp is alive; harness-native Task/Agent/Explore is not the sanctioned sub-agent channel. Use the subagent-mcp launch_agent MCP tool with the parent-process sentinel as prompt line 1.");
38
38
  }
39
- return zombieAllowedDecision;
39
+ return maintenanceAllowedDecision;
40
40
  }
41
41
  catch {
42
42
  return null;
@@ -0,0 +1,174 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readCheckForUpdates } from "../concurrency.js";
4
+ import { stateDir } from "./marker.js";
5
+ export const UPDATE_NOTICE_TEXT = "Notice: An improved version of subagent-mcp is available via the CLI command `subagent-mcp update` and can then be fully installed with `subagent-mcp setup`. This will fix security issues and improve user experience.";
6
+ export const UPDATE_CHECK_TIMEOUT_MS = 2500;
7
+ export const UPDATE_NOTICE_INTERVAL_MS = 12 * 60 * 60 * 1000;
8
+ function pendingNoticePath() {
9
+ return join(stateDir, "update-notice.json");
10
+ }
11
+ function emitRecordPath() {
12
+ return join(stateDir, "update-notice-emitted.json");
13
+ }
14
+ function atomicWriteJson(path, value) {
15
+ try {
16
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
17
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
18
+ writeFileSync(tmp, JSON.stringify(value), { encoding: "utf8", mode: 0o600 });
19
+ renameSync(tmp, path);
20
+ }
21
+ catch {
22
+ // Fail-safe: update notices must never affect the host turn or MCP channel.
23
+ }
24
+ }
25
+ function readJson(path) {
26
+ try {
27
+ return JSON.parse(readFileSync(path, "utf8"));
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ function removeFile(path) {
34
+ try {
35
+ rmSync(path, { force: true });
36
+ }
37
+ catch {
38
+ // Fail-safe.
39
+ }
40
+ }
41
+ export function clearUpdateNoticeState() {
42
+ removeFile(pendingNoticePath());
43
+ removeFile(emitRecordPath());
44
+ }
45
+ export function writePendingUpdateNotice(latestVersion, now = Date.now()) {
46
+ atomicWriteJson(pendingNoticePath(), {
47
+ latest_version: latestVersion,
48
+ checked_at: new Date(now).toISOString(),
49
+ });
50
+ }
51
+ export function readPendingUpdateNotice() {
52
+ const parsed = readJson(pendingNoticePath());
53
+ if (parsed &&
54
+ typeof parsed.latest_version === "string" &&
55
+ typeof parsed.checked_at === "string") {
56
+ return { latest_version: parsed.latest_version, checked_at: parsed.checked_at };
57
+ }
58
+ return undefined;
59
+ }
60
+ export function readUpdateNoticeEmitRecord() {
61
+ const parsed = readJson(emitRecordPath());
62
+ if (!parsed || typeof parsed.notified_at !== "number")
63
+ return undefined;
64
+ return {
65
+ notified_at: parsed.notified_at,
66
+ ...(typeof parsed.session_id === "string" ? { session_id: parsed.session_id } : {}),
67
+ };
68
+ }
69
+ function writeUpdateNoticeEmitRecord(record) {
70
+ atomicWriteJson(emitRecordPath(), record);
71
+ }
72
+ export function updateCheckEnvDisabled(env = process.env) {
73
+ const raw = env.SUBAGENT_UPDATE_CHECK;
74
+ return typeof raw === "string" && /^(?:0|false)$/i.test(raw.trim());
75
+ }
76
+ export function shouldCheckForUpdates(env = process.env, configPath) {
77
+ if (updateCheckEnvDisabled(env))
78
+ return false;
79
+ return readCheckForUpdates(configPath);
80
+ }
81
+ export function compareNumericVersions(a, b) {
82
+ const parse = (value) => {
83
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:$|[^\d])/.exec(value.trim());
84
+ return match ? match.slice(1, 4).map((part) => Number.parseInt(part, 10)) : undefined;
85
+ };
86
+ const left = parse(a);
87
+ const right = parse(b);
88
+ if (!left || !right)
89
+ return 0;
90
+ for (let i = 0; i < 3; i++) {
91
+ if (left[i] < right[i])
92
+ return -1;
93
+ if (left[i] > right[i])
94
+ return 1;
95
+ }
96
+ return 0;
97
+ }
98
+ export function isVersionNewer(latest, installed) {
99
+ return compareNumericVersions(installed, latest) < 0;
100
+ }
101
+ export function readInstalledPackageInfo() {
102
+ return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
103
+ }
104
+ function registryPackageUrl(packageName, registryBaseUrl) {
105
+ const base = registryBaseUrl.replace(/\/+$/, "");
106
+ return `${base}/${packageName.replace("/", "%2F")}`;
107
+ }
108
+ async function fetchLatestVersion(packageName, deps) {
109
+ const controller = new AbortController();
110
+ const timeout = setTimeout(() => controller.abort(), deps.timeoutMs);
111
+ try {
112
+ const response = await deps.fetch(registryPackageUrl(packageName, deps.registryBaseUrl), {
113
+ headers: { accept: "application/json" },
114
+ signal: controller.signal,
115
+ });
116
+ if (!response.ok)
117
+ return undefined;
118
+ const metadata = (await response.json());
119
+ return typeof metadata?.["dist-tags"]?.latest === "string"
120
+ ? metadata["dist-tags"].latest
121
+ : undefined;
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ finally {
127
+ clearTimeout(timeout);
128
+ }
129
+ }
130
+ export async function checkForNpmUpdate(deps = {}) {
131
+ try {
132
+ const env = deps.env ?? process.env;
133
+ if (!shouldCheckForUpdates(env, deps.configPath))
134
+ return;
135
+ const pkg = (deps.packageInfo ?? readInstalledPackageInfo)();
136
+ const latest = await fetchLatestVersion(pkg.name, {
137
+ fetch: deps.fetch ?? fetch,
138
+ registryBaseUrl: deps.registryBaseUrl ?? "https://registry.npmjs.org",
139
+ timeoutMs: deps.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS,
140
+ });
141
+ if (latest && isVersionNewer(latest, pkg.version)) {
142
+ writePendingUpdateNotice(latest, deps.now?.() ?? Date.now());
143
+ }
144
+ }
145
+ catch {
146
+ // Silent skip: never throw and never log to MCP stdio.
147
+ }
148
+ }
149
+ export function appendUpdateNotice(out, installedVersion, sessionId, env = process.env, now = Date.now(), configPath) {
150
+ try {
151
+ if (!out || !shouldCheckForUpdates(env, configPath))
152
+ return out;
153
+ const pending = readPendingUpdateNotice();
154
+ if (!pending)
155
+ return out;
156
+ if (!isVersionNewer(pending.latest_version, installedVersion)) {
157
+ removeFile(pendingNoticePath());
158
+ return out;
159
+ }
160
+ const emitted = readUpdateNoticeEmitRecord();
161
+ if (emitted?.session_id && sessionId && emitted.session_id === sessionId)
162
+ return out;
163
+ if (emitted && now - emitted.notified_at < UPDATE_NOTICE_INTERVAL_MS)
164
+ return out;
165
+ writeUpdateNoticeEmitRecord({
166
+ notified_at: now,
167
+ ...(sessionId ? { session_id: sessionId } : {}),
168
+ });
169
+ return `${out}\n${UPDATE_NOTICE_TEXT}`;
170
+ }
171
+ catch {
172
+ return out;
173
+ }
174
+ }