@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/src/changes.ts DELETED
@@ -1,629 +0,0 @@
1
- /** What git says a directory has changed, as a host's `ChangesetSource`. */
2
-
3
- import { execFile } from 'node:child_process';
4
- import { readFile, rm, writeFile } from 'node:fs/promises';
5
- import type {
6
- ChangesSummary, ChangesetFile, ChangesetOperation, ChangesetSource, ChangesetState,
7
- } from './types/changes.js';
8
-
9
- /**
10
- * How many lines a file has, for one git will not count.
11
- *
12
- * Capped, because this is a *summary* and a row saying 40,000 is worth no more
13
- * than one saying a lot - and reading a hundred-megabyte file to find that out
14
- * is the expensive way to learn nothing.
15
- */
16
- const lines = async (path: string): Promise<number> => {
17
- try {
18
- const text = await readFile(path, 'utf8');
19
- if (text === '') return 0;
20
- return text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
21
- }
22
- catch { return 0; }
23
- };
24
-
25
- /**
26
- * How many lines differ between two versions, without diffing them.
27
- *
28
- * A count, not a diff: the rows carry both sides and a client renders the
29
- * real thing from those. Computing a proper LCS here to fill in two numbers
30
- * would be the same work twice, once where nobody can see it.
31
- */
32
- const counted = (before: string, after: string): { added: number; removed: number } => {
33
- const was = before === '' ? [] : before.split('\n');
34
- const now = after === '' ? [] : after.split('\n');
35
- const shared = new Set(was);
36
- const added = now.filter((row) => !shared.has(row)).length;
37
- const kept_ = new Set(now);
38
- const removed = was.filter((row) => !kept_.has(row)).length;
39
- return { added, removed };
40
- };
41
-
42
- /** One `git` run, as text, or nothing when it would not run. */
43
- const git = (dir: string, args: string[]): Promise<string | undefined> =>
44
- new Promise((answer) => {
45
- execFile('git', ['-C', dir, ...args], { timeout: 5000, maxBuffer: 32 * 1024 * 1024 },
46
- (error, out) => answer(error ? undefined : out.toString()));
47
- });
48
-
49
- /**
50
- * One `git` run, with the failure kept.
51
- *
52
- * `git` above answers `undefined` for every kind of not-working, which is the
53
- * right shape for a question - a directory that is not a repository has no
54
- * diff, and why is not interesting. An *operation* is the other case: somebody
55
- * pressed a button, it did not work, and the only useful thing to say is what
56
- * git said.
57
- */
58
- const run = (dir: string, args: string[]): Promise<{ ok: boolean; out: string; err: string }> =>
59
- new Promise((answer) => {
60
- execFile('git', ['-C', dir, ...args], { timeout: 30000, maxBuffer: 32 * 1024 * 1024 },
61
- (error, out, errOut) => answer({
62
- ok: !error,
63
- out: out.toString(),
64
- err: errOut.toString().trim() || (error ? error.message : ''),
65
- }));
66
- });
67
-
68
- /** The path a `file://` URI names, or nothing for a URI that names none. */
69
- const pathIn = (dir: string, uri: string): string | undefined => {
70
- if (!uri.startsWith('file://')) return undefined;
71
- const path = uri.slice('file://'.length);
72
- // Inside the directory this changeset is about, and not merely starting with
73
- // its name: `/src/brb` must not reach `/src/brb_framework`.
74
- return path === dir || path.startsWith(`${dir}/`) ? path : undefined;
75
- };
76
-
77
- /**
78
- * The scheme for the side of an edit that is not on disk.
79
- *
80
- * `before` is what a file *used to be*, so no `file://` URI addresses it and
81
- * the filesystem port cannot serve it. This host mints its own URI and
82
- * resolves it itself, which is what `ChangesetSource.read` is for.
83
- */
84
- const BEFORE = 'ahp-git:';
85
- const beforeUri = (dir: string, path: string): string => `${BEFORE}//${dir}/${path}`;
86
-
87
- /**
88
- * The scheme for a side that was *captured* rather than read.
89
- *
90
- * A turn's changeset is what the files looked like on either side of that
91
- * turn, and neither side is on disk once a later turn has run. Both are held
92
- * here and served from here.
93
- */
94
- const CAPTURED = 'ahp-edit:';
95
-
96
- /**
97
- * Uncommitted changes, from `git status` and `git diff`.
98
- *
99
- * ```ts
100
- * createHost({ path, agents, changes: gitChanges() });
101
- * ```
102
- *
103
- * Only the `uncommitted` scope. `session` and `turn/<id>` are the other two
104
- * the protocol defines, and neither can be answered from git alone: git knows
105
- * what a working tree looks like, not which turn made it look that way.
106
- */
107
- /** One file, as a turn found it and as the turn left it. */
108
- interface Captured {
109
- before?: string;
110
- after?: string;
111
- }
112
-
113
- /**
114
- * The verbs this source offers, declared once.
115
- *
116
- * Ids and labels follow the reference host's where it has one - a client that
117
- * special-cases `commit` should find it spelled the way it expects - and every
118
- * one of them writes, which is what the host gates on.
119
- */
120
- const COMMIT: ChangesetOperation = {
121
- id: 'commit',
122
- label: 'Commit',
123
- description: 'Commit the working tree, including files git has not been told about',
124
- scopes: ['changeset'],
125
- icon: 'git-commit',
126
- group: 'commit',
127
- writes: true,
128
- };
129
-
130
- const DISCARD: ChangesetOperation = {
131
- id: 'discard',
132
- label: 'Discard Changes',
133
- description: 'Put this file back the way HEAD has it',
134
- scopes: ['resource'],
135
- confirmation: 'Discard the changes to this file? This cannot be undone.',
136
- icon: 'discard',
137
- writes: true,
138
- };
139
-
140
- /**
141
- * Undoing the agent rather than undoing the working tree.
142
- *
143
- * Separate from `discard` because the two put a file back to different places:
144
- * `discard` goes to HEAD, which is where a person's own uncommitted work goes
145
- * too, and this goes to the state the turn found the file in - which is only
146
- * knowable because both sides were captured as the tool ran.
147
- */
148
- const REVERT: ChangesetOperation = {
149
- id: 'revert',
150
- label: 'Revert This File',
151
- description: 'Put this file back the way the agent found it',
152
- scopes: ['resource'],
153
- confirmation: 'Put this file back the way the agent found it? Anything written since is lost.',
154
- icon: 'discard',
155
- writes: true,
156
- };
157
-
158
- export function gitChanges(): ChangesetSource {
159
- /**
160
- * What each turn changed, per session.
161
- *
162
- * `session -> turn -> path -> both sides`. Held rather than derived because
163
- * git cannot answer it: a working tree says what it looks like now, so a
164
- * turn asked about after two more have run would be handed their work too.
165
- */
166
- const seen = new Map<string, Map<string, Map<string, Captured>>>();
167
-
168
- /** The text held for a captured side, by the URI minted for it. */
169
- const kept = new Map<string, string>();
170
-
171
- /**
172
- * Which files somebody has ticked off, per changeset.
173
- *
174
- * A reader's bookkeeping rather than anything about the files: keyed by the
175
- * scope being read, because reviewing a turn is not reviewing the session
176
- * that contains it.
177
- */
178
- const reviewed = new Map<string, Set<string>>();
179
- const reviewKey = (session: string, scope: string): string => `${session}\u0000${scope}`;
180
-
181
- const capturedUri = (session: string, turn: string, path: string, phase: string): string =>
182
- `${CAPTURED}//${encodeURIComponent(session)}/${encodeURIComponent(turn)}/${phase}${path}`;
183
-
184
- /**
185
- * Fold a run of turns into one edit per file.
186
- *
187
- * The first `before` and the last `after`, which is what a range of turns
188
- * changed taken together: a file edited three times was found in one state
189
- * and left in another, and the two states in between are the middle of a
190
- * diff nobody asked for.
191
- */
192
- const fold = (turns: Iterable<Map<string, Captured>>): Map<string, Captured> => {
193
- const flat = new Map<string, Captured>();
194
- for (const files of turns) {
195
- for (const [path, sides] of files) {
196
- const already = flat.get(path);
197
- // The first `before` and the last `after`: a session's changeset is
198
- // the whole conversation as one edit, not the last turn of it.
199
- flat.set(path, {
200
- ...(already?.before !== undefined ? { before: already.before } : sides.before !== undefined ? { before: sides.before } : {}),
201
- ...(sides.after !== undefined ? { after: sides.after } : already?.after !== undefined ? { after: already.after } : {}),
202
- });
203
- }
204
- }
205
- return flat;
206
- };
207
-
208
- /** Every file a session has touched, in the order it touched them. */
209
- const across = (session: string): Map<string, Captured> =>
210
- fold([...(seen.get(session) ?? new Map()).values()]);
211
-
212
- /**
213
- * The turns from one to another, inclusive.
214
- *
215
- * Insertion order is turn order - a turn is first seen when its first tool
216
- * runs - so a range is a slice. Either end being unknown is a question about
217
- * something that did not happen, and answers nothing rather than everything.
218
- */
219
- const between = (session: string, from: string, to: string): Map<string, Captured> | undefined => {
220
- const turns = seen.get(session);
221
- if (!turns) return undefined;
222
- const order = [...turns.keys()];
223
- const start = order.indexOf(from);
224
- const end = order.indexOf(to);
225
- if (start < 0 || end < 0) return undefined;
226
- const [lo, hi] = start <= end ? [start, end] : [end, start];
227
- return fold(order.slice(lo, hi + 1).map((id) => turns.get(id) as Map<string, Captured>));
228
- };
229
-
230
- /**
231
- * Captured sides as the protocol's rows, with both sides fetchable.
232
- *
233
- * Minting a URI and remembering what is behind it are the same act, so they
234
- * are done in the same place: a row that names content nothing can resolve
235
- * is a row that opens onto an error.
236
- */
237
- const rowsOf = (session: string, turn: string, files: Map<string, Captured>): ChangesetFile[] => {
238
- const ticked = reviewed.get(reviewKey(session, turn));
239
- return [...files].map(([path, sides]) => {
240
- const uri = `file://${path}`;
241
- const before = capturedUri(session, turn, path, 'before');
242
- const after = capturedUri(session, turn, path, 'after');
243
- if (sides.before !== undefined) kept.set(before, sides.before);
244
- if (sides.after !== undefined) kept.set(after, sides.after);
245
- return {
246
- id: uri,
247
- edit: {
248
- // An empty `before` is a file the turn created, and the protocol
249
- // says a creation by leaving the side out rather than by a word.
250
- ...(sides.before ? { before: { uri, content: { uri: before } } } : {}),
251
- ...(sides.after !== undefined ? { after: { uri, content: { uri: after } } } : {}),
252
- diff: counted(sides.before ?? '', sides.after ?? ''),
253
- },
254
- // Absent is not-yet-reviewed, which is what the protocol says a
255
- // missing value means, so only a tick is worth sending.
256
- ...(ticked?.has(uri) ? { reviewed: true } : {}),
257
- };
258
- });
259
- };
260
-
261
- /**
262
- * The files one captured scope holds, by absolute path.
263
- *
264
- * The same branch `state` takes, wanted twice: an operation on a captured
265
- * scope needs the side the turn *found* the file in, and there is nowhere
266
- * else that survives - git only ever knows what the tree looks like now.
267
- */
268
- const capturedFor = (session: string, scope: string): Map<string, Captured> | undefined => {
269
- if (scope === 'session') return across(session);
270
- if (scope.startsWith('compare/')) {
271
- const [from, to] = scope.slice('compare/'.length).split('/');
272
- if (!from || !to) return undefined;
273
- return between(session, from, to);
274
- }
275
- if (scope.startsWith('turn/')) return seen.get(session)?.get(scope.slice('turn/'.length));
276
- return undefined;
277
- };
278
-
279
- /** The last answer per directory, so a catalogue of rows is not a hundred `git` runs. */
280
- const held = new Map<string, { files: ChangesetFile[]; summary: ChangesSummary }>();
281
-
282
- /**
283
- * What changed, as one pass over `git status` and one over `git diff`.
284
- *
285
- * `--porcelain=v1 -z` because a filename may contain anything a shell would
286
- * otherwise eat, newlines included; the NUL form is the only one that
287
- * survives a path somebody made on purpose.
288
- */
289
- const look = async (dir: string): Promise<{ files: ChangesetFile[]; summary: ChangesSummary } | undefined> => {
290
- const status = await git(dir, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
291
- if (status === undefined) return undefined;
292
-
293
- /** Line counts per path, for the files git can already diff. */
294
- const counts = new Map<string, { added: number; removed: number }>();
295
- const numstat = await git(dir, ['diff', '--numstat', '-z', 'HEAD']) ?? '';
296
- /*
297
- * `added<TAB>removed<TAB>path` per record, NUL between records.
298
- *
299
- * The tabs are *inside* a record and only the separator is NUL, which is
300
- * the whole point of `-z`: a path may contain a tab, and it may contain a
301
- * newline, and neither ends the record. A rename writes an empty path and
302
- * then two records of its own, old name and new.
303
- */
304
- const counted = numstat.split('\0');
305
- for (let i = 0; i < counted.length; i++) {
306
- const record = counted[i] as string;
307
- if (record === '') continue;
308
- const [rawAdded, rawRemoved, path] = record.split('\t');
309
- // `-` on both sides is a binary file, which has no lines to count.
310
- const added = Number(rawAdded);
311
- const removed = Number(rawRemoved);
312
- const named = path === '' || path === undefined
313
- // A rename: the two records after this one are the old and new names,
314
- // and it is the new one the working tree has.
315
- ? (i += 2, counted[i] as string | undefined)
316
- : path;
317
- if (!named) continue;
318
- counts.set(named, {
319
- added: Number.isFinite(added) ? added : 0,
320
- removed: Number.isFinite(removed) ? removed : 0,
321
- });
322
- }
323
-
324
- const files: ChangesetFile[] = [];
325
- const summary: ChangesSummary = { files: 0, additions: 0, deletions: 0 };
326
- const records = status.split('\0').filter((record) => record !== '');
327
- for (const record of records) {
328
- // `XY <path>`: two status letters, a space, then the path.
329
- const code = record.slice(0, 2);
330
- const path = record.slice(3);
331
- if (path === '') continue;
332
- const gone = code.includes('D');
333
- const fresh = code.includes('A') || code.includes('?');
334
- const uri = `file://${dir}/${path}`;
335
- // An untracked file is in no diff against HEAD, so git reports nothing
336
- // for it. Every line of it is an addition, which is what it is.
337
- const count = counts.get(path) ?? (fresh
338
- ? { added: await lines(`${dir}/${path}`), removed: 0 }
339
- : { added: 0, removed: 0 });
340
-
341
- files.push({
342
- id: uri,
343
- edit: {
344
- // Absent `before` is a creation and absent `after` a deletion. The
345
- // protocol says both by leaving a side out rather than by a word.
346
- ...(fresh ? {} : {
347
- before: { uri, content: { uri: beforeUri(dir, path) } },
348
- }),
349
- ...(gone ? {} : {
350
- after: { uri, content: { uri } },
351
- }),
352
- diff: { added: count.added, removed: count.removed },
353
- },
354
- });
355
- summary.files = (summary.files ?? 0) + 1;
356
- summary.additions = (summary.additions ?? 0) + count.added;
357
- summary.deletions = (summary.deletions ?? 0) + count.removed;
358
- }
359
- return { files, summary };
360
- };
361
-
362
- return {
363
- scopes: (dir, session) => {
364
- const scopes = held.has(dir)
365
- ? [{
366
- id: 'uncommitted',
367
- label: 'Uncommitted Changes',
368
- description: 'The working tree, against HEAD',
369
- changeKind: 'uncommitted',
370
- // Not reviewable: the working tree is whatever it is now, and a
371
- // tick against a file that something else may rewrite underneath it
372
- // is bookkeeping about a thing that has moved.
373
- }]
374
- : [];
375
- const turns = seen.get(session);
376
- if (!turns || turns.size === 0) return scopes;
377
- // The session's own first. A client showing one changeset shows the
378
- // first that needs no variable filling in, and what a *conversation*
379
- // changed is the one that belongs beside a conversation - the working
380
- // tree includes whatever else happened to the directory meanwhile.
381
- return [
382
- {
383
- id: 'session',
384
- label: 'This Session',
385
- description: 'Everything this conversation changed',
386
- changeKind: 'session',
387
- reviewable: true,
388
- },
389
- ...scopes,
390
- // Templates, which is how the protocol offers a scope that has to be
391
- // filled in: a client expands them from turns it can already see.
392
- {
393
- id: 'turn/{turnId}',
394
- label: 'This Turn',
395
- description: 'What one turn changed',
396
- changeKind: 'turn',
397
- reviewable: true,
398
- },
399
- {
400
- id: 'compare/{originalTurnId}/{modifiedTurnId}',
401
- label: 'Between Two Turns',
402
- description: 'What changed from one turn to another',
403
- changeKind: 'compare-turns',
404
- reviewable: true,
405
- },
406
- ];
407
- },
408
-
409
- state: async (dir, session, scope) => {
410
- if (scope === 'uncommitted') {
411
- const found = held.get(dir) ?? await look(dir);
412
- if (!found) return undefined;
413
- return { status: 'ready', files: found.files };
414
- }
415
- if (scope === 'session') {
416
- const files = across(session);
417
- if (files.size === 0) return { status: 'ready', files: [] };
418
- return { status: 'ready', files: rowsOf(session, 'session', files) };
419
- }
420
- if (scope.startsWith('compare/')) {
421
- const [from, to] = scope.slice('compare/'.length).split('/');
422
- if (!from || !to) return undefined;
423
- const files = between(session, from, to);
424
- if (!files) return undefined;
425
- return { status: 'ready', files: rowsOf(session, `compare/${from}/${to}`, files) };
426
- }
427
- if (scope.startsWith('turn/')) {
428
- const turn = scope.slice('turn/'.length);
429
- const files = seen.get(session)?.get(turn);
430
- // A turn nobody has heard of is not an empty changeset - it is a
431
- // question about something that did not happen.
432
- if (!files) return undefined;
433
- return { status: 'ready', files: rowsOf(session, turn, files) };
434
- }
435
- return undefined;
436
- },
437
-
438
- /*
439
- * Both sides of a file, captured as the tool runs.
440
- *
441
- * `before` is read as the tool is announced and `after` when its result
442
- * arrives. A file that did not exist reads as empty, which is what a
443
- * creation is.
444
- */
445
- observe: (dir, session, turnId, path, phase) => {
446
- void (async () => {
447
- const text = await readFile(path, 'utf8').catch(() => undefined);
448
- const turns = seen.get(session) ?? new Map<string, Map<string, Captured>>();
449
- seen.set(session, turns);
450
- const files = turns.get(turnId) ?? new Map<string, Captured>();
451
- turns.set(turnId, files);
452
- const sides = files.get(path) ?? {};
453
- // The first `before` wins: a turn that edits one file twice found it
454
- // in one state, and the second read is already its own work.
455
- if (phase === 'before' && sides.before === undefined) sides.before = text ?? '';
456
- if (phase === 'after') sides.after = text ?? '';
457
- files.set(path, sides);
458
-
459
- kept.set(capturedUri(session, turnId, path, phase), text ?? '');
460
-
461
- /*
462
- * A file that has changed again is not the file that was reviewed.
463
- *
464
- * The protocol makes this the server's job - it is the authority on
465
- * what changed - and says to reset explicitly rather than leave a tick
466
- * standing against content nobody has read. Only on `after`, because
467
- * `before` is the state a tick was about.
468
- */
469
- if (phase === 'after') {
470
- for (const scope of [turnId, 'session']) {
471
- reviewed.get(reviewKey(session, scope))?.delete(`file://${path}`);
472
- }
473
- }
474
- })().catch(() => {});
475
- },
476
-
477
- summary: (dir) => held.get(dir)?.summary,
478
-
479
- /*
480
- * The `before` side, out of git rather than off the disk.
481
- *
482
- * `git show HEAD:<path>` is what the file was at the last commit, which is
483
- * the only place that version still exists.
484
- */
485
- read: async (uri) => {
486
- // Captured sides are held, not fetched: neither is on disk any more.
487
- if (uri.startsWith(CAPTURED)) {
488
- const text = kept.get(uri);
489
- return text === undefined ? undefined : { data: text, encoding: 'utf-8' };
490
- }
491
- if (!uri.startsWith(BEFORE)) return undefined;
492
- const rest = uri.slice(`${BEFORE}//`.length);
493
- // The directory is the longest known one this URI starts with: a path
494
- // has slashes and so does a directory, and splitting on the first one
495
- // would name neither.
496
- const dir = [...held.keys()]
497
- .filter((known) => rest.startsWith(`${known}/`))
498
- .sort((a, b) => b.length - a.length)[0];
499
- if (dir === undefined) return undefined;
500
- const path = rest.slice(dir.length + 1);
501
- const data = await git(dir, ['show', `HEAD:${path}`]);
502
- // A file that is not in HEAD has no before, and empty is the truthful
503
- // answer for one: it did not exist.
504
- return { data: data ?? '', encoding: 'utf-8' };
505
- },
506
-
507
- /*
508
- * Ticked off, or cleared.
509
- *
510
- * The one thing a client may write here, and it writes nothing to disk:
511
- * it is a reader's note about a diff they are working through. Answers
512
- * whether it moved, so a client that ticks a file already ticked does not
513
- * make every other client redraw.
514
- */
515
- review: (_dir, session, scope, files, isReviewed) => {
516
- const key = reviewKey(session, scope);
517
- const ticked = reviewed.get(key) ?? new Set<string>();
518
- reviewed.set(key, ticked);
519
- let moved = false;
520
- for (const file of files) {
521
- if (isReviewed && !ticked.has(file)) { ticked.add(file); moved = true; }
522
- if (!isReviewed && ticked.delete(file)) moved = true;
523
- }
524
- return moved;
525
- },
526
-
527
- /**
528
- * What can be done to one scope, and only what can be done *now*.
529
- *
530
- * A working tree with nothing in it offers no commit, and a scope holding
531
- * no captured files offers no revert - an operation advertised against
532
- * nothing is a button that fails when pressed, and the protocol's whole
533
- * access model is that a client may only invoke what it was offered.
534
- */
535
- operations: (dir, session, scope) => {
536
- // Not a repository. `held` is only ever set for a directory `git status`
537
- // answered for, which is the same question as "is there git here".
538
- if (!held.has(dir)) return [];
539
- if (scope === 'uncommitted') return held.get(dir)?.summary?.files ? [COMMIT, DISCARD] : [];
540
- const files = capturedFor(session, scope);
541
- return files && files.size > 0 ? [REVERT] : [];
542
- },
543
-
544
- /*
545
- * Run one.
546
- *
547
- * Everything about *whether* this is allowed happened before the call: the
548
- * host checked the id against what `operations` offered for this scope,
549
- * checked the target against the operation's scopes, and checked that a
550
- * write grant is held. What is left is the doing, and saying what git said
551
- * when it did not work.
552
- */
553
- invoke: async ({ dir, session, scope, operationId, target, subject }) => {
554
- if (operationId === 'commit') {
555
- // `-A`, including files git has not been told about: the changeset this
556
- // was invoked on counted untracked files as changes, and committing
557
- // less than was listed would commit something other than what was
558
- // shown.
559
- const staged = await run(dir, ['add', '-A']);
560
- if (!staged.ok) throw new Error(`Could not stage: ${staged.err}`);
561
- // The session's own title, which is the sentence somebody already wrote
562
- // about this work. A generated one would need the agent, and running a
563
- // turn to commit a turn is a lot of machinery for a subject line.
564
- const line = (subject ?? '').split('\n')[0]?.trim();
565
- const message = line !== undefined && line !== '' ? line : 'Changes from an agent session';
566
- const done = await run(dir, ['commit', '-m', message]);
567
- if (!done.ok) throw new Error(`Could not commit: ${done.err || done.out.trim()}`);
568
- const at = (await git(dir, ['rev-parse', '--short', 'HEAD']))?.trim();
569
- return { message: at ? `Committed ${at}: ${message}` : `Committed: ${message}` };
570
- }
571
-
572
- const path = target?.resource === undefined ? undefined : pathIn(dir, target.resource);
573
- // Refused rather than clamped: a target outside this directory is a
574
- // client asking to write somewhere this changeset is not about.
575
- if (path === undefined) throw new Error('That file is not in this directory.');
576
- const named = path.slice(dir.length + 1);
577
-
578
- if (operationId === 'discard') {
579
- /*
580
- * Tracked and untracked are different undos.
581
- *
582
- * A file git knows goes back to HEAD; a file it does not was never
583
- * anywhere else, so putting it back means removing it. `restore` will
584
- * not do the second - it fails on a pathspec it has no record of - so
585
- * the failure is the signal to try the other one.
586
- */
587
- const back = await run(dir, ['restore', '--staged', '--worktree', '--source=HEAD', '--', path]);
588
- if (!back.ok) {
589
- const cleaned = await run(dir, ['clean', '-f', '--', path]);
590
- if (!cleaned.ok) throw new Error(`Could not discard: ${back.err || cleaned.err}`);
591
- }
592
- return { message: `Discarded ${named}` };
593
- }
594
-
595
- if (operationId === 'revert') {
596
- const sides = capturedFor(session, scope)?.get(path);
597
- if (!sides) throw new Error('This changeset does not hold that file.');
598
- /*
599
- * No `before` is a file the turn created, and putting a creation back
600
- * means it should not be there.
601
- *
602
- * Empty counts as none, which is the same reading `rowsOf` gives when
603
- * it decides whether to draw a `before` side at all - one rule, so a
604
- * row that shows as a creation reverts as one.
605
- */
606
- if (sides.before === undefined || sides.before === '') {
607
- await rm(path, { force: true });
608
- return { message: `Removed ${named}, which this changeset created` };
609
- }
610
- await writeFile(path, sides.before, 'utf8');
611
- return { message: `Reverted ${named}` };
612
- }
613
-
614
- throw new Error(`No operation called ${operationId}`);
615
- },
616
-
617
- refresh: async (dir) => {
618
- const found = await look(dir);
619
- const before = JSON.stringify(held.get(dir)?.summary ?? null);
620
- if (!found) {
621
- if (!held.has(dir)) return false;
622
- held.delete(dir);
623
- return true;
624
- }
625
- held.set(dir, found);
626
- return JSON.stringify(found.summary) !== before;
627
- },
628
- };
629
- }