@celestea/runtime 2.7.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/agent-config.d.ts +18 -0
  4. package/dist/agent-config.js +31 -0
  5. package/dist/autowake.d.ts +141 -0
  6. package/dist/autowake.js +262 -0
  7. package/dist/compact/index.d.ts +13 -0
  8. package/dist/compact/index.js +13 -0
  9. package/dist/compact/plan.d.ts +51 -0
  10. package/dist/compact/plan.js +98 -0
  11. package/dist/compact/rewrite.d.ts +23 -0
  12. package/dist/compact/rewrite.js +79 -0
  13. package/dist/compact/run.d.ts +44 -0
  14. package/dist/compact/run.js +59 -0
  15. package/dist/compact/summarize.d.ts +30 -0
  16. package/dist/compact/summarize.js +70 -0
  17. package/dist/compact/transcript.d.ts +35 -0
  18. package/dist/compact/transcript.js +88 -0
  19. package/dist/compose.d.ts +117 -0
  20. package/dist/compose.js +191 -0
  21. package/dist/errors.d.ts +25 -0
  22. package/dist/errors.js +34 -0
  23. package/dist/frames.d.ts +46 -0
  24. package/dist/frames.js +62 -0
  25. package/dist/gen.d.ts +86 -0
  26. package/dist/gen.js +129 -0
  27. package/dist/host/engine-session.d.ts +117 -0
  28. package/dist/host/engine-session.js +109 -0
  29. package/dist/host/index.d.ts +39 -0
  30. package/dist/host/index.js +39 -0
  31. package/dist/host/provider-target.d.ts +113 -0
  32. package/dist/host/provider-target.js +116 -0
  33. package/dist/inbox-checkpoint.d.ts +18 -0
  34. package/dist/inbox-checkpoint.js +37 -0
  35. package/dist/inbox.d.ts +94 -0
  36. package/dist/inbox.js +139 -0
  37. package/dist/index.d.ts +71 -0
  38. package/dist/index.js +71 -0
  39. package/dist/ledger-io.d.ts +27 -0
  40. package/dist/ledger-io.js +74 -0
  41. package/dist/ledger-llm.d.ts +48 -0
  42. package/dist/ledger-llm.js +115 -0
  43. package/dist/ledger-query.d.ts +91 -0
  44. package/dist/ledger-query.js +153 -0
  45. package/dist/ledger.d.ts +271 -0
  46. package/dist/ledger.js +444 -0
  47. package/dist/pricing.d.ts +100 -0
  48. package/dist/pricing.js +167 -0
  49. package/dist/profile.d.ts +26 -0
  50. package/dist/profile.js +39 -0
  51. package/dist/recovery.d.ts +56 -0
  52. package/dist/recovery.js +91 -0
  53. package/dist/retention.d.ts +49 -0
  54. package/dist/retention.js +119 -0
  55. package/dist/runtime.d.ts +197 -0
  56. package/dist/runtime.js +347 -0
  57. package/dist/sanitize.d.ts +35 -0
  58. package/dist/sanitize.js +36 -0
  59. package/dist/session-binding.d.ts +36 -0
  60. package/dist/session-binding.js +33 -0
  61. package/dist/session-registry.d.ts +238 -0
  62. package/dist/session-registry.js +388 -0
  63. package/dist/status.d.ts +279 -0
  64. package/dist/status.js +411 -0
  65. package/dist/tokens.d.ts +25 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/turn-runner.d.ts +169 -0
  68. package/dist/turn-runner.js +242 -0
  69. package/dist/usage.d.ts +64 -0
  70. package/dist/usage.js +88 -0
  71. package/dist/watchdog-mount.d.ts +79 -0
  72. package/dist/watchdog-mount.js +120 -0
  73. package/dist/worker-wiring.d.ts +74 -0
  74. package/dist/worker-wiring.js +107 -0
  75. package/package.json +31 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The composed runtime engine: one generation's services, wired and ready.
3
+ *
4
+ * A [Runtime] is deliberately thin — it owns the handles and the lifecycle and
5
+ * delegates turn driving to [TurnRunner]:
6
+ *
7
+ * - **turn driving** `runTurn(input, {signal, sink})`, single concurrency slot;
8
+ * - **statusline** `statusline()` reads the live tracker + usage accounting;
9
+ * - **rebinding** `rebind(binding)` re-opens the SAME session directory;
10
+ * - **shutdown** idempotent, re-entrant teardown (drivers, host process
11
+ * hooks, mailbox, registries): calling it twice is a
12
+ * no-op, and a concurrent caller awaits the same promise;
13
+ * - **release** explicit strong-reference drop for hot swaps (W248).
14
+ *
15
+ * `shutdown` also marks the session's checkpoint sidecar as cleanly closed
16
+ * (E §1.3 P0 ⑤), so the next boot can tell a graceful exit from a crash.
17
+ *
18
+ * [release] nulls every handle, which — together with the WeakRef the worker
19
+ * tools hold on the registry — breaks the
20
+ * `Runtime -> ctx -> ToolRegistry -> worker tool -> registry` cycle, so a
21
+ * swapped-out generation can actually be collected.
22
+ */
23
+ import type { AgentConfig, Context, LlmRegistry, ModelRequest, SessionLog, Statusline, ToolRegistry, TurnOutcome } from "@celestea/core";
24
+ import type { Watchdog, WorkerRegistry } from "@celestea/workers";
25
+ import type { InjectionLane } from "@celestea/core";
26
+ import type { InboxPushOptions, InjectedMessage, SessionInbox } from "./inbox.js";
27
+ import { type SessionBinding } from "./session-binding.js";
28
+ import { type AssembledContext, type StatusTracker, type StatusView } from "./status.js";
29
+ import type { FrameSink, TurnOptions, TurnRunner } from "./turn-runner.js";
30
+ import type { TurnFrame } from "./frames.js";
31
+ import type { UsageAccounting } from "./usage.js";
32
+ import type { Profile } from "./profile.js";
33
+ import type { WorkerHost } from "./worker-wiring.js";
34
+ export type ShutdownHook = () => void | Promise<void>;
35
+ /** Everything compose hands over; the constructor never does IO of its own. */
36
+ export interface RuntimeParts {
37
+ ctx: Context;
38
+ profile: Profile;
39
+ agentConfig: AgentConfig;
40
+ /** Mutable holder: a rebind swaps the log every reader observes. */
41
+ sessionRef: {
42
+ log: SessionLog;
43
+ };
44
+ binding: SessionBinding | null;
45
+ status: StatusTracker;
46
+ usage: UsageAccounting;
47
+ /** Per-session mid-turn injection queue (W513). */
48
+ inbox: SessionInbox;
49
+ runner: TurnRunner;
50
+ /** Worker wiring + the W740 watchdog mounted over it (null when off). */
51
+ workerHost: WorkerHost | null;
52
+ llm: LlmRegistry | null;
53
+ tools: ToolRegistry | null;
54
+ agentLoop: unknown | null;
55
+ /** Names of the mounted plugins, in mount order (order is semantics). */
56
+ plugins: readonly string[];
57
+ shutdownHooks: readonly ShutdownHook[];
58
+ }
59
+ export declare class Runtime {
60
+ private parts;
61
+ private binding;
62
+ private shutdownPromise;
63
+ private released;
64
+ /**
65
+ * W755 (Fix B): the context-usage projection state for THIS session (one
66
+ * generation = one session). Deliberately owned here rather than in
67
+ * `RuntimeParts` (compose passes no such thing) and never a module singleton:
68
+ * two live sessions must not share a prompt anchor. [rebind] re-opens the SAME
69
+ * session, so the anchor survives it exactly like the usage tracker's does.
70
+ */
71
+ private readonly pressure;
72
+ /**
73
+ * W762: the last assembly handed out, with the log state it was built from.
74
+ * One slot, per generation (never a module singleton, never a timer).
75
+ */
76
+ private snapshotCache;
77
+ constructor(parts: RuntimeParts);
78
+ private get p();
79
+ get ctx(): Context;
80
+ get profile(): Profile;
81
+ get agentConfig(): AgentConfig;
82
+ /** The active conversation log (a rebind swaps this handle). */
83
+ get session(): SessionLog;
84
+ get sessionBinding(): SessionBinding | null;
85
+ get status(): StatusTracker;
86
+ /** The session's injection queue (drained by the turn at step boundaries). */
87
+ get inbox(): SessionInbox;
88
+ get usage(): UsageAccounting;
89
+ get llm(): LlmRegistry | null;
90
+ get tools(): ToolRegistry | null;
91
+ get workers(): WorkerRegistry | null;
92
+ /**
93
+ * W740: the liveness watchdog mounted over this generation's worker registry
94
+ * (null when the watchdog is off). Adjudication itself belongs to the watchdog
95
+ * plugin — this is only the host's handle on it (`tick()` by hand, or read
96
+ * `running`), never a second liveness rule.
97
+ */
98
+ get watchdog(): Watchdog | null;
99
+ get hostSessionId(): string | null;
100
+ get isBusy(): boolean;
101
+ get isReleased(): boolean;
102
+ /** Mounted plugin names, in mount order. */
103
+ get pluginNames(): readonly string[];
104
+ /** Drive one turn; a second concurrent call is a [TurnBusyError] (409). */
105
+ runTurn(input: string | null, opts?: TurnOptions): Promise<TurnOutcome>;
106
+ /** Cancel the in-flight turn (cooperative); false when nothing was running. */
107
+ cancelTurn(): boolean;
108
+ /** Snapshot for the statusline reader (SSE status payloads / GET /api/status). */
109
+ statusView(): StatusView;
110
+ /** The frozen `/api/status` payload for this generation. */
111
+ statusline(): Statusline;
112
+ /**
113
+ * W725: this generation's model-visible context, exactly as the loop would
114
+ * build it for the NEXT step (system + trimmed history + tool schemas), or
115
+ * null when the mounted loop has no snapshot capability (a test double).
116
+ *
117
+ * The assembly is the agent loop's, never this layer's: runtime only forwards
118
+ * the Context, so the read-only snapshot cannot drift from the real request.
119
+ *
120
+ * W762: the result is memoized on the LOG STATE it was derived from, because
121
+ * the statusline reads it on every 2s tick and the context viewer on every
122
+ * refresh, while a session log only changes when the engine appends to it.
123
+ * The key is `(log identity, event count, last event reference)`, all three
124
+ * cheap to obtain, and it is COMPLETE within one generation: the profile /
125
+ * trim config is fixed at compose (a config change swaps the generation, not
126
+ * this object) and the tool surface is mounted at compose too, so
127
+ * `registry.schemas()` cannot drift under the cache. A rebind swaps the log,
128
+ * and the identity term catches it even when the new log has the same length.
129
+ *
130
+ * Consumers are read-only by construction (`contextViewOf` maps messages into
131
+ * fresh view rows; `estimatedContextTokens` only reads), so the cached object
132
+ * is shared rather than copied. A MISS costs one extra `events()` copy (the
133
+ * key) on top of the assembly: ~0.35ms at 50k events against an 18ms
134
+ * assembly. A HIT costs only that copy — ~2 orders of magnitude less.
135
+ */
136
+ contextSnapshot(): ModelRequest | null;
137
+ /**
138
+ * W766: the memoized assembly TOGETHER with its token estimate — what the
139
+ * statusline's `context_usage` fallback needs.
140
+ *
141
+ * W755 made the tick read the loop's own assembly; W762 memoized the assembly
142
+ * but left the ESTIMATE to be recomputed on every read, which dominated the
143
+ * tick (7.4ms of a 7.4ms tick at 50k events: an O(bytes) walk of the messages
144
+ * that could not have changed, because the request it walked was the very
145
+ * object the cache had just handed back). The estimate now rides in the same
146
+ * entry, so it is derived once per log state and dropped with the request.
147
+ *
148
+ * Lazy on purpose: the usage-frame path and the context viewer never read the
149
+ * estimate, so a MISS must not pay for it (same reason `tokens`/`assembled`
150
+ * start null rather than being filled by the assembly that built the request).
151
+ */
152
+ assembledContext(): AssembledContext | null;
153
+ /**
154
+ * The memoized snapshot entry for the log state right now, building (and
155
+ * caching) the assembly on a miss. This is the ONE cache the runtime keeps:
156
+ * the request, its estimate and its display wrapper all hang off it, so they
157
+ * can never disagree about which log state they describe.
158
+ */
159
+ private snapshotEntry;
160
+ /** Pending host receipts (worker -> host) that the next turn will inject. */
161
+ pendingReceipts(): number;
162
+ /**
163
+ * Deliver a message into THIS session's inbox on `lane` (W513/W515 §1):
164
+ * `next-turn` = drained at the next turn start (`placement: "queued"`),
165
+ * `next-step` = drained at the next step boundary of the RUNNING turn
166
+ * (`placement: "steering"`). The lane is the caller's decision — the host
167
+ * knows whether a turn is in flight, the runtime does not guess.
168
+ */
169
+ inject(text: string, lane?: InjectionLane, opts?: InboxPushOptions): InjectedMessage;
170
+ /** Messages queued on one lane, or on both (diagnostics / tests). */
171
+ pendingInjections(lane?: InjectionLane): number;
172
+ /**
173
+ * Rebind this generation to the SAME session (same directory, same id) by
174
+ * re-opening its log. Only between turns: mid-turn rebinding would let one
175
+ * turn write into two logs.
176
+ */
177
+ rebind(binding: SessionBinding): SessionLog;
178
+ /**
179
+ * Idempotent + re-entrant shutdown: stop drivers, run the host teardown hooks
180
+ * (process kills), purge mailboxes, clear registries. Repeating it is a no-op
181
+ * returning the first promise, so concurrent callers cannot double-run a hook.
182
+ */
183
+ shutdown(): Promise<void>;
184
+ private doShutdown;
185
+ /**
186
+ * Explicit strong-reference drop (the sync half of a hot swap): stop the
187
+ * runner, abort drivers, release the registry and null every handle. Call it
188
+ * after [shutdown] when the generation is discarded for good.
189
+ */
190
+ release(): void;
191
+ private assertLive;
192
+ }
193
+ /** Host-facing convenience: a sink that only collects frames (tests / CLI). */
194
+ export declare function collectingSink(): {
195
+ frames: TurnFrame[];
196
+ sink: FrameSink;
197
+ };
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The composed runtime engine: one generation's services, wired and ready.
3
+ *
4
+ * A [Runtime] is deliberately thin — it owns the handles and the lifecycle and
5
+ * delegates turn driving to [TurnRunner]:
6
+ *
7
+ * - **turn driving** `runTurn(input, {signal, sink})`, single concurrency slot;
8
+ * - **statusline** `statusline()` reads the live tracker + usage accounting;
9
+ * - **rebinding** `rebind(binding)` re-opens the SAME session directory;
10
+ * - **shutdown** idempotent, re-entrant teardown (drivers, host process
11
+ * hooks, mailbox, registries): calling it twice is a
12
+ * no-op, and a concurrent caller awaits the same promise;
13
+ * - **release** explicit strong-reference drop for hot swaps (W248).
14
+ *
15
+ * `shutdown` also marks the session's checkpoint sidecar as cleanly closed
16
+ * (E §1.3 P0 ⑤), so the next boot can tell a graceful exit from a crash.
17
+ *
18
+ * [release] nulls every handle, which — together with the WeakRef the worker
19
+ * tools hold on the registry — breaks the
20
+ * `Runtime -> ctx -> ToolRegistry -> worker tool -> registry` cycle, so a
21
+ * swapped-out generation can actually be collected.
22
+ */
23
+ import { EVENT_BUS_SERVICE, contextSnapshotOf, createEventBus } from "@celestea/core";
24
+ import { markCleanShutdown } from "@celestea/session";
25
+ import { closeLog } from "./host/engine-session.js";
26
+ import { RuntimeReleasedError, TurnBusyError } from "./errors.js";
27
+ import { bindSession } from "./session-binding.js";
28
+ import { ContextPressure, estimatedContextTokens, statuslineOf, } from "./status.js";
29
+ export class Runtime {
30
+ parts;
31
+ binding;
32
+ shutdownPromise = null;
33
+ released = false;
34
+ /**
35
+ * W755 (Fix B): the context-usage projection state for THIS session (one
36
+ * generation = one session). Deliberately owned here rather than in
37
+ * `RuntimeParts` (compose passes no such thing) and never a module singleton:
38
+ * two live sessions must not share a prompt anchor. [rebind] re-opens the SAME
39
+ * session, so the anchor survives it exactly like the usage tracker's does.
40
+ */
41
+ pressure = new ContextPressure();
42
+ /**
43
+ * W762: the last assembly handed out, with the log state it was built from.
44
+ * One slot, per generation (never a module singleton, never a timer).
45
+ */
46
+ snapshotCache = null;
47
+ constructor(parts) {
48
+ this.parts = parts;
49
+ this.binding = parts.binding;
50
+ if (!parts.ctx.has(EVENT_BUS_SERVICE))
51
+ parts.ctx.provide(EVENT_BUS_SERVICE, createEventBus());
52
+ }
53
+ get p() {
54
+ const parts = this.parts;
55
+ if (parts === null)
56
+ throw new RuntimeReleasedError();
57
+ return parts;
58
+ }
59
+ // --- handles -----------------------------------------------------------
60
+ get ctx() {
61
+ return this.p.ctx;
62
+ }
63
+ get profile() {
64
+ return this.p.profile;
65
+ }
66
+ get agentConfig() {
67
+ return this.p.agentConfig;
68
+ }
69
+ /** The active conversation log (a rebind swaps this handle). */
70
+ get session() {
71
+ return this.p.sessionRef.log;
72
+ }
73
+ get sessionBinding() {
74
+ return this.binding;
75
+ }
76
+ get status() {
77
+ return this.p.status;
78
+ }
79
+ /** The session's injection queue (drained by the turn at step boundaries). */
80
+ get inbox() {
81
+ return this.p.inbox;
82
+ }
83
+ get usage() {
84
+ return this.p.usage;
85
+ }
86
+ get llm() {
87
+ return this.p.llm;
88
+ }
89
+ get tools() {
90
+ return this.p.tools;
91
+ }
92
+ get workers() {
93
+ return this.p.workerHost?.registry ?? null;
94
+ }
95
+ /**
96
+ * W740: the liveness watchdog mounted over this generation's worker registry
97
+ * (null when the watchdog is off). Adjudication itself belongs to the watchdog
98
+ * plugin — this is only the host's handle on it (`tick()` by hand, or read
99
+ * `running`), never a second liveness rule.
100
+ */
101
+ get watchdog() {
102
+ return this.p.workerHost?.watchdog ?? null;
103
+ }
104
+ get hostSessionId() {
105
+ return this.p.workerHost?.hostSessionId ?? null;
106
+ }
107
+ get isBusy() {
108
+ return this.parts !== null && this.parts.runner.isBusy;
109
+ }
110
+ get isReleased() {
111
+ return this.released;
112
+ }
113
+ /** Mounted plugin names, in mount order. */
114
+ get pluginNames() {
115
+ return this.p.plugins;
116
+ }
117
+ // --- driving turns -----------------------------------------------------
118
+ /** Drive one turn; a second concurrent call is a [TurnBusyError] (409). */
119
+ async runTurn(input, opts = {}) {
120
+ this.assertLive();
121
+ return this.p.runner.runTurn(input, opts);
122
+ }
123
+ /** Cancel the in-flight turn (cooperative); false when nothing was running. */
124
+ cancelTurn() {
125
+ return this.parts?.runner.cancel() ?? false;
126
+ }
127
+ /** Snapshot for the statusline reader (SSE status payloads / GET /api/status). */
128
+ statusView() {
129
+ const p = this.p;
130
+ return {
131
+ model: p.profile.model,
132
+ reasoning_effort: p.profile.reasoning_effort,
133
+ status: p.status,
134
+ usage: p.usage,
135
+ context_window: p.profile.context_window_tokens,
136
+ events: () => this.p.sessionRef.log.events(),
137
+ // W755 (Fix A): the SAME assembly `/api/sessions/{id}/context` serves, so
138
+ // the fallback estimate can never drift from the real next request. The
139
+ // statusline is polled (and pushed on every SSE tick), so a failing read
140
+ // degrades to "no snapshot" instead of failing the endpoint.
141
+ assembled: () => {
142
+ try {
143
+ return this.assembledContext();
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ },
149
+ pressure: this.pressure,
150
+ };
151
+ }
152
+ /** The frozen `/api/status` payload for this generation. */
153
+ statusline() {
154
+ return statuslineOf(this.statusView());
155
+ }
156
+ /**
157
+ * W725: this generation's model-visible context, exactly as the loop would
158
+ * build it for the NEXT step (system + trimmed history + tool schemas), or
159
+ * null when the mounted loop has no snapshot capability (a test double).
160
+ *
161
+ * The assembly is the agent loop's, never this layer's: runtime only forwards
162
+ * the Context, so the read-only snapshot cannot drift from the real request.
163
+ *
164
+ * W762: the result is memoized on the LOG STATE it was derived from, because
165
+ * the statusline reads it on every 2s tick and the context viewer on every
166
+ * refresh, while a session log only changes when the engine appends to it.
167
+ * The key is `(log identity, event count, last event reference)`, all three
168
+ * cheap to obtain, and it is COMPLETE within one generation: the profile /
169
+ * trim config is fixed at compose (a config change swaps the generation, not
170
+ * this object) and the tool surface is mounted at compose too, so
171
+ * `registry.schemas()` cannot drift under the cache. A rebind swaps the log,
172
+ * and the identity term catches it even when the new log has the same length.
173
+ *
174
+ * Consumers are read-only by construction (`contextViewOf` maps messages into
175
+ * fresh view rows; `estimatedContextTokens` only reads), so the cached object
176
+ * is shared rather than copied. A MISS costs one extra `events()` copy (the
177
+ * key) on top of the assembly: ~0.35ms at 50k events against an 18ms
178
+ * assembly. A HIT costs only that copy — ~2 orders of magnitude less.
179
+ */
180
+ contextSnapshot() {
181
+ return this.snapshotEntry().request;
182
+ }
183
+ /**
184
+ * W766: the memoized assembly TOGETHER with its token estimate — what the
185
+ * statusline's `context_usage` fallback needs.
186
+ *
187
+ * W755 made the tick read the loop's own assembly; W762 memoized the assembly
188
+ * but left the ESTIMATE to be recomputed on every read, which dominated the
189
+ * tick (7.4ms of a 7.4ms tick at 50k events: an O(bytes) walk of the messages
190
+ * that could not have changed, because the request it walked was the very
191
+ * object the cache had just handed back). The estimate now rides in the same
192
+ * entry, so it is derived once per log state and dropped with the request.
193
+ *
194
+ * Lazy on purpose: the usage-frame path and the context viewer never read the
195
+ * estimate, so a MISS must not pay for it (same reason `tokens`/`assembled`
196
+ * start null rather than being filled by the assembly that built the request).
197
+ */
198
+ assembledContext() {
199
+ const entry = this.snapshotEntry();
200
+ const request = entry.request;
201
+ if (request === null)
202
+ return null;
203
+ entry.assembled ??= { request, tokens: estimatedContextTokens(request) };
204
+ return entry.assembled;
205
+ }
206
+ /**
207
+ * The memoized snapshot entry for the log state right now, building (and
208
+ * caching) the assembly on a miss. This is the ONE cache the runtime keeps:
209
+ * the request, its estimate and its display wrapper all hang off it, so they
210
+ * can never disagree about which log state they describe.
211
+ */
212
+ snapshotEntry() {
213
+ const log = this.p.sessionRef.log;
214
+ const last = lastEventOf(log);
215
+ const cached = this.snapshotCache;
216
+ if (cached !== null && cached.log === log && cached.events === last.count && cached.last === last.event) {
217
+ return cached;
218
+ }
219
+ const request = contextSnapshotOf(this.p.agentLoop, this.p.ctx);
220
+ const entry = { log, events: last.count, last: last.event, request, assembled: null };
221
+ this.snapshotCache = entry;
222
+ return entry;
223
+ }
224
+ /** Pending host receipts (worker -> host) that the next turn will inject. */
225
+ pendingReceipts() {
226
+ const host = this.parts?.workerHost ?? null;
227
+ return host === null ? 0 : host.registry.mailbox.pending(host.hostSessionId);
228
+ }
229
+ /**
230
+ * Deliver a message into THIS session's inbox on `lane` (W513/W515 §1):
231
+ * `next-turn` = drained at the next turn start (`placement: "queued"`),
232
+ * `next-step` = drained at the next step boundary of the RUNNING turn
233
+ * (`placement: "steering"`). The lane is the caller's decision — the host
234
+ * knows whether a turn is in flight, the runtime does not guess.
235
+ */
236
+ inject(text, lane = "next-turn", opts = {}) {
237
+ this.assertLive();
238
+ return this.p.inbox.push(text, lane, opts);
239
+ }
240
+ /** Messages queued on one lane, or on both (diagnostics / tests). */
241
+ pendingInjections(lane) {
242
+ return this.parts?.inbox.pending(lane) ?? 0;
243
+ }
244
+ // --- lifecycle ---------------------------------------------------------
245
+ /**
246
+ * Rebind this generation to the SAME session (same directory, same id) by
247
+ * re-opening its log. Only between turns: mid-turn rebinding would let one
248
+ * turn write into two logs.
249
+ */
250
+ rebind(binding) {
251
+ this.assertLive();
252
+ if (this.p.runner.isBusy)
253
+ throw new TurnBusyError("rebind");
254
+ const previous = this.p.sessionRef.log;
255
+ const log = bindSession(this.p.ctx, binding);
256
+ this.p.sessionRef.log = log;
257
+ // P1-4 (W836): the swapped-out log descriptor is closed HERE, not left for
258
+ // a shutdown that may be generations away; a long-lived studio otherwise
259
+ // leaks one fd per rebind.
260
+ closeLog(previous);
261
+ this.binding = binding;
262
+ // W762: the cached assembly belonged to the log that was just swapped out.
263
+ this.snapshotCache = null;
264
+ return log;
265
+ }
266
+ /**
267
+ * Idempotent + re-entrant shutdown: stop drivers, run the host teardown hooks
268
+ * (process kills), purge mailboxes, clear registries. Repeating it is a no-op
269
+ * returning the first promise, so concurrent callers cannot double-run a hook.
270
+ */
271
+ shutdown() {
272
+ if (this.shutdownPromise === null)
273
+ this.shutdownPromise = this.doShutdown();
274
+ return this.shutdownPromise;
275
+ }
276
+ async doShutdown() {
277
+ const parts = this.parts;
278
+ parts?.runner.stop();
279
+ // P1-5 (W836): stop() only aborts cooperatively. Defer the clean-shutdown
280
+ // claim until the in-flight turn has written its own terminal row.
281
+ await parts?.runner.join();
282
+ // E §1.3 P0 ⑤: a graceful teardown is the ONLY thing that may claim
283
+ // `clean_shutdown: true` in the session's checkpoint sidecar — that flag is
284
+ // what tells the next boot "do not repair", so a crash (no shutdown at all)
285
+ // keeps it false. A log without a checkpoint (tests, embedded use) is a no-op.
286
+ markCleanShutdown(parts?.sessionRef.log);
287
+ // P1-4 (W836): and a graceful teardown is where the log descriptor dies.
288
+ closeLog(parts?.sessionRef.log);
289
+ const host = parts?.workerHost ?? null;
290
+ if (host !== null) {
291
+ host.registry.abortAllNow();
292
+ await host.registry.joinDrivers();
293
+ }
294
+ for (const hook of parts?.shutdownHooks ?? [])
295
+ await runHook(hook);
296
+ if (host !== null) {
297
+ host.registry.mailbox.purgeAll();
298
+ host.registry.sessions.clear();
299
+ }
300
+ this.released = true;
301
+ }
302
+ /**
303
+ * Explicit strong-reference drop (the sync half of a hot swap): stop the
304
+ * runner, abort drivers, release the registry and null every handle. Call it
305
+ * after [shutdown] when the generation is discarded for good.
306
+ */
307
+ release() {
308
+ const parts = this.parts;
309
+ if (parts === null)
310
+ return;
311
+ parts.runner.stop();
312
+ // P1-4 (W836): release is the generation's last breath even when shutdown
313
+ // was skipped (GenerationHub / registry teardown). Close is idempotent, so
314
+ // the host's own closeLog before release cannot fail here.
315
+ closeLog(parts.sessionRef.log);
316
+ if (parts.workerHost !== null) {
317
+ parts.workerHost.registry.abortAllNow();
318
+ parts.workerHost.registry.release();
319
+ }
320
+ this.parts = null;
321
+ this.snapshotCache = null;
322
+ this.released = true;
323
+ }
324
+ assertLive() {
325
+ if (this.released || this.parts === null)
326
+ throw new RuntimeReleasedError();
327
+ }
328
+ }
329
+ async function runHook(hook) {
330
+ try {
331
+ await hook();
332
+ }
333
+ catch {
334
+ // A failing teardown hook must not stop the remaining ones: shutdown is the
335
+ // last thing a generation does, and it has to reach the end.
336
+ }
337
+ }
338
+ /** `(count, last event)` of a log — the cache key half that changes on append. */
339
+ function lastEventOf(log) {
340
+ const events = log.events();
341
+ return { count: events.length, event: events[events.length - 1] };
342
+ }
343
+ /** Host-facing convenience: a sink that only collects frames (tests / CLI). */
344
+ export function collectingSink() {
345
+ const frames = [];
346
+ return { frames, sink: (frame) => frames.push(frame) };
347
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Generation-level config sanitization.
3
+ *
4
+ * `GET/POST /api/config` hands the current generation's configuration to the
5
+ * client, so the projection is a whitelist: only the twelve frozen profile keys
6
+ * (minus the two key-bearing ones, which become existence flags) plus the
7
+ * derived loop budget. The result is then run through core's redactor before it
8
+ * is ever serialized, so a credential that sneaks into `base_url` cannot leave
9
+ * the process (ARCHITECTURE.md §6.3).
10
+ */
11
+ import type { Profile } from "./profile.js";
12
+ /** Client-facing projection of a [Profile]: no secret value, ever. */
13
+ export interface SanitizedConfig {
14
+ model: string;
15
+ base_url: string;
16
+ request_format: Profile["request_format"];
17
+ reasoning_effort: string | null;
18
+ max_steps: number;
19
+ max_parallel_tool_calls: number;
20
+ max_output_tokens: number | null;
21
+ context_window_tokens: number;
22
+ temperature: number | null;
23
+ system_prompt: string;
24
+ /** Env var NAME the key is read from — a name is not a secret. */
25
+ api_key_env: string;
26
+ has_api_key_file: boolean;
27
+ }
28
+ /** Whitelist projection of a profile (`api_key_file` becomes a boolean). */
29
+ export declare function sanitizeProfile(profile: Profile): SanitizedConfig;
30
+ /**
31
+ * The sanitized config as stable, redaction-clean JSON text. Redaction runs on
32
+ * every serialization (not once at compose), so the export can never carry a
33
+ * secret even if a future profile key smuggles one in.
34
+ */
35
+ export declare function sanitizeConfigJson(config: SanitizedConfig): string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Generation-level config sanitization.
3
+ *
4
+ * `GET/POST /api/config` hands the current generation's configuration to the
5
+ * client, so the projection is a whitelist: only the twelve frozen profile keys
6
+ * (minus the two key-bearing ones, which become existence flags) plus the
7
+ * derived loop budget. The result is then run through core's redactor before it
8
+ * is ever serialized, so a credential that sneaks into `base_url` cannot leave
9
+ * the process (ARCHITECTURE.md §6.3).
10
+ */
11
+ import { createRedactor, stableStringify } from "@celestea/core";
12
+ /** Whitelist projection of a profile (`api_key_file` becomes a boolean). */
13
+ export function sanitizeProfile(profile) {
14
+ return {
15
+ model: profile.model,
16
+ base_url: profile.base_url,
17
+ request_format: profile.request_format,
18
+ reasoning_effort: profile.reasoning_effort,
19
+ max_steps: profile.max_steps,
20
+ max_parallel_tool_calls: profile.max_parallel_tool_calls,
21
+ max_output_tokens: profile.max_output_tokens,
22
+ context_window_tokens: profile.context_window_tokens,
23
+ temperature: profile.temperature,
24
+ system_prompt: profile.system_prompt,
25
+ api_key_env: profile.api_key_env,
26
+ has_api_key_file: profile.api_key_file !== null,
27
+ };
28
+ }
29
+ /**
30
+ * The sanitized config as stable, redaction-clean JSON text. Redaction runs on
31
+ * every serialization (not once at compose), so the export can never carry a
32
+ * secret even if a future profile key smuggles one in.
33
+ */
34
+ export function sanitizeConfigJson(config) {
35
+ return createRedactor([]).redact(stableStringify(config));
36
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Session binding — how a generation is tied to ONE conversation.
3
+ *
4
+ * A binding is (session id + directory + a way to open the log). Keeping the
5
+ * opener as a callback is what lets the runtime stay out of the persistence
6
+ * business: `packages/session` owns JSONL replay/repair, the host passes
7
+ * `() => PersistentSessionLog.open(dir, id)`, and the runtime only knows the
8
+ * `SessionLog` seam.
9
+ *
10
+ * Rebinding re-opens the SAME directory/id (`binding.open()` again) and
11
+ * re-provides the result under `SESSION_LOG_SERVICE`; a later `provide` of the
12
+ * same token replaces the earlier one, so every consumer that resolves the
13
+ * service lazily (the agent loop does, per turn) sees the new log while an
14
+ * in-flight reader keeps the object it already holds.
15
+ */
16
+ import { type Context, type SessionLog } from "@celestea/core";
17
+ export interface SessionBinding {
18
+ /** Conversation id (`<workspace>/<session>` or the host id). */
19
+ readonly sessionId: string;
20
+ /** Session directory; the same value is reused by every rebind. */
21
+ readonly dir: string | null;
22
+ /** Open (or re-open) the log for this binding. Called at compose and on rebind. */
23
+ open(): SessionLog;
24
+ }
25
+ export interface SessionBindingSpec {
26
+ sessionId: string;
27
+ dir?: string | null;
28
+ open: () => SessionLog;
29
+ }
30
+ /** Ergonomic constructor for a binding (a plain object literal also works). */
31
+ export declare function createSessionBinding(spec: SessionBindingSpec): SessionBinding;
32
+ /**
33
+ * Open the binding and provide it into the context (last `provide` wins). The
34
+ * opened log is returned so the caller can keep a direct handle.
35
+ */
36
+ export declare function bindSession(ctx: Context, binding: SessionBinding): SessionLog;