polygram 0.10.0-rc.34 → 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 +300 -155
- 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) {
|
|
@@ -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
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
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
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
// `result`
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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 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
|
+
);
|
|
864
830
|
}
|
|
865
831
|
|
|
832
|
+
let resolvedVia = 'jsonl';
|
|
866
833
|
let text;
|
|
867
834
|
let resultSubtype = 'success';
|
|
868
835
|
let stopReason = null;
|
|
869
|
-
if (
|
|
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 (
|
|
881
|
-
text = turn.text ||
|
|
882
|
-
resultSubtype =
|
|
883
|
-
stopReason =
|
|
884
|
-
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;
|
|
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
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
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
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
|
|
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)) {
|
|
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
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
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
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
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
|
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": {
|