polygram 0.10.0-rc.4 → 0.10.0-rc.40
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/config.example.json +4 -2
- package/lib/autosteered-refs.js +20 -2
- package/lib/claude-bin.js +78 -0
- package/lib/db/sessions.js +114 -1
- package/lib/handlers/abort.js +42 -4
- package/lib/handlers/autosteer.js +6 -0
- 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 +2177 -223
- package/lib/process/turn-phase.js +150 -0
- package/lib/process-manager.js +75 -2
- package/lib/sdk/callbacks.js +387 -4
- package/lib/telegram/display-hint.js +11 -0
- package/lib/telegram/sanitize-reply.js +82 -0
- package/lib/tmux/session-log-parser.js +338 -61
- package/lib/tmux/tmux-runner.js +106 -11
- package/package.json +1 -1
- package/polygram.js +195 -29
|
@@ -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.40",
|
|
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",
|
package/config.example.json
CHANGED
|
@@ -98,7 +98,8 @@
|
|
|
98
98
|
"cwd": "/Users/you/admin-agent",
|
|
99
99
|
"requireMention": true,
|
|
100
100
|
"isolateTopics": true,
|
|
101
|
-
"_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
|
|
101
|
+
"_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode, isolateUserConfig}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
|
|
102
|
+
"_comment_isolateUserConfig": "0.10.0, tmux backend only: isolateUserConfig:true spawns the topic's claude TUI cut off from the user-level ~/.claude config — passes --strict-mcp-config (zero MCP servers load) and --setting-sources project,local (drops ~/.claude/settings.json; the spawn cwd's own .claude/settings.json still loads). Use it when a topic's agent would otherwise inherit slow user-global MCP servers whose cold-start (tens of seconds) wedges the TUI before it can accept a prompt. Settable at chat OR topic level (topic wins). Default false.",
|
|
102
103
|
"topics": {
|
|
103
104
|
"100": "Customer A",
|
|
104
105
|
"200": {
|
|
@@ -107,7 +108,8 @@
|
|
|
107
108
|
"cwd": "/Users/you/customer-b-projects",
|
|
108
109
|
"model": "opus",
|
|
109
110
|
"effort": "high",
|
|
110
|
-
"permissionMode": "default"
|
|
111
|
+
"permissionMode": "default",
|
|
112
|
+
"isolateUserConfig": true
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
},
|
package/lib/autosteered-refs.js
CHANGED
|
@@ -46,12 +46,22 @@
|
|
|
46
46
|
* logged to opts.logger?.error — they never block clearing of
|
|
47
47
|
* subsequent refs.
|
|
48
48
|
* @param {{ error?: (msg: string) => void }} [opts.logger]
|
|
49
|
+
* @param {number} [opts.minIntervalMs=250]
|
|
50
|
+
* minimum gap (ms) between successive applyClear calls inside a
|
|
51
|
+
* single clear() loop. Telegram's setMessageReaction rate limit
|
|
52
|
+
* is ~5/sec/chat; 250ms (4/sec) stays under that. Pass 0 to
|
|
53
|
+
* disable pacing in tests / contexts where the underlying applyClear
|
|
54
|
+
* doesn't talk to a rate-limited API. Only the GAP between calls
|
|
55
|
+
* is paced — the first call fires immediately, single-ref clears
|
|
56
|
+
* incur no delay. L7 fix 2026-05-16: was unpaced, exceeded the
|
|
57
|
+
* Telegram cap under N≥6 autosteers per turn.
|
|
49
58
|
* @returns {AutosteeredRefs}
|
|
50
59
|
*/
|
|
51
|
-
function createAutosteeredRefs({ applyClear, logger = console } = {}) {
|
|
60
|
+
function createAutosteeredRefs({ applyClear, logger = console, minIntervalMs = 250 } = {}) {
|
|
52
61
|
if (typeof applyClear !== 'function') {
|
|
53
62
|
throw new TypeError('applyClear function required');
|
|
54
63
|
}
|
|
64
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
65
|
/** @type {Map<string, MsgRef[]>} */
|
|
56
66
|
const refs = new Map();
|
|
57
67
|
|
|
@@ -79,7 +89,15 @@ function createAutosteeredRefs({ applyClear, logger = console } = {}) {
|
|
|
79
89
|
if (!list || list.length === 0) return 0;
|
|
80
90
|
refs.delete(sessionKey);
|
|
81
91
|
let cleared = 0;
|
|
82
|
-
|
|
92
|
+
// L7: pace inter-call gaps to stay under Telegram's
|
|
93
|
+
// setMessageReaction rate limit (~5/sec/chat). The first call
|
|
94
|
+
// fires immediately — pacing applies only to the gap BEFORE the
|
|
95
|
+
// 2nd+ call. minIntervalMs=0 disables pacing entirely.
|
|
96
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
97
|
+
const ref = list[i];
|
|
98
|
+
if (i > 0 && minIntervalMs > 0) {
|
|
99
|
+
await sleep(minIntervalMs);
|
|
100
|
+
}
|
|
83
101
|
try {
|
|
84
102
|
await applyClear(ref);
|
|
85
103
|
cleared += 1;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve + verify the pinned claude CLI binary for the tmux backend.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: the tmux backend reads claude CLI INTERNAL
|
|
11
|
+
* artefacts (JSONL events, queue-operation semantics, TUI banner
|
|
12
|
+
* ASCII, READY hint strings, stop_reason values) — none a stable
|
|
13
|
+
* public contract. polygram pins ONE version
|
|
14
|
+
* (CLAUDE_CLI_PINNED_VERSION in lib/process/tmux-process.js) and
|
|
15
|
+
* must spawn THAT binary, never whatever `claude` on $PATH happens
|
|
16
|
+
* to resolve to.
|
|
17
|
+
*
|
|
18
|
+
* Before this module the tmux runner spawned the bare string
|
|
19
|
+
* `claude`, resolved through $PATH. The claude CLI installs each
|
|
20
|
+
* version as a standalone binary at
|
|
21
|
+
* ~/.local/share/claude/versions/<version>
|
|
22
|
+
* and points ~/.local/bin/claude (a symlink) at the active one.
|
|
23
|
+
* Its auto-updater re-points that symlink whenever a new version
|
|
24
|
+
* lands — so a $PATH spawn silently drifts (shumorobot 2026-05-16:
|
|
25
|
+
* CLI auto-updated 2.1.142 → 2.1.143 between deploys).
|
|
26
|
+
*
|
|
27
|
+
* Spawning the ABSOLUTE versioned path is immune to that: the
|
|
28
|
+
* updater only ADDS new version files, it never overwrites an
|
|
29
|
+
* existing one. `versions/2.1.142` stays byte-identical forever.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Absolute path to the pinned claude binary.
|
|
34
|
+
*
|
|
35
|
+
* Resolution order:
|
|
36
|
+
* 1. POLYGRAM_CLAUDE_BIN env — explicit override (non-standard
|
|
37
|
+
* installs, CI, hosts where the layout differs).
|
|
38
|
+
* 2. ~/.local/share/claude/versions/<version> — the standard
|
|
39
|
+
* claude-CLI install location.
|
|
40
|
+
*
|
|
41
|
+
* The returned path is NOT guaranteed to exist — callers verify
|
|
42
|
+
* via verifyPinnedClaudeBin().
|
|
43
|
+
*
|
|
44
|
+
* @param {string} version — pinned version, e.g. '2.1.142'
|
|
45
|
+
* @returns {string} absolute path
|
|
46
|
+
*/
|
|
47
|
+
function resolvePinnedClaudeBin(version) {
|
|
48
|
+
const override = process.env.POLYGRAM_CLAUDE_BIN;
|
|
49
|
+
if (override) return override;
|
|
50
|
+
return path.join(os.homedir(), '.local', 'share', 'claude', 'versions', version);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Verify the pinned binary exists and is executable.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} version — pinned version, e.g. '2.1.142'
|
|
57
|
+
* @returns {{ ok: boolean, path: string, reason?: string }}
|
|
58
|
+
* ok=true → path is a spawnable binary.
|
|
59
|
+
* ok=false → reason carries an operator-actionable message.
|
|
60
|
+
*/
|
|
61
|
+
function verifyPinnedClaudeBin(version) {
|
|
62
|
+
const binPath = resolvePinnedClaudeBin(version);
|
|
63
|
+
try {
|
|
64
|
+
fs.accessSync(binPath, fs.constants.X_OK);
|
|
65
|
+
return { ok: true, path: binPath };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const code = err && err.code ? err.code : (err && err.message) || 'unknown';
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
path: binPath,
|
|
71
|
+
reason: `pinned claude CLI v${version} not found or not executable at `
|
|
72
|
+
+ `${binPath} (${code}). Install it with \`claude install ${version}\` `
|
|
73
|
+
+ 'or set POLYGRAM_CLAUDE_BIN to the correct binary path.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { resolvePinnedClaudeBin, verifyPinnedClaudeBin };
|
package/lib/db/sessions.js
CHANGED
|
@@ -95,4 +95,117 @@ function getClaudeSessionId(db, sessionKey) {
|
|
|
95
95
|
return row?.claude_session_id || null;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// ─── S2: session-config drift ────────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// A stored `sessions` row records the config the claude session was
|
|
101
|
+
// SPAWNED under. Two of the recorded fields are spawn-identity:
|
|
102
|
+
// - agent — `--agent <name>` is baked into the spawned process;
|
|
103
|
+
// resuming a session spawned under agent X under agent Y forces
|
|
104
|
+
// claude to use Y's system prompt + tool whitelist against
|
|
105
|
+
// conversation history built under X's. Incoherent.
|
|
106
|
+
// - cwd — `--cwd <path>` (SDK) / tmux session cwd; claude resolves
|
|
107
|
+
// project-local config (.claude/settings.json, agent files,
|
|
108
|
+
// plugins) relative to it. Mid-conversation cwd drift means
|
|
109
|
+
// half the messages are answered with one project's allowlist
|
|
110
|
+
// and the other half with another's.
|
|
111
|
+
//
|
|
112
|
+
// pm_backend was REMOVED from spawn-identity (rc.32, 2026-05-21).
|
|
113
|
+
// Both backends spawn the same pinned claude binary and write the
|
|
114
|
+
// same on-disk JSONL (~/.claude/projects/<cwd-enc>/<sid>.jsonl) —
|
|
115
|
+
// claude itself doesn't know or care which Node-side wrapper invoked
|
|
116
|
+
// it. Treating a backend flip as drift was destructively dropping
|
|
117
|
+
// context across the SDK→tmux migration window, costing every chat
|
|
118
|
+
// its conversation history on its first turn under the new backend.
|
|
119
|
+
// shumorobot 2026-05-20 18:51 incident: the Music topic flipped
|
|
120
|
+
// tmux→sdk→tmux during runtime and lost its agent's prior context
|
|
121
|
+
// at each flip. The orphan-tmux problem that the flip ALSO triggered
|
|
122
|
+
// is solved by rc.31's spawn-time reconcile (TmuxProcess.start) —
|
|
123
|
+
// independently, so a backend flip is now a no-op for session-state.
|
|
124
|
+
//
|
|
125
|
+
// shumorobot 2026-05-17 22:03, topic :3 (the original drift incident)
|
|
126
|
+
// remains correctly handled: that row had agent+cwd drift in
|
|
127
|
+
// addition to backend, so the agent+cwd drift alone still drops it.
|
|
128
|
+
//
|
|
129
|
+
// model + effort are deliberately EXCLUDED from the invalidating set.
|
|
130
|
+
// They are NOT spawn-identity: a live `/model` or `/effort` change is
|
|
131
|
+
// pushed into the running session by `pm.setModel` /
|
|
132
|
+
// `pm.applyFlagSettings` with no respawn (lib/handlers/slash-commands.js,
|
|
133
|
+
// lib/handlers/config-callback.js). Including them here would
|
|
134
|
+
// destructively drop the whole session — discarding all context — on
|
|
135
|
+
// every model switch, double-handling what the live-apply path
|
|
136
|
+
// already covers cleanly. The stored model/effort columns are
|
|
137
|
+
// informational, not identity.
|
|
138
|
+
const SPAWN_IDENTITY_FIELDS = ['agent', 'cwd'];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Decide whether a stored session can be resumed for the next spawn,
|
|
142
|
+
* or whether config drift means it must be dropped and re-spawned
|
|
143
|
+
* fresh.
|
|
144
|
+
*
|
|
145
|
+
* On drift the stale row is DELETED here — so the very next spawn
|
|
146
|
+
* mints a fresh claude_session_id under the correct config and the
|
|
147
|
+
* `onInit` callback re-upserts the row. This self-heals every
|
|
148
|
+
* pre-migration stale row across all chats with no manual SQL.
|
|
149
|
+
*
|
|
150
|
+
* @param {object|null} db — DB handle (null → fresh spawn)
|
|
151
|
+
* @param {string} sessionKey
|
|
152
|
+
* @param {object} resolved — freshly-resolved spawn config
|
|
153
|
+
* @param {string} [resolved.agent]
|
|
154
|
+
* @param {string} [resolved.cwd]
|
|
155
|
+
* @param {string} [resolved.backend] — 'sdk' | 'tmux' (resolved by
|
|
156
|
+
* process/factory.js pickBackend); compared to the row's pm_backend
|
|
157
|
+
* @returns {{ existingSessionId: string|null, drift: object|null }}
|
|
158
|
+
* existingSessionId — pass to start() for --resume, or null for a
|
|
159
|
+
* fresh spawn (no stored row, or drift dropped it)
|
|
160
|
+
* drift — null when no drift; otherwise { fields, before, after }
|
|
161
|
+
* for the `session-config-drift` telemetry event
|
|
162
|
+
*/
|
|
163
|
+
function resolveSessionForSpawn(db, sessionKey, resolved = {}) {
|
|
164
|
+
if (!db) return { existingSessionId: null, drift: null };
|
|
165
|
+
const row = db.getSession(sessionKey);
|
|
166
|
+
if (!row || !row.claude_session_id) {
|
|
167
|
+
return { existingSessionId: null, drift: null };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Normalise: a missing field on either side is treated as equal to
|
|
171
|
+
// a missing field on the other (both null/undefined → no drift).
|
|
172
|
+
const after = {
|
|
173
|
+
agent: resolved.agent || null,
|
|
174
|
+
cwd: resolved.cwd || null,
|
|
175
|
+
pm_backend: resolved.backend || null,
|
|
176
|
+
};
|
|
177
|
+
const before = {
|
|
178
|
+
agent: row.agent || null,
|
|
179
|
+
cwd: row.cwd || null,
|
|
180
|
+
pm_backend: row.pm_backend || null,
|
|
181
|
+
};
|
|
182
|
+
const drifted = SPAWN_IDENTITY_FIELDS.filter((f) => {
|
|
183
|
+
// If the resolved config does not specify a field, do not treat
|
|
184
|
+
// it as drift — we have nothing to compare against.
|
|
185
|
+
if (after[f] == null) return false;
|
|
186
|
+
return before[f] !== after[f];
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (drifted.length === 0) {
|
|
190
|
+
return { existingSessionId: row.claude_session_id, drift: null };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Drift: drop the stale row so the next spawn is fresh + correct.
|
|
194
|
+
db.clearSessionId(sessionKey);
|
|
195
|
+
return {
|
|
196
|
+
existingSessionId: null,
|
|
197
|
+
drift: {
|
|
198
|
+
fields: drifted,
|
|
199
|
+
before: { ...before, claude_session_id: row.claude_session_id },
|
|
200
|
+
after,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
migrateJsonToDb,
|
|
207
|
+
getClaudeSessionId,
|
|
208
|
+
resolveSessionForSpawn,
|
|
209
|
+
countSessions,
|
|
210
|
+
SPAWN_IDENTITY_FIELDS,
|
|
211
|
+
};
|
package/lib/handlers/abort.js
CHANGED
|
@@ -41,13 +41,37 @@ function createHandleAbort({
|
|
|
41
41
|
|
|
42
42
|
const threadId = msg.message_thread_id?.toString();
|
|
43
43
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
44
|
-
const
|
|
44
|
+
const proc = pm.has(sessionKey) ? pm.get(sessionKey) : null;
|
|
45
|
+
const hadActive = !!proc?.inFlight;
|
|
45
46
|
|
|
46
47
|
// Mark BEFORE killing: the 'close' event fires almost immediately
|
|
47
48
|
// after interrupt, and the surrounding handleMessage's catch
|
|
48
49
|
// needs to see the flag to skip the generic error-reply.
|
|
49
50
|
if (hadActive) markSessionAborted(sessionKey);
|
|
50
51
|
|
|
52
|
+
// Bug 1 (incident 2026-05-18): "Stop" was turn-scoped — it only
|
|
53
|
+
// looked at an in-flight TURN. But the agent can leave a DETACHED
|
|
54
|
+
// background shell running (a `run_in_background:true` Bash) that
|
|
55
|
+
// outlives the turn; the tmux TUI shows an `N shell` indicator.
|
|
56
|
+
// When there is no live turn, check for such a shell and stop it
|
|
57
|
+
// so "Stop" acts truthfully instead of replying "Nothing to stop"
|
|
58
|
+
// while work is still churning. tmux-only — the SDK Process has no
|
|
59
|
+
// hasBackgroundShell()/killBackgroundShells(); the typeof guards
|
|
60
|
+
// make this a no-op there.
|
|
61
|
+
let killedBackgroundShell = false;
|
|
62
|
+
if (!hadActive && proc
|
|
63
|
+
&& typeof proc.hasBackgroundShell === 'function'
|
|
64
|
+
&& typeof proc.killBackgroundShells === 'function') {
|
|
65
|
+
try {
|
|
66
|
+
if (await proc.hasBackgroundShell()) {
|
|
67
|
+
markSessionAborted(sessionKey);
|
|
68
|
+
killedBackgroundShell = await proc.killBackgroundShells();
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.error?.(`[${botName}] background-shell stop failed: ${err.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
// SDK abort: interrupt() + drainQueue(). interrupt() cancels
|
|
52
76
|
// the in-flight turn at SDK level WITHOUT tearing down the
|
|
53
77
|
// Query (cheap to reuse for the user's next message);
|
|
@@ -62,6 +86,7 @@ function createHandleAbort({
|
|
|
62
86
|
logEvent('abort-requested', {
|
|
63
87
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
64
88
|
had_active: hadActive,
|
|
89
|
+
killed_background_shell: killedBackgroundShell,
|
|
65
90
|
trigger: cleanText.slice(0, 40),
|
|
66
91
|
});
|
|
67
92
|
|
|
@@ -69,10 +94,23 @@ function createHandleAbort({
|
|
|
69
94
|
// detection is crude but reliable for ru/en.
|
|
70
95
|
const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
|
|
71
96
|
const strs = {
|
|
72
|
-
en: {
|
|
73
|
-
|
|
97
|
+
en: {
|
|
98
|
+
stopped: 'Stopped.',
|
|
99
|
+
bgStopped: 'Stopped the background task.',
|
|
100
|
+
nothing: 'Nothing to stop.',
|
|
101
|
+
},
|
|
102
|
+
ru: {
|
|
103
|
+
stopped: 'Остановлено.',
|
|
104
|
+
bgStopped: 'Фоновая задача остановлена.',
|
|
105
|
+
nothing: 'Нечего останавливать.',
|
|
106
|
+
},
|
|
74
107
|
}[lang];
|
|
75
|
-
|
|
108
|
+
// Truthful ack: a stopped in-flight turn → "Stopped"; a stopped
|
|
109
|
+
// background shell → "Stopped the background task"; neither →
|
|
110
|
+
// "Nothing to stop".
|
|
111
|
+
const reply = hadActive ? strs.stopped
|
|
112
|
+
: killedBackgroundShell ? strs.bgStopped
|
|
113
|
+
: strs.nothing;
|
|
76
114
|
try {
|
|
77
115
|
await tg(bot, 'sendMessage', {
|
|
78
116
|
chat_id: chatId, text: reply,
|
|
@@ -69,9 +69,15 @@ function createAutosteerHandlers({
|
|
|
69
69
|
if (!entry?.inFlight) return { autosteered: false };
|
|
70
70
|
|
|
71
71
|
const priority = priorityFor(chatConfig, config);
|
|
72
|
+
// rc.7: pass the autosteered msg_id through to the backend so the
|
|
73
|
+
// tmux backend can route an extra-turn reply back to Telegram if
|
|
74
|
+
// the TUI dequeues the paste as a fresh user turn (NEW-TURN path).
|
|
75
|
+
// SDK backend ignores msgId — its PostToolBatch fold path
|
|
76
|
+
// guarantees one combined reply via the primary pm.send.
|
|
72
77
|
const ok = pm.injectUserMessage(sessionKey, {
|
|
73
78
|
content: prompt,
|
|
74
79
|
priority,
|
|
80
|
+
msgId: msg.message_id,
|
|
75
81
|
});
|
|
76
82
|
if (!ok) return { autosteered: false };
|
|
77
83
|
|
|
@@ -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
|
+
};
|