chainlesschain 0.47.2 → 0.47.5
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/package.json +1 -1
- package/src/commands/hook.js +77 -0
- package/src/lib/agent-core.js +1 -0
- package/src/lib/session-hooks.js +113 -5
- package/src/repl/agent-repl.js +33 -16
- package/src/runtime/agent-core.js +18 -0
package/package.json
CHANGED
package/src/commands/hook.js
CHANGED
|
@@ -16,6 +16,12 @@ import {
|
|
|
16
16
|
executeHooks,
|
|
17
17
|
getHookStats,
|
|
18
18
|
} from "../lib/hook-manager.js";
|
|
19
|
+
import {
|
|
20
|
+
SESSION_HOOK_EVENTS,
|
|
21
|
+
fireSessionHook,
|
|
22
|
+
fireUserPromptSubmit,
|
|
23
|
+
fireAssistantResponse,
|
|
24
|
+
} from "../lib/session-hooks.js";
|
|
19
25
|
|
|
20
26
|
export function registerHookCommand(program) {
|
|
21
27
|
const hook = program.command("hook").description("Lifecycle hook management");
|
|
@@ -210,6 +216,77 @@ export function registerHookCommand(program) {
|
|
|
210
216
|
}
|
|
211
217
|
});
|
|
212
218
|
|
|
219
|
+
// hook fire — session-aware trigger that honors rewrite/abort/suppress
|
|
220
|
+
// directives (vs. `hook run` which just dumps raw results).
|
|
221
|
+
hook
|
|
222
|
+
.command("fire")
|
|
223
|
+
.description(
|
|
224
|
+
"Fire a session-level hook (SessionStart/UserPromptSubmit/AssistantResponse/SessionEnd) and honor stdout JSON directives",
|
|
225
|
+
)
|
|
226
|
+
.argument("<event>", `One of: ${SESSION_HOOK_EVENTS.join(", ")}`)
|
|
227
|
+
.option(
|
|
228
|
+
"--prompt <text>",
|
|
229
|
+
"Prompt text (UserPromptSubmit only) — used as input to rewrite/abort",
|
|
230
|
+
)
|
|
231
|
+
.option(
|
|
232
|
+
"--response <text>",
|
|
233
|
+
"Response text (AssistantResponse only) — used as input to rewrite/suppress",
|
|
234
|
+
)
|
|
235
|
+
.option("--context <json>", "Extra JSON context", "{}")
|
|
236
|
+
.option("--json", "Emit machine-readable JSON output")
|
|
237
|
+
.action(async (event, options) => {
|
|
238
|
+
try {
|
|
239
|
+
const ctx = await bootstrap({ verbose: program.opts().verbose });
|
|
240
|
+
if (!ctx.db) {
|
|
241
|
+
logger.error("Database not available");
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
const db = ctx.db.getDatabase();
|
|
245
|
+
|
|
246
|
+
let extra = {};
|
|
247
|
+
try {
|
|
248
|
+
extra = JSON.parse(options.context);
|
|
249
|
+
} catch (_e) {
|
|
250
|
+
logger.warn("Invalid JSON --context, using empty object");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let out;
|
|
254
|
+
if (event === HookEvents.UserPromptSubmit) {
|
|
255
|
+
out = await fireUserPromptSubmit(db, options.prompt || "", extra);
|
|
256
|
+
} else if (event === HookEvents.AssistantResponse) {
|
|
257
|
+
out = await fireAssistantResponse(db, options.response || "", extra);
|
|
258
|
+
} else {
|
|
259
|
+
const results = await fireSessionHook(db, event, extra);
|
|
260
|
+
out = { results };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (options.json) {
|
|
264
|
+
logger.log(JSON.stringify(out, null, 2));
|
|
265
|
+
} else if (event === HookEvents.UserPromptSubmit) {
|
|
266
|
+
if (out.abort) {
|
|
267
|
+
logger.log(chalk.yellow(`[abort] ${out.reason || "no reason"}`));
|
|
268
|
+
} else {
|
|
269
|
+
logger.log(chalk.cyan("prompt:"), out.prompt);
|
|
270
|
+
}
|
|
271
|
+
logger.log(chalk.gray(`(${out.results.length} hook(s) fired)`));
|
|
272
|
+
} else if (event === HookEvents.AssistantResponse) {
|
|
273
|
+
if (out.suppress) {
|
|
274
|
+
logger.log(chalk.yellow(`[suppress] ${out.reason || "no reason"}`));
|
|
275
|
+
} else {
|
|
276
|
+
logger.log(chalk.cyan("response:"), out.response);
|
|
277
|
+
}
|
|
278
|
+
logger.log(chalk.gray(`(${out.results.length} hook(s) fired)`));
|
|
279
|
+
} else {
|
|
280
|
+
logger.log(chalk.gray(`(${out.results.length} hook(s) fired)`));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await shutdown();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
logger.error(`Failed: ${err.message}`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
213
290
|
// hook stats
|
|
214
291
|
hook
|
|
215
292
|
.command("stats")
|
package/src/lib/agent-core.js
CHANGED
package/src/lib/session-hooks.js
CHANGED
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Semantics (matches existing PreToolUse convention):
|
|
14
14
|
* - Fire-and-forget by default: hook failures NEVER break the host flow
|
|
15
|
-
* - `
|
|
16
|
-
*
|
|
15
|
+
* - `fireUserPromptSubmit` / `fireAssistantResponse` are opt-in helpers
|
|
16
|
+
* that parse stdout JSON directives ({rewrittenPrompt,abort} /
|
|
17
|
+
* {rewrittenResponse,suppress}) for callers that want control flow
|
|
17
18
|
* - No-op when hookDb is null (REPL without DB)
|
|
19
|
+
* - Helper-side timeout protects against runaway hooks even if
|
|
20
|
+
* hook-manager's per-hook timeout is misconfigured
|
|
21
|
+
* - Swallowed errors are persisted to hook_execution_log when possible
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
import { executeHooks, HookEvents } from "./hook-manager.js";
|
|
@@ -30,6 +34,45 @@ export const SESSION_HOOK_EVENTS = Object.freeze([
|
|
|
30
34
|
HookEvents.SessionEnd,
|
|
31
35
|
]);
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Helper-side wall-clock cap. Per-hook timeout still lives in the
|
|
39
|
+
* registered hook row; this is a belt-and-suspenders bound so a
|
|
40
|
+
* misconfigured hook can never wedge the REPL.
|
|
41
|
+
*/
|
|
42
|
+
const HELPER_TIMEOUT_MS = Number(
|
|
43
|
+
process.env.CC_SESSION_HOOK_TIMEOUT_MS || 15000,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Internal deps — exposed for tests via `_deps` injection.
|
|
48
|
+
* Keeping this mutable lets vitest swap out timer/log writers without
|
|
49
|
+
* resorting to vi.mock (which doesn't intercept inlined CJS).
|
|
50
|
+
*/
|
|
51
|
+
export const _deps = {
|
|
52
|
+
executeHooks,
|
|
53
|
+
now: () => Date.now(),
|
|
54
|
+
logFailure: (hookDb, eventName, err) => {
|
|
55
|
+
// Best-effort persistence to hook_execution_log; never throws.
|
|
56
|
+
if (!hookDb || typeof hookDb.prepare !== "function") return;
|
|
57
|
+
try {
|
|
58
|
+
const stmt = hookDb.prepare(
|
|
59
|
+
`INSERT INTO hook_execution_log
|
|
60
|
+
(hook_id, event, success, error, executed_at)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
62
|
+
);
|
|
63
|
+
stmt.run(
|
|
64
|
+
null,
|
|
65
|
+
eventName,
|
|
66
|
+
0,
|
|
67
|
+
String(err && err.message ? err.message : err),
|
|
68
|
+
new Date().toISOString(),
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
/* table may not exist yet — silent */
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
33
76
|
/**
|
|
34
77
|
* Fire a session-level hook. Returns the raw results from executeHooks
|
|
35
78
|
* (array of {hookId, hookName, success, ...}) or an empty array if
|
|
@@ -54,9 +97,14 @@ export async function fireSessionHook(hookDb, eventName, context = {}) {
|
|
|
54
97
|
timestamp: new Date().toISOString(),
|
|
55
98
|
...context,
|
|
56
99
|
};
|
|
57
|
-
return await
|
|
58
|
-
|
|
59
|
-
|
|
100
|
+
return await withTimeout(
|
|
101
|
+
_deps.executeHooks(hookDb, eventName, enriched),
|
|
102
|
+
HELPER_TIMEOUT_MS,
|
|
103
|
+
`${eventName} session hook`,
|
|
104
|
+
);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// Hook failures must never break the REPL — but we record them
|
|
107
|
+
_deps.logFailure(hookDb, eventName, err);
|
|
60
108
|
return [];
|
|
61
109
|
}
|
|
62
110
|
}
|
|
@@ -108,6 +156,53 @@ export async function fireUserPromptSubmit(
|
|
|
108
156
|
return { prompt, abort, reason, results };
|
|
109
157
|
}
|
|
110
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Fire AssistantResponse with rewrite/suppress support.
|
|
161
|
+
*
|
|
162
|
+
* Symmetric to fireUserPromptSubmit but on the way out. A hook may emit:
|
|
163
|
+
* {"rewrittenResponse": "..."} — replace the response shown to the user
|
|
164
|
+
* {"suppress": true, "reason": "..."} — drop the response entirely
|
|
165
|
+
*
|
|
166
|
+
* Common use: PII / secret scrubbing, watermark injection, profanity
|
|
167
|
+
* filter on the model's final string before it reaches the terminal.
|
|
168
|
+
*
|
|
169
|
+
* @returns {Promise<{response: string, suppress: boolean, reason?: string, results: Array}>}
|
|
170
|
+
*/
|
|
171
|
+
export async function fireAssistantResponse(
|
|
172
|
+
hookDb,
|
|
173
|
+
originalResponse,
|
|
174
|
+
context = {},
|
|
175
|
+
) {
|
|
176
|
+
const results = await fireSessionHook(hookDb, HookEvents.AssistantResponse, {
|
|
177
|
+
...context,
|
|
178
|
+
response: originalResponse,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
let response = originalResponse;
|
|
182
|
+
let suppress = false;
|
|
183
|
+
let reason;
|
|
184
|
+
|
|
185
|
+
for (const r of results) {
|
|
186
|
+
if (!r || !r.success) continue;
|
|
187
|
+
const directive = extractDirective(r);
|
|
188
|
+
if (!directive) continue;
|
|
189
|
+
if (directive.suppress) {
|
|
190
|
+
suppress = true;
|
|
191
|
+
reason = directive.reason;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
typeof directive.rewrittenResponse === "string" &&
|
|
196
|
+
directive.rewrittenResponse.length > 0
|
|
197
|
+
) {
|
|
198
|
+
response = directive.rewrittenResponse;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { response, suppress, reason, results };
|
|
204
|
+
}
|
|
205
|
+
|
|
111
206
|
function extractDirective(result) {
|
|
112
207
|
const raw = result.stdout ?? result.output ?? result.result;
|
|
113
208
|
if (raw == null) return null;
|
|
@@ -121,3 +216,16 @@ function extractDirective(result) {
|
|
|
121
216
|
return null;
|
|
122
217
|
}
|
|
123
218
|
}
|
|
219
|
+
|
|
220
|
+
function withTimeout(promise, ms, label) {
|
|
221
|
+
if (!ms || ms <= 0) return promise;
|
|
222
|
+
let t;
|
|
223
|
+
const timeout = new Promise((_, reject) => {
|
|
224
|
+
t = setTimeout(
|
|
225
|
+
() => reject(new Error(`${label} exceeded ${ms}ms helper timeout`)),
|
|
226
|
+
ms,
|
|
227
|
+
);
|
|
228
|
+
if (typeof t.unref === "function") t.unref();
|
|
229
|
+
});
|
|
230
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(t));
|
|
231
|
+
}
|
package/src/repl/agent-repl.js
CHANGED
|
@@ -50,7 +50,11 @@ import { CLIAutonomousAgent, GoalStatus } from "../lib/autonomous-agent.js";
|
|
|
50
50
|
import { PromptCompressor } from "../harness/prompt-compressor.js";
|
|
51
51
|
import { feature } from "../lib/feature-flags.js";
|
|
52
52
|
import { recordCompressionMetric } from "../lib/compression-telemetry.js";
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
fireSessionHook,
|
|
55
|
+
fireUserPromptSubmit,
|
|
56
|
+
fireAssistantResponse,
|
|
57
|
+
} from "../lib/session-hooks.js";
|
|
54
58
|
import { HookEvents } from "../lib/hook-manager.js";
|
|
55
59
|
import { IterationBudget } from "../lib/iteration-budget.js";
|
|
56
60
|
import {
|
|
@@ -1243,21 +1247,34 @@ export async function startAgentRepl(options = {}) {
|
|
|
1243
1247
|
prepareCall: defaultPrepareCall,
|
|
1244
1248
|
});
|
|
1245
1249
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1250
|
+
// Fire AssistantResponse hook with rewrite/suppress support
|
|
1251
|
+
const responseDirective = await fireAssistantResponse(
|
|
1252
|
+
_hookDb,
|
|
1253
|
+
response || "",
|
|
1254
|
+
{
|
|
1255
|
+
sessionId,
|
|
1256
|
+
messageCount: messages.length,
|
|
1257
|
+
provider,
|
|
1258
|
+
model: activeModel,
|
|
1259
|
+
},
|
|
1260
|
+
);
|
|
1261
|
+
|
|
1262
|
+
let effectiveResponse = response;
|
|
1263
|
+
if (responseDirective.suppress) {
|
|
1264
|
+
process.stdout.write(
|
|
1265
|
+
`\n[hook suppress] ${responseDirective.reason || "response suppressed"}\n\n`,
|
|
1266
|
+
);
|
|
1267
|
+
effectiveResponse = "";
|
|
1268
|
+
} else if (responseDirective.response !== (response || "")) {
|
|
1269
|
+
effectiveResponse = responseDirective.response;
|
|
1251
1270
|
}
|
|
1252
1271
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
model: activeModel,
|
|
1260
|
-
});
|
|
1272
|
+
if (effectiveResponse) {
|
|
1273
|
+
process.stdout.write(`\n${effectiveResponse}\n\n`);
|
|
1274
|
+
messages.push({ role: "assistant", content: effectiveResponse });
|
|
1275
|
+
} else if (!responseDirective.suppress) {
|
|
1276
|
+
process.stdout.write("\n");
|
|
1277
|
+
}
|
|
1261
1278
|
|
|
1262
1279
|
// Auto-save session
|
|
1263
1280
|
if (sessionId) {
|
|
@@ -1265,8 +1282,8 @@ export async function startAgentRepl(options = {}) {
|
|
|
1265
1282
|
if (useJsonl) {
|
|
1266
1283
|
// Append incremental events (user + assistant)
|
|
1267
1284
|
appendUserMessage(sessionId, effectivePrompt);
|
|
1268
|
-
if (
|
|
1269
|
-
appendAssistantMessage(sessionId,
|
|
1285
|
+
if (effectiveResponse) {
|
|
1286
|
+
appendAssistantMessage(sessionId, effectiveResponse);
|
|
1270
1287
|
}
|
|
1271
1288
|
} else if (db) {
|
|
1272
1289
|
saveMessages(db, sessionId, messages);
|
|
@@ -48,6 +48,18 @@ import {
|
|
|
48
48
|
unmountSkillMcpServers,
|
|
49
49
|
} from "../lib/skill-mcp.js";
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Names of MCP servers currently mounted by an in-flight run_skill call.
|
|
53
|
+
* Populated by run_skill before invoking the handler and cleared in
|
|
54
|
+
* the finally block. Exposed via getActiveMcpServers() so external
|
|
55
|
+
* observers (web panel, future LLM prompt builders) can render only
|
|
56
|
+
* the tools that are actually live for this session.
|
|
57
|
+
*/
|
|
58
|
+
const _activeMcpServers = new Set();
|
|
59
|
+
export function getActiveMcpServers() {
|
|
60
|
+
return new Set(_activeMcpServers);
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
const { isReadOnlyGitCommand, normalizeGitCommand } = sharedCodingAgentPolicy;
|
|
52
64
|
const { evaluateShellCommandPolicy } = sharedShellPolicy;
|
|
53
65
|
|
|
@@ -1001,6 +1013,9 @@ async function executeToolInner(
|
|
|
1001
1013
|
},
|
|
1002
1014
|
});
|
|
1003
1015
|
mountedMcpServers = mountResult.mounted;
|
|
1016
|
+
for (const s of mountedMcpServers) {
|
|
1017
|
+
_activeMcpServers.add(typeof s === "string" ? s : s.name);
|
|
1018
|
+
}
|
|
1004
1019
|
} catch (err) {
|
|
1005
1020
|
return attachDescriptor({
|
|
1006
1021
|
error: `Skill MCP mount failed: ${err.message}`,
|
|
@@ -1042,6 +1057,9 @@ async function executeToolInner(
|
|
|
1042
1057
|
} catch (_err) {
|
|
1043
1058
|
// Non-critical — mount/unmount errors don't fail the skill
|
|
1044
1059
|
}
|
|
1060
|
+
for (const s of mountedMcpServers) {
|
|
1061
|
+
_activeMcpServers.delete(typeof s === "string" ? s : s.name);
|
|
1062
|
+
}
|
|
1045
1063
|
}
|
|
1046
1064
|
}
|
|
1047
1065
|
}
|