polygram 0.10.0-rc.33 → 0.10.0-rc.35

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.10.0-rc.33",
4
+ "version": "0.10.0-rc.35",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -0,0 +1,144 @@
1
+ /**
2
+ * hook-event-tail — typed-event parser around the per-session hook
3
+ * ndjson that `polygram-hook-append.js` writes for the H1 hook-based
4
+ * turn observability (docs/0.10.0-tmux-hook-observability.md).
5
+ *
6
+ * Mirrors the JSONL stream's `pipeToParser(tail)` shape so TmuxProcess
7
+ * wires it the same way `_armSessionLogTail` wires the JSONL tail.
8
+ *
9
+ * Per-line behaviour:
10
+ * - Parse JSON. If the line is missing, malformed, or the helper
11
+ * wrapped it with `polygram_parse_error`, emit a `parse-error`
12
+ * event (observability — H1 soak measures how often this fires).
13
+ * - Discriminate on `hook_event_name`. Known events become typed
14
+ * HookEvent records with normalized fields; unknown event names
15
+ * pass through as `unknown` with the raw object attached so we
16
+ * can investigate without re-deploying.
17
+ * - Empty lines are ignored (atomic-append interleave between two
18
+ * helper invocations can produce them in theory — H1 measures
19
+ * whether it happens in practice on macOS).
20
+ *
21
+ * Normalized HookEvent shape (the fields downstream code may rely on
22
+ * once H1's observer-only soak proves the stream — H2+ phases consume
23
+ * these):
24
+ *
25
+ * {
26
+ * type: 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit'
27
+ * | 'Stop' | 'SubagentStop' | 'Notification' | 'unknown'
28
+ * | 'parse-error',
29
+ * sessionId, transcriptPath, cwd, permissionMode, // common
30
+ * toolName, toolUseId, toolInput, toolResponse, durationMs, // tool events
31
+ * agentId, agentType, // subagent-inner
32
+ * agentTranscriptPath, // SubagentStop
33
+ * prompt, // UserPromptSubmit
34
+ * stopHookActive, lastAssistantMessage, // Stop
35
+ * receivedAtMs, raw, // always
36
+ * }
37
+ *
38
+ * Per the 2.1.142 spike, `parent_tool_use_id` is NOT a field, and
39
+ * `SubagentStart` does not fire (for general-purpose subagents) —
40
+ * neither is in the typed shape.
41
+ */
42
+
43
+ 'use strict';
44
+
45
+ const { LogTail } = require('../tmux/log-tail');
46
+
47
+ const KNOWN_EVENT_NAMES = new Set([
48
+ 'UserPromptSubmit',
49
+ 'PreToolUse',
50
+ 'PostToolUse',
51
+ 'SubagentStop',
52
+ 'Stop',
53
+ 'Notification',
54
+ ]);
55
+
56
+ /**
57
+ * Normalize one raw hook payload (already JSON.parsed) into the
58
+ * shape downstream code consumes. Unknown shapes pass through as
59
+ * `unknown` so a 2.1.143-style schema drift doesn't silently lose
60
+ * events.
61
+ */
62
+ function normalizeHookEvent(raw) {
63
+ if (raw && typeof raw === 'object' && raw.polygram_parse_error) {
64
+ return {
65
+ type: 'parse-error',
66
+ error: raw.polygram_parse_error,
67
+ receivedAtMs: raw.polygram_received_at_ms ?? null,
68
+ raw,
69
+ };
70
+ }
71
+ const name = raw && typeof raw === 'object' ? raw.hook_event_name : null;
72
+ const type = KNOWN_EVENT_NAMES.has(name) ? name : 'unknown';
73
+ return {
74
+ type,
75
+ sessionId: raw?.session_id ?? null,
76
+ transcriptPath: raw?.transcript_path ?? null,
77
+ cwd: raw?.cwd ?? null,
78
+ permissionMode: raw?.permission_mode ?? null,
79
+ toolName: raw?.tool_name ?? null,
80
+ toolUseId: raw?.tool_use_id ?? null,
81
+ toolInput: raw?.tool_input ?? null,
82
+ toolResponse: raw?.tool_response ?? null,
83
+ durationMs: raw?.duration_ms ?? null,
84
+ agentId: raw?.agent_id ?? null,
85
+ agentType: raw?.agent_type ?? null,
86
+ agentTranscriptPath: raw?.agent_transcript_path ?? null,
87
+ prompt: raw?.prompt ?? null,
88
+ stopHookActive: raw?.stop_hook_active ?? null,
89
+ lastAssistantMessage: raw?.last_assistant_message ?? null,
90
+ receivedAtMs: raw?.polygram_received_at_ms ?? null,
91
+ raw,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Wrap a LogTail with line-by-line hook parsing. Forwards parsed
97
+ * events via `'event'` (same shape as session-log-parser.pipeToParser).
98
+ *
99
+ * @returns the same emitter (chainable).
100
+ */
101
+ function pipeHookParser(tail) {
102
+ tail.on('line', (line) => {
103
+ const trimmed = line.trim();
104
+ if (!trimmed) return; // blank-line guard (interleave-paranoid)
105
+ let raw;
106
+ try {
107
+ raw = JSON.parse(trimmed);
108
+ } catch (err) {
109
+ tail.emit('event', {
110
+ type: 'parse-error',
111
+ error: err.message,
112
+ receivedAtMs: Date.now(),
113
+ raw: trimmed.length > 1024 ? trimmed.slice(0, 1024) + '…' : trimmed,
114
+ });
115
+ return;
116
+ }
117
+ tail.emit('event', normalizeHookEvent(raw));
118
+ });
119
+ return tail;
120
+ }
121
+
122
+ /**
123
+ * One-shot helper: build a LogTail at the given path with the
124
+ * H1-typical config (watch mode, no skipExisting because a fresh
125
+ * spawn's ndjson is empty), wire the hook parser, and return it.
126
+ * Caller calls `.start()` and `.on('event', ...)`.
127
+ */
128
+ function createHookTail({ path: filePath, logger = console } = {}) {
129
+ const tail = new LogTail({
130
+ path: filePath,
131
+ intervalMs: 50,
132
+ skipExisting: false,
133
+ useWatch: 'auto',
134
+ logger,
135
+ });
136
+ return pipeHookParser(tail);
137
+ }
138
+
139
+ module.exports = {
140
+ KNOWN_EVENT_NAMES,
141
+ normalizeHookEvent,
142
+ pipeHookParser,
143
+ createHookTail,
144
+ };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * hook-settings — build the per-session `--settings <file>` JSON
3
+ * polygram injects at claude-spawn time for the H1 hook-based
4
+ * observability stream.
5
+ *
6
+ * See docs/0.10.0-tmux-hook-observability.md. The settings file
7
+ * registers a single command-type hook on every event we want to
8
+ * observe; the command is `polygram-hook-append.js` (a Node helper at
9
+ * a fixed absolute path) which appends each event as a compacted JSON
10
+ * line to the per-session ndjson.
11
+ *
12
+ * Path layout:
13
+ * ~/.polygram/<bot>/hooks/<sid>.settings.json (this file's output)
14
+ * ~/.polygram/<bot>/hooks/<sid>.ndjson (hook stream sink)
15
+ *
16
+ * 2.1.142 spike findings (2026-05-21) baked into the schema:
17
+ * - hooks DO fire alongside `--strict-mcp-config
18
+ * --setting-sources project,local --settings <file>` (so the file
19
+ * is the right transport for the Music topic).
20
+ * - hooks are non-blocking by default → no `async`/`timeout` needed,
21
+ * but `timeout: 30` is included as a belt-and-braces backstop in
22
+ * case a future CLI release flips the default to sync. 30 s is a
23
+ * safe ceiling (the helper is single-syscall fast in practice).
24
+ * - registered events: the five confirmed in the spike +
25
+ * `Notification` (not yet observed; harmless to register).
26
+ * `SubagentStart` is intentionally omitted — it did not fire for
27
+ * general-purpose subagents on 2.1.142 and the design does not
28
+ * depend on it.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const path = require('path');
34
+ const fs = require('fs');
35
+
36
+ const HOOK_HELPER_ABS_PATH = path.resolve(__dirname, 'polygram-hook-append.js');
37
+
38
+ // Events we register hooks for. Order is informational only — claude
39
+ // merges by event name.
40
+ const HOOK_EVENTS = [
41
+ 'UserPromptSubmit',
42
+ 'PreToolUse',
43
+ 'PostToolUse',
44
+ 'SubagentStop',
45
+ 'Stop',
46
+ 'Notification',
47
+ ];
48
+
49
+ /**
50
+ * Per-bot hooks dir (parent of both settings + ndjson files).
51
+ * Mirrors `lib/tmux/tmux-runner.js#debugLogPath`'s `~/.polygram/<bot>/logs`
52
+ * convention. No /tmp fallback when HOME is unset — fail loud (audit
53
+ * M4 style — symlink races on world-writable dirs).
54
+ *
55
+ * @param {string} botName
56
+ * @param {string} [hooksDir] override (for tests)
57
+ */
58
+ function hooksBaseDir(botName, hooksDir) {
59
+ if (!hooksDir && !process.env.HOME) {
60
+ throw Object.assign(
61
+ new Error('HOME env var unset; refusing /tmp fallback for hooks dir'),
62
+ { code: 'HOME_UNSET' },
63
+ );
64
+ }
65
+ const safeBot = String(botName).replace(/[^\w-]/g, '_');
66
+ return hooksDir || path.join(process.env.HOME, '.polygram', safeBot, 'hooks');
67
+ }
68
+
69
+ function hookNdjsonPath(botName, sessionId, hooksDir) {
70
+ return path.join(hooksBaseDir(botName, hooksDir), `${sessionId}.ndjson`);
71
+ }
72
+
73
+ function hookSettingsPath(botName, sessionId, hooksDir) {
74
+ return path.join(hooksBaseDir(botName, hooksDir), `${sessionId}.settings.json`);
75
+ }
76
+
77
+ /**
78
+ * Build the settings-JSON object that claude reads via `--settings`.
79
+ *
80
+ * @param {object} opts
81
+ * @param {string} opts.ndjsonPath absolute path the helper appends to
82
+ * @param {string} [opts.helperPath] absolute path to polygram-hook-append.js
83
+ * (defaults to the one shipped with polygram)
84
+ */
85
+ function buildHookSettings({ ndjsonPath, helperPath = HOOK_HELPER_ABS_PATH } = {}) {
86
+ if (!ndjsonPath || !path.isAbsolute(ndjsonPath)) {
87
+ throw new TypeError('buildHookSettings: ndjsonPath must be an absolute path');
88
+ }
89
+ if (!path.isAbsolute(helperPath)) {
90
+ throw new TypeError('buildHookSettings: helperPath must be an absolute path');
91
+ }
92
+ const command = `node ${helperPath} ${ndjsonPath}`;
93
+ // Per-event entry: PreToolUse/PostToolUse take a matcher (".*" =
94
+ // every tool); the lifecycle events don't.
95
+ const matched = (matcher) => [{ matcher, hooks: [{ type: 'command', command, timeout: 30 }] }];
96
+ const unmatched = () => [{ hooks: [{ type: 'command', command, timeout: 30 }] }];
97
+ const hooks = {};
98
+ for (const evt of HOOK_EVENTS) {
99
+ hooks[evt] = (evt === 'PreToolUse' || evt === 'PostToolUse') ? matched('.*') : unmatched();
100
+ }
101
+ return { hooks };
102
+ }
103
+
104
+ /**
105
+ * Write the settings JSON to disk (creates parent dirs). Returns the
106
+ * absolute path. Caller pushes `--settings <path>` to the spawn args.
107
+ *
108
+ * The empty ndjson sink is also touched here so the LogTail's
109
+ * fs.watch can attach immediately (LogTail handles ENOENT, but touching
110
+ * eliminates a small race window on the first hook event).
111
+ */
112
+ function writeHookFiles({ botName, sessionId, hooksDir, helperPath, fsImpl = fs } = {}) {
113
+ const settingsPath = hookSettingsPath(botName, sessionId, hooksDir);
114
+ const ndjsonPath = hookNdjsonPath(botName, sessionId, hooksDir);
115
+ fsImpl.mkdirSync(path.dirname(settingsPath), { recursive: true });
116
+ const settings = buildHookSettings({ ndjsonPath, helperPath });
117
+ fsImpl.writeFileSync(settingsPath, JSON.stringify(settings));
118
+ // Touch the ndjson so fs.watch attaches before the first hook fires.
119
+ const fd = fsImpl.openSync(ndjsonPath, 'a');
120
+ fsImpl.closeSync(fd);
121
+ return { settingsPath, ndjsonPath };
122
+ }
123
+
124
+ /**
125
+ * Best-effort unlink of both files. Called on kill + orphan-sweep.
126
+ * Errors are swallowed (ENOENT is the common case after a clean kill).
127
+ */
128
+ function removeHookFiles({ botName, sessionId, hooksDir, fsImpl = fs } = {}) {
129
+ for (const p of [hookSettingsPath(botName, sessionId, hooksDir),
130
+ hookNdjsonPath(botName, sessionId, hooksDir)]) {
131
+ try { fsImpl.unlinkSync(p); } catch { /* swallow */ }
132
+ }
133
+ }
134
+
135
+ module.exports = {
136
+ HOOK_HELPER_ABS_PATH,
137
+ HOOK_EVENTS,
138
+ hooksBaseDir,
139
+ hookNdjsonPath,
140
+ hookSettingsPath,
141
+ buildHookSettings,
142
+ writeHookFiles,
143
+ removeHookFiles,
144
+ };
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * polygram-hook-append — claude-CLI hook subprocess that appends one
4
+ * compacted JSON line to a per-session ndjson file.
5
+ *
6
+ * Invoked by claude as: `node <abs path>/polygram-hook-append.js <ndjson abs path>`
7
+ * Stdin: one JSON document (the hook payload). Stdout: nothing.
8
+ *
9
+ * Used by the tmux backend H1 (hook-based turn observability). See
10
+ * docs/0.10.0-tmux-hook-observability.md.
11
+ *
12
+ * Behaviour:
13
+ * - Reads stdin to EOF, parses as JSON.
14
+ * - Stamps `polygram_received_at_ms` (Date.now) so we can measure
15
+ * Pre↔Post latency from polygram's wall clock independent of the
16
+ * hook's own `duration_ms`.
17
+ * - Writes ONE line (JSON.stringify + '\n') with a single fs.writeSync
18
+ * on a fd opened O_APPEND. On macOS, O_APPEND atomicity is NOT
19
+ * guaranteed above PIPE_BUF (~4 KB); H1 is observe-only and records
20
+ * parse failures so we can measure interleave during the soak.
21
+ * - Bad JSON → emits a wrapped record with the raw body and a marker
22
+ * so the tail's parser can surface it (never silent-drop).
23
+ * - Failures (missing argv, open/write error) exit non-zero but never
24
+ * throw out of `claude` (claude already runs us with stdout/stderr
25
+ * captured and a timeout); the worst case is a single missing line.
26
+ *
27
+ * Determinism: no shell, no external deps. Resolved at fixed absolute
28
+ * path so the `command:` string in the settings JSON is free of
29
+ * metachars and `~`-expansion.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+
36
+ const outPath = process.argv[2];
37
+ if (!outPath) {
38
+ process.stderr.write('polygram-hook-append: missing ndjson path (argv[2])\n');
39
+ process.exit(2);
40
+ }
41
+
42
+ let buf = '';
43
+ process.stdin.setEncoding('utf8');
44
+ process.stdin.on('data', (chunk) => { buf += chunk; });
45
+ process.stdin.on('end', () => {
46
+ let line;
47
+ try {
48
+ const obj = JSON.parse(buf);
49
+ obj.polygram_received_at_ms = Date.now();
50
+ line = JSON.stringify(obj);
51
+ } catch (err) {
52
+ // Preserve the raw body so the tail can flag it; never silent-drop.
53
+ line = JSON.stringify({
54
+ polygram_parse_error: err.message,
55
+ polygram_received_at_ms: Date.now(),
56
+ raw: buf.length > 64 * 1024 ? buf.slice(0, 64 * 1024) + '…[truncated]' : buf,
57
+ });
58
+ }
59
+ let fd;
60
+ try {
61
+ fd = fs.openSync(outPath, 'a');
62
+ fs.writeSync(fd, line + '\n');
63
+ } catch (err) {
64
+ process.stderr.write(`polygram-hook-append: write failed: ${err.message}\n`);
65
+ process.exitCode = 3;
66
+ } finally {
67
+ if (fd != null) {
68
+ try { fs.closeSync(fd); } catch { /* swallow */ }
69
+ }
70
+ }
71
+ });
@@ -44,6 +44,8 @@ const { POLYGRAM_DISPLAY_HINT } = require('../telegram/display-hint');
44
44
  const { verifyPinnedClaudeBin } = require('../claude-bin');
45
45
  const { createAsyncLock } = require('../async-lock');
46
46
  const { TurnPhase, isLegalTransition } = require('./turn-phase');
47
+ const { writeHookFiles, removeHookFiles } = require('./hook-settings');
48
+ const { createHookTail } = require('./hook-event-tail');
47
49
 
48
50
  // ─── Pinned claude CLI version ───────────────────────────────────────
49
51
  //
@@ -472,6 +474,40 @@ class TmuxProcess extends Process {
472
474
  args.push('--strict-mcp-config');
473
475
  args.push('--setting-sources', 'project,local');
474
476
  }
477
+ // 0.10.0 H1 — hook-based turn observability. Inject a per-spawn
478
+ // settings file that registers command-type hooks for every
479
+ // event we want to observe. Hooks fire INSIDE subagents and
480
+ // are non-blocking on 2.1.142 (spike 2026-05-21 confirmed).
481
+ // The hook command appends each event as a compacted JSON line
482
+ // to a per-session ndjson; polygram tails it via `_armHookTail`
483
+ // below. See docs/0.10.0-tmux-hook-observability.md.
484
+ //
485
+ // OBSERVER-ONLY in H1: events are persisted to the events DB
486
+ // (`hook-event` rows) but no control flow consumes them.
487
+ // Mirrors the patience-model Commit 1 discipline — soak proves
488
+ // stream reliability before H2 wires the reactor.
489
+ //
490
+ // Survives `--setting-sources project,local` (spike confirmed
491
+ // the `--settings <file>` layer is honored even when user-level
492
+ // settings are excluded).
493
+ try {
494
+ const { settingsPath, ndjsonPath } = writeHookFiles({
495
+ botName: this.botName,
496
+ sessionId: this.claudeSessionId,
497
+ });
498
+ this._hookSettingsPath = settingsPath;
499
+ this._hookNdjsonPath = ndjsonPath;
500
+ args.push('--settings', settingsPath);
501
+ } catch (err) {
502
+ // Refuse to spawn without hooks would be too aggressive in
503
+ // H1 (observer-only); log and continue without injection so
504
+ // a transient FS error never blocks a real turn.
505
+ this.logger.warn?.(
506
+ `[${this.label}] hook-settings write failed (continuing without hooks): ${err.message}`,
507
+ );
508
+ this._hookSettingsPath = null;
509
+ this._hookNdjsonPath = null;
510
+ }
475
511
  // Cross-backend parity: SDK appends polygram's Telegram display
476
512
  // hint to every agent's systemPrompt (lib/sdk/build-options.js).
477
513
  // Without this, the spawned claude session has no idea it's
@@ -579,6 +615,11 @@ class TmuxProcess extends Process {
579
615
  // LogTail tolerates ENOENT.
580
616
  this._cwd = cwd;
581
617
  this._armSessionLogTail({ resuming: Boolean(ctx.existingSessionId) });
618
+ // H1 — same-pattern hook tail. Only arm when the settings
619
+ // write succeeded above (otherwise there's nothing to tail).
620
+ if (this._hookNdjsonPath) {
621
+ this._armHookTail();
622
+ }
582
623
 
583
624
  // G6 — block until TUI is responsive.
584
625
  await this._waitForReady();
@@ -600,6 +641,19 @@ class TmuxProcess extends Process {
600
641
  try { this._sessionLogTail.close(); } catch { /* swallow */ }
601
642
  this._sessionLogTail = null;
602
643
  }
644
+ if (this._hookTail) {
645
+ try { this._hookTail.close(); } catch { /* swallow */ }
646
+ this._hookTail = null;
647
+ }
648
+ // Remove the per-spawn settings + ndjson so a retry gets a
649
+ // clean pair. Best-effort (ENOENT is fine).
650
+ if (this._hookSettingsPath || this._hookNdjsonPath) {
651
+ try {
652
+ removeHookFiles({ botName: this.botName, sessionId: this.claudeSessionId });
653
+ } catch { /* swallow */ }
654
+ this._hookSettingsPath = null;
655
+ this._hookNdjsonPath = null;
656
+ }
603
657
  try {
604
658
  await this.runner.killSession(this.tmuxName);
605
659
  } catch (killErr) {
@@ -726,139 +780,60 @@ class TmuxProcess extends Process {
726
780
  // ~1-2KB → the claude TUI collapses it into a `[Pasted text #N]`
727
781
  // placeholder whose single post-paste Enter can be absorbed
728
782
  // mid-ingest, leaving the prompt UNSUBMITTED — the turn never
729
- // starts. `_confirmSubmitViaJsonl` confirms the submit landed by
783
+ // starts. `_scheduleSubmitRetries` confirms the submit landed by
730
784
  // waiting for this paste's correlation token to surface in a
731
785
  // JSONL `user-message` (the ONLY reliable signal — capture-pane
732
786
  // false-positives on the collapsed placeholder); it re-sends
733
787
  // Enter on a miss and, after bounded retries, REJECTS with
734
788
  // TMUX_SUBMIT_FAILED.
735
789
  //
736
- // The confirm drives TWO derived promises below:
737
- // submitConfirmP rejects TMUX_SUBMIT_FAILED a racer that
738
- // fails the turn fast and loud; on success it never settles.
739
- // submitOkP — resolves ONLY once the submit is confirmed;
740
- // on failure it never settles. It GATES the capture-pane
741
- // racer: an "idle" capture-pane is meaningless until the turn
742
- // has actually started pre-gate, a `[Pasted text #N]`
743
- // prompt sitting unsubmitted reads as an idle (=="complete")
744
- // pane, so the capture racer would win with TMUX_NO_JSONL_TEXT
745
- // and mask the real TMUX_SUBMIT_FAILED cause.
790
+ // 0.10.0 Commit 2: `_scheduleSubmitRetries` is `paste-parked`-
791
+ // aware. If the predicate observed our paste queued by a busy
792
+ // TUI (the C1 trace), it waits for the eventual user-message
793
+ // instead of re-sending Enter / failing loud. See the method
794
+ // doc.
795
+ //
796
+ // 0.10.0 Commit 3: the submit-confirm watchdog. On success the
797
+ // turn proceeds; on TMUX_SUBMIT_FAILED `_awaitSettle` fails the
798
+ // turn fast. The pre-Commit-3 `submitConfirmP`/`submitOkP`
799
+ // never-settling-promise gymnastics are gone — `_awaitSettle`
800
+ // reads `turn.submitConfirmed` (a predicate field) directly to
801
+ // gate capture-pane quiescence, which is clearer and is the
802
+ // milestone where control flow starts consuming the predicate.
746
803
  const confirmP = turn.token
747
- ? this._confirmSubmitViaJsonl(turn.token, turn)
804
+ ? this._scheduleSubmitRetries(turn.token, turn)
748
805
  : Promise.resolve(); // no token — nothing to confirm
749
- const submitConfirmP = confirmP.then(() => new Promise(() => {}));
750
- const submitOkP = confirmP.then(() => true, () => new Promise(() => {}));
751
-
752
- // R7: an ABSOLUTE timeout wrapping the whole race. The
753
- // capture-pane completion detector re-checks its deadline only
754
- // BETWEEN `captureWide` subprocess calls — if a single `tmux
755
- // capture-pane` wedges (its promise never resolves), the poll
756
- // loop is parked on the await and neither the capture-complete
757
- // promise nor the JSONL `resultPromise` ever settle, so `send()`
758
- // hangs forever and starves the turn queue.
759
- //
760
- // One setTimeout drives two things: an `abortP` (resolves — fed
761
- // to _awaitTurnComplete so its poll loop can break out of a
762
- // wedged capture and release the scheduler), and a
763
- // `turnDeadlineP` (rejects — the third racer below, guaranteeing
764
- // _runTurn ALWAYS settles within turnTimeoutMs). `unref` so the
765
- // timer never keeps the process alive on its own; cleared in the
766
- // `finally` so a turn that ends early leaves no dangling timer.
767
- let turnDeadlineTimer = null;
768
- let signalAbort = null;
769
- const abortP = new Promise((resolve) => { signalAbort = resolve; });
770
- const turnDeadlineP = new Promise((_resolve, reject) => {
771
- turnDeadlineTimer = setTimeout(() => {
772
- signalAbort();
773
- reject(Object.assign(
774
- new Error('TmuxProcess: turn did not complete in time'),
775
- { code: 'TMUX_TURN_TIMEOUT', tmuxName: this.tmuxName },
776
- ));
777
- }, turnTimeoutMs);
778
- turnDeadlineTimer.unref?.();
779
- });
780
-
781
- // Race: JSONL terminal result vs capture-pane quiescence vs the
782
- // hard turn timeout. 0.10.0 Phase 4 §6: JSONL is the SOLE source
783
- // of reply text. capture-pane is a LIVENESS signal only — it
784
- // detects "the turn is done" so we never wait forever for a
785
- // `result` that never arrives, but it NEVER delivers text.
786
- const captureCompleteP = this._awaitTurnComplete({
787
- timeoutMs: turnTimeoutMs, abortP,
788
- });
789
- // The capture-pane loop may end via the absolute-deadline abort
790
- // (it returns the abort sentinel) or reject (its own timeout).
791
- // Both are swallowed here — the `turnDeadlineP` reject below is
792
- // what actually fails the turn.
793
- //
794
- // B7: gate the capture racer behind `submitOkP`. A capture-pane
795
- // "idle" reading only means "the turn completed" once the turn
796
- // has actually STARTED. If the paste never submitted, the pane is
797
- // idle because the prompt is still sitting in the input box — not
798
- // because a turn finished. Without the gate the capture racer
799
- // would win with TMUX_NO_JSONL_TEXT and mask the real
800
- // TMUX_SUBMIT_FAILED. `captureCompleteP` is still awaited inside
801
- // the racer (so its rejection is always handled — never orphaned
802
- // into an unhandled rejection) and the poll loop / scheduler
803
- // refcount lifecycle is unchanged; only the racer's eligibility
804
- // to WIN is gated on submit confirmation.
805
- const captureRaceP = (async () => {
806
- let buf;
807
- try {
808
- buf = await captureCompleteP;
809
- } catch {
810
- return new Promise(() => {}); // capture's own timeout — turnDeadlineP fails the turn
811
- }
812
- if (buf === ABORT_SENTINEL) return new Promise(() => {});
813
- await submitOkP; // gate: capture cannot win pre-submit-confirm
814
- return { kind: 'capture' };
815
- })();
816
806
 
817
- let resolvedVia = 'jsonl';
818
- let winner;
819
- try {
820
- winner = await Promise.race([
821
- turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
822
- captureRaceP,
823
- turnDeadlineP,
824
- turn.interruptP.then(() => ({ kind: 'interrupt' })),
825
- // B7: TMUX_SUBMIT_FAILED rejection fails the turn fast.
826
- submitConfirmP,
827
- ]);
828
-
829
- // If capture-pane won but the turn used a tool, the agent is
830
- // still working the "ready" hint was a transient idle between
831
- // tool calls. Wait for the real terminal result from JSONL, but
832
- // keep the absolute deadline armed so a JSONL `result` that
833
- // never arrives still fails the turn rather than hanging it.
834
- // The interrupt signal still wins here too — Bug 3: an
835
- // interrupted tool turn writes no terminal JSONL `result`, so
836
- // without this racer it would hang to `turnTimeoutMs`.
837
- //
838
- // B10: an outstanding `Agent` subagent counts as "tool in
839
- // flight" exactly like a foreground `Bash` — its `tool-use`
840
- // already set `toolUsedThisTurn`, so this branch catches the
841
- // common case. The race where capture wins BEFORE the `Agent`
842
- // tool_use line is tailed is handled by the §6 re-check below.
843
- if (winner.kind === 'capture' && turn.toolUsedThisTurn) {
844
- winner = await Promise.race([
845
- turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
846
- turnDeadlineP,
847
- turn.interruptP.then(() => ({ kind: 'interrupt' })),
848
- ]);
849
- }
850
- } finally {
851
- if (turnDeadlineTimer) clearTimeout(turnDeadlineTimer);
852
- // Ensure the capture-pane loop is released even when the JSONL
853
- // race won — otherwise it would poll on until its own internal
854
- // deadline (and, with a shared PollScheduler, hold a refcount).
855
- signalAbort();
807
+ // Commit 3: ONE settle subscription replaces the 5-way
808
+ // `Promise.race` (+ its nested capture-then-rewait). See
809
+ // `_awaitSettle`. The hardened behaviours are preserved as
810
+ // *dispositions* rather than race branches:
811
+ // - jsonl : terminal JSONL `result` (the happy path)
812
+ // - interrupt : `/stop` (Bug 3)
813
+ // - submit-fail : TMUX_SUBMIT_FAILED (B7)
814
+ // - quiesced : capture-pane idle, GATED on the predicate —
815
+ // only fires when submitConfirmed (subsumes the
816
+ // old submitOkP gate / B7) AND no tool/subagent
817
+ // outstanding (subsumes B10 — capture can no
818
+ // longer settle a turn mid-subagent, so the old
819
+ // nested re-wait is unnecessary)
820
+ // - timeout : W1 absolute deadline (one setTimeout, not a
821
+ // racer)
822
+ const outcome = await this._awaitSettle(turn, { turnTimeoutMs, confirmP });
823
+
824
+ if (outcome.kind === 'submit-fail') throw outcome.err;
825
+ if (outcome.kind === 'timeout') {
826
+ throw Object.assign(
827
+ new Error('TmuxProcess: turn did not complete in time'),
828
+ { code: 'TMUX_TURN_TIMEOUT', tmuxName: this.tmuxName },
829
+ );
856
830
  }
857
831
 
832
+ let resolvedVia = 'jsonl';
858
833
  let text;
859
834
  let resultSubtype = 'success';
860
835
  let stopReason = null;
861
- if (winner.kind === 'interrupt') {
836
+ if (outcome.kind === 'interrupt') {
862
837
  // Bug 3: `interrupt()` ended the turn. C-c was sent to the
863
838
  // TUI; the turn stops here instead of hanging until the
864
839
  // absolute `turnTimeoutMs`. Deliver whatever partial text the
@@ -869,11 +844,11 @@ class TmuxProcess extends Process {
869
844
  text = turn.text || '';
870
845
  resultSubtype = 'interrupted';
871
846
  stopReason = 'interrupted';
872
- } else if (winner.kind === 'jsonl') {
873
- text = turn.text || winner.ev.text || '';
874
- resultSubtype = winner.ev.subtype || 'success';
875
- stopReason = winner.ev.stopReason || null;
876
- if (winner.ev.sessionId) this.claudeSessionId = winner.ev.sessionId;
847
+ } else if (outcome.kind === 'jsonl') {
848
+ text = turn.text || outcome.ev.text || '';
849
+ resultSubtype = outcome.ev.subtype || 'success';
850
+ stopReason = outcome.ev.stopReason || null;
851
+ if (outcome.ev.sessionId) this.claudeSessionId = outcome.ev.sessionId;
877
852
  // R10: a genuinely-empty terminal `result` — end_turn, no
878
853
  // reply text, AND no tool ran this turn — is the agent
879
854
  // producing literally nothing (a thinking-only terminal
@@ -896,42 +871,65 @@ class TmuxProcess extends Process {
896
871
  );
897
872
  }
898
873
  } else {
899
- // Capture-pane quiescence judged the turn complete. Force the
900
- // JSONL aggregator to finalize any buffered message so the
901
- // structured `result` settles turn.resultPromise now.
874
+ // outcome.kind === 'quiesced': capture-pane went idle AND the
875
+ // predicate confirmed it is SAFE to conclude (submitConfirmed
876
+ // + no outstanding tool/subagent — see `_awaitSettle`). JSONL
877
+ // is the SOLE source of reply text; capture-pane never delivers
878
+ // text.
879
+ //
880
+ // B10 is structurally gone here: `_awaitSettle` cannot emit a
881
+ // `quiesced` outcome while `outstandingSubagents` (or
882
+ // `outstandingTools`) is non-empty, so a subagent turn can NO
883
+ // LONGER reach this branch mid-flight — it settles via the
884
+ // JSONL `result` (or the W1 deadline) instead. The old nested
885
+ // re-wait + `subagent-wait` emit are therefore removed.
902
886
  this._sessionLogTail?.flushParser?.();
903
887
  if (turn.text) {
904
888
  resolvedVia = 'jsonl-streamed';
905
889
  text = turn.text;
906
890
  } else {
891
+ // No streamed text yet — the terminal JSONL `result` may be
892
+ // milliseconds behind the pane going idle. Wait a short grace
893
+ // for it (interrupt still wins, Bug 3).
907
894
  const lateGraceMs = this.lateGraceMs ?? 1500;
908
895
  let late = await Promise.race([
909
896
  turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
897
+ turn.interruptP.then(() => ({ kind: 'interrupt' })),
910
898
  new Promise((r) => setTimeout(() => r({ kind: 'no-jsonl' }), lateGraceMs)),
911
899
  ]);
912
- // B10 (shumorobot Music topic, 2026-05-20): the main agent
913
- // delegated to an `Agent` subagent within ~7 s, then the main
914
- // pane went quiescent for MINUTES while the subagent ran in
915
- // its own sidechain. capture-pane read that quiescence as
916
- // "done"; the main agent had emitted only the `Agent` call so
917
- // no JSONL reply text existed yet, and the §6 fail-loud below
918
- // fired ~grace-window in closing a turn that was genuinely
919
- // in flight. A subagent is still running iff its `Agent`
920
- // tool_use has no matching `tool-result` yet. While one is
921
- // outstanding, capture-pane quiescence of the MAIN pane is
922
- // meaningless the turn completes only when the subagent
923
- // returns and the main agent emits its real terminal reply.
924
- // Wait for that JSONL `result`, bounded by the absolute turn
925
- // deadline so a genuinely wedged turn still fails loud.
926
- if (late.kind === 'no-jsonl' && turn.outstandingSubagents.size > 0) {
900
+ // B10 production race (shumorobot Music topic): the pane went
901
+ // idle BEFORE the `Agent` tool_use line was tailed, so the
902
+ // `_awaitSettle` B10 gate saw an empty outstanding set and let
903
+ // `quiesced` through. The `Agent` line then lands DURING this
904
+ // late grace, populating `outstandingSubagents`. The main
905
+ // pane stays quiescent for MINUTES while the subagent runs in
906
+ // its sidechainthat quiescence must NOT be read as "done."
907
+ // Wait for the real terminal JSONL `result`, bounded by the
908
+ // turn's remaining absolute budget (the `_awaitSettle` W1
909
+ // timer was cleared when `quiesced` won, so we re-arm a
910
+ // fresh remaining-budget timeout to the SAME wall-clock
911
+ // ceiling). Generalised to `outstandingTools` too a long
912
+ // foreground tool (dl-batch) is the same shape.
913
+ if (late.kind === 'no-jsonl'
914
+ && (turn.outstandingSubagents.size > 0
915
+ || turn.outstandingTools.size > 0)) {
927
916
  this.emit('subagent-wait', {
928
917
  outstanding: turn.outstandingSubagents.size,
918
+ outstandingTools: turn.outstandingTools.size,
929
919
  turnId: turn.turnId,
930
920
  });
921
+ const remainingMs = Math.max(
922
+ 0, (turn.startedAt + turnTimeoutMs) - this._now());
931
923
  late = await Promise.race([
932
924
  turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
933
- turnDeadlineP,
934
925
  turn.interruptP.then(() => ({ kind: 'interrupt' })),
926
+ new Promise((_resolve, reject) => {
927
+ const t = setTimeout(() => reject(Object.assign(
928
+ new Error('TmuxProcess: turn did not complete in time'),
929
+ { code: 'TMUX_TURN_TIMEOUT', tmuxName: this.tmuxName },
930
+ )), remainingMs);
931
+ t.unref?.();
932
+ }),
935
933
  ]);
936
934
  }
937
935
  if (late.kind === 'interrupt') {
@@ -946,13 +944,12 @@ class TmuxProcess extends Process {
946
944
  stopReason = late.ev.stopReason || null;
947
945
  if (late.ev.sessionId) this.claudeSessionId = late.ev.sessionId;
948
946
  } else {
949
- // §6: capture-pane judged the turn done, but JSONL
950
- // produced NO reply text within the grace window. FAIL
951
- // LOUD — never fall back to capture-pane diff text. That
952
- // fallback WAS the echoed-input failure (the pane diff
953
- // returned the user's own echoed prompt) and the
954
- // banner-as-reply failure (L1). The error result clears
955
- // the reactor explicitly instead of delivering garbage.
947
+ // §6: capture-pane judged the turn done, but JSONL produced
948
+ // NO reply text within the grace window. FAIL LOUD — never
949
+ // fall back to capture-pane diff text (that WAS the
950
+ // echoed-input failure and the banner-as-reply L1 failure).
951
+ // The error result clears the reactor explicitly instead of
952
+ // delivering garbage.
956
953
  throw Object.assign(
957
954
  new Error('turn produced no JSONL reply text within grace window'),
958
955
  { code: 'TMUX_NO_JSONL_TEXT' },
@@ -967,11 +964,10 @@ class TmuxProcess extends Process {
967
964
  const u = this._lastUsage;
968
965
  const cost = u ? computeCostUsd(u, u.model) : null;
969
966
  turn.state = 'done';
970
- // Predicate (observer-only): terminal phase. The JSONL `result`
971
- // event also drives this via `_evaluatePhaseFromSessionEvent`,
972
- // but the capture-pane / interrupt / submit-fail branches do
973
- // not; this ensures every successful exit lands the turn in
974
- // `done` regardless of which racer won.
967
+ // Terminal phase. The JSONL `result` event also drives DONE via
968
+ // `_evaluatePhaseFromSessionEvent`, but the quiesced / interrupt
969
+ // outcomes do not; this ensures every successful exit lands the
970
+ // turn in `done` regardless of which settle outcome won.
975
971
  this._setPhase(
976
972
  turn,
977
973
  TurnPhase.DONE,
@@ -1012,6 +1008,103 @@ class TmuxProcess extends Process {
1012
1008
  }
1013
1009
  }
1014
1010
 
1011
+ /**
1012
+ * 0.10.0 Commit 3: settle a turn via a single subscription instead
1013
+ * of the old 5-way `Promise.race` (+ its nested capture-then-rewait).
1014
+ *
1015
+ * Returns an `outcome` the caller maps to text/subtype/stopReason:
1016
+ * { kind: 'jsonl', ev } — terminal JSONL `result` arrived
1017
+ * (the authoritative happy path)
1018
+ * { kind: 'interrupt' } — `interrupt()` fired (Bug 3)
1019
+ * { kind: 'submit-fail', err }—`_scheduleSubmitRetries` rejected
1020
+ * TMUX_SUBMIT_FAILED (B7)
1021
+ * { kind: 'quiesced' } — capture-pane idle AND the predicate
1022
+ * says it is SAFE to conclude
1023
+ * { kind: 'timeout' } — W1 absolute deadline
1024
+ *
1025
+ * The structural win over the old race:
1026
+ * - B7 gate: capture quiescence is ignored until
1027
+ * `turn.submitConfirmed` (a predicate field) — no more
1028
+ * `submitOkP = confirmP.then(() => new Promise(() => {}))`.
1029
+ * - B10 gate: capture quiescence is ignored while any tool OR
1030
+ * subagent is outstanding (`outstandingTools` /
1031
+ * `outstandingSubagents`) — a subagent turn can no longer reach
1032
+ * the `quiesced` outcome mid-flight, so the old nested re-wait +
1033
+ * `subagent-wait` emit are unnecessary. The turn settles via the
1034
+ * JSONL `result` (or W1) instead, which is exactly B10's intent.
1035
+ * - W1 is ONE `setTimeout`, not a racer.
1036
+ *
1037
+ * Capture-pane is still polled (heartbeat + approval-prompt
1038
+ * detection live in `_awaitTurnComplete`); `signalAbort` releases
1039
+ * the poll loop + PollScheduler refcount the instant the turn
1040
+ * settles, exactly as the old `finally { signalAbort() }` did.
1041
+ */
1042
+ _awaitSettle(turn, { turnTimeoutMs, confirmP }) {
1043
+ let signalAbort = null;
1044
+ const abortP = new Promise((resolve) => { signalAbort = resolve; });
1045
+ return new Promise((resolve) => {
1046
+ let done = false;
1047
+ let deadlineTimer = null;
1048
+ const finish = (outcome) => {
1049
+ if (done) return;
1050
+ done = true;
1051
+ if (deadlineTimer) clearTimeout(deadlineTimer);
1052
+ // Release the capture-pane poll loop (and, with a shared
1053
+ // PollScheduler, its refcount) even when a non-capture outcome
1054
+ // won — mirrors the old `finally { signalAbort() }`.
1055
+ signalAbort();
1056
+ resolve(outcome);
1057
+ };
1058
+
1059
+ // 1. Terminal JSONL `result` — settled by `_flushActiveGroup`
1060
+ // via `turn.settleResult`. The happy path.
1061
+ turn.resultPromise.then((ev) => finish({ kind: 'jsonl', ev }));
1062
+
1063
+ // 2. Interrupt (`/stop` → C-c). Bug 3.
1064
+ turn.interruptP.then(() => finish({ kind: 'interrupt' }));
1065
+
1066
+ // 3. Submit-confirm. On success the turn proceeds (no settle);
1067
+ // on TMUX_SUBMIT_FAILED fail the turn fast (B7).
1068
+ confirmP.then(
1069
+ () => { /* submitted ok — turn proceeds */ },
1070
+ (err) => finish({ kind: 'submit-fail', err }),
1071
+ );
1072
+
1073
+ // 4. Capture-pane quiescence, GATED by the predicate.
1074
+ (async () => {
1075
+ let buf;
1076
+ try {
1077
+ buf = await this._awaitTurnComplete({ timeoutMs: turnTimeoutMs, abortP });
1078
+ } catch {
1079
+ return; // capture's own timeout — the W1 deadline (#5) settles
1080
+ }
1081
+ if (buf === ABORT_SENTINEL) return; // released by another outcome
1082
+ // B7 gate: a paste that never submitted leaves the pane idle
1083
+ // because the prompt still sits in the input box — not because
1084
+ // a turn finished. Ignore capture until the submit is
1085
+ // confirmed. (A token-less turn — no confirm to wait on — is
1086
+ // exempt: submitConfirmed stays false but there's nothing to
1087
+ // gate.)
1088
+ if (turn.token && !turn.submitConfirmed) return;
1089
+ // B10 gate: a tool or subagent is in flight — the main pane is
1090
+ // quiescent because the agent is WORKING, not done. Ignore
1091
+ // capture; settle via JSONL `result` (or W1) when the work
1092
+ // returns.
1093
+ if (turn.outstandingTools.size > 0
1094
+ || turn.outstandingSubagents.size > 0) return;
1095
+ finish({ kind: 'quiesced' });
1096
+ })();
1097
+
1098
+ // 5. W1 absolute deadline — one timer, not a racer. `unref` so
1099
+ // it never keeps the process alive on its own.
1100
+ deadlineTimer = setTimeout(
1101
+ () => finish({ kind: 'timeout' }),
1102
+ turnTimeoutMs,
1103
+ );
1104
+ deadlineTimer.unref?.();
1105
+ });
1106
+ }
1107
+
1015
1108
  /**
1016
1109
  * Retire a finished primary turn and drain the next queued one.
1017
1110
  */
@@ -1021,6 +1114,13 @@ class TmuxProcess extends Process {
1021
1114
  // without a terminal `result` (e.g. turnTimeoutMs) cannot leak
1022
1115
  // its buffered message into turn N+1.
1023
1116
  this._sessionLogTail?.flushParser?.();
1117
+ // Commit 2: clear any lingering submit-confirm waiter for this
1118
+ // turn's token. The parked branch of `_scheduleSubmitRetries`
1119
+ // races the turn's own settle promises so it normally self-cleans,
1120
+ // but a turn that ends via the hard W1 deadline (turnDeadlineP
1121
+ // rejects in `_runTurn`, never resolving `resultPromise`) would
1122
+ // otherwise leave a dangling Map entry. Defensive + cheap.
1123
+ if (turn?.token) this._submitConfirms.delete(turn.token);
1024
1124
  const qi = this.pendingQueue.indexOf(turn);
1025
1125
  if (qi >= 0) this.pendingQueue.splice(qi, 1);
1026
1126
  this._dropFromActiveGroup(turn);
@@ -1415,6 +1515,52 @@ class TmuxProcess extends Process {
1415
1515
  this._sessionLogPath = logPath;
1416
1516
  }
1417
1517
 
1518
+ /**
1519
+ * H1 hook tail — open a typed-event tail on the per-session ndjson
1520
+ * that `polygram-hook-append.js` appends to, and forward normalized
1521
+ * HookEvent objects to `_handleHookEvent`.
1522
+ *
1523
+ * Mirrors `_armSessionLogTail`: same LogTail watch/poll-fallback
1524
+ * pattern, idempotent, swallows ENOENT (the file is touched at spawn
1525
+ * time but a tail-before-write race is still possible). OBSERVER-
1526
+ * ONLY — `_handleHookEvent` only persists events; no control flow
1527
+ * consumes them in H1.
1528
+ *
1529
+ * See docs/0.10.0-tmux-hook-observability.md.
1530
+ */
1531
+ _armHookTail() {
1532
+ if (this._hookTail) return; // idempotent
1533
+ if (!this._hookNdjsonPath) {
1534
+ this.logger.warn?.(`[${this.label}] _armHookTail: no ndjson path, skipping`);
1535
+ return;
1536
+ }
1537
+ const tail = createHookTail({ path: this._hookNdjsonPath, logger: this.logger });
1538
+ tail.on('event', (ev) => this._handleHookEvent(ev));
1539
+ tail.on('error', (err) => {
1540
+ this.logger.warn?.(`[${this.label}] hook-tail error: ${err.message}`);
1541
+ });
1542
+ tail.start();
1543
+ this._hookTail = tail;
1544
+ }
1545
+
1546
+ /**
1547
+ * Observer-only hook-event handler. Persists each event for the
1548
+ * H1 soak (so the trajectory can be inspected against real Music
1549
+ * traffic) and emits a `hook-event` event so process-manager's
1550
+ * `onHookEvent` callback writes it to the events DB.
1551
+ *
1552
+ * No `turn.*` field consumes hook signals in H1. The next phases
1553
+ * (see hook-observability doc):
1554
+ * H2 — reactor wiring (kills the fear).
1555
+ * H3 — predicate progress + W1 retirement.
1556
+ * H4 — `Stop` as authoritative completion.
1557
+ */
1558
+ _handleHookEvent(ev) {
1559
+ // Parse errors and unknown event shapes are intentionally
1560
+ // forwarded — H1 measures how often they fire on real traffic.
1561
+ this.emit('hook-event', ev);
1562
+ }
1563
+
1418
1564
  _handleSessionEvent(ev) {
1419
1565
  // Predicate (observer-only): snapshot the active group's turns
1420
1566
  // BEFORE the existing branches run. The `result` and `last-prompt`
@@ -1674,7 +1820,7 @@ class TmuxProcess extends Process {
1674
1820
  this._confirmPaste(tokens);
1675
1821
  // B7: a user-message is the proof that a primary paste actually
1676
1822
  // STARTED a turn (claude registered the prompt). Release any
1677
- // _confirmSubmitViaJsonl waiter for these tokens.
1823
+ // _scheduleSubmitRetries waiter for these tokens.
1678
1824
  this._confirmSubmit(tokens);
1679
1825
  let matched = [];
1680
1826
  for (const tok of tokens) {
@@ -1829,7 +1975,7 @@ class TmuxProcess extends Process {
1829
1975
  * TUI into a `[Pasted text #N]` placeholder whose single post-paste
1830
1976
  * Enter can be absorbed mid-ingest, leaving the prompt unsubmitted —
1831
1977
  * but that submit-confirmation runs as a concurrent racer in
1832
- * `_runTurn` (`_confirmSubmitViaJsonl`), NOT here. Blocking
1978
+ * `_runTurn` (`_scheduleSubmitRetries`), NOT here. Blocking
1833
1979
  * `_pasteAndEnter` on the confirm would hold `_pasteLock` across the
1834
1980
  * whole confirm window and stall every following paste — an autosteer
1835
1981
  * that should fold into the primary turn could never paste.
@@ -1843,7 +1989,7 @@ class TmuxProcess extends Process {
1843
1989
  // (it false-positived on `[Pasted text #N]`). The runner just
1844
1990
  // pastes + Enter. Submit confirmation for a PRIMARY turn is
1845
1991
  // JSONL-token-based and runs as a CONCURRENT racer in `_runTurn`
1846
- // (`_confirmSubmitViaJsonl`) — NOT here. Blocking `_pasteAndEnter`
1992
+ // (`_scheduleSubmitRetries`) — NOT here. Blocking `_pasteAndEnter`
1847
1993
  // on the confirm would hold `_pasteLock` across the whole confirm
1848
1994
  // window and stall every following paste (an autosteer that
1849
1995
  // SHOULD fold into the primary turn could never paste). The
@@ -1869,17 +2015,33 @@ class TmuxProcess extends Process {
1869
2015
  }
1870
2016
 
1871
2017
  /**
1872
- * B7: confirm a primary paste actually submitted by waiting for its
2018
+ * Confirm a primary paste actually submitted by waiting for its
1873
2019
  * correlation `token` to surface in a JSONL `user-message`. On each
1874
2020
  * miss, re-send Enter (the prior Enter was absorbed by the TUI's
1875
2021
  * bracketed-paste ingest of a `[Pasted text #N]` block). After
1876
2022
  * `submitConfirmRetries` exhausted misses, throw `TMUX_SUBMIT_FAILED`.
1877
2023
  *
1878
- * The signal is the `user-message` ONLY not `queue-operation`. A
1879
- * `queue-operation enqueue` means the paste was parked in the TUI
1880
- * queue, which for a primary paste into an idle TUI would itself be
1881
- * wrong; the genuine "the prompt started a turn" proof is the
1882
- * `user-message` claude writes when it registers the prompt.
2024
+ * 0.10.0 Commit 2 `paste-parked`-aware (the C1 fix). The B7
2025
+ * predecessor (`_confirmSubmitViaJsonl`) re-sent Enter on every miss
2026
+ * and failed loud after 5, with NO way to tell "the Enter was
2027
+ * absorbed, the prompt is stuck" (genuine submit failure) apart from
2028
+ * "the TUI was busy and legitimately PARKED the paste in its queue"
2029
+ * (a paste that WILL submit when the prior turn finishes). The
2030
+ * 2026-05-20 C1 trace was the latter failing loud: a paste the TUI
2031
+ * queued got 5 spurious Enter re-sends then `TMUX_SUBMIT_FAILED`.
2032
+ *
2033
+ * The turn-phase predicate now distinguishes them: a
2034
+ * `queue-operation enqueue` carrying THIS turn's `corr-id` (or the
2035
+ * `Press up to edit queued messages` capture-pane fallback) sets
2036
+ * `turn.parked = true`. Once parked:
2037
+ * - STOP re-sending Enter — the paste is in the TUI queue; another
2038
+ * Enter could submit a DIFFERENT queued item or double-submit.
2039
+ * - Do NOT fail loud — the turn is legitimately in flight.
2040
+ * - Wait (unbounded here) for the eventual `user-message`. The
2041
+ * `_runTurn` turn deadline (W1) is the only floor; a paste that
2042
+ * is truly never released fails as `TMUX_TURN_TIMEOUT` (correct
2043
+ * attribution — the wedged thing is the prior turn, not our
2044
+ * submission), not `TMUX_SUBMIT_FAILED`.
1883
2045
  *
1884
2046
  * Runs as a concurrent racer in `_runTurn` (NOT a blocking gate in
1885
2047
  * `_pasteAndEnter` — that would hold `_pasteLock` across the confirm
@@ -1888,17 +2050,37 @@ class TmuxProcess extends Process {
1888
2050
  * racer already won, or the turn was killed) the retry loop bails so
1889
2051
  * a stray retry Enter cannot land in an unrelated turn.
1890
2052
  */
1891
- async _confirmSubmitViaJsonl(token, turn = null) {
2053
+ async _scheduleSubmitRetries(token, turn = null) {
1892
2054
  for (let attempt = 0; attempt <= this.submitConfirmRetries; attempt += 1) {
2055
+ // C1: parked → the paste is safely queued in the TUI. Wait for
2056
+ // the eventual user-message; never re-send Enter, never fail
2057
+ // loud. Checked at the TOP so a paste parked before the first
2058
+ // confirm-wait skips the wait entirely.
2059
+ if (turn && turn.parked) {
2060
+ this.emit('submit-parked', {
2061
+ token,
2062
+ turnId: turn.turnId,
2063
+ attempt,
2064
+ sessionId: this.claudeSessionId,
2065
+ backend: 'tmux',
2066
+ });
2067
+ await this._awaitSubmitOrTerminal(token, turn);
2068
+ return;
2069
+ }
1893
2070
  const confirmed = await this._awaitSubmitConfirm(token);
1894
2071
  if (confirmed) return; // submitted ✓
1895
2072
  // The turn already settled some other way (result/capture/kill)
1896
2073
  // — the submit clearly is no longer the open question. Stop:
1897
2074
  // re-sending Enter or throwing now would be wrong.
1898
2075
  if (turn && (turn.state === 'done' || turn.state === 'failed')) return;
2076
+ // The enqueue may have landed DURING the submitConfirmMs wait —
2077
+ // re-check before deciding to re-send Enter. The loop top then
2078
+ // handles the parked branch.
2079
+ if (turn && turn.parked) continue;
1899
2080
  if (attempt === this.submitConfirmRetries) break; // out of retries
1900
- // The tokened user-message never arrived the prompt is still
1901
- // sitting in the input box as `[Pasted text #N]`. Re-send Enter.
2081
+ // The tokened user-message never arrived AND the paste was not
2082
+ // parked — the prompt is still sitting in the input box as
2083
+ // `[Pasted text #N]`. Re-send Enter.
1902
2084
  this.logger.debug?.(
1903
2085
  `[${this.label}] paste not submitted (no user-message for ${token}), `
1904
2086
  + `re-sending Enter (attempt ${attempt + 1})`,
@@ -1920,6 +2102,34 @@ class TmuxProcess extends Process {
1920
2102
  );
1921
2103
  }
1922
2104
 
2105
+ /**
2106
+ * Parked-branch wait (Commit 2): resolve when `token` surfaces in a
2107
+ * JSONL `user-message` (submit landed), or when the owning turn goes
2108
+ * terminal another way (result flushed / interrupted / killed). NO
2109
+ * timeout — the caller's `_runTurn` turn deadline (W1) is the floor.
2110
+ *
2111
+ * Racing the turn's own settle promises prevents a leaked
2112
+ * `_submitConfirms` entry on a turn that ends without ever
2113
+ * producing our user-message (e.g. the prior turn wedges and W1
2114
+ * fires).
2115
+ */
2116
+ _awaitSubmitOrTerminal(token, turn) {
2117
+ return new Promise((resolve) => {
2118
+ let done = false;
2119
+ const finish = () => {
2120
+ if (done) return;
2121
+ done = true;
2122
+ this._submitConfirms.delete(token);
2123
+ resolve();
2124
+ };
2125
+ this._submitConfirms.set(token, finish); // user-message → finish
2126
+ // Bail if the turn settles via result / interrupt before the
2127
+ // user-message lands.
2128
+ turn?.resultPromise?.then(finish, finish);
2129
+ turn?.interruptP?.then(finish, finish);
2130
+ });
2131
+ }
2132
+
1923
2133
  /**
1924
2134
  * Resolve `true` once `token` surfaces in a JSONL `user-message`
1925
2135
  * (via `_confirmSubmit`), or `false` after `submitConfirmMs`.
@@ -2663,12 +2873,12 @@ class TmuxProcess extends Process {
2663
2873
  try { finish(); } catch { /* swallow */ }
2664
2874
  }
2665
2875
  // B7: release any pending submit-confirm waiters too — a
2666
- // `_confirmSubmitViaJsonl` blocked on a tokened user-message from a
2876
+ // `_scheduleSubmitRetries` blocked on a tokened user-message from a
2667
2877
  // now-dead session would otherwise burn its whole retry budget.
2668
2878
  // Each waiter's stored fn resolves it as confirmed, so the confirm
2669
2879
  // loop returns at once instead of retrying; the in-flight turn is
2670
2880
  // already rejected by `drainQueue` above, so the turn settles loud
2671
- // regardless. (`_confirmSubmitViaJsonl` also bails on its own when
2881
+ // regardless. (`_scheduleSubmitRetries` also bails on its own when
2672
2882
  // the owning turn reaches a terminal state — this is belt-and-
2673
2883
  // braces for a confirm whose turn ref it never received.)
2674
2884
  for (const finish of [...this._submitConfirms.values()]) {
@@ -2678,6 +2888,20 @@ class TmuxProcess extends Process {
2678
2888
  try { this._sessionLogTail.close(); } catch { /* swallow */ }
2679
2889
  this._sessionLogTail = null;
2680
2890
  }
2891
+ // H1 — close hook tail + remove the per-session settings + ndjson.
2892
+ // Files are best-effort unlinked (ENOENT fine if a sweeper or a
2893
+ // crashed cleanup got there first).
2894
+ if (this._hookTail) {
2895
+ try { this._hookTail.close(); } catch { /* swallow */ }
2896
+ this._hookTail = null;
2897
+ }
2898
+ if (this._hookSettingsPath || this._hookNdjsonPath) {
2899
+ try {
2900
+ removeHookFiles({ botName: this.botName, sessionId: this.claudeSessionId });
2901
+ } catch { /* swallow */ }
2902
+ this._hookSettingsPath = null;
2903
+ this._hookNdjsonPath = null;
2904
+ }
2681
2905
  await this.runner.killSession(this.tmuxName);
2682
2906
  // P1.3 close-event parity: emit integer code first (matches SDK
2683
2907
  // shape `0`/`1`). Optional second arg carries tmux-specific
@@ -88,6 +88,16 @@ const CALLBACK_TO_EVENT = {
88
88
  // consuming turn.phase for control flow. SDK backend never emits
89
89
  // this — predicate is tmux-specific.
90
90
  onPhaseChange: 'phase-change',
91
+ // 0.10.0 H1: tmux backend hook-based turn observability. TmuxProcess
92
+ // tails a per-session ndjson that claude appends to via
93
+ // `--settings`-injected command hooks (PreToolUse/PostToolUse/
94
+ // UserPromptSubmit/Stop/SubagentStop/Notification). Each event is
95
+ // forwarded here so polygram persists it as `hook-event` in the
96
+ // events DB for the H1 soak. OBSERVER-ONLY — no control flow
97
+ // consumes the events yet (mirrors Commit 1 of the patience-model
98
+ // unification). SDK backend never emits — hooks are tmux-specific.
99
+ // See docs/0.10.0-tmux-hook-observability.md.
100
+ onHookEvent: 'hook-event',
91
101
  };
92
102
 
93
103
  class ProcessManager {
@@ -344,6 +344,54 @@ function createSdkCallbacks({
344
344
  }
345
345
  },
346
346
 
347
+ // 0.10.0 H1 (observer-only): tmux backend hook-based turn
348
+ // observability. TmuxProcess emits `hook-event` with normalized
349
+ // HookEvent records for every claude-CLI hook firing (PreToolUse,
350
+ // PostToolUse, UserPromptSubmit, Stop, SubagentStop, Notification,
351
+ // plus `unknown` for any schema drift). Persisted compact so the
352
+ // soak can characterize the stream's reliability against real
353
+ // Music traffic before H2/H3/H4 consume it.
354
+ //
355
+ // Fields persisted are intentionally narrow: identity + tool/
356
+ // subagent scoping + `duration_ms` (free per-tool latency from
357
+ // PostToolUse) + a `received_at_ms` so we can measure Pre→Post
358
+ // wall-clock independently of the CLI's own clock. Bulky payloads
359
+ // (`tool_input`, full `tool_response`, `last_assistant_message`)
360
+ // are NOT persisted to the events DB — they'd inflate row size
361
+ // without informing the soak.
362
+ onHookEvent: (sessionKey, payload /* , entry */) => {
363
+ try {
364
+ const detail = {
365
+ chat_id: getChatIdFromKey(sessionKey),
366
+ session_key: sessionKey,
367
+ backend: 'tmux',
368
+ hook_type: payload?.type ?? null,
369
+ claude_session_id: payload?.sessionId ?? null,
370
+ tool_name: payload?.toolName ?? null,
371
+ tool_use_id: payload?.toolUseId ?? null,
372
+ agent_id: payload?.agentId ?? null,
373
+ agent_type: payload?.agentType ?? null,
374
+ duration_ms: payload?.durationMs ?? null,
375
+ stop_hook_active: payload?.stopHookActive ?? null,
376
+ received_at_ms: payload?.receivedAtMs ?? null,
377
+ };
378
+ // `parse-error` and `unknown` carry their raw body so soak
379
+ // analysis can decide whether they indicate transport
380
+ // corruption or schema drift. Truncate hard — these are
381
+ // expected to be rare.
382
+ if (payload?.type === 'parse-error' || payload?.type === 'unknown') {
383
+ let rawStr;
384
+ try { rawStr = JSON.stringify(payload.raw); }
385
+ catch { rawStr = String(payload.raw); }
386
+ detail.raw_truncated = (rawStr || '').slice(0, 512);
387
+ detail.parse_error = payload?.error ?? null;
388
+ }
389
+ logEvent('hook-event', detail);
390
+ } catch (err) {
391
+ logger.error?.(`[${botName}] hook-event handler: ${err.message}`);
392
+ }
393
+ },
394
+
347
395
  onInjectFail: (sessionKey, payload /* , entry */) => {
348
396
  try {
349
397
  const msgId = payload?.msgId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.33",
3
+ "version": "0.10.0-rc.35",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {