@ahpd/server 0.1.0 → 0.3.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 (170) hide show
  1. package/README.md +88 -81
  2. package/dist/config.d.ts +72 -0
  3. package/dist/config.js +75 -0
  4. package/dist/daemon.d.ts +31 -0
  5. package/dist/daemon.js +162 -0
  6. package/dist/main.d.ts +2 -0
  7. package/dist/main.js +335 -0
  8. package/dist/pty.d.ts +12 -0
  9. package/dist/pty.js +56 -0
  10. package/package.json +15 -25
  11. package/dist/automations.d.ts +0 -4
  12. package/dist/automations.d.ts.map +0 -1
  13. package/dist/automations.js +0 -191
  14. package/dist/automations.js.map +0 -1
  15. package/dist/catalog.d.ts +0 -48
  16. package/dist/catalog.d.ts.map +0 -1
  17. package/dist/catalog.js +0 -51
  18. package/dist/catalog.js.map +0 -1
  19. package/dist/changes.d.ts +0 -4
  20. package/dist/changes.d.ts.map +0 -1
  21. package/dist/changes.js +0 -601
  22. package/dist/changes.js.map +0 -1
  23. package/dist/cron.d.ts +0 -43
  24. package/dist/cron.d.ts.map +0 -1
  25. package/dist/cron.js +0 -211
  26. package/dist/cron.js.map +0 -1
  27. package/dist/git.d.ts +0 -16
  28. package/dist/git.d.ts.map +0 -1
  29. package/dist/git.js +0 -126
  30. package/dist/git.js.map +0 -1
  31. package/dist/host.d.ts +0 -24
  32. package/dist/host.d.ts.map +0 -1
  33. package/dist/host.js +0 -5850
  34. package/dist/host.js.map +0 -1
  35. package/dist/index.d.ts +0 -36
  36. package/dist/index.d.ts.map +0 -1
  37. package/dist/index.js +0 -34
  38. package/dist/index.js.map +0 -1
  39. package/dist/listen.d.ts +0 -3
  40. package/dist/listen.d.ts.map +0 -1
  41. package/dist/listen.js +0 -183
  42. package/dist/listen.js.map +0 -1
  43. package/dist/paging.d.ts +0 -38
  44. package/dist/paging.d.ts.map +0 -1
  45. package/dist/paging.js +0 -53
  46. package/dist/paging.js.map +0 -1
  47. package/dist/paths.d.ts +0 -15
  48. package/dist/paths.d.ts.map +0 -1
  49. package/dist/paths.js +0 -19
  50. package/dist/paths.js.map +0 -1
  51. package/dist/resources.d.ts +0 -122
  52. package/dist/resources.d.ts.map +0 -1
  53. package/dist/resources.js +0 -494
  54. package/dist/resources.js.map +0 -1
  55. package/dist/rpc.d.ts +0 -63
  56. package/dist/rpc.d.ts.map +0 -1
  57. package/dist/rpc.js +0 -208
  58. package/dist/rpc.js.map +0 -1
  59. package/dist/scheduled.d.ts +0 -48
  60. package/dist/scheduled.d.ts.map +0 -1
  61. package/dist/scheduled.js +0 -325
  62. package/dist/scheduled.js.map +0 -1
  63. package/dist/terminals.d.ts +0 -29
  64. package/dist/terminals.d.ts.map +0 -1
  65. package/dist/terminals.js +0 -346
  66. package/dist/terminals.js.map +0 -1
  67. package/dist/tools.d.ts +0 -18
  68. package/dist/tools.d.ts.map +0 -1
  69. package/dist/tools.js +0 -65
  70. package/dist/tools.js.map +0 -1
  71. package/dist/types/agent.d.ts +0 -245
  72. package/dist/types/agent.d.ts.map +0 -1
  73. package/dist/types/agent.js +0 -3
  74. package/dist/types/agent.js.map +0 -1
  75. package/dist/types/automations.d.ts +0 -164
  76. package/dist/types/automations.d.ts.map +0 -1
  77. package/dist/types/automations.js +0 -3
  78. package/dist/types/automations.js.map +0 -1
  79. package/dist/types/catalog.d.ts +0 -30
  80. package/dist/types/catalog.d.ts.map +0 -1
  81. package/dist/types/catalog.js +0 -3
  82. package/dist/types/catalog.js.map +0 -1
  83. package/dist/types/changes.d.ts +0 -272
  84. package/dist/types/changes.d.ts.map +0 -1
  85. package/dist/types/changes.js +0 -3
  86. package/dist/types/changes.js.map +0 -1
  87. package/dist/types/common.d.ts +0 -9
  88. package/dist/types/common.d.ts.map +0 -1
  89. package/dist/types/common.js +0 -3
  90. package/dist/types/common.js.map +0 -1
  91. package/dist/types/host.d.ts +0 -372
  92. package/dist/types/host.d.ts.map +0 -1
  93. package/dist/types/host.js +0 -3
  94. package/dist/types/host.js.map +0 -1
  95. package/dist/types/index.d.ts +0 -20
  96. package/dist/types/index.d.ts.map +0 -1
  97. package/dist/types/index.js +0 -8
  98. package/dist/types/index.js.map +0 -1
  99. package/dist/types/listen.d.ts +0 -48
  100. package/dist/types/listen.d.ts.map +0 -1
  101. package/dist/types/listen.js +0 -3
  102. package/dist/types/listen.js.map +0 -1
  103. package/dist/types/paging.d.ts +0 -10
  104. package/dist/types/paging.d.ts.map +0 -1
  105. package/dist/types/paging.js +0 -3
  106. package/dist/types/paging.js.map +0 -1
  107. package/dist/types/probe.d.ts +0 -46
  108. package/dist/types/probe.d.ts.map +0 -1
  109. package/dist/types/probe.js +0 -3
  110. package/dist/types/probe.js.map +0 -1
  111. package/dist/types/resources.d.ts +0 -83
  112. package/dist/types/resources.d.ts.map +0 -1
  113. package/dist/types/resources.js +0 -3
  114. package/dist/types/resources.js.map +0 -1
  115. package/dist/types/rpc.d.ts +0 -65
  116. package/dist/types/rpc.d.ts.map +0 -1
  117. package/dist/types/rpc.js +0 -3
  118. package/dist/types/rpc.js.map +0 -1
  119. package/dist/types/session.d.ts +0 -395
  120. package/dist/types/session.d.ts.map +0 -1
  121. package/dist/types/session.js +0 -3
  122. package/dist/types/session.js.map +0 -1
  123. package/dist/types/terminals.d.ts +0 -131
  124. package/dist/types/terminals.d.ts.map +0 -1
  125. package/dist/types/terminals.js +0 -3
  126. package/dist/types/terminals.js.map +0 -1
  127. package/dist/types/wire.d.ts +0 -62
  128. package/dist/types/wire.d.ts.map +0 -1
  129. package/dist/types/wire.js +0 -13
  130. package/dist/types/wire.js.map +0 -1
  131. package/dist/types/worktrees.d.ts +0 -86
  132. package/dist/types/worktrees.d.ts.map +0 -1
  133. package/dist/types/worktrees.js +0 -3
  134. package/dist/types/worktrees.js.map +0 -1
  135. package/dist/worktrees.d.ts +0 -27
  136. package/dist/worktrees.d.ts.map +0 -1
  137. package/dist/worktrees.js +0 -152
  138. package/dist/worktrees.js.map +0 -1
  139. package/src/automations.ts +0 -204
  140. package/src/catalog.ts +0 -53
  141. package/src/changes.ts +0 -629
  142. package/src/cron.ts +0 -251
  143. package/src/git.ts +0 -132
  144. package/src/host.ts +0 -5947
  145. package/src/index.ts +0 -40
  146. package/src/listen.ts +0 -240
  147. package/src/paging.ts +0 -56
  148. package/src/paths.ts +0 -19
  149. package/src/resources.ts +0 -513
  150. package/src/rpc.ts +0 -222
  151. package/src/scheduled.ts +0 -369
  152. package/src/terminals.ts +0 -353
  153. package/src/tools.ts +0 -64
  154. package/src/types/agent.ts +0 -255
  155. package/src/types/automations.ts +0 -164
  156. package/src/types/catalog.ts +0 -26
  157. package/src/types/changes.ts +0 -266
  158. package/src/types/common.ts +0 -9
  159. package/src/types/host.ts +0 -386
  160. package/src/types/index.ts +0 -27
  161. package/src/types/listen.ts +0 -53
  162. package/src/types/paging.ts +0 -11
  163. package/src/types/probe.ts +0 -42
  164. package/src/types/resources.ts +0 -107
  165. package/src/types/rpc.ts +0 -68
  166. package/src/types/session.ts +0 -415
  167. package/src/types/terminals.ts +0 -133
  168. package/src/types/wire.ts +0 -69
  169. package/src/types/worktrees.ts +0 -91
  170. package/src/worktrees.ts +0 -162
package/dist/main.js ADDED
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { automationsPath, configPath, loadConfig } from './config.js';
4
+ import { running, start, stop as stopDaemon } from './daemon.js';
5
+ import { pty } from './pty.js';
6
+ import { claude } from '@ahpd/agent-claude';
7
+ import { createHost, fileResources, gitBranches, gitChanges, gitWorktrees, hostTools, listen, memoryAutomations, scheduledAutomations, shellTerminals } from '@ahpd/sdk';
8
+ const USAGE = `ahpd - an Agent Host Protocol server, with a Claude backend
9
+
10
+ ahpd [options] run it here, in this terminal
11
+ ahpd start [options] run it in the background and let go of it
12
+ ahpd stop stop the one running in the background
13
+ ahpd status say whether one is, and where
14
+ ahpd config say where the configuration is, and what it says
15
+
16
+ --port <n> Listen here. Default 9187; 0 picks a free one.
17
+ --host <addr> Bind here. Default 127.0.0.1. Pass 0.0.0.0 to
18
+ accept from other machines, which needs a token.
19
+ --path <dir> A directory this host serves. Repeatable; the
20
+ first is the default a client gets when it
21
+ names none, and a directory not named here is
22
+ refused. Default: where the daemon started.
23
+ --connection-token <secret> Require this secret on every connection.
24
+ --connection-token-file <p> Require the secret in this file. A fresh one is
25
+ written if the file is not there.
26
+ --without-connection-token Accept any connection. Only when the port is
27
+ already reachable by nobody else.
28
+ --config-file <p> Read this instead of the file below.
29
+ --automations <where> file, the default, keeps them beside the
30
+ configuration and fires their schedules;
31
+ memory keeps them until this process ends and
32
+ fires nothing.
33
+ --help, -h This
34
+
35
+ Every option above can be a key in the configuration file instead, spelled the
36
+ way it is here without the dashes: port, host, paths, connectionToken,
37
+ connectionTokenFile, withoutConnectionToken, automations. A flag beats the file, because a
38
+ flag is this run and a file is every run until somebody edits it.
39
+
40
+ Clients present the token as ?tkn=<secret> on the URL, or as an
41
+ Authorization: Bearer <secret> header.
42
+
43
+ Point a client at it:
44
+ ahpc --host ws://127.0.0.1:9187
45
+ `;
46
+ function parse(argv) {
47
+ const options = {
48
+ port: 9187,
49
+ host: '127.0.0.1',
50
+ paths: [],
51
+ automations: 'file',
52
+ open: false,
53
+ help: false,
54
+ };
55
+ for (let i = 0; i < argv.length; i++) {
56
+ switch (argv[i]) {
57
+ case '--port':
58
+ options.port = Number(argv[++i]);
59
+ break;
60
+ case '--host':
61
+ options.host = String(argv[++i]);
62
+ break;
63
+ // Repeatable. One host over two projects is one catalogue and one
64
+ // process, which is the case a second `--path` is for; a directory this
65
+ // host was not told about is refused rather than served, because a host
66
+ // that ran the agent wherever it was told is one anybody who can reach
67
+ // the port can point at any directory on the machine.
68
+ case '--path':
69
+ options.paths.push(String(argv[++i]));
70
+ break;
71
+ case '--connection-token':
72
+ options.token = String(argv[++i]);
73
+ break;
74
+ case '--connection-token-file':
75
+ options.tokenFile = String(argv[++i]);
76
+ break;
77
+ case '--without-connection-token':
78
+ options.open = true;
79
+ break;
80
+ case '--config-file':
81
+ options.configFile = String(argv[++i]);
82
+ break;
83
+ case '--automations': {
84
+ const said = String(argv[++i]);
85
+ if (said === 'file' || said === 'memory')
86
+ options.automations = said;
87
+ else
88
+ stop(`--automations takes file or memory, not ${said}.`);
89
+ break;
90
+ }
91
+ case '--help':
92
+ case '-h':
93
+ options.help = true;
94
+ break;
95
+ default:
96
+ if (argv[i]?.startsWith('-')) {
97
+ process.stderr.write(`Unknown option ${argv[i]}. Try --help.\n`);
98
+ process.exit(2);
99
+ }
100
+ }
101
+ }
102
+ /*
103
+ * The file, under the flags.
104
+ *
105
+ * Each source is narrower than the one below it: a flag is this run and a
106
+ * file is every run until somebody edits it, so the flag wins. `paths` is
107
+ * replaced rather than merged - a file that named two directories and a
108
+ * `--path` that named a third would otherwise serve three, which is not
109
+ * what either of them said.
110
+ */
111
+ const file = loadConfig(options.configFile);
112
+ if (!argv.includes('--port') && typeof file.port === 'number')
113
+ options.port = file.port;
114
+ if (!argv.includes('--host') && typeof file.host === 'string')
115
+ options.host = file.host;
116
+ if (options.paths.length === 0 && Array.isArray(file.paths))
117
+ options.paths.push(...file.paths);
118
+ if (options.token === undefined && typeof file.connectionToken === 'string')
119
+ options.token = file.connectionToken;
120
+ if (options.tokenFile === undefined && typeof file.connectionTokenFile === 'string')
121
+ options.tokenFile = file.connectionTokenFile;
122
+ if (!options.open && file.withoutConnectionToken === true)
123
+ options.open = true;
124
+ if (!argv.includes('--automations') && (file.automations === 'file' || file.automations === 'memory')) {
125
+ options.automations = file.automations;
126
+ }
127
+ if (options.paths.length === 0)
128
+ options.paths.push(process.cwd());
129
+ return options;
130
+ }
131
+ const stop = (message) => {
132
+ process.stderr.write(`${message}\n`);
133
+ process.exit(2);
134
+ };
135
+ /**
136
+ * The secret this host will require, and where it came from.
137
+ *
138
+ * A token file that is not there is written rather than refused: the flag is
139
+ * how a supervisor points several processes at one secret, and requiring the
140
+ * person to invent one first makes the convenient spelling the unusable one.
141
+ */
142
+ function secret(options) {
143
+ if (options.open) {
144
+ if (options.token !== undefined || options.tokenFile !== undefined) {
145
+ stop('--without-connection-token contradicts the token you also passed.');
146
+ }
147
+ return { from: 'no token: any connection is accepted' };
148
+ }
149
+ if (options.token !== undefined && options.tokenFile !== undefined) {
150
+ stop('Pass --connection-token or --connection-token-file, not both.');
151
+ }
152
+ if (options.token !== undefined) {
153
+ if (options.token === '')
154
+ stop('--connection-token was empty.');
155
+ return { token: options.token, from: 'token: from --connection-token' };
156
+ }
157
+ if (options.tokenFile !== undefined) {
158
+ if (existsSync(options.tokenFile)) {
159
+ const held = readFileSync(options.tokenFile, 'utf8').trim();
160
+ if (held === '')
161
+ stop(`${options.tokenFile} is empty.`);
162
+ return { token: held, from: `token: read from ${options.tokenFile}` };
163
+ }
164
+ const made = crypto.randomUUID().replaceAll('-', '');
165
+ // Owner-only, because the file is the credential.
166
+ writeFileSync(options.tokenFile, `${made}\n`, { mode: 0o600 });
167
+ return { token: made, from: `token: written to ${options.tokenFile}` };
168
+ }
169
+ // Loopback needs no secret - anything reaching it is already on this
170
+ // machine. Any other address does, and starting without one there would be
171
+ // a host on the network that anybody can drive.
172
+ const loopback = options.host === '127.0.0.1' || options.host === '::1' || options.host === 'localhost';
173
+ if (!loopback) {
174
+ stop(`Binding ${options.host} exposes this host beyond this machine.\n`
175
+ + 'Pass --connection-token, --connection-token-file, or --without-connection-token.');
176
+ }
177
+ return { from: 'no token: loopback only' };
178
+ }
179
+ const argv = process.argv.slice(2);
180
+ /*
181
+ * The subcommands, which are about a daemon rather than being one.
182
+ *
183
+ * Answered before anything is built: `stop` and `status` need no host, and
184
+ * `start` is this same program run again with the rest of the line. Keeping
185
+ * them here means there is one place that knows how to read these options,
186
+ * and `start` cannot drift from what it starts.
187
+ */
188
+ const verb = argv[0] !== undefined && !argv[0].startsWith('-') ? argv[0] : undefined;
189
+ if (verb !== undefined) {
190
+ const rest = argv.slice(1);
191
+ if (verb === 'start') {
192
+ // Parsed here as well as by the child, so a bad option is refused now
193
+ // rather than by something that has already been let go of.
194
+ parse(rest);
195
+ try {
196
+ const begun = await start(rest, process.argv[1]);
197
+ process.stdout.write(`ahpd on ${begun.url} (pid ${String(begun.pid)}), sessions in ${begun.paths.join(', ') || process.cwd()}\n`);
198
+ if (begun.automations !== undefined)
199
+ process.stdout.write(`automations ${begun.automations}\n`);
200
+ process.exit(0);
201
+ }
202
+ catch (error) {
203
+ process.stderr.write(`Could not start it: ${error instanceof Error ? error.message : String(error)}\n`);
204
+ process.exit(1);
205
+ }
206
+ }
207
+ if (verb === 'stop') {
208
+ const stopped = stopDaemon();
209
+ process.stdout.write(stopped ? `Stopped ${stopped.url} (pid ${String(stopped.pid)}).\n` : 'None running.\n');
210
+ process.exit(stopped ? 0 : 1);
211
+ }
212
+ if (verb === 'status') {
213
+ const found = running();
214
+ if (!found) {
215
+ process.stdout.write('None running.\n');
216
+ process.exit(1);
217
+ }
218
+ process.stdout.write(`ahpd on ${found.url} (pid ${String(found.pid)}), started ${found.startedAt}\n`);
219
+ if (found.paths.length > 0)
220
+ process.stdout.write(`sessions in ${found.paths.join(', ')}\n`);
221
+ // Absent from a record written by an older daemon, which is the one case
222
+ // where saying nothing is better than guessing which store it was given.
223
+ if (found.automations !== undefined)
224
+ process.stdout.write(`automations ${found.automations}\n`);
225
+ process.exit(0);
226
+ }
227
+ if (verb === 'config') {
228
+ const at = argv.includes('--config-file') ? argv[argv.indexOf('--config-file') + 1] : configPath();
229
+ process.stdout.write(`${at}\n`);
230
+ const found = loadConfig(argv.includes('--config-file') ? at : undefined);
231
+ const rows = Object.entries(found);
232
+ process.stdout.write(rows.length === 0
233
+ ? ' (nothing set)\n'
234
+ : `${rows.map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`).join('\n')}\n`);
235
+ process.exit(0);
236
+ }
237
+ process.stderr.write(`No command called ${verb}. Try --help.\n`);
238
+ process.exit(2);
239
+ }
240
+ const options = parse(argv);
241
+ if (options.help) {
242
+ process.stdout.write(USAGE);
243
+ process.exit(0);
244
+ }
245
+ const { token, from } = secret(options);
246
+ /*
247
+ * Whether a clock is running, decided once and then said out loud.
248
+ *
249
+ * Both stores take a schedule trigger and only one of them ever fires it, and
250
+ * what tells a client which it got is a `nextRunAt` that is simply absent.
251
+ * That is too quiet for somebody who has just written a schedule, so the
252
+ * startup line says it in words and `ahpd status` repeats it.
253
+ */
254
+ const memory = options.automations === 'memory';
255
+ const host = createHost({
256
+ path: options.paths[0],
257
+ // The daemon serves Claude Code. The host serves whatever it is given -
258
+ // see `examples/` for what a second one looks like.
259
+ agents: [claude({ paths: options.paths })],
260
+ /*
261
+ * What this daemon can do that the protocol cannot.
262
+ *
263
+ * All three touch the machine - a file, a subprocess, a `git` binary - and
264
+ * all three are handed in rather than reached for, so `createHost` stays the
265
+ * protocol and nothing else. A host embedded somewhere with its own notion
266
+ * of a file passes its own; one with no shell passes no terminals and says
267
+ * `-32601` when asked for one.
268
+ */
269
+ resources: fileResources(),
270
+ terminals: shellTerminals(await pty()),
271
+ directories: gitBranches(),
272
+ changes: gitChanges(),
273
+ worktrees: gitWorktrees(),
274
+ /*
275
+ * The host's own tools, offered to every session's model.
276
+ *
277
+ * What a session cannot see from inside itself: the sessions running beside
278
+ * it and the terminals the person has open. Both read-only, and both facts
279
+ * only the host has.
280
+ */
281
+ tools: hostTools(),
282
+ /*
283
+ * Automations, with a clock unless asked otherwise.
284
+ *
285
+ * A daemon is the case the port was written for: it is already running at
286
+ * nine in the morning, which is the only way an automation fires with
287
+ * nobody connected. Definitions live in a file beside the configuration and
288
+ * come back on a restart; the runs do not, because they name sessions that
289
+ * went when the process did.
290
+ *
291
+ * `--automations memory` is the same store without either half: nothing is
292
+ * written and nothing fires. Both are an `AutomationStore`, so the host is
293
+ * not told which it was given - a host embedded in something that already
294
+ * schedules passes a third of its own.
295
+ */
296
+ automations: memory
297
+ ? memoryAutomations()
298
+ : scheduledAutomations({
299
+ // Beside the configuration, which is this daemon's decision to make and
300
+ // not the store's - see `ScheduledOptions.file`.
301
+ file: automationsPath(),
302
+ onProblem: (message) => process.stdout.write(`${message}\n`),
303
+ }),
304
+ /*
305
+ * When, as well as what.
306
+ *
307
+ * The daemon's own lines had no times on them, so a log read after
308
+ * something went wrong said what happened in order and nothing about how
309
+ * far apart - which is most of what is worth knowing when a process died
310
+ * a minute after starting. It is also what makes this log line up against
311
+ * a client's, since the two are separate programs and the only thing they
312
+ * share is a clock.
313
+ *
314
+ * The timestamp is added here rather than in `createHost`, because a host
315
+ * embedded in something else has its own log with its own format and
316
+ * `onEvent` hands it the message to do that with.
317
+ */
318
+ onEvent: (message) => process.stdout.write(`${new Date().toISOString()} ${message}\n`),
319
+ });
320
+ // Whichever runtime this is. `listen` is the only file that knows, and it
321
+ // says which one it found - a daemon that silently ran somewhere unexpected
322
+ // would be a daemon nobody could tell apart from the one they meant to start.
323
+ const listener = await listen({ port: options.port, host: options.host, ...(token !== undefined ? { token } : {}) }, (peer) => host.accept(peer));
324
+ process.stdout.write(`ahpd on ws://${listener.host}:${listener.port} (${listener.runtime}), sessions in ${options.paths.join(', ')}\n`
325
+ // Its own line rather than the end of the one above, which `daemon.ts`
326
+ // reads the session directories off with a regular expression.
327
+ + `automations ${memory ? 'in memory, schedules do not fire' : `in ${automationsPath()}, schedules fire`}\n`
328
+ // Where the secret came from, never the secret: stdout is a log, and a log
329
+ // is the one place a credential should not end up.
330
+ + `${from}\n`);
331
+ const shutdown = () => {
332
+ void Promise.resolve(listener.close()).finally(() => process.exit(0));
333
+ };
334
+ process.on('SIGINT', shutdown);
335
+ process.on('SIGTERM', shutdown);
package/dist/pty.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** The optional pseudoterminal binding, and how to find out whether it works. */
2
+ import type { SpawnPty } from '@ahpd/sdk';
3
+ /**
4
+ * The binding, if this machine has one that runs.
5
+ *
6
+ * With it, shells run under a real terminal and the shell's own OSC 133 marks
7
+ * turn into command boundaries. Without it they run on pipes and the state
8
+ * says `isPty: false`, which is what the protocol has that flag for.
9
+ */
10
+ export declare const pty: () => Promise<{
11
+ pty?: SpawnPty;
12
+ }>;
package/dist/pty.js ADDED
@@ -0,0 +1,56 @@
1
+ /** The optional pseudoterminal binding, and how to find out whether it works. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * By name at runtime, so the type checker is not asked for a module that may
6
+ * not be installed - which is the whole point of it being optional.
7
+ */
8
+ const MODULE = 'node-pty';
9
+ /** This file, as the child is told to run it. */
10
+ const SELF = fileURLToPath(import.meta.url);
11
+ /**
12
+ * Whether this runtime can load it, asked of a process that is allowed to die.
13
+ *
14
+ * `node-pty` is native code, and native code that does not match its runtime
15
+ * does not throw: `dlopen` aborts the process, or the module panics on a
16
+ * function the runtime does not implement, and neither is something a
17
+ * `try/catch` around the import can hold. Bun's install of `node-pty` does
18
+ * exactly this, which takes the whole daemon down over a feature that is
19
+ * marked optional.
20
+ *
21
+ * So the import is tried in a child of the same runtime first, and only
22
+ * repeated here once the child has survived it. A crash is then an exit code.
23
+ */
24
+ const loadable = () => {
25
+ const probe = spawnSync(process.execPath, [SELF, '--probe'], { stdio: 'ignore' });
26
+ return probe.status === 0;
27
+ };
28
+ /**
29
+ * The binding, if this machine has one that runs.
30
+ *
31
+ * With it, shells run under a real terminal and the shell's own OSC 133 marks
32
+ * turn into command boundaries. Without it they run on pipes and the state
33
+ * says `isPty: false`, which is what the protocol has that flag for.
34
+ */
35
+ export const pty = async () => {
36
+ if (!loadable())
37
+ return {};
38
+ try {
39
+ const found = await import(/* @vite-ignore */ MODULE);
40
+ return typeof found.spawn === 'function' ? { pty: found.spawn } : {};
41
+ }
42
+ catch {
43
+ return {};
44
+ }
45
+ };
46
+ // The child `loadable` spawns. Nothing else in this file has run yet, and
47
+ // nothing else is imported, so what it exits with is about `node-pty` alone.
48
+ if (process.argv[1] === SELF && process.argv[2] === '--probe') {
49
+ try {
50
+ const found = await import(/* @vite-ignore */ MODULE);
51
+ process.exit(typeof found.spawn === 'function' ? 0 : 1);
52
+ }
53
+ catch {
54
+ process.exit(1);
55
+ }
56
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@ahpd/server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "A server library for the Agent Host Protocol. No agent inside, you pass one in",
5
+ "description": "An Agent Host Protocol server on Node, Bun or Deno. Ships with a Claude backend",
6
6
  "keywords": [
7
7
  "ahp",
8
8
  "agent-host-protocol",
@@ -12,15 +12,14 @@
12
12
  "protocol",
13
13
  "json-rpc",
14
14
  "websocket",
15
- "server",
16
- "host",
17
- "daemon"
15
+ "cli",
16
+ "command-line",
17
+ "daemon",
18
+ "claude",
19
+ "claude-code"
18
20
  ],
19
21
  "author": "Luiz Fernando Softov <lfs@softov.dev>",
20
22
  "license": "MIT",
21
- "publishConfig": {
22
- "access": "public"
23
- },
24
23
  "homepage": "https://github.com/softov/ahpd#readme",
25
24
  "repository": {
26
25
  "type": "git",
@@ -30,32 +29,23 @@
30
29
  "bugs": {
31
30
  "url": "https://github.com/softov/ahpd/issues"
32
31
  },
33
- "main": "./dist/index.js",
34
- "types": "./dist/index.d.ts",
35
- "exports": {
36
- ".": {
37
- "types": "./dist/index.d.ts",
38
- "development": "./src/index.ts",
39
- "default": "./dist/index.js"
40
- }
32
+ "bin": {
33
+ "ahpd": "./dist/main.js"
41
34
  },
42
35
  "files": [
43
36
  "dist",
44
- "src",
45
37
  "README.md"
46
38
  ],
47
39
  "engines": {
48
40
  "node": ">=22"
49
41
  },
50
- "optionalDependencies": {
51
- "ws": "^8.18.0",
52
- "node-pty": "^1.1.0-beta40"
42
+ "dependencies": {
43
+ "@microsoft/agent-host-protocol": "^0.9.0",
44
+ "@ahpd/agent-claude": "^0.3.0",
45
+ "@ahpd/sdk": "^0.3.0"
53
46
  },
54
- "peerDependencies": {
55
- "@microsoft/agent-host-protocol": "^0.9.0"
56
- },
57
- "devDependencies": {
58
- "@microsoft/agent-host-protocol": "^0.9.0"
47
+ "publishConfig": {
48
+ "access": "public"
59
49
  },
60
50
  "scripts": {}
61
51
  }
@@ -1,4 +0,0 @@
1
- /** Automations held in memory, run when somebody asks. */
2
- import type { AutomationStore } from './types/automations.js';
3
- export declare function memoryAutomations(): AutomationStore;
4
- //# sourceMappingURL=automations.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"automations.d.ts","sourceRoot":"","sources":["../src/automations.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAG1D,OAAO,KAAK,EAA6B,eAAe,EAAgB,MAAM,wBAAwB,CAAC;AAsBvG,wBAAgB,iBAAiB,IAAI,eAAe,CAkLnD"}
@@ -1,191 +0,0 @@
1
- /** Automations held in memory, run when somebody asks. */
2
- import { randomUUID } from 'node:crypto';
3
- /**
4
- * The automations of a host that does not schedule.
5
- *
6
- * Deliberately half a store, and the half worth having first: definitions can
7
- * be written, patched, listed and *run*, and nothing here holds a clock. An
8
- * automation with a schedule trigger is accepted, kept and reported with no
9
- * `nextRunAt` - which is the honest form of "this host will not fire that",
10
- * and is what stops a client drawing a next-run time that will never arrive.
11
- *
12
- * Everything is in memory, so it goes when the process does. A store that
13
- * outlives a restart is a different implementation of the same interface, and
14
- * the reason the interface exists.
15
- */
16
- const now = () => new Date().toISOString();
17
- /** How many runs a summary list carries before it needs a cursor. */
18
- const PAGE = 20;
19
- export function memoryAutomations() {
20
- const held = new Map();
21
- /** Runs by automation, newest first. */
22
- const history = new Map();
23
- /** Every run by its own URI, for the channel a client watches it on. */
24
- const byRun = new Map();
25
- const listeners = [];
26
- const said = (event) => {
27
- for (const listener of listeners)
28
- listener(event);
29
- };
30
- /** The summary form of a run, which is what an automation carries. */
31
- const summary = (run) => ({
32
- resource: run.resource,
33
- automation: run.automation,
34
- origin: run.origin,
35
- lifecycle: run.lifecycle,
36
- sessionCount: run.sessions.length,
37
- ...(run.primarySession !== undefined ? { primarySession: run.primarySession } : {}),
38
- });
39
- /** Rebuild the entry a client reads, so `runs` and `operations` are never stale. */
40
- const entry = (automation) => {
41
- const enabled = automation.definition.enabled !== false;
42
- return {
43
- ...automation,
44
- runs: (history.get(automation.resource) ?? []).slice(0, PAGE).map(summary),
45
- ...((history.get(automation.resource) ?? []).length > PAGE
46
- ? { runsNextCursor: String(PAGE) }
47
- : {}),
48
- // `run` only where it would do something. A disabled automation is one
49
- // somebody switched off, and offering the button anyway is a control
50
- // that argues with the switch beside it.
51
- operations: enabled ? ['update', 'remove', 'run'] : ['update', 'remove'],
52
- };
53
- };
54
- return {
55
- list: () => [...held.values()].map(entry),
56
- get: (resource) => {
57
- const found = held.get(resource);
58
- return found && entry(found);
59
- },
60
- /*
61
- * What this store understands, which is nothing.
62
- *
63
- * This command answers with *event* triggers. Schedule triggers are
64
- * protocol-defined and never listed here, and manual is not a trigger at
65
- * all - the protocol says an empty trigger list is what manual-only means.
66
- * So a store with no event triggers answers with none, and a client reads
67
- * "this host will not fire that" from the absent `nextRunAt` instead.
68
- */
69
- triggers: () => [],
70
- create: (resource, definition) => {
71
- const at = now();
72
- const made = {
73
- resource,
74
- definition,
75
- runs: [],
76
- operations: [],
77
- createdAt: at,
78
- modifiedAt: at,
79
- };
80
- held.set(resource, made);
81
- said({ automation: resource });
82
- return entry(made);
83
- },
84
- update: (resource, changes) => {
85
- const found = held.get(resource);
86
- if (!found)
87
- return undefined;
88
- // A patch: absent keys are left alone, which is the whole difference
89
- // between this and a write. A client sending the whole definition back
90
- // would otherwise revert whatever another client changed meanwhile.
91
- const after = {
92
- ...found,
93
- definition: { ...found.definition, ...changes },
94
- modifiedAt: now(),
95
- };
96
- held.set(resource, after);
97
- said({ automation: resource });
98
- return entry(after);
99
- },
100
- remove: (resource) => {
101
- if (!held.delete(resource))
102
- return false;
103
- for (const run of history.get(resource) ?? [])
104
- byRun.delete(run.resource);
105
- history.delete(resource);
106
- said({ removed: resource });
107
- return true;
108
- },
109
- run: async (resource, origin, start) => {
110
- const found = held.get(resource);
111
- if (!found)
112
- return undefined;
113
- if (found.definition.enabled === false)
114
- return undefined;
115
- const template = (typeof found.definition.session === 'object' && found.definition.session !== null
116
- ? found.definition.session
117
- : {});
118
- const message = (typeof found.definition.message === 'object' && found.definition.message !== null
119
- ? found.definition.message
120
- : {});
121
- const run = {
122
- resource: `ahp-automation-run:/${randomUUID()}`,
123
- automation: resource,
124
- origin,
125
- lifecycle: { status: 'pending', createdAt: now() },
126
- sessions: [],
127
- };
128
- byRun.set(run.resource, run);
129
- const past = history.get(resource) ?? [];
130
- // Newest first, which is the order a client shows them in.
131
- history.set(resource, [run, ...past]);
132
- said({ automation: resource, run: run.resource });
133
- const directories = Array.isArray(template.workingDirectories) ? template.workingDirectories : [];
134
- const where = typeof directories[0] === 'string'
135
- ? directories[0].replace(/^file:\/\//, '')
136
- : undefined;
137
- const options = {
138
- ...(typeof template.provider === 'string' ? { provider: template.provider } : {}),
139
- ...(where !== undefined ? { workingDirectory: where } : {}),
140
- ...(typeof template.config === 'object' && template.config !== null
141
- ? { config: template.config }
142
- : {}),
143
- text: typeof message.text === 'string' ? message.text : String(found.definition.title ?? ''),
144
- origin: { kind: 'automation', automation: resource, run: run.resource },
145
- };
146
- try {
147
- const session = await start(options);
148
- run.sessions = [session];
149
- run.primarySession = session;
150
- run.lifecycle = { ...run.lifecycle, status: 'running', startedAt: now() };
151
- }
152
- catch (error) {
153
- // Failed, and why. A run that vanished would be indistinguishable from
154
- // one that never started.
155
- run.lifecycle = {
156
- ...run.lifecycle,
157
- status: 'failed',
158
- endedAt: now(),
159
- error: { message: error instanceof Error ? error.message : String(error) },
160
- };
161
- }
162
- said({ automation: resource, run: run.resource });
163
- return run;
164
- },
165
- runOf: (resource) => byRun.get(resource),
166
- unlink: (resource, session) => {
167
- const run = byRun.get(resource);
168
- if (run === undefined || !run.sessions.includes(session))
169
- return false;
170
- run.sessions = run.sessions.filter((one) => one !== session);
171
- // The protocol says removing the primary clears it. A run pointing at a
172
- // session that is gone is a run a client opens onto nothing.
173
- if (run.primarySession === session)
174
- delete run.primarySession;
175
- said({ automation: run.automation, run: run.resource });
176
- return true;
177
- },
178
- runs: (resource, cursor) => {
179
- const all = history.get(resource) ?? [];
180
- const from = cursor === undefined ? 0 : Number(cursor);
181
- const at = Number.isFinite(from) && from >= 0 ? from : 0;
182
- const page = all.slice(at, at + PAGE);
183
- return {
184
- items: page.map(summary),
185
- ...(at + PAGE < all.length ? { nextCursor: String(at + PAGE) } : {}),
186
- };
187
- },
188
- onChanged: (observer) => { listeners.push(observer); },
189
- };
190
- }
191
- //# sourceMappingURL=automations.js.map