agileflow 4.0.0-alpha.21 → 4.0.0-alpha.23
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,54 @@ 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
|
+
const result = runner.runSync(["list-windows", "-t", sessionName, "-F", fmt]);
|
|
1159
|
+
if (result.status !== 0) return;
|
|
1160
|
+
const lines = (result.stdout || "")
|
|
1161
|
+
.split("\n")
|
|
1162
|
+
.map((l) => l.trim())
|
|
1163
|
+
.filter((l) => l.length > 0);
|
|
1164
|
+
/** @type {import("../../runtime/launch/session-registry.js").WindowSnapshot[]} */
|
|
1165
|
+
const windows = [];
|
|
1166
|
+
for (const line of lines) {
|
|
1167
|
+
const parts = line.split(DELIM);
|
|
1168
|
+
if (parts.length !== 3) continue;
|
|
1169
|
+
const index = Number(parts[0]);
|
|
1170
|
+
if (!Number.isFinite(index)) continue;
|
|
1171
|
+
const name = parts[1];
|
|
1172
|
+
const cwd = parts[2];
|
|
1173
|
+
if (!cwd) continue;
|
|
1174
|
+
windows.push({ index, name, cwd });
|
|
1175
|
+
}
|
|
1176
|
+
try {
|
|
1177
|
+
const {
|
|
1178
|
+
updateSession,
|
|
1179
|
+
} = require("../../runtime/launch/session-registry.js");
|
|
1180
|
+
updateSession(sessionName, { windows });
|
|
1181
|
+
} catch {
|
|
1182
|
+
// Registry might not exist or session might have been forgotten;
|
|
1183
|
+
// either way we can't usefully recover. Stay silent.
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1139
1187
|
/**
|
|
1140
1188
|
* Auto-restore check on bare `agileflow launch`. Fires only when:
|
|
1141
1189
|
* - tmux is available (we use sessionExists to count alive sessions)
|
|
@@ -1353,6 +1401,14 @@ async function launch(sub, nameArg, _options) {
|
|
|
1353
1401
|
await runInternalRestoreWindow({ targetSession: nameArg || "" });
|
|
1354
1402
|
return;
|
|
1355
1403
|
}
|
|
1404
|
+
if (sub === "__snapshot-session") {
|
|
1405
|
+
// Hidden subcommand invoked from tmux hooks (window-linked,
|
|
1406
|
+
// window-unlinked, window-renamed). Queries the current window
|
|
1407
|
+
// layout for the named session and writes it to the registry's
|
|
1408
|
+
// `windows` field so restore can replay every tab.
|
|
1409
|
+
await runInternalSnapshotSession(nameArg || "");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1356
1412
|
if (sub && sub !== "setup") {
|
|
1357
1413
|
fail(
|
|
1358
1414
|
new OperationFailedError(`unknown launch subcommand: ${sub}`, {
|
|
@@ -22,6 +22,9 @@ const {
|
|
|
22
22
|
sessionExists,
|
|
23
23
|
createSession,
|
|
24
24
|
applyKeybindPreset,
|
|
25
|
+
applyTabFormat,
|
|
26
|
+
installSessionHooks,
|
|
27
|
+
detectTmuxVersion,
|
|
25
28
|
defaultRunner,
|
|
26
29
|
} = require("./tmux.js");
|
|
27
30
|
const { createWorktree, removeWorktree } = require("./worktree.js");
|
|
@@ -185,6 +188,15 @@ async function runParallelSpawn(opts) {
|
|
|
185
188
|
}
|
|
186
189
|
}
|
|
187
190
|
|
|
191
|
+
// Apply the tab strip styling to the new session — without this,
|
|
192
|
+
// Alt+s and Alt+n spawn sessions with tmux's default green status
|
|
193
|
+
// bar. Same call the engine does on every fresh-launch session.
|
|
194
|
+
runner.runSync(["set-option", "-t", sessionName, "status", "1"]);
|
|
195
|
+
applyTabFormat(sessionName, runner, {
|
|
196
|
+
tmuxVersion: detectTmuxVersion(runner),
|
|
197
|
+
});
|
|
198
|
+
installSessionHooks(sessionName, runner);
|
|
199
|
+
|
|
188
200
|
// Swap the user's tmux client to the new session. If switch-client
|
|
189
201
|
// fails the session is still alive — surface its name so the user
|
|
190
202
|
// can attach manually.
|
|
@@ -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,10 @@ 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
|
|
131
140
|
*
|
|
132
141
|
* @typedef {Object} RegistryShape
|
|
133
142
|
* @property {1} version
|
|
@@ -192,6 +201,22 @@ function loadRegistry(home) {
|
|
|
192
201
|
if (typeof s.name !== "string" || !s.name) continue;
|
|
193
202
|
if (typeof s.cli !== "string" || !s.cli) continue;
|
|
194
203
|
if (typeof s.cwd !== "string" || !s.cwd) continue;
|
|
204
|
+
/** @type {WindowSnapshot[] | undefined} */
|
|
205
|
+
let windows;
|
|
206
|
+
if (Array.isArray(s.windows)) {
|
|
207
|
+
windows = [];
|
|
208
|
+
for (const w of s.windows) {
|
|
209
|
+
if (!w || typeof w !== "object") continue;
|
|
210
|
+
if (typeof w.name !== "string") continue;
|
|
211
|
+
if (typeof w.cwd !== "string" || !w.cwd) continue;
|
|
212
|
+
windows.push({
|
|
213
|
+
index: Number.isFinite(w.index) ? Number(w.index) : 0,
|
|
214
|
+
name: w.name,
|
|
215
|
+
cwd: w.cwd,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (windows.length === 0) windows = undefined;
|
|
219
|
+
}
|
|
195
220
|
sane.push({
|
|
196
221
|
name: s.name,
|
|
197
222
|
cli: s.cli,
|
|
@@ -207,6 +232,7 @@ function loadRegistry(home) {
|
|
|
207
232
|
base: String(s.worktree.base || ""),
|
|
208
233
|
}
|
|
209
234
|
: undefined,
|
|
235
|
+
windows,
|
|
210
236
|
});
|
|
211
237
|
}
|
|
212
238
|
return { version: 1, sessions: sane };
|
|
@@ -288,6 +314,16 @@ function recordSession(entry, home) {
|
|
|
288
314
|
: previous
|
|
289
315
|
? previous.pinned === true
|
|
290
316
|
: false;
|
|
317
|
+
// Preserve the last-known windows snapshot across re-records.
|
|
318
|
+
// Restore re-records sessions on the way back from disk and we
|
|
319
|
+
// don't want the windows array to be silently dropped before
|
|
320
|
+
// we get a chance to replay it.
|
|
321
|
+
const windows =
|
|
322
|
+
entry.windows !== undefined
|
|
323
|
+
? entry.windows
|
|
324
|
+
: previous
|
|
325
|
+
? previous.windows
|
|
326
|
+
: undefined;
|
|
291
327
|
filtered.push({
|
|
292
328
|
name: entry.name,
|
|
293
329
|
cli: entry.cli,
|
|
@@ -296,6 +332,7 @@ function recordSession(entry, home) {
|
|
|
296
332
|
lastSeen: entry.lastSeen || new Date().toISOString(),
|
|
297
333
|
pinned,
|
|
298
334
|
worktree: entry.worktree,
|
|
335
|
+
windows,
|
|
299
336
|
});
|
|
300
337
|
writeRegistry({ version: 1, sessions: filtered }, home);
|
|
301
338
|
});
|
|
@@ -331,6 +368,9 @@ function updateSession(name, patch, home) {
|
|
|
331
368
|
if (patch.cwd !== undefined) s.cwd = patch.cwd;
|
|
332
369
|
if (patch.worktree !== undefined) s.worktree = patch.worktree;
|
|
333
370
|
if (patch.pinned !== undefined) s.pinned = patch.pinned === true;
|
|
371
|
+
if (patch.windows !== undefined) {
|
|
372
|
+
s.windows = Array.isArray(patch.windows) ? patch.windows : undefined;
|
|
373
|
+
}
|
|
334
374
|
updated = true;
|
|
335
375
|
}
|
|
336
376
|
}
|
|
@@ -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,32 @@ 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
|
+
const snapshotCmd = `run-shell -b '${agileflowBin} launch __snapshot-session ${sessionName}'`;
|
|
348
|
+
for (const event of ["window-linked", "window-unlinked", "window-renamed"]) {
|
|
349
|
+
runner.runSync(["set-hook", "-t", sessionName, event, snapshotCmd]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
326
353
|
/**
|
|
327
354
|
* Create a new detached tmux session that immediately runs `bin args...`.
|
|
328
355
|
* Returns the runner's exit status (0 on success).
|
|
@@ -651,6 +678,7 @@ async function launchInTmux(opts) {
|
|
|
651
678
|
// tmux's default green status bar entirely.
|
|
652
679
|
runner.runSync(["set-option", "-t", base, "status", "1"]);
|
|
653
680
|
applyTabFormat(base, runner, { tmuxVersion });
|
|
681
|
+
installSessionHooks(base, runner);
|
|
654
682
|
log(`agileflow launch: resuming session ${base}`);
|
|
655
683
|
return attachSession(base, runner);
|
|
656
684
|
}
|
|
@@ -716,6 +744,7 @@ async function launchInTmux(opts) {
|
|
|
716
744
|
}
|
|
717
745
|
runner.runSync(["set-option", "-t", name, "status", "1"]);
|
|
718
746
|
applyTabFormat(name, runner, { tmuxVersion });
|
|
747
|
+
installSessionHooks(name, runner);
|
|
719
748
|
return attachSession(name, runner);
|
|
720
749
|
}
|
|
721
750
|
|
|
@@ -759,6 +788,7 @@ module.exports = {
|
|
|
759
788
|
substituteBinding,
|
|
760
789
|
detectTmuxVersion,
|
|
761
790
|
applyTabFormat,
|
|
791
|
+
installSessionHooks,
|
|
762
792
|
KEYBIND_PRESET_BINDINGS,
|
|
763
793
|
defaultRunner,
|
|
764
794
|
};
|