@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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/core-client.d.ts +616 -0
- package/dist/core-client.d.ts.map +1 -0
- package/dist/core-client.js +1036 -0
- package/dist/core-client.js.map +1 -0
- package/dist/core-link-cursor-storage.d.ts +32 -0
- package/dist/core-link-cursor-storage.d.ts.map +1 -0
- package/dist/core-link-cursor-storage.js +66 -0
- package/dist/core-link-cursor-storage.js.map +1 -0
- package/dist/core-link-frames.d.ts +1123 -0
- package/dist/core-link-frames.d.ts.map +1 -0
- package/dist/core-link-frames.js +349 -0
- package/dist/core-link-frames.js.map +1 -0
- package/dist/core-link-socket.d.ts +54 -0
- package/dist/core-link-socket.d.ts.map +1 -0
- package/dist/core-link-socket.js +74 -0
- package/dist/core-link-socket.js.map +1 -0
- package/dist/core-link-transport.d.ts +177 -0
- package/dist/core-link-transport.d.ts.map +1 -0
- package/dist/core-link-transport.js +432 -0
- package/dist/core-link-transport.js.map +1 -0
- package/dist/core-registration-blob.d.ts +52 -0
- package/dist/core-registration-blob.d.ts.map +1 -0
- package/dist/core-registration-blob.js +61 -0
- package/dist/core-registration-blob.js.map +1 -0
- package/dist/core-session.d.ts +321 -0
- package/dist/core-session.d.ts.map +1 -0
- package/dist/core-session.js +660 -0
- package/dist/core-session.js.map +1 -0
- package/dist/durable-core-client.d.ts +172 -0
- package/dist/durable-core-client.d.ts.map +1 -0
- package/dist/durable-core-client.js +264 -0
- package/dist/durable-core-client.js.map +1 -0
- package/dist/terminal-screen.d.ts +139 -0
- package/dist/terminal-screen.d.ts.map +1 -0
- package/dist/terminal-screen.js +807 -0
- package/dist/terminal-screen.js.map +1 -0
- 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
|