@sayknow-cli/coding-agent 0.5.0 → 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 +157 -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
@@ -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. */
@@ -1,4 +1,4 @@
1
- import { type OwnerIsolationProbeSync, type PlanResponse, type TmuxOwnerIsolationExecutionDependencies, type TmuxOwnerIsolationExecutionResult } from "./tmux-owner-isolation";
1
+ import { type AbandonedIdentityCensus, type IdentityCreateKey, type IdentityCreateReservationResult, type OwnerIsolationProbeSync, type PlanResponse, type TmuxOwnerIsolationExecutionDependencies, type TmuxOwnerIsolationExecutionResult } from "./tmux-owner-isolation";
2
2
  export interface SkcTmuxSessionStatus {
3
3
  name: string;
4
4
  attached: boolean;
@@ -67,6 +67,17 @@ export interface ForceCloseOwnerDependencies {
67
67
  }
68
68
  export interface CreateSkcTmuxSessionOptions {
69
69
  platform?: NodeJS.Platform;
70
+ /**
71
+ * Restore only: run the child in the session's recorded directory instead of
72
+ * this process's cwd.
73
+ */
74
+ cwd?: string;
75
+ /**
76
+ * Restore only: argv appended to the `skc` child, e.g. `["--resume", "<id>"]`.
77
+ * Restore reuses this creator verbatim so it inherits the same identity fence
78
+ * and owner-isolation proof as an ordinary create.
79
+ */
80
+ childArgs?: string[];
70
81
  }
71
82
  export type CreateOwnerIsolationTestDependencies = {
72
83
  probe?: Partial<OwnerIsolationProbeSync>;
@@ -83,6 +94,20 @@ export declare function findSkcTmuxSessionByBranch(branch: string, env?: NodeJS.
83
94
  export declare function findSkcTmuxSessionByName(sessionName: string, env?: NodeJS.ProcessEnv): SkcTmuxSessionStatus | undefined;
84
95
  export declare function findSkcTmuxSessionByScope(project: string, branch: string | null | undefined, env?: NodeJS.ProcessEnv): SkcTmuxSessionStatus | undefined;
85
96
  export declare function statusSkcTmuxSession(sessionName: string, env?: NodeJS.ProcessEnv): SkcTmuxSessionStatus;
97
+ /**
98
+ * Live-tmux census for authority-first recovery of an abandoned reservation.
99
+ *
100
+ * Authority is the CURRENT published generation on disk compared against the
101
+ * live session's canonical tags — never the reservation's own `phase`, because a
102
+ * creator can publish and then die before recording that it published.
103
+ */
104
+ /**
105
+ * Resolves a reservation refused because its previous owner died after reaching
106
+ * the helper. Shared by every producer so none of them can brick an identity by
107
+ * treating an abandoned row as a permanent collision.
108
+ */
109
+ export declare function settleAbandonedIdentity(reserved: IdentityCreateReservationResult, identityKey: IdentityCreateKey, census: AbandonedIdentityCensus): IdentityCreateReservationResult;
110
+ export declare function abandonedIdentityCensus(tmuxCommand: string, env: NodeJS.ProcessEnv, stateFile: string): AbandonedIdentityCensus;
86
111
  export declare function createSkcTmuxSession(env?: NodeJS.ProcessEnv, options?: CreateSkcTmuxSessionOptions): SkcTmuxSessionStatus;
87
112
  /** Proves a managed reusable name still resolves to one immutable session on one server. */
88
113
  export declare function proveSkcTmuxSessionMutationTarget(sessionName: string, env?: NodeJS.ProcessEnv): ProvenTmuxSessionIdentity;
@@ -29,13 +29,92 @@ export declare const askSchema: z.ZodObject<{
29
29
  }, z.core.$strip>>;
30
30
  multi: z.ZodOptional<z.ZodBoolean>;
31
31
  recommended: z.ZodOptional<z.ZodNumber>;
32
- deepInterview: z.ZodOptional<z.ZodObject<{
32
+ workflowGate: z.ZodOptional<z.ZodObject<{
33
+ stage: z.ZodEnum<{
34
+ "deep-interview": "deep-interview";
35
+ ralplan: "ralplan";
36
+ ultragoal: "ultragoal";
37
+ }>;
38
+ kind: z.ZodEnum<{
39
+ question: "question";
40
+ approval: "approval";
41
+ execution: "execution";
42
+ }>;
43
+ }, z.core.$strip>>;
44
+ deepInterview: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
45
+ round: z.ZodLiteral<0>;
46
+ component: z.ZodLiteral<"review-topology">;
47
+ intent_contract: z.ZodObject<{
48
+ items: z.ZodArray<z.ZodObject<{
49
+ id: z.ZodString;
50
+ category: z.ZodEnum<{
51
+ surface: "surface";
52
+ artifact: "artifact";
53
+ integration: "integration";
54
+ constraint: "constraint";
55
+ }>;
56
+ statement: z.ZodString;
57
+ }, z.core.$strict>>;
58
+ confirmation_options: z.ZodArray<z.ZodString>;
59
+ }, z.core.$strict>;
33
60
  round_id: z.ZodOptional<z.ZodString>;
61
+ dimension: z.ZodString;
62
+ ambiguity: z.ZodNumber;
63
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
64
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
65
+ reference_id: z.ZodString;
66
+ label: z.ZodString;
67
+ origin: z.ZodString;
68
+ url: z.ZodOptional<z.ZodString>;
69
+ excerpt: z.ZodOptional<z.ZodString>;
70
+ }, z.core.$strict>>>;
71
+ }, z.core.$strict>, z.ZodObject<{
34
72
  round: z.ZodNumber;
73
+ intent_review: z.ZodObject<{
74
+ observed_items: z.ZodArray<z.ZodObject<{
75
+ id: z.ZodString;
76
+ category: z.ZodEnum<{
77
+ surface: "surface";
78
+ artifact: "artifact";
79
+ integration: "integration";
80
+ constraint: "constraint";
81
+ }>;
82
+ statement: z.ZodString;
83
+ }, z.core.$strict>>;
84
+ supporting_substitutions: z.ZodArray<z.ZodObject<{
85
+ removed_id: z.ZodString;
86
+ replacement_ids: z.ZodArray<z.ZodString>;
87
+ rationale: z.ZodString;
88
+ }, z.core.$strict>>;
89
+ approval_options: z.ZodArray<z.ZodString>;
90
+ }, z.core.$strict>;
91
+ round_id: z.ZodOptional<z.ZodString>;
35
92
  component: z.ZodString;
36
93
  dimension: z.ZodString;
37
94
  ambiguity: z.ZodNumber;
38
- }, z.core.$strip>>;
95
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
96
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
97
+ reference_id: z.ZodString;
98
+ label: z.ZodString;
99
+ origin: z.ZodString;
100
+ url: z.ZodOptional<z.ZodString>;
101
+ excerpt: z.ZodOptional<z.ZodString>;
102
+ }, z.core.$strict>>>;
103
+ }, z.core.$strict>, z.ZodObject<{
104
+ round_id: z.ZodOptional<z.ZodString>;
105
+ round: z.ZodNumber;
106
+ component: z.ZodString;
107
+ dimension: z.ZodString;
108
+ ambiguity: z.ZodNumber;
109
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
110
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
111
+ reference_id: z.ZodString;
112
+ label: z.ZodString;
113
+ origin: z.ZodString;
114
+ url: z.ZodOptional<z.ZodString>;
115
+ excerpt: z.ZodOptional<z.ZodString>;
116
+ }, z.core.$strict>>>;
117
+ }, z.core.$strict>]>>;
39
118
  }, z.core.$strip>>;
40
119
  }, z.core.$strip>;
41
120
  export type AskToolInput = z.infer<typeof askSchema>;
@@ -54,6 +133,8 @@ export interface AskToolDetails {
54
133
  multi?: boolean;
55
134
  selectedOptions?: string[];
56
135
  customInput?: string;
136
+ /** Set when the user answered with a clarification question instead of a choice. */
137
+ clarificationQuestion?: string;
57
138
  /** Multi-part question mode */
58
139
  results?: QuestionResult[];
59
140
  }
@@ -80,13 +161,92 @@ export declare class AskTool implements AgentTool<typeof askSchema, AskToolDetai
80
161
  }, z.core.$strip>>;
81
162
  multi: z.ZodOptional<z.ZodBoolean>;
82
163
  recommended: z.ZodOptional<z.ZodNumber>;
83
- deepInterview: z.ZodOptional<z.ZodObject<{
164
+ workflowGate: z.ZodOptional<z.ZodObject<{
165
+ stage: z.ZodEnum<{
166
+ "deep-interview": "deep-interview";
167
+ ralplan: "ralplan";
168
+ ultragoal: "ultragoal";
169
+ }>;
170
+ kind: z.ZodEnum<{
171
+ question: "question";
172
+ approval: "approval";
173
+ execution: "execution";
174
+ }>;
175
+ }, z.core.$strip>>;
176
+ deepInterview: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
177
+ round: z.ZodLiteral<0>;
178
+ component: z.ZodLiteral<"review-topology">;
179
+ intent_contract: z.ZodObject<{
180
+ items: z.ZodArray<z.ZodObject<{
181
+ id: z.ZodString;
182
+ category: z.ZodEnum<{
183
+ surface: "surface";
184
+ artifact: "artifact";
185
+ integration: "integration";
186
+ constraint: "constraint";
187
+ }>;
188
+ statement: z.ZodString;
189
+ }, z.core.$strict>>;
190
+ confirmation_options: z.ZodArray<z.ZodString>;
191
+ }, z.core.$strict>;
84
192
  round_id: z.ZodOptional<z.ZodString>;
193
+ dimension: z.ZodString;
194
+ ambiguity: z.ZodNumber;
195
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
196
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
197
+ reference_id: z.ZodString;
198
+ label: z.ZodString;
199
+ origin: z.ZodString;
200
+ url: z.ZodOptional<z.ZodString>;
201
+ excerpt: z.ZodOptional<z.ZodString>;
202
+ }, z.core.$strict>>>;
203
+ }, z.core.$strict>, z.ZodObject<{
85
204
  round: z.ZodNumber;
205
+ intent_review: z.ZodObject<{
206
+ observed_items: z.ZodArray<z.ZodObject<{
207
+ id: z.ZodString;
208
+ category: z.ZodEnum<{
209
+ surface: "surface";
210
+ artifact: "artifact";
211
+ integration: "integration";
212
+ constraint: "constraint";
213
+ }>;
214
+ statement: z.ZodString;
215
+ }, z.core.$strict>>;
216
+ supporting_substitutions: z.ZodArray<z.ZodObject<{
217
+ removed_id: z.ZodString;
218
+ replacement_ids: z.ZodArray<z.ZodString>;
219
+ rationale: z.ZodString;
220
+ }, z.core.$strict>>;
221
+ approval_options: z.ZodArray<z.ZodString>;
222
+ }, z.core.$strict>;
223
+ round_id: z.ZodOptional<z.ZodString>;
86
224
  component: z.ZodString;
87
225
  dimension: z.ZodString;
88
226
  ambiguity: z.ZodNumber;
89
- }, z.core.$strip>>;
227
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
228
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
229
+ reference_id: z.ZodString;
230
+ label: z.ZodString;
231
+ origin: z.ZodString;
232
+ url: z.ZodOptional<z.ZodString>;
233
+ excerpt: z.ZodOptional<z.ZodString>;
234
+ }, z.core.$strict>>>;
235
+ }, z.core.$strict>, z.ZodObject<{
236
+ round_id: z.ZodOptional<z.ZodString>;
237
+ round: z.ZodNumber;
238
+ component: z.ZodString;
239
+ dimension: z.ZodString;
240
+ ambiguity: z.ZodNumber;
241
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
242
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
243
+ reference_id: z.ZodString;
244
+ label: z.ZodString;
245
+ origin: z.ZodString;
246
+ url: z.ZodOptional<z.ZodString>;
247
+ excerpt: z.ZodOptional<z.ZodString>;
248
+ }, z.core.$strict>>>;
249
+ }, z.core.$strict>]>>;
90
250
  }, z.core.$strip>>;
91
251
  }, z.core.$strip>;
92
252
  readonly strict = true;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/coding-agent",
4
- "version": "0.5.0",
4
+ "version": "0.5.2",
5
5
  "description": "Sayknow-CLI CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -52,12 +52,12 @@
52
52
  "@agentclientprotocol/sdk": "1.3.0",
53
53
  "@babel/parser": "^7.29.3",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@sayknow-cli/stats": "0.5.0",
56
- "@sayknow-cli/agent-core": "0.5.0",
57
- "@sayknow-cli/ai": "0.5.0",
58
- "@sayknow-cli/natives": "0.5.0",
59
- "@sayknow-cli/tui": "0.5.0",
60
- "@sayknow-cli/utils": "0.5.0",
55
+ "@sayknow-cli/stats": "0.5.2",
56
+ "@sayknow-cli/agent-core": "0.5.2",
57
+ "@sayknow-cli/ai": "0.5.2",
58
+ "@sayknow-cli/natives": "0.5.2",
59
+ "@sayknow-cli/tui": "0.5.2",
60
+ "@sayknow-cli/utils": "0.5.2",
61
61
  "@puppeteer/browsers": "^2.13.0",
62
62
  "@types/turndown": "5.0.6",
63
63
  "@xterm/headless": "^6.0.0",
@@ -124,6 +124,9 @@
124
124
  "./commands/skc-runtime-bridge": null,
125
125
  "./capability/mcp": null,
126
126
  "./config/mcp-schema": null,
127
+ "./config/model-auth": null,
128
+ "./config/model-bindings-applier": null,
129
+ "./config/model-discovery-manager": null,
127
130
  "./discovery/mcp-json": null,
128
131
  "./exa": null,
129
132
  "./exa/*": null,
@@ -1,4 +1,13 @@
1
1
  import { Args, Command, Flags } from "@sayknow-cli/utils/cli";
2
+ import { readBootGeneration } from "../skc-runtime/boot-generation";
3
+ import {
4
+ decodeRestoreReference,
5
+ encodeRestoreReference,
6
+ evaluateRestoreCandidates,
7
+ listRestorePointers,
8
+ type RestoreCandidateVerdict,
9
+ } from "../skc-runtime/session-restore";
10
+ import { buildRestoreCandidateDeps, restoreSession } from "../skc-runtime/session-restore-runtime";
2
11
  import {
3
12
  attachSkcTmuxSession,
4
13
  createSkcTmuxSession,
@@ -45,12 +54,13 @@ function sessionJson(session: SessionJsonDto): SessionJsonDto {
45
54
  }
46
55
 
47
56
  export default class Session extends Command {
48
- static description = "List, inspect, attach, and remove tagged SKC-managed tmux sessions";
57
+ static description =
58
+ "List, inspect, attach, and remove tagged SKC-managed tmux sessions. `restore` is a manual, opt-in recovery step that only ever restores sessions this machine can prove died in a REBOOT: nothing starts automatically at login, a session that is still live is never duplicated, and running tools or background work are not recovered — only the tmux session, its directory, and its conversation.";
49
59
  static strict = false;
50
60
 
51
61
  static args = {
52
62
  action: Args.string({
53
- description: "list (default), status, create, attach, or remove",
63
+ description: "list (default), status, create, attach, remove, or restore",
54
64
  required: false,
55
65
  }),
56
66
  session: Args.string({
@@ -67,6 +77,15 @@ export default class Session extends Command {
67
77
  "state-file": Flags.string({
68
78
  description: "Expected @skc-session-state-file tag for force-close (defense-in-depth match)",
69
79
  }),
80
+ "dry-run": Flags.boolean({
81
+ description:
82
+ "restore: list candidates and their verdicts. Changes nothing at all (no tmux command, no lock, no database, no pointer write). This is the default when no --reference is given.",
83
+ default: false,
84
+ }),
85
+ reference: Flags.string({
86
+ description:
87
+ "restore: restore exactly one candidate by the opaque reference shown in --dry-run output. Refused unless that candidate is still reboot-eligible at this moment.",
88
+ }),
70
89
  };
71
90
 
72
91
  static examples = [
@@ -76,6 +95,8 @@ export default class Session extends Command {
76
95
  "skc session attach <session>",
77
96
  "skc session remove <session>",
78
97
  "skc session force-close <session> --session-id <id>",
98
+ "skc session restore --dry-run # list what a reboot left behind",
99
+ "skc session restore --reference <ref> # restore exactly that one",
79
100
  ];
80
101
 
81
102
  async run(): Promise<void> {
@@ -105,6 +126,71 @@ export default class Session extends Command {
105
126
  return;
106
127
  }
107
128
 
129
+ if (action === "restore") {
130
+ // Manual, reboot-proven, opt-in. Nothing here starts automatically at
131
+ // login, and a session is only ever restored when this host can prove
132
+ // the machine rebooted since that session was recorded.
133
+ const reference = flags.reference;
134
+ const decoded = reference ? decodeRestoreReference(reference) : null;
135
+ if (reference && !decoded) throw new Error("invalid_restore_reference");
136
+ const pointers = listRestorePointers().filter(
137
+ pointer =>
138
+ !decoded ||
139
+ (pointer.coordinator_session_id === decoded.coordinatorSessionId &&
140
+ pointer.state_file === decoded.stateFile),
141
+ );
142
+ if (reference && pointers.length === 0) throw new Error("restore_reference_not_found");
143
+ const verdicts = evaluateRestoreCandidates(pointers, buildRestoreCandidateDeps(readBootGeneration()));
144
+ const describe = (verdict: RestoreCandidateVerdict) => ({
145
+ reference: encodeRestoreReference(verdict.pointer.coordinator_session_id, verdict.pointer.state_file),
146
+ sessionId: verdict.pointer.skc_session_id,
147
+ cwd: verdict.pointer.cwd,
148
+ branch: verdict.pointer.branch,
149
+ eligible: verdict.eligible,
150
+ ...(verdict.eligible ? {} : { reason: verdict.reason }),
151
+ });
152
+ // Dry run is the default reporting surface and performs zero mutation:
153
+ // no pointer write, no lock, no SQLite, no tmux command.
154
+ if (flags["dry-run"] || !reference) {
155
+ const rows = verdicts.map(describe);
156
+ if (json) {
157
+ writeJson({ ok: true, dryRun: true, candidates: rows });
158
+ return;
159
+ }
160
+ writeText(
161
+ rows.length === 0
162
+ ? ["no restore candidates"]
163
+ : rows.map(row =>
164
+ [
165
+ row.eligible ? "restorable" : `skipped(${row.reason})`,
166
+ row.sessionId,
167
+ row.cwd,
168
+ row.reference,
169
+ ].join("\t"),
170
+ ),
171
+ );
172
+ return;
173
+ }
174
+ const verdict = verdicts[0];
175
+ if (!verdict) throw new Error("restore_reference_not_found");
176
+ if (!verdict.eligible) throw new Error(`restore_ineligible_${verdict.reason}`);
177
+ const outcome = restoreSession(verdict.pointer);
178
+ if (!outcome.ok) throw new Error(outcome.detail);
179
+ if (json) {
180
+ writeJson({
181
+ ok: true,
182
+ restored: {
183
+ sessionId: outcome.pointer.skc_session_id,
184
+ cwd: outcome.pointer.cwd,
185
+ tmuxSession: outcome.tmuxSession,
186
+ },
187
+ });
188
+ return;
189
+ }
190
+ writeText([`restored: ${outcome.tmuxSession} (${outcome.pointer.skc_session_id})`]);
191
+ return;
192
+ }
193
+
108
194
  if (action === "create") {
109
195
  const session = createSkcTmuxSession();
110
196
  if (json) {
@@ -1032,6 +1032,13 @@ export class ModelRegistry {
1032
1032
  #lastStaticLoadMtime: number | null = null;
1033
1033
  #registeredProviderSources: Set<string> = new Set();
1034
1034
  #providerDiscoveryStates: Map<string, ProviderDiscoveryState> = new Map();
1035
+ /**
1036
+ * Monotonic per-provider discovery sequence. Overlapping refreshes for the
1037
+ * same provider can complete out of order; only the newest-started refresh
1038
+ * may publish discovery state or contribute models, so a slow stale fetch
1039
+ * never overwrites a fresher result.
1040
+ */
1041
+ #providerDiscoverySequences: Map<string, number> = new Map();
1035
1042
  #cacheDbPath?: string;
1036
1043
  #suppressedSelectors: Map<string, number> = new Map();
1037
1044
  #backgroundRefresh?: Promise<void>;
@@ -1815,11 +1822,15 @@ export class ModelRegistry {
1815
1822
  providerConfig: DiscoveryProviderConfig,
1816
1823
  strategy: ModelRefreshStrategy,
1817
1824
  ): Promise<Model<Api>[]> {
1825
+ const sequence = (this.#providerDiscoverySequences.get(providerConfig.provider) ?? 0) + 1;
1826
+ this.#providerDiscoverySequences.set(providerConfig.provider, sequence);
1827
+ const isCurrentDiscovery = () => this.#providerDiscoverySequences.get(providerConfig.provider) === sequence;
1818
1828
  const cached = readModelCache<Api>(providerConfig.provider, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1819
1829
  const requiresAuth = !this.#keylessProviders.has(providerConfig.provider);
1820
1830
  if (requiresAuth) {
1821
1831
  const apiKey = await this.#peekApiKeyForProvider(providerConfig.provider);
1822
1832
  if (!isAuthenticated(apiKey)) {
1833
+ if (!isCurrentDiscovery()) return [];
1823
1834
  this.#providerDiscoveryStates.set(providerConfig.provider, {
1824
1835
  provider: providerConfig.provider,
1825
1836
  status: "unauthenticated",
@@ -1865,6 +1876,7 @@ export class ModelRegistry {
1865
1876
  : result.models.length > 0
1866
1877
  ? "ok"
1867
1878
  : "empty";
1879
+ if (!isCurrentDiscovery()) return [];
1868
1880
  this.#providerDiscoveryStates.set(providerId, {
1869
1881
  provider: providerId,
1870
1882
  status,
@@ -10,11 +10,18 @@
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { getConfigRootDir, isCompiledBinary, logger } from "@sayknow-cli/utils";
13
- import { settings } from "./settings";
13
+ import { settings as globalSettings, type Settings } from "./settings";
14
14
  import type { TelegramSettings } from "./settings-schema";
15
15
  import { telegramSettingsToEnv, validateTelegramSettings } from "./telegram-env-bridge";
16
16
 
17
- function readTelegramSettings(): TelegramSettings {
17
+ /**
18
+ * The startup command runs against an explicit Settings instance (tests and
19
+ * embedders inject one), so read through it instead of the global proxy —
20
+ * which may not be initialized at all on those paths.
21
+ */
22
+ type TelegramSettingsSource = Pick<Settings, "get">;
23
+
24
+ function readTelegramSettings(settings: TelegramSettingsSource): TelegramSettings {
18
25
  return {
19
26
  enabled: settings.get("telegram.enabled"),
20
27
  botToken: settings.get("telegram.botToken"),
@@ -69,10 +76,10 @@ function isAlreadyRunning(pidFile: string): boolean {
69
76
  * it in the background. Never throws — failures are logged as warnings so the
70
77
  * main skc session is unaffected.
71
78
  */
72
- export async function maybeAutostartTelegramRemote(): Promise<void> {
79
+ export async function maybeAutostartTelegramRemote(settings: TelegramSettingsSource = globalSettings): Promise<void> {
73
80
  if (!settings.get("telegram.enabled")) return;
74
81
 
75
- const config = readTelegramSettings();
82
+ const config = readTelegramSettings(settings);
76
83
  const errors = validateTelegramSettings(config);
77
84
  if (errors.length > 0) {
78
85
  logger.warn("Telegram Remote enabled but misconfigured, skipping autostart", { errors });