@denizokcu/haze 0.0.3 → 0.1.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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.0 - 2026-06-07
6
+
7
+ - Added ripgrep-backed `grep` for fast workspace search with regex, glob, context-line, case-insensitive, and result-limit options.
8
+ - Added focused `subagent` delegation for independent parallel tasks with fresh context, step caps, concise summaries, tool-call metadata, and parent abort propagation.
9
+ - Added compact inline diff display for successful `editFile` and `replaceLines` calls, including added/removed counts, colored additions/removals, one context line around small changes, and hidden summaries for large diffs.
10
+ - Improved agent-loop completion handling for truncated model output and long-running tool loops.
11
+ - Refined subagent prompting and parent transcript summaries to reduce noise and discourage single-task delegation.
12
+ - Updated release documentation and roadmap state for the 0.1.0 foundation release.
13
+
5
14
  ## 0.0.3 - 2026-06-06
6
15
 
7
16
  - Added stable transcript rendering for long sessions, compact placeholders for large multiline pastes, and clearer goal/status display.
package/README.md CHANGED
@@ -2,11 +2,16 @@
2
2
 
3
3
  A minimal LLM harness for your terminal.
4
4
 
5
- ## What's new in 0.0.3
5
+ ## What's new in 0.1.0
6
6
 
7
- Haze 0.0.3 keeps long sessions calmer with stable transcript rendering, compact placeholders for large multiline pastes, and clearer goal/status display. Pasted text is still sent to the model with line breaks preserved.
7
+ Haze 0.1.0 is the foundation release: the agent can now *find*, *delegate*, and *show its work* without turning your terminal into soup.
8
8
 
9
- Haze gives an AI model a small set of transparent local tools — read files, edit files, write files, list files, and run commandsthen gets out of the way. Start with chat. Build your workflows as you work. Teach Haze with Markdown skills when a pattern repeats. Tiny spell, useful goblin.
9
+ - `grep` gives the model fast, targeted codebase search with regex, globs, context lines, and `.gitignore` awarenessno more brute-force file spelunking.
10
+ - Subagents let Haze fan out independent investigations into fresh contexts, then fold the result back into the main turn as a concise summary.
11
+ - File edits now render compact, colorized inline diffs with one context line around the change; big diffs stay summarized so signal beats scrollback.
12
+ - Long-turn handling is calmer: truncated model output and tool-heavy loops recover more gracefully.
13
+
14
+ The result is a more capable agent loop while keeping the core small and inspectable. Haze gives an AI model transparent local tools — read, search, edit, write, list, and run commands — plus focused delegation when work can split safely. Tiny spell, sharper goblin.
10
15
 
11
16
  Haze works with OpenAI-compatible providers, including OpenRouter and local endpoints. Use `/provider` to choose or add one, then `/model` to select a model.
12
17
 
@@ -47,7 +52,7 @@ On first run, create or choose a provider, then choose your first model:
47
52
  `/model` selects the model Haze should use. You can also set one directly:
48
53
 
49
54
  ```txt
50
- /model x-ai/grok-build-0.1
55
+ /model anthropic/claude-sonnet-4.6
51
56
  /model local:llama3.1
52
57
  ```
53
58
 
@@ -57,7 +62,7 @@ Or use environment variables for any OpenAI-compatible endpoint:
57
62
  # e.g. OpenRouter, OpenAI, LM Studio, Ollama, or an OpenAI-compatible proxy
58
63
  export OPENAI_API_KEY=... # provider API key, if needed; local providers may not need one
59
64
  export OPENAI_BASE_URL=https://openrouter.ai/api/v1 # or http://localhost:1234/v1, http://localhost:11434/v1, ...
60
- export HAZE_MODEL=x-ai/grok-build-0.1 # or gpt-4.1, llama3.1, qwen2.5-coder, ...
65
+ export HAZE_MODEL=anthropic/claude-sonnet-4.6 # or gpt-4.1, llama3.1, qwen2.5-coder, ...
61
66
  ```
62
67
 
63
68
  Saved settings live in `~/.haze/settings.json`. Providers can include API keys, base URLs, and model lists; local OpenAI-compatible providers can be configured without a key.
@@ -72,7 +77,7 @@ Open a project and ask for work:
72
77
  create a calculator in calc-app in ruby with add subtract multiply divide
73
78
  ```
74
79
 
75
- Haze will inspect, write files, run commands, and show compact tool activity inline. Sessions are saved by default so you can resume the latest workspace conversation with `haze --continue` or `/resume`.
80
+ Haze will inspect, search, write files, run commands, and show compact tool activity inline. Small file edits include a colorized line diff with one context line before and after the change; large diffs stay summarized so the transcript does not become a wall of noise. Sessions are saved by default so you can resume the latest workspace conversation with `haze --continue` or `/resume`.
76
81
 
77
82
  Use `/` to discover commands and skills. `Tab` completes the top suggestion.
78
83
 
@@ -185,13 +190,20 @@ Haze exposes a deliberately small toolset:
185
190
 
186
191
  - `listFiles` — structured discovery, recursive with cursor pagination when needed.
187
192
  - `readFile` — read UTF-8 files with optional line ranges.
193
+ - `grep` — ripgrep-backed regex search with path, glob, context-line, case, and result-limit controls.
188
194
  - `editFile` — unique text replacements, with line-number-prefix tolerance for common model mistakes.
189
195
  - `replaceLines` — line-range edits when exact replacements are awkward; slightly-too-large EOF ranges are clamped.
190
196
  - `writeFile` — create files and parent directories.
191
197
  - `bash` — run tests, builds, git commands, and inspections.
192
198
  - `skill_*` — load Markdown skill instructions on demand.
193
199
 
194
- Tool calls are grouped in the transcript so you can see what happened without reading a novella. File-tool failures return structured recovery hints instead of mystery stack traces.
200
+ Tool calls are grouped in the transcript so you can see what happened without reading a novella. Successful targeted file edits show a compact diff with colored additions/removals and one context line around the change when the diff is small; larger diffs are summarized with a pointer to `git diff`. File-tool failures return structured recovery hints instead of mystery stack traces.
201
+
202
+ ## Subagents
203
+
204
+ Subagents are a delegation feature, not another file operation. When a request clearly splits into independent parallel work, Haze can spin up focused agents with fresh context, let them inspect or act with their own capped tool loop, then fold their concise summaries back into the main conversation.
205
+
206
+ Use them for parallel investigation across separate areas of a codebase. Do not use them for single sequential tasks where the main agent already has the best context.
195
207
 
196
208
  ## Context files
197
209
 
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { execFile as execFileCallback } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
@@ -87,6 +87,17 @@ function fullWidthBlankLine(width) {
87
87
  function ToolMessageText({ text, streaming }) {
88
88
  const lines = text.split('\n');
89
89
  return _jsx(Box, { flexDirection: "column", children: lines.map((line, index) => {
90
+ const diffRow = /^(\s*\d+\s+)([+-])(.*)$/.exec(line);
91
+ if (diffRow) {
92
+ const [, prefix, marker, rest] = diffRow;
93
+ const isAdd = marker === '+';
94
+ return _jsxs(Text, { color: "white", backgroundColor: isAdd ? theme.successBg : theme.dangerBg, children: [_jsxs(Text, { color: isAdd ? theme.success : theme.danger, backgroundColor: isAdd ? theme.successBg : theme.dangerBg, children: [prefix, marker] }), rest] }, `${index}-${line}`);
95
+ }
96
+ const contextRow = /^(\s*\d+\s+)\s(.*)$/.exec(line);
97
+ if (contextRow) {
98
+ const [, prefix, rest] = contextRow;
99
+ return _jsxs(Text, { color: "white", children: [_jsxs(Text, { color: theme.muted, children: [prefix, " "] }), rest] }, `${index}-${line}`);
100
+ }
90
101
  const row = /^(\s*)([✓✗…])\s+(\S+)(.*)$/.exec(line);
91
102
  if (!row) {
92
103
  return _jsxs(Text, { color: theme.muted, children: [index === 0 && streaming ? _jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, line] }, `${index}-${line}`);
@@ -133,6 +144,15 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
133
144
  const [messages, setMessages] = useState([
134
145
  { role: 'system', text: 'Welcome to Haze. Use /help for commands.' }
135
146
  ]);
147
+ const [liveMessages, setLiveMessages] = useState([]);
148
+ const liveMessagesRef = useRef([]);
149
+ const setLiveMessagesState = (updater) => {
150
+ setLiveMessages(previous => {
151
+ const next = updater(previous);
152
+ liveMessagesRef.current = next;
153
+ return next;
154
+ });
155
+ };
136
156
  const [settings, setSettings] = useState({});
137
157
  const conversationRef = useRef([]);
138
158
  const lastAssistantTextRef = useRef('');
@@ -224,6 +244,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
224
244
  sessionRef.current = session;
225
245
  conversationRef.current = conversation;
226
246
  setSessionLabel(session.id);
247
+ setLiveMessagesState(() => []);
227
248
  setMessages(m => [...m, { role: 'system', text: `Resumed session: ${formatSession(session)}` }, ...displayMessagesFromConversation(conversation)]);
228
249
  return;
229
250
  }
@@ -233,6 +254,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
233
254
  function clearConversation() {
234
255
  conversationRef.current = [];
235
256
  lastAssistantTextRef.current = '';
257
+ setLiveMessagesState(() => []);
236
258
  setMessages([{ role: 'system', text: 'Cleared. The void is productive.' }]);
237
259
  const session = sessionRef.current;
238
260
  if (session)
@@ -263,6 +285,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
263
285
  sessionRef.current = session;
264
286
  conversationRef.current = conversation;
265
287
  setSessionLabel(session.id);
288
+ setLiveMessagesState(() => []);
266
289
  setMessages([{ role: 'system', text: `Resumed session: ${formatSession(session)}` }, ...displayMessagesFromConversation(conversation)]);
267
290
  }
268
291
  function cancelThinking() {
@@ -507,6 +530,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
507
530
  newSession: async () => {
508
531
  conversationRef.current = [];
509
532
  lastAssistantTextRef.current = '';
533
+ setLiveMessagesState(() => []);
510
534
  setMessages([{ role: 'system', text: 'Started fresh. The fog parts.' }]);
511
535
  await startNewSession('Started a new session.');
512
536
  },
@@ -572,14 +596,39 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
572
596
  }
573
597
  }
574
598
  async function runSingleAgentTurn(value, displayValue) {
599
+ const persistUiMessage = (msg) => {
600
+ const session = sessionRef.current;
601
+ if (session)
602
+ void appendSessionEntry(session, { type: 'ui_message', at: new Date().toISOString(), role: msg.role, text: msg.text }).catch(() => undefined);
603
+ };
604
+ const finalizeMessage = (msg) => {
605
+ if (msg.hidden)
606
+ return;
607
+ setMessages(m => [...m, msg]);
608
+ persistUiMessage(msg);
609
+ };
575
610
  await runAgentTurn(value, displayValue, contextFiles, {
576
611
  addMessage: msg => {
577
- setMessages(m => [...m, msg]);
578
- const session = sessionRef.current;
579
- if (session)
580
- void appendSessionEntry(session, { type: 'ui_message', at: new Date().toISOString(), role: msg.role, text: msg.text }).catch(() => undefined);
612
+ if (msg.streaming) {
613
+ setLiveMessagesState(m => [...m, msg]);
614
+ return;
615
+ }
616
+ finalizeMessage(msg);
617
+ },
618
+ updateMessage: (id, update) => {
619
+ const liveMessage = liveMessagesRef.current.find(msg => msg.id === id);
620
+ if (liveMessage) {
621
+ const updated = { ...liveMessage, ...update };
622
+ if (updated.streaming === false) {
623
+ setLiveMessagesState(m => m.filter(msg => msg.id !== id));
624
+ finalizeMessage(updated);
625
+ return;
626
+ }
627
+ setLiveMessagesState(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg));
628
+ return;
629
+ }
630
+ setMessages(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg));
581
631
  },
582
- updateMessage: (id, update) => setMessages(m => m.map(msg => msg.id === id ? { ...msg, ...update } : msg)),
583
632
  setConversation: msgs => {
584
633
  conversationRef.current = msgs;
585
634
  const session = sessionRef.current;
@@ -587,6 +636,7 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
587
636
  void appendSessionEntry(session, { type: 'conversation_snapshot', at: new Date().toISOString(), messages: msgs }).catch(() => undefined);
588
637
  },
589
638
  setBusy,
639
+ setBusyLabel,
590
640
  debugLog,
591
641
  getConversation: () => conversationRef.current,
592
642
  getLastAssistantText: () => lastAssistantTextRef.current,
@@ -602,10 +652,8 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
602
652
  });
603
653
  }
604
654
  const visible = messages.filter(message => !message.hidden);
605
- const transcriptItems = visible
606
- .map((message, index) => ({ key: messageKey(message, index), message }))
607
- .filter(item => !item.message.streaming);
608
- const liveMessages = visible.filter(message => message.streaming);
655
+ const transcriptItems = visible.map((message, index) => ({ key: messageKey(message, index), message }));
656
+ const activeLiveMessages = liveMessages.filter(message => !message.hidden);
609
657
  const activeSelection = activeModel(settings);
610
658
  const placeholder = mode === 'provider'
611
659
  ? 'Choose provider'
@@ -639,8 +687,9 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
639
687
  ].join('\n')
640
688
  : 'First things first: run /provider to choose or add a provider, then select a model.';
641
689
  const workspaceLabel = `${process.cwd()}${branchName ? ` (${branchName})` : ''}`;
642
- const toolsUsed = toolCallCount(messages);
643
- const estimatedTokens = estimateConversationTokens(messages);
690
+ const allDisplayMessages = [...messages, ...liveMessages];
691
+ const toolsUsed = toolCallCount(allDisplayMessages);
692
+ const estimatedTokens = estimateConversationTokens(allDisplayMessages);
644
693
  const statusDetailLabel = `${conversationRef.current.length} messages / ${toolsUsed} tool call${toolsUsed === 1 ? '' : 's'} / ↑ ~${formatTokenCount(estimatedTokens.input)} ↓ ~${formatTokenCount(estimatedTokens.output)} / ${skills.length} skill${skills.length === 1 ? '' : 's'}${sessionLabel ? ` / ${sessionLabel}` : ''}`;
645
694
  const goalText = activeGoalStatus?.replace(/^Goal:\s*/, '');
646
695
  const [rawGoalRequest, ...goalStatusParts] = goalText?.split(' · ') ?? [];
@@ -672,7 +721,12 @@ function ChatScreen({ debug = false, version, continueSession = false, noSession
672
721
  ];
673
722
  return _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: item => item.kind === 'header'
674
723
  ? _jsx(Header, { subtitle: item.subtitle, version: version }, item.key)
675
- : _jsx(MessageView, { message: item.message, width: width }, item.key) }), liveMessages.length > 0 && _jsx(Box, { flexDirection: "column", flexShrink: 0, children: liveMessages.map((message, index) => _jsx(MessageView, { message: message, width: width }, messageKey(message, index))) }), debug && debugLogs.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, borderStyle: "round", borderColor: theme.muted, paddingX: 1, children: [_jsx(Text, { color: theme.muted, bold: true, children: "Debug" }), debugLogs.map((line, index) => _jsxs(Text, { color: theme.muted, children: ["\u2022 ", line] }, index))] }), queuedFollowUps.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, children: [_jsx(Text, { color: theme.muted, children: "Queued follow-ups:" }), queuedFollowUps.map((item, index) => _jsxs(Text, { color: theme.muted, dimColor: true, children: [" ", index + 1, ". ", item] }, `${index}-${item}`))] }), busy && _jsx(Box, { flexShrink: 0, marginBottom: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: theme.orange, bold: true, children: [_jsx(Spinner, { type: "dots" }), " ", busyLabel] }), _jsx(Text, { color: theme.muted, dimColor: true, children: " \u00B7 type to queue follow-up \u00B7 esc to interrupt" })] }) }), goalText && _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.blue, bold: true, children: "Goal:" }), _jsxs(Text, { color: "white", children: [" ", goalRequest] }), goalStatusText ? _jsxs(Text, { color: theme.orange, children: [" \u00B7 ", goalStatusText] }) : null] }) }), _jsx(Box, { borderStyle: "round", borderColor: theme.deepPurple, paddingX: 1, flexShrink: 0, children: _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(TextInput, { placeholder: placeholder, disabled: busy && mode !== 'chat', mask: mode === 'providerAddKey', historyItems: inputHistory, recordHistory: mode === 'chat', suggestions: inputSuggestions, suggestionMode: mode === 'provider' || mode === 'providerAction' || mode === 'model' ? 'always' : 'slash', submitOnEmpty: mode === 'providerAddKey', onHistoryAdd: persistInputHistory, onCancel: cancelThinking, onEscape: closeInputList, onSubmit: submit }) }) }), _jsxs(Box, { flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", flexShrink: 1, minWidth: 0, children: [_jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: workspaceLabel }), _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: statusDetailLabel })] }), _jsx(Box, { flexShrink: 0, marginLeft: 2, children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-start", children: activeModelName }) })] })] });
724
+ : _jsx(MessageView, { message: item.message, width: width }, item.key) }), activeLiveMessages.length > 0 && _jsx(Box, { flexDirection: "column", flexShrink: 0, children: activeLiveMessages.map((message, index) => _jsx(MessageView, { message: message, width: width }, messageKey(message, index))) }), debug && debugLogs.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, borderStyle: "round", borderColor: theme.muted, paddingX: 1, children: [_jsx(Text, { color: theme.muted, bold: true, children: "Debug" }), debugLogs.map((line, index) => _jsxs(Text, { color: theme.muted, children: ["\u2022 ", line] }, index))] }), queuedFollowUps.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, marginBottom: 1, children: [_jsx(Text, { color: theme.muted, children: "Queued follow-ups:" }), queuedFollowUps.map((item, index) => _jsxs(Text, { color: theme.muted, dimColor: true, children: [" ", index + 1, ". ", item] }, `${index}-${item}`))] }), busy && _jsx(Box, { flexShrink: 0, marginBottom: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: theme.orange, bold: true, children: [_jsx(Spinner, { type: "dots" }), " ", busyLabel] }), _jsx(Text, { color: theme.muted, dimColor: true, children: " \u00B7 type to queue follow-up \u00B7 esc to interrupt" })] }) }), goalText && _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.blue, bold: true, children: "Goal:" }), _jsxs(Text, { color: "white", children: [" ", goalRequest] }), goalStatusText ? _jsxs(Text, { color: theme.orange, children: [" \u00B7 ", goalStatusText] }) : null] }) }), _jsx(Box, { borderStyle: "round", borderColor: theme.deepPurple, paddingX: 1, flexShrink: 0, children: _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(TextInput, { placeholder: placeholder, disabled: busy && mode !== 'chat', mask: mode === 'providerAddKey', historyItems: inputHistory, recordHistory: mode === 'chat', suggestions: inputSuggestions, suggestionMode: mode === 'provider' || mode === 'providerAction' || mode === 'model' ? 'always' : 'slash', submitOnEmpty: mode === 'providerAddKey', onHistoryAdd: persistInputHistory, onCancel: cancelThinking, onEscape: () => {
725
+ if (busy)
726
+ cancelThinking();
727
+ else
728
+ closeInputList();
729
+ }, onSubmit: submit }) }) }), _jsxs(Box, { flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", flexShrink: 1, minWidth: 0, children: [_jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: workspaceLabel }), _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: statusDetailLabel })] }), _jsx(Box, { flexShrink: 0, marginLeft: 2, children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-start", children: activeModelName }) })] })] });
676
730
  }
677
731
  export async function chatCommand(options = {}) {
678
732
  if (process.stdout.isTTY) {
@@ -19,6 +19,11 @@ export function toolCallSummary(toolName, input) {
19
19
  const timeout = typeof data.timeoutSeconds === 'number' ? ` (timeout ${data.timeoutSeconds}s)` : '';
20
20
  return `bash $ ${data.command}${timeout}`;
21
21
  }
22
+ if (toolName === 'grep' && typeof data?.pattern === 'string') {
23
+ const path = typeof data.path === 'string' && data.path !== '.' ? ` in ${data.path}` : '';
24
+ const glob = typeof data.glob === 'string' ? ` (${data.glob})` : '';
25
+ return `grep "${data.pattern}"${path}${glob}`;
26
+ }
22
27
  if (toolName === 'listFiles' && typeof data?.path === 'string')
23
28
  return `listFiles ${data.path}`;
24
29
  if ((toolName === 'readFile' || toolName === 'writeFile') && typeof data?.path === 'string')
@@ -29,6 +34,10 @@ export function toolCallSummary(toolName, input) {
29
34
  }
30
35
  if (toolName === 'replaceLines' && typeof data?.path === 'string')
31
36
  return `replaceLines ${data.path}:${data.startLine}-${data.endLine}`;
37
+ if (toolName === 'subagent' && typeof data?.task === 'string') {
38
+ const taskPreview = data.task.length > 60 ? `${data.task.slice(0, 60).trimEnd()}…` : data.task;
39
+ return `subagent "${taskPreview}"`;
40
+ }
32
41
  return `${toolName} ${compact(input)}`;
33
42
  }
34
43
  export function toolResultSummary(event) {
@@ -37,11 +46,29 @@ export function toolResultSummary(event) {
37
46
  const output = event.output;
38
47
  if (output?.duplicateSkipped === true)
39
48
  return 'skipped duplicate';
49
+ if (typeof output?.totalMatches === 'number') {
50
+ const count = output.totalMatches;
51
+ return count === 0 ? 'no matches' : `${count} match${count === 1 ? '' : 'es'}`;
52
+ }
40
53
  if (typeof output?.code === 'number')
41
54
  return `exited with code ${output.code}`;
55
+ if (typeof output?.status === 'string' && typeof output?.summary === 'string') {
56
+ const summary = output.summary.split('\n')[0] ?? '';
57
+ const preview = summary.length > 120 ? `${summary.slice(0, 120).trimEnd()}…` : summary;
58
+ const calls = typeof output.toolCallCount === 'number' ? output.toolCallCount : output.toolCalls?.length ?? 0;
59
+ const duration = typeof output.durationMs === 'number' ? ` in ${(output.durationMs / 1000).toFixed(1)}s` : '';
60
+ const meta = calls > 0 ? ` (${calls} call${calls === 1 ? '' : 's'}${duration})` : '';
61
+ return `${output.status}${meta}: ${preview}`;
62
+ }
42
63
  if (typeof output?.ok === 'boolean') {
43
- if (output.ok)
64
+ if (output.ok) {
65
+ if (typeof output.addedLines === 'number' || typeof output.removedLines === 'number') {
66
+ const added = typeof output.addedLines === 'number' ? output.addedLines : 0;
67
+ const removed = typeof output.removedLines === 'number' ? output.removedLines : 0;
68
+ return `Added ${added} line${added === 1 ? '' : 's'}, removed ${removed} line${removed === 1 ? '' : 's'}`;
69
+ }
44
70
  return 'completed';
71
+ }
45
72
  return typeof output.error === 'string' ? `failed: ${compact(output.error)}` : 'failed';
46
73
  }
47
74
  return 'completed';
@@ -13,6 +13,7 @@ export interface StreamCallbacks {
13
13
  updateMessage: (id: string, update: Partial<Message>) => void;
14
14
  setConversation: (messages: ModelMessage[]) => void;
15
15
  setBusy: (busy: boolean) => void;
16
+ setBusyLabel?: (label: string) => void;
16
17
  debugLog: (line: string) => void;
17
18
  getConversation: () => ModelMessage[];
18
19
  getLastAssistantText: () => string;
@@ -10,6 +10,7 @@ import { completionDecision, looksIncomplete, noTextAfterToolPrompt, postContinu
10
10
  import { createSessionGoal, formatGoalStatus, observeGoalToolEvent } from '../../core/goal/sessionGoal.js';
11
11
  import { agentEvent } from '../../core/agent/events.js';
12
12
  import { isContextOverflowError, isRetryableModelError } from '../../core/agent/errors.js';
13
+ import { createSubagentTool } from '../../core/subagent/subagentRunner.js';
13
14
  function stableToolKey(toolCall) {
14
15
  return `${toolCall.toolName}:${JSON.stringify(toolCall.input)}`;
15
16
  }
@@ -44,6 +45,19 @@ function hideSyntheticToolCallMarkup(text) {
44
45
  .replace(/(^|\n)\s*(?:```(?:xml)?\s*)?(?:xml\s*)?<tool_call>[\s\S]*?<\/tool_call>\s*(?:```)?/gi, '$1')
45
46
  .replace(/(^|\n)\s*(?:```(?:xml)?\s*)?(?:xml\s*)?<tool_call>[\s\S]*$/i, '$1');
46
47
  }
48
+ function isNonSubstantiveAssistantText(text) {
49
+ return /^[`\s]*$/.test(text);
50
+ }
51
+ function assistantDisplayText(text) {
52
+ return hideSyntheticToolCallMarkup(text).trim();
53
+ }
54
+ function normalizeAssistantText(text) {
55
+ return assistantDisplayText(text)
56
+ .replace(/[`*_~#>\-–—:;,.!?()[\]{}"']/g, '')
57
+ .replace(/\s+/g, ' ')
58
+ .trim()
59
+ .toLowerCase();
60
+ }
47
61
  function toolInputPath(input) {
48
62
  return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
49
63
  ? input.path
@@ -66,7 +80,15 @@ async function abortableDelay(milliseconds, signal) {
66
80
  }, { once: true });
67
81
  });
68
82
  }
69
- const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
83
+ const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
84
+ const IDLE_TIMEOUT_MS = 5 * 60_000;
85
+ const MAIN_STEP_LIMIT = 40;
86
+ const MAIN_TOOL_CALL_LIMIT = 40;
87
+ const MAIN_TOOL_ONLY_STEP_LIMIT = 12;
88
+ const FOLLOW_UP_STEP_LIMIT = 30;
89
+ const FOLLOW_UP_TOOL_CALL_LIMIT = 30;
90
+ const FOLLOW_UP_TOOL_ONLY_STEP_LIMIT = 10;
91
+ const COMPLETION_CONTINUATION_LIMIT = 30;
70
92
  function toolOutputOk(output, success) {
71
93
  if (!success)
72
94
  return false;
@@ -86,7 +108,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
86
108
  const resetIdleTimer = () => {
87
109
  if (idleTimer)
88
110
  clearTimeout(idleTimer);
89
- idleTimer = setTimeout(() => abortController.abort('Haze turn timed out after no model/tool activity.'), 90_000);
111
+ idleTimer = setTimeout(() => abortController.abort('Haze turn timed out after no model/tool activity.'), IDLE_TIMEOUT_MS);
90
112
  };
91
113
  try {
92
114
  const m = await model();
@@ -96,7 +118,8 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
96
118
  }
97
119
  const activeModel = m;
98
120
  const skillRegistry = await loadSkillRegistry();
99
- const availableTools = { ...hazeTools, ...buildSkillTools(skillRegistry) };
121
+ const subagentTool = createSubagentTool({ model: activeModel, contextFiles });
122
+ const availableTools = { ...hazeTools, subagent: subagentTool, ...buildSkillTools(skillRegistry) };
100
123
  const goal = createSessionGoal(value);
101
124
  callbacks.setGoalStatus?.(formatGoalStatus(goal));
102
125
  const likelyPlanOnlyRequest = isPlanOnlyRequest(value);
@@ -126,14 +149,35 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
126
149
  let sawToolCall = false;
127
150
  let textAfterTool = false;
128
151
  let completionContinuationCount = 0;
129
- const maxCompletionContinuations = 4;
152
+ const maxCompletionContinuations = COMPLETION_CONTINUATION_LIMIT;
130
153
  let editRecoveryPath;
131
154
  let editRecoveryReadSatisfied = false;
132
155
  const toolSummaries = [];
156
+ const visibleAssistantTexts = new Set();
157
+ const previousAssistantText = normalizeAssistantText(callbacks.getLastAssistantText());
158
+ if (previousAssistantText)
159
+ visibleAssistantTexts.add(previousAssistantText);
160
+ const rememberVisibleAssistantText = (text) => {
161
+ const normalized = normalizeAssistantText(text);
162
+ if (!normalized)
163
+ return;
164
+ visibleAssistantTexts.add(normalized);
165
+ callbacks.setLastAssistantText(text);
166
+ };
167
+ const isDuplicateVisibleAssistantText = (text) => {
168
+ const normalized = normalizeAssistantText(text);
169
+ return normalized.length > 0 && visibleAssistantTexts.has(normalized);
170
+ };
171
+ const isPrefixOfVisibleAssistantText = (text) => {
172
+ const normalized = normalizeAssistantText(text);
173
+ return normalized.length > 0 && [...visibleAssistantTexts].some(previous => previous.startsWith(normalized) && previous !== normalized);
174
+ };
133
175
  const toolExecutionContext = { inFlightToolCalls: new Map() };
134
- const toolGroupId = `tools-${Date.now()}-${Math.random().toString(36).slice(2)}`;
176
+ let toolGroupId = `tools-${Date.now()}-${Math.random().toString(36).slice(2)}`;
177
+ const INLINE_DIFF_LINE_LIMIT = 20;
135
178
  const toolDisplayItems = [];
136
179
  let toolGroupStarted = false;
180
+ let toolGroupFinalized = false;
137
181
  function renderToolGroup(streaming) {
138
182
  const visibleItems = toolDisplayItems.filter(item => !item.hidden);
139
183
  const running = visibleItems.some(item => item.status === 'running');
@@ -156,12 +200,29 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
156
200
  const header = running || streaming
157
201
  ? 'Running tools'
158
202
  : `${visibleItems.length} call${visibleItems.length === 1 ? '' : 's'} · ${changes.length} change${changes.length === 1 ? '' : 's'} · ${failures.length} failed${compactSuffix}`;
159
- const lines = rows.map(({ item, count }) => {
203
+ const lines = [];
204
+ for (const { item, count } of rows) {
160
205
  const icon = item.status === 'running' ? '…' : item.status === 'success' ? '✓' : '✗';
161
206
  const countText = count > 1 ? ` ×${count}` : '';
162
207
  const result = item.status === 'running' ? '' : ` — ${item.result ?? item.status}${item.durationMs == null ? '' : ` in ${formatSeconds(item.durationMs)}`}`;
163
- return ` ${icon} ${item.summary}${countText}${result}`;
164
- });
208
+ lines.push(` ${icon} ${item.summary}${countText}${result}`);
209
+ if (item.diff && item.diff.length > 0 && (item.diffLineCount ?? item.diff.length) <= INLINE_DIFF_LINE_LIMIT) {
210
+ for (const diffLine of item.diff) {
211
+ const lineNumber = diffLine.type === 'add' ? diffLine.newLine : diffLine.oldLine;
212
+ const marker = diffLine.type === 'add' ? '+' : diffLine.type === 'remove' ? '-' : ' ';
213
+ lines.push(` ${String(lineNumber ?? '').padStart(5)} ${marker} ${diffLine.text}`);
214
+ }
215
+ }
216
+ else if ((item.diffLineCount ?? 0) > INLINE_DIFF_LINE_LIMIT) {
217
+ lines.push(` diff hidden (${item.diffLineCount} changed lines; run git diff to inspect)`);
218
+ }
219
+ if (item.subItems && item.subItems.length > 0) {
220
+ for (const sub of item.subItems) {
221
+ const subDuration = sub.durationMs > 1000 ? ` (${formatSeconds(sub.durationMs)})` : '';
222
+ lines.push(` · ${sub.name} — ${sub.summary}${subDuration}`);
223
+ }
224
+ }
225
+ }
165
226
  return [header, ...lines].join('\n');
166
227
  }
167
228
  function updateToolGroup(streaming = true) {
@@ -173,11 +234,22 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
173
234
  else {
174
235
  callbacks.updateMessage(toolGroupId, { text, streaming });
175
236
  }
237
+ if (!streaming)
238
+ toolGroupFinalized = true;
176
239
  }
177
240
  function recordToolStart(toolCall) {
241
+ if (toolGroupFinalized) {
242
+ toolDisplayItems.length = 0;
243
+ toolGroupId = `tools-${Date.now()}-${Math.random().toString(36).slice(2)}`;
244
+ toolGroupFinalized = false;
245
+ toolGroupStarted = false;
246
+ }
178
247
  callbacks.onEvent?.(agentEvent({ type: 'tool_start', id: toolCall.toolCallId, name: toolCall.toolName, input: toolCall.input }));
179
248
  toolDisplayItems.push({ id: toolCall.toolCallId, summary: toolCallSummary(toolCall.toolName, toolCall.input), status: 'running' });
180
249
  updateToolGroup(true);
250
+ const runningSubagents = toolDisplayItems.filter(item => item.status === 'running' && item.summary.startsWith('subagent')).length;
251
+ if (runningSubagents > 0)
252
+ callbacks.setBusyLabel?.(`Running ${runningSubagents} subagent${runningSubagents === 1 ? '' : 's'}`);
181
253
  }
182
254
  function recordToolDisplayFinish(event) {
183
255
  callbacks.onEvent?.(agentEvent({ type: 'tool_end', id: event.toolCall.toolCallId, name: event.toolCall.toolName, success: event.success, output: event.output, error: event.error, durationMs: event.durationMs }));
@@ -188,7 +260,29 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
188
260
  item.result = toolResultSummary(event);
189
261
  item.durationMs = event.durationMs;
190
262
  item.hidden = isDuplicateSkippedOutput(event.output);
263
+ if (typeof event.output === 'object' && event.output != null) {
264
+ const output = event.output;
265
+ if (typeof output.diffLineCount === 'number')
266
+ item.diffLineCount = output.diffLineCount;
267
+ if (Array.isArray(output.diff))
268
+ item.diff = output.diff;
269
+ }
270
+ if (event.toolCall.toolName === 'subagent' && typeof event.output === 'object' && event.output != null) {
271
+ const out = event.output;
272
+ if (Array.isArray(out.toolCalls)) {
273
+ item.subItems = out.toolCalls.map(tc => ({
274
+ name: tc.name,
275
+ summary: tc.summary,
276
+ durationMs: tc.durationMs,
277
+ }));
278
+ }
279
+ }
191
280
  updateToolGroup(toolDisplayItems.some(candidate => candidate.status === 'running'));
281
+ const runningSubagents = toolDisplayItems.filter(i => i.status === 'running' && i.summary.startsWith('subagent')).length;
282
+ if (runningSubagents === 0)
283
+ callbacks.setBusyLabel?.('Haze is thinking');
284
+ else
285
+ callbacks.setBusyLabel?.(`Running ${runningSubagents} subagent${runningSubagents === 1 ? '' : 's'}`);
192
286
  }
193
287
  callbacks.debugLog(`request started with ${requestMessages.length} conversation messages; intent=${goal.normalizedIntent}; action=${likelyActionRequest}`);
194
288
  function recordToolFinish(event) {
@@ -241,12 +335,12 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
241
335
  messages: continuationMessages,
242
336
  tools: availableTools,
243
337
  toolChoice: allowTools ? 'auto' : 'none',
244
- stopWhen: stepCountIs(10),
338
+ stopWhen: stepCountIs(FOLLOW_UP_STEP_LIMIT),
245
339
  abortSignal: abortController.signal,
246
340
  experimental_context: toolExecutionContext,
247
341
  prepareStep({ steps, messages }) {
248
342
  continuationToolCalls = steps.flatMap(step => step.toolCalls).length;
249
- if (continuationToolCalls >= 10 || toolOnlyStepCount(steps) >= 5) {
343
+ if (continuationToolCalls >= FOLLOW_UP_TOOL_CALL_LIMIT || toolOnlyStepCount(steps) >= FOLLOW_UP_TOOL_ONLY_STEP_LIMIT) {
250
344
  return {
251
345
  toolChoice: 'none',
252
346
  messages: [
@@ -308,8 +402,8 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
308
402
  resetIdleTimer();
309
403
  const delta = sanitizeAssistantText(rawDelta);
310
404
  responseText += delta;
311
- const displayText = hideSyntheticToolCallMarkup(responseText);
312
- if (!displayText && !responseStarted)
405
+ const displayText = assistantDisplayText(responseText);
406
+ if ((!displayText || isNonSubstantiveAssistantText(displayText) || isPrefixOfVisibleAssistantText(displayText)) && !responseStarted)
313
407
  continue;
314
408
  if (!responseStarted) {
315
409
  responseStarted = true;
@@ -327,15 +421,19 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
327
421
  catch (error) {
328
422
  throw followUpStreamError ?? error;
329
423
  }
330
- const finalText = hideSyntheticToolCallMarkup(responseText).trim();
424
+ const finalText = assistantDisplayText(responseText);
425
+ const visibleFinalText = finalText;
426
+ const hidden = visibleFinalText.length === 0 || isNonSubstantiveAssistantText(visibleFinalText) || isDuplicateVisibleAssistantText(visibleFinalText);
331
427
  if (responseStarted) {
332
- callbacks.setLastAssistantText(finalText);
333
- callbacks.onEvent?.(agentEvent({ type: 'message_end', id: responseId, text: finalText, hidden: finalText.length === 0 }));
334
- callbacks.updateMessage(responseId, { text: finalText, streaming: false, hidden: finalText.length === 0 });
428
+ if (!hidden)
429
+ rememberVisibleAssistantText(visibleFinalText);
430
+ callbacks.onEvent?.(agentEvent({ type: 'message_end', id: responseId, text: visibleFinalText, hidden }));
431
+ callbacks.updateMessage(responseId, { text: visibleFinalText, streaming: false, hidden });
335
432
  }
336
433
  return { text: finalText, id: responseId, started: responseStarted };
337
434
  }
338
435
  let streamError;
436
+ let lastFinishReason;
339
437
  const result = streamText({
340
438
  model: activeModel,
341
439
  temperature: 0,
@@ -343,7 +441,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
343
441
  system: buildSystemPrompt(contextFiles),
344
442
  messages: requestMessages,
345
443
  tools: availableTools,
346
- stopWhen: stepCountIs(12),
444
+ stopWhen: stepCountIs(MAIN_STEP_LIMIT),
347
445
  abortSignal: abortController.signal,
348
446
  experimental_context: toolExecutionContext,
349
447
  onError({ error }) {
@@ -384,7 +482,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
384
482
  ],
385
483
  };
386
484
  }
387
- if (likelyActionRequest && !mutatingToolSucceeded && consecutiveToolOnlySteps >= 3 && toolCalls.length < 10) {
485
+ if (likelyActionRequest && !mutatingToolSucceeded && consecutiveToolOnlySteps >= 3 && toolCalls.length < MAIN_TOOL_CALL_LIMIT) {
388
486
  callbacks.debugLog('nudging action request toward mutation after read-only steps');
389
487
  return {
390
488
  messages: [
@@ -393,7 +491,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
393
491
  ],
394
492
  };
395
493
  }
396
- if (toolCalls.length >= 12 || consecutiveToolOnlySteps >= 5) {
494
+ if (toolCalls.length >= MAIN_TOOL_CALL_LIMIT || consecutiveToolOnlySteps >= MAIN_TOOL_ONLY_STEP_LIMIT) {
397
495
  callbacks.debugLog('forcing text response to avoid tool loop');
398
496
  return {
399
497
  toolChoice: 'none',
@@ -408,6 +506,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
408
506
  return undefined;
409
507
  },
410
508
  onStepFinish({ stepNumber, text, toolCalls, toolResults, finishReason }) {
509
+ lastFinishReason = finishReason;
411
510
  callbacks.debugLog(`step ${stepNumber} finished: ${finishReason}; text=${text.length}; toolCalls=${toolCalls.length}; toolResults=${toolResults.length}`);
412
511
  },
413
512
  onFinish(event) {
@@ -440,8 +539,12 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
440
539
  if (sawToolCall)
441
540
  textAfterTool = true;
442
541
  if (currentAssistantStarted && currentAssistantText.length > 0 && toolEpoch > currentAssistantToolEpoch) {
443
- callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: hideSyntheticToolCallMarkup(currentAssistantText).trim() }));
444
- callbacks.updateMessage(currentAssistantId, { streaming: false });
542
+ const intermediateText = assistantDisplayText(currentAssistantText);
543
+ const hidden = intermediateText.length === 0 || isNonSubstantiveAssistantText(intermediateText) || isDuplicateVisibleAssistantText(intermediateText);
544
+ if (!hidden)
545
+ rememberVisibleAssistantText(intermediateText);
546
+ callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: intermediateText, hidden }));
547
+ callbacks.updateMessage(currentAssistantId, { text: intermediateText, streaming: false, hidden });
445
548
  currentAssistantId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
446
549
  currentAssistantStarted = false;
447
550
  currentAssistantText = '';
@@ -449,8 +552,8 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
449
552
  }
450
553
  assistantText += delta;
451
554
  currentAssistantText += delta;
452
- const displayText = hideSyntheticToolCallMarkup(currentAssistantText);
453
- if (!displayText && !currentAssistantStarted)
555
+ const displayText = assistantDisplayText(currentAssistantText);
556
+ if ((!displayText || isNonSubstantiveAssistantText(displayText) || isPrefixOfVisibleAssistantText(displayText)) && !currentAssistantStarted)
454
557
  continue;
455
558
  if (!currentAssistantStarted) {
456
559
  assistantStarted = true;
@@ -473,7 +576,16 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
473
576
  throw streamError ?? error;
474
577
  }
475
578
  callbacks.debugLog(`response stream finished; session has ${completedConversation.length} model messages`);
476
- const finalAssistantText = hideSyntheticToolCallMarkup(assistantText).trim();
579
+ if (lastFinishReason === 'length' && !sawToolCall && completionContinuationCount < maxCompletionContinuations) {
580
+ completionContinuationCount += 1;
581
+ callbacks.debugLog('output token limit reached, auto-continuing');
582
+ const continuation = await streamAssistantResponse(completedConversation, 'output token limit reached', 'Your response was cut off because you hit the output token limit. Continue from where you left off — do not repeat what you already said, just pick up exactly where you stopped.', true);
583
+ completedConversation = callbacks.getConversation();
584
+ if (continuation.text) {
585
+ assistantText += '\n' + continuation.text;
586
+ }
587
+ }
588
+ const combinedAssistantText = assistantDisplayText(assistantText);
477
589
  const decideCompletion = (text) => completionDecision({
478
590
  request: value,
479
591
  goal,
@@ -486,7 +598,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
486
598
  editFileFailed,
487
599
  editRecoveryPath,
488
600
  });
489
- let decision = decideCompletion(finalAssistantText);
601
+ let decision = decideCompletion(combinedAssistantText);
490
602
  async function runCompletionLoop(seedConversation, seedText) {
491
603
  let loopConversation = seedConversation;
492
604
  let latestText = seedText;
@@ -499,12 +611,9 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
499
611
  if (continuation.text)
500
612
  latestText = continuation.text;
501
613
  decision = decideCompletion(latestText);
502
- if (continuation.started && (decision.needsActionContinuation || decision.needsValidationContinuation)) {
503
- callbacks.updateMessage(continuation.id, { hidden: true });
504
- }
505
614
  }
506
615
  if ((decision.needsActionContinuation || decision.needsValidationContinuation) && completionContinuationCount >= maxCompletionContinuations) {
507
- callbacks.addMessage({ role: 'assistant', text: 'Stopped after the bounded completion loop. The current goal may still need work; ask me to continue and I will resume from the latest tool results.' });
616
+ callbacks.addMessage({ role: 'assistant', text: 'Stopped after the autonomous safety limit. The current goal may still need work; ask me to continue and I will resume from the latest tool results.' });
508
617
  }
509
618
  if (!latestText && toolSummaries.length > 0) {
510
619
  const followUp = await streamAssistantResponse(loopConversation, 'completion loop ended without text', noTextAfterToolPrompt(false), false);
@@ -514,12 +623,14 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks,
514
623
  }
515
624
  if (assistantStarted) {
516
625
  const hidePreToolFragment = sawToolCall && !textAfterTool;
517
- callbacks.setLastAssistantText(hidePreToolFragment ? '' : finalAssistantText);
518
- callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: finalAssistantText, hidden: finalAssistantText.length === 0 || hidePreToolFragment }));
519
- callbacks.updateMessage(currentAssistantId, { text: finalAssistantText, streaming: false, hidden: finalAssistantText.length === 0 || hidePreToolFragment });
626
+ const visibleFinalAssistantText = assistantDisplayText(currentAssistantText);
627
+ const hidden = visibleFinalAssistantText.length === 0 || isNonSubstantiveAssistantText(visibleFinalAssistantText) || isDuplicateVisibleAssistantText(visibleFinalAssistantText) || hidePreToolFragment;
628
+ if (!hidden)
629
+ rememberVisibleAssistantText(visibleFinalAssistantText);
630
+ callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: visibleFinalAssistantText, hidden }));
631
+ callbacks.updateMessage(currentAssistantId, { text: visibleFinalAssistantText, streaming: false, hidden });
520
632
  if (decision.needsActionContinuation || decision.needsValidationContinuation) {
521
- callbacks.updateMessage(currentAssistantId, { streaming: false, hidden: true });
522
- await runCompletionLoop(completedConversation, finalAssistantText);
633
+ await runCompletionLoop(completedConversation, combinedAssistantText);
523
634
  }
524
635
  else if (sawToolCall && !textAfterTool) {
525
636
  const followUp = await streamAssistantResponse(completedConversation, 'tool use completed without follow-up text', noTextAfterToolPrompt(false), false);
@@ -1,5 +1,6 @@
1
1
  import type { SessionGoal } from './sessionGoal.js';
2
2
  export declare function looksIncomplete(text: string): boolean;
3
+ export declare function looksTruncated(text: string): boolean;
3
4
  export declare function looksBlocked(text: string): boolean;
4
5
  export interface CompletionPolicyInput {
5
6
  request: string;
@@ -1,6 +1,23 @@
1
1
  import { isActionRequest, isPlanOnlyRequest, isValidationRequest } from './requestClassifier.js';
2
2
  export function looksIncomplete(text) {
3
- return /\b(incomplete|what remains|remains:|remaining:|next:|not implemented|not created|no tests exist|created no docs|has not been|have not been|not yet|never executed|not executed|not run|cannot retry|cannot write|cannot validate|tool budget reached)/i.test(text);
3
+ return /\b(incomplete|what remains|remains:|remaining:|next:|unfinished|not implemented|not created|no tests exist|created no docs|has not been|have not been|not yet|never executed|not executed|not run|cannot retry|cannot write|cannot validate|tool budget reached|tool slice reached)/i.test(text);
4
+ }
5
+ export function looksTruncated(text) {
6
+ const trimmed = text.trimEnd();
7
+ if (!trimmed)
8
+ return false;
9
+ const lines = trimmed.split('\n');
10
+ const lastLine = lines[lines.length - 1].trim();
11
+ if (/^#{1,6}\s+\S/.test(lastLine))
12
+ return true;
13
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(lastLine))
14
+ return true;
15
+ if (trimmed.endsWith(':'))
16
+ return true;
17
+ const fences = (trimmed.match(/```/g) ?? []).length;
18
+ if (fences % 2 !== 0)
19
+ return true;
20
+ return false;
4
21
  }
5
22
  export function looksBlocked(text) {
6
23
  return /\b(blocked|blocker|needs user|need user|missing permission|permission denied|missing dependency|no practical validation|unable to validate|can't validate|cannot validate)\b/i.test(text);
@@ -9,7 +26,7 @@ export function completionDecision(input) {
9
26
  const likelyPlanOnlyRequest = isPlanOnlyRequest(input.request);
10
27
  const likelyActionRequest = isActionRequest(input.request);
11
28
  const likelyValidationRequest = isValidationRequest(input.request);
12
- const assistantAdmitsIncomplete = looksIncomplete(input.assistantText);
29
+ const assistantAdmitsIncomplete = looksIncomplete(input.assistantText) || looksTruncated(input.assistantText);
13
30
  const assistantReportsBlocker = looksBlocked(input.assistantText);
14
31
  const requestCompletedByTools = input.mutatingToolSucceeded && input.validationToolSucceeded && !input.editRecoveryPath;
15
32
  const changedActionNeedsValidation = likelyActionRequest
@@ -55,7 +72,7 @@ export function completionDecision(input) {
55
72
  };
56
73
  }
57
74
  export function toolLoopBudgetPrompt() {
58
- return 'Tool budget reached. You cannot call tools now, and you must not output XML, JSON tool-call syntax, <tool_call> blocks, or function-call markup. If the current request is complete, summarize only current-turn changes and validation. If the requested change is incomplete, state the concrete blocker briefly. Do not claim tools are unavailable, recap unrelated earlier tasks, or provide a generic remains list.';
75
+ return 'Tool slice reached for this model step. Do not output XML, JSON tool-call syntax, <tool_call> blocks, or function-call markup. If the current request is complete, summarize only current-turn changes and validation. If the requested change is incomplete, state the next concrete unfinished action briefly so Haze can continue autonomously in a fresh tool slice. Do not claim tools are unavailable, recap unrelated earlier tasks, or provide a generic remains list.';
59
76
  }
60
77
  export function postContinuationPrompt() {
61
78
  return 'Your previous response still described unfinished work, missing validation, or a tool-budget issue. If any tools are still available, complete the remaining edit or run the final validation now. Only call something a blocker if a concrete tool failure prevents progress.';
@@ -0,0 +1,33 @@
1
+ import { streamText } from 'ai';
2
+ import type { ContextFile } from '../../config/contextFiles.js';
3
+ export interface SubagentResult {
4
+ status: 'ok' | 'error' | 'timeout' | 'cancelled';
5
+ summary: string;
6
+ toolCalls: Array<{
7
+ name: string;
8
+ summary: string;
9
+ durationMs: number;
10
+ }>;
11
+ toolCallCount: number;
12
+ tokens: {
13
+ in: number;
14
+ out: number;
15
+ };
16
+ durationMs: number;
17
+ error?: string;
18
+ }
19
+ export declare function runSubagent(task: string, options: {
20
+ model: Parameters<typeof streamText>[0]['model'];
21
+ contextFiles: ContextFile[];
22
+ allowedTools?: readonly string[];
23
+ maxSteps?: number;
24
+ abortSignal?: AbortSignal;
25
+ }): Promise<SubagentResult>;
26
+ export declare function createSubagentTool(options: {
27
+ model: Parameters<typeof streamText>[0]['model'];
28
+ contextFiles: ContextFile[];
29
+ }): import("ai").Tool<{
30
+ task: string;
31
+ tools?: ("editFile" | "replaceLines" | "writeFile" | "listFiles" | "readFile" | "grep" | "bash")[] | undefined;
32
+ maxSteps?: number | undefined;
33
+ }, SubagentResult>;
@@ -0,0 +1,140 @@
1
+ import { streamText, stepCountIs, tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { buildSystemPrompt } from '../../llm/systemPrompt.js';
4
+ import { hazeTools } from '../../llm/hazeTools.js';
5
+ const SUBAGENT_SYSTEM_PROMPT = `You are a focused subagent. Complete the assigned task using all tools available to you, then return a clear summary.
6
+
7
+ Rules:
8
+ - Use whatever tools you need. You have full access to file tools and bash.
9
+ - If the task requires creating or modifying files, do it directly — do not ask for permission.
10
+ - If a tool fails, read the file again and retry with the correct content.
11
+ - After completing the task, summarize what you did, what files you created or changed, and any important findings.
12
+ - If you cannot complete the task, explain exactly what blocked you and what you tried.
13
+ - Your summary is all the parent agent will see. Be specific: include file paths, function names, and concrete results.`;
14
+ const ALL_TOOLS = ['listFiles', 'readFile', 'grep', 'bash', 'editFile', 'replaceLines', 'writeFile'];
15
+ const STEP_LIMIT = 25;
16
+ const MAX_SUMMARY = 4000;
17
+ const TOOL_ONLY_LIMIT = 12;
18
+ function toolSummary(output) {
19
+ if (typeof output !== 'object' || output == null)
20
+ return 'completed';
21
+ const o = output;
22
+ if (typeof o.totalMatches === 'number')
23
+ return o.totalMatches === 0 ? 'no matches' : `${o.totalMatches} matches`;
24
+ if (typeof o.code === 'number')
25
+ return `exit ${o.code}`;
26
+ if (o.ok === true)
27
+ return 'completed';
28
+ if (o.ok === false && typeof o.error === 'string')
29
+ return `failed: ${o.error.slice(0, 120)}`;
30
+ return 'completed';
31
+ }
32
+ function toolOnlyStepCount(steps) {
33
+ let count = 0;
34
+ for (const step of [...steps].reverse()) {
35
+ if (step.toolCalls.length === 0 || step.text.trim().length > 0)
36
+ break;
37
+ count += 1;
38
+ }
39
+ return count;
40
+ }
41
+ export async function runSubagent(task, options) {
42
+ const start = performance.now();
43
+ const toolNames = (options.allowedTools ?? ALL_TOOLS).filter(t => ALL_TOOLS.includes(t));
44
+ const maxSteps = Math.min(options.maxSteps ?? STEP_LIMIT, STEP_LIMIT);
45
+ const scopedTools = {};
46
+ for (const name of toolNames) {
47
+ const key = name;
48
+ if (key in hazeTools)
49
+ scopedTools[name] = hazeTools[key];
50
+ }
51
+ const toolCallLog = [];
52
+ let tokensIn = 0;
53
+ let tokensOut = 0;
54
+ let lastStep = 0;
55
+ let totalToolCalls = 0;
56
+ try {
57
+ const result = streamText({
58
+ model: options.model,
59
+ temperature: 0,
60
+ maxOutputTokens: 4096,
61
+ system: `${SUBAGENT_SYSTEM_PROMPT}\n\n${buildSystemPrompt(options.contextFiles)}`,
62
+ messages: [{ role: 'user', content: task }],
63
+ tools: scopedTools,
64
+ stopWhen: stepCountIs(maxSteps),
65
+ abortSignal: options.abortSignal,
66
+ experimental_context: { inFlightToolCalls: new Map() },
67
+ prepareStep({ steps }) {
68
+ const calls = steps.flatMap(step => step.toolCalls);
69
+ const consecutiveToolOnly = toolOnlyStepCount(steps);
70
+ if (consecutiveToolOnly >= TOOL_ONLY_LIMIT || calls.length >= maxSteps * 2) {
71
+ return {
72
+ toolChoice: 'none',
73
+ messages: [
74
+ { role: 'user', content: 'You have done enough tool work. Summarize what you found or did right now.' },
75
+ ],
76
+ };
77
+ }
78
+ return undefined;
79
+ },
80
+ onStepFinish({ stepNumber }) {
81
+ lastStep = stepNumber;
82
+ },
83
+ onFinish(event) {
84
+ if (event.usage) {
85
+ tokensIn = event.usage.inputTokens ?? 0;
86
+ tokensOut = event.usage.outputTokens ?? 0;
87
+ }
88
+ },
89
+ experimental_onToolCallFinish(event) {
90
+ if (!event.toolCall)
91
+ return;
92
+ totalToolCalls += 1;
93
+ toolCallLog.push({
94
+ name: event.toolCall.toolName,
95
+ summary: toolSummary(event.output),
96
+ durationMs: event.durationMs,
97
+ });
98
+ },
99
+ });
100
+ let text = '';
101
+ for await (const delta of result.textStream) {
102
+ text += delta;
103
+ }
104
+ await result.response;
105
+ const summary = (text.trim() || 'Subagent completed without text output.').slice(0, MAX_SUMMARY);
106
+ const durationMs = performance.now() - start;
107
+ const status = options.abortSignal?.aborted ? 'cancelled'
108
+ : lastStep >= maxSteps ? 'timeout'
109
+ : 'ok';
110
+ return { status, summary, toolCalls: toolCallLog, toolCallCount: totalToolCalls, tokens: { in: tokensIn, out: tokensOut }, durationMs };
111
+ }
112
+ catch (error) {
113
+ return {
114
+ status: options.abortSignal?.aborted ? 'cancelled' : 'error',
115
+ summary: error instanceof Error ? error.message : String(error),
116
+ toolCalls: toolCallLog,
117
+ toolCallCount: totalToolCalls,
118
+ tokens: { in: tokensIn, out: tokensOut },
119
+ durationMs: performance.now() - start,
120
+ error: error instanceof Error ? error.message : String(error),
121
+ };
122
+ }
123
+ }
124
+ export function createSubagentTool(options) {
125
+ return tool({
126
+ description: 'Spawn subagents to run independent tasks in parallel. ONLY use when a request clearly decomposes into 2+ independent subtasks that can run concurrently — spawn all of them in one step. Do NOT use for single tasks, sequential work, or anything that benefits from conversation context; do those directly instead. Subagents have no conversation history and return a summary.',
127
+ inputSchema: z.object({
128
+ task: z.string().min(1).describe('Clear, specific task for the subagent to complete.'),
129
+ tools: z.array(z.enum(['listFiles', 'readFile', 'grep', 'bash', 'editFile', 'replaceLines', 'writeFile'])).optional().describe('Tools the subagent can use. Defaults to all tools. Restrict to a subset only when the subagent should be read-only.'),
130
+ maxSteps: z.number().int().positive().max(50).optional().describe('Maximum tool-call rounds. Default 25.'),
131
+ }),
132
+ execute: async ({ task, tools, maxSteps }, context) => runSubagent(task, {
133
+ model: options.model,
134
+ contextFiles: options.contextFiles,
135
+ allowedTools: tools,
136
+ maxSteps,
137
+ abortSignal: context.abortSignal,
138
+ }),
139
+ });
140
+ }
@@ -1,3 +1,9 @@
1
+ type ToolDiffLine = {
2
+ type: 'add' | 'remove' | 'context';
3
+ oldLine?: number;
4
+ newLine?: number;
5
+ text: string;
6
+ };
1
7
  export declare const hazeTools: {
2
8
  listFiles: import("ai").Tool<{
3
9
  path: string;
@@ -67,6 +73,39 @@ export declare const hazeTools: {
67
73
  totalLines: number;
68
74
  lineNumberedText: string;
69
75
  }>;
76
+ grep: import("ai").Tool<{
77
+ pattern: string;
78
+ path: string;
79
+ contextLines: number;
80
+ maxMatches: number;
81
+ caseInsensitive: boolean;
82
+ glob?: string | undefined;
83
+ }, {
84
+ ok: boolean;
85
+ toolName: string;
86
+ path: string | undefined;
87
+ error: string;
88
+ recoverable: boolean;
89
+ suggestedNextStep: string;
90
+ } | {
91
+ ok: true;
92
+ duplicateSkipped: true;
93
+ toolName: string;
94
+ reason: string;
95
+ } | {
96
+ pattern: string;
97
+ path: string;
98
+ glob: string | null;
99
+ caseInsensitive: boolean;
100
+ matches: {
101
+ file: string;
102
+ line: number;
103
+ content: string;
104
+ isContext: boolean;
105
+ }[];
106
+ totalMatches: number;
107
+ truncated: boolean;
108
+ }>;
70
109
  replaceLines: import("ai").Tool<{
71
110
  path: string;
72
111
  startLine: number;
@@ -94,6 +133,10 @@ export declare const hazeTools: {
94
133
  endLineClamped: boolean;
95
134
  replacementLines: number;
96
135
  appended: boolean;
136
+ addedLines: number;
137
+ removedLines: number;
138
+ diffLineCount: number;
139
+ diff: ToolDiffLine[] | undefined;
97
140
  }>;
98
141
  writeFile: import("ai").Tool<{
99
142
  path: string;
@@ -142,6 +185,10 @@ export declare const hazeTools: {
142
185
  path: string;
143
186
  edits: number;
144
187
  approximateMatches: number;
188
+ addedLines: number;
189
+ removedLines: number;
190
+ diffLineCount: number;
191
+ diff: ToolDiffLine[] | undefined;
145
192
  }>;
146
193
  bash: import("ai").Tool<{
147
194
  command: string;
@@ -150,3 +197,4 @@ export declare const hazeTools: {
150
197
  }, unknown>;
151
198
  };
152
199
  export type HazeTools = typeof hazeTools;
200
+ export {};
@@ -106,7 +106,7 @@ function isMutatingTool(toolName) {
106
106
  return ['editFile', 'replaceLines', 'writeFile'].includes(toolName);
107
107
  }
108
108
  function isReadOnlyFileTool(toolName) {
109
- return ['listFiles', 'readFile'].includes(toolName);
109
+ return ['listFiles', 'readFile', 'grep'].includes(toolName);
110
110
  }
111
111
  function inputPath(input) {
112
112
  return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
@@ -120,6 +120,32 @@ function structuredToolFailure(toolName, error, suggestedNextStep, pathForError)
120
120
  const message = error instanceof Error ? error.message : String(error);
121
121
  return { ok: false, toolName, path: pathForError, error: message, recoverable: true, suggestedNextStep };
122
122
  }
123
+ const INLINE_DIFF_LINE_LIMIT = 20;
124
+ function splitDiffLines(text) {
125
+ const lines = text.split(/\r?\n/);
126
+ if (text.endsWith('\n') || text.endsWith('\r\n'))
127
+ lines.pop();
128
+ return lines;
129
+ }
130
+ function lineNumberAtOffset(text, offset) {
131
+ let line = 1;
132
+ for (let index = 0; index < offset; index++) {
133
+ if (text.charCodeAt(index) === 10)
134
+ line += 1;
135
+ }
136
+ return line;
137
+ }
138
+ function replacementDiff(oldText, newText, oldStartLine, newStartLine, context) {
139
+ const oldLines = splitDiffLines(oldText);
140
+ const newLines = splitDiffLines(newText);
141
+ const diff = [];
142
+ if (context?.before)
143
+ diff.push({ type: 'context', ...context.before });
144
+ diff.push(...oldLines.map((text, index) => ({ type: 'remove', oldLine: oldStartLine + index, text })), ...newLines.map((text, index) => ({ type: 'add', newLine: newStartLine + index, text })));
145
+ if (context?.after)
146
+ diff.push({ type: 'context', ...context.after });
147
+ return { diff, addedLines: newLines.length, removedLines: oldLines.length };
148
+ }
123
149
  async function runDedupedTool(toolName, input, context, execute) {
124
150
  const ctx = hazeContext(context);
125
151
  if (!ctx)
@@ -279,6 +305,67 @@ export const hazeTools = {
279
305
  }
280
306
  }),
281
307
  }),
308
+ grep: tool({
309
+ description: 'Search file contents with a regex pattern using ripgrep. Use this to find symbol definitions, usages, string literals, import paths, and code patterns across the workspace. Much faster and more targeted than reading files one by one with readFile. Respects .gitignore by default.',
310
+ inputSchema: z.object({
311
+ pattern: z.string().min(1).describe('Regex pattern to search for (PCRE-compatible). Examples: "function handleClick", "import.*from.*react", "class UserService", "TODO|FIXME"'),
312
+ path: z.string().default('.').describe('Directory or file path to search in, relative to the workspace. Narrow this to focus results.'),
313
+ glob: z.string().optional().describe('File glob filter. Examples: "*.ts", "*.{js,jsx}", "src/**/*.py". Narrows search to matching files.'),
314
+ contextLines: z.number().int().nonnegative().max(5).default(2).describe('Number of context lines before and after each match (0-5). Use 0 for compact output, 2-3 for understanding surrounding code.'),
315
+ maxMatches: z.number().int().positive().max(200).default(50).describe('Maximum number of matches to return. Increase for broad searches, decrease for focused lookups.'),
316
+ caseInsensitive: z.boolean().default(false).describe('Case-insensitive matching. Useful for symbol names that may vary in casing.'),
317
+ }),
318
+ execute: async ({ pattern, path: searchPath, glob, contextLines, maxMatches, caseInsensitive }, context) => runDedupedTool('grep', { pattern, path: searchPath, glob, contextLines, maxMatches, caseInsensitive }, context, async () => {
319
+ try {
320
+ const absolutePath = resolveWorkspacePath(searchPath);
321
+ const args = [
322
+ '--no-heading', '--line-number', '--color=never',
323
+ '--max-count', String(maxMatches),
324
+ '--context', String(contextLines),
325
+ ];
326
+ if (caseInsensitive)
327
+ args.push('--ignore-case');
328
+ if (glob)
329
+ args.push('--glob', glob);
330
+ args.push('--', pattern, absolutePath);
331
+ let stdout = '';
332
+ try {
333
+ const result = await execFile('rg', args, { cwd: workspaceRoot(), timeout: 30_000 });
334
+ stdout = result.stdout;
335
+ }
336
+ catch (error) {
337
+ const code = typeof error === 'object' && error != null && 'code' in error ? error.code : undefined;
338
+ if (code === 1) {
339
+ stdout = '';
340
+ }
341
+ else {
342
+ throw error;
343
+ }
344
+ }
345
+ if (!stdout) {
346
+ return { pattern, path: searchPath, glob: glob ?? null, caseInsensitive, matches: [], totalMatches: 0, truncated: false };
347
+ }
348
+ const { text: output, truncated } = truncate(stdout);
349
+ const lines = output.split('\n').filter(Boolean);
350
+ const matches = [];
351
+ for (const line of lines) {
352
+ const match = line.match(/^(\S+?):(\d+)[-:](.*)$/);
353
+ if (!match)
354
+ continue;
355
+ const [, file, lineStr, content] = match;
356
+ if (file && lineStr && content !== undefined) {
357
+ const isContext = line.includes('-');
358
+ const relativePath = path.relative(workspaceRoot(), file);
359
+ matches.push({ file: relativePath, line: Number(lineStr), content, isContext });
360
+ }
361
+ }
362
+ return { pattern, path: searchPath, glob: glob ?? null, caseInsensitive, matches, totalMatches: matches.filter(m => !m.isContext).length, truncated };
363
+ }
364
+ catch (error) {
365
+ return structuredToolFailure('grep', error, 'Check that the search path exists and the pattern is valid regex. Try a narrower path or simpler pattern.', searchPath);
366
+ }
367
+ }),
368
+ }),
282
369
  replaceLines: tool({
283
370
  description: 'Replace a 1-based inclusive line range in an existing UTF-8 text file. Prefer this after reading a file when exact editFile replacements are ambiguous or fail. If endLine is slightly beyond EOF, it is clamped to the current last line.',
284
371
  inputSchema: z.object({
@@ -304,6 +391,11 @@ export const hazeTools = {
304
391
  throw new Error(`startLine ${startLine} is beyond end of file (${lines.length} lines)`);
305
392
  const effectiveEndLine = !isAppend && endLine > lines.length ? lines.length : endLine;
306
393
  const replacementLines = content.length === 0 ? [] : content.split(/\r?\n/);
394
+ const removedText = isAppend ? '' : lines.slice(startLine - 1, effectiveEndLine).join('\n');
395
+ const beforeContext = startLine > 1 ? { oldLine: startLine - 1, newLine: startLine - 1, text: lines[startLine - 2] ?? '' } : undefined;
396
+ const afterContext = !isAppend && effectiveEndLine < lines.length
397
+ ? { oldLine: effectiveEndLine + 1, newLine: startLine + replacementLines.length, text: lines[effectiveEndLine] ?? '' }
398
+ : undefined;
307
399
  if (isAppend) {
308
400
  lines.push(...replacementLines);
309
401
  }
@@ -311,8 +403,10 @@ export const hazeTools = {
311
403
  lines.splice(startLine - 1, effectiveEndLine - startLine + 1, ...replacementLines);
312
404
  }
313
405
  const updated = lines.join('\n') + (hasTrailingNewline ? '\n' : '');
406
+ const { diff, addedLines, removedLines } = replacementDiff(removedText, content, startLine, startLine, { before: beforeContext, after: afterContext });
407
+ const diffLineCount = diff.length;
314
408
  await fs.writeFile(absolutePath, updated, 'utf8');
315
- return { ok: true, path: filePath, startLine, endLine: effectiveEndLine, requestedEndLine: endLine, endLineClamped: effectiveEndLine !== endLine, replacementLines: replacementLines.length, appended: isAppend };
409
+ return { ok: true, path: filePath, startLine, endLine: effectiveEndLine, requestedEndLine: endLine, endLineClamped: effectiveEndLine !== endLine, replacementLines: replacementLines.length, appended: isAppend, addedLines, removedLines, diffLineCount, diff: diffLineCount <= INLINE_DIFF_LINE_LIMIT ? diff : undefined };
316
410
  }
317
411
  catch (error) {
318
412
  return structuredToolFailure('replaceLines', error, 'Read the file again for current line numbers, then retry replaceLines with a valid range.', filePath);
@@ -383,8 +477,30 @@ export const hazeTools = {
383
477
  for (const range of [...ranges].sort((a, b) => b.start - a.start)) {
384
478
  updated = updated.slice(0, range.start) + range.edit.newText + updated.slice(range.end);
385
479
  }
480
+ const originalLines = splitDiffLines(original);
481
+ let lineDelta = 0;
482
+ let addedLines = 0;
483
+ let removedLines = 0;
484
+ const diff = [];
485
+ for (const range of ranges) {
486
+ const oldStartLine = lineNumberAtOffset(original, range.start);
487
+ const newStartLine = oldStartLine + lineDelta;
488
+ const oldLineCount = splitDiffLines(range.edit.oldText).length;
489
+ const newLineCount = splitDiffLines(range.edit.newText).length;
490
+ const beforeContext = oldStartLine > 1 ? { oldLine: oldStartLine - 1, newLine: newStartLine - 1, text: originalLines[oldStartLine - 2] ?? '' } : undefined;
491
+ const afterOldLine = oldStartLine + oldLineCount;
492
+ const afterContext = afterOldLine <= originalLines.length
493
+ ? { oldLine: afterOldLine, newLine: newStartLine + newLineCount, text: originalLines[afterOldLine - 1] ?? '' }
494
+ : undefined;
495
+ const rangeDiff = replacementDiff(range.edit.oldText, range.edit.newText, oldStartLine, newStartLine, { before: beforeContext, after: afterContext });
496
+ diff.push(...rangeDiff.diff);
497
+ addedLines += rangeDiff.addedLines;
498
+ removedLines += rangeDiff.removedLines;
499
+ lineDelta += rangeDiff.addedLines - rangeDiff.removedLines;
500
+ }
501
+ const diffLineCount = diff.length;
386
502
  await fs.writeFile(absolutePath, updated, 'utf8');
387
- return { ok: true, path: filePath, edits: edits.length, approximateMatches: ranges.filter(range => range.approximate).length };
503
+ return { ok: true, path: filePath, edits: edits.length, approximateMatches: ranges.filter(range => range.approximate).length, addedLines, removedLines, diffLineCount, diff: diffLineCount <= INLINE_DIFF_LINE_LIMIT ? diff : undefined };
388
504
  }
389
505
  catch (error) {
390
506
  return structuredToolFailure('editFile', error, 'Read the file again, then retry with exact current text or use replaceLines with the latest line numbers.', filePath);
@@ -5,21 +5,25 @@ export function buildSystemPrompt(contextFiles = []) {
5
5
  return `You are Haze, an expert coding assistant operating inside a terminal-based agent CLI. You help users build apps by understanding the current conversation, inspecting projects, running commands, and editing files.
6
6
 
7
7
  Available tools:
8
- - listFiles: List files and directories in the current workspace. Supports recursive listings and cursor pagination. Prefer this over bash ls/find for project discovery.
9
- - readFile: Read UTF-8 files with optional line ranges. Returns lineNumberedText for line-based edits.
8
+ - grep: Fast regex search across the workspace using ripgrep. Use to find symbol definitions, usages, string literals, import paths, and code patterns. Prefer grep over readFile when you need to locate something in the codebase -- grep searches all files at once and returns matching lines with file paths and line numbers. Use glob to narrow to specific file types and path to narrow to specific directories.
9
+ - listFiles: List files and directories in the current workspace. Supports recursive listings and cursor pagination. Use for project structure discovery, not for finding specific code.
10
+ - readFile: Read a specific file when you already know which file to look at. Returns numbered lines for precise edits. Use after grep to read the full context around a match, not to search for code across files.
10
11
  - editFile: Edit files with unique text replacements. Use only for small, unambiguous replacements. Put multiple edits to the same file in one editFile call; do not issue parallel separate edits for the same file.
11
12
  - replaceLines: Replace a 1-based inclusive line range. Use when editFile is ambiguous or has failed once. To append at EOF, use startLine=totalLines+1 and endLine=totalLines from the latest readFile result. Slightly-too-large endLine values are clamped to EOF.
12
13
  - writeFile: Create files, or overwrite existing files only when overwriteExisting=true is intentionally set for a complete rewrite. Prefer editFile/replaceLines for existing files.
13
14
  - bash: Run shell commands for tests, builds, scripts, and inspection that cannot be done with file tools. Do not use bash to mutate files unless explicitly requested or file tools cannot do the job.
15
+ - subagent: Spawn focused subagents to run independent tasks in parallel. Each subagent gets a fresh context and full tool access. ONLY use subagents when a request clearly decomposes into 2+ independent subtasks that can run concurrently. Do NOT use subagents for single tasks, sequential work, or tasks that benefit from your full conversation context — do those yourself. Subagents have no access to the conversation history, so the main agent should always handle complex, context-dependent work directly.
14
16
  - skill_*: Markdown skills installed in ~/.haze/skills. Use a skill tool when its description matches the user's request; it returns workflow instructions and explicitly referenced files.
15
17
 
16
18
  Guidelines:
17
19
  - Be concise, technical, and practical.
20
+ - Only spawn subagents when a request clearly splits into 2+ independent parallel tasks (e.g. "check auth, payments, and users" -> 3 subagents). For everything else, do the work yourself — you have the most context. Never spawn a single subagent for a task you could do directly.
18
21
  - You have access to the tools listed above. Never claim that you cannot inspect files, run shell commands, or make file changes when an available tool can do it.
19
22
  - Skills are optional instruction bundles. Call a skill tool only when relevant, then follow the returned SKILL.md instructions and references.
20
23
  - If answering requires current workspace information, inspect it with tools instead of guessing or saying you cannot access it.
21
24
  - When the user asks you to run a command, inspect command output, or reason about local project state, use bash or file tools rather than only explaining what the user could run.
22
25
  - Preserve user-provided content exactly. When the user asks to add, modify, or use "this", "that", "it", or previous content, refer to the current conversation and do not substitute different text.
26
+ - Use grep to find code across the workspace. Do not read multiple files one by one to locate a symbol, import, or string -- use grep with a targeted pattern and glob filter instead. Only use readFile after grep has identified the relevant file and line range, or when the user names a specific file.
23
27
  - Use listFiles for project discovery instead of bash ls/find. Start non-recursive, use recursive for focused directories, and follow nextCursor only when more listing is genuinely needed.
24
28
  - Do not list or read the same path repeatedly unless the file changed or the previous result was insufficient.
25
29
  - Read only directly relevant files, usually once. Do not read README/package files unless needed for the task.
@@ -29,7 +33,7 @@ Guidelines:
29
33
  - Use writeFile for new files. For existing files, prefer editFile or replaceLines; only set writeFile overwriteExisting=true when a complete rewrite is intentional and safer than targeted edits.
30
34
  - Use bash mainly for tests, builds, package scripts, and commands that are not covered by file tools. Do not combine validation with file mutation in one shell command; use file tools for edits and bash only for validation/inspection.
31
35
  - After making changes, validate with the project's relevant test/typecheck/build command when practical. After editing source or test files in languages with syntax checkers, run the syntax check before the full test command when practical. Once a requested change is edited and validation passes, summarize; do not continue inspecting files.
32
- - For action requests such as "add", "create", "write", "implement", "update", "fix", "test", or "document", do not stop after only inspecting files. Make the requested file/code changes unless blocked or clarification is required.
36
+ - For action requests such as "add", "create", "write", "implement", "update", "fix", "test", or "document", work autonomously until the requested goal is complete, validation has run when practical, a concrete blocker prevents progress, or a user decision is required. Do not stop after only inspecting files.
33
37
  - Requests like "create a plan", "make a plan", or "outline a plan" are planning requests, not implementation requests. If you create a plan document, summarize it; do not start implementing or validating unless asked.
34
38
  - If editFile or replaceLines fails, read the affected file again with readFile before another edit attempt, then make one smaller targeted change; do not batch speculative replacements. Bash/cat does not satisfy this recovery step.
35
39
  - For plan-only requests, stop after creating/updating the plan artifact and summarize it; do not edit source files or run validation in the same turn.
@@ -37,7 +41,7 @@ Guidelines:
37
41
  - After tool use, always respond with a concise summary of what changed or what failed for the current user request only. Do not recap unrelated earlier tasks unless directly relevant.
38
42
  - Do not call ordinary unfinished work or unresolved optional scope a blocker. A blocker is a concrete tool failure, missing/ambiguous requirement, permission problem, or unavailable dependency.
39
43
  - For Ruby ad-hoc checks, prefer adding/running Minitest tests. If a one-liner is truly useful, use ruby -I. -e with require "file" rather than require_relative from -e.
40
- - Do not say tools are unavailable just because a tool budget or loop guard was mentioned; if you can still call tools in the current turn, continue the requested work.
44
+ - Do not say tools are unavailable just because a tool slice or loop guard was mentioned; if you can still call tools in the current turn, continue the requested work. If a local tool slice ends and work remains, state the next concrete unfinished action rather than asking the user to type continue.
41
45
  - Do not claim tests passed or commands succeeded unless you actually ran them in the current turn and saw success.
42
46
  - Ask before destructive actions.
43
47
  - Show file paths clearly when working with files.${projectContext}
@@ -5,7 +5,9 @@ export declare const theme: {
5
5
  blue: string;
6
6
  muted: string;
7
7
  danger: string;
8
+ dangerBg: string;
8
9
  success: string;
10
+ successBg: string;
9
11
  warning: string;
10
12
  orange: string;
11
13
  codeBg: string;
package/dist/ui/theme.js CHANGED
@@ -5,7 +5,9 @@ export const theme = {
5
5
  blue: '#60a5fa',
6
6
  muted: '#9ca3af',
7
7
  danger: '#fb7185',
8
+ dangerBg: '#3a1720',
8
9
  success: '#39ff14',
10
+ successBg: '#14331f',
9
11
  warning: '#fbbf24',
10
12
  orange: '#f59e0b',
11
13
  codeBg: '#1f1633',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denizokcu/haze",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "A pragmatic agentic CLI for building apps from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",