@genesislcap/ai-assistant 15.86.1 → 15.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/ai-assistant.api.json +5026 -645
  2. package/dist/ai-assistant.d.ts +802 -0
  3. package/dist/chat-driver.cjs +1487 -132
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +1480 -132
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +2779 -657
  8. package/dist/dts/autopilot/autopilot-session.d.ts +25 -0
  9. package/dist/dts/autopilot/autopilot-session.d.ts.map +1 -1
  10. package/dist/dts/chat-driver-node.d.ts +2 -0
  11. package/dist/dts/chat-driver-node.d.ts.map +1 -1
  12. package/dist/dts/components/ai-driver/ai-driver.d.ts +15 -0
  13. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  14. package/dist/dts/components/ai-driver/base-driver-decorator.d.ts +6 -0
  15. package/dist/dts/components/ai-driver/base-driver-decorator.d.ts.map +1 -1
  16. package/dist/dts/components/chat-driver/chat-driver.d.ts +7 -0
  17. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  18. package/dist/dts/components/chat-driver/interaction-controller.d.ts +12 -0
  19. package/dist/dts/components/chat-driver/interaction-controller.d.ts.map +1 -1
  20. package/dist/dts/headless/describe-interaction.d.ts +17 -0
  21. package/dist/dts/headless/describe-interaction.d.ts.map +1 -0
  22. package/dist/dts/headless/headless-debug.d.ts +28 -0
  23. package/dist/dts/headless/headless-debug.d.ts.map +1 -0
  24. package/dist/dts/headless/headless-persistence.d.ts +35 -0
  25. package/dist/dts/headless/headless-persistence.d.ts.map +1 -0
  26. package/dist/dts/headless/headless-session.d.ts +180 -0
  27. package/dist/dts/headless/headless-session.d.ts.map +1 -0
  28. package/dist/dts/headless/headless.types.d.ts +435 -0
  29. package/dist/dts/headless/headless.types.d.ts.map +1 -0
  30. package/dist/dts/headless/index.d.ts +5 -0
  31. package/dist/dts/headless/index.d.ts.map +1 -0
  32. package/dist/dts/headless-server/compact.d.ts +52 -0
  33. package/dist/dts/headless-server/compact.d.ts.map +1 -0
  34. package/dist/dts/headless-server/index.d.ts +23 -0
  35. package/dist/dts/headless-server/index.d.ts.map +1 -0
  36. package/dist/dts/headless-server/server.d.ts +13 -0
  37. package/dist/dts/headless-server/server.d.ts.map +1 -0
  38. package/dist/dts/headless-server/types.d.ts +210 -0
  39. package/dist/dts/headless-server/types.d.ts.map +1 -0
  40. package/dist/dts/index.d.ts +2 -0
  41. package/dist/dts/index.d.ts.map +1 -1
  42. package/dist/dts/main/main.d.ts.map +1 -1
  43. package/dist/dts/state/debug-event-log.d.ts +1 -1
  44. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  45. package/dist/dts/state/decision-surface.d.ts +5 -4
  46. package/dist/dts/state/decision-surface.d.ts.map +1 -1
  47. package/dist/dts/state/persistence/debug-log-assembly.d.ts +49 -0
  48. package/dist/dts/state/persistence/debug-log-assembly.d.ts.map +1 -0
  49. package/dist/dts/state/persistence/index.d.ts +1 -0
  50. package/dist/dts/state/persistence/index.d.ts.map +1 -1
  51. package/dist/dts/state/persistence/session-persister.d.ts +7 -0
  52. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  53. package/dist/dts/types/interaction-surface.d.ts +30 -0
  54. package/dist/dts/types/interaction-surface.d.ts.map +1 -1
  55. package/dist/esm/autopilot/autopilot-session.js +31 -16
  56. package/dist/esm/chat-driver-node.js +8 -0
  57. package/dist/esm/components/ai-driver/base-driver-decorator.js +12 -0
  58. package/dist/esm/components/chat-driver/chat-driver.js +8 -0
  59. package/dist/esm/components/chat-driver/interaction-controller.js +66 -0
  60. package/dist/esm/headless/describe-interaction.js +55 -0
  61. package/dist/esm/headless/headless-debug.js +62 -0
  62. package/dist/esm/headless/headless-persistence.js +192 -0
  63. package/dist/esm/headless/headless-session.js +808 -0
  64. package/dist/esm/headless/headless.types.js +1 -0
  65. package/dist/esm/headless/index.js +3 -0
  66. package/dist/esm/headless-server/compact.js +160 -0
  67. package/dist/esm/headless-server/index.js +21 -0
  68. package/dist/esm/headless-server/server.js +746 -0
  69. package/dist/esm/headless-server/types.js +3 -0
  70. package/dist/esm/index.js +3 -0
  71. package/dist/esm/main/main.js +18 -37
  72. package/dist/esm/state/debug-event-log.js +6 -1
  73. package/dist/esm/state/decision-surface.js +75 -48
  74. package/dist/esm/state/persistence/debug-log-assembly.js +69 -0
  75. package/dist/esm/state/persistence/index.js +1 -0
  76. package/dist/esm/state/persistence/session-persister.js +16 -0
  77. package/dist/headless-server.cjs +853 -0
  78. package/dist/headless-server.cjs.map +7 -0
  79. package/dist/headless-server.mjs +819 -0
  80. package/dist/headless-server.mjs.map +7 -0
  81. package/package.json +28 -18
  82. package/scripts/build-headless-server-node.mjs +42 -0
  83. package/scripts/smoke-chat-driver-node.mjs +3 -0
  84. package/scripts/smoke-headless-server-node.mjs +112 -0
@@ -0,0 +1,746 @@
1
+ import { __awaiter } from "tslib";
2
+ import { createWriteStream, mkdirSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
5
+ import { join, resolve } from 'node:path';
6
+ import { capOutput, compactDecision, compactEvent, present, shortDecision } from './compact';
7
+ import { DECISION_TEXTS, DEFAULT_DECISION_TEXT, } from './types';
8
+ const DEFAULT_MAX_WAIT_MS = 100000;
9
+ /** The longest a command may ask to wait (an hour), ms. A larger `maxWaitMs` is cut to it. */
10
+ const MAX_WAIT_CAP_MS = 3600000;
11
+ /** The ops the server answers itself: a host's `extraOps` cannot take their names. */
12
+ const BUILTIN_OPS = [
13
+ 'send',
14
+ 'act',
15
+ 'resolve',
16
+ 'autopilot',
17
+ 'wait',
18
+ 'state',
19
+ 'read',
20
+ 'debugLog',
21
+ 'stop',
22
+ ];
23
+ const BYTES_PER_KB = 1024;
24
+ const MAX_BODY_MB = 16;
25
+ const MAX_BODY_BYTES = MAX_BODY_MB * BYTES_PER_KB * BYTES_PER_KB;
26
+ /** HTTP statuses the server answers with. */
27
+ const HTTP_OK = 200;
28
+ const HTTP_BAD_REQUEST = 400;
29
+ const HTTP_FORBIDDEN = 403;
30
+ const HTTP_NOT_FOUND = 404;
31
+ const HTTP_UNSUPPORTED_MEDIA_TYPE = 415;
32
+ /** The addresses that mean this machine: a bound `host` that is none of them is the host's own choice. */
33
+ const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]', '::1'];
34
+ const HTTP_SERVER_ERROR = 500;
35
+ /** How long `shutdown` lets a reply on its way finish before it drops every connection, ms. */
36
+ const CLOSE_GRACE_MS = 500;
37
+ /** Cost is reported to four decimal places. */
38
+ const MONEY_SCALE = 10000;
39
+ /** How long the log watcher sleeps between looks when nothing happens. */
40
+ const WATCH_MS = 2000;
41
+ const ALL_EVENT_TYPES = [
42
+ 'decision',
43
+ 'working',
44
+ 'settled',
45
+ 'message',
46
+ 'turn',
47
+ 'autopilot',
48
+ 'probe',
49
+ 'error',
50
+ ];
51
+ /** `until` as the caller wrote it: one name or several. `idle` is the server's own, not an event type. */
52
+ function untilOf(value) {
53
+ if (value === undefined)
54
+ return undefined;
55
+ const names = (Array.isArray(value) ? value : [value]).map(String);
56
+ return {
57
+ types: names.filter((n) => ALL_EVENT_TYPES.includes(n)),
58
+ idle: names.includes('idle'),
59
+ };
60
+ }
61
+ /** `maxWaitMs` as the caller wrote it: a finite number cut to `0..MAX_WAIT_CAP_MS`, else the default. */
62
+ function waitMsOf(value, fallback) {
63
+ const n = Number(value !== null && value !== void 0 ? value : fallback);
64
+ return Number.isFinite(n) ? Math.min(Math.max(0, n), MAX_WAIT_CAP_MS) : fallback;
65
+ }
66
+ const hasOwn = (target, key) => !!target && Object.prototype.hasOwnProperty.call(target, key);
67
+ const money = (usd) => Math.round(usd * MONEY_SCALE) / MONEY_SCALE;
68
+ /**
69
+ * The headless server (PLAN §5, D7): `node:http` on 127.0.0.1, `GET /health` and `POST /cmd`.
70
+ *
71
+ * Every command is APPLIED and then LONG-POLLS (§5.5): the reply comes when the journey next needs the
72
+ * person (an interaction is held by them and ready), a turn ends, the run ends, or something fails, or at
73
+ * `maxWaitMs` with `status: "working"`. Nothing is lost on a client timeout: the command was applied before
74
+ * the wait, events carry `seq`, and `{op: "wait", since}` returns everything after it.
75
+ *
76
+ * The session is the only thing it drives; what the host owns is reached through {@link ServerOptions}.
77
+ */
78
+ // oxlint-disable-next-line max-lines-per-function -- one closure over the session, the log and the HTTP server, as it was in Create
79
+ export function createHeadlessServer(options) {
80
+ var _a, _b;
81
+ const runDir = resolve(options.runDir);
82
+ mkdirSync(runDir, { recursive: true });
83
+ const defaultMaxWaitMs = waitMsOf(options.defaultMaxWaitMs, DEFAULT_MAX_WAIT_MS);
84
+ let decisionText = (_a = options.decisionText) !== null && _a !== void 0 ? _a : DEFAULT_DECISION_TEXT;
85
+ const announce = (_b = options.announce) !== null && _b !== void 0 ? _b : ((line) => console.log(line));
86
+ const events = createWriteStream(join(runDir, 'events.jsonl'), { flags: 'a' });
87
+ const startedAt = Date.now();
88
+ let session;
89
+ let http;
90
+ let closing = false;
91
+ let loggedSeq = 0;
92
+ /** Serialises the APPLY phase of commands that change things; a long wait never holds it. */
93
+ let applying = Promise.resolve();
94
+ /** The save chain: every flush runs after the one before it, so the last one sees the latest state. */
95
+ let flushing = Promise.resolve();
96
+ let resolveClosed;
97
+ const closed = new Promise((r) => {
98
+ resolveClosed = r;
99
+ });
100
+ const appendLog = (line) => {
101
+ if (!events.writableEnded)
102
+ events.write(`${JSON.stringify(line)}\n`);
103
+ };
104
+ /** Write every event the log has not yet written. */
105
+ function drainLog() {
106
+ if (!session)
107
+ return;
108
+ for (const event of session.eventsSince(loggedSeq)) {
109
+ appendLog(Object.assign({ kind: 'event' }, event));
110
+ loggedSeq = event.seq;
111
+ }
112
+ }
113
+ /** Save what the host and the session hold, after any save already running. Never rejects. */
114
+ function flushAll() {
115
+ flushing = flushing.then(() => __awaiter(this, void 0, void 0, function* () {
116
+ var _a;
117
+ try {
118
+ yield ((_a = options.flush) === null || _a === void 0 ? void 0 : _a.call(options));
119
+ yield (session === null || session === void 0 ? void 0 : session.flush());
120
+ }
121
+ catch (e) {
122
+ appendLog({
123
+ kind: 'error',
124
+ where: 'flush',
125
+ message: e instanceof Error ? e.message : String(e),
126
+ });
127
+ }
128
+ }));
129
+ return flushing;
130
+ }
131
+ /** Follow the log in the background: write it down, and save at every turn end, whoever's turn. */
132
+ function watch(s) {
133
+ return __awaiter(this, void 0, void 0, function* () {
134
+ let cursor = s.state().lastSeq;
135
+ // Everything before the attach goes in the log too.
136
+ drainLog();
137
+ const watching = () => !closing && session === s;
138
+ while (watching()) {
139
+ try {
140
+ // oxlint-disable-next-line no-await-in-loop -- a sequential watcher and poll: each step needs the last
141
+ const woken = yield s.wait({ since: cursor, until: ALL_EVENT_TYPES, maxWaitMs: WATCH_MS });
142
+ cursor = woken.nextSeq;
143
+ drainLog();
144
+ // oxlint-disable-next-line no-await-in-loop -- a sequential watcher and poll: each step needs the last
145
+ if (woken.events.some((e) => e.type === 'turn'))
146
+ yield flushAll();
147
+ if (woken.status === 'closed')
148
+ break;
149
+ }
150
+ catch (e) {
151
+ appendLog({
152
+ kind: 'error',
153
+ where: 'watch',
154
+ message: e instanceof Error ? e.message : String(e),
155
+ });
156
+ break;
157
+ }
158
+ }
159
+ });
160
+ }
161
+ const handle = {
162
+ get session() {
163
+ return session;
164
+ },
165
+ runDir,
166
+ get decisionText() {
167
+ return decisionText;
168
+ },
169
+ setDecisionText(mode) {
170
+ decisionText = mode;
171
+ },
172
+ attach(next) {
173
+ if (session === next)
174
+ return;
175
+ session = next;
176
+ // A new session numbers its events from 1: the old one's last `seq` would hide its first ones.
177
+ loggedSeq = 0;
178
+ void watch(next);
179
+ },
180
+ listen,
181
+ handle: runCommand,
182
+ shutdown,
183
+ closed,
184
+ };
185
+ if (options.session)
186
+ handle.attach(options.session);
187
+ // ---------------------------------------------------------------------------------------- replies
188
+ const composerOf = (state) => {
189
+ var _a;
190
+ if (!state)
191
+ return 'blocked:not-started';
192
+ const blocked = (_a = options.composerBlockedReason) === null || _a === void 0 ? void 0 : _a.call(options);
193
+ if (blocked)
194
+ return `blocked:${blocked}`;
195
+ return state.busy ? 'busy' : 'open';
196
+ };
197
+ /** The status a reply with no waiting reports: what the session is doing now. */
198
+ function statusNow(state) {
199
+ var _a, _b;
200
+ if (!state)
201
+ return 'idle';
202
+ if (state.pending)
203
+ return 'decision';
204
+ if ((_a = options.isEnded) === null || _a === void 0 ? void 0 : _a.call(options))
205
+ return 'ended';
206
+ if ((_b = state.autopilot) === null || _b === void 0 ? void 0 : _b.needsUser)
207
+ return 'needs-user';
208
+ return state.busy ? 'working' : 'idle';
209
+ }
210
+ const probeView = (state) => {
211
+ const view = options.probeEvent;
212
+ if (!view)
213
+ return undefined;
214
+ return Object.fromEntries(view.keys.map((key) => { var _a; return [key, (_a = state.probe[key]) !== null && _a !== void 0 ? _a : null]; }));
215
+ };
216
+ /** What is going on, in words: the host's account of the open widget, or whose turn it is with none open. */
217
+ function stageOf(state, other) {
218
+ var _a, _b, _c;
219
+ if (other) {
220
+ let stage;
221
+ try {
222
+ stage = (_a = options.describeWorking) === null || _a === void 0 ? void 0 : _a.call(options, other.interactionId, other.component);
223
+ }
224
+ catch (_d) {
225
+ /* a stage is a nicety: a host that cannot say is the same as one that has nothing to say */
226
+ }
227
+ if (stage)
228
+ return stage;
229
+ // A paused autopilot answers nothing, whatever its `working` flag says.
230
+ const idle = other.heldBy === 'autopilot' && ((_b = state.autopilot) === null || _b === void 0 ? void 0 : _b.mode) !== 'running';
231
+ return other.heldBy !== 'user' && !idle ? `${other.heldBy} is answering it` : undefined;
232
+ }
233
+ // The flag can stay true while the mode is `paused`: only a running autopilot is doing anything.
234
+ if (((_c = state.autopilot) === null || _c === void 0 ? void 0 : _c.working) && state.autopilot.mode === 'running') {
235
+ return 'autopilot is working';
236
+ }
237
+ return state.busy ? 'assistant is thinking (no widget open)' : undefined;
238
+ }
239
+ function workingInfo(state, startedMs) {
240
+ var _a;
241
+ const other = (_a = state.others.find((o) => !o.ready || o.heldBy !== 'user')) !== null && _a !== void 0 ? _a : state.others[0];
242
+ return Object.assign(Object.assign(Object.assign({}, (other ? { component: other.component, heldBy: other.heldBy } : {})), { elapsedMs: Date.now() - startedMs }), present({ probe: probeView(state), stage: stageOf(state, other) }));
243
+ }
244
+ /** A command's own `decisionText` when it names a valid one, else the server's. */
245
+ const decisionTextFor = (command) => DECISION_TEXTS.includes(command === null || command === void 0 ? void 0 : command.decisionText)
246
+ ? command.decisionText
247
+ : decisionText;
248
+ function reply(command, status, since, extra = {}, startedMs = Date.now()) {
249
+ var _a, _b, _c;
250
+ const state = session === null || session === void 0 ? void 0 : session.state();
251
+ const compact = since
252
+ .map((e) => compactEvent(e, { probeEvent: options.probeEvent }))
253
+ .filter((e) => !!e);
254
+ return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ ok: (_a = extra.ok) !== null && _a !== void 0 ? _a : true }, ((command === null || command === void 0 ? void 0 : command.id) !== undefined ? { id: command.id } : {})), { status }), (compact.length ? { since: compact } : {})), ((state === null || state === void 0 ? void 0 : state.pending)
255
+ ? { decision: compactDecision(state.pending, decisionTextFor(command)) }
256
+ : {})), (status === 'working' && state ? { working: workingInfo(state, startedMs) } : {})), { composer: composerOf(state), cost: { usd: money((_b = state === null || state === void 0 ? void 0 : state.cost.costUsd) !== null && _b !== void 0 ? _b : 0) }, nextSeq: (_c = state === null || state === void 0 ? void 0 : state.lastSeq) !== null && _c !== void 0 ? _c : 0 }), extra);
257
+ }
258
+ /** A refusal: the reason, and where things stand (including the decision, if one is waiting). */
259
+ function refuse(command, error, reason, since = undefined,
260
+ /** The interaction an `act` was refused on: when it is the pending one, its card is not sent again. */
261
+ shortFor) {
262
+ const state = session === null || session === void 0 ? void 0 : session.state();
263
+ const missed = session && since !== undefined ? session.eventsSince(since) : [];
264
+ const pending = state === null || state === void 0 ? void 0 : state.pending;
265
+ return reply(command, statusNow(state), missed, Object.assign(Object.assign({ ok: false, error }, (reason ? { reason } : {})), (pending && shortFor !== undefined && pending.interactionId === shortFor
266
+ ? { decision: shortDecision(pending.interactionId, pending.component, pending.actions) }
267
+ : {})));
268
+ }
269
+ /**
270
+ * The reply to an action that ran and did not settle the interaction (a form field set): at once, with what
271
+ * it returned and the actions as they now stand, and not the card, which the caller has read.
272
+ */
273
+ function edited(command, interactionId, result, cursor) {
274
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
275
+ const s = session;
276
+ const state = s.state();
277
+ const open = ((_a = state.pending) === null || _a === void 0 ? void 0 : _a.interactionId) === interactionId ? state.pending : undefined;
278
+ const actions = (_d = (_c = (_b = result.description) === null || _b === void 0 ? void 0 : _b.actions) !== null && _c !== void 0 ? _c : open === null || open === void 0 ? void 0 : open.actions) !== null && _d !== void 0 ? _d : result.actions;
279
+ const component = (_j = (_g = (_f = (_e = result.description) === null || _e === void 0 ? void 0 : _e.component) !== null && _f !== void 0 ? _f : open === null || open === void 0 ? void 0 : open.component) !== null && _g !== void 0 ? _g : (_h = state.others.find((o) => o.interactionId === interactionId)) === null || _h === void 0 ? void 0 : _h.component) !== null && _j !== void 0 ? _j : '';
280
+ return reply(command, 'edited', s.eventsSince(cursor), Object.assign({ decision: shortDecision(interactionId, component, actions) }, (result.output !== undefined ? { output: capOutput(result.output) } : {})));
281
+ }
282
+ // ------------------------------------------------------------------------------------ the long poll
283
+ /**
284
+ * Wait for the journey to need the caller, then reply with everything since `cursor`. A `signal` that
285
+ * aborts (the connection closed) ends the wait with a `working` reply that no one will read. The terminal
286
+ * conditions are read from the session's live state, not from the events alone, so an event that has
287
+ * since been overtaken (a decision the autopilot took back) does not end the wait.
288
+ */
289
+ function poll(command, cursor, startedMs, signal) {
290
+ return __awaiter(this, void 0, void 0, function* () {
291
+ const s = session;
292
+ if (!s)
293
+ return reply(command, 'idle', [], {}, startedMs);
294
+ const maxWaitMs = waitMsOf(command.maxWaitMs, defaultMaxWaitMs);
295
+ const deadline = startedMs + maxWaitMs;
296
+ const until = untilOf(command.until);
297
+ let waitFrom = cursor;
298
+ for (;;) {
299
+ const since = s.eventsSince(cursor);
300
+ const state = s.state();
301
+ const verdict = terminal(since, state, until);
302
+ if (verdict) {
303
+ // oxlint-disable-next-line no-await-in-loop -- a sequential watcher and poll: each step needs the last
304
+ if (since.some((e) => e.type === 'turn') || verdict === 'ended')
305
+ yield flushAll();
306
+ drainLog();
307
+ return reply(command, verdict, s.eventsSince(cursor), {}, startedMs);
308
+ }
309
+ const remaining = deadline - Date.now();
310
+ // A caller that hung up has no one to wait for: stop looking, and let the waiter go.
311
+ if (remaining <= 0 || (signal === null || signal === void 0 ? void 0 : signal.aborted)) {
312
+ drainLog();
313
+ return reply(command, 'working', since, {}, startedMs);
314
+ }
315
+ // oxlint-disable-next-line no-await-in-loop -- a sequential watcher and poll: each step needs the last
316
+ const woken = yield s.wait(Object.assign(Object.assign(Object.assign({ since: waitFrom }, (until ? { until: until.types } : {})), { maxWaitMs: remaining }), (signal ? { signal } : {})));
317
+ waitFrom = woken.nextSeq;
318
+ if (woken.status === 'closed') {
319
+ drainLog();
320
+ return reply(command, 'ended', s.eventsSince(cursor), {}, startedMs);
321
+ }
322
+ }
323
+ });
324
+ }
325
+ /** The status this state and these events call for, or `undefined` to keep waiting. */
326
+ function terminal(since, state, until) {
327
+ var _a, _b;
328
+ const turnEnded = since.some((e) => e.type === 'turn');
329
+ const failed = since.some((e) => e.type === 'error' || (e.type === 'turn' && e.failureReason));
330
+ if (((_a = options.isEnded) === null || _a === void 0 ? void 0 : _a.call(options)) && !state.pending && (turnEnded || !state.busy))
331
+ return 'ended';
332
+ if (until) {
333
+ if (state.pending && until.types.includes('decision'))
334
+ return 'decision';
335
+ if (since.some((e) => until.types.includes(e.type))) {
336
+ return since.some((e) => e.type === 'error')
337
+ ? 'error'
338
+ : turnEnded
339
+ ? 'turn'
340
+ : statusNow(state);
341
+ }
342
+ if (until.idle && !state.busy && !state.pending)
343
+ return 'idle';
344
+ return undefined;
345
+ }
346
+ if (state.pending)
347
+ return 'decision';
348
+ if (failed)
349
+ return 'error';
350
+ if (turnEnded)
351
+ return 'turn';
352
+ if ((_b = state.autopilot) === null || _b === void 0 ? void 0 : _b.needsUser)
353
+ return 'needs-user';
354
+ return undefined;
355
+ }
356
+ // ----------------------------------------------------------------------------------------- the ops
357
+ const isOpen = (state, interactionId) => {
358
+ var _a;
359
+ return ((_a = state.pending) === null || _a === void 0 ? void 0 : _a.interactionId) === interactionId ||
360
+ state.others.some((o) => o.interactionId === interactionId);
361
+ };
362
+ /** The standard refusal for a command on an interaction that is no longer (or never was) pending. */
363
+ const gone = (command, interactionId, since) => refuse(command, `interaction ${interactionId} is not pending: it has settled, or never existed`, 'ended', since);
364
+ const notStarted = (command) => {
365
+ var _a;
366
+ return refuse(command, ((_a = options.extraOps) === null || _a === void 0 ? void 0 : _a.start)
367
+ ? 'not started: send {"op":"start", …} first'
368
+ : 'no session is attached', 'not-started');
369
+ };
370
+ const str = (value) => typeof value === 'string' ? value : undefined;
371
+ /** `act`'s action: a digit string is the option's number. */
372
+ const actionOf = (value) => typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
373
+ function applyOp(command, s, cursor) {
374
+ return __awaiter(this, void 0, void 0, function* () {
375
+ const state = s.state();
376
+ switch (command.op) {
377
+ case 'send': {
378
+ const text = str(command.text);
379
+ if (text === undefined)
380
+ return refuse(command, '`send` needs `text`', 'invalid-command');
381
+ const paths = Array.isArray(command.files) ? command.files.map(String) : [];
382
+ let attachments;
383
+ if (paths.length) {
384
+ if (!options.readAttachment) {
385
+ return refuse(command, 'this server cannot read files', 'invalid-command');
386
+ }
387
+ try {
388
+ attachments = yield Promise.all(paths.map((p) => options.readAttachment(p)));
389
+ }
390
+ catch (e) {
391
+ return refuse(command, `could not read a file: ${e instanceof Error ? e.message : String(e)}`, 'invalid-command');
392
+ }
393
+ }
394
+ const sent = s.send(text, Object.assign(Object.assign({}, (attachments ? { attachments } : {})), (str(command.agent) ? { agent: str(command.agent) } : {})));
395
+ return sent.ok ? 'poll' : refuse(command, sent.error, 'refused', cursor);
396
+ }
397
+ case 'act': {
398
+ const interactionId = str(command.interactionId);
399
+ if (!interactionId || command.action === undefined) {
400
+ return refuse(command, '`act` needs `interactionId` and `action`', 'invalid-command');
401
+ }
402
+ if (!isOpen(state, interactionId))
403
+ return gone(command, interactionId, cursor);
404
+ const result = yield s.act(interactionId, actionOf(command.action), Object.assign(Object.assign(Object.assign({}, (str(command.text) !== undefined ? { text: str(command.text) } : {})), (Array.isArray(command.options) ? { options: command.options.map(String) } : {})), (command.fields !== undefined ? { fields: command.fields } : {})));
405
+ if (!result.ok)
406
+ return refuse(command, result.message, result.error, cursor, interactionId);
407
+ // An edit (not a finishing action) settles nothing, so there is nothing to wait for.
408
+ if (result.finished === false)
409
+ return edited(command, interactionId, result, cursor);
410
+ return 'poll';
411
+ }
412
+ case 'resolve': {
413
+ const interactionId = str(command.interactionId);
414
+ if (!interactionId)
415
+ return refuse(command, '`resolve` needs `interactionId` and `result`', 'invalid-command');
416
+ if (!isOpen(state, interactionId))
417
+ return gone(command, interactionId, cursor);
418
+ const resolved = s.resolve(interactionId, command.result);
419
+ return resolved.ok ? 'poll' : refuse(command, resolved.error, 'refused', cursor);
420
+ }
421
+ case 'autopilot': {
422
+ const mode = {
423
+ pause: 'pause',
424
+ paused: 'pause',
425
+ resume: 'resume',
426
+ running: 'resume',
427
+ stop: 'stop',
428
+ }[String(command.mode)];
429
+ if (!mode)
430
+ return refuse(command, '`autopilot` needs `mode`: running, paused or stop', 'invalid-command');
431
+ const done = s.autopilot(mode);
432
+ return done.ok ? 'poll' : refuse(command, done.error, 'refused', cursor);
433
+ }
434
+ case 'wait':
435
+ return 'poll';
436
+ default:
437
+ return refuse(command, `unknown op "${command.op}"`, 'unknown-op');
438
+ }
439
+ });
440
+ }
441
+ /** Ops that report and do not wait. */
442
+ function readOp(command, s) {
443
+ return __awaiter(this, void 0, void 0, function* () {
444
+ var _a, _b, _c, _d;
445
+ switch (command.op) {
446
+ case 'state': {
447
+ const state = s.state();
448
+ return reply(command, statusNow(state), [], Object.assign(Object.assign(Object.assign(Object.assign({}, (state.others.length ? { others: state.others } : {})), (state.autopilot ? { autopilot: state.autopilot } : {})), (Object.keys(state.probe).length ? { probe: state.probe } : {})), { busy: state.busy }));
449
+ }
450
+ case 'read': {
451
+ const interactionId = str(command.interactionId);
452
+ if (!interactionId)
453
+ return refuse(command, '`read` needs `interactionId`', 'invalid-command');
454
+ const read = yield s.read(interactionId, Object.assign(Object.assign(Object.assign({}, (str(command.view) ? { view: str(command.view) } : {})), (str(command.tool) ? { tool: str(command.tool) } : {})), (command.args && typeof command.args === 'object'
455
+ ? { args: command.args }
456
+ : {})));
457
+ return read.ok
458
+ ? Object.assign(Object.assign({ ok: true }, (command.id !== undefined ? { id: command.id } : {})), { content: read.content })
459
+ : Object.assign(Object.assign({ ok: false }, (command.id !== undefined ? { id: command.id } : {})), { error: read.error });
460
+ }
461
+ case 'debugLog': {
462
+ yield flushAll();
463
+ const log = yield s.debugLog();
464
+ const path = join(runDir, 'debug-log.json');
465
+ yield writeFile(path, JSON.stringify(log));
466
+ const byKind = {};
467
+ for (const entry of log.timeline) {
468
+ const kind = String((_a = entry.kind) !== null && _a !== void 0 ? _a : 'unknown');
469
+ byKind[kind] = ((_b = byKind[kind]) !== null && _b !== void 0 ? _b : 0) + 1;
470
+ }
471
+ const usd = (_d = (_c = log.sessionUsage) === null || _c === void 0 ? void 0 : _c.costUsd) !== null && _d !== void 0 ? _d : s.state().cost.costUsd;
472
+ return reply(command, statusNow(s.state()), [], {
473
+ path,
474
+ entries: { total: log.timeline.length, byKind },
475
+ cost: { usd: money(usd) },
476
+ });
477
+ }
478
+ default:
479
+ return undefined;
480
+ }
481
+ });
482
+ }
483
+ /** Run one command to its reply. Never throws: a failure is a reply. */
484
+ function runCommand(raw, signal) {
485
+ return __awaiter(this, void 0, void 0, function* () {
486
+ const startedMs = Date.now();
487
+ const command = raw;
488
+ if (!command || typeof command !== 'object' || typeof command.op !== 'string') {
489
+ return reply(undefined, statusNow(session === null || session === void 0 ? void 0 : session.state()), [], {
490
+ ok: false,
491
+ error: 'a command is a JSON object with a string `op`',
492
+ reason: 'invalid-command',
493
+ });
494
+ }
495
+ appendLog({ kind: 'command', at: new Date().toISOString(), command });
496
+ if (command.decisionText !== undefined && !DECISION_TEXTS.includes(command.decisionText)) {
497
+ return refuse(command, `\`decisionText\` must be one of ${DECISION_TEXTS.join(', ')} (got ${JSON.stringify(command.decisionText)})`, 'invalid-command');
498
+ }
499
+ try {
500
+ const out = yield dispatch(command, startedMs, signal);
501
+ drainLog();
502
+ return out;
503
+ }
504
+ catch (e) {
505
+ drainLog();
506
+ appendLog({
507
+ kind: 'error',
508
+ where: `op:${command.op}`,
509
+ message: e instanceof Error ? e.message : String(e),
510
+ });
511
+ return reply(command, statusNow(session === null || session === void 0 ? void 0 : session.state()), [], {
512
+ ok: false,
513
+ status: 'error',
514
+ error: e instanceof Error ? e.message : String(e),
515
+ reason: 'threw',
516
+ });
517
+ }
518
+ });
519
+ }
520
+ function dispatch(command, startedMs, signal) {
521
+ return __awaiter(this, void 0, void 0, function* () {
522
+ var _a, _b, _c;
523
+ if (command.op === 'stop') {
524
+ session === null || session === void 0 ? void 0 : session.stop();
525
+ const out = reply(command, 'ended', [], { stopped: true });
526
+ // After the reply is on its way: the process may exit when the shutdown finishes.
527
+ setTimeout(() => void shutdown(), 0);
528
+ return out;
529
+ }
530
+ // A built-in wins over a host op of the same name, and an op is an OWN property of `extraOps`:
531
+ // `toString` and `constructor` are not ops.
532
+ const isBuiltin = BUILTIN_OPS.includes(command.op);
533
+ const extra = !isBuiltin && hasOwn(options.extraOps, command.op)
534
+ ? options.extraOps[command.op]
535
+ : undefined;
536
+ if (!extra && !isBuiltin) {
537
+ return refuse(command, `unknown op "${command.op}"`, 'unknown-op');
538
+ }
539
+ if (command.op === 'state' || command.op === 'read' || command.op === 'debugLog') {
540
+ if (!session)
541
+ return notStarted(command);
542
+ return (yield readOp(command, session));
543
+ }
544
+ // The cursor: where the log stood before the command, unless the caller names one.
545
+ const cursor = typeof command.since === 'number' ? command.since : ((_a = session === null || session === void 0 ? void 0 : session.state().lastSeq) !== null && _a !== void 0 ? _a : 0);
546
+ if (command.op === 'wait') {
547
+ if (!session)
548
+ return notStarted(command);
549
+ return poll(command, cursor, startedMs, signal);
550
+ }
551
+ // The apply phase is serial; the wait that follows is not.
552
+ const previous = applying;
553
+ let release;
554
+ applying = new Promise((r) => {
555
+ release = r;
556
+ });
557
+ let applied;
558
+ try {
559
+ yield previous;
560
+ if (extra) {
561
+ applied = yield extra(command, handle);
562
+ }
563
+ else {
564
+ if (!session)
565
+ return notStarted(command);
566
+ applied = yield applyOp(command, session, cursor);
567
+ }
568
+ }
569
+ finally {
570
+ release();
571
+ }
572
+ if (applied === 'poll')
573
+ return poll(command, cursor, startedMs, signal);
574
+ if ('status' in applied)
575
+ return applied;
576
+ // An extra op's result.
577
+ if (!applied.ok)
578
+ return refuse(command, (_b = applied.error) !== null && _b !== void 0 ? _b : 'refused', 'refused', typeof command.since === 'number' ? cursor : undefined);
579
+ const wait = (_c = applied.poll) !== null && _c !== void 0 ? _c : !!session;
580
+ if (!wait || !session) {
581
+ const pending = session === null || session === void 0 ? void 0 : session.state().pending;
582
+ return reply(command, statusNow(session === null || session === void 0 ? void 0 : session.state()), [], Object.assign(Object.assign({}, applied.fields), (applied.shortDecision && pending
583
+ ? { decision: shortDecision(pending.interactionId, pending.component, pending.actions) }
584
+ : {})), startedMs);
585
+ }
586
+ const polled = yield poll(command, cursor, startedMs, signal);
587
+ return Object.assign(Object.assign({}, polled), applied.fields);
588
+ });
589
+ }
590
+ // ------------------------------------------------------------------------------------------- HTTP
591
+ function readBody(req) {
592
+ return new Promise((resolveBody, reject) => {
593
+ const chunks = [];
594
+ let size = 0;
595
+ req.on('data', (chunk) => {
596
+ size += chunk.length;
597
+ if (size > MAX_BODY_BYTES) {
598
+ reject(new Error('the command is too large'));
599
+ req.destroy();
600
+ return;
601
+ }
602
+ chunks.push(chunk);
603
+ });
604
+ req.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8')));
605
+ req.on('error', reject);
606
+ });
607
+ }
608
+ function send(res, status, body) {
609
+ const json = JSON.stringify(body);
610
+ res.writeHead(status, {
611
+ 'content-type': 'application/json; charset=utf-8',
612
+ 'content-length': Buffer.byteLength(json),
613
+ });
614
+ res.end(json);
615
+ }
616
+ /**
617
+ * Whether a request may be answered. The socket is on this machine, but a web page open in the
618
+ * developer's browser can reach it: so a request that carries an `Origin` (a browser sent it) is
619
+ * refused, and the `Host` must be this server's own address (a rebound name is not), and a command is
620
+ * `application/json`, which a page cannot send without a preflight this server never answers.
621
+ */
622
+ function refusal(req, port) {
623
+ var _a, _b, _c;
624
+ if (req.headers.origin !== undefined) {
625
+ return { status: HTTP_FORBIDDEN, error: 'a request from a web page is refused' };
626
+ }
627
+ const bound = (_a = options.host) !== null && _a !== void 0 ? _a : '127.0.0.1';
628
+ // Only a loopback bind has a `Host` to hold to; a host that binds wider has chosen who reaches it.
629
+ if (LOOPBACK_HOSTS.includes(bound)) {
630
+ const host = ((_b = req.headers.host) !== null && _b !== void 0 ? _b : '').toLowerCase();
631
+ if (!LOOPBACK_HOSTS.some((name) => host === `${name}:${port}`)) {
632
+ return {
633
+ status: HTTP_FORBIDDEN,
634
+ error: `the Host must be 127.0.0.1:${port} or localhost:${port}`,
635
+ };
636
+ }
637
+ }
638
+ if (req.method === 'POST') {
639
+ const type = ((_c = req.headers['content-type']) !== null && _c !== void 0 ? _c : '').split(';')[0].trim().toLowerCase();
640
+ if (type !== 'application/json') {
641
+ return {
642
+ status: HTTP_UNSUPPORTED_MEDIA_TYPE,
643
+ error: 'POST /cmd takes content-type: application/json',
644
+ };
645
+ }
646
+ }
647
+ return undefined;
648
+ }
649
+ function onRequest(req, res) {
650
+ return __awaiter(this, void 0, void 0, function* () {
651
+ var _a;
652
+ const refused = refusal(req, http.address().port);
653
+ if (refused) {
654
+ send(res, refused.status, { ok: false, status: 'error', error: refused.error });
655
+ return;
656
+ }
657
+ const url = ((_a = req.url) !== null && _a !== void 0 ? _a : '/').split('?')[0];
658
+ if (req.method === 'GET' && url === '/health') {
659
+ const state = session === null || session === void 0 ? void 0 : session.state();
660
+ send(res, HTTP_OK, Object.assign({ ok: true, started: !!session, runDir, pid: process.pid, uptimeMs: Date.now() - startedAt }, (state ? { status: statusNow(state), nextSeq: state.lastSeq } : {})));
661
+ return;
662
+ }
663
+ if (req.method === 'POST' && url === '/cmd') {
664
+ let command;
665
+ try {
666
+ command = JSON.parse(yield readBody(req));
667
+ }
668
+ catch (e) {
669
+ send(res, HTTP_BAD_REQUEST, {
670
+ ok: false,
671
+ status: 'error',
672
+ error: `bad command: ${e instanceof Error ? e.message : String(e)}`,
673
+ });
674
+ return;
675
+ }
676
+ // A client that hangs up ends the wait it started; the command it sent was applied all the same.
677
+ const gone = new AbortController();
678
+ res.on('close', () => {
679
+ if (!res.writableEnded)
680
+ gone.abort();
681
+ });
682
+ const out = yield runCommand(command, gone.signal);
683
+ if (!gone.signal.aborted)
684
+ send(res, HTTP_OK, out);
685
+ return;
686
+ }
687
+ send(res, HTTP_NOT_FOUND, {
688
+ ok: false,
689
+ error: 'GET /health, or POST /cmd with one JSON command',
690
+ });
691
+ });
692
+ }
693
+ function listen() {
694
+ return new Promise((resolveListen, reject) => {
695
+ var _a, _b;
696
+ http = createServer((req, res) => {
697
+ onRequest(req, res).catch((e) => {
698
+ if (!res.headersSent)
699
+ send(res, HTTP_SERVER_ERROR, { ok: false, status: 'error', error: String(e) });
700
+ else
701
+ res.end();
702
+ });
703
+ });
704
+ http.once('error', reject);
705
+ http.listen((_a = options.port) !== null && _a !== void 0 ? _a : 0, (_b = options.host) !== null && _b !== void 0 ? _b : '127.0.0.1', () => {
706
+ const { port } = http.address();
707
+ announce(`HEADLESS_READY port=${port} runDir=${runDir}`);
708
+ resolveListen({ port, runDir });
709
+ });
710
+ });
711
+ }
712
+ function shutdown() {
713
+ return __awaiter(this, void 0, void 0, function* () {
714
+ var _a;
715
+ if (closing)
716
+ return closed;
717
+ closing = true;
718
+ try {
719
+ session === null || session === void 0 ? void 0 : session.stop();
720
+ }
721
+ catch (_b) {
722
+ /* stopping is best effort */
723
+ }
724
+ yield flushAll();
725
+ drainLog();
726
+ yield new Promise((r) => {
727
+ var _a;
728
+ if (!http) {
729
+ r();
730
+ return;
731
+ }
732
+ http.close(() => r());
733
+ (_a = http.closeIdleConnections) === null || _a === void 0 ? void 0 : _a.call(http);
734
+ // A reply on its way (`stop`'s) gets a moment; a long poll in flight must not hold the process open.
735
+ setTimeout(() => { var _a; return (_a = http === null || http === void 0 ? void 0 : http.closeAllConnections) === null || _a === void 0 ? void 0 : _a.call(http); }, CLOSE_GRACE_MS).unref();
736
+ });
737
+ yield new Promise((r) => {
738
+ events.end(r);
739
+ });
740
+ yield ((_a = options.onClosed) === null || _a === void 0 ? void 0 : _a.call(options));
741
+ resolveClosed();
742
+ return closed;
743
+ });
744
+ }
745
+ return handle;
746
+ }