surgent 0.7.0-alpha.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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/surgent.js +211 -0
  4. package/dist/optimizers/LICENSE +21 -0
  5. package/dist/optimizers/index.js +1984 -0
  6. package/dist/optimizers/index.js.map +7 -0
  7. package/dist/optimizers/package.json +31 -0
  8. package/package.json +45 -0
  9. package/src/agent/built-in/documenter.md +58 -0
  10. package/src/agent/built-in/general.md +107 -0
  11. package/src/agent/built-in/planner.md +73 -0
  12. package/src/agent/built-in/scout.md +97 -0
  13. package/src/agent/command.ts +140 -0
  14. package/src/agent/helpers.ts +95 -0
  15. package/src/agent/index.ts +9 -0
  16. package/src/agent/storage.ts +287 -0
  17. package/src/agent/types.ts +28 -0
  18. package/src/checkpoint/git.ts +173 -0
  19. package/src/checkpoint/index.ts +117 -0
  20. package/src/checkpoint/snapshot.ts +28 -0
  21. package/src/checkpoint/stage.ts +59 -0
  22. package/src/checkpoint/store.ts +108 -0
  23. package/src/cleanup/checkpoint.ts +31 -0
  24. package/src/cleanup/helpers.ts +24 -0
  25. package/src/cleanup/index.ts +21 -0
  26. package/src/cleanup/permission.ts +74 -0
  27. package/src/cleanup/subsession.ts +46 -0
  28. package/src/commands/helpers.ts +217 -0
  29. package/src/commands/index.ts +79 -0
  30. package/src/commands/render.ts +95 -0
  31. package/src/commands/types.ts +11 -0
  32. package/src/mcp-client/call-tool.ts +143 -0
  33. package/src/mcp-client/client.ts +90 -0
  34. package/src/mcp-client/command.ts +257 -0
  35. package/src/mcp-client/helpers.ts +153 -0
  36. package/src/mcp-client/index.ts +21 -0
  37. package/src/mcp-client/list-tools.ts +84 -0
  38. package/src/mcp-client/storage.ts +190 -0
  39. package/src/mcp-client/types.ts +34 -0
  40. package/src/mcp-client/validation.ts +115 -0
  41. package/src/optimizers/compactor/bash.ts +159 -0
  42. package/src/optimizers/compactor/grep.ts +141 -0
  43. package/src/optimizers/compactor/index.ts +132 -0
  44. package/src/optimizers/deduplicator/helpers.ts +75 -0
  45. package/src/optimizers/deduplicator/index.ts +23 -0
  46. package/src/optimizers/deduplicator/resources.ts +77 -0
  47. package/src/optimizers/deduplicator/state.ts +119 -0
  48. package/src/optimizers/deduplicator/types.ts +14 -0
  49. package/src/optimizers/entries.ts +104 -0
  50. package/src/optimizers/index.ts +17 -0
  51. package/src/optimizers/inspector/helpers.ts +60 -0
  52. package/src/optimizers/inspector/index.ts +89 -0
  53. package/src/optimizers/inspector/inspect.ts +88 -0
  54. package/src/optimizers/inspector/types.ts +7 -0
  55. package/src/optimizers/languages/go.ts +79 -0
  56. package/src/optimizers/languages/grammar.ts +200 -0
  57. package/src/optimizers/languages/index.ts +75 -0
  58. package/src/optimizers/languages/java.ts +64 -0
  59. package/src/optimizers/languages/python.ts +63 -0
  60. package/src/optimizers/languages/rust.ts +71 -0
  61. package/src/optimizers/languages/symbols.ts +95 -0
  62. package/src/optimizers/languages/tree-sitter-languages.d.ts +23 -0
  63. package/src/optimizers/languages/types.ts +134 -0
  64. package/src/optimizers/languages/typescript.ts +116 -0
  65. package/src/optimizers/mapper/files.ts +94 -0
  66. package/src/optimizers/mapper/index.ts +133 -0
  67. package/src/optimizers/mapper/types.ts +6 -0
  68. package/src/optimizers/pruner/cleanup.ts +121 -0
  69. package/src/optimizers/pruner/context.ts +46 -0
  70. package/src/optimizers/pruner/index.ts +45 -0
  71. package/src/optimizers/pruner/session.ts +34 -0
  72. package/src/optimizers/pruner/types.ts +18 -0
  73. package/src/permission/bash.ts +124 -0
  74. package/src/permission/command.ts +111 -0
  75. package/src/permission/components/prompt.ts +255 -0
  76. package/src/permission/components/rules-list.ts +342 -0
  77. package/src/permission/constants.ts +48 -0
  78. package/src/permission/helpers.ts +156 -0
  79. package/src/permission/index.ts +134 -0
  80. package/src/permission/pattern.ts +51 -0
  81. package/src/permission/piignore.ts +148 -0
  82. package/src/permission/precedence.ts +54 -0
  83. package/src/permission/resolution.ts +116 -0
  84. package/src/permission/storage.ts +142 -0
  85. package/src/permission/types.ts +57 -0
  86. package/src/questionnaire/component.ts +357 -0
  87. package/src/questionnaire/helpers.ts +220 -0
  88. package/src/questionnaire/index.ts +67 -0
  89. package/src/questionnaire/schemas.ts +50 -0
  90. package/src/questionnaire/types.ts +47 -0
  91. package/src/redactor/index.ts +34 -0
  92. package/src/redactor/patterns.ts +234 -0
  93. package/src/redactor/secrets.ts +113 -0
  94. package/src/subagent/helpers.ts +93 -0
  95. package/src/subagent/index.ts +81 -0
  96. package/src/subagent/storage.ts +100 -0
  97. package/src/subagent/subsession.ts +266 -0
  98. package/src/subagent/types.ts +83 -0
  99. package/src/subagent/validation.ts +100 -0
  100. package/src/ui/components/action-select-list.ts +165 -0
  101. package/src/ui/components/bash-mode.ts +281 -0
  102. package/src/ui/components/extended-select-list.ts +166 -0
  103. package/src/ui/components/form-field.ts +184 -0
  104. package/src/ui/components/form.ts +179 -0
  105. package/src/ui/components/frame.ts +60 -0
  106. package/src/ui/components/input-mode-indicator.ts +64 -0
  107. package/src/ui/components/keybound.ts +150 -0
  108. package/src/ui/components/lines.ts +27 -0
  109. package/src/ui/components/placeholder-input.ts +59 -0
  110. package/src/ui/components/scoped-input.ts +78 -0
  111. package/src/ui/components/scrollable-view.ts +155 -0
  112. package/src/ui/index.ts +40 -0
  113. package/src/utils.ts +206 -0
  114. package/src/web-tools/index.ts +15 -0
  115. package/src/web-tools/providers/brave.ts +55 -0
  116. package/src/web-tools/providers/firecrawl.ts +66 -0
  117. package/src/web-tools/providers/index.ts +50 -0
  118. package/src/web-tools/providers/jina.ts +48 -0
  119. package/src/web-tools/providers/native.ts +57 -0
  120. package/src/web-tools/providers/tavily.ts +56 -0
  121. package/src/web-tools/settings.ts +15 -0
  122. package/src/web-tools/web-fetch/helpers.ts +66 -0
  123. package/src/web-tools/web-fetch/index.ts +91 -0
  124. package/src/web-tools/web-fetch/parser.ts +51 -0
  125. package/src/web-tools/web-fetch/storage.ts +65 -0
  126. package/src/web-tools/web-fetch/types.ts +8 -0
  127. package/src/web-tools/web-login/helpers.ts +79 -0
  128. package/src/web-tools/web-login/index.ts +100 -0
  129. package/src/web-tools/web-login/types.ts +4 -0
  130. package/src/web-tools/web-search/helpers.ts +36 -0
  131. package/src/web-tools/web-search/index.ts +98 -0
  132. package/src/web-tools/web-search/types.ts +15 -0
@@ -0,0 +1,46 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import type { StoredSubsessions } from "../subagent/types.js";
4
+ import { getPiPath, isMissingFileError, readJson, writeJson } from "../utils.js";
5
+
6
+ function collectOrphanedSubsessionIds(store: StoredSubsessions, pids: Set<string>): Set<string> {
7
+ const orphanedIds = new Set<string>();
8
+ for (const [subsessionId, metadata] of Object.entries(store)) {
9
+ if (!pids.has(metadata.pid)) {
10
+ orphanedIds.add(subsessionId);
11
+ }
12
+ }
13
+ return orphanedIds;
14
+ }
15
+
16
+ async function deleteSessionFilesByIds(cwd: string, sessionIds: Set<string>): Promise<void> {
17
+ if (sessionIds.size === 0) return;
18
+ const sessions = await SessionManager.list(cwd, getPiPath("subsessionsDir", cwd));
19
+ const sessionPaths = sessions
20
+ .filter((session) => sessionIds.has(session.id))
21
+ .map((session) => session.path);
22
+
23
+ for (const sessionPath of sessionPaths) {
24
+ try {
25
+ await unlink(sessionPath);
26
+ } catch (error) {
27
+ if (isMissingFileError(error)) continue;
28
+ throw error;
29
+ }
30
+ }
31
+ }
32
+
33
+ export async function cleanupSubsessions(cwd: string, pids: Set<string>): Promise<void> {
34
+ const storeFilePath = getPiPath("subsessions", cwd);
35
+ const store = await readJson<StoredSubsessions>(storeFilePath, {});
36
+
37
+ const orphanedIds = collectOrphanedSubsessionIds(store, pids);
38
+ if (orphanedIds.size === 0) return;
39
+ await deleteSessionFilesByIds(cwd, orphanedIds);
40
+
41
+ for (const subsessionId of orphanedIds) {
42
+ delete store[subsessionId];
43
+ }
44
+
45
+ await writeJson(storeFilePath, store);
46
+ }
@@ -0,0 +1,217 @@
1
+ import { unlink, writeFile } from "node:fs/promises";
2
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import type { Subsession, SubsessionRequest } from "../subagent/types.js";
4
+ import { terminateSubsession } from "../subagent/storage.js";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { StoredSubsessions } from "../subagent/types.js";
7
+ import { ExtendedSelectList, type SelectEntry } from "../ui/components/extended-select-list.js";
8
+ import { getPiPath, isMissingFileError, isUuidv7, openInEditor, readJson } from "../utils.js";
9
+ import { openSubsession } from "../subagent/subsession.js";
10
+ import { renderSnapshotWidget, showPlanUi } from "./render.js";
11
+ import type { CommandInput } from "./types.js";
12
+
13
+ async function savePlanOutput(
14
+ ctx: ExtensionCommandContext,
15
+ subsession: Subsession,
16
+ ): Promise<string | null> {
17
+ if (!subsession.result.id) {
18
+ ctx.ui.notify("Failed to save plan: missing subsession ID", "error");
19
+ return null;
20
+ }
21
+
22
+ const outputPath = getPiPath("plans", ctx.cwd, `${subsession.result.id}.md`);
23
+
24
+ try {
25
+ await writeFile(outputPath, `${subsession.result.output.trimEnd()}\n`, "utf8");
26
+ return outputPath;
27
+ } catch (error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ ctx.ui.notify(`Failed to save plan: ${message}`, "error");
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function discardSubsession(ctx: ExtensionCommandContext, subsession: Subsession) {
35
+ const subsessionId = subsession.result.id;
36
+ if (!subsessionId) return;
37
+ terminateSubsession(ctx.cwd, subsessionId).catch(() => undefined);
38
+ }
39
+
40
+ async function forwardAction(
41
+ pi: ExtensionAPI,
42
+ ctx: ExtensionCommandContext,
43
+ subsession: Subsession,
44
+ outputPath: string | null,
45
+ ): Promise<boolean> {
46
+ const normalizedOutput = subsession.result.output.trim();
47
+ if (!normalizedOutput) {
48
+ ctx.ui.notify(`No ${subsession.label} to forward`, "warning");
49
+ return false;
50
+ }
51
+
52
+ try {
53
+ pi.sendUserMessage(normalizedOutput);
54
+ } catch {
55
+ ctx.ui.notify(`Failed to forward ${subsession.label}`, "error");
56
+ return false;
57
+ }
58
+
59
+ if (outputPath) {
60
+ try {
61
+ await unlink(outputPath);
62
+ } catch (error) {
63
+ if (!isMissingFileError(error)) {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ ctx.ui.notify(`Failed to delete ${subsession.label}: ${message}`, "error");
66
+ }
67
+ }
68
+ }
69
+
70
+ discardSubsession(ctx, subsession);
71
+ return true;
72
+ }
73
+
74
+ export async function runPlanLoop(
75
+ pi: ExtensionAPI,
76
+ ctx: ExtensionCommandContext,
77
+ subsession: Subsession,
78
+ ) {
79
+ try {
80
+ const outputPath = await savePlanOutput(ctx, subsession);
81
+ while (true) {
82
+ ctx.ui.setWidget("planner", undefined);
83
+ const action = await showPlanUi(ctx, subsession.result.output, outputPath);
84
+
85
+ if (action.kind === "save") {
86
+ ctx.ui.notify(
87
+ `Saved plan to ${outputPath}. Resume with '/plan <saved-plan-id>'`,
88
+ "info",
89
+ );
90
+ return;
91
+ }
92
+ if (action.kind === "discard") {
93
+ discardSubsession(ctx, subsession);
94
+ return;
95
+ }
96
+
97
+ if (action.kind === "open") {
98
+ if (outputPath) await openInEditor(ctx, outputPath);
99
+ } else if (action.kind === "forward") {
100
+ const forwarded = await forwardAction(pi, ctx, subsession, outputPath);
101
+ if (forwarded) return;
102
+ } else {
103
+ await subsession.exec(action.feedback);
104
+ }
105
+ }
106
+ } finally {
107
+ await subsession.dispose();
108
+ ctx.ui.setWidget("planner", undefined);
109
+ }
110
+ }
111
+
112
+ export async function getPlanPreviews(
113
+ cwd: string,
114
+ sessionId: string,
115
+ ): Promise<{ subsessionId: string; title: string }[]> {
116
+ const store = await readJson<StoredSubsessions>(getPiPath("subsessions", cwd), {});
117
+ const previews: { subsessionId: string; title: string }[] = [];
118
+
119
+ for (const [subsessionId, metadata] of Object.entries(store)) {
120
+ if (metadata.label === "plan" && metadata.pid === sessionId) {
121
+ previews.push({ subsessionId, title: metadata.title });
122
+ }
123
+ }
124
+
125
+ return previews;
126
+ }
127
+
128
+ export async function getPlanCompletions(cwd: string, sessionId: string, prefix: string) {
129
+ const normalizedPrefix = prefix.trim().toLowerCase();
130
+ const items = (await getPlanPreviews(cwd, sessionId))
131
+ .filter(
132
+ ({ subsessionId, title }) =>
133
+ subsessionId.startsWith(normalizedPrefix) || title.toLowerCase().includes(normalizedPrefix),
134
+ )
135
+ .map(({ subsessionId, title }) => ({ value: subsessionId, label: title }));
136
+ return items.length > 0 ? items : null;
137
+ }
138
+
139
+ async function pickPlanId(ctx: ExtensionContext): Promise<string | null> {
140
+ const previews = await getPlanPreviews(ctx.cwd, ctx.sessionManager.getSessionId());
141
+ if (previews.length === 0) {
142
+ ctx.ui.notify("No stored plan sessions", "warning");
143
+ return null;
144
+ }
145
+
146
+ const items: SelectEntry<{ subsessionId: string }>[] = previews.map((preview) => ({
147
+ value: preview.subsessionId,
148
+ label: preview.title,
149
+ data: { subsessionId: preview.subsessionId },
150
+ }));
151
+
152
+ return ctx.ui.custom<string | null>((_tui, theme, _keybindings, done) => {
153
+ const selectList = new ExtendedSelectList<{ subsessionId: string }>(theme, {
154
+ title: "Reopen plan session",
155
+ items,
156
+ maxVisibleRows: 12,
157
+ });
158
+
159
+ selectList.onCancel = () => done(null);
160
+ selectList.onSelect = (item) => done(item.data?.subsessionId ?? null);
161
+ selectList.onDelete = (item) => {
162
+ const subsessionId = item.data?.subsessionId;
163
+ if (!subsessionId) return;
164
+ terminateSubsession(ctx.cwd, subsessionId)
165
+ .then(() => ctx.ui.notify("Deleted plan session", "info"))
166
+ .catch(() => ctx.ui.notify("Failed to delete plan session", "error"));
167
+ };
168
+
169
+ return selectList;
170
+ });
171
+ }
172
+
173
+ export async function resolvePlan(
174
+ ctx: ExtensionCommandContext,
175
+ input: CommandInput,
176
+ ): Promise<Subsession | null> {
177
+ let prompt = "";
178
+ const request: SubsessionRequest = {
179
+ ctx,
180
+ label: "plan",
181
+ agent: "planner",
182
+ onSnapshot: (snapshot) => renderSnapshotWidget(ctx, "planner", snapshot),
183
+ };
184
+
185
+ if (input.kind === "prompt") {
186
+ prompt = input.prompt;
187
+ } else if (input.kind === "resume") {
188
+ request.id = input.subsessionId;
189
+ } else {
190
+ const selectedSubsessionId = await pickPlanId(ctx);
191
+ if (!selectedSubsessionId) return null;
192
+ request.id = selectedSubsessionId;
193
+ }
194
+
195
+ const subsession = await openSubsession(request);
196
+ if (!request.id && subsession.result.status !== "error") {
197
+ await subsession.exec(prompt);
198
+ }
199
+ if (!subsession.result.id) {
200
+ await subsession.dispose();
201
+ ctx.ui.notify(subsession.result.output || "Failed to initiate subsession", "error");
202
+ return null;
203
+ }
204
+
205
+ return subsession;
206
+ }
207
+
208
+ export function parseCommandInput(args: string): CommandInput {
209
+ const normalized = args.trim();
210
+ if (!normalized) {
211
+ return { kind: "list" };
212
+ }
213
+ if (isUuidv7(normalized)) {
214
+ return { kind: "resume", subsessionId: normalized };
215
+ }
216
+ return { kind: "prompt", prompt: normalized };
217
+ }
@@ -0,0 +1,79 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { renderSnapshotWidget } from "./render.js";
3
+ import { openSubsession } from "../subagent/subsession.js";
4
+ import { getPlanCompletions, parseCommandInput, resolvePlan, runPlanLoop } from "./helpers.js";
5
+
6
+ const INIT_PROMPT = `Analyze this repository and create or update AGENTS.md in its root. This file gives future coding agents concise, project-specific instructions.
7
+
8
+ Use only facts verified in repository files. Include build, lint, type-check, and test commands, focused test commands when available, architecture and important directories, coding conventions, and project-specific gotchas. Check existing AGENTS.md and other instruction files such as CLAUDE.md, .cursor/rules, .cursorrules, and .github/copilot-instructions.md. Preserve valid existing guidance and reference related files instead of duplicating them. Do not blindly replace AGENTS.md. Write the file, then briefly report what changed.`;
9
+
10
+ export default function (pi: ExtensionAPI) {
11
+ let cwd = "";
12
+ let pid = "";
13
+
14
+ pi.on("session_start", (_event, ctx) => {
15
+ cwd = ctx.cwd;
16
+ pid = ctx.sessionManager.getSessionId();
17
+ });
18
+
19
+ pi.registerCommand("init", {
20
+ description: "Create or update project AGENTS.md instructions",
21
+ handler: async (_args, ctx) => {
22
+ if (!ctx.hasUI) {
23
+ ctx.ui.notify("/init requires interactive UI", "error");
24
+ return;
25
+ }
26
+
27
+ const subsession = await openSubsession({
28
+ ctx,
29
+ label: "subagent",
30
+ agent: "documenter",
31
+ onSnapshot: (snapshot) => renderSnapshotWidget(ctx, "documenter", snapshot),
32
+ });
33
+ try {
34
+ if (subsession.result.status !== "error") {
35
+ await subsession.exec(INIT_PROMPT);
36
+ }
37
+
38
+ if (subsession.result.status === "done") {
39
+ ctx.ui.notify("AGENTS.md initialization finished", "info");
40
+ return;
41
+ }
42
+ ctx.ui.notify(subsession.result.output, "error");
43
+ } finally {
44
+ await subsession.dispose();
45
+ ctx.ui.setWidget("documenter", undefined);
46
+ }
47
+ },
48
+ });
49
+
50
+ pi.registerCommand("plan", {
51
+ description: `[empty|<plan-id>|<request>] - Resume or start a 'plan' background session. Leave empty to list saved plans`,
52
+ getArgumentCompletions: (prefix) => {
53
+ if (!cwd || !pid) return null;
54
+ return getPlanCompletions(cwd, pid, prefix);
55
+ },
56
+ handler: async (args, ctx) => {
57
+ if (!ctx.hasUI) {
58
+ ctx.ui.notify(`/plan requires interactive UI`, "error");
59
+ return;
60
+ }
61
+
62
+ const parsedInput = parseCommandInput(args);
63
+ const subsession = await resolvePlan(ctx, parsedInput);
64
+
65
+ if (!subsession) {
66
+ ctx.ui.setWidget("planner", undefined);
67
+ return;
68
+ }
69
+ if (subsession.result.status === "error") {
70
+ await subsession.dispose();
71
+ ctx.ui.setWidget("planner", undefined);
72
+ ctx.ui.notify(subsession.result.output, "error");
73
+ return;
74
+ }
75
+
76
+ await runPlanLoop(pi, ctx, subsession);
77
+ },
78
+ });
79
+ }
@@ -0,0 +1,95 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ ActionSelectList,
4
+ type ActionSelectOption,
5
+ type ActionSelectResult,
6
+ } from "../ui/components/action-select-list.js";
7
+ import { ScrollableView } from "../ui/components/scrollable-view.js";
8
+ import type { LoopAction } from "./types.js";
9
+ import { Container, Loader } from "@earendil-works/pi-tui";
10
+ import { TruncatedText } from "@earendil-works/pi-tui";
11
+ import { Spacer } from "@earendil-works/pi-tui";
12
+ import { formatSnapshotText } from "../subagent/helpers.js";
13
+ import type { SubsessionSnapshot } from "../subagent/types.js";
14
+
15
+ const ACTIVITY_LABELS = [
16
+ "analyzing",
17
+ "researching",
18
+ "synthesizing",
19
+ "scrutinizing",
20
+ "processing",
21
+ "cooking",
22
+ ] as const;
23
+
24
+ function mapActionResult(result: ActionSelectResult): LoopAction {
25
+ if (result.type === "input") {
26
+ return { kind: "feedback", feedback: result.value };
27
+ }
28
+ if (result.value === "open") {
29
+ return { kind: "open" };
30
+ }
31
+ if (result.value === "save") {
32
+ return { kind: "save" };
33
+ }
34
+ return { kind: "forward" };
35
+ }
36
+
37
+ export async function showPlanUi(
38
+ ctx: ExtensionCommandContext,
39
+ output: string,
40
+ outputPath: string | null,
41
+ ): Promise<LoopAction> {
42
+ const markdown = output.trim().length > 0 ? output : "_No planner output yet._";
43
+ const options: ActionSelectOption[] = [{ value: "forward", label: "Implement this plan" }];
44
+ if (outputPath) {
45
+ options.push({ value: "open", label: "Open plan in external editor" });
46
+ }
47
+ options.push({ value: "save", label: "Save and exit" });
48
+
49
+ return ctx.ui.custom<LoopAction>((tui, theme, keybindings, done) => {
50
+ const actionSelectList = new ActionSelectList(tui, keybindings, theme, {
51
+ title: "What should surgent do next?",
52
+ options,
53
+ placeholder: "Feedback for planner agent",
54
+ });
55
+ actionSelectList.onSubmit = (result) => done(mapActionResult(result));
56
+ actionSelectList.onCancel = () => done({ kind: "discard" });
57
+
58
+ const scrollableView = new ScrollableView(tui, theme, { markdown, input: actionSelectList });
59
+ scrollableView.focused = true;
60
+ scrollableView.onCancel = () => done({ kind: "discard" });
61
+
62
+ return scrollableView;
63
+ });
64
+ }
65
+
66
+ export function renderSnapshotWidget(
67
+ ctx: ExtensionCommandContext,
68
+ label: string,
69
+ snapshot: SubsessionSnapshot,
70
+ ) {
71
+ const activity = ACTIVITY_LABELS[Math.floor(Math.random() * ACTIVITY_LABELS.length)]!;
72
+ const snapshotText = formatSnapshotText(snapshot);
73
+
74
+ ctx.ui.setWidget(label, (tui, theme) => {
75
+ const widget = new Container() as Container & { dispose?: () => void };
76
+ const loader = new Loader(
77
+ tui,
78
+ (content) => theme.fg("accent", content),
79
+ (content) => theme.fg("muted", content),
80
+ `${label} (${activity}): ${snapshotText[0]}`,
81
+ );
82
+ if (snapshot.status !== "running") {
83
+ loader.setIndicator({ frames: ["•"] });
84
+ }
85
+
86
+ widget.addChild(loader);
87
+ for (const line of snapshotText.slice(1)) {
88
+ widget.addChild(new TruncatedText(` ${line}`, 1, 0));
89
+ }
90
+
91
+ widget.addChild(new Spacer(1));
92
+ widget.dispose = () => loader.stop();
93
+ return widget;
94
+ });
95
+ }
@@ -0,0 +1,11 @@
1
+ export type LoopAction =
2
+ | { kind: "forward" }
3
+ | { kind: "feedback"; feedback: string }
4
+ | { kind: "open" }
5
+ | { kind: "save" }
6
+ | { kind: "discard" };
7
+
8
+ export type CommandInput =
9
+ | { kind: "list" }
10
+ | { kind: "resume"; subsessionId: string }
11
+ | { kind: "prompt"; prompt: string };
@@ -0,0 +1,143 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { resolveServerConfig } from "./storage.js";
4
+ import { McpClientManager } from "./client.js";
5
+ import type { McpToolCallDetails } from "./types.js";
6
+ import { renderCallText } from "../utils.js";
7
+
8
+ export function createMcpCallTool(clientManager: McpClientManager) {
9
+ return defineTool({
10
+ name: "call_mcp_tool",
11
+ label: "MCP Call Tool",
12
+ description: "Call known tool on configured MCP server.",
13
+ promptSnippet: "Call known tool on configured MCP server.",
14
+ promptGuidelines: [
15
+ "Use call_mcp_tool only with known server and tool names.",
16
+ "Use call_mcp_tool instead of recreating capability already exposed by MCP.",
17
+ "Use call_mcp_tool arguments as schema-matching JSON.",
18
+ ],
19
+ parameters: Type.Object({
20
+ server: Type.String({ description: "Configured server name" }),
21
+ tool: Type.String({ description: "Remote tool name" }),
22
+ arguments: Type.Optional(
23
+ Type.Object({}, { additionalProperties: true, description: "JSON arguments" }),
24
+ ),
25
+ }),
26
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
27
+ if (signal?.aborted) {
28
+ throw new Error("call_mcp_tool was cancelled.");
29
+ }
30
+
31
+ const serverName = params.server.trim();
32
+ const toolName = params.tool.trim();
33
+ const serverConfig = await resolveServerConfig(ctx.cwd, serverName);
34
+
35
+ if (!serverConfig) {
36
+ throw new Error(`Unknown MCP server: ${serverName}. Configure it with /mcp.`);
37
+ }
38
+ if (serverConfig.enabled === false) {
39
+ throw new Error(`MCP server ${serverName} is disabled.`);
40
+ }
41
+
42
+ const toolsResult = await clientManager.listTools(serverConfig);
43
+ const remoteTool = toolsResult.tools.find((item) => item.name === toolName);
44
+
45
+ if (!remoteTool) {
46
+ const availableTools = toolsResult.tools
47
+ .map((item) => item.name)
48
+ .sort()
49
+ .join(", ");
50
+ throw new Error(
51
+ `MCP server ${serverName} does not expose ${toolName}.${availableTools ? ` Available tools: ${availableTools}.` : ""}`,
52
+ );
53
+ }
54
+
55
+ if (signal?.aborted) {
56
+ throw new Error("call_mcp_tool was cancelled.");
57
+ }
58
+
59
+ const result = await clientManager.callTool(serverConfig, {
60
+ name: toolName,
61
+ arguments: (params.arguments ?? {}) as Record<string, unknown>,
62
+ });
63
+ const text = formatCallToolResult(result);
64
+
65
+ return {
66
+ content: [{ type: "text", text }],
67
+ details: {
68
+ server: serverConfig.name,
69
+ transport: serverConfig.transport,
70
+ remoteTool: toolName,
71
+ } satisfies McpToolCallDetails,
72
+ isError: result.isError === true,
73
+ };
74
+ },
75
+ renderCall(args, theme, { isPartial }) {
76
+ return renderCallText(
77
+ `${theme.fg("toolTitle", "call_mcp_tool")} ${theme.fg("accent", `${args.server}:${args.tool}`)}`,
78
+ isPartial,
79
+ );
80
+ },
81
+ });
82
+ }
83
+
84
+ function formatCallToolResult(result: {
85
+ content?: Array<Record<string, unknown>>;
86
+ structuredContent?: Record<string, unknown>;
87
+ isError?: boolean;
88
+ toolResult?: unknown;
89
+ }): string {
90
+ if ("toolResult" in result) {
91
+ return JSON.stringify(result.toolResult, null, 2);
92
+ }
93
+
94
+ const chunks: string[] = [];
95
+ for (const item of result.content ?? []) {
96
+ if (item.type === "text" && typeof item.text === "string") {
97
+ chunks.push(item.text);
98
+ continue;
99
+ }
100
+
101
+ if (item.type === "image" && typeof item.mimeType === "string") {
102
+ chunks.push(`[image content: ${item.mimeType}]`);
103
+ continue;
104
+ }
105
+
106
+ if (item.type === "audio" && typeof item.mimeType === "string") {
107
+ chunks.push(`[audio content: ${item.mimeType}]`);
108
+ continue;
109
+ }
110
+
111
+ if (item.type === "resource" && item.resource && typeof item.resource === "object") {
112
+ const resource = item.resource as Record<string, unknown>;
113
+ if (typeof resource.uri === "string" && typeof resource.text === "string") {
114
+ chunks.push(`Resource ${resource.uri}\n${resource.text}`);
115
+ continue;
116
+ }
117
+ if (typeof resource.uri === "string") {
118
+ chunks.push(`[resource content: ${resource.uri}]`);
119
+ continue;
120
+ }
121
+ }
122
+
123
+ if (item.type === "resource_link" && typeof item.uri === "string") {
124
+ chunks.push(`[resource link: ${item.uri}]`);
125
+ continue;
126
+ }
127
+
128
+ chunks.push(JSON.stringify(item, null, 2));
129
+ }
130
+
131
+ if (result.structuredContent && Object.keys(result.structuredContent).length > 0) {
132
+ chunks.push(JSON.stringify(result.structuredContent, null, 2));
133
+ }
134
+
135
+ const body = chunks.filter(Boolean).join("\n\n").trim();
136
+ if (!body) {
137
+ return result.isError
138
+ ? "MCP tool returned an error with no content."
139
+ : "MCP tool returned no content.";
140
+ }
141
+
142
+ return result.isError ? `Remote MCP tool reported an error.\n\n${body}` : body;
143
+ }
@@ -0,0 +1,90 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";
5
+ import type { ResolvedMcpServer } from "./types.js";
6
+
7
+ interface ManagedConnection {
8
+ client: Client;
9
+ transport: StdioClientTransport | StreamableHTTPClientTransport;
10
+ configHash: string;
11
+ }
12
+
13
+ export class McpClientManager {
14
+ private readonly connections = new Map<string, ManagedConnection>();
15
+ private readonly CLIENT_INFO = {
16
+ name: "surgent-mcp-client",
17
+ version: "0.1.0",
18
+ };
19
+
20
+ async listTools(serverConfig: ResolvedMcpServer) {
21
+ const connection = await this.getConnection(serverConfig);
22
+ return connection.client.listTools();
23
+ }
24
+
25
+ async callTool(serverConfig: ResolvedMcpServer, params: CallToolRequest["params"]) {
26
+ const connection = await this.getConnection(serverConfig);
27
+ return connection.client.callTool(params);
28
+ }
29
+
30
+ async disposeAll() {
31
+ const activeConnections = Array.from(this.connections.values());
32
+ this.connections.clear();
33
+ await Promise.allSettled(
34
+ activeConnections.map((connection) => this.disposeConnection(connection)),
35
+ );
36
+ }
37
+
38
+ private async getConnection(serverConfig: ResolvedMcpServer): Promise<ManagedConnection> {
39
+ const cacheKey = serverConfig.name;
40
+ const configHash = JSON.stringify(serverConfig);
41
+ const existing = this.connections.get(cacheKey);
42
+
43
+ if (existing && existing.configHash === configHash) {
44
+ return existing;
45
+ }
46
+
47
+ if (existing) {
48
+ await this.disposeConnection(existing);
49
+ this.connections.delete(cacheKey);
50
+ }
51
+
52
+ const connection = await this.createConnection(serverConfig, configHash);
53
+ this.connections.set(cacheKey, connection);
54
+ return connection;
55
+ }
56
+
57
+ private async createConnection(
58
+ serverConfig: ResolvedMcpServer,
59
+ configHash: string,
60
+ ): Promise<ManagedConnection> {
61
+ const client = new Client(this.CLIENT_INFO);
62
+ const transport =
63
+ serverConfig.transport === "stdio"
64
+ ? new StdioClientTransport({
65
+ command: serverConfig.command,
66
+ args: serverConfig.args,
67
+ cwd: serverConfig.cwd,
68
+ env: serverConfig.env,
69
+ })
70
+ : new StreamableHTTPClientTransport(new URL(serverConfig.url), {
71
+ requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
72
+ });
73
+
74
+ await client.connect(transport);
75
+
76
+ return { client, transport, configHash };
77
+ }
78
+
79
+ private async disposeConnection(connection: ManagedConnection) {
80
+ if (connection.transport instanceof StreamableHTTPClientTransport) {
81
+ await Promise.allSettled([
82
+ connection.transport.terminateSession(),
83
+ connection.transport.close(),
84
+ ]);
85
+ return;
86
+ }
87
+
88
+ await connection.transport.close();
89
+ }
90
+ }