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,47 @@
1
+ export interface QuestionOption {
2
+ text: string;
3
+ description?: string;
4
+ exclusive?: boolean;
5
+ }
6
+
7
+ export interface Question {
8
+ prompt: string;
9
+ reason?: string;
10
+ options?: QuestionOption[];
11
+
12
+ placeholder: string;
13
+ multi?: boolean;
14
+ recommendedCount?: number;
15
+
16
+ minSelections?: number;
17
+ maxSelections?: number;
18
+ }
19
+
20
+ export interface QuestionnaireResult {
21
+ cancelled: boolean;
22
+ questions: string[];
23
+ answers: string[];
24
+ }
25
+
26
+ export interface NormalizedQuestion {
27
+ prompt: string;
28
+ reason?: string;
29
+ options: QuestionOption[];
30
+ placeholder: string;
31
+ multi: boolean;
32
+ recommendedCount?: number;
33
+ minSelections: number;
34
+ maxSelections: number;
35
+ }
36
+
37
+ export interface QuestionDraft {
38
+ text: string;
39
+ selectedIndexes: number[];
40
+ cursor: number;
41
+ editing: boolean;
42
+ }
43
+
44
+ export interface ToggleSelectionResult {
45
+ selectedIndexes: number[];
46
+ message?: string;
47
+ }
@@ -0,0 +1,34 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
3
+ import { containSecrets, replaceSecrets } from "./secrets.js";
4
+
5
+ export default function (pi: ExtensionAPI) {
6
+ pi.on("tool_call", async (event, _ctx) => {
7
+ if (isToolCallEventType("write", event)) {
8
+ if (containSecrets(event.input.content ?? "")) {
9
+ return { block: true, reason: "Secrets detected in content to be written" };
10
+ }
11
+ } else if (isToolCallEventType("edit", event)) {
12
+ const edits: Array<{ oldText?: string; newText?: string }> =
13
+ (event.input as { edits?: Array<{ oldText?: string; newText?: string }> }).edits ?? [];
14
+
15
+ for (const edit of edits) {
16
+ if (containSecrets(edit.newText ?? "")) {
17
+ return { block: true, reason: "Secrets detected in edit content" };
18
+ }
19
+ }
20
+ }
21
+ });
22
+
23
+ pi.on("tool_result", async (event, _ctx) => {
24
+ if (event.toolName !== "read" && event.toolName !== "bash" && event.toolName !== "grep") return;
25
+
26
+ const content = event.content.map((block) => {
27
+ if (block.type !== "text") return block;
28
+ const redactedText = replaceSecrets(block.text);
29
+ return { ...block, text: redactedText };
30
+ });
31
+
32
+ return { details: event.details, isError: event.isError, content };
33
+ });
34
+ }
@@ -0,0 +1,234 @@
1
+ interface SecretPattern {
2
+ name: string;
3
+ pattern: RegExp;
4
+ severe: boolean;
5
+ }
6
+
7
+ export const SECRET_PATTERNS: SecretPattern[] = [
8
+ // ── AWS ──
9
+ {
10
+ name: "AWS Access Key",
11
+ pattern: /(?<![A-Z0-9])(AKIA[0-9A-Z]{16})(?![A-Z0-9])/,
12
+ severe: true,
13
+ },
14
+ {
15
+ name: "AWS Secret Key",
16
+ pattern: /aws[_\-]?secret[_\-]?(?:access[_\-]?)?key["':\s=]+([A-Za-z0-9\/+]{40})/i,
17
+ severe: true,
18
+ },
19
+
20
+ // ── GCP ──
21
+ {
22
+ name: "GCP API Key",
23
+ pattern: /AIza[0-9A-Za-z\-_]{35}/,
24
+ severe: true,
25
+ },
26
+
27
+ // ── GitHub ──
28
+ {
29
+ name: "GitHub Token (classic)",
30
+ pattern: /ghp_[A-Za-z0-9]{36}/,
31
+ severe: true,
32
+ },
33
+ {
34
+ name: "GitHub Fine-grained Token",
35
+ pattern: /github_pat_[A-Za-z0-9_]{82}/,
36
+ severe: true,
37
+ },
38
+ {
39
+ name: "GitHub OAuth Token",
40
+ pattern: /gho_[A-Za-z0-9]{36}/,
41
+ severe: true,
42
+ },
43
+
44
+ // ── Stripe ──
45
+ {
46
+ name: "Stripe Secret Key",
47
+ pattern: /sk_live_[0-9a-zA-Z]{24,}/,
48
+ severe: true,
49
+ },
50
+ {
51
+ name: "Stripe Publishable Key",
52
+ pattern: /pk_live_[0-9a-zA-Z]{24,}/,
53
+ severe: true,
54
+ },
55
+ {
56
+ name: "Stripe Test Key",
57
+ pattern: /sk_test_[0-9a-zA-Z]{24,}/,
58
+ severe: false,
59
+ },
60
+
61
+ // ── Twilio ──
62
+ {
63
+ name: "Twilio Account SID",
64
+ pattern: /twilio.*?account[_\-]?sid["':\s=]+([AC][0-9a-fA-F]{32})/is,
65
+ severe: true,
66
+ },
67
+ {
68
+ name: "Twilio Auth Token",
69
+ pattern: /twilio.*?auth.*?token["':\s=]+([a-f0-9]{32})/i,
70
+ severe: true,
71
+ },
72
+
73
+ // ── SendGrid ──
74
+ {
75
+ name: "SendGrid API Key",
76
+ pattern: /SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}/,
77
+ severe: true,
78
+ },
79
+
80
+ // ── Mailgun ──
81
+ {
82
+ name: "Mailgun API Key",
83
+ pattern: /key-[0-9a-zA-Z]{32}/,
84
+ severe: true,
85
+ },
86
+
87
+ // ── Slack ──
88
+ {
89
+ name: "Slack Bot Token",
90
+ pattern: /xoxb-[0-9]{11,13}-[0-9]{11,13}-[a-zA-Z0-9]{24}/,
91
+ severe: true,
92
+ },
93
+ {
94
+ name: "Slack User Token",
95
+ pattern: /xoxp-[0-9]+-[0-9]+-[0-9]+-[a-f0-9]+/,
96
+ severe: true,
97
+ },
98
+ {
99
+ name: "Slack Webhook URL",
100
+ pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Z0-9]+\/[A-Z0-9]+\/[a-zA-Z0-9]+/,
101
+ severe: true,
102
+ },
103
+
104
+ // ── Firebase ──
105
+ {
106
+ name: "Firebase API Key",
107
+ pattern: /"apiKey"\s*:\s*"(AIza[0-9A-Za-z\-_]{35})"/,
108
+ severe: true,
109
+ },
110
+
111
+ // ── JWT ──
112
+ {
113
+ name: "JWT Token",
114
+ pattern: /eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}/,
115
+ severe: true,
116
+ },
117
+
118
+ // ── OAuth / Generic ──
119
+ {
120
+ name: "OAuth Client Secret",
121
+ pattern: /client[_\-]?secret["':\s=]+([A-Za-z0-9_\-]{20,80})/i,
122
+ severe: true,
123
+ },
124
+ {
125
+ name: "OAuth Client ID",
126
+ pattern: /client[_\-]?id["':\s=]+([A-Za-z0-9_\-]{15,60})/i,
127
+ severe: false,
128
+ },
129
+
130
+ // ── Authorization Headers ──
131
+ {
132
+ name: "Hardcoded Authorization Header",
133
+ pattern:
134
+ /["']Authorization["']\s*:\s*["'](?:Bearer|Basic|Token)\s+([A-Za-z0-9+\/=_.\-]{20,})["']/,
135
+ severe: true,
136
+ },
137
+ {
138
+ name: "Hardcoded X-API-Key Header",
139
+ pattern: /["']x-api-key["']\s*:\s*["']([A-Za-z0-9_\-]{20,80})["']/i,
140
+ severe: true,
141
+ },
142
+
143
+ // ── Private Keys ──
144
+ {
145
+ name: "RSA Private Key",
146
+ pattern: /-----BEGIN RSA PRIVATE KEY-----/,
147
+ severe: true,
148
+ },
149
+ {
150
+ name: "EC Private Key",
151
+ pattern: /-----BEGIN EC PRIVATE KEY-----/,
152
+ severe: true,
153
+ },
154
+ {
155
+ name: "Private Key (generic)",
156
+ pattern: /-----BEGIN PRIVATE KEY-----/,
157
+ severe: true,
158
+ },
159
+
160
+ // ── Crypto / Web3 ──
161
+ {
162
+ name: "Ethereum Private Key",
163
+ pattern: /(?:0x)?[0-9a-fA-F]{64}/,
164
+ severe: true,
165
+ },
166
+ {
167
+ name: "Mnemonic Phrase",
168
+ pattern: /(?:mnemonic|seed[_\s]?phrase)["']?\s*[:=]\s*["']([^"']{20,})["']/i,
169
+ severe: true,
170
+ },
171
+
172
+ // ── Database URIs ──
173
+ {
174
+ name: "MongoDB URI",
175
+ pattern: /mongodb(?:\+srv)?:\/\/[^\s"'<>]+/,
176
+ severe: true,
177
+ },
178
+ {
179
+ name: "PostgreSQL URI",
180
+ pattern: /postgres(?:ql)?:\/\/[^\s"'<>]+/i,
181
+ severe: true,
182
+ },
183
+ {
184
+ name: "MySQL URI",
185
+ pattern: /mysql:\/\/[^\s"'<>]+/,
186
+ severe: true,
187
+ },
188
+ {
189
+ name: "Redis URI",
190
+ pattern: /rediss?:\/\/[^\s"'<>]+/,
191
+ severe: true,
192
+ },
193
+
194
+ // ── Cloud Storage ──
195
+ {
196
+ name: "AWS S3 Bucket URL",
197
+ pattern: /https?:\/\/[a-z0-9.\-]+\.s3(?:[\-\.][a-z0-9\-]+)?\.amazonaws\.com/i,
198
+ severe: false,
199
+ },
200
+ {
201
+ name: "GCS Bucket URL",
202
+ pattern: /https:\/\/storage\.googleapis\.com\/[a-z0-9_.\-]+/,
203
+ severe: false,
204
+ },
205
+ {
206
+ name: "Azure Blob Storage URL",
207
+ pattern: /https:\/\/[a-z0-9]+\.blob\.core\.windows\.net\/[^\s"'<>]+/i,
208
+ severe: false,
209
+ },
210
+
211
+ // ── Internal URLs with credentials ──
212
+ {
213
+ name: "URL with Embedded Credentials",
214
+ pattern: /https?:\/\/[^:@\s"']+:[^@\s"']+@[^\s"'<>]+/,
215
+ severe: true,
216
+ },
217
+
218
+ // ── Generic secrets (with entropy check) ──
219
+ {
220
+ name: "Generic API Key",
221
+ pattern: /(?:api[_\-]?key|apikey)["']?\s*[:=]\s*["']([A-Za-z0-9_\-]{20,60})["']/i,
222
+ severe: false,
223
+ },
224
+ {
225
+ name: "Generic Secret",
226
+ pattern: /(?:secret|client_secret)["']?\s*[:=]\s*["']([A-Za-z0-9_\-+\/]{20,80})["']/i,
227
+ severe: false,
228
+ },
229
+ {
230
+ name: "Generic Password",
231
+ pattern: /(?:password|passwd|pwd)["']?\s*[:=]\s*["']([^"']{8,50})["']/i,
232
+ severe: false,
233
+ },
234
+ ];
@@ -0,0 +1,113 @@
1
+ import { SECRET_PATTERNS } from "./patterns.js";
2
+
3
+ const ENTROPY_THRESHOLDS = { hex: 3.2, b64: 4.2, any: 3.8 };
4
+ const ENTROPY_SECRET_PATTERN =
5
+ /(?:token|secret|key|password|credential|auth|api|private)[_\-]?[a-z]*["']?\s*[:=]\s*["']?([A-Za-z0-9+\/=\-_]{20,120})/gi;
6
+
7
+ function shannonEntropy(value: string): number {
8
+ const characterFrequencies = new Map<string, number>();
9
+ for (const character of value) {
10
+ characterFrequencies.set(character, (characterFrequencies.get(character) ?? 0) + 1);
11
+ }
12
+
13
+ let entropy = 0;
14
+ for (const count of characterFrequencies.values()) {
15
+ const probability = count / value.length;
16
+ entropy -= probability * Math.log2(probability);
17
+ }
18
+
19
+ return entropy;
20
+ }
21
+
22
+ function isHighEntropy(value: string, minimumLength = 20): boolean {
23
+ if (value.length < minimumLength) return false;
24
+ const entropy = shannonEntropy(value);
25
+ if (/^[0-9a-fA-F]+$/.test(value)) return entropy > ENTROPY_THRESHOLDS.hex;
26
+ if (/^[A-Za-z0-9+/=_\-]+$/.test(value)) return entropy > ENTROPY_THRESHOLDS.b64;
27
+ return entropy > ENTROPY_THRESHOLDS.any;
28
+ }
29
+
30
+ function isFalsePositive(value: string): boolean {
31
+ const normalized = value.toLowerCase().trim();
32
+ if (/^[a-z_\-]+$/.test(normalized)) return true;
33
+ if (/^[\d.]+$/.test(normalized)) return true;
34
+ if (new Set(normalized.replace(/[-_]/g, "")).size < 3) return true;
35
+ return false;
36
+ }
37
+
38
+ // Sub-milisecond overhead
39
+ export function containSecrets(input: string): boolean {
40
+ for (const { pattern, severe } of SECRET_PATTERNS) {
41
+ const match = pattern.exec(input);
42
+ if (!match) continue;
43
+ const value = match[1] ?? match[0];
44
+ if (value.length < 8 || isFalsePositive(value) || (!severe && !isHighEntropy(value))) continue;
45
+ return true;
46
+ }
47
+
48
+ let match: RegExpExecArray | null;
49
+ ENTROPY_SECRET_PATTERN.lastIndex = 0;
50
+
51
+ while ((match = ENTROPY_SECRET_PATTERN.exec(input)) !== null) {
52
+ const value = match[1] ?? "";
53
+ if (value && !isFalsePositive(value) && isHighEntropy(value)) return true;
54
+ }
55
+
56
+ return false;
57
+ }
58
+
59
+ export function replaceSecrets(input: string): string {
60
+ const replacements: Array<{ start: number; end: number }> = [];
61
+
62
+ for (const { pattern, severe } of SECRET_PATTERNS) {
63
+ const globalPattern = new RegExp(
64
+ pattern.source,
65
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
66
+ );
67
+ let match: RegExpExecArray | null;
68
+
69
+ while ((match = globalPattern.exec(input)) !== null) {
70
+ const value = match[1] ?? match[0];
71
+ if (value.length < 8 || isFalsePositive(value) || (!severe && !isHighEntropy(value)))
72
+ continue;
73
+
74
+ if (match[1]) {
75
+ const capturedValueOffset = match[0].indexOf(match[1]);
76
+ if (capturedValueOffset >= 0) {
77
+ replacements.push({
78
+ start: match.index + capturedValueOffset,
79
+ end: match.index + capturedValueOffset + match[1].length,
80
+ });
81
+ continue;
82
+ }
83
+ }
84
+
85
+ replacements.push({ start: match.index, end: match.index + match[0].length });
86
+ }
87
+ }
88
+
89
+ let entropyMatch: RegExpExecArray | null;
90
+ ENTROPY_SECRET_PATTERN.lastIndex = 0;
91
+ while ((entropyMatch = ENTROPY_SECRET_PATTERN.exec(input)) !== null) {
92
+ const value = entropyMatch[1] ?? "";
93
+ if (!value || isFalsePositive(value) || !isHighEntropy(value)) continue;
94
+
95
+ const capturedValueOffset = entropyMatch[0].indexOf(value);
96
+ if (capturedValueOffset < 0) continue;
97
+
98
+ replacements.push({
99
+ start: entropyMatch.index + capturedValueOffset,
100
+ end: entropyMatch.index + capturedValueOffset + value.length,
101
+ });
102
+ }
103
+
104
+ let redacted = input;
105
+ let nextAppliedStart = input.length + 1;
106
+ for (const replacement of replacements.sort((first, second) => second.start - first.start)) {
107
+ if (replacement.end > nextAppliedStart) continue;
108
+ redacted = `${redacted.slice(0, replacement.start)}(redacted texts)${redacted.slice(replacement.end)}`;
109
+ nextAppliedStart = replacement.start;
110
+ }
111
+
112
+ return redacted;
113
+ }
@@ -0,0 +1,93 @@
1
+ import type {
2
+ AgentSession,
3
+ ExtensionContext,
4
+ InlineExtension,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { SubsessionResult, SubsessionSnapshot } from "./types.js";
7
+ import type { AgentMeta } from "../agent/types.js";
8
+ import { createQuestionnaireTool } from "../questionnaire/index.js";
9
+ import { enforceToolPermission } from "../permission/index.js";
10
+ import { readAgentMode } from "../permission/storage.js";
11
+ import webFetchTool from "../web-tools/web-fetch/index.js";
12
+ import webSearchTool from "../web-tools/web-search/index.js";
13
+
14
+ const PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
15
+
16
+ export function createSubsessionBridge(
17
+ ctx: ExtensionContext,
18
+ agentMeta: AgentMeta,
19
+ sessionId: string,
20
+ ): InlineExtension {
21
+ return {
22
+ name: "subsession-bridge",
23
+ factory(pi) {
24
+ pi.registerTool(createQuestionnaireTool(ctx));
25
+ pi.registerTool(webFetchTool);
26
+ pi.registerTool(webSearchTool);
27
+
28
+ pi.on("tool_call", async (event) => {
29
+ const path = (event.input as { path?: unknown }).path;
30
+ if (PATH_TOOLS.has(event.toolName) && typeof path !== "string") {
31
+ return { block: true, reason: "Explicit path required in subsession" };
32
+ }
33
+
34
+ const agentMode = await readAgentMode();
35
+ return enforceToolPermission(pi, event, ctx, agentMeta, sessionId, agentMode);
36
+ });
37
+ },
38
+ };
39
+ }
40
+
41
+ function formatUsageCount(value: number): string {
42
+ if (value >= 10000) {
43
+ return `${Math.trunc(value / 1000)}k`;
44
+ }
45
+ return `${(value / 1000).toFixed(1)}k`;
46
+ }
47
+
48
+ export function formatSnapshotText(snapshot: SubsessionSnapshot): string[] {
49
+ const context = snapshot.contextUsage?.percent;
50
+ const recentToolCalls = snapshot.toolsUsed.slice(-5);
51
+ const lines = [
52
+ `tools_used=${snapshot.usage.toolCalls} | in=${formatUsageCount(snapshot.usage.input)} | out=${formatUsageCount(snapshot.usage.output)} | cost=$${snapshot.usage.cost.toFixed(3)} | ctx=${context === null || context === undefined ? "n/a" : `${context.toFixed(1)}%`}`,
53
+ ];
54
+
55
+ for (let toolCallIndex = 0; toolCallIndex < recentToolCalls.length; toolCallIndex += 1) {
56
+ const branchIndicator = toolCallIndex === recentToolCalls.length - 1 ? "└─" : "├─";
57
+ lines.push(`${branchIndicator} ${recentToolCalls[toolCallIndex]}`);
58
+ }
59
+
60
+ return lines;
61
+ }
62
+
63
+ export function createErrorResult(message: string): SubsessionResult {
64
+ return {
65
+ status: "error",
66
+ output: message,
67
+ usage: { input: 0, output: 0, toolCalls: 0, cost: 0 },
68
+ toolCounts: {},
69
+ };
70
+ }
71
+
72
+ export function formatToolUse(name: string, argumentsValue: unknown): string {
73
+ if (!argumentsValue || typeof argumentsValue !== "object") {
74
+ return `${name}()`;
75
+ }
76
+ try {
77
+ return `${name}(${JSON.stringify(argumentsValue)})`;
78
+ } catch {
79
+ return `${name}(<args>)`;
80
+ }
81
+ }
82
+
83
+ export function getLastAssistantOutput(session: AgentSession): string {
84
+ for (const message of [...session.messages].reverse()) {
85
+ if (message.role !== "assistant") continue;
86
+ for (const contentPart of [...message.content].reverse()) {
87
+ if (contentPart.type === "text") {
88
+ return contentPart.text;
89
+ }
90
+ }
91
+ }
92
+ return "";
93
+ }
@@ -0,0 +1,81 @@
1
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Text, TruncatedText } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { openSubsession } from "./subsession.js";
5
+ import { formatSnapshotText } from "./helpers.js";
6
+ import type { SubsessionRequest, SubsessionSnapshot } from "./types.js";
7
+ import { renderResultText } from "../utils.js";
8
+
9
+ export default function (pi: ExtensionAPI) {
10
+ pi.registerTool({
11
+ name: "subagent",
12
+ label: "Subagent",
13
+ description:
14
+ "Delegate bounded, self-contained work to a configured agent. Choose by agent description, provide complete context and expected output, batch independent calls, and do not duplicate delegated work.",
15
+ promptSnippet: "Offload bounded, context-heavy work to configured agents",
16
+ parameters: Type.Object({
17
+ agent: Type.String({ description: "Configured agent profile selected by its description" }),
18
+ task: Type.String({
19
+ description:
20
+ "Standalone task with outcome, scope, known context, constraints, expected output, and done condition",
21
+ }),
22
+ }),
23
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
24
+ let snapshot: SubsessionSnapshot | undefined;
25
+ const request: SubsessionRequest = {
26
+ ctx,
27
+ label: "subagent",
28
+ agent: params.agent,
29
+ signal,
30
+ onSnapshot: (nextSnapshot) => {
31
+ snapshot = nextSnapshot;
32
+ onUpdate?.({ content: [], details: nextSnapshot });
33
+ },
34
+ };
35
+
36
+ const subsession = await openSubsession(request);
37
+ try {
38
+ if (subsession.result.status !== "error") {
39
+ await subsession.exec(params.task, signal);
40
+ }
41
+ return {
42
+ content: [{ type: "text", text: subsession.result.output || subsession.result.status }],
43
+ details: snapshot ?? { status: subsession.result.status, usage: subsession.result.usage },
44
+ };
45
+ } finally {
46
+ await subsession.dispose();
47
+ }
48
+ },
49
+ renderCall(args, theme) {
50
+ return new Text(
51
+ `${theme.fg("toolTitle", "subagent")} ${theme.fg("accent", `"${args.task}"`)}\n`,
52
+ 0,
53
+ 0,
54
+ );
55
+ },
56
+ renderResult(result, { expanded, isPartial }, theme, context) {
57
+ if (!isPartial) {
58
+ const output = result.content[0];
59
+ const text = output?.type === "text" ? output.text : "";
60
+ return renderResultText(text, theme, expanded);
61
+ }
62
+
63
+ const snapshot = result.details as SubsessionSnapshot | undefined;
64
+ if (!snapshot?.toolsUsed) {
65
+ return new Text(theme.fg("toolOutput", `Subagent ${context.args.agent}: starting`), 0, 0);
66
+ }
67
+
68
+ const lines = [
69
+ theme.bold(`${context.args.agent}: ${snapshot.status}`),
70
+ ...formatSnapshotText(snapshot).map((line) => ` ${line}`),
71
+ ];
72
+
73
+ const output = new Container();
74
+ for (const line of lines) {
75
+ output.addChild(new TruncatedText(theme.fg("toolOutput", line), 1, 0));
76
+ }
77
+
78
+ return output;
79
+ },
80
+ });
81
+ }
@@ -0,0 +1,100 @@
1
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { isBuiltIn, loadAgents } from "../agent/storage.js";
3
+ import { getPiPath, readJson, writeJson } from "../utils.js";
4
+ import type { StoredSubsessions, RuntimeConfig, Subsession } from "./types.js";
5
+ import { unlink } from "node:fs/promises";
6
+
7
+ const MARKDOWN_HEADING_PATTERN = /^\s*#\s+(.+?)\s*$/m;
8
+
9
+ function extractSubsessionTitle(output: string): string | undefined {
10
+ const headingMatch = output.match(MARKDOWN_HEADING_PATTERN);
11
+ if (!headingMatch) return;
12
+
13
+ const headingText = headingMatch[1]?.trim();
14
+ if (!headingText) return;
15
+
16
+ const separatorIdx = headingText.indexOf(":");
17
+ const title = separatorIdx >= 0 ? headingText.slice(separatorIdx + 1).trim() : headingText;
18
+ if (!title) return;
19
+
20
+ return title;
21
+ }
22
+
23
+ export async function findSubsessionFile(cwd: string, id: string) {
24
+ const dir = getPiPath("subsessionsDir", cwd);
25
+ const sessions = await SessionManager.list(cwd, dir);
26
+ const session = sessions.find((item) => item.id === id);
27
+ if (session) return { path: session.path, dir };
28
+ }
29
+
30
+ async function loadStore(cwd: string) {
31
+ return readJson<StoredSubsessions>(getPiPath("subsessions", cwd), {});
32
+ }
33
+
34
+ export async function findSubsession(cwd: string, id?: string, pid?: string) {
35
+ if (!id) return null;
36
+ const subsessions = await loadStore(cwd);
37
+ const found = subsessions[id];
38
+ if (!found || (pid && found.pid !== pid)) return null;
39
+ return found;
40
+ }
41
+
42
+ export async function saveSubsession(cwd: string, subsession: Subsession) {
43
+ if (!subsession.result.id || subsession.label === "subagent") return;
44
+ if (subsession.result.status === "done") {
45
+ subsession.title = extractSubsessionTitle(subsession.result.output) ?? "Untitled";
46
+ }
47
+
48
+ const subsessions = await loadStore(cwd);
49
+ subsessions[subsession.result.id] = {
50
+ agent: subsession.runtime.agent,
51
+ label: subsession.label,
52
+ pid: subsession.pid,
53
+ title: subsession.title,
54
+ usage: subsession.result.usage,
55
+ };
56
+ await writeJson(getPiPath("subsessions", cwd), subsessions);
57
+ }
58
+
59
+ export async function loadSubsessionOutput(cwd: string, id: string): Promise<string> {
60
+ try {
61
+ const file = await findSubsessionFile(cwd, id);
62
+ if (!file) return "";
63
+
64
+ const sessionManager = SessionManager.open(file.path, file.dir, cwd);
65
+ const branchEntries = sessionManager.getBranch();
66
+
67
+ for (let entryIndex = branchEntries.length - 1; entryIndex >= 0; entryIndex -= 1) {
68
+ const branchEntry = branchEntries[entryIndex];
69
+ if (!branchEntry || branchEntry.type !== "message") continue;
70
+
71
+ const message = branchEntry.message;
72
+ if (message.role !== "assistant") continue;
73
+
74
+ for (let idx = message.content.length - 1; idx >= 0; idx -= 1) {
75
+ const contentPart = message.content[idx] as { type?: unknown; text?: unknown };
76
+ if (contentPart.type === "text" && typeof contentPart.text === "string") {
77
+ return contentPart.text;
78
+ }
79
+ }
80
+ }
81
+ return "";
82
+ } catch {
83
+ return "";
84
+ }
85
+ }
86
+
87
+ export async function terminateSubsession(cwd: string, id: string) {
88
+ const subsessions = await loadStore(cwd);
89
+ delete subsessions[id];
90
+ await writeJson(getPiPath("subsessions", cwd), subsessions);
91
+
92
+ const file = await findSubsessionFile(cwd, id);
93
+ if (!file) return;
94
+ await unlink(file.path);
95
+ }
96
+
97
+ export async function resolveRuntime(cwd: string, agent: string): Promise<RuntimeConfig> {
98
+ const [cfg] = await loadAgents(cwd, agent);
99
+ return { agent, builtIn: isBuiltIn(cfg.filePath), meta: cfg.meta, systemPrompt: cfg.body };
100
+ }