@pentoshi/clai 3.9.4 → 3.9.5
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/dist/app/controllers/session-controller.d.ts +0 -8
- package/dist/app/controllers/session-controller.js +4 -20
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/index.js +8 -21
- package/dist/index.js.map +1 -1
- package/dist/repl.js +0 -10
- package/dist/repl.js.map +1 -1
- package/dist/tools/definitions.js +0 -95
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/jobs.js +70 -2
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/registry.js +0 -4
- package/dist/tools/registry.js.map +1 -1
- package/dist/tui-v2/bootstrap/composition-root.d.ts +0 -3
- package/dist/tui-v2/bootstrap/composition-root.js +0 -3
- package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
- package/dist/tui-v2/bootstrap/start-tui-v2.js +0 -10
- package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
- package/dist/types.d.ts +0 -5
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/package.json +1 -1
- package/dist/app/adapters/current-interactive-sessions-adapter.d.ts +0 -4
- package/dist/app/adapters/current-interactive-sessions-adapter.js +0 -11
- package/dist/app/adapters/current-interactive-sessions-adapter.js.map +0 -1
- package/dist/app/controllers/cancel-all-result.d.ts +0 -8
- package/dist/app/controllers/cancel-all-result.js +0 -32
- package/dist/app/controllers/cancel-all-result.js.map +0 -1
- package/dist/app/ports/interactive-sessions-port.d.ts +0 -21
- package/dist/app/ports/interactive-sessions-port.js +0 -9
- package/dist/app/ports/interactive-sessions-port.js.map +0 -1
- package/dist/interactive-session/artifact-writer.d.ts +0 -54
- package/dist/interactive-session/artifact-writer.js +0 -161
- package/dist/interactive-session/artifact-writer.js.map +0 -1
- package/dist/interactive-session/cleanup.d.ts +0 -36
- package/dist/interactive-session/cleanup.js +0 -157
- package/dist/interactive-session/cleanup.js.map +0 -1
- package/dist/interactive-session/config.d.ts +0 -118
- package/dist/interactive-session/config.js +0 -141
- package/dist/interactive-session/config.js.map +0 -1
- package/dist/interactive-session/input-policy.d.ts +0 -41
- package/dist/interactive-session/input-policy.js +0 -163
- package/dist/interactive-session/input-policy.js.map +0 -1
- package/dist/interactive-session/manager.d.ts +0 -107
- package/dist/interactive-session/manager.js +0 -927
- package/dist/interactive-session/manager.js.map +0 -1
- package/dist/interactive-session/output-store.d.ts +0 -81
- package/dist/interactive-session/output-store.js +0 -239
- package/dist/interactive-session/output-store.js.map +0 -1
- package/dist/interactive-session/output-view.d.ts +0 -20
- package/dist/interactive-session/output-view.js +0 -142
- package/dist/interactive-session/output-view.js.map +0 -1
- package/dist/interactive-session/recovery-journal.d.ts +0 -55
- package/dist/interactive-session/recovery-journal.js +0 -115
- package/dist/interactive-session/recovery-journal.js.map +0 -1
- package/dist/interactive-session/registry.d.ts +0 -59
- package/dist/interactive-session/registry.js +0 -170
- package/dist/interactive-session/registry.js.map +0 -1
- package/dist/interactive-session/runtime.d.ts +0 -107
- package/dist/interactive-session/runtime.js +0 -191
- package/dist/interactive-session/runtime.js.map +0 -1
- package/dist/interactive-session/session-runtime.d.ts +0 -50
- package/dist/interactive-session/session-runtime.js +0 -63
- package/dist/interactive-session/session-runtime.js.map +0 -1
- package/dist/interactive-session/streaming-redactor.d.ts +0 -53
- package/dist/interactive-session/streaming-redactor.js +0 -263
- package/dist/interactive-session/streaming-redactor.js.map +0 -1
- package/dist/interactive-session/telemetry.d.ts +0 -30
- package/dist/interactive-session/telemetry.js +0 -47
- package/dist/interactive-session/telemetry.js.map +0 -1
- package/dist/interactive-session/transport-factory.d.ts +0 -8
- package/dist/interactive-session/transport-factory.js +0 -16
- package/dist/interactive-session/transport-factory.js.map +0 -1
- package/dist/interactive-session/transport-node-pty.d.ts +0 -49
- package/dist/interactive-session/transport-node-pty.js +0 -219
- package/dist/interactive-session/transport-node-pty.js.map +0 -1
- package/dist/interactive-session/transport-pipe.d.ts +0 -9
- package/dist/interactive-session/transport-pipe.js +0 -215
- package/dist/interactive-session/transport-pipe.js.map +0 -1
- package/dist/interactive-session/transport.d.ts +0 -104
- package/dist/interactive-session/transport.js +0 -89
- package/dist/interactive-session/transport.js.map +0 -1
- package/dist/interactive-session/types.d.ts +0 -254
- package/dist/interactive-session/types.js +0 -191
- package/dist/interactive-session/types.js.map +0 -1
- package/dist/os/process-identity.d.ts +0 -46
- package/dist/os/process-identity.js +0 -147
- package/dist/os/process-identity.js.map +0 -1
- package/dist/tools/interactive-session-tools.d.ts +0 -11
- package/dist/tools/interactive-session-tools.js +0 -261
- package/dist/tools/interactive-session-tools.js.map +0 -1
|
@@ -1,927 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive session manager: the single owner of persistent interactive
|
|
3
|
-
* process lifecycle.
|
|
4
|
-
*
|
|
5
|
-
* Ownership is conversation-scoped. Every operation requires an owner id, and a
|
|
6
|
-
* lookup for another owner is indistinguishable from a missing session so the
|
|
7
|
-
* manager cannot be used as an existence oracle. Direct user-terminal handoff is
|
|
8
|
-
* outside this contract by design.
|
|
9
|
-
*/
|
|
10
|
-
import { safeCwd } from "../os/cwd.js";
|
|
11
|
-
import { classifyShellCommand } from "../safety/classifier.js";
|
|
12
|
-
import { createArtifactWriter } from "./artifact-writer.js";
|
|
13
|
-
import { clampReadWait, resolveDeadline, resolveDimensions, resolveInteractiveSessionConfig, resolveOptionalTimeout, resolveQuietInterval, } from "./config.js";
|
|
14
|
-
import { CleanupCoordinator } from "./cleanup.js";
|
|
15
|
-
import { ApprovalTokenVault, classifyInteractiveInput, describeInput, } from "./input-policy.js";
|
|
16
|
-
import { OutputStore } from "./output-store.js";
|
|
17
|
-
import { RecoveryJournal, hashOwner } from "./recovery-journal.js";
|
|
18
|
-
import { SessionRegistry } from "./registry.js";
|
|
19
|
-
import { systemClock } from "./runtime.js";
|
|
20
|
-
import { SessionRuntime } from "./session-runtime.js";
|
|
21
|
-
import { SessionTelemetry } from "./telemetry.js";
|
|
22
|
-
import { defaultTransportFactory } from "./transport-factory.js";
|
|
23
|
-
import { LaunchFailure, encodeTextInput, ptyControlBytes, pipeControlAction, } from "./transport.js";
|
|
24
|
-
import { artifactReference, asStableError, isTerminalState, sessionError, SessionErrorException, throwSessionError, toSummary, } from "./types.js";
|
|
25
|
-
export class InteractiveSessionManager {
|
|
26
|
-
registry = new SessionRegistry();
|
|
27
|
-
runtimes = new Map();
|
|
28
|
-
approvals = new ApprovalTokenVault();
|
|
29
|
-
telemetry;
|
|
30
|
-
journal;
|
|
31
|
-
cleanup;
|
|
32
|
-
transports;
|
|
33
|
-
clock;
|
|
34
|
-
baseConfig;
|
|
35
|
-
artifactBaseDir;
|
|
36
|
-
ownerCloses = new Map();
|
|
37
|
-
constructor(deps = {}) {
|
|
38
|
-
this.baseConfig = resolveInteractiveSessionConfig(deps.config ?? {});
|
|
39
|
-
this.transports = deps.transports ?? defaultTransportFactory;
|
|
40
|
-
this.clock = deps.clock ?? systemClock;
|
|
41
|
-
this.telemetry = deps.telemetry ?? new SessionTelemetry();
|
|
42
|
-
this.journal = deps.journal ?? new RecoveryJournal();
|
|
43
|
-
this.artifactBaseDir = deps.artifactBaseDir;
|
|
44
|
-
this.cleanup = new CleanupCoordinator({
|
|
45
|
-
registry: this.registry,
|
|
46
|
-
journal: this.journal,
|
|
47
|
-
onFinalized: (runtime) => {
|
|
48
|
-
this.runtimes.delete(runtime.record.id);
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
get config() {
|
|
53
|
-
return this.baseConfig;
|
|
54
|
-
}
|
|
55
|
-
/** Reconcile crashed-session records. Must run before tools are enabled. */
|
|
56
|
-
reconcileOrphans() {
|
|
57
|
-
return this.journal.reconcile();
|
|
58
|
-
}
|
|
59
|
-
async ptyCapability() {
|
|
60
|
-
const capability = await this.transports.capability();
|
|
61
|
-
return capability.available
|
|
62
|
-
? { available: true }
|
|
63
|
-
: { available: false, reason: capability.reason };
|
|
64
|
-
}
|
|
65
|
-
// --- Start ------------------------------------------------------------
|
|
66
|
-
async start(request) {
|
|
67
|
-
const startedAt = Date.now();
|
|
68
|
-
const config = this.baseConfig;
|
|
69
|
-
this.assertOwner(request.ownerId, "start");
|
|
70
|
-
this.assertEnabled(config, "start");
|
|
71
|
-
if (typeof request.command !== "string" || request.command.trim().length === 0) {
|
|
72
|
-
throwSessionError({
|
|
73
|
-
code: "INVALID_REQUEST",
|
|
74
|
-
operation: "start",
|
|
75
|
-
message: "A non-empty command is required to start an interactive session.",
|
|
76
|
-
details: { field: "command" },
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
const deadlineMs = resolveDeadline("startDeadlineMs", request.deadlineMs, config, "start");
|
|
80
|
-
const idleTimeoutMs = resolveOptionalTimeout("idleTimeoutMs", request.idleTimeoutMs, config, "start");
|
|
81
|
-
const lifetimeTimeoutMs = resolveOptionalTimeout("lifetimeTimeoutMs", request.lifetimeTimeoutMs, config, "start");
|
|
82
|
-
const mode = request.terminalMode ?? "preferred";
|
|
83
|
-
if (mode !== "required" && mode !== "preferred" && mode !== "pipe") {
|
|
84
|
-
throwSessionError({
|
|
85
|
-
code: "INVALID_REQUEST",
|
|
86
|
-
operation: "start",
|
|
87
|
-
message: "terminalMode must be required, preferred, or pipe.",
|
|
88
|
-
details: { field: "terminalMode" },
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
const dimensions = resolveDimensions(request.columns, request.rows, "start");
|
|
92
|
-
await this.authorizeCommand(request.command, request, config);
|
|
93
|
-
// Resolve capability before allocating anything, so a required-PTY failure
|
|
94
|
-
// never spawns a process.
|
|
95
|
-
const capability = mode === "pipe" ? undefined : await this.transports.capability();
|
|
96
|
-
let transportKind = "pipe";
|
|
97
|
-
let degraded;
|
|
98
|
-
if (mode === "required") {
|
|
99
|
-
if (!capability?.available) {
|
|
100
|
-
throwSessionError({
|
|
101
|
-
code: "PTY_UNAVAILABLE",
|
|
102
|
-
operation: "start",
|
|
103
|
-
message: capability?.reason ??
|
|
104
|
-
"A pseudoterminal is required but unavailable on this target.",
|
|
105
|
-
details: { terminalMode: mode },
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
transportKind = "pty";
|
|
109
|
-
}
|
|
110
|
-
else if (mode === "preferred") {
|
|
111
|
-
if (capability?.available)
|
|
112
|
-
transportKind = "pty";
|
|
113
|
-
else
|
|
114
|
-
degraded = "PTY_UNAVAILABLE";
|
|
115
|
-
}
|
|
116
|
-
const cwd = request.cwd ?? safeCwd();
|
|
117
|
-
const reservation = await this.registry.withOwnerLock(request.ownerId, async () => {
|
|
118
|
-
this.assertNotFenced(request.ownerId, "start");
|
|
119
|
-
const live = this.registry.liveCount(request.ownerId);
|
|
120
|
-
if (live >= config.liveSessionLimit) {
|
|
121
|
-
throwSessionError({
|
|
122
|
-
code: "LIMIT_REACHED",
|
|
123
|
-
operation: "start",
|
|
124
|
-
message: `This conversation already owns ${live} live interactive sessions.`,
|
|
125
|
-
details: { liveSessions: live, limit: config.liveSessionLimit },
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
const id = this.registry.mintId();
|
|
129
|
-
const artifact = await createArtifactWriter({
|
|
130
|
-
sessionId: id,
|
|
131
|
-
captureBytes: config.artifactCaptureBytes,
|
|
132
|
-
chunkBytes: config.artifactChunkBytes,
|
|
133
|
-
persistenceQueueBytes: config.persistenceQueueBytes,
|
|
134
|
-
onLimit: config.onOutputLimit,
|
|
135
|
-
baseDir: this.artifactBaseDir,
|
|
136
|
-
});
|
|
137
|
-
const record = {
|
|
138
|
-
id,
|
|
139
|
-
ownerId: request.ownerId,
|
|
140
|
-
state: "starting",
|
|
141
|
-
transport: transportKind,
|
|
142
|
-
startedAt,
|
|
143
|
-
lastActivityAt: startedAt,
|
|
144
|
-
...(transportKind === "pty" ? { dimensions } : {}),
|
|
145
|
-
...(degraded ? { degradedReason: degraded } : {}),
|
|
146
|
-
artifact: artifact.receipt(),
|
|
147
|
-
earliestCursor: 0,
|
|
148
|
-
latestCursor: 0,
|
|
149
|
-
inputClosed: false,
|
|
150
|
-
};
|
|
151
|
-
this.registry.insert(record);
|
|
152
|
-
// Live record written before launch so a crash between here and spawn
|
|
153
|
-
// confirmation still leaves reconcilable evidence.
|
|
154
|
-
this.journal.upsert({
|
|
155
|
-
id,
|
|
156
|
-
ownerHash: hashOwner(request.ownerId),
|
|
157
|
-
pid: undefined,
|
|
158
|
-
processGroupId: undefined,
|
|
159
|
-
identity: undefined,
|
|
160
|
-
platform: process.platform,
|
|
161
|
-
startedAt,
|
|
162
|
-
artifactPath: artifact.path,
|
|
163
|
-
launchConfirmed: false,
|
|
164
|
-
});
|
|
165
|
-
return { record, artifact };
|
|
166
|
-
});
|
|
167
|
-
const { record, artifact } = reservation;
|
|
168
|
-
let retried = false;
|
|
169
|
-
try {
|
|
170
|
-
const launch = await this.launchWithDeadline({ command: request.command, cwd }, transportKind, dimensions, deadlineMs, (didRetry) => {
|
|
171
|
-
retried = didRetry;
|
|
172
|
-
});
|
|
173
|
-
const transport = launch.transport;
|
|
174
|
-
const output = new OutputStore({
|
|
175
|
-
memoryWindowBytes: config.memoryWindowBytes,
|
|
176
|
-
pageBytes: config.pageBytes,
|
|
177
|
-
redactionOverlapBytes: config.redactionOverlapBytes,
|
|
178
|
-
sink: artifact,
|
|
179
|
-
onPause: () => transport.pauseOutput(),
|
|
180
|
-
onResume: () => transport.resumeOutput(),
|
|
181
|
-
onPersistenceStop: (reason) => {
|
|
182
|
-
void this.finalize(record.id, reason === "output-limit" ? "output-limit" : "explicit-close");
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
const runtime = new SessionRuntime({
|
|
186
|
-
record,
|
|
187
|
-
transport,
|
|
188
|
-
output,
|
|
189
|
-
artifact,
|
|
190
|
-
config,
|
|
191
|
-
clock: this.clock,
|
|
192
|
-
idleTimeoutMs,
|
|
193
|
-
lifetimeTimeoutMs,
|
|
194
|
-
onTimeout: (reason) => {
|
|
195
|
-
void this.finalize(record.id, reason);
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
// Subscribe before reporting launch confirmation so no output is lost.
|
|
199
|
-
runtime.track(transport.onOutput((event) => {
|
|
200
|
-
output.ingest(event.stream, event.bytes, event.observedAt);
|
|
201
|
-
runtime.syncCursors();
|
|
202
|
-
runtime.touch();
|
|
203
|
-
}));
|
|
204
|
-
runtime.track(transport.onExit((outcome) => {
|
|
205
|
-
runtime.processExited = true;
|
|
206
|
-
this.registry.enrich(record, { processOutcome: outcome });
|
|
207
|
-
runtime.exitSignal.bump();
|
|
208
|
-
void this.finalize(record.id, "process-exit");
|
|
209
|
-
}));
|
|
210
|
-
this.runtimes.set(record.id, runtime);
|
|
211
|
-
this.journal.upsert({
|
|
212
|
-
id: record.id,
|
|
213
|
-
ownerHash: hashOwner(request.ownerId),
|
|
214
|
-
pid: transport.pid,
|
|
215
|
-
processGroupId: transport.processGroupId,
|
|
216
|
-
identity: transport.identity,
|
|
217
|
-
platform: process.platform,
|
|
218
|
-
startedAt,
|
|
219
|
-
artifactPath: artifact.path,
|
|
220
|
-
launchConfirmed: true,
|
|
221
|
-
});
|
|
222
|
-
this.registry.transition(record, "running");
|
|
223
|
-
this.telemetry.record({
|
|
224
|
-
operation: "start",
|
|
225
|
-
sessionId: record.id,
|
|
226
|
-
durationMs: Date.now() - startedAt,
|
|
227
|
-
result: "ok",
|
|
228
|
-
state: record.state,
|
|
229
|
-
transport: transportKind,
|
|
230
|
-
retryCount: retried ? 1 : 0,
|
|
231
|
-
});
|
|
232
|
-
return {
|
|
233
|
-
operation: "start",
|
|
234
|
-
sessionId: record.id,
|
|
235
|
-
state: record.state,
|
|
236
|
-
transport: transportKind,
|
|
237
|
-
...(record.dimensions ? { dimensions: record.dimensions } : {}),
|
|
238
|
-
...(degraded ? { degradedReason: degraded } : {}),
|
|
239
|
-
cursor: output.latestCursor,
|
|
240
|
-
artifact: artifactReference(record.artifact),
|
|
241
|
-
...(retried ? { retriedLaunch: true } : {}),
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
catch (error) {
|
|
245
|
-
// Every allocated resource is released before Start returns.
|
|
246
|
-
let cleanupVerified = true;
|
|
247
|
-
try {
|
|
248
|
-
await artifact.close();
|
|
249
|
-
}
|
|
250
|
-
catch {
|
|
251
|
-
cleanupVerified = false;
|
|
252
|
-
}
|
|
253
|
-
this.journal.remove(record.id);
|
|
254
|
-
this.registry.transition(record, "failed", {
|
|
255
|
-
terminationReason: "launch-failure",
|
|
256
|
-
cleanupVerified,
|
|
257
|
-
now: Date.now(),
|
|
258
|
-
});
|
|
259
|
-
const stable = this.launchError(error, record.id, retried);
|
|
260
|
-
this.telemetry.record({
|
|
261
|
-
operation: "start",
|
|
262
|
-
sessionId: record.id,
|
|
263
|
-
durationMs: Date.now() - startedAt,
|
|
264
|
-
result: stable.code,
|
|
265
|
-
state: record.state,
|
|
266
|
-
transport: transportKind,
|
|
267
|
-
retryCount: retried ? 1 : 0,
|
|
268
|
-
cleanupVerified,
|
|
269
|
-
});
|
|
270
|
-
throw new SessionErrorException(stable);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
launchError(error, sessionId, retried) {
|
|
274
|
-
if (error instanceof LaunchFailure) {
|
|
275
|
-
return sessionError({
|
|
276
|
-
code: error.code === "PTY_UNAVAILABLE" ? "PTY_UNAVAILABLE" : "LAUNCH_FAILED",
|
|
277
|
-
operation: "start",
|
|
278
|
-
sessionId,
|
|
279
|
-
message: error.message,
|
|
280
|
-
state: "failed",
|
|
281
|
-
// Only a proven pre-spawn transient failure could be safely retried, and
|
|
282
|
-
// that retry has already been consumed if it was available.
|
|
283
|
-
retryable: error.transient && !error.spawnConfirmed && !retried,
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
return asStableError(error, {
|
|
287
|
-
code: "LAUNCH_FAILED",
|
|
288
|
-
operation: "start",
|
|
289
|
-
sessionId,
|
|
290
|
-
state: "failed",
|
|
291
|
-
message: "The interactive session process failed to start.",
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
async launchWithDeadline(request, kind, dimensions, deadlineMs, onRetry) {
|
|
295
|
-
const attempt = async () => kind === "pty"
|
|
296
|
-
? await this.transports.startPty({ ...request, dimensions })
|
|
297
|
-
: await this.transports.startPipe(request);
|
|
298
|
-
let deadlineTimer;
|
|
299
|
-
const deadline = new Promise((_resolve, reject) => {
|
|
300
|
-
deadlineTimer = this.clock.setTimeout(() => {
|
|
301
|
-
reject(new LaunchFailure("START_DEADLINE", "The interactive session did not start before its deadline.", false, false));
|
|
302
|
-
}, deadlineMs);
|
|
303
|
-
});
|
|
304
|
-
try {
|
|
305
|
-
try {
|
|
306
|
-
return await Promise.race([attempt(), deadline]);
|
|
307
|
-
}
|
|
308
|
-
catch (error) {
|
|
309
|
-
// One retry only when the adapter proved no process side effect occurred.
|
|
310
|
-
if (error instanceof LaunchFailure && error.transient && !error.spawnConfirmed) {
|
|
311
|
-
onRetry(true);
|
|
312
|
-
return await Promise.race([attempt(), deadline]);
|
|
313
|
-
}
|
|
314
|
-
throw error;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
finally {
|
|
318
|
-
if (deadlineTimer)
|
|
319
|
-
this.clock.clearTimeout(deadlineTimer);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
// --- Send -------------------------------------------------------------
|
|
323
|
-
async send(request) {
|
|
324
|
-
const startedAt = this.clock.now();
|
|
325
|
-
const runtime = this.requireRuntime(request.ownerId, request.id, "send");
|
|
326
|
-
const config = runtime.config;
|
|
327
|
-
const view = request.view ?? "plain";
|
|
328
|
-
const quietMs = resolveQuietInterval(request.quietMs, config, "send");
|
|
329
|
-
const deadlineMs = resolveDeadline("sendDeadlineMs", request.deadlineMs, config, "send");
|
|
330
|
-
const deadlineAt = startedAt + deadlineMs;
|
|
331
|
-
const input = validateInput(request.input, "send");
|
|
332
|
-
await this.authorizeInput(runtime, input, request);
|
|
333
|
-
const accepted = await runtime.mutation.run(() => this.accept(runtime, input));
|
|
334
|
-
const delivery = await accepted.settled;
|
|
335
|
-
runtime.touch();
|
|
336
|
-
const cursor = request.cursor !== undefined ? request.cursor : accepted.cursorAtAcceptance;
|
|
337
|
-
const stop = await this.gather(runtime, quietMs, deadlineAt, request.signal);
|
|
338
|
-
const pageOutcome = runtime.output.page({
|
|
339
|
-
cursor,
|
|
340
|
-
view,
|
|
341
|
-
operation: "send",
|
|
342
|
-
sessionId: runtime.record.id,
|
|
343
|
-
});
|
|
344
|
-
const error = this.sendError(runtime, delivery, stop, pageOutcome);
|
|
345
|
-
this.telemetry.record({
|
|
346
|
-
operation: "send",
|
|
347
|
-
sessionId: runtime.record.id,
|
|
348
|
-
durationMs: this.clock.now() - startedAt,
|
|
349
|
-
result: error?.code ?? "ok",
|
|
350
|
-
state: runtime.record.state,
|
|
351
|
-
transport: runtime.record.transport,
|
|
352
|
-
inputBytes: accepted.queuedBytes,
|
|
353
|
-
outputBytes: pageOutcome.page.nextCursor - pageOutcome.page.requestedCursor,
|
|
354
|
-
queueDepth: runtime.input.depth,
|
|
355
|
-
retryCount: 0,
|
|
356
|
-
});
|
|
357
|
-
return {
|
|
358
|
-
operation: "send",
|
|
359
|
-
sessionId: runtime.record.id,
|
|
360
|
-
inputSequence: accepted.sequence,
|
|
361
|
-
delivery: delivery.status,
|
|
362
|
-
deliveredBytes: delivery.deliveredBytes,
|
|
363
|
-
state: runtime.record.state,
|
|
364
|
-
page: pageOutcome.page,
|
|
365
|
-
...(error ? { error } : {}),
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
sendError(runtime, delivery, stop, pageOutcome) {
|
|
369
|
-
const sessionId = runtime.record.id;
|
|
370
|
-
if (delivery.status === "unknown") {
|
|
371
|
-
return sessionError({
|
|
372
|
-
code: "INPUT_DELIVERY_UNKNOWN",
|
|
373
|
-
operation: "send",
|
|
374
|
-
sessionId,
|
|
375
|
-
state: runtime.record.state,
|
|
376
|
-
message: "The transport could not confirm whether the input bytes were accepted. Do not resend; read output to determine the effect.",
|
|
377
|
-
details: { deliveredBytes: delivery.deliveredBytes },
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
if (delivery.status === "not-delivered") {
|
|
381
|
-
return sessionError({
|
|
382
|
-
code: "SESSION_NOT_RUNNING",
|
|
383
|
-
operation: "send",
|
|
384
|
-
sessionId,
|
|
385
|
-
state: runtime.record.state,
|
|
386
|
-
message: "The session was no longer accepting input when the write ran.",
|
|
387
|
-
details: { deliveredBytes: 0 },
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
if (stop === "cancelled") {
|
|
391
|
-
return sessionError({
|
|
392
|
-
code: "CANCELLED",
|
|
393
|
-
operation: "send",
|
|
394
|
-
sessionId,
|
|
395
|
-
state: runtime.record.state,
|
|
396
|
-
message: "Output gathering was cancelled after the input was delivered; the session is still available.",
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
if (stop === "deadline") {
|
|
400
|
-
return sessionError({
|
|
401
|
-
code: "DEADLINE_EXCEEDED",
|
|
402
|
-
operation: "send",
|
|
403
|
-
sessionId,
|
|
404
|
-
state: runtime.record.state,
|
|
405
|
-
message: "The send deadline elapsed after input delivery. Continue with a read from nextCursor; do not resend the input.",
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
return pageOutcome.ok ? undefined : pageOutcome.error;
|
|
409
|
-
}
|
|
410
|
-
/**
|
|
411
|
-
* Acceptance is all-or-nothing under the mutation lock: either the whole action
|
|
412
|
-
* is reserved within the backpressure bound, or no sequence or queue state
|
|
413
|
-
* changes at all.
|
|
414
|
-
*/
|
|
415
|
-
accept(runtime, input) {
|
|
416
|
-
const record = runtime.record;
|
|
417
|
-
if (record.state === "closing") {
|
|
418
|
-
throwSessionError({
|
|
419
|
-
code: "SESSION_CLOSING",
|
|
420
|
-
operation: "send",
|
|
421
|
-
sessionId: record.id,
|
|
422
|
-
state: record.state,
|
|
423
|
-
message: "This session is closing and no longer accepts input.",
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
if (record.state !== "running") {
|
|
427
|
-
throwSessionError({
|
|
428
|
-
code: "SESSION_NOT_RUNNING",
|
|
429
|
-
operation: "send",
|
|
430
|
-
sessionId: record.id,
|
|
431
|
-
state: record.state,
|
|
432
|
-
message: "This session is not running.",
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
if (record.inputClosed) {
|
|
436
|
-
throwSessionError({
|
|
437
|
-
code: "INPUT_CLOSED",
|
|
438
|
-
operation: "send",
|
|
439
|
-
sessionId: record.id,
|
|
440
|
-
state: record.state,
|
|
441
|
-
message: "Session input was closed by a prior EOF; no further input is accepted.",
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
if (input.kind === "control")
|
|
445
|
-
this.assertControlSupported(runtime, input.control);
|
|
446
|
-
const bytes = input.kind === "text"
|
|
447
|
-
? encodeTextInput(input.text, input.submit, runtime.transport.kind)
|
|
448
|
-
: new Uint8Array(0);
|
|
449
|
-
const queuedBytes = bytes.length;
|
|
450
|
-
const pending = runtime.input.pendingBytes;
|
|
451
|
-
if (pending + queuedBytes > runtime.config.queuedInputBytes) {
|
|
452
|
-
throwSessionError({
|
|
453
|
-
code: "BACKPRESSURE",
|
|
454
|
-
operation: "send",
|
|
455
|
-
sessionId: record.id,
|
|
456
|
-
state: record.state,
|
|
457
|
-
message: "Queued input for this session would exceed its backpressure limit.",
|
|
458
|
-
details: {
|
|
459
|
-
queuedBytes: pending,
|
|
460
|
-
limitBytes: runtime.config.queuedInputBytes,
|
|
461
|
-
},
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
if (input.kind === "eof")
|
|
465
|
-
record.inputClosed = true;
|
|
466
|
-
const sequence = runtime.input.reserve(queuedBytes);
|
|
467
|
-
const cursorAtAcceptance = runtime.output.latestCursor;
|
|
468
|
-
let settle = () => undefined;
|
|
469
|
-
const settled = new Promise((resolve) => {
|
|
470
|
-
settle = resolve;
|
|
471
|
-
});
|
|
472
|
-
runtime.input.enqueue({
|
|
473
|
-
sequence,
|
|
474
|
-
queuedBytes,
|
|
475
|
-
cursorAtAcceptance,
|
|
476
|
-
deliver: async () => {
|
|
477
|
-
if (input.kind === "eof")
|
|
478
|
-
return await runtime.transport.closeInput();
|
|
479
|
-
if (input.kind === "control")
|
|
480
|
-
return await runtime.transport.control(input.control);
|
|
481
|
-
return await runtime.transport.write(bytes);
|
|
482
|
-
},
|
|
483
|
-
settle: (outcome) => settle({ status: outcome.status, deliveredBytes: outcome.deliveredBytes }),
|
|
484
|
-
});
|
|
485
|
-
runtime.touch();
|
|
486
|
-
return { sequence, queuedBytes, cursorAtAcceptance, settled };
|
|
487
|
-
}
|
|
488
|
-
assertControlSupported(runtime, control) {
|
|
489
|
-
const supported = runtime.transport.kind === "pty"
|
|
490
|
-
? ptyControlBytes(control) !== undefined
|
|
491
|
-
: pipeControlAction(control).kind !== "unsupported";
|
|
492
|
-
if (supported)
|
|
493
|
-
return;
|
|
494
|
-
throwSessionError({
|
|
495
|
-
code: "UNSUPPORTED_CONTROL",
|
|
496
|
-
operation: "send",
|
|
497
|
-
sessionId: runtime.record.id,
|
|
498
|
-
state: runtime.record.state,
|
|
499
|
-
message: "That control is not supported by this transport on this platform.",
|
|
500
|
-
details: { control, transport: runtime.transport.kind },
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
/**
|
|
504
|
-
* Quiet detection starts at delivery completion: only output observed after
|
|
505
|
-
* this point resets the quiet timer, while the absolute deadline never extends.
|
|
506
|
-
*/
|
|
507
|
-
gather(runtime, quietMs, deadlineAt, signal) {
|
|
508
|
-
if (runtime.processExited)
|
|
509
|
-
return Promise.resolve("exit");
|
|
510
|
-
if (signal?.aborted)
|
|
511
|
-
return Promise.resolve("cancelled");
|
|
512
|
-
return new Promise((resolve) => {
|
|
513
|
-
const cleanups = [];
|
|
514
|
-
let quietTimer = this.clock.setTimeout(() => finish("quiet"), quietMs);
|
|
515
|
-
const deadlineTimer = this.clock.setTimeout(() => finish("deadline"), Math.max(0, deadlineAt - this.clock.now()));
|
|
516
|
-
let settled = false;
|
|
517
|
-
const finish = (stop) => {
|
|
518
|
-
if (settled)
|
|
519
|
-
return;
|
|
520
|
-
settled = true;
|
|
521
|
-
this.clock.clearTimeout(quietTimer);
|
|
522
|
-
this.clock.clearTimeout(deadlineTimer);
|
|
523
|
-
for (const cleanup of cleanups)
|
|
524
|
-
cleanup();
|
|
525
|
-
signal?.removeEventListener("abort", onAbort);
|
|
526
|
-
resolve(stop);
|
|
527
|
-
};
|
|
528
|
-
const onAbort = () => finish("cancelled");
|
|
529
|
-
cleanups.push(runtime.output.subscribe(() => {
|
|
530
|
-
if (settled)
|
|
531
|
-
return;
|
|
532
|
-
this.clock.clearTimeout(quietTimer);
|
|
533
|
-
quietTimer = this.clock.setTimeout(() => finish("quiet"), quietMs);
|
|
534
|
-
}));
|
|
535
|
-
cleanups.push(runtime.exitSignal.subscribe(() => finish("exit")));
|
|
536
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
// --- Read -------------------------------------------------------------
|
|
540
|
-
async read(request) {
|
|
541
|
-
const startedAt = Date.now();
|
|
542
|
-
const runtime = this.requireRuntime(request.ownerId, request.id, "read");
|
|
543
|
-
const view = request.view ?? "plain";
|
|
544
|
-
if (!Number.isInteger(request.cursor) || request.cursor < 0) {
|
|
545
|
-
throwSessionError({
|
|
546
|
-
code: "INVALID_REQUEST",
|
|
547
|
-
operation: "read",
|
|
548
|
-
sessionId: request.id,
|
|
549
|
-
message: "read requires a non-negative integer cursor.",
|
|
550
|
-
details: { field: "cursor" },
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
const waitMs = clampReadWait(request.waitMs);
|
|
554
|
-
let stop = "quiet";
|
|
555
|
-
if (waitMs > 0 && request.cursor >= runtime.output.latestCursor) {
|
|
556
|
-
stop = await this.waitForOutput(runtime, request.cursor, waitMs, request.signal);
|
|
557
|
-
}
|
|
558
|
-
const pageOutcome = runtime.output.page({
|
|
559
|
-
cursor: request.cursor,
|
|
560
|
-
view,
|
|
561
|
-
operation: "read",
|
|
562
|
-
sessionId: runtime.record.id,
|
|
563
|
-
});
|
|
564
|
-
const error = stop === "cancelled"
|
|
565
|
-
? sessionError({
|
|
566
|
-
code: "CANCELLED",
|
|
567
|
-
operation: "read",
|
|
568
|
-
sessionId: runtime.record.id,
|
|
569
|
-
state: runtime.record.state,
|
|
570
|
-
message: "The blocking read was cancelled; the session is still available.",
|
|
571
|
-
})
|
|
572
|
-
: pageOutcome.ok
|
|
573
|
-
? undefined
|
|
574
|
-
: pageOutcome.error;
|
|
575
|
-
this.telemetry.record({
|
|
576
|
-
operation: "read",
|
|
577
|
-
sessionId: runtime.record.id,
|
|
578
|
-
durationMs: Date.now() - startedAt,
|
|
579
|
-
result: error?.code ?? "ok",
|
|
580
|
-
state: runtime.record.state,
|
|
581
|
-
transport: runtime.record.transport,
|
|
582
|
-
outputBytes: pageOutcome.page.nextCursor - pageOutcome.page.requestedCursor,
|
|
583
|
-
});
|
|
584
|
-
return {
|
|
585
|
-
operation: "read",
|
|
586
|
-
sessionId: runtime.record.id,
|
|
587
|
-
state: runtime.record.state,
|
|
588
|
-
page: pageOutcome.page,
|
|
589
|
-
...(error ? { error } : {}),
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
waitForOutput(runtime, cursor, waitMs, signal) {
|
|
593
|
-
if (signal?.aborted)
|
|
594
|
-
return Promise.resolve("cancelled");
|
|
595
|
-
return new Promise((resolve) => {
|
|
596
|
-
const cleanups = [];
|
|
597
|
-
let settled = false;
|
|
598
|
-
const timer = this.clock.setTimeout(() => finish("deadline"), waitMs);
|
|
599
|
-
const finish = (stop) => {
|
|
600
|
-
if (settled)
|
|
601
|
-
return;
|
|
602
|
-
settled = true;
|
|
603
|
-
this.clock.clearTimeout(timer);
|
|
604
|
-
for (const cleanup of cleanups)
|
|
605
|
-
cleanup();
|
|
606
|
-
signal?.removeEventListener("abort", onAbort);
|
|
607
|
-
resolve(stop);
|
|
608
|
-
};
|
|
609
|
-
const onAbort = () => finish("cancelled");
|
|
610
|
-
cleanups.push(runtime.output.subscribe(() => {
|
|
611
|
-
if (runtime.output.latestCursor > cursor)
|
|
612
|
-
finish("quiet");
|
|
613
|
-
}));
|
|
614
|
-
cleanups.push(runtime.exitSignal.subscribe(() => finish("exit")));
|
|
615
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
// --- Status, List, Resize --------------------------------------------
|
|
619
|
-
status(request) {
|
|
620
|
-
this.assertOwner(request.ownerId, "status");
|
|
621
|
-
const record = this.registry.get(request.ownerId, request.id);
|
|
622
|
-
if (!record)
|
|
623
|
-
this.notFound(request.id, "status");
|
|
624
|
-
return { operation: "status", session: toSummary(record) };
|
|
625
|
-
}
|
|
626
|
-
list(request) {
|
|
627
|
-
this.assertOwner(request.ownerId, "list");
|
|
628
|
-
return { operation: "list", sessions: this.registry.list(request.ownerId) };
|
|
629
|
-
}
|
|
630
|
-
async resize(request) {
|
|
631
|
-
const startedAt = Date.now();
|
|
632
|
-
this.assertOwner(request.ownerId, "resize");
|
|
633
|
-
// Dimensions are validated before any lookup or mutation.
|
|
634
|
-
const dimensions = resolveDimensions(request.columns, request.rows, "resize");
|
|
635
|
-
const runtime = this.requireRuntime(request.ownerId, request.id, "resize");
|
|
636
|
-
if (runtime.transport.kind !== "pty" || !runtime.transport.resize) {
|
|
637
|
-
throwSessionError({
|
|
638
|
-
code: "UNSUPPORTED_OPERATION",
|
|
639
|
-
operation: "resize",
|
|
640
|
-
sessionId: request.id,
|
|
641
|
-
state: runtime.record.state,
|
|
642
|
-
message: "Resize requires a pseudoterminal session.",
|
|
643
|
-
details: { transport: runtime.transport.kind },
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
const result = await runtime.mutation.run(async () => {
|
|
647
|
-
const record = runtime.record;
|
|
648
|
-
if (record.state !== "running") {
|
|
649
|
-
throwSessionError({
|
|
650
|
-
code: record.state === "closing" ? "SESSION_CLOSING" : "SESSION_NOT_RUNNING",
|
|
651
|
-
operation: "resize",
|
|
652
|
-
sessionId: record.id,
|
|
653
|
-
state: record.state,
|
|
654
|
-
message: "The session is no longer running, so it cannot be resized.",
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
|
-
await runtime.transport.resize(dimensions);
|
|
658
|
-
record.dimensions = dimensions;
|
|
659
|
-
return dimensions;
|
|
660
|
-
});
|
|
661
|
-
this.telemetry.record({
|
|
662
|
-
operation: "resize",
|
|
663
|
-
sessionId: request.id,
|
|
664
|
-
durationMs: Date.now() - startedAt,
|
|
665
|
-
result: "ok",
|
|
666
|
-
state: runtime.record.state,
|
|
667
|
-
transport: runtime.record.transport,
|
|
668
|
-
});
|
|
669
|
-
return {
|
|
670
|
-
operation: "resize",
|
|
671
|
-
sessionId: request.id,
|
|
672
|
-
state: runtime.record.state,
|
|
673
|
-
dimensions: result,
|
|
674
|
-
};
|
|
675
|
-
}
|
|
676
|
-
// --- Close and owner lifecycle ---------------------------------------
|
|
677
|
-
async close(request) {
|
|
678
|
-
const startedAt = Date.now();
|
|
679
|
-
this.assertOwner(request.ownerId, "close");
|
|
680
|
-
const record = this.registry.get(request.ownerId, request.id);
|
|
681
|
-
if (!record)
|
|
682
|
-
this.notFound(request.id, "close");
|
|
683
|
-
const deadlineMs = resolveDeadline("closeDeadlineMs", request.deadlineMs, this.baseConfig, "close");
|
|
684
|
-
const result = await this.finalizeRecord(record, "explicit-close", deadlineMs);
|
|
685
|
-
this.telemetry.record({
|
|
686
|
-
operation: "close",
|
|
687
|
-
sessionId: record.id,
|
|
688
|
-
durationMs: Date.now() - startedAt,
|
|
689
|
-
result: result.error?.code ?? "ok",
|
|
690
|
-
state: result.state,
|
|
691
|
-
transport: record.transport,
|
|
692
|
-
...(result.terminationReason ? { terminationReason: result.terminationReason } : {}),
|
|
693
|
-
cleanupVerified: result.cleanupVerified,
|
|
694
|
-
});
|
|
695
|
-
return result;
|
|
696
|
-
}
|
|
697
|
-
/** Owner cancellation: closes every live session owned by a conversation. */
|
|
698
|
-
async cancelOwner(ownerId) {
|
|
699
|
-
return await this.closeOwnerSessions(ownerId, "cancelled");
|
|
700
|
-
}
|
|
701
|
-
/**
|
|
702
|
-
* Fence an owner synchronously, then start one tracked close. Callers rebinding
|
|
703
|
-
* a conversation id must fence before the old id becomes unreachable.
|
|
704
|
-
*/
|
|
705
|
-
beginCloseOwner(ownerId, reason = "conversation-teardown") {
|
|
706
|
-
this.registry.fenceOwner(ownerId);
|
|
707
|
-
const existing = this.ownerCloses.get(ownerId);
|
|
708
|
-
if (existing)
|
|
709
|
-
return existing;
|
|
710
|
-
const promise = this.closeOwnerSessions(ownerId, reason).finally(() => {
|
|
711
|
-
this.ownerCloses.delete(ownerId);
|
|
712
|
-
});
|
|
713
|
-
this.ownerCloses.set(ownerId, promise);
|
|
714
|
-
return promise;
|
|
715
|
-
}
|
|
716
|
-
/** Await any teardown already started for an owner. */
|
|
717
|
-
async awaitOwnerClose(ownerId) {
|
|
718
|
-
await this.ownerCloses.get(ownerId);
|
|
719
|
-
}
|
|
720
|
-
async closeAll(reason = "app-shutdown") {
|
|
721
|
-
const owners = this.registry.owners();
|
|
722
|
-
await Promise.all([...this.ownerCloses.values()].map((promise) => promise.catch(() => undefined)));
|
|
723
|
-
const results = await Promise.all(owners.map((ownerId) => this.closeOwnerSessions(ownerId, reason)));
|
|
724
|
-
return {
|
|
725
|
-
owners: owners.length,
|
|
726
|
-
closed: results.reduce((sum, result) => sum + result.closed, 0),
|
|
727
|
-
failures: results.flatMap((result) => result.failures),
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
async closeOwnerSessions(ownerId, reason) {
|
|
731
|
-
const records = this.registry.liveRecords(ownerId);
|
|
732
|
-
const results = await Promise.all(records.map((record) => this.finalizeRecord(record, reason, this.baseConfig.closeDeadlineMs)));
|
|
733
|
-
return {
|
|
734
|
-
closed: results.length,
|
|
735
|
-
failures: results
|
|
736
|
-
.map((result) => result.error)
|
|
737
|
-
.filter((error) => error !== undefined),
|
|
738
|
-
};
|
|
739
|
-
}
|
|
740
|
-
async finalizeRecord(record, reason, deadlineMs) {
|
|
741
|
-
const runtime = this.runtimes.get(record.id);
|
|
742
|
-
if (!runtime) {
|
|
743
|
-
// Already finalized (or never launched): return the recorded terminal
|
|
744
|
-
// result without signalling a process.
|
|
745
|
-
return {
|
|
746
|
-
operation: "close",
|
|
747
|
-
sessionId: record.id,
|
|
748
|
-
state: record.state,
|
|
749
|
-
...(record.terminationReason ? { terminationReason: record.terminationReason } : {}),
|
|
750
|
-
...(record.processOutcome ? { processOutcome: record.processOutcome } : {}),
|
|
751
|
-
cleanupVerified: record.cleanupVerified ?? isTerminalState(record.state),
|
|
752
|
-
artifact: artifactReference(record.artifact),
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
return await this.cleanup.close(runtime, reason, deadlineMs);
|
|
756
|
-
}
|
|
757
|
-
async finalize(id, reason) {
|
|
758
|
-
const record = this.registry.getUnscoped(id);
|
|
759
|
-
if (!record)
|
|
760
|
-
return;
|
|
761
|
-
await this.finalizeRecord(record, reason, this.baseConfig.closeDeadlineMs);
|
|
762
|
-
}
|
|
763
|
-
// --- Policy -----------------------------------------------------------
|
|
764
|
-
async authorizeCommand(command, request, config) {
|
|
765
|
-
void config;
|
|
766
|
-
const decision = classifyShellCommand(command, request.scope ? { scope: request.scope } : {});
|
|
767
|
-
if (decision.level === "block") {
|
|
768
|
-
throwSessionError({
|
|
769
|
-
code: "INPUT_REJECTED",
|
|
770
|
-
operation: "start",
|
|
771
|
-
message: `Blocked by safety policy: ${decision.reason}.`,
|
|
772
|
-
});
|
|
773
|
-
}
|
|
774
|
-
if (decision.level === "safe")
|
|
775
|
-
return;
|
|
776
|
-
const approved = await request.confirm?.({
|
|
777
|
-
title: "Start interactive session",
|
|
778
|
-
description: describeInput({ kind: "text", text: command, submit: "enter" }),
|
|
779
|
-
reason: decision.reason,
|
|
780
|
-
});
|
|
781
|
-
if (!approved) {
|
|
782
|
-
throwSessionError({
|
|
783
|
-
code: "INPUT_REJECTED",
|
|
784
|
-
operation: "start",
|
|
785
|
-
message: `Confirmation is required to start this interactive session: ${decision.reason}.`,
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
async authorizeInput(runtime, input, request) {
|
|
790
|
-
const decision = classifyInteractiveInput({
|
|
791
|
-
ownerId: runtime.record.ownerId,
|
|
792
|
-
sessionId: runtime.record.id,
|
|
793
|
-
transport: runtime.transport.kind,
|
|
794
|
-
input,
|
|
795
|
-
...(request.scope ? { scope: request.scope } : {}),
|
|
796
|
-
});
|
|
797
|
-
if (decision.level === "safe")
|
|
798
|
-
return;
|
|
799
|
-
if (decision.level === "block") {
|
|
800
|
-
throwSessionError({
|
|
801
|
-
code: "INPUT_REJECTED",
|
|
802
|
-
operation: "send",
|
|
803
|
-
sessionId: runtime.record.id,
|
|
804
|
-
state: runtime.record.state,
|
|
805
|
-
message: `Blocked by interactive input policy: ${decision.reason}.`,
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
const binding = {
|
|
809
|
-
ownerId: runtime.record.ownerId,
|
|
810
|
-
sessionId: runtime.record.id,
|
|
811
|
-
input,
|
|
812
|
-
decision: decision.level,
|
|
813
|
-
};
|
|
814
|
-
const approved = await request.confirm?.({
|
|
815
|
-
title: "Send interactive input",
|
|
816
|
-
description: describeInput(input),
|
|
817
|
-
reason: decision.reason,
|
|
818
|
-
});
|
|
819
|
-
// The token binds the approval to these exact bytes and is consumed once,
|
|
820
|
-
// so a concurrent replay cannot deliver the same input twice.
|
|
821
|
-
const token = approved ? this.approvals.mint(binding) : undefined;
|
|
822
|
-
if (!this.approvals.consume(token, binding)) {
|
|
823
|
-
throwSessionError({
|
|
824
|
-
code: "INPUT_REJECTED",
|
|
825
|
-
operation: "send",
|
|
826
|
-
sessionId: runtime.record.id,
|
|
827
|
-
state: runtime.record.state,
|
|
828
|
-
message: `Confirmation is required for this input: ${decision.reason}.`,
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
// --- Helpers ----------------------------------------------------------
|
|
833
|
-
assertOwner(ownerId, operation) {
|
|
834
|
-
if (typeof ownerId === "string" && ownerId.length > 0)
|
|
835
|
-
return;
|
|
836
|
-
throwSessionError({
|
|
837
|
-
code: "INVALID_REQUEST",
|
|
838
|
-
operation,
|
|
839
|
-
message: "An owning conversation id is required for interactive session operations.",
|
|
840
|
-
details: { field: "ownerId" },
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
assertNotFenced(ownerId, operation) {
|
|
844
|
-
if (!this.registry.isFenced(ownerId))
|
|
845
|
-
return;
|
|
846
|
-
throwSessionError({
|
|
847
|
-
code: "SESSION_CLOSING",
|
|
848
|
-
operation,
|
|
849
|
-
message: "This conversation is being torn down and cannot own new sessions.",
|
|
850
|
-
});
|
|
851
|
-
}
|
|
852
|
-
assertEnabled(config, operation) {
|
|
853
|
-
if (config.enabled)
|
|
854
|
-
return;
|
|
855
|
-
throwSessionError({
|
|
856
|
-
code: "UNSUPPORTED_OPERATION",
|
|
857
|
-
operation,
|
|
858
|
-
message: "Interactive terminal sessions are disabled in this configuration.",
|
|
859
|
-
});
|
|
860
|
-
}
|
|
861
|
-
notFound(id, operation) {
|
|
862
|
-
throwSessionError({
|
|
863
|
-
code: "SESSION_NOT_FOUND",
|
|
864
|
-
operation,
|
|
865
|
-
sessionId: id,
|
|
866
|
-
message: "No interactive session with that id is owned by this conversation.",
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
requireRuntime(ownerId, id, operation) {
|
|
870
|
-
this.assertOwner(ownerId, operation);
|
|
871
|
-
const record = this.registry.get(ownerId, id);
|
|
872
|
-
if (!record)
|
|
873
|
-
this.notFound(id, operation);
|
|
874
|
-
const runtime = this.runtimes.get(id);
|
|
875
|
-
if (!runtime) {
|
|
876
|
-
throwSessionError({
|
|
877
|
-
code: "SESSION_NOT_RUNNING",
|
|
878
|
-
operation,
|
|
879
|
-
sessionId: id,
|
|
880
|
-
state: record.state,
|
|
881
|
-
message: "This session has already reached a terminal state.",
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
return runtime;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
/** Strict validation of the dependent input fields before acceptance. */
|
|
888
|
-
export function validateInput(input, operation) {
|
|
889
|
-
if (!input) {
|
|
890
|
-
throwSessionError({
|
|
891
|
-
code: "INVALID_REQUEST",
|
|
892
|
-
operation,
|
|
893
|
-
message: "An input action is required.",
|
|
894
|
-
details: { field: "kind" },
|
|
895
|
-
});
|
|
896
|
-
}
|
|
897
|
-
if (input.kind === "text") {
|
|
898
|
-
if (typeof input.text !== "string") {
|
|
899
|
-
throwSessionError({
|
|
900
|
-
code: "INVALID_REQUEST",
|
|
901
|
-
operation,
|
|
902
|
-
message: "Text input requires a string body.",
|
|
903
|
-
details: { field: "text" },
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
if (input.submit !== "enter" && input.submit !== "none") {
|
|
907
|
-
throwSessionError({
|
|
908
|
-
code: "INVALID_REQUEST",
|
|
909
|
-
operation,
|
|
910
|
-
message: "Text input requires submit to be enter or none.",
|
|
911
|
-
details: { field: "submit" },
|
|
912
|
-
});
|
|
913
|
-
}
|
|
914
|
-
return input;
|
|
915
|
-
}
|
|
916
|
-
if (input.kind === "control" || input.kind === "eof")
|
|
917
|
-
return input;
|
|
918
|
-
throwSessionError({
|
|
919
|
-
code: "INVALID_REQUEST",
|
|
920
|
-
operation,
|
|
921
|
-
message: "Input kind must be text, control, or eof.",
|
|
922
|
-
details: { field: "kind" },
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
/** Process-wide manager, mirroring the existing process-wide job manager. */
|
|
926
|
-
export const interactiveSessionManager = new InteractiveSessionManager();
|
|
927
|
-
//# sourceMappingURL=manager.js.map
|