viber-channel 0.5.3 → 0.6.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/README.md +28 -0
- package/lib/auth.ts +50 -1
- package/lib/bridge_spawn.ts +105 -0
- package/lib/channel_session.ts +20 -17
- package/lib/connect.ts +10 -1
- package/lib/control_stream.ts +307 -0
- package/lib/conversation.ts +23 -9
- package/lib/instance.ts +137 -0
- package/lib/messages.ts +24 -0
- package/lib/self_echo.ts +17 -0
- package/lib/supervisor.ts +292 -0
- package/lib/supervisor_config.ts +121 -0
- package/package.json +7 -2
- package/viber-channel.ts +138 -16
- package/viber-codex-bridge.ts +1247 -0
- package/viber-codex-supervisor.ts +96 -0
package/README.md
CHANGED
|
@@ -37,12 +37,40 @@ claude mcp add viber-channel --scope user \
|
|
|
37
37
|
|
|
38
38
|
Restart Claude Code in the project directory. The channel acquires a single-instance lock under `%APPDATA%/viber/` (or `~/.config/viber/` on Linux/macOS), mints a conversation against the Worker, and subscribes to the conversation-scoped SSE stream.
|
|
39
39
|
|
|
40
|
+
## Codex bridge experiment
|
|
41
|
+
|
|
42
|
+
Issue #252 adds a Codex bridge as a sibling process to the Claude Code MCP channel. The bridge joins an existing Viber conversation as its own fresh Viber instance, listens to SSE events, forwards user/user_voice messages into a bridge-owned persistent `codex app-server` thread, and posts Codex's final reply back to Viber.
|
|
43
|
+
|
|
44
|
+
Launch it against a conversation that already exists:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bun run viber-codex-bridge.ts --conversation <conversation_id>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or via env fallback:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
VIBER_TARGET_CONVERSATION_ID=<conversation_id> bun run viber-codex-bridge.ts
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For one-shot validation, add `--once`; the bridge exits after the first Codex reply.
|
|
57
|
+
|
|
58
|
+
The bridge persists its Codex thread id in `.viber/codex-bridge-threads.json` by default, keyed by project/fingerprint/conversation. Restarting the bridge resumes that thread, so Codex context carries across Viber turns. To reset/respawn the bridge-owned Codex session, start with `--new-thread`.
|
|
59
|
+
|
|
60
|
+
Initial validation runs conservatively: `sandbox=read-only`, `approvalPolicy=on-request`, network disabled for turns, and developer instructions tell Codex not to run shell commands or read/write files. This is a validation posture, not a complete security boundary yet; hard permission enforcement must be designed before widening the bridge beyond read-only. Widen permissions only after the voice round-trip and memory test are validated.
|
|
61
|
+
|
|
62
|
+
By default the bridge registers a fresh instance and does not persist it into the shared auth file. This avoids rotating another live agent's per-membership conversation token. For diagnostics only, `--reuse-auth-instance` restores the shared-auth behavior.
|
|
63
|
+
|
|
64
|
+
For an agent-to-agent proof, add `--include-agent-messages --echo-content`. The bridge then reacts to non-user messages from other instances and replies with the exact content it received.
|
|
65
|
+
|
|
40
66
|
## Configuration
|
|
41
67
|
|
|
42
68
|
| Env var | Default | Purpose |
|
|
43
69
|
|---|---|---|
|
|
44
70
|
| `VIBER_BASE_URL` | `https://viber.dgypx.dev` | Worker base URL (staging today; switch later if a separate prod hostname appears). |
|
|
45
71
|
| `VIBER_CHANNEL_LABEL` | folder basename | Human-readable name for the conversation in the UI. |
|
|
72
|
+
| `VIBER_CODEX_BIN` | `codex` | Codex executable used by `viber-codex-bridge` to spawn `codex app-server`. |
|
|
73
|
+
| `VIBER_CODEX_BRIDGE_STATE` | `.viber/codex-bridge-threads.json` | Optional path for the bridge-owned Codex thread state file. |
|
|
46
74
|
| `VIBER_CF_CLIENT_ID` / `VIBER_CF_CLIENT_SECRET` | unset | CF Access service-token headers (only needed when targeting a CF Access-protected hostname without a public bypass policy). |
|
|
47
75
|
|
|
48
76
|
## Source
|
package/lib/auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
export interface AuthJson {
|
|
@@ -20,6 +20,17 @@ export interface AuthJson {
|
|
|
20
20
|
* the first successful mint, but doing so is not required for correctness.
|
|
21
21
|
*/
|
|
22
22
|
target_conversation_id?: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Server-issued per-instance identity (#269). Both optional + additive, so
|
|
25
|
+
* `schema_version` stays **1**: an existing auth.json with neither field is
|
|
26
|
+
* valid and self-heals on first run (the channel registers an instance with
|
|
27
|
+
* its stable project_token and writes these back). `instance_token` is the
|
|
28
|
+
* durable, opaque per-agent credential (D4); `instance_id` is its server id,
|
|
29
|
+
* used to namespace the conversation handle (replacing the local fingerprint
|
|
30
|
+
* + VIBER_CHANNEL_SESSION_ID axes, #259).
|
|
31
|
+
*/
|
|
32
|
+
instance_token?: string;
|
|
33
|
+
instance_id?: string;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
/**
|
|
@@ -68,3 +79,41 @@ export function loadAuth(cwd: string = process.cwd()): AuthJson {
|
|
|
68
79
|
}
|
|
69
80
|
return auth;
|
|
70
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Merge server-issued instance credentials into the auth file in place (#269
|
|
85
|
+
* self-healing migration). Reads the current JSON, sets `instance_id` +
|
|
86
|
+
* `instance_token`, and writes it back — preserving every other field and the
|
|
87
|
+
* `schema_version` (still 1; the fields are additive + optional).
|
|
88
|
+
*
|
|
89
|
+
* Best-effort: a write failure is logged, not fatal. The channel keeps the
|
|
90
|
+
* in-memory token for this run and simply re-registers on the next run (which
|
|
91
|
+
* yields a new instance — acceptable, the old one is just never reused).
|
|
92
|
+
*/
|
|
93
|
+
export function persistInstanceCredentials(
|
|
94
|
+
path: string,
|
|
95
|
+
instanceId: string,
|
|
96
|
+
instanceToken: string,
|
|
97
|
+
log: (msg: string) => void = (m) => process.stderr.write(m),
|
|
98
|
+
): void {
|
|
99
|
+
try {
|
|
100
|
+
const raw = readFileSync(path, "utf-8");
|
|
101
|
+
const obj = JSON.parse(raw) as AuthJson;
|
|
102
|
+
obj.instance_id = instanceId;
|
|
103
|
+
obj.instance_token = instanceToken;
|
|
104
|
+
// mode 0o600: the file holds long-lived secrets (project_token + now the
|
|
105
|
+
// instance_token) — owner-read-only on POSIX (no-op on Windows). `mode` only
|
|
106
|
+
// applies when CREATING the file, so chmod afterwards too, to tighten an
|
|
107
|
+
// already-loose auth.json from an older version (best-effort).
|
|
108
|
+
writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
109
|
+
try {
|
|
110
|
+
chmodSync(path, 0o600);
|
|
111
|
+
} catch {
|
|
112
|
+
/* best-effort — some filesystems (or Windows) reject chmod */
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
log(
|
|
116
|
+
`[viber-channel] Warning: failed to persist instance credentials to \`${path}\`: ${String(err)}\n`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge spawn adapter (#280 step-12, slice 3).
|
|
3
|
+
*
|
|
4
|
+
* Turns an `AgentSpec` into a real launched `viber-codex-bridge` child process and
|
|
5
|
+
* adapts it to the Supervisor's `ChildHandle`. The node `spawn` primitive is
|
|
6
|
+
* injected so the mapping (command, args, env, lifecycle wiring) is unit-testable
|
|
7
|
+
* without launching anything.
|
|
8
|
+
*
|
|
9
|
+
* Per-agent identity (critical): each child must register its OWN Viber instance.
|
|
10
|
+
* The supervisor process may itself have been launched with VIBER_INSTANCE_TOKEN /
|
|
11
|
+
* VIBER_INSTANCE_ID in its environment; if those leaked into every child, all
|
|
12
|
+
* agents would share one instance identity and evict each other's membership (the
|
|
13
|
+
* known shared-auth gotcha). So the adapter strips them from the child env, forcing
|
|
14
|
+
* the bridge's default "register a fresh instance" path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
18
|
+
import type { AgentSpec, ChildHandle, SpawnFn } from "./supervisor.ts";
|
|
19
|
+
|
|
20
|
+
/** Minimal child surface the adapter consumes (matches node's ChildProcess). */
|
|
21
|
+
export interface SpawnedProcess {
|
|
22
|
+
readonly pid?: number;
|
|
23
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
24
|
+
once(
|
|
25
|
+
event: "exit",
|
|
26
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
27
|
+
): unknown;
|
|
28
|
+
/**
|
|
29
|
+
* Launch failures (ENOENT for a missing command, EACCES, …) arrive here as an
|
|
30
|
+
* async event — NOT a synchronous throw — so the adapter must listen for it or
|
|
31
|
+
* a failed launch would leave the agent "running" with a dead child.
|
|
32
|
+
*/
|
|
33
|
+
once(event: "error", listener: (err: Error) => void): unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The spawn primitive (injected; defaults to node's child_process.spawn). */
|
|
37
|
+
export type NodeSpawnLike = (
|
|
38
|
+
command: string,
|
|
39
|
+
args: string[],
|
|
40
|
+
options: {
|
|
41
|
+
env: NodeJS.ProcessEnv;
|
|
42
|
+
stdio: ("ignore" | "inherit" | "pipe")[];
|
|
43
|
+
},
|
|
44
|
+
) => SpawnedProcess;
|
|
45
|
+
|
|
46
|
+
export interface BridgeSpawnOptions {
|
|
47
|
+
/** Launcher command. Default: the `viber-codex-bridge` bin on PATH. */
|
|
48
|
+
command?: string;
|
|
49
|
+
/** Args prepended before each agent's own args (e.g. a script path for `bun`). */
|
|
50
|
+
baseArgs?: string[];
|
|
51
|
+
/** Spawn primitive (injected for tests). Default: node child_process.spawn. */
|
|
52
|
+
spawnImpl?: NodeSpawnLike;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a `SpawnFn` for the Supervisor that launches the Codex bridge per agent.
|
|
57
|
+
*
|
|
58
|
+
* `AgentSpec.tier` → `VIBER_AGENT_TIER`; `AgentSpec.args` → bridge CLI flags;
|
|
59
|
+
* `AgentSpec.env` is merged last (so a spec can override). `VIBER_INSTANCE_TOKEN`
|
|
60
|
+
* / `VIBER_INSTANCE_ID` are stripped unless the spec explicitly re-supplies them.
|
|
61
|
+
*/
|
|
62
|
+
export function createBridgeSpawn(opts: BridgeSpawnOptions = {}): SpawnFn {
|
|
63
|
+
const command = opts.command ?? "viber-codex-bridge";
|
|
64
|
+
const baseArgs = opts.baseArgs ?? [];
|
|
65
|
+
const spawnImpl = opts.spawnImpl ?? (nodeSpawn as unknown as NodeSpawnLike);
|
|
66
|
+
|
|
67
|
+
return (spec: AgentSpec): ChildHandle => {
|
|
68
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
69
|
+
// Force a fresh per-agent instance (see file header).
|
|
70
|
+
delete env.VIBER_INSTANCE_TOKEN;
|
|
71
|
+
delete env.VIBER_INSTANCE_ID;
|
|
72
|
+
env.VIBER_AGENT_TIER = spec.tier;
|
|
73
|
+
// Surface the agent id as the bridge's Viber instance label so distinct
|
|
74
|
+
// agents are identifiable in the control plane (participants strip / recipient
|
|
75
|
+
// picker) instead of all showing the same generic "Codex bridge" label.
|
|
76
|
+
env.VIBER_CODEX_BRIDGE_LABEL = spec.agentId;
|
|
77
|
+
if (spec.env) Object.assign(env, spec.env);
|
|
78
|
+
|
|
79
|
+
const child = spawnImpl(command, [...baseArgs, ...spec.args], {
|
|
80
|
+
env,
|
|
81
|
+
// stdin + stdout ignored, stderr inherited. stdout must NOT be inherited:
|
|
82
|
+
// if viber-channel runs over stdio MCP transport, the parent's stdout is
|
|
83
|
+
// the protocol wire and a child's stray stdout line would corrupt it.
|
|
84
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
pid: child.pid,
|
|
89
|
+
kill: (signal?: NodeJS.Signals) => child.kill(signal),
|
|
90
|
+
onExit: (cb) => {
|
|
91
|
+
// Deliver exactly one terminal callback whether the child exits or
|
|
92
|
+
// fails to launch ("error"). A launch failure maps to a crash
|
|
93
|
+
// (null code/signal) so the supervisor runs its backoff path.
|
|
94
|
+
let fired = false;
|
|
95
|
+
const fire = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
96
|
+
if (fired) return;
|
|
97
|
+
fired = true;
|
|
98
|
+
cb(code, signal);
|
|
99
|
+
};
|
|
100
|
+
child.once("exit", (code, signal) => fire(code, signal));
|
|
101
|
+
child.once("error", () => fire(null, null));
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
}
|
package/lib/channel_session.ts
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
* conversation_id here. A respawned channel process reads this file to find the
|
|
6
6
|
* existing conversation instead of minting a new one — avoiding duplicates (#257).
|
|
7
7
|
*
|
|
8
|
-
* Namespaced by `(VIBER_BASE_URL,
|
|
8
|
+
* Namespaced by `(VIBER_BASE_URL, instance_key)` since #269, where `instance_key`
|
|
9
|
+
* is the server-issued `instance_id` (or the `instance_token` when the id is
|
|
10
|
+
* unknown, e.g. an env-injected agent). This replaces the old
|
|
11
|
+
* `(client_fingerprint, VIBER_CHANNEL_SESSION_ID)` namespacing so:
|
|
9
12
|
* - dev and staging channels coexist on the same machine without colliding;
|
|
10
|
-
* - two *different*
|
|
11
|
-
* never reattach to each other's
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
* - channel respawns within one launch inherit the same session id and find
|
|
17
|
-
* the same handle — reattach still works (#257).
|
|
13
|
+
* - two *different* instances (different projects, OR two parallel agents of the
|
|
14
|
+
* same project) each have their own handle and never reattach to each other's
|
|
15
|
+
* conversation — the instance is the isolation axis now, no fingerprint or
|
|
16
|
+
* session id required (#259);
|
|
17
|
+
* - channel respawns reusing the same persisted instance_token inherit the same
|
|
18
|
+
* instance_id and find the same handle — reattach still works (#257).
|
|
18
19
|
*/
|
|
19
20
|
import { createHash } from "node:crypto";
|
|
20
21
|
import { join } from "node:path";
|
|
@@ -26,22 +27,24 @@ export interface ChannelHandle {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
* Return the session file path for a given base URL
|
|
30
|
-
* id, under `dir`.
|
|
30
|
+
* Return the session file path for a given base URL and instance key, under `dir`.
|
|
31
31
|
*
|
|
32
32
|
* The filename is `channel-session-{8-hex}.json` where the hex is a stable
|
|
33
|
-
* SHA-256 prefix of `${baseUrl}\n${
|
|
34
|
-
*
|
|
35
|
-
* the
|
|
33
|
+
* SHA-256 prefix of `${baseUrl}\n${instanceKey}`. Since #269 the handle is
|
|
34
|
+
* namespaced by the **server-issued instance** (its `instance_id`, or the
|
|
35
|
+
* `instance_token` when the id is unknown) instead of the local
|
|
36
|
+
* `(fingerprint, sessionId)` pair: one durable instance ⇒ one handle. Two
|
|
37
|
+
* parallel agents of the same project hold distinct instances, so they get
|
|
38
|
+
* distinct handles and never reattach to each other's conversation — without
|
|
39
|
+
* relying on `VIBER_CHANNEL_SESSION_ID`.
|
|
36
40
|
*/
|
|
37
41
|
export function sessionFilePath(
|
|
38
42
|
baseUrl: string,
|
|
39
|
-
|
|
40
|
-
sessionId: string,
|
|
43
|
+
instanceKey: string,
|
|
41
44
|
dir: string,
|
|
42
45
|
): string {
|
|
43
46
|
const suffix = createHash("sha256")
|
|
44
|
-
.update(`${baseUrl}\n${
|
|
47
|
+
.update(`${baseUrl}\n${instanceKey}`)
|
|
45
48
|
.digest("hex")
|
|
46
49
|
.slice(0, 8);
|
|
47
50
|
return join(dir, `channel-session-${suffix}.json`);
|
package/lib/connect.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
appendFileSync,
|
|
15
|
+
chmodSync,
|
|
15
16
|
existsSync,
|
|
16
17
|
mkdirSync,
|
|
17
18
|
readFileSync,
|
|
@@ -106,7 +107,15 @@ function writeAuthJson(
|
|
|
106
107
|
auth.target_conversation_id = data.target_conversation_id;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
// mode 0o600: auth.json holds the project_token (and later the instance_token)
|
|
111
|
+
// — owner-read-only on POSIX (no-op on Windows). chmod afterwards too, since
|
|
112
|
+
// `mode` only applies on file creation (tightens an older loose auth.json).
|
|
113
|
+
writeFileSync(authPath, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
114
|
+
try {
|
|
115
|
+
chmodSync(authPath, 0o600);
|
|
116
|
+
} catch {
|
|
117
|
+
/* best-effort — some filesystems (or Windows) reject chmod */
|
|
118
|
+
}
|
|
110
119
|
writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
|
|
111
120
|
|
|
112
121
|
// Append `.viber/` to .gitignore if not already present.
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* control_stream — subscribe to an instance-scoped server→agent control stream
|
|
3
|
+
* (#280 step-01/03).
|
|
4
|
+
*
|
|
5
|
+
* The control stream (`GET /api/instances/<id>/events`, authenticated by the
|
|
6
|
+
* instance_token) is how the server pushes commands to an agent BEFORE it is in
|
|
7
|
+
* any conversation. Today it carries:
|
|
8
|
+
* - `connected` — open marker
|
|
9
|
+
* - `ping` — keepalive
|
|
10
|
+
* - `stop` — the instance was revoked; the agent should exit
|
|
11
|
+
* - `join` — admit this agent to a conversation; the payload IS a
|
|
12
|
+
* ConversationMintResponse (conversation_id, conversation_token,
|
|
13
|
+
* ws_url, expires_at), so it feeds the conversation SSE loop
|
|
14
|
+
* directly with no copy-pasted ids or tokens (the step-03 payoff).
|
|
15
|
+
*
|
|
16
|
+
* This module is intentionally side-effect-free and runtime-agnostic (no Codex /
|
|
17
|
+
* Claude specifics) so both bridges can consume it and it can be unit-tested with
|
|
18
|
+
* a mock fetch.
|
|
19
|
+
*/
|
|
20
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
21
|
+
import type { ConversationMintResponse } from "./conversation.js";
|
|
22
|
+
|
|
23
|
+
/** One parsed Server-Sent Event from the control stream. */
|
|
24
|
+
export interface ControlEvent {
|
|
25
|
+
event: string;
|
|
26
|
+
data: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build the instance control-stream SSE URL. */
|
|
30
|
+
export function buildControlStreamUrl(baseUrl: string, instanceId: string): string {
|
|
31
|
+
return `${baseUrl}/api/instances/${instanceId}/events`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ControlStreamOptions {
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
instanceId: string;
|
|
37
|
+
instanceToken: string;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
40
|
+
fetchImpl?: typeof fetch;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Thrown when the control stream is rejected with 401 — the instance_token is
|
|
45
|
+
* revoked or invalid, so the agent must re-register (a fresh instance_id).
|
|
46
|
+
*/
|
|
47
|
+
export class ControlStreamAuthError extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super("control stream unauthorized (instance token revoked or invalid)");
|
|
50
|
+
this.name = "ControlStreamAuthError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Async generator that connects to the instance control stream and yields each
|
|
56
|
+
* parsed `{ event, data }`. One connection attempt — the caller decides whether
|
|
57
|
+
* to reconnect (the server keeps the stream open with `ping` keepalives). Throws
|
|
58
|
+
* `ControlStreamAuthError` on 401; the AbortSignal ends it cleanly.
|
|
59
|
+
*/
|
|
60
|
+
export async function* subscribeControlStream(
|
|
61
|
+
opts: ControlStreamOptions,
|
|
62
|
+
): AsyncGenerator<ControlEvent> {
|
|
63
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
64
|
+
const url = buildControlStreamUrl(opts.baseUrl, opts.instanceId);
|
|
65
|
+
|
|
66
|
+
const resp = await doFetch(url, {
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${opts.instanceToken}`,
|
|
69
|
+
Accept: "text/event-stream",
|
|
70
|
+
"Cache-Control": "no-cache",
|
|
71
|
+
...cfAccessHeaders(),
|
|
72
|
+
},
|
|
73
|
+
signal: opts.signal,
|
|
74
|
+
});
|
|
75
|
+
if (resp.status === 401) throw new ControlStreamAuthError();
|
|
76
|
+
if (!resp.ok || !resp.body) {
|
|
77
|
+
throw new Error(`control stream connection failed: HTTP ${resp.status}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const reader = resp.body.getReader();
|
|
81
|
+
const decoder = new TextDecoder();
|
|
82
|
+
let buffer = "";
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
while (true) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done) break;
|
|
88
|
+
// Normalise CRLF → LF: the SSE spec allows \r\n line/record separators and
|
|
89
|
+
// some servers (incl. Cloudflare) emit them; without this the `\n\n` split
|
|
90
|
+
// never matches and a trailing `\r` corrupts every field value.
|
|
91
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
92
|
+
|
|
93
|
+
while (true) {
|
|
94
|
+
const eventEnd = buffer.indexOf("\n\n");
|
|
95
|
+
if (eventEnd === -1) break;
|
|
96
|
+
const block = buffer.slice(0, eventEnd);
|
|
97
|
+
buffer = buffer.slice(eventEnd + 2);
|
|
98
|
+
|
|
99
|
+
let event = "";
|
|
100
|
+
let data = "";
|
|
101
|
+
for (const line of block.split("\n")) {
|
|
102
|
+
if (line.startsWith(":")) continue; // comment line (keepalive)
|
|
103
|
+
if (line.startsWith("event: ")) event = line.slice(7);
|
|
104
|
+
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
105
|
+
}
|
|
106
|
+
if (event) yield { event, data };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
// Release the reader (and cancel the body) so an early `break`/`return` by
|
|
111
|
+
// the consumer — e.g. waitForJoin resolving on the first join — or an abort
|
|
112
|
+
// doesn't leak the underlying connection until GC.
|
|
113
|
+
try {
|
|
114
|
+
await reader.cancel();
|
|
115
|
+
} catch {
|
|
116
|
+
/* already closed / aborted */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Raised when the control stream is signalled `stop` (instance revoked). */
|
|
122
|
+
export class ControlStreamStopped extends Error {
|
|
123
|
+
constructor() {
|
|
124
|
+
super("control stream received stop (instance revoked)");
|
|
125
|
+
this.name = "ControlStreamStopped";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse a `join` event's data into a validated `ConversationMintResponse`, or
|
|
131
|
+
* null if it is malformed / missing fields (the caller skips it and waits for
|
|
132
|
+
* the next). Shared by waitForJoin and runPersistentControlStream.
|
|
133
|
+
*/
|
|
134
|
+
function parseJoinPayload(
|
|
135
|
+
data: string,
|
|
136
|
+
log: (msg: string) => void,
|
|
137
|
+
): ConversationMintResponse | null {
|
|
138
|
+
let payload: Partial<ConversationMintResponse>;
|
|
139
|
+
try {
|
|
140
|
+
payload = JSON.parse(data) as Partial<ConversationMintResponse>;
|
|
141
|
+
} catch {
|
|
142
|
+
log("[control-stream] ignored malformed join payload\n");
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
typeof payload.conversation_id === "string" &&
|
|
147
|
+
typeof payload.conversation_token === "string" &&
|
|
148
|
+
typeof payload.ws_url === "string" &&
|
|
149
|
+
typeof payload.expires_at === "number"
|
|
150
|
+
) {
|
|
151
|
+
return payload as ConversationMintResponse;
|
|
152
|
+
}
|
|
153
|
+
log("[control-stream] ignored join payload with missing fields\n");
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Consume the control stream until the first `join` event and return its payload
|
|
159
|
+
* as a `ConversationMintResponse` — ready to feed the conversation SSE loop. A
|
|
160
|
+
* `stop` event before any join throws `ControlStreamStopped`. `connected`/`ping`
|
|
161
|
+
* are ignored (keepalive). Malformed `join` data is skipped (waits for the next).
|
|
162
|
+
*
|
|
163
|
+
* NOTE: this CLOSES the stream on the first join (the generator returns). For the
|
|
164
|
+
* control-plane DM model — where presence must survive after joining — use
|
|
165
|
+
* runPersistentControlStream instead, which keeps the stream open for life.
|
|
166
|
+
*/
|
|
167
|
+
export async function waitForJoin(
|
|
168
|
+
opts: ControlStreamOptions,
|
|
169
|
+
log: (msg: string) => void = () => {},
|
|
170
|
+
): Promise<ConversationMintResponse> {
|
|
171
|
+
for await (const ev of subscribeControlStream(opts)) {
|
|
172
|
+
if (ev.event === "stop") throw new ControlStreamStopped();
|
|
173
|
+
if (ev.event !== "join") continue; // connected / ping / unknown → ignore
|
|
174
|
+
const minted = parseJoinPayload(ev.data, log);
|
|
175
|
+
if (minted) return minted;
|
|
176
|
+
}
|
|
177
|
+
// Stream ended without a join — the caller reconnects.
|
|
178
|
+
throw new Error("control stream ended before a join event");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Resolve after `ms`, or early when `signal` aborts. */
|
|
182
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
if (signal?.aborted) return resolve();
|
|
185
|
+
const onAbort = () => {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
resolve();
|
|
188
|
+
};
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
signal?.removeEventListener("abort", onAbort);
|
|
191
|
+
resolve();
|
|
192
|
+
}, ms);
|
|
193
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface PersistentControlStreamHandlers {
|
|
198
|
+
/** Called for EACH pushed join (the conversation to admit this agent into). */
|
|
199
|
+
onJoin?: (minted: ConversationMintResponse) => void;
|
|
200
|
+
/**
|
|
201
|
+
* Called once when the stream terminates for a NON-transient reason:
|
|
202
|
+
* "stopped" — a `stop` event arrived (the instance was revoked)
|
|
203
|
+
* "unauthorized" — the control stream returned 401 (token revoked/invalid)
|
|
204
|
+
* The agent should shut down. NOT called on transient drops (those reconnect).
|
|
205
|
+
*/
|
|
206
|
+
onStop?: (reason: "stopped" | "unauthorized") => void;
|
|
207
|
+
log?: (msg: string) => void;
|
|
208
|
+
/** Backoff before reconnecting after a drop / clean end. Default 2000ms. */
|
|
209
|
+
reconnectDelayMs?: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface PersistentControlStream {
|
|
213
|
+
/**
|
|
214
|
+
* Resolves with the FIRST pushed join — the caller starts the conversation
|
|
215
|
+
* from it. Rejects with ControlStreamStopped / ControlStreamAuthError if the
|
|
216
|
+
* stream ends (revoke / 401) before any join, or a generic Error on abort.
|
|
217
|
+
*/
|
|
218
|
+
firstJoin: Promise<ConversationMintResponse>;
|
|
219
|
+
/** Resolves when the run loop exits (abort, stop, or auth error). */
|
|
220
|
+
done: Promise<void>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Run the instance control stream for the agent's WHOLE lifetime (hub-and-spoke:
|
|
225
|
+
* ONE persistent server→agent link). Unlike waitForJoin — which returns on the
|
|
226
|
+
* first join and CLOSES the stream — this keeps the stream open AFTER the first
|
|
227
|
+
* join so server-side presence (#280 step-02) keeps seeing the agent online and
|
|
228
|
+
* re-invitable (step-20, Codex P1-3). Reconnects with backoff on transient drops;
|
|
229
|
+
* a `stop` (revoke) or 401 ends the loop via onStop. The AbortSignal ends it.
|
|
230
|
+
*/
|
|
231
|
+
export function runPersistentControlStream(
|
|
232
|
+
opts: ControlStreamOptions,
|
|
233
|
+
handlers: PersistentControlStreamHandlers = {},
|
|
234
|
+
): PersistentControlStream {
|
|
235
|
+
const log = handlers.log ?? (() => {});
|
|
236
|
+
const delayMs = handlers.reconnectDelayMs ?? 2000;
|
|
237
|
+
const signal = opts.signal;
|
|
238
|
+
|
|
239
|
+
let firstSettled = false;
|
|
240
|
+
let resolveFirst!: (m: ConversationMintResponse) => void;
|
|
241
|
+
let rejectFirst!: (err: unknown) => void;
|
|
242
|
+
const firstJoin = new Promise<ConversationMintResponse>((res, rej) => {
|
|
243
|
+
resolveFirst = res;
|
|
244
|
+
rejectFirst = rej;
|
|
245
|
+
});
|
|
246
|
+
// Mark settled synchronously so the rejection on abort never fires after a
|
|
247
|
+
// resolve (and vice-versa) — and so an unobserved firstJoin still rejects once.
|
|
248
|
+
const settleFirstResolve = (m: ConversationMintResponse) => {
|
|
249
|
+
if (firstSettled) return;
|
|
250
|
+
firstSettled = true;
|
|
251
|
+
resolveFirst(m);
|
|
252
|
+
};
|
|
253
|
+
const settleFirstReject = (err: unknown) => {
|
|
254
|
+
if (firstSettled) return;
|
|
255
|
+
firstSettled = true;
|
|
256
|
+
rejectFirst(err);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const done = (async () => {
|
|
260
|
+
try {
|
|
261
|
+
while (true) {
|
|
262
|
+
if (signal?.aborted) {
|
|
263
|
+
settleFirstReject(new Error("control stream aborted before join"));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
for await (const ev of subscribeControlStream(opts)) {
|
|
268
|
+
if (ev.event === "stop") {
|
|
269
|
+
settleFirstReject(new ControlStreamStopped());
|
|
270
|
+
handlers.onStop?.("stopped");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (ev.event !== "join") continue; // connected / ping / unknown
|
|
274
|
+
const minted = parseJoinPayload(ev.data, log);
|
|
275
|
+
if (!minted) continue;
|
|
276
|
+
handlers.onJoin?.(minted);
|
|
277
|
+
settleFirstResolve(minted);
|
|
278
|
+
}
|
|
279
|
+
// Clean end (server closed the SSE) → reconnect after backoff.
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (err instanceof ControlStreamAuthError) {
|
|
282
|
+
settleFirstReject(err);
|
|
283
|
+
handlers.onStop?.("unauthorized");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (signal?.aborted) {
|
|
287
|
+
settleFirstReject(new Error("control stream aborted before join"));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
log(
|
|
291
|
+
`[control-stream] ${err instanceof Error ? err.message : String(err)}, reconnecting...\n`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (signal?.aborted) {
|
|
295
|
+
settleFirstReject(new Error("control stream aborted before join"));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
await abortableDelay(delayMs, signal);
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
// Defensive: never leave an awaiter of firstJoin hanging if the loop exits.
|
|
302
|
+
settleFirstReject(new Error("control stream ended before a join event"));
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
|
|
306
|
+
return { firstJoin, done };
|
|
307
|
+
}
|