polygram 0.8.0-rc.2 → 0.8.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/agent-loader.js +219 -64
- package/lib/approval-ui.js +135 -0
- package/lib/autosteer-buffer.js +131 -0
- package/lib/canonical-json.js +44 -0
- package/lib/error-classify.js +38 -9
- package/lib/history-preload.js +160 -0
- package/lib/pm-interface.js +95 -0
- package/lib/pm-router.js +159 -0
- package/lib/process-manager-sdk.js +32 -1
- package/lib/process-manager.js +13 -0
- package/lib/status-reactions.js +70 -19
- package/package.json +1 -1
- package/polygram.js +412 -204
package/lib/error-classify.js
CHANGED
|
@@ -97,7 +97,10 @@ const USER_MESSAGES = {
|
|
|
97
97
|
missingToolInput: '⚠️ Session history looks corrupted. Try /new.',
|
|
98
98
|
timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
|
|
99
99
|
format: '⚠️ Invalid request format. Try rephrasing or /new.',
|
|
100
|
-
|
|
100
|
+
// Used both for in-flight retry attempts AND for the post-retry-failed
|
|
101
|
+
// bubble-up message. Avoid promising "retrying once" since by the
|
|
102
|
+
// time the user reads it pm has already retried and given up.
|
|
103
|
+
transient5xx: '☁️ Server hiccup — please try again in a moment.',
|
|
101
104
|
};
|
|
102
105
|
|
|
103
106
|
// Auto-recovery actions for kinds where the session is irrecoverable
|
|
@@ -183,15 +186,16 @@ function classify(err) {
|
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
// SDKAssistantMessage.error is a short string code from a fixed
|
|
186
|
-
// union — match those directly, not via regex.
|
|
189
|
+
// union — match those directly, not via regex. Result subtypes
|
|
190
|
+
// are checked LATER (after pattern matching) so a more-specific
|
|
191
|
+
// pattern in the message text (e.g. 'HTTP 401' inside an
|
|
192
|
+
// error_during_execution subtype) wins over the generic subtype
|
|
193
|
+
// mapping that defaults the entire error_during_execution class
|
|
194
|
+
// to transient.
|
|
187
195
|
if (typeof err === 'string') {
|
|
188
196
|
const sdkMessageError = matchSdkMessageError(err);
|
|
189
197
|
if (sdkMessageError) return sdkMessageError;
|
|
190
198
|
}
|
|
191
|
-
if (err?.subtype && typeof err.subtype === 'string') {
|
|
192
|
-
const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
|
|
193
|
-
if (sdkResultSubtype) return sdkResultSubtype;
|
|
194
|
-
}
|
|
195
199
|
|
|
196
200
|
const msg = extractMessage(err);
|
|
197
201
|
for (const [kind, re] of Object.entries(PATTERNS)) {
|
|
@@ -205,6 +209,20 @@ function classify(err) {
|
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
211
|
|
|
212
|
+
// After pattern matching: try SDK result subtypes. A bare string
|
|
213
|
+
// like 'error_during_execution' (no message context) lands here
|
|
214
|
+
// and gets the friendly transient5xx kind. Object inputs with a
|
|
215
|
+
// subtype field also land here when their message text didn't
|
|
216
|
+
// match a more specific pattern.
|
|
217
|
+
if (typeof err === 'string') {
|
|
218
|
+
const sdkResultSubtype = matchSdkResultSubtype(err);
|
|
219
|
+
if (sdkResultSubtype) return sdkResultSubtype;
|
|
220
|
+
}
|
|
221
|
+
if (err?.subtype && typeof err.subtype === 'string') {
|
|
222
|
+
const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
|
|
223
|
+
if (sdkResultSubtype) return sdkResultSubtype;
|
|
224
|
+
}
|
|
225
|
+
|
|
208
226
|
// Fall-through: surface a snippet of the raw error so users at
|
|
209
227
|
// least know SOMETHING happened. Same shape as before, just
|
|
210
228
|
// routed through the classifier so callers get a uniform return.
|
|
@@ -252,8 +270,15 @@ function matchSdkMessageError(s) {
|
|
|
252
270
|
|
|
253
271
|
// SDKResultMessage.subtype values (sdk.d.ts:3121). Most are
|
|
254
272
|
// terminal-error indicators that don't have a clean pattern equivalent.
|
|
273
|
+
//
|
|
274
|
+
// `error_during_execution` is the SDK's catch-all for "something went
|
|
275
|
+
// wrong mid-turn" — could be a transient stream/network blip OR a
|
|
276
|
+
// systemic model issue. We treat it as transient (1 retry is cheap;
|
|
277
|
+
// if it's systemic the second attempt fails fast). Pre-rc.5 this was
|
|
278
|
+
// mapped to 'unknown' which fell through to the default "Hit a snag:
|
|
279
|
+
// error_during_execution" template — leaking the SDK enum to users.
|
|
255
280
|
const SDK_RESULT_SUBTYPE_MAP = {
|
|
256
|
-
error_during_execution: '
|
|
281
|
+
error_during_execution: 'transient5xx',
|
|
257
282
|
error_max_turns: 'format',
|
|
258
283
|
error_max_budget_usd: 'billing',
|
|
259
284
|
error_max_structured_output_retries: 'format',
|
|
@@ -265,8 +290,12 @@ function matchSdkResultSubtype(s) {
|
|
|
265
290
|
return {
|
|
266
291
|
kind,
|
|
267
292
|
userMessage: USER_MESSAGES[kind] ?? null,
|
|
268
|
-
|
|
269
|
-
|
|
293
|
+
// Derive transience from the kind so error_during_execution →
|
|
294
|
+
// transient5xx → isTransient=true, matching the pattern-match
|
|
295
|
+
// branch's behaviour. pm guards retry with firstAssistantSeen=
|
|
296
|
+
// false, which prevents budget waste when the turn already had
|
|
297
|
+
// billable assistant output.
|
|
298
|
+
isTransient: kind === 'transient5xx' || kind === 'rateLimit',
|
|
270
299
|
autoRecover: AUTO_RECOVER[kind] ?? null,
|
|
271
300
|
};
|
|
272
301
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart hook factory: preloads recent chat history into a
|
|
3
|
+
* fresh SDK Query so the agent has context on day-zero.
|
|
4
|
+
*
|
|
5
|
+
* Why: when polygram spawns a brand-new Query for a chat (daemon
|
|
6
|
+
* boot, /new, /reset), the SDK has no transcript — the model
|
|
7
|
+
* starts blank even though the chat has been running for weeks.
|
|
8
|
+
* The user has to re-explain context every time. This hook injects
|
|
9
|
+
* the last N polygram-stored messages into the new session's
|
|
10
|
+
* `additionalContext`, plus a hint that the agent can query the
|
|
11
|
+
* history skill for older messages it didn't get preloaded.
|
|
12
|
+
*
|
|
13
|
+
* Fires only when SessionStart's `source` is 'startup' or 'clear'
|
|
14
|
+
* (genuinely fresh sessions). Skips on 'resume' (SDK is restoring
|
|
15
|
+
* the prior transcript) and 'compact' (SDK just compacted; history
|
|
16
|
+
* is already in the post-compact summary).
|
|
17
|
+
*
|
|
18
|
+
* Reuses lib/history.js's `recent()` helper — same DB query the
|
|
19
|
+
* polygram history skill exposes via CLI, so the agent's skill
|
|
20
|
+
* invocations and our preload return consistent shapes.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const history = require('./history');
|
|
26
|
+
|
|
27
|
+
const DEFAULT_PRELOAD_LIMIT = 15;
|
|
28
|
+
const DEFAULT_PRELOAD_SINCE = '7d';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format a single message row as a transcript line.
|
|
32
|
+
*
|
|
33
|
+
* [2026-04-30 09:15] Ivan Shumkov: hello
|
|
34
|
+
* [2026-04-30 09:16] bot: hey
|
|
35
|
+
*
|
|
36
|
+
* Schema notes: messages table uses `direction` = 'in'|'out',
|
|
37
|
+
* `user` for the sender display name (inbound) or bot identity
|
|
38
|
+
* (outbound). reply_to_id is on the row directly. Attachment and
|
|
39
|
+
* voice flags live on the attachments table via JOIN — not
|
|
40
|
+
* surfaced here in the preload (operator-curated history docs are
|
|
41
|
+
* the place for that level of detail).
|
|
42
|
+
*/
|
|
43
|
+
function formatRow(row) {
|
|
44
|
+
const ts = new Date(row.ts).toISOString().replace('T', ' ').slice(0, 16);
|
|
45
|
+
const who = row.direction === 'in'
|
|
46
|
+
? (row.user || row.user_id || 'user')
|
|
47
|
+
: (row.user || row.bot_name || 'bot');
|
|
48
|
+
const prefix = row.reply_to_id ? `[reply→#${row.reply_to_id}] ` : '';
|
|
49
|
+
const text = (row.text || '').replace(/\s+/g, ' ').slice(0, 600);
|
|
50
|
+
return `[${ts}] ${who}: ${prefix}${text}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the SessionStart hook callback.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} opts
|
|
57
|
+
* @param {object} opts.db polygram db wrapper (has .raw better-sqlite3 instance)
|
|
58
|
+
* @param {string} opts.chatId the chat being spawned
|
|
59
|
+
* @param {string|null} [opts.threadId]
|
|
60
|
+
* @param {string[]} [opts.allowedChatIds] scope-narrowing safety; defaults to [chatId]
|
|
61
|
+
* @param {number} [opts.limit] max messages to preload (default 15)
|
|
62
|
+
* @param {string} [opts.since] cutoff window (default '7d')
|
|
63
|
+
* @param {(kind: string, detail: object) => void} [opts.logEvent]
|
|
64
|
+
* @param {object} [opts.logger]
|
|
65
|
+
*
|
|
66
|
+
* @returns {async (input) => Promise<HookJSONOutput>}
|
|
67
|
+
*/
|
|
68
|
+
function makeSessionStartHook({
|
|
69
|
+
db,
|
|
70
|
+
chatId,
|
|
71
|
+
threadId = null,
|
|
72
|
+
allowedChatIds = null,
|
|
73
|
+
limit = DEFAULT_PRELOAD_LIMIT,
|
|
74
|
+
since = DEFAULT_PRELOAD_SINCE,
|
|
75
|
+
logEvent = null,
|
|
76
|
+
logger = console,
|
|
77
|
+
} = {}) {
|
|
78
|
+
if (!db || !db.raw) throw new TypeError('db (with .raw better-sqlite3) required');
|
|
79
|
+
if (!chatId) throw new TypeError('chatId required');
|
|
80
|
+
|
|
81
|
+
return async (input) => {
|
|
82
|
+
try {
|
|
83
|
+
// Skip on resume / compact — transcript already has history.
|
|
84
|
+
if (input?.source === 'resume' || input?.source === 'compact') {
|
|
85
|
+
return { continue: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const scope = allowedChatIds || [String(chatId)];
|
|
89
|
+
let rows;
|
|
90
|
+
try {
|
|
91
|
+
// history.recent() expects the polygram db wrapper (it
|
|
92
|
+
// calls db.raw.prepare internally), not the raw bsqlite3.
|
|
93
|
+
rows = history.recent(db, {
|
|
94
|
+
chatId: String(chatId),
|
|
95
|
+
threadId: threadId ?? null,
|
|
96
|
+
limit,
|
|
97
|
+
since,
|
|
98
|
+
includeOutbound: true,
|
|
99
|
+
allowedChatIds: scope,
|
|
100
|
+
}) || [];
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger?.error?.(`[history-preload] recent() failed: ${err?.message || err}`);
|
|
103
|
+
return { continue: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (rows.length === 0) {
|
|
107
|
+
return { continue: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// history.recent() returns rows in chronological order
|
|
111
|
+
// already (it does `ORDER BY ts DESC LIMIT N` then `.reverse()`
|
|
112
|
+
// internally — see lib/history.js:69).
|
|
113
|
+
const lines = rows.map(formatRow).join('\n');
|
|
114
|
+
|
|
115
|
+
const additionalContext = [
|
|
116
|
+
`<polygram-history chat_id="${chatId}"${threadId ? ` thread_id="${threadId}"` : ''} preloaded="${rows.length}" since="${since}">`,
|
|
117
|
+
lines,
|
|
118
|
+
`</polygram-history>`,
|
|
119
|
+
'',
|
|
120
|
+
'— More history available via `node skills/history/scripts/query.js`:',
|
|
121
|
+
' recent <chat_id> [thread_id] --limit N (older than the preload window)',
|
|
122
|
+
' around --chat <id> --msg-id N (context window around a message)',
|
|
123
|
+
' search <term> [chat_id] (FTS5 across full transcript)',
|
|
124
|
+
' by-user <name> [chat_id] [thread_id]',
|
|
125
|
+
' Bot scope is auto-resolved from cwd; no admin flag needed.',
|
|
126
|
+
].join('\n');
|
|
127
|
+
|
|
128
|
+
if (typeof logEvent === 'function') {
|
|
129
|
+
try {
|
|
130
|
+
logEvent('history-preloaded', {
|
|
131
|
+
chat_id: chatId,
|
|
132
|
+
session_source: input?.source ?? 'startup',
|
|
133
|
+
row_count: rows.length,
|
|
134
|
+
text_len: additionalContext.length,
|
|
135
|
+
});
|
|
136
|
+
} catch { /* swallow logger errors */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
continue: true,
|
|
141
|
+
hookSpecificOutput: {
|
|
142
|
+
hookEventName: 'SessionStart',
|
|
143
|
+
additionalContext,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger?.error?.(`[history-preload] hook error: ${err?.message || err}`);
|
|
148
|
+
// Never throw out of a hook.
|
|
149
|
+
return { continue: true };
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
makeSessionStartHook,
|
|
156
|
+
// Internals for tests
|
|
157
|
+
_formatRow: formatRow,
|
|
158
|
+
DEFAULT_PRELOAD_LIMIT,
|
|
159
|
+
DEFAULT_PRELOAD_SINCE,
|
|
160
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Pm interface (JSDoc typedef).
|
|
3
|
+
*
|
|
4
|
+
* Both `lib/process-manager.js` (CLI pm) and `lib/process-manager-sdk.js`
|
|
5
|
+
* (SDK pm) implement this. `lib/pm-router.js`'s `createPmRouter()`
|
|
6
|
+
* forwards calls to one or the other based on per-chat policy.
|
|
7
|
+
*
|
|
8
|
+
* Optional methods are marked `?` — the router exposes them too but
|
|
9
|
+
* returns documented sentinels when the routed pm doesn't implement
|
|
10
|
+
* them. Sites that need to feature-detect should call
|
|
11
|
+
* `pm.pickFor(sessionKey)` and probe `typeof X === 'function'` on
|
|
12
|
+
* the returned pm instance, not on the router.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {object} PmEntry
|
|
15
|
+
* The shape pm.get(sessionKey) returns. Different pms decorate it
|
|
16
|
+
* with their own internal fields; only the documented fields below
|
|
17
|
+
* are part of the public contract.
|
|
18
|
+
* @property {string} sessionKey
|
|
19
|
+
* @property {string|null} chatId
|
|
20
|
+
* @property {string|null} threadId
|
|
21
|
+
* @property {boolean} closed
|
|
22
|
+
* @property {boolean} inFlight
|
|
23
|
+
* @property {Array<object>} pendingQueue — array of pending sends
|
|
24
|
+
*
|
|
25
|
+
* @typedef {object} PmSendResult
|
|
26
|
+
* The shape pm.send() resolves with on success. Failure rejects.
|
|
27
|
+
* @property {string} text — final assistant text (may be '')
|
|
28
|
+
* @property {string|null} sessionId — Claude session id (for resume)
|
|
29
|
+
* @property {number} cost — total cost USD
|
|
30
|
+
* @property {number} duration — turn duration ms
|
|
31
|
+
* @property {string|null} error — error string or null on success
|
|
32
|
+
* @property {object} metrics — token / tool / msg counts
|
|
33
|
+
* @property {number} metrics.inputTokens
|
|
34
|
+
* @property {number} metrics.outputTokens
|
|
35
|
+
* @property {number} metrics.cacheCreationTokens
|
|
36
|
+
* @property {number} metrics.cacheReadTokens
|
|
37
|
+
* @property {number} metrics.numAssistantMessages
|
|
38
|
+
* @property {number} metrics.numToolUses
|
|
39
|
+
* @property {string|null} metrics.resultSubtype
|
|
40
|
+
*
|
|
41
|
+
* @typedef {object} PmSendOptions
|
|
42
|
+
* @property {number} [timeoutMs]
|
|
43
|
+
* @property {number} [maxTurnMs]
|
|
44
|
+
* @property {object} [context] — opaque per-turn state (streamer, reactor, sourceMsgId)
|
|
45
|
+
*
|
|
46
|
+
* @typedef {object} PmSpawnContext
|
|
47
|
+
* What polygram passes to spawnFn(sessionKey, ctx). Internal to
|
|
48
|
+
* each pm but documented here so callers know what's available.
|
|
49
|
+
* @property {string|null} chatId
|
|
50
|
+
* @property {string|null} threadId
|
|
51
|
+
* @property {string} label
|
|
52
|
+
*
|
|
53
|
+
* @typedef {object} Pm
|
|
54
|
+
* The unified ProcessManager interface. Both CLI and SDK pm
|
|
55
|
+
* implement these; the router forwards.
|
|
56
|
+
*
|
|
57
|
+
* Required (every pm has these):
|
|
58
|
+
* @property {(sessionKey: string) => boolean} has
|
|
59
|
+
* @property {(sessionKey: string) => PmEntry|null} get
|
|
60
|
+
* @property {(sessionKey: string, ctx: PmSpawnContext) => Promise<PmEntry>} getOrSpawn
|
|
61
|
+
* @property {(sessionKey: string, prompt: string, opts?: PmSendOptions) => Promise<PmSendResult>} send
|
|
62
|
+
* @property {(sessionKey: string) => Promise<void>} kill
|
|
63
|
+
* @property {(chatId: string|number) => Promise<void>} killChat
|
|
64
|
+
* — closes ALL sessions belonging to a chat (broadcast across topics).
|
|
65
|
+
* @property {() => Promise<void>} shutdown
|
|
66
|
+
* — graceful daemon-wide drain + close.
|
|
67
|
+
*
|
|
68
|
+
* Optional (only one of the two pms implements these — feature-detect):
|
|
69
|
+
* @property {((sessionKey: string, text: string, opts?: object) => boolean)=} steer
|
|
70
|
+
* — SDK pm only (rc.9 PostToolBatch hook drains buffer).
|
|
71
|
+
* @property {((sessionKey: string, model: string) => Promise<boolean>)=} setModel
|
|
72
|
+
* — SDK pm only (Query.setModel live).
|
|
73
|
+
* @property {((sessionKey: string, settings: {effortLevel?: string}) => Promise<boolean>)=} applyFlagSettings
|
|
74
|
+
* — SDK pm only (Query.applyFlagSettings live).
|
|
75
|
+
* @property {((sessionKey: string, mode: string) => Promise<boolean>)=} setPermissionMode
|
|
76
|
+
* — SDK pm only.
|
|
77
|
+
* @property {((sessionKey: string, reason?: string) => {killed: boolean, queued: number})=} requestRespawn
|
|
78
|
+
* — CLI pm only (drain pending queue then kill; respawn on next send).
|
|
79
|
+
* @property {((sessionKey: string, errCode: string) => number)=} drainQueue
|
|
80
|
+
* — SDK pm only (reject all queued pendings with errCode).
|
|
81
|
+
* @property {((sessionKey: string) => Promise<void>)=} interrupt
|
|
82
|
+
* — SDK pm only (Query.interrupt — non-destructive).
|
|
83
|
+
* @property {((sessionKey: string, opts?: {reason?: string}) => Promise<{closed: boolean, drainedPendings: number}>)=} resetSession
|
|
84
|
+
* — SDK pm only (close Query + clear sessionId from DB).
|
|
85
|
+
*
|
|
86
|
+
* Lifecycle introspection (for tests / debugging — not required
|
|
87
|
+
* to be present, but both current pms expose them):
|
|
88
|
+
* @property {() => string[]=} keys — sessionKey list
|
|
89
|
+
* @property {() => number=} size — number of live sessions
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
// This file is JSDoc-only; no runtime exports. It exists so editors
|
|
93
|
+
// + the JSDoc-aware test mocks reference a single canonical type.
|
|
94
|
+
|
|
95
|
+
module.exports = {};
|
package/lib/pm-router.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
|
|
77
|
+
if (!cliPm) throw new TypeError('cliPm required');
|
|
78
|
+
if (typeof pickPmKindFor !== 'function') {
|
|
79
|
+
throw new TypeError('pickPmKindFor function required');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function routedPm(sessionKey) {
|
|
83
|
+
return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
pickFor: routedPm,
|
|
88
|
+
isSdkFor(sessionKey) {
|
|
89
|
+
return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// Methods that exist on every pm instance — direct routing.
|
|
93
|
+
has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
|
|
94
|
+
get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
|
|
95
|
+
getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
|
|
96
|
+
send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
|
|
97
|
+
kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
|
|
98
|
+
|
|
99
|
+
// Lifecycle methods broadcast to both pms because a chat may
|
|
100
|
+
// have spawned sessions on either side at different times.
|
|
101
|
+
async killChat(chatId) {
|
|
102
|
+
const tasks = [cliPm.killChat(chatId)];
|
|
103
|
+
if (sdkPm) tasks.push(sdkPm.killChat(chatId));
|
|
104
|
+
await Promise.all(tasks);
|
|
105
|
+
},
|
|
106
|
+
async shutdown() {
|
|
107
|
+
const tasks = [cliPm.shutdown()];
|
|
108
|
+
if (sdkPm) tasks.push(sdkPm.shutdown());
|
|
109
|
+
await Promise.all(tasks);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// Optional methods — forward when the routed pm implements
|
|
113
|
+
// them, return a documented sentinel otherwise. Use
|
|
114
|
+
// `pm.pickFor(sessionKey)` for proper feature detection at
|
|
115
|
+
// call sites that need to branch on capability.
|
|
116
|
+
steer(sessionKey, ...args) {
|
|
117
|
+
const target = routedPm(sessionKey);
|
|
118
|
+
return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
|
|
119
|
+
},
|
|
120
|
+
resetSession(sessionKey, opts) {
|
|
121
|
+
const target = routedPm(sessionKey);
|
|
122
|
+
return typeof target.resetSession === 'function'
|
|
123
|
+
? target.resetSession(sessionKey, opts)
|
|
124
|
+
: Promise.resolve({ closed: false, drainedPendings: 0 });
|
|
125
|
+
},
|
|
126
|
+
applyFlagSettings(sessionKey, settings) {
|
|
127
|
+
const target = routedPm(sessionKey);
|
|
128
|
+
return typeof target.applyFlagSettings === 'function'
|
|
129
|
+
? target.applyFlagSettings(sessionKey, settings)
|
|
130
|
+
: Promise.resolve(false);
|
|
131
|
+
},
|
|
132
|
+
setModel(sessionKey, model) {
|
|
133
|
+
const target = routedPm(sessionKey);
|
|
134
|
+
return typeof target.setModel === 'function'
|
|
135
|
+
? target.setModel(sessionKey, model)
|
|
136
|
+
: Promise.resolve(false);
|
|
137
|
+
},
|
|
138
|
+
requestRespawn(sessionKey, reason) {
|
|
139
|
+
const target = routedPm(sessionKey);
|
|
140
|
+
return typeof target.requestRespawn === 'function'
|
|
141
|
+
? target.requestRespawn(sessionKey, reason)
|
|
142
|
+
: { killed: false, queued: 0 };
|
|
143
|
+
},
|
|
144
|
+
drainQueue(sessionKey, errCode) {
|
|
145
|
+
const target = routedPm(sessionKey);
|
|
146
|
+
return typeof target.drainQueue === 'function'
|
|
147
|
+
? target.drainQueue(sessionKey, errCode)
|
|
148
|
+
: 0;
|
|
149
|
+
},
|
|
150
|
+
interrupt(sessionKey) {
|
|
151
|
+
const target = routedPm(sessionKey);
|
|
152
|
+
return typeof target.interrupt === 'function'
|
|
153
|
+
? target.interrupt(sessionKey)
|
|
154
|
+
: Promise.resolve();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { makeRouterPolicy, createPmRouter };
|
|
@@ -168,6 +168,18 @@ function makeInputController({ queueCap = DEFAULT_QUEUE_CAP } = {}) {
|
|
|
168
168
|
|
|
169
169
|
// ─── ProcessManager ────────────────────────────────────────────────
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* @anthropic-ai/claude-agent-sdk-backed ProcessManager. Implements
|
|
173
|
+
* the canonical Pm interface (`lib/pm-interface.js`). Optional
|
|
174
|
+
* methods exposed: `steer`, `setModel`, `applyFlagSettings`,
|
|
175
|
+
* `setPermissionMode`, `drainQueue`, `interrupt`, `resetSession`.
|
|
176
|
+
*
|
|
177
|
+
* Optional methods NOT implemented (CLI pm has this): `requestRespawn`.
|
|
178
|
+
* For mid-session config changes use `applyFlagSettings` (effort)
|
|
179
|
+
* or `setModel`.
|
|
180
|
+
*
|
|
181
|
+
* @implements {import('./pm-interface.js').Pm}
|
|
182
|
+
*/
|
|
171
183
|
class ProcessManagerSdk {
|
|
172
184
|
constructor({
|
|
173
185
|
cap = DEFAULT_CAP,
|
|
@@ -470,6 +482,7 @@ class ProcessManagerSdk {
|
|
|
470
482
|
entry.inputController.push({
|
|
471
483
|
type: 'user',
|
|
472
484
|
message: { role: 'user', content: head.prompt },
|
|
485
|
+
parent_tool_use_id: null,
|
|
473
486
|
});
|
|
474
487
|
} catch (err) {
|
|
475
488
|
entry.pendingQueue.shift();
|
|
@@ -655,6 +668,7 @@ class ProcessManagerSdk {
|
|
|
655
668
|
entry.inputController.push({
|
|
656
669
|
type: 'user',
|
|
657
670
|
message: { role: 'user', content: prompt },
|
|
671
|
+
parent_tool_use_id: null,
|
|
658
672
|
});
|
|
659
673
|
} catch (err) {
|
|
660
674
|
const idx = entry.pendingQueue.indexOf(pending);
|
|
@@ -754,13 +768,30 @@ class ProcessManagerSdk {
|
|
|
754
768
|
* Returns true if push succeeded; false if session not found or
|
|
755
769
|
* input controller closed.
|
|
756
770
|
*/
|
|
757
|
-
steer(sessionKey, text, { shouldQuery =
|
|
771
|
+
steer(sessionKey, text, { shouldQuery = false } = {}) {
|
|
758
772
|
const entry = this.procs.get(sessionKey);
|
|
759
773
|
if (!entry || entry.closed) return false;
|
|
760
774
|
try {
|
|
775
|
+
// 0.8.0-rc.7 (per v4 plan §0 row 9 + Phase 2 step 1's original
|
|
776
|
+
// shape): push with `shouldQuery: false` so the SDK appends to
|
|
777
|
+
// the transcript without trying to terminate the in-flight turn.
|
|
778
|
+
// The previous default `shouldQuery: true` triggered the CLI
|
|
779
|
+
// binary's `m87` gate (transcript well-formedness check) which
|
|
780
|
+
// emitted `result.subtype = error_during_execution` whenever a
|
|
781
|
+
// plain-text user message arrived while the assistant was mid-
|
|
782
|
+
// tool-use. With shouldQuery=false the message merges into the
|
|
783
|
+
// next natural user turn — the in-flight tools complete first,
|
|
784
|
+
// then the assistant sees the steered context.
|
|
785
|
+
//
|
|
786
|
+
// parent_tool_use_id is required by SDKUserMessage type
|
|
787
|
+
// (sdk.d.ts:3479-3498). The SDK runtime checks `!== null` in
|
|
788
|
+
// multiple places; omitting it falls through to wrong handling
|
|
789
|
+
// branches. The SDK's own `mz.send()` and `pz` replay set it
|
|
790
|
+
// to null explicitly.
|
|
761
791
|
entry.inputController.push({
|
|
762
792
|
type: 'user',
|
|
763
793
|
message: { role: 'user', content: text },
|
|
794
|
+
parent_tool_use_id: null,
|
|
764
795
|
priority: 'now',
|
|
765
796
|
shouldQuery,
|
|
766
797
|
});
|
package/lib/process-manager.js
CHANGED
|
@@ -93,6 +93,19 @@ function sumUsage(usageByMessage) {
|
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Stream-json CLI-backed ProcessManager. Implements the canonical
|
|
98
|
+
* Pm interface (`lib/pm-interface.js`). Optional methods exposed:
|
|
99
|
+
* `requestRespawn` — drain queue and respawn process on next send
|
|
100
|
+
* (kept for parity with rc.6+ feature-detection at the router; SDK
|
|
101
|
+
* pm uses `applyFlagSettings` + `setModel` for the same UX).
|
|
102
|
+
*
|
|
103
|
+
* Optional methods NOT implemented (SDK pm has these): `steer`,
|
|
104
|
+
* `setModel`, `applyFlagSettings`, `setPermissionMode`,
|
|
105
|
+
* `drainQueue`, `interrupt`, `resetSession`.
|
|
106
|
+
*
|
|
107
|
+
* @implements {import('./pm-interface.js').Pm}
|
|
108
|
+
*/
|
|
96
109
|
class ProcessManager {
|
|
97
110
|
constructor({
|
|
98
111
|
cap = DEFAULT_CAP,
|