@mafdet/cli 0.4.3 → 0.4.5

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 (73) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +16 -8
  3. package/README.zh-CN.md +12 -4
  4. package/dist/agent/command-policy.d.ts.map +1 -1
  5. package/dist/agent/command-policy.js +10 -1
  6. package/dist/agent/context-budget.d.ts +47 -0
  7. package/dist/agent/context-budget.d.ts.map +1 -1
  8. package/dist/agent/context-budget.js +104 -0
  9. package/dist/agent/loop.d.ts +16 -1
  10. package/dist/agent/loop.d.ts.map +1 -1
  11. package/dist/agent/loop.js +44 -13
  12. package/dist/agent/session.d.ts +2 -0
  13. package/dist/agent/session.d.ts.map +1 -1
  14. package/dist/agent/session.js +14 -3
  15. package/dist/api/http.d.ts +15 -1
  16. package/dist/api/http.d.ts.map +1 -1
  17. package/dist/api/http.js +26 -5
  18. package/dist/api/sse-parser.d.ts +41 -1
  19. package/dist/api/sse-parser.d.ts.map +1 -1
  20. package/dist/api/sse-parser.js +90 -22
  21. package/dist/commands/agent.d.ts.map +1 -1
  22. package/dist/commands/agent.js +146 -21
  23. package/dist/commands/chat.d.ts.map +1 -1
  24. package/dist/commands/chat.js +21 -4
  25. package/dist/commands/config.d.ts.map +1 -1
  26. package/dist/commands/config.js +0 -2
  27. package/dist/commands/runtime.d.ts +6 -0
  28. package/dist/commands/runtime.d.ts.map +1 -1
  29. package/dist/commands/sessions.d.ts.map +1 -1
  30. package/dist/commands/sessions.js +163 -26
  31. package/dist/config/config.d.ts +0 -12
  32. package/dist/config/config.d.ts.map +1 -1
  33. package/dist/config/config.js +31 -6
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +17 -1
  36. package/dist/output/format.d.ts.map +1 -1
  37. package/dist/output/format.js +10 -5
  38. package/dist/output/terminal-safe.d.ts +12 -0
  39. package/dist/output/terminal-safe.d.ts.map +1 -0
  40. package/dist/output/terminal-safe.js +38 -0
  41. package/dist/sessions/index-file.d.ts +2 -0
  42. package/dist/sessions/index-file.d.ts.map +1 -1
  43. package/dist/sessions/index-file.js +3 -0
  44. package/dist/sessions/open.d.ts.map +1 -1
  45. package/dist/sessions/open.js +29 -3
  46. package/dist/sessions/recorder.d.ts.map +1 -1
  47. package/dist/sessions/recorder.js +6 -1
  48. package/dist/sessions/resume.d.ts +1 -0
  49. package/dist/sessions/resume.d.ts.map +1 -1
  50. package/dist/sessions/resume.js +5 -3
  51. package/dist/sessions/session-id.d.ts +16 -0
  52. package/dist/sessions/session-id.d.ts.map +1 -0
  53. package/dist/sessions/session-id.js +93 -0
  54. package/dist/sessions/store.d.ts +41 -2
  55. package/dist/sessions/store.d.ts.map +1 -1
  56. package/dist/sessions/store.js +48 -24
  57. package/dist/sessions/usage.d.ts +6 -0
  58. package/dist/sessions/usage.d.ts.map +1 -1
  59. package/dist/sessions/usage.js +12 -1
  60. package/dist/tui/agent-surface.d.ts.map +1 -1
  61. package/dist/tui/agent-surface.js +16 -3
  62. package/dist/tui/app.d.ts +14 -0
  63. package/dist/tui/app.d.ts.map +1 -1
  64. package/dist/tui/app.js +84 -17
  65. package/dist/tui/picker.d.ts.map +1 -1
  66. package/dist/tui/picker.js +2 -1
  67. package/dist/tui/session-centre.d.ts +5 -0
  68. package/dist/tui/session-centre.d.ts.map +1 -1
  69. package/dist/tui/session-centre.js +9 -3
  70. package/dist/tui/start-agent.d.ts +59 -0
  71. package/dist/tui/start-agent.d.ts.map +1 -1
  72. package/dist/tui/start-agent.js +182 -50
  73. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,113 @@ 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.5 - 2026-08-12
10
+
11
+ A second audit round, and the security fix in it is the important one.
12
+
13
+ **With no tool-calling model, the interactive session no longer opens.** It
14
+ used to print a warning and then offer every chat model - so the one case with
15
+ nothing safe to offer was the case that offered everything, and the agent then
16
+ ran on a model whose tool calling had never been verified. `mafdet agent`
17
+ refused the same model outright, so the two surfaces disagreed about
18
+ admission. They no longer do.
19
+
20
+ **A cost ceiling that cannot be enforced stops the run.** When the catalogue
21
+ has no price for a model, each turn counted as zero towards `--max-cost-usd`,
22
+ so the ceiling quietly stopped being one while the run kept spending. Asking
23
+ for a limit we cannot apply now ends the run and says why.
24
+
25
+ **Context compaction keeps going until the history fits.** It used to drop a
26
+ single tool exchange and send regardless, which changes nothing when the
27
+ history is several times over the window - the request then failed upstream
28
+ and was billed for the attempt.
29
+
30
+ **A stream is closed when the answer ends.** `[DONE]` means the reply is
31
+ complete, not that the socket is; an upstream that kept the connection open
32
+ held ours too. There is also a total deadline now, because a trickle of bytes
33
+ defeats an idle one.
34
+
35
+ **More of what reaches the terminal is sanitised**: the answer from `mafdet
36
+ agent`, the interactive session's answer, the session picker, the resume
37
+ notice and the remaining `sessions` columns. The stripper also handles the
38
+ 8-bit C1 forms, which do the same things without an escape character.
39
+
40
+ **An unpriced request is no longer displayed as `$0.00`** in `sessions list`,
41
+ `show` or `usage`, and the JSON carries `unpriced_requests` so a script can
42
+ tell the difference too.
43
+
44
+ Also: `sessions list` refuses stray arguments like the other subcommands;
45
+ `mafdet agent` documents `--title`, `--no-history` and `--read-only`, shows
46
+ the first-save notice, and closes its recorder even when the session throws;
47
+ resuming no longer asks you to pick a model it is going to ignore, and the
48
+ banner names the model actually in use; and the Windows command-timeout note
49
+ now says the `taskkill` path is written but unverified, rather than describing
50
+ the implementation it replaced.
51
+
52
+ ## 0.4.4 - 2026-08-11
53
+
54
+ A security fix and twenty-one other corrections, all from an external audit of
55
+ the 0.4.3 release.
56
+
57
+ **Session ids are now checked before they become paths.** `mafdet sessions
58
+ show`, `usage`, `delete` and `--resume` each joined whatever you typed onto
59
+ the sessions directory, and `join` resolves `..` - so an id like
60
+ `../../../something` reached outside the store. Reading it leaked another
61
+ directory's contents, and `sessions delete ../../../something --yes` removed
62
+ that directory's files. This was present from 0.3.0. It requires you (or a
63
+ script running as you) to pass such a path - the agent has no way to invoke
64
+ these commands - but it was a real destructive defect and it is fixed.
65
+
66
+ **Resuming a conversation now honours what it says.** Blockers were computed
67
+ and printed and then ignored, so a session whose workspace had been deleted,
68
+ or whose model your key can no longer call, continued anyway - and it
69
+ continued in your _current_ directory rather than the recorded one, which with
70
+ `--allow-write` meant carrying one project's conversation into another
71
+ project's files. A resumed session now runs where it was recorded, on the
72
+ model it was recorded with, and refuses when it cannot.
73
+
74
+ **Deleting reports what happened.** Every failure used to be swallowed and
75
+ "deleted" printed regardless. It now takes the session lock first and only
76
+ claims success when the directory is gone.
77
+
78
+ **`--read-only` works in the interactive session.** It existed only in `mafdet
79
+ agent`, so `mafdet --allow-write --read-only` was read-only there and
80
+ write-enabled here.
81
+
82
+ **Streams have limits.** The request timeout was cleared when the response
83
+ headers arrived, which for a stream is the beginning: a gateway that then went
84
+ quiet hung the CLI indefinitely. There is now a per-chunk idle deadline and a
85
+ byte cap, and both close the connection.
86
+
87
+ **Terminal control sequences.** A model's reply and a session's title, model
88
+ and workspace are stripped of escape sequences when a terminal is reading
89
+ them. Redirected output still receives exact bytes.
90
+
91
+ **Context compaction covers conversations without tools**, and when nothing
92
+ can safely be dropped the run stops and says so instead of sending a request
93
+ that is over the window.
94
+
95
+ **An unpriced request is no longer recorded as costing zero** - unknown and
96
+ free are different claims.
97
+
98
+ **`approvalMode` and `testCommands` are removed from the config.** They were
99
+ accepted, displayed by `config list`, and read by nothing, so setting
100
+ `auto-edit` looked like it turned approval off. They are removed rather than
101
+ implemented because in a project file both would let a cloned repository raise
102
+ its own permission level. Setting one is now an error explaining why.
103
+
104
+ **A damaged line in the middle of a transcript is reported** rather than
105
+ skipped in silence, and resuming from one is refused.
106
+
107
+ Also: `mafdet agent` gains `--title` and `--no-history` (`--resume` stays in
108
+ the interactive session and now says so); the model picker highlights the
109
+ default you saved; `sessions` refuses unknown flags and stray arguments
110
+ instead of ignoring them; `--json --plain` prints a usage error rather than a
111
+ stack trace; the resume lock is released on exit; `mafdet help` describes what
112
+ the agent can actually do; and the README states plainly that approving a
113
+ command means allowing that program to run as you - the workspace bounds the
114
+ agent's own tools, not the program, and is not a sandbox.
115
+
9
116
  ## 0.4.3 - 2026-08-11
10
117
 
11
118
  A long agent run no longer walks into the model's context window, a one-shot
package/README.md CHANGED
@@ -3,13 +3,11 @@
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.5.** 中文版见 [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
  >
10
- > macOS and Windows are not yet verified. On Windows the command timeout
11
- > relies on POSIX process groups, so only the direct child is terminated and a
12
- > grandchild can survive.
10
+ > macOS and Windows are not yet verified. On Windows the command timeout uses `taskkill /T`, which is written but has never been run on a real Windows machine - so whether every grandchild process is reached there is unverified rather than fixed.
13
11
 
14
12
  ## Requirements
15
13
 
@@ -25,8 +23,8 @@ Or build it from this repository:
25
23
 
26
24
  ```bash
27
25
  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
26
+ cd packages/cli && npm pack # -> mafdet-cli-0.4.5.tgz
27
+ npm install -g ./mafdet-cli-0.4.5.tgz
30
28
  ```
31
29
 
32
30
  ## The interactive session
@@ -113,6 +111,16 @@ prefixes: `pnpm test` permits `pnpm test --filter web` but never `pnpm publish`.
113
111
  `;`, `&&`, backticks and `$( )` are refused with an explanation rather than
114
112
  sanitised.
115
113
 
114
+ **What approving a command means.** It means allowing that program to run on
115
+ your machine, as you. The workspace bounds which files the agent's own tools
116
+ may touch; it is not a sandbox, and it does not bound the program. `pnpm test`
117
+ can read your home directory, reach the network, or start something that
118
+ outlives the run - because that is what running a program is. A short list of
119
+ obviously destructive names is refused (`sudo`, `su`, `doas`, `chown`,
120
+ `shutdown`, `reboot`, and invoking a program by path), but treat that as a
121
+ guard against slips rather than as containment. Approve prefixes the way you
122
+ would run the command yourself.
123
+
116
124
  In a session: `/model`, `/permissions`, `/diff`, `/cost`, `/compact`, `/exit`.
117
125
 
118
126
  ## Configuration
@@ -131,8 +139,8 @@ User config lives at `~/.config/mafdet/config.json` (Linux),
131
139
  `~/Library/Application Support/mafdet/config.json` (macOS) or
132
140
  `%APPDATA%\mafdet\config.json` (Windows).
133
141
 
134
- A project may carry `.mafdet.json` with `model`, `approvalMode` and
135
- `testCommands` — and nothing else. Credentials and endpoints in a project file
142
+ A project may carry `.mafdet.json` with `model` — and nothing else.
143
+ Credentials and endpoints in a project file
136
144
  are rejected outright: cloning a repository must never change where your key
137
145
  is sent.
138
146
 
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.5。** 英文版见 [README.md](./README.md)。
6
6
 
7
7
  ## 环境要求
8
8
 
@@ -55,8 +55,9 @@ mafdet doctor
55
55
  的默认状态,中文系统为 cp936)也不会变成乱码。管道传入 prompt 也可以,但 PowerShell
56
56
  默认按 UTF-16 走管道——建议直接把 prompt 作为参数,或用 `--input <文件>`。
57
57
 
58
- ⚠️ **macOS 与 Windows 尚未验收**。Windows 上有一个已知差异:命令超时终止依赖 POSIX 的
59
- 进程组语义,Windows 只会终止直接子进程,孙子进程可能存活。
58
+ ⚠️ **macOS 与 Windows 尚未验收**。Windows 上的命令超时终止已改用 `taskkill /T`,
59
+ 但**从未在真 Windows 上执行过**——它是否真的能杀到每一个孙子进程,属于**未验证**,
60
+ 不是「已修复」。
60
61
 
61
62
  ## 命令
62
63
 
@@ -94,6 +95,13 @@ mafdet doctor
94
95
  **没有 shell**:命令被切成 argv 直接执行,所以管道、重定向、`;`、`&&`、反引号、`$( )`
95
96
  一律被拒绝并说明原因,而不是尝试"消毒"。
96
97
 
98
+ **批准一条命令意味着什么。** 意味着允许那个程序在你的机器上、以你的身份运行。
99
+ 工作区限定的是 **Agent 自己的工具**能碰哪些文件,它**不是沙箱**,也约束不了那个程序。
100
+ `pnpm test` 可以读你的家目录、可以联网、可以起一个比这次运行活得更久的进程——
101
+ 因为那就是"运行一个程序"的含义。我们会拒绝一小份明显破坏性的名字
102
+ (`sudo`、`su`、`doas`、`chown`、`shutdown`、`reboot`,以及按路径调用),
103
+ 但请把它当作**防手滑的护栏,而不是隔离**。批准前缀时,请按"我自己会不会直接敲这条命令"来判断。
104
+
97
105
  会话内命令:`/model`、`/permissions`、`/diff`、`/cost`、`/compact`、`/exit`。
98
106
 
99
107
  ## 配置
@@ -111,7 +119,7 @@ mafdet doctor
111
119
  用户配置位置:Linux `~/.config/mafdet/config.json`、macOS
112
120
  `~/Library/Application Support/mafdet/config.json`、Windows `%APPDATA%\mafdet\config.json`。
113
121
 
114
- 项目可以放一个 `.mafdet.json`,但**只允许** `model`、`approvalMode`、`testCommands` 三项。
122
+ 项目可以放一个 `.mafdet.json`,但**只允许 `model` 一项**。
115
123
  里面出现凭据或端点会**直接报错**:克隆一个仓库绝不该改变你的 Key 发往哪里。
116
124
 
117
125
  ## 退出码
@@ -1 +1 @@
1
- {"version":3,"file":"command-policy.d.ts","sourceRoot":"","sources":["../../src/agent/command-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAeH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,OAAO,CAAC;IACZ,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BlD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,SAAS,MAAM,EAAO,GAChC,cAAc,CAwChB;AAmBD,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAe9C"}
1
+ {"version":3,"file":"command-policy.d.ts","sourceRoot":"","sources":["../../src/agent/command-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAeH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,OAAO,CAAC;IACZ,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BlD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,SAAS,MAAM,EAAO,GAChC,cAAc,CAwChB;AAmBD,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAwB9C"}
@@ -129,5 +129,14 @@ export function stripAnsi(text) {
129
129
  // Remaining C0 controls. Tab, newline and carriage return survive; the
130
130
  // rest can retitle a window, move the cursor or hide what follows, and a
131
131
  // model's output is not a trusted source of terminal instructions.
132
- .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ""));
132
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
133
+ // R-4 · The 8-bit C1 forms of the same sequences. A terminal in 8-bit
134
+ // mode reads U+009B as CSI and U+009D as OSC - identical instructions to
135
+ // the ESC forms above, with no ESC to match on. Taken as whole
136
+ // sequences, so the payload goes with the introducer rather than being
137
+ // left behind as visible text.
138
+ .replace(/\u009b[0-?]*[ -/]*[@-~]/g, "")
139
+ .replace(/\u009d[^\u0007\u009c]*(?:\u0007|\u009c)?/g, "")
140
+ // Any remaining C1, including NEL (U+0085) and the lone introducers.
141
+ .replace(/[\u0080-\u009f]/g, ""));
133
142
  }
@@ -34,4 +34,51 @@ 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;
69
+ /**
70
+ * R-2 · Shorten until it fits, or until nothing more can go.
71
+ *
72
+ * `compactForBudget` used to return the moment it dropped a single tool
73
+ * exchange, and the caller then sent the request regardless. One exchange out
74
+ * of a history three times over the window changes nothing, so the request
75
+ * still failed upstream - and was billed for the attempt.
76
+ *
77
+ * `estimate` maps the current history back to a token count. There is no
78
+ * tokenizer here (see the note at the top of this file), so it is derived from
79
+ * the last measurement: tokens per message, applied to what is left. Rough, but
80
+ * it is the only honest figure available and it is monotone in the right
81
+ * direction, which is what a loop needs.
82
+ */
83
+ export declare function compactUntilItFits(history: ChatMessage[], contextLength: number | undefined, lastPromptTokens: number, keepRecent?: number): CompactionResult;
37
84
  //# 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,CASlB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,EACxB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CA6BlB"}
@@ -103,3 +103,107 @@ 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
+ const turnsRemoved = dropOldestTurns(history, keepRecent);
160
+ return {
161
+ removed: pruned.removed + turnsRemoved,
162
+ exchanges: pruned.exchanges,
163
+ turns: turnsRemoved > 0 ? 1 : 0,
164
+ exhausted: pruned.exchanges === 0 && turnsRemoved === 0,
165
+ };
166
+ }
167
+ /**
168
+ * R-2 · Shorten until it fits, or until nothing more can go.
169
+ *
170
+ * `compactForBudget` used to return the moment it dropped a single tool
171
+ * exchange, and the caller then sent the request regardless. One exchange out
172
+ * of a history three times over the window changes nothing, so the request
173
+ * still failed upstream - and was billed for the attempt.
174
+ *
175
+ * `estimate` maps the current history back to a token count. There is no
176
+ * tokenizer here (see the note at the top of this file), so it is derived from
177
+ * the last measurement: tokens per message, applied to what is left. Rough, but
178
+ * it is the only honest figure available and it is monotone in the right
179
+ * direction, which is what a loop needs.
180
+ */
181
+ export function compactUntilItFits(history, contextLength, lastPromptTokens, keepRecent = KEEP_RECENT_EXCHANGES) {
182
+ const startedWith = history.length;
183
+ const perMessage = startedWith > 0 ? lastPromptTokens / startedWith : 0;
184
+ let exchanges = 0;
185
+ let turns = 0;
186
+ for (;;) {
187
+ const estimate = Math.round(perMessage * history.length);
188
+ if (!shouldCompact(contextLength, estimate)) {
189
+ return {
190
+ removed: startedWith - history.length,
191
+ exchanges,
192
+ turns,
193
+ exhausted: false,
194
+ };
195
+ }
196
+ const step = compactForBudget(history, keepRecent);
197
+ if (step.exhausted) {
198
+ return {
199
+ removed: startedWith - history.length,
200
+ exchanges,
201
+ turns,
202
+ // Still over budget with nothing left that is safe to drop.
203
+ exhausted: true,
204
+ };
205
+ }
206
+ exchanges += step.exchanges;
207
+ turns += step.turns;
208
+ }
209
+ }
@@ -12,7 +12,15 @@ 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"
18
+ /**
19
+ * R-5 · A cost ceiling was asked for and cannot be honoured, because the
20
+ * catalogue has no price for this model. Distinct from `max_cost`, which
21
+ * means the ceiling was reached.
22
+ */
23
+ | "cost_unknown" | "error";
16
24
  export interface AgentReport {
17
25
  stopReason: StopReason;
18
26
  /** The model's last text answer, if it produced one. */
@@ -102,6 +110,11 @@ export type AgentEvent = {
102
110
  promptTokens: number;
103
111
  completionTokens: number;
104
112
  costMicroUsd: bigint;
113
+ /**
114
+ * F-012 · False when the catalogue had no price for this model, so a
115
+ * recorder can tell "we could not work it out" from "it was free".
116
+ */
117
+ priced: boolean;
105
118
  } | {
106
119
  type: "rate_limited";
107
120
  waitMs: number;
@@ -115,6 +128,8 @@ export type AgentEvent = {
115
128
  | {
116
129
  type: "compacted";
117
130
  exchanges: number;
131
+ /** Whole conversation turns dropped, when exchanges were not enough. */
132
+ turns: number;
118
133
  messages: number;
119
134
  promptTokens: number;
120
135
  };
@@ -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 { compactUntilItFits, shouldCompact } from "./context-budget.js";
3
3
  import { toolToJsonSchema, } from "./tools/types.js";
4
4
  /** Plan §7.4. */
5
5
  export const DEFAULT_LIMITS = {
@@ -47,6 +47,8 @@ export async function runAgent(options) {
47
47
  };
48
48
  /** What the provider counted for the previous request; 0 until one lands. */
49
49
  let lastPromptTokens = 0;
50
+ /** R-5 · Turns whose cost the catalogue could not tell us. */
51
+ let unpricedTurns = 0;
50
52
  for (let turn = 1; turn <= limits.maxTurns; turn += 1) {
51
53
  if (options.signal?.aborted)
52
54
  return stop(report, "cancelled");
@@ -56,23 +58,43 @@ export async function runAgent(options) {
56
58
  report.costMicroUsd >= limits.maxCostMicroUsd) {
57
59
  return stop(report, "max_cost");
58
60
  }
61
+ // R-5 · An unpriceable turn counted as 0 towards the ceiling, so
62
+ // `--max-cost-usd` silently stopped being a ceiling at all and the run
63
+ // continued spending real money. A limit that cannot be enforced has to
64
+ // stop the run, not be quietly abandoned.
65
+ if (limits.maxCostMicroUsd !== undefined && unpricedTurns > 0) {
66
+ return stop(report, "cost_unknown", `--max-cost-usd cannot be enforced: the catalogue has no price for ` +
67
+ `${options.model}, so ${unpricedTurns} request(s) could not be counted ` +
68
+ `towards the ceiling. Re-run without the limit to continue anyway.`);
69
+ }
59
70
  report.turns = turn;
60
71
  options.onEvent?.({ type: "turn", turn });
61
72
  // §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
73
  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;
74
+ // R-2 · Keep going until the estimate is inside the budget. Dropping one
75
+ // exchange and sending anyway left a history that was still over the
76
+ // window: the request failed upstream and was billed for the attempt.
77
+ const compacted = compactUntilItFits(messages, options.pricing?.contextLength, lastPromptTokens);
78
+ if (compacted.exhausted) {
79
+ // F-008 · Nothing left that can be dropped without breaking the
80
+ // conversation. Sending anyway means a request over the window, which
81
+ // fails upstream and is billed for the attempt - stopping and saying
82
+ // so is the only honest option.
83
+ return stop(report, "context_limit", `the conversation no longer fits in ${options.model}'s context window ` +
84
+ `(${lastPromptTokens} input tokens last turn) and cannot be shortened further`);
75
85
  }
86
+ options.onEvent?.({
87
+ type: "compacted",
88
+ exchanges: compacted.exchanges,
89
+ // R-2 · Reported separately: a pure conversation drops turns and no
90
+ // exchanges, and the display said "dropped 0 earlier tool exchanges".
91
+ turns: compacted.turns,
92
+ messages: compacted.removed,
93
+ promptTokens: lastPromptTokens,
94
+ });
95
+ // Reset the measurement: the next response measures the shorter
96
+ // history, and reusing the old figure would compact again every turn.
97
+ lastPromptTokens = 0;
76
98
  }
77
99
  let response;
78
100
  try {
@@ -109,7 +131,10 @@ export async function runAgent(options) {
109
131
  promptTokens: usage?.prompt_tokens ?? 0,
110
132
  completionTokens: usage?.completion_tokens ?? 0,
111
133
  costMicroUsd: turnCost,
134
+ priced: canPrice(options.pricing, usage),
112
135
  });
136
+ if (!canPrice(options.pricing, usage))
137
+ unpricedTurns += 1;
113
138
  const choice = response.choices?.[0];
114
139
  const message = choice?.message;
115
140
  const calls = message?.tool_calls ?? [];
@@ -267,6 +292,12 @@ export function backoffMs(retryAfterSeconds, attempt, random = Math.random) {
267
292
  // Full jitter: a fleet of CLIs must not retry in lockstep.
268
293
  return Math.round(base * (0.5 + random() * 0.5));
269
294
  }
295
+ /** F-012 · Whether `priceTurn` had everything it needed to give an answer. */
296
+ function canPrice(model, usage) {
297
+ return (usage !== undefined &&
298
+ model?.inputPricePer1mMicroUsd !== undefined &&
299
+ model?.outputPricePer1mMicroUsd !== undefined);
300
+ }
270
301
  function priceTurn(model, usage) {
271
302
  if (!model?.inputPricePer1mMicroUsd ||
272
303
  !model.outputPricePer1mMicroUsd ||
@@ -32,6 +32,8 @@ export interface SessionOptions {
32
32
  * the same events are drawn.
33
33
  */
34
34
  onEvent?: (event: AgentEvent) => void;
35
+ /** R-4 · False when stdout is a pipe or a file, which get exact bytes. */
36
+ isTty?: boolean;
35
37
  /**
36
38
  * A conversation to continue, rebuilt from a saved session (CLI-024).
37
39
  * Appended to in place, exactly as the fresh array would be.
@@ -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;AAE/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,0EAA0E;IAC1E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;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,CAqGzB;AAgHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,MAAM,CA2B7D"}
@@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises";
2
2
  import { isAgentEligible } from "../api/models.js";
3
3
  import { formatUsd } from "../output/money.js";
4
4
  import { renderRecord } from "../output/format.js";
5
+ import { safeForTerminal } from "../output/terminal-safe.js";
5
6
  import { runAgent } from "./loop.js";
6
7
  const HELP = `Session commands:
7
8
  /model [id] Show or switch the model
@@ -80,7 +81,13 @@ export async function runSession(options) {
80
81
  history,
81
82
  approver: options.approver,
82
83
  signal: options.signal,
83
- onEvent: options.onEvent ?? ((event) => reportEvent(event, options)),
84
+ // Both, not either: `onEvent` was replacing the progress display, so
85
+ // attaching a recorder would have silently blanked the terminal. An
86
+ // observer is an addition to the rendering, never a substitute for it.
87
+ onEvent: (event) => {
88
+ reportEvent(event, options);
89
+ options.onEvent?.(event);
90
+ },
84
91
  });
85
92
  summary.turns += report.turns;
86
93
  summary.modelRequests += report.modelRequests;
@@ -88,8 +95,12 @@ export async function runSession(options) {
88
95
  summary.promptTokens += report.promptTokens;
89
96
  summary.completionTokens += report.completionTokens;
90
97
  summary.costMicroUsd += report.costMicroUsd;
91
- if (report.finalText)
92
- options.stdout(`${report.finalText}\n`);
98
+ // R-4 · The model's answer reaches a terminal here too. The TUI wraps
99
+ // this writer, but `mafdet agent` without a goal does not, so the
100
+ // sanitising belongs at the sink rather than at one of its callers.
101
+ if (report.finalText) {
102
+ options.stdout(`${safeForTerminal(report.finalText, options.isTty ?? true)}\n`);
103
+ }
93
104
  if (report.stopReason !== "completed") {
94
105
  options.stderr(`[${report.stopReason}${report.error ? `: ${report.error}` : ""}]\n`);
95
106
  }
@@ -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;