@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
@@ -35,19 +35,29 @@ import {
35
35
  SKC_TMUX_VERSION_OPTION,
36
36
  } from "./tmux-common";
37
37
  import {
38
+ type AbandonedIdentityCensus,
39
+ abandonIdentityCreate,
40
+ advanceIdentityCreatePhase,
38
41
  captureOwnerGenerationBaselineSync,
39
42
  classifyCgroup,
40
43
  closeExactTmuxOwner,
41
44
  executeTmuxOwnerIsolationPlanSync,
45
+ type IdentityCreateKey,
46
+ type IdentityCreateReservation,
47
+ type IdentityCreateReservationResult,
42
48
  isOwnerGenerationBaselineCurrentSync,
43
49
  isValidOwnerVerdict,
44
50
  lifecyclePaths,
51
+ type OwnerGenerationBaseline,
45
52
  type OwnerIsolationProbeSync,
46
53
  type OwnerVerdict,
47
54
  observeOwnerTerminal,
48
55
  type PlanResponse,
49
56
  planTmuxOwnerIsolationSync,
57
+ recoverAbandonedIdentityCreate,
58
+ releaseIdentityCreate,
50
59
  replaceOwnerGenerationSync,
60
+ reserveIdentityCreate,
51
61
  type TmuxOwnerIsolationExecutionDependencies,
52
62
  type TmuxOwnerIsolationExecutionResult,
53
63
  type TmuxServerProof,
@@ -134,6 +144,17 @@ const FORCE_CLOSE_VERDICT_POLL_MS = 50;
134
144
 
135
145
  export interface CreateSkcTmuxSessionOptions {
136
146
  platform?: NodeJS.Platform;
147
+ /**
148
+ * Restore only: run the child in the session's recorded directory instead of
149
+ * this process's cwd.
150
+ */
151
+ cwd?: string;
152
+ /**
153
+ * Restore only: argv appended to the `skc` child, e.g. `["--resume", "<id>"]`.
154
+ * Restore reuses this creator verbatim so it inherits the same identity fence
155
+ * and owner-isolation proof as an ordinary create.
156
+ */
157
+ childArgs?: string[];
137
158
  }
138
159
 
139
160
  export type CreateOwnerIsolationTestDependencies = {
@@ -362,6 +383,113 @@ export function statusSkcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv
362
383
  throw new Error(`skc_tmux_session_not_found:${sessionName}`);
363
384
  }
364
385
 
386
+ /**
387
+ * Live-tmux census for authority-first recovery of an abandoned reservation.
388
+ *
389
+ * Authority is the CURRENT published generation on disk compared against the
390
+ * live session's canonical tags — never the reservation's own `phase`, because a
391
+ * creator can publish and then die before recording that it published.
392
+ */
393
+ /**
394
+ * Resolves a reservation refused because its previous owner died after reaching
395
+ * the helper. Shared by every producer so none of them can brick an identity by
396
+ * treating an abandoned row as a permanent collision.
397
+ */
398
+ export function settleAbandonedIdentity(
399
+ reserved: IdentityCreateReservationResult,
400
+ identityKey: IdentityCreateKey,
401
+ census: AbandonedIdentityCensus,
402
+ ): IdentityCreateReservationResult {
403
+ if (reserved.ok) return reserved;
404
+ if (reserved.code !== "identity_orphan_unresolved" || !reserved.existing) return reserved;
405
+ return recoverAbandonedIdentityCreate(identityKey, reserved.existing, census);
406
+ }
407
+
408
+ export function abandonedIdentityCensus(
409
+ tmuxCommand: string,
410
+ env: NodeJS.ProcessEnv,
411
+ stateFile: string,
412
+ ): AbandonedIdentityCensus {
413
+ return {
414
+ inspect: evidence => {
415
+ // Cleanup uncertainty covers a child abandoned BEFORE it was tagged, so a
416
+ // profile-filtered listing would report `absent` for exactly the sessions
417
+ // this census exists to find. Check the raw name list first.
418
+ let rawNames: string[];
419
+ try {
420
+ rawNames = listRawTmuxSessionNames(env);
421
+ } catch (error) {
422
+ return { kind: "unknown", reason: `list_failed:${error instanceof Error ? error.message : "error"}` };
423
+ }
424
+ let sessions: SkcTmuxSessionStatus[];
425
+ try {
426
+ sessions = listSkcTmuxSessions(env);
427
+ } catch (error) {
428
+ return { kind: "unknown", reason: `list_failed:${error instanceof Error ? error.message : "error"}` };
429
+ }
430
+ const match = sessions.find(
431
+ session =>
432
+ (evidence.attemptSessionName !== null && session.name === evidence.attemptSessionName) ||
433
+ (session.sessionId === evidence.sessionId && session.sessionStateFile === stateFile),
434
+ );
435
+ if (!match) {
436
+ // An untagged remnant still occupies the attempt name. It carries no
437
+ // canonical proof, so it can be neither authority nor a safe kill
438
+ // target from here: refuse rather than reclaim over it.
439
+ if (evidence.attemptSessionName !== null && rawNames.includes(evidence.attemptSessionName)) {
440
+ return { kind: "unknown", reason: "untagged_attempt_remnant" };
441
+ }
442
+ return { kind: "absent" };
443
+ }
444
+ let nativeSessionId: string | undefined;
445
+ try {
446
+ nativeSessionId = evidence.nativeSessionId ?? readNativeTmuxSessionId(match.name, env);
447
+ } catch {
448
+ nativeSessionId = undefined;
449
+ }
450
+ if (!nativeSessionId) return { kind: "unknown", reason: "native_session_id_unavailable" };
451
+ let published: OwnerGenerationBaseline;
452
+ try {
453
+ published = captureOwnerGenerationBaselineSync(evidence.stateDir, evidence.sessionId);
454
+ } catch (error) {
455
+ return {
456
+ kind: "unknown",
457
+ reason: `generation_unreadable:${error instanceof Error ? error.message : "error"}`,
458
+ };
459
+ }
460
+ // A missing, corrupt or unreadable generation must never be downgraded to
461
+ // "orphan": that would authorize killing a session we cannot judge.
462
+ if (published.state !== "current") return { kind: "unknown", reason: `generation_${published.state}` };
463
+ if (!match.sessionId || !match.sessionStateFile || !match.ownerGeneration)
464
+ return { kind: "unknown", reason: "incomplete_canonical_tags" };
465
+ if (match.sessionId !== evidence.sessionId || match.sessionStateFile !== stateFile)
466
+ return { kind: "unknown", reason: "tag_identity_mismatch" };
467
+ return match.ownerGeneration === published.generation
468
+ ? { kind: "authoritative", nativeSessionId }
469
+ : { kind: "orphan", nativeSessionId };
470
+ },
471
+ cleanupOrphan: (nativeSessionId, attemptSessionName) => {
472
+ const server = requireSafeTmuxServerForMutation(tmuxCommand, env);
473
+ cleanupExactCreatedTmuxSession(
474
+ nativeSessionId,
475
+ attemptSessionName ?? nativeSessionId,
476
+ tmuxCommand,
477
+ env,
478
+ server.pid,
479
+ server.startTime,
480
+ );
481
+ },
482
+ };
483
+ }
484
+
485
+ /**
486
+ * A lost reservation means another process already recovered this identity.
487
+ * The caller must perform no further tmux mutation.
488
+ */
489
+ function fenceLost(): never {
490
+ throw new Error("skc_tmux_identity_create_fence_lost");
491
+ }
492
+
365
493
  export function createSkcTmuxSession(
366
494
  env: NodeJS.ProcessEnv = process.env,
367
495
  options: CreateSkcTmuxSessionOptions = {},
@@ -369,7 +497,7 @@ export function createSkcTmuxSession(
369
497
  const platform = options.platform ?? process.platform;
370
498
  const tmuxCommand = resolveSkcTmuxCommand(env, platform);
371
499
  const sessionName = buildSkcTmuxSessionName(env);
372
- const cwd = process.cwd();
500
+ const cwd = options.cwd ?? process.cwd();
373
501
  const sessionId = env[SKC_COORDINATOR_SESSION_ID_ENV]?.trim() || sessionName;
374
502
  const stateFile =
375
503
  env[SKC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim() ||
@@ -385,12 +513,13 @@ export function createSkcTmuxSession(
385
513
  [SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: stateFile,
386
514
  };
387
515
  const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
516
+ const childArgs = options.childArgs ?? [];
388
517
  const command =
389
518
  platform === "win32"
390
- ? buildWindowsPowerShellInnerCommand({ command: ["skc"], environment: childEnvironment })
391
- : `exec env ${Object.entries(childEnvironment)
519
+ ? buildWindowsPowerShellInnerCommand({ command: ["skc", ...childArgs], environment: childEnvironment })
520
+ : `cd ${shellQuote(cwd)} && exec env ${Object.entries(childEnvironment)
392
521
  .map(([name, value]) => `${name}=${shellQuote(value)}`)
393
- .join(" ")} skc`;
522
+ .join(" ")} skc${childArgs.map(argument => ` ${shellQuote(argument)}`).join("")}`;
394
523
  const tmuxArgv = [
395
524
  tmuxCommand,
396
525
  "new-session",
@@ -492,100 +621,157 @@ export function createSkcTmuxSession(
492
621
  if (resolveSkcTmuxBinary({ env, platform }).isPsmux)
493
622
  throw new Error("skc_tmux_owner_isolation_native_session_identity_unavailable");
494
623
 
495
- const baseline = captureOwnerGenerationBaselineSync(stateDir, sessionId);
496
- const ownerPlan = planTmuxOwnerIsolationSync(
497
- {
498
- schema_version: 1,
499
- op: "plan",
500
- platform,
501
- session_id: sessionId,
502
- owner_generation: generation,
503
- baseline,
504
- cwd,
505
- state_dir: stateDir,
506
- socket_key: tmuxCommand,
507
- tmux_argv: tmuxArgv,
508
- },
509
- ownerProbe,
510
- );
511
- if (!ownerPlan.ok) throw new Error(`skc_tmux_owner_isolation_${ownerPlan.code}:${ownerPlan.diagnostic}`);
512
-
513
- const outcome = (createOwnerIsolationTestDependencies?.execute ?? executeTmuxOwnerIsolationPlanSync)(ownerPlan, {
514
- socketKey: tmuxCommand,
515
- spawn: (argv, stdinLine) => {
516
- const result = stdinLine
517
- ? Bun.spawnSync(argv, {
518
- stdout: "pipe",
519
- stderr: "pipe",
520
- stdin: Buffer.from(stdinLine),
521
- env,
522
- })
523
- : Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe", env });
524
- return { exitCode: result.exitCode, stdout: result.stdout.toString() };
525
- },
526
- probeServer: ownerProbe.probeServer,
527
- isCurrentGeneration: () => isOwnerGenerationBaselineCurrentSync(stateDir, sessionId, baseline),
528
- cleanupSpawned: ({ execution, nativeSessionId, server }) => {
529
- if (!server.pid || !server.startTime) throw new Error("skc_tmux_cleanup_target_changed");
530
- cleanupExactCreatedTmuxSession(
531
- nativeSessionId,
532
- execution.attempt_session,
533
- tmuxCommand,
534
- env,
535
- server.pid,
536
- server.startTime,
537
- );
538
- },
539
- });
540
- if (!outcome.ok) throw new Error(`skc_tmux_owner_isolation_${outcome.code}:${outcome.diagnostic}`);
541
-
542
- const nativeSessionId = outcome.native_session_id;
543
- if (!nativeSessionId) throw new Error("skc_tmux_owner_isolation_native_session_identity_unavailable");
544
- const server = requireSafeTmuxServerForMutation(tmuxCommand, env);
545
- if (server.pid !== outcome.server_pid || server.startTime !== outcome.server_start_time)
546
- throw new Error("skc_tmux_owner_changed_after_create");
547
- if (!isNativeTmuxSessionBoundToName(nativeSessionId, sessionName, env))
548
- throw new Error("skc_tmux_owner_changed_after_create");
549
-
624
+ // Shared identity create fence. `SessionManager` has no inter-process
625
+ // single-writer lock on a transcript, so two owners for one canonical
626
+ // (stateDir, sessionId) is a data-integrity failure rather than a
627
+ // recoverable skip. The reservation is a durable row, not a held SQLite
628
+ // transaction: the owner-isolation helper below runs in its own process and
629
+ // acquires the same database itself, so holding a transaction across the
630
+ // `Bun.spawnSync` would deadlock the helper against its own parent.
631
+ const identityKey = { stateDir, sessionId, stateFile };
632
+ const reserved = reserveIdentityCreate(identityKey);
633
+ // Fail closed. The fence database lives under SKC's own config root, so it is
634
+ // always reachable for a native-identity provider; an unreachable fence means
635
+ // the one-create guarantee cannot be honored and creating anyway could put a
636
+ // second writer on this identity's transcript.
637
+ // A proven-dead owner that reached the helper may have left an untagged child.
638
+ // Resolve it authority-first OUTSIDE the fence window before creating anything.
639
+ const settled = settleAbandonedIdentity(reserved, identityKey, abandonedIdentityCensus(tmuxCommand, env, stateFile));
640
+ if (!settled.ok) throw new Error(`skc_tmux_identity_create_${settled.code}:${settled.diagnostic}`);
641
+ let reservation: IdentityCreateReservation | null = settled.reservation;
642
+ let cleanupUncertain = false;
550
643
  try {
551
- tagCreatedTmuxSession(
552
- nativeSessionId,
553
- sessionName,
554
- outcome.server_pid,
555
- env,
644
+ const baseline = captureOwnerGenerationBaselineSync(stateDir, sessionId);
645
+ const ownerPlan = planTmuxOwnerIsolationSync(
556
646
  {
557
- sessionId,
558
- sessionStateFile: stateFile,
559
- ownerGeneration: generation,
560
- ownerServerKey: tmuxCommand,
561
- version: env.npm_package_version ?? null,
647
+ schema_version: 1,
648
+ op: "plan",
649
+ platform,
650
+ session_id: sessionId,
651
+ owner_generation: generation,
652
+ baseline,
653
+ cwd,
654
+ state_dir: stateDir,
655
+ socket_key: tmuxCommand,
656
+ tmux_argv: tmuxArgv,
562
657
  },
563
- tmuxCommand,
658
+ ownerProbe,
564
659
  );
565
- } catch (tagError) {
660
+ if (!ownerPlan.ok) throw new Error(`skc_tmux_owner_isolation_${ownerPlan.code}:${ownerPlan.diagnostic}`);
661
+
662
+ // Record the attempt session name BEFORE the helper can create anything.
663
+ // Creator names are random, so this row is the only way a successor can name
664
+ // an orphan left behind by a creator that died mid-spawn.
665
+ if (reservation)
666
+ reservation =
667
+ advanceIdentityCreatePhase(reservation, "helper_invoked", { attemptSessionName: sessionName }) ??
668
+ fenceLost();
669
+
670
+ const outcome = (createOwnerIsolationTestDependencies?.execute ?? executeTmuxOwnerIsolationPlanSync)(ownerPlan, {
671
+ socketKey: tmuxCommand,
672
+ spawn: (argv, stdinLine) => {
673
+ const result = stdinLine
674
+ ? Bun.spawnSync(argv, {
675
+ stdout: "pipe",
676
+ stderr: "pipe",
677
+ stdin: Buffer.from(stdinLine),
678
+ env,
679
+ })
680
+ : Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe", env });
681
+ return { exitCode: result.exitCode, stdout: result.stdout.toString() };
682
+ },
683
+ probeServer: ownerProbe.probeServer,
684
+ isCurrentGeneration: () => isOwnerGenerationBaselineCurrentSync(stateDir, sessionId, baseline),
685
+ cleanupSpawned: ({ execution, nativeSessionId, server }) => {
686
+ if (!server.pid || !server.startTime) throw new Error("skc_tmux_cleanup_target_changed");
687
+ cleanupExactCreatedTmuxSession(
688
+ nativeSessionId,
689
+ execution.attempt_session,
690
+ tmuxCommand,
691
+ env,
692
+ server.pid,
693
+ server.startTime,
694
+ );
695
+ },
696
+ });
697
+ if (!outcome.ok) throw new Error(`skc_tmux_owner_isolation_${outcome.code}:${outcome.diagnostic}`);
698
+
699
+ const nativeSessionId = outcome.native_session_id;
700
+ if (!nativeSessionId) throw new Error("skc_tmux_owner_isolation_native_session_identity_unavailable");
701
+ const server = requireSafeTmuxServerForMutation(tmuxCommand, env);
702
+ if (server.pid !== outcome.server_pid || server.startTime !== outcome.server_start_time)
703
+ throw new Error("skc_tmux_owner_changed_after_create");
704
+ if (!isNativeTmuxSessionBoundToName(nativeSessionId, sessionName, env))
705
+ throw new Error("skc_tmux_owner_changed_after_create");
706
+ if (reservation)
707
+ reservation =
708
+ advanceIdentityCreatePhase(reservation, "spawned", {
709
+ nativeSessionId,
710
+ serverPid: outcome.server_pid ?? null,
711
+ serverStartTime: outcome.server_start_time ?? null,
712
+ }) ?? fenceLost();
713
+
566
714
  try {
567
- cleanupExactCreatedTmuxSession(
715
+ tagCreatedTmuxSession(
568
716
  nativeSessionId,
569
717
  sessionName,
570
- tmuxCommand,
571
- env,
572
718
  outcome.server_pid,
573
- outcome.server_start_time,
719
+ env,
720
+ {
721
+ sessionId,
722
+ sessionStateFile: stateFile,
723
+ ownerGeneration: generation,
724
+ ownerServerKey: tmuxCommand,
725
+ version: env.npm_package_version ?? null,
726
+ },
727
+ tmuxCommand,
574
728
  );
575
- } catch (cleanupError) {
576
- throw new AggregateError([tagError, cleanupError], "skc_tmux_profile_tag_failed_cleanup_failed");
729
+ } catch (tagError) {
730
+ try {
731
+ cleanupExactCreatedTmuxSession(
732
+ nativeSessionId,
733
+ sessionName,
734
+ tmuxCommand,
735
+ env,
736
+ outcome.server_pid,
737
+ outcome.server_start_time,
738
+ );
739
+ } catch (cleanupError) {
740
+ throw new AggregateError([tagError, cleanupError], "skc_tmux_profile_tag_failed_cleanup_failed");
741
+ }
742
+ throw tagError;
577
743
  }
578
- throw tagError;
579
- }
580
744
 
581
- if (nativeSessionId) {
582
- const firstServer = requireSafeTmuxServerForMutation(tmuxCommand, env);
583
- if (firstServer.pid !== outcome.server_pid || firstServer.startTime !== outcome.server_start_time)
584
- throw new Error("skc_tmux_owner_changed_after_create");
585
- const status = statusSkcTmuxSessionByNativeId(nativeSessionId, env);
586
- const finalServer = requireSafeTmuxServerForMutation(tmuxCommand, env);
587
- if (finalServer.pid !== firstServer.pid || finalServer.startTime !== firstServer.startTime)
588
- throw new Error("skc_tmux_owner_changed_after_create");
745
+ if (nativeSessionId) {
746
+ const firstServer = requireSafeTmuxServerForMutation(tmuxCommand, env);
747
+ if (firstServer.pid !== outcome.server_pid || firstServer.startTime !== outcome.server_start_time)
748
+ throw new Error("skc_tmux_owner_changed_after_create");
749
+ const status = statusSkcTmuxSessionByNativeId(nativeSessionId, env);
750
+ const finalServer = requireSafeTmuxServerForMutation(tmuxCommand, env);
751
+ if (finalServer.pid !== firstServer.pid || finalServer.startTime !== firstServer.startTime)
752
+ throw new Error("skc_tmux_owner_changed_after_create");
753
+ try {
754
+ replaceOwnerGenerationSync(stateDir, sessionId, generation, baseline);
755
+ } catch (publicationError) {
756
+ try {
757
+ cleanupExactCreatedTmuxSession(
758
+ nativeSessionId,
759
+ sessionName,
760
+ tmuxCommand,
761
+ env,
762
+ outcome.server_pid,
763
+ outcome.server_start_time,
764
+ );
765
+ } catch (cleanupError) {
766
+ throw new AggregateError(
767
+ [publicationError, cleanupError],
768
+ "skc_tmux_owner_generation_publish_failed_cleanup_failed",
769
+ );
770
+ }
771
+ throw publicationError;
772
+ }
773
+ return status;
774
+ }
589
775
  try {
590
776
  replaceOwnerGenerationSync(stateDir, sessionId, generation, baseline);
591
777
  } catch (publicationError) {
@@ -606,29 +792,19 @@ export function createSkcTmuxSession(
606
792
  }
607
793
  throw publicationError;
608
794
  }
609
- return status;
610
- }
611
- try {
612
- replaceOwnerGenerationSync(stateDir, sessionId, generation, baseline);
613
- } catch (publicationError) {
614
- try {
615
- cleanupExactCreatedTmuxSession(
616
- nativeSessionId,
617
- sessionName,
618
- tmuxCommand,
619
- env,
620
- outcome.server_pid,
621
- outcome.server_start_time,
622
- );
623
- } catch (cleanupError) {
624
- throw new AggregateError(
625
- [publicationError, cleanupError],
626
- "skc_tmux_owner_generation_publish_failed_cleanup_failed",
627
- );
795
+ return statusSkcTmuxSession(sessionName, env);
796
+ } catch (error) {
797
+ // A failed cleanup means a child may still exist that we could not remove.
798
+ // Releasing here would erase the only durable evidence a successor has, so
799
+ // the row stays and authority-first recovery resolves it later.
800
+ if (error instanceof AggregateError && /cleanup_failed/.test(String(error.message))) cleanupUncertain = true;
801
+ throw error;
802
+ } finally {
803
+ if (reservation) {
804
+ if (cleanupUncertain) abandonIdentityCreate(reservation);
805
+ else releaseIdentityCreate(reservation);
628
806
  }
629
- throw publicationError;
630
807
  }
631
- return statusSkcTmuxSession(sessionName, env);
632
808
  }
633
809
 
634
810
  function statusSkcTmuxSessionByNativeId(nativeSessionId: string, env: NodeJS.ProcessEnv): SkcTmuxSessionStatus {
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@sayknow-cli/agent-core";
4
4
  import { type Model, modelsAreEqual } from "@sayknow-cli/ai";
5
5
  import { getOAuthProviders } from "@sayknow-cli/ai/utils/oauth";
6
- import { PET_SKINS, type PetMode, Spacer, Text } from "@sayknow-cli/tui";
6
+ import { PET_SKIN_IDS, PET_SKINS, type PetMode, Spacer, Text } from "@sayknow-cli/tui";
7
7
  import { setProjectDir } from "@sayknow-cli/utils";
8
8
  import { jobElapsedMs } from "../async";
9
9
  import { materializeActiveModelProfileAssignments } from "../config/model-profile-activation";
@@ -78,18 +78,23 @@ function canClearComposer(runtime: BuiltinSlashCommandRuntime): boolean {
78
78
 
79
79
  const PET_COMMAND_OPTIONS: ReadonlyArray<{ name: string; mode: PetMode; description: string }> = [
80
80
  { name: "off", mode: "off", description: "Hide the pet" },
81
- { name: "RedSayknow", mode: "red", description: PET_SKINS.red.description },
82
- { name: "BlueSayknow", mode: "blue", description: PET_SKINS.blue.description },
81
+ // Names come from the skin registry so `/pet`, both selectors and the
82
+ // confirmation status line always speak the same skin name.
83
+ ...PET_SKIN_IDS.map(id => ({ name: PET_SKINS[id].label, mode: id, description: PET_SKINS[id].description })),
83
84
  ];
84
85
  const PET_COMMAND_HINT = `[${PET_COMMAND_OPTIONS.map(option => option.name).join("|")}]`;
85
86
  /**
86
- * Deprecated inputs kept accepted for compatibility (`/pet on|red|blue`).
87
+ * Deprecated inputs kept accepted for compatibility (`/pet on|red|blue`, plus the
88
+ * `RedSayknow`/`BlueSayknow` spellings this command accepted before the skin
89
+ * registry became its only name source).
87
90
  * Display, completion, and inline hints stay canonical (`PET_COMMAND_OPTIONS`).
88
91
  */
89
92
  const PET_COMMAND_DEPRECATED_INPUTS: Readonly<Record<string, PetMode>> = {
90
93
  on: "red",
91
94
  red: "red",
92
95
  blue: "blue",
96
+ redsayknow: "red",
97
+ bluesayknow: "blue",
93
98
  };
94
99
 
95
100
  type SkcModelBatchAssignmentTargetId = "all-role-agents" | "all-targets";