@mtreeai/msapling-cli 2.3.6-beta.44 → 2.3.6-beta.46

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 (2) hide show
  1. package/dist/index.js +520 -30
  2. package/package.json +3 -1
package/dist/index.js CHANGED
@@ -1017,6 +1017,21 @@ var init_src = __esm({
1017
1017
  const data = overviewData;
1018
1018
  return this.mapUser(data, billingData);
1019
1019
  }
1020
+ /**
1021
+ * CLI-PARITY-P0-3: Web search via the backend proxy (POST /api/web/search).
1022
+ *
1023
+ * Provider API keys (Tavily/Serper) stay server-side; the CLI only ever sees
1024
+ * normalised result rows. Mirrors the WebSearchTool surface so the agent can
1025
+ * search the live web without local credentials. Backend fails open with an
1026
+ * empty result set + provider "none" rather than 500 when no provider is
1027
+ * available, so callers can degrade gracefully.
1028
+ */
1029
+ async webSearch(query, maxResults = 5) {
1030
+ return await this.request("/api/web/search", {
1031
+ method: "POST",
1032
+ body: JSON.stringify({ query, max_results: maxResults })
1033
+ });
1034
+ }
1020
1035
  async getHistory(chatId) {
1021
1036
  const data = await this.request(`/api/projects/chat/${chatId}/history`);
1022
1037
  return data.messages.map((m) => ({
@@ -1268,6 +1283,30 @@ var init_src = __esm({
1268
1283
  const data = await this.request(`/api/projects/${encodeURIComponent(projectId)}/chats`);
1269
1284
  return Array.isArray(data) ? data : data.chats ?? [];
1270
1285
  }
1286
+ // P0-4 (CLI-RESUME-01): list the user's recent chats across ALL projects for
1287
+ // `/resume` + `msapling --continue`. The /api/projects/ tree keys projects by
1288
+ // NAME and nests each project's `chats` array ({id, title, model, created_at,
1289
+ // updated_at?}). We flatten the tree, stamp each chat with its project name,
1290
+ // and sort by recency (updated_at, then created_at) so the most-recently
1291
+ // touched chat is first. Backend doesn't expose a flat "recent chats" route,
1292
+ // so this client-side flatten is the canonical path (mirrors chat.ts).
1293
+ async listRecentChats(limit = 20) {
1294
+ const data = await this.request("/api/projects/");
1295
+ const flat = [];
1296
+ for (const [projectName, bucket] of Object.entries(data.projects ?? {})) {
1297
+ for (const chat of bucket?.chats ?? []) {
1298
+ if (!chat?.id) continue;
1299
+ flat.push({ ...chat, project: projectName });
1300
+ }
1301
+ }
1302
+ const recencyKey = (c) => {
1303
+ const raw = c.updated_at ?? c.created_at;
1304
+ const t = raw ? Date.parse(raw) : NaN;
1305
+ return Number.isNaN(t) ? 0 : t;
1306
+ };
1307
+ flat.sort((a, b) => recencyKey(b) - recencyKey(a));
1308
+ return flat.slice(0, Math.max(1, limit));
1309
+ }
1271
1310
  // CLI-CHAT-CREATE-01 (Iter 34): create a new chat in a project. Per LAB
1272
1311
  // projects.py:365 — POST /api/projects/chat/new with
1273
1312
  // {project_name, slot_label?, chat_name?, model?, client_type?}.
@@ -3400,11 +3439,20 @@ var init_DispatchAgentTool = __esm({
3400
3439
  client;
3401
3440
  parentChatId;
3402
3441
  projectRoot;
3442
+ onSubagentStop;
3403
3443
  constructor(options) {
3404
3444
  super();
3405
3445
  this.client = options.client;
3406
3446
  this.parentChatId = options.parentChatId ?? void 0;
3407
3447
  this.projectRoot = options.projectRoot;
3448
+ this.onSubagentStop = options.onSubagentStop;
3449
+ }
3450
+ /** Fire the subagent-stop callback defensively — it must never throw. */
3451
+ signalStop(info) {
3452
+ try {
3453
+ this.onSubagentStop?.(info);
3454
+ } catch {
3455
+ }
3408
3456
  }
3409
3457
  async execute(args2, _projectRoot) {
3410
3458
  if (!args2?.prompt || typeof args2.prompt !== "string" || args2.prompt.trim() === "") {
@@ -3444,12 +3492,14 @@ var init_DispatchAgentTool = __esm({
3444
3492
  try {
3445
3493
  await Promise.race([streamPromise, timeoutPromise]);
3446
3494
  } catch (e) {
3495
+ this.signalStop({ description, ok: false, chars: response.length, error: e?.message });
3447
3496
  return {
3448
3497
  content: `dispatch_agent error [${description}]: ${e.message}`,
3449
3498
  isError: true
3450
3499
  };
3451
3500
  }
3452
3501
  if (!response.trim()) {
3502
+ this.signalStop({ description, ok: false, chars: 0, error: "empty response" });
3453
3503
  return {
3454
3504
  content: `dispatch_agent [${description}]: Sub-agent returned an empty response.`,
3455
3505
  isError: true
@@ -3461,6 +3511,7 @@ var init_DispatchAgentTool = __esm({
3461
3511
 
3462
3512
  ... [TRUNCATED: Sub-agent response exceeded ${MAX_RESPONSE_CHARS} characters]`;
3463
3513
  }
3514
+ this.signalStop({ description, ok: true, chars: finalResponse.length });
3464
3515
  return {
3465
3516
  content: `[dispatch_agent: ${description}]
3466
3517
 
@@ -3776,6 +3827,83 @@ Content-Type: ${contentType}
3776
3827
  }
3777
3828
  });
3778
3829
 
3830
+ // ../core/src/tools/WebSearchTool.ts
3831
+ var DEFAULT_MAX_RESULTS, MAX_RESULTS_CEILING, MAX_SNIPPET_CHARS, WebSearchTool;
3832
+ var init_WebSearchTool = __esm({
3833
+ "../core/src/tools/WebSearchTool.ts"() {
3834
+ "use strict";
3835
+ init_esm_shims();
3836
+ init_BaseTool();
3837
+ DEFAULT_MAX_RESULTS = 5;
3838
+ MAX_RESULTS_CEILING = 10;
3839
+ MAX_SNIPPET_CHARS = 500;
3840
+ WebSearchTool = class extends BaseTool {
3841
+ name = "web_search";
3842
+ description = "Search the live web for current information and return a ranked list of result titles, URLs, and snippets. Use this to find documentation, recent events, library versions, error explanations, or any topic where you need up-to-date sources you do not already have a URL for. Pair with web_fetch to read a specific result in full. Runs through the MSapling backend so no API keys are needed client-side.";
3843
+ parameters = {
3844
+ type: "object",
3845
+ required: ["query"],
3846
+ properties: {
3847
+ query: {
3848
+ type: "string",
3849
+ description: "The search query. Be specific for better results."
3850
+ },
3851
+ max_results: {
3852
+ type: "number",
3853
+ description: `Maximum number of results to return. Default: ${DEFAULT_MAX_RESULTS}. Max: ${MAX_RESULTS_CEILING}.`
3854
+ }
3855
+ }
3856
+ };
3857
+ client;
3858
+ constructor(options) {
3859
+ super();
3860
+ this.client = options.client;
3861
+ }
3862
+ async execute(args2, _projectRoot) {
3863
+ const rawQuery = args2?.query;
3864
+ if (!rawQuery || typeof rawQuery !== "string" || rawQuery.trim() === "") {
3865
+ return {
3866
+ content: 'Error: web_search requires a non-empty "query" argument.',
3867
+ isError: true
3868
+ };
3869
+ }
3870
+ const query = rawQuery.trim();
3871
+ let maxResults = DEFAULT_MAX_RESULTS;
3872
+ if (typeof args2?.max_results === "number" && args2.max_results > 0) {
3873
+ maxResults = Math.min(Math.floor(args2.max_results), MAX_RESULTS_CEILING);
3874
+ }
3875
+ let resp;
3876
+ try {
3877
+ resp = await this.client.webSearch(query, maxResults);
3878
+ } catch (e) {
3879
+ return {
3880
+ content: `Error: web_search failed \u2014 ${e?.message ?? String(e)}`,
3881
+ isError: true
3882
+ };
3883
+ }
3884
+ const results = Array.isArray(resp?.results) ? resp.results : [];
3885
+ if (results.length === 0) {
3886
+ return {
3887
+ content: `[web_search] No results for "${query}" (provider: ${resp?.provider ?? "none"}). Try rephrasing the query or use web_fetch if you already have a URL.`
3888
+ };
3889
+ }
3890
+ const confidence = results[0]?.confidence ?? 0;
3891
+ const confidenceLabel = confidence >= 0.9 ? "high" : "low (scraped)";
3892
+ const header = `[web_search: ${results.length} result(s) for "${query}" \u2014 provider: ${resp.provider}, confidence: ${confidenceLabel}]
3893
+
3894
+ `;
3895
+ const body = results.map((r, i) => {
3896
+ const snippet = (r.snippet || "").slice(0, MAX_SNIPPET_CHARS);
3897
+ return `${i + 1}. ${r.title || "(untitled)"}
3898
+ ${r.url || ""}
3899
+ ${snippet}`;
3900
+ }).join("\n\n");
3901
+ return { content: header + body };
3902
+ }
3903
+ };
3904
+ }
3905
+ });
3906
+
3779
3907
  // ../core/src/tools/BashTool.ts
3780
3908
  import { resolve as resolve7, normalize as normalize6, relative as relative7, isAbsolute as isAbsolute7 } from "path";
3781
3909
  import { spawn as spawn5 } from "child_process";
@@ -4953,6 +5081,65 @@ Backup: ${backedUpTo}`;
4953
5081
  }
4954
5082
  });
4955
5083
 
5084
+ // ../core/src/tools/PlanModeTools.ts
5085
+ var MAX_PLAN_CHARS, EnterPlanModeTool, ExitPlanModeTool;
5086
+ var init_PlanModeTools = __esm({
5087
+ "../core/src/tools/PlanModeTools.ts"() {
5088
+ "use strict";
5089
+ init_esm_shims();
5090
+ init_BaseTool();
5091
+ MAX_PLAN_CHARS = 2e4;
5092
+ EnterPlanModeTool = class extends BaseTool {
5093
+ name = "enter_plan_mode";
5094
+ description = "Switch into Plan Mode for a complex task that needs exploration before any changes. In Plan Mode you may ONLY use read-only tools (read_file, grep, glob, list_directory, web_fetch, web_search, etc.) \u2014 file writes and shell commands are blocked. Use this when the task is non-trivial and you should design an approach first. When your plan is ready, call exit_plan_mode to present it for the user's approval before executing.";
5095
+ parameters = {
5096
+ type: "object",
5097
+ properties: {}
5098
+ };
5099
+ async execute(_args, _projectRoot) {
5100
+ return {
5101
+ content: "Entered Plan Mode. This is a READ-ONLY exploration and design phase.\n1. Thoroughly explore the codebase to understand existing patterns.\n2. Identify similar features and architectural approaches.\n3. Consider multiple approaches and their trade-offs.\n4. Design a concrete implementation strategy.\n5. When ready, call exit_plan_mode with your finished plan for approval.\nDo NOT write or edit files or run commands until the plan is approved."
5102
+ };
5103
+ }
5104
+ };
5105
+ ExitPlanModeTool = class _ExitPlanModeTool extends BaseTool {
5106
+ name = "exit_plan_mode";
5107
+ description = "Present your finished implementation plan to the user for approval and exit Plan Mode. Only call this once you have a concrete plan. The plan is shown to the user; if they approve, Plan Mode is lifted and you may begin making changes. If they reject, you remain in Plan Mode and should revise the plan based on their feedback. Pass the full plan as Markdown in `plan`.";
5108
+ parameters = {
5109
+ type: "object",
5110
+ required: ["plan"],
5111
+ properties: {
5112
+ plan: {
5113
+ type: "string",
5114
+ description: "The full implementation plan, formatted as Markdown. Describe the steps you will take, the files you will change, and any trade-offs."
5115
+ }
5116
+ }
5117
+ };
5118
+ /** Validate the plan argument. Returns an error string or null when valid. */
5119
+ static validatePlan(args2) {
5120
+ const plan = args2?.plan;
5121
+ if (!plan || typeof plan !== "string" || plan.trim() === "") {
5122
+ return 'Error: exit_plan_mode requires a non-empty "plan" argument (the implementation plan to present for approval).';
5123
+ }
5124
+ if (plan.length > MAX_PLAN_CHARS) {
5125
+ return `Error: plan is too long (${plan.length} chars; max ${MAX_PLAN_CHARS}). Summarize the plan.`;
5126
+ }
5127
+ return null;
5128
+ }
5129
+ async execute(args2, _projectRoot) {
5130
+ const err = _ExitPlanModeTool.validatePlan(args2);
5131
+ if (err) return { content: err, isError: true };
5132
+ return {
5133
+ content: `Plan recorded. (No interactive approval surface available \u2014 use /mode default or /mode acceptEdits to leave Plan Mode and begin execution.)
5134
+
5135
+ ## Plan
5136
+ ${String(args2.plan)}`
5137
+ };
5138
+ }
5139
+ };
5140
+ }
5141
+ });
5142
+
4956
5143
  // ../core/src/MDrive.ts
4957
5144
  import { createHash as createHash2 } from "crypto";
4958
5145
  var MDriveService;
@@ -5430,7 +5617,7 @@ async function runOne(entry, ctx) {
5430
5617
  if (settled) return;
5431
5618
  settled = true;
5432
5619
  clearTimeout(killer);
5433
- const blocked = !!entry.blocking && (exitCode === null || exitCode !== 0);
5620
+ const blocked = !NON_BLOCKING_EVENTS.has(ctx.event) && !!entry.blocking && (exitCode === null || exitCode !== 0);
5434
5621
  resolve20({ command, exitCode, stdout, stderr, timedOut, blocked });
5435
5622
  };
5436
5623
  const killer = setTimeout(() => {
@@ -5467,11 +5654,20 @@ async function runOne(entry, ctx) {
5467
5654
  }
5468
5655
  });
5469
5656
  }
5470
- var DEFAULT_TIMEOUT_MS3, MAX_OUTPUT_BYTES3, HookRunner;
5657
+ var NON_BLOCKING_EVENTS, DEFAULT_TIMEOUT_MS3, MAX_OUTPUT_BYTES3, HookRunner;
5471
5658
  var init_Hooks = __esm({
5472
5659
  "../core/src/Hooks.ts"() {
5473
5660
  "use strict";
5474
5661
  init_esm_shims();
5662
+ NON_BLOCKING_EVENTS = /* @__PURE__ */ new Set([
5663
+ "post-tool-use",
5664
+ "session-start",
5665
+ "session-end",
5666
+ "stop",
5667
+ "subagent-stop",
5668
+ "pre-compact",
5669
+ "notification"
5670
+ ]);
5475
5671
  DEFAULT_TIMEOUT_MS3 = 5e3;
5476
5672
  MAX_OUTPUT_BYTES3 = 32e3;
5477
5673
  HookRunner = class {
@@ -5499,6 +5695,18 @@ var init_Hooks = __esm({
5499
5695
  }
5500
5696
  return outcomes;
5501
5697
  }
5698
+ /**
5699
+ * P1-7a: fire an observation-only lifecycle event without awaiting or
5700
+ * blocking the caller. Errors are swallowed so a misbehaving hook can never
5701
+ * break session start/stop/teardown. Returns immediately; the hook chain
5702
+ * runs in the background. No-op if no hooks are registered for the event.
5703
+ */
5704
+ fireForget(ctx) {
5705
+ const entries = this.hooks[ctx.event];
5706
+ if (!entries || entries.length === 0) return;
5707
+ void this.fire(ctx).catch(() => {
5708
+ });
5709
+ }
5502
5710
  /** Convenience: did any outcome block? */
5503
5711
  static anyBlocked(outcomes) {
5504
5712
  return outcomes.find((o) => o.blocked) ?? null;
@@ -5834,12 +6042,14 @@ var init_ToolExecutor = __esm({
5834
6042
  init_PatchFileTool();
5835
6043
  init_DispatchAgentTool();
5836
6044
  init_WebFetchTool();
6045
+ init_WebSearchTool();
5837
6046
  init_BashTool();
5838
6047
  init_NotebookReadTool();
5839
6048
  init_NotebookEditTool();
5840
6049
  init_MultiEditFileTool();
5841
6050
  init_MoveFileTool();
5842
6051
  init_DeleteFileTool();
6052
+ init_PlanModeTools();
5843
6053
  init_MDrive();
5844
6054
  init_Sandbox();
5845
6055
  init_Voice();
@@ -5875,6 +6085,13 @@ var init_ToolExecutor = __esm({
5875
6085
  sessionTrust = /* @__PURE__ */ new Set();
5876
6086
  mcpRegistry = null;
5877
6087
  hooks = null;
6088
+ /**
6089
+ * CLI-PARITY-P0-2: Notifier fired whenever a tool flips the permission mode
6090
+ * (enter_plan_mode / exit_plan_mode). Lets the CLI keep its React `mode`
6091
+ * state + footer in sync with a model-driven mode change. Optional — when
6092
+ * unset the mode still flips internally; only the UI display would lag.
6093
+ */
6094
+ onModeChange = null;
5878
6095
  constructor(client, projectRoot) {
5879
6096
  this.sandbox = new Sandbox(projectRoot);
5880
6097
  this.mdrive = new MDriveService(client);
@@ -5894,15 +6111,28 @@ var init_ToolExecutor = __esm({
5894
6111
  this.registerTool(new PatchFileTool());
5895
6112
  this.registerTool(new DispatchAgentTool({
5896
6113
  client,
5897
- projectRoot
6114
+ projectRoot,
6115
+ // P1-7a (CLI-HOOKS-LIFECYCLE-01): fire the subagent-stop lifecycle hook
6116
+ // when a dispatched sub-agent finishes. Observation-only / non-blocking.
6117
+ onSubagentStop: (info) => {
6118
+ this.hooks?.fireForget({
6119
+ event: "subagent-stop",
6120
+ tool: info.description,
6121
+ payload: info,
6122
+ cwd: projectRoot
6123
+ });
6124
+ }
5898
6125
  }));
5899
6126
  this.registerTool(new WebFetchTool());
6127
+ this.registerTool(new WebSearchTool({ client }));
5900
6128
  this.registerTool(new BashTool());
5901
6129
  this.registerTool(new NotebookReadTool());
5902
6130
  this.registerTool(new NotebookEditTool());
5903
6131
  this.registerTool(new MultiEditFileTool());
5904
6132
  this.registerTool(new MoveFileTool());
5905
6133
  this.registerTool(new DeleteFileTool());
6134
+ this.registerTool(new EnterPlanModeTool());
6135
+ this.registerTool(new ExitPlanModeTool());
5906
6136
  }
5907
6137
  setToolsEnabled(enabled) {
5908
6138
  this.toolsEnabled = enabled;
@@ -5913,6 +6143,24 @@ var init_ToolExecutor = __esm({
5913
6143
  getMode() {
5914
6144
  return this.mode;
5915
6145
  }
6146
+ /**
6147
+ * CLI-PARITY-P0-2: Register a callback invoked when a tool changes the
6148
+ * permission mode (enter_plan_mode / exit_plan_mode), so the CLI can mirror
6149
+ * the new mode into its React state. Does not affect the internal mode.
6150
+ */
6151
+ setOnModeChange(cb) {
6152
+ this.onModeChange = cb;
6153
+ }
6154
+ /** Internal: flip the mode AND notify any registered listener. */
6155
+ applyModeChange(mode) {
6156
+ this.mode = mode;
6157
+ if (this.onModeChange) {
6158
+ try {
6159
+ this.onModeChange(mode);
6160
+ } catch {
6161
+ }
6162
+ }
6163
+ }
5916
6164
  setApprovalCallback(cb) {
5917
6165
  this.approvalCallback = cb;
5918
6166
  }
@@ -5957,6 +6205,61 @@ var init_ToolExecutor = __esm({
5957
6205
  registerTool(tool) {
5958
6206
  this.tools.set(tool.name, tool);
5959
6207
  }
6208
+ /**
6209
+ * CLI-PARITY-P0-2: enter_plan_mode handler. Flips the executor into read-only
6210
+ * `plan` mode (idempotent) and returns the research instructions. No approval:
6211
+ * entering plan mode only tightens permissions.
6212
+ */
6213
+ async handleEnterPlanMode() {
6214
+ if (this.mode !== "plan") {
6215
+ this.applyModeChange("plan");
6216
+ }
6217
+ const tool = this.tools.get("enter_plan_mode");
6218
+ return tool.execute({}, "");
6219
+ }
6220
+ /**
6221
+ * CLI-PARITY-P0-2: exit_plan_mode handler. Validates the drafted plan,
6222
+ * presents it through the existing approval UI (approvalCallback), and on
6223
+ * approve flips `plan` → default/acceptEdits so execution can proceed. On deny
6224
+ * the executor stays in plan mode and the model is told to revise.
6225
+ */
6226
+ async handleExitPlanMode(args2) {
6227
+ const err = ExitPlanModeTool.validatePlan(args2);
6228
+ if (err) return { content: err, isError: true };
6229
+ if (this.mode !== "plan") {
6230
+ return {
6231
+ content: "You are not in Plan Mode, so there is nothing to exit. Continue with implementation."
6232
+ };
6233
+ }
6234
+ const plan = String(args2.plan);
6235
+ if (!this.approvalCallback) {
6236
+ this.applyModeChange("default");
6237
+ return {
6238
+ content: `No interactive approval surface available \u2014 Plan Mode lifted (now in default mode). Proceed with the plan.
6239
+
6240
+ ## Approved Plan
6241
+ ${plan}`
6242
+ };
6243
+ }
6244
+ const decision = await this.approvalCallback({
6245
+ tool: "exit_plan_mode",
6246
+ command: plan,
6247
+ reason: "The agent has finished planning and wants to start executing this plan. Approve to leave Plan Mode."
6248
+ });
6249
+ if (decision === "no") {
6250
+ return {
6251
+ content: "User rejected the plan. You remain in Plan Mode. Ask the user for specific feedback and revise the plan, then call exit_plan_mode again."
6252
+ };
6253
+ }
6254
+ const targetMode = decision === "always" ? "acceptEdits" : "default";
6255
+ this.applyModeChange(targetMode);
6256
+ return {
6257
+ content: `User approved the plan. Plan Mode lifted (now in ${targetMode} mode). You can now start executing. Begin with the first step.
6258
+
6259
+ ## Approved Plan
6260
+ ${plan}`
6261
+ };
6262
+ }
5960
6263
  async execute(toolName, args2, projectRoot) {
5961
6264
  const tool = this.tools.get(toolName);
5962
6265
  if (!tool) {
@@ -5972,6 +6275,12 @@ var init_ToolExecutor = __esm({
5972
6275
  }
5973
6276
  return { content: `Error: unknown tool: ${toolName}`, isError: true };
5974
6277
  }
6278
+ if (toolName === "enter_plan_mode") {
6279
+ return this.handleEnterPlanMode();
6280
+ }
6281
+ if (toolName === "exit_plan_mode") {
6282
+ return this.handleExitPlanMode(args2);
6283
+ }
5975
6284
  if (this.mode === "plan") {
5976
6285
  if (APPROVAL_GATED.has(toolName)) {
5977
6286
  return {
@@ -6396,6 +6705,14 @@ var init_Agent = __esm({
6396
6705
  getMode() {
6397
6706
  return this.executor.getMode();
6398
6707
  }
6708
+ /**
6709
+ * CLI-PARITY-P0-2: Register a listener for model-driven mode changes
6710
+ * (enter_plan_mode / exit_plan_mode) so the CLI can mirror the new mode into
6711
+ * its own state + footer.
6712
+ */
6713
+ setOnModeChange(cb) {
6714
+ this.executor.setOnModeChange(cb);
6715
+ }
6399
6716
  setApprovalCallback(cb) {
6400
6717
  this.executor.setApprovalCallback(cb);
6401
6718
  }
@@ -6406,6 +6723,19 @@ var init_Agent = __esm({
6406
6723
  this.executor.setHookRunner(runner);
6407
6724
  this.hooks = runner;
6408
6725
  }
6726
+ /** Expose the active HookRunner (e.g. for App-level session-start/end). */
6727
+ getHookRunner() {
6728
+ return this.hooks;
6729
+ }
6730
+ /**
6731
+ * P1-7a: fire an observation-only lifecycle hook event without blocking.
6732
+ * No-op when no HookRunner is wired. `name` is the descriptive string used
6733
+ * for matcher evaluation (e.g. the subagent description); `payload` is the
6734
+ * JSON event body delivered on the hook's stdin.
6735
+ */
6736
+ fireLifecycleHook(event, payload, name = "") {
6737
+ this.hooks?.fireForget({ event, tool: name, payload, cwd: this.projectRoot });
6738
+ }
6409
6739
  /**
6410
6740
  * CLI-PARITY-37: Wire a pre-loaded TrustStore so "always" approval decisions
6411
6741
  * are persisted across CLI restarts. Must be called after `store.load()`.
@@ -6465,6 +6795,11 @@ var init_Agent = __esm({
6465
6795
  `
6466
6796
  [Agent: context budget reached \u2014 auto-compacting conversation (${this.contextBudget.summary()})]`
6467
6797
  );
6798
+ this.fireLifecycleHook(
6799
+ "pre-compact",
6800
+ { chat_id: chatId, reason: "auto", budget: this.contextBudget.summary() },
6801
+ "auto"
6802
+ );
6468
6803
  rounds++;
6469
6804
  const compactionStream = this.client.streamChat({
6470
6805
  chat_id: chatId,
@@ -6547,6 +6882,7 @@ ${next}`;
6547
6882
  if (rounds >= MAX_WORKER_TURN_DEPTH) {
6548
6883
  throw new Error("runWorkerTurn exceeded MAX_DEPTH");
6549
6884
  }
6885
+ this.fireLifecycleHook("stop", { chat_id: chatId, rounds }, "");
6550
6886
  return streamUsage;
6551
6887
  }
6552
6888
  async run(prompt4, model) {
@@ -8040,11 +8376,14 @@ __export(src_exports2, {
8040
8376
  DEFAULT_SETTINGS: () => DEFAULT_SETTINGS,
8041
8377
  DeleteFileTool: () => DeleteFileTool,
8042
8378
  DispatchAgentTool: () => DispatchAgentTool,
8379
+ EnterPlanModeTool: () => EnterPlanModeTool,
8380
+ ExitPlanModeTool: () => ExitPlanModeTool,
8043
8381
  GlobFilesTool: () => GlobFilesTool,
8044
8382
  GrepSearchTool: () => GrepSearchTool,
8045
8383
  HookRunner: () => HookRunner,
8046
8384
  KeychainUnavailableError: () => KeychainUnavailableError,
8047
8385
  ListDirectoryTool: () => ListDirectoryTool,
8386
+ MAX_PLAN_CHARS: () => MAX_PLAN_CHARS,
8048
8387
  MCPClient: () => MCPClient,
8049
8388
  MCPClientError: () => MCPClientError,
8050
8389
  MCPRegistry: () => MCPRegistry,
@@ -8061,6 +8400,7 @@ __export(src_exports2, {
8061
8400
  ToolExecutor: () => ToolExecutor,
8062
8401
  TrustStore: () => TrustStore,
8063
8402
  WebFetchTool: () => WebFetchTool,
8403
+ WebSearchTool: () => WebSearchTool,
8064
8404
  WriteFileTool: () => WriteFileTool,
8065
8405
  buildCompactionPrompt: () => buildCompactionPrompt,
8066
8406
  buildHwContext: () => buildHwContext,
@@ -8099,6 +8439,8 @@ var init_src3 = __esm({
8099
8439
  init_TodoTools();
8100
8440
  init_DispatchAgentTool();
8101
8441
  init_WebFetchTool();
8442
+ init_WebSearchTool();
8443
+ init_PlanModeTools();
8102
8444
  init_BashTool();
8103
8445
  init_NotebookReadTool();
8104
8446
  init_NotebookEditTool();
@@ -9037,6 +9379,104 @@ var init_chat = __esm({
9037
9379
  }
9038
9380
  });
9039
9381
 
9382
+ // src/commands/resume.ts
9383
+ var resume_exports = {};
9384
+ __export(resume_exports, {
9385
+ rehydrateChat: () => rehydrateChat,
9386
+ resolveRecentChat: () => resolveRecentChat,
9387
+ resumeCommand: () => resumeCommand
9388
+ });
9389
+ function labelFor2(i) {
9390
+ return i < 9 ? String(i + 1) : LETTERS2[i - 9] ?? `#${i + 1}`;
9391
+ }
9392
+ function resolveRecentChat(arg, chats) {
9393
+ const trimmed = arg.trim();
9394
+ if (!trimmed) return { kind: "error", message: "empty argument" };
9395
+ const idHit = chats.find((c) => c.id === trimmed);
9396
+ if (idHit) return { kind: "ok", chat: idHit };
9397
+ if (/^\d+$/.test(trimmed)) {
9398
+ const n = parseInt(trimmed, 10);
9399
+ if (n >= 1 && n <= chats.length) return { kind: "ok", chat: chats[n - 1] };
9400
+ return { kind: "error", message: `no chat at index ${n} (have ${chats.length})` };
9401
+ }
9402
+ if (/^[a-zA-Z]$/.test(trimmed)) {
9403
+ const idx = 9 + LETTERS2.indexOf(trimmed.toLowerCase());
9404
+ if (idx >= 9 && idx < chats.length) return { kind: "ok", chat: chats[idx] };
9405
+ }
9406
+ return {
9407
+ kind: "error",
9408
+ message: `no recent chat matches '${trimmed}'. Run /resume (no args) for the list.`
9409
+ };
9410
+ }
9411
+ async function rehydrateChat(chat, context) {
9412
+ context.clearHistory();
9413
+ context.setActiveChatId(chat.id);
9414
+ let messages = [];
9415
+ try {
9416
+ messages = await context.client.getHistory(chat.id);
9417
+ } catch (e) {
9418
+ context.addMessage("error", `Resumed ${chat.id} but failed to load history: ${e?.message ?? e}`);
9419
+ }
9420
+ const label = chat.title ?? chat.id;
9421
+ const where = chat.project ? ` in '${chat.project}'` : "";
9422
+ context.addMessage("system", `Resumed chat "${label}"${where} (${messages.length} message(s) replayed).`);
9423
+ for (const m of messages) {
9424
+ const role = m.role === "user" ? "user" : m.role === "assistant" ? "assistant" : "system";
9425
+ context.addMessage(role, m.content);
9426
+ }
9427
+ try {
9428
+ await context.refreshOverview();
9429
+ } catch {
9430
+ }
9431
+ return messages.length;
9432
+ }
9433
+ async function listAndResume(args2, context) {
9434
+ let chats;
9435
+ try {
9436
+ chats = await context.client.listRecentChats(35);
9437
+ } catch (e) {
9438
+ context.addMessage("error", `Failed to list recent chats: ${e?.message ?? e}`);
9439
+ return;
9440
+ }
9441
+ if (chats.length === 0) {
9442
+ context.addMessage("system", "No prior chats found. Send a message to start one.");
9443
+ return;
9444
+ }
9445
+ const arg = args2.join(" ").trim();
9446
+ if (!arg) {
9447
+ context.addMessage("system", "Recent chats \u2014 /resume <number|letter|id> to reopen:");
9448
+ chats.forEach((c, i) => {
9449
+ const star = c.id === context.activeChatId ? " \u2605" : "";
9450
+ const project = c.project ? ` (${c.project})` : "";
9451
+ const model = c.model ? ` [${c.model}]` : "";
9452
+ context.addMessage("system", ` ${labelFor2(i)}. ${c.title ?? c.id}${project}${model}${star}`);
9453
+ });
9454
+ return;
9455
+ }
9456
+ const resolved = resolveRecentChat(arg, chats);
9457
+ if (resolved.kind === "error") {
9458
+ context.addMessage("error", resolved.message);
9459
+ return;
9460
+ }
9461
+ await rehydrateChat(resolved.chat, context);
9462
+ }
9463
+ var LETTERS2, resumeCommand;
9464
+ var init_resume = __esm({
9465
+ "src/commands/resume.ts"() {
9466
+ "use strict";
9467
+ init_esm_shims();
9468
+ LETTERS2 = "abcdefghijklmnopqrstuvwxyz";
9469
+ resumeCommand = {
9470
+ name: "resume",
9471
+ aliases: ["continue"],
9472
+ args: "[number|letter|id]",
9473
+ description: "List recent chats and reopen one \u2014 replays its history into the REPL",
9474
+ category: "chat",
9475
+ handler: async (args2, context) => listAndResume(args2, context)
9476
+ };
9477
+ }
9478
+ });
9479
+
9040
9480
  // src/commands/broadcast.ts
9041
9481
  var broadcastCommand;
9042
9482
  var init_broadcast = __esm({
@@ -9913,8 +10353,8 @@ var init_memory = __esm({
9913
10353
  });
9914
10354
 
9915
10355
  // src/commands/project.ts
9916
- function labelFor2(i) {
9917
- return i < 9 ? String(i + 1) : LETTERS2[i - 9] ?? `#${i + 1}`;
10356
+ function labelFor3(i) {
10357
+ return i < 9 ? String(i + 1) : LETTERS3[i - 9] ?? `#${i + 1}`;
9918
10358
  }
9919
10359
  function resolveProject(arg, list2) {
9920
10360
  const trimmed = arg.trim();
@@ -9927,7 +10367,7 @@ function resolveProject(arg, list2) {
9927
10367
  return { kind: "error", message: `no project at index ${n} (have ${list2.length})` };
9928
10368
  }
9929
10369
  if (/^[a-zA-Z]$/.test(trimmed)) {
9930
- const idx = 9 + LETTERS2.indexOf(trimmed.toLowerCase());
10370
+ const idx = 9 + LETTERS3.indexOf(trimmed.toLowerCase());
9931
10371
  if (idx >= 9 && idx < list2.length) return { kind: "ok", id: list2[idx].id };
9932
10372
  }
9933
10373
  const lower = trimmed.toLowerCase();
@@ -9941,12 +10381,12 @@ function resolveProject(arg, list2) {
9941
10381
  }
9942
10382
  return { kind: "error", message: `no project matches '${trimmed}'. Run /project (no args) for the list.` };
9943
10383
  }
9944
- var LETTERS2, projectCommand;
10384
+ var LETTERS3, projectCommand;
9945
10385
  var init_project = __esm({
9946
10386
  "src/commands/project.ts"() {
9947
10387
  "use strict";
9948
10388
  init_esm_shims();
9949
- LETTERS2 = "abcdefghijklmnopqrstuvwxyz";
10389
+ LETTERS3 = "abcdefghijklmnopqrstuvwxyz";
9950
10390
  projectCommand = {
9951
10391
  name: "project",
9952
10392
  args: "[number|letter|name|id|config]",
@@ -10005,7 +10445,7 @@ var init_project = __esm({
10005
10445
  }
10006
10446
  context.addMessage("system", "Available Projects:");
10007
10447
  projects.forEach((p, i) => {
10008
- const label = labelFor2(i);
10448
+ const label = labelFor3(i);
10009
10449
  const star = p.id === currentId ? " \u2605" : "";
10010
10450
  const usage = p.chat_count != null && p.chat_limit != null ? ` (${p.chat_count}/${p.chat_limit} chats)` : "";
10011
10451
  context.addMessage("system", ` ${label}. ${p.name ?? p.id}${usage}${star}`);
@@ -10616,7 +11056,7 @@ var init_hooks = __esm({
10616
11056
  init_src3();
10617
11057
  hooksCommand = {
10618
11058
  name: "hooks",
10619
- description: "List configured lifecycle hooks (pre-tool-use, post-tool-use, user-prompt-submit)",
11059
+ description: "List configured lifecycle hooks (pre/post-tool-use, user-prompt-submit, session-start/end, stop, subagent-stop, pre-compact, notification)",
10620
11060
  category: "debug",
10621
11061
  handler: async (args2, context) => {
10622
11062
  const sub = args2[0]?.toLowerCase();
@@ -10920,7 +11360,7 @@ var init_version = __esm({
10920
11360
  description: "Show version information for CLI and core packages",
10921
11361
  category: "debug",
10922
11362
  handler: async (_args, context) => {
10923
- const cliVersion = true ? "2.3.6-beta.44" : "(dev)";
11363
+ const cliVersion = true ? "2.3.6-beta.46" : "(dev)";
10924
11364
  const coreVersion = true ? "2.3.6-beta.43" : "(dev)";
10925
11365
  const runtime = process.version;
10926
11366
  context.addMessage("system", "MSapling Version Info");
@@ -10929,7 +11369,7 @@ var init_version = __esm({
10929
11369
  context.addMessage("system", row2("Core (@msapling/core)", coreVersion));
10930
11370
  context.addMessage("system", row2("Runtime (Node/Bun)", runtime));
10931
11371
  try {
10932
- const ts = "2026-06-20T07:08:23.320Z";
11372
+ const ts = "2026-06-20T07:22:28.613Z";
10933
11373
  if (ts && ts !== "__BUILD_TIMESTAMP__") {
10934
11374
  context.addMessage("system", row2("Build Timestamp", ts));
10935
11375
  }
@@ -12689,6 +13129,7 @@ var init_commands = __esm({
12689
13129
  init_exit();
12690
13130
  init_help();
12691
13131
  init_chat();
13132
+ init_resume();
12692
13133
  init_broadcast();
12693
13134
  init_ollama();
12694
13135
  init_keys();
@@ -12752,6 +13193,7 @@ var init_commands = __esm({
12752
13193
  helpCommand,
12753
13194
  chatCommand,
12754
13195
  chatsCommand,
13196
+ resumeCommand,
12755
13197
  broadcastCommand,
12756
13198
  ollamaCommand,
12757
13199
  keysCommand,
@@ -16462,7 +16904,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
16462
16904
  var Header = () => /* @__PURE__ */ jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1, marginBottom: 1, children: [
16463
16905
  /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
16464
16906
  "\u25CF MSapling CLI v",
16465
- "2.3.6-beta.44"
16907
+ "2.3.6-beta.46"
16466
16908
  ] }),
16467
16909
  /* @__PURE__ */ jsx(Box, { marginLeft: 2, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "Platinum Tier Architecture" }) })
16468
16910
  ] });
@@ -17263,7 +17705,7 @@ function useTerminalResize() {
17263
17705
 
17264
17706
  // src/App.tsx
17265
17707
  import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
17266
- var App = ({ compact: compact2 = false }) => {
17708
+ var App = ({ compact: compact2 = false, continueSession: continueSession2 = false }) => {
17267
17709
  const [user, setUser] = useState4(null);
17268
17710
  const [input, setInput] = useState4("");
17269
17711
  const [history, setHistory] = useState4([]);
@@ -17285,18 +17727,32 @@ var App = ({ compact: compact2 = false }) => {
17285
17727
  const { columns: termResizeCols, rows: termResizeRows } = useTerminalResize();
17286
17728
  const storage = useRef(new StorageManager()).current;
17287
17729
  const client = useRef(new MSaplingClient()).current;
17730
+ const agentRef = useRef(null);
17288
17731
  const requestApproval = useCallback2((request) => {
17732
+ agentRef.current?.fireLifecycleHook(
17733
+ "notification",
17734
+ { kind: "approval-request", tool: request.tool, command: request.command, reason: request.reason },
17735
+ request.tool
17736
+ );
17289
17737
  return new Promise((resolve20) => {
17290
17738
  setPendingApproval({ request, resolve: resolve20 });
17291
17739
  });
17292
17740
  }, []);
17293
17741
  const agent = useRef(new Agent(client, process.cwd(), requestApproval)).current;
17742
+ agentRef.current = agent;
17294
17743
  const trustStore = useRef(new TrustStore()).current;
17295
17744
  const lastActivityRef = useRef(Date.now());
17296
17745
  const pollingIntervalRef = useRef(null);
17297
17746
  useEffect4(() => {
17298
17747
  agent.setApprovalCallback(requestApproval);
17299
17748
  }, [agent, requestApproval]);
17749
+ useEffect4(() => {
17750
+ agent.setOnModeChange((m) => {
17751
+ setModeState(m);
17752
+ addMessage("system", `Mode changed to: ${m} (via plan-mode tool)`);
17753
+ });
17754
+ return () => agent.setOnModeChange(null);
17755
+ }, [agent]);
17300
17756
  const resolveApproval = useCallback2((decision) => {
17301
17757
  setPendingApproval((current) => {
17302
17758
  current?.resolve(decision);
@@ -17358,21 +17814,53 @@ var App = ({ compact: compact2 = false }) => {
17358
17814
  }
17359
17815
  }, [client, activeChatId, activeProjectId, setProjectId, handle401]);
17360
17816
  useEffect4(() => {
17361
- initSession({
17362
- agent,
17363
- client,
17364
- storage,
17365
- trustStore,
17366
- setActiveModel,
17367
- setMode,
17368
- setStatus,
17369
- addMessage,
17370
- refreshOverview,
17371
- setShellEscapeEnabled,
17372
- // CLI-PORT-STATE-04: restore persisted project/chat on cold start.
17373
- setProjectId,
17374
- setActiveChatId
17375
- });
17817
+ (async () => {
17818
+ await initSession({
17819
+ agent,
17820
+ client,
17821
+ storage,
17822
+ trustStore,
17823
+ setActiveModel,
17824
+ setMode,
17825
+ setStatus,
17826
+ addMessage,
17827
+ refreshOverview,
17828
+ setShellEscapeEnabled,
17829
+ // CLI-PORT-STATE-04: restore persisted project/chat on cold start.
17830
+ setProjectId,
17831
+ setActiveChatId
17832
+ });
17833
+ if (continueSession2) {
17834
+ try {
17835
+ const { rehydrateChat: rehydrateChat2 } = await Promise.resolve().then(() => (init_resume(), resume_exports));
17836
+ const recent = await client.listRecentChats(1);
17837
+ if (recent.length > 0) {
17838
+ await rehydrateChat2(recent[0], {
17839
+ client,
17840
+ setActiveChatId,
17841
+ clearHistory,
17842
+ addMessage,
17843
+ refreshOverview
17844
+ });
17845
+ } else {
17846
+ addMessage("system", "--continue: no prior chats to resume.");
17847
+ }
17848
+ } catch (e) {
17849
+ addMessage("system", `--continue: could not resume most-recent chat: ${e?.message ?? e}`);
17850
+ }
17851
+ }
17852
+ agent.fireLifecycleHook("session-start", {
17853
+ cwd: process.cwd(),
17854
+ compact: compact2,
17855
+ continued: continueSession2,
17856
+ chat_id: activeChatId
17857
+ });
17858
+ })();
17859
+ }, []);
17860
+ useEffect4(() => {
17861
+ return () => {
17862
+ agent.fireLifecycleHook("session-end", { cwd: process.cwd() });
17863
+ };
17376
17864
  }, []);
17377
17865
  useEffect4(() => {
17378
17866
  if (!user) return;
@@ -17531,6 +18019,7 @@ function handleCliArgs(args2) {
17531
18019
  console.log("msapling \u2014 MSapling CLI (React/Ink)");
17532
18020
  console.log("Usage: msapling start interactive REPL");
17533
18021
  console.log(" msapling --compact start REPL in compact mode (no footer, thin separators)");
18022
+ console.log(" msapling --continue resume your most-recent chat (alias: -c); replays its history");
17534
18023
  console.log(' msapling --exec "<cmd>" run one slash command non-interactively and exit');
17535
18024
  console.log(" msapling mcp serve run as MCP stdio server (Claude Code / Cursor / Windsurf integration)");
17536
18025
  console.log(" msapling doctor run diagnostic health checks");
@@ -17645,9 +18134,10 @@ if (!process.env.NODE_ENV?.includes("test")) {
17645
18134
  }
17646
18135
  var args = process.argv.slice(2);
17647
18136
  var compact = args.includes("--compact");
18137
+ var continueSession = args.includes("--continue") || args.includes("-c");
17648
18138
  var shouldRenderRepl = handleCliArgs(args);
17649
18139
  if (shouldRenderRepl && !process.env.NODE_ENV?.includes("test")) {
17650
- render(/* @__PURE__ */ jsx9(App, { compact }));
18140
+ render(/* @__PURE__ */ jsx9(App, { compact, continueSession }));
17651
18141
  }
17652
18142
  export {
17653
18143
  App,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtreeai/msapling-cli",
3
- "version": "2.3.6-beta.44",
3
+ "version": "2.3.6-beta.46",
4
4
  "description": "MSapling CLI — React/Ink terminal client for the MSapling backend (chat, projects, MDrive, agent tools). Proprietary; redistribution prohibited.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "MSapling Team",
@@ -51,7 +51,9 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "ink": "^4.4.1",
54
+ "proper-lockfile": "^4.1.2",
54
55
  "react": "^18.3.1",
56
+ "shell-quote": "^1.8.1",
55
57
  "yaml": "^2.8.3"
56
58
  },
57
59
  "optionalDependencies": {