@saga-ai/cli 0.7.0 → 0.7.2

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 (3) hide show
  1. package/README.md +21 -0
  2. package/dist/cli.cjs +47 -126
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,6 +16,7 @@ npx @saga-ai/cli init
16
16
  npx @saga-ai/cli find <query>
17
17
  npx @saga-ai/cli worktree <epic-slug> <story-slug>
18
18
  npx @saga-ai/cli implement <story-slug>
19
+ npx @saga-ai/cli sessions list
19
20
  npx @saga-ai/cli dashboard
20
21
  ```
21
22
 
@@ -49,6 +50,7 @@ saga find auth --type epic
49
50
 
50
51
  Options:
51
52
  - `--type <type>` - Type to search for: `epic` or `story` (default: story)
53
+ - `--status <status>` - Filter stories by status: `ready`, `completed`, `blocked`, etc.
52
54
 
53
55
  Returns JSON with:
54
56
  - `found: true` + `data` - Single match found
@@ -90,8 +92,27 @@ Options:
90
92
  - `--max-time <n>` - Maximum time in minutes (default: 60)
91
93
  - `--model <name>` - Model to use (default: opus)
92
94
 
95
+ By default, `implement` runs in **detached mode** using a tmux session. This allows the worker to continue running even if your terminal disconnects. Use `saga sessions` to monitor detached sessions.
96
+
93
97
  **Note:** Requires `SAGA_PLUGIN_ROOT` environment variable to be set. This is automatically configured when running via the SAGA plugin.
94
98
 
99
+ ### `saga sessions`
100
+
101
+ Manage tmux sessions created by detached `implement` runs.
102
+
103
+ ```bash
104
+ saga sessions list # List all SAGA sessions
105
+ saga sessions status <name> # Check if session is running
106
+ saga sessions logs <name> # Stream session output
107
+ saga sessions kill <name> # Terminate a session
108
+ ```
109
+
110
+ Subcommands:
111
+ - `list` - Returns JSON array of all SAGA sessions with name, status, and output file
112
+ - `status <name>` - Returns JSON `{running: boolean}` for the session
113
+ - `logs <name>` - Streams the session output file via `tail -f`
114
+ - `kill <name>` - Terminates the session, returns JSON `{killed: boolean}`
115
+
95
116
  ### `saga dashboard`
96
117
 
97
118
  Start the SAGA dashboard server for viewing epics, stories, and progress.
package/dist/cli.cjs CHANGED
@@ -630,6 +630,9 @@ async function createSession(epicSlug, storySlug, command) {
630
630
  # Auto-generated wrapper script for SAGA session
631
631
  set -e
632
632
 
633
+ # Mark this as an internal SAGA session (used by CLI to detect it's running inside tmux)
634
+ export SAGA_INTERNAL_SESSION=1
635
+
633
636
  COMMAND_FILE="${commandFilePath}"
634
637
  OUTPUT_FILE="${outputFile}"
635
638
  SCRIPT_FILE="${wrapperScriptPath}"
@@ -958,64 +961,6 @@ function buildScopeSettings() {
958
961
  }
959
962
  };
960
963
  }
961
- function parseWorkerOutput(output) {
962
- if (!output || !output.trim()) {
963
- throw new Error("Worker output is empty");
964
- }
965
- let cliResponse;
966
- try {
967
- cliResponse = JSON.parse(output.trim());
968
- } catch (e) {
969
- throw new Error(`Invalid JSON in worker output: ${e}`);
970
- }
971
- if (!("structured_output" in cliResponse)) {
972
- if (cliResponse.is_error) {
973
- const errorMsg = cliResponse.result || "Unknown error";
974
- throw new Error(`Worker failed: ${errorMsg}`);
975
- }
976
- throw new Error(`Worker output missing structured_output field. Got keys: ${Object.keys(cliResponse).join(", ")}`);
977
- }
978
- const parsed = cliResponse.structured_output;
979
- if (!("status" in parsed)) {
980
- throw new Error("Worker output missing required field: status");
981
- }
982
- if (!("summary" in parsed)) {
983
- throw new Error("Worker output missing required field: summary");
984
- }
985
- if (!VALID_STATUSES.has(parsed.status)) {
986
- throw new Error(`Invalid status value: ${parsed.status}. Must be one of: ${[...VALID_STATUSES].join(", ")}`);
987
- }
988
- return {
989
- status: parsed.status,
990
- summary: parsed.summary,
991
- blocker: parsed.blocker ?? null
992
- };
993
- }
994
- function spawnWorker(prompt, model, settings, workingDir) {
995
- const args = [
996
- "-p",
997
- prompt,
998
- "--model",
999
- model,
1000
- "--output-format",
1001
- "json",
1002
- "--json-schema",
1003
- JSON.stringify(WORKER_OUTPUT_SCHEMA),
1004
- "--settings",
1005
- JSON.stringify(settings),
1006
- "--dangerously-skip-permissions"
1007
- ];
1008
- const result = (0, import_node_child_process2.spawnSync)("claude", args, {
1009
- cwd: workingDir,
1010
- encoding: "utf-8",
1011
- maxBuffer: 50 * 1024 * 1024
1012
- // 50MB buffer for large outputs
1013
- });
1014
- if (result.error) {
1015
- throw new Error(`Failed to spawn worker: ${result.error.message}`);
1016
- }
1017
- return result.stdout || "";
1018
- }
1019
964
  function formatStreamLine(line) {
1020
965
  try {
1021
966
  const data = JSON.parse(line);
@@ -1042,6 +987,22 @@ function formatStreamLine(line) {
1042
987
  return null;
1043
988
  }
1044
989
  }
990
+ function extractStructuredOutputFromToolCall(lines) {
991
+ for (let i = lines.length - 1; i >= 0; i--) {
992
+ try {
993
+ const data = JSON.parse(lines[i]);
994
+ if (data.type === "assistant" && data.message?.content) {
995
+ for (const block of data.message.content) {
996
+ if (block.type === "tool_use" && block.name === "StructuredOutput") {
997
+ return block.input;
998
+ }
999
+ }
1000
+ }
1001
+ } catch {
1002
+ }
1003
+ }
1004
+ return null;
1005
+ }
1045
1006
  function parseStreamingResult(buffer) {
1046
1007
  const lines = buffer.split("\n").filter((line) => line.trim());
1047
1008
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -1051,10 +1012,13 @@ function parseStreamingResult(buffer) {
1051
1012
  if (data.is_error) {
1052
1013
  throw new Error(`Worker failed: ${data.result || "Unknown error"}`);
1053
1014
  }
1054
- if (!data.structured_output) {
1015
+ let output = data.structured_output;
1016
+ if (!output) {
1017
+ output = extractStructuredOutputFromToolCall(lines);
1018
+ }
1019
+ if (!output) {
1055
1020
  throw new Error("Worker result missing structured_output");
1056
1021
  }
1057
- const output = data.structured_output;
1058
1022
  if (!VALID_STATUSES.has(output.status)) {
1059
1023
  throw new Error(`Invalid status: ${output.status}`);
1060
1024
  }
@@ -1123,7 +1087,7 @@ function spawnWorkerAsync(prompt, model, settings, workingDir) {
1123
1087
  });
1124
1088
  });
1125
1089
  }
1126
- async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot, stream = false) {
1090
+ async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot) {
1127
1091
  const worktree = (0, import_node_path5.join)(projectDir, ".saga", "worktrees", epicSlug, storySlug);
1128
1092
  const validation = validateStoryFiles(worktree, epicSlug, storySlug);
1129
1093
  if (!validation.valid) {
@@ -1166,55 +1130,25 @@ async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDi
1166
1130
  }
1167
1131
  cycles += 1;
1168
1132
  let parsed;
1169
- if (stream) {
1170
- console.log(`
1133
+ console.log(`
1171
1134
  --- Worker ${cycles} started ---
1172
1135
  `);
1173
- try {
1174
- parsed = await spawnWorkerAsync(workerPrompt, model, settings, worktree);
1175
- } catch (e) {
1176
- return {
1177
- status: "ERROR",
1178
- summary: e.message,
1179
- cycles,
1180
- elapsedMinutes: (Date.now() - startTime) / 6e4,
1181
- blocker: null,
1182
- epicSlug,
1183
- storySlug
1184
- };
1185
- }
1186
- console.log(`
1136
+ try {
1137
+ parsed = await spawnWorkerAsync(workerPrompt, model, settings, worktree);
1138
+ } catch (e) {
1139
+ return {
1140
+ status: "ERROR",
1141
+ summary: e.message,
1142
+ cycles,
1143
+ elapsedMinutes: (Date.now() - startTime) / 6e4,
1144
+ blocker: null,
1145
+ epicSlug,
1146
+ storySlug
1147
+ };
1148
+ }
1149
+ console.log(`
1187
1150
  --- Worker ${cycles} result: ${parsed.status} ---
1188
1151
  `);
1189
- } else {
1190
- let output;
1191
- try {
1192
- output = spawnWorker(workerPrompt, model, settings, worktree);
1193
- } catch (e) {
1194
- return {
1195
- status: "ERROR",
1196
- summary: e.message,
1197
- cycles,
1198
- elapsedMinutes: (Date.now() - startTime) / 6e4,
1199
- blocker: null,
1200
- epicSlug,
1201
- storySlug
1202
- };
1203
- }
1204
- try {
1205
- parsed = parseWorkerOutput(output);
1206
- } catch (e) {
1207
- return {
1208
- status: "ERROR",
1209
- summary: e.message,
1210
- cycles,
1211
- elapsedMinutes: (Date.now() - startTime) / 6e4,
1212
- blocker: null,
1213
- epicSlug,
1214
- storySlug
1215
- };
1216
- }
1217
- }
1218
1152
  summaries.push(parsed.summary);
1219
1153
  if (parsed.status === "FINISH") {
1220
1154
  finalStatus = "FINISH";
@@ -1241,7 +1175,7 @@ async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDi
1241
1175
  };
1242
1176
  }
1243
1177
  function buildDetachedCommand(storySlug, projectPath, options) {
1244
- const parts = ["saga", "implement", storySlug, "--attached"];
1178
+ const parts = ["saga", "implement", storySlug];
1245
1179
  parts.push("--path", projectPath);
1246
1180
  if (options.maxCycles !== void 0) {
1247
1181
  parts.push("--max-cycles", String(options.maxCycles));
@@ -1252,9 +1186,6 @@ function buildDetachedCommand(storySlug, projectPath, options) {
1252
1186
  if (options.model !== void 0) {
1253
1187
  parts.push("--model", options.model);
1254
1188
  }
1255
- if (options.stream) {
1256
- parts.push("--stream");
1257
- }
1258
1189
  return shellEscapeArgs(parts);
1259
1190
  }
1260
1191
  async function implementCommand(storySlug, options) {
@@ -1297,18 +1228,12 @@ Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`)
1297
1228
  const maxCycles = options.maxCycles ?? DEFAULT_MAX_CYCLES;
1298
1229
  const maxTime = options.maxTime ?? DEFAULT_MAX_TIME;
1299
1230
  const model = options.model ?? DEFAULT_MODEL;
1300
- const stream = options.stream ?? false;
1301
- const attached = options.attached ?? false;
1302
- if (!attached) {
1303
- if (stream) {
1304
- console.error("Warning: --stream is ignored in detached mode. Use --attached --stream for streaming output.");
1305
- }
1231
+ const isInternalSession = process.env.SAGA_INTERNAL_SESSION === "1";
1232
+ if (!isInternalSession) {
1306
1233
  const detachedCommand = buildDetachedCommand(storySlug, projectPath, {
1307
1234
  maxCycles: options.maxCycles,
1308
1235
  maxTime: options.maxTime,
1309
- model: options.model,
1310
- stream: true
1311
- // Always use streaming in detached mode so output is captured
1236
+ model: options.model
1312
1237
  });
1313
1238
  try {
1314
1239
  const sessionResult = await createSession(
@@ -1334,7 +1259,6 @@ Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`)
1334
1259
  console.log(` Epic: ${storyInfo.epicSlug}`);
1335
1260
  console.log(` Story: ${storyInfo.storySlug}`);
1336
1261
  console.log(` Worktree: ${storyInfo.worktreePath}`);
1337
- console.log(` Streaming: ${stream ? "enabled" : "disabled"}`);
1338
1262
  console.log("");
1339
1263
  const result = await runLoop(
1340
1264
  storyInfo.epicSlug,
@@ -1343,8 +1267,7 @@ Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`)
1343
1267
  maxTime,
1344
1268
  model,
1345
1269
  projectPath,
1346
- pluginRoot,
1347
- stream
1270
+ pluginRoot
1348
1271
  );
1349
1272
  console.log(JSON.stringify(result, null, 2));
1350
1273
  if (result.status === "ERROR") {
@@ -2288,16 +2211,14 @@ program.command("init").description("Initialize .saga/ directory structure").opt
2288
2211
  const globalOpts = program.opts();
2289
2212
  await initCommand({ path: globalOpts.path, dryRun: options.dryRun });
2290
2213
  });
2291
- program.command("implement <story-slug>").description("Run story implementation").option("--max-cycles <n>", "Maximum number of implementation cycles", parseInt).option("--max-time <n>", "Maximum time in minutes", parseInt).option("--model <name>", "Model to use for implementation").option("--dry-run", "Validate dependencies without running implementation").option("--stream", "Stream worker output in real-time").option("--attached", "Run in attached mode (synchronous, tied to terminal)").action(async (storySlug, options) => {
2214
+ program.command("implement <story-slug>").description("Run story implementation").option("--max-cycles <n>", "Maximum number of implementation cycles", parseInt).option("--max-time <n>", "Maximum time in minutes", parseInt).option("--model <name>", "Model to use for implementation").option("--dry-run", "Validate dependencies without running implementation").action(async (storySlug, options) => {
2292
2215
  const globalOpts = program.opts();
2293
2216
  await implementCommand(storySlug, {
2294
2217
  path: globalOpts.path,
2295
2218
  maxCycles: options.maxCycles,
2296
2219
  maxTime: options.maxTime,
2297
2220
  model: options.model,
2298
- dryRun: options.dryRun,
2299
- stream: options.stream,
2300
- attached: options.attached
2221
+ dryRun: options.dryRun
2301
2222
  });
2302
2223
  });
2303
2224
  program.command("find <query>").description("Find an epic or story by slug/title").option("--type <type>", "Type to search for: epic or story (default: story)").option("--status <status>", "Filter stories by status (e.g., ready, in-progress, completed)").action(async (query, options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saga-ai/cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "CLI for SAGA - Structured Autonomous Goal Achievement",
5
5
  "type": "module",
6
6
  "bin": {