pilotswarm-cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/cli/tui.js +164 -49
  2. package/package.json +1 -1
package/cli/tui.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * npx pilotswarm remote --env .env.remote # client-only (AKS)
14
14
  */
15
15
 
16
- import { PilotSwarmClient, PilotSwarmWorker, PilotSwarmManagementClient, SessionBlobStore, systemAgentUUID } from "pilotswarm-sdk";
16
+ import { PilotSwarmClient, PilotSwarmWorker, PilotSwarmManagementClient, SessionBlobStore, FilesystemArtifactStore, systemAgentUUID, loadAgentFiles } from "pilotswarm-sdk";
17
17
  import { createRequire } from "node:module";
18
18
  import { marked } from "marked";
19
19
  import { markedTerminal } from "marked-terminal";
@@ -340,15 +340,19 @@ const sessionArtifacts = new Map();
340
340
  const _registeredArtifacts = new Set();
341
341
  const MAX_ARTIFACT_REGISTRY = 500;
342
342
 
343
- /** TUI-level blob store for on-demand artifact downloads. Created lazily. */
344
- let _tuiBlobStore = null;
345
- function getTuiBlobStore() {
346
- if (_tuiBlobStore) return _tuiBlobStore;
343
+ /** TUI-level artifact store for on-demand downloads. Created lazily.
344
+ * Uses Azure Blob when configured, otherwise falls back to local filesystem. */
345
+ let _tuiArtifactStore = null;
346
+ function getTuiArtifactStore() {
347
+ if (_tuiArtifactStore) return _tuiArtifactStore;
347
348
  const connStr = process.env.AZURE_STORAGE_CONNECTION_STRING;
348
- const container = process.env.AZURE_STORAGE_CONTAINER || "copilot-sessions";
349
- if (!connStr) return null;
350
- _tuiBlobStore = new SessionBlobStore(connStr, container);
351
- return _tuiBlobStore;
349
+ if (connStr) {
350
+ const container = process.env.AZURE_STORAGE_CONTAINER || "copilot-sessions";
351
+ _tuiArtifactStore = new SessionBlobStore(connStr, container);
352
+ } else {
353
+ _tuiArtifactStore = new FilesystemArtifactStore();
354
+ }
355
+ return _tuiArtifactStore;
352
356
  }
353
357
 
354
358
  /**
@@ -381,17 +385,13 @@ function detectArtifactLinks(text, orchId) {
381
385
  * Returns the local path on success, null on failure.
382
386
  */
383
387
  async function downloadArtifact(sessionId, filename) {
384
- const bs = getTuiBlobStore();
385
- if (!bs) {
386
- appendLog("{red-fg}📥 No blob storage configured — cannot download artifacts.{/red-fg}");
387
- return null;
388
- }
388
+ const store = getTuiArtifactStore();
389
389
  const sessionDir = path.join(EXPORTS_DIR, sessionId.slice(0, 8));
390
390
  fs.mkdirSync(sessionDir, { recursive: true });
391
391
  const localPath = path.join(sessionDir, filename);
392
392
 
393
393
  try {
394
- const content = await bs.downloadArtifact(sessionId, filename);
394
+ const content = await store.downloadArtifact(sessionId, filename);
395
395
  fs.writeFileSync(localPath, content, "utf-8");
396
396
  appendLog(`{green-fg}📥 Downloaded: ~/${path.relative(os.homedir(), localPath)} (${(content.length / 1024).toFixed(1)}KB){/green-fg}`);
397
397
  return localPath;
@@ -1099,6 +1099,58 @@ function appendActivity(text, orchId) {
1099
1099
  }
1100
1100
  }
1101
1101
 
1102
+ function formatToolArgValue(value) {
1103
+ if (typeof value === "string") return JSON.stringify(value);
1104
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1105
+ if (value === null) return "null";
1106
+ if (Array.isArray(value)) return `[${value.map(formatToolArgValue).join(", ")}]`;
1107
+ try {
1108
+ return JSON.stringify(value);
1109
+ } catch {
1110
+ return String(value);
1111
+ }
1112
+ }
1113
+
1114
+ function formatToolArgsSummary(toolName, args) {
1115
+ if (!args || typeof args !== "object") return "";
1116
+ if (toolName === "wait") {
1117
+ const seconds = args.seconds != null ? `${args.seconds}s` : "?";
1118
+ const preserve = args.preserveWorkerAffinity === true ? " preserve=true" : "";
1119
+ const reason = typeof args.reason === "string" && args.reason
1120
+ ? ` reason=${JSON.stringify(args.reason)}`
1121
+ : "";
1122
+ return ` ${seconds}${preserve}${reason}`;
1123
+ }
1124
+
1125
+ const entries = Object.entries(args)
1126
+ .slice(0, 4)
1127
+ .map(([key, value]) => `${key}=${formatToolArgValue(value)}`);
1128
+ if (entries.length === 0) return "";
1129
+ const suffix = Object.keys(args).length > entries.length ? ", ..." : "";
1130
+ return ` ${entries.join(", ")}${suffix}`;
1131
+ }
1132
+
1133
+ function formatToolActivityLine(timeStr, evt, phase = "start") {
1134
+ const toolName = evt.data?.toolName || evt.data?.name || "tool";
1135
+ const args = evt.data?.arguments || evt.data?.args;
1136
+ const dsid = evt.data?.durableSessionId ? ` {gray-fg}[${shortId(evt.data.durableSessionId)}]{/gray-fg}` : "";
1137
+ const summary = formatToolArgsSummary(toolName, args);
1138
+ if (phase === "start") {
1139
+ return `{white-fg}[${timeStr}]{/white-fg} {yellow-fg}▶ ${toolName}${summary}{/yellow-fg}${dsid}`;
1140
+ }
1141
+ return `{white-fg}[${timeStr}]{/white-fg} {green-fg}✓ ${toolName}{/green-fg}${dsid}`;
1142
+ }
1143
+
1144
+ function summarizeActivityPreview(text, maxLen = 100) {
1145
+ const compact = String(text || "")
1146
+ .replace(/\s+/g, " ")
1147
+ .trim();
1148
+ if (!compact) return "(no content)";
1149
+ return compact.length > maxLen
1150
+ ? `${compact.slice(0, maxLen - 1)}...`
1151
+ : compact;
1152
+ }
1153
+
1102
1154
  function ensureSessionSplashBuffer(orchId) {
1103
1155
  const existing = sessionChatBuffers.get(orchId) || [];
1104
1156
  const splashText = systemSplashText.get(orchId);
@@ -2907,12 +2959,9 @@ async function loadCmsHistory(orchId, options = {}) {
2907
2959
  lines.push("");
2908
2960
  }
2909
2961
  } else if (type === "tool.execution_start") {
2910
- const toolName = evt.data?.toolName || "tool";
2911
- const dsid = evt.data?.durableSessionId ? ` {gray-fg}[${shortId(evt.data.durableSessionId)}]{/gray-fg}` : "";
2912
- activityLines.push(`{white-fg}[${timeStr}]{/white-fg} {yellow-fg}▶ ${toolName}{/yellow-fg}${dsid}`);
2962
+ activityLines.push(formatToolActivityLine(timeStr, evt, "start"));
2913
2963
  } else if (type === "tool.execution_complete") {
2914
- const toolName = evt.data?.toolName || "tool";
2915
- activityLines.push(`{white-fg}[${timeStr}]{/white-fg} {green-fg}✓ ${toolName}{/green-fg}`);
2964
+ activityLines.push(formatToolActivityLine(timeStr, evt, "complete"));
2916
2965
  } else if (type === "abort" || type === "session.info" || type === "session.idle"
2917
2966
  || type === "session.usage_info" || type === "pending_messages.modified"
2918
2967
  || type === "assistant.usage") {
@@ -2971,7 +3020,45 @@ async function loadCmsHistory(orchId, options = {}) {
2971
3020
  }
2972
3021
 
2973
3022
  const maxRenderedSeq = (events || []).reduce((max, evt) => Math.max(max, evt.seq || 0), 0);
2974
- sessionChatBuffers.set(orchId, lines);
3023
+
3024
+ // Append pending question so it survives the history buffer swap.
3025
+ // The observer may have written it into the old buffer, but this
3026
+ // replacement would nuke it without this check.
3027
+ const pendingQ = sessionPendingQuestions.get(orchId);
3028
+ if (pendingQ) {
3029
+ lines.push(`{cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`);
3030
+ const renderedQ = renderMarkdown(pendingQ);
3031
+ for (const line of renderedQ.split("\n")) {
3032
+ lines.push(line);
3033
+ }
3034
+ lines.push("");
3035
+ }
3036
+
3037
+ // If CMS history produced no chat-visible lines (only the footer),
3038
+ // but the observer previously wrote real content into the buffer,
3039
+ // keep the existing buffer. This handles the case where assistant
3040
+ // response text comes via the observer (customStatus streaming) but
3041
+ // hasn't been persisted as CMS assistant.message events yet.
3042
+ const chatContentLines = lines.filter(l =>
3043
+ l && !/^{(?:white|gray)-fg}──/.test(l) && l.trim() !== "",
3044
+ );
3045
+ const existing = sessionChatBuffers.get(orchId);
3046
+ const existingHasContent = existing && existing.length > 1
3047
+ && existing.some(l => l && !/Loading/.test(l) && !/no recent/.test(l));
3048
+
3049
+ if (chatContentLines.length === 0 && existingHasContent) {
3050
+ // CMS has no chat-worthy content but observer buffer does —
3051
+ // append the footer to the existing buffer instead of replacing.
3052
+ if (eventCount > 0) {
3053
+ const footerIdx = lines.findIndex(l => /recent history loaded/.test(l));
3054
+ if (footerIdx >= 0) {
3055
+ existing.push(lines[footerIdx]);
3056
+ existing.push("");
3057
+ }
3058
+ }
3059
+ } else {
3060
+ sessionChatBuffers.set(orchId, lines);
3061
+ }
2975
3062
  sessionActivityBuffers.set(orchId, activityLines);
2976
3063
  sessionHistoryLoadedAt.set(orchId, Date.now());
2977
3064
  sessionRenderedCmsSeq.set(orchId, maxRenderedSeq);
@@ -3134,8 +3221,11 @@ if (!isRemote) {
3134
3221
  ...(workerModuleConfig.mcpServers && { mcpServers: workerModuleConfig.mcpServers }),
3135
3222
  });
3136
3223
  // Register custom tools from worker module
3137
- if (workerModuleConfig.tools?.length) {
3138
- w.registerTools(workerModuleConfig.tools);
3224
+ const workerTools = typeof workerModuleConfig.createTools === "function"
3225
+ ? await workerModuleConfig.createTools({ workerNodeId: `local-rt-${i}`, workerIndex: i })
3226
+ : workerModuleConfig.tools;
3227
+ if (workerTools?.length) {
3228
+ w.registerTools(workerTools);
3139
3229
  }
3140
3230
  await w.start();
3141
3231
  workers.push(w);
@@ -3276,10 +3366,30 @@ if (!modelProviders) {
3276
3366
  // Will be populated from mgmt.getModelsByProvider() after mgmt.start()
3277
3367
  }
3278
3368
 
3279
- // Capture session policy + agent list from the first worker
3280
- const _workerSessionPolicy = workers[0]?.sessionPolicy || null;
3281
- const _workerAllowedAgentNames = workers[0]?.allowedAgentNames || [];
3282
- const _workerLoadedAgents = workers[0]?.loadedAgents || [];
3369
+ // Capture session policy + agent list from the first worker.
3370
+ // In remote mode (no local workers), load directly from the plugin directory
3371
+ // so the TUI enforces the same session creation restrictions as the backend.
3372
+ let _workerSessionPolicy = workers[0]?.sessionPolicy || null;
3373
+ let _workerAllowedAgentNames = workers[0]?.allowedAgentNames || [];
3374
+ let _workerLoadedAgents = workers[0]?.loadedAgents || [];
3375
+
3376
+ if (workers.length === 0 && process.env.PLUGIN_DIRS) {
3377
+ const pluginDirsArr = process.env.PLUGIN_DIRS.split(",").map(d => d.trim()).filter(Boolean);
3378
+ for (const dir of pluginDirsArr) {
3379
+ const policyFile = path.join(dir, "session-policy.json");
3380
+ if (fs.existsSync(policyFile)) {
3381
+ try { _workerSessionPolicy = JSON.parse(fs.readFileSync(policyFile, "utf-8")); } catch {}
3382
+ }
3383
+ const agentsDir = path.join(dir, "agents");
3384
+ if (fs.existsSync(agentsDir)) {
3385
+ try {
3386
+ const agents = loadAgentFiles(agentsDir).filter(a => !a.system && a.name !== "default");
3387
+ _workerLoadedAgents = agents;
3388
+ _workerAllowedAgentNames = agents.map(a => a.name).filter(Boolean);
3389
+ } catch {}
3390
+ }
3391
+ }
3392
+ }
3283
3393
  if (_workerSessionPolicy) {
3284
3394
  appendLog(`Session policy: mode=${_workerSessionPolicy.creation?.mode || "open"}, allowGeneric=${_workerSessionPolicy.creation?.allowGeneric ?? true}`);
3285
3395
  }
@@ -5108,12 +5218,8 @@ function startObserver(orchId) {
5108
5218
  }
5109
5219
  if (response.type === "wait" && response.content) {
5110
5220
  appendActivity(`{green-fg}[obs] ✓ SHOWING ${source}: version=${response.version} type=wait content=${response.content.slice(0, 80)}{/green-fg}`, orchId);
5111
- const prefix = `{white-fg}[${ts()}]{/white-fg} {gray-fg}[intermediate]{/gray-fg}`;
5112
- appendActivity(prefix, orchId);
5113
- const rendered = renderMarkdown(response.content);
5114
- for (const line of rendered.split("\n")) {
5115
- appendActivity(line, orchId);
5116
- }
5221
+ const preview = summarizeActivityPreview(response.content);
5222
+ appendActivity(`{white-fg}[${ts()}]{/white-fg} {gray-fg}[intermediate]{/gray-fg} ${preview}`, orchId);
5117
5223
  promoteIntermediateContent(response.content, orchId);
5118
5224
  setStatusIfActive(`Waiting (${cs.waitReason || response.waitReason || "timer"})…`);
5119
5225
  return;
@@ -5513,13 +5619,12 @@ function startCmsPoller(orchId) {
5513
5619
 
5514
5620
  if (type === "tool.execution_start") {
5515
5621
  const toolName = evt.data?.toolName || "tool";
5516
- const dsid = evt.data?.durableSessionId ? ` {gray-fg}[${shortId(evt.data.durableSessionId)}]{/gray-fg}` : "";
5517
5622
  // Track last tool name so we can show it on completion too
5518
5623
  sess._lastToolName = toolName;
5519
- appendActivity(`{white-fg}[${t}]{/white-fg} {yellow-fg}▶ ${toolName}{/yellow-fg}${dsid}`, orchId);
5624
+ appendActivity(formatToolActivityLine(t, evt, "start"), orchId);
5520
5625
  } else if (type === "tool.execution_complete") {
5521
5626
  const toolName = evt.data?.toolName || sess._lastToolName || "tool";
5522
- appendActivity(`{white-fg}[${t}]{/white-fg} {green-fg} ${toolName}{/green-fg}`, orchId);
5627
+ appendActivity(formatToolActivityLine(t, { ...evt, data: { ...(evt.data || {}), toolName } }, "complete"), orchId);
5523
5628
  } else if (type === "assistant.reasoning") {
5524
5629
  appendActivity(`{white-fg}[${t}]{/white-fg} {gray-fg}[reasoning]{/gray-fg}`, orchId);
5525
5630
  } else if (type === "assistant.turn_start") {
@@ -6376,33 +6481,43 @@ screen.on("keypress", (ch, key) => {
6376
6481
  picker.key(["escape", "q", "a"], closePicker);
6377
6482
 
6378
6483
  picker.on("select", async (_el, idx) => {
6379
- closePicker();
6380
6484
  const art = artifacts[idx];
6381
6485
  if (!art) return;
6382
6486
 
6383
- setStatus(`Downloading ${art.filename}...`);
6384
- screen.render();
6385
- const localPath = await downloadArtifact(art.sessionId, art.filename);
6386
- if (localPath) {
6387
- art.downloaded = true;
6388
- art.localPath = localPath;
6389
-
6390
- // Open markdown viewer with this file selected
6487
+ if (art.downloaded) {
6488
+ // Already downloaded — close picker and open viewer
6489
+ closePicker();
6391
6490
  mdViewActive = true;
6392
6491
  refreshMarkdownViewer();
6393
-
6394
- // Find and select the downloaded file in the viewer
6395
6492
  const files = scanExportFiles();
6396
- const matchIdx = files.findIndex(f => f.localPath === localPath);
6493
+ const matchIdx = files.findIndex(f => f.localPath === art.localPath);
6397
6494
  if (matchIdx >= 0) {
6398
6495
  mdViewerSelectedIdx = matchIdx;
6399
6496
  mdFileListPane.select(matchIdx);
6400
6497
  refreshMarkdownViewer();
6401
6498
  }
6402
-
6403
6499
  screen.realloc();
6404
6500
  relayoutAll();
6405
6501
  setStatus("Markdown Viewer (v to exit)");
6502
+ screen.render();
6503
+ return;
6504
+ }
6505
+
6506
+ setStatus(`Downloading ${art.filename}...`);
6507
+ screen.render();
6508
+ const localPath = await downloadArtifact(art.sessionId, art.filename);
6509
+ if (localPath) {
6510
+ art.downloaded = true;
6511
+ art.localPath = localPath;
6512
+
6513
+ // Update picker item to show downloaded state
6514
+ const updatedItems = artifacts.map((a) => {
6515
+ const icon = a.downloaded ? "✓" : "↓";
6516
+ return ` ${icon} ${a.filename}`;
6517
+ });
6518
+ picker.setItems(updatedItems);
6519
+ picker.select(idx);
6520
+ setStatus(`Downloaded ${art.filename}`);
6406
6521
  } else {
6407
6522
  setStatus("Download failed — check logs");
6408
6523
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Terminal UI for pilotswarm — interactive durable agent orchestration.",
5
5
  "type": "module",
6
6
  "bin": {