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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainlesschain",
3
- "version": "0.47.2",
3
+ "version": "0.47.5",
4
4
  "description": "CLI for ChainlessChain - install, configure, and manage your personal AI management system",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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")
@@ -23,4 +23,5 @@ export {
23
23
  chatWithTools,
24
24
  agentLoop,
25
25
  formatToolArgs,
26
+ getActiveMcpServers,
26
27
  } from "../runtime/agent-core.js";
@@ -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
- * - `fireSessionHookWithRewrite` opt-in: lets a UserPromptSubmit hook
16
- * return `{ rewrittenPrompt }` or `{ abort: true }` via stdout JSON
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 executeHooks(hookDb, eventName, enriched);
58
- } catch (_err) {
59
- // Hook failures must never break the REPL
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
+ }
@@ -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 { fireSessionHook, fireUserPromptSubmit } from "../lib/session-hooks.js";
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
- if (response) {
1247
- process.stdout.write(`\n${response}\n\n`);
1248
- messages.push({ role: "assistant", content: response });
1249
- } else {
1250
- process.stdout.write("\n");
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
- // Fire AssistantResponse hook (fire-and-forget; observational only)
1254
- await fireSessionHook(_hookDb, HookEvents.AssistantResponse, {
1255
- sessionId,
1256
- response: response || "",
1257
- messageCount: messages.length,
1258
- provider,
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 (response) {
1269
- appendAssistantMessage(sessionId, response);
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
  }