klyro 0.1.61 → 0.1.63

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/READ.md CHANGED
@@ -465,3 +465,71 @@ Global flags `src/index.ts:60` `--cwd/--config/--debug/--verbose/--quiet/--json/
465
465
 
466
466
  Run: `pnpm build && pnpm test && node dist/index.js --help` → `klyro -p "fix login test" --output json` → `klyro doctor`.
467
467
 
468
+ ---
469
+
470
+ ## 20. Session Ledger v0.1.39 → v0.1.61 (what was done, what failed)
471
+
472
+ All work below was done live against user-reported production issues, verified with
473
+ `typecheck 0` + full `vitest` + built-`dist` smoke tests before every publish.
474
+ Current: **`klyro@0.1.61` `latest`, 51 test files, 442/442 tests, commits as
475
+ `siddu-lingampelli`** (history rewritten from `Mythicai-lab`, tags moved, §20.6).
476
+
477
+ ### 20.1 Release history
478
+
479
+ | Version | Commit | What shipped |
480
+ |---|---|---|
481
+ | 0.1.39 | base | Single-column Klyro TUI, true `#E8843C` orange via 24-bit, hello Direct-LLM (no tools) |
482
+ | 0.1.40 | `ca85d56` | True `#FF6B1A` orange, clean single-column, no `g`-letter corruption |
483
+ | 0.1.41 | `1884807` | `useChatScroll` hook (pin/unpin, snap-to-message, new-messages pill) |
484
+ | 0.1.42 | `50c84dc` | Audit batch: git-blame argv+path validation, `NODE_OPTIONS`/`LD_PRELOAD` block, ordered transcript, configurable `rerunOnce` timeout, model-aware `estimateCost`, Buffer-based (non-O(n²)) verify collectors, deleted empty `packages/cli+core`, `snippets.ts`, `util/log.ts`, `stuckCount`; TUI ellipsis `…`→`...`, mojibake purge |
485
+ | 0.1.43 | `04bd24e` | Slash groundwork: `/provider` `/effort` `/model` `/compact` `/plan` `/login` `/logout` `/init` handlers |
486
+ | 0.1.44 | `81d9627` | **Priority 1 per `commands.md`** (~39 cmds): session/model/project/perms/auth groups; TUI orphan-file reconciliation (deleted `app.inline/banner/input-box/activity-line/thinking-block/components/Header`) |
487
+ | 0.1.45 | `feb30ff` | **Priority 2 per `commands.md`** (~70 cmds): review/build/git/files/agents/config/misc; `/` autocomplete popup + Tab |
488
+ | 0.1.46 | `b8947d1` | **`scroll.md` S1–S9 Ink adaptation**: `measure.ts` (display-width/wrap/cache/line-index), `scroll-model.ts` (anchor reducer), input history + contextual ↑/↓, run-length activity merge, console-patch, exit transcript replay |
489
+ | 0.1.47 | `c440bc3` | **Dead scroll trap fix** (directional epsilon — line/wheel up from bottom could never escape) + mouse-wheel capture + `Ctrl+F/B` pager keys |
490
+ | 0.1.48 | `1e7f85c` | **Set-once auth**: persisted provider resolution (env → config+credentials → probe), first-run setup, `/model` `/provider` persist, `/reload` rebuilds adapter |
491
+ | 0.1.49 | `f0e840d` + `262b8fc` | Plain-HTTP endpoints explain+confirm with persisted `allowInsecure` opt-in, loud rejections (`lastProviderError`), prefilled setup, keep-saved-key; (+ separate commit preserving a concurrent working-tree `scroll.md` rewrite) |
492
+ | 0.1.50 | `5b3d3ee` | **`design.md` conformance**: half-page keys, Esc/Ctrl+C/Shift+Enter semantics, full Markdown renderer, header/statusbar/plan restyle |
493
+ | 0.1.51 | `6ba47de` | **`scroll.md` command architecture**: `transcript-commands.ts` keybind mapper + 4-command handle, submit-key settle effect |
494
+ | 0.1.52 | `db09e5f` | **Markdown columns bug**: `MarkdownText` fragment rendered lines as side-by-side columns — back to single `<Text>` |
495
+ | 0.1.53 | `5f9fa84` | Root `overflow:hidden` I1 guard (input can never be pushed out), tool results patch start-items in place (no forever-`running` spinners), narrow-terminal hints |
496
+ | 0.1.54 | `1ec3d0c` | **Full audit application** (P0+P1, §20.3) |
497
+ | 0.1.55 | `eb8776f` | Live working spinner (Thinking row, running tool groups, status bar) |
498
+ | 0.1.56 | `cf1a94d` | **Thinking channel**: `reasoning_content`/Anthropic-thinking capture → dim display → cleared on response |
499
+ | 0.1.57 | `f6fd749` | Composer audit: `flexShrink={0}` chrome, position/multiline/aggregation tests |
500
+ | 0.1.58 | `a57d526` | Abort clears thinking, `/clear` resets scroll anchor, badge overlap fix |
501
+ | 0.1.59 | `bbe9ba6` | Scroll position readout (`⇅ top/max`) in status bar |
502
+ | 0.1.60 | `6627b5b` | **Wheel tap fix**: intercept `stdin.read` (Ink7 paused mode), not `data` events that never fire |
503
+ | 0.1.61 | `5a1a97f` | Brand sweep: Claude naming removed, `CLAUDE.md` fallback dropped (Klyro-only) |
504
+
505
+ ### 20.2 What was built (by area)
506
+
507
+ - **Slash commands (113, all smoke-tested on built `dist`)**: session (`/new` `/resume` `/sessions` `/rename` `/fork` `/branch` `/export` `/copy`), model (`/model` `/models` `/provider` `/effort` `/fast`), project (`/init` `/status` `/context` `/diff` `/plan` `/todos` `/memory`), perms (`/permissions` `/mode` `/sandbox` `/approve` `/deny`), app (`/login` `/logout` `/auth` `/version` `/update` `/cancel` `/shell` `!` `/mention` `@` `/tools`), workflow (`/review` `/code-review` `/security-review` `/simplify` `/test` `/lint` `/build` `/run` `/fix` `/explain` `/format` `/ask`), checkpoints (`/undo` `/redo`→honest-noop `/rewind` `/checkpoint` `/accept` `/reject`), activity (`/details` `/verbose` `/raw` `/activity` `/tasks` `/ps` `/stop` `/queue` `/retry` `/kill`), agents+MCP, files (`/attach`→in-context `/drop` `/files` `/image` `/paste` `/ls` `/tree` `/search` `/web` `/read` `/map` `/tokens`), git (`/commit` via temp file `/push` `/pull` `/pr` `/issue`), config UI (`/editor` argv-spawn `/keymap` `/vim` `/theme` `/statusline` `/output-style`), diagnostics (`/doctor` `/debug` `/whoami` `/reload` `/reset` `/bug` `/changelog`), library (`/prompt` `/alias`→auto-expand `/commands` `/env` (injection keys blocked) `/deps` `/install`).
508
+ - **Scroll system** (`src/tui/scroll-model.ts` + `measure.ts` + `mouse.ts` + `transcript-commands.ts`): anchor `{itemId,lineInItem}` (never raw rows), measured wrapped display lines (CJK=2/emoji=2/combining=0), `MeasureCache`, follow-at-bottom / freeze-when-pinned / `↓ N new` badge, `End`/`Ctrl+G`/empty-Enter dismiss, wheel ±3, PageUp/Dn half-page, Home/End first/last, exit transcript replay, degraded tiny-terminal mode.
509
+ - **Auth persistence**: `~/.klyro/settings.json` + `credentials.json` (0600); resolution env → config → stored keys → local probe; `KLYRO_CONFIG`/`KLYRO_CONFIG_DIR`/`KLYRO_CREDENTIALS_FILE` overrides (also used by tests); persisted `allowInsecure` replaces per-terminal `KLYRO_ALLOW_INSECURE=1`.
510
+ - **Verification**: denylist (destructive-only; metachars allowed since commands are user/config-sourced), sanity checks fail-fast *before* the suite, scoped→full, flaky rerun with configured timeout, `hasEdits` drives `--require-verify` (exit 8 only when edits exist).
511
+ - **Safety**: `resolveAndFollowSymlinks` everywhere incl. checkpoints (traversal-contained undo that also restores deletions), `wasRead` guards on all four mutators with normalized + per-task history, `readFileSizeRule` stats real files, `clonePolicyConfig` per engine, `setByPath` proto-pollution guard, shell full-output caps + POSIX process-group kill, `startBackgroundArgv` (shell:false), secret redaction before model/summary paths.
512
+
513
+ ### 20.3 Audit application (the big diagnostic)
514
+
515
+ Every claim was verified against code before touching. **Confirmed + fixed (P0)**: ApprovalModal never rendered (+ single-listener bridge deafened it — also the flake root cause); Anthropic `role:tool` flattened to empty turns; duplicate `message_end`; edit-file fuzzy rewriting whole files (plus a dead line-window tier); apply-patch append-only stub → real hunk applier; `require()` in ESM (`run-verify`, later all touched files → static imports). **Confirmed + fixed (P1)**: shared policy config; `inferFileChanged` gaps; tokenizer pair orphans; klyro-md import escape + no caps; verify-order waste + `$()` overblock; unbounded full buffers; `/web` caps; checkpoint traversal/undo; store locking + `delete()`/`fork()` with messages; SIGINT-once; silent-mode newline; retry sleep ignores abort; read-history normalization; dotfile flag; `read_fileBody` shape bug in `/attach` `/mention` `/read` `/review` `/explain`; Ctrl+C-safe login/setup; `isToolErrorCode` swallowing custom codes; read-size rule via stat; multi-edit guards; `last.diff` writer; dry-run real prompt; debug.log rotation; `/cd` cwd reset; cost/context single source; headless URL policy. **Deliberately not changed**: `isSimpleChat` fast-path (intended UX); Ink-vs-OpenTUI architecture (approved); eval/MCP/L10–L20 (roadmap); `$EDITOR`/user-typed shell passthrough (user's own intent); trace paths; `isToolErrorCode`-adjacent churn that worked.
516
+
517
+ ### 20.4 What FAILED along the way (honest log)
518
+
519
+ - **Markdown columns regression (shipped 0.1.50, caught by user screenshot, fixed 0.1.52)**: fragment of `<Text>` in a row `Box` laid lines out as columns. Lesson: frame-level regression test added (fails on old code, passes on new).
520
+ - **Dead scroll trap (mine)**: symmetric follow-epsilon made line/wheel-up from bottom re-stick forever. Found by tracing, fixed directional, regression-tested.
521
+ - **Deaf wheel tap (mine)**: intercepted `data` events; Ink7 only uses `readable`+`read()`. Found by reading installed Ink source, rewrote + tested the wrapper contract.
522
+ - **Silent setup loop (mine)**: saved-but-rejected base URLs were swallowed → infinite first-run. Fixed with recorded reasons + prefilled recovery.
523
+ - **`require()` in ESM**: would have thrown `ReferenceError` in production `dist` while passing under `tsx`/`vitest` shims. Converted everything touched to static imports.
524
+ - **My own test-math errors**: indent tier (4sp→2 tabs), wheel maxTop, badge line counts, half-page expectations — each caught by running tests, fixed, and (for scroll-reset) proven discriminating by noop-check.
525
+ - **Infra friction**: npm `E409` staging + `E404` propagation delays (60–150s waits per publish); PowerShell mangling inline `python`/`node` quoting (rule learned: script files only); `git add -A` twice scooped a concurrent agent's doc rewrites (split into separate commits; own files only since); vitest load flakes under parallel sessions (fixed sleeps → poll-with-timeout; one residual publish-gate flake at 0.1.59, green on rerun).
526
+ - **Authorship**: 49 commits + 10 tags rewritten to `siddu-lingampelli` (force-with-lease, trees verified identical); repo-local git identity set.
527
+
528
+ ### 20.5 Environment notes for future sessions
529
+
530
+ - Windows PowerShell 5.1: chain with `;`, never `&&`; quote paths; never inline `python -c` with brackets/quotes (write temp scripts under `C:\Users\L.Siddhartha\AppData\Local\Temp\opencode`).
531
+ - Never `git add -A` blindly — another agent rewrites `design.md`/`scroll.md`/`TUI_DESIGN.md`/`commands.md` concurrently; stage own files explicitly.
532
+ - `npm publish` runs `typecheck && test && build`; expect CDN delays; `latest` flips ~1–3 min after `+ klyro@x`.
533
+ - Test isolation for anything reading `~/.klyro`: set `KLYRO_CONFIG` + `KLYRO_CONFIG_DIR` + `KLYRO_CREDENTIALS_FILE` to temp dirs.
534
+ - `ink-testing-library` has fixed terminal geometry (no resize tests) and normalizes keys like production Ink; production-only paths (stdin tap, alt-screen) need contract tests, not frame tests.
535
+
@@ -8,14 +8,15 @@
8
8
  * 1. `system` is a top-level field, not a message with role=system.
9
9
  * 2. Tool definitions use `input_schema` not `parameters`, and have no
10
10
  * `type: 'function'` wrapper.
11
- * 3. `tool_use_id` becomes our `id`; the tool input is sent as a single
12
- * `input_json_delta` block.
11
+ * 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
12
+ * `input_json_delta` frames, assembled per content_block index (never
13
+ * assumed whole, never silently dropped).
13
14
  *
14
15
  * Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
15
16
  * Auth can be a Bearer token (for proxies) — the adapter accepts either.
16
17
  */
17
18
  import type { Message } from './message.js';
18
- import type { ProviderAdapter, ToolDefinition } from './provider-adapter.js';
19
+ import type { ProviderAdapter, StreamEvent, ToolDefinition } from './provider-adapter.js';
19
20
  export interface AnthropicAdapterOptions {
20
21
  baseURL?: string;
21
22
  apiKey: string;
@@ -50,17 +51,37 @@ interface AnthropicMessage {
50
51
  is_error?: boolean;
51
52
  }>;
52
53
  }
54
+ interface AnthropicSseEvent {
55
+ type: string;
56
+ [key: string]: unknown;
57
+ }
53
58
  export declare class AnthropicApiError extends Error {
54
59
  readonly status: number;
55
60
  readonly body: string;
56
61
  constructor(status: number, body: string);
57
62
  }
58
63
  export declare function anthropicAdapter(opts: AnthropicAdapterOptions): ProviderAdapter;
59
- /** Match an Anthropic content_block index to the tool_use id we emitted. */
60
- declare function findToolIdByIndex(index: number | undefined, buffers: Map<string, {
61
- name: string;
62
- argsJson: string;
63
- }>, indexToToolId?: Map<number, string>): string | undefined;
64
+ /**
65
+ * Mutable per-stream assembly state. Blocks are keyed by content_block
66
+ * index; the tool id is carried inside the block entry. There is no global
67
+ * "current tool" — concurrent or interleaved blocks stay correctly routed.
68
+ */
69
+ interface AnthropicStreamState {
70
+ blocks: Map<number, {
71
+ id: string;
72
+ name: string;
73
+ argsJson: string;
74
+ open: boolean;
75
+ }>;
76
+ /** Fragments for an index with no open block yet (flushed on block start). */
77
+ orphans: Map<number, string>;
78
+ thinkingIdx: number | null;
79
+ usage: {
80
+ input?: number;
81
+ output?: number;
82
+ };
83
+ }
84
+ declare function translateSse(event: string, parsed: AnthropicSseEvent, state: AnthropicStreamState): StreamEvent[];
64
85
  declare function toAnthropicMessages(messages: Message[]): AnthropicMessage[];
65
86
  declare function toAnthropicTool(t: ToolDefinition): {
66
87
  name: string;
@@ -70,6 +91,6 @@ declare function toAnthropicTool(t: ToolDefinition): {
70
91
  export declare const _internal: {
71
92
  toAnthropicMessages: typeof toAnthropicMessages;
72
93
  toAnthropicTool: typeof toAnthropicTool;
73
- findToolIdByIndex: typeof findToolIdByIndex;
94
+ translateSse: typeof translateSse;
74
95
  };
75
96
  export {};
@@ -8,8 +8,9 @@
8
8
  * 1. `system` is a top-level field, not a message with role=system.
9
9
  * 2. Tool definitions use `input_schema` not `parameters`, and have no
10
10
  * `type: 'function'` wrapper.
11
- * 3. `tool_use_id` becomes our `id`; the tool input is sent as a single
12
- * `input_json_delta` block.
11
+ * 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
12
+ * `input_json_delta` frames, assembled per content_block index (never
13
+ * assumed whole, never silently dropped).
13
14
  *
14
15
  * Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
15
16
  * Auth can be a Bearer token (for proxies) — the adapter accepts either.
@@ -111,12 +112,14 @@ async function* streamAnthropic(req, opts) {
111
112
  const reader = resp.body.getReader();
112
113
  const decoder = new TextDecoder('utf-8');
113
114
  let buf = '';
114
- // Track in-progress tool calls so we can emit start/delta/end.
115
- const toolBuffers = new Map();
116
- // Map content_block index → tool_use id (persists after tool completes to handle late deltas)
117
- const indexToToolId = new Map();
118
- // Active thinking-block index (Anthropic reasoning channel).
119
- const thinkingState = { idx: null };
115
+ // Per-block assembly keyed by content_block index (tool input IS
116
+ // fragmented across input_json_delta frames — never assume otherwise).
117
+ const state = {
118
+ blocks: new Map(),
119
+ orphans: new Map(),
120
+ thinkingIdx: null,
121
+ usage: {},
122
+ };
120
123
  // message_stop already yields message_end — don't emit a second one at EOF.
121
124
  let sawMessageEnd = false;
122
125
  try {
@@ -155,7 +158,7 @@ async function* streamAnthropic(req, opts) {
155
158
  catch {
156
159
  continue;
157
160
  }
158
- const out = translateSse(e.event, parsed, toolBuffers, indexToToolId, thinkingState);
161
+ const out = translateSse(e.event, parsed, state);
159
162
  for (const ev of out) {
160
163
  if (ev.kind === 'message_end')
161
164
  sawMessageEnd = true;
@@ -172,23 +175,69 @@ async function* streamAnthropic(req, opts) {
172
175
  finally {
173
176
  reader.releaseLock();
174
177
  }
175
- if (!sawMessageEnd)
176
- yield { kind: 'message_end', finishReason: 'stop' };
178
+ // Truncated stream: close open blocks so the runtime finalizes them as
179
+ // (malformed) structured errors instead of hanging, then terminate.
180
+ for (const b of state.blocks.values()) {
181
+ if (b.open) {
182
+ b.open = false;
183
+ yield { kind: 'tool_call_end', id: b.id };
184
+ }
185
+ }
186
+ state.blocks.clear();
187
+ // Fragments that could never be attributed to a tool block are a stream
188
+ // integrity failure — surface loudly, never silently drop.
189
+ if (state.orphans.size > 0 && !sawMessageEnd) {
190
+ const count = [...state.orphans.values()].reduce((n, s) => n + s.length, 0);
191
+ state.orphans.clear();
192
+ yield {
193
+ kind: 'error',
194
+ code: 'ORPHAN_TOOL_DELTAS',
195
+ message: `stream ended with ${count} chars of tool input that match no content block`,
196
+ retryable: false,
197
+ };
198
+ return;
199
+ }
200
+ state.orphans.clear();
201
+ if (!sawMessageEnd) {
202
+ yield {
203
+ kind: 'message_end',
204
+ finishReason: 'stop',
205
+ ...(state.usage.input !== undefined || state.usage.output !== undefined
206
+ ? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
207
+ : {}),
208
+ };
209
+ }
177
210
  }
178
- function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
211
+ function translateSse(event, parsed, state) {
179
212
  const out = [];
180
213
  switch (event) {
214
+ case 'message_start': {
215
+ const usage = parsed.message?.usage;
216
+ if (typeof usage?.input_tokens === 'number')
217
+ state.usage.input = usage.input_tokens;
218
+ return out;
219
+ }
220
+ case 'message_delta': {
221
+ const usage = parsed.usage;
222
+ if (typeof usage?.output_tokens === 'number') {
223
+ state.usage.output = (state.usage.output ?? 0) + usage.output_tokens;
224
+ }
225
+ return out;
226
+ }
181
227
  case 'content_block_start': {
182
228
  const block = parsed.content_block;
183
229
  const idx = parsed.index;
184
- if (block?.type === 'tool_use' && block.id && block.name) {
185
- toolBuffers.set(block.id, { name: block.name, argsJson: '' });
186
- if (idx !== undefined)
187
- indexToToolId.set(idx, block.id);
230
+ if (block?.type === 'tool_use' && block.id && block.name && idx !== undefined) {
231
+ // Flush any fragments that arrived before the block start.
232
+ const stashed = state.orphans.get(idx);
233
+ state.orphans.delete(idx);
234
+ state.blocks.set(idx, { id: block.id, name: block.name, argsJson: stashed ?? '', open: true });
188
235
  out.push({ kind: 'tool_call_start', id: block.id, name: block.name });
236
+ if (stashed)
237
+ out.push({ kind: 'tool_call_delta', id: block.id, argsJson: stashed });
189
238
  }
190
- else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') && thinking && idx !== undefined) {
191
- thinking.idx = idx;
239
+ else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') && idx !== undefined) {
240
+ state.thinkingIdx = idx;
192
241
  }
193
242
  return out;
194
243
  }
@@ -202,31 +251,50 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
202
251
  out.push({ kind: 'thinking_delta', text: delta.thinking });
203
252
  }
204
253
  else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
205
- const id = findToolIdByIndex(index, toolBuffers, indexToToolId);
206
- if (id) {
207
- const buf = toolBuffers.get(id);
208
- if (buf) {
209
- buf.argsJson += delta.partial_json;
210
- out.push({ kind: 'tool_call_delta', id, argsJson: delta.partial_json });
254
+ const entry = index !== undefined ? state.blocks.get(index) : undefined;
255
+ if (entry && entry.open) {
256
+ entry.argsJson += delta.partial_json;
257
+ out.push({ kind: 'tool_call_delta', id: entry.id, argsJson: delta.partial_json });
258
+ }
259
+ else if (index !== undefined) {
260
+ const open = [...state.blocks.values()].filter((b) => b.open);
261
+ if (open.length === 1) {
262
+ // Single in-flight tool: attribute here (documented heuristic).
263
+ open[0].argsJson += delta.partial_json;
264
+ out.push({ kind: 'tool_call_delta', id: open[0].id, argsJson: delta.partial_json });
265
+ }
266
+ else {
267
+ // No safe attribution — stash for a later block start, or
268
+ // surface as ORPHAN_TOOL_DELTAS at stream end. Never drop.
269
+ state.orphans.set(index, (state.orphans.get(index) ?? '') + delta.partial_json);
211
270
  }
212
271
  }
272
+ else {
273
+ state.orphans.set(-1, (state.orphans.get(-1) ?? '') + delta.partial_json);
274
+ }
213
275
  }
214
276
  return out;
215
277
  }
216
278
  case 'content_block_stop': {
217
279
  const index = parsed.index;
218
- if (thinking && index !== undefined && index === thinking.idx)
219
- thinking.idx = null;
220
- const id = findToolIdByIndex(index, toolBuffers, indexToToolId);
221
- if (id) {
222
- toolBuffers.delete(id);
223
- // Keep index mapping for late deltas that may arrive after stop (rare)
224
- out.push({ kind: 'tool_call_end', id });
280
+ if (index !== undefined && index === state.thinkingIdx)
281
+ state.thinkingIdx = null;
282
+ const entry = index !== undefined ? state.blocks.get(index) : undefined;
283
+ if (entry && entry.open) {
284
+ entry.open = false;
285
+ state.blocks.delete(index);
286
+ out.push({ kind: 'tool_call_end', id: entry.id });
225
287
  }
226
288
  return out;
227
289
  }
228
290
  case 'message_stop': {
229
- out.push({ kind: 'message_end', finishReason: 'stop' });
291
+ out.push({
292
+ kind: 'message_end',
293
+ finishReason: 'stop',
294
+ ...(state.usage.input !== undefined || state.usage.output !== undefined
295
+ ? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
296
+ : {}),
297
+ });
230
298
  return out;
231
299
  }
232
300
  case 'error': {
@@ -243,21 +311,6 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
243
311
  return out;
244
312
  }
245
313
  }
246
- /** Match an Anthropic content_block index to the tool_use id we emitted. */
247
- function findToolIdByIndex(index, buffers, indexToToolId) {
248
- if (index === undefined)
249
- return undefined;
250
- if (indexToToolId) {
251
- const direct = indexToToolId.get(index);
252
- if (direct)
253
- return direct;
254
- }
255
- // Single-buffer fallback: if only one in-flight tool, any delta belongs to it
256
- if (buffers.size === 1)
257
- return buffers.keys().next().value;
258
- // No reliable mapping — drop the delta rather than misroute to wrong tool (prevents _parse_error loops)
259
- return undefined;
260
- }
261
314
  function toAnthropicMessages(messages) {
262
315
  return messages.map((m) => {
263
316
  if (m.role === 'user') {
@@ -320,4 +373,4 @@ function toAnthropicTool(t) {
320
373
  };
321
374
  }
322
375
  // Re-export for testability.
323
- export const _internal = { toAnthropicMessages, toAnthropicTool, findToolIdByIndex };
376
+ export const _internal = { toAnthropicMessages, toAnthropicTool, translateSse };
@@ -195,10 +195,41 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
195
195
  const reader = res.body.getReader();
196
196
  const decoder = new TextDecoder();
197
197
  let buf = '';
198
- // Track per-tool-call id by index.
199
- const toolIds = new Map();
200
- const toolNames = new Map();
198
+ const blocks = new Map();
201
199
  let pendingUsage;
200
+ // Canonical contract: exactly one terminal event per stream.
201
+ let terminalEmitted = false;
202
+ function* emitTerminal(finishReason, usage) {
203
+ if (terminalEmitted)
204
+ return;
205
+ terminalEmitted = true;
206
+ for (const b of blocks.values()) {
207
+ if (b.started && !b.ended && b.id) {
208
+ b.ended = true;
209
+ yield { kind: 'tool_call_end', id: b.id };
210
+ }
211
+ }
212
+ // Fragments that never gained an identity are surfaced as incomplete
213
+ // calls (the runtime turns them into structured errors) — never dropped.
214
+ let incomplete = 0;
215
+ for (const b of blocks.values()) {
216
+ if (!b.started && (b.argsJson || b.id || b.name)) {
217
+ const id = b.id ?? `incomplete_${incomplete++}`;
218
+ yield { kind: 'tool_call_start', id, name: b.name ?? 'unknown' };
219
+ if (b.argsJson)
220
+ yield { kind: 'tool_call_delta', id, argsJson: b.argsJson };
221
+ yield { kind: 'tool_call_end', id };
222
+ b.started = true;
223
+ b.ended = true;
224
+ }
225
+ }
226
+ const end = { kind: 'message_end' };
227
+ if (finishReason)
228
+ end.finishReason = finishReason;
229
+ if (usage)
230
+ end.usage = usage;
231
+ yield end;
232
+ }
202
233
  try {
203
234
  while (true) {
204
235
  const { value, done } = await reader.read();
@@ -215,7 +246,7 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
215
246
  continue;
216
247
  const data = line.slice(5).trim();
217
248
  if (data === '[DONE]') {
218
- yield { kind: 'message_end' };
249
+ yield* emitTerminal(undefined, pendingUsage);
219
250
  return;
220
251
  }
221
252
  let chunk;
@@ -248,17 +279,28 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
248
279
  yield { kind: 'thinking_delta', text: thinking };
249
280
  }
250
281
  for (const tc of choice.delta.tool_calls ?? []) {
251
- if (tc.id && tc.function?.name) {
252
- toolIds.set(tc.index, tc.id);
253
- toolNames.set(tc.index, tc.function.name);
254
- yield { kind: 'tool_call_start', id: tc.id, name: tc.function.name };
282
+ let b = blocks.get(tc.index);
283
+ if (!b) {
284
+ b = { argsJson: '', started: false, ended: false };
285
+ blocks.set(tc.index, b);
255
286
  }
256
- else if (tc.id) {
257
- toolIds.set(tc.index, tc.id);
287
+ if (tc.id)
288
+ b.id = tc.id;
289
+ if (tc.function?.name)
290
+ b.name = tc.function.name;
291
+ const newArgs = tc.function?.arguments;
292
+ if (newArgs)
293
+ b.argsJson += newArgs;
294
+ if (b.id && b.name && !b.started) {
295
+ // Identity arrived (possibly after earlier fragments). Emit
296
+ // start, then flush any accumulated args as one delta.
297
+ b.started = true;
298
+ yield { kind: 'tool_call_start', id: b.id, name: b.name };
299
+ if (b.argsJson)
300
+ yield { kind: 'tool_call_delta', id: b.id, argsJson: b.argsJson };
258
301
  }
259
- if (tc.function?.arguments) {
260
- const id = toolIds.get(tc.index) ?? `call_${tc.index}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
261
- yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
302
+ else if (b.started && b.id && newArgs) {
303
+ yield { kind: 'tool_call_delta', id: b.id, argsJson: newArgs };
262
304
  }
263
305
  }
264
306
  if (choice.finish_reason) {
@@ -266,38 +308,19 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
266
308
  ? { input: chunk.usage.prompt_tokens, output: chunk.usage.completion_tokens }
267
309
  : undefined);
268
310
  pendingUsage = undefined;
269
- // Clear tool tracking per message to avoid stale ids on next turn
270
- const ids = [...toolIds.values()];
271
- toolIds.clear();
272
- toolNames.clear();
273
- for (const id of ids)
274
- yield { kind: 'tool_call_end', id };
275
- yield { kind: 'message_end', finishReason: choice.finish_reason, usage };
311
+ yield* emitTerminal(choice.finish_reason, usage);
276
312
  }
277
313
  }
278
314
  }
279
315
  }
280
316
  }
281
- if (toolIds.size) {
282
- for (const id of toolIds.values())
283
- yield { kind: 'tool_call_end', id };
284
- if (pendingUsage) {
285
- yield { kind: 'message_end', usage: pendingUsage };
286
- }
287
- else {
288
- yield { kind: 'message_end' };
289
- }
290
- }
291
- else if (pendingUsage) {
292
- yield { kind: 'message_end', usage: pendingUsage };
293
- }
294
- else {
295
- yield { kind: 'message_end' };
296
- }
317
+ yield* emitTerminal(undefined, pendingUsage);
297
318
  }
298
319
  catch (err) {
299
- const msg = err instanceof Error ? err.message : String(err);
300
- yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
320
+ if (!terminalEmitted) {
321
+ const msg = err instanceof Error ? err.message : String(err);
322
+ yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
323
+ }
301
324
  }
302
325
  finally {
303
326
  clearTimeout(timer);
@@ -139,6 +139,7 @@ export type RuntimeEvent = {
139
139
  kind: 'usage';
140
140
  input: number;
141
141
  output: number;
142
+ estimated?: boolean;
142
143
  } | {
143
144
  kind: 'final_text';
144
145
  text: string;
@@ -171,7 +172,7 @@ export type RuntimeEvent = {
171
172
  sessionId: string;
172
173
  };
173
174
  export interface RunResult {
174
- status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked';
175
+ status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked' | 'stuck';
175
176
  steps: number;
176
177
  toolCalls: number;
177
178
  finalText: string;
@@ -181,6 +182,7 @@ export interface RunResult {
181
182
  usage: {
182
183
  input: number;
183
184
  output: number;
185
+ estimated?: boolean;
184
186
  };
185
187
  /** Number of policy-driven user prompts the user accepted. */
186
188
  repairs?: number;