agileflow 4.0.0-alpha.24 → 4.0.0-alpha.26

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.26",
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",
@@ -983,7 +983,13 @@ async function runInternalCloseWindow(deps = {}) {
983
983
  const exit = deps.exit || ((code) => process.exit(code));
984
984
  // ASCII Unit Separator — never appears in a session/window name or
985
985
  // filesystem path, so splitting on it is unambiguous.
986
- const DELIM = "\x1f";
986
+ // tmux's format-string parser escape-encodes control bytes (0x00-0x1F)
987
+ // into the literal 4-char `\OOO` octal sequence in output, so a
988
+ // 0x1f byte delimiter comes back as the text "\037" and split()
989
+ // never finds it. Use TAB instead — it survives format-string
990
+ // processing untouched, and is impossible in tmux session/window
991
+ // names (tmux rejects them) and rare-to-impossible in cwd paths.
992
+ const DELIM = "\t";
987
993
  // When the tmux keybind passes session+index positionally, target
988
994
  // that exact window. This avoids a wrong-window kill if focus shifts
989
995
  // between Alt+w being pressed and this subprocess starting.
@@ -1153,7 +1159,13 @@ async function runInternalRestoreWindow(deps = {}) {
1153
1159
  async function runInternalSnapshotSession(sessionName, deps = {}) {
1154
1160
  if (!sessionName) return;
1155
1161
  const runner = deps.runner || defaultTmuxRunner();
1156
- const DELIM = "\x1f";
1162
+ // tmux's format-string parser escape-encodes control bytes (0x00-0x1F)
1163
+ // into the literal 4-char `\OOO` octal sequence in output, so a
1164
+ // 0x1f byte delimiter comes back as the text "\037" and split()
1165
+ // never finds it. Use TAB instead — it survives format-string
1166
+ // processing untouched, and is impossible in tmux session/window
1167
+ // names (tmux rejects them) and rare-to-impossible in cwd paths.
1168
+ const DELIM = "\t";
1157
1169
  const fmt = `#{window_index}${DELIM}#{window_name}${DELIM}#{pane_current_path}`;
1158
1170
  // Capture timestamp BEFORE list-windows so concurrent subprocesses
1159
1171
  // produce a stable ordering. The registry rejects writes whose
@@ -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
  });
@@ -352,7 +352,16 @@ function installSessionHooks(sessionName, runner, opts = {}) {
352
352
  const snapshotCmd = `run-shell -b "${shellCmd.replace(/"/g, '\\"')}"`;
353
353
  /** @type {Array<{ event: string, stderr: string }>} */
354
354
  const failures = [];
355
- for (const event of ["window-linked", "window-unlinked", "window-renamed"]) {
355
+ // tmux 3.x hook names: `window-linked` and `window-unlinked` fire
356
+ // on add/remove. There is NO `window-renamed` event — that name is
357
+ // silently accepted by set-hook but never fires. The correct hook
358
+ // for rename is `after-rename-window` (the "after-<command>" family
359
+ // fires whenever the named command runs from any source).
360
+ for (const event of [
361
+ "window-linked",
362
+ "window-unlinked",
363
+ "after-rename-window",
364
+ ]) {
356
365
  const r = runner.runSync([
357
366
  "set-hook",
358
367
  "-t",
@@ -727,6 +736,10 @@ async function launchInTmux(opts) {
727
736
  cli: cliId,
728
737
  cwd,
729
738
  uuid: null,
739
+ // new-session creates the wrapper at index 0 (default base-index
740
+ // before we change it). Restore replay skips this index so we
741
+ // don't duplicate the wrapper window.
742
+ wrapperWindowIndex: 0,
730
743
  });
731
744
  const create = createSession(
732
745
  {