@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.
- package/CHANGELOG.md +132 -0
- package/dist/types/commands/session.d.ts +7 -0
- package/dist/types/config/telegram-autostart.d.ts +9 -1
- package/dist/types/modes/components/pet-capability.d.ts +8 -7
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -0
- package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
- package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
- package/dist/types/skc-runtime/session-restore.d.ts +99 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
- package/dist/types/tools/ask.d.ts +164 -4
- package/package.json +10 -7
- package/src/commands/session.ts +88 -2
- package/src/config/model-registry.ts +12 -0
- package/src/config/telegram-autostart.ts +11 -4
- package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +1 -1
- package/src/modes/components/pet-capability.ts +22 -13
- package/src/modes/components/pet-selector.ts +1 -1
- package/src/modes/components/sayknow-pet-widget.ts +41 -7
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
- package/src/notifications/lifecycle-control-runtime.ts +258 -179
- package/src/prompts/system/eager-todo.md +2 -0
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/system-prompt.md +4 -2
- package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
- package/src/session/agent-session.ts +31 -11
- package/src/skc-runtime/boot-generation.ts +172 -0
- package/src/skc-runtime/launch-tmux.ts +219 -41
- package/src/skc-runtime/session-restore-runtime.ts +120 -0
- package/src/skc-runtime/session-restore.ts +296 -0
- package/src/skc-runtime/session-state-sidecar.ts +41 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
- package/src/skc-runtime/tmux-sessions.ts +284 -108
- package/src/slash-commands/builtin-registry.ts +9 -4
- package/src/tools/ask.ts +183 -10
- 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
|
|
13
|
-
*
|
|
14
|
-
* supported terminal is never told it 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 =
|
|
17
|
+
export declare const PET_CAPABILITY_SETTLE_MS = 1500;
|
|
18
18
|
/**
|
|
19
|
-
* Whether
|
|
20
|
-
* graphics for this session, meaning current
|
|
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 /
|
|
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 (
|
|
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. */
|