agileflow 4.0.0-alpha.21 → 4.0.0-alpha.23

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.21",
3
+ "version": "4.0.0-alpha.23",
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,54 @@ 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
+ const result = runner.runSync(["list-windows", "-t", sessionName, "-F", fmt]);
1159
+ if (result.status !== 0) return;
1160
+ const lines = (result.stdout || "")
1161
+ .split("\n")
1162
+ .map((l) => l.trim())
1163
+ .filter((l) => l.length > 0);
1164
+ /** @type {import("../../runtime/launch/session-registry.js").WindowSnapshot[]} */
1165
+ const windows = [];
1166
+ for (const line of lines) {
1167
+ const parts = line.split(DELIM);
1168
+ if (parts.length !== 3) continue;
1169
+ const index = Number(parts[0]);
1170
+ if (!Number.isFinite(index)) continue;
1171
+ const name = parts[1];
1172
+ const cwd = parts[2];
1173
+ if (!cwd) continue;
1174
+ windows.push({ index, name, cwd });
1175
+ }
1176
+ try {
1177
+ const {
1178
+ updateSession,
1179
+ } = require("../../runtime/launch/session-registry.js");
1180
+ updateSession(sessionName, { windows });
1181
+ } catch {
1182
+ // Registry might not exist or session might have been forgotten;
1183
+ // either way we can't usefully recover. Stay silent.
1184
+ }
1185
+ }
1186
+
1139
1187
  /**
1140
1188
  * Auto-restore check on bare `agileflow launch`. Fires only when:
1141
1189
  * - tmux is available (we use sessionExists to count alive sessions)
@@ -1353,6 +1401,14 @@ async function launch(sub, nameArg, _options) {
1353
1401
  await runInternalRestoreWindow({ targetSession: nameArg || "" });
1354
1402
  return;
1355
1403
  }
1404
+ if (sub === "__snapshot-session") {
1405
+ // Hidden subcommand invoked from tmux hooks (window-linked,
1406
+ // window-unlinked, window-renamed). Queries the current window
1407
+ // layout for the named session and writes it to the registry's
1408
+ // `windows` field so restore can replay every tab.
1409
+ await runInternalSnapshotSession(nameArg || "");
1410
+ return;
1411
+ }
1356
1412
  if (sub && sub !== "setup") {
1357
1413
  fail(
1358
1414
  new OperationFailedError(`unknown launch subcommand: ${sub}`, {
@@ -22,6 +22,9 @@ const {
22
22
  sessionExists,
23
23
  createSession,
24
24
  applyKeybindPreset,
25
+ applyTabFormat,
26
+ installSessionHooks,
27
+ detectTmuxVersion,
25
28
  defaultRunner,
26
29
  } = require("./tmux.js");
27
30
  const { createWorktree, removeWorktree } = require("./worktree.js");
@@ -185,6 +188,15 @@ async function runParallelSpawn(opts) {
185
188
  }
186
189
  }
187
190
 
191
+ // Apply the tab strip styling to the new session — without this,
192
+ // Alt+s and Alt+n spawn sessions with tmux's default green status
193
+ // bar. Same call the engine does on every fresh-launch session.
194
+ runner.runSync(["set-option", "-t", sessionName, "status", "1"]);
195
+ applyTabFormat(sessionName, runner, {
196
+ tmuxVersion: detectTmuxVersion(runner),
197
+ });
198
+ installSessionHooks(sessionName, runner);
199
+
188
200
  // Swap the user's tmux client to the new session. If switch-client
189
201
  // fails the session is still alive — surface its name so the user
190
202
  // can attach manually.
@@ -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,10 @@ 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
131
140
  *
132
141
  * @typedef {Object} RegistryShape
133
142
  * @property {1} version
@@ -192,6 +201,22 @@ function loadRegistry(home) {
192
201
  if (typeof s.name !== "string" || !s.name) continue;
193
202
  if (typeof s.cli !== "string" || !s.cli) continue;
194
203
  if (typeof s.cwd !== "string" || !s.cwd) continue;
204
+ /** @type {WindowSnapshot[] | undefined} */
205
+ let windows;
206
+ if (Array.isArray(s.windows)) {
207
+ windows = [];
208
+ for (const w of s.windows) {
209
+ if (!w || typeof w !== "object") continue;
210
+ if (typeof w.name !== "string") continue;
211
+ if (typeof w.cwd !== "string" || !w.cwd) continue;
212
+ windows.push({
213
+ index: Number.isFinite(w.index) ? Number(w.index) : 0,
214
+ name: w.name,
215
+ cwd: w.cwd,
216
+ });
217
+ }
218
+ if (windows.length === 0) windows = undefined;
219
+ }
195
220
  sane.push({
196
221
  name: s.name,
197
222
  cli: s.cli,
@@ -207,6 +232,7 @@ function loadRegistry(home) {
207
232
  base: String(s.worktree.base || ""),
208
233
  }
209
234
  : undefined,
235
+ windows,
210
236
  });
211
237
  }
212
238
  return { version: 1, sessions: sane };
@@ -288,6 +314,16 @@ function recordSession(entry, home) {
288
314
  : previous
289
315
  ? previous.pinned === true
290
316
  : false;
317
+ // Preserve the last-known windows snapshot across re-records.
318
+ // Restore re-records sessions on the way back from disk and we
319
+ // don't want the windows array to be silently dropped before
320
+ // we get a chance to replay it.
321
+ const windows =
322
+ entry.windows !== undefined
323
+ ? entry.windows
324
+ : previous
325
+ ? previous.windows
326
+ : undefined;
291
327
  filtered.push({
292
328
  name: entry.name,
293
329
  cli: entry.cli,
@@ -296,6 +332,7 @@ function recordSession(entry, home) {
296
332
  lastSeen: entry.lastSeen || new Date().toISOString(),
297
333
  pinned,
298
334
  worktree: entry.worktree,
335
+ windows,
299
336
  });
300
337
  writeRegistry({ version: 1, sessions: filtered }, home);
301
338
  });
@@ -331,6 +368,9 @@ function updateSession(name, patch, home) {
331
368
  if (patch.cwd !== undefined) s.cwd = patch.cwd;
332
369
  if (patch.worktree !== undefined) s.worktree = patch.worktree;
333
370
  if (patch.pinned !== undefined) s.pinned = patch.pinned === true;
371
+ if (patch.windows !== undefined) {
372
+ s.windows = Array.isArray(patch.windows) ? patch.windows : undefined;
373
+ }
334
374
  updated = true;
335
375
  }
336
376
  }
@@ -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,32 @@ 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
+ const snapshotCmd = `run-shell -b '${agileflowBin} launch __snapshot-session ${sessionName}'`;
348
+ for (const event of ["window-linked", "window-unlinked", "window-renamed"]) {
349
+ runner.runSync(["set-hook", "-t", sessionName, event, snapshotCmd]);
350
+ }
351
+ }
352
+
326
353
  /**
327
354
  * Create a new detached tmux session that immediately runs `bin args...`.
328
355
  * Returns the runner's exit status (0 on success).
@@ -651,6 +678,7 @@ async function launchInTmux(opts) {
651
678
  // tmux's default green status bar entirely.
652
679
  runner.runSync(["set-option", "-t", base, "status", "1"]);
653
680
  applyTabFormat(base, runner, { tmuxVersion });
681
+ installSessionHooks(base, runner);
654
682
  log(`agileflow launch: resuming session ${base}`);
655
683
  return attachSession(base, runner);
656
684
  }
@@ -716,6 +744,7 @@ async function launchInTmux(opts) {
716
744
  }
717
745
  runner.runSync(["set-option", "-t", name, "status", "1"]);
718
746
  applyTabFormat(name, runner, { tmuxVersion });
747
+ installSessionHooks(name, runner);
719
748
  return attachSession(name, runner);
720
749
  }
721
750
 
@@ -759,6 +788,7 @@ module.exports = {
759
788
  substituteBinding,
760
789
  detectTmuxVersion,
761
790
  applyTabFormat,
791
+ installSessionHooks,
762
792
  KEYBIND_PRESET_BINDINGS,
763
793
  defaultRunner,
764
794
  };