@ahpd/server 0.1.0 → 0.2.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 +87 -81
  2. package/dist/config.d.ts +65 -0
  3. package/dist/config.js +75 -0
  4. package/dist/daemon.d.ts +26 -0
  5. package/dist/daemon.js +160 -0
  6. package/dist/main.d.ts +2 -0
  7. package/dist/main.js +297 -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/README.md CHANGED
@@ -1,127 +1,133 @@
1
1
  # @ahpd/server
2
2
 
3
- A server library for the [Agent Host Protocol](https://github.com/microsoft/agent-host-protocol).
3
+ [`@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
4
 
5
- It has no agent in it. You pass one in when you create the host.
5
+ It runs agent sessions and serves them over a WebSocket, so several clients can watch and drive the same session at once.
6
+
7
+ It is built on [@ahpd/sdk](https://www.npmjs.com/package/@ahpd/sdk) and currently ships with a Claude agent backend, which is the only one so far.
8
+
9
+ - [`@ahpd/agent-claude`](https://www.npmjs.com/package/@ahpd/agent-claude) is the Claude backend.
10
+
11
+ Backends are registered at startup, so adding another does not change the server.
12
+
13
+ To serve a different agent, you can write your own server with [@ahpd/sdk](https://www.npmjs.com/package/@ahpd/sdk). Create an implementation of the `Agent` interface and pass it to `createHost`.
6
14
 
7
15
  ## Install
8
16
 
9
17
  ```bash
10
- pnpm add @ahpd/server @microsoft/agent-host-protocol
18
+ npm i -g @ahpd/server
19
+ ahpd --path /work/project
11
20
  ```
12
21
 
13
- The protocol package is a peer dependency. This package uses runtime values from it, so there should only be one copy in the dependency tree.
22
+ Or run it without installing:
14
23
 
15
- ## Use
24
+ ```bash
25
+ npx @ahpd/server --path /work/project
26
+ ```
16
27
 
17
- A small host using the Claude backend looks like this:
28
+ It listens on `ws://127.0.0.1:9187`. Run it with no arguments to serve the directory you are in.
18
29
 
19
- ```ts
20
- import { createHost, listen } from '@ahpd/server';
21
- import { claude } from '@ahpd/agent-claude';
30
+ Needs Node 22 or later. Also runs on Bun and Deno.
22
31
 
23
- const host = createHost({
24
- path: process.cwd(),
25
- agents: [claude({ paths: [process.cwd()] })],
26
- });
32
+ ## Commands
27
33
 
28
- const listener = await listen({ port: 9187 }, (peer) => host.accept(peer));
29
- console.log(`on ws://${listener.host}:${listener.port} (${listener.runtime})`);
34
+ ```
35
+ ahpd [options] run it in this terminal
36
+ ahpd start [options] run it in the background
37
+ ahpd stop stop the background one
38
+ ahpd status say whether one is running, and where
39
+ ahpd config print the config file path and its contents
30
40
  ```
31
41
 
32
- `createHost()` creates the AHP host.
42
+ `start` re-runs the same program detached. It writes its output to `daemon.log` and records the pid and URL in `daemon.json`, both next to the config, which is where `status` reads from and how you find out what happened after the fact.
33
43
 
34
- `listen()` is the WebSocket listener included for Node, Bun and Deno. The host itself is not tied to WebSockets.
44
+ ## Options
35
45
 
36
- Once a client is connected, the host takes care of version negotiation, snapshots, subscriptions, sequence numbers, transcript paging, completions, queued messages, shared drafts, read and archived flags, multiple chats per session, and turns.
46
+ | flag | |
47
+ | --- | --- |
48
+ | `--port <n>` | Default `9187`. Use `0` for a free port |
49
+ | `--host <addr>` | Default `127.0.0.1`. Use `0.0.0.0` to accept remote connections, which requires a token |
50
+ | `--path <dir>` | A directory to serve. Repeatable. Defaults to the working directory |
51
+ | `--connection-token <secret>` | Require this secret on every connection |
52
+ | `--connection-token-file <p>` | Require the secret in this file. Writes a new one if the file is missing |
53
+ | `--without-connection-token` | Accept any connection |
54
+ | `--config-file <p>` | Use this config file instead of the default |
55
+ | `--help`, `-h` | |
37
56
 
38
- `accept(peer)` takes anything that can `send`, `notify` and `close`, and returns a handler. `listen` is a WebSocket implementation for Node, Bun and Deno. Tests supply their own, which is why the test suite runs without a network.
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.
39
58
 
40
- ## Options
59
+ ## Directories
41
60
 
42
- `path` and `agents` are required. The rest are optional. If you leave one out, the host returns an error for the commands it cannot serve instead of an empty result.
61
+ `--path` is repeatable:
43
62
 
63
+ ```bash
64
+ ahpd --path ~/src/project-a --path ~/src/project-b
65
+ ```
44
66
 
45
- ```ts
46
- import {
47
- createHost,
48
- fileResources,
49
- shellTerminals,
50
- gitChanges,
51
- gitBranches,
52
- gitWorktrees,
53
- hostTools,
54
- scheduledAutomations,
55
- } from '@ahpd/server';
56
-
57
- const host = createHost({
58
- path,
59
- agents,
60
- resources: fileResources(),
61
- terminals: shellTerminals(),
62
- changes: gitChanges(),
63
- directories: gitBranches(),
64
- worktrees: gitWorktrees(),
65
- tools: hostTools(),
66
- automations: scheduledAutomations({ file: './automations.json', }),
67
- });
67
+ The first is the default, and it is what a client gets when it names no directory. A directory that was not named is refused rather than served, so one daemon serves exactly the paths you gave it.
68
+
69
+ ## Remote connections
68
70
 
71
+ It binds to loopback and needs no token there. Binding anywhere else does:
72
+
73
+ ```bash
74
+ ahpd --host 0.0.0.0 --connection-token <secret>
69
75
  ```
70
76
 
71
- | option | |
72
- | --- | --- |
73
- | `path` | the directory whose sessions this host serves |
74
- | `agents` | the backends to serve, as `Agent` implementations |
75
- | `resources` | file reads, writes, and `@` completion. Use `fileResources()` |
76
- | `terminals` | a shell as a terminal channel. Use `shellTerminals()` |
77
- | `changes` | uncommitted changes as a changeset. Use `gitChanges()` |
78
- | `directories` | the current branch of each served directory. Use `gitBranches()` |
79
- | `automations` | triggered agents. Use `memoryAutomations()`, or `scheduledAutomations({ file })` for cron |
80
- | `worktrees` | sessions in their own git worktree. Use `gitWorktrees()` |
81
- | `tools` | tools the host adds to every session. Use `hostTools()` |
82
- | `onEvent` | called with one line per notable event, for logging |
77
+ Or keep the secret in a file, which is written with a fresh one if it is not there yet:
78
+
79
+ ```bash
80
+ ahpd --host 0.0.0.0 --connection-token-file ~/.config/ahpd/token
81
+ ```
83
82
 
84
- None of these are imported by the host itself. `fileResources` reads files, `shellTerminals` spawns shells, and `gitBranches` runs `git`, and you pass them in.
83
+ Clients present it as `?tkn=<secret>` on the URL or as an `Authorization: Bearer <secret>` header.
85
84
 
86
- ## Writing a backend
85
+ `--without-connection-token` binds without one. Only do that when something else is already keeping the port to yourself.
87
86
 
88
- `Agent` has five required members: `provider`, `displayName`, `schema`, `defaults` and `create`.
87
+ ## What it serves
89
88
 
90
- ```ts
91
- import type { Agent, Session, Start } from '@ahpd/server';
92
-
93
- export function parrot(): Agent {
94
- return {
95
- provider: 'parrot',
96
- displayName: 'Parrot',
97
- schema: () => ({ properties: {} }),
98
- defaults: () => ({}),
99
- create: (start: Start): Session => converse(start),
100
- };
101
- }
89
+ Sessions, chats and turns with streaming responses, tool calls and approvals, questions from the agent, file reads and writes, a shell as a terminal channel, git branches and changesets, sessions in their own worktree, scheduled automations, and OTLP telemetry.
90
+
91
+ The Claude backend adds what Claude has: models and effort, permission modes, skills and slash commands, MCP servers, and OAuth sign-in.
92
+
93
+ With the Claude backend, past sessions are read from Claude's transcript files. Opening one does not start anything. The agent process starts when you send a turn.
94
+
95
+ ## Connecting
96
+
97
+ Any AHP client works. [`ahpc`](https://github.com/softov/ahpc) is one:
98
+
99
+ ```bash
100
+ ahpc --host ws://127.0.0.1:9187
102
101
  ```
103
102
 
104
- `provider` is the id a client names in `createSession`. It has to be unique among the agents one host was given.
103
+ ## Packages
105
104
 
106
- `schema()` says what a session of this kind can be configured with, and `defaults()` says where those keys start. Both can be empty.
105
+ `@ahpd/server` is a thin wrapper over two libraries:
107
106
 
108
- `create()` returns the session. The session holds the state of the session and chat channels, and calls `start.emit('chat', ...)` as things happen.
107
+ - [`@ahpd/sdk`](https://www.npmjs.com/package/@ahpd/sdk) is the protocol and the ports. It has no backend in it.
108
+ - [`@ahpd/agent-claude`](https://www.npmjs.com/package/@ahpd/agent-claude) is the Claude backend.
109
109
 
110
- Pass it to the host like any other backend: `createHost({ path, agents: [parrot()] })`. Your backend and [`@ahpd/agent-claude`](https://www.npmjs.com/package/@ahpd/agent-claude) register identically and can run side by side.
110
+ The daemon is those two and a socket:
111
111
 
112
- See [docs/AGENT.md](https://github.com/softov/ahpd/blob/main/docs/AGENT.md) for the contract, and [examples/echo](https://github.com/softov/ahpd/tree/main/examples/echo) for a working backend in about two hundred lines.
112
+ ```ts
113
+ import { createHost, listen } from '@ahpd/sdk';
114
+ import { claude } from '@ahpd/agent-claude';
113
115
 
114
- ## Types
116
+ const host = createHost({ path, agents: [claude({ paths: [path] })] });
117
+ await listen({ port: 9187 }, (peer) => host.accept(peer));
118
+ ```
115
119
 
116
- All types are exported. Nothing under `types/` imports a runtime value, so you can read the contract without loading the implementation.
120
+ If you want a host of a different shape, build it from `@ahpd/sdk` and skip this package. To serve a different agent, write an `Agent` and add it to `agents`. See [docs/AGENT.md](https://github.com/softov/ahpd/blob/main/docs/AGENT.md).
117
121
 
118
122
  ## Documentation
119
123
 
120
124
  | | |
121
125
  | --- | --- |
122
- | [LIBRARY.md](https://github.com/softov/ahpd/blob/main/docs/LIBRARY.md) | `createHost` and the ports in full |
123
- | [AGENT.md](https://github.com/softov/ahpd/blob/main/docs/AGENT.md) | The `Agent` and `Session` contracts |
124
- | [AHP.md](https://github.com/softov/ahpd/blob/main/docs/AHP.md) | Protocol coverage action by action |
126
+ | [DAEMON.md](https://github.com/softov/ahpd/blob/main/docs/DAEMON.md) | The CLI, config file, connection tokens, and Node/Bun/Deno |
127
+ | [LIBRARY.md](https://github.com/softov/ahpd/blob/main/docs/LIBRARY.md) | Building a host with `@ahpd/sdk` |
128
+ | [AGENT.md](https://github.com/softov/ahpd/blob/main/docs/AGENT.md) | Writing another agent backend |
129
+ | [AHP.md](https://github.com/softov/ahpd/blob/main/docs/AHP.md) | Protocol coverage, and every action it emits |
130
+ | [agent-host-protocol](https://github.com/microsoft/agent-host-protocol) | The protocol itself, and its [documentation](https://microsoft.github.io/agent-host-protocol/) |
125
131
 
126
132
  ## License
127
133
 
@@ -0,0 +1,65 @@
1
+ /** What this daemon was told before anybody typed a flag, and where it is. */
2
+ /** What a config file may say. Every key is what a flag would have said. */
3
+ export interface Config {
4
+ /** TCP port to bind. 0 lets the OS choose. */
5
+ port?: number;
6
+ /** Address to bind. */
7
+ host?: string;
8
+ /** The directories whose sessions this host serves. */
9
+ paths?: string[];
10
+ /** The secret every connection must present. */
11
+ connectionToken?: string;
12
+ /** A file holding that secret, written with a fresh one if absent. */
13
+ connectionTokenFile?: string;
14
+ /** Accept any connection, with no secret at all. */
15
+ withoutConnectionToken?: boolean;
16
+ }
17
+ /**
18
+ * Where this tool's files live.
19
+ *
20
+ * XDG, and the environment variable before the fallback: `$XDG_CONFIG_HOME` is
21
+ * what somebody sets when their configuration is not in `~/.config`, and a
22
+ * tool that reads the fallback anyway is a tool that ignores them.
23
+ */
24
+ export declare const configHome: () => string;
25
+ /** The directory this tool owns inside it. */
26
+ export declare const configDir: () => string;
27
+ /** The file a person edits. */
28
+ export declare const configPath: () => string;
29
+ /**
30
+ * Where a detached daemon records itself.
31
+ *
32
+ * Beside the configuration rather than in a runtime directory, so everything
33
+ * about this tool is in one place a person can look at. It is written by the
34
+ * daemon and not by hand, which is the one thing that makes it different from
35
+ * its neighbour.
36
+ */
37
+ export declare const daemonPath: () => string;
38
+ /**
39
+ * Where a detached daemon's output goes.
40
+ *
41
+ * It has to go somewhere real. A background process whose stdout is a pipe
42
+ * dies the moment the thing holding the other end exits, and one whose stdout
43
+ * is discarded leaves nothing to read when it misbehaves.
44
+ */
45
+ export declare const daemonLog: () => string;
46
+ /**
47
+ * Where automations are kept.
48
+ *
49
+ * Beside the configuration and not inside it: `config.json` is a file a person
50
+ * edits and this one is written by the daemon every time somebody adds an
51
+ * automation, and a tool that rewrites a hand-edited file loses the comments
52
+ * and the ordering somebody put there.
53
+ */
54
+ export declare const automationsPath: () => string;
55
+ /** Make sure the directory is there, so a write into it can succeed. */
56
+ export declare const ensureConfigDir: () => void;
57
+ /**
58
+ * Read it, or answer that there was nothing to read.
59
+ *
60
+ * A file that is not there is not an error - most people have none. One that
61
+ * is there and is broken *is* one, and says so rather than starting on
62
+ * defaults nobody chose: silently ignoring a configuration somebody wrote is
63
+ * worse than refusing to start.
64
+ */
65
+ export declare function loadConfig(named?: string): Config;
package/dist/config.js ADDED
@@ -0,0 +1,75 @@
1
+ /** What this daemon was told before anybody typed a flag, and where it is. */
2
+ import { mkdirSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ /**
6
+ * Where this tool's files live.
7
+ *
8
+ * XDG, and the environment variable before the fallback: `$XDG_CONFIG_HOME` is
9
+ * what somebody sets when their configuration is not in `~/.config`, and a
10
+ * tool that reads the fallback anyway is a tool that ignores them.
11
+ */
12
+ export const configHome = () => process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
13
+ /** The directory this tool owns inside it. */
14
+ export const configDir = () => join(configHome(), 'ahpd');
15
+ /** The file a person edits. */
16
+ export const configPath = () => join(configDir(), 'config.json');
17
+ /**
18
+ * Where a detached daemon records itself.
19
+ *
20
+ * Beside the configuration rather than in a runtime directory, so everything
21
+ * about this tool is in one place a person can look at. It is written by the
22
+ * daemon and not by hand, which is the one thing that makes it different from
23
+ * its neighbour.
24
+ */
25
+ export const daemonPath = () => join(configDir(), 'daemon.json');
26
+ /**
27
+ * Where a detached daemon's output goes.
28
+ *
29
+ * It has to go somewhere real. A background process whose stdout is a pipe
30
+ * dies the moment the thing holding the other end exits, and one whose stdout
31
+ * is discarded leaves nothing to read when it misbehaves.
32
+ */
33
+ export const daemonLog = () => join(configDir(), 'daemon.log');
34
+ /**
35
+ * Where automations are kept.
36
+ *
37
+ * Beside the configuration and not inside it: `config.json` is a file a person
38
+ * edits and this one is written by the daemon every time somebody adds an
39
+ * automation, and a tool that rewrites a hand-edited file loses the comments
40
+ * and the ordering somebody put there.
41
+ */
42
+ export const automationsPath = () => join(configDir(), 'automations.json');
43
+ /** Make sure the directory is there, so a write into it can succeed. */
44
+ export const ensureConfigDir = () => { mkdirSync(configDir(), { recursive: true }); };
45
+ /**
46
+ * Read it, or answer that there was nothing to read.
47
+ *
48
+ * A file that is not there is not an error - most people have none. One that
49
+ * is there and is broken *is* one, and says so rather than starting on
50
+ * defaults nobody chose: silently ignoring a configuration somebody wrote is
51
+ * worse than refusing to start.
52
+ */
53
+ export function loadConfig(named) {
54
+ const path = named ?? configPath();
55
+ let text;
56
+ try {
57
+ text = readFileSync(path, 'utf8');
58
+ }
59
+ catch {
60
+ // Only a file asked for *by name* is worth complaining about.
61
+ if (named === undefined)
62
+ return {};
63
+ throw new Error(`No configuration at ${named}`);
64
+ }
65
+ try {
66
+ const found = JSON.parse(text);
67
+ if (typeof found !== 'object' || found === null || Array.isArray(found)) {
68
+ throw new Error('it is not an object');
69
+ }
70
+ return found;
71
+ }
72
+ catch (error) {
73
+ throw new Error(`${path} could not be read: ${error instanceof Error ? error.message : String(error)}`);
74
+ }
75
+ }
@@ -0,0 +1,26 @@
1
+ /** Starting one of these in the background, and finding it again. */
2
+ /** What a detached daemon records about itself. */
3
+ export interface Running {
4
+ pid: number;
5
+ url: string;
6
+ paths: string[];
7
+ startedAt: string;
8
+ }
9
+ /**
10
+ * The daemon this user has running, if the record names one that still is.
11
+ *
12
+ * A stale record is cleared rather than reported: a `daemon.json` left behind
13
+ * by a crash would otherwise have `start` refuse for ever, on the strength of
14
+ * a process that is not there.
15
+ */
16
+ export declare function running(): Running | undefined;
17
+ /**
18
+ * Start one in the background, and wait until it says where it is.
19
+ *
20
+ * Detached and with its streams let go, so it outlives the shell that started
21
+ * it - which is the whole point, and the difference between this and running
22
+ * `ahpd` in a terminal you then have to keep open.
23
+ */
24
+ export declare function start(argv: string[], self: string): Promise<Running>;
25
+ /** Stop it, and forget where it was. Answers what was stopped, or nothing. */
26
+ export declare function stop(): Running | undefined;
package/dist/daemon.js ADDED
@@ -0,0 +1,160 @@
1
+ /** Starting one of these in the background, and finding it again. */
2
+ import { spawn } from 'node:child_process';
3
+ import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { daemonLog, daemonPath, ensureConfigDir } from './config.js';
5
+ /** Is that process still there? A record outlives a crash, and says nothing about one. */
6
+ const alive = (pid) => {
7
+ // `0` and the negatives are not processes: to `kill` they mean process
8
+ // *groups*, and pid 0 is the caller's own - which would report any record
9
+ // holding one as running, and then signal this process rather than it.
10
+ if (pid <= 0)
11
+ return false;
12
+ try {
13
+ // Signal 0 checks for existence without asking the process to do anything.
14
+ process.kill(pid, 0);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ };
21
+ /**
22
+ * The daemon this user has running, if the record names one that still is.
23
+ *
24
+ * A stale record is cleared rather than reported: a `daemon.json` left behind
25
+ * by a crash would otherwise have `start` refuse for ever, on the strength of
26
+ * a process that is not there.
27
+ */
28
+ export function running() {
29
+ let found;
30
+ try {
31
+ found = JSON.parse(readFileSync(daemonPath(), 'utf8'));
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ if (typeof found?.pid !== 'number' || !alive(found.pid)) {
37
+ try {
38
+ unlinkSync(daemonPath());
39
+ }
40
+ catch { /* it was already gone, which is what was wanted */ }
41
+ return undefined;
42
+ }
43
+ return found;
44
+ }
45
+ /**
46
+ * Start one in the background, and wait until it says where it is.
47
+ *
48
+ * Detached and with its streams let go, so it outlives the shell that started
49
+ * it - which is the whole point, and the difference between this and running
50
+ * `ahpd` in a terminal you then have to keep open.
51
+ */
52
+ export async function start(argv, self) {
53
+ const already = running();
54
+ if (already)
55
+ throw new Error(`One is already running: ${already.url} (pid ${String(already.pid)})`);
56
+ ensureConfigDir();
57
+ /*
58
+ * Where this run's output will start.
59
+ *
60
+ * The log is appended to across runs, so reading the whole file for an
61
+ * address finds the *previous* daemon's - which is a record pointing at a
62
+ * port this process never bound, and was.
63
+ */
64
+ const from = (() => {
65
+ try {
66
+ return statSync(daemonLog()).size;
67
+ }
68
+ catch {
69
+ return 0;
70
+ }
71
+ })();
72
+ /*
73
+ * Its output goes to a file, not to a pipe held here.
74
+ *
75
+ * A pipe dies with the process holding its other end, and this process is
76
+ * about to exit - so the daemon's next log line would kill it. A file also
77
+ * leaves something to read when it misbehaves, which a background process
78
+ * with no output at all does not.
79
+ */
80
+ const log = openSync(daemonLog(), 'a');
81
+ const child = spawn(process.execPath, [self, ...argv], {
82
+ detached: true,
83
+ stdio: ['ignore', log, log],
84
+ });
85
+ child.unref();
86
+ closeSync(log);
87
+ /*
88
+ * Wait for it to say where it is, by reading what it wrote.
89
+ *
90
+ * Polling rather than listening, because nothing here is holding its
91
+ * output any more - which is exactly the point.
92
+ */
93
+ /** What it said about itself, so the record is its answer and not a guess. */
94
+ let announced = '';
95
+ const url = await new Promise((answer, fail) => {
96
+ const gaveUp = Date.now() + 20_000;
97
+ const look = () => {
98
+ let said = '';
99
+ try {
100
+ said = readFileSync(daemonLog(), 'utf8').slice(from);
101
+ }
102
+ catch { /* not written yet */ }
103
+ const found = /ws:\/\/[^\s,]+/.exec(said);
104
+ if (found) {
105
+ announced = said;
106
+ answer(found[0]);
107
+ return;
108
+ }
109
+ if (child.exitCode !== null) {
110
+ fail(new Error(`it exited with ${String(child.exitCode)}. See ${daemonLog()}`));
111
+ return;
112
+ }
113
+ if (Date.now() > gaveUp) {
114
+ fail(new Error(`it started but never said where it was listening. See ${daemonLog()}`));
115
+ return;
116
+ }
117
+ setTimeout(look, 100);
118
+ };
119
+ look();
120
+ }).catch((error) => {
121
+ if (child.pid !== undefined) {
122
+ try {
123
+ process.kill(child.pid, 'SIGKILL');
124
+ }
125
+ catch { /* already gone */ }
126
+ }
127
+ throw error;
128
+ });
129
+ // It announced where it was listening, so it started; this is for the type
130
+ // rather than for the case, and `0` must never reach the record.
131
+ if (child.pid === undefined)
132
+ throw new Error('it started but has no process id');
133
+ const record = {
134
+ pid: child.pid,
135
+ url,
136
+ // Off its own announcement rather than off the command line: the
137
+ // directories may have come from the configuration file, and a record
138
+ // built from argv would name none of them.
139
+ paths: (/sessions in (.+)/.exec(announced)?.[1] ?? '')
140
+ .trim().split(',').map((one) => one.trim()).filter((one) => one !== ''),
141
+ startedAt: new Date().toISOString(),
142
+ };
143
+ writeFileSync(daemonPath(), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
144
+ return record;
145
+ }
146
+ /** Stop it, and forget where it was. Answers what was stopped, or nothing. */
147
+ export function stop() {
148
+ const found = running();
149
+ if (!found)
150
+ return undefined;
151
+ try {
152
+ process.kill(found.pid, 'SIGTERM');
153
+ }
154
+ catch { /* it went between the check and the signal, which is a stop */ }
155
+ try {
156
+ unlinkSync(daemonPath());
157
+ }
158
+ catch { /* already gone */ }
159
+ return found;
160
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};