polygram 0.8.0-rc.8 → 0.8.0

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.
@@ -3,18 +3,26 @@
3
3
  * v4 plan §6.5.5).
4
4
  *
5
5
  * Background: today's CLI pm passes `--agent <name>` on spawn; the
6
- * Claude CLI then loads that agent's directory under
7
- * `~/.claude/agents/<name>/` (system prompt from `CLAUDE.md`,
8
- * skills, mcpServers from settings.json). Phase 0 gate 15 is DEFER
9
- * the SDK's `Options.agents` is for in-memory subagent definitions
10
- * (the Task tool), NOT a "run THIS query AS this agent" mechanism.
6
+ * Claude CLI then loads that agent's content. Phase 0 gate 15 was
7
+ * DEFER the SDK's `Options.agents` is for in-memory subagent
8
+ * definitions (the Task tool), NOT a "run THIS query AS this agent"
9
+ * mechanism. So polygram reads the agent file itself and passes its
10
+ * content as `systemPrompt`.
11
11
  *
12
- * This module provides a polygram-side loader so buildSdkOptions
13
- * can compose the per-chat agent's settings into the chat's
14
- * SdkOptions: read the agent's CLAUDE.md (system prompt), enumerate
15
- * its skills, pick up its mcpServers from settings.json. Then merge
16
- * into the per-chat SdkOptions with chat-level overrides taking
17
- * precedence (chatConfig wins over agent wins over defaults).
12
+ * Search order (rc.13+ supports BOTH Claude Code's standard
13
+ * single-file convention AND polygram's pre-0.8.0 directory layout):
14
+ *
15
+ * 1. `<cwd>/.claude/agents/<name>.md` — Claude Code project-level
16
+ * 2. `<homeDir>/.claude/agents/<name>.md` — Claude Code user-level
17
+ * 3. `<cwd>/.claude/agents/<name>/CLAUDE.md` — polygram convention
18
+ * (also `AGENTS.md`, `system-prompt.txt`)
19
+ * 4. `<homeDir>/.claude/agents/<name>/CLAUDE.md` — polygram legacy
20
+ * (also `AGENTS.md`, `system-prompt.txt`)
21
+ *
22
+ * Single-file Claude Code agents may have YAML frontmatter; we strip
23
+ * it before using the body as systemPrompt. Frontmatter `model` /
24
+ * `effort` are merged into the bundle.raw so composeSdkOptions can
25
+ * use them as agent-level defaults.
18
26
  *
19
27
  * Used by `polygram.js` `buildSdkOptions(sessionKey, ctx)` —
20
28
  * Phase 1 step 14.
@@ -31,106 +39,330 @@
31
39
  const fs = require('fs');
32
40
  const path = require('path');
33
41
 
34
- const cache = new Map(); // agentName → AgentBundle
42
+ const cache = new Map(); // cacheKey → AgentBundle
43
+
44
+ // Resolve agent file by checking each search path in order.
45
+ // Returns { kind: 'file'|'dir', path, dir | null } or null.
46
+ // Restrict agent names to a conservative charset so they can't
47
+ // path-traverse out of the `.claude/agents/` directory. Pre-fix, an
48
+ // agent name like `../../etc/passwd` silently resolved to whatever
49
+ // existed at that path, loading arbitrary file content as the
50
+ // system prompt. Chat configs are operator-controlled (not user
51
+ // input), so the practical threat is operator typos — but pinning
52
+ // the contract removes the foot-gun.
53
+ //
54
+ // Allowed: alphanumerics, hyphen, underscore, single dots inside
55
+ // (e.g. "shumabit-finance.v2"). Forbidden: leading/trailing dot,
56
+ // consecutive dots, slashes, NUL.
57
+ const AGENT_NAME_RE = /^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)*$/;
58
+
59
+ // rc.49: parse `<plugin>:<agent>` qualified names. Each side must
60
+ // independently satisfy AGENT_NAME_RE. Returns { plugin, agent } or
61
+ // null if not qualified or malformed.
62
+ function parseQualifiedName(name) {
63
+ if (typeof name !== 'string' || !name.includes(':')) return null;
64
+ const parts = name.split(':');
65
+ if (parts.length !== 2) return null;
66
+ const [plugin, agent] = parts;
67
+ if (!AGENT_NAME_RE.test(plugin) || !AGENT_NAME_RE.test(agent)) return null;
68
+ return { plugin, agent };
69
+ }
70
+
71
+ // rc.49: look up a plugin's installPath in
72
+ // ~/.claude/plugins/installed_plugins.json. Keys are
73
+ // `<name>@<marketplace>` — match by the bare `<name>` prefix and
74
+ // return the first installed entry's `installPath`. Returns null if
75
+ // not enrolled or registry unreadable.
76
+ function lookupInstalledPlugin(pluginName, homeDir) {
77
+ const registryPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
78
+ if (!fs.existsSync(registryPath)) return null;
79
+ let registry;
80
+ try {
81
+ registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
82
+ } catch {
83
+ return null;
84
+ }
85
+ const plugins = registry?.plugins || {};
86
+ const prefix = pluginName + '@';
87
+ for (const key of Object.keys(plugins)) {
88
+ if (key.startsWith(prefix)) {
89
+ const entries = plugins[key];
90
+ if (Array.isArray(entries) && entries[0]?.installPath) {
91
+ return entries[0].installPath;
92
+ }
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function resolveAgentLocation(agentName, homeDir, cwd) {
99
+ if (typeof agentName !== 'string') return null;
100
+
101
+ // rc.49: plugin-qualified `<plugin>:<agent>` names look up the plugin
102
+ // from the installed registry (with fallback to ~/.claude-plugins-local/).
103
+ // Resolution is intentionally NARROW — only the qualified form
104
+ // searches plugin directories. Plain unqualified names keep the
105
+ // pre-rc.49 ~/.claude/agents/ + <cwd>/.claude/agents/ behaviour.
106
+ const qualified = parseQualifiedName(agentName);
107
+ if (qualified) {
108
+ const { plugin, agent } = qualified;
109
+ const installPath = lookupInstalledPlugin(plugin, homeDir);
110
+ if (installPath) {
111
+ const p = path.join(installPath, 'agents', agent + '.md');
112
+ if (fs.existsSync(p)) return { kind: 'file', path: p, dir: null };
113
+ }
114
+ // Fallback: ~/.claude-plugins-local/<plugin>/agents/<agent>.md
115
+ const localPath = path.join(homeDir, '.claude-plugins-local', plugin, 'agents', agent + '.md');
116
+ if (fs.existsSync(localPath)) return { kind: 'file', path: localPath, dir: null };
117
+ return null;
118
+ }
119
+
120
+ if (!AGENT_NAME_RE.test(agentName)) return null;
121
+
122
+ const fileCandidates = [];
123
+ if (cwd) fileCandidates.push(path.join(cwd, '.claude', 'agents', agentName + '.md'));
124
+ fileCandidates.push(path.join(homeDir, '.claude', 'agents', agentName + '.md'));
125
+ for (const p of fileCandidates) {
126
+ if (fs.existsSync(p)) return { kind: 'file', path: p, dir: null };
127
+ }
128
+ const dirCandidates = [];
129
+ if (cwd) dirCandidates.push(path.join(cwd, '.claude', 'agents', agentName));
130
+ dirCandidates.push(path.join(homeDir, '.claude', 'agents', agentName));
131
+ for (const d of dirCandidates) {
132
+ if (fs.existsSync(d) && fs.statSync(d).isDirectory()) {
133
+ return { kind: 'dir', path: d, dir: d };
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
139
+ // Strip leading YAML frontmatter (---\n...\n---\n) from markdown.
140
+ function stripFrontmatter(content) {
141
+ if (typeof content !== 'string' || !content.startsWith('---\n')) return content;
142
+ const end = content.indexOf('\n---\n', 4);
143
+ if (end === -1) return content;
144
+ return content.slice(end + 5);
145
+ }
146
+
147
+ // Recursively expand Claude Code @<file> import directives. A line
148
+ // starting with `@<path>` is replaced with the file's contents
149
+ // (frontmatter stripped, imports recursively expanded). Paths
150
+ // resolve relative to the importing file's directory FIRST, then
151
+ // fall back to cwd. Cycle detection via visited Set.
152
+ //
153
+ // rc.15: pre-rc.15 the literal "@_shumabit-base.md" reached the
154
+ // model verbatim because polygram's loader didn't process imports.
155
+ // Symptom: agent appeared loaded but the system prompt was
156
+ // effectively empty (just an unresolved import directive).
157
+ function expandImports(content, importingFile, cwd, visited, logger) {
158
+ if (typeof content !== 'string' || !content) return content;
159
+ const lines = content.split('\n');
160
+ const out = [];
161
+ for (const line of lines) {
162
+ const m = /^@(\S+)\s*$/.exec(line);
163
+ if (!m) {
164
+ out.push(line);
165
+ continue;
166
+ }
167
+ const ref = m[1];
168
+ const importingDir = path.dirname(importingFile);
169
+ // Resolution order: relative to importing file's dir; relative
170
+ // to cwd; absolute path as-is.
171
+ const candidates = [];
172
+ if (path.isAbsolute(ref)) {
173
+ candidates.push(ref);
174
+ } else {
175
+ candidates.push(path.join(importingDir, ref));
176
+ if (cwd) candidates.push(path.join(cwd, ref));
177
+ }
178
+ let resolved = null;
179
+ for (const c of candidates) {
180
+ if (fs.existsSync(c)) { resolved = c; break; }
181
+ }
182
+ if (!resolved) {
183
+ logger?.warn?.(`[agent-loader] @-import not found: ${ref} (in ${importingFile})`);
184
+ out.push(line);
185
+ continue;
186
+ }
187
+ if (visited.has(resolved)) {
188
+ logger?.warn?.(`[agent-loader] @-import cycle: ${resolved}`);
189
+ continue;
190
+ }
191
+ visited.add(resolved);
192
+ let imported = '';
193
+ try {
194
+ imported = fs.readFileSync(resolved, 'utf8');
195
+ } catch (err) {
196
+ logger?.error?.(`[agent-loader] reading @-import ${resolved}: ${err.message}`);
197
+ out.push(line);
198
+ continue;
199
+ }
200
+ // Strip frontmatter from imported file (same convention as
201
+ // top-level agent file) and recursively expand its imports.
202
+ imported = stripFrontmatter(imported);
203
+ imported = expandImports(imported, resolved, cwd, visited, logger);
204
+ out.push(imported);
205
+ }
206
+ return out.join('\n');
207
+ }
208
+
209
+ // Parse a tiny subset of YAML frontmatter (key: value lines).
210
+ function parseFrontmatter(content) {
211
+ if (typeof content !== 'string' || !content.startsWith('---\n')) return {};
212
+ const end = content.indexOf('\n---\n', 4);
213
+ if (end === -1) return {};
214
+ const block = content.slice(4, end);
215
+ const out = {};
216
+ for (const line of block.split('\n')) {
217
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
218
+ if (!m) continue;
219
+ let v = m[2].trim();
220
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
221
+ v = v.slice(1, -1);
222
+ }
223
+ out[m[1]] = v;
224
+ }
225
+ return out;
226
+ }
35
227
 
36
228
  /**
37
229
  * Load an agent bundle from disk.
38
230
  *
39
- * @param {string} agentName — e.g. 'shumabit-finance'
231
+ * @param {string} agentName
40
232
  * @param {object} opts
41
233
  * @param {string} [opts.homeDir] — defaults to process.env.HOME.
42
- * Resolves agent at `${homeDir}/.claude/agents/${agentName}/`.
234
+ * @param {string} [opts.cwd] — chat's working directory; checked
235
+ * FIRST for Claude Code project-level agent discovery.
43
236
  * @param {object} [opts.logger] — error logger.
44
- *
45
- * @returns {AgentBundle}
46
- * { agentName, agentDir, systemPrompt, skills: string[],
47
- * mcpServers: object, raw: settingsJson }
48
- *
49
- * Throws `{ code: 'AGENT_NOT_FOUND' }` if the agent dir doesn't
50
- * exist. Does NOT throw on partial agents (missing CLAUDE.md or
51
- * skills/ etc — fields just default to null/empty).
52
237
  */
53
- function loadAgent(agentName, { homeDir = process.env.HOME, logger = console } = {}) {
54
- if (cache.has(agentName)) return cache.get(agentName);
238
+ function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger = console } = {}) {
239
+ // Cache key includes cwd because the same agentName can resolve
240
+ // to different files when called from different chats with
241
+ // different cwds (e.g. shumabit-claude vs shumabit-partners).
242
+ const cacheKey = agentName + '\x00' + (cwd || '');
243
+ if (cache.has(cacheKey)) return cache.get(cacheKey);
55
244
 
56
- const agentDir = path.join(homeDir, '.claude', 'agents', agentName);
57
- if (!fs.existsSync(agentDir)) {
245
+ const loc = resolveAgentLocation(agentName, homeDir, cwd);
246
+ if (!loc) {
247
+ const looked = [
248
+ cwd ? cwd + '/.claude/agents/' + agentName + '.md' : null,
249
+ homeDir + '/.claude/agents/' + agentName + '.md',
250
+ cwd ? cwd + '/.claude/agents/' + agentName + '/' : null,
251
+ homeDir + '/.claude/agents/' + agentName + '/',
252
+ ].filter(Boolean).join(', ');
58
253
  throw Object.assign(
59
- new Error(`agent not found: ${agentName} (looked in ${agentDir})`),
60
- { code: 'AGENT_NOT_FOUND', agentDir },
254
+ new Error('agent not found: ' + agentName + ' (looked in ' + looked + ')'),
255
+ { code: 'AGENT_NOT_FOUND', searchPaths: looked },
61
256
  );
62
257
  }
63
258
 
64
- // System prompt: prefer CLAUDE.md (the standard polygram convention),
65
- // fall back to AGENTS.md (OpenClaw legacy), then to a single-line
66
- // file `system-prompt.txt` if either of the markdown files is
67
- // absent. Whichever is present, read as UTF-8 string.
68
259
  let systemPrompt = null;
69
- for (const fname of ['CLAUDE.md', 'AGENTS.md', 'system-prompt.txt']) {
70
- const p = path.join(agentDir, fname);
71
- if (fs.existsSync(p)) {
72
- try {
73
- systemPrompt = fs.readFileSync(p, 'utf8');
74
- break;
75
- } catch (err) {
76
- logger.error?.(`[agent-loader] reading ${p}: ${err.message}`);
260
+ let frontmatter = {};
261
+ let agentPath = loc.path;
262
+
263
+ if (loc.kind === 'file') {
264
+ // Claude Code single-file format. Read whole file, parse and
265
+ // strip frontmatter, body becomes systemPrompt. Then expand
266
+ // any @<file> import directives recursively (rc.15).
267
+ try {
268
+ const raw = fs.readFileSync(loc.path, 'utf8');
269
+ frontmatter = parseFrontmatter(raw);
270
+ const stripped = stripFrontmatter(raw);
271
+ const visited = new Set([loc.path]);
272
+ systemPrompt = expandImports(stripped, loc.path, cwd, visited, logger);
273
+ } catch (err) {
274
+ logger.error?.('[agent-loader] reading ' + loc.path + ': ' + err.message);
275
+ }
276
+ } else {
277
+ // polygram directory layout. CLAUDE.md > AGENTS.md > system-prompt.txt.
278
+ for (const fname of ['CLAUDE.md', 'AGENTS.md', 'system-prompt.txt']) {
279
+ const p = path.join(loc.dir, fname);
280
+ if (fs.existsSync(p)) {
281
+ try {
282
+ const raw = fs.readFileSync(p, 'utf8');
283
+ // Expand @-imports for directory-layout agents too —
284
+ // their content might also reference shared base files.
285
+ const visited = new Set([p]);
286
+ systemPrompt = expandImports(raw, p, cwd, visited, logger);
287
+ agentPath = p;
288
+ break;
289
+ } catch (err) {
290
+ logger.error?.('[agent-loader] reading ' + p + ': ' + err.message);
291
+ }
77
292
  }
78
293
  }
79
294
  }
80
295
 
81
- // Settings: optional `settings.json` for per-agent overrides
82
- // (mcpServers, model, effort defaults, etc.).
296
+ // Settings.json — only meaningful for directory-layout agents.
83
297
  let settings = {};
84
- const settingsPath = path.join(agentDir, 'settings.json');
85
- if (fs.existsSync(settingsPath)) {
86
- try {
87
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
88
- } catch (err) {
89
- logger.error?.(`[agent-loader] parsing ${settingsPath}: ${err.message}`);
298
+ if (loc.dir) {
299
+ const settingsPath = path.join(loc.dir, 'settings.json');
300
+ if (fs.existsSync(settingsPath)) {
301
+ try {
302
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
303
+ } catch (err) {
304
+ logger.error?.('[agent-loader] parsing ' + settingsPath + ': ' + err.message);
305
+ }
90
306
  }
91
307
  }
92
308
 
93
- // Skills: enumerate `${agentDir}/skills/*` directories. SDK's
94
- // `Options.skills` accepts a string[] of skill names.
95
- const skillsDir = path.join(agentDir, 'skills');
309
+ // Skills (only for directory layout).
96
310
  let skills = [];
97
- if (fs.existsSync(skillsDir)) {
98
- try {
99
- skills = fs.readdirSync(skillsDir, { withFileTypes: true })
100
- .filter((d) => d.isDirectory())
101
- .map((d) => d.name);
102
- } catch (err) {
103
- logger.error?.(`[agent-loader] enumerating ${skillsDir}: ${err.message}`);
311
+ if (loc.dir) {
312
+ const skillsDir = path.join(loc.dir, 'skills');
313
+ if (fs.existsSync(skillsDir)) {
314
+ try {
315
+ skills = fs.readdirSync(skillsDir, { withFileTypes: true })
316
+ .filter((d) => d.isDirectory())
317
+ .map((d) => d.name);
318
+ } catch (err) {
319
+ logger.error?.('[agent-loader] enumerating ' + skillsDir + ': ' + err.message);
320
+ }
104
321
  }
105
322
  }
106
323
 
107
324
  const mcpServers = settings.mcpServers ?? {};
108
325
 
326
+ // Frontmatter merged with settings — composeSdkOptions can pick up
327
+ // model/effort overrides from either source.
328
+ const raw = { ...frontmatter, ...settings };
329
+
109
330
  const bundle = {
110
331
  agentName,
111
- agentDir,
332
+ agentPath,
333
+ agentDir: loc.dir,
112
334
  systemPrompt,
113
335
  skills,
114
336
  mcpServers,
115
- // Pass through extra settings for callers that want them
116
- // (e.g. agent-level model/effort defaults).
117
- raw: settings,
337
+ raw,
118
338
  };
119
- cache.set(agentName, bundle);
339
+ cache.set(cacheKey, bundle);
120
340
  return bundle;
121
341
  }
122
342
 
123
343
  /**
124
344
  * Compose a chat's final SdkOptions from defaults + agent + per-chat
125
- * overrides. Precedence: chatConfig > agent > defaults.
345
+ * overrides + per-topic overrides. Precedence (highest to lowest):
346
+ * topicConfig > chatConfig > agent.raw > defaults
347
+ *
348
+ * rc.48 added the per-topic layer (`topicConfig` arg). Per-topic
349
+ * overrides are the principal rc.48 use case — typically loosening
350
+ * an agent's `bypassPermissions` default to `default` for a sensitive
351
+ * topic (so canUseTool prompts fire there) while keeping the rest of
352
+ * the chat in bypass mode. Per-topic permissionMode MUST override the
353
+ * chat-level one for this to work.
126
354
  *
127
355
  * @param {object} chatConfig — config.chats[chatId].
128
356
  * @param {AgentBundle|null} agentBundle — null if chat has no agent.
129
357
  * @param {object} defaults — config.defaults.
358
+ * @param {object} [topicConfig] — per-topic overrides from
359
+ * getTopicConfig(chatConfig, threadId). Empty object when there's
360
+ * no active topic, no override config, or topic uses legacy string
361
+ * form. Highest precedence — overrides chatConfig.
130
362
  *
131
363
  * @returns {object} SdkOptions for `query({ options: ... })`.
132
364
  */
133
- function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
365
+ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}, topicConfig = {}) {
134
366
  // Start with defaults — these are the lowest-priority.
135
367
  const opts = { ...defaults };
136
368
 
@@ -141,16 +373,18 @@ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
141
373
  if (agentBundle.mcpServers && Object.keys(agentBundle.mcpServers).length) {
142
374
  opts.mcpServers = { ...(opts.mcpServers || {}), ...agentBundle.mcpServers };
143
375
  }
144
- // Agent-level model/effort/etc — only if chatConfig doesn't
145
- // override.
376
+ // Agent-level model/effort/etc — only if chatConfig AND
377
+ // topicConfig don't override.
146
378
  for (const key of ['model', 'effort', 'thinking', 'permissionMode']) {
147
- if (agentBundle.raw?.[key] != null && chatConfig[key] == null) {
379
+ if (agentBundle.raw?.[key] != null
380
+ && chatConfig[key] == null
381
+ && topicConfig?.[key] == null) {
148
382
  opts[key] = agentBundle.raw[key];
149
383
  }
150
384
  }
151
385
  }
152
386
 
153
- // Chat-level overrides (highest priority).
387
+ // Chat-level overrides.
154
388
  for (const [k, v] of Object.entries(chatConfig)) {
155
389
  if (v == null) continue;
156
390
  // Don't override the spread system-prompt with `agent` config
@@ -159,6 +393,18 @@ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
159
393
  opts[k] = v;
160
394
  }
161
395
 
396
+ // rc.48: per-topic overrides (highest priority). Same `agent` exclusion
397
+ // — `agent` here is a polygram name reference, NOT an SdkOptions
398
+ // field. polygram's spawn flow resolves topicConfig.agent into the
399
+ // correct agentBundle BEFORE calling composeSdkOptions, so by the
400
+ // time we get here, agentBundle already reflects the topic's agent
401
+ // choice and the `agent` string itself shouldn't leak into opts.
402
+ for (const [k, v] of Object.entries(topicConfig || {})) {
403
+ if (v == null) continue;
404
+ if (k === 'agent') continue;
405
+ opts[k] = v;
406
+ }
407
+
162
408
  return opts;
163
409
  }
164
410
 
@@ -166,4 +412,14 @@ function clearCache() {
166
412
  cache.clear();
167
413
  }
168
414
 
169
- module.exports = { loadAgent, composeSdkOptions, clearCache, _cache: cache };
415
+ module.exports = {
416
+ loadAgent,
417
+ composeSdkOptions,
418
+ clearCache,
419
+ // Internals for tests.
420
+ _resolveAgentLocation: resolveAgentLocation,
421
+ _stripFrontmatter: stripFrontmatter,
422
+ _parseFrontmatter: parseFrontmatter,
423
+ _expandImports: expandImports,
424
+ _cache: cache,
425
+ };
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pure UI builders for the approval flow's Telegram surface.
3
+ *
4
+ * - 2-button keyboard (CLI pm IPC approval-hook flow)
5
+ * - 4-button keyboard (rc.6 SDK pm canUseTool flow with persisted
6
+ * "Always allow / Always deny" via chat_tool_decisions)
7
+ * - Card text with friendly heading + clipped tool_input body
8
+ *
9
+ * No runtime dependencies — these are pure transforms suitable for
10
+ * unit-testing in isolation. The polygram.js side wires them to
11
+ * `tg(bot, 'sendMessage', ...)` / `editMessageText`.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ /**
17
+ * 2-button keyboard for the legacy IPC approval flow.
18
+ * @param {number|string} approvalId
19
+ * @param {string} token
20
+ */
21
+ function buildApprovalKeyboard(approvalId, token) {
22
+ return {
23
+ inline_keyboard: [[
24
+ { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
25
+ { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
26
+ ]],
27
+ };
28
+ }
29
+
30
+ /**
31
+ * 4-button keyboard for the SDK canUseTool flow (rc.6 Phase 2 step 6).
32
+ * "Always allow" / "Always deny" rows persist the decision into
33
+ * `chat_tool_decisions` so subsequent invocations of the same tool
34
+ * with the same input short-circuit.
35
+ *
36
+ * Callback_data conventions:
37
+ * approve:<id>:<token> — one-time allow
38
+ * deny:<id>:<token> — one-time deny
39
+ * approve-always:<id>:<token> — allow + persist
40
+ * deny-always:<id>:<token> — deny + persist
41
+ *
42
+ * @param {number|string} approvalId
43
+ * @param {string} token
44
+ */
45
+ function buildApprovalKeyboardWithAlways(approvalId, token) {
46
+ return {
47
+ inline_keyboard: [
48
+ [
49
+ { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
50
+ { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
51
+ ],
52
+ [
53
+ { text: '🔁 Always allow', callback_data: `approve-always:${approvalId}:${token}` },
54
+ { text: '🚫 Always deny', callback_data: `deny-always:${approvalId}:${token}` },
55
+ ],
56
+ ],
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Format a tool_input value for the inline-keyboard card body.
62
+ * Clips aggressively so the whole card stays under Telegram's
63
+ * 4096-char limit (approval card has surrounding metadata too).
64
+ *
65
+ * @param {unknown} input — string OR any JSON-able object
66
+ * @returns {string}
67
+ */
68
+ function formatToolInputForCard(input) {
69
+ let s;
70
+ try {
71
+ s = typeof input === 'string' ? input : JSON.stringify(input, null, 2);
72
+ } catch {
73
+ s = String(input);
74
+ }
75
+ // JSON.stringify(undefined) returns undefined, and objects with
76
+ // a circular toJSON could surface odd values too. Fall back to
77
+ // String() so we always operate on a real string.
78
+ if (typeof s !== 'string') s = String(input);
79
+ if (s.length <= 1200) return s;
80
+ return s.slice(0, 900) + '\n…[clipped]…\n' + s.slice(-200);
81
+ }
82
+
83
+ /**
84
+ * Approval card text. Plain-text only (NO parse_mode) — tool_input
85
+ * originates from Claude and could contain Markdown specials or
86
+ * tg:// links crafted for phishing.
87
+ *
88
+ * @param {object} row — approval row from the approvals store
89
+ * @param {string} row.tool_name
90
+ * @param {number|string|null} row.turn_id
91
+ * @param {string} row.requester_chat_id
92
+ * @param {object|string|null} [row.tool_input_json]
93
+ * @param {object|string|null} [row.tool_input] — alias for tool_input_json
94
+ * @param {number} row.timeout_ts — unix ms when the row expires
95
+ * @param {object} [opts]
96
+ * @param {string} [opts.resolvedBy] — heading override for resolved cards
97
+ * (e.g. "✓ Approved by ivan").
98
+ * When set, footer is dropped.
99
+ * When unset, heading is "Approval needed — <tool>"
100
+ * and footer shows seconds-to-expire.
101
+ * @param {() => number} [opts.now] — clock injection for tests
102
+ *
103
+ * @returns {string}
104
+ */
105
+ function approvalCardText(row, opts = {}) {
106
+ const now = (typeof opts.now === 'function' ? opts.now : Date.now)();
107
+ const heading = opts.resolvedBy
108
+ ? opts.resolvedBy
109
+ : `Approval needed — ${row.tool_name}`;
110
+ // tool_input may arrive as a parsed object OR a JSON string under
111
+ // either key name depending on the call site.
112
+ const inputSource = row.tool_input_json !== undefined
113
+ ? row.tool_input_json
114
+ : row.tool_input;
115
+ const parsed = typeof inputSource === 'string'
116
+ ? safeParse(inputSource)
117
+ : inputSource;
118
+ const body = formatToolInputForCard(parsed);
119
+ const ttl = Math.max(0, Math.round((row.timeout_ts - now) / 1000));
120
+ const footer = opts.resolvedBy ? '' : `\n\n⏱ expires in ${ttl}s`;
121
+ return `${heading}\nChat: ${row.requester_chat_id}\nTurn: ${row.turn_id || '-'}\n\n${body}${footer}`;
122
+ }
123
+
124
+ function safeParse(s) {
125
+ try { return JSON.parse(s); } catch { return s; }
126
+ }
127
+
128
+ module.exports = {
129
+ buildApprovalKeyboard,
130
+ buildApprovalKeyboardWithAlways,
131
+ formatToolInputForCard,
132
+ approvalCardText,
133
+ // Internals exposed for tests
134
+ _safeParse: safeParse,
135
+ };
@@ -110,6 +110,13 @@ function createApprovalWaiters({
110
110
  parkedAt: Date.now(),
111
111
  sessionKey,
112
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();
113
120
  });
114
121
  }
115
122
 
package/lib/approvals.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  const crypto = require('crypto');
14
+ const { canonicalizeToolInput } = require('./canonical-json');
14
15
 
15
16
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
16
17
  // 16 random bytes → 22 base64url chars ≈ 128 bits of entropy. Prevents
@@ -19,7 +20,14 @@ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
19
20
  const TOKEN_BYTES = 16;
20
21
 
21
22
  function digestInput(input) {
22
- const json = typeof input === 'string' ? input : JSON.stringify(input);
23
+ // Canonicalise object inputs so key-order doesn't change the digest.
24
+ // Pre-fix `JSON.stringify({a:1,b:2})` and `JSON.stringify({b:2,a:1})`
25
+ // produced different hashes — the dedup contract assumed logical
26
+ // equivalence but the impl was order-sensitive, so an SDK that
27
+ // re-serialised the input between turns would dedup-miss.
28
+ const json = typeof input === 'string'
29
+ ? input
30
+ : JSON.stringify(canonicalizeToolInput(input));
23
31
  return crypto.createHash('sha256').update(json).digest('hex').slice(0, 16);
24
32
  }
25
33