polygram 0.8.0-rc.4 → 0.8.0-rc.41

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0-rc.4",
4
+ "version": "0.8.0-rc.41",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -2,35 +2,72 @@
2
2
  * Detect "stop working on the current turn" signals in natural language.
3
3
  *
4
4
  * Mirrors OpenClaw's isAbortRequestText semantics: users should be able to
5
- * say "stop" / "подожди" / "cancel" / or just `/stop` and have polygram
5
+ * say "stop" / "стоп" / "cancel" / or just `/stop` and have polygram
6
6
  * interrupt the in-flight turn instead of queueing the message behind it.
7
7
  *
8
8
  * Conservative on purpose. False positives hijack user intent — "stop using
9
9
  * emoji" should NOT abort. So we require ONE of:
10
10
  * 1. The whole message (after stripping leading @-mention + trailing
11
- * punctuation) is an exact match against a known abort phrase, OR
11
+ * punctuation) is an exact match against a known abort phrase
12
+ * (HARD or SOFT phrases — see below), OR
12
13
  * 2. It starts with an explicit slash command: /stop, /abort, /cancel, OR
13
- * 3. The FIRST SENTENCE (split on . ! ?) is an exact abort phrase. This
14
- * catches "Stop. I'll ask in another session." — clear abort intent
15
- * with continuation explaining what comes next. Comma is not a split
16
- * character ("Stop, look here" is ambiguous and stays non-abort).
14
+ * 3. The FIRST SENTENCE (split on . ! ?) is an exact match against the
15
+ * HARD phrases ONLY. Catches "Stop. I'll ask in another session." —
16
+ * clear abort intent with continuation. Does NOT trigger on
17
+ * "Wait? Something is off..." (rc.41 false-positive fix soft words
18
+ * like "wait" / "hold on" are too conversational to abort on
19
+ * first-sentence alone).
20
+ *
21
+ * Hard phrases (whole-message OR first-sentence trigger):
22
+ * English: stop, cancel, abort, halt
23
+ * Russian: стоп, остановись, остановить, отмена, прекрати, прекращай,
24
+ * хватит, отставить
25
+ *
26
+ * Soft phrases (whole-message ONLY):
27
+ * English: wait, hold on, hold up, nevermind, never mind, nvm,
28
+ * forget it, forget that
29
+ * Russian: подожди, подожди-ка, забей, не надо, отмени
30
+ *
31
+ * The split exists because "wait", "hold on", "подожди" are commonly used
32
+ * as conversational openers ("Wait? There is something wrong..." — Ivan DM
33
+ * 2026-05-01 19:01) where the user is NOT asking the bot to stop, they're
34
+ * flagging an issue. Hard phrases ("stop", "cancel", "abort") are
35
+ * unambiguously about ending the current task.
17
36
  *
18
37
  * Not detected (on purpose):
19
38
  * - "stop using markdown" → first sentence is the whole thing, not exact
20
39
  * - "I said stop" → not at start / not exact match
40
+ * - "Wait? Something is wrong..." (rc.41) — soft word, multi-sentence
41
+ * - "Hold on, let me think" — same shape
21
42
  */
22
43
 
23
- const ABORT_PHRASES = new Set([
44
+ 'use strict';
45
+
46
+ // HARD phrases: unambiguous abort intent. Trigger on whole-message OR
47
+ // first-sentence match.
48
+ const HARD_ABORT_PHRASES = new Set([
24
49
  // English
25
- 'stop', 'wait', 'cancel', 'abort', 'halt',
26
- 'hold on', 'hold up', 'nevermind', 'never mind', 'nvm',
50
+ 'stop', 'cancel', 'abort', 'halt',
51
+ // Russian
52
+ 'стоп', 'остановись', 'остановить',
53
+ 'отмена', 'прекрати', 'прекращай', 'хватит', 'отставить',
54
+ ]);
55
+
56
+ // SOFT phrases: conversational filler that COULD mean abort but commonly
57
+ // doesn't. Whole-message match only.
58
+ const SOFT_ABORT_PHRASES = new Set([
59
+ // English
60
+ 'wait', 'hold on', 'hold up', 'nevermind', 'never mind', 'nvm',
27
61
  'forget it', 'forget that',
28
62
  // Russian
29
- 'стоп', 'подожди', 'подожди-ка', 'остановись', 'остановить',
30
- 'отмена', 'отставить', 'прекрати', 'прекращай', 'хватит',
31
- 'забей', 'не надо', 'отмени',
63
+ 'подожди', 'подожди-ка', 'забей', 'не надо', 'отмени',
32
64
  ]);
33
65
 
66
+ // Combined set for whole-message matching. Kept exported as ABORT_PHRASES
67
+ // for backward compatibility with any callers / tests that import it
68
+ // directly.
69
+ const ABORT_PHRASES = new Set([...HARD_ABORT_PHRASES, ...SOFT_ABORT_PHRASES]);
70
+
34
71
  const ABORT_SLASH_RE = /^\/(stop|abort|cancel)(\s|$|@)/i;
35
72
 
36
73
  // Strip leading @botname mentions ("@shumobot stop" → "stop"). Matches any
@@ -58,20 +95,28 @@ function isAbortRequest(text) {
58
95
  const n = normalize(text);
59
96
  if (!n) return false;
60
97
  // Whole-message exact match (capped — a long message that happens to
61
- // start with "stop" is real content, not an abort).
98
+ // start with "stop" is real content, not an abort). HARD or SOFT
99
+ // phrases both qualify here — the user typed JUST that word, which is
100
+ // unambiguous regardless of category.
62
101
  if (n.length <= 40 && ABORT_PHRASES.has(n)) return true;
63
102
 
64
103
  // First-sentence exact match. Splits on . ! ? (NOT comma — "Stop, look
65
- // here" is ambiguous and stays non-abort). The leading @-mention has
66
- // already been stripped by normalize but only on the whole string, so
67
- // we strip it again on the raw text before splitting.
104
+ // here" is ambiguous and stays non-abort). HARD phrases ONLY — soft
105
+ // phrases like "wait" or "hold on" are conversational openers ("Wait?
106
+ // There is something wrong...") and shouldn't hijack a message where
107
+ // the rest contains real content.
68
108
  const head = text.trim().replace(LEADING_MENTION_RE, '');
69
109
  const firstSentence = head.split(/[.!?]/, 1)[0]?.trim().toLowerCase();
70
- if (firstSentence && firstSentence.length <= 40 && ABORT_PHRASES.has(firstSentence)) {
110
+ if (firstSentence && firstSentence.length <= 40 && HARD_ABORT_PHRASES.has(firstSentence)) {
71
111
  return true;
72
112
  }
73
113
 
74
114
  return false;
75
115
  }
76
116
 
77
- module.exports = { isAbortRequest, ABORT_PHRASES };
117
+ module.exports = {
118
+ isAbortRequest,
119
+ ABORT_PHRASES,
120
+ HARD_ABORT_PHRASES,
121
+ SOFT_ABORT_PHRASES,
122
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Abort-grace tracker — per-session timestamps marking "user just
3
+ * /stop'd this session, suppress the next batch of generic error
4
+ * replies".
5
+ *
6
+ * Why this exists: when the user types /stop (or natural-language
7
+ * "стоп"), polygram calls pm.kill(sessionKey). The kill SIGTERM's
8
+ * the in-flight process — every pending in the queue rejects with
9
+ * "Process killed" or INTERRUPTED. WITHOUT abort-grace, polygram
10
+ * would post "💥 Hit a snag" for each rejected pending, even though
11
+ * the user already saw the /stop ack and these errors are caused
12
+ * by their own action.
13
+ *
14
+ * Timestamp model (vs the earlier "delete after first read" Set):
15
+ * a single /stop can drain many pendings, so we mark a TS and let
16
+ * every error within ABORT_GRACE_MS see "yes, aborted, stay quiet".
17
+ *
18
+ * Closes v6 plan §7.1 G11 unit gate.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const DEFAULT_ABORT_GRACE_MS = 15_000;
24
+
25
+ /**
26
+ * @param {object} [opts]
27
+ * @param {number} [opts.windowMs] — grace window (default 15s)
28
+ * @param {() => number} [opts.now] — clock injection for tests
29
+ */
30
+ function createAbortGrace({ windowMs = DEFAULT_ABORT_GRACE_MS, now = () => Date.now() } = {}) {
31
+ const aborted = new Map(); // sessionKey → ts of abort
32
+
33
+ function mark(sessionKey) {
34
+ if (!sessionKey) return;
35
+ const ts = now();
36
+ aborted.set(sessionKey, ts);
37
+ // Sweep old entries opportunistically. Use 2× window so a
38
+ // session that's marked-and-checked at the boundary doesn't
39
+ // disappear before the check completes.
40
+ for (const [k, t] of aborted) {
41
+ if (ts - t > windowMs * 2) aborted.delete(k);
42
+ }
43
+ }
44
+
45
+ function isRecent(sessionKey) {
46
+ const ts = aborted.get(sessionKey);
47
+ return ts != null && (now() - ts) < windowMs;
48
+ }
49
+
50
+ function clear(sessionKey) {
51
+ aborted.delete(sessionKey);
52
+ }
53
+
54
+ return {
55
+ mark,
56
+ isRecent,
57
+ clear,
58
+ get size() { return aborted.size; },
59
+ };
60
+ }
61
+
62
+ module.exports = { createAbortGrace, DEFAULT_ABORT_GRACE_MS };
@@ -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,92 +39,245 @@
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
+ function resolveAgentLocation(agentName, homeDir, cwd) {
60
+ if (typeof agentName !== 'string' || !AGENT_NAME_RE.test(agentName)) {
61
+ return null;
62
+ }
63
+ const fileCandidates = [];
64
+ if (cwd) fileCandidates.push(path.join(cwd, '.claude', 'agents', agentName + '.md'));
65
+ fileCandidates.push(path.join(homeDir, '.claude', 'agents', agentName + '.md'));
66
+ for (const p of fileCandidates) {
67
+ if (fs.existsSync(p)) return { kind: 'file', path: p, dir: null };
68
+ }
69
+ const dirCandidates = [];
70
+ if (cwd) dirCandidates.push(path.join(cwd, '.claude', 'agents', agentName));
71
+ dirCandidates.push(path.join(homeDir, '.claude', 'agents', agentName));
72
+ for (const d of dirCandidates) {
73
+ if (fs.existsSync(d) && fs.statSync(d).isDirectory()) {
74
+ return { kind: 'dir', path: d, dir: d };
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // Strip leading YAML frontmatter (---\n...\n---\n) from markdown.
81
+ function stripFrontmatter(content) {
82
+ if (typeof content !== 'string' || !content.startsWith('---\n')) return content;
83
+ const end = content.indexOf('\n---\n', 4);
84
+ if (end === -1) return content;
85
+ return content.slice(end + 5);
86
+ }
87
+
88
+ // Recursively expand Claude Code @<file> import directives. A line
89
+ // starting with `@<path>` is replaced with the file's contents
90
+ // (frontmatter stripped, imports recursively expanded). Paths
91
+ // resolve relative to the importing file's directory FIRST, then
92
+ // fall back to cwd. Cycle detection via visited Set.
93
+ //
94
+ // rc.15: pre-rc.15 the literal "@_shumabit-base.md" reached the
95
+ // model verbatim because polygram's loader didn't process imports.
96
+ // Symptom: agent appeared loaded but the system prompt was
97
+ // effectively empty (just an unresolved import directive).
98
+ function expandImports(content, importingFile, cwd, visited, logger) {
99
+ if (typeof content !== 'string' || !content) return content;
100
+ const lines = content.split('\n');
101
+ const out = [];
102
+ for (const line of lines) {
103
+ const m = /^@(\S+)\s*$/.exec(line);
104
+ if (!m) {
105
+ out.push(line);
106
+ continue;
107
+ }
108
+ const ref = m[1];
109
+ const importingDir = path.dirname(importingFile);
110
+ // Resolution order: relative to importing file's dir; relative
111
+ // to cwd; absolute path as-is.
112
+ const candidates = [];
113
+ if (path.isAbsolute(ref)) {
114
+ candidates.push(ref);
115
+ } else {
116
+ candidates.push(path.join(importingDir, ref));
117
+ if (cwd) candidates.push(path.join(cwd, ref));
118
+ }
119
+ let resolved = null;
120
+ for (const c of candidates) {
121
+ if (fs.existsSync(c)) { resolved = c; break; }
122
+ }
123
+ if (!resolved) {
124
+ logger?.warn?.(`[agent-loader] @-import not found: ${ref} (in ${importingFile})`);
125
+ out.push(line);
126
+ continue;
127
+ }
128
+ if (visited.has(resolved)) {
129
+ logger?.warn?.(`[agent-loader] @-import cycle: ${resolved}`);
130
+ continue;
131
+ }
132
+ visited.add(resolved);
133
+ let imported = '';
134
+ try {
135
+ imported = fs.readFileSync(resolved, 'utf8');
136
+ } catch (err) {
137
+ logger?.error?.(`[agent-loader] reading @-import ${resolved}: ${err.message}`);
138
+ out.push(line);
139
+ continue;
140
+ }
141
+ // Strip frontmatter from imported file (same convention as
142
+ // top-level agent file) and recursively expand its imports.
143
+ imported = stripFrontmatter(imported);
144
+ imported = expandImports(imported, resolved, cwd, visited, logger);
145
+ out.push(imported);
146
+ }
147
+ return out.join('\n');
148
+ }
149
+
150
+ // Parse a tiny subset of YAML frontmatter (key: value lines).
151
+ function parseFrontmatter(content) {
152
+ if (typeof content !== 'string' || !content.startsWith('---\n')) return {};
153
+ const end = content.indexOf('\n---\n', 4);
154
+ if (end === -1) return {};
155
+ const block = content.slice(4, end);
156
+ const out = {};
157
+ for (const line of block.split('\n')) {
158
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
159
+ if (!m) continue;
160
+ let v = m[2].trim();
161
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
162
+ v = v.slice(1, -1);
163
+ }
164
+ out[m[1]] = v;
165
+ }
166
+ return out;
167
+ }
35
168
 
36
169
  /**
37
170
  * Load an agent bundle from disk.
38
171
  *
39
- * @param {string} agentName — e.g. 'shumabit-finance'
172
+ * @param {string} agentName
40
173
  * @param {object} opts
41
174
  * @param {string} [opts.homeDir] — defaults to process.env.HOME.
42
- * Resolves agent at `${homeDir}/.claude/agents/${agentName}/`.
175
+ * @param {string} [opts.cwd] — chat's working directory; checked
176
+ * FIRST for Claude Code project-level agent discovery.
43
177
  * @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
178
  */
53
- function loadAgent(agentName, { homeDir = process.env.HOME, logger = console } = {}) {
54
- if (cache.has(agentName)) return cache.get(agentName);
179
+ function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger = console } = {}) {
180
+ // Cache key includes cwd because the same agentName can resolve
181
+ // to different files when called from different chats with
182
+ // different cwds (e.g. shumabit-claude vs shumabit-partners).
183
+ const cacheKey = agentName + '\x00' + (cwd || '');
184
+ if (cache.has(cacheKey)) return cache.get(cacheKey);
55
185
 
56
- const agentDir = path.join(homeDir, '.claude', 'agents', agentName);
57
- if (!fs.existsSync(agentDir)) {
186
+ const loc = resolveAgentLocation(agentName, homeDir, cwd);
187
+ if (!loc) {
188
+ const looked = [
189
+ cwd ? cwd + '/.claude/agents/' + agentName + '.md' : null,
190
+ homeDir + '/.claude/agents/' + agentName + '.md',
191
+ cwd ? cwd + '/.claude/agents/' + agentName + '/' : null,
192
+ homeDir + '/.claude/agents/' + agentName + '/',
193
+ ].filter(Boolean).join(', ');
58
194
  throw Object.assign(
59
- new Error(`agent not found: ${agentName} (looked in ${agentDir})`),
60
- { code: 'AGENT_NOT_FOUND', agentDir },
195
+ new Error('agent not found: ' + agentName + ' (looked in ' + looked + ')'),
196
+ { code: 'AGENT_NOT_FOUND', searchPaths: looked },
61
197
  );
62
198
  }
63
199
 
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
200
  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}`);
201
+ let frontmatter = {};
202
+ let agentPath = loc.path;
203
+
204
+ if (loc.kind === 'file') {
205
+ // Claude Code single-file format. Read whole file, parse and
206
+ // strip frontmatter, body becomes systemPrompt. Then expand
207
+ // any @<file> import directives recursively (rc.15).
208
+ try {
209
+ const raw = fs.readFileSync(loc.path, 'utf8');
210
+ frontmatter = parseFrontmatter(raw);
211
+ const stripped = stripFrontmatter(raw);
212
+ const visited = new Set([loc.path]);
213
+ systemPrompt = expandImports(stripped, loc.path, cwd, visited, logger);
214
+ } catch (err) {
215
+ logger.error?.('[agent-loader] reading ' + loc.path + ': ' + err.message);
216
+ }
217
+ } else {
218
+ // polygram directory layout. CLAUDE.md > AGENTS.md > system-prompt.txt.
219
+ for (const fname of ['CLAUDE.md', 'AGENTS.md', 'system-prompt.txt']) {
220
+ const p = path.join(loc.dir, fname);
221
+ if (fs.existsSync(p)) {
222
+ try {
223
+ const raw = fs.readFileSync(p, 'utf8');
224
+ // Expand @-imports for directory-layout agents too —
225
+ // their content might also reference shared base files.
226
+ const visited = new Set([p]);
227
+ systemPrompt = expandImports(raw, p, cwd, visited, logger);
228
+ agentPath = p;
229
+ break;
230
+ } catch (err) {
231
+ logger.error?.('[agent-loader] reading ' + p + ': ' + err.message);
232
+ }
77
233
  }
78
234
  }
79
235
  }
80
236
 
81
- // Settings: optional `settings.json` for per-agent overrides
82
- // (mcpServers, model, effort defaults, etc.).
237
+ // Settings.json — only meaningful for directory-layout agents.
83
238
  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}`);
239
+ if (loc.dir) {
240
+ const settingsPath = path.join(loc.dir, 'settings.json');
241
+ if (fs.existsSync(settingsPath)) {
242
+ try {
243
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
244
+ } catch (err) {
245
+ logger.error?.('[agent-loader] parsing ' + settingsPath + ': ' + err.message);
246
+ }
90
247
  }
91
248
  }
92
249
 
93
- // Skills: enumerate `${agentDir}/skills/*` directories. SDK's
94
- // `Options.skills` accepts a string[] of skill names.
95
- const skillsDir = path.join(agentDir, 'skills');
250
+ // Skills (only for directory layout).
96
251
  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}`);
252
+ if (loc.dir) {
253
+ const skillsDir = path.join(loc.dir, 'skills');
254
+ if (fs.existsSync(skillsDir)) {
255
+ try {
256
+ skills = fs.readdirSync(skillsDir, { withFileTypes: true })
257
+ .filter((d) => d.isDirectory())
258
+ .map((d) => d.name);
259
+ } catch (err) {
260
+ logger.error?.('[agent-loader] enumerating ' + skillsDir + ': ' + err.message);
261
+ }
104
262
  }
105
263
  }
106
264
 
107
265
  const mcpServers = settings.mcpServers ?? {};
108
266
 
267
+ // Frontmatter merged with settings — composeSdkOptions can pick up
268
+ // model/effort overrides from either source.
269
+ const raw = { ...frontmatter, ...settings };
270
+
109
271
  const bundle = {
110
272
  agentName,
111
- agentDir,
273
+ agentPath,
274
+ agentDir: loc.dir,
112
275
  systemPrompt,
113
276
  skills,
114
277
  mcpServers,
115
- // Pass through extra settings for callers that want them
116
- // (e.g. agent-level model/effort defaults).
117
- raw: settings,
278
+ raw,
118
279
  };
119
- cache.set(agentName, bundle);
280
+ cache.set(cacheKey, bundle);
120
281
  return bundle;
121
282
  }
122
283
 
@@ -166,4 +327,14 @@ function clearCache() {
166
327
  cache.clear();
167
328
  }
168
329
 
169
- module.exports = { loadAgent, composeSdkOptions, clearCache, _cache: cache };
330
+ module.exports = {
331
+ loadAgent,
332
+ composeSdkOptions,
333
+ clearCache,
334
+ // Internals for tests.
335
+ _resolveAgentLocation: resolveAgentLocation,
336
+ _stripFrontmatter: stripFrontmatter,
337
+ _parseFrontmatter: parseFrontmatter,
338
+ _expandImports: expandImports,
339
+ _cache: cache,
340
+ };