@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.
- package/README.md +88 -81
- package/dist/config.d.ts +72 -0
- package/dist/config.js +75 -0
- package/dist/daemon.d.ts +31 -0
- package/dist/daemon.js +162 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +335 -0
- package/dist/pty.d.ts +12 -0
- package/dist/pty.js +56 -0
- package/package.json +15 -25
- package/dist/automations.d.ts +0 -4
- package/dist/automations.d.ts.map +0 -1
- package/dist/automations.js +0 -191
- package/dist/automations.js.map +0 -1
- package/dist/catalog.d.ts +0 -48
- package/dist/catalog.d.ts.map +0 -1
- package/dist/catalog.js +0 -51
- package/dist/catalog.js.map +0 -1
- package/dist/changes.d.ts +0 -4
- package/dist/changes.d.ts.map +0 -1
- package/dist/changes.js +0 -601
- package/dist/changes.js.map +0 -1
- package/dist/cron.d.ts +0 -43
- package/dist/cron.d.ts.map +0 -1
- package/dist/cron.js +0 -211
- package/dist/cron.js.map +0 -1
- package/dist/git.d.ts +0 -16
- package/dist/git.d.ts.map +0 -1
- package/dist/git.js +0 -126
- package/dist/git.js.map +0 -1
- package/dist/host.d.ts +0 -24
- package/dist/host.d.ts.map +0 -1
- package/dist/host.js +0 -5850
- package/dist/host.js.map +0 -1
- package/dist/index.d.ts +0 -36
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -34
- package/dist/index.js.map +0 -1
- package/dist/listen.d.ts +0 -3
- package/dist/listen.d.ts.map +0 -1
- package/dist/listen.js +0 -183
- package/dist/listen.js.map +0 -1
- package/dist/paging.d.ts +0 -38
- package/dist/paging.d.ts.map +0 -1
- package/dist/paging.js +0 -53
- package/dist/paging.js.map +0 -1
- package/dist/paths.d.ts +0 -15
- package/dist/paths.d.ts.map +0 -1
- package/dist/paths.js +0 -19
- package/dist/paths.js.map +0 -1
- package/dist/resources.d.ts +0 -122
- package/dist/resources.d.ts.map +0 -1
- package/dist/resources.js +0 -494
- package/dist/resources.js.map +0 -1
- package/dist/rpc.d.ts +0 -63
- package/dist/rpc.d.ts.map +0 -1
- package/dist/rpc.js +0 -208
- package/dist/rpc.js.map +0 -1
- package/dist/scheduled.d.ts +0 -48
- package/dist/scheduled.d.ts.map +0 -1
- package/dist/scheduled.js +0 -325
- package/dist/scheduled.js.map +0 -1
- package/dist/terminals.d.ts +0 -29
- package/dist/terminals.d.ts.map +0 -1
- package/dist/terminals.js +0 -346
- package/dist/terminals.js.map +0 -1
- package/dist/tools.d.ts +0 -18
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -65
- package/dist/tools.js.map +0 -1
- package/dist/types/agent.d.ts +0 -245
- package/dist/types/agent.d.ts.map +0 -1
- package/dist/types/agent.js +0 -3
- package/dist/types/agent.js.map +0 -1
- package/dist/types/automations.d.ts +0 -164
- package/dist/types/automations.d.ts.map +0 -1
- package/dist/types/automations.js +0 -3
- package/dist/types/automations.js.map +0 -1
- package/dist/types/catalog.d.ts +0 -30
- package/dist/types/catalog.d.ts.map +0 -1
- package/dist/types/catalog.js +0 -3
- package/dist/types/catalog.js.map +0 -1
- package/dist/types/changes.d.ts +0 -272
- package/dist/types/changes.d.ts.map +0 -1
- package/dist/types/changes.js +0 -3
- package/dist/types/changes.js.map +0 -1
- package/dist/types/common.d.ts +0 -9
- package/dist/types/common.d.ts.map +0 -1
- package/dist/types/common.js +0 -3
- package/dist/types/common.js.map +0 -1
- package/dist/types/host.d.ts +0 -372
- package/dist/types/host.d.ts.map +0 -1
- package/dist/types/host.js +0 -3
- package/dist/types/host.js.map +0 -1
- package/dist/types/index.d.ts +0 -20
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -8
- package/dist/types/index.js.map +0 -1
- package/dist/types/listen.d.ts +0 -48
- package/dist/types/listen.d.ts.map +0 -1
- package/dist/types/listen.js +0 -3
- package/dist/types/listen.js.map +0 -1
- package/dist/types/paging.d.ts +0 -10
- package/dist/types/paging.d.ts.map +0 -1
- package/dist/types/paging.js +0 -3
- package/dist/types/paging.js.map +0 -1
- package/dist/types/probe.d.ts +0 -46
- package/dist/types/probe.d.ts.map +0 -1
- package/dist/types/probe.js +0 -3
- package/dist/types/probe.js.map +0 -1
- package/dist/types/resources.d.ts +0 -83
- package/dist/types/resources.d.ts.map +0 -1
- package/dist/types/resources.js +0 -3
- package/dist/types/resources.js.map +0 -1
- package/dist/types/rpc.d.ts +0 -65
- package/dist/types/rpc.d.ts.map +0 -1
- package/dist/types/rpc.js +0 -3
- package/dist/types/rpc.js.map +0 -1
- package/dist/types/session.d.ts +0 -395
- package/dist/types/session.d.ts.map +0 -1
- package/dist/types/session.js +0 -3
- package/dist/types/session.js.map +0 -1
- package/dist/types/terminals.d.ts +0 -131
- package/dist/types/terminals.d.ts.map +0 -1
- package/dist/types/terminals.js +0 -3
- package/dist/types/terminals.js.map +0 -1
- package/dist/types/wire.d.ts +0 -62
- package/dist/types/wire.d.ts.map +0 -1
- package/dist/types/wire.js +0 -13
- package/dist/types/wire.js.map +0 -1
- package/dist/types/worktrees.d.ts +0 -86
- package/dist/types/worktrees.d.ts.map +0 -1
- package/dist/types/worktrees.js +0 -3
- package/dist/types/worktrees.js.map +0 -1
- package/dist/worktrees.d.ts +0 -27
- package/dist/worktrees.d.ts.map +0 -1
- package/dist/worktrees.js +0 -152
- package/dist/worktrees.js.map +0 -1
- package/src/automations.ts +0 -204
- package/src/catalog.ts +0 -53
- package/src/changes.ts +0 -629
- package/src/cron.ts +0 -251
- package/src/git.ts +0 -132
- package/src/host.ts +0 -5947
- package/src/index.ts +0 -40
- package/src/listen.ts +0 -240
- package/src/paging.ts +0 -56
- package/src/paths.ts +0 -19
- package/src/resources.ts +0 -513
- package/src/rpc.ts +0 -222
- package/src/scheduled.ts +0 -369
- package/src/terminals.ts +0 -353
- package/src/tools.ts +0 -64
- package/src/types/agent.ts +0 -255
- package/src/types/automations.ts +0 -164
- package/src/types/catalog.ts +0 -26
- package/src/types/changes.ts +0 -266
- package/src/types/common.ts +0 -9
- package/src/types/host.ts +0 -386
- package/src/types/index.ts +0 -27
- package/src/types/listen.ts +0 -53
- package/src/types/paging.ts +0 -11
- package/src/types/probe.ts +0 -42
- package/src/types/resources.ts +0 -107
- package/src/types/rpc.ts +0 -68
- package/src/types/session.ts +0 -415
- package/src/types/terminals.ts +0 -133
- package/src/types/wire.ts +0 -69
- package/src/types/worktrees.ts +0 -91
- package/src/worktrees.ts +0 -162
package/README.md
CHANGED
|
@@ -1,127 +1,134 @@
|
|
|
1
1
|
# @ahpd/server
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
18
|
+
npm i -g @ahpd/server
|
|
19
|
+
ahpd --path /work/project
|
|
11
20
|
```
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
Or run it without installing:
|
|
14
23
|
|
|
15
|
-
|
|
24
|
+
```bash
|
|
25
|
+
npx @ahpd/server --path /work/project
|
|
26
|
+
```
|
|
16
27
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
path: process.cwd(),
|
|
25
|
-
agents: [claude({ paths: [process.cwd()] })],
|
|
26
|
-
});
|
|
32
|
+
## Commands
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
44
|
+
## Options
|
|
35
45
|
|
|
36
|
-
|
|
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
|
+
| `--automations <where>` | `file`, the default, keeps them beside the config and fires their schedules. `memory` keeps them until the process ends and fires nothing |
|
|
56
|
+
| `--help`, `-h` | |
|
|
37
57
|
|
|
38
|
-
|
|
58
|
+
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
59
|
|
|
40
|
-
##
|
|
60
|
+
## Directories
|
|
41
61
|
|
|
42
|
-
|
|
62
|
+
`--path` is repeatable:
|
|
43
63
|
|
|
64
|
+
```bash
|
|
65
|
+
ahpd --path ~/src/project-a --path ~/src/project-b
|
|
66
|
+
```
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
});
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
## Remote connections
|
|
68
71
|
|
|
72
|
+
It binds to loopback and needs no token there. Binding anywhere else does:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ahpd --host 0.0.0.0 --connection-token <secret>
|
|
69
76
|
```
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 |
|
|
78
|
+
Or keep the secret in a file, which is written with a fresh one if it is not there yet:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ahpd --host 0.0.0.0 --connection-token-file ~/.config/ahpd/token
|
|
82
|
+
```
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
Clients present it as `?tkn=<secret>` on the URL or as an `Authorization: Bearer <secret>` header.
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
`--without-connection-token` binds without one. Only do that when something else is already keeping the port to yourself.
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
## What it serves
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
The Claude backend adds what Claude has: models and effort, permission modes, skills and slash commands, MCP servers, and OAuth sign-in.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
## Connecting
|
|
97
|
+
|
|
98
|
+
Any AHP client works. [`ahpc`](https://github.com/softov/ahpc) is one:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
ahpc --host ws://127.0.0.1:9187
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
## Packages
|
|
105
105
|
|
|
106
|
-
`
|
|
106
|
+
`@ahpd/server` is a thin wrapper over two libraries:
|
|
107
107
|
|
|
108
|
-
`
|
|
108
|
+
- [`@ahpd/sdk`](https://www.npmjs.com/package/@ahpd/sdk) is the protocol and the ports. It has no backend in it.
|
|
109
|
+
- [`@ahpd/agent-claude`](https://www.npmjs.com/package/@ahpd/agent-claude) is the Claude backend.
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
The daemon is those two and a socket:
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
```ts
|
|
114
|
+
import { createHost, listen } from '@ahpd/sdk';
|
|
115
|
+
import { claude } from '@ahpd/agent-claude';
|
|
113
116
|
|
|
114
|
-
|
|
117
|
+
const host = createHost({ path, agents: [claude({ paths: [path] })] });
|
|
118
|
+
await listen({ port: 9187 }, (peer) => host.accept(peer));
|
|
119
|
+
```
|
|
115
120
|
|
|
116
|
-
|
|
121
|
+
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
122
|
|
|
118
123
|
## Documentation
|
|
119
124
|
|
|
120
125
|
| | |
|
|
121
126
|
| --- | --- |
|
|
122
|
-
| [
|
|
123
|
-
| [
|
|
124
|
-
| [
|
|
127
|
+
| [DAEMON.md](https://github.com/softov/ahpd/blob/main/docs/DAEMON.md) | The CLI, config file, connection tokens, and Node/Bun/Deno |
|
|
128
|
+
| [LIBRARY.md](https://github.com/softov/ahpd/blob/main/docs/LIBRARY.md) | Building a host with `@ahpd/sdk` |
|
|
129
|
+
| [AGENT.md](https://github.com/softov/ahpd/blob/main/docs/AGENT.md) | Writing another agent backend |
|
|
130
|
+
| [AHP.md](https://github.com/softov/ahpd/blob/main/docs/AHP.md) | Protocol coverage, and every action it emits |
|
|
131
|
+
| [agent-host-protocol](https://github.com/microsoft/agent-host-protocol) | The protocol itself, and its [documentation](https://microsoft.github.io/agent-host-protocol/) |
|
|
125
132
|
|
|
126
133
|
## License
|
|
127
134
|
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
* 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
|
+
/**
|
|
25
|
+
* Where this tool's files live.
|
|
26
|
+
*
|
|
27
|
+
* XDG, and the environment variable before the fallback: `$XDG_CONFIG_HOME` is
|
|
28
|
+
* what somebody sets when their configuration is not in `~/.config`, and a
|
|
29
|
+
* tool that reads the fallback anyway is a tool that ignores them.
|
|
30
|
+
*/
|
|
31
|
+
export declare const configHome: () => string;
|
|
32
|
+
/** The directory this tool owns inside it. */
|
|
33
|
+
export declare const configDir: () => string;
|
|
34
|
+
/** The file a person edits. */
|
|
35
|
+
export declare const configPath: () => string;
|
|
36
|
+
/**
|
|
37
|
+
* Where a detached daemon records itself.
|
|
38
|
+
*
|
|
39
|
+
* Beside the configuration rather than in a runtime directory, so everything
|
|
40
|
+
* about this tool is in one place a person can look at. It is written by the
|
|
41
|
+
* daemon and not by hand, which is the one thing that makes it different from
|
|
42
|
+
* its neighbour.
|
|
43
|
+
*/
|
|
44
|
+
export declare const daemonPath: () => string;
|
|
45
|
+
/**
|
|
46
|
+
* Where a detached daemon's output goes.
|
|
47
|
+
*
|
|
48
|
+
* It has to go somewhere real. A background process whose stdout is a pipe
|
|
49
|
+
* dies the moment the thing holding the other end exits, and one whose stdout
|
|
50
|
+
* is discarded leaves nothing to read when it misbehaves.
|
|
51
|
+
*/
|
|
52
|
+
export declare const daemonLog: () => string;
|
|
53
|
+
/**
|
|
54
|
+
* Where automations are kept.
|
|
55
|
+
*
|
|
56
|
+
* Beside the configuration and not inside it: `config.json` is a file a person
|
|
57
|
+
* edits and this one is written by the daemon every time somebody adds an
|
|
58
|
+
* automation, and a tool that rewrites a hand-edited file loses the comments
|
|
59
|
+
* and the ordering somebody put there.
|
|
60
|
+
*/
|
|
61
|
+
export declare const automationsPath: () => string;
|
|
62
|
+
/** Make sure the directory is there, so a write into it can succeed. */
|
|
63
|
+
export declare const ensureConfigDir: () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Read it, or answer that there was nothing to read.
|
|
66
|
+
*
|
|
67
|
+
* A file that is not there is not an error - most people have none. One that
|
|
68
|
+
* is there and is broken *is* one, and says so rather than starting on
|
|
69
|
+
* defaults nobody chose: silently ignoring a configuration somebody wrote is
|
|
70
|
+
* worse than refusing to start.
|
|
71
|
+
*/
|
|
72
|
+
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
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
* 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;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The daemon this user has running, if the record names one that still is.
|
|
16
|
+
*
|
|
17
|
+
* A stale record is cleared rather than reported: a `daemon.json` left behind
|
|
18
|
+
* by a crash would otherwise have `start` refuse for ever, on the strength of
|
|
19
|
+
* a process that is not there.
|
|
20
|
+
*/
|
|
21
|
+
export declare function running(): Running | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Start one in the background, and wait until it says where it is.
|
|
24
|
+
*
|
|
25
|
+
* Detached and with its streams let go, so it outlives the shell that started
|
|
26
|
+
* it - which is the whole point, and the difference between this and running
|
|
27
|
+
* `ahpd` in a terminal you then have to keep open.
|
|
28
|
+
*/
|
|
29
|
+
export declare function start(argv: string[], self: string): Promise<Running>;
|
|
30
|
+
/** Stop it, and forget where it was. Answers what was stopped, or nothing. */
|
|
31
|
+
export declare function stop(): Running | undefined;
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
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 automations = /^automations (.+)$/m.exec(announced)?.[1]?.trim();
|
|
134
|
+
const record = {
|
|
135
|
+
pid: child.pid,
|
|
136
|
+
url,
|
|
137
|
+
// Off its own announcement rather than off the command line: the
|
|
138
|
+
// directories may have come from the configuration file, and a record
|
|
139
|
+
// built from argv would name none of them.
|
|
140
|
+
paths: (/sessions in (.+)/.exec(announced)?.[1] ?? '')
|
|
141
|
+
.trim().split(',').map((one) => one.trim()).filter((one) => one !== ''),
|
|
142
|
+
startedAt: new Date().toISOString(),
|
|
143
|
+
...(automations !== undefined ? { automations } : {}),
|
|
144
|
+
};
|
|
145
|
+
writeFileSync(daemonPath(), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
146
|
+
return record;
|
|
147
|
+
}
|
|
148
|
+
/** Stop it, and forget where it was. Answers what was stopped, or nothing. */
|
|
149
|
+
export function stop() {
|
|
150
|
+
const found = running();
|
|
151
|
+
if (!found)
|
|
152
|
+
return undefined;
|
|
153
|
+
try {
|
|
154
|
+
process.kill(found.pid, 'SIGTERM');
|
|
155
|
+
}
|
|
156
|
+
catch { /* it went between the check and the signal, which is a stop */ }
|
|
157
|
+
try {
|
|
158
|
+
unlinkSync(daemonPath());
|
|
159
|
+
}
|
|
160
|
+
catch { /* already gone */ }
|
|
161
|
+
return found;
|
|
162
|
+
}
|
package/dist/main.d.ts
ADDED