@volter/supercode-orchestrator 0.5.68 → 0.5.70

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/agent-door.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // The orchestrator's door to Teams for agent identity, called in-process through the installed neutral SDK: the board's
2
+ // owner is resident and async, so it awaits the request itself, never through a helper process.
3
+ import { isCallersCall } from './board/call.mjs';
4
+ import { fromContext, WorkspaceContexts } from '@volter/teams/context';
5
+ import { supercodeHome } from '@volter/teams/home';
6
+ import { openMachine, normalMachineName } from '@volter/teams/machine-door';
7
+ import { StreamRpc } from '@volter/teams/client';
8
+ import { hostname } from 'node:os';
9
+
10
+ // This machine is reached through its own daemon's socket, never through the Teams server: the server route lists the
11
+ // org's machines and opens a channel on the shared credential's allowance for a request that never leaves the box.
12
+ const reach = (machine) => machine && normalMachineName(machine) !== normalMachineName(hostname()) ? machine : null;
13
+ // A caller's verb in the board's owner names its context (the CLI and the daemon send it): an absent one is refused,
14
+ // never taken from the owner's own environment. The process's own work reads its environment, then the selection.
15
+ const contextOf = (context) => {
16
+ if (context) return context;
17
+ if (isCallersCall()) throw Object.assign(new Error('the caller named no Teams context'), { code: 'invalid_request' });
18
+ return process.env.SUPERCODE_CONTEXT ?? new WorkspaceContexts(supercodeHome()).read().current;
19
+ };
20
+
21
+ /**
22
+ * The Org agent identity belongs to: the selected Teams context as this home records it (read locally, no request), or
23
+ * this home's local store when none is selected. A context named but unreadable here is this home's error, and throws.
24
+ */
25
+ export function agentScope(context) {
26
+ const named = contextOf(context);
27
+ if (!named || named === 'local') return { server: 'local', org: 'local', principal: null, offline: true, reason: 'no Teams context is selected' };
28
+ const { context: scope } = fromContext({ supercodeHome: supercodeHome(), name: named });
29
+ return { server: scope.origin, server_id: scope.server_id, org: scope.team_id, principal: scope.principal_id };
30
+ }
31
+
32
+ /** One agent-identity request to the selected context's Teams server. Throws its refusal, with its status and code. */
33
+ export async function agentDoor(op, args = [], { context, timeout = 25_000 } = {}) {
34
+ if (op === 'scope') return agentScope(context);
35
+ const named = contextOf(context);
36
+ const { client, context: scope } = fromContext({ supercodeHome: supercodeHome(), name: named });
37
+ const team = client.team(scope.team_id);
38
+ // the door names itself to the server's tally of who spent the credential's allowance (X-Supercode-Caller)
39
+ const options = { signal: AbortSignal.timeout(timeout), caller: `agent-door ${op}` };
40
+ switch (op) {
41
+ case 'root': return team.agents.root(options);
42
+ case 'get': return team.agents.get(args[0], options);
43
+ case 'management': return team.agents.management(args[0], options);
44
+ case 'profile': return team.agents.update(args[0], { profile: args[1] }, { ...options, ifMatch: args[2] });
45
+ case 'reparent': return team.agents.update(args[0], { parent: args[1] }, { ...options, ifMatch: args[2] });
46
+ case 'declare': return team.agents.declare(args[0], { ...options, idempotencyKey: args[1] });
47
+ case 'sessions': return team.agents.sessions(args[0], args[1] ?? {}, options);
48
+ case 'ownSession': return team.agents.ownSession(args[0], args[1], { ...options, idempotencyKey: args[2] });
49
+ case 'session': return team.sessions.get(args[0]).identity(options);
50
+ case 'end': {
51
+ const agent = (await team.agents.get(args[0], options)).data;
52
+ return agent.state === 'ended' ? { data: { id: agent.id, state: 'ended' } } : team.agents.end(agent.id, { reason: args[1] }, { ...options, ifMatch: agent.revision, idempotencyKey: args[2] });
53
+ }
54
+ case 'reader': return team.agents.delegateReader(args[0], {}, { ...options, ifMatch: args[1] });
55
+ case 'readerContext': {
56
+ const identity = await team.agents.get(args[0], options);
57
+ const reader = await team.agents.delegateReader(args[0], { expires_in_ms: 86_400_000 }, { ...options, ifMatch: identity.data.revision });
58
+ const channel = await openMachine({ machine: reach(args[1]), context: named });
59
+ try { return await new StreamRpc(await channel.open('node')).call('harness.v1.agents.context', { origin: scope.origin, team: scope.team_id, agent: args[0], credential: reader.data }); }
60
+ finally { channel.close(); }
61
+ }
62
+ case 'resolveIdentity': return team.identity.resolve(args[0], options);
63
+ case 'find': {
64
+ let after = '';
65
+ do {
66
+ const page = await team.agents.list({ after, limit: 200 }, options);
67
+ const found = page.items.find((agent) => agent.name === args[0] && agent.parent === args[1] && agent.state !== 'ended') ?? null;
68
+ if (found) return { data: found };
69
+ after = page.next_after;
70
+ } while (after);
71
+ return { data: null };
72
+ }
73
+ default: throw Object.assign(new Error('unsupported agent door'), { code: 'invalid_request' });
74
+ }
75
+ }
package/agents.mjs CHANGED
@@ -2,28 +2,15 @@
2
2
  import { DatabaseSync } from 'node:sqlite';
3
3
  import { mkdirSync } from 'node:fs';
4
4
  import { join, basename } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { spawnSync } from 'node:child_process';
7
5
  import { createHash, randomBytes } from 'node:crypto';
8
6
  import { runtimeDoor } from './board/runtime-door.mjs';
9
7
  import { resolveAgentProfile } from './agent-profiles.mjs';
10
8
  import { compareAgentProfile } from '@volter/teams/agent-profile';
11
9
  import { WorkspaceContexts } from '@volter/teams/context';
12
10
  import { supercodeHome } from '@volter/teams/home';
13
- const entry=fileURLToPath(new URL('./bin/agent-door.mjs',import.meta.url));
14
- // The caller each door names to Teams (X-Supercode-Caller): this host's own command words, then the door's operation.
15
- const HOST_CALLER=(process.env.SUPERCODE_CALLER_NAME??`orchestrator ${process.argv.slice(2).filter(arg=>!arg.startsWith('-')).slice(0,2).filter(arg=>/^[a-z][a-z0-9-]{0,30}$/.test(arg)).join(' ')}`).slice(0,80);
16
- const lastJson=text=>{for(const line of String(text??'').trim().split('\n').reverse()){try{const value=JSON.parse(line);if(value&&typeof value==='object')return value;}catch{/* not the door's line */}}return null;};
17
- export function agentDoor(op,args=[],{context,timeout=25000}={}){
18
- const reply=spawnSync(process.execPath,[entry],{input:JSON.stringify({op,args,context}),encoding:'utf8',timeout,maxBuffer:4*1024*1024,env:{...process.env,SUPERCODE_CALLER_NAME:`${HOST_CALLER} agent-door ${op}`}});
19
- // The door's answer to a failure is the last stderr line that is JSON: a runtime's own lines (a preload's notice, a
20
- // Node warning) may come before or after it.
21
- if(reply.status!==0){const detail=lastJson(reply.stderr)??{message:reply.error?.message??String(reply.stderr).slice(-1000)};throw Object.assign(new Error(detail.message??'Teams agent door unavailable'),detail);}
22
- return JSON.parse(reply.stdout);
23
- }
24
- /** The Org agent identity belongs to: the selected Teams context as this home records it (read locally, no request),
25
- * or this home's local store when none is selected. A context named but unreadable here is this home's error, and throws. */
26
- export function agentScope(context){return agentDoor('scope',[],{context});}
11
+ // Agent identity is Teams's (agent-door.mjs, in-process); its intent and assignments are this home's (below).
12
+ export { agentDoor, agentScope } from './agent-door.mjs';
13
+ import { agentDoor, agentScope } from './agent-door.mjs';
27
14
  function store(root){
28
15
  mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(join(root,'agent-state.sqlite'));
29
16
  db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;
@@ -50,13 +37,13 @@ export function assignAgent(root,org,id,{kind,target,role}){
50
37
  const db=store(root),key=digest(JSON.stringify([kind,target,role]));try{db.prepare("INSERT INTO agent_assignments(org,id,agent_id,kind,target,role,state,started_at) VALUES (?,?,?,?,?,?,'active',?) ON CONFLICT(org,id) DO UPDATE SET agent_id=excluded.agent_id,state='active',ended_at=NULL").run(org,key,id,kind,target,role,Date.now());return key;}finally{db.close();}
51
38
  }
52
39
  export function activeAgentAssignments(root){const db=store(root);try{return db.prepare("SELECT * FROM agent_assignments WHERE state='active'").all();}finally{db.close();}}
53
- export function reassignAgent(root,org,assignment,to,{context}={}) {
54
- const target=agentDoor('management',[to],{context}).data;
40
+ export async function reassignAgent(root,org,assignment,to,{context}={}) {
41
+ const target=(await agentDoor('management',[to],{context})).data;
55
42
  const db=store(root);
56
43
  try {
57
44
  const old=db.prepare("SELECT * FROM agent_assignments WHERE org=? AND id=? AND state='active'").get(org,assignment);
58
45
  if(!old)throw new Error('active assignment not found');
59
- if(old.agent_id!==to)agentDoor('management',[old.agent_id],{context});
46
+ if(old.agent_id!==to)await agentDoor('management',[old.agent_id],{context});
60
47
  db.exec('BEGIN IMMEDIATE');
61
48
  const moved=db.prepare("UPDATE agent_assignments SET agent_id=? WHERE org=? AND id=? AND agent_id=? AND state='active'").run(to,org,assignment,old.agent_id);
62
49
  if(moved.changes!==1)throw new Error('assignment changed while reassigning it');
@@ -224,10 +211,43 @@ export function localForkAgent(options){
224
211
  * socket), which declares and ends them in Teams (D137: identity is Teams' job, never the dispatcher's or the gateway's).
225
212
  * Local only: a daemon not running keeps them here for the next hand-off. Answers what could not be handed on.
226
213
  */
227
- export function handOffAgents(root,{context=process.env.SUPERCODE_CONTEXT}={}){
228
- const scope=agentScope(context);if(scope.offline)return [];
229
- adoptLocalOrg(root,scope);
214
+ /**
215
+ * The Teams context this machine has for `org`: the one context of that team that is not an agent's delegated reader
216
+ * context (`agent-…`); among several, the selected one. Null when there is none, or several and none selected: the
217
+ * caller says so rather than taking another org's or the process's.
218
+ */
219
+ function contextForOrg(org){
220
+ const doc=new WorkspaceContexts(supercodeHome()).read();
221
+ const names=Object.entries(doc.contexts??{}).filter(([name,value])=>value?.team_id===org&&!name.startsWith('agent-')).map(([name])=>name);
222
+ return names.length===1?names[0]:names.includes(doc.current)?doc.current:null;
223
+ }
224
+
225
+ /**
226
+ * Hand this home's pending agent declarations and ends to Teams, each org's through this machine's context for that org
227
+ * (the context named, for its org alone). Never the process's own environment: the work was recorded for an org, and
228
+ * that org's context is the one that delivers it.
229
+ */
230
+ export function handOffAgents(root,{context=null}={}){
230
231
  const waiting=[];
232
+ if(context){const scope=agentScope(context);if(!scope.offline)handOffOrg(root,scope,waiting);return waiting;}
233
+ let orgs;
234
+ const db=store(root);
235
+ try{orgs=db.prepare("SELECT org FROM agent_intents WHERE declared=0 AND declaration_json IS NOT NULL UNION SELECT org FROM agent_end_outbox").all().map(row=>row.org).filter(org=>org&&org!=='local');}
236
+ finally{db.close();}
237
+ for(const org of orgs){
238
+ // one org whose context cannot be read (a saved context gone bad) is said and left; the others are still handed off
239
+ try{
240
+ const named=contextForOrg(org);
241
+ if(!named){waiting.push({agent:'*',error:`no Teams context on this machine for org ${org} (or several, none selected); its agent work waits`});continue;}
242
+ const scope=agentScope(named);
243
+ if(!scope.offline&&scope.org===org)handOffOrg(root,scope,waiting);
244
+ }catch(error){waiting.push({agent:'*',error:`org ${org}: ${error.message}; its agent work waits`});}
245
+ }
246
+ return waiting;
247
+ }
248
+
249
+ function handOffOrg(root,scope,waiting){
250
+ adoptLocalOrg(root,scope);
231
251
  let db=store(root),rows,ends;
232
252
  try{
233
253
  rows=db.prepare('SELECT * FROM agent_intents WHERE org=? AND declared=0 AND declaration_json IS NOT NULL ORDER BY created_at').all(scope.org);
@@ -254,7 +274,6 @@ export function handOffAgents(root,{context=process.env.SUPERCODE_CONTEXT}={}){
254
274
  db.prepare('DELETE FROM agent_end_outbox WHERE org=? AND agent_id=?').run(scope.org,row.agent_id);
255
275
  }finally{db.close();}
256
276
  }
257
- return waiting;
258
277
  }
259
278
  /** A managed runtime may report its native id after opening. Keep the lease pointer out of the catalogue. */
260
279
  export function recordNativeAgentSession(root,runtimeRef,session) {
@@ -11,8 +11,10 @@
11
11
  // and every answer is posted back anchored at its thread's root, by `AgentMailboxBridge` over the RH2 adapter.
12
12
  //
13
13
  // The Room is found by its key (`--room-key`, default `account-<agent>`), or opened once when the organization has
14
- // none: the principal (`--principal`, RH2's principal id) as its operator, and the bot seated in its conversation
15
- // answering every line. `--room <id>` names an existing Room instead and opens nothing.
14
+ // none: from the organization's preset (`--preset`) with the principal (`--principal`, RH2's principal id) and the agent
15
+ // both seated in the role the org's floor names (`--role`), and the agent answering every line of its conversation.
16
+ // Authority comes from a stated role: opening a Room without both is refused. `--room <id>` names an existing Room
17
+ // instead and opens nothing.
16
18
  //
17
19
  // RH2 is reached as the agent's own principal where the organization declares the agent (docs/adr/0017: `supercode
18
20
  // orchestrator apps install rh2-agent … --profile <agent>` in the home, `--root` or HERMES_HOME), so its threads, its DM
@@ -37,10 +39,10 @@ import { installedApps } from '../installed-apps.mjs';
37
39
 
38
40
  const { values } = parseArgs({
39
41
  args: process.argv.slice(3),
40
- options: { agent: { type: 'string' }, root: { type: 'string' }, room: { type: 'string' }, 'room-key': { type: 'string' }, 'room-name': { type: 'string' }, principal: { type: 'string' }, state: { type: 'string' }, platform: { type: 'string' }, site: { type: 'string' }, jql: { type: 'string' }, 'slack-channel': { type: 'string' }, help: { type: 'boolean' } },
42
+ options: { agent: { type: 'string' }, root: { type: 'string' }, room: { type: 'string' }, 'room-key': { type: 'string' }, 'room-name': { type: 'string' }, principal: { type: 'string' }, preset: { type: 'string' }, role: { type: 'string' }, state: { type: 'string' }, platform: { type: 'string' }, site: { type: 'string' }, jql: { type: 'string' }, 'slack-channel': { type: 'string' }, help: { type: 'boolean' } },
41
43
  });
42
44
  if (values.help || !values.agent) {
43
- console.log('supercode-orchestrator agent-channel --agent <name> [--root <home>] [--room <id> | --room-key <key> --principal <rh2 principal id> [--room-name <name>]] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform slack --slack-channel <id>[,<id>] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform jira --site <url> --jql <jql> [--state <file>]');
45
+ console.log('supercode-orchestrator agent-channel --agent <name> [--root <home>] [--room <id> | --room-key <key> [--principal <rh2 principal id> --preset <preset id> --role <role key> [--room-name <name>]]] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform slack --slack-channel <id>[,<id>] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform jira --site <url> --jql <jql> [--state <file>]');
44
46
  process.exit(values.help ? 0 : 2);
45
47
  }
46
48
  const log = (level, line) => (level === 'debug' ? undefined : console.error(`${new Date().toISOString()} ${level} ${line}`));
@@ -99,10 +101,15 @@ async function accountRoom() {
99
101
  const found = (Array.isArray(listing) ? listing : listing.rooms ?? []).map((entry) => entry.room ?? entry).find((room) => room?.key === key && room.state === 'active');
100
102
  if (found) return String(found.roomId);
101
103
  if (!values.principal) throw new Error(`no active Room with key ${key}, and no --principal to open it for`);
104
+ // the Room's shape and the seats' role are the organization's (its floor names them), never this door's
105
+ const missing = ['preset', 'role'].filter((name) => !values[name]);
106
+ if (missing.length) throw new Error(`no active Room with key ${key}; opening one needs ${missing.map((name) => `--${name}`).join(' and ')} from the organization's floor (the preset the Room is made from and the role its principal and agent hold)`);
107
+ const seated = [...new Set([values.principal, adapter.principalId()])];
102
108
  const { room } = await post('/rooms', {
103
109
  key,
110
+ presetId: values.preset,
104
111
  name: values['room-name'] ?? `${values.agent}`,
105
- members: [{ principalId: values.principal, roleKeys: ['operator'] }],
112
+ members: seated.map((principalId) => ({ principalId, roleKeys: [values.role] })),
106
113
  });
107
114
  const view = await adapter.get(`/rooms/${encodeURIComponent(room.roomId)}`);
108
115
  const conversation = view.room?.defaultConversationId;
@@ -9,7 +9,7 @@
9
9
  }
10
10
  // Board commands do not load daemon transports, platform adapters or the MCP server.
11
11
  if (process.argv[2] === 'workflow') {
12
- const [{runWorkflow},{closeWorld},{timeCommand}]=await Promise.all([import('../board/cli.mjs'),import('../orchestration.mjs'),import('@volter/supercode-harness-sdk/slow-log')]);
12
+ const [{runWorkflow},{timeCommand}]=await Promise.all([import('../board/client.mjs'),import('@volter/supercode-harness-sdk/slow-log')]);
13
13
  // A board command is timed into the slow log; `serve` runs until stopped and is not one.
14
14
  const verb=process.argv.slice(3).find((arg,index,all)=>!arg.startsWith('-')&&!(index>0&&/^--(root|board|cwd|context|at)$/.test(all[index-1])));
15
15
  if(verb!=='serve')timeCommand(`workflow ${verb??''}`.trim(),process.argv.slice(3));
@@ -18,7 +18,6 @@ if (process.argv[2] === 'workflow') {
18
18
  // a store's own error (SQLite's, with its errcode) is no message written for a person: its stack says which statement
19
19
  // met it, as a follower's end in the connector's log (board events) otherwise could not
20
20
  catch(error) {process.stderr.write(`supercode workflow: ${error.message??error}\n${error?.errcode!=null&&error.stack?`${error.stack}\n`:''}`);code=1;}
21
- finally {await closeWorld();}
22
21
  await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.write('',done))));
23
22
  process.exit(code);
24
23
  } else if (process.argv[2] === 'agent-channel') {
@@ -14,7 +14,9 @@ export function declareCardAgentEffect(board,effect) {
14
14
  const told=view(board,db=>new Set(db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='agent_declaration_pending' AND id>COALESCE((SELECT max(id) FROM task_events WHERE task_id=? AND kind='agent_declared'),0)").all(effect.id,effect.id).map(row=>json(row.payload,{})?.reason)));
15
15
  try {
16
16
  if(!entry.profiles[profile])throw new Error('card agent declaration needs a named profile');
17
- const context=effect.context??process.env.SUPERCODE_CONTEXT,scope=agentScope(context);
17
+ // the context the card's creator named, kept with the declaration: never the context of whoever retries it
18
+ if(!effect.context)throw new Error('the card agent declaration recorded no Teams context; it is declared again by a call that names one');
19
+ const context=effect.context,scope=agentScope(context);
18
20
  const options={root:board.root,org:scope.org,server:scope.server,profiles:entry.profiles,
19
21
  profile,card:effect.id,role:effect.role,sponsor:task.sponsor??null,context,starterSession:effect.starter_session};
20
22
  const agent=localCardAgent(options);
package/board/args.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // A workflow verb's arguments, as the CLI and the board's owner both read them.
2
+ import { resolve } from 'node:path';
3
+
4
+ export const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
5
+ export const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
6
+ 'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'commit']);
7
+ // flags that take every following word up to the next flag (Hermes's nargs='+')
8
+ export const GREEDY = new Set(['ids', 'rm']);
9
+
10
+ export function parse(argv) {
11
+ const out = { _: [] };
12
+ for (let i = 0; i < argv.length; i += 1) {
13
+ const a = argv[i];
14
+ if (!a.startsWith('--') || a === '--') { out._.push(a); continue; }
15
+ const [key, inline] = a.slice(2).split(/=(.*)/s);
16
+ if (FLAGS.has(key)) { out[key] = true; continue; }
17
+ if (GREEDY.has(key)) {
18
+ const values = inline !== undefined ? [inline] : [];
19
+ while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) values.push(argv[++i]);
20
+ (out[key] ??= []).push(...values);
21
+ continue;
22
+ }
23
+ const value = inline ?? argv[++i];
24
+ if (value === undefined) throw new Error(`--${key} needs a value`);
25
+ if (MULTI.has(key)) (out[key] ??= []).push(value);
26
+ else out[key] = value;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /** The home a verb names: `--root`, else the home's own variable. */
32
+ export function homeOf(args) {
33
+ const root = args.root ?? process.env.SUPERCODE_ORCHESTRATOR_HOME;
34
+ if (!root) throw new Error('which home? --root <home> (or SUPERCODE_ORCHESTRATOR_HOME)');
35
+ return resolve(root);
36
+ }
package/board/call.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // The call a board verb answers: who it answers for and where its answer goes. A board's resident owner (`workflow
2
+ // serve`) answers many callers' verbs at once, each a call of its own, so nothing that names the caller or writes the
3
+ // answer reads it from the process: it reads the call it runs in. A process's own work (the owner's dispatching, an
4
+ // operation verb run on the machine) runs in the process's own call.
5
+ //
6
+ // caller the calling session's address (`sc:<machine>:<harness>:<id>`), as the caller's own side named it, or null
7
+ // facts what the caller's environment says of it: SUPERCODE_AGENT_ID, SUPERCODE_AUTOMATION, SUPERCODE_CONTEXT,
8
+ // HERMES_PROFILE, USER (the process's own environment in its own call)
9
+ // actor, principal, roles, recorded what the verb learns of the caller as it runs (store.mjs, engine.mjs, cli.mjs)
10
+ // said the standing warnings this call has already been told (a backing's, once per call)
11
+ // cwd the caller's working directory, against which a path it names is read
12
+ // stdin what the caller sent on its standard input, for a `-` it names as a file (null: none)
13
+ // out, err where the verb writes its answer
14
+ import { readFileSync } from 'node:fs';
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+
17
+ const calls = new AsyncLocalStorage();
18
+ // what a call learns as it runs, and the workflow it read (pinned: a reload while it runs does not change its basis)
19
+ const fresh = () => ({ actor: null, principal: null, roles: () => ['manager'], recorded: null, workflows: new Map(), said: new Set() });
20
+ // the process's own context pins no workflow (it lives as long as the process): its reads always take the latest
21
+ const unpinned = { get: () => undefined, set: () => {} };
22
+ const own = { kind: 'own', caller: null, facts: process.env, ...fresh(), workflows: unpinned, get cwd() { return process.cwd(); }, get stdin() { return readFileSync(0, 'utf8'); }, out: (text) => process.stdout.write(text), err: (text) => process.stderr.write(text) };
23
+
24
+ /** The call running now: a caller's verb in the owner, else the process's own. */
25
+ export const current = () => calls.getStore() ?? own;
26
+
27
+ /** Run `fn` as one caller's call, its answer written through `out` and `err`. */
28
+ export function within({ caller = null, facts = {}, cwd, stdin = null, out, err }, fn) {
29
+ return calls.run({ kind: 'caller', caller, facts, ...fresh(), cwd, stdin, out, err }, fn);
30
+ }
31
+
32
+ /** Run `fn` as a unit of the process's own work (a round, a delivery pass): the process's identity and output, with
33
+ * what it learns and the workflow it read kept to itself. */
34
+ export function ownCall(fn) {
35
+ return calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn);
36
+ }
37
+
38
+ /** The caller as its address names it, or null. */
39
+ export function callerOf(call = current()) {
40
+ const m = /^sc:([^:\s]+):(claude-code|codex):([^:\s]+)$/.exec(call.caller ?? '');
41
+ return m ? { address: m[0], machine: m[1], harness: m[2], session: m[3] } : null;
42
+ }
43
+
44
+ /** Whether this is a caller's call in the owner rather than the process's own. */
45
+ export const isCallersCall = () => calls.getStore()?.kind === 'caller';
@@ -0,0 +1,25 @@
1
+ // The session this process runs in, as its own harness names it (Claude Code CLAUDE_CODE_SESSION_ID, Codex
2
+ // CODEX_THREAD_ID). A pane inherits every variable the program that started its terminal server had, so a Codex pane
3
+ // can carry a Claude session's id too: the nearest harness among the process's ancestors says which is its own.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { basename } from 'node:path';
6
+
7
+ export function processSession() {
8
+ const claude = process.env.CLAUDE_CODE_SESSION_ID || null;
9
+ const codex = process.env.CODEX_THREAD_ID || null;
10
+ if (!claude || !codex) return claude ?? codex;
11
+ const table = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8' });
12
+ const processes = new Map();
13
+ for (const row of String(table.stdout ?? '').split('\n')) {
14
+ const m = row.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
15
+ if (m) processes.set(Number(m[1]), { parent: Number(m[2]), name: basename(m[3]) });
16
+ }
17
+ for (let pid = process.ppid, depth = 0; pid > 1 && depth < 64; depth++) {
18
+ const p = processes.get(pid);
19
+ if (!p) break;
20
+ if (p.name === 'codex') return codex;
21
+ if (/^claude(\.exe)?$/.test(p.name)) return claude;
22
+ pid = p.parent;
23
+ }
24
+ return claude;
25
+ }