@mtreeai/msapling-cli 2.3.6-beta.5 → 2.3.6-beta.6

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 +1168 -159
  2. package/package.json +55 -55
package/dist/index.js CHANGED
@@ -272,7 +272,11 @@ var init_src = __esm({
272
272
  try {
273
273
  const parts = token.split(".");
274
274
  if (parts.length !== 3) return null;
275
- const payload = JSON.parse(atob(parts[1]));
275
+ let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
276
+ const padLen = (4 - b64.length % 4) % 4;
277
+ b64 += "=".repeat(padLen);
278
+ const decoded = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
279
+ const payload = JSON.parse(decoded);
276
280
  return payload;
277
281
  } catch {
278
282
  return null;
@@ -374,8 +378,9 @@ var init_src = __esm({
374
378
  */
375
379
  async chatOnce(prompt, model, chatId) {
376
380
  let acc = "";
377
- for await (const chunk of this.streamChat({ prompt, model, chat_id: chatId })) {
378
- if (chunk.content) acc += chunk.content;
381
+ for await (const chunk of this.streamChat({ content: prompt, model, chat_id: chatId ?? "" })) {
382
+ if (chunk.delta) acc += chunk.delta;
383
+ else if (chunk.content) acc += chunk.content;
379
384
  }
380
385
  return acc;
381
386
  }
@@ -455,6 +460,33 @@ var init_src = __esm({
455
460
  });
456
461
  return data.hash;
457
462
  }
463
+ async getActiveTasks() {
464
+ return this.request("/api/task-monitor/active");
465
+ }
466
+ /**
467
+ * Wakeup Management (LAB-CHAT-PARITY-04)
468
+ */
469
+ async listWakeups(params = {}) {
470
+ const queryParams = new URLSearchParams(params);
471
+ return this.request(`/api/wakeups/?${queryParams.toString()}`);
472
+ }
473
+ async createWakeup(data) {
474
+ return this.request("/api/wakeups/", {
475
+ method: "POST",
476
+ body: JSON.stringify(data)
477
+ });
478
+ }
479
+ async cancelWakeup(id) {
480
+ await this.request(`/api/wakeups/${id}`, {
481
+ method: "DELETE"
482
+ });
483
+ }
484
+ /**
485
+ * Orchestration & Fleet Status
486
+ */
487
+ async getFleetStatus() {
488
+ return this.request("/api/orchestration/status");
489
+ }
458
490
  async proposeEdit(params) {
459
491
  return await this.request("/api/mdrive/ram/blocks/propose", {
460
492
  method: "POST",
@@ -462,6 +494,9 @@ var init_src = __esm({
462
494
  });
463
495
  }
464
496
  async *streamChat(params) {
497
+ if (!params.frame_id) {
498
+ params = { ...params, frame_id: "earth_surface" };
499
+ }
465
500
  const controller = new AbortController();
466
501
  const timeout = setTimeout(() => controller.abort(), 3e4);
467
502
  try {
@@ -487,22 +522,34 @@ var init_src = __esm({
487
522
  if (!response.body) throw new Error("No response body");
488
523
  const reader = response.body.getReader();
489
524
  const decoder = new TextDecoder();
525
+ let pending = "";
526
+ const yieldLine = function* (raw) {
527
+ const line = raw.replace(/\r$/, "").trim();
528
+ if (!line) return;
529
+ try {
530
+ yield JSON.parse(line);
531
+ } catch {
532
+ if (line.startsWith("{")) {
533
+ console.warn(`[Stream] Dropped malformed JSON: ${line}`);
534
+ }
535
+ }
536
+ };
490
537
  while (true) {
491
538
  const { done, value } = await reader.read();
492
539
  if (done) break;
493
- const chunk = decoder.decode(value, { stream: true });
494
- const lines = chunk.split("\n").filter((l) => l.trim());
495
- for (const line of lines) {
496
- try {
497
- const data = JSON.parse(line);
498
- yield data;
499
- } catch (e) {
500
- if (line.startsWith("{")) {
501
- console.warn(`[Stream] Dropped partial JSON: ${line}`);
502
- }
503
- }
540
+ pending += decoder.decode(value, { stream: true });
541
+ let nlIdx;
542
+ while ((nlIdx = pending.indexOf("\n")) !== -1) {
543
+ const raw = pending.slice(0, nlIdx);
544
+ pending = pending.slice(nlIdx + 1);
545
+ yield* yieldLine(raw);
504
546
  }
505
547
  }
548
+ pending += decoder.decode();
549
+ if (pending.length > 0) {
550
+ yield* yieldLine(pending);
551
+ pending = "";
552
+ }
506
553
  } catch (e) {
507
554
  if (e.name === "AbortError") {
508
555
  throw new MSaplingError("Request timed out after 30s.", 408, "timeout");
@@ -3493,18 +3540,18 @@ async function copyDir(src, dst) {
3493
3540
  async function backupFile(absPath) {
3494
3541
  try {
3495
3542
  const { readFile: readFile23, writeFile: writeFile12, mkdir: mkdir9 } = await import("fs/promises");
3496
- const { homedir: homedir16 } = await import("os");
3497
- const { join: join29 } = await import("path");
3543
+ const { homedir: homedir17 } = await import("os");
3544
+ const { join: join30 } = await import("path");
3498
3545
  const filename = absPath.split(/[\\/]/).pop() ?? "file";
3499
3546
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3500
3547
  const suffix = randomBytes5(4).toString("hex");
3501
- const backupPath = join29(
3502
- homedir16(),
3548
+ const backupPath = join30(
3549
+ homedir17(),
3503
3550
  ".msapling",
3504
3551
  "backups",
3505
3552
  `${filename}.backup-${stamp}-${suffix}.bak`
3506
3553
  );
3507
- await mkdir9(join29(homedir16(), ".msapling", "backups"), { recursive: true });
3554
+ await mkdir9(join30(homedir17(), ".msapling", "backups"), { recursive: true });
3508
3555
  const content = await readFile23(absPath, "utf8");
3509
3556
  await writeFile12(backupPath, content, "utf8");
3510
3557
  return backupPath;
@@ -3732,13 +3779,13 @@ var init_DeleteFileTool = __esm({
3732
3779
  if (isFile) {
3733
3780
  try {
3734
3781
  const { readFile: readFile23, writeFile: writeFile12, mkdir: mkdir9 } = await import("fs/promises");
3735
- const { homedir: homedir16 } = await import("os");
3782
+ const { homedir: homedir17 } = await import("os");
3736
3783
  const existingContent = await readFile23(abs, "utf8");
3737
3784
  const filename = abs.split(/[\\/]/).pop() ?? "file";
3738
3785
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3739
3786
  const suffix = randomBytes6(4).toString("hex");
3740
- const backupPath = join11(homedir16(), ".msapling", "backups", `${filename}.backup-${stamp}-${suffix}.bak`);
3741
- await mkdir9(join11(homedir16(), ".msapling", "backups"), { recursive: true });
3787
+ const backupPath = join11(homedir17(), ".msapling", "backups", `${filename}.backup-${stamp}-${suffix}.bak`);
3788
+ await mkdir9(join11(homedir17(), ".msapling", "backups"), { recursive: true });
3742
3789
  await writeFile12(backupPath, existingContent, "utf8");
3743
3790
  backedUpTo = backupPath;
3744
3791
  } catch {
@@ -3960,12 +4007,81 @@ var init_Sandbox = __esm({
3960
4007
  }
3961
4008
  return { status: "safe", hash };
3962
4009
  }
3963
- getRestrictedEnv() {
3964
- const safeKeys = ["PATH", "LANG", "LC_ALL", "NODE_ENV", "BUN_ENV"];
4010
+ /**
4011
+ * CLI-AUDIT-AUTO-44: curate PATH for sandboxed subprocesses.
4012
+ *
4013
+ * Previously `getRestrictedEnv` forwarded `process.env.PATH` verbatim. That
4014
+ * means any directory injected onto the user's PATH (npm-global, ~/.local/bin,
4015
+ * a malicious package's postinstall step adding /tmp/evil) inherits straight
4016
+ * into every sandboxed tool spawn. Containment is then trivially defeated:
4017
+ * "git" might resolve to /tmp/evil/git.
4018
+ *
4019
+ * Strategy:
4020
+ * - Default to a small allowlist of canonical system dirs (matches POSIX
4021
+ * `/etc/login.defs` style + common Windows system roots).
4022
+ * - Filter the caller's PATH against the allowlist, preserving order so
4023
+ * that legitimate /usr/local/bin/git still wins over /usr/bin/git.
4024
+ * - Drop world-writable / home-relative entries (`/tmp`, `~`, `.`, ``).
4025
+ * - Caller can override with an explicit `pathOverride` argument (e.g.
4026
+ * boot-time validation, tests).
4027
+ */
4028
+ static SAFE_PATH_ENTRIES = /* @__PURE__ */ new Set([
4029
+ // POSIX
4030
+ "/usr/local/sbin",
4031
+ "/usr/local/bin",
4032
+ "/usr/sbin",
4033
+ "/usr/bin",
4034
+ "/sbin",
4035
+ "/bin",
4036
+ // macOS extras
4037
+ "/opt/homebrew/bin",
4038
+ "/opt/homebrew/sbin",
4039
+ "/opt/local/bin",
4040
+ // Windows
4041
+ "C:\\Windows\\System32",
4042
+ "C:\\Windows",
4043
+ "C:\\Windows\\System32\\Wbem",
4044
+ "C:\\Windows\\System32\\WindowsPowerShell\\v1.0"
4045
+ ]);
4046
+ /**
4047
+ * Decide whether a single PATH entry is acceptable inside the sandbox.
4048
+ * Exposed (static) for unit testing.
4049
+ */
4050
+ static isSafePathEntry(entry) {
4051
+ if (!entry) return false;
4052
+ const e = entry.trim();
4053
+ if (!e) return false;
4054
+ if (e === "." || e === "..") return false;
4055
+ if (e.startsWith("~")) return false;
4056
+ if (e.startsWith("/tmp") || e.startsWith("/var/tmp")) return false;
4057
+ if (process.platform === "win32") {
4058
+ const lower = e.toLowerCase();
4059
+ for (const safe of _Sandbox.SAFE_PATH_ENTRIES) {
4060
+ if (safe.toLowerCase() === lower) return true;
4061
+ }
4062
+ return false;
4063
+ }
4064
+ return _Sandbox.SAFE_PATH_ENTRIES.has(e);
4065
+ }
4066
+ /**
4067
+ * Build a curated PATH from the host environment, retaining only entries that
4068
+ * pass `isSafePathEntry`. Order is preserved. If nothing matches, fall back
4069
+ * to a minimal platform default so subprocess spawn never sees an empty PATH.
4070
+ */
4071
+ static curatePath(rawPath) {
4072
+ const sep3 = process.platform === "win32" ? ";" : ":";
4073
+ const entries = (rawPath ?? "").split(sep3);
4074
+ const kept = entries.filter((e) => _Sandbox.isSafePathEntry(e));
4075
+ if (kept.length > 0) return kept.join(sep3);
4076
+ return process.platform === "win32" ? "C:\\Windows\\System32;C:\\Windows" : "/usr/local/bin:/usr/bin:/bin";
4077
+ }
4078
+ getRestrictedEnv(pathOverride) {
4079
+ const safeKeys = ["LANG", "LC_ALL", "NODE_ENV", "BUN_ENV"];
3965
4080
  const filteredEnv = {};
3966
4081
  for (const key of safeKeys) {
3967
4082
  if (process.env[key]) filteredEnv[key] = process.env[key];
3968
4083
  }
4084
+ filteredEnv["PATH"] = pathOverride ?? _Sandbox.curatePath(process.env.PATH);
3969
4085
  filteredEnv["MSAPLING_SANDBOX"] = "true";
3970
4086
  return filteredEnv;
3971
4087
  }
@@ -4389,6 +4505,35 @@ ${blocker.stderr || "(empty)"}`,
4389
4505
  };
4390
4506
  }
4391
4507
  }
4508
+ if (args2.path) {
4509
+ const check = this.sandbox.isPathSafe(args2.path);
4510
+ if (!check.safe) {
4511
+ await this.voice.speak(`Security block detected`, "urgent");
4512
+ const staleKey = `${toolName}:${(args2.path || "").trim()}`;
4513
+ if (this.trustStore?.has(staleKey)) {
4514
+ this.trustStore.delete(staleKey).catch(() => {
4515
+ });
4516
+ }
4517
+ return { content: `Security Block: ${check.reason}`, isError: true };
4518
+ }
4519
+ }
4520
+ if (toolName === "run_command" || toolName === "bash_command") {
4521
+ if (!args2.command) {
4522
+ return { content: `Error: ${toolName} requires a command argument`, isError: true };
4523
+ }
4524
+ const analysis = this.sandbox.analyzeCommand(args2.command);
4525
+ if (analysis.status === "blocked") {
4526
+ const staleCmdKey = toolName === "bash_command" ? `bash_command:${(args2.command || "").trim().replace(/\s+/g, " ")}${args2.cwd ? `:cwd=${args2.cwd}` : ""}` : `run_command:${(args2.command || "").trim()}`;
4527
+ if (this.trustStore?.has(staleCmdKey)) {
4528
+ this.trustStore.delete(staleCmdKey).catch(() => {
4529
+ });
4530
+ }
4531
+ return { content: `Security Block: ${analysis.reason}`, isError: true };
4532
+ }
4533
+ if (analysis.status === "dangerous" && this.mode !== "bypassPermissions") {
4534
+ return { content: `Dangerous Command Blocked: ${analysis.reason}. Use manual approval or trust hash ${analysis.hash}.`, isError: true };
4535
+ }
4536
+ }
4392
4537
  if (this.needsApproval(toolName) && this.approvalCallback) {
4393
4538
  let cmdKey = "";
4394
4539
  if (toolName === "run_command") {
@@ -4400,9 +4545,6 @@ ${blocker.stderr || "(empty)"}`,
4400
4545
  } else {
4401
4546
  cmdKey = `${toolName}:${(args2.path || args2.instruction || "").trim()}`;
4402
4547
  }
4403
- if ((toolName === "run_command" || toolName === "bash_command") && !args2.command) {
4404
- return { content: `Error: ${toolName} requires a command argument`, isError: true };
4405
- }
4406
4548
  const alreadyTrusted = this.trustStore ? this.trustStore.has(cmdKey) : this.sessionTrust.has(cmdKey);
4407
4549
  if (!alreadyTrusted) {
4408
4550
  const decision = await this.approvalCallback({
@@ -4449,22 +4591,6 @@ ${blocker.stderr || "(empty)"}`,
4449
4591
  Please approve the diff in the UI to sync this change locally.`
4450
4592
  };
4451
4593
  }
4452
- if (args2.path) {
4453
- const check = this.sandbox.isPathSafe(args2.path);
4454
- if (!check.safe) {
4455
- await this.voice.speak(`Security block detected`, "urgent");
4456
- return { content: `Security Block: ${check.reason}`, isError: true };
4457
- }
4458
- }
4459
- if (toolName === "run_command" || toolName === "bash_command") {
4460
- const analysis = this.sandbox.analyzeCommand(args2.command);
4461
- if (analysis.status === "blocked") {
4462
- return { content: `Security Block: ${analysis.reason}`, isError: true };
4463
- }
4464
- if (analysis.status === "dangerous" && this.mode !== "bypassPermissions") {
4465
- return { content: `Dangerous Command Blocked: ${analysis.reason}. Use manual approval or trust hash ${analysis.hash}.`, isError: true };
4466
- }
4467
- }
4468
4594
  const result = await tool.execute(args2, projectRoot);
4469
4595
  if (this.hooks) {
4470
4596
  this.hooks.fire({
@@ -5011,9 +5137,9 @@ var init_Mutex = __esm({
5011
5137
 
5012
5138
  // ../core/src/TrustStore.ts
5013
5139
  import { join as join13 } from "path";
5014
- import { homedir as homedir6 } from "os";
5140
+ import { homedir as homedir6, platform as platform2 } from "os";
5015
5141
  import { existsSync as existsSync11, mkdirSync } from "fs";
5016
- import { readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
5142
+ import { readFile as readFile10, writeFile as writeFile5, chmod } from "fs/promises";
5017
5143
  var USER_SETTINGS_PATH, TrustStore;
5018
5144
  var init_TrustStore = __esm({
5019
5145
  "../core/src/TrustStore.ts"() {
@@ -5053,6 +5179,12 @@ var init_TrustStore = __esm({
5053
5179
  const dir = join13(homedir6(), ".msapling");
5054
5180
  if (!existsSync11(dir)) mkdirSync(dir, { recursive: true });
5055
5181
  await writeFile5(this.settingsPath, JSON.stringify(settings, null, 2), "utf8");
5182
+ if (platform2() !== "win32") {
5183
+ try {
5184
+ await chmod(this.settingsPath, 384);
5185
+ } catch {
5186
+ }
5187
+ }
5056
5188
  }
5057
5189
  // ── Public API ─────────────────────────────────────────────────────────────
5058
5190
  /**
@@ -5130,7 +5262,7 @@ var require_polyfills = __commonJS({
5130
5262
  var constants = __require("constants");
5131
5263
  var origCwd = process.cwd;
5132
5264
  var cwd = null;
5133
- var platform3 = process.env.GRACEFUL_FS_PLATFORM || process.platform;
5265
+ var platform4 = process.env.GRACEFUL_FS_PLATFORM || process.platform;
5134
5266
  process.cwd = function() {
5135
5267
  if (!cwd)
5136
5268
  cwd = origCwd.call(process);
@@ -5189,7 +5321,7 @@ var require_polyfills = __commonJS({
5189
5321
  fs3.lchownSync = function() {
5190
5322
  };
5191
5323
  }
5192
- if (platform3 === "win32") {
5324
+ if (platform4 === "win32") {
5193
5325
  fs3.rename = typeof fs3.rename !== "function" ? fs3.rename : (function(fs$rename) {
5194
5326
  function rename2(from, to, cb) {
5195
5327
  var start = Date.now();
@@ -5656,8 +5788,8 @@ var require_graceful_fs = __commonJS({
5656
5788
  }
5657
5789
  var fs$appendFile = fs4.appendFile;
5658
5790
  if (fs$appendFile)
5659
- fs4.appendFile = appendFile;
5660
- function appendFile(path2, data, options, cb) {
5791
+ fs4.appendFile = appendFile2;
5792
+ function appendFile2(path2, data, options, cb) {
5661
5793
  if (typeof options === "function")
5662
5794
  cb = options, options = null;
5663
5795
  return go$appendFile(path2, data, options, cb);
@@ -6730,7 +6862,7 @@ var init_keytar = __esm({
6730
6862
 
6731
6863
  // node-file:<repo>\node_modules\.bun\keytar@7.9.0\node_modules\keytar\build\Release\keytar.node
6732
6864
  var require_keytar = __commonJS({
6733
- "node-file:D:\\Projects\\MSapling_CLI\\node_modules\\.bun\\keytar@7.9.0\\node_modules\\keytar\\build\\Release\\keytar.node"(exports, module) {
6865
+ "node-file:F:\\MForest\\projects\\MSapling_CLI\\node_modules\\.bun\\keytar@7.9.0\\node_modules\\keytar\\build\\Release\\keytar.node"(exports, module) {
6734
6866
  "use strict";
6735
6867
  init_esm_shims();
6736
6868
  init_keytar();
@@ -6784,9 +6916,12 @@ var require_keytar2 = __commonJS({
6784
6916
  // ../core/src/Storage.ts
6785
6917
  import { join as join14 } from "path";
6786
6918
  import { homedir as homedir7 } from "os";
6787
- import { chmodSync, existsSync as existsSync12, renameSync, unlinkSync } from "fs";
6788
- import { mkdir as mkdir6, writeFile as writeFile6, readFile as readFile11 } from "fs/promises";
6789
- import { randomBytes as randomBytes7 } from "crypto";
6919
+ import { chmodSync, existsSync as existsSync12, renameSync, unlinkSync, writeFileSync } from "fs";
6920
+ import { mkdir as mkdir6, writeFile as writeFile6, readFile as readFile11, appendFile } from "fs/promises";
6921
+ import { randomBytes as randomBytes7, createHash as createHash3 } from "crypto";
6922
+ function hashLine(line) {
6923
+ return createHash3("sha256").update(line, "utf8").digest("hex");
6924
+ }
6790
6925
  var lockfile, keytar, StorageManager;
6791
6926
  var init_Storage = __esm({
6792
6927
  "../core/src/Storage.ts"() {
@@ -6819,7 +6954,18 @@ var init_Storage = __esm({
6819
6954
  async ensureDirs() {
6820
6955
  try {
6821
6956
  await mkdir6(this.baseDir, { recursive: true });
6822
- const subdirs = ["history", "backups", "cache", "vault"];
6957
+ const subdirs = [
6958
+ "history",
6959
+ "backups",
6960
+ "cache",
6961
+ // CLI-ARCH-CONTENT-HASH-VAULT-01: content-addressed vault layout
6962
+ "vault",
6963
+ "vault/objects",
6964
+ "vault/refs",
6965
+ // CLI-ARCH-RECIPE-AT-HASH-URI-01: recipe hash cache
6966
+ "cache/recipes",
6967
+ "cache/recipes/objects"
6968
+ ];
6823
6969
  for (const sub of subdirs) {
6824
6970
  const path2 = join14(this.baseDir, sub);
6825
6971
  await mkdir6(path2, { recursive: true });
@@ -6895,6 +7041,230 @@ var init_Storage = __esm({
6895
7041
  console.warn(`Failed to delete token file: ${e}`);
6896
7042
  }
6897
7043
  }
7044
+ // ─────────────────────────────────────────────────────────────────────────
7045
+ // CLI-ARCH-CONTENT-HASH-VAULT-01 — content-addressed vault helpers
7046
+ //
7047
+ // Every vault value is a content-addressed blob stored under
7048
+ // ~/.msapling/vault/objects/<sha256-hex>
7049
+ // A human-readable ref file under
7050
+ // ~/.msapling/vault/refs/<label>
7051
+ // holds the sha256-hex of the current value.
7052
+ //
7053
+ // Updating a value: write new object → atomically rewrite ref.
7054
+ // Old objects are GC'd separately (retention window TBD — see roadmap).
7055
+ // The keytar account name stores the sha256-hex so the plaintext label
7056
+ // remains opaque on disk (label → hash is in keytar; hash → blob is on disk).
7057
+ // ─────────────────────────────────────────────────────────────────────────
7058
+ /**
7059
+ * Write a value into the content-addressed object store and atomically
7060
+ * update the named ref. Returns the sha256 hex of the stored object.
7061
+ *
7062
+ * WAL-PATTERN: write to objects/<hash> (idempotent — same content = same
7063
+ * path), then rename refs/<label>.tmp → refs/<label>.
7064
+ */
7065
+ async writeVaultRef(label, value) {
7066
+ await this._ready;
7067
+ const hash = createHash3("sha256").update(value, "utf8").digest("hex");
7068
+ const objectPath = join14(this.baseDir, "vault", "objects", hash);
7069
+ const refPath = join14(this.baseDir, "vault", "refs", label);
7070
+ const refTmp = `${refPath}.tmp`;
7071
+ await writeFile6(objectPath, value, "utf8");
7072
+ if (process.platform !== "win32") {
7073
+ chmodSync(objectPath, 384);
7074
+ }
7075
+ await writeFile6(refTmp, hash, "utf8");
7076
+ renameSync(refTmp, refPath);
7077
+ return hash;
7078
+ }
7079
+ /**
7080
+ * Read a vault value by label. Returns null if the ref or its object is
7081
+ * missing (bootstrap / first-run case).
7082
+ */
7083
+ async readVaultRef(label) {
7084
+ await this._ready;
7085
+ const refPath = join14(this.baseDir, "vault", "refs", label);
7086
+ if (!existsSync12(refPath)) return null;
7087
+ const hash = (await readFile11(refPath, "utf8")).trim();
7088
+ const objectPath = join14(this.baseDir, "vault", "objects", hash);
7089
+ if (!existsSync12(objectPath)) return null;
7090
+ return readFile11(objectPath, "utf8");
7091
+ }
7092
+ // ─────────────────────────────────────────────────────────────────────────
7093
+ // CLI-ARCH-RECIPE-AT-HASH-URI-01 — recipe hash registry helpers
7094
+ //
7095
+ // On first load of a recipe file, its content is sha256-hashed and the
7096
+ // mapping { name → hash } is stored in
7097
+ // ~/.msapling/cache/recipes/index.json
7098
+ // The immutable blob is written to
7099
+ // ~/.msapling/cache/recipes/objects/<sha256>
7100
+ //
7101
+ // Invocations reference recipes as `recipe@<sha256>` URIs. The /recipe
7102
+ // command resolves a bare name through the index and logs the hash for
7103
+ // traceability.
7104
+ // ─────────────────────────────────────────────────────────────────────────
7105
+ /**
7106
+ * Register a recipe file in the local hash index. Idempotent — if the
7107
+ * content hash already exists the call is a no-op (returns existing hash).
7108
+ * Returns the sha256 hex for use in log messages / URI construction.
7109
+ */
7110
+ async registerRecipe(name, content) {
7111
+ await this._ready;
7112
+ const hash = createHash3("sha256").update(content, "utf8").digest("hex");
7113
+ const objectPath = join14(this.baseDir, "cache", "recipes", "objects", hash);
7114
+ const indexPath = join14(this.baseDir, "cache", "recipes", "index.json");
7115
+ const indexTmp = `${indexPath}.tmp`;
7116
+ if (!existsSync12(objectPath)) {
7117
+ await writeFile6(objectPath, content, "utf8");
7118
+ }
7119
+ let index = {};
7120
+ if (existsSync12(indexPath)) {
7121
+ try {
7122
+ index = JSON.parse(await readFile11(indexPath, "utf8"));
7123
+ } catch {
7124
+ index = {};
7125
+ }
7126
+ }
7127
+ index[name] = hash;
7128
+ await writeFile6(indexTmp, JSON.stringify(index, null, 2), "utf8");
7129
+ renameSync(indexTmp, indexPath);
7130
+ return hash;
7131
+ }
7132
+ /**
7133
+ * Resolve a recipe name or `recipe@<hash>` URI to its cached content.
7134
+ * Returns { hash, content } or null if not found in the local cache.
7135
+ */
7136
+ async resolveRecipe(nameOrRef) {
7137
+ await this._ready;
7138
+ const indexPath = join14(this.baseDir, "cache", "recipes", "index.json");
7139
+ const atIdx = nameOrRef.indexOf("@");
7140
+ if (atIdx !== -1) {
7141
+ const hash2 = nameOrRef.slice(atIdx + 1);
7142
+ const objectPath2 = join14(this.baseDir, "cache", "recipes", "objects", hash2);
7143
+ if (!existsSync12(objectPath2)) return null;
7144
+ return { hash: hash2, content: await readFile11(objectPath2, "utf8") };
7145
+ }
7146
+ if (!existsSync12(indexPath)) return null;
7147
+ let index;
7148
+ try {
7149
+ index = JSON.parse(await readFile11(indexPath, "utf8"));
7150
+ } catch {
7151
+ return null;
7152
+ }
7153
+ const hash = index[nameOrRef];
7154
+ if (!hash) return null;
7155
+ const objectPath = join14(this.baseDir, "cache", "recipes", "objects", hash);
7156
+ if (!existsSync12(objectPath)) return null;
7157
+ return { hash, content: await readFile11(objectPath, "utf8") };
7158
+ }
7159
+ // ─────────────────────────────────────────────────────────────────────────
7160
+ // CLI-ARCH-HASH-CHAIN-HISTORY-01 — append-only NDJSON history with hash chain
7161
+ //
7162
+ // Shell history is stored as an append-only NDJSON file
7163
+ // ~/.msapling/history/shell_history.jsonl
7164
+ // Each entry carries a `prev_hash` field so tampering is detectable.
7165
+ // `msapling --verify-history` re-hashes the chain and reports breaks.
7166
+ // ─────────────────────────────────────────────────────────────────────────
7167
+ /**
7168
+ * Append a single command to the hash-chain history.
7169
+ * Uses lockfile + in-process mutex as a critical section around:
7170
+ * 1. Load last entry to compute prev_hash
7171
+ * 2. Build + serialize new entry
7172
+ * 3. Append to shell_history.jsonl
7173
+ *
7174
+ * WAL-PATTERN note: append-only log uses appendFile, not rename — the
7175
+ * lockfile is the write-serialisation primitive here.
7176
+ */
7177
+ async appendHistoryEntry(content) {
7178
+ await this._ready;
7179
+ const path2 = join14(this.baseDir, "history", "shell_history.jsonl");
7180
+ return this.historyMutex.run(async () => {
7181
+ let release2 = null;
7182
+ try {
7183
+ if (!existsSync12(path2)) {
7184
+ await writeFile6(path2, "", "utf8");
7185
+ }
7186
+ release2 = await lockfile.lock(path2, { retries: 5, retryWait: 50 });
7187
+ let prevHash = null;
7188
+ let seq = 1;
7189
+ if (existsSync12(path2)) {
7190
+ const raw = (await readFile11(path2, "utf8")).trimEnd();
7191
+ if (raw.length > 0) {
7192
+ const lines = raw.split("\n");
7193
+ const lastLine = lines[lines.length - 1];
7194
+ try {
7195
+ const last = JSON.parse(lastLine);
7196
+ seq = last.seq + 1;
7197
+ prevHash = hashLine(lastLine);
7198
+ } catch {
7199
+ }
7200
+ }
7201
+ }
7202
+ const entry = {
7203
+ seq,
7204
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
7205
+ content,
7206
+ prev_hash: prevHash
7207
+ };
7208
+ const line = JSON.stringify(entry);
7209
+ await appendFile(path2, line + "\n", "utf8");
7210
+ return entry;
7211
+ } finally {
7212
+ if (release2) {
7213
+ try {
7214
+ await lockfile.unlock(path2, { skipStale: true });
7215
+ } catch {
7216
+ }
7217
+ }
7218
+ }
7219
+ });
7220
+ }
7221
+ /**
7222
+ * Load all history entries from shell_history.jsonl.
7223
+ * Skips malformed lines (logs a warning for each).
7224
+ *
7225
+ * // TODO: implement tail-read for large history files to avoid loading entire file into memory
7226
+ */
7227
+ async loadHistoryEntries() {
7228
+ await this._ready;
7229
+ const path2 = join14(this.baseDir, "history", "shell_history.jsonl");
7230
+ if (!existsSync12(path2)) return [];
7231
+ const raw = await readFile11(path2, "utf8");
7232
+ const entries = [];
7233
+ for (const line of raw.split("\n")) {
7234
+ if (!line.trim()) continue;
7235
+ try {
7236
+ entries.push(JSON.parse(line));
7237
+ } catch {
7238
+ console.warn(`[history] Skipped malformed NDJSON line: ${line.slice(0, 80)}`);
7239
+ }
7240
+ }
7241
+ return entries;
7242
+ }
7243
+ /**
7244
+ * Verify the hash chain of shell_history.jsonl.
7245
+ * Returns { ok: true } if intact, or { ok: false, breaks: [...] } listing
7246
+ * each break as { seq, expected, actual }.
7247
+ *
7248
+ * Used by `msapling --verify-history`.
7249
+ */
7250
+ async verifyHistory() {
7251
+ const entries = await this.loadHistoryEntries();
7252
+ const breaks = [];
7253
+ let prevLine = null;
7254
+ for (const entry of entries) {
7255
+ if (prevLine !== null) {
7256
+ const expected = hashLine(prevLine);
7257
+ const actual = entry.prev_hash ?? "";
7258
+ if (expected !== actual) {
7259
+ breaks.push({ seq: entry.seq, expected, actual });
7260
+ }
7261
+ } else if (entry.prev_hash !== null) {
7262
+ breaks.push({ seq: entry.seq, expected: "null", actual: entry.prev_hash });
7263
+ }
7264
+ prevLine = JSON.stringify(entry);
7265
+ }
7266
+ return breaks.length === 0 ? { ok: true } : { ok: false, breaks };
7267
+ }
6898
7268
  /**
6899
7269
  * SYNC-01: Serialised history write with file-based locking.
6900
7270
  * Uses proper-lockfile to coordinate writes across multiple terminal instances
@@ -6905,10 +7275,12 @@ var init_Storage = __esm({
6905
7275
  * 2. Partial writes don't corrupt the JSON (atomic rename)
6906
7276
  */
6907
7277
  async saveHistory(history) {
7278
+ await this._ready;
6908
7279
  const path2 = join14(this.baseDir, "history", "shell_history.json");
6909
7280
  let release2;
6910
7281
  try {
6911
- release2 = await lockfile.lock(path2, { retries: 5, retryWait: 50 });
7282
+ if (!existsSync12(path2)) writeFileSync(path2, "[]", "utf8");
7283
+ release2 = await lockfile.lock(path2, { realpath: false, retries: 5, retryWait: 50 });
6912
7284
  await this.historyMutex.run(async () => {
6913
7285
  const tmpPath = `${path2}.tmp`;
6914
7286
  const content = JSON.stringify(history, null, 2);
@@ -6940,16 +7312,38 @@ var init_Storage = __esm({
6940
7312
  * SYNC-01: Serialised history read with file-based locking.
6941
7313
  * Reads are gated behind the file lock so a read that overlaps with an
6942
7314
  * in-progress write from another process always sees a complete, valid JSON.
7315
+ *
7316
+ * // TODO: implement tail-read for large history files to avoid loading entire file into memory
6943
7317
  */
6944
7318
  async loadHistory() {
7319
+ await this._ready;
6945
7320
  const path2 = join14(this.baseDir, "history", "shell_history.json");
7321
+ if (!existsSync12(path2)) return [];
6946
7322
  let release2;
6947
7323
  try {
6948
- release2 = await lockfile.lock(path2, { retries: 5, retryWait: 50 });
7324
+ release2 = await lockfile.lock(path2, { realpath: false, retries: 5, retryWait: 50 });
6949
7325
  return this.historyMutex.run(async () => {
6950
7326
  if (existsSync12(path2)) {
6951
7327
  const text = await readFile11(path2, "utf8");
6952
- return JSON.parse(text);
7328
+ try {
7329
+ return JSON.parse(text);
7330
+ } catch (parseErr) {
7331
+ const filename = path2.split("/").pop() || "shell_history.json";
7332
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7333
+ const suffix = randomBytes7(4).toString("hex");
7334
+ const corruptBackupPath = join14(
7335
+ this.baseDir,
7336
+ "history",
7337
+ `${filename}.corrupt.${stamp}-${suffix}.bak`
7338
+ );
7339
+ try {
7340
+ renameSync(path2, corruptBackupPath);
7341
+ console.warn(`History file was corrupt; backed up to ${corruptBackupPath}`);
7342
+ } catch (backupErr) {
7343
+ console.error(`Failed to backup corrupt history file: ${backupErr}`);
7344
+ }
7345
+ return [];
7346
+ }
6953
7347
  }
6954
7348
  return [];
6955
7349
  });
@@ -6965,12 +7359,14 @@ var init_Storage = __esm({
6965
7359
  }
6966
7360
  /**
6967
7361
  * SYNC-01: Serialised permissions write with file-based locking.
7362
+ * R20-CLI-2: Typed with PermissionState from Sandbox.ts.
6968
7363
  */
6969
7364
  async savePermissions(permissions) {
6970
7365
  const path2 = join14(this.baseDir, "vault", "permissions.json");
6971
7366
  let release2;
6972
7367
  try {
6973
- release2 = await lockfile.lock(path2, { retries: 5, retryWait: 50 });
7368
+ if (!existsSync12(path2)) writeFileSync(path2, "{}", "utf8");
7369
+ release2 = await lockfile.lock(path2, { realpath: false, retries: 5, retryWait: 50 });
6974
7370
  await this.permissionsMutex.run(async () => {
6975
7371
  const tmpPath = `${path2}.tmp`;
6976
7372
  const content = JSON.stringify(permissions, null, 2);
@@ -7000,16 +7396,36 @@ var init_Storage = __esm({
7000
7396
  }
7001
7397
  /**
7002
7398
  * SYNC-01: Serialised permissions read with file-based locking.
7399
+ * R20-CLI-2: Typed with PermissionState from Sandbox.ts.
7003
7400
  */
7004
7401
  async loadPermissions() {
7005
7402
  const path2 = join14(this.baseDir, "vault", "permissions.json");
7403
+ if (!existsSync12(path2)) return { trustedCommands: [], trustedPaths: [] };
7006
7404
  let release2;
7007
7405
  try {
7008
- release2 = await lockfile.lock(path2, { retries: 5, retryWait: 50 });
7406
+ release2 = await lockfile.lock(path2, { realpath: false, retries: 5, retryWait: 50 });
7009
7407
  return this.permissionsMutex.run(async () => {
7010
7408
  if (existsSync12(path2)) {
7011
7409
  const text = await readFile11(path2, "utf8");
7012
- return JSON.parse(text);
7410
+ try {
7411
+ return JSON.parse(text);
7412
+ } catch (parseErr) {
7413
+ const filename = path2.split("/").pop() || "permissions.json";
7414
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7415
+ const suffix = randomBytes7(4).toString("hex");
7416
+ const corruptBackupPath = join14(
7417
+ this.baseDir,
7418
+ "vault",
7419
+ `${filename}.corrupt.${stamp}-${suffix}.bak`
7420
+ );
7421
+ try {
7422
+ renameSync(path2, corruptBackupPath);
7423
+ console.warn(`Permissions file was corrupt; backed up to ${corruptBackupPath}`);
7424
+ } catch (backupErr) {
7425
+ console.error(`Failed to backup corrupt permissions file: ${backupErr}`);
7426
+ }
7427
+ return { trustedCommands: [], trustedPaths: [] };
7428
+ }
7013
7429
  }
7014
7430
  return { trustedCommands: [], trustedPaths: [] };
7015
7431
  });
@@ -7051,6 +7467,7 @@ import { join as join15 } from "path";
7051
7467
  import { existsSync as existsSync13 } from "fs";
7052
7468
  import * as fs from "fs";
7053
7469
  import { readFile as readFile12 } from "fs/promises";
7470
+ import { randomBytes as randomBytes8 } from "crypto";
7054
7471
  function ensureConfigDir(p) {
7055
7472
  try {
7056
7473
  fs.mkdirSync(p, { recursive: true, mode: 448 });
@@ -7058,7 +7475,9 @@ function ensureConfigDir(p) {
7058
7475
  if (e.code === "EEXIST" || e.code === "ENOTDIR") {
7059
7476
  const stat5 = fs.statSync(p);
7060
7477
  if (!stat5.isDirectory()) {
7061
- fs.renameSync(p, `${p}.broken-${Date.now()}`);
7478
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7479
+ const suffix = randomBytes8(4).toString("hex");
7480
+ fs.renameSync(p, `${p}.broken-${stamp}-${suffix}`);
7062
7481
  fs.mkdirSync(p, { recursive: true, mode: 448 });
7063
7482
  }
7064
7483
  } else {
@@ -7508,26 +7927,31 @@ function isEmailAddress(str) {
7508
7927
  function isTokenLike(str) {
7509
7928
  return str.length >= 20 && /^[A-Za-z0-9_\-\.]+$/.test(str);
7510
7929
  }
7930
+ function setRawModeGuarded(stdin, mode) {
7931
+ try {
7932
+ if (typeof stdin.setRawMode === "function") {
7933
+ stdin.setRawMode(mode);
7934
+ }
7935
+ } catch (e) {
7936
+ }
7937
+ }
7511
7938
  async function promptPassword(prompt) {
7512
7939
  return new Promise((resolve18) => {
7513
7940
  const stdin = process.stdin;
7514
7941
  const stdout = process.stdout;
7515
7942
  stdout.write(prompt);
7516
- const wasRaw = stdin.isRaw;
7517
- try {
7518
- stdin.setRawMode(true);
7519
- } catch (e) {
7520
- }
7943
+ const wasRaw = stdin.isRaw ?? false;
7944
+ setRawModeGuarded(stdin, true);
7521
7945
  let password = "";
7522
7946
  const onData = (chunk) => {
7523
7947
  const char = chunk.toString();
7524
7948
  if (char === "\n" || char === "\r") {
7525
- stdin.setRawMode(wasRaw);
7949
+ setRawModeGuarded(stdin, wasRaw);
7526
7950
  stdin.removeListener("data", onData);
7527
7951
  stdout.write("\n");
7528
7952
  resolve18(password);
7529
7953
  } else if (char === "") {
7530
- stdin.setRawMode(wasRaw);
7954
+ setRawModeGuarded(stdin, wasRaw);
7531
7955
  stdin.removeListener("data", onData);
7532
7956
  stdout.write("\n");
7533
7957
  resolve18("");
@@ -7545,11 +7969,8 @@ async function promptTotp(prompt) {
7545
7969
  const stdin = process.stdin;
7546
7970
  const stdout = process.stdout;
7547
7971
  stdout.write(prompt);
7548
- const wasRaw = stdin.isRaw;
7549
- try {
7550
- stdin.setRawMode(true);
7551
- } catch (e) {
7552
- }
7972
+ const wasRaw = stdin.isRaw ?? false;
7973
+ setRawModeGuarded(stdin, true);
7553
7974
  let code = "";
7554
7975
  const onData = (chunk) => {
7555
7976
  const char = chunk.toString();
@@ -7559,7 +7980,7 @@ async function promptTotp(prompt) {
7559
7980
  stdout.write("\n");
7560
7981
  resolve18(code);
7561
7982
  } else if (char === "") {
7562
- stdin.setRawMode(wasRaw);
7983
+ setRawModeGuarded(stdin, wasRaw);
7563
7984
  stdin.removeListener("data", onData);
7564
7985
  stdout.write("\n");
7565
7986
  resolve18("");
@@ -7647,7 +8068,7 @@ async function loginWithGithubDevice(context) {
7647
8068
  `Open ${verification_uri} in your browser and enter code: ${user_code}
7648
8069
  (Waiting for authorization \u2014 expires in ${expires_in}s)`
7649
8070
  );
7650
- const pollMs = (interval + 1) * 1e3;
8071
+ let pollMs = (interval + 1) * 1e3;
7651
8072
  const deadline = Date.now() + expires_in * 1e3;
7652
8073
  let githubToken = null;
7653
8074
  while (Date.now() < deadline) {
@@ -7674,6 +8095,7 @@ async function loginWithGithubDevice(context) {
7674
8095
  }
7675
8096
  if (tokenData.error === "authorization_pending") continue;
7676
8097
  if (tokenData.error === "slow_down") {
8098
+ pollMs += 5e3;
7677
8099
  await new Promise((resolve18) => setTimeout(resolve18, 5e3));
7678
8100
  continue;
7679
8101
  }
@@ -7714,7 +8136,7 @@ var init_login = __esm({
7714
8136
  init_esm_shims();
7715
8137
  GITHUB_CLIENT_ID = "Ov23liOA9yKFLUEEVY3G";
7716
8138
  GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
7717
- GITHUB_TOKEN_URL = "https://github.com/oauth/access_token";
8139
+ GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
7718
8140
  loginCommand = {
7719
8141
  name: "login",
7720
8142
  args: "[email|github|token]",
@@ -8547,6 +8969,7 @@ var init_recipe = __esm({
8547
8969
  "src/commands/recipe.ts"() {
8548
8970
  "use strict";
8549
8971
  init_esm_shims();
8972
+ init_src3();
8550
8973
  RECIPE_DIRS = [".msapling/recipes", ".claude/recipes", ".gemini/recipes"];
8551
8974
  NAME_SUFFIXES = ["", "-workflow"];
8552
8975
  FILE_EXTS = [".yaml", ".yml"];
@@ -8580,14 +9003,21 @@ var init_recipe = __esm({
8580
9003
  context.addMessage("system", `Usage: /recipe ${name} <prompt> (the prompt fills $SELECTION / $PROMPT in step templates)`);
8581
9004
  return;
8582
9005
  }
9006
+ let text;
8583
9007
  let recipe;
8584
9008
  try {
8585
- const text = await readFile16(path2, "utf8");
9009
+ text = await readFile16(path2, "utf8");
8586
9010
  recipe = parseYaml(text);
8587
9011
  } catch (e) {
8588
9012
  context.addMessage("error", `Failed to load ${path2}: ${e.message}`);
8589
9013
  return;
8590
9014
  }
9015
+ try {
9016
+ const storage = new StorageManager();
9017
+ const hash = await storage.registerRecipe(name, text);
9018
+ context.addMessage("system", `[recipe] Resolved ${name} \u2192 recipe@${hash.slice(0, 12)}\u2026`);
9019
+ } catch {
9020
+ }
8591
9021
  const steps = recipe.steps ?? [];
8592
9022
  if (steps.length === 0) {
8593
9023
  context.addMessage("system", `Recipe '${name}' has no steps.`);
@@ -9721,6 +10151,207 @@ var init_todo = __esm({
9721
10151
  }
9722
10152
  });
9723
10153
 
10154
+ // src/commands/outputStyle.ts
10155
+ import { homedir as homedir15 } from "os";
10156
+ import { join as join27, basename, extname as extname3 } from "path";
10157
+ import { existsSync as existsSync24, mkdirSync as mkdirSync5, readdirSync as readdirSync3, readFileSync, writeFileSync as writeFileSync2 } from "fs";
10158
+ function stylesDir() {
10159
+ return join27(homedir15(), ".msapling", "output-styles");
10160
+ }
10161
+ function activeFile() {
10162
+ return join27(stylesDir(), ".active");
10163
+ }
10164
+ function parseStyleFile(text) {
10165
+ const fm = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
10166
+ if (!fm) {
10167
+ const lines = text.split(/\r?\n/);
10168
+ for (const line of lines) {
10169
+ if (!line.trim()) continue;
10170
+ const desc = line.replace(/^#\s+/, "").trim();
10171
+ return { description: desc || "(user style)", body: text.trim() };
10172
+ }
10173
+ return { description: "(user style)", body: text.trim() };
10174
+ }
10175
+ const meta = fm[1];
10176
+ const body = text.slice(fm[0].length).trim();
10177
+ const descMatch = meta.match(/^description:\s*(.+)$/m);
10178
+ const description = descMatch ? descMatch[1].trim().replace(/^['"]|['"]$/g, "") : "(user style)";
10179
+ return { description, body };
10180
+ }
10181
+ function listUserStyles() {
10182
+ const dir = stylesDir();
10183
+ if (!existsSync24(dir)) return [];
10184
+ const out = [];
10185
+ for (const entry of readdirSync3(dir)) {
10186
+ if (extname3(entry).toLowerCase() !== ".md") continue;
10187
+ const full = join27(dir, entry);
10188
+ try {
10189
+ const text = readFileSync(full, "utf8");
10190
+ const { description, body } = parseStyleFile(text);
10191
+ out.push({
10192
+ name: basename(entry, ".md"),
10193
+ description,
10194
+ body,
10195
+ source: "user",
10196
+ path: full
10197
+ });
10198
+ } catch {
10199
+ }
10200
+ }
10201
+ return out;
10202
+ }
10203
+ function listStyles() {
10204
+ const user = listUserStyles();
10205
+ const userNames = new Set(user.map((s) => s.name));
10206
+ const builtins = BUILTIN_STYLES.filter((s) => !userNames.has(s.name));
10207
+ return [...builtins, ...user];
10208
+ }
10209
+ function findStyle(name) {
10210
+ return listStyles().find((s) => s.name === name) ?? null;
10211
+ }
10212
+ function getActiveStyleName() {
10213
+ try {
10214
+ const f = activeFile();
10215
+ if (!existsSync24(f)) return "default";
10216
+ return readFileSync(f, "utf8").trim() || "default";
10217
+ } catch {
10218
+ return "default";
10219
+ }
10220
+ }
10221
+ function setActiveStyleName(name) {
10222
+ const dir = stylesDir();
10223
+ if (!existsSync24(dir)) mkdirSync5(dir, { recursive: true });
10224
+ writeFileSync2(activeFile(), `${name}
10225
+ `, "utf8");
10226
+ }
10227
+ function getActiveStyle() {
10228
+ const name = getActiveStyleName();
10229
+ return findStyle(name) ?? BUILTIN_STYLES[0];
10230
+ }
10231
+ function createUserStyle(name, description, body) {
10232
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) {
10233
+ throw new Error(`Invalid style name "${name}" \u2014 use letters, digits, _ and - only.`);
10234
+ }
10235
+ const dir = stylesDir();
10236
+ if (!existsSync24(dir)) mkdirSync5(dir, { recursive: true });
10237
+ const target = join27(dir, `${name}.md`);
10238
+ const frontmatter = `---
10239
+ description: ${description.replace(/\n/g, " ")}
10240
+ ---
10241
+
10242
+ `;
10243
+ writeFileSync2(target, frontmatter + body.trim() + "\n", "utf8");
10244
+ return target;
10245
+ }
10246
+ var BUILTIN_STYLES, outputStyleCommand;
10247
+ var init_outputStyle = __esm({
10248
+ "src/commands/outputStyle.ts"() {
10249
+ "use strict";
10250
+ init_esm_shims();
10251
+ BUILTIN_STYLES = [
10252
+ {
10253
+ name: "default",
10254
+ description: "Default MSapling behavior \u2014 no extra system prefix.",
10255
+ body: "",
10256
+ source: "builtin",
10257
+ path: null
10258
+ },
10259
+ {
10260
+ name: "concise",
10261
+ description: "Terse, code-first answers; minimal prose.",
10262
+ body: "You are MSapling in concise mode. Keep every response as short as possible.\nPrefer code blocks, bullet lists, and direct answers. Skip preamble, recap,\nand offers to elaborate unless explicitly asked.",
10263
+ source: "builtin",
10264
+ path: null
10265
+ },
10266
+ {
10267
+ name: "explanatory",
10268
+ description: "Walk through reasoning and trade-offs before the answer.",
10269
+ body: "You are MSapling in explanatory mode. Briefly walk through your reasoning,\npoint out trade-offs, and cite the relevant file or doc before giving the\nfinal answer. Aim for 2\u20133 short paragraphs, then a clear conclusion.",
10270
+ source: "builtin",
10271
+ path: null
10272
+ },
10273
+ {
10274
+ name: "learning",
10275
+ description: "Teach-by-doing: explain concepts, ask checkpoint questions.",
10276
+ body: "You are MSapling in learning mode. Assume the user is new to the topic.\nExplain key concepts in plain language, surface common pitfalls, and end\nwith a one-sentence comprehension check the user can answer or skip.",
10277
+ source: "builtin",
10278
+ path: null
10279
+ }
10280
+ ];
10281
+ outputStyleCommand = {
10282
+ name: "output-style",
10283
+ aliases: ["style"],
10284
+ args: "[list|use <name>|new <name> <description>]",
10285
+ description: "List, switch, or create assistant output styles",
10286
+ category: "model",
10287
+ handler: (args2, context) => {
10288
+ const sub = (args2[0] ?? "list").toLowerCase();
10289
+ if (sub === "list" || sub === "ls") {
10290
+ const styles = listStyles();
10291
+ const active = getActiveStyleName();
10292
+ context.addMessage("system", "Output Styles:");
10293
+ for (const s of styles) {
10294
+ const marker = s.name === active ? "*" : " ";
10295
+ const tag = s.source === "builtin" ? "[builtin]" : "[user]";
10296
+ context.addMessage("system", ` ${marker} ${s.name.padEnd(14)} ${tag} ${s.description}`);
10297
+ }
10298
+ context.addMessage("system", `Active: ${active}`);
10299
+ return;
10300
+ }
10301
+ if (sub === "use") {
10302
+ const name = args2[1];
10303
+ if (!name) {
10304
+ context.addMessage("error", "Usage: /output-style use <name>");
10305
+ return;
10306
+ }
10307
+ const style = findStyle(name);
10308
+ if (!style) {
10309
+ context.addMessage("error", `Unknown style: ${name}. Try /output-style list.`);
10310
+ return;
10311
+ }
10312
+ setActiveStyleName(name);
10313
+ context.addMessage("system", `Active output style: ${name} \u2014 ${style.description}`);
10314
+ return;
10315
+ }
10316
+ if (sub === "new" || sub === "create") {
10317
+ const name = args2[1];
10318
+ const description = args2.slice(2).join(" ");
10319
+ if (!name) {
10320
+ context.addMessage("error", "Usage: /output-style new <name> <description>");
10321
+ return;
10322
+ }
10323
+ try {
10324
+ const path2 = createUserStyle(name, description || `User style: ${name}`, "");
10325
+ context.addMessage(
10326
+ "system",
10327
+ `Created ${path2} \u2014 edit the file to define the system prompt body, then /output-style use ${name}`
10328
+ );
10329
+ } catch (e) {
10330
+ context.addMessage("error", e.message ?? String(e));
10331
+ }
10332
+ return;
10333
+ }
10334
+ if (sub === "show") {
10335
+ const style = getActiveStyle();
10336
+ context.addMessage("system", `Active style: ${style.name} (${style.source})`);
10337
+ context.addMessage("system", `Description: ${style.description}`);
10338
+ if (style.body) {
10339
+ context.addMessage("system", "\u2500\u2500\u2500 body \u2500\u2500\u2500");
10340
+ context.addMessage("system", style.body);
10341
+ } else {
10342
+ context.addMessage("system", "(no system-prompt body \u2014 default behavior)");
10343
+ }
10344
+ return;
10345
+ }
10346
+ context.addMessage(
10347
+ "error",
10348
+ `Unknown subcommand: ${sub}. Try /output-style list|use|new|show`
10349
+ );
10350
+ }
10351
+ };
10352
+ }
10353
+ });
10354
+
9724
10355
  // src/commands/index.ts
9725
10356
  var commands_exports = {};
9726
10357
  __export(commands_exports, {
@@ -9767,6 +10398,7 @@ var init_commands = __esm({
9767
10398
  init_plan();
9768
10399
  init_note();
9769
10400
  init_todo();
10401
+ init_outputStyle();
9770
10402
  commands = [
9771
10403
  loginCommand,
9772
10404
  unlockCommand,
@@ -9798,7 +10430,52 @@ var init_commands = __esm({
9798
10430
  shortcutsCommand,
9799
10431
  planCommand,
9800
10432
  noteCommand,
9801
- todoCommand
10433
+ todoCommand,
10434
+ outputStyleCommand
10435
+ ];
10436
+ }
10437
+ });
10438
+
10439
+ // src/runtime/doctorRedact.ts
10440
+ function redactSecrets(text) {
10441
+ let out = text;
10442
+ for (const [re, replacement] of PATTERNS) {
10443
+ out = out.replace(re, replacement);
10444
+ }
10445
+ return out;
10446
+ }
10447
+ var PATTERNS;
10448
+ var init_doctorRedact = __esm({
10449
+ "src/runtime/doctorRedact.ts"() {
10450
+ "use strict";
10451
+ init_esm_shims();
10452
+ PATTERNS = [
10453
+ // Legacy key=value pairs (from original doctor.ts redactSecrets).
10454
+ [/token[=:]\s*['"]?[a-zA-Z0-9_.-]+['"]?/gi, "token=***"],
10455
+ [/password[=:]\s*['"]?[a-zA-Z0-9_.-]+['"]?/gi, "password=***"],
10456
+ [/api[_-]?key[=:]\s*['"]?[a-zA-Z0-9_.-]+['"]?/gi, "api_key=***"],
10457
+ [/secret[=:]\s*['"]?[a-zA-Z0-9_.-]+['"]?/gi, "secret=***"],
10458
+ [/MSAPLING_TOKEN=.*/gi, "MSAPLING_TOKEN=***"],
10459
+ [/MSAPLING_API_KEY=.*/gi, "MSAPLING_API_KEY=***"],
10460
+ // Bearer tokens (case-insensitive).
10461
+ [/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer ***"],
10462
+ // GitHub PATs — keep the prefix so the credential class is identifiable.
10463
+ [/\bghp_[A-Za-z0-9]{20,}/g, "ghp_***"],
10464
+ [/\bghs_[A-Za-z0-9]{20,}/g, "ghs_***"],
10465
+ [/\bgho_[A-Za-z0-9]{20,}/g, "gho_***"],
10466
+ [/\bghu_[A-Za-z0-9]{20,}/g, "ghu_***"],
10467
+ [/\bghr_[A-Za-z0-9]{20,}/g, "ghr_***"],
10468
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "github_pat_***"],
10469
+ // AWS access key IDs.
10470
+ [/\bAKIA[0-9A-Z]{16}\b/g, "AKIA***"],
10471
+ [/\bASIA[0-9A-Z]{16}\b/g, "ASIA***"],
10472
+ // Slack tokens (xoxa/xoxb/xoxp/xoxr/xoxs/xoxe-...).
10473
+ [/\bxox[abpres]-[A-Za-z0-9-]{10,}/gi, "xox-***"],
10474
+ // Anthropic + OpenAI style. Anthropic first to preserve "sk-ant-" prefix.
10475
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}/g, "sk-ant-***"],
10476
+ [/\bsk-[A-Za-z0-9_-]{20,}/g, "sk-***"],
10477
+ // JWT-ish payload: three base64url segments separated by dots, header starts eyJ.
10478
+ [/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g, "***jwt***"]
9802
10479
  ];
9803
10480
  }
9804
10481
  });
@@ -9808,15 +10485,12 @@ var doctor_exports = {};
9808
10485
  __export(doctor_exports, {
9809
10486
  runDoctor: () => runDoctor
9810
10487
  });
9811
- import { homedir as homedir15, platform as platform2 } from "os";
9812
- import { join as join27 } from "path";
9813
- import { existsSync as existsSync25, statSync as statSync6, accessSync } from "fs";
10488
+ import { homedir as homedir16, platform as platform3 } from "os";
10489
+ import { join as join28 } from "path";
10490
+ import { existsSync as existsSync26, statSync as statSync6, accessSync } from "fs";
9814
10491
  import { readdir as readdir3 } from "fs/promises";
9815
10492
  import { exec } from "child_process";
9816
10493
  import { promisify } from "util";
9817
- function redactSecrets(text) {
9818
- return text.replace(/token[=:]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, "token=***").replace(/password[=:]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, "password=***").replace(/api[_-]?key[=:]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, "api_key=***").replace(/secret[=:]\s*['"]?[a-zA-Z0-9_-]+['"]?/gi, "secret=***").replace(/MSAPLING_TOKEN=.*/gi, "MSAPLING_TOKEN=***").replace(/MSAPLING_API_KEY=.*/gi, "MSAPLING_API_KEY=***");
9819
- }
9820
10494
  async function checkNodeVersion() {
9821
10495
  const version = process.version;
9822
10496
  const match = version.match(/v(\d+)/);
@@ -9836,8 +10510,8 @@ async function checkNodeVersion() {
9836
10510
  };
9837
10511
  }
9838
10512
  async function checkConfigDir() {
9839
- const configDir = join27(homedir15(), ".msapling");
9840
- if (!existsSync25(configDir)) {
10513
+ const configDir = join28(homedir16(), ".msapling");
10514
+ if (!existsSync26(configDir)) {
9841
10515
  return {
9842
10516
  name: "Config directory",
9843
10517
  status: "WARN",
@@ -9854,7 +10528,7 @@ async function checkConfigDir() {
9854
10528
  remediation: `rm "${configDir}" && mkdir -p "${configDir}"`
9855
10529
  };
9856
10530
  }
9857
- if (platform2() !== "win32") {
10531
+ if (platform3() !== "win32") {
9858
10532
  const mode = stats.mode & 511;
9859
10533
  const safe = (mode & 63) === 0;
9860
10534
  if (!safe) {
@@ -9900,15 +10574,15 @@ async function checkKeytar() {
9900
10574
  }
9901
10575
  async function checkPathConflicts() {
9902
10576
  const pathEnv = process.env.PATH || "";
9903
- const paths = pathEnv.split(platform2() === "win32" ? ";" : ":");
10577
+ const paths = pathEnv.split(platform3() === "win32" ? ";" : ":");
9904
10578
  const conflicts = [];
9905
10579
  for (const dir of paths) {
9906
- if (!dir || !existsSync25(dir)) continue;
10580
+ if (!dir || !existsSync26(dir)) continue;
9907
10581
  try {
9908
10582
  const files = await readdir3(dir);
9909
10583
  for (const file of files) {
9910
10584
  if (file === "msapling" || file === "msapling.exe" || file === "msapling.py") {
9911
- const fullPath = join27(dir, file);
10585
+ const fullPath = join28(dir, file);
9912
10586
  conflicts.push(fullPath);
9913
10587
  }
9914
10588
  }
@@ -10008,11 +10682,11 @@ async function checkTokenValidity() {
10008
10682
  }
10009
10683
  }
10010
10684
  async function checkOsSpecific() {
10011
- if (platform2() === "win32") {
10685
+ if (platform3() === "win32") {
10012
10686
  try {
10013
- const configDir = join27(homedir15(), ".msapling");
10687
+ const configDir = join28(homedir16(), ".msapling");
10014
10688
  const longPath = "A".repeat(260);
10015
- const testPath = join27(configDir, longPath);
10689
+ const testPath = join28(configDir, longPath);
10016
10690
  try {
10017
10691
  accessSync(configDir);
10018
10692
  } catch {
@@ -10037,14 +10711,14 @@ async function checkOsSpecific() {
10037
10711
  };
10038
10712
  }
10039
10713
  }
10040
- if (platform2() === "darwin") {
10714
+ if (platform3() === "darwin") {
10041
10715
  return {
10042
10716
  name: "OS-specific (macOS)",
10043
10717
  status: "PASS",
10044
10718
  message: "macOS detected"
10045
10719
  };
10046
10720
  }
10047
- if (platform2() === "linux") {
10721
+ if (platform3() === "linux") {
10048
10722
  try {
10049
10723
  await import("keytar");
10050
10724
  return {
@@ -10064,7 +10738,7 @@ async function checkOsSpecific() {
10064
10738
  return {
10065
10739
  name: "OS-specific",
10066
10740
  status: "PASS",
10067
- message: `${platform2()} detected`
10741
+ message: `${platform3()} detected`
10068
10742
  };
10069
10743
  }
10070
10744
  function formatCheckResult(result, maxLabelWidth) {
@@ -10128,14 +10802,22 @@ async function runDoctor(debug = false) {
10128
10802
  output.push(...dumpEnv(true));
10129
10803
  output.push("");
10130
10804
  }
10131
- console.log(output.join("\n"));
10132
- process.exit(failCount > 0 ? 1 : 0);
10805
+ const rendered = output.join("\n");
10806
+ console.log(rendered);
10807
+ return {
10808
+ exitCode: failCount > 0 ? 1 : 0,
10809
+ checks,
10810
+ failCount,
10811
+ warnCount,
10812
+ rendered
10813
+ };
10133
10814
  }
10134
10815
  var execAsync;
10135
10816
  var init_doctor2 = __esm({
10136
10817
  "src/runtime/doctor.ts"() {
10137
10818
  "use strict";
10138
10819
  init_esm_shims();
10820
+ init_doctorRedact();
10139
10821
  execAsync = promisify(exec);
10140
10822
  }
10141
10823
  });
@@ -11286,7 +11968,7 @@ async function mergeToolRegistries(localTools, client) {
11286
11968
  return Array.from(merged.values());
11287
11969
  }
11288
11970
  for (const backendTool of registry.tools) {
11289
- const tier = backendTool.tier_required === "free" || backendTool.tier_required === "pro" ? backendTool.tier_required : "free";
11971
+ const tier = backendTool.tier_required === "free" || backendTool.tier_required === "pro" || backendTool.tier_required === "lifetime" || backendTool.tier_required === "enterprise" ? backendTool.tier_required : "enterprise";
11290
11972
  if (!merged.has(backendTool.name)) {
11291
11973
  merged.set(backendTool.name, {
11292
11974
  name: backendTool.name,
@@ -11665,8 +12347,8 @@ __export(server_exports, {
11665
12347
  runStdio: () => runStdio,
11666
12348
  runStdioWithRegistry: () => runStdioWithRegistry
11667
12349
  });
11668
- import { readdirSync as readdirSync3, readFileSync, statSync as statSync7 } from "fs";
11669
- import { join as join28, relative as relative14, resolve as resolve17 } from "path";
12350
+ import { readdirSync as readdirSync4, readFileSync as readFileSync2, statSync as statSync7 } from "fs";
12351
+ import { join as join29, relative as relative14, resolve as resolve17 } from "path";
11670
12352
  function asResult2(text, isError = false) {
11671
12353
  return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
11672
12354
  }
@@ -11679,14 +12361,14 @@ function buildFileTree(root, maxFiles) {
11679
12361
  const dir = queue.shift();
11680
12362
  let entries;
11681
12363
  try {
11682
- entries = readdirSync3(dir);
12364
+ entries = readdirSync4(dir);
11683
12365
  } catch {
11684
12366
  continue;
11685
12367
  }
11686
12368
  for (const name of entries) {
11687
12369
  if (out.length >= maxFiles) break;
11688
12370
  if (SKIP_DIRS2.has(name)) continue;
11689
- const full = join28(dir, name);
12371
+ const full = join29(dir, name);
11690
12372
  let s;
11691
12373
  try {
11692
12374
  s = statSync7(full);
@@ -11710,7 +12392,7 @@ function readFilesAsContext(root, files, maxKB) {
11710
12392
  for (const f of files) {
11711
12393
  let body;
11712
12394
  try {
11713
- body = readFileSync(f, "utf8");
12395
+ body = readFileSync2(f, "utf8");
11714
12396
  } catch {
11715
12397
  continue;
11716
12398
  }
@@ -11724,11 +12406,37 @@ ${body}
11724
12406
  }
11725
12407
  return parts.join("\n\n");
11726
12408
  }
12409
+ function installDrainHandlers(server) {
12410
+ const abort = new AbortController();
12411
+ let drainStarted = false;
12412
+ const onSignal = (sig) => {
12413
+ if (drainStarted) return;
12414
+ drainStarted = true;
12415
+ process.stderr.write(`[mcp-server] received ${sig}, starting graceful drain
12416
+ `);
12417
+ server.drain().then((forcedResponses) => {
12418
+ for (const resp of forcedResponses) {
12419
+ try {
12420
+ process.stdout.write(JSON.stringify(resp) + "\n");
12421
+ } catch {
12422
+ }
12423
+ }
12424
+ process.stderr.write("[mcp-server] drain complete, exiting\n");
12425
+ abort.abort();
12426
+ process.exit(0);
12427
+ });
12428
+ };
12429
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
12430
+ process.on("SIGINT", () => onSignal("SIGINT"));
12431
+ return abort;
12432
+ }
11727
12433
  async function runStdio(client) {
11728
12434
  const server = new MCPServer(client);
12435
+ const abort = installDrainHandlers(server);
11729
12436
  let buffer = "";
11730
12437
  const decoder = new TextDecoder();
11731
12438
  for await (const chunk of process.stdin) {
12439
+ if (abort.signal.aborted) break;
11732
12440
  buffer += decoder.decode(chunk, { stream: true });
11733
12441
  let idx;
11734
12442
  while ((idx = buffer.indexOf("\n")) !== -1) {
@@ -11748,9 +12456,11 @@ async function runStdio(client) {
11748
12456
  }
11749
12457
  async function runStdioWithRegistry(client, registryClient) {
11750
12458
  const server = new MCPServer(client, registryClient || client);
12459
+ const abort = installDrainHandlers(server);
11751
12460
  let buffer = "";
11752
12461
  const decoder = new TextDecoder();
11753
12462
  for await (const chunk of process.stdin) {
12463
+ if (abort.signal.aborted) break;
11754
12464
  buffer += decoder.decode(chunk, { stream: true });
11755
12465
  let idx;
11756
12466
  while ((idx = buffer.indexOf("\n")) !== -1) {
@@ -11768,7 +12478,7 @@ async function runStdioWithRegistry(client, registryClient) {
11768
12478
  }
11769
12479
  }
11770
12480
  }
11771
- var TOOLS, PROTOCOL_VERSION2, PRO_TIERS2, TIER_CACHE_TTL_MS, SAFE_PATH_RE, MCPServer;
12481
+ var AP4_MAX_BLOB_BYTES, TOOLS, PROTOCOL_VERSION2, PRO_TIERS2, TIER_CACHE_TTL_MS, DRAIN_TIMEOUT_MS, SAFE_PATH_RE, MCPServer;
11772
12482
  var init_server = __esm({
11773
12483
  "../core/src/mcp/server.ts"() {
11774
12484
  "use strict";
@@ -11777,6 +12487,7 @@ var init_server = __esm({
11777
12487
  init_libesm();
11778
12488
  init_registry_merger();
11779
12489
  init_local_tools();
12490
+ AP4_MAX_BLOB_BYTES = 4 * 1024;
11780
12491
  TOOLS = [
11781
12492
  {
11782
12493
  name: "msapling_chat",
@@ -11895,11 +12606,57 @@ var init_server = __esm({
11895
12606
  tier: "free",
11896
12607
  inputSchema: { type: "object", properties: {} }
11897
12608
  },
12609
+ {
12610
+ name: "msapling_list_wakeups",
12611
+ description: "[Pro] List pending AI wake-ups/scheduled tasks for your account.",
12612
+ tier: "pro",
12613
+ inputSchema: {
12614
+ type: "object",
12615
+ properties: {
12616
+ status: { type: "string", enum: ["pending", "fired", "cancelled", "expired"], description: "Filter by status" },
12617
+ session_id: { type: "string", description: "Filter by chat session ID" }
12618
+ }
12619
+ }
12620
+ },
12621
+ {
12622
+ name: "msapling_schedule_wakeup",
12623
+ description: "[Pro] Schedule a future AI task or follow-up wakeup.",
12624
+ tier: "pro",
12625
+ inputSchema: {
12626
+ type: "object",
12627
+ required: ["delay_seconds", "reason"],
12628
+ properties: {
12629
+ delay_seconds: { type: "number", description: "Delay in seconds from now" },
12630
+ reason: { type: "string", description: "Short description of what the AI should do" },
12631
+ prompt: { type: "string", description: "Specific prompt to process when the wakeup fires" },
12632
+ session_id: { type: "string", description: "Associate with an existing chat session" }
12633
+ }
12634
+ }
12635
+ },
12636
+ {
12637
+ name: "msapling_cancel_wakeup",
12638
+ description: "[Pro] Cancel a pending AI wakeup.",
12639
+ tier: "pro",
12640
+ inputSchema: {
12641
+ type: "object",
12642
+ required: ["id"],
12643
+ properties: {
12644
+ id: { type: "string", description: "The wakeup UUID to cancel" }
12645
+ }
12646
+ }
12647
+ },
12648
+ {
12649
+ name: "msapling_fleet_status",
12650
+ description: "[Pro] View status of your remote agent fleet and active tasks.",
12651
+ tier: "pro",
12652
+ inputSchema: { type: "object", properties: {} }
12653
+ },
11898
12654
  ...LOCAL_TOOLS
11899
12655
  ];
11900
12656
  PROTOCOL_VERSION2 = "2024-11-05";
11901
12657
  PRO_TIERS2 = /* @__PURE__ */ new Set(["pro", "monthly", "lifetime", "enterprise", "admin", "superadmin"]);
11902
12658
  TIER_CACHE_TTL_MS = 6e4;
12659
+ DRAIN_TIMEOUT_MS = 5e3;
11903
12660
  SAFE_PATH_RE = /^[A-Za-z0-9_./\-\\: ]+$/;
11904
12661
  MCPServer = class {
11905
12662
  constructor(client, backendClient) {
@@ -11910,6 +12667,83 @@ var init_server = __esm({
11910
12667
  backendClient;
11911
12668
  tierCache = null;
11912
12669
  registryCache = null;
12670
+ /**
12671
+ * CLIENT-CLI-16: When true, the server is shutting down. New tool calls
12672
+ * are rejected with a JSON-RPC error; only in-flight calls are allowed
12673
+ * to finish.
12674
+ */
12675
+ _isDraining = false;
12676
+ /**
12677
+ * CLIENT-CLI-16: Set of in-flight tool call IDs. Used during draining to
12678
+ * wait for completion before closing the transport. Capped at
12679
+ * MAX_INFLIGHT_TOOLS.
12680
+ */
12681
+ _inflightCalls = /* @__PURE__ */ new Map();
12682
+ /**
12683
+ * CLIENT-CLI-16: Resolved when all in-flight calls complete during drain,
12684
+ * or when the drain timeout fires.
12685
+ */
12686
+ _drainResolve = null;
12687
+ /** CLIENT-CLI-16: Whether the server is currently draining. */
12688
+ get isDraining() {
12689
+ return this._isDraining;
12690
+ }
12691
+ /**
12692
+ * CLIENT-CLI-16: Enter draining mode. Returns a promise that resolves when
12693
+ * all in-flight calls complete or DRAIN_TIMEOUT_MS elapses, whichever
12694
+ * comes first.
12695
+ *
12696
+ * After the promise resolves, any remaining in-flight calls have been
12697
+ * forcefully closed with error responses (written to stdout by the caller).
12698
+ */
12699
+ async drain() {
12700
+ this._isDraining = true;
12701
+ const inflight = this._inflightCalls.size;
12702
+ process.stderr.write(
12703
+ `[mcp-server] draining: ${inflight} in-flight tool call(s)
12704
+ `
12705
+ );
12706
+ if (inflight === 0) {
12707
+ return [];
12708
+ }
12709
+ const forcedResponses = await new Promise((resolve18) => {
12710
+ this._drainResolve = () => resolve18([]);
12711
+ setTimeout(() => {
12712
+ this._drainResolve = null;
12713
+ const remaining = Array.from(this._inflightCalls.values());
12714
+ if (remaining.length === 0) {
12715
+ resolve18([]);
12716
+ return;
12717
+ }
12718
+ process.stderr.write(
12719
+ `[mcp-server] drain timeout: force-closing ${remaining.length} call(s)
12720
+ `
12721
+ );
12722
+ const errorResponses = remaining.map((call) => ({
12723
+ jsonrpc: "2.0",
12724
+ id: call.id,
12725
+ error: {
12726
+ code: -32e3,
12727
+ message: `Server shutdown: tool call "${call.name}" timed out after ${DRAIN_TIMEOUT_MS}ms`
12728
+ }
12729
+ }));
12730
+ this._inflightCalls.clear();
12731
+ resolve18(errorResponses);
12732
+ }, DRAIN_TIMEOUT_MS);
12733
+ });
12734
+ return forcedResponses;
12735
+ }
12736
+ /**
12737
+ * CLIENT-CLI-16: Called when an in-flight tool call completes during drain.
12738
+ * If all calls are done, resolves the drain promise.
12739
+ */
12740
+ _completeInflight(id) {
12741
+ this._inflightCalls.delete(id);
12742
+ if (this._isDraining && this._inflightCalls.size === 0 && this._drainResolve) {
12743
+ this._drainResolve();
12744
+ this._drainResolve = null;
12745
+ }
12746
+ }
11913
12747
  /**
11914
12748
  * Fetch the caller's tier (Pro vs free) and cache it briefly. We avoid a
11915
12749
  * `me()` call per `tools/list` because Claude Code calls tools/list often
@@ -11941,6 +12775,25 @@ var init_server = __esm({
11941
12775
  invalidateTierCache() {
11942
12776
  this.tierCache = null;
11943
12777
  }
12778
+ /**
12779
+ * CLI-R12-20260507-TIER-02: Map tier name to numeric level for comparison.
12780
+ * Higher level = more restrictive/expensive.
12781
+ * free (1) < pro (5) < lifetime (10) < enterprise (100)
12782
+ */
12783
+ tierLevel(tier) {
12784
+ switch (String(tier).toLowerCase()) {
12785
+ case "free":
12786
+ return 1;
12787
+ case "pro":
12788
+ return 5;
12789
+ case "lifetime":
12790
+ return 10;
12791
+ case "enterprise":
12792
+ return 100;
12793
+ default:
12794
+ return 100;
12795
+ }
12796
+ }
11944
12797
  /**
11945
12798
  * Fetch and cache the merged tool registry (local + backend).
11946
12799
  * Called on first tools/list request. Cached for session lifetime.
@@ -11978,30 +12831,62 @@ var init_server = __esm({
11978
12831
  return { jsonrpc: "2.0", id, result: { tools: wireFormat } };
11979
12832
  }
11980
12833
  case "tools/call": {
12834
+ if (this._isDraining) {
12835
+ return {
12836
+ jsonrpc: "2.0",
12837
+ id,
12838
+ error: {
12839
+ code: -32e3,
12840
+ message: "Server shutting down: not accepting new tool calls"
12841
+ }
12842
+ };
12843
+ }
11981
12844
  const name = req.params?.name;
11982
12845
  const args2 = req.params?.arguments ?? {};
11983
- const localTool = TOOLS.find((t) => t.name === name);
11984
- if (localTool) {
11985
- try {
11986
- const result = await this.callTool(name, args2);
11987
- return { jsonrpc: "2.0", id, result };
11988
- } catch (e) {
11989
- const msg = e instanceof MSaplingError ? `[backend ${e.status ?? "?"} ${e.code ?? ""}] ${e.message}` : e?.message ?? "tool call failed";
11990
- return { jsonrpc: "2.0", id, result: asResult2(msg, true) };
12846
+ this._inflightCalls.set(id, {
12847
+ id,
12848
+ name: name ?? "unknown",
12849
+ startedAt: Date.now(),
12850
+ resolve: () => {
11991
12851
  }
11992
- }
11993
- const merged = await this.getMergedRegistry();
11994
- const backendTool = merged.find((t) => t.name === name && t.runs_on === "backend");
11995
- if (backendTool) {
11996
- try {
11997
- const result = await this.invokeBackendTool(name, args2);
11998
- return { jsonrpc: "2.0", id, result };
11999
- } catch (e) {
12000
- const msg = e instanceof MSaplingError ? `[backend ${e.status ?? "?"} ${e.code ?? ""}] ${e.message}` : e?.message ?? "tool call failed";
12001
- return { jsonrpc: "2.0", id, result: asResult2(msg, true) };
12852
+ });
12853
+ try {
12854
+ const localTool = TOOLS.find((t) => t.name === name);
12855
+ if (localTool) {
12856
+ try {
12857
+ const result = await this.callTool(name, args2);
12858
+ const resp = { jsonrpc: "2.0", id, result };
12859
+ return resp;
12860
+ } catch (e) {
12861
+ const msg = e instanceof MSaplingError ? `[backend ${e.status ?? "?"} ${e.code ?? ""}] ${e.message}` : e?.message ?? "tool call failed";
12862
+ return { jsonrpc: "2.0", id, result: asResult2(msg, true) };
12863
+ }
12864
+ }
12865
+ const merged = await this.getMergedRegistry();
12866
+ const backendTool = merged.find((t) => t.name === name && t.runs_on === "backend");
12867
+ if (backendTool) {
12868
+ const isPro = await this.getIsProCached();
12869
+ const toolTierLevel = this.tierLevel(backendTool.tier);
12870
+ const userTierLevel = isPro ? 5 : 1;
12871
+ if (toolTierLevel > userTierLevel) {
12872
+ return {
12873
+ jsonrpc: "2.0",
12874
+ id,
12875
+ result: asResult2(`Access denied: tool "${name}" requires ${backendTool.tier} tier`, true)
12876
+ };
12877
+ }
12878
+ try {
12879
+ const result = await this.invokeBackendTool(name, args2);
12880
+ return { jsonrpc: "2.0", id, result };
12881
+ } catch (e) {
12882
+ const msg = e instanceof MSaplingError ? `[backend ${e.status ?? "?"} ${e.code ?? ""}] ${e.message}` : e?.message ?? "tool call failed";
12883
+ return { jsonrpc: "2.0", id, result: asResult2(msg, true) };
12884
+ }
12002
12885
  }
12886
+ return { jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown tool: ${name}` } };
12887
+ } finally {
12888
+ this._completeInflight(id);
12003
12889
  }
12004
- return { jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown tool: ${name}` } };
12005
12890
  }
12006
12891
  case "shutdown":
12007
12892
  return { jsonrpc: "2.0", id, result: {} };
@@ -12052,12 +12937,30 @@ ${r.response ?? ""}`;
12052
12937
  return asResult2(text);
12053
12938
  }
12054
12939
  case "msapling_diff": {
12940
+ const oldContent = String(args2.old_content ?? "");
12941
+ const newContent = String(args2.new_content ?? "");
12942
+ const oldBytes = Buffer.byteLength(oldContent, "utf8");
12943
+ const newBytes = Buffer.byteLength(newContent, "utf8");
12944
+ if (oldBytes > AP4_MAX_BLOB_BYTES || newBytes > AP4_MAX_BLOB_BYTES) {
12945
+ return asResult2(
12946
+ `AP-4: Blobs exceed 4 KB (old=${oldBytes}B, new=${newBytes}B). Use the backend endpoint: POST /api/projects/:id/diff`,
12947
+ true
12948
+ );
12949
+ }
12055
12950
  const filename = String(args2.filename ?? "file");
12056
- const patch = createPatch(filename, String(args2.old_content ?? ""), String(args2.new_content ?? ""), "", "");
12951
+ const patch = createPatch(filename, oldContent, newContent, "", "");
12057
12952
  return asResult2(patch);
12058
12953
  }
12059
12954
  case "msapling_apply_diff": {
12060
- const applied = applyPatch(String(args2.original_content ?? ""), String(args2.diff_text ?? ""));
12955
+ const originalContent = String(args2.original_content ?? "");
12956
+ const originalBytes = Buffer.byteLength(originalContent, "utf8");
12957
+ if (originalBytes > AP4_MAX_BLOB_BYTES) {
12958
+ return asResult2(
12959
+ `AP-4: Blob exceeds 4 KB (${originalBytes}B). Use the backend endpoint: POST /api/projects/:id/apply`,
12960
+ true
12961
+ );
12962
+ }
12963
+ const applied = applyPatch(originalContent, String(args2.diff_text ?? ""));
12061
12964
  if (applied === false) {
12062
12965
  return asResult2("Diff did not apply cleanly (hunks rejected).", true);
12063
12966
  }
@@ -12154,6 +13057,36 @@ ${text}`);
12154
13057
  return asResult2(`Available models (${models.length} total, showing first 30):
12155
13058
  ${lines.join("\n")}`);
12156
13059
  }
13060
+ case "msapling_list_wakeups": {
13061
+ const isPro = await this.getIsProCached();
13062
+ if (!isPro) return asResult2("msapling_list_wakeups requires a Pro subscription.", true);
13063
+ const data = await this.client.listWakeups(args2);
13064
+ return asResult2(JSON.stringify(data, null, 2));
13065
+ }
13066
+ case "msapling_schedule_wakeup": {
13067
+ const isPro = await this.getIsProCached();
13068
+ if (!isPro) return asResult2("msapling_schedule_wakeup requires a Pro subscription.", true);
13069
+ const data = await this.client.createWakeup({
13070
+ delay_seconds: Number(args2.delay_seconds),
13071
+ reason: String(args2.reason ?? ""),
13072
+ prompt: args2.prompt ? String(args2.prompt) : void 0,
13073
+ session_id: args2.session_id ? String(args2.session_id) : void 0
13074
+ });
13075
+ return asResult2(JSON.stringify(data, null, 2));
13076
+ }
13077
+ case "msapling_cancel_wakeup": {
13078
+ const isPro = await this.getIsProCached();
13079
+ if (!isPro) return asResult2("msapling_cancel_wakeup requires a Pro subscription.", true);
13080
+ await this.client.cancelWakeup(String(args2.id));
13081
+ return asResult2("Wakeup cancelled");
13082
+ }
13083
+ case "msapling_fleet_status": {
13084
+ const isPro = await this.getIsProCached();
13085
+ if (!isPro) return asResult2("msapling_fleet_status requires a Pro subscription.", true);
13086
+ const status = await this.client.getFleetStatus();
13087
+ const tasks = await this.client.getActiveTasks();
13088
+ return asResult2(JSON.stringify({ status, tasks }, null, 2));
13089
+ }
12157
13090
  // Local MCP tools (filesystem, shell, git)
12158
13091
  case "local_run_command":
12159
13092
  case "local_list_directory":
@@ -12201,7 +13134,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
12201
13134
  var Header = () => /* @__PURE__ */ jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1, marginBottom: 1, children: [
12202
13135
  /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
12203
13136
  "\u25CF MSapling CLI v",
12204
- "2.3.6-beta.5"
13137
+ "2.3.6-beta.6"
12205
13138
  ] }),
12206
13139
  /* @__PURE__ */ jsx(Box, { marginLeft: 2, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "Platinum Tier Architecture" }) })
12207
13140
  ] });
@@ -12717,9 +13650,9 @@ ${prompt}` : prompt;
12717
13650
  for (const mention of fileMentions) {
12718
13651
  const filePath = mention.slice(1);
12719
13652
  try {
12720
- const { existsSync: existsSync26 } = await import("fs");
13653
+ const { existsSync: existsSync27 } = await import("fs");
12721
13654
  const { readFile: readFile23 } = await import("fs/promises");
12722
- if (existsSync26(filePath)) {
13655
+ if (existsSync27(filePath)) {
12723
13656
  const content = await readFile23(filePath, "utf8");
12724
13657
  const MAX_LEN = 32768;
12725
13658
  const truncated = content.length > MAX_LEN ? content.slice(0, MAX_LEN) + "\n...[TRUNCATED]" : content;
@@ -12772,7 +13705,66 @@ ${finalCmd}`;
12772
13705
  init_esm_shims();
12773
13706
  init_src3();
12774
13707
  import { readFile as readFile22 } from "fs/promises";
12775
- import { existsSync as existsSync24 } from "fs";
13708
+ import { existsSync as existsSync25 } from "fs";
13709
+
13710
+ // src/state/parseApprovalMode.ts
13711
+ init_esm_shims();
13712
+ var VALID_MODES = [
13713
+ "default",
13714
+ "plan",
13715
+ "acceptEdits",
13716
+ "bypassPermissions"
13717
+ ];
13718
+ function parseApprovalMode(raw, now) {
13719
+ if (!raw || typeof raw !== "object") {
13720
+ return { kind: "invalid", error: "settings file is not a JSON object" };
13721
+ }
13722
+ const block = raw.approvalMode;
13723
+ if (block === void 0) return { kind: "absent" };
13724
+ if (typeof block === "string") {
13725
+ if (!VALID_MODES.includes(block)) {
13726
+ return { kind: "invalid", error: `unknown approvalMode "${block}"` };
13727
+ }
13728
+ return { kind: "ok", mode: block };
13729
+ }
13730
+ if (typeof block !== "object" || block === null) {
13731
+ return {
13732
+ kind: "invalid",
13733
+ error: `approvalMode must be a string or object, got ${typeof block}`
13734
+ };
13735
+ }
13736
+ const obj = block;
13737
+ const mode = obj.mode;
13738
+ if (typeof mode !== "string" || !VALID_MODES.includes(mode)) {
13739
+ return { kind: "invalid", error: `approvalMode.mode invalid: ${JSON.stringify(mode)}` };
13740
+ }
13741
+ if (obj.ttlMs === void 0 && obj.timestamp === void 0) {
13742
+ return { kind: "ok", mode };
13743
+ }
13744
+ if (typeof obj.ttlMs !== "number" || !Number.isFinite(obj.ttlMs) || obj.ttlMs <= 0) {
13745
+ return {
13746
+ kind: "invalid",
13747
+ error: `approvalMode.ttlMs must be a positive number, got ${JSON.stringify(obj.ttlMs)}`
13748
+ };
13749
+ }
13750
+ if (typeof obj.timestamp !== "number" || !Number.isFinite(obj.timestamp) || obj.timestamp <= 0) {
13751
+ return {
13752
+ kind: "invalid",
13753
+ error: `approvalMode.timestamp must be a positive number, got ${JSON.stringify(obj.timestamp)}`
13754
+ };
13755
+ }
13756
+ if (mode !== "bypassPermissions") {
13757
+ return { kind: "ok", mode };
13758
+ }
13759
+ const age = now - obj.timestamp;
13760
+ if (age > obj.ttlMs) {
13761
+ return { kind: "expired", mode: "default", agedMs: age };
13762
+ }
13763
+ const remainingMs = obj.ttlMs - age;
13764
+ return { kind: "ok", mode: "bypassPermissions", ttl: { remainingMs } };
13765
+ }
13766
+
13767
+ // src/state/initSession.ts
12776
13768
  async function initSession(ctx) {
12777
13769
  try {
12778
13770
  const { settings } = await loadSettings(
@@ -12786,38 +13778,40 @@ async function initSession(ctx) {
12786
13778
  ctx.setShellEscapeEnabled(settings.shellEscapeEnabled !== false);
12787
13779
  }
12788
13780
  try {
12789
- const { homedir: homedir16 } = await import("os");
12790
- const { join: join29 } = await import("path");
12791
- const userSettingsPath = join29(homedir16(), ".msapling", "settings.json");
12792
- if (existsSync24(userSettingsPath)) {
13781
+ const { homedir: homedir17 } = await import("os");
13782
+ const { join: join30 } = await import("path");
13783
+ const userSettingsPath = join30(homedir17(), ".msapling", "settings.json");
13784
+ if (existsSync25(userSettingsPath)) {
12793
13785
  const userText = await readFile22(userSettingsPath, "utf8");
12794
- const userJson = JSON.parse(userText);
12795
- const validModes = ["default", "plan", "acceptEdits", "bypassPermissions"];
13786
+ let parsed;
13787
+ try {
13788
+ parsed = JSON.parse(userText);
13789
+ } catch (e) {
13790
+ ctx.addMessage("system", `\u26A0 ~/.msapling/settings.json is not valid JSON: ${e.message}. Using default mode.`);
13791
+ parsed = {};
13792
+ }
13793
+ const result = parseApprovalMode(parsed, Date.now());
12796
13794
  let modeToApply = "default";
12797
- const approvalMode = userJson.approvalMode;
12798
- if (typeof approvalMode === "object" && approvalMode !== null) {
12799
- const { mode, timestamp, ttlMs } = approvalMode;
12800
- if (mode && validModes.includes(mode)) {
12801
- if (mode === "bypassPermissions" && ttlMs && timestamp) {
12802
- const now = Date.now();
12803
- const age = now - timestamp;
12804
- if (age > ttlMs) {
12805
- ctx.addMessage("system", "\u26A0 bypassPermissions TTL expired. Reverting to default mode.");
12806
- modeToApply = "default";
13795
+ switch (result.kind) {
13796
+ case "absent":
13797
+ break;
13798
+ case "invalid":
13799
+ ctx.addMessage("system", `\u26A0 Persisted approvalMode invalid (${result.error}). Reverting to default mode.`);
13800
+ break;
13801
+ case "expired":
13802
+ ctx.addMessage("system", "\u26A0 bypassPermissions TTL expired. Reverting to default mode.");
13803
+ break;
13804
+ case "ok":
13805
+ modeToApply = result.mode;
13806
+ if (result.mode === "bypassPermissions") {
13807
+ if (result.ttl) {
13808
+ const mins = (result.ttl.remainingMs / 1e3 / 60).toFixed(1);
13809
+ ctx.addMessage("system", `\u26A0 bypassPermissions active (expires in ~${mins} min). Use /mode default to re-enable controls.`);
12807
13810
  } else {
12808
- modeToApply = mode;
12809
- const remaining = ttlMs - age;
12810
- ctx.addMessage("system", `\u26A0 bypassPermissions active (expires in ~${(remaining / 1e3 / 60).toFixed(1)} min). Use /mode default to re-enable controls.`);
13811
+ ctx.addMessage("system", "\u26A0 bypassPermissions active (no TTL). Use /mode default to re-enable controls.");
12811
13812
  }
12812
- } else {
12813
- modeToApply = mode;
12814
13813
  }
12815
- }
12816
- } else if (typeof approvalMode === "string" && validModes.includes(approvalMode)) {
12817
- modeToApply = approvalMode;
12818
- if (modeToApply === "bypassPermissions") {
12819
- ctx.addMessage("system", "\u26A0 bypassPermissions active (no TTL). Use /mode default to re-enable controls.");
12820
- }
13814
+ break;
12821
13815
  }
12822
13816
  ctx.setMode(modeToApply);
12823
13817
  }
@@ -13083,9 +14077,23 @@ var App = ({ compact: compact2 = false }) => {
13083
14077
 
13084
14078
  // src/runtime/bootstrap.ts
13085
14079
  init_esm_shims();
14080
+ import { createRequire } from "module";
14081
+ function readCliVersion2() {
14082
+ try {
14083
+ const require2 = createRequire(import.meta.url);
14084
+ const pkg = require2("../../package.json");
14085
+ return {
14086
+ name: typeof pkg.name === "string" && pkg.name ? pkg.name : "@msapling/cli",
14087
+ version: typeof pkg.version === "string" && pkg.version ? pkg.version : "unknown"
14088
+ };
14089
+ } catch {
14090
+ return { name: "@msapling/cli", version: "unknown" };
14091
+ }
14092
+ }
14093
+ var CLI_PKG = readCliVersion2();
13086
14094
  function handleCliArgs(args2) {
13087
14095
  if (args2.includes("--version") || args2.includes("-v")) {
13088
- console.log("@msapling/cli 2.3.3");
14096
+ console.log(`${CLI_PKG.name} ${CLI_PKG.version}`);
13089
14097
  process.exit(0);
13090
14098
  }
13091
14099
  if (args2.includes("--help") || args2.includes("-h")) {
@@ -13104,7 +14112,8 @@ function handleCliArgs(args2) {
13104
14112
  (async () => {
13105
14113
  const { runDoctor: runDoctor2 } = await Promise.resolve().then(() => (init_doctor2(), doctor_exports));
13106
14114
  const debug = args2.includes("--debug");
13107
- await runDoctor2(debug);
14115
+ const result = await runDoctor2(debug);
14116
+ process.exit(result.exitCode);
13108
14117
  })().catch((e) => {
13109
14118
  process.stderr.write(`[msapling-doctor] fatal: ${e?.message ?? e}
13110
14119
  `);