@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
@@ -0,0 +1,296 @@
1
+ // Discovery and eligibility for reboot-only session restore.
2
+ //
3
+ // Session sidecars live inside each project (under `<project>/.skc/_session-<id>/
4
+ // runtime/tmux-sessions/`), so there is no way to enumerate them globally — that
5
+ // is precisely why restore needs its own index. Each session publishes an
6
+ // immutable POINTER under SKC's own config root; the pointer is a candidate
7
+ // list, never authority. Every decision is re-derived from the sidecar, the
8
+ // transcript and live tmux at the moment of restore.
9
+ //
10
+ // Every branch here is fail-closed: an answer that is not provably "safe to
11
+ // restore" is never treated as one.
12
+
13
+ import * as crypto from "node:crypto";
14
+ import * as fsSync from "node:fs";
15
+ import * as path from "node:path";
16
+ import { getAgentDir } from "@sayknow-cli/utils/dirs";
17
+ import {
18
+ type BootComparison,
19
+ type BootGeneration,
20
+ compareBootGeneration,
21
+ type RecordedBootGeneration,
22
+ readBootGeneration,
23
+ recordBootGeneration,
24
+ } from "./boot-generation";
25
+
26
+ export const RESTORE_POINTER_SCHEMA_VERSION = 1;
27
+
28
+ export interface RestorePointer {
29
+ schema_version: number;
30
+ /** Coordinator identity: what the create fence and the sidecar are keyed by. */
31
+ coordinator_session_id: string;
32
+ state_file: string;
33
+ /** SKC session id whose transcript `skc --resume` would reopen. */
34
+ skc_session_id: string;
35
+ session_file: string;
36
+ cwd: string;
37
+ branch: string | null;
38
+ boot: RecordedBootGeneration;
39
+ updated_at: string;
40
+ }
41
+
42
+ /** Pointers live under SKC's own root so they are discoverable without scanning projects. */
43
+ export function restorePointerDirectory(): string {
44
+ return path.join(getAgentDir(), "session-restore", "pointers");
45
+ }
46
+
47
+ export function restorePointerFile(coordinatorSessionId: string, stateFile: string): string {
48
+ const digest = crypto.createHash("sha256").update(`${coordinatorSessionId}\u0000${stateFile}`).digest("hex");
49
+ return path.join(restorePointerDirectory(), `${digest}.json`);
50
+ }
51
+
52
+ export function isRestorePointer(value: unknown): value is RestorePointer {
53
+ if (typeof value !== "object" || value === null) return false;
54
+ const record = value as Record<string, unknown>;
55
+ const strings = ["coordinator_session_id", "state_file", "skc_session_id", "session_file", "cwd", "updated_at"];
56
+ if (record.schema_version !== RESTORE_POINTER_SCHEMA_VERSION) return false;
57
+ if (!strings.every(key => typeof record[key] === "string" && (record[key] as string).length > 0)) return false;
58
+ if (record.branch !== null && typeof record.branch !== "string") return false;
59
+ const boot = record.boot as Record<string, unknown> | undefined;
60
+ return (
61
+ typeof boot === "object" &&
62
+ boot !== null &&
63
+ typeof boot.source === "string" &&
64
+ typeof boot.value === "string" &&
65
+ boot.value.length > 0
66
+ );
67
+ }
68
+
69
+ /**
70
+ * The session id `skc --resume` resolves is the transcript header id, which is
71
+ * NOT the coordinator id: a normal `skc --tmux` child inherits only the
72
+ * coordinator identity and then mints its own session id. Reading it from the
73
+ * transcript is the only way a pointer can name the conversation that will
74
+ * actually be reopened.
75
+ */
76
+ export function readTranscriptSessionId(sessionFile: string): string | null {
77
+ let handle: number | undefined;
78
+ try {
79
+ handle = fsSync.openSync(sessionFile, "r");
80
+ const buffer = Buffer.alloc(8192);
81
+ const read = fsSync.readSync(handle, buffer, 0, buffer.length, 0);
82
+ const firstLine = buffer.subarray(0, read).toString("utf8").split("\n", 1)[0] ?? "";
83
+ if (!firstLine.trim()) return null;
84
+ const parsed = JSON.parse(firstLine) as Record<string, unknown>;
85
+ if (parsed.type !== "session") return null;
86
+ const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
87
+ return id.length > 0 ? id : null;
88
+ } catch {
89
+ return null;
90
+ } finally {
91
+ if (handle !== undefined) {
92
+ try {
93
+ fsSync.closeSync(handle);
94
+ } catch {}
95
+ }
96
+ }
97
+ }
98
+
99
+ export interface PublishRestorePointerInput {
100
+ coordinatorSessionId: string;
101
+ stateFile: string;
102
+ skcSessionId: string;
103
+ sessionFile: string;
104
+ cwd: string;
105
+ branch?: string | null;
106
+ bootGeneration?: BootGeneration;
107
+ now?: () => Date;
108
+ }
109
+
110
+ /**
111
+ * Publishes (or refreshes) the pointer for a live session.
112
+ *
113
+ * Returns false without writing when the platform cannot produce boot evidence:
114
+ * a pointer whose boot value is unusable could never be judged `changed`, so
115
+ * writing one would only add noise. Never throws — losing a pointer must not
116
+ * break the session that was trying to publish it.
117
+ */
118
+ export function publishRestorePointer(input: PublishRestorePointerInput): boolean {
119
+ const boot = recordBootGeneration(input.bootGeneration ?? readBootGeneration());
120
+ if (!boot) return false;
121
+ const pointer: RestorePointer = {
122
+ schema_version: RESTORE_POINTER_SCHEMA_VERSION,
123
+ coordinator_session_id: input.coordinatorSessionId,
124
+ state_file: input.stateFile,
125
+ skc_session_id: input.skcSessionId,
126
+ session_file: input.sessionFile,
127
+ cwd: input.cwd,
128
+ branch: input.branch ?? null,
129
+ boot,
130
+ updated_at: (input.now ?? (() => new Date()))().toISOString(),
131
+ };
132
+ const file = restorePointerFile(input.coordinatorSessionId, input.stateFile);
133
+ const temporary = `${file}.${crypto.randomUUID()}.tmp`;
134
+ try {
135
+ fsSync.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
136
+ fsSync.writeFileSync(temporary, `${JSON.stringify(pointer)}\n`, { mode: 0o600 });
137
+ fsSync.renameSync(temporary, file);
138
+ return true;
139
+ } catch {
140
+ try {
141
+ fsSync.unlinkSync(temporary);
142
+ } catch {}
143
+ return false;
144
+ }
145
+ }
146
+
147
+ function readRestorePointer(file: string): RestorePointer | null {
148
+ try {
149
+ const parsed = JSON.parse(fsSync.readFileSync(file, "utf8")) as unknown;
150
+ return isRestorePointer(parsed) ? parsed : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /** Lists candidate pointers. Unreadable or malformed entries are skipped, never guessed at. */
157
+ export function listRestorePointers(): RestorePointer[] {
158
+ const directory = restorePointerDirectory();
159
+ let names: string[];
160
+ try {
161
+ names = fsSync.readdirSync(directory);
162
+ } catch {
163
+ return [];
164
+ }
165
+ const pointers: RestorePointer[] = [];
166
+ for (const name of names) {
167
+ if (!name.endsWith(".json")) continue;
168
+ const file = path.join(directory, name);
169
+ // Only regular files inside the index count. A symlink here would let
170
+ // anything outside the index be read as a restore candidate.
171
+ try {
172
+ if (!fsSync.lstatSync(file).isFile()) continue;
173
+ } catch {
174
+ continue;
175
+ }
176
+ const pointer = readRestorePointer(file);
177
+ if (pointer) pointers.push(pointer);
178
+ }
179
+ return pointers;
180
+ }
181
+
182
+ /**
183
+ * Exact reference to one candidate, for `skc session restore --reference`.
184
+ *
185
+ * base64url of the identity pair, so a reference cannot be confused with a
186
+ * session id, a path, or a prefix. It selects a candidate; it never overrides
187
+ * any eligibility check.
188
+ */
189
+ export function encodeRestoreReference(coordinatorSessionId: string, stateFile: string): string {
190
+ return Buffer.from(JSON.stringify([coordinatorSessionId, stateFile]), "utf8").toString("base64url");
191
+ }
192
+
193
+ export function decodeRestoreReference(reference: string): { coordinatorSessionId: string; stateFile: string } | null {
194
+ if (!/^[A-Za-z0-9_-]+$/u.test(reference)) return null;
195
+ try {
196
+ const parsed = JSON.parse(Buffer.from(reference, "base64url").toString("utf8")) as unknown;
197
+ if (!Array.isArray(parsed) || parsed.length !== 2) return null;
198
+ const [coordinatorSessionId, stateFile] = parsed;
199
+ if (typeof coordinatorSessionId !== "string" || typeof stateFile !== "string") return null;
200
+ if (!coordinatorSessionId || !stateFile) return null;
201
+ // Reject non-canonical encodings: several byte strings can decode to the
202
+ // same pair, and a reference must name exactly one candidate.
203
+ if (encodeRestoreReference(coordinatorSessionId, stateFile) !== reference) return null;
204
+ return { coordinatorSessionId, stateFile };
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ export type RestoreIneligibleReason =
211
+ | "same_boot"
212
+ | "boot_unknown"
213
+ | "sidecar_missing"
214
+ | "sidecar_identity_mismatch"
215
+ | "sidecar_terminal"
216
+ | "transcript_missing"
217
+ | "cwd_missing"
218
+ | "live_identity_collision"
219
+ | "transcript_identity_mismatch"
220
+ | "unsupported_owner_proof";
221
+
222
+ export type RestoreCandidateVerdict =
223
+ | { eligible: true; pointer: RestorePointer }
224
+ | { eligible: false; pointer: RestorePointer; reason: RestoreIneligibleReason; detail?: string };
225
+
226
+ /** The sidecar fields restore is allowed to trust, read fresh at decision time. */
227
+ export interface RestoreSidecarFacts {
228
+ sessionId: string;
229
+ stateFile: string;
230
+ sessionFile: string | null;
231
+ cwd: string | null;
232
+ terminal: boolean;
233
+ }
234
+
235
+ export interface RestoreCandidateDeps {
236
+ currentBoot: BootGeneration;
237
+ /** Strict re-read of the referenced sidecar. Null when absent or unparseable. */
238
+ readSidecar: (pointer: RestorePointer) => RestoreSidecarFacts | null;
239
+ pathExists: (target: string) => boolean;
240
+ /** True when a live tmux session already owns this identity. */
241
+ hasLiveIdentity: (pointer: RestorePointer) => boolean;
242
+ /** Header id of the transcript the pointer names, re-read at decision time. */
243
+ readTranscriptSessionId: (pointer: RestorePointer) => string | null;
244
+ /** False when this host cannot produce the owner proof restore requires (psmux). */
245
+ ownerProofAvailable: () => boolean;
246
+ }
247
+
248
+ /**
249
+ * Decides whether one candidate may be restored.
250
+ *
251
+ * Order matters: the reboot proof comes first because it is the cheapest and the
252
+ * most restrictive gate, and the pointer's own contents are never trusted beyond
253
+ * naming what to re-read.
254
+ */
255
+ export function evaluateRestoreCandidate(pointer: RestorePointer, deps: RestoreCandidateDeps): RestoreCandidateVerdict {
256
+ const boot: BootComparison = compareBootGeneration(pointer.boot, deps.currentBoot);
257
+ if (boot !== "changed") {
258
+ return { eligible: false, pointer, reason: boot === "same_boot" ? "same_boot" : "boot_unknown" };
259
+ }
260
+
261
+ if (!deps.ownerProofAvailable()) return { eligible: false, pointer, reason: "unsupported_owner_proof" };
262
+
263
+ const sidecar = deps.readSidecar(pointer);
264
+ if (!sidecar) return { eligible: false, pointer, reason: "sidecar_missing" };
265
+ // The pointer is a hint; the sidecar is authority. They must agree exactly.
266
+ if (
267
+ sidecar.sessionId !== pointer.coordinator_session_id ||
268
+ sidecar.stateFile !== pointer.state_file ||
269
+ // A sidecar that records no transcript cannot corroborate the pointer's
270
+ // claim, and a stale pointer would then select somebody else's transcript.
271
+ sidecar.sessionFile === null ||
272
+ sidecar.sessionFile !== pointer.session_file
273
+ ) {
274
+ return { eligible: false, pointer, reason: "sidecar_identity_mismatch" };
275
+ }
276
+ if (sidecar.terminal) return { eligible: false, pointer, reason: "sidecar_terminal" };
277
+
278
+ if (!deps.pathExists(pointer.session_file)) return { eligible: false, pointer, reason: "transcript_missing" };
279
+ if (!deps.pathExists(pointer.cwd)) return { eligible: false, pointer, reason: "cwd_missing" };
280
+ // `--resume` resolves the transcript header id, so a pointer whose recorded id
281
+ // no longer matches would reopen the wrong conversation — or none at all.
282
+ if (deps.readTranscriptSessionId(pointer) !== pointer.skc_session_id) {
283
+ return { eligible: false, pointer, reason: "transcript_identity_mismatch" };
284
+ }
285
+ // A live owner means this identity never died; restoring would be a duplicate.
286
+ if (deps.hasLiveIdentity(pointer)) return { eligible: false, pointer, reason: "live_identity_collision" };
287
+
288
+ return { eligible: true, pointer };
289
+ }
290
+
291
+ export function evaluateRestoreCandidates(
292
+ pointers: readonly RestorePointer[],
293
+ deps: RestoreCandidateDeps,
294
+ ): RestoreCandidateVerdict[] {
295
+ return pointers.map(pointer => evaluateRestoreCandidate(pointer, deps));
296
+ }
@@ -6,6 +6,7 @@ import type { AssistantMessage } from "@sayknow-cli/ai";
6
6
  import { normalizePathForComparison, postmortem } from "@sayknow-cli/utils";
7
7
  import { withFileLock } from "../config/file-lock";
8
8
  import { sessionRuntimeDir } from "./session-layout";
9
+ import { publishRestorePointer, readTranscriptSessionId } from "./session-restore";
9
10
  import {
10
11
  isValidOwnerIntent,
11
12
  lifecyclePaths,
@@ -754,6 +755,41 @@ async function withCoordinatorTransactionLock<T>(stateFile: string, operation: (
754
755
  return await withStateFileLock(coordinatorTransactionLockFile(stateFile), operation);
755
756
  }
756
757
 
758
+ /**
759
+ * Mirrors a live session into the global restore index.
760
+ *
761
+ * Sidecars are scattered across every project, so restore cannot find them by
762
+ * scanning; this pointer is the only discovery path. Failures are swallowed on
763
+ * purpose — a missing pointer costs a restore candidate, while a throw here
764
+ * would break the session that was merely reporting its state.
765
+ */
766
+ function publishRestorePointerForSidecar(stateFile: string, payload: Record<string, unknown>): void {
767
+ try {
768
+ const state = payload.state;
769
+ if (state === "completed" || state === "errored") return;
770
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
771
+ const sessionFile = typeof payload.session_file === "string" ? payload.session_file : "";
772
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : "";
773
+ if (!sessionId || !sessionFile || !cwd) return;
774
+ // The coordinator id is NOT what `skc --resume` resolves. A normal tmux child
775
+ // mints its own session id, so the transcript header is the only authority.
776
+ // Without it a pointer would name a conversation that cannot be reopened, so
777
+ // publishing is skipped rather than guessed.
778
+ const skcSessionId = readTranscriptSessionId(sessionFile);
779
+ if (!skcSessionId) return;
780
+ publishRestorePointer({
781
+ coordinatorSessionId: sessionId,
782
+ stateFile,
783
+ skcSessionId,
784
+ sessionFile,
785
+ cwd,
786
+ branch: typeof payload.branch === "string" ? payload.branch : null,
787
+ });
788
+ } catch {
789
+ // Never let restore bookkeeping break session state reporting.
790
+ }
791
+ }
792
+
757
793
  async function writeStateFile(stateFile: string, payload: Record<string, unknown>): Promise<void> {
758
794
  await fs.mkdir(path.dirname(stateFile), { recursive: true });
759
795
  await Bun.write(stateFile, `${JSON.stringify(payload)}\n`);
@@ -807,6 +843,11 @@ export async function persistCoordinatorRuntimeStateFromEvent(
807
843
  };
808
844
  if (shouldSkipRuntimeStateWrite(previous, payload, nowMs)) return;
809
845
  await writeStateFile(stateFile, payload);
846
+ // Publish the restore pointer only AFTER the sidecar it points at is
847
+ // durable, so a pointer can never reference a state that was never
848
+ // written. Terminal states publish nothing: a finished session is
849
+ // not a restore candidate.
850
+ publishRestorePointerForSidecar(stateFile, payload);
810
851
  }),
811
852
  ),
812
853
  );