@ccmsg/cli 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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. package/src/version.ts +8 -0
@@ -0,0 +1,421 @@
1
+ import {
2
+ closeSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ readSync,
9
+ renameSync,
10
+ statSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import type { Dirent } from "node:fs";
15
+ import { basename, dirname, join, relative } from "node:path";
16
+ import type {
17
+ DirEntry,
18
+ DirListArgs,
19
+ DirListResult,
20
+ FileCreateArgs,
21
+ FileCreateResult,
22
+ FileDeleteArgs,
23
+ FileDeleteResult,
24
+ FileEditArgs,
25
+ FileEditResult,
26
+ FileFindArgs,
27
+ FileFindHit,
28
+ FileFindResult,
29
+ FileReadArgs,
30
+ FileReadResult,
31
+ FileStatBatchArgs,
32
+ FileStatBatchResult,
33
+ FileStatEntry,
34
+ FileWriteArgs,
35
+ FileWriteResult,
36
+ Timestamp,
37
+ } from "@ccmsg/protocol";
38
+ import { type HandlerInput, OpError } from "../dispatch/index.ts";
39
+ import type { Containment, Located, Viewer } from "./containment.ts";
40
+
41
+ /** How much of a file `file_read` carries. Larger files are answered with their
42
+ * head and `truncated`, so one file can never cost the connection more than
43
+ * this however big it grew (DR-0008 §5). */
44
+ const READ_LIMIT = 512 * 1024;
45
+
46
+ /** How much of a file decides whether it is text. A NUL in the head is what
47
+ * separates a document from a binary here: enough to keep an image out of a
48
+ * text pane, and deliberately not a full content-type guess. */
49
+ const SNIFF = 8 * 1024;
50
+
51
+ /** What one walk may answer with, and how far it may look to find it. The
52
+ * second is what bounds a walk over a tree whose matches are few — without it a
53
+ * query that matches nothing is a walk of everything. */
54
+ const FIND_HITS = 200;
55
+ const FIND_VISITS = 20_000;
56
+
57
+ /** The eight ops that read and write files.
58
+ *
59
+ * None of them decides what may be reached: each turns its arguments into a
60
+ * located path, does the one thing it names, and states the result in the shape
61
+ * the kind implies. The decision is `Containment`'s, and what it needs of the
62
+ * caller — the role dispatch states for a `scope: "role"` op, and the session
63
+ * the connection speaks for — is handed over unread. */
64
+ export function fileHandlers(paths: Containment) {
65
+ const viewer = (input: HandlerInput): Viewer => ({ role: input.role, sid: input.identity?.sid });
66
+
67
+ return {
68
+ dir_list: (input: HandlerInput): DirListResult => {
69
+ const args = input.args as unknown as DirListArgs;
70
+ const at = paths.root(args, viewer(input));
71
+ const stat = existing(at);
72
+ if (!stat.isDirectory()) throw new OpError("not_found", `${args.path ?? ""} is not a folder`);
73
+ return { sid: args.sid, path: at.path, entries: entriesOf(at.real) };
74
+ },
75
+
76
+ file_read: (input: HandlerInput): FileReadResult => {
77
+ const args = input.args as unknown as FileReadArgs;
78
+ const at = paths.locate(args, viewer(input));
79
+ const stat = existing(at);
80
+ if (!stat.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
81
+ // As much as the answer may carry and no more: a file larger than the
82
+ // limit is answered from its head, so reading it whole would cost the
83
+ // instance the whole of a file whose size is what the limit exists to
84
+ // refuse.
85
+ const head = bytesOf(at.real, READ_LIMIT);
86
+ const binary = isBinary(head);
87
+ return {
88
+ sid: args.sid,
89
+ path: at.path,
90
+ size: stat.size,
91
+ truncated: stat.size > head.byteLength,
92
+ binary,
93
+ content: binary ? "" : head.toString("utf8"),
94
+ mtime_at: mtimeOf(stat),
95
+ };
96
+ },
97
+
98
+ file_write: (input: HandlerInput): FileWriteResult => {
99
+ const args = input.args as unknown as FileWriteArgs;
100
+ const at = paths.inbox(args.sid, args.path, viewer(input));
101
+ // The inbox takes new notes, so an existing name is refused rather than
102
+ // replaced; the folder itself is made, since a repository that has never
103
+ // had one is exactly where the first note goes (DR-0019 §2.1).
104
+ mkdirSync(dirname(at.real), { recursive: true });
105
+ create(at.real, args.content);
106
+ return { sid: args.sid, path: at.path };
107
+ },
108
+
109
+ file_create: (input: HandlerInput): FileCreateResult => {
110
+ const args = input.args as unknown as FileCreateArgs;
111
+ const at = paths.locate(args, viewer(input));
112
+ const parent = dirname(at.real);
113
+ if (!isDirectory(parent)) {
114
+ throw new OpError("not_found", `${args.path} has no folder to be created in`);
115
+ }
116
+ create(at.real, args.content);
117
+ return { sid: args.sid, path: at.path };
118
+ },
119
+
120
+ file_edit: (input: HandlerInput): FileEditResult => {
121
+ const args = input.args as unknown as FileEditArgs;
122
+ const at = paths.locate(args, viewer(input));
123
+ const stat = existing(at);
124
+ if (!stat.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
125
+ if (isBinary(bytesOf(at.real, SNIFF))) {
126
+ throw new OpError("not_a_text_file", `${args.path} holds binary content`);
127
+ }
128
+ if (mtimeOf(stat) !== args.expected_mtime_at || stat.size !== args.expected_size) {
129
+ throw new OpError("file_conflict", `${args.path} changed since it was read`);
130
+ }
131
+ replace(at.real, args.content);
132
+ const after = statSync(at.real);
133
+ return { sid: args.sid, path: at.path, size: after.size, mtime_at: mtimeOf(after) };
134
+ },
135
+
136
+ file_delete: (input: HandlerInput): FileDeleteResult => {
137
+ const args = input.args as unknown as FileDeleteArgs;
138
+ const at = paths.locate(args, viewer(input));
139
+ // What is unlinked is what is named, so this reads the name itself rather
140
+ // than what it resolves to: a symlink is refused as the wrong kind of
141
+ // thing instead of taking its target's answer. The resolved path is the
142
+ // one containment admitted and would answer for the target, which is the
143
+ // file a link inside the root could otherwise be pointed at.
144
+ const stat = lstatOf(at.named);
145
+ if (stat === undefined) throw new OpError("not_found", `${args.path} is not there`);
146
+ if (!stat.isFile()) {
147
+ throw new OpError("path_forbidden", `${args.path} is not a plain file`);
148
+ }
149
+ unlinkSync(at.named);
150
+ return { sid: args.sid, path: at.path };
151
+ },
152
+
153
+ file_find: (input: HandlerInput): FileFindResult => {
154
+ const args = input.args as unknown as FileFindArgs;
155
+ const at = paths.root(
156
+ { sid: args.sid, kind: args.kind, ...(args.root === undefined ? {} : { path: args.root }) },
157
+ viewer(input),
158
+ );
159
+ const terms = parseQuery(args.query);
160
+ // A query with nothing to include matches nothing rather than the whole
161
+ // tree, so a cleared search box costs no walk at all.
162
+ if (terms.include.length === 0) return { sid: args.sid, hits: [], truncated: false };
163
+ const walk = find(at, terms, args.respect_gitignore ?? true);
164
+ return { sid: args.sid, hits: walk.hits, truncated: walk.truncated };
165
+ },
166
+
167
+ file_stat_batch: (input: HandlerInput): FileStatBatchResult => {
168
+ const args = input.args as unknown as FileStatBatchArgs;
169
+ const results = args.paths.map((path): FileStatEntry | null => {
170
+ const at = paths.identify(args.sid, path, viewer(input));
171
+ if (at === undefined || !isFile(at.real)) return null;
172
+ return { kind: at.kind, path: at.path };
173
+ });
174
+ return { results };
175
+ },
176
+ };
177
+ }
178
+
179
+ /** The file a located path names, or the contract's word for "not there". */
180
+ function existing(at: Located) {
181
+ try {
182
+ return statSync(at.real);
183
+ } catch {
184
+ throw new OpError("not_found", `${at.path} is not there`);
185
+ }
186
+ }
187
+
188
+ function lstatOf(path: string) {
189
+ try {
190
+ return lstatSync(path);
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ }
195
+
196
+ function isFile(path: string): boolean {
197
+ try {
198
+ return statSync(path).isFile();
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ function isDirectory(path: string): boolean {
205
+ try {
206
+ return statSync(path).isDirectory();
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ /** Whole milliseconds, which is what the contract's timestamps are and what an
213
+ * edit compares its token against. */
214
+ function mtimeOf(stat: { mtimeMs: number }): Timestamp {
215
+ return Math.floor(stat.mtimeMs);
216
+ }
217
+
218
+ /** A file's leading bytes, at most `limit` of them. */
219
+ function bytesOf(path: string, limit: number): Buffer {
220
+ const fd = openSync(path, "r");
221
+ try {
222
+ const buffer = Buffer.alloc(limit);
223
+ const read = readSync(fd, buffer, 0, limit, 0);
224
+ return buffer.subarray(0, read);
225
+ } finally {
226
+ closeSync(fd);
227
+ }
228
+ }
229
+
230
+ function isBinary(bytes: Buffer): boolean {
231
+ return bytes.subarray(0, SNIFF).includes(0);
232
+ }
233
+
234
+ /** Write a file that must not be there yet. The exclusive open is what decides
235
+ * it: a check followed by a write would answer about the moment before. */
236
+ function create(path: string, content: string): void {
237
+ try {
238
+ writeFileSync(path, content, { flag: "wx" });
239
+ } catch (cause) {
240
+ if ((cause as NodeJS.ErrnoException).code === "EEXIST") {
241
+ throw new OpError("file_exists", `${basename(path)} is already there`);
242
+ }
243
+ throw cause;
244
+ }
245
+ }
246
+
247
+ /** Replace a file's content whole. The write lands beside it and is renamed
248
+ * over it, so a reader sees either the old file or the new one and never a
249
+ * half-written one. */
250
+ function replace(path: string, content: string): void {
251
+ const temporary = `${path}.ccmsg-${process.pid}-${Date.now()}`;
252
+ writeFileSync(temporary, content);
253
+ try {
254
+ renameSync(temporary, path);
255
+ } catch (cause) {
256
+ unlinkSync(temporary);
257
+ throw cause;
258
+ }
259
+ }
260
+
261
+ function entriesOf(dir: string): DirEntry[] {
262
+ return readdirSync(dir, { withFileTypes: true })
263
+ .map((entry): DirEntry => {
264
+ const type = entry.isSymbolicLink()
265
+ ? "symlink"
266
+ : entry.isDirectory()
267
+ ? "dir"
268
+ : entry.isFile()
269
+ ? "file"
270
+ : "other";
271
+ // A symlink is reported as itself, so what is stated about it is the link
272
+ // and never what it points at — including one pointing out of the root,
273
+ // which is listed here and refuses to resolve everywhere else.
274
+ const stat = type === "symlink" ? undefined : lstatOf(join(dir, entry.name));
275
+ return {
276
+ name: entry.name,
277
+ type,
278
+ ...(stat?.isFile() === true ? { size: stat.size } : {}),
279
+ ...(stat === undefined ? {} : { mtime_at: mtimeOf(stat) }),
280
+ };
281
+ })
282
+ .sort((a, b) => a.name.localeCompare(b.name));
283
+ }
284
+
285
+ interface Terms {
286
+ readonly include: readonly string[];
287
+ readonly exclude: readonly string[];
288
+ }
289
+
290
+ function parseQuery(query: string): Terms {
291
+ const include: string[] = [];
292
+ const exclude: string[] = [];
293
+ for (const word of query.split(/\s+/).filter((each) => each !== "")) {
294
+ if (word.startsWith("-")) {
295
+ const rest = word.slice(1);
296
+ if (rest !== "") exclude.push(rest.toLowerCase());
297
+ } else include.push(word.toLowerCase());
298
+ }
299
+ return { include, exclude };
300
+ }
301
+
302
+ function matches(path: string, terms: Terms): boolean {
303
+ const haystack = path.toLowerCase();
304
+ return (
305
+ terms.include.every((word) => haystack.includes(word)) &&
306
+ !terms.exclude.some((word) => haystack.includes(word))
307
+ );
308
+ }
309
+
310
+ /** Walk one subtree, answering the paths whose own spelling matches.
311
+ *
312
+ * Both caps are reported the same way: the hits are the ones found, and
313
+ * `truncated` says they are not the whole match set. Saying so is better than
314
+ * implying these are all. */
315
+ function find(at: Located, terms: Terms, respectGitignore: boolean) {
316
+ const hits: FileFindHit[] = [];
317
+ let visits = 0;
318
+ let truncated = false;
319
+
320
+ const walk = (dir: string, ignored: Ignores): void => {
321
+ if (truncated) return;
322
+ const here = respectGitignore ? ignored.descend(dir) : ignored;
323
+ let entries: Dirent[];
324
+ try {
325
+ entries = readdirSync(dir, { withFileTypes: true });
326
+ } catch {
327
+ // A folder that cannot be read contributes nothing, and a walk that
328
+ // stopped at one would answer less than it can.
329
+ return;
330
+ }
331
+ for (const entry of entries) {
332
+ if (++visits > FIND_VISITS) {
333
+ truncated = true;
334
+ return;
335
+ }
336
+ const full = join(dir, entry.name);
337
+ const isDir = entry.isDirectory();
338
+ if (respectGitignore && here.hides(entry.name, isDir)) continue;
339
+ const type = entry.isSymbolicLink() ? "symlink" : isDir ? "dir" : "file";
340
+ if (type !== "file" && type !== "dir" && type !== "symlink") continue;
341
+ const shown = at.kind === "contained" ? relative(at.real, full) : full;
342
+ if (matches(shown, terms)) {
343
+ hits.push({ path: shown, type });
344
+ if (hits.length >= FIND_HITS) {
345
+ truncated = true;
346
+ return;
347
+ }
348
+ }
349
+ // Only real directories are descended: a symlink is answered as itself,
350
+ // and following one would walk out of the root the walk is bounded by.
351
+ if (isDir && !entry.isSymbolicLink()) {
352
+ walk(full, here);
353
+ if (truncated) return;
354
+ }
355
+ }
356
+ };
357
+
358
+ walk(at.real, EMPTY_IGNORES);
359
+ return { hits, truncated };
360
+ }
361
+
362
+ /** What the repository's ignore rules hide, as far as a name-matching walk can
363
+ * read them.
364
+ *
365
+ * Design rationale: this reads `.gitignore` as literal names and simple globs
366
+ * and stops there — no negation, no anchored paths, no parent `.gitignore`
367
+ * outside the walked subtree. The rules exist here to keep vendored trees from
368
+ * pushing the real answer out of a capped reply, and a `node_modules/` line is
369
+ * what does that; implementing the whole format would buy accuracy on patterns
370
+ * that do not change which answer a person is looking for. Anything the rules
371
+ * do not hide is answered, so an unimplemented pattern shows a file rather than
372
+ * hiding one. */
373
+ interface Ignores {
374
+ hides(name: string, isDir: boolean): boolean;
375
+ descend(dir: string): Ignores;
376
+ }
377
+
378
+ const ALWAYS_HIDDEN = new Set([".git"]);
379
+
380
+ const EMPTY_IGNORES: Ignores = makeIgnores([]);
381
+
382
+ function makeIgnores(patterns: readonly RegExp[]): Ignores {
383
+ return {
384
+ hides(name, _isDir) {
385
+ if (ALWAYS_HIDDEN.has(name)) return true;
386
+ return patterns.some((pattern) => pattern.test(name));
387
+ },
388
+ descend(dir) {
389
+ const own = readIgnoreFile(join(dir, ".gitignore"));
390
+ return own.length === 0 ? makeIgnores(patterns) : makeIgnores([...patterns, ...own]);
391
+ },
392
+ };
393
+ }
394
+
395
+ function readIgnoreFile(file: string): RegExp[] {
396
+ let text: string;
397
+ try {
398
+ text = readFileSync(file, "utf8");
399
+ } catch {
400
+ return [];
401
+ }
402
+ return text
403
+ .split("\n")
404
+ .map((line) => line.trim())
405
+ .filter((line) => line !== "" && !line.startsWith("#") && !line.startsWith("!"))
406
+ .map((line) => line.replace(/\/+$/, ""))
407
+ .filter((line) => !line.includes("/"))
408
+ .map(globToRegExp);
409
+ }
410
+
411
+ function globToRegExp(pattern: string): RegExp {
412
+ const body = pattern
413
+ .split("")
414
+ .map((char) => (char === "*" ? "[^/]*" : char === "?" ? "[^/]" : escapeRegExp(char)))
415
+ .join("");
416
+ return new RegExp(`^${body}$`);
417
+ }
418
+
419
+ function escapeRegExp(char: string): string {
420
+ return /[\\^$.|?*+()[\]{}]/.test(char) ? `\\${char}` : char;
421
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ canonical,
3
+ Containment,
4
+ type DirArgs,
5
+ type Located,
6
+ type PathArgs,
7
+ type RootsSource,
8
+ sees,
9
+ type SessionRoots,
10
+ type Viewer,
11
+ within,
12
+ } from "./containment.ts";
13
+ export { fileHandlers } from "./files.ts";
14
+ export { isSandboxOrigin, sandboxCapabilities, SandboxGrants, sandboxHandlers } from "./sandbox.ts";
Binary file
@@ -0,0 +1,48 @@
1
+ /** What a harness lifecycle event tells the command it fires.
2
+ *
3
+ * The harness writes one JSON object to the command's standard input, and only
4
+ * the fields a greeting or a departure is made of are taken from it. The names
5
+ * are the harness's own (`session_id`, `cwd`, `transcript_path`, `reason`);
6
+ * they are read here and nowhere else, so the rest of the CLI speaks the
7
+ * contract's names. */
8
+ export interface HookEvent {
9
+ readonly sid?: string;
10
+ readonly cwd?: string;
11
+ readonly transcript_path?: string;
12
+ readonly reason?: string;
13
+ }
14
+
15
+ /** Read one such event, or nothing to go on.
16
+ *
17
+ * A hook runs beside a session rather than for a person, so nothing it is
18
+ * handed is an error: input that is absent, truncated or not an object leaves
19
+ * the command with whatever its own options gave it. What the command does
20
+ * with nothing is the command's decision, and for every command that reads one
21
+ * that decision is to stay quiet. */
22
+ export async function hookEvent(
23
+ read: () => Promise<string> = () => Bun.stdin.text(),
24
+ ): Promise<HookEvent> {
25
+ let parsed: unknown;
26
+ try {
27
+ parsed = JSON.parse(await read());
28
+ } catch {
29
+ return {};
30
+ }
31
+ if (typeof parsed !== "object" || parsed === null) return {};
32
+ const event = parsed as Record<string, unknown>;
33
+ return {
34
+ ...text(event, "session_id", "sid"),
35
+ ...text(event, "cwd", "cwd"),
36
+ ...text(event, "transcript_path", "transcript_path"),
37
+ ...text(event, "reason", "reason"),
38
+ };
39
+ }
40
+
41
+ function text(
42
+ event: Record<string, unknown>,
43
+ from: string,
44
+ as: keyof HookEvent,
45
+ ): Partial<HookEvent> {
46
+ const value = event[from];
47
+ return typeof value === "string" && value !== "" ? { [as]: value } : {};
48
+ }
@@ -0,0 +1,2 @@
1
+ export { type HookEvent, hookEvent } from "./hook.ts";
2
+ export { type Ask, type StatedMeta, statedMeta } from "./meta.ts";
@@ -0,0 +1,66 @@
1
+ import { basename, dirname } from "node:path";
2
+ import type { HelloArgs } from "@ccmsg/protocol";
3
+
4
+ /** What a session can say about itself when it greets: the contract's shared
5
+ * fields, each of them optional because a greeting states what it knows and
6
+ * the instance leaves the rest unknown (contract, `SessionMetaFields`). */
7
+ export type StatedMeta = Partial<
8
+ Pick<HelloArgs, "repo" | "ws" | "cwd" | "repo_root" | "branch" | "transcript_path" | "title">
9
+ >;
10
+
11
+ /** How a question is put to the version control the session works under. The
12
+ * answer is its output, or nothing when there is none to give. Named so a test
13
+ * can answer for a tree it never has to create. */
14
+ export type Ask = (args: readonly string[], cwd: string) => string | undefined;
15
+
16
+ const askGit: Ask = (args, cwd) => {
17
+ try {
18
+ const done = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
19
+ return done.exitCode === 0 ? done.stdout.toString() : undefined;
20
+ } catch {
21
+ // No git on this host, or a working directory that is not there to run it
22
+ // in. Both are "nothing can be said about a repository here", which is a
23
+ // thing a session is allowed to be.
24
+ return undefined;
25
+ }
26
+ };
27
+
28
+ /** What `git rev-parse` is asked, in one exchange: where the worktree begins,
29
+ * and what is checked out in it. The two answers arrive in this order, one per
30
+ * line. */
31
+ const WHERE = ["rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD"] as const;
32
+
33
+ /** The name `--abbrev-ref` gives a head that is on no branch. Read as "not on
34
+ * one" rather than as a branch called HEAD. */
35
+ const DETACHED = "HEAD";
36
+
37
+ /** Where this process is working, as a session would state it.
38
+ *
39
+ * `repo` and `ws` are display names for a layout, and the layout read here is
40
+ * the one the contract describes: a repository is a container and a workspace
41
+ * is a checkout inside it, which is what makes `repo_root` the place sibling
42
+ * workspaces are reachable from (contract, `SessionMetaFields.repo_root`). So
43
+ * the worktree git reports is the workspace, its parent is the repository, and
44
+ * a tree that is not one workspace among siblings simply reads as a repository
45
+ * with a single one.
46
+ *
47
+ * Outside a repository only the working directory is stated. Nothing is
48
+ * guessed from a path that git does not stand behind — a session that names
49
+ * neither is shown by its sid, which is a thing the instance already does.
50
+ *
51
+ * `transcript_path` and `title` are never derived: one is a file this process
52
+ * has no way to find and the other is the session's own name for itself. Both
53
+ * reach a greeting only by being handed in. */
54
+ export function statedMeta(cwd: string = process.cwd(), ask: Ask = askGit): StatedMeta {
55
+ const answer = ask(WHERE, cwd);
56
+ const [top, branch] = (answer ?? "").split("\n").map((line) => line.trim());
57
+ if (top === undefined || top === "") return { cwd };
58
+ const root = dirname(top);
59
+ return {
60
+ repo: basename(root),
61
+ ws: basename(top),
62
+ cwd,
63
+ repo_root: root,
64
+ ...(branch === undefined || branch === "" || branch === DETACHED ? {} : { branch }),
65
+ };
66
+ }