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
|
@@ -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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
{
|