mindweave 2.4.4 → 2.4.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 (49) hide show
  1. package/dist/cli/App.js +158 -99
  2. package/dist/cli/App.js.map +1 -1
  3. package/dist/cli/attachments.js +14 -1
  4. package/dist/cli/attachments.js.map +1 -1
  5. package/dist/cli/components/KeyManager.js +3 -42
  6. package/dist/cli/components/KeyManager.js.map +1 -1
  7. package/dist/cli/components/McpMinitabs.js +689 -0
  8. package/dist/cli/components/McpMinitabs.js.map +1 -0
  9. package/dist/cli/components/minitabs.js +71 -0
  10. package/dist/cli/components/minitabs.js.map +1 -0
  11. package/dist/dynamo/engine.js +29 -7
  12. package/dist/dynamo/engine.js.map +1 -1
  13. package/dist/dynamo/prompt.js +2 -0
  14. package/dist/dynamo/prompt.js.map +1 -1
  15. package/dist/mcp/config.js +57 -3
  16. package/dist/mcp/config.js.map +1 -1
  17. package/dist/mcp/configWrite.js +40 -2
  18. package/dist/mcp/configWrite.js.map +1 -1
  19. package/dist/mcp/connection.js +51 -1
  20. package/dist/mcp/connection.js.map +1 -1
  21. package/dist/mcp/manager.js +65 -0
  22. package/dist/mcp/manager.js.map +1 -1
  23. package/dist/mcp/oauth/callback.js +171 -0
  24. package/dist/mcp/oauth/callback.js.map +1 -0
  25. package/dist/mcp/oauth/flow.js +415 -0
  26. package/dist/mcp/oauth/flow.js.map +1 -0
  27. package/dist/mcp/oauth/index.js +243 -0
  28. package/dist/mcp/oauth/index.js.map +1 -0
  29. package/dist/mcp/oauth/metadata.js +192 -0
  30. package/dist/mcp/oauth/metadata.js.map +1 -0
  31. package/dist/mcp/oauth/pkce.js +56 -0
  32. package/dist/mcp/oauth/pkce.js.map +1 -0
  33. package/dist/mcp/oauth/tokenStore.js +103 -0
  34. package/dist/mcp/oauth/tokenStore.js.map +1 -0
  35. package/dist/mcp/transport/http.js +33 -5
  36. package/dist/mcp/transport/http.js.map +1 -1
  37. package/dist/memory/session.js +4 -0
  38. package/dist/memory/session.js.map +1 -1
  39. package/dist/tools/backgroundShells.js +42 -4
  40. package/dist/tools/backgroundShells.js.map +1 -1
  41. package/dist/tools/mcpAdd.js +7 -0
  42. package/dist/tools/mcpAdd.js.map +1 -1
  43. package/dist/tools/subagent.js +41 -3
  44. package/dist/tools/subagent.js.map +1 -1
  45. package/dist/tools/todo.js +35 -0
  46. package/dist/tools/todo.js.map +1 -1
  47. package/dist/tools/verifyReport.js +144 -0
  48. package/dist/tools/verifyReport.js.map +1 -0
  49. package/package.json +2 -2
package/dist/cli/App.js CHANGED
@@ -38,6 +38,7 @@ import { APPROVAL_DISMISSED, APPROVAL_TEXT } from "../tools/approval.js";
38
38
  import { KeySetup } from "./components/KeySetup.js";
39
39
  import { setupView } from "./keySetup.js";
40
40
  import { KeyManager } from "./components/KeyManager.js";
41
+ import { McpMinitabs } from "./components/McpMinitabs.js";
41
42
  import { providerRows, keyRowsFor, nextSlotFor } from "./keyManager.js";
42
43
  import { keysFor } from "./keyStore.js";
43
44
  import { TrustGate } from "./components/TrustGate.js";
@@ -93,7 +94,7 @@ import { workingVerb } from "./workingVerb.js";
93
94
  import { narrationPending, revealWait } from "./revealPace.js";
94
95
  import { summarizeTask, formatTokens } from "../dynamo/pricing.js";
95
96
  import { meterReset, meterDelta, meterTick, meterValue } from "../dynamo/liveMeter.js";
96
- import { addServerToConfig, configPathFor, parseAddSpec, removeServerFromConfig, splitArgs } from "../mcp/configWrite.js";
97
+ import { addServerToConfig, configPathFor, parseAddSpec, removeServerFromConfig, resolveConfigPath, splitArgs } from "../mcp/configWrite.js";
97
98
  import { mapPromptArguments, promptCommand, promptUsage } from "../mcp/prompts.js";
98
99
  import { DEFAULT_MODE, modeById, modeFromFlags, nextMode } from "./modes.js";
99
100
  import { ApprovalChannel } from "./approvalChannel.js";
@@ -358,6 +359,13 @@ export function App({ resumeSessionId, initialScreen }) {
358
359
  // Distinct from the first-run setup screen, which only ever needs to get one key in.
359
360
  const [keysOpen, setKeysOpen] = useState(false);
360
361
  const [keysTick, setKeysTick] = useState(0);
362
+ // Opened by /mcp — the minitabs manager: a server list, and picking one drops into
363
+ // everything you can do to it. Its own footer overlay, same shape as /key's.
364
+ const [mcpOpen, setMcpOpen] = useState(false);
365
+ /** Which MCP servers we hold a stored credential for. Read when the box opens rather
366
+ * than kept live: it changes only when someone signs in or out, both of which close
367
+ * the box, and each entry is a file read that has no business on a render path. */
368
+ const [mcpSignedIn, setMcpSignedIn] = useState(() => new Set());
361
369
  // A /provider switch held back because that provider had no key. Finished the moment
362
370
  // one is saved for it, so choosing a provider and adding its key is one flow rather
363
371
  // than two commands with a dead end in between.
@@ -1230,11 +1238,21 @@ export function App({ resumeSessionId, initialScreen }) {
1230
1238
  return;
1231
1239
  }
1232
1240
  const caret = caretCell();
1241
+ const { height, y } = measureElement(liveRef.current);
1233
1242
  if (!caret) {
1234
- setRowsBelowCaret(0);
1243
+ // NO CARET IS NOT "NOTHING TO DO". It is declared null whenever the input is not the
1244
+ // thing being typed into — a picker is open, a turn is in flight — and publishing
1245
+ // zero there says "the cursor is already on the last row", which is a claim, not an
1246
+ // absence. Exiting from that state left the cursor mid-frame and the shell printed
1247
+ // its prompt across the conversation, one row per Enter.
1248
+ //
1249
+ // The whole region's height is the safe answer instead: CUD is clamped by the
1250
+ // terminal at the bottom row, so overshooting costs a blank line and undershooting
1251
+ // costs the corruption above. Only the full-screen shell publishes a true zero, and
1252
+ // it means it — leaving the alternate buffer restores the cursor with it.
1253
+ setRowsBelowCaret(Math.max(0, height - 1));
1235
1254
  return;
1236
1255
  }
1237
- const { height, y } = measureElement(liveRef.current);
1238
1256
  // `caretCell` reports the caret's row within the live region; `liveRef` measures that
1239
1257
  // region. The last row it drew is `height - 1`, so the gap is what remains below.
1240
1258
  setRowsBelowCaret(height - 1 - (caret.y - (y ?? 0)));
@@ -1383,64 +1401,101 @@ export function App({ resumeSessionId, initialScreen }) {
1383
1401
  dispatch({ type: "endTurn" });
1384
1402
  setBusy(false);
1385
1403
  }
1404
+ /**
1405
+ * Write a server's config and connect it live — shared by the typed `/mcp add`, the
1406
+ * `/mcp` box's Add and Edit, and (indirectly, through the same writer) the
1407
+ * `add_mcp_server` tool. One path so a config any of them accepts is one the others
1408
+ * would too. Connects immediately: writing the file and telling the user to restart
1409
+ * would defeat the entire point of a guided add.
1410
+ */
1411
+ async function mcpWriteAndConnect(spec) {
1412
+ const cur = session.current;
1413
+ if (!cur)
1414
+ return;
1415
+ const path = configPathFor(spec.scope, cur.cwd);
1416
+ try {
1417
+ const written = await addServerToConfig(path, spec);
1418
+ note(`${written.replaced ? "replaced" : "added"} '${spec.name}' in ${path} — connecting…`);
1419
+ const status = await cur.toolContext.mcp?.addServer(spec.config);
1420
+ if (status?.state === "connected") {
1421
+ say(`${spec.name}: connected — ${status.toolCount} tool${status.toolCount === 1 ? "" : "s"} (protocol ${status.version}).`);
1422
+ }
1423
+ else if (status) {
1424
+ // Say what went wrong HERE rather than leaving it to be discovered in /mcp: a
1425
+ // typo'd command is the most likely outcome of typing this by hand.
1426
+ say(`${spec.name}: ${status.state}${status.error ? ` — ${status.error}` : ""}. It's saved; fix the config and /mcp to retry.`);
1427
+ }
1428
+ }
1429
+ catch (e) {
1430
+ say(`Couldn't write ${path}: ${e instanceof Error ? e.message : String(e)}`);
1431
+ }
1432
+ }
1433
+ /**
1434
+ * Stop a server and take it out of the config — the file half AND the live half.
1435
+ *
1436
+ * The file half alone used to be the whole of `/mcp remove`, with its own reply saying
1437
+ * "it stays connected until this session ends" — true, but not what "remove" sounds
1438
+ * like it means. `mcp.removeServer` is the live half: it closes the connection and
1439
+ * drops its tools from what the model is offered, right now, not at the next launch.
1440
+ */
1441
+ async function mcpRemove(name) {
1442
+ const cur = session.current;
1443
+ const mcp = cur?.toolContext.mcp;
1444
+ if (!cur || !mcp)
1445
+ return;
1446
+ const gone = (await removeServerFromConfig(configPathFor("project", cur.cwd), name)) ||
1447
+ (await removeServerFromConfig(configPathFor("global", cur.cwd), name));
1448
+ const stopped = await mcp.removeServer(name);
1449
+ if (!gone && !stopped)
1450
+ return say(`No server called '${name}' is configured.`);
1451
+ say(`Removed '${name}'${stopped ? " and stopped it" : " from the config (it was not running)"}.`);
1452
+ }
1386
1453
  /**
1387
1454
  * `/mcp add <name> <command|url> [args…]` and `/mcp remove <name>`.
1388
1455
  *
1389
1456
  * Shares its parser and writer with the `add_mcp_server` tool, so a config the command
1390
- * accepts is exactly one the tool would, and vice versa. Connects immediately on add:
1391
- * writing the file and telling the user to restart would defeat the point.
1457
+ * accepts is exactly one the tool would, and vice versa.
1392
1458
  */
1393
1459
  async function mcpConfigCommand(arg) {
1394
- const cur = session.current;
1395
- if (!cur)
1396
- return;
1397
1460
  const argv = splitArgs(arg);
1398
1461
  const verb = argv.shift();
1399
1462
  if (verb === "remove" || verb === "rm") {
1400
1463
  const target = argv[0];
1401
1464
  if (!target)
1402
1465
  return say("Usage: /mcp remove <name>");
1403
- const root = cur.cwd;
1404
- const gone = (await removeServerFromConfig(configPathFor("project", root), target)) ||
1405
- (await removeServerFromConfig(configPathFor("global", root), target));
1406
- say(gone
1407
- ? `Removed '${target}' from the config. It stays connected until this session ends.`
1408
- : `No server called '${target}' is configured.`);
1409
- return;
1466
+ return mcpRemove(target);
1410
1467
  }
1411
1468
  const parsed = parseAddSpec(argv);
1412
1469
  if (!parsed.ok)
1413
1470
  return say(parsed.error);
1414
- const path = configPathFor(parsed.spec.scope, cur.cwd);
1471
+ await mcpWriteAndConnect(parsed.spec);
1472
+ }
1473
+ /**
1474
+ * Flip a server's `disabled` flag and apply it live — the box's Enable/Disable.
1475
+ *
1476
+ * Reads the server's LIVE config rather than the file, because that is what is
1477
+ * actually true right now; writes back to whichever file already defines it
1478
+ * (`resolveConfigPath`), so this cannot create a second, shadowing entry for a
1479
+ * server that already exists in the other config.
1480
+ */
1481
+ async function mcpSetDisabled(name, disabled) {
1482
+ const cur = session.current;
1483
+ const mcp = cur?.toolContext.mcp;
1484
+ const config = mcp?.configFor(name);
1485
+ if (!cur || !mcp || !config)
1486
+ return;
1487
+ const path = await resolveConfigPath(cur.cwd, name);
1488
+ const scope = path === configPathFor("global", cur.cwd) ? "global" : "project";
1489
+ const next = { ...config, disabled };
1415
1490
  try {
1416
- const written = await addServerToConfig(path, parsed.spec);
1417
- note(`${written.replaced ? "replaced" : "added"} '${parsed.spec.name}' in ${path} — connecting…`);
1418
- const status = await cur.toolContext.mcp?.addServer(parsed.spec.config);
1419
- if (status?.state === "connected") {
1420
- say(`${parsed.spec.name}: connected — ${status.toolCount} tool${status.toolCount === 1 ? "" : "s"} (protocol ${status.version}).`);
1421
- }
1422
- else if (status) {
1423
- // Say what went wrong HERE rather than leaving it to be discovered in /mcp: a
1424
- // typo'd command is the most likely outcome of typing this by hand.
1425
- say(`${parsed.spec.name}: ${status.state}${status.error ? ` — ${status.error}` : ""}. It's saved; fix the config and /mcp to retry.`);
1426
- }
1491
+ await addServerToConfig(path, { name, scope, config: next });
1492
+ await mcp.addServer(next);
1493
+ note(`${name}: ${disabled ? "disabled" : "enabled"}.`);
1427
1494
  }
1428
1495
  catch (e) {
1429
- say(`Couldn't write ${path}: ${e instanceof Error ? e.message : String(e)}`);
1496
+ note(`Couldn't update ${path}: ${e instanceof Error ? e.message : String(e)}`, { error: true });
1430
1497
  }
1431
1498
  }
1432
- // What Enter does on a /mcp row: review blocked tools if there are any, otherwise
1433
- // reconnect. Blocked tools take priority because that is the state the user cannot
1434
- // otherwise resolve — reconnecting will not clear a quarantine.
1435
- async function mcpAction(name) {
1436
- const mgr = session.current?.toolContext.mcp;
1437
- if (mgr && mgr.blockedCountFor(name) > 0) {
1438
- const allowed = await mgr.reviewQuarantine((q, options) => askApproval.current(q, options));
1439
- note(allowed ? `${name}: blocked tools allowed for this project.` : `${name}: tools stay blocked.`);
1440
- return;
1441
- }
1442
- await reconnectMcp(name);
1443
- }
1444
1499
  // Reconnect one MCP server by name, reporting the outcome in the chat rather than
1445
1500
  // silently. A server that stays down is the thing a user most needs told about.
1446
1501
  async function reconnectMcp(name) {
@@ -1458,6 +1513,57 @@ export function App({ resumeSessionId, initialScreen }) {
1458
1513
  note(`${name} is ${status.state}${status.error ? ` — ${status.error}` : ""}`);
1459
1514
  }
1460
1515
  }
1516
+ /**
1517
+ * Sign in to a remote MCP server. Closes the box first, for the same reason Review does:
1518
+ * this takes a browser, a consent screen and a minute of the user's attention, and a
1519
+ * footer overlay frozen on "signing in…" for that long is a frozen app.
1520
+ *
1521
+ * The URL is printed BEFORE the browser is launched. Launching silently fails on
1522
+ * machines with no default browser or no desktop session at all, and the URL on screen
1523
+ * is the difference between "nothing happened" and something the user can paste.
1524
+ */
1525
+ /**
1526
+ * Sign in to a remote MCP server, reporting INTO the `/mcp` box rather than out here.
1527
+ *
1528
+ * Nothing is written to the transcript, and that is the point: the whole interaction
1529
+ * belongs to one screen about one server, and the alternative was a running commentary
1530
+ * ending in a 400-character URL pasted across the conversation. The box stays open and
1531
+ * shows its own progress; this just does the work and hands back the line to show.
1532
+ *
1533
+ * Throws rather than reporting failure as a string, so the box can style it as an error
1534
+ * and keep the server's own sentence, which is almost always the whole explanation.
1535
+ */
1536
+ async function mcpSignIn(name, handlers) {
1537
+ const mgr = session.current?.toolContext.mcp;
1538
+ if (!mgr)
1539
+ throw new Error("no MCP manager in this session");
1540
+ const status = await mgr.authenticate(name, handlers);
1541
+ if (!status)
1542
+ throw new Error(`${name} is no longer configured`);
1543
+ if (status.state === "connected") {
1544
+ return `${name} connected — ${status.toolCount} tool${status.toolCount === 1 ? "" : "s"}`;
1545
+ }
1546
+ throw new Error(status.error ? `${name} is ${status.state} — ${status.error}` : `${name} is ${status.state}`);
1547
+ }
1548
+ /** Drop a stored MCP credential. The connection is left running: it keeps working until
1549
+ * its current token is refused, and that 401 is what honestly returns it to needs-auth. */
1550
+ async function mcpSignOut(name) {
1551
+ const mgr = session.current?.toolContext.mcp;
1552
+ if (!mgr)
1553
+ return;
1554
+ await mgr.signOut(name);
1555
+ note(`signed out of ${name}.`);
1556
+ }
1557
+ /** Review blocked tools from the /mcp box: closes it first, since the approval channel
1558
+ * and the box's own screen cannot share the one footer surface at the same time. */
1559
+ async function mcpReviewBlocked(name) {
1560
+ setMcpOpen(false);
1561
+ const mgr = session.current?.toolContext.mcp;
1562
+ if (!mgr)
1563
+ return;
1564
+ const allowed = await mgr.reviewQuarantine((q, options) => askApproval.current(q, options));
1565
+ note(allowed ? `${name}: blocked tools allowed for this project.` : `${name}: tools stay blocked.`);
1566
+ }
1461
1567
  // Stop every root's background lane and any running shells from the current
1462
1568
  // session (before a swap).
1463
1569
  async function stopCurrentLanes() {
@@ -2141,11 +2247,6 @@ export function App({ resumeSessionId, initialScreen }) {
2141
2247
  note(`stopped shell #${sh.id} (${clipCmd(sh.command)})`);
2142
2248
  }
2143
2249
  }
2144
- else if (o.kind === "mcp") {
2145
- const server = o.items[index];
2146
- if (server && server.state !== "disabled")
2147
- void mcpAction(server.name);
2148
- }
2149
2250
  else if (o.kind === "approval") {
2150
2251
  approvals.current.answer(o.options[index] ?? o.options[0]);
2151
2252
  showNextApproval.current();
@@ -2460,15 +2561,15 @@ export function App({ resumeSessionId, initialScreen }) {
2460
2561
  await mcpConfigCommand(route.arg);
2461
2562
  return;
2462
2563
  }
2463
- // /mcp — server health; selecting one reconnects it.
2564
+ // /mcp — the minitabs manager: your servers, and "+ Add a server" always at the top,
2565
+ // so an empty config is a place to add one rather than a dead end.
2464
2566
  if (name === "/mcp") {
2465
- const mcp = s.toolContext.mcp;
2466
- if (!mcp?.hasServers()) {
2467
- say("No MCP servers configured. Add them in .mindweave/mcp.json (this project) or " +
2468
- "~/.mindweave/mcp.json (everywhere), then /mcp to check they came up.");
2469
- return;
2470
- }
2471
- setOverlay({ kind: "mcp", items: mcp.statuses() });
2567
+ // Which servers are signed in is read once, here, before the box is shown. Doing it
2568
+ // as the box opens rather than on every render keeps a handful of file reads off
2569
+ // the render path, and this is the only moment the answer can change unseen.
2570
+ const mgr = session.current?.toolContext.mcp;
2571
+ setMcpSignedIn(mgr ? await mgr.credentialed().catch(() => new Set()) : new Set());
2572
+ setMcpOpen(true);
2472
2573
  return;
2473
2574
  }
2474
2575
  if (name === "/continue") {
@@ -2909,6 +3010,10 @@ export function App({ resumeSessionId, initialScreen }) {
2909
3010
  setKeysOpen(false);
2910
3011
  } }, keysTick));
2911
3012
  }
3013
+ if (mcpOpen) {
3014
+ const mcp = session.current?.toolContext.mcp;
3015
+ return (_jsx(McpMinitabs, { servers: mcp?.statuses() ?? [], blockedCountFor: (name) => mcp?.blockedCountFor(name) ?? 0, configFor: (name) => mcp?.configFor(name), onSubmit: (spec) => void mcpWriteAndConnect(spec), onSetDisabled: (name, disabled) => void mcpSetDisabled(name, disabled), onRemove: (name) => void mcpRemove(name), onReconnect: (name) => void reconnectMcp(name), onReviewBlocked: (name) => void mcpReviewBlocked(name), signedIn: mcpSignedIn, onSignIn: mcpSignIn, onCopyLink: (url) => copyToClipboard(url), onSignOut: (name) => void mcpSignOut(name), width: width, maxRows: maxRows, onClose: () => setMcpOpen(false) }));
3016
+ }
2912
3017
  if (!overlay)
2913
3018
  return null;
2914
3019
  const cur = session.current;
@@ -2937,14 +3042,6 @@ export function App({ resumeSessionId, initialScreen }) {
2937
3042
  }));
2938
3043
  return (_jsx(Picker, { title: "Background shells", items: items, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
2939
3044
  }
2940
- if (overlay.kind === "mcp") {
2941
- const mgr = cur?.toolContext.mcp;
2942
- const items = overlay.items.map((srv) => ({
2943
- label: mcpLabel(srv),
2944
- description: mcpDetail(srv, mgr?.blockedCountFor(srv.name) ?? 0),
2945
- }));
2946
- return _jsx(Picker, { title: "MCP servers", items: items, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel });
2947
- }
2948
3045
  if (overlay.kind === "provider") {
2949
3046
  const activeModel = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
2950
3047
  const active = providerOf(activeModel).id;
@@ -3744,44 +3841,6 @@ function clipCmd(command, max = 44) {
3744
3841
  function shellElapsed(sh) {
3745
3842
  return fmtElapsed(Math.floor((Date.now() - sh.startedAt) / 1000));
3746
3843
  }
3747
- /** One MCP server's headline row: name plus a state marker readable at a glance. */
3748
- export function mcpLabel(server) {
3749
- const marker = server.state === "connected" ? "●" : server.state === "pending" ? "◐" : server.state === "disabled" ? "○" : "✗";
3750
- return `${marker} ${server.name}`;
3751
- }
3752
- /** The dim detail column: what this server is doing, and what Enter would do to it. */
3753
- export function mcpDetail(server, blocked = 0) {
3754
- // A blocked tool is the one state the user must act on, so it outranks everything
3755
- // else in the row — including a healthy connection.
3756
- if (blocked > 0) {
3757
- return `${blocked} tool${blocked === 1 ? "" : "s"} BLOCKED (description changed) — Enter to review`;
3758
- }
3759
- switch (server.state) {
3760
- case "connected": {
3761
- // Prompts are counted separately because they are the user's to invoke, not the
3762
- // model's: a server offering only prompts would otherwise read as "0 tools" and
3763
- // look broken.
3764
- const counts = [
3765
- `${server.toolCount} tool${server.toolCount === 1 ? "" : "s"}`,
3766
- ...(server.promptCount ? [`${server.promptCount} prompt${server.promptCount === 1 ? "" : "s"}`] : []),
3767
- ...(server.offersResources ? ["resources"] : []),
3768
- ];
3769
- const tools = counts.join(", ");
3770
- // The negotiated revision is worth surfacing: "legacy" is why a server may be
3771
- // missing capabilities, and it is otherwise invisible.
3772
- const proto = server.version ? ` · ${server.version}${server.legacy ? " (legacy)" : ""}` : "";
3773
- return `${tools}${proto} — Enter to reconnect`;
3774
- }
3775
- case "pending":
3776
- return "connecting…";
3777
- case "needs-auth":
3778
- return "needs authentication — Enter to retry";
3779
- case "disabled":
3780
- return "disabled in mcp.json";
3781
- default:
3782
- return `${server.error ?? "failed"} — Enter to retry`;
3783
- }
3784
- }
3785
3844
  /**
3786
3845
  * The under-the-chat indicator for background shells: one running shell shows its
3787
3846
  * command + elapsed; several collapse to a count. It self-ticks once a second while