@naswerks/periscope 1.0.0 → 1.0.1

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 (187) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
  4. package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
  5. package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
  6. package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
  7. package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
  8. package/contracts/wire-vectors/bulk_request.full.json +21 -0
  9. package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
  10. package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
  11. package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
  12. package/contracts/wire-vectors/link_ack.cursors.json +25 -0
  13. package/contracts/wire-vectors/link_ack.empty.json +16 -0
  14. package/contracts/wire-vectors/link_bye.cause.json +16 -0
  15. package/contracts/wire-vectors/link_hello.empty.json +33 -0
  16. package/contracts/wire-vectors/link_hello.full.json +44 -0
  17. package/contracts/wire-vectors/link_ping.nonce.json +16 -0
  18. package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
  19. package/contracts/wire-vectors/link_pong.nonce.json +16 -0
  20. package/contracts/wire-vectors/link_welcome.empty.json +18 -0
  21. package/contracts/wire-vectors/link_welcome.full.json +25 -0
  22. package/contracts/wire-vectors/refused.empty.json +10 -0
  23. package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
  24. package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
  25. package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
  26. package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
  27. package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
  28. package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
  29. package/contracts/wire-vectors/refused.missing-member.json +10 -0
  30. package/contracts/wire-vectors/refused.not-json.json +10 -0
  31. package/contracts/wire-vectors/refused.over-limit.json +10 -0
  32. package/contracts/wire-vectors/refused.seq-zero.json +10 -0
  33. package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
  34. package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
  35. package/contracts/wire-vectors/repository_list.root.json +19 -0
  36. package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
  37. package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
  38. package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
  39. package/contracts/wire-vectors/repository_read.head.json +20 -0
  40. package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
  41. package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
  42. package/contracts/wire-vectors/session_cancel.bare.json +17 -0
  43. package/contracts/wire-vectors/session_configure.full.json +22 -0
  44. package/contracts/wire-vectors/session_configure.minimal.json +20 -0
  45. package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
  46. package/contracts/wire-vectors/session_list.request.json +18 -0
  47. package/contracts/wire-vectors/session_list_result.empty.json +21 -0
  48. package/contracts/wire-vectors/session_list_result.full.json +36 -0
  49. package/contracts/wire-vectors/session_new.full.json +72 -0
  50. package/contracts/wire-vectors/session_new.minimal.json +22 -0
  51. package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
  52. package/contracts/wire-vectors/session_prompt.empty.json +18 -0
  53. package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
  54. package/contracts/wire-vectors/session_prompt.text.json +18 -0
  55. package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
  56. package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
  57. package/contracts/wire-vectors/session_update.agent-message.json +26 -0
  58. package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
  59. package/contracts/wire-vectors/session_update.state-transition.json +41 -0
  60. package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
  61. package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
  62. package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
  63. package/contracts/wire-vectors/transcript_list.paged.json +19 -0
  64. package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
  65. package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
  66. package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
  67. package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
  68. package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
  69. package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
  70. package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
  71. package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
  72. package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
  73. package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
  74. package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
  75. package/contracts/wire-vectors/workspace_release.request.json +22 -0
  76. package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
  77. package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
  78. package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
  79. package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
  80. package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
  81. package/package.json +6 -1
  82. package/src/bin/command.ts +198 -0
  83. package/src/bin/config.ts +104 -0
  84. package/src/bin/login.ts +149 -0
  85. package/src/bin/main.ts +204 -0
  86. package/src/bin/pair.ts +246 -0
  87. package/src/bin/periscope.ts +3 -0
  88. package/src/bin/posture.ts +232 -0
  89. package/src/bin/reconfigure.ts +266 -0
  90. package/src/bin/serve.ts +615 -0
  91. package/src/bin/status.ts +61 -0
  92. package/src/bin/workspaces.ts +280 -0
  93. package/src/control/backoff.ts +40 -0
  94. package/src/control/codec.ts +557 -0
  95. package/src/control/credential.ts +33 -0
  96. package/src/control/frames.ts +1586 -0
  97. package/src/control/link-state.ts +67 -0
  98. package/src/control/link.ts +894 -0
  99. package/src/control/queue.ts +248 -0
  100. package/src/control/seq.ts +76 -0
  101. package/src/control/stream-routing.ts +216 -0
  102. package/src/control/stream.ts +149 -0
  103. package/src/core/async-queue.ts +63 -0
  104. package/src/core/failure.ts +61 -0
  105. package/src/core/index.ts +22 -0
  106. package/src/core/keyed-turns.ts +47 -0
  107. package/src/core/paths.ts +84 -0
  108. package/src/core/refusal.ts +430 -0
  109. package/src/core/result.ts +27 -0
  110. package/src/core/time.ts +34 -0
  111. package/src/core/vocab.ts +44 -0
  112. package/src/core/workspace-id.ts +11 -0
  113. package/src/gate/command.ts +617 -0
  114. package/src/gate/decision.ts +173 -0
  115. package/src/gate/escalate.ts +164 -0
  116. package/src/gate/gate.ts +635 -0
  117. package/src/gate/index.ts +53 -0
  118. package/src/gate/jail.ts +210 -0
  119. package/src/gate/local.ts +115 -0
  120. package/src/gate/outcome.ts +164 -0
  121. package/src/gate/shell.ts +295 -0
  122. package/src/host/agent-process.ts +694 -0
  123. package/src/host/bulk-post.ts +166 -0
  124. package/src/host/claude-transcripts.ts +404 -0
  125. package/src/host/config-file.ts +246 -0
  126. package/src/host/git-facts.ts +119 -0
  127. package/src/host/hooks.ts +102 -0
  128. package/src/host/host.ts +1901 -0
  129. package/src/host/index.ts +92 -0
  130. package/src/host/link-state-file.ts +79 -0
  131. package/src/host/loopback.ts +177 -0
  132. package/src/host/machine.ts +27 -0
  133. package/src/host/mcp-server.ts +66 -0
  134. package/src/host/package-facts.ts +25 -0
  135. package/src/host/paired-credential-store.ts +140 -0
  136. package/src/host/paths.ts +175 -0
  137. package/src/host/repository-read.ts +245 -0
  138. package/src/host/session-store.ts +133 -0
  139. package/src/host/sign-in.ts +352 -0
  140. package/src/host/telemetry.ts +105 -0
  141. package/src/host/token-cache.ts +214 -0
  142. package/src/host/transcript-fs.ts +110 -0
  143. package/src/host/wire-request.ts +285 -0
  144. package/src/host/workspace-fs.ts +90 -0
  145. package/src/host/workspace-trust.ts +92 -0
  146. package/src/identity/authorize.ts +127 -0
  147. package/src/identity/config.ts +227 -0
  148. package/src/identity/credential.ts +176 -0
  149. package/src/identity/device-code.ts +217 -0
  150. package/src/identity/index.ts +84 -0
  151. package/src/identity/mode.ts +161 -0
  152. package/src/identity/paired-credential.ts +89 -0
  153. package/src/identity/pkce.ts +113 -0
  154. package/src/identity/store.ts +98 -0
  155. package/src/identity/token.ts +203 -0
  156. package/src/index.ts +140 -0
  157. package/src/mcp/descriptor.ts +75 -0
  158. package/src/mcp/index.ts +23 -0
  159. package/src/mcp/schema.ts +214 -0
  160. package/src/mcp/server.ts +180 -0
  161. package/src/persistence/entry.ts +142 -0
  162. package/src/persistence/index.ts +47 -0
  163. package/src/persistence/key.ts +77 -0
  164. package/src/persistence/mirror.ts +118 -0
  165. package/src/persistence/receipt.ts +182 -0
  166. package/src/persistence/retention.ts +118 -0
  167. package/src/persistence/store.ts +183 -0
  168. package/src/persistence/transition-log.ts +176 -0
  169. package/src/protocol.ts +215 -0
  170. package/src/sessions/index.ts +27 -0
  171. package/src/sessions/registry.ts +392 -0
  172. package/src/sessions/session.ts +440 -0
  173. package/src/sessions/spawn-env.ts +318 -0
  174. package/src/state/coverage.ts +360 -0
  175. package/src/state/index.ts +58 -0
  176. package/src/state/machine.ts +359 -0
  177. package/src/state/model.ts +417 -0
  178. package/src/state/observer.ts +499 -0
  179. package/src/state/reporter.ts +60 -0
  180. package/src/state/store.ts +157 -0
  181. package/src/telemetry/index.ts +10 -0
  182. package/src/telemetry/usage.ts +117 -0
  183. package/src/workspace/git-worktree.ts +551 -0
  184. package/src/workspace/index.ts +17 -0
  185. package/src/workspace/plain-dir.ts +132 -0
  186. package/src/workspace/provider.ts +159 -0
  187. package/src/workspace/worktree-porcelain.ts +118 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The config file: the CLI as a first-class writer, so a library user with no UI is not env-only.
3
+ *
4
+ * It lives beside the credential files on purpose. `periscopeCredentialDir(env)` is the single
5
+ * source both the token cache's location and the gate's protected set derive from, so a config file
6
+ * written under it is protected from the sessions this host runs by having been put there — not by
7
+ * somebody remembering to add a line. (Nothing in it is secret today; the property costs nothing
8
+ * and the alternative is a second directory with a second protection question.)
9
+ *
10
+ * The environment still wins, per key. The file is a fallback for an absence: a value here is
11
+ * consulted only when the same variable is unset or empty in the process environment, so a
12
+ * deployment that sets env vars is configured by them alone — and there is
13
+ * never a precedence question to look up, because a stated env var makes the file's value
14
+ * unreachable. `withConfigFallback` is the one place that rule is implemented.
15
+ *
16
+ * The key set is closed, on both the write and the read. An unknown key written is refused naming
17
+ * the legal set (a typo'd key that "worked" would configure nothing, silently). An unknown key
18
+ * read is a problem too, for the same reason from the other side: this host wrote none, so one in
19
+ * the file is a hand-edit that does nothing, the misconfiguration nobody finds. And
20
+ * `PERISCOPE_CONFIG_DIR` is deliberately not settable here: it is what says where this file is,
21
+ * and a file that could move itself is a bootstrap circle.
22
+ *
23
+ * Sync I/O throughout, deliberately: `serve()` stays synchronous (its own docblock says why), and a
24
+ * one-shot read at boot plus a one-shot write per `config` invocation need no event loop.
25
+ */
26
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+
28
+ import { join, periscopeCredentialDir } from './paths.js';
29
+
30
+ /**
31
+ * Every key the config file may carry — the environment variables the composition root reads,
32
+ * minus `PERISCOPE_CONFIG_DIR` (the bootstrap exclusion above).
33
+ */
34
+ export const CONFIG_KEYS = [
35
+ 'PERISCOPE_CONTROLLER_URL',
36
+ 'PERISCOPE_DECISION_URL',
37
+ 'PERISCOPE_HOST_ID',
38
+ 'PERISCOPE_WORKSPACE_ROOT',
39
+ 'PERISCOPE_REPOSITORY_ROOT',
40
+ 'PERISCOPE_BRANCH_SCHEME',
41
+ 'PERISCOPE_WORKSPACE_KEY',
42
+ 'PERISCOPE_AGENT_HOME',
43
+ ] as const;
44
+
45
+ export type ConfigKey = (typeof CONFIG_KEYS)[number];
46
+
47
+ /**
48
+ * The keys a controller may set over the wire (`host_configure`): where this machine's things are,
49
+ * and where its control plane is. The two URLs are written to the file and never applied to
50
+ * the live link: the host dials what it dialled until its next start, and names the keys as pending
51
+ * in its answer and in every hello until then. Never the host id (a paired credential outranks it
52
+ * anyway) or the default workspace key (a start-up posture, screened at boot).
53
+ */
54
+ export const WIRE_CONFIGURABLE_KEYS = [
55
+ 'PERISCOPE_WORKSPACE_ROOT',
56
+ 'PERISCOPE_REPOSITORY_ROOT',
57
+ 'PERISCOPE_BRANCH_SCHEME',
58
+ 'PERISCOPE_AGENT_HOME',
59
+ 'PERISCOPE_CONTROLLER_URL',
60
+ 'PERISCOPE_DECISION_URL',
61
+ ] as const satisfies readonly ConfigKey[];
62
+
63
+ export type WireConfigurableKey = (typeof WIRE_CONFIGURABLE_KEYS)[number];
64
+
65
+ /** The wire-settable keys that take effect only at the next start: the control-plane addresses. */
66
+ export const RESTART_KEYS = [
67
+ 'PERISCOPE_CONTROLLER_URL',
68
+ 'PERISCOPE_DECISION_URL',
69
+ ] as const satisfies readonly WireConfigurableKey[];
70
+
71
+ export function isWireConfigurableKey(value: string): value is WireConfigurableKey {
72
+ return (WIRE_CONFIGURABLE_KEYS as readonly string[]).includes(value);
73
+ }
74
+
75
+ export function isConfigKey(value: string): value is ConfigKey {
76
+ return (CONFIG_KEYS as readonly string[]).includes(value);
77
+ }
78
+
79
+ /** Where the config file lives, or null when there is nowhere to keep one (no home, no override). */
80
+ export function configFilePath(env: NodeJS.ProcessEnv = process.env): string | null {
81
+ const dir = periscopeCredentialDir(env);
82
+ return dir === null ? null : join(dir, 'config.json');
83
+ }
84
+
85
+ /** What a read found: the values, or the named problem that makes them unusable. */
86
+ export interface ConfigFileReading {
87
+ readonly values: Readonly<Record<string, string>>;
88
+ /** Null when the file is usable (a missing file is usable: it reads as empty). */
89
+ readonly problem: string | null;
90
+ }
91
+
92
+ /**
93
+ * Read the config file. A MISSING file is `{}` — the ordinary state of every machine that has
94
+ * never run `periscope config`. A file that exists and cannot be used is a PROBLEM, never a silent
95
+ * empty: falling back would make a corrupt file behave like a deliberate absence, which is the
96
+ * exact ambiguity the paired credential's fatal-on-corrupt posture exists to end.
97
+ */
98
+ export function readConfigFile(env: NodeJS.ProcessEnv = process.env): ConfigFileReading {
99
+ const path = configFilePath(env);
100
+ if (path === null) return { values: {}, problem: null };
101
+
102
+ let raw: string;
103
+ try {
104
+ raw = readFileSync(path, 'utf8');
105
+ } catch (error) {
106
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { values: {}, problem: null };
107
+ return {
108
+ values: {},
109
+ problem: `the config file at ${path} exists and cannot be read: ${describe(error)}`,
110
+ };
111
+ }
112
+
113
+ let parsed: unknown;
114
+ try {
115
+ parsed = JSON.parse(raw);
116
+ } catch (error) {
117
+ return {
118
+ values: {},
119
+ problem: `the config file at ${path} is not JSON: ${describe(error)}`,
120
+ };
121
+ }
122
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
123
+ return {
124
+ values: {},
125
+ problem: `the config file at ${path} must hold one JSON object of string values`,
126
+ };
127
+ }
128
+
129
+ const values: Record<string, string> = {};
130
+ for (const [key, value] of Object.entries(parsed)) {
131
+ if (!isConfigKey(key)) {
132
+ return {
133
+ values: {},
134
+ problem:
135
+ `the config file at ${path} carries an unknown key '${key}' — this host reads only ` +
136
+ `${CONFIG_KEYS.join(', ')}, so the entry would configure nothing`,
137
+ };
138
+ }
139
+ if (typeof value !== 'string') {
140
+ return {
141
+ values: {},
142
+ problem: `the config file at ${path} carries a non-string value for '${key}'`,
143
+ };
144
+ }
145
+ values[key] = value;
146
+ }
147
+ return { values, problem: null };
148
+ }
149
+
150
+ /**
151
+ * Write one entry, creating the directory and file as needed. Returns null, or the named problem.
152
+ * Refuses an unknown key BEFORE touching the disk — see the closed-set rule in the header.
153
+ */
154
+ export function writeConfigEntry(env: NodeJS.ProcessEnv, key: string, value: string): string | null {
155
+ if (!isConfigKey(key)) {
156
+ return `'${key}' is not a config key this host reads — the legal keys are ${CONFIG_KEYS.join(', ')}`;
157
+ }
158
+ const path = configFilePath(env);
159
+ if (path === null) {
160
+ return 'there is nowhere to keep a config file — no home directory and no PERISCOPE_CONFIG_DIR';
161
+ }
162
+ const existing = readConfigFile(env);
163
+ if (existing.problem !== null) {
164
+ // Never merged-over: writing "around" a corrupt file destroys whatever the operator hand-wrote.
165
+ return `${existing.problem} — fix or remove it before writing`;
166
+ }
167
+ try {
168
+ const dir = path.slice(0, path.lastIndexOf('/'));
169
+ mkdirSync(dir, { recursive: true });
170
+ const next = { ...existing.values, [key]: value };
171
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
172
+ return null;
173
+ } catch (error) {
174
+ return `the config file at ${path} could not be written: ${describe(error)}`;
175
+ }
176
+ }
177
+
178
+ /** One entry of a multi-key write: a value to set, or null to remove the key. */
179
+ export interface ConfigEntryWrite {
180
+ readonly key: string;
181
+ readonly value: string | null;
182
+ }
183
+
184
+ /**
185
+ * Write several entries as one read-modify-write, creating the directory and file as needed.
186
+ * Returns null, or the named problem. Every key is screened BEFORE the disk is touched, so a set
187
+ * with one bad key writes nothing; a null value removes its key. The same corrupt-file posture as
188
+ * the single-entry write: never merged over.
189
+ */
190
+ export function writeConfigEntries(
191
+ env: NodeJS.ProcessEnv,
192
+ entries: readonly ConfigEntryWrite[],
193
+ ): string | null {
194
+ for (const entry of entries) {
195
+ if (!isConfigKey(entry.key)) {
196
+ return `'${entry.key}' is not a config key this host reads — the legal keys are ${CONFIG_KEYS.join(', ')}`;
197
+ }
198
+ }
199
+ const path = configFilePath(env);
200
+ if (path === null) {
201
+ return 'there is nowhere to keep a config file — no home directory and no PERISCOPE_CONFIG_DIR';
202
+ }
203
+ const existing = readConfigFile(env);
204
+ if (existing.problem !== null) {
205
+ return `${existing.problem} — fix or remove it before writing`;
206
+ }
207
+ try {
208
+ const dir = path.slice(0, path.lastIndexOf('/'));
209
+ mkdirSync(dir, { recursive: true });
210
+ const next: Record<string, string> = { ...existing.values };
211
+ for (const entry of entries) {
212
+ if (entry.value === null) delete next[entry.key];
213
+ else next[entry.key] = entry.value;
214
+ }
215
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
216
+ return null;
217
+ } catch (error) {
218
+ return `the config file at ${path} could not be written: ${describe(error)}`;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The environment, with file values FILLING ABSENCES — the one implementation of "env wins".
224
+ *
225
+ * Returns a NEW plain object; the process environment is never mutated. Only the closed key set is
226
+ * consulted, so nothing outside it (identity configuration, the config dir itself) can arrive from
227
+ * a file. Empty-string env values count as absent, matching how the composition root reads them.
228
+ */
229
+ export function withConfigFallback(
230
+ env: NodeJS.ProcessEnv,
231
+ values: Readonly<Record<string, string>>,
232
+ ): NodeJS.ProcessEnv {
233
+ const merged: NodeJS.ProcessEnv = { ...env };
234
+ for (const key of CONFIG_KEYS) {
235
+ const fromEnv = env[key];
236
+ const fromFile = values[key];
237
+ if ((fromEnv === undefined || fromEnv === '') && fromFile !== undefined && fromFile !== '') {
238
+ merged[key] = fromFile;
239
+ }
240
+ }
241
+ return merged;
242
+ }
243
+
244
+ function describe(error: unknown): string {
245
+ return error instanceof Error ? error.message : String(error);
246
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Which worktree and which branch a directory sits in: the "where" half of every transition.
3
+ *
4
+ * Why this is read at all: a trace whose location is a bare path answers "where" with something
5
+ * a reader still has to go and look up. Worktree and branch are what makes a transition legible on
6
+ * its own, which is the whole bar the state model is held to.
7
+ *
8
+ * It reads; it never provisions. No `git` subprocess, no writes, no repository creation, just
9
+ * two small file reads. Creating and managing worktrees is a different job and stays one.
10
+ *
11
+ * `.git` is often a file, not a directory, and that is the trap this file exists around.
12
+ * In a linked worktree, `.git` is a text file holding `gitdir: <path>` and pointing at a directory
13
+ * under the main repository's `.git/worktrees/`. A walk-up that looks for a `.git` directory finds
14
+ * nothing there and reports "not a repository", which is wrong, silently, in exactly the
15
+ * environment agents are most often given.
16
+ *
17
+ * Every failure is a named null, never an exception and never an empty string. Three ordinary
18
+ * situations produce no branch: the directory is not in a repository at all (normal — a plain
19
+ * working directory is a legitimate place to run), HEAD is detached (there IS no branch name), or
20
+ * HEAD is unreadable. An empty string would make all three indistinguishable from each other and
21
+ * from a branch literally named "".
22
+ */
23
+ import { readFileSync, statSync } from 'node:fs';
24
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
25
+
26
+ import type { TransitionWhere } from '../state/model.js';
27
+
28
+ /** How far up to walk. A repository root further than this from cwd is not a case worth serving. */
29
+ const MAX_DEPTH = 64;
30
+
31
+ /** A `.git` entry found on the way up: the worktree that holds it and the git directory it names. */
32
+ interface GitLocation {
33
+ readonly worktree: string;
34
+ readonly gitDir: string;
35
+ }
36
+
37
+ /**
38
+ * The `where` for a directory. Never throws — the caller stamps this onto every transition, so a
39
+ * throw here would take out the state record along with the fact it was decorating.
40
+ */
41
+ export function readWhere(cwd: string): TransitionWhere {
42
+ const located = locateGit(cwd);
43
+ if (located === null) {
44
+ return { cwd, worktree: null, branch: null, unknownReason: 'not inside a git repository' };
45
+ }
46
+
47
+ const head = readHead(located.gitDir);
48
+ return {
49
+ cwd,
50
+ worktree: located.worktree,
51
+ branch: head.branch,
52
+ unknownReason: head.reason,
53
+ };
54
+ }
55
+
56
+ /** Walk up from `start` for a `.git` entry, accepting both the directory and the pointer-file form. */
57
+ function locateGit(start: string): GitLocation | null {
58
+ let current = resolve(start);
59
+
60
+ for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
61
+ const marker = join(current, '.git');
62
+ const kind = entryKind(marker);
63
+
64
+ if (kind === 'dir') return { worktree: current, gitDir: marker };
65
+ if (kind === 'file') {
66
+ const pointed = readGitDirPointer(marker, current);
67
+ // A `.git` file that does not parse is still a `.git` file: this IS the worktree root, and
68
+ // saying so with an unreadable HEAD beats walking past it and reporting "not a repository".
69
+ return { worktree: current, gitDir: pointed ?? marker };
70
+ }
71
+
72
+ const parent = dirname(current);
73
+ if (parent === current) return null;
74
+ current = parent;
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ function entryKind(path: string): 'dir' | 'file' | 'absent' {
81
+ try {
82
+ const stats = statSync(path);
83
+ return stats.isDirectory() ? 'dir' : 'file';
84
+ } catch {
85
+ return 'absent';
86
+ }
87
+ }
88
+
89
+ /** `gitdir: <path>` out of a linked worktree's `.git` file. Relative paths resolve against it. */
90
+ function readGitDirPointer(marker: string, worktree: string): string | null {
91
+ try {
92
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(marker, 'utf8'));
93
+ const target = pointer?.[1]?.trim();
94
+ if (target === undefined || target.length === 0) return null;
95
+ return isAbsolute(target) ? target : resolve(worktree, target);
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** The branch, or which of the ordinary situations means there is not one. */
102
+ function readHead(gitDir: string): { branch: string | null; reason: string | null } {
103
+ let raw: string;
104
+ try {
105
+ raw = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
106
+ } catch {
107
+ return { branch: null, reason: 'HEAD could not be read' };
108
+ }
109
+
110
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(raw);
111
+ if (ref?.[1] !== undefined) return { branch: ref[1].trim(), reason: null };
112
+
113
+ // A bare object id is a detached HEAD: there genuinely is no branch, which is different from
114
+ // failing to find one, and a reader deciding whether a run is on the branch it should be needs
115
+ // to be able to tell those apart.
116
+ if (/^[0-9a-f]{40}$/i.test(raw)) return { branch: null, reason: 'HEAD is detached' };
117
+
118
+ return { branch: null, reason: `HEAD is in an unrecognised form: ${raw.slice(0, 40)}` };
119
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Turns the coverage table into the `options.hooks` object the SDK actually takes.
3
+ *
4
+ * Registration is derived from the table, not written beside it. The list of events registered
5
+ * here is the list `coverage.ts` marks `wired`, so the table cannot claim an event is wired while
6
+ * the wiring quietly lacks it. A table that can disagree with the code is a document that will.
7
+ *
8
+ * Every handler is wrapped, because a throwing hook is fail-open. A hook that throws is treated
9
+ * by the CLI as absent rather than as a refusal (observed behaviour, not an assumption). For an
10
+ * observer that would mean a lost transition and nothing else, which is precisely the silent loss
11
+ * this model exists to prevent, so the wrapper is here and not left to the caller.
12
+ *
13
+ * This handler never decides anything. It returns an empty output on every path, including the
14
+ * failure path. `HookCallbackMatcher.hooks` is an array and the SDK runs every entry, so a
15
+ * permission-decision handler registers on the same event alongside this one; neither has to know
16
+ * about the other, and observation cannot accidentally become authorization.
17
+ */
18
+ import type { HookCallbackMatcher, HookInput, HookJSONOutput, HookRegistrations } from './agent-process.js';
19
+ import type { SessionObserver } from '../state/observer.js';
20
+ import { HOOK_COVERAGE } from '../state/coverage.js';
21
+ import { HOOK_EVENTS } from '../state/model.js';
22
+
23
+ /** Told about a handler that threw, so a fail-open hook is never a silent one. */
24
+ export type HookFailureListener = (failure: { event: string; error: unknown }) => void;
25
+
26
+ export interface ObservationHookOptions {
27
+ readonly observer: SessionObserver;
28
+ readonly onHandlerFailure?: HookFailureListener;
29
+ }
30
+
31
+ /** Every event the table marks `wired`, in the SDK's own order. */
32
+ export function wiredHookEvents(): readonly string[] {
33
+ return HOOK_EVENTS.filter((event) => HOOK_COVERAGE[event].handling === 'wired');
34
+ }
35
+
36
+ /**
37
+ * The `hooks` object for a session, registering exactly the wired events.
38
+ *
39
+ * No matcher is set: a matcher filters by tool name, and this observes every tool. The absence is
40
+ * deliberate rather than an omission.
41
+ */
42
+ export function observationHooks(options: ObservationHookOptions): HookRegistrations {
43
+ const handler = (input: HookInput): Promise<HookJSONOutput> => {
44
+ try {
45
+ // The results are not read here. A transition the machine refuses is reported on
46
+ // `machine.onRejected` and counted in `rejectedCount`, the one channel every refused record
47
+ // takes whatever lane produced it; reporting it again from this handler would count one
48
+ // refusal twice. The observer builds a request only for an event it names, so an event this
49
+ // package does not know records nothing rather than producing a refusal to route.
50
+ options.observer.observeHook(input);
51
+ } catch (error) {
52
+ // A throw here would make the CLI treat the hook as absent, which is the fail-open hole. It
53
+ // is caught, reported, and never rethrown; losing one observation loudly beats losing the
54
+ // handler entirely and silently. The listener is guarded too: a reporter that throws must
55
+ // not reopen the hole it exists to report.
56
+ try {
57
+ options.onHandlerFailure?.({ event: input.hook_event_name, error });
58
+ } catch {
59
+ // Nothing further can be reported; the handler still answers.
60
+ }
61
+ }
62
+ return Promise.resolve({});
63
+ };
64
+
65
+ // One matcher object per event. A shared instance would let a `timeout` or `matcher` set on one
66
+ // event's entry apply to every event.
67
+ const registrations: HookRegistrations = {};
68
+ for (const event of HOOK_EVENTS) {
69
+ if (HOOK_COVERAGE[event].handling !== 'wired') continue;
70
+ const matcher: HookCallbackMatcher = { hooks: [handler] };
71
+ registrations[event] = [matcher];
72
+ }
73
+ return registrations;
74
+ }
75
+
76
+ /**
77
+ * Combine independent hook registrations, concatenating the matchers per event.
78
+ *
79
+ * This is the seam that keeps observation and authorization apart. A permission decision and a
80
+ * state record answer different questions, have different consumers and fail differently, and the
81
+ * failure this package is built against is exactly what happens when they share a guard: the
82
+ * control concern wins, and the observability loss is silent. Two matchers on one event, merged
83
+ * here, means neither can suppress the other because neither knows the other exists.
84
+ *
85
+ * "Earlier arguments run first" is true of dispatch and false of completion. Handlers on one
86
+ * event have their synchronous prologues run in array order, and are then awaited concurrently
87
+ * (observed behaviour, not an assumption). So a handler may rely on an earlier one having started
88
+ * and must never rely on it having finished: anything order-dependent belongs before the first
89
+ * `await`. (The
90
+ * gate's permission entry opens from its hold timer, not in its prologue, so it depends on no
91
+ * registration order; it registers second by convention.)
92
+ */
93
+ export function mergeHooks(...registrations: readonly HookRegistrations[]): HookRegistrations {
94
+ const merged: HookRegistrations = {};
95
+ for (const registration of registrations) {
96
+ for (const [event, matchers] of Object.entries(registration)) {
97
+ const key = event as keyof HookRegistrations;
98
+ merged[key] = [...(merged[key] ?? []), ...(matchers ?? [])];
99
+ }
100
+ }
101
+ return merged;
102
+ }