polygram 0.8.0-rc.9 → 0.9.0-rc.1
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/agents/loader.js +423 -0
- package/lib/{approvals.js → approvals/store.js} +36 -5
- package/lib/approvals/ui.js +119 -0
- package/lib/async-lock.js +11 -3
- package/lib/attachments.js +61 -2
- package/lib/autosteered-refs.js +100 -0
- package/lib/canonical-json.js +62 -0
- package/lib/config.js +121 -0
- package/lib/context-format.js +96 -0
- package/lib/db/auto-resume.js +101 -0
- package/lib/db/replay-window.js +53 -0
- package/lib/db.js +85 -0
- package/lib/{error-classify.js → error/classify.js} +25 -34
- package/lib/handlers/abort.js +89 -0
- package/lib/handlers/approvals.js +361 -0
- package/lib/handlers/autosteer.js +94 -0
- package/lib/handlers/config-callback.js +118 -0
- package/lib/handlers/config-ui.js +104 -0
- package/lib/handlers/dispatcher.js +263 -0
- package/lib/handlers/download.js +182 -0
- package/lib/handlers/extract-attachments.js +97 -0
- package/lib/handlers/ipc-send.js +80 -0
- package/lib/handlers/poll.js +140 -0
- package/lib/handlers/record-inbound.js +88 -0
- package/lib/handlers/slash-commands.js +319 -0
- package/lib/handlers/voice.js +107 -0
- package/lib/history-preload.js +220 -0
- package/lib/ipc/file-validator.js +75 -0
- package/lib/pm-interface.js +97 -0
- package/lib/process-guard.js +240 -0
- package/lib/prompt.js +13 -1
- package/lib/sdk/build-options.js +177 -0
- package/lib/sdk/callbacks.js +213 -0
- package/lib/{process-manager-sdk.js → sdk/process-manager.js} +256 -37
- package/lib/session-key.js +85 -1
- package/lib/{telegram.js → telegram/api.js} +2 -2
- package/lib/telegram/display-hint.js +104 -0
- package/lib/telegram/parse.js +223 -0
- package/lib/telegram/reactions.js +464 -0
- package/lib/{stream-reply.js → telegram/streamer.js} +88 -15
- package/lib/{typing-indicator.js → telegram/typing.js} +6 -1
- package/package.json +2 -3
- package/polygram.js +868 -2403
- package/scripts/doctor.js +7 -2
- package/scripts/ipc-smoke.js +1 -10
- package/skills/polygram-send/SKILL.md +154 -0
- package/bin/approval-hook.js +0 -113
- package/lib/agent-loader.js +0 -169
- package/lib/approval-waiters.js +0 -194
- package/lib/autosteer-buffer.js +0 -80
- package/lib/parse-response.js +0 -56
- package/lib/process-manager.js +0 -760
- package/lib/status-reactions.js +0 -250
- /package/lib/{inbox.js → db/inbox.js} +0 -0
- /package/lib/{pairings.js → db/pairings.js} +0 -0
- /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
- /package/lib/{sessions.js → db/sessions.js} +0 -0
- /package/lib/{net-errors.js → error/net.js} +0 -0
- /package/lib/{ipc-client.js → ipc/client.js} +0 -0
- /package/lib/{ipc-server.js → ipc/server.js} +0 -0
- /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
- /package/lib/{deliver.js → telegram/deliver.js} +0 -0
- /package/lib/{telegram-format.js → telegram/format.js} +0 -0
- /package/lib/{voice.js → telegram/voice.js} +0 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "polygram",
|
|
12
|
-
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Bundles /polygram:status|logs|pair-code|approvals admin commands and
|
|
12
|
+
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
13
13
|
"category": "integration",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"homepage": "https://github.com/shumkov/polygram"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
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
|
|
4
|
+
"version": "0.9.0-rc.1",
|
|
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
|
8
8
|
"openclaw",
|
package/config.example.json
CHANGED
|
@@ -98,9 +98,17 @@
|
|
|
98
98
|
"cwd": "/Users/you/admin-agent",
|
|
99
99
|
"requireMention": true,
|
|
100
100
|
"isolateTopics": true,
|
|
101
|
+
"_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
|
|
101
102
|
"topics": {
|
|
102
103
|
"100": "Customer A",
|
|
103
|
-
"200":
|
|
104
|
+
"200": {
|
|
105
|
+
"name": "Customer B",
|
|
106
|
+
"agent": "customer-b-helper",
|
|
107
|
+
"cwd": "/Users/you/customer-b-projects",
|
|
108
|
+
"model": "opus",
|
|
109
|
+
"effort": "high",
|
|
110
|
+
"permissionMode": "default"
|
|
111
|
+
}
|
|
104
112
|
}
|
|
105
113
|
},
|
|
106
114
|
|
package/lib/abort-detector.js
CHANGED
|
@@ -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" / "
|
|
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
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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', '
|
|
26
|
-
|
|
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).
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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 &&
|
|
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 = {
|
|
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 };
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-chat agent loader.
|
|
3
|
+
*
|
|
4
|
+
* polygram reads the per-chat agent file itself and passes its
|
|
5
|
+
* content as `systemPrompt`. The SDK's `Options.agents` is for
|
|
6
|
+
* in-memory subagent definitions (the Task tool), NOT a "run THIS
|
|
7
|
+
* query AS this agent" mechanism — so we resolve the agent file
|
|
8
|
+
* out-of-band and inject the system prompt directly.
|
|
9
|
+
*
|
|
10
|
+
* Search order (rc.13+ — supports BOTH Claude Code's standard
|
|
11
|
+
* single-file convention AND polygram's pre-0.8.0 directory layout):
|
|
12
|
+
*
|
|
13
|
+
* 1. `<cwd>/.claude/agents/<name>.md` — Claude Code project-level
|
|
14
|
+
* 2. `<homeDir>/.claude/agents/<name>.md` — Claude Code user-level
|
|
15
|
+
* 3. `<cwd>/.claude/agents/<name>/CLAUDE.md` — polygram convention
|
|
16
|
+
* (also `AGENTS.md`, `system-prompt.txt`)
|
|
17
|
+
* 4. `<homeDir>/.claude/agents/<name>/CLAUDE.md` — polygram legacy
|
|
18
|
+
* (also `AGENTS.md`, `system-prompt.txt`)
|
|
19
|
+
*
|
|
20
|
+
* Single-file Claude Code agents may have YAML frontmatter; we strip
|
|
21
|
+
* it before using the body as systemPrompt. Frontmatter `model` /
|
|
22
|
+
* `effort` are merged into the bundle.raw so composeSdkOptions can
|
|
23
|
+
* use them as agent-level defaults.
|
|
24
|
+
*
|
|
25
|
+
* Used by `polygram.js` `buildSdkOptions(sessionKey, ctx)` —
|
|
26
|
+
* Phase 1 step 14.
|
|
27
|
+
*
|
|
28
|
+
* Cache: agentName → resolved AgentBundle. Invalidated on SIGHUP
|
|
29
|
+
* (callable via `clearCache()`). Phase 5 acceptance includes "agent
|
|
30
|
+
* config edits don't require daemon restart" — but for 0.8.0
|
|
31
|
+
* initial release, restart-on-edit is acceptable; clearCache hook
|
|
32
|
+
* is forward-compat.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
|
|
40
|
+
const cache = new Map(); // cacheKey → AgentBundle
|
|
41
|
+
|
|
42
|
+
// Resolve agent file by checking each search path in order.
|
|
43
|
+
// Returns { kind: 'file'|'dir', path, dir | null } or null.
|
|
44
|
+
// Restrict agent names to a conservative charset so they can't
|
|
45
|
+
// path-traverse out of the `.claude/agents/` directory. Pre-fix, an
|
|
46
|
+
// agent name like `../../etc/passwd` silently resolved to whatever
|
|
47
|
+
// existed at that path, loading arbitrary file content as the
|
|
48
|
+
// system prompt. Chat configs are operator-controlled (not user
|
|
49
|
+
// input), so the practical threat is operator typos — but pinning
|
|
50
|
+
// the contract removes the foot-gun.
|
|
51
|
+
//
|
|
52
|
+
// Allowed: alphanumerics, hyphen, underscore, single dots inside
|
|
53
|
+
// (e.g. "shumabit-finance.v2"). Forbidden: leading/trailing dot,
|
|
54
|
+
// consecutive dots, slashes, NUL.
|
|
55
|
+
const AGENT_NAME_RE = /^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)*$/;
|
|
56
|
+
|
|
57
|
+
// rc.49: parse `<plugin>:<agent>` qualified names. Each side must
|
|
58
|
+
// independently satisfy AGENT_NAME_RE. Returns { plugin, agent } or
|
|
59
|
+
// null if not qualified or malformed.
|
|
60
|
+
function parseQualifiedName(name) {
|
|
61
|
+
if (typeof name !== 'string' || !name.includes(':')) return null;
|
|
62
|
+
const parts = name.split(':');
|
|
63
|
+
if (parts.length !== 2) return null;
|
|
64
|
+
const [plugin, agent] = parts;
|
|
65
|
+
if (!AGENT_NAME_RE.test(plugin) || !AGENT_NAME_RE.test(agent)) return null;
|
|
66
|
+
return { plugin, agent };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// rc.49: look up a plugin's installPath in
|
|
70
|
+
// ~/.claude/plugins/installed_plugins.json. Keys are
|
|
71
|
+
// `<name>@<marketplace>` — match by the bare `<name>` prefix and
|
|
72
|
+
// return the first installed entry's `installPath`. Returns null if
|
|
73
|
+
// not enrolled or registry unreadable.
|
|
74
|
+
function lookupInstalledPlugin(pluginName, homeDir) {
|
|
75
|
+
const registryPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
|
|
76
|
+
if (!fs.existsSync(registryPath)) return null;
|
|
77
|
+
let registry;
|
|
78
|
+
try {
|
|
79
|
+
registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const plugins = registry?.plugins || {};
|
|
84
|
+
const prefix = pluginName + '@';
|
|
85
|
+
for (const key of Object.keys(plugins)) {
|
|
86
|
+
if (key.startsWith(prefix)) {
|
|
87
|
+
const entries = plugins[key];
|
|
88
|
+
if (Array.isArray(entries) && entries[0]?.installPath) {
|
|
89
|
+
return entries[0].installPath;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveAgentLocation(agentName, homeDir, cwd) {
|
|
97
|
+
if (typeof agentName !== 'string') return null;
|
|
98
|
+
|
|
99
|
+
// rc.49: plugin-qualified `<plugin>:<agent>` names look up the plugin
|
|
100
|
+
// from the installed registry (with fallback to ~/.claude-plugins-local/).
|
|
101
|
+
// Resolution is intentionally NARROW — only the qualified form
|
|
102
|
+
// searches plugin directories. Plain unqualified names keep the
|
|
103
|
+
// pre-rc.49 ~/.claude/agents/ + <cwd>/.claude/agents/ behaviour.
|
|
104
|
+
const qualified = parseQualifiedName(agentName);
|
|
105
|
+
if (qualified) {
|
|
106
|
+
const { plugin, agent } = qualified;
|
|
107
|
+
const installPath = lookupInstalledPlugin(plugin, homeDir);
|
|
108
|
+
if (installPath) {
|
|
109
|
+
const p = path.join(installPath, 'agents', agent + '.md');
|
|
110
|
+
if (fs.existsSync(p)) return { kind: 'file', path: p, dir: null };
|
|
111
|
+
}
|
|
112
|
+
// Fallback: ~/.claude-plugins-local/<plugin>/agents/<agent>.md
|
|
113
|
+
const localPath = path.join(homeDir, '.claude-plugins-local', plugin, 'agents', agent + '.md');
|
|
114
|
+
if (fs.existsSync(localPath)) return { kind: 'file', path: localPath, dir: null };
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!AGENT_NAME_RE.test(agentName)) return null;
|
|
119
|
+
|
|
120
|
+
const fileCandidates = [];
|
|
121
|
+
if (cwd) fileCandidates.push(path.join(cwd, '.claude', 'agents', agentName + '.md'));
|
|
122
|
+
fileCandidates.push(path.join(homeDir, '.claude', 'agents', agentName + '.md'));
|
|
123
|
+
for (const p of fileCandidates) {
|
|
124
|
+
if (fs.existsSync(p)) return { kind: 'file', path: p, dir: null };
|
|
125
|
+
}
|
|
126
|
+
const dirCandidates = [];
|
|
127
|
+
if (cwd) dirCandidates.push(path.join(cwd, '.claude', 'agents', agentName));
|
|
128
|
+
dirCandidates.push(path.join(homeDir, '.claude', 'agents', agentName));
|
|
129
|
+
for (const d of dirCandidates) {
|
|
130
|
+
if (fs.existsSync(d) && fs.statSync(d).isDirectory()) {
|
|
131
|
+
return { kind: 'dir', path: d, dir: d };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Strip leading YAML frontmatter (---\n...\n---\n) from markdown.
|
|
138
|
+
function stripFrontmatter(content) {
|
|
139
|
+
if (typeof content !== 'string' || !content.startsWith('---\n')) return content;
|
|
140
|
+
const end = content.indexOf('\n---\n', 4);
|
|
141
|
+
if (end === -1) return content;
|
|
142
|
+
return content.slice(end + 5);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Recursively expand Claude Code @<file> import directives. A line
|
|
146
|
+
// starting with `@<path>` is replaced with the file's contents
|
|
147
|
+
// (frontmatter stripped, imports recursively expanded). Paths
|
|
148
|
+
// resolve relative to the importing file's directory FIRST, then
|
|
149
|
+
// fall back to cwd. Cycle detection via visited Set.
|
|
150
|
+
//
|
|
151
|
+
// rc.15: pre-rc.15 the literal "@_shumabit-base.md" reached the
|
|
152
|
+
// model verbatim because polygram's loader didn't process imports.
|
|
153
|
+
// Symptom: agent appeared loaded but the system prompt was
|
|
154
|
+
// effectively empty (just an unresolved import directive).
|
|
155
|
+
function expandImports(content, importingFile, cwd, visited, logger) {
|
|
156
|
+
if (typeof content !== 'string' || !content) return content;
|
|
157
|
+
const lines = content.split('\n');
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
const m = /^@(\S+)\s*$/.exec(line);
|
|
161
|
+
if (!m) {
|
|
162
|
+
out.push(line);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const ref = m[1];
|
|
166
|
+
const importingDir = path.dirname(importingFile);
|
|
167
|
+
// Resolution order: relative to importing file's dir; relative
|
|
168
|
+
// to cwd; absolute path as-is.
|
|
169
|
+
const candidates = [];
|
|
170
|
+
if (path.isAbsolute(ref)) {
|
|
171
|
+
candidates.push(ref);
|
|
172
|
+
} else {
|
|
173
|
+
candidates.push(path.join(importingDir, ref));
|
|
174
|
+
if (cwd) candidates.push(path.join(cwd, ref));
|
|
175
|
+
}
|
|
176
|
+
let resolved = null;
|
|
177
|
+
for (const c of candidates) {
|
|
178
|
+
if (fs.existsSync(c)) { resolved = c; break; }
|
|
179
|
+
}
|
|
180
|
+
if (!resolved) {
|
|
181
|
+
logger?.warn?.(`[agent-loader] @-import not found: ${ref} (in ${importingFile})`);
|
|
182
|
+
out.push(line);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (visited.has(resolved)) {
|
|
186
|
+
logger?.warn?.(`[agent-loader] @-import cycle: ${resolved}`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
visited.add(resolved);
|
|
190
|
+
let imported = '';
|
|
191
|
+
try {
|
|
192
|
+
imported = fs.readFileSync(resolved, 'utf8');
|
|
193
|
+
} catch (err) {
|
|
194
|
+
logger?.error?.(`[agent-loader] reading @-import ${resolved}: ${err.message}`);
|
|
195
|
+
out.push(line);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// Strip frontmatter from imported file (same convention as
|
|
199
|
+
// top-level agent file) and recursively expand its imports.
|
|
200
|
+
imported = stripFrontmatter(imported);
|
|
201
|
+
imported = expandImports(imported, resolved, cwd, visited, logger);
|
|
202
|
+
out.push(imported);
|
|
203
|
+
}
|
|
204
|
+
return out.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse a tiny subset of YAML frontmatter (key: value lines).
|
|
208
|
+
function parseFrontmatter(content) {
|
|
209
|
+
if (typeof content !== 'string' || !content.startsWith('---\n')) return {};
|
|
210
|
+
const end = content.indexOf('\n---\n', 4);
|
|
211
|
+
if (end === -1) return {};
|
|
212
|
+
const block = content.slice(4, end);
|
|
213
|
+
const out = {};
|
|
214
|
+
for (const line of block.split('\n')) {
|
|
215
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
|
|
216
|
+
if (!m) continue;
|
|
217
|
+
let v = m[2].trim();
|
|
218
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
219
|
+
v = v.slice(1, -1);
|
|
220
|
+
}
|
|
221
|
+
out[m[1]] = v;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Load an agent bundle from disk.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} agentName
|
|
230
|
+
* @param {object} opts
|
|
231
|
+
* @param {string} [opts.homeDir] — defaults to process.env.HOME.
|
|
232
|
+
* @param {string} [opts.cwd] — chat's working directory; checked
|
|
233
|
+
* FIRST for Claude Code project-level agent discovery.
|
|
234
|
+
* @param {object} [opts.logger] — error logger.
|
|
235
|
+
*/
|
|
236
|
+
function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger = console } = {}) {
|
|
237
|
+
// Cache key includes cwd because the same agentName can resolve
|
|
238
|
+
// to different files when called from different chats with
|
|
239
|
+
// different cwds (e.g. shumabit-claude vs shumabit-partners).
|
|
240
|
+
const cacheKey = agentName + '\x00' + (cwd || '');
|
|
241
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
242
|
+
|
|
243
|
+
const loc = resolveAgentLocation(agentName, homeDir, cwd);
|
|
244
|
+
if (!loc) {
|
|
245
|
+
const looked = [
|
|
246
|
+
cwd ? cwd + '/.claude/agents/' + agentName + '.md' : null,
|
|
247
|
+
homeDir + '/.claude/agents/' + agentName + '.md',
|
|
248
|
+
cwd ? cwd + '/.claude/agents/' + agentName + '/' : null,
|
|
249
|
+
homeDir + '/.claude/agents/' + agentName + '/',
|
|
250
|
+
].filter(Boolean).join(', ');
|
|
251
|
+
throw Object.assign(
|
|
252
|
+
new Error('agent not found: ' + agentName + ' (looked in ' + looked + ')'),
|
|
253
|
+
{ code: 'AGENT_NOT_FOUND', searchPaths: looked },
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let systemPrompt = null;
|
|
258
|
+
let frontmatter = {};
|
|
259
|
+
let agentPath = loc.path;
|
|
260
|
+
|
|
261
|
+
if (loc.kind === 'file') {
|
|
262
|
+
// Claude Code single-file format. Read whole file, parse and
|
|
263
|
+
// strip frontmatter, body becomes systemPrompt. Then expand
|
|
264
|
+
// any @<file> import directives recursively (rc.15).
|
|
265
|
+
try {
|
|
266
|
+
const raw = fs.readFileSync(loc.path, 'utf8');
|
|
267
|
+
frontmatter = parseFrontmatter(raw);
|
|
268
|
+
const stripped = stripFrontmatter(raw);
|
|
269
|
+
const visited = new Set([loc.path]);
|
|
270
|
+
systemPrompt = expandImports(stripped, loc.path, cwd, visited, logger);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
logger.error?.('[agent-loader] reading ' + loc.path + ': ' + err.message);
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
// polygram directory layout. CLAUDE.md > AGENTS.md > system-prompt.txt.
|
|
276
|
+
for (const fname of ['CLAUDE.md', 'AGENTS.md', 'system-prompt.txt']) {
|
|
277
|
+
const p = path.join(loc.dir, fname);
|
|
278
|
+
if (fs.existsSync(p)) {
|
|
279
|
+
try {
|
|
280
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
281
|
+
// Expand @-imports for directory-layout agents too —
|
|
282
|
+
// their content might also reference shared base files.
|
|
283
|
+
const visited = new Set([p]);
|
|
284
|
+
systemPrompt = expandImports(raw, p, cwd, visited, logger);
|
|
285
|
+
agentPath = p;
|
|
286
|
+
break;
|
|
287
|
+
} catch (err) {
|
|
288
|
+
logger.error?.('[agent-loader] reading ' + p + ': ' + err.message);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Settings.json — only meaningful for directory-layout agents.
|
|
295
|
+
let settings = {};
|
|
296
|
+
if (loc.dir) {
|
|
297
|
+
const settingsPath = path.join(loc.dir, 'settings.json');
|
|
298
|
+
if (fs.existsSync(settingsPath)) {
|
|
299
|
+
try {
|
|
300
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
301
|
+
} catch (err) {
|
|
302
|
+
logger.error?.('[agent-loader] parsing ' + settingsPath + ': ' + err.message);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Skills (only for directory layout).
|
|
308
|
+
let skills = [];
|
|
309
|
+
if (loc.dir) {
|
|
310
|
+
const skillsDir = path.join(loc.dir, 'skills');
|
|
311
|
+
if (fs.existsSync(skillsDir)) {
|
|
312
|
+
try {
|
|
313
|
+
skills = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
314
|
+
.filter((d) => d.isDirectory())
|
|
315
|
+
.map((d) => d.name);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
logger.error?.('[agent-loader] enumerating ' + skillsDir + ': ' + err.message);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const mcpServers = settings.mcpServers ?? {};
|
|
323
|
+
|
|
324
|
+
// Frontmatter merged with settings — composeSdkOptions can pick up
|
|
325
|
+
// model/effort overrides from either source.
|
|
326
|
+
const raw = { ...frontmatter, ...settings };
|
|
327
|
+
|
|
328
|
+
const bundle = {
|
|
329
|
+
agentName,
|
|
330
|
+
agentPath,
|
|
331
|
+
agentDir: loc.dir,
|
|
332
|
+
systemPrompt,
|
|
333
|
+
skills,
|
|
334
|
+
mcpServers,
|
|
335
|
+
raw,
|
|
336
|
+
};
|
|
337
|
+
cache.set(cacheKey, bundle);
|
|
338
|
+
return bundle;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Compose a chat's final SdkOptions from defaults + agent + per-chat
|
|
343
|
+
* overrides + per-topic overrides. Precedence (highest to lowest):
|
|
344
|
+
* topicConfig > chatConfig > agent.raw > defaults
|
|
345
|
+
*
|
|
346
|
+
* rc.48 added the per-topic layer (`topicConfig` arg). Per-topic
|
|
347
|
+
* overrides are the principal rc.48 use case — typically loosening
|
|
348
|
+
* an agent's `bypassPermissions` default to `default` for a sensitive
|
|
349
|
+
* topic (so canUseTool prompts fire there) while keeping the rest of
|
|
350
|
+
* the chat in bypass mode. Per-topic permissionMode MUST override the
|
|
351
|
+
* chat-level one for this to work.
|
|
352
|
+
*
|
|
353
|
+
* @param {object} chatConfig — config.chats[chatId].
|
|
354
|
+
* @param {AgentBundle|null} agentBundle — null if chat has no agent.
|
|
355
|
+
* @param {object} defaults — config.defaults.
|
|
356
|
+
* @param {object} [topicConfig] — per-topic overrides from
|
|
357
|
+
* getTopicConfig(chatConfig, threadId). Empty object when there's
|
|
358
|
+
* no active topic, no override config, or topic uses legacy string
|
|
359
|
+
* form. Highest precedence — overrides chatConfig.
|
|
360
|
+
*
|
|
361
|
+
* @returns {object} SdkOptions for `query({ options: ... })`.
|
|
362
|
+
*/
|
|
363
|
+
function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}, topicConfig = {}) {
|
|
364
|
+
// Start with defaults — these are the lowest-priority.
|
|
365
|
+
const opts = { ...defaults };
|
|
366
|
+
|
|
367
|
+
// Layer agent on top.
|
|
368
|
+
if (agentBundle) {
|
|
369
|
+
if (agentBundle.systemPrompt) opts.systemPrompt = agentBundle.systemPrompt;
|
|
370
|
+
if (agentBundle.skills?.length) opts.skills = agentBundle.skills;
|
|
371
|
+
if (agentBundle.mcpServers && Object.keys(agentBundle.mcpServers).length) {
|
|
372
|
+
opts.mcpServers = { ...(opts.mcpServers || {}), ...agentBundle.mcpServers };
|
|
373
|
+
}
|
|
374
|
+
// Agent-level model/effort/etc — only if chatConfig AND
|
|
375
|
+
// topicConfig don't override.
|
|
376
|
+
for (const key of ['model', 'effort', 'thinking', 'permissionMode']) {
|
|
377
|
+
if (agentBundle.raw?.[key] != null
|
|
378
|
+
&& chatConfig[key] == null
|
|
379
|
+
&& topicConfig?.[key] == null) {
|
|
380
|
+
opts[key] = agentBundle.raw[key];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Chat-level overrides.
|
|
386
|
+
for (const [k, v] of Object.entries(chatConfig)) {
|
|
387
|
+
if (v == null) continue;
|
|
388
|
+
// Don't override the spread system-prompt with `agent` config
|
|
389
|
+
// string — that's a polygram concept, not an SdkOptions field.
|
|
390
|
+
if (k === 'agent') continue;
|
|
391
|
+
opts[k] = v;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// rc.48: per-topic overrides (highest priority). Same `agent` exclusion
|
|
395
|
+
// — `agent` here is a polygram name reference, NOT an SdkOptions
|
|
396
|
+
// field. polygram's spawn flow resolves topicConfig.agent into the
|
|
397
|
+
// correct agentBundle BEFORE calling composeSdkOptions, so by the
|
|
398
|
+
// time we get here, agentBundle already reflects the topic's agent
|
|
399
|
+
// choice and the `agent` string itself shouldn't leak into opts.
|
|
400
|
+
for (const [k, v] of Object.entries(topicConfig || {})) {
|
|
401
|
+
if (v == null) continue;
|
|
402
|
+
if (k === 'agent') continue;
|
|
403
|
+
opts[k] = v;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return opts;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function clearCache() {
|
|
410
|
+
cache.clear();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = {
|
|
414
|
+
loadAgent,
|
|
415
|
+
composeSdkOptions,
|
|
416
|
+
clearCache,
|
|
417
|
+
// Internals for tests.
|
|
418
|
+
_resolveAgentLocation: resolveAgentLocation,
|
|
419
|
+
_stripFrontmatter: stripFrontmatter,
|
|
420
|
+
_parseFrontmatter: parseFrontmatter,
|
|
421
|
+
_expandImports: expandImports,
|
|
422
|
+
_cache: cache,
|
|
423
|
+
};
|