@mafdet/cli 0.4.1 → 0.4.2

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 (72) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +61 -0
  3. package/README.zh-CN.md +52 -0
  4. package/dist/agent/loop.d.ts +14 -0
  5. package/dist/agent/loop.d.ts.map +1 -1
  6. package/dist/agent/loop.js +16 -3
  7. package/dist/agent/session.d.ts +5 -0
  8. package/dist/agent/session.d.ts.map +1 -1
  9. package/dist/agent/session.js +1 -1
  10. package/dist/agent/tools/read-only.d.ts.map +1 -1
  11. package/dist/agent/tools/read-only.js +6 -0
  12. package/dist/api/account.d.ts +43 -0
  13. package/dist/api/account.d.ts.map +1 -1
  14. package/dist/api/account.js +40 -0
  15. package/dist/commands/sessions.d.ts +5 -0
  16. package/dist/commands/sessions.d.ts.map +1 -0
  17. package/dist/commands/sessions.js +379 -0
  18. package/dist/config/paths.d.ts +7 -0
  19. package/dist/config/paths.d.ts.map +1 -1
  20. package/dist/config/paths.js +24 -0
  21. package/dist/filesystem/protected-dirs.d.ts +26 -0
  22. package/dist/filesystem/protected-dirs.d.ts.map +1 -0
  23. package/dist/filesystem/protected-dirs.js +78 -0
  24. package/dist/filesystem/workspace.d.ts +12 -1
  25. package/dist/filesystem/workspace.d.ts.map +1 -1
  26. package/dist/filesystem/workspace.js +24 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +8 -0
  29. package/dist/security/redact.d.ts +26 -3
  30. package/dist/security/redact.d.ts.map +1 -1
  31. package/dist/security/redact.js +48 -3
  32. package/dist/sessions/index-file.d.ts +38 -0
  33. package/dist/sessions/index-file.d.ts.map +1 -0
  34. package/dist/sessions/index-file.js +174 -0
  35. package/dist/sessions/lock.d.ts +39 -0
  36. package/dist/sessions/lock.d.ts.map +1 -0
  37. package/dist/sessions/lock.js +134 -0
  38. package/dist/sessions/open.d.ts +45 -0
  39. package/dist/sessions/open.d.ts.map +1 -0
  40. package/dist/sessions/open.js +96 -0
  41. package/dist/sessions/policy.d.ts +32 -0
  42. package/dist/sessions/policy.d.ts.map +1 -0
  43. package/dist/sessions/policy.js +57 -0
  44. package/dist/sessions/reconcile.d.ts +49 -0
  45. package/dist/sessions/reconcile.d.ts.map +1 -0
  46. package/dist/sessions/reconcile.js +115 -0
  47. package/dist/sessions/recorder.d.ts +42 -0
  48. package/dist/sessions/recorder.d.ts.map +1 -0
  49. package/dist/sessions/recorder.js +147 -0
  50. package/dist/sessions/resume.d.ts +67 -0
  51. package/dist/sessions/resume.d.ts.map +1 -0
  52. package/dist/sessions/resume.js +100 -0
  53. package/dist/sessions/store.d.ts +141 -0
  54. package/dist/sessions/store.d.ts.map +1 -0
  55. package/dist/sessions/store.js +272 -0
  56. package/dist/sessions/usage.d.ts +35 -0
  57. package/dist/sessions/usage.d.ts.map +1 -0
  58. package/dist/sessions/usage.js +157 -0
  59. package/dist/tui/agent-surface.d.ts +3 -0
  60. package/dist/tui/agent-surface.d.ts.map +1 -1
  61. package/dist/tui/agent-surface.js +16 -1
  62. package/dist/tui/app.d.ts.map +1 -1
  63. package/dist/tui/app.js +18 -1
  64. package/dist/tui/route.d.ts.map +1 -1
  65. package/dist/tui/route.js +19 -2
  66. package/dist/tui/session-centre.d.ts +15 -0
  67. package/dist/tui/session-centre.d.ts.map +1 -0
  68. package/dist/tui/session-centre.js +59 -0
  69. package/dist/tui/start-agent.d.ts +9 -0
  70. package/dist/tui/start-agent.d.ts.map +1 -1
  71. package/dist/tui/start-agent.js +122 -0
  72. package/package.json +1 -1
@@ -0,0 +1,157 @@
1
+ // CLI-025 · Adding up what a session used.
2
+ //
3
+ // This module does arithmetic and nothing else. In particular it never prices
4
+ // anything: it sums the `estimated_charge_micro_usd` each request already
5
+ // recorded, which the agent loop computed with the gateway's own arithmetic.
6
+ // A second price implementation here would drift from the first one, and the
7
+ // first one is the one that was reconciled against the ledger.
8
+ //
9
+ // The four states in §20.7 are the other half. `estimated` is what we worked
10
+ // out locally; `settled` is what the server's ledger says; `pending` means the
11
+ // answer exists but has not been fetched; `unknown` means we never got a
12
+ // request id and so can never ask. They are counted separately because
13
+ // collapsing them would let a display show an estimate as though it were the
14
+ // final charge.
15
+ const ZERO = {
16
+ modelRequests: 0,
17
+ promptTokens: 0,
18
+ completionTokens: 0,
19
+ estimatedMicroUsd: 0n,
20
+ settledMicroUsd: 0n,
21
+ bySettlement: { settled: 0, pending: 0, unknown: 0 },
22
+ };
23
+ /**
24
+ * A request with no id can never be reconciled, whatever it says.
25
+ *
26
+ * CLI-001 found that a cancelled or timed-out request may not return one, and
27
+ * calling such a request `pending` would promise an answer that will never
28
+ * arrive.
29
+ *
30
+ * Note what is *not* consulted: the `settlement_status` written on the usage
31
+ * line itself. That records what was known at the moment of the request, which
32
+ * is always "pending" - only a `settlement` event, appended by CLI-027 after
33
+ * the ledger was actually asked, can make a request settled.
34
+ */
35
+ function statusOf(event, settled) {
36
+ if (typeof event.request_id !== "string" || event.request_id.length === 0) {
37
+ return "unknown";
38
+ }
39
+ return settled ? "settled" : "pending";
40
+ }
41
+ function add(base, event, settledMicro) {
42
+ const status = statusOf(event, settledMicro !== undefined);
43
+ return {
44
+ modelRequests: base.modelRequests + 1,
45
+ promptTokens: base.promptTokens + count(event.prompt_tokens),
46
+ completionTokens: base.completionTokens + count(event.completion_tokens),
47
+ estimatedMicroUsd: base.estimatedMicroUsd + micro(event.estimated_charge_micro_usd),
48
+ settledMicroUsd: status === "settled"
49
+ ? base.settledMicroUsd + (settledMicro ?? 0n)
50
+ : base.settledMicroUsd,
51
+ bySettlement: {
52
+ ...base.bySettlement,
53
+ [status]: base.bySettlement[status] + 1,
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * The settled amount per request id, from the `settlement` events.
59
+ *
60
+ * Read in a first pass because a settlement is appended long after the request
61
+ * it settles. Later wins: re-running `--refresh` after the ledger corrected a
62
+ * charge should show the correction, not the first answer we happened to get.
63
+ */
64
+ function settlementsIn(events) {
65
+ const found = new Map();
66
+ for (const event of events) {
67
+ if (event.type !== "settlement")
68
+ continue;
69
+ const id = event.request_id;
70
+ if (typeof id !== "string" || id.length === 0)
71
+ continue;
72
+ found.set(id, micro(event.settled_charge_micro_usd));
73
+ }
74
+ return found;
75
+ }
76
+ function count(value) {
77
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
78
+ }
79
+ /** Money is integral end to end; a malformed figure counts as zero, not NaN. */
80
+ function micro(value) {
81
+ if (typeof value !== "string" || !/^-?\d+$/.test(value))
82
+ return 0n;
83
+ return BigInt(value);
84
+ }
85
+ export function summariseUsage(events) {
86
+ let totals = ZERO;
87
+ const byModel = new Map();
88
+ const byKey = new Map();
89
+ const settlements = settlementsIn(events);
90
+ let userMessages = 0;
91
+ let toolRuns = 0;
92
+ let toolRunsRefused = 0;
93
+ let rateLimitHits = 0;
94
+ let rateLimitWaitMs = 0;
95
+ for (const event of events) {
96
+ if (event.type === "user_message") {
97
+ userMessages += 1;
98
+ }
99
+ else if (event.type === "tool_result") {
100
+ toolRuns += 1;
101
+ if (event.status === "refused")
102
+ toolRunsRefused += 1;
103
+ }
104
+ else if (event.type === "rate_limited") {
105
+ rateLimitHits += 1;
106
+ rateLimitWaitMs += count(event.wait_ms);
107
+ }
108
+ else if (event.type === "usage") {
109
+ const usage = event;
110
+ const settled = typeof usage.request_id === "string"
111
+ ? settlements.get(usage.request_id)
112
+ : undefined;
113
+ totals = add(totals, usage, settled);
114
+ const model = typeof usage.model_id === "string" ? usage.model_id : "(unknown model)";
115
+ byModel.set(model, add(byModel.get(model) ?? ZERO, usage, settled));
116
+ // Grouped only when the key is known: inventing a bucket for requests
117
+ // recorded before per-key attribution existed would misattribute them.
118
+ if (typeof usage.api_key_ref === "string" &&
119
+ usage.api_key_ref.length > 0) {
120
+ byKey.set(usage.api_key_ref, add(byKey.get(usage.api_key_ref) ?? ZERO, usage, settled));
121
+ }
122
+ }
123
+ }
124
+ return {
125
+ ...totals,
126
+ userMessages,
127
+ toolRuns,
128
+ toolRunsRefused,
129
+ rateLimitHits,
130
+ rateLimitWaitMs,
131
+ byModel: [...byModel.entries()]
132
+ .map(([modelId, t]) => ({ modelId, ...t }))
133
+ .sort((a, b) => b.modelRequests - a.modelRequests),
134
+ byKey: [...byKey.entries()]
135
+ .map(([apiKeyRef, t]) => ({ apiKeyRef, ...t }))
136
+ .sort((a, b) => b.modelRequests - a.modelRequests),
137
+ };
138
+ }
139
+ /**
140
+ * How to describe the settled column without overclaiming.
141
+ *
142
+ * Returns undefined when there is nothing honest to say, so a caller renders
143
+ * no figure rather than a zero - a zero reads as "it was free".
144
+ */
145
+ export function settledCaveat(totals) {
146
+ const { settled, pending, unknown } = totals.bySettlement;
147
+ if (settled === 0 && pending === 0 && unknown === 0)
148
+ return undefined;
149
+ const parts = [];
150
+ if (pending > 0)
151
+ parts.push(`${pending} not settled yet`);
152
+ if (unknown > 0)
153
+ parts.push(`${unknown} with no request id, so unknowable`);
154
+ if (parts.length === 0)
155
+ return undefined;
156
+ return `${parts.join(", ")}; the estimate covers all ${totals.modelRequests}`;
157
+ }
@@ -1,5 +1,6 @@
1
1
  import { runSession, type SessionOptions, type SessionSummary } from "../agent/session.js";
2
2
  import { type TerminalCapabilities } from "./theme.js";
3
+ import type { SessionRecorder } from "../sessions/recorder.js";
3
4
  import type { WidgetIo } from "./widgets.js";
4
5
  export interface AgentSurfaceOptions {
5
6
  readonly io: WidgetIo;
@@ -16,6 +17,8 @@ export interface AgentSurfaceOptions {
16
17
  * the signal to whatever controller existed at startup.
17
18
  */
18
19
  readonly getSignal?: () => AbortSignal | undefined;
20
+ /** Present when this run is being recorded; absent for --no-history. */
21
+ readonly recorder?: SessionRecorder;
19
22
  /** Injected by the tests; production uses runSession. */
20
23
  readonly run?: typeof runSession;
21
24
  }
@@ -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,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,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,CA6CzB;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;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"}
@@ -56,6 +56,7 @@ export async function runAgentSurface(options) {
56
56
  const cards = new ToolCardLog();
57
57
  const run = options.run ?? runSession;
58
58
  const onEvent = (event) => {
59
+ options.recorder?.observe(event);
59
60
  const changed = cards.apply(event);
60
61
  if (changed !== undefined) {
61
62
  options.stdout.write(renderCard(changed, capabilities));
@@ -74,7 +75,20 @@ export async function runAgentSurface(options) {
74
75
  // Here everything is on one terminal, so it goes to the same place.
75
76
  stderr: (text) => void options.stdout.write(dim(text)),
76
77
  approver: tuiApprover(io),
77
- readLine: () => promptLine(io, "> "),
78
+ readLine: async () => {
79
+ // Checkpoint between goals rather than per event: the listing only needs
80
+ // to be current when someone might go and look at it.
81
+ options.recorder?.checkpoint();
82
+ const line = await promptLine(io, "> ");
83
+ // The user's own words never appear in the agent event stream, so they
84
+ // are recorded here. Slash commands are control, not conversation.
85
+ if (line !== undefined &&
86
+ line.trim().length > 0 &&
87
+ !line.startsWith("/")) {
88
+ options.recorder?.goal(line);
89
+ }
90
+ return line;
91
+ },
78
92
  onEvent,
79
93
  };
80
94
  // Installed after the spread, on purpose - see getSignal above.
@@ -86,6 +100,7 @@ export async function runAgentSurface(options) {
86
100
  });
87
101
  }
88
102
  const summary = await run(sessionOptions);
103
+ options.recorder?.close();
89
104
  options.stdout.write(renderSummary(summary, capabilities));
90
105
  return summary;
91
106
  }
@@ -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;AAQrE,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,CAgInE"}
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"}
package/dist/tui/app.js CHANGED
@@ -16,7 +16,9 @@ import { palette, detectCapabilities, } from "./theme.js";
16
16
  import { followResize } from "./resize.js";
17
17
  import { installTerminalGuard } from "./terminal.js";
18
18
  import { inspectWorkspace, renderWorkspaceSummary, } from "./workspace-summary.js";
19
- import { parsePermissions, startAgent } from "./start-agent.js";
19
+ import { parsePermissions, parseSessionOptions, startAgent, } from "./start-agent.js";
20
+ import { sessionsDir } from "../config/paths.js";
21
+ import { chooseSession } from "./session-centre.js";
20
22
  import { select } from "./widgets.js";
21
23
  export async function runTui(options) {
22
24
  // Live, not a snapshot: a window resized mid-session must not keep laying
@@ -104,6 +106,17 @@ export async function runTui(options) {
104
106
  if (chosen === undefined)
105
107
  return ExitCode.Cancelled;
106
108
  options.stdout.write(renderWorkspaceSummary(inspectWorkspace(options.workspace, existsSync), capabilities));
109
+ // Offered only when there is something to resume, and only when the user
110
+ // did not already say what they wanted on the command line.
111
+ const sessionOptions = parseSessionOptions(options.argv);
112
+ let resumeId = sessionOptions.resumeId;
113
+ if (resumeId === undefined && !sessionOptions.noHistory) {
114
+ const choice = await chooseSession(io, sessionsDir(options.commandIo.env ?? process.env));
115
+ if (choice.kind === "cancelled")
116
+ return ExitCode.Cancelled;
117
+ if (choice.kind === "resume")
118
+ resumeId = choice.sessionId;
119
+ }
107
120
  return await startAgent({
108
121
  io,
109
122
  capabilities,
@@ -113,6 +126,10 @@ export async function runTui(options) {
113
126
  model: chosen,
114
127
  workspace: options.workspace,
115
128
  permissions: parsePermissions(options.argv),
129
+ session: {
130
+ ...sessionOptions,
131
+ ...(resumeId === undefined ? {} : { resumeId }),
132
+ },
116
133
  getSignal: () => {
117
134
  inFlight ??= new AbortController();
118
135
  return inFlight.signal;
@@ -1 +1 @@
1
- {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/tui/route.ts"],"names":[],"mappings":"AAOA,0EAA0E;AAC1E,MAAM,MAAM,UAAU,GAClB,qBAAqB,GACrB,aAAa,GACb,eAAe,GACf,gBAAgB,GAChB,WAAW,GACX,IAAI,CAAC;AAET,MAAM,MAAM,aAAa;AACvB,gDAAgD;AAC9C;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;CAAE;AAC1B,4EAA4E;GAC1E;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE;AAC9B,yEAAyE;GACvE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,cAAc,EAAE,UAAU,CAAA;CAAE,CAAC;AAEpE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;CAC5D;AAyBD,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,aAAa,CAoBlE;AAED,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAezD"}
1
+ {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/tui/route.ts"],"names":[],"mappings":"AAOA,0EAA0E;AAC1E,MAAM,MAAM,UAAU,GAClB,qBAAqB,GACrB,aAAa,GACb,eAAe,GACf,gBAAgB,GAChB,WAAW,GACX,IAAI,CAAC;AAET,MAAM,MAAM,aAAa;AACvB,gDAAgD;AAC9C;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;CAAE;AAC1B,4EAA4E;GAC1E;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE;AAC9B,yEAAyE;GACvE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,cAAc,EAAE,UAAU,CAAA;CAAE,CAAC;AAEpE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;CAC5D;AA0CD,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,aAAa,CAoBlE;AAED,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAezD"}
package/dist/tui/route.js CHANGED
@@ -9,9 +9,26 @@
9
9
  * `mafdet` so that `mafdet --json` can never be answered with a drawing.
10
10
  */
11
11
  const MACHINE_FLAGS = new Set(["--json", "--quiet", "-q"]);
12
- /** Treated as a command rather than a bare invocation. */
12
+ /** Flags whose next argument is a value, not a command. */
13
+ const FLAGS_TAKING_A_VALUE = new Set([
14
+ "--title",
15
+ "--allow-command",
16
+ "--resume",
17
+ ]);
13
18
  function hasCommand(argv) {
14
- return argv.some((arg) => !arg.startsWith("-"));
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const arg = argv[i];
21
+ if (arg.startsWith("-")) {
22
+ // `mafdet --title "Fix login"` is still a bare invocation: the value
23
+ // belongs to the flag, and reading it as a command would send the user
24
+ // to "unknown command 'Fix login'".
25
+ if (FLAGS_TAKING_A_VALUE.has(arg))
26
+ i += 1;
27
+ continue;
28
+ }
29
+ return true;
30
+ }
31
+ return false;
15
32
  }
16
33
  /**
17
34
  * CI systems attach a pipe often enough that the TTY checks usually catch them,
@@ -0,0 +1,15 @@
1
+ import { type IndexEntry } from "../sessions/index-file.js";
2
+ import { type TerminalCapabilities } from "./theme.js";
3
+ import { type WidgetIo } from "./widgets.js";
4
+ export type SessionChoice = {
5
+ readonly kind: "new";
6
+ } | {
7
+ readonly kind: "resume";
8
+ readonly sessionId: string;
9
+ } | {
10
+ readonly kind: "cancelled";
11
+ };
12
+ export declare function recentSessions(root: string): readonly IndexEntry[];
13
+ export declare function describeSession(entry: IndexEntry, capabilities: TerminalCapabilities): string;
14
+ export declare function chooseSession(io: WidgetIo, root: string): Promise<SessionChoice>;
15
+ //# sourceMappingURL=session-centre.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-centre.d.ts","sourceRoot":"","sources":["../../src/tui/session-centre.ts"],"names":[],"mappings":"AAOA,OAAO,EAAa,KAAK,UAAU,EAAE,MAAM,2BAA2B,CAAC;AACvE,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAU,KAAK,QAAQ,EAAE,MAAM,cAAc,CAAC;AAKrD,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;CAAE,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAE,CAAC;AAEnC,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,UAAU,EAAE,CAQlE;AAED,wBAAgB,eAAe,CAC7B,KAAK,EAAE,UAAU,EACjB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAQR;AAOD,wBAAsB,aAAa,CACjC,EAAE,EAAE,QAAQ,EACZ,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,aAAa,CAAC,CAoBxB"}
@@ -0,0 +1,59 @@
1
+ // CLI-024 · New conversation, or one of the recent ones.
2
+ //
3
+ // Offered only when there is something to resume: a menu whose only entry is
4
+ // "New conversation" is a keystroke charged for nothing. Everything here reads
5
+ // the local index and sends no request, so browsing costs nothing (plan 20.2).
6
+ import { formatUsd } from "../output/money.js";
7
+ import { loadIndex } from "../sessions/index-file.js";
8
+ import { palette } from "./theme.js";
9
+ import { select } from "./widgets.js";
10
+ /** How many recent conversations to offer before it stops being a list. */
11
+ const RECENT_LIMIT = 8;
12
+ export function recentSessions(root) {
13
+ try {
14
+ return loadIndex(root).sessions.slice(0, RECENT_LIMIT);
15
+ }
16
+ catch {
17
+ // A store we cannot read is a store with nothing to offer, not a failure
18
+ // that should stop someone starting a new conversation.
19
+ return [];
20
+ }
21
+ }
22
+ export function describeSession(entry, capabilities) {
23
+ const parts = [
24
+ entry.model_id,
25
+ workspaceName(entry.workspace),
26
+ `${entry.model_requests} request(s)`,
27
+ `~${formatUsd(entry.estimated_charge_micro_usd)}`,
28
+ ];
29
+ return palette(capabilities).dim(parts.join(" - "));
30
+ }
31
+ function workspaceName(workspace) {
32
+ const name = workspace.split(/[\\/]/).filter(Boolean).at(-1);
33
+ return name === undefined || name.length === 0 ? workspace : name;
34
+ }
35
+ export async function chooseSession(io, root) {
36
+ const recent = recentSessions(root);
37
+ if (recent.length === 0)
38
+ return { kind: "new" };
39
+ const answer = await select(io, {
40
+ title: "What would you like to do?",
41
+ items: [
42
+ { value: "", label: "New conversation" },
43
+ ...recent.map((entry) => ({
44
+ value: entry.session_id,
45
+ label: entry.title,
46
+ detail: stripStyles(describeSession(entry, io.capabilities)),
47
+ })),
48
+ ],
49
+ });
50
+ if (answer.kind === "cancelled")
51
+ return { kind: "cancelled" };
52
+ return answer.value === ""
53
+ ? { kind: "new" }
54
+ : { kind: "resume", sessionId: answer.value };
55
+ }
56
+ /** The select widget dims details itself; doubling it up reads as a bug. */
57
+ function stripStyles(text) {
58
+ return text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
59
+ }
@@ -3,6 +3,12 @@ import { type CommandIo } from "../commands/runtime.js";
3
3
  import { ExitCode } from "../output/exit-codes.js";
4
4
  import { type TerminalCapabilities } from "./theme.js";
5
5
  import type { WidgetIo } from "./widgets.js";
6
+ export interface SessionOptions {
7
+ readonly noHistory: boolean;
8
+ readonly title?: string;
9
+ /** Continue this session rather than starting a new one. */
10
+ readonly resumeId?: string;
11
+ }
6
12
  export interface AgentPermissions {
7
13
  readonly allowWrite: boolean;
8
14
  readonly allowedCommands: readonly string[];
@@ -13,6 +19,8 @@ export interface AgentPermissions {
13
19
  * The same spellings the agent command uses, so there is one grammar for
14
20
  * granting these and no second one to keep in step.
15
21
  */
22
+ /** `--no-history` and `--title`, read off the bare invocation. */
23
+ export declare function parseSessionOptions(argv: readonly string[]): SessionOptions;
16
24
  export declare function parsePermissions(argv: readonly string[]): AgentPermissions;
17
25
  export interface StartAgentOptions {
18
26
  readonly io: WidgetIo;
@@ -25,6 +33,7 @@ export interface StartAgentOptions {
25
33
  readonly model: ResolvedModel;
26
34
  readonly workspace: string;
27
35
  readonly permissions: AgentPermissions;
36
+ readonly session?: SessionOptions;
28
37
  /**
29
38
  * Read fresh for every goal. A cancel aborts the request in flight and then
30
39
  * swaps in a new controller, so the session survives Ctrl+C instead of
@@ -1 +1 @@
1
- {"version":3,"file":"start-agent.d.ts","sourceRoot":"","sources":["../../src/tui/start-agent.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAEtE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7C;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,gBAAgB,CAe1E;AAED,MAAM,WAAW,iBAAiB;IAChC,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,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,WAAW,GAAG,SAAS,CAAC;CACpD;AAED,wBAAsB,UAAU,CAC9B,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,QAAQ,CAAC,CA0DnB"}
1
+ {"version":3,"file":"start-agent.d.ts","sourceRoot":"","sources":["../../src/tui/start-agent.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAEtE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAcnD,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7C;AAED;;;;;GAKG;AACH,kEAAkE;AAClE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,cAAc,CAqB3E;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,gBAAgB,CAe1E;AAED,MAAM,WAAW,iBAAiB;IAChC,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,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,WAAW,GAAG,SAAS,CAAC;CACpD;AAED,wBAAsB,UAAU,CAC9B,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,QAAQ,CAAC,CAkGnB"}
@@ -9,6 +9,13 @@ import { buildToolset, READ_ONLY_PROMPT, WRITE_PROMPT, } from "../commands/agent
9
9
  import { buildRuntime } from "../commands/runtime.js";
10
10
  import { Workspace } from "../filesystem/workspace.js";
11
11
  import { ExitCode } from "../output/exit-codes.js";
12
+ import { existsSync, writeFileSync } from "node:fs";
13
+ import { sessionsDir } from "../config/paths.js";
14
+ import { keyDigest } from "../security/redact.js";
15
+ import { SessionRecorder } from "../sessions/recorder.js";
16
+ import { SessionWriter } from "../sessions/store.js";
17
+ import { NOTICE_FILE, decideRecording, firstSaveNotice, } from "../sessions/policy.js";
18
+ import { describeResume, openForResume } from "../sessions/open.js";
12
19
  import { runAgentSurface } from "./agent-surface.js";
13
20
  import { palette } from "./theme.js";
14
21
  /**
@@ -17,6 +24,32 @@ import { palette } from "./theme.js";
17
24
  * The same spellings the agent command uses, so there is one grammar for
18
25
  * granting these and no second one to keep in step.
19
26
  */
27
+ /** `--no-history` and `--title`, read off the bare invocation. */
28
+ export function parseSessionOptions(argv) {
29
+ let title;
30
+ for (let i = 0; i < argv.length; i += 1) {
31
+ if (argv[i] !== "--title")
32
+ continue;
33
+ const value = argv[i + 1];
34
+ if (value !== undefined && !value.startsWith("-")) {
35
+ title = value;
36
+ i += 1;
37
+ }
38
+ }
39
+ let resumeId;
40
+ for (let i = 0; i < argv.length; i += 1) {
41
+ if (argv[i] !== "--resume")
42
+ continue;
43
+ const value = argv[i + 1];
44
+ if (value !== undefined && !value.startsWith("-"))
45
+ resumeId = value;
46
+ }
47
+ return {
48
+ noHistory: argv.includes("--no-history"),
49
+ ...(title === undefined ? {} : { title }),
50
+ ...(resumeId === undefined ? {} : { resumeId }),
51
+ };
52
+ }
20
53
  export function parsePermissions(argv) {
21
54
  let allowWrite = false;
22
55
  const allowedCommands = [];
@@ -58,8 +91,37 @@ export async function startAgent(options) {
58
91
  ? ` - commands: ${options.permissions.allowedCommands.join(", ")}`
59
92
  : "") +
60
93
  "\n type a goal, or /help for the session commands\n\n"));
94
+ const opened = options.session?.resumeId
95
+ ? openForResume({
96
+ root: sessionsDir(options.commandIo.env ?? process.env),
97
+ sessionId: options.session.resumeId,
98
+ systemPrompt: mode === "read-only" ? READ_ONLY_PROMPT : WRITE_PROMPT,
99
+ callable: [options.model.modelId],
100
+ agentCapable: options.model.agentEligible
101
+ ? [options.model.modelId]
102
+ : [],
103
+ allowedCommandsThisRun: options.permissions.allowedCommands,
104
+ })
105
+ : undefined;
106
+ if (opened !== undefined && opened.kind !== "opened") {
107
+ options.stdout.write(`\n ${bold("cannot resume that conversation")}: ${opened.kind === "missing"
108
+ ? "no session with that id on this computer"
109
+ : opened.reason}\n\n`);
110
+ return ExitCode.General;
111
+ }
112
+ if (opened !== undefined)
113
+ options.stdout.write(dim(describeResume(opened)));
114
+ const recorder = opened !== undefined
115
+ ? new SessionRecorder({
116
+ writer: opened.writer,
117
+ root: sessionsDir(options.commandIo.env ?? process.env),
118
+ apiKeyRef: keyDigest(options.apiKey),
119
+ onProblem: (message) => void options.stdout.write(dim(` ${message}\n`)),
120
+ })
121
+ : startRecording(options, mode);
61
122
  await runAgentSurface({
62
123
  io: options.io,
124
+ ...(recorder === undefined ? {} : { recorder }),
63
125
  capabilities: options.capabilities,
64
126
  stdout: options.stdout,
65
127
  ...(options.getSignal === undefined
@@ -77,8 +139,68 @@ export async function startAgent(options) {
77
139
  allowedCommands: options.permissions.allowedCommands,
78
140
  },
79
141
  mode,
142
+ // The rebuilt conversation, or nothing for a new session. runSession
143
+ // appends to this array in place, which is what continues the thread.
144
+ ...(opened === undefined ? {} : { history: opened.messages }),
80
145
  allowedCommands: options.permissions.allowedCommands,
81
146
  },
82
147
  });
83
148
  return ExitCode.Ok;
84
149
  }
150
+ /**
151
+ * Open a recorder for this run, or not.
152
+ *
153
+ * Returns undefined for `--no-history`, and - importantly - reaches the
154
+ * filesystem only when it is going to record. A flag that still created an
155
+ * empty directory would be a flag the user was right not to trust.
156
+ */
157
+ function startRecording(options, mode) {
158
+ const decision = decideRecording({
159
+ surface: "interactive",
160
+ noHistory: options.session?.noHistory ?? false,
161
+ save: false,
162
+ });
163
+ if (!decision.record)
164
+ return undefined;
165
+ const env = options.commandIo.env ?? process.env;
166
+ const root = sessionsDir(env);
167
+ try {
168
+ const writer = SessionWriter.create({
169
+ root,
170
+ title: options.session?.title ?? defaultTitle(options.workspace),
171
+ modelId: options.model.modelId,
172
+ workspace: options.workspace,
173
+ mode,
174
+ });
175
+ showNoticeOnce(root, options);
176
+ return new SessionRecorder({
177
+ writer,
178
+ root,
179
+ apiKeyRef: keyDigest(options.apiKey),
180
+ onProblem: (message) => void options.stdout.write(palette(options.capabilities).dim(` ${message}\n`)),
181
+ });
182
+ }
183
+ catch (error) {
184
+ // A store that cannot be opened is worth one line, not a dead session.
185
+ const message = error instanceof Error ? error.message : String(error);
186
+ options.stdout.write(palette(options.capabilities).dim(` not recording this conversation: ${message}\n`));
187
+ return undefined;
188
+ }
189
+ }
190
+ /** The directory name is the most useful thing we know before anyone speaks. */
191
+ function defaultTitle(workspace) {
192
+ const name = workspace.split(/[\\/]/).filter(Boolean).at(-1);
193
+ return name === undefined || name.length === 0 ? "Conversation" : name;
194
+ }
195
+ function showNoticeOnce(root, options) {
196
+ const marker = `${root}/${NOTICE_FILE}`;
197
+ try {
198
+ if (existsSync(marker))
199
+ return;
200
+ options.stdout.write(firstSaveNotice(root));
201
+ writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600 });
202
+ }
203
+ catch {
204
+ // Showing it twice is a much smaller problem than failing to start.
205
+ }
206
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mafdet/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Mafdet AI command line — model calls, account queries, and a permissioned local agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",