@sayknow-cli/coding-agent 0.5.1 → 0.5.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/dist/types/commands/session.d.ts +7 -0
  3. package/dist/types/config/telegram-autostart.d.ts +9 -1
  4. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  5. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  6. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  7. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  8. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  9. package/dist/types/session/agent-session.d.ts +1 -0
  10. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  11. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  12. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  13. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  14. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  15. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  16. package/dist/types/tools/ask.d.ts +164 -4
  17. package/package.json +10 -7
  18. package/src/commands/session.ts +88 -2
  19. package/src/config/model-registry.ts +12 -0
  20. package/src/config/telegram-autostart.ts +11 -4
  21. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  22. package/src/internal-urls/docs-index.generated.ts +1 -1
  23. package/src/main.ts +1 -1
  24. package/src/modes/components/pet-capability.ts +22 -13
  25. package/src/modes/components/pet-selector.ts +1 -1
  26. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  27. package/src/modes/controllers/event-controller.ts +1 -1
  28. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  29. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  30. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  31. package/src/prompts/system/eager-todo.md +2 -0
  32. package/src/prompts/system/plan-mode-approved.md +1 -1
  33. package/src/prompts/system/system-prompt.md +4 -2
  34. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  35. package/src/session/agent-session.ts +31 -11
  36. package/src/skc-runtime/boot-generation.ts +172 -0
  37. package/src/skc-runtime/launch-tmux.ts +219 -41
  38. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  39. package/src/skc-runtime/session-restore.ts +296 -0
  40. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  41. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  42. package/src/skc-runtime/tmux-sessions.ts +284 -108
  43. package/src/slash-commands/builtin-registry.ts +9 -4
  44. package/src/tools/ask.ts +183 -10
  45. package/src/tools/eval.ts +2 -2
@@ -19,6 +19,13 @@ import {
19
19
  SKC_TMUX_SESSION_PREFIX,
20
20
  type SkcTmuxProfileCommand,
21
21
  } from "./tmux-common";
22
+ import {
23
+ abandonIdentityCreate,
24
+ advanceIdentityCreatePhase,
25
+ type IdentityCreateReservation,
26
+ releaseIdentityCreate,
27
+ reserveIdentityCreate,
28
+ } from "./tmux-owner-isolation";
22
29
  import { findSkcTmuxSessionByName, findSkcTmuxSessionByScope, type SkcTmuxSessionStatus } from "./tmux-sessions";
23
30
 
24
31
  export {
@@ -65,6 +72,8 @@ export interface TmuxSpawnResult {
65
72
  exitCode: number | null;
66
73
  signalCode?: string | null;
67
74
  stderr?: string;
75
+ /** Populated only when the caller asked for `stdout: "pipe"`. */
76
+ stdout?: string;
68
77
  }
69
78
 
70
79
  export type TmuxSpawnSync = (command: string, args: string[], options: TmuxSpawnOptions) => TmuxSpawnResult;
@@ -73,8 +82,8 @@ export interface TmuxSpawnOptions {
73
82
  cwd: string;
74
83
  env: NodeJS.ProcessEnv;
75
84
  stdin: "inherit";
76
- stdout: "inherit";
77
- stderr: "inherit";
85
+ stdout: "inherit" | "pipe";
86
+ stderr: "inherit" | "pipe";
78
87
  }
79
88
 
80
89
  export interface TmuxLaunchPlan {
@@ -88,6 +97,12 @@ export interface TmuxLaunchPlan {
88
97
  project?: string | null;
89
98
  sessionId?: string | null;
90
99
  sessionStateFile?: string | null;
100
+ /**
101
+ * Capability of the RESOLVED provider, not a platform guess. psmux has no
102
+ * immutable native session identity, so it stays outside the native-proof
103
+ * create fence and keeps its existing spawn/profile/attach behavior.
104
+ */
105
+ isPsmux: boolean;
91
106
  }
92
107
 
93
108
  function explicitTmuxSessionName(env: NodeJS.ProcessEnv): string | undefined {
@@ -408,12 +423,106 @@ function readCurrentBranch(cwd: string): string | null {
408
423
  }
409
424
  }
410
425
 
411
- function cleanupCreatedTmuxSession(plan: TmuxLaunchPlan, spawnSync: TmuxSpawnSync, options: TmuxSpawnOptions): void {
426
+ /**
427
+ * Immutable identity of a session this process created, used so cleanup can
428
+ * never target a different session that merely reuses the name.
429
+ */
430
+ interface CreatedSessionIdentity {
431
+ nativeSessionId: string;
432
+ serverPid: string;
433
+ /** Session birth time: distinguishes a reused `$id` on a restarted server. */
434
+ sessionCreated: string;
435
+ }
436
+
437
+ /**
438
+ * Reads `#{session_id}` and the server `#{pid}` for the session just created.
439
+ * Returns null when the provider cannot supply them, in which case cleanup falls
440
+ * back to the pre-existing name-based path.
441
+ */
442
+ function captureCreatedSessionIdentity(
443
+ plan: TmuxLaunchPlan,
444
+ spawnSync: TmuxSpawnSync,
445
+ options: TmuxSpawnOptions,
446
+ ): CreatedSessionIdentity | null {
447
+ const probed = spawnSync(
448
+ plan.tmuxCommand,
449
+ [
450
+ "display-message",
451
+ "-p",
452
+ "-t",
453
+ buildSkcTmuxExactSessionTarget(plan.sessionName, { env: options.env }),
454
+ "#{session_id}\t#{pid}\t#{session_created}",
455
+ ],
456
+ { ...options, stdout: "pipe" },
457
+ );
458
+ if (probed.exitCode !== 0 || typeof probed.stdout !== "string") return null;
459
+ const [nativeSessionId, serverPid, sessionCreated] = probed.stdout.trim().split("\t");
460
+ if (!nativeSessionId?.startsWith("$") || !serverPid || !sessionCreated) return null;
461
+ return { nativeSessionId, serverPid, sessionCreated };
462
+ }
463
+
464
+ /**
465
+ * Returns false when the created session was NOT provably removed, so the caller
466
+ * can keep the reservation as evidence for authority-first recovery.
467
+ */
468
+ function cleanupCreatedTmuxSession(
469
+ plan: TmuxLaunchPlan,
470
+ spawnSync: TmuxSpawnSync,
471
+ options: TmuxSpawnOptions,
472
+ identity?: CreatedSessionIdentity | null,
473
+ ): boolean {
474
+ if (plan.isPsmux) {
475
+ // Capability carveout: psmux exposes no immutable session identity, so the
476
+ // pre-existing name-based cleanup is all that is available there.
477
+ const killed = spawnSync(
478
+ plan.tmuxCommand,
479
+ ["kill-session", "-t", buildSkcTmuxExactSessionTarget(plan.sessionName, { env: options.env })],
480
+ options,
481
+ );
482
+ return killed.exitCode === 0;
483
+ }
484
+ // Fail closed: without the immutable identity we cannot prove what we would be
485
+ // killing, and a session NAME may belong to somebody else by now. Leaking a
486
+ // session is recoverable; killing an unrelated one is not.
487
+ if (!identity) return false;
488
+ // One tmux invocation proves AND kills, so no other process can swap the
489
+ // target between the check and the kill. The predicate pins the server
490
+ // incarnation, the exact session id, and that session's birth time.
491
+ const predicate = [
492
+ `#{&&:#{==:#{pid},${identity.serverPid}}`,
493
+ `#{&&:#{==:#{session_id},${identity.nativeSessionId}}`,
494
+ `#{==:#{session_created},${identity.sessionCreated}}}}`,
495
+ ].join(",");
412
496
  spawnSync(
413
497
  plan.tmuxCommand,
414
- ["kill-session", "-t", buildSkcTmuxExactSessionTarget(plan.sessionName, { env: options.env })],
498
+ ["if-shell", "-t", identity.nativeSessionId, "-F", predicate, `kill-session -t ${identity.nativeSessionId}`],
415
499
  options,
416
500
  );
501
+ // `if-shell` exits 0 even when the predicate is false and nothing ran, so its
502
+ // exit code is not proof. Ask whether that exact session is still there.
503
+ const survivor = spawnSync(plan.tmuxCommand, ["has-session", "-t", identity.nativeSessionId], {
504
+ ...options,
505
+ stdout: "pipe",
506
+ stderr: "pipe",
507
+ });
508
+ // A signalled or otherwise unfinished probe answers nothing. Treating that as
509
+ // removal would delete the only evidence that a child may survive, so an
510
+ // indeterminate result fails closed.
511
+ if (typeof survivor.exitCode !== "number") return false;
512
+ // Zero means the session is still there.
513
+ if (survivor.exitCode === 0) return false;
514
+ // Non-zero is ambiguous on its own: tmux exits non-zero both for a session it
515
+ // cannot find and for an operational failure such as a refused socket. Only an
516
+ // explicit absence — including the whole server being gone — proves our child
517
+ // is no longer on a server we own. Anything else fails closed.
518
+ const diagnostic = (survivor.stderr ?? "").toLowerCase();
519
+ return (
520
+ diagnostic.includes("can't find session") ||
521
+ diagnostic.includes("cannot find session") ||
522
+ diagnostic.includes("session not found") ||
523
+ diagnostic.includes("no server running") ||
524
+ diagnostic.includes("no such file or directory")
525
+ );
417
526
  }
418
527
  function isTmuxAttachDisconnectError(result: TmuxSpawnResult): boolean {
419
528
  if (result.signalCode === "SIGHUP") return true;
@@ -486,6 +595,7 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
486
595
  sessionId,
487
596
  sessionStateFile,
488
597
  attachSessionName: existingSessionName,
598
+ isPsmux: resolvedBinary.isPsmux,
489
599
  };
490
600
  }
491
601
 
@@ -498,7 +608,12 @@ function defaultSpawnSync(command: string, args: string[], options: TmuxSpawnOpt
498
608
  stdout: options.stdout,
499
609
  stderr: options.stderr,
500
610
  });
501
- return { exitCode: result.exitCode, signalCode: result.signalCode };
611
+ return {
612
+ exitCode: result.exitCode,
613
+ signalCode: result.signalCode,
614
+ ...(options.stdout === "pipe" ? { stdout: result.stdout?.toString() } : {}),
615
+ ...(options.stderr === "pipe" ? { stderr: result.stderr?.toString() } : {}),
616
+ };
502
617
  }
503
618
 
504
619
  export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
@@ -525,49 +640,112 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
525
640
  if (attached.exitCode === 0) return true;
526
641
  }
527
642
 
528
- const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, options);
529
- if (created.exitCode === 0) {
530
- renameTmuxWindow(
643
+ // Shared identity create fence for the CREATED branch only. The attach
644
+ // fallback above is not a creator and stays unfenced.
645
+ //
646
+ // Capability, not platform: psmux exposes no immutable native session
647
+ // identity, so it cannot participate in the native-proof fence and keeps its
648
+ // existing behavior. That carveout is safe because restore already ends in
649
+ // `unsupported_owner_proof` before it can create on psmux, so no restore
650
+ // child can race this producer.
651
+ const fenceIdentity =
652
+ plan.isPsmux || !plan.sessionId || !plan.sessionStateFile
653
+ ? null
654
+ : {
655
+ stateDir: path.dirname(plan.sessionStateFile),
656
+ sessionId: plan.sessionId,
657
+ stateFile: plan.sessionStateFile,
658
+ };
659
+ let reservation: IdentityCreateReservation | null = null;
660
+ let cleanupUncertain = false;
661
+ if (fenceIdentity) {
662
+ const reserved = reserveIdentityCreate(fenceIdentity);
663
+ if (!reserved.ok) {
664
+ // A PROVEN competing owner blocks the launch: perform no tmux mutation
665
+ // (no spawn, no attach, no delete). Falling through to a direct
666
+ // in-process launch is refused too, because it would put a second
667
+ // writer on the same coordinator transcript.
668
+ // Fail closed for every non-ok result. The fence database lives under
669
+ // SKC's own config root, so an unreachable fence is not a routine
670
+ // environment difference: it means the one-create guarantee cannot be
671
+ // honored, and launching anyway risks a second writer on this identity.
672
+ (context.diagnosticWriter ?? safeStderrWrite)(
673
+ formatTmuxLaunchDiagnostic("identity create fence", `${reserved.code}: ${reserved.diagnostic}`),
674
+ );
675
+ return true;
676
+ }
677
+ reservation = reserved.reservation;
678
+ }
679
+ try {
680
+ // Record the attempt name BEFORE the first spawn-capable call, exactly like
681
+ // the other producers. Without it a crash between `new-session` and the
682
+ // release leaves the row at `reserved`, which a successor reclaims WITHOUT a
683
+ // census — and that is how a second owner reaches one transcript.
684
+ if (reservation) {
685
+ reservation =
686
+ advanceIdentityCreatePhase(reservation, "helper_invoked", { attemptSessionName: plan.sessionName }) ??
687
+ (() => {
688
+ throw new Error("skc_tmux_identity_create_fence_lost");
689
+ })();
690
+ }
691
+ const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, options);
692
+ // Capture the immutable native identity while we still know the session we
693
+ // just made is the one behind this name. psmux cannot supply it, which is
694
+ // exactly why it stays outside the native-proof guarantee.
695
+ const createdIdentity =
696
+ created.exitCode === 0 && !plan.isPsmux ? captureCreatedSessionIdentity(plan, spawnSync, options) : null;
697
+ if (created.exitCode === 0) {
698
+ renameTmuxWindow(
699
+ plan.tmuxCommand,
700
+ buildSkcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch),
701
+ spawnSync,
702
+ options,
703
+ buildSkcTmuxExactSessionTarget(plan.sessionName, { env }),
704
+ );
705
+
706
+ const profile = applySkcTmuxProfile({
707
+ tmuxCommand: plan.tmuxCommand,
708
+ target: plan.sessionName,
709
+ cwd: plan.cwd,
710
+ env,
711
+ spawnSync,
712
+ branch: plan.branch,
713
+ project: plan.project,
714
+ sessionId: plan.sessionId ?? null,
715
+ sessionStateFile: plan.sessionStateFile ?? null,
716
+ version: VERSION,
717
+ });
718
+ const ownershipFailure = profile.failures.find(item => item.command.args.includes("@skc-profile"));
719
+ if (ownershipFailure) {
720
+ if (!cleanupCreatedTmuxSession(plan, spawnSync, options, createdIdentity)) cleanupUncertain = true;
721
+ (context.diagnosticWriter ?? safeStderrWrite)(
722
+ formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
723
+ );
724
+ return true;
725
+ }
726
+ }
727
+ if (created.exitCode !== 0) return false;
728
+ const attached = spawnSync(
531
729
  plan.tmuxCommand,
532
- buildSkcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch),
533
- spawnSync,
730
+ ["attach-session", "-t", buildSkcTmuxExactSessionTarget(plan.sessionName, { env })],
534
731
  options,
535
- buildSkcTmuxExactSessionTarget(plan.sessionName, { env }),
536
732
  );
537
-
538
- const profile = applySkcTmuxProfile({
539
- tmuxCommand: plan.tmuxCommand,
540
- target: plan.sessionName,
541
- cwd: plan.cwd,
542
- env,
543
- spawnSync,
544
- branch: plan.branch,
545
- project: plan.project,
546
- sessionId: plan.sessionId ?? null,
547
- sessionStateFile: plan.sessionStateFile ?? null,
548
- version: VERSION,
549
- });
550
- const ownershipFailure = profile.failures.find(item => item.command.args.includes("@skc-profile"));
551
- if (ownershipFailure) {
552
- cleanupCreatedTmuxSession(plan, spawnSync, options);
733
+ if (attached.exitCode === 0) return true;
734
+ if (isTmuxAttachDisconnectError(attached)) {
553
735
  (context.diagnosticWriter ?? safeStderrWrite)(
554
- formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
736
+ formatTmuxLaunchDiagnostic("attach disconnected", attached.stderr),
555
737
  );
556
738
  return true;
557
739
  }
558
- }
559
- if (created.exitCode !== 0) return false;
560
- const attached = spawnSync(
561
- plan.tmuxCommand,
562
- ["attach-session", "-t", buildSkcTmuxExactSessionTarget(plan.sessionName, { env })],
563
- options,
564
- );
565
- if (attached.exitCode === 0) return true;
566
- if (isTmuxAttachDisconnectError(attached)) {
567
- (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach disconnected", attached.stderr));
740
+ if (!cleanupCreatedTmuxSession(plan, spawnSync, options, createdIdentity)) cleanupUncertain = true;
741
+ (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach failed", attached.stderr));
568
742
  return true;
743
+ } finally {
744
+ if (reservation) {
745
+ // An unproven cleanup may have left a child; its row is the only evidence
746
+ // a successor gets, so end the attempt without deleting it.
747
+ if (cleanupUncertain) abandonIdentityCreate(reservation);
748
+ else releaseIdentityCreate(reservation);
749
+ }
569
750
  }
570
- cleanupCreatedTmuxSession(plan, spawnSync, options);
571
- (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach failed", attached.stderr));
572
- return true;
573
751
  }
@@ -0,0 +1,120 @@
1
+ // Live-system bindings for restore eligibility.
2
+ //
3
+ // `session-restore.ts` stays pure and injectable so its fail-closed decision
4
+ // table can be tested without a filesystem or a tmux server. This module is the
5
+ // thin adapter that supplies the real readings.
6
+
7
+ import * as fsSync from "node:fs";
8
+ import type { BootGeneration } from "./boot-generation";
9
+ import {
10
+ type RestoreCandidateDeps,
11
+ type RestorePointer,
12
+ type RestoreSidecarFacts,
13
+ readTranscriptSessionId,
14
+ } from "./session-restore";
15
+ import { SKC_COORDINATOR_SESSION_ID_ENV, SKC_COORDINATOR_SESSION_STATE_FILE_ENV } from "./session-state-sidecar";
16
+ import { resolveSkcTmuxBinary } from "./tmux-common";
17
+ import { createSkcTmuxSession, listSkcTmuxSessions } from "./tmux-sessions";
18
+
19
+ const TERMINAL_SIDECAR_STATES = new Set(["completed", "errored"]);
20
+
21
+ /**
22
+ * Strict re-read of the sidecar a pointer names.
23
+ *
24
+ * Returns null for missing, unreadable, or malformed content: restore must never
25
+ * infer a session's identity from the pointer alone.
26
+ */
27
+ export function readSidecarFacts(pointer: RestorePointer): RestoreSidecarFacts | null {
28
+ let parsed: Record<string, unknown>;
29
+ try {
30
+ parsed = JSON.parse(fsSync.readFileSync(pointer.state_file, "utf8")) as Record<string, unknown>;
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (typeof parsed !== "object" || parsed === null) return null;
35
+ const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : null;
36
+ if (!sessionId) return null;
37
+ return {
38
+ sessionId,
39
+ stateFile: pointer.state_file,
40
+ sessionFile: typeof parsed.session_file === "string" ? parsed.session_file : null,
41
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : null,
42
+ terminal: typeof parsed.state === "string" && TERMINAL_SIDECAR_STATES.has(parsed.state),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * True when a tmux session already carries this exact coordinator identity.
48
+ *
49
+ * An unreadable tmux is reported as a collision on purpose: not being able to
50
+ * see the server is not evidence that the identity is free.
51
+ */
52
+ export function hasLiveIdentity(pointer: RestorePointer, env: NodeJS.ProcessEnv = process.env): boolean {
53
+ try {
54
+ return listSkcTmuxSessions(env).some(
55
+ session =>
56
+ session.sessionId === pointer.coordinator_session_id && session.sessionStateFile === pointer.state_file,
57
+ );
58
+ } catch {
59
+ return true;
60
+ }
61
+ }
62
+
63
+ /** psmux exposes no immutable native session identity, so restore cannot prove ownership there. */
64
+ export function ownerProofAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
65
+ try {
66
+ return !resolveSkcTmuxBinary({ env }).isPsmux;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export function buildRestoreCandidateDeps(
73
+ currentBoot: BootGeneration,
74
+ env: NodeJS.ProcessEnv = process.env,
75
+ ): RestoreCandidateDeps {
76
+ return {
77
+ currentBoot,
78
+ readSidecar: readSidecarFacts,
79
+ pathExists: target => fsSync.existsSync(target),
80
+ hasLiveIdentity: pointer => hasLiveIdentity(pointer, env),
81
+ readTranscriptSessionId: pointer => readTranscriptSessionId(pointer.session_file),
82
+ ownerProofAvailable: () => ownerProofAvailable(env),
83
+ };
84
+ }
85
+
86
+ export type RestoreOutcome =
87
+ | { ok: true; pointer: RestorePointer; tmuxSession: string }
88
+ | { ok: false; pointer: RestorePointer; code: string; detail: string };
89
+
90
+ /**
91
+ * Restores exactly one eligible candidate.
92
+ *
93
+ * Deliberately goes through the ordinary fenced creator rather than spawning
94
+ * tmux directly: restore must inherit the same identity fence, owner-isolation
95
+ * proof, and exact cleanup as every other producer. The only differences are the
96
+ * working directory and the `--resume` argv handed to the child.
97
+ *
98
+ * Never cleans up another owner's session. A fence refusal is reported and the
99
+ * candidate is skipped.
100
+ */
101
+ export function restoreSession(pointer: RestorePointer, env: NodeJS.ProcessEnv = process.env): RestoreOutcome {
102
+ // The child must adopt the recorded identity, not this process's.
103
+ const childEnv: NodeJS.ProcessEnv = {
104
+ ...env,
105
+ [SKC_COORDINATOR_SESSION_ID_ENV]: pointer.coordinator_session_id,
106
+ [SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: pointer.state_file,
107
+ SKC_SESSION_ID: pointer.skc_session_id,
108
+ };
109
+ try {
110
+ const session = createSkcTmuxSession(childEnv, {
111
+ cwd: pointer.cwd,
112
+ childArgs: ["--resume", pointer.skc_session_id],
113
+ });
114
+ return { ok: true, pointer, tmuxSession: session.name };
115
+ } catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ const [code = "restore_failed"] = message.split(":");
118
+ return { ok: false, pointer, code, detail: message };
119
+ }
120
+ }