@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.
- package/CHANGELOG.md +157 -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
|
@@ -13,7 +13,7 @@ const PET_OPTIONS: { value: PetMode; label: string; description: string }[] = [
|
|
|
13
13
|
];
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Theme-style picker for the sayknow pet skin (Off /
|
|
16
|
+
* Theme-style picker for the sayknow pet skin (Off / RedOctopus / BlueOctopus). Preview
|
|
17
17
|
* fires as the selection moves; select commits, cancel restores.
|
|
18
18
|
*/
|
|
19
19
|
export class PetSelectorComponent extends Container {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type Component,
|
|
5
5
|
type Container,
|
|
6
6
|
getCellDimensions,
|
|
7
|
+
getTmuxOverlayImageProtocol,
|
|
7
8
|
isUnderTmux,
|
|
8
9
|
PARA_PARA_STEPS,
|
|
9
10
|
PET_SKINS,
|
|
@@ -14,8 +15,10 @@ import {
|
|
|
14
15
|
registerAnimationCallback,
|
|
15
16
|
type SayknowPixelFrameName,
|
|
16
17
|
type SayknowPixelFrames,
|
|
18
|
+
TERMINAL,
|
|
17
19
|
type TUI,
|
|
18
20
|
tmuxOwnsSixel,
|
|
21
|
+
tmuxPaneOffset,
|
|
19
22
|
wrapTmuxPassthrough,
|
|
20
23
|
} from "@sayknow-cli/tui";
|
|
21
24
|
import type { CustomEditor } from "./custom-editor";
|
|
@@ -216,7 +219,7 @@ export class SayknowPetWidget {
|
|
|
216
219
|
|
|
217
220
|
/** Live preview during a selector: change the sprite without re-mounting the
|
|
218
221
|
* composer editor (that would tear down the open overlay). After a short idle
|
|
219
|
-
* eye-roll it fires the signature burst once (
|
|
222
|
+
* eye-roll it fires the signature burst once (RedOctopus flex, BlueOctopus para-para
|
|
220
223
|
* then sob) so the selector demos the animation instead of waiting the random gap. */
|
|
221
224
|
previewMode(mode: PetMode): void {
|
|
222
225
|
this.#applyMode(mode, false);
|
|
@@ -341,7 +344,7 @@ export class SayknowPetWidget {
|
|
|
341
344
|
const mode = this.#mode;
|
|
342
345
|
if (mode === "off") return "base";
|
|
343
346
|
// Random idle burst → the skin's own animation, driven by its burst descriptor
|
|
344
|
-
// (
|
|
347
|
+
// (RedOctopus holds a flex; BlueOctopus dances the para-para then sobs).
|
|
345
348
|
if (now < this.#flexUntil) {
|
|
346
349
|
const burst = PET_SKINS[mode].burst;
|
|
347
350
|
const elapsed = now - (this.#flexUntil - petBurstDurationMs(burst));
|
|
@@ -384,7 +387,7 @@ export class SayknowPetWidget {
|
|
|
384
387
|
}
|
|
385
388
|
}
|
|
386
389
|
// Random show-off, both while idle and while working. Each skin's burst runs for
|
|
387
|
-
// its own length (
|
|
390
|
+
// its own length (RedOctopus a brief flex; BlueOctopus a para-para cycle plus sob).
|
|
388
391
|
if (this.#autoFlexGapMs && now >= this.#flexUntil) {
|
|
389
392
|
if (this.#nextAutoFlexAt === 0) {
|
|
390
393
|
this.#scheduleAutoFlex(now);
|
|
@@ -429,11 +432,35 @@ export class SayknowPetWidget {
|
|
|
429
432
|
return out;
|
|
430
433
|
}
|
|
431
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Whether pet art must be smuggled to the outer terminal through tmux's DCS
|
|
437
|
+
* passthrough envelope. True exactly when the multiplexer suppressed inline
|
|
438
|
+
* graphics and the startup probe proved the outer terminal can draw them.
|
|
439
|
+
*/
|
|
440
|
+
#usesTmuxPassthrough(): boolean {
|
|
441
|
+
return TERMINAL.imageProtocol === null && getTmuxOverlayImageProtocol() !== null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Cursor-position escape for the pet's top-left cell.
|
|
446
|
+
*
|
|
447
|
+
* Passthrough payloads address the OUTER terminal's screen, so they carry the
|
|
448
|
+
* pane origin; tmux-level payloads stay pane-relative.
|
|
449
|
+
*/
|
|
450
|
+
#cursorTo(x: number, y: number, outer: boolean): string {
|
|
451
|
+
const offset = outer ? tmuxPaneOffset() : { top: 0, left: 0 };
|
|
452
|
+
return `\x1b[${y + offset.top + 1};${x + offset.left + 1}H`;
|
|
453
|
+
}
|
|
454
|
+
|
|
432
455
|
/** Pending on-screen image cleanup. Pure: authority is consumed separately, on delivery. */
|
|
433
456
|
#imageCleanupPayload(): string {
|
|
434
457
|
let out = "";
|
|
435
458
|
if (this.#kittyCleanupPending && this.#kittyImageId !== undefined) {
|
|
436
|
-
|
|
459
|
+
// Deleting by image id needs no cursor addressing, but it still has to
|
|
460
|
+
// reach the terminal that holds the placement — the outer one when the
|
|
461
|
+
// art was delivered through passthrough.
|
|
462
|
+
const del = `\x1b_Ga=d,d=I,i=${this.#kittyImageId},q=2\x1b\\`;
|
|
463
|
+
out += this.#usesTmuxPassthrough() ? wrapTmuxPassthrough(del) : del;
|
|
437
464
|
}
|
|
438
465
|
if (this.#lastSixelFootprint) {
|
|
439
466
|
out += this.#clearSixelFootprint(this.#lastSixelFootprint);
|
|
@@ -491,8 +518,10 @@ export class SayknowPetWidget {
|
|
|
491
518
|
// A full frame supersedes any cleanup-only frame still awaiting ack.
|
|
492
519
|
this.#frameCleanupAwaitingAck = false;
|
|
493
520
|
const { x, y } = pos;
|
|
521
|
+
const frame = pixel.frames[this.#frame];
|
|
494
522
|
let out = "";
|
|
495
|
-
const positioned =
|
|
523
|
+
const positioned = `${this.#cursorTo(x, y, false)}${frame}`;
|
|
524
|
+
const positionedOuter = `${this.#cursorTo(x, y, true)}${frame}`;
|
|
496
525
|
|
|
497
526
|
if (pixel.protocol === "sixel") {
|
|
498
527
|
const footprint = { x, y, columns: pixel.columns, rows: pixel.rasterRows };
|
|
@@ -518,7 +547,7 @@ export class SayknowPetWidget {
|
|
|
518
547
|
out += tmuxOwnsSixel()
|
|
519
548
|
? positioned
|
|
520
549
|
: isUnderTmux()
|
|
521
|
-
? wrapTmuxPassthrough(`\x1b7${
|
|
550
|
+
? wrapTmuxPassthrough(`\x1b7${positionedOuter}\x1b8`)
|
|
522
551
|
: positioned;
|
|
523
552
|
return out;
|
|
524
553
|
}
|
|
@@ -526,7 +555,12 @@ export class SayknowPetWidget {
|
|
|
526
555
|
// A kitty frame (re)places the image cursor-neutrally, so cleanup is pending
|
|
527
556
|
// again even if a narrow-terminal pass consumed it earlier.
|
|
528
557
|
this.#kittyCleanupPending = true;
|
|
529
|
-
|
|
558
|
+
// Under tmux the placement is cursor-neutral for the OUTER terminal but its
|
|
559
|
+
// addressing is not: tmux forwards passthrough bytes without first syncing
|
|
560
|
+
// the physical cursor, so the frame carries its own outer-screen position,
|
|
561
|
+
// and the save/restore pair sits inside the envelope so the outer cursor is
|
|
562
|
+
// exactly where tmux believes it left it.
|
|
563
|
+
out += this.#usesTmuxPassthrough() ? wrapTmuxPassthrough(`\x1b7${positionedOuter}\x1b8`) : positioned;
|
|
530
564
|
return out;
|
|
531
565
|
}
|
|
532
566
|
}
|
|
@@ -869,7 +869,7 @@ export class EventController {
|
|
|
869
869
|
(content: { type: string; text?: string }) => content.type === "text",
|
|
870
870
|
)?.text;
|
|
871
871
|
this.ctx.showWarning(
|
|
872
|
-
`Todo update failed${textContent ? `: ${textContent}` : ".
|
|
872
|
+
`Todo update failed${textContent ? `: ${textContent}` : ""}. Work continues without visible todo progress for this turn.`,
|
|
873
873
|
);
|
|
874
874
|
}
|
|
875
875
|
if (event.toolName === "resolve" && !event.isError) {
|
|
@@ -104,6 +104,12 @@ export function modelSupportsTokenCostMetrics(model: Model | undefined): boolean
|
|
|
104
104
|
export interface WorkflowGateEmitter {
|
|
105
105
|
/** True only when unattended mode has been negotiated. */
|
|
106
106
|
isUnattended(): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* True when gates emitted through this emitter can be answered remotely
|
|
109
|
+
* (workflow_gate_response over RPC/bridge). The ask tool refuses headless
|
|
110
|
+
* execution unless this reports true.
|
|
111
|
+
*/
|
|
112
|
+
supportsRemoteGateAnswers(): boolean;
|
|
107
113
|
/** Open + emit a gate; resolves with the agent's answer (from workflow_gate_response). */
|
|
108
114
|
emitGate(input: OpenGateInput): Promise<unknown>;
|
|
109
115
|
/**
|
|
@@ -157,6 +163,10 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
|
|
|
157
163
|
return this.#controller !== undefined;
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
supportsRemoteGateAnswers(): boolean {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
160
170
|
/** Observe every emitted gate (e.g. so an extension can map an ask to its gate_id). */
|
|
161
171
|
onGateEmitted(listener: (gate: RpcWorkflowGate) => void): () => void {
|
|
162
172
|
this.#gateListeners.add(listener);
|
|
@@ -219,19 +229,27 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
|
|
|
219
229
|
this.#broker = new WorkflowGateBroker(this.opts.runId, this.opts.store ?? new MemoryGateStore(), {
|
|
220
230
|
emit: gate => this.opts.emitFrame(gate),
|
|
221
231
|
audit: e => this.opts.audit?.(e),
|
|
222
|
-
|
|
223
|
-
|
|
232
|
+
// The broker verifies the continuation is still live *after* `advance`
|
|
233
|
+
// returns, so the pending waiter must survive advance and only settle in
|
|
234
|
+
// `completeAccepted` (mirroring the session-side gate emitter).
|
|
235
|
+
advance: () => {},
|
|
236
|
+
completeAccepted: record => {
|
|
237
|
+
const pending = this.#pending.get(record.gate.gate_id);
|
|
224
238
|
if (pending) {
|
|
225
|
-
this.#pending.delete(gate.gate_id);
|
|
226
|
-
pending.resolve(answer);
|
|
239
|
+
this.#pending.delete(record.gate.gate_id);
|
|
240
|
+
pending.resolve(record.answer);
|
|
227
241
|
return;
|
|
228
242
|
}
|
|
229
243
|
// A controller may answer synchronously while emitFrame is still on the
|
|
230
244
|
// stack. Keep the accepted answer so emitGate can still resolve after
|
|
231
245
|
// openGate returns and the caller starts awaiting.
|
|
232
|
-
this.#earlyAnswers.set(gate.gate_id, answer);
|
|
246
|
+
this.#earlyAnswers.set(record.gate.gate_id, record.answer);
|
|
233
247
|
},
|
|
234
248
|
finalizeAccepted: record => this.#finalizeAcceptedGate(record),
|
|
249
|
+
// The control plane's durable record is the gate store itself; it never
|
|
250
|
+
// publishes a separate terminal transcript, so accepted gates (live and
|
|
251
|
+
// recovered) terminalize with the designed "not_published" proof.
|
|
252
|
+
terminalizeAccepted: () => "not_published",
|
|
235
253
|
});
|
|
236
254
|
this.#brokerReady = true;
|
|
237
255
|
void this.startRecoveryOnce();
|
|
@@ -496,7 +514,21 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
|
|
|
496
514
|
if (!this.#broker) {
|
|
497
515
|
return Promise.reject(new Error("cannot emit a workflow gate before unattended mode is negotiated"));
|
|
498
516
|
}
|
|
499
|
-
|
|
517
|
+
// Opening without a continuation quarantines the gate as
|
|
518
|
+
// "opened_without_continuation", which makes it permanently unanswerable.
|
|
519
|
+
// Register the pending waiter through the continuation contract so the
|
|
520
|
+
// broker records a live, resolvable gate and can fence it on release.
|
|
521
|
+
const waiter = Promise.withResolvers<unknown>();
|
|
522
|
+
const gate = this.#broker.openGate(input, {
|
|
523
|
+
activate: opened => this.#pending.set(opened.gate_id, { resolve: waiter.resolve, reject: waiter.reject }),
|
|
524
|
+
isLive: gateId => this.#pending.has(gateId),
|
|
525
|
+
release: gateId => {
|
|
526
|
+
const pending = this.#pending.get(gateId);
|
|
527
|
+
if (!pending) return;
|
|
528
|
+
this.#pending.delete(gateId);
|
|
529
|
+
pending.reject(new Error(`workflow gate ${gateId} continuation was fenced`));
|
|
530
|
+
},
|
|
531
|
+
});
|
|
500
532
|
for (const listener of this.#gateListeners) {
|
|
501
533
|
try {
|
|
502
534
|
listener(gate);
|
|
@@ -507,11 +539,10 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
|
|
|
507
539
|
if (this.#earlyAnswers.has(gate.gate_id)) {
|
|
508
540
|
const answer = this.#earlyAnswers.get(gate.gate_id);
|
|
509
541
|
this.#earlyAnswers.delete(gate.gate_id);
|
|
542
|
+
this.#pending.delete(gate.gate_id);
|
|
510
543
|
return Promise.resolve(answer);
|
|
511
544
|
}
|
|
512
|
-
|
|
513
|
-
this.#pending.set(gate.gate_id, { resolve, reject });
|
|
514
|
-
return promise;
|
|
545
|
+
return waiter.promise;
|
|
515
546
|
}
|
|
516
547
|
|
|
517
548
|
async recover(): Promise<void> {
|
|
@@ -96,6 +96,8 @@ export interface AskSelectedAckRecoveryParticipant {
|
|
|
96
96
|
/** SDK-native surface for emitting a workflow gate and awaiting its answer. */
|
|
97
97
|
export interface WorkflowGateEmitter {
|
|
98
98
|
supportsRemoteGateAnswers(): boolean;
|
|
99
|
+
/** True when an unattended run has been negotiated for this emitter (control-plane emitters). */
|
|
100
|
+
isUnattended?(): boolean;
|
|
99
101
|
emitGate(input: OpenGateInput): Promise<unknown>;
|
|
100
102
|
onGateEmitted?(listener: (gate: WorkflowGate) => void): () => void;
|
|
101
103
|
resolveGate?(response: WorkflowGateResponse): Promise<WorkflowGateResolution>;
|