@sentropic/h2a-runtime 0.91.1 → 0.93.0
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/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -3
- package/dist/config.js.map +1 -1
- package/dist/conv-guard.d.ts +6 -1
- package/dist/conv-guard.d.ts.map +1 -1
- package/dist/conv-guard.js +33 -8
- package/dist/conv-guard.js.map +1 -1
- package/dist/enroll.d.ts.map +1 -1
- package/dist/enroll.js +12 -2
- package/dist/enroll.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +819 -155
- package/dist/index.js.map +1 -1
- package/dist/launch-layout.d.ts +74 -0
- package/dist/launch-layout.d.ts.map +1 -0
- package/dist/launch-layout.js +324 -0
- package/dist/launch-layout.js.map +1 -0
- package/dist/llm-gateway-runtime/index.d.ts +6 -0
- package/dist/llm-gateway-runtime/index.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/index.js +134 -60
- package/dist/llm-gateway-runtime/index.js.map +1 -1
- package/dist/llm-gateway-runtime/session-ledger.d.ts +38 -55
- package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/session-ledger.js +102 -250
- package/dist/llm-gateway-runtime/session-ledger.js.map +1 -1
- package/dist/llm-gateway-runtime/sticky.d.ts +25 -52
- package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/sticky.js +36 -605
- package/dist/llm-gateway-runtime/sticky.js.map +1 -1
- package/dist/llm-mesh.d.ts +19 -72
- package/dist/llm-mesh.d.ts.map +1 -1
- package/dist/llm-mesh.js +42 -399
- package/dist/llm-mesh.js.map +1 -1
- package/dist/llm-routing-config.d.ts +28 -0
- package/dist/llm-routing-config.d.ts.map +1 -0
- package/dist/llm-routing-config.js +100 -0
- package/dist/llm-routing-config.js.map +1 -0
- package/dist/native-host.d.ts +146 -0
- package/dist/native-host.d.ts.map +1 -0
- package/dist/native-host.js +456 -0
- package/dist/native-host.js.map +1 -0
- package/dist/native-terminal/host.d.ts +317 -1
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +696 -3
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/op.d.ts +5 -0
- package/dist/native-terminal/op.d.ts.map +1 -0
- package/dist/native-terminal/op.js +408 -0
- package/dist/native-terminal/op.js.map +1 -0
- package/dist/native-terminal/process.d.ts +2 -0
- package/dist/native-terminal/process.d.ts.map +1 -1
- package/dist/native-terminal/process.js +16 -2
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +6 -2
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/socket-path.d.ts +8 -0
- package/dist/native-terminal/socket-path.d.ts.map +1 -1
- package/dist/native-terminal/socket-path.js +31 -1
- package/dist/native-terminal/socket-path.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts +13 -0
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +45 -0
- package/dist/native-terminal/supervisor.js.map +1 -1
- package/dist/pty.d.ts +23 -0
- package/dist/pty.d.ts.map +1 -1
- package/dist/pty.js +35 -0
- package/dist/pty.js.map +1 -1
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.d.ts.map +1 -1
- package/dist/readiness.js +12 -9
- package/dist/readiness.js.map +1 -1
- package/dist/registry.d.ts +324 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +659 -20
- package/dist/registry.js.map +1 -1
- package/dist/restore.d.ts +91 -32
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +226 -186
- package/dist/restore.js.map +1 -1
- package/dist/status-projection.d.ts.map +1 -1
- package/dist/tmux.d.ts +9 -6
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +34 -8
- package/dist/tmux.js.map +1 -1
- package/package.json +3 -3
- package/dist/llm-gateway-runtime/accounts.d.ts +0 -62
- package/dist/llm-gateway-runtime/accounts.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/accounts.js +0 -323
- package/dist/llm-gateway-runtime/accounts.js.map +0 -1
- package/dist/llm-gateway-runtime/model-catalog.d.ts +0 -44
- package/dist/llm-gateway-runtime/model-catalog.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/model-catalog.js +0 -191
- package/dist/llm-gateway-runtime/model-catalog.js.map +0 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +0 -3
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.js +0 -318
- package/dist/llm-gateway-runtime/proxy-anthropic.js.map +0 -1
- package/dist/llm-gateway-runtime/proxy-cloud-code.d.ts +0 -11
- package/dist/llm-gateway-runtime/proxy-cloud-code.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/proxy-cloud-code.js +0 -215
- package/dist/llm-gateway-runtime/proxy-cloud-code.js.map +0 -1
- package/dist/llm-gateway-runtime/proxy-openai.d.ts +0 -118
- package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/proxy-openai.js +0 -1305
- package/dist/llm-gateway-runtime/proxy-openai.js.map +0 -1
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
|
|
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(),
|
|
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
|
-
//
|
|
321
|
-
//
|
|
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
|
|
428
|
+
isManagedLocalKind(e.kind) &&
|
|
324
429
|
e.source === "run" &&
|
|
325
430
|
resolution.unresolvedRunIds.has(e.id)) {
|
|
326
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
751
|
-
//
|
|
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
|
|
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
|
});
|