@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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,138 @@ This file tracks the **fork's own releases**; upstream's full feature history li
5
5
  in that project. Each release notes the upstream version it is built on.
6
6
 
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.2] — 2026-08-14
11
+
12
+ Built on upstream **gajae-code v0.12.0**.
13
+
14
+ ### Fixed (unattended workflow gates were unanswerable through the control plane)
15
+
16
+ `UnattendedSessionControlPlane.emitGate` opened gates without a broker
17
+ continuation, so every gate it emitted was quarantined as
18
+ `opened_without_continuation` and could never be answered; its `advance` hook
19
+ also settled the waiter early, violating the broker's post-advance liveness
20
+ check, and accepted gates then failed terminalization for lack of a proof. The
21
+ control plane now registers a live continuation, settles waiters in
22
+ `completeAccepted` (mirroring the session-side emitter), and declares the
23
+ designed `not_published` terminal proof. The ask tool regained the fork's gate
24
+ semantics on top of that: a negotiated unattended emitter wins over an attended
25
+ UI, clarification answers surface the user's question instead of aborting as a
26
+ cancel, multi-select keeps its empty-"Next" semantics through the gate schema,
27
+ and gate metadata carries the Next/Done navigation label again.
28
+
29
+ ### Fixed (ask wire schema: deep-interview intent branches and gate addressing)
30
+
31
+ The deep-interview intent contract is enforced **on the wire** again: ask's
32
+ `deepInterview` metadata is a union of three mutually exclusive strict branches
33
+ (ordinary round / Round-0 `intent_contract` locked to `component:
34
+ "review-topology"` / post-Round-0 `intent_review` with `round >= 1`), so a
35
+ provider that only sees the JSON schema cannot combine a manifest lock with a
36
+ reduction review or attach either to the wrong round. Questions also regained
37
+ the `workflowGate` stage/kind override for addressing non-deep-interview gates,
38
+ and such overridden questions are excluded from the interview recorder.
39
+
40
+ ### Fixed (`SKC_PY` / `PI_PY` / `PI_JS` eval backend selection was ignored)
41
+
42
+ `resolveEvalBackends` existed but the eval tool never called it: backend
43
+ allowance was read from settings only, so `SKC_PY=js` still spawned the Python
44
+ kernel. The tool now resolves allowance through the documented precedence
45
+ (`SKC_PY`, then legacy `PI_PY`/`PI_JS`, then `eval.py`/`eval.js` settings).
46
+
47
+ ### Fixed (stale model discovery result could overwrite a fresher one)
48
+
49
+ Overlapping `refreshProvider` calls for the same provider raced without any
50
+ ordering guard: whichever fetch *completed* last published its discovery state
51
+ and model list, so a slow stale response could erase the models a fresher
52
+ refresh had just delivered. Discovery now carries a monotonic per-provider
53
+ sequence and only the newest-started refresh may publish state or contribute
54
+ models.
55
+
56
+ ### Fixed (post-merge repair: type-check and test contracts realigned)
57
+
58
+ The upstream merges left the workspace `check` red for several releases: test
59
+ files frozen at fork v0.4.7 kept exercising APIs their source had since dropped
60
+ (session-sticky canonical model resolution, `getSelectorSuppressionStatus`,
61
+ `refreshPresetProfiles`, the pre-broker in-process `AcpAgent`, the fork-era
62
+ bridge-client `WorkflowGate` surface). Stale suites superseded by current
63
+ coverage were removed, the survivors were realigned to the shipped APIs, the
64
+ form-elicitation bridge kept its only coverage via a ported focused suite, the
65
+ `node-pty` dev dependency the merge dropped from `@sayknow-cli/tui` is restored,
66
+ and the extracted model helper modules (`config/model-auth`,
67
+ `config/model-bindings-applier`, `config/model-discovery-manager`) are now
68
+ explicitly unexported, matching the documented package surface.
69
+
70
+ ### Fixed (todo_write truncation and retry loops)
71
+
72
+ Structurally complete JSON tool calls now execute even when a Responses provider
73
+ hits its output-token limit before emitting the final item event. Invalid todo
74
+ payloads retry at most once, while transport failures and repeated errors fail
75
+ open so the requested work continues without visible todo tracking.
76
+
77
+ ### Fixed (v0.5.0 upstream merge reverted the fork's tmux graphics support)
78
+
79
+ The v0.12.0 upstream merge overwrote `packages/tui/src/tui.ts` and silently
80
+ dropped three fork-only behaviors, which is why the pet was unavailable in every
81
+ tmux session and `packages/tui/test/sixel-probe.test.ts` shipped with three
82
+ failing tests:
83
+
84
+ - the capability probe refused to run under any multiplexer, so
85
+ `isSixelMultiplexerEnabled()` became dead code
86
+ - the sixel probe was no longer wrapped in tmux's DCS passthrough envelope, so
87
+ tmux answered for a client it knows nothing about
88
+ - the `CSI 16 t` cell-size query lost its passthrough wrapper (the v0.4.6 fix),
89
+ so tmux reported a cell size the outer terminal never uses
90
+
91
+ All three are restored, with the probe deadline back at 600 ms under tmux to
92
+ cover the passthrough round trip.
93
+
94
+ ### Fixed (transcript images stacked over the text under tmux)
95
+
96
+ Enabling the sixel probe under tmux also switched INLINE graphics on, and the
97
+ inline path wrapped every raster in the DCS passthrough envelope. Passthrough
98
+ writes pixels straight into the OUTER terminal's image plane at its *physical*
99
+ cursor: tmux neither positions that cursor for the pane nor records the pixels,
100
+ so tool-result screenshots landed in the wrong row and survived every repaint —
101
+ each render stacked another copy over the transcript.
102
+
103
+ Inline placements now follow the same ownership rule the pet already used:
104
+
105
+ - tmux advertising the `sixel` terminal-feature (which the SKC tmux profile sets
106
+ automatically) parses the raster into its own screen model, so the raster is
107
+ written raw and scroll / erase / resize move the image with its text.
108
+ - Without that feature the inline render returns the `[image/png …]` placeholder
109
+ instead. An image welded to the outer terminal's physical cursor for the rest
110
+ of the session is worse than no image.
111
+ - Absolutely positioned overlays (the pet) are unaffected: they carry their own
112
+ coordinates through the envelope and remain the only passthrough user.
113
+
114
+ ### Added (Sayknow Pet under tmux on kitty-protocol terminals)
115
+
116
+ Ghostty, Kitty and WezTerm implement kitty graphics and no sixel at all, so the
117
+ sixel probe alone left them with no pet inside tmux. SKC now also forwards a
118
+ kitty capability query (`a=q`) through the passthrough envelope. tmux cannot
119
+ answer it on the terminal's behalf — it does not implement the protocol — so an
120
+ `OK` coming back is genuine end-to-end evidence, unlike tmux's compile-time DA1
121
+ sixel claim.
122
+
123
+ - A successful query enables a dedicated **overlay** channel
124
+ (`setTmuxOverlayImageProtocol`) rather than inline image rendering: absolutely
125
+ positioned art can carry its own cursor addressing through the envelope, while
126
+ inline placements would land at tmux's stale physical cursor.
127
+ - Overlay payloads now carry the pane origin (`#{pane_top}`, `#{pane_left}`, and
128
+ top status lines), so a split window or a top status bar no longer draws the
129
+ pet in the wrong place. This also fixes the pre-existing sixel-fallback path.
130
+ - `allow-passthrough` is requested pane-locally at startup, so a tmux pane the
131
+ user created by hand works without manual configuration.
132
+ - Kill switch: `SKC_KITTY_MULTIPLEXER=0`.
133
+ - The tmux-specific unavailable warning no longer tells users to leave the
134
+ multiplexer or to force sixel; it names the actual requirement.
135
+
136
+ Verified end to end on Ghostty 1.3.1 + tmux 3.6b: pane passthrough is enabled
137
+ automatically, the probe reports `ImageProtocol.Kitty`, and the outer terminal's
138
+ real cell metrics (16x34) replace the 9x18 default.
139
+
8
140
  ## [0.5.1] — 2026-07-29
9
141
 
10
142
  Built on upstream **gajae-code v0.12.0**.
@@ -24,6 +24,13 @@ export default class Session extends Command {
24
24
  "state-file": import("@sayknow-cli/utils/cli").FlagDescriptor<"string"> & {
25
25
  description: string;
26
26
  };
27
+ "dry-run": import("@sayknow-cli/utils/cli").FlagDescriptor<"boolean"> & {
28
+ description: string;
29
+ default: boolean;
30
+ };
31
+ reference: import("@sayknow-cli/utils/cli").FlagDescriptor<"string"> & {
32
+ description: string;
33
+ };
27
34
  };
28
35
  static examples: string[];
29
36
  run(): Promise<void>;
@@ -6,9 +6,17 @@
6
6
  * process, tracks its PID so repeated skc invocations don't double-spawn,
7
7
  * and redirects output to a log file under the config root.
8
8
  */
9
+ import { type Settings } from "./settings";
10
+ /**
11
+ * The startup command runs against an explicit Settings instance (tests and
12
+ * embedders inject one), so read through it instead of the global proxy —
13
+ * which may not be initialized at all on those paths.
14
+ */
15
+ type TelegramSettingsSource = Pick<Settings, "get">;
9
16
  /**
10
17
  * If `telegram.enabled` is true and the gateway is not already running, spawn
11
18
  * it in the background. Never throws — failures are logged as warnings so the
12
19
  * main skc session is unaffected.
13
20
  */
14
- export declare function maybeAutostartTelegramRemote(): Promise<void>;
21
+ export declare function maybeAutostartTelegramRemote(settings?: TelegramSettingsSource): Promise<void>;
22
+ export {};
@@ -9,15 +9,16 @@ export declare function isPetAvailable(): boolean;
9
9
  export declare function createPetSelectItems(options: ReadonlyArray<SelectItem>, currentValue: string, available: boolean): SelectItem[];
10
10
  /**
11
11
  * Grace period before declaring the terminal pet-incapable at startup. The
12
- * asynchronous Sixel capability probe starts inside `TUI.start()` and answers
13
- * within its own 250 ms deadline; this margin covers probe scheduling so a
14
- * supported terminal is never told it is incompatible while the probe is
15
- * still in flight.
12
+ * asynchronous capability probes start inside `TUI.start()` and answer within
13
+ * their own deadline (250 ms directly, 600 ms through tmux passthrough); this
14
+ * margin covers probe scheduling so a supported terminal is never told it is
15
+ * incompatible while a probe is still in flight.
16
16
  */
17
- export declare const PET_CAPABILITY_SETTLE_MS = 1000;
17
+ export declare const PET_CAPABILITY_SETTLE_MS = 1500;
18
18
  /**
19
- * Whether the asynchronous startup Sixel capability probe may still enable
20
- * graphics for this session, meaning current unavailability is not final.
19
+ * Whether an asynchronous startup capability probe (Sixel, or Kitty through
20
+ * tmux passthrough) may still enable graphics for this session, meaning current
21
+ * unavailability is not final.
21
22
  */
22
23
  export declare function isPetCapabilityProbePending(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
23
24
  /**
@@ -1,7 +1,7 @@
1
1
  import { Container, type SelectList } from "@sayknow-cli/tui";
2
2
  import type { PetMode } from "./sayknow-pet-widget";
3
3
  /**
4
- * Theme-style picker for the sayknow pet skin (Off / RedSayknow / BlueSayknow). Preview
4
+ * Theme-style picker for the sayknow pet skin (Off / RedOctopus / BlueOctopus). Preview
5
5
  * fires as the selection moves; select commits, cancel restores.
6
6
  */
7
7
  export declare class PetSelectorComponent extends Container {
@@ -50,7 +50,7 @@ export declare class SayknowPetWidget {
50
50
  setMode(mode: PetMode): void;
51
51
  /** Live preview during a selector: change the sprite without re-mounting the
52
52
  * composer editor (that would tear down the open overlay). After a short idle
53
- * eye-roll it fires the signature burst once (RedSayknow flex, BlueSayknow para-para
53
+ * eye-roll it fires the signature burst once (RedOctopus flex, BlueOctopus para-para
54
54
  * then sob) so the selector demos the animation instead of waiting the random gap. */
55
55
  previewMode(mode: PetMode): void;
56
56
  commitPreviewMode(mode: PetMode): void;
@@ -67,6 +67,12 @@ export declare function modelSupportsTokenCostMetrics(model: Model | undefined):
67
67
  export interface WorkflowGateEmitter {
68
68
  /** True only when unattended mode has been negotiated. */
69
69
  isUnattended(): boolean;
70
+ /**
71
+ * True when gates emitted through this emitter can be answered remotely
72
+ * (workflow_gate_response over RPC/bridge). The ask tool refuses headless
73
+ * execution unless this reports true.
74
+ */
75
+ supportsRemoteGateAnswers(): boolean;
70
76
  /** Open + emit a gate; resolves with the agent's answer (from workflow_gate_response). */
71
77
  emitGate(input: OpenGateInput): Promise<unknown>;
72
78
  /**
@@ -107,6 +113,7 @@ export declare class UnattendedSessionControlPlane implements RpcUnattendedContr
107
113
  private readonly opts;
108
114
  constructor(opts: UnattendedSessionOptions);
109
115
  isUnattended(): boolean;
116
+ supportsRemoteGateAnswers(): boolean;
110
117
  /** Observe every emitted gate (e.g. so an extension can map an ask to its gate_id). */
111
118
  onGateEmitted(listener: (gate: RpcWorkflowGate) => void): () => void;
112
119
  get controller(): UnattendedRunController | undefined;
@@ -60,6 +60,8 @@ export interface AskSelectedAckRecoveryParticipant {
60
60
  /** SDK-native surface for emitting a workflow gate and awaiting its answer. */
61
61
  export interface WorkflowGateEmitter {
62
62
  supportsRemoteGateAnswers(): boolean;
63
+ /** True when an unattended run has been negotiated for this emitter (control-plane emitters). */
64
+ isUnattended?(): boolean;
63
65
  emitGate(input: OpenGateInput): Promise<unknown>;
64
66
  onGateEmitted?(listener: (gate: WorkflowGate) => void): () => void;
65
67
  resolveGate?(response: WorkflowGateResponse): Promise<WorkflowGateResolution>;
@@ -528,6 +528,7 @@ export declare class StreamingEditFileCache {
528
528
  has(path: string): boolean;
529
529
  get totalBytes(): number;
530
530
  }
531
+ export declare function buildTodoWriteFailureReminder(errorText: string | undefined, failureCount: number): string;
531
532
  export declare class AgentSession {
532
533
  #private;
533
534
  readonly agent: Agent;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Boot-generation evidence for reboot-only session restore.
3
+ *
4
+ * Restore exists to survive a reboot, and only a reboot. The eligibility rule is
5
+ * therefore narrow on purpose: a session may be restored only when the recorded
6
+ * and current boot values come from the SAME source and differ. Anything else —
7
+ * equal values, a missing or malformed record, an unreadable probe, or two
8
+ * different sources — is inconclusive and must never spawn.
9
+ *
10
+ * The same-source requirement is not pedantry. Linux can report `boot-id` at
11
+ * record time and fall back to `proc-btime` later; those two values are always
12
+ * unequal even on one boot, so comparing across sources would read every launch
13
+ * as a reboot and duplicate live sessions.
14
+ */
15
+ /** Where a boot value came from. Values are only ever compared within one source. */
16
+ export type BootGenerationSource = "darwin-kern-boottime" | "linux-boot-id" | "linux-proc-btime" | "unavailable";
17
+ export interface BootGeneration {
18
+ source: BootGenerationSource;
19
+ /** Opaque; only equality within the same source is meaningful. */
20
+ value: string | null;
21
+ }
22
+ export type BootComparison = "changed" | "same_boot" | "boot_unknown";
23
+ export interface RecordedBootGeneration {
24
+ schema_version: number;
25
+ source: string;
26
+ value: string;
27
+ }
28
+ export interface BootGenerationProbeDeps {
29
+ platform?: NodeJS.Platform;
30
+ readFile?: (file: string) => string;
31
+ runCommand?: (command: string, args: string[]) => {
32
+ exitCode: number | null;
33
+ stdout: string;
34
+ };
35
+ }
36
+ /** `{ sec = 1785305532, usec = 377197 } Wed Jul 29 ...` -> `1785305532.377197`. */
37
+ export declare function parseDarwinBootTime(raw: string): string | null;
38
+ /** A boot id is a UUID that changes on every boot; anything else is not usable. */
39
+ export declare function parseLinuxBootId(raw: string): string | null;
40
+ /** `/proc/stat` carries `btime <seconds>` once, as the kernel boot wall-clock. */
41
+ export declare function parseLinuxProcBtime(raw: string): string | null;
42
+ /**
43
+ * Reads the strongest boot evidence this platform offers.
44
+ *
45
+ * Windows returns `unavailable`: restore is unsupported there (no immutable
46
+ * native tmux session identity), so there is nothing to gate.
47
+ */
48
+ export declare function readBootGeneration(deps?: BootGenerationProbeDeps): BootGeneration;
49
+ export declare function isBootValueWellFormed(source: string, value: string): boolean;
50
+ export declare function isRecordedBootGeneration(value: unknown): value is RecordedBootGeneration;
51
+ export declare function recordBootGeneration(current: BootGeneration): RecordedBootGeneration | null;
52
+ /**
53
+ * Decides whether the machine rebooted since the session was recorded.
54
+ *
55
+ * `changed` is the ONLY executable answer. It requires a well-formed record, a
56
+ * readable current probe, an identical source, and different values. Source
57
+ * mismatch is deliberately `boot_unknown` rather than `changed`.
58
+ */
59
+ export declare function compareBootGeneration(recorded: unknown, current: BootGeneration): BootComparison;
@@ -30,14 +30,16 @@ export interface TmuxSpawnResult {
30
30
  exitCode: number | null;
31
31
  signalCode?: string | null;
32
32
  stderr?: string;
33
+ /** Populated only when the caller asked for `stdout: "pipe"`. */
34
+ stdout?: string;
33
35
  }
34
36
  export type TmuxSpawnSync = (command: string, args: string[], options: TmuxSpawnOptions) => TmuxSpawnResult;
35
37
  export interface TmuxSpawnOptions {
36
38
  cwd: string;
37
39
  env: NodeJS.ProcessEnv;
38
40
  stdin: "inherit";
39
- stdout: "inherit";
40
- stderr: "inherit";
41
+ stdout: "inherit" | "pipe";
42
+ stderr: "inherit" | "pipe";
41
43
  }
42
44
  export interface TmuxLaunchPlan {
43
45
  tmuxCommand: string;
@@ -50,6 +52,12 @@ export interface TmuxLaunchPlan {
50
52
  project?: string | null;
51
53
  sessionId?: string | null;
52
54
  sessionStateFile?: string | null;
55
+ /**
56
+ * Capability of the RESOLVED provider, not a platform guess. psmux has no
57
+ * immutable native session identity, so it stays outside the native-proof
58
+ * create fence and keeps its existing spawn/profile/attach behavior.
59
+ */
60
+ isPsmux: boolean;
53
61
  }
54
62
  export interface SkcTmuxProfileResult {
55
63
  skipped: boolean;
@@ -0,0 +1,41 @@
1
+ import type { BootGeneration } from "./boot-generation";
2
+ import { type RestoreCandidateDeps, type RestorePointer, type RestoreSidecarFacts } from "./session-restore";
3
+ /**
4
+ * Strict re-read of the sidecar a pointer names.
5
+ *
6
+ * Returns null for missing, unreadable, or malformed content: restore must never
7
+ * infer a session's identity from the pointer alone.
8
+ */
9
+ export declare function readSidecarFacts(pointer: RestorePointer): RestoreSidecarFacts | null;
10
+ /**
11
+ * True when a tmux session already carries this exact coordinator identity.
12
+ *
13
+ * An unreadable tmux is reported as a collision on purpose: not being able to
14
+ * see the server is not evidence that the identity is free.
15
+ */
16
+ export declare function hasLiveIdentity(pointer: RestorePointer, env?: NodeJS.ProcessEnv): boolean;
17
+ /** psmux exposes no immutable native session identity, so restore cannot prove ownership there. */
18
+ export declare function ownerProofAvailable(env?: NodeJS.ProcessEnv): boolean;
19
+ export declare function buildRestoreCandidateDeps(currentBoot: BootGeneration, env?: NodeJS.ProcessEnv): RestoreCandidateDeps;
20
+ export type RestoreOutcome = {
21
+ ok: true;
22
+ pointer: RestorePointer;
23
+ tmuxSession: string;
24
+ } | {
25
+ ok: false;
26
+ pointer: RestorePointer;
27
+ code: string;
28
+ detail: string;
29
+ };
30
+ /**
31
+ * Restores exactly one eligible candidate.
32
+ *
33
+ * Deliberately goes through the ordinary fenced creator rather than spawning
34
+ * tmux directly: restore must inherit the same identity fence, owner-isolation
35
+ * proof, and exact cleanup as every other producer. The only differences are the
36
+ * working directory and the `--resume` argv handed to the child.
37
+ *
38
+ * Never cleans up another owner's session. A fence refusal is reported and the
39
+ * candidate is skipped.
40
+ */
41
+ export declare function restoreSession(pointer: RestorePointer, env?: NodeJS.ProcessEnv): RestoreOutcome;
@@ -0,0 +1,99 @@
1
+ import { type BootGeneration, type RecordedBootGeneration } from "./boot-generation";
2
+ export declare const RESTORE_POINTER_SCHEMA_VERSION = 1;
3
+ export interface RestorePointer {
4
+ schema_version: number;
5
+ /** Coordinator identity: what the create fence and the sidecar are keyed by. */
6
+ coordinator_session_id: string;
7
+ state_file: string;
8
+ /** SKC session id whose transcript `skc --resume` would reopen. */
9
+ skc_session_id: string;
10
+ session_file: string;
11
+ cwd: string;
12
+ branch: string | null;
13
+ boot: RecordedBootGeneration;
14
+ updated_at: string;
15
+ }
16
+ /** Pointers live under SKC's own root so they are discoverable without scanning projects. */
17
+ export declare function restorePointerDirectory(): string;
18
+ export declare function restorePointerFile(coordinatorSessionId: string, stateFile: string): string;
19
+ export declare function isRestorePointer(value: unknown): value is RestorePointer;
20
+ /**
21
+ * The session id `skc --resume` resolves is the transcript header id, which is
22
+ * NOT the coordinator id: a normal `skc --tmux` child inherits only the
23
+ * coordinator identity and then mints its own session id. Reading it from the
24
+ * transcript is the only way a pointer can name the conversation that will
25
+ * actually be reopened.
26
+ */
27
+ export declare function readTranscriptSessionId(sessionFile: string): string | null;
28
+ export interface PublishRestorePointerInput {
29
+ coordinatorSessionId: string;
30
+ stateFile: string;
31
+ skcSessionId: string;
32
+ sessionFile: string;
33
+ cwd: string;
34
+ branch?: string | null;
35
+ bootGeneration?: BootGeneration;
36
+ now?: () => Date;
37
+ }
38
+ /**
39
+ * Publishes (or refreshes) the pointer for a live session.
40
+ *
41
+ * Returns false without writing when the platform cannot produce boot evidence:
42
+ * a pointer whose boot value is unusable could never be judged `changed`, so
43
+ * writing one would only add noise. Never throws — losing a pointer must not
44
+ * break the session that was trying to publish it.
45
+ */
46
+ export declare function publishRestorePointer(input: PublishRestorePointerInput): boolean;
47
+ /** Lists candidate pointers. Unreadable or malformed entries are skipped, never guessed at. */
48
+ export declare function listRestorePointers(): RestorePointer[];
49
+ /**
50
+ * Exact reference to one candidate, for `skc session restore --reference`.
51
+ *
52
+ * base64url of the identity pair, so a reference cannot be confused with a
53
+ * session id, a path, or a prefix. It selects a candidate; it never overrides
54
+ * any eligibility check.
55
+ */
56
+ export declare function encodeRestoreReference(coordinatorSessionId: string, stateFile: string): string;
57
+ export declare function decodeRestoreReference(reference: string): {
58
+ coordinatorSessionId: string;
59
+ stateFile: string;
60
+ } | null;
61
+ export type RestoreIneligibleReason = "same_boot" | "boot_unknown" | "sidecar_missing" | "sidecar_identity_mismatch" | "sidecar_terminal" | "transcript_missing" | "cwd_missing" | "live_identity_collision" | "transcript_identity_mismatch" | "unsupported_owner_proof";
62
+ export type RestoreCandidateVerdict = {
63
+ eligible: true;
64
+ pointer: RestorePointer;
65
+ } | {
66
+ eligible: false;
67
+ pointer: RestorePointer;
68
+ reason: RestoreIneligibleReason;
69
+ detail?: string;
70
+ };
71
+ /** The sidecar fields restore is allowed to trust, read fresh at decision time. */
72
+ export interface RestoreSidecarFacts {
73
+ sessionId: string;
74
+ stateFile: string;
75
+ sessionFile: string | null;
76
+ cwd: string | null;
77
+ terminal: boolean;
78
+ }
79
+ export interface RestoreCandidateDeps {
80
+ currentBoot: BootGeneration;
81
+ /** Strict re-read of the referenced sidecar. Null when absent or unparseable. */
82
+ readSidecar: (pointer: RestorePointer) => RestoreSidecarFacts | null;
83
+ pathExists: (target: string) => boolean;
84
+ /** True when a live tmux session already owns this identity. */
85
+ hasLiveIdentity: (pointer: RestorePointer) => boolean;
86
+ /** Header id of the transcript the pointer names, re-read at decision time. */
87
+ readTranscriptSessionId: (pointer: RestorePointer) => string | null;
88
+ /** False when this host cannot produce the owner proof restore requires (psmux). */
89
+ ownerProofAvailable: () => boolean;
90
+ }
91
+ /**
92
+ * Decides whether one candidate may be restored.
93
+ *
94
+ * Order matters: the reboot proof comes first because it is the cheapest and the
95
+ * most restrictive gate, and the pointer's own contents are never trusted beyond
96
+ * naming what to re-read.
97
+ */
98
+ export declare function evaluateRestoreCandidate(pointer: RestorePointer, deps: RestoreCandidateDeps): RestoreCandidateVerdict;
99
+ export declare function evaluateRestoreCandidates(pointers: readonly RestorePointer[], deps: RestoreCandidateDeps): RestoreCandidateVerdict[];
@@ -302,6 +302,166 @@ export declare function replaceOwnerGenerationSync(stateDir: string, sessionId:
302
302
  /** Publishes a raw-owner generation through the canonical SQLite-serialized CAS path. */
303
303
  export declare function publishOwnerGenerationSync(request: PublishGenerationRequest): PublishGenerationResult;
304
304
  export declare function createOwnerIntent(stateDir: string, input: Omit<OwnerIntent, "schema_version" | "intent_id" | "state">): Promise<OwnerIntent>;
305
+ /**
306
+ * Shared identity create fence (frozen contract F1'-F9''', architect-approved).
307
+ *
308
+ * Concurrent creators of the SAME canonical `(stateDir, sessionId)` identity must
309
+ * not both spawn a child: `SessionManager` opens transcripts with append flags and
310
+ * has no inter-process single-writer lock, so two owners on one transcript is a
311
+ * data-integrity failure rather than a recoverable skip.
312
+ *
313
+ * The fence deliberately does NOT hold the SQLite writer transaction across the
314
+ * spawn. `bootstrapTmuxOwnerIsolation()` runs in a separate process and acquires
315
+ * this very database twice itself, and the synchronous creator blocks on that
316
+ * helper through `Bun.spawnSync`, so a held transaction would deadlock the helper
317
+ * against its own parent. A long hold would also starve the 250 ms verdict path
318
+ * that shares this database. Instead each transition takes a short (target 50 ms)
319
+ * transaction that only reads and writes a durable reservation row; mutual
320
+ * exclusion outlives the transaction as row state, not as a held lock.
321
+ */
322
+ export type IdentityCreatePhase = "reserved" | "helper_invoked" | "spawned" | "tagged" | "published";
323
+ /** Phases at or beyond which an untagged child may already exist on the server. */
324
+ export declare function identityCreatePhaseMayHaveChild(phase: IdentityCreatePhase): boolean;
325
+ export interface IdentityCreateKey {
326
+ stateDir: string;
327
+ sessionId: string;
328
+ stateFile: string;
329
+ }
330
+ export interface IdentityCreateReservation {
331
+ reservationId: string;
332
+ stateDir: string;
333
+ sessionId: string;
334
+ stateFile: string;
335
+ ownerPid: number;
336
+ ownerIncarnation: string;
337
+ phase: IdentityCreatePhase;
338
+ attemptSessionName: string | null;
339
+ nativeSessionId: string | null;
340
+ serverPid: number | null;
341
+ serverStartTime: string | null;
342
+ claimedAt: string;
343
+ updatedAt: string;
344
+ leaseDeadline: string;
345
+ }
346
+ export type IdentityCreateReservationResult = {
347
+ ok: true;
348
+ reservation: IdentityCreateReservation;
349
+ recovered: IdentityCreateReservation | null;
350
+ } | {
351
+ ok: false;
352
+ code: "identity_reserved_live" | "identity_reserved_unknown" | "identity_fence_contended" | "identity_incarnation_unavailable" | "identity_orphan_unresolved" | "identity_existing_owner";
353
+ existing: IdentityCreateReservation | null;
354
+ diagnostic: string;
355
+ };
356
+ /** Owner liveness is tri-state: only a proven-dead owner may be displaced. */
357
+ export type OwnerLiveness = "alive" | "dead" | "unknown";
358
+ /**
359
+ * `processIncarnation()` returns `undefined` both for a vanished process and for a
360
+ * failed probe, so existence is resolved first. Signal 0 distinguishes them:
361
+ * `ESRCH` proves absence, `EPERM` proves a live process owned by another user, and
362
+ * anything else is unknown. Only then does the incarnation discriminate PID reuse.
363
+ */
364
+ export declare function probeOwnerLiveness(pid: number, recordedIncarnation: string, deps?: {
365
+ readIncarnation?: (pid: number) => string | undefined;
366
+ signal?: (pid: number) => void;
367
+ }): OwnerLiveness;
368
+ /** @internal Test seam for the incarnation reader. */
369
+ export declare function __setOwnerIncarnationReaderForTests(reader: ((pid: number) => string | undefined) | null): void;
370
+ export declare function canonicalIdentityCreateKey(key: IdentityCreateKey): IdentityCreateKey;
371
+ /**
372
+ * Ends the attempt while KEEPING the durable row.
373
+ *
374
+ * Used when cleanup after a spawn was uncertain: a child may survive that we
375
+ * could not remove, so the evidence must outlive the attempt. Unlike
376
+ * `releaseIdentityCreate` this leaves the row for authority-first recovery.
377
+ */
378
+ export declare function abandonIdentityCreate(reservation: IdentityCreateReservation): void;
379
+ export interface ReserveIdentityCreateOptions {
380
+ ttlMs?: number;
381
+ ownerPid?: number;
382
+ ownerIncarnation?: string;
383
+ now?: () => Date;
384
+ probeLiveness?: (pid: number, incarnation: string) => OwnerLiveness;
385
+ }
386
+ /**
387
+ * Claims the identity when it is free, and otherwise reports why not.
388
+ *
389
+ * A dead previous owner is deliberately NOT displaced here: authority-first
390
+ * recovery has to census live tmux, which shells out and must never run inside
391
+ * the fence window. The caller performs that census and then calls
392
+ * {@link reclaimIdentityCreate}.
393
+ *
394
+ * The lease deadline is diagnostic only. Expiry never authorizes takeover; only a
395
+ * proven-dead owner does, because real creator paths block in unbounded
396
+ * `Bun.spawnSync` and cannot heartbeat while blocked.
397
+ */
398
+ export declare function reserveIdentityCreate(rawKey: IdentityCreateKey, options?: ReserveIdentityCreateOptions): IdentityCreateReservationResult;
399
+ /**
400
+ * Completes authority-first recovery after the caller proved, outside the fence
401
+ * window, that the abandoned reservation left no authoritative child behind.
402
+ *
403
+ * `expectedReservationId` pins the exact abandoned row: if another process already
404
+ * recovered it, the row no longer matches and this fails closed rather than
405
+ * producing a second child.
406
+ */
407
+ export declare function reclaimIdentityCreate(rawKey: IdentityCreateKey, expectedReservationId: string, options?: ReserveIdentityCreateOptions): IdentityCreateReservationResult;
408
+ /**
409
+ * What a live-tmux census concluded about the child an abandoned reservation may
410
+ * have left behind. `unknown` is not a soft failure: it is the fail-closed case.
411
+ */
412
+ export type AbandonedIdentityVerdict = {
413
+ kind: "authoritative";
414
+ nativeSessionId: string;
415
+ } | {
416
+ kind: "orphan";
417
+ nativeSessionId: string;
418
+ } | {
419
+ kind: "absent";
420
+ } | {
421
+ kind: "unknown";
422
+ reason: string;
423
+ };
424
+ /**
425
+ * Injected so this module never imports the tmux session helpers (which already
426
+ * depend on it). The census shells out and therefore runs OUTSIDE the fence
427
+ * window, by construction.
428
+ */
429
+ export interface AbandonedIdentityCensus {
430
+ inspect(evidence: {
431
+ stateDir: string;
432
+ sessionId: string;
433
+ attemptSessionName: string | null;
434
+ nativeSessionId: string | null;
435
+ }): AbandonedIdentityVerdict;
436
+ cleanupOrphan(nativeSessionId: string, attemptSessionName: string | null): void;
437
+ }
438
+ /**
439
+ * Authority-first recovery for a reservation whose owner is proven dead and that
440
+ * reached at least `helper_invoked`, so an untagged child may exist.
441
+ *
442
+ * The reservation's own `phase` is a hint, never the authority: a creator can
443
+ * publish its generation and die before recording `published`. The census reads
444
+ * the live canonical tags and the current published generation instead, so a
445
+ * valid child is preserved rather than killed — which is what keeps a
446
+ * tag-before-report crash from producing a successor.
447
+ */
448
+ export declare function recoverAbandonedIdentityCreate(key: IdentityCreateKey, existing: IdentityCreateReservation, census: AbandonedIdentityCensus, options?: ReserveIdentityCreateOptions): IdentityCreateReservationResult;
449
+ export interface IdentityCreatePhasePatch {
450
+ attemptSessionName?: string | null;
451
+ nativeSessionId?: string | null;
452
+ serverPid?: number | null;
453
+ serverStartTime?: string | null;
454
+ }
455
+ /**
456
+ * Records a create-progress transition and renews the lease.
457
+ *
458
+ * Returns `null` when the reservation is no longer ours. The caller MUST treat
459
+ * that as a lost fence and perform no tmux mutation: another process has already
460
+ * recovered this identity.
461
+ */
462
+ export declare function advanceIdentityCreatePhase(reservation: IdentityCreateReservation, phase: IdentityCreatePhase, patch?: IdentityCreatePhasePatch, options?: Pick<ReserveIdentityCreateOptions, "ttlMs" | "now">): IdentityCreateReservation | null;
463
+ /** Releases the reservation. Idempotent, and never deletes a successor's row. */
464
+ export declare function releaseIdentityCreate(reservation: IdentityCreateReservation): void;
305
465
  /** Publish exactly one terminal verdict. Existing valid verdicts always win. */
306
466
  export declare function observeOwnerTerminal(request: ObserveTerminalRequest): Promise<OwnerVerdict>;
307
467
  /** Strictly validate the persisted authorization, optionally against its terminal observation. */