pilotswarm-cli 0.1.4 → 0.1.6

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 +316 -273
  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;
@@ -2703,11 +2703,10 @@ const seqCmsSeededSessions = new Set();
2703
2703
  * Load conversation history from CMS and rebuild chat buffer for the session.
2704
2704
  * Includes ALL persisted events (not truncated) so switching sessions is deterministic.
2705
2705
  */
2706
- async function loadCmsHistory(orchId) {
2706
+ async function loadCmsHistory(orchId, options = {}) {
2707
2707
  const _ph = perfStart("loadCmsHistory");
2708
2708
  const sid = orchId.startsWith("session-") ? orchId.slice(8) : orchId;
2709
- const generation = (sessionHistoryLoadGeneration.get(orchId) ?? 0) + 1;
2710
- sessionHistoryLoadGeneration.set(orchId, generation);
2709
+ const force = options.force === true;
2711
2710
  let eventCount = 0;
2712
2711
  let loadFailed = false;
2713
2712
 
@@ -2716,297 +2715,320 @@ async function loadCmsHistory(orchId) {
2716
2715
  // so reloading on every session switch just adds latency.
2717
2716
  const cached = sessionChatBuffers.get(orchId);
2718
2717
  const loadedAt = sessionHistoryLoadedAt.get(orchId) ?? 0;
2719
- if (cached && cached.length > 1 && (Date.now() - loadedAt) < 30_000 && !orchHasChanges.has(orchId)) {
2718
+ if (!force && cached && cached.length > 1 && (Date.now() - loadedAt) < 30_000 && !orchHasChanges.has(orchId)) {
2719
+ perfEnd(_ph, { orchId: orchId.slice(0, 12), cached: true });
2720
2720
  return;
2721
2721
  }
2722
2722
 
2723
- // Ensure we have a PilotSwarmSession handle (may not exist for sessions from previous TUI runs)
2724
- let sess = sessions.get(sid);
2725
- if (!sess) {
2726
- try {
2727
- sess = await client.resumeSession(sid);
2728
- sessions.set(sid, sess);
2729
- } catch (err) {
2730
- appendLog(`{yellow-fg}Could not resume session ${shortId(sid)}: ${err.message}{/yellow-fg}`);
2731
- return;
2732
- }
2723
+ const inFlight = sessionHistoryLoadPromises.get(orchId);
2724
+ if (inFlight && !force) {
2725
+ perfEnd(_ph, { orchId: orchId.slice(0, 12), deduped: true });
2726
+ return inFlight;
2733
2727
  }
2734
2728
 
2735
- try {
2736
- const expand = sessionExpandLevel.get(orchId) || 0;
2737
- const CMS_HISTORY_FETCH_LIMIT = expand >= 2 ? 2000 : expand >= 1 ? 500 : 250;
2738
- const MAX_RENDERED_EVENTS = expand >= 2 ? 2000 : expand >= 1 ? 500 : 120;
2739
- const MAX_TOTAL_RENDER_CHARS = expand >= 2 ? 500_000 : expand >= 1 ? 200_000 : 50_000;
2740
- const MAX_ASSISTANT_MESSAGE_CHARS = expand >= 1 ? 20_000 : 4_000;
2741
- const dc = getDc();
2729
+ const generation = (sessionHistoryLoadGeneration.get(orchId) ?? 0) + 1;
2730
+ sessionHistoryLoadGeneration.set(orchId, generation);
2742
2731
 
2743
- // Fetch events, session info, and live status in parallel.
2744
- // The live custom status may contain the latest `turnResult` even when
2745
- // the CMS history does not yet have a persisted `assistant.message`.
2746
- const [events, info, liveStatus] = await Promise.all([
2747
- sess.getMessages(CMS_HISTORY_FETCH_LIMIT),
2748
- (!sessionModels.has(orchId)) ? sess.getInfo().catch(() => null) : Promise.resolve(null),
2749
- dc ? dc.getStatus(orchId).catch(() => null) : Promise.resolve(null),
2750
- ]);
2751
- eventCount = events?.length || 0;
2752
-
2753
- let liveCustomStatus = null;
2754
- let liveResponsePayload = null;
2755
- if (liveStatus?.customStatus) {
2732
+ const loadPromise = (async () => {
2733
+ // Ensure we have a PilotSwarmSession handle (may not exist for sessions from previous TUI runs)
2734
+ let sess = sessions.get(sid);
2735
+ if (!sess) {
2756
2736
  try {
2757
- liveCustomStatus = typeof liveStatus.customStatus === "string"
2758
- ? JSON.parse(liveStatus.customStatus)
2759
- : liveStatus.customStatus;
2760
- } catch {}
2761
- }
2762
- if (liveCustomStatus?.responseVersion) {
2763
- liveResponsePayload = await fetchLatestResponsePayload(orchId, dc);
2737
+ sess = await client.resumeSession(sid);
2738
+ sessions.set(sid, sess);
2739
+ } catch (err) {
2740
+ appendLog(`{yellow-fg}Could not resume session ${shortId(sid)}: ${err.message}{/yellow-fg}`);
2741
+ return;
2742
+ }
2764
2743
  }
2765
2744
 
2766
- const liveTurnContent = liveCustomStatus?.turnResult?.type === "completed"
2767
- ? liveCustomStatus.turnResult.content
2768
- : liveResponsePayload?.type === "completed"
2769
- ? liveResponsePayload.content
2770
- : "";
2771
-
2772
- // Populate session model if not already known
2773
- if (info?.model) {
2774
- sessionModels.set(orchId, info.model);
2775
- if (orchId === activeOrchId) updateChatLabel();
2776
- }
2745
+ try {
2746
+ const expand = sessionExpandLevel.get(orchId) || 0;
2747
+ const CMS_HISTORY_FETCH_LIMIT = expand >= 2 ? 2000 : expand >= 1 ? 500 : 250;
2748
+ const MAX_RENDERED_EVENTS = expand >= 2 ? 2000 : expand >= 1 ? 500 : 120;
2749
+ const MAX_TOTAL_RENDER_CHARS = expand >= 2 ? 500_000 : expand >= 1 ? 200_000 : 50_000;
2750
+ const MAX_ASSISTANT_MESSAGE_CHARS = expand >= 1 ? 20_000 : 4_000;
2751
+ const dc = getDc();
2777
2752
 
2778
- if ((!events || events.length === 0) && !liveTurnContent) {
2779
- if (sessionHistoryLoadGeneration.get(orchId) !== generation) {
2780
- return;
2753
+ // Fetch events, session info, and live status in parallel.
2754
+ // The live custom status may contain the latest `turnResult` even when
2755
+ // the CMS history does not yet have a persisted `assistant.message`.
2756
+ const [events, info, liveStatus] = await Promise.all([
2757
+ sess.getMessages(CMS_HISTORY_FETCH_LIMIT),
2758
+ (!sessionModels.has(orchId)) ? sess.getInfo().catch(() => null) : Promise.resolve(null),
2759
+ dc ? dc.getStatus(orchId).catch(() => null) : Promise.resolve(null),
2760
+ ]);
2761
+ eventCount = events?.length || 0;
2762
+
2763
+ let liveCustomStatus = null;
2764
+ let liveResponsePayload = null;
2765
+ if (liveStatus?.customStatus) {
2766
+ try {
2767
+ liveCustomStatus = typeof liveStatus.customStatus === "string"
2768
+ ? JSON.parse(liveStatus.customStatus)
2769
+ : liveStatus.customStatus;
2770
+ } catch {}
2781
2771
  }
2782
- // Only blank the buffer if the observer hasn't already written
2783
- // content into it. Otherwise we'd nuke live turn output that
2784
- // arrived while we were fetching from CMS (race condition that
2785
- // causes empty chat on first switch to a session).
2786
- const existing = sessionChatBuffers.get(orchId);
2787
- const isLoadingPlaceholder = existing
2788
- && existing.length === 1
2789
- && /Loading/.test(existing[0]);
2790
- if (!existing || existing.length === 0 || isLoadingPlaceholder) {
2791
- const splashLines = ensureSessionSplashBuffer(orchId);
2792
- if (!splashLines) {
2793
- sessionChatBuffers.set(orchId, []);
2794
- }
2772
+ if (liveCustomStatus?.responseVersion) {
2773
+ liveResponsePayload = await fetchLatestResponsePayload(orchId, dc);
2795
2774
  }
2796
- if (!sessionActivityBuffers.has(orchId)) {
2797
- sessionActivityBuffers.set(orchId, ["{gray-fg}(no recent activity yet){/gray-fg}"]);
2775
+
2776
+ const liveTurnContent = liveCustomStatus?.turnResult?.type === "completed"
2777
+ ? liveCustomStatus.turnResult.content
2778
+ : liveResponsePayload?.type === "completed"
2779
+ ? liveResponsePayload.content
2780
+ : "";
2781
+
2782
+ // Populate session model if not already known
2783
+ if (info?.model) {
2784
+ sessionModels.set(orchId, info.model);
2785
+ if (orchId === activeOrchId) updateChatLabel();
2798
2786
  }
2799
- sessionHistoryLoadedAt.set(orchId, Date.now());
2800
- sessionRenderedCmsSeq.set(orchId, 0);
2801
- sessionRecoveredTurnResult.delete(orchId);
2802
- if (orchId === activeOrchId) {
2803
- invalidateChat();
2804
- invalidateActivity();
2787
+
2788
+ if ((!events || events.length === 0) && !liveTurnContent) {
2789
+ if (sessionHistoryLoadGeneration.get(orchId) !== generation) {
2790
+ return;
2791
+ }
2792
+ // Only blank the buffer if the observer hasn't already written
2793
+ // content into it. Otherwise we'd nuke live turn output that
2794
+ // arrived while we were fetching from CMS (race condition that
2795
+ // causes empty chat on first switch to a session).
2796
+ const existing = sessionChatBuffers.get(orchId);
2797
+ const isLoadingPlaceholder = existing
2798
+ && existing.length === 1
2799
+ && /Loading/.test(existing[0]);
2800
+ if (!existing || existing.length === 0 || isLoadingPlaceholder) {
2801
+ const splashLines = ensureSessionSplashBuffer(orchId);
2802
+ if (!splashLines) {
2803
+ sessionChatBuffers.set(orchId, ["{gray-fg}(no recent chat history yet){/gray-fg}"]);
2804
+ }
2805
+ }
2806
+ const existingActivity = sessionActivityBuffers.get(orchId);
2807
+ if (!existingActivity || existingActivity.length === 0) {
2808
+ sessionActivityBuffers.set(orchId, ["{gray-fg}(no recent activity yet){/gray-fg}"]);
2809
+ }
2810
+ sessionHistoryLoadedAt.set(orchId, Date.now());
2811
+ sessionRenderedCmsSeq.set(orchId, 0);
2812
+ sessionRecoveredTurnResult.delete(orchId);
2813
+ if (orchId === activeOrchId) {
2814
+ invalidateChat();
2815
+ invalidateActivity();
2816
+ }
2817
+ return;
2805
2818
  }
2806
- return;
2807
- }
2808
2819
 
2809
- // Strip the [SYSTEM: Running on host ...] prefix from user prompts
2810
- const stripHostPrefix = (text) => text?.replace(/^\[SYSTEM: Running on host "[^"]*"\.\]\n\n/, "") || text;
2820
+ // Strip the [SYSTEM: Running on host ...] prefix from user prompts
2821
+ const stripHostPrefix = (text) => text?.replace(/^\[SYSTEM: Running on host "[^"]*"\.\]\n\n/, "") || text;
2811
2822
 
2812
- // Filter out internal timer continuation prompts — these aren't real user messages
2813
- const isTimerPrompt = (text) => /^The \d+ second wait is now complete\./i.test(text);
2823
+ // Filter out internal timer continuation prompts — these aren't real user messages
2824
+ const isTimerPrompt = (text) => /^The \d+ second wait is now complete\./i.test(text);
2814
2825
 
2815
- const lines = [];
2816
- const fmtTime = (value) => {
2817
- if (!value) return "--:--:--";
2818
- return formatDisplayTime(value, {
2819
- hour: "2-digit",
2820
- minute: "2-digit",
2821
- second: "2-digit",
2822
- });
2823
- };
2824
- const normalizeContent = (text) => (text || "").replace(/\r\n/g, "\n").trim();
2825
-
2826
- // Cap rendered events to the most recent N to keep switching fast.
2827
- const renderEvents = (events || []).length > MAX_RENDERED_EVENTS
2828
- ? events.slice(-MAX_RENDERED_EVENTS)
2829
- : (events || []);
2830
- const truncated = (events || []).length > MAX_RENDERED_EVENTS;
2831
-
2832
- // Build display lines from persisted events
2833
- // Chat lines = user messages + assistant responses
2834
- // Activity lines = tool calls, reasoning, status changes
2835
- const activityLines = [];
2836
- let renderedChars = 0;
2837
- let lastAssistantContent = "";
2838
- if (truncated) {
2839
- lines.push(`{gray-fg}── ${events.length - MAX_RENDERED_EVENTS} older events omitted (${events.length} total) · press {bold}e{/bold} to expand ──{/gray-fg}`);
2840
- lines.push("");
2841
- }
2842
- for (const evt of renderEvents) {
2843
- const type = evt.eventType;
2844
- const timeStr = fmtTime(evt.createdAt);
2845
- if (type === "user.message") {
2846
- const content = stripHostPrefix(evt.data?.content);
2847
- if (content && !content.startsWith("[SYSTEM:") && !isTimerPrompt(content) && !isBootstrapPromptForSession(content, orchId)) {
2848
- // Format CHILD_UPDATE messages as distinct cards
2849
- const childMatch = content.match(/^\[CHILD_UPDATE from=(\S+) type=(\S+)(?:\s+iter=(\d+))?\]\n?(.*)$/s);
2850
- if (childMatch) {
2851
- const childId = childMatch[1].slice(0, 8);
2852
- const updateType = childMatch[2];
2853
- const body = (childMatch[4] || "").trim();
2854
- const childTitle = sessionHeadings.get(`session-${childMatch[1]}`) || `Agent ${childId}`;
2855
- const typeColor = updateType === "completed" ? "green" : updateType === "error" ? "red" : "magenta";
2856
- lines.push(`{white-fg}[${timeStr}]{/white-fg}`);
2857
- lines.push(`{${typeColor}-fg}┌─ {bold}${childTitle}{/bold} · ${updateType} ─┐{/${typeColor}-fg}`);
2858
- if (body) {
2859
- const bodyLines = body.split("\n");
2860
- for (const bl of bodyLines.slice(0, 8)) {
2861
- lines.push(`{${typeColor}-fg}│{/${typeColor}-fg} ${bl}`);
2862
- }
2863
- if (bodyLines.length > 8) {
2864
- lines.push(`{${typeColor}-fg}│{/${typeColor}-fg} {gray-fg}… ${bodyLines.length - 8} more lines{/gray-fg}`);
2826
+ const lines = [];
2827
+ const fmtTime = (value) => {
2828
+ if (!value) return "--:--:--";
2829
+ return formatDisplayTime(value, {
2830
+ hour: "2-digit",
2831
+ minute: "2-digit",
2832
+ second: "2-digit",
2833
+ });
2834
+ };
2835
+ const normalizeContent = (text) => (text || "").replace(/\r\n/g, "\n").trim();
2836
+
2837
+ // Cap rendered events to the most recent N to keep switching fast.
2838
+ const renderEvents = (events || []).length > MAX_RENDERED_EVENTS
2839
+ ? events.slice(-MAX_RENDERED_EVENTS)
2840
+ : (events || []);
2841
+ const truncated = (events || []).length > MAX_RENDERED_EVENTS;
2842
+
2843
+ // Build display lines from persisted events
2844
+ // Chat lines = user messages + assistant responses
2845
+ // Activity lines = tool calls, reasoning, status changes
2846
+ const activityLines = [];
2847
+ let renderedChars = 0;
2848
+ let lastAssistantContent = "";
2849
+ if (truncated) {
2850
+ lines.push(`{gray-fg}── ${events.length - MAX_RENDERED_EVENTS} older events omitted (${events.length} total) · press {bold}e{/bold} to expand ──{/gray-fg}`);
2851
+ lines.push("");
2852
+ }
2853
+ for (const evt of renderEvents) {
2854
+ const type = evt.eventType;
2855
+ const timeStr = fmtTime(evt.createdAt);
2856
+ if (type === "user.message") {
2857
+ const content = stripHostPrefix(evt.data?.content);
2858
+ if (content && !content.startsWith("[SYSTEM:") && !isTimerPrompt(content) && !isBootstrapPromptForSession(content, orchId)) {
2859
+ // Format CHILD_UPDATE messages as distinct cards
2860
+ const childMatch = content.match(/^\[CHILD_UPDATE from=(\S+) type=(\S+)(?:\s+iter=(\d+))?\]\n?(.*)$/s);
2861
+ if (childMatch) {
2862
+ const childId = childMatch[1].slice(0, 8);
2863
+ const updateType = childMatch[2];
2864
+ const body = (childMatch[4] || "").trim();
2865
+ const childTitle = sessionHeadings.get(`session-${childMatch[1]}`) || `Agent ${childId}`;
2866
+ const typeColor = updateType === "completed" ? "green" : updateType === "error" ? "red" : "magenta";
2867
+ lines.push(`{white-fg}[${timeStr}]{/white-fg}`);
2868
+ lines.push(`{${typeColor}-fg}┌─ {bold}${childTitle}{/bold} · ${updateType} ─┐{/${typeColor}-fg}`);
2869
+ if (body) {
2870
+ const bodyLines = body.split("\n");
2871
+ for (const bl of bodyLines.slice(0, 8)) {
2872
+ lines.push(`{${typeColor}-fg}│{/${typeColor}-fg} ${bl}`);
2873
+ }
2874
+ if (bodyLines.length > 8) {
2875
+ lines.push(`{${typeColor}-fg}│{/${typeColor}-fg} {gray-fg}… ${bodyLines.length - 8} more lines{/gray-fg}`);
2876
+ }
2865
2877
  }
2878
+ lines.push(`{${typeColor}-fg}└${"─".repeat(30)}┘{/${typeColor}-fg}`);
2879
+ lines.push("");
2880
+ } else {
2881
+ lines.push(`{white-fg}[${timeStr}]{/white-fg} {bold}You:{/bold} ${content}`);
2866
2882
  }
2867
- lines.push(`{${typeColor}-fg}└${'─'.repeat(30)}┘{/${typeColor}-fg}`);
2868
- lines.push("");
2869
- } else {
2870
- lines.push(`{white-fg}[${timeStr}]{/white-fg} {bold}You:{/bold} ${content}`);
2871
2883
  }
2872
- }
2873
- } else if (type === "assistant.message") {
2874
- const content = evt.data?.content;
2875
- if (content) {
2876
- lastAssistantContent = content;
2877
- // Detect artifact links in history
2878
- detectArtifactLinks(content, orchId);
2879
- if (renderedChars >= MAX_TOTAL_RENDER_CHARS) {
2880
- lines.push(`{gray-fg}── additional assistant output omitted to keep session switching fast ──{/gray-fg}`);
2884
+ } else if (type === "assistant.message") {
2885
+ const content = evt.data?.content;
2886
+ if (content) {
2887
+ lastAssistantContent = content;
2888
+ detectArtifactLinks(content, orchId);
2889
+ if (renderedChars >= MAX_TOTAL_RENDER_CHARS) {
2890
+ lines.push(`{gray-fg}── additional assistant output omitted to keep session switching fast ──{/gray-fg}`);
2891
+ lines.push("");
2892
+ break;
2893
+ }
2894
+ const clipped = content.length > MAX_ASSISTANT_MESSAGE_CHARS
2895
+ ? content.slice(0, MAX_ASSISTANT_MESSAGE_CHARS) + "\n\n[output truncated in TUI history view]"
2896
+ : content;
2897
+ const displayClipped = clipped.replace(
2898
+ /artifact:\/\/[a-f0-9-]+\/([^\s"'{}]+)/g,
2899
+ "📎 **$1** _(press 'a' to download)_",
2900
+ );
2901
+ lines.push(`{white-fg}[${timeStr}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`);
2902
+ const rendered = renderMarkdown(displayClipped);
2903
+ renderedChars += clipped.length;
2904
+ for (const line of rendered.split("\n")) {
2905
+ lines.push(line);
2906
+ }
2881
2907
  lines.push("");
2882
- break;
2883
2908
  }
2884
- const clipped = content.length > MAX_ASSISTANT_MESSAGE_CHARS
2885
- ? content.slice(0, MAX_ASSISTANT_MESSAGE_CHARS) + "\n\n[output truncated in TUI history view]"
2886
- : content;
2887
- // Replace artifact:// URIs with highlighted display
2888
- const displayClipped = clipped.replace(
2889
- /artifact:\/\/[a-f0-9-]+\/([^\s"'{}]+)/g,
2890
- "📎 **$1** _(press 'a' to download)_",
2891
- );
2892
- lines.push(`{white-fg}[${timeStr}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`);
2893
- const rendered = renderMarkdown(displayClipped);
2894
- renderedChars += clipped.length;
2895
- for (const line of rendered.split("\n")) {
2896
- lines.push(line);
2897
- }
2898
- lines.push("");
2909
+ } 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}`);
2913
+ } 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}`);
2916
+ } else if (type === "abort" || type === "session.info" || type === "session.idle"
2917
+ || type === "session.usage_info" || type === "pending_messages.modified"
2918
+ || type === "assistant.usage") {
2919
+ // skip internal/noisy events
2920
+ } else {
2921
+ activityLines.push(`{white-fg}[${timeStr}] [${type}]{/white-fg}`);
2899
2922
  }
2900
- } else if (type === "tool.execution_start") {
2901
- const toolName = evt.data?.toolName || "tool";
2902
- const dsid = evt.data?.durableSessionId ? ` {gray-fg}[${shortId(evt.data.durableSessionId)}]{/gray-fg}` : "";
2903
- activityLines.push(`{white-fg}[${timeStr}]{/white-fg} {yellow-fg}▶ ${toolName}{/yellow-fg}${dsid}`);
2904
- } else if (type === "tool.execution_complete") {
2905
- const toolName = evt.data?.toolName || "tool";
2906
- activityLines.push(`{white-fg}[${timeStr}]{/white-fg} {green-fg}✓ ${toolName}{/green-fg}`);
2907
- } else if (type === "abort" || type === "session.info" || type === "session.idle"
2908
- || type === "session.usage_info" || type === "pending_messages.modified"
2909
- || type === "assistant.usage") {
2910
- // skip internal/noisy events
2911
- } else {
2912
- activityLines.push(`{white-fg}[${timeStr}] [${type}]{/white-fg}`);
2913
2923
  }
2914
- }
2915
2924
 
2916
- const normalizedLiveTurn = normalizeContent(liveTurnContent);
2917
- const normalizedLastAssistant = normalizeContent(lastAssistantContent);
2918
- const liveTurnMissingFromHistory = normalizedLiveTurn
2919
- && normalizedLiveTurn !== normalizedLastAssistant;
2925
+ const normalizedLiveTurn = normalizeContent(liveTurnContent);
2926
+ const normalizedLastAssistant = normalizeContent(lastAssistantContent);
2927
+ const liveTurnMissingFromHistory = normalizedLiveTurn
2928
+ && normalizedLiveTurn !== normalizedLastAssistant;
2920
2929
 
2921
- if (liveTurnMissingFromHistory) {
2922
- if (lines.length > 0 && lines[lines.length - 1] !== "") {
2930
+ if (liveTurnMissingFromHistory) {
2931
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
2932
+ lines.push("");
2933
+ }
2934
+ lines.push("{gray-fg}── latest turn result recovered from live status ──{/gray-fg}");
2923
2935
  lines.push("");
2936
+
2937
+ const clippedLiveTurn = liveTurnContent.length > MAX_ASSISTANT_MESSAGE_CHARS
2938
+ ? liveTurnContent.slice(0, MAX_ASSISTANT_MESSAGE_CHARS) + "\n\n[output truncated in TUI history view]"
2939
+ : liveTurnContent;
2940
+ lines.push(`{white-fg}[${fmtTime(Date.now())}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`);
2941
+ const renderedLiveTurn = renderMarkdown(clippedLiveTurn);
2942
+ renderedChars += clippedLiveTurn.length;
2943
+ for (const line of renderedLiveTurn.split("\n")) {
2944
+ lines.push(line);
2945
+ }
2946
+ lines.push("");
2947
+ sessionRecoveredTurnResult.set(orchId, normalizedLiveTurn);
2948
+ noteSeenResponseVersion(orchId, liveResponsePayload?.version);
2949
+ } else {
2950
+ sessionRecoveredTurnResult.delete(orchId);
2924
2951
  }
2925
- lines.push("{gray-fg}── latest turn result recovered from live status ──{/gray-fg}");
2926
- lines.push("");
2927
-
2928
- const clippedLiveTurn = liveTurnContent.length > MAX_ASSISTANT_MESSAGE_CHARS
2929
- ? liveTurnContent.slice(0, MAX_ASSISTANT_MESSAGE_CHARS) + "\n\n[output truncated in TUI history view]"
2930
- : liveTurnContent;
2931
- lines.push(`{white-fg}[${fmtTime(Date.now())}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`);
2932
- const renderedLiveTurn = renderMarkdown(clippedLiveTurn);
2933
- renderedChars += clippedLiveTurn.length;
2934
- for (const line of renderedLiveTurn.split("\n")) {
2935
- lines.push(line);
2936
- }
2937
- lines.push("");
2938
- sessionRecoveredTurnResult.set(orchId, normalizedLiveTurn);
2939
- noteSeenResponseVersion(orchId, liveResponsePayload?.version);
2940
- } else {
2941
- sessionRecoveredTurnResult.delete(orchId);
2942
- }
2943
2952
 
2944
- if (eventCount > 0) {
2945
- lines.push(`{white-fg}── recent history loaded from database (${eventCount} events fetched) ──{/white-fg}`);
2946
- lines.push("");
2947
- }
2953
+ if (eventCount > 0) {
2954
+ lines.push(`{white-fg}── recent history loaded from database (${eventCount} events fetched) ──{/white-fg}`);
2955
+ lines.push("");
2956
+ }
2948
2957
 
2949
- // For any session with splash metadata, keep the splash banner at the top of the chat
2950
- if (systemSplashText.has(orchId)) {
2951
- const splashText = systemSplashText.get(orchId);
2952
- const splashLines = splashText.split("\n");
2953
- const hasSplashPrefix = lines.length >= splashLines.length
2954
- && splashLines.every((line, idx) => lines[idx] === line);
2955
- if (!hasSplashPrefix) {
2956
- lines.unshift(...splashLines, "");
2958
+ if (systemSplashText.has(orchId)) {
2959
+ const splashText = systemSplashText.get(orchId);
2960
+ const splashLines = splashText.split("\n");
2961
+ const hasSplashPrefix = lines.length >= splashLines.length
2962
+ && splashLines.every((line, idx) => lines[idx] === line);
2963
+ if (!hasSplashPrefix) {
2964
+ lines.unshift(...splashLines, "");
2965
+ }
2966
+ sessionSplashApplied.add(orchId);
2957
2967
  }
2958
- sessionSplashApplied.add(orchId);
2959
- }
2960
2968
 
2961
- if (sessionHistoryLoadGeneration.get(orchId) !== generation) {
2962
- return;
2963
- }
2969
+ if (sessionHistoryLoadGeneration.get(orchId) !== generation) {
2970
+ return;
2971
+ }
2964
2972
 
2965
- const maxRenderedSeq = (events || []).reduce((max, evt) => Math.max(max, evt.seq || 0), 0);
2966
- sessionChatBuffers.set(orchId, lines);
2967
- sessionActivityBuffers.set(orchId, activityLines);
2968
- sessionHistoryLoadedAt.set(orchId, Date.now());
2969
- sessionRenderedCmsSeq.set(orchId, maxRenderedSeq);
2973
+ const maxRenderedSeq = (events || []).reduce((max, evt) => Math.max(max, evt.seq || 0), 0);
2974
+ sessionChatBuffers.set(orchId, lines);
2975
+ sessionActivityBuffers.set(orchId, activityLines);
2976
+ sessionHistoryLoadedAt.set(orchId, Date.now());
2977
+ sessionRenderedCmsSeq.set(orchId, maxRenderedSeq);
2970
2978
 
2971
- if (orchId === activeOrchId) {
2972
- // Let the frame loop sync this buffer to chatBox
2973
- invalidateChat();
2974
- invalidateActivity();
2975
- }
2979
+ if (orchId === activeOrchId) {
2980
+ invalidateChat();
2981
+ invalidateActivity();
2982
+ }
2976
2983
 
2977
- // Seed sequence view from CMS when no live worker-log sequence exists yet.
2978
- if (!seqCmsSeededSessions.has(orchId)) {
2979
- const existingSeq = seqEventBuffers.get(orchId) ?? [];
2980
- if (existingSeq.length === 0) {
2981
- const cmsNode = addSeqNode("cms");
2982
- const seeded = [];
2983
- for (const evt of events) {
2984
- const t = fmtTime(evt.createdAt);
2985
- if (evt.eventType === "user.message") {
2986
- const txt = stripHostPrefix(evt.data?.content || "");
2987
- if (txt && !isTimerPrompt(txt)) {
2988
- seeded.push({ type: "user_msg_synth", time: t, orchNode: cmsNode, actNode: cmsNode, label: txt });
2989
- }
2990
- } else if (evt.eventType === "assistant.message") {
2991
- const txt = evt.data?.content || "";
2992
- if (txt) {
2993
- seeded.push({ type: "response", time: t, orchNode: cmsNode, actNode: cmsNode, snippet: txt.slice(0, 40) });
2984
+ if (!seqCmsSeededSessions.has(orchId)) {
2985
+ const existingSeq = seqEventBuffers.get(orchId) ?? [];
2986
+ if (existingSeq.length === 0) {
2987
+ const cmsNode = addSeqNode("cms");
2988
+ const seeded = [];
2989
+ for (const evt of events) {
2990
+ const t = fmtTime(evt.createdAt);
2991
+ if (evt.eventType === "user.message") {
2992
+ const txt = stripHostPrefix(evt.data?.content || "");
2993
+ if (txt && !isTimerPrompt(txt)) {
2994
+ seeded.push({ type: "user_msg_synth", time: t, orchNode: cmsNode, actNode: cmsNode, label: txt });
2995
+ }
2996
+ } else if (evt.eventType === "assistant.message") {
2997
+ const txt = evt.data?.content || "";
2998
+ if (txt) {
2999
+ seeded.push({ type: "response", time: t, orchNode: cmsNode, actNode: cmsNode, snippet: txt.slice(0, 40) });
3000
+ }
3001
+ } else if (evt.eventType === "tool.execution_start") {
3002
+ seeded.push({ type: "activity_start", time: t, orchNode: cmsNode, actNode: cmsNode });
2994
3003
  }
2995
- } else if (evt.eventType === "tool.execution_start") {
2996
- seeded.push({ type: "activity_start", time: t, orchNode: cmsNode, actNode: cmsNode });
3004
+ }
3005
+ if (seeded.length > 0) {
3006
+ seqEventBuffers.set(orchId, seeded);
2997
3007
  }
2998
3008
  }
2999
- if (seeded.length > 0) {
3000
- seqEventBuffers.set(orchId, seeded);
3001
- }
3009
+ seqCmsSeededSessions.add(orchId);
3010
+ }
3011
+ } catch (err) {
3012
+ loadFailed = true;
3013
+ appendLog(`{yellow-fg}CMS history load failed: ${err.message}{/yellow-fg}`);
3014
+ } finally {
3015
+ if (sessionHistoryLoadPromises.get(orchId) === loadPromise) {
3016
+ sessionHistoryLoadPromises.delete(orchId);
3002
3017
  }
3003
- seqCmsSeededSessions.add(orchId);
3004
3018
  }
3005
- } catch (err) {
3006
- loadFailed = true;
3007
- appendLog(`{yellow-fg}CMS history load failed: ${err.message}{/yellow-fg}`);
3019
+ })();
3020
+
3021
+ sessionHistoryLoadPromises.set(orchId, loadPromise);
3022
+ try {
3023
+ return await loadPromise;
3024
+ } finally {
3025
+ perfEnd(_ph, {
3026
+ orchId: orchId.slice(0, 12),
3027
+ events: eventCount,
3028
+ err: loadFailed || undefined,
3029
+ force: force || undefined,
3030
+ });
3008
3031
  }
3009
- perfEnd(_ph, { orchId: orchId.slice(0, 12), events: eventCount, err: loadFailed || undefined });
3010
3032
  }
3011
3033
 
3012
3034
  // ─── Start the PilotSwarm client (embedded workers + client) ────────
@@ -3254,10 +3276,30 @@ if (!modelProviders) {
3254
3276
  // Will be populated from mgmt.getModelsByProvider() after mgmt.start()
3255
3277
  }
3256
3278
 
3257
- // Capture session policy + agent list from the first worker
3258
- const _workerSessionPolicy = workers[0]?.sessionPolicy || null;
3259
- const _workerAllowedAgentNames = workers[0]?.allowedAgentNames || [];
3260
- const _workerLoadedAgents = workers[0]?.loadedAgents || [];
3279
+ // Capture session policy + agent list from the first worker.
3280
+ // In remote mode (no local workers), load directly from the plugin directory
3281
+ // so the TUI enforces the same session creation restrictions as the backend.
3282
+ let _workerSessionPolicy = workers[0]?.sessionPolicy || null;
3283
+ let _workerAllowedAgentNames = workers[0]?.allowedAgentNames || [];
3284
+ let _workerLoadedAgents = workers[0]?.loadedAgents || [];
3285
+
3286
+ if (workers.length === 0 && process.env.PLUGIN_DIRS) {
3287
+ const pluginDirsArr = process.env.PLUGIN_DIRS.split(",").map(d => d.trim()).filter(Boolean);
3288
+ for (const dir of pluginDirsArr) {
3289
+ const policyFile = path.join(dir, "session-policy.json");
3290
+ if (fs.existsSync(policyFile)) {
3291
+ try { _workerSessionPolicy = JSON.parse(fs.readFileSync(policyFile, "utf-8")); } catch {}
3292
+ }
3293
+ const agentsDir = path.join(dir, "agents");
3294
+ if (fs.existsSync(agentsDir)) {
3295
+ try {
3296
+ const agents = loadAgentFiles(agentsDir).filter(a => !a.system && a.name !== "default");
3297
+ _workerLoadedAgents = agents;
3298
+ _workerAllowedAgentNames = agents.map(a => a.name).filter(Boolean);
3299
+ } catch {}
3300
+ }
3301
+ }
3302
+ }
3261
3303
  if (_workerSessionPolicy) {
3262
3304
  appendLog(`Session policy: mode=${_workerSessionPolicy.creation?.mode || "open"}, allowGeneric=${_workerSessionPolicy.creation?.allowGeneric ?? true}`);
3263
3305
  }
@@ -3402,6 +3444,7 @@ const sessionAgentIds = new Map(); // orchId → agentId string (e.g. "pilotswar
3402
3444
  const sessionChatBuffers = new Map(); // orchId → string[]
3403
3445
  const sessionHistoryLoadedAt = new Map(); // orchId → epoch ms of last CMS history load
3404
3446
  const sessionHistoryLoadGeneration = new Map(); // orchId → monotonically increasing async load token
3447
+ const sessionHistoryLoadPromises = new Map(); // orchId → in-flight CMS history load promise
3405
3448
  const sessionRenderedCmsSeq = new Map(); // orchId → highest CMS seq already incorporated into buffers
3406
3449
  const sessionExpandLevel = new Map(); // orchId → 0 (default) | 1 | 2 (how many times user expanded history)
3407
3450
  const sessionSplashApplied = new Set(); // orchIds that have had splash prepended (idempotency guard)
@@ -6509,7 +6552,7 @@ screen.on("keypress", (ch, key) => {
6509
6552
  const levelNames = ["", "expanded (500)", "full history"];
6510
6553
  setStatus(`Loading ${levelNames[currentLevel + 1]}...`);
6511
6554
  screen.render();
6512
- loadCmsHistory(targetOrchId).then(() => {
6555
+ loadCmsHistory(targetOrchId, { force: true }).then(() => {
6513
6556
  if (targetOrchId === activeOrchId) {
6514
6557
  startCmsPoller(targetOrchId);
6515
6558
  invalidateChat("top");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Terminal UI for pilotswarm — interactive durable agent orchestration.",
5
5
  "type": "module",
6
6
  "bin": {