polygram 0.8.0-rc.12 → 0.8.0-rc.13

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.12",
4
+ "version": "0.8.0-rc.13",
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",
@@ -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,160 @@
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
+ function resolveAgentLocation(agentName, homeDir, cwd) {
47
+ const fileCandidates = [];
48
+ if (cwd) fileCandidates.push(path.join(cwd, '.claude', 'agents', agentName + '.md'));
49
+ fileCandidates.push(path.join(homeDir, '.claude', 'agents', agentName + '.md'));
50
+ for (const p of fileCandidates) {
51
+ if (fs.existsSync(p)) return { kind: 'file', path: p, dir: null };
52
+ }
53
+ const dirCandidates = [];
54
+ if (cwd) dirCandidates.push(path.join(cwd, '.claude', 'agents', agentName));
55
+ dirCandidates.push(path.join(homeDir, '.claude', 'agents', agentName));
56
+ for (const d of dirCandidates) {
57
+ if (fs.existsSync(d) && fs.statSync(d).isDirectory()) {
58
+ return { kind: 'dir', path: d, dir: d };
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ // Strip leading YAML frontmatter (---\n...\n---\n) from markdown.
65
+ function stripFrontmatter(content) {
66
+ if (typeof content !== 'string' || !content.startsWith('---\n')) return content;
67
+ const end = content.indexOf('\n---\n', 4);
68
+ if (end === -1) return content;
69
+ return content.slice(end + 5);
70
+ }
71
+
72
+ // Parse a tiny subset of YAML frontmatter (key: value lines).
73
+ function parseFrontmatter(content) {
74
+ if (typeof content !== 'string' || !content.startsWith('---\n')) return {};
75
+ const end = content.indexOf('\n---\n', 4);
76
+ if (end === -1) return {};
77
+ const block = content.slice(4, end);
78
+ const out = {};
79
+ for (const line of block.split('\n')) {
80
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
81
+ if (!m) continue;
82
+ let v = m[2].trim();
83
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
84
+ v = v.slice(1, -1);
85
+ }
86
+ out[m[1]] = v;
87
+ }
88
+ return out;
89
+ }
35
90
 
36
91
  /**
37
92
  * Load an agent bundle from disk.
38
93
  *
39
- * @param {string} agentName — e.g. 'shumabit-finance'
94
+ * @param {string} agentName
40
95
  * @param {object} opts
41
96
  * @param {string} [opts.homeDir] — defaults to process.env.HOME.
42
- * Resolves agent at `${homeDir}/.claude/agents/${agentName}/`.
97
+ * @param {string} [opts.cwd] — chat's working directory; checked
98
+ * FIRST for Claude Code project-level agent discovery.
43
99
  * @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
100
  */
53
- function loadAgent(agentName, { homeDir = process.env.HOME, logger = console } = {}) {
54
- if (cache.has(agentName)) return cache.get(agentName);
55
-
56
- const agentDir = path.join(homeDir, '.claude', 'agents', agentName);
57
- if (!fs.existsSync(agentDir)) {
101
+ function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger = console } = {}) {
102
+ // Cache key includes cwd because the same agentName can resolve
103
+ // to different files when called from different chats with
104
+ // different cwds (e.g. shumabit-claude vs shumabit-partners).
105
+ const cacheKey = agentName + '\x00' + (cwd || '');
106
+ if (cache.has(cacheKey)) return cache.get(cacheKey);
107
+
108
+ const loc = resolveAgentLocation(agentName, homeDir, cwd);
109
+ if (!loc) {
110
+ const looked = [
111
+ cwd ? cwd + '/.claude/agents/' + agentName + '.md' : null,
112
+ homeDir + '/.claude/agents/' + agentName + '.md',
113
+ cwd ? cwd + '/.claude/agents/' + agentName + '/' : null,
114
+ homeDir + '/.claude/agents/' + agentName + '/',
115
+ ].filter(Boolean).join(', ');
58
116
  throw Object.assign(
59
- new Error(`agent not found: ${agentName} (looked in ${agentDir})`),
60
- { code: 'AGENT_NOT_FOUND', agentDir },
117
+ new Error('agent not found: ' + agentName + ' (looked in ' + looked + ')'),
118
+ { code: 'AGENT_NOT_FOUND', searchPaths: looked },
61
119
  );
62
120
  }
63
121
 
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
122
  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}`);
123
+ let frontmatter = {};
124
+ let agentPath = loc.path;
125
+
126
+ if (loc.kind === 'file') {
127
+ // Claude Code single-file format. Read whole file, parse and
128
+ // strip frontmatter, body becomes systemPrompt.
129
+ try {
130
+ const raw = fs.readFileSync(loc.path, 'utf8');
131
+ frontmatter = parseFrontmatter(raw);
132
+ systemPrompt = stripFrontmatter(raw);
133
+ } catch (err) {
134
+ logger.error?.('[agent-loader] reading ' + loc.path + ': ' + err.message);
135
+ }
136
+ } else {
137
+ // polygram directory layout. CLAUDE.md > AGENTS.md > system-prompt.txt.
138
+ for (const fname of ['CLAUDE.md', 'AGENTS.md', 'system-prompt.txt']) {
139
+ const p = path.join(loc.dir, fname);
140
+ if (fs.existsSync(p)) {
141
+ try {
142
+ systemPrompt = fs.readFileSync(p, 'utf8');
143
+ agentPath = p;
144
+ break;
145
+ } catch (err) {
146
+ logger.error?.('[agent-loader] reading ' + p + ': ' + err.message);
147
+ }
77
148
  }
78
149
  }
79
150
  }
80
151
 
81
- // Settings: optional `settings.json` for per-agent overrides
82
- // (mcpServers, model, effort defaults, etc.).
152
+ // Settings.json — only meaningful for directory-layout agents.
83
153
  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}`);
154
+ if (loc.dir) {
155
+ const settingsPath = path.join(loc.dir, 'settings.json');
156
+ if (fs.existsSync(settingsPath)) {
157
+ try {
158
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
159
+ } catch (err) {
160
+ logger.error?.('[agent-loader] parsing ' + settingsPath + ': ' + err.message);
161
+ }
90
162
  }
91
163
  }
92
164
 
93
- // Skills: enumerate `${agentDir}/skills/*` directories. SDK's
94
- // `Options.skills` accepts a string[] of skill names.
95
- const skillsDir = path.join(agentDir, 'skills');
165
+ // Skills (only for directory layout).
96
166
  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}`);
167
+ if (loc.dir) {
168
+ const skillsDir = path.join(loc.dir, 'skills');
169
+ if (fs.existsSync(skillsDir)) {
170
+ try {
171
+ skills = fs.readdirSync(skillsDir, { withFileTypes: true })
172
+ .filter((d) => d.isDirectory())
173
+ .map((d) => d.name);
174
+ } catch (err) {
175
+ logger.error?.('[agent-loader] enumerating ' + skillsDir + ': ' + err.message);
176
+ }
104
177
  }
105
178
  }
106
179
 
107
180
  const mcpServers = settings.mcpServers ?? {};
108
181
 
182
+ // Frontmatter merged with settings — composeSdkOptions can pick up
183
+ // model/effort overrides from either source.
184
+ const raw = { ...frontmatter, ...settings };
185
+
109
186
  const bundle = {
110
187
  agentName,
111
- agentDir,
188
+ agentPath,
189
+ agentDir: loc.dir,
112
190
  systemPrompt,
113
191
  skills,
114
192
  mcpServers,
115
- // Pass through extra settings for callers that want them
116
- // (e.g. agent-level model/effort defaults).
117
- raw: settings,
193
+ raw,
118
194
  };
119
- cache.set(agentName, bundle);
195
+ cache.set(cacheKey, bundle);
120
196
  return bundle;
121
197
  }
122
198
 
@@ -166,4 +242,13 @@ function clearCache() {
166
242
  cache.clear();
167
243
  }
168
244
 
169
- module.exports = { loadAgent, composeSdkOptions, clearCache, _cache: cache };
245
+ module.exports = {
246
+ loadAgent,
247
+ composeSdkOptions,
248
+ clearCache,
249
+ // Internals for tests.
250
+ _resolveAgentLocation: resolveAgentLocation,
251
+ _stripFrontmatter: stripFrontmatter,
252
+ _parseFrontmatter: parseFrontmatter,
253
+ _cache: cache,
254
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.12",
3
+ "version": "0.8.0-rc.13",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -793,6 +793,10 @@ function buildSdkOptions(sessionKey, ctx) {
793
793
  try {
794
794
  agentBundle = agentLoader.loadAgent(chatConfig.agent, {
795
795
  homeDir: CHILD_HOME,
796
+ // Pass cwd so the loader checks Claude Code's project-level
797
+ // path (`<cwd>/.claude/agents/<name>.md`) before the
798
+ // user-level path or polygram's directory convention.
799
+ cwd: chatConfig.cwd,
796
800
  logger: console,
797
801
  });
798
802
  } catch (err) {
@@ -2782,7 +2786,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2782
2786
  const abortedByUser = isSessionRecentlyAborted(sessionKey);
2783
2787
  if (abortedByUser) {
2784
2788
  await streamer.finalize('').catch(() => {});
2785
- // Leave reaction as-is no 🤯 / 😨; user asked for stop.
2789
+ // 0.8.0-rc.13: clear the in-flight emoji on abort so the user
2790
+ // sees a clean message after their /stop ack — pre-rc.13 the
2791
+ // last 👀 / 🤔 / ✍ stayed stuck on the message indefinitely
2792
+ // because reactor.stop() (in finally) only kills timers, not
2793
+ // the visible reaction. We DON'T set 🤯/😨 (those are for
2794
+ // unexpected errors); the user just wants their stop honored.
2795
+ await reactor.clear().catch(() => {});
2786
2796
  } else {
2787
2797
  await streamer.finalize('', { errorSuffix: 'stream interrupted' }).catch(() => {});
2788
2798
  if (/wall-clock ceiling|idle with no Claude activity/i.test(err?.message || '')) {
@@ -2992,6 +3002,11 @@ function createBot(token) {
2992
3002
  await stopTarget.kill(sessionKey).catch((err) =>
2993
3003
  console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
2994
3004
  }
3005
+ // 0.8.0-rc.13: drop any buffered autosteer follow-ups for this
3006
+ // session — otherwise they'd be injected into the NEXT turn
3007
+ // (stale steer leak across abort boundary, which is what the
3008
+ // user just asked us not to do).
3009
+ autosteerBuffer.clear(sessionKey);
2995
3010
  logEvent('abort-requested', {
2996
3011
  chat_id: chatId, user_id: msg.from?.id || null,
2997
3012
  had_active: hadActive,