@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.24-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +35 -13
  2. package/README_ZH.md +35 -13
  3. package/compatibility.json +6 -2
  4. package/lib/artifacts.js +342 -0
  5. package/lib/capabilities.js +6 -4
  6. package/lib/client/ActivityPanel.js +5 -2
  7. package/lib/client/agent-teams-card-definition.js +100 -6
  8. package/lib/client.js +90 -7
  9. package/lib/client.js.map +1 -1
  10. package/lib/command.js +3 -2
  11. package/lib/git-artifact-stamp.json +2 -2
  12. package/lib/harness-compat.js +139 -24
  13. package/lib/index.js +26 -3
  14. package/lib/lineage.js +198 -0
  15. package/lib/mailbox.js +59 -34
  16. package/lib/members.js +355 -82
  17. package/lib/profiles.js +3 -2
  18. package/lib/quality-gates.js +850 -65
  19. package/lib/scheduler.js +22 -3
  20. package/lib/snapshot.js +22 -1
  21. package/lib/state.js +282 -13
  22. package/lib/tool-policies.js +187 -0
  23. package/lib/tools.js +382 -109
  24. package/lib/types/artifacts.d.ts +66 -0
  25. package/lib/types/client/activity-monitor.d.ts +6 -0
  26. package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
  27. package/lib/types/harness-compat.d.ts +78 -15
  28. package/lib/types/index.d.ts +26 -1
  29. package/lib/types/lineage.d.ts +92 -0
  30. package/lib/types/members.d.ts +113 -2
  31. package/lib/types/profiles.d.ts +6 -0
  32. package/lib/types/quality-gates.d.ts +216 -10
  33. package/lib/types/scheduler.d.ts +2 -1
  34. package/lib/types/snapshot.d.ts +20 -0
  35. package/lib/types/state.d.ts +22 -5
  36. package/lib/types/tool-policies.d.ts +119 -0
  37. package/lib/types/tools.d.ts +12 -1
  38. package/lib/types/types.d.ts +100 -0
  39. package/lib/types/workspace-audit.d.ts +27 -0
  40. package/lib/workspace-audit.js +52 -0
  41. package/package.json +61 -54
  42. package/pnpm-workspace.yaml +304 -273
  43. package/release-notes/v0.1.23.md +34 -0
  44. package/release-notes/v0.1.24-rc.1.md +13 -0
  45. package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/command.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import { agentTeamsSource } from "./harness-compat.js";
2
3
  import { parseProfileInvocation, resolveProfileTaskPlanning } from "./profiles.js";
3
4
  export const AGENT_TEAMS_COMMAND = 'agent-teams';
4
5
  const PROFILE_COMMAND_PREFIX = `${AGENT_TEAMS_COMMAND}-`;
@@ -125,7 +126,7 @@ export function installAgentTeamsGestureBoundary(ctx, getProfiles = () => ({}))
125
126
  invocation = invokedAgentTeamsInvocation(messages, getProfiles);
126
127
  }
127
128
  catch (error) {
128
- return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text: `AgentTeams profile parsing failed: ${String(error)}` }], source: { kind: 'agent-teams-command' } })] };
129
+ return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text: `AgentTeams profile parsing failed: ${String(error)}` }], source: agentTeamsSource({ kind: 'agent-teams-command' }) })] };
129
130
  }
130
131
  if (invocation === undefined)
131
132
  return decision;
@@ -138,6 +139,6 @@ export function installAgentTeamsGestureBoundary(ctx, getProfiles = () => ({}))
138
139
  const text = !known
139
140
  ? `AgentTeams profile "${invocation.profile}" does not exist. Available profiles: ${Object.keys(profiles).join(', ') || '(none)'}. Do not create a team.`
140
141
  : buildActivationDirective(invocation.goal, invocation.profile, resolveProfileTaskPlanning(matched?.[1]));
141
- return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'agent-teams-command', ...invocation.goal === '' ? {} : { goal: invocation.goal }, ...invocation.profile === undefined ? {} : { profile: invocation.profile } } })] };
142
+ return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text }], source: agentTeamsSource({ kind: 'agent-teams-command', ...invocation.goal === '' ? {} : { goal: invocation.goal }, ...invocation.profile === undefined ? {} : { profile: invocation.profile } }) })] };
142
143
  });
143
144
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "schema": 1,
3
- "source": "c90a0becc42c4a394f223739e76e01c8c4b6ba4dd1700e08cb65c81949a1f33c",
4
- "output": "1373527d718f862f5fae89f40dc6bd3347cef086159d596ff2519c5f5182bd4b"
3
+ "source": "983fb392dfa483a3095fd1e7ad67bfd5786d467c77244865d22e7ca7b046e2ad",
4
+ "output": "bb2031aa6d907a929ad81d99ab74f6f2c2136ce1f3a1e9bb68006453bff43908"
5
5
  }
@@ -1,6 +1,7 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import * as dshSession from '@deepseek-ai/dsh-session';
2
3
  import { SubagentError } from '@deepseek-ai/dsh-subagent';
3
- import { CAPTAIN_TOOL_NAMES } from "./tool-names.js";
4
+ import { BUILTIN_DENY_TOOLS, resolveMemberToolFilter } from "./tool-policies.js";
4
5
  /**
5
6
  * Exact protocol exported by dsh-subagent/internal in Alpha.5 and rc.1.
6
7
  * That subpath does not exist in Alpha.2, so importing it statically prevents
@@ -12,12 +13,95 @@ const hostPromptQueue = Symbol.for('dsh.subagent.queuePrompt');
12
13
  // 0.1.5 keeps queueHostSubagentPrompt but replaces its symbol with this
13
14
  // delivery-mode-aware implementation (packages/subagent/subagent/src/internal.ts).
14
15
  const hostPromptDeliver = Symbol.for('dsh.subagent.deliverPrompt');
16
+ export function usesManagedActivations(runtime) {
17
+ return typeof runtime.startActivation === 'function';
18
+ }
19
+ export async function startMember(runtime, spec) {
20
+ const host = runtime;
21
+ if (typeof host.startActivation === 'function') {
22
+ const { persona: _persona, toolFilter: _filter, ...request } = spec.request;
23
+ const activation = await host.startActivation.call(runtime, { ...spec, request, delivery: 'parent' });
24
+ // Preserve native settlement as the missing-report/error fallback on both
25
+ // fresh and resumed activations. mailbox.ts deduplicates successful reports.
26
+ void activation.result.catch(() => undefined);
27
+ return activation;
28
+ }
29
+ if (typeof host.startContinuable !== 'function')
30
+ return unsupported('missing member creation API');
31
+ return host.startContinuable.call(runtime, spec);
32
+ }
33
+ /** Wrap every available creation API, restoring only this plugin's own methods. */
34
+ export function guardSubagentCreation(ctx, check) {
35
+ const runtime = ctx.subagents;
36
+ ctx.effect(() => {
37
+ const disposers = [];
38
+ for (const key of ['start', 'startContinuable', 'startActivation']) {
39
+ const original = runtime[key];
40
+ if (typeof original !== 'function')
41
+ continue;
42
+ const descriptor = Object.getOwnPropertyDescriptor(runtime, key);
43
+ const guarded = async (...args) => {
44
+ const request = key === 'start' ? args[1] : args[0].request;
45
+ check(request.parent);
46
+ return Reflect.apply(original, ctx.subagents, args);
47
+ };
48
+ Object.defineProperty(runtime, key, { value: guarded, writable: true, configurable: true });
49
+ disposers.push(() => {
50
+ if (Object.getOwnPropertyDescriptor(runtime, key)?.value !== guarded)
51
+ return;
52
+ if (descriptor === undefined)
53
+ Reflect.deleteProperty(runtime, key);
54
+ else
55
+ Object.defineProperty(runtime, key, descriptor);
56
+ });
57
+ }
58
+ return () => { for (const dispose of disposers)
59
+ dispose(); };
60
+ }, 'agent-teams: member delegation budget');
61
+ }
15
62
  function boundary(runtime) {
16
63
  return runtime;
17
64
  }
18
65
  function unsupported(detail) {
19
66
  throw new Error(`agent-teams: unsupported Harness subagent contract (${detail}); use an explicitly tested Harness version and a coherent dependency installation`);
20
67
  }
68
+ /** Package identity a released plugin-wrapper source names on older hosts. */
69
+ export const AGENT_TEAMS_PLUGIN_NAME = '@nanmicoder/dsh-agent-teams';
70
+ /**
71
+ * First stored format generation whose migration edges admit producer-owned
72
+ * source kinds. The released v2→v3 payload audit validates every message source
73
+ * against a closed list of released kinds, so a stored format older than this
74
+ * must carry a released source or the whole session becomes unreadable once a
75
+ * newer host opens it (#160).
76
+ */
77
+ export const RELEASED_SOURCE_VOCABULARY_VERSION = 3;
78
+ /** Stored session format generation of the running host; 0 when it exposes none. */
79
+ export function hostSessionFormatVersion() {
80
+ const version = dshSession.SESSION_FORMAT_VERSION;
81
+ return typeof version === 'number' && Number.isSafeInteger(version) && version >= 0 ? version : 0;
82
+ }
83
+ /**
84
+ * Source for one AgentTeams-authored message on the running host.
85
+ *
86
+ * Current hosts mint producer-owned kinds: the Harness documents message
87
+ * sources as a merge-extensible vocabulary without a catch-all, so this plugin
88
+ * declares its own. Hosts that still store a pre-v3 generation are a different
89
+ * contract — their released v2→v3 migration rejects any source outside the
90
+ * released vocabulary, which is exactly how a plugin kind written on an older
91
+ * supported host poisons its session log for every later host. On those hosts
92
+ * the released plugin wrapper is used instead; the goal and profile already
93
+ * travel in the message text.
94
+ * @param modern - the producer-owned source to use where the host admits it.
95
+ * @returns a source accepted by the running host and by its migration chain.
96
+ */
97
+ export function agentTeamsSource(modern) {
98
+ if (hostSessionFormatVersion() >= RELEASED_SOURCE_VOCABULARY_VERSION)
99
+ return modern;
100
+ // Released-format hosts persist plugin-authored messages as this wrapper; the
101
+ // current vocabulary retired it, so the cast stays confined to this branch
102
+ // and `modern` is the only shape a current host ever sees.
103
+ return { kind: 'plugin', plugin: AGENT_TEAMS_PLUGIN_NAME };
104
+ }
21
105
  /**
22
106
  * 0.1.6 moved startup admission to serial agent/created. Older created events
23
107
  * have no source and precede session-start; ignore those until the old hook.
@@ -63,7 +147,7 @@ export function installContinuableMemberSetup(ctx, setup) {
63
147
  const installed = new WeakSet();
64
148
  const active = new Set();
65
149
  ctx.effect(() => {
66
- const stop = onAgentReady(ctx, (agent, vetoable) => {
150
+ const attach = (agent, vetoable) => {
67
151
  if (installed.has(agent))
68
152
  return;
69
153
  // Deliberately synchronous: awaiting here loses the first-request race.
@@ -101,18 +185,30 @@ export function installContinuableMemberSetup(ctx, setup) {
101
185
  dispose();
102
186
  throw error;
103
187
  }
104
- });
105
- return () => {
188
+ };
189
+ const stop = onAgentReady(ctx, attach);
190
+ const cleanup = () => {
106
191
  stop();
107
192
  for (const dispose of [...active])
108
193
  dispose();
109
194
  };
195
+ try {
196
+ // Reloads keep Agents alive but dispose the previous plugin's setup.
197
+ // Subscribe first, then hydrate the live registry; attach is idempotent.
198
+ for (const agent of ctx.agents.list())
199
+ attach(agent, false);
200
+ }
201
+ catch (error) {
202
+ cleanup();
203
+ throw error;
204
+ }
205
+ return cleanup;
110
206
  }, 'agent-teams: member lifecycle compatibility');
111
207
  }
112
208
  /** Queue a distinct host-authored turn; never substitute model-message steer. */
113
209
  export async function queueMemberPrompt(runtime, parent, childId, content, signal) {
114
210
  const host = boundary(runtime);
115
- const source = { kind: 'agent-teams' };
211
+ const source = agentTeamsSource({ kind: 'agent-teams' });
116
212
  if (typeof host.followup === 'function') {
117
213
  return host.followup.call(runtime, parent, childId, content, { source, signal });
118
214
  }
@@ -127,7 +223,7 @@ export async function queueMemberPrompt(runtime, parent, childId, content, signa
127
223
  /** Coordination joins the nearest step, including waking an idle/cold child. */
128
224
  export async function steerMemberPrompt(runtime, parent, childId, content, signal, live) {
129
225
  const host = boundary(runtime);
130
- const source = { kind: 'agent-teams' };
226
+ const source = agentTeamsSource({ kind: 'agent-teams' });
131
227
  const deliver = host[hostPromptDeliver];
132
228
  if (typeof deliver === 'function')
133
229
  return deliver.call(runtime, parent, childId, content, source, signal, 'steer');
@@ -247,15 +343,12 @@ export function restrictableToolNames(agent) {
247
343
  * forwarding a name the host dropped aborts every member spawn (#163, #164).
248
344
  * Depth enforcement itself does not depend on these names —
249
345
  * installMemberDelegationGuard bounds descendant creation by parent chain.
346
+ *
347
+ * This is the legacy filter of a member without a configured tool policy; a
348
+ * selected policy resolves through `resolveMemberToolFilter` instead (#59).
250
349
  */
251
350
  export function memberToolFilter(maxDepth, knownTools) {
252
- const depthDeny = maxDepth === 0 ? ['subagent', 'send_message'] : [];
253
- return {
254
- deny: [
255
- ...CAPTAIN_TOOL_NAMES,
256
- ...(knownTools === undefined ? depthDeny : depthDeny.filter(name => knownTools.has(name))),
257
- ],
258
- };
351
+ return resolveMemberToolFilter(undefined, { maxDepth, knownTools });
259
352
  }
260
353
  /**
261
354
  * Names a `tools.restrict()` rejection reported as unknown, or an empty list
@@ -270,33 +363,55 @@ function unknownToolNames(error) {
270
363
  return [...message.matchAll(/"([^"]+)"/g)].map(match => match[1]);
271
364
  }
272
365
  /**
273
- * Start one member, dropping any filter name the host reports as unknown and
274
- * retrying.
366
+ * Start one member, dropping only the built-in filter names the host reports
367
+ * as unknown and retrying.
275
368
  *
276
369
  * Resolving the filter against the registry view ({@link memberToolFilter})
277
370
  * only covers hosts that expose one. A rejected filter still leaves the member
278
371
  * without a session for the lifetime of the team, so the start must survive the
279
372
  * cases that resolution cannot see — no registry view, a name that is known but
280
373
  * not restrictable, a composition that mounted differently than expected. The
281
- * host names the offenders in its rejection, so they are removed and the start
282
- * retried; the retry only runs while the deny list strictly shrinks, and any
283
- * failure that is not an unknown-name report propagates untouched.
374
+ * host names the offenders in its rejection, so the relaxable ones are removed
375
+ * and the start retried.
376
+ *
377
+ * Only `relaxableDeny` names may be dropped, and only from `deny`. The default
378
+ * is exactly the built-in captain-only and depth entries (#163/#164); a name an
379
+ * owner-declared tool policy contributed is never removed, because silently
380
+ * spawning a member with a different filter than the owner declared is contract
381
+ * drift, not recovery. Dropping a denied name the host does not register
382
+ * changes nothing; dropping an allowed name would loosen the policy, so `allow`
383
+ * is never relaxed and the four required coordination tools can never be
384
+ * removed.
284
385
  * @param start - performs one start attempt for the given `toolFilter`.
285
386
  * @param filter - the filter to attempt first.
387
+ * @param relaxableDeny - built-in deny names the retry may drop.
286
388
  * @returns the started member.
287
389
  */
288
- export async function startMemberWithLenientFilter(start, filter) {
289
- let deny = [...filter.deny];
390
+ export async function startMemberWithLenientFilter(start, filter, relaxableDeny = BUILTIN_DENY_TOOLS) {
391
+ const current = {
392
+ ...filter.allow === undefined ? {} : { allow: [...filter.allow] },
393
+ deny: [...filter.deny],
394
+ };
290
395
  for (;;) {
291
396
  try {
292
- return await start({ deny });
397
+ return await start(current);
293
398
  }
294
399
  catch (error) {
295
400
  const unknown = unknownToolNames(error);
296
- const remaining = deny.filter(name => !unknown.includes(name));
297
- if (unknown.length === 0 || remaining.length === deny.length)
401
+ if (unknown.length === 0)
402
+ throw error;
403
+ const droppable = unknown.filter(name => relaxableDeny.includes(name) && current.deny.includes(name));
404
+ if (droppable.length === 0)
298
405
  throw error;
299
- deny = remaining;
406
+ current.deny = current.deny.filter(name => !droppable.includes(name));
300
407
  }
301
408
  }
302
409
  }
410
+ /** Targeted recursive cancellation renamed with the unified activation manager. */
411
+ export async function drainMemberChildren(runtime, parent, ids) {
412
+ const host = runtime;
413
+ const drain = host.drainChildren ?? host.drainContinuableChildren;
414
+ if (typeof drain !== 'function')
415
+ return unsupported('missing targeted child drain');
416
+ await drain.call(runtime, parent, ids);
417
+ }
package/lib/index.js CHANGED
@@ -27,7 +27,9 @@ import { join } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { collectArchivedTeamsActivity, collectTeamsActivity } from "./snapshot.js";
29
29
  import { findTeamByCaptain } from "./state.js";
30
+ import { leadIdentityOfAsync } from "./lineage.js";
30
31
  import { formatProfilesForPrompt } from "./profiles.js";
32
+ import { parseToolPolicies } from "./tool-policies.js";
31
33
  import { installTeamCapabilities } from "./capabilities.js";
32
34
  import { TEAM_TOOL_NAMES } from "./tool-names.js";
33
35
  import { authenticatedWebRoutes, readJsonRequest, RequestBodyError } from "./web-routes.js";
@@ -44,10 +46,20 @@ const fallbackRouteConfig = z.union([
44
46
  z.object({ provider: z.string().required(), model: z.string().required() }),
45
47
  z.const(undefined),
46
48
  ]);
49
+ // Same Schemastery defaulting trap for a tool policy: an `allow`-only policy
50
+ // would materialize `deny: []` and fail a `.min(1)` on an absent list. Model
51
+ // each accepted combination explicitly, so a policy that declares only one
52
+ // side stays valid while an empty side is still rejected.
53
+ const toolPolicyConfig = z.union([
54
+ z.object({ allow: z.array(z.string()).min(1).required(), deny: z.const(undefined) }),
55
+ z.object({ allow: z.const(undefined), deny: z.array(z.string()).min(1).required() }),
56
+ z.object({ allow: z.array(z.string()).min(1).required(), deny: z.array(z.string()).min(1).required() }),
57
+ ]);
47
58
  export const Config = z.object({
48
59
  stateDir: z.string().default('.agent-teams'),
49
60
  memberProvider: z.string().default('spawn'),
50
61
  memberModel: z.string(),
62
+ memberLlmProvider: z.string(),
51
63
  executionPrompt: z.string(),
52
64
  fallback: fallbackRouteConfig,
53
65
  profiles: z.dict(z.object({
@@ -63,6 +75,8 @@ export const Config = z.object({
63
75
  reasoning_effort: z.string(),
64
76
  executionPrompt: z.string(),
65
77
  fallback: fallbackRouteConfig,
78
+ // Owner-declared tool-policy id this profile member selects (#59).
79
+ tool_policy: z.string(),
66
80
  })).min(1).required(),
67
81
  taskPlanning: z.union([z.const('captain'), z.const('seed')]),
68
82
  reviewPolicy: z.object({
@@ -80,6 +94,7 @@ export const Config = z.object({
80
94
  dependencies: z.array(z.string()),
81
95
  })),
82
96
  })).default({}),
97
+ toolPolicies: z.dict(toolPolicyConfig).default({}),
83
98
  memberMaxDepth: z.natural().default(0),
84
99
  maxMembers: z.natural().min(1).default(8),
85
100
  promptSectionOrder: z.natural().default(117),
@@ -89,7 +104,7 @@ export const Config = z.object({
89
104
  export function usageSectionText(toolNames, profilesText = '') {
90
105
  return `AgentTeams captain protocol:
91
106
  1. Inspect current team state when needed, using agent_teams_status. Continue existing work without duplicating its roster/tasks. Create only when no current team exists, with the user's goal as description and approval="required"; automatic approval requires an explicit request to run immediately. Staged plans never spawn or schedule work.
92
- 2. Add each needed role once; members inherit your model route unless another is requested/needed. A requested profile goes to create({profile}); it supplies its roster. Seed profiles also supply tasks; captain-planning profiles require your DAG. Do not duplicate either.
107
+ 2. Add each needed role once; members inherit your model route unless another is requested/needed. A requested profile goes to create({profile}); it supplies its roster. Seed profiles also supply tasks; captain-planning profiles require your DAG. Do not duplicate either. A member may also select one owner-declared tool policy by id (tool_policy on add_member, a plan member, or a profile member): it is the only way to change which global tools that member sees and may invoke, and you can never pass raw allow/deny lists.
93
108
  3. Build the complete smallest useful DAG while staged. For an ordinary research/audit plan, pass the roster and dependency graph together in create({plan:{members,tasks}}) to avoid repeated setup rounds. Every task needs a subject; pass kind and assignee explicitly when the plan specifies them. Titles, descriptions and member roles do not set these fields. Dependencies represent prerequisites. Give every required contributor a task or explicit message. Present the plan and end your turn for review; never approve in that planning turn. Approve only after a later explicit user approval or the Web action.
94
109
  4. Respect Web approve/return/discard control messages. On return, ask what to change before editing; after the answer, use one atomic agent_teams_edit_plan batch (edit downstream references before removals), summarize and await review again. Never inspect or edit .agent-teams state files or plugin source code to revise plans. Discard does not authorize a replacement.
95
110
  5. The scheduler dispatches ready tasks after approval. Delegate; do not duplicate slow work or send messages merely to start a stage. Handle reports/user work, then yield when waiting is all that remains: reports wake you automatically. Use status after a delivery or user request, never busy-poll or wait for unassigned members.
@@ -105,11 +120,15 @@ export function apply(ctx, config) {
105
120
  stateDir: config.stateDir ?? '.agent-teams',
106
121
  memberProvider: config.memberProvider ?? 'spawn',
107
122
  memberModel: config.memberModel,
123
+ memberLlmProvider: config.memberLlmProvider,
108
124
  executionPrompt: config.executionPrompt,
109
125
  fallback: config.fallback,
110
126
  memberMaxDepth: config.memberMaxDepth ?? 0,
111
127
  maxMembers: config.maxMembers ?? 8,
112
128
  profiles: config.profiles ?? {},
129
+ // Owner configuration is validated once, at mount: an empty or malformed
130
+ // policy must never reach a member creation path.
131
+ toolPolicies: parseToolPolicies(config.toolPolicies),
113
132
  };
114
133
  // Provider registration is a sibling plugin's effect (`subagent-spawn` /
115
134
  // `subagent-fork` rows), which can land after this mount under the Loader's
@@ -166,6 +185,9 @@ export function apply(ctx, config) {
166
185
  const url = new URL(req.url ?? '/', 'http://x');
167
186
  const roots = workspaceRegistry.list().map((workspace) => ({
168
187
  workspace: workspace.title,
188
+ // The panel audits the same current bytes as agent_teams_status, so it
189
+ // cannot advertise a passing gate over a changed revision (#161).
190
+ workspacePath: workspace.path,
169
191
  stateRoot: join(workspace.path, resolved.stateDir),
170
192
  }));
171
193
  // ?archived=1 serves teams moved to archive/ (post-delete review).
@@ -213,7 +235,7 @@ export function apply(ctx, config) {
213
235
  }
214
236
  const workspace = captain.session.header.cwd ?? process.cwd();
215
237
  const stateRoot = join(workspace, resolved.stateDir);
216
- const team = await findTeamByCaptain(stateRoot, captain.id);
238
+ const team = await findTeamByCaptain(stateRoot, captain.id, await leadIdentityOfAsync(ctx, captain.session));
217
239
  if (team === undefined || team.id !== teamId) {
218
240
  res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
219
241
  res.end(JSON.stringify({ error: 'team not found for this captain' }));
@@ -270,7 +292,7 @@ export function apply(ctx, config) {
270
292
  }
271
293
  const workspace = captain.session.header.cwd ?? process.cwd();
272
294
  const stateRoot = join(workspace, resolved.stateDir);
273
- const team = await findTeamByCaptain(stateRoot, captain.id);
295
+ const team = await findTeamByCaptain(stateRoot, captain.id, await leadIdentityOfAsync(ctx, captain.session));
274
296
  if (team === undefined || team.id !== teamId) {
275
297
  res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
276
298
  res.end(JSON.stringify({ error: 'team not found for this captain' }));
@@ -330,6 +352,7 @@ export function apply(ctx, config) {
330
352
  ...typeof payload['executionPrompt'] === 'string' || payload['executionPrompt'] === null
331
353
  ? { executionPrompt: payload['executionPrompt'] }
332
354
  : {},
355
+ ...typeof payload['tool_policy'] === 'string' ? { toolPolicy: payload['tool_policy'] } : {},
333
356
  };
334
357
  }
335
358
  else if (action === 'update_task') {
package/lib/lineage.js ADDED
@@ -0,0 +1,198 @@
1
+ /** Maximum fork-chain depth resolved for one identity (cycle- and length-bounded). */
2
+ export const LINEAGE_MAX_DEPTH = 32;
3
+ /**
4
+ * Official optional service access. `ctx.get` is the reflection accessor for a
5
+ * service a plugin does not declare in `inject`; a missing service yields
6
+ * `undefined` instead of a contract error, and a getter that still refuses is
7
+ * contained here.
8
+ */
9
+ function optionalService(ctx, name) {
10
+ const get = ctx.get;
11
+ if (typeof get !== 'function')
12
+ return undefined;
13
+ try {
14
+ return get.call(ctx, name);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ /** Ancestry this process already resolved for one session. */
21
+ const ancestryCache = new Map();
22
+ /** The live session for one id: resident agent first, then the live session store. */
23
+ export function liveSession(ctx, id) {
24
+ const branded = id;
25
+ const agent = ctx.agents.get(branded);
26
+ if (agent !== undefined)
27
+ return agent.session;
28
+ return optionalService(ctx, 'sessions')?.get?.(branded);
29
+ }
30
+ /** Whether one session is a top-level conversation rather than a subagent child. */
31
+ export function isTopLevelSession(session) {
32
+ return session.header.origin !== 'subagent' && (session.header.delegationDepth ?? 0) === 0;
33
+ }
34
+ /** Durable parent of one session id: the live header, else the stored header. */
35
+ async function durableParent(ctx, id) {
36
+ const live = liveSession(ctx, id);
37
+ if (live !== undefined)
38
+ return live.header.parentSession;
39
+ const persistence = optionalService(ctx, 'sessionPersistence');
40
+ try {
41
+ const snapshot = await persistence?.stat?.(id);
42
+ const parent = snapshot?.header?.parentSession;
43
+ return parent;
44
+ }
45
+ catch {
46
+ // Unreadable stored metadata ends this chain; authorization then fails
47
+ // closed on the identity the caller itself provides.
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Durable ancestry of one session, nearest first. The calling session's own
53
+ * header supplies the first hop; later hops come from resident sessions or
54
+ * stored headers, so a fork resolves through sessions that are not loaded.
55
+ * @param ctx - the plugin context (injects `agents`).
56
+ * @param session - the calling session.
57
+ * @returns ancestor session ids, bounded by {@link LINEAGE_MAX_DEPTH}.
58
+ */
59
+ export async function resolveAncestry(ctx, session) {
60
+ const ancestry = [];
61
+ const seen = new Set([session.id]);
62
+ let parent = session.header.parentSession;
63
+ while (parent !== undefined && ancestry.length < LINEAGE_MAX_DEPTH && !seen.has(parent)) {
64
+ seen.add(parent);
65
+ ancestry.push(parent);
66
+ parent = await durableParent(ctx, parent);
67
+ }
68
+ ancestryCache.set(session.id, ancestry);
69
+ return ancestry;
70
+ }
71
+ /**
72
+ * Ancestry resolvable without awaiting: the calling session's durable first
73
+ * hop, then resident sessions, then whatever the async resolver cached.
74
+ */
75
+ export function sessionAncestry(ctx, session) {
76
+ const cached = ancestryCache.get(session.id);
77
+ if (cached !== undefined)
78
+ return cached;
79
+ const ancestry = [];
80
+ const seen = new Set([session.id]);
81
+ let parent = session.header.parentSession;
82
+ while (parent !== undefined && ancestry.length < LINEAGE_MAX_DEPTH && !seen.has(parent)) {
83
+ seen.add(parent);
84
+ ancestry.push(parent);
85
+ parent = liveSession(ctx, parent)?.header.parentSession;
86
+ }
87
+ return ancestry;
88
+ }
89
+ /** Resolve one session's lead identity synchronously (prompt-assembly path). */
90
+ export function leadIdentityOf(ctx, session) {
91
+ return {
92
+ sessionId: session.id,
93
+ ancestry: sessionAncestry(ctx, session),
94
+ inheritsLead: isTopLevelSession(session),
95
+ };
96
+ }
97
+ /** Resolve one session's lead identity durably (authorization paths). */
98
+ export async function leadIdentityOfAsync(ctx, session) {
99
+ return {
100
+ sessionId: session.id,
101
+ ancestry: await resolveAncestry(ctx, session),
102
+ inheritsLead: isTopLevelSession(session),
103
+ };
104
+ }
105
+ /** Whether one identity leads (or inherits the lead of) a team's recorded captain. */
106
+ export function teamLeadMatch(team, lead) {
107
+ const members = team.members ?? [];
108
+ // A listed member is never a lead, retired or not: a member session is a
109
+ // subagent child of its captain, so its parent chain contains the captain id
110
+ // too, and membership revocation must never hand that session lead authority.
111
+ // Member participation itself stays governed by the removed filter in the
112
+ // team lookups.
113
+ if (members.some(member => member.id === lead.sessionId))
114
+ return false;
115
+ if (team.captainSessionId === lead.sessionId)
116
+ return true;
117
+ if (!lead.inheritsLead)
118
+ return false;
119
+ // Descending from a member (a fork of one) is not lead identity either.
120
+ if (members.some(member => lead.ancestry.includes(member.id)))
121
+ return false;
122
+ return lead.ancestry.includes(team.captainSessionId);
123
+ }
124
+ /**
125
+ * Whether one live session id leads a team: the recorded captain itself, or a
126
+ * top-level fork descendant of it. Members never match through their parent
127
+ * chain, and a member's fork does not match either.
128
+ */
129
+ export function sessionLeadsTeam(ctx, team, sessionId) {
130
+ if (team.members?.some(member => member.id === sessionId) === true)
131
+ return false;
132
+ if (team.captainSessionId === sessionId)
133
+ return true;
134
+ const session = liveSession(ctx, sessionId);
135
+ return session !== undefined && teamLeadMatch(team, leadIdentityOf(ctx, session));
136
+ }
137
+ /** Durable {@link sessionLeadsTeam}: resolves the caller's chain through stored headers. */
138
+ export async function sessionLeadsTeamAsync(ctx, team, sessionId) {
139
+ if (team.members?.some(member => member.id === sessionId) === true)
140
+ return false;
141
+ if (team.captainSessionId === sessionId)
142
+ return true;
143
+ const session = liveSession(ctx, sessionId);
144
+ return session !== undefined && teamLeadMatch(team, await leadIdentityOfAsync(ctx, session));
145
+ }
146
+ /**
147
+ * Nearest live lead session of a team: the recorded captain while it is
148
+ * resident, otherwise the closest live fork descendant of it. Captain-directed
149
+ * reports use this so a human working in a fork still receives them, while the
150
+ * durable mail record stays addressed to the recorded captain.
151
+ * @param ctx - the plugin context (injects `agents`).
152
+ * @param team - current team state.
153
+ * @returns the live lead agent, or undefined when no lead session is resident.
154
+ */
155
+ export function nearestLiveLead(ctx, team, live) {
156
+ // The recorded captain is the delivery parent whenever it is resident; a
157
+ // registry without `list` (or with rows the lineage cannot read) only means
158
+ // the fork fallback is unavailable, never that this lookup fails.
159
+ const exact = ctx.agents.get(team.captainSessionId);
160
+ if (exact !== undefined)
161
+ return exact;
162
+ let candidates;
163
+ try {
164
+ candidates = live ?? ctx.agents.list();
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ let best;
170
+ let bestDistance = Number.POSITIVE_INFINITY;
171
+ for (const agent of candidates) {
172
+ try {
173
+ const identity = leadIdentityOf(ctx, agent.session);
174
+ if (!teamLeadMatch(team, identity))
175
+ continue;
176
+ const distance = identity.ancestry.indexOf(team.captainSessionId);
177
+ if (distance < 0 || distance >= bestDistance)
178
+ continue;
179
+ best = agent;
180
+ bestDistance = distance;
181
+ }
182
+ catch {
183
+ continue;
184
+ }
185
+ }
186
+ return best;
187
+ }
188
+ /**
189
+ * Diagnostic note for a caller that matched no team but is a fork, so an
190
+ * operator can distinguish "no team exists" from "a team exists that this
191
+ * session does not lead".
192
+ */
193
+ export function forkLineageNote(session) {
194
+ const parent = session.header.parentSession;
195
+ if (parent === undefined)
196
+ return '';
197
+ return ` (this session is a fork of ${parent}; no active team in this workspace is led by that session or by a lead session in its lineage)`;
198
+ }