@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.
- package/README.md +141 -3
- package/dist/api/aiSdkClient.js +260 -0
- package/dist/api/client.js +2 -1
- package/dist/api/llmClient.js +19 -0
- package/dist/api/models.js +106 -0
- package/dist/api/provider.js +16 -0
- package/dist/config/settings.js +101 -1
- package/dist/core/agent.js +235 -10
- package/dist/core/hooks.js +358 -0
- package/dist/core/sessions.js +5 -1
- package/dist/headless.js +31 -3
- package/dist/prompts/system.js +4 -0
- package/dist/ui/App.js +85 -9
- package/dist/ui/components/HookTrustPrompt.js +21 -0
- package/package.json +4 -1
package/dist/config/settings.js
CHANGED
|
@@ -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)) {
|
package/dist/core/agent.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
702
|
+
return resultText;
|
|
478
703
|
}
|
|
479
704
|
}
|