polygram 0.8.0 → 0.9.0-rc.2
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/{agent-loader.js → agents/loader.js} +6 -8
- package/lib/{approvals.js → approvals/store.js} +28 -5
- package/lib/{approval-ui.js → approvals/ui.js} +1 -17
- package/lib/config.js +121 -0
- package/lib/{error-classify.js → error/classify.js} +25 -34
- package/lib/handlers/abort.js +89 -0
- package/lib/handlers/approvals.js +361 -0
- package/lib/handlers/autosteer.js +94 -0
- package/lib/handlers/config-callback.js +118 -0
- package/lib/handlers/config-ui.js +104 -0
- package/lib/handlers/dispatcher.js +263 -0
- package/lib/handlers/download.js +182 -0
- package/lib/handlers/extract-attachments.js +97 -0
- package/lib/handlers/ipc-send.js +80 -0
- package/lib/handlers/poll.js +140 -0
- package/lib/handlers/record-inbound.js +88 -0
- package/lib/handlers/slash-commands.js +319 -0
- package/lib/handlers/voice.js +107 -0
- package/lib/pm-interface.js +27 -29
- package/lib/sdk/build-options.js +177 -0
- package/lib/sdk/callbacks.js +213 -0
- package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
- package/lib/{telegram.js → telegram/api.js} +2 -2
- package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
- package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
- package/package.json +2 -3
- package/polygram.js +347 -2581
- package/scripts/doctor.js +1 -1
- package/scripts/ipc-smoke.js +1 -10
- package/bin/approval-hook.js +0 -113
- package/lib/approval-waiters.js +0 -201
- package/lib/pm-router.js +0 -201
- package/lib/process-manager.js +0 -806
- /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
- /package/lib/{inbox.js → db/inbox.js} +0 -0
- /package/lib/{pairings.js → db/pairings.js} +0 -0
- /package/lib/{replay-window.js → db/replay-window.js} +0 -0
- /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
- /package/lib/{sessions.js → db/sessions.js} +0 -0
- /package/lib/{net-errors.js → error/net.js} +0 -0
- /package/lib/{ipc-client.js → ipc/client.js} +0 -0
- /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
- /package/lib/{ipc-server.js → ipc/server.js} +0 -0
- /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
- /package/lib/{deliver.js → telegram/deliver.js} +0 -0
- /package/lib/{telegram-format.js → telegram/format.js} +0 -0
- /package/lib/{parse-response.js → telegram/parse.js} +0 -0
- /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
- /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
- /package/lib/{voice.js → telegram/voice.js} +0 -0
package/lib/pm-interface.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Canonical Pm interface (JSDoc typedef).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Implemented by `lib/sdk/process-manager.js` (the only pm impl
|
|
5
|
+
* post-0.9.0). `lib/sdk/router.js`'s `createPmRouter()` wraps the
|
|
6
|
+
* pm and is currently an identity passthrough — kept as a forward-
|
|
7
|
+
* compat seam for future alternate pm impls (a pi-agent-core
|
|
8
|
+
* adapter, a synthetic test pm). When that lands, the router
|
|
9
|
+
* becomes the per-chat dispatch layer again; this interface stays
|
|
10
|
+
* the contract.
|
|
7
11
|
*
|
|
8
|
-
* Optional methods are marked
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* Optional methods are marked `?`. SDK pm currently exposes ALL
|
|
13
|
+
* of them, so post-0.9.0 polygram.js can call them directly
|
|
14
|
+
* without `typeof === 'function'` guards. Future alternate impls
|
|
15
|
+
* may opt out of an optional method; if they do, callers will
|
|
16
|
+
* need to feature-detect at the call site.
|
|
13
17
|
*
|
|
14
18
|
* @typedef {object} PmEntry
|
|
15
|
-
* The shape pm.get(sessionKey) returns.
|
|
16
|
-
* with
|
|
19
|
+
* The shape pm.get(sessionKey) returns. The pm impl decorates it
|
|
20
|
+
* with its own internal fields; only the documented fields below
|
|
17
21
|
* are part of the public contract.
|
|
18
22
|
* @property {string} sessionKey
|
|
19
23
|
* @property {string|null} chatId
|
|
@@ -45,16 +49,15 @@
|
|
|
45
49
|
*
|
|
46
50
|
* @typedef {object} PmSpawnContext
|
|
47
51
|
* What polygram passes to spawnFn(sessionKey, ctx). Internal to
|
|
48
|
-
*
|
|
52
|
+
* the pm but documented here so callers know what's available.
|
|
49
53
|
* @property {string|null} chatId
|
|
50
54
|
* @property {string|null} threadId
|
|
51
55
|
* @property {string} label
|
|
52
56
|
*
|
|
53
57
|
* @typedef {object} Pm
|
|
54
|
-
* The unified ProcessManager interface.
|
|
55
|
-
* implement these; the router forwards.
|
|
58
|
+
* The unified ProcessManager interface.
|
|
56
59
|
*
|
|
57
|
-
* Required
|
|
60
|
+
* Required:
|
|
58
61
|
* @property {(sessionKey: string) => boolean} has
|
|
59
62
|
* @property {(sessionKey: string) => PmEntry|null} get
|
|
60
63
|
* @property {(sessionKey: string, ctx: PmSpawnContext) => Promise<PmEntry>} getOrSpawn
|
|
@@ -65,30 +68,25 @@
|
|
|
65
68
|
* @property {() => Promise<void>} shutdown
|
|
66
69
|
* — graceful daemon-wide drain + close.
|
|
67
70
|
*
|
|
68
|
-
* Optional (
|
|
71
|
+
* Optional (SDK pm exposes all of these; future alt impls may not):
|
|
69
72
|
* @property {((sessionKey: string, text: string, opts?: object) => boolean)=} steer
|
|
70
|
-
* —
|
|
73
|
+
* — priority='now' direct push, opt-in shouldQuery.
|
|
71
74
|
* @property {((sessionKey: string, opts: {content: string, priority?: 'now'|'next'|'later', shouldQuery?: boolean, parent_tool_use_id?: string|null}) => boolean)=} injectUserMessage
|
|
72
|
-
* —
|
|
73
|
-
*
|
|
74
|
-
* surface) or when sessionKey not found.
|
|
75
|
+
* — native autosteer / queue via SDKUserMessage priority hint.
|
|
76
|
+
* Returns false when sessionKey not found.
|
|
75
77
|
* @property {((sessionKey: string, model: string) => Promise<boolean>)=} setModel
|
|
76
|
-
* —
|
|
78
|
+
* — Query.setModel live (no respawn).
|
|
77
79
|
* @property {((sessionKey: string, settings: {effortLevel?: string}) => Promise<boolean>)=} applyFlagSettings
|
|
78
|
-
* —
|
|
80
|
+
* — Query.applyFlagSettings live (no respawn).
|
|
79
81
|
* @property {((sessionKey: string, mode: string) => Promise<boolean>)=} setPermissionMode
|
|
80
|
-
* — SDK pm only.
|
|
81
|
-
* @property {((sessionKey: string, reason?: string) => {killed: boolean, queued: number})=} requestRespawn
|
|
82
|
-
* — CLI pm only (drain pending queue then kill; respawn on next send).
|
|
83
82
|
* @property {((sessionKey: string, errCode: string) => number)=} drainQueue
|
|
84
|
-
* —
|
|
83
|
+
* — reject all queued pendings with errCode.
|
|
85
84
|
* @property {((sessionKey: string) => Promise<void>)=} interrupt
|
|
86
|
-
* —
|
|
85
|
+
* — Query.interrupt (non-destructive — preserves session for resume).
|
|
87
86
|
* @property {((sessionKey: string, opts?: {reason?: string}) => Promise<{closed: boolean, drainedPendings: number}>)=} resetSession
|
|
88
|
-
* —
|
|
87
|
+
* — close Query + clear sessionId from DB.
|
|
89
88
|
*
|
|
90
|
-
* Lifecycle introspection (for tests / debugging
|
|
91
|
-
* to be present, but both current pms expose them):
|
|
89
|
+
* Lifecycle introspection (for tests / debugging):
|
|
92
90
|
* @property {() => string[]=} keys — sessionKey list
|
|
93
91
|
* @property {() => number=} size — number of live sessions
|
|
94
92
|
*/
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory for the SDK pm's spawn `Options` builder.
|
|
3
|
+
*
|
|
4
|
+
* polygram.js wires this at boot via createBuildSdkOptions(deps);
|
|
5
|
+
* the returned function is what gets passed to ProcessManagerSdk
|
|
6
|
+
* as `spawnFn`. Per-call, it composes the SdkOptions object the SDK
|
|
7
|
+
* needs: model + effort + cwd + permissionMode +
|
|
8
|
+
* canUseTool wiring + agent overlay + per-topic overrides + env
|
|
9
|
+
* shadow + appended display hint.
|
|
10
|
+
*
|
|
11
|
+
* Why a factory instead of a top-level function: buildSdkOptions
|
|
12
|
+
* needs polygram-runtime context (config, botName, childHome,
|
|
13
|
+
* makeCanUseTool, logEvent, agentLoader). Passing them via
|
|
14
|
+
* factory closure keeps the per-call signature `(sessionKey, ctx)`
|
|
15
|
+
* — the shape pm-sdk's spawnFn contract requires.
|
|
16
|
+
*
|
|
17
|
+
* Per v4 plan §6.5.7 — explicit env enumeration (Options.env is
|
|
18
|
+
* SHADOW per Phase 0 gate 33), bypassPermissions +
|
|
19
|
+
* allowDangerouslySkipPermissions both set for forward-compat,
|
|
20
|
+
* agent-loader composes per-chat agent into systemPrompt + skills +
|
|
21
|
+
* mcpServers, optional resume sessionId for continuity.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const agentLoader = require('../agents/loader');
|
|
27
|
+
const { getTopicConfig } = require('../session-key');
|
|
28
|
+
const { appendDisplayHint } = require('../telegram/display-hint');
|
|
29
|
+
|
|
30
|
+
// Env: SHADOW semantics — must enumerate every var the spawned
|
|
31
|
+
// worker is allowed to see. Anything else is dropped.
|
|
32
|
+
const CHILD_ENV_ALLOWLIST = new Set([
|
|
33
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'COLORTERM',
|
|
34
|
+
'TMPDIR', 'TMP', 'TEMP', 'TZ', 'LANG', 'PWD', 'SHLVL',
|
|
35
|
+
]);
|
|
36
|
+
const CHILD_ENV_PREFIXES = ['LC_', 'NODE_', 'CLAUDE_', 'ANTHROPIC_'];
|
|
37
|
+
|
|
38
|
+
function filterEnv(src) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [k, v] of Object.entries(src)) {
|
|
41
|
+
if (CHILD_ENV_ALLOWLIST.has(k) || CHILD_ENV_PREFIXES.some((p) => k.startsWith(p))) {
|
|
42
|
+
out[k] = v;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} deps
|
|
50
|
+
* @param {object} deps.config — runtime config object (config.bot, config.chats, config.defaults)
|
|
51
|
+
* @param {string} deps.botName — current bot's name
|
|
52
|
+
* @param {string} deps.childHome — HOME passed to spawned child
|
|
53
|
+
* @param {(sessionKey: string) => Function} deps.makeCanUseTool — closure that builds canUseTool callbacks
|
|
54
|
+
* @param {(kind: string, detail: object) => void} deps.logEvent — bound to db
|
|
55
|
+
* @param {object} [deps.logger=console]
|
|
56
|
+
* @param {object} [deps.processEnv=process.env] — overridable for tests
|
|
57
|
+
* @returns {(sessionKey: string, ctx: object) => object} spawnFn
|
|
58
|
+
*/
|
|
59
|
+
function createBuildSdkOptions({
|
|
60
|
+
config,
|
|
61
|
+
botName,
|
|
62
|
+
childHome,
|
|
63
|
+
makeCanUseTool,
|
|
64
|
+
logEvent,
|
|
65
|
+
logger = console,
|
|
66
|
+
processEnv = process.env,
|
|
67
|
+
} = {}) {
|
|
68
|
+
|
|
69
|
+
return function buildSdkOptions(sessionKey, ctx) {
|
|
70
|
+
const { chatConfig, existingSessionId, label, chatId, threadId } = ctx;
|
|
71
|
+
|
|
72
|
+
// rc.48: per-topic config overrides. Per-topic agent / cwd /
|
|
73
|
+
// permissionMode take precedence over chat-level config when
|
|
74
|
+
// isolateTopics is true (each topic has its own SDK Query).
|
|
75
|
+
const topicConfig = getTopicConfig(chatConfig, threadId);
|
|
76
|
+
const effectiveAgent = topicConfig.agent || chatConfig.agent;
|
|
77
|
+
|
|
78
|
+
// Per-chat agent: load + compose. Failure is non-fatal — chat
|
|
79
|
+
// falls back to defaults; the failure is logged for ops.
|
|
80
|
+
let agentBundle = null;
|
|
81
|
+
if (effectiveAgent) {
|
|
82
|
+
try {
|
|
83
|
+
agentBundle = agentLoader.loadAgent(effectiveAgent, {
|
|
84
|
+
homeDir: childHome,
|
|
85
|
+
cwd: topicConfig.cwd || chatConfig.cwd,
|
|
86
|
+
logger,
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logger.error?.(`[${label}] agent-loader: ${err.message}`);
|
|
90
|
+
logEvent('agent-load-failed', {
|
|
91
|
+
chat_id: chatId, agent: effectiveAgent, error: err.message,
|
|
92
|
+
topic: threadId || null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const effectiveModel = topicConfig.model || chatConfig.model;
|
|
98
|
+
const effectiveEffort = topicConfig.effort || chatConfig.effort;
|
|
99
|
+
const agentSuffix = effectiveAgent && effectiveAgent !== chatConfig.agent
|
|
100
|
+
? ` agent=${effectiveAgent}` : '';
|
|
101
|
+
logger.log?.(`[${label}] Spawning SDK Query (${effectiveModel}/${effectiveEffort}${agentSuffix})`);
|
|
102
|
+
|
|
103
|
+
// Env scrub: SHADOW. Pass the bot's per-call additions on top.
|
|
104
|
+
const botConfig = config.bot || {};
|
|
105
|
+
const childEnv = filterEnv(processEnv);
|
|
106
|
+
childEnv.HOME = childHome;
|
|
107
|
+
childEnv.CLAUDE_CHANNEL_BOT = botName;
|
|
108
|
+
// 0.9.0: gated behind explicit opt-in. Pre-cleanup, the IPC secret
|
|
109
|
+
// was unconditionally exported so the deleted bin/approval-hook.js
|
|
110
|
+
// could authenticate; with the hook gone, the only IPC consumers
|
|
111
|
+
// are external scripts (cron-driven sends) running in their own
|
|
112
|
+
// processes with their own access to /tmp/polygram-<bot>.secret.
|
|
113
|
+
if (botConfig.exposeIpcSecretToChildren && processEnv.POLYGRAM_IPC_SECRET) {
|
|
114
|
+
childEnv.POLYGRAM_IPC_SECRET = processEnv.POLYGRAM_IPC_SECRET;
|
|
115
|
+
}
|
|
116
|
+
if (botConfig.needsToken) {
|
|
117
|
+
childEnv.TELEGRAM_BOT_TOKEN = botConfig.token || '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// canUseTool: in-process approval flow. Wire up only when
|
|
121
|
+
// approvals.gatedTools is configured for this bot — otherwise
|
|
122
|
+
// leave canUseTool unset and rely on bypassPermissions.
|
|
123
|
+
const apprCfg = config.bot?.approvals;
|
|
124
|
+
const useCanUseTool = apprCfg && apprCfg.adminChatId
|
|
125
|
+
&& Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
|
|
126
|
+
|
|
127
|
+
const baseOpts = {
|
|
128
|
+
model: chatConfig.model || config.defaults.model,
|
|
129
|
+
effort: chatConfig.effort || config.defaults.effort,
|
|
130
|
+
cwd: chatConfig.cwd,
|
|
131
|
+
env: childEnv,
|
|
132
|
+
permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
|
|
133
|
+
allowDangerouslySkipPermissions: !useCanUseTool,
|
|
134
|
+
...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
|
|
135
|
+
hooks: {},
|
|
136
|
+
executable: 'node',
|
|
137
|
+
...(existingSessionId && { resume: existingSessionId }),
|
|
138
|
+
...(processEnv.POLYGRAM_CLAUDE_BIN && {
|
|
139
|
+
pathToClaudeCodeExecutable: processEnv.POLYGRAM_CLAUDE_BIN,
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// agent-loader precedence: topicConfig > chatConfig > agent > defaults.
|
|
144
|
+
const composed = agentLoader.composeSdkOptions(
|
|
145
|
+
{
|
|
146
|
+
model: chatConfig.model,
|
|
147
|
+
effort: chatConfig.effort,
|
|
148
|
+
cwd: chatConfig.cwd,
|
|
149
|
+
...(chatConfig.thinking && { thinking: chatConfig.thinking }),
|
|
150
|
+
},
|
|
151
|
+
agentBundle,
|
|
152
|
+
baseOpts,
|
|
153
|
+
topicConfig,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// rc.48: keep permissionMode + allowDangerouslySkipPermissions
|
|
157
|
+
// consistent. If a topic flipped permissionMode away from
|
|
158
|
+
// 'bypassPermissions', also disable the skip flag.
|
|
159
|
+
if (composed.permissionMode && composed.permissionMode !== 'bypassPermissions') {
|
|
160
|
+
composed.allowDangerouslySkipPermissions = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Append polygram's display constraints to the systemPrompt —
|
|
164
|
+
// infrastructure-layer hint, not agent business logic.
|
|
165
|
+
composed.systemPrompt = appendDisplayHint(composed.systemPrompt);
|
|
166
|
+
return composed;
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
createBuildSdkOptions,
|
|
172
|
+
// Exposed for tests + adjacent extractions that need the same env
|
|
173
|
+
// discipline (e.g. lib/sdk/callbacks.js when it ships).
|
|
174
|
+
filterEnv,
|
|
175
|
+
CHILD_ENV_ALLOWLIST,
|
|
176
|
+
CHILD_ENV_PREFIXES,
|
|
177
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory for the SDK pm's lifecycle callbacks.
|
|
3
|
+
*
|
|
4
|
+
* polygram.js wires this at boot via createSdkCallbacks(deps); the
|
|
5
|
+
* returned object is spread into ProcessManagerSdk's constructor as
|
|
6
|
+
* `{ onInit, onClose, onStreamChunk, onToolUse,
|
|
7
|
+
* onAssistantMessageStart, onAutonomousAssistantMessage,
|
|
8
|
+
* onCompactBoundary }`.
|
|
9
|
+
*
|
|
10
|
+
* Each callback is a thin glue layer: pm-sdk emits a typed event,
|
|
11
|
+
* polygram's callback decides what to persist (db / events) and
|
|
12
|
+
* what to surface to the user (telegram).
|
|
13
|
+
*
|
|
14
|
+
* Why factory: callbacks need polygram-runtime context (db, config,
|
|
15
|
+
* bot, BOT_NAME, tg, logEvent, dbWrite, classifyToolName, announce,
|
|
16
|
+
* shouldAnnounce, contextHintShown, extractAssistantText, getChatIdFromKey,
|
|
17
|
+
* getThreadIdFromKey). Closing over them at boot keeps each callback's
|
|
18
|
+
* runtime signature compatible with pm-sdk's contract.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
function createSdkCallbacks({
|
|
24
|
+
db,
|
|
25
|
+
dbWrite,
|
|
26
|
+
config,
|
|
27
|
+
bot,
|
|
28
|
+
botName,
|
|
29
|
+
tg,
|
|
30
|
+
logEvent,
|
|
31
|
+
classifyToolName,
|
|
32
|
+
announce,
|
|
33
|
+
shouldAnnounce,
|
|
34
|
+
contextHintShown,
|
|
35
|
+
extractAssistantText,
|
|
36
|
+
getChatIdFromKey,
|
|
37
|
+
getThreadIdFromKey,
|
|
38
|
+
logger = console,
|
|
39
|
+
} = {}) {
|
|
40
|
+
return {
|
|
41
|
+
onInit: (sessionKey, event, entry) => {
|
|
42
|
+
dbWrite(() => db.upsertSession({
|
|
43
|
+
session_key: sessionKey,
|
|
44
|
+
chat_id: entry.chatId,
|
|
45
|
+
thread_id: entry.threadId,
|
|
46
|
+
claude_session_id: event.session_id,
|
|
47
|
+
agent: config.chats[entry.chatId]?.agent || null,
|
|
48
|
+
cwd: config.chats[entry.chatId]?.cwd || null,
|
|
49
|
+
model: config.chats[entry.chatId]?.model || null,
|
|
50
|
+
effort: config.chats[entry.chatId]?.effort || null,
|
|
51
|
+
}), `upsert session ${sessionKey}`);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
onClose: (sessionKey, code, entry) => {
|
|
55
|
+
logger.log?.(`[${entry.label}] Process exited (code ${code})`);
|
|
56
|
+
logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code });
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
onStreamChunk: (sessionKey, partial, entry) => {
|
|
60
|
+
// Route to the head pending's per-turn streamer. In the
|
|
61
|
+
// concurrent-pending model, only the HEAD is the turn Claude
|
|
62
|
+
// is actively emitting events for.
|
|
63
|
+
const head = entry.pendingQueue?.[0];
|
|
64
|
+
const s = head?.context?.streamer;
|
|
65
|
+
if (s) s.onChunk(partial).catch(() => {});
|
|
66
|
+
// Heartbeat the reactor so long text generation doesn't trip
|
|
67
|
+
// the 10s STALL → 🥱 / 30s TIMEOUT → 😨 promotion.
|
|
68
|
+
const r = head?.context?.reactor;
|
|
69
|
+
if (r && typeof r.heartbeat === 'function') r.heartbeat();
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
onToolUse: (sessionKey, toolName, entry) => {
|
|
73
|
+
const head = entry.pendingQueue?.[0];
|
|
74
|
+
const r = head?.context?.reactor;
|
|
75
|
+
if (r) r.setState(classifyToolName(toolName));
|
|
76
|
+
// Subagent announce: when Claude uses Task to spawn a subagent,
|
|
77
|
+
// post a brief informational message. Per-chat 30s debounce
|
|
78
|
+
// prevents announce-storms in tool-heavy turns.
|
|
79
|
+
const chatCfg = config.chats[entry.chatId] || {};
|
|
80
|
+
const optOut = chatCfg.announceSubagents != null
|
|
81
|
+
? chatCfg.announceSubagents === false
|
|
82
|
+
: config.bot?.announceSubagents === false;
|
|
83
|
+
if (toolName === 'Task' && !optOut) {
|
|
84
|
+
if (shouldAnnounce(entry.chatId)) {
|
|
85
|
+
announce({
|
|
86
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
87
|
+
bot, chatId: entry.chatId,
|
|
88
|
+
threadId: head?.context?.threadId ?? null,
|
|
89
|
+
text: '🤖 Spawning subagent…',
|
|
90
|
+
meta: { botName, source: 'subagent-announce' },
|
|
91
|
+
logger: { error: (m) => logger.error?.(`[${entry.label}] ${m}`) },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// Each new top-level assistant message gets its own bubble.
|
|
98
|
+
// When Claude emits text, then tool_use, then more text in a NEW
|
|
99
|
+
// assistant message, the previous bubble's content stays visible
|
|
100
|
+
// as a "thinking out loud" intermediate; the new message starts
|
|
101
|
+
// fresh below.
|
|
102
|
+
onAssistantMessageStart: (sessionKey, entry) => {
|
|
103
|
+
const head = entry.pendingQueue?.[0];
|
|
104
|
+
const s = head?.context?.streamer;
|
|
105
|
+
if (s) s.forceNewMessage();
|
|
106
|
+
// Heartbeat at every assistant-message boundary too. A long
|
|
107
|
+
// thinking phase (effort=high, 30+s before first chunk) doesn't
|
|
108
|
+
// fire onStreamChunk; without this, the freeze timer could
|
|
109
|
+
// expire while the model is "still thinking but about to speak".
|
|
110
|
+
const r = head?.context?.reactor;
|
|
111
|
+
if (r && typeof r.heartbeat === 'function') r.heartbeat();
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// rc.47: autonomous wakeup forwarding. Fires when an SDK
|
|
115
|
+
// assistant message arrives with no head pending — typical
|
|
116
|
+
// ScheduleWakeup case where the agent self-fires without an
|
|
117
|
+
// inbound user message. Best-effort send: failures are logged
|
|
118
|
+
// but don't propagate.
|
|
119
|
+
onAutonomousAssistantMessage: (sessionKey, msg /* , entry */) => {
|
|
120
|
+
try {
|
|
121
|
+
const text = extractAssistantText(msg);
|
|
122
|
+
if (!text) return;
|
|
123
|
+
const chatId = getChatIdFromKey(sessionKey);
|
|
124
|
+
const threadIdRaw = getThreadIdFromKey(sessionKey);
|
|
125
|
+
const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
|
|
126
|
+
if (!bot) {
|
|
127
|
+
logger.error?.(`[${botName}] autonomous wakeup: bot not ready, dropping ${text.length} chars`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const params = {
|
|
131
|
+
chat_id: chatId,
|
|
132
|
+
text,
|
|
133
|
+
...(Number.isInteger(threadId) && { message_thread_id: threadId }),
|
|
134
|
+
};
|
|
135
|
+
// Don't await — keep the pm-sdk event loop unblocked.
|
|
136
|
+
tg(bot, 'sendMessage', params,
|
|
137
|
+
{ source: 'autonomous-wakeup', botName }).catch((err) => {
|
|
138
|
+
logger.error?.(`[${botName}] autonomous wakeup send failed: ${err.message}`);
|
|
139
|
+
});
|
|
140
|
+
logEvent('autonomous-wakeup-message', {
|
|
141
|
+
chat_id: chatId,
|
|
142
|
+
session_key: sessionKey,
|
|
143
|
+
thread_id: threadIdRaw,
|
|
144
|
+
text_len: text.length,
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger.error?.(`[${botName}] autonomous wakeup handler: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// SDK auto-compaction observability. Fires when SDK emits
|
|
152
|
+
// SDKCompactBoundaryMessage. Surfaces a quiet system status note
|
|
153
|
+
// to the chat so the user knows the bot is busy reorganising
|
|
154
|
+
// context. ON by default; set per-chat or per-bot
|
|
155
|
+
// `announceCompact: false` to silence.
|
|
156
|
+
onCompactBoundary: async (sessionKey, msg, entry) => {
|
|
157
|
+
// Clear the contextHint once-per-cycle gate. After compaction,
|
|
158
|
+
// context drops below threshold; if it climbs back up the next
|
|
159
|
+
// cycle should fire a fresh hint.
|
|
160
|
+
contextHintShown.delete(sessionKey);
|
|
161
|
+
|
|
162
|
+
const chatCfg = config.chats[entry.chatId] || {};
|
|
163
|
+
const optOut = chatCfg.announceCompact != null
|
|
164
|
+
? chatCfg.announceCompact === false
|
|
165
|
+
: config.bot?.announceCompact === false;
|
|
166
|
+
if (optOut) return;
|
|
167
|
+
const threadId = entry.threadId || undefined;
|
|
168
|
+
|
|
169
|
+
// Word the message based on what actually happened. Pre-rc.62
|
|
170
|
+
// every event read as "💭 Catching up…" — but compact_boundary
|
|
171
|
+
// fires AFTER compaction completes, leaving users confused
|
|
172
|
+
// when nothing followed. Now: distinguish manual vs auto and
|
|
173
|
+
// surface the compression ratio.
|
|
174
|
+
const meta = msg?.compact_metadata || {};
|
|
175
|
+
const trigger = meta.trigger; // 'manual' | 'auto'
|
|
176
|
+
const preTokens = meta.pre_tokens;
|
|
177
|
+
const postTokens = meta.post_tokens;
|
|
178
|
+
const durationMs = meta.duration_ms;
|
|
179
|
+
const fmtTok = (n) => {
|
|
180
|
+
if (n == null) return null;
|
|
181
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
182
|
+
return String(n);
|
|
183
|
+
};
|
|
184
|
+
const ratio = (preTokens && postTokens)
|
|
185
|
+
? `${fmtTok(preTokens)} → ${fmtTok(postTokens)}` : null;
|
|
186
|
+
const duration = durationMs ? `${(durationMs / 1000).toFixed(1)}s` : null;
|
|
187
|
+
const stats = [ratio, duration].filter(Boolean).join(', ');
|
|
188
|
+
|
|
189
|
+
let text;
|
|
190
|
+
if (trigger === 'manual') {
|
|
191
|
+
text = stats
|
|
192
|
+
? `✅ Compacted (${stats}). Ready for your next message.`
|
|
193
|
+
: `✅ Compacted. Ready for your next message.`;
|
|
194
|
+
} else {
|
|
195
|
+
text = stats
|
|
196
|
+
? `💭 Auto-compacted (${stats}). Continuing…`
|
|
197
|
+
: `💭 Auto-compacted. Continuing…`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await tg(bot, 'sendMessage', {
|
|
202
|
+
chat_id: entry.chatId,
|
|
203
|
+
text,
|
|
204
|
+
...(threadId ? { message_thread_id: threadId } : {}),
|
|
205
|
+
}, { source: 'compact-boundary', botName });
|
|
206
|
+
} catch (err) {
|
|
207
|
+
logger.error?.(`[${entry.label}] compact-boundary post: ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { createSdkCallbacks };
|
|
@@ -1,34 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* objects in place of `child_process.spawn('claude', ...)` and
|
|
4
|
-
* stream-json line parsing.
|
|
2
|
+
* ProcessManager — `@anthropic-ai/claude-agent-sdk` Query objects.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* mitigations), Phase 0 spike findings (docs/0.8.0-phase0-findings.md).
|
|
4
|
+
* The canonical pm impl post-0.9.0. Pre-0.9.0 polygram ran a dual-pm
|
|
5
|
+
* router (CLI subprocess pm + this SDK pm) behind env flags; the CLI
|
|
6
|
+
* variant was deleted with the rest of the migration safety net once
|
|
7
|
+
* both bots had soaked on SDK pm. See `lib/pm-interface.js` for the
|
|
8
|
+
* canonical contract and `docs/0.8.0-sdk-migration-plan.md` for the
|
|
9
|
+
* migration history.
|
|
13
10
|
*
|
|
14
11
|
* Architecture:
|
|
15
|
-
* - One Query per active sessionKey, held for the chat lifetime
|
|
16
|
-
* (Phase 0 gate 1 PASS — long-lived input AsyncIterable works).
|
|
12
|
+
* - One Query per active sessionKey, held for the chat lifetime.
|
|
17
13
|
* - inputController is the writable end of an
|
|
18
14
|
* AsyncIterable<SDKUserMessage>; pm.send() pushes user messages
|
|
19
15
|
* onto it; the SDK's streamInput() consumes from the other end.
|
|
20
16
|
* - iteratePromise is the for-await loop over the Query's
|
|
21
17
|
* AsyncGenerator output. Wrapped in try/catch (D7 commitment).
|
|
22
18
|
* - pendingQueue maps N user messages → N SDKResultMessage events
|
|
23
|
-
* in FIFO order
|
|
24
|
-
* - LRU eviction across the procs Map (cap = DEFAULT_CAP)
|
|
25
|
-
*
|
|
26
|
-
* proc.kill().
|
|
19
|
+
* in FIFO order.
|
|
20
|
+
* - LRU eviction across the procs Map (cap = DEFAULT_CAP) via
|
|
21
|
+
* Query.close().
|
|
27
22
|
*
|
|
28
|
-
* Decisions encoded:
|
|
23
|
+
* Decisions encoded (v4 plan):
|
|
29
24
|
* D1 streaming: subscribe to SDKAssistantMessage (cumulative)
|
|
30
25
|
* D2 long-lived Query per chat
|
|
31
|
-
* D3 /effort via applyFlagSettings
|
|
26
|
+
* D3 /effort via applyFlagSettings (no respawn)
|
|
32
27
|
* D5 Options.env SHADOW — buildSdkOptions enumerates everything
|
|
33
28
|
* D6 Query.close() is fast — 100ms shutdown timeout safe
|
|
34
29
|
* D7 killChat Promise.allSettled with 5s per-Query timeout
|
|
@@ -39,7 +34,7 @@
|
|
|
39
34
|
'use strict';
|
|
40
35
|
|
|
41
36
|
const { query } = require('@anthropic-ai/claude-agent-sdk');
|
|
42
|
-
const { isTransientHttpError } = require('
|
|
37
|
+
const { isTransientHttpError } = require('../error/classify');
|
|
43
38
|
|
|
44
39
|
const DEFAULT_CAP = 10;
|
|
45
40
|
const DEFAULT_QUEUE_CAP = 50;
|
|
@@ -169,14 +164,11 @@ function makeInputController({ queueCap = DEFAULT_QUEUE_CAP } = {}) {
|
|
|
169
164
|
// ─── ProcessManager ────────────────────────────────────────────────
|
|
170
165
|
|
|
171
166
|
/**
|
|
172
|
-
* @anthropic-ai/claude-agent-sdk-backed ProcessManager.
|
|
173
|
-
*
|
|
174
|
-
* methods exposed: `steer`,
|
|
175
|
-
* `
|
|
176
|
-
*
|
|
177
|
-
* Optional methods NOT implemented (CLI pm has this): `requestRespawn`.
|
|
178
|
-
* For mid-session config changes use `applyFlagSettings` (effort)
|
|
179
|
-
* or `setModel`.
|
|
167
|
+
* @anthropic-ai/claude-agent-sdk-backed ProcessManager. The canonical
|
|
168
|
+
* pm impl post-0.9.0. Implements the Pm interface
|
|
169
|
+
* (`lib/pm-interface.js`); optional methods exposed: `steer`,
|
|
170
|
+
* `setModel`, `applyFlagSettings`, `setPermissionMode`, `drainQueue`,
|
|
171
|
+
* `interrupt`, `resetSession`.
|
|
180
172
|
*
|
|
181
173
|
* @implements {import('./pm-interface.js').Pm}
|
|
182
174
|
*/
|
|
@@ -333,7 +325,6 @@ class ProcessManagerSdk {
|
|
|
333
325
|
inFlight: false,
|
|
334
326
|
lastUsedTs: Date.now(),
|
|
335
327
|
iteratePromise: null,
|
|
336
|
-
needsRespawn: null,
|
|
337
328
|
};
|
|
338
329
|
|
|
339
330
|
inputController.onDrop((dropped) => {
|
|
@@ -640,9 +631,6 @@ class ProcessManagerSdk {
|
|
|
640
631
|
if (!entry || entry.closed) {
|
|
641
632
|
return reject(new Error('No process for session'));
|
|
642
633
|
}
|
|
643
|
-
if (entry.needsRespawn) {
|
|
644
|
-
return reject(new Error(`Session awaiting respawn (${entry.needsRespawn})`));
|
|
645
|
-
}
|
|
646
634
|
|
|
647
635
|
entry.lastUsedTs = Date.now();
|
|
648
636
|
|
|
@@ -26,8 +26,8 @@ const {
|
|
|
26
26
|
isMessageNotModifiedError,
|
|
27
27
|
isRateLimitError,
|
|
28
28
|
getRetryAfterMs,
|
|
29
|
-
} = require('./
|
|
30
|
-
const { isSafeToRetry, redactBotToken } = require('
|
|
29
|
+
} = require('./format');
|
|
30
|
+
const { isSafeToRetry, redactBotToken } = require('../error/net');
|
|
31
31
|
|
|
32
32
|
// Topic deletion race: a user can delete a forum topic while a turn is in
|
|
33
33
|
// flight, turning a valid `message_thread_id` into a 404. Telegram's error
|
|
@@ -97,22 +97,8 @@ function appendDisplayHint(systemPromptOpt, hint = POLYGRAM_DISPLAY_HINT) {
|
|
|
97
97
|
return systemPromptOpt;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
101
|
-
* For the CLI pm (`claude -p ...`), the equivalent of an appended
|
|
102
|
-
* system prompt is the `--append-system-prompt <text>` flag. This
|
|
103
|
-
* helper returns the args the CLI pm should add to its argv.
|
|
104
|
-
*
|
|
105
|
-
* @param {string} [hint] — override (tests)
|
|
106
|
-
* @returns {string[]} — argv tail, e.g. ['--append-system-prompt', '...']
|
|
107
|
-
*/
|
|
108
|
-
function appendDisplayHintCliArgs(hint = POLYGRAM_DISPLAY_HINT) {
|
|
109
|
-
if (!hint) return [];
|
|
110
|
-
return ['--append-system-prompt', hint];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
100
|
module.exports = {
|
|
114
101
|
POLYGRAM_DISPLAY_HINT,
|
|
115
102
|
TELEGRAM_TABLE_WIDTH_BUDGET,
|
|
116
103
|
appendDisplayHint,
|
|
117
|
-
appendDisplayHintCliArgs,
|
|
118
104
|
};
|
|
@@ -211,10 +211,10 @@ function createStreamer({
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
// Reset bubble state so the next onChunk creates a NEW message.
|
|
214
|
-
// Used by `onAssistantMessageStart` in process-manager.js
|
|
215
|
-
// emits a new top-level assistant message mid-turn
|
|
216
|
-
// we want it in its own bubble below the
|
|
217
|
-
// via editMessageText to the original.
|
|
214
|
+
// Used by `onAssistantMessageStart` in lib/process-manager-sdk.js
|
|
215
|
+
// when Claude emits a new top-level assistant message mid-turn
|
|
216
|
+
// (post tool-result): we want it in its own bubble below the
|
|
217
|
+
// previous one, not appended via editMessageText to the original.
|
|
218
218
|
//
|
|
219
219
|
// rc.44: by default, the previous bubble is PRESERVED (not archived
|
|
220
220
|
// for end-of-turn deletion). Intermediate text segments are
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-rc.2",
|
|
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
|
-
"main": "lib/ipc
|
|
5
|
+
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"polygram": "polygram.js",
|
|
8
8
|
"polygram-split-db": "scripts/split-db.js",
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"polygram.js",
|
|
14
|
-
"bin/",
|
|
15
14
|
"lib/",
|
|
16
15
|
"migrations/",
|
|
17
16
|
"scripts/split-db.js",
|