agileflow 4.0.0-alpha.22 → 4.0.0-alpha.24

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.22",
3
+ "version": "4.0.0-alpha.24",
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",
@@ -1136,6 +1136,59 @@ async function runInternalRestoreWindow(deps = {}) {
1136
1136
  }
1137
1137
  }
1138
1138
 
1139
+ /**
1140
+ * Hidden subcommand wired to tmux's window-linked / window-unlinked /
1141
+ * window-renamed hooks. Queries the named session's current window
1142
+ * layout and patches the registry's `windows` array so restore can
1143
+ * later replay every tab in its original cwd.
1144
+ *
1145
+ * Silent on every failure path: this runs in the background on every
1146
+ * tab change, so noise here would clutter the user's terminal. If the
1147
+ * session no longer exists or tmux is gone, we just no-op.
1148
+ *
1149
+ * @param {string} sessionName
1150
+ * @param {{ runner?: ReturnType<typeof defaultTmuxRunner> }} [deps]
1151
+ * @returns {Promise<void>}
1152
+ */
1153
+ async function runInternalSnapshotSession(sessionName, deps = {}) {
1154
+ if (!sessionName) return;
1155
+ const runner = deps.runner || defaultTmuxRunner();
1156
+ const DELIM = "\x1f";
1157
+ const fmt = `#{window_index}${DELIM}#{window_name}${DELIM}#{pane_current_path}`;
1158
+ // Capture timestamp BEFORE list-windows so concurrent subprocesses
1159
+ // produce a stable ordering. The registry rejects writes whose
1160
+ // capturedAt is older than what's already stored — without this,
1161
+ // out-of-order lock acquisition can clobber newer state with older.
1162
+ const capturedAt = Date.now();
1163
+ const result = runner.runSync(["list-windows", "-t", sessionName, "-F", fmt]);
1164
+ if (result.status !== 0) return;
1165
+ const lines = (result.stdout || "")
1166
+ .split("\n")
1167
+ .map((l) => l.trim())
1168
+ .filter((l) => l.length > 0);
1169
+ /** @type {import("../../runtime/launch/session-registry.js").WindowSnapshot[]} */
1170
+ const windows = [];
1171
+ for (const line of lines) {
1172
+ const parts = line.split(DELIM);
1173
+ if (parts.length !== 3) continue;
1174
+ const index = Number(parts[0]);
1175
+ if (!Number.isFinite(index)) continue;
1176
+ const name = parts[1];
1177
+ const cwd = parts[2];
1178
+ if (!cwd) continue;
1179
+ windows.push({ index, name, cwd });
1180
+ }
1181
+ try {
1182
+ const {
1183
+ updateSession,
1184
+ } = require("../../runtime/launch/session-registry.js");
1185
+ updateSession(sessionName, { windows, windowsCapturedAt: capturedAt });
1186
+ } catch {
1187
+ // Registry might not exist or session might have been forgotten;
1188
+ // either way we can't usefully recover. Stay silent.
1189
+ }
1190
+ }
1191
+
1139
1192
  /**
1140
1193
  * Auto-restore check on bare `agileflow launch`. Fires only when:
1141
1194
  * - tmux is available (we use sessionExists to count alive sessions)
@@ -1353,6 +1406,14 @@ async function launch(sub, nameArg, _options) {
1353
1406
  await runInternalRestoreWindow({ targetSession: nameArg || "" });
1354
1407
  return;
1355
1408
  }
1409
+ if (sub === "__snapshot-session") {
1410
+ // Hidden subcommand invoked from tmux hooks (window-linked,
1411
+ // window-unlinked, window-renamed). Queries the current window
1412
+ // layout for the named session and writes it to the registry's
1413
+ // `windows` field so restore can replay every tab.
1414
+ await runInternalSnapshotSession(nameArg || "");
1415
+ return;
1416
+ }
1356
1417
  if (sub && sub !== "setup") {
1357
1418
  fail(
1358
1419
  new OperationFailedError(`unknown launch subcommand: ${sub}`, {
@@ -23,6 +23,7 @@ const {
23
23
  createSession,
24
24
  applyKeybindPreset,
25
25
  applyTabFormat,
26
+ installSessionHooks,
26
27
  detectTmuxVersion,
27
28
  defaultRunner,
28
29
  } = require("./tmux.js");
@@ -194,6 +195,7 @@ async function runParallelSpawn(opts) {
194
195
  applyTabFormat(sessionName, runner, {
195
196
  tmuxVersion: detectTmuxVersion(runner),
196
197
  });
198
+ installSessionHooks(sessionName, runner);
197
199
 
198
200
  // Swap the user's tmux client to the new session. If switch-client
199
201
  // fails the session is still alive — surface its name so the user
@@ -20,6 +20,7 @@ const {
20
20
  createSession,
21
21
  applyKeybindPreset,
22
22
  applyTabFormat,
23
+ installSessionHooks,
23
24
  detectTmuxVersion,
24
25
  } = require("./tmux.js");
25
26
  const { loadRegistry } = require("./session-registry.js");
@@ -126,7 +127,36 @@ function runRestore(opts) {
126
127
  runner.runSync(["set-option", "-t", entry.name, "status", "1"]);
127
128
  applyTabFormat(entry.name, runner, {
128
129
  tmuxVersion: detectTmuxVersion(runner),
130
+ agileflowBin,
129
131
  });
132
+ // Replay saved tabs (windows) if we have any from the last hook
133
+ // snapshot. The first window already exists (createSession opened
134
+ // it running __exec), so we skip it. Each restored window is
135
+ // opened with `new-window -t session -c cwd -n name` and only
136
+ // gets a shell — restoring the original CLI is out of scope (we
137
+ // don't know what command was running, and respawning Claude in
138
+ // every tab would be surprising). Users can re-run agileflow in
139
+ // any tab manually.
140
+ if (Array.isArray(entry.windows) && entry.windows.length > 1) {
141
+ const sorted = entry.windows
142
+ .slice()
143
+ .sort((a, b) => (a.index || 0) - (b.index || 0));
144
+ // Skip the first — already created by new-session.
145
+ for (let i = 1; i < sorted.length; i++) {
146
+ const w = sorted[i];
147
+ if (!existsSync(w.cwd)) continue;
148
+ const args = ["new-window", "-t", entry.name, "-c", w.cwd];
149
+ if (w.name) args.push("-n", w.name);
150
+ runner.runSync(args);
151
+ }
152
+ log(
153
+ `agileflow launch: replayed ${sorted.length - 1} tab(s) for ${entry.name}`,
154
+ );
155
+ }
156
+ // Install hooks AFTER replay so the new-window calls above don't
157
+ // each fire window-linked and overwrite the saved snapshot with
158
+ // a partial mid-replay state.
159
+ installSessionHooks(entry.name, runner, { agileflowBin });
130
160
  result.restored++;
131
161
  log(`agileflow launch: restored session ${entry.name} (${entry.cwd})`);
132
162
  }
@@ -117,6 +117,11 @@ function withRegistryLock(home, fn) {
117
117
  * @property {string} branch
118
118
  * @property {string} base
119
119
  *
120
+ * @typedef {Object} WindowSnapshot
121
+ * @property {number} index - tmux window index at snapshot time
122
+ * @property {string} name - tmux #W (window name)
123
+ * @property {string} cwd - pane's cwd
124
+ *
120
125
  * @typedef {Object} SessionEntry
121
126
  * @property {string} name
122
127
  * @property {string} cli
@@ -128,6 +133,14 @@ function withRegistryLock(home, fn) {
128
133
  * are pre-selected in the auto-
129
134
  * restore picker
130
135
  * @property {WorktreeMeta} [worktree]
136
+ * @property {WindowSnapshot[]} [windows] - last-known tab layout, written
137
+ * by tmux hooks; replayed on
138
+ * restore so reboots bring back
139
+ * every tab in its original cwd
140
+ * @property {number} [windowsCapturedAt] - epoch ms when `windows` was
141
+ * captured. Newer captures
142
+ * always win when concurrent
143
+ * hook subprocesses race.
131
144
  *
132
145
  * @typedef {Object} RegistryShape
133
146
  * @property {1} version
@@ -192,6 +205,22 @@ function loadRegistry(home) {
192
205
  if (typeof s.name !== "string" || !s.name) continue;
193
206
  if (typeof s.cli !== "string" || !s.cli) continue;
194
207
  if (typeof s.cwd !== "string" || !s.cwd) continue;
208
+ /** @type {WindowSnapshot[] | undefined} */
209
+ let windows;
210
+ if (Array.isArray(s.windows)) {
211
+ windows = [];
212
+ for (const w of s.windows) {
213
+ if (!w || typeof w !== "object") continue;
214
+ if (typeof w.name !== "string") continue;
215
+ if (typeof w.cwd !== "string" || !w.cwd) continue;
216
+ windows.push({
217
+ index: Number.isFinite(w.index) ? Number(w.index) : 0,
218
+ name: w.name,
219
+ cwd: w.cwd,
220
+ });
221
+ }
222
+ if (windows.length === 0) windows = undefined;
223
+ }
195
224
  sane.push({
196
225
  name: s.name,
197
226
  cli: s.cli,
@@ -207,6 +236,9 @@ function loadRegistry(home) {
207
236
  base: String(s.worktree.base || ""),
208
237
  }
209
238
  : undefined,
239
+ windows,
240
+ windowsCapturedAt:
241
+ typeof s.windowsCapturedAt === "number" ? s.windowsCapturedAt : 0,
210
242
  });
211
243
  }
212
244
  return { version: 1, sessions: sane };
@@ -288,6 +320,16 @@ function recordSession(entry, home) {
288
320
  : previous
289
321
  ? previous.pinned === true
290
322
  : false;
323
+ // Preserve the last-known windows snapshot across re-records.
324
+ // Restore re-records sessions on the way back from disk and we
325
+ // don't want the windows array to be silently dropped before
326
+ // we get a chance to replay it.
327
+ const windows =
328
+ entry.windows !== undefined
329
+ ? entry.windows
330
+ : previous
331
+ ? previous.windows
332
+ : undefined;
291
333
  filtered.push({
292
334
  name: entry.name,
293
335
  cli: entry.cli,
@@ -296,6 +338,7 @@ function recordSession(entry, home) {
296
338
  lastSeen: entry.lastSeen || new Date().toISOString(),
297
339
  pinned,
298
340
  worktree: entry.worktree,
341
+ windows,
299
342
  });
300
343
  writeRegistry({ version: 1, sessions: filtered }, home);
301
344
  });
@@ -331,6 +374,24 @@ function updateSession(name, patch, home) {
331
374
  if (patch.cwd !== undefined) s.cwd = patch.cwd;
332
375
  if (patch.worktree !== undefined) s.worktree = patch.worktree;
333
376
  if (patch.pinned !== undefined) s.pinned = patch.pinned === true;
377
+ if (patch.windows !== undefined) {
378
+ // Stale-snapshot guard: hooks fire on every tab change, so
379
+ // when the user spams Alt+t multiple subprocesses can race.
380
+ // Lock acquisition isn't FIFO so an older capture can be
381
+ // committed after a newer one. Reject any update whose
382
+ // capturedAt timestamp is older than what's already stored.
383
+ const incomingTs =
384
+ typeof patch.windowsCapturedAt === "number"
385
+ ? patch.windowsCapturedAt
386
+ : Date.now();
387
+ if (!s.windowsCapturedAt || incomingTs >= s.windowsCapturedAt) {
388
+ s.windows = Array.isArray(patch.windows)
389
+ ? patch.windows
390
+ : undefined;
391
+ s.windowsCapturedAt = incomingTs;
392
+ }
393
+ // else: silently drop the stale write
394
+ }
334
395
  updated = true;
335
396
  }
336
397
  }
@@ -193,6 +193,7 @@ function detectTmuxVersion(runner) {
193
193
  * @param {{
194
194
  * tmuxVersion?: { major: number, minor: number } | null,
195
195
  * theme?: Partial<typeof tabs.DEFAULT_TAB_THEME>,
196
+ * agileflowBin?: string,
196
197
  * }} [opts]
197
198
  * @returns {{ applied: boolean, stderr: string }}
198
199
  */
@@ -323,6 +324,58 @@ function applyTabFormat(sessionName, runner, opts = {}) {
323
324
  };
324
325
  }
325
326
 
327
+ /**
328
+ * Install per-session tmux hooks that fire whenever the window layout
329
+ * changes (new tab, close, rename). Each hook invokes the
330
+ * `__snapshot-session` callback so the registry's `windows` array
331
+ * stays current — that's what restore replays after a reboot.
332
+ *
333
+ * Kept separate from `applyTabFormat` so the restore path can replay
334
+ * the saved windows BEFORE installing hooks (otherwise the new-window
335
+ * calls during replay would fire window-linked, clobbering the saved
336
+ * snapshot with a partial mid-replay state).
337
+ *
338
+ * Scoped per-session via `-t` so non-AgileFlow sessions on the same
339
+ * tmux server aren't touched.
340
+ *
341
+ * @param {string} sessionName
342
+ * @param {TmuxRunner} runner
343
+ * @param {{ agileflowBin?: string }} [opts]
344
+ */
345
+ function installSessionHooks(sessionName, runner, opts = {}) {
346
+ const agileflowBin = opts.agileflowBin || resolveAgileflowBin();
347
+ // Double-quote the binary path and session name so paths with
348
+ // spaces (e.g., /Users/x with space/agileflow) work. The shell
349
+ // command is itself wrapped in double quotes for run-shell to
350
+ // accept it as a single argument.
351
+ const shellCmd = `"${agileflowBin}" launch __snapshot-session "${sessionName}"`;
352
+ const snapshotCmd = `run-shell -b "${shellCmd.replace(/"/g, '\\"')}"`;
353
+ /** @type {Array<{ event: string, stderr: string }>} */
354
+ const failures = [];
355
+ for (const event of ["window-linked", "window-unlinked", "window-renamed"]) {
356
+ const r = runner.runSync([
357
+ "set-hook",
358
+ "-t",
359
+ sessionName,
360
+ event,
361
+ snapshotCmd,
362
+ ]);
363
+ if (r.status !== 0) {
364
+ failures.push({ event, stderr: (r.stderr || "").trim() });
365
+ }
366
+ }
367
+ if (failures.length > 0) {
368
+ // eslint-disable-next-line no-console
369
+ console.error(
370
+ `agileflow launch: tab persistence — ${failures.length} of 3 tmux hooks failed to install:`,
371
+ );
372
+ for (const f of failures) {
373
+ // eslint-disable-next-line no-console
374
+ console.error(` ${f.event}: ${f.stderr || "(no error message)"}`);
375
+ }
376
+ }
377
+ }
378
+
326
379
  /**
327
380
  * Create a new detached tmux session that immediately runs `bin args...`.
328
381
  * Returns the runner's exit status (0 on success).
@@ -651,6 +704,7 @@ async function launchInTmux(opts) {
651
704
  // tmux's default green status bar entirely.
652
705
  runner.runSync(["set-option", "-t", base, "status", "1"]);
653
706
  applyTabFormat(base, runner, { tmuxVersion });
707
+ installSessionHooks(base, runner);
654
708
  log(`agileflow launch: resuming session ${base}`);
655
709
  return attachSession(base, runner);
656
710
  }
@@ -716,6 +770,7 @@ async function launchInTmux(opts) {
716
770
  }
717
771
  runner.runSync(["set-option", "-t", name, "status", "1"]);
718
772
  applyTabFormat(name, runner, { tmuxVersion });
773
+ installSessionHooks(name, runner);
719
774
  return attachSession(name, runner);
720
775
  }
721
776
 
@@ -759,6 +814,7 @@ module.exports = {
759
814
  substituteBinding,
760
815
  detectTmuxVersion,
761
816
  applyTabFormat,
817
+ installSessionHooks,
762
818
  KEYBIND_PRESET_BINDINGS,
763
819
  defaultRunner,
764
820
  };