@myagentroam/node 0.12.4 → 0.12.6

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.
@@ -389,16 +389,18 @@ export class CodexRunner extends AbstractRunner {
389
389
  return { failureCode: 'RUNNER_RESUME_FAILED' };
390
390
  }
391
391
  }
392
- async cleanupThreads() {
392
+ async cleanupThreads(signal) {
393
393
  await this.start();
394
394
  const threads = [];
395
395
  const cursors = new Set();
396
396
  let cursor;
397
397
  for (;;) {
398
+ signal?.throwIfAborted();
398
399
  const result = await this.client.listThreads({
399
400
  limit: 200,
400
401
  ...(cursor === undefined ? {} : { cursor })
401
402
  });
403
+ signal?.throwIfAborted();
402
404
  if (!isPlainRecord(result) ||
403
405
  !Array.isArray(result.data) ||
404
406
  result.data.some((entry) => !isPlainRecord(entry) ||
@@ -418,21 +420,25 @@ export class CodexRunner extends AbstractRunner {
418
420
  }
419
421
  }
420
422
  async discoverSessions(context, workspace) {
423
+ context.signal?.throwIfAborted();
421
424
  const [threads, nativeSessions] = context.cleanup
422
- ? [await this.cleanupThreads(), []]
425
+ ? [await this.cleanupThreads(context.signal), []]
423
426
  : await Promise.all([
424
427
  (async () => {
425
428
  try {
426
429
  await this.start();
427
- return extractCodexThreads(await this.listThreads());
430
+ context.signal?.throwIfAborted();
431
+ return extractCodexThreads(await this.listThreads(context.signal));
428
432
  }
429
433
  catch {
434
+ context.signal?.throwIfAborted();
430
435
  // Native transcripts remain a valid read-only discovery source.
431
436
  return [];
432
437
  }
433
438
  })(),
434
439
  discoverAllNativeSessions(this.name)
435
440
  ]);
441
+ context.signal?.throwIfAborted();
436
442
  const sessions = [];
437
443
  for (const thread of threads) {
438
444
  if (thread.derived || (thread.source !== 'cli' && thread.source !== 'vscode'))
@@ -642,8 +648,9 @@ export class CodexRunner extends AbstractRunner {
642
648
  respond(requestId, result, runId) {
643
649
  this.clientFor(runId).respond(requestId, result);
644
650
  }
645
- async listThreads() {
651
+ async listThreads(signal) {
646
652
  const result = await this.client.listThreads();
653
+ signal?.throwIfAborted();
647
654
  this.observeThreadHistoryModes(result);
648
655
  return result;
649
656
  }
@@ -129,7 +129,8 @@ const collaborationPrompt = [
129
129
  "Use native agent_* tools to collaborate within the Agent Network when another Session's context or ongoing work materially helps. Prefer focused requests and avoid unnecessary cross-Session coordination. Peer Sessions retain their own work and lifecycle; do not treat them as task-owned children or stop them merely for task cleanup.",
130
130
  'Prefer reusing known Session IDs rather than rediscovering them. Updates are most useful when requested or when the recipient needs to decide or change course; prefer combining related updates.',
131
131
  'An accepted message normally needs no separate acknowledgment. Requests for receipt-only replies and immediate agent_view calls solely to confirm receipt are usually unnecessary.',
132
- 'For delegated work, prefer a concise final handoff over routine progress reports or duplicate completion messages. Early contact is most useful for requested updates, blockers, scope conflicts, or information needed for a decision.'
132
+ 'The parent automatically collects the latest unread public reply and terminal state from each observed direct child before its next natural model request. Usually reply in the child Session itself rather than sending duplicate progress or completion reports with agent_send.',
133
+ 'Contact the parent proactively when timely action or a decision is needed, such as a blocker, scope conflict or major risk, or when explicitly asked to report. Routine child updates usually need no reply; prefer combining related guidance.'
133
134
  ].join('\n\n');
134
135
  const managementPrompt = [
135
136
  '# Workspace management',
@@ -4,11 +4,15 @@ import { createServer } from 'node:net';
4
4
  import { minimalRunnerEnvironment, platformCliInvocation } from '../../operational.js';
5
5
  import { terminateProcessTree } from '../../process-tree.js';
6
6
  const START_TIMEOUT_MS = 10_000;
7
+ /** Owns one supervised backend and its startup I/O. Request cancellation is caller-scoped;
8
+ * Stopping cancels startup and joins it along with child termination.
9
+ */
7
10
  export class OpenCodeServerClient {
8
11
  options;
9
12
  child;
10
13
  client;
11
14
  starting;
15
+ startingController;
12
16
  environment = {};
13
17
  constructor(options = {}) {
14
18
  this.options = options;
@@ -23,19 +27,25 @@ export class OpenCodeServerClient {
23
27
  return;
24
28
  if (this.starting !== undefined)
25
29
  return this.starting;
26
- const starting = this.startServer();
30
+ const controller = new AbortController();
31
+ const starting = this.startServer(controller.signal);
32
+ this.startingController = controller;
27
33
  this.starting = starting;
28
34
  try {
29
35
  await starting;
30
36
  }
31
37
  finally {
32
- if (this.starting === starting)
38
+ if (this.starting === starting) {
33
39
  this.starting = undefined;
40
+ this.startingController = undefined;
41
+ }
34
42
  }
35
43
  }
36
- async startServer() {
44
+ async startServer(signal) {
37
45
  const { createOpencodeClient } = await import('@opencode-ai/sdk/v2/client');
46
+ signal.throwIfAborted();
38
47
  const port = await availablePort();
48
+ signal.throwIfAborted();
39
49
  const password = randomBytes(24).toString('base64url');
40
50
  const invocation = platformCliInvocation(this.options.command ?? 'opencode', [
41
51
  'serve',
@@ -65,11 +75,12 @@ export class OpenCodeServerClient {
65
75
  throwOnError: true
66
76
  });
67
77
  try {
68
- await waitUntilHealthy(`http://127.0.0.1:${port}/global/health`, authorization, child, this.options.startTimeoutMs ?? START_TIMEOUT_MS);
78
+ await waitUntilHealthy(`http://127.0.0.1:${port}/global/health`, authorization, child, this.options.startTimeoutMs ?? START_TIMEOUT_MS, signal);
79
+ signal.throwIfAborted();
69
80
  this.client = client;
70
81
  }
71
82
  catch (error) {
72
- await this.stop();
83
+ await this.stopChild(child);
73
84
  throw error;
74
85
  }
75
86
  }
@@ -89,9 +100,11 @@ export class OpenCodeServerClient {
89
100
  });
90
101
  }
91
102
  }
92
- async sessions(directory, limit = 100) {
103
+ async sessions(directory, limit = 100, signal) {
104
+ signal?.throwIfAborted();
93
105
  const client = await this.requireClient();
94
- return unwrap(await client.session.list({ directory, limit }));
106
+ signal?.throwIfAborted();
107
+ return unwrap(await client.session.list({ directory, limit }, { signal: signal ?? null }));
95
108
  }
96
109
  async session(directory, sessionID) {
97
110
  const client = await this.requireClient();
@@ -238,11 +251,17 @@ export class OpenCodeServerClient {
238
251
  }
239
252
  }
240
253
  async stop() {
254
+ this.startingController?.abort(new Error('OPENCODE_SERVER_STOPPED'));
255
+ const starting = this.starting;
241
256
  this.client = undefined;
242
- const child = this.child;
243
- this.child = undefined;
244
- if (child === undefined)
257
+ await this.stopChild(this.child);
258
+ if (starting !== undefined)
259
+ await Promise.allSettled([starting]);
260
+ }
261
+ async stopChild(child) {
262
+ if (child === undefined || this.child !== child)
245
263
  return;
264
+ this.child = undefined;
246
265
  if (child.pid === undefined) {
247
266
  child.kill('SIGTERM');
248
267
  return;
@@ -280,24 +299,30 @@ async function availablePort() {
280
299
  });
281
300
  });
282
301
  }
283
- async function waitUntilHealthy(url, authorization, child, timeoutMs) {
302
+ async function waitUntilHealthy(url, authorization, child, timeoutMs, signal) {
284
303
  const deadline = Date.now() + timeoutMs;
285
304
  while (Date.now() < deadline) {
305
+ signal.throwIfAborted();
286
306
  if (child.exitCode !== null)
287
307
  throw new Error('OPENCODE_SERVER_EXITED');
288
308
  try {
289
309
  const response = await fetch(url, {
290
310
  headers: { authorization },
291
- signal: AbortSignal.timeout(Math.min(1_000, Math.max(1, deadline - Date.now())))
311
+ signal: AbortSignal.any([
312
+ signal,
313
+ AbortSignal.timeout(Math.min(1_000, Math.max(1, deadline - Date.now())))
314
+ ])
292
315
  });
293
316
  if (response.ok)
294
317
  return;
295
318
  }
296
319
  catch {
320
+ signal.throwIfAborted();
297
321
  // The child may still be binding its loopback socket.
298
322
  }
299
323
  await new Promise((resolve) => setTimeout(resolve, 50));
300
324
  }
325
+ signal.throwIfAborted();
301
326
  throw new Error('OPENCODE_SERVER_START_TIMEOUT');
302
327
  }
303
328
  function unwrap(result) {
@@ -196,15 +196,18 @@ export class OpenCodeRunner extends AbstractRunner {
196
196
  async discoverSessions(context, workspace) {
197
197
  const result = [];
198
198
  for (const target of workspace === undefined ? context.workspaces : [workspace]) {
199
+ context.signal?.throwIfAborted();
199
200
  let sessions;
200
201
  try {
201
- sessions = await this.client.sessions(target.path);
202
+ sessions = await this.client.sessions(target.path, 100, context.signal);
202
203
  }
203
204
  catch (error) {
205
+ context.signal?.throwIfAborted();
204
206
  if (context.cleanup)
205
207
  throw error;
206
208
  continue;
207
209
  }
210
+ context.signal?.throwIfAborted();
208
211
  for (const value of sessions) {
209
212
  const session = openCodeSession(value);
210
213
  if (session === undefined ||
@@ -213,7 +216,8 @@ export class OpenCodeRunner extends AbstractRunner {
213
216
  continue;
214
217
  const title = context.cleanup
215
218
  ? { title: session.title, titleOrigin: 'OFFICIAL' }
216
- : await this.sessionTitle(session);
219
+ : await this.sessionTitle(session, context.signal);
220
+ context.signal?.throwIfAborted();
217
221
  result.push(context.createProjection({
218
222
  workspace: target,
219
223
  runner: this.name,
@@ -237,16 +241,18 @@ export class OpenCodeRunner extends AbstractRunner {
237
241
  return undefined;
238
242
  return (await this.sessionTitle(native)).title;
239
243
  }
240
- async sessionTitle(session) {
244
+ async sessionTitle(session, signal) {
245
+ signal?.throwIfAborted();
241
246
  const nativeTitle = session.title.trim();
242
247
  if (nativeTitle.length === 0 ||
243
248
  /^(?:new|untitled) session(?:\s*[-–—:]\s*\d{4}-\d{2}-\d{2}.*)?$/iu.test(nativeTitle)) {
244
249
  try {
245
- const preview = await this.firstUserMessagePreview(session);
250
+ const preview = await this.firstUserMessagePreview(session, signal);
246
251
  if (preview)
247
252
  return { title: preview, titleOrigin: 'PREVIEW' };
248
253
  }
249
254
  catch (error) {
255
+ signal?.throwIfAborted();
250
256
  nodeLog('runner.opencode.title.failed', {
251
257
  sessionId: session.id,
252
258
  stage: 'preview',
@@ -256,10 +262,12 @@ export class OpenCodeRunner extends AbstractRunner {
256
262
  }
257
263
  return { title: session.title, titleOrigin: 'OFFICIAL' };
258
264
  }
259
- async firstUserMessagePreview(session) {
265
+ async firstUserMessagePreview(session, signal) {
260
266
  const key = `${session.directory}\0${session.id}`;
261
267
  const cached = this.titlePreviews.get(key);
268
+ // Only in-flight reads share a cancellation owner; completed values retain their cache lifetime.
262
269
  if (cached !== undefined &&
270
+ (cached.fulfilled || cached.signal === signal) &&
263
271
  cached.updatedAt === session.updatedAt &&
264
272
  cached.expiresAt > Date.now())
265
273
  return cached.result;
@@ -267,6 +275,8 @@ export class OpenCodeRunner extends AbstractRunner {
267
275
  if (this.titlePreviews.size >= MAX_TITLE_PREVIEWS)
268
276
  this.titlePreviews.delete(this.titlePreviews.keys().next().value);
269
277
  const entry = {
278
+ signal,
279
+ fulfilled: false,
270
280
  updatedAt: session.updatedAt,
271
281
  expiresAt: Date.now() + TITLE_PREVIEW_TTL_MS,
272
282
  result: (async () => {
@@ -274,10 +284,12 @@ export class OpenCodeRunner extends AbstractRunner {
274
284
  let preview;
275
285
  const cursors = new Set();
276
286
  for (;;) {
287
+ signal?.throwIfAborted();
277
288
  const page = await this.client.messagePage(session.directory, session.id, {
278
289
  limit: 100,
279
290
  ...(before === undefined ? {} : { before })
280
- });
291
+ }, signal);
292
+ signal?.throwIfAborted();
281
293
  preview = openCodeUserMessagePreview(page.messages) ?? preview;
282
294
  if (page.nextCursor === null)
283
295
  return preview;
@@ -290,7 +302,9 @@ export class OpenCodeRunner extends AbstractRunner {
290
302
  };
291
303
  this.titlePreviews.set(key, entry);
292
304
  try {
293
- return await entry.result;
305
+ const preview = await entry.result;
306
+ entry.fulfilled = true;
307
+ return preview;
294
308
  }
295
309
  catch (error) {
296
310
  if (this.titlePreviews.get(key) === entry)
@@ -0,0 +1,60 @@
1
+ import { agentObservationFromRunEvent } from './agent-network-observations.js';
2
+ import { sessionPendingApprovals } from './session-recent-activity.js';
3
+ /** Projects normalized Run events and owns transient observers from attachment through terminal cleanup.
4
+ * Run-keyed handles survive Session identity promotion without borrowing a replacement's lifetime.
5
+ * Runner-owned context observers remain with the Runner; this owner only borrows their delivery port.
6
+ */
7
+ export class AgentNetworkRunProjection {
8
+ runtime;
9
+ observations;
10
+ transient = new Map();
11
+ constructor(runtime, observations) {
12
+ this.runtime = runtime;
13
+ this.observations = observations;
14
+ }
15
+ ensureObservations = (actor) => {
16
+ const existing = this.observations.get(actor.observationId ?? actor.sessionId);
17
+ if (existing)
18
+ return existing;
19
+ const run = actor.runId === undefined ? undefined : this.runtime.getRun(actor.runId);
20
+ if (!run ||
21
+ run.sessionId !== actor.sessionId ||
22
+ this.runtime.activeSessionRun(actor.sessionId)?.id !== run.id)
23
+ throw new Error('AGENT_ACTOR_NOT_ACTIVE');
24
+ const observer = this.observations.attach(actor.sessionId);
25
+ this.transient.set(run.id, observer);
26
+ return observer;
27
+ };
28
+ accept = ({ run, executionId, eventType, payload, terminalStatus }) => {
29
+ const child = this.runtime.getAgentSession(run.sessionId);
30
+ if (!child)
31
+ return;
32
+ const parent = child.parentSessionId == null
33
+ ? undefined
34
+ : this.runtime.getAgentSession(child.parentSessionId);
35
+ let observation = agentObservationFromRunEvent({ eventType, payload, terminalStatus });
36
+ if (eventType === 'approval.request') {
37
+ const approvals = sessionPendingApprovals(this.runtime, child.id).pendingApprovals;
38
+ if (approvals.length)
39
+ observation = { kind: 'message', value: JSON.stringify({ pendingApprovals: approvals }) };
40
+ }
41
+ if (observation)
42
+ this.observations.publishChild({
43
+ ...(parent?.workspaceId === child.workspaceId ? { parentId: parent.id } : {}),
44
+ childId: child.id,
45
+ runId: run.id,
46
+ executionId,
47
+ ...observation
48
+ });
49
+ if (terminalStatus !== undefined) {
50
+ const observer = this.transient.get(run.id);
51
+ this.transient.delete(run.id);
52
+ observer?.close();
53
+ }
54
+ };
55
+ dispose() {
56
+ for (const observer of this.transient.values())
57
+ observer.close();
58
+ this.transient.clear();
59
+ }
60
+ }
@@ -67,7 +67,7 @@ export async function validateComposerSkills(input, workspacePath, home = homedi
67
67
  const directories = new SkillDirectoryService();
68
68
  const [node, workspace] = await Promise.all([
69
69
  directories.inspectNodeHome(home),
70
- directories.inspectWorkspace(workspacePath)
70
+ directories.inspectWorkspace(workspacePath, true, { includeClaudeCompatibility: false })
71
71
  ]);
72
72
  const effective = new Map(node.skills.map((skill) => [skill.name, { ...skill, source: 'NODE_HOME' }]));
73
73
  for (const skill of workspace.skills)
@@ -76,7 +76,7 @@ function activity(runtime, sessionId) {
76
76
  }
77
77
  export function marctlSessionProgress(runtime, sessionId) {
78
78
  const { latestActivity: _latest, ...progress } = activity(runtime, sessionId);
79
- return { ...progress, ...pendingApprovals(runtime, sessionId) };
79
+ return { ...progress, ...sessionPendingApprovals(runtime, sessionId) };
80
80
  }
81
81
  export function sessionActivityStatus(runtime, sessionId) {
82
82
  const run = runtime.activeSessionRun(sessionId);
@@ -100,7 +100,7 @@ function isSleepingTool(item) {
100
100
  typeof presentation.startedAt === 'string');
101
101
  }
102
102
  /** Summarizes current approval facts without exposing tool input or native payloads. */
103
- function pendingApprovals(runtime, sessionId) {
103
+ export function sessionPendingApprovals(runtime, sessionId) {
104
104
  const run = runtime.activeSessionRun(sessionId);
105
105
  const executionId = run && runtime.currentExecutionId(run.id);
106
106
  const approvals = run && !isTerminalRunStatus(run.status)
@@ -150,7 +150,7 @@ export function marctlSessionDetail(runtime, session, queuedCount) {
150
150
  queuedCount,
151
151
  latestMessage,
152
152
  recentTools,
153
- ...pendingApprovals(runtime, session.id)
153
+ ...sessionPendingApprovals(runtime, session.id)
154
154
  };
155
155
  }
156
156
  /** Agent state views omit execution identities; low-level CLI queries keep their own contract. */
@@ -5,25 +5,27 @@ import { hasBrokenWorkspaceSkillGitExclude } from '../workspace/workspace-git-ex
5
5
  const MAX_METADATA_BYTES = 64 * 1024;
6
6
  const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
7
7
  const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u;
8
+ /** Owns filesystem Skill inspection; effective lists exclude installation compatibility copies. */
8
9
  export class SkillDirectoryService {
9
- async inspectNodeHome(home, options = {}) {
10
+ async inspectNodeHome(home) {
10
11
  const agentsRoot = path.join(home, '.agents', 'skills');
11
12
  const claudeRoot = path.join(home, '.claude', 'skills');
12
13
  const compatibilityMode = await nodeCompatibilityMode(agentsRoot, claudeRoot);
13
14
  const agents = await scanRoot(agentsRoot, 'AGENTS');
14
- const claude = options.includeClaudeCompatibility === true && compatibilityMode !== 'UNAVAILABLE'
15
- ? await scanRoot(claudeRoot, 'CLAUDE')
16
- : [];
17
15
  return {
18
16
  targetKind: 'NODE',
19
17
  compatibilityMode,
20
- skills: options.includeClaudeCompatibility === true
21
- ? mergeNodeCompatibilityRoots(agents, claude)
22
- : agents.map(withoutSource)
18
+ skills: agents.map(withoutSource)
23
19
  };
24
20
  }
25
- async inspectWorkspace(workspace, includeParentRepository = true) {
21
+ async inspectWorkspace(workspace, includeParentRepository = true, options = {}) {
26
22
  const agents = await scanRoot(path.join(workspace, '.agents', 'skills'), 'AGENTS');
23
+ if (options.includeClaudeCompatibility === false)
24
+ return {
25
+ targetKind: 'WORKSPACE',
26
+ compatibilityMode: 'MANAGED_COPY',
27
+ skills: agents.map(withoutSource)
28
+ };
27
29
  const claude = await scanRoot(path.join(workspace, '.claude', 'skills'), 'CLAUDE');
28
30
  const skills = mergeWorkspaceCopies(agents, claude);
29
31
  const gitExcludeBroken = await hasBrokenWorkspaceSkillGitExclude(workspace, skills.map((skill) => skill.name), includeParentRepository);
@@ -181,29 +183,6 @@ function mergeWorkspaceCopies(agents, claude) {
181
183
  return withoutSource(copies.agents);
182
184
  });
183
185
  }
184
- function mergeNodeCompatibilityRoots(agents, claude) {
185
- const byName = new Map();
186
- for (const item of agents)
187
- byName.set(item.name, { agents: item });
188
- for (const item of claude)
189
- byName.set(item.name, { ...byName.get(item.name), claude: item });
190
- return [...byName.entries()]
191
- .sort(([left], [right]) => left.localeCompare(right))
192
- .map(([name, copies]) => {
193
- if (copies.agents === undefined || copies.claude === undefined)
194
- return withoutSource((copies.agents ?? copies.claude));
195
- if (copies.agents.description !== copies.claude.description ||
196
- copies.agents.localStatus !== copies.claude.localStatus ||
197
- JSON.stringify(copies.agents.install) !== JSON.stringify(copies.claude.install))
198
- return {
199
- name,
200
- description: copies.agents.description,
201
- localStatus: 'PARTIAL',
202
- skillFilePath: copies.agents.skillFilePath
203
- };
204
- return withoutSource(copies.agents);
205
- });
206
- }
207
186
  function withoutSource(entry) {
208
187
  return {
209
188
  name: entry.name,
@@ -17,18 +17,16 @@ export class SkillNodeOperationService {
17
17
  }
18
18
  operations() {
19
19
  return {
20
- 'node.skills.inspect': async (data) => {
20
+ 'node.skills.inspect': async (_data) => {
21
21
  await this.installer.recoverNodeHome(homedir());
22
- return this.directories.inspectNodeHome(homedir(), {
23
- includeClaudeCompatibility: record(data)['includeClaudeCompatibility'] === true
24
- });
22
+ return this.directories.inspectNodeHome(homedir());
25
23
  },
26
24
  'node.skills.install': (data) => this.install('NODE', data),
27
25
  'node.skills.remove': (data) => this.remove('NODE', data),
28
26
  'workspace.skills.inspect': async (data) => {
29
27
  const workspace = this.workspacePath(record(data)['workspaceId']);
30
28
  await this.installer.recoverWorkspace(workspace);
31
- return this.directories.inspectWorkspace(workspace, this.database().getWorkspaceByPath(workspace)?.includeParentRepository ?? true);
29
+ return this.directories.inspectWorkspace(workspace, this.database().getWorkspaceByPath(workspace)?.includeParentRepository ?? true, { includeClaudeCompatibility: record(data)['includeClaudeCompatibility'] !== false });
32
30
  },
33
31
  'workspace.skills.install': (data) => this.install('WORKSPACE', data),
34
32
  'workspace.skills.remove': (data) => this.remove('WORKSPACE', data),
@@ -1,4 +1,8 @@
1
1
  import { deduplicateDirectSessions } from './runner-native-session-parsers.js';
2
+ /** Owns native identity/metadata projection, not Runner execution.
3
+ * Discovery borrows its caller's lifetime; cancellation revokes database projection before
4
+ * releasing the wait on a third-party read that may not itself support interruption.
5
+ */
2
6
  export class NativeSessionProjectionService {
3
7
  options;
4
8
  directNative = new Map();
@@ -8,7 +12,8 @@ export class NativeSessionProjectionService {
8
12
  constructor(options) {
9
13
  this.options = options;
10
14
  }
11
- async discover(runnerName, workspaceId, cleanup = false) {
15
+ async discover(runnerName, workspaceId, cleanup = false, signal) {
16
+ signal?.throwIfAborted();
12
17
  const database = this.requireDatabase();
13
18
  const workspace = workspaceId === undefined ? undefined : database.getWorkspace(workspaceId);
14
19
  if (workspaceId !== undefined && workspace === undefined)
@@ -16,11 +21,16 @@ export class NativeSessionProjectionService {
16
21
  const runner = this.options.runners.product(runnerName);
17
22
  if (runner === undefined || !runner.available(this.options.capabilities()))
18
23
  throw new Error('RUNNER_UNAVAILABLE');
19
- const sessions = await runner.discoverSessions({
24
+ const sessions = await awaitDiscovery(runner.discoverSessions({
25
+ ...(signal === undefined ? {} : { signal }),
20
26
  ...(cleanup ? { cleanup: true } : {}),
21
27
  workspaces: database.listWorkspaces(),
22
- createProjection: (input) => this.project(input)
23
- }, workspace);
28
+ createProjection: (input) => {
29
+ signal?.throwIfAborted();
30
+ return this.project(input);
31
+ }
32
+ }, workspace), signal);
33
+ signal?.throwIfAborted();
24
34
  const ordered = deduplicateDirectSessions(sessions);
25
35
  if (!cleanup) {
26
36
  this.remember(ordered);
@@ -57,16 +67,19 @@ export class NativeSessionProjectionService {
57
67
  this.remember(sessions);
58
68
  return sessions.map(this.options.present);
59
69
  }
60
- async discoverWorkspace(workspaceId, onRunnerDiscovered, cleanup = false) {
70
+ async discoverWorkspace(workspaceId, onRunnerDiscovered, cleanup = false, signal) {
71
+ signal?.throwIfAborted();
61
72
  const discovered = await Promise.allSettled(this.options.runners
62
73
  .advertised()
63
74
  .filter((runner) => runner.available(this.options.capabilities()))
64
75
  .map(async (runner) => {
65
76
  const name = runner.name;
66
- const sessions = await this.discover(name, workspaceId, cleanup);
77
+ const sessions = await this.discover(name, workspaceId, cleanup, signal);
78
+ signal?.throwIfAborted();
67
79
  onRunnerDiscovered?.(name, sessions);
68
80
  return sessions;
69
81
  }));
82
+ signal?.throwIfAborted();
70
83
  const failed = discovered.find((result) => result.status === 'rejected');
71
84
  if (failed?.status === 'rejected')
72
85
  throw failed.reason;
@@ -435,6 +448,28 @@ export class NativeSessionProjectionService {
435
448
  return database;
436
449
  }
437
450
  }
451
+ /** The callback capability is revoked by discover(); late SDK outcomes are still consumed. */
452
+ function awaitDiscovery(read, signal) {
453
+ if (signal === undefined)
454
+ return read;
455
+ return new Promise((resolve, reject) => {
456
+ const abort = () => {
457
+ signal.removeEventListener('abort', abort);
458
+ reject(signal.reason);
459
+ };
460
+ read.then((sessions) => {
461
+ signal.removeEventListener('abort', abort);
462
+ resolve(sessions);
463
+ }, (error) => {
464
+ signal.removeEventListener('abort', abort);
465
+ reject(error);
466
+ });
467
+ if (signal.aborted)
468
+ abort();
469
+ else
470
+ signal.addEventListener('abort', abort, { once: true });
471
+ });
472
+ }
438
473
  function sessionIdDistance(left, right, maximum) {
439
474
  if (Math.abs(left.length - right.length) > maximum)
440
475
  return maximum + 1;
@@ -7,12 +7,23 @@ import { sessionPage } from './pagination.js';
7
7
  import { latestSessionActivityAt } from './session-presentation-service.js';
8
8
  import { marctlSessionSummary, sessionActivityStatus } from '../service/session-recent-activity.js';
9
9
  import { WorkspaceSessionCleanupBudget, WorkspaceSessionCleanupDeadline } from '../service/workspace-session-cleanup-budget.js';
10
+ /** Owns catalog queries and Session catalog mutations; borrows the Workspace native cache.
11
+ * Public operations and internal observation share query rules, not transport/admission routing.
12
+ */
10
13
  export class SessionCatalogService {
11
14
  options;
12
15
  rewindQueue = Promise.resolve();
13
16
  constructor(options) {
14
17
  this.options = options;
15
18
  }
19
+ /** Internal observation uses the same catalog query without public operation dispatch. */
20
+ listWorkspacePage(workspaceId, sessionGroup) {
21
+ return this.list({
22
+ workspaceId,
23
+ limit: 30,
24
+ ...(sessionGroup === undefined ? {} : { sessionGroup })
25
+ });
26
+ }
16
27
  operations() {
17
28
  return {
18
29
  'session.list': (data) => this.list(record(data)),