polygram 0.8.0-rc.9 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/config.example.json +9 -1
- package/lib/abort-detector.js +63 -18
- package/lib/abort-grace.js +62 -0
- package/lib/agent-loader.js +326 -70
- package/lib/approval-ui.js +135 -0
- package/lib/approval-waiters.js +7 -0
- package/lib/approvals.js +9 -1
- package/lib/async-lock.js +11 -3
- package/lib/attachments.js +61 -2
- package/lib/auto-resume.js +101 -0
- package/lib/autosteered-refs.js +100 -0
- package/lib/canonical-json.js +62 -0
- package/lib/context-format.js +96 -0
- package/lib/db.js +85 -0
- package/lib/history-preload.js +220 -0
- package/lib/ipc-file-validator.js +75 -0
- package/lib/parse-response.js +178 -11
- package/lib/pm-interface.js +99 -0
- package/lib/pm-router.js +201 -0
- package/lib/process-guard.js +240 -0
- package/lib/process-manager-sdk.js +245 -14
- package/lib/process-manager.js +47 -1
- package/lib/prompt.js +13 -1
- package/lib/replay-window.js +53 -0
- package/lib/session-key.js +85 -1
- package/lib/status-reactions.js +256 -42
- package/lib/stream-reply.js +84 -11
- package/lib/telegram-prompt.js +118 -0
- package/lib/typing-indicator.js +6 -1
- package/package.json +1 -1
- package/polygram.js +1126 -427
- package/scripts/doctor.js +6 -1
- package/skills/polygram-send/SKILL.md +154 -0
- package/lib/autosteer-buffer.js +0 -80
package/lib/agent-loader.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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(); //
|
|
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
|
|
231
|
+
* @param {string} agentName
|
|
40
232
|
* @param {object} opts
|
|
41
233
|
* @param {string} [opts.homeDir] — defaults to process.env.HOME.
|
|
42
|
-
*
|
|
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
|
-
|
|
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
|
|
57
|
-
if (!
|
|
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(
|
|
60
|
-
{ code: 'AGENT_NOT_FOUND',
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
82
|
-
// (mcpServers, model, effort defaults, etc.).
|
|
296
|
+
// Settings.json — only meaningful for directory-layout agents.
|
|
83
297
|
let settings = {};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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 (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
332
|
+
agentPath,
|
|
333
|
+
agentDir: loc.dir,
|
|
112
334
|
systemPrompt,
|
|
113
335
|
skills,
|
|
114
336
|
mcpServers,
|
|
115
|
-
|
|
116
|
-
// (e.g. agent-level model/effort defaults).
|
|
117
|
-
raw: settings,
|
|
337
|
+
raw,
|
|
118
338
|
};
|
|
119
|
-
cache.set(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
+
};
|
package/lib/approval-waiters.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|