polygram 0.10.0-rc.34 → 0.10.0-rc.36

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.34",
4
+ "version": "0.10.0-rc.36",
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) {
@@ -739,134 +793,47 @@ class TmuxProcess extends Process {
739
793
  // instead of re-sending Enter / failing loud. See the method
740
794
  // doc.
741
795
  //
742
- // The confirm drives TWO derived promises below:
743
- // submitConfirmP rejects TMUX_SUBMIT_FAILED a racer that
744
- // fails the turn fast and loud; on success it never settles.
745
- // submitOkP — resolves ONLY once the submit is confirmed;
746
- // on failure it never settles. It GATES the capture-pane
747
- // racer: an "idle" capture-pane is meaningless until the turn
748
- // has actually started pre-gate, a `[Pasted text #N]`
749
- // prompt sitting unsubmitted reads as an idle (=="complete")
750
- // pane, so the capture racer would win with TMUX_NO_JSONL_TEXT
751
- // and mask the real TMUX_SUBMIT_FAILED cause.
752
- // (submitConfirmP / submitOkP plumbing is retired in Commit 3's
753
- // _runTurn race rewrite — kept here so Commit 2 stays surgical.)
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.
754
803
  const confirmP = turn.token
755
804
  ? this._scheduleSubmitRetries(turn.token, turn)
756
805
  : Promise.resolve(); // no token — nothing to confirm
757
- const submitConfirmP = confirmP.then(() => new Promise(() => {}));
758
- const submitOkP = confirmP.then(() => true, () => new Promise(() => {}));
759
-
760
- // R7: an ABSOLUTE timeout wrapping the whole race. The
761
- // capture-pane completion detector re-checks its deadline only
762
- // BETWEEN `captureWide` subprocess calls — if a single `tmux
763
- // capture-pane` wedges (its promise never resolves), the poll
764
- // loop is parked on the await and neither the capture-complete
765
- // promise nor the JSONL `resultPromise` ever settle, so `send()`
766
- // hangs forever and starves the turn queue.
767
- //
768
- // One setTimeout drives two things: an `abortP` (resolves — fed
769
- // to _awaitTurnComplete so its poll loop can break out of a
770
- // wedged capture and release the scheduler), and a
771
- // `turnDeadlineP` (rejects — the third racer below, guaranteeing
772
- // _runTurn ALWAYS settles within turnTimeoutMs). `unref` so the
773
- // timer never keeps the process alive on its own; cleared in the
774
- // `finally` so a turn that ends early leaves no dangling timer.
775
- let turnDeadlineTimer = null;
776
- let signalAbort = null;
777
- const abortP = new Promise((resolve) => { signalAbort = resolve; });
778
- const turnDeadlineP = new Promise((_resolve, reject) => {
779
- turnDeadlineTimer = setTimeout(() => {
780
- signalAbort();
781
- reject(Object.assign(
782
- new Error('TmuxProcess: turn did not complete in time'),
783
- { code: 'TMUX_TURN_TIMEOUT', tmuxName: this.tmuxName },
784
- ));
785
- }, turnTimeoutMs);
786
- turnDeadlineTimer.unref?.();
787
- });
788
806
 
789
- // Race: JSONL terminal result vs capture-pane quiescence vs the
790
- // hard turn timeout. 0.10.0 Phase 4 §6: JSONL is the SOLE source
791
- // of reply text. capture-pane is a LIVENESS signal only — it
792
- // detects "the turn is done" so we never wait forever for a
793
- // `result` that never arrives, but it NEVER delivers text.
794
- const captureCompleteP = this._awaitTurnComplete({
795
- timeoutMs: turnTimeoutMs, abortP,
796
- });
797
- // The capture-pane loop may end via the absolute-deadline abort
798
- // (it returns the abort sentinel) or reject (its own timeout).
799
- // Both are swallowed here the `turnDeadlineP` reject below is
800
- // what actually fails the turn.
801
- //
802
- // B7: gate the capture racer behind `submitOkP`. A capture-pane
803
- // "idle" reading only means "the turn completed" once the turn
804
- // has actually STARTED. If the paste never submitted, the pane is
805
- // idle because the prompt is still sitting in the input box — not
806
- // because a turn finished. Without the gate the capture racer
807
- // would win with TMUX_NO_JSONL_TEXT and mask the real
808
- // TMUX_SUBMIT_FAILED. `captureCompleteP` is still awaited inside
809
- // the racer (so its rejection is always handled — never orphaned
810
- // into an unhandled rejection) and the poll loop / scheduler
811
- // refcount lifecycle is unchanged; only the racer's eligibility
812
- // to WIN is gated on submit confirmation.
813
- const captureRaceP = (async () => {
814
- let buf;
815
- try {
816
- buf = await captureCompleteP;
817
- } catch {
818
- return new Promise(() => {}); // capture's own timeout — turnDeadlineP fails the turn
819
- }
820
- if (buf === ABORT_SENTINEL) return new Promise(() => {});
821
- await submitOkP; // gate: capture cannot win pre-submit-confirm
822
- return { kind: 'capture' };
823
- })();
824
-
825
- let resolvedVia = 'jsonl';
826
- let winner;
827
- try {
828
- winner = await Promise.race([
829
- turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
830
- captureRaceP,
831
- turnDeadlineP,
832
- turn.interruptP.then(() => ({ kind: 'interrupt' })),
833
- // B7: TMUX_SUBMIT_FAILED rejection fails the turn fast.
834
- submitConfirmP,
835
- ]);
836
-
837
- // If capture-pane won but the turn used a tool, the agent is
838
- // still working — the "ready" hint was a transient idle between
839
- // tool calls. Wait for the real terminal result from JSONL, but
840
- // keep the absolute deadline armed so a JSONL `result` that
841
- // never arrives still fails the turn rather than hanging it.
842
- // The interrupt signal still wins here too — Bug 3: an
843
- // interrupted tool turn writes no terminal JSONL `result`, so
844
- // without this racer it would hang to `turnTimeoutMs`.
845
- //
846
- // B10: an outstanding `Agent` subagent counts as "tool in
847
- // flight" exactly like a foreground `Bash` — its `tool-use`
848
- // already set `toolUsedThisTurn`, so this branch catches the
849
- // common case. The race where capture wins BEFORE the `Agent`
850
- // tool_use line is tailed is handled by the §6 re-check below.
851
- if (winner.kind === 'capture' && turn.toolUsedThisTurn) {
852
- winner = await Promise.race([
853
- turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
854
- turnDeadlineP,
855
- turn.interruptP.then(() => ({ kind: 'interrupt' })),
856
- ]);
857
- }
858
- } finally {
859
- if (turnDeadlineTimer) clearTimeout(turnDeadlineTimer);
860
- // Ensure the capture-pane loop is released even when the JSONL
861
- // race won — otherwise it would poll on until its own internal
862
- // deadline (and, with a shared PollScheduler, hold a refcount).
863
- 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 B10capture 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
+ );
864
830
  }
865
831
 
832
+ let resolvedVia = 'jsonl';
866
833
  let text;
867
834
  let resultSubtype = 'success';
868
835
  let stopReason = null;
869
- if (winner.kind === 'interrupt') {
836
+ if (outcome.kind === 'interrupt') {
870
837
  // Bug 3: `interrupt()` ended the turn. C-c was sent to the
871
838
  // TUI; the turn stops here instead of hanging until the
872
839
  // absolute `turnTimeoutMs`. Deliver whatever partial text the
@@ -877,11 +844,11 @@ class TmuxProcess extends Process {
877
844
  text = turn.text || '';
878
845
  resultSubtype = 'interrupted';
879
846
  stopReason = 'interrupted';
880
- } else if (winner.kind === 'jsonl') {
881
- text = turn.text || winner.ev.text || '';
882
- resultSubtype = winner.ev.subtype || 'success';
883
- stopReason = winner.ev.stopReason || null;
884
- 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;
885
852
  // R10: a genuinely-empty terminal `result` — end_turn, no
886
853
  // reply text, AND no tool ran this turn — is the agent
887
854
  // producing literally nothing (a thinking-only terminal
@@ -904,42 +871,65 @@ class TmuxProcess extends Process {
904
871
  );
905
872
  }
906
873
  } else {
907
- // Capture-pane quiescence judged the turn complete. Force the
908
- // JSONL aggregator to finalize any buffered message so the
909
- // 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.
910
886
  this._sessionLogTail?.flushParser?.();
911
887
  if (turn.text) {
912
888
  resolvedVia = 'jsonl-streamed';
913
889
  text = turn.text;
914
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).
915
894
  const lateGraceMs = this.lateGraceMs ?? 1500;
916
895
  let late = await Promise.race([
917
896
  turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
897
+ turn.interruptP.then(() => ({ kind: 'interrupt' })),
918
898
  new Promise((r) => setTimeout(() => r({ kind: 'no-jsonl' }), lateGraceMs)),
919
899
  ]);
920
- // B10 (shumorobot Music topic, 2026-05-20): the main agent
921
- // delegated to an `Agent` subagent within ~7 s, then the main
922
- // pane went quiescent for MINUTES while the subagent ran in
923
- // its own sidechain. capture-pane read that quiescence as
924
- // "done"; the main agent had emitted only the `Agent` call so
925
- // no JSONL reply text existed yet, and the §6 fail-loud below
926
- // fired ~grace-window in closing a turn that was genuinely
927
- // in flight. A subagent is still running iff its `Agent`
928
- // tool_use has no matching `tool-result` yet. While one is
929
- // outstanding, capture-pane quiescence of the MAIN pane is
930
- // meaningless the turn completes only when the subagent
931
- // returns and the main agent emits its real terminal reply.
932
- // Wait for that JSONL `result`, bounded by the absolute turn
933
- // deadline so a genuinely wedged turn still fails loud.
934
- 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)) {
935
916
  this.emit('subagent-wait', {
936
917
  outstanding: turn.outstandingSubagents.size,
918
+ outstandingTools: turn.outstandingTools.size,
937
919
  turnId: turn.turnId,
938
920
  });
921
+ const remainingMs = Math.max(
922
+ 0, (turn.startedAt + turnTimeoutMs) - this._now());
939
923
  late = await Promise.race([
940
924
  turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
941
- turnDeadlineP,
942
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
+ }),
943
933
  ]);
944
934
  }
945
935
  if (late.kind === 'interrupt') {
@@ -954,13 +944,12 @@ class TmuxProcess extends Process {
954
944
  stopReason = late.ev.stopReason || null;
955
945
  if (late.ev.sessionId) this.claudeSessionId = late.ev.sessionId;
956
946
  } else {
957
- // §6: capture-pane judged the turn done, but JSONL
958
- // produced NO reply text within the grace window. FAIL
959
- // LOUD — never fall back to capture-pane diff text. That
960
- // fallback WAS the echoed-input failure (the pane diff
961
- // returned the user's own echoed prompt) and the
962
- // banner-as-reply failure (L1). The error result clears
963
- // 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.
964
953
  throw Object.assign(
965
954
  new Error('turn produced no JSONL reply text within grace window'),
966
955
  { code: 'TMUX_NO_JSONL_TEXT' },
@@ -975,11 +964,10 @@ class TmuxProcess extends Process {
975
964
  const u = this._lastUsage;
976
965
  const cost = u ? computeCostUsd(u, u.model) : null;
977
966
  turn.state = 'done';
978
- // Predicate (observer-only): terminal phase. The JSONL `result`
979
- // event also drives this via `_evaluatePhaseFromSessionEvent`,
980
- // but the capture-pane / interrupt / submit-fail branches do
981
- // not; this ensures every successful exit lands the turn in
982
- // `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.
983
971
  this._setPhase(
984
972
  turn,
985
973
  TurnPhase.DONE,
@@ -1020,6 +1008,103 @@ class TmuxProcess extends Process {
1020
1008
  }
1021
1009
  }
1022
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
+
1023
1108
  /**
1024
1109
  * Retire a finished primary turn and drain the next queued one.
1025
1110
  */
@@ -1430,6 +1515,52 @@ class TmuxProcess extends Process {
1430
1515
  this._sessionLogPath = logPath;
1431
1516
  }
1432
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
+
1433
1564
  _handleSessionEvent(ev) {
1434
1565
  // Predicate (observer-only): snapshot the active group's turns
1435
1566
  // BEFORE the existing branches run. The `result` and `last-prompt`
@@ -2757,6 +2888,20 @@ class TmuxProcess extends Process {
2757
2888
  try { this._sessionLogTail.close(); } catch { /* swallow */ }
2758
2889
  this._sessionLogTail = null;
2759
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
+ }
2760
2905
  await this.runner.killSession(this.tmuxName);
2761
2906
  // P1.3 close-event parity: emit integer code first (matches SDK
2762
2907
  // shape `0`/`1`). Optional second arg carries tmux-specific
@@ -109,7 +109,15 @@ function isTerminal(phase) {
109
109
  * doesn't gate any transitions; future commits MAY tighten this.
110
110
  */
111
111
  const ALLOWED_TRANSITIONS = Object.freeze({
112
- [TurnPhase.QUEUED]: new Set([TurnPhase.PASTED_UNCONFIRMED, TurnPhase.FAILED]),
112
+ // PASTE_PARKED is reachable directly from QUEUED on cold-start +
113
+ // stacked-message flows: claude TUI emits `queue-operation:enqueue`
114
+ // before the corresponding `user-message` when it is still
115
+ // cold-starting or finishing a prior turn. The predicate sees the
116
+ // enqueue first and sets PASTE_PARKED without ever observing the
117
+ // PASTED_UNCONFIRMED intermediate. rc.35 production caught this as
118
+ // log noise once Commit 3 (`_awaitSettle`) started consuming
119
+ // predicate fields more strictly.
120
+ [TurnPhase.QUEUED]: new Set([TurnPhase.PASTED_UNCONFIRMED, TurnPhase.PASTE_PARKED, TurnPhase.FAILED]),
113
121
  [TurnPhase.PASTED_UNCONFIRMED]: new Set([TurnPhase.PASTE_PARKED, TurnPhase.SUBMITTED, TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
114
122
  [TurnPhase.PASTE_PARKED]: new Set([TurnPhase.SUBMITTED, TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
115
123
  [TurnPhase.SUBMITTED]: new Set([TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
@@ -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.34",
3
+ "version": "0.10.0-rc.36",
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": {