@sayknow-cli/coding-agent 0.5.1 → 0.5.6

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 (122) hide show
  1. package/CHANGELOG.md +185 -14
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/commands/session.d.ts +7 -0
  4. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-registry.d.ts +1 -1
  7. package/dist/types/config/models-config-schema.d.ts +4 -0
  8. package/dist/types/config/settings-schema.d.ts +9 -0
  9. package/dist/types/config/telegram-autostart.d.ts +9 -1
  10. package/dist/types/dap/client.d.ts +1 -0
  11. package/dist/types/lsp/client.d.ts +5 -0
  12. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  13. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  14. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  15. package/dist/types/modes/components/welcome.d.ts +1 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +13 -1
  18. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  19. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  20. package/dist/types/modes/types.d.ts +7 -1
  21. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  22. package/dist/types/session/agent-session.d.ts +9 -0
  23. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  24. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  25. package/dist/types/session/session-manager.d.ts +9 -0
  26. package/dist/types/session/session-storage.d.ts +17 -0
  27. package/dist/types/session-import/claude.d.ts +26 -0
  28. package/dist/types/session-import/codex.d.ts +22 -0
  29. package/dist/types/session-import/command.d.ts +20 -0
  30. package/dist/types/session-import/detect.d.ts +22 -0
  31. package/dist/types/session-import/index.d.ts +7 -0
  32. package/dist/types/session-import/redact.d.ts +23 -0
  33. package/dist/types/session-import/service.d.ts +28 -0
  34. package/dist/types/session-import/types.d.ts +125 -0
  35. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  36. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  37. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  38. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  39. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  40. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  41. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  42. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  43. package/dist/types/slash-commands/types.d.ts +5 -0
  44. package/dist/types/tools/ask.d.ts +169 -4
  45. package/package.json +16 -7
  46. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  47. package/src/cli/config-cli.ts +23 -8
  48. package/src/cli/update-cli.ts +265 -21
  49. package/src/commands/session.ts +88 -2
  50. package/src/config/atomic-yaml-patch.ts +167 -30
  51. package/src/config/keybindings.ts +10 -0
  52. package/src/config/model-profiles.ts +69 -1
  53. package/src/config/model-registry.ts +72 -38
  54. package/src/config/models-config-schema.ts +1 -1
  55. package/src/config/settings-schema.ts +9 -0
  56. package/src/config/settings.ts +13 -2
  57. package/src/config/telegram-autostart.ts +11 -4
  58. package/src/dap/client.ts +78 -30
  59. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  60. package/src/internal-urls/docs-index.generated.ts +5 -4
  61. package/src/lsp/client.ts +17 -29
  62. package/src/main.ts +1 -1
  63. package/src/modes/action-registry.ts +2 -0
  64. package/src/modes/components/pet-capability.ts +22 -13
  65. package/src/modes/components/pet-selector.ts +1 -1
  66. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  67. package/src/modes/components/welcome.ts +5 -0
  68. package/src/modes/controllers/event-controller.ts +1 -1
  69. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  70. package/src/modes/controllers/input-controller.ts +71 -6
  71. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  72. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  73. package/src/modes/controllers/selector-controller.ts +47 -19
  74. package/src/modes/interactive-mode.ts +54 -4
  75. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  76. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  77. package/src/modes/types.ts +5 -1
  78. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  79. package/src/prompts/system/eager-todo.md +2 -0
  80. package/src/prompts/system/plan-mode-approved.md +1 -1
  81. package/src/prompts/system/system-prompt.md +4 -2
  82. package/src/runtime-mcp/transports/stdio.ts +96 -33
  83. package/src/sdk/broker/lifecycle.ts +6 -5
  84. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  85. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  86. package/src/sdk/session.ts +25 -18
  87. package/src/session/agent-session.ts +120 -25
  88. package/src/session/internal/managed-session-scope.ts +41 -6
  89. package/src/session/internal/managed-session-storage.ts +14 -0
  90. package/src/session/session-manager.ts +113 -11
  91. package/src/session/session-storage.ts +70 -0
  92. package/src/session-import/claude.ts +382 -0
  93. package/src/session-import/codex.ts +458 -0
  94. package/src/session-import/command.ts +61 -0
  95. package/src/session-import/detect.ts +133 -0
  96. package/src/session-import/index.ts +27 -0
  97. package/src/session-import/redact.ts +125 -0
  98. package/src/session-import/service.ts +749 -0
  99. package/src/session-import/types.ts +163 -0
  100. package/src/skc-runtime/boot-generation.ts +172 -0
  101. package/src/skc-runtime/launch-tmux.ts +219 -41
  102. package/src/skc-runtime/launch-worktree.ts +277 -4
  103. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  104. package/src/skc-runtime/session-restore.ts +296 -0
  105. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  106. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  107. package/src/skc-runtime/tmux-sessions.ts +284 -108
  108. package/src/slash-commands/acp-builtins.ts +5 -1
  109. package/src/slash-commands/builtin-registry.ts +109 -5
  110. package/src/slash-commands/types.ts +5 -0
  111. package/src/tools/ask.ts +188 -10
  112. package/src/tools/eval.ts +2 -2
  113. package/src/export/html/template.css +0 -1060
  114. package/src/export/html/template.html +0 -47
  115. package/src/export/html/template.js +0 -2348
  116. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  117. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  118. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  119. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  120. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  121. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  122. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -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 {
@@ -15,7 +15,7 @@ export type { AcpBuiltinCommandRuntime, AcpBuiltinSlashCommandResult } from "./t
15
15
  * see commands it cannot drive.
16
16
  */
17
17
  export const ACP_BUILTIN_SLASH_COMMANDS: AvailableCommand[] = BUILTIN_SLASH_COMMANDS_INTERNAL.filter(
18
- command => command.handle !== undefined,
18
+ command => command.handle !== undefined && command.acp !== false,
19
19
  ).map(command => {
20
20
  // Honor mode-specific copy: ACP clients receive concise text-mode
21
21
  // descriptions/hints when the spec sets `acpDescription` / `acpInputHint`,
@@ -43,6 +43,10 @@ export async function executeAcpBuiltinSlashCommand(
43
43
  const parsed = parseSlashCommand(text);
44
44
  if (!parsed) return false;
45
45
  const command = lookupBuiltinSlashCommand(parsed.name);
46
+ if (command?.acp === false) {
47
+ await runtime.output(`/${parsed.name} is not available over ACP.`);
48
+ return { consumed: true };
49
+ }
46
50
  if (!command?.handle) {
47
51
  const diagnostic = formatUnknownBuiltinSlashCommandDiagnostic(parsed.name);
48
52
  if (!diagnostic) return false;
@@ -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";
@@ -41,6 +41,7 @@ import {
41
41
  sendNotificationTest,
42
42
  } from "../sdk/bus/notification-service";
43
43
  import { computeCacheMissCostSummary, formatCacheMissSummaryLines } from "../session/cache-economics";
44
+ import { formatSessionImportSummary, runSessionImportCommand } from "../session-import";
44
45
  import { formatModelOnboardingGuidance } from "../setup/model-onboarding-guidance";
45
46
  import {
46
47
  addApiCompatibleProvider,
@@ -78,18 +79,23 @@ function canClearComposer(runtime: BuiltinSlashCommandRuntime): boolean {
78
79
 
79
80
  const PET_COMMAND_OPTIONS: ReadonlyArray<{ name: string; mode: PetMode; description: string }> = [
80
81
  { 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 },
82
+ // Names come from the skin registry so `/pet`, both selectors and the
83
+ // confirmation status line always speak the same skin name.
84
+ ...PET_SKIN_IDS.map(id => ({ name: PET_SKINS[id].label, mode: id, description: PET_SKINS[id].description })),
83
85
  ];
84
86
  const PET_COMMAND_HINT = `[${PET_COMMAND_OPTIONS.map(option => option.name).join("|")}]`;
85
87
  /**
86
- * Deprecated inputs kept accepted for compatibility (`/pet on|red|blue`).
88
+ * Deprecated inputs kept accepted for compatibility (`/pet on|red|blue`, plus the
89
+ * `RedSayknow`/`BlueSayknow` spellings this command accepted before the skin
90
+ * registry became its only name source).
87
91
  * Display, completion, and inline hints stay canonical (`PET_COMMAND_OPTIONS`).
88
92
  */
89
93
  const PET_COMMAND_DEPRECATED_INPUTS: Readonly<Record<string, PetMode>> = {
90
94
  on: "red",
91
95
  red: "red",
92
96
  blue: "blue",
97
+ redsayknow: "red",
98
+ bluesayknow: "blue",
93
99
  };
94
100
 
95
101
  type SkcModelBatchAssignmentTargetId = "all-role-agents" | "all-targets";
@@ -505,6 +511,93 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
505
511
  return commandConsumed();
506
512
  };
507
513
 
514
+ type SessionImportRunner = typeof runSessionImportCommand;
515
+
516
+ function isBusySessionImportActivation(error: unknown): boolean {
517
+ return typeof error === "object" && error !== null && "code" in error && error.code === "busy";
518
+ }
519
+
520
+ export async function handleImportSessionCommand(
521
+ command: ParsedSlashCommand,
522
+ runtime: SlashCommandRuntime,
523
+ importer: SessionImportRunner = runSessionImportCommand,
524
+ ): Promise<SlashCommandResult> {
525
+ if (runtime.session.isStreaming)
526
+ return usage("Cannot import a session while a response is streaming; wait for it to finish.", runtime);
527
+ const outcome = await importer(command.args, runtime.cwd);
528
+ if (outcome.kind === "error") return usage(outcome.message, runtime);
529
+ const importTargetLabel = outcome.result.reused ? "reused imported session" : "newly published imported session";
530
+ if (runtime.session.isStreaming)
531
+ return usage(
532
+ `Import completed, but the ${importTargetLabel} was not activated because a response started streaming. Resume it later from the session picker.`,
533
+ runtime,
534
+ );
535
+ let switched: boolean;
536
+ try {
537
+ switched = await runtime.session.switchSession(outcome.result.targetPath, { requireIdle: true });
538
+ } catch (error) {
539
+ return usage(
540
+ isBusySessionImportActivation(error)
541
+ ? `Import completed, but the ${importTargetLabel} was not activated because a response or turn started. Resume it later from the session picker.`
542
+ : `Import completed, but the ${importTargetLabel} could not be activated. Resume it later from the session picker.`,
543
+ runtime,
544
+ );
545
+ }
546
+ if (!switched)
547
+ return usage(
548
+ `${outcome.result.reused ? "Import found an existing imported session" : "Import created a new session"}, but switching to it was cancelled. Resume it later from the session picker.`,
549
+ runtime,
550
+ );
551
+ await runtime.notifyTitleChanged?.();
552
+ await runtime.output(formatSessionImportSummary(outcome.result));
553
+ return commandConsumed();
554
+ }
555
+
556
+ export async function handleImportSessionTuiCommand(
557
+ command: ParsedSlashCommand,
558
+ runtime: TuiSlashCommandRuntime,
559
+ importer: SessionImportRunner = runSessionImportCommand,
560
+ ): Promise<SlashCommandResult> {
561
+ runtime.ctx.editor.setText("");
562
+ if (runtime.ctx.session.isStreaming) {
563
+ runtime.ctx.showError("Cannot import a session while a response is streaming; wait for it to finish.");
564
+ return;
565
+ }
566
+ const outcome = await importer(command.args, runtime.ctx.sessionManager.getCwd());
567
+ if (outcome.kind === "error") {
568
+ runtime.ctx.showError(outcome.message);
569
+ return;
570
+ }
571
+ const importTargetLabel = outcome.result.reused ? "reused imported session" : "newly published imported session";
572
+ if (runtime.ctx.session.isStreaming) {
573
+ runtime.ctx.showError(
574
+ `Import completed, but the ${importTargetLabel} was not activated because a response started streaming. Resume it later from the session picker.`,
575
+ );
576
+ return;
577
+ }
578
+ let resumed: boolean;
579
+ try {
580
+ resumed = await runtime.ctx.handleResumeSession(outcome.result.targetPath, { requireIdle: true });
581
+ } catch (error) {
582
+ runtime.ctx.showError(
583
+ isBusySessionImportActivation(error)
584
+ ? `Import completed, but the ${importTargetLabel} was not activated because a response or turn started. Resume it later from the session picker.`
585
+ : `Import completed, but the ${importTargetLabel} could not be activated. Resume it later from the session picker.`,
586
+ );
587
+ return;
588
+ }
589
+ if (!resumed) {
590
+ runtime.ctx.showError(
591
+ `${outcome.result.reused ? "Import found an existing imported session" : "Import created a new session"}, but switching to it was cancelled. Resume it later from the session picker.`,
592
+ );
593
+ return;
594
+ }
595
+ const { prepared } = outcome.result;
596
+ const providerLabel = prepared.conversation.provider === "codex" ? "Codex" : "Claude";
597
+ runtime.ctx.showStatus(
598
+ `${outcome.result.reused ? "Reused" : "Imported"} ${providerLabel} session "${outcome.result.title}" (${prepared.conversation.messages.length} messages reconstructed${prepared.counts.redacted > 0 ? `, ${prepared.counts.redacted} secret values redacted` : ""}).`,
599
+ );
600
+ }
508
601
  const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
509
602
  {
510
603
  name: "notify",
@@ -877,6 +970,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
877
970
  name: "export",
878
971
  priority: 50,
879
972
  description: "Export this session to an HTML file",
973
+ acp: false,
880
974
  inlineHint: "[path]",
881
975
  allowArgs: true,
882
976
  handle: async (command, runtime) => {
@@ -901,6 +995,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
901
995
  runtime.ctx.editor.setText("");
902
996
  },
903
997
  },
998
+ {
999
+ name: "import-session",
1000
+ priority: 51,
1001
+ description: "Import or reuse a Codex or Claude session transcript",
1002
+ acp: false,
1003
+ inlineHint: "<transcript-file> [--provider codex|claude]",
1004
+ allowArgs: true,
1005
+ handle: handleImportSessionCommand,
1006
+ handleTui: handleImportSessionTuiCommand,
1007
+ },
904
1008
  {
905
1009
  name: "copy",
906
1010
  priority: 55,
@@ -1649,7 +1753,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1649
1753
  {
1650
1754
  name: "move",
1651
1755
  description: "Move session to a different working directory",
1652
- acpDescription: "Move the current session file",
1756
+ acp: false,
1653
1757
  inlineHint: "<path>",
1654
1758
  allowArgs: true,
1655
1759
  handle: async (command, runtime) => {
@@ -84,6 +84,11 @@ export interface SlashCommandSpec extends BuiltinSlashCommand {
84
84
  aliases?: string[];
85
85
  /** When false, the dispatcher refuses to handle invocations that include arguments. */
86
86
  allowArgs?: boolean;
87
+ /**
88
+ * Whether ACP may advertise and execute this command. Commands that read
89
+ * caller-selected local paths must set this to false.
90
+ */
91
+ acp?: boolean;
87
92
  /**
88
93
  * ACP-specific override for `description`. Used by `ACP_BUILTIN_SLASH_COMMANDS`
89
94
  * when building `available_commands_update` payloads so the client receives