agileflow 4.0.0-alpha.22 → 4.0.0-alpha.24
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
|
@@ -1136,6 +1136,59 @@ async function runInternalRestoreWindow(deps = {}) {
|
|
|
1136
1136
|
}
|
|
1137
1137
|
}
|
|
1138
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* Hidden subcommand wired to tmux's window-linked / window-unlinked /
|
|
1141
|
+
* window-renamed hooks. Queries the named session's current window
|
|
1142
|
+
* layout and patches the registry's `windows` array so restore can
|
|
1143
|
+
* later replay every tab in its original cwd.
|
|
1144
|
+
*
|
|
1145
|
+
* Silent on every failure path: this runs in the background on every
|
|
1146
|
+
* tab change, so noise here would clutter the user's terminal. If the
|
|
1147
|
+
* session no longer exists or tmux is gone, we just no-op.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {string} sessionName
|
|
1150
|
+
* @param {{ runner?: ReturnType<typeof defaultTmuxRunner> }} [deps]
|
|
1151
|
+
* @returns {Promise<void>}
|
|
1152
|
+
*/
|
|
1153
|
+
async function runInternalSnapshotSession(sessionName, deps = {}) {
|
|
1154
|
+
if (!sessionName) return;
|
|
1155
|
+
const runner = deps.runner || defaultTmuxRunner();
|
|
1156
|
+
const DELIM = "\x1f";
|
|
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();
|
|
1163
|
+
const result = runner.runSync(["list-windows", "-t", sessionName, "-F", fmt]);
|
|
1164
|
+
if (result.status !== 0) return;
|
|
1165
|
+
const lines = (result.stdout || "")
|
|
1166
|
+
.split("\n")
|
|
1167
|
+
.map((l) => l.trim())
|
|
1168
|
+
.filter((l) => l.length > 0);
|
|
1169
|
+
/** @type {import("../../runtime/launch/session-registry.js").WindowSnapshot[]} */
|
|
1170
|
+
const windows = [];
|
|
1171
|
+
for (const line of lines) {
|
|
1172
|
+
const parts = line.split(DELIM);
|
|
1173
|
+
if (parts.length !== 3) continue;
|
|
1174
|
+
const index = Number(parts[0]);
|
|
1175
|
+
if (!Number.isFinite(index)) continue;
|
|
1176
|
+
const name = parts[1];
|
|
1177
|
+
const cwd = parts[2];
|
|
1178
|
+
if (!cwd) continue;
|
|
1179
|
+
windows.push({ index, name, cwd });
|
|
1180
|
+
}
|
|
1181
|
+
try {
|
|
1182
|
+
const {
|
|
1183
|
+
updateSession,
|
|
1184
|
+
} = require("../../runtime/launch/session-registry.js");
|
|
1185
|
+
updateSession(sessionName, { windows, windowsCapturedAt: capturedAt });
|
|
1186
|
+
} catch {
|
|
1187
|
+
// Registry might not exist or session might have been forgotten;
|
|
1188
|
+
// either way we can't usefully recover. Stay silent.
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1139
1192
|
/**
|
|
1140
1193
|
* Auto-restore check on bare `agileflow launch`. Fires only when:
|
|
1141
1194
|
* - tmux is available (we use sessionExists to count alive sessions)
|
|
@@ -1353,6 +1406,14 @@ async function launch(sub, nameArg, _options) {
|
|
|
1353
1406
|
await runInternalRestoreWindow({ targetSession: nameArg || "" });
|
|
1354
1407
|
return;
|
|
1355
1408
|
}
|
|
1409
|
+
if (sub === "__snapshot-session") {
|
|
1410
|
+
// Hidden subcommand invoked from tmux hooks (window-linked,
|
|
1411
|
+
// window-unlinked, window-renamed). Queries the current window
|
|
1412
|
+
// layout for the named session and writes it to the registry's
|
|
1413
|
+
// `windows` field so restore can replay every tab.
|
|
1414
|
+
await runInternalSnapshotSession(nameArg || "");
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1356
1417
|
if (sub && sub !== "setup") {
|
|
1357
1418
|
fail(
|
|
1358
1419
|
new OperationFailedError(`unknown launch subcommand: ${sub}`, {
|
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
createSession,
|
|
24
24
|
applyKeybindPreset,
|
|
25
25
|
applyTabFormat,
|
|
26
|
+
installSessionHooks,
|
|
26
27
|
detectTmuxVersion,
|
|
27
28
|
defaultRunner,
|
|
28
29
|
} = require("./tmux.js");
|
|
@@ -194,6 +195,7 @@ async function runParallelSpawn(opts) {
|
|
|
194
195
|
applyTabFormat(sessionName, runner, {
|
|
195
196
|
tmuxVersion: detectTmuxVersion(runner),
|
|
196
197
|
});
|
|
198
|
+
installSessionHooks(sessionName, runner);
|
|
197
199
|
|
|
198
200
|
// Swap the user's tmux client to the new session. If switch-client
|
|
199
201
|
// fails the session is still alive — surface its name so the user
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
createSession,
|
|
21
21
|
applyKeybindPreset,
|
|
22
22
|
applyTabFormat,
|
|
23
|
+
installSessionHooks,
|
|
23
24
|
detectTmuxVersion,
|
|
24
25
|
} = require("./tmux.js");
|
|
25
26
|
const { loadRegistry } = require("./session-registry.js");
|
|
@@ -126,7 +127,36 @@ function runRestore(opts) {
|
|
|
126
127
|
runner.runSync(["set-option", "-t", entry.name, "status", "1"]);
|
|
127
128
|
applyTabFormat(entry.name, runner, {
|
|
128
129
|
tmuxVersion: detectTmuxVersion(runner),
|
|
130
|
+
agileflowBin,
|
|
129
131
|
});
|
|
132
|
+
// Replay saved tabs (windows) if we have any from the last hook
|
|
133
|
+
// snapshot. The first window already exists (createSession opened
|
|
134
|
+
// it running __exec), so we skip it. Each restored window is
|
|
135
|
+
// opened with `new-window -t session -c cwd -n name` and only
|
|
136
|
+
// gets a shell — restoring the original CLI is out of scope (we
|
|
137
|
+
// don't know what command was running, and respawning Claude in
|
|
138
|
+
// every tab would be surprising). Users can re-run agileflow in
|
|
139
|
+
// any tab manually.
|
|
140
|
+
if (Array.isArray(entry.windows) && entry.windows.length > 1) {
|
|
141
|
+
const sorted = entry.windows
|
|
142
|
+
.slice()
|
|
143
|
+
.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];
|
|
147
|
+
if (!existsSync(w.cwd)) continue;
|
|
148
|
+
const args = ["new-window", "-t", entry.name, "-c", w.cwd];
|
|
149
|
+
if (w.name) args.push("-n", w.name);
|
|
150
|
+
runner.runSync(args);
|
|
151
|
+
}
|
|
152
|
+
log(
|
|
153
|
+
`agileflow launch: replayed ${sorted.length - 1} tab(s) for ${entry.name}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
// Install hooks AFTER replay so the new-window calls above don't
|
|
157
|
+
// each fire window-linked and overwrite the saved snapshot with
|
|
158
|
+
// a partial mid-replay state.
|
|
159
|
+
installSessionHooks(entry.name, runner, { agileflowBin });
|
|
130
160
|
result.restored++;
|
|
131
161
|
log(`agileflow launch: restored session ${entry.name} (${entry.cwd})`);
|
|
132
162
|
}
|
|
@@ -117,6 +117,11 @@ function withRegistryLock(home, fn) {
|
|
|
117
117
|
* @property {string} branch
|
|
118
118
|
* @property {string} base
|
|
119
119
|
*
|
|
120
|
+
* @typedef {Object} WindowSnapshot
|
|
121
|
+
* @property {number} index - tmux window index at snapshot time
|
|
122
|
+
* @property {string} name - tmux #W (window name)
|
|
123
|
+
* @property {string} cwd - pane's cwd
|
|
124
|
+
*
|
|
120
125
|
* @typedef {Object} SessionEntry
|
|
121
126
|
* @property {string} name
|
|
122
127
|
* @property {string} cli
|
|
@@ -128,6 +133,14 @@ function withRegistryLock(home, fn) {
|
|
|
128
133
|
* are pre-selected in the auto-
|
|
129
134
|
* restore picker
|
|
130
135
|
* @property {WorktreeMeta} [worktree]
|
|
136
|
+
* @property {WindowSnapshot[]} [windows] - last-known tab layout, written
|
|
137
|
+
* by tmux hooks; replayed on
|
|
138
|
+
* restore so reboots bring back
|
|
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.
|
|
131
144
|
*
|
|
132
145
|
* @typedef {Object} RegistryShape
|
|
133
146
|
* @property {1} version
|
|
@@ -192,6 +205,22 @@ function loadRegistry(home) {
|
|
|
192
205
|
if (typeof s.name !== "string" || !s.name) continue;
|
|
193
206
|
if (typeof s.cli !== "string" || !s.cli) continue;
|
|
194
207
|
if (typeof s.cwd !== "string" || !s.cwd) continue;
|
|
208
|
+
/** @type {WindowSnapshot[] | undefined} */
|
|
209
|
+
let windows;
|
|
210
|
+
if (Array.isArray(s.windows)) {
|
|
211
|
+
windows = [];
|
|
212
|
+
for (const w of s.windows) {
|
|
213
|
+
if (!w || typeof w !== "object") continue;
|
|
214
|
+
if (typeof w.name !== "string") continue;
|
|
215
|
+
if (typeof w.cwd !== "string" || !w.cwd) continue;
|
|
216
|
+
windows.push({
|
|
217
|
+
index: Number.isFinite(w.index) ? Number(w.index) : 0,
|
|
218
|
+
name: w.name,
|
|
219
|
+
cwd: w.cwd,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (windows.length === 0) windows = undefined;
|
|
223
|
+
}
|
|
195
224
|
sane.push({
|
|
196
225
|
name: s.name,
|
|
197
226
|
cli: s.cli,
|
|
@@ -207,6 +236,9 @@ function loadRegistry(home) {
|
|
|
207
236
|
base: String(s.worktree.base || ""),
|
|
208
237
|
}
|
|
209
238
|
: undefined,
|
|
239
|
+
windows,
|
|
240
|
+
windowsCapturedAt:
|
|
241
|
+
typeof s.windowsCapturedAt === "number" ? s.windowsCapturedAt : 0,
|
|
210
242
|
});
|
|
211
243
|
}
|
|
212
244
|
return { version: 1, sessions: sane };
|
|
@@ -288,6 +320,16 @@ function recordSession(entry, home) {
|
|
|
288
320
|
: previous
|
|
289
321
|
? previous.pinned === true
|
|
290
322
|
: false;
|
|
323
|
+
// Preserve the last-known windows snapshot across re-records.
|
|
324
|
+
// Restore re-records sessions on the way back from disk and we
|
|
325
|
+
// don't want the windows array to be silently dropped before
|
|
326
|
+
// we get a chance to replay it.
|
|
327
|
+
const windows =
|
|
328
|
+
entry.windows !== undefined
|
|
329
|
+
? entry.windows
|
|
330
|
+
: previous
|
|
331
|
+
? previous.windows
|
|
332
|
+
: undefined;
|
|
291
333
|
filtered.push({
|
|
292
334
|
name: entry.name,
|
|
293
335
|
cli: entry.cli,
|
|
@@ -296,6 +338,7 @@ function recordSession(entry, home) {
|
|
|
296
338
|
lastSeen: entry.lastSeen || new Date().toISOString(),
|
|
297
339
|
pinned,
|
|
298
340
|
worktree: entry.worktree,
|
|
341
|
+
windows,
|
|
299
342
|
});
|
|
300
343
|
writeRegistry({ version: 1, sessions: filtered }, home);
|
|
301
344
|
});
|
|
@@ -331,6 +374,24 @@ function updateSession(name, patch, home) {
|
|
|
331
374
|
if (patch.cwd !== undefined) s.cwd = patch.cwd;
|
|
332
375
|
if (patch.worktree !== undefined) s.worktree = patch.worktree;
|
|
333
376
|
if (patch.pinned !== undefined) s.pinned = patch.pinned === true;
|
|
377
|
+
if (patch.windows !== undefined) {
|
|
378
|
+
// Stale-snapshot guard: hooks fire on every tab change, so
|
|
379
|
+
// when the user spams Alt+t multiple subprocesses can race.
|
|
380
|
+
// Lock acquisition isn't FIFO so an older capture can be
|
|
381
|
+
// committed after a newer one. Reject any update whose
|
|
382
|
+
// capturedAt timestamp is older than what's already stored.
|
|
383
|
+
const incomingTs =
|
|
384
|
+
typeof patch.windowsCapturedAt === "number"
|
|
385
|
+
? patch.windowsCapturedAt
|
|
386
|
+
: Date.now();
|
|
387
|
+
if (!s.windowsCapturedAt || incomingTs >= s.windowsCapturedAt) {
|
|
388
|
+
s.windows = Array.isArray(patch.windows)
|
|
389
|
+
? patch.windows
|
|
390
|
+
: undefined;
|
|
391
|
+
s.windowsCapturedAt = incomingTs;
|
|
392
|
+
}
|
|
393
|
+
// else: silently drop the stale write
|
|
394
|
+
}
|
|
334
395
|
updated = true;
|
|
335
396
|
}
|
|
336
397
|
}
|
|
@@ -193,6 +193,7 @@ function detectTmuxVersion(runner) {
|
|
|
193
193
|
* @param {{
|
|
194
194
|
* tmuxVersion?: { major: number, minor: number } | null,
|
|
195
195
|
* theme?: Partial<typeof tabs.DEFAULT_TAB_THEME>,
|
|
196
|
+
* agileflowBin?: string,
|
|
196
197
|
* }} [opts]
|
|
197
198
|
* @returns {{ applied: boolean, stderr: string }}
|
|
198
199
|
*/
|
|
@@ -323,6 +324,58 @@ function applyTabFormat(sessionName, runner, opts = {}) {
|
|
|
323
324
|
};
|
|
324
325
|
}
|
|
325
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Install per-session tmux hooks that fire whenever the window layout
|
|
329
|
+
* changes (new tab, close, rename). Each hook invokes the
|
|
330
|
+
* `__snapshot-session` callback so the registry's `windows` array
|
|
331
|
+
* stays current — that's what restore replays after a reboot.
|
|
332
|
+
*
|
|
333
|
+
* Kept separate from `applyTabFormat` so the restore path can replay
|
|
334
|
+
* the saved windows BEFORE installing hooks (otherwise the new-window
|
|
335
|
+
* calls during replay would fire window-linked, clobbering the saved
|
|
336
|
+
* snapshot with a partial mid-replay state).
|
|
337
|
+
*
|
|
338
|
+
* Scoped per-session via `-t` so non-AgileFlow sessions on the same
|
|
339
|
+
* tmux server aren't touched.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} sessionName
|
|
342
|
+
* @param {TmuxRunner} runner
|
|
343
|
+
* @param {{ agileflowBin?: string }} [opts]
|
|
344
|
+
*/
|
|
345
|
+
function installSessionHooks(sessionName, runner, opts = {}) {
|
|
346
|
+
const agileflowBin = opts.agileflowBin || resolveAgileflowBin();
|
|
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 = [];
|
|
355
|
+
for (const event of ["window-linked", "window-unlinked", "window-renamed"]) {
|
|
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
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
326
379
|
/**
|
|
327
380
|
* Create a new detached tmux session that immediately runs `bin args...`.
|
|
328
381
|
* Returns the runner's exit status (0 on success).
|
|
@@ -651,6 +704,7 @@ async function launchInTmux(opts) {
|
|
|
651
704
|
// tmux's default green status bar entirely.
|
|
652
705
|
runner.runSync(["set-option", "-t", base, "status", "1"]);
|
|
653
706
|
applyTabFormat(base, runner, { tmuxVersion });
|
|
707
|
+
installSessionHooks(base, runner);
|
|
654
708
|
log(`agileflow launch: resuming session ${base}`);
|
|
655
709
|
return attachSession(base, runner);
|
|
656
710
|
}
|
|
@@ -716,6 +770,7 @@ async function launchInTmux(opts) {
|
|
|
716
770
|
}
|
|
717
771
|
runner.runSync(["set-option", "-t", name, "status", "1"]);
|
|
718
772
|
applyTabFormat(name, runner, { tmuxVersion });
|
|
773
|
+
installSessionHooks(name, runner);
|
|
719
774
|
return attachSession(name, runner);
|
|
720
775
|
}
|
|
721
776
|
|
|
@@ -759,6 +814,7 @@ module.exports = {
|
|
|
759
814
|
substituteBinding,
|
|
760
815
|
detectTmuxVersion,
|
|
761
816
|
applyTabFormat,
|
|
817
|
+
installSessionHooks,
|
|
762
818
|
KEYBIND_PRESET_BINDINGS,
|
|
763
819
|
defaultRunner,
|
|
764
820
|
};
|