polygram 0.37.1 → 0.38.0
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/db.js +28 -0
- package/lib/handlers/claude-autosteer-ownership.js +70 -0
- package/lib/handlers/config-ui.js +53 -44
- package/lib/ipc/restart-request-id.js +17 -0
- package/lib/telegram/display-hint.js +1 -0
- package/package.json +1 -1
- package/polygram.js +184 -62
- package/scripts/ipc-smoke.js +13 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.38.0",
|
|
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/lib/db.js
CHANGED
|
@@ -553,6 +553,13 @@ function wrap(db) {
|
|
|
553
553
|
AND bot_name = ?
|
|
554
554
|
AND ts > ?
|
|
555
555
|
`);
|
|
556
|
+
const completeAcceptedClaudeAutosteerStmt = db.prepare(`
|
|
557
|
+
UPDATE messages SET handler_status = 'replied'
|
|
558
|
+
WHERE chat_id = ?
|
|
559
|
+
AND msg_id = ?
|
|
560
|
+
AND direction = 'in'
|
|
561
|
+
AND handler_status IN ('dispatched', 'replay-attempted')
|
|
562
|
+
`);
|
|
556
563
|
const clearCleanShutdownMarkerStmt = db.prepare(`
|
|
557
564
|
UPDATE polling_state SET clean_shutdown_at = NULL
|
|
558
565
|
WHERE bot_name = ?
|
|
@@ -4174,6 +4181,27 @@ function wrap(db) {
|
|
|
4174
4181
|
AND handler_status NOT IN ('codex-ambiguous', 'failed')
|
|
4175
4182
|
`).run(status, chat_id, msg_id);
|
|
4176
4183
|
},
|
|
4184
|
+
completeAcceptedClaudeAutosteer({ chat_id, msg_id }) {
|
|
4185
|
+
let result;
|
|
4186
|
+
try {
|
|
4187
|
+
result = completeAcceptedClaudeAutosteerStmt.run(chat_id, msg_id);
|
|
4188
|
+
} catch (cause) {
|
|
4189
|
+
const error = new Error(
|
|
4190
|
+
'accepted Claude follow-up handler status could not be persisted',
|
|
4191
|
+
{ cause },
|
|
4192
|
+
);
|
|
4193
|
+
error.code = 'CLAUDE_AUTOSTEER_STATUS_PERSIST_FAILED';
|
|
4194
|
+
throw error;
|
|
4195
|
+
}
|
|
4196
|
+
if (result.changes !== 1) {
|
|
4197
|
+
const error = new Error(
|
|
4198
|
+
'accepted Claude follow-up did not own one live inbound row',
|
|
4199
|
+
);
|
|
4200
|
+
error.code = 'CLAUDE_AUTOSTEER_STATUS_CONFLICT';
|
|
4201
|
+
throw error;
|
|
4202
|
+
}
|
|
4203
|
+
return result;
|
|
4204
|
+
},
|
|
4177
4205
|
|
|
4178
4206
|
// 0.9.0: True when a specific inbound msg is still being processed
|
|
4179
4207
|
// by the SDK turn loop (handler_status in dispatched/processing).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PRIMARY_BLOCKING_CODEX_DECISIONS = new Set([
|
|
4
|
+
'duplicate',
|
|
5
|
+
'ambiguous',
|
|
6
|
+
'unavailable',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Complete ownership of a follow-up that the Claude injection path accepted.
|
|
11
|
+
*
|
|
12
|
+
* Codex acceptance is deliberately a no-op here: its linked target turn and
|
|
13
|
+
* Telegram delivery checkpoint own the eventual terminal status.
|
|
14
|
+
*/
|
|
15
|
+
function settleAcceptedAutosteerOwnership({
|
|
16
|
+
selectedProvider,
|
|
17
|
+
steered,
|
|
18
|
+
db,
|
|
19
|
+
chatId,
|
|
20
|
+
msgId,
|
|
21
|
+
sessionKey,
|
|
22
|
+
logLabel = sessionKey,
|
|
23
|
+
logEvent = () => {},
|
|
24
|
+
logger = console,
|
|
25
|
+
} = {}) {
|
|
26
|
+
if (selectedProvider !== 'claude' || steered?.autosteered !== true) {
|
|
27
|
+
return steered;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
db.completeAcceptedClaudeAutosteer({
|
|
32
|
+
chat_id: chatId,
|
|
33
|
+
msg_id: msgId,
|
|
34
|
+
});
|
|
35
|
+
return steered;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
const errorCode = error?.code || error?.name || 'unknown';
|
|
38
|
+
logger.error(
|
|
39
|
+
`[${logLabel}] accepted Claude follow-up persistence failed: ${errorCode}`,
|
|
40
|
+
);
|
|
41
|
+
logEvent('autosteer-handler-status-failed', {
|
|
42
|
+
chat_id: chatId,
|
|
43
|
+
msg_id: msgId,
|
|
44
|
+
session_key: sessionKey,
|
|
45
|
+
code: errorCode,
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
...steered,
|
|
49
|
+
autosteered: false,
|
|
50
|
+
outcome: 'accepted-persistence-ambiguous',
|
|
51
|
+
errorCode,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shouldDispatchPrimaryAfterAutosteer({
|
|
57
|
+
steered,
|
|
58
|
+
codexDispatchDecision = null,
|
|
59
|
+
} = {}) {
|
|
60
|
+
return (
|
|
61
|
+
steered?.autosteered !== true
|
|
62
|
+
&& steered?.outcome !== 'accepted-persistence-ambiguous'
|
|
63
|
+
&& !PRIMARY_BLOCKING_CODEX_DECISIONS.has(codexDispatchDecision)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
settleAcceptedAutosteerOwnership,
|
|
69
|
+
shouldDispatchPrimaryAfterAutosteer,
|
|
70
|
+
};
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
|
|
19
19
|
const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
|
|
20
20
|
const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
21
|
-
const CODEX_MODEL_OPTIONS =
|
|
22
|
-
'gpt-5.6-sol',
|
|
23
|
-
'gpt-5.6-terra',
|
|
24
|
-
'gpt-5.6-luna',
|
|
21
|
+
const CODEX_MODEL_OPTIONS = Object.freeze([
|
|
22
|
+
Object.freeze({ model: 'gpt-5.6-sol', label: 'Sol' }),
|
|
23
|
+
Object.freeze({ model: 'gpt-5.6-terra', label: 'Terra' }),
|
|
24
|
+
Object.freeze({ model: 'gpt-5.6-luna', label: 'Luna' }),
|
|
25
25
|
]);
|
|
26
26
|
// Which runtimes the card offers. The sdk backend stays fully supported in
|
|
27
27
|
// code and config files — it is per-token and nobody picks it from a chat, so
|
|
@@ -63,20 +63,30 @@ function getCodexCatalogModels(runtimeView) {
|
|
|
63
63
|
if (!isCodexRuntimeView(runtimeView) || !Array.isArray(runtimeView.models)) {
|
|
64
64
|
return [];
|
|
65
65
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
!entry
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|| !CODEX_MODEL_OPTIONS.has(entry.model)
|
|
73
|
-
|| seen.has(entry.model)
|
|
74
|
-
) {
|
|
75
|
-
return false;
|
|
66
|
+
const catalogByModel = new Map();
|
|
67
|
+
for (const entry of runtimeView.models) {
|
|
68
|
+
if (entry && typeof entry.model === 'string' && entry.model.length > 0) {
|
|
69
|
+
if (!catalogByModel.has(entry.model)) {
|
|
70
|
+
catalogByModel.set(entry.model, entry);
|
|
71
|
+
}
|
|
76
72
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
}
|
|
74
|
+
return CODEX_MODEL_OPTIONS.flatMap(({ model }) => (
|
|
75
|
+
catalogByModel.has(model) ? [catalogByModel.get(model)] : []
|
|
76
|
+
));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function codexModelLabel(model) {
|
|
80
|
+
return CODEX_MODEL_OPTIONS.find((entry) => entry.model === model)?.label
|
|
81
|
+
?? model;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasCodexCatalogModels(runtimeView) {
|
|
85
|
+
return isCodexRuntimeView(runtimeView)
|
|
86
|
+
&& Array.isArray(runtimeView.models)
|
|
87
|
+
&& runtimeView.models.some(
|
|
88
|
+
(entry) => typeof entry?.model === 'string' && entry.model.length > 0,
|
|
89
|
+
);
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
function getCodexCatalogEfforts(runtimeView, model = runtimeView?.model) {
|
|
@@ -262,10 +272,15 @@ function buildConfigKeyboard(
|
|
|
262
272
|
}
|
|
263
273
|
if ((show === 'model' || show === 'all') && modelOptions.length > 0) {
|
|
264
274
|
rows.push(modelOptions.map((entry) => {
|
|
265
|
-
|
|
275
|
+
let label = entry.model;
|
|
276
|
+
if (codex) {
|
|
277
|
+
label = codexModelLabel(entry.model);
|
|
278
|
+
} else if (
|
|
266
279
|
typeof entry.displayName === 'string'
|
|
267
280
|
&& entry.displayName.length > 0
|
|
268
|
-
)
|
|
281
|
+
) {
|
|
282
|
+
label = entry.displayName;
|
|
283
|
+
}
|
|
269
284
|
return {
|
|
270
285
|
text: entry.model === model ? `✓ ${label}` : label,
|
|
271
286
|
callback_data: `cfg:model:${entry.model}`,
|
|
@@ -325,33 +340,30 @@ function createFormatConfigInfoText({
|
|
|
325
340
|
? codexModels.length > 0 ? [
|
|
326
341
|
'',
|
|
327
342
|
'**Models**',
|
|
328
|
-
...codexModels.map(
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
&& entry.displayName.length > 0
|
|
332
|
-
&& entry.displayName !== entry.model
|
|
333
|
-
) ? ` — ${entry.displayName}` : '';
|
|
334
|
-
return `• **${entry.model}**${displayName}`;
|
|
335
|
-
}),
|
|
343
|
+
...codexModels.map(
|
|
344
|
+
(entry) => `• **${codexModelLabel(entry.model)}** — ${entry.model}`,
|
|
345
|
+
),
|
|
336
346
|
].join('\n') : [
|
|
337
347
|
'',
|
|
338
348
|
'**Models**',
|
|
339
|
-
|
|
349
|
+
hasCodexCatalogModels(runtimeView)
|
|
350
|
+
? 'Sol, Terra, and Luna are unavailable for this account.'
|
|
351
|
+
: 'Models are unavailable until Codex preflight succeeds.',
|
|
340
352
|
].join('\n')
|
|
341
353
|
: [
|
|
342
354
|
'',
|
|
343
355
|
'**Models**',
|
|
344
|
-
'🧠 **opus** —
|
|
345
|
-
'🤖 **sonnet** — default
|
|
346
|
-
'⚡ **haiku** —
|
|
356
|
+
'🧠 **opus** — deepest reasoning',
|
|
357
|
+
'🤖 **sonnet** — balanced default',
|
|
358
|
+
'⚡ **haiku** — fastest',
|
|
347
359
|
].join('\n');
|
|
348
360
|
|
|
349
361
|
const effortHelp = codex
|
|
350
362
|
? codexEfforts.length > 0 ? [
|
|
351
363
|
'',
|
|
352
|
-
`**Effort
|
|
353
|
-
|
|
354
|
-
'
|
|
364
|
+
`**Effort for ${codexModelLabel(codexModel)}**`,
|
|
365
|
+
codexEfforts.map((value) => `**${value}**`).join(' · '),
|
|
366
|
+
'Changes apply on the next turn; an active turn is unchanged.',
|
|
355
367
|
].join('\n') : [
|
|
356
368
|
'',
|
|
357
369
|
'**Effort**',
|
|
@@ -359,11 +371,9 @@ function createFormatConfigInfoText({
|
|
|
359
371
|
].join('\n')
|
|
360
372
|
: [
|
|
361
373
|
'',
|
|
362
|
-
'**Effort**
|
|
363
|
-
'
|
|
364
|
-
'
|
|
365
|
-
'• **high** — multi-step tasks. Audit, debug, multi-source analysis.',
|
|
366
|
-
'• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
|
|
374
|
+
'**Effort**',
|
|
375
|
+
'**low** · **medium** · **high** · **xhigh** · **max**',
|
|
376
|
+
'Higher values allow more reasoning when needed.',
|
|
367
377
|
].join('\n');
|
|
368
378
|
|
|
369
379
|
// The card is its buttons. Everything the body used to state — model,
|
|
@@ -373,11 +383,10 @@ function createFormatConfigInfoText({
|
|
|
373
383
|
// button to live on.
|
|
374
384
|
let body = '⚙️ Settings';
|
|
375
385
|
if (codex) {
|
|
376
|
-
body += '\n\
|
|
377
|
-
+ '
|
|
378
|
-
+ '
|
|
379
|
-
+ 'Native goals are disabled
|
|
380
|
-
+ 'native goal support is implemented.';
|
|
386
|
+
body += '\n\n_Codex beta: no command network, web search, product MCP tools, '
|
|
387
|
+
+ 'or interactive approvals. Detached/background servers are unsupported '
|
|
388
|
+
+ 'and may survive hard runtime loss. '
|
|
389
|
+
+ 'Native goals are currently disabled._';
|
|
381
390
|
}
|
|
382
391
|
if (show === 'model' || show === 'all') body += '\n' + modelHelp;
|
|
383
392
|
if (show === 'effort' || show === 'all') body += '\n' + effortHelp;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function requireRestartRequestId(value) {
|
|
4
|
+
if (
|
|
5
|
+
typeof value !== 'string'
|
|
6
|
+
|| value.length === 0
|
|
7
|
+
|| Buffer.byteLength(value, 'utf8') > 128
|
|
8
|
+
|| /[\x00-\x1f\x7f]/.test(value)
|
|
9
|
+
) {
|
|
10
|
+
const error = new Error('deploy restart request ID is invalid');
|
|
11
|
+
error.code = 'INVALID_DEPLOY_RESTART_REQUEST_ID';
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { requireRestartRequestId };
|
|
@@ -90,6 +90,7 @@ const richSection = (inlineMedia) => [
|
|
|
90
90
|
'- A numbered sequence (steps, ranked facts, top-N) → a real ordered list (`1.` at line start, continuation lines indented under their item), not bold pseudo-items like `**1. First thing.**` on their own paragraphs. A real list renders with native numbering and hanging indentation; the bold fake renders as flat paragraphs that merely start with a digit.',
|
|
91
91
|
'- A list of items each with a yes/no/pending state (approve this? confirm that?) → a real task list (`- [ ] item`), not `• item` with an emoji glued on.',
|
|
92
92
|
'- A comparison or key/value summary → a real markdown table.',
|
|
93
|
+
'- Rows and columns you are laying out yourself → a real markdown table, never hand-aligned columns inside a ``` fence. A fence is not a table: its columns are just whitespace you typed, so the client cannot re-lay-out them to fit and on a narrow screen the rows break mid-row and the alignment collapses into noise. Keep fences for code, logs, and command output you are quoting verbatim — including output that is already table-shaped, which you reproduce exactly rather than retype as a markdown table.',
|
|
93
94
|
'- A quoted aside, caveat, or callout you want visually set apart → a real blockquote (`> like this`), not a bold prefix.',
|
|
94
95
|
'- A visual break between distinct sections of a long reply → a real divider (`---` on its own line), the same way you already reach for it, but now it renders as a divider instead of literal dashes.',
|
|
95
96
|
'- Verbose supporting detail (a diff, a log, raw output) you want available but not front-and-center → `<details><summary>Label</summary>...</details>`.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
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": {
|
package/polygram.js
CHANGED
|
@@ -31,6 +31,9 @@ const {
|
|
|
31
31
|
const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
|
|
32
32
|
const { countInFlight } = require('./lib/queue-utils');
|
|
33
33
|
const { createIpcHandlers } = require('./lib/ipc/handlers');
|
|
34
|
+
const {
|
|
35
|
+
requireRestartRequestId,
|
|
36
|
+
} = require('./lib/ipc/restart-request-id');
|
|
34
37
|
const { classifyOrphanSweep } = require('./lib/ops/tmux-preflight');
|
|
35
38
|
const {
|
|
36
39
|
parseSystemdInvocationId,
|
|
@@ -98,6 +101,10 @@ const { createDownloadAttachments } = require('./lib/handlers/download');
|
|
|
98
101
|
const { createHandleConfigCallback } = require('./lib/handlers/config-callback');
|
|
99
102
|
const { createHandleAbort } = require('./lib/handlers/abort');
|
|
100
103
|
const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
|
|
104
|
+
const {
|
|
105
|
+
settleAcceptedAutosteerOwnership,
|
|
106
|
+
shouldDispatchPrimaryAfterAutosteer,
|
|
107
|
+
} = require('./lib/handlers/claude-autosteer-ownership');
|
|
101
108
|
const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
|
|
102
109
|
const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
|
|
103
110
|
const { createGateInbound, ADMIN_CMD_RE, PAIR_CLAIM_RE } = require('./lib/handlers/gate-inbound');
|
|
@@ -475,6 +482,13 @@ let richEditMessageText;
|
|
|
475
482
|
// classification, but it never throws: its caller holds the only copy of the
|
|
476
483
|
// reply and owns the plain fallback.
|
|
477
484
|
let richSendMessage;
|
|
485
|
+
// The rich half of the reply-tool delivery chain, hoisted so the interactive
|
|
486
|
+
// turn path can reuse the same instance rather than configure a second one.
|
|
487
|
+
// Two instances would be two capability latches, two styling verdicts and two
|
|
488
|
+
// sets of ceilings to keep in step; one is the only way they cannot drift.
|
|
489
|
+
// Null until main() wires it — callers fall back to plain, which is what a
|
|
490
|
+
// chat without rich gets anyway.
|
|
491
|
+
let makeRichDeliverText = null;
|
|
478
492
|
let richMediaFileIdCache;
|
|
479
493
|
|
|
480
494
|
// 0.15 secret redaction (agent-flagged path): the agent marks a secret it saw
|
|
@@ -1547,12 +1561,15 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1547
1561
|
// path — same contract for "not opted in", "capability known
|
|
1548
1562
|
// unsupported", and "content doesn't need it" (needsRichRendering
|
|
1549
1563
|
// inside toTelegramRichBlocks handles the last one).
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1564
|
+
// This callback covers the streamed bubble only. A turn that ends with
|
|
1565
|
+
// text instead of streaming it renders rich too, through the reply tool's
|
|
1566
|
+
// own factory reused at the non-streamed branch below — one gate, one
|
|
1567
|
+
// sender, one latch, so the same markdown cannot format two ways
|
|
1568
|
+
// depending on how the answer arrived.
|
|
1569
|
+
// Still plain: the non-streaming send paths — autonomous-wakeup/
|
|
1553
1570
|
// auto-resume/autosteer via lib/telegram/process-agent-reply.js, cron/
|
|
1554
|
-
// IPC sends
|
|
1555
|
-
//
|
|
1571
|
+
// IPC sends. Those render plain even when richText is on and the content
|
|
1572
|
+
// qualifies.
|
|
1556
1573
|
// Re-read both controls for every flush so a config change or a
|
|
1557
1574
|
// capability result applies to the turn already in progress.
|
|
1558
1575
|
// Typed media resolution for rich replies. Same
|
|
@@ -2024,6 +2041,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2024
2041
|
let sendPromise = null;
|
|
2025
2042
|
let codexDispatch = null;
|
|
2026
2043
|
let codexDispatchDecision = null;
|
|
2044
|
+
let acceptedAutosteerProvider = null;
|
|
2027
2045
|
try {
|
|
2028
2046
|
const current = pm.get(sessionKey);
|
|
2029
2047
|
const liveCodexGeneration = Boolean(
|
|
@@ -2070,6 +2088,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2070
2088
|
msg,
|
|
2071
2089
|
prompt,
|
|
2072
2090
|
});
|
|
2091
|
+
acceptedAutosteerProvider = 'codex';
|
|
2073
2092
|
if (steered.outcome === 'accepted') {
|
|
2074
2093
|
try {
|
|
2075
2094
|
codexRuntimeController.finalizeAcceptedSteer({
|
|
@@ -2196,12 +2215,23 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2196
2215
|
msg,
|
|
2197
2216
|
prompt,
|
|
2198
2217
|
});
|
|
2218
|
+
acceptedAutosteerProvider = 'claude';
|
|
2199
2219
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2220
|
+
steered = settleAcceptedAutosteerOwnership({
|
|
2221
|
+
selectedProvider: acceptedAutosteerProvider,
|
|
2222
|
+
steered,
|
|
2223
|
+
db,
|
|
2224
|
+
chatId,
|
|
2225
|
+
msgId: msg.message_id,
|
|
2226
|
+
sessionKey,
|
|
2227
|
+
logLabel: label,
|
|
2228
|
+
logEvent,
|
|
2229
|
+
logger: console,
|
|
2230
|
+
});
|
|
2231
|
+
if (shouldDispatchPrimaryAfterAutosteer({
|
|
2232
|
+
steered,
|
|
2233
|
+
codexDispatchDecision,
|
|
2234
|
+
})) {
|
|
2205
2235
|
if (codexDispatchDecision === 'queue') {
|
|
2206
2236
|
reactor.setState('THINKING');
|
|
2207
2237
|
}
|
|
@@ -2279,6 +2309,31 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2279
2309
|
}
|
|
2280
2310
|
return;
|
|
2281
2311
|
}
|
|
2312
|
+
if (steered.outcome === 'accepted-persistence-ambiguous') {
|
|
2313
|
+
stopTyping();
|
|
2314
|
+
await finishStreamer();
|
|
2315
|
+
await reactor.clear().catch(() => {});
|
|
2316
|
+
reactor.stop();
|
|
2317
|
+
await tg(bot, 'sendMessage', {
|
|
2318
|
+
chat_id: chatId,
|
|
2319
|
+
text: 'This follow-up may have been incorporated. Please wait for the current turn to finish before retrying it.',
|
|
2320
|
+
...replyOpts(threadId),
|
|
2321
|
+
}, {
|
|
2322
|
+
source: 'claude-autosteer-notice',
|
|
2323
|
+
botName: BOT_NAME,
|
|
2324
|
+
}).catch((error) => {
|
|
2325
|
+
console.error(
|
|
2326
|
+
`[${label}] failed to send Claude autosteer ambiguity notice: ${error.message}`,
|
|
2327
|
+
);
|
|
2328
|
+
logEvent('autosteer-ambiguity-notice-failed', {
|
|
2329
|
+
chat_id: chatId,
|
|
2330
|
+
msg_id: msg.message_id,
|
|
2331
|
+
session_key: sessionKey,
|
|
2332
|
+
code: error?.code || error?.name || 'unknown',
|
|
2333
|
+
});
|
|
2334
|
+
});
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2282
2337
|
if (['ambiguous', 'unavailable'].includes(codexDispatchDecision)) {
|
|
2283
2338
|
stopTyping();
|
|
2284
2339
|
await finishStreamer();
|
|
@@ -2862,27 +2917,78 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2862
2917
|
console.error(`[${label}] sendSticker failed: ${err.message}`);
|
|
2863
2918
|
});
|
|
2864
2919
|
} else if (parsed.text) {
|
|
2865
|
-
//
|
|
2866
|
-
//
|
|
2867
|
-
//
|
|
2868
|
-
//
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2920
|
+
// A turn that ends with text instead of calling the reply tool lands
|
|
2921
|
+
// here. It is the same agent writing the same markdown as the reply
|
|
2922
|
+
// tool, so it renders through the same gate and the same sender —
|
|
2923
|
+
// otherwise a table is a table or a wall of pipes depending only on
|
|
2924
|
+
// which way the answer happened to arrive, which is not a distinction
|
|
2925
|
+
// the reader can see or act on.
|
|
2926
|
+
//
|
|
2927
|
+
// Text-only by construction: no allowedRoots, so media stays degraded
|
|
2928
|
+
// to caption text exactly as it was here before, and this path opens no
|
|
2929
|
+
// upload surface of its own.
|
|
2930
|
+
// Wrapped even though the factory is contracted not to throw: that
|
|
2931
|
+
// contract is convention, and its styling-retry ladder evaluates
|
|
2932
|
+
// blocksAreStyled/flattenStyledBlocks outside any try. A throw escaping
|
|
2933
|
+
// here would skip the plain arm and cost the whole reply, so this catch
|
|
2934
|
+
// is the difference between a flat answer and no answer.
|
|
2935
|
+
let richOut = null;
|
|
2936
|
+
try {
|
|
2937
|
+
richOut = makeRichDeliverText
|
|
2938
|
+
? await makeRichDeliverText({ chatId, threadId })({
|
|
2939
|
+
text: parsed.text,
|
|
2940
|
+
replyToMessageId: msg.message_id,
|
|
2941
|
+
// Its own source, because rich-message-sent carries none and the
|
|
2942
|
+
// streamed rich open reports as bot-reply-stream-open-rich.
|
|
2943
|
+
// Without a distinct tag nothing downstream can tell this branch
|
|
2944
|
+
// from the other sites writing the bare streamer tag.
|
|
2945
|
+
meta: { ...outMeta, source: 'bot-reply-final-rich' },
|
|
2946
|
+
})
|
|
2947
|
+
: null;
|
|
2948
|
+
} catch (richErr) {
|
|
2949
|
+
// Named apart from the turn's own `err` on purpose: this is the
|
|
2950
|
+
// rich attempt failing, not the turn, and the completion tail is
|
|
2951
|
+
// located in tests by scanning for the turn handler's catch.
|
|
2952
|
+
console.error(`[${label}] rich delivery threw, falling back to plain: ${richErr.message}`);
|
|
2953
|
+
richOut = null;
|
|
2954
|
+
}
|
|
2955
|
+
if (richOut?.handled) {
|
|
2956
|
+
// One bubble, no chunks, nothing failed — but still report, because
|
|
2957
|
+
// this is the same incomplete-delivery gate the plain arm feeds and
|
|
2958
|
+
// it must hear from whichever arm ran.
|
|
2959
|
+
mediaContext.recordTextFailures(richOut.failed.length);
|
|
2960
|
+
} else {
|
|
2961
|
+
// 0.7.0: use markdown-aware chunker + deliverReplies primitive.
|
|
2962
|
+
// The old chunkText was newline/byte-only; chunkMarkdownText also
|
|
2963
|
+
// respects code-fence boundaries (closes + reopens across chunks).
|
|
2964
|
+
// Image markdown from a rich-enabled chat degrades to caption text
|
|
2965
|
+
// here too — never a raw local path in the bubble.
|
|
2966
|
+
//
|
|
2967
|
+
// The `??` arm is load-bearing: a chat with rich off declines with
|
|
2968
|
+
// no body at all, and chunking `undefined` would drop the reply
|
|
2969
|
+
// entirely for most of the fleet. Both arms strip through the same
|
|
2970
|
+
// call, so the delivered text is byte-identical either way.
|
|
2971
|
+
const shortText = richOut?.text ?? (resolveRichTextEnabled(config, chatId, threadId)
|
|
2972
|
+
? stripMediaMarkdown(parsed.text)
|
|
2973
|
+
: parsed.text);
|
|
2974
|
+
const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
|
|
2975
|
+
const deliveryResult = await deliverReplies({
|
|
2976
|
+
bot,
|
|
2977
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
2978
|
+
chatId,
|
|
2979
|
+
threadId,
|
|
2980
|
+
chunks,
|
|
2981
|
+
replyToMessageId: msg.message_id,
|
|
2982
|
+
// Tagged like its rich sibling. Five call sites write the bare
|
|
2983
|
+
// 'bot-reply-stream' tag and three of them deliver reply bodies,
|
|
2984
|
+
// so a row carrying it says nothing about which one produced it.
|
|
2985
|
+
// Naming both arms makes this branch identifiable from the
|
|
2986
|
+
// messages table whichever way the reply went out.
|
|
2987
|
+
meta: { ...outMeta, source: 'bot-reply-final' },
|
|
2988
|
+
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
2989
|
+
});
|
|
2990
|
+
mediaContext.recordTextFailures(deliveryResult.failed.length);
|
|
2991
|
+
}
|
|
2886
2992
|
}
|
|
2887
2993
|
|
|
2888
2994
|
// A turn whose final output was a solo sticker/reaction skips the
|
|
@@ -4017,6 +4123,38 @@ async function main() {
|
|
|
4017
4123
|
//
|
|
4018
4124
|
// Neither feature may switch the other off, which a single strategy slot
|
|
4019
4125
|
// could not express.
|
|
4126
|
+
// Built before the chain rather than inline in it, because the interactive
|
|
4127
|
+
// turn path reuses this exact instance for its non-streamed replies. See
|
|
4128
|
+
// the makeRichDeliverText declaration for why it must be one instance.
|
|
4129
|
+
// Injected rather than imported by the dispatcher: rich-media.js requires
|
|
4130
|
+
// the dispatcher module, so the reverse direction would be a require cycle.
|
|
4131
|
+
makeRichDeliverText = createRichDeliveryFactory({
|
|
4132
|
+
bot,
|
|
4133
|
+
sendRich: (args) => richSendMessage(args),
|
|
4134
|
+
isRichTextEnabled: (chatId, threadId) => resolveRichTextEnabled(config, chatId, threadId),
|
|
4135
|
+
getRichKnownUnsupported: () => richSendKnownUnsupported,
|
|
4136
|
+
// No config knob: the automatic flatten-and-retry below IS the control,
|
|
4137
|
+
// and it needs no operator to notice anything. A knob would add a state
|
|
4138
|
+
// where styling is off while the server accepts it, which nothing in
|
|
4139
|
+
// the system can detect or correct.
|
|
4140
|
+
isInlineStylingEnabled: () => !richInlineStylingUnsupported,
|
|
4141
|
+
onStylingRejected: () => richStylingLatch?.recordStylingRejection(),
|
|
4142
|
+
onStylingAccepted: () => richStylingLatch?.recordHealthyOutcome(),
|
|
4143
|
+
redactError: redactBotToken,
|
|
4144
|
+
// Media for one reply. The envelope itself (no URL media, the `files:`
|
|
4145
|
+
// fan-out ceiling, the shared stat) lives in rich-media.js so it is
|
|
4146
|
+
// pinned by behavior rather than by this literal — nothing executes
|
|
4147
|
+
// this file. `allowedRoots` arrives per call from the dispatcher, which
|
|
4148
|
+
// computed it once and validates `files:` against that very array, so a
|
|
4149
|
+
// path the reply tool may upload is exactly a path image syntax may
|
|
4150
|
+
// upload. The interactive path passes none, so it renders text-only.
|
|
4151
|
+
makeMediaWiring: makeReplyMediaWiring({
|
|
4152
|
+
config,
|
|
4153
|
+
fileIdCache: richMediaFileIdCache,
|
|
4154
|
+
logEvent,
|
|
4155
|
+
botName: BOT_NAME,
|
|
4156
|
+
}),
|
|
4157
|
+
});
|
|
4020
4158
|
const makeDeliverText = composeDeliverTextFactories([
|
|
4021
4159
|
createDeliverTextFactory({
|
|
4022
4160
|
registry: streamerRegistry,
|
|
@@ -4034,35 +4172,7 @@ async function main() {
|
|
|
4034
4172
|
resolveRichTextEnabled(config, chatId, threadId) ? stripMediaMarkdown(text) : text
|
|
4035
4173
|
),
|
|
4036
4174
|
}),
|
|
4037
|
-
|
|
4038
|
-
// the dispatcher module, so the reverse direction would be a require cycle.
|
|
4039
|
-
createRichDeliveryFactory({
|
|
4040
|
-
bot,
|
|
4041
|
-
sendRich: (args) => richSendMessage(args),
|
|
4042
|
-
isRichTextEnabled: (chatId, threadId) => resolveRichTextEnabled(config, chatId, threadId),
|
|
4043
|
-
getRichKnownUnsupported: () => richSendKnownUnsupported,
|
|
4044
|
-
// No config knob: the automatic flatten-and-retry below IS the control,
|
|
4045
|
-
// and it needs no operator to notice anything. A knob would add a state
|
|
4046
|
-
// where styling is off while the server accepts it, which nothing in
|
|
4047
|
-
// the system can detect or correct.
|
|
4048
|
-
isInlineStylingEnabled: () => !richInlineStylingUnsupported,
|
|
4049
|
-
onStylingRejected: () => richStylingLatch?.recordStylingRejection(),
|
|
4050
|
-
onStylingAccepted: () => richStylingLatch?.recordHealthyOutcome(),
|
|
4051
|
-
redactError: redactBotToken,
|
|
4052
|
-
// Media for one reply. The envelope itself (no URL media, the `files:`
|
|
4053
|
-
// fan-out ceiling, the shared stat) lives in rich-media.js so it is
|
|
4054
|
-
// pinned by behavior rather than by this literal — nothing executes
|
|
4055
|
-
// this file. `allowedRoots` arrives per call from the dispatcher, which
|
|
4056
|
-
// computed it once and validates `files:` against that very array, so a
|
|
4057
|
-
// path the reply tool may upload is exactly a path image syntax may
|
|
4058
|
-
// upload.
|
|
4059
|
-
makeMediaWiring: makeReplyMediaWiring({
|
|
4060
|
-
config,
|
|
4061
|
-
fileIdCache: richMediaFileIdCache,
|
|
4062
|
-
logEvent,
|
|
4063
|
-
botName: BOT_NAME,
|
|
4064
|
-
}),
|
|
4065
|
-
}),
|
|
4175
|
+
makeRichDeliverText,
|
|
4066
4176
|
]);
|
|
4067
4177
|
|
|
4068
4178
|
// The edit tool's counterpart to the reply chain above. An agent that posts
|
|
@@ -4419,7 +4529,11 @@ async function main() {
|
|
|
4419
4529
|
// replay picks it up. Prevents "Sorry, I couldn't process that message"
|
|
4420
4530
|
// from showing on every restart.
|
|
4421
4531
|
const SHUTDOWN_DRAIN_MS = 30_000;
|
|
4422
|
-
const shutdown = async ({
|
|
4532
|
+
const shutdown = async ({
|
|
4533
|
+
continuationAuthorized = false,
|
|
4534
|
+
trigger = 'signal',
|
|
4535
|
+
restartRequestId = null,
|
|
4536
|
+
} = {}) => {
|
|
4423
4537
|
if (isShuttingDown) return;
|
|
4424
4538
|
const shutdownObservation = cgroupOomObserver.sample();
|
|
4425
4539
|
// Sample in-flight work at SIGNAL time, alongside the OOM observation and
|
|
@@ -4611,6 +4725,12 @@ async function main() {
|
|
|
4611
4725
|
elapsed_ms: drainElapsed,
|
|
4612
4726
|
clean: res.clean,
|
|
4613
4727
|
shutdown_reason: res.shutdownReason,
|
|
4728
|
+
restart_trigger: trigger,
|
|
4729
|
+
continuation_authorized: continuationAuthorized === true,
|
|
4730
|
+
resume_intents_recorded: res.intentsRecorded ?? 0,
|
|
4731
|
+
restart_request_id: trigger === 'deploy-ipc'
|
|
4732
|
+
? restartRequestId
|
|
4733
|
+
: null,
|
|
4614
4734
|
...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
|
|
4615
4735
|
}, invocationId));
|
|
4616
4736
|
console.log(`[shutdown] ${res.clean ? 'clean' : 'crash-like'} shutdown recorded (${res.shutdownReason}); ${inFlightAtSignal} in-flight at signal, drained ${drainElapsed}ms, ${remaining} still in-flight, ${res.replayMarked} marked replay-pending`);
|
|
@@ -4684,16 +4804,18 @@ async function main() {
|
|
|
4684
4804
|
botName: BOT_NAME,
|
|
4685
4805
|
getInFlightHandlers: () => inFlightHandlers,
|
|
4686
4806
|
handleSendOverIpc: (req) => handleSendOverIpc(req),
|
|
4687
|
-
requestDeployRestart: () => {
|
|
4807
|
+
requestDeployRestart: (req) => {
|
|
4688
4808
|
if (isShuttingDown) {
|
|
4689
4809
|
const error = new Error('shutdown already in progress');
|
|
4690
4810
|
error.code = 'SHUTDOWN_IN_PROGRESS';
|
|
4691
4811
|
throw error;
|
|
4692
4812
|
}
|
|
4813
|
+
const restartRequestId = requireRestartRequestId(req.id);
|
|
4693
4814
|
const oldPid = process.pid;
|
|
4694
4815
|
shutdown({
|
|
4695
4816
|
continuationAuthorized: true,
|
|
4696
4817
|
trigger: 'deploy-ipc',
|
|
4818
|
+
restartRequestId,
|
|
4697
4819
|
}).catch((error) => {
|
|
4698
4820
|
console.error(`[shutdown] deploy restart failed: ${error.message}`);
|
|
4699
4821
|
});
|
package/scripts/ipc-smoke.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Quick IPC round-trip probe.
|
|
4
|
-
* Usage: node scripts/ipc-smoke.js <bot-name> [busy|restart]
|
|
4
|
+
* Usage: node scripts/ipc-smoke.js <bot-name> [busy|restart] [restart-request-id]
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const crypto = require('node:crypto');
|
|
7
8
|
const { call, readSecret, socketPathFor } = require('../lib/ipc/client');
|
|
9
|
+
const {
|
|
10
|
+
requireRestartRequestId,
|
|
11
|
+
} = require('../lib/ipc/restart-request-id');
|
|
12
|
+
|
|
13
|
+
function restartRequestId(value) {
|
|
14
|
+
return requireRestartRequestId(value || crypto.randomUUID());
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
(async () => {
|
|
10
18
|
const bot = process.argv[2] || 'shumabit';
|
|
@@ -33,15 +41,18 @@ const { call, readSecret, socketPathFor } = require('../lib/ipc/client');
|
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
if (command === 'restart') {
|
|
44
|
+
const requestId = restartRequestId(process.argv[4]);
|
|
36
45
|
const result = await call({
|
|
37
46
|
path,
|
|
38
47
|
op: 'deploy_restart',
|
|
48
|
+
id: requestId,
|
|
39
49
|
secret: readSecret(bot),
|
|
40
50
|
callTimeoutMs: 60_000,
|
|
41
51
|
});
|
|
42
52
|
if (
|
|
43
53
|
result.ok !== true
|
|
44
54
|
|| result.accepted !== true
|
|
55
|
+
|| result.id !== requestId
|
|
45
56
|
|| !Number.isSafeInteger(result.old_pid)
|
|
46
57
|
|| result.old_pid <= 1
|
|
47
58
|
) {
|
|
@@ -51,6 +62,7 @@ const { call, readSecret, socketPathFor } = require('../lib/ipc/client');
|
|
|
51
62
|
bot,
|
|
52
63
|
accepted: true,
|
|
53
64
|
old_pid: result.old_pid,
|
|
65
|
+
restart_request_id: requestId,
|
|
54
66
|
}));
|
|
55
67
|
return;
|
|
56
68
|
}
|