@ahpd/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/automations.d.ts +4 -0
  4. package/dist/automations.d.ts.map +1 -0
  5. package/dist/automations.js +191 -0
  6. package/dist/automations.js.map +1 -0
  7. package/dist/catalog.d.ts +48 -0
  8. package/dist/catalog.d.ts.map +1 -0
  9. package/dist/catalog.js +51 -0
  10. package/dist/catalog.js.map +1 -0
  11. package/dist/changes.d.ts +4 -0
  12. package/dist/changes.d.ts.map +1 -0
  13. package/dist/changes.js +601 -0
  14. package/dist/changes.js.map +1 -0
  15. package/dist/cron.d.ts +43 -0
  16. package/dist/cron.d.ts.map +1 -0
  17. package/dist/cron.js +211 -0
  18. package/dist/cron.js.map +1 -0
  19. package/dist/git.d.ts +16 -0
  20. package/dist/git.d.ts.map +1 -0
  21. package/dist/git.js +126 -0
  22. package/dist/git.js.map +1 -0
  23. package/dist/host.d.ts +24 -0
  24. package/dist/host.d.ts.map +1 -0
  25. package/dist/host.js +5850 -0
  26. package/dist/host.js.map +1 -0
  27. package/dist/index.d.ts +36 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +34 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/listen.d.ts +3 -0
  32. package/dist/listen.d.ts.map +1 -0
  33. package/dist/listen.js +183 -0
  34. package/dist/listen.js.map +1 -0
  35. package/dist/paging.d.ts +38 -0
  36. package/dist/paging.d.ts.map +1 -0
  37. package/dist/paging.js +53 -0
  38. package/dist/paging.js.map +1 -0
  39. package/dist/paths.d.ts +15 -0
  40. package/dist/paths.d.ts.map +1 -0
  41. package/dist/paths.js +19 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/resources.d.ts +122 -0
  44. package/dist/resources.d.ts.map +1 -0
  45. package/dist/resources.js +494 -0
  46. package/dist/resources.js.map +1 -0
  47. package/dist/rpc.d.ts +63 -0
  48. package/dist/rpc.d.ts.map +1 -0
  49. package/dist/rpc.js +208 -0
  50. package/dist/rpc.js.map +1 -0
  51. package/dist/scheduled.d.ts +48 -0
  52. package/dist/scheduled.d.ts.map +1 -0
  53. package/dist/scheduled.js +325 -0
  54. package/dist/scheduled.js.map +1 -0
  55. package/dist/terminals.d.ts +29 -0
  56. package/dist/terminals.d.ts.map +1 -0
  57. package/dist/terminals.js +346 -0
  58. package/dist/terminals.js.map +1 -0
  59. package/dist/tools.d.ts +18 -0
  60. package/dist/tools.d.ts.map +1 -0
  61. package/dist/tools.js +65 -0
  62. package/dist/tools.js.map +1 -0
  63. package/dist/types/agent.d.ts +245 -0
  64. package/dist/types/agent.d.ts.map +1 -0
  65. package/dist/types/agent.js +3 -0
  66. package/dist/types/agent.js.map +1 -0
  67. package/dist/types/automations.d.ts +164 -0
  68. package/dist/types/automations.d.ts.map +1 -0
  69. package/dist/types/automations.js +3 -0
  70. package/dist/types/automations.js.map +1 -0
  71. package/dist/types/catalog.d.ts +30 -0
  72. package/dist/types/catalog.d.ts.map +1 -0
  73. package/dist/types/catalog.js +3 -0
  74. package/dist/types/catalog.js.map +1 -0
  75. package/dist/types/changes.d.ts +272 -0
  76. package/dist/types/changes.d.ts.map +1 -0
  77. package/dist/types/changes.js +3 -0
  78. package/dist/types/changes.js.map +1 -0
  79. package/dist/types/common.d.ts +9 -0
  80. package/dist/types/common.d.ts.map +1 -0
  81. package/dist/types/common.js +3 -0
  82. package/dist/types/common.js.map +1 -0
  83. package/dist/types/host.d.ts +372 -0
  84. package/dist/types/host.d.ts.map +1 -0
  85. package/dist/types/host.js +3 -0
  86. package/dist/types/host.js.map +1 -0
  87. package/dist/types/index.d.ts +20 -0
  88. package/dist/types/index.d.ts.map +1 -0
  89. package/dist/types/index.js +8 -0
  90. package/dist/types/index.js.map +1 -0
  91. package/dist/types/listen.d.ts +48 -0
  92. package/dist/types/listen.d.ts.map +1 -0
  93. package/dist/types/listen.js +3 -0
  94. package/dist/types/listen.js.map +1 -0
  95. package/dist/types/paging.d.ts +10 -0
  96. package/dist/types/paging.d.ts.map +1 -0
  97. package/dist/types/paging.js +3 -0
  98. package/dist/types/paging.js.map +1 -0
  99. package/dist/types/probe.d.ts +46 -0
  100. package/dist/types/probe.d.ts.map +1 -0
  101. package/dist/types/probe.js +3 -0
  102. package/dist/types/probe.js.map +1 -0
  103. package/dist/types/resources.d.ts +83 -0
  104. package/dist/types/resources.d.ts.map +1 -0
  105. package/dist/types/resources.js +3 -0
  106. package/dist/types/resources.js.map +1 -0
  107. package/dist/types/rpc.d.ts +65 -0
  108. package/dist/types/rpc.d.ts.map +1 -0
  109. package/dist/types/rpc.js +3 -0
  110. package/dist/types/rpc.js.map +1 -0
  111. package/dist/types/session.d.ts +395 -0
  112. package/dist/types/session.d.ts.map +1 -0
  113. package/dist/types/session.js +3 -0
  114. package/dist/types/session.js.map +1 -0
  115. package/dist/types/terminals.d.ts +131 -0
  116. package/dist/types/terminals.d.ts.map +1 -0
  117. package/dist/types/terminals.js +3 -0
  118. package/dist/types/terminals.js.map +1 -0
  119. package/dist/types/wire.d.ts +62 -0
  120. package/dist/types/wire.d.ts.map +1 -0
  121. package/dist/types/wire.js +13 -0
  122. package/dist/types/wire.js.map +1 -0
  123. package/dist/types/worktrees.d.ts +86 -0
  124. package/dist/types/worktrees.d.ts.map +1 -0
  125. package/dist/types/worktrees.js +3 -0
  126. package/dist/types/worktrees.js.map +1 -0
  127. package/dist/worktrees.d.ts +27 -0
  128. package/dist/worktrees.d.ts.map +1 -0
  129. package/dist/worktrees.js +152 -0
  130. package/dist/worktrees.js.map +1 -0
  131. package/package.json +61 -0
  132. package/src/automations.ts +204 -0
  133. package/src/catalog.ts +53 -0
  134. package/src/changes.ts +629 -0
  135. package/src/cron.ts +251 -0
  136. package/src/git.ts +132 -0
  137. package/src/host.ts +5947 -0
  138. package/src/index.ts +40 -0
  139. package/src/listen.ts +240 -0
  140. package/src/paging.ts +56 -0
  141. package/src/paths.ts +19 -0
  142. package/src/resources.ts +513 -0
  143. package/src/rpc.ts +222 -0
  144. package/src/scheduled.ts +369 -0
  145. package/src/terminals.ts +353 -0
  146. package/src/tools.ts +64 -0
  147. package/src/types/agent.ts +255 -0
  148. package/src/types/automations.ts +164 -0
  149. package/src/types/catalog.ts +26 -0
  150. package/src/types/changes.ts +266 -0
  151. package/src/types/common.ts +9 -0
  152. package/src/types/host.ts +386 -0
  153. package/src/types/index.ts +27 -0
  154. package/src/types/listen.ts +53 -0
  155. package/src/types/paging.ts +11 -0
  156. package/src/types/probe.ts +42 -0
  157. package/src/types/resources.ts +107 -0
  158. package/src/types/rpc.ts +68 -0
  159. package/src/types/session.ts +415 -0
  160. package/src/types/terminals.ts +133 -0
  161. package/src/types/wire.ts +69 -0
  162. package/src/types/worktrees.ts +91 -0
  163. package/src/worktrees.ts +162 -0
@@ -0,0 +1,91 @@
1
+ /** A working tree of its own, so two sessions in one repository do not collide. */
2
+
3
+ /** What to make, and from where. */
4
+ export interface Worktree {
5
+ /** The repository to make it from. A path, not a URI. */
6
+ repository: string;
7
+ /** The branch to start from. */
8
+ base: string;
9
+ /**
10
+ * The branch to create and check out, or nothing to check out `base` itself.
11
+ *
12
+ * Absent is what `worktreeCreateNewBranch: false` means: the session
13
+ * continues an existing branch rather than starting one. Git refuses a
14
+ * second worktree on a branch already checked out somewhere, which is the
15
+ * right refusal - two sessions on one branch is the collision worktrees
16
+ * exist to prevent.
17
+ */
18
+ branch?: string;
19
+ /**
20
+ * Whether the created branch tracks the upstream of the one it started from.
21
+ *
22
+ * Off unless asked. A tracking branch pushes at its upstream by default, so
23
+ * a `git push` inside a session's worktree would go at the branch it was
24
+ * started *from* - which is the one mistake in here that reaches a shared
25
+ * repository.
26
+ */
27
+ track?: boolean;
28
+ /** Where to put it. */
29
+ path: string;
30
+ /**
31
+ * Git-ignored files to copy in, as glob patterns relative to the repository.
32
+ *
33
+ * A checkout carries what git tracks and nothing else, so a worktree of an
34
+ * ordinary project has no `.env`, no `node_modules`, and none of the local
35
+ * configuration the thing needs to run. Without these the isolation works
36
+ * and the session inside it cannot build - which is a failure the person
37
+ * meets after choosing it rather than while choosing it.
38
+ */
39
+ include?: string[];
40
+ }
41
+
42
+ /**
43
+ * Making and unmaking git worktrees.
44
+ *
45
+ * A port for the reason `DirectoryFacts` and `ChangesetSource` are ports: it
46
+ * spawns `git`, which is a binary rather than a runtime API and may not be
47
+ * installed. A host given none advertises no `isolation` at all, which is the
48
+ * honest answer - a client draws the control a host says it has.
49
+ *
50
+ * Deliberately not part of `ChangesetSource`. That port describes changes; a
51
+ * worktree is a directory, and the two happen to share an implementation
52
+ * rather than a subject.
53
+ */
54
+ export interface Worktrees {
55
+ /**
56
+ * The repository root above this directory, if it is in one.
57
+ *
58
+ * The root and not the directory: a worktree is made from the repository,
59
+ * and a session pointed at `src/` inside one is still a session in it.
60
+ * Undefined means there is no repository here and no isolation to offer.
61
+ */
62
+ repository(dir: string): Promise<string | undefined>;
63
+
64
+ /**
65
+ * The branches this repository has, most useful first.
66
+ *
67
+ * What a client draws the base-branch picker from. An empty list is a real
68
+ * answer - a repository with no commits has no branches - and leaves the
69
+ * control with nothing to choose but the default.
70
+ */
71
+ branches(repository: string): Promise<string[]>;
72
+
73
+ /** Make one. Answers the path it made, which is where the session runs. */
74
+ create(worktree: Worktree): Promise<void>;
75
+
76
+ /**
77
+ * Whether anything in it is uncommitted.
78
+ *
79
+ * Asked before it is removed, and the answer decides whether it is: work
80
+ * nobody committed is work this daemon cannot judge the value of.
81
+ */
82
+ dirty(path: string): Promise<boolean>;
83
+
84
+ /**
85
+ * Take one away, and the branch with it if nothing else points at it.
86
+ *
87
+ * Only ever called for a worktree this host made and only when `dirty`
88
+ * answered no.
89
+ */
90
+ remove(repository: string, path: string, branch?: string): Promise<void>;
91
+ }
@@ -0,0 +1,162 @@
1
+ /** Git worktrees, so two sessions in one repository do not edit under each other. */
2
+
3
+ import { execFile } from 'node:child_process';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { cp, mkdir, readdir, stat } from 'node:fs/promises';
6
+ import type { Worktree, Worktrees } from './types/worktrees.js';
7
+
8
+ /** Run git, and answer what it said. Rejects with git's own words. */
9
+ const git = (dir: string, args: string[], ms = 60_000): Promise<string> =>
10
+ new Promise((resolve, reject) => {
11
+ execFile('git', ['-C', dir, ...args], { timeout: ms, maxBuffer: 8 << 20 }, (error, out, bad) => {
12
+ if (!error) return resolve(out.toString());
13
+ // git's own message, not node's: "fatal: a branch named x already
14
+ // exists" is something a person can act on and `Command failed` is not.
15
+ const said = bad.toString().trim() || error.message;
16
+ reject(new Error(said));
17
+ });
18
+ });
19
+
20
+ /**
21
+ * Where a repository's worktrees live: `<repo>.worktrees`, beside it.
22
+ *
23
+ * Beside rather than inside, because a worktree inside its own repository is
24
+ * a directory git then has to be told to ignore, and every tool that walks the
25
+ * tree finds the same project twice. The name is the one VS Code's host uses,
26
+ * so a worktree either of them makes is one the other finds.
27
+ */
28
+ export const worktreesOf = (repository: string): string =>
29
+ join(dirname(repository), `${basename(repository)}.worktrees`);
30
+
31
+ /** The directory name for a branch: no slashes, since it has to be one segment. */
32
+ export const worktreeFor = (branch: string): string =>
33
+ branch.replace(/^agents\//, '').replace(/\//g, '-');
34
+
35
+ /**
36
+ * Git worktrees, as a host's `Worktrees` port.
37
+ *
38
+ * Deliberately not part of `createHost`: it spawns `git`, which is a binary
39
+ * rather than a runtime API and may not be installed at all. A host without it
40
+ * offers no isolation and every session runs in the folder it was pointed at,
41
+ * which is what this daemon did before there was a choice.
42
+ *
43
+ * ```ts
44
+ * createHost({ path, agents: [claude({ paths })], worktrees: gitWorktrees() });
45
+ * ```
46
+ */
47
+ export function gitWorktrees(): Worktrees {
48
+ return {
49
+ repository: async (dir) => {
50
+ const found = await git(dir, ['rev-parse', '--show-toplevel'], 5_000).catch(() => undefined);
51
+ const root = found?.trim();
52
+ return root === undefined || root === '' ? undefined : root;
53
+ },
54
+
55
+ branches: async (repository) => {
56
+ /*
57
+ * Local branches by most recent commit, then the remote ones.
58
+ *
59
+ * Ordered rather than alphabetical because the useful answer is almost
60
+ * always near the top of the first list, and a picker that opens on
61
+ * `archive/2019-cleanup` is one somebody has to search.
62
+ */
63
+ const said = await git(
64
+ repository,
65
+ ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'],
66
+ 10_000,
67
+ ).catch(() => '');
68
+ const seen = new Set<string>();
69
+ for (const line of said.split('\n')) {
70
+ const name = line.trim();
71
+ // `origin/HEAD` is a symbolic ref rather than a branch, and checking
72
+ // it out is a detached head instead of the branch somebody meant.
73
+ if (name === '' || name.endsWith('/HEAD')) continue;
74
+ seen.add(name.replace(/^origin\//, ''));
75
+ }
76
+ return [...seen];
77
+ },
78
+
79
+ create: async (worktree: Worktree) => {
80
+ await mkdir(dirname(worktree.path), { recursive: true });
81
+ /*
82
+ * A new branch, or the one that was chosen.
83
+ *
84
+ * `--no-track` unless asked, because the new branch is this session's
85
+ * own: tracking would make it push to the base branch's upstream by
86
+ * default, so a `git push` inside a worktree session would go at the
87
+ * branch it was started *from*. That is the one mistake in here that
88
+ * reaches a shared repository.
89
+ *
90
+ * No branch at all is `worktreeCreateNewBranch: false` - the session
91
+ * continues `base` rather than starting something. Git refuses a second
92
+ * worktree on a branch already checked out, and that refusal is right.
93
+ */
94
+ await git(worktree.repository, worktree.branch === undefined
95
+ ? ['worktree', 'add', worktree.path, worktree.base]
96
+ : [
97
+ 'worktree', 'add', worktree.track === true ? '--track' : '--no-track',
98
+ '-b', worktree.branch, worktree.path, worktree.base,
99
+ ]);
100
+ for (const pattern of worktree.include ?? []) {
101
+ // Best effort, one pattern at a time: a `.env` that is not there is
102
+ // the ordinary case, and a session that refused to start over a
103
+ // missing optional file would be worse than one without it.
104
+ await copy(worktree.repository, worktree.path, pattern).catch(() => undefined);
105
+ }
106
+ },
107
+
108
+ dirty: async (path) => {
109
+ // `--porcelain` says nothing at all about a clean tree, which makes the
110
+ // empty answer the reliable one. Untracked files count: a file the
111
+ // agent wrote and never added is exactly the work worth keeping.
112
+ const said = await git(path, ['status', '--porcelain'], 10_000).catch(() => 'unknown');
113
+ return said.trim() !== '';
114
+ },
115
+
116
+ remove: async (repository, path, branch) => {
117
+ await git(repository, ['worktree', 'remove', path]);
118
+ /*
119
+ * The branch too, and only the one this host made.
120
+ *
121
+ * Named by the caller rather than derived from the directory: a branch
122
+ * prefix a client asked for changes the name, and guessing it wrong
123
+ * either deletes nothing or - far worse - names somebody else's. `-d`
124
+ * refuses to delete a branch carrying commits nothing else has, which
125
+ * is the same judgement the dirty check makes about uncommitted work.
126
+ */
127
+ if (branch !== undefined) await git(repository, ['branch', '-d', branch]).catch(() => undefined);
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Copy one git-ignored path into the new worktree, keeping its place.
134
+ *
135
+ * A glob only in the shallow sense the reference uses it: a literal path, or
136
+ * one trailing `*` in the last segment. A full matcher here would be a
137
+ * dependency and a surprise - the patterns clients send are `.env`,
138
+ * `.env.local` and `node_modules`.
139
+ */
140
+ const copy = async (from: string, to: string, pattern: string): Promise<void> => {
141
+ if (pattern.includes('..') || pattern.startsWith('/')) return;
142
+ const star = pattern.lastIndexOf('*');
143
+ if (star === -1) {
144
+ await cp(join(from, pattern), join(to, pattern), { recursive: true, errorOnExist: false });
145
+ return;
146
+ }
147
+ const at = pattern.lastIndexOf('/');
148
+ const dir = at === -1 ? '' : pattern.slice(0, at);
149
+ const leaf = at === -1 ? pattern : pattern.slice(at + 1);
150
+ if (leaf.indexOf('*') !== leaf.lastIndexOf('*')) return;
151
+ const [before, after] = leaf.split('*');
152
+ const entries = await readdir(join(from, dir)).catch(() => [] as string[]);
153
+ for (const entry of entries) {
154
+ if (!entry.startsWith(before ?? '') || !entry.endsWith(after ?? '')) continue;
155
+ const source = join(from, dir, entry);
156
+ // Directories as well as files: `node_modules` is the pattern that makes
157
+ // the difference between a worktree that builds and one that does not.
158
+ const what = await stat(source).catch(() => undefined);
159
+ if (!what) continue;
160
+ await cp(source, join(to, dir, entry), { recursive: what.isDirectory(), errorOnExist: false });
161
+ }
162
+ };