@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
@@ -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,51 @@ 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
+ * R-3 · A ceiling on the whole stream, not just on the gaps in it.
77
+ *
78
+ * The idle deadline is the right primitive for "this hung" - a long
79
+ * generation is not a stalled one - but it is defeated by a trickle: a few
80
+ * bytes before each deadline holds the connection indefinitely. The byte cap
81
+ * bounds the volume and not the time, so this bounds the time.
82
+ *
83
+ * Generous on purpose. It is a backstop against a hostile upstream, not a
84
+ * limit on how long a model may think, and a single response that takes
85
+ * longer than this is not a case worth serving.
86
+ */
87
+ readonly maxTotalMs?: number;
88
+ /**
89
+ * Closes the underlying connection. Cancelling the reader alone stops us
90
+ * reading; it does not reliably stop the gateway generating, and generation
91
+ * is what gets billed.
92
+ */
93
+ readonly abort?: () => void;
94
+ }
95
+ export declare const DEFAULT_STREAM_IDLE_MS = 120000;
96
+ export declare const DEFAULT_STREAM_MAX_BYTES: number;
97
+ /** Matches the agent's wall-clock ceiling (§7.4), for the same reason. */
98
+ export declare const DEFAULT_STREAM_TOTAL_MS: number;
65
99
  /**
66
100
  * Read a streaming response to completion.
67
101
  *
68
102
  * `signal` is the user's Ctrl+C: aborting stops reading AND closes the
69
103
  * connection, which is what actually tells the gateway to stop generating.
104
+ *
105
+ * F-009 · The request timeout in `HttpClient` is cleared the moment the
106
+ * response headers arrive, which for a stream is the beginning rather than the
107
+ * end. Without the two limits below, a gateway that sent headers and then
108
+ * stopped would hang the CLI forever, and one that never stopped sending would
109
+ * grow the buffer until the process died.
70
110
  */
71
- export declare function readChatStream(response: Response, events?: StreamEvents, signal?: AbortSignal): Promise<StreamResult>;
111
+ export declare function readChatStream(response: Response, events?: StreamEvents, signal?: AbortSignal, limits?: StreamLimits): Promise<StreamResult>;
72
112
  //# 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;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B;AAED,eAAO,MAAM,sBAAsB,SAAU,CAAC;AAC9C,eAAO,MAAM,wBAAwB,QAAmB,CAAC;AACzD,0EAA0E;AAC1E,eAAO,MAAM,uBAAuB,QAAc,CAAC;AAEnD;;;;;;;;;;;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,CAyFvB"}
@@ -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,20 +100,37 @@ 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;
105
+ /** Matches the agent's wall-clock ceiling (§7.4), for the same reason. */
106
+ export const DEFAULT_STREAM_TOTAL_MS = 30 * 60_000;
120
107
  /**
121
108
  * Read a streaming response to completion.
122
109
  *
123
110
  * `signal` is the user's Ctrl+C: aborting stops reading AND closes the
124
111
  * connection, which is what actually tells the gateway to stop generating.
112
+ *
113
+ * F-009 · The request timeout in `HttpClient` is cleared the moment the
114
+ * response headers arrive, which for a stream is the beginning rather than the
115
+ * end. Without the two limits below, a gateway that sent headers and then
116
+ * stopped would hang the CLI forever, and one that never stopped sending would
117
+ * grow the buffer until the process died.
125
118
  */
126
- export async function readChatStream(response, events = {}, signal) {
119
+ export async function readChatStream(response, events = {}, signal, limits = {}) {
127
120
  const framer = new SseFramer();
128
121
  const assembler = new ChatStreamAssembler(events);
129
122
  const body = response.body;
130
123
  if (!body)
131
124
  return assembler.result();
125
+ const idleMs = limits.idleTimeoutMs ?? DEFAULT_STREAM_IDLE_MS;
126
+ const maxBytes = limits.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
127
+ const totalMs = limits.maxTotalMs ?? DEFAULT_STREAM_TOTAL_MS;
128
+ const startedAt = Date.now();
129
+ let received = 0;
132
130
  const reader = body.getReader();
133
131
  const onAbort = () => void reader.cancel();
132
+ /** Set when `[DONE]` ended the answer while bytes may still be coming. */
133
+ let finished = false;
134
134
  // Attaching a listener is not enough: a signal that aborted between the
135
135
  // request and here would never fire one, and the read below would hang
136
136
  // forever on a stream nobody is going to close.
@@ -144,12 +144,47 @@ export async function readChatStream(response, events = {}, signal) {
144
144
  for (;;) {
145
145
  if (signal?.aborted)
146
146
  break;
147
- const { done, value } = await reader.read();
147
+ if (Date.now() - startedAt > totalMs) {
148
+ limits.abort?.();
149
+ await reader.cancel().catch(() => { });
150
+ throw new ApiError({
151
+ kind: "timeout",
152
+ exitCode: ExitCode.Unavailable,
153
+ message: `the stream ran for longer than ${totalMs}ms`,
154
+ });
155
+ }
156
+ const chunk = await withIdleDeadline(reader.read(), idleMs);
157
+ if (chunk === IDLE) {
158
+ limits.abort?.();
159
+ await reader.cancel().catch(() => { });
160
+ throw new ApiError({
161
+ kind: "timeout",
162
+ exitCode: ExitCode.Unavailable,
163
+ message: `the stream sent nothing for ${idleMs}ms`,
164
+ });
165
+ }
166
+ const { done, value } = chunk;
148
167
  if (done)
149
168
  break;
169
+ received += value.byteLength;
170
+ if (received > maxBytes) {
171
+ limits.abort?.();
172
+ await reader.cancel().catch(() => { });
173
+ throw new ApiError({
174
+ kind: "too_large",
175
+ exitCode: ExitCode.Unavailable,
176
+ message: `the stream exceeded the ${maxBytes} byte limit`,
177
+ });
178
+ }
150
179
  for (const payload of framer.push(value)) {
151
- if (assembler.push(payload))
180
+ if (assembler.push(payload)) {
181
+ // R-3 · `[DONE]` means the answer is complete, not that the socket
182
+ // is. An upstream that keeps the connection open after it would
183
+ // otherwise hold ours too - releasing the reader lock in the
184
+ // `finally` does not close anything.
185
+ finished = true;
152
186
  return assembler.result();
187
+ }
153
188
  }
154
189
  }
155
190
  for (const payload of framer.end())
@@ -157,7 +192,40 @@ export async function readChatStream(response, events = {}, signal) {
157
192
  }
158
193
  finally {
159
194
  signal?.removeEventListener("abort", onAbort);
195
+ if (finished) {
196
+ // Cancel first, then release: cancelling closes the body, and the abort
197
+ // reaches the fetch itself, which is what actually frees the socket.
198
+ void reader.cancel().catch(() => { });
199
+ limits.abort?.();
200
+ }
160
201
  reader.releaseLock();
161
202
  }
162
203
  return assembler.result();
163
204
  }
205
+ /** Sentinel for "the deadline won", distinguishable from a real chunk. */
206
+ const IDLE = Symbol("idle");
207
+ /**
208
+ * Race a read against the idle deadline.
209
+ *
210
+ * The timer is unref'd and always cleared: a pending timer would keep the
211
+ * process alive after a stream that finished normally.
212
+ */
213
+ async function withIdleDeadline(read, idleMs) {
214
+ let timer;
215
+ try {
216
+ return await Promise.race([
217
+ read,
218
+ new Promise((resolve) => {
219
+ // Deliberately not unref'd: if a stalled stream were the only thing
220
+ // left, an unref'd timer would let the process exit silently instead
221
+ // of reporting the timeout. The finally below always clears it, so
222
+ // nothing is held open after a stream that ended normally.
223
+ timer = setTimeout(() => resolve(IDLE), idleMs);
224
+ }),
225
+ ]);
226
+ }
227
+ finally {
228
+ if (timer !== undefined)
229
+ clearTimeout(timer);
230
+ }
231
+ }
@@ -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":"AAQA,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAiBvE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAmBtB,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,CA6HnB;AAoLD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,EAAE,kBAAkB,EAAE,OAAO,0JAI3E"}
@@ -1,7 +1,15 @@
1
1
  import { fetchAccessibleModels, fetchCatalog, intersectModels, } from "../api/models.js";
2
+ import { existsSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { approverFor } from "../agent/approvals.js";
3
5
  import { runAgent } from "../agent/loop.js";
4
6
  import { runSession } from "../agent/session.js";
7
+ import { safeForTerminal } from "../output/terminal-safe.js";
8
+ import { sessionsDir } from "../config/paths.js";
9
+ import { keyDigest } from "../security/redact.js";
10
+ import { decideRecording, firstSaveNotice, NOTICE_FILE, } from "../sessions/policy.js";
11
+ import { SessionRecorder } from "../sessions/recorder.js";
12
+ import { SessionWriter } from "../sessions/store.js";
5
13
  import { READ_ONLY_TOOLS } from "../agent/tools/read-only.js";
6
14
  import { COMMAND_TOOLS } from "../agent/tools/run-command.js";
7
15
  import { WRITE_TOOLS } from "../agent/tools/write.js";
@@ -11,14 +19,20 @@ import { renderJson, renderRecord } from "../output/format.js";
11
19
  import { formatUsd } from "../output/money.js";
12
20
  import { buildRuntime, reportError, requireApiKey, } from "./runtime.js";
13
21
  const USAGE = `Usage:
14
- mafdet agent "<goal>" [--model <id>] [--allow-write]
22
+ mafdet agent "<goal>" [--model <id>] [--allow-write] [--read-only]
15
23
  [--allow-command "<prefix>"]... [--max-turns <n>]
16
24
  [--max-cost-usd <n>] [--json]
25
+ mafdet agent Interactive session (needs a terminal)
26
+ [--title <text>] [--no-history]
17
27
 
18
28
  Read-only by default. With --allow-write the agent may edit and create files,
19
- and every change is shown as a diff and confirmed before it is written. There
20
- is no flag that approves everything in advance, and a non-interactive run
21
- cannot write at all.
29
+ and every change is shown as a diff and confirmed before it is written.
30
+ --read-only cancels an earlier --allow-write. There is no flag that approves
31
+ everything in advance, and a non-interactive run cannot write at all.
32
+
33
+ An interactive session is saved on this computer; --no-history records
34
+ nothing and --title names it in 'mafdet sessions list'. To continue a saved
35
+ conversation, use 'mafdet --resume <id>'.
22
36
  `;
23
37
  export const READ_ONLY_PROMPT = `You are the Mafdet CLI agent working inside a user's workspace.
24
38
 
@@ -74,21 +88,36 @@ export async function agentCommand(argv, io) {
74
88
  io.stderr(`mafdet agent: no goal\n${USAGE}`);
75
89
  return ExitCode.Usage;
76
90
  }
77
- const summary = await runSession({
78
- client: runtime.dataPlane,
79
- model,
80
- pricing: resolved,
81
- catalog,
82
- systemPrompt: mode === "read-only" ? READ_ONLY_PROMPT : WRITE_PROMPT,
83
- tools: buildToolset(mode, parsed.allowedCommands.length > 0),
84
- toolContext: { workspace, allowedCommands: parsed.allowedCommands },
85
- approver: io.approver ?? approverFor(mode, io, interactive),
86
- mode,
87
- allowedCommands: parsed.allowedCommands,
88
- stdout: io.stdout,
89
- stderr: io.stderr,
90
- signal: io.signal,
91
- });
91
+ // F-007 · `decideRecording` already called this surface "interactive",
92
+ // so the policy claimed these runs were saved while nothing saved them.
93
+ const recorder = openRecorder(parsed, io, model, workspace.root, mode);
94
+ // R-8 · In a finally: `close()` is what writes the final index entry, so
95
+ // a session that threw left the listing without it.
96
+ let summary;
97
+ try {
98
+ summary = await runSession({
99
+ client: runtime.dataPlane,
100
+ ...(recorder === undefined
101
+ ? {}
102
+ : { onEvent: (event) => recorder.observe(event) }),
103
+ model,
104
+ pricing: resolved,
105
+ catalog,
106
+ systemPrompt: mode === "read-only" ? READ_ONLY_PROMPT : WRITE_PROMPT,
107
+ tools: buildToolset(mode, parsed.allowedCommands.length > 0),
108
+ toolContext: { workspace, allowedCommands: parsed.allowedCommands },
109
+ approver: io.approver ?? approverFor(mode, io, interactive),
110
+ mode,
111
+ allowedCommands: parsed.allowedCommands,
112
+ stdout: io.stdout,
113
+ stderr: io.stderr,
114
+ signal: io.signal,
115
+ isTty: isTty(io),
116
+ });
117
+ }
118
+ finally {
119
+ recorder?.close();
120
+ }
92
121
  io.stderr("\n" +
93
122
  renderRecord([
94
123
  ["goals", `${summary.goals}`],
@@ -130,6 +159,72 @@ export async function agentCommand(argv, io) {
130
159
  return reportError(error, io);
131
160
  }
132
161
  }
162
+ /**
163
+ * F-007 · A recorder for an interactive `mafdet agent`, or nothing.
164
+ *
165
+ * The same store, policy and failure behaviour as the TUI: `--no-history`
166
+ * writes nothing at all, and a store that cannot be opened costs the
167
+ * transcript rather than the session.
168
+ */
169
+ /**
170
+ * R-8 · The first-save notice, once per machine.
171
+ *
172
+ * The marker file is shared with the TUI on purpose: the notice is about this
173
+ * computer, not about which command happened to write first.
174
+ */
175
+ function showNoticeOnce(root, io) {
176
+ const marker = join(root, NOTICE_FILE);
177
+ try {
178
+ if (existsSync(marker))
179
+ return;
180
+ io.stderr(firstSaveNotice(root));
181
+ writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600 });
182
+ }
183
+ catch {
184
+ // Telling the user is best effort; failing to record that we told them
185
+ // must not stop the session.
186
+ }
187
+ }
188
+ /** Whether a terminal is on the other end of stdout. Injectable for tests. */
189
+ function isTty(io) {
190
+ return io.isTty ?? process.stdout.isTTY === true;
191
+ }
192
+ function openRecorder(parsed, io, modelId, workspaceRoot, mode) {
193
+ const decision = decideRecording({
194
+ surface: "interactive",
195
+ noHistory: parsed.noHistory,
196
+ save: false,
197
+ });
198
+ if (!decision.record)
199
+ return undefined;
200
+ const env = io.env ?? process.env;
201
+ const root = sessionsDir(env);
202
+ try {
203
+ const writer = SessionWriter.create({
204
+ root,
205
+ title: parsed.title ??
206
+ workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ??
207
+ "Conversation",
208
+ modelId,
209
+ workspace: workspaceRoot,
210
+ mode,
211
+ });
212
+ // R-8 · Said once per machine, on whichever surface records first. The
213
+ // TUI showed it and this one did not, so a `mafdet agent` user could have
214
+ // a transcript on disk without ever being told.
215
+ showNoticeOnce(root, io);
216
+ return new SessionRecorder({
217
+ writer,
218
+ root,
219
+ apiKeyRef: keyDigest(env.MAFDET_API_KEY ?? ""),
220
+ onProblem: (message) => io.stderr(`${message}\n`),
221
+ });
222
+ }
223
+ catch (error) {
224
+ io.stderr(`not recording this conversation: ${error instanceof Error ? error.message : String(error)}\n`);
225
+ return undefined;
226
+ }
227
+ }
133
228
  function parseArgs(argv, io) {
134
229
  const positional = [];
135
230
  let model;
@@ -137,6 +232,8 @@ function parseArgs(argv, io) {
137
232
  let maxCostMicroUsd;
138
233
  let json = false;
139
234
  let allowWrite = false;
235
+ let noHistory = false;
236
+ let title;
140
237
  const allowedCommands = [];
141
238
  for (let i = 0; i < argv.length; i += 1) {
142
239
  const arg = argv[i];
@@ -157,6 +254,25 @@ function parseArgs(argv, io) {
157
254
  }
158
255
  else if (arg === "--read-only")
159
256
  allowWrite = false;
257
+ else if (arg === "--no-history")
258
+ noHistory = true;
259
+ else if (arg === "--title") {
260
+ const entry = next();
261
+ if (!entry) {
262
+ io.stderr("mafdet agent: --title needs a value\n");
263
+ return ExitCode.Usage;
264
+ }
265
+ title = entry;
266
+ }
267
+ else if (arg === "--resume") {
268
+ // F-007 · Deliberately not implemented here, and said rather than
269
+ // silently ignored. Resuming has to reconcile the session's model and
270
+ // workspace against the current key, which the TUI has a place to do
271
+ // and this surface does not - and a second, weaker resume path is
272
+ // exactly how the two drift apart. F-003 was that drift.
273
+ io.stderr("mafdet agent: --resume is not available here; run 'mafdet --resume <id>'\n");
274
+ return ExitCode.Usage;
275
+ }
160
276
  else if (arg === "--max-turns") {
161
277
  const raw = next();
162
278
  maxTurns = Number(raw);
@@ -198,6 +314,8 @@ function parseArgs(argv, io) {
198
314
  maxTurns,
199
315
  maxCostMicroUsd,
200
316
  json,
317
+ noHistory,
318
+ ...(title === undefined ? {} : { title }),
201
319
  };
202
320
  }
203
321
  /**
@@ -229,8 +347,10 @@ function finish(report, json, io) {
229
347
  io.stdout(renderJson({ ...report, costMicroUsd: report.costMicroUsd.toString() }));
230
348
  }
231
349
  else {
232
- if (report.finalText)
233
- io.stdout(`${report.finalText}\n`);
350
+ // R-4 · The model's answer, sanitised only when a terminal is reading it.
351
+ if (report.finalText) {
352
+ io.stdout(`${safeForTerminal(report.finalText, isTty(io))}\n`);
353
+ }
234
354
  io.stderr("\n" +
235
355
  renderRecord([
236
356
  ["status", report.stopReason],
@@ -258,6 +378,11 @@ function finish(report, json, io) {
258
378
  case "max_turns":
259
379
  case "timeout":
260
380
  case "max_cost":
381
+ // F-008 · A ceiling too: the window ran out, and the report says which.
382
+ case "context_limit":
383
+ // R-5 · Asked for a ceiling we cannot enforce; stopped rather than spend
384
+ // past it. A limit, so it belongs with the other limits.
385
+ case "cost_unknown":
261
386
  // Stopped short on purpose; the report says so rather than pretending.
262
387
  return ExitCode.LimitReached;
263
388
  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;AAOxE,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;AAoSD,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"}
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { fetchCatalog } from "../api/models.js";
3
3
  import { readChatStream } from "../api/sse-parser.js";
4
4
  import { ApiError } from "../api/errors.js";
5
+ import { safeForTerminal, terminalSafeWriter, } from "../output/terminal-safe.js";
5
6
  import { sessionsDir } from "../config/paths.js";
6
7
  import { keyDigest } from "../security/redact.js";
7
8
  import { upsertIndexEntry } from "../sessions/index-file.js";
@@ -196,7 +197,11 @@ function saveChat(args, io, answer, usage, requestId, model) {
196
197
  model_id: args.model,
197
198
  prompt_tokens: usage.prompt_tokens,
198
199
  completion_tokens: usage.completion_tokens,
199
- estimated_charge_micro_usd: (micros ?? 0n).toString(),
200
+ // F-012 · Omitted rather than zeroed when the catalogue could not
201
+ // price it: "we do not know" must not be recorded as "it was free".
202
+ ...(micros === undefined
203
+ ? {}
204
+ : { estimated_charge_micro_usd: micros.toString() }),
200
205
  settlement_status: "pending",
201
206
  api_key_ref: keyDigest(readKey(io)),
202
207
  });
@@ -217,6 +222,10 @@ function chatTitle(prompt) {
217
222
  const trimmed = line.trim();
218
223
  return trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed;
219
224
  }
225
+ /** Whether a terminal is on the other end of stdout. Injectable for tests. */
226
+ function isTty(io) {
227
+ return io.isTty ?? process.stdout.isTTY === true;
228
+ }
220
229
  function readKey(io) {
221
230
  return (io.env ?? process.env).MAFDET_API_KEY ?? "";
222
231
  }
@@ -238,12 +247,20 @@ async function priceModel(runtime, modelId, needed) {
238
247
  }
239
248
  }
240
249
  async function streamChat(runtime, body, args, io) {
241
- const { response, requestId } = await runtime.dataPlane.postStream("/chat/completions", body, {
250
+ const { response, requestId, abort, limits } = await runtime.dataPlane.postStream("/chat/completions", body, {
242
251
  signal: io.signal,
243
252
  });
244
253
  // Text goes to stdout as it arrives; everything else is stderr, so
245
254
  // `mafdet chat ... > answer.txt` captures only the answer.
246
- const result = await readChatStream(response, { onText: io.stdout }, io.signal);
255
+ // F-009 · The limits travel with the stream: without them a gateway that
256
+ // sent headers and then went quiet would hang here indefinitely.
257
+ // F-010 · Sanitised only when a terminal is reading: `mafdet chat >
258
+ // answer.txt` and `| jq` must get the bytes the model sent.
259
+ const write = terminalSafeWriter(io.stdout, isTty(io));
260
+ const result = await readChatStream(response, { onText: write }, io.signal, {
261
+ ...limits,
262
+ abort,
263
+ });
247
264
  io.stdout("\n");
248
265
  const model = await priceModel(runtime, args.model, !args.quiet || args.save);
249
266
  if (io.signal?.aborted) {
@@ -260,7 +277,7 @@ async function streamChat(runtime, body, args, io) {
260
277
  async function completeChat(runtime, body, args, io) {
261
278
  const { data, requestId } = await runtime.dataPlane.postJson("/chat/completions", body, { signal: io.signal });
262
279
  const answer = data.choices?.[0]?.message?.content ?? "";
263
- io.stdout(`${answer}\n`);
280
+ io.stdout(`${safeForTerminal(answer, isTty(io))}\n`);
264
281
  const model = await priceModel(runtime, args.model, !args.quiet || args.save);
265
282
  summarise(args, data.usage, io, model);
266
283
  saveChat(args, io, answer, data.usage, requestId, model);
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/commands/config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAOnD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,QAAQ,CAwEV"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/commands/config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAOnD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,QAAQ,CAsEV"}
@@ -44,7 +44,6 @@ export function configCommand(argv, io) {
44
44
  model_source: config.modelSource,
45
45
  base_url: config.baseUrl,
46
46
  backend_url: config.backendUrl,
47
- approval_mode: config.approvalMode,
48
47
  api_key: config.apiKey ? keyFingerprint(config.apiKey) : null,
49
48
  user_config: config.sources.userConfigFound
50
49
  ? config.sources.userConfigPath
@@ -61,7 +60,6 @@ export function configCommand(argv, io) {
61
60
  ["model", view.model ? `${view.model} (${view.model_source})` : "<unset>"],
62
61
  ["base url", view.base_url],
63
62
  ["backend url", view.backend_url],
64
- ["approval mode", view.approval_mode],
65
63
  ["api key", view.api_key ?? "<unset> (set MAFDET_API_KEY)"],
66
64
  ["user config", view.user_config ?? "<none>"],
67
65
  ["project config", view.project_config ?? "<none>"],
@@ -10,6 +10,12 @@ export interface CommandIo {
10
10
  /** Injected by tests; production uses global fetch. */
11
11
  fetchImpl?: typeof fetch;
12
12
  version?: string;
13
+ /**
14
+ * F-010 · Whether stdout is a terminal. Untrusted text is stripped of
15
+ * control sequences only then: a pipe or a file must receive exact bytes.
16
+ * Defaults to `process.stdout.isTTY`; tests set it explicitly.
17
+ */
18
+ isTty?: boolean;
13
19
  }
14
20
  export interface Runtime {
15
21
  config: LoadedConfig;
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/commands/runtime.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE5C,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uDAAuD;IACvD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,YAAY,CAAC;IACrB,sCAAsC;IACtC,SAAS,EAAE,UAAU,CAAC;IACtB,yCAAyC;IACzC,YAAY,EAAE,UAAU,CAAC;CAC1B;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGjD;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAgBnD;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CASxD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,GAAG,QAAQ,CAkBnE"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/commands/runtime.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE5C,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uDAAuD;IACvD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,YAAY,CAAC;IACrB,sCAAsC;IACtC,SAAS,EAAE,UAAU,CAAC;IACtB,yCAAyC;IACzC,YAAY,EAAE,UAAU,CAAC;CAC1B;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGjD;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAgBnD;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CASxD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,GAAG,QAAQ,CAkBnE"}
@@ -1 +1 @@
1
- {"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/commands/sessions.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAuBnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAgBtB,wBAAsB,eAAe,CACnC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CAenB;AAqID,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAUX,CAAC"}
1
+ {"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/commands/sessions.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AA4BnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAgBtB,wBAAsB,eAAe,CACnC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CAUnB;AAoRD,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAUX,CAAC"}