@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,7 +1,5 @@
1
- /**
2
- * Rendering for query commands. Three shapes, one rule: stdout carries the
3
- * result and nothing else, so `mafdet ... --json | jq` is always valid.
4
- */
1
+ import { ApiError } from "../api/errors.js";
2
+ import { ExitCode } from "./exit-codes.js";
5
3
  /** `--json` and `--plain` are mutually exclusive; table is the default. */
6
4
  export function parseFormatFlags(argv) {
7
5
  const rest = [];
@@ -10,7 +8,14 @@ export function parseFormatFlags(argv) {
10
8
  for (const arg of argv) {
11
9
  if (arg === "--json" || arg === "--plain") {
12
10
  if (chosen && chosen !== arg) {
13
- throw new Error(`${chosen} and ${arg} cannot be combined`);
11
+ // F-013 · A typed error, so the command surface turns this into a
12
+ // usage message and exit 2. A bare Error escaped to the top level and
13
+ // printed a Node stack trace at the user.
14
+ throw new ApiError({
15
+ kind: "request",
16
+ exitCode: ExitCode.Usage,
17
+ message: `${chosen} and ${arg} cannot be combined`,
18
+ });
14
19
  }
15
20
  chosen = arg;
16
21
  format = arg === "--json" ? "json" : "plain";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Make untrusted text safe to print, when and only when a terminal will read
3
+ * it.
4
+ *
5
+ * `isTty` is passed in rather than read from `process.stdout` so that the
6
+ * decision is testable and so a caller writing somewhere else cannot get the
7
+ * wrong answer by accident.
8
+ */
9
+ export declare function safeForTerminal(text: string, isTty: boolean): string;
10
+ /** Wrap a writer so everything through it is sanitised for a terminal. */
11
+ export declare function terminalSafeWriter(write: (text: string) => void, isTty: boolean): (text: string) => void;
12
+ //# sourceMappingURL=terminal-safe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminal-safe.d.ts","sourceRoot":"","sources":["../../src/output/terminal-safe.ts"],"names":[],"mappings":"AAuBA;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAEpE;AAED,0EAA0E;AAC1E,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,EAC7B,KAAK,EAAE,OAAO,GACb,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAGxB"}
@@ -0,0 +1,38 @@
1
+ // F-010 · Text we did not write, on its way to a terminal.
2
+ //
3
+ // Control sequences were already stripped from file contents, command output,
4
+ // tool cards and diff previews. Three sources were not covered, and they are
5
+ // the ones a remote party controls most directly:
6
+ //
7
+ // - the model's own reply, streamed straight to stdout;
8
+ // - a session's title, workspace and model id, rendered into tables and
9
+ // pickers - a transcript is a file, and files can be edited;
10
+ // - anything else read back out of the local store.
11
+ //
12
+ // An OSC sequence retitles the window, a CSI can clear the screen or move the
13
+ // cursor to overwrite what came before, and either makes the terminal show
14
+ // something other than what happened.
15
+ //
16
+ // The rule is deliberately about the *destination*, not the source: escapes
17
+ // only do damage when something interprets them. Stripping on the way to a
18
+ // pipe or a file would corrupt `mafdet chat > answer.txt` and any JSON a
19
+ // script parses, for no safety gain - a byte in a file is inert until it is
20
+ // cat'd, and at that point it is that terminal's problem, not a hole we made.
21
+ import { stripAnsi } from "../agent/command-policy.js";
22
+ /**
23
+ * Make untrusted text safe to print, when and only when a terminal will read
24
+ * it.
25
+ *
26
+ * `isTty` is passed in rather than read from `process.stdout` so that the
27
+ * decision is testable and so a caller writing somewhere else cannot get the
28
+ * wrong answer by accident.
29
+ */
30
+ export function safeForTerminal(text, isTty) {
31
+ return isTty ? stripAnsi(text) : text;
32
+ }
33
+ /** Wrap a writer so everything through it is sanitised for a terminal. */
34
+ export function terminalSafeWriter(write, isTty) {
35
+ if (!isTty)
36
+ return write;
37
+ return (text) => write(stripAnsi(text));
38
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"open.d.ts","sourceRoot":"","sources":["../../src/sessions/open.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAEL,oBAAoB,EAGpB,KAAK,aAAa,EACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAe,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAE9E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,QAAQ,CAAC,sBAAsB,EAAE,SAAS,MAAM,EAAE,CAAC;CACpD;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,WAAW,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,WAAW,EAAE,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;CAC/D,CAAC;AAEN,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,UAAU,CA4DnE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,GAC9C,MAAM,CAwBR"}
1
+ {"version":3,"file":"open.d.ts","sourceRoot":"","sources":["../../src/sessions/open.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAEL,oBAAoB,EAGpB,KAAK,aAAa,EACnB,MAAM,aAAa,CAAC;AAKrB,OAAO,EAAE,aAAa,EAAe,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAE9E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,QAAQ,CAAC,sBAAsB,EAAE,SAAS,MAAM,EAAE,CAAC;CACpD;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,WAAW,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,WAAW,EAAE,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;CAC/D,CAAC;AAEN,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,UAAU,CAqFnE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,GAC9C,MAAM,CAwBR"}
@@ -6,12 +6,24 @@
6
6
  // and the checks are all re-derived from the key in use *now*, never from
7
7
  // what the session was recorded with.
8
8
  import { existsSync } from "node:fs";
9
- import { join } from "node:path";
10
9
  import { acquireSessionLock, SessionLockedError, } from "./lock.js";
11
10
  import { describeBlocker, permissionsForResume, rebuildContext, resumeBlockers, } from "./resume.js";
11
+ import { InvalidSessionIdError, resolveSessionDirectory, } from "./session-id.js";
12
12
  import { SessionWriter, readSession } from "./store.js";
13
13
  export function openForResume(input) {
14
- const directory = join(input.root, input.sessionId);
14
+ // F-001 · Through the same boundary as the `sessions` subcommands. This is
15
+ // the write path: before the check, `--resume ../../../x` opened a directory
16
+ // outside the store and appended to it.
17
+ let directory;
18
+ try {
19
+ directory = resolveSessionDirectory(input.root, input.sessionId);
20
+ }
21
+ catch (error) {
22
+ if (error instanceof InvalidSessionIdError) {
23
+ return { kind: "unreadable", reason: error.message };
24
+ }
25
+ throw error;
26
+ }
15
27
  if (!existsSync(directory))
16
28
  return { kind: "missing" };
17
29
  let session;
@@ -24,6 +36,18 @@ export function openForResume(input) {
24
36
  reason: error instanceof Error ? error.message : String(error),
25
37
  };
26
38
  }
39
+ // F-016 · Continuing a conversation with a hole in it sends the model a
40
+ // history that is missing pieces, and it has no way to know. Reading it is
41
+ // fine; carrying on from it needs to be a decision, not a default.
42
+ if (session.corruptLines.length > 0) {
43
+ return {
44
+ kind: "unreadable",
45
+ reason: `${session.corruptLines.length} unreadable line(s) in this transcript ` +
46
+ `(line ${session.corruptLines.join(", ")}) - part of the conversation ` +
47
+ `is missing, so continuing it would send the model an incomplete ` +
48
+ `history. 'mafdet sessions show' still reads what survived.`,
49
+ };
50
+ }
27
51
  // Taken before anything is written, so two processes cannot interleave into
28
52
  // one transcript.
29
53
  let lock;
@@ -1 +1 @@
1
- {"version":3,"file":"recorder.d.ts","sourceRoot":"","sources":["../../src/sessions/recorder.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,aAAa,EAAqB,MAAM,YAAY,CAAC;AAE9D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAChD;AAED;;;;;GAKG;AACH,qBAAa,eAAe;IAId,OAAO,CAAC,QAAQ,CAAC,OAAO;IAHpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,KAAK,CAAS;gBAEO,OAAO,EAAE,eAAe;IAErD,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,wEAAwE;IACxE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIxB,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAoDhC,sEAAsE;IACtE,UAAU,IAAI,IAAI;IAUlB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAU7B,KAAK,IAAI,IAAI;IASb,OAAO,CAAC,KAAK;IAUb;;;;;OAKG;IACH,OAAO,CAAC,IAAI;CAOb"}
1
+ {"version":3,"file":"recorder.d.ts","sourceRoot":"","sources":["../../src/sessions/recorder.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,aAAa,EAAqB,MAAM,YAAY,CAAC;AAE9D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAChD;AAED;;;;;GAKG;AACH,qBAAa,eAAe;IAId,OAAO,CAAC,QAAQ,CAAC,OAAO;IAHpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,KAAK,CAAS;gBAEO,OAAO,EAAE,eAAe;IAErD,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,wEAAwE;IACxE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIxB,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAyDhC,sEAAsE;IACtE,UAAU,IAAI,IAAI;IAUlB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAU7B,KAAK,IAAI,IAAI;IASb,OAAO,CAAC,KAAK;IAUb;;;;;OAKG;IACH,OAAO,CAAC,IAAI;CAOb"}
@@ -71,7 +71,12 @@ export class SessionRecorder {
71
71
  model_id: event.model,
72
72
  prompt_tokens: event.promptTokens,
73
73
  completion_tokens: event.completionTokens,
74
- estimated_charge_micro_usd: event.costMicroUsd.toString(),
74
+ // F-012 · The loop reports 0 when the catalogue had no price for the
75
+ // model, which is indistinguishable from a genuinely free request.
76
+ // `priced` says which it was, so an unknown is recorded as unknown.
77
+ ...(event.priced === false
78
+ ? {}
79
+ : { estimated_charge_micro_usd: event.costMicroUsd.toString() }),
75
80
  ...(this.options.apiKeyRef === undefined
76
81
  ? {}
77
82
  : { api_key_ref: this.options.apiKeyRef }),
@@ -0,0 +1,16 @@
1
+ export declare class InvalidSessionIdError extends Error {
2
+ }
3
+ /**
4
+ * Is this a plain directory name, rather than a path?
5
+ *
6
+ * Exported for tests and for anywhere that wants to check without resolving.
7
+ */
8
+ export declare function isPlainSessionId(id: string): boolean;
9
+ /**
10
+ * The directory for a session id, or a refusal.
11
+ *
12
+ * @throws InvalidSessionIdError when the id is not a bare name, or when the
13
+ * path it produces would land outside `root`.
14
+ */
15
+ export declare function resolveSessionDirectory(root: string, id: string): string;
16
+ //# sourceMappingURL=session-id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-id.d.ts","sourceRoot":"","sources":["../../src/sessions/session-id.ts"],"names":[],"mappings":"AA6BA,qBAAa,qBAAsB,SAAQ,KAAK;CAAG;AAOnD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAOpD;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAkBxE"}
@@ -0,0 +1,93 @@
1
+ // F-001 · The one place a session id becomes a path.
2
+ //
3
+ // Every `sessions` subcommand and `--resume` used to do `join(root, id)` with
4
+ // whatever the user typed. `join` resolves `..`, so an id of `../../../x`
5
+ // pointed outside the store: `show` and `usage` read it, `--resume` appended
6
+ // to it, and `delete --yes` unlinked its files and removed the directory.
7
+ // That last one shipped in 0.3.0 and was a local destructive defect, not a
8
+ // theoretical one - it was reproduced before this was written.
9
+ //
10
+ // The fix is one function that every caller must go through, and two
11
+ // independent checks inside it:
12
+ //
13
+ // 1. Reject anything that is not a bare name. A session id is a directory
14
+ // name and nothing else - no separator, no `.` or `..`, no NUL, not
15
+ // absolute, not a Windows drive or UNC path.
16
+ // 2. Resolve it anyway and require the result to be inside the root. The
17
+ // first check should already be sufficient; this one is what holds if it
18
+ // ever is not, and it is the check that also copes with a root reached
19
+ // through a symlink.
20
+ //
21
+ // Deliberately NOT a `^ses_[0-9a-z]{19}$` format match. That would be tighter
22
+ // against this attack and worse overall: it breaks ids from a future
23
+ // generator, and it turns "no session called that" - the common typo - into a
24
+ // format error. Containment is the property that actually matters, so
25
+ // containment is what is enforced.
26
+ import { realpathSync } from "node:fs";
27
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
28
+ export class InvalidSessionIdError extends Error {
29
+ }
30
+ /** Separators on either platform, plus the ones a Windows path can carry. */
31
+ const SEPARATORS = /[/\\]/;
32
+ /** `C:` or `\\server\share`, which are absolute even where isAbsolute is not. */
33
+ const WINDOWS_ROOTED = /^(?:[A-Za-z]:|\\\\)/;
34
+ /**
35
+ * Is this a plain directory name, rather than a path?
36
+ *
37
+ * Exported for tests and for anywhere that wants to check without resolving.
38
+ */
39
+ export function isPlainSessionId(id) {
40
+ if (id.length === 0)
41
+ return false;
42
+ if (id.includes("\0"))
43
+ return false;
44
+ if (SEPARATORS.test(id))
45
+ return false;
46
+ if (id === "." || id === "..")
47
+ return false;
48
+ if (isAbsolute(id) || WINDOWS_ROOTED.test(id))
49
+ return false;
50
+ return true;
51
+ }
52
+ /**
53
+ * The directory for a session id, or a refusal.
54
+ *
55
+ * @throws InvalidSessionIdError when the id is not a bare name, or when the
56
+ * path it produces would land outside `root`.
57
+ */
58
+ export function resolveSessionDirectory(root, id) {
59
+ if (!isPlainSessionId(id)) {
60
+ throw new InvalidSessionIdError(`'${id}' is not a session id. Session ids are plain names like ` +
61
+ `ses_0msod7qr3y83x5k7t7t; run 'mafdet sessions list' to see them.`);
62
+ }
63
+ const directory = join(root, id);
64
+ if (!isInside(root, directory)) {
65
+ // Unreachable given the check above, and kept anyway: this is the check
66
+ // that states the actual property, and the one a future caller that
67
+ // bypasses `isPlainSessionId` would still hit.
68
+ throw new InvalidSessionIdError(`'${id}' would resolve outside the sessions directory`);
69
+ }
70
+ return directory;
71
+ }
72
+ /**
73
+ * Containment against the real path of the root, so a symlinked state
74
+ * directory - `XDG_STATE_HOME` pointing through one is ordinary - does not
75
+ * make an inside path look outside.
76
+ */
77
+ function isInside(root, candidate) {
78
+ const realRoot = realOrSelf(root);
79
+ const rel = relative(realRoot, realOrSelf(candidate));
80
+ if (rel === "")
81
+ return true;
82
+ return !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
83
+ }
84
+ function realOrSelf(path) {
85
+ try {
86
+ return realpathSync(resolve(path));
87
+ }
88
+ catch {
89
+ // Not created yet - a fresh machine has no sessions directory, and the
90
+ // lexical form is still the right thing to compare.
91
+ return resolve(path);
92
+ }
93
+ }
@@ -25,7 +25,15 @@ export type SessionEvent = {
25
25
  readonly model_id: string;
26
26
  readonly prompt_tokens: number;
27
27
  readonly completion_tokens: number;
28
- readonly estimated_charge_micro_usd: string;
28
+ /**
29
+ * F-012 · Integer micro-USD, or absent when we could not price it.
30
+ *
31
+ * Absent and "0" are different claims: one is "we do not know", the
32
+ * other is "it was free". Writing 0 for an unpriced request - a
33
+ * catalogue that was briefly unreachable, or a model the catalogue does
34
+ * not list - made an unknown look like a gift.
35
+ */
36
+ readonly estimated_charge_micro_usd?: string;
29
37
  readonly settlement_status: "pending" | "settled" | "unknown";
30
38
  /**
31
39
  * Which key paid, as an irreversible digest. Plan §20.3 requires
@@ -69,6 +77,8 @@ export interface SessionMetadata {
69
77
  prompt_tokens: number;
70
78
  completion_tokens: number;
71
79
  estimated_charge_micro_usd: string;
80
+ /** F-012 · How many requests the estimate above does not include. */
81
+ unpriced_requests?: number;
72
82
  }
73
83
  export declare class SessionStoreError extends Error {
74
84
  }
@@ -126,6 +136,16 @@ export interface ReadSession {
126
136
  readonly events: readonly StoredEvent[];
127
137
  /** Set when the last line was incomplete, which a crash mid-append leaves. */
128
138
  readonly truncatedTail: boolean;
139
+ /**
140
+ * F-016 · Line numbers, 1-based, that could not be parsed - not counting the
141
+ * truncated tail above, which has its own well-understood cause.
142
+ *
143
+ * A damaged line in the *middle* of a file is a different thing entirely: it
144
+ * means disk corruption or interleaved writes, and history is missing from
145
+ * the middle of the record. Skipping those in silence let `sessions show`
146
+ * and resume present an incomplete conversation as a complete one.
147
+ */
148
+ readonly corruptLines: readonly number[];
129
149
  }
130
150
  /**
131
151
  * Read a session back.
@@ -137,5 +157,24 @@ export interface ReadSession {
137
157
  export declare function readSession(directory: string): ReadSession;
138
158
  /** Session directories present on disk, newest first by mtime of metadata. */
139
159
  export declare function listSessionDirectories(root: string): string[];
140
- export declare function deleteSession(directory: string): void;
160
+ /**
161
+ * F-002 · What actually happened when a session was deleted.
162
+ *
163
+ * The previous version swallowed every `unlink`/`rmdir` failure and returned
164
+ * nothing, so the caller printed "deleted <id>" whether or not anything went.
165
+ * A read-only file, a permission problem or another process holding the
166
+ * directory all produced a cheerful success and a session still on disk.
167
+ *
168
+ * Errors are still not thrown - a partial delete should report what is left,
169
+ * not abort halfway - but they are now returned, and `removed` is the truth.
170
+ */
171
+ export interface DeleteResult {
172
+ readonly removed: boolean;
173
+ /** Paths that could not be removed, with the reason. */
174
+ readonly failures: readonly {
175
+ path: string;
176
+ reason: string;
177
+ }[];
178
+ }
179
+ export declare function deleteSession(directory: string): DeleteResult;
141
180
  //# sourceMappingURL=store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sessions/store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sessions/store.ts"],"names":[],"mappings":"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"}
@@ -11,7 +11,7 @@
11
11
  // - Nothing written here may carry a credential. Everything goes through
12
12
  // `redact()` on the way to disk - the forbidden list in §20.5 covers what
13
13
  // the CLI holds, not a password the user typed into a prompt.
14
- import { appendFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
14
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { redactDeep } from "../security/redact.js";
17
17
  export const SCHEMA_VERSION = 1;
@@ -150,8 +150,16 @@ export class SessionWriter {
150
150
  this.meta.prompt_tokens += event.prompt_tokens;
151
151
  this.meta.completion_tokens += event.completion_tokens;
152
152
  // Money stays integral: strings in, BigInt to add, string out.
153
- this.meta.estimated_charge_micro_usd = (BigInt(this.meta.estimated_charge_micro_usd) +
154
- BigInt(event.estimated_charge_micro_usd)).toString();
153
+ // F-012 · An unpriced request adds nothing rather than adding zero, and
154
+ // the count below records that the total is short - so a reader can see
155
+ // the difference between "free" and "we could not work it out".
156
+ if (event.estimated_charge_micro_usd === undefined) {
157
+ this.meta.unpriced_requests = (this.meta.unpriced_requests ?? 0) + 1;
158
+ }
159
+ else {
160
+ this.meta.estimated_charge_micro_usd = (BigInt(this.meta.estimated_charge_micro_usd) +
161
+ BigInt(event.estimated_charge_micro_usd)).toString();
162
+ }
155
163
  }
156
164
  this.meta.updated_at = this.now().toISOString();
157
165
  this.writeMetadata();
@@ -212,23 +220,27 @@ export function readSession(directory) {
212
220
  raw = readFileSync(join(directory, "events.jsonl"), "utf8");
213
221
  }
214
222
  catch {
215
- return { metadata, events: [], truncatedTail: false };
223
+ return { metadata, events: [], truncatedTail: false, corruptLines: [] };
216
224
  }
217
225
  const lines = raw.split("\n");
218
226
  const trailing = lines.pop();
219
227
  const truncatedTail = trailing !== undefined && trailing.length > 0;
220
228
  const events = [];
221
- for (const line of lines) {
229
+ const corruptLines = [];
230
+ for (const [index, line] of lines.entries()) {
222
231
  if (line.length === 0)
223
232
  continue;
224
233
  try {
225
234
  events.push(JSON.parse(line));
226
235
  }
227
236
  catch {
228
- // One unreadable line does not invalidate the rest of the transcript.
237
+ // One unreadable line does not invalidate the rest of the transcript,
238
+ // but it is reported: the reader is entitled to know the record has a
239
+ // hole in it before deciding to continue the conversation.
240
+ corruptLines.push(index + 1);
229
241
  }
230
242
  }
231
- return { metadata, events, truncatedTail };
243
+ return { metadata, events, truncatedTail, corruptLines };
232
244
  }
233
245
  /** Session directories present on disk, newest first by mtime of metadata. */
234
246
  export function listSessionDirectories(root) {
@@ -246,27 +258,39 @@ export function listSessionDirectories(root) {
246
258
  }
247
259
  export function deleteSession(directory) {
248
260
  assertNotLink(directory);
249
- for (const name of ["events.jsonl", "metadata.json"]) {
261
+ const failures = [];
262
+ const remove = (path, unlink) => {
250
263
  try {
251
- unlinkSync(join(directory, name));
264
+ unlink(path);
252
265
  }
253
- catch {
254
- // Already gone.
266
+ catch (error) {
267
+ const code = error.code;
268
+ // Already gone is the goal, not a failure.
269
+ if (code === "ENOENT")
270
+ return;
271
+ failures.push({
272
+ path,
273
+ reason: error instanceof Error ? error.message : String(error),
274
+ });
255
275
  }
256
- }
276
+ };
277
+ let entries = [];
257
278
  try {
258
- // Anything else in there (a stale temp file) goes too, then the directory.
259
- for (const entry of readdirSync(directory)) {
260
- unlinkSync(join(directory, entry));
261
- }
279
+ entries = readdirSync(directory);
262
280
  }
263
- catch {
264
- // Nothing left to remove.
265
- }
266
- try {
267
- rmdirSync(directory);
268
- }
269
- catch {
270
- // Leave it; listing filters on metadata anyway.
281
+ catch (error) {
282
+ const code = error.code;
283
+ if (code !== "ENOENT") {
284
+ failures.push({
285
+ path: directory,
286
+ reason: error instanceof Error ? error.message : String(error),
287
+ });
288
+ }
271
289
  }
290
+ for (const entry of entries)
291
+ remove(join(directory, entry), unlinkSync);
292
+ remove(directory, rmdirSync);
293
+ // The only claim worth making: is it actually gone?
294
+ const removed = !existsSync(directory);
295
+ return { removed, failures };
272
296
  }
@@ -2,6 +2,12 @@ import type { StoredEvent } from "./store.js";
2
2
  export type SettlementStatus = "settled" | "pending" | "unknown";
3
3
  export interface UsageTotals {
4
4
  readonly modelRequests: number;
5
+ /**
6
+ * F-012 · Requests whose local estimate could not be computed. Counted so a
7
+ * caller can say the estimate is partial instead of presenting a total that
8
+ * quietly omits them.
9
+ */
10
+ readonly unpricedRequests: number;
5
11
  readonly promptTokens: number;
6
12
  readonly completionTokens: number;
7
13
  readonly estimatedMicroUsd: bigint;
@@ -1 +1 @@
1
- {"version":3,"file":"usage.d.ts","sourceRoot":"","sources":["../../src/sessions/usage.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;AAEjE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,gEAAgE;IAChE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,CAAC;IAC1E,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,WAAW,GAAG;QAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,CAAC;CAC3E;AA2FD,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,GAAG,cAAc,CA6D7E;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAQrE"}
1
+ {"version":3,"file":"usage.d.ts","sourceRoot":"","sources":["../../src/sessions/usage.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;AAEjE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,gEAAgE;IAChE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,CAAC;IAC1E,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,WAAW,GAAG;QAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,CAAC;CAC3E;AAoGD,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,GAAG,cAAc,CA6D7E;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAcrE"}
@@ -14,6 +14,7 @@
14
14
  // final charge.
15
15
  const ZERO = {
16
16
  modelRequests: 0,
17
+ unpricedRequests: 0,
17
18
  promptTokens: 0,
18
19
  completionTokens: 0,
19
20
  estimatedMicroUsd: 0n,
@@ -42,6 +43,8 @@ function add(base, event, settledMicro) {
42
43
  const status = statusOf(event, settledMicro !== undefined);
43
44
  return {
44
45
  modelRequests: base.modelRequests + 1,
46
+ unpricedRequests: base.unpricedRequests +
47
+ (isPriced(event.estimated_charge_micro_usd) ? 0 : 1),
45
48
  promptTokens: base.promptTokens + count(event.prompt_tokens),
46
49
  completionTokens: base.completionTokens + count(event.completion_tokens),
47
50
  estimatedMicroUsd: base.estimatedMicroUsd + micro(event.estimated_charge_micro_usd),
@@ -76,6 +79,10 @@ function settlementsIn(events) {
76
79
  function count(value) {
77
80
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
78
81
  }
82
+ /** Was this request priced at all? Absent is not the same as zero. */
83
+ function isPriced(value) {
84
+ return typeof value === "string" && /^-?\d+$/.test(value);
85
+ }
79
86
  /** Money is integral end to end; a malformed figure counts as zero, not NaN. */
80
87
  function micro(value) {
81
88
  if (typeof value !== "string" || !/^-?\d+$/.test(value))
@@ -151,7 +158,11 @@ export function settledCaveat(totals) {
151
158
  parts.push(`${pending} not settled yet`);
152
159
  if (unknown > 0)
153
160
  parts.push(`${unknown} with no request id, so unknowable`);
161
+ if (totals.unpricedRequests > 0) {
162
+ parts.push(`${totals.unpricedRequests} could not be priced locally, so the estimate is short`);
163
+ }
154
164
  if (parts.length === 0)
155
165
  return undefined;
156
- return `${parts.join(", ")}; the estimate covers all ${totals.modelRequests}`;
166
+ const covered = totals.modelRequests - totals.unpricedRequests;
167
+ return `${parts.join(", ")}; the estimate covers ${covered} of ${totals.modelRequests}`;
157
168
  }
@@ -1 +1 @@
1
- {"version":3,"file":"agent-surface.d.ts","sourceRoot":"","sources":["../../src/tui/agent-surface.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,qBAAqB,CAAC;AAI7B,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEhE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,YAAY,EAAE,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,IAAI,CACpB,cAAc,EACd,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAC1D,CAAC;IACF;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,WAAW,GAAG,SAAS,CAAC;IACnD,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,CAAC,EAAE,eAAe,CAAC;IACpC,yDAAyD;IACzD,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,UAAU,CAAC;CAClC;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA+B7B;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,cAAc,CAAC,CA8DzB;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,cAAc,EACvB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAgBR"}
1
+ {"version":3,"file":"agent-surface.d.ts","sourceRoot":"","sources":["../../src/tui/agent-surface.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,qBAAqB,CAAC;AAI7B,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAGhE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,YAAY,EAAE,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,IAAI,CACpB,cAAc,EACd,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAC1D,CAAC;IACF;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,WAAW,GAAG,SAAS,CAAC;IACnD,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,CAAC,EAAE,eAAe,CAAC;IACpC,yDAAyD;IACzD,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,UAAU,CAAC;CAClC;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA+B7B;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,cAAc,CAAC,CA4EzB;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,cAAc,EACvB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAgBR"}
@@ -10,6 +10,7 @@ import { formatUsd } from "../output/money.js";
10
10
  import { tuiApprover } from "./approval.js";
11
11
  import { palette } from "./theme.js";
12
12
  import { renderCard, ToolCardLog } from "./tool-card.js";
13
+ import { stripAnsi } from "../agent/command-policy.js";
13
14
  /**
14
15
  * Read one line of a goal or slash command.
15
16
  *
@@ -67,13 +68,23 @@ export async function runAgentSurface(options) {
67
68
  // when someone is deciding whether to press Ctrl+C.
68
69
  options.stdout.write(dim(` rate limited - waiting ${Math.round(event.waitMs / 1000)}s\n`));
69
70
  }
71
+ if (event.type === "compacted") {
72
+ // Said out loud: from here the model can no longer see those results,
73
+ // which is the sort of thing that otherwise looks like it forgot.
74
+ // The saved transcript still has them - only what we send got shorter.
75
+ options.stdout.write(dim(` context near the limit (${event.promptTokens} tokens) - dropped ` +
76
+ `${event.exchanges} earlier tool exchange(s) from what is sent\n`));
77
+ }
70
78
  };
71
79
  const sessionOptions = {
72
80
  ...options.session,
73
- stdout: (text) => void options.stdout.write(text),
81
+ // F-010 · The model's reply passes through here. The TUI is a terminal by
82
+ // construction, so an OSC or CSI in the answer would be executed rather
83
+ // than shown - retitling the window or clearing what came before.
84
+ stdout: (text) => void options.stdout.write(stripAnsi(text)),
74
85
  // The line session sends progress to stderr so it survives redirection.
75
86
  // Here everything is on one terminal, so it goes to the same place.
76
- stderr: (text) => void options.stdout.write(dim(text)),
87
+ stderr: (text) => void options.stdout.write(dim(stripAnsi(text))),
77
88
  approver: tuiApprover(io),
78
89
  readLine: async () => {
79
90
  // Checkpoint between goals rather than per event: the listing only needs
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/tui/app.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAaxD,OAAO,EAAwB,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAcrE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,UAAU,GAAG;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACzE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC3D,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,wEAAwE;IACxE,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAED,wBAAsB,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,CAiJnE"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/tui/app.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAcxD,OAAO,EAAwB,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAiBrE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,UAAU,GAAG;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACzE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC3D,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,wEAAwE;IACxE,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAED,wBAAsB,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,CAgKnE"}