@volter/supercode-orchestrator 0.5.204 → 0.5.206

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.
Files changed (51) hide show
  1. package/README.md +138 -4
  2. package/activation.mjs +93 -2
  3. package/adapters/rh2.mjs +7 -2
  4. package/agents.mjs +24 -2
  5. package/apply/doors.mjs +25 -2
  6. package/bin/agent-channel.mjs +41 -22
  7. package/bin/connection-runner.mjs +141 -0
  8. package/bin/orchestrator.mjs +11 -2
  9. package/bin/runtime.mjs +16 -9
  10. package/board/args.mjs +3 -2
  11. package/board/attachment-read.mjs +77 -0
  12. package/board/call.mjs +4 -2
  13. package/board/capacity.mjs +46 -9
  14. package/board/ci.mjs +157 -53
  15. package/board/cli.mjs +225 -87
  16. package/board/client.mjs +18 -2
  17. package/board/custody.mjs +109 -0
  18. package/board/dispatch.mjs +297 -75
  19. package/board/doors.mjs +47 -17
  20. package/board/engine.mjs +78 -24
  21. package/board/fleet-listeners.mjs +268 -0
  22. package/board/fleet-observer.mjs +79 -0
  23. package/board/fleet-targets.mjs +82 -0
  24. package/board/incidents.mjs +7 -0
  25. package/board/listing.mjs +5 -0
  26. package/board/machine-fleet.mjs +7 -0
  27. package/board/notice-receipts.mjs +67 -0
  28. package/board/owner-door-worker.mjs +61 -0
  29. package/board/owner-door.mjs +46 -0
  30. package/board/owner-health.mjs +8 -2
  31. package/board/published.mjs +2 -2
  32. package/board/record-view.mjs +2 -0
  33. package/board/store.mjs +55 -3
  34. package/board/tools.mjs +11 -4
  35. package/board/workflow.mjs +91 -28
  36. package/board/ztrack.mjs +87 -21
  37. package/board-input.d.ts +2 -0
  38. package/board-input.mjs +16 -0
  39. package/connection.mjs +153 -0
  40. package/index.d.ts +7 -5
  41. package/index.mjs +1 -1
  42. package/installed-apps.mjs +31 -20
  43. package/loop.mjs +91 -36
  44. package/mcp/bridge.mjs +12 -0
  45. package/mcp/hermes-kanban-tools.json +22 -14
  46. package/mcp/mount.mjs +10 -9
  47. package/ontology.d.ts +37 -0
  48. package/operator.mjs +72 -4
  49. package/orchestration.mjs +7 -2
  50. package/package.json +9 -5
  51. package/reducer.mjs +74 -5
package/README.md CHANGED
@@ -4,6 +4,21 @@ This is currently a private workspace package. Examples assume the checkout's
4
4
  workspace dependencies and a built Volter Harness CLI; the package name is not a claim
5
5
  that an installable npm release exists.
6
6
 
7
+ Verified worker binding replaces superseded worker notice subscriptions across
8
+ an arc and its subtasks. Native notices wait with their event cursor intact when
9
+ liveness is unread; agent and board mailboxes keep their independent routes.
10
+ Read the [worker-notice contract](../../docs/reference/board-worker-notices.md)
11
+ and [retained subscription evidence](../../docs/evidence/board-worker-notices-2026-10-09.md)
12
+ for source behavior and the remaining native/publication qualification.
13
+
14
+ `supercode workflow claim <card>` opens a manual run and commits the declared
15
+ `running` workflow stage with its claim. Readiness is checked against the
16
+ workflow stage; the home must declare `running`. Creating with
17
+ `--initial-status running` uses the same path. Subsequent `block` follows that
18
+ stage's own transition, which ends the run in the default workflow. See the
19
+ [private manual reading](../../docs/evidence/board-manual-claim-2026-10-09.md)
20
+ for scope and limits; existing inconsistent claims are not repaired automatically.
21
+
7
22
  The orchestrator's runtime over the ontology: one typed operational model
8
23
  (`OrchestratorState` = an orchestration value plus a runtime half) whose folder is its serialization.
9
24
  `docs/ORCHESTRATOR-IR.md` is normative. The orchestration value and every codec are the ontology's, in
@@ -42,6 +57,48 @@ Rules the codec keeps (enforced in Rust, observed here):
42
57
  since import (UNI-22); it copies the store byte for byte when they did not.
43
58
  - Files the IR does not model are never touched by `save` and are carried by path on a migration.
44
59
 
60
+ ## Board machine-call deadlines
61
+
62
+ The board owner's machine calls share one timeout budget across channel admission,
63
+ node or harness admission, and the method request. A call whose admission expires
64
+ never sends the method after its caller has stopped waiting. It leaves the owner's
65
+ shared channel and clients available to other callers. Once a method request has
66
+ started, a timeout reports that whether it ran is unknown.
67
+
68
+ Timing steps name each admission phase and the request. The SDK request receives
69
+ the remaining budget and cancellation signal; node requests receive the remaining
70
+ budget, with their existing request/reply cleanup. The workflow caller also records
71
+ session lookup, request preparation and the wait for the owner's answer, including
72
+ an owner call that ends by throwing. These steps contain durations, not request bodies.
73
+ These bounds do not establish
74
+ subsecond board verbs or identify the cause of a historical slow call. The retained
75
+ readings and limits are in
76
+ [board call admission evidence](../../docs/evidence/board-call-admission-2026-10-10.md).
77
+
78
+ ## Read a card's attachment
79
+
80
+ List a card's recorded uploads, then download one onto the calling machine:
81
+
82
+ ```sh
83
+ supercode workflow attachments <card> --at sc:<host>:board:<name> --json
84
+ supercode workflow attachment-get <card> <attachment-id> --at sc:<host>:board:<name> --out ./artifact.png
85
+ ```
86
+
87
+ The npm front door serves `attachment-get`. Without `--at`, use `--root <home>`
88
+ and optionally `--board <slug>`. `--out` names a local file; its parent directory
89
+ must exist. A completed download replaces that file. An interrupted download
90
+ retains a separate partial file and reports its path; there is no automatic resume.
91
+ The board host needs an orchestrator that serves `attachment-read` and its normal
92
+ running board owner. Remote reads keep the board door's caller and grant checks.
93
+
94
+ `workflow attachment-read <card> <attachment-id>` returns JSON with at most 1 MiB
95
+ of `content_base64`, metadata, offsets, `eof` and a filesystem metadata `version`.
96
+ Subsequent reads pass `--offset`, `--length` and the first response's `--version`.
97
+ It accepts a recorded attachment id rather than an arbitrary host path. Version
98
+ checking detects metadata changes; it is not a content digest or writer attestation.
99
+ The worker's existing `kanban_attachments` MCP tool lists uploads as before;
100
+ adding `attachment_id`, and optionally `offset`, `length` and `version`, reads a
101
+ range under the same worker scope. The maximum `length` is 1,048,576 bytes.
45
102
  ## Board GitHub REST receipts
46
103
 
47
104
  `supercode workflow github-usage --since 1h --json` reads this user's local
@@ -140,7 +197,7 @@ Tiers on the way out:
140
197
  too, rather than written as its own reference.
141
198
 
142
199
  Secrets never enter the model: any string under a credential-shaped key — openclaw's own rule
143
- (`channels.rs::is_credential_key`: the lower-cased key ENDS WITH `token|key|secret|password|
200
+ (`orchestration/codec/openclaw.rs::is_credential_key`: the lower-cased key ENDS WITH `token|key|secret|password|
144
201
  credential`), minus the names it spells that way for non-secrets (`sessionKey`, `store_key`, …) —
145
202
  moves to the vault as a `{dotenv}` ref and is re-inlined on export where openclaw reads it.
146
203
 
@@ -169,6 +226,12 @@ await orch.dispatch(events.tick(now)); // the clock is an event too
169
226
  adapters (`deliver`). A worker runs with the profile folder as cwd and the profile's harness home
170
227
  inside it (`activationEnv`), so its AGENTS.md is in context by the ordinary instruction-file walk.
171
228
 
229
+ The board's held profile view follows root/profile `AGENTS.md` changes, including
230
+ linked targets, and reloads on its next call. The current call retains its basis.
231
+ This file refresh does not replace instructions in a running native session;
232
+ [c60's source record](../../docs/evidence/persona-file-invalidation-2026-10-10.md)
233
+ keeps running-main delivery and readback open.
234
+
172
235
  ## Cron and webhooks (ORC-5)
173
236
 
174
237
  ```js
@@ -308,6 +371,29 @@ Tools, one per verb, names and argument shapes transcribed from Hermes's `tools/
308
371
  `supercode mcp serve` (the coding tools) is a different server for a different purpose and is
309
372
  untouched.
310
373
 
374
+ ## Board model selection and native refusals
375
+
376
+ Board pane lanes take the card's model override, then `worker.model`, then the
377
+ profile's `model.default` when the harness accepts a native model flag. With no
378
+ declared model the harness retains its own default. For a fresh Claude launch,
379
+ the dispatcher reads the native first assistant outcome through the session door:
380
+ an API error fails the run even if the terminal is still alive. The workflow's
381
+ existing incident policy decides the card state and recovery; a monitor board
382
+ retains the failed run and holds dispatch without closing its pane. This requires
383
+ the matching TypeScript session reader and is not resumed-turn or general
384
+ completion monitoring. See the [owned Mac reading](../../docs/evidence/board-claude-model-first-response-2026-10-09.md).
385
+
386
+ ## Board reviewer selection
387
+
388
+ The workflow can select a reviewer from the latest coding run's harness, while
389
+ retaining a card's or request's explicit profile. Review-machine choices stay in
390
+ workflow parameters. Default same-harness review retains the coding model; an
391
+ explicit reviewer uses its profile's model. The review request records the
392
+ implementer's machine and model/provider overrides for a return with changes.
393
+ See [the configuration contract](../../docs/reference/board-reviewers.md) and
394
+ [source qualification](../../docs/evidence/board-reviewer-selection-2026-10-09.md).
395
+ A custom home needs its own workflow update; this source does not rewrite it.
396
+
311
397
  ## Acceptance (ORC-8)
312
398
 
313
399
  The bar of `docs/ORCHESTRATOR-IR.md` §8 — one scripted week of realistic use replayed against
@@ -358,11 +444,12 @@ it as unsupported. Native Hermes channel configuration remains the worker's conf
358
444
 
359
445
  A mail agent the RH2 organization declares is one participant there, as its own agent principal: an admin issues that
360
446
  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
361
- conversation answering every line:
447
+ conversation answering every line. The record names the one agent it is (`--agent`, its Teams id): it is mounted in
448
+ that agent's own sessions as the `rh2-agent` MCP server, and never in another agent's that shares its name or profile:
362
449
 
363
450
  ```sh
364
451
  supercode orchestrator apps install rh2-agent --origin https://pilot.runhuman.com --organization volter \
365
- --principal prn_… --token-file ./account-manager.token --profile account-manager
452
+ --principal prn_… --agent agent_… --token-file ./account-manager.token --profile account-manager
366
453
  supercode orchestrator apps remove rh2-agent.account-manager
367
454
  ```
368
455
 
@@ -490,6 +577,30 @@ mark without the notice — nobody is reading them.
490
577
  The repo's Hermes fixture home (`crates/harness/fixtures/hermes_home`) is a ready home to drive it
491
578
  against by hand; that needs `target/debug/supercode` built.
492
579
 
580
+ ## Board session cleanup
581
+
582
+ A manager or creator changing a card's assignee can release its ended worker
583
+ from a `needs_input` dialog. The board requires its retained reassignment and
584
+ release, a fresh reading of the exact harness/session, and an unexpired close
585
+ claim. It closes the pane through the owning board's door and retains the
586
+ reading; it sends no dialog key or approval response. Working turns, adopted sessions and panes held by an active successor keep
587
+ their existing protection. Unknown activity retains main’s native-record quiet
588
+ watch; this exception requires a positive needs_input reading for the exact
589
+ launched worker. The candidate's [source and package record](../../docs/evidence/board-reassignment-input-dialog-2026-10-10.md)
590
+ leaves native closing and replacement-launch qualification open.
591
+
592
+ ## Remote card text
593
+
594
+ The npm front door's `workflow … --at sc:<machine>:board:<name>` uses
595
+ `prepareBoardText(argv)` from `@volter/supercode-orchestrator/board-input` before calling
596
+ the machine's board door. It returns `{ argv, stdin }`: the board parser selects a
597
+ comment's `--file` or a create/specify verb's `--body-file`, reads that file on the
598
+ caller (`-` reads standard input), and appends the selected file flag with `-`.
599
+ The last flag value wins, preserving the other arguments. Other verbs and help
600
+ do not read a text file. The helper opens no transport and makes no card write;
601
+ the daemon carries the input to the board owner under the caller's existing grants
602
+ and card permissions. See the [Teams guide](../../docs/guides/teams.md#call-a-board-on-another-machine).
603
+
493
604
  ## The operator door (ORC-13)
494
605
 
495
606
  The orchestrator's WRITE door — what `supercode jobs|profiles|sessions <verb> --harness orchestrator`
@@ -510,7 +621,9 @@ out {"ok":true,"result":{"ran":"created cron job job_… …","job_id":"…","j
510
621
  ```
511
622
 
512
623
  `op` is one of `jobs.create|update|pause|resume|run|delete`, `profiles.create|delete`,
513
- `sessions.new|reset`, `access.pair_approve`. The verb dispatches its `events.operator(...)` on the
624
+ `sessions.new|reset`, `sessions.launch|launch_status|launch_close` (live only: a concurrent session of the profile's
625
+ agent started on request, under a launch key; `supercode agent launch` drives them; a launch's refusal is answered
626
+ `{ok:false, error, code}` with `code` `conflict`, `budget` or `invalid_request`), `access.pair_approve`. The verb dispatches its `events.operator(...)` on the
514
627
  **loop's own queue**, so an operator write is ordered against inbound messages and ticks like
515
628
  anything else and the running loop's state and the folder can never disagree. `result` re-reads the
516
629
  affected record out of state after the reducer ran.
@@ -579,3 +692,24 @@ root/executor is refused; the lease is retained until shutdown completes. The
579
692
  helper does not clear a live lease or escalate to SIGKILL. Embedders can stop their
580
693
  owned clock before disabling the integration and start it again on the next boot.
581
694
  Hosted HTTP refusal messages preserve the executor's bounded JSON error reason.
695
+
696
+ Review source metadata admits four active GitHub reads and 64 waiting owners,
697
+ using four ordered source workers and a shared stored-token lookup. A source
698
+ verification reuses its one PR observation, retaining head and authority
699
+ checks; failed fetches retain their cause and available native code.
700
+ [The source record](../../docs/evidence/board-review-metadata-admission-2026-10-12.md)
701
+ states the current handoff and adoption limits.
702
+
703
+ ## Board session custody
704
+
705
+ The candidate Node board CLI adds `supercode workflow detach <card> --to person:<id>`.
706
+ It retains the native identity, records the person's custody and removes the
707
+ dispatcher run, subscriptions and capacity counts without closing the session.
708
+ Queued effects and later continuation cannot take it back; the existing `adopt`
709
+ door must verify a live native session before the board owns it again.
710
+
711
+ The [command contract](../../docs/reference/board-session-detach.md) describes
712
+ authority, in-progress-operation refusals and already-issued-request boundaries.
713
+ [Source observations](../../docs/evidence/board-session-detach-2026-10-09.md)
714
+ cover actual help, compilation and read-only retained records. Live handback,
715
+ return through adoption and released availability remain unqualified.
package/activation.mjs CHANGED
@@ -2,9 +2,9 @@
2
2
  // The profile folder is a complete home; each harness's config home is a
3
3
  // subfolder named by its id, pointed at through the harness's own env var.
4
4
 
5
- import { join, relative, resolve } from 'node:path';
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
- import { mkdirSync, readFileSync, readdirSync, lstatSync, rmSync, writeFileSync, existsSync } from 'node:fs';
7
+ import { mkdirSync, readFileSync, readdirSync, lstatSync, rmSync, writeFileSync, existsSync, statSync, realpathSync } from 'node:fs';
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';
@@ -73,10 +73,101 @@ export function declareWorkerHome({ harness, env, cwd, log = () => {} }) {
73
73
  });
74
74
  }
75
75
 
76
+ /**
77
+ * The folder a worker runs in, made one of this machine's Teams sync roots when none covers it, through the connector's
78
+ * own door: `supercode teams connect --context <context> --cwd <folder> --add-root --exact-folder`, the restricted rule a
79
+ * person's `--add-root` makes, on a root that covers that one folder and nothing under it (`exact_only`: the connector
80
+ * captures a session only when its recorded folder is that folder, after realpath). A worker's session is recorded in the
81
+ * folder it was started in, so the exact root captures it, and no subfolder is published by it. Whether a root covers
82
+ * the folder is the CLI's own rule (`teams roots --cwd`: rootCovers in roots.mjs, as `agent launch --status` reads it).
83
+ *
84
+ * A folder a root already covers (one a person connected by hand, wherever it is) is captured, and nothing more is done.
85
+ * Otherwise one folder is still every session recorded in it, so only a folder the orchestrator owns is connected: one
86
+ * strictly inside its home (`root`), after realpath, that is no profile's own folder. The home itself is refused
87
+ * (`default` with no `worker.cwd` runs there, and a person's own session started at the home's top level would be
88
+ * published with it), every folder outside the home (an absolute or `..` `worker.cwd`: a checkout or a parent a person
89
+ * works in), and every profile's folder (`profileDirs`: a profile with no `worker.cwd` runs there, where people edit
90
+ * the profile in sessions of their own). A refusal says which `worker.cwd` to set; a person may connect such a folder by
91
+ * hand.
92
+ *
93
+ * The context is the orchestrator's (`SUPERCODE_CONTEXT`), whose connector the root goes to. A launch into a folder
94
+ * outside every root ran, was never captured, and its launch read `starting` until the Workplace integration gave it up
95
+ * (volter-org/home/workspace, 2026-10-15). Once per folder per process, off the launch's path; a refusal (not the
96
+ * orchestrator's folder, no context, no installed connector) is said once and held REFUSAL_HOLD_MS before it is asked
97
+ * again.
98
+ */
99
+ const coveredFolders = new Map();
100
+ const REFUSAL_HOLD_MS = 5 * 60_000;
101
+ export function declareWorkerFolder({ cwd, root, profile = null, profileDirs = [], context = process.env.SUPERCODE_CONTEXT, scope = null, log = () => {} }) {
102
+ let folder, home;
103
+ try { folder = realpathSync(cwd); home = realpathSync(root); } catch { return Promise.resolve(false); }
104
+ const held = coveredFolders.get(folder);
105
+ if (held && (held.until === undefined || Date.now() < held.until)) return held.answer;
106
+ 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 })));
108
+ const said = (answer) => String(answer.stderr ?? '').trim().split('\n')
109
+ .filter((line) => line && !/^\(node:\d+\) ExperimentalWarning: SQLite|^\(Use `node --trace-warnings/.test(line)).at(-1)
110
+ ?? (answer.error?.code != null ? `exited ${answer.error.code}` : 'failed');
111
+ const refused = (why) => { log('warn', `worker folder ${folder}: its sessions are not captured by Teams: ${why}`); return false; };
112
+ const entry = {};
113
+ entry.answer = (async () => {
114
+ if (!context) return refused('no Teams context names where to connect it (SUPERCODE_CONTEXT)');
115
+ if (scope?.offline) return refused(`context ${context} is not a Teams context`);
116
+ // a root that covers it already (one a person connected by hand, wherever the folder is) captures it: nothing to do
117
+ const roots = await run(['teams', 'roots', '--json', '--context', context, '--cwd', folder]);
118
+ let covering = null;
119
+ try { covering = JSON.parse(roots.stdout).data.covering; } catch { /* unread: the connect below says what holds it */ }
120
+ if (Array.isArray(covering) && covering.length) return true;
121
+ // connected on a person's behalf only when the folder is the orchestrator's own: strictly inside its home, and not
122
+ // a profile's own folder (where people edit the profile, its AGENTS.md, in sessions of their own)
123
+ const within = relative(home, folder);
124
+ const set = `set worker.cwd for ${profile ? `profile ${profile}` : 'its profile'} to a folder of its own (for example \`worker.cwd: workspace\`), or connect this folder yourself (\`supercode teams connect --cwd ${folder} --add-root --exact-folder\`)`;
125
+ if (within === '') return refused(`it is the orchestrator home itself, where people start their own sessions, and an exact root there would publish theirs too: ${set}`);
126
+ if (within === '..' || within.startsWith(`..${sep}`) || isAbsolute(within)) return refused(`it is outside the orchestrator home ${home}, a folder people may work in, and is never connected on their behalf: ${set}`);
127
+ const real = (dir) => { try { return realpathSync(dir); } catch { return null; } };
128
+ if (profileDirs.some((dir) => dir && real(dir) === folder)) return refused(`it is a profile's own folder, where people edit the profile in sessions of their own, and an exact root there would publish theirs too: ${set}`);
129
+ const connected = await run(['teams', 'connect', '--context', context, '--cwd', folder, '--add-root', '--exact-folder']);
130
+ if (connected.error) return refused(`it could not be connected: ${said(connected)}`);
131
+ log('info', `worker folder ${folder} connected to Teams as an exact-folder sync root (its sessions are captured from the connector's next heartbeat)`);
132
+ return true;
133
+ })();
134
+ coveredFolders.set(folder, entry);
135
+ // a refusal is held a few minutes, then asked again by the next start; a covered folder stays covered for the process
136
+ void entry.answer.then((covered) => { if (!covered) entry.until = Date.now() + REFUSAL_HOLD_MS; });
137
+ return entry.answer;
138
+ }
139
+
76
140
  export function activationCwd(profile, workdir) {
77
141
  return resolve(profile.dir, workdir ?? profile.worker?.cwd ?? '.');
78
142
  }
79
143
 
144
+ /**
145
+ * The folder a worker starts in, ready: `activationCwd`. A profile is a folder its worker runs in, or a subfolder of
146
+ * it (docs/architecture/orchestrator.md §0.3, `cwd: RelPath`, relative to the profile dir): a subfolder the profile names
147
+ * and lacks is the profile's own, and is made, empty. A folder outside the profile's (an absolute path, or one reached
148
+ * through `..`) is someone else's, and is never made here: a start refuses, naming it. Before this a missing cwd failed
149
+ * the start inside harness serve, and nothing kept why.
150
+ */
151
+ export function workerCwd(profile, workdir) {
152
+ const cwd = activationCwd(profile, workdir);
153
+ if (existsSync(cwd)) {
154
+ if (!statSync(cwd).isDirectory()) throw new Error(`the worker's cwd ${cwd} (profile ${profile.name}) is not a folder`);
155
+ return cwd;
156
+ }
157
+ // Inside is judged where the folder would really be made: its deepest existing ancestor, symbolic links resolved,
158
+ // with the missing rest under it, against the profile's own folder resolved the same way. A link in the profile's
159
+ // folder to another (profile/repo -> a checkout) puts what is made under it in that other folder, never the profile's.
160
+ let ancestor = cwd;
161
+ while (!existsSync(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
162
+ const target = join(realpathSync(ancestor), relative(ancestor, cwd));
163
+ const inside = relative(realpathSync(profile.dir), target);
164
+ if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
165
+ throw new Error(`the worker's cwd ${cwd} (worker.cwd ${JSON.stringify(workdir ?? profile.worker?.cwd ?? '.')} in profile ${profile.name}) does not exist; it is outside the profile's folder${target === cwd ? '' : ` (it would be made at ${target})`}, so it is not made here: create it, or name another`);
166
+ }
167
+ mkdirSync(target, { recursive: true });
168
+ return cwd;
169
+ }
170
+
80
171
  /**
81
172
  * Where each harness reads a persona from any cwd, and in what form: its config home's own instruction
82
173
  * file. A worker often runs in a project checkout outside the home, where the project walk never reaches
package/adapters/rh2.mjs CHANGED
@@ -49,7 +49,8 @@ export class Rh2Adapter {
49
49
  this.running = false;
50
50
  /** conversation id -> { roomId, after } */
51
51
  this.cursors = new Map();
52
- /** room id -> { name, names: Map<principalId, displayName>, conversations: Map<id, name> } */
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
54
  this.rooms = new Map();
54
55
  /** message id -> the root of the thread it is in (its own id for a root). */
55
56
  this.threadOf = new Map();
@@ -158,6 +159,9 @@ export class Rh2Adapter {
158
159
  const room = view.room ?? {};
159
160
  if (room.state && room.state !== 'active') continue;
160
161
  const names = new Map((view.members ?? []).filter((m) => m.principalId).map((m) => [String(m.principalId), String(m.displayName ?? m.principalId)]));
162
+ // Where this principal's own seat carries a session, the Workplace delivers the Room's lines to that session itself
163
+ // (runhuman-2 ADR 0046 decision 4); this adapter then hands none of them on, so no line reaches it twice.
164
+ const seated = (view.members ?? []).some((m) => String(m.principalId) === this.principalId() && m.sessionBinding);
161
165
  const conversations = new Map();
162
166
  for (const conversation of view.conversations ?? []) {
163
167
  if (!conversation.conversationId || !(conversation.permissions ?? []).includes('conversation.read')) continue;
@@ -174,7 +178,7 @@ export class Rh2Adapter {
174
178
  : { roomId, after: 0, since: previous - CLOCK_SKEW_MS });
175
179
  }
176
180
  }
177
- this.rooms.set(roomId, { name: String(room.name ?? roomId), names, conversations });
181
+ this.rooms.set(roomId, { name: String(room.name ?? roomId), names, conversations, seated });
178
182
  }
179
183
  for (const id of [...this.cursors.keys()]) if (!live.has(id)) this.cursors.delete(id);
180
184
  }
@@ -242,6 +246,7 @@ export class Rh2Adapter {
242
246
  if (this.threadOf.size > THREAD_MEMORY) this.threadOf.delete(this.threadOf.keys().next().value);
243
247
  }
244
248
  if (!isMe && conversation?.answers !== 'all' && !mentioned) return;
249
+ if (!isMe && room?.seated) return;
245
250
  const chatId = `${roomId}/${conversationId}`;
246
251
  // A person's line opens a turn: show typing here until the reply lands (the loop has no
247
252
  // typing effect by design — the adapter, which knows both ends of the turn, carries it).
package/agents.mjs CHANGED
@@ -26,6 +26,9 @@ function store(root){
26
26
  CREATE TABLE IF NOT EXISTS agent_runtime_sessions(org TEXT NOT NULL,runtime_ref TEXT NOT NULL,session_ref TEXT NOT NULL,PRIMARY KEY(org,runtime_ref));
27
27
  CREATE TABLE IF NOT EXISTS agent_runs(org TEXT NOT NULL,agent_id TEXT NOT NULL,session_ref TEXT NOT NULL,machine TEXT,run_id TEXT,profile_json TEXT,ended_at INTEGER,end_reason TEXT,main INTEGER NOT NULL DEFAULT 1,starter_session TEXT,usage_json TEXT,PRIMARY KEY(org,session_ref));
28
28
  CREATE TABLE IF NOT EXISTS agent_usage_unread(org TEXT NOT NULL,session_ref TEXT NOT NULL,stamp TEXT NOT NULL,PRIMARY KEY(org,session_ref));`);
29
+ const columns=new Set(db.prepare('PRAGMA table_info(agent_runs)').all().map(row=>row.name));
30
+ if(!columns.has('conversation'))db.exec('ALTER TABLE agent_runs ADD COLUMN conversation INTEGER NOT NULL DEFAULT 0');
31
+ if(!columns.has('started_at'))db.exec('ALTER TABLE agent_runs ADD COLUMN started_at INTEGER NOT NULL DEFAULT 0');
29
32
  db.prepare('DELETE FROM agent_card_assignment_changes WHERE recorded=1').run();
30
33
  return db;
31
34
  }
@@ -222,6 +225,19 @@ export function localAgentRecord(root,org,id){
222
225
  sponsor:declaration.sponsor??null,profile:intent.profile_ref,state:'active',synced:!!intent.declared,main_session:null,revision:0},
223
226
  intended:intent.profile,assignments:intent.assignments,org,synced:!!intent.declared};
224
227
  }
228
+ /** The id a home's mail agent runs as, read and never minted: its own declared identity (`agent/<name>`) where the home
229
+ * holds one, else its profile's (`profile/<profile>`), as localProfileAgent keys them, each at its current generation.
230
+ * Null when the home holds neither, or has no agent store. A channel names its agent by this id (agent-channel). */
231
+ export function homeAgentId(root,{org,server,name,profile=name}){
232
+ if(!existsSync(join(root,'agent-state.sqlite')))return null;
233
+ const db=store(root);
234
+ try{
235
+ const at=(key)=>{const stable=`${server}/${org}/${key}`;
236
+ return db.prepare('SELECT agent_id FROM agent_declaration_heads WHERE org=? AND key=?').get(org,stable)?.agent_id
237
+ ??db.prepare('SELECT id FROM agent_intents WHERE org=? AND key=?').get(org,stable)?.id??null;};
238
+ return (name!==profile?at(`agent/${name}`):null)??at(`profile/${profile}`);
239
+ }finally{db.close();}
240
+ }
225
241
  /** Earlier sessions this home ran for an agent, for a relaunch's pointers when Teams cannot list them. */
226
242
  export function localAgentSessions(root,org,id){
227
243
  const db=store(root);try{return db.prepare('SELECT session_ref AS session,machine,ended_at,end_reason FROM agent_runs WHERE org=? AND agent_id=? ORDER BY rowid DESC LIMIT 200').all(org,id);}finally{db.close();}
@@ -377,7 +393,7 @@ export function recordNativeAgentSession(root,runtimeRef,session) {
377
393
  const owner=agentRunForSession(root,[runtimeRef]);
378
394
  if(!owner||owner.session_ref===session)return owner;
379
395
  recordAgentRun(root,owner.org,owner.agent_id,{session,machine:owner.machine,run:owner.run_id,
380
- profile:owner.profile_json?JSON.parse(owner.profile_json):null,main:owner.main===1,starterSession:owner.starter_session});
396
+ profile:owner.profile_json?JSON.parse(owner.profile_json):null,main:owner.main===1,starterSession:owner.starter_session,conversation:owner.conversation===1});
381
397
  const db=store(root);try{
382
398
  db.exec('BEGIN IMMEDIATE');
383
399
  db.prepare('INSERT INTO agent_runtime_sessions(org,runtime_ref,session_ref) VALUES (?,?,?) ON CONFLICT(org,runtime_ref) DO UPDATE SET session_ref=excluded.session_ref').run(owner.org,runtimeRef,session);
@@ -387,12 +403,13 @@ export function recordNativeAgentSession(root,runtimeRef,session) {
387
403
  }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
388
404
  return agentRunForSession(root,[runtimeRef]);
389
405
  }
390
- export function recordAgentRun(root,org,id,{session,machine=null,run,profile=null,main=true,starterSession=null}){
406
+ export function recordAgentRun(root,org,id,{session,machine=null,run,profile=null,main=true,starterSession=null,conversation=false}){
391
407
  const db=store(root);try{
392
408
  db.exec('BEGIN IMMEDIATE');
393
409
  const old=db.prepare('SELECT agent_id FROM agent_runs WHERE org=? AND session_ref=?').get(org,session);
394
410
  if(old&&old.agent_id!==id)throw new Error('local session ownership cannot change agent identity');
395
411
  db.prepare('INSERT INTO agent_runs(org,agent_id,session_ref,machine,run_id,profile_json,main,starter_session) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(org,session_ref) DO UPDATE SET machine=excluded.machine,run_id=excluded.run_id,profile_json=COALESCE(excluded.profile_json,agent_runs.profile_json),main=excluded.main,starter_session=COALESCE(agent_runs.starter_session,excluded.starter_session) WHERE agent_runs.machine IS NOT excluded.machine OR agent_runs.run_id IS NOT excluded.run_id OR (excluded.profile_json IS NOT NULL AND agent_runs.profile_json IS NOT excluded.profile_json) OR agent_runs.main IS NOT excluded.main OR (agent_runs.starter_session IS NULL AND excluded.starter_session IS NOT NULL)').run(org,id,session,machine,String(run),profile?JSON.stringify(profile):null,main?1:0,starterSession);
412
+ db.prepare('UPDATE agent_runs SET conversation=?,started_at=CASE WHEN started_at=0 THEN ? ELSE started_at END WHERE org=? AND session_ref=?').run(conversation?1:0,Date.now(),org,session);
396
413
  db.exec('COMMIT');
397
414
  }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
398
415
  }
@@ -470,3 +487,8 @@ export function agentEnvironment(agent,{root,context,session=null,main=true}={})
470
487
  SUPERCODE_AGENT_MAIN:main?'1':'0',SUPERCODE_AGENT_SYNCED:a.synced===false?'0':'1',SUPERCODE_AGENT_ASSIGNMENTS:JSON.stringify(agent.assignments??[]),
471
488
  ...(session?{SUPERCODE_AGENT_SESSION:session}:{})};
472
489
  }
490
+
491
+ /** A standing profile's conversation runtime; scheduled/card runs never select its conversation. */
492
+ export function localAgentConversation(root,org,id){
493
+ 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();}
494
+ }
package/apply/doors.mjs CHANGED
@@ -18,11 +18,14 @@ import { existsSync, readFileSync } from 'node:fs';
18
18
  import { createConnection } from 'node:net';
19
19
  import { dirname, join, resolve } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { redactText } from '@volter/teams/redact';
21
22
 
22
23
  const HERE = dirname(fileURLToPath(import.meta.url));
23
24
  const MARK = '@@supercode-apply@@';
24
25
  /** A door call's ceiling: Hermes's own functions answer in well under this. */
25
26
  const DOOR_TIMEOUT_MS = 60_000;
27
+ // Redact the complete text before truncation: a length bound must not split a token before its detector sees it.
28
+ const diagnostic = text => redactText(String(text ?? '')).slice(0, 2000);
26
29
 
27
30
  /** Hermes's schedule string for a declared schedule. */
28
31
  export function hermesSchedule(schedule) {
@@ -223,8 +226,28 @@ export async function orchestratorDoor({ root, profile = 'default', load, socket
223
226
  const op = async (name, args) => {
224
227
  const live = await viaSocket(socket ?? join(root, 'orchestrator.sock'), { op: name, args, profile });
225
228
  const answer = live ?? (() => {
226
- const run = spawnSync(process.execPath, [bin, name, '--root', root, '--profile', profile, '--json', JSON.stringify(args)], { encoding: 'utf8', timeout: DOOR_TIMEOUT_MS });
227
- return JSON.parse((run.stdout ?? '').trim().split('\n').pop() || '{"ok":false,"error":"the orchestrator answered nothing"}');
229
+ // The applier may be imported by Bun (the install's keeper). The operator CLI needs Node's runtime,
230
+ // including node:sqlite; a caller's executable is not necessarily the CLI's interpreter.
231
+ const node = process.env.SUPERCODE_NODE_BIN?.trim()
232
+ || (process.versions.bun || process.versions.deno ? 'node' : process.execPath);
233
+ const run = spawnSync(node, [bin, name, '--root', root, '--profile', profile, '--json', JSON.stringify(args)], { encoding: 'utf8', timeout: DOOR_TIMEOUT_MS });
234
+ let parsed;
235
+ try { parsed = JSON.parse((run.stdout ?? '').trim().split('\n').pop()); } catch { /* retained below */ }
236
+ if (run.error || run.signal || run.status !== 0 || typeof parsed?.ok !== 'boolean' || !parsed.ok) {
237
+ const details = [
238
+ run.error?.message && `spawn: ${diagnostic(run.error.message)}`,
239
+ run.signal && `signal ${run.signal}`,
240
+ parsed?.error && `refusal: ${diagnostic(typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error))}`,
241
+ typeof parsed?.ok !== 'boolean' && 'no readable operator response',
242
+ typeof parsed?.ok !== 'boolean' && run.stdout?.trim() && `stdout: ${diagnostic(run.stdout)}`,
243
+ run.stderr?.trim() && `stderr: ${diagnostic(run.stderr)}`,
244
+ ].filter(Boolean).join('; ');
245
+ throw Object.assign(new Error(`${name} via cold Node ${diagnostic(node)} failed (exit ${run.status ?? 'unknown'}): ${details}`), {
246
+ code: run.error?.code ?? (run.status || (parsed?.ok === false ? 'OPERATOR_REFUSED' : 'BAD_OPERATOR_RESPONSE')),
247
+ exitCode: run.status, signal: run.signal,
248
+ });
249
+ }
250
+ return parsed;
228
251
  })();
229
252
  if (!answer.ok) throw new Error(answer.error);
230
253
  return answer.result;
@@ -17,17 +17,20 @@
17
17
  // instead and opens nothing.
18
18
  //
19
19
  // RH2 is reached as the agent's own principal where the organization declares the agent (docs/adr/0017: `supercode
20
- // orchestrator apps install rh2-agent … --profile <agent>` in the home, `--root` or HERMES_HOME), so its threads, its DM
21
- // and its call are one participant; otherwise through the Room-chat app's installation for this agent (`apps install
22
- // room-chat … --profile <agent>`). The record's origin, organization, principal and token are the agent's identity in
23
- // the Room. The bridge's thread anchors are kept in `--state`.
20
+ // orchestrator apps install rh2-agent … --agent <its Teams id>` in the home, `--root` or HERMES_HOME), the record bound to
21
+ // the agent this channel names with `--agent-id`, else to the id the home holds for that agent (homeAgentId: its declared
22
+ // identity, else its profile's by the home's agent layer `agents.json`), so its threads and its call are one participant
23
+ // and no other agent's record is taken; otherwise through the Room-chat app's installation for this agent (`apps install
24
+ // room-chat … --profile <agent>`). The record's origin, organization, principal and token are the agent's identity in the
25
+ // Room. The bridge's thread anchors are kept in `--state`.
24
26
  //
25
- // The agent's DM in its Room is its main session (RFC 0020 decision 26): where this machine is in a Teams team, the
26
- // participant's seat is bound to the main session (`supercode sessions bind`, the session owner's view binding for the
27
- // Room's DM with that participant), and rebound to each new main session, as the Agents → Room agents app binds an
28
- // arc's agent. Until the main session is in the team's catalog the binding is retried.
27
+ // The agent's seat in its Room carries its main session (RFC 0020 decision 26): where this machine is in a Teams team,
28
+ // the seat is bound to the main session (`supercode sessions bind`, the session owner's view binding for the seat), and
29
+ // rebound to each new main session, as the Agents → Room agents app binds an arc's agent. Until the main session is in
30
+ // the team's catalog the binding is retried.
29
31
  import { parseArgs } from 'node:util';
30
32
  import { execFileSync } from 'node:child_process';
33
+ import { readFileSync } from 'node:fs';
31
34
  import { homedir } from 'node:os';
32
35
  import { join } from 'node:path';
33
36
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
@@ -35,14 +38,15 @@ import { Rh2Adapter } from '../adapters/rh2.mjs';
35
38
  import { AgentMailboxBridge } from '../adapters/agent-mailbox.mjs';
36
39
  import { JiraCommentsAdapter } from '../adapters/jira-comments.mjs';
37
40
  import { ChatSdkAdapter } from '../adapters/chat-sdk.mjs';
38
- import { installedApps } from '../installed-apps.mjs';
41
+ import { installedAgentRecord, installedApps } from '../installed-apps.mjs';
42
+ import { agentScope, homeAgentId } from '../agents.mjs';
39
43
 
40
44
  const { values } = parseArgs({
41
45
  args: process.argv.slice(3),
42
- options: { agent: { type: 'string' }, root: { type: 'string' }, room: { type: 'string' }, 'room-key': { type: 'string' }, 'room-name': { type: 'string' }, principal: { type: 'string' }, preset: { type: 'string' }, role: { type: 'string' }, state: { type: 'string' }, platform: { type: 'string' }, site: { type: 'string' }, jql: { type: 'string' }, 'slack-channel': { type: 'string' }, help: { type: 'boolean' } },
46
+ options: { agent: { type: 'string' }, 'agent-id': { type: 'string' }, root: { type: 'string' }, room: { type: 'string' }, 'room-key': { type: 'string' }, 'room-name': { type: 'string' }, principal: { type: 'string' }, preset: { type: 'string' }, role: { type: 'string' }, state: { type: 'string' }, platform: { type: 'string' }, site: { type: 'string' }, jql: { type: 'string' }, 'slack-channel': { type: 'string' }, help: { type: 'boolean' } },
43
47
  });
44
48
  if (values.help || !values.agent) {
45
- console.log('supercode-orchestrator agent-channel --agent <name> [--root <home>] [--room <id> | --room-key <key> [--principal <rh2 principal id> --preset <preset id> --role <role key> [--room-name <name>]]] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform slack --slack-channel <id>[,<id>] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform jira --site <url> --jql <jql> [--state <file>]');
49
+ console.log('supercode-orchestrator agent-channel --agent <name> [--agent-id <its Teams id>] [--root <home>] [--room <id> | --room-key <key> [--principal <rh2 principal id> --preset <preset id> --role <role key> [--room-name <name>]]] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform slack --slack-channel <id>[,<id>] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform jira --site <url> --jql <jql> [--state <file>]');
46
50
  process.exit(values.help ? 0 : 2);
47
51
  }
48
52
  const log = (level, line) => (level === 'debug' ? undefined : console.error(`${new Date().toISOString()} ${level} ${line}`));
@@ -78,10 +82,21 @@ if (values.platform && values.platform !== 'rh2') {
78
82
  process.on('SIGINT', stop);
79
83
  } else {
80
84
  const root = values.root ?? process.env.HERMES_HOME;
81
- const apps = root ? installedApps(root).filter((app) => app.profile === values.agent) : [];
82
- const installation = apps.find((app) => app.app === 'rh2-agent') ?? apps.find((app) => app.app === 'room-chat');
85
+ // The agent's own record only when it is this agent's (bound to its Teams id, as an agent's sessions are mounted with it):
86
+ // the id named, else the one this home holds for the agent, read and never minted.
87
+ const agentId = values['agent-id'] ?? (root ? (() => {
88
+ try {
89
+ // a named profile implies its own agent: a home with no agent layer runs the agent on the profile of its name
90
+ let layer = {};
91
+ try { layer = JSON.parse(readFileSync(join(root, 'agents.json'), 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
92
+ const { org, server } = agentScope(process.env.SUPERCODE_CONTEXT);
93
+ return homeAgentId(root, { org, server, name: values.agent, profile: layer.agents?.[values.agent]?.profile ?? values.agent });
94
+ } catch (error) { log('warn', `${values.agent}: its id in ${root} was not read (${error.message})`); return null; }
95
+ })() : null);
96
+ const own = root && agentId ? installedAgentRecord(root, agentId) : null;
97
+ const installation = own ?? (root ? installedApps(root).find((app) => app.app === 'room-chat' && app.profile === values.agent) : undefined);
83
98
  if (!installation) {
84
- console.error(`agent-channel: ${values.agent} has no RH2 identity in ${root ?? '(no home: --root or HERMES_HOME)'}; install its agent (supercode orchestrator apps install rh2-agent … --profile ${values.agent}) or Room chat for it (apps install room-chat … --profile ${values.agent})`);
99
+ console.error(`agent-channel: ${values.agent} has no RH2 identity in ${root ?? '(no home: --root or HERMES_HOME)'}; install its agent (supercode orchestrator apps install rh2-agent … --agent ${agentId ?? '<its Teams id>, and run this with --agent-id <its Teams id>'}) or Room chat for it (apps install room-chat … --profile ${values.agent})`);
85
100
  process.exit(1);
86
101
  }
87
102
  const identity = { api_url: installation.origin, organization: installation.organizationId, principal_id: installation.principalId, token: installation.token };
@@ -157,23 +172,27 @@ async function seatSession(session) {
157
172
  * is read again on every tick, so a re-declared main is bound at the next one. A bind that fails is said once per main
158
173
  * and reason (a main that is not in the team's catalog, or one no session ever ran as), never only at debug level,
159
174
  * which this process does not print. */
160
- async function bindDm() {
175
+ async function bindMainSession() {
161
176
  let main;
162
177
  try { main = [sc(['agent', 'show', values.agent, '--json'])].flat().find((a) => a.name === values.agent)?.main_session; }
163
- catch (error) { warnOnce(`agent show: ${lastLine(error)}`, `${values.agent}: its agent record was not read (${lastLine(error)}); its DM stays bound to ${boundTo ?? 'nothing'}`); return; }
178
+ catch (error) { warnOnce(`agent show: ${lastLine(error)}`, `${values.agent}: its agent record was not read (${lastLine(error)}); its seat stays bound to ${boundTo ?? 'nothing'}`); return; }
164
179
  const session = typeof main === 'string' ? main.split(':').pop() : null;
165
- if (!session) { warnOnce('no main', `${values.agent}: its agent record names no main session; its DM stays bound to ${boundTo ?? 'nothing'}`); return; }
180
+ if (!session) { warnOnce('no main', `${values.agent}: its agent record names no main session; its seat stays bound to ${boundTo ?? 'nothing'}`); return; }
166
181
  if (session === boundTo) return;
167
182
  let bound;
168
183
  try { bound = sc(['sessions', 'bind', session, '--room', roomId, '--agent', adapter.principalId(), '--rh2', adapter.apiUrl(), '--json']).data; }
169
- catch (error) { warnOnce(`${session}: ${lastLine(error)}`, `${values.agent}: its DM is not bound to its main session ${main} (exit ${error.status ?? '?'}: ${lastLine(error)}); retried every 15 s with the record read again`); return; }
184
+ catch (error) { warnOnce(`${session}: ${lastLine(error)}`, `${values.agent}: its seat is not bound to its main session ${main} (exit ${error.status ?? '?'}: ${lastLine(error)}); retried every 15 s with the record read again`); return; }
185
+ // The session by its reference (RH2 ADR 0050 decision 4); RH2 takes the registration that provides this agent's sessions.
186
+ await seatSession({ harness: bound.session.harness, reference: bound.session.reference });
170
187
  failedAs = null;
171
- await seatSession({ harness: bound.harness, sessionId: bound.sessionId, team: bound.team, teamsUrl: bound.teamsUrl });
172
188
  boundTo = session;
173
- log('info', `${values.agent}: its DM in the account Room is its main session ${session}`);
189
+ log('info', `${values.agent}: its seat in the account Room carries its main session ${session}`);
174
190
  }
175
- await bindDm();
176
- const rebinding = setInterval(() => { void bindDm().catch((error) => log('warn', `${values.agent}: binding its DM: ${error.message}`)); }, 15_000);
191
+ // A refused seat is said once per reason and retried on the next tick, the first one too: it never ends the channel (a
192
+ // refusal at start would otherwise restart the service in a loop, with the mailbox bridge down each time).
193
+ const rebind = () => bindMainSession().catch((error) => warnOnce(`seat: ${error.message}`, `${values.agent}: its seat was refused its main session (${error.message}); retried every 15 s`));
194
+ await rebind();
195
+ const rebinding = setInterval(() => { void rebind(); }, 15_000);
177
196
  const stop = async () => { clearInterval(rebinding); await bridge.stop(); await client.close?.(); process.exit(0); };
178
197
  process.on('SIGTERM', stop);
179
198
  process.on('SIGINT', stop);