tascan-mcp 3.16.2 → 3.18.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 +6 -0
- package/package.json +3 -1
- package/scopes.cjs +22 -0
- package/tools-fanout.cjs +175 -0
- package/tools-runs.cjs +110 -0
- package/tools.cjs +5 -1
package/README.md
CHANGED
|
@@ -288,6 +288,12 @@ API keys are scoped to your organization and support rate limiting (60 requests/
|
|
|
288
288
|
|
|
289
289
|
## Changelog
|
|
290
290
|
|
|
291
|
+
### v3.18.0 — 2026-09-28
|
|
292
|
+
- **Fan-out (phase 2, item 1: subagent seats), 104 tools.** A fan-out seat may now name `runner: 'local'` (`tascan_create_fanout` `seats[i].runner`, default `research`) — a seat claimable ONLY by its own registered local/subagent instance, never webhook-fired from the cloud. Three new tools over the seat's own run lifecycle (`/runs/claim`, `/runs/:id/heartbeat`, `/runs/:id/finish`, migration 229): `tascan_claim_run` (claims as `"<agent_id>:<anything>"`; a foreign instance is refused `seat_not_yours`, before routing is even evaluated), `tascan_heartbeat_run` (extends the lease; `{ok:false, reason:'not_live'}` means stop), `tascan_finish_run` (stores `document`[/`verification`] as the seat's own build artifacts, hashes `build_ref`, and finishes the run — a real signed completion, exactly like a research seat's or a CODE build's; zero changes to `coord_fanout_settle` / the barrier / the roll-up). All three require `agent:dispatch:code`; a run or task outside your org is 404, never 403.
|
|
293
|
+
|
|
294
|
+
### v3.17.0 — 2026-09-28
|
|
295
|
+
- **Fan-out (phase 1b), 101 tools.** Three new tools over the phase 1a REST surface (`/coord/fanouts`, migration 224): `tascan_create_fanout` (1-30 RESEARCH seats plus one synthesizer under ONE cycle root T_F — zero Decision cards, zero Parked cards, zero pages per seat; requires `agent:dispatch:code`; fails closed `fanout_ceiling_unset` until the org owner sets a spending ceiling), `tascan_get_fanout` (`view=status|report|rollup|verify`, read tier), `tascan_control_fanout` (`pause|resume|cancel|close_barrier` — no `amend`, no `answer` in phase 1; requires `agent:dispatch:code`). `dry_run` on create is forwarded to the route as-is; the route has no server-side preflight for this path yet, so it does not yet prevent a real create — see the tool description.
|
|
296
|
+
|
|
291
297
|
### v3.16.2 — 2026-09-28
|
|
292
298
|
- 98 tools. Rolls up 3.16.0 to 3.16.2: `tascan_get_usage` (GET /usage, per-key daily actions and rate-limit headers, migration 220); the device control plane read/revoke tools (`tascan_list_devices`, `tascan_get_device`, `tascan_revoke_device`; register and rotate stay admin-app only by design); `tascan_delegate_to_agent` (child keys with a TTL of 60 s to 24 h clamped to the parent, non-delegable flags refused, the plaintext returned once) and the per-key action budgets that fail closed (migration 221). Hosted MCP at https://app.tascan.io/mcp already serves this version; this release brings the stdio package up to it.
|
|
293
299
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tascan-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.0",
|
|
4
4
|
"mcpName": "io.github.snowbikemike/tascan-mcp",
|
|
5
5
|
"description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 70 tools for projects, tasks, workers, QR codes, NFC tags, worker marketplace with consented SMS invites, geofenced zones with rules (work site / hazard + AI-verified PPE checkpoints / containment / restricted, breach alerts, zone compliance audit), AI condition assessment, worker passports, verification-gated gig payments, client invoicing from verified work (overtime / day rate / per diem / expenses), shareable report links (completion, client service report, project, evidence pack), signed Action Receipts (Ed25519 JWS), cross-entity search, duplicate-worker detection, worker identity merge, AI issue analysis, and autonomous remediation dispatch. 15 patents filed (470+ claims). Task. Scan. Done.",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"index.js",
|
|
16
16
|
"tools.cjs",
|
|
17
17
|
"tools-devices.cjs",
|
|
18
|
+
"tools-fanout.cjs",
|
|
19
|
+
"tools-runs.cjs",
|
|
18
20
|
"scopes.cjs",
|
|
19
21
|
"README.md",
|
|
20
22
|
"LICENSE"
|
package/scopes.cjs
CHANGED
|
@@ -219,6 +219,16 @@ const TOOL_SCOPES = {
|
|
|
219
219
|
tascan_post_message: W,
|
|
220
220
|
// creating a cycle queues a CODE:/SHELL: prompt on the AI Inbox → agent:dispatch:code, always
|
|
221
221
|
tascan_create_cycle: { tier: 'write', dispatch: 'code' },
|
|
222
|
+
// Fan-out phase 1b (2026-09-28): three tools over coord-fanout-lib.js's REST routes — same shapes as
|
|
223
|
+
// ROUTE_SCOPES' coord_fanouts / coord_fanout / coord_fanout_control below (:363-365).
|
|
224
|
+
tascan_create_fanout: { tier: 'write', dispatch: 'code' },
|
|
225
|
+
tascan_get_fanout: R,
|
|
226
|
+
tascan_control_fanout: { tier: 'write', dispatch: 'code' },
|
|
227
|
+
// Fan-out phase 2 item 1 (2026-09-28): three tools over coord-run-lib.js's REST routes -- same shapes as
|
|
228
|
+
// ROUTE_SCOPES' runs_claim / run_heartbeat / run_finish above.
|
|
229
|
+
tascan_claim_run: { tier: 'write', dispatch: 'code' },
|
|
230
|
+
tascan_heartbeat_run: { tier: 'write', dispatch: 'code' },
|
|
231
|
+
tascan_finish_run: { tier: 'write', dispatch: 'code' },
|
|
222
232
|
// M1 (2026-09-23): ten tools wrapping routes that already existed in api-v1.js / coord-dispatch-lib.js /
|
|
223
233
|
// receipt.js but had no MCP tool. tascan_verify_receipt: receipt.js's POST /receipts/verify is a PUBLIC
|
|
224
234
|
// route (no scope check at all inside receipt.js) — read is the floor because mcp-endpoint.js still
|
|
@@ -357,6 +367,18 @@ const ROUTE_SCOPES = {
|
|
|
357
367
|
// coordination layer (api-v1-helpers.js parsePath handlers task_messages / coord_cycles / coord_cycle_report / build / build_file)
|
|
358
368
|
task_messages: { GET: R, POST: CYCLE_WRITE }, // trail messages; POST answer on a dispatcher question → coord_answer_question
|
|
359
369
|
coord_cycles: { GET: R, POST: { tier: 'write', dispatch: 'code' } }, // list roots / coord_create_cycle (T1 = CODE:/SHELL: on the AI Inbox)
|
|
370
|
+
// Fan-out phase 1a-ii (master item 4): matched in coord-fanout-lib.js's own matchFanoutRoute, not
|
|
371
|
+
// api-v1-helpers.js's parsePath ROUTES table — same D6/D8 idiom as coord_dispatcher_action/coord_build_diff below.
|
|
372
|
+
// coordFanoutLib.handleFanoutRoute self-gates with these exact shapes too (belt-and-suspenders).
|
|
373
|
+
coord_fanouts: { GET: R, POST: { tier: 'write', dispatch: 'code' } }, // GET /coord/fanouts (list) / POST (create, T_F = CODE:/SHELL:-equivalent dispatch)
|
|
374
|
+
coord_fanout: { GET: R }, // GET /coord/fanouts/:id[?view=status|report|rollup|verify]
|
|
375
|
+
coord_fanout_control: { POST: { tier: 'write', dispatch: 'code' } }, // POST /coord/fanouts/:id/control (pause|resume|cancel|close_barrier)
|
|
376
|
+
// Fan-out phase 2 item 1 (2026-09-28): matched in coord-run-lib.js's own matchRunRoute, same D6/D8 idiom as the
|
|
377
|
+
// fan-out routes above -- a LOCAL fan-out seat's own run lifecycle (migration 229). coordRunLib's three handlers
|
|
378
|
+
// self-gate with these exact shapes too (belt-and-suspenders, same reason coord-fanout-lib.js documents).
|
|
379
|
+
runs_claim: { POST: { tier: 'write', dispatch: 'code' } }, // POST /runs/claim -- claim_agent_run(p_runner='local') for a subagent seat
|
|
380
|
+
run_heartbeat: { POST: { tier: 'write', dispatch: 'code' } }, // POST /runs/:id/heartbeat
|
|
381
|
+
run_finish: { POST: { tier: 'write', dispatch: 'code' } }, // POST /runs/:id/finish -- stores document[/verification], finish_agent_run
|
|
360
382
|
coord_integrate: { POST: WD }, // D4 (181): POST /coord/cycles/:root/integrate — the dispatcher records the deploy id (agent:dispatch)
|
|
361
383
|
coord_dispatcher_action: { POST: WD }, // D6 (194): POST /coord/cycles/:root/dispatcher-actions — a chief-of-staff action becomes a receipt (agent:dispatch); the handler self-gates too
|
|
362
384
|
coord_build_diff: { POST: WD }, // D8 item 6 (196): POST /coord/builds/:ref/diff — the dispatcher stores an already-computed diff (agent:dispatch); the handler self-gates too
|
package/tools-fanout.cjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Fan-out phase 1b (2026-09-28, briefs/2026-09-28-fanout-phase1b-mcp-tools.md; master
|
|
2
|
+
// briefs/2026-09-27-fanout-phase1.md, r3): three MCP tools over the 1a-ii REST surface
|
|
3
|
+
// (coord-fanout-lib.js's matchFanoutRoute / handleFanoutRoute, wired into api-v1.js) -- never Supabase
|
|
4
|
+
// directly, same split-file pattern as tools-devices.cjs (spread into TOOLS by one line there).
|
|
5
|
+
//
|
|
6
|
+
// tascan_create_fanout: POST /coord/fanouts. Every preflight problem the route can see (fanout_ceiling_unset,
|
|
7
|
+
// over_ceiling, cap_below_plan, idempotency_body_mismatch, fanout_open_limit, seats_invalid, reserve_floor_unset,
|
|
8
|
+
// no_authority, key_required) reaches the caller VERBATIM: api() (tascan-mcp/index.js / mcp-endpoint.js) throws
|
|
9
|
+
// Error(data.error) on a non-2xx response and the transport prints "Error: <message>" unchanged -- this file adds
|
|
10
|
+
// no try/catch around that path on purpose. dry_run is forwarded to the route as-is (literal passthrough, the
|
|
11
|
+
// brief's own word); the route built in 1a-ii has NO server-side preflight mode for this path (unlike
|
|
12
|
+
// tascan_create_cycle's dry_run, which /coord/cycles DOES implement) -- a create call with dry_run true still
|
|
13
|
+
// creates a REAL fan-out today. Documented here, in the tool description and at runtime, rather than silently
|
|
14
|
+
// guessed: fixing that gap means touching coord-fanout-lib.js, which is out of this brief's six deliverables.
|
|
15
|
+
//
|
|
16
|
+
// tascan_get_fanout: GET /coord/fanouts/:id?view=. tascan_control_fanout: POST /coord/fanouts/:id/control, action
|
|
17
|
+
// enum WITHOUT amend and WITHOUT answer (master: no such actions in phase 1) -- exactly coord-fanout-lib.js's own
|
|
18
|
+
// CONTROL_ACTIONS.
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
const VIEWS = ['status', 'report', 'rollup', 'verify'];
|
|
23
|
+
const CONTROL_ACTIONS = ['pause', 'resume', 'cancel', 'close_barrier'];
|
|
24
|
+
|
|
25
|
+
module.exports = [
|
|
26
|
+
{
|
|
27
|
+
name: 'tascan_create_fanout',
|
|
28
|
+
description: 'Create a fan-out: 1-30 RESEARCH seats plus one synthesizer under ONE cycle root T_F (POST /coord/fanouts). Zero Decision cards, zero Parked cards, zero pages per seat -- only a control action or the closing roll-up reaches a human. Requires an API key with agent:dispatch:code -- an admin session has no dispatch budget (key_required). Consumes seats.length + 1 dispatch units BEFORE the authoritative create; a refusal only the RPC can see (cap_below_plan, fanout_open_limit, bad agent_id, reserve_floor_unset) still consumes them, by design. Fails closed fanout_ceiling_unset until the owner sets a ceiling; over_ceiling when cap exceeds it. idempotency_key replays duplicate within 24h on the same plan, else idempotency_body_mismatch. dry_run is forwarded as-is; the route has no preflight mode yet, so dry_run true still creates for real -- never a way to avoid creating. Leaves fanout_created; every seat, the synth and the roll-up leave their own signed completion. Track with tascan_get_fanout.',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
project_id: { type: 'string', description: 'Working project (UUID). The fan-out root T_F is minted on its Questions list (coord_ensure_lists).' },
|
|
33
|
+
title: { type: 'string', description: 'Short human title (1-200 chars). T_F becomes "FANOUT: <title>"; seats become "RESEARCH: [seat-NN] <title>"; the synth becomes "RESEARCH: [synth] <title>".' },
|
|
34
|
+
seats: {
|
|
35
|
+
type: 'array',
|
|
36
|
+
description: '1-30 seats. Each names the agent that runs it; brief/role fall back to seat_defaults when omitted.',
|
|
37
|
+
items: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
agent_id: { type: 'string', description: 'A registered agent_registry id in this org, matching ^[a-z][a-z0-9-]{0,39}$. Never a system-* worker.' },
|
|
41
|
+
brief: { type: 'string', description: 'This seat\'s own prompt (1-40000 chars). Falls back to seat_defaults.brief when omitted -- one of the two is required.' },
|
|
42
|
+
role: { type: 'string', description: 'Optional label for this seat (1-100 chars), e.g. "topic A".' }
|
|
43
|
+
},
|
|
44
|
+
required: ['agent_id']
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
seat_defaults: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
description: 'Shared defaults applied when a seat names no brief of its own.',
|
|
50
|
+
properties: {
|
|
51
|
+
brief: { type: 'string', description: 'Default seat prompt (1-40000 chars), used by any seat that names no brief of its own.' },
|
|
52
|
+
est_micro_usd: { type: 'integer', description: 'Positive integer micro-USD estimate per seat, used by the RPC\'s cap_below_plan check.' },
|
|
53
|
+
review_est_micro_usd: { type: 'integer', description: 'Non-negative integer micro-USD estimate per seat review. Must be > 0 when seat_review is per_seat.' }
|
|
54
|
+
},
|
|
55
|
+
required: ['est_micro_usd']
|
|
56
|
+
},
|
|
57
|
+
synth: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
description: 'The synthesizer that runs once the barrier closes, reading every done seat\'s stored output as DATA.',
|
|
60
|
+
properties: {
|
|
61
|
+
brief: { type: 'string', description: 'The synthesizer\'s prompt (1-40000 chars).' },
|
|
62
|
+
max_cost_micro_usd: { type: 'integer', description: 'Positive integer micro-USD reserved for the synth run; reserved out of cap up front, released back into the seat budget only at barrier close.' }
|
|
63
|
+
},
|
|
64
|
+
required: ['brief', 'max_cost_micro_usd']
|
|
65
|
+
},
|
|
66
|
+
canary: { type: 'integer', description: 'How many seats release immediately; the rest stay held on T_F until the first canary settles done. Default min(2, seats.length).' },
|
|
67
|
+
min_ok: { type: 'integer', description: 'Minimum seats that must finish done before the synth barrier can close. Default ceil(seats.length / 2).' },
|
|
68
|
+
breaker_k: { type: 'integer', description: 'Paid (non-infrastructure) seat failures before the breaker pauses the fan-out (2-5, default 2). An expired run never counts.' },
|
|
69
|
+
deadline_min: { type: 'integer', description: 'Minutes before an idle seat is dropped and the barrier re-evaluated, and before a stuck synthesizing fan-out closes deadline (10-1440, default 240).' },
|
|
70
|
+
seat_review: { type: 'string', enum: ['per_seat', 'none'], description: 'per_seat mints one review per seat (allowed for at most 10 seats); none skips seat review. Default per_seat for <=10 seats, none above that.' },
|
|
71
|
+
cap: { type: 'integer', description: 'This fan-out\'s total micro-USD budget, synth reserve included. Refused over_ceiling if it exceeds the org\'s fan-out spending ceiling.' },
|
|
72
|
+
idempotency_key: { type: 'string', description: 'Optional replay key (<= 200 chars). The same key within 24h on the same plan returns duplicate:true with the existing fanout_id; on a different plan it is refused idempotency_body_mismatch.' },
|
|
73
|
+
dry_run: { type: 'boolean', description: 'Forwarded to POST /coord/fanouts as given. The route has no server-side preflight for this path today, so true still performs a REAL create -- do not rely on it to avoid creating a fan-out.' }
|
|
74
|
+
},
|
|
75
|
+
required: ['project_id', 'title', 'seats', 'synth', 'cap']
|
|
76
|
+
},
|
|
77
|
+
annotations: { title: 'Create Fan-out', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
78
|
+
handler: async (args, api) => {
|
|
79
|
+
const projectId = String(args.project_id || '').trim();
|
|
80
|
+
if (!UUID_RE.test(projectId)) throw new Error('project_id must be a UUID');
|
|
81
|
+
if (!args.title || typeof args.title !== 'string') throw new Error('title is required');
|
|
82
|
+
if (!Array.isArray(args.seats) || !args.seats.length) throw new Error('seats is required (1-30 entries)');
|
|
83
|
+
if (!Number.isInteger(args.cap) || args.cap <= 0) throw new Error('cap is required (a positive integer, this fan-out\'s total micro-USD budget)');
|
|
84
|
+
if (!args.synth || typeof args.synth !== 'object') throw new Error('synth is required ({brief, max_cost_micro_usd})');
|
|
85
|
+
|
|
86
|
+
const body = {
|
|
87
|
+
project_id: projectId,
|
|
88
|
+
title: args.title,
|
|
89
|
+
seats: args.seats.map(s => ({
|
|
90
|
+
agent_id: s && s.agent_id,
|
|
91
|
+
...(s && s.brief != null ? { brief: s.brief } : {}),
|
|
92
|
+
...(s && s.role != null ? { role: s.role } : {})
|
|
93
|
+
})),
|
|
94
|
+
synth: { brief: args.synth.brief, max_cost_micro_usd: args.synth.max_cost_micro_usd },
|
|
95
|
+
cap: args.cap
|
|
96
|
+
};
|
|
97
|
+
if (args.seat_defaults) body.seat_defaults = args.seat_defaults;
|
|
98
|
+
for (const k of ['canary', 'min_ok', 'breaker_k', 'deadline_min', 'seat_review', 'idempotency_key']) {
|
|
99
|
+
if (args[k] != null) body[k] = args[k];
|
|
100
|
+
}
|
|
101
|
+
const dryRun = args.dry_run === true || args.dry_run === 'true';
|
|
102
|
+
if (args.dry_run != null) body.dry_run = dryRun;
|
|
103
|
+
|
|
104
|
+
const result = await api('POST', '/coord/fanouts', body, args.idempotency_key);
|
|
105
|
+
const d = result.data || {};
|
|
106
|
+
if (d.duplicate) {
|
|
107
|
+
return `DUPLICATE — nothing was created. A fan-out with the same idempotency_key and plan exists from the last 24h.\nfanout_id: ${d.fanout_id}\n\nRead it: tascan_get_fanout fanout_id=${d.fanout_id}`;
|
|
108
|
+
}
|
|
109
|
+
const seatLines = Array.isArray(d.seats)
|
|
110
|
+
? d.seats.map(s => ` seat-${String(s.seat_no).padStart(2, '0')}: ${s.seat_id} (${s.released ? 'released' : 'held on T_F'}, ${s.status})`).join('\n')
|
|
111
|
+
: ' (none returned)';
|
|
112
|
+
let text = `Fan-out created.\nfanout_id (T_F): ${d.fanout_id}\nplan_sha256: ${d.plan_sha256}\nstatus: ${d.status}\nsynth_task_id: ${d.synth_task_id}\npoll_after_s: ${d.poll_after_s}\nseats:\n${seatLines}\n\nFollow with tascan_get_fanout fanout_id=${d.fanout_id} (view=status).`;
|
|
113
|
+
if (dryRun) {
|
|
114
|
+
text = 'NOTE: dry_run has no server-side preflight on this route yet — the fan-out below was created for REAL, not simulated.\n\n' + text;
|
|
115
|
+
}
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'tascan_get_fanout',
|
|
121
|
+
description: 'Read a fan-out (GET /coord/fanouts/:id?view=). view=status (default): state, seat counts, spend, cap, synth_deadline_at, poll_after_s. view=report: the full cycle report (get_cycle_report) — every seat, review and control step. view=rollup: the exact roll-up response_value plus its leaves (path, sha256, completion_id) once closed. view=verify: the server recomputes rollup_ref over the frozen leaves and returns ok / mismatches — the same check scripts/verify-fanout.js performs independently from another machine, which is the trusted proof; this view is a convenience, not a substitute. A foreign org\'s fanout_id answers 404, never 403, so another org\'s fan-out never even appears to exist. Read tier, no dispatch permission required.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
fanout_id: { type: 'string', description: 'Fan-out id (UUID) — the T_F root, from tascan_create_fanout or tascan_list_cycles.' },
|
|
126
|
+
view: { type: 'string', enum: VIEWS, description: 'status (default), report, rollup or verify.' }
|
|
127
|
+
},
|
|
128
|
+
required: ['fanout_id']
|
|
129
|
+
},
|
|
130
|
+
annotations: { title: 'Get Fan-out', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
131
|
+
handler: async (args, api) => {
|
|
132
|
+
const id = String(args.fanout_id || '').trim();
|
|
133
|
+
if (!UUID_RE.test(id)) throw new Error('fanout_id must be a UUID');
|
|
134
|
+
const view = args.view != null && args.view !== '' ? String(args.view) : 'status';
|
|
135
|
+
if (!VIEWS.includes(view)) throw new Error(`view must be one of: ${VIEWS.join(', ')}`);
|
|
136
|
+
const result = await api('GET', `/coord/fanouts/${id}?view=${view}`);
|
|
137
|
+
const out = `Fan-out ${id} (view=${view}):\n\n${JSON.stringify(result.data, null, 2)}`;
|
|
138
|
+
return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'tascan_control_fanout',
|
|
143
|
+
description: 'Control a running fan-out (POST /coord/fanouts/:id/control). pause holds every unclaimed seat and stops the orphan pass from re-firing them; resume releases them again; cancel supersedes every unclaimed task and closes once quiescent; close_barrier drops unsettled or named exclude_seats and forces the synth barrier now instead of waiting for more seats. No amend and no answer in phase 1. Every action mints a signed control-child completion FIRST, as the org\'s own system worker (coord_fanout_control_receipt) — never coord_dispatcher_action, so a control action can never sign into another org\'s Chief of Staff identity. idempotency_key replays the prior outcome (replayed:true) and applies nothing twice; a replayed resume after a later pause leaves the fan-out paused. Refused fanout_closed once the roll-up has closed. Requires agent:dispatch:code.',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
fanout_id: { type: 'string', description: 'Fan-out id (UUID) — the T_F root.' },
|
|
148
|
+
action: { type: 'string', enum: CONTROL_ACTIONS, description: 'pause, resume, cancel or close_barrier. No amend, no answer (phase 1).' },
|
|
149
|
+
reason: { type: 'string', description: 'Optional free-text reason (<= 4000 chars), recorded on the control child\'s completion.' },
|
|
150
|
+
exclude_seats: { type: 'array', items: { type: 'integer' }, description: 'close_barrier only: 1-based seat numbers to drop from the barrier instead of waiting for them to settle.' },
|
|
151
|
+
idempotency_key: { type: 'string', description: 'Optional replay key (<= 200 chars). The same key returns the prior outcome (replayed:true) and applies nothing a second time.' }
|
|
152
|
+
},
|
|
153
|
+
required: ['fanout_id', 'action']
|
|
154
|
+
},
|
|
155
|
+
annotations: { title: 'Control Fan-out', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
156
|
+
handler: async (args, api) => {
|
|
157
|
+
const id = String(args.fanout_id || '').trim();
|
|
158
|
+
if (!UUID_RE.test(id)) throw new Error('fanout_id must be a UUID');
|
|
159
|
+
const action = String(args.action || '');
|
|
160
|
+
if (!CONTROL_ACTIONS.includes(action)) throw new Error(`action must be one of: ${CONTROL_ACTIONS.join(', ')}`);
|
|
161
|
+
const body = { action };
|
|
162
|
+
if (args.reason != null) body.reason = args.reason;
|
|
163
|
+
if (args.exclude_seats != null) {
|
|
164
|
+
if (!Array.isArray(args.exclude_seats) || !args.exclude_seats.every(n => Number.isInteger(n) && n > 0)) {
|
|
165
|
+
throw new Error('exclude_seats must be an array of positive integers');
|
|
166
|
+
}
|
|
167
|
+
body.exclude_seats = args.exclude_seats;
|
|
168
|
+
}
|
|
169
|
+
if (args.idempotency_key != null) body.idempotency_key = args.idempotency_key;
|
|
170
|
+
const result = await api('POST', `/coord/fanouts/${id}/control`, body, args.idempotency_key);
|
|
171
|
+
const d = result.data || {};
|
|
172
|
+
return `${d.replayed ? 'REPLAYED (idempotent — nothing applied twice)' : 'Applied'}: ${action} on fan-out ${id}.\nfanout_status: ${d.fanout_status}\nreceipt_completion_id: ${d.receipt_completion_id}\n\n${JSON.stringify(d, null, 2)}`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
];
|
package/tools-runs.cjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Fan-out phase 2, item 1 (2026-09-28, briefs/2026-09-28-fanout-phase2-subagent-seats.md): three MCP tools over
|
|
2
|
+
// coord-run-lib.js's REST surface (POST /runs/claim, POST /runs/:id/heartbeat, POST /runs/:id/finish) -- never
|
|
3
|
+
// Supabase directly, same split-file pattern as tools-devices.cjs / tools-fanout.cjs (spread into TOOLS by one
|
|
4
|
+
// line there).
|
|
5
|
+
//
|
|
6
|
+
// The Workflow: a fan-out seat named with runner:'local' (tascan_create_fanout, seats[i].runner) is claimable ONLY
|
|
7
|
+
// by its own registered agent -- p_instance ("<agent_id>:<anything>") must start with the seat's own coord.agent_id
|
|
8
|
+
// (migration 229's claim_agent_run gate). A subagent that is new registers itself first (tascan_register_agent,
|
|
9
|
+
// type 'local' or 'subagent'), then: tascan_claim_run -> works -> tascan_finish_run with its document. The seat's
|
|
10
|
+
// completion is a normal, signed TaScan receipt (tascan_get_receipt), exactly like a research seat's or a CODE
|
|
11
|
+
// build's -- coord_fanout_settle / the barrier / the roll-up need no awareness that the seat was local at all
|
|
12
|
+
// (migration 229's own header note: "prove it, do not re-implement it").
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
|
|
17
|
+
module.exports = [
|
|
18
|
+
{
|
|
19
|
+
name: 'tascan_claim_run',
|
|
20
|
+
description: 'Claim a fan-out seat task as a LOCAL subagent run (POST /runs/claim -> claim_agent_run(p_runner=\'local\')). Only admitted when instance starts with "<your registered agent_id>:" -- the seat\'s own coord.agent_id, set when the fan-out was created (tascan_create_fanout seats[i].runner=\'local\'). Any other instance is refused, claimed false with reason seat_not_yours -- this tool never claims a seat that is not yours, and never claims a cloud (research) seat at all (refused not_routable). Never mints a completion or a receipt by itself -- claiming only opens the run; work happens after, and tascan_finish_run is what leaves the signed receipt. Requires agent:dispatch:code. A foreign or unknown task_id is 404, never 403 (S2: another org\'s task never even appears to exist).',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
task_id: { type: 'string', description: 'The seat task ID (UUID) -- from tascan_create_fanout\'s seats[] or tascan_get_fanout (view=status/report).' },
|
|
25
|
+
instance: { type: 'string', description: 'Your claim identity, "<agent_id>:<anything>" -- e.g. "researcher-07:host-1234". Must start with the seat\'s own registered agent_id or the claim is refused seat_not_yours.' },
|
|
26
|
+
ttl_seconds: { type: 'integer', description: 'Optional lease length in seconds (60-86400, default 1800). Extend it later with tascan_heartbeat_run instead of claiming a longer one up front.' }
|
|
27
|
+
},
|
|
28
|
+
required: ['task_id', 'instance']
|
|
29
|
+
},
|
|
30
|
+
annotations: { title: 'Claim Run', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
31
|
+
handler: async (args, api) => {
|
|
32
|
+
const taskId = String(args.task_id || '').trim();
|
|
33
|
+
if (!UUID_RE.test(taskId)) throw new Error('task_id must be a UUID');
|
|
34
|
+
const instance = typeof args.instance === 'string' ? args.instance.trim() : '';
|
|
35
|
+
if (!instance) throw new Error('instance is required, "<agent_id>:<anything>"');
|
|
36
|
+
const body = { task_id: taskId, instance };
|
|
37
|
+
if (args.ttl_seconds != null) body.ttl_seconds = args.ttl_seconds;
|
|
38
|
+
const result = await api('POST', '/runs/claim', body);
|
|
39
|
+
const d = result.data || {};
|
|
40
|
+
if (d.claimed !== true) {
|
|
41
|
+
return `NOT claimed -- reason: ${d.reason}\n\n${JSON.stringify(d, null, 2)}`;
|
|
42
|
+
}
|
|
43
|
+
return `Claimed. run_id: ${d.run_id}\nroute: ${d.route}\nattempt: ${d.attempt} / max_attempts: ${d.max_attempts}\nexpires_at: ${d.expires_at}\ncycle_root: ${d.cycle_root}\nbuild_ref (the cycle's own binding, if any): ${d.build_ref}\n\nNext: work, optionally tascan_heartbeat_run before the lease expires, then tascan_finish_run with your document.`;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'tascan_heartbeat_run',
|
|
48
|
+
description: 'Extend a claimed local run\'s lease and mark it running (POST /runs/:run_id/heartbeat -> heartbeat_agent_run). ok false with reason not_live means the run is no longer yours -- the sweep expired it, or it was never yours to begin with -- STOP, do not finish it. Never mints a completion or a receipt. Requires agent:dispatch:code. A run outside your org is 404, never 403.',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
run_id: { type: 'string', description: 'The run ID from tascan_claim_run\'s response.' },
|
|
53
|
+
instance: { type: 'string', description: 'The SAME instance string you claimed with -- a mismatch answers not_live.' },
|
|
54
|
+
extend_seconds: { type: 'integer', description: 'Optional: push expires_at out to now + this many seconds (60-86400) if that is later than the current lease -- never shortens it.' }
|
|
55
|
+
},
|
|
56
|
+
required: ['run_id', 'instance']
|
|
57
|
+
},
|
|
58
|
+
annotations: { title: 'Heartbeat Run', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
59
|
+
handler: async (args, api) => {
|
|
60
|
+
const runId = String(args.run_id || '').trim();
|
|
61
|
+
if (!UUID_RE.test(runId)) throw new Error('run_id must be a UUID');
|
|
62
|
+
const instance = typeof args.instance === 'string' ? args.instance.trim() : '';
|
|
63
|
+
if (!instance) throw new Error('instance is required (the same one you claimed with)');
|
|
64
|
+
const body = { instance };
|
|
65
|
+
if (args.extend_seconds != null) body.extend_seconds = args.extend_seconds;
|
|
66
|
+
const result = await api('POST', `/runs/${runId}/heartbeat`, body);
|
|
67
|
+
const d = result.data || {};
|
|
68
|
+
return d.ok ? `Alive. state: ${d.state}, expires_at: ${d.expires_at}, attempt: ${d.attempt}` : `NOT live -- reason: ${d.reason}, state: ${d.state}. Stop; do not finish this run.`;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'tascan_finish_run',
|
|
73
|
+
description: 'Finish a claimed local run (POST /runs/:run_id/finish). outcome=\'completed\' stores `document` (and optional `verification`) as the seat\'s own build artifacts under EXACTLY the artifact_paths the fan-out named for this seat, hashes them into build_ref, and finishes the run -- this mints a real task_completions row and a SIGNED TASCAN RECEIPT for your work (tascan_get_receipt), the same as any other build; `document` is your deliverable and IS what gets hashed, so send the real content, not a summary. Zero changes to coord_fanout_settle / the barrier / the roll-up -- your completion releases the next held seat and, once every seat settles, feeds the fan-out\'s own synthesizer and rollup exactly like a cloud research seat\'s would. outcome=\'failed\' finishes the run failed with your `error` text and stores nothing. Requires agent:dispatch:code. A run outside your org, or already finished, is refused (404 / the RPC\'s own not_live).',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
run_id: { type: 'string', description: 'The run ID from tascan_claim_run\'s response.' },
|
|
78
|
+
instance: { type: 'string', description: 'The SAME instance string you claimed with.' },
|
|
79
|
+
outcome: { type: 'string', enum: ['completed', 'failed'], description: '\'completed\' stores your document and mints a signed completion; \'failed\' stores nothing.' },
|
|
80
|
+
document: { type: 'string', description: 'Required when outcome is completed. The seat\'s deliverable -- hashed as build_ref, stored verbatim under the seat\'s own artifact_paths.' },
|
|
81
|
+
verification: { type: 'string', description: 'Optional second document (a short verification/self-check note) -- stored as the seat\'s second artifact_path when the fan-out named two.' },
|
|
82
|
+
error: { type: 'string', description: 'Required when outcome is failed -- why (up to 4000 chars).' },
|
|
83
|
+
cost_micro_usd: { type: 'integer', description: 'Optional micro-USD cost to record against the cycle\'s own budget (default 0 -- a Claude Code subscription seat has no per-call API cost to report).' }
|
|
84
|
+
},
|
|
85
|
+
required: ['run_id', 'instance', 'outcome']
|
|
86
|
+
},
|
|
87
|
+
annotations: { title: 'Finish Run', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
88
|
+
handler: async (args, api) => {
|
|
89
|
+
const runId = String(args.run_id || '').trim();
|
|
90
|
+
if (!UUID_RE.test(runId)) throw new Error('run_id must be a UUID');
|
|
91
|
+
const instance = typeof args.instance === 'string' ? args.instance.trim() : '';
|
|
92
|
+
if (!instance) throw new Error('instance is required (the same one you claimed with)');
|
|
93
|
+
const outcome = String(args.outcome || '');
|
|
94
|
+
if (!['completed', 'failed'].includes(outcome)) throw new Error('outcome must be "completed" or "failed"');
|
|
95
|
+
if (outcome === 'completed' && (typeof args.document !== 'string' || !args.document.trim())) {
|
|
96
|
+
throw new Error('document is required when outcome is "completed"');
|
|
97
|
+
}
|
|
98
|
+
const body = { instance, outcome };
|
|
99
|
+
if (args.document != null) body.document = args.document;
|
|
100
|
+
if (args.verification != null) body.verification = args.verification;
|
|
101
|
+
if (args.error != null) body.error = args.error;
|
|
102
|
+
if (args.cost_micro_usd != null) body.cost_micro_usd = args.cost_micro_usd;
|
|
103
|
+
const result = await api('POST', `/runs/${runId}/finish`, body);
|
|
104
|
+
const d = result.data || {};
|
|
105
|
+
if (outcome === 'failed') return `Finished failed. ok: ${d.ok}, state: ${d.state}.`;
|
|
106
|
+
if (d.ok === false) return `NOT accepted -- reason: ${d.reason}, state: ${d.state}. The seat is still unfinished; nothing was released.`;
|
|
107
|
+
return `Finished completed. completion_id: ${result.completion_id}\nbuild_ref: ${result.build_ref}\nrun state: ${d.state}${d.advance ? '\nadvance: ' + JSON.stringify(d.advance) : ''}\n\nThis is a signed receipt -- tascan_get_receipt completion_id=${result.completion_id} to fetch it.`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
];
|
package/tools.cjs
CHANGED
|
@@ -2824,7 +2824,11 @@ const TOOLS = [
|
|
|
2824
2824
|
}
|
|
2825
2825
|
},
|
|
2826
2826
|
// P0 (migration 216): device control plane — split out (194 KB already close to the ~200 KB bundle cap).
|
|
2827
|
-
...require('./tools-devices.cjs')
|
|
2827
|
+
...require('./tools-devices.cjs'),
|
|
2828
|
+
// Fan-out phase 1b (2026-09-28): three tools over the 1a-ii REST surface — split out, same reason.
|
|
2829
|
+
...require('./tools-fanout.cjs'),
|
|
2830
|
+
// Fan-out phase 2 item 1 (2026-09-28): three tools over a LOCAL seat's own run lifecycle — split out, same reason.
|
|
2831
|
+
...require('./tools-runs.cjs')
|
|
2828
2832
|
];
|
|
2829
2833
|
|
|
2830
2834
|
module.exports = { TOOLS, AGENT_REGISTRY, dynamicAgents };
|