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.
@@ -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
- transient5xx: '☁️ Anthropic is temporarily unavailable. Retrying once…',
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: 'unknown',
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
- isTransient: false, // result subtypes don't auto-retry; the
269
- // turn already burned its budget.
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 = {};
@@ -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 = true } = {}) {
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
  });
@@ -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,