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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
{
|