This repository was archived by the owner on Jul 24, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathding.ts
More file actions
2245 lines (2151 loc) · 91 KB
/
Copy pathding.ts
File metadata and controls
2245 lines (2151 loc) · 91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// commands/ding.ts — busy-aware push notifier for harnesses without
// extension points. Watches `<identity>/inbox/`, reads
// `<identity>/status`, and pty-sends a notice into a target session
// only when the agent is `available` or `offline`. Buffers while
// `busy`/`dnd`, flushes when status flips back.
//
// Long-running. Lives in the same process as `st ding ...`; pair
// with `pty up` (or any supervisor) for restart-on-crash. Designed
// so the underlying daemon (`runDing`) is testable without a real
// pty binary or a real St — see tests/unit/ding.test.ts.
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { invokedName, type CliContext } from '../cli-context.ts';
import {
filenameTimestamp,
hasByteIdenticalArchiveTwin,
inboxDir,
LIVENESS_HEARTBEAT_MS,
msNow,
statusPath,
TIDY_CHECK_INTERVAL_MS,
validFilename,
} from '../common.ts';
import { type St } from '../lib.ts';
import { refreshIdentityStatus } from '../commands/status.ts';
import { evaluateDrift, type DriftResult } from '../mcp/tidy-check.ts';
import {
asFilename,
type Filename,
type Identity,
type State,
type WatchEvent,
} from '../types.ts';
const DEFAULT_INTERVAL_MS = 1000;
/** brief-031 amendment: how often to check whether the target pty
* session is still alive. 30s is more than fast enough — the
* expensive case is an orphan daemon hanging around for hours after
* the agent died, not the few seconds between session-death and
* ding-exit. */
const DEFAULT_SESSION_WATCH_INTERVAL_MS = 30_000;
/**
* Session-flap debounce: how many CONSECUTIVE "target gone"
* observations the session-watch tick needs before tripping the
* exit-when-gone path. A pty `--permanent` session is auto-restarted
* by pty's supervisor; the window between the old process's exit
* and the pidfile's rewrite can look "gone" to `process.kill(pid,
* 0)` for a tick or two. Without debounce, ding exits right when
* its target is about to come back; its own supervisor restarts
* ding eventually but arrivals during the gap are missed. Three
* consecutive misses at the default 30s interval = ~90s of "really
* gone" evidence before we trip; at aggressive test intervals a
* quick flap (single miss) rides through cleanly.
*/
const SESSION_GONE_DEBOUNCE_MISSES = 3;
const SUPPRESS_STATES: ReadonlySet<State> = new Set<State>(['busy', 'dnd']);
// brief-031: tidy-check gate is stricter than the inbox-arrival gate.
// `unknown` joins busy/dnd because we don't know what the agent's
// actually doing — same call as the MCP tick made in brief-030.
const TIDY_GATE_STATES: ReadonlySet<State> = new Set<State>([
'busy',
'dnd',
'unknown',
]);
/** Test seam: how the daemon delivers a notice. Production binds to
* `pty send <session> --with-delay 0.5 --seq <text> --seq key:return`.
* The `--with-delay 0.5` (brief-034) keeps the terminal from racing
* the trailing Enter against the text on bracketed-paste-aware
* input panes. */
export interface PtySender {
(
sessionName: string,
sequences: readonly string[]
): Promise<{ status: number; stderr: string }>;
}
/** Test seam: how the typing-aware guard reads the target pane.
* Production binds to `pty peek --plain <session>` (plain text, no
* ANSI). Used to frame-diff the pane before a poke — see
* {@link isPaneBusy}. */
export interface PtyPeeker {
(
sessionName: string
): Promise<{ status: number; stdout: string; stderr: string }>;
}
/** Test seam: bracketed-paste text into the target pane WITHOUT
* submitting. Production binds to `pty send <session> --paste <text>`.
* Used by the walked-away-mid-type path to preserve a human's
* in-progress input (append a newline + the ding, then submit) so no
* typed text is clobbered. See {@link preserveDeliver}. */
export interface PtyPaster {
(
sessionName: string,
text: string
): Promise<{ status: number; stderr: string }>;
}
/** Test seam: how the daemon checks whether the target session is
* alive. Production reads `<PTY_SESSION_DIR>/<session>.pid` and
* probes the PID with `process.kill(pid, 0)`. */
export interface IsSessionAlive {
(sessionName: string): boolean;
}
export interface DingDeps {
/** Pre-built St. Production uses `createSt({ root, identity })`. */
st: St;
/** Identity whose inbox + status the daemon watches. */
identity: Identity;
/** Target pty session name (matches `pty list`). */
ptySession: string;
/** How often to re-check status when buffered notices are pending. */
intervalMs?: number;
/**
* brief-031: how often to run the tidy-check drift detector and
* pty-send a summary if drift fires. Defaults to
* TIDY_CHECK_INTERVAL_MS (20 min). Set to 0 to disable tidy-check
* entirely (the daemon becomes push-only, the pre-brief-031
* behavior). Tests pass a small value to observe ticks.
*/
tidyIntervalMs?: number;
/** Optional test-injectable sender. Defaults to the real `pty` binary. */
ptySend?: PtySender;
/**
* brief-031: test seam for the tidy-check clock. Production omits
* → Date.now. The unit suite injects to deterministically advance
* drift age without sleeping real minutes.
*/
tidyNow?: () => number;
/**
* brief-031 amendment: when true (default), ding periodically
* checks whether the target pty session is still alive and exits
* cleanly when it's not. Disable with the
* `--no-exit-when-session-gone` CLI flag for the rare case where
* you want ding to wait for the session to come back.
*/
exitWhenSessionGone?: boolean;
/**
* brief-031 amendment: how often to run the session-alive check.
* Defaults to DEFAULT_SESSION_WATCH_INTERVAL_MS (30s). Tests use a
* small value to observe transitions without sleeping. Ignored
* when exitWhenSessionGone is false.
*/
sessionWatchIntervalMs?: number;
/**
* brief-031 amendment: test seam for the alive check. Defaults to
* a pid-file + process.kill(pid, 0) probe under
* $PTY_SESSION_DIR.
*/
isSessionAlive?: IsSessionAlive;
/**
* How often (ms) to refresh the watched identity's status-file mtime.
* This is the cross-machine liveness HEARTBEAT (the R in R/T): while the
* agent is alive the ding bumps the mtime this often; when the harness
* (= the pty session process) dies, the ding exits and the touch stops, so
* the frozen mtime reads as dead cross-machine. Also subsumes brief-032's
* anti-`unknown`-drift role. Defaults to LIVENESS_HEARTBEAT_MS (30s). Set
* to 0 to disable.
*/
statusRefreshIntervalMs?: number;
/**
* Reboot self-healing: how often (ms) to re-scan the inbox for
* files that are unarchived + not-recently-delivered and re-poke
* the target session. Defaults to DEFAULT_RESCAN_INTERVAL_MS
* (60s). Set to 0 to disable (the pre-tick push-only behavior).
* Tests use a small value to observe ticks.
*/
rescanIntervalMs?: number;
/**
* Quiet period (ms) after a successful delivery before the
* re-scan will re-poke the same file. Defaults to
* DEFAULT_RESCAN_QUIET_AFTER_DELIVERY_MS (90s). Tunes how long
* the agent has to archive a delivered message before ding
* assumes it may have been missed and re-nudges.
*/
rescanQuietAfterDeliveryMs?: number;
/**
* brief-036 (typing-aware ding): test seam for reading the target
* pane. Production binds to `pty peek --plain <session>`. When the
* guard is enabled and a message isn't urgent, the daemon peeks the
* pane before poking; if it's active (frames differ, or the input
* line has un-submitted text) it holds and retries rather than
* interrupting a mid-type human.
*/
ptyPeek?: PtyPeeker;
/**
* brief-036: master toggle for the typing-aware guard. Default true.
* When false the daemon delivers on arrival (the pre-brief-036
* behavior). Env: `ST_DING_PANE_GUARD=0`.
*/
paneGuard?: boolean;
/**
* brief-036: gap (ms) between the two `pty peek` frames used to
* detect activity. Default {@link DEFAULT_PEEK_DIFF_MS} (300ms).
* Env: `ST_DING_PEEK_DIFF_MS`.
*/
peekDiffMs?: number;
/**
* brief-036: how long (ms) to hold a busy pane before re-checking.
* Default {@link DEFAULT_HOLD_RETRY_MS} (20s). Env:
* `ST_DING_HOLD_RETRY_MS`.
*/
holdRetryMs?: number;
/**
* brief-036: max times to hold for a busy pane before force-
* delivering anyway (never drop). Default {@link DEFAULT_MAX_HOLDS}
* (3) → ~60s worst-case hold. Env: `ST_DING_MAX_HOLDS`.
*/
maxHolds?: number;
/**
* brief-036: also treat the pane as busy when its last non-blank
* line looks like a prompt with un-submitted text (the "typed then
* paused" case that frame-diff alone misses). Best-effort +
* per-harness-tunable. Default true; env `ST_DING_INPUT_GUARD=0`.
*/
inputGuard?: boolean;
/**
* brief-036: pattern matched against the pane's last non-blank line
* for the input-area check. Default {@link DEFAULT_INPUT_PATTERN}
* (a prompt glyph followed by text). Env: `ST_DING_INPUT_PATTERN`
* (a regex source string).
*/
inputPattern?: RegExp;
/**
* brief-036 refinement: consecutive unchanged-input retries before a
* non-empty input line is treated as walked-away-mid-type and
* preserve-delivered. Default {@link DEFAULT_INPUT_STALE_MAX} (3).
* Env: `ST_DING_INPUT_STALE_MAX`.
*/
inputStaleMax?: number;
/**
* brief-036 refinement: test seam for the bracketed-paste primitive
* used by preserve-and-deliver. Production binds to
* `pty send <session> --paste <text>`.
*/
ptyPaste?: PtyPaster;
/**
* Test seam for the "is this message still pending in the inbox?"
* check that gates delivery (see `stillInInbox`). Production defaults
* to a filesystem `existsSync` of `<inbox>/<filename>`. Tests that use
* the in-memory fake `st` (no real files) inject their own predicate.
*/
messagePending?: (filename: Filename) => boolean;
/**
* When true, emit verbose `[st ding debug]` lines to stderr:
* - per-rescan-tick summary (inbox / in-flight / quiet-skipped
* / attempted counts)
* - per-delivery-attempt (filename, session name, exit status,
* stderr tail)
* - startup-backlog scan summary (files eligible / skipped)
* Used by evals + operators to diagnose delivery/rescan gaps
* without pty-peeking. Toggled by ST_DING_DEBUG=1 in
* `cmdDingCli`. Default off — production doesn't need the noise.
*/
debug?: boolean;
/** Stops the daemon. Aborts the watcher and clears the status timer. */
signal?: AbortSignal;
/** Where to log warnings. Defaults to `process.stderr.write`. */
stderr?: (s: string) => void;
}
interface BufferedEvent {
filename: Filename;
from: Identity | '';
subject?: string;
/**
* Retry counter — bumped when `deliver()` returns a failure signal
* and the event gets requeued. Capped by {@link MAX_DELIVER_RETRIES}
* so a permanently-broken target doesn't produce an infinite retry
* loop. Undefined = first attempt.
*/
retries?: number;
/**
* brief-036: how many times this event has been HELD for a busy
* pane (typing-aware guard). Distinct from {@link retries} (transient
* pty failures). At `maxHolds` we force-deliver — a held message is
* never dropped. Undefined = never held.
*/
holds?: number;
/**
* brief-036: don't re-attempt a held event before this `msNow()`
* value. Set to `now + holdRetryMs` on each hold so the ~20s retry
* cadence rides the existing 1s flush timer without a second timer.
*/
notBefore?: number;
/**
* brief-036: message priority from frontmatter. `high` = urgent →
* skip the pane guard and deliver immediately.
*/
priority?: string;
/**
* brief-036 refinement: the pane's input-line text observed on the
* previous hold, used to tell "actively typing" (text changes across
* retries) from "walked away mid-type" (text stays UNCHANGED). Only
* set while an un-submitted input line is present.
*/
lastInputText?: string;
/**
* brief-036 refinement: consecutive retries the input line has been
* present AND unchanged. At `inputStaleMax` we treat it as
* walked-away and preserve-and-deliver (newlines + ding + submit)
* rather than holding forever or clobbering the typed text.
*/
inputStaleCount?: number;
}
/**
* Max retries per event before giving up (with a loud log). Bounded so
* a permanently-broken pty target doesn't monopolize the flush loop
* forever, but generous enough to survive a session flap +
* restart-in-under-a-minute (each retry runs on the flush interval,
* so 5 retries = ~5s at the default 1s interval).
*/
const MAX_DELIVER_RETRIES = 5;
/**
* How long after the daemon starts to keep the startup-dedup set
* populated. Beyond this window the scan+watcher race is over — any
* arrival goes through the watcher, and the dedup set only wastes
* memory. 60s is more than enough for a fresh filesystem watcher to
* settle; a slow FSEvents subscription typically arms in single-digit
* ms. Cleared when the window expires.
*/
const STARTUP_DEDUP_WINDOW_MS = 60_000;
/**
* How often the periodic backlog re-scan tick fires. Reads the
* inbox and re-pokes for any file that's unarchived and hasn't
* been delivered recently.
*
* This is the reboot self-healing leg: when the target claude
* session dies + comes back (respawn / restart / crash), the ding
* sidecar's `deliver` fails during the down window and the file is
* dropped after MAX_DELIVER_RETRIES. Once the session returns,
* this tick catches the still-unarchived file and re-pokes.
*
* 60s per cos's tuning: fast enough that the capstone-eval's ~220s
* LOOP-CLOSED window sees a re-poke, cheap enough to run forever
* (readdirSync of a folder is microseconds).
*/
const DEFAULT_RESCAN_INTERVAL_MS = 60_000;
/**
* Quiet period after a SUCCESSFUL delivery before the re-scan will
* re-poke the same file. Gives the agent time to read + archive
* before we nudge again. If the agent is healthy + mid-processing,
* the archive lands during this window and the file is gone from
* the inbox before the next re-scan looks at it — no wasted poke.
* If the agent parks (delivered but never drained — mid-`--resume`
* that skipped the boot ritual, or a wedged reply), the file stays
* unarchived and we re-poke after this window elapses.
*
* 90s per capstone tuning: the capstone grades LOOP-CLOSED in
* ~220s, so the quiet window MUST be < 220s for a delivered-but-
* parked agent to be re-poked in time. 5 min (300s) missed the
* window and left parked agents stuck; 90s means at most ~150s
* total (60s scan interval + 90s quiet) before a re-poke — well
* inside the 220s grade window. Trade-off: a healthy agent
* mid-read gets a re-nudge if they take longer than 90s to
* archive — acceptable noise, and archive latency is typically
* much shorter than that.
*/
const DEFAULT_RESCAN_QUIET_AFTER_DELIVERY_MS = 90_000;
/**
* brief-036 (typing-aware ding) defaults. The guard peeks the target
* pane twice `DEFAULT_PEEK_DIFF_MS` apart; if the frames differ (active
* typing/output) or the input line has un-submitted text, it holds the
* poke and retries every `DEFAULT_HOLD_RETRY_MS`, up to
* `DEFAULT_MAX_HOLDS` times. At the cap it force-delivers, but ONLY once
* the frame is static — a still-changing (mid-turn) frame keeps holding,
* because a submit queued into an active Claude Code turn seeds a
* queued-input re-poke bug (deferred, never dropped: urgent bypasses and
* the re-scan re-pokes an un-archived message once the pane idles).
* Urgent (`priority: high`) messages skip the guard. ~60s worst-case
* hold for a pane that goes idle; longer while it stays busy. All
* env-overridable in `cmdDingCli`.
*/
const DEFAULT_PEEK_DIFF_MS = 300;
const DEFAULT_HOLD_RETRY_MS = 20_000;
const DEFAULT_MAX_HOLDS = 3;
/**
* Input-area busy heuristic: the pane's last non-blank line matches
* this when it holds a prompt glyph (`>`/`❯`/`›`/`$`/`#`) followed by
* at least one non-space char — i.e. text typed but not yet submitted.
* Best-effort + per-harness-tunable via `ST_DING_INPUT_PATTERN`; an
* empty prompt (`> `) does not match.
*/
const DEFAULT_INPUT_PATTERN = /[>❯›$#][ \t]*\S/;
/**
* brief-036 refinement: how many consecutive retries an un-submitted
* input line must stay UNCHANGED before we treat it as "walked away
* mid-type" and preserve-and-deliver (rather than holding forever for
* a human who isn't coming back). Env: `ST_DING_INPUT_STALE_MAX`.
*/
const DEFAULT_INPUT_STALE_MAX = 3;
/**
* Run the ding daemon. Resolves when the AbortSignal aborts (or
* when the upstream watcher exits, which only happens on signal in
* normal operation). Production callers from `cmdDingCli` expect
* this to run forever; tests pass a tight signal.
*/
export async function runDing(deps: DingDeps): Promise<void> {
const intervalMs = deps.intervalMs ?? DEFAULT_INTERVAL_MS;
const rawSend = deps.ptySend ?? defaultPtySend;
const log = deps.stderr ?? ((s) => process.stderr.write(s));
const debug = deps.debug === true;
const dbg = (msg: string): void => {
if (debug) log(`[st ding debug] ${msg}\n`);
};
// Send serialization: every `pty send` invocation goes through this
// chain so `--with-delay 0.5`-widened windows can't interleave (a
// second send starting mid-way through the first's text-then-Enter
// sequence would let text-A/text-B/return-A/return-B garble on the
// receiving terminal). The chain awaits the previous send's
// completion (regardless of its outcome) before invoking the next.
let sendChain: Promise<unknown> = Promise.resolve();
const send: PtySender = async (sessionName, sequences) => {
const prev = sendChain;
const p = (async () => {
await prev.catch(() => undefined);
const result = await rawSend(sessionName, sequences);
if (debug) {
// Preview line: extract the first non-key sequence for a
// human-readable hint (usually the [DING] line body).
const preview =
sequences.find((s) => !s.startsWith('key:')) ?? sequences[0] ?? '';
const shortPreview =
preview.length > 80 ? `${preview.slice(0, 77)}...` : preview;
const stderrTail = result.stderr.trim().slice(-120);
dbg(
`pty send → session="${sessionName}" status=${result.status}` +
` preview=${JSON.stringify(shortPreview)}` +
(stderrTail.length > 0 ? ` stderr=${JSON.stringify(stderrTail)}` : '')
);
}
return result;
})();
sendChain = p.catch(() => undefined);
return p;
};
// brief-036: typing-aware pane guard config. Env-overridable in
// cmdDingCli; deps override for tests.
const peek = deps.ptyPeek ?? defaultPtyPeek;
const paste = deps.ptyPaste ?? defaultPtyPaste;
const paneGuardOn = deps.paneGuard ?? true;
const peekDiffMs = deps.peekDiffMs ?? DEFAULT_PEEK_DIFF_MS;
const holdRetryMs = deps.holdRetryMs ?? DEFAULT_HOLD_RETRY_MS;
const maxHolds = deps.maxHolds ?? DEFAULT_MAX_HOLDS;
const inputGuardOn = deps.inputGuard ?? true;
const inputPattern = deps.inputPattern ?? DEFAULT_INPUT_PATTERN;
const inputStaleMax = deps.inputStaleMax ?? DEFAULT_INPUT_STALE_MAX;
// "Is this message still pending in the inbox?" — production checks the
// filesystem; tests inject a predicate (the fake `st` has no real files).
const messagePending =
deps.messagePending ??
((filename: Filename): boolean =>
existsSync(join(inboxDir(deps.identity, deps.st.root), filename)));
// Outcome of a guarded delivery attempt. `held` carries the tracking
// state the caller must persist on the requeued event so the next
// retry can tell "still typing" from "walked away".
type GuardOutcome =
| { kind: 'delivered' }
| { kind: 'failed' }
| { kind: 'held'; inputText: string; staleCount: number };
// ─── Delivery-stall tracking (#101) ───────────────────────────────
//
// The pane guard deliberately NEVER force-submits into a frame that
// is still changing: a submit landing mid-turn seeds Claude Code's
// queued-input replay bug (see the comment in `guardedDeliver` and
// the "keeps HOLDING, never force-submits" regression test). That
// decision stands — but it means a target pane that keeps changing
// holds every poke for as long as it keeps changing, with no upper
// bound.
//
// Before this fix that state was INVISIBLE and, worse, DISHONEST:
// the hold logged only under ST_DING_DEBUG, while the status-refresh
// tick (which is entirely decoupled from delivery) kept bumping the
// identity's status mtime. Senders therefore read `available` from an
// agent whose mail the sidecar was demonstrably not delivering.
//
// `deliveryStalled` closes that gap. Once a message has been held
// PAST the hold cap, the sidecar has proven it cannot currently
// deliver, so it (a) says so loudly, once, on stderr and (b) stops
// refreshing the status file. The mtime then freezes and readers
// derive staleness through the normal path — the same death-coupling
// the session-gone watch already relies on. A successful delivery
// clears the stall and the heartbeat resumes.
//
// Invariant: a sidecar must not write liveness it has not earned.
let deliveryStalled = false;
/** Loud-log the stall exactly once per stall episode, not per retry. */
let loggedStall = false;
function markDeliveryStalled(ev: BufferedEvent, holds: number): void {
deliveryStalled = true;
if (loggedStall) return;
loggedStall = true;
log(
`st ding: DELIVERY STALLED — "${ev.filename}" has been held ` +
`${holds} times (cap ${maxHolds}) because pty session ` +
`"${deps.ptySession}" is never static long enough to submit ` +
`into safely. The message is NOT lost — it stays in the inbox ` +
`and is retried every ${holdRetryMs}ms — but it will not be ` +
`delivered until the pane goes idle. Suspending the status ` +
`heartbeat for "${deps.identity}" so peers stop reading this ` +
`agent as available while its mail is undeliverable.\n`
);
}
function clearDeliveryStall(): void {
if (!deliveryStalled) return;
deliveryStalled = false;
loggedStall = false;
log(
`st ding: delivery recovered for "${deps.identity}" — resuming ` +
`the status heartbeat.\n`
);
}
async function normalDeliver(ev: BufferedEvent): Promise<GuardOutcome> {
const ok = await deliver(send, deps.ptySession, ev, log);
if (ok) clearDeliveryStall();
return ok ? { kind: 'delivered' } : { kind: 'failed' };
}
/**
* brief-036 refinement: deliver WITHOUT clobbering the human's
* un-submitted input. Bracketed-paste a leading newline + the ding
* (appended after their cursor text — nothing submits yet), then
* submit, so the turn is "<their text>\n[DING] …" and nothing typed
* is lost. (The exact "insert newline without submit" keystrokes are
* pending confirmation on a live Claude Code pane — isolated here so
* that's a one-function change.)
*/
async function preserveDeliver(ev: BufferedEvent): Promise<GuardOutcome> {
const dingText = buildDingText(ev);
let pasteRes: { status: number; stderr: string };
try {
pasteRes = await paste(deps.ptySession, `\n${dingText}`);
} catch (err) {
log(`st ding: preserve-deliver paste failed: ${errMsg(err)}\n`);
return { kind: 'failed' };
}
if (pasteRes.status !== 0) {
const tail = pasteRes.stderr.trim().slice(-200);
log(
`st ding: preserve-deliver paste to "${deps.ptySession}" exited ${pasteRes.status}${
tail ? `: ${tail}` : ''
}\n`
);
return { kind: 'failed' };
}
// Submit the whole buffer (their text + the pasted ding).
let submitRes: { status: number; stderr: string };
try {
submitRes = await send(deps.ptySession, ['key:return']);
} catch (err) {
log(`st ding: preserve-deliver submit failed: ${errMsg(err)}\n`);
return { kind: 'failed' };
}
if (submitRes.status !== 0) {
log(
`st ding: preserve-deliver submit to "${deps.ptySession}" exited ${submitRes.status}\n`
);
return { kind: 'failed' };
}
dbg(`preserve-delivered ${ev.filename} (kept un-submitted input)`);
clearDeliveryStall();
return { kind: 'delivered' };
}
/**
* brief-036: decide whether to deliver, hold, or preserve-and-deliver.
* - urgent (priority high) or guard off → deliver now, no peek.
* - empty input + frame static → deliver (idle / walked away).
* - empty input + frame changing → hold (mid-turn). The hold cap
* force-delivers ONLY once the frame is static — never into an
* active turn, since a submit queued mid-turn seeds Claude Code's
* queued-input re-poke bug.
* - un-submitted input CHANGING (frame OR text) → active (mid-turn
* or typing) → hold (don't interrupt / don't seed the queue).
* - un-submitted input + frame BOTH static for `inputStaleMax`
* retries (or the hold cap, which is likewise gated on a static
* frame) → walked-away-mid-type → preserve-and-deliver.
* A peek failure → deliver (never block on a peek problem).
*/
async function guardedDeliver(ev: BufferedEvent): Promise<GuardOutcome> {
const holds = ev.holds ?? 0;
const urgent = ev.priority === 'high';
if (urgent || !paneGuardOn) {
if (urgent && paneGuardOn) {
dbg(`urgent (priority=high) → skipping pane guard for ${ev.filename}`);
}
return normalDeliver(ev);
}
const forceCap = holds >= maxHolds;
const a = await assessPane(peek, deps.ptySession, { diffMs: peekDiffMs });
if (!a.ok) return normalDeliver(ev); // peek failed → deliver
const inputText =
inputGuardOn && hasInputText(a.inputLine, inputPattern) ? a.inputLine : '';
const hasInput = inputText !== '';
// An actively-changing frame means the pane is mid-turn. NEVER
// submit into it — not even at the hold cap. A submit that lands
// while Claude Code is processing a turn goes into CC's own
// queued-input buffer and (a CC-side bug) is re-submitted on every
// subsequent turn, surfacing as the same [DING] re-poking the agent
// ~once per turn indefinitely. So the cap force-delivers only once
// the frame has gone STATIC (idle prompt / genuinely walked away),
// which is exactly when a submit is safe. This defers — never
// drops: priority=high bypasses the guard entirely (above), and the
// periodic re-scan keeps an undelivered message un-archived until an
// idle moment lands it.
// No un-submitted text to protect.
if (!hasInput) {
if (a.frameChanged) {
dbg(`frame changing (mid-turn) → holding ${ev.filename} (hold ${holds + 1}; cap won't force into an active turn)`);
// #101: the hold itself is correct (never submit mid-turn), but
// past the cap the sidecar has proven it is not delivering —
// say so, and stop asserting liveness we have not earned.
if (forceCap) markDeliveryStalled(ev, holds + 1);
return { kind: 'held', inputText: '', staleCount: 0 };
}
return normalDeliver(ev); // frame static (idle / walked away) → safe to submit
}
// Un-submitted input present. A changing frame OR input text that
// changed across retries → the pane is active (mid-turn, or a human
// is typing) → hold, regardless of the cap (see above). Only once
// BOTH frame and input are static do we treat it as walked-away-
// mid-type and preserve-deliver (safe: a static frame is not
// mid-turn, so the submit won't be queued).
const changed =
a.frameChanged ||
ev.lastInputText === undefined ||
ev.lastInputText !== inputText;
if (changed) {
dbg(`pane active (frame/input changing) → holding ${ev.filename} (hold ${holds + 1})`);
// #101: same invariant as the no-input branch above — a pane that
// stays active past the cap means we are not delivering.
if (forceCap) markDeliveryStalled(ev, holds + 1);
return { kind: 'held', inputText, staleCount: 1 };
}
const staleCount = (ev.inputStaleCount ?? 1) + 1;
if (forceCap || staleCount >= inputStaleMax) {
dbg(`input stale ${staleCount}/${inputStaleMax} + frame static → walked away, preserve-deliver ${ev.filename}`);
return preserveDeliver(ev);
}
dbg(`input unchanged ${staleCount}/${inputStaleMax} → holding ${ev.filename}`);
return { kind: 'held', inputText, staleCount };
}
// brief-031 amendment: an internal AbortController so the
// session-watch tick can end runDing on its own (target session
// died → cleanly exit) without process.exit. The caller's
// deps.signal still drives external aborts; we just chain it in.
const internalAc = new AbortController();
if (deps.signal !== undefined) {
if (deps.signal.aborted) internalAc.abort();
else
deps.signal.addEventListener('abort', () => internalAc.abort(), {
once: true,
});
}
const signal = internalAc.signal;
const buffer: BufferedEvent[] = [];
// Filenames whose `buildEvent` failed (e.g. peer's atomic write
// races the watcher fire → read sees mid-rename / partial file).
// Retried on each flush tick. At-least-once semantics — better than
// silently dropping a notice on a transient FS race.
const readPending: Filename[] = [];
// Last successful delivery timestamp per filename. Used by the
// periodic re-scan tick to skip files the agent was recently
// notified about (giving them time to process before we re-poke).
// Entries are pruned lazily at the top of runRescanTick when the
// file is no longer in the inbox (archived).
const deliveredAt = new Map<Filename, number>();
let timer: ReturnType<typeof setInterval> | undefined;
// Guard against re-entrant tryFlush: setInterval schedules the
// callback at fixed times regardless of whether the previous
// invocation is still awaiting `deliver`. Two concurrent flushes
// shifting the same buffer risked out-of-order delivery + wasted
// work; the guard makes flush single-threaded.
let flushing = false;
function ensureTimerArmed(): void {
if (timer !== undefined) return;
timer = setInterval(() => {
// Schedule the flush; ignore the returned promise (errors
// are surfaced via stderr inside `tryFlush`).
void tryFlush();
}, intervalMs);
}
function disarmTimer(): void {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
}
// brief-031: tidy-check tick. Independent of the inbox-arrival
// buffer above; runs on its own interval, reads identity status,
// gates on busy/dnd/unknown, evaluates drift, dedupes per-condition,
// pty-sends a single-line summary when a new condition appears.
const tidyIntervalMs = deps.tidyIntervalMs ?? TIDY_CHECK_INTERVAL_MS;
let tidyTimer: ReturnType<typeof setInterval> | undefined;
let lastTidyFired = { inbox: false };
async function runTidyTick(): Promise<void> {
let state: State;
try {
state = await deps.st.getStatus(deps.identity);
} catch (err) {
log(`st ding: tidy getStatus failed: ${errMsg(err)}\n`);
return; // best-effort; don't arm dedup on errors
}
// Gate: busy/dnd/unknown → no emit, no lastFired update. Drift
// accumulates; next eligible tick catches up.
if (TIDY_GATE_STATES.has(state)) return;
let drift: DriftResult;
try {
const driftOpts: { now?: () => number } = {};
if (deps.tidyNow !== undefined) driftOpts.now = deps.tidyNow;
drift = evaluateDrift(deps.identity, deps.st.root, driftOpts);
} catch (err) {
log(`st ding: tidy evaluate failed: ${errMsg(err)}\n`);
return;
}
const newCondition = drift.inbox && !lastTidyFired.inbox;
if (newCondition && drift.body.length > 0) {
const text = formatTidyLine(drift);
let result: { status: number; stderr: string };
try {
result = await send(deps.ptySession, [text, 'key:return']);
} catch (err) {
log(`st ding: tidy pty send failed: ${errMsg(err)}\n`);
// Don't arm lastFired — we want a retry on next tick.
return;
}
if (result.status !== 0) {
const tail = result.stderr.trim().slice(-200);
log(
`st ding: tidy pty send to "${deps.ptySession}" exited ${result.status}${
tail ? `: ${tail}` : ''
}\n`
);
return; // same — leave lastFired alone for retry
}
}
// Update lastFired on every eligible tick (not just emits) so a
// drift that clears stops counting as "old news" — only its
// recurrence-after-clear re-fires.
lastTidyFired = { inbox: drift.inbox };
}
function startTidyTick(): void {
if (tidyIntervalMs <= 0) return;
tidyTimer = setInterval(() => {
void runTidyTick();
}, tidyIntervalMs);
tidyTimer.unref?.();
}
function stopTidyTick(): void {
if (tidyTimer !== undefined) {
clearInterval(tidyTimer);
tidyTimer = undefined;
}
}
// brief-031 amendment: session-watch tick. When the target pty
// session is gone, abort the internal signal so runDing's
// for-await falls through to the finally block and the daemon
// exits cleanly. Default ON; opt-out via `--no-exit-when-session-gone`.
const exitWhenSessionGone = deps.exitWhenSessionGone !== false;
const sessionWatchIntervalMs =
deps.sessionWatchIntervalMs ?? DEFAULT_SESSION_WATCH_INTERVAL_MS;
const isSessionAlive = deps.isSessionAlive ?? defaultIsSessionAlive;
let sessionWatchTimer: ReturnType<typeof setInterval> | undefined;
// Startup-grace state. The ding sidecar racing pty registration is
// the load-bearing case: evals-claude's live ding-mode run caught
// this as the reason `--ding` delivered NOTHING unattended. The
// ding starts BEFORE the agent's pty session is registered → the
// first tick sees "target gone" → the daemon exits → being
// ephemeral, it never comes back. Fix: only trip the exit path
// AFTER we've seen the target alive at least once. Robust to any
// launch timing; no timeout needed (an operator who typo'd the
// session name will notice from other signals — hooks-loud, no
// delivered `[DING]`s, etc.).
//
// Once we've seen alive, revert to normal exit-when-gone
// behavior — a target that WAS alive but is now gone is a real
// "session ended" signal, not a race.
let seenTargetAlive = false;
// Bookkeeping to keep the "still waiting" log a single line, not
// a per-tick spam.
let loggedWaitingForTarget = false;
// Session-flap debounce: require N consecutive "gone" observations
// before tripping the exit-when-gone path. A pty `--permanent`
// session is auto-restarted by pty's supervisor; between the old
// process exiting and the pidfile being rewritten there's a
// window where `process.kill(pid, 0)` returns ESRCH but the
// session is actually about to come back. Without debounce, ding
// exits right when its target has just briefly flapped — its
// supervisor restarts ding eventually but arrivals during the gap
// are missed. Debounce closes that hole.
let consecutiveMisses = 0;
function runSessionWatchTick(): void {
let alive: boolean;
try {
alive = isSessionAlive(deps.ptySession);
} catch (err) {
// Probe failure: be conservative — treat as alive so we don't
// tear down on a transient permission glitch. Log so the
// operator can investigate. Also reset the miss counter — an
// unknown state shouldn't count as a "gone" observation.
log(`st ding: session-alive check failed: ${errMsg(err)}\n`);
consecutiveMisses = 0;
return;
}
if (alive) {
seenTargetAlive = true;
consecutiveMisses = 0;
return;
}
// alive === false
if (!seenTargetAlive) {
// Startup grace: the target hasn't appeared yet. Log once so
// the operator sees the daemon is waiting (not dead), then
// stay silent until the target appears or we get an external
// signal.
if (!loggedWaitingForTarget) {
log(
`st ding: target session "${deps.ptySession}" not yet ` +
`registered; waiting for it to appear before enabling the ` +
`exit-when-gone watch.\n`
);
loggedWaitingForTarget = true;
}
return;
}
// Post-startup, target has flipped from alive to gone. Debounce:
// require SESSION_GONE_DEBOUNCE_MISSES consecutive misses before
// aborting. A permanent-session flap (~1-2 misses at the default
// 30s interval, or ~1 miss at aggressive test intervals) rides
// through cleanly; a real "session ended" (target won't come
// back) still trips the exit path within a couple of ticks.
consecutiveMisses++;
if (consecutiveMisses < SESSION_GONE_DEBOUNCE_MISSES) {
log(
`st ding: target session "${deps.ptySession}" appears gone ` +
`(miss ${consecutiveMisses}/${SESSION_GONE_DEBOUNCE_MISSES}); ` +
`debouncing before exit.\n`
);
return;
}
log(
`st ding: target session "${deps.ptySession}" is gone; exiting.\n`
);
internalAc.abort();
}
function startSessionWatch(): void {
if (!exitWhenSessionGone) return;
if (sessionWatchIntervalMs <= 0) return;
sessionWatchTimer = setInterval(
runSessionWatchTick,
sessionWatchIntervalMs
);
sessionWatchTimer.unref?.();
}
function stopSessionWatch(): void {
if (sessionWatchTimer !== undefined) {
clearInterval(sessionWatchTimer);
sessionWatchTimer = undefined;
}
}
// Status-file mtime heartbeat — the cross-machine liveness signal (R=30s).
// The ding IS the toucher: it bumps this agent's status mtime on a timer
// WHILE alive, and stopStatusRefresh() (called from stop(), which the
// exit-when-session-gone watch triggers) clears the timer when the harness
// dies — so the touch ceases, the mtime freezes, and a remote reader reads
// it as dead. That death-coupling is the crux; it is verified by the ding
// tests (session-gone -> exit -> touches cease).
//
// KNOWN LIMIT (see docs/KNOWN-LIMITS.md): this catches clean DEATH
// (process exit), not a HANG. A harness that is wedged but whose pty
// session .pid still exists keeps getting touched here -> reads healthy
// while actually stuck. Truly catching that needs the agent to self-touch
// to prove responsiveness (a separate, later improvement).
const statusRefreshIntervalMs =
deps.statusRefreshIntervalMs ?? LIVENESS_HEARTBEAT_MS;
let statusRefreshTimer: ReturnType<typeof setInterval> | undefined;
function runStatusRefreshTick(): void {
// #101 invariant: a sidecar must not write liveness it has not
// earned. While delivery is stalled (a message held past the cap on
// a pane that never goes static) this sidecar is NOT surfacing the
// agent's mail — refreshing the mtime here would advertise an
// availability it cannot honor. Skip the touch instead: the mtime
// freezes, and every reader derives staleness through the existing
// path. `markDeliveryStalled` already logged the reason once.
if (deliveryStalled) {
dbg(
`status refresh suppressed for "${deps.identity}" — delivery stalled`
);
return;
}
const outcome = refreshIdentityStatus(deps.identity, deps.st.root);
if (outcome === 'error') {
log(
`st ding: status refresh for "${deps.identity}" failed (best-effort, will retry next tick).\n`
);
} else if (outcome === 'left-corrupt') {
log(
`st ding: status file for "${deps.identity}" contains invalid content; refresh skipped.\n`
);
}
// refreshed / wrote-default / left-unknown are silent — they're
// either the happy path or a deliberate no-op.
}
function startStatusRefresh(): void {
if (statusRefreshIntervalMs <= 0) return;
statusRefreshTimer = setInterval(
runStatusRefreshTick,
statusRefreshIntervalMs
);
statusRefreshTimer.unref?.();
}
function stopStatusRefresh(): void {
if (statusRefreshTimer !== undefined) {
clearInterval(statusRefreshTimer);
statusRefreshTimer = undefined;
}
}
async function tryFlush(): Promise<void> {
if (flushing) return; // re-entry guard — setInterval doesn't skip
flushing = true;
try {
// Retry any pending reads first — a peer's atomic-rename race
// may have resolved. Failed reads stay in readPending; a
// successful read pushes into buffer for the drain below.
if (readPending.length > 0) {
const attemptList = readPending.splice(0);
for (const fn of attemptList) {
try {
const ev = await buildEvent(deps.st, deps.identity, fn);
buffer.push(ev);
} catch (err) {
log(
`st ding: read retry still failing for ${fn}: ${errMsg(err)}\n`
);
readPending.push(fn);
}
}
}
// #106 follow-up: prune messages the agent archived while they
// were buffered, BEFORE the status gate below. Two reasons this
// cannot wait for the drain loop's own `stillInInbox` check:
//
// 1. The drain never runs while the identity is busy/dnd (the
// SUPPRESS_STATES return below). A busy agent that reads and
// archives its own mail — literally the documented boot
// ritual, "drain your inbox" — would otherwise leave the
// archived event parked in the buffer indefinitely.
// 2. `deliveryStalled` is cleared on the drained checkpoints
// below. If archived events are never pruned, the buffer
// never drains, and a stall outlives the message that caused
// it — permanently suspending the heartbeat of a healthy
// agent whose inbox is empty.
//
// Archived means "no longer needs delivery" regardless of status,
// so this prune is status-independent by construction.
for (let i = buffer.length - 1; i >= 0; i -= 1) {
if (!stillInInbox(buffer[i]!.filename)) {
dbg(
`buffered message ${buffer[i]!.filename} archived while held → dropping stale poke`
);
buffer.splice(i, 1);
}
}
if (buffer.length === 0 && readPending.length === 0) {