@blunking/codexlink 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 +21 -0
- package/README.md +127 -0
- package/bin/blun-codex.js +26 -0
- package/blun-codex.cmd +3 -0
- package/blun-codex.ps1 +110 -0
- package/package.json +37 -0
- package/profiles/default.json +20 -0
- package/start-codex-agent.ps1 +727 -0
- package/start-codex.cmd +2 -0
- package/telegram-doctor.ps1 +125 -0
- package/telegram-plugin/.codex-plugin/plugin.json +6 -0
- package/telegram-plugin/.env.example +9 -0
- package/telegram-plugin/.mcp.json +8 -0
- package/telegram-plugin/README.md +68 -0
- package/telegram-plugin/app-server-cli.js +98 -0
- package/telegram-plugin/dispatcher.js +37 -0
- package/telegram-plugin/lib/app-server-client.js +290 -0
- package/telegram-plugin/lib/bridge.js +944 -0
- package/telegram-plugin/lib/codex.js +185 -0
- package/telegram-plugin/lib/env.js +46 -0
- package/telegram-plugin/lib/paths.js +45 -0
- package/telegram-plugin/lib/sidecars.js +142 -0
- package/telegram-plugin/lib/storage.js +49 -0
- package/telegram-plugin/lib/telegram.js +37 -0
- package/telegram-plugin/package.json +10 -0
- package/telegram-plugin/poller.js +37 -0
- package/telegram-plugin/responder.js +37 -0
- package/telegram-plugin/server.js +140 -0
- package/telegram-plugin/sidecar-manager.js +8 -0
- package/telegram-status.ps1 +160 -0
package/start-codex.cmd
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Profile = "default"
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
$ErrorActionPreference = "Stop"
|
|
6
|
+
|
|
7
|
+
function Read-DotEnvFile {
|
|
8
|
+
param([string]$Path)
|
|
9
|
+
$values = @{}
|
|
10
|
+
if (-not (Test-Path $Path)) { return $values }
|
|
11
|
+
foreach ($line in (Get-Content -Path $Path)) {
|
|
12
|
+
if (-not $line) { continue }
|
|
13
|
+
if ($line.Trim().StartsWith("#")) { continue }
|
|
14
|
+
$parts = $line -split "=", 2
|
|
15
|
+
if ($parts.Count -ne 2) { continue }
|
|
16
|
+
$values[$parts[0].Trim()] = $parts[1]
|
|
17
|
+
}
|
|
18
|
+
return $values
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Add-Check {
|
|
22
|
+
param(
|
|
23
|
+
[System.Collections.Generic.List[object]]$List,
|
|
24
|
+
[string]$Name,
|
|
25
|
+
[string]$Status,
|
|
26
|
+
[string]$Detail
|
|
27
|
+
)
|
|
28
|
+
$List.Add([pscustomobject]@{
|
|
29
|
+
name = $Name
|
|
30
|
+
status = $Status
|
|
31
|
+
detail = $Detail
|
|
32
|
+
}) | Out-Null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$runtimeRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
36
|
+
$profilePath = Join-Path $runtimeRoot ("profiles\" + $Profile.ToLower() + ".json")
|
|
37
|
+
$checks = New-Object 'System.Collections.Generic.List[object]'
|
|
38
|
+
|
|
39
|
+
if (Test-Path $profilePath) {
|
|
40
|
+
Add-Check -List $checks -Name "profile_file" -Status "ok" -Detail $profilePath
|
|
41
|
+
} else {
|
|
42
|
+
Add-Check -List $checks -Name "profile_file" -Status "fail" -Detail ("Missing profile: " + $profilePath)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
$statusRaw = & powershell -ExecutionPolicy Bypass -File (Join-Path $runtimeRoot "telegram-status.ps1") -Profile $Profile
|
|
46
|
+
$status = $statusRaw | ConvertFrom-Json
|
|
47
|
+
|
|
48
|
+
$nodeCommand = Get-Command node -ErrorAction SilentlyContinue
|
|
49
|
+
$codexCommand = Get-Command codex -ErrorAction SilentlyContinue
|
|
50
|
+
Add-Check -List $checks -Name "node" -Status $(if ($nodeCommand) { "ok" } else { "fail" }) -Detail $(if ($nodeCommand) { $nodeCommand.Source } else { "node not found in PATH" })
|
|
51
|
+
Add-Check -List $checks -Name "codex" -Status $(if ($codexCommand) { "ok" } else { "fail" }) -Detail $(if ($codexCommand) { $codexCommand.Source } else { "codex not found in PATH" })
|
|
52
|
+
|
|
53
|
+
if ($status.plugin_root) {
|
|
54
|
+
Add-Check -List $checks -Name "telegram_plugin_root" -Status "ok" -Detail $status.plugin_root
|
|
55
|
+
} else {
|
|
56
|
+
Add-Check -List $checks -Name "telegram_plugin_root" -Status "fail" -Detail "Telegram plugin root could not be resolved."
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if ($status.state_dir -and (Test-Path $status.state_dir)) {
|
|
60
|
+
Add-Check -List $checks -Name "state_dir" -Status "ok" -Detail $status.state_dir
|
|
61
|
+
} else {
|
|
62
|
+
Add-Check -List $checks -Name "state_dir" -Status "fail" -Detail ("Missing state dir: " + $status.state_dir)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
$activeEnv = Read-DotEnvFile -Path (Join-Path $status.state_dir ".env")
|
|
66
|
+
$legacyEnv = Read-DotEnvFile -Path (Join-Path $env:USERPROFILE ".codex\channels\codexlink-telegram\.env")
|
|
67
|
+
$tokenSource = if ($activeEnv["BLUN_TELEGRAM_BOT_TOKEN"]) {
|
|
68
|
+
"active_state_env"
|
|
69
|
+
} elseif ($legacyEnv["BLUN_TELEGRAM_BOT_TOKEN"]) {
|
|
70
|
+
"legacy_env_fallback"
|
|
71
|
+
} else {
|
|
72
|
+
""
|
|
73
|
+
}
|
|
74
|
+
Add-Check -List $checks -Name "bot_token" -Status $(if ($tokenSource) { "ok" } else { "fail" }) -Detail $(if ($tokenSource) { $tokenSource } else { "No BLUN_TELEGRAM_BOT_TOKEN found in active or legacy env files." })
|
|
75
|
+
|
|
76
|
+
Add-Check -List $checks -Name "app_server_ws" -Status $(if ($status.active_ws) { "ok" } else { "warn" }) -Detail $(if ($status.active_ws) { $status.active_ws } else { "No active websocket recorded." })
|
|
77
|
+
Add-Check -List $checks -Name "bound_thread" -Status $(if ($status.active_thread_id) { "ok" } else { "warn" }) -Detail $(if ($status.active_thread_id) { $status.active_thread_id } else { "No active thread bound yet." })
|
|
78
|
+
Add-Check -List $checks -Name "poller" -Status $(if ($status.poller_alive) { "ok" } else { "warn" }) -Detail ("pid=" + [string]$status.poller_pid + " alive=" + [string]$status.poller_alive)
|
|
79
|
+
Add-Check -List $checks -Name "dispatcher" -Status $(if ($status.dispatcher_alive) { "ok" } else { "warn" }) -Detail ("pid=" + [string]$status.dispatcher_pid + " alive=" + [string]$status.dispatcher_alive)
|
|
80
|
+
Add-Check -List $checks -Name "responder" -Status $(if ($status.responder_alive) { "ok" } else { "warn" }) -Detail ("pid=" + [string]$status.responder_pid + " alive=" + [string]$status.responder_alive)
|
|
81
|
+
|
|
82
|
+
if ($status.last_inbound) {
|
|
83
|
+
$lastInboundSummary = [string]::Format(
|
|
84
|
+
"chat={0} message={1} type={2} thread={3}",
|
|
85
|
+
$status.last_inbound.chatId,
|
|
86
|
+
$status.last_inbound.messageId,
|
|
87
|
+
$status.last_inbound.chatType,
|
|
88
|
+
$(if ($status.last_inbound.telegramThreadId) { $status.last_inbound.telegramThreadId } else { "-" })
|
|
89
|
+
)
|
|
90
|
+
Add-Check -List $checks -Name "last_inbound" -Status "ok" -Detail $lastInboundSummary
|
|
91
|
+
} else {
|
|
92
|
+
Add-Check -List $checks -Name "last_inbound" -Status "warn" -Detail "No inbound Telegram message recorded yet."
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if ($status.last_outbound) {
|
|
96
|
+
$lastOutboundSummary = [string]::Format(
|
|
97
|
+
"chat={0} message={1} reply_to={2} thread={3}",
|
|
98
|
+
$status.last_outbound.chatId,
|
|
99
|
+
$status.last_outbound.messageId,
|
|
100
|
+
$(if ($status.last_outbound.replyToMessageId) { $status.last_outbound.replyToMessageId } else { "-" }),
|
|
101
|
+
$(if ($status.last_outbound.telegramThreadId) { $status.last_outbound.telegramThreadId } else { "-" })
|
|
102
|
+
)
|
|
103
|
+
Add-Check -List $checks -Name "last_outbound" -Status "ok" -Detail $lastOutboundSummary
|
|
104
|
+
} else {
|
|
105
|
+
Add-Check -List $checks -Name "last_outbound" -Status "warn" -Detail "No outbound Telegram message recorded yet."
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Add-Check -List $checks -Name "queue" -Status $(if (([int]$status.queue_depth -eq 0) -and ([int]$status.pending_reply_depth -eq 0)) { "ok" } else { "warn" }) -Detail ("queued=" + $status.queue_depth + " submitted=" + $status.submitted_depth + " pending_replies=" + $status.pending_reply_depth)
|
|
109
|
+
|
|
110
|
+
$overall = "ok"
|
|
111
|
+
if (@($checks | Where-Object { $_.status -eq "fail" }).Count -gt 0) {
|
|
112
|
+
$overall = "fail"
|
|
113
|
+
} elseif (@($checks | Where-Object { $_.status -eq "warn" }).Count -gt 0) {
|
|
114
|
+
$overall = "warn"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
[ordered]@{
|
|
118
|
+
profile = $status.profile
|
|
119
|
+
overall = $overall
|
|
120
|
+
runtime_root = $runtimeRoot
|
|
121
|
+
state_dir = $status.state_dir
|
|
122
|
+
plugin_root = $status.plugin_root
|
|
123
|
+
checks = $checks
|
|
124
|
+
status = $status
|
|
125
|
+
} | ConvertTo-Json -Depth 8
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codexlink-telegram",
|
|
3
|
+
"description": "BLUN Telegram bridge for one visible CLI session. Queues inbound messages, binds the live thread, and sends explicit replies without a shadow bot.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"keywords": ["blun", "telegram", "codexlink", "bridge", "mcp"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
BLUN_TELEGRAM_AGENT_NAME=default
|
|
2
|
+
BLUN_TELEGRAM_BOT_TOKEN=123456789:replace_me
|
|
3
|
+
BLUN_TELEGRAM_ALLOWED_CHAT_ID=1605241602
|
|
4
|
+
BLUN_TELEGRAM_CODEX_BIN=codex
|
|
5
|
+
BLUN_TELEGRAM_STATE_DIR=
|
|
6
|
+
BLUN_TELEGRAM_THREAD_ID=
|
|
7
|
+
BLUN_TELEGRAM_RESUME_TIMEOUT_MS=15000
|
|
8
|
+
BLUN_TELEGRAM_POLL_INTERVAL_MS=5000
|
|
9
|
+
BLUN_TELEGRAM_INJECT_INTERVAL_MS=15000
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# CodexLink Telegram Plugin
|
|
2
|
+
|
|
3
|
+
This is the bundled Telegram plugin for CodexLink.
|
|
4
|
+
|
|
5
|
+
It is intentionally **not** an autonomous answer bot.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- polls Telegram updates into a local queue
|
|
10
|
+
- stores inbound and outbound history under a local state directory
|
|
11
|
+
- keeps private chats and group threads separated
|
|
12
|
+
- binds a live thread id
|
|
13
|
+
- injects the next queued Telegram message into that exact live thread
|
|
14
|
+
- sends explicit manual replies from the visible operator session
|
|
15
|
+
|
|
16
|
+
## What it does not do
|
|
17
|
+
|
|
18
|
+
- no hidden second session
|
|
19
|
+
- no autonomous answer loop
|
|
20
|
+
- no background reply worker pretending to be the operator
|
|
21
|
+
|
|
22
|
+
## State
|
|
23
|
+
|
|
24
|
+
Default state directory:
|
|
25
|
+
|
|
26
|
+
`%USERPROFILE%\\.codex\\channels\\codexlink-telegram`
|
|
27
|
+
|
|
28
|
+
Files created there:
|
|
29
|
+
|
|
30
|
+
- `.env`
|
|
31
|
+
- `state.json`
|
|
32
|
+
- `inbox.jsonl`
|
|
33
|
+
- `outbox.jsonl`
|
|
34
|
+
- `activity.log`
|
|
35
|
+
- `prompts/`
|
|
36
|
+
- `responses/`
|
|
37
|
+
- `poller.pid`
|
|
38
|
+
- `dispatcher.pid`
|
|
39
|
+
- `responder.pid`
|
|
40
|
+
|
|
41
|
+
## Env
|
|
42
|
+
|
|
43
|
+
Copy `.env.example` to `.env` in the state directory or export env vars:
|
|
44
|
+
|
|
45
|
+
- `BLUN_TELEGRAM_AGENT_NAME`
|
|
46
|
+
- `BLUN_TELEGRAM_BOT_TOKEN`
|
|
47
|
+
- `BLUN_TELEGRAM_ALLOWED_CHAT_ID`
|
|
48
|
+
- `BLUN_TELEGRAM_CODEX_BIN`
|
|
49
|
+
- `BLUN_TELEGRAM_THREAD_ID`
|
|
50
|
+
- `BLUN_TELEGRAM_RESUME_TIMEOUT_MS`
|
|
51
|
+
|
|
52
|
+
## Tools
|
|
53
|
+
|
|
54
|
+
- `bridge_status`
|
|
55
|
+
- `bridge_bind_current_thread`
|
|
56
|
+
- `bridge_poll_once`
|
|
57
|
+
- `bridge_list_queue`
|
|
58
|
+
- `bridge_inject_next`
|
|
59
|
+
- `bridge_reply`
|
|
60
|
+
- `bridge_relay_once`
|
|
61
|
+
- `bridge_tail_activity`
|
|
62
|
+
|
|
63
|
+
## Runtime split
|
|
64
|
+
|
|
65
|
+
- `poller.js` only fetches Telegram updates into the queue
|
|
66
|
+
- `dispatcher.js` only retries queue delivery into the bound live thread
|
|
67
|
+
- `responder.js` only relays finished answers back out
|
|
68
|
+
- none of them are allowed to invent an answer on their own
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { listLoadedThreadsOverWs, readThreadOverWs, startTextTurnOverWs, startThreadOverWs } from "./lib/app-server-client.js";
|
|
3
|
+
|
|
4
|
+
function writeLine(stream, text) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
stream.write(text, (error) => {
|
|
7
|
+
if (error) {
|
|
8
|
+
reject(error);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
resolve();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const [command = "", ...rest] = argv;
|
|
18
|
+
const args = { _: [], command };
|
|
19
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
20
|
+
const token = rest[i];
|
|
21
|
+
if (!token.startsWith("--")) {
|
|
22
|
+
args._.push(token);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const key = token.slice(2);
|
|
26
|
+
const next = rest[i + 1];
|
|
27
|
+
if (!next || next.startsWith("--")) {
|
|
28
|
+
args[key] = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
args[key] = next;
|
|
32
|
+
i += 1;
|
|
33
|
+
}
|
|
34
|
+
return args;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const args = parseArgs(process.argv.slice(2));
|
|
39
|
+
|
|
40
|
+
if (args.command === "start-thread") {
|
|
41
|
+
const result = await startThreadOverWs({
|
|
42
|
+
wsUrl: args["ws-url"],
|
|
43
|
+
cwd: args.cwd,
|
|
44
|
+
model: args.model,
|
|
45
|
+
sandbox: args.sandbox,
|
|
46
|
+
approvalPolicy: args["approval-policy"],
|
|
47
|
+
personality: args.personality,
|
|
48
|
+
timeoutMs: args["timeout-ms"]
|
|
49
|
+
});
|
|
50
|
+
await writeLine(process.stdout, `${JSON.stringify(result)}\n`);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (args.command === "start-turn") {
|
|
55
|
+
const result = await startTextTurnOverWs({
|
|
56
|
+
wsUrl: args["ws-url"],
|
|
57
|
+
threadId: args["thread-id"],
|
|
58
|
+
text: args.text || "",
|
|
59
|
+
model: args.model,
|
|
60
|
+
effort: args.effort,
|
|
61
|
+
personality: args.personality,
|
|
62
|
+
timeoutMs: args["timeout-ms"]
|
|
63
|
+
});
|
|
64
|
+
await writeLine(process.stdout, `${JSON.stringify(result)}\n`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (args.command === "read-thread") {
|
|
69
|
+
const result = await readThreadOverWs({
|
|
70
|
+
wsUrl: args["ws-url"],
|
|
71
|
+
threadId: args["thread-id"],
|
|
72
|
+
timeoutMs: args["timeout-ms"]
|
|
73
|
+
});
|
|
74
|
+
await writeLine(process.stdout, `${JSON.stringify(result)}\n`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (args.command === "list-loaded") {
|
|
79
|
+
const result = await listLoadedThreadsOverWs({
|
|
80
|
+
wsUrl: args["ws-url"],
|
|
81
|
+
timeoutMs: args["timeout-ms"]
|
|
82
|
+
});
|
|
83
|
+
await writeLine(process.stdout, `${JSON.stringify(result)}\n`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await writeLine(process.stderr, "Usage: node app-server-cli.js <start-thread|start-turn|read-thread|list-loaded> [--key value]\n");
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const code = await main();
|
|
93
|
+
process.exit(typeof code === "number" ? code : 0);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const message = error?.stack || error?.message || String(error);
|
|
96
|
+
await writeLine(process.stderr, `${message}\n`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { injectNext } from "./lib/bridge.js";
|
|
3
|
+
|
|
4
|
+
const intervalMs = Number.parseInt(process.env.BLUN_TELEGRAM_INJECT_INTERVAL_MS || "1500", 10) || 1500;
|
|
5
|
+
let stopping = false;
|
|
6
|
+
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
process.on("SIGINT", () => {
|
|
12
|
+
stopping = true;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
process.on("SIGTERM", () => {
|
|
16
|
+
stopping = true;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
while (!stopping) {
|
|
21
|
+
try {
|
|
22
|
+
const result = await injectNext("");
|
|
23
|
+
if (result.status !== "empty") {
|
|
24
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), kind: "inject", result }));
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(JSON.stringify({
|
|
28
|
+
ts: new Date().toISOString(),
|
|
29
|
+
kind: "error",
|
|
30
|
+
error: `${error}`
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
await sleep(intervalMs);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await main();
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
function normalizeWsUrl(rawUrl) {
|
|
2
|
+
const value = String(rawUrl || "").trim();
|
|
3
|
+
if (!value) {
|
|
4
|
+
throw new Error("App-server websocket URL is missing.");
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function makeError(message, meta = {}) {
|
|
10
|
+
const error = new Error(message);
|
|
11
|
+
Object.assign(error, meta);
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseJson(data) {
|
|
16
|
+
const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
17
|
+
return JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildInitializeRequest(id) {
|
|
21
|
+
return {
|
|
22
|
+
jsonrpc: "2.0",
|
|
23
|
+
id,
|
|
24
|
+
method: "initialize",
|
|
25
|
+
params: {
|
|
26
|
+
clientInfo: {
|
|
27
|
+
name: "codexlink-telegram",
|
|
28
|
+
version: "0.2.0"
|
|
29
|
+
},
|
|
30
|
+
capabilities: {
|
|
31
|
+
experimentalApi: true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildInitializedNotification() {
|
|
38
|
+
return {
|
|
39
|
+
jsonrpc: "2.0",
|
|
40
|
+
method: "initialized"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractThreadId(response) {
|
|
45
|
+
return response?.result?.thread?.id
|
|
46
|
+
|| response?.result?.threadId
|
|
47
|
+
|| response?.result?.id
|
|
48
|
+
|| "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractTurnId(response) {
|
|
52
|
+
return response?.result?.turn?.id
|
|
53
|
+
|| response?.result?.turnId
|
|
54
|
+
|| response?.result?.id
|
|
55
|
+
|| "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractThreadPath(response) {
|
|
59
|
+
return response?.result?.thread?.path
|
|
60
|
+
|| response?.result?.path
|
|
61
|
+
|| "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class AppServerClient {
|
|
65
|
+
constructor(wsUrl, options = {}) {
|
|
66
|
+
this.wsUrl = normalizeWsUrl(wsUrl);
|
|
67
|
+
this.timeoutMs = Number.parseInt(String(options.timeoutMs || "15000"), 10) || 15000;
|
|
68
|
+
this.socket = null;
|
|
69
|
+
this.pending = new Map();
|
|
70
|
+
this.nextId = 1;
|
|
71
|
+
this.connected = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async connect() {
|
|
75
|
+
if (this.connected && this.socket?.readyState === WebSocket.OPEN) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const socket = new WebSocket(this.wsUrl);
|
|
80
|
+
this.socket = socket;
|
|
81
|
+
|
|
82
|
+
await new Promise((resolve, reject) => {
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
reject(makeError(`Timed out connecting to ${this.wsUrl}`));
|
|
85
|
+
}, this.timeoutMs);
|
|
86
|
+
|
|
87
|
+
socket.addEventListener("open", () => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
resolve();
|
|
90
|
+
}, { once: true });
|
|
91
|
+
|
|
92
|
+
socket.addEventListener("error", (event) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
reject(makeError(`WebSocket connection failed for ${this.wsUrl}`, { cause: event?.error || event }));
|
|
95
|
+
}, { once: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
socket.addEventListener("message", (event) => {
|
|
99
|
+
let message;
|
|
100
|
+
try {
|
|
101
|
+
message = parseJson(event.data);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (message && Object.prototype.hasOwnProperty.call(message, "id")) {
|
|
107
|
+
const key = String(message.id);
|
|
108
|
+
const pending = this.pending.get(key);
|
|
109
|
+
if (!pending) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.pending.delete(key);
|
|
113
|
+
clearTimeout(pending.timer);
|
|
114
|
+
if (message.error) {
|
|
115
|
+
pending.reject(makeError(message.error.message || "App-server request failed.", {
|
|
116
|
+
code: message.error.code,
|
|
117
|
+
data: message.error.data
|
|
118
|
+
}));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
pending.resolve(message);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
socket.addEventListener("close", () => {
|
|
126
|
+
this.connected = false;
|
|
127
|
+
for (const [key, pending] of this.pending.entries()) {
|
|
128
|
+
clearTimeout(pending.timer);
|
|
129
|
+
pending.reject(makeError("App-server websocket closed before request completed."));
|
|
130
|
+
this.pending.delete(key);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const initId = String(this.nextId++);
|
|
135
|
+
const initPromise = this._waitForResponse(initId);
|
|
136
|
+
await this._sendRaw(buildInitializeRequest(Number(initId)));
|
|
137
|
+
const initializeResponse = await initPromise;
|
|
138
|
+
if (!initializeResponse?.result) {
|
|
139
|
+
throw makeError("App-server initialize returned no result.");
|
|
140
|
+
}
|
|
141
|
+
await this._sendRaw(buildInitializedNotification());
|
|
142
|
+
|
|
143
|
+
this.connected = true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async request(method, params, options = {}) {
|
|
147
|
+
await this.connect();
|
|
148
|
+
const id = String(this.nextId++);
|
|
149
|
+
const payload = {
|
|
150
|
+
jsonrpc: "2.0",
|
|
151
|
+
id,
|
|
152
|
+
method,
|
|
153
|
+
params
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const responsePromise = this._waitForResponse(id, options.timeoutMs);
|
|
157
|
+
await this._sendRaw(payload);
|
|
158
|
+
return responsePromise;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async close() {
|
|
162
|
+
if (!this.socket) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
this.socket.close();
|
|
167
|
+
} catch {
|
|
168
|
+
// Ignore shutdown errors.
|
|
169
|
+
}
|
|
170
|
+
this.connected = false;
|
|
171
|
+
this.socket = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async _sendRaw(payload) {
|
|
175
|
+
const text = JSON.stringify(payload);
|
|
176
|
+
this.socket.send(text);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_waitForResponse(id, timeoutOverride) {
|
|
180
|
+
const timeoutMs = Number.parseInt(String(timeoutOverride || this.timeoutMs), 10) || this.timeoutMs;
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
this.pending.delete(id);
|
|
184
|
+
reject(makeError(`App-server request ${id} timed out.`));
|
|
185
|
+
}, timeoutMs);
|
|
186
|
+
|
|
187
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function startThreadOverWs(options) {
|
|
193
|
+
const client = new AppServerClient(options.wsUrl, { timeoutMs: options.timeoutMs || 20000 });
|
|
194
|
+
try {
|
|
195
|
+
const response = await client.request("thread/start", {
|
|
196
|
+
cwd: options.cwd || null,
|
|
197
|
+
model: options.model || null,
|
|
198
|
+
sandbox: options.sandbox || null,
|
|
199
|
+
approvalPolicy: options.approvalPolicy || null,
|
|
200
|
+
personality: options.personality || null,
|
|
201
|
+
threadSource: "user",
|
|
202
|
+
sessionStartSource: "startup"
|
|
203
|
+
}, { timeoutMs: options.timeoutMs || 20000 });
|
|
204
|
+
|
|
205
|
+
const threadId = extractThreadId(response);
|
|
206
|
+
if (!threadId) {
|
|
207
|
+
throw makeError("App-server thread/start returned no thread id.");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
threadId,
|
|
213
|
+
response
|
|
214
|
+
};
|
|
215
|
+
} finally {
|
|
216
|
+
await client.close();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function startTextTurnOverWs(options) {
|
|
221
|
+
const client = new AppServerClient(options.wsUrl, { timeoutMs: options.timeoutMs || 20000 });
|
|
222
|
+
try {
|
|
223
|
+
const response = await client.request("turn/start", {
|
|
224
|
+
threadId: options.threadId,
|
|
225
|
+
input: [
|
|
226
|
+
{
|
|
227
|
+
type: "text",
|
|
228
|
+
text: options.text
|
|
229
|
+
}
|
|
230
|
+
],
|
|
231
|
+
model: options.model || null,
|
|
232
|
+
effort: options.effort || null,
|
|
233
|
+
personality: options.personality || null
|
|
234
|
+
}, { timeoutMs: options.timeoutMs || 20000 });
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
busy: false,
|
|
239
|
+
turnId: extractTurnId(response),
|
|
240
|
+
response
|
|
241
|
+
};
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const details = `${error?.message || error}`.toLowerCase();
|
|
244
|
+
const busy = details.includes("active turn")
|
|
245
|
+
|| details.includes("cannot accept")
|
|
246
|
+
|| details.includes("already running")
|
|
247
|
+
|| details.includes("busy");
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
busy,
|
|
252
|
+
error
|
|
253
|
+
};
|
|
254
|
+
} finally {
|
|
255
|
+
await client.close();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function readThreadOverWs(options) {
|
|
260
|
+
const client = new AppServerClient(options.wsUrl, { timeoutMs: options.timeoutMs || 10000 });
|
|
261
|
+
try {
|
|
262
|
+
const response = await client.request("thread/read", {
|
|
263
|
+
threadId: options.threadId
|
|
264
|
+
}, { timeoutMs: options.timeoutMs || 10000 });
|
|
265
|
+
const threadId = extractThreadId(response);
|
|
266
|
+
return {
|
|
267
|
+
ok: Boolean(threadId),
|
|
268
|
+
threadId,
|
|
269
|
+
threadPath: extractThreadPath(response),
|
|
270
|
+
response
|
|
271
|
+
};
|
|
272
|
+
} finally {
|
|
273
|
+
await client.close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function listLoadedThreadsOverWs(options) {
|
|
278
|
+
const client = new AppServerClient(options.wsUrl, { timeoutMs: options.timeoutMs || 10000 });
|
|
279
|
+
try {
|
|
280
|
+
const response = await client.request("thread/loaded/list", {}, { timeoutMs: options.timeoutMs || 10000 });
|
|
281
|
+
const data = Array.isArray(response?.result?.data) ? response.result.data : [];
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
data,
|
|
285
|
+
response
|
|
286
|
+
};
|
|
287
|
+
} finally {
|
|
288
|
+
await client.close();
|
|
289
|
+
}
|
|
290
|
+
}
|