tickmarkr 1.96.0 → 2.0.0
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/dist/brand.d.ts +31 -0
- package/dist/brand.js +44 -1
- package/dist/cli/commands/compile.js +32 -1
- package/dist/cli/commands/resume.js +9 -3
- package/dist/cli/commands/run.d.ts +61 -1
- package/dist/cli/commands/run.js +368 -17
- package/dist/cli/commands/status.js +303 -145
- package/dist/compile/collateral.d.ts +25 -0
- package/dist/compile/collateral.js +46 -11
- package/dist/compile/native.js +10 -0
- package/dist/drivers/herdr.d.ts +19 -13
- package/dist/drivers/herdr.js +88 -26
- package/dist/drivers/types.d.ts +2 -0
- package/dist/gates/acceptance.js +17 -7
- package/dist/gates/llm.d.ts +19 -0
- package/dist/gates/llm.js +104 -6
- package/dist/gates/run-gates.d.ts +18 -0
- package/dist/gates/run-gates.js +195 -29
- package/dist/gates/scope.d.ts +9 -1
- package/dist/gates/scope.js +22 -2
- package/dist/graph/graph.d.ts +1 -0
- package/dist/graph/graph.js +19 -2
- package/dist/report/compare.js +17 -2
- package/dist/run/daemon.d.ts +1 -8
- package/dist/run/daemon.js +231 -246
- package/dist/run/environment.d.ts +18 -1
- package/dist/run/environment.js +19 -2
- package/dist/run/journal.d.ts +50 -3
- package/dist/run/journal.js +181 -5
- package/dist/run/protocol.d.ts +4 -4
- package/dist/run/stall.d.ts +30 -0
- package/dist/run/stall.js +173 -0
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +20 -15
- package/skills/tickmarkr-overseer/scripts/seat-send.sh +98 -5
package/dist/run/stall.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { shGit } from "./git.js";
|
|
1
3
|
// OBS-82: normalize known presentation tokens before measuring transcript extent or filtering an
|
|
2
4
|
// LLM-bound transcript. This remains a closed allowlist — ANSI/VT escapes, braille-range spinner
|
|
3
5
|
// glyphs, and elapsed-time tokens bound to time-unit suffixes. Every other byte passes through
|
|
@@ -18,6 +20,177 @@ const ELAPSED_RE = /(?<![\w.])\d+(?:\.\d+)?(?:ms|[hms])(?!\w)/g;
|
|
|
18
20
|
export function normalizeStallSnapshot(text) {
|
|
19
21
|
return text.replace(ANSI_RE, "").replace(SPINNER_RE, "").replace(ELAPSED_RE, "");
|
|
20
22
|
}
|
|
23
|
+
// T2 (OBS-264): a CPU delta needs two samples separated in WALL CLOCK, and the CPU clock is
|
|
24
|
+
// QUANTIZED: darwin's `ps` prints hundredths ("0:00.03"), linux's precise /proc clock advances in
|
|
25
|
+
// jiffies. Equality across a window shorter than the quantum is not evidence of rest. Crossing 30
|
|
26
|
+
// ticks means the tree burned <1 tick in 30, i.e. under ~3% of one core. This process-tree liveness
|
|
27
|
+
// primitive lives here rather than in daemon.ts so lower-level gate waits can use it without closing
|
|
28
|
+
// the daemon -> run-gates -> llm -> daemon dependency cycle.
|
|
29
|
+
const HARVEST_CPU_FLAT_MS = 3_000;
|
|
30
|
+
const HARVEST_CPU_FLAT_TICKS = 30;
|
|
31
|
+
const WORKER_TREE_CPU_ACCOUNTING_POLL_MS = 100;
|
|
32
|
+
const WORKER_TREE_CPU_UNMEASURABLE_SAMPLE_CAP = 20;
|
|
33
|
+
let harvestCpuFlatMs;
|
|
34
|
+
export function harvestCpuFlatWindowMs(resolutionMs) {
|
|
35
|
+
return harvestCpuFlatMs ?? Math.max(HARVEST_CPU_FLAT_MS, resolutionMs * HARVEST_CPU_FLAT_TICKS);
|
|
36
|
+
}
|
|
37
|
+
/** Test seam — pin the quantization-aware flat window without changing production policy. */
|
|
38
|
+
export function setHarvestCpuFlatMsForTests(ms) {
|
|
39
|
+
harvestCpuFlatMs = ms;
|
|
40
|
+
}
|
|
41
|
+
export function resetHarvestCpuFlatMsForTests() {
|
|
42
|
+
harvestCpuFlatMs = undefined;
|
|
43
|
+
}
|
|
44
|
+
// `ps` CPU time: "[[dd-]hh:]mm:ss[.frac]". Anything else is a header or a row this parser must
|
|
45
|
+
// not guess at. `frac` reports whether this host exposes sub-second digits so callers use the
|
|
46
|
+
// sampled clock's quantum rather than assuming one.
|
|
47
|
+
function parsePsCpu(raw) {
|
|
48
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(raw);
|
|
49
|
+
if (!m)
|
|
50
|
+
return undefined;
|
|
51
|
+
const ms = ((Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0)) * 60 + Number(m[3])) * 60_000
|
|
52
|
+
+ Math.round(Number(m[4]) * 1000);
|
|
53
|
+
return { ms, frac: m[4].includes(".") };
|
|
54
|
+
}
|
|
55
|
+
let linuxClockTickMs;
|
|
56
|
+
function linuxProcessCpuMs(pid, cwd) {
|
|
57
|
+
if (!existsSync("/proc/self/stat"))
|
|
58
|
+
return Promise.resolve(undefined);
|
|
59
|
+
// shGit, not sh: the accountant samples at 100ms cadence and must not run the operator's login
|
|
60
|
+
// profile (nvm/pyenv/direnv side effects included) on every sample.
|
|
61
|
+
linuxClockTickMs ??= shGit("getconf CLK_TCK", cwd, 15_000).then((r) => {
|
|
62
|
+
const ticks = r.code === 0 ? Number(r.stdout.trim()) : Number.NaN;
|
|
63
|
+
return Number.isFinite(ticks) && ticks > 0 ? 1_000 / ticks : undefined;
|
|
64
|
+
});
|
|
65
|
+
return linuxClockTickMs.then((resolutionMs) => {
|
|
66
|
+
if (resolutionMs === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
try {
|
|
69
|
+
// `/proc/<pid>/stat` fields 14-17 are user/system jiffies for the process and its waited-for
|
|
70
|
+
// children. Child totals retain tools that start and exit wholly between live-tree polls.
|
|
71
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
72
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
73
|
+
const ticks = Number(fields[11]) + Number(fields[12]) + Number(fields[13]) + Number(fields[14]);
|
|
74
|
+
return Number.isFinite(ticks) ? { ms: ticks * resolutionMs, resolutionMs } : undefined;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Every non-seeded worker process descends from its attempt-unique dispatch script. One ps snapshot
|
|
82
|
+
// finds that root and its descendants. An empty tree is measurable zero; a failed or unparseable
|
|
83
|
+
// snapshot is undefined because missing evidence can never prove inactivity.
|
|
84
|
+
async function workerTreeCpuSnapshot(marker, cwd) {
|
|
85
|
+
const snapshot = await shGit("ps -Awwo pid=,ppid=,time=,command=", cwd, 15_000);
|
|
86
|
+
if (snapshot.code !== 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
const rows = [];
|
|
89
|
+
for (const line of snapshot.stdout.split("\n")) {
|
|
90
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
91
|
+
if (!m)
|
|
92
|
+
continue;
|
|
93
|
+
const cpu = parsePsCpu(m[3]);
|
|
94
|
+
if (cpu !== undefined)
|
|
95
|
+
rows.push({ pid: m[1], ppid: m[2], cpuMs: cpu.ms, frac: cpu.frac, cmd: m[4] });
|
|
96
|
+
}
|
|
97
|
+
if (rows.length === 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
const tree = new Set(rows.filter((p) => p.cmd.includes(marker)).map((p) => p.pid));
|
|
100
|
+
// ps output is not topologically ordered; relax the parent -> child closure until stable.
|
|
101
|
+
for (let grew = true; grew;) {
|
|
102
|
+
grew = false;
|
|
103
|
+
for (const p of rows) {
|
|
104
|
+
if (!tree.has(p.pid) && tree.has(p.ppid)) {
|
|
105
|
+
tree.add(p.pid);
|
|
106
|
+
grew = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const precise = new Map();
|
|
111
|
+
let preciseResolutionMs;
|
|
112
|
+
for (const p of rows) {
|
|
113
|
+
if (!tree.has(p.pid))
|
|
114
|
+
continue;
|
|
115
|
+
const cpu = await linuxProcessCpuMs(p.pid, cwd);
|
|
116
|
+
precise.set(p.pid, cpu?.ms ?? p.cpuMs);
|
|
117
|
+
if (cpu !== undefined)
|
|
118
|
+
preciseResolutionMs = cpu.resolutionMs;
|
|
119
|
+
}
|
|
120
|
+
if (preciseResolutionMs === undefined && existsSync("/proc/self/stat")) {
|
|
121
|
+
preciseResolutionMs = (await linuxProcessCpuMs(String(process.pid), cwd))?.resolutionMs;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
processes: precise,
|
|
125
|
+
resolutionMs: preciseResolutionMs ?? (rows.some((p) => p.frac) ? 10 : 1_000),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function workerTreeCpuMs(marker, cwd) {
|
|
129
|
+
const snapshot = await workerTreeCpuSnapshot(marker, cwd);
|
|
130
|
+
if (snapshot === undefined)
|
|
131
|
+
return undefined;
|
|
132
|
+
return {
|
|
133
|
+
ms: [...snapshot.processes.values()].reduce((sum, cpuMs) => sum + cpuMs, 0),
|
|
134
|
+
resolutionMs: snapshot.resolutionMs,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Sparse live-tree totals forget a tool's CPU as soon as it exits. This attempt-local accountant
|
|
138
|
+
// instead accumulates each observed PID's delta and replaces only the live-PID cursor. Daemon workers
|
|
139
|
+
// and gate workers deliberately share these semantics: the marker is the unique dispatch script,
|
|
140
|
+
// an unreadable sample clears the current evidence, and stopped descendants remain in the total.
|
|
141
|
+
export class WorkerTreeCpuAccountant {
|
|
142
|
+
marker;
|
|
143
|
+
cwd;
|
|
144
|
+
active = false;
|
|
145
|
+
loop;
|
|
146
|
+
live = new Map();
|
|
147
|
+
totalMs = 0;
|
|
148
|
+
gaps = 0;
|
|
149
|
+
consecutiveGaps = 0;
|
|
150
|
+
latest;
|
|
151
|
+
constructor(marker, cwd) {
|
|
152
|
+
this.marker = marker;
|
|
153
|
+
this.cwd = cwd;
|
|
154
|
+
}
|
|
155
|
+
async sample() {
|
|
156
|
+
const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd);
|
|
157
|
+
if (snapshot === undefined) {
|
|
158
|
+
this.gaps++;
|
|
159
|
+
this.live.clear();
|
|
160
|
+
this.latest = undefined;
|
|
161
|
+
if (++this.consecutiveGaps >= WORKER_TREE_CPU_UNMEASURABLE_SAMPLE_CAP)
|
|
162
|
+
this.active = false;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.consecutiveGaps = 0;
|
|
166
|
+
for (const [pid, cpuMs] of snapshot.processes) {
|
|
167
|
+
const prior = this.live.get(pid);
|
|
168
|
+
this.totalMs += prior === undefined || cpuMs < prior ? cpuMs : cpuMs - prior;
|
|
169
|
+
}
|
|
170
|
+
this.live = snapshot.processes;
|
|
171
|
+
this.latest = { ms: this.totalMs, resolutionMs: snapshot.resolutionMs };
|
|
172
|
+
}
|
|
173
|
+
async start() {
|
|
174
|
+
if (this.active)
|
|
175
|
+
return;
|
|
176
|
+
this.active = true;
|
|
177
|
+
await this.sample();
|
|
178
|
+
this.loop = (async () => {
|
|
179
|
+
while (this.active) {
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, WORKER_TREE_CPU_ACCOUNTING_POLL_MS));
|
|
181
|
+
if (this.active)
|
|
182
|
+
await this.sample();
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
}
|
|
186
|
+
read() {
|
|
187
|
+
return { cpu: this.latest, gaps: this.gaps };
|
|
188
|
+
}
|
|
189
|
+
async stop() {
|
|
190
|
+
this.active = false;
|
|
191
|
+
await this.loop;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
21
194
|
// T1 (OBS-262): the rescue nudge's adapter scope — claude-code only (steering path proven,
|
|
22
195
|
// OBS-122). Widening it is a future fixture-capture chore (an occupied-frame capture per adapter,
|
|
23
196
|
// OBS-181 scar), never a drive-by edit. Lives in the stall module so the watchdog's policy and its
|
package/package.json
CHANGED
|
@@ -64,13 +64,15 @@ through brief lineage. **An executor choice nobody made is still an executor cho
|
|
|
64
64
|
**FIVE-TAB CANON (standing operator layout — corrected three times on 2026-07-27, layout approved
|
|
65
65
|
2026-07-29, re-earned 2026-08-17):**
|
|
66
66
|
- `OVERSEER` — you. Do not add a second live run surface: the daemon self-places the shipped board
|
|
67
|
-
|
|
68
|
-
- `ORCH` — the orchestrator
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
ABOVE the supervising seat that invokes the run.
|
|
68
|
+
- `ORCH` — the orchestrator with the daemon-placed, run-id-pinned shipped board STACKED ABOVE it: the
|
|
69
|
+
board owns the tab's full width and the top 72% of its height, and the orchestrator's own narration
|
|
70
|
+
is the rail underneath. **Look for that `role: "watch"` pane; never hand-place or hand-roll a live
|
|
71
|
+
run surface. Nothing else, ever: a work seat NEVER splits into the ORCH tab.** Operator verbatim:
|
|
72
|
+
*"in orch tab should be the orch and the watcher only."* Re-earned 2026-08-17: a planning seat split
|
|
73
|
+
beside the orchestrator, and the operator caught it, again. The daemon owns this vertical stack and
|
|
74
|
+
places it the same way at every terminal width; neither the worker-pane halving floor, nor a
|
|
75
|
+
measured column count, nor an overseer split command places this pane.
|
|
74
76
|
- Worker/seat tabs — tickmarkr opens ONE TAB PER TASK itself; GSD-leg seats get the same treatment
|
|
75
77
|
(own tab, or a shared WORKERS tab), never the ORCH tab.
|
|
76
78
|
- `CONSULT · <topic>` — ONE shared tab for ALL consultants of a round, side-by-side splits; never one
|
|
@@ -134,10 +136,12 @@ journal tail to decide what happens next, or sweeping orphans — you have taken
|
|
|
134
136
|
### What the ORCHESTRATOR does, and what you require of it
|
|
135
137
|
|
|
136
138
|
- **The live surface arrives with the run.** `tickmarkr run` is stdout-silent until run-end by design;
|
|
137
|
-
its daemon self-places one shipped `role: "watch"` board
|
|
138
|
-
board to the daemon's run id. **Look for the matching
|
|
139
|
-
as a daemon/run liveness fault and use the normal
|
|
140
|
-
a replacement live surface.
|
|
139
|
+
its daemon self-places one shipped `role: "watch"` board ABOVE the supervising seat — full width, top
|
|
140
|
+
72% of the height, narration below — and pins that board to the daemon's run id. **Look for the matching
|
|
141
|
+
daemon-placed pane.** If it is absent, treat that as a daemon/run liveness fault and use the normal
|
|
142
|
+
recovery path; never hand-place, hand-roll, or launch a replacement live surface. A board the daemon
|
|
143
|
+
could not stack is not silently re-arranged: the split is closed and the run continues BOARDLESS, so an
|
|
144
|
+
absent board means the placement failed, never that it landed somewhere else in the tab.
|
|
141
145
|
- **The journal is the source of truth**, not panes. Watchers go on `run-end` / `task-human` /
|
|
142
146
|
`task-failed` / `consult-verdict`; never sleep-poll inside an agent turn. **Never key a watcher on an
|
|
143
147
|
agent's `done`** — that is turn end and fires the moment a seat finishes acknowledging you.
|
|
@@ -551,10 +555,11 @@ orchestrator turn boundary.
|
|
|
551
555
|
safe 53 → floor 108"*), and it splits right only while `paneWidth/2 ≥ 108 + 2` (`herdr.ts:494`),
|
|
552
556
|
otherwise **down**. Apply the same test by hand: `herdr pane layout --pane <id>`, halve the width,
|
|
553
557
|
and if the halves fall under the floor, split `--direction down`.
|
|
554
|
-
- **The daemon-placed ORCH board is outside this manual split rule
|
|
555
|
-
worker-pane trailers; the daemon, not the overseer,
|
|
556
|
-
the supervising seat
|
|
557
|
-
|
|
558
|
+
- **The daemon-placed ORCH board is outside this manual split rule — width does not place it at all.**
|
|
559
|
+
The halving bound protects worker-pane trailers; the board carries none. The daemon, not the overseer,
|
|
560
|
+
splits the supervising seat DOWN at ratio 0.72 and swaps the new pane ABOVE it (`boardSplitPlan`,
|
|
561
|
+
`src/drivers/herdr.ts`), so the board is full width and the narration is the rail beneath it at every
|
|
562
|
+
terminal width. Look for that pane and do not split, place, resize, or recreate it.
|
|
558
563
|
- **Binary splits cannot produce an even 3-column row at any width.** 220 goes to 110/55/55 whichever
|
|
559
564
|
pane you split. **At a 220-col terminal the width-derived cap is TWO side-by-side panes**; a third
|
|
560
565
|
seat goes below one of them, or into its own tab. "Three panes" is a *height* heuristic
|
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
#
|
|
26
26
|
# First output line is the machine-greppable verdict:
|
|
27
27
|
# DELIVERED_SUBMITTED | QUEUED_BEHIND_TURN | SEND_UNSUBMITTED | INTERRUPT_FAILED | TARGET_GONE
|
|
28
|
-
# | REFUSED_SIZE | REFUSED_BOX_OCCUPIED
|
|
28
|
+
# | REFUSED_SIZE | REFUSED_BOX_OCCUPIED | SEND_UNVERIFIED | SEND_REFUSED | SEND_FAILED
|
|
29
|
+
#
|
|
30
|
+
# A success verdict names the surface that delivered when it was not the default one, so a fallback
|
|
31
|
+
# is visible in the transcript rather than inferred (OBS-552).
|
|
29
32
|
#
|
|
30
33
|
# This verifies SUBMISSION — the text left the input box — never that the seat understood or acted.
|
|
31
34
|
# Read the seat's ARTIFACT for that; a send receipt is not a response.
|
|
@@ -46,6 +49,14 @@ status_of() {
|
|
|
46
49
|
| sed -n 's/.*"agent_status":"\([a-z_]*\)".*/\1/p' | head -1
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
# The submit-fallback needs a PANE id, and $TARGET may be an agent NAME. `agent get` answers for a
|
|
53
|
+
# detected-but-undrivable seat — it is only `agent prompt` that refuses one (OBS-552) — so this
|
|
54
|
+
# resolves either target form. The first `pane_id` in the response is the agent's own.
|
|
55
|
+
pane_of() {
|
|
56
|
+
herdr agent get "$TARGET" 2>/dev/null \
|
|
57
|
+
| sed -n 's/.*"pane_id":"\([^"]*\)".*/\1/p' | head -1
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
# The rendered `❯` line — the only state that discriminates submitted from sitting. head -1 returns
|
|
50
61
|
# the first rendered line, i.e. a PREFIX of a wrapped draft, so callers compare prefixes, never
|
|
51
62
|
# whole sentences (OBS-396: a full-sentence match returns false on a message that arrived intact).
|
|
@@ -54,6 +65,24 @@ prompt_line() {
|
|
|
54
65
|
| sed -n 's/^[[:space:]]*❯[[:space:]]*//p' | head -1 | sed 's/[[:space:]]*$//'
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
# STAGED-QUEUE DISCRIMINATOR (OBS-552 addendum, captured 2026-08-19 on a live kimi seat). After its
|
|
69
|
+
# first turn ends, kimi stages every later message into a SECOND `❯` region —
|
|
70
|
+
# `❯ <text> ↑ to edit · ctrl-s to steer immediately` — and EMPTIES the ordinary box when it does. So
|
|
71
|
+
# `prompt_line`'s emptiness is not submission on a kimi seat: it is the queue swallowing the
|
|
72
|
+
# directive. Six consecutive paths were tried against that state and the pane revision never moved
|
|
73
|
+
# off 3; `seat-send.sh` called the first one DELIVERED_SUBMITTED. This reads the staging AFFORDANCE
|
|
74
|
+
# rather than the vendor name, so any TUI that grows the same queue is covered without a matcher list.
|
|
75
|
+
# Returns the staged TEXT only: the `❯` marker and the trailing affordance are chrome, and leaving the
|
|
76
|
+
# affordance on would break `is_ours`, whose contract is that the rendered text is a PREFIX of $MSG
|
|
77
|
+
# (OBS-396). A wrapped staged entry still yields a prefix, which is all the comparison needs.
|
|
78
|
+
staged_line() {
|
|
79
|
+
herdr agent read "$TARGET" --source visible --lines 14 2>/dev/null \
|
|
80
|
+
| sed -n '/↑ to edit/{/steer/p;}' | head -1 \
|
|
81
|
+
| sed 's/^[[:space:]]*❯[[:space:]]*//' \
|
|
82
|
+
| sed 's/[[:space:]]*↑ to edit.*$//' \
|
|
83
|
+
| sed 's/[[:space:]]*$//'
|
|
84
|
+
}
|
|
85
|
+
|
|
57
86
|
# Is $1 a rendered prefix of our message? (first line of a wrapped draft = prefix of MSG)
|
|
58
87
|
is_ours() {
|
|
59
88
|
case "$MSG" in "$1"*) return 0 ;; *) return 1 ;; esac
|
|
@@ -107,9 +136,73 @@ fi
|
|
|
107
136
|
|
|
108
137
|
# Atomic text+Enter honouring the pane's live bracketed-paste mode — never send-text + separate
|
|
109
138
|
# Enter, which is the "sitting unsubmitted" failure by construction.
|
|
110
|
-
|
|
139
|
+
#
|
|
140
|
+
# OBS-552: the outcome of this verb was thrown away — `>/dev/null 2>&1` discarded BOTH the exit code
|
|
141
|
+
# and the reason. A refusal that wrote NOTHING then fell through to the prompt-line checks below with
|
|
142
|
+
# an empty line, which is byte-identical to a clean submit, and this script exited 0 announcing
|
|
143
|
+
# DELIVERED_SUBMITTED — or QUEUED_BEHIND_TURN when the seat read `working`, which also EXPLAINS AWAY
|
|
144
|
+
# the silence that follows. Measured 2026-08-19 against a live seat: `herdr agent prompt` refuses a
|
|
145
|
+
# detected-but-undrivable occupant with `agent_not_ready` and never touches the input box, while
|
|
146
|
+
# `pane run` delivers to that same pane. A send receipt that cannot fail is not a receipt.
|
|
147
|
+
PROMPT_ERR=$(herdr agent prompt "$TARGET" "$MSG" 2>&1 >/dev/null)
|
|
148
|
+
PROMPT_RC=$?
|
|
149
|
+
VIA=""
|
|
150
|
+
if [ "$PROMPT_RC" -ne 0 ]; then
|
|
151
|
+
case "$PROMPT_ERR" in
|
|
152
|
+
*agent_not_ready*)
|
|
153
|
+
# UNEQUIVOCALLY PRE-WRITE: herdr rejected the target before typing anything, so a second
|
|
154
|
+
# delivery cannot duplicate. This is the ONLY class that earns a fallback.
|
|
155
|
+
PANE=$(pane_of)
|
|
156
|
+
if [ -z "$PANE" ]; then
|
|
157
|
+
echo "SEND_REFUSED $TARGET — agent prompt refused pre-write and no pane id resolved: $PROMPT_ERR"
|
|
158
|
+
exit 1
|
|
159
|
+
fi
|
|
160
|
+
PANE_ERR=$(herdr pane run "$PANE" "$MSG" 2>&1 >/dev/null)
|
|
161
|
+
PANE_RC=$?
|
|
162
|
+
if [ "$PANE_RC" -ne 0 ]; then
|
|
163
|
+
echo "SEND_FAILED $TARGET — both delivery surfaces refused; nothing was typed."
|
|
164
|
+
echo " agent prompt: $PROMPT_ERR"
|
|
165
|
+
echo " pane run ($PANE): $PANE_ERR"
|
|
166
|
+
exit 1
|
|
167
|
+
fi
|
|
168
|
+
VIA=" (via pane run $PANE — agent prompt refused pre-write: $PROMPT_ERR)"
|
|
169
|
+
;;
|
|
170
|
+
*)
|
|
171
|
+
# AMBIGUOUS: this class cannot prove the write did not land, and a second delivery APPENDS to
|
|
172
|
+
# whatever did. So it never retries — it surfaces the cause and stops, which is the one outcome
|
|
173
|
+
# the discarded stderr made impossible.
|
|
174
|
+
echo "SEND_UNVERIFIED $TARGET — agent prompt exited $PROMPT_RC with an ambiguous outcome: $PROMPT_ERR"
|
|
175
|
+
echo " NOT retrying (a second send appends to anything that landed). Read the pane, then decide."
|
|
176
|
+
exit 1
|
|
177
|
+
;;
|
|
178
|
+
esac
|
|
179
|
+
fi
|
|
111
180
|
|
|
112
181
|
sleep 2
|
|
182
|
+
|
|
183
|
+
# BEFORE any emptiness verdict: a non-empty staged queue means this seat has accepted text and is not
|
|
184
|
+
# running it, so an empty ordinary box proves nothing. Fail closed — there is no delivery remedy to
|
|
185
|
+
# offer, and every one was tried against the captured instance: bare `enter` on the empty box no-ops,
|
|
186
|
+
# `up`+`enter` moves the entry to the box and puts it straight BACK in the queue, `herdr agent
|
|
187
|
+
# send-keys` cannot type kimi's own `ctrl-s` affordance (no ctrl chords), and `pane run` is a
|
|
188
|
+
# shell-pane path with no effect on a TUI. Recovery is the operator's: capture provenance
|
|
189
|
+
# (pid/ppid/pgid/lstart), TERM the CLI, then `herdr agent start … -- --auto -c` in the SAME pane —
|
|
190
|
+
# session continuation preserved 136k of context and the queue drained on restart. NEVER retry here:
|
|
191
|
+
# the entry may drain at any moment and a second send would then run twice.
|
|
192
|
+
STAGED=$(staged_line)
|
|
193
|
+
if [ -n "$STAGED" ]; then
|
|
194
|
+
if is_ours "$STAGED"; then
|
|
195
|
+
echo "SEND_UNVERIFIED $TARGET — the directive is STAGED in the seat's queue, not running: $STAGED"
|
|
196
|
+
else
|
|
197
|
+
echo "SEND_UNVERIFIED $TARGET — this seat's queue holds an entry that is not draining, so nothing sent now is running: $STAGED"
|
|
198
|
+
fi
|
|
199
|
+
echo " An empty prompt line does NOT mean submitted on a staging TUI — the box is empty BECAUSE the"
|
|
200
|
+
echo " text went to the queue. Do NOT re-send: the queue may drain at any moment and run it twice."
|
|
201
|
+
echo " No input path drains it (enter no-ops, up+enter re-queues, send-keys has no ctrl chords,"
|
|
202
|
+
echo " pane run is shell-only). Capture provenance, TERM the CLI, restart it in the SAME pane with"
|
|
203
|
+
echo " session continuation, then verify the pane REVISION moves before trusting any receipt."
|
|
204
|
+
exit 1
|
|
205
|
+
fi
|
|
113
206
|
PL=$(prompt_line)
|
|
114
207
|
if [ -n "$PL" ] && is_ours "$PL"; then
|
|
115
208
|
# The text is SITTING, not sent — the swallowed-Enter shape. Submitting the existing draft is not a
|
|
@@ -127,14 +220,14 @@ fi
|
|
|
127
220
|
|
|
128
221
|
if [ -n "$PL" ]; then
|
|
129
222
|
# Not ours: either autosuggest ghost (dim SGR — benign) or a draft that appeared under us.
|
|
130
|
-
echo "DELIVERED_SUBMITTED $TARGET — prompt line clear of our text; NOTE it now holds: $PL"
|
|
223
|
+
echo "DELIVERED_SUBMITTED $TARGET$VIA — prompt line clear of our text; NOTE it now holds: $PL"
|
|
131
224
|
echo " ANSI-verify (dim = ghost). If typed, treat per D-206: discriminate before anyone clears it."
|
|
132
225
|
exit 0
|
|
133
226
|
fi
|
|
134
227
|
|
|
135
228
|
if [ -n "$QUEUED" ]; then
|
|
136
|
-
echo "QUEUED_BEHIND_TURN $TARGET — submitted into a working seat's queue; it is READ at turn end. A freeze-class or superseding directive belongs behind TKR_INTERRUPT=1."
|
|
229
|
+
echo "QUEUED_BEHIND_TURN $TARGET$VIA — submitted into a working seat's queue; it is READ at turn end. A freeze-class or superseding directive belongs behind TKR_INTERRUPT=1."
|
|
137
230
|
else
|
|
138
|
-
echo "DELIVERED_SUBMITTED $TARGET"
|
|
231
|
+
echo "DELIVERED_SUBMITTED $TARGET$VIA"
|
|
139
232
|
fi
|
|
140
233
|
exit 0
|