@saga-ai/cli 2.12.1 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -25,8 +25,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
 
26
26
  // src/cli.ts
27
27
  var import_commander = require("commander");
28
- var import_node_path8 = require("node:path");
29
- var import_node_fs7 = require("node:fs");
28
+ var import_node_path9 = require("node:path");
29
+ var import_node_fs8 = require("node:fs");
30
30
 
31
31
  // src/commands/init.ts
32
32
  var import_node_path2 = require("node:path");
@@ -583,6 +583,7 @@ async function findStory(projectPath, query, options = {}) {
583
583
  // src/lib/sessions.ts
584
584
  var import_node_child_process = require("node:child_process");
585
585
  var import_node_fs4 = require("node:fs");
586
+ var import_promises2 = require("node:fs/promises");
586
587
  var import_node_path4 = require("node:path");
587
588
  var OUTPUT_DIR = "/tmp/saga-sessions";
588
589
  function shellEscape(str) {
@@ -741,6 +742,70 @@ async function killSession(sessionName) {
741
742
  killed: result.status === 0
742
743
  };
743
744
  }
745
+ function parseSessionName(name) {
746
+ if (!name || !name.startsWith("saga__")) {
747
+ return null;
748
+ }
749
+ const parts = name.split("__");
750
+ if (parts.length !== 4) {
751
+ return null;
752
+ }
753
+ const [, epicSlug, storySlug, pid] = parts;
754
+ if (!epicSlug || !storySlug || !pid) {
755
+ return null;
756
+ }
757
+ return {
758
+ epicSlug,
759
+ storySlug
760
+ };
761
+ }
762
+ async function buildSessionInfo(name, status) {
763
+ const parsed = parseSessionName(name);
764
+ if (!parsed) {
765
+ return null;
766
+ }
767
+ const outputFile = (0, import_node_path4.join)(OUTPUT_DIR, `${name}.out`);
768
+ const outputAvailable = (0, import_node_fs4.existsSync)(outputFile);
769
+ let startTime = /* @__PURE__ */ new Date();
770
+ let endTime;
771
+ let outputPreview;
772
+ if (outputAvailable) {
773
+ try {
774
+ const stats = await (0, import_promises2.stat)(outputFile);
775
+ startTime = stats.birthtime;
776
+ if (status === "completed") {
777
+ endTime = stats.mtime;
778
+ }
779
+ } catch {
780
+ }
781
+ try {
782
+ const content = await (0, import_promises2.readFile)(outputFile, "utf-8");
783
+ const lines = content.split("\n").filter((line) => line.length > 0);
784
+ if (lines.length > 0) {
785
+ const lastLines = lines.slice(-5);
786
+ let preview = lastLines.join("\n");
787
+ if (preview.length > 500) {
788
+ const truncated = preview.slice(0, 500);
789
+ const lastNewline = truncated.lastIndexOf("\n");
790
+ preview = lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated;
791
+ }
792
+ outputPreview = preview;
793
+ }
794
+ } catch {
795
+ }
796
+ }
797
+ return {
798
+ name,
799
+ epicSlug: parsed.epicSlug,
800
+ storySlug: parsed.storySlug,
801
+ status,
802
+ outputFile,
803
+ outputAvailable,
804
+ startTime,
805
+ endTime,
806
+ outputPreview
807
+ };
808
+ }
744
809
 
745
810
  // src/commands/implement.ts
746
811
  var DEFAULT_MAX_CYCLES = 10;
@@ -1276,21 +1341,21 @@ Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`)
1276
1341
  }
1277
1342
 
1278
1343
  // src/server/index.ts
1279
- var import_express2 = __toESM(require("express"), 1);
1344
+ var import_express3 = __toESM(require("express"), 1);
1280
1345
  var import_http = require("http");
1281
1346
  var import_path6 = require("path");
1282
1347
 
1283
1348
  // src/server/routes.ts
1284
- var import_express = require("express");
1349
+ var import_express2 = require("express");
1285
1350
 
1286
1351
  // src/server/parser.ts
1287
- var import_promises2 = require("fs/promises");
1352
+ var import_promises3 = require("fs/promises");
1288
1353
  var import_path2 = require("path");
1289
1354
  var import_gray_matter2 = __toESM(require("gray-matter"), 1);
1290
1355
  async function toStoryDetail(story, sagaRoot) {
1291
1356
  let tasks = [];
1292
1357
  try {
1293
- const content = await (0, import_promises2.readFile)(story.storyPath, "utf-8");
1358
+ const content = await (0, import_promises3.readFile)(story.storyPath, "utf-8");
1294
1359
  const parsed = (0, import_gray_matter2.default)(content);
1295
1360
  tasks = parseTasks(parsed.data.tasks);
1296
1361
  } catch {
@@ -1335,11 +1400,11 @@ function validateTaskStatus(status) {
1335
1400
  return "pending";
1336
1401
  }
1337
1402
  async function parseStory(storyPath, epicSlug) {
1338
- const { join: join13 } = await import("path");
1339
- const { stat: stat2 } = await import("fs/promises");
1403
+ const { join: join14 } = await import("path");
1404
+ const { stat: stat4 } = await import("fs/promises");
1340
1405
  let content;
1341
1406
  try {
1342
- content = await (0, import_promises2.readFile)(storyPath, "utf-8");
1407
+ content = await (0, import_promises3.readFile)(storyPath, "utf-8");
1343
1408
  } catch {
1344
1409
  return null;
1345
1410
  }
@@ -1355,10 +1420,10 @@ async function parseStory(storyPath, epicSlug) {
1355
1420
  const title = frontmatter.title || dirName;
1356
1421
  const status = validateStatus(frontmatter.status);
1357
1422
  const tasks = parseTasks(frontmatter.tasks);
1358
- const journalPath = join13(storyDir, "journal.md");
1423
+ const journalPath = join14(storyDir, "journal.md");
1359
1424
  let hasJournal = false;
1360
1425
  try {
1361
- await stat2(journalPath);
1426
+ await stat4(journalPath);
1362
1427
  hasJournal = true;
1363
1428
  } catch {
1364
1429
  }
@@ -1376,7 +1441,7 @@ async function parseStory(storyPath, epicSlug) {
1376
1441
  }
1377
1442
  async function parseJournal(journalPath) {
1378
1443
  try {
1379
- const content = await (0, import_promises2.readFile)(journalPath, "utf-8");
1444
+ const content = await (0, import_promises3.readFile)(journalPath, "utf-8");
1380
1445
  const entries = [];
1381
1446
  const sections = content.split(/^##\s+/m).slice(1);
1382
1447
  for (const section of sections) {
@@ -1455,6 +1520,142 @@ async function scanSagaDirectory(sagaRoot) {
1455
1520
 
1456
1521
  // src/server/routes.ts
1457
1522
  var import_path3 = require("path");
1523
+
1524
+ // src/server/session-routes.ts
1525
+ var import_express = require("express");
1526
+
1527
+ // src/lib/session-polling.ts
1528
+ var POLLING_INTERVAL_MS = 3e3;
1529
+ var pollingInterval = null;
1530
+ var currentSessions = [];
1531
+ var isFirstPoll = true;
1532
+ function getCurrentSessions() {
1533
+ return [...currentSessions];
1534
+ }
1535
+ function startSessionPolling(broadcast) {
1536
+ stopSessionPolling();
1537
+ pollSessions(broadcast);
1538
+ pollingInterval = setInterval(() => {
1539
+ pollSessions(broadcast);
1540
+ }, POLLING_INTERVAL_MS);
1541
+ }
1542
+ function stopSessionPolling() {
1543
+ if (pollingInterval) {
1544
+ clearInterval(pollingInterval);
1545
+ pollingInterval = null;
1546
+ }
1547
+ currentSessions = [];
1548
+ isFirstPoll = true;
1549
+ }
1550
+ async function pollSessions(broadcast) {
1551
+ try {
1552
+ const sessions = await discoverSessions();
1553
+ const hasChanges = detectChanges(sessions);
1554
+ if (hasChanges) {
1555
+ currentSessions = sessions;
1556
+ isFirstPoll = false;
1557
+ broadcast({
1558
+ type: "sessions:updated",
1559
+ sessions
1560
+ });
1561
+ }
1562
+ } catch (error) {
1563
+ console.error("Error polling sessions:", error);
1564
+ }
1565
+ }
1566
+ async function discoverSessions() {
1567
+ const rawSessions = await listSessions();
1568
+ const detailedSessions = [];
1569
+ for (const session of rawSessions) {
1570
+ try {
1571
+ const statusResult = await getSessionStatus(session.name);
1572
+ const status = statusResult.running ? "running" : "completed";
1573
+ const detailed = await buildSessionInfo(session.name, status);
1574
+ if (detailed) {
1575
+ detailedSessions.push(detailed);
1576
+ }
1577
+ } catch (error) {
1578
+ console.error(`Error building session info for ${session.name}:`, error);
1579
+ }
1580
+ }
1581
+ detailedSessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
1582
+ return detailedSessions;
1583
+ }
1584
+ function detectChanges(newSessions) {
1585
+ if (isFirstPoll) {
1586
+ return true;
1587
+ }
1588
+ if (newSessions.length !== currentSessions.length) {
1589
+ return true;
1590
+ }
1591
+ const newSessionMap = /* @__PURE__ */ new Map();
1592
+ for (const session of newSessions) {
1593
+ newSessionMap.set(session.name, session);
1594
+ }
1595
+ const currentSessionMap = /* @__PURE__ */ new Map();
1596
+ for (const session of currentSessions) {
1597
+ currentSessionMap.set(session.name, session);
1598
+ }
1599
+ for (const name of newSessionMap.keys()) {
1600
+ if (!currentSessionMap.has(name)) {
1601
+ return true;
1602
+ }
1603
+ }
1604
+ for (const name of currentSessionMap.keys()) {
1605
+ if (!newSessionMap.has(name)) {
1606
+ return true;
1607
+ }
1608
+ }
1609
+ for (const [name, newSession] of newSessionMap) {
1610
+ const currentSession = currentSessionMap.get(name);
1611
+ if (currentSession && currentSession.status !== newSession.status) {
1612
+ return true;
1613
+ }
1614
+ }
1615
+ return false;
1616
+ }
1617
+
1618
+ // src/server/session-routes.ts
1619
+ function createSessionApiRouter() {
1620
+ const router = (0, import_express.Router)();
1621
+ router.get("/sessions", (_req, res) => {
1622
+ try {
1623
+ let sessions = getCurrentSessions();
1624
+ const { epicSlug, storySlug, status } = _req.query;
1625
+ if (epicSlug && typeof epicSlug === "string") {
1626
+ sessions = sessions.filter((s) => s.epicSlug === epicSlug);
1627
+ if (storySlug && typeof storySlug === "string") {
1628
+ sessions = sessions.filter((s) => s.storySlug === storySlug);
1629
+ }
1630
+ }
1631
+ if (status && typeof status === "string" && (status === "running" || status === "completed")) {
1632
+ sessions = sessions.filter((s) => s.status === status);
1633
+ }
1634
+ res.json(sessions);
1635
+ } catch (error) {
1636
+ console.error("Error fetching sessions:", error);
1637
+ res.status(500).json({ error: "Failed to fetch sessions" });
1638
+ }
1639
+ });
1640
+ router.get("/sessions/:sessionName", (req, res) => {
1641
+ try {
1642
+ const { sessionName } = req.params;
1643
+ const sessions = getCurrentSessions();
1644
+ const session = sessions.find((s) => s.name === sessionName);
1645
+ if (!session) {
1646
+ res.status(404).json({ error: "Session not found" });
1647
+ return;
1648
+ }
1649
+ res.json(session);
1650
+ } catch (error) {
1651
+ console.error("Error fetching session:", error);
1652
+ res.status(500).json({ error: "Failed to fetch session" });
1653
+ }
1654
+ });
1655
+ return router;
1656
+ }
1657
+
1658
+ // src/server/routes.ts
1458
1659
  async function getEpics(sagaRoot) {
1459
1660
  return scanSagaDirectory(sagaRoot);
1460
1661
  }
@@ -1467,7 +1668,7 @@ function toEpicSummary(epic) {
1467
1668
  };
1468
1669
  }
1469
1670
  function createApiRouter(sagaRoot) {
1470
- const router = (0, import_express.Router)();
1671
+ const router = (0, import_express2.Router)();
1471
1672
  router.get("/epics", async (_req, res) => {
1472
1673
  try {
1473
1674
  const epics = await getEpics(sagaRoot);
@@ -1520,6 +1721,7 @@ function createApiRouter(sagaRoot) {
1520
1721
  res.status(500).json({ error: "Failed to fetch story" });
1521
1722
  }
1522
1723
  });
1724
+ router.use(createSessionApiRouter());
1523
1725
  router.use((_req, res) => {
1524
1726
  res.status(404).json({ error: "API endpoint not found" });
1525
1727
  });
@@ -1685,6 +1887,290 @@ async function createSagaWatcher(sagaRoot) {
1685
1887
 
1686
1888
  // src/server/websocket.ts
1687
1889
  var import_path5 = require("path");
1890
+
1891
+ // src/lib/log-stream-manager.ts
1892
+ var import_chokidar2 = __toESM(require("chokidar"), 1);
1893
+ var import_node_fs6 = require("node:fs");
1894
+ var import_promises4 = require("node:fs/promises");
1895
+ var import_node_path6 = require("node:path");
1896
+ var LogStreamManager = class {
1897
+ /**
1898
+ * Active file watchers indexed by session name
1899
+ */
1900
+ watchers = /* @__PURE__ */ new Map();
1901
+ /**
1902
+ * Current file position (byte offset) per session for incremental reads
1903
+ */
1904
+ filePositions = /* @__PURE__ */ new Map();
1905
+ /**
1906
+ * Client subscriptions per session
1907
+ */
1908
+ subscriptions = /* @__PURE__ */ new Map();
1909
+ /**
1910
+ * Function to send messages to clients
1911
+ */
1912
+ sendToClient;
1913
+ /**
1914
+ * Create a new LogStreamManager instance
1915
+ *
1916
+ * @param sendToClient - Function to send log data messages to clients
1917
+ */
1918
+ constructor(sendToClient) {
1919
+ this.sendToClient = sendToClient;
1920
+ }
1921
+ /**
1922
+ * Get the number of subscriptions for a session
1923
+ *
1924
+ * @param sessionName - The session to check
1925
+ * @returns Number of subscribed clients
1926
+ */
1927
+ getSubscriptionCount(sessionName) {
1928
+ const subs = this.subscriptions.get(sessionName);
1929
+ return subs ? subs.size : 0;
1930
+ }
1931
+ /**
1932
+ * Check if a watcher exists for a session
1933
+ *
1934
+ * @param sessionName - The session to check
1935
+ * @returns True if a watcher exists
1936
+ */
1937
+ hasWatcher(sessionName) {
1938
+ return this.watchers.has(sessionName);
1939
+ }
1940
+ /**
1941
+ * Get the current file position for a session
1942
+ *
1943
+ * @param sessionName - The session to check
1944
+ * @returns The current byte offset, or 0 if not tracked
1945
+ */
1946
+ getFilePosition(sessionName) {
1947
+ return this.filePositions.get(sessionName) ?? 0;
1948
+ }
1949
+ /**
1950
+ * Subscribe a client to a session's log stream
1951
+ *
1952
+ * Reads the full file content and sends it as the initial message.
1953
+ * Adds the client to the subscription set for incremental updates.
1954
+ * Creates a file watcher if this is the first subscriber.
1955
+ *
1956
+ * @param sessionName - The session to subscribe to
1957
+ * @param ws - The WebSocket client to subscribe
1958
+ */
1959
+ async subscribe(sessionName, ws) {
1960
+ const outputFile = (0, import_node_path6.join)(OUTPUT_DIR, `${sessionName}.out`);
1961
+ if (!(0, import_node_fs6.existsSync)(outputFile)) {
1962
+ this.sendToClient(ws, {
1963
+ type: "logs:error",
1964
+ sessionName,
1965
+ error: `Output file not found: ${outputFile}`
1966
+ });
1967
+ return;
1968
+ }
1969
+ const content = await (0, import_promises4.readFile)(outputFile, "utf-8");
1970
+ this.sendToClient(ws, {
1971
+ type: "logs:data",
1972
+ sessionName,
1973
+ data: content,
1974
+ isInitial: true,
1975
+ isComplete: false
1976
+ });
1977
+ this.filePositions.set(sessionName, content.length);
1978
+ let subs = this.subscriptions.get(sessionName);
1979
+ if (!subs) {
1980
+ subs = /* @__PURE__ */ new Set();
1981
+ this.subscriptions.set(sessionName, subs);
1982
+ }
1983
+ subs.add(ws);
1984
+ if (!this.watchers.has(sessionName)) {
1985
+ this.createWatcher(sessionName, outputFile);
1986
+ }
1987
+ }
1988
+ /**
1989
+ * Create a chokidar file watcher for a session's output file
1990
+ *
1991
+ * The watcher detects changes and triggers incremental content delivery
1992
+ * to all subscribed clients.
1993
+ *
1994
+ * @param sessionName - The session name
1995
+ * @param outputFile - Path to the session output file
1996
+ */
1997
+ createWatcher(sessionName, outputFile) {
1998
+ const watcher = import_chokidar2.default.watch(outputFile, {
1999
+ persistent: true,
2000
+ awaitWriteFinish: false
2001
+ });
2002
+ watcher.on("change", async () => {
2003
+ await this.sendIncrementalContent(sessionName, outputFile);
2004
+ });
2005
+ this.watchers.set(sessionName, watcher);
2006
+ }
2007
+ /**
2008
+ * Clean up a watcher and associated state for a session
2009
+ *
2010
+ * Closes the file watcher and removes all tracking state for the session.
2011
+ * Should be called when the last subscriber unsubscribes or disconnects.
2012
+ *
2013
+ * @param sessionName - The session to clean up
2014
+ */
2015
+ cleanupWatcher(sessionName) {
2016
+ const watcher = this.watchers.get(sessionName);
2017
+ if (watcher) {
2018
+ watcher.close();
2019
+ this.watchers.delete(sessionName);
2020
+ }
2021
+ this.filePositions.delete(sessionName);
2022
+ this.subscriptions.delete(sessionName);
2023
+ }
2024
+ /**
2025
+ * Send incremental content to all subscribed clients for a session
2026
+ *
2027
+ * Reads from the last known position to the end of the file and sends
2028
+ * the new content to all subscribed clients.
2029
+ *
2030
+ * @param sessionName - The session name
2031
+ * @param outputFile - Path to the session output file
2032
+ */
2033
+ async sendIncrementalContent(sessionName, outputFile) {
2034
+ const lastPosition = this.filePositions.get(sessionName) ?? 0;
2035
+ const fileStat = await (0, import_promises4.stat)(outputFile);
2036
+ const currentSize = fileStat.size;
2037
+ if (currentSize <= lastPosition) {
2038
+ return;
2039
+ }
2040
+ const newContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
2041
+ this.filePositions.set(sessionName, currentSize);
2042
+ const subs = this.subscriptions.get(sessionName);
2043
+ if (subs) {
2044
+ const message = {
2045
+ type: "logs:data",
2046
+ sessionName,
2047
+ data: newContent,
2048
+ isInitial: false,
2049
+ isComplete: false
2050
+ };
2051
+ for (const ws of subs) {
2052
+ this.sendToClient(ws, message);
2053
+ }
2054
+ }
2055
+ }
2056
+ /**
2057
+ * Read file content from a specific position
2058
+ *
2059
+ * @param filePath - Path to the file
2060
+ * @param start - Starting byte position
2061
+ * @param end - Ending byte position
2062
+ * @returns The content read from the file
2063
+ */
2064
+ readFromPosition(filePath, start, end) {
2065
+ return new Promise((resolve2, reject) => {
2066
+ let content = "";
2067
+ const stream = (0, import_node_fs6.createReadStream)(filePath, {
2068
+ start,
2069
+ end: end - 1,
2070
+ // createReadStream end is inclusive
2071
+ encoding: "utf-8"
2072
+ });
2073
+ stream.on("data", (chunk) => {
2074
+ content += chunk;
2075
+ });
2076
+ stream.on("end", () => {
2077
+ resolve2(content);
2078
+ });
2079
+ stream.on("error", reject);
2080
+ });
2081
+ }
2082
+ /**
2083
+ * Unsubscribe a client from a session's log stream
2084
+ *
2085
+ * Removes the client from the subscription set. If this was the last
2086
+ * subscriber, cleans up the watcher and associated state.
2087
+ *
2088
+ * @param sessionName - The session to unsubscribe from
2089
+ * @param ws - The WebSocket client to unsubscribe
2090
+ */
2091
+ unsubscribe(sessionName, ws) {
2092
+ const subs = this.subscriptions.get(sessionName);
2093
+ if (subs) {
2094
+ subs.delete(ws);
2095
+ if (subs.size === 0) {
2096
+ this.cleanupWatcher(sessionName);
2097
+ }
2098
+ }
2099
+ }
2100
+ /**
2101
+ * Handle client disconnect by removing from all subscriptions
2102
+ *
2103
+ * Should be called when a WebSocket connection closes to clean up
2104
+ * any subscriptions the client may have had. Also triggers watcher
2105
+ * cleanup for any sessions that no longer have subscribers.
2106
+ *
2107
+ * @param ws - The WebSocket client that disconnected
2108
+ */
2109
+ handleClientDisconnect(ws) {
2110
+ for (const [sessionName, subs] of this.subscriptions) {
2111
+ subs.delete(ws);
2112
+ if (subs.size === 0) {
2113
+ this.cleanupWatcher(sessionName);
2114
+ }
2115
+ }
2116
+ }
2117
+ /**
2118
+ * Notify that a session has completed
2119
+ *
2120
+ * Reads any remaining content from the file and sends it with isComplete=true
2121
+ * to all subscribed clients, then cleans up the watcher regardless of
2122
+ * subscription count. Called by session polling when it detects completion.
2123
+ *
2124
+ * @param sessionName - The session that has completed
2125
+ */
2126
+ async notifySessionCompleted(sessionName) {
2127
+ const subs = this.subscriptions.get(sessionName);
2128
+ if (!subs || subs.size === 0) {
2129
+ return;
2130
+ }
2131
+ const outputFile = (0, import_node_path6.join)(OUTPUT_DIR, `${sessionName}.out`);
2132
+ let finalContent = "";
2133
+ try {
2134
+ if ((0, import_node_fs6.existsSync)(outputFile)) {
2135
+ const lastPosition = this.filePositions.get(sessionName) ?? 0;
2136
+ const fileStat = await (0, import_promises4.stat)(outputFile);
2137
+ const currentSize = fileStat.size;
2138
+ if (currentSize > lastPosition) {
2139
+ finalContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
2140
+ }
2141
+ }
2142
+ } catch {
2143
+ }
2144
+ const message = {
2145
+ type: "logs:data",
2146
+ sessionName,
2147
+ data: finalContent,
2148
+ isInitial: false,
2149
+ isComplete: true
2150
+ };
2151
+ for (const ws of subs) {
2152
+ this.sendToClient(ws, message);
2153
+ }
2154
+ this.cleanupWatcher(sessionName);
2155
+ }
2156
+ /**
2157
+ * Clean up all watchers and subscriptions
2158
+ *
2159
+ * Call this when shutting down the server.
2160
+ */
2161
+ async dispose() {
2162
+ const closePromises = [];
2163
+ for (const [, watcher] of this.watchers) {
2164
+ closePromises.push(watcher.close());
2165
+ }
2166
+ await Promise.all(closePromises);
2167
+ this.watchers.clear();
2168
+ this.filePositions.clear();
2169
+ this.subscriptions.clear();
2170
+ }
2171
+ };
2172
+
2173
+ // src/server/websocket.ts
1688
2174
  function makeStoryKey(epicSlug, storySlug) {
1689
2175
  return `${epicSlug}:${storySlug}`;
1690
2176
  }
@@ -1721,11 +2207,30 @@ async function createWebSocketServer(httpServer, sagaRoot) {
1721
2207
  ws.send(JSON.stringify(message));
1722
2208
  }
1723
2209
  }
2210
+ function sendLogMessage(ws, message) {
2211
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
2212
+ ws.send(JSON.stringify({ event: message.type, data: message }));
2213
+ }
2214
+ }
2215
+ const logStreamManager = new LogStreamManager(sendLogMessage);
1724
2216
  function broadcast(message) {
1725
2217
  for (const [ws] of clients) {
1726
2218
  sendToClient(ws, message);
1727
2219
  }
1728
2220
  }
2221
+ let previousSessionStates = /* @__PURE__ */ new Map();
2222
+ startSessionPolling((msg) => {
2223
+ broadcast({ event: msg.type, data: msg.sessions });
2224
+ const currentStates = /* @__PURE__ */ new Map();
2225
+ for (const session of msg.sessions) {
2226
+ currentStates.set(session.name, session.status);
2227
+ const previousStatus = previousSessionStates.get(session.name);
2228
+ if (previousStatus === "running" && session.status === "completed") {
2229
+ logStreamManager.notifySessionCompleted(session.name);
2230
+ }
2231
+ }
2232
+ previousSessionStates = currentStates;
2233
+ });
1729
2234
  function broadcastToSubscribers(storyKey, message) {
1730
2235
  for (const [ws, state] of clients) {
1731
2236
  if (state.subscribedStories.has(storyKey)) {
@@ -1766,6 +2271,20 @@ async function createWebSocketServer(httpServer, sagaRoot) {
1766
2271
  }
1767
2272
  break;
1768
2273
  }
2274
+ case "subscribe:logs": {
2275
+ const { sessionName } = message.data || {};
2276
+ if (sessionName) {
2277
+ logStreamManager.subscribe(sessionName, ws);
2278
+ }
2279
+ break;
2280
+ }
2281
+ case "unsubscribe:logs": {
2282
+ const { sessionName } = message.data || {};
2283
+ if (sessionName) {
2284
+ logStreamManager.unsubscribe(sessionName, ws);
2285
+ }
2286
+ break;
2287
+ }
1769
2288
  default:
1770
2289
  break;
1771
2290
  }
@@ -1774,10 +2293,12 @@ async function createWebSocketServer(httpServer, sagaRoot) {
1774
2293
  });
1775
2294
  ws.on("close", () => {
1776
2295
  clients.delete(ws);
2296
+ logStreamManager.handleClientDisconnect(ws);
1777
2297
  });
1778
2298
  ws.on("error", (err) => {
1779
2299
  console.error("WebSocket error:", err);
1780
2300
  clients.delete(ws);
2301
+ logStreamManager.handleClientDisconnect(ws);
1781
2302
  });
1782
2303
  });
1783
2304
  if (watcher) {
@@ -1848,6 +2369,8 @@ async function createWebSocketServer(httpServer, sagaRoot) {
1848
2369
  },
1849
2370
  async close() {
1850
2371
  clearInterval(heartbeatInterval);
2372
+ stopSessionPolling();
2373
+ await logStreamManager.dispose();
1851
2374
  for (const [ws] of clients) {
1852
2375
  ws.close();
1853
2376
  }
@@ -1871,8 +2394,8 @@ async function createWebSocketServer(httpServer, sagaRoot) {
1871
2394
  // src/server/index.ts
1872
2395
  var DEFAULT_PORT = 3847;
1873
2396
  function createApp(sagaRoot) {
1874
- const app = (0, import_express2.default)();
1875
- app.use(import_express2.default.json());
2397
+ const app = (0, import_express3.default)();
2398
+ app.use(import_express3.default.json());
1876
2399
  app.use((_req, res, next) => {
1877
2400
  res.header("Access-Control-Allow-Origin", "*");
1878
2401
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
@@ -1885,7 +2408,7 @@ function createApp(sagaRoot) {
1885
2408
  app.use("/api", createApiRouter(sagaRoot));
1886
2409
  const clientDistPath = (0, import_path6.join)(__dirname, "client");
1887
2410
  const indexHtmlPath = (0, import_path6.join)(clientDistPath, "index.html");
1888
- app.use(import_express2.default.static(clientDistPath));
2411
+ app.use(import_express3.default.static(clientDistPath));
1889
2412
  app.get("/{*splat}", (_req, res) => {
1890
2413
  res.sendFile("index.html", { root: clientDistPath });
1891
2414
  });
@@ -1953,7 +2476,7 @@ async function dashboardCommand(options) {
1953
2476
  }
1954
2477
 
1955
2478
  // src/commands/scope-validator.ts
1956
- var import_node_path6 = require("node:path");
2479
+ var import_node_path7 = require("node:path");
1957
2480
  function getFilePathFromInput(hookInput) {
1958
2481
  try {
1959
2482
  const data = JSON.parse(hookInput);
@@ -1973,10 +2496,10 @@ function isArchiveAccess(path) {
1973
2496
  return path.includes(".saga/archive");
1974
2497
  }
1975
2498
  function isWithinWorktree(filePath, worktreePath) {
1976
- const absoluteFilePath = (0, import_node_path6.resolve)(filePath);
1977
- const absoluteWorktree = (0, import_node_path6.resolve)(worktreePath);
1978
- const relativePath = (0, import_node_path6.relative)(absoluteWorktree, absoluteFilePath);
1979
- if (relativePath.startsWith("..") || (0, import_node_path6.resolve)(relativePath) === relativePath) {
2499
+ const absoluteFilePath = (0, import_node_path7.resolve)(filePath);
2500
+ const absoluteWorktree = (0, import_node_path7.resolve)(worktreePath);
2501
+ const relativePath = (0, import_node_path7.relative)(absoluteWorktree, absoluteFilePath);
2502
+ if (relativePath.startsWith("..") || (0, import_node_path7.resolve)(relativePath) === relativePath) {
1980
2503
  return false;
1981
2504
  }
1982
2505
  return true;
@@ -2091,8 +2614,8 @@ async function findCommand(query, options) {
2091
2614
  }
2092
2615
 
2093
2616
  // src/commands/worktree.ts
2094
- var import_node_path7 = require("node:path");
2095
- var import_node_fs6 = require("node:fs");
2617
+ var import_node_path8 = require("node:path");
2618
+ var import_node_fs7 = require("node:fs");
2096
2619
  var import_node_child_process3 = require("node:child_process");
2097
2620
  function runGitCommand(args, cwd) {
2098
2621
  try {
@@ -2123,7 +2646,7 @@ function getMainBranch(cwd) {
2123
2646
  }
2124
2647
  function createWorktree(projectPath, epicSlug, storySlug) {
2125
2648
  const branchName = `story-${storySlug}-epic-${epicSlug}`;
2126
- const worktreePath = (0, import_node_path7.join)(
2649
+ const worktreePath = (0, import_node_path8.join)(
2127
2650
  projectPath,
2128
2651
  ".saga",
2129
2652
  "worktrees",
@@ -2136,7 +2659,7 @@ function createWorktree(projectPath, epicSlug, storySlug) {
2136
2659
  error: `Branch already exists: ${branchName}`
2137
2660
  };
2138
2661
  }
2139
- if ((0, import_node_fs6.existsSync)(worktreePath)) {
2662
+ if ((0, import_node_fs7.existsSync)(worktreePath)) {
2140
2663
  return {
2141
2664
  success: false,
2142
2665
  error: `Worktree directory already exists: ${worktreePath}`
@@ -2154,8 +2677,8 @@ function createWorktree(projectPath, epicSlug, storySlug) {
2154
2677
  error: `Failed to create branch: ${createBranchResult.output}`
2155
2678
  };
2156
2679
  }
2157
- const worktreeParent = (0, import_node_path7.join)(projectPath, ".saga", "worktrees", epicSlug);
2158
- (0, import_node_fs6.mkdirSync)(worktreeParent, { recursive: true });
2680
+ const worktreeParent = (0, import_node_path8.join)(projectPath, ".saga", "worktrees", epicSlug);
2681
+ (0, import_node_fs7.mkdirSync)(worktreeParent, { recursive: true });
2159
2682
  const createWorktreeResult = runGitCommand(
2160
2683
  ["worktree", "add", worktreePath, branchName],
2161
2684
  projectPath
@@ -2211,8 +2734,8 @@ async function sessionsKillCommand(sessionName) {
2211
2734
  }
2212
2735
 
2213
2736
  // src/cli.ts
2214
- var packageJsonPath = (0, import_node_path8.join)(__dirname, "..", "package.json");
2215
- var packageJson = JSON.parse((0, import_node_fs7.readFileSync)(packageJsonPath, "utf-8"));
2737
+ var packageJsonPath = (0, import_node_path9.join)(__dirname, "..", "package.json");
2738
+ var packageJson = JSON.parse((0, import_node_fs8.readFileSync)(packageJsonPath, "utf-8"));
2216
2739
  var program = new import_commander.Command();
2217
2740
  program.name("saga").description("CLI for SAGA - Structured Autonomous Goal Achievement").version(packageJson.version).addHelpCommand("help [command]", "Display help for a command");
2218
2741
  program.option("-p, --path <dir>", "Path to SAGA project directory (overrides auto-discovery)");