@bubblebrain-ai/bubble 0.0.18 → 0.0.20

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.
Files changed (58) hide show
  1. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -0
  2. package/dist/agent/internal-reminder-sanitizer.js +46 -0
  3. package/dist/agent.d.ts +9 -0
  4. package/dist/agent.js +305 -17
  5. package/dist/approval/controller.d.ts +6 -0
  6. package/dist/approval/controller.js +104 -11
  7. package/dist/debug-trace.js +4 -0
  8. package/dist/feishu/agent-host/run-driver.js +28 -0
  9. package/dist/hooks/config.d.ts +9 -0
  10. package/dist/hooks/config.js +278 -0
  11. package/dist/hooks/controller.d.ts +24 -0
  12. package/dist/hooks/controller.js +254 -0
  13. package/dist/hooks/index.d.ts +6 -0
  14. package/dist/hooks/index.js +4 -0
  15. package/dist/hooks/log.d.ts +14 -0
  16. package/dist/hooks/log.js +54 -0
  17. package/dist/hooks/runner.d.ts +5 -0
  18. package/dist/hooks/runner.js +225 -0
  19. package/dist/hooks/trust.d.ts +37 -0
  20. package/dist/hooks/trust.js +143 -0
  21. package/dist/hooks/types.d.ts +173 -0
  22. package/dist/hooks/types.js +46 -0
  23. package/dist/main.js +32 -0
  24. package/dist/memory/prompts.js +3 -1
  25. package/dist/model-catalog.js +2 -0
  26. package/dist/model-pricing.js +8 -0
  27. package/dist/network/chatgpt-transport.js +34 -9
  28. package/dist/network/provider-transport.d.ts +32 -0
  29. package/dist/network/provider-transport.js +265 -0
  30. package/dist/network/retry.d.ts +29 -0
  31. package/dist/network/retry.js +88 -0
  32. package/dist/network/system-proxy.d.ts +18 -0
  33. package/dist/network/system-proxy.js +175 -0
  34. package/dist/provider-anthropic.d.ts +1 -0
  35. package/dist/provider-anthropic.js +127 -52
  36. package/dist/provider-openai-codex.js +19 -29
  37. package/dist/session-log.js +3 -3
  38. package/dist/slash-commands/commands.js +84 -0
  39. package/dist/slash-commands/types.d.ts +2 -0
  40. package/dist/tools/edit-apply.js +63 -3
  41. package/dist/tools/edit.js +4 -4
  42. package/dist/tui/display-history.d.ts +4 -3
  43. package/dist/tui/display-history.js +34 -57
  44. package/dist/tui/display-sanitizer.d.ts +3 -0
  45. package/dist/tui/display-sanitizer.js +38 -0
  46. package/dist/tui/paste-placeholder.d.ts +1 -0
  47. package/dist/tui/paste-placeholder.js +7 -0
  48. package/dist/tui/run.d.ts +2 -0
  49. package/dist/tui/run.js +260 -155
  50. package/dist/tui/trace-groups.js +40 -4
  51. package/dist/tui/wordmark.d.ts +1 -0
  52. package/dist/tui/wordmark.js +56 -54
  53. package/dist/tui-ink/app.js +2 -1
  54. package/dist/tui-ink/trace-groups.js +40 -4
  55. package/dist/tui-opentui/app.js +2 -1
  56. package/dist/tui-opentui/trace-groups.js +40 -4
  57. package/dist/types.d.ts +27 -0
  58. package/package.json +1 -1
@@ -1,6 +1,10 @@
1
1
  import { getAvailableThinkingLevels, normalizeThinkingLevel } from "./provider-transform.js";
2
+ import { isProviderTransportError, normalizeProviderNetworkError, providerFetch } from "./network/provider-transport.js";
3
+ import { computeRetryDelayMs, getProviderMaxRetries, isRetryableHttpStatus, ProviderStreamInterruptedError, retryAfterMsFromResponse, sleepBeforeRetry, } from "./network/retry.js";
2
4
  const ANTHROPIC_VERSION = "2023-06-01";
3
5
  const DEFAULT_MAX_TOKENS = 8192;
6
+ const ANTHROPIC_OPUS_LONG_OUTPUT_MAX_TOKENS = 128000;
7
+ const ANTHROPIC_LONG_OUTPUT_MAX_TOKENS = 64000;
4
8
  const ANTHROPIC_PROMPT_CACHE_CONTROL = { type: "ephemeral" };
5
9
  const MINIMAX_PROMPT_CACHE_MODELS = new Set([
6
10
  "minimax-m2.7",
@@ -22,14 +26,14 @@ export function createAnthropicMessagesProvider(options) {
22
26
  thinkingLevel: chatOptions.thinkingLevel,
23
27
  stream: true,
24
28
  });
25
- const response = await fetchAnthropicResponseWithRetry(options, {
29
+ const events = streamAnthropicEventsWithRetry(options, {
26
30
  url: resolveAnthropicMessagesUrl(options.baseURL),
27
31
  stream: true,
28
32
  method: "POST",
29
33
  body: JSON.stringify(body),
30
34
  signal: chatOptions.abortSignal,
31
35
  });
32
- yield* translateAnthropicStream(readSseEvents(response));
36
+ yield* translateAnthropicStream(events);
33
37
  yield { type: "done" };
34
38
  }
35
39
  async function complete(messages, chatOptions) {
@@ -65,24 +69,37 @@ export function buildAnthropicRequest(options, messages, chatOptions) {
65
69
  cache_control: ANTHROPIC_PROMPT_CACHE_CONTROL,
66
70
  };
67
71
  }
72
+ const effectiveThinkingLevel = normalizeThinkingLevel(chatOptions.thinkingLevel ?? options.thinkingLevel ?? "off", getAvailableThinkingLevels(options.providerId || "", chatOptions.model));
68
73
  const body = {
69
74
  model: chatOptions.model,
70
- max_tokens: DEFAULT_MAX_TOKENS,
75
+ max_tokens: resolveAnthropicMaxTokens(options, chatOptions.model),
71
76
  system: buildAnthropicSystem(system, enablePromptCache),
72
77
  messages: anthropicMessages,
73
78
  tools: tools && tools.length > 0 ? tools : undefined,
74
79
  tool_choice: tools && tools.length > 0 ? { type: chatOptions.toolChoice ?? "auto" } : undefined,
75
80
  stream: chatOptions.stream || undefined,
76
81
  };
77
- if (typeof chatOptions.temperature === "number") {
82
+ if (typeof chatOptions.temperature === "number"
83
+ && shouldSendTemperature(options, chatOptions.model, effectiveThinkingLevel)) {
78
84
  body.temperature = chatOptions.temperature;
79
85
  }
80
- const effectiveThinkingLevel = normalizeThinkingLevel(chatOptions.thinkingLevel ?? options.thinkingLevel ?? "off", getAvailableThinkingLevels(options.providerId || "", chatOptions.model));
81
86
  if (effectiveThinkingLevel !== "off") {
82
87
  body.thinking = { type: "adaptive" };
83
88
  }
84
89
  return body;
85
90
  }
91
+ export function resolveAnthropicMaxTokens(options, model) {
92
+ if (!isOfficialAnthropicBaseUrl(options.baseURL)) {
93
+ return DEFAULT_MAX_TOKENS;
94
+ }
95
+ if (isFableModelWith128kOutput(model) || isOpusModelWith128kOutput(model)) {
96
+ return ANTHROPIC_OPUS_LONG_OUTPUT_MAX_TOKENS;
97
+ }
98
+ if (isSonnetOrHaikuModelWith64kOutput(model)) {
99
+ return ANTHROPIC_LONG_OUTPUT_MAX_TOKENS;
100
+ }
101
+ return DEFAULT_MAX_TOKENS;
102
+ }
86
103
  function buildAnthropicSystem(system, enablePromptCache) {
87
104
  if (!system)
88
105
  return undefined;
@@ -352,27 +369,71 @@ export async function* readSseEvents(response) {
352
369
  reader.releaseLock();
353
370
  }
354
371
  }
372
+ async function* streamAnthropicEventsWithRetry(options, request) {
373
+ const maxRetries = getProviderMaxRetries();
374
+ for (let attempt = 0;; attempt++) {
375
+ // Connection-level failures and retryable HTTP statuses are retried
376
+ // inside fetchAnthropicResponseWithRetry; an error thrown from it has
377
+ // already exhausted its budget, so it propagates without another loop.
378
+ const response = await fetchAnthropicResponseWithRetry(options, request);
379
+ let sawSseEvent = false;
380
+ try {
381
+ for await (const event of readSseEvents(response)) {
382
+ sawSseEvent = true;
383
+ yield event;
384
+ }
385
+ return;
386
+ }
387
+ catch (error) {
388
+ const normalized = normalizeAnthropicTransportError(error, request.url);
389
+ if (sawSseEvent) {
390
+ // Partial content already surfaced — only the agent loop can discard
391
+ // the half-built assistant message and safely re-issue the request.
392
+ if (!request.signal?.aborted && isProviderTransportError(error)) {
393
+ throw new ProviderStreamInterruptedError(normalized.message, { cause: normalized });
394
+ }
395
+ throw normalized;
396
+ }
397
+ if (request.signal?.aborted || attempt >= maxRetries || !isProviderTransportError(error)) {
398
+ throw normalized;
399
+ }
400
+ await sleepBeforeRetry(computeRetryDelayMs(attempt + 1), request.signal);
401
+ }
402
+ }
403
+ }
355
404
  async function fetchAnthropicResponseWithRetry(options, request) {
356
- const maxAttempts = shouldRetryMiniMaxAnthropic(options) ? 2 : 1;
357
- let lastError;
358
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
359
- const response = await fetch(request.url, {
360
- method: request.method,
361
- headers: buildAnthropicHeaders(options, request.stream),
362
- body: request.body,
363
- signal: request.signal,
364
- });
405
+ const maxRetries = getProviderMaxRetries();
406
+ for (let attempt = 0;; attempt++) {
407
+ let response;
408
+ try {
409
+ response = await providerFetch(request.url, {
410
+ method: request.method,
411
+ headers: buildAnthropicHeaders(options, request.stream),
412
+ body: request.body,
413
+ signal: request.signal,
414
+ keepalive: false,
415
+ }, {
416
+ providerName: "Anthropic",
417
+ verboseEnvVar: "BUBBLE_ANTHROPIC_FETCH_VERBOSE",
418
+ });
419
+ }
420
+ catch (error) {
421
+ // No response received, so the request is safe to re-issue.
422
+ if (request.signal?.aborted || attempt >= maxRetries || !isProviderTransportError(error)) {
423
+ throw normalizeAnthropicTransportError(error, request.url);
424
+ }
425
+ await sleepBeforeRetry(computeRetryDelayMs(attempt + 1), request.signal);
426
+ continue;
427
+ }
365
428
  if (response.ok)
366
429
  return response;
367
430
  const detail = await readAnthropicErrorDetail(response);
368
431
  const error = new Error(`Anthropic Messages API error ${response.status}: ${detail || response.statusText}`);
369
- lastError = error;
370
- if (attempt >= maxAttempts || !isRetryableMiniMaxAnthropicError(response.status, detail)) {
432
+ if (request.signal?.aborted || attempt >= maxRetries || !isRetryableAnthropicHttpError(response.status, detail)) {
371
433
  throw error;
372
434
  }
373
- await sleepBeforeRetry(getAnthropicRetryDelayMs(), request.signal);
435
+ await sleepBeforeRetry(computeRetryDelayMs(attempt + 1, { retryAfterMs: retryAfterMsFromResponse(response) }), request.signal);
374
436
  }
375
- throw lastError ?? new Error("Anthropic Messages API request failed");
376
437
  }
377
438
  function resolveAnthropicMessagesUrl(baseURL) {
378
439
  const normalized = baseURL.trim().replace(/\/+$/, "");
@@ -403,42 +464,18 @@ async function readAnthropicErrorDetail(response) {
403
464
  return response.statusText;
404
465
  }
405
466
  }
406
- function shouldRetryMiniMaxAnthropic(options) {
407
- const providerId = (options.providerId || "").toLowerCase();
408
- const baseURL = options.baseURL.toLowerCase();
409
- return providerId.startsWith("minimax") || baseURL.includes("api.minimaxi.com") || baseURL.includes("api.minimax.io");
410
- }
411
- function isRetryableMiniMaxAnthropicError(status, detail) {
412
- return status === 500
413
- || status === 502
414
- || status === 503
415
- || status === 504
416
- || detail.includes("714 (1000)");
417
- }
418
- function getAnthropicRetryDelayMs() {
419
- if (process.env.NODE_ENV === "test")
420
- return 0;
421
- return 800 + Math.floor(Math.random() * 700);
422
- }
423
- function sleepBeforeRetry(ms, signal) {
424
- if (signal?.aborted) {
425
- return Promise.reject(toAbortError(signal));
426
- }
427
- return new Promise((resolve, reject) => {
428
- const timeout = setTimeout(resolve, ms);
429
- signal?.addEventListener("abort", () => {
430
- clearTimeout(timeout);
431
- reject(toAbortError(signal));
432
- }, { once: true });
467
+ function normalizeAnthropicTransportError(error, url) {
468
+ if (!isProviderTransportError(error)) {
469
+ return error instanceof Error ? error : new Error(String(error));
470
+ }
471
+ return normalizeProviderNetworkError(error, {
472
+ providerName: "Anthropic",
473
+ input: url,
433
474
  });
434
475
  }
435
- function toAbortError(signal) {
436
- const reason = signal?.reason;
437
- if (reason instanceof Error)
438
- return reason;
439
- const error = new Error("Anthropic request retry aborted.");
440
- error.name = "AbortError";
441
- return error;
476
+ function isRetryableAnthropicHttpError(status, detail) {
477
+ // "714 (1000)" is a transient MiniMax backend error surfaced as a 500.
478
+ return isRetryableHttpStatus(status) || detail.includes("714 (1000)");
442
479
  }
443
480
  function parseSseEvent(raw) {
444
481
  const dataLines = [];
@@ -573,6 +610,44 @@ function shouldEchoThinking(providerId) {
573
610
  function shouldSendBearerAuth(options) {
574
611
  return !isOfficialAnthropicBaseUrl(options.baseURL) || options.providerId?.startsWith("minimax") === true;
575
612
  }
613
+ function shouldSendTemperature(options, model, thinkingLevel) {
614
+ if (!isOfficialAnthropicBaseUrl(options.baseURL))
615
+ return true;
616
+ if (thinkingLevel !== "off")
617
+ return false;
618
+ return !isOpusModelWithoutSamplingControls(model);
619
+ }
620
+ function isOpusModelWith128kOutput(model) {
621
+ return isClaudeFamilyVersionAtLeast(model, "opus", 4, 6);
622
+ }
623
+ function isFableModelWith128kOutput(model) {
624
+ return model.toLowerCase().startsWith("claude-fable-5");
625
+ }
626
+ function isSonnetOrHaikuModelWith64kOutput(model) {
627
+ const normalized = model.toLowerCase();
628
+ return normalized.startsWith("claude-sonnet-4-6")
629
+ || normalized.startsWith("claude-haiku-4-5");
630
+ }
631
+ function isOpusModelWithoutSamplingControls(model) {
632
+ return isClaudeFamilyVersionAtLeast(model, "opus", 4, 7);
633
+ }
634
+ function isClaudeFamilyVersionAtLeast(model, family, minMajor, minMinor) {
635
+ const normalized = model.toLowerCase();
636
+ if (!normalized.startsWith(`claude-${family}-`))
637
+ return false;
638
+ const [, , majorSegment, minorSegment] = normalized.split("-");
639
+ const major = Number(majorSegment);
640
+ if (!Number.isFinite(major))
641
+ return false;
642
+ if (major > minMajor)
643
+ return true;
644
+ if (major < minMajor)
645
+ return false;
646
+ if (!minorSegment || minorSegment.length > 2)
647
+ return false;
648
+ const minor = Number(minorSegment);
649
+ return Number.isFinite(minor) && minor >= minMinor;
650
+ }
576
651
  function isMiniMaxAnthropicEndpoint(options) {
577
652
  const providerId = (options.providerId ?? "").toLowerCase();
578
653
  if (providerId !== "minimax" && providerId !== "minimax-anthropic")
@@ -2,11 +2,10 @@ import { createHash } from "node:crypto";
2
2
  import { listBuiltinModels } from "./model-catalog.js";
3
3
  import { resolveProviderRequestConfig } from "./provider-transform.js";
4
4
  import { chatGptFetch } from "./network/chatgpt-transport.js";
5
+ import { computeRetryDelayMs, getProviderMaxRetries, isRetryableHttpStatus, ProviderStreamInterruptedError, sleepBeforeRetry, } from "./network/retry.js";
5
6
  const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
6
7
  const OPENAI_BETA_RESPONSES = "responses=experimental";
7
8
  const TOKEN_REFRESH_GRACE_MS = 5 * 60 * 1000;
8
- const CODEX_TRANSPORT_MAX_RETRIES = 2;
9
- const CODEX_TRANSPORT_RETRY_BASE_DELAY_MS = 250;
10
9
  // OpenAI gates new codex models server-side by client_version (each model carries a
11
10
  // `minimal_client_version`). Track a recent real Codex CLI release; override via env
12
11
  // when OpenAI lifts the gate again before we cut a new release.
@@ -221,6 +220,13 @@ export function createOpenAICodexProvider(options) {
221
220
  return;
222
221
  }
223
222
  catch (error) {
223
+ if (sawParsedSseEvent
224
+ && !chatOptions.abortSignal?.aborted
225
+ && isTransientCodexTransportError(error)) {
226
+ // Partial content already surfaced — the agent loop discards the
227
+ // half-built assistant message and re-issues the whole request.
228
+ throw new ProviderStreamInterruptedError(error instanceof Error ? error.message : String(error), { cause: error });
229
+ }
224
230
  if (!shouldRetryCodexTransportError({
225
231
  error,
226
232
  attempt,
@@ -229,7 +235,7 @@ export function createOpenAICodexProvider(options) {
229
235
  })) {
230
236
  throw error;
231
237
  }
232
- await sleepBeforeCodexRetry(codexRetryDelayMs(attempt), chatOptions.abortSignal);
238
+ await sleepBeforeRetry(computeRetryDelayMs(attempt + 1), chatOptions.abortSignal);
233
239
  }
234
240
  }
235
241
  }
@@ -439,9 +445,17 @@ function shouldRetryCodexTransportError(input) {
439
445
  return false;
440
446
  if (input.sawParsedSseEvent)
441
447
  return false;
442
- if (input.attempt >= CODEX_TRANSPORT_MAX_RETRIES)
448
+ if (input.attempt >= getProviderMaxRetries())
449
+ return false;
450
+ return isTransientCodexTransportError(input.error) || isRetryableCodexHttpError(input.error);
451
+ }
452
+ function isRetryableCodexHttpError(error) {
453
+ if (!(error instanceof Error))
454
+ return false;
455
+ const match = error.message.match(/^(\d{3}) status code/);
456
+ if (!match)
443
457
  return false;
444
- return isTransientCodexTransportError(input.error);
458
+ return isRetryableHttpStatus(Number(match[1]));
445
459
  }
446
460
  function isTransientCodexTransportError(error) {
447
461
  const text = errorMessageChain(error).join("\n");
@@ -487,30 +501,6 @@ function errorMessageChain(error) {
487
501
  }
488
502
  return messages;
489
503
  }
490
- function codexRetryDelayMs(attempt) {
491
- return CODEX_TRANSPORT_RETRY_BASE_DELAY_MS * Math.pow(3, attempt);
492
- }
493
- function sleepBeforeCodexRetry(ms, signal) {
494
- if (signal?.aborted)
495
- return Promise.reject(toAbortError(signal));
496
- return new Promise((resolve, reject) => {
497
- const onAbort = () => {
498
- clearTimeout(timeout);
499
- signal?.removeEventListener("abort", onAbort);
500
- reject(toAbortError(signal));
501
- };
502
- const timeout = setTimeout(() => {
503
- signal?.removeEventListener("abort", onAbort);
504
- resolve();
505
- }, ms);
506
- signal?.addEventListener("abort", onAbort, { once: true });
507
- });
508
- }
509
- function toAbortError(signal) {
510
- if (signal?.reason instanceof Error)
511
- return signal.reason;
512
- return new DOMException(typeof signal?.reason === "string" ? signal.reason : "Aborted", "AbortError");
513
- }
514
504
  function buildBaseHeaders(accessToken, accountId, sessionId, extraHeaders) {
515
505
  const headers = new Headers(extraHeaders);
516
506
  headers.set("Authorization", `Bearer ${accessToken}`);
@@ -1,4 +1,4 @@
1
- import { sanitizeAssistantProviderMetadata, sanitizeInternalReminderBlocks } from "./agent/internal-reminder-sanitizer.js";
1
+ import { sanitizeAssistantProviderMetadata, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "./agent/internal-reminder-sanitizer.js";
2
2
  export class SessionLog {
3
3
  entries = [];
4
4
  load(lines) {
@@ -130,7 +130,7 @@ export class SessionLog {
130
130
  role: "assistant",
131
131
  content: sanitizeInternalReminderBlocks(entry.message.content),
132
132
  reasoning: entry.message.reasoning !== undefined
133
- ? sanitizeInternalReminderBlocks(entry.message.reasoning)
133
+ ? sanitizeInternalReasoningText(entry.message.reasoning)
134
134
  : undefined,
135
135
  providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(entry.message.providerMetadata)),
136
136
  });
@@ -197,7 +197,7 @@ function normalizeMessageToEntries(message, id, timestamp) {
197
197
  role: "assistant",
198
198
  content: sanitizeInternalReminderBlocks(message.content),
199
199
  reasoning: message.reasoning !== undefined
200
- ? sanitizeInternalReminderBlocks(message.reasoning)
200
+ ? sanitizeInternalReasoningText(message.reasoning)
201
201
  : undefined,
202
202
  model: message.model,
203
203
  providerId: message.providerId,
@@ -6,6 +6,7 @@ import { parseRule } from "../permissions/rule.js";
6
6
  import { encodeModel, decodeModel, displayModel, BUILTIN_PROVIDERS, isUserVisibleProvider } from "../provider-registry.js";
7
7
  import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
8
8
  import { buildSystemPrompt } from "../system-prompt.js";
9
+ import { HOOK_EVENT_NAMES, isHookEventName } from "../hooks/index.js";
9
10
  import { isThinkingLevel } from "../variant/thinking-level.js";
10
11
  import { collectUsageStatsBundle, formatStatsText } from "../stats/usage.js";
11
12
  import { buildMemoryPrompt, getMemoryStatus, isMemoryDisabled, resetMemory, searchMemory, } from "../memory/index.js";
@@ -49,6 +50,45 @@ function handlePermissionsMutation(sub, tokens, ctx) {
49
50
  return `Rule not found in ${scope} ${list}: ${rule}`;
50
51
  return `Removed from ${scope} ${list}: ${rule}`;
51
52
  }
53
+ async function handleHooksCommand(args, ctx) {
54
+ const hooks = ctx.hookController;
55
+ if (!hooks)
56
+ return "Hooks controller is not attached to this session.";
57
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
58
+ const sub = tokens[0] ?? "status";
59
+ if (sub === "status" || sub === "list" || sub === "") {
60
+ return hooks.status();
61
+ }
62
+ if (sub === "reload") {
63
+ hooks.reload();
64
+ return `Reloaded hooks.\n\n${hooks.status()}`;
65
+ }
66
+ if (sub === "trust" && tokens[1] === "project") {
67
+ return hooks.trustProject();
68
+ }
69
+ if (sub === "untrust" && tokens[1] === "project") {
70
+ return hooks.untrustProject();
71
+ }
72
+ if (sub === "test") {
73
+ const event = tokens[1];
74
+ if (!isHookEventName(event)) {
75
+ return `Usage: /hooks test <event> [target]\nEvents: ${HOOK_EVENT_NAMES.join(", ")}`;
76
+ }
77
+ return hooks.test(event, tokens.slice(2).join(" ") || undefined);
78
+ }
79
+ if (sub === "explain") {
80
+ const event = tokens[1];
81
+ if (!isHookEventName(event)) {
82
+ return `Usage: /hooks explain <event>\nEvents: ${HOOK_EVENT_NAMES.join(", ")}`;
83
+ }
84
+ return hooks.explain(event);
85
+ }
86
+ if (sub === "logs") {
87
+ const limit = Number(tokens[1] ?? 20);
88
+ return hooks.logs(Number.isFinite(limit) ? limit : 20);
89
+ }
90
+ return "Usage: /hooks [status|reload|trust project|untrust project|test <event> [target]|explain <event>|logs [limit]]";
91
+ }
52
92
  function persistSelectedModel(model, ctx) {
53
93
  const userConfig = new UserConfig();
54
94
  userConfig.setDefaultModel(model);
@@ -667,6 +707,13 @@ const builtinSlashCommandEntries = [
667
707
  return lines.join("\n");
668
708
  },
669
709
  },
710
+ {
711
+ name: "hooks",
712
+ description: "Inspect and manage lifecycle hooks. Usage: /hooks [status|trust project|test <event>]",
713
+ async handler(args, ctx) {
714
+ return handleHooksCommand(args, ctx);
715
+ },
716
+ },
670
717
  {
671
718
  name: "lsp",
672
719
  description: "Inspect or restart language servers. Usage: /lsp [status|diagnostics|restart]",
@@ -788,8 +835,33 @@ const builtinSlashCommandEntries = [
788
835
  if (!ctx.sessionManager) {
789
836
  return "Compaction requires session persistence. Start an interactive session first.";
790
837
  }
838
+ const preHook = await ctx.hookController?.runEvent({
839
+ eventName: "PreCompact",
840
+ cwd: ctx.cwd,
841
+ sessionId: ctx.sessionManager.getSessionFile(),
842
+ agentRole: "driver",
843
+ target: "manual",
844
+ payload: {
845
+ kind: "manual",
846
+ messageCount: ctx.agent.messages.length,
847
+ },
848
+ });
849
+ if (preHook?.decision === "deny") {
850
+ return preHook.reason ?? `Compaction blocked by hook ${preHook.sourceHookId ?? "<unknown>"}.`;
851
+ }
791
852
  const result = ctx.sessionManager.compact();
792
853
  if (!result.compacted) {
854
+ await ctx.hookController?.runEvent({
855
+ eventName: "PostCompact",
856
+ cwd: ctx.cwd,
857
+ sessionId: ctx.sessionManager.getSessionFile(),
858
+ agentRole: "driver",
859
+ target: "manual",
860
+ payload: {
861
+ kind: "manual",
862
+ compacted: false,
863
+ },
864
+ });
793
865
  return "Session is already compact enough.";
794
866
  }
795
867
  const systemMessage = ctx.agent.messages.find((message) => message.role === "system");
@@ -799,6 +871,18 @@ const builtinSlashCommandEntries = [
799
871
  ];
800
872
  ctx.agent.resetContextUsageAnchor();
801
873
  const dropped = result.droppedEntries ?? 0;
874
+ await ctx.hookController?.runEvent({
875
+ eventName: "PostCompact",
876
+ cwd: ctx.cwd,
877
+ sessionId: ctx.sessionManager.getSessionFile(),
878
+ agentRole: "driver",
879
+ target: "manual",
880
+ payload: {
881
+ kind: "manual",
882
+ compacted: true,
883
+ droppedEntries: dropped,
884
+ },
885
+ });
802
886
  return `✓ Compaction complete · ${dropped} log entr${dropped === 1 ? "y" : "ies"} summarized`;
803
887
  },
804
888
  },
@@ -9,6 +9,7 @@ import type { McpManager } from "../mcp/manager.js";
9
9
  import type { LspService } from "../lsp/index.js";
10
10
  import type { MemoryScope } from "../memory/index.js";
11
11
  import type { ThemeMode } from "../config.js";
12
+ import type { ExternalHookController } from "../hooks/controller.js";
12
13
  export type SidebarMode = "auto" | "expanded" | "collapsed";
13
14
  export interface SidebarCommandState {
14
15
  mode: SidebarMode;
@@ -28,6 +29,7 @@ export interface SlashCommandContext {
28
29
  skillRegistry: SkillRegistry;
29
30
  bashAllowlist?: BashAllowlist;
30
31
  settingsManager?: SettingsManager;
32
+ hookController?: ExternalHookController;
31
33
  mcpManager?: McpManager;
32
34
  lspService?: LspService;
33
35
  flushMemory?: () => Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { isSensitivePath } from "./sensitive-paths.js";
1
2
  export class EditApplyError extends Error {
2
3
  status;
3
4
  constructor(message, status = "no_match") {
@@ -6,6 +7,9 @@ export class EditApplyError extends Error {
6
7
  this.name = "EditApplyError";
7
8
  }
8
9
  }
10
+ const CANDIDATE_EXCERPT_CONTEXT_LINES = 3;
11
+ const CANDIDATE_EXCERPT_MAX_LINES = 8;
12
+ const CANDIDATE_EXCERPT_MAX_CHARS = 1200;
9
13
  function detectLineEnding(content) {
10
14
  const crlf = content.indexOf("\r\n");
11
15
  const lf = content.indexOf("\n");
@@ -234,19 +238,75 @@ function findBestLineHint(content, oldText) {
234
238
  return undefined;
235
239
  const contentLines = nonBlankLines(splitLines(content));
236
240
  let best;
241
+ let tieCount = 0;
237
242
  for (let i = 0; i < contentLines.length; i++) {
238
243
  let score = 0;
239
244
  for (let j = 0; j < oldLines.length && i + j < contentLines.length; j++) {
240
245
  if (contentLines[i + j].normalized === oldLines[j])
241
246
  score++;
242
247
  }
243
- if (!best || score > best.score)
248
+ if (!best || score > best.score) {
244
249
  best = { index: i, score };
250
+ tieCount = 1;
251
+ }
252
+ else if (score === best.score) {
253
+ tieCount++;
254
+ }
245
255
  }
246
256
  if (!best || best.score === 0)
247
257
  return undefined;
248
258
  const startLine = contentLines[best.index].lineIndex + 1;
249
- return `Closest line-based candidate starts near line ${startLine} and matched ${best.score}/${oldLines.length} non-blank lines.`;
259
+ return {
260
+ startLine,
261
+ score: best.score,
262
+ total: oldLines.length,
263
+ lineIndex: contentLines[best.index].lineIndex,
264
+ tieCount,
265
+ };
266
+ }
267
+ function isHighConfidenceLineHint(hint) {
268
+ return hint.score >= 2 && hint.score / hint.total >= 0.5 && hint.tieCount === 1;
269
+ }
270
+ function formatLineHint(hint) {
271
+ if (hint.tieCount > 1) {
272
+ return `Closest ambiguous line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines, but ${hint.tieCount} candidates tied. Current bytes were not included because the candidate may be unrelated.`;
273
+ }
274
+ if (!isHighConfidenceLineHint(hint)) {
275
+ return `Closest low-confidence line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines. Current bytes were not included because the candidate may be unrelated.`;
276
+ }
277
+ return `Closest line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines.`;
278
+ }
279
+ function formatFence(content) {
280
+ let fence = "```";
281
+ while (content.includes(fence))
282
+ fence += "`";
283
+ return `${fence}\n${content}\n${fence}`;
284
+ }
285
+ function truncateExcerpt(excerpt) {
286
+ if (excerpt.length <= CANDIDATE_EXCERPT_MAX_CHARS)
287
+ return excerpt;
288
+ const marker = "\n...[truncated current candidate excerpt]";
289
+ return excerpt.slice(0, Math.max(0, CANDIDATE_EXCERPT_MAX_CHARS - marker.length)) + marker;
290
+ }
291
+ function formatCandidateExcerpt(content, hint) {
292
+ const lines = splitLines(content);
293
+ const startLineIndex = Math.max(0, hint.lineIndex - CANDIDATE_EXCERPT_CONTEXT_LINES);
294
+ const requestedEnd = Math.min(lines.length, hint.lineIndex + CANDIDATE_EXCERPT_CONTEXT_LINES + 1);
295
+ const endLineIndex = Math.min(requestedEnd, startLineIndex + CANDIDATE_EXCERPT_MAX_LINES);
296
+ const excerpt = truncateExcerpt(lines.slice(startLineIndex, endLineIndex).map((line) => line.text).join("\n"));
297
+ return [
298
+ `Current candidate excerpt (high confidence, current file lines ${startLineIndex + 1}-${endLineIndex}, not guaranteed target):`,
299
+ formatFence(excerpt),
300
+ ].join("\n");
301
+ }
302
+ function formatBestLineHint(content, hint, options) {
303
+ const lineHint = formatLineHint(hint);
304
+ if (!isHighConfidenceLineHint(hint))
305
+ return lineHint;
306
+ if (options?.path && isSensitivePath(options.path)) {
307
+ return `${lineHint}\nCurrent bytes were not included because this path is blocked by the sensitive-path read policy.`;
308
+ }
309
+ return `${lineHint}\n\n${formatCandidateExcerpt(content, hint)}`;
250
310
  }
251
311
  function matchEdit(content, edit, index, total, options) {
252
312
  if (edit.oldText.length === 0) {
@@ -350,7 +410,7 @@ function matchEdit(content, edit, index, total, options) {
350
410
  }
351
411
  }
352
412
  const hint = findBestLineHint(content, oldText);
353
- const hintSuffix = hint ? `\n${hint}` : "";
413
+ const hintSuffix = hint ? `\n${formatBestLineHint(content, hint, options)}` : "";
354
414
  const recovery = [
355
415
  "",
356
416
  "How to recover:",
@@ -65,12 +65,12 @@ export function createEditTool(cwd, approval, lsp, fileState) {
65
65
  name: "edit",
66
66
  effect: "write_direct",
67
67
  requiresApproval: true,
68
- description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
68
+ description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes.",
69
69
  promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
70
70
  promptGuidelines: [
71
- "Use edit for precise changes; edits[].oldText should be copied from a recent read and must identify a unique target.",
72
- "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls.",
73
- "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
71
+ "Use edit for precise changes; each edits[].oldText must be copied verbatim from a fresh read of the current exact target block and must identify a unique target. Do not reconstruct oldText from memory, stale reads, or similar code elsewhere.",
72
+ "When changing multiple small, clearly disjoint locations copied from the same fresh read, you may use one edit call with multiple entries in edits[]. Use separate smaller edit calls after re-reading when anchors are uncertain, stale, or likely to drift.",
73
+ "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits; merge only truly overlapping targets.",
74
74
  "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
75
75
  ],
76
76
  parameters: {
@@ -2,7 +2,6 @@ import type { ToolResultMetadata, TokenUsage } from "../types.js";
2
2
  export interface CompactionMeta {
3
3
  turns: number;
4
4
  messages: number;
5
- tokensSaved: number;
6
5
  summarySections: Array<{
7
6
  label: string;
8
7
  content: string;
@@ -10,11 +9,12 @@ export interface CompactionMeta {
10
9
  contextWindow?: number;
11
10
  compactedAt: number;
12
11
  }
12
+ export type UserInputStatus = "queued" | "pending_steer";
13
13
  export interface DisplayMessage {
14
14
  role: "user" | "assistant" | "error";
15
15
  content: string;
16
16
  clientId?: string;
17
- queued?: boolean;
17
+ inputStatus?: UserInputStatus;
18
18
  reasoning?: string;
19
19
  toolCalls?: DisplayToolCall[];
20
20
  parts?: DisplayMessagePart[];
@@ -53,11 +53,12 @@ export interface DisplayToolCall {
53
53
  startedAt?: number;
54
54
  completedAt?: number;
55
55
  }
56
+ export declare function userInputStatusBadgeLabel(status?: UserInputStatus): string | undefined;
57
+ export declare function setUserInputStatus(message: DisplayMessage, inputStatus?: UserInputStatus): DisplayMessage;
56
58
  export declare function appendTextPart(parts: DisplayMessagePart[], content: string): void;
57
59
  export declare function appendToolPart(parts: DisplayMessagePart[], toolCall: DisplayToolCall): void;
58
60
  export declare function snapshotDisplayParts(parts: DisplayMessagePart[]): DisplayMessagePart[];
59
61
  export declare function contentFromParts(parts: DisplayMessagePart[]): string;
60
62
  export declare function toolCallsFromParts(parts: DisplayMessagePart[]): DisplayToolCall[];
61
63
  export declare function compactDisplayMessages(messages: DisplayMessage[]): DisplayMessage[];
62
- export declare function truncateText(value: string, maxChars: number): string;
63
64
  export declare function formatCompactNumber(n: number): string;