@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.
- package/LICENSE +201 -201
- package/NOTICE +5 -5
- package/README.md +113 -137
- package/directives/carryover-codex.md +4 -2
- package/directives/reminder-on.md +1 -1
- package/directives/short-on.md +1 -1
- package/dist/advanced-ruleset.py +67 -67
- package/dist/concurrency.js +32 -6
- package/dist/config-scaffold.js +1 -1
- package/dist/drivers.js +106 -0
- package/dist/effort.js +3 -1
- package/dist/global-concurrency.jsonc +29 -24
- package/dist/hooks/orchestration-codex.js +5 -5
- package/dist/index.js +130 -49
- package/dist/orchestration/hook-core.js +29 -19
- package/dist/orchestration/pretool.js +8 -8
- package/dist/orchestration/update-check.js +174 -0
- package/dist/routing-table.json +3561 -3561
- package/dist/routing.js +6 -3
- package/dist/ruleset-scaffold.js +1 -1
- package/dist/ruleset.js +2 -2
- package/dist/setup.js +2 -0
- package/dist/zombie.js +16 -7
- package/package.json +69 -69
- package/scripts/postinstall.mjs +102 -102
|
@@ -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
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
+
cullHookZombies();
|
|
263
272
|
if (adapter.isSubagent(payload, env)) {
|
|
264
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
25
|
+
const zombieRecords = cullHookZombies();
|
|
26
26
|
if (env.SUBAGENT_MCP_SUBAGENT === "1")
|
|
27
27
|
return null;
|
|
28
|
-
const
|
|
29
|
-
? decision("allow", "
|
|
28
|
+
const maintenanceAllowedDecision = zombieRecords.length > 0
|
|
29
|
+
? decision("allow", "maintenance completed; allowing requested tool.")
|
|
30
30
|
: null;
|
|
31
31
|
if (!serverAlive(now))
|
|
32
|
-
return
|
|
32
|
+
return maintenanceAllowedDecision;
|
|
33
33
|
const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
|
|
34
34
|
if (!tool)
|
|
35
|
-
return
|
|
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."
|
|
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
|
|
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
|
+
}
|