@fitchmultz/pi-subagents 0.47.0 → 0.49.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/CHANGELOG.md +29 -3
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +15 -3
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +195 -27
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -66
- package/dist/pi-intercom/bridge-storage.js +73 -0
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake-owner.js +57 -0
- package/dist/pi-intercom/bridge-wake-state.js +293 -0
- package/dist/pi-intercom/bridge-wake.js +190 -99
- package/dist/pi-intercom/bridge.js +39 -39
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +315 -174
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +213 -38
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +20 -3
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +291 -49
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +98 -96
- package/src/pi-intercom/bridge-storage.ts +83 -0
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake-owner.ts +58 -0
- package/src/pi-intercom/bridge-wake-state.ts +321 -0
- package/src/pi-intercom/bridge-wake.ts +224 -130
- package/src/pi-intercom/bridge.ts +41 -49
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
|
|
3
|
+
import type { Identity } from "./bridge-config.ts";
|
|
4
|
+
import type { RequestContext } from "./bridge-http.ts";
|
|
5
|
+
import type { Operation } from "./bridge-routes.ts";
|
|
6
|
+
import type { Session } from "./bridge-session.ts";
|
|
7
|
+
import { Launcher } from "./bridge-launch.ts";
|
|
8
|
+
import { SessionCatalog, savedView, trustedJournal } from "./bridge-catalog.ts";
|
|
9
|
+
import { Groups } from "./bridge-groups.ts";
|
|
10
|
+
import { broadcastTargets } from "./bridge-broadcast.ts";
|
|
11
|
+
import { getPiAgentDir } from "./agent-dir.ts";
|
|
12
|
+
import { Fault } from "./bridge-protocol.ts";
|
|
13
|
+
import { resolveBridgeTarget } from "./bridge-target.ts";
|
|
14
|
+
import { readSavedTranscript } from "./saved-transcript.ts";
|
|
15
|
+
|
|
16
|
+
type OperationContext = RequestContext & {
|
|
17
|
+
readonly expires: number;
|
|
18
|
+
readonly certificate: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Request operations own launch/catalog/group state; transport owns admission and cancellation. */
|
|
22
|
+
export class BridgeOperations {
|
|
23
|
+
private readonly catalog = new SessionCatalog();
|
|
24
|
+
private readonly launcher = new Launcher(this.catalog);
|
|
25
|
+
private readonly stateDir: string;
|
|
26
|
+
private groups?: Groups;
|
|
27
|
+
|
|
28
|
+
constructor(stateDir?: string) {
|
|
29
|
+
this.stateDir = stateDir ?? join(getPiAgentDir(), "intercom", "bridge-state");
|
|
30
|
+
}
|
|
31
|
+
revoke(clients: ReadonlyMap<string, Identity>): Promise<void> {
|
|
32
|
+
return this.launcher.revoke(clients);
|
|
33
|
+
}
|
|
34
|
+
shutdown(): Promise<void> {
|
|
35
|
+
return this.launcher.shutdown();
|
|
36
|
+
}
|
|
37
|
+
private groupStore(): Groups {
|
|
38
|
+
this.groups ??= new Groups(this.stateDir);
|
|
39
|
+
return this.groups;
|
|
40
|
+
}
|
|
41
|
+
async execute(
|
|
42
|
+
operation: Operation,
|
|
43
|
+
session: Readonly<Session>,
|
|
44
|
+
context: OperationContext,
|
|
45
|
+
): Promise<UnknownRecord> {
|
|
46
|
+
const launchContext = {
|
|
47
|
+
identity: session.identity,
|
|
48
|
+
expires: context.expires,
|
|
49
|
+
signal: context.signal,
|
|
50
|
+
peers: (signal: AbortSignal) => session.connectedPeers(signal),
|
|
51
|
+
submit: (peer: string, task: string) => session.send(peer, task, context),
|
|
52
|
+
};
|
|
53
|
+
switch (operation.route) {
|
|
54
|
+
case "POST /v1/start":
|
|
55
|
+
return this.launcher.start(operation.name, launchContext);
|
|
56
|
+
case "POST /v1/spawn":
|
|
57
|
+
return this.launcher.spawn(operation, launchContext);
|
|
58
|
+
case "POST /v1/resume":
|
|
59
|
+
return this.launcher.resume(operation, launchContext);
|
|
60
|
+
case "GET /v1/sessions":
|
|
61
|
+
return operation.query === undefined
|
|
62
|
+
? this.launcher.list(session.identity)
|
|
63
|
+
: this.launcher.browse(operation.query, launchContext);
|
|
64
|
+
case "POST /v1/stop":
|
|
65
|
+
case "POST /v1/cancel":
|
|
66
|
+
return this.stop(operation, session, context);
|
|
67
|
+
case "GET /v1/groups":
|
|
68
|
+
return { groups: this.groupStore().list(session.identity.fingerprint256) };
|
|
69
|
+
case "POST /v1/groups":
|
|
70
|
+
return {
|
|
71
|
+
groups: this.groupStore().upsert(session.identity.fingerprint256, operation.group),
|
|
72
|
+
};
|
|
73
|
+
case "POST /v1/broadcast":
|
|
74
|
+
return broadcastTargets(
|
|
75
|
+
session,
|
|
76
|
+
this.groupStore().targets(
|
|
77
|
+
session.identity.fingerprint256,
|
|
78
|
+
operation.group,
|
|
79
|
+
await session.connectedPeers(context.signal),
|
|
80
|
+
),
|
|
81
|
+
operation.message,
|
|
82
|
+
context,
|
|
83
|
+
);
|
|
84
|
+
case "POST /v1/control":
|
|
85
|
+
return session.nativeControl(operation, context.certificate, context);
|
|
86
|
+
case "GET /v1/transcript":
|
|
87
|
+
return this.transcript(operation, session, context);
|
|
88
|
+
case "GET /v1/inbox":
|
|
89
|
+
case "GET /v1/list":
|
|
90
|
+
case "POST /v1/ack":
|
|
91
|
+
case "POST /v1/ask":
|
|
92
|
+
case "POST /v1/register":
|
|
93
|
+
case "POST /v1/reply":
|
|
94
|
+
case "POST /v1/send":
|
|
95
|
+
return session.execute(operation, context);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
private async stop(
|
|
99
|
+
operation: Extract<Operation, { readonly route: "POST /v1/stop" | "POST /v1/cancel" }>,
|
|
100
|
+
session: Readonly<Session>,
|
|
101
|
+
context: OperationContext,
|
|
102
|
+
): Promise<UnknownRecord> {
|
|
103
|
+
const cancel = operation.route === "POST /v1/cancel";
|
|
104
|
+
const control = {
|
|
105
|
+
signal: context.signal,
|
|
106
|
+
peers: (signal: AbortSignal) => session.connectedPeers(signal),
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
return cancel
|
|
110
|
+
? await this.launcher.cancel(session.identity, operation.sessionId, control)
|
|
111
|
+
: await this.launcher.stop(session.identity, operation.sessionId, control);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// Only a pre-effect ownership miss may dispatch elsewhere; never retry an unknown outcome.
|
|
114
|
+
if (!(error instanceof Fault) || error.code !== "unknown_session") {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return session.nativeControl(
|
|
119
|
+
{ to: operation.sessionId, verb: cancel ? "cancel" : "stop", timeoutMs: operation.timeoutMs },
|
|
120
|
+
context.certificate,
|
|
121
|
+
context,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
private async transcript(
|
|
125
|
+
operation: Extract<Operation, { readonly route: "GET /v1/transcript" }>,
|
|
126
|
+
session: Readonly<Session>,
|
|
127
|
+
context: OperationContext,
|
|
128
|
+
): Promise<UnknownRecord> {
|
|
129
|
+
const peers = await session.connectedPeers(context.signal);
|
|
130
|
+
try {
|
|
131
|
+
const peer = resolveBridgeTarget(peers, operation.target, null);
|
|
132
|
+
const result = await session.nativeControl(
|
|
133
|
+
{
|
|
134
|
+
to: peer.id,
|
|
135
|
+
verb: "transcript",
|
|
136
|
+
options: operation.options,
|
|
137
|
+
timeoutMs: operation.timeoutMs,
|
|
138
|
+
},
|
|
139
|
+
context.certificate,
|
|
140
|
+
context,
|
|
141
|
+
);
|
|
142
|
+
return {
|
|
143
|
+
...result,
|
|
144
|
+
source: "live",
|
|
145
|
+
transcript: isRecord(result.native) ? result.native.transcript : undefined,
|
|
146
|
+
};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!(error instanceof Fault) || error.code !== "peer_offline") {
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const policy = session.identity.launch;
|
|
153
|
+
if (!policy) {
|
|
154
|
+
throw new Fault(403, "launch_disabled", "Saved transcripts require local resume policy.");
|
|
155
|
+
}
|
|
156
|
+
const saved = await this.catalog.resolve(policy, { target: operation.target }, context.signal);
|
|
157
|
+
trustedJournal(policy, saved.sessionFile);
|
|
158
|
+
const page = await readSavedTranscript(
|
|
159
|
+
policy.packageRoot,
|
|
160
|
+
saved.sessionFile,
|
|
161
|
+
operation.options,
|
|
162
|
+
);
|
|
163
|
+
session.guard(context.signal);
|
|
164
|
+
if (page.sessionId !== saved.sessionId) {
|
|
165
|
+
throw new Fault(409, "session_changed", "Saved journal identity changed during reading.");
|
|
166
|
+
}
|
|
167
|
+
return { status: "ok", source: "saved", session: savedView(saved), transcript: page };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -15,6 +15,7 @@ interface PresenceOwner {
|
|
|
15
15
|
readonly stateDir: string;
|
|
16
16
|
readonly clients: () => ReadonlyMap<string, Identity>;
|
|
17
17
|
readonly connect: (identity: Identity, expires: number) => Promise<void>;
|
|
18
|
+
readonly restored: (identity: Identity, expires: number) => void;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export class PersistentPresence {
|
|
@@ -26,7 +27,11 @@ export class PersistentPresence {
|
|
|
26
27
|
|
|
27
28
|
constructor(owner: PresenceOwner) {
|
|
28
29
|
this.owner = owner;
|
|
29
|
-
|
|
30
|
+
this.restoreAll();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
restoreAll(): void {
|
|
34
|
+
for (const identity of this.owner.clients().values()) {
|
|
30
35
|
this.restore(identity);
|
|
31
36
|
}
|
|
32
37
|
}
|
|
@@ -55,6 +60,7 @@ export class PersistentPresence {
|
|
|
55
60
|
cwd: identity.cwd,
|
|
56
61
|
expires: value.expires,
|
|
57
62
|
});
|
|
63
|
+
this.owner.restored(identity, value.expires);
|
|
58
64
|
}
|
|
59
65
|
} catch {
|
|
60
66
|
// An absent/invalid saved registration never grants a certificate identity.
|
|
@@ -17,6 +17,14 @@ export class Fault extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export class BroadcastFault extends Fault {
|
|
21
|
+
readonly outcome: UnknownRecord;
|
|
22
|
+
constructor(fault: Fault, outcome: UnknownRecord) {
|
|
23
|
+
super(fault.status, fault.code, fault.message, fault.details);
|
|
24
|
+
this.outcome = { ...outcome, status: "partial" };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
export function object(value: unknown, fields: readonly string[]): UnknownRecord {
|
|
21
29
|
if (!isRecord(value)) {
|
|
22
30
|
throw new Fault(400, "invalid_input", "Expected a JSON object.");
|
|
@@ -2,15 +2,27 @@ import type { IncomingMessage } from "node:http";
|
|
|
2
2
|
import { isUnknownArray } from "../shared/unknown.ts";
|
|
3
3
|
import { ASK_TIMEOUT, Fault, INBOX_COUNT, label, object, text } from "./bridge-protocol.ts";
|
|
4
4
|
import { body } from "./bridge-http.ts";
|
|
5
|
+
import type { CatalogQuery } from "./bridge-catalog.ts";
|
|
6
|
+
import { groupDefinition, type GroupDefinition } from "./bridge-groups.ts";
|
|
7
|
+
import { isControlVerb, type NativeControlVerb } from "./native-control-protocol.ts";
|
|
8
|
+
import { transcriptOptions, type TranscriptOptions } from "./transcript.ts";
|
|
5
9
|
|
|
6
|
-
export type
|
|
10
|
+
export type MessagingOperation =
|
|
7
11
|
| { readonly route: "GET /v1/list" | "POST /v1/register" }
|
|
8
|
-
| {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
| {
|
|
13
|
+
readonly route: "GET /v1/inbox";
|
|
14
|
+
readonly after: number;
|
|
15
|
+
readonly limit: number;
|
|
16
|
+
readonly waitMs: number;
|
|
17
|
+
}
|
|
12
18
|
| { readonly route: "POST /v1/ack"; readonly ids: readonly string[] }
|
|
13
|
-
| {
|
|
19
|
+
| {
|
|
20
|
+
readonly route: "POST /v1/send";
|
|
21
|
+
readonly to: string;
|
|
22
|
+
readonly message: string;
|
|
23
|
+
readonly passive?: boolean;
|
|
24
|
+
readonly messageId?: string;
|
|
25
|
+
}
|
|
14
26
|
| {
|
|
15
27
|
readonly route: "POST /v1/ask";
|
|
16
28
|
readonly to: string;
|
|
@@ -18,25 +30,77 @@ export type Operation =
|
|
|
18
30
|
readonly timeoutMs: number;
|
|
19
31
|
}
|
|
20
32
|
| { readonly route: "POST /v1/reply"; readonly replyTo: string; readonly message: string };
|
|
21
|
-
export type
|
|
22
|
-
|
|
23
|
-
{ readonly route: "
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
export type Operation =
|
|
34
|
+
| MessagingOperation
|
|
35
|
+
| { readonly route: "GET /v1/sessions"; readonly query?: CatalogQuery }
|
|
36
|
+
| { readonly route: "POST /v1/start"; readonly name: string }
|
|
37
|
+
| {
|
|
38
|
+
readonly route: "POST /v1/spawn";
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly cwd?: string;
|
|
41
|
+
readonly provider?: string;
|
|
42
|
+
readonly model?: string;
|
|
43
|
+
readonly task?: string;
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly route: "POST /v1/resume";
|
|
47
|
+
readonly sessionId?: string;
|
|
48
|
+
readonly target?: string;
|
|
49
|
+
readonly name?: string;
|
|
50
|
+
readonly task?: string;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
readonly route: "POST /v1/stop" | "POST /v1/cancel";
|
|
54
|
+
readonly sessionId: string;
|
|
55
|
+
readonly timeoutMs: number;
|
|
56
|
+
}
|
|
57
|
+
| { readonly route: "GET /v1/groups" }
|
|
58
|
+
| { readonly route: "POST /v1/groups"; readonly group: GroupDefinition }
|
|
59
|
+
| { readonly route: "POST /v1/broadcast"; readonly message: string; readonly group?: string }
|
|
60
|
+
| {
|
|
61
|
+
readonly route: "POST /v1/control";
|
|
62
|
+
readonly to: string;
|
|
63
|
+
readonly verb: NativeControlVerb;
|
|
64
|
+
readonly options?: TranscriptOptions;
|
|
65
|
+
readonly timeoutMs: number;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
readonly route: "GET /v1/transcript";
|
|
69
|
+
readonly target: string;
|
|
70
|
+
readonly options: TranscriptOptions;
|
|
71
|
+
readonly timeoutMs: number;
|
|
72
|
+
};
|
|
73
|
+
type Route = Operation["route"] | "POST /v1/transcript" | "POST /v1/capabilities";
|
|
74
|
+
type MessagingRoute =
|
|
75
|
+
| MessagingOperation["route"]
|
|
76
|
+
| "GET /v1/groups"
|
|
77
|
+
| "GET /v1/sessions"
|
|
78
|
+
| "GET /v1/transcript"
|
|
79
|
+
| "POST /v1/control"
|
|
80
|
+
| "POST /v1/transcript"
|
|
81
|
+
| "POST /v1/capabilities";
|
|
27
82
|
const fields = {
|
|
28
83
|
"GET /v1/list": [],
|
|
29
84
|
"GET /v1/inbox": [],
|
|
30
85
|
"POST /v1/register": [],
|
|
31
86
|
"POST /v1/ack": ["ids"],
|
|
32
|
-
"POST /v1/send": ["to", "message"],
|
|
87
|
+
"POST /v1/send": ["to", "message", "passive", "messageId"],
|
|
33
88
|
"POST /v1/ask": ["to", "message", "timeoutMs"],
|
|
34
89
|
"POST /v1/reply": ["replyTo", "message"],
|
|
35
90
|
"POST /v1/start": ["name"],
|
|
91
|
+
"POST /v1/spawn": ["name", "cwd", "task", "provider", "model"],
|
|
92
|
+
"POST /v1/resume": ["sessionId", "name", "target", "task"],
|
|
36
93
|
"GET /v1/sessions": [],
|
|
37
|
-
"POST /v1/stop": ["sessionId"],
|
|
94
|
+
"POST /v1/stop": ["sessionId", "target", "to", "mode", "timeoutMs"],
|
|
95
|
+
"POST /v1/cancel": ["sessionId", "target", "to", "timeoutMs"],
|
|
96
|
+
"GET /v1/groups": [],
|
|
97
|
+
"POST /v1/groups": ["name", "members", "namePattern", "cwdPattern", "match"],
|
|
98
|
+
"POST /v1/broadcast": ["message", "group"],
|
|
99
|
+
"POST /v1/control": ["to", "verb", "options", "timeoutMs"],
|
|
100
|
+
"POST /v1/capabilities": ["to", "timeoutMs"],
|
|
101
|
+
"POST /v1/transcript": ["to", "limit", "maxBytes", "cursor", "timeoutMs"],
|
|
102
|
+
"GET /v1/transcript": ["target", "limit", "maxBytes", "cursor", "timeoutMs"],
|
|
38
103
|
};
|
|
39
|
-
|
|
40
104
|
function isRoute(route: string): route is Route {
|
|
41
105
|
return Object.hasOwn(fields, route);
|
|
42
106
|
}
|
|
@@ -51,13 +115,19 @@ export function routeFor(req: IncomingMessage): Route {
|
|
|
51
115
|
if (!isRoute(route)) {
|
|
52
116
|
throw new Fault(404, "not_found", "Unknown method or path.");
|
|
53
117
|
}
|
|
54
|
-
if (
|
|
55
|
-
|
|
118
|
+
if (
|
|
119
|
+
req.url?.includes("?") === true &&
|
|
120
|
+
!["GET /v1/inbox", "GET /v1/sessions", "GET /v1/transcript"].includes(route)
|
|
121
|
+
) {
|
|
122
|
+
throw new Fault(
|
|
123
|
+
400,
|
|
124
|
+
"invalid_input",
|
|
125
|
+
"Query parameters are only supported for inbox, sessions and transcript.",
|
|
126
|
+
);
|
|
56
127
|
}
|
|
57
128
|
checkGetBody(req);
|
|
58
129
|
return route;
|
|
59
130
|
}
|
|
60
|
-
|
|
61
131
|
function checkGetBody(req: IncomingMessage): void {
|
|
62
132
|
if (
|
|
63
133
|
req.method === "GET" &&
|
|
@@ -67,68 +137,176 @@ function checkGetBody(req: IncomingMessage): void {
|
|
|
67
137
|
throw new Fault(400, "invalid_input", "GET requests must not have a body.");
|
|
68
138
|
}
|
|
69
139
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const query = new URLSearchParams(separator === -1 ? "" : url.slice(separator + 1));
|
|
75
|
-
if (
|
|
76
|
-
[...query.keys()].some(
|
|
77
|
-
(key) => !["after", "limit"].includes(key) || query.getAll(key).length !== 1,
|
|
78
|
-
)
|
|
79
|
-
) {
|
|
80
|
-
throw new Fault(400, "invalid_input", "Unknown or repeated inbox query parameter.");
|
|
140
|
+
function queryFor(req: IncomingMessage, allowed: readonly string[]): URLSearchParams {
|
|
141
|
+
const query = new URL(req.url ?? "", "https://127.0.0.1").searchParams;
|
|
142
|
+
if ([...query.keys()].some((key) => !allowed.includes(key) || query.getAll(key).length !== 1)) {
|
|
143
|
+
throw new Fault(400, "invalid_input", "Unknown or repeated query parameter.");
|
|
81
144
|
}
|
|
145
|
+
return query;
|
|
146
|
+
}
|
|
147
|
+
function inboxQuery(req: IncomingMessage): Extract<Operation, { readonly route: "GET /v1/inbox" }> {
|
|
148
|
+
const query = queryFor(req, ["after", "limit", "waitMs"]);
|
|
82
149
|
const after = query.get("after") ?? "0";
|
|
83
150
|
const limit = query.get("limit") ?? String(INBOX_COUNT);
|
|
151
|
+
const waitMs = query.get("waitMs") ?? "0";
|
|
84
152
|
if (
|
|
85
153
|
!/^(0|[1-9]\d{0,15})$/.test(after) ||
|
|
86
154
|
!Number.isSafeInteger(Number(after)) ||
|
|
87
155
|
!/^[1-9]\d{0,2}$/.test(limit) ||
|
|
88
|
-
Number(limit) > INBOX_COUNT
|
|
156
|
+
Number(limit) > INBOX_COUNT ||
|
|
157
|
+
!/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)
|
|
89
158
|
) {
|
|
90
159
|
throw new Fault(
|
|
91
160
|
400,
|
|
92
161
|
"invalid_input",
|
|
93
|
-
"after must be a nonnegative safe integer cursor; limit must be 1..256.",
|
|
162
|
+
"after must be a nonnegative safe integer cursor; limit must be 1..256; waitMs must be 0..10000.",
|
|
94
163
|
);
|
|
95
164
|
}
|
|
96
|
-
return {
|
|
165
|
+
return {
|
|
166
|
+
route: "GET /v1/inbox",
|
|
167
|
+
after: Number(after),
|
|
168
|
+
limit: Number(limit),
|
|
169
|
+
waitMs: Number(waitMs),
|
|
170
|
+
};
|
|
97
171
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
172
|
+
function pageNumber(value: string | null, fallback: number, min: number, max: number): number {
|
|
173
|
+
const number = value === null ? fallback : Number(value);
|
|
174
|
+
if (value === "" || !Number.isSafeInteger(number) || number < min || number > max) {
|
|
175
|
+
throw new Fault(400, "invalid_input", "Invalid catalog pagination.");
|
|
176
|
+
}
|
|
177
|
+
return number;
|
|
178
|
+
}
|
|
179
|
+
function queryBoolean(value: string | null): boolean {
|
|
180
|
+
if (value !== null && value !== "true" && value !== "false") {
|
|
181
|
+
throw new Fault(400, "invalid_input", "Catalog switches must be true or false.");
|
|
182
|
+
}
|
|
183
|
+
return value === "true";
|
|
184
|
+
}
|
|
185
|
+
function catalogQuery(req: IncomingMessage): CatalogQuery | undefined {
|
|
186
|
+
if (req.url?.includes("?") !== true) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const params = queryFor(req, ["live", "saved", "q", "offset", "limit"]);
|
|
190
|
+
return {
|
|
191
|
+
live: queryBoolean(params.get("live")),
|
|
192
|
+
saved: queryBoolean(params.get("saved")),
|
|
193
|
+
q: params.has("q") ? label(params.get("q"), "catalog filter", 128) : undefined,
|
|
194
|
+
offset: pageNumber(params.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER),
|
|
195
|
+
limit: pageNumber(params.get("limit"), 50, 1, 100),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function timeout(value: unknown, control = false): number {
|
|
199
|
+
const defaultMs = control ? 3000 : ASK_TIMEOUT;
|
|
200
|
+
const ms = value === undefined ? defaultMs : value;
|
|
201
|
+
const max = control ? 10000 : ASK_TIMEOUT;
|
|
202
|
+
if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 100 || ms > max) {
|
|
203
|
+
throw new Fault(400, "invalid_input", `timeoutMs must be an integer from 100 to ${max}.`);
|
|
103
204
|
}
|
|
104
205
|
return ms;
|
|
105
206
|
}
|
|
106
|
-
|
|
107
|
-
|
|
207
|
+
function resume(
|
|
208
|
+
input: Readonly<Record<string, unknown>>,
|
|
209
|
+
): Extract<Operation, { readonly route: "POST /v1/resume" }> {
|
|
210
|
+
if (["sessionId", "name", "target"].filter((key) => input[key] !== undefined).length !== 1) {
|
|
211
|
+
throw new Fault(
|
|
212
|
+
400,
|
|
213
|
+
"invalid_input",
|
|
214
|
+
"Resume requires exactly one of sessionId, name or target.",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const sessionId =
|
|
218
|
+
input.sessionId === undefined ? undefined : label(input.sessionId, "native session ID", 128);
|
|
219
|
+
if (sessionId !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sessionId)) {
|
|
220
|
+
throw new Fault(400, "invalid_input", "Resume takes a native ID, never a journal path.");
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
route: "POST /v1/resume",
|
|
224
|
+
sessionId,
|
|
225
|
+
target:
|
|
226
|
+
input.target === undefined ? undefined : label(input.target, "resume target", 256).trim(),
|
|
227
|
+
name:
|
|
228
|
+
input.name === undefined ? undefined : label(input.name, "saved session name", 256).trim(),
|
|
229
|
+
task: input.task === undefined ? undefined : text(input.task),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function stop(
|
|
233
|
+
route: "POST /v1/stop" | "POST /v1/cancel",
|
|
234
|
+
input: Readonly<Record<string, unknown>>,
|
|
235
|
+
): Extract<Operation, { readonly route: "POST /v1/stop" | "POST /v1/cancel" }> {
|
|
236
|
+
if (["sessionId", "target", "to"].filter((key) => input[key] !== undefined).length !== 1) {
|
|
237
|
+
throw new Fault(400, "invalid_input", "Control requires exactly one sessionId, target or to.");
|
|
238
|
+
}
|
|
239
|
+
const mode = input.mode ?? "kill";
|
|
240
|
+
if (mode !== "kill" && mode !== "cancel") {
|
|
241
|
+
throw new Fault(400, "invalid_input", "Stop mode must be cancel or kill.");
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
route: mode === "cancel" ? "POST /v1/cancel" : route,
|
|
245
|
+
sessionId: label(input.sessionId ?? input.target ?? input.to, "session target", 256).trim(),
|
|
246
|
+
timeoutMs: timeout(input.timeoutMs, true),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function nativeControl(
|
|
250
|
+
route: Route,
|
|
251
|
+
input: Readonly<Record<string, unknown>>,
|
|
252
|
+
): Extract<Operation, { readonly route: "POST /v1/control" | "GET /v1/transcript" }> {
|
|
253
|
+
const verb = route === "POST /v1/control" ? input.verb : route.split("/").at(-1);
|
|
254
|
+
if (!isControlVerb(verb)) {
|
|
255
|
+
throw new Fault(400, "invalid_input", "Unknown native control verb.");
|
|
256
|
+
}
|
|
257
|
+
const timeoutMs = timeout(input.timeoutMs, true);
|
|
258
|
+
const to = label(input.to ?? input.target, "session target").trim();
|
|
259
|
+
if (verb === "transcript") {
|
|
260
|
+
const options = transcriptOptions(
|
|
261
|
+
route === "POST /v1/control"
|
|
262
|
+
? input.options
|
|
263
|
+
: { limit: input.limit, maxBytes: input.maxBytes, cursor: input.cursor },
|
|
264
|
+
);
|
|
265
|
+
return { route: "GET /v1/transcript", target: to, options, timeoutMs };
|
|
266
|
+
}
|
|
267
|
+
if (input.options !== undefined) {
|
|
268
|
+
throw new Fault(400, "invalid_input", "Only transcript accepts options.");
|
|
269
|
+
}
|
|
270
|
+
return { route: "POST /v1/control", verb, to, timeoutMs };
|
|
271
|
+
}
|
|
272
|
+
async function operationInput(
|
|
108
273
|
req: IncomingMessage,
|
|
109
274
|
route: Route,
|
|
110
275
|
signal: AbortSignal,
|
|
111
|
-
): Promise<
|
|
112
|
-
|
|
276
|
+
): Promise<Record<string, unknown>> {
|
|
277
|
+
if (req.method === "POST") {
|
|
278
|
+
return object(await body(req, signal), fields[route]);
|
|
279
|
+
}
|
|
280
|
+
if (route !== "GET /v1/transcript") {
|
|
281
|
+
return {};
|
|
282
|
+
}
|
|
283
|
+
const input: Record<string, unknown> = {};
|
|
284
|
+
for (const [key, value] of queryFor(req, fields[route])) {
|
|
285
|
+
input[key] = ["limit", "maxBytes", "timeoutMs"].includes(key) ? Number(value) : value;
|
|
286
|
+
}
|
|
287
|
+
return input;
|
|
288
|
+
}
|
|
289
|
+
function messagingOperation(
|
|
290
|
+
route: MessagingRoute,
|
|
291
|
+
input: Readonly<Record<string, unknown>>,
|
|
292
|
+
req: IncomingMessage,
|
|
293
|
+
): Operation {
|
|
113
294
|
switch (route) {
|
|
114
295
|
case "GET /v1/list":
|
|
115
296
|
case "POST /v1/register":
|
|
116
|
-
case "GET /v1/
|
|
297
|
+
case "GET /v1/groups":
|
|
117
298
|
return { route };
|
|
118
299
|
case "GET /v1/inbox":
|
|
119
300
|
return inboxQuery(req);
|
|
120
|
-
case "
|
|
121
|
-
return { route,
|
|
122
|
-
case "POST /v1/
|
|
123
|
-
return { route, sessionId: label(input.sessionId, "session ID", 128) };
|
|
124
|
-
case "POST /v1/ack": {
|
|
301
|
+
case "GET /v1/sessions":
|
|
302
|
+
return { route, query: catalogQuery(req) };
|
|
303
|
+
case "POST /v1/ack":
|
|
125
304
|
if (!isUnknownArray(input.ids) || input.ids.length > INBOX_COUNT) {
|
|
126
305
|
throw new Fault(400, "invalid_input", "ids must be an array of at most 256 message IDs.");
|
|
127
306
|
}
|
|
128
307
|
return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
|
|
129
|
-
}
|
|
130
308
|
case "POST /v1/send":
|
|
131
|
-
return
|
|
309
|
+
return send(input);
|
|
132
310
|
case "POST /v1/ask":
|
|
133
311
|
return {
|
|
134
312
|
route,
|
|
@@ -138,5 +316,69 @@ export async function operationFor(
|
|
|
138
316
|
};
|
|
139
317
|
case "POST /v1/reply":
|
|
140
318
|
return { route, message: text(input.message), replyTo: label(input.replyTo, "replyTo") };
|
|
319
|
+
case "GET /v1/transcript":
|
|
320
|
+
case "POST /v1/control":
|
|
321
|
+
case "POST /v1/transcript":
|
|
322
|
+
case "POST /v1/capabilities":
|
|
323
|
+
return nativeControl(route, input);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function send(input: Readonly<Record<string, unknown>>): MessagingOperation {
|
|
327
|
+
if (input.passive !== undefined && typeof input.passive !== "boolean") {
|
|
328
|
+
throw new Fault(400, "invalid_input", "passive must be a boolean.");
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
route: "POST /v1/send",
|
|
332
|
+
message: text(input.message),
|
|
333
|
+
to: label(input.to, "to").trim(),
|
|
334
|
+
passive: input.passive,
|
|
335
|
+
messageId: input.messageId === undefined ? undefined : label(input.messageId, "message ID"),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
export async function operationFor(
|
|
339
|
+
req: IncomingMessage,
|
|
340
|
+
route: Route,
|
|
341
|
+
signal: AbortSignal,
|
|
342
|
+
): Promise<Operation> {
|
|
343
|
+
const input = await operationInput(req, route, signal);
|
|
344
|
+
switch (route) {
|
|
345
|
+
case "POST /v1/start":
|
|
346
|
+
return { route, name: label(input.name, "session name", 64).trim() };
|
|
347
|
+
case "POST /v1/spawn":
|
|
348
|
+
return {
|
|
349
|
+
route,
|
|
350
|
+
name: label(input.name, "session name", 64).trim(),
|
|
351
|
+
cwd: input.cwd === undefined ? undefined : label(input.cwd, "cwd", 4096),
|
|
352
|
+
provider: input.provider === undefined ? undefined : label(input.provider, "provider", 128),
|
|
353
|
+
model: input.model === undefined ? undefined : label(input.model, "model reference", 512),
|
|
354
|
+
task: input.task === undefined ? undefined : text(input.task),
|
|
355
|
+
};
|
|
356
|
+
case "POST /v1/resume":
|
|
357
|
+
return resume(input);
|
|
358
|
+
case "POST /v1/stop":
|
|
359
|
+
case "POST /v1/cancel":
|
|
360
|
+
return stop(route, input);
|
|
361
|
+
case "POST /v1/groups":
|
|
362
|
+
return { route, group: groupDefinition(input) };
|
|
363
|
+
case "POST /v1/broadcast":
|
|
364
|
+
return {
|
|
365
|
+
route,
|
|
366
|
+
message: text(input.message),
|
|
367
|
+
group: input.group === undefined ? undefined : label(input.group, "group", 64).trim(),
|
|
368
|
+
};
|
|
369
|
+
case "GET /v1/groups":
|
|
370
|
+
case "GET /v1/inbox":
|
|
371
|
+
case "GET /v1/list":
|
|
372
|
+
case "GET /v1/sessions":
|
|
373
|
+
case "GET /v1/transcript":
|
|
374
|
+
case "POST /v1/ack":
|
|
375
|
+
case "POST /v1/ask":
|
|
376
|
+
case "POST /v1/capabilities":
|
|
377
|
+
case "POST /v1/control":
|
|
378
|
+
case "POST /v1/register":
|
|
379
|
+
case "POST /v1/reply":
|
|
380
|
+
case "POST /v1/send":
|
|
381
|
+
case "POST /v1/transcript":
|
|
382
|
+
return messagingOperation(route, input, req);
|
|
141
383
|
}
|
|
142
384
|
}
|