@volter/supercode-orchestrator 0.5.228 → 0.5.230

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 (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
package/connection.mjs CHANGED
@@ -1,14 +1,20 @@
1
+ import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
2
+ import { registerWorkOperations, measureWork } from "@volter/supercode-harness-sdk/work-ledger";
3
+ registerWorkOperations([{"kind":"call","operation":"connection.http"}]);
4
+ import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
5
+ const execFileSync = (...args) => measureSyncChild(() => workLedgerNativeExecFileSync(...args));
6
+ const spawnSync = (...args) => measureSyncChild(() => workLedgerNativeSpawnSync(...args));
1
7
  // The kit's connection coordinator. Module manifests and programs belong to their application publisher.
2
8
  import { chmodSync, copyFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
9
  import { createHash, randomBytes } from 'node:crypto';
4
10
  import { dirname, join, resolve } from 'node:path';
5
11
  import { fileURLToPath } from 'node:url';
6
- import { execFileSync, spawn, spawnSync } from 'node:child_process';
12
+ import { execFile, execFileSync as workLedgerNativeExecFileSync, spawn, spawnSync as workLedgerNativeSpawnSync } from 'node:child_process';
7
13
  import { fromContext } from '@volter/teams/context';
8
14
  import { supercodeHome } from '@volter/teams/home';
9
15
 
10
16
  // RH2's modules, each its own program under --modules-root.
11
- export const MODULE_APPS = ['orchestration-rh2'];
17
+ export const MODULE_APPS = ['orchestration-rh2', 'project-rooms'];
12
18
  // supercode's Workplace service's registrations (supercode ADR 0041 decision 3; runhuman-2 ADR 0055 decision 3): one program,
13
19
  // sdk/teams/workplace/main.mjs, hosts them all, each with its own credentials on both sides. Their manifests ship here, so an
14
20
  // organization's file selects them by these names and `rh2 apply-org` reads them with --modules-root SERVICE_REGISTRATIONS.
@@ -17,12 +23,23 @@ const SERVICE_ROOT = new URL('./workplace/', import.meta.resolve('@volter/superc
17
23
  export const SERVICE_REGISTRATIONS = fileURLToPath(new URL('registrations/', SERVICE_ROOT));
18
24
  export const SERVICE_PROGRAM = fileURLToPath(new URL('main.mjs', SERVICE_ROOT));
19
25
  const SERVICE_TOKENS = { agents: ['AGENTS_WORKPLACE_TOKEN','AGENTS_TEAMS_TOKEN'], cards: ['CARDS_WORKPLACE_TOKEN','CARDS_TEAMS_TOKEN'], machines: ['MACHINES_WORKPLACE_TOKEN','MACHINES_TEAMS_TOKEN'] };
20
- export function privateJson(path, value) {
26
+ // The connection's modules that project the board into the Workplace and place a card's run session there (Open Autonomy's
27
+ // Project Rooms seats it in its project's Room): a session a live card's run works in is handed to their principals
28
+ // (the service's rh2.boardApps; organization.mjs keepHandover, runhuman-2 ADR 0046 decision 6).
29
+ export const BOARD_APPS = ['project-rooms'];
30
+ /** Writes `value` whole through a private temporary file and a rename, and returns the text it holds. Given the text the
31
+ * caller last wrote there (`unchanged`), an identical value writes nothing: a record saved on every event costs a write
32
+ * only when its content changed. */
33
+ export function privateJson(path, value, unchanged) {
34
+ const text=JSON.stringify(value,null,2)+'\n';
35
+ if(text===unchanged)return text;
21
36
  mkdirSync(dirname(path),{recursive:true,mode:0o700});
22
37
  const pending=`${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
23
- writeFileSync(pending,JSON.stringify(value,null,2)+'\n',{mode:0o600});renameSync(pending,path);chmodSync(path,0o600);
38
+ writeFileSync(pending,text,{mode:0o600});renameSync(pending,path);chmodSync(path,0o600);
39
+ return text;
24
40
  }
25
41
  export async function connectionCall(record, side, method, path, body, token) {
42
+ return measureWork("call", "connection.http", async () => {
26
43
  const origin=side==='teams'?record.teams.server:record.origin;
27
44
  // Registration, consent, exchange and renewal have receiver-staged random values in their bodies:
28
45
  // their digest is one operation's key, retained on a retry.
@@ -38,20 +55,33 @@ export async function connectionCall(record, side, method, path, body, token) {
38
55
  let payload;try{payload=text===''&&response.status===204?{}:JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
39
56
  if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status,detail:typeof payload.error?.message==='string'?payload.error.message:null});
40
57
  return payload;
41
- }
58
+
59
+
60
+ });
61
+ }
42
62
  /** A registration that trusts a Workplace as its issuer trusts this connection's Workplace and organization, never the
43
63
  * one its committed manifest names (agents.teams-app.json names production's): otherwise every connection's agents app
44
64
  * trusted production's tokens, and its own Workplace's notices and views were decided as the issuer app instead. */
45
65
  export function trustedIssuer(manifest,record){return manifest.issuer===undefined?{}:{issuer:record.issuer,issuer_organization:record.organization};}
46
66
  export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
67
+ export function boardAppPrincipals(root, record) {
68
+ return [...new Set(record.grants.filter(g=>BOARD_APPS.includes(g.name)&&g.rh2?.principalId&&!g.retiring&&!g.disabled
69
+ &&existsSync(join(root,'apps',`${g.name}.${record.id}.json`))).map(g=>g.rh2.principalId))].sort();
70
+ }
47
71
  export function generateModuleConfig(root, record, grant) {
48
72
  if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
49
73
  if(grant.adopted)return generateAdoptedConfig(root,record,grant);
50
74
  const folder=dirname(connectionPath(root,record.id)), dir=join(folder,grant.name);
51
75
  mkdirSync(dir,{recursive:true,mode:0o700});
52
76
  const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
53
- writeFileSync(custody,`RH2_TOKEN=${grant.rh2.accessToken}\n`,{mode:0o600});chmodSync(custody,0o600);
54
- const config={rh2:{baseUrl:record.origin,issuer:record.issuer,organization:record.organization,tokenEnv:'RH2_TOKEN'},envFile:custody,
77
+ writeFileSync(custody,`RH2_TOKEN=${grant.rh2.accessToken}\n${grant.teams?`TEAMS_TOKEN=${grant.teams.credential.token}\n`:''}`,{mode:0o600});chmodSync(custody,0o600);
78
+ const config=grant.name==='project-rooms'?{
79
+ teams:{server:record.teams.server,team:record.teams.team,source:record.source,tokenEnv:'TEAMS_TOKEN'},
80
+ rh2:{baseUrl:record.origin,issuer:record.issuer,organization:record.organization,tokenEnv:'RH2_TOKEN',principalId:grant.rh2.principalId,
81
+ agentsRegistration:record.grants.find(g=>g.name==='agents')?.clientId,cardsPrincipalId:record.grants.find(g=>g.name==='cards')?.rh2?.principalId},
82
+ ...(record.scope?{source:{namespace:`cx${record.scope}`}}:{}),envFile:custody,stateFile:join(dir,'state.json'),
83
+ board:{root,...(process.env.SUPERCODE_BIN?{supercode:process.env.SUPERCODE_BIN}:{})}
84
+ }:{rh2:{baseUrl:record.origin,issuer:record.issuer,organization:record.organization,tokenEnv:'RH2_TOKEN'},envFile:custody,
55
85
  source:{key:`connection-${record.scope}`,name:`${record.organization} / ${record.source}`,home:root,...(process.env.SUPERCODE_BIN?{program:process.env.SUPERCODE_BIN}:{}),harness:record.harness??'hermes'},
56
86
  alerts:{role:record.alertRole??'admin'}};
57
87
  privateJson(configPath,config);
@@ -66,7 +96,7 @@ function generateAdoptedConfig(root, record, grant) {
66
96
  mkdirSync(dir,{recursive:true,mode:0o700});
67
97
  const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
68
98
  writeFileSync(custody,`${grant.config.rh2.tokenEnv}=${grant.rh2.accessToken}\n${grant.teams?`${grant.config.teams.tokenEnv}=${grant.teams.credential.token}\n`:''}`,{mode:0o600});chmodSync(custody,0o600);
69
- privateJson(configPath,{...grant.config,envFile:custody,...(grant.config.stateFile?{stateFile:join(dir,'state.json')}:{})});
99
+ privateJson(configPath,{...grant.config,...(grant.name==='project-rooms'?{rh2:{...grant.config.rh2,principalId:grant.rh2.principalId,agentsRegistration:record.grants.find(g=>g.name==='agents')?.clientId??grant.config.rh2.agentsRegistration,cardsPrincipalId:record.grants.find(g=>g.name==='cards')?.rh2?.principalId??grant.config.rh2.cardsPrincipalId}}:{}),envFile:custody,...(grant.config.stateFile?{stateFile:join(dir,'state.json')}:{})});
70
100
  privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),{app:grant.name,connection:record.id,configPath,program:grant.program,adopted:true,
71
101
  origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
72
102
  return configPath;
@@ -82,11 +112,13 @@ export function generateServiceConfig(root, record, grant) {
82
112
  const held=Object.fromEntries(record.grants.filter(g=>SERVICE_APPS.includes(g.name)&&g.rh2&&g.teams).map(g=>[g.name,g]));
83
113
  writeFileSync(custody,Object.entries(held).map(([name,g])=>`${SERVICE_TOKENS[name][0]}=${g.rh2.accessToken}\n${SERVICE_TOKENS[name][1]}=${g.teams.credential.token}\n`).join(''),{mode:0o600});chmodSync(custody,0o600);
84
114
  const agents=held.agents;
115
+ const boardApps=boardAppPrincipals(root,record);
85
116
  if(agents){
86
117
  const section=name=>({registration:held[name].clientId,principal:held[name].rh2.principalId,tokenEnv:SERVICE_TOKENS[name][0],teamsTokenEnv:SERVICE_TOKENS[name][1],stateFile:join(dir,`${name}.state.json`),...record.settings?.[name]});
87
118
  const config={
88
119
  teams:{server:record.teams.server,team:record.teams.team,installation:agents.teams.installation.id,source:record.source,tokenEnv:SERVICE_TOKENS.agents[1]},
89
- rh2:{baseUrl:record.origin,organization:record.organization,registration:agents.clientId,...(record.issuer!==record.origin?{issuer:record.issuer}:{}),tokenEnv:SERVICE_TOKENS.agents[0],principal:agents.rh2.principalId},
120
+ rh2:{baseUrl:record.origin,organization:record.organization,registration:agents.clientId,...(record.issuer!==record.origin?{issuer:record.issuer}:{}),tokenEnv:SERVICE_TOKENS.agents[0],principal:agents.rh2.principalId,
121
+ ...(boardApps.length?{boardApps}:{})},
90
122
  ...(record.scope?{source:{namespace:`cx${record.scope}`}}:{}),envFile:custody,stateFile:join(dir,'agents.state.json'),
91
123
  ...record.settings?.service,
92
124
  ...(record.webhook?{webhook:record.webhook}:{}),
@@ -117,9 +149,12 @@ export const runnerStopPath=lock=>join(dirname(lock),'runner.stop');
117
149
  * through that file, and an older one is ended with its whole process tree. */
118
150
  export async function stopConnectionRunner(lock,running,wait=20000){
119
151
  if(!alive(running.pid))return true;
120
- if(process.platform!=='win32'){try{process.kill(running.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}}
152
+ if(process.platform!=='win32'){try{stopWorkChild(running.pid, () => process.kill(running.pid,'SIGTERM'), { signal: 'SIGTERM' });}catch(error){if(error.code!=='ESRCH')throw error;}}
121
153
  else if(running.tree)privateJson(runnerStopPath(lock),{pid:running.pid,at:new Date().toISOString()});
122
- else spawnSync('taskkill',['/PID',String(running.pid),'/T','/F'],{stdio:'ignore',windowsHide:true});
154
+ else stopWorkChild(running.pid, () => {
155
+ const result = spawnSync('taskkill',['/PID',String(running.pid),'/T','/F'],{stdio:'ignore',windowsHide:true});
156
+ return !result.error && result.status === 0;
157
+ }, { exitCode: 1 });
123
158
  for(let waited=0;waited<wait&&alive(running.pid);waited+=250)await new Promise(done=>setTimeout(done,250));
124
159
  return !alive(running.pid);
125
160
  }
@@ -157,6 +192,7 @@ export async function connectWorkplace(flags) {
157
192
  if(!/^[0-9a-f]{24}$/.test(ticket.scope??''))throw new Error('connection ticket needs a source namespace; apply to a new private output path');
158
193
  if(ticket.version!==1||!Array.isArray(ticket.grants)||!ticket.grants.length||ticket.grants.some(g=>!MODULE_APPS.includes(g.name)&&!SERVICE_APPS.includes(g.name))||new Set(ticket.grants.map(g=>g.name)).size!==ticket.grants.length)throw new Error(`unsupported or duplicate connection apps (connect takes ${[...MODULE_APPS,...SERVICE_APPS].join(', ')})`);
159
194
  const names=ticket.grants.map(g=>g.name);
195
+ if(names.includes('project-rooms')&&(!names.includes('agents')||!names.includes('cards')))throw new Error('a fresh Project Rooms connection selects agents and cards too');
160
196
  // The service is the agents registration's host: its other registrations run only inside it.
161
197
  if(names.some(name=>name!=='agents'&&SERVICE_APPS.includes(name))&&!names.includes('agents'))throw new Error('cards and machines run in the Workplace service, which the agents registration hosts: select agents too');
162
198
  if(names.some(name=>MODULE_APPS.includes(name))&&!flags['modules-root'])throw new Error(`--modules-root names where ${names.filter(name=>MODULE_APPS.includes(name)).join(' and ')} are`);
@@ -191,7 +227,7 @@ export async function connectWorkplace(flags) {
191
227
  let grant=record.grants.find(g=>g.name===selected.name);
192
228
  if(grant && JSON.stringify([grant.manifest,grant.teamsManifest])!==JSON.stringify([selected.manifest,selected.teamsManifest]))throw new Error('existing connection consent differs from the ticket');
193
229
  if(!grant){grant={name:selected.name,clientId:selected.clientId,manifest:selected.manifest,teamsManifest:selected.teamsManifest};record.grants.push(grant);privateJson(path,record);}
194
- if(SERVICE_APPS.includes(grant.name)&&!selected.teamsManifest)throw new Error(`${grant.name}: its Teams manifest is missing from the ticket (apply with --modules-root naming ${SERVICE_REGISTRATIONS})`);
230
+ if((SERVICE_APPS.includes(grant.name)||BOARD_APPS.includes(grant.name))&&!selected.teamsManifest)throw new Error(`${grant.name}: its Teams manifest is missing from the ticket (apply with --modules-root naming ${SERVICE_REGISTRATIONS})`);
195
231
  if(!grant.rh2){
196
232
  const recovering=typeof grant.redemption==='string';
197
233
  grant.redemption??=randomBytes(32).toString('base64url');privateJson(path,record);
@@ -221,8 +257,8 @@ export async function connectWorkplace(flags) {
221
257
  grant.teams=await connectionCall(record,'teams','POST','/api/v1/apps/token',{app_id:grant.registration.id,app_secret:grant.registration.secret,code:grant.consent.code,redirect_url:selected.teamsManifest.redirect_urls[0],credential_token:grant.stagedCredential});privateJson(path,record);
222
258
  delete grant.consent;delete grant.registration.secret;privateJson(path,record);
223
259
  }
224
- if(!SERVICE_APPS.includes(grant.name))generateModuleConfig(root,record,grant);
225
260
  }
261
+ for(const grant of record.grants.filter(g=>!SERVICE_APPS.includes(g.name)))generateModuleConfig(root,record,grant);
226
262
  // The service's config names every one of its registrations, so it is written once all of them hold credentials.
227
263
  for(const grant of record.grants.filter(g=>SERVICE_APPS.includes(g.name)))generateServiceConfig(root,record,grant);
228
264
  if(!record.issuerRegistration){record.issuerStagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);record.issuerRegistration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{name:`Workplace issuer (${id.slice(0,8)})`,description:'The Workplace as a delegated issuer, verified against its JWKS',actions:['delegation.sessions','delegation.surfaces','delegation.notices','delegation.machines'],event_kinds:[],redirect_urls:['http://127.0.0.1/connected'],issuer:record.issuer,secret:record.issuerStagedSecret},owner)).data;privateJson(path,record);}
@@ -237,7 +273,7 @@ export async function connectWorkplace(flags) {
237
273
  /** The service settings an adopted config carries as it ran (main.mjs names every key it accepts); everything else in its
238
274
  * config is rebuilt from the connection's own custody, and any key that is neither is named in the answer as dropped. */
239
275
  const ADOPTED_SETTINGS={service:['closedLanes','leadProfiles','launch','teamsPerMinute'],cards:['floorTagPrefix'],machines:[]};
240
- const REBUILT={teams:['server','team','installation','source','tokenEnv'],rh2:['baseUrl','organization','registration','issuer','tokenEnv','principal','cardsPrincipalId'],
276
+ const REBUILT={teams:['server','team','installation','source','tokenEnv'],rh2:['baseUrl','organization','registration','issuer','tokenEnv','principal','cardsPrincipalId','boardApps'],
241
277
  source:['namespace'],envFile:null,stateFile:null,webhook:['url','listen'],cards:['registration','principal','tokenEnv','teamsTokenEnv','stateFile'],machines:['registration','principal','tokenEnv','teamsTokenEnv','stateFile']};
242
278
  function dropped(config){
243
279
  const out=[];
@@ -402,7 +438,8 @@ export async function adoptWorkplaceService(flags) {
402
438
  if(existsSync(a)){copyFileSync(a,`${b}.${process.pid}.tmp`);chmodSync(`${b}.${process.pid}.tmp`,0o600);renameSync(`${b}.${process.pid}.tmp`,b);}else if(existsSync(b))unlinkSync(b);
403
439
  }
404
440
  }
405
- for(const grant of record.grants)if(SERVICE_APPS.includes(grant.name))generateServiceConfig(root,record,grant);else generateModuleConfig(root,record,grant);
441
+ for(const grant of record.grants)if(!SERVICE_APPS.includes(grant.name))generateModuleConfig(root,record,grant);
442
+ for(const grant of record.grants)if(SERVICE_APPS.includes(grant.name))generateServiceConfig(root,record,grant);
406
443
  stopped();
407
444
  record.connectedAt??=new Date().toISOString();
408
445
  record.preparing=false;record.paused=Boolean(flags['no-start']);privateJson(path,record);
@@ -427,21 +464,39 @@ export async function disconnectWorkplace(flags) {
427
464
  record.paused=true;privateJson(path,record);
428
465
  const lock=join(dirname(path),'runner.lock');
429
466
  if(existsSync(lock)){
430
- await stopConnectionRunner(lock,JSON.parse(readFileSync(lock,'utf8')));
467
+ if(!await stopConnectionRunner(lock,JSON.parse(readFileSync(lock,'utf8'))))throw new Error('the connection runner has not yielded its projection writers');
468
+ }
469
+ record.withdrawals??={};privateJson(path,record);
470
+ // Project Rooms removes its seats with its own credential, then Agents withdraws links and handovers with its own.
471
+ const owners=[...record.grants.filter(g=>BOARD_APPS.includes(g.name)),...record.grants.filter(g=>g.name==='agents')];
472
+ for(const grant of owners){
473
+ if(record.withdrawals[grant.name])continue;
474
+ const folder=dirname(path),service=SERVICE_APPS.includes(grant.name);
475
+ const config=join(folder,service?'service':grant.name,'config.json');
476
+ const program=service?SERVICE_PROGRAM:grant.program??join(record.moduleRoot,grant.name,'main.mjs');
477
+ try{
478
+ await new Promise((done,fail)=>execFile(process.execPath,[program,config,'--disconnect'],{windowsHide:true,maxBuffer:1024*1024},error=>error?fail(new Error(`${grant.name}: projection withdrawal remains incomplete; custody and state retained`)):done()));
479
+ record.withdrawals[grant.name]=new Date().toISOString();delete record.withdrawalError;privateJson(path,record);
480
+ }catch(error){record.withdrawalError={module:grant.name,at:new Date().toISOString()};privateJson(path,record);throw error;}
431
481
  }
432
482
  const team=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`, left=[];
483
+ record.departures??={};
433
484
  const leave=async(name,side,method,door,token)=>{
485
+ const key=`${name}:${side}`;
486
+ if(record.departures[key]){left.push({name,side,left:true,already:true});return;}
434
487
  try{await connectionCall(record,side,method,door,side==='rh2'?{}:undefined,token);left.push({name,side,left:true});}
435
488
  catch(error){if(error.status===404||error.code==='installation-unknown'){left.push({name,side,left:true,already:true});}else left.push({name,side,left:false,code:error.code??error.status??error.message});}
489
+ if(left.at(-1).left)record.departures[key]=true;
436
490
  privateJson(path,record);
437
491
  };
438
492
  for(const grant of record.grants){
439
493
  if(grant.teams?.installation?.id)await leave(grant.name,'teams','DELETE',`${team}/app-installations/${encodeURIComponent(grant.teams.installation.id)}`,grant.teams.credential.token);
440
494
  if(grant.rh2?.installationId)await leave(grant.name,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(grant.rh2.installationId)}/leave`,grant.rh2.accessToken);
441
- const entry=join(root,'apps',`${grant.name}.${record.id}.json`);if(existsSync(entry))unlinkSync(entry);
495
+ const entry=join(root,'apps',`${grant.name}.${record.id}.json`);if((!grant.teams||record.departures[`${grant.name}:teams`])&&(!grant.rh2||record.departures[`${grant.name}:rh2`])&&existsSync(entry))unlinkSync(entry);
442
496
  }
443
497
  // The token exchange answers {installation, credential: {token, …}} (renewTeams reads the same shape).
444
498
  if(record.issuerCredential?.installation?.id)await leave('issuer','teams','DELETE',`${team}/app-installations/${encodeURIComponent(record.issuerCredential.installation.id)}`,record.issuerCredential.credential?.token);
499
+ if(left.some(each=>!each.left)){record.left=left;privateJson(path,record);throw new Error('connection departure remains incomplete; custody and state retained for retry');}
445
500
  record.disconnectedAt=new Date().toISOString();record.left=left;privateJson(path,record);
446
501
  return {connection:record.id,root,left,registrations:[...record.grants.map(g=>g.registration?.id).filter(Boolean),record.issuerRegistration?.id].filter(Boolean)};
447
502
  }
package/gates.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
1
2
  // A fire's gate, the I/O half (docs/architecture/orchestrator.md §4.3 step 2a): the job's
2
3
  // pre-run script and its monitor source, run by the loop off the event queue. Transcribed from
3
4
  // Hermes 0.21.0: `cron/scheduler.py` `_run_job_script` / `_get_script_timeout`, and
@@ -107,7 +108,7 @@ export async function runJobScript(profile, scriptPath, workdir = null, { env =
107
108
  // SUPERCODE_FIRE_ID names the fire it runs for, so what it files can be keyed to that fire (a retried run of the
108
109
  // same fire files nothing new), not to the wall clock an interval schedule drifts against.
109
110
  const child = spawn(argv[0], argv[1], { cwd, env: { ...scriptEnv(), HERMES_HOME: profile.dir, ...(automation ? { SUPERCODE_AUTOMATION: automation } : {}), ...(fire ? { SUPERCODE_FIRE_ID: String(fire) } : {}) }, detached: true, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
110
- const kill = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ } };
111
+ const kill = () => { try { stopWorkChild(child, () => process.kill(-child.pid, 'SIGKILL'), { signal: 'SIGKILL' }); } catch { /* already gone */ } };
111
112
  const end = (result) => {
112
113
  if (finished) return;
113
114
  finished = true;
package/hand-off.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
1
2
  // THE ONE PLACE A HOME'S ORCHESTRATOR IS DECIDED (D152 row 11). Every command on a home ends in an orchestrator's entry
2
3
  // (bin/orchestrator.mjs), whichever way it came: a shell's or a pane's supercode, the native binary (its \`workflow\`,
3
4
  // \`orchestrator start\`, \`apps\`, \`access\` and its cold write door all run \`node <entry> … --root <home>\` with the
@@ -12,7 +13,7 @@ import { spawn } from 'node:child_process';
12
13
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
13
14
  import { join, resolve } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
- import { enforceSourceLock } from '@volter/supercode-teams/source-lock';
16
+ // The pinned source's check (enforceSourceLock, the host's source lock) is handed in by handOff's caller, the entry.
16
17
 
17
18
  /** The home a command (the whole of it, after the entry) names: --root, wherever it stands (the gateway's form,
18
19
  * `node bin/orchestrator.mjs --root <home>`, has no verb before it); for `import`, which writes the home it names
@@ -27,7 +28,7 @@ export function homeOfArgs(argv, env = process.env) {
27
28
 
28
29
  /** The command (\`argv\`, after node and the entry) handed to the orchestrator the home's record names: its exit code, or
29
30
  * null when this orchestrator serves it (it is the one named, or the home has no record). */
30
- export function handOff(root, argv) {
31
+ export function handOff(root, argv, enforceSourceLock) {
31
32
  if (!root) return null;
32
33
  let record;
33
34
  try { record = JSON.parse(readFileSync(join(root, 'orchestrator.json'), 'utf8')); } catch { return null; }
@@ -43,7 +44,7 @@ export function handOff(root, argv) {
43
44
  if (named === own) { process.env.SUPERCODE_BIN = supercode; return null; }
44
45
  return new Promise((done) => {
45
46
  const child = spawn(process.execPath, [named, ...argv], { stdio: 'inherit', env: { ...process.env, SUPERCODE_BIN: supercode } });
46
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
47
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => { try { stopWorkChild(child, () => child.kill(signal), { signal: signal }); } catch { /* gone */ } });
47
48
  child.on('error', (error) => { process.stderr.write(`supercode: could not run orchestrator ${record.version ?? ''} (${named}): ${error.message}\n`); done(1); });
48
49
  child.on('close', (code, signal) => done(signal ? 1 : code ?? 1));
49
50
  });
package/hermes-cli.mjs CHANGED
@@ -1,13 +1,15 @@
1
+ import { measureSyncChild, stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
2
+ const spawnSync = (...args) => measureSyncChild(() => workLedgerNativeSpawnSync(...args));
1
3
  // Hermes's own tools beside a worker (docs/plans/hermes-compat.md row 7). A home is a Hermes home, and the
2
4
  // skills it carries call Hermes's CLI (`hermes kanban …`, `hermes cron notepad …`, `hermes cron doctor`)
3
5
  // against it, so a worker of any harness runs with `HERMES_HOME` set to its profile's folder and the
4
6
  // pinned `hermes` on its `PATH`. Native cron incidents use the owning interchange codec, in the same executions store.
5
- import { spawn, spawnSync } from 'node:child_process';
7
+ import { spawn, spawnSync as workLedgerNativeSpawnSync } from 'node:child_process';
6
8
  import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
7
9
  import { delimiter, dirname, join } from 'node:path';
8
10
  import { parseDotenv } from './orchestration.mjs';
9
11
  import { locateHermes } from './apply/doors.mjs';
10
- import { recordCronIncident } from '@volter/supercode-interchange/cron-incidents';
12
+ import { recordCronIncident } from '@volter/supercode-teams/sessions-cron-incidents';
11
13
  import { redactText } from '@volter/teams/redact';
12
14
 
13
15
  /** Hermes's interpreter over `code`, `payload` on stdin, without blocking the daemon: `{ status, stdout, stderr }`. */
@@ -15,7 +17,7 @@ function python(install, code, payload, env, timeout) {
15
17
  return new Promise((done) => {
16
18
  const child = spawn(install.python, ['-c', code], { cwd: install.source, env, stdio: ['pipe', 'pipe', 'pipe'] });
17
19
  let stdout = '', stderr = '';
18
- const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
20
+ const timer = setTimeout(() => stopWorkChild(child, () => child.kill('SIGKILL'), { signal: 'SIGKILL' }), timeout);
19
21
  child.stdout.setEncoding('utf8').on('data', (c) => { stdout += c; });
20
22
  child.stderr.setEncoding('utf8').on('data', (c) => { stderr = (stderr + c).slice(-4000); });
21
23
  child.on('error', (error) => { clearTimeout(timer); done({ status: null, stdout, stderr: error.message }); });
package/hermes-locks.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
2
+ import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
3
+ const spawnSync = (...args) => measureSyncChild(() => workLedgerNativeSpawnSync(...args));
1
4
  // Hermes's two runtime locks, held the way Hermes holds them, so one home is served by one runtime and
2
5
  // one bot token by one gateway (docs/plans/hermes-compat.md, row 1; pinned Hermes gateway/status.py).
3
6
  //
@@ -9,7 +12,7 @@
9
12
  // Hermes calls a holder live when its pid exists and its command line is a gateway's (`hermes-gateway`,
10
13
  // `hermes gateway run`, `gateway/run.py`). The daemon runs as `hermes-gateway` (bin/orchestrator.mjs), so
11
14
  // Hermes reads the orchestrator's locks as a gateway's, which they are.
12
- import { spawn, spawnSync } from 'node:child_process';
15
+ import { spawn, spawnSync as workLedgerNativeSpawnSync } from 'node:child_process';
13
16
  import { createHash } from 'node:crypto';
14
17
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeSync } from 'node:fs';
15
18
  import { homedir } from 'node:os';
@@ -163,7 +166,7 @@ function holdFileLock(path, home, { python, what, tail }) {
163
166
  const line = out.split('\n')[0];
164
167
  if (line === 'ok') {
165
168
  done({ release: () => releasing ??= (async () => {
166
- child.stdin.end();
169
+ stopWorkChild(child, () => child.stdin.end());
167
170
  const result = await ended;
168
171
  if (result.code !== 0) throw new Error(`lock holder for ${path} exited ${result.code ?? result.signal}`);
169
172
  })() });
package/hosted.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { stopWorkChild } from '@volter/supercode-harness-sdk/work-ledger';
1
2
  // Host integration: the orchestrator owns time and durable fires; a trusted
2
3
  // application executes each task through its existing conversation/action door.
3
4
  import { connect } from 'node:net';
@@ -71,7 +72,7 @@ export async function stopHostedDaemon({ root, executorUrl, timeoutMs = 30_000 }
71
72
  if (!leaseIsHere(lease)) return { stopped: true, wasRunning: false };
72
73
  const alive = () => { try { process.kill(lease.pid, 0); return true; } catch (error) { if (error.code === 'ESRCH') return false; throw error; } };
73
74
  if (!alive()) return { stopped: true, wasRunning: false };
74
- process.kill(lease.pid, 'SIGTERM');
75
+ stopWorkChild(lease.pid, () => process.kill(lease.pid, 'SIGTERM'), { signal: 'SIGTERM' });
75
76
  const deadline = Date.now() + timeoutMs;
76
77
  while (alive()) {
77
78
  if (Date.now() >= deadline) throw new Error('The orchestrator has not finished stopping. Its lease and data were preserved.');
package/index.d.ts CHANGED
@@ -599,3 +599,5 @@ export function leaseIsHere(lease: Pick<OrchestratorLease, 'host' | 'boot'> | nu
599
599
  /** Whether the daemon a lease names is alive now: here, and its pid live. */
600
600
  export function leaseIsLive(lease: OrchestratorLease | null | undefined): boolean;
601
601
  export function readLease(root: string): OrchestratorLease | null;
602
+
603
+ export type { PrDispositionRecorded, PrReleaseReadback } from './ontology.js';
package/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import './source-lock.mjs';
1
+ import './source-lock.mjs'; // layer: the orchestrator package enforces its pinned source when it loads (the host's source lock); owed: the host hands the lock in
2
2
  export {
3
3
  LoadError, sessionReset, homeChannel, CHAT_TYPES, END_REASONS, FIRE_STATUSES, OBLIGATION_STATES,
4
4
  renderSurfaceKey, parseSurfaceKey, surfaceKeyString, parseTarget, renderTarget, resolveRoute, routeSpecificity,
package/kanban-worker.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // run), its memory is its harness's own, its requests are answered by supercode's unattended rule. A profile whose
9
9
  // worker is Hermes runs Hermes itself, as its harness.
10
10
  import { spawn } from 'node:child_process';
11
- import { existsSync, statSync } from 'node:fs';
11
+ import { existsSync, statSync, writeFileSync } from 'node:fs';
12
12
  import { basename, dirname, join, resolve } from 'node:path';
13
13
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
14
14
  import { load } from './orchestration.mjs';
@@ -24,6 +24,7 @@ import { DEFAULT_BOARD, openBoard, view } from './board/store.mjs';
24
24
  import { getTask } from './board/engine.mjs';
25
25
  import { permissionResponse } from './reducer.mjs';
26
26
  import { boardGuardArguments } from '@volter/teams/board-process-guard';
27
+ import { resolveNativeSessionHome } from '@volter/supercode-interchange';
27
28
 
28
29
  /** Hermes's worker argv, as `_default_spawn` builds it. */
29
30
  export function parseHermesArgv(argv) {
@@ -48,8 +49,14 @@ function rootOf(home) {
48
49
  return basename(dirname(home)) === 'profiles' ? dirname(dirname(home)) : home;
49
50
  }
50
51
 
51
- export async function runKanbanWorker(argv, env = process.env) {
52
+ // currentRelease: the machine's current release (the host's), which its caller hands in for the board's hook command.
53
+ export async function runKanbanWorker(argv, env = process.env, currentRelease) {
52
54
  const say = (line) => process.stdout.write(`${line}\n`);
55
+ const refuse = (line) => {
56
+ say(line);
57
+ if (env.SUPERCODE_BOARD_FAILURE_FILE) writeFileSync(env.SUPERCODE_BOARD_FAILURE_FILE, `${line}\n`, { mode: 0o600 });
58
+ return 78;
59
+ };
53
60
  const args = parseHermesArgv(argv);
54
61
  if (!env.HERMES_HOME || !args.query) { say('hermes-worker: HERMES_HOME and -q are required (the dispatcher sets both)'); return 2; }
55
62
  const home = resolve(env.HERMES_HOME);
@@ -57,22 +64,42 @@ export async function runKanbanWorker(argv, env = process.env) {
57
64
  const { state, vault } = await load(root);
58
65
  const profile = Object.values(state.profiles).find((p) => resolve(p.dir) === home) ?? state.profiles[args.profile ?? 'default'];
59
66
  if (!profile?.worker?.harness) { say(`hermes-worker: no worker for profile ${args.profile ?? home}`); return 2; }
67
+ const task = Object.fromEntries(KANBAN_ENV.filter((k) => env[k]).map((k) => [k, env[k]]));
60
68
  // The agent's home is already the effective profile. Hermes must not switch back to the shared template.
61
- if (profile.worker.harness === 'hermes') return runHermes(env.SUPERCODE_AGENT_ID?withoutProfile(argv):argv, env);
69
+ if (profile.worker.harness === 'hermes') {
70
+ if (env.SUPERCODE_BOARD_RESUME_SESSION) return refuse(`hermes-worker: Hermes headless continuation of ${env.SUPERCODE_BOARD_RESUME_SESSION} has no verified resume door; a deliberate disposition is required`);
71
+ try { await recordSession(root, task, 'hermes', null, await resolveNativeSessionHome('hermes', env, process.cwd())); }
72
+ catch (error) { return refuse(`hermes-worker: recording the native home on its run failed: ${error.message}`); }
73
+ return runHermes(env.SUPERCODE_AGENT_ID?withoutProfile(argv):argv, env);
74
+ }
62
75
  const harness = profile.worker.harness;
63
- const task = Object.fromEntries(KANBAN_ENV.filter((k) => env[k]).map((k) => [k, env[k]]));
64
76
  const taskId = task.HERMES_KANBAN_TASK ?? 'none';
65
77
  const workspace = env.HERMES_KANBAN_WORKSPACE;
66
78
  const cwd = workspace && existsSync(workspace) && statSync(workspace).isDirectory() ? workspace : activationCwd(profile, profile.worker.cwd);
67
79
  const worker = { ...profile.worker, ...(args.model ? { model: args.model } : {}) };
80
+ let retained = null;
81
+ if (env.SUPERCODE_BOARD_RESUME_SESSION) {
82
+ try {
83
+ retained = JSON.parse(env.SUPERCODE_BOARD_NATIVE_HOME ?? 'null');
84
+ const selected = retained?.env && await resolveNativeSessionHome(harness, retained.env, cwd);
85
+ if (!selected || JSON.stringify(selected.homes) !== JSON.stringify(retained.homes)) retained = null;
86
+ } catch { retained = null; }
87
+ if (!retained) return refuse('hermes-worker: the retained native home is unknown; nothing was resumed');
88
+ }
68
89
  if (args.toolsets) say(`hermes-worker: --toolsets ${args.toolsets} is Hermes's agent's setting, carried and not performed for ${harness}`);
69
90
 
70
91
  materializePersona(profile);
71
92
  materializeSkills(profile);
72
- const route = workerRoute(profile, worker, vault);
93
+ const route = workerRoute(profile, worker, vault, retained);
73
94
  const surfaceKey = `kanban|task|${taskId}`;
74
95
  const identityEnv=Object.fromEntries(Object.entries(env).filter(([key])=>key.startsWith('SUPERCODE_AGENT_')));
75
96
  const runEnv = { ...activationEnv({ root, profile, surfaceKey, vault }), ...route.env, ...task, ...identityEnv,HERMES_HOME: home };
97
+ if (retained) {
98
+ for (const [name, value] of Object.entries(retained.env)) {
99
+ if (value === null) { delete runEnv[name]; delete process.env[name]; }
100
+ else runEnv[name] = value;
101
+ }
102
+ }
76
103
  const memory=env.SUPERCODE_AGENT_ID?await memorySnapshot(home):'';
77
104
  // the persona, the task protocol and the pinned skills, supercode's own (board/worker-prompt.mjs)
78
105
  // the board workflow's own words for this run (its status's prompt and doors), after the task protocol
@@ -80,14 +107,17 @@ export async function runKanbanWorker(argv, env = process.env) {
80
107
 
81
108
  const client = new SupercodeHarnessClient(env.SUPERCODE_BIN ? { command: env.SUPERCODE_BIN } : {});
82
109
  try {
83
- return await runTurn({ client, profile, harness, worker, route, root, vault, surfaceKey, runEnv, instructions, task, cwd, args, say });
110
+ return await runTurn({ client, profile, harness, worker, route, root, vault, surfaceKey, runEnv, instructions, task, cwd, args, say, currentRelease, refuse, resumeSession: env.SUPERCODE_BOARD_RESUME_SESSION || null });
84
111
  } finally {
85
112
  await client.close().catch(() => {});
86
113
  }
87
114
  }
88
115
 
89
- async function runTurn({ client, profile, harness, worker, route, root, vault, surfaceKey, runEnv, instructions, task, cwd, args, say }) {
116
+ async function runTurn({ client, profile, harness, worker, route, root, vault, surfaceKey, runEnv, instructions, task, cwd, args, say, currentRelease, refuse, resumeSession }) {
90
117
  const descriptor = (await client.supportReport()).harnesses.find((h) => h.id === harness);
118
+ if (resumeSession && !descriptor?.runtime?.capabilities?.resume_session) {
119
+ return refuse(`hermes-worker: ${harness} cannot continue ${resumeSession}: its native runtime advertises no resume door; a deliberate disposition is required`);
120
+ }
91
121
  // A worker's folder that is gone (a home moved or swept) fails its launch as the harness program missing; say which.
92
122
  if (!existsSync(cwd) || !statSync(cwd).isDirectory()) { say(`hermes-worker: the worker's folder ${cwd} does not exist (the profile's worker.cwd)`); return 2; }
93
123
  const base = descriptor?.runtime?.default_launch;
@@ -95,14 +125,31 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
95
125
  const reasoning = args.reasoning && harness === 'codex' ? ['-c', `model_reasoning_effort=${JSON.stringify(args.reasoning)}`] : [];
96
126
  const launch = {
97
127
  program: base.program,
98
- arguments: boardGuardArguments(harness, [...(base.arguments ?? []), ...modelArguments(worker), ...route.args, ...(instructions && INSTRUCTION_ARGUMENTS[harness] ? INSTRUCTION_ARGUMENTS[harness](instructions) : []), ...userHomeArguments(profile), ...reasoning, ...modeArguments(profile, harness), ...limitArguments(profile, worker, (level, line) => say(line))], { enabled: ['claude-code','codex'].includes(harness), cwd }),
128
+ arguments: boardGuardArguments(harness, [...(base.arguments ?? []), ...modelArguments(worker), ...route.args, ...(instructions && INSTRUCTION_ARGUMENTS[harness] ? INSTRUCTION_ARGUMENTS[harness](instructions) : []), ...userHomeArguments(profile), ...reasoning, ...modeArguments(profile, harness), ...limitArguments(profile, worker, (level, line) => say(line))], { enabled: ['claude-code','codex'].includes(harness), cwd, currentRelease }),
99
129
  env: { ...(base.env ?? {}), ...runEnv },
100
130
  };
101
131
  declareWorkerHome({ harness, env: launch.env, cwd, log: (_level, line) => say(`hermes-worker: ${line}`) });
102
132
  const mount = mountOrchestratorMcp({ root, profile, harness, surfaceKey, launch, vault, task });
133
+ const nativeHome = await resolveNativeSessionHome(harness, { ...process.env, ...mount.launch.env }, cwd);
134
+ try { if (nativeHome) await recordSession(root, task, harness, null, nativeHome); }
135
+ catch (error) { return refuse(`hermes-worker: recording the native home on its run failed: ${error.message}`); }
103
136
  const policy = approvalPolicy(profile, harness), reviewer = approvalsReviewer(profile, harness);
104
- const runtime = await client.startManagedRuntime({ harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}), ...(reviewer ? { approvals_reviewer: reviewer } : {}) });
105
- try { await recordSession(root, task, harness, runtime.handle.runtime_id); } catch (error) { say(`hermes-worker: recording the session on its run: ${error.message}`); }
137
+ const params = { harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}), ...(reviewer ? { approvals_reviewer: reviewer } : {}) };
138
+ let runtime;
139
+ try {
140
+ runtime = resumeSession ? await client.resumeManagedRuntime({ ...params, runtime_id: resumeSession }) : await client.startManagedRuntime(params);
141
+ } catch (error) {
142
+ const failure = `hermes-worker: ${resumeSession ? `native resume of ${resumeSession}` : 'native start'} failed: ${error.message}`;
143
+ if (resumeSession) return refuse(failure);
144
+ say(failure); return 1;
145
+ }
146
+ try {
147
+ if (resumeSession && runtime.handle.runtime_id !== resumeSession) throw new Error(`native resume returned ${runtime.handle.runtime_id}, expected ${resumeSession}`);
148
+ await recordSession(root, task, harness, runtime.handle.runtime_id);
149
+ } catch (error) {
150
+ await runtime.close().catch(() => {});
151
+ return refuse(`hermes-worker: recording the session on its run failed: ${error.message}`);
152
+ }
106
153
  let text = '', last = '';
107
154
  // `agent.max_turns` for a worker that takes no cap at launch, held as the daemon holds it (loop.mjs `holdTurnLimit`)
108
155
  const counter = turnCounter(profile, harness);
@@ -187,15 +234,18 @@ function cardWords(root, task) {
187
234
  * dispatcher recorded): the harness's own transcript, found and resumed by supercode like any session. Written by the
188
235
  * board's owner, as every board write is (`workflow run-session`).
189
236
  */
190
- async function recordSession(root, task, harness, sessionId) {
191
- if (!task.HERMES_KANBAN_RUN_ID || !sessionId) return;
237
+ async function recordSession(root, task, harness, sessionId, nativeHome = null) {
238
+ if (!task.HERMES_KANBAN_RUN_ID || (!sessionId && !nativeHome)) return;
192
239
  const answer = await askBoardOwner(root, {
193
- argv: ['run-session', String(task.HERMES_KANBAN_RUN_ID), String(sessionId), '--harness', harness, '--task', String(task.HERMES_KANBAN_TASK ?? '')],
240
+ argv: ['run-session', String(task.HERMES_KANBAN_RUN_ID), ...(sessionId ? [String(sessionId)] : []), '--harness', harness, '--task', String(task.HERMES_KANBAN_TASK ?? ''), ...(nativeHome ? ['--native-home', JSON.stringify(nativeHome)] : [])],
194
241
  board: task.HERMES_KANBAN_BOARD || null, caller: null, session: null, cwd: process.cwd(), stdin: null,
195
242
  facts: { ...callerFacts(), SUPERCODE_CONTEXT: process.env.SUPERCODE_CONTEXT || 'local' },
196
243
  });
197
244
  if (answer.unanswered === true) throw new Error(unansweredText(answer, `the run's session record (run-session ${task.HERMES_KANBAN_RUN_ID})`).trim());
198
245
  if (answer.code) throw new Error((answer.stderr || 'the board owner did not record it').trim());
246
+ let result;
247
+ try { result = JSON.parse(answer.stdout ?? '{}'); } catch { /* text output is allowed */ }
248
+ if (result?.error) throw new Error(String(result.error));
199
249
  }
200
250
 
201
251
  /** A profile whose worker is Hermes: the dispatcher's own command, run as Hermes would have run it. */