@mafdet/cli 0.4.2 → 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 (61) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +35 -19
  3. package/README.zh-CN.md +28 -16
  4. package/dist/agent/context-budget.d.ts +69 -0
  5. package/dist/agent/context-budget.d.ts.map +1 -0
  6. package/dist/agent/context-budget.js +169 -0
  7. package/dist/agent/loop.d.ts +19 -1
  8. package/dist/agent/loop.d.ts.map +1 -1
  9. package/dist/agent/loop.js +32 -0
  10. package/dist/agent/session.d.ts.map +1 -1
  11. package/dist/agent/session.js +7 -1
  12. package/dist/agent/tools/run-command.d.ts +28 -0
  13. package/dist/agent/tools/run-command.d.ts.map +1 -1
  14. package/dist/agent/tools/run-command.js +34 -13
  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 +26 -1
  19. package/dist/api/sse-parser.d.ts.map +1 -1
  20. package/dist/api/sse-parser.js +62 -21
  21. package/dist/commands/agent.d.ts.map +1 -1
  22. package/dist/commands/agent.js +76 -0
  23. package/dist/commands/chat.d.ts.map +1 -1
  24. package/dist/commands/chat.js +136 -19
  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 +129 -18
  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/open.d.ts.map +1 -1
  42. package/dist/sessions/open.js +26 -2
  43. package/dist/sessions/recorder.d.ts.map +1 -1
  44. package/dist/sessions/recorder.js +6 -1
  45. package/dist/sessions/session-id.d.ts +16 -0
  46. package/dist/sessions/session-id.d.ts.map +1 -0
  47. package/dist/sessions/session-id.js +93 -0
  48. package/dist/sessions/store.d.ts +41 -2
  49. package/dist/sessions/store.d.ts.map +1 -1
  50. package/dist/sessions/store.js +48 -24
  51. package/dist/sessions/usage.d.ts +6 -0
  52. package/dist/sessions/usage.d.ts.map +1 -1
  53. package/dist/sessions/usage.js +12 -1
  54. package/dist/tui/agent-surface.d.ts.map +1 -1
  55. package/dist/tui/agent-surface.js +13 -2
  56. package/dist/tui/app.d.ts.map +1 -1
  57. package/dist/tui/app.js +43 -10
  58. package/dist/tui/start-agent.d.ts +31 -0
  59. package/dist/tui/start-agent.d.ts.map +1 -1
  60. package/dist/tui/start-agent.js +147 -45
  61. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { ApiError } from "../api/errors.js";
2
+ import { compactForBudget, shouldCompact } from "./context-budget.js";
2
3
  import { toolToJsonSchema, } from "./tools/types.js";
3
4
  /** Plan §7.4. */
4
5
  export const DEFAULT_LIMITS = {
@@ -44,6 +45,8 @@ export async function runAgent(options) {
44
45
  completionTokens: 0,
45
46
  costMicroUsd: 0n,
46
47
  };
48
+ /** What the provider counted for the previous request; 0 until one lands. */
49
+ let lastPromptTokens = 0;
47
50
  for (let turn = 1; turn <= limits.maxTurns; turn += 1) {
48
51
  if (options.signal?.aborted)
49
52
  return stop(report, "cancelled");
@@ -55,6 +58,27 @@ export async function runAgent(options) {
55
58
  }
56
59
  report.turns = turn;
57
60
  options.onEvent?.({ type: "turn", turn });
61
+ // §7.4 · Shorten before asking, using what the provider counted last time.
62
+ if (shouldCompact(options.pricing?.contextLength, lastPromptTokens)) {
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`);
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;
81
+ }
58
82
  let response;
59
83
  try {
60
84
  response = await requestWithRateLimitHandling(options, limits, {
@@ -74,6 +98,7 @@ export async function runAgent(options) {
74
98
  return stop(report, "error", error instanceof Error ? error.message : String(error));
75
99
  }
76
100
  const usage = response.usage;
101
+ lastPromptTokens = usage?.prompt_tokens ?? lastPromptTokens;
77
102
  report.promptTokens += usage?.prompt_tokens ?? 0;
78
103
  report.completionTokens += usage?.completion_tokens ?? 0;
79
104
  // Cost comes from returned usage, never from the reservation: CLI-000B
@@ -89,6 +114,7 @@ export async function runAgent(options) {
89
114
  promptTokens: usage?.prompt_tokens ?? 0,
90
115
  completionTokens: usage?.completion_tokens ?? 0,
91
116
  costMicroUsd: turnCost,
117
+ priced: canPrice(options.pricing, usage),
92
118
  });
93
119
  const choice = response.choices?.[0];
94
120
  const message = choice?.message;
@@ -247,6 +273,12 @@ export function backoffMs(retryAfterSeconds, attempt, random = Math.random) {
247
273
  // Full jitter: a fleet of CLIs must not retry in lockstep.
248
274
  return Math.round(base * (0.5 + random() * 0.5));
249
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
+ }
250
282
  function priceTurn(model, usage) {
251
283
  if (!model?.inputPricePer1mMicroUsd ||
252
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;
@@ -15,6 +15,34 @@ export interface CommandResult {
15
15
  * failure mode where a "timed out" test run keeps holding a port.
16
16
  */
17
17
  export declare function executeCommand(argv: readonly string[], cwd: string, timeoutMs?: number): Promise<CommandResult>;
18
+ /**
19
+ * What "kill the whole tree" means on this platform.
20
+ *
21
+ * Pure, and separated from doing it, because the Windows branch cannot be
22
+ * executed here: there is no Windows machine in this project, and a kill path
23
+ * that has never run is exactly the kind of code that is wrong in a way only
24
+ * production finds. Keeping the decision pure means the *shape* of the command
25
+ * is under test on both platforms even though only one of them can be run.
26
+ *
27
+ * POSIX: a negative pid signals the process group, which is why the child is
28
+ * spawned detached. Killing the parent alone leaves grandchildren running -
29
+ * the familiar case where a "timed out" test runner keeps holding a port.
30
+ *
31
+ * Windows has no process groups in that sense. `taskkill /T` walks the child
32
+ * tree instead, and `/F` is the forceful pass: without it taskkill asks
33
+ * politely, which a console process is free to ignore - the same distinction
34
+ * as SIGTERM and SIGKILL.
35
+ */
36
+ export type KillPlan = {
37
+ readonly kind: "signal";
38
+ readonly target: number;
39
+ readonly signal: NodeJS.Signals;
40
+ } | {
41
+ readonly kind: "spawn";
42
+ readonly command: string;
43
+ readonly args: readonly string[];
44
+ };
45
+ export declare function killPlan(pid: number, force: boolean, platform?: string): KillPlan;
18
46
  export declare const runCommandTool: ToolDefinition;
19
47
  export declare const COMMAND_TOOLS: ToolDefinition[];
20
48
  //# sourceMappingURL=run-command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"run-command.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/run-command.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAA+B,cAAc,EAAE,MAAM,YAAY,CAAC;AAE9E,0DAA0D;AAC1D,eAAO,MAAM,kBAAkB,SAAU,CAAC;AAG1C,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,MAAM,EACX,SAAS,GAAE,MAA2B,GACrC,OAAO,CAAC,aAAa,CAAC,CAkDxB;AAwBD,eAAO,MAAM,cAAc,EAAE,cAmC5B,CAAC;AAEF,eAAO,MAAM,aAAa,EAAE,cAAc,EAAqB,CAAC"}
1
+ {"version":3,"file":"run-command.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/run-command.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAA+B,cAAc,EAAE,MAAM,YAAY,CAAC;AAE9E,0DAA0D;AAC1D,eAAO,MAAM,kBAAkB,SAAU,CAAC;AAG1C,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,MAAM,EACX,SAAS,GAAE,MAA2B,GACrC,OAAO,CAAC,aAAa,CAAC,CAkDxB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,QAAQ,GAChB;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC;CACjC,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC,CAAC;AAEN,wBAAgB,QAAQ,CACtB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,OAAO,EACd,QAAQ,GAAE,MAAyB,GAClC,QAAQ,CAaV;AA8BD,eAAO,MAAM,cAAc,EAAE,cAmC5B,CAAC;AAEF,eAAO,MAAM,aAAa,EAAE,cAAc,EAAqB,CAAC"}
@@ -48,8 +48,8 @@ export async function executeCommand(argv, cwd, timeoutMs = COMMAND_TIMEOUT_MS)
48
48
  const timer = setTimeout(() => {
49
49
  timedOut = true;
50
50
  killTree(child.pid);
51
- // A process ignoring SIGTERM still has to go.
52
- setTimeout(() => killTree(child.pid, "SIGKILL"), 2000).unref();
51
+ // A process ignoring the polite request still has to go.
52
+ setTimeout(() => killTree(child.pid, true), 2000).unref();
53
53
  }, timeoutMs);
54
54
  const finish = (code, signal) => {
55
55
  clearTimeout(timer);
@@ -59,23 +59,44 @@ export async function executeCommand(argv, cwd, timeoutMs = COMMAND_TIMEOUT_MS)
59
59
  child.on("close", (code, signal) => finish(code, signal));
60
60
  });
61
61
  }
62
+ export function killPlan(pid, force, platform = process.platform) {
63
+ if (platform === "win32") {
64
+ return {
65
+ kind: "spawn",
66
+ command: "taskkill",
67
+ args: ["/PID", String(pid), "/T", ...(force ? ["/F"] : [])],
68
+ };
69
+ }
70
+ return {
71
+ kind: "signal",
72
+ target: -pid,
73
+ signal: force ? "SIGKILL" : "SIGTERM",
74
+ };
75
+ }
62
76
  /**
63
- * Kill the whole process group, not just the child.
77
+ * Kill the whole tree.
64
78
  *
65
- * Killing the parent alone leaves grandchildren running - the familiar case
66
- * where a "timed out" test runner keeps holding a port. The negative pid is
67
- * POSIX; Windows has no process groups in this sense, so there the direct
68
- * child is killed and the limitation is stated rather than papered over
69
- * (no Windows machine here to verify a taskkill /T path).
79
+ * ⚠️ The Windows branch is UNVERIFIED on a real machine. Its shape is tested;
80
+ * whether `taskkill /T` actually reaches every grandchild of a Node-spawned
81
+ * process is not something this project has been able to check.
70
82
  */
71
- function killTree(pid, signal = "SIGTERM") {
83
+ function killTree(pid, force = false) {
72
84
  if (pid === undefined)
73
85
  return;
86
+ const plan = killPlan(pid, force);
74
87
  try {
75
- if (process.platform === "win32")
76
- process.kill(pid, signal);
77
- else
78
- process.kill(-pid, signal);
88
+ if (plan.kind === "signal") {
89
+ process.kill(plan.target, plan.signal);
90
+ return;
91
+ }
92
+ // Detached and ignored: this is fire-and-forget cleanup, and a taskkill
93
+ // that fails must not become an unhandled error on the way out.
94
+ const killer = spawn(plan.command, [...plan.args], {
95
+ stdio: "ignore",
96
+ windowsHide: true,
97
+ });
98
+ killer.on("error", () => { });
99
+ killer.unref();
79
100
  }
80
101
  catch {
81
102
  // Already exited, or never became a group leader.
@@ -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
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/commands/agent.ts"],"names":[],"mappings":"AAKA,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAOvE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAatB,eAAO,MAAM,gBAAgB,0XAK+C,CAAC;AAE7E,eAAO,MAAM,YAAY,mgBAOmB,CAAC;AAE7C,MAAM,WAAW,OAAQ,SAAQ,SAAS;IACxC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,qDAAqD;IACrD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,uBAAuB,EAAE,QAAQ,CAAC;CACrD;AAED,wBAAsB,YAAY,CAChC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,OAAO,GACV,OAAO,CAAC,QAAQ,CAAC,CA+GnB;AAgFD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,EAAE,kBAAkB,EAAE,OAAO,0JAI3E"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/commands/agent.ts"],"names":[],"mappings":"AAKA,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAYvE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAatB,eAAO,MAAM,gBAAgB,0XAK+C,CAAC;AAE7E,eAAO,MAAM,YAAY,mgBAOmB,CAAC;AAE7C,MAAM,WAAW,OAAQ,SAAQ,SAAS;IACxC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,qDAAqD;IACrD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,uBAAuB,EAAE,QAAQ,CAAC;CACrD;AAED,wBAAsB,YAAY,CAChC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,OAAO,GACV,OAAO,CAAC,QAAQ,CAAC,CAsHnB;AAyJD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,EAAE,kBAAkB,EAAE,OAAO,0JAI3E"}
@@ -2,6 +2,11 @@ import { fetchAccessibleModels, fetchCatalog, intersectModels, } from "../api/mo
2
2
  import { approverFor } from "../agent/approvals.js";
3
3
  import { runAgent } from "../agent/loop.js";
4
4
  import { runSession } from "../agent/session.js";
5
+ import { sessionsDir } from "../config/paths.js";
6
+ import { keyDigest } from "../security/redact.js";
7
+ import { decideRecording } from "../sessions/policy.js";
8
+ import { SessionRecorder } from "../sessions/recorder.js";
9
+ import { SessionWriter } from "../sessions/store.js";
5
10
  import { READ_ONLY_TOOLS } from "../agent/tools/read-only.js";
6
11
  import { COMMAND_TOOLS } from "../agent/tools/run-command.js";
7
12
  import { WRITE_TOOLS } from "../agent/tools/write.js";
@@ -74,8 +79,14 @@ export async function agentCommand(argv, io) {
74
79
  io.stderr(`mafdet agent: no goal\n${USAGE}`);
75
80
  return ExitCode.Usage;
76
81
  }
82
+ // F-007 · `decideRecording` already called this surface "interactive",
83
+ // so the policy claimed these runs were saved while nothing saved them.
84
+ const recorder = openRecorder(parsed, io, model, workspace.root, mode);
77
85
  const summary = await runSession({
78
86
  client: runtime.dataPlane,
87
+ ...(recorder === undefined
88
+ ? {}
89
+ : { onEvent: (event) => recorder.observe(event) }),
79
90
  model,
80
91
  pricing: resolved,
81
92
  catalog,
@@ -89,6 +100,7 @@ export async function agentCommand(argv, io) {
89
100
  stderr: io.stderr,
90
101
  signal: io.signal,
91
102
  });
103
+ recorder?.close();
92
104
  io.stderr("\n" +
93
105
  renderRecord([
94
106
  ["goals", `${summary.goals}`],
@@ -130,6 +142,45 @@ export async function agentCommand(argv, io) {
130
142
  return reportError(error, io);
131
143
  }
132
144
  }
145
+ /**
146
+ * F-007 · A recorder for an interactive `mafdet agent`, or nothing.
147
+ *
148
+ * The same store, policy and failure behaviour as the TUI: `--no-history`
149
+ * writes nothing at all, and a store that cannot be opened costs the
150
+ * transcript rather than the session.
151
+ */
152
+ function openRecorder(parsed, io, modelId, workspaceRoot, mode) {
153
+ const decision = decideRecording({
154
+ surface: "interactive",
155
+ noHistory: parsed.noHistory,
156
+ save: false,
157
+ });
158
+ if (!decision.record)
159
+ return undefined;
160
+ const env = io.env ?? process.env;
161
+ const root = sessionsDir(env);
162
+ try {
163
+ const writer = SessionWriter.create({
164
+ root,
165
+ title: parsed.title ??
166
+ workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ??
167
+ "Conversation",
168
+ modelId,
169
+ workspace: workspaceRoot,
170
+ mode,
171
+ });
172
+ return new SessionRecorder({
173
+ writer,
174
+ root,
175
+ apiKeyRef: keyDigest(env.MAFDET_API_KEY ?? ""),
176
+ onProblem: (message) => io.stderr(`${message}\n`),
177
+ });
178
+ }
179
+ catch (error) {
180
+ io.stderr(`not recording this conversation: ${error instanceof Error ? error.message : String(error)}\n`);
181
+ return undefined;
182
+ }
183
+ }
133
184
  function parseArgs(argv, io) {
134
185
  const positional = [];
135
186
  let model;
@@ -137,6 +188,8 @@ function parseArgs(argv, io) {
137
188
  let maxCostMicroUsd;
138
189
  let json = false;
139
190
  let allowWrite = false;
191
+ let noHistory = false;
192
+ let title;
140
193
  const allowedCommands = [];
141
194
  for (let i = 0; i < argv.length; i += 1) {
142
195
  const arg = argv[i];
@@ -157,6 +210,25 @@ function parseArgs(argv, io) {
157
210
  }
158
211
  else if (arg === "--read-only")
159
212
  allowWrite = false;
213
+ else if (arg === "--no-history")
214
+ noHistory = true;
215
+ else if (arg === "--title") {
216
+ const entry = next();
217
+ if (!entry) {
218
+ io.stderr("mafdet agent: --title needs a value\n");
219
+ return ExitCode.Usage;
220
+ }
221
+ title = entry;
222
+ }
223
+ else if (arg === "--resume") {
224
+ // F-007 · Deliberately not implemented here, and said rather than
225
+ // silently ignored. Resuming has to reconcile the session's model and
226
+ // workspace against the current key, which the TUI has a place to do
227
+ // and this surface does not - and a second, weaker resume path is
228
+ // exactly how the two drift apart. F-003 was that drift.
229
+ io.stderr("mafdet agent: --resume is not available here; run 'mafdet --resume <id>'\n");
230
+ return ExitCode.Usage;
231
+ }
160
232
  else if (arg === "--max-turns") {
161
233
  const raw = next();
162
234
  maxTurns = Number(raw);
@@ -198,6 +270,8 @@ function parseArgs(argv, io) {
198
270
  maxTurns,
199
271
  maxCostMicroUsd,
200
272
  json,
273
+ noHistory,
274
+ ...(title === undefined ? {} : { title }),
201
275
  };
202
276
  }
203
277
  /**
@@ -258,6 +332,8 @@ function finish(report, json, io) {
258
332
  case "max_turns":
259
333
  case "timeout":
260
334
  case "max_cost":
335
+ // F-008 · A ceiling too: the window ran out, and the report says which.
336
+ case "context_limit":
261
337
  // Stopped short on purpose; the report says so rather than pretending.
262
338
  return ExitCode.LimitReached;
263
339
  case "rate_limited":
@@ -1 +1 @@
1
- {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACnE,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAUtB,MAAM,WAAW,MAAO,SAAQ,SAAS;IACvC,qDAAqD;IACrD,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,gDAAgD;IAChD,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAWD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,QAAQ,CAAC,CA8BnB;AAwLD,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,YAAY,GAAG,SAAS,EAC/B,KAAK,EAAE,WAAW,GACjB,MAAM,GAAG,SAAS,CASpB"}
1
+ {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACnE,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAWxE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAatB,MAAM,WAAW,MAAO,SAAQ,SAAS;IACvC,qDAAqD;IACrD,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,gDAAgD;IAChD,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAcD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,QAAQ,CAAC,CA8BnB;AA8SD,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,YAAY,GAAG,SAAS,EAC/B,KAAK,EAAE,WAAW,GACjB,MAAM,GAAG,SAAS,CASpB"}