@corenel/term 0.2.9 → 0.4.0
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.
- package/dist/{chunk-L4KCEKIT.js → chunk-2MGDL3I4.js} +7 -2
- package/dist/chunk-5RPHYXND.js +138 -0
- package/dist/{chunk-QZJTOVZL.js → chunk-HUVRQX53.js} +513 -34
- package/dist/commands.d.ts +39 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +11 -3
- package/dist/contextText.d.ts +37 -0
- package/dist/contextText.d.ts.map +1 -0
- package/dist/ink/App.d.ts +14 -0
- package/dist/ink/App.d.ts.map +1 -1
- package/dist/ink/ContextOverlay.d.ts +19 -0
- package/dist/ink/ContextOverlay.d.ts.map +1 -0
- package/dist/ink/HelpOverlay.d.ts +19 -0
- package/dist/ink/HelpOverlay.d.ts.map +1 -0
- package/dist/ink/McpOverlay.d.ts +7 -0
- package/dist/ink/McpOverlay.d.ts.map +1 -0
- package/dist/ink/Modal.d.ts +11 -3
- package/dist/ink/Modal.d.ts.map +1 -1
- package/dist/ink/index.d.ts.map +1 -1
- package/dist/ink/index.js +400 -92
- package/dist/ink/overlayBudget.d.ts +27 -0
- package/dist/ink/overlayBudget.d.ts.map +1 -0
- package/dist/ink/scroll.d.ts.map +1 -1
- package/dist/mcpText.d.ts +20 -0
- package/dist/mcpText.d.ts.map +1 -0
- package/dist/session.d.ts +175 -6
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +3 -3
- package/dist/state.d.ts +83 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +1 -1
- package/package.json +4 -4
- package/dist/chunk-VTJEWHM3.js +0 -65
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
emptyState,
|
|
3
3
|
parseTodos
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-2MGDL3I4.js";
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
formatHelpText,
|
|
7
|
+
isKnownCommandWord,
|
|
8
|
+
parseCommand,
|
|
9
|
+
unknownCommandMessage
|
|
10
|
+
} from "./chunk-5RPHYXND.js";
|
|
8
11
|
import {
|
|
9
12
|
WRITE_TOOL_NAMES,
|
|
10
13
|
buildWritePreview
|
|
@@ -24,6 +27,76 @@ import {
|
|
|
24
27
|
import { runAgent } from "@corenel/harness/core/loop";
|
|
25
28
|
import { priceUsageWith } from "@corenel/harness/core/pricing";
|
|
26
29
|
|
|
30
|
+
// src/mcpText.ts
|
|
31
|
+
function mcpStatusWord(v) {
|
|
32
|
+
if (v.error) return "unreachable";
|
|
33
|
+
if (!v.signedIn) return "sign-in needed";
|
|
34
|
+
if (v.connected) return "connected";
|
|
35
|
+
return "cached";
|
|
36
|
+
}
|
|
37
|
+
function ageWord(fetchedAt, now) {
|
|
38
|
+
if (fetchedAt === null) return "never fetched";
|
|
39
|
+
const ms = Math.max(0, now - fetchedAt);
|
|
40
|
+
const mins = Math.floor(ms / 6e4);
|
|
41
|
+
if (mins < 1) return "fetched just now";
|
|
42
|
+
if (mins < 60) return `fetched ${mins}m ago`;
|
|
43
|
+
const hours = Math.floor(mins / 60);
|
|
44
|
+
if (hours < 24) return `fetched ${hours}h ago`;
|
|
45
|
+
const days = Math.floor(hours / 24);
|
|
46
|
+
return `fetched ${days}d ago`;
|
|
47
|
+
}
|
|
48
|
+
function formatMcpServers(views, now) {
|
|
49
|
+
if (views.length === 0) {
|
|
50
|
+
return "No mcp servers configured. /mcp add <name> <url> to add one.";
|
|
51
|
+
}
|
|
52
|
+
const nameWidth = Math.max(...views.map((v) => v.name.length));
|
|
53
|
+
const statusWidth = Math.max(...views.map((v) => mcpStatusWord(v).length));
|
|
54
|
+
const lines = views.map((v) => {
|
|
55
|
+
const tools = `${v.toolCount} tool${v.toolCount === 1 ? "" : "s"}`;
|
|
56
|
+
const hosted = v.hosted ? " hosted" : "";
|
|
57
|
+
const error = v.error ? ` (${v.error})` : "";
|
|
58
|
+
return ` ${v.name.padEnd(nameWidth)} ${mcpStatusWord(v).padEnd(statusWidth)} ${tools}, ${ageWord(v.fetchedAt, now)}${hosted}${error}`;
|
|
59
|
+
});
|
|
60
|
+
return [
|
|
61
|
+
`${views.length} mcp server${views.length === 1 ? "" : "s"} configured:`,
|
|
62
|
+
...lines
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/contextText.ts
|
|
67
|
+
function fmtBytes(bytes) {
|
|
68
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
69
|
+
}
|
|
70
|
+
function formatContext(s) {
|
|
71
|
+
const lines = [];
|
|
72
|
+
if (s.sentTokens === null) {
|
|
73
|
+
lines.push("no model call has completed yet this conversation -- nothing sent to size.");
|
|
74
|
+
} else if (s.contextWindow === null) {
|
|
75
|
+
lines.push(`${s.sentTokens} tokens sent on the last model call -- context window unknown for this model.`);
|
|
76
|
+
} else {
|
|
77
|
+
const pct = Math.round(s.sentTokens / s.contextWindow * 100);
|
|
78
|
+
lines.push(`${s.sentTokens} / ${s.contextWindow} tokens (${pct}%) sent on the last model call.`);
|
|
79
|
+
}
|
|
80
|
+
lines.push("");
|
|
81
|
+
const loaded = s.loadedToolNames.length ? `, ${s.loadedToolNames.length} MCP tool${s.loadedToolNames.length === 1 ? "" : "s"} loaded (${s.loadedToolNames.join(", ")})` : "";
|
|
82
|
+
lines.push(`tools: ${s.toolCount} available${loaded}`);
|
|
83
|
+
lines.push("");
|
|
84
|
+
if (s.offloaded.length === 0) {
|
|
85
|
+
lines.push("offloaded: none");
|
|
86
|
+
} else {
|
|
87
|
+
lines.push(`offloaded (${s.offloaded.length}, recall with /expand):`);
|
|
88
|
+
for (const o of s.offloaded) lines.push(` ${o.name} ${fmtBytes(o.bytes)}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push("");
|
|
91
|
+
if (s.checkpoints.length === 0) {
|
|
92
|
+
lines.push("compaction: none this conversation");
|
|
93
|
+
} else {
|
|
94
|
+
lines.push(`compaction seams (${s.checkpoints.length}):`);
|
|
95
|
+
for (const c of s.checkpoints) lines.push(` ${c.collapsed} messages collapsed into a checkpoint`);
|
|
96
|
+
}
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
27
100
|
// src/markdown.ts
|
|
28
101
|
var FENCE = /^```(\w*)\s*$/;
|
|
29
102
|
var HEADING = /^(#{1,6})\s+(.*)$/;
|
|
@@ -83,34 +156,14 @@ function stripInline(s) {
|
|
|
83
156
|
}
|
|
84
157
|
|
|
85
158
|
// src/session.ts
|
|
86
|
-
var HELP_TEXT = [
|
|
87
|
-
"Type a request and press enter.",
|
|
88
|
-
"",
|
|
89
|
-
" /tools what the agent can do, and what it can change",
|
|
90
|
-
" /policy how this session is governed -- levels, caps, and what actually confines it",
|
|
91
|
-
" /expand #3 re-print a tool result in full (#3 is the id on its row)",
|
|
92
|
-
" /resume [id] reopen an earlier conversation",
|
|
93
|
-
" /clear forget the conversation",
|
|
94
|
-
" /exit leave",
|
|
95
|
-
"",
|
|
96
|
-
" /mode [id] switch the agent mode, or list what is configured",
|
|
97
|
-
" /persona [id] switch persona, or list what is configured",
|
|
98
|
-
" /skill [name] apply a skill to your NEXT message only, or list what is installed",
|
|
99
|
-
" /strategy [name] run a saved strategy from .prompd/strategies, or list what is saved",
|
|
100
|
-
"",
|
|
101
|
-
/* Deliberately not 'esc stops a turn': this text is shared, and Escape is
|
|
102
|
-
* an Ink-only key -- the readline fallback has no key handler at all, so
|
|
103
|
-
* there Ctrl-C is the only way to stop a turn. Name the one that is true
|
|
104
|
-
* on both faces, and mark the other as what it is. */
|
|
105
|
-
"Ctrl-C stops a turn; twice leaves. Ctrl-D leaves. In the full terminal face, esc also stops a turn."
|
|
106
|
-
].join("\n");
|
|
107
159
|
var DELTA_FLUSH_MS = 50;
|
|
108
160
|
var TOOL_DELTA_CAP = 4e3;
|
|
109
161
|
var TermSession = class {
|
|
110
162
|
constructor(deps) {
|
|
111
163
|
this.deps = deps;
|
|
164
|
+
this.state = emptyState(deps.model);
|
|
112
165
|
}
|
|
113
|
-
state
|
|
166
|
+
state;
|
|
114
167
|
listeners = /* @__PURE__ */ new Set();
|
|
115
168
|
nextOrd = 1;
|
|
116
169
|
idSeq = 0;
|
|
@@ -176,6 +229,13 @@ var TermSession = class {
|
|
|
176
229
|
* reach a renderer. */
|
|
177
230
|
pendingSkill = null;
|
|
178
231
|
wantsExit = false;
|
|
232
|
+
/** The last line that was WARNED ABOUT rather than sent, because its first
|
|
233
|
+
* word after `/` looked like a command but wasn't one -- see `submit`. Set
|
|
234
|
+
* on the warn, cleared the moment anything else happens (a different line,
|
|
235
|
+
* a real command, an empty line): only an EXACT resubmission of this same
|
|
236
|
+
* text counts as "I meant it, send it anyway". `null` means nothing is
|
|
237
|
+
* pending. */
|
|
238
|
+
lastRejectedLine = null;
|
|
179
239
|
attachPermission(p) {
|
|
180
240
|
this.permission = p;
|
|
181
241
|
}
|
|
@@ -428,6 +488,11 @@ var TermSession = class {
|
|
|
428
488
|
}
|
|
429
489
|
case "offload":
|
|
430
490
|
this.pendingOffload = { refId: e.id, bytes: e.bytes };
|
|
491
|
+
this.set({ offloaded: [...this.state.offloaded, { id: e.id, name: e.name, bytes: e.bytes }] });
|
|
492
|
+
break;
|
|
493
|
+
case "checkpoint":
|
|
494
|
+
this.note("info", `checkpoint ${e.collapsed} messages collapsed into CHECKPOINT.md`);
|
|
495
|
+
this.set({ checkpoints: [...this.state.checkpoints, { collapsed: e.collapsed, checkpoint: e.checkpoint }] });
|
|
431
496
|
break;
|
|
432
497
|
case "permission":
|
|
433
498
|
case "permission-audit":
|
|
@@ -459,9 +524,18 @@ var TermSession = class {
|
|
|
459
524
|
* rates, and costUsd stays 0, which the face reads as "no cost to
|
|
460
525
|
* show". Rates are read per event, not captured once, because the
|
|
461
526
|
* host may still be hydrating them when the first turn runs. */
|
|
462
|
-
costUsd: this.state.costUsd + (priceUsageWith(e.usage, this.deps.rates?.() ?? null) ?? 0)
|
|
527
|
+
costUsd: this.state.costUsd + (priceUsageWith(e.usage, this.deps.rates?.() ?? null) ?? 0),
|
|
528
|
+
// `/context`'s fill answer: OVERWRITTEN, not accumulated like
|
|
529
|
+
// `usage`/`turnUsage` above -- this is "what's in the window right
|
|
530
|
+
// now" (the last call's prompt size), not a running total. The loop
|
|
531
|
+
// emits one `usage` event per model call, so the LAST one to land
|
|
532
|
+
// is always the most recent call, tool round-trips included.
|
|
533
|
+
sentTokens: e.usage.promptTokens
|
|
463
534
|
});
|
|
464
535
|
break;
|
|
536
|
+
case "tools-loaded":
|
|
537
|
+
this.set({ toolNames: e.names });
|
|
538
|
+
break;
|
|
465
539
|
case "artifact":
|
|
466
540
|
this.note("info", `artifact ${e.kind}${e.title ? ` ${e.title}` : ""}`);
|
|
467
541
|
break;
|
|
@@ -481,15 +555,63 @@ var TermSession = class {
|
|
|
481
555
|
break;
|
|
482
556
|
}
|
|
483
557
|
}
|
|
484
|
-
/**
|
|
558
|
+
/** Whether calling `submit(text)` right now would warn-and-refuse rather
|
|
559
|
+
* than run a command or send a message -- i.e. `text`'s first word after a
|
|
560
|
+
* leading `/` looks like a command attempt but isn't one, AND this isn't
|
|
561
|
+
* already the resend of a line that was just warned about. PURE: does not
|
|
562
|
+
* touch `lastRejectedLine` or commit anything, so a caller can check it
|
|
563
|
+
* before deciding what to do with the input box (see ink/index.tsx's
|
|
564
|
+
* onSubmit, which must NOT clear the box for the line this reports true
|
|
565
|
+
* for) without racing `submit`'s own mutation of the same state. */
|
|
566
|
+
willReject(text) {
|
|
567
|
+
const line = text.trim();
|
|
568
|
+
if (!line || !line.startsWith("/")) return false;
|
|
569
|
+
if (parseCommand(line)) return false;
|
|
570
|
+
const word = line.slice(1).split(/\s+/)[0] ?? "";
|
|
571
|
+
if (!word || isKnownCommandWord(word)) return false;
|
|
572
|
+
return this.lastRejectedLine !== line;
|
|
573
|
+
}
|
|
574
|
+
/** Route one line: a command runs here; anything else is a turn -- UNLESS
|
|
575
|
+
* the first word after a leading `/` looks like a command attempt and
|
|
576
|
+
* isn't one (see commands.ts's `isKnownCommandWord`), in which case the
|
|
577
|
+
* FIRST Enter on that exact text warns and stops rather than paying for a
|
|
578
|
+
* model call on a typo (`/context` before Feature 2 added it, `/toosl`,
|
|
579
|
+
* `/hepl`, ...). A SECOND Enter on the identical, unchanged line is taken
|
|
580
|
+
* as "I meant it" and sent as an ordinary message -- tracked in
|
|
581
|
+
* `lastRejectedLine`, which both faces share automatically since it lives
|
|
582
|
+
* here rather than in either face's own state: the Ink face keeps the text
|
|
583
|
+
* in the input box (ink/index.tsx checks `willReject` before clearing it)
|
|
584
|
+
* so retyping Enter resubmits the SAME string; the readline face has no
|
|
585
|
+
* input box to hold onto, so there the "second Enter" is the user
|
|
586
|
+
* retyping the line themselves -- an honest equivalent, not a shortcut. */
|
|
485
587
|
async submit(text) {
|
|
486
588
|
const line = text.trim();
|
|
487
|
-
if (!line)
|
|
589
|
+
if (!line) {
|
|
590
|
+
this.lastRejectedLine = null;
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
488
593
|
const cmd = parseCommand(line);
|
|
489
594
|
if (cmd) {
|
|
595
|
+
this.lastRejectedLine = null;
|
|
490
596
|
await this.runCommand(cmd);
|
|
491
597
|
return;
|
|
492
598
|
}
|
|
599
|
+
if (line.startsWith("/")) {
|
|
600
|
+
const word = line.slice(1).split(/\s+/)[0] ?? "";
|
|
601
|
+
if (word && !isKnownCommandWord(word)) {
|
|
602
|
+
if (this.lastRejectedLine === line) {
|
|
603
|
+
this.lastRejectedLine = null;
|
|
604
|
+
} else {
|
|
605
|
+
this.lastRejectedLine = line;
|
|
606
|
+
this.note("warn", unknownCommandMessage(word));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
this.lastRejectedLine = null;
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
this.lastRejectedLine = null;
|
|
614
|
+
}
|
|
493
615
|
await this.queueOrRun(line);
|
|
494
616
|
}
|
|
495
617
|
/** Run `line` as a turn now, or queue it, exactly like `submit` does for
|
|
@@ -522,7 +644,15 @@ var TermSession = class {
|
|
|
522
644
|
// conversation's cost to this one.
|
|
523
645
|
costUsd: 0,
|
|
524
646
|
todos: [],
|
|
525
|
-
pendingSkill: null
|
|
647
|
+
pendingSkill: null,
|
|
648
|
+
// /context's accumulators are per-conversation facts (what was
|
|
649
|
+
// sent, what got offloaded, what got compacted) -- same reasoning
|
|
650
|
+
// as costUsd just above. `toolNames` (the MCP set) is NOT reset
|
|
651
|
+
// here: it describes what is currently LOADED, independent of the
|
|
652
|
+
// conversation that gets forgotten.
|
|
653
|
+
sentTokens: null,
|
|
654
|
+
offloaded: [],
|
|
655
|
+
checkpoints: []
|
|
526
656
|
});
|
|
527
657
|
if (droppedSkill) this.note("info", `cleared -- the armed skill "${droppedSkill.label}" was dropped too`);
|
|
528
658
|
return;
|
|
@@ -537,8 +667,11 @@ var TermSession = class {
|
|
|
537
667
|
}
|
|
538
668
|
this.note("info", formatPolicy(this.deps.policy, this.deps.policySource, this.deps.tools));
|
|
539
669
|
return;
|
|
670
|
+
case "context":
|
|
671
|
+
this.note("info", formatContext(this.contextSnapshot()));
|
|
672
|
+
return;
|
|
540
673
|
case "help":
|
|
541
|
-
this.note("info",
|
|
674
|
+
this.note("info", formatHelpText());
|
|
542
675
|
return;
|
|
543
676
|
case "expand": {
|
|
544
677
|
if (cmd.ord === void 0) {
|
|
@@ -551,6 +684,9 @@ var TermSession = class {
|
|
|
551
684
|
case "resume":
|
|
552
685
|
await this.resume(cmd.id);
|
|
553
686
|
return;
|
|
687
|
+
case "model":
|
|
688
|
+
await this.runModelCommand(cmd.id);
|
|
689
|
+
return;
|
|
554
690
|
case "mode":
|
|
555
691
|
await this.switchIdentity({
|
|
556
692
|
kind: "mode",
|
|
@@ -573,6 +709,9 @@ var TermSession = class {
|
|
|
573
709
|
case "strategy":
|
|
574
710
|
await this.runStrategyCommand(cmd.id);
|
|
575
711
|
return;
|
|
712
|
+
case "mcp":
|
|
713
|
+
await this.runMcpCommand(cmd.sub, cmd.args ?? [], cmd.unknown);
|
|
714
|
+
return;
|
|
576
715
|
}
|
|
577
716
|
}
|
|
578
717
|
/** Runs a host-supplied config-workspace call (`listModes`, `listSkills`,
|
|
@@ -597,11 +736,17 @@ var TermSession = class {
|
|
|
597
736
|
return { ok: false };
|
|
598
737
|
}
|
|
599
738
|
}
|
|
600
|
-
/** Shared `/mode
|
|
739
|
+
/** Shared `/mode`/`/persona` shape: no id lists what's configured, an
|
|
601
740
|
* unknown id reports the valid options (never silently no-ops, never sends
|
|
602
|
-
* a typo through to the model), a real id applies immediately
|
|
603
|
-
*
|
|
604
|
-
*
|
|
741
|
+
* a typo through to the model), a real id applies immediately.
|
|
742
|
+
*
|
|
743
|
+
* "applies" means `deps.system` will pick it up on the NEXT
|
|
744
|
+
* `resolveSystem()` call, not this instant (see the field docs on
|
|
745
|
+
* TermSessionDeps.system) -- `opts.apply` (the host's `setMode`/
|
|
746
|
+
* `setPersona`) is the whole story there.
|
|
747
|
+
*
|
|
748
|
+
* `/model` does NOT go through here -- see `runModelCommand`: the model is
|
|
749
|
+
* fixed for a session, so there is nothing for it to apply. */
|
|
605
750
|
async switchIdentity(opts) {
|
|
606
751
|
if (!opts.list || !opts.apply) {
|
|
607
752
|
this.note("warn", `/${opts.kind} is not available in this session`);
|
|
@@ -623,6 +768,36 @@ var TermSession = class {
|
|
|
623
768
|
opts.apply(found.id);
|
|
624
769
|
this.note("info", `${opts.kind} set to ${found.label} -- takes effect on your next message`);
|
|
625
770
|
}
|
|
771
|
+
/** `/model` -- READ-ONLY. No id lists what's available (see `listModels`'s
|
|
772
|
+
* field doc for why); an id refuses rather than switching, because the
|
|
773
|
+
* model is fixed for the life of a session -- `resolveContextWindow(model)`
|
|
774
|
+
* runs once at launch and sizes rung-2 compaction from it, so a downward
|
|
775
|
+
* switch would leave compaction believing it has a bigger window than the
|
|
776
|
+
* model actually has and the conversation would meet the context wall as a
|
|
777
|
+
* fatal 400 instead of compacting in time. Changing the model requires
|
|
778
|
+
* reprocessing the session, which is not implemented (docs/BACKLOG.md
|
|
779
|
+
* section 5) -- the remedy is a restart with `--model <id>`, not this
|
|
780
|
+
* command. Routed through `safeCall` for the listing, same guard every
|
|
781
|
+
* other identity command uses. */
|
|
782
|
+
async runModelCommand(id) {
|
|
783
|
+
const list = this.deps.listModels;
|
|
784
|
+
if (!list) {
|
|
785
|
+
this.note("warn", "/model is not available in this session");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const listed = await this.safeCall("model", list);
|
|
789
|
+
if (!listed.ok) return;
|
|
790
|
+
const entries = listed.value;
|
|
791
|
+
if (!id) {
|
|
792
|
+
const names = entries.map((e) => e.id).join(", ");
|
|
793
|
+
this.note("info", names ? `models: ${names}` : `models: not known here -- the catalogue is fetched, so this is empty offline or before it answers. This session is on ${this.deps.model} either way.`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
this.note(
|
|
797
|
+
"warn",
|
|
798
|
+
`the model is fixed for a session -- its context window, compaction sizing and pricing are all derived from it at start, and switching it mid-session cannot be done safely. Restart with --model ${id} to use it.`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
626
801
|
async armSkillCommand(id) {
|
|
627
802
|
if (!this.deps.listSkills || !this.deps.armSkill) {
|
|
628
803
|
this.note("warn", "/skill is not available in this session");
|
|
@@ -681,6 +856,306 @@ var TermSession = class {
|
|
|
681
856
|
this.note("info", `running strategy "${resolved.value.name}"`);
|
|
682
857
|
await this.queueOrRun(resolved.value.prompt);
|
|
683
858
|
}
|
|
859
|
+
/** Like `safeCall`, but for `/mcp`'s own host calls -- a SEPARATE wrapper,
|
|
860
|
+
* not a shared message, because the failure shape is different in kind.
|
|
861
|
+
* `safeCall`'s "not available -- could not read the config workspace"
|
|
862
|
+
* framing was written for `/mode`/`/persona`: there, a throw really does
|
|
863
|
+
* mean the config workspace itself is broken. An MCP host call throwing is
|
|
864
|
+
* usually something else entirely -- `McpAdmin`'s write methods
|
|
865
|
+
* (packages/cli/src/mcpAdmin.ts) return `Promise<void>` with no result
|
|
866
|
+
* channel, so a REFUSAL (a duplicate name, a daemon saying no, a failed
|
|
867
|
+
* sign-in) surfaces as a thrown Error too, indistinguishable by shape from
|
|
868
|
+
* a real transport failure. Telling the user the command is "not
|
|
869
|
+
* available" when it ran and was refused is actively wrong -- it says "try
|
|
870
|
+
* nothing" when the honest answer is "try something else, here is why this
|
|
871
|
+
* attempt didn't work". So this wrapper names the ACTION and reports the
|
|
872
|
+
* underlying reason verbatim, and leaves `safeCall` untouched for the four
|
|
873
|
+
* callers whose failure really is "could not read the config workspace". */
|
|
874
|
+
async safeMcpCall(action, fn) {
|
|
875
|
+
try {
|
|
876
|
+
return { ok: true, value: await fn() };
|
|
877
|
+
} catch (e) {
|
|
878
|
+
this.note("warn", `/mcp ${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
879
|
+
return { ok: false };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/** `/mcp` and its subcommands -- what is connected, and sign in or add one.
|
|
883
|
+
* `listMcpServers` gates the whole command, the same way `deps.policy` gates
|
|
884
|
+
* `/policy`: a host that wires none of the five deps reports `/mcp` itself
|
|
885
|
+
* unavailable rather than half-working. Each mutating subcommand ALSO checks
|
|
886
|
+
* its own specific dep, so a host that wires read-only `listMcpServers` still
|
|
887
|
+
* gets a working `/mcp list` instead of every subcommand failing alike.
|
|
888
|
+
*
|
|
889
|
+
* `unknown` carries a subcommand `parseCommand` did not recognize -- see
|
|
890
|
+
* commands.ts's `mcp` case. It is rejected BY NAME rather than aliased to
|
|
891
|
+
* `list` (a typo must never produce ordinary-looking output with nothing
|
|
892
|
+
* to say it was not understood) and rather than reaching here as `null`
|
|
893
|
+
* from the parser (which would send it to the model as a chat message --
|
|
894
|
+
* worse still: a silent misfire that LOOKS like an answer). */
|
|
895
|
+
async runMcpCommand(sub, args, unknown) {
|
|
896
|
+
const listMcpServers = this.deps.listMcpServers;
|
|
897
|
+
if (!listMcpServers) {
|
|
898
|
+
this.note("warn", "/mcp is not available in this session");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (unknown) {
|
|
902
|
+
this.note("warn", `unknown /mcp subcommand "${unknown}" -- try: list, add, remove, login, refresh, forget`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
switch (sub ?? "list") {
|
|
906
|
+
case "list": {
|
|
907
|
+
const listed = await this.safeMcpCall("list", listMcpServers);
|
|
908
|
+
if (!listed.ok) return;
|
|
909
|
+
this.note("info", formatMcpServers(listed.value, Date.now()));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
case "add": {
|
|
913
|
+
const [name, url] = args;
|
|
914
|
+
if (!name || !url) {
|
|
915
|
+
this.note("warn", "usage: /mcp add <name> <url>");
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const addMcpServer = this.deps.addMcpServer;
|
|
919
|
+
if (!addMcpServer) {
|
|
920
|
+
this.note("warn", "/mcp add is not available in this session");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const added = await this.safeMcpCall("add", () => addMcpServer(name, url));
|
|
924
|
+
if (!added.ok) return;
|
|
925
|
+
this.note("info", `added mcp server "${name}"`);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
case "remove": {
|
|
929
|
+
const [name] = args;
|
|
930
|
+
if (!name) {
|
|
931
|
+
this.note("warn", "usage: /mcp remove <name>");
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const removeMcpServer = this.deps.removeMcpServer;
|
|
935
|
+
if (!removeMcpServer) {
|
|
936
|
+
this.note("warn", "/mcp remove is not available in this session");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const removed = await this.safeMcpCall("remove", () => removeMcpServer(name));
|
|
940
|
+
if (!removed.ok) return;
|
|
941
|
+
this.note("info", `removed mcp server "${name}"`);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
case "login": {
|
|
945
|
+
const [name] = args;
|
|
946
|
+
if (!name) {
|
|
947
|
+
this.note("warn", "usage: /mcp login <name>");
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const loginMcpServer = this.deps.loginMcpServer;
|
|
951
|
+
if (!loginMcpServer) {
|
|
952
|
+
this.note("warn", "/mcp login is not available in this session");
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const loggedIn = await this.safeMcpCall("login", () => loginMcpServer(name, (url) => this.note("info", `sign in at: ${url}`)));
|
|
956
|
+
if (!loggedIn.ok) return;
|
|
957
|
+
this.note("info", `signed in to "${name}"`);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
case "refresh": {
|
|
961
|
+
const [name] = args;
|
|
962
|
+
const refreshMcpServer = this.deps.refreshMcpServer;
|
|
963
|
+
if (!refreshMcpServer) {
|
|
964
|
+
this.note("warn", "/mcp refresh is not available in this session");
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const refreshed = await this.safeMcpCall("refresh", () => refreshMcpServer(name));
|
|
968
|
+
if (!refreshed.ok) return;
|
|
969
|
+
this.note("info", name ? `refreshed "${name}"` : "refreshed all mcp servers");
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
case "forget": {
|
|
973
|
+
const [name] = args;
|
|
974
|
+
if (!name) {
|
|
975
|
+
this.note("warn", "usage: /mcp forget <name>");
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const forgetMcpServer = this.deps.forgetMcpServer;
|
|
979
|
+
if (!forgetMcpServer) {
|
|
980
|
+
this.note("warn", "/mcp forget is not available in this session");
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const forgotten = await this.safeMcpCall("forget", () => forgetMcpServer(name));
|
|
984
|
+
if (!forgotten.ok) return;
|
|
985
|
+
this.note("info", `forgot the client registration for "${name}" -- the next /mcp login will register afresh`);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/* ---- the Ink `/mcp` manager overlay ------------------------------------
|
|
991
|
+
*
|
|
992
|
+
* Reachable ONLY from `packages/term/src/ink/index.tsx` -- the readline
|
|
993
|
+
* face keeps sending bare `/mcp` through `submit()` -> `runMcpCommand`'s
|
|
994
|
+
* `'list'` case exactly as before (it has no key handler to drive anything
|
|
995
|
+
* richer, and no code path here changes for it). These methods are a
|
|
996
|
+
* SEPARATE entry point the Ink face uses instead of `submit()` once the
|
|
997
|
+
* overlay is open, so it can move a selection and re-render without a
|
|
998
|
+
* round trip through command parsing -- but every mutating action still
|
|
999
|
+
* calls the exact same host deps (`removeMcpServer`, `loginMcpServer`,
|
|
1000
|
+
* `listMcpServers`) that `runMcpCommand` above uses, so the overlay is
|
|
1001
|
+
* presentation over that one dispatch, never a second implementation of
|
|
1002
|
+
* it. `McpManager.servers` is a snapshot fetched here and re-fetched after
|
|
1003
|
+
* a mutation succeeds -- the overlay itself caches nothing (see
|
|
1004
|
+
* ink/McpOverlay.tsx). */
|
|
1005
|
+
/** `/mcp` with no argument, from the Ink face only -- see the module doc
|
|
1006
|
+
* above. Fetches the current servers and opens the manager. */
|
|
1007
|
+
async openMcpManager() {
|
|
1008
|
+
const listMcpServers = this.deps.listMcpServers;
|
|
1009
|
+
if (!listMcpServers) {
|
|
1010
|
+
this.note("warn", "/mcp is not available in this session");
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const listed = await this.safeMcpCall("list", listMcpServers);
|
|
1014
|
+
if (!listed.ok) return;
|
|
1015
|
+
this.set({ modal: { kind: "mcp", servers: listed.value, selected: 0, login: null } });
|
|
1016
|
+
}
|
|
1017
|
+
/** esc, or any other reason the overlay should close -- a no-op unless the
|
|
1018
|
+
* manager is actually the modal open right now, so it can never steal a
|
|
1019
|
+
* permission or question modal's close. */
|
|
1020
|
+
closeMcpManager() {
|
|
1021
|
+
if (this.state.modal?.kind === "mcp") this.set({ modal: null });
|
|
1022
|
+
}
|
|
1023
|
+
/* ---- the Ink `/help` overlay -------------------------------------------
|
|
1024
|
+
*
|
|
1025
|
+
* Same split as `/mcp` above: `ink/index.tsx` intercepts a bare `/help`
|
|
1026
|
+
* before it reaches `submit()` and calls this instead; the readline face
|
|
1027
|
+
* has no overlay to open, so its `/help` keeps going through `submit()` ->
|
|
1028
|
+
* the `'help'` case in `runCommand` -> `note('info', formatHelpText())`,
|
|
1029
|
+
* unchanged -- both that text and this overlay render from the same
|
|
1030
|
+
* `TERM_COMMANDS` table (see commands.ts's `formatHelpText` and
|
|
1031
|
+
* ink/HelpOverlay.tsx), never a hand-written copy either face could fall
|
|
1032
|
+
* behind. This method needs no host dep and nothing to fetch -- opening it
|
|
1033
|
+
* is just setting the modal. */
|
|
1034
|
+
/** `/help` with no argument, from the Ink face only -- see the module doc
|
|
1035
|
+
* above. */
|
|
1036
|
+
openHelp() {
|
|
1037
|
+
this.set({ modal: { kind: "help" } });
|
|
1038
|
+
}
|
|
1039
|
+
/** esc, or any other reason the overlay should close -- a no-op unless the
|
|
1040
|
+
* help overlay is actually the modal open right now, so it can never steal
|
|
1041
|
+
* a permission, question, or MCP modal's close. */
|
|
1042
|
+
closeHelp() {
|
|
1043
|
+
if (this.state.modal?.kind === "help") this.set({ modal: null });
|
|
1044
|
+
}
|
|
1045
|
+
/* ---- the Ink `/context` overlay ----------------------------------------
|
|
1046
|
+
*
|
|
1047
|
+
* Same split as `/help`/`/mcp` above: `ink/index.tsx` intercepts a bare
|
|
1048
|
+
* `/context` before it reaches `submit()` and calls `openContext` instead;
|
|
1049
|
+
* the readline face's `/context` keeps going through `submit()` -> the
|
|
1050
|
+
* `'context'` case in `runCommand` -> `note('info', formatContext(...))`,
|
|
1051
|
+
* unchanged. Unlike the MCP manager, there is nothing to FETCH here --
|
|
1052
|
+
* every field `formatContext` needs already lives on `TuiState` (or is a
|
|
1053
|
+
* host-supplied getter), kept current by `handleEvent`'s `usage`/
|
|
1054
|
+
* `offload`/`checkpoint`/`tools-loaded` cases -- so opening the overlay is
|
|
1055
|
+
* just setting the modal, and it stays live: a turn that lands while it is
|
|
1056
|
+
* open updates the numbers the next time `App` re-renders, with no
|
|
1057
|
+
* re-fetch and no re-open. */
|
|
1058
|
+
/** The data `formatContext` (contextText.ts) needs, assembled from
|
|
1059
|
+
* `TuiState`'s own accumulators and the two host-supplied static facts
|
|
1060
|
+
* (`deps.tools.length`, `deps.contextWindow()`) -- called fresh by both
|
|
1061
|
+
* `runCommand`'s `'context'` case and the Ink overlay's render, never
|
|
1062
|
+
* cached, so neither face can show a stale window size after `/context`'s
|
|
1063
|
+
* OWN state changes mid-turn. */
|
|
1064
|
+
contextSnapshot() {
|
|
1065
|
+
return {
|
|
1066
|
+
sentTokens: this.state.sentTokens,
|
|
1067
|
+
contextWindow: this.deps.contextWindow?.() ?? null,
|
|
1068
|
+
offloaded: this.state.offloaded,
|
|
1069
|
+
checkpoints: this.state.checkpoints,
|
|
1070
|
+
toolCount: this.deps.tools.length,
|
|
1071
|
+
loadedToolNames: this.state.toolNames
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
/** `/context` with no argument, from the Ink face only -- see the module
|
|
1075
|
+
* doc above. Needs no host dep and nothing to fetch -- opening it is just
|
|
1076
|
+
* setting the modal. */
|
|
1077
|
+
openContext() {
|
|
1078
|
+
this.set({ modal: { kind: "context" } });
|
|
1079
|
+
}
|
|
1080
|
+
/** esc, or any other reason the context overlay should close -- a no-op
|
|
1081
|
+
* unless the context overlay is actually the modal open right now, so it
|
|
1082
|
+
* can never steal a permission, question, help, or MCP modal's close. */
|
|
1083
|
+
closeContext() {
|
|
1084
|
+
if (this.state.modal?.kind === "context") this.set({ modal: null });
|
|
1085
|
+
}
|
|
1086
|
+
/** Up/down inside the overlay -- wraps, and does nothing with zero rows. */
|
|
1087
|
+
moveMcpSelection(delta) {
|
|
1088
|
+
const modal = this.state.modal;
|
|
1089
|
+
if (modal?.kind !== "mcp" || modal.servers.length === 0) return;
|
|
1090
|
+
const selected = (modal.selected + delta + modal.servers.length) % modal.servers.length;
|
|
1091
|
+
this.set({ modal: { ...modal, selected } });
|
|
1092
|
+
}
|
|
1093
|
+
/** `x` inside the overlay -- removes the selected row via the same
|
|
1094
|
+
* `removeMcpServer` dep `/mcp remove` uses, then refreshes the list. */
|
|
1095
|
+
async removeMcpSelection() {
|
|
1096
|
+
const modal = this.state.modal;
|
|
1097
|
+
if (modal?.kind !== "mcp") return;
|
|
1098
|
+
const server = modal.servers[modal.selected];
|
|
1099
|
+
if (!server) return;
|
|
1100
|
+
await this.runMcpCommand("remove", [server.name]);
|
|
1101
|
+
await this.refreshMcpManager();
|
|
1102
|
+
}
|
|
1103
|
+
/** `l` inside the overlay -- signs the selected row in via the same
|
|
1104
|
+
* `loginMcpServer` dep `/mcp login` uses. The consent URL genuinely
|
|
1105
|
+
* arrives before the sign-in itself completes (a daemon message, or a
|
|
1106
|
+
* synchronous callback locally), so `modal.login.url` is updated live as
|
|
1107
|
+
* it arrives rather than buffered until the whole call resolves. */
|
|
1108
|
+
async loginMcpSelection() {
|
|
1109
|
+
const modal = this.state.modal;
|
|
1110
|
+
if (modal?.kind !== "mcp" || modal.login) return;
|
|
1111
|
+
const server = modal.servers[modal.selected];
|
|
1112
|
+
if (!server) return;
|
|
1113
|
+
const loginMcpServer = this.deps.loginMcpServer;
|
|
1114
|
+
if (!loginMcpServer) {
|
|
1115
|
+
this.note("warn", "/mcp login is not available in this session");
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
this.set({ modal: { ...modal, login: { server: server.name, url: null } } });
|
|
1119
|
+
const result = await this.safeMcpCall("login", () => loginMcpServer(server.name, (url) => {
|
|
1120
|
+
const cur = this.state.modal;
|
|
1121
|
+
if (cur?.kind === "mcp" && cur.login?.server === server.name) {
|
|
1122
|
+
this.set({ modal: { ...cur, login: { server: server.name, url } } });
|
|
1123
|
+
}
|
|
1124
|
+
}));
|
|
1125
|
+
const after = this.state.modal;
|
|
1126
|
+
if (after?.kind !== "mcp") return;
|
|
1127
|
+
this.set({ modal: { ...after, login: null } });
|
|
1128
|
+
if (result.ok) {
|
|
1129
|
+
this.note("info", `signed in to "${server.name}"`);
|
|
1130
|
+
await this.refreshMcpManager();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** `r` inside the overlay -- refreshes the selected row via the same
|
|
1134
|
+
* `refreshMcpServer` dep `/mcp refresh` uses, then refreshes the list so
|
|
1135
|
+
* the updated tool count and age show without the user reopening the
|
|
1136
|
+
* overlay. Same shape as `removeMcpSelection` above. */
|
|
1137
|
+
async refreshMcpSelection() {
|
|
1138
|
+
const modal = this.state.modal;
|
|
1139
|
+
if (modal?.kind !== "mcp") return;
|
|
1140
|
+
const server = modal.servers[modal.selected];
|
|
1141
|
+
if (!server) return;
|
|
1142
|
+
await this.runMcpCommand("refresh", [server.name]);
|
|
1143
|
+
await this.refreshMcpManager();
|
|
1144
|
+
}
|
|
1145
|
+
/** Re-fetches `listMcpServers` and replaces the overlay's snapshot,
|
|
1146
|
+
* clamping the selection into range -- used after add/remove/login
|
|
1147
|
+
* succeed so the overlay never shows a server list that mutation just
|
|
1148
|
+
* made stale. */
|
|
1149
|
+
async refreshMcpManager() {
|
|
1150
|
+
const listMcpServers = this.deps.listMcpServers;
|
|
1151
|
+
if (!listMcpServers) return;
|
|
1152
|
+
const listed = await this.safeMcpCall("list", listMcpServers);
|
|
1153
|
+
if (!listed.ok) return;
|
|
1154
|
+
const cur = this.state.modal;
|
|
1155
|
+
if (cur?.kind !== "mcp") return;
|
|
1156
|
+
const selected = Math.min(cur.selected, Math.max(listed.value.length - 1, 0));
|
|
1157
|
+
this.set({ modal: { ...cur, servers: listed.value, selected } });
|
|
1158
|
+
}
|
|
684
1159
|
/** Save after the turn, never during: a half-written turn is a lie on disk. */
|
|
685
1160
|
async persist() {
|
|
686
1161
|
const p = this.deps.persistence;
|
|
@@ -797,6 +1272,7 @@ ${line}` : line;
|
|
|
797
1272
|
signal: ac.signal,
|
|
798
1273
|
...this.permission ? { permission: this.permission } : {},
|
|
799
1274
|
...this.deps.compression ? { compression: this.deps.compression } : {},
|
|
1275
|
+
...this.deps.compaction ? { compaction: this.deps.compaction } : {},
|
|
800
1276
|
onEvent: (e) => {
|
|
801
1277
|
if (this.activeTurn === mine) this.handleEvent(e);
|
|
802
1278
|
}
|
|
@@ -913,6 +1389,9 @@ ${line}` : line;
|
|
|
913
1389
|
};
|
|
914
1390
|
|
|
915
1391
|
export {
|
|
1392
|
+
mcpStatusWord,
|
|
1393
|
+
ageWord,
|
|
1394
|
+
formatContext,
|
|
916
1395
|
parseMarkdown,
|
|
917
1396
|
DELTA_FLUSH_MS,
|
|
918
1397
|
TOOL_DELTA_CAP,
|