@mafdet/cli 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +15 -5
  3. package/README.zh-CN.md +9 -2
  4. package/dist/agent/context-budget.d.ts +32 -0
  5. package/dist/agent/context-budget.d.ts.map +1 -1
  6. package/dist/agent/context-budget.js +64 -0
  7. package/dist/agent/loop.d.ts +8 -1
  8. package/dist/agent/loop.d.ts.map +1 -1
  9. package/dist/agent/loop.js +25 -13
  10. package/dist/agent/session.d.ts.map +1 -1
  11. package/dist/agent/session.js +7 -1
  12. package/dist/api/http.d.ts +15 -1
  13. package/dist/api/http.d.ts.map +1 -1
  14. package/dist/api/http.js +26 -5
  15. package/dist/api/sse-parser.d.ts +26 -1
  16. package/dist/api/sse-parser.d.ts.map +1 -1
  17. package/dist/api/sse-parser.js +62 -21
  18. package/dist/commands/agent.d.ts.map +1 -1
  19. package/dist/commands/agent.js +76 -0
  20. package/dist/commands/chat.d.ts.map +1 -1
  21. package/dist/commands/chat.js +21 -4
  22. package/dist/commands/config.d.ts.map +1 -1
  23. package/dist/commands/config.js +0 -2
  24. package/dist/commands/runtime.d.ts +6 -0
  25. package/dist/commands/runtime.d.ts.map +1 -1
  26. package/dist/commands/sessions.d.ts.map +1 -1
  27. package/dist/commands/sessions.js +129 -18
  28. package/dist/config/config.d.ts +0 -12
  29. package/dist/config/config.d.ts.map +1 -1
  30. package/dist/config/config.js +31 -6
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +17 -1
  33. package/dist/output/format.d.ts.map +1 -1
  34. package/dist/output/format.js +10 -5
  35. package/dist/output/terminal-safe.d.ts +12 -0
  36. package/dist/output/terminal-safe.d.ts.map +1 -0
  37. package/dist/output/terminal-safe.js +38 -0
  38. package/dist/sessions/open.d.ts.map +1 -1
  39. package/dist/sessions/open.js +26 -2
  40. package/dist/sessions/recorder.d.ts.map +1 -1
  41. package/dist/sessions/recorder.js +6 -1
  42. package/dist/sessions/session-id.d.ts +16 -0
  43. package/dist/sessions/session-id.d.ts.map +1 -0
  44. package/dist/sessions/session-id.js +93 -0
  45. package/dist/sessions/store.d.ts +41 -2
  46. package/dist/sessions/store.d.ts.map +1 -1
  47. package/dist/sessions/store.js +48 -24
  48. package/dist/sessions/usage.d.ts +6 -0
  49. package/dist/sessions/usage.d.ts.map +1 -1
  50. package/dist/sessions/usage.js +12 -1
  51. package/dist/tui/agent-surface.d.ts.map +1 -1
  52. package/dist/tui/agent-surface.js +6 -2
  53. package/dist/tui/app.d.ts.map +1 -1
  54. package/dist/tui/app.js +43 -10
  55. package/dist/tui/start-agent.d.ts +31 -0
  56. package/dist/tui/start-agent.d.ts.map +1 -1
  57. package/dist/tui/start-agent.js +147 -45
  58. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,70 @@ The version is the contract for the JSON output shapes and the exit codes.
6
6
  Being 0.x, semver still permits a break in a minor bump; the intent is not to
7
7
  use that latitude, and the promise becomes binding at 1.0.0.
8
8
 
9
+ ## 0.4.4 - 2026-08-11
10
+
11
+ A security fix and twenty-one other corrections, all from an external audit of
12
+ the 0.4.3 release.
13
+
14
+ **Session ids are now checked before they become paths.** `mafdet sessions
15
+ show`, `usage`, `delete` and `--resume` each joined whatever you typed onto
16
+ the sessions directory, and `join` resolves `..` - so an id like
17
+ `../../../something` reached outside the store. Reading it leaked another
18
+ directory's contents, and `sessions delete ../../../something --yes` removed
19
+ that directory's files. This was present from 0.3.0. It requires you (or a
20
+ script running as you) to pass such a path - the agent has no way to invoke
21
+ these commands - but it was a real destructive defect and it is fixed.
22
+
23
+ **Resuming a conversation now honours what it says.** Blockers were computed
24
+ and printed and then ignored, so a session whose workspace had been deleted,
25
+ or whose model your key can no longer call, continued anyway - and it
26
+ continued in your _current_ directory rather than the recorded one, which with
27
+ `--allow-write` meant carrying one project's conversation into another
28
+ project's files. A resumed session now runs where it was recorded, on the
29
+ model it was recorded with, and refuses when it cannot.
30
+
31
+ **Deleting reports what happened.** Every failure used to be swallowed and
32
+ "deleted" printed regardless. It now takes the session lock first and only
33
+ claims success when the directory is gone.
34
+
35
+ **`--read-only` works in the interactive session.** It existed only in `mafdet
36
+ agent`, so `mafdet --allow-write --read-only` was read-only there and
37
+ write-enabled here.
38
+
39
+ **Streams have limits.** The request timeout was cleared when the response
40
+ headers arrived, which for a stream is the beginning: a gateway that then went
41
+ quiet hung the CLI indefinitely. There is now a per-chunk idle deadline and a
42
+ byte cap, and both close the connection.
43
+
44
+ **Terminal control sequences.** A model's reply and a session's title, model
45
+ and workspace are stripped of escape sequences when a terminal is reading
46
+ them. Redirected output still receives exact bytes.
47
+
48
+ **Context compaction covers conversations without tools**, and when nothing
49
+ can safely be dropped the run stops and says so instead of sending a request
50
+ that is over the window.
51
+
52
+ **An unpriced request is no longer recorded as costing zero** - unknown and
53
+ free are different claims.
54
+
55
+ **`approvalMode` and `testCommands` are removed from the config.** They were
56
+ accepted, displayed by `config list`, and read by nothing, so setting
57
+ `auto-edit` looked like it turned approval off. They are removed rather than
58
+ implemented because in a project file both would let a cloned repository raise
59
+ its own permission level. Setting one is now an error explaining why.
60
+
61
+ **A damaged line in the middle of a transcript is reported** rather than
62
+ skipped in silence, and resuming from one is refused.
63
+
64
+ Also: `mafdet agent` gains `--title` and `--no-history` (`--resume` stays in
65
+ the interactive session and now says so); the model picker highlights the
66
+ default you saved; `sessions` refuses unknown flags and stray arguments
67
+ instead of ignoring them; `--json --plain` prints a usage error rather than a
68
+ stack trace; the resume lock is released on exit; `mafdet help` describes what
69
+ the agent can actually do; and the README states plainly that approving a
70
+ command means allowing that program to run as you - the workspace bounds the
71
+ agent's own tools, not the program, and is not a sandbox.
72
+
9
73
  ## 0.4.3 - 2026-08-11
10
74
 
11
75
  A long agent run no longer walks into the model's context window, a one-shot
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The official Mafdet AI command line: model calls, account queries, and a
4
4
  permissioned local agent.
5
5
 
6
- > **0.4.1.** 中文版见 [README.zh-CN.md](./README.zh-CN.md). The CLI never
6
+ > **0.4.4.** 中文版见 [README.zh-CN.md](./README.zh-CN.md). The CLI never
7
7
  > advertises a command it cannot serve, so `mafdet help` always lists exactly
8
8
  > what works.
9
9
  >
@@ -25,8 +25,8 @@ Or build it from this repository:
25
25
 
26
26
  ```bash
27
27
  pnpm --filter @mafdet/cli build
28
- cd packages/cli && npm pack # -> mafdet-cli-0.4.1.tgz
29
- npm install -g ./mafdet-cli-0.4.1.tgz
28
+ cd packages/cli && npm pack # -> mafdet-cli-0.4.4.tgz
29
+ npm install -g ./mafdet-cli-0.4.4.tgz
30
30
  ```
31
31
 
32
32
  ## The interactive session
@@ -113,6 +113,16 @@ prefixes: `pnpm test` permits `pnpm test --filter web` but never `pnpm publish`.
113
113
  `;`, `&&`, backticks and `$( )` are refused with an explanation rather than
114
114
  sanitised.
115
115
 
116
+ **What approving a command means.** It means allowing that program to run on
117
+ your machine, as you. The workspace bounds which files the agent's own tools
118
+ may touch; it is not a sandbox, and it does not bound the program. `pnpm test`
119
+ can read your home directory, reach the network, or start something that
120
+ outlives the run - because that is what running a program is. A short list of
121
+ obviously destructive names is refused (`sudo`, `su`, `doas`, `chown`,
122
+ `shutdown`, `reboot`, and invoking a program by path), but treat that as a
123
+ guard against slips rather than as containment. Approve prefixes the way you
124
+ would run the command yourself.
125
+
116
126
  In a session: `/model`, `/permissions`, `/diff`, `/cost`, `/compact`, `/exit`.
117
127
 
118
128
  ## Configuration
@@ -131,8 +141,8 @@ User config lives at `~/.config/mafdet/config.json` (Linux),
131
141
  `~/Library/Application Support/mafdet/config.json` (macOS) or
132
142
  `%APPDATA%\mafdet\config.json` (Windows).
133
143
 
134
- A project may carry `.mafdet.json` with `model`, `approvalMode` and
135
- `testCommands` — and nothing else. Credentials and endpoints in a project file
144
+ A project may carry `.mafdet.json` with `model` — and nothing else.
145
+ Credentials and endpoints in a project file
136
146
  are rejected outright: cloning a repository must never change where your key
137
147
  is sent.
138
148
 
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Mafdet AI 官方命令行:模型调用、账户查询,以及一个受权限约束的本地 Agent。
4
4
 
5
- > **0.4.1。** 英文版见 [README.md](./README.md)。
5
+ > **0.4.4。** 英文版见 [README.md](./README.md)。
6
6
 
7
7
  ## 环境要求
8
8
 
@@ -94,6 +94,13 @@ mafdet doctor
94
94
  **没有 shell**:命令被切成 argv 直接执行,所以管道、重定向、`;`、`&&`、反引号、`$( )`
95
95
  一律被拒绝并说明原因,而不是尝试"消毒"。
96
96
 
97
+ **批准一条命令意味着什么。** 意味着允许那个程序在你的机器上、以你的身份运行。
98
+ 工作区限定的是 **Agent 自己的工具**能碰哪些文件,它**不是沙箱**,也约束不了那个程序。
99
+ `pnpm test` 可以读你的家目录、可以联网、可以起一个比这次运行活得更久的进程——
100
+ 因为那就是"运行一个程序"的含义。我们会拒绝一小份明显破坏性的名字
101
+ (`sudo`、`su`、`doas`、`chown`、`shutdown`、`reboot`,以及按路径调用),
102
+ 但请把它当作**防手滑的护栏,而不是隔离**。批准前缀时,请按"我自己会不会直接敲这条命令"来判断。
103
+
97
104
  会话内命令:`/model`、`/permissions`、`/diff`、`/cost`、`/compact`、`/exit`。
98
105
 
99
106
  ## 配置
@@ -111,7 +118,7 @@ mafdet doctor
111
118
  用户配置位置:Linux `~/.config/mafdet/config.json`、macOS
112
119
  `~/Library/Application Support/mafdet/config.json`、Windows `%APPDATA%\mafdet\config.json`。
113
120
 
114
- 项目可以放一个 `.mafdet.json`,但**只允许** `model`、`approvalMode`、`testCommands` 三项。
121
+ 项目可以放一个 `.mafdet.json`,但**只允许 `model` 一项**。
115
122
  里面出现凭据或端点会**直接报错**:克隆一个仓库绝不该改变你的 Key 发往哪里。
116
123
 
117
124
  ## 退出码
@@ -34,4 +34,36 @@ export interface PruneResult {
34
34
  * running out of context.
35
35
  */
36
36
  export declare function pruneToolExchanges(history: ChatMessage[], keepRecent?: number): PruneResult;
37
+ /**
38
+ * F-008 · Drop the oldest whole turns.
39
+ *
40
+ * The exchange-based prune above cannot help a conversation with no tools in
41
+ * it - a long back-and-forth of plain questions removed nothing and carried on
42
+ * requesting, so "a long run no longer walks into the context window" was only
43
+ * true for tool-using runs.
44
+ *
45
+ * A turn starts at a user message and runs to just before the next one, so
46
+ * cutting on that boundary can never orphan a tool result or leave a tool call
47
+ * unanswered: CLI-014's rule holds by construction. The system prompt and the
48
+ * first user message - the goal - always stay.
49
+ */
50
+ export declare function dropOldestTurns(history: ChatMessage[], keepTurns: number): number;
51
+ export interface CompactionResult extends PruneResult {
52
+ /** Whole turns dropped, when exchanges were not enough. */
53
+ readonly turns: number;
54
+ /** True when nothing could be dropped and the history is still too big. */
55
+ readonly exhausted: boolean;
56
+ }
57
+ /**
58
+ * Shorten the history as much as can be done safely.
59
+ *
60
+ * Tool exchanges go first because they are the bulk of a long agent run and
61
+ * the least useful to keep. Only when there are none left does it start
62
+ * dropping turns, which costs the model more context.
63
+ *
64
+ * `exhausted` is the honest outcome when neither helped: the caller must stop
65
+ * rather than send a request that is over the window, which would fail
66
+ * upstream and be billed for the attempt.
67
+ */
68
+ export declare function compactForBudget(history: ChatMessage[], keepRecent?: number): CompactionResult;
37
69
  //# sourceMappingURL=context-budget.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context-budget.d.ts","sourceRoot":"","sources":["../../src/agent/context-budget.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,OAAO,CAAC;AAE/B,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAKT;AAED,MAAM,WAAW,WAAW;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAsCD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,WAAW,CAkBb"}
1
+ {"version":3,"file":"context-budget.d.ts","sourceRoot":"","sources":["../../src/agent/context-budget.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,OAAO,CAAC;AAE/B,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAKT;AAED,MAAM,WAAW,WAAW;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAsCD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,WAAW,CAkBb;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,WAAW,EAAE,EACtB,SAAS,EAAE,MAAM,GAChB,MAAM,CAuBR;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CAYlB"}
@@ -103,3 +103,67 @@ export function pruneToolExchanges(history, keepRecent = KEEP_RECENT_EXCHANGES)
103
103
  history.push(...kept);
104
104
  return { removed, exchanges: droppable.length };
105
105
  }
106
+ /**
107
+ * F-008 · Drop the oldest whole turns.
108
+ *
109
+ * The exchange-based prune above cannot help a conversation with no tools in
110
+ * it - a long back-and-forth of plain questions removed nothing and carried on
111
+ * requesting, so "a long run no longer walks into the context window" was only
112
+ * true for tool-using runs.
113
+ *
114
+ * A turn starts at a user message and runs to just before the next one, so
115
+ * cutting on that boundary can never orphan a tool result or leave a tool call
116
+ * unanswered: CLI-014's rule holds by construction. The system prompt and the
117
+ * first user message - the goal - always stay.
118
+ */
119
+ export function dropOldestTurns(history, keepTurns) {
120
+ const userAt = [];
121
+ for (let i = 0; i < history.length; i += 1) {
122
+ if (history[i]?.role === "user")
123
+ userAt.push(i);
124
+ }
125
+ // The first user message is the goal and is not a droppable turn, so there
126
+ // have to be more than `keepTurns` turns after it before anything can go.
127
+ if (userAt.length <= keepTurns + 1)
128
+ return 0;
129
+ const cut = userAt[userAt.length - keepTurns];
130
+ if (cut === undefined)
131
+ return 0;
132
+ const kept = [];
133
+ if (history[0]?.role === "system")
134
+ kept.push(history[0]);
135
+ const goal = history[userAt[0]];
136
+ if (goal !== undefined && userAt[0] !== cut)
137
+ kept.push(goal);
138
+ kept.push(...history.slice(cut));
139
+ const removed = history.length - kept.length;
140
+ if (removed <= 0)
141
+ return 0;
142
+ history.length = 0;
143
+ history.push(...kept);
144
+ return removed;
145
+ }
146
+ /**
147
+ * Shorten the history as much as can be done safely.
148
+ *
149
+ * Tool exchanges go first because they are the bulk of a long agent run and
150
+ * the least useful to keep. Only when there are none left does it start
151
+ * dropping turns, which costs the model more context.
152
+ *
153
+ * `exhausted` is the honest outcome when neither helped: the caller must stop
154
+ * rather than send a request that is over the window, which would fail
155
+ * upstream and be billed for the attempt.
156
+ */
157
+ export function compactForBudget(history, keepRecent = KEEP_RECENT_EXCHANGES) {
158
+ const pruned = pruneToolExchanges(history, keepRecent);
159
+ if (pruned.exchanges > 0) {
160
+ return { ...pruned, turns: 0, exhausted: false };
161
+ }
162
+ const turnsRemoved = dropOldestTurns(history, keepRecent);
163
+ return {
164
+ removed: turnsRemoved,
165
+ exchanges: 0,
166
+ turns: turnsRemoved > 0 ? 1 : 0,
167
+ exhausted: turnsRemoved === 0,
168
+ };
169
+ }
@@ -12,7 +12,9 @@ export declare const DEFAULT_LIMITS: {
12
12
  maxCostMicroUsd: bigint | undefined;
13
13
  };
14
14
  export type AgentLimits = typeof DEFAULT_LIMITS;
15
- export type StopReason = "completed" | "max_turns" | "timeout" | "max_cost" | "cancelled" | "rate_limited" | "error";
15
+ export type StopReason = "completed" | "max_turns" | "timeout" | "max_cost" | "cancelled" | "rate_limited"
16
+ /** F-008 · Over the model's window with nothing left that is safe to drop. */
17
+ | "context_limit" | "error";
16
18
  export interface AgentReport {
17
19
  stopReason: StopReason;
18
20
  /** The model's last text answer, if it produced one. */
@@ -102,6 +104,11 @@ export type AgentEvent = {
102
104
  promptTokens: number;
103
105
  completionTokens: number;
104
106
  costMicroUsd: bigint;
107
+ /**
108
+ * F-012 · False when the catalogue had no price for this model, so a
109
+ * recorder can tell "we could not work it out" from "it was free".
110
+ */
111
+ priced: boolean;
105
112
  } | {
106
113
  type: "rate_limited";
107
114
  waitMs: number;
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { ApiError } from "../api/errors.js";
2
- import { pruneToolExchanges, shouldCompact } from "./context-budget.js";
2
+ import { compactForBudget, shouldCompact } from "./context-budget.js";
3
3
  import { toolToJsonSchema, } from "./tools/types.js";
4
4
  /** Plan §7.4. */
5
5
  export const DEFAULT_LIMITS = {
@@ -59,20 +59,25 @@ export async function runAgent(options) {
59
59
  report.turns = turn;
60
60
  options.onEvent?.({ type: "turn", turn });
61
61
  // §7.4 · Shorten before asking, using what the provider counted last time.
62
- // Only whole tool exchanges go, and only when the window is known.
63
62
  if (shouldCompact(options.pricing?.contextLength, lastPromptTokens)) {
64
- const pruned = pruneToolExchanges(messages);
65
- if (pruned.exchanges > 0) {
66
- options.onEvent?.({
67
- type: "compacted",
68
- exchanges: pruned.exchanges,
69
- messages: pruned.removed,
70
- promptTokens: lastPromptTokens,
71
- });
72
- // Reset the measurement: the next response measures the shorter
73
- // history, and reusing the old figure would compact again every turn.
74
- lastPromptTokens = 0;
63
+ const compacted = compactForBudget(messages);
64
+ if (compacted.exhausted) {
65
+ // F-008 · Nothing left that can be dropped without breaking the
66
+ // conversation. Sending anyway means a request over the window, which
67
+ // fails upstream and is billed for the attempt - stopping and saying
68
+ // so is the only honest option.
69
+ return stop(report, "context_limit", `the conversation no longer fits in ${options.model}'s context window ` +
70
+ `(${lastPromptTokens} input tokens last turn) and cannot be shortened further`);
75
71
  }
72
+ options.onEvent?.({
73
+ type: "compacted",
74
+ exchanges: compacted.exchanges,
75
+ messages: compacted.removed,
76
+ promptTokens: lastPromptTokens,
77
+ });
78
+ // Reset the measurement: the next response measures the shorter
79
+ // history, and reusing the old figure would compact again every turn.
80
+ lastPromptTokens = 0;
76
81
  }
77
82
  let response;
78
83
  try {
@@ -109,6 +114,7 @@ export async function runAgent(options) {
109
114
  promptTokens: usage?.prompt_tokens ?? 0,
110
115
  completionTokens: usage?.completion_tokens ?? 0,
111
116
  costMicroUsd: turnCost,
117
+ priced: canPrice(options.pricing, usage),
112
118
  });
113
119
  const choice = response.choices?.[0];
114
120
  const message = choice?.message;
@@ -267,6 +273,12 @@ export function backoffMs(retryAfterSeconds, attempt, random = Math.random) {
267
273
  // Full jitter: a fleet of CLIs must not retry in lockstep.
268
274
  return Math.round(base * (0.5 + random() * 0.5));
269
275
  }
276
+ /** F-012 · Whether `priceTurn` had everything it needed to give an answer. */
277
+ function canPrice(model, usage) {
278
+ return (usage !== undefined &&
279
+ model?.inputPricePer1mMicroUsd !== undefined &&
280
+ model?.outputPricePer1mMicroUsd !== undefined);
281
+ }
270
282
  function priceTurn(model, usage) {
271
283
  if (!model?.inputPricePer1mMicroUsd ||
272
284
  !model.outputPricePer1mMicroUsd ||
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/agent/session.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAIrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAY,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAapE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,oEAAoE;IACpE,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC,cAAc,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7C,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,cAAc,CAAC,CAwFzB;AAgHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,MAAM,CA2B7D"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/agent/session.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAIrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAY,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAapE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,oEAAoE;IACpE,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC,cAAc,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7C,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,cAAc,CAAC,CA8FzB;AAgHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,MAAM,CA2B7D"}
@@ -80,7 +80,13 @@ export async function runSession(options) {
80
80
  history,
81
81
  approver: options.approver,
82
82
  signal: options.signal,
83
- onEvent: options.onEvent ?? ((event) => reportEvent(event, options)),
83
+ // Both, not either: `onEvent` was replacing the progress display, so
84
+ // attaching a recorder would have silently blanked the terminal. An
85
+ // observer is an addition to the rendering, never a substitute for it.
86
+ onEvent: (event) => {
87
+ reportEvent(event, options);
88
+ options.onEvent?.(event);
89
+ },
84
90
  });
85
91
  summary.turns += report.turns;
86
92
  summary.modelRequests += report.modelRequests;
@@ -44,10 +44,24 @@ export declare class HttpClient {
44
44
  postJson<T>(path: string, body: unknown, options?: RequestOptions): Promise<HttpResponse<T>>;
45
45
  /** For `/v1/audio/speech`, which answers with audio bytes. */
46
46
  postBinary(path: string, body: unknown, options?: RequestOptions): Promise<HttpResponse<Uint8Array>>;
47
- /** Raw streaming response, for the SSE reader to consume. */
47
+ /**
48
+ * Raw streaming response, for the SSE reader to consume.
49
+ *
50
+ * F-009 · Returns `abort` and the limits the reader should apply. The
51
+ * request timeout in `send` is cleared once the headers arrive - which for a
52
+ * stream is the start - so from here on the deadline belongs to whoever is
53
+ * reading the body, and it needs a way to close the connection rather than
54
+ * merely stop reading. Generation is what gets billed; dropping our end of
55
+ * a socket we are no longer reading does not stop it.
56
+ */
48
57
  postStream(path: string, body: unknown, options?: RequestOptions): Promise<{
49
58
  response: Response;
50
59
  requestId?: string;
60
+ abort: () => void;
61
+ limits: {
62
+ idleTimeoutMs: number;
63
+ maxBytes: number;
64
+ };
51
65
  }>;
52
66
  private send;
53
67
  private readJson;
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/api/http.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,kBAAkB,SAAU,CAAC;AAC1C,yEAAyE;AACzE,eAAO,MAAM,iBAAiB,QAAmB,CAAC;AAElD,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,IAAI,EAAE,CAAC,CAAC;IACR,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAE7B,OAAO,EAAE,iBAAiB;IAShC,OAAO,CAAC,CAAC,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAKrB,QAAQ,CAAC,CAAC,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAK3B,8DAA8D;IACxD,UAAU,CACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC;IAiBpC,6DAA6D;IACvD,UAAU,CACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC;QAAE,QAAQ,EAAE,QAAQ,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;YASxC,IAAI;YA4DJ,QAAQ;YAuCR,SAAS;CAexB;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,UAAU,CAAC,CAkCrB"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/api/http.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,kBAAkB,SAAU,CAAC;AAC1C,yEAAyE;AACzE,eAAO,MAAM,iBAAiB,QAAmB,CAAC;AAElD,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,IAAI,EAAE,CAAC,CAAC;IACR,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAE7B,OAAO,EAAE,iBAAiB;IAShC,OAAO,CAAC,CAAC,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAKrB,QAAQ,CAAC,CAAC,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAK3B,8DAA8D;IACxD,UAAU,CACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC;IAiBpC;;;;;;;;;OASG;IACG,UAAU,CACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC;QACT,QAAQ,EAAE,QAAQ,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,KAAK,EAAE,MAAM,IAAI,CAAC;QAClB,MAAM,EAAE;YAAE,aAAa,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAA;SAAE,CAAC;KACrD,CAAC;YA2BY,IAAI;YA4DJ,QAAQ;YAuCR,SAAS;CAexB;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,UAAU,CAAC,CAkCrB"}
package/dist/api/http.js CHANGED
@@ -49,15 +49,36 @@ export class HttpClient {
49
49
  requestId,
50
50
  };
51
51
  }
52
- /** Raw streaming response, for the SSE reader to consume. */
52
+ /**
53
+ * Raw streaming response, for the SSE reader to consume.
54
+ *
55
+ * F-009 · Returns `abort` and the limits the reader should apply. The
56
+ * request timeout in `send` is cleared once the headers arrive - which for a
57
+ * stream is the start - so from here on the deadline belongs to whoever is
58
+ * reading the body, and it needs a way to close the connection rather than
59
+ * merely stop reading. Generation is what gets billed; dropping our end of
60
+ * a socket we are no longer reading does not stop it.
61
+ */
53
62
  async postStream(path, body, options = {}) {
54
- const response = await this.send("POST", path, body, options, {
55
- accept: "text/event-stream",
56
- });
63
+ const streamController = new AbortController();
64
+ const response = await this.send("POST", path, body, {
65
+ ...options,
66
+ signal: options.signal
67
+ ? AbortSignal.any([options.signal, streamController.signal])
68
+ : streamController.signal,
69
+ }, { accept: "text/event-stream" });
57
70
  const requestId = requestIdOf(response.headers);
58
71
  if (!response.ok)
59
72
  throw await this.errorFrom(response, requestId, options);
60
- return { response, requestId };
73
+ return {
74
+ response,
75
+ requestId,
76
+ abort: () => streamController.abort(),
77
+ limits: {
78
+ idleTimeoutMs: options.timeoutMs ?? this.timeoutMs,
79
+ maxBytes: options.maxBytes ?? this.maxBytes,
80
+ },
81
+ };
61
82
  }
62
83
  async send(method, path, body, options, extraHeaders = {}) {
63
84
  const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
@@ -62,11 +62,36 @@ export declare class ChatStreamAssembler {
62
62
  push(payload: string): boolean;
63
63
  result(): StreamResult;
64
64
  }
65
+ /** F-009 · Limits that apply for the whole life of the stream. */
66
+ export interface StreamLimits {
67
+ /**
68
+ * How long to wait for the *next* chunk, not for the whole stream. A total
69
+ * deadline would kill a legitimately long generation; what "hung" actually
70
+ * means is that nothing is arriving.
71
+ */
72
+ readonly idleTimeoutMs?: number;
73
+ /** Total bytes accepted before the stream is treated as hostile. */
74
+ readonly maxBytes?: number;
75
+ /**
76
+ * Closes the underlying connection. Cancelling the reader alone stops us
77
+ * reading; it does not reliably stop the gateway generating, and generation
78
+ * is what gets billed.
79
+ */
80
+ readonly abort?: () => void;
81
+ }
82
+ export declare const DEFAULT_STREAM_IDLE_MS = 120000;
83
+ export declare const DEFAULT_STREAM_MAX_BYTES: number;
65
84
  /**
66
85
  * Read a streaming response to completion.
67
86
  *
68
87
  * `signal` is the user's Ctrl+C: aborting stops reading AND closes the
69
88
  * connection, which is what actually tells the gateway to stop generating.
89
+ *
90
+ * F-009 · The request timeout in `HttpClient` is cleared the moment the
91
+ * response headers arrive, which for a stream is the beginning rather than the
92
+ * end. Without the two limits below, a gateway that sent headers and then
93
+ * stopped would hang the CLI forever, and one that never stopped sending would
94
+ * grow the buffer until the process died.
70
95
  */
71
- export declare function readChatStream(response: Response, events?: StreamEvents, signal?: AbortSignal): Promise<StreamResult>;
96
+ export declare function readChatStream(response: Response, events?: StreamEvents, signal?: AbortSignal, limits?: StreamLimits): Promise<StreamResult>;
72
97
  //# sourceMappingURL=sse-parser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sse-parser.d.ts","sourceRoot":"","sources":["../../src/api/sse-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;CACf;AAWD,+EAA+E;AAC/E,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAE7C,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,EAAE;IAMjC,kDAAkD;IAClD,GAAG,IAAI,MAAM,EAAE;IASf,OAAO,CAAC,KAAK;CAYd;AAED,8DAA8D;AAC9D,qBAAa,mBAAmB;IAOlB,OAAO,CAAC,QAAQ,CAAC,MAAM;IANnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAuC;IAC7D,OAAO,CAAC,IAAI,CAAM;IAClB,OAAO,CAAC,YAAY,CAAC,CAAS;IAC9B,OAAO,CAAC,KAAK,CAAC,CAAc;IAC5B,OAAO,CAAC,IAAI,CAAS;gBAEQ,MAAM,GAAE,YAAiB;IAEtD,4DAA4D;IAC5D,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAgD9B,MAAM,IAAI,YAAY;CASvB;AAED;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,QAAQ,EAAE,QAAQ,EAClB,MAAM,GAAE,YAAiB,EACzB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,YAAY,CAAC,CAkCvB"}
1
+ {"version":3,"file":"sse-parser.d.ts","sourceRoot":"","sources":["../../src/api/sse-parser.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;CACf;AAWD,+EAA+E;AAC/E,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAE7C,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,EAAE;IAMjC,kDAAkD;IAClD,GAAG,IAAI,MAAM,EAAE;IASf,OAAO,CAAC,KAAK;CAYd;AAED,8DAA8D;AAC9D,qBAAa,mBAAmB;IAOlB,OAAO,CAAC,QAAQ,CAAC,MAAM;IANnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAuC;IAC7D,OAAO,CAAC,IAAI,CAAM;IAClB,OAAO,CAAC,YAAY,CAAC,CAAS;IAC9B,OAAO,CAAC,KAAK,CAAC,CAAc;IAC5B,OAAO,CAAC,IAAI,CAAS;gBAEQ,MAAM,GAAE,YAAiB;IAEtD,4DAA4D;IAC5D,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAgD9B,MAAM,IAAI,YAAY;CASvB;AAED,kEAAkE;AAClE,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B;AAED,eAAO,MAAM,sBAAsB,SAAU,CAAC;AAC9C,eAAO,MAAM,wBAAwB,QAAmB,CAAC;AAEzD;;;;;;;;;;;GAWG;AACH,wBAAsB,cAAc,CAClC,QAAQ,EAAE,QAAQ,EAClB,MAAM,GAAE,YAAiB,EACzB,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,GAAE,YAAiB,GACxB,OAAO,CAAC,YAAY,CAAC,CA8DvB"}
@@ -1,22 +1,5 @@
1
- /**
2
- * SSE reader for the chat data plane.
3
- *
4
- * Every rule here is a fact measured in Phase 0 against real transcripts from
5
- * DeepSeek, Kimi, Qwen and Vertex Gemini (tests/fixtures/cli-000*), not an
6
- * assumption about how streaming "usually" works:
7
- *
8
- * - `id` and `name` arrive exactly once, so tool calls merge by `index`.
9
- * - Qwen sends `"id": ""` on continuation frames; `??` would overwrite the
10
- * real id with an empty string and the tool result would be rejected.
11
- * - Gemini packs a thought signature into the id (700+ chars) and delivers a
12
- * whole call in one frame, where Kimi takes nine — fragment counts mean
13
- * nothing.
14
- * - `finish_reason` arrives in its own frame with an empty delta; `[DONE]`
15
- * ends the stream.
16
- * - With `stream_options.include_usage`, usage arrives AFTER finish_reason
17
- * in a frame whose `choices` is non-empty but whose delta is empty. A
18
- * parser that only reads usage when `choices` is empty reports zero cost.
19
- */
1
+ import { ApiError } from "./errors.js";
2
+ import { ExitCode } from "../output/exit-codes.js";
20
3
  /** Split a byte stream into SSE `data:` payloads across arbitrary chunking. */
21
4
  export class SseFramer {
22
5
  buffer = "";
@@ -117,18 +100,29 @@ export class ChatStreamAssembler {
117
100
  };
118
101
  }
119
102
  }
103
+ export const DEFAULT_STREAM_IDLE_MS = 120_000;
104
+ export const DEFAULT_STREAM_MAX_BYTES = 32 * 1024 * 1024;
120
105
  /**
121
106
  * Read a streaming response to completion.
122
107
  *
123
108
  * `signal` is the user's Ctrl+C: aborting stops reading AND closes the
124
109
  * connection, which is what actually tells the gateway to stop generating.
110
+ *
111
+ * F-009 · The request timeout in `HttpClient` is cleared the moment the
112
+ * response headers arrive, which for a stream is the beginning rather than the
113
+ * end. Without the two limits below, a gateway that sent headers and then
114
+ * stopped would hang the CLI forever, and one that never stopped sending would
115
+ * grow the buffer until the process died.
125
116
  */
126
- export async function readChatStream(response, events = {}, signal) {
117
+ export async function readChatStream(response, events = {}, signal, limits = {}) {
127
118
  const framer = new SseFramer();
128
119
  const assembler = new ChatStreamAssembler(events);
129
120
  const body = response.body;
130
121
  if (!body)
131
122
  return assembler.result();
123
+ const idleMs = limits.idleTimeoutMs ?? DEFAULT_STREAM_IDLE_MS;
124
+ const maxBytes = limits.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
125
+ let received = 0;
132
126
  const reader = body.getReader();
133
127
  const onAbort = () => void reader.cancel();
134
128
  // Attaching a listener is not enough: a signal that aborted between the
@@ -144,9 +138,29 @@ export async function readChatStream(response, events = {}, signal) {
144
138
  for (;;) {
145
139
  if (signal?.aborted)
146
140
  break;
147
- const { done, value } = await reader.read();
141
+ const chunk = await withIdleDeadline(reader.read(), idleMs);
142
+ if (chunk === IDLE) {
143
+ limits.abort?.();
144
+ await reader.cancel().catch(() => { });
145
+ throw new ApiError({
146
+ kind: "timeout",
147
+ exitCode: ExitCode.Unavailable,
148
+ message: `the stream sent nothing for ${idleMs}ms`,
149
+ });
150
+ }
151
+ const { done, value } = chunk;
148
152
  if (done)
149
153
  break;
154
+ received += value.byteLength;
155
+ if (received > maxBytes) {
156
+ limits.abort?.();
157
+ await reader.cancel().catch(() => { });
158
+ throw new ApiError({
159
+ kind: "too_large",
160
+ exitCode: ExitCode.Unavailable,
161
+ message: `the stream exceeded the ${maxBytes} byte limit`,
162
+ });
163
+ }
150
164
  for (const payload of framer.push(value)) {
151
165
  if (assembler.push(payload))
152
166
  return assembler.result();
@@ -161,3 +175,30 @@ export async function readChatStream(response, events = {}, signal) {
161
175
  }
162
176
  return assembler.result();
163
177
  }
178
+ /** Sentinel for "the deadline won", distinguishable from a real chunk. */
179
+ const IDLE = Symbol("idle");
180
+ /**
181
+ * Race a read against the idle deadline.
182
+ *
183
+ * The timer is unref'd and always cleared: a pending timer would keep the
184
+ * process alive after a stream that finished normally.
185
+ */
186
+ async function withIdleDeadline(read, idleMs) {
187
+ let timer;
188
+ try {
189
+ return await Promise.race([
190
+ read,
191
+ new Promise((resolve) => {
192
+ // Deliberately not unref'd: if a stalled stream were the only thing
193
+ // left, an unref'd timer would let the process exit silently instead
194
+ // of reporting the timeout. The finally below always clears it, so
195
+ // nothing is held open after a stream that ended normally.
196
+ timer = setTimeout(() => resolve(IDLE), idleMs);
197
+ }),
198
+ ]);
199
+ }
200
+ finally {
201
+ if (timer !== undefined)
202
+ clearTimeout(timer);
203
+ }
204
+ }