@ahpd/server 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ahpd/server
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/%40ahpd%2Fserver)](https://www.npmjs.com/package/@ahpd/server)
4
+ [![CI](https://github.com/softov/ahpd/actions/workflows/ci.yml/badge.svg)](https://github.com/softov/ahpd/actions/workflows/ci.yml)
5
+ ![license MIT](https://img.shields.io/badge/license-MIT-blue)
6
+ ![node >=22](https://img.shields.io/badge/node-%3E%3D22-5fa04e)
7
+ ![Agent Host Protocol 0.9.0](https://img.shields.io/badge/AHP-0.9.0-0b7285)
8
+
3
9
  [`@ahpd/server`](https://www.npmjs.com/package/@ahpd/server) is a ready-to-run [Agent Host Protocol](https://microsoft.github.io/agent-host-protocol/) server. It installs the `ahpd` command.
4
10
 
5
11
  It runs agent sessions and serves them over a WebSocket, so several clients can watch and drive the same session at once.
@@ -52,9 +58,12 @@ ahpd config print the config file path and its contents
52
58
  | `--connection-token-file <p>` | Require the secret in this file. Writes a new one if the file is missing |
53
59
  | `--without-connection-token` | Accept any connection |
54
60
  | `--config-file <p>` | Use this config file instead of the default |
61
+ | `--automations <where>` | `file`, the default, keeps them beside the config and fires their schedules. `memory` keeps them until the process ends and fires nothing |
62
+ | `--sessions <where>` | Where the read and archived bits and a session's settings go. `file`, the default, keeps them beside the config. `memory` forgets them when the process ends |
63
+ | `--version`, `-v` | What version this is |
55
64
  | `--help`, `-h` | |
56
65
 
57
- Every flag also has a key in `config.json` under `$XDG_CONFIG_HOME/ahpd`, spelled the same way without the dashes. A flag beats the file. Run `ahpd config` to see the path and the current values.
66
+ `--version` and `--help` are the two that are not configuration; every other flag also has a key in `config.json` under `$XDG_CONFIG_HOME/ahpd`, spelled the same way without the dashes. A flag beats the file. Run `ahpd config` to see the path and the current values.
58
67
 
59
68
  ## Directories
60
69
 
package/dist/config.d.ts CHANGED
@@ -13,6 +13,18 @@ export interface Config {
13
13
  connectionTokenFile?: string;
14
14
  /** Accept any connection, with no secret at all. */
15
15
  withoutConnectionToken?: boolean;
16
+ /**
17
+ * Where automations are kept: `file` beside this configuration, or `memory`.
18
+ *
19
+ * `file` is the default and is the one with a clock in it. `memory` holds
20
+ * definitions for the life of the process and fires nothing.
21
+ */
22
+ automations?: 'file' | 'memory';
23
+ /**
24
+ * Where the read and archived bits and a session's settings are kept: `file`
25
+ * beside this configuration, or `memory` until the process ends.
26
+ */
27
+ sessions?: 'file' | 'memory';
16
28
  }
17
29
  /**
18
30
  * Where this tool's files live.
@@ -52,6 +64,15 @@ export declare const daemonLog: () => string;
52
64
  * and the ordering somebody put there.
53
65
  */
54
66
  export declare const automationsPath: () => string;
67
+ /**
68
+ * Where what this host adds on top of a backend is kept.
69
+ *
70
+ * The `IsRead` and `IsArchived` bits every client shares, and the settings a
71
+ * session is running under. Beside the automations for the same reason: this
72
+ * one is written whenever somebody archives a row, and `config.json` is a file
73
+ * a person edits.
74
+ */
75
+ export declare const sessionsPath: () => string;
55
76
  /** Make sure the directory is there, so a write into it can succeed. */
56
77
  export declare const ensureConfigDir: () => void;
57
78
  /**
package/dist/config.js CHANGED
@@ -40,6 +40,15 @@ export const daemonLog = () => join(configDir(), 'daemon.log');
40
40
  * and the ordering somebody put there.
41
41
  */
42
42
  export const automationsPath = () => join(configDir(), 'automations.json');
43
+ /**
44
+ * Where what this host adds on top of a backend is kept.
45
+ *
46
+ * The `IsRead` and `IsArchived` bits every client shares, and the settings a
47
+ * session is running under. Beside the automations for the same reason: this
48
+ * one is written whenever somebody archives a row, and `config.json` is a file
49
+ * a person edits.
50
+ */
51
+ export const sessionsPath = () => join(configDir(), 'sessions.json');
43
52
  /** Make sure the directory is there, so a write into it can succeed. */
44
53
  export const ensureConfigDir = () => { mkdirSync(configDir(), { recursive: true }); };
45
54
  /**
package/dist/daemon.d.ts CHANGED
@@ -5,6 +5,11 @@ export interface Running {
5
5
  url: string;
6
6
  paths: string[];
7
7
  startedAt: string;
8
+ /**
9
+ * Where automations are kept and whether their schedules fire, in the
10
+ * daemon's own words. Absent from a record an older daemon wrote.
11
+ */
12
+ automations?: string;
8
13
  }
9
14
  /**
10
15
  * The daemon this user has running, if the record names one that still is.
package/dist/daemon.js CHANGED
@@ -130,6 +130,7 @@ export async function start(argv, self) {
130
130
  // rather than for the case, and `0` must never reach the record.
131
131
  if (child.pid === undefined)
132
132
  throw new Error('it started but has no process id');
133
+ const automations = /^automations (.+)$/m.exec(announced)?.[1]?.trim();
133
134
  const record = {
134
135
  pid: child.pid,
135
136
  url,
@@ -139,6 +140,7 @@ export async function start(argv, self) {
139
140
  paths: (/sessions in (.+)/.exec(announced)?.[1] ?? '')
140
141
  .trim().split(',').map((one) => one.trim()).filter((one) => one !== ''),
141
142
  startedAt: new Date().toISOString(),
143
+ ...(automations !== undefined ? { automations } : {}),
142
144
  };
143
145
  writeFileSync(daemonPath(), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
144
146
  return record;
package/dist/main.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { automationsPath, configPath, loadConfig } from './config.js';
3
+ import { automationsPath, configPath, loadConfig, sessionsPath } from './config.js';
4
+ import { version } from './version.js';
4
5
  import { running, start, stop as stopDaemon } from './daemon.js';
5
6
  import { pty } from './pty.js';
6
7
  import { claude } from '@ahpd/agent-claude';
7
- import { createHost, fileResources, gitBranches, gitChanges, gitWorktrees, hostTools, listen, scheduledAutomations, shellTerminals } from '@ahpd/sdk';
8
+ import { createHost, fileResources, gitBranches, gitChanges, gitWorktrees, hostTools, listen, fileSessions, memoryAutomations, memorySessions, scheduledAutomations, shellTerminals } from '@ahpd/sdk';
8
9
  const USAGE = `ahpd - an Agent Host Protocol server, with a Claude backend
9
10
 
10
11
  ahpd [options] run it here, in this terminal
@@ -26,11 +27,20 @@ const USAGE = `ahpd - an Agent Host Protocol server, with a Claude backend
26
27
  --without-connection-token Accept any connection. Only when the port is
27
28
  already reachable by nobody else.
28
29
  --config-file <p> Read this instead of the file below.
30
+ --automations <where> file, the default, keeps them beside the
31
+ configuration and fires their schedules;
32
+ memory keeps them until this process ends and
33
+ fires nothing.
34
+ --sessions <where> Where the read and archived bits and a
35
+ session's settings go. file, the default,
36
+ keeps them beside the configuration; memory
37
+ forgets them when this process ends.
38
+ --version, -v What version this is
29
39
  --help, -h This
30
40
 
31
41
  Every option above can be a key in the configuration file instead, spelled the
32
42
  way it is here without the dashes: port, host, paths, connectionToken,
33
- connectionTokenFile, withoutConnectionToken. A flag beats the file, because a
43
+ connectionTokenFile, withoutConnectionToken, automations, sessions. A flag beats the file, because a
34
44
  flag is this run and a file is every run until somebody edits it.
35
45
 
36
46
  Clients present the token as ?tkn=<secret> on the URL, or as an
@@ -44,8 +54,11 @@ function parse(argv) {
44
54
  port: 9187,
45
55
  host: '127.0.0.1',
46
56
  paths: [],
57
+ automations: 'file',
58
+ sessions: 'file',
47
59
  open: false,
48
60
  help: false,
61
+ version: false,
49
62
  };
50
63
  for (let i = 0; i < argv.length; i++) {
51
64
  switch (argv[i]) {
@@ -75,10 +88,30 @@ function parse(argv) {
75
88
  case '--config-file':
76
89
  options.configFile = String(argv[++i]);
77
90
  break;
91
+ case '--automations': {
92
+ const said = String(argv[++i]);
93
+ if (said === 'file' || said === 'memory')
94
+ options.automations = said;
95
+ else
96
+ stop(`--automations takes file or memory, not ${said}.`);
97
+ break;
98
+ }
99
+ case '--sessions': {
100
+ const said = String(argv[++i]);
101
+ if (said === 'file' || said === 'memory')
102
+ options.sessions = said;
103
+ else
104
+ stop(`--sessions takes file or memory, not ${said}.`);
105
+ break;
106
+ }
78
107
  case '--help':
79
108
  case '-h':
80
109
  options.help = true;
81
110
  break;
111
+ case '--version':
112
+ case '-v':
113
+ options.version = true;
114
+ break;
82
115
  default:
83
116
  if (argv[i]?.startsWith('-')) {
84
117
  process.stderr.write(`Unknown option ${argv[i]}. Try --help.\n`);
@@ -108,6 +141,12 @@ function parse(argv) {
108
141
  options.tokenFile = file.connectionTokenFile;
109
142
  if (!options.open && file.withoutConnectionToken === true)
110
143
  options.open = true;
144
+ if (!argv.includes('--automations') && (file.automations === 'file' || file.automations === 'memory')) {
145
+ options.automations = file.automations;
146
+ }
147
+ if (!argv.includes('--sessions') && (file.sessions === 'file' || file.sessions === 'memory')) {
148
+ options.sessions = file.sessions;
149
+ }
111
150
  if (options.paths.length === 0)
112
151
  options.paths.push(process.cwd());
113
152
  return options;
@@ -179,6 +218,8 @@ if (verb !== undefined) {
179
218
  try {
180
219
  const begun = await start(rest, process.argv[1]);
181
220
  process.stdout.write(`ahpd on ${begun.url} (pid ${String(begun.pid)}), sessions in ${begun.paths.join(', ') || process.cwd()}\n`);
221
+ if (begun.automations !== undefined)
222
+ process.stdout.write(`automations ${begun.automations}\n`);
182
223
  process.exit(0);
183
224
  }
184
225
  catch (error) {
@@ -200,6 +241,10 @@ if (verb !== undefined) {
200
241
  process.stdout.write(`ahpd on ${found.url} (pid ${String(found.pid)}), started ${found.startedAt}\n`);
201
242
  if (found.paths.length > 0)
202
243
  process.stdout.write(`sessions in ${found.paths.join(', ')}\n`);
244
+ // Absent from a record written by an older daemon, which is the one case
245
+ // where saying nothing is better than guessing which store it was given.
246
+ if (found.automations !== undefined)
247
+ process.stdout.write(`automations ${found.automations}\n`);
203
248
  process.exit(0);
204
249
  }
205
250
  if (verb === 'config') {
@@ -216,11 +261,24 @@ if (verb !== undefined) {
216
261
  process.exit(2);
217
262
  }
218
263
  const options = parse(argv);
264
+ if (options.version) {
265
+ process.stdout.write(`${version()}\n`);
266
+ process.exit(0);
267
+ }
219
268
  if (options.help) {
220
269
  process.stdout.write(USAGE);
221
270
  process.exit(0);
222
271
  }
223
272
  const { token, from } = secret(options);
273
+ /*
274
+ * Whether a clock is running, decided once and then said out loud.
275
+ *
276
+ * Both stores take a schedule trigger and only one of them ever fires it, and
277
+ * what tells a client which it got is a `nextRunAt` that is simply absent.
278
+ * That is too quiet for somebody who has just written a schedule, so the
279
+ * startup line says it in words and `ahpd status` repeats it.
280
+ */
281
+ const memory = options.automations === 'memory';
224
282
  const host = createHost({
225
283
  path: options.paths[0],
226
284
  // The daemon serves Claude Code. The host serves whatever it is given -
@@ -249,7 +307,7 @@ const host = createHost({
249
307
  */
250
308
  tools: hostTools(),
251
309
  /*
252
- * Automations, with a clock.
310
+ * Automations, with a clock unless asked otherwise.
253
311
  *
254
312
  * A daemon is the case the port was written for: it is already running at
255
313
  * nine in the morning, which is the only way an automation fires with
@@ -257,15 +315,33 @@ const host = createHost({
257
315
  * come back on a restart; the runs do not, because they name sessions that
258
316
  * went when the process did.
259
317
  *
260
- * A host embedded in something that already schedules passes its own store
261
- * instead, and one that should fire nothing passes `memoryAutomations()`.
318
+ * `--automations memory` is the same store without either half: nothing is
319
+ * written and nothing fires. Both are an `AutomationStore`, so the host is
320
+ * not told which it was given - a host embedded in something that already
321
+ * schedules passes a third of its own.
322
+ */
323
+ /*
324
+ * What this host adds on top of a backend, kept between restarts.
325
+ *
326
+ * The bits every client shares and the settings a session runs under. A
327
+ * daemon is exactly the case the port was written for: it is restarted for
328
+ * an upgrade, and without this every archived session comes back into the
329
+ * catalogue and every read one is unread, for everybody, with nothing said.
262
330
  */
263
- automations: scheduledAutomations({
264
- // Beside the configuration, which is this daemon's decision to make and
265
- // not the store's - see `ScheduledOptions.file`.
266
- file: automationsPath(),
267
- onProblem: (message) => process.stdout.write(`${message}\n`),
268
- }),
331
+ sessions: options.sessions === 'memory'
332
+ ? memorySessions()
333
+ : fileSessions({
334
+ file: sessionsPath(),
335
+ onProblem: (message) => process.stdout.write(`${message}\n`),
336
+ }),
337
+ automations: memory
338
+ ? memoryAutomations()
339
+ : scheduledAutomations({
340
+ // Beside the configuration, which is this daemon's decision to make and
341
+ // not the store's - see `ScheduledOptions.file`.
342
+ file: automationsPath(),
343
+ onProblem: (message) => process.stdout.write(`${message}\n`),
344
+ }),
269
345
  /*
270
346
  * When, as well as what.
271
347
  *
@@ -287,6 +363,9 @@ const host = createHost({
287
363
  // would be a daemon nobody could tell apart from the one they meant to start.
288
364
  const listener = await listen({ port: options.port, host: options.host, ...(token !== undefined ? { token } : {}) }, (peer) => host.accept(peer));
289
365
  process.stdout.write(`ahpd on ws://${listener.host}:${listener.port} (${listener.runtime}), sessions in ${options.paths.join(', ')}\n`
366
+ // Its own line rather than the end of the one above, which `daemon.ts`
367
+ // reads the session directories off with a regular expression.
368
+ + `automations ${memory ? 'in memory, schedules do not fire' : `in ${automationsPath()}, schedules fire`}\n`
290
369
  // Where the secret came from, never the secret: stdout is a log, and a log
291
370
  // is the one place a credential should not end up.
292
371
  + `${from}\n`);
@@ -0,0 +1,2 @@
1
+ /** The version in the nearest `package.json`, or `unknown` where there is none. */
2
+ export declare const version: () => string;
@@ -0,0 +1,34 @@
1
+ /*
2
+ * What version this is, read from the manifest rather than written twice.
3
+ *
4
+ * A literal in the source is a literal that drifts, and a `--version` that
5
+ * lies is worse than no `--version` at all.
6
+ *
7
+ * Found by walking up from this module rather than by a fixed relative path,
8
+ * because the depth differs: `src/version.ts` in this checkout and
9
+ * `dist/version.js` in an install, and neither should have to know which it
10
+ * is. `package.json` is always at the package root and npm always ships it,
11
+ * so the first one above this file is the right one - which in a workspace is
12
+ * this package's rather than the repository's.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ /** The version in the nearest `package.json`, or `unknown` where there is none. */
18
+ export const version = () => {
19
+ let at = dirname(fileURLToPath(import.meta.url));
20
+ for (;;) {
21
+ try {
22
+ const found = JSON.parse(readFileSync(join(at, 'package.json'), 'utf8'));
23
+ if (typeof found.version === 'string')
24
+ return found.version;
25
+ }
26
+ catch { /* not this directory */ }
27
+ const up = dirname(at);
28
+ // The root of the filesystem, which means there is no manifest anywhere
29
+ // above this file - a bundler inlined it, or something unpacked it wrong.
30
+ if (up === at)
31
+ return 'unknown';
32
+ at = up;
33
+ }
34
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahpd/server",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "An Agent Host Protocol server on Node, Bun or Deno. Ships with a Claude backend",
6
6
  "keywords": [
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@microsoft/agent-host-protocol": "^0.9.0",
44
- "@ahpd/agent-claude": "^0.2.0",
45
- "@ahpd/sdk": "^0.2.0"
44
+ "@ahpd/agent-claude": "^0.4.0",
45
+ "@ahpd/sdk": "^0.4.0"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"