@mafdet/cli 0.4.3 → 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 (58) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +15 -5
  3. package/README.zh-CN.md +9 -2
  4. package/dist/agent/context-budget.d.ts +32 -0
  5. package/dist/agent/context-budget.d.ts.map +1 -1
  6. package/dist/agent/context-budget.js +64 -0
  7. package/dist/agent/loop.d.ts +8 -1
  8. package/dist/agent/loop.d.ts.map +1 -1
  9. package/dist/agent/loop.js +25 -13
  10. package/dist/agent/session.d.ts.map +1 -1
  11. package/dist/agent/session.js +7 -1
  12. package/dist/api/http.d.ts +15 -1
  13. package/dist/api/http.d.ts.map +1 -1
  14. package/dist/api/http.js +26 -5
  15. package/dist/api/sse-parser.d.ts +26 -1
  16. package/dist/api/sse-parser.d.ts.map +1 -1
  17. package/dist/api/sse-parser.js +62 -21
  18. package/dist/commands/agent.d.ts.map +1 -1
  19. package/dist/commands/agent.js +76 -0
  20. package/dist/commands/chat.d.ts.map +1 -1
  21. package/dist/commands/chat.js +21 -4
  22. package/dist/commands/config.d.ts.map +1 -1
  23. package/dist/commands/config.js +0 -2
  24. package/dist/commands/runtime.d.ts +6 -0
  25. package/dist/commands/runtime.d.ts.map +1 -1
  26. package/dist/commands/sessions.d.ts.map +1 -1
  27. package/dist/commands/sessions.js +129 -18
  28. package/dist/config/config.d.ts +0 -12
  29. package/dist/config/config.d.ts.map +1 -1
  30. package/dist/config/config.js +31 -6
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +17 -1
  33. package/dist/output/format.d.ts.map +1 -1
  34. package/dist/output/format.js +10 -5
  35. package/dist/output/terminal-safe.d.ts +12 -0
  36. package/dist/output/terminal-safe.d.ts.map +1 -0
  37. package/dist/output/terminal-safe.js +38 -0
  38. package/dist/sessions/open.d.ts.map +1 -1
  39. package/dist/sessions/open.js +26 -2
  40. package/dist/sessions/recorder.d.ts.map +1 -1
  41. package/dist/sessions/recorder.js +6 -1
  42. package/dist/sessions/session-id.d.ts +16 -0
  43. package/dist/sessions/session-id.d.ts.map +1 -0
  44. package/dist/sessions/session-id.js +93 -0
  45. package/dist/sessions/store.d.ts +41 -2
  46. package/dist/sessions/store.d.ts.map +1 -1
  47. package/dist/sessions/store.js +48 -24
  48. package/dist/sessions/usage.d.ts +6 -0
  49. package/dist/sessions/usage.d.ts.map +1 -1
  50. package/dist/sessions/usage.js +12 -1
  51. package/dist/tui/agent-surface.d.ts.map +1 -1
  52. package/dist/tui/agent-surface.js +6 -2
  53. package/dist/tui/app.d.ts.map +1 -1
  54. package/dist/tui/app.js +43 -10
  55. package/dist/tui/start-agent.d.ts +31 -0
  56. package/dist/tui/start-agent.d.ts.map +1 -1
  57. package/dist/tui/start-agent.js +147 -45
  58. package/package.json +1 -1
package/dist/tui/app.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // picker (CLI-020) and the agent surface (CLI-021) attach after onboarding.
7
7
  import { existsSync } from "node:fs";
8
8
  import { ExitCode } from "../output/exit-codes.js";
9
+ import { loadConfig } from "../config/load.js";
9
10
  import { loadCatalogue, saveDefaultModel } from "./adapters/models.js";
10
11
  import { renderBanner } from "./banner.js";
11
12
  import { keyboard } from "./input.js";
@@ -16,11 +17,24 @@ import { palette, detectCapabilities, } from "./theme.js";
16
17
  import { followResize } from "./resize.js";
17
18
  import { installTerminalGuard } from "./terminal.js";
18
19
  import { inspectWorkspace, renderWorkspaceSummary, } from "./workspace-summary.js";
19
- import { parsePermissions, parseSessionOptions, startAgent, } from "./start-agent.js";
20
+ import { checkTuiArguments, parsePermissions, parseSessionOptions, startAgent, TuiArgumentError, TUI_FLAGS, } from "./start-agent.js";
20
21
  import { sessionsDir } from "../config/paths.js";
21
22
  import { chooseSession } from "./session-centre.js";
22
23
  import { select } from "./widgets.js";
23
24
  export async function runTui(options) {
25
+ // F-014 · Before anything is drawn: a mistyped flag or a `--title` with no
26
+ // value used to start a session that quietly did something else.
27
+ try {
28
+ checkTuiArguments(options.argv);
29
+ }
30
+ catch (error) {
31
+ if (error instanceof TuiArgumentError) {
32
+ options.stdout.write(`mafdet: ${error.message}\n`);
33
+ options.stdout.write(`Supported here: ${TUI_FLAGS.join(", ")}\n`);
34
+ return ExitCode.Usage;
35
+ }
36
+ throw error;
37
+ }
24
38
  // Live, not a snapshot: a window resized mid-session must not keep laying
25
39
  // out for the width it used to be.
26
40
  const live = followResize(detectCapabilities({
@@ -123,7 +137,9 @@ export async function runTui(options) {
123
137
  stdout: options.stdout,
124
138
  commandIo: options.commandIo,
125
139
  apiKey: auth.apiKey,
126
- model: chosen,
140
+ model: chosen.model,
141
+ catalogue: chosen.catalogue,
142
+ agentCapableModels: chosen.agentCapable,
127
143
  workspace: options.workspace,
128
144
  permissions: parsePermissions(options.argv),
129
145
  session: {
@@ -143,12 +159,21 @@ export async function runTui(options) {
143
159
  guard.release();
144
160
  }
145
161
  }
146
- /**
147
- * Pick a model, then offer to remember it.
148
- *
149
- * Only the model id is ever persisted. The key stays in memory for the life of
150
- * the process, which is the promise onboarding made.
151
- */
162
+ /** The model the whole precedence chain settles on: flag > env > project > user. */
163
+ function resolvedDefaultModel(options) {
164
+ try {
165
+ return loadConfig({
166
+ env: options.commandIo.env,
167
+ cwd: options.commandIo.cwd,
168
+ userConfigFile: options.commandIo.userConfigFile,
169
+ }).model;
170
+ }
171
+ catch {
172
+ // A broken config must not stop the picker from opening; it will be
173
+ // reported the moment anything actually needs the configuration.
174
+ return options.commandIo.env?.MAFDET_MODEL;
175
+ }
176
+ }
152
177
  async function chooseModel(options) {
153
178
  const { dim, bold } = palette(options.capabilities);
154
179
  options.stdout.write(dim(" loading the model catalogue...\n"));
@@ -184,7 +209,11 @@ async function chooseModel(options) {
184
209
  // Fail closed: offer only agent-capable models when any exist, so a
185
210
  // model the agent cannot drive never becomes the session's default.
186
211
  agentOnly: catalogue.agentReady.length > 0,
187
- current: options.commandIo.env?.MAFDET_MODEL,
212
+ // F-005 · The resolved model, not just the environment variable. After
213
+ // "Remember X as your default?" wrote X to the user config, the next
214
+ // launch still highlighted whatever MAFDET_MODEL said - usually nothing -
215
+ // so the setting the user had just agreed to appeared not to have worked.
216
+ current: resolvedDefaultModel(options),
188
217
  });
189
218
  if (picked.kind === "cancelled" || picked.model === undefined)
190
219
  return undefined;
@@ -213,5 +242,9 @@ async function chooseModel(options) {
213
242
  ? dim(` saved to ${saved.path}\n`)
214
243
  : dim(` could not save to ${saved.path}: ${saved.problem}\n`));
215
244
  }
216
- return picked.model;
245
+ return {
246
+ model: picked.model,
247
+ catalogue: catalogue.chat,
248
+ agentCapable: catalogue.agentReady.map((m) => m.modelId),
249
+ };
217
250
  }
@@ -20,7 +20,27 @@ export interface AgentPermissions {
20
20
  * granting these and no second one to keep in step.
21
21
  */
22
22
  /** `--no-history` and `--title`, read off the bare invocation. */
23
+ /** Every flag the bare `mafdet` invocation understands. */
24
+ export declare const TUI_FLAGS: readonly ["--allow-write", "--read-only", "--allow-command", "--no-history", "--no-tui", "--resume", "--title"];
25
+ export declare class TuiArgumentError extends Error {
26
+ }
27
+ /**
28
+ * F-014 · Refuse arguments we do not understand.
29
+ *
30
+ * `--title` with no value, or a mistyped `--reusme`, used to be dropped in
31
+ * silence: the session started, looked fine, and did something other than what
32
+ * was asked. That is worse than an error, because nothing tells the user.
33
+ */
34
+ export declare function checkTuiArguments(argv: readonly string[]): void;
23
35
  export declare function parseSessionOptions(argv: readonly string[]): SessionOptions;
36
+ /**
37
+ * F-004 · The same permission syntax as the command surface.
38
+ *
39
+ * `--read-only` existed only in `mafdet agent`, so `mafdet --allow-write
40
+ * --read-only` was read-only there and write-enabled here. A flag whose whole
41
+ * purpose is to take permission away must not be the one the TUI ignores, and
42
+ * last-flag-wins is the only ordering a user can reason about.
43
+ */
24
44
  export declare function parsePermissions(argv: readonly string[]): AgentPermissions;
25
45
  export interface StartAgentOptions {
26
46
  readonly io: WidgetIo;
@@ -32,6 +52,17 @@ export interface StartAgentOptions {
32
52
  readonly apiKey: string;
33
53
  readonly model: ResolvedModel;
34
54
  readonly workspace: string;
55
+ /**
56
+ * F-003 · Everything this key can reach.
57
+ *
58
+ * Passed in whole rather than as two id lists because a resumed session runs
59
+ * on its *own* recorded model, and that model needs its catalogue entry for
60
+ * pricing. Checking resume against only the model picked moments earlier was
61
+ * asking the wrong question: resume asks whether this key can still call the
62
+ * model the session was recorded with.
63
+ */
64
+ readonly catalogue?: readonly ResolvedModel[];
65
+ readonly agentCapableModels?: readonly string[];
35
66
  readonly permissions: AgentPermissions;
36
67
  readonly session?: SessionOptions;
37
68
  /**
@@ -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;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"}
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,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,2DAA2D;AAC3D,eAAO,MAAM,SAAS,iHAQZ,CAAC;AASX,qBAAa,gBAAiB,SAAQ,KAAK;CAAG;AAE9C;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAgB/D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,cAAc,CAiB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,gBAAgB,CAgB1E;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;;;;;;;;OAQG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IAC9C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChD,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,CA8JnB"}
@@ -16,6 +16,7 @@ import { SessionRecorder } from "../sessions/recorder.js";
16
16
  import { SessionWriter } from "../sessions/store.js";
17
17
  import { NOTICE_FILE, decideRecording, firstSaveNotice, } from "../sessions/policy.js";
18
18
  import { describeResume, openForResume } from "../sessions/open.js";
19
+ import { describeBlocker } from "../sessions/resume.js";
19
20
  import { runAgentSurface } from "./agent-surface.js";
20
21
  import { palette } from "./theme.js";
21
22
  /**
@@ -25,24 +26,64 @@ import { palette } from "./theme.js";
25
26
  * granting these and no second one to keep in step.
26
27
  */
27
28
  /** `--no-history` and `--title`, read off the bare invocation. */
28
- export function parseSessionOptions(argv) {
29
- let title;
29
+ /** Every flag the bare `mafdet` invocation understands. */
30
+ export const TUI_FLAGS = [
31
+ "--allow-write",
32
+ "--read-only",
33
+ "--allow-command",
34
+ "--no-history",
35
+ "--no-tui",
36
+ "--resume",
37
+ "--title",
38
+ ];
39
+ /** The ones that consume the argument after them. */
40
+ const TUI_FLAGS_WITH_VALUE = new Set([
41
+ "--allow-command",
42
+ "--resume",
43
+ "--title",
44
+ ]);
45
+ export class TuiArgumentError extends Error {
46
+ }
47
+ /**
48
+ * F-014 · Refuse arguments we do not understand.
49
+ *
50
+ * `--title` with no value, or a mistyped `--reusme`, used to be dropped in
51
+ * silence: the session started, looked fine, and did something other than what
52
+ * was asked. That is worse than an error, because nothing tells the user.
53
+ */
54
+ export function checkTuiArguments(argv) {
30
55
  for (let i = 0; i < argv.length; i += 1) {
31
- if (argv[i] !== "--title")
56
+ const arg = argv[i];
57
+ if (!arg.startsWith("-")) {
58
+ throw new TuiArgumentError(`unexpected argument '${arg}'`);
59
+ }
60
+ if (!TUI_FLAGS.includes(arg)) {
61
+ throw new TuiArgumentError(`unknown option '${arg}'`);
62
+ }
63
+ if (!TUI_FLAGS_WITH_VALUE.has(arg))
32
64
  continue;
33
65
  const value = argv[i + 1];
34
- if (value !== undefined && !value.startsWith("-")) {
35
- title = value;
36
- i += 1;
66
+ if (value === undefined || value.startsWith("-")) {
67
+ throw new TuiArgumentError(`${arg} needs a value`);
37
68
  }
69
+ i += 1;
38
70
  }
71
+ }
72
+ export function parseSessionOptions(argv) {
73
+ let title;
39
74
  let resumeId;
40
75
  for (let i = 0; i < argv.length; i += 1) {
41
- if (argv[i] !== "--resume")
76
+ const arg = argv[i];
77
+ if (!TUI_FLAGS_WITH_VALUE.has(arg))
42
78
  continue;
43
79
  const value = argv[i + 1];
44
- if (value !== undefined && !value.startsWith("-"))
80
+ if (value === undefined || value.startsWith("-"))
81
+ continue;
82
+ if (arg === "--title")
83
+ title = value;
84
+ if (arg === "--resume")
45
85
  resumeId = value;
86
+ i += 1;
46
87
  }
47
88
  return {
48
89
  noHistory: argv.includes("--no-history"),
@@ -50,6 +91,14 @@ export function parseSessionOptions(argv) {
50
91
  ...(resumeId === undefined ? {} : { resumeId }),
51
92
  };
52
93
  }
94
+ /**
95
+ * F-004 · The same permission syntax as the command surface.
96
+ *
97
+ * `--read-only` existed only in `mafdet agent`, so `mafdet --allow-write
98
+ * --read-only` was read-only there and write-enabled here. A flag whose whole
99
+ * purpose is to take permission away must not be the one the TUI ignores, and
100
+ * last-flag-wins is the only ordering a user can reason about.
101
+ */
53
102
  export function parsePermissions(argv) {
54
103
  let allowWrite = false;
55
104
  const allowedCommands = [];
@@ -57,6 +106,8 @@ export function parsePermissions(argv) {
57
106
  const arg = argv[i];
58
107
  if (arg === "--allow-write")
59
108
  allowWrite = true;
109
+ else if (arg === "--read-only")
110
+ allowWrite = false;
60
111
  else if (arg === "--allow-command") {
61
112
  const value = argv[i + 1];
62
113
  if (value !== undefined && !value.startsWith("-")) {
@@ -69,15 +120,6 @@ export function parsePermissions(argv) {
69
120
  }
70
121
  export async function startAgent(options) {
71
122
  const { dim, bold } = palette(options.capabilities);
72
- let workspace;
73
- try {
74
- workspace = Workspace.open(options.workspace);
75
- }
76
- catch (error) {
77
- const message = error instanceof Error ? error.message : String(error);
78
- options.stdout.write(`\n ${bold("cannot use this directory")}: ${message}\n`);
79
- return ExitCode.General;
80
- }
81
123
  // The TUI is interactive by construction, so `--allow-write` can be honoured
82
124
  // here without the non-interactive downgrade the command surface applies.
83
125
  const mode = options.permissions.allowWrite ? "suggest" : "read-only";
@@ -96,10 +138,13 @@ export async function startAgent(options) {
96
138
  root: sessionsDir(options.commandIo.env ?? process.env),
97
139
  sessionId: options.session.resumeId,
98
140
  systemPrompt: mode === "read-only" ? READ_ONLY_PROMPT : WRITE_PROMPT,
99
- callable: [options.model.modelId],
100
- agentCapable: options.model.agentEligible
101
- ? [options.model.modelId]
102
- : [],
141
+ // F-003 · The whole set this key can call, not just the model that
142
+ // happened to be picked before the session was chosen. Checking
143
+ // against a one-element set made every resume of a different model
144
+ // look unavailable, and told the user the wrong thing about why.
145
+ callable: (options.catalogue ?? [options.model]).map((m) => m.modelId),
146
+ agentCapable: options.agentCapableModels ??
147
+ (options.model.agentEligible ? [options.model.modelId] : []),
103
148
  allowedCommandsThisRun: options.permissions.allowedCommands,
104
149
  })
105
150
  : undefined;
@@ -111,6 +156,44 @@ export async function startAgent(options) {
111
156
  }
112
157
  if (opened !== undefined)
113
158
  options.stdout.write(dim(describeResume(opened)));
159
+ // F-003 · A blocker used to be printed and then ignored, so a resume with an
160
+ // uncallable model or a deleted workspace started anyway. Resuming into a
161
+ // different directory is the worst of them: with writing enabled the agent
162
+ // would carry one project's context into another project's files.
163
+ if (opened !== undefined && opened.blockers.length > 0) {
164
+ options.stdout.write(`\n ${bold("cannot continue this conversation yet")}:\n` +
165
+ opened.blockers
166
+ .map((blocker) => ` - ${describeBlocker(blocker)}\n`)
167
+ .join("") +
168
+ `\n You can still read it with 'mafdet sessions show ${options.session?.resumeId ?? ""}'.\n\n`);
169
+ opened.writer.close();
170
+ opened.lock.release();
171
+ return ExitCode.General;
172
+ }
173
+ // F-003 · A resumed session runs where it was recorded and on the model it
174
+ // was recorded with. Continuing project A's conversation against project B's
175
+ // files is how an agent with write access edits the wrong repository - and
176
+ // it used to be the default, because the workspace came from the cwd.
177
+ const effectiveWorkspace = opened === undefined ? options.workspace : opened.metadata.workspace;
178
+ const effectiveModel = opened === undefined
179
+ ? options.model
180
+ : ((options.catalogue ?? []).find((m) => m.modelId === opened.metadata.model_id) ?? options.model);
181
+ let workspace;
182
+ try {
183
+ workspace = Workspace.open(effectiveWorkspace);
184
+ }
185
+ catch (error) {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ options.stdout.write(`\n ${bold("cannot use this directory")}: ${message}\n`);
188
+ if (opened !== undefined) {
189
+ opened.writer.close();
190
+ opened.lock.release();
191
+ }
192
+ return ExitCode.General;
193
+ }
194
+ if (opened !== undefined && effectiveWorkspace !== options.workspace) {
195
+ options.stdout.write(dim(` continuing in ${effectiveWorkspace}, where it was recorded\n`));
196
+ }
114
197
  const recorder = opened !== undefined
115
198
  ? new SessionRecorder({
116
199
  writer: opened.writer,
@@ -119,32 +202,51 @@ export async function startAgent(options) {
119
202
  onProblem: (message) => void options.stdout.write(dim(` ${message}\n`)),
120
203
  })
121
204
  : startRecording(options, mode);
122
- await runAgentSurface({
123
- io: options.io,
124
- ...(recorder === undefined ? {} : { recorder }),
125
- capabilities: options.capabilities,
126
- stdout: options.stdout,
127
- ...(options.getSignal === undefined
128
- ? {}
129
- : { getSignal: options.getSignal }),
130
- session: {
131
- client: runtime.dataPlane,
132
- model: options.model.modelId,
133
- pricing: options.model,
134
- catalog: [options.model],
135
- systemPrompt: mode === "read-only" ? READ_ONLY_PROMPT : WRITE_PROMPT,
136
- tools: buildToolset(mode, hasCommands),
137
- toolContext: {
138
- workspace,
205
+ // F-015 · The resume lock lives for exactly the run that holds it. It used
206
+ // to be taken and never released, so the lock file survived until the next
207
+ // process judged the pid dead - and on a shared filesystem, where the
208
+ // recorded hostname differs, `isStale` never reclaims it at all and the
209
+ // session is locked forever. The finally covers a normal exit, a Ctrl+C that
210
+ // unwinds through here, and a throw.
211
+ try {
212
+ await runAgentSurface({
213
+ io: options.io,
214
+ ...(recorder === undefined ? {} : { recorder }),
215
+ capabilities: options.capabilities,
216
+ stdout: options.stdout,
217
+ ...(options.getSignal === undefined
218
+ ? {}
219
+ : { getSignal: options.getSignal }),
220
+ session: {
221
+ client: runtime.dataPlane,
222
+ model: effectiveModel.modelId,
223
+ pricing: effectiveModel,
224
+ catalog: options.catalogue ?? [effectiveModel],
225
+ systemPrompt: mode === "read-only" ? READ_ONLY_PROMPT : WRITE_PROMPT,
226
+ tools: buildToolset(mode, hasCommands),
227
+ toolContext: {
228
+ workspace,
229
+ allowedCommands: options.permissions.allowedCommands,
230
+ },
231
+ mode,
232
+ // The rebuilt conversation, or nothing for a new session. runSession
233
+ // appends to this array in place, which is what continues the thread.
234
+ ...(opened === undefined ? {} : { history: opened.messages }),
139
235
  allowedCommands: options.permissions.allowedCommands,
140
236
  },
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 }),
145
- allowedCommands: options.permissions.allowedCommands,
146
- },
147
- });
237
+ });
238
+ }
239
+ finally {
240
+ if (opened !== undefined) {
241
+ try {
242
+ opened.writer.close();
243
+ }
244
+ catch {
245
+ // Closing is bookkeeping; nothing depends on it succeeding.
246
+ }
247
+ opened.lock.release();
248
+ }
249
+ }
148
250
  return ExitCode.Ok;
149
251
  }
150
252
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mafdet/cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Mafdet AI command line — model calls, account queries, and a permissioned local agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",