@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +132 -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
@@ -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 (RedSayknow flex, BlueSayknow para-para
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
- // (RedSayknow holds a flex; BlueSayknow dances the para-para then sobs).
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 (RedSayknow a brief flex; BlueSayknow a para-para cycle plus sob).
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
- out += `\x1b_Ga=d,d=I,i=${this.#kittyImageId},q=2\x1b\\`;
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 = `\x1b[${y + 1};${x + 1}H${pixel.frames[this.#frame]}`;
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${positioned}\x1b8`)
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
- out += positioned;
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}` : ". Progress may be stale until todo_write succeeds."}`,
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
- advance: (gate, answer) => {
223
- const pending = this.#pending.get(gate.gate_id);
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
- const gate = this.#broker.openGate(input);
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
- const { promise, resolve, reject } = Promise.withResolvers<unknown>();
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>;