agileflow 4.0.0-alpha.24 → 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.24",
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",
@@ -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
@@ -141,6 +141,13 @@ function withRegistryLock(home, fn) {
141
141
  * captured. Newer captures
142
142
  * always win when concurrent
143
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.
144
151
  *
145
152
  * @typedef {Object} RegistryShape
146
153
  * @property {1} version
@@ -239,6 +246,8 @@ function loadRegistry(home) {
239
246
  windows,
240
247
  windowsCapturedAt:
241
248
  typeof s.windowsCapturedAt === "number" ? s.windowsCapturedAt : 0,
249
+ wrapperWindowIndex:
250
+ typeof s.wrapperWindowIndex === "number" ? s.wrapperWindowIndex : 0,
242
251
  });
243
252
  }
244
253
  return { version: 1, sessions: sane };
@@ -330,6 +339,16 @@ function recordSession(entry, home) {
330
339
  : previous
331
340
  ? previous.windows
332
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;
333
352
  filtered.push({
334
353
  name: entry.name,
335
354
  cli: entry.cli,
@@ -339,6 +358,7 @@ function recordSession(entry, home) {
339
358
  pinned,
340
359
  worktree: entry.worktree,
341
360
  windows,
361
+ wrapperWindowIndex,
342
362
  });
343
363
  writeRegistry({ version: 1, sessions: filtered }, home);
344
364
  });
@@ -727,6 +727,10 @@ async function launchInTmux(opts) {
727
727
  cli: cliId,
728
728
  cwd,
729
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,
730
734
  });
731
735
  const create = createSession(
732
736
  {