macro-agent 0.1.11 → 0.2.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/dist/agent/agent-manager-v2.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.js +240 -7
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/agent/types.d.ts +47 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/boot-v2.d.ts +33 -0
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +142 -11
- package/dist/boot-v2.js.map +1 -1
- package/dist/cli/inbox-mcp-proxy.d.ts +36 -0
- package/dist/cli/inbox-mcp-proxy.d.ts.map +1 -0
- package/dist/cli/inbox-mcp-proxy.js +51 -0
- package/dist/cli/inbox-mcp-proxy.js.map +1 -0
- package/dist/dispatch/loadout-translation.d.ts +100 -0
- package/dist/dispatch/loadout-translation.d.ts.map +1 -0
- package/dist/dispatch/loadout-translation.js +90 -0
- package/dist/dispatch/loadout-translation.js.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts +89 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.js +261 -0
- package/dist/dispatch/mail-inbound-consumer.js.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts +75 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js +325 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js.map +1 -0
- package/dist/dispatch/permission-evaluator.d.ts +68 -0
- package/dist/dispatch/permission-evaluator.d.ts.map +1 -0
- package/dist/dispatch/permission-evaluator.js +159 -0
- package/dist/dispatch/permission-evaluator.js.map +1 -0
- package/dist/dispatch/permission-overlay.d.ts +64 -0
- package/dist/dispatch/permission-overlay.d.ts.map +1 -0
- package/dist/dispatch/permission-overlay.js +72 -0
- package/dist/dispatch/permission-overlay.js.map +1 -0
- package/dist/dispatch/permissions-handler.d.ts +71 -0
- package/dist/dispatch/permissions-handler.d.ts.map +1 -0
- package/dist/dispatch/permissions-handler.js +83 -0
- package/dist/dispatch/permissions-handler.js.map +1 -0
- package/dist/dispatch/spawn-agent-handler.d.ts +84 -0
- package/dist/dispatch/spawn-agent-handler.d.ts.map +1 -0
- package/dist/dispatch/spawn-agent-handler.js +85 -0
- package/dist/dispatch/spawn-agent-handler.js.map +1 -0
- package/dist/lifecycle/handlers-v2.d.ts +7 -0
- package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
- package/dist/lifecycle/handlers-v2.js +27 -0
- package/dist/lifecycle/handlers-v2.js.map +1 -1
- package/dist/map/lifecycle-bridge.d.ts +18 -0
- package/dist/map/lifecycle-bridge.d.ts.map +1 -1
- package/dist/map/lifecycle-bridge.js +23 -1
- package/dist/map/lifecycle-bridge.js.map +1 -1
- package/dist/map/mail-bridge.d.ts +55 -0
- package/dist/map/mail-bridge.d.ts.map +1 -0
- package/dist/map/mail-bridge.js +115 -0
- package/dist/map/mail-bridge.js.map +1 -0
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +245 -1
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/types.d.ts +15 -0
- package/dist/map/types.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.js +1 -0
- package/dist/mcp/tools/done-v2.js.map +1 -1
- package/dist/teams/seed-defaults.d.ts.map +1 -1
- package/dist/teams/seed-defaults.js +6 -2
- package/dist/teams/seed-defaults.js.map +1 -1
- package/dist/teams/team-loader.d.ts.map +1 -1
- package/dist/teams/team-loader.js +17 -1
- package/dist/teams/team-loader.js.map +1 -1
- package/dist/teams/team-runtime-v2.d.ts.map +1 -1
- package/dist/teams/team-runtime-v2.js +2 -0
- package/dist/teams/team-runtime-v2.js.map +1 -1
- package/package.json +6 -6
- package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
- package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
- package/src/agent/agent-manager-v2.ts +268 -8
- package/src/agent/types.ts +51 -0
- package/src/boot-v2.ts +190 -12
- package/src/cli/inbox-mcp-proxy.ts +56 -0
- package/src/dispatch/CLAUDE.md +129 -0
- package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +589 -0
- package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
- package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
- package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
- package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
- package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
- package/src/dispatch/loadout-translation.ts +138 -0
- package/src/dispatch/mail-inbound-consumer.ts +397 -0
- package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
- package/src/dispatch/permission-evaluator.ts +191 -0
- package/src/dispatch/permission-overlay.ts +89 -0
- package/src/dispatch/permissions-handler.ts +112 -0
- package/src/dispatch/spawn-agent-handler.ts +160 -0
- package/src/lifecycle/handlers-v2.ts +34 -0
- package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
- package/src/map/__tests__/mail-bridge.test.ts +196 -0
- package/src/map/lifecycle-bridge.ts +48 -2
- package/src/map/mail-bridge.ts +203 -0
- package/src/map/sidecar.ts +346 -1
- package/src/map/types.ts +21 -0
- package/src/mcp/tools/done-v2.ts +1 -0
- package/src/teams/seed-defaults.ts +6 -2
- package/src/teams/team-loader.ts +21 -2
- package/src/teams/team-runtime-v2.ts +2 -0
- package/src/workspace/__tests__/self-driving-yaml.test.ts +10 -2
- package/templates/teams/self-driving/team.yaml +142 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission Overlay Registry
|
|
3
|
+
*
|
|
4
|
+
* Process-singleton map of `agentId → Permissions` for in-flight
|
|
5
|
+
* dispatches. The `PreToolUse` hook installed at spawn time consults
|
|
6
|
+
* this registry on every tool call. Dispatch consumers (e.g., the
|
|
7
|
+
* mail-inbound-reuse-consumer) set the overlay when claiming an
|
|
8
|
+
* in-flight dispatch and clear it on resolution.
|
|
9
|
+
*
|
|
10
|
+
* Why a registry rather than a per-spawn argument:
|
|
11
|
+
* - The session is created with permissive rules at boot. The
|
|
12
|
+
* dispatch's narrower deny rules need to apply at *call time*, not
|
|
13
|
+
* at spawn time, because the agent already exists when the dispatch
|
|
14
|
+
* arrives. The hook closure captures `agentId` and reads the
|
|
15
|
+
* registry per-call so updates propagate without recreating the
|
|
16
|
+
* session.
|
|
17
|
+
*
|
|
18
|
+
* Semantics:
|
|
19
|
+
* - Intersection-only: the overlay can ADD denies to a session but
|
|
20
|
+
* cannot grant new allows. If the session was spawned with broad
|
|
21
|
+
* permissions and the overlay says `allow: [Read]`, the session's
|
|
22
|
+
* other tools still work — the overlay only tightens.
|
|
23
|
+
* - Single overlay per agent: `mail-inbound-reuse-consumer` already
|
|
24
|
+
* enforces one in-flight dispatch per agent (`recipient_busy`
|
|
25
|
+
* reject), so overwriting is safe.
|
|
26
|
+
*
|
|
27
|
+
* @module dispatch/permission-overlay
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface OverlayPermissions {
|
|
31
|
+
allow?: string[];
|
|
32
|
+
deny?: string[];
|
|
33
|
+
ask?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const overlays = new Map<string, OverlayPermissions>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply a permission overlay for an agent. Subsequent tool calls by
|
|
40
|
+
* that agent flow through the `PreToolUse` hook, which consults this
|
|
41
|
+
* registry. Overwrites any prior overlay for the same agent.
|
|
42
|
+
*/
|
|
43
|
+
export function setPermissionOverlay(
|
|
44
|
+
agentId: string,
|
|
45
|
+
perms: OverlayPermissions,
|
|
46
|
+
): void {
|
|
47
|
+
overlays.set(agentId, perms);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove the active overlay for an agent. Subsequent tool calls fall
|
|
52
|
+
* back to the session's static permission rules.
|
|
53
|
+
*/
|
|
54
|
+
export function clearPermissionOverlay(agentId: string): void {
|
|
55
|
+
overlays.delete(agentId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read the current overlay for an agent. Returns `undefined` when no
|
|
60
|
+
* overlay is in effect — the hook treats that as pass-through.
|
|
61
|
+
*/
|
|
62
|
+
export function getPermissionOverlay(
|
|
63
|
+
agentId: string,
|
|
64
|
+
): OverlayPermissions | undefined {
|
|
65
|
+
return overlays.get(agentId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clear all overlays. Used as defense-in-depth when a fresh consumer
|
|
70
|
+
* starts (process startup); also exposed for test isolation.
|
|
71
|
+
*/
|
|
72
|
+
export function clearAllPermissionOverlays(): void {
|
|
73
|
+
overlays.clear();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Test helper — alias for `clearAllPermissionOverlays`. Kept distinct
|
|
78
|
+
* so it's grep-able for "test-only" cleanup paths.
|
|
79
|
+
*/
|
|
80
|
+
export function _resetForTest(): void {
|
|
81
|
+
overlays.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Test helper — number of overlays currently active.
|
|
86
|
+
*/
|
|
87
|
+
export function _sizeForTest(): number {
|
|
88
|
+
return overlays.size;
|
|
89
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `x-dispatch/permissions.set` and `x-dispatch/permissions.clear` MAP
|
|
3
|
+
* notification handlers — runtime-agnostic wire shape, macro-agent-specific
|
|
4
|
+
* implementation.
|
|
5
|
+
*
|
|
6
|
+
* Called by OpenHive's orchestrator on the ACP+reuse dispatch path to apply
|
|
7
|
+
* per-dispatch loadout deny/allow rules to a long-lived agent's session at
|
|
8
|
+
* runtime, without recreating the session. The handler invokes
|
|
9
|
+
* `setPermissionOverlay` (resp. `clearPermissionOverlay`); the prompt
|
|
10
|
+
* iterator in `agent-manager-v2.ts` enforces the overlay against
|
|
11
|
+
* `permission_request` ACP session updates.
|
|
12
|
+
*
|
|
13
|
+
* See `docs/PERMISSION_OVERLAY_ACP_DESIGN.md` for the four-process flow
|
|
14
|
+
* diagram and rationale. The mail+reuse path uses the same overlay
|
|
15
|
+
* registry but sets/clears it directly from `mail-inbound-reuse-consumer`
|
|
16
|
+
* (which has a clean entry point on swarm side); ACP+reuse needs this MAP
|
|
17
|
+
* wire because the dispatch's prompt arrives through the ACP server with
|
|
18
|
+
* no equivalent consumer to bracket.
|
|
19
|
+
*
|
|
20
|
+
* Wire shape (set):
|
|
21
|
+
*
|
|
22
|
+
* request: { agent_id: string, deny?: string[], allow?: string[] }
|
|
23
|
+
* response: { ok: true } | { ok: false, error: string }
|
|
24
|
+
*
|
|
25
|
+
* Wire shape (clear):
|
|
26
|
+
*
|
|
27
|
+
* request: { agent_id: string }
|
|
28
|
+
* response: { ok: true } | { ok: false, error: string }
|
|
29
|
+
*
|
|
30
|
+
* Notification-pair pattern matches the existing `x-dispatch/spawn-agent`
|
|
31
|
+
* handler (the MAP SDK's AgentConnection doesn't expose
|
|
32
|
+
* setRequestHandler, so we use notifications with correlation_ids).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
setPermissionOverlay,
|
|
37
|
+
clearPermissionOverlay,
|
|
38
|
+
} from "./permission-overlay.js";
|
|
39
|
+
|
|
40
|
+
export interface PermissionsSetRequest {
|
|
41
|
+
agent_id: string;
|
|
42
|
+
deny?: string[];
|
|
43
|
+
allow?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PermissionsClearRequest {
|
|
47
|
+
agent_id: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type PermissionsResponse =
|
|
51
|
+
| { ok: true }
|
|
52
|
+
| { ok: false; error: string };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle `x-dispatch/permissions.set.request`. Sets the per-agent overlay
|
|
56
|
+
* registry entry that the prompt iterator consults when answering
|
|
57
|
+
* `permission_request` session updates.
|
|
58
|
+
*
|
|
59
|
+
* Idempotent: re-setting on the same agent overwrites the prior overlay.
|
|
60
|
+
* Safe: returns an error response (rather than throwing) for malformed
|
|
61
|
+
* params or unknown agents — the orchestrator should log and proceed.
|
|
62
|
+
*/
|
|
63
|
+
export function handlePermissionsSet(
|
|
64
|
+
params: PermissionsSetRequest,
|
|
65
|
+
log: (msg: string) => void = console.log,
|
|
66
|
+
): PermissionsResponse {
|
|
67
|
+
if (!params || typeof params.agent_id !== "string" || params.agent_id === "") {
|
|
68
|
+
return { ok: false, error: "x-dispatch/permissions.set: missing or invalid 'agent_id'" };
|
|
69
|
+
}
|
|
70
|
+
if (params.deny !== undefined && !Array.isArray(params.deny)) {
|
|
71
|
+
return { ok: false, error: "x-dispatch/permissions.set: 'deny' must be an array of strings" };
|
|
72
|
+
}
|
|
73
|
+
if (params.allow !== undefined && !Array.isArray(params.allow)) {
|
|
74
|
+
return { ok: false, error: "x-dispatch/permissions.set: 'allow' must be an array of strings" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const overlay = {
|
|
78
|
+
...(params.deny ? { deny: params.deny } : {}),
|
|
79
|
+
...(params.allow ? { allow: params.allow } : {}),
|
|
80
|
+
};
|
|
81
|
+
setPermissionOverlay(params.agent_id, overlay);
|
|
82
|
+
log(
|
|
83
|
+
`[x-dispatch/permissions.set] agent=${params.agent_id} ` +
|
|
84
|
+
`deny=${params.deny?.length ?? 0} allow=${params.allow?.length ?? 0}`,
|
|
85
|
+
);
|
|
86
|
+
return { ok: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Handle `x-dispatch/permissions.clear.request`. Removes any overlay set
|
|
91
|
+
* on the named agent. Idempotent: clearing a non-existent overlay is a
|
|
92
|
+
* no-op success. Always paired with a prior `set` from the same dispatch;
|
|
93
|
+
* the orchestrator must guarantee `clear` runs even on dispatch failure.
|
|
94
|
+
*/
|
|
95
|
+
export function handlePermissionsClear(
|
|
96
|
+
params: PermissionsClearRequest,
|
|
97
|
+
log: (msg: string) => void = console.log,
|
|
98
|
+
): PermissionsResponse {
|
|
99
|
+
if (!params || typeof params.agent_id !== "string" || params.agent_id === "") {
|
|
100
|
+
return { ok: false, error: "x-dispatch/permissions.clear: missing or invalid 'agent_id'" };
|
|
101
|
+
}
|
|
102
|
+
clearPermissionOverlay(params.agent_id);
|
|
103
|
+
log(`[x-dispatch/permissions.clear] agent=${params.agent_id}`);
|
|
104
|
+
return { ok: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const X_DISPATCH_PERMISSIONS_METHODS = {
|
|
108
|
+
SET_REQUEST: "x-dispatch/permissions.set.request",
|
|
109
|
+
SET_RESPONSE: "x-dispatch/permissions.set.response",
|
|
110
|
+
CLEAR_REQUEST: "x-dispatch/permissions.clear.request",
|
|
111
|
+
CLEAR_RESPONSE: "x-dispatch/permissions.clear.response",
|
|
112
|
+
} as const;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `x-dispatch/spawn-agent` MAP request handler — runtime-agnostic wire shape,
|
|
3
|
+
* macro-agent-specific implementation.
|
|
4
|
+
*
|
|
5
|
+
* Called by OpenHive's orchestrator when ACP-routing a dispatch with
|
|
6
|
+
* `lifecycle: 'fresh'` — the hub asks the swarm to spawn a fresh agent
|
|
7
|
+
* with the materialized loadout's structured fields applied to its spawn
|
|
8
|
+
* config. The handler returns the spawned agent's id; the orchestrator
|
|
9
|
+
* then attaches an ACP stream via the existing `findAcpAgentInfo` path.
|
|
10
|
+
*
|
|
11
|
+
* Wire shape — canonical (swarm-dispatch's `RemoteSpawnRequest`):
|
|
12
|
+
*
|
|
13
|
+
* request:
|
|
14
|
+
* {
|
|
15
|
+
* role: string,
|
|
16
|
+
* capabilities_required: string[], // e.g. ["acp"]
|
|
17
|
+
* lifecycle: "fresh" | "reuse",
|
|
18
|
+
* cwd?: string,
|
|
19
|
+
* fullAutonomous?: boolean,
|
|
20
|
+
* consumer_extensions?: {
|
|
21
|
+
* openhive?: { loadout?: WireLoadout }, // OpenHive payload
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* response:
|
|
26
|
+
* { agentId: string }
|
|
27
|
+
*
|
|
28
|
+
* Legacy shape support: pre-Tier-2 callers passed `loadout` at the top
|
|
29
|
+
* level (not under consumer_extensions). The handler accepts both for
|
|
30
|
+
* one release window — `consumer_extensions.openhive.loadout` is read
|
|
31
|
+
* first; falls back to top-level `loadout`.
|
|
32
|
+
*
|
|
33
|
+
* Symmetric with the mail path: both routes use `loadoutToSpawnOptions`
|
|
34
|
+
* to translate the wire-level loadout into macro-agent's spawn options.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { AgentManager } from "../agent/agent-manager.js";
|
|
38
|
+
import { loadoutToSpawnOptions, type WireLoadout } from "./loadout-translation.js";
|
|
39
|
+
|
|
40
|
+
export interface SpawnAgentRequest {
|
|
41
|
+
role: string;
|
|
42
|
+
/** Optional cwd; defaults to the swarm's process.cwd() via agentManager. */
|
|
43
|
+
cwd?: string;
|
|
44
|
+
capabilities_required?: string[];
|
|
45
|
+
lifecycle?: "fresh" | "reuse";
|
|
46
|
+
/**
|
|
47
|
+
* @deprecated — pre-Tier-2 top-level loadout slot. Newer callers ship
|
|
48
|
+
* the loadout under `consumer_extensions.openhive.loadout`. Both
|
|
49
|
+
* shapes are accepted for one release window.
|
|
50
|
+
*/
|
|
51
|
+
loadout?: WireLoadout;
|
|
52
|
+
/** Canonical consumer-extension namespace (Tier 2+). */
|
|
53
|
+
consumer_extensions?: {
|
|
54
|
+
openhive?: { loadout?: WireLoadout };
|
|
55
|
+
[otherConsumer: string]: unknown;
|
|
56
|
+
};
|
|
57
|
+
fullAutonomous?: boolean;
|
|
58
|
+
/** Optional initial task description; defaults to a placeholder so the
|
|
59
|
+
* agent's session has *something* to render until the orchestrator's
|
|
60
|
+
* ACP `session/prompt` arrives. */
|
|
61
|
+
task?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SpawnAgentResponse {
|
|
65
|
+
agentId: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SpawnAgentHandlerDeps {
|
|
69
|
+
agentManager: AgentManager;
|
|
70
|
+
/**
|
|
71
|
+
* Optional barrier called after spawn so the spawned agent's
|
|
72
|
+
* lifecycle-bridge has had time to register its `protocols: ['acp']`
|
|
73
|
+
* capability with the hub before this handler returns. Without this
|
|
74
|
+
* the orchestrator's subsequent `findAcpAgentInfo` would race the
|
|
75
|
+
* registration and return null.
|
|
76
|
+
*
|
|
77
|
+
* Returns true once the agent is registered and ACP-capable; false on
|
|
78
|
+
* timeout. Implementations typically wait on a Promise resolved by
|
|
79
|
+
* the lifecycle-bridge's `agent.registered` callback.
|
|
80
|
+
*/
|
|
81
|
+
waitForAcpRegistration?: (
|
|
82
|
+
agentId: string,
|
|
83
|
+
timeoutMs?: number,
|
|
84
|
+
) => Promise<boolean>;
|
|
85
|
+
/** Optional logger; defaults to console.log. */
|
|
86
|
+
log?: (msg: string) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DEFAULT_REGISTRATION_TIMEOUT_MS = 5_000;
|
|
90
|
+
|
|
91
|
+
export async function handleDispatchSpawnAgent(
|
|
92
|
+
params: SpawnAgentRequest,
|
|
93
|
+
deps: SpawnAgentHandlerDeps,
|
|
94
|
+
): Promise<SpawnAgentResponse> {
|
|
95
|
+
const { agentManager, waitForAcpRegistration, log = console.log } = deps;
|
|
96
|
+
|
|
97
|
+
if (!params.role) {
|
|
98
|
+
throw new Error("x-dispatch/spawn-agent: missing 'role'");
|
|
99
|
+
}
|
|
100
|
+
if (params.lifecycle && params.lifecycle !== "fresh") {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`x-dispatch/spawn-agent: lifecycle='${params.lifecycle}' not supported by this handler — ` +
|
|
103
|
+
`'reuse' is handled hub-side via findAcpAgentInfo, not via this method`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// cwd is optional — agentManager.spawn defaults to its own defaultCwd
|
|
107
|
+
// (typically process.cwd() of the macro-agent process) when omitted.
|
|
108
|
+
|
|
109
|
+
// Loadout location: prefer the canonical consumer_extensions.openhive
|
|
110
|
+
// slot (Tier 2+); fall back to the top-level field (pre-Tier-2). Older
|
|
111
|
+
// hubs ship the loadout at the top level; newer hubs ship under
|
|
112
|
+
// consumer_extensions. Both work during the dual-listen window.
|
|
113
|
+
const loadout: WireLoadout | undefined =
|
|
114
|
+
params.consumer_extensions?.openhive?.loadout ?? params.loadout;
|
|
115
|
+
|
|
116
|
+
const fullAutonomous = params.fullAutonomous ?? true;
|
|
117
|
+
const spawnLoadoutOpts = loadoutToSpawnOptions(loadout, {
|
|
118
|
+
fullAutonomous,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
log(
|
|
122
|
+
`[x-dispatch/spawn-agent] Spawning fresh ${params.role} cwd=${params.cwd} ` +
|
|
123
|
+
`permissions=${
|
|
124
|
+
spawnLoadoutOpts.permissions ? JSON.stringify(spawnLoadoutOpts.permissions) : "(none)"
|
|
125
|
+
} fullAutonomous=${fullAutonomous}`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const spawned = await agentManager.spawn({
|
|
129
|
+
role: params.role,
|
|
130
|
+
...(params.cwd ? { cwd: params.cwd } : {}),
|
|
131
|
+
parent: null,
|
|
132
|
+
// Mail-inbound and dispatch-spawned coordinators alike run under
|
|
133
|
+
// isolated settings so host-level Claude plugins don't auto-mount.
|
|
134
|
+
isolatedSettings: true,
|
|
135
|
+
task:
|
|
136
|
+
params.task ?? "Awaiting dispatch (created by dispatch/spawn-agent)",
|
|
137
|
+
...spawnLoadoutOpts,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Block until the lifecycle-bridge has registered ACP capabilities,
|
|
141
|
+
// otherwise the orchestrator's subsequent `findAcpAgentInfo` lookup
|
|
142
|
+
// races the registration and fails. Best-effort: a timeout returns
|
|
143
|
+
// anyway and the orchestrator will retry on its own poll.
|
|
144
|
+
if (waitForAcpRegistration) {
|
|
145
|
+
const ok = await waitForAcpRegistration(
|
|
146
|
+
spawned.id,
|
|
147
|
+
DEFAULT_REGISTRATION_TIMEOUT_MS,
|
|
148
|
+
).catch(() => false);
|
|
149
|
+
if (!ok) {
|
|
150
|
+
log(
|
|
151
|
+
`[x-dispatch/spawn-agent] Warning: ACP registration not confirmed for ` +
|
|
152
|
+
`agent ${spawned.id} within ${DEFAULT_REGISTRATION_TIMEOUT_MS}ms; ` +
|
|
153
|
+
`orchestrator may need to retry.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
log(`[x-dispatch/spawn-agent] Spawn complete agentId=${spawned.id}`);
|
|
159
|
+
return { agentId: spawned.id };
|
|
160
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
import type { InboxAdapter } from "../adapters/types.js";
|
|
17
17
|
import type { TasksAdapter } from "../adapters/types.js";
|
|
18
18
|
import type { AgentManager } from "../agent/agent-manager.js";
|
|
19
|
+
import type { AgentStore } from "../agent/agent-store.js";
|
|
20
|
+
import { getPermissionOverlay } from "../dispatch/permission-overlay.js";
|
|
19
21
|
import type {
|
|
20
22
|
LifecycleContext,
|
|
21
23
|
DoneArgs,
|
|
@@ -47,6 +49,12 @@ export interface HandlerDepsV2 {
|
|
|
47
49
|
* Without it, commits use raw git (legacy / null-workspace path).
|
|
48
50
|
*/
|
|
49
51
|
workspaceManager?: WorkspaceManager;
|
|
52
|
+
/**
|
|
53
|
+
* Optional agent store. When provided, the done() summary is persisted in
|
|
54
|
+
* agent metadata for parentless agents (mail-inbound dispatch workers) so
|
|
55
|
+
* the dispatch reply bridge can forward it as a hub mail turn.
|
|
56
|
+
*/
|
|
57
|
+
agentStore?: AgentStore;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
// =============================================================================
|
|
@@ -210,6 +218,32 @@ async function handleWorkerDone(
|
|
|
210
218
|
warnings.push("Failed to emit WORKER_DONE");
|
|
211
219
|
}
|
|
212
220
|
|
|
221
|
+
// Step 3b: Persist `_lastSummary` to agent metadata when:
|
|
222
|
+
// - Agent is parentless (mail-inbound fresh-spawn dispatch workers —
|
|
223
|
+
// emitSignal is a no-op for parentless, so metadata is the only
|
|
224
|
+
// reply-path channel), OR
|
|
225
|
+
// - Agent is processing a dispatch (Phase 1 permission overlay set
|
|
226
|
+
// for this agent → in-flight). Gives the mail-inbound-reuse-consumer
|
|
227
|
+
// a metadata-side fallback for the reply summary that's
|
|
228
|
+
// independent of whether the prompt iterator's update stream
|
|
229
|
+
// races with ACP connection close. Applies to both parentless
|
|
230
|
+
// AND parented in-flight agents (parented dispatch targets are
|
|
231
|
+
// the typical mail+reuse setup).
|
|
232
|
+
const inDispatch = !!getPermissionOverlay(context.agentId);
|
|
233
|
+
if ((inDispatch || !context.parentId) && args.summary && deps.agentStore) {
|
|
234
|
+
try {
|
|
235
|
+
const existing = deps.agentStore.getAgent(context.agentId);
|
|
236
|
+
deps.agentStore.updateAgent(context.agentId, {
|
|
237
|
+
metadata: {
|
|
238
|
+
...(existing?.metadata ?? {}),
|
|
239
|
+
_lastSummary: args.summary,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
} catch {
|
|
243
|
+
// best effort — don't block termination
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
213
247
|
// NOTE: Task transition is NOT done here — AgentManagerV2.terminate() handles
|
|
214
248
|
// it to avoid double-transitioning.
|
|
215
249
|
|
|
@@ -363,4 +363,68 @@ describe("LifecycleBridge", () => {
|
|
|
363
363
|
expect.objectContaining({ name: "agent-99" }),
|
|
364
364
|
);
|
|
365
365
|
});
|
|
366
|
+
|
|
367
|
+
// ── awaitRegistration() ─────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
describe("awaitRegistration", () => {
|
|
370
|
+
it("returns true once map/agents/register completes (mapId populated)", async () => {
|
|
371
|
+
// Hub returns a MAP-assigned ULID for the registered agent.
|
|
372
|
+
conn.callExtension.mockResolvedValueOnce({ agent: { id: "map-ulid-A" } });
|
|
373
|
+
|
|
374
|
+
const { callback, awaitRegistration } = createLifecycleBridge(
|
|
375
|
+
conn,
|
|
376
|
+
{} as AgentStore,
|
|
377
|
+
scope,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
callback({
|
|
381
|
+
type: "spawned",
|
|
382
|
+
agent: mockAgent({ id: "agent-A", role: "coordinator" }),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// The async register IIFE inside the bridge does waitForLocalMapId(~500ms)
|
|
386
|
+
// then resolves callExtension. Wait long enough for that to settle.
|
|
387
|
+
const ok = await awaitRegistration("agent-A", 2_000);
|
|
388
|
+
expect(ok).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("returns false if timeout elapses before registration completes", async () => {
|
|
392
|
+
// Stall the registration call so mapId is never populated within the window.
|
|
393
|
+
let resolveExt: (value: unknown) => void = () => {};
|
|
394
|
+
conn.callExtension.mockImplementationOnce(
|
|
395
|
+
() =>
|
|
396
|
+
new Promise((resolve) => {
|
|
397
|
+
resolveExt = resolve;
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const { callback, awaitRegistration } = createLifecycleBridge(
|
|
402
|
+
conn,
|
|
403
|
+
{} as AgentStore,
|
|
404
|
+
scope,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
callback({
|
|
408
|
+
type: "spawned",
|
|
409
|
+
agent: mockAgent({ id: "agent-B", role: "coordinator" }),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const ok = await awaitRegistration("agent-B", 200);
|
|
413
|
+
expect(ok).toBe(false);
|
|
414
|
+
|
|
415
|
+
// Cleanup the dangling promise so vitest doesn't warn about leaks.
|
|
416
|
+
resolveExt({ agent: { id: "map-ulid-late" } });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("returns false for an agentId that was never spawned", async () => {
|
|
420
|
+
const { awaitRegistration } = createLifecycleBridge(
|
|
421
|
+
conn,
|
|
422
|
+
{} as AgentStore,
|
|
423
|
+
scope,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const ok = await awaitRegistration("never-spawned-agent", 150);
|
|
427
|
+
expect(ok).toBe(false);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
366
430
|
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for mail-bridge — verifies that hub `mail/turn.received`
|
|
3
|
+
* notifications are forwarded into the local inbox with the correct shape
|
|
4
|
+
* so createAgentInboxPort.onIncoming can classify them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
8
|
+
import { setupMailBridge, type MailBridgeConnection } from "../mail-bridge.js";
|
|
9
|
+
|
|
10
|
+
// Minimal InboxAdapter mock that captures send() calls.
|
|
11
|
+
function mockInboxAdapter() {
|
|
12
|
+
return {
|
|
13
|
+
registerAgent: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
send: vi.fn().mockResolvedValue("msg-001"),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Minimal MAP connection mock.
|
|
19
|
+
function mockConnection(): MailBridgeConnection & {
|
|
20
|
+
onNotification: ReturnType<typeof vi.fn>;
|
|
21
|
+
offNotification: ReturnType<typeof vi.fn>;
|
|
22
|
+
_fire: (params: unknown) => Promise<void>;
|
|
23
|
+
} {
|
|
24
|
+
let registeredHandler: ((params: unknown) => void | Promise<void>) | null =
|
|
25
|
+
null;
|
|
26
|
+
|
|
27
|
+
const conn = {
|
|
28
|
+
onNotification: vi.fn((method: string, handler: (params: unknown) => void | Promise<void>) => {
|
|
29
|
+
if (method === "mail/turn.received") {
|
|
30
|
+
registeredHandler = handler;
|
|
31
|
+
}
|
|
32
|
+
}),
|
|
33
|
+
offNotification: vi.fn(),
|
|
34
|
+
_fire: async (params: unknown) => {
|
|
35
|
+
if (registeredHandler) await registeredHandler(params);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return conn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("setupMailBridge", () => {
|
|
42
|
+
let conn: ReturnType<typeof mockConnection>;
|
|
43
|
+
let inbox: ReturnType<typeof mockInboxAdapter>;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
conn = mockConnection();
|
|
47
|
+
inbox = mockInboxAdapter();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("registers notification handler on 'mail/turn.received'", async () => {
|
|
51
|
+
await setupMailBridge({ connection: conn, inboxAdapter: inbox as any });
|
|
52
|
+
expect(conn.onNotification).toHaveBeenCalledWith(
|
|
53
|
+
"mail/turn.received",
|
|
54
|
+
expect.any(Function),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns cleanup that calls offNotification", async () => {
|
|
59
|
+
const cleanup = await setupMailBridge({
|
|
60
|
+
connection: conn,
|
|
61
|
+
inboxAdapter: inbox as any,
|
|
62
|
+
});
|
|
63
|
+
cleanup();
|
|
64
|
+
expect(conn.offNotification).toHaveBeenCalledWith(
|
|
65
|
+
"mail/turn.received",
|
|
66
|
+
expect.any(Function),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("drops non-JSON turns silently", async () => {
|
|
71
|
+
const logs: string[] = [];
|
|
72
|
+
await setupMailBridge({
|
|
73
|
+
connection: conn,
|
|
74
|
+
inboxAdapter: inbox as any,
|
|
75
|
+
log: (m) => logs.push(m),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await conn._fire({
|
|
79
|
+
conversation_id: "conv-1",
|
|
80
|
+
turn_id: "turn-1",
|
|
81
|
+
participant_id: "some-agent",
|
|
82
|
+
content_type: "text/plain",
|
|
83
|
+
content: "hello world",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(inbox.send).not.toHaveBeenCalled();
|
|
87
|
+
expect(logs.some((l) => l.includes("Dropping"))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("with dispatcherAgentId", () => {
|
|
91
|
+
const DISPATCHER_ID = "dispatcher:host:1234:abc";
|
|
92
|
+
|
|
93
|
+
it("registers the dispatcher as inbox recipient", async () => {
|
|
94
|
+
await setupMailBridge({
|
|
95
|
+
connection: conn,
|
|
96
|
+
inboxAdapter: inbox as any,
|
|
97
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
98
|
+
});
|
|
99
|
+
expect(inbox.registerAgent).toHaveBeenCalledWith(
|
|
100
|
+
DISPATCHER_ID,
|
|
101
|
+
expect.objectContaining({ role: "dispatcher" }),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("translates hub envelope { type, body } → { schema, data } and delivers to dispatcher", async () => {
|
|
106
|
+
await setupMailBridge({
|
|
107
|
+
connection: conn,
|
|
108
|
+
inboxAdapter: inbox as any,
|
|
109
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const hubEnvelope = {
|
|
113
|
+
type: "x-dispatch/work",
|
|
114
|
+
body: { prompt: "do the thing", taskId: "task-123", role: "worker" },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
await conn._fire({
|
|
118
|
+
conversation_id: "conv-1",
|
|
119
|
+
turn_id: "turn-1",
|
|
120
|
+
participant_id: "openhive:dispatcher",
|
|
121
|
+
content_type: "application/json",
|
|
122
|
+
content: JSON.stringify(hubEnvelope),
|
|
123
|
+
thread_id: "thread-abc",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(inbox.send).toHaveBeenCalledOnce();
|
|
127
|
+
const [from, to, content, opts] = inbox.send.mock.calls[0];
|
|
128
|
+
|
|
129
|
+
// Must deliver FROM the hub participant, TO the dispatcher
|
|
130
|
+
expect(from).toBe("openhive:dispatcher");
|
|
131
|
+
expect(to).toBe(DISPATCHER_ID);
|
|
132
|
+
|
|
133
|
+
// Content must carry top-level `schema` and `data`, plus `type: "data"`
|
|
134
|
+
// so agent-inbox's `normalizeContent` passes it through unchanged
|
|
135
|
+
// (without a `type`, the inbox wraps the payload as { type: "data",
|
|
136
|
+
// data: <original> }, burying `schema` and breaking downstream
|
|
137
|
+
// classifiers like swarm-dispatch's createAgentInboxPort).
|
|
138
|
+
expect(content).toMatchObject({
|
|
139
|
+
type: "data",
|
|
140
|
+
schema: "x-dispatch/work",
|
|
141
|
+
data: { prompt: "do the thing", taskId: "task-123", role: "worker" },
|
|
142
|
+
});
|
|
143
|
+
expect(content).not.toHaveProperty("body");
|
|
144
|
+
|
|
145
|
+
// Thread tag preserved
|
|
146
|
+
expect(opts?.threadTag).toBe("thread-abc");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("passes through already-canonical { schema, data } payloads unchanged", async () => {
|
|
150
|
+
await setupMailBridge({
|
|
151
|
+
connection: conn,
|
|
152
|
+
inboxAdapter: inbox as any,
|
|
153
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Payload already in canonical shape (no 'body' key)
|
|
157
|
+
const canonical = {
|
|
158
|
+
schema: "x-dispatch/work",
|
|
159
|
+
data: { taskId: "t-99", prompt: "go", role: "worker" },
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
await conn._fire({
|
|
163
|
+
conversation_id: "conv-2",
|
|
164
|
+
turn_id: "turn-2",
|
|
165
|
+
participant_id: "openhive:dispatcher",
|
|
166
|
+
content_type: "application/json",
|
|
167
|
+
content: JSON.stringify(canonical),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(inbox.send).toHaveBeenCalledOnce();
|
|
171
|
+
const [, , content] = inbox.send.mock.calls[0];
|
|
172
|
+
expect(content).toMatchObject(canonical);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("without dispatcherAgentId (fallback mode)", () => {
|
|
177
|
+
it("delivers to BRIDGE_RECIPIENT_ID", async () => {
|
|
178
|
+
await setupMailBridge({
|
|
179
|
+
connection: conn,
|
|
180
|
+
inboxAdapter: inbox as any,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await conn._fire({
|
|
184
|
+
conversation_id: "conv-3",
|
|
185
|
+
turn_id: "turn-3",
|
|
186
|
+
participant_id: "openhive:dispatcher",
|
|
187
|
+
content_type: "application/json",
|
|
188
|
+
content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-1" } }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(inbox.send).toHaveBeenCalledOnce();
|
|
192
|
+
const [, to] = inbox.send.mock.calls[0];
|
|
193
|
+
expect(to).toBe("openhive-mail-bridge");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|