agent-freeway 0.1.0-alpha.2
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 +21 -0
- package/README.md +180 -0
- package/bin/agent-freeway.mjs +179 -0
- package/package.json +31 -0
- package/src/adapters/telegram.mjs +30 -0
- package/src/adapters/ticlawk.mjs +51 -0
- package/src/delivery/webhook.mjs +25 -0
- package/src/index.mjs +139 -0
- package/src/runtimes/claude-code.mjs +56 -0
- package/src/runtimes/codex.mjs +129 -0
- package/src/store/file-store.mjs +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# agent-freeway
|
|
2
|
+
|
|
3
|
+
`agent-freeway` is a small hook-first bridge for local agent runtimes such as Claude Code and Codex.
|
|
4
|
+
|
|
5
|
+
It is intentionally narrow:
|
|
6
|
+
|
|
7
|
+
- send a message to a runtime
|
|
8
|
+
- normalize runtime hook events
|
|
9
|
+
- resolve those events onto your own application thread id
|
|
10
|
+
- deliver the normalized event through a product adapter
|
|
11
|
+
|
|
12
|
+
It does **not** include product-specific backend logic, pairing UX, feed/card models, scan/watch daemons, or history sync.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g agent-freeway
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Concepts
|
|
21
|
+
|
|
22
|
+
There are two ids:
|
|
23
|
+
|
|
24
|
+
- `sessionId`: the runtime session/thread id used by Claude Code or Codex
|
|
25
|
+
- `appThreadId`: your own stable conversation id
|
|
26
|
+
|
|
27
|
+
`agent-freeway` stores an explicit mapping between them. It does not guess by working directory.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
### Register a mapping
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
agent-freeway register \
|
|
35
|
+
--runtime claude_code \
|
|
36
|
+
--app-thread-id thread_123 \
|
|
37
|
+
--session-id claude_session_456 \
|
|
38
|
+
--project-dir /path/to/repo
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
agent-freeway register \
|
|
43
|
+
--runtime codex \
|
|
44
|
+
--app-thread-id thread_123 \
|
|
45
|
+
--session-id codex_thread_456 \
|
|
46
|
+
--cwd /path/to/repo
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Send a message
|
|
50
|
+
|
|
51
|
+
Resume an existing Claude Code session:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
agent-freeway send \
|
|
55
|
+
--runtime claude_code \
|
|
56
|
+
--app-thread-id thread_123 \
|
|
57
|
+
--text "hello"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Create a new Claude Code session:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
agent-freeway send \
|
|
64
|
+
--runtime claude_code \
|
|
65
|
+
--app-thread-id thread_123 \
|
|
66
|
+
--project-dir /path/to/repo \
|
|
67
|
+
--text "start a new session"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Resume an existing Codex session:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
agent-freeway send \
|
|
74
|
+
--runtime codex \
|
|
75
|
+
--app-thread-id thread_123 \
|
|
76
|
+
--text "hello"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Create a new Codex session:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
agent-freeway send \
|
|
83
|
+
--runtime codex \
|
|
84
|
+
--app-thread-id thread_123 \
|
|
85
|
+
--cwd /path/to/repo \
|
|
86
|
+
--text "start a new session"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Dispatch a hook event through an adapter
|
|
90
|
+
|
|
91
|
+
Ticlawk:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
agent-freeway dispatch-hook \
|
|
95
|
+
--runtime claude_code \
|
|
96
|
+
--adapter ticlawk
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Telegram:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
agent-freeway dispatch-hook \
|
|
103
|
+
--runtime codex \
|
|
104
|
+
--adapter telegram
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The command reads the hook payload from stdin, resolves the runtime `session_id` to a registered `appThreadId`, normalizes the event, and passes it into the selected adapter.
|
|
108
|
+
|
|
109
|
+
Built-in adapters:
|
|
110
|
+
|
|
111
|
+
- `ticlawk`
|
|
112
|
+
- `telegram`
|
|
113
|
+
|
|
114
|
+
Low-level raw webhook delivery is still available, but it is treated as a helper transport rather than the main adapter surface.
|
|
115
|
+
|
|
116
|
+
### Raw webhook delivery
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
agent-freeway dispatch-hook \
|
|
120
|
+
--runtime codex \
|
|
121
|
+
--webhook https://example.com/agent-events
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Webhook payload
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"runtime": "codex",
|
|
129
|
+
"kind": "assistant",
|
|
130
|
+
"appThreadId": "thread_123",
|
|
131
|
+
"runtimeSessionId": "codex_thread_456",
|
|
132
|
+
"text": "done",
|
|
133
|
+
"timestamp": "2026-04-06T00:00:00.000Z"
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Adapter configuration
|
|
138
|
+
|
|
139
|
+
### Ticlawk
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
export TICLAWK_API_URL=https://ticlawk.com
|
|
143
|
+
export TICLAWK_API_KEY=tk_xxxx
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The `ticlawk` adapter maps:
|
|
147
|
+
|
|
148
|
+
- `appThreadId` -> `channelId`
|
|
149
|
+
- `assistant` -> `type: "assistant"`
|
|
150
|
+
- `user` -> `type: "user"`
|
|
151
|
+
|
|
152
|
+
### Telegram
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
export TELEGRAM_BOT_TOKEN=123:abc
|
|
156
|
+
export TELEGRAM_CHAT_ID=123456789
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The `telegram` adapter sends normalized events into `sendMessage`.
|
|
160
|
+
|
|
161
|
+
## Hook snippets
|
|
162
|
+
|
|
163
|
+
Print a ready-to-paste hook command:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
agent-freeway print-hook --runtime claude_code --adapter ticlawk
|
|
167
|
+
agent-freeway print-hook --runtime codex --adapter ticlawk
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Storage
|
|
171
|
+
|
|
172
|
+
Mappings are stored in:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
~/.agent-freeway/registrations.json
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Scope
|
|
179
|
+
|
|
180
|
+
This package is best used as a small runtime bridge with product adapters, not as a full agent platform.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
dispatchHookToAdapter,
|
|
8
|
+
dispatchHookToWebhook,
|
|
9
|
+
listRegistrations,
|
|
10
|
+
sendMessage,
|
|
11
|
+
upsertRegistration,
|
|
12
|
+
} from '../src/index.mjs';
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const args = { _: [] };
|
|
16
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
17
|
+
const arg = argv[i];
|
|
18
|
+
if (arg.startsWith('--')) {
|
|
19
|
+
const key = arg.slice(2);
|
|
20
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
|
|
21
|
+
args[key] = value;
|
|
22
|
+
} else {
|
|
23
|
+
args._.push(arg);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return args;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printUsage() {
|
|
30
|
+
console.log(`agent-freeway
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
agent-freeway register --runtime <claude_code|codex> --app-thread-id <id> --session-id <id> [--project-dir <dir>] [--cwd <dir>]
|
|
34
|
+
agent-freeway list
|
|
35
|
+
agent-freeway send --runtime <claude_code|codex> --text <message> [--app-thread-id <id>] [--session-id <id>] [--project-dir <dir>] [--cwd <dir>]
|
|
36
|
+
agent-freeway dispatch-hook --runtime <claude_code|codex> --adapter <ticlawk|telegram> [adapter flags]
|
|
37
|
+
agent-freeway dispatch-hook --runtime <claude_code|codex> --webhook <url> [--bearer-token <token>]
|
|
38
|
+
agent-freeway print-hook --runtime <claude_code|codex> --adapter <ticlawk|telegram> [adapter flags]
|
|
39
|
+
agent-freeway version
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readStdinJson() {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
46
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
47
|
+
return text ? JSON.parse(text) : {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printCodexContinue() {
|
|
51
|
+
process.stdout.write('{"continue":true}\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function requireRuntime(runtime) {
|
|
55
|
+
if (runtime !== 'claude_code' && runtime !== 'codex') {
|
|
56
|
+
throw new Error('--runtime must be claude_code or codex');
|
|
57
|
+
}
|
|
58
|
+
return runtime;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printHookCommand({ runtime, webhookUrl, bearerToken }) {
|
|
62
|
+
const parts = ['agent-freeway', 'dispatch-hook', '--runtime', runtime];
|
|
63
|
+
if (webhookUrl) parts.push('--webhook', JSON.stringify(webhookUrl));
|
|
64
|
+
if (bearerToken) parts.push('--bearer-token', JSON.stringify(bearerToken));
|
|
65
|
+
console.log(parts.join(' '));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main() {
|
|
69
|
+
const args = parseArgs(process.argv.slice(2));
|
|
70
|
+
const command = args._[0];
|
|
71
|
+
|
|
72
|
+
if (!command || command === 'help' || args.help) {
|
|
73
|
+
printUsage();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (command === 'version' || args.version) {
|
|
78
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
79
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
80
|
+
console.log(pkg.version);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (command === 'register') {
|
|
85
|
+
const runtime = requireRuntime(args.runtime);
|
|
86
|
+
if (!args['app-thread-id']) throw new Error('--app-thread-id is required');
|
|
87
|
+
if (!args['session-id']) throw new Error('--session-id is required');
|
|
88
|
+
const registration = upsertRegistration({
|
|
89
|
+
runtime,
|
|
90
|
+
appThreadId: String(args['app-thread-id']),
|
|
91
|
+
sessionId: String(args['session-id']),
|
|
92
|
+
cwd: args.cwd ? String(args.cwd) : null,
|
|
93
|
+
projectDir: args['project-dir'] ? String(args['project-dir']) : null,
|
|
94
|
+
});
|
|
95
|
+
console.log(JSON.stringify(registration, null, 2));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (command === 'list') {
|
|
100
|
+
console.log(JSON.stringify(listRegistrations(), null, 2));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (command === 'send') {
|
|
105
|
+
const runtime = requireRuntime(args.runtime);
|
|
106
|
+
const result = await sendMessage({
|
|
107
|
+
runtime,
|
|
108
|
+
appThreadId: args['app-thread-id'] ? String(args['app-thread-id']) : null,
|
|
109
|
+
sessionId: args['session-id'] ? String(args['session-id']) : null,
|
|
110
|
+
text: args.text ? String(args.text) : '',
|
|
111
|
+
cwd: args.cwd ? String(args.cwd) : null,
|
|
112
|
+
projectDir: args['project-dir'] ? String(args['project-dir']) : null,
|
|
113
|
+
});
|
|
114
|
+
console.log(JSON.stringify(result, null, 2));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (command === 'dispatch-hook') {
|
|
119
|
+
const runtime = requireRuntime(args.runtime);
|
|
120
|
+
const event = await readStdinJson();
|
|
121
|
+
try {
|
|
122
|
+
const delivered = args.adapter
|
|
123
|
+
? await dispatchHookToAdapter({
|
|
124
|
+
runtime,
|
|
125
|
+
event,
|
|
126
|
+
adapter: String(args.adapter),
|
|
127
|
+
options: {
|
|
128
|
+
apiUrl: args['api-url'] ? String(args['api-url']) : undefined,
|
|
129
|
+
apiKey: args['api-key'] ? String(args['api-key']) : undefined,
|
|
130
|
+
botToken: args['bot-token'] ? String(args['bot-token']) : undefined,
|
|
131
|
+
chatId: args['chat-id'] ? String(args['chat-id']) : undefined,
|
|
132
|
+
webhookUrl: args.webhook ? String(args.webhook) : undefined,
|
|
133
|
+
bearerToken: args['bearer-token'] ? String(args['bearer-token']) : undefined,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
: await dispatchHookToWebhook({
|
|
137
|
+
runtime,
|
|
138
|
+
event,
|
|
139
|
+
webhookUrl: args.webhook ? String(args.webhook) : undefined,
|
|
140
|
+
bearerToken: args['bearer-token'] ? String(args['bearer-token']) : undefined,
|
|
141
|
+
});
|
|
142
|
+
if (runtime === 'codex') printCodexContinue();
|
|
143
|
+
if (delivered) {
|
|
144
|
+
console.error(`[agent-freeway] delivered ${delivered.kind} for ${runtime}:${delivered.runtimeSessionId}`);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (runtime === 'codex') printCodexContinue();
|
|
149
|
+
console.error(`[agent-freeway] ${err.message}`);
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (command === 'print-hook') {
|
|
156
|
+
const runtime = requireRuntime(args.runtime);
|
|
157
|
+
if (args.adapter === 'ticlawk') {
|
|
158
|
+
console.log(`agent-freeway dispatch-hook --runtime ${runtime} --adapter ticlawk`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (args.adapter === 'telegram') {
|
|
162
|
+
console.log(`agent-freeway dispatch-hook --runtime ${runtime} --adapter telegram`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
printHookCommand({
|
|
166
|
+
runtime,
|
|
167
|
+
webhookUrl: args.webhook ? String(args.webhook) : undefined,
|
|
168
|
+
bearerToken: args['bearer-token'] ? String(args['bearer-token']) : undefined,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
throw new Error(`unknown command: ${command}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
main().catch((err) => {
|
|
177
|
+
console.error(err.message || String(err));
|
|
178
|
+
process.exit(1);
|
|
179
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-freeway",
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
|
+
"description": "Hook-first runtime bridge for Claude Code and Codex.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agent-freeway": "bin/agent-freeway.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"check": "node --check bin/agent-freeway.mjs && node --check src/index.mjs && node --check src/store/file-store.mjs && node --check src/delivery/webhook.mjs && node --check src/adapters/ticlawk.mjs && node --check src/adapters/telegram.mjs && node --check src/runtimes/claude-code.mjs && node --check src/runtimes/codex.mjs"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"agent",
|
|
24
|
+
"bridge",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"codex",
|
|
27
|
+
"hooks"
|
|
28
|
+
],
|
|
29
|
+
"author": "ticlawk",
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function buildTelegramText(event) {
|
|
2
|
+
const runtime = event?.runtime || 'agent';
|
|
3
|
+
const prefix = event?.kind === 'user' ? 'User' : 'Assistant';
|
|
4
|
+
return `[${runtime}] ${prefix}\n\n${event?.text || ''}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function deliverToTelegram(event, options = {}) {
|
|
8
|
+
const botToken = options.botToken || process.env.TELEGRAM_BOT_TOKEN || '';
|
|
9
|
+
const chatId = options.chatId || event?.appThreadId || process.env.TELEGRAM_CHAT_ID || '';
|
|
10
|
+
if (!botToken) throw new Error('TELEGRAM_BOT_TOKEN is required');
|
|
11
|
+
if (!chatId) throw new Error('Telegram chat id is required');
|
|
12
|
+
|
|
13
|
+
const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
chat_id: chatId,
|
|
18
|
+
text: buildTelegramText(event),
|
|
19
|
+
disable_web_page_preview: false,
|
|
20
|
+
}),
|
|
21
|
+
signal: AbortSignal.timeout(Number(options.timeoutMs || 15000)),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const text = await res.text().catch(() => '');
|
|
26
|
+
throw new Error(`telegram ${res.status}: ${text || res.statusText}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
async function ticlawkFetch(path, options = {}) {
|
|
2
|
+
const apiUrl = options.apiUrl || process.env.TICLAWK_API_URL || 'https://ticlawk.com';
|
|
3
|
+
const apiKey = options.apiKey || process.env.TICLAWK_API_KEY || '';
|
|
4
|
+
if (!apiKey) throw new Error('TICLAWK_API_KEY is required');
|
|
5
|
+
|
|
6
|
+
const res = await fetch(`${apiUrl}${path}`, {
|
|
7
|
+
method: options.method || 'POST',
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${apiKey}`,
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
},
|
|
12
|
+
body: JSON.stringify(options.body || {}),
|
|
13
|
+
signal: AbortSignal.timeout(Number(options.timeoutMs || 15000)),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const text = await res.text().catch(() => '');
|
|
18
|
+
throw new Error(`ticlawk ${res.status}: ${text || res.statusText}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return res;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function toTiclawkDispatchEvent(event) {
|
|
25
|
+
if (!event?.appThreadId) {
|
|
26
|
+
throw new Error('appThreadId is required for ticlawk delivery');
|
|
27
|
+
}
|
|
28
|
+
if (!event?.text) {
|
|
29
|
+
throw new Error('text is required for ticlawk delivery');
|
|
30
|
+
}
|
|
31
|
+
if (event.kind !== 'assistant' && event.kind !== 'user') {
|
|
32
|
+
throw new Error(`unsupported ticlawk event kind: ${event.kind}`);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
type: event.kind,
|
|
36
|
+
text: event.text,
|
|
37
|
+
channelId: event.appThreadId,
|
|
38
|
+
timestamp: event.timestamp || new Date().toISOString(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function deliverToTiclawk(event, options = {}) {
|
|
43
|
+
const payload = toTiclawkDispatchEvent(event);
|
|
44
|
+
await ticlawkFetch('/dispatch', {
|
|
45
|
+
apiUrl: options.apiUrl,
|
|
46
|
+
apiKey: options.apiKey,
|
|
47
|
+
timeoutMs: options.timeoutMs,
|
|
48
|
+
body: payload,
|
|
49
|
+
});
|
|
50
|
+
return payload;
|
|
51
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function deliverToWebhook(event, options = {}) {
|
|
2
|
+
const webhookUrl = options.webhookUrl || process.env.AGENT_BRIDGE_WEBHOOK_URL;
|
|
3
|
+
const bearerToken = options.bearerToken || process.env.AGENT_BRIDGE_WEBHOOK_BEARER || '';
|
|
4
|
+
|
|
5
|
+
if (!webhookUrl) {
|
|
6
|
+
throw new Error('webhook URL is required');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const res = await fetch(webhookUrl, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: {
|
|
12
|
+
'Content-Type': 'application/json',
|
|
13
|
+
...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
|
|
14
|
+
},
|
|
15
|
+
body: JSON.stringify(event),
|
|
16
|
+
signal: AbortSignal.timeout(Number(options.timeoutMs || 15000)),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const text = await res.text().catch(() => '');
|
|
21
|
+
throw new Error(`webhook ${res.status}: ${text || res.statusText}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return true;
|
|
25
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listRegistrations,
|
|
3
|
+
resolveByAppThreadId,
|
|
4
|
+
resolveBySessionId,
|
|
5
|
+
upsertRegistration,
|
|
6
|
+
} from './store/file-store.mjs';
|
|
7
|
+
import { deliverToWebhook } from './delivery/webhook.mjs';
|
|
8
|
+
import { deliverToTiclawk } from './adapters/ticlawk.mjs';
|
|
9
|
+
import { deliverToTelegram } from './adapters/telegram.mjs';
|
|
10
|
+
import {
|
|
11
|
+
createClaudeSession,
|
|
12
|
+
normalizeClaudeHookEvent,
|
|
13
|
+
resumeClaudeSession,
|
|
14
|
+
} from './runtimes/claude-code.mjs';
|
|
15
|
+
import {
|
|
16
|
+
createCodexSession,
|
|
17
|
+
normalizeCodexHookEvent,
|
|
18
|
+
resumeCodexSession,
|
|
19
|
+
} from './runtimes/codex.mjs';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
listRegistrations,
|
|
23
|
+
resolveByAppThreadId,
|
|
24
|
+
resolveBySessionId,
|
|
25
|
+
upsertRegistration,
|
|
26
|
+
deliverToWebhook,
|
|
27
|
+
deliverToTiclawk,
|
|
28
|
+
deliverToTelegram,
|
|
29
|
+
createClaudeSession,
|
|
30
|
+
normalizeClaudeHookEvent,
|
|
31
|
+
resumeClaudeSession,
|
|
32
|
+
createCodexSession,
|
|
33
|
+
normalizeCodexHookEvent,
|
|
34
|
+
resumeCodexSession,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function normalizeRuntimeHook({ runtime, event, registration }) {
|
|
38
|
+
return runtime === 'claude_code'
|
|
39
|
+
? normalizeClaudeHookEvent(event, registration)
|
|
40
|
+
: runtime === 'codex'
|
|
41
|
+
? normalizeCodexHookEvent(event, registration)
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function dispatchHookEvent({ runtime, event, deliver }) {
|
|
46
|
+
const sessionId = event?.session_id ? String(event.session_id) : '';
|
|
47
|
+
if (!sessionId) return null;
|
|
48
|
+
|
|
49
|
+
const registration = resolveBySessionId(runtime, sessionId);
|
|
50
|
+
if (!registration) {
|
|
51
|
+
throw new Error(`no registration for ${runtime}:${sessionId}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalized = normalizeRuntimeHook({ runtime, event, registration });
|
|
55
|
+
|
|
56
|
+
if (!normalized) return null;
|
|
57
|
+
if (deliver) {
|
|
58
|
+
await deliver(normalized, registration);
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function dispatchHookToWebhook({ runtime, event, webhookUrl, bearerToken }) {
|
|
64
|
+
return dispatchHookEvent({
|
|
65
|
+
runtime,
|
|
66
|
+
event,
|
|
67
|
+
deliver: (normalized) => deliverToWebhook(normalized, { webhookUrl, bearerToken }),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function dispatchHookToAdapter({ runtime, event, adapter, options = {} }) {
|
|
72
|
+
if (adapter === 'ticlawk') {
|
|
73
|
+
return dispatchHookEvent({
|
|
74
|
+
runtime,
|
|
75
|
+
event,
|
|
76
|
+
deliver: (normalized) => deliverToTiclawk(normalized, options),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (adapter === 'telegram') {
|
|
80
|
+
return dispatchHookEvent({
|
|
81
|
+
runtime,
|
|
82
|
+
event,
|
|
83
|
+
deliver: (normalized) => deliverToTelegram(normalized, options),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (adapter === 'webhook') {
|
|
87
|
+
return dispatchHookToWebhook({
|
|
88
|
+
runtime,
|
|
89
|
+
event,
|
|
90
|
+
webhookUrl: options.webhookUrl,
|
|
91
|
+
bearerToken: options.bearerToken,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`unsupported adapter: ${adapter}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function sendMessage({ runtime, appThreadId, sessionId, text, cwd, projectDir }) {
|
|
98
|
+
if (!runtime) throw new Error('runtime is required');
|
|
99
|
+
if (!text) throw new Error('text is required');
|
|
100
|
+
|
|
101
|
+
const registration = appThreadId ? resolveByAppThreadId(runtime, appThreadId) : null;
|
|
102
|
+
const effectiveSessionId = sessionId || registration?.sessionId || null;
|
|
103
|
+
|
|
104
|
+
if (runtime === 'claude_code') {
|
|
105
|
+
const effectiveProjectDir = projectDir || registration?.projectDir || null;
|
|
106
|
+
if (effectiveSessionId) {
|
|
107
|
+
return resumeClaudeSession({ sessionId: effectiveSessionId, projectDir: effectiveProjectDir, text });
|
|
108
|
+
}
|
|
109
|
+
const created = createClaudeSession({ projectDir: effectiveProjectDir, text });
|
|
110
|
+
if (appThreadId) {
|
|
111
|
+
upsertRegistration({
|
|
112
|
+
runtime,
|
|
113
|
+
appThreadId,
|
|
114
|
+
sessionId: created.sessionId,
|
|
115
|
+
projectDir: created.projectDir,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return created;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (runtime === 'codex') {
|
|
122
|
+
const effectiveCwd = cwd || registration?.cwd || null;
|
|
123
|
+
if (effectiveSessionId) {
|
|
124
|
+
return resumeCodexSession({ sessionId: effectiveSessionId, cwd: effectiveCwd, text });
|
|
125
|
+
}
|
|
126
|
+
const created = await createCodexSession({ cwd: effectiveCwd, text });
|
|
127
|
+
if (appThreadId) {
|
|
128
|
+
upsertRegistration({
|
|
129
|
+
runtime,
|
|
130
|
+
appThreadId,
|
|
131
|
+
sessionId: created.sessionId,
|
|
132
|
+
cwd: created.cwd,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return created;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(`unsupported runtime: ${runtime}`);
|
|
139
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execSync, spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function normalizeClaudeHookEvent(event, registration) {
|
|
4
|
+
if (event?.hook_event_name !== 'Stop') return null;
|
|
5
|
+
if (!event?.last_assistant_message || !event?.session_id) return null;
|
|
6
|
+
if (!registration?.appThreadId) return null;
|
|
7
|
+
return {
|
|
8
|
+
runtime: 'claude_code',
|
|
9
|
+
kind: 'assistant',
|
|
10
|
+
appThreadId: registration.appThreadId,
|
|
11
|
+
runtimeSessionId: String(event.session_id),
|
|
12
|
+
text: String(event.last_assistant_message),
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resumeClaudeSession({ sessionId, projectDir, text, timeoutMs = Number(process.env.CC_EXEC_TIMEOUT_MS || 1800000) }) {
|
|
18
|
+
if (!sessionId) throw new Error('sessionId is required');
|
|
19
|
+
if (!projectDir) throw new Error('projectDir is required');
|
|
20
|
+
if (!text) throw new Error('text is required');
|
|
21
|
+
|
|
22
|
+
const child = spawn(
|
|
23
|
+
'claude',
|
|
24
|
+
['-p', text, '--resume', sessionId, '--dangerously-skip-permissions', '--output-format', 'json'],
|
|
25
|
+
{ cwd: projectDir, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (timeoutMs > 0) {
|
|
29
|
+
const timeout = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
|
|
30
|
+
timeout.unref();
|
|
31
|
+
child.on('exit', () => clearTimeout(timeout));
|
|
32
|
+
child.on('error', () => clearTimeout(timeout));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
child.unref();
|
|
36
|
+
return { sessionId, projectDir };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createClaudeSession({ projectDir, text, timeoutMs = Number(process.env.CC_EXEC_TIMEOUT_MS || 1800000) }) {
|
|
40
|
+
if (!projectDir) throw new Error('projectDir is required');
|
|
41
|
+
if (!text) throw new Error('text is required');
|
|
42
|
+
|
|
43
|
+
const output = execSync(
|
|
44
|
+
`claude -p ${JSON.stringify(text)} --dangerously-skip-permissions --output-format json`,
|
|
45
|
+
{ cwd: projectDir, timeout: timeoutMs, encoding: 'utf8' }
|
|
46
|
+
);
|
|
47
|
+
const lines = String(output || '').trim().split('\n').filter(Boolean);
|
|
48
|
+
const payload = JSON.parse(lines[lines.length - 1] || '{}');
|
|
49
|
+
if (!payload.session_id) {
|
|
50
|
+
throw new Error('claude session_id missing from output');
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
sessionId: String(payload.session_id),
|
|
54
|
+
projectDir,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function normalizeCodexHookEvent(event, registration) {
|
|
4
|
+
if (!event?.session_id || !registration?.appThreadId) return null;
|
|
5
|
+
|
|
6
|
+
if (event.hook_event_name === 'Stop' && event.last_assistant_message) {
|
|
7
|
+
return {
|
|
8
|
+
runtime: 'codex',
|
|
9
|
+
kind: 'assistant',
|
|
10
|
+
appThreadId: registration.appThreadId,
|
|
11
|
+
runtimeSessionId: String(event.session_id),
|
|
12
|
+
text: String(event.last_assistant_message),
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (event.hook_event_name === 'UserPromptSubmit' && typeof event.prompt === 'string' && event.prompt.trim()) {
|
|
18
|
+
return {
|
|
19
|
+
runtime: 'codex',
|
|
20
|
+
kind: 'user',
|
|
21
|
+
appThreadId: registration.appThreadId,
|
|
22
|
+
runtimeSessionId: String(event.session_id),
|
|
23
|
+
text: event.prompt.trim(),
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resumeCodexSession({ sessionId, cwd, text, timeoutMs = Number(process.env.CODEX_EXEC_TIMEOUT_MS || 1800000) }) {
|
|
32
|
+
if (!sessionId) throw new Error('sessionId is required');
|
|
33
|
+
if (!cwd) throw new Error('cwd is required');
|
|
34
|
+
if (!text) throw new Error('text is required');
|
|
35
|
+
|
|
36
|
+
const child = spawn(
|
|
37
|
+
'codex',
|
|
38
|
+
[
|
|
39
|
+
'exec',
|
|
40
|
+
'resume',
|
|
41
|
+
sessionId,
|
|
42
|
+
'--json',
|
|
43
|
+
'--skip-git-repo-check',
|
|
44
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
45
|
+
text,
|
|
46
|
+
],
|
|
47
|
+
{ cwd, stdio: 'ignore' }
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (timeoutMs > 0) {
|
|
51
|
+
const timeout = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
|
|
52
|
+
timeout.unref();
|
|
53
|
+
child.on('exit', () => clearTimeout(timeout));
|
|
54
|
+
child.on('error', () => clearTimeout(timeout));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
child.unref();
|
|
58
|
+
return { sessionId, cwd };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createCodexSession({ cwd, text, timeoutMs = Number(process.env.CODEX_EXEC_TIMEOUT_MS || 1800000) }) {
|
|
62
|
+
if (!cwd) throw new Error('cwd is required');
|
|
63
|
+
if (!text) throw new Error('text is required');
|
|
64
|
+
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const child = spawn(
|
|
67
|
+
'codex',
|
|
68
|
+
[
|
|
69
|
+
'exec',
|
|
70
|
+
'--json',
|
|
71
|
+
'--skip-git-repo-check',
|
|
72
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
73
|
+
text,
|
|
74
|
+
],
|
|
75
|
+
{
|
|
76
|
+
cwd,
|
|
77
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
let settled = false;
|
|
82
|
+
let buffer = '';
|
|
83
|
+
let timeout = null;
|
|
84
|
+
|
|
85
|
+
const settle = (fn, value) => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
if (timeout) clearTimeout(timeout);
|
|
89
|
+
fn(value);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (timeoutMs > 0) {
|
|
93
|
+
timeout = setTimeout(() => {
|
|
94
|
+
child.kill('SIGTERM');
|
|
95
|
+
settle(reject, new Error('codex session creation timed out'));
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
timeout.unref();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
child.stdout.on('data', (chunk) => {
|
|
101
|
+
buffer += chunk.toString('utf8');
|
|
102
|
+
const lines = buffer.split('\n');
|
|
103
|
+
buffer = lines.pop() || '';
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (!line.trim()) continue;
|
|
106
|
+
let payload;
|
|
107
|
+
try {
|
|
108
|
+
payload = JSON.parse(line);
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (payload.type === 'thread.started' && payload.thread_id) {
|
|
113
|
+
settle(resolve, {
|
|
114
|
+
sessionId: String(payload.thread_id),
|
|
115
|
+
cwd,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
child.on('error', (err) => settle(reject, err));
|
|
123
|
+
child.on('exit', (code, signal) => {
|
|
124
|
+
if (settled) return;
|
|
125
|
+
if (signal) settle(reject, new Error(`codex session exited via ${signal}`));
|
|
126
|
+
else if (code !== 0) settle(reject, new Error(`codex session exited with code ${code}`));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const AGENT_FREEWAY_HOME = join(homedir(), '.agent-freeway');
|
|
6
|
+
export const REGISTRATIONS_PATH = join(AGENT_FREEWAY_HOME, 'registrations.json');
|
|
7
|
+
|
|
8
|
+
function ensureStateDir() {
|
|
9
|
+
mkdirSync(AGENT_FREEWAY_HOME, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeState(raw) {
|
|
13
|
+
if (Array.isArray(raw)) return { registrations: raw };
|
|
14
|
+
if (raw && Array.isArray(raw.registrations)) return { registrations: raw.registrations };
|
|
15
|
+
return { registrations: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadState() {
|
|
19
|
+
if (!existsSync(REGISTRATIONS_PATH)) return { registrations: [] };
|
|
20
|
+
try {
|
|
21
|
+
return normalizeState(JSON.parse(readFileSync(REGISTRATIONS_PATH, 'utf8')));
|
|
22
|
+
} catch {
|
|
23
|
+
return { registrations: [] };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveState(state) {
|
|
28
|
+
ensureStateDir();
|
|
29
|
+
writeFileSync(REGISTRATIONS_PATH, `${JSON.stringify(normalizeState(state), null, 2)}\n`, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function listRegistrations() {
|
|
33
|
+
return loadState().registrations;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function upsertRegistration(registration) {
|
|
37
|
+
const state = loadState();
|
|
38
|
+
const next = {
|
|
39
|
+
runtime: registration.runtime,
|
|
40
|
+
appThreadId: registration.appThreadId,
|
|
41
|
+
sessionId: registration.sessionId,
|
|
42
|
+
cwd: registration.cwd || null,
|
|
43
|
+
projectDir: registration.projectDir || null,
|
|
44
|
+
updatedAt: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
state.registrations = state.registrations.filter((item) => {
|
|
47
|
+
if (item.runtime !== next.runtime) return true;
|
|
48
|
+
if (item.appThreadId === next.appThreadId) return false;
|
|
49
|
+
if (item.sessionId === next.sessionId) return false;
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
state.registrations.push(next);
|
|
53
|
+
saveState(state);
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveByAppThreadId(runtime, appThreadId) {
|
|
58
|
+
return listRegistrations().find((item) => item.runtime === runtime && item.appThreadId === appThreadId) || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveBySessionId(runtime, sessionId) {
|
|
62
|
+
return listRegistrations().find((item) => item.runtime === runtime && item.sessionId === sessionId) || null;
|
|
63
|
+
}
|