polygram 0.12.0-rc.2 → 0.12.0-rc.21
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/config.example.json +5 -1
- package/lib/attachments.js +46 -2
- package/lib/claude-bin.js +8 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/error/classify.js +12 -0
- package/lib/handlers/abort.js +38 -1
- package/lib/handlers/config-callback.js +8 -2
- package/lib/handlers/config-ui.js +23 -9
- package/lib/handlers/dispatcher.js +43 -0
- package/lib/handlers/download.js +101 -58
- package/lib/ipc/file-validator.js +8 -1
- package/lib/process/channels-tool-dispatcher.js +20 -2
- package/lib/process/cli-process.js +447 -73
- package/lib/process/factory.js +0 -5
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process-manager.js +6 -0
- package/lib/sdk/callbacks.js +61 -7
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/api.js +9 -0
- package/lib/telegram/input-file.js +76 -0
- package/lib/telegram/reactions.js +5 -0
- package/lib/tmux/log-tail.js +11 -1
- package/lib/tmux/startup-gate.js +65 -1
- package/package.json +1 -1
- package/polygram.js +64 -21
package/lib/process/factory.js
CHANGED
|
@@ -91,10 +91,6 @@ function _maybeWarnR12Migration({ rawPm, canonical, chatId, threadId, chatCfg, t
|
|
|
91
91
|
* @param {number} [opts.queryCloseTimeoutMs]
|
|
92
92
|
* @param {object} [opts.tmuxRunner] — required when ANY chat routes to 'cli'
|
|
93
93
|
* @param {string} [opts.botName] — required when ANY chat routes to 'cli'
|
|
94
|
-
* @param {object} [opts.pollScheduler] — DEPRECATED in 0.12 — was used by the
|
|
95
|
-
* removed tmux backend to share one setInterval across all chats; CliProcess's
|
|
96
|
-
* per-session pongWatchdog handles its own cadence. Param kept for caller
|
|
97
|
-
* back-compat; ignored. Will be removed in 0.13.
|
|
98
94
|
* @param {Function} [opts.toolDispatcher] — required when ANY chat routes to 'cli'.
|
|
99
95
|
* async ({sessionKey, chatId, threadId, toolName, text, files}) => {ok, error?}.
|
|
100
96
|
* Called when Claude's reply (or react/edit_message) tool fires inside a
|
|
@@ -113,7 +109,6 @@ function createProcessFactory({
|
|
|
113
109
|
queryCloseTimeoutMs,
|
|
114
110
|
tmuxRunner = null,
|
|
115
111
|
botName = null,
|
|
116
|
-
pollScheduler = null,
|
|
117
112
|
toolDispatcher = null,
|
|
118
113
|
channelsClaudeBin = null,
|
|
119
114
|
} = {}) {
|
|
@@ -51,6 +51,9 @@ const KNOWN_EVENT_NAMES = new Set([
|
|
|
51
51
|
'SubagentStop',
|
|
52
52
|
'Stop',
|
|
53
53
|
'Notification',
|
|
54
|
+
// 0.12.0-rc.13: compaction lifecycle (carry `trigger` + custom_instructions).
|
|
55
|
+
'PreCompact',
|
|
56
|
+
'PostCompact',
|
|
54
57
|
]);
|
|
55
58
|
|
|
56
59
|
/**
|
|
@@ -87,6 +90,10 @@ function normalizeHookEvent(raw) {
|
|
|
87
90
|
prompt: raw?.prompt ?? null,
|
|
88
91
|
stopHookActive: raw?.stop_hook_active ?? null,
|
|
89
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,
|
|
90
97
|
receivedAtMs: raw?.polygram_received_at_ms ?? null,
|
|
91
98
|
raw,
|
|
92
99
|
};
|
|
@@ -44,6 +44,13 @@ const HOOK_EVENTS = [
|
|
|
44
44
|
'SubagentStop',
|
|
45
45
|
'Stop',
|
|
46
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',
|
|
47
54
|
];
|
|
48
55
|
|
|
49
56
|
/**
|
package/lib/process-manager.js
CHANGED
|
@@ -41,6 +41,12 @@ const CALLBACK_TO_EVENT = {
|
|
|
41
41
|
onAssistantMessageStart: 'assistant-message-start',
|
|
42
42
|
onAutonomousAssistantMessage: 'autonomous-assistant-message',
|
|
43
43
|
onCompactBoundary: 'compact-boundary',
|
|
44
|
+
// 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
|
|
45
|
+
// 'compaction-warn' {kind:'proactive'|'reactive', pct?} when (proactive)
|
|
46
|
+
// context crosses the chat's threshold at turn-end, or (reactive) claude is
|
|
47
|
+
// auto-compacting now. The callback posts a chat message proposing /compact
|
|
48
|
+
// — opt-in per chat. See docs/0.12.0-file-send.md / lib/compaction-warn.js.
|
|
49
|
+
onCompactionWarn: 'compaction-warn',
|
|
44
50
|
onQueueDrop: 'queue-drop',
|
|
45
51
|
onThinking: 'thinking',
|
|
46
52
|
// Tmux backend: TUI shows in-pane approval prompt. SDK backend
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -464,7 +464,10 @@ function createSdkCallbacks({
|
|
|
464
464
|
const detail = {
|
|
465
465
|
chat_id: getChatIdFromKey(sessionKey),
|
|
466
466
|
session_key: sessionKey,
|
|
467
|
-
|
|
467
|
+
// Finding 0.12-M3: tmux backend was deleted in 0.12; these hook
|
|
468
|
+
// handlers only ever fire on the CLI driver now — default to 'cli'
|
|
469
|
+
// (honor an explicit payload.backend if a caller ever sets one).
|
|
470
|
+
backend: payload?.backend ?? 'cli',
|
|
468
471
|
hook_type: payload?.type ?? null,
|
|
469
472
|
claude_session_id: payload?.sessionId ?? null,
|
|
470
473
|
tool_name: payload?.toolName ?? null,
|
|
@@ -555,7 +558,7 @@ function createSdkCallbacks({
|
|
|
555
558
|
logEvent('turn-timeout', {
|
|
556
559
|
chat_id: getChatIdFromKey(sessionKey),
|
|
557
560
|
session_key: sessionKey,
|
|
558
|
-
backend: '
|
|
561
|
+
backend: payload?.backend ?? 'cli', // Finding 0.12-M3
|
|
559
562
|
turn_id: payload?.turnId ?? null,
|
|
560
563
|
reason: payload?.reason ?? null,
|
|
561
564
|
idle_ms: payload?.idleMs ?? null,
|
|
@@ -568,6 +571,42 @@ function createSdkCallbacks({
|
|
|
568
571
|
}
|
|
569
572
|
},
|
|
570
573
|
|
|
574
|
+
// 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
|
|
575
|
+
// 'compaction-warn' when context crosses the chat's threshold at turn-end
|
|
576
|
+
// (proactive) or claude is auto-compacting now (reactive). Post a chat
|
|
577
|
+
// message proposing /compact so the user can compact on their terms BEFORE
|
|
578
|
+
// an auto-compaction interrupts a turn (and detaches the channels bridge).
|
|
579
|
+
// Opt-in per chat (lib/compaction-warn.js) — CliProcess only emits when
|
|
580
|
+
// enabled, so no extra config gate is needed here. Best-effort send.
|
|
581
|
+
onCompactionWarn: (sessionKey, payload /* , entry */) => {
|
|
582
|
+
try {
|
|
583
|
+
const chatId = getChatIdFromKey(sessionKey);
|
|
584
|
+
const threadIdRaw = getThreadIdFromKey(sessionKey);
|
|
585
|
+
const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
|
|
586
|
+
const kind = payload?.kind === 'reactive' ? 'reactive' : 'proactive';
|
|
587
|
+
logEvent('compaction-warn', {
|
|
588
|
+
chat_id: chatId,
|
|
589
|
+
session_key: sessionKey,
|
|
590
|
+
kind,
|
|
591
|
+
pct: payload?.pct ?? null,
|
|
592
|
+
backend: payload?.backend ?? 'cli',
|
|
593
|
+
});
|
|
594
|
+
if (!bot) return;
|
|
595
|
+
const text = kind === 'reactive'
|
|
596
|
+
? '🗜️ Auto-compacting now — context filled up. If this turn goes quiet, please resend. (Tip: running `/compact` at a natural break avoids mid-task compactions.)'
|
|
597
|
+
: `📚 Heads up — this chat's context is ~${payload?.pct ?? '?'}% full. To avoid an auto-compaction that can interrupt a turn, run \`/compact\` (optionally with a hint, e.g. \`/compact keep the recent decisions\`) at a natural break — or \`/new\` for a fresh start.`;
|
|
598
|
+
tg(bot, 'sendMessage', {
|
|
599
|
+
chat_id: chatId,
|
|
600
|
+
text,
|
|
601
|
+
...(threadId ? { message_thread_id: threadId } : {}),
|
|
602
|
+
}, { source: 'compaction-warn', botName }).catch((err) => {
|
|
603
|
+
logger.error?.(`[${botName}] compaction-warn send failed: ${err.message}`);
|
|
604
|
+
});
|
|
605
|
+
} catch (err) {
|
|
606
|
+
logger.error?.(`[${botName}] compaction-warn handler: ${err.message}`);
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
|
|
571
610
|
// 0.10.0 rc.42 #8: tmux backend hook-tail error observability.
|
|
572
611
|
// Persistent failures of the hook ndjson tail degrade H3 idle-
|
|
573
612
|
// ceiling accuracy and H4 Stop-synth coverage with no surface
|
|
@@ -578,7 +617,7 @@ function createSdkCallbacks({
|
|
|
578
617
|
logEvent('hook-tail-error', {
|
|
579
618
|
chat_id: getChatIdFromKey(sessionKey),
|
|
580
619
|
session_key: sessionKey,
|
|
581
|
-
backend: '
|
|
620
|
+
backend: payload?.backend ?? 'cli', // Finding 0.12-M3 (fires on the CLI hook tail)
|
|
582
621
|
message: (payload?.message || '').slice(0, 200),
|
|
583
622
|
path: payload?.path ?? null,
|
|
584
623
|
claude_session_id: payload?.sessionId ?? null,
|
|
@@ -596,7 +635,7 @@ function createSdkCallbacks({
|
|
|
596
635
|
logEvent('stop-hook-resolved', {
|
|
597
636
|
chat_id: getChatIdFromKey(sessionKey),
|
|
598
637
|
session_key: sessionKey,
|
|
599
|
-
backend: '
|
|
638
|
+
backend: payload?.backend ?? 'cli', // Finding 0.12-M3
|
|
600
639
|
turn_id: payload?.turnId ?? null,
|
|
601
640
|
claude_session_id: payload?.sessionId ?? null,
|
|
602
641
|
});
|
|
@@ -614,7 +653,7 @@ function createSdkCallbacks({
|
|
|
614
653
|
logEvent('session-age-prompt-dismissed', {
|
|
615
654
|
chat_id: getChatIdFromKey(sessionKey),
|
|
616
655
|
session_key: sessionKey,
|
|
617
|
-
backend: '
|
|
656
|
+
backend: payload?.backend ?? 'cli', // Finding 0.12-M3
|
|
618
657
|
claude_session_id: payload?.sessionId ?? null,
|
|
619
658
|
});
|
|
620
659
|
} catch (err) {
|
|
@@ -680,7 +719,7 @@ function createSdkCallbacks({
|
|
|
680
719
|
// ON json_extract(s.detail_json, '$.tool_use_id') =
|
|
681
720
|
// json_extract(d.detail_json, '$.tool_use_id')
|
|
682
721
|
// WHERE s.kind='subagent-start' AND d.kind='subagent-done';
|
|
683
|
-
onSubagentStart: (sessionKey, payload
|
|
722
|
+
onSubagentStart: (sessionKey, payload, entry) => {
|
|
684
723
|
try {
|
|
685
724
|
logEvent('subagent-start', {
|
|
686
725
|
chat_id: getChatIdFromKey(sessionKey),
|
|
@@ -689,13 +728,23 @@ function createSdkCallbacks({
|
|
|
689
728
|
agent_type: payload?.agentType ?? null,
|
|
690
729
|
tool_use_id: payload?.toolUseId ?? null,
|
|
691
730
|
});
|
|
731
|
+
// Findings L9/L14: drive the head reactor into the distinct SUBAGENT
|
|
732
|
+
// state so a running subagent shows 👾 rather than freezing on the
|
|
733
|
+
// prior tool's emoji. The plan promised this; previously the handler
|
|
734
|
+
// only persisted the DB row and never touched the reactor.
|
|
735
|
+
const r = entry?.pendingQueue?.[0]?.context?.reactor;
|
|
736
|
+
if (r) r.setState('SUBAGENT');
|
|
692
737
|
} catch (err) {
|
|
693
738
|
logger.error?.(`[${botName}] subagent-start handler: ${err.message}`);
|
|
694
739
|
}
|
|
695
740
|
},
|
|
696
741
|
|
|
697
|
-
onSubagentDone: (sessionKey, payload
|
|
742
|
+
onSubagentDone: (sessionKey, payload, entry) => {
|
|
698
743
|
try {
|
|
744
|
+
// L9/L14: heartbeat at subagent end so the cascade/stall clock
|
|
745
|
+
// resets; the next tool's PreToolUse sets the following state.
|
|
746
|
+
const r = entry?.pendingQueue?.[0]?.context?.reactor;
|
|
747
|
+
if (r && typeof r.heartbeat === 'function') r.heartbeat();
|
|
699
748
|
logEvent('subagent-done', {
|
|
700
749
|
chat_id: getChatIdFromKey(sessionKey),
|
|
701
750
|
session_key: sessionKey,
|
|
@@ -703,6 +752,11 @@ function createSdkCallbacks({
|
|
|
703
752
|
agent_type: payload?.agentType ?? null,
|
|
704
753
|
agent_id: payload?.agentId ?? null,
|
|
705
754
|
duration_ms: payload?.durationMs ?? null,
|
|
755
|
+
// Finding 0.12-M4: persist the originating Agent tool_use_id so the
|
|
756
|
+
// documented subagent-start/subagent-done soak JOIN on
|
|
757
|
+
// $.tool_use_id matches (subagent-done's tool_use_id is recovered
|
|
758
|
+
// in cli-process.js from the paired Agent PreToolUse).
|
|
759
|
+
tool_use_id: payload?.toolUseId ?? null,
|
|
706
760
|
});
|
|
707
761
|
} catch (err) {
|
|
708
762
|
logger.error?.(`[${botName}] subagent-done handler: ${err.message}`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* album-reactions — apply one status reaction to every message of a Telegram
|
|
3
|
+
* album (the anchor + its siblings), so a multi-file send shows the same emoji
|
|
4
|
+
* on each item instead of only the first.
|
|
5
|
+
*
|
|
6
|
+
* Background: Telegram delivers an album as N separate messages sharing a
|
|
7
|
+
* media_group_id; polygram coalesces them into ONE turn anchored on the first.
|
|
8
|
+
* The status reactor therefore only ever reacted to that anchor, leaving the
|
|
9
|
+
* sibling files with no visible reaction (the rc.16 observation). This mirrors
|
|
10
|
+
* the reactor's emoji onto the siblings.
|
|
11
|
+
*
|
|
12
|
+
* Semantics:
|
|
13
|
+
* - The ANCHOR (first id) is awaited so a failure surfaces to the reactor's
|
|
14
|
+
* own error handling (same as the single-message path).
|
|
15
|
+
* - SIBLINGS are best-effort: a failure on one must not drop the anchor's
|
|
16
|
+
* reaction or the other siblings (and must not throw — reactions are
|
|
17
|
+
* cosmetic). They also can't share the anchor's fate of being retried.
|
|
18
|
+
* - Calls are sequential to respect Telegram's setMessageReaction rate limit
|
|
19
|
+
* (~5/s/chat) — an album is ≤10 items so this stays well within budget.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {Function} opts.tg async (bot, method, params, meta) => any
|
|
27
|
+
* @param {*} opts.bot
|
|
28
|
+
* @param {string} opts.chatId
|
|
29
|
+
* @param {number[]} opts.msgIds [anchor, ...siblings] — anchor first
|
|
30
|
+
* @param {string|null} opts.emoji emoji to set, or null/'' to clear
|
|
31
|
+
* @param {string} [opts.botName]
|
|
32
|
+
*/
|
|
33
|
+
async function applyReactionToMessages({ tg, bot, chatId, msgIds, emoji, botName } = {}) {
|
|
34
|
+
const reaction = emoji ? [{ type: 'emoji', emoji }] : [];
|
|
35
|
+
const ids = Array.isArray(msgIds) ? msgIds : [];
|
|
36
|
+
for (let i = 0; i < ids.length; i++) {
|
|
37
|
+
const params = { chat_id: chatId, message_id: ids[i], reaction };
|
|
38
|
+
const meta = {
|
|
39
|
+
source: i === 0 ? 'status-reaction' : 'status-reaction-album-sibling',
|
|
40
|
+
botName,
|
|
41
|
+
};
|
|
42
|
+
if (i === 0) {
|
|
43
|
+
await tg(bot, 'setMessageReaction', params, meta); // anchor: surface failure
|
|
44
|
+
} else {
|
|
45
|
+
await tg(bot, 'setMessageReaction', params, meta).catch(() => {}); // siblings: best-effort
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { applyReactionToMessages };
|
package/lib/telegram/api.js
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
getRetryAfterMs,
|
|
29
29
|
} = require('./format');
|
|
30
30
|
const { isSafeToRetry, redactBotToken } = require('../error/net');
|
|
31
|
+
const { coerceFileParams } = require('./input-file');
|
|
31
32
|
|
|
32
33
|
// Topic deletion race: a user can delete a forum topic while a turn is in
|
|
33
34
|
// flight, turning a valid `message_thread_id` into a 404. Telegram's error
|
|
@@ -112,6 +113,14 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
112
113
|
const chatId = params.chat_id != null ? String(params.chat_id) : null;
|
|
113
114
|
const threadId = params.message_thread_id != null ? String(params.message_thread_id) : null;
|
|
114
115
|
|
|
116
|
+
// File-upload bug fix (2026-05-31): coerce a `{ source: '/abs/path' }`
|
|
117
|
+
// file param into a grammy InputFile so local-file uploads actually work.
|
|
118
|
+
// grammy doesn't recognize the bare envelope → it failed every send with
|
|
119
|
+
// "Wrong port number". Single choke point: fixes channels reply(files)
|
|
120
|
+
// AND the IPC send path at once. No-op for non-file methods / file_id /
|
|
121
|
+
// URL strings / existing InputFile instances.
|
|
122
|
+
coerceFileParams(method, params);
|
|
123
|
+
|
|
115
124
|
// 0.7.4: empty-text short-circuit. Pre-fix, an empty params.text on
|
|
116
125
|
// sendMessage/editMessageText reached Telegram and 400'd with
|
|
117
126
|
// "message text is empty"; the row was marked failed and propagated
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* input-file — coerce file-upload params into grammy InputFile instances.
|
|
3
|
+
*
|
|
4
|
+
* The bug (2026-05-31, shumorobot Music): callers passed a Telegraf-style
|
|
5
|
+
* `{ source: '/abs/path' }` envelope as the file param (document/photo/…).
|
|
6
|
+
* grammy 1.x does NOT recognize that shape — it's not an InputFile, so
|
|
7
|
+
* grammy serializes it as a plain object and Telegram tries to read it as
|
|
8
|
+
* a URL/file_id, failing with "invalid file HTTP URL: Wrong port number".
|
|
9
|
+
* Result: file-send NEVER worked (channels reply(files) AND the IPC path
|
|
10
|
+
* both produced this exact error). The existing dispatcher test used a fake
|
|
11
|
+
* `send` and only asserted the METHOD, so it couldn't catch the bad shape.
|
|
12
|
+
*
|
|
13
|
+
* grammy uploads a local file only when the param is `new InputFile(path)`.
|
|
14
|
+
* This helper normalizes, at the single send choke point (tg()), the
|
|
15
|
+
* `{ source: <abs path> }` envelope → `new InputFile(path)`, leaving every
|
|
16
|
+
* other shape untouched:
|
|
17
|
+
* - string file_id / https URL → pass through (Telegram resolves)
|
|
18
|
+
* - existing InputFile instance → pass through (already correct)
|
|
19
|
+
* - Buffer / stream → pass through (grammy handles)
|
|
20
|
+
*
|
|
21
|
+
* Only the explicit `{ source: string }` envelope is transformed — bare
|
|
22
|
+
* path strings are intentionally NOT coerced (a Telegram file_id is also a
|
|
23
|
+
* bare string; coercing would break sends-by-id).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { InputFile } = require('grammy');
|
|
29
|
+
|
|
30
|
+
// method → the params field that carries the file.
|
|
31
|
+
const FILE_FIELD_BY_METHOD = {
|
|
32
|
+
sendPhoto: 'photo',
|
|
33
|
+
sendDocument: 'document',
|
|
34
|
+
sendAudio: 'audio',
|
|
35
|
+
sendVideo: 'video',
|
|
36
|
+
sendAnimation: 'animation',
|
|
37
|
+
sendVoice: 'voice',
|
|
38
|
+
sendVideoNote: 'video_note',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Return a grammy-uploadable value for a single file param, or the original
|
|
43
|
+
* value unchanged if it's not the `{ source }` envelope we coerce.
|
|
44
|
+
*/
|
|
45
|
+
function coerceFileValue(val) {
|
|
46
|
+
if (val && typeof val === 'object' && !(val instanceof InputFile)
|
|
47
|
+
&& typeof val.source === 'string' && val.source.length > 0) {
|
|
48
|
+
// { source: '/abs/path' } | { source: 'https://…', filename } → InputFile
|
|
49
|
+
return new InputFile(val.source, val.filename);
|
|
50
|
+
}
|
|
51
|
+
return val;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Mutate `params` in place so its file field (if any) is grammy-uploadable.
|
|
56
|
+
* No-op for non-file methods and for params with no file field set.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} method
|
|
59
|
+
* @param {object} params
|
|
60
|
+
* @returns {object} the same params object (for chaining)
|
|
61
|
+
*/
|
|
62
|
+
function coerceFileParams(method, params) {
|
|
63
|
+
if (!params || typeof params !== 'object') return params;
|
|
64
|
+
const field = FILE_FIELD_BY_METHOD[method];
|
|
65
|
+
if (!field) return params;
|
|
66
|
+
if (params[field] != null) {
|
|
67
|
+
params[field] = coerceFileValue(params[field]);
|
|
68
|
+
}
|
|
69
|
+
return params;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
coerceFileParams,
|
|
74
|
+
coerceFileValue,
|
|
75
|
+
FILE_FIELD_BY_METHOD,
|
|
76
|
+
};
|
|
@@ -55,6 +55,11 @@ const STATES = {
|
|
|
55
55
|
// mid-turn user message is buffered for the next PostToolBatch
|
|
56
56
|
// injection.
|
|
57
57
|
AUTOSTEERED: { label: 'autosteered', chain: ['✍', '👀'] },
|
|
58
|
+
// 0.12 (Findings L9/L14): distinct in-progress reaction for a running
|
|
59
|
+
// subagent (Agent PreToolUse → SubagentStop). Driven by onSubagentStart.
|
|
60
|
+
// Preferred 👾 (NOT 🤖 — 🤖 is REACTION_INVALID for bots, same class as
|
|
61
|
+
// the rc.37 🧐 bug); falls back to 🔥 then 🤔, all bot-usable.
|
|
62
|
+
SUBAGENT: { label: 'subagent', chain: ['👾', '🔥', '🤔'] },
|
|
58
63
|
DONE: { label: 'done', chain: ['👍'] },
|
|
59
64
|
ERROR: { label: 'error', chain: ['🤯', '🤔'] },
|
|
60
65
|
STALL: { label: 'stall', chain: ['🥱', '🤔'] },
|
package/lib/tmux/log-tail.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
const EventEmitter = require('events');
|
|
43
43
|
const fs = require('fs');
|
|
44
44
|
const path = require('path');
|
|
45
|
+
const { StringDecoder } = require('string_decoder');
|
|
45
46
|
|
|
46
47
|
const DEFAULT_INTERVAL_MS = 100;
|
|
47
48
|
// Slow safety-net poll when fs.watch is active. Catches any events
|
|
@@ -91,6 +92,13 @@ class LogTail extends EventEmitter {
|
|
|
91
92
|
this.fs = fsOverride || fs;
|
|
92
93
|
this._offset = 0;
|
|
93
94
|
this._buf = '';
|
|
95
|
+
// L8: decode bytes through a StringDecoder so a multibyte UTF-8 char
|
|
96
|
+
// split across two read chunks (the 64KB DEFAULT_CHUNK_BYTES boundary)
|
|
97
|
+
// isn't corrupted into U+FFFD. The decoder holds an incomplete trailing
|
|
98
|
+
// sequence until the continuation bytes arrive on the next read. The
|
|
99
|
+
// hook ndjson carries large non-ASCII tool payloads, so this is
|
|
100
|
+
// load-bearing on the CliProcess observability path.
|
|
101
|
+
this._decoder = new StringDecoder('utf8');
|
|
94
102
|
this._closed = false;
|
|
95
103
|
this._timer = null;
|
|
96
104
|
this._watcher = null;
|
|
@@ -260,7 +268,9 @@ class LogTail extends EventEmitter {
|
|
|
260
268
|
const readSize = Math.min(remaining, buffer.length);
|
|
261
269
|
const { bytesRead } = await fd.read(buffer, 0, readSize, this._offset + totalRead);
|
|
262
270
|
if (bytesRead === 0) break;
|
|
263
|
-
|
|
271
|
+
// L8: StringDecoder.write instead of per-chunk toString('utf8') so a
|
|
272
|
+
// multibyte char straddling the read boundary survives intact.
|
|
273
|
+
this._buf += this._decoder.write(buffer.subarray(0, bytesRead));
|
|
264
274
|
totalRead += bytesRead;
|
|
265
275
|
}
|
|
266
276
|
this._offset += totalRead;
|
package/lib/tmux/startup-gate.js
CHANGED
|
@@ -17,6 +17,19 @@
|
|
|
17
17
|
* - if `readySignal` regex matches the captured pane content, resolve
|
|
18
18
|
* - if `Date.now()` exceeds the deadline, throw with `err.code = timeoutCode`
|
|
19
19
|
*
|
|
20
|
+
* Progress-aware (stall) deadline — `stallMs`:
|
|
21
|
+
* The blind wall-clock `deadlineMs` can't tell "claude is mid-download
|
|
22
|
+
* (24% progress bar, genuinely working)" from "claude is wedged". The
|
|
23
|
+
* shumorobot General incident (2026-05-30) killed a cold-spawn that was
|
|
24
|
+
* actively downloading the runtime. When `stallMs` is set, the gate
|
|
25
|
+
* tracks pane ACTIVITY: any change in captured pane content — or a
|
|
26
|
+
* trigger key being sent — resets a stall clock. The gate fails early
|
|
27
|
+
* (with `timeoutCode`) only after `stallMs` elapses with NO activity,
|
|
28
|
+
* i.e. the pane is frozen. `deadlineMs` remains an absolute backstop so
|
|
29
|
+
* a pane that animates forever but never reaches `readySignal` still
|
|
30
|
+
* terminates. When `stallMs` is omitted (default), behavior is the pure
|
|
31
|
+
* `deadlineMs` wall-clock exactly as before.
|
|
32
|
+
*
|
|
20
33
|
* Each trigger is one-shot per gate run (tracked by `name` in a Set).
|
|
21
34
|
*
|
|
22
35
|
* Caller supplies:
|
|
@@ -40,7 +53,10 @@ const DEFAULT_SETTLE_MS = 500;
|
|
|
40
53
|
* @param {string} opts.tmuxName — tmux session name to poll
|
|
41
54
|
* @param {Array<{name:string, regex:RegExp, key:string}>} opts.triggers
|
|
42
55
|
* @param {RegExp} opts.readySignal — match → resolve
|
|
43
|
-
* @param {number} [opts.deadlineMs=30000]
|
|
56
|
+
* @param {number} [opts.deadlineMs=30000] — absolute backstop
|
|
57
|
+
* @param {number} [opts.stallMs] — if set, fail after this much
|
|
58
|
+
* wall-clock with NO pane activity (progress-aware). Omit for pure
|
|
59
|
+
* wall-clock behavior.
|
|
44
60
|
* @param {number} [opts.pollMs=300]
|
|
45
61
|
* @param {number} [opts.settleMs=500]
|
|
46
62
|
* @param {string} [opts.timeoutCode='TUI_STARTUP_TIMEOUT']
|
|
@@ -54,6 +70,7 @@ async function runStartupGate({
|
|
|
54
70
|
triggers = [],
|
|
55
71
|
readySignal,
|
|
56
72
|
deadlineMs = DEFAULT_DEADLINE_MS,
|
|
73
|
+
stallMs,
|
|
57
74
|
pollMs = DEFAULT_POLL_MS,
|
|
58
75
|
settleMs = DEFAULT_SETTLE_MS,
|
|
59
76
|
timeoutCode = 'TUI_STARTUP_TIMEOUT',
|
|
@@ -70,6 +87,7 @@ async function runStartupGate({
|
|
|
70
87
|
|
|
71
88
|
const startedAt = Date.now();
|
|
72
89
|
const deadline = startedAt + deadlineMs;
|
|
90
|
+
const stallEnabled = Number.isFinite(stallMs) && stallMs > 0;
|
|
73
91
|
const seen = new Set();
|
|
74
92
|
const matchedTriggers = [];
|
|
75
93
|
// rc.4: remember the most recent successful pane snapshot. If the gate
|
|
@@ -78,8 +96,37 @@ async function runStartupGate({
|
|
|
78
96
|
// this, "claude exits code 0 after dev-channels Enter" surfaces as a
|
|
79
97
|
// 30-second `can't find pane` spam with no diagnostic about WHY.
|
|
80
98
|
let lastPane = null;
|
|
99
|
+
// Progress-aware gate: timestamp of the last observed pane CHANGE (or
|
|
100
|
+
// trigger send). Only consulted when stallEnabled.
|
|
101
|
+
let lastActivityAt = startedAt;
|
|
102
|
+
// Music incident (2026-06-01): the stall timer must NOT arm while the pane
|
|
103
|
+
// is still BLANK. A blank-and-unchanging pane means claude hasn't started
|
|
104
|
+
// rendering yet (slow cold-start), NOT that it wedged — the TUI for some
|
|
105
|
+
// topics takes 30-45s to first-render. Arming the stall timer on a blank
|
|
106
|
+
// pane killed a legitimate slow spawn at stallMs with a false "wedged".
|
|
107
|
+
// So the stall clock only runs once the pane has shown non-whitespace
|
|
108
|
+
// content; before that, only the absolute `deadlineMs` governs.
|
|
109
|
+
let sawContent = false;
|
|
81
110
|
|
|
82
111
|
while (Date.now() < deadline) {
|
|
112
|
+
// Stall check (progress-aware): the pane RENDERED something and has then
|
|
113
|
+
// been static for stallMs → genuinely wedged. Gated on sawContent so a
|
|
114
|
+
// blank cold-start isn't mistaken for a wedge. Fires early so a truly
|
|
115
|
+
// hung TUI fails fast, while an actively-progressing one (download bar,
|
|
116
|
+
// dialog navigation) keeps resetting lastActivityAt below.
|
|
117
|
+
if (stallEnabled && sawContent && Date.now() - lastActivityAt >= stallMs) {
|
|
118
|
+
const err = new Error(
|
|
119
|
+
`[${label}] startup gate: pane rendered then went static for ${stallMs}ms for ${tmuxName} ` +
|
|
120
|
+
`(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
|
|
121
|
+
`Appears wedged. Last pane content:\n` +
|
|
122
|
+
_formatPaneTail(lastPane),
|
|
123
|
+
);
|
|
124
|
+
err.code = timeoutCode;
|
|
125
|
+
err.lastPane = lastPane;
|
|
126
|
+
err.matchedTriggers = matchedTriggers;
|
|
127
|
+
err.reason = 'stall';
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
83
130
|
let pane;
|
|
84
131
|
try {
|
|
85
132
|
pane = await runner.captureWide(tmuxName);
|
|
@@ -107,6 +154,19 @@ async function runStartupGate({
|
|
|
107
154
|
await new Promise(r => setTimeout(r, settleMs));
|
|
108
155
|
continue;
|
|
109
156
|
}
|
|
157
|
+
// First non-whitespace content = the TUI has started rendering. Only
|
|
158
|
+
// from here does the stall timer become meaningful (before this, a blank
|
|
159
|
+
// pane is cold-start, governed by the absolute deadline). Seed
|
|
160
|
+
// lastActivityAt at the moment content first appears so the stall window
|
|
161
|
+
// is measured from "rendered", not from spawn.
|
|
162
|
+
if (!sawContent && pane && pane.trim().length > 0) {
|
|
163
|
+
sawContent = true;
|
|
164
|
+
lastActivityAt = Date.now();
|
|
165
|
+
}
|
|
166
|
+
// Progress signal: any change in pane content is activity → reset the
|
|
167
|
+
// stall clock. A captureWide that returns the SAME bytes is NOT
|
|
168
|
+
// activity (a frozen download bar at 24% reads identically each poll).
|
|
169
|
+
if (pane !== lastPane) lastActivityAt = Date.now();
|
|
110
170
|
lastPane = pane;
|
|
111
171
|
|
|
112
172
|
// Walk triggers in declaration order — first match (and not yet seen) wins
|
|
@@ -122,6 +182,10 @@ async function runStartupGate({
|
|
|
122
182
|
seen.add(trigger.name);
|
|
123
183
|
matchedTriggers.push(trigger.name);
|
|
124
184
|
matched = true;
|
|
185
|
+
// Sending a key is activity — navigating the TUI counts as progress
|
|
186
|
+
// even if the pre-transition pane text was static (e.g. a dialog we
|
|
187
|
+
// just answered). Reset the stall clock so we don't fail mid-nav.
|
|
188
|
+
lastActivityAt = Date.now();
|
|
125
189
|
// Settle window so the TUI transitions out of the dialog before next poll
|
|
126
190
|
await new Promise(r => setTimeout(r, settleMs));
|
|
127
191
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0-rc.
|
|
3
|
+
"version": "0.12.0-rc.21",
|
|
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": {
|