connelio 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Connelio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # connelio
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Put your AI agent in [Connelio](https://connelio.app) and talk to it from your phone — like a contact.
4
+
5
+ ```bash
6
+ npx connelio connect
7
+ ```
8
+
9
+ A QR code appears in your terminal. On your phone open **Connelio → Chats → + → Connect your AI**, scan it, tap **Connect**. Your agent shows up in your chat list and says it is online. No bot tokens to copy, no BotFather.
10
+
11
+ ## What you get
12
+
13
+ - **Claude Code in your pocket.** Each message runs Claude Code in your project folder. Every step shows up as a live progress card. When Claude wants to edit a file or run a command, your phone asks **Allow / Always in this chat / Deny** — only you can tap it.
14
+ - **Any CLI agent.** `npx connelio connect -- <command>` sends each message to the command on stdin and replies with its stdout. Works with `codex exec`, `gemini`, `llm`, `ollama run …`, or your own script.
15
+ - **Groups.** Add your agent to a group; it answers when someone @mentions it. Put two agents in one group and let them work together while you watch.
16
+ - **Telegram-compatible.** Agents built on the Telegram Bot API (python-telegram-bot, grammY, aiogram, Telegraf, Hermes) connect by changing two settings: the token and the API address `https://connelio.app/tg`. See [Hermes](#hermes).
17
+
18
+ ## Private and public agents
19
+
20
+ | | `--mode private` (default) | `--mode public` |
21
+ | --- | --- | --- |
22
+ | Who can use it | only you | anyone you share it with |
23
+ | Where it works | your `--dir` (default: current folder) | an isolated folder per chat |
24
+ | Edit files / run commands | yes, with your approval on the phone | no |
25
+ | Reads | anything Claude Code can read | its own chat folder + an optional read-only `--knowledge` folder |
26
+
27
+ **Every chat is isolated.** Each conversation gets its own folder under `~/.connelio/sessions/` and its own Claude session, so one person's chat can never see another's. In public mode, the first time someone new writes to your agent they are told it runs on your computer and that you can see what they send.
28
+
29
+ ## Commands
30
+
31
+ ```
32
+ npx connelio connect [--mode private|public] [--dir PATH] [--knowledge PATH] [--name TEXT] [-- COMMAND ...]
33
+ npx connelio run [name] start a paired agent again
34
+ npx connelio list paired agents on this computer
35
+ npx connelio forget <name> delete a saved token
36
+ ```
37
+
38
+ Tokens are stored in `~/.connelio/agents/` with file mode 600. `forget` removes one; resetting the agent on your phone revokes it.
39
+
40
+ ## Hermes
41
+
42
+ One command on the computer that runs Hermes:
43
+
44
+ ```bash
45
+ npx connelio connect --hermes # use the active Hermes profile
46
+ npx connelio connect --hermes --hermes-profile connelio # keep your Telegram bot, add Connelio as its own profile
47
+ ```
48
+
49
+ Scan the QR code, and the command writes Hermes's settings (address, token, and an allow-list with only you) through `hermes config set`, then restarts the Hermes gateway.
50
+
51
+ Doing it by hand: Hermes talks to Telegram through python-telegram-bot and lets you set the API address. Create a Bot Agent in Connelio, copy its token once, then in your Hermes config:
52
+
53
+ ```yaml
54
+ platforms:
55
+ telegram:
56
+ enabled: true
57
+ extra:
58
+ base_url: https://connelio.app/tg/bot
59
+ base_file_url: https://connelio.app/tg/file/bot
60
+ ```
61
+
62
+ and set `TELEGRAM_BOT_TOKEN` to the agent's token and `TELEGRAM_ALLOWED_USERS` to your own user id.
63
+
64
+ ## Costs and accounts
65
+
66
+ The agent runs on your computer with your own Claude Code login or API key. Connelio never sees your model credentials.
67
+
68
+ ## Requirements
69
+
70
+ Node.js 20+. For Claude Code: the Claude Agent SDK (installed with this package) and a working Claude Code login on this computer.
71
+
72
+ ---
73
+
74
+ # connelio(中文)
75
+
76
+ 把你的 AI 代理接进 Connelio,在手机上像跟联系人聊天一样指挥它。
77
+
78
+ ```bash
79
+ npx connelio connect
80
+ ```
81
+
82
+ 终端里出一个二维码。手机打开 **Connelio → 消息 → 右上 + → 接入我的 AI**,扫码,点 **连接**。代理会出现在聊天列表里并说"已上线"。不用复制 token。
83
+
84
+ - **Claude Code**:每条消息在你的项目目录里跑;每一步实时显示在进度卡上;要改文件、跑命令时手机弹「允许 / 本会话都允许 / 拒绝」,只有你能点。
85
+ - **任意命令行代理**:`npx connelio connect -- <命令>`,消息从 stdin 进,stdout 作为回复。
86
+ - **群聊**:把代理拉进群,有人 @ 它就回答;两个代理放一个群可以协作。
87
+ - **兼容 Telegram**:基于 Telegram Bot API 的代理(含 Hermes)改 token 和地址两项即可接入。
88
+
89
+ **私用 / 公开**:私用(默认)只有你能用,在你指定的目录里工作;公开模式谁都能聊,但每个会话一个独立目录、不能改文件和跑命令,陌生人第一次来会被告知"代理由你运行、内容你看得到"。
90
+
91
+ 模型费用和账号都是你自己的,Connelio 看不到你的模型密钥。
package/TRADEMARKS.md ADDED
@@ -0,0 +1,5 @@
1
+ # Trademarks
2
+
3
+ The code in this repository is MIT-licensed: use it, fork it, ship it.
4
+
5
+ The MIT license does not cover the **Connelio** name or logo. You may say your project "works with Connelio" or "connects to Connelio", but please do not use the name or logo in a way that suggests your project is made or endorsed by Connelio.
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ // connelio — 把你的 AI 代理接进 Connelio:扫码配对,手机上像聊天一样指挥它。
3
+ import { resolve } from 'node:path';
4
+ import { createApi } from '../src/api.mjs';
5
+ import { listAgents, loadAgent, removeAgent, saveAgent } from '../src/config.mjs';
6
+ import { pair } from '../src/pair.mjs';
7
+ import { runAgent } from '../src/runner.mjs';
8
+ import { claudeCodeAdapter } from '../src/adapters/claude-code.mjs';
9
+ import { commandAdapter } from '../src/adapters/command.mjs';
10
+ import { configureHermes, hermesCli } from '../src/hermes.mjs';
11
+
12
+ const HELP = `connelio — put your AI agent in Connelio
13
+
14
+ npx connelio connect pair Claude Code in this folder (scan the QR with Connelio on your phone)
15
+ npx connelio connect --hermes connect the Hermes agent on this computer (writes its config, restarts it)
16
+ npx connelio connect -- <command> pair any CLI agent: each message goes to <command> on stdin, stdout is the reply
17
+ npx connelio run [name] start a paired agent again (no new pairing)
18
+ npx connelio list show paired agents on this computer
19
+ npx connelio forget <name> remove a paired agent's saved token from this computer
20
+
21
+ Options for connect:
22
+ --mode private|public private (default): only you can use it, it works in --dir with your approval on the phone
23
+ public: anyone you share it with can chat; each chat is isolated, no file changes or commands
24
+ --dir <path> project folder for private mode (default: current folder)
25
+ --knowledge <path> read-only folder that public chats may read
26
+ --name <text> how this computer is shown on your phone
27
+ --server <url> Connelio server (default https://connelio.app)
28
+ --hermes-profile <name> with --hermes: use (or create) this Hermes profile instead of the active one
29
+ --replace with --hermes: replace the Telegram bot this Hermes profile already uses
30
+ `;
31
+
32
+ function parse(argv) {
33
+ const dash = argv.indexOf('--');
34
+ const head = dash >= 0 ? argv.slice(0, dash) : argv;
35
+ const command = dash >= 0 ? argv.slice(dash + 1) : [];
36
+ const [verb, ...rest] = head;
37
+ const flags = {};
38
+ const positional = [];
39
+ for (let i = 0; i < rest.length; i++) {
40
+ const arg = rest[i];
41
+ if (arg.startsWith('--')) {
42
+ const [key, inline] = arg.slice(2).split('=');
43
+ flags[key] = inline ?? (rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[++i] : true);
44
+ } else positional.push(arg);
45
+ }
46
+ return { verb, flags, positional, command };
47
+ }
48
+
49
+ async function adapterFor(config) {
50
+ return config.command?.length ? commandAdapter(config.command) : claudeCodeAdapter();
51
+ }
52
+
53
+ async function start(config) {
54
+ const controller = new AbortController();
55
+ const stop = () => { controller.abort(); process.stdout.write('\n[connelio] stopped.\n'); setTimeout(() => process.exit(0), 200).unref(); };
56
+ process.on('SIGINT', stop);
57
+ process.on('SIGTERM', stop);
58
+ const api = createApi({ apiBase: config.apiBase, token: config.token, consumerId: `bridge-${config.deviceName ?? 'local'}`.slice(0, 64) });
59
+ const adapter = await adapterFor(config);
60
+ await runAgent({ api, config, adapter, signal: controller.signal });
61
+ saveAgent(config);
62
+ }
63
+
64
+ async function main() {
65
+ const { verb, flags, positional, command } = parse(process.argv.slice(2));
66
+ if (!verb || verb === 'help' || flags.help || flags.h) { process.stdout.write(HELP); return; }
67
+ const server = String(flags.server || process.env.CONNELIO_SERVER || 'https://connelio.app');
68
+
69
+ if (verb === 'connect' && flags.hermes) {
70
+ const profile = typeof flags['hermes-profile'] === 'string' ? flags['hermes-profile'] : '';
71
+ const hermes = hermesCli(profile);
72
+ if (!hermes.available()) throw new Error('Hermes was not found on this computer (`hermes --version` failed). Install Hermes first: https://hermes-agent.nousresearch.com');
73
+ hermes.ensureProfile();
74
+ if (hermes.existingTelegramToken() && !flags.replace) {
75
+ throw new Error(`This Hermes profile already talks to a Telegram bot. Keep it and add Connelio as a separate profile:\n npx connelio connect --hermes --hermes-profile connelio\nor replace it:\n npx connelio connect --hermes --replace`);
76
+ }
77
+ const result = await pair({ server, kind: 'hermes', deviceName: typeof flags.name === 'string' ? flags.name : undefined });
78
+ configureHermes({ hermes, server, token: result.token, ownerTelegramId: result.owner?.telegramId });
79
+ process.stdout.write(`✓ Hermes is now connected as ${result.bot.displayName} (@${result.bot.username}).\n`);
80
+ if (hermes.restart()) process.stdout.write('✓ Hermes gateway restarted. Say hi to it in Connelio.\n');
81
+ else process.stdout.write(`Start Hermes to go online: hermes${profile ? ` -p ${profile}` : ''} gateway run\n`);
82
+ return;
83
+ }
84
+ if (verb === 'connect') {
85
+ const mode = flags.mode === 'public' ? 'public' : 'private';
86
+ const kind = command.length ? 'generic' : 'claude-code';
87
+ if (!command.length) await claudeCodeAdapter(); // 先确认本机能跑 Claude Code,再去配对,避免配完才报错
88
+ const result = await pair({ server, kind, deviceName: typeof flags.name === 'string' ? flags.name : undefined });
89
+ const config = {
90
+ apiBase: server.replace(/\/+$/, ''), token: result.token, bot: result.bot, owner: result.owner,
91
+ conversationId: result.conversationId, deviceName: typeof flags.name === 'string' ? flags.name : undefined,
92
+ kind, mode, command,
93
+ dir: mode === 'private' ? resolve(typeof flags.dir === 'string' ? flags.dir : process.cwd()) : null,
94
+ knowledge: typeof flags.knowledge === 'string' ? resolve(flags.knowledge) : null,
95
+ };
96
+ const file = saveAgent(config);
97
+ process.stdout.write(`✓ Paired as ${result.bot.displayName} (@${result.bot.username}). Saved to ${file}\n`);
98
+ if (flags['no-run']) return;
99
+ await start(config);
100
+ return;
101
+ }
102
+ if (verb === 'run') {
103
+ const config = loadAgent(positional[0]);
104
+ if (!config) throw new Error(positional[0] ? `No paired agent named ${positional[0]}.` : 'Pick one: `npx connelio run <name>` (see `npx connelio list`).');
105
+ await start(config);
106
+ return;
107
+ }
108
+ if (verb === 'list') {
109
+ const agents = listAgents();
110
+ if (!agents.length) process.stdout.write('No paired agents. Run `npx connelio connect`.\n');
111
+ for (const a of agents) process.stdout.write(`${a.bot.username}\t${a.bot.displayName}\t${a.mode}\t${a.command?.length ? a.command.join(' ') : 'claude-code'}\n`);
112
+ return;
113
+ }
114
+ if (verb === 'forget') {
115
+ if (!positional[0]) throw new Error('Usage: npx connelio forget <name>');
116
+ process.stdout.write(removeAgent(positional[0]) ? 'Removed.\n' : 'Not found.\n');
117
+ return;
118
+ }
119
+ process.stdout.write(HELP);
120
+ process.exitCode = 1;
121
+ }
122
+
123
+ main().catch((error) => { process.stderr.write(`connelio: ${error?.message || error}\n`); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "connelio",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Put your AI agent (Claude Code, Hermes, any CLI) in Connelio: pair with a QR code, chat with it from your phone.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "connelio": "bin/connelio.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE",
15
+ "TRADEMARKS.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "dependencies": {
21
+ "qrcode": "1.5.4"
22
+ },
23
+ "optionalDependencies": {
24
+ "@anthropic-ai/claude-agent-sdk": "^0.3.318"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test test/*.test.mjs"
28
+ },
29
+ "keywords": [
30
+ "connelio",
31
+ "claude-code",
32
+ "hermes",
33
+ "agent",
34
+ "telegram",
35
+ "remote-control",
36
+ "chat"
37
+ ]
38
+ }
@@ -0,0 +1,110 @@
1
+ // Claude Code 适配器:用官方 Claude Agent SDK 在本机跑 Claude Code。
2
+ // - 每个会话独立的 Claude 会话(resume 上次的 session_id),历史互不可见。
3
+ // - 进度:工具调用一步一行,回到手机上的进度卡。
4
+ // - 权限:Claude 要改文件 / 跑命令时,手机上弹「允许 / 本会话都允许 / 拒绝」,只有主人能点。
5
+ // - public 模式:不给改文件、跑命令的工具;最多只读本会话目录和 --knowledge 指定的只读资料目录。
6
+ // 用的是你本机 Claude Code 的登录 / API key,费用和额度都是你自己的。
7
+ import { resolve, sep } from 'node:path';
8
+ import { realpathSync } from 'node:fs';
9
+
10
+ const READ_ONLY = ['Read', 'Grep', 'Glob'];
11
+ // public 模式只给 Claude 认证真正需要的环境变量;其余(可能含主人机器上的密钥)一律不带。
12
+ const PUBLIC_ENV_KEEP = ['PATH', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'SYSTEMROOT', 'COMSPEC',
13
+ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL',
14
+ 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR'];
15
+
16
+ const real = (p) => { try { return realpathSync(p); } catch { return null; } };
17
+ const hasMagic = (part) => /[*?[\]{}]/.test(part);
18
+
19
+ export async function claudeCodeAdapter({ sdk } = {}) {
20
+ try { sdk ??= await import('@anthropic-ai/claude-agent-sdk'); }
21
+ catch {
22
+ throw new Error('Claude Agent SDK is not installed. Run `npm i -g @anthropic-ai/claude-agent-sdk` (or reinstall connelio) and make sure Claude Code is logged in.');
23
+ }
24
+ return {
25
+ name: 'claude-code',
26
+ async run(ctx) {
27
+ const state = ctx.state();
28
+ const allowedAlways = new Set(state.alwaysAllow ?? []);
29
+ // 根目录本身也解析成真实路径(macOS 的 /tmp、/var 都是软链接,字符串前缀比较会全判错)
30
+ const roots = [ctx.dir, ...(ctx.knowledge ? [ctx.knowledge] : [])].map((p) => resolve(p)).map(real).filter(Boolean);
31
+ const underRoots = (full) => Boolean(full) && roots.some((root) => full === root || full.startsWith(root + sep));
32
+ // 目标必须先真实存在:软链接按其指向算(知识目录里放一个指向 ~/.ssh 的软链接不能读出去)
33
+ const inRoots = (p) => typeof p === 'string' && p !== '' && underRoots(real(resolve(ctx.cwd, p)));
34
+ // Glob 的 pattern 带通配符,realpath 整串会失败:取第一段通配符之前的目录(相对路径按 cwd 解析,
35
+ // 绝对路径原样),解析成真实路径再比。pattern 不存在 / 解析不出来 → 拒绝。
36
+ const globRoot = (pattern) => {
37
+ if (typeof pattern !== 'string' || pattern === '') return null;
38
+ const full = resolve(ctx.cwd, pattern);
39
+ const parts = full.split(sep);
40
+ const at = parts.findIndex(hasMagic);
41
+ const base = at === -1 ? full : (parts.slice(0, at).join(sep) || sep);
42
+ return real(base);
43
+ };
44
+ const isPublic = ctx.mode === 'public';
45
+ const prompt = [ctx.text, ...ctx.attachments.map((p) => `(The user attached a file, saved at ${p})`)].filter(Boolean).join('\n\n');
46
+
47
+ const options = {
48
+ cwd: ctx.cwd,
49
+ resume: state.claudeSessionId || undefined,
50
+ permissionMode: 'default',
51
+ // 对外代理:只读工具,且只在本会话目录 / 知识库目录内;不读主人的用户级设置(避免带出主人的 MCP、记忆等)
52
+ ...(isPublic ? { tools: ctx.knowledge ? READ_ONLY : [], settingSources: [], additionalDirectories: ctx.knowledge ? [ctx.knowledge] : [] }
53
+ : { settingSources: ['user', 'project', 'local'] }),
54
+ // private:主人自己的机器,本来就用这份环境。
55
+ // public:对外代理,只给 Claude 认证需要的那几项 + PATH/HOME(HOME 指会话目录,不把主人的家目录交出去);
56
+ // 登录凭据仍从主人自己的 Claude 配置目录读(订阅登录不会因此失效)。
57
+ env: isPublic
58
+ ? { ...Object.fromEntries(PUBLIC_ENV_KEEP.filter((k) => process.env[k]).map((k) => [k, process.env[k]])),
59
+ HOME: ctx.dir,
60
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR || resolve(process.env.HOME || ctx.dir, '.claude'),
61
+ CONNELIO_SESSION_DIR: ctx.dir,
62
+ CLAUDE_AGENT_SDK_CLIENT_APP: 'connelio-bridge/0.1.0' }
63
+ : { ...process.env, CLAUDE_AGENT_SDK_CLIENT_APP: 'connelio-bridge/0.1.0' },
64
+ canUseTool: async (toolName, input) => {
65
+ if (isPublic) {
66
+ if (!READ_ONLY.includes(toolName)) return { behavior: 'deny', message: 'This agent is in public mode: no file changes or commands.' };
67
+ const allowed = toolName === 'Glob'
68
+ ? underRoots(globRoot(input.pattern)) // pattern 可能是绝对路径 / 含通配符
69
+ : inRoots(input.file_path ?? input.path ?? ctx.cwd);
70
+ return allowed ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: 'Outside the allowed folders.' };
71
+ }
72
+ if (allowedAlways.has(toolName)) return { behavior: 'allow', updatedInput: input };
73
+ const decision = await ctx.askApproval(`Claude wants to use ${toolName}`, describe(toolName, input));
74
+ if (decision === 'always') {
75
+ allowedAlways.add(toolName);
76
+ ctx.setState({ alwaysAllow: [...allowedAlways] });
77
+ }
78
+ return decision === 'deny' ? { behavior: 'deny', message: 'The owner denied this on their phone.' } : { behavior: 'allow', updatedInput: input };
79
+ },
80
+ };
81
+
82
+ let finalText = '';
83
+ let lastAssistant = '';
84
+ for await (const message of sdk.query({ prompt, options })) {
85
+ if (message.type === 'system' && message.subtype === 'init' && message.session_id) ctx.setState({ claudeSessionId: message.session_id });
86
+ if (message.type === 'assistant') {
87
+ for (const block of message.message?.content ?? []) {
88
+ if (block.type === 'text' && block.text) lastAssistant = block.text;
89
+ if (block.type === 'tool_use') ctx.progress.step(`${block.name} ${describe(block.name, block.input)}`.trim());
90
+ }
91
+ }
92
+ if (message.type === 'result') {
93
+ if (message.session_id) ctx.setState({ claudeSessionId: message.session_id });
94
+ if (message.subtype === 'success') finalText = message.result || lastAssistant;
95
+ else throw new Error(message.subtype === 'error_max_turns' ? 'Stopped: too many steps.' : 'Claude Code stopped with an error.');
96
+ }
97
+ }
98
+ return { text: finalText || lastAssistant || '(done)' };
99
+ },
100
+ };
101
+ }
102
+
103
+ function describe(tool, input = {}) {
104
+ if (input.command) return String(input.command).slice(0, 200);
105
+ if (input.file_path) return String(input.file_path);
106
+ if (input.pattern) return String(input.pattern).slice(0, 120);
107
+ if (input.url) return String(input.url).slice(0, 200);
108
+ if (input.description) return String(input.description).slice(0, 160);
109
+ return '';
110
+ }
@@ -0,0 +1,42 @@
1
+ // 通用命令行适配器:`npx connelio connect -- <命令> [参数…]`
2
+ // 每条消息启动一次该命令,消息正文从 stdin 进、stdout 作为回复。工作目录 = 本会话的隔间(或主人指定的项目目录)。
3
+ // 适合任何"读 stdin、写 stdout"的工具:codex exec、gemini、llm、ollama run、自己写的脚本……
4
+ import { spawn } from 'node:child_process';
5
+
6
+ export function commandAdapter(argv) {
7
+ if (!argv.length) throw new Error('No command given after `--`.');
8
+ return {
9
+ name: argv[0],
10
+ async run(ctx) {
11
+ const prompt = [ctx.text, ...ctx.attachments.map((p) => `[attached file: ${p}]`)].filter(Boolean).join('\n');
12
+ ctx.progress.step(`${argv[0]} started`);
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn(argv[0], argv.slice(1), {
15
+ cwd: ctx.cwd,
16
+ // 只给必要的环境:不把本机所有环境变量(可能含密钥)原样交给对外代理
17
+ env: ctx.mode === 'public' ? minimalEnv(ctx) : { ...process.env, ...connelioEnv(ctx) },
18
+ stdio: ['pipe', 'pipe', 'pipe'],
19
+ });
20
+ let out = '', err = '';
21
+ child.stdout.on('data', (chunk) => { out += chunk; });
22
+ child.stderr.on('data', (chunk) => { err += chunk; });
23
+ child.on('error', reject);
24
+ child.on('close', (code) => {
25
+ if (code === 0) resolve({ text: out.trim() || '(no output)' });
26
+ else reject(new Error(`${argv[0]} exited with ${code}${err ? `: ${err.trim().slice(-400)}` : ''}`));
27
+ });
28
+ // 命令可能根本不读 stdin、早早退出(比如 pwd):写管道会 EPIPE,不能让它把整个桥打崩
29
+ child.stdin.on('error', () => undefined);
30
+ child.stdin.end(prompt);
31
+ });
32
+ },
33
+ };
34
+ }
35
+
36
+ function connelioEnv(ctx) {
37
+ return { CONNELIO_CONVERSATION_ID: ctx.conversationId, CONNELIO_SENDER: ctx.sender?.username ?? '', CONNELIO_IS_OWNER: ctx.isOwner ? '1' : '0', CONNELIO_SESSION_DIR: ctx.dir };
38
+ }
39
+ function minimalEnv(ctx) {
40
+ const keep = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'SYSTEMROOT', 'COMSPEC'];
41
+ return { ...Object.fromEntries(keep.filter((k) => process.env[k]).map((k) => [k, process.env[k]])), HOME: ctx.dir, ...connelioEnv(ctx) };
42
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // Connelio Bot API 客户端(原生 /api/bot/v1/*)。只用 Node 自带 fetch,不依赖第三方。
2
+
3
+ export class ConnelioApiError extends Error {
4
+ constructor(status, code, detail) {
5
+ super(`${status} ${code}${detail ? ` (${detail})` : ''}`);
6
+ this.status = status;
7
+ this.code = code;
8
+ }
9
+ }
10
+
11
+ export function createApi({ apiBase, token, consumerId = 'connelio-bridge' }) {
12
+ const base = apiBase.replace(/\/+$/, '');
13
+ async function call(method, body, { get = false, signal } = {}) {
14
+ const response = await fetch(`${base}/api/bot/v1/${method}`, {
15
+ method: get ? 'GET' : 'POST',
16
+ headers: { authorization: `Bearer ${token}`, 'x-bot-consumer': consumerId, ...(body instanceof FormData || !body ? {} : { 'content-type': 'application/json' }) },
17
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
18
+ signal,
19
+ });
20
+ const payload = await response.json().catch(() => ({}));
21
+ if (!response.ok || payload.ok === false) throw new ConnelioApiError(response.status, payload.error || `HTTP_${response.status}`, payload.detail);
22
+ return payload;
23
+ }
24
+ return {
25
+ base,
26
+ getMe: async () => (await call('getMe', null, { get: true })).result,
27
+ getUpdates: async ({ offset = 0, timeout = 25, signal } = {}) => call('getUpdates', { offset, timeout, limit: 50, consumerId }, { signal }),
28
+ sendMessage: async (conversationId, text, replyMarkup) =>
29
+ (await call('sendMessage', { conversationId, text, ...(replyMarkup ? { reply_markup: replyMarkup } : {}) })).result,
30
+ editMessage: async (messageId, fields) => call('editMessage', { messageId, ...fields }),
31
+ editButtons: async (messageId, replyMarkup) => call('editMessageReplyMarkup', { messageId, reply_markup: replyMarkup }),
32
+ typing: async (conversationId) => call('sendChatAction', { conversationId, action: 'typing' }).catch(() => undefined),
33
+ answerCallback: async (callbackQueryId, text) => call('answerCallbackQuery', { callbackQueryId, text }).catch(() => undefined),
34
+ sendDocument: async (conversationId, filename, bytes, caption) => {
35
+ const form = new FormData();
36
+ form.set('conversationId', conversationId);
37
+ if (caption) form.set('caption', caption);
38
+ form.set('file', new Blob([bytes]), filename);
39
+ return (await call('sendDocument', form)).result;
40
+ },
41
+ download: async (path) => {
42
+ const response = await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${token}` } });
43
+ if (!response.ok) throw new ConnelioApiError(response.status, 'DOWNLOAD_FAILED');
44
+ return Buffer.from(await response.arrayBuffer());
45
+ },
46
+ };
47
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // 本机保存的代理凭据:~/.connelio/agents/<username>.json,权限 600。token 只存在这里。
2
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export const home = () => process.env.CONNELIO_HOME || join(homedir(), '.connelio');
7
+ const agentsDir = () => join(home(), 'agents');
8
+
9
+ export function saveAgent(config) {
10
+ mkdirSync(agentsDir(), { recursive: true, mode: 0o700 });
11
+ const file = join(agentsDir(), `${config.bot.username}.json`);
12
+ writeFileSync(file, JSON.stringify(config, null, 2), { mode: 0o600 });
13
+ chmodSync(file, 0o600);
14
+ return file;
15
+ }
16
+
17
+ export function loadAgent(name) {
18
+ const dir = agentsDir();
19
+ if (!existsSync(dir)) return null;
20
+ const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
21
+ const pick = name ? files.find((f) => f === `${name}.json` || f === `${name}-agent.json`) : files.length === 1 ? files[0] : null;
22
+ return pick ? JSON.parse(readFileSync(join(dir, pick), 'utf8')) : null;
23
+ }
24
+
25
+ export function listAgents() {
26
+ const dir = agentsDir();
27
+ if (!existsSync(dir)) return [];
28
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')));
29
+ }
30
+
31
+ export function removeAgent(name) {
32
+ const file = join(agentsDir(), `${name}.json`);
33
+ if (!existsSync(file)) return false;
34
+ rmSync(file);
35
+ return true;
36
+ }
37
+
38
+ /** 每个会话一个隔间:独立工作目录 + 独立状态(对话历史 id 等),互相看不见。 */
39
+ export function sessionDir(username, conversationId) {
40
+ const safe = String(conversationId).replace(/[^A-Za-z0-9-]/g, '');
41
+ const dir = join(home(), 'sessions', username, safe);
42
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ return dir;
44
+ }
45
+
46
+ export function readState(dir) {
47
+ try { return JSON.parse(readFileSync(join(dir, '.connelio-state.json'), 'utf8')); } catch { return {}; }
48
+ }
49
+ export function writeState(dir, state) {
50
+ writeFileSync(join(dir, '.connelio-state.json'), JSON.stringify(state, null, 2), { mode: 0o600 });
51
+ }
package/src/hermes.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // Hermes 一键接入:配对拿到 token 后,用 Hermes 自己的 `hermes config set` 把 Telegram 通道指向 Connelio。
2
+ // Hermes 走 python-telegram-bot,原生支持 platforms.telegram.extra.base_url —— 不需要给 Hermes 装任何插件。
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ export function hermesCli(profile) {
6
+ const bin = process.env.CONNELIO_HERMES_BIN || 'hermes';
7
+ const prefix = profile ? ['-p', profile] : [];
8
+ const run = (args) => spawnSync(bin, [...prefix, ...args], { encoding: 'utf8' });
9
+ return {
10
+ available() {
11
+ const r = spawnSync(bin, ['--version'], { encoding: 'utf8' });
12
+ return !r.error && r.status === 0;
13
+ },
14
+ /** 这个 profile 是否已经接着一个 Telegram 机器人(接上 Connelio 会把它替换掉) */
15
+ existingTelegramToken() {
16
+ const r = run(['config', 'get', 'TELEGRAM_BOT_TOKEN']);
17
+ const value = (r.stdout || '').trim();
18
+ return r.status === 0 && value && !/^(none|null|not set|)$/i.test(value) ? value : '';
19
+ },
20
+ ensureProfile() {
21
+ if (!profile) return;
22
+ spawnSync(bin, ['profile', 'create', profile, '--clone'], { encoding: 'utf8' }); // 已存在会失败,无妨
23
+ },
24
+ set(key, value) {
25
+ const r = run(['config', 'set', key, String(value)]);
26
+ if (r.error || r.status !== 0) throw new Error(`hermes config set ${key} failed: ${(r.stderr || r.error?.message || '').trim()}`);
27
+ },
28
+ restart() {
29
+ const r = run(['gateway', 'restart']);
30
+ return !r.error && r.status === 0;
31
+ },
32
+ };
33
+ }
34
+
35
+ export function configureHermes({ hermes, server, token, ownerTelegramId }) {
36
+ const base = server.replace(/\/+$/, '');
37
+ hermes.set('platforms.telegram.enabled', 'true');
38
+ hermes.set('platforms.telegram.extra.base_url', `${base}/tg/bot`);
39
+ hermes.set('platforms.telegram.extra.base_file_url', `${base}/tg/file/bot`);
40
+ hermes.set('TELEGRAM_BOT_TOKEN', token);
41
+ // Hermes 默认谁都不放行:只放主人本人
42
+ if (ownerTelegramId) hermes.set('TELEGRAM_ALLOWED_USERS', ownerTelegramId);
43
+ }
package/src/pair.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // 设备码配对:终端出二维码 + 8 位码 → 手机确认 → 一次性领 token。
2
+ import QRCode from 'qrcode';
3
+ import { hostname } from 'node:os';
4
+
5
+ export async function pair({ server, kind, deviceName = prettyHost(), out = process.stdout, signal }) {
6
+ const base = server.replace(/\/+$/, '');
7
+ const started = await fetch(`${base}/api/agent-pair/start`, { method: 'POST', headers: { 'content-type': 'application/json' },
8
+ body: JSON.stringify({ deviceName, kind }), signal });
9
+ if (started.status === 404) throw new Error('This Connelio server does not have agent pairing enabled yet.');
10
+ if (!started.ok) throw new Error(`Pairing failed to start (HTTP ${started.status}).`);
11
+ const pairing = await started.json();
12
+ const qr = await QRCode.toString(pairing.qr, { type: 'terminal', small: true, errorCorrectionLevel: 'M' });
13
+ out.write(`\n${qr}\n`);
14
+ out.write(` Open Connelio on your phone → Chats → + → Connect an Agent\n`);
15
+ out.write(` Scan the QR code above, or enter this code: ${pairing.code}\n\n`);
16
+ out.write(` 在手机 Connelio 里:消息 → 右上 + → 接入 Agent,扫上面的码或输入配对码。\n\n`);
17
+ const deadline = Date.now() + pairing.expiresInSeconds * 1000;
18
+ while (Date.now() < deadline) {
19
+ if (signal?.aborted) throw new Error('Pairing cancelled.');
20
+ const response = await fetch(`${base}/api/agent-pair/poll`, { method: 'POST', headers: { 'content-type': 'application/json' },
21
+ body: JSON.stringify({ pairingId: pairing.pairingId, pollSecret: pairing.pollSecret, timeout: 20 }), signal }).catch(() => null);
22
+ if (!response) { await sleep(2000); continue; }
23
+ const result = await response.json().catch(() => ({}));
24
+ if (result.status === 'approved') return result;
25
+ if (result.status === 'denied') throw new Error('Pairing was declined on the phone.');
26
+ if (result.status === 'expired' || response.status === 404) throw new Error('The pairing code expired. Run the command again.');
27
+ if (result.status === 'claimed') throw new Error('This pairing was already used.');
28
+ }
29
+ throw new Error('The pairing code expired. Run the command again.');
30
+ }
31
+
32
+ function prettyHost() {
33
+ return hostname().replace(/\.local$/, '').slice(0, 48) || 'Computer';
34
+ }
35
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
package/src/runner.mjs ADDED
@@ -0,0 +1,203 @@
1
+ // 主循环:长轮询收消息 → 按会话排队(同一会话串行、不同会话并行)→ 交给适配器 → 进度卡 / 授权按钮 / 回复。
2
+ //
3
+ // 会话隔离(对外代理的底线):每个会话一个独立目录和独立状态(对话历史 id、附件),适配器只拿到本会话的东西。
4
+ // 访问控制:private 模式只认主人;public 模式谁都能用,但陌生人第一次来先告知"代理由谁运行、内容对方可见"。
5
+ import { randomUUID } from 'node:crypto';
6
+ import { mkdirSync, writeFileSync } from 'node:fs';
7
+ import { basename, join } from 'node:path';
8
+ import { readState, sessionDir, writeState } from './config.mjs';
9
+
10
+ const MAX_CHUNK = 3800;
11
+ const APPROVAL_TIMEOUT_MS = 10 * 60_000;
12
+ const PROGRESS_THROTTLE_MS = 1500;
13
+
14
+ export async function runAgent({ api, config, adapter, log = console.log, signal }) {
15
+ const me = await api.getMe();
16
+ const owner = config.owner;
17
+ const pendingApprovals = new Map(); // id → {resolve, conversationId, messageId, title}
18
+ const queues = new Map(); // conversationId → Promise chain
19
+ const told = new Set(); // 已经提示过的 conversationId:accountId
20
+ let offset = 0;
21
+ let leaseWarned = false;
22
+
23
+ if (config.conversationId && !config.greeted) {
24
+ await api.sendMessage(config.conversationId, `✅ ${me.displayName} is online`).catch(() => undefined);
25
+ config.greeted = true;
26
+ }
27
+ log(`[connelio] ${me.displayName} (@${me.username}) is online — mode=${config.mode}, agent=${adapter.name}`);
28
+
29
+ while (!signal?.aborted) {
30
+ let batch;
31
+ try {
32
+ batch = await api.getUpdates({ offset, timeout: 25, signal });
33
+ leaseWarned = false;
34
+ } catch (error) {
35
+ if (signal?.aborted) break;
36
+ if (error.code === 'BOT_LEASE_HELD') {
37
+ if (!leaseWarned) log('[connelio] This agent is already running on another computer. Waiting…');
38
+ leaseWarned = true;
39
+ await sleep(10_000);
40
+ } else if (error.status === 401) {
41
+ log('[connelio] The token was revoked or the agent was disabled. Run `npx connelio connect` again.');
42
+ return;
43
+ } else {
44
+ log(`[connelio] connection problem (${error.message}), retrying…`);
45
+ await sleep(3000);
46
+ }
47
+ continue;
48
+ }
49
+ for (const item of batch.result ?? []) {
50
+ if (item.callbackQuery) { await onCallback(item.callbackQuery); continue; }
51
+ if (item.message) enqueue(item.message, item.chat);
52
+ }
53
+ if (batch.cursor?.nextOffset) offset = batch.cursor.nextOffset;
54
+ }
55
+
56
+ async function onCallback(tap) {
57
+ const match = /^ap:([0-9a-f-]{36}):(allow|always|deny)$/.exec(tap.data || '');
58
+ if (!match) { await api.answerCallback(tap.id); return; }
59
+ const pending = pendingApprovals.get(match[1]);
60
+ if (!pending) { await api.answerCallback(tap.id, 'Expired'); return; }
61
+ if (tap.from?.id !== owner.accountId) { await api.answerCallback(tap.id, 'Only the owner can approve this.'); return; }
62
+ pendingApprovals.delete(match[1]);
63
+ await api.answerCallback(tap.id, match[2] === 'deny' ? 'Denied' : 'Allowed');
64
+ const label = match[2] === 'deny' ? '✗ Denied' : match[2] === 'always' ? '✓ Allowed for this chat' : '✓ Allowed';
65
+ await api.editMessage(pending.messageId, { text: `${pending.title}\n${label}` }).catch(() => undefined);
66
+ await api.editButtons(pending.messageId, null).catch(() => undefined);
67
+ pending.resolve(match[2]);
68
+ }
69
+
70
+ function enqueue(message, chat) {
71
+ const key = chat.id;
72
+ const previous = queues.get(key) ?? Promise.resolve();
73
+ const next = previous.then(() => handle(message, chat)).catch((error) => log(`[connelio] ${error?.stack || error}`));
74
+ queues.set(key, next);
75
+ next.finally(() => { if (queues.get(key) === next) queues.delete(key); });
76
+ }
77
+
78
+ async function handle(message, chat) {
79
+ const sender = message.sender ?? {};
80
+ const isOwner = sender.id === owner.accountId;
81
+ const conversationId = chat.id;
82
+ let text = String(message.content ?? '');
83
+ if (chat.type !== 'direct') {
84
+ const addressed = text.includes(`@${me.username}`) || (message.mentionedAccountIds ?? []).includes(me.accountId)
85
+ || message.repliedTo?.senderId === me.accountId || text.startsWith('/');
86
+ if (!addressed) return;
87
+ text = text.replaceAll(`@${me.username}`, '').trim();
88
+ }
89
+ const toldKey = `${conversationId}:${sender.id}`;
90
+ if (config.mode === 'private' && !isOwner) {
91
+ if (!told.has(toldKey)) { told.add(toldKey); await api.sendMessage(conversationId, 'This agent only works for its owner.'); }
92
+ return;
93
+ }
94
+ if (config.mode === 'public' && !isOwner && !told.has(toldKey)) {
95
+ told.add(toldKey);
96
+ const ownerName = owner.username ? `@${owner.username}` : 'its owner';
97
+ await api.sendMessage(conversationId, `ℹ️ This agent is run by ${ownerName} on their own computer. Anything you send here is visible to them.`);
98
+ }
99
+
100
+ const dir = sessionDir(me.username, conversationId);
101
+ const attachments = [];
102
+ if (message.attachmentUrl) {
103
+ try {
104
+ const bytes = await api.download(message.attachmentUrl);
105
+ const inbox = join(dir, 'inbox');
106
+ mkdirSync(inbox, { recursive: true });
107
+ const name = `${Date.now()}-${basename(message.attachmentName || 'file').replace(/[^\w.\-]/g, '_')}`;
108
+ writeFileSync(join(inbox, name), bytes);
109
+ attachments.push(join(inbox, name));
110
+ if (text === '[Image]' || text === message.attachmentName) text = '';
111
+ } catch (error) { log(`[connelio] attachment download failed: ${error.message}`); }
112
+ }
113
+ if (!text && !attachments.length) return;
114
+
115
+ const progress = createProgress(conversationId);
116
+ const typing = setInterval(() => void api.typing(conversationId), 2000);
117
+ void api.typing(conversationId);
118
+ try {
119
+ const result = await adapter.run({
120
+ text, attachments, conversationId, chatType: chat.type, sender, isOwner, mode: config.mode,
121
+ dir,
122
+ // 只有主人在 private 模式下才进你指定的项目目录;其余一律关在本会话的隔间里
123
+ cwd: config.mode === 'private' && isOwner && config.dir ? config.dir : dir,
124
+ knowledge: config.knowledge ?? null,
125
+ state: () => readState(dir),
126
+ setState: (patch) => writeState(dir, { ...readState(dir), ...patch }),
127
+ progress,
128
+ askApproval: (title, detail) => askApproval(conversationId, title, detail),
129
+ log,
130
+ });
131
+ await progress.finish();
132
+ if (result?.text) await reply(conversationId, result.text);
133
+ } catch (error) {
134
+ await progress.finish('✗ Failed');
135
+ await reply(conversationId, `Sorry, something went wrong: ${error?.message || error}`);
136
+ } finally {
137
+ clearInterval(typing);
138
+ }
139
+ }
140
+
141
+ function createProgress(conversationId) {
142
+ let messageId = null, lines = [], last = 0, timer = null, steps = 0;
143
+ const flush = async () => {
144
+ timer = null; last = Date.now();
145
+ const body = `⏳ Working…\n${lines.slice(-6).join('\n')}`.slice(0, MAX_CHUNK);
146
+ if (!messageId) messageId = (await api.sendMessage(conversationId, body).catch(() => null))?.id ?? null;
147
+ else await api.editMessage(messageId, { text: body }).catch(() => undefined);
148
+ };
149
+ return {
150
+ step(line) {
151
+ steps += 1;
152
+ lines.push(`• ${String(line).slice(0, 160)}`);
153
+ if (timer) return;
154
+ const wait = Math.max(0, PROGRESS_THROTTLE_MS - (Date.now() - last));
155
+ timer = setTimeout(() => void flush(), wait);
156
+ },
157
+ async finish(label) {
158
+ if (timer) { clearTimeout(timer); timer = null; }
159
+ if (!messageId) return;
160
+ await api.editMessage(messageId, { text: `${label ?? `✓ Done · ${steps} step${steps === 1 ? '' : 's'}`}\n${lines.slice(-6).join('\n')}`.slice(0, MAX_CHUNK) }).catch(() => undefined);
161
+ },
162
+ };
163
+ }
164
+
165
+ async function askApproval(conversationId, title, detail) {
166
+ const id = randomUUID();
167
+ const body = `🔐 ${title}${detail ? `\n${String(detail).slice(0, 600)}` : ''}`;
168
+ const sent = await api.sendMessage(conversationId, body, { inline_keyboard: [[
169
+ { text: 'Allow', callback_data: `ap:${id}:allow`, style: 'primary' },
170
+ { text: 'Always in this chat', callback_data: `ap:${id}:always` },
171
+ { text: 'Deny', callback_data: `ap:${id}:deny`, style: 'danger' },
172
+ ]] });
173
+ return new Promise((resolve) => {
174
+ pendingApprovals.set(id, { resolve, conversationId, messageId: sent.id, title: body });
175
+ setTimeout(() => {
176
+ if (!pendingApprovals.has(id)) return;
177
+ pendingApprovals.delete(id);
178
+ void api.editButtons(sent.id, null).catch(() => undefined);
179
+ resolve('deny');
180
+ }, APPROVAL_TIMEOUT_MS).unref?.();
181
+ });
182
+ }
183
+
184
+ async function reply(conversationId, text) {
185
+ for (const chunk of split(String(text))) await api.sendMessage(conversationId, chunk);
186
+ }
187
+ }
188
+
189
+ export function split(text) {
190
+ if (text.length <= MAX_CHUNK) return [text];
191
+ const out = [];
192
+ let rest = text;
193
+ while (rest.length > MAX_CHUNK) {
194
+ let cut = rest.lastIndexOf('\n', MAX_CHUNK);
195
+ if (cut < MAX_CHUNK * 0.5) cut = MAX_CHUNK;
196
+ out.push(rest.slice(0, cut));
197
+ rest = rest.slice(cut).replace(/^\n/, '');
198
+ }
199
+ if (rest) out.push(rest);
200
+ return out;
201
+ }
202
+
203
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));