@sentropic/h2a-runtime 0.92.0 → 0.93.1

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.
Files changed (67) hide show
  1. package/dist/conv-guard.d.ts +6 -1
  2. package/dist/conv-guard.d.ts.map +1 -1
  3. package/dist/conv-guard.js +33 -8
  4. package/dist/conv-guard.js.map +1 -1
  5. package/dist/enroll.d.ts.map +1 -1
  6. package/dist/enroll.js +12 -2
  7. package/dist/enroll.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +743 -97
  11. package/dist/index.js.map +1 -1
  12. package/dist/launch-layout.d.ts +74 -0
  13. package/dist/launch-layout.d.ts.map +1 -0
  14. package/dist/launch-layout.js +324 -0
  15. package/dist/launch-layout.js.map +1 -0
  16. package/dist/llm-mesh.d.ts +13 -7
  17. package/dist/llm-mesh.d.ts.map +1 -1
  18. package/dist/llm-mesh.js +6 -4
  19. package/dist/llm-mesh.js.map +1 -1
  20. package/dist/native-host.d.ts +146 -0
  21. package/dist/native-host.d.ts.map +1 -0
  22. package/dist/native-host.js +456 -0
  23. package/dist/native-host.js.map +1 -0
  24. package/dist/native-terminal/host.d.ts +317 -1
  25. package/dist/native-terminal/host.d.ts.map +1 -1
  26. package/dist/native-terminal/host.js +696 -3
  27. package/dist/native-terminal/host.js.map +1 -1
  28. package/dist/native-terminal/op.d.ts +5 -0
  29. package/dist/native-terminal/op.d.ts.map +1 -0
  30. package/dist/native-terminal/op.js +408 -0
  31. package/dist/native-terminal/op.js.map +1 -0
  32. package/dist/native-terminal/process.d.ts +2 -0
  33. package/dist/native-terminal/process.d.ts.map +1 -1
  34. package/dist/native-terminal/process.js +16 -2
  35. package/dist/native-terminal/process.js.map +1 -1
  36. package/dist/native-terminal/server.d.ts.map +1 -1
  37. package/dist/native-terminal/server.js +6 -2
  38. package/dist/native-terminal/server.js.map +1 -1
  39. package/dist/native-terminal/socket-path.d.ts +8 -0
  40. package/dist/native-terminal/socket-path.d.ts.map +1 -1
  41. package/dist/native-terminal/socket-path.js +31 -1
  42. package/dist/native-terminal/socket-path.js.map +1 -1
  43. package/dist/native-terminal/supervisor.d.ts +13 -0
  44. package/dist/native-terminal/supervisor.d.ts.map +1 -1
  45. package/dist/native-terminal/supervisor.js +73 -1
  46. package/dist/native-terminal/supervisor.js.map +1 -1
  47. package/dist/pty.d.ts +23 -0
  48. package/dist/pty.d.ts.map +1 -1
  49. package/dist/pty.js +39 -2
  50. package/dist/pty.js.map +1 -1
  51. package/dist/readiness.d.ts +7 -0
  52. package/dist/readiness.d.ts.map +1 -1
  53. package/dist/readiness.js +12 -9
  54. package/dist/readiness.js.map +1 -1
  55. package/dist/registry.d.ts +317 -2
  56. package/dist/registry.d.ts.map +1 -1
  57. package/dist/registry.js +622 -19
  58. package/dist/registry.js.map +1 -1
  59. package/dist/restore.d.ts +91 -32
  60. package/dist/restore.d.ts.map +1 -1
  61. package/dist/restore.js +226 -186
  62. package/dist/restore.js.map +1 -1
  63. package/dist/tmux.d.ts +2 -1
  64. package/dist/tmux.d.ts.map +1 -1
  65. package/dist/tmux.js +1 -1
  66. package/dist/tmux.js.map +1 -1
  67. package/package.json +2 -2
package/dist/restore.js CHANGED
@@ -6,14 +6,18 @@
6
6
  * terminal launch), so `~/bin/resume-dev-sessions` is just `exec remote
7
7
  * restore`. SCW sessions and persisted positions come later.
8
8
  */
9
- import { spawn } from "node:child_process";
10
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
11
10
  import { homedir } from "node:os";
12
11
  import { dirname, join } from "node:path";
12
+ import { launchLayout, tabCommand, } from "./launch-layout.js";
13
13
  import { getLayoutConfig, resolveConfigPath, } from "./config.js";
14
14
  import { encodeCwd } from "./convsync.js";
15
- import { loadRegistry, looksLikeConversationUuid, persistReconciledConvIds, } from "./registry.js";
16
- import { listLocalSessions, listLocalSessionsWithDiagnostics, slugify, } from "./tmux.js";
15
+ import { isManagedLocalKind, loadRegistry, looksLikeConversationUuid, persistReconciledConvIds, resolveManagedHost, } from "./registry.js";
16
+ import { listLocalSessions, listLocalSessionsWithDiagnostics, localSessionName, managedSessionCandidates, slugify, } from "./tmux.js";
17
+ import { listNativeSessions } from "./native-host.js";
18
+ // Compatibility re-exports: the layout renderer lives in launch-layout.ts —
19
+ // a module WITHOUT reader access (F3) — but callers keep importing it here.
20
+ export { ambiguousLiveSessionNames, launchLayout, tabCommand, } from "./launch-layout.js";
17
21
  /** Project identity is the whole workspace path below ~/src, never its root. */
18
22
  function projectForCwd(src, cwd) {
19
23
  const prefix = `${src}/`;
@@ -251,7 +255,7 @@ export function reconcileRunConvIds(entries, customTitleFor, evidence = legacySe
251
255
  const unresolvedRunIds = new Set();
252
256
  const claimedConvIds = new Set();
253
257
  for (const r of entries) {
254
- if (r.kind !== "local-tmux" || r.source !== "run")
258
+ if (!isManagedLocalKind(r.kind) || r.source !== "run")
255
259
  continue;
256
260
  // Delegated jobs / background launches are never restored as human tabs, so
257
261
  // don't reconcile (and don't emit a spurious skip note) for them.
@@ -294,6 +298,79 @@ export function reconcileRunConvIds(entries, customTitleFor, evidence = legacySe
294
298
  }
295
299
  return { resolvedSid, unresolvedRunIds, claimedConvIds };
296
300
  }
301
+ /**
302
+ * THE one host-inventory read of a restore: BOTH inventories captured once,
303
+ * so the plan gate AND the layout render (launchLayout, which has no reader
304
+ * of its own — F3) see the SAME probe state. A failed native inventory read
305
+ * is carried as `known: false` — an UNKNOWN view, never an empty one.
306
+ */
307
+ export function captureHostViewSnapshot() {
308
+ const tmux = listLocalSessionsWithDiagnostics();
309
+ const native = (() => {
310
+ try {
311
+ return {
312
+ known: true,
313
+ sessions: listNativeSessions()
314
+ .filter((session) => session.status === "running")
315
+ .map((session) => ({
316
+ name: session.id,
317
+ controlled: session.controlled,
318
+ })),
319
+ };
320
+ }
321
+ catch {
322
+ return { known: false };
323
+ }
324
+ })();
325
+ return {
326
+ tmux: tmux.known
327
+ ? { known: true, sessions: tmux.sessions }
328
+ : { known: false, ...(tmux.reason !== undefined ? { reason: tmux.reason } : {}) },
329
+ native,
330
+ };
331
+ }
332
+ /**
333
+ * Adapt the ONE `HostViewSnapshot` to the plan's ManagedLiveLookup probe
334
+ * surface: every plan decision of one restore reads the same batch snapshot.
335
+ * A `known: false` inventory yields "unknown" for that host's sessions —
336
+ * restore must fail closed on it, never treat it as death.
337
+ */
338
+ export function managedLiveLookupFromSnapshot(view) {
339
+ return (kind, names) => {
340
+ if (kind === "local-native") {
341
+ if (!view.native.known)
342
+ return { state: "unknown" };
343
+ const running = view.native.sessions;
344
+ const hit = names.find((name) => running.some((session) => session.name === name));
345
+ return hit !== undefined ? { state: "live", name: hit } : { state: "dead" };
346
+ }
347
+ if (!view.tmux.known)
348
+ return { state: "unknown" };
349
+ const sessions = view.tmux.sessions;
350
+ const hit = names.find((name) => sessions.some((session) => session.name === name));
351
+ return hit !== undefined ? { state: "live", name: hit } : { state: "dead" };
352
+ };
353
+ }
354
+ /**
355
+ * Adapt the plan's ONE `ManagedLiveLookup` snapshot to `resolveManagedHost`'s
356
+ * injected probe surface, so every per-session host decision of a restore
357
+ * reads the SAME batch snapshot — the resolver is consulted per session but
358
+ * never re-probes a host per session (no TOCTOU across the batch). A plan
359
+ * built WITHOUT a snapshot (legacy callers) must never probe live hosts from
360
+ * inside discovery either: its probes answer "unknown" (fail closed), never
361
+ * a real probe.
362
+ */
363
+ function managedHostProbes(liveView) {
364
+ const probe = (kind) => (name) => {
365
+ if (!liveView)
366
+ return "unknown";
367
+ const r = liveView(kind, [name]);
368
+ if (r.state === "live")
369
+ return "live";
370
+ return r.state === "dead" ? "dead" : "unknown";
371
+ };
372
+ return { native: probe("local-native"), tmux: probe("local-tmux") };
373
+ }
297
374
  /**
298
375
  * REGISTRY-FIRST discovery: durable registry entries (local kinds) mapped to
299
376
  * discovered sessions. label/cwd/convId come straight from enrolment, no
@@ -304,10 +381,33 @@ export function reconcileRunConvIds(entries, customTitleFor, evidence = legacySe
304
381
  * resolvable conversation (so restore never emits `--resume <label>`), and
305
382
  * dedups the `hook` twin of a conversation already represented by a run session.
306
383
  * Omitted (the plain 2-arg call) → legacy behaviour, one session per entry.
384
+ *
385
+ * `liveView` (one host-probe snapshot) upgrades the unresolved-convId gate to
386
+ * the §4.1 state machine: a LIVE managed row is never lost to a broken
387
+ * conversation id — it becomes an ATTACH-LIVE session keyed on its persisted
388
+ * kind + exact managed name. Without it (legacy callers), every unresolved
389
+ * row is skipped as before.
390
+ *
391
+ * `unreadable` (PART A/A2) is `loadRegistry()`'s raw rows that FAILED
392
+ * validation, from the SAME snapshot `entries` was drawn from. Passed through
393
+ * to `resolveManagedHost` so a valid row + an unreadable TWIN of the same
394
+ * identity poisons that ONE identity's host resolution (never emitted as an
395
+ * attach/relaunch target) instead of silently trusting the valid row alone.
396
+ * Defaults to `[]` so every existing plain-array test caller is unaffected.
307
397
  */
308
- export function registrySessions(home = homedir(), entries = loadRegistry(), resolution, evidence = legacySessionEvidence(home)) {
398
+ export function registrySessions(home = homedir(),
399
+ // No default read: restore() owns the ONE registry read and refuses on an
400
+ // unknown read state before any plan is built (F2/F3).
401
+ entries, resolution, evidence = legacySessionEvidence(home), liveView, unreadable = []) {
309
402
  const src = join(home, "src");
310
403
  const out = [];
404
+ // ONE probe surface for every host decision of this plan, adapted from the
405
+ // single batch snapshot (or fail-closed "unknown" probes without one).
406
+ const probes = managedHostProbes(liveView);
407
+ // The registry READ RESULT `resolveManagedHost` consults below (A1/A2):
408
+ // the SAME `entries` this loop already iterates, plus the unreadable rows
409
+ // from that identical read, so a per-identity poison check sees them.
410
+ const registryReadForResolve = { state: "ok", entries, unreadable };
311
411
  for (const e of entries) {
312
412
  if (e.kind === "remote")
313
413
  continue; // remote groups are filled from SCW
@@ -317,13 +417,24 @@ export function registrySessions(home = homedir(), entries = loadRegistry(), res
317
417
  continue;
318
418
  if (!e.cwd.startsWith(`${src}/`))
319
419
  continue;
320
- // A run session whose conversation id could not be resolved is skipped here
321
- // (restore() emits the attach-hint note) — never a broken `--resume <label>`.
420
+ // §4.1 unresolved-convId gate: skip ONLY when the row is dead/absent (or
421
+ // its host state is unprovable — an unknown probe is no death
422
+ // certificate, but with no identity there is nothing safe to launch
423
+ // either; restore() emits the attach-hint note). A LIVE managed row is
424
+ // NEVER lost to a broken conversation id: the process exists and is
425
+ // re-enterable by its persisted kind + exact managed name alone.
426
+ let attachLiveName;
322
427
  if (resolution &&
323
- e.kind === "local-tmux" &&
428
+ isManagedLocalKind(e.kind) &&
324
429
  e.source === "run" &&
325
430
  resolution.unresolvedRunIds.has(e.id)) {
326
- continue;
431
+ const candidates = e.tmuxSession
432
+ ? [e.tmuxSession]
433
+ : managedSessionCandidates(e.id);
434
+ const live = liveView?.(e.kind, candidates);
435
+ if (live === undefined || live.state !== "live")
436
+ continue;
437
+ attachLiveName = live.name;
327
438
  }
328
439
  // A hook conversation already owned by a run session (same real convId, but
329
440
  // the run entry carries the label + tmux name) must not ALSO appear as an
@@ -339,7 +450,11 @@ export function registrySessions(home = homedir(), entries = loadRegistry(), res
339
450
  if (!project)
340
451
  continue;
341
452
  const seen = Date.parse(e.lastSeenAt);
342
- const sid = resolution?.resolvedSid.get(e.id) ?? e.convId ?? "";
453
+ // An attach-live row has NO resolved conversation: its sid stays empty so
454
+ // no path can ever render a broken `--resume <label>` from it.
455
+ const sid = attachLiveName !== undefined
456
+ ? ""
457
+ : (resolution?.resolvedSid.get(e.id) ?? e.convId ?? "");
343
458
  const session = {
344
459
  project,
345
460
  mtimeMs: Number.isFinite(seen) ? seen : Date.now(),
@@ -347,12 +462,51 @@ export function registrySessions(home = homedir(), entries = loadRegistry(), res
347
462
  sid,
348
463
  cwd: e.cwd,
349
464
  origin: "registry",
465
+ registryId: e.id,
350
466
  restoreClass: "human",
351
467
  };
352
468
  if (e.label !== undefined)
353
469
  session.label = e.label;
354
470
  if (e.gatewayMode !== undefined)
355
471
  session.gatewayMode = e.gatewayMode;
472
+ // WHICH host serves this tab is decided by the ONE symmetric resolver
473
+ // (resolveManagedHost) over this plan's registry read, with the plan's
474
+ // single probe snapshot injected — restore never re-implements the
475
+ // host-choice rules (F1/F2) and never re-probes a host per session. For
476
+ // a current managed row the resolver lands on its persisted kind + exact
477
+ // managed name (probes only ever run for an identity with no matching
478
+ // row). A `kind:"local"` hook entry has no terminal host.
479
+ if (isManagedLocalKind(e.kind)) {
480
+ const resolution = resolveManagedHost(e.tmuxSession ?? localSessionName(e.id),
481
+ // The WHOLE read (A2), not the bare `entries` array: an unreadable
482
+ // twin of this identity must poison the resolution — no tab emitted,
483
+ // no relaunch/attach target derived from the valid row alone.
484
+ registryReadForResolve, probes);
485
+ if (resolution.state === "unknown") {
486
+ // Fail closed (resolver rule 4): an unattributable host is no
487
+ // license to guess one via the slug-union fallback — emit no tab.
488
+ continue;
489
+ }
490
+ if (resolution.state === "recorded" ||
491
+ resolution.state === "discovered") {
492
+ session.hostKind = resolution.kind;
493
+ session.managedName = attachLiveName ?? resolution.name;
494
+ }
495
+ else if (resolution.state === "ambiguous") {
496
+ // Rule 3 verdict, stamped for the plan filter
497
+ // (dropDualHostIdentities); kind + name are kept for the skip
498
+ // report only — no act may serve this tab on either host.
499
+ session.hostAmbiguous = true;
500
+ session.hostKind = e.kind;
501
+ session.managedName =
502
+ attachLiveName ?? e.tmuxSession ?? localSessionName(e.id);
503
+ }
504
+ // "missing": an identity the resolver pins to NO host is emitted
505
+ // unhosted — a relaunch follows the product default host, exactly as
506
+ // the act path treats a missing identity (a brand-new session).
507
+ }
508
+ if (attachLiveName !== undefined)
509
+ session.attachLive = true;
356
510
  out.push(session);
357
511
  }
358
512
  return out;
@@ -413,6 +567,28 @@ export function dropRemoteBackedLocals(locals, remoteTabs) {
413
567
  }
414
568
  return { kept, dropped };
415
569
  }
570
+ /**
571
+ * Dual-host identity fails CLOSED (resolver rule 3): sessions of BOTH managed
572
+ * kinds for one exact managed name cannot be attributed to a host — neither
573
+ * an attach nor a replacement may pick one by luck. The verdict is NOT
574
+ * decided here: it was stamped per session at plan time from
575
+ * `resolveManagedHost` returning `ambiguous` (`hostAmbiguous`); this filter
576
+ * only acts on that stamp and never counts kinds itself, so the dual-host
577
+ * rule lives in exactly ONE place. Pure; the caller reports the ambiguous
578
+ * names.
579
+ */
580
+ export function dropDualHostIdentities(sessions) {
581
+ const ambiguous = new Set();
582
+ for (const s of sessions) {
583
+ if (s.hostAmbiguous === true && s.managedName !== undefined) {
584
+ ambiguous.add(s.managedName);
585
+ }
586
+ }
587
+ return {
588
+ kept: sessions.filter((s) => s.hostAmbiguous !== true),
589
+ ambiguousNames: [...ambiguous].sort(),
590
+ };
591
+ }
416
592
  function safeStat(p) {
417
593
  try {
418
594
  return statSync(p);
@@ -492,6 +668,12 @@ export function groupSessions(sessions, cfg) {
492
668
  tab.origin = s.origin;
493
669
  if (s.gatewayMode !== undefined)
494
670
  tab.gatewayMode = s.gatewayMode;
671
+ if (s.hostKind !== undefined)
672
+ tab.hostKind = s.hostKind;
673
+ if (s.managedName !== undefined)
674
+ tab.managedName = s.managedName;
675
+ if (s.attachLive !== undefined)
676
+ tab.attachLive = s.attachLive;
495
677
  return tab;
496
678
  });
497
679
  };
@@ -539,174 +721,6 @@ export function groupSessions(sessions, cfg) {
539
721
  function projLatest(arr) {
540
722
  return arr.reduce((m, s) => Math.max(m, s.mtimeMs), 0);
541
723
  }
542
- /**
543
- * Per-tab command for an ABSENT local session: create it via `remote run …
544
- * --resume …` (which attaches by default). Live sessions are deliberately
545
- * handled by `launchLayout`, which knows their exact tmux names and whether they
546
- * have a client attached; it emits `tmux attach -t <exact-name>` and never
547
- * relaunches them.
548
- */
549
- export function tabCommand(tab, liveSlugs = new Set(), opts = {}) {
550
- const q = (s) => `'${s.replace(/'/g, "'\\''")}'`;
551
- if (tab.remoteId) {
552
- // SCW: attach straight into the Pod's tmux (live, copy-friendly).
553
- return `h2a attach ${q(tab.remoteId)} --exec`;
554
- }
555
- const slug = slugify(tab.label);
556
- // Effective gateway posture: an explicit `restore --gw/--no-gw` OVERRIDES the
557
- // per-instance pin; otherwise honour the pinned gatewayMode (absent = default).
558
- const posture = opts.forceGateway ?? tab.gatewayMode;
559
- const gwFlag = posture === "gateway" ? " --gw" : posture === "direct" ? " --no-gw" : "";
560
- const runCmd = (extra) => `h2a run ${q(tab.tool ?? "shell")} ${q(tab.cwd)} ` +
561
- (tab.sid ? `--resume ${q(tab.sid)} ` : "") +
562
- `--name ${q(tab.label)}${gwFlag}${extra}`;
563
- if (opts.attachSession) {
564
- return `tmux attach -t ${q(opts.attachSession)}`;
565
- }
566
- if (liveSlugs.has(slug)) {
567
- // Compatibility for direct callers that only know a unique slug. Restore
568
- // itself always supplies the exact name above, avoiding prefix collisions.
569
- // Crucially, a gateway override never turns a live session into --replace:
570
- // restore must preserve its process and conversation.
571
- return `h2a attach ${q(slug)}`;
572
- }
573
- return runCmd("");
574
- }
575
- /** Group collision candidates before restore emits bare-slug attach/resume commands. */
576
- export function ambiguousLiveSessionNames(sessions) {
577
- const namesBySlug = new Map();
578
- for (const session of sessions) {
579
- const names = namesBySlug.get(session.slug) ?? [];
580
- names.push(session.name);
581
- namesBySlug.set(session.slug, names);
582
- }
583
- return new Map([...namesBySlug].filter(([, names]) => names.length > 1));
584
- }
585
- // gnome-terminal applies ONE trailing `-- command` to EVERY tab of an
586
- // invocation (you cannot give each tab its own `--`). So all tabs run the same
587
- // dispatcher; each claims (under flock) the first map line matching its $PWD
588
- // and runs that tab's command — exactly how ~/bin/resume-dev-sessions worked.
589
- const DISPATCHER = `map="$1"
590
- lock="$map.lock"
591
- exec 9>"$lock"; flock 9
592
- line=$(awk -F'\\t' -v c="$PWD" '$1==c{print;exit}' "$map")
593
- if [ -n "$line" ]; then
594
- awk -F'\\t' -v c="$PWD" 'BEGIN{d=0} d==0 && $1==c {d=1; next} {print}' "$map" > "$map.tmp" && mv "$map.tmp" "$map"
595
- fi
596
- flock -u 9
597
- cmd=$(printf '%s' "$line" | cut -f2-)
598
- if [ -n "$cmd" ]; then eval "$cmd"; else echo "[h2a] rien a reprendre pour $PWD" >&2; fi
599
- exec bash -l`;
600
- let mapCounter = 0;
601
- function runDir() {
602
- const base = process.env.XDG_RUNTIME_DIR
603
- ? join(process.env.XDG_RUNTIME_DIR, "sentropic-remote")
604
- : join(homedir(), ".config", "sentropic", "remote-cli", "run");
605
- mkdirSync(base, { recursive: true });
606
- return base;
607
- }
608
- /**
609
- * Launch the layout in gnome-terminal: one window per group, one tab per session.
610
- *
611
- * Default behaviour: a live session with a tmux client is already visible and is
612
- * skipped; a live session with zero clients is orphaned, so a terminal tab is
613
- * opened directly onto that exact tmux session. An absent session is launched as
614
- * before. `--reattach` explicitly opens another tab even for an already-visible
615
- * session. This is deliberately a view decision, not a liveness decision.
616
- */
617
- export function launchLayout(windows, stderr = process.stderr, opts = {}) {
618
- const localSessions = listLocalSessionsWithDiagnostics();
619
- const liveSessions = localSessions.sessions;
620
- const ambiguousLiveNames = ambiguousLiveSessionNames(liveSessions);
621
- const skippedLive = [];
622
- let opened = 0;
623
- // A gateway override can configure a NEW process but must never replace a live
624
- // one. `--reattach` is the sole opt-in that duplicates an already-visible tab.
625
- const includeAttached = opts.reattach === true;
626
- const tabOpts = opts.forceGateway ? { forceGateway: opts.forceGateway } : {};
627
- if (!localSessions.known) {
628
- // We cannot reliably tell "attached" from "orphaned". Prefer a harmless
629
- // duplicate/failing attach over `h2a run` possibly racing a live tmux
630
- // session; a restore command must never reclaim a view by killing its work.
631
- stderr.write(`[h2a] tmux view state unavailable${localSessions.reason ? ` (${localSessions.reason})` : ""}; ` +
632
- "opening attach tabs rather than relaunching local sessions\n");
633
- }
634
- for (const win of windows) {
635
- // Remote (k8s) tabs are always included — we can't probe pod health here.
636
- // Local tabs carry their finalized command because a live orphan must attach
637
- // to its exact tmux name, while an absent session must be launched.
638
- const activeTabs = [];
639
- for (const t of win.tabs) {
640
- if (t.remoteId) {
641
- activeTabs.push({ tab: t, command: tabCommand(t) });
642
- continue;
643
- }
644
- const slug = slugify(t.label);
645
- if (!localSessions.known) {
646
- activeTabs.push({
647
- tab: t,
648
- command: tabCommand(t, new Set([slug]), { attachSession: `h2a-${slug}` }),
649
- });
650
- continue;
651
- }
652
- const names = ambiguousLiveNames.get(slug);
653
- if (names) {
654
- skippedLive.push(t.label);
655
- stderr.write(`[h2a] restore skipped "${t.label}": local tmux slug is ambiguous (${names.sort().join(", ")}); ` +
656
- `attach explicitly with h2a attach ${names.sort()[0]} or h2a attach ${names.sort()[1]}\n`);
657
- continue;
658
- }
659
- const liveSession = liveSessions.find((session) => session.slug === slug);
660
- if (liveSession && !includeAttached && liveSession.attached) {
661
- skippedLive.push(t.label);
662
- continue;
663
- }
664
- activeTabs.push({
665
- tab: t,
666
- command: liveSession
667
- ? tabCommand(t, new Set([slug]), { attachSession: liveSession.name })
668
- : tabCommand(t, new Set(), tabOpts),
669
- });
670
- }
671
- if (activeTabs.length === 0) {
672
- // All sessions in this window are already active — no tab needed.
673
- continue;
674
- }
675
- // Map keyed by per-tab working directory -> the tab's command. Tabs sharing
676
- // a cwd (several sessions of one project) each claim a distinct line FIFO.
677
- const slug = win.title.replace(/[^a-zA-Z0-9]+/g, "-");
678
- const mapPath = join(runDir(), `restore-${process.pid}-${slug}-${mapCounter++}.map`);
679
- const body = activeTabs
680
- .map(({ tab, command }) => `${tab.cwd}\t${command}`)
681
- .join("\n") + "\n";
682
- writeFileSync(mapPath, body, "utf8");
683
- const args = [];
684
- activeTabs.forEach(({ tab }, i) => {
685
- args.push(i === 0 ? "--window" : "--tab", `--working-directory=${tab.cwd}`, `--title=${tab.label}`);
686
- });
687
- // ONE shared dispatcher command for all tabs of this window.
688
- args.push("--", "bash", "-lc", DISPATCHER, "remote-restore", mapPath);
689
- stderr.write(`[h2a] fenêtre "${win.title}" (${activeTabs.length} onglet(s))\n`);
690
- // Surface gnome-terminal errors (e.g. "Failed to get screen…") instead of
691
- // silently claiming the window opened.
692
- const { GNOME_TERMINAL_SCREEN: _drop, ...childEnv } = process.env;
693
- const child = spawn("gnome-terminal", args, {
694
- stdio: ["ignore", "ignore", "pipe"],
695
- detached: true,
696
- env: childEnv,
697
- });
698
- child.stderr?.on("data", (chunk) => {
699
- stderr.write(`[h2a] gnome-terminal: ${chunk.toString().trim()}\n`);
700
- });
701
- child.unref();
702
- opened += activeTabs.length;
703
- }
704
- if (skippedLive.length > 0) {
705
- stderr.write(`[h2a] ${skippedLive.length} session(s) déjà visibles ignorées` +
706
- ` (--reattach pour les rouvrir quand même): ${skippedLive.join(", ")}\n`);
707
- }
708
- return { opened, skippedLive };
709
- }
710
724
  function titleMatches(title, query) {
711
725
  const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
712
726
  return norm(title) === norm(query);
@@ -726,7 +740,16 @@ export function restore(opts = {}) {
726
740
  // convId is joined to the `hook` conversation carrying the real uuid, so it
727
741
  // resumes correctly (and each named session of a repo keeps its own uuid).
728
742
  const home = homedir();
729
- const registryEntries = loadRegistry();
743
+ const registryRead = loadRegistry();
744
+ if (registryRead.state === "unknown") {
745
+ // Restore LAUNCHES sessions: with an unreadable registry the plan cannot
746
+ // dedup or host-attribute anything — a launch here could fabricate a
747
+ // second writer over a live-but-unprovable session. Fail closed: no
748
+ // windows, nothing launched (F2/F3).
749
+ stderr.write(`[h2a] restore refused: the local registry is unreadable (${registryRead.reason}); nothing was launched (fail closed).\n`);
750
+ return { windows: [], total: 0, dropped: 0 };
751
+ }
752
+ const registryEntries = registryRead.entries;
730
753
  const evidence = legacySessionEvidence(home);
731
754
  const resolution = reconcileRunConvIds(registryEntries, (cwd, convId) => readConversationCustomTitle(home, cwd, convId), evidence);
732
755
  // Persist the resolved conversation ids back onto the run entries so the state
@@ -737,7 +760,7 @@ export function restore(opts = {}) {
737
760
  const updates = new Map();
738
761
  for (const [id, sid] of resolution.resolvedSid) {
739
762
  const e = registryEntries.find((x) => x.id === id);
740
- if (e && e.kind === "local-tmux" && e.source === "run" && e.convId !== sid) {
763
+ if (e && isManagedLocalKind(e.kind) && e.source === "run" && e.convId !== sid) {
741
764
  updates.set(id, sid);
742
765
  }
743
766
  }
@@ -747,9 +770,23 @@ export function restore(opts = {}) {
747
770
  // best-effort: a persistence hiccup must never break restore
748
771
  }
749
772
  }
750
- // Never emit a broken `--resume <label>`: note the un-resumable named sessions
751
- // with an explicit attach hint instead of silently dropping (or mis-running) them.
773
+ const scanned = discoverSessions(cfg.maxAgeHours * 3600 * 1000);
774
+ // ONE host-inventory snapshot for the whole restore (§4.1 / F3): the
775
+ // attach-live gate, the skip notes below AND the layout render consume
776
+ // this same capture — launchLayout has no reader of its own to diverge on.
777
+ const hostView = captureHostViewSnapshot();
778
+ const liveView = managedLiveLookupFromSnapshot(hostView);
779
+ const allDiscovered = mergeDiscovered(registrySessions(home, registryEntries, resolution, evidence, liveView,
780
+ // PART A/A2: the unreadable rows from this SAME read, so a valid row +
781
+ // an unreadable twin poisons that one identity's host resolution.
782
+ registryRead.unreadable), scanned);
783
+ // Never emit a broken `--resume <label>`. A LIVE unresolved row was kept by
784
+ // the §4.1 gate as an attach-live tab; only the dead/unprovable remainder
785
+ // is skipped, with an explicit attach hint instead of a silent drop.
786
+ const attachLiveIds = new Set(allDiscovered.flatMap((s) => s.attachLive === true && s.registryId !== undefined ? [s.registryId] : []));
752
787
  for (const id of resolution.unresolvedRunIds) {
788
+ if (attachLiveIds.has(id))
789
+ continue;
753
790
  const e = registryEntries.find((x) => x.id === id);
754
791
  if (!e)
755
792
  continue;
@@ -758,11 +795,14 @@ export function restore(opts = {}) {
758
795
  `(convId ${e.convId ? `"${e.convId}"` : "missing"} is not a real conversation); ` +
759
796
  `attach it live with h2a attach ${slugify(label)}\n`);
760
797
  }
761
- const scanned = discoverSessions(cfg.maxAgeHours * 3600 * 1000);
762
- const allDiscovered = mergeDiscovered(registrySessions(home, registryEntries, resolution, evidence), scanned);
763
798
  // Transcript scans lack a durable human/job marker. They are deliberately
764
799
  // represented as `unclassified` and filtered here, before grouping/capping.
765
- const allLocal = allDiscovered.filter(isRestorableDiscoveredSession);
800
+ const preDualLocal = allDiscovered.filter(isRestorableDiscoveredSession);
801
+ const { kept: allLocal, ambiguousNames } = dropDualHostIdentities(preDualLocal);
802
+ for (const name of ambiguousNames) {
803
+ stderr.write(`[h2a] restore skipped "${name}": recorded on BOTH local hosts (local-tmux AND local-native); ` +
804
+ "clean up the conflicting registry rows before restoring it\n");
805
+ }
766
806
  // Bug #3: a session moved to a remote Pod must NOT also be re-launched as a
767
807
  // ghost LOCAL tmux. Drop locals already covered by a remote tab.
768
808
  const { kept: sessions, dropped: remoteBacked } = dropRemoteBackedLocals(allLocal, remoteTabs);
@@ -811,7 +851,7 @@ export function restore(opts = {}) {
811
851
  if (dropped > 0 && !opts.group)
812
852
  stderr.write(` (! ${dropped} session(s) ignorée(s), plafond atteint)\n`);
813
853
  if (!opts.dryRun && total > 0) {
814
- launchLayout(windows, stderr, {
854
+ launchLayout(windows, hostView, stderr, {
815
855
  ...(opts.reattach ? { reattach: true } : {}),
816
856
  ...(opts.forceGateway ? { forceGateway: opts.forceGateway } : {}),
817
857
  });