@hasna/snapshots 0.1.1 → 0.1.2

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/dist/restore.js CHANGED
@@ -3,8 +3,23 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { commandExists, nowIso, runTmux, sha256, stableJson, tmuxCommand } from "./util.js";
4
4
  import { resolvePolicy } from "./policy.js";
5
5
  export function createRestorePlan(snapshot, resources, policies = [], options = {}) {
6
+ const request = normalizeRestoreRequest(options);
7
+ const selection = selectResources(resources, request);
8
+ const selectedResourceIds = new Set(selection.resources.map((resource) => resource.id));
9
+ const strictExistingTmuxSessions = strictExistingTmuxSessionNames(selection.resources, request);
6
10
  const operations = [];
7
- for (const resource of resources) {
11
+ const planWarnings = [...selection.warnings, ...tmuxPlanWarnings(selection.resources, request)];
12
+ for (const resource of selection.resources) {
13
+ const missingParent = missingSelectedParent(resource, resources, selectedResourceIds);
14
+ if (missingParent) {
15
+ operations.push(operation(resource, "dependency.missing", `Resource requires parent ${missingParent}. Re-run with --with-dependencies to include it.`, "blocked", "Partial restore cannot safely apply a child resource without its captured parent.", undefined, {
16
+ dependsOn: [missingParent],
17
+ warnings: ["Dependency closure is incomplete."],
18
+ confidence: "impossible",
19
+ risk: "medium"
20
+ }));
21
+ continue;
22
+ }
8
23
  const policy = resolvePolicy(resource, policies);
9
24
  if (policy.mode === "ignore") {
10
25
  operations.push(operation(resource, "ignored", "Ignored by restore policy.", "skipped", policy.reason));
@@ -18,14 +33,25 @@ export function createRestorePlan(snapshot, resources, policies = [], options =
18
33
  operations.push(planProject(resource));
19
34
  }
20
35
  else if (resource.kind === "tmux-session") {
21
- operations.push(planTmuxSession(resource, resources));
36
+ operations.push(planTmuxSession(resource, resources, request));
37
+ operations.push(...planTmuxSessionState(resource, resources));
22
38
  }
23
39
  else if (resource.kind === "tmux-window") {
24
- operations.push(planTmuxWindow(resource));
40
+ const blockedSession = tmuxSessionForResource(resource);
41
+ if (blockedSession && strictExistingTmuxSessions.has(blockedSession)) {
42
+ operations.push(blockedExistingTmuxSubtree(resource, blockedSession));
43
+ continue;
44
+ }
45
+ operations.push(planTmuxWindow(resource, request));
25
46
  operations.push(...planTmuxWindowState(resource));
26
47
  }
27
48
  else if (resource.kind === "tmux-pane") {
28
- operations.push(planTmuxPane(resource, resources));
49
+ const blockedSession = tmuxSessionForResource(resource);
50
+ if (blockedSession && strictExistingTmuxSessions.has(blockedSession)) {
51
+ operations.push(blockedExistingTmuxSubtree(resource, blockedSession));
52
+ continue;
53
+ }
54
+ operations.push(planTmuxPane(resource, resources, request));
29
55
  operations.push(...planTmuxPaneState(resource));
30
56
  }
31
57
  else if (resource.kind === "process") {
@@ -38,14 +64,25 @@ export function createRestorePlan(snapshot, resources, policies = [], options =
38
64
  operations.push(operation(resource, "unsupported", "No restore adapter for this resource kind.", "skipped"));
39
65
  }
40
66
  }
41
- const plan = {
42
- id: `plan_${snapshot.id}_${sha256(stableJson(operations.map((op) => ({ id: op.id, status: op.status })))).slice(0, 12)}`,
67
+ const basePlan = {
68
+ id: `plan_${snapshot.id}_pending`,
43
69
  snapshotId: snapshot.id,
44
70
  createdAt: nowIso(),
45
71
  apply: Boolean(options.apply),
72
+ request,
73
+ matchedSelectors: selection.matchedSelectors,
74
+ unmatchedSelectors: selection.unmatchedSelectors,
75
+ autoAddedDependencies: selection.autoAddedDependencies,
76
+ warnings: planWarnings,
77
+ autopilot: assessAutopilot(operations),
46
78
  operations: sortOperations(operations),
47
79
  summary: summarizeOperations(operations)
48
80
  };
81
+ basePlan.planHash = hashRestorePlan(basePlan);
82
+ const plan = {
83
+ ...basePlan,
84
+ id: `plan_${snapshot.id}_${basePlan.planHash.slice(0, 12)}`
85
+ };
49
86
  if (options.apply) {
50
87
  return executeRestorePlan(plan, options);
51
88
  }
@@ -87,7 +124,7 @@ function planProject(resource) {
87
124
  path
88
125
  ]);
89
126
  }
90
- function planTmuxSession(resource, resources) {
127
+ function planTmuxSession(resource, resources, request) {
91
128
  const name = resource.name;
92
129
  if (!/^[A-Za-z0-9_.:-]+$/.test(name)) {
93
130
  return operation(resource, "tmux.create-session", `Unsafe tmux session name: ${name}`, "blocked");
@@ -112,32 +149,55 @@ function planTmuxSession(resource, resources) {
112
149
  command.push("-n", firstWindow.attributes.name);
113
150
  }
114
151
  const startCommand = typeof firstWindow?.attributes.start_command === "string" ? firstWindow.attributes.start_command : "";
115
- if (firstWindow?.attributes.restartable === true && startCommand) {
152
+ if (shouldReplayTmuxCommand(firstWindow, request, startCommand)) {
116
153
  command.push(startCommand);
117
154
  }
118
155
  return operation(resource, "tmux.create-session", `Create detached tmux session: ${name}`, "planned", undefined, [
119
156
  ...command
120
- ]);
157
+ ], tmuxCreateExtras(firstWindow ?? resource, request, startCommand));
158
+ }
159
+ function planTmuxSessionState(resource, resources) {
160
+ const name = resource.name;
161
+ if (!commandExists("tmux"))
162
+ return [];
163
+ if (tmuxSessionExists(name))
164
+ return [];
165
+ const firstWindow = resources
166
+ .filter((candidate) => candidate.kind === "tmux-window" && candidate.parentId === resource.id)
167
+ .sort((a, b) => Number(a.attributes.index ?? 0) - Number(b.attributes.index ?? 0))[0];
168
+ const windowIndex = Number(firstWindow?.attributes.index);
169
+ if (!Number.isFinite(windowIndex))
170
+ return [];
171
+ return [
172
+ operation(firstWindow ?? resource, "tmux.move-window", `Restore first tmux window index: ${name}:${windowIndex}`, "planned", undefined, tmuxCommand(["move-window", "-s", `${name}:`, "-t", `${name}:${windowIndex}`]), {
173
+ confidence: "best-effort",
174
+ warnings: ["Moves the implicit first tmux window created by new-session to the captured index when tmux base-index differs."],
175
+ risk: "low",
176
+ effects: ["restore tmux first-window index"]
177
+ })
178
+ ];
121
179
  }
122
- function planTmuxWindow(resource) {
180
+ function planTmuxWindow(resource, request) {
123
181
  const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
124
182
  const name = typeof resource.attributes.name === "string" ? resource.attributes.name : resource.name.split(":").slice(2).join(":");
183
+ const windowIndex = Number(resource.attributes.index);
125
184
  if (!session || !name)
126
185
  return operation(resource, "tmux.create-window", "Window is missing session/name metadata.", "blocked");
127
186
  if (!commandExists("tmux"))
128
187
  return operation(resource, "tmux.create-window", "tmux is not installed or not on PATH.", "blocked");
129
- if (tmuxWindowExists(session, name)) {
130
- return operation(resource, "tmux.window-exists", `tmux window already exists: ${session}:${name}`, "noop");
188
+ if (tmuxWindowExists(session, name, Number.isFinite(windowIndex) ? windowIndex : undefined)) {
189
+ return operation(resource, "tmux.window-exists", `tmux window already exists: ${session}:${Number.isFinite(windowIndex) ? windowIndex : name}`, "noop");
131
190
  }
132
191
  const cwd = typeof resource.attributes.current_path === "string" ? resource.attributes.current_path : process.cwd();
133
- const command = tmuxCommand(["new-window", "-d", "-t", session, "-n", name, "-c", cwd]);
192
+ const target = Number.isFinite(windowIndex) ? `${session}:${windowIndex}` : session;
193
+ const command = tmuxCommand(["new-window", "-d", "-t", target, "-n", name, "-c", cwd]);
134
194
  const startCommand = typeof resource.attributes.start_command === "string" ? resource.attributes.start_command : "";
135
- if (resource.attributes.restartable === true && startCommand) {
195
+ if (shouldReplayTmuxCommand(resource, request, startCommand)) {
136
196
  command.push(startCommand);
137
197
  }
138
- return operation(resource, "tmux.create-window", `Create tmux window: ${session}:${name}`, "planned", undefined, command);
198
+ return operation(resource, "tmux.create-window", `Create tmux window: ${session}:${name}`, "planned", undefined, command, tmuxCreateExtras(resource, request, startCommand));
139
199
  }
140
- function planTmuxPane(resource, resources) {
200
+ function planTmuxPane(resource, resources, request) {
141
201
  const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
142
202
  const windowIndex = Number(resource.attributes.window_index);
143
203
  const paneIndex = Number(resource.attributes.pane_index);
@@ -155,10 +215,10 @@ function planTmuxPane(resource, resources) {
155
215
  const cwd = typeof resource.attributes.current_path === "string" ? resource.attributes.current_path : process.cwd();
156
216
  const command = tmuxCommand(["split-window", "-d", "-t", `${session}:${windowIndex}`, "-c", cwd]);
157
217
  const startCommand = typeof resource.attributes.start_command === "string" ? resource.attributes.start_command : "";
158
- if (resource.attributes.restartable === true && startCommand) {
218
+ if (shouldReplayTmuxCommand(resource, request, startCommand)) {
159
219
  command.push(startCommand);
160
220
  }
161
- return operation(resource, "tmux.create-pane", `Create tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, command);
221
+ return operation(resource, "tmux.create-pane", `Create tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, command, tmuxCreateExtras(resource, request, startCommand));
162
222
  }
163
223
  function planTmuxWindowState(resource) {
164
224
  const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
@@ -168,10 +228,16 @@ function planTmuxWindowState(resource) {
168
228
  const operations = [];
169
229
  const layout = typeof resource.attributes.layout === "string" ? resource.attributes.layout : undefined;
170
230
  if (layout && Number(resource.attributes.pane_count ?? 0) > 1) {
171
- operations.push(operation(resource, "tmux.select-layout", `Restore tmux layout: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-layout", "-t", `${session}:${windowIndex}`, layout])));
231
+ operations.push(operation(resource, "tmux.select-layout", `Restore tmux layout: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-layout", "-t", `${session}:${windowIndex}`, layout]), {
232
+ confidence: "best-effort",
233
+ warnings: ["tmux layout restore is best-effort and does not restore shell/process state, scrollback, marks, or client size."]
234
+ }));
172
235
  }
173
236
  if (resource.attributes.active === true) {
174
- operations.push(operation(resource, "tmux.select-window", `Restore active tmux window: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-window", "-t", `${session}:${windowIndex}`])));
237
+ operations.push(operation(resource, "tmux.select-window", `Restore active tmux window: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-window", "-t", `${session}:${windowIndex}`]), {
238
+ confidence: "best-effort",
239
+ warnings: ["tmux active-window selection may affect the current live client when merging into an existing session."]
240
+ }));
175
241
  }
176
242
  return operations;
177
243
  }
@@ -184,7 +250,10 @@ function planTmuxPaneState(resource) {
184
250
  if (resource.attributes.active !== true)
185
251
  return [];
186
252
  return [
187
- operation(resource, "tmux.select-pane", `Restore active tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, tmuxCommand(["select-pane", "-t", `${session}:${windowIndex}.${paneIndex}`]))
253
+ operation(resource, "tmux.select-pane", `Restore active tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, tmuxCommand(["select-pane", "-t", `${session}:${windowIndex}.${paneIndex}`]), {
254
+ confidence: "best-effort",
255
+ warnings: ["tmux active-pane selection may affect the current live client when merging into an existing session."]
256
+ })
188
257
  ];
189
258
  }
190
259
  function planProcess(resource) {
@@ -275,6 +344,21 @@ function executeOperation(op) {
275
344
  return { ...op, status: "applied" };
276
345
  return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
277
346
  }
347
+ if (op.kind === "tmux.move-window" && op.command) {
348
+ const session = String(op.resource?.attributes.session ?? "");
349
+ const windowIndex = Number(op.resource?.attributes.index);
350
+ if (session && Number.isFinite(windowIndex)) {
351
+ const current = runTmux(["display-message", "-p", "-t", `${session}:`, "#{window_index}"], 2_000);
352
+ if (current.status === 0 && Number(current.stdout.trim()) === windowIndex) {
353
+ return { ...op, status: "noop", reason: "First window already has the captured index." };
354
+ }
355
+ }
356
+ const [command, ...args] = op.command;
357
+ const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
358
+ if (result.status === 0)
359
+ return { ...op, status: "applied" };
360
+ return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
361
+ }
278
362
  if ((op.kind === "tmux.select-layout" || op.kind === "tmux.select-pane" || op.kind === "tmux.select-window") && op.command) {
279
363
  const [command, ...args] = op.command;
280
364
  const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
@@ -297,7 +381,7 @@ function executeOperation(op) {
297
381
  }
298
382
  return { ...op, status: "blocked", reason: "No executor for operation kind." };
299
383
  }
300
- function operation(resource, kind, summary, status, reason, command) {
384
+ function operation(resource, kind, summary, status, reason, command, extra = {}) {
301
385
  return {
302
386
  id: `${kind}:${resource.id}`,
303
387
  kind,
@@ -307,9 +391,274 @@ function operation(resource, kind, summary, status, reason, command) {
307
391
  status,
308
392
  reason,
309
393
  command,
310
- resource
394
+ resource,
395
+ ...extra
396
+ };
397
+ }
398
+ function normalizeRestoreRequest(options) {
399
+ const request = {
400
+ dependencyMode: options.dependencyMode ?? "none",
401
+ targetMode: options.targetMode ?? "strict",
402
+ tmuxMode: options.tmuxMode ?? "layout-only"
403
+ };
404
+ if (options.include?.length)
405
+ request.include = uniqueStrings(options.include);
406
+ if (options.exclude?.length)
407
+ request.exclude = uniqueStrings(options.exclude);
408
+ if (options.applyPlanId)
409
+ request.applyPlanId = options.applyPlanId;
410
+ if (options.planHash)
411
+ request.planHash = options.planHash;
412
+ return request;
413
+ }
414
+ function selectResources(resources, request) {
415
+ const include = request.include ?? [];
416
+ const exclude = request.exclude ?? [];
417
+ const matchedSelectors = [];
418
+ const unmatchedSelectors = [];
419
+ const warnings = [];
420
+ const autoAddedDependencies = [];
421
+ const byId = new Map(resources.map((resource) => [resource.id, resource]));
422
+ const childrenByParent = new Map();
423
+ for (const resource of resources) {
424
+ if (!resource.parentId)
425
+ continue;
426
+ const children = childrenByParent.get(resource.parentId) ?? [];
427
+ children.push(resource);
428
+ childrenByParent.set(resource.parentId, children);
429
+ }
430
+ const selectedIds = new Set();
431
+ const includeSelectors = include.length ? include : ["*"];
432
+ for (const selector of includeSelectors) {
433
+ const matches = matchResources(resources, selector).map((resource) => resource.id);
434
+ matchedSelectors.push({ selector, matchedResourceIds: matches });
435
+ if (!matches.length && selector !== "*")
436
+ unmatchedSelectors.push(selector);
437
+ for (const id of matches)
438
+ selectedIds.add(id);
439
+ }
440
+ if ((request.dependencyMode === "parents" || request.dependencyMode === "full") && include.length) {
441
+ for (const id of [...selectedIds])
442
+ addParentDependencies(id, id, byId, selectedIds, autoAddedDependencies);
443
+ }
444
+ if (request.dependencyMode === "full" && include.length) {
445
+ for (const id of [...selectedIds])
446
+ addChildDependencies(id, id, childrenByParent, selectedIds, autoAddedDependencies);
447
+ }
448
+ for (const selector of exclude) {
449
+ const matches = matchResources(resources, selector).map((resource) => resource.id);
450
+ matchedSelectors.push({ selector: `!${selector}`, matchedResourceIds: matches });
451
+ if (!matches.length)
452
+ unmatchedSelectors.push(`!${selector}`);
453
+ for (const id of matches)
454
+ selectedIds.delete(id);
455
+ }
456
+ if (include.length && request.dependencyMode === "none") {
457
+ warnings.push("Partial restore requested without dependency closure; child resources with omitted parents will be blocked.");
458
+ }
459
+ return {
460
+ resources: resources.filter((resource) => selectedIds.has(resource.id)),
461
+ matchedSelectors,
462
+ unmatchedSelectors,
463
+ autoAddedDependencies: autoAddedDependencies.filter((entry) => selectedIds.has(entry.resourceId)),
464
+ warnings
311
465
  };
312
466
  }
467
+ function matchResources(resources, selector) {
468
+ const trimmed = selector.trim();
469
+ if (!trimmed || trimmed === "*")
470
+ return resources;
471
+ const [prefix, ...rest] = trimmed.split(":");
472
+ const value = rest.join(":");
473
+ if (!value)
474
+ return resources.filter((resource) => resource.id === trimmed);
475
+ if (prefix === "id")
476
+ return resources.filter((resource) => resource.id === value);
477
+ if (prefix === "kind")
478
+ return resources.filter((resource) => resource.kind === value);
479
+ if (prefix === "source")
480
+ return resources.filter((resource) => resource.source === value);
481
+ if (prefix === "parent")
482
+ return resources.filter((resource) => resource.parentId === value);
483
+ if (prefix === "name") {
484
+ const normalized = value.toLowerCase();
485
+ return resources.filter((resource) => resource.name.toLowerCase().includes(normalized));
486
+ }
487
+ if (prefix === "path") {
488
+ return resources.filter((resource) => resourcePaths(resource).some((path) => path === value || path.startsWith(`${value}/`)));
489
+ }
490
+ return resources.filter((resource) => resource.id === trimmed);
491
+ }
492
+ function resourcePaths(resource) {
493
+ return ["path", "current_path", "app_path"]
494
+ .map((key) => resource.attributes[key])
495
+ .filter((value) => typeof value === "string");
496
+ }
497
+ function addParentDependencies(resourceId, requiredBy, byId, selectedIds, added) {
498
+ const resource = byId.get(resourceId);
499
+ if (!resource?.parentId)
500
+ return;
501
+ if (!selectedIds.has(resource.parentId)) {
502
+ selectedIds.add(resource.parentId);
503
+ added.push({ resourceId: resource.parentId, requiredBy, reason: "parent dependency" });
504
+ }
505
+ addParentDependencies(resource.parentId, requiredBy, byId, selectedIds, added);
506
+ }
507
+ function addChildDependencies(resourceId, requiredBy, childrenByParent, selectedIds, added) {
508
+ for (const child of childrenByParent.get(resourceId) ?? []) {
509
+ if (!selectedIds.has(child.id)) {
510
+ selectedIds.add(child.id);
511
+ added.push({ resourceId: child.id, requiredBy, reason: "child dependency" });
512
+ }
513
+ addChildDependencies(child.id, requiredBy, childrenByParent, selectedIds, added);
514
+ }
515
+ }
516
+ function missingSelectedParent(resource, allResources, selectedResourceIds) {
517
+ if (!resource.parentId)
518
+ return undefined;
519
+ if (selectedResourceIds.has(resource.parentId))
520
+ return undefined;
521
+ return allResources.some((candidate) => candidate.id === resource.parentId) ? resource.parentId : undefined;
522
+ }
523
+ function strictExistingTmuxSessionNames(resources, request) {
524
+ if (request.targetMode === "merge-existing")
525
+ return new Set();
526
+ if (!commandExists("tmux"))
527
+ return new Set();
528
+ const names = new Set();
529
+ for (const resource of resources) {
530
+ if (resource.kind !== "tmux-session")
531
+ continue;
532
+ if (tmuxSessionExists(resource.name))
533
+ names.add(resource.name);
534
+ }
535
+ return names;
536
+ }
537
+ function shouldReplayTmuxCommand(resource, request, startCommand) {
538
+ return request.tmuxMode === "resume-marked" && resource?.attributes.restartable === true && Boolean(startCommand);
539
+ }
540
+ function tmuxCreateExtras(resource, request, startCommand) {
541
+ const warnings = [
542
+ "tmux restore recreates layout/cwd best-effort; it cannot restore shell internals, scrollback, process memory, or client attachment."
543
+ ];
544
+ if (startCommand && resource.attributes.restartable === true && request.tmuxMode !== "resume-marked") {
545
+ warnings.push("Captured restartable command was not replayed because tmux mode is layout-only.");
546
+ }
547
+ if (startCommand && resource.attributes.restartable !== true) {
548
+ warnings.push("Captured command is forensic-only because it lacks a restartable marker.");
549
+ }
550
+ return {
551
+ warnings,
552
+ confidence: "best-effort",
553
+ risk: shouldReplayTmuxCommand(resource, request, startCommand) ? "high" : "low",
554
+ effects: shouldReplayTmuxCommand(resource, request, startCommand)
555
+ ? ["create tmux structure", "replay restartable command"]
556
+ : ["create tmux structure"]
557
+ };
558
+ }
559
+ function tmuxPlanWarnings(resources, request) {
560
+ const warnings = [];
561
+ if (resources.some((resource) => resource.kind === "tmux-session" && resource.attributes.attached === true)) {
562
+ warnings.push("tmux client attachment is captured for context but restore creates detached sessions.");
563
+ }
564
+ if (request.tmuxMode === "layout-only" && resources.some((resource) => resource.source === "tmux" && typeof resource.attributes.start_command === "string" && resource.attributes.start_command)) {
565
+ warnings.push("tmux restore mode is layout-only; captured start commands are preserved as forensic data but not replayed.");
566
+ }
567
+ return warnings;
568
+ }
569
+ function tmuxSessionExists(name) {
570
+ return runTmux(["has-session", "-t", name], 2_000).status === 0;
571
+ }
572
+ function tmuxSessionForResource(resource) {
573
+ if (resource.kind === "tmux-session")
574
+ return resource.name;
575
+ return typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
576
+ }
577
+ function blockedExistingTmuxSubtree(resource, session) {
578
+ return operation(resource, "tmux.blocked-existing-session", `Blocked restore into existing tmux session: ${session}`, "blocked", "Existing tmux sessions are not merged by default. Re-run with --merge-existing to opt into live-session mutation.", undefined, {
579
+ preconditions: [`tmux session must be absent or --merge-existing must be set: ${session}`],
580
+ warnings: ["Default strict restore avoids mutating live tmux sessions."],
581
+ risk: "high",
582
+ confidence: "impossible"
583
+ });
584
+ }
585
+ function assessAutopilot(operations) {
586
+ const allowedOperationIds = [];
587
+ const approvalRequiredOperationIds = [];
588
+ const forbiddenOperationIds = [];
589
+ const reasons = [];
590
+ for (const op of operations) {
591
+ if (op.status === "blocked" || op.status === "failed") {
592
+ forbiddenOperationIds.push(op.id);
593
+ reasons.push(`${op.id}: ${op.status} operation prevents autopilot apply.`);
594
+ continue;
595
+ }
596
+ if (op.status !== "planned")
597
+ continue;
598
+ if (op.kind === "project.mkdir") {
599
+ allowedOperationIds.push(op.id);
600
+ continue;
601
+ }
602
+ if (op.command?.[0] === "sh" && op.command?.[1] === "-lc") {
603
+ forbiddenOperationIds.push(op.id);
604
+ reasons.push(`${op.id}: shell command replay is forbidden for autopilot.`);
605
+ continue;
606
+ }
607
+ if (op.kind.startsWith("tmux.") || op.kind === "app.open" || op.kind === "process.restart") {
608
+ approvalRequiredOperationIds.push(op.id);
609
+ reasons.push(`${op.id}: ${op.kind} requires human approval.`);
610
+ continue;
611
+ }
612
+ approvalRequiredOperationIds.push(op.id);
613
+ reasons.push(`${op.id}: operation kind is not autopilot-allowlisted.`);
614
+ }
615
+ return {
616
+ safeToApply: approvalRequiredOperationIds.length === 0 && forbiddenOperationIds.length === 0,
617
+ allowedOperationIds,
618
+ approvalRequiredOperationIds,
619
+ forbiddenOperationIds,
620
+ reasons
621
+ };
622
+ }
623
+ function hashRestorePlan(plan) {
624
+ return sha256(stableJson({
625
+ snapshotId: plan.snapshotId,
626
+ request: {
627
+ include: plan.request?.include ?? [],
628
+ exclude: plan.request?.exclude ?? [],
629
+ dependencyMode: plan.request?.dependencyMode ?? "none",
630
+ targetMode: plan.request?.targetMode ?? "strict",
631
+ tmuxMode: plan.request?.tmuxMode ?? "layout-only",
632
+ applyPlanId: plan.request?.applyPlanId ?? null,
633
+ planHash: plan.request?.planHash ?? null
634
+ },
635
+ operations: plan.operations.map((op) => ({
636
+ id: op.id,
637
+ kind: op.kind,
638
+ resourceId: op.resourceId,
639
+ resourceKind: op.resourceKind,
640
+ status: op.status,
641
+ command: op.command ?? [],
642
+ reason: op.reason ?? null,
643
+ resourceHash: op.resource?.hash ?? null
644
+ }))
645
+ }));
646
+ }
647
+ export function prepareRestorePlanForExecution(plan) {
648
+ const operations = plan.operations.map((op) => op.status === "blocked" && op.reason === "Restore execution requires --apply --yes."
649
+ ? { ...op, status: "planned", reason: undefined }
650
+ : op);
651
+ return {
652
+ ...plan,
653
+ apply: false,
654
+ operations,
655
+ summary: summarizeOperations(operations),
656
+ autopilot: assessAutopilot(operations)
657
+ };
658
+ }
659
+ function uniqueStrings(values) {
660
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
661
+ }
313
662
  function summarizeOperations(operations) {
314
663
  const summary = {
315
664
  planned: 0,
@@ -328,6 +677,7 @@ function sortOperations(operations) {
328
677
  const priority = {
329
678
  "project.mkdir": 10,
330
679
  "tmux.create-session": 20,
680
+ "tmux.move-window": 25,
331
681
  "tmux.create-window": 30,
332
682
  "tmux.create-pane": 35,
333
683
  "tmux.select-layout": 36,
@@ -336,11 +686,26 @@ function sortOperations(operations) {
336
686
  "process.restart": 40,
337
687
  "app.open": 50
338
688
  };
339
- return [...operations].sort((a, b) => (priority[a.kind] ?? 100) - (priority[b.kind] ?? 100) || a.id.localeCompare(b.id));
689
+ return [...operations].sort((a, b) => (priority[a.kind] ?? 100) - (priority[b.kind] ?? 100)
690
+ || operationOrderKey(a).localeCompare(operationOrderKey(b), undefined, { numeric: true }));
340
691
  }
341
- function tmuxWindowExists(session, name) {
342
- const result = runTmux(["list-windows", "-t", session, "-F", "#{window_name}"], 2_000);
343
- return result.status === 0 && result.stdout.split("\n").map((line) => line.trim()).includes(name);
692
+ function operationOrderKey(op) {
693
+ const resource = op.resource;
694
+ const session = typeof resource?.attributes.session === "string" ? resource.attributes.session : resource?.name ?? "";
695
+ const windowIndex = Number(resource?.attributes.index ?? resource?.attributes.window_index ?? 0);
696
+ const paneIndex = Number(resource?.attributes.pane_index ?? 0);
697
+ return `${session}:${Number.isFinite(windowIndex) ? windowIndex : 0}:${Number.isFinite(paneIndex) ? paneIndex : 0}:${op.id}`;
698
+ }
699
+ function tmuxWindowExists(session, name, index) {
700
+ const result = runTmux(["list-windows", "-t", session, "-F", "#{window_index}\t#{window_name}"], 2_000);
701
+ if (result.status !== 0)
702
+ return false;
703
+ return result.stdout.split("\n").some((line) => {
704
+ const [windowIndex, windowName] = line.trim().split("\t");
705
+ if (typeof index === "number")
706
+ return Number(windowIndex) === index;
707
+ return windowName === name;
708
+ });
344
709
  }
345
710
  function firstPaneIndexForWindow(resource, resources) {
346
711
  const session = resource.attributes.session;