@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,157 @@
1
+ /**
2
+ * Where recorded transitions are kept, and where anyone asking "what happened?" reads from.
3
+ *
4
+ * Separate from the machine on purpose. A machine that both emitted and answered questions about
5
+ * itself would let a test prove emission by asking the thing that emitted — the assertion and the
6
+ * subject would be the same object. This subscribes, so "no transition without a cause" is checked
7
+ * against what was actually retained rather than against what a call site claims it passed.
8
+ *
9
+ * Bounded, in memory, and not durable. It holds a window per session so a long-running host does
10
+ * not grow without limit; durability of the transition log belongs to the persistence layer, and
11
+ * this deliberately does not pretend to be it. `dropped` counts what fell out of the window rather
12
+ * than letting a full ring look like a quiet one.
13
+ */
14
+ import type { SessionTransition } from './model.js';
15
+ import type { RejectedTransition, SessionStateMachine, Unsubscribe } from './machine.js';
16
+
17
+ /** Per session. A session that outruns this loses its oldest transitions, and says how many. */
18
+ const DEFAULT_WINDOW = 500;
19
+
20
+ export interface TransitionStoreOptions {
21
+ readonly windowPerSession?: number;
22
+ }
23
+
24
+ /** Keyed while a session has no id yet — see SessionTransition.sessionId. */
25
+ const UNIDENTIFIED = '(unidentified)';
26
+
27
+ export class TransitionStore {
28
+ readonly #window: number;
29
+ readonly #bySession = new Map<string, SessionTransition[]>();
30
+ readonly #rejected: RejectedTransition[] = [];
31
+ #dropped = 0;
32
+ #droppedRejections = 0;
33
+
34
+ constructor(options: TransitionStoreOptions = {}) {
35
+ this.#window = options.windowPerSession ?? DEFAULT_WINDOW;
36
+ }
37
+
38
+ /**
39
+ * Retain everything one machine records, and everything it refuses.
40
+ *
41
+ * Both halves matter: a store that kept only the successes would answer "were all transitions
42
+ * caused?" with a yes it earned by discarding the counter-examples.
43
+ */
44
+ attach(machine: SessionStateMachine): Unsubscribe {
45
+ const dropTransitions = machine.onTransition((transition) => this.#retain(transition));
46
+ const dropRejections = machine.onRejected((rejected) => {
47
+ // Bounded on the same terms as the transitions, and for the same reason: a rejection arrives
48
+ // from whatever drives the machine, and once that is a wire path the rate is a stranger's to
49
+ // set. An uncapped array beside a capped ring would make the counter-examples the thing that
50
+ // grows without limit — the one collection nobody thinks to watch.
51
+ this.#rejected.push(rejected);
52
+ while (this.#rejected.length > this.#window) {
53
+ this.#rejected.shift();
54
+ this.#droppedRejections += 1;
55
+ }
56
+ });
57
+ return () => {
58
+ dropTransitions();
59
+ dropRejections();
60
+ };
61
+ }
62
+
63
+ /**
64
+ * One session's transitions, oldest first.
65
+ *
66
+ * Transitions recorded before the agent named itself are re-keyed when it does. They were
67
+ * genuinely recorded without an id — that window is real — but leaving them in a separate bucket
68
+ * would mean the trace of a session's own start could not be read by that session's id, which is
69
+ * the one thing a reader has.
70
+ */
71
+ forSession(sessionId: string): readonly SessionTransition[] {
72
+ return this.#bySession.get(sessionId) ?? [];
73
+ }
74
+
75
+ /** Transitions recorded while no session had reported itself yet, across every machine. */
76
+ unidentified(): readonly SessionTransition[] {
77
+ return this.#bySession.get(UNIDENTIFIED) ?? [];
78
+ }
79
+
80
+ /** Everything retained, across every session. The subject of the store-level assertions. */
81
+ all(): SessionTransition[] {
82
+ return [...this.#bySession.values()].flat();
83
+ }
84
+
85
+ sessionIds(): string[] {
86
+ return [...this.#bySession.keys()].filter((key) => key !== UNIDENTIFIED);
87
+ }
88
+
89
+ /** Every refused transition still held, with why. Never summarised into a count alone. */
90
+ rejected(): readonly RejectedTransition[] {
91
+ return this.#rejected;
92
+ }
93
+
94
+ /** How many transitions fell out of a window. A full ring must not read as a quiet one. */
95
+ get droppedCount(): number {
96
+ return this.#dropped;
97
+ }
98
+
99
+ /**
100
+ * How many rejections fell out of their window.
101
+ *
102
+ * Counted separately from `droppedCount` because the two mean opposite things: transitions
103
+ * falling out is an ordinary busy session, rejections falling out means something is refusing
104
+ * faster than anyone is reading, and collapsing them into one number would hide that.
105
+ */
106
+ get droppedRejectionCount(): number {
107
+ return this.#droppedRejections;
108
+ }
109
+
110
+ #retain(transition: SessionTransition): void {
111
+ const key = transition.sessionId ?? UNIDENTIFIED;
112
+ this.#adopt(transition);
113
+
114
+ const kept = this.#bySession.get(key) ?? [];
115
+ kept.push(transition);
116
+ while (kept.length > this.#window) {
117
+ kept.shift();
118
+ this.#dropped += 1;
119
+ }
120
+ this.#bySession.set(key, kept);
121
+ }
122
+
123
+ /** Move this machine's earlier, id-less transitions under the id it has just reported. */
124
+ #adopt(transition: SessionTransition): void {
125
+ const id = transition.sessionId;
126
+ if (id === null) return;
127
+
128
+ const orphans = this.#bySession.get(UNIDENTIFIED);
129
+ if (orphans === undefined) return;
130
+
131
+ // Only this machine's own: an id-less transition belongs to whichever machine produced the
132
+ // seq series it sits in, and a machine's seq is dense from 1. Anything at or above this
133
+ // transition's own seq cannot be its predecessor.
134
+ const mine = orphans.filter((orphan) => orphan.seq < transition.seq && sameOrigin(orphan, transition));
135
+ if (mine.length === 0) return;
136
+
137
+ this.#bySession.set(
138
+ UNIDENTIFIED,
139
+ orphans.filter((orphan) => !mine.includes(orphan)),
140
+ );
141
+ const kept = this.#bySession.get(id) ?? [];
142
+ this.#bySession.set(id, [...mine, ...kept]);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Whether two transitions came from the same machine, without either carrying a machine id.
148
+ *
149
+ * `where` is per-machine and fixed for its lifetime, and `correlationId` is whatever the controller
150
+ * supplied for that one session — so together they distinguish two concurrent id-less starts unless
151
+ * a controller starts two sessions in the same directory with no correlation id, in which case
152
+ * their early transitions are genuinely indistinguishable and both are adopted. That is a truthful
153
+ * outcome rather than a wrong one: nothing is lost, and nothing is invented to prevent it.
154
+ */
155
+ function sameOrigin(left: SessionTransition, right: SessionTransition): boolean {
156
+ return left.correlationId === right.correlationId && left.where.cwd === right.where.cwd;
157
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Usage, cost and rate-limit facts — consumed from what the agent reports, never recomputed.
3
+ *
4
+ * There is no price table in this package and there must not be one. The agent reports a cost per
5
+ * model, with the provider and the id it priced against, so a mixed-model turn is correct without
6
+ * this host knowing any rate. A table would be wrong twice over and silently: stale the moment a
7
+ * price moves, and blind to a turn that used more than one model.
8
+ */
9
+ export type { ModelSpend, RateLimitStanding, SpendTotal, TurnSpend } from './usage.js';
10
+ export { NO_SPEND, foldSpend, spendReconciles } from './usage.js';
@@ -0,0 +1,117 @@
1
+ /**
2
+ * What a turn cost — consumed from what the agent reports, never computed from a price table.
3
+ *
4
+ * The whole point is that nothing here multiplies tokens by a rate. A host that keeps a per-model
5
+ * price table and does the arithmetic itself has two failure modes, and both are silent: a price
6
+ * changes and every historical figure is quietly wrong, and a mixed-model turn is costed at one
7
+ * model's rate because the table is keyed by "the session's model". The agent already reports a
8
+ * per-model cost, with the provider and the canonical id it priced against. So this reads.
9
+ *
10
+ * How that is actually proven, because "it only reads" is easy to claim and easy to regress: the
11
+ * tests feed a cost no price table could ever produce and assert it comes out unchanged. A
12
+ * reimplementation that started computing would have to reproduce an arbitrary number to stay green.
13
+ *
14
+ * No SDK types here. This is the model the layer above works in; `host/telemetry.ts` lifts it off
15
+ * the agent's own result message, the same way `readInitFacts` lifts the version receipt.
16
+ */
17
+
18
+ /** What one model cost within a turn. Every number is reported, none derived. */
19
+ export interface ModelSpend {
20
+ /** The model string the agent was keyed by — provider-specific ids and aliases included. */
21
+ readonly model: string;
22
+ readonly costUsd: number;
23
+ readonly inputTokens: number;
24
+ readonly outputTokens: number;
25
+ readonly cacheReadInputTokens: number;
26
+ readonly cacheCreationInputTokens: number;
27
+ /** The window this model was serving with. Absent is null, never zero. */
28
+ readonly contextWindow: number | null;
29
+ /** The id the agent priced against, when it differs from `model`. */
30
+ readonly canonicalModel: string | null;
31
+ /** Which API served it, when reported. */
32
+ readonly provider: string | null;
33
+ }
34
+
35
+ /** One turn's spend. */
36
+ export interface TurnSpend {
37
+ /** The agent's own total. Not the sum of `byModel` — see `spendReconciles`. */
38
+ readonly totalCostUsd: number;
39
+ readonly byModel: readonly ModelSpend[];
40
+ }
41
+
42
+ /** Spend accumulated across turns. */
43
+ export interface SpendTotal {
44
+ readonly totalCostUsd: number;
45
+ readonly turnCount: number;
46
+ /** Per model, summed across every turn folded in. */
47
+ readonly byModel: readonly ModelSpend[];
48
+ }
49
+
50
+ export const NO_SPEND: SpendTotal = { totalCostUsd: 0, turnCount: 0, byModel: [] };
51
+
52
+ /**
53
+ * Add one turn to a running total.
54
+ *
55
+ * Models are merged by their reported key, not by the canonical id. Two entries keyed differently
56
+ * that price against one canonical model are genuinely two things the agent distinguished — an alias
57
+ * and a provider-specific id can bill differently — and collapsing them here would throw away the
58
+ * distinction the report was making.
59
+ */
60
+ export function foldSpend(prev: SpendTotal, turn: TurnSpend): SpendTotal {
61
+ const byModel = new Map<string, ModelSpend>();
62
+ for (const spend of prev.byModel) byModel.set(spend.model, spend);
63
+
64
+ for (const spend of turn.byModel) {
65
+ const existing = byModel.get(spend.model);
66
+ byModel.set(
67
+ spend.model,
68
+ existing === undefined
69
+ ? spend
70
+ : {
71
+ ...existing,
72
+ costUsd: existing.costUsd + spend.costUsd,
73
+ inputTokens: existing.inputTokens + spend.inputTokens,
74
+ outputTokens: existing.outputTokens + spend.outputTokens,
75
+ cacheReadInputTokens: existing.cacheReadInputTokens + spend.cacheReadInputTokens,
76
+ cacheCreationInputTokens: existing.cacheCreationInputTokens + spend.cacheCreationInputTokens,
77
+ // Last report wins: these describe the model, not the usage, and the newest is truest.
78
+ contextWindow: spend.contextWindow ?? existing.contextWindow,
79
+ canonicalModel: spend.canonicalModel ?? existing.canonicalModel,
80
+ provider: spend.provider ?? existing.provider,
81
+ },
82
+ );
83
+ }
84
+
85
+ return {
86
+ totalCostUsd: prev.totalCostUsd + turn.totalCostUsd,
87
+ turnCount: prev.turnCount + 1,
88
+ byModel: [...byModel.values()],
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Whether a turn's per-model costs add up to the total the agent reported.
94
+ *
95
+ * It is an observation, not a validation, and must not become one. Both numbers come from the
96
+ * agent; a mismatch means the agent counted something the per-model breakdown does not itemise, and
97
+ * that is the agent's business rather than a defect this host should refuse over. The reason to
98
+ * expose it is that a caller charging money wants to know which of the two it is quoting, and
99
+ * whether they agree.
100
+ *
101
+ * @param toleranceUsd absolute, because these are floats and an exact comparison would fail on
102
+ * arithmetic that is otherwise perfectly correct.
103
+ */
104
+ export function spendReconciles(turn: TurnSpend, toleranceUsd = 1e-9): boolean {
105
+ const summed = turn.byModel.reduce((total, spend) => total + spend.costUsd, 0);
106
+ return Math.abs(summed - turn.totalCostUsd) <= toleranceUsd;
107
+ }
108
+
109
+ /** How a rate limit currently stands, as the agent reports it. */
110
+ export interface RateLimitStanding {
111
+ readonly status: 'allowed' | 'allowed_warning' | 'rejected';
112
+ /** Epoch seconds, as reported. Null when the agent did not say. */
113
+ readonly resetsAt: number | null;
114
+ readonly limitType: string | null;
115
+ /** 0–1 where reported, else null. Never defaulted to 0 — that would read as "plenty left". */
116
+ readonly utilization: number | null;
117
+ }