@volter/supercode-orchestrator 0.5.225 → 0.5.227

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/connection.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  // The kit's connection coordinator. Module manifests and programs belong to their application publisher.
2
- import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { spawn } from 'node:child_process';
6
+ import { execFileSync, spawn, spawnSync } from 'node:child_process';
7
7
  import { fromContext } from '@volter/teams/context';
8
8
  import { supercodeHome } from '@volter/teams/home';
9
9
 
@@ -36,7 +36,7 @@ export async function connectionCall(record, side, method, path, body, token) {
36
36
  const text=await response.text();
37
37
  record.calls??=[];record.calls.push({side,method,path,status:response.status,ms:Date.now()-began,bytes:Buffer.byteLength(text)});record.calls=record.calls.slice(-200);
38
38
  let payload;try{payload=text===''&&response.status===204?{}:JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
39
- if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status});
39
+ 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
40
  return payload;
41
41
  }
42
42
  /** A registration that trusts a Workplace as its issuer trusts this connection's Workplace and organization, never the
@@ -46,6 +46,7 @@ export function trustedIssuer(manifest,record){return manifest.issuer===undefine
46
46
  export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
47
47
  export function generateModuleConfig(root, record, grant) {
48
48
  if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
49
+ if(grant.adopted)return generateAdoptedConfig(root,record,grant);
49
50
  const folder=dirname(connectionPath(root,record.id)), dir=join(folder,grant.name);
50
51
  mkdirSync(dir,{recursive:true,mode:0o700});
51
52
  const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
@@ -58,8 +59,22 @@ export function generateModuleConfig(root, record, grant) {
58
59
  origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
59
60
  return configPath;
60
61
  }
62
+ /** An adopted module's config is its own, as it ran (adoptWorkplaceService): only its custody (the token names it reads,
63
+ * now holding the connection's current tokens) and its state move into the connection. */
64
+ function generateAdoptedConfig(root, record, grant) {
65
+ const dir=join(dirname(connectionPath(root,record.id)),grant.name);
66
+ mkdirSync(dir,{recursive:true,mode:0o700});
67
+ const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
68
+ 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')}:{})});
70
+ privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),{app:grant.name,connection:record.id,configPath,program:grant.program,adopted:true,
71
+ origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
72
+ return configPath;
73
+ }
61
74
  /** The Workplace service's config and custody, written from the connection alone (no hand-written config): every installed
62
- * service registration's credentials, ids and state, and the home's registry entry for `grant`. One config serves all. */
75
+ * service registration's credentials, ids and state, and the home's registry entry for `grant`. One config serves all.
76
+ * A connection's keys are made under its namespace (`cx<scope>`); an adopted service (adoptWorkplaceService) has no scope
77
+ * and keeps the unqualified keys its installations have always written, with the settings it ran with. */
63
78
  export function generateServiceConfig(root, record, grant) {
64
79
  const folder=dirname(connectionPath(root,record.id)), dir=join(folder,'service');
65
80
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -68,18 +83,23 @@ export function generateServiceConfig(root, record, grant) {
68
83
  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);
69
84
  const agents=held.agents;
70
85
  if(agents){
71
- 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`)});
86
+ 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]});
72
87
  const config={
73
88
  teams:{server:record.teams.server,team:record.teams.team,installation:agents.teams.installation.id,source:record.source,tokenEnv:SERVICE_TOKENS.agents[1]},
74
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},
75
- source:{namespace:`cx${record.scope}`},envFile:custody,stateFile:join(dir,'agents.state.json'),
90
+ ...(record.scope?{source:{namespace:`cx${record.scope}`}}:{}),envFile:custody,stateFile:join(dir,'agents.state.json'),
91
+ ...record.settings?.service,
76
92
  ...(record.webhook?{webhook:record.webhook}:{}),
77
93
  ...(held.cards?{cards:section('cards')}:{}),...(held.machines?{machines:section('machines')}:{}),
78
94
  };
79
95
  privateJson(configPath,config);
80
96
  }
81
- privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),{app:grant.name,connection:record.id,configPath,program:SERVICE_PROGRAM,
82
- origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
97
+ const entry=g=>({app:g.name,connection:record.id,configPath,program:SERVICE_PROGRAM,
98
+ origin:record.origin,organizationId:record.organization,principalId:g.rh2.principalId,installationId:g.rh2.installationId,expiresAt:g.rh2.expiresAt});
99
+ privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),entry(grant));
100
+ // The one config restarts for every renewal, so each registration's entry shows its own current expiry (`apps list`); an
101
+ // entry that is gone stays gone (it is what stops that registration).
102
+ for(const other of Object.values(held))if(other.name!==grant.name&&existsSync(join(root,'apps',`${other.name}.${record.id}.json`)))privateJson(join(root,'apps',`${other.name}.${record.id}.json`),entry(other));
83
103
  return configPath;
84
104
  }
85
105
  export function startConnectionRunner(root,id) {
@@ -89,8 +109,49 @@ export function startConnectionRunner(root,id) {
89
109
  const child=spawn(process.execPath,[fileURLToPath(new URL('./bin/connection-runner.mjs',import.meta.url)),'--root',root,'--connection',id],{detached:true,stdio:['ignore',fd,fd],env:process.env});
90
110
  closeSync(fd);child.unref();return child.pid;
91
111
  }
112
+ /** The file a runner that names its tree in its lock watches for its stop: Windows has no signal a process can catch. */
113
+ export const runnerStopPath=lock=>join(dirname(lock),'runner.stop');
114
+ /** Stop a connection's runner as it stops itself, its modules first and then its lock, and answer whether it is gone
115
+ * within the wait. POSIX delivers SIGTERM to its handler. On Windows process.kill ends the runner alone and leaves its
116
+ * modules serving with nobody stopping them, so a runner whose lock names its tree (it watches runnerStopPath) is asked
117
+ * through that file, and an older one is ended with its whole process tree. */
118
+ export async function stopConnectionRunner(lock,running,wait=20000){
119
+ 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;}}
121
+ 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});
123
+ for(let waited=0;waited<wait&&alive(running.pid);waited+=250)await new Promise(done=>setTimeout(done,250));
124
+ return !alive(running.pid);
125
+ }
92
126
  export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string'&&!record.disconnectedAt;}
127
+ /** The Teams team a connection installs into and the admin credential that installs there: an explicit local credential
128
+ * (useful for an owned stack), else the existing Teams context. */
129
+ async function teamsAccess(flags) {
130
+ if(flags['teams-token-file']){
131
+ if(!flags['teams-url']||!flags.team)throw new Error('--teams-token-file needs --teams-url and --team');
132
+ return {teams:{server:new URL(flags['teams-url']).origin,team:flags.team},owner:readFileSync(flags['teams-token-file'],'utf8').trim()};
133
+ }
134
+ // An agent session connects as its agent: its delegated context, never the person's who signed the box in (the
135
+ // Teams verbs' rule, agentContextName). A person's context named from an agent session is refused, as a grant is.
136
+ const {WorkspaceContexts}=await import('@volter/teams/context');
137
+ const {agentSessionEnvironment,agentSessionHint,agentContextName,doorGlass}=await import('@volter/supercode-teams/agent-session');
138
+ const contexts=new WorkspaceContexts(supercodeHome());
139
+ // Asked of the daemon whatever the environment says: a resumed session can lack its launch's SUPERCODE_AGENT_ID,
140
+ // and the daemon's retained binding is what names its agent (workspace.mjs does the same for every Teams verb).
141
+ // On purpose (SUPERCODE_BREAK_GLASS="<reason>"), its guards let the connection act for another and record it.
142
+ const glass=doorGlass(process.env,'orchestrator connect');
143
+ const env=await agentSessionEnvironment(process.env,agentSessionHint(process.env),glass);
144
+ const teamsContext=agentContextName(contexts,env,flags.context??null,glass);
145
+ const {context}=fromContext({supercodeHome:supercodeHome(),name:teamsContext});
146
+ return {teams:{server:context.origin,team:context.team_id},owner:contexts.credential(context.credential_ref).token};
147
+ }
148
+ function exactOrigins(origins){
149
+ for(const origin of origins){
150
+ const url=new URL(origin);if(url.origin!==origin||url.username||url.password||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw new Error('connection endpoints must be exact HTTPS origins or loopback HTTP');
151
+ }
152
+ }
93
153
  export async function connectWorkplace(flags) {
154
+ if(flags.adopt||flags['adopt-module'])return adoptWorkplaceService(flags);
94
155
  if(!flags.root||!flags['ticket-file']||!flags.source)throw new Error('connect needs --root, --ticket-file and --source (one board event source)');
95
156
  const root=resolve(flags.root), ticket=JSON.parse(readFileSync(flags['ticket-file'],'utf8'));
96
157
  if(!/^[0-9a-f]{24}$/.test(ticket.scope??''))throw new Error('connection ticket needs a source namespace; apply to a new private output path');
@@ -101,27 +162,8 @@ export async function connectWorkplace(flags) {
101
162
  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`);
102
163
  if(Boolean(flags['webhook-url'])!==Boolean(flags['webhook-listen']))throw new Error('--webhook-url and --webhook-listen go together: where the Workplace sends the agents registration\'s events, and the loopback address the service listens on');
103
164
  const moduleRoot=flags['modules-root']?resolve(flags['modules-root']):null;
104
- // An explicit local credential input is useful for an owned stack; a normal connect uses its existing Teams context.
105
- let teams,owner;
106
- if(flags['teams-token-file']){
107
- if(!flags['teams-url']||!flags.team)throw new Error('--teams-token-file needs --teams-url and --team');
108
- teams={server:new URL(flags['teams-url']).origin,team:flags.team};owner=readFileSync(flags['teams-token-file'],'utf8').trim();
109
- }else{
110
- // An agent session connects as its agent: its delegated context, never the person's who signed the box in (the
111
- // Teams verbs' rule, agentContextName). A person's context named from an agent session is refused, as a grant is.
112
- const {WorkspaceContexts}=await import('@volter/teams/context');
113
- const {agentSessionEnvironment,agentSessionHint,agentContextName}=await import('@volter/supercode-teams/agent-session');
114
- const contexts=new WorkspaceContexts(supercodeHome());
115
- // Asked of the daemon whatever the environment says: a resumed session can lack its launch's SUPERCODE_AGENT_ID,
116
- // and the daemon's retained binding is what names its agent (workspace.mjs does the same for every Teams verb).
117
- const env=await agentSessionEnvironment(process.env,agentSessionHint(process.env));
118
- const teamsContext=agentContextName(contexts,env,flags.context??null);
119
- const {context}=fromContext({supercodeHome:supercodeHome(),name:teamsContext});
120
- teams={server:context.origin,team:context.team_id};owner=contexts.credential(context.credential_ref).token;
121
- }
122
- for(const origin of [ticket.origin,ticket.issuer,teams.server]){
123
- const url=new URL(origin);if(url.origin!==origin||url.username||url.password||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw new Error('connection endpoints must be exact HTTPS origins or loopback HTTP');
124
- }
165
+ const {teams,owner}=await teamsAccess(flags);
166
+ exactOrigins([ticket.origin,ticket.issuer,teams.server]);
125
167
  const id=createHash('sha256').update(`${ticket.scope}\0${ticket.issuer}\0${ticket.organization}\0${teams.server}\0${teams.team}\0${flags.source}`).digest('hex').slice(0,24);
126
168
  const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
127
169
  if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('connection ticket expired; apply the organization file again');
@@ -192,6 +234,184 @@ export async function connectWorkplace(flags) {
192
234
  return {connection:id,root,modules:record.grants.map(g=>g.name),runner:pid,calls:record.calls.map(({side,method,path,status,ms,bytes})=>({side,method,path,status,ms,bytes}))};
193
235
  }
194
236
 
237
+ /** The service settings an adopted config carries as it ran (main.mjs names every key it accepts); everything else in its
238
+ * config is rebuilt from the connection's own custody, and any key that is neither is named in the answer as dropped. */
239
+ 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'],
241
+ source:['namespace'],envFile:null,stateFile:null,webhook:['url','listen'],cards:['registration','principal','tokenEnv','teamsTokenEnv','stateFile'],machines:['registration','principal','tokenEnv','teamsTokenEnv','stateFile']};
242
+ function dropped(config){
243
+ const out=[];
244
+ for(const [key,value] of Object.entries(config)){
245
+ if(ADOPTED_SETTINGS.service.includes(key))continue;
246
+ if(!Object.hasOwn(REBUILT,key)){out.push(key);continue;}
247
+ if(REBUILT[key]===null||value===null||typeof value!=='object')continue;
248
+ for(const inner of Object.keys(value))if(!REBUILT[key].includes(inner)&&!(ADOPTED_SETTINGS[key]??[]).includes(inner))out.push(`${key}.${inner}`);
249
+ }
250
+ return out;
251
+ }
252
+ const envValues=path=>Object.fromEntries(readFileSync(path,'utf8').split('\n').filter(line=>line.includes('=')).map(line=>[line.slice(0,line.indexOf('=')),line.slice(line.indexOf('=')+1)]));
253
+ function alive(pid){if(!Number.isInteger(pid)||pid<=0)return false;try{process.kill(pid,0);return true;}catch(error){if(error.code==='ESRCH')return false;throw error;}}
254
+ /** The pids of processes whose arguments name `path` (a config only its own program is started with). */
255
+ function runningWith(path){
256
+ if(process.platform==='win32')return [];
257
+ return execFileSync('/bin/ps',['-axww','-o','pid=,command='],{encoding:'utf8',timeout:5000}).split('\n')
258
+ .map(line=>line.trim().match(/^(\d+)\s+(.*)$/)).filter(Boolean).filter(([,pid,command])=>Number(pid)!==process.pid&&` ${command} `.includes(` ${path} `)).map(([,pid])=>Number(pid));
259
+ }
260
+ /** Every `…Env` field of a config, by its dotted path: the custody names its program reads. */
261
+ function envFields(value,at=''){
262
+ if(value===null||typeof value!=='object'||Array.isArray(value))return [];
263
+ return Object.entries(value).flatMap(([key,inner])=>/Env$/.test(key)&&typeof inner==='string'?[[`${at}${key}`,inner]]:envFields(inner,`${at}${key}.`));
264
+ }
265
+ // A Teams installation's credential lives TOKEN_MS (sdk/volter-teams/server/apps.mjs, 90 days) from its exchange, which
266
+ // follows the installation's (re)creation within its ten-minute code: created_at plus that lifetime is its earliest end.
267
+ const TEAMS_CREDENTIAL_MS=90*86400000;
268
+
269
+ /** `connect --adopt <process.json>` and `--adopt-module NAME=CONFIG`: programs already running on their own installations
270
+ * (the Workplace service from its process record, a module from its config, each with hand-written custody) move under
271
+ * this home's installed-app runner as the connection of the installations they already hold. Nothing is registered,
272
+ * consented or exchanged: the adoption ticket (`rh2 apply-org … --connection-out … --adopt …`) names each registration's
273
+ * one active installation, and each token proves itself through its own installation's doors (RH2's renew, which keeps
274
+ * the expiry outside its last seven days and answers which installation and principal the token is; Teams' /me, which
275
+ * names the token's installation, that installation read with the token itself, and its renew door asked with the
276
+ * credential as its own replacement, which only an installation root is answered for and which changes nothing). No
277
+ * admin is needed: the Teams context only names the team. There is no scope: the same installations keep writing
278
+ * the keys they always have, so nothing is made twice and no history changes hands (ADR 0032, "Adoption"). Every program
279
+ * adopted must be stopped, and is checked again before its state is copied and before the runner starts: one writer at a
280
+ * time. State is copied into the connection until the connection first runs, the originals kept as they were. No
281
+ * credential is renewed by adopting: the old custody stays usable for a way back until the first renewal, in the last
282
+ * seven days. A Teams credential's expiry was never kept by hand, so it is its earliest possible end (TEAMS_CREDENTIAL_MS). */
283
+ export async function adoptWorkplaceService(flags) {
284
+ if(!flags.root||!flags['ticket-file'])throw new Error('connect --adopt needs --root and --ticket-file (the ticket of rh2 apply-org … --connection-out … --adopt)');
285
+ for(const flag of ['source','webhook-url','webhook-listen','alert-role'])if(flags[flag]!==undefined)throw new Error(`connect --adopt takes no --${flag}: the adopted programs' own configs name it`);
286
+ const root=resolve(flags.root), ticket=JSON.parse(readFileSync(flags['ticket-file'],'utf8'));
287
+ const processPath=flags.adopt?realpathSync(resolve(flags.adopt)):null;
288
+ const pairs=(list,flag)=>Object.fromEntries([].concat(list??[]).map(value=>{const at=value.indexOf('=');if(at<1)throw new Error(`--${flag} is NAME=PATH`);return [value.slice(0,at),resolve(value.slice(at+1))];}));
289
+ const moduleConfigs=pairs(flags['adopt-module'],'adopt-module'), programs=pairs(flags['adopt-program'],'adopt-program');
290
+ if(Object.keys(programs).some(name=>!moduleConfigs[name]))throw new Error('--adopt-program names a module that --adopt-module adopts');
291
+ if(!processPath&&!Object.keys(moduleConfigs).length)throw new Error('connect --adopt names the service\'s process record, --adopt-module NAME=CONFIG each module, or both');
292
+ if(ticket.version!==1||ticket.adopt!==true||ticket.scope!==undefined||!Array.isArray(ticket.grants)||!ticket.grants.length)throw new Error('connect --adopt takes an adoption ticket (rh2 apply-org … --connection-out … --adopt): it names existing installations and has no scope');
293
+ const names=ticket.grants.map(g=>g.name);
294
+ if(new Set(names).size!==names.length)throw new Error('an adoption ticket names each app once');
295
+ for(const g of ticket.grants)if(!/^app_[a-f0-9]{16}$/.test(g.clientId??'')||!/^prn_[a-z0-9]+$/.test(g.principalId??'')||!/^installation_[a-z0-9]+$/.test(g.installationId??'')||!Array.isArray(g.manifest?.actions))throw new Error(`${g.name}: the adoption ticket names its registration, principal, installation and consented actions`);
296
+ if(Object.keys(moduleConfigs).some(name=>SERVICE_APPS.includes(name)))throw new Error(`${SERVICE_APPS.join(', ')} run in the Workplace service: adopt it with --adopt <its process record>`);
297
+ // What each grant runs as now: a section of the service's config, or a module's own config.
298
+ const service=processPath?JSON.parse(readFileSync(processPath,'utf8')):null, config=service?JSON.parse(readFileSync(service.configPath,'utf8')):null;
299
+ const sections=config?{agents:{registration:config.rh2?.registration,principal:config.rh2?.principal,tokenEnv:config.rh2?.tokenEnv,teamsTokenEnv:config.teams?.tokenEnv,stateFile:config.stateFile},
300
+ ...(config.cards?{cards:config.cards}:{}),...(config.machines?{machines:config.machines}:{})}:{};
301
+ const modules={};
302
+ for(const [name,path] of Object.entries(moduleConfigs)){
303
+ const own=JSON.parse(readFileSync(path,'utf8')), fields=envFields(own);
304
+ const unknown=fields.filter(([at])=>!['rh2.tokenEnv','teams.tokenEnv'].includes(at));
305
+ if(unknown.length)throw new Error(`${name}: ${unknown.map(([at])=>at).join(', ')} name custody adopt does not renew`);
306
+ if(!own.rh2?.tokenEnv||!own.envFile)throw new Error(`${name}: its config names no rh2.tokenEnv and envFile`);
307
+ const program=programs[name]??(flags['modules-root']?join(resolve(flags['modules-root']),name,'main.mjs'):null);
308
+ if(!program||!existsSync(program))throw new Error(`${name}: its program is --adopt-program ${name}=PATH or <--modules-root>/${name}/main.mjs`);
309
+ modules[name]={path,config:own,program:realpathSync(program),tokenEnv:own.rh2.tokenEnv,teamsTokenEnv:own.teams?.tokenEnv,stateFile:own.stateFile,principal:own.rh2.principalId};
310
+ }
311
+ const expected=[...Object.keys(sections),...Object.keys(modules)].sort().join(',');
312
+ if(expected!==[...names].sort().join(','))throw new Error(`the ticket names ${names.join(', ')}; the programs adopted run ${expected}: adopt exactly those`);
313
+ if(Object.keys(sections).length&&!sections.agents)throw new Error('the service is the agents registration\'s host');
314
+ for(const g of ticket.grants){
315
+ const section=sections[g.name];
316
+ if(section&&(section.registration!==g.clientId||(section.principal!==undefined&&section.principal!==g.principalId)))throw new Error(`${g.name}: the ticket's registration ${g.clientId} (${g.principalId}) is not the one the service runs (${section.registration})`);
317
+ if(modules[g.name]?.principal!==undefined&&modules[g.name].principal!==g.principalId)throw new Error(`${g.name}: the ticket's principal ${g.principalId} is not the one its config names (${modules[g.name].principal})`);
318
+ }
319
+ if(config){
320
+ if(ticket.organization!==config.rh2.organization)throw new Error(`the ticket is for ${ticket.organization}; the service writes ${config.rh2.organization}`);
321
+ // The service's state is bound to the Workplace origin its tokens are issued by (main.mjs identity): the same one.
322
+ if(ticket.issuer!==new URL(config.rh2.issuer??config.rh2.baseUrl).origin)throw new Error(`the ticket's issuer ${ticket.issuer} is not the service's Workplace (${new URL(config.rh2.issuer??config.rh2.baseUrl).origin}); apply with --issuer naming it`);
323
+ if(config.source?.namespace)throw new Error('this service already writes under a connection namespace; adopt takes a service with the unqualified keys');
324
+ if(config.webhook?.secretEnv)throw new Error('adopt does not carry webhook.secretEnv; the adopted service keeps its webhook secret in its state');
325
+ }
326
+ for(const [name,m] of Object.entries(modules))if(m.config.rh2.organization!==ticket.organization)throw new Error(`${name}: its config writes ${m.config.rh2.organization}, not ${ticket.organization}`);
327
+ // One writer at a time: nothing adopted may still run, asked again before its state is copied and before the runner starts.
328
+ const stopped=()=>{
329
+ if(service&&alive(service.servicePid))throw new Error(`the service ${processPath} names still runs (pid ${service.servicePid}): stop it and whatever restarts it first (one writer at a time)`);
330
+ for(const path of new Set([...(service?[service.configPath,realpathSync(service.configPath)]:[]),...Object.values(modules).flatMap(m=>[m.path,realpathSync(m.path)])])){const pids=runningWith(path);if(pids.length)throw new Error(`pid ${pids.join(', ')} still runs with ${path}: stop it and whatever restarts it first (one writer at a time)`);}
331
+ };
332
+ stopped();
333
+ // The context names the team only: no call is made with its credential, so a member's context adopts as an admin's does.
334
+ const {teams}=await teamsAccess(flags);
335
+ exactOrigins([ticket.origin,ticket.issuer,teams.server]);
336
+ if(config&&(teams.server!==new URL(config.teams.server).origin||teams.team!==config.teams.team))throw new Error(`the Teams context is ${teams.server} ${teams.team}; the service reads ${config.teams.server} ${config.teams.team}`);
337
+ for(const [name,m] of Object.entries(modules))if(m.teamsTokenEnv&&(teams.server!==new URL(m.config.teams.server).origin||teams.team!==m.config.teams.team))throw new Error(`${name}: the Teams context is ${teams.server} ${teams.team}; it reads ${m.config.teams.server} ${m.config.teams.team}`);
338
+ const held={};
339
+ for(const [name,s] of Object.entries(sections))held[name]={tokenEnv:s.tokenEnv,teamsTokenEnv:s.teamsTokenEnv,stateFile:s.stateFile,env:envValues(config.envFile),envFile:config.envFile};
340
+ for(const [name,m] of Object.entries(modules))held[name]={tokenEnv:m.tokenEnv,teamsTokenEnv:m.teamsTokenEnv,stateFile:m.stateFile,env:envValues(m.config.envFile),envFile:m.config.envFile};
341
+ for(const [name,h] of Object.entries(held))for(const key of [h.tokenEnv,h.teamsTokenEnv].filter(Boolean))if(!h.env[key])throw new Error(`${name}: ${key} is not in ${h.envFile}`);
342
+ const source=config?.teams.source??null;
343
+ const id=createHash('sha256').update(`adopt\0${processPath??''}\0${Object.entries(moduleConfigs).sort().map(([n,p])=>`${n}=${p}`).join('\0')}\0${ticket.issuer}\0${ticket.organization}\0${teams.server}\0${teams.team}\0${source??''}`).digest('hex').slice(0,24);
344
+ const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
345
+ if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('adoption ticket expired; apply the organization file again');
346
+ if(prior?.disconnectedAt)throw new Error('this connection was disconnected; it is never adopted again');
347
+ if(prior&&((prior.adopted?.process??null)!==processPath||prior.selection.join(',')!==names.join(',')))throw new Error('the existing connection adopted other programs or another selection');
348
+ const lock=join(dirname(path),'runner.lock');
349
+ if(prior&&existsSync(lock)){
350
+ const running=JSON.parse(readFileSync(lock,'utf8'));
351
+ if(alive(running.pid)){if(flags['no-start'])throw new Error('stop this connection runner before preparing an owner handover');return {connection:id,root,adopted:processPath,modules:prior.grants.map(g=>g.name),runner:running.pid,calls:[]};}
352
+ }
353
+ const carry=(from,keys)=>Object.fromEntries(keys.filter(key=>from?.[key]!==undefined).map(key=>[key,from[key]]));
354
+ const record=prior??{version:1,id,adopted:{process:processPath,modules:moduleConfigs,source:service?.source??null,at:new Date().toISOString()},origin:ticket.origin,issuer:ticket.issuer,organization:ticket.organization,teams,
355
+ source,selection:names,moduleRoot:null,...(config?.webhook?{webhook:{url:config.webhook.url,listen:config.webhook.listen}}:{}),alertRole:'admin',
356
+ ...(config?{settings:{service:carry(config,ADOPTED_SETTINGS.service),cards:carry(config.cards,ADOPTED_SETTINGS.cards)}}:{}),grants:[]};
357
+ record.root=root;
358
+ record.preparing=true;record.paused=true;
359
+ privateJson(path,record);
360
+ for(const selected of ticket.grants){
361
+ const h=held[selected.name];
362
+ let grant=record.grants.find(g=>g.name===selected.name);
363
+ if(!grant){grant={name:selected.name,clientId:selected.clientId,adopted:true,manifest:selected.manifest,teamsManifest:selected.teamsManifest??null,
364
+ ...(modules[selected.name]?{program:modules[selected.name].program,config:modules[selected.name].config}:{})};record.grants.push(grant);privateJson(path,record);}
365
+ if(!grant.rh2){
366
+ // Only this installation's current token is answered here; outside the last seven days the expiry stays as it is.
367
+ const renewed=(await connectionCall(record,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(selected.installationId)}/renew`,{},h.env[h.tokenEnv])).data;
368
+ if(renewed.installationId!==selected.installationId||renewed.principalId!==selected.principalId||renewed.organizationId!==ticket.organization)throw new Error(`${selected.name}: ${h.tokenEnv} answers as ${renewed.principalId} on ${renewed.installationId}, not the ticket's installation`);
369
+ grant.rh2={accessToken:renewed.accessToken,expiresAt:renewed.expiresAt,installationId:renewed.installationId,organizationId:renewed.organizationId,principalId:renewed.principalId};privateJson(path,record);
370
+ }
371
+ if(h.teamsTokenEnv&&!grant.teams){
372
+ // The token names its own installation (/me) and reads it in the team (a credential of another team is refused, and
373
+ // one whose installation or app is no longer active authenticates as no one): the installation's own doors, no admin's.
374
+ const token=h.env[h.teamsTokenEnv], me=await connectionCall(record,'teams','GET','/api/v1/me',undefined,token);
375
+ if(!me.installation_id)throw new Error(`${selected.name}: ${h.teamsTokenEnv} is no installation's credential`);
376
+ const installation=(await connectionCall(record,'teams','GET',`/api/v1/teams/${encodeURIComponent(teams.team)}/app-installations/${encodeURIComponent(me.installation_id)}`,undefined,token)).data;
377
+ if(installation.team_id!==teams.team||installation.principal_id!==me.principal?.id||installation.state!=='active')throw new Error(`${selected.name}: ${h.teamsTokenEnv} is no active installation of team ${teams.team}`);
378
+ if(selected.name==='agents'&&installation.id!==config.teams.installation)throw new Error(`agents: ${h.teamsTokenEnv} is installation ${installation.id}, not the service's ${config.teams.installation}`);
379
+ // Renewal is answered only for an installation's root credential (issuer-connection.mjs root()); asked with itself as
380
+ // its replacement, an unrenewed root with no predecessor is refused as "this root has no renewal predecessor" and
381
+ // nothing changes. Any other answer refuses the adoption: a non-root is forbidden, and a root already renewed (its
382
+ // replacement elsewhere, its own expiry cut to five minutes) is "already has a replacement".
383
+ let probe=null;
384
+ try{await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/app-installations/${encodeURIComponent(installation.id)}/renew`,{replacement_token:token},token);probe='answered';}
385
+ catch(error){probe=error;}
386
+ if(!(probe instanceof Error&&probe.status===409&&/no renewal predecessor/.test(probe.detail??'')))throw new Error(`${selected.name}: ${h.teamsTokenEnv} is not its installation's unrenewed root credential (${probe instanceof Error?`${probe.code??probe.status}${probe.detail?`: ${probe.detail}`:''}`:probe}), so it could not be renewed from here; reconsent the installation`);
387
+ grant.registration={id:installation.app_id};
388
+ grant.teams={installation,credential:{token,kind:'service',expires_at:installation.created_at+TEAMS_CREDENTIAL_MS,expiresAtDerived:true}};privateJson(path,record);
389
+ }
390
+ }
391
+ // State: what each adopted program wrote up to its stop, with the ledger of its writes whose answers were lost. Copied
392
+ // again on every run until the connection first runs (a run that stopped before its runner started copies afresh); once
393
+ // it has run, its own state stands, and adopting the old programs' later state means setting the connection's folder aside.
394
+ stopped();
395
+ const folder=dirname(path);
396
+ for(const grant of record.grants){
397
+ const from=held[grant.name]?.stateFile;
398
+ if(!from||record.connectedAt)continue;
399
+ const into=SERVICE_APPS.includes(grant.name)?join(folder,'service',`${grant.name}.state.json`):join(folder,grant.name,'state.json');
400
+ mkdirSync(dirname(into),{recursive:true,mode:0o700});
401
+ for(const [a,b] of [[`${from}.unknown-writes.json`,`${into}.unknown-writes.json`],[from,into],[`${from}.cursor.json`,`${into}.cursor.json`]]){
402
+ 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
+ }
404
+ }
405
+ for(const grant of record.grants)if(SERVICE_APPS.includes(grant.name))generateServiceConfig(root,record,grant);else generateModuleConfig(root,record,grant);
406
+ stopped();
407
+ record.connectedAt??=new Date().toISOString();
408
+ record.preparing=false;record.paused=Boolean(flags['no-start']);privateJson(path,record);
409
+ const pid=record.paused?null:startConnectionRunner(root,id);
410
+ return {connection:id,root,adopted:{process:processPath,modules:moduleConfigs},modules:record.grants.map(g=>g.name),runner:pid,dropped:config?dropped(config):[],
411
+ expiry:record.grants.map(g=>({name:g.name,rh2:g.rh2.expiresAt,teams:g.teams?new Date(g.teams.credential.expires_at).toISOString():null})),
412
+ calls:(record.calls??[]).map(({side,method,path,status,ms,bytes})=>({side,method,path,status,ms,bytes}))};
413
+ }
414
+
195
415
  /** Undo a connection with its own credentials, no admin needed (runhuman-2 ADR 0055 decision 3): its runner stops, every
196
416
  * installation it holds leaves both sides by itself (each Workplace installation through its own leave door, each
197
417
  * Teams installation and the issuer's by deleting itself), and the home's registry entries go. The registrations stay, as
@@ -202,12 +422,12 @@ export async function disconnectWorkplace(flags) {
202
422
  const root=resolve(flags.root), path=connectionPath(root,flags.connection);
203
423
  if(!existsSync(path))throw new Error(`no connection ${flags.connection} in ${root}`);
204
424
  const record=JSON.parse(readFileSync(path,'utf8'));record.root=root;
425
+ // An adopted connection's installations predate it: leaving uninstalls what the adopted programs ran on before.
426
+ if(record.adopted&&!flags['uninstall-adopted'])throw new Error(`connection ${record.id} adopted installations made before it (${record.grants.map(g=>`${g.name} ${g.rh2?.installationId}`).join(', ')}); disconnect uninstalls them on both sides. Stopping its runner (kill -TERM the pid in its runner.lock) hands nothing back; pass --uninstall-adopted to uninstall them`);
205
427
  record.paused=true;privateJson(path,record);
206
428
  const lock=join(dirname(path),'runner.lock');
207
429
  if(existsSync(lock)){
208
- const running=JSON.parse(readFileSync(lock,'utf8'));
209
- try{process.kill(running.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}
210
- for(let waited=0;waited<20000;waited+=250){try{process.kill(running.pid,0);}catch{break;}await new Promise(done=>setTimeout(done,250));}
430
+ await stopConnectionRunner(lock,JSON.parse(readFileSync(lock,'utf8')));
211
431
  }
212
432
  const team=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`, left=[];
213
433
  const leave=async(name,side,method,door,token)=>{
package/index.d.ts CHANGED
@@ -151,7 +151,9 @@ export interface RuntimeState {
151
151
  /** The last worker event per lease (§4.3's inactivity limit). */
152
152
  activity: Record<string, Instant>;
153
153
  adapters: Record<Platform, { state: 'up' | 'down' | 'paused'; breaker: { failures: number; until?: Instant } }>;
154
- pending_requests: Record<string, { profile: ProfileName; keyString: string; runtime_id: string | null; asked_at: Instant; options: RequestOption[] }>;
154
+ pending_requests: Record<string, { profile: ProfileName; keyString: string; runtime_id: string | null; asked_at: Instant; options: RequestOption[]; subject?: string | null }>;
155
+ /** The worker's settlements of requests not yet queued (`settled` before its `request`), as `runtime_id\nrequest id`: the most recent 256. */
156
+ settled_requests?: string[];
155
157
  clock: Instant | null;
156
158
  hostedJobs?: boolean;
157
159
  hostedFires?: Record<string, boolean>;
@@ -169,6 +171,8 @@ export type WorkerEvent =
169
171
  | { kind: 'steer_refused'; text: string; webhook_input_id?: string | null }
170
172
  | { kind: 'final'; text: string | null; outcome?: 'completed' | 'error'; message?: string | null }
171
173
  | { kind: 'request'; id: string; subject: string; options: RequestOption[] }
174
+ /** The worker settled request `id` itself (Claude Code's `control_cancel_request`, Codex's `serverRequest/resolved`). */
175
+ | { kind: 'settled'; id: string }
172
176
  | { kind: 'ended'; reason: string }
173
177
  | { kind: 'error'; message: string };
174
178
 
@@ -10,6 +10,10 @@ export const APPS_DIR = 'apps';
10
10
  /** The apps this orchestrator can host, by name: what each installs and which adapter it attaches. */
11
11
  export const INSTALLABLE_APPS = Object.freeze({
12
12
  'orchestration-rh2': { side: 'rh2', module: true, describe: 'orchestration-rh2 installed module' },
13
+ // The Workplace service's registrations (connection.mjs SERVICE_APPS): one program the connection runner hosts.
14
+ agents: { side: 'rh2', service: true, describe: 'Agents → Room agents, in the connection\'s Workplace service' },
15
+ cards: { side: 'rh2', service: true, describe: 'Cards → Tasks, in the connection\'s Workplace service' },
16
+ machines: { side: 'rh2', service: true, describe: 'Machines → RH2 resources, in the connection\'s Workplace service' },
13
17
  'room-chat': { side: 'rh2', adapter: 'rh2', describe: 'Room chat → orchestrator: a person\'s line in an RH2 Room reaches the agent seated there' },
14
18
  // An agent the RH2 organization declares is one participant there: its chat and its call are its own agent principal,
15
19
  // never an automation beside it. One record per agent per Workplace, bound to its Teams id (`agentId`) and that
@@ -26,7 +30,7 @@ export function installedApps(root) {
26
30
  return readdirSync(dir).filter((file) => file.endsWith('.json')).map((file) => {
27
31
  const record = JSON.parse(readFileSync(join(dir, file), 'utf8'));
28
32
  return { ...record, name: file.slice(0, -5) };
29
- }).filter((record) => INSTALLABLE_APPS[record.app ?? record.name]);
33
+ }).filter((record) => INSTALLABLE_APPS[record.app ?? record.name] || typeof record.connection === 'string');
30
34
  }
31
35
 
32
36
  /** An RH2 agent's own identity: the token the organization's admin issued that agent principal (RH2's agent-token door),
@@ -104,7 +108,7 @@ export function rh2AgentEnv(record, root = null) {
104
108
  /** Exchange an RH2 install code for the installation's token and keep the record. */
105
109
  export async function installApp({ root, name, origin, clientId, clientSecret, code, profile = 'default', fetchImpl = globalThis.fetch }) {
106
110
  const app = INSTALLABLE_APPS[name];
107
- if(app?.module)throw new Error('module apps are consented and installed together by orchestrator connect');
111
+ if(app?.module||app?.service)throw new Error('module and service apps are consented and installed together by orchestrator connect');
108
112
  if (!app) throw new Error(`no installable app named ${name} (${Object.keys(INSTALLABLE_APPS).join(', ')})`);
109
113
  if (!rh2Origin(origin)) throw new Error('the RH2 origin is an https URL');
110
114
  const base = new URL(origin).origin;
@@ -128,12 +132,14 @@ export function removeApp(root, name) {
128
132
  }
129
133
 
130
134
  /** What `apps list` shows: never the token. */
131
- export const appView = (record) => ({ name: record.name, side: record.side, origin: record.origin, organization: record.organizationId, principal: record.principalId, agent: record.agentId, installation: record.installationId, profile: record.profile, expires_at: record.expiresAt });
135
+ export const appView = (record) => ({ name: record.name, side: record.side ?? INSTALLABLE_APPS[record.app]?.side, origin: record.origin, organization: record.organizationId, principal: record.principalId, agent: record.agentId, installation: record.installationId, profile: record.profile, expires_at: record.expiresAt });
132
136
 
133
- /** The same home's app registry drives module hosting after a daemon restart. */
137
+ /** The same home's app registry drives every connection's hosting after a daemon restart: each connection with an entry
138
+ * (its modules, its Workplace service, the programs it adopted) gets its runner back, so its renewals go on after a reboot.
139
+ * A source activation's restart does the same: a live runner of another tree yields to the serving tree's (connection-runner.mjs). */
134
140
  export async function startInstalledModules(root) {
135
141
  if(!root)return [];
136
142
  const {startConnectionRunner}=await import('./connection.mjs');
137
- const ids=[...new Set(installedApps(root).filter(app=>INSTALLABLE_APPS[app.app]?.module).map(app=>app.connection))];
143
+ const ids=[...new Set(installedApps(root).filter(app=>typeof app.connection==='string').map(app=>app.connection))];
138
144
  return ids.map(id=>({connection:id,pid:startConnectionRunner(root,id)})).filter(row=>row.pid!==null);
139
145
  }
package/loop.mjs CHANGED
@@ -1363,8 +1363,14 @@ export function normalizeWorkerEvent(ev) {
1363
1363
  case 'native':
1364
1364
  if (ev.kind === 'supercode/acp_request_completed') return { kind: 'final', text: null };
1365
1365
  if (ev.kind === 'control_request') return claudePermissionRequest(ev.payload);
1366
+ // Claude Code withdraws a `can_use_tool` it settled without this host (a PermissionRequest hook or permission
1367
+ // rule decided it, an answer arrived another way, or its turn was stopped): the request no longer waits on an answer
1368
+ if (ev.kind === 'control_cancel_request') return typeof ev.payload?.request_id === 'string' ? { kind: 'settled', id: JSON.stringify(ev.payload.request_id) } : null;
1366
1369
  return null;
1367
1370
  case 'request': return { kind: 'request', id: JSON.stringify(ev.requestId), command: requestCommand(ev.payload), ...requestTool(ev), subject: ev.requestKind ?? ev.kind, options: ev.options ?? [] };
1371
+ // Codex's `serverRequest/resolved` (`params.requestId`, the request's own id): the request was settled, by this host's
1372
+ // answer or without it
1373
+ case 'request_resolved': return ev.requestId == null ? null : { kind: 'settled', id: JSON.stringify(ev.requestId) };
1368
1374
  case 'error': return ev.terminal ? { kind: 'error', message: ev.message } : null;
1369
1375
  case 'closed': return { kind: 'ended', reason: ev.message ?? ev.kind };
1370
1376
  default: return null;
@@ -202,6 +202,10 @@
202
202
  "type": "string"
203
203
  },
204
204
  "description": "The full 40-character pushed head SHA of each PR, in the same order as pr."
205
+ },
206
+ "plan": {
207
+ "type": "boolean",
208
+ "description": "Ask the plan review of a big arc's proposed ADR (its PR in pr, its pushed head in pr_head) before any code. Without it, one PR named with its head is that PR's review."
205
209
  }
206
210
  },
207
211
  "required": [
@@ -400,6 +404,10 @@
400
404
  "type": "string",
401
405
  "description": "Short task title (required)."
402
406
  },
407
+ "readable_id": {
408
+ "type": "string",
409
+ "description": "The card's readable id (required): a few lowercase words joined by hyphens naming its outcome (e.g. 'voice-calls'), at most 32 characters, unique on the board. It names the card's branch, worktree folder, sessions and agents."
410
+ },
403
411
  "assignee": {
404
412
  "type": "string",
405
413
  "description": "Profile name that should execute this task (e.g. 'researcher-a', 'reviewer', 'writer'). Required \u2014 tasks without an assignee are never dispatched."
@@ -438,7 +446,7 @@
438
446
  },
439
447
  "project": {
440
448
  "type": "string",
441
- "description": "Optional project id or slug to link the task to. When set, the task becomes a git worktree under the project's primary repo with a deterministic branch (project slug + task id), instead of a random branch."
449
+ "description": "Optional project id or slug to link the task to. When set, the task becomes a git worktree under the project's primary repo with a deterministic branch (project slug + readable id), instead of a random branch."
442
450
  },
443
451
  "triage": {
444
452
  "type": "boolean",
@@ -490,6 +498,7 @@
490
498
  },
491
499
  "required": [
492
500
  "title",
501
+ "readable_id",
493
502
  "assignee"
494
503
  ]
495
504
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.225",
3
+ "version": "0.5.227",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {
@@ -36,11 +36,11 @@
36
36
  "check-types": "node scripts/generate-types.mjs --check"
37
37
  },
38
38
  "dependencies": {
39
- "@volter/supercode-teams": "^0.3.382",
40
- "@volter/supercode-terminal": "^0.3.12",
41
- "@volter/supercode-harness-sdk": "^0.3.106",
42
- "@volter/teams": "0.2.106",
43
- "@volter/ztrack": "^2.4.23",
39
+ "@volter/supercode-teams": "^0.3.344",
40
+ "@volter/supercode-terminal": "^0.3.10",
41
+ "@volter/supercode-harness-sdk": "^0.3.104",
42
+ "@volter/teams": "0.2.99",
43
+ "@volter/ztrack": "2.4.23",
44
44
  "yaml": "^2.9.0"
45
45
  },
46
46
  "devDependencies": {
package/reducer.mjs CHANGED
@@ -29,6 +29,11 @@ export function ensureRuntime(state) {
29
29
  return r;
30
30
  }
31
31
 
32
+ // The settlements of requests not yet queued that the reducer remembers (`settled` in handleWorker): the most recent
33
+ // this many. A request id is its runtime's (Codex numbers them per connection), so each is kept with its runtime.
34
+ const SETTLED_KEPT = 256;
35
+ const settledKey = (runtimeId, requestId) => `${runtimeId ?? ''}\n${requestId}`;
36
+
32
37
  function leaseId(profile, keyString) {
33
38
  return `${profile} ${keyString}`;
34
39
  }
@@ -564,7 +569,31 @@ function handleWorker(state, ev, effects) {
564
569
  effects.push({ type: 'save' });
565
570
  break;
566
571
  }
572
+ case 'settled': {
573
+ // The worker settled one of its requests itself (Claude Code's `control_cancel_request`, Codex's
574
+ // `serverRequest/resolved`). One still asked on the surface retires, so no later number answers it and its timeout
575
+ // sends nothing. One not asked yet is remembered: Hermes's approval rule runs before a request is queued here
576
+ // (loop.mjs onWorkerEvent), so its settlement can arrive first.
577
+ const id = String(w.id);
578
+ const p = rt.pending_requests[id];
579
+ if (p && p.profile === profile.name && p.keyString === ev.keyString && (!p.runtime_id || !ev.runtime_id || p.runtime_id === ev.runtime_id)) {
580
+ delete rt.pending_requests[id];
581
+ if (binding) reply(state, profile, binding.key, `Permission no longer needed: ${p.subject ?? 'the worker settled its request'}. The worker settled it itself; no reply is needed.`, now, effects);
582
+ break;
583
+ }
584
+ const settled = (rt.settled_requests ??= []);
585
+ settled.push(settledKey(ev.runtime_id, id));
586
+ if (settled.length > SETTLED_KEPT) settled.splice(0, settled.length - SETTLED_KEPT);
587
+ break;
588
+ }
567
589
  case 'request': {
590
+ // a request the worker settled before it was queued here is asked of nobody and answered by nobody
591
+ const settled = rt.settled_requests?.indexOf(settledKey(ev.runtime_id, String(w.id))) ?? -1;
592
+ if (settled >= 0) {
593
+ rt.settled_requests.splice(settled, 1);
594
+ effects.push({ type: 'log', level: 'info', message: `session ${ev.keyString}: the worker settled ${String(w.subject ?? 'a request').slice(0, 200)} itself` });
595
+ break;
596
+ }
568
597
  if (!binding) break;
569
598
  // Hermes's approval rule allowed it (a safe command, any other tool): it runs without asking, as under Hermes
570
599
  if (w.hermes?.verdict === 'allow') {
@@ -580,7 +609,7 @@ function handleWorker(state, ev, effects) {
580
609
  effects.push({ type: 'log', level: 'info', message: `launched session ${ev.keyString}: ${approve ? 'approved' : 'denied'} ${String(w.subject ?? 'a tool call').slice(0, 200)}` });
581
610
  break;
582
611
  }
583
- rt.pending_requests[String(w.id)] = { profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid]?.runtime_id ?? null, asked_at: now, options: w.options ?? [] };
612
+ rt.pending_requests[String(w.id)] = { profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid]?.runtime_id ?? null, asked_at: now, options: w.options ?? [], subject: w.subject ?? null };
584
613
  const options = (w.options ?? []).map((o, i) => `${i + 1}. ${o.name}`).join('\n');
585
614
  reply(state, profile, binding.key, `Permission needed: ${w.subject ?? 'the worker wants to act'}\nReply with a number:\n${options}`, now, effects);
586
615
  break;
@@ -723,7 +752,7 @@ function handleStopping(state, ev, effects) {
723
752
 
724
753
  function handleRestart(state, ev, effects) {
725
754
  const rt = ensureRuntime(state);
726
- rt.leases = {}; rt.turns = {}; rt.queues = {}; rt.pending_requests = {};
755
+ rt.leases = {}; rt.turns = {}; rt.queues = {}; rt.pending_requests = {}; rt.settled_requests = [];
727
756
  let live = 0;
728
757
  const replays = [];
729
758
  for (const profile of Object.values(state.profiles)) {