agileflow 4.0.0-alpha.23 → 4.0.0-alpha.25

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.23",
3
+ "version": "4.0.0-alpha.25",
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",
@@ -1155,6 +1155,11 @@ async function runInternalSnapshotSession(sessionName, deps = {}) {
1155
1155
  const runner = deps.runner || defaultTmuxRunner();
1156
1156
  const DELIM = "\x1f";
1157
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();
1158
1163
  const result = runner.runSync(["list-windows", "-t", sessionName, "-F", fmt]);
1159
1164
  if (result.status !== 0) return;
1160
1165
  const lines = (result.stdout || "")
@@ -1177,7 +1182,7 @@ async function runInternalSnapshotSession(sessionName, deps = {}) {
1177
1182
  const {
1178
1183
  updateSession,
1179
1184
  } = require("../../runtime/launch/session-registry.js");
1180
- updateSession(sessionName, { windows });
1185
+ updateSession(sessionName, { windows, windowsCapturedAt: capturedAt });
1181
1186
  } catch {
1182
1187
  // Registry might not exist or session might have been forgotten;
1183
1188
  // either way we can't usefully recover. Stay silent.
@@ -140,6 +140,10 @@ async function runParallelSpawn(opts) {
140
140
  cli: cliId,
141
141
  cwd: targetCwd,
142
142
  uuid: null,
143
+ // Same as the engine path: new-session puts the wrapper at
144
+ // window-index 0 before our base-index=1 setting takes effect
145
+ // on subsequently-created windows. Restore skips this index.
146
+ wrapperWindowIndex: 0,
143
147
  worktree: worktree
144
148
  ? { path: worktree.path, branch: worktree.branch, base: worktree.base }
145
149
  : undefined,
@@ -12,6 +12,7 @@
12
12
  * Returns counts so the caller can show a "restored N of M" summary.
13
13
  */
14
14
  const fs = require("fs");
15
+ const os = require("os");
15
16
  const path = require("path");
16
17
 
17
18
  const {
@@ -26,6 +27,63 @@ const {
26
27
  const { loadRegistry } = require("./session-registry.js");
27
28
  const { resolveAgileflowBin } = require("./alias-installer.js");
28
29
 
30
+ const RESTORE_LOCK_FILE = "launch-restore.lock";
31
+ const RESTORE_LOCK_STALE_MS = 30000;
32
+
33
+ /**
34
+ * O_EXCL lock around the entire restore operation so two concurrent
35
+ * `agileflow launch restore` invocations don't race on createSession
36
+ * and produce duplicate-session errors plus wasted hook installation
37
+ * work. Same pattern as the registry lock but on a separate file so
38
+ * it doesn't block snapshot writes from running sessions.
39
+ *
40
+ * @template T
41
+ * @param {string | undefined} home
42
+ * @param {() => T} fn
43
+ * @returns {T}
44
+ */
45
+ function withRestoreLock(home, fn) {
46
+ const dir = path.join(home || os.homedir(), ".agileflow");
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ const lockFile = path.join(dir, RESTORE_LOCK_FILE);
49
+ let lockFd = null;
50
+ try {
51
+ lockFd = fs.openSync(lockFile, "wx");
52
+ } catch (err) {
53
+ if (err && err.code === "EEXIST") {
54
+ // Check for stale lock (process crashed mid-restore).
55
+ try {
56
+ const stat = fs.statSync(lockFile);
57
+ if (Date.now() - (stat.mtimeMs || 0) > RESTORE_LOCK_STALE_MS) {
58
+ fs.unlinkSync(lockFile);
59
+ lockFd = fs.openSync(lockFile, "wx");
60
+ }
61
+ } catch {
62
+ /* fall through to throw */
63
+ }
64
+ }
65
+ if (lockFd === null) {
66
+ throw new Error(
67
+ "another `agileflow launch restore` is already in progress",
68
+ );
69
+ }
70
+ }
71
+ try {
72
+ return fn();
73
+ } finally {
74
+ try {
75
+ fs.closeSync(lockFd);
76
+ } catch {
77
+ /* swallow */
78
+ }
79
+ try {
80
+ fs.unlinkSync(lockFile);
81
+ } catch {
82
+ /* swallow */
83
+ }
84
+ }
85
+ }
86
+
29
87
  /**
30
88
  * @typedef {Object} RestoreResult
31
89
  * @property {number} restored
@@ -49,6 +107,11 @@ const { resolveAgileflowBin } = require("./alias-installer.js");
49
107
  * @returns {RestoreResult}
50
108
  */
51
109
  function runRestore(opts) {
110
+ return withRestoreLock(opts.home, () => runRestoreInner(opts));
111
+ }
112
+
113
+ /** @param {Parameters<typeof runRestore>[0]} opts */
114
+ function runRestoreInner(opts) {
52
115
  const runner = opts.runner || defaultRunner();
53
116
  const existsSync = opts.existsSync || ((p) => fs.existsSync(p));
54
117
  const log =
@@ -141,17 +204,26 @@ function runRestore(opts) {
141
204
  const sorted = entry.windows
142
205
  .slice()
143
206
  .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];
207
+ // Skip the wrapper window specifically new-session already
208
+ // recreated it. Tracked by wrapperWindowIndex (set at session-
209
+ // creation time, defaults to 0) so a user who reordered windows
210
+ // doesn't lose a real tab to "first index = wrapper" assumption.
211
+ const wrapperIdx =
212
+ typeof entry.wrapperWindowIndex === "number"
213
+ ? entry.wrapperWindowIndex
214
+ : 0;
215
+ let replayed = 0;
216
+ for (const w of sorted) {
217
+ if (w.index === wrapperIdx) continue;
147
218
  if (!existsSync(w.cwd)) continue;
148
219
  const args = ["new-window", "-t", entry.name, "-c", w.cwd];
149
220
  if (w.name) args.push("-n", w.name);
150
221
  runner.runSync(args);
222
+ replayed++;
223
+ }
224
+ if (replayed > 0) {
225
+ log(`agileflow launch: replayed ${replayed} tab(s) for ${entry.name}`);
151
226
  }
152
- log(
153
- `agileflow launch: replayed ${sorted.length - 1} tab(s) for ${entry.name}`,
154
- );
155
227
  }
156
228
  // Install hooks AFTER replay so the new-window calls above don't
157
229
  // each fire window-linked and overwrite the saved snapshot with
@@ -137,6 +137,17 @@ function withRegistryLock(home, fn) {
137
137
  * by tmux hooks; replayed on
138
138
  * restore so reboots bring back
139
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.
144
+ * @property {number} [wrapperWindowIndex] - tmux window index that holds
145
+ * the agileflow __exec wrapper
146
+ * (the window created by
147
+ * new-session). Replay skips
148
+ * THIS index so the user's
149
+ * tabs don't get a duplicate
150
+ * wrapper. Defaults to 0.
140
151
  *
141
152
  * @typedef {Object} RegistryShape
142
153
  * @property {1} version
@@ -233,6 +244,10 @@ function loadRegistry(home) {
233
244
  }
234
245
  : undefined,
235
246
  windows,
247
+ windowsCapturedAt:
248
+ typeof s.windowsCapturedAt === "number" ? s.windowsCapturedAt : 0,
249
+ wrapperWindowIndex:
250
+ typeof s.wrapperWindowIndex === "number" ? s.wrapperWindowIndex : 0,
236
251
  });
237
252
  }
238
253
  return { version: 1, sessions: sane };
@@ -324,6 +339,16 @@ function recordSession(entry, home) {
324
339
  : previous
325
340
  ? previous.windows
326
341
  : undefined;
342
+ // Preserve wrapperWindowIndex across re-records. It's set once at
343
+ // session creation (when new-session puts the wrapper at index 0
344
+ // before we change base-index) and never changes for the life of
345
+ // the session entry.
346
+ const wrapperWindowIndex =
347
+ typeof entry.wrapperWindowIndex === "number"
348
+ ? entry.wrapperWindowIndex
349
+ : previous && typeof previous.wrapperWindowIndex === "number"
350
+ ? previous.wrapperWindowIndex
351
+ : 0;
327
352
  filtered.push({
328
353
  name: entry.name,
329
354
  cli: entry.cli,
@@ -333,6 +358,7 @@ function recordSession(entry, home) {
333
358
  pinned,
334
359
  worktree: entry.worktree,
335
360
  windows,
361
+ wrapperWindowIndex,
336
362
  });
337
363
  writeRegistry({ version: 1, sessions: filtered }, home);
338
364
  });
@@ -369,7 +395,22 @@ function updateSession(name, patch, home) {
369
395
  if (patch.worktree !== undefined) s.worktree = patch.worktree;
370
396
  if (patch.pinned !== undefined) s.pinned = patch.pinned === true;
371
397
  if (patch.windows !== undefined) {
372
- s.windows = Array.isArray(patch.windows) ? patch.windows : undefined;
398
+ // Stale-snapshot guard: hooks fire on every tab change, so
399
+ // when the user spams Alt+t multiple subprocesses can race.
400
+ // Lock acquisition isn't FIFO so an older capture can be
401
+ // committed after a newer one. Reject any update whose
402
+ // capturedAt timestamp is older than what's already stored.
403
+ const incomingTs =
404
+ typeof patch.windowsCapturedAt === "number"
405
+ ? patch.windowsCapturedAt
406
+ : Date.now();
407
+ if (!s.windowsCapturedAt || incomingTs >= s.windowsCapturedAt) {
408
+ s.windows = Array.isArray(patch.windows)
409
+ ? patch.windows
410
+ : undefined;
411
+ s.windowsCapturedAt = incomingTs;
412
+ }
413
+ // else: silently drop the stale write
373
414
  }
374
415
  updated = true;
375
416
  }
@@ -344,9 +344,35 @@ function applyTabFormat(sessionName, runner, opts = {}) {
344
344
  */
345
345
  function installSessionHooks(sessionName, runner, opts = {}) {
346
346
  const agileflowBin = opts.agileflowBin || resolveAgileflowBin();
347
- const snapshotCmd = `run-shell -b '${agileflowBin} launch __snapshot-session ${sessionName}'`;
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 = [];
348
355
  for (const event of ["window-linked", "window-unlinked", "window-renamed"]) {
349
- runner.runSync(["set-hook", "-t", sessionName, event, snapshotCmd]);
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
+ }
350
376
  }
351
377
  }
352
378
 
@@ -701,6 +727,10 @@ async function launchInTmux(opts) {
701
727
  cli: cliId,
702
728
  cwd,
703
729
  uuid: null,
730
+ // new-session creates the wrapper at index 0 (default base-index
731
+ // before we change it). Restore replay skips this index so we
732
+ // don't duplicate the wrapper window.
733
+ wrapperWindowIndex: 0,
704
734
  });
705
735
  const create = createSession(
706
736
  {