polygram 0.10.0-rc.2 → 0.10.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/.claude-plugin/plugin.json +1 -1
- package/lib/autosteered-refs.js +20 -2
- package/lib/claude-bin.js +78 -0
- package/lib/db/sessions.js +97 -1
- package/lib/handlers/autosteer.js +6 -0
- package/lib/process/tmux-process.js +967 -216
- package/lib/process-manager.js +56 -2
- package/lib/sdk/callbacks.js +219 -0
- package/lib/tmux/session-log-parser.js +302 -61
- package/lib/tmux/tmux-runner.js +59 -8
- package/package.json +1 -1
- package/polygram.js +150 -29
package/polygram.js
CHANGED
|
@@ -24,7 +24,9 @@ const fs = require('fs');
|
|
|
24
24
|
const path = require('path');
|
|
25
25
|
const processGuard = require('./lib/process-guard');
|
|
26
26
|
const dbClient = require('./lib/db');
|
|
27
|
-
const {
|
|
27
|
+
const {
|
|
28
|
+
migrateJsonToDb, getClaudeSessionId, resolveSessionForSpawn,
|
|
29
|
+
} = require('./lib/db/sessions');
|
|
28
30
|
const { buildPrompt } = require('./lib/prompt');
|
|
29
31
|
const { filterAttachments } = require('./lib/attachments');
|
|
30
32
|
// 0.9.0: SDK ProcessManager is the only pm. CLI pm
|
|
@@ -38,7 +40,7 @@ const { filterAttachments } = require('./lib/attachments');
|
|
|
38
40
|
// per-session mechanics. The pre-0.10.0 monolithic ProcessManagerSdk
|
|
39
41
|
// is deleted; SdkProcess inherits its per-entry guts.
|
|
40
42
|
const { ProcessManager } = require('./lib/process-manager');
|
|
41
|
-
const { createProcessFactory } = require('./lib/process/factory');
|
|
43
|
+
const { createProcessFactory, pickBackend } = require('./lib/process/factory');
|
|
42
44
|
const { extractAssistantText } = require('./lib/process/sdk-process');
|
|
43
45
|
const { createTmuxRunner } = require('./lib/tmux/tmux-runner');
|
|
44
46
|
const { sweepTmuxOrphans } = require('./lib/tmux/orphan-sweep');
|
|
@@ -396,12 +398,56 @@ function buildSpawnContext(sessionKey) {
|
|
|
396
398
|
const chatConfig = config.chats[chatId];
|
|
397
399
|
if (!chatConfig) return null;
|
|
398
400
|
const threadId = sessionKey.includes(':') ? sessionKey.split(':')[1] : null;
|
|
401
|
+
|
|
402
|
+
// S2: a stored session is valid ONLY for the config it was spawned
|
|
403
|
+
// under. agent / cwd / pm_backend are spawn-identity — baked into
|
|
404
|
+
// the process at spawn time, never mutable on a live session.
|
|
405
|
+
// Resolve them the same way the backends do (topic override merged
|
|
406
|
+
// over chat-level) and compare to the stored `sessions` row. On
|
|
407
|
+
// drift, resolveSessionForSpawn drops the stale row and returns
|
|
408
|
+
// existingSessionId:null → the spawn starts fresh under the correct
|
|
409
|
+
// config instead of `--resume`-ing a stale one. This self-heals the
|
|
410
|
+
// pre-per-topic-config rows (e.g. shumorobot's Music topic :3,
|
|
411
|
+
// stored agent=shumabit / cwd=$HOME / sdk vs the current
|
|
412
|
+
// music-curation:music-curator / .../Music/rekordbox / tmux).
|
|
413
|
+
// model/effort are NOT compared — they apply live via setModel /
|
|
414
|
+
// applyFlagSettings with no respawn.
|
|
415
|
+
//
|
|
416
|
+
// The drift check runs only at COLD spawn (no warm process). A warm
|
|
417
|
+
// process already runs under its spawn-time config; getOrSpawn
|
|
418
|
+
// returns it without using this context, so dropping its row here
|
|
419
|
+
// would be premature — defer to the next cold spawn.
|
|
420
|
+
const isColdSpawn = !pm || !pm.has(sessionKey) || pm.get(sessionKey)?.closed;
|
|
421
|
+
let existingSessionId;
|
|
422
|
+
if (isColdSpawn) {
|
|
423
|
+
const topicConfig = getTopicConfig(chatConfig, threadId || null);
|
|
424
|
+
const resolved = {
|
|
425
|
+
agent: topicConfig.agent || chatConfig.agent || null,
|
|
426
|
+
cwd: topicConfig.cwd || chatConfig.cwd || null,
|
|
427
|
+
backend: pickBackend({ config, chatId, threadId: threadId || null }),
|
|
428
|
+
};
|
|
429
|
+
const r = resolveSessionForSpawn(db, sessionKey, resolved);
|
|
430
|
+
existingSessionId = r.existingSessionId;
|
|
431
|
+
if (r.drift) {
|
|
432
|
+
logEvent('session-config-drift', {
|
|
433
|
+
chat_id: chatId,
|
|
434
|
+
thread_id: threadId || null,
|
|
435
|
+
session_key: sessionKey,
|
|
436
|
+
fields: r.drift.fields,
|
|
437
|
+
before: r.drift.before,
|
|
438
|
+
after: r.drift.after,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
existingSessionId = getClaudeSessionId(db, sessionKey);
|
|
443
|
+
}
|
|
444
|
+
|
|
399
445
|
return {
|
|
400
446
|
chatConfig,
|
|
401
447
|
chatId,
|
|
402
448
|
threadId: threadId || null,
|
|
403
449
|
label: getSessionLabel(chatConfig, threadId),
|
|
404
|
-
existingSessionId
|
|
450
|
+
existingSessionId,
|
|
405
451
|
};
|
|
406
452
|
}
|
|
407
453
|
|
|
@@ -411,7 +457,7 @@ async function getOrSpawnForChat(sessionKey) {
|
|
|
411
457
|
return pm.getOrSpawn(sessionKey, ctx);
|
|
412
458
|
}
|
|
413
459
|
|
|
414
|
-
async function sendToProcess(sessionKey, prompt, context = {}) {
|
|
460
|
+
async function sendToProcess(sessionKey, prompt, context = {}, { onDispatched } = {}) {
|
|
415
461
|
const entry = await getOrSpawnForChat(sessionKey);
|
|
416
462
|
if (!entry) throw new Error('No process for chat');
|
|
417
463
|
const chatId = getChatIdFromKey(sessionKey);
|
|
@@ -437,7 +483,13 @@ async function sendToProcess(sessionKey, prompt, context = {}) {
|
|
|
437
483
|
// starts, which is the correct UX (and what the user already expects).
|
|
438
484
|
const release = await stdinLock.acquire(sessionKey);
|
|
439
485
|
try {
|
|
440
|
-
|
|
486
|
+
const turnP = pm.send(sessionKey, prompt, { timeoutMs, maxTurnMs, context });
|
|
487
|
+
// Phase 3 §4: pm.send synchronously kicks off the turn — the
|
|
488
|
+
// process is now inFlight. Signal the committed-intent latch so
|
|
489
|
+
// it can release; a concurrent handler will then correctly see
|
|
490
|
+
// the live turn and autosteer instead of racing into a 2nd send.
|
|
491
|
+
if (typeof onDispatched === 'function') onDispatched();
|
|
492
|
+
return await turnP;
|
|
441
493
|
} finally {
|
|
442
494
|
release();
|
|
443
495
|
}
|
|
@@ -481,6 +533,15 @@ let inFlightHandlers = null;
|
|
|
481
533
|
// Per-session lock ordering stdin writes. Module is I/O-pure.
|
|
482
534
|
const stdinLock = createAsyncLock();
|
|
483
535
|
|
|
536
|
+
// 0.10.0 Phase 3 §4: committed-intent latch. Serialises the
|
|
537
|
+
// autosteer-vs-primary decision per session so a burst of concurrent
|
|
538
|
+
// handleMessage calls cannot each independently mis-read `inFlight`
|
|
539
|
+
// and all classify themselves as primary. The first to acquire it
|
|
540
|
+
// for an idle session commits the primary turn and holds the latch
|
|
541
|
+
// until the process is inFlight; later acquirers see the live turn
|
|
542
|
+
// and autosteer.
|
|
543
|
+
const intentLock = createAsyncLock();
|
|
544
|
+
|
|
484
545
|
// Typing indicator is imported from lib/typing-indicator — it adds a
|
|
485
546
|
// per-chat circuit breaker with exponential backoff so a chat that
|
|
486
547
|
// permanently 401s (bot blocked, chat deleted) doesn't have us
|
|
@@ -971,9 +1032,39 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
971
1032
|
// standard emoji per core.telegram.org/bots/api#availablereactions).
|
|
972
1033
|
// 🛞 is NOT on it (400: REACTION_INVALID). ✍ ("writing/noting")
|
|
973
1034
|
// is on the list and conveys "incorporating this".
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1035
|
+
// 0.10.0 Phase 3 §4: committed-intent latch. The autosteer-vs-
|
|
1036
|
+
// primary decision AND the turn dispatch happen inside one
|
|
1037
|
+
// per-session critical section. tryAutosteer's `inFlight` read is
|
|
1038
|
+
// now reliable: the previous primary held this latch until its
|
|
1039
|
+
// pm.send made the process inFlight, so a concurrent burst can no
|
|
1040
|
+
// longer mis-classify followups as primary turns.
|
|
1041
|
+
const releaseIntent = await intentLock.acquire(sessionKey);
|
|
1042
|
+
let steered = { autosteered: false };
|
|
1043
|
+
let sendPromise = null;
|
|
1044
|
+
try {
|
|
1045
|
+
steered = autosteer.tryAutosteer({ sessionKey, chatConfig, chatId, msg, prompt });
|
|
1046
|
+
if (!steered.autosteered) {
|
|
1047
|
+
// Primary turn. Kick off the dispatch and hold the latch until
|
|
1048
|
+
// pm.send has made the process inFlight (onDispatched). The
|
|
1049
|
+
// turn RESULT is awaited only AFTER the latch is released — the
|
|
1050
|
+
// latch covers the decision + commitment, never the whole turn
|
|
1051
|
+
// (that would block every autosteer).
|
|
1052
|
+
// Pass streamer + reactor as per-turn context; pm's callbacks
|
|
1053
|
+
// pick them off entry.pendingQueue[0].context.
|
|
1054
|
+
await new Promise((dispatched) => {
|
|
1055
|
+
sendPromise = sendToProcess(sessionKey, prompt, {
|
|
1056
|
+
streamer, reactor, sourceMsgId: msg.message_id,
|
|
1057
|
+
// 0.7.4 (item B): fire THINKING when Claude actually starts
|
|
1058
|
+
// emitting — not the moment we wrote stdin.
|
|
1059
|
+
onFirstStream: () => reactor.setState('THINKING'),
|
|
1060
|
+
}, { onDispatched: dispatched })
|
|
1061
|
+
.catch((e) => ({ __sendError: e }))
|
|
1062
|
+
.finally(dispatched);
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
} finally {
|
|
1066
|
+
releaseIntent();
|
|
1067
|
+
}
|
|
977
1068
|
if (steered.autosteered) {
|
|
978
1069
|
stopTyping();
|
|
979
1070
|
// setState('AUTOSTEERED') is terminal — bypasses throttle,
|
|
@@ -987,18 +1078,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
987
1078
|
}
|
|
988
1079
|
|
|
989
1080
|
try {
|
|
990
|
-
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
streamer, reactor, sourceMsgId: msg.message_id,
|
|
995
|
-
// 0.7.4 (item B): fire THINKING when Claude actually starts
|
|
996
|
-
// emitting (first assistant text or tool_use). Pre-fix, onActivate
|
|
997
|
-
// (queue-head transition) flipped to THINKING the moment we wrote
|
|
998
|
-
// stdin, even though Claude could spend hundreds of ms loading.
|
|
999
|
-
// Result: long flat 🤔 with nothing happening; users assumed stall.
|
|
1000
|
-
onFirstStream: () => reactor.setState('THINKING'),
|
|
1001
|
-
});
|
|
1081
|
+
const result = await sendPromise;
|
|
1082
|
+
// sendToProcess failures are captured (not thrown) so the latch
|
|
1083
|
+
// always releases; re-throw here into the existing handler.
|
|
1084
|
+
if (result && result.__sendError) throw result.__sendError;
|
|
1002
1085
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
1003
1086
|
|
|
1004
1087
|
// 0.7.6 (item F): persist per-turn telemetry. Stream-json result
|
|
@@ -1083,15 +1166,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1083
1166
|
// (→ 'failed', user gets an apology with retry hint).
|
|
1084
1167
|
if (!result.text) throw new Error(result.error);
|
|
1085
1168
|
} else {
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
|
|
1169
|
+
// rc.10: reactor.clear() and clearAutosteeredReactions() moved
|
|
1170
|
+
// to AFTER deliverReplies completes (see just before
|
|
1171
|
+
// markReplied() below). Pre-rc.10 they fired the moment pm.send
|
|
1172
|
+
// returned (JSONL result event), which was ~1-3s BEFORE the
|
|
1173
|
+
// Telegram reply actually landed via the streamer / chunked
|
|
1174
|
+
// delivery path. User saw: 🤔/✍ visible → reactions cleared →
|
|
1175
|
+
// ~1-3s of nothing → reply bubble lands. Ivan caught this on
|
|
1176
|
+
// shumorobot 2026-05-15 ("both reactions disappeared, typing
|
|
1177
|
+
// disappeared, at some point he responded"). Deferring the
|
|
1178
|
+
// clears closes the visual gap.
|
|
1095
1179
|
// rc.42: tool-less-turn stale-drain DELETED. With native priority
|
|
1096
1180
|
// push, the SDK's input controller has the followups directly —
|
|
1097
1181
|
// there's no buffer for us to drain. Tool-less turns just emit
|
|
@@ -1385,6 +1469,23 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1385
1469
|
|
|
1386
1470
|
await sendInlineStickers();
|
|
1387
1471
|
await sendInlineReactions();
|
|
1472
|
+
// rc.10: clear progress reactions AFTER the reply has been
|
|
1473
|
+
// delivered so the user doesn't see a "reactions cleared, then
|
|
1474
|
+
// ~1-3s of nothing, then reply bubble" gap. The reply bubble
|
|
1475
|
+
// itself is the "done" signal; clearing the emoji simultaneously
|
|
1476
|
+
// with the delivery completion is the smooth UX path. Both
|
|
1477
|
+
// fire-and-forget — these are best-effort cleanups, not part of
|
|
1478
|
+
// the reply contract.
|
|
1479
|
+
reactor.clear().catch(() => {});
|
|
1480
|
+
// 0.8.0-rc.14: also clear ✍ reactions on every follow-up
|
|
1481
|
+
// message that was autosteered into THIS turn — they live in
|
|
1482
|
+
// separate handleMessage scopes whose reactors are already GC'd.
|
|
1483
|
+
// rc.9 caveat: TmuxProcess.extra-turn-started re-applies ✍ if
|
|
1484
|
+
// there's a pending autosteer dequeue happening (NEW-TURN case),
|
|
1485
|
+
// and extra-turn-reply clears it again when the second reply
|
|
1486
|
+
// lands. So the FOLD path benefits from this deferred clear
|
|
1487
|
+
// without breaking NEW-TURN.
|
|
1488
|
+
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
1388
1489
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1389
1490
|
markReplied();
|
|
1390
1491
|
} catch (err) {
|
|
@@ -2027,6 +2128,26 @@ async function main() {
|
|
|
2027
2128
|
// instances. Construction is cheap (no system call until first
|
|
2028
2129
|
// spawn/send). Only used if any chat in config has pm:'tmux'.
|
|
2029
2130
|
const tmuxRunner = createTmuxRunner({ logger: console });
|
|
2131
|
+
// Verify the pinned claude CLI binary is present. The tmux
|
|
2132
|
+
// backend spawns this exact binary by absolute path (see
|
|
2133
|
+
// lib/claude-bin.js + TmuxProcess.start) — it never resolves
|
|
2134
|
+
// `claude` through $PATH, so the CLI auto-updater can't drift
|
|
2135
|
+
// it. This boot check is informational: it tells the operator
|
|
2136
|
+
// up-front which binary the tmux backend will use, and warns
|
|
2137
|
+
// (non-fatal — SDK-backed chats don't need it) if it's missing.
|
|
2138
|
+
// A missing binary still hard-fails per-chat at TmuxProcess.start.
|
|
2139
|
+
{
|
|
2140
|
+
const { CLAUDE_CLI_PINNED_VERSION } = require('./lib/process/tmux-process');
|
|
2141
|
+
const { verifyPinnedClaudeBin } = require('./lib/claude-bin');
|
|
2142
|
+
const binCheck = verifyPinnedClaudeBin(CLAUDE_CLI_PINNED_VERSION);
|
|
2143
|
+
if (binCheck.ok) {
|
|
2144
|
+
console.log(
|
|
2145
|
+
`[polygram] tmux backend pinned to claude CLI v${CLAUDE_CLI_PINNED_VERSION}: ${binCheck.path}`,
|
|
2146
|
+
);
|
|
2147
|
+
} else {
|
|
2148
|
+
console.warn(`[polygram] WARNING: ${binCheck.reason}`);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2030
2151
|
// O1 optimization: shared poll-tick scheduler. N TmuxProcess
|
|
2031
2152
|
// instances share ONE setInterval instead of spawning N independent
|
|
2032
2153
|
// setTimeout chains. Idle when no chats are in flight (zero timers
|