@volter/supercode-teams 0.2.178 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -146,7 +146,8 @@ enrollments).
146
146
  ### RH2 agent DMs
147
147
 
148
148
  Share a session as its RH2 agent with `supercode teams sessions bind REF --room ROOM --agent PRINCIPAL --rh2 ORIGIN`.
149
- Only the session custodian can grant this binding. The returned `session` can be passed to RH2’s room-member door;
149
+ Only the session custodian can grant this binding, in person or through an app they installed with
150
+ `sessions.access` (the Agents → Room agents app binds a card agent's main session this way). The returned `session` can be passed to RH2’s room-member door;
150
151
  no session-backed channel is created. The agent DM uses the exact resource `${ROOM}:dm:${PRINCIPAL}`.
151
152
  `unbind` accepts the same flags. `--conversation` remains available for existing grants; it is mutually exclusive
152
153
  with `--agent`. Opening a DM reads the existing session; sending a turn is a separate action.
package/bin/fleet.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // context the caller can reach (or the named subset), merged into one
10
10
  // page; each unreachable machine is a row of its own. Nothing is stored.
11
11
  // rpc --on <machine> <harness.v1 method> [json] one operator request, one machine
12
- // launch [--on <machine>] <program> [--cwd d] [--launch-key k] [--kind k] [--input text] -- args
12
+ // launch [--on <machine>] <program> [--cwd d] [--launch-key k] [--kind k] [--input text] [--env=KEY=VALUE]… -- args
13
13
  // (`open --new`)
14
14
  // attach [--on <machine>] <pane> [--mode observe|control] ctrl-] detaches (`open --on`)
15
15
  //
@@ -28,6 +28,8 @@ for (let i = 0; i < argv.length; i += 1) {
28
28
  if (a === '--') { positional.push(...argv.slice(i + 1)); break; }
29
29
  if (a.startsWith('--')) {
30
30
  const eq = a.indexOf('=');
31
+ // `--env=KEY=VALUE` repeats: the pane's environment, one variable each.
32
+ if (eq > 0 && a.slice(2, eq) === 'env') { (flags.env ??= []).push(a.slice(eq + 1)); continue; }
31
33
  if (eq > 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; } // --args=--norc
32
34
  const key = a.slice(2);
33
35
  if (key === 'act-for-caller') { flags[key] = true; continue; }
@@ -95,6 +97,7 @@ async function main() {
95
97
  arguments: flags.args ? String(flags.args).split(' ').filter(Boolean) : rest,
96
98
  cwd: flags.cwd,
97
99
  initialInput: flags.input,
100
+ ...(Array.isArray(flags.env) ? { env: Object.fromEntries(flags.env.map((pair) => [pair.slice(0, pair.indexOf('=')), pair.slice(pair.indexOf('=') + 1)]).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key))) } : {}),
98
101
  name: flags.name,
99
102
  // The launch's window choice, when its caller states one: `open` attaching this terminal to the pane asks
100
103
  // for none, and the board's dispatcher states its workflow's choice on every launch.
package/bin/teams.mjs CHANGED
@@ -305,11 +305,11 @@ async function main() {
305
305
  return;
306
306
  }
307
307
  case 'workspace': {
308
- if (sub === 'list') return withNode(async (rpc) => out(await rpc.call('workspace.list', {})));
309
- if (sub === 'register' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.register', { path: rest[0], repository: flags.repo ?? null })));
310
- if (sub === 'unregister' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.unregister', { path: rest[0] })));
311
- if (sub === 'resolve' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.resolve', { repository: rest[0], branch: flags.branch ?? null, solo: Boolean(flags.solo), card: flags.card ?? null, create: Boolean(flags.create) })));
312
- if (sub === 'inspect' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.inspect', { path: rest[0] })));
308
+ if (sub === 'list') return withNode(async (rpc) => out(await rpc.call('harness.v1.workspaces.list', {})));
309
+ if (sub === 'register' && rest[0]) return withNode(async (rpc) => out(await rpc.call('harness.v1.workspaces.register', { path: rest[0], repository: flags.repo ?? null })));
310
+ if (sub === 'unregister' && rest[0]) return withNode(async (rpc) => out(await rpc.call('harness.v1.workspaces.unregister', { path: rest[0] })));
311
+ if (sub === 'resolve' && rest[0]) return withNode(async (rpc) => out(await rpc.call('harness.v1.workspaces.resolve', { repository: rest[0], branch: flags.branch ?? null, solo: Boolean(flags.solo), card: flags.card ?? null, create: Boolean(flags.create) })));
312
+ if (sub === 'inspect' && rest[0]) return withNode(async (rpc) => out(await rpc.call('harness.v1.workspaces.inspect', { path: rest[0] })));
313
313
  // `workspace move --from A --to B --path P [--to-path Q]`: the project leaves A and arrives
314
314
  // at B, published there only if every byte arrived (sha256 on commit) and neither machine
315
315
  // runs an exec job. A is left as it was; B's path must not exist.
@@ -0,0 +1,20 @@
1
+ // Native session hints choose the nearest actual harness when terminal servers inherited both variables.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { basename } from 'node:path';
4
+ export function agentSessionHint(env=process.env){
5
+ if(env.SUPERCODE_AGENT_SESSION)return env.SUPERCODE_AGENT_SESSION;
6
+ const claude=env.CLAUDE_CODE_SESSION_ID,codex=env.CODEX_THREAD_ID;
7
+ if(!claude)return codex?`codex:${codex}`:null;
8
+ if(!codex)return `claude-code:${claude}`;
9
+ const table=spawnSync('ps',['-A','-o','pid=,ppid=,comm='],{encoding:'utf8',timeout:2000});
10
+ const rows=new Map();for(const line of String(table.stdout??'').split('\n')){
11
+ const row=line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);if(row)rows.set(Number(row[1]),{parent:Number(row[2]),name:basename(row[3])});
12
+ }
13
+ for(let pid=process.ppid,depth=0;pid>1&&depth<64;depth++){
14
+ const row=rows.get(pid);if(!row)break;
15
+ if(row.name==='codex')return `codex:${codex}`;
16
+ if(/^claude(\.exe)?$/.test(row.name))return `claude-code:${claude}`;
17
+ pid=row.parent;
18
+ }
19
+ throw new Error('native session hints are ambiguous; name --session SESSION');
20
+ }
@@ -1,7 +1,7 @@
1
1
  const BOOLEANS = new Set([
2
2
  'json', 'help', 'live', 'saved', 'watch', 'paginate', 'token-stdin',
3
3
  'invite-stdin', 'enrollment-stdin', 'include-children', 'rotate', 'install', 'force',
4
- 'take-control', 'dry-run', 'volter', 'foreground', 'no-connect', 'record-only', 'workplace-only',
4
+ 'take-control', 'dry-run', 'volter', 'foreground', 'no-connect', 'record-only', 'workplace-only', 'main', 'descendants',
5
5
  ]);
6
6
 
7
7
  const VALUES = new Set([
@@ -9,9 +9,9 @@ const VALUES = new Set([
9
9
  'public-origin', 'init-team', 'as', 'user', 'repo', 'harness', 'machine',
10
10
  'source', 'cursor', 'limit', 'tail', 'before', 'after', 'revision', 'role',
11
11
  'out', 'ttl', 'type', 'name', 'visibility', 'backfill', 'format', 'to',
12
- 'grant', 'binding', 'caps', 'fidelity', 'cwd', 'root', 'workspace-from', 'since', 'until', 'session',
12
+ 'grant', 'binding', 'caps', 'fidelity', 'cwd', 'root', 'workspace-from', 'since', 'until', 'session', 'starter',
13
13
  'on', 'timeout', 'plan', 'tmux-socket', 'volter-issuer', 'workplace', 'organization', 'key', 'room',
14
- 'conversation', 'rh2', 'scope', 'agent', 'body-file', 'enrollment',
14
+ 'if-match', 'observation', 'conversation', 'rh2', 'scope', 'agent', 'body-file', 'enrollment',
15
15
  ]);
16
16
 
17
17
  export function parseArgs(argv) {
package/bin/workspace.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { hostname } from 'node:os';
3
+ import { agentSessionHint } from './workspace/agent-session.mjs';
4
+ import { compareAgentProfile } from '@volter/teams/agent-profile';
3
5
  import { fileURLToPath } from 'node:url';
4
6
  import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
5
7
  import { basename, join, resolve } from 'node:path';
@@ -43,12 +45,48 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
43
45
  const contexts = new WorkspaceContexts(home);
44
46
  const outputStream = dependencies.stdout ?? process.stdout;
45
47
  const output = value => outputStream.write(flags.json ? `${JSON.stringify(value)}\n` : humanOutput(value));
48
+ if (verb==='identities'&&sub==='sender') {
49
+ only(flags,['json','help','context','body_file']);
50
+ if(!flags.body_file)usage('--body-file is required (use - for stdin)');
51
+ const {SenderIdentityRecord}=await import('@volter/teams/sender-identity');
52
+ const record=new SenderIdentityRecord({supercodeHome:home,context:flags.context??env.SUPERCODE_CONTEXT});
53
+ const identity=await record.observe(JSON.parse(readFileSync(flags.body_file==='-'?0:flags.body_file,'utf8')));
54
+ return output({...identity,links:identity.links.map(link=>({id:link.id,classification:link.classification,label:link.label,principal_id:link.principal_id}))});
55
+ }
46
56
  if (verb === 'context') return contextCommand({ contexts, flags, sub, rest, output });
47
57
  if (verb === 'server') return serverCommand({ flags, sub, rest, home, output });
48
58
  if (verb === 'login') return loginCommand({ flags, origin: sub, rest, home, output });
49
59
  if (verb === 'auth') return authCommand({ flags, sub, rest, home, contexts, output, env, dependencies });
50
60
  if (verb === 'connect' && flags.enrollment_stdin) adoptEnrollment(contexts, home, flags);
51
- const explicitContext = flags.context ?? env.SUPERCODE_CONTEXT;
61
+ let agentLocal=null;
62
+ if(['agent','sessions'].includes(verb)){
63
+ const session=flags.session??agentSessionHint(env);
64
+ if(session){
65
+ let channel;
66
+ try{
67
+ channel=await openLocalMachine(paths(teamsHome()).socket);const rpc=await nodeRpc(channel);
68
+ const identity=await rpc.call('harness.v1.agents.session',{session});
69
+ agentLocal=identity;
70
+ if(identity&&!flags.context&&!env.SUPERCODE_AGENT_CONTEXT)env={...env,SUPERCODE_AGENT_CONTEXT:identity.context,SUPERCODE_AGENT_ID:identity.agent,SUPERCODE_AGENT_ASSIGNMENTS:JSON.stringify(identity.assignments)};
71
+ }catch(error){
72
+ // Only absence of an older/offline daemon permits the ordinary plain-session context.
73
+ if(!['ENOENT','ECONNREFUSED',-32601].includes(error.code))throw error;
74
+ }finally{channel?.close();}
75
+ }
76
+ }
77
+ // Every door called from an agent starts under its delegated context. A sibling product verb
78
+ // must not silently recover the custodian's ordinary login when an agent's reader is bounded.
79
+ const agentContext = env.SUPERCODE_AGENT_CONTEXT || null;
80
+ if(env.SUPERCODE_AGENT_ID&&!flags.context&&!agentContext){
81
+ // An identity minted while Teams was unreachable still answers whoami from its launch. No other door
82
+ // proceeds without the agent's delegation, and the custodian's own login is never borrowed.
83
+ if(verb==='agent'&&sub==='whoami')return output({data:{session:flags.session??agentSessionHint(env),
84
+ agent:{id:env.SUPERCODE_AGENT_ID,name:env.SUPERCODE_AGENT_NAME,path:env.SUPERCODE_AGENT_PATH,org:env.SUPERCODE_AGENT_ORG},
85
+ main:env.SUPERCODE_AGENT_MAIN!=='0',synced:env.SUPERCODE_AGENT_SYNCED!=='0',
86
+ assignment_intent:JSON.parse(env.SUPERCODE_AGENT_ASSIGNMENTS||'[]'),source:'launch environment; Teams delegation unavailable'}});
87
+ throw new Error('agent delegation is unavailable; no custodian context fallback is permitted');
88
+ }
89
+ const explicitContext = flags.context ?? agentContext ?? env.SUPERCODE_CONTEXT;
52
90
  let selected = explicitContext ?? contexts.read().current;
53
91
  const urlReference = verb === 'sessions' ? rest.find(value => /^https?:\/\//.test(value)) : null;
54
92
  if (!explicitContext && urlReference) selected = contextForUrl(contexts.read(), urlReference) ?? selected;
@@ -58,8 +96,13 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
58
96
  const team = client.team(context.team_id);
59
97
  // A machine-only context (a box enrolled by its custodian) holds its machine credential and nothing a person has.
60
98
  if (context.machine_only && !['connect', 'disconnect', 'status'].includes(verb)) usage(`context ${selected} is machine ${context.machine_name}'s: run teams ${verb} where a person is signed in`);
61
- const command = { flags, rest, output, client, context, team, explicitContext, home, contextName: selected, env, dependencies };
99
+ const command = { flags, rest, output, client, context, team, explicitContext, home, contextName: selected, env, dependencies,agentLocal };
62
100
  if (teamsPrefix && ['list', 'create'].includes(verb)) return teamsCommand(verb, sub, command);
101
+ if (verb === 'agent') return agentCommand(sub,command);
102
+ if (verb === 'principals') return principalsCommand(sub,command);
103
+ if (verb === 'permissions') return permissionsCommand(sub,command);
104
+ if (verb === 'org') return orgCommand(sub,command);
105
+ if (verb === 'identities') return identityRecordCommand(sub,command);
63
106
  if (verb === 'apps' || verb === 'app-installations') return appsCommand(verb,sub,{...command,output:value=>output(flags.json?value:redactAppSecrets(value))});
64
107
  if (verb === 'sessions') return sessionsCommand(sub, command);
65
108
  if (verb === 'members') return membersCommand(sub, command);
@@ -73,6 +116,115 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
73
116
  usage(`unsupported workspace command ${verb}`);
74
117
  }
75
118
 
119
+ async function agentCommand(sub,{flags,rest,team,output,env,agentLocal}) {
120
+ only(flags,['json','help','context','body_file','if_match','agent','session','starter','main','descendants','after','limit']);
121
+ const body=()=>{if(!flags.body_file)usage('--body-file is required (use - for stdin)');return JSON.parse(readFileSync(flags.body_file==='-'?0:flags.body_file,'utf8'));};
122
+ const currentSession=()=>{
123
+ const ref=flags.session??agentSessionHint(env);
124
+ if(!ref)usage('no session identity available; name --session SESSION');
125
+ return ref;
126
+ };
127
+ const whoami=async()=>{
128
+ const session=currentSession();
129
+ let identity;
130
+ try{identity=await team.sessions.get(session).identity();}
131
+ catch(error){
132
+ // A session asks at its start, before the board has joined it to its agent: it is told its launch, and says so.
133
+ if(error.code==='not_found'&&!env.SUPERCODE_AGENT_ID)throw Object.assign(new Error(`session ${session} is not in Teams yet, or not readable to you: a machine publishes a session its sync rules cover once it has recorded a turn`),{code:'not_found'});
134
+ if(error.code!=='not_found')throw error;
135
+ // the agent's own record is public to it before its session is joined; the launch names it when that is unreadable
136
+ const record=await team.agents.get(env.SUPERCODE_AGENT_ID).then(answer=>answer.data,()=>null);
137
+ return {data:{session,agent:record??{id:env.SUPERCODE_AGENT_ID,name:env.SUPERCODE_AGENT_NAME,path:env.SUPERCODE_AGENT_PATH,org:env.SUPERCODE_AGENT_ORG},
138
+ main:env.SUPERCODE_AGENT_MAIN!=='0',synced:env.SUPERCODE_AGENT_SYNCED!=='0',assignment_intent:JSON.parse(env.SUPERCODE_AGENT_ASSIGNMENTS||'[]'),
139
+ source:'launch environment; Teams has not yet joined this session to its agent'}};
140
+ }
141
+ if(env.SUPERCODE_AGENT_ID&&identity.data.agent?.id!==env.SUPERCODE_AGENT_ID)throw new Error('session ownership does not match its launch identity; catalogue publication may still be pending');
142
+ if(identity.data.agent&&env.SUPERCODE_AGENT_ASSIGNMENTS){
143
+ // Assignment hints are launch intent; live identity and access always come from the public door.
144
+ identity.data.assignment_intent=JSON.parse(env.SUPERCODE_AGENT_ASSIGNMENTS);
145
+ }
146
+ if(agentLocal&&identity.data.agent&&agentLocal.agent===identity.data.agent.id&&agentLocal.intended){
147
+ identity.data.profile_intent=agentLocal.intended;
148
+ identity.data.profile_comparison=compareAgentProfile(agentLocal.intended,identity.data.resolved);
149
+ }
150
+ return identity;
151
+ };
152
+ if(sub==='whoami'){requireArgs(rest,0,0,'agent whoami [--session SESSION]');return output(await whoami());}
153
+ if(sub==='sessions'){
154
+ requireArgs(rest,0,1,'agent sessions [AGENT] [--descendants]');
155
+ const id=rest[0]??flags.agent??(await whoami()).data.agent?.id;
156
+ if(!id)usage('this is a plain session; it has no owning agent');
157
+ return output(await team.agents.sessions(id,{descendants:flags.descendants?true:undefined,after:flags.after,limit:flags.limit}));
158
+ }
159
+ if(sub==='message'){
160
+ requireArgs(rest,1,1,'agent message SESSION --body-file FILE');
161
+ return output(await team.sessions.get(rest[0]).mail(body()));
162
+ }
163
+ if(sub==='list'){requireArgs(rest,0,0,'agent list');return output(await team.agents.list({after:flags.after,limit:flags.limit}));}
164
+ if(sub==='get'){requireArgs(rest,1,1,'agent get AGENT');return output(await team.agents.get(rest[0]));}
165
+ if(sub==='closures'){requireArgs(rest,1,1,'agent closures AGENT');return output(await team.agents.closures(rest[0]));}
166
+ if(sub==='declare'){requireArgs(rest,0,0,'agent declare --body-file FILE');return output(await team.agents.declare(body()));}
167
+ if(sub==='update'||sub==='end'){
168
+ requireArgs(rest,1,1,'agent update|end AGENT --body-file FILE --if-match REVISION');
169
+ if(!flags.if_match)usage('--if-match is required');
170
+ return output(await team.agents[sub](rest[0],body(),{ifMatch:Number(flags.if_match)}));
171
+ }
172
+ if(sub==='record'){
173
+ requireArgs(rest,0,0,'agent record --session SESSION [--agent AGENT | --starter STARTER_SESSION]');
174
+ const session=currentSession();
175
+ // Without --agent the starter decides: an agent's session makes this one that agent's; otherwise it stays plain.
176
+ return output(flags.agent?await team.agents.ownSession(flags.agent,{session,...(flags.main?{main:true}:{}),...(flags.starter?{starter_session:flags.starter}:{})})
177
+ :await team.sessions.get(session).recordLineage(flags.starter?{starter_session:flags.starter}:{}));
178
+ }
179
+ usage('agent whoami|sessions|message|list|get|declare|update|end|closures|record');
180
+ }
181
+
182
+ /** C5: one role-and-binding shape. Sharing a session, a machine or anything else in the Org is a binding. */
183
+ async function permissionsCommand(sub,{flags,rest,team,output}) {
184
+ only(flags,['json','help','context','body_file']);
185
+ const body=()=>{if(!flags.body_file)usage('--body-file is required (use - for stdin)');return JSON.parse(readFileSync(flags.body_file==='-'?0:flags.body_file,'utf8'));};
186
+ if(sub==='roles'){requireArgs(rest,0,0,'permissions roles');return output(await team.permissions.roles());}
187
+ if(sub==='define-role'){requireArgs(rest,0,0,'permissions define-role --body-file FILE');return output(await team.permissions.defineRole(body()));}
188
+ if(sub==='bindings'){requireArgs(rest,2,2,'permissions bindings RESOURCE_KIND RESOURCE_ID');return output(await team.permissions.bindings({resource_kind:rest[0],resource_id:rest[1]}));}
189
+ if(sub==='bind'){requireArgs(rest,0,0,'permissions bind --body-file FILE');return output(await team.permissions.bind(body()));}
190
+ if(sub==='revoke'){requireArgs(rest,1,1,'permissions revoke BINDING');return output(await team.permissions.revoke(rest[0]));}
191
+ if(sub==='decide'){requireArgs(rest,0,0,'permissions decide --body-file FILE');return output(await team.permissions.decide(body()));}
192
+ usage('permissions roles|define-role|bindings|bind|revoke|decide');
193
+ }
194
+
195
+ /** The team's standard org doors: an identity issuer's groups it admits, the app it takes its members from, and the
196
+ * removals that source asked for that the team could not apply. */
197
+ async function orgCommand(sub,{flags,rest,client,context,output}) {
198
+ only(flags,['json','help','context']);
199
+ const path=`/api/v1/teams/${encodeURIComponent(context.team_id)}`;
200
+ if(sub==='groups'){requireArgs(rest,0,0,'org groups');return output(await client.request('GET',`${path}/identity/groups`));}
201
+ if(sub==='link-group'||sub==='unlink-group'){requireArgs(rest,2,2,`org ${sub} ISSUER GROUP`);return output(await client.request(sub==='link-group'?'POST':'DELETE',`${path}/identity/groups`,{body:{issuer:rest[0],group:rest[1]}}));}
202
+ if(sub==='source'){requireArgs(rest,0,0,'org source');return output(await client.request('GET',`${path}/membership-source`));}
203
+ if(sub==='link-source'){requireArgs(rest,1,1,'org link-source INSTALLATION');return output(await client.request('PUT',`${path}/membership-source`,{body:{installation_id:rest[0]}}));}
204
+ if(sub==='unlink-source'){requireArgs(rest,0,0,'org unlink-source');return output(await client.request('DELETE',`${path}/membership-source`));}
205
+ if(sub==='alarms'){requireArgs(rest,0,0,'org alarms');return output(await client.request('GET',`${path}/membership-alarms`));}
206
+ if(sub==='seats'){requireArgs(rest,0,0,'org seats');return output(await client.request('GET',`${path}/membership-seats`));}
207
+ usage('org groups|link-group|unlink-group|source|link-source|unlink-source|alarms|seats');
208
+ }
209
+
210
+ /** An automation's accountable person; agents it starts inherit this sponsor. */
211
+ async function principalsCommand(sub,{flags,rest,team,output}) {
212
+ only(flags,['json','help','context']);
213
+ if(sub==='sponsor'){requireArgs(rest,1,2,'principals sponsor AUTOMATION [PERSON]');return output(rest[1]?await team.principals.setSponsor(rest[0],rest[1]):await team.principals.sponsor(rest[0]));}
214
+ if(sub==='unsponsor'){requireArgs(rest,1,1,'principals unsponsor AUTOMATION');return output(await team.principals.clearSponsor(rest[0]));}
215
+ usage('principals sponsor|unsponsor');
216
+ }
217
+
218
+ async function identityRecordCommand(sub,{flags,rest,team,output}) {
219
+ only(flags,['json','help','context','body_file','if_match','observation','after','limit']);
220
+ const body=()=>{if(!flags.body_file)usage('--body-file is required (use - for stdin)');return JSON.parse(readFileSync(flags.body_file==='-'?0:flags.body_file,'utf8'));};
221
+ if(sub==='resolve'){requireArgs(rest,1,1,'teams identities resolve OBSERVATION');return output(await team.identity.resolve(rest[0]));}
222
+ if(sub==='observations'||sub==='sightings'){requireArgs(rest,0,0,'teams identities observations|sightings');return output(await team.identity[sub]({observation:flags.observation,after:flags.after,limit:flags.limit}));}
223
+ if(['observe','link','infer','declare-person'].includes(sub)){requireArgs(rest,0,0,'teams identities observe|link|declare-person --body-file FILE');return output(await team.identity[sub==='declare-person'?'declarePerson':sub](body()));}
224
+ if(sub==='revoke'||sub==='revoke-inference'){requireArgs(rest,1,1,'teams identities revoke LINK --if-match REVISION');return output(await team.identity[sub==='revoke'?'revoke':'revokeInference'](rest[0],{ifMatch:Number(flags.if_match)}));}
225
+ usage('teams identities observations|sightings|resolve|observe|declare-person|link|revoke');
226
+ }
227
+
76
228
  function redactAppSecrets(value) {
77
229
  if(!value||typeof value!=='object')return value;
78
230
  if(Array.isArray(value))return value.map(redactAppSecrets);
@@ -842,6 +994,16 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
842
994
  const machineEvents = new MachineEvents({home:daemonHome,server:context.origin,teamId:context.team_id,credential:saved.credential.token,machineName:saved.machine.name,binary});
843
995
  const machine = new TeamsMachine({ home: daemonHome, supercode: binary, ...(flags.tmux_socket ? { tmuxSocket: String(flags.tmux_socket) } : {}), link: { server: context.origin, teamId: context.team_id, machineId: saved.machine.id, credential: saved.credential.token, incarnationId: connector.state.incarnation_id, configuration: () => connector.configuration() }, onTurn: (turn) => connector.noteTurn(turn), onTurnWithdrawn: (turn) => connector.withdrawTurn(turn) });
844
996
  machine.machineEvents = machineEvents;
997
+ connector.closeAgentSession=async id=>{
998
+ const channel=await openLocalMachine(paths(daemonHome).socket);
999
+ try{const rpc=await nodeRpc(channel);return await rpc.call('harness.v1.agents.closeSession',{closure:id},{timeoutMs:60000});}
1000
+ finally{channel.close();}
1001
+ };
1002
+ connector.agentClosureReceipt=async id=>{
1003
+ const channel=await openLocalMachine(paths(daemonHome).socket);
1004
+ try{const rpc=await nodeRpc(channel);return await rpc.call('harness.v1.agents.closureReceipt',{closure:id});}
1005
+ finally{channel.close();}
1006
+ };
845
1007
  machine.on('link', (event) => { if (event.event === 'replaced') process.stderr.write(`supercode teams: ${event.error}. Two connectors were running for this machine; stop one of them (\`supercode teams disconnect\` where it should not run).\n`); });
846
1008
  // The link's state for the watch after start: down until it is up, down again (since when) when it drops. A
847
1009
  // `replaced` link is left alone: another connector for this machine owns it, by design.
@@ -859,7 +1021,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
859
1021
  // restarts (from a supercode old enough not to wait for the service): it yields to the service's connector, which
860
1022
  // otherwise failed on EADDRINUSE at every restart and left this machine unlinked.
861
1023
  if (saved.service) await retireLinklessDaemon(paths(daemonHome).machine);
862
- try { await machine.start(); await connector.start({ roots: () => saved.roots }); machineEvents.start(); } catch (error) { await stopAll(); throw error; }
1024
+ try { await machine.start(); await connector.start({ roots: () => saved.roots,bindings:()=>machine.sessionBindings(saved.machine.id,connector.state.incarnation_id) }); machineEvents.start(); } catch (error) { await stopAll(); throw error; }
863
1025
  output({ version: 1, context: scope(context), data: { machine: saved.machine, source: saved.source, roots: saved.roots.map(({ path: _path, ...item }) => item), socket: machine.socketPath, foreground: true } });
864
1026
  // Run by a service, the connector moves to an upgrade on its own: its readers would otherwise answer from the code
865
1027
  // they loaded until something restarted it (after 0.5.51 this Mac's went on reading a Codex turn waiting on an
@@ -1085,10 +1247,14 @@ function printHelp() {
1085
1247
  process.stdout.write(`supercode workspace commands:
1086
1248
  context create|list|show|use|remove
1087
1249
  teams list|create
1250
+ agent whoami|sessions|message|list|get|declare|update|end|closures|record
1251
+ principals sponsor AUTOMATION [PERSON] | principals unsponsor AUTOMATION
1252
+ permissions roles | define-role --body-file F | bindings KIND ID | bind --body-file F | revoke BINDING | decide --body-file F
1088
1253
  auth login|status|switch|logout (one Volter sign-in; "supercode login" is "auth login")
1089
1254
  login [ORIGIN] [--team TEAM] [--as CONTEXT] [--token-stdin|--invite-stdin] (Volter sign-in in the browser by default)
1090
1255
  identity link
1091
- server start [--volter-issuer ORIGIN] [--delegated-issuer ORIGIN] [--delegated-issuers ORIGIN,ORIGIN]
1256
+ identities observations|sightings|resolve|observe|declare-person|link|infer|revoke|revoke-inference --body-file FILE
1257
+ server start [--data-dir DIR] [--listen HOST:PORT] [--public-origin ORIGIN] [--init-team NAME] | server token
1092
1258
  publish pane|root TARGET --room ROOM --workplace ORIGIN --organization ORG [--scope view|control] [--name] [--key] [--machine] [--token-stdin] [--record-only]
1093
1259
  unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG [--machine] [--token-stdin] [--record-only|--workplace-only]
1094
1260
  connect|disconnect|status
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // The board's native PreToolUse command policy. No process lookup or signal occurs here.
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { resolve } from 'node:path';
6
+ export const processGuardReason = 'Pattern kills are refused for board sessions. Kill only the PID this session recorded when it started that process; use the owning service door for a service.';
7
+ export function patternKill(command) {
8
+ // Inspect shell bodies as well as nested `sh -c` / substitutions. Removing shell
9
+ // escapes and quotes catches split words, paths and quoted embedded commands.
10
+ const text = String(command).replace(/\\\r?\n/g, '').replace(/\\([a-zA-Z-])/g, '$1').replace(/["']/g, '');
11
+ const head = String.raw`(?:^|[;|&(\n{}]|\s-[a-zA-Z]*c\s+)\s*(?:(?:env|sudo|exec|command|xargs|nohup|do|then|else|!)\s+(?:--?[^\s;|&()]+\s+)*|[A-Za-z_][A-Za-z0-9_]*=[^\s;|&()]+\s+)*(?:[\w./-]+/)?`;
12
+ return new RegExp(head + String.raw`(?:pkill|killall)(?=$|[\s;|&()])`).test(text) ||
13
+ new RegExp(head + String.raw`pgrep\s+[^;|&\n)]*(?:--full\b|-[a-zA-Z]*f[a-zA-Z]*(?=$|\s))`).test(text);
14
+ }
15
+ const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
16
+ export function boardGuardArguments(kind, args, { cwd, enabled, node = process.execPath } = {}) {
17
+ if (!enabled) return args;
18
+ const command = `${quote(node)} ${quote(fileURLToPath(import.meta.url))}`;
19
+ const hooks = [{ matcher: '^Bash$', hooks: [{ type: 'command', command, timeout: 10 }] }];
20
+ if (kind === 'claude-code') {
21
+ const next = [...args], at = next.findIndex(arg => arg === '--settings' || arg.startsWith('--settings='));
22
+ if (at < 0) return [...next, '--settings', JSON.stringify({ hooks: { PreToolUse: hooks } })];
23
+ const inline = next[at].startsWith('--settings='), value = inline ? next[at].slice(11) : next[at + 1];
24
+ const settings = JSON.parse(value?.trimStart().startsWith('{') ? value : readFileSync(resolve(cwd || process.cwd(), value), 'utf8'));
25
+ settings.hooks ??= {}; settings.hooks.PreToolUse = [...(settings.hooks.PreToolUse ?? []), ...hooks];
26
+ next.splice(at, inline ? 1 : 2, '--settings', JSON.stringify(settings));
27
+ return next;
28
+ }
29
+ if (kind === 'codex') {
30
+ // Native Codex rules cannot split substitutions such as kill $(pgrep -f …).
31
+ // Its synchronous Bash hook can deny the entire command before execution.
32
+ // This invocation enables and trusts the board-vetted command policy.
33
+ const handler = `{ matcher = "^Bash$", hooks = [{ type = "command", command = ${JSON.stringify(command)}, timeout = 10 }] }`;
34
+ const next = [...args], at = next.indexOf('--');
35
+ next.splice(at < 0 ? next.length : at, 0, ...(next.includes('--dangerously-bypass-hook-trust') ? [] : ['--dangerously-bypass-hook-trust']), '-c', 'features.hooks=true', '-c', `hooks.PreToolUse=[${handler}]`);
36
+ return next;
37
+ }
38
+ throw new Error(`board process guard is unavailable for ${kind}; no unguarded session was launched`);
39
+ }
40
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
41
+ try {
42
+ const input = JSON.parse(readFileSync(0, 'utf8'));
43
+ const command = input.tool_input?.command ?? input.tool_input?.cmd;
44
+ if (typeof command !== 'string') throw new Error('Bash hook input has no command');
45
+ if (patternKill(command)) process.stdout.write(JSON.stringify({ hookSpecificOutput: {
46
+ hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: processGuardReason,
47
+ } }) + '\n');
48
+ } catch (error) {
49
+ process.stderr.write(`Board process policy could not read the command: ${error.message}. ${processGuardReason}\n`);
50
+ process.exitCode = 2;
51
+ }
52
+ }
package/client.mjs CHANGED
@@ -6,10 +6,10 @@ import { connect as netConnect } from 'node:net';
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import { EventEmitter } from 'node:events';
8
8
  import { Channel } from '@volter/teams/channel';
9
- import { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage } from '@volter/teams/client';
10
- import { lineSplitter } from './lines.mjs';
9
+ import { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage, harnessTransport } from '@volter/teams/client';
10
+ import { lineSplitter } from '@volter/teams/lines';
11
11
 
12
- export { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage };
12
+ export { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage, harnessTransport };
13
13
 
14
14
  export async function execServerBridge(channel, harness, { host = '127.0.0.1' } = {}) {
15
15
  // a channel, or a function opening one per connection (closed with it), so a daemon's restart strands nothing
@@ -47,32 +47,6 @@ export async function execServerBridge(channel, harness, { host = '127.0.0.1' }
47
47
  };
48
48
  }
49
49
 
50
- /**
51
- * A `HarnessTransport` (harness SDK `ClientOptions.transport`) over the
52
- * channel's `harness` stream. Only an operator's channel is admitted to it.
53
- */
54
- export function harnessTransport(channel) {
55
- let stream = null;
56
- return {
57
- get running() { return stream !== null && !stream.closed; },
58
- async start(handlers) {
59
- stream = await channel.open('harness');
60
- const lines = lineSplitter();
61
- stream.on('data', (chunk) => {
62
- for (const line of lines(chunk)) if (line) handlers.onLine(line);
63
- });
64
- stream.on('close', (reason) => handlers.onExit(null, reason ?? null));
65
- },
66
- write(line) {
67
- if (!stream || stream.closed) throw new Error('harness stream is not open');
68
- stream.write(line.endsWith('\n') ? line : line + '\n');
69
- },
70
- async close() {
71
- stream?.close();
72
- stream = null;
73
- },
74
- };
75
- }
76
50
 
77
51
  /** The node's own doors (describe, grants, launch, files, ports, attention, titles) over the `node` stream. */
78
52
  export async function nodeRpc(channel) {
@@ -0,0 +1,28 @@
1
+ // End obligations survive reconnects. Once an attempt may have crossed the process boundary, it is never replayed.
2
+ export async function consumeAgentClosures(connector){
3
+ const c=connector,path=`/machines/${c.machineId}/agent-closures`;
4
+ const page=await c.request('GET',path),attempts=c.state.agent_closures??={};
5
+ for(const closure of page.items??[]){
6
+ if(c.abort.signal.aborted)return;
7
+ const target=`${path}/${encodeURIComponent(closure.id)}`;
8
+ let local=attempts[closure.id];
9
+ if(local?.result){await c.request('POST',`${target}/result`,local.result);local.reported=true;c.save();continue;}
10
+ if(local||closure.state==='claimed'){
11
+ if(closure.state==='pending')await c.request('POST',`${target}/claim`,{});
12
+ // Read the native receipt before classifying a lost RPC reply. Never replay the close.
13
+ if(!c.agentClosureReceipt)continue;
14
+ const receipt=await c.agentClosureReceipt(closure.id);
15
+ if(receipt.pending)continue;
16
+ local=attempts[closure.id]??={};local.result=receipt.result??{state:'unknown',reason:'prior closure attempt has no retained receipt'};c.save();
17
+ await c.request('POST',`${target}/result`,local.result);local.reported=true;c.save();continue;
18
+ }
19
+ local=attempts[closure.id]={attempted_at:c.clock.nowMs()};c.save();
20
+ const claim=await c.request('POST',`${target}/claim`,{});
21
+ if(!claim.execute){local.result={state:'unknown',reason:'closure was already claimed'};c.save();continue;}
22
+ try{
23
+ if(!c.closeAgentSession)throw new Error('this connector has no native closure door');
24
+ local.result=await c.closeAgentSession(closure.id);
25
+ }catch(error){local.error=String(error.message).slice(0,1024);c.save();continue;}
26
+ c.save();await c.request('POST',`${target}/result`,local.result);local.reported=true;c.save();
27
+ }
28
+ }
@@ -7,6 +7,7 @@ export interface ConnectorClock {
7
7
  }
8
8
 
9
9
  export interface LoadedConnectorSession {
10
+ resolved_profile?: import('@volter/supercode-harness-sdk').NormalizedSession['resolved_profile'];
10
11
  messages?: Array<NormalizedMessage | ({ value: NormalizedMessage } & Record<string, unknown>)>;
11
12
  turns?: Array<NormalizedMessage | ({ value: NormalizedMessage } & Record<string, unknown>)>;
12
13
  title?: string | null;