@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,430 @@
1
+ /**
2
+ * The closed vocabulary of reasons this package refuses something.
3
+ *
4
+ * A degrade is a named outcome. Nothing here returns a bare `false`, an empty string, or a silent
5
+ * skip: a caller that cannot proceed learns which of these it hit, and the set is closed so a
6
+ * reader can enumerate every way a call can fail without reading every call site.
7
+ */
8
+ export const REFUSAL_REASONS = [
9
+ // Codec — the wire edge.
10
+ 'frame-not-json',
11
+ 'frame-malformed',
12
+ 'frame-too-large',
13
+
14
+ // Sequencing.
15
+ 'seq-gap',
16
+ 'seq-regressed',
17
+
18
+ // The outbound link.
19
+ // link-write-deferred The controller's own send outcome: the frame was validated,
20
+ // minted its seq and is retained, but the socket write did not land
21
+ // (closed, or faulted mid-write); the reconnect replay delivers it. It is
22
+ // not `link-send-failed`: that means never-queued, nothing coming. The
23
+ // split exists so that neither is reported as success on the controller
24
+ // (bytes that went nowhere, a caller told "sent"). Never emitted by this
25
+ // host; declared here because the vocabulary is shared.
26
+ // link-unauthorized The controller refused this host's identity at the door: the
27
+ // WebSocket upgrade answered 401 or 403, so no socket ever opened. It is
28
+ // not `link-send-failed`: that names a transport that faltered, and this
29
+ // transport worked perfectly while the peer said no. The token layer's own
30
+ // split (`token-grant-rejected`) cannot see this one, because the material
31
+ // was minted fine and refused later, at the controller; a revoked or
32
+ // mis-scoped credential presents exactly here. Terminal by predicate
33
+ // (see `needsHumanReauthentication`): redialling a door that has refused
34
+ // the identity is a loop with no exit. Sign in again.
35
+ 'link-not-open',
36
+ 'link-closed',
37
+ 'link-send-failed',
38
+ 'link-unauthorized',
39
+ 'link-write-deferred',
40
+
41
+ // The offline queue.
42
+ // retention-released-unacked An ended session's written frames, released because the hold
43
+ // outlived its bound, never because they were acked. It is not
44
+ // `queue-dropped-droppable`: that names a delta discarded before it
45
+ // was numbered, which the wire never misses. These are stamped,
46
+ // undroppable frames a controller could still have asked for, so
47
+ // losing them is a real loss and it says so. Named rather than
48
+ // silent, because "the host released them" and "they were never
49
+ // sent" are indistinguishable from the outside and only one of them
50
+ // is something to investigate.
51
+ 'queue-dropped-droppable',
52
+ 'queue-overflow-undroppable',
53
+ 'retention-released-unacked',
54
+
55
+ // No credential configured. The link connects without a header in that case (see `link.ts`).
56
+ 'credential-unavailable',
57
+
58
+ // Sessions. Three reasons, and they must not be collapsed into one: they are three different
59
+ // kinds of failure and only the caller can decide what to do about each.
60
+ // session-unknown a caller error: an id this registry does not hold. Retrying is futile.
61
+ // session-spawn-failed an outage: the agent process did not come up. Retrying may work.
62
+ // workspace-untrusted a degrade: the work proceeds, but settings-file rules are silently
63
+ // void, so anything depending on them is not in force.
64
+ // Merging them would let an outage arrive wearing a refusal's clothes and impersonate a
65
+ // deliberate "no".
66
+ 'session-unknown',
67
+ 'session-spawn-failed',
68
+ 'workspace-untrusted',
69
+
70
+ // The declared state model. A transition that cannot name what caused it is refused rather than
71
+ // recorded, because a state nobody can explain is worse than no state at all: it reads as fact.
72
+ 'transition-cause-unnamed',
73
+
74
+ // The permission decision. Two reasons, and they must not be collapsed into one; neither is a
75
+ // denial, which is a deliberate answer and carries `cause.kind:'control'` instead.
76
+ // permission-decision-unavailable an outage: the decider threw, the escalation transport
77
+ // failed, or the controller answered non-2xx. Nobody decided.
78
+ // permission-decision-unrecognised a decision value this build has never seen: a controller
79
+ // running ahead of a host, a new tier, a rolled-back deploy.
80
+ // An outage is an infrastructure fix and an unrecognised value is a controller-version fix, so
81
+ // telling them apart is the difference between two different investigations. Both block the tool.
82
+ // permission-grant-shadows-settings A composition this package will not assemble, refused
83
+ // before any session exists. The gate can make its allow
84
+ // effective (see `grantOnAllow` in gate/gate.ts), but an
85
+ // effective allow short-circuits every permission check that
86
+ // runs after it, and when settings files are loaded those
87
+ // checks include the operator's own deny rules. The two are
88
+ // individually reasonable and jointly mean "the gate silently
89
+ // overrides rules the operator wrote". Refused by name rather
90
+ // than resolved by precedence, because either precedence is a
91
+ // surprise to somebody.
92
+ // gate-deadlines-inverted The second composition this package will not assemble,
93
+ // reachable from off-box because gate timings ride
94
+ // `session_new`. `permissionHooks` throws at construction
95
+ // when the host's own deadline does not expire before the
96
+ // matcher's (correct, deliberate, and pinned, because a
97
+ // matcher expiring first blocks the tool with nothing
98
+ // recorded and reads as a hang). But a throw escaping the
99
+ // payload dispatcher is a command that vanishes, which reads
100
+ // to a controller as a host that hung: the exact failure the
101
+ // dispatcher refuses everything by name to avoid. So the
102
+ // throw stays where an embedder meets it and the composer
103
+ // converts it into this named refusal, before any process
104
+ // exists.
105
+ 'permission-decision-unavailable',
106
+ 'permission-decision-unrecognised',
107
+ 'permission-grant-shadows-settings',
108
+ 'gate-deadlines-inverted',
109
+
110
+ // The bulk lane.
111
+ //
112
+ // `bulk-target-not-controller` is a separate reason from `bulk-target-invalid`, deliberately.
113
+ // A malformed or wrong-scheme URL is a peer that sent nonsense; an origin that parses perfectly
114
+ // and simply is not this host's controller is a peer trying to make the host POST a transcript
115
+ // and its durable credential somewhere else. The second is the only one of the two a reader
116
+ // should be able to grep for, alert on, or count; folding it into the first would make an
117
+ // exfiltration attempt indistinguishable from a typo in a URL.
118
+ 'bulk-target-invalid',
119
+ 'bulk-target-not-controller',
120
+ 'bulk-delivery-failed',
121
+
122
+ // Paths.
123
+ 'path-not-absolute',
124
+ 'path-escapes-root',
125
+
126
+ // The host's own gate: refusals this package decides by itself, with no controller involved.
127
+ //
128
+ // They are refusal reasons rather than deny messages, and that is the contract.
129
+ // A deny decided by a controller is recorded `control/permission_denied`. If a locally-decided
130
+ // refusal were also expressed as a deny it would carry that same cause, and the only thing telling
131
+ // a local policy answer apart from a remote one would be free text in `detail` — which is
132
+ // documented as never branched on. Naming them here makes the difference a fact code can read:
133
+ // `state/model.ts`'s `CauseEvent` already admits every RefusalReason, so each of these becomes
134
+ // `refusal/<name>` on the transition, distinct both from a controller's deny and from
135
+ // `permission-decision-unavailable`, which means nobody decided at all.
136
+ //
137
+ // path-input-missing a path-taking tool whose input carries no readable path. A call
138
+ // whose target cannot be found is not one that can be bounded.
139
+ // path-unresolvable normalizing the path failed. Unresolvable is not provably inside.
140
+ // credential-path-denied the call targets the host's own credential material. The agent runs
141
+ // as the same OS user as the host, so file permissions are not a
142
+ // boundary against it and this is the only control that is.
143
+ // shell-command-missing a shell tool whose input carries no readable command.
144
+ // shell-boundary-command a named boundary operation: publishing, remote surgery, branch
145
+ // deletion, merging. Reported with the rule that fired.
146
+ // shell-verb-unrecognised a git invocation whose verb is not provably safe. Not the same as
147
+ // the above: this one fires on a verb nobody enumerated, the layer
148
+ // that exists because a denylist under-includes (the plumbing behind
149
+ // a publish can match none of its patterns).
150
+ 'path-input-missing',
151
+ 'path-unresolvable',
152
+ 'credential-path-denied',
153
+ 'shell-command-missing',
154
+ 'shell-boundary-command',
155
+ 'shell-verb-unrecognised',
156
+
157
+ // Identity: acquiring, holding and presenting the user's own token.
158
+ //
159
+ // The split between `identity-not-configured` and `token-unavailable` is the one that matters.
160
+ // The first says the operator has not set this host up; the second says they have, and nobody has
161
+ // signed in yet (or the token aged out and there is no refresh token). They are different people's
162
+ // problems on different days, and collapsing them would make the host's most common message
163
+ // useless: "not configured" sent to someone who configured it a week ago reads as a bug.
164
+ //
165
+ // identity-not-configured no authority/client id is set. The host cannot even try.
166
+ // identity-config-invalid set, but unusable: a non-https authority, a malformed redirect.
167
+ // token-unavailable configured and valid, but there is no usable token right now.
168
+ //
169
+ // `auth-state-mismatch` is a security event, not a protocol hiccup, and it is named separately
170
+ // for that reason. The loopback listener accepts a callback on 127.0.0.1, and the agent runs as
171
+ // the same OS user as this host, the same fact that makes file permissions useless here. So any
172
+ // local process can reach that listener, and an unverified callback would let one hand this host
173
+ // an authorization code it obtained itself. The `state` value is what makes the callback provably
174
+ // the answer to the request this host made. A mismatch is someone else talking.
175
+ //
176
+ // auth-state-mismatch the callback's `state` is not the one this host minted.
177
+ // auth-callback-refused the callback carried no authorization code: the user declined, or
178
+ // the provider returned an `error` instead.
179
+ // pkce-method-unsupported a code-challenge method other than S256 was asked for. RFC 7636
180
+ // permits `plain`; this host refuses it rather than merely not using
181
+ // it, because an unused branch is one a later reader restores.
182
+ //
183
+ // The token endpoint's two failure kinds are split for the same reason the permission decision's
184
+ // are: an outage and a shape this host does not recognise are different investigations.
185
+ //
186
+ // token-request-failed transport error, or a non-2xx this host may retry its way out of.
187
+ // token-response-invalid 2xx, and the body is not a token response this host can use.
188
+ // token-grant-rejected The grant itself is dead, and no amount of retrying fixes it.
189
+ // RFC 6749 calls this `invalid_grant`: the refresh token is expired,
190
+ // revoked, or was issued to someone else. A person has to sign in.
191
+ // It is split from `token-request-failed` because the two need
192
+ // opposite actions. A provider that is briefly unreachable wants
193
+ // patience; a grant that has lapsed wants a human, and a host that
194
+ // waits politely for one to fix itself waits forever (a refresh token
195
+ // that dies after a day of inactivity is a common provider policy).
196
+ // Some providers append their own diagnostic code to
197
+ // `error_description`; the code branches on the RFC name.
198
+ //
199
+ // The cache, and the mode check. Three outcomes rather than two, because on win32 a file written
200
+ // 0600, one chmod'ed to 0600 and a deliberately world-readable 0666 all read back as 666.
201
+ //
202
+ // credential-cache-unreadable the cache exists and could not be read or parsed.
203
+ // credential-cache-write-failed the cache could not be written.
204
+ // credential-mode-too-wide verify-after-write found the file readable by more than its
205
+ // owner on a platform that enforces modes. A real finding.
206
+ // credential-mode-unenforced A named degrade, not a failure. This filesystem records only
207
+ // the write bit, so 0600 and 0666 are indistinguishable and
208
+ // privacy cannot be confirmed. The win32 shape. "Verified after
209
+ // write" is not available here, and saying so matters, because
210
+ // the alternative is a check that passes vacuously and reads as
211
+ // proof.
212
+ // credential-mode-unobservable Inconclusive, and deliberately not the same reason. Nothing
213
+ // about a mode was observable at all, not even clearing the
214
+ // write bit, which win32 does record. So the probe cannot tell
215
+ // a filesystem that records nothing apart from a broken probe,
216
+ // and that is a thing to investigate rather than a fact about
217
+ // privacy. An instrument with no inconclusive state reports
218
+ // confidence it has not earned.
219
+ //
220
+ // device-code-not-enabled the device-code flow was reached without being configured. It is
221
+ // never a silent fallback: the provider calls it a high-risk method
222
+ // and recommends blocking it, so it must be asked for by name.
223
+ // device-code-declined the device-code flow ended without a token: declined, expired, or
224
+ // refused by the tenant.
225
+ 'identity-not-configured',
226
+ 'identity-config-invalid',
227
+ 'token-unavailable',
228
+ 'auth-state-mismatch',
229
+ 'auth-callback-refused',
230
+ 'pkce-method-unsupported',
231
+ 'token-request-failed',
232
+ 'token-response-invalid',
233
+ 'token-grant-rejected',
234
+ 'credential-cache-unreadable',
235
+ 'credential-cache-write-failed',
236
+ 'credential-mode-too-wide',
237
+ 'credential-mode-unenforced',
238
+ 'credential-mode-unobservable',
239
+ 'device-code-not-enabled',
240
+ 'device-code-declined',
241
+
242
+ // The workspace a session runs in. Two reasons, split for the same reason as the session trio
243
+ // above: provisioning failed means the session never got a directory and must not start;
244
+ // releasing failed means the work is done and something was left behind. The first blocks, the
245
+ // second is cleanup debt, and a host that reported both as one could never tell an operator
246
+ // which of the two it was looking at.
247
+ //
248
+ // workspace-provision-failed no usable directory. Never a silent fallback to a temp path or to
249
+ // the host's own cwd, which is how a session ends up writing into
250
+ // the directory the host itself is running in.
251
+ // workspace-release-failed the directory or worktree could not be released. Named rather than
252
+ // swallowed: a release that quietly fails leaks a directory per
253
+ // session, which is invisible until a disk fills.
254
+ // resume-cwd-not-honoured a resume named a directory this host's provider would not honour
255
+ // (anything but its own repository root). The CLI keeps transcripts
256
+ // per cwd, so a resume moved into a provisioned workspace finds
257
+ // nothing and becomes a fresh session that says nothing; refused by
258
+ // name instead, on the wire.
259
+ 'workspace-provision-failed',
260
+ 'workspace-release-failed',
261
+ 'resume-cwd-not-honoured',
262
+
263
+ // Tool descriptors the controller hands over, and the schemas they carry.
264
+ //
265
+ // `mcp-schema-unsupported` is the one that matters, and it exists to prevent a false green.
266
+ // A converter that met a construct it did not recognise and fell back to a permissive schema would
267
+ // register the tool successfully and validate nothing, so the property "a malformed call is
268
+ // rejected" would be silently false for exactly the tools nobody checked, while every test stayed
269
+ // green. Refusing at registration is loud, happens before any session exists, and names the
270
+ // construct. A permissive fallback is the fail-open shape this package is built against.
271
+ //
272
+ // mcp-descriptor-invalid the descriptor itself is unusable: no name, no schema, a duplicate
273
+ // name within one server.
274
+ // mcp-schema-unsupported a schema construct this host cannot convert. Refused, never widened.
275
+ // mcp-tool-input-invalid a call whose arguments failed the tool's own schema. The one of the
276
+ // three that happens at run time rather than at registration.
277
+ 'mcp-descriptor-invalid',
278
+ 'mcp-schema-unsupported',
279
+ 'mcp-tool-input-invalid',
280
+
281
+ // Durability: the transcript mirror, the transition log, and the receipt read.
282
+ //
283
+ // `receipt-anchor-unknown` is the one that matters, and it exists because the alternative is
284
+ // invisible. A delivery receipt answers "did the text land?", and the only two honest answers are
285
+ // yes and no. A third situation exists (the baseline anchor is not in the transcript at all, so
286
+ // the question cannot be evaluated) and reporting that as "no" is the failure this whole read
287
+ // path is built against: a controller that reads a false "no" pastes the text again. Naming it
288
+ // makes "could not tell" a fact code branches on rather than a silence.
289
+ //
290
+ // transcript-key-invalid an empty project key or session id, or a subpath present and
291
+ // empty. The store's key type calls an empty subpath invalid and
292
+ // says to omit the field instead, so it is refused, not coerced;
293
+ // coercing would silently address the main transcript when a
294
+ // subagent's was asked for.
295
+ // transcript-entry-malformed a stored line that will not round-trip. Round-tripping is the
296
+ // only invariant the adapter contract requires, so a line failing
297
+ // it is named rather than skipped: skipping is how a transcript
298
+ // quietly loses entries nobody counted.
299
+ // transcript-read-failed the store or the local file could not be read.
300
+ // transcript-write-failed the append did not land anywhere.
301
+ // receipt-anchor-unknown see above. Not a negative receipt.
302
+ // retention-window-invalid a retention window that is negative or not a number. Refused
303
+ // rather than defaulted, because a default here silently deletes
304
+ // on a schedule nobody chose.
305
+ // gate-cannot-grant A degrade, not a blocked call, and the only entry here that
306
+ // reports on the gate's own configuration rather than on a request.
307
+ // Raised once per gate, on the first allow issued while
308
+ // `grantOnAllow` is off: the call was approved and the tool still
309
+ // will not run, because a silent allow leaves the agent's own
310
+ // permission mode to decide and a host has nobody to answer it.
311
+ // Named because the alternative is nothing at all, on every
312
+ // approved call, for the life of the session.
313
+ 'transcript-key-invalid',
314
+ 'transcript-entry-malformed',
315
+ 'transcript-read-failed',
316
+ 'transcript-write-failed',
317
+ 'receipt-anchor-unknown',
318
+ 'retention-window-invalid',
319
+ 'gate-cannot-grant',
320
+
321
+ // The discovery door: reading the agent CLI's own transcript directory, jailed.
322
+ //
323
+ // transcript-path-escape A name that could shape a path outside the projects root. One
324
+ // reason for all three layers of the jail (the name allowlist, the
325
+ // explicit dot-name reject, and the resolve-then-containment check)
326
+ // with `detail` naming which layer refused. It is not
327
+ // `path-escapes-root`: that names the workspace jail around a
328
+ // session's own files; this names the read-only door over a directory
329
+ // the host reads on the controller's behalf. An absent transcript is
330
+ // not this: absence is a value the door reports, never a refusal.
331
+ 'transcript-path-escape',
332
+
333
+ // The configure ask: a controller changing this host's own config file over the wire.
334
+ //
335
+ // config-key-unknown An entry names a key this host does not take over the wire: a key
336
+ // outside the closed config set, or one of the keys that name the
337
+ // controller itself (the URLs, the host id). A controller re-pointing
338
+ // a host at another controller has no legitimate use, so those keys
339
+ // are refused here by name; the detail lists the settable set.
340
+ // config-value-invalid A value the host would refuse at start-up: a root that is not
341
+ // absolute, a branch scheme with no `{key}`, an unknown placeholder,
342
+ // an unmatched brace, a literal that renders an illegal branch, a
343
+ // scheme with no repository root behind it. Screened as a whole set
344
+ // before any write, so a half-applied posture never lands.
345
+ // config-host-busy The ask changes a workspace root while a session is live or
346
+ // opening. A session releases through the provider that provisioned
347
+ // it; swapping roots under one turns that release into a guess. Close
348
+ // the sessions and ask again.
349
+ // config-write-failed The file could not be written: the directory is unwritable, the
350
+ // existing file is corrupt (fix or remove it first), or this host was
351
+ // composed without a configuration seam at all.
352
+ 'config-key-unknown',
353
+ 'config-value-invalid',
354
+ 'config-host-busy',
355
+ 'config-write-failed',
356
+
357
+ // The inventory: listing the worktrees under the workspace root from disk.
358
+ //
359
+ // workspace-list-failed The host could not look: no workspace provider, a provider that
360
+ // keeps no inventory, or git refusing to list. The detail names which.
361
+ 'workspace-list-failed',
362
+
363
+ // The repository read: a controller listing a directory or reading a text file of the operator's
364
+ // checkout through this host, jailed to the repository root.
365
+ //
366
+ // repository-path-escape The asked path resolves outside the repository root, or this host
367
+ // has no repository root to read under. The same shape as
368
+ // `transcript-path-escape`, over a different root; an absent file or
369
+ // directory is not this, it is `repository-read-failed`.
370
+ // repository-read-failed The path is not a directory (for a listing) or not a file (for a
371
+ // read), the file holds a NUL byte in its head (binary; the answer is
372
+ // a string), or the filesystem refused. The detail names which.
373
+ 'repository-path-escape',
374
+ 'repository-read-failed',
375
+
376
+ // The branch half of a release: deleting a branch is not reversible, so it is refused by name
377
+ // rather than done quietly.
378
+ //
379
+ // branch-not-merged The ask said deleteBranch without force and the branch is not in
380
+ // the default branch (or no default branch exists to judge by). Nothing
381
+ // was removed: the check runs before the directory goes.
382
+ 'branch-not-merged',
383
+ ] as const;
384
+
385
+ export type RefusalReason = (typeof REFUSAL_REASONS)[number];
386
+
387
+ /** A named refusal. `detail` is for a human reading a log; `reason` is what code branches on. */
388
+ export interface Refusal {
389
+ readonly reason: RefusalReason;
390
+ readonly detail: string;
391
+ }
392
+
393
+ export function refusal(reason: RefusalReason, detail: string): Refusal {
394
+ return { reason, detail };
395
+ }
396
+
397
+ /**
398
+ * The reasons a retry cannot help with: the credential is gone until a person restores it.
399
+ *
400
+ * This is a predicate and not a comment because the link's reconnect loop is correct for every
401
+ * transient failure and actively harmful for these: it dials a door that structurally cannot open,
402
+ * forever, while the one fact the operator could act on scrolls past as another retry. So "is this
403
+ * terminal" has to be a thing code branches on, and it has to have one home; a second list
404
+ * somewhere else is how the link and the reporter end up disagreeing about whether a host is dead.
405
+ *
406
+ * The set is deliberately small, and erring toward transient is the safe direction. Treating a
407
+ * recoverable failure as terminal takes down a host that would have healed; treating a terminal one
408
+ * as recoverable costs a retry loop that is loud. Only reasons whose own detail already ends in
409
+ * "sign in again" belong here.
410
+ *
411
+ * `link-unauthorized` is the third member, and it is the only one the token layer cannot raise.
412
+ * The provider judged the material and approved it; the controller refused it at the upgrade. The
413
+ * conclusion is identical (the next dial with the same identity refuses identically, and only a
414
+ * person can change what is presented), so it takes the same exit rather than a second, private
415
+ * notion of "terminal" living in the link.
416
+ *
417
+ * `credential-unavailable` is not one of them, and must never be. It is what a host with no
418
+ * identity configured reports on every dial, and that host is supposed to keep connecting without a
419
+ * header, the supported no-identity mode. Putting it here would turn the most common unconfigured
420
+ * setup into a host that refuses to start.
421
+ */
422
+ export function needsHumanReauthentication(reason: RefusalReason): boolean {
423
+ return (
424
+ reason === 'token-grant-rejected' || reason === 'token-unavailable' || reason === 'link-unauthorized'
425
+ );
426
+ }
427
+
428
+ export function isRefusalReason(value: string): value is RefusalReason {
429
+ return (REFUSAL_REASONS as readonly string[]).includes(value);
430
+ }
@@ -0,0 +1,27 @@
1
+ import type { Refusal, RefusalReason } from './refusal.js';
2
+ import { refusal } from './refusal.js';
3
+
4
+ /**
5
+ * The return type of anything that can decline. There is no thrown-exception path for expected
6
+ * failure — a caller cannot forget to handle a `Result` the way it can forget a `try`.
7
+ */
8
+ export type Result<T> =
9
+ { readonly ok: true; readonly value: T } | { readonly ok: false; readonly refusal: Refusal };
10
+
11
+ export function ok<T>(value: T): Result<T> {
12
+ return { ok: true, value };
13
+ }
14
+
15
+ export function refuse<T>(reason: RefusalReason, detail: string): Result<T> {
16
+ return { ok: false, refusal: refusal(reason, detail) };
17
+ }
18
+
19
+ /** Narrow without destructuring, for call sites that only branch. */
20
+ export function isOk<T>(result: Result<T>): result is { ok: true; value: T } {
21
+ return result.ok;
22
+ }
23
+
24
+ /** The value, or a fallback. For call sites where a refusal is genuinely uninteresting. */
25
+ export function valueOr<T>(result: Result<T>, fallback: T): T {
26
+ return result.ok ? result.value : fallback;
27
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Time as an injected capability, so anything that stamps a frame is testable without waiting.
3
+ * The one real clock is built in the composition root and threaded down.
4
+ */
5
+
6
+ /** Returns the current instant as an ISO-8601 UTC string — the `at` on every frame. */
7
+ export type Clock = () => string;
8
+
9
+ /** Milliseconds since the epoch, for elapsed-time arithmetic (deadlines, backoff, heartbeat). */
10
+ export type Ticker = () => number;
11
+
12
+ export const systemClock: Clock = () => new Date().toISOString();
13
+
14
+ export const systemTicker: Ticker = () => Date.now();
15
+
16
+ /** A clock that advances only when told to. */
17
+ export function fixedClock(startMs: number): Clock & { advance: (ms: number) => void } {
18
+ let now = startMs;
19
+ const clock = (() => new Date(now).toISOString()) as Clock & { advance: (ms: number) => void };
20
+ clock.advance = (ms: number) => {
21
+ now += ms;
22
+ };
23
+ return clock;
24
+ }
25
+
26
+ /** A ticker that advances only when told to. Pairs with `fixedClock` for deadline tests. */
27
+ export function fixedTicker(startMs: number): Ticker & { advance: (ms: number) => void } {
28
+ let now = startMs;
29
+ const ticker = (() => now) as Ticker & { advance: (ms: number) => void };
30
+ ticker.advance = (ms: number) => {
31
+ now += ms;
32
+ };
33
+ return ticker;
34
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The nouns this package is allowed to name things after — the Claude Agent SDK's own words.
3
+ *
4
+ * Every wire discriminator begins with one of these, which is what makes the naming rule
5
+ * mechanically checkable instead of a style note: a payload kind naming a concept the SDK does not
6
+ * have is a concept this layer should not be modelling.
7
+ */
8
+ export const SDK_NOUNS = [
9
+ 'session',
10
+ 'message',
11
+ 'hook',
12
+ 'tool',
13
+ 'subagent',
14
+ 'turn',
15
+ 'result',
16
+ 'permission',
17
+ 'compaction',
18
+ ] as const;
19
+
20
+ export type SdkNoun = (typeof SDK_NOUNS)[number];
21
+
22
+ /**
23
+ * Nouns this package owns because the SDK has no concept for them: the outbound link itself, and
24
+ * the out-of-band transfer that keeps bulk bytes off it. Kept separate from `SDK_NOUNS` so the
25
+ * distinction stays visible: these are the words this package invented rather than borrowed.
26
+ */
27
+ export const HOST_NOUNS = ['link', 'bulk'] as const;
28
+
29
+ export type HostNoun = (typeof HOST_NOUNS)[number];
30
+
31
+ const ALL_NOUNS: readonly string[] = [...SDK_NOUNS, ...HOST_NOUNS];
32
+
33
+ /**
34
+ * A discriminator is `<noun>_<verb>` or `<noun>/<method>`. Returns the leading noun, or null when
35
+ * the string does not start with a declared one.
36
+ */
37
+ export function nounOf(discriminator: string): string | null {
38
+ const head = discriminator.split(/[_/]/, 1)[0] ?? '';
39
+ return ALL_NOUNS.includes(head) ? head : null;
40
+ }
41
+
42
+ export function isDeclaredNoun(value: string): boolean {
43
+ return ALL_NOUNS.includes(value);
44
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The bound on a workspace id (a session key, a workspace key, a worktree's directory name).
3
+ *
4
+ * It exists so a refusal can always be sent. Every refusal echoes the key, so a key near
5
+ * `MAX_FRAME_BYTES` would make its own refusal unencodable and the named answer would degrade to
6
+ * silence. 200 keeps every echo-bearing detail far under the frame cap; a controller storing keys
7
+ * should refuse the same bound so both halves reject the same population. It lives in `core/` so
8
+ * the wire codec can enforce it on an inventory entry without the protocol closure reaching the
9
+ * workspace provider.
10
+ */
11
+ export const MAX_WORKSPACE_ID_LENGTH = 200;