polygram 0.17.11 → 0.17.12
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/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +188 -41
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
package/lib/process/factory.js
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Process factory — chooses + constructs the right Process subclass
|
|
3
|
-
* per session, based on chat / topic / bot config.
|
|
4
|
-
*
|
|
5
|
-
* Backends (post-0.12):
|
|
6
|
-
* - 'sdk' → SdkProcess (long-lived SDK Query, per-token API billing)
|
|
7
|
-
* - 'cli' → CliProcess (claude TUI in tmux + Channels MCP bridge + hooks ndjson,
|
|
8
|
-
* subscription billing; default production path)
|
|
9
|
-
*
|
|
10
|
-
* Config aliases (back-compat for existing chat configs):
|
|
11
|
-
* - 'channels' → 'cli' (0.11.0-channels driver folded into CliProcess)
|
|
12
|
-
* - 'tmux' → 'cli' (0.10.0 tmux backend deleted in 0.12 Phase 4;
|
|
13
|
-
* existing configs keep working via this alias)
|
|
14
|
-
*
|
|
15
|
-
* Backend selection precedence:
|
|
16
|
-
* topicConfig.pm > chatConfig.pm > config.bot.pm > 'sdk'
|
|
17
|
-
*
|
|
18
|
-
* Per-backend wiring requirements:
|
|
19
|
-
* cli — tmuxRunner + botName + toolDispatcher + claudeBin
|
|
20
|
-
*
|
|
21
|
-
* If a backend is configured but its wiring is missing, we log a loud
|
|
22
|
-
* warning and fall back to SDK so the daemon stays up (R2-F7 — never
|
|
23
|
-
* silent-fail config).
|
|
24
|
-
*
|
|
25
|
-
* @see docs/0.10.0-process-manager-abstraction-plan.md §6.4
|
|
26
|
-
* @see docs/0.11.0-channels-driver-plan.md
|
|
27
|
-
* @see docs/0.12.0-cli-driver-plan.md
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
'use strict';
|
|
31
|
-
|
|
32
|
-
const { SdkProcess } = require('./sdk-process');
|
|
33
|
-
const { CliProcess } = require('./cli-process');
|
|
34
|
-
|
|
35
|
-
// Aliases — config values that map to a different canonical backend.
|
|
36
|
-
// Each alias emits ONE deprecation warn per-bot-process lifetime
|
|
37
|
-
// (tracked in `_warnedAliases` below). Avoids per-spawn log flooding
|
|
38
|
-
// on multi-chat deploys.
|
|
39
|
-
const ALIASES = new Map([
|
|
40
|
-
['channels', 'cli'],
|
|
41
|
-
['tmux', 'cli'], // 0.12 Phase 4: tmux backend deleted; existing configs alias to cli
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
const _warnedAliases = new Set();
|
|
45
|
-
function _maybeWarnAlias(alias, canonical, logger) {
|
|
46
|
-
if (_warnedAliases.has(alias)) return;
|
|
47
|
-
_warnedAliases.add(alias);
|
|
48
|
-
logger.warn?.(
|
|
49
|
-
`[factory] pm:'${alias}' is deprecated and now aliases to pm:'${canonical}'. ` +
|
|
50
|
-
`Update chat config to silence this warning. ` +
|
|
51
|
-
`See docs/0.12.0-cli-driver-plan.md §"Open questions resolved here" / Q5.`,
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// 0.12 Phase 4.5.3 (R12 mitigation): chats migrating from pm:'tmux' (the
|
|
56
|
-
// 0.10 backend with implicit pane-scrape approval gating) to pm:'cli'
|
|
57
|
-
// silently lose approvals unless the operator explicitly sets
|
|
58
|
-
// permissionMode. Warn ONCE per (botName, chatId, threadId) tuple so
|
|
59
|
-
// the migration trade-off is deliberate, not a surprise regression.
|
|
60
|
-
// Fires at pickBackend time (factory.js is the choke point for backend
|
|
61
|
-
// resolution).
|
|
62
|
-
const _warnedR12Chats = new Set();
|
|
63
|
-
function _maybeWarnR12Migration({ rawPm, canonical, chatId, threadId, chatCfg, topicCfg, logger }) {
|
|
64
|
-
if (rawPm !== 'tmux' || canonical !== 'cli') return;
|
|
65
|
-
// Resolved permissionMode honors the same precedence cli-process.js
|
|
66
|
-
// uses: topic > chat > opt-default. Check both topic and chat config
|
|
67
|
-
// here; we don't know opt-default (set inside CliProcess.start), but
|
|
68
|
-
// its default is 'bypassPermissions' so absence = bypass.
|
|
69
|
-
const explicitMode = topicCfg?.permissionMode || chatCfg?.permissionMode;
|
|
70
|
-
if (explicitMode && explicitMode !== 'bypassPermissions') return;
|
|
71
|
-
const key = `${chatId}:${threadId ?? ''}`;
|
|
72
|
-
if (_warnedR12Chats.has(key)) return;
|
|
73
|
-
_warnedR12Chats.add(key);
|
|
74
|
-
logger.warn?.(
|
|
75
|
-
`[factory] R12 migration warning: chat=${chatId}${threadId ? ` thread=${threadId}` : ''} ` +
|
|
76
|
-
`was configured as pm:'tmux' and now aliases to pm:'cli'. The 0.10 tmux backend gated ` +
|
|
77
|
-
`Bash/Edit/etc tool calls via pane-scrape approval cards; the 0.12 CliProcess defaults to ` +
|
|
78
|
-
`permissionMode:'bypassPermissions' (no approvals). To preserve approval gating on this ` +
|
|
79
|
-
`chat, set permissionMode: 'default' (or 'acceptEdits' / 'plan') in chat or topic config. ` +
|
|
80
|
-
`See docs/0.12.0-cli-driver-plan.md §"Security posture" + R12.`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* @param {object} opts
|
|
86
|
-
* @param {object} opts.config — runtime config object
|
|
87
|
-
* @param {Function} opts.spawnFn — buildSdkOptions (SDK backend only)
|
|
88
|
-
* @param {object} [opts.db] — for SdkProcess._logEvent + clearSessionId
|
|
89
|
-
* @param {object} [opts.logger]
|
|
90
|
-
* @param {number} [opts.queueCap]
|
|
91
|
-
* @param {number} [opts.queryCloseTimeoutMs]
|
|
92
|
-
* @param {object} [opts.tmuxRunner] — required when ANY chat routes to 'cli'
|
|
93
|
-
* @param {string} [opts.botName] — required when ANY chat routes to 'cli'
|
|
94
|
-
* @param {Function} [opts.toolDispatcher] — required when ANY chat routes to 'cli'.
|
|
95
|
-
* async ({sessionKey, chatId, threadId, toolName, text, files}) => {ok, error?}.
|
|
96
|
-
* Called when Claude's reply (or react/edit_message) tool fires inside a
|
|
97
|
-
* CliProcess. Polygram supplies the actual Telegram-send wiring.
|
|
98
|
-
* @param {string} [opts.channelsClaudeBin] — absolute path to pinned claude binary;
|
|
99
|
-
* required when ANY chat routes to 'cli'. (Name kept for back-compat with
|
|
100
|
-
* existing wiring; can be renamed to `claudeBin` in a future refactor.)
|
|
101
|
-
* @returns {Function} processFactory(sessionKey, ctx) → Process
|
|
102
|
-
*/
|
|
103
|
-
function createProcessFactory({
|
|
104
|
-
config,
|
|
105
|
-
spawnFn,
|
|
106
|
-
db = null,
|
|
107
|
-
logger = console,
|
|
108
|
-
queueCap,
|
|
109
|
-
queryCloseTimeoutMs,
|
|
110
|
-
tmuxRunner = null,
|
|
111
|
-
botName = null,
|
|
112
|
-
toolDispatcher = null,
|
|
113
|
-
channelsClaudeBin = null,
|
|
114
|
-
} = {}) {
|
|
115
|
-
if (typeof spawnFn !== 'function') {
|
|
116
|
-
throw new TypeError('createProcessFactory: spawnFn required');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return function processFactory(sessionKey, ctx) {
|
|
120
|
-
const chatId = ctx?.chatId ?? null;
|
|
121
|
-
const threadId = ctx?.threadId ?? null;
|
|
122
|
-
const label = ctx?.label || sessionKey;
|
|
123
|
-
|
|
124
|
-
const choice = pickBackend({ config, chatId, threadId, logger });
|
|
125
|
-
|
|
126
|
-
if (choice === 'cli') {
|
|
127
|
-
const missing = [];
|
|
128
|
-
if (!tmuxRunner) missing.push('tmuxRunner');
|
|
129
|
-
if (!botName) missing.push('botName');
|
|
130
|
-
if (typeof toolDispatcher !== 'function') missing.push('toolDispatcher');
|
|
131
|
-
if (!channelsClaudeBin) missing.push('channelsClaudeBin');
|
|
132
|
-
if (missing.length) {
|
|
133
|
-
logger.warn?.(
|
|
134
|
-
`[${label}] config requests pm:'cli' but ${missing.join(', ')} not wired; ` +
|
|
135
|
-
`falling back to SdkProcess. Pass these to createProcessFactory.`,
|
|
136
|
-
);
|
|
137
|
-
} else {
|
|
138
|
-
return new CliProcess({
|
|
139
|
-
sessionKey, chatId, threadId, label,
|
|
140
|
-
tmuxRunner,
|
|
141
|
-
botName,
|
|
142
|
-
claudeBin: channelsClaudeBin,
|
|
143
|
-
toolDispatcher,
|
|
144
|
-
logger,
|
|
145
|
-
db, // Parity P1: telemetry parity with sdk/tmux
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return new SdkProcess({
|
|
151
|
-
sessionKey, chatId, threadId, label,
|
|
152
|
-
spawnFn,
|
|
153
|
-
db,
|
|
154
|
-
logger,
|
|
155
|
-
queueCap,
|
|
156
|
-
queryCloseTimeoutMs,
|
|
157
|
-
});
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Per-chat / per-topic backend choice.
|
|
163
|
-
*
|
|
164
|
-
* Honors topicConfig.pm / chatConfig.pm / config.bot.pm. Resolves aliases
|
|
165
|
-
* (e.g., 'channels' → 'cli') and emits a once-per-process deprecation warn.
|
|
166
|
-
*
|
|
167
|
-
* Review AC3: unknown `pm` values (typos like `'channel'` singular) used to
|
|
168
|
-
* silently fall through to 'sdk' with no warning — violates R2-F7 "never
|
|
169
|
-
* silent-fail config". Now logs a warn and falls back to the default.
|
|
170
|
-
*/
|
|
171
|
-
const CANONICAL_BACKENDS = new Set(['sdk', 'cli']);
|
|
172
|
-
|
|
173
|
-
function pickBackend({ config, chatId, threadId, logger = console } = {}) {
|
|
174
|
-
if (!chatId) return 'sdk';
|
|
175
|
-
const chatCfg = config?.chats?.[chatId];
|
|
176
|
-
const topicCfg = threadId && chatCfg?.topics?.[threadId];
|
|
177
|
-
const raw = topicCfg?.pm || chatCfg?.pm || config?.bot?.pm || 'sdk';
|
|
178
|
-
|
|
179
|
-
// Resolve alias (e.g., 'channels' → 'cli'). Warns once per process per
|
|
180
|
-
// alias kind, NOT per spawn — multi-chat deploys shouldn't flood logs.
|
|
181
|
-
let picked = raw;
|
|
182
|
-
if (ALIASES.has(raw)) {
|
|
183
|
-
picked = ALIASES.get(raw);
|
|
184
|
-
_maybeWarnAlias(raw, picked, logger);
|
|
185
|
-
// R12 — per-chat migration warning when pm:'tmux' aliases to 'cli'
|
|
186
|
-
// without an explicit non-bypass permissionMode override.
|
|
187
|
-
_maybeWarnR12Migration({
|
|
188
|
-
rawPm: raw,
|
|
189
|
-
canonical: picked,
|
|
190
|
-
chatId, threadId,
|
|
191
|
-
chatCfg, topicCfg,
|
|
192
|
-
logger,
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (!CANONICAL_BACKENDS.has(picked)) {
|
|
197
|
-
logger.warn?.(
|
|
198
|
-
`[factory] unknown pm value '${raw}' for chat=${chatId} thread=${threadId ?? ''}; ` +
|
|
199
|
-
`falling back to 'sdk'. Valid: ${[...CANONICAL_BACKENDS].join(', ')} ` +
|
|
200
|
-
`(aliases: ${[...ALIASES.keys()].join(', ')}).`,
|
|
201
|
-
);
|
|
202
|
-
return 'sdk';
|
|
203
|
-
}
|
|
204
|
-
return picked;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// _resetAliasWarnings — test-only helper. Resets the once-per-process warn
|
|
208
|
-
// tracking so unit tests can verify alias-warn + R12-migration-warn behavior
|
|
209
|
-
// across multiple pickBackend() invocations within a single test run.
|
|
210
|
-
function _resetAliasWarnings() {
|
|
211
|
-
_warnedAliases.clear();
|
|
212
|
-
_warnedR12Chats.clear();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
module.exports = { createProcessFactory, pickBackend, _resetAliasWarnings, ALIASES, CANONICAL_BACKENDS };
|
|
@@ -1,162 +0,0 @@
|
|
|
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
|
-
// 0.12.0-rc.13: compaction lifecycle (carry `trigger` + custom_instructions).
|
|
55
|
-
'PreCompact',
|
|
56
|
-
'PostCompact',
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Normalize one raw hook payload (already JSON.parsed) into the
|
|
61
|
-
* shape downstream code consumes. Unknown shapes pass through as
|
|
62
|
-
* `unknown` so a 2.1.143-style schema drift doesn't silently lose
|
|
63
|
-
* events.
|
|
64
|
-
*/
|
|
65
|
-
function normalizeHookEvent(raw) {
|
|
66
|
-
if (raw && typeof raw === 'object' && raw.polygram_parse_error) {
|
|
67
|
-
return {
|
|
68
|
-
type: 'parse-error',
|
|
69
|
-
error: raw.polygram_parse_error,
|
|
70
|
-
receivedAtMs: raw.polygram_received_at_ms ?? null,
|
|
71
|
-
raw,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
const name = raw && typeof raw === 'object' ? raw.hook_event_name : null;
|
|
75
|
-
const type = KNOWN_EVENT_NAMES.has(name) ? name : 'unknown';
|
|
76
|
-
return {
|
|
77
|
-
type,
|
|
78
|
-
sessionId: raw?.session_id ?? null,
|
|
79
|
-
transcriptPath: raw?.transcript_path ?? null,
|
|
80
|
-
cwd: raw?.cwd ?? null,
|
|
81
|
-
permissionMode: raw?.permission_mode ?? null,
|
|
82
|
-
toolName: raw?.tool_name ?? null,
|
|
83
|
-
toolUseId: raw?.tool_use_id ?? null,
|
|
84
|
-
toolInput: raw?.tool_input ?? null,
|
|
85
|
-
toolResponse: raw?.tool_response ?? null,
|
|
86
|
-
durationMs: raw?.duration_ms ?? null,
|
|
87
|
-
agentId: raw?.agent_id ?? null,
|
|
88
|
-
agentType: raw?.agent_type ?? null,
|
|
89
|
-
agentTranscriptPath: raw?.agent_transcript_path ?? null,
|
|
90
|
-
prompt: raw?.prompt ?? null,
|
|
91
|
-
stopHookActive: raw?.stop_hook_active ?? null,
|
|
92
|
-
lastAssistantMessage: raw?.last_assistant_message ?? null,
|
|
93
|
-
// PreCompact/PostCompact payload: trigger distinguishes auto vs manual
|
|
94
|
-
// compaction; custom_instructions is the `/compact <hint>` text (manual).
|
|
95
|
-
trigger: raw?.trigger ?? null,
|
|
96
|
-
customInstructions: raw?.custom_instructions ?? null,
|
|
97
|
-
receivedAtMs: raw?.polygram_received_at_ms ?? null,
|
|
98
|
-
raw,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Wrap a LogTail with line-by-line hook parsing. Forwards parsed
|
|
104
|
-
* events via `'event'` (same shape as claude-session-jsonl.pipeToParser).
|
|
105
|
-
*
|
|
106
|
-
* @returns the same emitter (chainable).
|
|
107
|
-
*/
|
|
108
|
-
function pipeHookParser(tail) {
|
|
109
|
-
tail.on('line', (line) => {
|
|
110
|
-
const trimmed = line.trim();
|
|
111
|
-
if (!trimmed) return; // blank-line guard (interleave-paranoid)
|
|
112
|
-
let raw;
|
|
113
|
-
try {
|
|
114
|
-
raw = JSON.parse(trimmed);
|
|
115
|
-
} catch (err) {
|
|
116
|
-
tail.emit('event', {
|
|
117
|
-
type: 'parse-error',
|
|
118
|
-
error: err.message,
|
|
119
|
-
receivedAtMs: Date.now(),
|
|
120
|
-
raw: trimmed.length > 1024 ? trimmed.slice(0, 1024) + '…' : trimmed,
|
|
121
|
-
});
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
tail.emit('event', normalizeHookEvent(raw));
|
|
125
|
-
});
|
|
126
|
-
return tail;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* One-shot helper: build a LogTail at the given path with the
|
|
131
|
-
* H1-typical config (watch mode), wire the hook parser, and return
|
|
132
|
-
* it. Caller calls `.start()` and `.on('event', ...)`.
|
|
133
|
-
*
|
|
134
|
-
* `skipExisting`:
|
|
135
|
-
* - false (default) for a FRESH spawn — the ndjson was just
|
|
136
|
-
* touched at start time and is empty, so any future write IS a
|
|
137
|
-
* new event.
|
|
138
|
-
* - true for a `--resume` spawn — `writeHookFiles` uses 'a' mode
|
|
139
|
-
* (append) and never truncates, so the prior session's hook
|
|
140
|
-
* events are still on disk. Without skipExisting they replay
|
|
141
|
-
* into the fresh process, arming a Stop synth against the
|
|
142
|
-
* fresh turn (H4) and heartbeating it (H3) from stale events.
|
|
143
|
-
* rc.42 #5 (review-driven): mirror what `_armSessionLogTail`
|
|
144
|
-
* already does for the JSONL tail.
|
|
145
|
-
*/
|
|
146
|
-
function createHookTail({ path: filePath, skipExisting = false, logger = console } = {}) {
|
|
147
|
-
const tail = new LogTail({
|
|
148
|
-
path: filePath,
|
|
149
|
-
intervalMs: 50,
|
|
150
|
-
skipExisting,
|
|
151
|
-
useWatch: 'auto',
|
|
152
|
-
logger,
|
|
153
|
-
});
|
|
154
|
-
return pipeHookParser(tail);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
module.exports = {
|
|
158
|
-
KNOWN_EVENT_NAMES,
|
|
159
|
-
normalizeHookEvent,
|
|
160
|
-
pipeHookParser,
|
|
161
|
-
createHookTail,
|
|
162
|
-
};
|
|
@@ -1,181 +0,0 @@
|
|
|
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
|
-
// 0.12.0-rc.13: compaction lifecycle. PreCompact fires when claude is about
|
|
48
|
-
// to (auto-)compact — the moment that detaches the channels MCP bridge.
|
|
49
|
-
// PostCompact fires after, when context has dropped (used to re-arm the
|
|
50
|
-
// per-chat compaction warning). Both confirmed supported by the pinned CLI
|
|
51
|
-
// (2.1.142) and carry a `trigger: auto|manual` field.
|
|
52
|
-
'PreCompact',
|
|
53
|
-
'PostCompact',
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Per-bot hooks dir (parent of both settings + ndjson files).
|
|
58
|
-
* Mirrors `lib/tmux/tmux-runner.js#debugLogPath`'s `~/.polygram/<bot>/logs`
|
|
59
|
-
* convention. No /tmp fallback when HOME is unset — fail loud (audit
|
|
60
|
-
* M4 style — symlink races on world-writable dirs).
|
|
61
|
-
*
|
|
62
|
-
* @param {string} botName
|
|
63
|
-
* @param {string} [hooksDir] override (for tests)
|
|
64
|
-
*/
|
|
65
|
-
function hooksBaseDir(botName, hooksDir) {
|
|
66
|
-
if (!hooksDir && !process.env.HOME) {
|
|
67
|
-
throw Object.assign(
|
|
68
|
-
new Error('HOME env var unset; refusing /tmp fallback for hooks dir'),
|
|
69
|
-
{ code: 'HOME_UNSET' },
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
const safeBot = String(botName).replace(/[^\w-]/g, '_');
|
|
73
|
-
return hooksDir || path.join(process.env.HOME, '.polygram', safeBot, 'hooks');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function hookNdjsonPath(botName, sessionId, hooksDir) {
|
|
77
|
-
return path.join(hooksBaseDir(botName, hooksDir), `${sessionId}.ndjson`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function hookSettingsPath(botName, sessionId, hooksDir) {
|
|
81
|
-
return path.join(hooksBaseDir(botName, hooksDir), `${sessionId}.settings.json`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Build the settings-JSON object that claude reads via `--settings`.
|
|
86
|
-
*
|
|
87
|
-
* @param {object} opts
|
|
88
|
-
* @param {string} opts.ndjsonPath absolute path the helper appends to
|
|
89
|
-
* @param {string} [opts.helperPath] absolute path to polygram-hook-append.js
|
|
90
|
-
* (defaults to the one shipped with polygram)
|
|
91
|
-
*/
|
|
92
|
-
// 0.12 SEC-03: shell-quote helper + ndjson paths in the command string.
|
|
93
|
-
// claude's hook runner shells out via execvp on a parsed argv. If either
|
|
94
|
-
// path contains characters the shell tokenizer would split on (most
|
|
95
|
-
// commonly: spaces in HOME like `/Users/Ivan Shumkov/...`), the helper
|
|
96
|
-
// receives the wrong argv and exits with code 2 ("missing ndjson path").
|
|
97
|
-
// Silent-drop of all hook events on bots whose HOME has a space.
|
|
98
|
-
//
|
|
99
|
-
// Single-quote escaping rule: replace any literal `'` with `'\''` (close
|
|
100
|
-
// quote, escaped quote, re-open quote), then wrap the whole thing in
|
|
101
|
-
// single quotes. POSIX-shell safe; no special chars inside single quotes
|
|
102
|
-
// except `'` itself.
|
|
103
|
-
function _shqSingle(s) {
|
|
104
|
-
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function buildHookSettings({ ndjsonPath, helperPath = HOOK_HELPER_ABS_PATH } = {}) {
|
|
108
|
-
if (!ndjsonPath || !path.isAbsolute(ndjsonPath)) {
|
|
109
|
-
throw new TypeError('buildHookSettings: ndjsonPath must be an absolute path');
|
|
110
|
-
}
|
|
111
|
-
if (!path.isAbsolute(helperPath)) {
|
|
112
|
-
throw new TypeError('buildHookSettings: helperPath must be an absolute path');
|
|
113
|
-
}
|
|
114
|
-
const command = `node ${_shqSingle(helperPath)} ${_shqSingle(ndjsonPath)}`;
|
|
115
|
-
// Per-event entry: PreToolUse/PostToolUse take a matcher (".*" =
|
|
116
|
-
// every tool); the lifecycle events don't.
|
|
117
|
-
const matched = (matcher) => [{ matcher, hooks: [{ type: 'command', command, timeout: 30 }] }];
|
|
118
|
-
const unmatched = () => [{ hooks: [{ type: 'command', command, timeout: 30 }] }];
|
|
119
|
-
const hooks = {};
|
|
120
|
-
for (const evt of HOOK_EVENTS) {
|
|
121
|
-
hooks[evt] = (evt === 'PreToolUse' || evt === 'PostToolUse') ? matched('.*') : unmatched();
|
|
122
|
-
}
|
|
123
|
-
return { hooks };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Write the settings JSON to disk (creates parent dirs). Returns the
|
|
128
|
-
* absolute path. Caller pushes `--settings <path>` to the spawn args.
|
|
129
|
-
*
|
|
130
|
-
* The empty ndjson sink is also touched here so the LogTail's
|
|
131
|
-
* fs.watch can attach immediately (LogTail handles ENOENT, but touching
|
|
132
|
-
* eliminates a small race window on the first hook event).
|
|
133
|
-
*/
|
|
134
|
-
function writeHookFiles({ botName, sessionId, hooksDir, helperPath, fsImpl = fs } = {}) {
|
|
135
|
-
const settingsPath = hookSettingsPath(botName, sessionId, hooksDir);
|
|
136
|
-
const ndjsonPath = hookNdjsonPath(botName, sessionId, hooksDir);
|
|
137
|
-
fsImpl.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
138
|
-
const settings = buildHookSettings({ ndjsonPath, helperPath });
|
|
139
|
-
|
|
140
|
-
// 0.12 SEC-04: write both files with mode 0o600. The ndjson stream
|
|
141
|
-
// carries every tool_input (file paths, Bash commands, write content,
|
|
142
|
-
// partial outputs) — load-bearing observability in CliProcess, NOT the
|
|
143
|
-
// tmux-backend observe-only surface this code was originally built for.
|
|
144
|
-
// umask 022 default leaves the files world-readable; mirror the existing
|
|
145
|
-
// 0o600 pattern from cli-process.js mcp-config writes.
|
|
146
|
-
fsImpl.writeFileSync(settingsPath, JSON.stringify(settings), { mode: 0o600 });
|
|
147
|
-
// Touch the ndjson so fs.watch attaches before the first hook fires.
|
|
148
|
-
// Use openSync with mode 0o600 so a brand-new file is created restricted
|
|
149
|
-
// from birth; if the file already exists (re-spawn case) the mode flag
|
|
150
|
-
// is silently ignored, which is fine because writeHookFiles' caller
|
|
151
|
-
// (CliProcess._spawnTmuxClaude) controls the parent dir's lifecycle.
|
|
152
|
-
const fd = fsImpl.openSync(ndjsonPath, 'a', 0o600);
|
|
153
|
-
fsImpl.closeSync(fd);
|
|
154
|
-
// Defensive re-chmod on both — in case umask interfered with the mode
|
|
155
|
-
// arg above. Same belt-and-suspenders pattern as cli-process.js:404-406.
|
|
156
|
-
try { fsImpl.chmodSync(settingsPath, 0o600); } catch {}
|
|
157
|
-
try { fsImpl.chmodSync(ndjsonPath, 0o600); } catch {}
|
|
158
|
-
return { settingsPath, ndjsonPath };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Best-effort unlink of both files. Called on kill + orphan-sweep.
|
|
163
|
-
* Errors are swallowed (ENOENT is the common case after a clean kill).
|
|
164
|
-
*/
|
|
165
|
-
function removeHookFiles({ botName, sessionId, hooksDir, fsImpl = fs } = {}) {
|
|
166
|
-
for (const p of [hookSettingsPath(botName, sessionId, hooksDir),
|
|
167
|
-
hookNdjsonPath(botName, sessionId, hooksDir)]) {
|
|
168
|
-
try { fsImpl.unlinkSync(p); } catch { /* swallow */ }
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
module.exports = {
|
|
173
|
-
HOOK_HELPER_ABS_PATH,
|
|
174
|
-
HOOK_EVENTS,
|
|
175
|
-
hooksBaseDir,
|
|
176
|
-
hookNdjsonPath,
|
|
177
|
-
hookSettingsPath,
|
|
178
|
-
buildHookSettings,
|
|
179
|
-
writeHookFiles,
|
|
180
|
-
removeHookFiles,
|
|
181
|
-
};
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
});
|