@sayknow-cli/coding-agent 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -0
- package/dist/types/commands/session.d.ts +7 -0
- package/dist/types/config/telegram-autostart.d.ts +9 -1
- package/dist/types/modes/components/pet-capability.d.ts +8 -7
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -0
- package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
- package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
- package/dist/types/skc-runtime/session-restore.d.ts +99 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
- package/dist/types/tools/ask.d.ts +164 -4
- package/package.json +10 -7
- package/src/commands/session.ts +88 -2
- package/src/config/model-registry.ts +12 -0
- package/src/config/telegram-autostart.ts +11 -4
- package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +1 -1
- package/src/modes/components/pet-capability.ts +22 -13
- package/src/modes/components/pet-selector.ts +1 -1
- package/src/modes/components/sayknow-pet-widget.ts +41 -7
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
- package/src/notifications/lifecycle-control-runtime.ts +258 -179
- package/src/prompts/system/eager-todo.md +2 -0
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/system-prompt.md +4 -2
- package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
- package/src/session/agent-session.ts +31 -11
- package/src/skc-runtime/boot-generation.ts +172 -0
- package/src/skc-runtime/launch-tmux.ts +219 -41
- package/src/skc-runtime/session-restore-runtime.ts +120 -0
- package/src/skc-runtime/session-restore.ts +296 -0
- package/src/skc-runtime/session-state-sidecar.ts +41 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
- package/src/skc-runtime/tmux-sessions.ts +284 -108
- package/src/slash-commands/builtin-registry.ts +9 -4
- package/src/tools/ask.ts +183 -10
- package/src/tools/eval.ts +2 -2
|
@@ -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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
);
|
|
511
|
-
if (!
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
sessionName,
|
|
554
|
-
outcome.server_pid,
|
|
555
|
-
env,
|
|
644
|
+
const baseline = captureOwnerGenerationBaselineSync(stateDir, sessionId);
|
|
645
|
+
const ownerPlan = planTmuxOwnerIsolationSync(
|
|
556
646
|
{
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
658
|
+
ownerProbe,
|
|
564
659
|
);
|
|
565
|
-
|
|
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
|
-
|
|
715
|
+
tagCreatedTmuxSession(
|
|
568
716
|
nativeSessionId,
|
|
569
717
|
sessionName,
|
|
570
|
-
tmuxCommand,
|
|
571
|
-
env,
|
|
572
718
|
outcome.server_pid,
|
|
573
|
-
|
|
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 (
|
|
576
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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";
|