agileflow 4.0.0-alpha.30 → 4.0.0-alpha.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agileflow",
3
- "version": "4.0.0-alpha.30",
3
+ "version": "4.0.0-alpha.32",
4
4
  "description": "AI-driven agile development toolkit for Claude Code — skills-first architecture with opt-in plugins (v4)",
5
5
  "keywords": [
6
6
  "agile",
@@ -1341,6 +1341,31 @@ async function maybeOfferAutoRestore(prefs) {
1341
1341
  async function launch(sub, nameArg, _options) {
1342
1342
  try {
1343
1343
  if (sub === "new") {
1344
+ // Alt+n triggers `agileflow launch new --prompt` (in a fresh
1345
+ // tmux window with a real TTY) so we can use Clack to read the
1346
+ // worktree name safely. Avoids the shell-injection vulnerability
1347
+ // where tmux's command-prompt substitutes %% into a run-shell
1348
+ // command BEFORE shell parsing, allowing `name"; rm -rf ~; #`
1349
+ // to execute arbitrary commands.
1350
+ const wantPrompt = (process.argv || []).includes("--prompt");
1351
+ if (!nameArg && wantPrompt) {
1352
+ const promptName = await prompts.text({
1353
+ message: "Worktree name (Esc cancels):",
1354
+ validate: (v) => {
1355
+ if (!v) return "name required";
1356
+ if (!/^[A-Za-z0-9._-]+$/.test(v)) {
1357
+ return "letters, digits, dot, underscore, hyphen only";
1358
+ }
1359
+ return undefined;
1360
+ },
1361
+ });
1362
+ if (prompts.isCancel(promptName)) {
1363
+ prompts.cancel("Cancelled.");
1364
+ process.exit(0);
1365
+ }
1366
+ await runNew(String(promptName));
1367
+ return;
1368
+ }
1344
1369
  await runNew(nameArg);
1345
1370
  return;
1346
1371
  }
@@ -309,6 +309,12 @@ function clearOlderThan(maxAgeMs, home) {
309
309
  const log = loadLog(home);
310
310
  const cutoff = Date.now() - maxAgeMs;
311
311
  let removed = 0;
312
+ // Collect keys to delete in a separate pass — mutating an object
313
+ // during Object.entries iteration works in current V8 (entries
314
+ // returns a snapshot of keys) but is fragile and silently breaks
315
+ // if a maintainer swaps to `for (const k in log.sessions)`.
316
+ /** @type {string[]} */
317
+ const toDelete = [];
312
318
  for (const [sessionName, list] of Object.entries(log.sessions)) {
313
319
  const kept = list.filter((e) => {
314
320
  const t = Date.parse(e.closedAt || "");
@@ -316,9 +322,13 @@ function clearOlderThan(maxAgeMs, home) {
316
322
  return t >= cutoff;
317
323
  });
318
324
  removed += list.length - kept.length;
319
- if (kept.length === 0) delete log.sessions[sessionName];
320
- else log.sessions[sessionName] = kept;
325
+ if (kept.length === 0) {
326
+ toDelete.push(sessionName);
327
+ } else {
328
+ log.sessions[sessionName] = kept;
329
+ }
321
330
  }
331
+ for (const k of toDelete) delete log.sessions[k];
322
332
  if (removed > 0) writeLog(log, home);
323
333
  return removed;
324
334
  });
@@ -213,16 +213,34 @@ function runRestoreInner(opts) {
213
213
  ? entry.wrapperWindowIndex
214
214
  : 0;
215
215
  let replayed = 0;
216
+ let replayFailures = 0;
216
217
  for (const w of sorted) {
217
218
  if (w.index === wrapperIdx) continue;
218
219
  if (!existsSync(w.cwd)) continue;
219
220
  const args = ["new-window", "-t", entry.name, "-c", w.cwd];
220
221
  if (w.name) args.push("-n", w.name);
221
- runner.runSync(args);
222
- replayed++;
222
+ const r = runner.runSync(args);
223
+ // Only count tabs that actually got created. tmux can refuse
224
+ // new-window on resource limits, permission errors, or if the
225
+ // session got killed between createSession and this call —
226
+ // surface those so the user knows the restore was partial,
227
+ // instead of a misleading green-check log.
228
+ if (r.status === 0) {
229
+ replayed++;
230
+ } else {
231
+ replayFailures++;
232
+ log(
233
+ `agileflow launch: failed to replay tab ${
234
+ w.name || `(index ${w.index})`
235
+ } for ${entry.name} — ${(r.stderr || "unknown").trim()}`,
236
+ );
237
+ }
223
238
  }
224
- if (replayed > 0) {
225
- log(`agileflow launch: replayed ${replayed} tab(s) for ${entry.name}`);
239
+ if (replayed > 0 || replayFailures > 0) {
240
+ log(
241
+ `agileflow launch: replayed ${replayed} tab(s) for ${entry.name}` +
242
+ (replayFailures > 0 ? ` (${replayFailures} failed)` : ""),
243
+ );
226
244
  }
227
245
  }
228
246
  // Install hooks AFTER replay so the new-window calls above don't
@@ -486,18 +486,18 @@ const KEYBIND_PRESET_BINDINGS = {
486
486
  hint: "Alt+s → spawn a same-dir parallel session",
487
487
  },
488
488
  {
489
- // Prompt for a worktree name, then spawn. tmux's command-prompt
490
- // natively cancels on Escape (or Ctrl+G) without firing the
491
- // deferred command the prompt label calls that out so users
492
- // know they can back out. The agileflow CLI also bails cleanly
493
- // if `%%` came in empty (user hit Enter with no input).
489
+ // Open a fresh tmux window and run the agileflow CLI with the
490
+ // `--prompt` flag. The CLI uses Clack to read the worktree name
491
+ // from a real TTY no shell substitution involved.
492
+ //
493
+ // Previous design used `command-prompt -p '...' "run-shell
494
+ // '%AGILEFLOW% launch new \"%%\"'"`, which substituted %% INTO
495
+ // the run-shell argument BEFORE shell parsing. A user typing
496
+ // `name"; rm -rf ~; #` would get arbitrary shell execution.
497
+ // The new design routes input through stdin to a Node process
498
+ // that never touches a shell, so injection is impossible.
494
499
  key: "M-n",
495
- action: [
496
- "command-prompt",
497
- "-p",
498
- "worktree name (esc to cancel):",
499
- "run-shell '%AGILEFLOW% launch new \"%%\"'",
500
- ],
500
+ action: ["new-window", "%AGILEFLOW% launch new --prompt"],
501
501
  hint: "Alt+n → prompt for a name, create a worktree, spawn there",
502
502
  },
503
503
  // v3-equivalent tab (tmux window) keybinds. Living in a separate