@someonegg/codex-hub 0.1.2 → 0.1.3

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.
@@ -0,0 +1,82 @@
1
+ const WINDOW_MS = 5000;
2
+ const LEASE_MS = 60_000;
3
+ /** Output activity is independent of terminal captures and connection health. */
4
+ export class TerminalActivityService {
5
+ listen;
6
+ now;
7
+ #observations = new Map();
8
+ #timer;
9
+ #closed = false;
10
+ constructor(listen, now = () => performance.now()) {
11
+ this.listen = listen;
12
+ this.now = now;
13
+ this.#timer = setInterval(() => this.invalidate(() => true), 1000);
14
+ this.#timer.unref();
15
+ }
16
+ read(sessionId, binding) {
17
+ if (this.#closed)
18
+ return "unknown";
19
+ let observation = this.#observations.get(sessionId);
20
+ if (observation &&
21
+ (observation.failed ||
22
+ observation.expiresAt <= this.now() ||
23
+ observation.binding.socketPath !== binding.socketPath ||
24
+ observation.binding.paneId !== binding.paneId ||
25
+ observation.binding.sessionName !== binding.sessionName)) {
26
+ this.remove(sessionId);
27
+ observation = undefined;
28
+ }
29
+ if (!observation) {
30
+ observation = { binding, expiresAt: this.now() + LEASE_MS, failed: false };
31
+ this.#observations.set(sessionId, observation);
32
+ this.#subscribe(observation);
33
+ }
34
+ observation.expiresAt = this.now() + LEASE_MS;
35
+ if (observation.failed || observation.readyAt === undefined)
36
+ return "unknown";
37
+ if (observation.outputAt !== undefined && this.now() - observation.outputAt < WINDOW_MS)
38
+ return "working";
39
+ return this.now() - observation.readyAt >= WINDOW_MS ? "idle" : "unknown";
40
+ }
41
+ #subscribe(observation) {
42
+ const { binding } = observation;
43
+ const fail = () => {
44
+ observation.failed = true;
45
+ observation.readyAt = undefined;
46
+ observation.outputAt = undefined;
47
+ observation.subscription?.close();
48
+ };
49
+ try {
50
+ observation.subscription = this.listen(binding, (pane) => {
51
+ if (!observation.failed && pane === binding.paneId)
52
+ observation.outputAt = this.now();
53
+ }, fail);
54
+ if (observation.failed)
55
+ observation.subscription.close();
56
+ void observation.subscription.ready.then(() => {
57
+ if (!observation.failed)
58
+ observation.readyAt = this.now();
59
+ }, fail);
60
+ }
61
+ catch {
62
+ fail();
63
+ }
64
+ }
65
+ remove(sessionId) {
66
+ const observation = this.#observations.get(sessionId);
67
+ this.#observations.delete(sessionId);
68
+ observation?.subscription?.close();
69
+ }
70
+ invalidate(owns) {
71
+ for (const [id, observation] of this.#observations)
72
+ if (observation.expiresAt <= this.now() || !owns(id, observation.binding))
73
+ this.remove(id);
74
+ }
75
+ close() {
76
+ this.#closed = true;
77
+ clearInterval(this.#timer);
78
+ for (const id of this.#observations.keys())
79
+ this.remove(id);
80
+ }
81
+ }
82
+ //# sourceMappingURL=terminal-activity-service.js.map
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { lstat } from "node:fs/promises";
4
4
  import { createConnection } from "node:net";
5
+ import { setTimeout as delay } from "node:timers/promises";
5
6
  import { normalizeMessage } from "./message.js";
6
7
  import { InteractionTransportError, } from "./transport.js";
7
8
  export const MAX_TERMINAL_PREVIEW_BYTES = 256_000;
@@ -117,11 +118,15 @@ export class TmuxInteractionService {
117
118
  }
118
119
  });
119
120
  }
120
- sendKeys(binding, keys, assertAvailable = () => { }) {
121
+ sendKeys(binding, steps, assertAvailable = () => { }) {
121
122
  return this.#serialize(binding, async () => {
122
- await this.#revalidate(binding);
123
- assertAvailable();
124
- await this.#run(binding, ["send-keys", "-t", binding.paneId, ...keys]);
123
+ for (const step of steps) {
124
+ if (step.delayMs > 0)
125
+ await delay(step.delayMs);
126
+ await this.#revalidate(binding);
127
+ assertAvailable();
128
+ await this.#run(binding, ["send-keys", "-t", binding.paneId, ...step.keys]);
129
+ }
125
130
  });
126
131
  }
127
132
  captureTerminal(binding, assertAvailable = () => { }) {
@@ -1,10 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { paneBindings } from "../interaction/pane-binding-manager.js";
3
3
  import { attachCommand } from "../interaction/tmux-launcher.js";
4
- import { SessionExitService } from "./session-exit-service.js";
5
- import { resolveResumeDirectory, SessionLaunchError, } from "./session-manager.js";
6
- class AssociationError extends Error {
7
- }
4
+ import { AssociationError, } from "./operation-state.js";
5
+ import { SessionLaunchExecutor } from "./session-launch-executor.js";
6
+ import { SessionLifecycleExecutor } from "./session-lifecycle-executor.js";
7
+ import { SessionLaunchError } from "./session-manager.js";
8
8
  export class ManagementError extends Error {
9
9
  status;
10
10
  constructor(status, message) {
@@ -13,34 +13,38 @@ export class ManagementError extends Error {
13
13
  }
14
14
  }
15
15
  export class OperationService {
16
- sessions;
17
16
  interaction;
18
17
  managers;
19
18
  now;
20
19
  waitMs;
21
- bindings;
22
- runtime;
23
20
  #entries = new Map();
24
21
  #requests = new Map();
25
22
  #active = new Map();
26
23
  #results = new Map();
27
24
  #timer;
28
25
  #closed = false;
29
- #exit;
26
+ #launch;
27
+ #lifecycle;
30
28
  constructor(sessions, interaction, managers, now = Date.now, waitMs = {
31
29
  create: 300_000,
32
30
  exit: 30_000,
33
31
  archive: 30_000,
34
32
  delete: 30_000,
35
33
  }, bindings = paneBindings, runtime) {
36
- this.sessions = sessions;
37
34
  this.interaction = interaction;
38
35
  this.managers = managers;
39
36
  this.now = now;
40
37
  this.waitMs = waitMs;
41
- this.bindings = bindings;
42
- this.runtime = runtime;
43
- this.#exit = new SessionExitService(interaction, now);
38
+ const control = {
39
+ pending: (entry) => this.#pending(entry),
40
+ transition: (entry, context, deadline) => this.#transition(entry, context, deadline),
41
+ finish: (entry, status, error) => this.#finish(entry, status, error),
42
+ error: (entry, error) => this.#error(entry, error),
43
+ timeout: (entry) => this.#timeout(entry),
44
+ observe: (entry) => this.#observe(entry),
45
+ };
46
+ this.#launch = new SessionLaunchExecutor(sessions, interaction, managers, bindings, control, now, waitMs, runtime);
47
+ this.#lifecycle = new SessionLifecycleExecutor(sessions, interaction, managers, control, now, waitMs);
44
48
  this.#timer = setInterval(() => {
45
49
  void this.poll();
46
50
  }, 1_000);
@@ -70,34 +74,7 @@ export class OperationService {
70
74
  entry.metadata.mode = "new";
71
75
  entry.sourceInstanceId = sourceInstanceId;
72
76
  this.#requests.set(input.requestId, { fingerprint, operationId: entry.metadata.operationId });
73
- void (async () => {
74
- try {
75
- const manager = this.managers.get(entry.sourceInstanceId);
76
- const resolved = await manager.resolveDirectory(input.directory);
77
- if (!this.#pending(entry))
78
- return;
79
- this.#transition(entry, { kind: "create", phase: "launching" });
80
- const target = await manager.launch({
81
- operationId: entry.metadata.operationId,
82
- directory: resolved.directory,
83
- worktree: input.worktree,
84
- yolo: input.yolo ?? false,
85
- projectDirectory: resolved.directory,
86
- });
87
- if (!this.#pending(entry))
88
- return;
89
- this.#transition(entry, {
90
- kind: "create",
91
- phase: "observing",
92
- project: resolved.directory,
93
- target,
94
- startedAt: this.now(),
95
- }, this.now() + this.waitMs.create);
96
- }
97
- catch (error) {
98
- this.#error(entry, error);
99
- }
100
- })();
77
+ void this.#launch.create(entry, input);
101
78
  return this.#project(entry);
102
79
  }
103
80
  #resume(input) {
@@ -116,77 +93,7 @@ export class OperationService {
116
93
  this.#active.set(sessionId, entry);
117
94
  this.interaction.setBusy(sessionId, true);
118
95
  this.#requests.set(input.requestId, { fingerprint, operationId: entry.metadata.operationId });
119
- void (async () => {
120
- try {
121
- if (!this.runtime)
122
- throw new Error("Resume is unavailable");
123
- let snapshot = await this.sessions.managementSnapshot();
124
- let session = snapshot.sessions.find((item) => item.id === sessionId);
125
- if (!session)
126
- throw new Error("Session not found");
127
- let capability = await this.runtime.evaluate(session, snapshot.sessions);
128
- if (!capability.resume.allowed)
129
- throw new Error(capability.resume.reason);
130
- entry.sourceInstanceId = session.origin.sourceInstanceId;
131
- entry.resumeNativeId = session.sourceId ?? undefined;
132
- const manager = this.managers.get(entry.sourceInstanceId);
133
- const attempt = this.bindings.latest(session.id);
134
- const replacement = this.bindings.replacement(session.id);
135
- await resolveResumeDirectory(manager, session);
136
- // Refresh the source after asynchronous checks, then revalidate immediately before launch.
137
- snapshot = await this.sessions.managementSnapshot();
138
- const latest = snapshot.sessions.find((item) => item.id === sessionId);
139
- if (!latest ||
140
- latest.sourceId !== session.sourceId ||
141
- latest.origin.sourceInstanceId !== entry.sourceInstanceId ||
142
- latest.workingDirectory !== session.workingDirectory ||
143
- latest.projectDirectory !== session.projectDirectory ||
144
- attempt !== this.bindings.latest(session.id) ||
145
- replacement !== this.bindings.replacement(session.id))
146
- throw new Error("Session identity or directory changed during validation");
147
- session = latest;
148
- capability = await this.runtime.evaluate(session, snapshot.sessions);
149
- if (!capability.resume.allowed)
150
- throw new Error(capability.resume.reason);
151
- const resolved = await resolveResumeDirectory(manager, session);
152
- const finalSnapshot = await this.sessions.managementSnapshot();
153
- const finalSession = finalSnapshot.sessions.find((item) => item.id === sessionId);
154
- if (!finalSession ||
155
- finalSession.sourceId !== session.sourceId ||
156
- finalSession.origin.sourceInstanceId !== session.origin.sourceInstanceId ||
157
- finalSession.archived ||
158
- finalSession.workingDirectory !== session.workingDirectory ||
159
- finalSession.projectDirectory !== session.projectDirectory ||
160
- finalSnapshot.sessions.filter((item) => item.sourceId === session.sourceId &&
161
- item.origin.sourceInstanceId === session.origin.sourceInstanceId).length !== 1 ||
162
- attempt !== this.bindings.latest(session.id) ||
163
- replacement !== this.bindings.replacement(session.id))
164
- throw new Error("Session identity, binding or directory changed during validation");
165
- if (!this.#pending(entry))
166
- return;
167
- this.#transition(entry, { kind: "create", phase: "launching" });
168
- const target = await manager.launch({
169
- operationId: entry.metadata.operationId,
170
- directory: resolved.directory,
171
- worktree: false,
172
- yolo: input.yolo ?? false,
173
- resumeNativeId: session.sourceId,
174
- projectDirectory: session.projectDirectory,
175
- });
176
- if (!this.#pending(entry))
177
- return;
178
- this.#transition(entry, {
179
- kind: "create",
180
- phase: "observing",
181
- target,
182
- project: session.projectDirectory ?? resolved.directory,
183
- startedAt: this.now(),
184
- }, this.now() + this.waitMs.create);
185
- }
186
- catch (error) {
187
- this.#error(entry, error);
188
- }
189
- })();
96
+ void this.#launch.resume(entry, input);
190
97
  return this.#project(entry);
191
98
  }
192
99
  manage(type, sessionId) {
@@ -201,54 +108,7 @@ export class OperationService {
201
108
  this.interaction.setBusy(sessionId, true);
202
109
  this.#transition(entry, { kind: "manage", phase: "resolving" }, this.now() +
203
110
  (type === "exit" ? (this.waitMs.exit ?? this.waitMs.archive) : this.waitMs[type]));
204
- void (async () => {
205
- try {
206
- const snapshot = await this.sessions.managementSnapshot();
207
- if (!this.#pending(entry))
208
- return;
209
- const session = snapshot.sessions.find((item) => item.id === sessionId);
210
- if (!session) {
211
- if (type === "delete") {
212
- this.#finish(entry, "succeeded", null);
213
- return;
214
- }
215
- throw new Error("Session not found");
216
- }
217
- entry.sourceInstanceId = session.origin.sourceInstanceId;
218
- const manager = this.managers.get(entry.sourceInstanceId);
219
- if (type === "archive" && session.archived) {
220
- this.#finish(entry, "succeeded", null);
221
- return;
222
- }
223
- if (type === "exit") {
224
- await this.#startExit(entry, sessionId, null);
225
- return;
226
- }
227
- const nativeId = session.sourceId;
228
- if (!nativeId?.trim())
229
- throw new Error("Session has no valid native identity");
230
- if (this.#matches(snapshot, entry, nativeId).length !== 1)
231
- throw new Error("Native session identity is ambiguous");
232
- if (type === "delete") {
233
- if (!session.archived)
234
- throw new Error("Only archived sessions can be deleted");
235
- this.#transition(entry, { kind: "delete", phase: "deleting", nativeId });
236
- await manager.delete(nativeId);
237
- if (!this.#pending(entry))
238
- return;
239
- this.#transition(entry, { kind: "delete", phase: "confirming", nativeId });
240
- }
241
- else {
242
- this.#transition(entry, { kind: "archive", phase: "resolving_target", nativeId });
243
- await this.#startExit(entry, sessionId, nativeId);
244
- }
245
- if (this.#pending(entry))
246
- await this.#observe(entry);
247
- }
248
- catch (error) {
249
- this.#error(entry, error);
250
- }
251
- })();
111
+ void this.#lifecycle.manage(entry, type, sessionId);
252
112
  return this.#project(entry);
253
113
  }
254
114
  async get(id) {
@@ -273,8 +133,8 @@ export class OperationService {
273
133
  }
274
134
  if ((context.kind === "create" && context.phase === "observing") ||
275
135
  context.kind === "exiting" ||
276
- (context.kind === "archive" && context.phase === "confirming") ||
277
- (context.kind === "delete" && context.phase === "confirming")) {
136
+ ((context.kind === "archive" || context.kind === "delete") &&
137
+ context.phase === "confirming")) {
278
138
  checks.push(this.#observe(entry));
279
139
  }
280
140
  }
@@ -298,87 +158,10 @@ export class OperationService {
298
158
  if (!this.#pending(entry))
299
159
  return;
300
160
  try {
301
- let context = entry.stage;
302
- if (context.kind === "exiting") {
303
- try {
304
- // Refresh adapter bindings before observing or acting on the fixed target.
305
- await this.sessions.managementSnapshot();
306
- if (!this.#pending(entry))
307
- return;
308
- const phase = context.task.phase;
309
- const exited = await context.task.advance();
310
- if (!this.#pending(entry))
311
- return;
312
- if (phase !== context.task.phase)
313
- entry.metadata.updatedAt = new Date(this.now()).toISOString();
314
- if (!exited)
315
- return;
316
- if (context.nativeId === null) {
317
- this.#finish(entry, "succeeded", null);
318
- return;
319
- }
320
- await this.#archive(entry, context.nativeId);
321
- if (!this.#pending(entry))
322
- return;
323
- }
324
- catch (error) {
325
- if (this.#pending(entry))
326
- this.#finish(entry, "unknown", error instanceof Error ? error.message : String(error));
327
- return;
328
- }
329
- }
330
- const snapshot = await this.sessions.managementSnapshot();
331
- if (!this.#pending(entry))
332
- return;
333
- context = entry.stage;
334
- if (context.kind === "create" && context.phase === "observing") {
335
- const matches = snapshot.sessions.filter((session) => {
336
- const attempt = this.bindings.latest(session.id);
337
- return (session.origin.sourceInstanceId === entry.sourceInstanceId &&
338
- attempt?.valid &&
339
- attempt.operationId === entry.metadata.operationId);
340
- });
341
- if (matches.length > 1)
342
- throw new AssociationError("Multiple sessions claimed this operation ID");
343
- const match = matches[0];
344
- if (match) {
345
- if (entry.metadata.mode === "resume" &&
346
- (match.id !== entry.metadata.sessionId ||
347
- match.archived ||
348
- match.sourceId !== entry.resumeNativeId ||
349
- !match.sourceId ||
350
- this.#matches(snapshot, entry, match.sourceId).length !== 1))
351
- throw new AssociationError("Resume did not bind the original unique session");
352
- const attempt = this.bindings.latest(match.id);
353
- if (!attempt?.valid ||
354
- attempt.socketPath !== context.target.socketPath ||
355
- attempt.paneId !== context.target.paneId ||
356
- match.projectDirectory !== context.project) {
357
- throw new AssociationError("Binding does not match the launched target/project");
358
- }
359
- const binding = await this.interaction.describe(match.id);
360
- if (!this.#pending(entry))
361
- return;
362
- if (binding?.supported && binding.state === "unknown")
363
- return;
364
- if (!binding?.supported || binding.state !== "connected")
365
- throw new AssociationError("The new session binding failed validation");
366
- const matchesTarget = await this.interaction.matchesTarget(match.id, context.target);
367
- if (!this.#pending(entry))
368
- return;
369
- if (!matchesTarget)
370
- throw new AssociationError("Binding does not match the launched target");
371
- entry.metadata.sessionId = match.id;
372
- this.#finish(entry, "succeeded", null);
373
- }
374
- }
375
- else if ((context.kind === "archive" || context.kind === "delete") &&
376
- context.phase === "confirming") {
377
- const matches = this.#matches(snapshot, entry, context.nativeId);
378
- if ((context.kind === "archive" && matches.length === 1 && matches[0]?.archived) ||
379
- (context.kind === "delete" && matches.length === 0))
380
- this.#finish(entry, "succeeded", null);
381
- }
161
+ if (entry.stage.kind === "create")
162
+ await this.#launch.observe(entry);
163
+ else
164
+ await this.#lifecycle.observe(entry);
382
165
  }
383
166
  catch (error) {
384
167
  // Read failures never prove disappearance or justify another command.
@@ -387,49 +170,14 @@ export class OperationService {
387
170
  }
388
171
  this.#timeout(entry);
389
172
  }
390
- async #startExit(entry, sessionId, nativeId) {
391
- const target = await this.interaction.resolveTarget(sessionId);
392
- if (!this.#pending(entry))
393
- return;
394
- if (!target) {
395
- if (nativeId === null)
396
- this.#finish(entry, "succeeded", null);
397
- else
398
- await this.#archive(entry, nativeId);
399
- return;
400
- }
401
- const task = this.#exit.start(sessionId, target, this.managers.get(entry.sourceInstanceId), () => {
402
- this.#timeout(entry);
403
- if (!this.#pending(entry))
404
- throw new Error("Exit operation is no longer active");
405
- });
406
- this.#transition(entry, { kind: "exiting", task, nativeId }, this.now() + (this.waitMs.exit ?? this.waitMs.archive));
407
- await this.#observe(entry);
408
- }
409
- async #archive(entry, nativeId) {
410
- if (!this.#pending(entry))
411
- return;
412
- this.#transition(entry, { kind: "archive", phase: "archiving", nativeId }, this.now() + 25_000);
413
- try {
414
- await this.managers.get(entry.sourceInstanceId).archive(nativeId);
415
- if (!this.#pending(entry))
416
- return;
417
- this.#transition(entry, { kind: "archive", phase: "confirming", nativeId }, this.now() + this.waitMs.archive);
418
- }
419
- catch (error) {
420
- if (!this.#pending(entry))
421
- return;
422
- this.#error(entry, error);
423
- }
424
- }
425
173
  #transition(entry, context, deadline = entry.stage?.deadline ?? null) {
426
174
  if (!this.#pending(entry))
427
175
  return;
428
- const previous = this.#project(entry).phase;
176
+ const previous = operationPhase(entry.stage);
429
177
  entry.stage = { ...context, deadline };
430
178
  if (context.kind === "create" && context.phase === "observing")
431
179
  entry.metadata.target = context.target;
432
- if (previous !== this.#project(entry).phase)
180
+ if (previous !== operationPhase(entry.stage))
433
181
  entry.metadata.updatedAt = new Date(this.now()).toISOString();
434
182
  }
435
183
  #lookup(id) {
@@ -442,16 +190,7 @@ export class OperationService {
442
190
  const context = entry.stage;
443
191
  if (!context)
444
192
  throw new Error("Pending operation has no execution stage");
445
- const phase = context.kind === "exiting"
446
- ? context.task.phase
447
- : context.kind === "archive"
448
- ? context.phase === "confirming"
449
- ? "confirming_archive"
450
- : context.phase === "resolving_target"
451
- ? null
452
- : context.phase
453
- : null;
454
- return { ...entry.metadata, phase, status: "pending", error: null };
193
+ return { ...entry.metadata, phase: operationPhase(context), status: "pending", error: null };
455
194
  }
456
195
  #attach(entry, command) {
457
196
  if (!this.#pending(entry) || entry.metadata.attachCommand === command)
@@ -459,9 +198,6 @@ export class OperationService {
459
198
  entry.metadata.attachCommand = command;
460
199
  entry.metadata.updatedAt = new Date(this.now()).toISOString();
461
200
  }
462
- #matches(snapshot, entry, nativeId) {
463
- return snapshot.sessions.filter((session) => session.origin.sourceInstanceId === entry.sourceInstanceId && session.sourceId === nativeId);
464
- }
465
201
  #pending(entry) {
466
202
  return !this.#closed && !entry.result && entry.stage !== null;
467
203
  }
@@ -550,4 +286,18 @@ export class OperationService {
550
286
  }
551
287
  }
552
288
  }
289
+ function operationPhase(context) {
290
+ if (context.kind === "exiting")
291
+ return context.task.phase;
292
+ if (context.kind !== "archive")
293
+ return null;
294
+ switch (context.phase) {
295
+ case "confirming":
296
+ return "confirming_archive";
297
+ case "resolving_target":
298
+ return null;
299
+ case "archiving":
300
+ return context.phase;
301
+ }
302
+ }
553
303
  //# sourceMappingURL=operation-service.js.map
@@ -0,0 +1,3 @@
1
+ export class AssociationError extends Error {
2
+ }
3
+ //# sourceMappingURL=operation-state.js.map