@matterailab/orbcode 0.1.10 → 0.1.14

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,7 +1,9 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { DEFAULT_MODEL_ID, isValidAxonModel, registerCustomModels } from "../api/models.js";
6
+ import { isHookEvent } from "../core/hooks.js";
5
7
  const DEFAULTS = {
6
8
  model: DEFAULT_MODEL_ID,
7
9
  autoApproveEdits: false,
@@ -27,6 +29,19 @@ function getConfigPath() {
27
29
  export function getSettingsPaths(cwd = process.cwd()) {
28
30
  return [path.join(getConfigDir(), "settings.json"), path.join(cwd, ".orbcode", "settings.json")];
29
31
  }
32
+ /** Concatenate a settings file's `hooks` block into the accumulator, keeping
33
+ * only known events and well-formed matcher arrays. */
34
+ function mergeHooksInto(target, raw) {
35
+ if (!raw || typeof raw !== "object")
36
+ return;
37
+ for (const [event, matchers] of Object.entries(raw)) {
38
+ if (!isHookEvent(event))
39
+ continue;
40
+ if (!Array.isArray(matchers))
41
+ continue;
42
+ (target[event] ??= []).push(...matchers);
43
+ }
44
+ }
30
45
  function readJson(filePath) {
31
46
  try {
32
47
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -35,6 +50,74 @@ function readJson(filePath) {
35
50
  return undefined;
36
51
  }
37
52
  }
53
+ // --- Project-hook trust ---------------------------------------------------
54
+ // User-level hooks (~/.orbcode/settings.json) are written by the user and are
55
+ // always trusted. Project-level hooks (<cwd>/.orbcode/settings.json) ship
56
+ // inside a repo and could come from anyone, so they run arbitrary shell
57
+ // commands only after the user explicitly trusts them. Trust is keyed by
58
+ // project path + a hash of the hooks, so changing the hooks re-prompts.
59
+ function getProjectSettingsPath(cwd) {
60
+ return path.join(cwd, ".orbcode", "settings.json");
61
+ }
62
+ function getTrustStorePath() {
63
+ return path.join(getConfigDir(), "hook-trust.json");
64
+ }
65
+ /** Normalized project hooks (known events only), or undefined if none. */
66
+ function readProjectHooks(cwd) {
67
+ const merged = {};
68
+ mergeHooksInto(merged, readJson(getProjectSettingsPath(cwd))?.hooks);
69
+ return Object.keys(merged).length > 0 ? merged : undefined;
70
+ }
71
+ function hashHooks(hooks) {
72
+ return crypto.createHash("sha256").update(JSON.stringify(hooks)).digest("hex").slice(0, 16);
73
+ }
74
+ function isProjectHooksTrusted(cwd, hash) {
75
+ // Escape hatch for CI / non-interactive automation. Only honored when
76
+ // stdin is not a TTY (so a stray export in a shell rc file can't silently
77
+ // disable the trust gate for interactive sessions). A non-TTY check keeps
78
+ // the gate meaningful in the TUI while still letting CI opt in.
79
+ if (process.env.ORBCODE_TRUST_PROJECT_HOOKS === "1" && !process.stdin.isTTY)
80
+ return true;
81
+ const store = readJson(getTrustStorePath());
82
+ return Boolean(store && store[cwd] === hash);
83
+ }
84
+ /** Persist trust for the current project's hooks (call after the user agrees). */
85
+ export function trustProjectHooks(cwd = process.cwd()) {
86
+ const hooks = readProjectHooks(cwd);
87
+ if (!hooks)
88
+ return;
89
+ const store = readJson(getTrustStorePath()) ?? {};
90
+ store[cwd] = hashHooks(hooks);
91
+ try {
92
+ fs.mkdirSync(getConfigDir(), { recursive: true });
93
+ fs.writeFileSync(getTrustStorePath(), JSON.stringify(store, null, "\t") + "\n", { mode: 0o600 });
94
+ }
95
+ catch {
96
+ // best-effort; if it can't be persisted the user will simply be re-prompted
97
+ }
98
+ }
99
+ /**
100
+ * If this project defines hooks that are not yet trusted, return the list of
101
+ * shell commands awaiting approval; otherwise null (no project hooks, or already
102
+ * trusted). The TUI uses this to gate project hooks behind a trust prompt.
103
+ */
104
+ export function getPendingProjectHooks(cwd = process.cwd()) {
105
+ const hooks = readProjectHooks(cwd);
106
+ if (!hooks)
107
+ return null;
108
+ if (isProjectHooksTrusted(cwd, hashHooks(hooks)))
109
+ return null;
110
+ const commands = [];
111
+ for (const matchers of Object.values(hooks)) {
112
+ for (const matcher of matchers ?? []) {
113
+ for (const hook of matcher.hooks ?? []) {
114
+ if (hook?.command)
115
+ commands.push(hook.command);
116
+ }
117
+ }
118
+ }
119
+ return { commands };
120
+ }
38
121
  /** Make sure ~/.orbcode/settings.json exists (empty JSON) so users can find it. */
39
122
  function ensureUserSettingsFile() {
40
123
  try {
@@ -52,8 +135,9 @@ export function loadSettings() {
52
135
  ensureUserSettingsFile();
53
136
  const settings = { ...DEFAULTS, ...readJson(getConfigPath()) };
54
137
  // Layer settings.json files on top (user config dir, then project).
138
+ const cwd = process.cwd();
55
139
  const customModels = [];
56
- for (const settingsPath of getSettingsPaths()) {
140
+ for (const settingsPath of getSettingsPaths(cwd)) {
57
141
  const fileSettings = readJson(settingsPath);
58
142
  if (!fileSettings)
59
143
  continue;
@@ -71,6 +155,22 @@ export function loadSettings() {
71
155
  settings.customModels = customModels;
72
156
  registerCustomModels(customModels);
73
157
  }
158
+ // Hooks: user-level hooks always apply; project-level hooks run alongside
159
+ // them but only once trusted (they execute arbitrary shell commands). The
160
+ // two sets concatenate, so a trusted project adds to — never clobbers — your
161
+ // global hooks.
162
+ const mergedHooks = {};
163
+ mergeHooksInto(mergedHooks, readJson(path.join(getConfigDir(), "settings.json"))?.hooks);
164
+ const projectHooks = readProjectHooks(cwd);
165
+ if (projectHooks && isProjectHooksTrusted(cwd, hashHooks(projectHooks))) {
166
+ for (const [event, matchers] of Object.entries(projectHooks)) {
167
+ ;
168
+ (mergedHooks[event] ??= []).push(...matchers);
169
+ }
170
+ }
171
+ if (Object.keys(mergedHooks).length > 0) {
172
+ settings.hooks = mergedHooks;
173
+ }
74
174
  // env block from settings.json applies to this process.
75
175
  if (settings.env && typeof settings.env === "object") {
76
176
  for (const [key, value] of Object.entries(settings.env)) {
@@ -1,12 +1,14 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { AxonClient } from "../api/client.js";
4
+ import { REASONING_DETAILS_FIELD } from "../api/llmClient.js";
5
+ import { createLLMClient } from "../api/provider.js";
5
6
  import { buildSystemPrompt } from "../prompts/system.js";
6
7
  import { describeToolCall, executeTool, getActiveTools, getApprovalKind, } from "../tools/index.js";
7
8
  import { walkFiles } from "../tools/executors/listFiles.js";
8
9
  import { previewFileChange } from "../tools/executors/files.js";
9
- import { saveSession } from "./sessions.js";
10
+ import { getSessionFilePath, saveSession } from "./sessions.js";
11
+ import { HookRunner } from "./hooks.js";
10
12
  const MAX_STEPS_PER_TURN = 50;
11
13
  const RESULT_PREVIEW_LINES = 6;
12
14
  function detectRepo(cwd) {
@@ -50,6 +52,11 @@ function getGitSummary(cwd) {
50
52
  function stripUserQueryTags(text) {
51
53
  return text.replace(/<user_query>\n?/g, "").replace(/\n?<\/user_query>/g, "");
52
54
  }
55
+ /** Wrap hook-injected context in clearly delimited tags so the model can
56
+ * distinguish it from user/system content (prompt-injection defense). */
57
+ function wrapHookContext(source, text) {
58
+ return `<hook_context source="${source}">\n${text}\n</hook_context>`;
59
+ }
53
60
  export class Agent {
54
61
  options;
55
62
  client;
@@ -69,10 +76,27 @@ export class Agent {
69
76
  contextTokens = 0;
70
77
  title = "";
71
78
  createdAt = new Date().toISOString();
79
+ hooks;
80
+ /** SessionStart fires once, lazily, before the first turn of this instance. */
81
+ sessionStarted = false;
82
+ /** carries SessionStart additionalContext into the first user message */
83
+ pendingStartContext = "";
84
+ /** guards Stop-hook forced continuation against infinite loops */
85
+ stopHookActive = false;
86
+ /** in-flight fire-and-forget hook promises (Notification), awaited on exit */
87
+ pendingBackground = new Set();
72
88
  taskId;
73
89
  constructor(options) {
74
90
  this.options = options;
75
91
  this.taskId = options.resume?.id ?? randomUUID();
92
+ this.hooks = new HookRunner({
93
+ cwd: options.cwd,
94
+ sessionId: this.taskId,
95
+ transcriptPath: getSessionFilePath(this.taskId),
96
+ config: options.hooks,
97
+ onSystemMessage: (message, isError) => options.callbacks.onEvent({ type: "system", message, isError }),
98
+ getSignal: () => this.abortController?.signal,
99
+ });
76
100
  if (options.resume) {
77
101
  this.messages = options.resume.messages;
78
102
  this.todos = options.resume.todos;
@@ -84,7 +108,7 @@ export class Agent {
84
108
  }
85
109
  this.sessionApproveEdits = options.autoApproveEdits;
86
110
  this.systemPrompt = buildSystemPrompt(options.cwd);
87
- this.client = new AxonClient({
111
+ this.client = createLLMClient({
88
112
  token: options.token,
89
113
  modelId: options.modelId,
90
114
  taskId: this.taskId,
@@ -95,7 +119,7 @@ export class Agent {
95
119
  }
96
120
  setModel(modelId) {
97
121
  this.options.modelId = modelId;
98
- this.client = new AxonClient({
122
+ this.client = createLLMClient({
99
123
  token: this.options.token,
100
124
  modelId,
101
125
  taskId: this.taskId,
@@ -121,6 +145,11 @@ export class Agent {
121
145
  this.title = title;
122
146
  this.persist();
123
147
  }
148
+ /** Swap the hook config mid-session (e.g. after the user trusts project hooks). */
149
+ setHooks(hooks) {
150
+ this.options.hooks = hooks;
151
+ this.hooks.setConfig(hooks);
152
+ }
124
153
  /** Update auto-approval behavior mid-session (shift+tab cycling in the TUI). */
125
154
  setApprovalMode(autoApproveEdits, autoApproveSafeCommands) {
126
155
  this.sessionApproveEdits = autoApproveEdits;
@@ -136,6 +165,9 @@ export class Agent {
136
165
  this.totalCost = 0;
137
166
  this.contextTokens = 0;
138
167
  this.title = "";
168
+ this.pendingStartContext = "";
169
+ this.sessionStarted = false;
170
+ this.stopHookActive = false;
139
171
  }
140
172
  /** Write the current conversation to the sessions directory. */
141
173
  persist() {
@@ -206,6 +238,21 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
206
238
  async runTurn(userText) {
207
239
  const { onEvent } = this.options.callbacks;
208
240
  this.abortController = new AbortController();
241
+ await this.maybeFireSessionStart();
242
+ // UserPromptSubmit may block the prompt outright or attach extra context.
243
+ let promptContext = "";
244
+ if (this.hooks.hasHooks("UserPromptSubmit")) {
245
+ const result = await this.hooks.run("UserPromptSubmit", { prompt: userText });
246
+ if (result.blocked || result.stopAll) {
247
+ const reason = result.blockReason || result.stopReason || "Prompt blocked by a hook.";
248
+ onEvent({ type: "system", message: reason, isError: true });
249
+ this.abortController = undefined;
250
+ onEvent({ type: "turn-end" });
251
+ return;
252
+ }
253
+ if (result.additionalContext)
254
+ promptContext = result.additionalContext;
255
+ }
209
256
  if (!this.title) {
210
257
  this.title = userText.replace(/\s+/g, " ").trim().slice(0, 80);
211
258
  }
@@ -214,12 +261,27 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
214
261
  userContent = `${this.buildEnvironmentDetails()}\n\n${userContent}`;
215
262
  this.firstMessageSent = true;
216
263
  }
264
+ // SessionStart context sits above the prompt; UserPromptSubmit context
265
+ // is appended after it. Both reach the model but neither is shown as
266
+ // user-typed text (they live outside the <user_query> markers).
267
+ if (this.pendingStartContext) {
268
+ userContent = `${wrapHookContext("SessionStart", this.pendingStartContext)}\n\n${userContent}`;
269
+ this.pendingStartContext = "";
270
+ }
271
+ if (promptContext) {
272
+ userContent = `${userContent}\n\n${wrapHookContext("UserPromptSubmit", promptContext)}`;
273
+ }
217
274
  this.messages.push({ role: "user", content: userContent });
218
275
  try {
276
+ this.stopHookActive = false;
219
277
  for (let step = 0; step < MAX_STEPS_PER_TURN; step++) {
220
278
  const done = await this.runStep();
221
- if (done)
222
- break;
279
+ if (!done)
280
+ continue;
281
+ // The model is ready to stop; Stop hooks may force it to continue.
282
+ if (await this.shouldContinueAfterStop())
283
+ continue;
284
+ break;
223
285
  }
224
286
  }
225
287
  catch (error) {
@@ -241,6 +303,65 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
241
303
  onEvent({ type: "turn-end" });
242
304
  }
243
305
  }
306
+ /** Fire SessionStart once per instance, stashing any injected context for
307
+ * the first user message. */
308
+ async maybeFireSessionStart() {
309
+ if (this.sessionStarted)
310
+ return;
311
+ this.sessionStarted = true;
312
+ if (!this.hooks.hasHooks("SessionStart"))
313
+ return;
314
+ const result = await this.hooks.run("SessionStart", {
315
+ source: this.options.resume ? "resume" : "startup",
316
+ });
317
+ if (result.additionalContext)
318
+ this.pendingStartContext = result.additionalContext;
319
+ }
320
+ /** Ask Stop hooks whether the turn should keep going. Forces at most one
321
+ * continuation per turn (stop_hook_active) so a hook can't loop forever. */
322
+ async shouldContinueAfterStop() {
323
+ if (!this.hooks.hasHooks("Stop"))
324
+ return false;
325
+ const result = await this.hooks.run("Stop", { stop_hook_active: this.stopHookActive });
326
+ if (result.stopAll)
327
+ return false;
328
+ if (result.blocked && !this.stopHookActive) {
329
+ this.stopHookActive = true;
330
+ const reason = result.blockReason || "A Stop hook asked you to keep going.";
331
+ this.messages.push({ role: "user", content: `System reminder (Stop hook): ${reason}` });
332
+ return true;
333
+ }
334
+ return false;
335
+ }
336
+ /** Track a fire-and-forget hook promise so it can be awaited on exit. */
337
+ trackBackground(p) {
338
+ this.pendingBackground.add(p);
339
+ p.finally(() => this.pendingBackground.delete(p));
340
+ }
341
+ /** Wait (up to `timeoutMs`) for in-flight background hooks to settle. */
342
+ async awaitBackground(timeoutMs) {
343
+ if (this.pendingBackground.size === 0)
344
+ return;
345
+ const all = Promise.allSettled([...this.pendingBackground]);
346
+ const cap = new Promise((resolve) => {
347
+ const t = setTimeout(resolve, timeoutMs);
348
+ t.unref?.();
349
+ });
350
+ await Promise.race([all, cap]);
351
+ }
352
+ /** Fire SessionEnd hooks. Best-effort; never blocks shutdown. */
353
+ async endSession(reason) {
354
+ // Let in-flight Notification hooks settle before the final SessionEnd.
355
+ await this.awaitBackground(3000);
356
+ if (!this.hooks.hasHooks("SessionEnd"))
357
+ return;
358
+ try {
359
+ await this.hooks.run("SessionEnd", { reason });
360
+ }
361
+ catch {
362
+ // a SessionEnd hook must never prevent the app from exiting
363
+ }
364
+ }
244
365
  /** Summarize the conversation so far and replace history with the summary. */
245
366
  async compact() {
246
367
  const { onEvent } = this.options.callbacks;
@@ -249,6 +370,20 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
249
370
  onEvent({ type: "turn-end" });
250
371
  return;
251
372
  }
373
+ // Covers the resume-then-immediately-/compact path, so SessionStart
374
+ // always fires before any SessionEnd.
375
+ await this.maybeFireSessionStart();
376
+ // If SessionStart produced context, fold it into the compaction request
377
+ // rather than letting it linger for the next turn.
378
+ let startContext = "";
379
+ if (this.pendingStartContext) {
380
+ startContext = wrapHookContext("SessionStart", this.pendingStartContext) + "\n\n";
381
+ this.pendingStartContext = "";
382
+ }
383
+ // PreCompact runs before summarizing (it cannot cancel compaction).
384
+ if (this.hooks.hasHooks("PreCompact")) {
385
+ await this.hooks.run("PreCompact", { trigger: "manual", custom_instructions: "" });
386
+ }
252
387
  this.abortController = new AbortController();
253
388
  const signal = this.abortController.signal;
254
389
  try {
@@ -256,7 +391,8 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
256
391
  ...this.outgoingMessages(),
257
392
  {
258
393
  role: "user",
259
- content: "Summarize this conversation so it can replace the full history. Capture the user's goals, decisions made, files created or modified (with paths), important code details, and any remaining next steps. Be thorough but concise. Respond with only the summary.",
394
+ content: startContext +
395
+ "Summarize this conversation so it can replace the full history. Capture the user's goals, decisions made, files created or modified (with paths), important code details, and any remaining next steps. Be thorough but concise. Respond with only the summary.",
260
396
  },
261
397
  ];
262
398
  let summary = "";
@@ -313,6 +449,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
313
449
  let assistantText = "";
314
450
  let hadReasoning = false;
315
451
  let reasoningStart = 0;
452
+ let reasoningDetails;
316
453
  const toolCallsByIndex = new Map();
317
454
  let nextSyntheticIndex = 10000;
318
455
  const stream = this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(), signal);
@@ -331,6 +468,10 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
331
468
  }
332
469
  onEvent({ type: "reasoning-delta", text: chunk.text });
333
470
  break;
471
+ case "reasoning_details":
472
+ // Opaque thinking blocks (with signatures) for next-turn replay.
473
+ reasoningDetails = chunk.details;
474
+ break;
334
475
  case "native_tool_calls":
335
476
  for (const tc of chunk.toolCalls) {
336
477
  const index = tc.index ?? nextSyntheticIndex++;
@@ -378,6 +519,12 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
378
519
  function: { name: tc.name, arguments: tc.arguments || "{}" },
379
520
  }));
380
521
  }
522
+ // Stash reasoning blocks (opaque) so the next turn can replay them. The
523
+ // field is persisted with the session and stripped on the OpenAI path.
524
+ if (reasoningDetails !== undefined) {
525
+ ;
526
+ assistantMessage[REASONING_DETAILS_FIELD] = reasoningDetails;
527
+ }
381
528
  this.messages.push(assistantMessage);
382
529
  if (toolCalls.length === 0) {
383
530
  return true;
@@ -410,7 +557,6 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
410
557
  });
411
558
  return message;
412
559
  }
413
- const summary = describeToolCall(toolCall.name, args);
414
560
  if (toolCall.name === "attempt_completion") {
415
561
  onEvent({ type: "completion", result: String(args.result ?? "") });
416
562
  return "The user has been shown the completion result.";
@@ -420,9 +566,54 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
420
566
  const suggestions = (Array.isArray(args.follow_up) ? args.follow_up : [])
421
567
  .map((s) => ({ text: String(s?.text ?? "") }))
422
568
  .filter((s) => s.text);
569
+ // Notification fires whenever OrbCode pauses to wait on the user.
570
+ if (this.hooks.hasHooks("Notification")) {
571
+ this.trackBackground(this.hooks.run("Notification", {
572
+ message: question || "OrbCode is asking a follow-up question.",
573
+ }));
574
+ }
423
575
  const answer = await requestFollowup(question, suggestions);
424
576
  return `<answer>\n${answer}\n</answer>`;
425
577
  }
578
+ // PreToolUse runs before approval/execution. It can block the call,
579
+ // override the approval decision, rewrite the tool input, or add context.
580
+ let preContext = "";
581
+ let bypassApproval = false;
582
+ let forceApproval = false;
583
+ if (this.hooks.hasHooks("PreToolUse")) {
584
+ const pre = await this.hooks.run("PreToolUse", { tool_name: toolCall.name, tool_input: args });
585
+ if (pre.stopAll || pre.blocked || pre.permissionDecision === "deny") {
586
+ const reason = pre.blockReason || pre.stopReason || pre.permissionReason || "Blocked by a PreToolUse hook.";
587
+ const blockedSummary = describeToolCall(toolCall.name, args);
588
+ onEvent({ type: "tool-start", id: toolCall.id, name: toolCall.name, summary: blockedSummary });
589
+ onEvent({
590
+ type: "tool-end",
591
+ id: toolCall.id,
592
+ name: toolCall.name,
593
+ summary: blockedSummary,
594
+ resultPreview: reason,
595
+ isError: true,
596
+ });
597
+ if (pre.stopAll)
598
+ this.abortController?.abort();
599
+ return reason;
600
+ }
601
+ if (pre.updatedInput) {
602
+ args = pre.updatedInput;
603
+ onEvent({
604
+ type: "system",
605
+ message: `PreToolUse hook rewrote the input for ${toolCall.name}.`,
606
+ isError: false,
607
+ });
608
+ }
609
+ if (pre.permissionDecision === "allow")
610
+ bypassApproval = true;
611
+ if (pre.permissionDecision === "ask")
612
+ forceApproval = true;
613
+ if (pre.additionalContext)
614
+ preContext = pre.additionalContext;
615
+ }
616
+ const summary = describeToolCall(toolCall.name, args);
426
617
  onEvent({ type: "tool-start", id: toolCall.id, name: toolCall.name, summary });
427
618
  const approvalKind = getApprovalKind(toolCall.name, args);
428
619
  const diff = approvalKind === "edit" ? previewFileChange(toolCall.name, args, this.options.cwd) : undefined;
@@ -433,7 +624,18 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
433
624
  if (approvalKind === "command") {
434
625
  needsApproval = isDangerous || !(this.sessionApproveCommands || this.options.autoApproveSafeCommands);
435
626
  }
627
+ // A PreToolUse hook can force the approval prompt ("ask") or skip it ("allow").
628
+ if (forceApproval)
629
+ needsApproval = true;
630
+ else if (bypassApproval)
631
+ needsApproval = false;
436
632
  if (needsApproval) {
633
+ // Notification fires when OrbCode needs the user to grant permission.
634
+ if (this.hooks.hasHooks("Notification")) {
635
+ this.trackBackground(this.hooks.run("Notification", {
636
+ message: `OrbCode needs your permission to use ${toolCall.name}`,
637
+ }));
638
+ }
437
639
  const decision = await requestApproval({
438
640
  kind: approvalKind,
439
641
  toolName: toolCall.name,
@@ -462,7 +664,30 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
462
664
  }
463
665
  }
464
666
  const result = await executeTool(toolCall.name, args, this.toolContext());
465
- const previewLines = result.text.split("\n");
667
+ // PostToolUse can feed extra context (or a block reason) back to the
668
+ // model. PreToolUse additionalContext is delivered here too.
669
+ let resultText = result.text;
670
+ const extras = [];
671
+ if (preContext)
672
+ extras.push(wrapHookContext("PreToolUse", preContext));
673
+ if (this.hooks.hasHooks("PostToolUse")) {
674
+ const post = await this.hooks.run("PostToolUse", {
675
+ tool_name: toolCall.name,
676
+ tool_input: args,
677
+ tool_response: result.text,
678
+ });
679
+ if (post.additionalContext)
680
+ extras.push(wrapHookContext("PostToolUse", post.additionalContext));
681
+ if (post.blocked && post.blockReason)
682
+ extras.push(`[PostToolUse hook]: ${post.blockReason}`);
683
+ if (post.stopAll) {
684
+ this.abortController?.abort();
685
+ onEvent({ type: "system", message: "A PostToolUse hook stopped the turn.", isError: false });
686
+ }
687
+ }
688
+ if (extras.length)
689
+ resultText += `\n\n${extras.join("\n\n")}`;
690
+ const previewLines = resultText.split("\n");
466
691
  const resultPreview = previewLines.slice(0, RESULT_PREVIEW_LINES).join("\n") +
467
692
  (previewLines.length > RESULT_PREVIEW_LINES ? `\n… (${previewLines.length} lines)` : "");
468
693
  onEvent({
@@ -474,6 +699,6 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
474
699
  isError: Boolean(result.isError),
475
700
  diff: result.isError ? undefined : diff,
476
701
  });
477
- return result.text;
702
+ return resultText;
478
703
  }
479
704
  }