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/scripts/doctor.js
CHANGED
package/scripts/ipc-smoke.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Usage: node scripts/ipc-smoke.js <bot-name>
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const { call, socketPathFor } = require('../lib/ipc
|
|
7
|
+
const { call, socketPathFor } = require('../lib/ipc/client');
|
|
8
8
|
|
|
9
9
|
(async () => {
|
|
10
10
|
const bot = process.argv[2] || 'shumabit';
|
|
@@ -12,15 +12,6 @@ const { call, socketPathFor } = require('../lib/ipc-client');
|
|
|
12
12
|
|
|
13
13
|
console.log('path:', path);
|
|
14
14
|
console.log('ping:', JSON.stringify(await call({ path, op: 'ping' })));
|
|
15
|
-
|
|
16
|
-
console.log('ungated:', JSON.stringify(await call({
|
|
17
|
-
path, op: 'approval_request',
|
|
18
|
-
payload: {
|
|
19
|
-
bot_name: bot, chat_id: '111111111',
|
|
20
|
-
tool_name: 'Read', tool_input: { path: '/etc/hosts' },
|
|
21
|
-
},
|
|
22
|
-
})));
|
|
23
|
-
|
|
24
15
|
console.log('DONE');
|
|
25
16
|
})().catch((err) => {
|
|
26
17
|
console.error('ERR:', err.message);
|
package/bin/approval-hook.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Claude Code PreToolUse hook -> polygram daemon approval round-trip.
|
|
4
|
-
*
|
|
5
|
-
* Installed into an agent's settings.json:
|
|
6
|
-
* { "hooks": { "PreToolUse": [
|
|
7
|
-
* { "matcher": "Bash|WebFetch|mcp__*", "hooks": [
|
|
8
|
-
* { "type": "command",
|
|
9
|
-
* "command": "/Users/YOURNAME/polygram/bin/approval-hook.js" }
|
|
10
|
-
* ]}
|
|
11
|
-
* ]}}
|
|
12
|
-
*
|
|
13
|
-
* Environment (set by polygram when spawning Claude):
|
|
14
|
-
* POLYGRAM_BOT - bot name owning this session (socket suffix)
|
|
15
|
-
* POLYGRAM_CHAT_ID - chat whose message triggered this turn (for the card)
|
|
16
|
-
* POLYGRAM_TURN_ID - optional; helps dedupe re-fires on Claude retries
|
|
17
|
-
*
|
|
18
|
-
* Contract (Claude Code):
|
|
19
|
-
* stdin JSON: { session_id, hook_event_name: "PreToolUse",
|
|
20
|
-
* tool_name, tool_input, ... }
|
|
21
|
-
* stdout JSON reply for PreToolUse: either pass-through (exit 0 empty stdout),
|
|
22
|
-
* or a block decision:
|
|
23
|
-
* {"hookSpecificOutput": {"hookEventName":"PreToolUse",
|
|
24
|
-
* "permissionDecision":"allow"|"deny"|"ask",
|
|
25
|
-
* "permissionDecisionReason":"..."}}
|
|
26
|
-
* Exit codes:
|
|
27
|
-
* 0 - allow (empty stdout) or structured decision in stdout
|
|
28
|
-
* 2 - block (deny)
|
|
29
|
-
*
|
|
30
|
-
* Failure policy: on IPC error (polygram down, socket missing, timeout) we
|
|
31
|
-
* deny by default. Better to block a legitimate tool call than to let a
|
|
32
|
-
* destructive one through when the approver is unreachable.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const fs = require('fs');
|
|
36
|
-
|
|
37
|
-
(async () => {
|
|
38
|
-
const botName = process.env.POLYGRAM_BOT;
|
|
39
|
-
const chatId = process.env.POLYGRAM_CHAT_ID;
|
|
40
|
-
const turnId = process.env.POLYGRAM_TURN_ID || null;
|
|
41
|
-
|
|
42
|
-
if (!botName || !chatId) {
|
|
43
|
-
deny('polygram-approval-hook: POLYGRAM_BOT and POLYGRAM_CHAT_ID env vars required');
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
let req;
|
|
48
|
-
try {
|
|
49
|
-
req = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
50
|
-
} catch (err) {
|
|
51
|
-
deny(`bad hook input: ${err.message}`);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
if (req.hook_event_name !== 'PreToolUse') {
|
|
55
|
-
// Not our event; pass through silently.
|
|
56
|
-
process.exit(0);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Resolve relative to this hook's own location rather than a hardcoded
|
|
60
|
-
// absolute path — an absolute-path require is a symlink-swap RCE vector
|
|
61
|
-
// (anyone who can write to that path gets code execution in-polygram).
|
|
62
|
-
const path = require('path');
|
|
63
|
-
const { call, socketPathFor, readSecret } = require(path.join(__dirname, '..', 'lib', 'ipc-client'));
|
|
64
|
-
let res;
|
|
65
|
-
try {
|
|
66
|
-
res = await call({
|
|
67
|
-
path: socketPathFor(botName),
|
|
68
|
-
op: 'approval_request',
|
|
69
|
-
secret: readSecret(botName),
|
|
70
|
-
payload: {
|
|
71
|
-
bot_name: botName,
|
|
72
|
-
chat_id: chatId,
|
|
73
|
-
turn_id: turnId,
|
|
74
|
-
tool_name: req.tool_name,
|
|
75
|
-
tool_input: req.tool_input,
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
} catch (err) {
|
|
79
|
-
deny(`polygram unreachable: ${err.message}`);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!res || !res.ok) {
|
|
84
|
-
deny(`polygram error: ${res?.error || 'unknown'}`);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Bridge signals one of: 'not-gated' | 'approved' | 'denied' | 'timeout' | 'auto-approved'
|
|
89
|
-
if (res.decision === 'not-gated' || res.decision === 'approved' || res.decision === 'auto-approved') {
|
|
90
|
-
// Pass through — let the default permission flow decide. An empty
|
|
91
|
-
// stdout + exit 0 means "no opinion" from this hook.
|
|
92
|
-
process.exit(0);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const reason = res.reason || `approval ${res.decision}`;
|
|
96
|
-
deny(reason, res.decision);
|
|
97
|
-
})().catch((err) => {
|
|
98
|
-
deny(`hook crashed: ${err.message}`);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
function deny(reason, decision = 'denied') {
|
|
102
|
-
const out = {
|
|
103
|
-
hookSpecificOutput: {
|
|
104
|
-
hookEventName: 'PreToolUse',
|
|
105
|
-
permissionDecision: 'deny',
|
|
106
|
-
permissionDecisionReason: `[${decision}] ${reason}`,
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
try {
|
|
110
|
-
process.stdout.write(JSON.stringify(out));
|
|
111
|
-
} catch {}
|
|
112
|
-
process.exit(2);
|
|
113
|
-
}
|
package/lib/approval-waiters.js
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parked-Promise Map for canUseTool's async user-approval flow.
|
|
3
|
-
*
|
|
4
|
-
* Per v4 plan §6.5.3 / Phase 1 step 8.
|
|
5
|
-
*
|
|
6
|
-
* Background: under SDK migration, canUseTool is an in-process
|
|
7
|
-
* callback (replaces today's `bin/approval-hook.js` IPC). When a
|
|
8
|
-
* gated tool fires, polygram posts a Telegram inline-keyboard card
|
|
9
|
-
* to the admin chat and PARKS a Promise that resolves on user click.
|
|
10
|
-
* The SDK awaits that Promise — so the in-flight tool sleeps until
|
|
11
|
-
* the user decides.
|
|
12
|
-
*
|
|
13
|
-
* This module owns the waiter Map. Five cleanup paths are wired:
|
|
14
|
-
* 1. resolveByClick(toolUseId, decision) — user pressed a button
|
|
15
|
-
* 2. signal abort — SDK called Query.interrupt() / Query.close();
|
|
16
|
-
* AbortSignal fires → Promise rejects with code:'ABORTED'
|
|
17
|
-
* 3. timeout — periodic sweeper rejects waiters parked > timeoutMs
|
|
18
|
-
* 4. rejectAllForSession(sessionKey) — pm.resetSession or kill
|
|
19
|
-
* 5. shutdown — daemon SIGTERM; reject all
|
|
20
|
-
*
|
|
21
|
-
* Memory bound: MAX_WAITERS (200). Park beyond cap throws a typed
|
|
22
|
-
* error so the caller can return `{behavior:'deny'}` to the SDK
|
|
23
|
-
* instead of accumulating garbage.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
'use strict';
|
|
27
|
-
|
|
28
|
-
const DEFAULT_MAX_WAITERS = 200;
|
|
29
|
-
const DEFAULT_TIMEOUT_MS = 60_000; // 60s; matches OpenClaw cancel window
|
|
30
|
-
const DEFAULT_SWEEP_INTERVAL_MS = 5_000;
|
|
31
|
-
|
|
32
|
-
function createApprovalWaiters({
|
|
33
|
-
logger = console,
|
|
34
|
-
maxWaiters = DEFAULT_MAX_WAITERS,
|
|
35
|
-
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
36
|
-
sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS,
|
|
37
|
-
} = {}) {
|
|
38
|
-
// toolUseId → entry { resolve, reject, signal, sigCleanup,
|
|
39
|
-
// parkedAt, sessionKey }
|
|
40
|
-
const waiters = new Map();
|
|
41
|
-
let sweepTimer = null;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Park the canUseTool Promise; return a Promise that resolves on
|
|
45
|
-
* user click / rejects on signal-abort / timeout / shutdown.
|
|
46
|
-
*
|
|
47
|
-
* @param {object} args
|
|
48
|
-
* @param {string} args.toolUseId — SDK opts.toolUseID. Required.
|
|
49
|
-
* @param {string} args.sessionKey — for rejectAllForSession routing.
|
|
50
|
-
* @param {AbortSignal} [args.signal] — opts.signal from canUseTool.
|
|
51
|
-
*
|
|
52
|
-
* @returns {Promise<PermissionResult>}
|
|
53
|
-
* @throws {Error{code:'WAITER_CAP'}} if cap exceeded.
|
|
54
|
-
*/
|
|
55
|
-
function park({ toolUseId, sessionKey, signal }) {
|
|
56
|
-
if (!toolUseId) {
|
|
57
|
-
throw Object.assign(new Error('toolUseId required'),
|
|
58
|
-
{ code: 'NO_TOOL_USE_ID' });
|
|
59
|
-
}
|
|
60
|
-
if (waiters.size >= maxWaiters) {
|
|
61
|
-
logger.error?.(`[approval-waiters] cap reached (${maxWaiters}); rejecting`);
|
|
62
|
-
throw Object.assign(
|
|
63
|
-
new Error(`approval waiter cap exceeded (${maxWaiters})`),
|
|
64
|
-
{ code: 'WAITER_CAP' },
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
if (waiters.has(toolUseId)) {
|
|
68
|
-
// Concurrent canUseTool with same toolUseID — SDK doesn't
|
|
69
|
-
// typically retry the same call, but handle defensively by
|
|
70
|
-
// resolving the old one with a deny first.
|
|
71
|
-
logger.error?.(`[approval-waiters] duplicate toolUseId ${toolUseId}; abandoning prior waiter`);
|
|
72
|
-
const prior = waiters.get(toolUseId);
|
|
73
|
-
prior.reject(Object.assign(new Error('superseded'), { code: 'SUPERSEDED' }));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return new Promise((resolve, reject) => {
|
|
77
|
-
// signal-abort cleanup wired here so signal-fires always
|
|
78
|
-
// unparks the waiter, even if user click never arrives.
|
|
79
|
-
const sigCleanup = signal
|
|
80
|
-
? () => {
|
|
81
|
-
const e = waiters.get(toolUseId);
|
|
82
|
-
if (e) {
|
|
83
|
-
waiters.delete(toolUseId);
|
|
84
|
-
e.reject(Object.assign(new Error('aborted'), { code: 'ABORTED' }));
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
: null;
|
|
88
|
-
if (signal && sigCleanup) {
|
|
89
|
-
signal.addEventListener('abort', sigCleanup, { once: true });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
waiters.set(toolUseId, {
|
|
93
|
-
resolve: (decision) => {
|
|
94
|
-
if (signal && sigCleanup) {
|
|
95
|
-
try { signal.removeEventListener('abort', sigCleanup); }
|
|
96
|
-
catch { /* swallow */ }
|
|
97
|
-
}
|
|
98
|
-
waiters.delete(toolUseId);
|
|
99
|
-
resolve(decision);
|
|
100
|
-
},
|
|
101
|
-
reject: (err) => {
|
|
102
|
-
if (signal && sigCleanup) {
|
|
103
|
-
try { signal.removeEventListener('abort', sigCleanup); }
|
|
104
|
-
catch { /* swallow */ }
|
|
105
|
-
}
|
|
106
|
-
waiters.delete(toolUseId);
|
|
107
|
-
reject(err);
|
|
108
|
-
},
|
|
109
|
-
signal,
|
|
110
|
-
parkedAt: Date.now(),
|
|
111
|
-
sessionKey,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// If the signal was ALREADY aborted before we attached the
|
|
115
|
-
// listener, addEventListener never fires — the waiter would
|
|
116
|
-
// sit in the map until timeout-sweep / shutdown picked it up.
|
|
117
|
-
// Trigger the cleanup manually so the parked promise rejects
|
|
118
|
-
// immediately (matches "abort fired during park" semantics).
|
|
119
|
-
if (signal && signal.aborted) sigCleanup();
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Path 1: user clicked a button. `decision` is the
|
|
125
|
-
* SDK-shape PermissionResult.
|
|
126
|
-
*/
|
|
127
|
-
function resolveByClick(toolUseId, decision) {
|
|
128
|
-
const e = waiters.get(toolUseId);
|
|
129
|
-
if (!e) return false;
|
|
130
|
-
e.resolve(decision);
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Path 4: pm.resetSession or kill. Reject every waiter whose
|
|
136
|
-
* sessionKey matches.
|
|
137
|
-
*/
|
|
138
|
-
function rejectAllForSession(sessionKey, code = 'RESET_SESSION') {
|
|
139
|
-
let count = 0;
|
|
140
|
-
for (const [id, e] of [...waiters.entries()]) {
|
|
141
|
-
if (e.sessionKey === sessionKey) {
|
|
142
|
-
e.reject(Object.assign(new Error('session reset'), { code }));
|
|
143
|
-
count++;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return count;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Path 5: daemon shutdown. Reject every waiter.
|
|
151
|
-
*/
|
|
152
|
-
function rejectAll(code = 'DAEMON_SHUTDOWN') {
|
|
153
|
-
let count = 0;
|
|
154
|
-
for (const [id, e] of [...waiters.entries()]) {
|
|
155
|
-
e.reject(Object.assign(new Error('daemon shutdown'), { code }));
|
|
156
|
-
count++;
|
|
157
|
-
}
|
|
158
|
-
return count;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Path 3: timeout sweeper. Periodically reject waiters parked
|
|
163
|
-
* longer than timeoutMs.
|
|
164
|
-
*/
|
|
165
|
-
function startTimeoutSweeper() {
|
|
166
|
-
if (sweepTimer) return;
|
|
167
|
-
const sweep = () => {
|
|
168
|
-
const cutoff = Date.now() - timeoutMs;
|
|
169
|
-
for (const [id, e] of [...waiters.entries()]) {
|
|
170
|
-
if (e.parkedAt < cutoff) {
|
|
171
|
-
e.reject(Object.assign(new Error('approval timeout'),
|
|
172
|
-
{ code: 'TIMEOUT' }));
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
};
|
|
176
|
-
sweepTimer = setInterval(sweep, sweepIntervalMs);
|
|
177
|
-
sweepTimer.unref?.();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function stopTimeoutSweeper() {
|
|
181
|
-
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
park,
|
|
186
|
-
resolveByClick,
|
|
187
|
-
rejectAllForSession,
|
|
188
|
-
rejectAll,
|
|
189
|
-
startTimeoutSweeper,
|
|
190
|
-
stopTimeoutSweeper,
|
|
191
|
-
get size() { return waiters.size; },
|
|
192
|
-
// Test introspection only:
|
|
193
|
-
_waiters: waiters,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
module.exports = {
|
|
198
|
-
createApprovalWaiters,
|
|
199
|
-
DEFAULT_MAX_WAITERS,
|
|
200
|
-
DEFAULT_TIMEOUT_MS,
|
|
201
|
-
};
|
package/lib/pm-router.js
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-chat ProcessManager router (rc.6+).
|
|
3
|
-
*
|
|
4
|
-
* Daemon hosts up to TWO pm instances simultaneously — the
|
|
5
|
-
* stream-json-CLI ProcessManager and the @anthropic-ai/claude-agent-sdk
|
|
6
|
-
* ProcessManagerSdk. Each chat is assigned to one of them based on
|
|
7
|
-
* env config:
|
|
8
|
-
*
|
|
9
|
-
* POLYGRAM_USE_SDK=1 → all chats SDK pm
|
|
10
|
-
* POLYGRAM_SDK_CHATS=id1,id2,... → those chats SDK; others CLI
|
|
11
|
-
* neither set → all chats CLI
|
|
12
|
-
*
|
|
13
|
-
* The router exposes the same surface a single pm did, plus two
|
|
14
|
-
* introspection methods:
|
|
15
|
-
*
|
|
16
|
-
* pm.pickFor(sessionKey) → underlying pm instance (for feature
|
|
17
|
-
* detection at call sites)
|
|
18
|
-
* pm.isSdkFor(sessionKey) → boolean shortcut
|
|
19
|
-
*
|
|
20
|
-
* Lifecycle methods (`killChat`, `shutdown`) broadcast to BOTH pms
|
|
21
|
-
* when both are alive — a chat could have a session on either side
|
|
22
|
-
* (e.g. mid-config-change), so we don't risk leaking one.
|
|
23
|
-
*
|
|
24
|
-
* Optional methods (steer / setModel / applyFlagSettings /
|
|
25
|
-
* requestRespawn / drainQueue / interrupt / resetSession) forward
|
|
26
|
-
* when the routed pm has the method and return a sentinel otherwise.
|
|
27
|
-
* Sites that need to feature-detect should `pm.pickFor(sessionKey)`
|
|
28
|
-
* and check `typeof X === 'function'` directly.
|
|
29
|
-
*
|
|
30
|
-
* Used by `polygram.js` main() — Phase 5 + rc.6.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
'use strict';
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Parse the SDK-chats env config into a router policy.
|
|
37
|
-
*
|
|
38
|
-
* @param {object} opts
|
|
39
|
-
* @param {boolean} opts.useSdkAll — POLYGRAM_USE_SDK=1
|
|
40
|
-
* @param {Iterable<string>} [opts.sdkChats] — POLYGRAM_SDK_CHATS list
|
|
41
|
-
* @param {(sessionKey: string) => string|null} opts.getChatIdFromKey
|
|
42
|
-
*
|
|
43
|
-
* @returns {object} { sdkAllChats, sdkSomeChats, sdkActive,
|
|
44
|
-
* sdkChatIdSet, pickPmKindFor }
|
|
45
|
-
*/
|
|
46
|
-
function makeRouterPolicy({ useSdkAll = false, sdkChats = [], getChatIdFromKey } = {}) {
|
|
47
|
-
if (typeof getChatIdFromKey !== 'function') {
|
|
48
|
-
throw new TypeError('getChatIdFromKey function required');
|
|
49
|
-
}
|
|
50
|
-
const sdkChatIdSet = new Set(
|
|
51
|
-
[...sdkChats].map((s) => String(s).trim()).filter(Boolean),
|
|
52
|
-
);
|
|
53
|
-
const sdkAllChats = !!useSdkAll && sdkChatIdSet.size === 0;
|
|
54
|
-
const sdkSomeChats = sdkChatIdSet.size > 0;
|
|
55
|
-
const sdkActive = sdkAllChats || sdkSomeChats;
|
|
56
|
-
|
|
57
|
-
function pickPmKindFor(sessionKey) {
|
|
58
|
-
if (sdkAllChats) return 'sdk';
|
|
59
|
-
if (!sdkSomeChats) return 'cli';
|
|
60
|
-
const chatId = String(getChatIdFromKey(sessionKey) ?? '');
|
|
61
|
-
return sdkChatIdSet.has(chatId) ? 'sdk' : 'cli';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return { sdkAllChats, sdkSomeChats, sdkActive, sdkChatIdSet, pickPmKindFor };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Build a routing pm proxy. cliPm is required; sdkPm is optional
|
|
69
|
-
* (null when SDK isn't enabled for any chat).
|
|
70
|
-
*
|
|
71
|
-
* @param {object} opts
|
|
72
|
-
* @param {object} opts.cliPm
|
|
73
|
-
* @param {object|null} opts.sdkPm
|
|
74
|
-
* @param {(sessionKey: string) => 'sdk'|'cli'} opts.pickPmKindFor
|
|
75
|
-
*/
|
|
76
|
-
/**
|
|
77
|
-
* Broadcast helper for killChat / shutdown. Awaits every task to
|
|
78
|
-
* settlement (success OR rejection), then throws an aggregate error
|
|
79
|
-
* if any task rejected. Single rejections re-throw the original
|
|
80
|
-
* error untouched (no AggregateError noise); multiple rejections
|
|
81
|
-
* surface as `AggregateError` with all causes preserved.
|
|
82
|
-
*
|
|
83
|
-
* Each task entry is `[label, () => Promise]`; the label appears in
|
|
84
|
-
* AggregateError messages so a debugger can tell which pm failed.
|
|
85
|
-
*/
|
|
86
|
-
async function broadcastSettle(method, tasks) {
|
|
87
|
-
const results = await Promise.allSettled(tasks.map(([, fn]) => fn()));
|
|
88
|
-
const errors = [];
|
|
89
|
-
results.forEach((r, i) => {
|
|
90
|
-
if (r.status === 'rejected') {
|
|
91
|
-
const tag = tasks[i][0];
|
|
92
|
-
const err = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
|
|
93
|
-
err.pmTag = tag;
|
|
94
|
-
errors.push(err);
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
if (errors.length === 1) throw errors[0];
|
|
98
|
-
if (errors.length > 1) {
|
|
99
|
-
throw new AggregateError(errors, `${method} failed in ${errors.length} pms`);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
|
|
104
|
-
if (!cliPm) throw new TypeError('cliPm required');
|
|
105
|
-
if (typeof pickPmKindFor !== 'function') {
|
|
106
|
-
throw new TypeError('pickPmKindFor function required');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function routedPm(sessionKey) {
|
|
110
|
-
return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
pickFor: routedPm,
|
|
115
|
-
isSdkFor(sessionKey) {
|
|
116
|
-
return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
|
|
117
|
-
},
|
|
118
|
-
|
|
119
|
-
// Methods that exist on every pm instance — direct routing.
|
|
120
|
-
has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
|
|
121
|
-
get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
|
|
122
|
-
getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
|
|
123
|
-
send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
|
|
124
|
-
kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
|
|
125
|
-
|
|
126
|
-
// Lifecycle methods broadcast to both pms because a chat may
|
|
127
|
-
// have spawned sessions on either side at different times.
|
|
128
|
-
// Promise.allSettled (NOT Promise.all) so a rejection from one
|
|
129
|
-
// pm doesn't abandon the other mid-tear-down. Both must always
|
|
130
|
-
// complete; we then surface aggregated errors. Pre-fix, a cliPm
|
|
131
|
-
// rejection let sdkPm's Query.close() get GC'd with handles
|
|
132
|
-
// still open.
|
|
133
|
-
killChat(chatId) {
|
|
134
|
-
const tasks = [['cli', () => cliPm.killChat(chatId)]];
|
|
135
|
-
if (sdkPm) tasks.push(['sdk', () => sdkPm.killChat(chatId)]);
|
|
136
|
-
return broadcastSettle('killChat', tasks);
|
|
137
|
-
},
|
|
138
|
-
shutdown() {
|
|
139
|
-
const tasks = [['cli', () => cliPm.shutdown()]];
|
|
140
|
-
if (sdkPm) tasks.push(['sdk', () => sdkPm.shutdown()]);
|
|
141
|
-
return broadcastSettle('shutdown', tasks);
|
|
142
|
-
},
|
|
143
|
-
|
|
144
|
-
// Optional methods — forward when the routed pm implements
|
|
145
|
-
// them, return a documented sentinel otherwise. Use
|
|
146
|
-
// `pm.pickFor(sessionKey)` for proper feature detection at
|
|
147
|
-
// call sites that need to branch on capability.
|
|
148
|
-
steer(sessionKey, ...args) {
|
|
149
|
-
const target = routedPm(sessionKey);
|
|
150
|
-
return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
|
|
151
|
-
},
|
|
152
|
-
// rc.42: native autosteer / queue. CLI pm doesn't have an
|
|
153
|
-
// input-controller push primitive (the binary's stream-json
|
|
154
|
-
// input is one-shot per pm.send), so it returns false. SDK pm
|
|
155
|
-
// forwards to its inject implementation.
|
|
156
|
-
injectUserMessage(sessionKey, opts) {
|
|
157
|
-
const target = routedPm(sessionKey);
|
|
158
|
-
return typeof target.injectUserMessage === 'function'
|
|
159
|
-
? target.injectUserMessage(sessionKey, opts)
|
|
160
|
-
: false;
|
|
161
|
-
},
|
|
162
|
-
resetSession(sessionKey, opts) {
|
|
163
|
-
const target = routedPm(sessionKey);
|
|
164
|
-
return typeof target.resetSession === 'function'
|
|
165
|
-
? target.resetSession(sessionKey, opts)
|
|
166
|
-
: Promise.resolve({ closed: false, drainedPendings: 0 });
|
|
167
|
-
},
|
|
168
|
-
applyFlagSettings(sessionKey, settings) {
|
|
169
|
-
const target = routedPm(sessionKey);
|
|
170
|
-
return typeof target.applyFlagSettings === 'function'
|
|
171
|
-
? target.applyFlagSettings(sessionKey, settings)
|
|
172
|
-
: Promise.resolve(false);
|
|
173
|
-
},
|
|
174
|
-
setModel(sessionKey, model) {
|
|
175
|
-
const target = routedPm(sessionKey);
|
|
176
|
-
return typeof target.setModel === 'function'
|
|
177
|
-
? target.setModel(sessionKey, model)
|
|
178
|
-
: Promise.resolve(false);
|
|
179
|
-
},
|
|
180
|
-
requestRespawn(sessionKey, reason) {
|
|
181
|
-
const target = routedPm(sessionKey);
|
|
182
|
-
return typeof target.requestRespawn === 'function'
|
|
183
|
-
? target.requestRespawn(sessionKey, reason)
|
|
184
|
-
: { killed: false, queued: 0 };
|
|
185
|
-
},
|
|
186
|
-
drainQueue(sessionKey, errCode) {
|
|
187
|
-
const target = routedPm(sessionKey);
|
|
188
|
-
return typeof target.drainQueue === 'function'
|
|
189
|
-
? target.drainQueue(sessionKey, errCode)
|
|
190
|
-
: 0;
|
|
191
|
-
},
|
|
192
|
-
interrupt(sessionKey) {
|
|
193
|
-
const target = routedPm(sessionKey);
|
|
194
|
-
return typeof target.interrupt === 'function'
|
|
195
|
-
? target.interrupt(sessionKey)
|
|
196
|
-
: Promise.resolve();
|
|
197
|
-
},
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
module.exports = { makeRouterPolicy, createPmRouter };
|