@volter/supercode-orchestrator 0.5.215 → 0.5.217

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/README.md CHANGED
@@ -77,6 +77,14 @@ Other SDK callers that persist runtime state set `preserve_workflow: true` on
77
77
 
78
78
  ## Board machine-call deadlines
79
79
 
80
+ `workflow serve` retains its public socket and kernel lock while dispatcher generations change.
81
+ `workflow handover --entry /absolute/path/to/bin/orchestrator.mjs --supercode /absolute/path/to/native --root HOME`
82
+ prepares a successor through the normal source checks, drains admitted work, and transfers its writer epoch.
83
+ It routes calls only after a clean round and a successful stats reply. The parked predecessor remains available
84
+ for rollback after candidate loss. A request already sent to a lost generation returns an unknown outcome and
85
+ is never replayed. This source mechanism still needs the complete machine release integration and recorded
86
+ qualification described in [ADR 0080](../../docs/adr/0080-serving-generation-handovers.md).
87
+
80
88
  The board owner's machine calls share one timeout budget across channel admission,
81
89
  node or harness admission, and the method request. A call whose admission expires
82
90
  never sends the method after its caller has stopped waiting. It leaves the owner's
@@ -109,6 +117,11 @@ retains a separate partial file and reports its path; there is no automatic resu
109
117
  The board host needs an orchestrator that serves `attachment-read` and its normal
110
118
  running board owner. Remote reads keep the board door's caller and grant checks.
111
119
 
120
+ A card's published snapshot lists its attachments (`attachments`: each `{ id, name, content_type, size, by, at }`,
121
+ never where the board keeps it), and attaching or removing a file publishes the card, so an integration that reads only
122
+ the board (runhuman-2 ADR 0063 decision 3: Project Rooms reposting a card's images and videos) learns of a new file from
123
+ the card's change and reads its bytes through the board door with `attachment-get` or `attachment-read`.
124
+
112
125
  `workflow attachment-read <card> <attachment-id>` returns JSON with at most 1 MiB
113
126
  of `content_base64`, metadata, offsets, `eof` and a filesystem metadata `version`.
114
127
  Subsequent reads pass `--offset`, `--length` and the first response's `--version`.
@@ -326,7 +339,8 @@ await orch.tick(); // the same, on the orchestrator's
326
339
  job's `last_delivery_error`; a delivery that finally fails writes it.
327
340
  - A fire whose worker has been silent for `HERMES_CRON_TIMEOUT` seconds (600 by default, 0 for no
328
341
  limit, as in Hermes) is closed as a timeout, its failure delivered, and its runtime closed; before,
329
- a hung fire kept its job from ever running again.
342
+ a hung fire kept its job from ever running again. A fire in flight when the daemon stopped or crashed
343
+ is not that: the next daemon reopens its session and tells it to carry the run on.
330
344
  - A job's `model` (and a profile's, and `/model`'s) reaches the worker's launch (`claude --model`,
331
345
  `codex app-server -c model=…`, `gemini -m`). For a harness with no such flag it is refused where it is
332
346
  set (`/model`, `jobs.create|update`, `cronjob_create`) and again at the start, rather than run on
@@ -366,8 +380,16 @@ await orch.tick(); // the same, on the orchestrator's
366
380
  decided goes to the job's `failure_deliver`, else `deliver`, else home (a webhook's: its
367
381
  `deliver`). A chat's prompt is still relayed to the chat.
368
382
  - A Codex worker asks only when started with an approval policy: where the profile keeps Hermes's
369
- approvals (`approvals.mode` unset or not `off`) it starts with `approval_policy: untrusted`, so it asks
370
- before every command and Hermes's rule answers; with approvals off it asks nothing.
383
+ approvals (`approvals.mode` unset or not `off`) it starts with `approval_policy: untrusted` and
384
+ `approvals_reviewer: user` on its thread (`APPROVAL_POLICY`, `APPROVALS_REVIEWER`), so it asks before
385
+ every command and Hermes's rule answers, whatever reviewer the worker's config names. A headful
386
+ main takes its policy on Codex's CLI, which refuses `untrusted` (codex-cli 0.163.0), so it starts
387
+ with `--ask-for-approval on-request -c approvals_reviewer="user"` (`MAIN_ASKING_ARGUMENTS`): what Codex
388
+ asks about comes to a person. With approvals off it asks no person: it starts with
389
+ `approval_policy: on-request`, sandbox `danger-full-access` and Codex's auto-reviewer
390
+ (`approvals_reviewer = "auto_review"`) under an `auto_review.policy` that approves every request
391
+ (`OPEN_POLICY`, `OPEN_ARGUMENTS` in `activation.mjs`). It never starts with `never`, under which
392
+ Codex refuses outright any command its own check flags as dangerous.
371
393
 
372
394
  ```yaml
373
395
  worker:
@@ -490,10 +512,14 @@ daemon start attaches the `rh2` channel for that profile. Uninstalling in RH2 re
490
512
  the channel stops. A `platforms: rh2` block grants no integration authority and the runtime reports
491
513
  it as unsupported. Native Hermes channel configuration remains the worker's configuration.
492
514
 
493
- A mail agent the RH2 organization declares is one participant there, as its own agent principal: an admin issues that
494
- agent a token at RH2's agent-token door, and the home keeps it for the agent's channel, which then joins its Room's
495
- conversation answering every line. The record names the one agent it is (`--agent`, its Teams id): it is mounted in
496
- that agent's own sessions as the `rh2-agent` MCP server, and never in another agent's that shares its name or profile:
515
+ A mail agent the RH2 organization declares is one participant there, as its own agent principal, with one record per
516
+ Workplace in the home: the Agents → Room agents integration (`sdk/teams/workplace`, `launch.root`) provisions one for
517
+ every agent it provides, the token of its principal minted at RH2's agent-token door on that registration's own
518
+ installation (`rh2-agent.<agent id>@<Workplace host>`, renewed before it lapses, revoked when Teams ends the agent); one
519
+ can also be kept by hand. Every session of the agent acts through them (the `rh2` CLI and MCP read the record for the
520
+ Workplace each call goes to, from `RH2_AGENT_HOMES`), the agent's channel joins its Room's conversation on its record,
521
+ and orchestrator-launched sessions mount them as the `rh2-agent` MCP server, never in another agent's that shares its
522
+ name or profile. Kept by hand:
497
523
 
498
524
  ```sh
499
525
  supercode orchestrator apps install rh2-agent --origin https://pilot.runhuman.com --organization volter \
@@ -613,7 +639,39 @@ lock names a live process on this machine, since this boot, is refused; a lock n
613
639
  that is **gone**, or one another machine or an earlier boot wrote (a home on a volume the
614
640
  sandbox before this one mounted), is the mark of a daemon that died, and the daemon dispatches
615
641
  `restart` (§4.7) before it accepts anything. Nothing is signalled on the word of a lock from
616
- elsewhere. `leaseIsLive` and `readLease` are the package's reading of it. A clean stop removes its own lock, so an ordinary start replays nothing.
642
+ elsewhere. `leaseIsLive` and `readLease` are observations; PID liveness never
643
+ authorizes stop. A clean stop removes its own lock, so an ordinary start replays nothing.
644
+
645
+ `supercode orchestrator stop --root <home> [--json]` requests the daemon's
646
+ incarnation-bound local control socket. Its issued lease names the canonical
647
+ home, host, boot and random incarnation. The daemon checks that exact record
648
+ before accepting shutdown, joins its owners and removes only its own unchanged
649
+ lease. The CLI sends no PID signal and deletes no lease. Old leases without an
650
+ incarnation refuse; their actual process/service owner retains shutdown authority.
651
+ JSON results name the selected daemon and `stopped`, `failed` or `refused`.
652
+ Lost replies or a five-second deadline report an unknown outcome and retain the
653
+ incarnation; there is no automatic retry. Shutdown continues in the daemon.
654
+ Successful completion concerns that incarnation; a service manager may launch a
655
+ successor. See the [stop contract](../../docs/adr/0165-the-complete-orchestrator-entry-is-owned-by-node.md#stop-names-the-issued-daemon-2026-10-19-t_b37f54c7).
656
+ Foreground start reports that same actual daemon only after its live control
657
+ door confirms the invocation's issued lease. A gateway re-exec never turns the
658
+ launcher PID into an invented daemon lease; unconfirmed startup refuses.
659
+ Channel close retains the actual Chat SDK platform/state disconnect results,
660
+ including failures swallowed by `chat@4.39.0` itself, and joins the owned gateway
661
+ and its pending work. A failed channel keeps its token claim and yields failed
662
+ stop/exit status; it cannot become `stopped` merely because SDK shutdown fulfilled.
663
+ The daemon retains its lease on cleanup failure. Passive webhook bodies are
664
+ cancelled on close while complete-body handlers already admitted are joined.
665
+ Ingress closes before adapter connection waits. Already-admitted bodies keep
666
+ their processing authority through asynchronous signature verification and are
667
+ joined before the loop's stopping event. Direct-delivery HTTP 202 follows dispatch
668
+ and persistence. Worker-backed HTTP 202 also waits for actual runtime input
669
+ handoff; a memory queue alone receives no success acknowledgement. Stop refuses
670
+ unsent input with retryable HTTP 503, joins sends already in progress, drains
671
+ internal completion events and records interrupted handed turns before teardown.
672
+ Refusal removes only that unhanded input's queue/turn and dedup entry, permitting
673
+ retry. Concurrent matching delivery IDs share the handoff result. Intentional
674
+ refusal permits clean stop; actual processing/cleanup failures still fail close.
617
675
 
618
676
  On `restart`: every pending obligation goes out again, and every binding that held a lease is
619
677
  re-opened. The lease table is never persisted (§2.8), so a lease start records `runtime_id` on the
@@ -623,7 +681,11 @@ binding's residue; the loop tries `runtimes.attach_existing` where the harness's
623
681
  mark without the notice — nobody is reading them.
624
682
 
625
683
  The repo's Hermes fixture home (`crates/harness/fixtures/hermes_home`) is a ready home to drive it
626
- against by hand; that needs `target/debug/supercode` built.
684
+ against by hand after the source tree's locked SDK dependencies are installed.
685
+ The source orchestrator selects that tree's Node front door; it requires no
686
+ debug native build. A source lock must explicitly bind Node at its exact root,
687
+ version and commit. Legacy native-bound declarations refuse until explicitly
688
+ re-pinned; no source lock or installed service is migrated by a command start.
627
689
 
628
690
  ## Board session cleanup
629
691
 
package/activation.mjs CHANGED
@@ -8,6 +8,7 @@ import { mkdirSync, readFileSync, readdirSync, lstatSync, rmSync, writeFileSync,
8
8
  import { CLAUDE_BRIDGE, linkTo, skillSources } from './hermes-home.mjs';
9
9
  import { ownLogin } from './worker-route.mjs';
10
10
  import { execFile } from 'node:child_process';
11
+ import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
11
12
 
12
13
  /** Which env var each harness reads its config home from (supercode registry evidence). */
13
14
  export const HOME_ENV = Object.freeze({
@@ -66,7 +67,8 @@ export function declareWorkerHome({ harness, env, cwd, log = () => {} }) {
66
67
  const home = ['codex', 'claude-code'].includes(harness) ? env?.[HOME_ENV[harness]] : null;
67
68
  if (!home || declaredHomes.has(`${harness}\0${home}\0${cwd}`)) return;
68
69
  declaredHomes.add(`${harness}\0${home}\0${cwd}`);
69
- execFile(process.env.SUPERCODE_BIN || 'supercode', ['teams', 'roots', 'home', 'add', home, '--harness', harness, '--cwd', cwd, '--json'], { timeout: 30_000 }, (error, _stdout, stderr) => {
70
+ const selected = supercodeInvocation(process.env.SUPERCODE_BIN || 'supercode', ['teams', 'roots', 'home', 'add', home, '--harness', harness, '--cwd', cwd, '--json']);
71
+ execFile(selected.program, selected.arguments, { timeout: 30_000 }, (error, _stdout, stderr) => {
70
72
  if (!error) return;
71
73
  declaredHomes.delete(`${harness}\0${home}\0${cwd}`);
72
74
  log('warn', `worker home ${home} not declared to this machine's connector: ${String(stderr || error.message).trim().split('\n').at(-1)}`);
@@ -104,7 +106,10 @@ export function declareWorkerFolder({ cwd, root, profile = null, profileDirs = [
104
106
  const held = coveredFolders.get(folder);
105
107
  if (held && (held.until === undefined || Date.now() < held.until)) return held.answer;
106
108
  const program = process.env.SUPERCODE_BIN || 'supercode';
107
- const run = (args) => new Promise((done) => execFile(program, args, { timeout: 30_000 }, (error, stdout, stderr) => done({ error, stdout, stderr })));
109
+ const run = (args) => {
110
+ const selected = supercodeInvocation(program, args);
111
+ return new Promise((done) => execFile(selected.program, selected.arguments, { timeout: 30_000 }, (error, stdout, stderr) => done({ error, stdout, stderr })));
112
+ };
108
113
  const said = (answer) => String(answer.stderr ?? '').trim().split('\n')
109
114
  .filter((line) => line && !/^\(node:\d+\) ExperimentalWarning: SQLite|^\(Use `node --trace-warnings/.test(line)).at(-1)
110
115
  ?? (answer.error?.code != null ? `exited ${answer.error.code}` : 'failed');
@@ -308,21 +313,36 @@ export function modelArguments(worker) {
308
313
  * Where each harness takes an approval policy at start (the harness SDK's `approval_policy`, Codex's
309
314
  * `approvalPolicy` on `thread/start` and `thread/resume`): `untrusted` asks before every command, and the
310
315
  * orchestrator answers each ask with Hermes's approval rule (loop.mjs `onWorkerEvent`). Codex takes no such
311
- * policy from its config (`approval_policy = "untrusted"` in `config.toml` stops its app server: measured).
316
+ * policy from its config (`approval_policy = "untrusted"` in `config.toml` stops its app server: measured); its
317
+ * app-server protocol still takes `untrusted`, its CLI does not (see `MAIN_ASKING_ARGUMENTS`).
312
318
  */
313
319
  export const APPROVAL_POLICY = Object.freeze({ codex: 'untrusted' });
320
+ /**
321
+ * A headful main's CLI arguments with approvals on: a person answers. codex-cli 0.163.0 takes only `on-request` or
322
+ * `never` for `--ask-for-approval` (`untrusted` and `on-failure` are refused: measured), so the main runs `on-request`
323
+ * with its asks going to the user, never its auto-reviewer, whatever the person's config.toml says.
324
+ */
325
+ export const MAIN_ASKING_ARGUMENTS = Object.freeze({ codex: ['--ask-for-approval', 'on-request', '-c', 'approvals_reviewer="user"'] });
314
326
 
315
327
  /**
316
- * Where the owner turned a profile's approvals off, its worker runs as a container's does: nothing asks, and the
317
- * harness's own sandbox is off. Codex's default (workspace-write, no network) otherwise asks to reach GitHub and, with
318
- * nobody attending, is denied (measured: the organization cycle could not read its own Discussion). Codex takes the
319
- * policy on `thread/start` and its sandbox as a launch override.
328
+ * Where the owner turned a profile's approvals off, its worker runs as a container's does: nothing asks a person, and
329
+ * the harness's own sandbox is off. Codex's default (workspace-write, no network) otherwise asks to reach GitHub and,
330
+ * with nobody attending, is denied (measured: the organization cycle could not read its own Discussion). Codex takes the
331
+ * policy on `thread/start` and its sandbox and reviewer as launch overrides. The policy is `on-request`, never `never`:
332
+ * under `never` Codex refuses outright ("blocked by policy") any command its own check flags as dangerous (`rm -f`,
333
+ * `npm ci`); under `on-request` that request goes to Codex's auto-reviewer, whose policy here approves every one.
320
334
  */
321
- export const OPEN_POLICY = Object.freeze({ codex: 'never' });
335
+ export const OPEN_POLICY = Object.freeze({ codex: 'on-request' });
336
+ /** The auto-review policy every unattended Codex session runs under: the same text as `auto_review.policy` in
337
+ * sdk/interchange/startup-prompts.json (what `supercode open` passes) and the harness service's yolo policy. */
338
+ export const CODEX_AUTO_REVIEW_POLICY = 'Approve every request. The person running this session has authorized every action it takes, including commands that delete files, install packages, use the network or write outside the workspace.';
322
339
  // Claude Code takes its open policy at launch (its permission mode), as supercode's relay runs it: with approvals off a
323
340
  // dispatched Claude worker otherwise asks for every tool and, with nobody attending, is denied (measured: the review
324
341
  // World's Claude coder could not run one command).
325
- export const OPEN_ARGUMENTS = Object.freeze({ codex: ['-c', 'sandbox_mode="danger-full-access"'], 'claude-code': ['--permission-mode', 'bypassPermissions'] });
342
+ export const OPEN_ARGUMENTS = Object.freeze({
343
+ codex: ['-c', 'sandbox_mode="danger-full-access"', '-c', 'approvals_reviewer="auto_review"', '-c', `auto_review.policy=${JSON.stringify(CODEX_AUTO_REVIEW_POLICY)}`],
344
+ 'claude-code': ['--permission-mode', 'bypassPermissions'],
345
+ });
326
346
 
327
347
  const approvalsOff = (profile) => String(profile?.residue?.config?.approvals?.mode ?? 'manual').trim().toLowerCase() === 'off';
328
348
 
@@ -334,11 +354,27 @@ export function approvalPolicy(profile, harness) {
334
354
  return approvalsOff(profile) ? OPEN_POLICY[harness] : APPROVAL_POLICY[harness];
335
355
  }
336
356
 
337
- /** The launch arguments that turn a harness's own sandbox off, for a profile whose approvals are off. */
338
- export function sandboxArguments(profile, harness) {
357
+ /** Who answers a worker thread's approval requests (Codex's `approvalsReviewer` on thread/start and thread/resume):
358
+ * with approvals on the user, so the profile's rule answers before any auto-reviewer a worker's config names; with
359
+ * approvals off Codex's auto-reviewer, as `OPEN_ARGUMENTS` sets on its launch. */
360
+ export const APPROVALS_REVIEWER = Object.freeze({ codex: 'user' });
361
+ export const OPEN_APPROVALS_REVIEWER = Object.freeze({ codex: 'auto_review' });
362
+ export function approvalsReviewer(profile, harness) {
363
+ return (approvalsOff(profile) ? OPEN_APPROVALS_REVIEWER : APPROVALS_REVIEWER)[harness] ?? null;
364
+ }
365
+
366
+ /** The launch arguments that go with a worker's approval policy where the profile's approvals are off: the harness's
367
+ * own sandbox off and (Codex) its auto-reviewer approving every request. */
368
+ export function modeArguments(profile, harness) {
339
369
  return approvalsOff(profile) ? [...(OPEN_ARGUMENTS[harness] ?? [])] : [];
340
370
  }
341
371
 
372
+ /** A headful Codex main's approval arguments on its CLI: the open policy and its arguments with approvals off, a
373
+ * person answering with approvals on (`MAIN_ASKING_ARGUMENTS`). */
374
+ export function mainApprovalArguments(profile) {
375
+ return approvalsOff(profile) ? ['--ask-for-approval', OPEN_POLICY.codex, ...OPEN_ARGUMENTS.codex] : [...MAIN_ASKING_ARGUMENTS.codex];
376
+ }
377
+
342
378
  /**
343
379
  * Hermes `resolve_turn_limit`: the profile's `agent.max_turns` as a positive
344
380
  * cap, or null for unlimited (Hermes's default; `none`, `inf`, 0, -1 and
@@ -179,6 +179,28 @@ export class ChatSdkAdapter {
179
179
  this.emit = null;
180
180
  /** thread_id -> the live `Thread`, so `send` posts through the SDK's own door. */
181
181
  this.threads = new Map();
182
+ this.chats = new Set();
183
+ this.disconnects = new Map();
184
+ }
185
+
186
+ // chat@4.39.0 logs platform disconnect rejections and fulfills shutdown().
187
+ // Observe the actual lifecycle door, preserving its receiver and original
188
+ // rejection. SDK shutdown and our final join share ONE physical disconnect.
189
+ ownDisconnect(owner, name) {
190
+ if (!owner || typeof owner.disconnect !== 'function' || this.disconnects.has(owner)) return;
191
+ const original = owner.disconnect;
192
+ let result;
193
+ const disconnect = (...args) => result ??= Promise.resolve().then(() => original.apply(owner, args));
194
+ owner.disconnect = disconnect;
195
+ this.disconnects.set(owner, { name, disconnect });
196
+ }
197
+
198
+ ownChat(chat) {
199
+ this.chats.add(chat);
200
+ this.ownDisconnect(this.sdkAdapter, `platform ${this.platform}`);
201
+ // Public SDK doors also cover an already-built Chat supplied by a client.
202
+ for (const name of Object.keys(chat.webhooks ?? {})) this.ownDisconnect(chat.getAdapter?.(name), `platform ${name}`);
203
+ this.ownDisconnect(chat.getState?.(), 'state');
182
204
  }
183
205
 
184
206
  /** The block's `extra:` map, whose keys the loader prefixes with `extra.` (the world codec's channel decoder). */
@@ -294,15 +316,20 @@ export class ChatSdkAdapter {
294
316
  * and a follow-up in a subscribed thread all become one `inbound`.
295
317
  */
296
318
  async connect(emit) {
319
+ if (this.closePromise) throw new Error('chat-sdk adapter is closing');
297
320
  this.emit = emit;
298
321
  if (!this.chat) {
299
322
  try {
300
323
  const { Chat } = await import(/* @vite-ignore */ CHAT_SDK_PACKAGE);
301
324
  const sdkAdapter = this.sdkAdapter ?? (await this.buildSdkAdapter());
302
325
  this.sdkAdapter = sdkAdapter;
326
+ // Retain custody even if constructing/initializing Chat fails.
327
+ this.ownDisconnect(sdkAdapter, `platform ${this.platform}`);
328
+ const state = await this.buildState();
329
+ this.ownDisconnect(state, 'state');
303
330
  this.chat = new Chat({
304
331
  adapters: { [sdkAdapter.name]: sdkAdapter },
305
- state: await this.buildState(),
332
+ state,
306
333
  // F4 finding 11: the IR owns the queue, so the SDK's is off by default
307
334
  concurrency: this.extra('concurrency', 'concurrent'),
308
335
  logger: this.extra('logger', 'silent'),
@@ -320,6 +347,7 @@ export class ChatSdkAdapter {
320
347
  return { state: 'down', error: error.message, configured: false };
321
348
  }
322
349
  }
350
+ this.ownChat(this.chat);
323
351
  if (!this.handlersOn) {
324
352
  // registered once: a retried connect must not answer each message twice
325
353
  const handle = (thread, message) => this.handle(thread, message);
@@ -357,15 +385,25 @@ export class ChatSdkAdapter {
357
385
  while (!abort.signal.aborted && typeof this.sdkAdapter?.startGatewayListener === 'function') {
358
386
  const tasks = [];
359
387
  const began = Date.now();
360
- let why = null;
388
+ const errors = [];
361
389
  try {
362
- const res = await this.sdkAdapter.startGatewayListener({ waitUntil: (task) => { tasks.push(task); } }, GATEWAY_SLICE_MS, abort.signal);
363
- if (res && typeof res.status === 'number' && res.status >= 400) why = `the listener answered ${res.status}`;
364
- await Promise.allSettled(tasks);
390
+ const res = await this.sdkAdapter.startGatewayListener({ waitUntil: (task) => {
391
+ const pending = Promise.resolve(task);
392
+ void pending.catch(() => {}); // the slice owns and joins it below
393
+ tasks.push(pending);
394
+ } }, GATEWAY_SLICE_MS, abort.signal);
395
+ if (res && typeof res.status === 'number' && res.status >= 400) errors.push(new Error(`the listener answered ${res.status}`));
365
396
  } catch (error) {
366
- why = error.message;
397
+ errors.push(error);
398
+ }
399
+ // Join waitUntil work even when the listener itself rejected.
400
+ for (const result of await Promise.allSettled(tasks)) if (result.status === 'rejected') errors.push(result.reason);
401
+ if (abort.signal.aborted) {
402
+ const failed = errors.filter(error => error !== abort.signal.reason && error?.cause !== abort.signal.reason);
403
+ if (failed.length) throw new AggregateError(failed, failed.map(error => String(error?.message ?? error)).join('; '));
404
+ break;
367
405
  }
368
- if (abort.signal.aborted) break;
406
+ const why = errors.length ? errors.map(error => String(error?.message ?? error)).join('; ') : null;
369
407
  // a slice that ended well before its time (a refused login, a
370
408
  // dropped socket) is retried with backoff, never in a tight loop
371
409
  if (why || Date.now() - began < GATEWAY_SLICE_MS * 0.9) {
@@ -380,7 +418,9 @@ export class ChatSdkAdapter {
380
418
  } else backoff = 0;
381
419
  }
382
420
  };
383
- void run().catch((error) => this.log('error', `chat-sdk ${this.platform}: gateway: ${error.message}`));
421
+ this.gatewayTask = run();
422
+ // Keep the original rejected promise for close(), even before it joins.
423
+ void this.gatewayTask.catch(() => {});
384
424
  }
385
425
 
386
426
  /**
@@ -392,7 +432,8 @@ export class ChatSdkAdapter {
392
432
  if (!this.emit) throw new Error('chat-sdk adapter is not connected');
393
433
  const inbound = toInbound({ profile: this.profile, thread, message });
394
434
  // one message reaches several handlers (a mention in a free channel): it is one turn
395
- const seen = (this.seen ??= new Set());
435
+ this.seen ??= new Set();
436
+ const seen = this.seen;
396
437
  if (seen.has(inbound.message_id)) return undefined;
397
438
  if (!this.admitDiscord(inbound, unmentioned)) return undefined;
398
439
  seen.add(inbound.message_id);
@@ -408,11 +449,12 @@ export class ChatSdkAdapter {
408
449
  */
409
450
  discordRules() {
410
451
  if (this.rules !== undefined) return this.rules;
411
- if (String(this.extra('slug', this.platform)) !== 'discord') return (this.rules = null);
452
+ if (String(this.extra('slug', this.platform)) !== 'discord') { this.rules = null; return this.rules; }
412
453
  const file = this.profileDir ? join(this.profileDir, '.env') : null;
413
454
  const env = file && existsSync(file) ? parseDotenv(readFileSync(file, 'utf8')) : new Map();
414
455
  const list = (name) => new Set(String(env.get(name) ?? process.env[name] ?? '').split(',').map((v) => v.trim()).filter(Boolean));
415
- return (this.rules = { allowed: list('DISCORD_ALLOWED_CHANNELS'), ignored: list('DISCORD_IGNORED_CHANNELS'), free: list('DISCORD_FREE_RESPONSE_CHANNELS') });
456
+ this.rules = { allowed: list('DISCORD_ALLOWED_CHANNELS'), ignored: list('DISCORD_IGNORED_CHANNELS'), free: list('DISCORD_FREE_RESPONSE_CHANNELS') };
457
+ return this.rules;
416
458
  }
417
459
 
418
460
  /** Whether Hermes's Discord adapter would take this message: DMs always; else not ignored, allowed, and mentioned unless free. */
@@ -471,12 +513,37 @@ export class ChatSdkAdapter {
471
513
  return this.sdkAdapter.encodeThreadId({ guildId: '@me', channelId: chat });
472
514
  }
473
515
 
474
- async close() {
516
+ close() {
517
+ this.closePromise ??= this.closeOwned();
518
+ return this.closePromise;
519
+ }
520
+
521
+ async closeOwned() {
475
522
  this.gateway?.abort();
476
- this.gateway = null;
477
523
  this.emit = null;
478
524
  this.threads.clear();
479
- try { await this.chat?.shutdown(); } catch (error) { this.log('warn', `chat-sdk shutdown: ${error.message}`); }
480
- if (this.tokenLock) { releaseTokenLock(this.tokenLock); this.tokenLock = null; }
525
+ if (this.chat) this.ownChat(this.chat);
526
+ const sdkErrors = [];
527
+ for (const chat of this.chats) {
528
+ try { await chat.shutdown(); } catch (error) { sdkErrors.push(error); }
529
+ }
530
+ const errors = [], seen = new Set();
531
+ const attempt = async (name, work) => {
532
+ try { await work(); }
533
+ catch (error) {
534
+ if (seen.has(error)) return;
535
+ seen.add(error);
536
+ errors.push(new Error(`${name}: ${String(error?.message ?? error)}`, { cause: error }));
537
+ }
538
+ };
539
+ // Rejoin the SDK's actual disconnects, including rejections it swallowed,
540
+ // and attempt any owner it never reached. No disconnect is issued twice.
541
+ for (const { name, disconnect } of this.disconnects.values()) await attempt(name, disconnect);
542
+ for (const error of sdkErrors) await attempt('SDK shutdown', () => { throw error; });
543
+ await attempt('gateway', () => this.gatewayTask);
544
+ this.gateway = null;
545
+ // A failed physical owner must not be presented as a released token claim.
546
+ if (!errors.length && this.tokenLock) await attempt('token lock', () => { releaseTokenLock(this.tokenLock); this.tokenLock = null; });
547
+ if (errors.length) throw new AggregateError(errors, errors.map(error => error.message).join('; '));
481
548
  }
482
549
  }
package/adapters/rh2.mjs CHANGED
@@ -1,16 +1,17 @@
1
1
  // The RH2 adapter: a Runhuman Room conversation as an ordinary channel, the
2
2
  // same three-method trait as every adapter — connect(emit) / send(target,
3
3
  // content) / close(). The orchestrator holds an RH2 session (the bot
4
- // principal's automation token), is a member of the Rooms it follows and a
5
- // participant of their conversations, and reads and answers there the way it
6
- // does in a Slack channel. RH2's own rule keeps a coding seat and this loop
4
+ // principal's automation token), reads the Rooms it may read and answers
5
+ // every line its conversation participation asks for only in a Room it was
6
+ // brought into by the join command (RH2's `broughtIn` on the Room's members),
7
+ // elsewhere only a line naming it, the way it does in a Slack channel. RH2's own rule keeps a coding seat and this loop
7
8
  // from answering each other: a seat is never woken by an agent's message, and
8
9
  // this adapter turns only a person's line (kind `human`, or a trigger's
9
10
  // `automation`) into an `inbound`.
10
11
  //
11
12
  // Doors (RH2 `/api/v3`, bearer + `x-rh2-organization`):
12
13
  // GET /rooms the Rooms to follow
13
- // GET /rooms/:room/view members (names), conversations (permissions, nextSequence)
14
+ // GET /rooms/:room/view members (names, broughtIn), conversations (permissions, nextSequence)
14
15
  // GET /rooms/:room/conversations/:c/messages?after=&limit=
15
16
  // POST /rooms/:room/conversations/:c/messages {body, correlationId, kind: "agent", parentMessageId?}
16
17
  // PATCH /rooms/:room/conversations/:c/messages/:id {body} — a streamed reply grows, a progress line updates, by edit
@@ -49,8 +50,8 @@ export class Rh2Adapter {
49
50
  this.running = false;
50
51
  /** conversation id -> { roomId, after } */
51
52
  this.cursors = new Map();
52
- /** room id -> { name, names: Map<principalId, displayName>, conversations: Map<id, name>, seated: whether the Workplace
53
- * delivers this Room's lines to this principal's own session } */
53
+ /** room id -> { name, names: Map<principalId, displayName>, conversations: Map<id, { name, answers }>, seated: whether the
54
+ * Workplace delivers this Room's lines to this principal's own session } */
54
55
  this.rooms = new Map();
55
56
  /** message id -> the root of the thread it is in (its own id for a root). */
56
57
  this.threadOf = new Map();
@@ -159,6 +160,9 @@ export class Rh2Adapter {
159
160
  const room = view.room ?? {};
160
161
  if (room.state && room.state !== 'active') continue;
161
162
  const names = new Map((view.members ?? []).filter((m) => m.principalId).map((m) => [String(m.principalId), String(m.displayName ?? m.principalId)]));
163
+ // Whether this principal is in the Room is RH2's own answer: brought in by the join command (`broughtIn`, runhuman-2
164
+ // ADR 0063). Only then does it take the lines its channel participation asks for; elsewhere, only those naming it.
165
+ const broughtIn = (view.members ?? []).some((m) => String(m.principalId) === this.principalId() && m.broughtIn === true);
162
166
  // Where this principal's own seat carries a session, the Workplace delivers the Room's lines to that session itself
163
167
  // (runhuman-2 ADR 0046 decision 4); this adapter then hands none of them on, so no line reaches it twice.
164
168
  const seated = (view.members ?? []).some((m) => String(m.principalId) === this.principalId() && m.sessionBinding);
@@ -166,7 +170,7 @@ export class Rh2Adapter {
166
170
  for (const conversation of view.conversations ?? []) {
167
171
  if (!conversation.conversationId || !(conversation.permissions ?? []).includes('conversation.read')) continue;
168
172
  const participation = (conversation.agentParticipants ?? []).find((p) => String(p.principalId) === this.principalId());
169
- conversations.set(conversation.conversationId, { name: String(conversation.name ?? conversation.conversationId), answers: participation?.answers ?? null });
173
+ conversations.set(conversation.conversationId, { name: String(conversation.name ?? conversation.conversationId), answers: broughtIn ? participation?.answers ?? null : null });
170
174
  live.add(conversation.conversationId);
171
175
  if (!this.cursors.has(conversation.conversationId)) {
172
176
  // At start, a conversation is read from its current end: no backlog is answered. One that becomes
package/agents.mjs CHANGED
@@ -251,7 +251,7 @@ export function renameCardAgent(root, assignment, readableId) {
251
251
  }
252
252
  /** The id a home's mail agent runs as, read and never minted: its own declared identity (`agent/<name>`) where the home
253
253
  * holds one, else its profile's (`profile/<profile>`), as localProfileAgent keys them, each at its current generation.
254
- * Null when the home holds neither, or has no agent store. A channel names its agent by this id (agent-channel). */
254
+ * Null when the home holds neither, or has no agent store. */
255
255
  export function homeAgentId(root,{org,server,name,profile=name}){
256
256
  if(!existsSync(join(root,'agent-state.sqlite')))return null;
257
257
  const db=store(root);
@@ -512,7 +512,3 @@ export function agentEnvironment(agent,{root,context,session=null,main=true}={})
512
512
  ...(session?{SUPERCODE_AGENT_SESSION:session}:{})};
513
513
  }
514
514
 
515
- /** A standing profile's conversation runtime; scheduled/card runs never select its conversation. */
516
- export function localAgentConversation(root,org,id){
517
- const db=store(root);try{return db.prepare('SELECT session_ref session,started_at,ended_at FROM agent_runs WHERE org=? AND agent_id=? AND conversation=1 ORDER BY (ended_at IS NULL) DESC,started_at DESC,rowid DESC LIMIT 1').get(org,id)??null;}finally{db.close();}
518
- }