topchester-ai 0.18.0 → 0.19.0

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/dist/cli.mjs CHANGED
@@ -4452,6 +4452,39 @@ const commandPolicySchema = z.object({
4452
4452
  allowExact: z.array(commandPatternSchema).optional().default([]),
4453
4453
  deny: z.array(commandPatternSchema).optional().default([])
4454
4454
  }).strict();
4455
+ const hookEventNames = [
4456
+ "SessionStart",
4457
+ "UserPromptSubmit",
4458
+ "PreToolUse",
4459
+ "PostToolUse",
4460
+ "PreCompact",
4461
+ "Stop"
4462
+ ];
4463
+ const hookEventAliasMap = {
4464
+ TaskStart: "SessionStart",
4465
+ TaskComplete: "Stop"
4466
+ };
4467
+ const hookTimeoutMsSchema = z.number().int().positive().max(6e5);
4468
+ const hookMatcherSchema = z.union([z.string().min(1), z.array(z.string().min(1))]).optional();
4469
+ const hookHandlerSchema = z.object({
4470
+ type: z.literal("command").optional(),
4471
+ command: z.string().min(1),
4472
+ timeoutMs: hookTimeoutMsSchema.optional(),
4473
+ matcher: hookMatcherSchema
4474
+ }).strict();
4475
+ const canonicalHooksConfigSchema = z.object({
4476
+ enabled: z.boolean().optional(),
4477
+ SessionStart: z.array(hookHandlerSchema).optional(),
4478
+ UserPromptSubmit: z.array(hookHandlerSchema).optional(),
4479
+ PreToolUse: z.array(hookHandlerSchema).optional(),
4480
+ PostToolUse: z.array(hookHandlerSchema).optional(),
4481
+ PreCompact: z.array(hookHandlerSchema).optional(),
4482
+ Stop: z.array(hookHandlerSchema).optional()
4483
+ }).strict();
4484
+ const rawHooksConfigSchema = canonicalHooksConfigSchema.extend({
4485
+ TaskStart: z.array(hookHandlerSchema).optional(),
4486
+ TaskComplete: z.array(hookHandlerSchema).optional()
4487
+ }).strict();
4455
4488
  const topchesterConfigSchema = z.object({
4456
4489
  models: z.object({
4457
4490
  defaultPurpose: modelPurposeSchema.optional(),
@@ -4460,12 +4493,14 @@ const topchesterConfigSchema = z.object({
4460
4493
  providers: providersSchema.optional()
4461
4494
  }).strict().optional(),
4462
4495
  ignore: z.object({ paths: z.array(ignorePathSchema).optional() }).optional(),
4463
- tools: z.object({ commands: commandPolicySchema.optional() }).strict().optional()
4496
+ tools: z.object({ commands: commandPolicySchema.optional() }).strict().optional(),
4497
+ hooks: canonicalHooksConfigSchema.optional()
4464
4498
  });
4465
4499
  const rawTopchesterConfigSchema = z.object({
4466
4500
  models: rawModelsSchema.optional(),
4467
4501
  ignore: z.object({ paths: z.array(ignorePathSchema).optional() }).optional(),
4468
- tools: z.object({ commands: commandPolicySchema.optional() }).strict().optional()
4502
+ tools: z.object({ commands: commandPolicySchema.optional() }).strict().optional(),
4503
+ hooks: rawHooksConfigSchema.optional()
4469
4504
  });
4470
4505
  function getGlobalTopchesterConfigDir() {
4471
4506
  return join(homedir(), ".config", "topchester");
@@ -4644,6 +4679,9 @@ function parseConfigFile(path, value) {
4644
4679
  return parsed.data;
4645
4680
  }
4646
4681
  function normalizeConfigInput(value) {
4682
+ return normalizeHooksConfigInput(normalizeModelsConfigInput(value));
4683
+ }
4684
+ function normalizeModelsConfigInput(value) {
4647
4685
  if (!isPlainObject(value) || !isPlainObject(value.models)) return value;
4648
4686
  const models = { ...value.models };
4649
4687
  const providers = isPlainObject(models.providers) ? { ...models.providers } : {};
@@ -4683,6 +4721,22 @@ function normalizeConfigInput(value) {
4683
4721
  }
4684
4722
  };
4685
4723
  }
4724
+ function normalizeHooksConfigInput(value) {
4725
+ if (!isPlainObject(value) || !isPlainObject(value.hooks)) return value;
4726
+ const hooks = { ...value.hooks };
4727
+ for (const [alias, canonical] of Object.entries(hookEventAliasMap)) {
4728
+ const aliasHandlers = hooks[alias];
4729
+ if (aliasHandlers === void 0) continue;
4730
+ const canonicalHandlers = hooks[canonical];
4731
+ if (!Array.isArray(aliasHandlers) || canonicalHandlers !== void 0 && !Array.isArray(canonicalHandlers)) continue;
4732
+ hooks[canonical] = [...canonicalHandlers ?? [], ...aliasHandlers];
4733
+ delete hooks[alias];
4734
+ }
4735
+ return {
4736
+ ...value,
4737
+ hooks
4738
+ };
4739
+ }
4686
4740
  function normalizeModelRef(ref, defaultProvider) {
4687
4741
  if (typeof ref === "string") return parseModelRef(ref, defaultProvider);
4688
4742
  if (!isPlainObject(ref) || typeof ref.name !== "string") return;
@@ -4745,7 +4799,7 @@ function isOpenAIProvider(providerId, baseURL) {
4745
4799
  function deepMerge(base, override, path = []) {
4746
4800
  if (Array.isArray(base) && Array.isArray(override)) {
4747
4801
  const joinedPath = path.join(".");
4748
- return joinedPath === "ignore.paths" || joinedPath === "tools.commands.allow" || joinedPath === "tools.commands.allowExact" || joinedPath === "tools.commands.deny" ? [...base, ...override] : override;
4802
+ return joinedPath === "ignore.paths" || joinedPath === "tools.commands.allow" || joinedPath === "tools.commands.allowExact" || joinedPath === "tools.commands.deny" || path.length === 2 && path[0] === "hooks" && hookEventNames.includes(path[1]) ? [...base, ...override] : override;
4749
4803
  }
4750
4804
  if (!isPlainObject(base) || !isPlainObject(override)) return override;
4751
4805
  const result = { ...base };
@@ -8629,6 +8683,225 @@ function isAbortError(error) {
8629
8683
  return error.name === "AbortError" || error.message.toLowerCase().includes("aborted");
8630
8684
  }
8631
8685
  //#endregion
8686
+ //#region src/agent/hooks.ts
8687
+ const DEFAULT_HOOK_TIMEOUT_MS = 5e3;
8688
+ const MAX_CAPTURED_OUTPUT_CHARS = 64e3;
8689
+ const hookResponseSchema = z.object({
8690
+ action: z.enum([
8691
+ "continue",
8692
+ "block",
8693
+ "stop"
8694
+ ]).optional(),
8695
+ decision: z.string().optional(),
8696
+ cancel: z.boolean().optional(),
8697
+ context: z.union([z.string(), z.array(z.string())]).optional(),
8698
+ message: z.string().optional(),
8699
+ feedback: z.string().optional(),
8700
+ reason: z.string().optional()
8701
+ }).passthrough();
8702
+ async function runTopchesterHooks(context, event, payload, options = {}) {
8703
+ const handlers = getConfiguredHookHandlers(context, event, options.toolName);
8704
+ const result = {
8705
+ contexts: [],
8706
+ messages: [],
8707
+ handlerCount: 0
8708
+ };
8709
+ for (const handler of handlers) {
8710
+ result.handlerCount += 1;
8711
+ const handlerResult = await runCommandHandler(context, event, payload, handler, options);
8712
+ result.contexts.push(...handlerResult.contexts);
8713
+ result.messages.push(...handlerResult.messages);
8714
+ if (handlerResult.blocked) {
8715
+ result.blocked = handlerResult.blocked;
8716
+ break;
8717
+ }
8718
+ if (handlerResult.stopped) {
8719
+ result.stopped = handlerResult.stopped;
8720
+ break;
8721
+ }
8722
+ }
8723
+ return result;
8724
+ }
8725
+ function formatHookContextsForPrompt(event, contexts) {
8726
+ const normalized = contexts.map((context) => context.trim()).filter(Boolean);
8727
+ if (normalized.length === 0) return "";
8728
+ return [`Hook context from ${event}:`, ...normalized].join("\n\n");
8729
+ }
8730
+ function getConfiguredHookHandlers(context, event, toolName) {
8731
+ const hooks = context.config.hooks;
8732
+ if (!hooks || hooks.enabled === false) return [];
8733
+ return (hooks[event] ?? []).filter((handler) => hookMatches(handler, event, toolName));
8734
+ }
8735
+ function hookMatches(handler, event, toolName) {
8736
+ const matcher = handler.matcher;
8737
+ if (matcher === void 0) return true;
8738
+ const target = toolName ?? event;
8739
+ return (Array.isArray(matcher) ? matcher : [matcher]).some((entry) => entry === "*" || entry === target);
8740
+ }
8741
+ async function runCommandHandler(context, event, payload, handler, options) {
8742
+ const result = await runHookProcess(handler.command ?? "", payload, {
8743
+ cwd: context.workspaceRoot,
8744
+ timeoutMs: handler.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
8745
+ abortSignal: options.abortSignal,
8746
+ env: buildHookEnv(event, options.toolName)
8747
+ });
8748
+ logHookProcessResult(context, event, handler, result);
8749
+ if (result.timedOut || result.aborted || result.spawnError || result.exitCode !== 0) return emptyHookRunResult();
8750
+ const stdout = result.stdout.trim();
8751
+ if (!stdout) return emptyHookRunResult();
8752
+ let parsed;
8753
+ try {
8754
+ parsed = JSON.parse(stdout);
8755
+ } catch (error) {
8756
+ logHookWarning(context, {
8757
+ event: "hook_response_parse_failed",
8758
+ hookEventName: event,
8759
+ error: error instanceof Error ? error.message : String(error),
8760
+ stdoutLength: result.stdout.length
8761
+ });
8762
+ return emptyHookRunResult();
8763
+ }
8764
+ const response = hookResponseSchema.safeParse(parsed);
8765
+ if (!response.success) {
8766
+ logHookWarning(context, {
8767
+ event: "hook_response_invalid",
8768
+ hookEventName: event,
8769
+ issues: response.error.issues.map((issue) => issue.message)
8770
+ });
8771
+ return emptyHookRunResult();
8772
+ }
8773
+ return normalizeHookResponse(event, response.data);
8774
+ }
8775
+ function normalizeHookResponse(event, response) {
8776
+ const result = emptyHookRunResult();
8777
+ const action = normalizeHookAction(event, response);
8778
+ const message = firstNonEmpty(response.message, response.feedback, response.reason);
8779
+ const contexts = Array.isArray(response.context) ? response.context : response.context ? [response.context] : [];
8780
+ result.contexts.push(...contexts.filter((context) => context.trim().length > 0));
8781
+ if (message) result.messages.push(message);
8782
+ if (action === "block") result.blocked = { message: message || `Hook ${event} blocked the request.` };
8783
+ else if (action === "stop") result.stopped = { message: message || `Hook ${event} stopped the turn.` };
8784
+ return result;
8785
+ }
8786
+ function normalizeHookAction(event, response) {
8787
+ if (response.cancel) return event === "Stop" ? "stop" : "block";
8788
+ if (response.action) return response.action;
8789
+ const decision = response.decision?.trim().toLowerCase();
8790
+ if (decision === "block" || decision === "deny" || decision === "denied") return "block";
8791
+ if (decision === "stop" || decision === "halt") return "stop";
8792
+ return "continue";
8793
+ }
8794
+ function buildHookEnv(event, toolName) {
8795
+ return {
8796
+ ...process.env,
8797
+ TOPCHESTER_HOOK_EVENT: event,
8798
+ TOPCHESTER_HOOK_TOOL: toolName ?? ""
8799
+ };
8800
+ }
8801
+ async function runHookProcess(command, payload, options) {
8802
+ const startedAt = Date.now();
8803
+ const shell = process.env.SHELL || "/bin/sh";
8804
+ return new Promise((resolve) => {
8805
+ let stdout = "";
8806
+ let stderr = "";
8807
+ let settled = false;
8808
+ let timedOut = false;
8809
+ let aborted = false;
8810
+ const child = spawn(shell, ["-lc", command], {
8811
+ cwd: options.cwd,
8812
+ env: options.env,
8813
+ stdio: [
8814
+ "pipe",
8815
+ "pipe",
8816
+ "pipe"
8817
+ ]
8818
+ });
8819
+ const finish = (partial) => {
8820
+ if (settled) return;
8821
+ settled = true;
8822
+ clearTimeout(timeout);
8823
+ options.abortSignal?.removeEventListener("abort", abort);
8824
+ resolve({
8825
+ stdout,
8826
+ stderr,
8827
+ exitCode: null,
8828
+ signal: null,
8829
+ timedOut,
8830
+ aborted,
8831
+ durationMs: Date.now() - startedAt,
8832
+ ...partial
8833
+ });
8834
+ };
8835
+ const timeout = setTimeout(() => {
8836
+ timedOut = true;
8837
+ child.kill("SIGTERM");
8838
+ }, options.timeoutMs);
8839
+ const abort = () => {
8840
+ aborted = true;
8841
+ child.kill("SIGTERM");
8842
+ };
8843
+ if (options.abortSignal?.aborted) abort();
8844
+ else options.abortSignal?.addEventListener("abort", abort, { once: true });
8845
+ child.stdout?.setEncoding("utf8");
8846
+ child.stderr?.setEncoding("utf8");
8847
+ child.stdout?.on("data", (chunk) => {
8848
+ stdout = appendCapped(stdout, chunk);
8849
+ });
8850
+ child.stderr?.on("data", (chunk) => {
8851
+ stderr = appendCapped(stderr, chunk);
8852
+ });
8853
+ child.on("error", (error) => {
8854
+ finish({ spawnError: error.message });
8855
+ });
8856
+ child.on("close", (exitCode, signal) => {
8857
+ finish({
8858
+ exitCode,
8859
+ signal
8860
+ });
8861
+ });
8862
+ child.stdin?.on("error", () => {});
8863
+ child.stdin?.end(`${JSON.stringify(payload)}\n`);
8864
+ });
8865
+ }
8866
+ function logHookProcessResult(context, event, handler, result) {
8867
+ context.logger.debug({
8868
+ event: "hook_run",
8869
+ hookEventName: event,
8870
+ handlerType: handler.type ?? "command",
8871
+ matcher: handler.matcher,
8872
+ exitCode: result.exitCode,
8873
+ signal: result.signal,
8874
+ timedOut: result.timedOut,
8875
+ aborted: result.aborted,
8876
+ spawnError: result.spawnError,
8877
+ durationMs: result.durationMs,
8878
+ stdoutLength: result.stdout.length,
8879
+ stderrLength: result.stderr.length
8880
+ }, "hook run");
8881
+ }
8882
+ function logHookWarning(context, payload) {
8883
+ const logger = context.logger;
8884
+ if (typeof logger.warn === "function") {
8885
+ logger.warn(payload, "hook warning");
8886
+ return;
8887
+ }
8888
+ context.logger.debug(payload, "hook warning");
8889
+ }
8890
+ function emptyHookRunResult() {
8891
+ return {
8892
+ contexts: [],
8893
+ messages: [],
8894
+ handlerCount: 0
8895
+ };
8896
+ }
8897
+ function appendCapped(current, chunk) {
8898
+ if (current.length >= MAX_CAPTURED_OUTPUT_CHARS) return current;
8899
+ return `${current}${chunk}`.slice(0, MAX_CAPTURED_OUTPUT_CHARS);
8900
+ }
8901
+ function firstNonEmpty(...values) {
8902
+ return values.find((value) => value !== void 0 && value.trim().length > 0);
8903
+ }
8904
+ //#endregion
8632
8905
  //#region src/agent/profiles.ts
8633
8906
  const READ_ONLY_TOOLS = [
8634
8907
  "read_file",
@@ -9578,6 +9851,7 @@ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
9578
9851
  options;
9579
9852
  taskPlan = createTaskPlanController();
9580
9853
  approvedRunCommands = /* @__PURE__ */ new Set();
9854
+ startedHookSessionKeys = /* @__PURE__ */ new Set();
9581
9855
  /**
9582
9856
  * Holds the shared application context for one runtime instance.
9583
9857
  * The runtime does not own those dependencies; it coordinates the
@@ -9610,6 +9884,20 @@ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
9610
9884
  async checkKnowledgeBase() {
9611
9885
  return getKnowledgeStatusEvents(await this.getKnowledgeStatusWithNonCleanFileCount());
9612
9886
  }
9887
+ async runSessionStartHooks(session, options = {}) {
9888
+ const sessionKey = session?.sessionId ?? `workspace:${this.context.workspaceRoot}`;
9889
+ if (this.startedHookSessionKeys.has(sessionKey)) return [];
9890
+ this.startedHookSessionKeys.add(sessionKey);
9891
+ const result = await this.runHookEvent("SessionStart", this.createBaseHookPayload("SessionStart", session, {
9892
+ isResumed: Boolean(options.isResumed),
9893
+ taskStartAlias: "TaskStart"
9894
+ }), { abortSignal: options.abortSignal });
9895
+ return this.hookResultToEvents(result);
9896
+ }
9897
+ async runPreCompactHooks(session, options = {}) {
9898
+ const result = await this.runHookEvent("PreCompact", this.createBaseHookPayload("PreCompact", session, { reason: options.reason ?? "Compaction is about to start." }), { abortSignal: options.abortSignal });
9899
+ return this.hookResultToEvents(result);
9900
+ }
9613
9901
  /**
9614
9902
  * Streams one user chat turn through the agent loop. It builds the model
9615
9903
  * prompt with relevant KB context, calls the model, executes any requested
@@ -9622,13 +9910,26 @@ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
9622
9910
  * ordered events.
9623
9911
  */
9624
9912
  async *submitMessageStream(conversation, message, abortSignal, options = {}) {
9625
- let nextPrompt = await this.buildPromptWithKnowledgeContext(buildConversationPrompt(conversation, message), message);
9913
+ const session = options.session ?? this.options.session;
9914
+ for (const event of await this.runSessionStartHooks(session, { abortSignal })) yield event;
9915
+ const userPromptHook = await this.runHookEvent("UserPromptSubmit", this.createBaseHookPayload("UserPromptSubmit", session, {
9916
+ prompt: { text: message },
9917
+ prompt_text: message,
9918
+ user_prompt: message
9919
+ }), { abortSignal });
9920
+ for (const event of this.hookResultToEvents(userPromptHook)) yield event;
9921
+ if (userPromptHook.blocked || userPromptHook.stopped) {
9922
+ const interruption = userPromptHook.blocked ?? userPromptHook.stopped;
9923
+ if (userPromptHook.messages.length === 0) yield agentEvent.systemMessage(interruption.message);
9924
+ yield agentEvent.status("ready");
9925
+ return;
9926
+ }
9927
+ let nextPrompt = this.appendHookContextsToPrompt(await this.buildPromptWithKnowledgeContext(buildConversationPrompt(conversation, message), message), "UserPromptSubmit", userPromptHook.contexts);
9626
9928
  let totalDurationMs = 0;
9627
9929
  const tokenUsageTotals = {};
9628
9930
  const profile = this.options.profile ?? PRIMARY_AGENT_PROFILE;
9629
9931
  const permissions = createToolPermissionView(profile, { deniedTools: this.options.parentPermissions?.deniedTools });
9630
9932
  const tools = getProfileToolDefinitions(permissions);
9631
- const session = options.session ?? this.options.session;
9632
9933
  const subagents = new SubagentManager({
9633
9934
  context: this.context,
9634
9935
  parentSession: session,
@@ -9733,7 +10034,9 @@ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
9733
10034
  }
9734
10035
  yield agentEvent.taskPlan(this.taskPlan.update({ items: [] }));
9735
10036
  }
9736
- yield agentEvent.assistantMessage(finalText.trim() || "I got an empty response from the model.", formatAgentMessageMeta(result.modelId, totalDurationMs, tokenUsageTotals));
10037
+ const finalMessage = finalText.trim() || "I got an empty response from the model.";
10038
+ yield agentEvent.assistantMessage(finalMessage, formatAgentMessageMeta(result.modelId, totalDurationMs, tokenUsageTotals));
10039
+ for (const event of await this.runStopHookEvents(session, finalMessage, "completed", abortSignal)) yield event;
9737
10040
  yield agentEvent.status("ready");
9738
10041
  return;
9739
10042
  }
@@ -9750,10 +10053,33 @@ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
9750
10053
  if (modelToolCalls.length > 1 && modelToolCalls.every((call) => call.tool === "task")) {
9751
10054
  const taskCalls = modelToolCalls.map((call) => call);
9752
10055
  const taskResults = [];
10056
+ const postHookContexts = [];
9753
10057
  for (let index = 0; index < taskCalls.length; index += DEFAULT_TASK_CONCURRENCY) {
9754
10058
  const batch = taskCalls.slice(index, index + DEFAULT_TASK_CONCURRENCY);
10059
+ const executableBatch = [];
10060
+ for (let batchIndex = 0; batchIndex < batch.length; batchIndex += 1) {
10061
+ const call = batch[batchIndex];
10062
+ const resultIndex = index + batchIndex;
10063
+ const preHook = await this.runPreToolUseHook(call, modelToolCalls[resultIndex]?.id, session, abortSignal);
10064
+ for (const event of this.hookResultToEvents(preHook)) yield event;
10065
+ if (preHook.stopped) {
10066
+ if (preHook.messages.length === 0) yield agentEvent.systemMessage(preHook.stopped.message);
10067
+ yield agentEvent.status("ready");
10068
+ return;
10069
+ }
10070
+ if (preHook.blocked) {
10071
+ taskResults[resultIndex] = createToolErrorResult(call.tool, preHook.blocked.message);
10072
+ continue;
10073
+ }
10074
+ executableBatch.push({
10075
+ call,
10076
+ resultIndex,
10077
+ toolCallId: modelToolCalls[resultIndex]?.id
10078
+ });
10079
+ }
10080
+ if (executableBatch.length === 0) continue;
9755
10081
  const taskEventQueue = createRuntimeEventQueue();
9756
- const batchResultPromise = Promise.all(batch.map((call, batchIndex) => executeToolCall(this.context.workspaceRoot, call, {
10082
+ const batchResultPromise = Promise.all(executableBatch.map((entry) => executeToolCall(this.context.workspaceRoot, entry.call, {
9757
10083
  logger: this.context.logger,
9758
10084
  config: this.context.config,
9759
10085
  taskPlan: this.taskPlan,
@@ -9761,22 +10087,60 @@ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
9761
10087
  permissions,
9762
10088
  subagents,
9763
10089
  abortSignal,
9764
- toolCallId: modelToolCalls[index + batchIndex]?.id,
10090
+ toolCallId: entry.toolCallId,
9765
10091
  eventSink: (event) => taskEventQueue.push(event)
9766
10092
  }))).finally(() => {
9767
10093
  taskEventQueue.close();
9768
10094
  });
9769
10095
  for await (const event of taskEventQueue) yield event;
9770
- taskResults.push(...await batchResultPromise);
10096
+ const batchResults = await batchResultPromise;
10097
+ for (let batchIndex = 0; batchIndex < executableBatch.length; batchIndex += 1) {
10098
+ const entry = executableBatch[batchIndex];
10099
+ taskResults[entry.resultIndex] = batchResults[batchIndex];
10100
+ }
10101
+ }
10102
+ for (let index = 0; index < taskCalls.length; index += 1) {
10103
+ const call = taskCalls[index];
10104
+ const toolResult = taskResults[index];
10105
+ yield agentEvent.toolCall(call, formatToolCallMessage(call, toolResult));
10106
+ const postHook = await this.runPostToolUseHook(call, modelToolCalls[index]?.id, toolResult, session, abortSignal);
10107
+ for (const event of this.hookResultToEvents(postHook)) yield event;
10108
+ postHookContexts.push(...postHook.contexts);
10109
+ if (postHook.stopped) {
10110
+ if (postHook.messages.length === 0) yield agentEvent.systemMessage(postHook.stopped.message);
10111
+ yield agentEvent.status("ready");
10112
+ return;
10113
+ }
9771
10114
  }
9772
- for (let index = 0; index < taskCalls.length; index += 1) yield agentEvent.toolCall(taskCalls[index], formatToolCallMessage(taskCalls[index], taskResults[index]));
9773
10115
  afterTool = "task";
9774
- nextPrompt = `${nextPrompt}\n\n${taskResults.map((toolResult) => formatToolResultForPrompt(toolResult)).join("\n\n")}\n\n${formatContinuationInstruction(result.toolProtocol, taskResults.at(-1), isToolAllowed(permissions, "plan_todo"))}`;
10116
+ nextPrompt = this.appendHookContextsToPrompt(`${nextPrompt}\n\n${taskResults.map((toolResult) => formatToolResultForPrompt(toolResult)).join("\n\n")}\n\n${formatContinuationInstruction(result.toolProtocol, taskResults.at(-1), isToolAllowed(permissions, "plan_todo"))}`, "PostToolUse", postHookContexts);
9775
10117
  continue;
9776
10118
  }
9777
10119
  if (modelToolCalls.length > 1 && modelToolCalls.every((call) => isParallelSafeToolName(call.tool))) {
9778
10120
  const parallelCalls = modelToolCalls.map((call) => call);
9779
- const parallelResults = await Promise.all(parallelCalls.map((call, index) => executeToolCall(this.context.workspaceRoot, call, {
10121
+ const parallelResults = [];
10122
+ const executableCalls = [];
10123
+ const postHookContexts = [];
10124
+ for (let index = 0; index < parallelCalls.length; index += 1) {
10125
+ const call = parallelCalls[index];
10126
+ const preHook = await this.runPreToolUseHook(call, modelToolCalls[index]?.id, session, abortSignal);
10127
+ for (const event of this.hookResultToEvents(preHook)) yield event;
10128
+ if (preHook.stopped) {
10129
+ if (preHook.messages.length === 0) yield agentEvent.systemMessage(preHook.stopped.message);
10130
+ yield agentEvent.status("ready");
10131
+ return;
10132
+ }
10133
+ if (preHook.blocked) {
10134
+ parallelResults[index] = createToolErrorResult(call.tool, preHook.blocked.message);
10135
+ continue;
10136
+ }
10137
+ executableCalls.push({
10138
+ call,
10139
+ resultIndex: index,
10140
+ toolCallId: modelToolCalls[index]?.id
10141
+ });
10142
+ }
10143
+ const executedResults = await Promise.all(executableCalls.map((entry) => executeToolCall(this.context.workspaceRoot, entry.call, {
9780
10144
  logger: this.context.logger,
9781
10145
  config: this.context.config,
9782
10146
  taskPlan: this.taskPlan,
@@ -9784,48 +10148,85 @@ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
9784
10148
  permissions,
9785
10149
  subagents,
9786
10150
  abortSignal,
9787
- toolCallId: modelToolCalls[index]?.id
10151
+ toolCallId: entry.toolCallId
9788
10152
  })));
9789
- for (let index = 0; index < parallelCalls.length; index += 1) yield agentEvent.toolCall(parallelCalls[index], formatToolCallMessage(parallelCalls[index], parallelResults[index]));
10153
+ for (let index = 0; index < executableCalls.length; index += 1) {
10154
+ const entry = executableCalls[index];
10155
+ parallelResults[entry.resultIndex] = executedResults[index];
10156
+ }
10157
+ for (let index = 0; index < parallelCalls.length; index += 1) {
10158
+ const call = parallelCalls[index];
10159
+ const toolResult = parallelResults[index];
10160
+ yield agentEvent.toolCall(call, formatToolCallMessage(call, toolResult));
10161
+ const postHook = await this.runPostToolUseHook(call, modelToolCalls[index]?.id, toolResult, session, abortSignal);
10162
+ for (const event of this.hookResultToEvents(postHook)) yield event;
10163
+ postHookContexts.push(...postHook.contexts);
10164
+ if (postHook.stopped) {
10165
+ if (postHook.messages.length === 0) yield agentEvent.systemMessage(postHook.stopped.message);
10166
+ yield agentEvent.status("ready");
10167
+ return;
10168
+ }
10169
+ }
9790
10170
  afterTool = parallelCalls.at(-1)?.tool;
9791
- nextPrompt = `${nextPrompt}\n\n${parallelResults.map((toolResult) => formatToolResultForPrompt(toolResult)).join("\n\n")}\n\n${formatContinuationInstruction(result.toolProtocol, parallelResults.at(-1), isToolAllowed(permissions, "plan_todo"))}`;
10171
+ nextPrompt = this.appendHookContextsToPrompt(`${nextPrompt}\n\n${parallelResults.map((toolResult) => formatToolResultForPrompt(toolResult)).join("\n\n")}\n\n${formatContinuationInstruction(result.toolProtocol, parallelResults.at(-1), isToolAllowed(permissions, "plan_todo"))}`, "PostToolUse", postHookContexts);
9792
10172
  continue;
9793
10173
  }
9794
10174
  const executableToolCall = toolCall;
9795
10175
  const suppressiblePlanTodoAnswer = getSuppressiblePlanTodoAnswer(executableToolCall, result.text, this.taskPlan.get());
9796
10176
  if (suppressiblePlanTodoAnswer !== void 0) {
9797
- yield agentEvent.assistantMessage(suppressiblePlanTodoAnswer || "I got an empty response from the model.", formatAgentMessageMeta(result.modelId, totalDurationMs, tokenUsageTotals));
10177
+ const finalMessage = suppressiblePlanTodoAnswer || "I got an empty response from the model.";
10178
+ yield agentEvent.assistantMessage(finalMessage, formatAgentMessageMeta(result.modelId, totalDurationMs, tokenUsageTotals));
10179
+ for (const event of await this.runStopHookEvents(session, finalMessage, "completed", abortSignal)) yield event;
9798
10180
  yield agentEvent.status("ready");
9799
10181
  return;
9800
10182
  }
9801
- const approval = await this.resolveRunCommandApproval(executableToolCall, options);
10183
+ const preHook = await this.runPreToolUseHook(executableToolCall, toolCall.id, session, abortSignal);
10184
+ for (const event of this.hookResultToEvents(preHook)) yield event;
9802
10185
  let toolResult;
9803
- if (approval.cancelled) toolResult = createToolErrorResult(executableToolCall.tool, approval.reason);
10186
+ if (preHook.stopped) {
10187
+ if (preHook.messages.length === 0) yield agentEvent.systemMessage(preHook.stopped.message);
10188
+ yield agentEvent.status("ready");
10189
+ return;
10190
+ }
10191
+ if (preHook.blocked) toolResult = createToolErrorResult(executableToolCall.tool, preHook.blocked.message);
9804
10192
  else {
9805
- const toolEventQueue = createRuntimeEventQueue();
9806
- const toolResultPromise = executeToolCall(this.context.workspaceRoot, executableToolCall, {
9807
- logger: this.context.logger,
9808
- config: this.context.config,
9809
- runCommandApprovals: { allowExactCommands: approval.approvedCommands },
9810
- taskPlan: this.taskPlan,
9811
- profile,
9812
- permissions,
9813
- subagents,
9814
- abortSignal,
9815
- toolCallId: toolCall.id,
9816
- eventSink: (event) => toolEventQueue.push(event)
9817
- }).finally(() => {
9818
- toolEventQueue.close();
9819
- });
9820
- for await (const event of toolEventQueue) yield event;
9821
- toolResult = await toolResultPromise;
10193
+ const approval = await this.resolveRunCommandApproval(executableToolCall, options);
10194
+ if (approval.cancelled) toolResult = createToolErrorResult(executableToolCall.tool, approval.reason);
10195
+ else {
10196
+ const toolEventQueue = createRuntimeEventQueue();
10197
+ const toolResultPromise = executeToolCall(this.context.workspaceRoot, executableToolCall, {
10198
+ logger: this.context.logger,
10199
+ config: this.context.config,
10200
+ runCommandApprovals: { allowExactCommands: approval.approvedCommands },
10201
+ taskPlan: this.taskPlan,
10202
+ profile,
10203
+ permissions,
10204
+ subagents,
10205
+ abortSignal,
10206
+ toolCallId: toolCall.id,
10207
+ eventSink: (event) => toolEventQueue.push(event)
10208
+ }).finally(() => {
10209
+ toolEventQueue.close();
10210
+ });
10211
+ for await (const event of toolEventQueue) yield event;
10212
+ toolResult = await toolResultPromise;
10213
+ }
9822
10214
  }
9823
10215
  yield agentEvent.toolCall(executableToolCall, formatToolCallMessage(executableToolCall, toolResult));
9824
10216
  if (!isToolErrorResult(toolResult) && toolResult.tool === "plan_todo") yield agentEvent.taskPlan(toolResult.plan);
10217
+ const postHook = await this.runPostToolUseHook(executableToolCall, toolCall.id, toolResult, session, abortSignal);
10218
+ for (const event of this.hookResultToEvents(postHook)) yield event;
10219
+ if (postHook.stopped) {
10220
+ if (postHook.messages.length === 0) yield agentEvent.systemMessage(postHook.stopped.message);
10221
+ yield agentEvent.status("ready");
10222
+ return;
10223
+ }
9825
10224
  afterTool = executableToolCall.tool;
9826
- nextPrompt = `${nextPrompt}\n\n${formatToolResultForPrompt(toolResult)}\n\n${formatContinuationInstruction(result.toolProtocol, toolResult, isToolAllowed(permissions, "plan_todo"))}`;
10225
+ nextPrompt = this.appendHookContextsToPrompt(`${nextPrompt}\n\n${formatToolResultForPrompt(toolResult)}\n\n${formatContinuationInstruction(result.toolProtocol, toolResult, isToolAllowed(permissions, "plan_todo"))}`, "PostToolUse", postHook.contexts);
9827
10226
  }
9828
- yield agentEvent.assistantMessage("I stopped because the tool loop ended unexpectedly.", formatAgentMessageMeta(lastModelId, totalDurationMs, tokenUsageTotals));
10227
+ const finalMessage = "I stopped because the tool loop ended unexpectedly.";
10228
+ yield agentEvent.assistantMessage(finalMessage, formatAgentMessageMeta(lastModelId, totalDurationMs, tokenUsageTotals));
10229
+ for (const event of await this.runStopHookEvents(session, finalMessage, "failed", abortSignal)) yield event;
9829
10230
  yield agentEvent.status("ready");
9830
10231
  }
9831
10232
  /**
@@ -9840,6 +10241,68 @@ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
9840
10241
  }
9841
10242
  return events;
9842
10243
  }
10244
+ async runPreToolUseHook(call, toolCallId, session, abortSignal) {
10245
+ return this.runHookEvent("PreToolUse", this.createToolHookPayload("PreToolUse", call, toolCallId, session), {
10246
+ toolName: call.tool,
10247
+ abortSignal
10248
+ });
10249
+ }
10250
+ async runPostToolUseHook(call, toolCallId, result, session, abortSignal) {
10251
+ return this.runHookEvent("PostToolUse", this.createToolHookPayload("PostToolUse", call, toolCallId, session, { result }), {
10252
+ toolName: call.tool,
10253
+ abortSignal
10254
+ });
10255
+ }
10256
+ async runStopHookEvents(session, finalMessage, status, abortSignal) {
10257
+ const result = await this.runHookEvent("Stop", this.createBaseHookPayload("Stop", session, {
10258
+ taskCompleteAlias: "TaskComplete",
10259
+ finalMessage,
10260
+ status
10261
+ }), { abortSignal });
10262
+ return this.hookResultToEvents(result);
10263
+ }
10264
+ async runHookEvent(event, payload, options = {}) {
10265
+ return runTopchesterHooks(this.context, event, payload, options);
10266
+ }
10267
+ createBaseHookPayload(event, session, extra = {}) {
10268
+ return {
10269
+ hook_event_name: event,
10270
+ event,
10271
+ cwd: this.context.workspaceRoot,
10272
+ workspaceRoot: this.context.workspaceRoot,
10273
+ source: "topchester",
10274
+ ...session ? {
10275
+ session_id: session.sessionId,
10276
+ sessionId: session.sessionId,
10277
+ session: {
10278
+ sessionId: session.sessionId,
10279
+ rootSessionId: session.metadata.rootSessionId,
10280
+ parentSessionId: session.metadata.parentSessionId,
10281
+ source: session.metadata.source
10282
+ }
10283
+ } : {},
10284
+ ...extra
10285
+ };
10286
+ }
10287
+ createToolHookPayload(event, call, toolCallId, session, extra = {}) {
10288
+ return this.createBaseHookPayload(event, session, {
10289
+ tool_name: call.tool,
10290
+ tool_input: call.args,
10291
+ tool: {
10292
+ name: call.tool,
10293
+ input: call.args,
10294
+ ...toolCallId ? { callId: toolCallId } : {}
10295
+ },
10296
+ ...extra
10297
+ });
10298
+ }
10299
+ hookResultToEvents(result) {
10300
+ return result.messages.map((message) => agentEvent.systemMessage(message));
10301
+ }
10302
+ appendHookContextsToPrompt(prompt, event, contexts) {
10303
+ const hookContext = formatHookContextsForPrompt(event, contexts);
10304
+ return hookContext ? `${prompt}\n\n${hookContext}` : prompt;
10305
+ }
9843
10306
  async resolveRunCommandApproval(call, options) {
9844
10307
  const approvedCommands = [...this.approvedRunCommands];
9845
10308
  if (call.tool !== "run_command") return {
@@ -10368,6 +10831,7 @@ var TopchesterTuiShell = class {
10368
10831
  const isResumed = this.options.session !== void 0;
10369
10832
  const messages = this.options.initialMessages ?? getStartupThreadMessages(this.context);
10370
10833
  if (!isResumed) await persistMessagesWithWarning(session, messages, messages);
10834
+ await this.appendStartupRuntimeEvents(session, messages, await this.runtime.runSessionStartHooks?.(session, { isResumed }) ?? []);
10371
10835
  const folderName = getFolderName(this.context.workspaceRoot);
10372
10836
  const modelLabel = getModelLabel(this.context);
10373
10837
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -10837,10 +11301,24 @@ var TopchesterTuiShell = class {
10837
11301
  this.session = session;
10838
11302
  this.sessionStartedAt = Date.now();
10839
11303
  await persistMessagesWithWarning(session, messages, messages);
11304
+ await this.appendStartupRuntimeEvents(session, messages, await this.runtime.runSessionStartHooks?.(session, { isResumed: false }) ?? []);
10840
11305
  app.resetForNewSession(messages);
10841
11306
  tui.requestRender();
10842
11307
  await this.checkAgent(app, tui);
10843
11308
  }
11309
+ async appendStartupRuntimeEvents(session, messages, events) {
11310
+ for (const event of events) {
11311
+ messages.push(...renderRuntimeEvent(event));
11312
+ const payload = runtimeEventToSessionPayload(event);
11313
+ if (!payload) continue;
11314
+ try {
11315
+ await session.append(payload);
11316
+ } catch (error) {
11317
+ messages.push(systemMessage(`Session save failed: ${formatPlainError(error)}`));
11318
+ return;
11319
+ }
11320
+ }
11321
+ }
10844
11322
  async applyRuntimeEvents(app, events, renderRequester) {
10845
11323
  for (const event of events) {
10846
11324
  if (event.type === "status") app.setStatus(event.status);
@@ -10929,6 +11407,16 @@ async function executeRunCommand(context, options) {
10929
11407
  });
10930
11408
  try {
10931
11409
  if (!options.resume) await persistStartupMessages(session, runContext);
11410
+ await applyRuntimeEvents({
11411
+ events: await runtime.runSessionStartHooks(session, {
11412
+ isResumed: Boolean(options.resume),
11413
+ abortSignal: abortController.signal
11414
+ }),
11415
+ session,
11416
+ jsonEvents,
11417
+ runId,
11418
+ plain: !options.json
11419
+ });
10932
11420
  await applyRuntimeEvents({
10933
11421
  events: await runtime.checkKnowledgeBase(),
10934
11422
  session,
@@ -11169,15 +11657,6 @@ program.command("dev").description("start local development mode").action(() =>
11169
11657
  console.log("Topchester local dev mode");
11170
11658
  printStartupSummary(context);
11171
11659
  });
11172
- program.command("update").alias("upgrade").description("update Topchester with the package manager that installed it").argument("[target]", "version or npm dist tag to install", "latest").action(async (target) => {
11173
- try {
11174
- const command = await runSelfUpdate({ target });
11175
- console.log(formatSelfUpdateSuccess(command).join("\n"));
11176
- } catch (error) {
11177
- console.error(formatStartupError(error));
11178
- process.exitCode = 1;
11179
- }
11180
- });
11181
11660
  program.command("run").description("run one prompt or slash command without opening the TUI").argument("<prompt...>", "prompt text or slash command").option("--model <model>", "override the agent.primary model for this run").option("--timeout <ms>", "timeout for the run in milliseconds", parsePositiveInteger).option("--json", "write JSONL run events to stdout").option("--output-json <path>", "write JSONL run events to a file").action(async (promptParts, options) => {
11182
11661
  const context = createContextFromOptions();
11183
11662
  const globalOptions = program.opts();
@@ -11237,6 +11716,15 @@ kbCommand.command("status").description("show project files that are not current
11237
11716
  const result = await ui.spinner("Checking KB file status...", async () => filterNonCleanKnowledgeCompileResult(await dryRunKnowledgeCompile(context.workspaceRoot, { config: context.config })));
11238
11717
  console.log(formatKnowledgeCompileStatusResult(result, { formatSyncStatus: formatDryRunSyncStatus }).join("\n"));
11239
11718
  });
11719
+ program.command("update").alias("upgrade").description("update Topchester with the package manager that installed it").argument("[target]", "version or npm dist tag to install", "latest").action(async (target) => {
11720
+ try {
11721
+ const command = await runSelfUpdate({ target });
11722
+ console.log(formatSelfUpdateSuccess(command).join("\n"));
11723
+ } catch (error) {
11724
+ console.error(formatStartupError(error));
11725
+ process.exitCode = 1;
11726
+ }
11727
+ });
11240
11728
  await program.parseAsync();
11241
11729
  function printStartupSummary(context) {
11242
11730
  const assignments = context.config.models?.assignments ?? {};