pilotswarm-cli 0.1.14 → 0.1.15
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 +3 -0
- package/bin/tui.js +5 -2
- package/package.json +4 -3
- package/src/app.js +33 -0
- package/src/bootstrap-env.js +1 -37
- package/src/node-sdk-transport.js +381 -61
- package/src/platform.js +43 -11
- package/src/plugin-config.js +239 -0
- package/src/portal.js +7 -0
package/README.md
CHANGED
|
@@ -22,6 +22,9 @@ npx pilotswarm local --env .env --plugin ./plugin --worker ./worker-module.js
|
|
|
22
22
|
|
|
23
23
|
`pilotswarm-cli` provides the shipped TUI. Your app customizes it with `plugin/plugin.json`, `plugin/agents/*.agent.md`, `plugin/skills/*/SKILL.md`, and optional worker-side tools.
|
|
24
24
|
|
|
25
|
+
Portal/runtime helpers that are intentionally shared with `pilotswarm-web`
|
|
26
|
+
are exported from `pilotswarm-cli/portal`.
|
|
27
|
+
|
|
25
28
|
Common docs:
|
|
26
29
|
|
|
27
30
|
- CLI apps: `https://github.com/affandar/PilotSwarm/blob/main/docs/cli/building-cli-apps.md`
|
package/bin/tui.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// Force the shipped TUI onto production React/Ink unless the caller
|
|
4
|
+
// explicitly opts into another environment for debugging.
|
|
5
|
+
process.env.NODE_ENV ??= "production";
|
|
5
6
|
|
|
7
|
+
const { parseCliIntoEnv } = await import("../src/bootstrap-env.js");
|
|
6
8
|
const config = parseCliIntoEnv(process.argv.slice(2));
|
|
9
|
+
const { startTuiApp } = await import("../src/index.js");
|
|
7
10
|
await startTuiApp(config);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pilotswarm-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Terminal UI for PilotSwarm.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"main": "./src/index.js",
|
|
11
11
|
"exports": {
|
|
12
|
-
".": "./src/index.js"
|
|
12
|
+
".": "./src/index.js",
|
|
13
|
+
"./portal": "./src/portal.js"
|
|
13
14
|
},
|
|
14
15
|
"scripts": {
|
|
15
16
|
"build": "echo 'pilotswarm-cli: no build step (plain JS)'",
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"ink": "^6.8.0",
|
|
39
|
-
"pilotswarm-sdk": "^0.1.
|
|
40
|
+
"pilotswarm-sdk": "^0.1.15",
|
|
40
41
|
"pilotswarm-ui-core": "0.1.0",
|
|
41
42
|
"pilotswarm-ui-react": "0.1.0",
|
|
42
43
|
"react": "^19.2.4"
|
package/src/app.js
CHANGED
|
@@ -231,6 +231,7 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
|
|
|
231
231
|
const focus = controller.getState().ui.focusRegion;
|
|
232
232
|
const modal = controller.getState().ui.modal;
|
|
233
233
|
const inspectorTab = controller.getState().ui.inspectorTab;
|
|
234
|
+
const fullscreenPane = controller.getState().ui.fullscreenPane || null;
|
|
234
235
|
const plainShortcut = isPlainShortcutKey(key);
|
|
235
236
|
const matchesCtrlKey = (name, controlChar) => key.ctrl
|
|
236
237
|
&& (key.name === name || input === name || input === controlChar);
|
|
@@ -357,6 +358,9 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
|
|
|
357
358
|
return;
|
|
358
359
|
}
|
|
359
360
|
if (key.tab) {
|
|
361
|
+
if (focus === "prompt" && controller.acceptPromptReferenceAutocomplete()) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
360
364
|
controller.handleCommand(UI_COMMANDS.FOCUS_NEXT).catch(() => {});
|
|
361
365
|
return;
|
|
362
366
|
}
|
|
@@ -364,6 +368,15 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
|
|
|
364
368
|
controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {});
|
|
365
369
|
return;
|
|
366
370
|
}
|
|
371
|
+
if (key.escape && fullscreenPane) {
|
|
372
|
+
if (focus === "prompt") {
|
|
373
|
+
controller.setPrompt("");
|
|
374
|
+
controller.setFocus(fullscreenPane);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
controller.handleCommand(UI_COMMANDS.TOGGLE_PANE_FULLSCREEN).catch(() => {});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
367
380
|
if (key.escape) {
|
|
368
381
|
if (focus === "prompt") {
|
|
369
382
|
controller.setPrompt("");
|
|
@@ -397,6 +410,14 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
|
|
|
397
410
|
controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {});
|
|
398
411
|
return;
|
|
399
412
|
}
|
|
413
|
+
if (focus === "inspector" && inspectorTab === "files" && input === "u") {
|
|
414
|
+
controller.handleCommand(UI_COMMANDS.OPEN_ARTIFACT_UPLOAD).catch(() => {});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (focus === "inspector" && inspectorTab === "files" && input === "a") {
|
|
418
|
+
controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
400
421
|
if (focus === "inspector" && inspectorTab === "history" && input === "f") {
|
|
401
422
|
controller.handleCommand(UI_COMMANDS.OPEN_HISTORY_FORMAT).catch(() => {});
|
|
402
423
|
return;
|
|
@@ -413,6 +434,10 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
|
|
|
413
434
|
controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {});
|
|
414
435
|
return;
|
|
415
436
|
}
|
|
437
|
+
if (focus !== "prompt" && input === "v") {
|
|
438
|
+
controller.handleCommand(UI_COMMANDS.TOGGLE_PANE_FULLSCREEN).catch(() => {});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
416
441
|
if (focus === "inspector" && inspectorTab === "files" && plainShortcut && input === "o") {
|
|
417
442
|
controller.handleCommand(UI_COMMANDS.OPEN_SELECTED_FILE).catch(() => {});
|
|
418
443
|
return;
|
|
@@ -436,6 +461,14 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
|
|
|
436
461
|
controller.handleCommand(UI_COMMANDS.GROW_RIGHT_PANE).catch(() => {});
|
|
437
462
|
return;
|
|
438
463
|
}
|
|
464
|
+
if (focus !== "prompt" && input === "{") {
|
|
465
|
+
controller.handleCommand(UI_COMMANDS.SHRINK_SESSION_PANE).catch(() => {});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (focus !== "prompt" && input === "}") {
|
|
469
|
+
controller.handleCommand(UI_COMMANDS.GROW_SESSION_PANE).catch(() => {});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
439
472
|
|
|
440
473
|
if (focus === "prompt") {
|
|
441
474
|
if (isCtrlA) {
|
package/src/bootstrap-env.js
CHANGED
|
@@ -3,21 +3,10 @@ import { execFileSync } from "node:child_process";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { resolveTuiBranding } from "./plugin-config.js";
|
|
6
7
|
|
|
7
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const pkgRoot = path.resolve(__dirname, "..");
|
|
9
|
-
const defaultTuiSplashPath = path.join(pkgRoot, "tui-splash.txt");
|
|
10
|
-
|
|
11
|
-
function readPluginMetadata(pluginDir) {
|
|
12
|
-
if (!pluginDir) return null;
|
|
13
|
-
const pluginJsonPath = path.join(pluginDir, "plugin.json");
|
|
14
|
-
if (!fs.existsSync(pluginJsonPath)) return null;
|
|
15
|
-
try {
|
|
16
|
-
return JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"));
|
|
17
|
-
} catch (error) {
|
|
18
|
-
throw new Error(`Failed to parse plugin metadata: ${pluginJsonPath}: ${error.message}`);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
10
|
|
|
22
11
|
function resolvePluginDir(flags) {
|
|
23
12
|
if (flags.plugin) return path.resolve(flags.plugin);
|
|
@@ -52,31 +41,6 @@ function resolveSystemMessage(flags) {
|
|
|
52
41
|
return undefined;
|
|
53
42
|
}
|
|
54
43
|
|
|
55
|
-
function resolveTuiBranding(pluginDir) {
|
|
56
|
-
const pluginMeta = readPluginMetadata(pluginDir);
|
|
57
|
-
const tui = pluginMeta?.tui;
|
|
58
|
-
let defaultSplash = "{bold}{cyan-fg}PilotSwarm{/cyan-fg}{/bold}";
|
|
59
|
-
if (fs.existsSync(defaultTuiSplashPath)) {
|
|
60
|
-
defaultSplash = fs.readFileSync(defaultTuiSplashPath, "utf-8").trimEnd();
|
|
61
|
-
}
|
|
62
|
-
if (!tui || typeof tui !== "object") {
|
|
63
|
-
return { title: "PilotSwarm", splash: defaultSplash };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const title = typeof tui.title === "string" && tui.title.trim() ? tui.title.trim() : "PilotSwarm";
|
|
67
|
-
let splash = defaultSplash;
|
|
68
|
-
if (typeof tui.splash === "string" && tui.splash.trim()) {
|
|
69
|
-
splash = tui.splash;
|
|
70
|
-
} else if (typeof tui.splashFile === "string" && tui.splashFile.trim()) {
|
|
71
|
-
const splashPath = path.resolve(pluginDir, tui.splashFile);
|
|
72
|
-
if (!fs.existsSync(splashPath)) {
|
|
73
|
-
throw new Error(`TUI splash file not found: ${splashPath}`);
|
|
74
|
-
}
|
|
75
|
-
splash = fs.readFileSync(splashPath, "utf-8").trimEnd();
|
|
76
|
-
}
|
|
77
|
-
return { title, splash };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
44
|
function loadEnvFile(envFile) {
|
|
81
45
|
if (!fs.existsSync(envFile)) return;
|
|
82
46
|
const envContent = fs.readFileSync(envFile, "utf-8");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import https from "node:https";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import {
|
|
@@ -10,9 +11,65 @@ import {
|
|
|
10
11
|
SessionBlobStore,
|
|
11
12
|
} from "pilotswarm-sdk";
|
|
12
13
|
import { startEmbeddedWorkers, stopEmbeddedWorkers } from "./embedded-workers.js";
|
|
14
|
+
import { getPluginDirsFromEnv } from "./plugin-config.js";
|
|
13
15
|
|
|
14
16
|
const EXPORTS_DIR = path.join(os.homedir(), "pilotswarm-exports");
|
|
15
17
|
fs.mkdirSync(EXPORTS_DIR, { recursive: true });
|
|
18
|
+
const K8S_SERVICE_ACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount";
|
|
19
|
+
|
|
20
|
+
function fileExists(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.existsSync(filePath);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getInClusterK8sPaths() {
|
|
29
|
+
const baseDir = process.env.PILOTSWARM_K8S_SERVICE_ACCOUNT_DIR || K8S_SERVICE_ACCOUNT_DIR;
|
|
30
|
+
return {
|
|
31
|
+
tokenPath: process.env.PILOTSWARM_K8S_TOKEN_PATH || path.join(baseDir, "token"),
|
|
32
|
+
caPath: process.env.PILOTSWARM_K8S_CA_PATH || path.join(baseDir, "ca.crt"),
|
|
33
|
+
namespacePath: process.env.PILOTSWARM_K8S_NAMESPACE_PATH || path.join(baseDir, "namespace"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readOptionalTextFile(filePath) {
|
|
38
|
+
try {
|
|
39
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
40
|
+
} catch {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasInClusterK8sAccess() {
|
|
46
|
+
const { tokenPath, caPath } = getInClusterK8sPaths();
|
|
47
|
+
return Boolean(process.env.KUBERNETES_SERVICE_HOST)
|
|
48
|
+
&& fileExists(tokenPath)
|
|
49
|
+
&& fileExists(caPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getInClusterK8sConfig() {
|
|
53
|
+
if (!hasInClusterK8sAccess()) return null;
|
|
54
|
+
|
|
55
|
+
const { tokenPath, caPath, namespacePath } = getInClusterK8sPaths();
|
|
56
|
+
return {
|
|
57
|
+
host: String(process.env.KUBERNETES_SERVICE_HOST || "").trim(),
|
|
58
|
+
port: Number(process.env.KUBERNETES_SERVICE_PORT || 443) || 443,
|
|
59
|
+
token: readOptionalTextFile(tokenPath),
|
|
60
|
+
ca: fs.readFileSync(caPath),
|
|
61
|
+
namespace: String(process.env.K8S_NAMESPACE || "").trim() || readOptionalTextFile(namespacePath) || "default",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasExplicitKubectlConfig() {
|
|
66
|
+
return Boolean((process.env.K8S_CONTEXT || "").trim() || (process.env.KUBECONFIG || "").trim());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isKubectlAvailable() {
|
|
70
|
+
const result = spawnSync("kubectl", ["version", "--client=true"], { stdio: "ignore" });
|
|
71
|
+
return !result.error;
|
|
72
|
+
}
|
|
16
73
|
|
|
17
74
|
function stripAnsi(value) {
|
|
18
75
|
return String(value || "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -105,9 +162,14 @@ function buildLogEntry(line, counter) {
|
|
|
105
162
|
const prefixMatch = line.match(/^\[pod\/([^/\]]+)/);
|
|
106
163
|
const podName = prefixMatch ? prefixMatch[1] : "unknown";
|
|
107
164
|
const rawLine = trimLogText(stripAnsi(line.replace(/^\[pod\/[^\]]+\]\s*/, "")).trim());
|
|
108
|
-
const orchMatch = rawLine.match(/\b(instance_id|orchestration_id)=(session-[^\s,]+)/i)
|
|
165
|
+
const orchMatch = rawLine.match(/\b(?:instance_id|orchestration_id|orch)=(session-[^\s,]+)/i)
|
|
109
166
|
|| rawLine.match(/\b(session-[0-9a-f-]{8,})\b/i);
|
|
110
|
-
const
|
|
167
|
+
const parsedOrchId = orchMatch ? orchMatch[1] : null;
|
|
168
|
+
const sessionIdMatch = rawLine.match(/\b(?:sessionId|session|durableSessionId)=([0-9a-f-]{8,})\b/i);
|
|
169
|
+
const sessionId = sessionIdMatch
|
|
170
|
+
? sessionIdMatch[1]
|
|
171
|
+
: (parsedOrchId && parsedOrchId.startsWith("session-") ? parsedOrchId.slice("session-".length) : null);
|
|
172
|
+
const orchId = parsedOrchId || (sessionId ? `session-${sessionId}` : null);
|
|
111
173
|
const category = rawLine.includes("duroxide::activity")
|
|
112
174
|
? "activity"
|
|
113
175
|
: rawLine.includes("duroxide::orchestration") || rawLine.includes("::orchestration")
|
|
@@ -120,6 +182,7 @@ function buildLogEntry(line, counter) {
|
|
|
120
182
|
podName,
|
|
121
183
|
level: normalizeLogLevel(rawLine),
|
|
122
184
|
orchId,
|
|
185
|
+
sessionId,
|
|
123
186
|
category,
|
|
124
187
|
rawLine,
|
|
125
188
|
message: extractPrettyLogMessage(rawLine),
|
|
@@ -127,6 +190,22 @@ function buildLogEntry(line, counter) {
|
|
|
127
190
|
};
|
|
128
191
|
}
|
|
129
192
|
|
|
193
|
+
function buildSyntheticLogEntry({ message, level = "info", podName = "k8s", counter = 0 }) {
|
|
194
|
+
const safeMessage = trimLogText(String(message || "").trim());
|
|
195
|
+
return {
|
|
196
|
+
id: `log:${Date.now()}:${counter}`,
|
|
197
|
+
time: extractLogTime(safeMessage),
|
|
198
|
+
podName,
|
|
199
|
+
level,
|
|
200
|
+
orchId: null,
|
|
201
|
+
sessionId: null,
|
|
202
|
+
category: "log",
|
|
203
|
+
rawLine: safeMessage,
|
|
204
|
+
message: safeMessage,
|
|
205
|
+
prettyMessage: safeMessage,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
130
209
|
function sanitizeArtifactFilename(filename) {
|
|
131
210
|
return String(filename || "").replace(/[/\\]/g, "_");
|
|
132
211
|
}
|
|
@@ -179,13 +258,6 @@ function isTerminalSendError(error) {
|
|
|
179
258
|
return /instance is terminal|terminal orchestration|cannot accept new messages/i.test(message);
|
|
180
259
|
}
|
|
181
260
|
|
|
182
|
-
function getPluginDirsFromEnv() {
|
|
183
|
-
return String(process.env.PLUGIN_DIRS || "")
|
|
184
|
-
.split(",")
|
|
185
|
-
.map((value) => value.trim())
|
|
186
|
-
.filter(Boolean);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
261
|
function normalizeCreatableAgent(agent) {
|
|
190
262
|
const name = String(agent?.name || "").trim();
|
|
191
263
|
if (!name) return null;
|
|
@@ -235,8 +307,8 @@ function loadSessionCreationMetadataFromPluginDirs(pluginDirs = []) {
|
|
|
235
307
|
}
|
|
236
308
|
|
|
237
309
|
function buildTerminalSendError(sessionId, session) {
|
|
238
|
-
if (session?.status === "failed" || session?.orchestrationStatus === "Failed") {
|
|
239
|
-
return `Session ${sessionId.slice(0, 8)} is a
|
|
310
|
+
if (session?.status === "failed" || session?.status === "cancelled" || session?.orchestrationStatus === "Failed") {
|
|
311
|
+
return `Session ${sessionId.slice(0, 8)} is a terminal orchestration and cannot accept new messages.`;
|
|
240
312
|
}
|
|
241
313
|
|
|
242
314
|
const statusLabel = String(session?.orchestrationStatus || session?.status || "Unknown");
|
|
@@ -256,10 +328,12 @@ export class NodeSdkTransport {
|
|
|
256
328
|
this.allowedAgentNames = [];
|
|
257
329
|
this.creatableAgents = [];
|
|
258
330
|
this.logProc = null;
|
|
331
|
+
this.logTailHandle = null;
|
|
259
332
|
this.logBuffer = "";
|
|
260
333
|
this.logRestartTimer = null;
|
|
261
334
|
this.logSubscribers = new Set();
|
|
262
335
|
this.logEntryCounter = 0;
|
|
336
|
+
this.kubectlAvailable = null;
|
|
263
337
|
}
|
|
264
338
|
|
|
265
339
|
async start() {
|
|
@@ -314,12 +388,30 @@ export class NodeSdkTransport {
|
|
|
314
388
|
}
|
|
315
389
|
|
|
316
390
|
getLogConfig() {
|
|
317
|
-
const
|
|
391
|
+
const hasInClusterConfig = hasInClusterK8sAccess();
|
|
392
|
+
const hasKubectlConfig = hasExplicitKubectlConfig();
|
|
393
|
+
if (hasInClusterConfig) {
|
|
394
|
+
return {
|
|
395
|
+
available: true,
|
|
396
|
+
availabilityReason: "",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (hasKubectlConfig) {
|
|
401
|
+
if (this.kubectlAvailable == null) {
|
|
402
|
+
this.kubectlAvailable = isKubectlAvailable();
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
available: this.kubectlAvailable,
|
|
406
|
+
availabilityReason: this.kubectlAvailable
|
|
407
|
+
? ""
|
|
408
|
+
: "Log tailing disabled: kubectl is not installed in this environment.",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
318
412
|
return {
|
|
319
|
-
available:
|
|
320
|
-
availabilityReason:
|
|
321
|
-
? ""
|
|
322
|
-
: "Log tailing disabled: no K8S_CONTEXT configured in the env file.",
|
|
413
|
+
available: false,
|
|
414
|
+
availabilityReason: "Log tailing disabled: no K8S_CONTEXT/KUBECONFIG or in-cluster Kubernetes access detected.",
|
|
323
415
|
};
|
|
324
416
|
}
|
|
325
417
|
|
|
@@ -375,11 +467,11 @@ export class NodeSdkTransport {
|
|
|
375
467
|
if (!session) {
|
|
376
468
|
throw new Error(`Session ${sessionId.slice(0, 8)} was not found.`);
|
|
377
469
|
}
|
|
378
|
-
if (session.status === "failed" || session.orchestrationStatus === "Failed") {
|
|
470
|
+
if (session.status === "failed" || session.status === "cancelled" || session.orchestrationStatus === "Failed") {
|
|
379
471
|
throw new Error(buildTerminalSendError(sessionId, session));
|
|
380
472
|
}
|
|
381
473
|
if (
|
|
382
|
-
session.status === "completed"
|
|
474
|
+
(session.status === "completed" || session.status === "cancelled")
|
|
383
475
|
&& session.parentSessionId
|
|
384
476
|
&& !session.isSystem
|
|
385
477
|
&& !session.cronActive
|
|
@@ -480,6 +572,36 @@ export class NodeSdkTransport {
|
|
|
480
572
|
};
|
|
481
573
|
}
|
|
482
574
|
|
|
575
|
+
async uploadArtifactContent(sessionId, filename, content, contentType = guessArtifactContentType(filename)) {
|
|
576
|
+
if (!this.artifactStore) {
|
|
577
|
+
throw new Error("Artifact store is not available for this transport.");
|
|
578
|
+
}
|
|
579
|
+
const safeSessionId = String(sessionId || "").trim();
|
|
580
|
+
const safeFilename = path.basename(String(filename || "").trim());
|
|
581
|
+
const safeContent = typeof content === "string" ? content : String(content || "");
|
|
582
|
+
if (!safeSessionId) {
|
|
583
|
+
throw new Error("Session id is required for artifact upload.");
|
|
584
|
+
}
|
|
585
|
+
if (!safeFilename) {
|
|
586
|
+
throw new Error("Filename is required for artifact upload.");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await this.artifactStore.uploadArtifact(
|
|
590
|
+
safeSessionId,
|
|
591
|
+
safeFilename,
|
|
592
|
+
safeContent,
|
|
593
|
+
contentType || guessArtifactContentType(safeFilename),
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
sessionId: safeSessionId,
|
|
598
|
+
filename: safeFilename,
|
|
599
|
+
resolvedPath: safeFilename,
|
|
600
|
+
sizeBytes: Buffer.byteLength(safeContent, "utf8"),
|
|
601
|
+
contentType: contentType || guessArtifactContentType(safeFilename),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
483
605
|
getArtifactExportDirectory() {
|
|
484
606
|
return EXPORTS_DIR;
|
|
485
607
|
}
|
|
@@ -575,10 +697,19 @@ export class NodeSdkTransport {
|
|
|
575
697
|
}
|
|
576
698
|
|
|
577
699
|
emitLogEntry(entry) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
700
|
+
if (!this._logBatch) this._logBatch = [];
|
|
701
|
+
this._logBatch.push(entry);
|
|
702
|
+
if (!this._logBatchTimer) {
|
|
703
|
+
this._logBatchTimer = setTimeout(() => {
|
|
704
|
+
const batch = this._logBatch;
|
|
705
|
+
this._logBatch = [];
|
|
706
|
+
this._logBatchTimer = null;
|
|
707
|
+
for (const handler of this.logSubscribers) {
|
|
708
|
+
try {
|
|
709
|
+
handler(batch);
|
|
710
|
+
} catch {}
|
|
711
|
+
}
|
|
712
|
+
}, 250);
|
|
582
713
|
}
|
|
583
714
|
}
|
|
584
715
|
|
|
@@ -592,9 +723,208 @@ export class NodeSdkTransport {
|
|
|
592
723
|
}, 5000);
|
|
593
724
|
}
|
|
594
725
|
|
|
595
|
-
|
|
726
|
+
emitSyntheticLogMessage(message, level = "info", podName = "k8s") {
|
|
727
|
+
this.logEntryCounter += 1;
|
|
728
|
+
this.emitLogEntry(buildSyntheticLogEntry({
|
|
729
|
+
message,
|
|
730
|
+
level,
|
|
731
|
+
podName,
|
|
732
|
+
counter: this.logEntryCounter,
|
|
733
|
+
}));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async listPodsFromKubeApi(config, labelSelector) {
|
|
737
|
+
const params = new URLSearchParams();
|
|
738
|
+
if (labelSelector) params.set("labelSelector", labelSelector);
|
|
739
|
+
const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods${params.size > 0 ? `?${params.toString()}` : ""}`;
|
|
740
|
+
|
|
741
|
+
return await new Promise((resolve, reject) => {
|
|
742
|
+
const req = https.request({
|
|
743
|
+
method: "GET",
|
|
744
|
+
hostname: config.host,
|
|
745
|
+
port: config.port,
|
|
746
|
+
path: pathName,
|
|
747
|
+
ca: config.ca,
|
|
748
|
+
headers: {
|
|
749
|
+
Authorization: `Bearer ${config.token}`,
|
|
750
|
+
Accept: "application/json",
|
|
751
|
+
},
|
|
752
|
+
}, (res) => {
|
|
753
|
+
let body = "";
|
|
754
|
+
res.setEncoding("utf8");
|
|
755
|
+
res.on("data", (chunk) => {
|
|
756
|
+
body += chunk;
|
|
757
|
+
});
|
|
758
|
+
res.on("end", () => {
|
|
759
|
+
if ((res.statusCode || 0) >= 400) {
|
|
760
|
+
reject(new Error(
|
|
761
|
+
`Kubernetes API pod list failed (${res.statusCode}): ${trimLogText(body || res.statusMessage || "unknown error")}`,
|
|
762
|
+
));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const payload = JSON.parse(body || "{}");
|
|
767
|
+
const items = Array.isArray(payload?.items) ? payload.items : [];
|
|
768
|
+
resolve(items
|
|
769
|
+
.map((item) => String(item?.metadata?.name || "").trim())
|
|
770
|
+
.filter(Boolean));
|
|
771
|
+
} catch (error) {
|
|
772
|
+
reject(error);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
req.on("error", reject);
|
|
778
|
+
req.end();
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
streamPodLogsFromKubeApi(config, podName, handle, options = {}) {
|
|
783
|
+
const params = new URLSearchParams({
|
|
784
|
+
follow: "true",
|
|
785
|
+
timestamps: "true",
|
|
786
|
+
tailLines: String(options.tailLines ?? 500),
|
|
787
|
+
});
|
|
788
|
+
const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods/${encodeURIComponent(podName)}/log?${params.toString()}`;
|
|
789
|
+
|
|
790
|
+
return new Promise((resolve, reject) => {
|
|
791
|
+
let buffer = "";
|
|
792
|
+
let settled = false;
|
|
793
|
+
let response = null;
|
|
794
|
+
|
|
795
|
+
const finish = (error = null) => {
|
|
796
|
+
if (settled) return;
|
|
797
|
+
settled = true;
|
|
798
|
+
|
|
799
|
+
if (buffer.trim()) {
|
|
800
|
+
this.logEntryCounter += 1;
|
|
801
|
+
this.emitLogEntry(buildLogEntry(`[pod/${podName}] ${buffer.trim()}`, this.logEntryCounter));
|
|
802
|
+
buffer = "";
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (response) {
|
|
806
|
+
handle.responses.delete(response);
|
|
807
|
+
}
|
|
808
|
+
handle.requests.delete(request);
|
|
809
|
+
|
|
810
|
+
if (error) reject(error);
|
|
811
|
+
else resolve();
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const request = https.request({
|
|
815
|
+
method: "GET",
|
|
816
|
+
hostname: config.host,
|
|
817
|
+
port: config.port,
|
|
818
|
+
path: pathName,
|
|
819
|
+
ca: config.ca,
|
|
820
|
+
headers: {
|
|
821
|
+
Authorization: `Bearer ${config.token}`,
|
|
822
|
+
Accept: "*/*",
|
|
823
|
+
},
|
|
824
|
+
}, (res) => {
|
|
825
|
+
response = res;
|
|
826
|
+
handle.responses.add(res);
|
|
827
|
+
|
|
828
|
+
if ((res.statusCode || 0) >= 400) {
|
|
829
|
+
let body = "";
|
|
830
|
+
res.setEncoding("utf8");
|
|
831
|
+
res.on("data", (chunk) => {
|
|
832
|
+
body += chunk;
|
|
833
|
+
});
|
|
834
|
+
res.on("end", () => {
|
|
835
|
+
finish(new Error(
|
|
836
|
+
`Kubernetes log stream failed for ${podName} (${res.statusCode}): ${trimLogText(body || res.statusMessage || "unknown error")}`,
|
|
837
|
+
));
|
|
838
|
+
});
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
res.setEncoding("utf8");
|
|
843
|
+
res.on("data", (chunk) => {
|
|
844
|
+
buffer += chunk;
|
|
845
|
+
const lines = buffer.split("\n");
|
|
846
|
+
buffer = lines.pop() || "";
|
|
847
|
+
for (const line of lines) {
|
|
848
|
+
if (!line.trim()) continue;
|
|
849
|
+
this.logEntryCounter += 1;
|
|
850
|
+
this.emitLogEntry(buildLogEntry(`[pod/${podName}] ${line}`, this.logEntryCounter));
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
res.on("end", () => finish());
|
|
854
|
+
res.on("close", () => finish());
|
|
855
|
+
res.on("error", (error) => finish(error));
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
handle.requests.add(request);
|
|
859
|
+
request.on("error", (error) => finish(error));
|
|
860
|
+
request.end();
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
startInClusterLogProcess() {
|
|
865
|
+
const config = getInClusterK8sConfig();
|
|
866
|
+
if (!config || this.logTailHandle) return;
|
|
867
|
+
|
|
868
|
+
const labelSelector = process.env.K8S_POD_LABEL || "app.kubernetes.io/component=worker";
|
|
869
|
+
const handle = {
|
|
870
|
+
stopped: false,
|
|
871
|
+
requests: new Set(),
|
|
872
|
+
responses: new Set(),
|
|
873
|
+
stop: () => {
|
|
874
|
+
if (handle.stopped) return;
|
|
875
|
+
handle.stopped = true;
|
|
876
|
+
for (const response of handle.responses) {
|
|
877
|
+
try { response.destroy(); } catch {}
|
|
878
|
+
}
|
|
879
|
+
handle.responses.clear();
|
|
880
|
+
for (const request of handle.requests) {
|
|
881
|
+
try { request.destroy(); } catch {}
|
|
882
|
+
}
|
|
883
|
+
handle.requests.clear();
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
this.logTailHandle = handle;
|
|
887
|
+
|
|
888
|
+
this.listPodsFromKubeApi(config, labelSelector)
|
|
889
|
+
.then(async (podNames) => {
|
|
890
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
891
|
+
if (podNames.length === 0) {
|
|
892
|
+
this.emitSyntheticLogMessage(
|
|
893
|
+
`No pods matched label selector ${JSON.stringify(labelSelector)} in namespace ${config.namespace}.`,
|
|
894
|
+
"warn",
|
|
895
|
+
);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const results = await Promise.allSettled(
|
|
900
|
+
podNames.map((podName) => this.streamPodLogsFromKubeApi(config, podName, handle)),
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
904
|
+
for (const result of results) {
|
|
905
|
+
if (result.status === "fulfilled") continue;
|
|
906
|
+
this.emitSyntheticLogMessage(result.reason?.message || String(result.reason), "error");
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
.catch((error) => {
|
|
910
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
911
|
+
this.emitSyntheticLogMessage(error?.message || String(error), "error");
|
|
912
|
+
})
|
|
913
|
+
.finally(() => {
|
|
914
|
+
if (this.logTailHandle === handle) {
|
|
915
|
+
this.logTailHandle = null;
|
|
916
|
+
}
|
|
917
|
+
if (!handle.stopped) {
|
|
918
|
+
this.scheduleLogRestart();
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
startKubectlLogProcess() {
|
|
924
|
+
if (this.logProc) return;
|
|
925
|
+
|
|
596
926
|
const config = this.getLogConfig();
|
|
597
|
-
if (!config.available
|
|
927
|
+
if (!config.available) return;
|
|
598
928
|
|
|
599
929
|
const k8sContext = process.env.K8S_CONTEXT || "";
|
|
600
930
|
const k8sNamespace = process.env.K8S_NAMESPACE || "copilot-runtime";
|
|
@@ -626,53 +956,32 @@ export class NodeSdkTransport {
|
|
|
626
956
|
this.logProc.stderr.on("data", (chunk) => {
|
|
627
957
|
const text = stripAnsi(chunk.toString()).trim();
|
|
628
958
|
if (!text) return;
|
|
629
|
-
this.
|
|
630
|
-
this.emitLogEntry({
|
|
631
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
632
|
-
time: extractLogTime(text),
|
|
633
|
-
podName: "kubectl",
|
|
634
|
-
level: "warn",
|
|
635
|
-
orchId: null,
|
|
636
|
-
category: "log",
|
|
637
|
-
rawLine: trimLogText(text),
|
|
638
|
-
message: trimLogText(text),
|
|
639
|
-
prettyMessage: trimLogText(text),
|
|
640
|
-
});
|
|
959
|
+
this.emitSyntheticLogMessage(text, "warn", "kubectl");
|
|
641
960
|
});
|
|
642
961
|
|
|
643
962
|
this.logProc.on("error", (error) => {
|
|
644
|
-
this.
|
|
645
|
-
this.emitLogEntry({
|
|
646
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
647
|
-
time: extractLogTime(""),
|
|
648
|
-
podName: "kubectl",
|
|
649
|
-
level: "error",
|
|
650
|
-
orchId: null,
|
|
651
|
-
category: "log",
|
|
652
|
-
rawLine: trimLogText(`kubectl error: ${error.message}`),
|
|
653
|
-
message: trimLogText(`kubectl error: ${error.message}`),
|
|
654
|
-
prettyMessage: trimLogText(`kubectl error: ${error.message}`),
|
|
655
|
-
});
|
|
963
|
+
this.emitSyntheticLogMessage(`kubectl error: ${error.message}`, "error", "kubectl");
|
|
656
964
|
});
|
|
657
965
|
|
|
658
966
|
this.logProc.on("exit", (code, signal) => {
|
|
659
967
|
this.logProc = null;
|
|
660
|
-
this.
|
|
661
|
-
this.emitLogEntry({
|
|
662
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
663
|
-
time: extractLogTime(""),
|
|
664
|
-
podName: "kubectl",
|
|
665
|
-
level: "warn",
|
|
666
|
-
orchId: null,
|
|
667
|
-
category: "log",
|
|
668
|
-
rawLine: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
669
|
-
message: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
670
|
-
prettyMessage: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
671
|
-
});
|
|
968
|
+
this.emitSyntheticLogMessage(`kubectl exited (code=${code} signal=${signal})`, "warn", "kubectl");
|
|
672
969
|
this.scheduleLogRestart();
|
|
673
970
|
});
|
|
674
971
|
}
|
|
675
972
|
|
|
973
|
+
startLogProcess() {
|
|
974
|
+
const config = this.getLogConfig();
|
|
975
|
+
if (!config.available || this.logProc || this.logTailHandle) return;
|
|
976
|
+
|
|
977
|
+
if (hasInClusterK8sAccess()) {
|
|
978
|
+
this.startInClusterLogProcess();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
this.startKubectlLogProcess();
|
|
983
|
+
}
|
|
984
|
+
|
|
676
985
|
startLogTail(handler) {
|
|
677
986
|
if (typeof handler === "function") {
|
|
678
987
|
this.logSubscribers.add(handler);
|
|
@@ -690,10 +999,21 @@ export class NodeSdkTransport {
|
|
|
690
999
|
}
|
|
691
1000
|
|
|
692
1001
|
async stopLogTail() {
|
|
1002
|
+
if (this._logBatchTimer) {
|
|
1003
|
+
clearTimeout(this._logBatchTimer);
|
|
1004
|
+
this._logBatchTimer = null;
|
|
1005
|
+
this._logBatch = [];
|
|
1006
|
+
}
|
|
693
1007
|
if (this.logRestartTimer) {
|
|
694
1008
|
clearTimeout(this.logRestartTimer);
|
|
695
1009
|
this.logRestartTimer = null;
|
|
696
1010
|
}
|
|
1011
|
+
if (this.logTailHandle) {
|
|
1012
|
+
try {
|
|
1013
|
+
this.logTailHandle.stop();
|
|
1014
|
+
} catch {}
|
|
1015
|
+
this.logTailHandle = null;
|
|
1016
|
+
}
|
|
697
1017
|
if (this.logProc) {
|
|
698
1018
|
try {
|
|
699
1019
|
this.logProc.kill("SIGKILL");
|
package/src/platform.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
|
-
import { DEFAULT_THEME_ID, getTheme, parseTerminalMarkupRuns } from "pilotswarm-ui-core";
|
|
4
|
+
import { DEFAULT_THEME_ID, getTheme, isThemeLight, parseTerminalMarkupRuns } from "pilotswarm-ui-core";
|
|
5
5
|
|
|
6
6
|
const MAX_PROMPT_INPUT_ROWS = 3;
|
|
7
7
|
const SELECTION_BACKGROUND = "selectionBackground";
|
|
@@ -38,8 +38,12 @@ function resolveColorToken(color) {
|
|
|
38
38
|
return theme?.tui?.[color] || color;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function shouldDimGrayTextForTheme(theme = getCurrentTheme()) {
|
|
42
|
+
return !isThemeLight(theme);
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
function isDimColorToken(color) {
|
|
42
|
-
return color === "gray";
|
|
46
|
+
return color === "gray" && shouldDimGrayTextForTheme();
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
function trimText(value, width) {
|
|
@@ -361,19 +365,46 @@ function renderPanelRow(line, rowKey, contentWidth, borderColor, scrollIndicator
|
|
|
361
365
|
: selectedRuns.length > 0
|
|
362
366
|
? renderInlineRuns(selectedRuns, `inline:${rowKey}`)
|
|
363
367
|
: React.createElement(Text, null, "")),
|
|
364
|
-
React.createElement(Text, {
|
|
368
|
+
React.createElement(Text, {
|
|
369
|
+
color: scrollIndicator ? resolveColorToken("gray") : undefined,
|
|
370
|
+
dimColor: Boolean(scrollIndicator) && shouldDimGrayTextForTheme(),
|
|
371
|
+
}, scrollChar),
|
|
365
372
|
React.createElement(Text, { color: resolveColorToken(borderColor) }, "│"));
|
|
366
373
|
}
|
|
367
374
|
|
|
368
|
-
function renderBorderTop(title, color, width) {
|
|
375
|
+
function renderBorderTop(title, color, width, titleRight = null) {
|
|
369
376
|
const safeWidth = Math.max(8, Number(width) || 40);
|
|
370
|
-
const
|
|
371
|
-
const
|
|
377
|
+
const normalizedTitleRuns = normalizeTitleRuns(title, color);
|
|
378
|
+
const normalizedRightRuns = titleRight ? normalizeTitleRuns(titleRight, "gray") : [];
|
|
379
|
+
let safeTitleRuns = trimRuns(normalizedTitleRuns, Math.max(1, safeWidth - 6));
|
|
380
|
+
let safeRightRuns = trimRuns(normalizedRightRuns, Math.max(0, safeWidth - 8));
|
|
381
|
+
let titleWidth = titleRunLength(safeTitleRuns);
|
|
382
|
+
let rightWidth = titleRunLength(safeRightRuns);
|
|
383
|
+
const hasRightTitle = rightWidth > 0;
|
|
384
|
+
const chromeWidth = hasRightTitle ? 6 : 5;
|
|
385
|
+
const availableTitleWidth = Math.max(1, safeWidth - chromeWidth);
|
|
386
|
+
|
|
387
|
+
if (titleWidth + rightWidth > availableTitleWidth) {
|
|
388
|
+
safeTitleRuns = trimRuns(normalizedTitleRuns, Math.max(1, availableTitleWidth - rightWidth));
|
|
389
|
+
titleWidth = titleRunLength(safeTitleRuns);
|
|
390
|
+
if (titleWidth + rightWidth > availableTitleWidth) {
|
|
391
|
+
safeRightRuns = trimRuns(normalizedRightRuns, Math.max(0, availableTitleWidth - titleWidth));
|
|
392
|
+
rightWidth = titleRunLength(safeRightRuns);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const fill = Math.max(0, safeWidth - titleWidth - rightWidth - chromeWidth);
|
|
372
397
|
|
|
373
398
|
return React.createElement(Box, null,
|
|
374
399
|
React.createElement(Text, { color: resolveColorToken(color) }, "╭─ "),
|
|
375
400
|
renderInlineRuns(safeTitleRuns, "title"),
|
|
376
|
-
|
|
401
|
+
hasRightTitle
|
|
402
|
+
? [
|
|
403
|
+
React.createElement(Text, { key: "title-fill", color: resolveColorToken(color) }, ` ${"─".repeat(fill)} `),
|
|
404
|
+
...renderInlineRuns(safeRightRuns, "title-right"),
|
|
405
|
+
React.createElement(Text, { key: "title-end", color: resolveColorToken(color) }, "╮"),
|
|
406
|
+
]
|
|
407
|
+
: React.createElement(Text, { color: resolveColorToken(color) }, ` ${"─".repeat(fill)}╮`));
|
|
377
408
|
}
|
|
378
409
|
|
|
379
410
|
function renderBorderBottom(color, width) {
|
|
@@ -576,11 +607,12 @@ function Header({ title, subtitle }) {
|
|
|
576
607
|
justifyContent: "space-between",
|
|
577
608
|
},
|
|
578
609
|
React.createElement(Text, { bold: true, color: resolveColorToken("cyan") }, title),
|
|
579
|
-
React.createElement(Text, { color: resolveColorToken("gray"), dimColor:
|
|
610
|
+
React.createElement(Text, { color: resolveColorToken("gray"), dimColor: shouldDimGrayTextForTheme() }, subtitle || ""));
|
|
580
611
|
}
|
|
581
612
|
|
|
582
613
|
function Panel({
|
|
583
614
|
title,
|
|
615
|
+
titleRight,
|
|
584
616
|
color = "white",
|
|
585
617
|
focused = false,
|
|
586
618
|
width,
|
|
@@ -649,7 +681,7 @@ function Panel({
|
|
|
649
681
|
flexGrow,
|
|
650
682
|
flexBasis,
|
|
651
683
|
},
|
|
652
|
-
renderBorderTop(title, borderColor, safeWidth),
|
|
684
|
+
renderBorderTop(title, borderColor, safeWidth, titleRight),
|
|
653
685
|
lines
|
|
654
686
|
? React.createElement(Box, { flexDirection: "column", flexGrow: 1, backgroundColor: fillColor || undefined },
|
|
655
687
|
[
|
|
@@ -773,7 +805,7 @@ function Input({ label, value, focused, placeholder, rows = 1, cursorIndex = 0 }
|
|
|
773
805
|
? [
|
|
774
806
|
renderPromptRow(placeholder || "Type a message and press Enter", focused ? 0 : null, {
|
|
775
807
|
color: resolveColorToken("gray"),
|
|
776
|
-
dimColor:
|
|
808
|
+
dimColor: shouldDimGrayTextForTheme(),
|
|
777
809
|
showCursor: Boolean(focused),
|
|
778
810
|
keyPrefix: "prompt-line:0",
|
|
779
811
|
prefix: labelPrefix,
|
|
@@ -800,7 +832,7 @@ function StatusLine({ left, right }) {
|
|
|
800
832
|
justifyContent: "space-between",
|
|
801
833
|
},
|
|
802
834
|
React.createElement(Text, { color: resolveColorToken("white") }, left || ""),
|
|
803
|
-
React.createElement(Text, { color: resolveColorToken("gray"), dimColor:
|
|
835
|
+
React.createElement(Text, { color: resolveColorToken("gray"), dimColor: shouldDimGrayTextForTheme() }, right || ""));
|
|
804
836
|
}
|
|
805
837
|
|
|
806
838
|
function Overlay({ children }) {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const pkgRoot = path.resolve(__dirname, "..");
|
|
7
|
+
const defaultTuiSplashPath = path.join(pkgRoot, "tui-splash.txt");
|
|
8
|
+
|
|
9
|
+
function fileExists(filePath) {
|
|
10
|
+
try {
|
|
11
|
+
return fs.existsSync(filePath);
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readOptionalTextFile(filePath) {
|
|
18
|
+
try {
|
|
19
|
+
return fs.readFileSync(filePath, "utf-8").trimEnd();
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getObject(value) {
|
|
26
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
27
|
+
? value
|
|
28
|
+
: {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function firstNonEmptyString(...values) {
|
|
32
|
+
for (const value of values) {
|
|
33
|
+
if (typeof value === "string" && value.trim()) {
|
|
34
|
+
return value.trim();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveRelativePath(baseDir, relativePath) {
|
|
41
|
+
if (!baseDir || typeof relativePath !== "string" || !relativePath.trim()) return null;
|
|
42
|
+
const basePath = path.resolve(baseDir);
|
|
43
|
+
const filePath = path.resolve(basePath, relativePath);
|
|
44
|
+
if (filePath !== basePath && !filePath.startsWith(`${basePath}${path.sep}`)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return filePath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readRelativeTextFile(baseDir, relativePath) {
|
|
51
|
+
const filePath = resolveRelativePath(baseDir, relativePath);
|
|
52
|
+
if (!filePath) return null;
|
|
53
|
+
return readOptionalTextFile(filePath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveRelativeAssetFile(baseDir, relativePath) {
|
|
57
|
+
const filePath = resolveRelativePath(baseDir, relativePath);
|
|
58
|
+
if (!filePath || !fileExists(filePath)) return null;
|
|
59
|
+
return filePath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstAssetUrl(...values) {
|
|
63
|
+
for (const value of values) {
|
|
64
|
+
if (typeof value !== "string" || !value.trim()) continue;
|
|
65
|
+
const trimmed = value.trim();
|
|
66
|
+
if (/^(https?:\/\/|\/|data:|blob:)/iu.test(trimmed)) {
|
|
67
|
+
return trimmed;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolvePortalAsset(baseDir, { file, url }) {
|
|
74
|
+
const directUrl = firstAssetUrl(url);
|
|
75
|
+
if (directUrl) {
|
|
76
|
+
return { filePath: null, publicUrl: directUrl };
|
|
77
|
+
}
|
|
78
|
+
const filePath = resolveRelativeAssetFile(baseDir, file);
|
|
79
|
+
if (!filePath) {
|
|
80
|
+
return { filePath: null, publicUrl: null };
|
|
81
|
+
}
|
|
82
|
+
return { filePath, publicUrl: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readSplashValue(baseDir, config, fallback) {
|
|
86
|
+
if (typeof config?.splash === "string" && config.splash.trim()) {
|
|
87
|
+
return config.splash;
|
|
88
|
+
}
|
|
89
|
+
if (typeof config?.splashFile === "string" && config.splashFile.trim()) {
|
|
90
|
+
const fileText = readRelativeTextFile(baseDir, config.splashFile);
|
|
91
|
+
if (fileText != null) return fileText;
|
|
92
|
+
}
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getDefaultSplash() {
|
|
97
|
+
return readOptionalTextFile(defaultTuiSplashPath) || "{bold}{cyan-fg}PilotSwarm{/cyan-fg}{/bold}";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function readPluginMetadata(pluginDir) {
|
|
101
|
+
if (!pluginDir) return null;
|
|
102
|
+
const pluginJsonPath = path.join(pluginDir, "plugin.json");
|
|
103
|
+
if (!fileExists(pluginJsonPath)) return null;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new Error(`Failed to parse plugin metadata: ${pluginJsonPath}: ${error.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getPluginDirsFromEnv() {
|
|
112
|
+
const envDirs = String(process.env.PLUGIN_DIRS || "")
|
|
113
|
+
.split(",")
|
|
114
|
+
.map((value) => value.trim())
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((value) => path.resolve(value));
|
|
117
|
+
if (envDirs.length > 0) return envDirs;
|
|
118
|
+
|
|
119
|
+
const cwdPlugin = path.resolve(process.cwd(), "plugins");
|
|
120
|
+
if (fileExists(cwdPlugin)) return [cwdPlugin];
|
|
121
|
+
|
|
122
|
+
const bundledPlugin = path.join(pkgRoot, "plugins");
|
|
123
|
+
if (fileExists(bundledPlugin)) return [bundledPlugin];
|
|
124
|
+
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function resolveTuiBranding(pluginDir) {
|
|
129
|
+
const pluginMeta = readPluginMetadata(pluginDir);
|
|
130
|
+
const tui = pluginMeta?.tui;
|
|
131
|
+
const defaultSplash = getDefaultSplash();
|
|
132
|
+
if (!tui || typeof tui !== "object") {
|
|
133
|
+
return { title: "PilotSwarm", splash: defaultSplash };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const title = firstNonEmptyString(tui.title, "PilotSwarm") || "PilotSwarm";
|
|
137
|
+
const splash = readSplashValue(pluginDir, tui, defaultSplash);
|
|
138
|
+
return { title, splash };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolvePortalConfigBundleFromPluginDirs(pluginDirs = []) {
|
|
142
|
+
const defaultSplash = getDefaultSplash();
|
|
143
|
+
const defaults = {
|
|
144
|
+
branding: {
|
|
145
|
+
title: "PilotSwarm",
|
|
146
|
+
pageTitle: "PilotSwarm",
|
|
147
|
+
splash: defaultSplash,
|
|
148
|
+
logoUrl: null,
|
|
149
|
+
faviconUrl: null,
|
|
150
|
+
},
|
|
151
|
+
ui: {
|
|
152
|
+
loadingMessage: "Preparing your workspace",
|
|
153
|
+
loadingCopy: "Connecting the shared workspace and live session feeds...",
|
|
154
|
+
},
|
|
155
|
+
auth: {
|
|
156
|
+
provider: null,
|
|
157
|
+
providers: {},
|
|
158
|
+
signInTitle: "Sign in to PilotSwarm",
|
|
159
|
+
signInMessage: null,
|
|
160
|
+
signInLabel: "Sign In",
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
for (const pluginDir of pluginDirs) {
|
|
165
|
+
const absDir = path.resolve(pluginDir);
|
|
166
|
+
const pluginMeta = readPluginMetadata(absDir);
|
|
167
|
+
if (!pluginMeta) continue;
|
|
168
|
+
|
|
169
|
+
const portal = getObject(pluginMeta?.portal);
|
|
170
|
+
const portalBranding = getObject(portal.branding);
|
|
171
|
+
const portalUi = getObject(portal.ui);
|
|
172
|
+
const portalAuth = getObject(portal.auth);
|
|
173
|
+
const tui = getObject(pluginMeta?.tui);
|
|
174
|
+
|
|
175
|
+
const title = firstNonEmptyString(portalBranding.title, portal.title, tui.title, defaults.branding.title) || defaults.branding.title;
|
|
176
|
+
const pageTitle = firstNonEmptyString(portalBranding.pageTitle, portal.pageTitle, title, defaults.branding.pageTitle) || defaults.branding.pageTitle;
|
|
177
|
+
const splash = readSplashValue(
|
|
178
|
+
absDir,
|
|
179
|
+
portalBranding,
|
|
180
|
+
readSplashValue(absDir, portal, readSplashValue(absDir, tui, defaults.branding.splash)),
|
|
181
|
+
);
|
|
182
|
+
const logoAsset = resolvePortalAsset(absDir, {
|
|
183
|
+
file: firstNonEmptyString(portalBranding.logoFile, portal.logoFile),
|
|
184
|
+
url: firstNonEmptyString(portalBranding.logoUrl, portal.logoUrl),
|
|
185
|
+
});
|
|
186
|
+
const faviconAsset = resolvePortalAsset(absDir, {
|
|
187
|
+
file: firstNonEmptyString(portalBranding.faviconFile, portal.faviconFile, portalBranding.logoFile, portal.logoFile),
|
|
188
|
+
url: firstNonEmptyString(portalBranding.faviconUrl, portal.faviconUrl, portalBranding.logoUrl, portal.logoUrl),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const assetFiles = {};
|
|
192
|
+
const branding = {
|
|
193
|
+
title,
|
|
194
|
+
pageTitle,
|
|
195
|
+
splash,
|
|
196
|
+
logoUrl: logoAsset.publicUrl || null,
|
|
197
|
+
faviconUrl: faviconAsset.publicUrl || null,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (logoAsset.filePath) {
|
|
201
|
+
assetFiles.logo = logoAsset.filePath;
|
|
202
|
+
branding.logoUrl = "/api/portal-assets/logo";
|
|
203
|
+
}
|
|
204
|
+
if (faviconAsset.filePath) {
|
|
205
|
+
assetFiles.favicon = faviconAsset.filePath;
|
|
206
|
+
branding.faviconUrl = "/api/portal-assets/favicon";
|
|
207
|
+
}
|
|
208
|
+
if (!branding.faviconUrl && branding.logoUrl) {
|
|
209
|
+
branding.faviconUrl = branding.logoUrl;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
portalConfig: {
|
|
214
|
+
branding,
|
|
215
|
+
ui: {
|
|
216
|
+
loadingMessage: firstNonEmptyString(portalUi.loadingMessage, portal.loadingMessage, defaults.ui.loadingMessage) || defaults.ui.loadingMessage,
|
|
217
|
+
loadingCopy: firstNonEmptyString(portalUi.loadingCopy, portal.loadingCopy, defaults.ui.loadingCopy) || defaults.ui.loadingCopy,
|
|
218
|
+
},
|
|
219
|
+
auth: {
|
|
220
|
+
provider: firstNonEmptyString(portalAuth.provider, portal.provider),
|
|
221
|
+
providers: getObject(portalAuth.providers),
|
|
222
|
+
signInTitle: firstNonEmptyString(portalAuth.signInTitle, portal.signInTitle, `Sign in to ${title}`) || `Sign in to ${title}`,
|
|
223
|
+
signInMessage: firstNonEmptyString(portalAuth.signInMessage, portal.signInMessage, defaults.auth.signInMessage),
|
|
224
|
+
signInLabel: firstNonEmptyString(portalAuth.signInLabel, defaults.auth.signInLabel) || defaults.auth.signInLabel,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
assetFiles,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
portalConfig: defaults,
|
|
233
|
+
assetFiles: {},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function resolvePortalConfigFromPluginDirs(pluginDirs = []) {
|
|
238
|
+
return resolvePortalConfigBundleFromPluginDirs(pluginDirs).portalConfig;
|
|
239
|
+
}
|