@ahpd/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/automations.d.ts +4 -0
  4. package/dist/automations.d.ts.map +1 -0
  5. package/dist/automations.js +191 -0
  6. package/dist/automations.js.map +1 -0
  7. package/dist/catalog.d.ts +48 -0
  8. package/dist/catalog.d.ts.map +1 -0
  9. package/dist/catalog.js +51 -0
  10. package/dist/catalog.js.map +1 -0
  11. package/dist/changes.d.ts +4 -0
  12. package/dist/changes.d.ts.map +1 -0
  13. package/dist/changes.js +601 -0
  14. package/dist/changes.js.map +1 -0
  15. package/dist/cron.d.ts +43 -0
  16. package/dist/cron.d.ts.map +1 -0
  17. package/dist/cron.js +211 -0
  18. package/dist/cron.js.map +1 -0
  19. package/dist/git.d.ts +16 -0
  20. package/dist/git.d.ts.map +1 -0
  21. package/dist/git.js +126 -0
  22. package/dist/git.js.map +1 -0
  23. package/dist/host.d.ts +24 -0
  24. package/dist/host.d.ts.map +1 -0
  25. package/dist/host.js +5850 -0
  26. package/dist/host.js.map +1 -0
  27. package/dist/index.d.ts +36 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +34 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/listen.d.ts +3 -0
  32. package/dist/listen.d.ts.map +1 -0
  33. package/dist/listen.js +183 -0
  34. package/dist/listen.js.map +1 -0
  35. package/dist/paging.d.ts +38 -0
  36. package/dist/paging.d.ts.map +1 -0
  37. package/dist/paging.js +53 -0
  38. package/dist/paging.js.map +1 -0
  39. package/dist/paths.d.ts +15 -0
  40. package/dist/paths.d.ts.map +1 -0
  41. package/dist/paths.js +19 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/resources.d.ts +122 -0
  44. package/dist/resources.d.ts.map +1 -0
  45. package/dist/resources.js +494 -0
  46. package/dist/resources.js.map +1 -0
  47. package/dist/rpc.d.ts +63 -0
  48. package/dist/rpc.d.ts.map +1 -0
  49. package/dist/rpc.js +208 -0
  50. package/dist/rpc.js.map +1 -0
  51. package/dist/scheduled.d.ts +48 -0
  52. package/dist/scheduled.d.ts.map +1 -0
  53. package/dist/scheduled.js +325 -0
  54. package/dist/scheduled.js.map +1 -0
  55. package/dist/terminals.d.ts +29 -0
  56. package/dist/terminals.d.ts.map +1 -0
  57. package/dist/terminals.js +346 -0
  58. package/dist/terminals.js.map +1 -0
  59. package/dist/tools.d.ts +18 -0
  60. package/dist/tools.d.ts.map +1 -0
  61. package/dist/tools.js +65 -0
  62. package/dist/tools.js.map +1 -0
  63. package/dist/types/agent.d.ts +245 -0
  64. package/dist/types/agent.d.ts.map +1 -0
  65. package/dist/types/agent.js +3 -0
  66. package/dist/types/agent.js.map +1 -0
  67. package/dist/types/automations.d.ts +164 -0
  68. package/dist/types/automations.d.ts.map +1 -0
  69. package/dist/types/automations.js +3 -0
  70. package/dist/types/automations.js.map +1 -0
  71. package/dist/types/catalog.d.ts +30 -0
  72. package/dist/types/catalog.d.ts.map +1 -0
  73. package/dist/types/catalog.js +3 -0
  74. package/dist/types/catalog.js.map +1 -0
  75. package/dist/types/changes.d.ts +272 -0
  76. package/dist/types/changes.d.ts.map +1 -0
  77. package/dist/types/changes.js +3 -0
  78. package/dist/types/changes.js.map +1 -0
  79. package/dist/types/common.d.ts +9 -0
  80. package/dist/types/common.d.ts.map +1 -0
  81. package/dist/types/common.js +3 -0
  82. package/dist/types/common.js.map +1 -0
  83. package/dist/types/host.d.ts +372 -0
  84. package/dist/types/host.d.ts.map +1 -0
  85. package/dist/types/host.js +3 -0
  86. package/dist/types/host.js.map +1 -0
  87. package/dist/types/index.d.ts +20 -0
  88. package/dist/types/index.d.ts.map +1 -0
  89. package/dist/types/index.js +8 -0
  90. package/dist/types/index.js.map +1 -0
  91. package/dist/types/listen.d.ts +48 -0
  92. package/dist/types/listen.d.ts.map +1 -0
  93. package/dist/types/listen.js +3 -0
  94. package/dist/types/listen.js.map +1 -0
  95. package/dist/types/paging.d.ts +10 -0
  96. package/dist/types/paging.d.ts.map +1 -0
  97. package/dist/types/paging.js +3 -0
  98. package/dist/types/paging.js.map +1 -0
  99. package/dist/types/probe.d.ts +46 -0
  100. package/dist/types/probe.d.ts.map +1 -0
  101. package/dist/types/probe.js +3 -0
  102. package/dist/types/probe.js.map +1 -0
  103. package/dist/types/resources.d.ts +83 -0
  104. package/dist/types/resources.d.ts.map +1 -0
  105. package/dist/types/resources.js +3 -0
  106. package/dist/types/resources.js.map +1 -0
  107. package/dist/types/rpc.d.ts +65 -0
  108. package/dist/types/rpc.d.ts.map +1 -0
  109. package/dist/types/rpc.js +3 -0
  110. package/dist/types/rpc.js.map +1 -0
  111. package/dist/types/session.d.ts +395 -0
  112. package/dist/types/session.d.ts.map +1 -0
  113. package/dist/types/session.js +3 -0
  114. package/dist/types/session.js.map +1 -0
  115. package/dist/types/terminals.d.ts +131 -0
  116. package/dist/types/terminals.d.ts.map +1 -0
  117. package/dist/types/terminals.js +3 -0
  118. package/dist/types/terminals.js.map +1 -0
  119. package/dist/types/wire.d.ts +62 -0
  120. package/dist/types/wire.d.ts.map +1 -0
  121. package/dist/types/wire.js +13 -0
  122. package/dist/types/wire.js.map +1 -0
  123. package/dist/types/worktrees.d.ts +86 -0
  124. package/dist/types/worktrees.d.ts.map +1 -0
  125. package/dist/types/worktrees.js +3 -0
  126. package/dist/types/worktrees.js.map +1 -0
  127. package/dist/worktrees.d.ts +27 -0
  128. package/dist/worktrees.d.ts.map +1 -0
  129. package/dist/worktrees.js +152 -0
  130. package/dist/worktrees.js.map +1 -0
  131. package/package.json +61 -0
  132. package/src/automations.ts +204 -0
  133. package/src/catalog.ts +53 -0
  134. package/src/changes.ts +629 -0
  135. package/src/cron.ts +251 -0
  136. package/src/git.ts +132 -0
  137. package/src/host.ts +5947 -0
  138. package/src/index.ts +40 -0
  139. package/src/listen.ts +240 -0
  140. package/src/paging.ts +56 -0
  141. package/src/paths.ts +19 -0
  142. package/src/resources.ts +513 -0
  143. package/src/rpc.ts +222 -0
  144. package/src/scheduled.ts +369 -0
  145. package/src/terminals.ts +353 -0
  146. package/src/tools.ts +64 -0
  147. package/src/types/agent.ts +255 -0
  148. package/src/types/automations.ts +164 -0
  149. package/src/types/catalog.ts +26 -0
  150. package/src/types/changes.ts +266 -0
  151. package/src/types/common.ts +9 -0
  152. package/src/types/host.ts +386 -0
  153. package/src/types/index.ts +27 -0
  154. package/src/types/listen.ts +53 -0
  155. package/src/types/paging.ts +11 -0
  156. package/src/types/probe.ts +42 -0
  157. package/src/types/resources.ts +107 -0
  158. package/src/types/rpc.ts +68 -0
  159. package/src/types/session.ts +415 -0
  160. package/src/types/terminals.ts +133 -0
  161. package/src/types/wire.ts +69 -0
  162. package/src/types/worktrees.ts +91 -0
  163. package/src/worktrees.ts +162 -0
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The host: the protocol, and the parts to build your own out of.
3
+ *
4
+ * There is no backend in here, and that is the point of it being its own
5
+ * package. `rpc` is JSON-RPC and holds no socket, `listen` is the only file
6
+ * that knows which runtime it is on, and `createHost` is the protocol and
7
+ * imports nothing that runs an agent. A harness is an `Agent` handed to
8
+ * `createHost` from outside - `@ahpd/agent-claude` is one, `examples/` has one
9
+ * written from nothing - and adding another is not a fork of this.
10
+ *
11
+ * Anything that touches the machine is the same shape again: `fileResources`
12
+ * reads files, `shellTerminals` spawns shells and `gitBranches` spawns `git`,
13
+ * and all three are passed to `createHost` rather than reached for by it. So
14
+ * the protocol imports no runtime, and a host without one of them refuses the
15
+ * commands it cannot answer instead of pretending to.
16
+ *
17
+ * Every shape lives in `types/` and nothing there imports a runtime value, so
18
+ * the contract can be read without loading any of this.
19
+ */
20
+
21
+ export { createHost, ROOT } from './host.js';
22
+ export { within } from './paths.js';
23
+ export { listen } from './listen.js';
24
+ export {
25
+ createPeer, receive, RpcError, RpcTimeout, RpcClosed, ANSWER_TIMEOUT,
26
+ PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INTERNAL_ERROR,
27
+ } from './rpc.js';
28
+ export { gitBranches } from './git.js';
29
+ export { gitChanges } from './changes.js';
30
+ export { fileResources } from './resources.js';
31
+ export { shellTerminals } from './terminals.js';
32
+ export { hostTools } from './tools.js';
33
+ export { gitWorktrees, worktreesOf, worktreeFor } from './worktrees.js';
34
+ export { memoryAutomations } from './automations.js';
35
+ export { scheduledAutomations } from './scheduled.js';
36
+ export type { ScheduledOptions } from './scheduled.js';
37
+ export { uriFor, idFor, idOf, Status } from './catalog.js';
38
+ export { tail, older, PAGE } from './paging.js';
39
+
40
+ export type * from './types/index.js';
package/src/listen.ts ADDED
@@ -0,0 +1,240 @@
1
+ import { createPeer, receive } from './rpc.js';
2
+ import type { Connected, Listener, ListenOptions, OnConnect, Runtime } from './types/listen.js';
3
+
4
+ /**
5
+ * Accepts WebSocket connections on Node, Bun or Deno.
6
+ *
7
+ * The runtime is detected at startup and the matching server is used: `ws` on
8
+ * Node, which has no WebSocket server of its own, and the built-in servers on
9
+ * Bun and Deno. `ws` is an optional dependency and is imported only on Node.
10
+ *
11
+ * This is the only module that knows which runtime it is on. Supporting
12
+ * another one is a case added here.
13
+ */
14
+
15
+ const runtimeOf = (): Runtime => {
16
+ const g = globalThis as { Bun?: unknown; Deno?: unknown };
17
+ if (g.Bun !== undefined) return 'bun';
18
+ if (g.Deno !== undefined) return 'deno';
19
+ return 'node';
20
+ };
21
+
22
+ /** What a runtime's socket has to look like once it is wired up. */
23
+ interface Bound {
24
+ peer: ReturnType<typeof createPeer>;
25
+ connected: Connected;
26
+ }
27
+
28
+ /**
29
+ * The token a connection presented, if it presented one.
30
+ *
31
+ * Two places, because only one of them always works: a browser cannot set
32
+ * headers on a WebSocket handshake, so the query string is the portable form
33
+ * and the header is for clients that can.
34
+ */
35
+ const presented = (url: string | undefined, authorization: string | null): string | undefined => {
36
+ const query = /[?&]tkn=([^&]*)/.exec(url ?? '');
37
+ if (query) return decodeURIComponent(query[1] ?? '');
38
+ const bearer = /^Bearer\s+(.+)$/i.exec(authorization ?? '');
39
+ return bearer?.[1];
40
+ };
41
+
42
+ /**
43
+ * Compares two secrets without returning early on the first difference.
44
+ *
45
+ * The lengths still differ observably, which is why a token is generated
46
+ * rather than chosen: they are all the same length.
47
+ */
48
+ const same = (a: string, b: string): boolean => {
49
+ if (a.length !== b.length) return false;
50
+ let differing = 0;
51
+ for (let i = 0; i < a.length; i++) differing |= a.charCodeAt(i) ^ b.charCodeAt(i);
52
+ return differing === 0;
53
+ };
54
+
55
+ export async function listen(options: ListenOptions, onConnect: OnConnect): Promise<Listener> {
56
+ const runtime = runtimeOf();
57
+ const host = options.host ?? '127.0.0.1';
58
+ const token = options.token;
59
+ /** Whether this handshake may proceed. No token configured accepts any. */
60
+ const allowed = (url: string | undefined, authorization: string | null): boolean =>
61
+ token === undefined || same(token, presented(url, authorization) ?? '');
62
+
63
+ if (runtime === 'bun') {
64
+ const Bun = (globalThis as unknown as { Bun: {
65
+ serve(options: Record<string, unknown>): { stop(closeActive?: boolean): void; port: number };
66
+ } }).Bun;
67
+ // Per socket, because Bun's handler table is one set of callbacks for
68
+ // every connection - `ws` is the only thing distinguishing them.
69
+ const bound = new Map<object, Bound>();
70
+ const server = Bun.serve({
71
+ port: options.port,
72
+ hostname: host,
73
+ fetch(request: Request_, server_: { upgrade(r: Request_): boolean }) {
74
+ // Refused before the upgrade, so an unauthorised client is told in
75
+ // HTTP rather than handed a socket that closes on its first message.
76
+ if (!allowed(request.url, request.headers.get('authorization'))) {
77
+ return new Response('A connection token is required', { status: 401 });
78
+ }
79
+ if (server_.upgrade(request)) return undefined;
80
+ return new Response('ahpd speaks the Agent Host Protocol over WebSocket', { status: 426 });
81
+ },
82
+ websocket: {
83
+ open(ws: BunSocket) {
84
+ const peer = createPeer({
85
+ send: (text) => { ws.send(text); },
86
+ close: () => ws.close(),
87
+ isOpen: () => ws.readyState === 1,
88
+ });
89
+ bound.set(ws, { peer, connected: onConnect(peer) });
90
+ },
91
+ message(ws: BunSocket, raw: string | Uint8Array) {
92
+ const held = bound.get(ws);
93
+ if (!held) return;
94
+ const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
95
+ receive(text, held.peer, (request) => held.connected.handle(request));
96
+ },
97
+ close(ws: BunSocket) {
98
+ const held = bound.get(ws);
99
+ // The peer first: a question this host asked is still pending, and
100
+ // the socket that would have answered it is gone.
101
+ held?.peer.close();
102
+ held?.connected.close();
103
+ bound.delete(ws);
104
+ },
105
+ },
106
+ });
107
+ return { runtime, host, port: server.port, guarded: token !== undefined, close: () => server.stop(true) };
108
+ }
109
+
110
+ if (runtime === 'deno') {
111
+ const Deno = (globalThis as unknown as { Deno: {
112
+ serve(options: { port: number; hostname: string }, handler: (r: Request_) => Response): {
113
+ shutdown(): Promise<void>;
114
+ addr: { port: number };
115
+ };
116
+ upgradeWebSocket(r: Request_): { socket: DenoSocket; response: Response };
117
+ } }).Deno;
118
+ const server = Deno.serve({ port: options.port, hostname: host }, (request) => {
119
+ if (!allowed(request.url, request.headers.get('authorization'))) {
120
+ return new Response('A connection token is required', { status: 401 });
121
+ }
122
+ if ((request.headers.get('upgrade') ?? '').toLowerCase() !== 'websocket') {
123
+ return new Response('ahpd speaks the Agent Host Protocol over WebSocket', { status: 426 });
124
+ }
125
+ const { socket, response } = Deno.upgradeWebSocket(request);
126
+ let held: Bound | undefined;
127
+ socket.onopen = () => {
128
+ const peer = createPeer({
129
+ send: (text) => { socket.send(text); },
130
+ close: () => socket.close(),
131
+ isOpen: () => socket.readyState === 1,
132
+ });
133
+ held = { peer, connected: onConnect(peer) };
134
+ };
135
+ socket.onmessage = (event) => {
136
+ const open = held;
137
+ if (!open) return;
138
+ receive(String(event.data), open.peer, (request_) => open.connected.handle(request_));
139
+ };
140
+ socket.onclose = () => { held?.peer.close(); held?.connected.close(); held = undefined; };
141
+ return response;
142
+ });
143
+ return {
144
+ runtime,
145
+ host,
146
+ port: server.addr.port,
147
+ guarded: token !== undefined,
148
+ close: () => server.shutdown(),
149
+ };
150
+ }
151
+
152
+ let WebSocketServer: new (options: NodeOptions) => NodeServer;
153
+ try {
154
+ ({ WebSocketServer } = await import('ws') as unknown as {
155
+ WebSocketServer: new (options: NodeOptions) => NodeServer;
156
+ });
157
+ } catch {
158
+ throw new Error(
159
+ 'Running on Node needs the `ws` package, which has no server in the standard library.\n'
160
+ + ' npm install ws\n'
161
+ + 'Bun and Deno have one built in and need nothing.',
162
+ );
163
+ }
164
+
165
+ const server = new WebSocketServer({
166
+ port: options.port,
167
+ host,
168
+ // `ws` answers a rejected handshake with the status this passes back, so
169
+ // an unauthorised client reads 401 rather than a socket that opened and
170
+ // then closed for no stated reason.
171
+ verifyClient: (info, accept) => {
172
+ if (allowed(info.req.url, info.req.headers.authorization ?? null)) accept(true);
173
+ else accept(false, 401, 'A connection token is required');
174
+ },
175
+ });
176
+ server.on('connection', (socket) => {
177
+ const peer = createPeer({
178
+ send: (text) => { socket.send(text); },
179
+ close: () => socket.close(),
180
+ isOpen: () => socket.readyState === 1,
181
+ });
182
+ const connected = onConnect(peer);
183
+ socket.on('message', (raw) => {
184
+ receive(typeof raw === 'string' ? raw : raw.toString('utf8'), peer, (request) => connected.handle(request));
185
+ });
186
+ socket.on('close', () => { peer.close(); connected.close(); });
187
+ });
188
+
189
+ await new Promise<void>((resolve, reject) => {
190
+ server.once('listening', () => resolve());
191
+ server.once('error', (error) => reject(error instanceof Error ? error : new Error(String(error))));
192
+ });
193
+ return {
194
+ runtime,
195
+ host,
196
+ // What was bound, not what was asked for: port 0 means the OS chooses.
197
+ port: server.address()?.port ?? options.port,
198
+ guarded: token !== undefined,
199
+ close: () => { server.close(); },
200
+ };
201
+ }
202
+
203
+ // --- the shapes each runtime hands back, named so the code above reads ------
204
+
205
+ type Request_ = { url: string; headers: { get(name: string): string | null } };
206
+
207
+ interface BunSocket { send(text: string): unknown; close(): void; readyState: number }
208
+
209
+ interface DenoSocket {
210
+ send(text: string): void;
211
+ close(): void;
212
+ readyState: number;
213
+ onopen: (() => void) | null;
214
+ onmessage: ((event: { data: unknown }) => void) | null;
215
+ onclose: (() => void) | null;
216
+ }
217
+
218
+ interface NodeOptions {
219
+ port: number;
220
+ host: string;
221
+ verifyClient(
222
+ info: { req: { url?: string; headers: { authorization?: string } } },
223
+ accept: (allow: boolean, code?: number, message?: string) => void,
224
+ ): void;
225
+ }
226
+
227
+ interface NodeServer {
228
+ on(event: 'connection', handler: (socket: NodeSocket) => void): void;
229
+ once(event: 'listening' | 'error', handler: (error?: unknown) => void): void;
230
+ address(): { port: number } | null;
231
+ close(): void;
232
+ }
233
+
234
+ interface NodeSocket {
235
+ send(text: string): void;
236
+ close(): void;
237
+ readyState: number;
238
+ on(event: 'message', handler: (raw: string | Buffer) => void): void;
239
+ on(event: 'close', handler: () => void): void;
240
+ }
package/src/paging.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { Bag } from './types/common.js';
2
+ import type { Page } from './types/paging.js';
3
+
4
+ /**
5
+ * A long list of turns, served a page at a time.
6
+ *
7
+ * Nothing here knows what a turn is beyond it being one of many, which is why
8
+ * it is not in `transcript.ts`: reading a transcript is the backend's, because
9
+ * only the thing that ran the session knows the shape its frames are in.
10
+ * Paging is the host's, and it is the same paging whichever backend produced
11
+ * them.
12
+ */
13
+
14
+ /**
15
+ * How many turns a snapshot carries.
16
+ *
17
+ * A session in this directory has eight hundred and fifty turns. Sending them
18
+ * all works and will not keep working: a snapshot is what a client waits for
19
+ * before it can draw anything, and the oldest turns are the ones nobody is
20
+ * looking at.
21
+ */
22
+ export const PAGE = 50;
23
+
24
+ /**
25
+ * The newest page, and where the rest begins.
26
+ *
27
+ * The cursor is the index of the oldest turn served - opaque to a client, and
28
+ * deliberately so, but it has to mean something here or `fetchTurns` cannot
29
+ * answer twice in a row.
30
+ */
31
+ export function tail(turns: Bag[], size = PAGE): Page {
32
+ const start = Math.max(0, turns.length - size);
33
+ return {
34
+ turns: turns.slice(start),
35
+ ...(start > 0 ? { turnsNextCursor: String(start) } : {}),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * The page before a cursor, or nothing when the cursor is not one of ours.
41
+ *
42
+ * The protocol requires an unrecognised cursor to be rejected rather than
43
+ * guessed at: a host that quietly returned its newest page for a cursor it
44
+ * did not issue would answer a question about old turns with new ones, and
45
+ * the client would page forever without noticing.
46
+ */
47
+ export function older(turns: Bag[], cursor: string, size = PAGE): Page | undefined {
48
+ if (!/^\d+$/.test(cursor)) return undefined;
49
+ const at = Number(cursor);
50
+ if (at <= 0 || at > turns.length) return undefined;
51
+ const start = Math.max(0, at - size);
52
+ return {
53
+ turns: turns.slice(start, at),
54
+ ...(start > 0 ? { turnsNextCursor: String(start) } : {}),
55
+ };
56
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { isAbsolute, relative } from 'node:path';
2
+
3
+ /**
4
+ * Whether a path is a served root or under one.
5
+ *
6
+ * By `relative` rather than by string: `startsWith(root + '/')` is wrong for
7
+ * `/` - it asks whether the path begins `//` and refuses the whole filesystem -
8
+ * and `root === path` alone is wrong for everything else. Three places check
9
+ * this and they used to do it three ways, of which one was exact equality: a
10
+ * host told to serve `/home/you` served that directory and refused every
11
+ * project inside it, which is the only kind of directory anybody opens.
12
+ *
13
+ * Textual, so callers that can resolve symlinks resolve first and pass the
14
+ * real path - `served/link` pointing at `/etc` passes any textual test.
15
+ */
16
+ export const within = (root: string, path: string): boolean => {
17
+ const step = relative(root, path);
18
+ return step === '' || (!step.startsWith('..') && !isAbsolute(step));
19
+ };