@actana/sdk 0.2.2

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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/core-client.d.ts +616 -0
  4. package/dist/core-client.d.ts.map +1 -0
  5. package/dist/core-client.js +1036 -0
  6. package/dist/core-client.js.map +1 -0
  7. package/dist/core-link-cursor-storage.d.ts +32 -0
  8. package/dist/core-link-cursor-storage.d.ts.map +1 -0
  9. package/dist/core-link-cursor-storage.js +66 -0
  10. package/dist/core-link-cursor-storage.js.map +1 -0
  11. package/dist/core-link-frames.d.ts +1123 -0
  12. package/dist/core-link-frames.d.ts.map +1 -0
  13. package/dist/core-link-frames.js +349 -0
  14. package/dist/core-link-frames.js.map +1 -0
  15. package/dist/core-link-socket.d.ts +54 -0
  16. package/dist/core-link-socket.d.ts.map +1 -0
  17. package/dist/core-link-socket.js +74 -0
  18. package/dist/core-link-socket.js.map +1 -0
  19. package/dist/core-link-transport.d.ts +177 -0
  20. package/dist/core-link-transport.d.ts.map +1 -0
  21. package/dist/core-link-transport.js +432 -0
  22. package/dist/core-link-transport.js.map +1 -0
  23. package/dist/core-registration-blob.d.ts +52 -0
  24. package/dist/core-registration-blob.d.ts.map +1 -0
  25. package/dist/core-registration-blob.js +61 -0
  26. package/dist/core-registration-blob.js.map +1 -0
  27. package/dist/core-session.d.ts +321 -0
  28. package/dist/core-session.d.ts.map +1 -0
  29. package/dist/core-session.js +660 -0
  30. package/dist/core-session.js.map +1 -0
  31. package/dist/durable-core-client.d.ts +172 -0
  32. package/dist/durable-core-client.d.ts.map +1 -0
  33. package/dist/durable-core-client.js +264 -0
  34. package/dist/durable-core-client.js.map +1 -0
  35. package/dist/terminal-screen.d.ts +139 -0
  36. package/dist/terminal-screen.d.ts.map +1 -0
  37. package/dist/terminal-screen.js +807 -0
  38. package/dist/terminal-screen.js.map +1 -0
  39. package/package.json +51 -0
@@ -0,0 +1,660 @@
1
+ // `CoreSession` — the second level of the SDK: start a Session, let the Core
2
+ // deliver the prompt, read the result (#129 D2, D11; issue 155).
3
+ //
4
+ // The transport level below this one (`CoreLinkTransport` → `CoreClient`) knows
5
+ // frames. It will spawn a PTY and hand you `data` frames, and that is as far as
6
+ // it goes: a caller wanting a *Session* has to correlate the spawn with its
7
+ // byte stream, filter that stream out of every other PTY on the machine, know
8
+ // that a harness's output is a screen rather than a log, and know how a Core
9
+ // reports that a harness has finished. This is that, once, so a script is four
10
+ // calls:
11
+ //
12
+ // const client = CoreClient.fromRegistrationBlob(blob);
13
+ // await client.connect();
14
+ // const session = await CoreSession.start(client, {
15
+ // projectId, cwd, harness: "claude-code", prompt: "…",
16
+ // });
17
+ // await session.waitForIdle();
18
+ // console.log(session.screen());
19
+ //
20
+ // **No TTY, ever (D11).** Nothing here reads `process.stdin`, sets raw mode,
21
+ // opens `/dev/tty` or asks whether one exists. Terminal handling belongs to the
22
+ // `actana` CLI, which is a different program with a human in front of it; an SDK
23
+ // that touched the process's terminal would be unusable from the cron job, the
24
+ // CI runner and the web service that are the reason this package exists.
25
+ // `send` takes a string and `screen` returns one.
26
+ //
27
+ // Three things this layer deliberately does NOT do:
28
+ //
29
+ // 1. **It does not time the prompt.** The starting prompt is handed to the
30
+ // Core as `initialInput` and the Core delivers it on the harness's own
31
+ // schedule — wait for the TUI to stop painting, answer the blocking dialog
32
+ // by its own numbered option, write the text, send the carriage return as a
33
+ // separate keystroke (ADR 0026, #191). There is no delay, no ready-signal
34
+ // and no retry here to disagree with it, which is what makes a Panel, the
35
+ // CLI and this behave identically on a machine none of them is on.
36
+ // 2. **It does not pre-empt the Core's spawn policy.** A Session is spawned
37
+ // against a registered Project: the Core checks that the working directory
38
+ // resolves inside a known Project root, that the command's first token is
39
+ // that harness's canonical binary, and that every flag is allow-listed.
40
+ // Those checks read a database and a filesystem on another machine, so a
41
+ // copy of them here would be a guess — and a guess that says no to a spawn
42
+ // the Core would have accepted is worse than the round trip. {@link start}
43
+ // surfaces the rejection.
44
+ // 3. **It does not decide what "done" means from the bytes.** Idleness is the
45
+ // Core's report — the harness's own lifecycle hooks moving the Session's
46
+ // status — read off the event log. Watching the stream go quiet is the
47
+ // 450 ms timer that #191 deleted, in a new place. (A status read the link
48
+ // lost is asked again, which is a retry of a *question*: it can only ever
49
+ // report what the Core already decided, never decide it here.)
50
+ import { DEFAULT_COLS, DEFAULT_ROWS, TerminalScreen } from "./terminal-screen.js";
51
+ /**
52
+ * The command a fresh Session starts with, per harness, when the caller names
53
+ * none.
54
+ *
55
+ * The first token has to be that harness's canonical binary or the Core refuses
56
+ * the spawn — that is the allow-list, and it is enforced there, not here. What
57
+ * this table adds beyond the binary is the one flag a harness needs for the
58
+ * Core to hear about it at all: `codex` reports its lifecycle through hooks only
59
+ * when started with `--enable hooks`, and a Session that never reports is a
60
+ * Session {@link CoreSession.waitForIdle} waits on forever.
61
+ *
62
+ * A caller wanting anything else — a model, a resumed session id — passes
63
+ * `command` and takes the Core's answer on it.
64
+ */
65
+ export const HARNESS_LAUNCH_COMMANDS = {
66
+ "claude-code": "claude",
67
+ codex: "codex --enable hooks",
68
+ "cursor-cli": "cursor-agent",
69
+ opencode: "opencode",
70
+ };
71
+ /**
72
+ * Each harness's spelling of "do not stop to ask me", appended to the default
73
+ * command when {@link CoreSessionOptions.dangerouslySkipPermissions} is set.
74
+ *
75
+ * The option and the flag are two halves of one gesture and the Core checks
76
+ * both: the flag is allow-listed only for a spawn that also set the option, so
77
+ * setting one without the other is a rejected spawn rather than a quiet
78
+ * downgrade. OpenCode has no such flag, and gets none.
79
+ */
80
+ const HARNESS_SKIP_PERMISSION_FLAGS = {
81
+ "claude-code": "--dangerously-skip-permissions",
82
+ codex: "--yolo",
83
+ "cursor-cli": "--force",
84
+ opencode: null,
85
+ };
86
+ /**
87
+ * The statuses that mean the harness has stopped and is waiting on a human.
88
+ *
89
+ * `finished` is a completed turn, `needs-input` a permission prompt or a
90
+ * question, `interrupted` an escape, `terminated` a dead process, and
91
+ * `disconnected` a Core that restarted underneath the Session. Every one of them
92
+ * is a state that does not leave on its own, which is the property
93
+ * {@link CoreSession.waitForIdle} is waiting for — not "finished", which would
94
+ * hang on the question a caller could have answered.
95
+ */
96
+ export const SETTLED_SESSION_STATUSES = new Set([
97
+ "finished",
98
+ "needs-input",
99
+ "interrupted",
100
+ "terminated",
101
+ "disconnected",
102
+ ]);
103
+ /**
104
+ * Event kinds that may carry a status change for a Session.
105
+ *
106
+ * `task:updated` is the general one and says only that the row moved; the status
107
+ * itself is read back off the Core, which owns it. `session:finished` is
108
+ * appended on the transition into `finished` and nowhere else (see the Core's
109
+ * task writer), so it is the one kind whose meaning needs no round trip.
110
+ */
111
+ const STATUS_BEARING_EVENT_KINDS = new Set([
112
+ "task:updated",
113
+ "task:statusChanged",
114
+ "session:finished",
115
+ ]);
116
+ /**
117
+ * How a failed status read is re-asked: this many further attempts, this long
118
+ * apart.
119
+ *
120
+ * A retry of a *read*, and only of a read. It re-asks the Core a question whose
121
+ * answer the Core already settled on — it does not retry a prompt, a keystroke
122
+ * or a spawn, and it cannot make a Session look idle sooner than the Core says
123
+ * it is. The reason it has to exist: `needs-input`, `interrupted` and
124
+ * `terminated` reach this layer only as `task:updated`, and that event is
125
+ * appended once. Swallowing the read that failed on it leaves
126
+ * {@link CoreSession.waitForIdle} waiting for a report that will not be made
127
+ * again, and by design there is no deadline to end that wait.
128
+ *
129
+ * Bounded rather than indefinite: a link that is still down after three tries a
130
+ * quarter-second apart is not going to be talked round by a fourth, and a
131
+ * Session that polls forever is the busy-loop version of the timer #191 deleted.
132
+ */
133
+ export const STATUS_READ_RETRIES = 3;
134
+ export const STATUS_READ_RETRY_MS = 250;
135
+ /** The Core refused to start this Session. Carries the Core's own reason. */
136
+ export class CoreSessionStartError extends Error {
137
+ constructor(message, options) {
138
+ super(message, options);
139
+ this.name = "CoreSessionStartError";
140
+ }
141
+ }
142
+ /**
143
+ * One Session on one Core, driven programmatically.
144
+ *
145
+ * Built by {@link start}. Holds a screen fed from the Session's PTY, the Core's
146
+ * last reported status for it, and the listeners that keep both current — so
147
+ * it must be released with {@link dispose} (or {@link kill}, which disposes)
148
+ * when the caller is done, or those listeners outlive it on the client.
149
+ */
150
+ export class CoreSession {
151
+ /** The Task this Session belongs to. Its status is the Session's status. */
152
+ taskId;
153
+ /** The Core's id for this Session's PTY. */
154
+ ptyId;
155
+ /** The harness running in it. */
156
+ harness;
157
+ /** The command the Core was asked to start, after defaulting. */
158
+ command;
159
+ client;
160
+ terminal;
161
+ unsubscribes = [];
162
+ dataListeners = new Set();
163
+ exitListeners = new Set();
164
+ statusListeners = new Set();
165
+ idleWaiters = new Set();
166
+ /**
167
+ * The Core's last reported status, or null before one has been *observed*.
168
+ *
169
+ * Null rather than the status the Task carried when this Session started, and
170
+ * the distinction is what makes {@link waitForIdle} correct: a caller starting
171
+ * a Session on a Task that was already `finished` is waiting for the next turn
172
+ * to end, not being told about the last one. Only a status learned from an
173
+ * event after {@link start} lands here.
174
+ */
175
+ lastStatus = null;
176
+ exit = null;
177
+ disposed = false;
178
+ /** A status read is in flight; another event arrived while it was. */
179
+ statusReadInFlight = false;
180
+ statusReadAgain = false;
181
+ /** Re-asks left for the read that failed, and the timer carrying the next. */
182
+ statusReadRetriesLeft = STATUS_READ_RETRIES;
183
+ statusRetryTimer = null;
184
+ constructor(opts) {
185
+ this.client = opts.client;
186
+ this.taskId = opts.taskId;
187
+ this.ptyId = opts.ptyId;
188
+ this.harness = opts.harness;
189
+ this.command = opts.command;
190
+ this.terminal = opts.terminal;
191
+ }
192
+ /**
193
+ * Start a Session and return once the Core has one running.
194
+ *
195
+ * What happens, in order: a Task row is created when the caller named a
196
+ * Project rather than a Task; the client is subscribed to the Core's event log
197
+ * if nothing has done that yet; the PTY's byte stream is wired up *before* the
198
+ * spawn goes out; and the spawn carries the prompt as `initialInput` for the
199
+ * Core to deliver.
200
+ *
201
+ * The stream is wired first on purpose. A harness starts printing its banner
202
+ * immediately, and on a Core that fans output out by subscription the
203
+ * connection that spawned a PTY is subscribed to it before the answer is even
204
+ * written — so the first bytes can be on the wire before this side knows the
205
+ * PTY's id. They are held and replayed into the screen once it is known, which
206
+ * is the difference between a transcript that starts at the beginning and one
207
+ * that starts wherever the round trip happened to end.
208
+ *
209
+ * Rejects with the Core's own message when the Core refuses: a working
210
+ * directory outside every registered Project root, a command whose binary is
211
+ * not that harness's, a flag that is not allow-listed, a harness that is not
212
+ * installed on that machine.
213
+ */
214
+ static async start(client, opts) {
215
+ if (!opts.taskId && !opts.projectId) {
216
+ throw new CoreSessionStartError("CoreSession.start needs a taskId or a projectId to start a Session against");
217
+ }
218
+ const cols = opts.cols ?? DEFAULT_COLS;
219
+ const rows = opts.rows ?? DEFAULT_ROWS;
220
+ const command = opts.command ?? defaultLaunchCommand(opts.harness, opts.dangerouslySkipPermissions === true);
221
+ // The event log first: a subscribe sent after the spawn could miss the
222
+ // status change of a harness that answered before this side asked.
223
+ if (opts.subscribeToEvents !== false && !client.isSubscribedToEvents()) {
224
+ client.subscribeEvents();
225
+ }
226
+ const taskId = opts.taskId ?? (await createTask(client, opts));
227
+ // Held until the spawn answers with the id these belong to. Every PTY on a
228
+ // single-connection Core arrives on this listener, so nothing can be routed
229
+ // by anything but the id, and the id is what has not come back yet.
230
+ const held = [];
231
+ const heldEvents = [];
232
+ const heldExits = [];
233
+ let ptyId = null;
234
+ const terminal = new TerminalScreen({ cols, rows, scrollback: opts.scrollback });
235
+ let session = null;
236
+ const stopData = client.onData((frame) => {
237
+ if (ptyId === null) {
238
+ held.push(frame);
239
+ return;
240
+ }
241
+ if (frame.ptyId !== ptyId)
242
+ return;
243
+ session?.ingest(frame.data);
244
+ });
245
+ const stopExit = client.onExit((frame) => {
246
+ if (ptyId === null) {
247
+ // Every exit, not the first: this listener hears the whole Core, so a
248
+ // co-tenant PTY exiting inside the spawn's round trip would otherwise
249
+ // take the one slot and this Session's own exit frame would be dropped
250
+ // — `onExit` silent, and `waitForIdle` short an exit route. Held like
251
+ // the bytes above and filtered by id for the same reason.
252
+ heldExits.push(frame);
253
+ return;
254
+ }
255
+ if (frame.ptyId !== ptyId)
256
+ return;
257
+ session?.ingestExit(frame);
258
+ });
259
+ // Held for the same reason the bytes are, and with a sharper consequence:
260
+ // a status change is not a stream, and the one event saying the harness
261
+ // finished is the only one that will ever say it. Dropped in the window
262
+ // between the spawn going out and its answer landing, `waitForIdle` waits
263
+ // for a report that has already been made.
264
+ const stopEvents = client.onEvent(({ event }) => {
265
+ if (session === null) {
266
+ heldEvents.push(event);
267
+ return;
268
+ }
269
+ session.onCoreEvent(event);
270
+ });
271
+ let spawned;
272
+ try {
273
+ spawned = await client.spawn({
274
+ taskId,
275
+ cwd: opts.cwd,
276
+ command,
277
+ agent: opts.harness,
278
+ cols,
279
+ rows,
280
+ ...(opts.dangerouslySkipPermissions === true
281
+ ? { dangerouslySkipPermissions: true }
282
+ : {}),
283
+ ...(opts.theme ? { missionControlTheme: opts.theme } : {}),
284
+ // Text, and no timing with it. See this module's header.
285
+ ...(opts.prompt === undefined ? {} : { initialInput: opts.prompt }),
286
+ });
287
+ }
288
+ catch (err) {
289
+ stopData();
290
+ stopExit();
291
+ stopEvents();
292
+ throw new CoreSessionStartError(`the Core refused to start a ${opts.harness} Session in ${opts.cwd}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
293
+ }
294
+ ptyId = spawned.ptyId;
295
+ session = new CoreSession({
296
+ client,
297
+ taskId,
298
+ ptyId: spawned.ptyId,
299
+ harness: opts.harness,
300
+ command,
301
+ terminal,
302
+ });
303
+ session.unsubscribes.push(stopData, stopExit, stopEvents);
304
+ for (const event of heldEvents)
305
+ session.onCoreEvent(event);
306
+ for (const frame of held) {
307
+ if (frame.ptyId === ptyId)
308
+ session.ingest(frame.data);
309
+ }
310
+ const mineExit = heldExits.find((frame) => frame.ptyId === ptyId);
311
+ if (mineExit)
312
+ session.ingestExit(mineExit);
313
+ return session;
314
+ }
315
+ // ─── Programmatic I/O ──────────────────────────────────────────────────────
316
+ /**
317
+ * Write to the Session, exactly these bytes and nothing else.
318
+ *
319
+ * The equivalent of typing, not of prompting. Nothing is appended: no carriage
320
+ * return, no delay, no waiting for the harness to look ready. That restraint
321
+ * is the rule rather than a gap — a client that decided when to press Enter
322
+ * would be doing prompt delivery, which is the Core's (ADR 0026), and would do
323
+ * it differently from every other client. A *starting* prompt goes through
324
+ * {@link CoreSessionStartOptions.prompt}, where the Core owns the schedule.
325
+ *
326
+ * What this is for is everything after: answering the numbered option of a
327
+ * question the harness asked (`send("2")` then `send("\r")`), an escape
328
+ * (`send("\u001B")`), a follow-up typed into a harness already at its prompt.
329
+ *
330
+ * Resolves false when the Core did not accept the write — a PTY that has
331
+ * exited. Rejects when another Core client holds this Session's lock.
332
+ */
333
+ send(text) {
334
+ return this.client.write(this.ptyId, text);
335
+ }
336
+ /**
337
+ * Every chunk of this Session's output, as it arrives.
338
+ *
339
+ * Raw PTY bytes, escape sequences included — the same stream the screen is
340
+ * built from. A caller wanting the rendered text wants {@link screen}; this is
341
+ * for one that is streaming somewhere else.
342
+ */
343
+ onData(cb) {
344
+ this.dataListeners.add(cb);
345
+ return () => this.dataListeners.delete(cb);
346
+ }
347
+ /** The harness's process exited. Fires once. */
348
+ onExit(cb) {
349
+ this.exitListeners.add(cb);
350
+ return () => this.exitListeners.delete(cb);
351
+ }
352
+ /** The Core reported a new status for this Session. */
353
+ onStatus(cb) {
354
+ this.statusListeners.add(cb);
355
+ return () => this.statusListeners.delete(cb);
356
+ }
357
+ /**
358
+ * What a terminal would be showing for this Session, **including the lines
359
+ * that have scrolled off the top of it**.
360
+ *
361
+ * The scrolled-off part is not a bonus: a harness's conversation left the
362
+ * screen long ago, so the transcript is the scrollback and a caller reading
363
+ * only the visible rows reads a status bar. See `terminal-screen.ts` for what
364
+ * is emulated and what an erase costs.
365
+ *
366
+ * **Read it while the Session is alive.** A harness that runs full-screen
367
+ * leaves the alternate screen when it quits, and what a terminal shows after
368
+ * that is the main buffer — which is where nothing was ever printed. Reading
369
+ * before {@link kill}, not after, is the difference between the transcript and
370
+ * an empty string. (Claude Code 2.1.228 runs full-screen, so this is the
371
+ * ordinary case rather than an exotic one.)
372
+ */
373
+ screen() {
374
+ return this.terminal.text();
375
+ }
376
+ /** Only the rows on screen right now — for reading a dialog rather than a transcript. */
377
+ viewport() {
378
+ return this.terminal.viewportText();
379
+ }
380
+ /** The screen as an array of lines, scrollback first. */
381
+ lines() {
382
+ return this.terminal.lines();
383
+ }
384
+ /** The Core's last reported status, or null before one has been observed. */
385
+ status() {
386
+ return this.lastStatus;
387
+ }
388
+ /** How the harness's process ended, or null while it is running. */
389
+ exitStatus() {
390
+ return this.exit;
391
+ }
392
+ // ─── Waiting ───────────────────────────────────────────────────────────────
393
+ /**
394
+ * Wait until the Core reports this Session settled — the harness finished its
395
+ * turn, asked a question, was interrupted, or died.
396
+ *
397
+ * **The Core's report, not a guess from the byte stream.** The harness's own
398
+ * lifecycle hooks move the Session's status on the Core, the change lands in
399
+ * the event log, and this is watching for it. Nothing here inspects output for
400
+ * quietness: that is a timing decision, it belongs to the Core, and the flat
401
+ * timer that used to make it here is what #191 deleted.
402
+ *
403
+ * Only statuses observed *after* this Session started count, so starting one
404
+ * on a Task that was already `finished` waits for this turn rather than
405
+ * returning last turn's answer. An exit resolves it too — a harness that died
406
+ * is not going to report anything else.
407
+ *
408
+ * Resolves as soon as it can: if the Session has already settled by the time
409
+ * this is called, it answers from what it saw.
410
+ *
411
+ * With no {@link CoreSessionWaitOptions.timeoutMs} this waits indefinitely, on
412
+ * purpose. A status read that fails is re-asked, so a link that blinks does
413
+ * not cost the report; a link that stays down does, and nothing here invents a
414
+ * status the Core never sent. A caller that must not hang on a broken Core
415
+ * passes a deadline it chose itself.
416
+ */
417
+ waitForIdle(opts = {}) {
418
+ const settled = this.settledNow();
419
+ if (settled)
420
+ return Promise.resolve(settled);
421
+ return new Promise((resolve, reject) => {
422
+ let timer = null;
423
+ const waiter = (idle) => {
424
+ this.idleWaiters.delete(waiter);
425
+ if (timer)
426
+ clearTimeout(timer);
427
+ resolve(idle);
428
+ };
429
+ this.idleWaiters.add(waiter);
430
+ if (opts.timeoutMs && opts.timeoutMs > 0) {
431
+ timer = setTimeout(() => {
432
+ this.idleWaiters.delete(waiter);
433
+ reject(new Error(`session ${this.taskId} was still ${this.lastStatus ?? "unreported"} after ${opts.timeoutMs}ms`));
434
+ }, opts.timeoutMs);
435
+ }
436
+ });
437
+ }
438
+ // ─── Lifecycle ─────────────────────────────────────────────────────────────
439
+ /** Resize the PTY and the screen together, so both agree about wrapping. */
440
+ async resize(cols, rows) {
441
+ const ok = await this.client.resize(this.ptyId, cols, rows);
442
+ this.terminal.resize(cols, rows);
443
+ return ok;
444
+ }
445
+ /** Kill the harness's process, then release this Session's listeners. */
446
+ async kill() {
447
+ try {
448
+ return await this.client.kill(this.ptyId);
449
+ }
450
+ finally {
451
+ this.dispose();
452
+ }
453
+ }
454
+ /**
455
+ * Release the listeners this Session holds on the client, leaving the harness
456
+ * running. The screen stops advancing; the Core carries on.
457
+ */
458
+ dispose() {
459
+ if (this.disposed)
460
+ return;
461
+ this.disposed = true;
462
+ for (const off of this.unsubscribes)
463
+ off();
464
+ this.unsubscribes.length = 0;
465
+ if (this.statusRetryTimer) {
466
+ clearTimeout(this.statusRetryTimer);
467
+ this.statusRetryTimer = null;
468
+ }
469
+ // Anyone still waiting is waiting on a report this Session will no longer
470
+ // hear, so they are settled on the way out rather than left pending
471
+ // forever. `kill()` disposes, and `await session.kill()` after starting a
472
+ // `waitForIdle()` is an ordinary thing to write.
473
+ for (const waiter of [...this.idleWaiters]) {
474
+ waiter(this.settledNow() ?? { status: this.lastStatus ?? "disposed", exited: false });
475
+ }
476
+ this.dataListeners.clear();
477
+ this.exitListeners.clear();
478
+ this.statusListeners.clear();
479
+ this.idleWaiters.clear();
480
+ }
481
+ // ─── Internals ─────────────────────────────────────────────────────────────
482
+ ingest(chunk) {
483
+ this.terminal.write(chunk);
484
+ for (const cb of this.dataListeners) {
485
+ try {
486
+ cb(chunk);
487
+ }
488
+ catch {
489
+ /* a listener's failure is not this Session's failure */
490
+ }
491
+ }
492
+ }
493
+ ingestExit(frame) {
494
+ if (this.exit)
495
+ return;
496
+ this.exit = {
497
+ exitCode: frame.exitCode,
498
+ ...(frame.signal === undefined ? {} : { signal: frame.signal }),
499
+ };
500
+ for (const cb of this.exitListeners) {
501
+ try {
502
+ cb(this.exit);
503
+ }
504
+ catch {
505
+ /* same */
506
+ }
507
+ }
508
+ this.releaseWaiters();
509
+ }
510
+ /** @internal — called by {@link start} for events held during the spawn. */
511
+ onCoreEvent(event) {
512
+ if (event.taskId !== this.taskId)
513
+ return;
514
+ if (!STATUS_BEARING_EVENT_KINDS.has(event.kind))
515
+ return;
516
+ // `session:finished` is appended on the transition into `finished` and on
517
+ // nothing else, so it is the one kind that already says what happened.
518
+ if (event.kind === "session:finished") {
519
+ this.noteStatus("finished");
520
+ return;
521
+ }
522
+ void this.readStatus();
523
+ }
524
+ /**
525
+ * Read this Session's status back off the Core.
526
+ *
527
+ * The `task:updated` event says a row moved, not what it moved to, and the
528
+ * Core owns the answer — so it is asked. Coalesced, because a turn's worth of
529
+ * hook events arrives in a burst and each one would otherwise be its own round
530
+ * trip: a read already in flight is re-run once at the end rather than queued
531
+ * behind itself.
532
+ *
533
+ * A read that fails is re-asked ({@link STATUS_READ_RETRIES}), because on
534
+ * `needs-input`, `interrupted` and `terminated` there is no second event to
535
+ * carry the news.
536
+ */
537
+ async readStatus() {
538
+ if (this.disposed)
539
+ return;
540
+ if (this.statusReadInFlight) {
541
+ this.statusReadAgain = true;
542
+ return;
543
+ }
544
+ this.statusReadInFlight = true;
545
+ let failed = false;
546
+ try {
547
+ const sessions = await this.client.sessionsList();
548
+ const mine = sessions.find((s) => s.taskId === this.taskId);
549
+ if (mine)
550
+ this.noteStatus(mine.status);
551
+ this.statusReadRetriesLeft = STATUS_READ_RETRIES;
552
+ }
553
+ catch {
554
+ // A read that failed is a link that dropped or a Core that is busy. It is
555
+ // re-asked below rather than swallowed: on the transitions that reach this
556
+ // layer as a bare `task:updated` there is no later event to ask on, so a
557
+ // dropped read is the difference between a caller learning the harness is
558
+ // waiting for an answer and a caller waiting forever for one.
559
+ failed = true;
560
+ }
561
+ finally {
562
+ this.statusReadInFlight = false;
563
+ if (this.statusReadAgain && !this.disposed) {
564
+ // An event that arrived mid-read is the re-ask, and a fresher one.
565
+ this.statusReadAgain = false;
566
+ void this.readStatus();
567
+ }
568
+ else if (failed && !this.disposed && this.statusReadRetriesLeft > 0) {
569
+ this.statusReadRetriesLeft -= 1;
570
+ this.scheduleStatusRetry();
571
+ }
572
+ }
573
+ }
574
+ /** The next re-ask of a read that failed. Cleared by {@link dispose}. */
575
+ scheduleStatusRetry() {
576
+ if (this.statusRetryTimer)
577
+ return;
578
+ this.statusRetryTimer = setTimeout(() => {
579
+ this.statusRetryTimer = null;
580
+ if (!this.disposed)
581
+ void this.readStatus();
582
+ }, STATUS_READ_RETRY_MS);
583
+ // A pending re-ask is not a reason for a script that is otherwise done to
584
+ // stay alive.
585
+ this.statusRetryTimer.unref?.();
586
+ }
587
+ noteStatus(status) {
588
+ if (status === this.lastStatus)
589
+ return;
590
+ this.lastStatus = status;
591
+ for (const cb of this.statusListeners) {
592
+ try {
593
+ cb(status);
594
+ }
595
+ catch {
596
+ /* same */
597
+ }
598
+ }
599
+ this.releaseWaiters();
600
+ }
601
+ /** What {@link waitForIdle} would answer right now, or null if it must wait. */
602
+ settledNow() {
603
+ if (this.exit) {
604
+ return {
605
+ status: this.lastStatus ?? "terminated",
606
+ exited: true,
607
+ ...(this.exit.exitCode === undefined ? {} : { exitCode: this.exit.exitCode }),
608
+ };
609
+ }
610
+ if (this.lastStatus && SETTLED_SESSION_STATUSES.has(this.lastStatus)) {
611
+ return { status: this.lastStatus, exited: false };
612
+ }
613
+ return null;
614
+ }
615
+ releaseWaiters() {
616
+ const settled = this.settledNow();
617
+ if (!settled)
618
+ return;
619
+ for (const waiter of [...this.idleWaiters])
620
+ waiter(settled);
621
+ }
622
+ }
623
+ /** The launch command for a harness nobody named one for. */
624
+ function defaultLaunchCommand(harness, skipPermissions) {
625
+ const base = HARNESS_LAUNCH_COMMANDS[harness];
626
+ if (!skipPermissions)
627
+ return base;
628
+ const flag = HARNESS_SKIP_PERMISSION_FLAGS[harness];
629
+ return flag ? `${base} ${flag}` : base;
630
+ }
631
+ /**
632
+ * Create the Task a Session hangs off, in the Project the caller named.
633
+ *
634
+ * A Session's status is a column on this row: the Core's hook pipeline patches
635
+ * it, the patch appends the event, and the event is what {@link
636
+ * CoreSession.waitForIdle} is waiting for. A spawn naming a row that does not
637
+ * exist runs a harness that reports to nowhere — so the row comes first, and a
638
+ * Core that will not create it (an unknown Project) fails the start here rather
639
+ * than producing a Session nothing can observe.
640
+ */
641
+ async function createTask(client, opts) {
642
+ const projectId = opts.projectId;
643
+ let created;
644
+ try {
645
+ created = await client.tasksMutate({
646
+ op: "create",
647
+ projectId,
648
+ title: opts.title ?? "SDK session",
649
+ agent: opts.harness,
650
+ });
651
+ }
652
+ catch (err) {
653
+ throw new CoreSessionStartError(`the Core refused to create a Session in project ${projectId}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
654
+ }
655
+ if (!created) {
656
+ throw new CoreSessionStartError(`the Core has no project ${projectId} to start a Session in`);
657
+ }
658
+ return created.taskId;
659
+ }
660
+ //# sourceMappingURL=core-session.js.map