@agentuity/coder 1.0.37
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/README.md +57 -0
- package/dist/chain-preview.d.ts +55 -0
- package/dist/chain-preview.d.ts.map +1 -0
- package/dist/chain-preview.js +472 -0
- package/dist/chain-preview.js.map +1 -0
- package/dist/client.d.ts +43 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +402 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +22 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +99 -0
- package/dist/commands.js.map +1 -0
- package/dist/footer.d.ts +34 -0
- package/dist/footer.d.ts.map +1 -0
- package/dist/footer.js +249 -0
- package/dist/footer.js.map +1 -0
- package/dist/handlers.d.ts +24 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +83 -0
- package/dist/handlers.js.map +1 -0
- package/dist/hub-overlay.d.ts +107 -0
- package/dist/hub-overlay.d.ts.map +1 -0
- package/dist/hub-overlay.js +1794 -0
- package/dist/hub-overlay.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1585 -0
- package/dist/index.js.map +1 -0
- package/dist/output-viewer.d.ts +49 -0
- package/dist/output-viewer.d.ts.map +1 -0
- package/dist/output-viewer.js +389 -0
- package/dist/output-viewer.js.map +1 -0
- package/dist/overlay.d.ts +40 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +225 -0
- package/dist/overlay.js.map +1 -0
- package/dist/protocol.d.ts +118 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +3 -0
- package/dist/protocol.js.map +1 -0
- package/dist/remote-session.d.ts +113 -0
- package/dist/remote-session.d.ts.map +1 -0
- package/dist/remote-session.js +645 -0
- package/dist/remote-session.js.map +1 -0
- package/dist/remote-tui.d.ts +40 -0
- package/dist/remote-tui.d.ts.map +1 -0
- package/dist/remote-tui.js +606 -0
- package/dist/remote-tui.js.map +1 -0
- package/dist/renderers.d.ts +34 -0
- package/dist/renderers.d.ts.map +1 -0
- package/dist/renderers.js +669 -0
- package/dist/renderers.js.map +1 -0
- package/dist/review.d.ts +15 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +154 -0
- package/dist/review.js.map +1 -0
- package/dist/titlebar.d.ts +3 -0
- package/dist/titlebar.d.ts.map +1 -0
- package/dist/titlebar.js +59 -0
- package/dist/titlebar.js.map +1 -0
- package/dist/todo/index.d.ts +3 -0
- package/dist/todo/index.d.ts.map +1 -0
- package/dist/todo/index.js +3 -0
- package/dist/todo/index.js.map +1 -0
- package/dist/todo/store.d.ts +6 -0
- package/dist/todo/store.d.ts.map +1 -0
- package/dist/todo/store.js +43 -0
- package/dist/todo/store.js.map +1 -0
- package/dist/todo/types.d.ts +13 -0
- package/dist/todo/types.d.ts.map +1 -0
- package/dist/todo/types.js +2 -0
- package/dist/todo/types.js.map +1 -0
- package/package.json +44 -0
- package/src/chain-preview.ts +621 -0
- package/src/client.ts +515 -0
- package/src/commands.ts +132 -0
- package/src/footer.ts +305 -0
- package/src/handlers.ts +113 -0
- package/src/hub-overlay.ts +2324 -0
- package/src/index.ts +1907 -0
- package/src/output-viewer.ts +480 -0
- package/src/overlay.ts +294 -0
- package/src/protocol.ts +157 -0
- package/src/remote-session.ts +800 -0
- package/src/remote-tui.ts +707 -0
- package/src/renderers.ts +740 -0
- package/src/review.ts +201 -0
- package/src/titlebar.ts +63 -0
- package/src/todo/index.ts +2 -0
- package/src/todo/store.ts +49 -0
- package/src/todo/types.ts +14 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1585 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { Type } from '@sinclair/typebox';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { HubClient } from "./client.js";
|
|
12
|
+
import { processActions } from "./handlers.js";
|
|
13
|
+
import { getToolRenderers } from "./renderers.js";
|
|
14
|
+
import { setupCoderFooter } from "./footer.js";
|
|
15
|
+
import { setupTitlebar } from "./titlebar.js";
|
|
16
|
+
import { registerAgentCommands } from "./commands.js";
|
|
17
|
+
import { AgentManagerOverlay } from "./overlay.js";
|
|
18
|
+
import { ChainEditorOverlay } from "./chain-preview.js";
|
|
19
|
+
import { HubOverlay } from "./hub-overlay.js";
|
|
20
|
+
import { OutputViewerOverlay } from "./output-viewer.js";
|
|
21
|
+
import { setupRemoteMode, } from "./remote-session.js";
|
|
22
|
+
// ESM doesn't have require() — create one for synchronous child_process access
|
|
23
|
+
const _require = createRequire(import.meta.url);
|
|
24
|
+
const HUB_URL_ENV = 'AGENTUITY_CODER_HUB_URL';
|
|
25
|
+
const AGENT_ENV = 'AGENTUITY_CODER_AGENT';
|
|
26
|
+
const REMOTE_SESSION_ENV = 'AGENTUITY_CODER_REMOTE_SESSION';
|
|
27
|
+
const NATIVE_REMOTE_ENV = 'AGENTUITY_CODER_NATIVE_REMOTE';
|
|
28
|
+
// TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
|
|
29
|
+
const API_KEY_ENV = 'AGENTUITY_CODER_API_KEY';
|
|
30
|
+
const API_KEY_HEADER = 'x-agentuity-auth-api-key';
|
|
31
|
+
const RECONNECT_WAIT_TIMEOUT_MS = 120_000;
|
|
32
|
+
// Recent agent results for full-screen viewer (Ctrl+Shift+V / Alt+Shift+V)
|
|
33
|
+
const recentResults = [];
|
|
34
|
+
const MAX_STORED_RESULTS = 20;
|
|
35
|
+
function startStreamingResult(agentName, description, prompt) {
|
|
36
|
+
const result = {
|
|
37
|
+
agentName,
|
|
38
|
+
text: '',
|
|
39
|
+
thinking: '',
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
isStreaming: true,
|
|
42
|
+
description,
|
|
43
|
+
prompt,
|
|
44
|
+
};
|
|
45
|
+
recentResults.unshift(result);
|
|
46
|
+
if (recentResults.length > MAX_STORED_RESULTS)
|
|
47
|
+
recentResults.pop();
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
// ══════════════════════════════════════════════
|
|
51
|
+
// Sub-Agent Output Limits (prevents context bloat in parent)
|
|
52
|
+
// Inspired by pi-subagents (200KB/5K lines) and oh-my-pi (500KB/5K lines)
|
|
53
|
+
// ══════════════════════════════════════════════
|
|
54
|
+
const MAX_OUTPUT_BYTES = 200_000;
|
|
55
|
+
const MAX_OUTPUT_LINES = 5_000;
|
|
56
|
+
// All Pi events we subscribe to
|
|
57
|
+
const PROXY_EVENTS = [
|
|
58
|
+
'session_shutdown',
|
|
59
|
+
'session_before_switch',
|
|
60
|
+
'session_switch',
|
|
61
|
+
'session_before_fork',
|
|
62
|
+
'session_fork',
|
|
63
|
+
'session_before_compact',
|
|
64
|
+
'session_compact',
|
|
65
|
+
'before_agent_start',
|
|
66
|
+
'agent_start',
|
|
67
|
+
'agent_end',
|
|
68
|
+
'turn_start',
|
|
69
|
+
'turn_end',
|
|
70
|
+
'tool_call',
|
|
71
|
+
'tool_result',
|
|
72
|
+
'tool_execution_start',
|
|
73
|
+
'tool_execution_update',
|
|
74
|
+
'tool_execution_end',
|
|
75
|
+
'message_start',
|
|
76
|
+
'message_update',
|
|
77
|
+
'message_end',
|
|
78
|
+
'input',
|
|
79
|
+
'model_select',
|
|
80
|
+
'context',
|
|
81
|
+
];
|
|
82
|
+
const DEBUG = !!process.env['AGENTUITY_DEBUG'];
|
|
83
|
+
function log(msg) {
|
|
84
|
+
if (DEBUG)
|
|
85
|
+
console.error(`[agentuity-coder] ${msg}`);
|
|
86
|
+
}
|
|
87
|
+
/** Build headers object with API key if available. Merges with any existing headers. */
|
|
88
|
+
// TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
|
|
89
|
+
function authHeaders(extra) {
|
|
90
|
+
const apiKey = process.env[API_KEY_ENV];
|
|
91
|
+
const headers = { ...extra };
|
|
92
|
+
if (apiKey)
|
|
93
|
+
headers[API_KEY_HEADER] = apiKey;
|
|
94
|
+
return headers;
|
|
95
|
+
}
|
|
96
|
+
// ══════════════════════════════════════════════
|
|
97
|
+
// Synchronous Bootstrap — fetch InitMessage from Hub REST endpoint
|
|
98
|
+
// This runs BEFORE tool registration so we know what tools/agents
|
|
99
|
+
// the server actually provides. No hardcoded schemas.
|
|
100
|
+
// ══════════════════════════════════════════════
|
|
101
|
+
function buildInitUrl(hubUrl, agentRole) {
|
|
102
|
+
let httpUrl = hubUrl.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
|
|
103
|
+
if (httpUrl.includes('/api/ws')) {
|
|
104
|
+
httpUrl = httpUrl.replace('/api/ws', '/api/hub/tui/init');
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
httpUrl = httpUrl.replace(/\/?$/, '/api/hub/tui/init');
|
|
108
|
+
}
|
|
109
|
+
if (agentRole && agentRole !== 'lead') {
|
|
110
|
+
httpUrl += `?agent=${encodeURIComponent(agentRole)}`;
|
|
111
|
+
}
|
|
112
|
+
return httpUrl;
|
|
113
|
+
}
|
|
114
|
+
function getHubHttpBaseUrl(hubUrl) {
|
|
115
|
+
let httpUrl = hubUrl.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
|
|
116
|
+
httpUrl = httpUrl.replace(/\/api\/ws\b.*$/, '');
|
|
117
|
+
return httpUrl.replace(/\/+$/, '');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Synchronously fetch the InitMessage from Hub's REST endpoint.
|
|
121
|
+
*
|
|
122
|
+
* Uses `curl` via `execFileSync` because Pi's extension registration is synchronous —
|
|
123
|
+
* we need tools/agents BEFORE the extension returns. Node's `fetch()` is async-only,
|
|
124
|
+
* and `Bun.spawnSync` isn't available in Pi's Node.js runtime.
|
|
125
|
+
*
|
|
126
|
+
* Requires `curl` binary (available on macOS, Linux, Windows 10+).
|
|
127
|
+
*/
|
|
128
|
+
function fetchInitMessageSync(hubUrl, agentRole) {
|
|
129
|
+
const httpUrl = buildInitUrl(hubUrl, agentRole);
|
|
130
|
+
try {
|
|
131
|
+
const { execFileSync } = _require('node:child_process');
|
|
132
|
+
const apiKey = process.env[API_KEY_ENV];
|
|
133
|
+
const curlArgs = ['-s', '--connect-timeout', '3', '--max-time', '5'];
|
|
134
|
+
// TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
|
|
135
|
+
if (apiKey)
|
|
136
|
+
curlArgs.push('-H', `${API_KEY_HEADER}: ${apiKey}`);
|
|
137
|
+
curlArgs.push(httpUrl);
|
|
138
|
+
const result = execFileSync('curl', curlArgs, { encoding: 'utf-8' });
|
|
139
|
+
const parsed = JSON.parse(result);
|
|
140
|
+
if (parsed && parsed.type === 'init') {
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Fetch session snapshot from Hub REST endpoint.
|
|
151
|
+
* Extracts observer count and session label for the footer display.
|
|
152
|
+
* Best-effort, non-blocking — failures are silently ignored.
|
|
153
|
+
*/
|
|
154
|
+
async function fetchSessionSnapshot(hubUrl, sessionId, observerState) {
|
|
155
|
+
const baseUrl = getHubHttpBaseUrl(hubUrl);
|
|
156
|
+
const httpUrl = sessionId
|
|
157
|
+
? `${baseUrl}/api/hub/session/${encodeURIComponent(sessionId)}`
|
|
158
|
+
: `${baseUrl}/api/hub/sessions`;
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
161
|
+
try {
|
|
162
|
+
const response = await fetch(httpUrl, {
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
headers: authHeaders({ accept: 'application/json' }),
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok)
|
|
167
|
+
return;
|
|
168
|
+
if (sessionId) {
|
|
169
|
+
const snapshot = (await response.json());
|
|
170
|
+
if (observerState) {
|
|
171
|
+
if (snapshot.label)
|
|
172
|
+
observerState.label = snapshot.label;
|
|
173
|
+
if (Array.isArray(snapshot.participants)) {
|
|
174
|
+
observerState.count = snapshot.participants.filter((p) => p.role === 'observer').length;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const data = (await response.json());
|
|
180
|
+
const first = data.sessions?.websocket?.[0];
|
|
181
|
+
if (first && observerState) {
|
|
182
|
+
if (first.label)
|
|
183
|
+
observerState.label = first.label;
|
|
184
|
+
if (typeof first.observerCount === 'number')
|
|
185
|
+
observerState.count = first.observerCount;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Ignore — best effort
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
clearTimeout(timeout);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function fetchInitMessage(hubUrl, agentRole) {
|
|
196
|
+
const httpUrl = buildInitUrl(hubUrl, agentRole);
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
199
|
+
try {
|
|
200
|
+
const response = await fetch(httpUrl, {
|
|
201
|
+
signal: controller.signal,
|
|
202
|
+
headers: authHeaders({ accept: 'application/json' }),
|
|
203
|
+
});
|
|
204
|
+
if (!response.ok)
|
|
205
|
+
return null;
|
|
206
|
+
const parsed = (await response.json());
|
|
207
|
+
if (parsed.type === 'init') {
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
clearTimeout(timeout);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export function agentuityCoderHub(pi) {
|
|
220
|
+
const hubUrl = process.env[HUB_URL_ENV];
|
|
221
|
+
if (!hubUrl)
|
|
222
|
+
return;
|
|
223
|
+
// ── Remote mode detection ──
|
|
224
|
+
// If AGENTUITY_CODER_REMOTE_SESSION is set, the TUI connects as a controller
|
|
225
|
+
// to an existing sandbox session. The full UI is set up (tools, commands, /hub)
|
|
226
|
+
// but user input is relayed to the remote sandbox instead of the local Pi agent.
|
|
227
|
+
const remoteSessionId = process.env[REMOTE_SESSION_ENV] || null;
|
|
228
|
+
if (remoteSessionId) {
|
|
229
|
+
log(`Remote mode: will connect as controller to session ${remoteSessionId}`);
|
|
230
|
+
}
|
|
231
|
+
const isSubAgent = !!process.env[AGENT_ENV];
|
|
232
|
+
const agentRole = process.env[AGENT_ENV] || 'lead';
|
|
233
|
+
log(`Hub URL: ${hubUrl} (role: ${agentRole})`);
|
|
234
|
+
// ══════════════════════════════════════════════
|
|
235
|
+
// Fetch InitMessage from Hub REST endpoint (synchronous)
|
|
236
|
+
// This is how we discover what tools/agents the server provides.
|
|
237
|
+
// ══════════════════════════════════════════════
|
|
238
|
+
const initMsg = fetchInitMessageSync(hubUrl, agentRole);
|
|
239
|
+
if (!initMsg) {
|
|
240
|
+
log('Hub not reachable — no tools or agents registered');
|
|
241
|
+
log('Make sure the Hub server is running');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const serverTools = initMsg.tools || [];
|
|
245
|
+
const serverAgents = initMsg.agents || [];
|
|
246
|
+
let hubConfig = initMsg.config;
|
|
247
|
+
const openChainEditor = async (ctx, initialAgents = []) => {
|
|
248
|
+
if (!ctx.hasUI)
|
|
249
|
+
return;
|
|
250
|
+
const result = await ctx.ui.custom((_tui, theme, _keybindings, done) => new ChainEditorOverlay(theme, serverAgents, done, initialAgents), {
|
|
251
|
+
overlay: true,
|
|
252
|
+
overlayOptions: { width: '95%', maxHeight: '95%', anchor: 'center', margin: 1 },
|
|
253
|
+
});
|
|
254
|
+
if (!result || result.steps.length === 0)
|
|
255
|
+
return;
|
|
256
|
+
const instructions = result.steps
|
|
257
|
+
.map((step, index) => `${index + 1}) @${step.agent}: ${step.task || '(no task provided)'}`)
|
|
258
|
+
.join(', ');
|
|
259
|
+
const message = result.mode === 'parallel'
|
|
260
|
+
? `@lead Execute these tasks in parallel: ${instructions}`
|
|
261
|
+
: `@lead Execute this plan in order: ${instructions}`;
|
|
262
|
+
pi.sendUserMessage(message, { deliverAs: 'followUp' });
|
|
263
|
+
};
|
|
264
|
+
const openAgentManager = async (ctx) => {
|
|
265
|
+
if (!ctx.hasUI)
|
|
266
|
+
return;
|
|
267
|
+
const result = await ctx.ui.custom((_tui, theme, _keybindings, done) => new AgentManagerOverlay(theme, serverAgents, done), {
|
|
268
|
+
overlay: true,
|
|
269
|
+
overlayOptions: { width: '95%', maxHeight: '95%', anchor: 'center', margin: 1 },
|
|
270
|
+
});
|
|
271
|
+
// TODO: chain action from Agent Manager overlay (multi-select + Ctrl+R) not yet implemented
|
|
272
|
+
if (result?.action === 'chain' && Array.isArray(result.agents)) {
|
|
273
|
+
await openChainEditor(ctx, result.agents);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (result?.action === 'run' && result.agent) {
|
|
277
|
+
const task = await ctx.ui.input(`Task for ${result.agent}`, 'What should this agent do?');
|
|
278
|
+
const trimmed = task?.trim();
|
|
279
|
+
if (trimmed) {
|
|
280
|
+
pi.sendUserMessage(`@${result.agent} ${trimmed}`, { deliverAs: 'followUp' });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const openHubOverlay = async (ctx, activeSessionId, detailSessionId) => {
|
|
285
|
+
if (!ctx.hasUI)
|
|
286
|
+
return;
|
|
287
|
+
if (hubOverlayOpen)
|
|
288
|
+
return;
|
|
289
|
+
hubOverlayOpen = true;
|
|
290
|
+
try {
|
|
291
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) => new HubOverlay(tui, theme, {
|
|
292
|
+
baseUrl: getHubHttpBaseUrl(hubUrl),
|
|
293
|
+
currentSessionId: activeSessionId ?? undefined,
|
|
294
|
+
initialSessionId: detailSessionId ?? undefined,
|
|
295
|
+
startInDetail: !!detailSessionId,
|
|
296
|
+
done,
|
|
297
|
+
}), {
|
|
298
|
+
overlay: true,
|
|
299
|
+
overlayOptions: { width: '95%', maxHeight: '95%', anchor: 'center', margin: 1 },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
hubOverlayOpen = false;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const buildActionContext = (ctx) => ({
|
|
307
|
+
ui: ctx.hasUI ? ctx.ui : undefined,
|
|
308
|
+
sendUserMessage: (message, options) => {
|
|
309
|
+
pi.sendUserMessage(message, { deliverAs: options?.deliverAs ?? 'followUp' });
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
log(`Hub connected. Tools: ${serverTools.length}, Agents: ${serverAgents.length}`);
|
|
313
|
+
// Titlebar: branding + spinner (registers its own event handlers)
|
|
314
|
+
setupTitlebar(pi);
|
|
315
|
+
// ══════════════════════════════════════════════
|
|
316
|
+
// WebSocket client for runtime communication (tool execution + events)
|
|
317
|
+
// ══════════════════════════════════════════════
|
|
318
|
+
const client = new HubClient();
|
|
319
|
+
// TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
|
|
320
|
+
client.apiKey = process.env[API_KEY_ENV] || null;
|
|
321
|
+
let cachedInitMessage = initMsg;
|
|
322
|
+
let currentSessionId = initMsg.sessionId ?? null;
|
|
323
|
+
let systemPromptApplied = false;
|
|
324
|
+
let connectPromise = null;
|
|
325
|
+
// In native remote mode, remote-tui.ts owns the Hub connection — show as connected
|
|
326
|
+
let hubUiStatus = process.env[NATIVE_REMOTE_ENV] ? 'connected' : 'offline';
|
|
327
|
+
let footerCtx = null;
|
|
328
|
+
let hubOverlayOpen = false;
|
|
329
|
+
// Observer awareness state — tracks who's watching this session.
|
|
330
|
+
// Updated via broadcast events from the Hub (session_join, session_leave).
|
|
331
|
+
const observerState = { count: 0, label: '' };
|
|
332
|
+
const observerParticipantIds = new Set();
|
|
333
|
+
function getHubUiStatus() {
|
|
334
|
+
return hubUiStatus;
|
|
335
|
+
}
|
|
336
|
+
function getObserverState() {
|
|
337
|
+
return observerState;
|
|
338
|
+
}
|
|
339
|
+
function mapConnectionStateToUiStatus(state) {
|
|
340
|
+
if (state === 'connected')
|
|
341
|
+
return 'connected';
|
|
342
|
+
if (state === 'reconnecting')
|
|
343
|
+
return 'reconnecting';
|
|
344
|
+
return 'offline';
|
|
345
|
+
}
|
|
346
|
+
function updateHubUiStatus(state) {
|
|
347
|
+
hubUiStatus = mapConnectionStateToUiStatus(state);
|
|
348
|
+
if (footerCtx?.hasUI) {
|
|
349
|
+
footerCtx.ui.setStatus('hub_connection', hubUiStatus);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function applyInitMessage(nextInit) {
|
|
353
|
+
cachedInitMessage = nextInit;
|
|
354
|
+
if (nextInit.sessionId)
|
|
355
|
+
currentSessionId = nextInit.sessionId;
|
|
356
|
+
if (nextInit.config)
|
|
357
|
+
hubConfig = nextInit.config;
|
|
358
|
+
}
|
|
359
|
+
client.onInitMessage = (nextInit) => {
|
|
360
|
+
applyInitMessage(nextInit);
|
|
361
|
+
};
|
|
362
|
+
client.onConnectionStateChange = (state) => {
|
|
363
|
+
updateHubUiStatus(state);
|
|
364
|
+
log(`Hub connection state: ${state}`);
|
|
365
|
+
};
|
|
366
|
+
client.onBeforeReconnect = async () => {
|
|
367
|
+
const refreshedInit = await fetchInitMessage(hubUrl, agentRole);
|
|
368
|
+
if (refreshedInit) {
|
|
369
|
+
applyInitMessage(refreshedInit);
|
|
370
|
+
log('Refreshed Hub init payload before reconnect');
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
// Handle unsolicited server messages (broadcast, presence)
|
|
374
|
+
// Updates observer state for footer display
|
|
375
|
+
client.onServerMessage = (message) => {
|
|
376
|
+
const msgType = message.type;
|
|
377
|
+
if (msgType === 'broadcast') {
|
|
378
|
+
const event = message.event;
|
|
379
|
+
if (event === 'session_join') {
|
|
380
|
+
const participant = message.data?.participant;
|
|
381
|
+
if (participant?.role === 'observer' && typeof participant.id === 'string') {
|
|
382
|
+
observerParticipantIds.add(participant.id);
|
|
383
|
+
observerState.count = observerParticipantIds.size;
|
|
384
|
+
log(`Observer joined: ${observerState.count} observers`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else if (event === 'session_label_updated') {
|
|
388
|
+
const label = message.data?.label;
|
|
389
|
+
if (label) {
|
|
390
|
+
observerState.label = label;
|
|
391
|
+
log(`Session label updated: ${label}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if (event === 'session_leave') {
|
|
395
|
+
const participant = message.data?.participant;
|
|
396
|
+
if (participant?.role === 'observer' && typeof participant.id === 'string') {
|
|
397
|
+
observerParticipantIds.delete(participant.id);
|
|
398
|
+
observerState.count = observerParticipantIds.size;
|
|
399
|
+
log(`Observer left: ${observerState.count} observers`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (msgType === 'presence') {
|
|
404
|
+
// Full presence update — may include participant list
|
|
405
|
+
const participants = message.participants;
|
|
406
|
+
if (participants) {
|
|
407
|
+
observerParticipantIds.clear();
|
|
408
|
+
for (const participant of participants) {
|
|
409
|
+
if (participant.role === 'observer' && typeof participant.id === 'string') {
|
|
410
|
+
observerParticipantIds.add(participant.id);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
observerState.count = observerParticipantIds.size;
|
|
414
|
+
log(`Presence update: ${observerState.count} observers`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
// Lazy WebSocket connect — returns cached InitMessage
|
|
419
|
+
// In native remote mode, remote-tui.ts owns the controller WebSocket via RemoteSession.
|
|
420
|
+
// Skip the extension's own HubClient connection to avoid duplicate controllers.
|
|
421
|
+
function ensureConnected() {
|
|
422
|
+
if (isNativeRemote) {
|
|
423
|
+
log('Native remote mode — skipping HubClient WebSocket (remote-tui owns controller)');
|
|
424
|
+
return Promise.resolve(cachedInitMessage);
|
|
425
|
+
}
|
|
426
|
+
if (client.connected && cachedInitMessage)
|
|
427
|
+
return Promise.resolve(cachedInitMessage);
|
|
428
|
+
if (client.connectionState === 'reconnecting' || client.connectionState === 'disconnected') {
|
|
429
|
+
return client
|
|
430
|
+
.waitUntilConnected(RECONNECT_WAIT_TIMEOUT_MS)
|
|
431
|
+
.then(() => cachedInitMessage)
|
|
432
|
+
.catch(() => null);
|
|
433
|
+
}
|
|
434
|
+
if (connectPromise)
|
|
435
|
+
return connectPromise;
|
|
436
|
+
connectPromise = (async () => {
|
|
437
|
+
log('Connecting WebSocket to Hub...');
|
|
438
|
+
try {
|
|
439
|
+
// In remote mode, connect as a controller to the existing session
|
|
440
|
+
const connectOpts = remoteSessionId
|
|
441
|
+
? { sessionId: remoteSessionId, role: 'controller' }
|
|
442
|
+
: undefined;
|
|
443
|
+
const wsInitMsg = await client.connect(hubUrl, connectOpts);
|
|
444
|
+
log('WebSocket connected');
|
|
445
|
+
applyInitMessage(wsInitMsg);
|
|
446
|
+
connectPromise = null; // Clear so future disconnects can reconnect
|
|
447
|
+
return wsInitMsg;
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
log(`WebSocket failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
451
|
+
connectPromise = null;
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
})();
|
|
455
|
+
return connectPromise;
|
|
456
|
+
}
|
|
457
|
+
// ══════════════════════════════════════════════
|
|
458
|
+
// Register Hub tools from server's InitMessage
|
|
459
|
+
// Tools come from the server — NOT hardcoded in the extension.
|
|
460
|
+
// ══════════════════════════════════════════════
|
|
461
|
+
for (const toolDef of serverTools) {
|
|
462
|
+
log(`Registering tool: ${toolDef.name}`);
|
|
463
|
+
const renderers = getToolRenderers(toolDef.name);
|
|
464
|
+
pi.registerTool({
|
|
465
|
+
name: toolDef.name,
|
|
466
|
+
label: toolDef.label || toolDef.name,
|
|
467
|
+
description: toolDef.description,
|
|
468
|
+
// Server sends JSON Schema; TypeBox schemas are JSON Schema at runtime
|
|
469
|
+
parameters: toolDef.parameters,
|
|
470
|
+
...(toolDef.promptSnippet ? { promptSnippet: toolDef.promptSnippet } : {}),
|
|
471
|
+
...(toolDef.promptGuidelines ? { promptGuidelines: toolDef.promptGuidelines } : {}),
|
|
472
|
+
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
473
|
+
// Ensure WebSocket is connected before executing
|
|
474
|
+
await ensureConnected();
|
|
475
|
+
if (!client.connected) {
|
|
476
|
+
return {
|
|
477
|
+
content: [{ type: 'text', text: 'Error: Hub WebSocket not connected' }],
|
|
478
|
+
details: undefined,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const id = client.nextId();
|
|
482
|
+
let response;
|
|
483
|
+
try {
|
|
484
|
+
response = await client.send({
|
|
485
|
+
id,
|
|
486
|
+
type: 'tool',
|
|
487
|
+
name: toolDef.name,
|
|
488
|
+
toolCallId,
|
|
489
|
+
params: (params ?? {}),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return {
|
|
494
|
+
content: [{ type: 'text', text: 'Error: Hub connection lost' }],
|
|
495
|
+
details: undefined,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
// Process ALL Hub actions (NOTIFY, STATUS, RETURN, etc.)
|
|
499
|
+
const result = await processActions(response.actions, buildActionContext(ctx));
|
|
500
|
+
// If there's a return value from processActions, use it
|
|
501
|
+
if (result.returnValue !== undefined) {
|
|
502
|
+
const text = typeof result.returnValue === 'string'
|
|
503
|
+
? result.returnValue
|
|
504
|
+
: JSON.stringify(result.returnValue, null, 2);
|
|
505
|
+
return {
|
|
506
|
+
content: [{ type: 'text', text }],
|
|
507
|
+
details: undefined,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
// Fallback — check for RETURN action directly (backward compat)
|
|
511
|
+
const returnAction = response.actions.find((a) => a.action === 'RETURN');
|
|
512
|
+
if (returnAction && 'result' in returnAction) {
|
|
513
|
+
const text = typeof returnAction.result === 'string'
|
|
514
|
+
? returnAction.result
|
|
515
|
+
: JSON.stringify(returnAction.result, null, 2);
|
|
516
|
+
return {
|
|
517
|
+
content: [{ type: 'text', text }],
|
|
518
|
+
details: undefined,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
content: [{ type: 'text', text: 'Done' }],
|
|
523
|
+
details: undefined,
|
|
524
|
+
};
|
|
525
|
+
},
|
|
526
|
+
// TUI renderers — optional, only for known Hub tools.
|
|
527
|
+
// Cast needed: SimpleText satisfies Component, but TS can't verify cross-package structural match.
|
|
528
|
+
...(renderers?.renderCall && {
|
|
529
|
+
renderCall: renderers.renderCall,
|
|
530
|
+
}),
|
|
531
|
+
...(renderers?.renderResult && {
|
|
532
|
+
renderResult: renderers.renderResult,
|
|
533
|
+
}),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
// ══════════════════════════════════════════════
|
|
537
|
+
// Register task tools (LEAD only) from server's agent list
|
|
538
|
+
// Agent names and configs come from the Hub, not hardcoded.
|
|
539
|
+
// ══════════════════════════════════════════════
|
|
540
|
+
if (!isSubAgent && serverAgents.length > 0) {
|
|
541
|
+
pi.registerShortcut('ctrl+shift+a', {
|
|
542
|
+
description: 'Open Agent Manager',
|
|
543
|
+
handler: async (ctx) => {
|
|
544
|
+
await openAgentManager(ctx);
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
const openOutputViewer = async (ctx) => {
|
|
548
|
+
if (!ctx.hasUI || recentResults.length === 0)
|
|
549
|
+
return;
|
|
550
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) => new OutputViewerOverlay(tui, theme, recentResults, done), {
|
|
551
|
+
overlay: true,
|
|
552
|
+
overlayOptions: { width: '95%', maxHeight: '95%', anchor: 'center', margin: 1 },
|
|
553
|
+
});
|
|
554
|
+
};
|
|
555
|
+
pi.registerShortcut('ctrl+shift+v', {
|
|
556
|
+
description: 'View full agent output',
|
|
557
|
+
handler: openOutputViewer,
|
|
558
|
+
});
|
|
559
|
+
// Tmux/terminal environments often cannot emit Ctrl+Shift+V consistently.
|
|
560
|
+
pi.registerShortcut('alt+shift+v', {
|
|
561
|
+
description: 'View full agent output',
|
|
562
|
+
handler: openOutputViewer,
|
|
563
|
+
});
|
|
564
|
+
pi.registerShortcut('ctrl+shift+c', {
|
|
565
|
+
description: 'Open Chain Editor',
|
|
566
|
+
handler: async (ctx) => {
|
|
567
|
+
await openChainEditor(ctx);
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
pi.registerShortcut('ctrl+h', {
|
|
571
|
+
description: 'Open Hub overlay',
|
|
572
|
+
handler: async (ctx) => {
|
|
573
|
+
if (!ctx.hasUI)
|
|
574
|
+
return;
|
|
575
|
+
await openHubOverlay(ctx, currentSessionId);
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
const agentRegistry = new Map(serverAgents.map((a) => [a.name, a]));
|
|
579
|
+
const agentNames = serverAgents.map((a) => a.name);
|
|
580
|
+
log(`Registering task tools. Agents: ${agentNames.join(', ')}`);
|
|
581
|
+
const taskRenderers = getToolRenderers('task');
|
|
582
|
+
pi.registerTool({
|
|
583
|
+
name: 'task',
|
|
584
|
+
label: 'Delegate Task to Agent',
|
|
585
|
+
description: `Delegate a task to a specialized agent on your team. ` +
|
|
586
|
+
`Available agents: ${agentNames.join(', ')}. ` +
|
|
587
|
+
`Each agent runs independently with its own context window.`,
|
|
588
|
+
parameters: Type.Object({
|
|
589
|
+
description: Type.String({ description: 'Short 3-5 word task description' }),
|
|
590
|
+
prompt: Type.String({ description: 'Detailed task instructions for the agent' }),
|
|
591
|
+
subagent_type: Type.String({
|
|
592
|
+
description: `Agent: ${agentNames.join(', ')}`,
|
|
593
|
+
}),
|
|
594
|
+
}),
|
|
595
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
596
|
+
const { description, prompt, subagent_type } = params;
|
|
597
|
+
if (signal?.aborted) {
|
|
598
|
+
return {
|
|
599
|
+
content: [{ type: 'text', text: 'Cancelled' }],
|
|
600
|
+
details: undefined,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
const agent = agentRegistry.get(subagent_type);
|
|
604
|
+
if (!agent) {
|
|
605
|
+
return {
|
|
606
|
+
content: [
|
|
607
|
+
{
|
|
608
|
+
type: 'text',
|
|
609
|
+
text: `Unknown agent: ${subagent_type}. Available: ${agentNames.join(', ')}`,
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
details: undefined,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
log(`Task: ${description} → ${subagent_type}`);
|
|
616
|
+
const startTime = Date.now();
|
|
617
|
+
const formatElapsed = () => {
|
|
618
|
+
const s = Math.floor((Date.now() - startTime) / 1000);
|
|
619
|
+
if (s < 60)
|
|
620
|
+
return `${s}s`;
|
|
621
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
622
|
+
};
|
|
623
|
+
let elapsedTimer = null;
|
|
624
|
+
// ── Single-agent status via working message ──
|
|
625
|
+
let lastWidgetTool;
|
|
626
|
+
let lastWidgetToolArgs;
|
|
627
|
+
function updateWidget(status, tool, toolArgs) {
|
|
628
|
+
if (!ctx.hasUI)
|
|
629
|
+
return;
|
|
630
|
+
let msg = '';
|
|
631
|
+
if (status === 'running') {
|
|
632
|
+
msg = '\u25CF ' + subagent_type; // ● name
|
|
633
|
+
if (tool) {
|
|
634
|
+
const toolInfo = toolArgs ? `${tool} ${toolArgs}` : tool;
|
|
635
|
+
msg += ' ' + toolInfo.slice(0, 40);
|
|
636
|
+
}
|
|
637
|
+
msg += ' ' + formatElapsed();
|
|
638
|
+
}
|
|
639
|
+
else if (status === 'completed') {
|
|
640
|
+
msg = '\u2713 ' + subagent_type + ' ' + formatElapsed(); // ✓ name Xs
|
|
641
|
+
}
|
|
642
|
+
else if (status === 'failed') {
|
|
643
|
+
msg = '\u2717 ' + subagent_type + ' failed'; // ✗ name failed
|
|
644
|
+
}
|
|
645
|
+
ctx.ui.setWorkingMessage(msg);
|
|
646
|
+
}
|
|
647
|
+
if (ctx.hasUI) {
|
|
648
|
+
ctx.ui.setStatus('active_agent', subagent_type);
|
|
649
|
+
updateWidget('running');
|
|
650
|
+
elapsedTimer = setInterval(() => {
|
|
651
|
+
updateWidget('running', lastWidgetTool, lastWidgetToolArgs);
|
|
652
|
+
}, 1000);
|
|
653
|
+
}
|
|
654
|
+
// Create live streaming result before starting sub-agent
|
|
655
|
+
const liveResult = startStreamingResult(subagent_type, description, prompt);
|
|
656
|
+
sendEventNoWait('task_start', {
|
|
657
|
+
taskId: toolCallId,
|
|
658
|
+
agent: subagent_type,
|
|
659
|
+
prompt,
|
|
660
|
+
description,
|
|
661
|
+
});
|
|
662
|
+
try {
|
|
663
|
+
const result = await runSubAgent(agent, prompt, client, ctx.hasUI
|
|
664
|
+
? (progress) => {
|
|
665
|
+
// Update TUI working message with live tool activity
|
|
666
|
+
try {
|
|
667
|
+
if (progress.status === 'thinking_delta' && progress.delta) {
|
|
668
|
+
liveResult.thinking += progress.delta;
|
|
669
|
+
updateWidget('running', 'thinking...');
|
|
670
|
+
}
|
|
671
|
+
else if (progress.status === 'text_delta' && progress.delta) {
|
|
672
|
+
liveResult.text += progress.delta;
|
|
673
|
+
updateWidget('running', 'writing...');
|
|
674
|
+
}
|
|
675
|
+
else if (progress.status === 'tool_start' && progress.currentTool) {
|
|
676
|
+
lastWidgetTool = progress.currentTool;
|
|
677
|
+
lastWidgetToolArgs = progress.currentToolArgs;
|
|
678
|
+
updateWidget('running', progress.currentTool, progress.currentToolArgs);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
// Best-effort live widget updates.
|
|
683
|
+
}
|
|
684
|
+
// Forward progress to Hub (fire-and-forget, queued while disconnected)
|
|
685
|
+
sendEventNoWait('agent_progress', {
|
|
686
|
+
agentName: progress.agentName,
|
|
687
|
+
status: progress.status,
|
|
688
|
+
taskId: toolCallId,
|
|
689
|
+
delta: progress.delta,
|
|
690
|
+
currentTool: progress.currentTool,
|
|
691
|
+
currentToolArgs: progress.currentToolArgs,
|
|
692
|
+
elapsed: progress.elapsed,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
: undefined, signal);
|
|
696
|
+
// Flash completed state briefly before clearing
|
|
697
|
+
updateWidget('completed');
|
|
698
|
+
// Finalize the live result instead of creating a new one
|
|
699
|
+
liveResult.isStreaming = false;
|
|
700
|
+
liveResult.text = result.output || liveResult.text || '(no output)';
|
|
701
|
+
await sendEvent('task_complete', {
|
|
702
|
+
taskId: toolCallId,
|
|
703
|
+
agent: subagent_type,
|
|
704
|
+
duration: result.duration,
|
|
705
|
+
result: result.output.slice(0, 10000),
|
|
706
|
+
}, ctx);
|
|
707
|
+
let output = result.output;
|
|
708
|
+
let tokenInfoStr;
|
|
709
|
+
if (result.tokens && (result.tokens.input > 0 || result.tokens.output > 0)) {
|
|
710
|
+
tokenInfoStr = `${subagent_type}: ${result.duration}ms | ${result.tokens.input} in ${result.tokens.output} out | $${result.tokens.cost.toFixed(4)}`;
|
|
711
|
+
output += `\n\n---\n_${subagent_type}: ${result.duration}ms | ${result.tokens.input} in ${result.tokens.output} out tokens | $${result.tokens.cost.toFixed(4)}_`;
|
|
712
|
+
}
|
|
713
|
+
if (tokenInfoStr)
|
|
714
|
+
liveResult.tokenInfo = tokenInfoStr;
|
|
715
|
+
return {
|
|
716
|
+
content: [{ type: 'text', text: output }],
|
|
717
|
+
details: undefined,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
722
|
+
liveResult.isStreaming = false;
|
|
723
|
+
liveResult.text = liveResult.text || `Agent ${subagent_type} failed: ${errorMsg}`;
|
|
724
|
+
await sendEvent('task_error', {
|
|
725
|
+
taskId: toolCallId,
|
|
726
|
+
agent: subagent_type,
|
|
727
|
+
error: errorMsg,
|
|
728
|
+
}, ctx);
|
|
729
|
+
updateWidget('failed');
|
|
730
|
+
return {
|
|
731
|
+
content: [
|
|
732
|
+
{ type: 'text', text: `Agent ${subagent_type} failed: ${errorMsg}` },
|
|
733
|
+
],
|
|
734
|
+
details: undefined,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
if (elapsedTimer)
|
|
739
|
+
clearInterval(elapsedTimer);
|
|
740
|
+
if (ctx.hasUI) {
|
|
741
|
+
ctx.ui.setStatus('active_agent', undefined);
|
|
742
|
+
ctx.ui.setWorkingMessage(); // Restore Pi's default working message
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
...(taskRenderers?.renderCall && {
|
|
747
|
+
renderCall: taskRenderers.renderCall,
|
|
748
|
+
}),
|
|
749
|
+
...(taskRenderers?.renderResult && {
|
|
750
|
+
renderResult: taskRenderers.renderResult,
|
|
751
|
+
}),
|
|
752
|
+
});
|
|
753
|
+
const parallelRenderers = getToolRenderers('parallel_tasks');
|
|
754
|
+
pi.registerTool({
|
|
755
|
+
name: 'parallel_tasks',
|
|
756
|
+
label: 'Delegate Parallel Tasks',
|
|
757
|
+
description: `Run multiple agent tasks concurrently (max 4). ` +
|
|
758
|
+
`Available agents: ${agentNames.join(', ')}.`,
|
|
759
|
+
parameters: Type.Object({
|
|
760
|
+
tasks: Type.Array(Type.Object({
|
|
761
|
+
description: Type.String({ description: 'Short task description' }),
|
|
762
|
+
prompt: Type.String({ description: 'Detailed instructions' }),
|
|
763
|
+
subagent_type: Type.String({ description: 'Agent to delegate to' }),
|
|
764
|
+
}), { maxItems: 4 }),
|
|
765
|
+
}),
|
|
766
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
767
|
+
const { tasks } = params;
|
|
768
|
+
if (signal?.aborted) {
|
|
769
|
+
return {
|
|
770
|
+
content: [{ type: 'text', text: 'Cancelled' }],
|
|
771
|
+
details: undefined,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
if (tasks.length > 4) {
|
|
775
|
+
return {
|
|
776
|
+
content: [{ type: 'text', text: 'Maximum 4 concurrent tasks allowed.' }],
|
|
777
|
+
details: undefined,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
log(`Parallel tasks: ${tasks.map((t) => `${t.subagent_type}:${t.description}`).join(', ')}`);
|
|
781
|
+
let elapsedTimer = null;
|
|
782
|
+
const agentStatuses = tasks.map((t) => ({
|
|
783
|
+
name: t.subagent_type,
|
|
784
|
+
status: 'pending',
|
|
785
|
+
}));
|
|
786
|
+
function updateWidget() {
|
|
787
|
+
if (!ctx.hasUI)
|
|
788
|
+
return;
|
|
789
|
+
const parts = agentStatuses
|
|
790
|
+
.filter((s) => s.status !== 'pending')
|
|
791
|
+
.map((s) => {
|
|
792
|
+
const elapsed = s.startTime
|
|
793
|
+
? Math.floor((Date.now() - s.startTime) / 1000)
|
|
794
|
+
: 0;
|
|
795
|
+
const timeStr = elapsed < 60
|
|
796
|
+
? `${elapsed}s`
|
|
797
|
+
: `${Math.floor(elapsed / 60)}m${elapsed % 60}s`;
|
|
798
|
+
if (s.status === 'running') {
|
|
799
|
+
let info = `\u25CF ${s.name}`;
|
|
800
|
+
if (s.currentTool)
|
|
801
|
+
info += ` ${s.currentTool.slice(0, 15)}`;
|
|
802
|
+
return info + ` ${timeStr}`;
|
|
803
|
+
}
|
|
804
|
+
if (s.status === 'completed') {
|
|
805
|
+
return `\u2713 ${s.name} ${timeStr}`;
|
|
806
|
+
}
|
|
807
|
+
if (s.status === 'failed') {
|
|
808
|
+
return `\u2717 ${s.name}`;
|
|
809
|
+
}
|
|
810
|
+
return `\u25CB ${s.name}`;
|
|
811
|
+
});
|
|
812
|
+
ctx.ui.setWorkingMessage(parts.join(' '));
|
|
813
|
+
}
|
|
814
|
+
if (ctx.hasUI) {
|
|
815
|
+
ctx.ui.setStatus('active_agent', 'agents');
|
|
816
|
+
updateWidget();
|
|
817
|
+
elapsedTimer = setInterval(() => {
|
|
818
|
+
updateWidget(); // Refresh elapsed times in widget
|
|
819
|
+
}, 1000);
|
|
820
|
+
}
|
|
821
|
+
// Create live streaming results for each parallel task
|
|
822
|
+
const liveResults = tasks.map((task) => startStreamingResult(task.subagent_type, task.description, task.prompt));
|
|
823
|
+
const promises = tasks.map(async (task, index) => {
|
|
824
|
+
const taskId = `${toolCallId}-${index}-${task.subagent_type}`;
|
|
825
|
+
const agent = agentRegistry.get(task.subagent_type);
|
|
826
|
+
if (!agent) {
|
|
827
|
+
agentStatuses[index].status = 'failed';
|
|
828
|
+
liveResults[index].isStreaming = false;
|
|
829
|
+
liveResults[index].text = `Unknown agent: ${task.subagent_type}`;
|
|
830
|
+
await sendEvent('task_error', {
|
|
831
|
+
taskId,
|
|
832
|
+
agent: task.subagent_type,
|
|
833
|
+
error: `Unknown agent: ${task.subagent_type}`,
|
|
834
|
+
}, ctx);
|
|
835
|
+
updateWidget();
|
|
836
|
+
return {
|
|
837
|
+
agent: task.subagent_type,
|
|
838
|
+
error: `Unknown agent: ${task.subagent_type}`,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
agentStatuses[index].status = 'running';
|
|
842
|
+
agentStatuses[index].startTime = Date.now();
|
|
843
|
+
sendEventNoWait('task_start', {
|
|
844
|
+
taskId,
|
|
845
|
+
agent: task.subagent_type,
|
|
846
|
+
prompt: task.prompt,
|
|
847
|
+
description: task.description,
|
|
848
|
+
});
|
|
849
|
+
updateWidget();
|
|
850
|
+
try {
|
|
851
|
+
const result = await runSubAgent(agent, task.prompt, client, ctx.hasUI
|
|
852
|
+
? (progress) => {
|
|
853
|
+
// Handle streaming deltas
|
|
854
|
+
if (progress.status === 'thinking_delta' && progress.delta) {
|
|
855
|
+
liveResults[index].thinking += progress.delta;
|
|
856
|
+
}
|
|
857
|
+
else if (progress.status === 'text_delta' && progress.delta) {
|
|
858
|
+
liveResults[index].text += progress.delta;
|
|
859
|
+
}
|
|
860
|
+
// Update per-agent widget with tool activity
|
|
861
|
+
agentStatuses[index].currentTool = progress.currentTool;
|
|
862
|
+
agentStatuses[index].currentToolArgs = progress.currentToolArgs;
|
|
863
|
+
updateWidget();
|
|
864
|
+
// Forward progress to Hub (fire-and-forget, queued while disconnected)
|
|
865
|
+
sendEventNoWait('agent_progress', {
|
|
866
|
+
agentName: progress.agentName,
|
|
867
|
+
status: progress.status,
|
|
868
|
+
taskId,
|
|
869
|
+
delta: progress.delta,
|
|
870
|
+
currentTool: progress.currentTool,
|
|
871
|
+
currentToolArgs: progress.currentToolArgs,
|
|
872
|
+
elapsed: progress.elapsed,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
: undefined, signal);
|
|
876
|
+
agentStatuses[index].status = 'completed';
|
|
877
|
+
agentStatuses[index].duration = result.duration;
|
|
878
|
+
agentStatuses[index].currentTool = undefined;
|
|
879
|
+
agentStatuses[index].currentToolArgs = undefined;
|
|
880
|
+
// Finalize the live result
|
|
881
|
+
liveResults[index].isStreaming = false;
|
|
882
|
+
liveResults[index].text =
|
|
883
|
+
result.output || liveResults[index].text || '(no output)';
|
|
884
|
+
await sendEvent('task_complete', {
|
|
885
|
+
taskId,
|
|
886
|
+
agent: task.subagent_type,
|
|
887
|
+
duration: result.duration,
|
|
888
|
+
result: result.output.slice(0, 10000),
|
|
889
|
+
}, ctx);
|
|
890
|
+
updateWidget();
|
|
891
|
+
return {
|
|
892
|
+
agent: task.subagent_type,
|
|
893
|
+
output: result.output,
|
|
894
|
+
duration: result.duration,
|
|
895
|
+
tokens: result.tokens,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
catch (err) {
|
|
899
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
900
|
+
agentStatuses[index].status = 'failed';
|
|
901
|
+
agentStatuses[index].currentTool = undefined;
|
|
902
|
+
agentStatuses[index].currentToolArgs = undefined;
|
|
903
|
+
liveResults[index].isStreaming = false;
|
|
904
|
+
liveResults[index].text = liveResults[index].text || `Failed: ${errorMsg}`;
|
|
905
|
+
await sendEvent('task_error', {
|
|
906
|
+
taskId,
|
|
907
|
+
agent: task.subagent_type,
|
|
908
|
+
error: errorMsg,
|
|
909
|
+
}, ctx);
|
|
910
|
+
updateWidget();
|
|
911
|
+
return { agent: task.subagent_type, error: errorMsg };
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
try {
|
|
915
|
+
const results = await Promise.all(promises);
|
|
916
|
+
// Finalize live results with token info
|
|
917
|
+
results.forEach((r, idx) => {
|
|
918
|
+
if ('output' in r && r.output && !('error' in r && r.error)) {
|
|
919
|
+
if ('tokens' in r && r.tokens && (r.tokens.input > 0 || r.tokens.output > 0)) {
|
|
920
|
+
liveResults[idx].tokenInfo =
|
|
921
|
+
`${r.agent}: ${'duration' in r ? r.duration : 0}ms | ${r.tokens.input} in ${r.tokens.output} out | $${r.tokens.cost.toFixed(4)}`;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
const output = results
|
|
926
|
+
.map((r) => {
|
|
927
|
+
if ('error' in r && r.error)
|
|
928
|
+
return `### ${r.agent} (FAILED)\n${r.error}`;
|
|
929
|
+
let text = `### ${r.agent} (${'duration' in r ? r.duration : 0}ms)\n${'output' in r ? r.output : ''}`;
|
|
930
|
+
if ('tokens' in r && r.tokens && (r.tokens.input > 0 || r.tokens.output > 0)) {
|
|
931
|
+
text += `\n\n---\n_${r.agent}: ${'duration' in r ? r.duration : 0}ms | ${r.tokens.input} in ${r.tokens.output} out tokens | $${r.tokens.cost.toFixed(4)}_`;
|
|
932
|
+
}
|
|
933
|
+
return text;
|
|
934
|
+
})
|
|
935
|
+
.join('\n\n---\n\n');
|
|
936
|
+
return {
|
|
937
|
+
content: [{ type: 'text', text: output }],
|
|
938
|
+
details: undefined,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
finally {
|
|
942
|
+
if (elapsedTimer)
|
|
943
|
+
clearInterval(elapsedTimer);
|
|
944
|
+
if (ctx.hasUI) {
|
|
945
|
+
ctx.ui.setStatus('active_agent', undefined);
|
|
946
|
+
ctx.ui.setWorkingMessage(); // Restore Pi's default working message
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
},
|
|
950
|
+
...(parallelRenderers?.renderCall && {
|
|
951
|
+
renderCall: parallelRenderers.renderCall,
|
|
952
|
+
}),
|
|
953
|
+
...(parallelRenderers?.renderResult && {
|
|
954
|
+
renderResult: parallelRenderers.renderResult,
|
|
955
|
+
}),
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
log('Tool registration complete');
|
|
959
|
+
// ══════════════════════════════════════════════
|
|
960
|
+
// Register slash commands for agent routing (LEAD only)
|
|
961
|
+
// When user types /memory, /scout, etc., the message is routed
|
|
962
|
+
// to that specific agent via a routing prefix.
|
|
963
|
+
// ══════════════════════════════════════════════
|
|
964
|
+
if (!isSubAgent && serverAgents.length > 0) {
|
|
965
|
+
registerAgentCommands(pi, serverAgents, getHubUiStatus, openAgentManager, openChainEditor);
|
|
966
|
+
}
|
|
967
|
+
// ══════════════════════════════════════════════
|
|
968
|
+
// /hub command — Hub session overview (LEAD only)
|
|
969
|
+
// ══════════════════════════════════════════════
|
|
970
|
+
if (!isSubAgent) {
|
|
971
|
+
pi.registerCommand('hub', {
|
|
972
|
+
description: 'Open Coder Hub overlay (sessions, detail, feed)',
|
|
973
|
+
handler: async (_args, ctx) => {
|
|
974
|
+
if (!ctx.hasUI)
|
|
975
|
+
return;
|
|
976
|
+
await openHubOverlay(ctx, currentSessionId);
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
pi.registerCommand('todos', {
|
|
980
|
+
description: 'Open session todo board for current Hub session',
|
|
981
|
+
handler: async (args, ctx) => {
|
|
982
|
+
if (!ctx.hasUI)
|
|
983
|
+
return;
|
|
984
|
+
const targetSessionId = args.trim().length > 0 ? args.trim() : currentSessionId;
|
|
985
|
+
if (!targetSessionId) {
|
|
986
|
+
ctx.ui.notify('No active Hub session id available yet.', 'warning');
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
await openHubOverlay(ctx, currentSessionId, targetSessionId);
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
pi.registerCommand('rename-session', {
|
|
993
|
+
description: 'Rename the current Hub session (max 30 chars)',
|
|
994
|
+
handler: async (args, ctx) => {
|
|
995
|
+
const label = args.trim().slice(0, 30);
|
|
996
|
+
if (!label) {
|
|
997
|
+
if (ctx.hasUI)
|
|
998
|
+
ctx.ui.notify('Usage: /rename-session <label>', 'warning');
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (!client.connected) {
|
|
1002
|
+
if (ctx.hasUI)
|
|
1003
|
+
ctx.ui.notify('Not connected to Hub', 'warning');
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
try {
|
|
1007
|
+
client.send({ type: 'rename_session', label });
|
|
1008
|
+
observerState.label = label;
|
|
1009
|
+
if (ctx.hasUI)
|
|
1010
|
+
ctx.ui.notify(`Session renamed to "${label}"`, 'info');
|
|
1011
|
+
log(`Session renamed to "${label}"`);
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
if (ctx.hasUI)
|
|
1015
|
+
ctx.ui.notify(`Failed to rename: ${err?.message || err}`, 'error');
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
pi.registerCommand('sync-hub-skills', {
|
|
1020
|
+
description: 'Sync skills from Coder Hub to local .agents/skills/ directory',
|
|
1021
|
+
handler: async (_args, ctx) => {
|
|
1022
|
+
const baseUrl = getHubHttpBaseUrl(hubUrl);
|
|
1023
|
+
const url = `${baseUrl}/api/hub/skills`;
|
|
1024
|
+
try {
|
|
1025
|
+
const resp = await fetch(url, { headers: authHeaders() });
|
|
1026
|
+
if (!resp.ok) {
|
|
1027
|
+
const msg = `Hub skills fetch failed: ${resp.status} ${resp.statusText}`;
|
|
1028
|
+
if (ctx.hasUI)
|
|
1029
|
+
ctx.ui.notify(msg, 'error');
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const data = (await resp.json());
|
|
1033
|
+
if (!data.ok || !data.skills?.length) {
|
|
1034
|
+
if (ctx.hasUI)
|
|
1035
|
+
ctx.ui.notify('No skills available from Hub.', 'info');
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const { mkdirSync, writeFileSync } = _require('node:fs');
|
|
1039
|
+
const { dirname, resolve, relative } = _require('node:path');
|
|
1040
|
+
const cwd = process.cwd();
|
|
1041
|
+
const synced = [];
|
|
1042
|
+
for (const skill of data.skills) {
|
|
1043
|
+
if (skill.path.includes('\0')) {
|
|
1044
|
+
log(`Skipping skill with null byte in path: ${skill.path}`);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const fullPath = resolve(cwd, skill.path);
|
|
1048
|
+
const rel = relative(cwd, fullPath);
|
|
1049
|
+
if (rel.startsWith('..') || resolve(cwd, rel) !== fullPath) {
|
|
1050
|
+
log(`Skipping skill with path traversal: ${skill.path}`);
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
1054
|
+
writeFileSync(fullPath, skill.content, 'utf-8');
|
|
1055
|
+
synced.push(skill.path);
|
|
1056
|
+
}
|
|
1057
|
+
const msg = `Synced ${synced.length} skill files:\n${synced.map((p) => ` ${p}`).join('\n')}`;
|
|
1058
|
+
if (ctx.hasUI)
|
|
1059
|
+
ctx.ui.notify(msg, 'info');
|
|
1060
|
+
log(msg);
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
const msg = `Failed to sync skills: ${err?.message || err}`;
|
|
1064
|
+
if (ctx.hasUI)
|
|
1065
|
+
ctx.ui.notify(msg, 'error');
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
// ══════════════════════════════════════════════
|
|
1071
|
+
// Event Handlers
|
|
1072
|
+
// ══════════════════════════════════════════════
|
|
1073
|
+
function serializeEvent(event) {
|
|
1074
|
+
const data = {};
|
|
1075
|
+
if (event && typeof event === 'object') {
|
|
1076
|
+
for (const [key, value] of Object.entries(event)) {
|
|
1077
|
+
if (typeof value !== 'function' && key !== 'signal') {
|
|
1078
|
+
try {
|
|
1079
|
+
JSON.stringify(value);
|
|
1080
|
+
data[key] = value;
|
|
1081
|
+
}
|
|
1082
|
+
catch {
|
|
1083
|
+
/* skip */
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return data;
|
|
1089
|
+
}
|
|
1090
|
+
async function sendEvent(eventName, data, ctx) {
|
|
1091
|
+
const id = client.nextId();
|
|
1092
|
+
try {
|
|
1093
|
+
const response = await client.send({
|
|
1094
|
+
id,
|
|
1095
|
+
type: 'event',
|
|
1096
|
+
event: eventName,
|
|
1097
|
+
data: { ...data, agentRole },
|
|
1098
|
+
});
|
|
1099
|
+
const result = await processActions(response.actions, buildActionContext(ctx));
|
|
1100
|
+
if (result.block)
|
|
1101
|
+
return result.block;
|
|
1102
|
+
if (result.returnValue !== undefined)
|
|
1103
|
+
return result.returnValue;
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
/* ignore */
|
|
1107
|
+
}
|
|
1108
|
+
return undefined;
|
|
1109
|
+
}
|
|
1110
|
+
function sendEventNoWait(eventName, data) {
|
|
1111
|
+
client.sendNoWait({
|
|
1112
|
+
id: client.nextId(),
|
|
1113
|
+
type: 'event',
|
|
1114
|
+
event: eventName,
|
|
1115
|
+
data: { ...data, agentRole },
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
const onEvent = pi.on.bind(pi);
|
|
1119
|
+
// session_start: establish WebSocket connection to Hub + set up footer
|
|
1120
|
+
onEvent('session_start', async (event, ctx) => {
|
|
1121
|
+
await ensureConnected();
|
|
1122
|
+
footerCtx = ctx;
|
|
1123
|
+
if (ctx.hasUI) {
|
|
1124
|
+
ctx.ui.setStatus('hub_connection', getHubUiStatus());
|
|
1125
|
+
}
|
|
1126
|
+
// Set up Coder footer (powerline: model or active agent > branch > status + observer count)
|
|
1127
|
+
setupCoderFooter(ctx, getHubUiStatus, getObserverState);
|
|
1128
|
+
// Fire-and-forget: fetch session snapshot for label + initial observer count.
|
|
1129
|
+
// Uses the Hub REST endpoint — non-blocking, best-effort.
|
|
1130
|
+
if (!isSubAgent) {
|
|
1131
|
+
fetchSessionSnapshot(hubUrl, currentSessionId, observerState).catch(() => { });
|
|
1132
|
+
}
|
|
1133
|
+
return sendEvent('session_start', serializeEvent(event), ctx);
|
|
1134
|
+
});
|
|
1135
|
+
// before_agent_start: inject system prompt from Hub
|
|
1136
|
+
onEvent('before_agent_start', async (event, ctx) => {
|
|
1137
|
+
const eventData = event;
|
|
1138
|
+
let systemPrompt = eventData.systemPrompt || '';
|
|
1139
|
+
const id = client.nextId();
|
|
1140
|
+
try {
|
|
1141
|
+
const response = await client.send({
|
|
1142
|
+
id,
|
|
1143
|
+
type: 'event',
|
|
1144
|
+
event: 'before_agent_start',
|
|
1145
|
+
data: { ...serializeEvent(event), agentRole },
|
|
1146
|
+
});
|
|
1147
|
+
const result = await processActions(response.actions, buildActionContext(ctx));
|
|
1148
|
+
if (result.block)
|
|
1149
|
+
return result.block;
|
|
1150
|
+
if (result.systemPrompt) {
|
|
1151
|
+
const mode = result.systemPromptMode || 'suffix';
|
|
1152
|
+
if (mode === 'prefix') {
|
|
1153
|
+
systemPrompt = result.systemPrompt + '\n\n' + systemPrompt;
|
|
1154
|
+
}
|
|
1155
|
+
else if (mode === 'suffix') {
|
|
1156
|
+
systemPrompt = systemPrompt + '\n\n' + result.systemPrompt;
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
systemPrompt = result.systemPrompt;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
catch {
|
|
1164
|
+
/* ignore */
|
|
1165
|
+
}
|
|
1166
|
+
// Apply config prefix/suffix — LEAD ONLY
|
|
1167
|
+
if (!isSubAgent) {
|
|
1168
|
+
if (hubConfig?.systemPromptPrefix && !systemPromptApplied) {
|
|
1169
|
+
systemPrompt = hubConfig.systemPromptPrefix + '\n\n' + systemPrompt;
|
|
1170
|
+
systemPromptApplied = true;
|
|
1171
|
+
}
|
|
1172
|
+
if (hubConfig?.systemPromptSuffix) {
|
|
1173
|
+
systemPrompt = systemPrompt + '\n\n' + hubConfig.systemPromptSuffix;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return { systemPrompt };
|
|
1177
|
+
});
|
|
1178
|
+
// Proxy all other events
|
|
1179
|
+
for (const eventName of PROXY_EVENTS) {
|
|
1180
|
+
if (eventName === 'before_agent_start')
|
|
1181
|
+
continue;
|
|
1182
|
+
onEvent(eventName, async (event, ctx) => {
|
|
1183
|
+
return sendEvent(eventName, serializeEvent(event), ctx);
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
// ── Remote mode: input interception + remote event rendering ──
|
|
1187
|
+
// Two sub-modes:
|
|
1188
|
+
// 1. Native remote (AGENTUITY_CODER_NATIVE_REMOTE=1): remote-tui.ts drives rendering
|
|
1189
|
+
// via Agent.emit(). Extension only provides Hub UI (footer, /hub, commands).
|
|
1190
|
+
// No pi.sendMessage() rendering, no setupRemoteMode() event handlers.
|
|
1191
|
+
// 2. Legacy remote: Extension handles all rendering via pi.sendMessage({ customType }).
|
|
1192
|
+
const isNativeRemote = !!process.env[NATIVE_REMOTE_ENV];
|
|
1193
|
+
if (remoteSessionId) {
|
|
1194
|
+
let remoteSession = null;
|
|
1195
|
+
// Register custom message renderers (used only in legacy mode, harmless in native)
|
|
1196
|
+
try {
|
|
1197
|
+
pi.registerMessageRenderer('remote_message', () => undefined);
|
|
1198
|
+
pi.registerMessageRenderer('remote_history', () => undefined);
|
|
1199
|
+
}
|
|
1200
|
+
catch {
|
|
1201
|
+
/* not available in this Pi version */
|
|
1202
|
+
}
|
|
1203
|
+
if (!isNativeRemote) {
|
|
1204
|
+
// Legacy remote: intercept input and render events via pi.sendMessage()
|
|
1205
|
+
pi.on('input', async (event, ctx) => {
|
|
1206
|
+
const inputEvent = event;
|
|
1207
|
+
const userMessage = inputEvent.text || inputEvent.message;
|
|
1208
|
+
if (!userMessage)
|
|
1209
|
+
return;
|
|
1210
|
+
if (!remoteSession?.isConnected) {
|
|
1211
|
+
if (ctx.hasUI)
|
|
1212
|
+
ctx.ui.notify('Not connected to remote session yet');
|
|
1213
|
+
return { action: 'handled' };
|
|
1214
|
+
}
|
|
1215
|
+
pi.sendMessage({
|
|
1216
|
+
customType: 'remote_message',
|
|
1217
|
+
content: `**You:** ${userMessage}`,
|
|
1218
|
+
display: true,
|
|
1219
|
+
});
|
|
1220
|
+
remoteSession.prompt(userMessage, inputEvent.images);
|
|
1221
|
+
log(`Sent prompt to remote: ${userMessage.slice(0, 100)}`);
|
|
1222
|
+
if (ctx.hasUI) {
|
|
1223
|
+
ctx.ui.setWorkingMessage('Sending to remote agent…');
|
|
1224
|
+
}
|
|
1225
|
+
return { action: 'handled' };
|
|
1226
|
+
});
|
|
1227
|
+
// Connect the remote session with legacy event rendering
|
|
1228
|
+
(async () => {
|
|
1229
|
+
try {
|
|
1230
|
+
remoteSession = await setupRemoteMode(pi, hubUrl, remoteSessionId);
|
|
1231
|
+
log(`Remote session connected: ${remoteSessionId}`);
|
|
1232
|
+
if (footerCtx) {
|
|
1233
|
+
remoteSession._setExtensionCtx?.(footerCtx);
|
|
1234
|
+
}
|
|
1235
|
+
pi.sendMessage({
|
|
1236
|
+
customType: 'remote_message',
|
|
1237
|
+
content: `Connected to remote session **${remoteSessionId}**`,
|
|
1238
|
+
display: true,
|
|
1239
|
+
});
|
|
1240
|
+
remoteSession.setUiHandler(async (request) => {
|
|
1241
|
+
if (!footerCtx?.hasUI)
|
|
1242
|
+
return null;
|
|
1243
|
+
const ui = footerCtx.ui;
|
|
1244
|
+
switch (request.method) {
|
|
1245
|
+
case 'select': {
|
|
1246
|
+
const options = request.params.options ??
|
|
1247
|
+
[];
|
|
1248
|
+
const title = request.params.title ?? 'Select';
|
|
1249
|
+
const result = await ui.select(title, options.map((o) => o.label));
|
|
1250
|
+
if (result === null || result === undefined)
|
|
1251
|
+
return null;
|
|
1252
|
+
const idx = typeof result === 'number' ? result : Number(result);
|
|
1253
|
+
return options[idx]?.value ?? null;
|
|
1254
|
+
}
|
|
1255
|
+
case 'confirm':
|
|
1256
|
+
return await ui.confirm(request.params.message ?? 'Confirm?', request.params.message ?? 'Confirm?');
|
|
1257
|
+
case 'input':
|
|
1258
|
+
return await ui.input(request.params.prompt ?? 'Input:', request.params.placeholder ?? '');
|
|
1259
|
+
case 'editor':
|
|
1260
|
+
return await ui.editor(request.params.content ?? '', request.params.language ?? 'text');
|
|
1261
|
+
case 'notify':
|
|
1262
|
+
ui.notify(request.params.message ?? '');
|
|
1263
|
+
return undefined;
|
|
1264
|
+
case 'setStatus':
|
|
1265
|
+
ui.setStatus(request.params.key ?? 'remote', request.params.text ?? '');
|
|
1266
|
+
return undefined;
|
|
1267
|
+
case 'setTitle':
|
|
1268
|
+
ui.setTitle(request.params.title ?? '');
|
|
1269
|
+
return undefined;
|
|
1270
|
+
default:
|
|
1271
|
+
return null;
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
catch (err) {
|
|
1276
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1277
|
+
log(`Remote connection failed: ${msg}`);
|
|
1278
|
+
pi.sendMessage({
|
|
1279
|
+
customType: 'remote_message',
|
|
1280
|
+
content: `Failed to connect to remote session: ${msg}`,
|
|
1281
|
+
display: true,
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
})();
|
|
1285
|
+
}
|
|
1286
|
+
// In native remote mode: no input interception, no event rendering.
|
|
1287
|
+
// remote-tui.ts handles Agent.emit() for native rendering and
|
|
1288
|
+
// monkey-patches Agent.prompt/steer/abort for input relay.
|
|
1289
|
+
}
|
|
1290
|
+
// Clean up on shutdown
|
|
1291
|
+
pi.on('session_shutdown', async (_event, _ctx) => {
|
|
1292
|
+
log('Shutting down — closing Hub connection');
|
|
1293
|
+
try {
|
|
1294
|
+
client.close();
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
/* pending promises rejected on close — safe to ignore */
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
function truncateOutput(text) {
|
|
1302
|
+
let result = text;
|
|
1303
|
+
const lines = result.split('\n');
|
|
1304
|
+
if (lines.length > MAX_OUTPUT_LINES) {
|
|
1305
|
+
result =
|
|
1306
|
+
lines.slice(0, MAX_OUTPUT_LINES).join('\n') +
|
|
1307
|
+
`\n\n[Output truncated — ${lines.length - MAX_OUTPUT_LINES} lines omitted]`;
|
|
1308
|
+
}
|
|
1309
|
+
if (result.length > MAX_OUTPUT_BYTES) {
|
|
1310
|
+
result =
|
|
1311
|
+
result.slice(0, MAX_OUTPUT_BYTES) +
|
|
1312
|
+
`\n\n[Output truncated — exceeded ${MAX_OUTPUT_BYTES} bytes]`;
|
|
1313
|
+
}
|
|
1314
|
+
return result;
|
|
1315
|
+
}
|
|
1316
|
+
/** Cache resolved Pi SDK modules to avoid repeated dynamic import resolution */
|
|
1317
|
+
let _piSdkCache = null;
|
|
1318
|
+
/**
|
|
1319
|
+
* Load Pi SDK packages at runtime.
|
|
1320
|
+
* The extension runs inside Pi's process, but @mariozechner/pi-ai isn't in
|
|
1321
|
+
* our node_modules — resolve it from Pi's install directory via process.argv[1].
|
|
1322
|
+
*/
|
|
1323
|
+
async function loadPiSdk() {
|
|
1324
|
+
if (_piSdkCache)
|
|
1325
|
+
return _piSdkCache;
|
|
1326
|
+
// Try direct import first (works if packages are in module resolution path)
|
|
1327
|
+
try {
|
|
1328
|
+
const piSdk = await import('@mariozechner/pi-coding-agent');
|
|
1329
|
+
// @ts-expect-error pi-ai is a runtime dependency available inside Pi's process
|
|
1330
|
+
const piAi = await import('@mariozechner/pi-ai');
|
|
1331
|
+
_piSdkCache = { piSdk, piAi };
|
|
1332
|
+
return _piSdkCache;
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
/* fall through to argv[1] resolution */
|
|
1336
|
+
}
|
|
1337
|
+
// Resolve from Pi CLI binary (process.argv[1] → pi-coding-agent package root)
|
|
1338
|
+
const { realpathSync } = _require('node:fs');
|
|
1339
|
+
const { pathToFileURL } = _require('node:url');
|
|
1340
|
+
const { dirname, join } = _require('node:path');
|
|
1341
|
+
const piRealPath = realpathSync(process.argv[1] || '');
|
|
1342
|
+
const piPkgDir = dirname(dirname(piRealPath));
|
|
1343
|
+
const piSdkEntry = pathToFileURL(join(piPkgDir, 'dist', 'index.js')).href;
|
|
1344
|
+
const piAiEntry = pathToFileURL(join(piPkgDir, 'node_modules', '@mariozechner', 'pi-ai', 'dist', 'index.js')).href;
|
|
1345
|
+
const piSdk = await import(__rewriteRelativeImportExtension(piSdkEntry));
|
|
1346
|
+
const piAi = await import(__rewriteRelativeImportExtension(piAiEntry));
|
|
1347
|
+
_piSdkCache = { piSdk, piAi };
|
|
1348
|
+
return _piSdkCache;
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Create a Pi-compatible tool that proxies execution to the Hub via WebSocket.
|
|
1352
|
+
* Used to give sub-agents access to Hub tools (memory, context7, etc.).
|
|
1353
|
+
*/
|
|
1354
|
+
function createHubToolProxy(toolDef, hubClient) {
|
|
1355
|
+
return {
|
|
1356
|
+
name: toolDef.name,
|
|
1357
|
+
label: toolDef.label || toolDef.name,
|
|
1358
|
+
description: toolDef.description,
|
|
1359
|
+
parameters: toolDef.parameters,
|
|
1360
|
+
async execute(toolCallId, params) {
|
|
1361
|
+
if (!hubClient.connected) {
|
|
1362
|
+
return {
|
|
1363
|
+
content: [
|
|
1364
|
+
{ type: 'text', text: `Hub not connected — cannot execute ${toolDef.name}` },
|
|
1365
|
+
],
|
|
1366
|
+
details: undefined,
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
const id = hubClient.nextId();
|
|
1370
|
+
try {
|
|
1371
|
+
const response = await hubClient.send({
|
|
1372
|
+
id,
|
|
1373
|
+
type: 'tool',
|
|
1374
|
+
name: toolDef.name,
|
|
1375
|
+
toolCallId,
|
|
1376
|
+
params: (params ?? {}),
|
|
1377
|
+
});
|
|
1378
|
+
// Extract RETURN action result
|
|
1379
|
+
const returnAction = response.actions.find((a) => a.action === 'RETURN');
|
|
1380
|
+
if (returnAction && 'result' in returnAction) {
|
|
1381
|
+
const text = typeof returnAction.result === 'string'
|
|
1382
|
+
? returnAction.result
|
|
1383
|
+
: JSON.stringify(returnAction.result, null, 2);
|
|
1384
|
+
return { content: [{ type: 'text', text }], details: undefined };
|
|
1385
|
+
}
|
|
1386
|
+
return { content: [{ type: 'text', text: 'Done' }], details: undefined };
|
|
1387
|
+
}
|
|
1388
|
+
catch (err) {
|
|
1389
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1390
|
+
return {
|
|
1391
|
+
content: [{ type: 'text', text: `Hub tool error: ${msg}` }],
|
|
1392
|
+
details: undefined,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
},
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Run a sub-agent in-process using Pi's createAgentSession().
|
|
1400
|
+
* Sub-agents are created with noExtensions=true so they can't recursively
|
|
1401
|
+
* spawn further sub-agents (no task tool registered).
|
|
1402
|
+
* Sub-agents DO get Hub tools (memory, context7, etc.) via extensionFactories.
|
|
1403
|
+
* Only returns the final assistant text, not intermediate events.
|
|
1404
|
+
*/
|
|
1405
|
+
async function runSubAgent(agentConfig, task, hubClient, onProgress, signal) {
|
|
1406
|
+
const startTime = Date.now();
|
|
1407
|
+
const { piSdk, piAi } = await loadPiSdk();
|
|
1408
|
+
// Runtime-resolved dynamic imports — exact types unavailable statically
|
|
1409
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1410
|
+
const { createAgentSession, DefaultResourceLoader, SessionManager, createCodingTools, createReadOnlyTools, } = piSdk;
|
|
1411
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1412
|
+
const { getModel } = piAi;
|
|
1413
|
+
// Model — use agent's configured model (sub-agents typically use haiku for speed)
|
|
1414
|
+
const modelId = agentConfig.model || 'claude-haiku-4-5';
|
|
1415
|
+
const [provider, id] = modelId.includes('/')
|
|
1416
|
+
? modelId.split('/', 2)
|
|
1417
|
+
: ['anthropic', modelId];
|
|
1418
|
+
const subModel = getModel(provider, id);
|
|
1419
|
+
if (!subModel) {
|
|
1420
|
+
throw new Error(`Model "${modelId}" not available. ` +
|
|
1421
|
+
`Check that the ${provider} API key is configured ` +
|
|
1422
|
+
`(e.g. ${provider.toUpperCase().replace(/[^A-Z]/g, '_')}_API_KEY).`);
|
|
1423
|
+
}
|
|
1424
|
+
// Hub tools for this sub-agent (shared WebSocket connection)
|
|
1425
|
+
// Sub-agents get Hub tools (memory, context7, etc.) via extensionFactories
|
|
1426
|
+
// so they work in both driver and TUI mode.
|
|
1427
|
+
const hubTools = agentConfig.hubTools ?? [];
|
|
1428
|
+
// Resource loader — no extensions (prevents recursive task tool registration),
|
|
1429
|
+
// no skills, agent's system prompt injected directly.
|
|
1430
|
+
// Hub tools are injected via extensionFactories so sub-agents can use
|
|
1431
|
+
// memory_recall, context7_search, etc.
|
|
1432
|
+
const subLoader = new DefaultResourceLoader({
|
|
1433
|
+
cwd: process.cwd(),
|
|
1434
|
+
noExtensions: true,
|
|
1435
|
+
extensionFactories: hubTools.length > 0
|
|
1436
|
+
? [
|
|
1437
|
+
(pi) => {
|
|
1438
|
+
for (const toolDef of hubTools) {
|
|
1439
|
+
// Proxy object has the correct shape; cast needed because return type is Record<string, unknown>
|
|
1440
|
+
pi.registerTool(createHubToolProxy(toolDef, hubClient));
|
|
1441
|
+
}
|
|
1442
|
+
},
|
|
1443
|
+
]
|
|
1444
|
+
: [],
|
|
1445
|
+
systemPromptOverride: () => agentConfig.systemPrompt,
|
|
1446
|
+
});
|
|
1447
|
+
await subLoader.reload();
|
|
1448
|
+
// Select tools based on readOnly flag
|
|
1449
|
+
const cwd = process.cwd();
|
|
1450
|
+
const tools = agentConfig.readOnly ? createReadOnlyTools(cwd) : createCodingTools(cwd);
|
|
1451
|
+
const { session } = await createAgentSession({
|
|
1452
|
+
// subModel is already untyped (from dynamic import) — createAgentSession is also dynamically imported
|
|
1453
|
+
model: subModel,
|
|
1454
|
+
thinkingLevel: (agentConfig.thinkingLevel || 'xhigh'),
|
|
1455
|
+
tools,
|
|
1456
|
+
resourceLoader: subLoader,
|
|
1457
|
+
sessionManager: SessionManager.inMemory('/tmp'),
|
|
1458
|
+
});
|
|
1459
|
+
await session.bindExtensions({});
|
|
1460
|
+
// Subscribe to sub-agent events for live progress tracking
|
|
1461
|
+
if (onProgress) {
|
|
1462
|
+
try {
|
|
1463
|
+
session.subscribe?.((event) => {
|
|
1464
|
+
try {
|
|
1465
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1466
|
+
const evt = event;
|
|
1467
|
+
const elapsed = Date.now() - startTime;
|
|
1468
|
+
// Handle streaming message updates (thinking + text tokens)
|
|
1469
|
+
if (evt.type === 'message_update' && evt.assistantMessageEvent) {
|
|
1470
|
+
const ame = evt.assistantMessageEvent;
|
|
1471
|
+
if (ame.type === 'thinking_delta' && ame.delta) {
|
|
1472
|
+
onProgress({
|
|
1473
|
+
agentName: agentConfig.name,
|
|
1474
|
+
status: 'thinking_delta',
|
|
1475
|
+
delta: ame.delta,
|
|
1476
|
+
elapsed,
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
else if (ame.type === 'text_delta' && ame.delta) {
|
|
1480
|
+
onProgress({
|
|
1481
|
+
agentName: agentConfig.name,
|
|
1482
|
+
status: 'text_delta',
|
|
1483
|
+
delta: ame.delta,
|
|
1484
|
+
elapsed,
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (evt.type === 'tool_execution_start' || evt.type === 'tool_call') {
|
|
1490
|
+
const toolName = evt.toolName || evt.name || evt.tool || 'unknown';
|
|
1491
|
+
let toolArgs = '';
|
|
1492
|
+
if (evt.args && typeof evt.args === 'object') {
|
|
1493
|
+
const args = evt.args;
|
|
1494
|
+
if (args.command)
|
|
1495
|
+
toolArgs = String(args.command).slice(0, 60);
|
|
1496
|
+
else if (args.filePath || args.path)
|
|
1497
|
+
toolArgs = String(args.filePath || args.path);
|
|
1498
|
+
else if (args.pattern)
|
|
1499
|
+
toolArgs = String(args.pattern).slice(0, 40);
|
|
1500
|
+
else {
|
|
1501
|
+
const first = Object.values(args)[0];
|
|
1502
|
+
if (first)
|
|
1503
|
+
toolArgs = String(first).slice(0, 40);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
onProgress({
|
|
1507
|
+
agentName: agentConfig.name,
|
|
1508
|
+
status: 'tool_start',
|
|
1509
|
+
currentTool: toolName,
|
|
1510
|
+
currentToolArgs: toolArgs,
|
|
1511
|
+
elapsed,
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
else if (evt.type === 'tool_execution_end' || evt.type === 'tool_result') {
|
|
1515
|
+
onProgress({
|
|
1516
|
+
agentName: agentConfig.name,
|
|
1517
|
+
status: 'tool_end',
|
|
1518
|
+
elapsed,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
catch {
|
|
1523
|
+
/* ignore — progress tracking is best-effort */
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
catch {
|
|
1528
|
+
/* ignore — subscribe may not be available */
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
// Abort signal support — cancel sub-agent when user presses Esc
|
|
1532
|
+
if (signal) {
|
|
1533
|
+
if (signal.aborted) {
|
|
1534
|
+
throw new Error('Aborted');
|
|
1535
|
+
}
|
|
1536
|
+
const onAbort = () => {
|
|
1537
|
+
log(`Sub-agent ${agentConfig.name} aborted by signal`);
|
|
1538
|
+
try {
|
|
1539
|
+
session.abort?.();
|
|
1540
|
+
}
|
|
1541
|
+
catch {
|
|
1542
|
+
/* ignore */
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1546
|
+
}
|
|
1547
|
+
log(`Sub-agent started: ${agentConfig.name} (model: ${modelId})`);
|
|
1548
|
+
try {
|
|
1549
|
+
await session.prompt(task);
|
|
1550
|
+
// Only return the final assistant text — NOT intermediate JSONL events
|
|
1551
|
+
const output = session.getLastAssistantText?.() || '(no output)';
|
|
1552
|
+
const duration = Date.now() - startTime;
|
|
1553
|
+
log(`Sub-agent ${agentConfig.name} completed in ${duration}ms`);
|
|
1554
|
+
// Best-effort token extraction from sub-agent session messages
|
|
1555
|
+
const subTokens = { input: 0, output: 0, cost: 0 };
|
|
1556
|
+
try {
|
|
1557
|
+
const branch = session.sessionManager?.getBranch?.() || [];
|
|
1558
|
+
for (const entry of branch) {
|
|
1559
|
+
if (entry.type === 'message') {
|
|
1560
|
+
const msg = entry.message;
|
|
1561
|
+
if (msg.role === 'assistant' && msg.usage) {
|
|
1562
|
+
subTokens.input += msg.usage.input;
|
|
1563
|
+
subTokens.output += msg.usage.output;
|
|
1564
|
+
subTokens.cost += msg.usage.cost.total;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
catch {
|
|
1570
|
+
/* ignore — token extraction is best-effort */
|
|
1571
|
+
}
|
|
1572
|
+
return { output: truncateOutput(output.trim()), duration, tokens: subTokens };
|
|
1573
|
+
}
|
|
1574
|
+
catch (err) {
|
|
1575
|
+
try {
|
|
1576
|
+
session.abort?.();
|
|
1577
|
+
}
|
|
1578
|
+
catch {
|
|
1579
|
+
/* ignore */
|
|
1580
|
+
}
|
|
1581
|
+
throw err;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
export default agentuityCoderHub;
|
|
1585
|
+
//# sourceMappingURL=index.js.map
|