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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/process/hook-event-tail.js +144 -0
- package/lib/process/hook-settings.js +144 -0
- package/lib/process/polygram-hook-append.js +71 -0
- package/lib/process/tmux-process.js +393 -169
- package/lib/process-manager.js +10 -0
- package/lib/sdk/callbacks.js +48 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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. `
|
|
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
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
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.
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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 (
|
|
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 (
|
|
873
|
-
text = turn.text ||
|
|
874
|
-
resultSubtype =
|
|
875
|
-
stopReason =
|
|
876
|
-
if (
|
|
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
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
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
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
|
|
926
|
-
|
|
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 sidechain — that 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
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
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
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
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
|
-
//
|
|
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` (`
|
|
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
|
-
// (`
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1879
|
-
*
|
|
1880
|
-
*
|
|
1881
|
-
*
|
|
1882
|
-
*
|
|
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
|
|
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
|
|
1901
|
-
// sitting in the input box as
|
|
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
|
-
// `
|
|
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. (`
|
|
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
|
package/lib/process-manager.js
CHANGED
|
@@ -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 {
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -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.
|
|
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": {
|