@gajae-code/coding-agent 0.11.6 → 0.11.7
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/CHANGELOG.md +37 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +56 -6
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
- package/dist/types/main.d.ts +24 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-status-header.d.ts +1 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -3
- package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +28 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
- package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
- package/dist/types/session/session-manager.d.ts +25 -4
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +16 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +34 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +42 -9
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profiles.ts +18 -0
- package/src/config/settings-schema.ts +57 -6
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/ultragoal-guard.ts +88 -10
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/internal-urls/local-protocol.ts +32 -6
- package/src/main.ts +86 -27
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +31 -3
- package/src/modes/components/tool-status-header.ts +6 -1
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/input-controller.ts +31 -0
- package/src/modes/controllers/selector-controller.ts +106 -12
- package/src/modes/interactive-mode.ts +16 -7
- package/src/modes/types.ts +1 -0
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/transports/http.ts +50 -16
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/index.ts +4 -5
- package/src/sdk/bus/lifecycle-commands.ts +16 -7
- package/src/sdk/bus/notification-service.ts +41 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -3
- package/src/sdk/bus/telegram-daemon-control.ts +2 -2
- package/src/sdk/bus/telegram-daemon.ts +1663 -375
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +29 -5
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +240 -149
- package/src/session/blob-store.ts +19 -6
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +313 -57
- package/src/session/internal/managed-session-storage.ts +146 -35
- package/src/session/session-manager.ts +319 -85
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/tools/ask.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +301 -20
- package/src/tools/read.ts +2 -0
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +138 -27
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
package/src/sdk/session.ts
CHANGED
|
@@ -92,6 +92,7 @@ import type { FileSlashCommand } from "../extensibility/slash-commands";
|
|
|
92
92
|
import type { HindsightSessionState } from "../hindsight/state";
|
|
93
93
|
import { initializeLocalRoot, LocalProtocolHandler, type LocalProtocolOptions } from "../internal-urls";
|
|
94
94
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
95
|
+
import btwUserPrompt from "../prompts/system/btw-user.md" with { type: "text" };
|
|
95
96
|
import asyncResultTemplate from "../prompts/tools/async-result.md" with { type: "text" };
|
|
96
97
|
import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
|
|
97
98
|
import { MCPManager } from "../runtime-mcp";
|
|
@@ -149,6 +150,7 @@ import {
|
|
|
149
150
|
ReadTool,
|
|
150
151
|
ResolveTool,
|
|
151
152
|
SearchTool,
|
|
153
|
+
setConfiguredImageModel,
|
|
152
154
|
setPreferredImageProvider,
|
|
153
155
|
setPreferredSearchProvider,
|
|
154
156
|
setSearchFallbackProviders,
|
|
@@ -1158,14 +1160,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1158
1160
|
applyConfiguredSearchTimeout(settings);
|
|
1159
1161
|
|
|
1160
1162
|
const imageProvider = settings.get("providers.image");
|
|
1163
|
+
const imageModel = settings.get("providers.imageModel");
|
|
1164
|
+
const imageCustomUrl = settings.get("providers.imageCustomUrl");
|
|
1165
|
+
const imageCustomKey = settings.get("providers.imageCustomKey");
|
|
1166
|
+
const imageCustomKeyEnv = settings.get("providers.imageCustomKeyEnv");
|
|
1161
1167
|
if (
|
|
1162
1168
|
imageProvider === "auto" ||
|
|
1163
1169
|
imageProvider === "openai" ||
|
|
1164
1170
|
imageProvider === "gemini" ||
|
|
1165
1171
|
imageProvider === "openrouter" ||
|
|
1166
|
-
imageProvider === "antigravity"
|
|
1172
|
+
imageProvider === "antigravity" ||
|
|
1173
|
+
imageProvider === "custom"
|
|
1167
1174
|
) {
|
|
1168
|
-
setPreferredImageProvider(imageProvider);
|
|
1175
|
+
setPreferredImageProvider(imageProvider === "custom" ? "auto" : imageProvider);
|
|
1176
|
+
setConfiguredImageModel({
|
|
1177
|
+
provider: imageProvider,
|
|
1178
|
+
model: imageModel ?? null,
|
|
1179
|
+
customUrl: imageCustomUrl,
|
|
1180
|
+
customKey: imageCustomKey,
|
|
1181
|
+
customKeyEnv: imageCustomKeyEnv,
|
|
1182
|
+
});
|
|
1169
1183
|
}
|
|
1170
1184
|
|
|
1171
1185
|
const sessionManager =
|
|
@@ -1590,13 +1604,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1590
1604
|
// which collapses to the parent's dir for subagents (they adopt the
|
|
1591
1605
|
// parent's ArtifactManager) so one lookup hits everything.
|
|
1592
1606
|
const getArtifactsDir = () => sessionManager.getArtifactsDir();
|
|
1607
|
+
const localProtocolOptions = options.localProtocolOptions ?? {
|
|
1608
|
+
getArtifactsDir,
|
|
1609
|
+
isManagedDestination: () => sessionManager.isManagedDestination(),
|
|
1610
|
+
getManagedLegacyLocalMigrationSource: () => sessionManager.getManagedLegacyLocalMigrationSource(),
|
|
1611
|
+
getSessionId: () => sessionManager.getSessionId(),
|
|
1612
|
+
};
|
|
1593
1613
|
if (!options.parentTaskPrefix) {
|
|
1594
1614
|
setActiveSkills(skills);
|
|
1595
1615
|
setActiveRules([...rulebookRules, ...alwaysApplyRules]);
|
|
1596
1616
|
if (asyncJobManager) AsyncJobManager.setInstance(asyncJobManager);
|
|
1597
1617
|
}
|
|
1618
|
+
await initializeLocalRoot(localProtocolOptions);
|
|
1598
1619
|
if (options.localProtocolOptions) {
|
|
1599
|
-
await initializeLocalRoot(options.localProtocolOptions);
|
|
1600
1620
|
disposeLocalProtocolOverride = LocalProtocolHandler.installOverride(options.localProtocolOptions);
|
|
1601
1621
|
}
|
|
1602
1622
|
toolSession.getArtifactsDir = getArtifactsDir;
|
|
@@ -1849,9 +1869,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1849
1869
|
spawnedByGjc,
|
|
1850
1870
|
sdkHostModeSupported: options.sdkHostModeSupported,
|
|
1851
1871
|
ensureProviderDaemon: options.ensureNotificationProviderDaemon,
|
|
1852
|
-
|
|
1872
|
+
runBtwTurn: async (question, signal) => {
|
|
1853
1873
|
if (!session) throw new Error("Ephemeral turns are unavailable.");
|
|
1854
|
-
const { replyText } = await session.runEphemeralTurn({
|
|
1874
|
+
const { replyText } = await session.runEphemeralTurn({
|
|
1875
|
+
purpose: "btw",
|
|
1876
|
+
turn: { question, scope: session.createBtwConversationScope(btwUserPrompt) },
|
|
1877
|
+
signal,
|
|
1878
|
+
});
|
|
1855
1879
|
return { replyText };
|
|
1856
1880
|
},
|
|
1857
1881
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type * as net from "node:net";
|
|
3
|
+
|
|
4
|
+
const PREFACE_LIMIT_BYTES = 4 * 1024;
|
|
5
|
+
const PREFACE_TIMEOUT_MS = 1_000;
|
|
6
|
+
const VERSION = "gjc-sdk-transport/1";
|
|
7
|
+
|
|
8
|
+
type AuthFailureCode =
|
|
9
|
+
| "auth_missing"
|
|
10
|
+
| "auth_malformed"
|
|
11
|
+
| "auth_invalid"
|
|
12
|
+
| "auth_oversize"
|
|
13
|
+
| "auth_timeout"
|
|
14
|
+
| "unsupported_version";
|
|
15
|
+
|
|
16
|
+
export class AuthPrefaceError extends Error {
|
|
17
|
+
constructor(readonly code: AuthFailureCode) {
|
|
18
|
+
super(code);
|
|
19
|
+
this.name = "AuthPrefaceError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function digest(token: string): Buffer {
|
|
24
|
+
return createHash("sha256").update(token).digest();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validToken(received: string, expected: string): boolean {
|
|
28
|
+
return timingSafeEqual(digest(received), digest(expected));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function failureFrame(): string {
|
|
32
|
+
return `${JSON.stringify({ type: "transport_error", code: "auth_failed" })}\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Consumes the one-shot socket authentication preface and leaves later bytes readable. */
|
|
36
|
+
export async function authenticatePreface(socket: net.Socket, token: string): Promise<Buffer> {
|
|
37
|
+
return await new Promise<Buffer>((resolve, reject) => {
|
|
38
|
+
let buffer = Buffer.alloc(0);
|
|
39
|
+
let settled = false;
|
|
40
|
+
const finish = (error?: AuthPrefaceError, remainder = Buffer.alloc(0)): void => {
|
|
41
|
+
if (settled) return;
|
|
42
|
+
settled = true;
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
socket.removeListener("data", onData);
|
|
45
|
+
socket.removeListener("end", onEnd);
|
|
46
|
+
socket.removeListener("error", onSocketError);
|
|
47
|
+
if (error) {
|
|
48
|
+
socket.end(failureFrame(), () => reject(error));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
socket.pause();
|
|
52
|
+
resolve(remainder);
|
|
53
|
+
};
|
|
54
|
+
const fail = (code: AuthFailureCode): void => finish(new AuthPrefaceError(code));
|
|
55
|
+
const onSocketError = (): void => fail("auth_missing");
|
|
56
|
+
const onEnd = (): void => fail(buffer.length === 0 ? "auth_missing" : "auth_malformed");
|
|
57
|
+
const onData = (chunk: Buffer): void => {
|
|
58
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
59
|
+
const newline = buffer.indexOf(0x0a);
|
|
60
|
+
if (newline < 0) {
|
|
61
|
+
if (buffer.length > PREFACE_LIMIT_BYTES) fail("auth_oversize");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (newline > PREFACE_LIMIT_BYTES) return fail("auth_oversize");
|
|
65
|
+
const line = buffer.subarray(0, newline).toString("utf8");
|
|
66
|
+
const remainder = buffer.subarray(newline + 1);
|
|
67
|
+
const prefix = `${VERSION} token=`;
|
|
68
|
+
if (!line.startsWith(prefix)) {
|
|
69
|
+
return fail(line.startsWith("gjc-sdk-transport/") ? "unsupported_version" : "auth_malformed");
|
|
70
|
+
}
|
|
71
|
+
const received = line.slice(prefix.length);
|
|
72
|
+
if (received.length === 0 || /\s/.test(received)) return fail("auth_malformed");
|
|
73
|
+
if (!validToken(received, token)) return fail("auth_invalid");
|
|
74
|
+
finish(undefined, remainder);
|
|
75
|
+
};
|
|
76
|
+
const timeout = setTimeout(() => fail("auth_timeout"), PREFACE_TIMEOUT_MS);
|
|
77
|
+
socket.on("data", onData);
|
|
78
|
+
socket.once("end", onEnd);
|
|
79
|
+
socket.once("error", onSocketError);
|
|
80
|
+
socket.resume();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_PENDING_CEILING_BYTES,
|
|
3
|
+
MIN_PENDING_CEILING_BYTES,
|
|
4
|
+
REQUEST_FRAME_BYTES,
|
|
5
|
+
} from "./relay";
|
|
6
|
+
export { startSocketServe } from "./socket";
|
|
7
|
+
export { startStdioServe } from "./stdio";
|
|
8
|
+
|
|
9
|
+
export type ServeOptions = {
|
|
10
|
+
url: string;
|
|
11
|
+
token: string;
|
|
12
|
+
pendingCeilingBytes: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ServeHandle = {
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
readonly done: Promise<void>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
|
|
3
|
+
export const REQUEST_FRAME_BYTES = 256 * 1024;
|
|
4
|
+
export const DEFAULT_PENDING_CEILING_BYTES = 8 * 1024 * 1024;
|
|
5
|
+
export const MIN_PENDING_CEILING_BYTES = REQUEST_FRAME_BYTES;
|
|
6
|
+
|
|
7
|
+
export type RelayDirection = "downstream->ws" | "ws->downstream";
|
|
8
|
+
export type TransportError = {
|
|
9
|
+
type: "transport_error";
|
|
10
|
+
code: "frame_oversize" | "pending_overflow" | "protocol_error";
|
|
11
|
+
direction?: RelayDirection;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type RelayPair = {
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
readonly done: Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class RelayOpenAbortedError extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("relay_open_aborted");
|
|
22
|
+
this.name = "RelayOpenAbortedError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RelayWebSocketEvent = { data?: unknown };
|
|
27
|
+
|
|
28
|
+
export type RelayWebSocket = {
|
|
29
|
+
readonly bufferedAmount: number;
|
|
30
|
+
readonly readyState: number;
|
|
31
|
+
close(): void;
|
|
32
|
+
send(data: string): void;
|
|
33
|
+
addEventListener(type: string, listener: (event: RelayWebSocketEvent) => void, options?: { once?: boolean }): void;
|
|
34
|
+
removeEventListener(type: string, listener: (event: RelayWebSocketEvent) => void): void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RelayOptions = {
|
|
38
|
+
url: string;
|
|
39
|
+
token: string;
|
|
40
|
+
pendingCeilingBytes: number;
|
|
41
|
+
downstream: Readable;
|
|
42
|
+
downstreamSink: Writable;
|
|
43
|
+
initialDownstreamBytes?: Buffer;
|
|
44
|
+
onTransportError: (error: TransportError) => void;
|
|
45
|
+
webSocketFactory?: (url: string) => RelayWebSocket;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type QueuedFrame = { bytes: Buffer; accounted: boolean };
|
|
50
|
+
|
|
51
|
+
function upstreamUrl(url: string, token: string): string {
|
|
52
|
+
const endpoint = new URL(url);
|
|
53
|
+
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, "")}/`;
|
|
54
|
+
endpoint.searchParams.set("token", token);
|
|
55
|
+
return endpoint.toString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function waitForDrain(stream: Writable): Promise<void> {
|
|
59
|
+
const { promise, reject, resolve } = Promise.withResolvers<void>();
|
|
60
|
+
const onDrain = (): void => done(resolve);
|
|
61
|
+
const onError = (error: Error): void => done(() => reject(error));
|
|
62
|
+
const done = (callback: () => void): void => {
|
|
63
|
+
stream.removeListener("drain", onDrain);
|
|
64
|
+
stream.removeListener("error", onError);
|
|
65
|
+
callback();
|
|
66
|
+
};
|
|
67
|
+
stream.once("drain", onDrain);
|
|
68
|
+
stream.once("error", onError);
|
|
69
|
+
return promise;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const WEBSOCKET_CONNECTING = 0;
|
|
73
|
+
const WEBSOCKET_OPEN = 1;
|
|
74
|
+
|
|
75
|
+
function waitForWebSocketDrain(ws: RelayWebSocket, isClosed: () => boolean): Promise<void> {
|
|
76
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
77
|
+
const poll = (): void => {
|
|
78
|
+
if (isClosed() || ws.readyState !== WEBSOCKET_OPEN || ws.bufferedAmount === 0) {
|
|
79
|
+
resolve();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setTimeout(poll, 10);
|
|
83
|
+
};
|
|
84
|
+
poll();
|
|
85
|
+
return promise;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Starts a dedicated raw-WebSocket relay for exactly one downstream stream. */
|
|
89
|
+
export async function startRelayPair(options: RelayOptions): Promise<RelayPair> {
|
|
90
|
+
if (options.signal?.aborted) throw new RelayOpenAbortedError();
|
|
91
|
+
const ws: RelayWebSocket =
|
|
92
|
+
options.webSocketFactory?.(upstreamUrl(options.url, options.token)) ??
|
|
93
|
+
new WebSocket(upstreamUrl(options.url, options.token));
|
|
94
|
+
const opened = Promise.withResolvers<void>();
|
|
95
|
+
const finished = Promise.withResolvers<void>();
|
|
96
|
+
let closed = false;
|
|
97
|
+
let completed = false;
|
|
98
|
+
let downstreamBuffer = Buffer.alloc(0);
|
|
99
|
+
const toWs: QueuedFrame[] = [];
|
|
100
|
+
const toDownstream: QueuedFrame[] = [];
|
|
101
|
+
let pendingToWs = 0;
|
|
102
|
+
let pendingToDownstream = 0;
|
|
103
|
+
let writingWs = false;
|
|
104
|
+
let writingDownstream = false;
|
|
105
|
+
|
|
106
|
+
const settle = (error?: Error): void => {
|
|
107
|
+
if (completed) return;
|
|
108
|
+
completed = true;
|
|
109
|
+
if (error) finished.reject(error);
|
|
110
|
+
else finished.resolve();
|
|
111
|
+
};
|
|
112
|
+
void finished.promise.catch(() => undefined);
|
|
113
|
+
const detach = (): void => {
|
|
114
|
+
options.downstream.removeListener("data", onData);
|
|
115
|
+
options.downstream.removeListener("end", onEnd);
|
|
116
|
+
options.downstream.removeListener("error", onDownstreamError);
|
|
117
|
+
ws.removeEventListener("message", onMessage);
|
|
118
|
+
ws.removeEventListener("close", onClose);
|
|
119
|
+
ws.removeEventListener("error", onWebSocketError);
|
|
120
|
+
cleanupOpening();
|
|
121
|
+
};
|
|
122
|
+
const close = async (error?: Error): Promise<void> => {
|
|
123
|
+
if (closed) return finished.promise.catch(() => undefined);
|
|
124
|
+
closed = true;
|
|
125
|
+
detach();
|
|
126
|
+
options.downstream.pause();
|
|
127
|
+
if (ws.readyState === WEBSOCKET_OPEN || ws.readyState === WEBSOCKET_CONNECTING) ws.close();
|
|
128
|
+
settle(error);
|
|
129
|
+
return await finished.promise.catch(() => undefined);
|
|
130
|
+
};
|
|
131
|
+
const fail = (transportError: TransportError): void => {
|
|
132
|
+
if (closed) return;
|
|
133
|
+
options.onTransportError(transportError);
|
|
134
|
+
void close(new Error(transportError.code));
|
|
135
|
+
};
|
|
136
|
+
const enqueue = (queue: QueuedFrame[], direction: RelayDirection, bytes: Buffer): void => {
|
|
137
|
+
const isWsDirection = direction === "downstream->ws";
|
|
138
|
+
const active = isWsDirection ? writingWs : writingDownstream;
|
|
139
|
+
const pending = isWsDirection ? pendingToWs : pendingToDownstream;
|
|
140
|
+
if (active && pending + bytes.length > options.pendingCeilingBytes) {
|
|
141
|
+
fail({ type: "transport_error", code: "pending_overflow", direction });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
queue.push({ bytes, accounted: active });
|
|
145
|
+
if (active) {
|
|
146
|
+
if (isWsDirection) pendingToWs += bytes.length;
|
|
147
|
+
else pendingToDownstream += bytes.length;
|
|
148
|
+
}
|
|
149
|
+
if (isWsDirection) void pumpWs();
|
|
150
|
+
else void pumpDownstream();
|
|
151
|
+
};
|
|
152
|
+
const pumpWs = async (): Promise<void> => {
|
|
153
|
+
if (writingWs || closed) return;
|
|
154
|
+
const frame = toWs.shift();
|
|
155
|
+
if (!frame) return;
|
|
156
|
+
writingWs = true;
|
|
157
|
+
if (frame.accounted) pendingToWs -= frame.bytes.length;
|
|
158
|
+
try {
|
|
159
|
+
if (ws.readyState !== WEBSOCKET_OPEN) throw new Error("upstream_closed");
|
|
160
|
+
ws.send(frame.bytes.toString("utf8"));
|
|
161
|
+
await waitForWebSocketDrain(ws, () => closed);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
await close(error instanceof Error ? error : new Error(String(error)));
|
|
164
|
+
return;
|
|
165
|
+
} finally {
|
|
166
|
+
writingWs = false;
|
|
167
|
+
}
|
|
168
|
+
void pumpWs();
|
|
169
|
+
};
|
|
170
|
+
const pumpDownstream = async (): Promise<void> => {
|
|
171
|
+
if (writingDownstream || closed) return;
|
|
172
|
+
const frame = toDownstream.shift();
|
|
173
|
+
if (!frame) return;
|
|
174
|
+
writingDownstream = true;
|
|
175
|
+
if (frame.accounted) pendingToDownstream -= frame.bytes.length;
|
|
176
|
+
try {
|
|
177
|
+
if (!options.downstreamSink.write(frame.bytes)) await waitForDrain(options.downstreamSink);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
await close(error instanceof Error ? error : new Error(String(error)));
|
|
180
|
+
return;
|
|
181
|
+
} finally {
|
|
182
|
+
writingDownstream = false;
|
|
183
|
+
}
|
|
184
|
+
void pumpDownstream();
|
|
185
|
+
};
|
|
186
|
+
const consumeLines = (chunk: Buffer): void => {
|
|
187
|
+
downstreamBuffer = Buffer.concat([downstreamBuffer, chunk]);
|
|
188
|
+
let newline = downstreamBuffer.indexOf(0x0a);
|
|
189
|
+
while (newline >= 0) {
|
|
190
|
+
const line = downstreamBuffer.subarray(0, newline);
|
|
191
|
+
downstreamBuffer = downstreamBuffer.subarray(newline + 1);
|
|
192
|
+
if (line.length === 0)
|
|
193
|
+
return fail({ type: "transport_error", code: "protocol_error", direction: "downstream->ws" });
|
|
194
|
+
if (line.length > REQUEST_FRAME_BYTES)
|
|
195
|
+
return fail({ type: "transport_error", code: "frame_oversize", direction: "downstream->ws" });
|
|
196
|
+
enqueue(toWs, "downstream->ws", line);
|
|
197
|
+
if (closed) return;
|
|
198
|
+
newline = downstreamBuffer.indexOf(0x0a);
|
|
199
|
+
}
|
|
200
|
+
if (downstreamBuffer.length > REQUEST_FRAME_BYTES)
|
|
201
|
+
fail({ type: "transport_error", code: "frame_oversize", direction: "downstream->ws" });
|
|
202
|
+
};
|
|
203
|
+
const onData = (chunk: Buffer | string): void => consumeLines(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
204
|
+
const onEnd = (): void => void close();
|
|
205
|
+
const onDownstreamError = (error: Error): void => void close(error);
|
|
206
|
+
const onMessage = (event: RelayWebSocketEvent): void => {
|
|
207
|
+
if (typeof event.data !== "string") {
|
|
208
|
+
fail({ type: "transport_error", code: "protocol_error", direction: "ws->downstream" });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
enqueue(toDownstream, "ws->downstream", Buffer.concat([Buffer.from(event.data, "utf8"), Buffer.from("\n")]));
|
|
212
|
+
};
|
|
213
|
+
const onClose = (): void => void close();
|
|
214
|
+
const onWebSocketError = (): void => void close(new Error("upstream_error"));
|
|
215
|
+
const onOpen = (): void => {
|
|
216
|
+
cleanupOpening();
|
|
217
|
+
opened.resolve();
|
|
218
|
+
};
|
|
219
|
+
const onOpenError = (): void => {
|
|
220
|
+
cleanupOpening();
|
|
221
|
+
opened.reject(new Error("upstream_error"));
|
|
222
|
+
};
|
|
223
|
+
const onAbort = (): void => {
|
|
224
|
+
const error = new RelayOpenAbortedError();
|
|
225
|
+
cleanupOpening();
|
|
226
|
+
opened.reject(error);
|
|
227
|
+
void close(error);
|
|
228
|
+
};
|
|
229
|
+
const cleanupOpening = (): void => {
|
|
230
|
+
ws.removeEventListener("open", onOpen);
|
|
231
|
+
ws.removeEventListener("error", onOpenError);
|
|
232
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
233
|
+
};
|
|
234
|
+
ws.addEventListener("open", onOpen, { once: true });
|
|
235
|
+
ws.addEventListener("error", onOpenError, { once: true });
|
|
236
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
237
|
+
try {
|
|
238
|
+
await opened.promise;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
await close(error instanceof Error ? error : new Error(String(error)));
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
ws.addEventListener("message", onMessage);
|
|
244
|
+
ws.addEventListener("close", onClose);
|
|
245
|
+
ws.addEventListener("error", onWebSocketError);
|
|
246
|
+
options.downstream.on("data", onData);
|
|
247
|
+
options.downstream.once("end", onEnd);
|
|
248
|
+
options.downstream.once("error", onDownstreamError);
|
|
249
|
+
if (options.initialDownstreamBytes?.length) consumeLines(options.initialDownstreamBytes);
|
|
250
|
+
if (!closed) options.downstream.resume();
|
|
251
|
+
return { close: () => close(), done: finished.promise };
|
|
252
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { CliParseError } from "@gajae-code/utils/cli";
|
|
2
|
+
import type { SdkSessionEndpoint } from "../client/discovery";
|
|
3
|
+
import { listSdkSessionEndpoints } from "../client/discovery";
|
|
4
|
+
import { type SdkEndpointSelectionError, selectLiveEndpoint } from "../client/liveness";
|
|
5
|
+
import { DEFAULT_PENDING_CEILING_BYTES, MIN_PENDING_CEILING_BYTES, startSocketServe, startStdioServe } from "./index";
|
|
6
|
+
|
|
7
|
+
type ServeMode = { kind: "stdio" } | { kind: "socket"; socketPath: string };
|
|
8
|
+
|
|
9
|
+
interface ServeArguments {
|
|
10
|
+
mode: ServeMode;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
pendingCeiling?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function usageError(message: string): never {
|
|
16
|
+
throw new CliParseError(`gjc sdk serve: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readFlagValue(argv: string[], index: number, flag: string): string {
|
|
20
|
+
const value = argv[index + 1];
|
|
21
|
+
if (value === undefined || value.startsWith("-")) usageError(`${flag} requires a value`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseServeArguments(argv: string[]): ServeArguments {
|
|
26
|
+
let stdio = false;
|
|
27
|
+
let socketPath: string | undefined;
|
|
28
|
+
let sessionId: string | undefined;
|
|
29
|
+
let pendingCeiling: string | undefined;
|
|
30
|
+
for (let index = 0; index < argv.length; index++) {
|
|
31
|
+
switch (argv[index]) {
|
|
32
|
+
case "--stdio":
|
|
33
|
+
if (stdio) usageError("--stdio may only be specified once");
|
|
34
|
+
stdio = true;
|
|
35
|
+
break;
|
|
36
|
+
case "--socket":
|
|
37
|
+
if (socketPath !== undefined) usageError("--socket may only be specified once");
|
|
38
|
+
socketPath = readFlagValue(argv, index, "--socket");
|
|
39
|
+
index++;
|
|
40
|
+
break;
|
|
41
|
+
case "--session":
|
|
42
|
+
if (sessionId !== undefined) usageError("--session may only be specified once");
|
|
43
|
+
sessionId = readFlagValue(argv, index, "--session");
|
|
44
|
+
index++;
|
|
45
|
+
break;
|
|
46
|
+
case "--pending-ceiling":
|
|
47
|
+
if (pendingCeiling !== undefined) usageError("--pending-ceiling may only be specified once");
|
|
48
|
+
pendingCeiling = readFlagValue(argv, index, "--pending-ceiling");
|
|
49
|
+
index++;
|
|
50
|
+
break;
|
|
51
|
+
default:
|
|
52
|
+
usageError(`unknown argument: ${argv[index]}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (stdio === (socketPath !== undefined)) usageError("specify exactly one of --stdio or --socket <path>");
|
|
56
|
+
return { mode: stdio ? { kind: "stdio" } : { kind: "socket", socketPath: socketPath! }, sessionId, pendingCeiling };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolves the pending ceiling with flag > env > default precedence; exported for tests. */
|
|
60
|
+
export function resolveServePendingCeiling(flagValue: string | undefined, envValue: string | undefined): number {
|
|
61
|
+
const value = flagValue ?? envValue;
|
|
62
|
+
if (value === undefined) return DEFAULT_PENDING_CEILING_BYTES;
|
|
63
|
+
if (!/^\d+$/.test(value)) usageError("--pending-ceiling must be a positive integer");
|
|
64
|
+
const ceiling = Number(value);
|
|
65
|
+
if (!Number.isSafeInteger(ceiling) || ceiling < MIN_PENDING_CEILING_BYTES)
|
|
66
|
+
usageError(`--pending-ceiling must be an integer of at least ${MIN_PENDING_CEILING_BYTES}`);
|
|
67
|
+
return ceiling;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isSelectionError(value: SdkSessionEndpoint | SdkEndpointSelectionError): value is SdkEndpointSelectionError {
|
|
71
|
+
return "code" in value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Attaches a stdio or Unix-socket relay to one live SDK session endpoint. */
|
|
75
|
+
export async function runSdkServe(argv: string[]): Promise<void> {
|
|
76
|
+
const parsed = parseServeArguments(argv);
|
|
77
|
+
if (parsed.mode.kind === "socket" && process.platform === "win32")
|
|
78
|
+
throw new Error("unsupported_platform: --socket is unavailable on Windows.");
|
|
79
|
+
const pendingCeilingBytes = resolveServePendingCeiling(
|
|
80
|
+
parsed.pendingCeiling,
|
|
81
|
+
process.env.GJC_SDK_SERVE_PENDING_CEILING_BYTES,
|
|
82
|
+
);
|
|
83
|
+
const discovered = await listSdkSessionEndpoints(process.cwd());
|
|
84
|
+
const selected = selectLiveEndpoint(discovered.endpoints, parsed.sessionId);
|
|
85
|
+
if (isSelectionError(selected)) {
|
|
86
|
+
const sessionHint = parsed.sessionId ? ` for session ${parsed.sessionId}` : "; specify --session <id>";
|
|
87
|
+
throw new Error(`${selected.code}${sessionHint}`);
|
|
88
|
+
}
|
|
89
|
+
const options = { url: selected.url, token: selected.token, pendingCeilingBytes };
|
|
90
|
+
const handle =
|
|
91
|
+
parsed.mode.kind === "stdio"
|
|
92
|
+
? await startStdioServe(options)
|
|
93
|
+
: await startSocketServe({ ...options, socketPath: parsed.mode.socketPath });
|
|
94
|
+
const stop = () => {
|
|
95
|
+
void handle.close();
|
|
96
|
+
};
|
|
97
|
+
process.once("SIGINT", stop);
|
|
98
|
+
process.once("SIGTERM", stop);
|
|
99
|
+
try {
|
|
100
|
+
await handle.done;
|
|
101
|
+
} finally {
|
|
102
|
+
process.removeListener("SIGINT", stop);
|
|
103
|
+
process.removeListener("SIGTERM", stop);
|
|
104
|
+
}
|
|
105
|
+
}
|