@p4code/cli 0.5.16 → 0.5.17
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/bin.mjs +466 -65
- package/dist/client/assets/DiffPanel-BX6oPj-G.js +98 -0
- package/dist/client/assets/{FilePreviewPanel-ByLSi1JL.js → FilePreviewPanel-Bh2tvvsr.js} +6 -6
- package/dist/client/assets/PreviewPanel-Ds2Q7clx.js +2 -0
- package/dist/client/assets/PullRequestCodeTab-BqU0ufjO.js +251 -0
- package/dist/client/assets/{fileCommentAnnotations-T78XG7k8.js → fileCommentAnnotations-DFTgO2Eq.js} +2 -2
- package/dist/client/assets/index-DUp1fSQm.js +2929 -0
- package/dist/client/assets/{previewAssetResource-CKOao7Zb.js → previewAssetResource-CiAACum3.js} +3 -3
- package/dist/client/assets/{renderFileChildren-BcefTgOQ.js → renderFileChildren-LNOBfS1K.js} +2 -2
- package/dist/client/assets/{toggle-group-NIaLKwkg.js → toggle-group-DFyg0Pe6.js} +2 -2
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/dist/client/assets/DiffPanel-D67u97Q2.js +0 -98
- package/dist/client/assets/PreviewPanel-hzSHtmkO.js +0 -2
- package/dist/client/assets/PullRequestCodeTab-DoXs9qKG.js +0 -251
- package/dist/client/assets/index-DwVt0vnY.js +0 -2923
package/dist/bin.mjs
CHANGED
|
@@ -265,7 +265,7 @@ const make$120 = () => {
|
|
|
265
265
|
const layer$122 = Layer.sync(NetService, make$120);
|
|
266
266
|
//#endregion
|
|
267
267
|
//#region package.json
|
|
268
|
-
var version$1 = "0.5.
|
|
268
|
+
var version$1 = "0.5.17";
|
|
269
269
|
//#endregion
|
|
270
270
|
//#region src/config.ts
|
|
271
271
|
/**
|
|
@@ -29731,13 +29731,17 @@ function parseMarkdownFrontmatter(contents) {
|
|
|
29731
29731
|
const name = typeof record.name === "string" ? record.name.trim() : "";
|
|
29732
29732
|
const description = typeof record.description === "string" ? record.description.trim() : "";
|
|
29733
29733
|
const disableModelInvocation = readFrontmatterBoolean(record["disable-model-invocation"]);
|
|
29734
|
+
const tools = Array.isArray(record.tools) ? record.tools.flatMap((tool) => typeof tool === "string" && tool.trim() ? [tool.trim()] : []) : void 0;
|
|
29735
|
+
const model = typeof record.model === "string" ? record.model.trim() : "";
|
|
29734
29736
|
const requiredInteractionMode = record["required-interaction-mode"] === "default" || record["required-interaction-mode"] === "plan" ? record["required-interaction-mode"] : void 0;
|
|
29735
29737
|
return {
|
|
29736
29738
|
kind: "parsed",
|
|
29737
29739
|
...name ? { name } : {},
|
|
29738
29740
|
...description ? { description } : {},
|
|
29739
29741
|
...disableModelInvocation === void 0 ? {} : { disableModelInvocation },
|
|
29740
|
-
...requiredInteractionMode === void 0 ? {} : { requiredInteractionMode }
|
|
29742
|
+
...requiredInteractionMode === void 0 ? {} : { requiredInteractionMode },
|
|
29743
|
+
...tools && tools.length > 0 ? { tools } : {},
|
|
29744
|
+
...model ? { model } : {}
|
|
29741
29745
|
};
|
|
29742
29746
|
}
|
|
29743
29747
|
//#endregion
|
|
@@ -30041,6 +30045,42 @@ const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (config
|
|
|
30041
30045
|
scope: "project"
|
|
30042
30046
|
}] : []], disabledSkills);
|
|
30043
30047
|
});
|
|
30048
|
+
/**
|
|
30049
|
+
* Load named subagent definitions from the user config dir.
|
|
30050
|
+
*
|
|
30051
|
+
* The CLI finds these itself whenever the `user` setting source is loaded, so
|
|
30052
|
+
* nothing calls this on an ordinary session. It exists for the sessions that
|
|
30053
|
+
* drop that source and still owe the user a specific agent - a Fusion half
|
|
30054
|
+
* under the lean context profile, which must still run the delivery reviewer.
|
|
30055
|
+
* Reading only the names asked for keeps that session from paying for the rest
|
|
30056
|
+
* of the user's agents, which is what dropping the source bought.
|
|
30057
|
+
*
|
|
30058
|
+
* Best-effort like the skill discovery beside it: a missing or malformed
|
|
30059
|
+
* definition is skipped rather than failing the session, because a session
|
|
30060
|
+
* that cannot start is worse than one missing an agent.
|
|
30061
|
+
*/
|
|
30062
|
+
const discoverClaudeUserAgents = Effect.fn("discoverClaudeUserAgents")(function* (config, names, environment, cwd) {
|
|
30063
|
+
if (names.length === 0) return {};
|
|
30064
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
30065
|
+
const path = yield* Path$1.Path;
|
|
30066
|
+
const agentsDir = yield* resolveClaudeUserAgentsDir(config, environment, cwd);
|
|
30067
|
+
const definitions = {};
|
|
30068
|
+
for (const name of names) {
|
|
30069
|
+
const contents = yield* fileSystem.readFileString(path.join(agentsDir, `${name}.md`)).pipe(Effect.orElseSucceed(() => void 0));
|
|
30070
|
+
if (contents === void 0) continue;
|
|
30071
|
+
const frontmatter = parseMarkdownFrontmatter(contents);
|
|
30072
|
+
if (frontmatter.kind !== "parsed") continue;
|
|
30073
|
+
const prompt = stripMarkdownFrontmatter(contents);
|
|
30074
|
+
if (frontmatter.description === void 0 || prompt.length === 0) continue;
|
|
30075
|
+
definitions[name] = {
|
|
30076
|
+
description: frontmatter.description,
|
|
30077
|
+
prompt,
|
|
30078
|
+
...frontmatter.tools ? { tools: frontmatter.tools } : {},
|
|
30079
|
+
...frontmatter.model ? { model: frontmatter.model } : {}
|
|
30080
|
+
};
|
|
30081
|
+
}
|
|
30082
|
+
return definitions;
|
|
30083
|
+
});
|
|
30044
30084
|
//#endregion
|
|
30045
30085
|
//#region src/mcp/ClaudeMcpFiles.ts
|
|
30046
30086
|
/**
|
|
@@ -41620,7 +41660,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
|
|
|
41620
41660
|
const EvidenceReadContextRow = Schema$1.Struct({
|
|
41621
41661
|
usedTokensAtRead: Schema$1.NullOr(Schema$1.Number),
|
|
41622
41662
|
readAt: Schema$1.String,
|
|
41623
|
-
afterSequence: Schema$1.NullOr(Schema$1.Number)
|
|
41663
|
+
afterSequence: Schema$1.NullOr(Schema$1.Number),
|
|
41664
|
+
throughSequence: Schema$1.NullOr(Schema$1.Number)
|
|
41624
41665
|
});
|
|
41625
41666
|
const getEvidenceReadContextRow = SqlSchema.findOneOption({
|
|
41626
41667
|
Request: Schema$1.String,
|
|
@@ -41633,7 +41674,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
|
|
|
41633
41674
|
ELSE NULL
|
|
41634
41675
|
END AS "usedTokensAtRead",
|
|
41635
41676
|
created_at AS "readAt",
|
|
41636
|
-
json_extract(payload_json, '$.afterSequence') AS "afterSequence"
|
|
41677
|
+
json_extract(payload_json, '$.afterSequence') AS "afterSequence",
|
|
41678
|
+
json_extract(payload_json, '$.throughSequence') AS "throughSequence"
|
|
41637
41679
|
FROM projection_thread_activities
|
|
41638
41680
|
WHERE activity_id = ${activityId}
|
|
41639
41681
|
LIMIT 1
|
|
@@ -41747,6 +41789,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
|
|
|
41747
41789
|
WHERE reads.thread_id = ${threadId}
|
|
41748
41790
|
AND reads.kind = 'fusion.evidence.read'
|
|
41749
41791
|
AND json_extract(reads.payload_json, '$.throughSequence') >= 0
|
|
41792
|
+
-- A raw row's bound is wherever paging reached, never the bound a
|
|
41793
|
+
-- wake named, so no report can pair with it and an unpaired one must
|
|
41794
|
+
-- not read as a review nobody answered.
|
|
41795
|
+
AND COALESCE(json_extract(reads.payload_json, '$.raw'), 0) = 0
|
|
41750
41796
|
ORDER BY reads.created_at DESC, reads.rowid DESC
|
|
41751
41797
|
LIMIT 1
|
|
41752
41798
|
`
|
|
@@ -59449,8 +59495,17 @@ const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(functio
|
|
|
59449
59495
|
* written as if the setting applied only to user-facing answers.
|
|
59450
59496
|
*/
|
|
59451
59497
|
const FUSION_PAIR_COMMUNICATION_INSTRUCTIONS = `Everything you send the other half of the pair obeys the thread's active response-compression mode, exactly as a user-facing answer does: phase reports, advice, reviews, questions, and handoffs. When the mode is off, write normal prose. Compression governs form only - never drop a requirement, a negation, a number or unit, a file, symbol, or command name, code, security or warning text, the evidence behind a claim, or an exact protocol heading or token in order to save words.`;
|
|
59452
|
-
|
|
59453
|
-
|
|
59498
|
+
/**
|
|
59499
|
+
* What a role does when a tool it wants is not its own.
|
|
59500
|
+
*
|
|
59501
|
+
* Both halves get the same words because the failure is the same on both
|
|
59502
|
+
* sides: a denied capability is an answer about what this role may do, and
|
|
59503
|
+
* treating it as an obstacle to route around turns a boundary the server
|
|
59504
|
+
* enforces into one it merely suggests.
|
|
59505
|
+
*/
|
|
59506
|
+
const FUSION_TOOL_BOUNDARY_INSTRUCTIONS = `A tool missing from your role, or a call the server denies for want of a capability or a grant, is a limit to report rather than an obstacle to route around. Never substitute the server's own database, its HTTP API, another MCP server, or any other back channel for a capability you were not granted; say which tool or grant you lacked and what you would have done with it.`;
|
|
59507
|
+
const FUSION_BUILDER_INSTRUCTIONS = `You are Fusion Builder in an already-created native server pair. Server owns pairing and coordination. Follow the current turn's [fusion-review-policy] block when present; it overrides the phase-boundary scheduling rules below. Do not inspect or invoke the Fusion skill, create/pair/rename threads, or announce/setup Fusion. Start the user's task directly. The phase plan is not yours: Supervisor authors it, orders it, and moves every phase status, and it renders in your task banner from there. Never create or edit a phase list yourself - not with TaskCreate, TaskUpdate, TodoWrite, update_plan, the MCP task board tools, or thread_plan_update - and never mark your own phase complete. Work the one phase Supervisor put in progress, and report what you did. Step-level notes for your own use are fine as long as they never become a phase list. Complete exactly one phase per turn, and finish the whole phase in that turn rather than stopping early. Do not run tests, typecheck, lint, or builds per phase; write the tests the change needs, then run verification once in the final phase over the whole task. Exception: a phase whose own correctness is unclear may run the single narrowest check that resolves it. End every phase turn with a report of at most six non-empty lines: the phase you finished, what changed and where, what you verified with its result, and anything unresolved. One line each, every fact once, no restatement of the plan or of advice you were given. Code, exact error text and security warnings are exempt from the cap. Do not start the next phase in the same turn. Server then wakes the paired Supervisor, which resumes you through ${FUSION_ADVICE_PROMPT_PREFIX}; a user message may also revise or resume the work. Final phase verifies the entire task against the original request and labels it ready for whole-task review. Supervisor is unreachable during your turn. Never spawn/use another Supervisor thread/subagent or attribute Supervisor decisions without ${FUSION_ADVICE_PROMPT_PREFIX}. Within the current phase, continue when straightforward or evidence is clear. For a concrete unresolved tradeoff, correctness risk, or design decision materially needing judgment, stop safely before the risky choice; final response states the exact question and why review is needed. Evaluate/follow Supervisor advice unless conflicting with user request or verified repo state. The review tools are Supervisor's half of the pair, not yours: thread_watch_review, thread_watch_events and thread_evidence_report are its tools, and a thread outside this pair is not yours to read. Your provider may still list them; that is not a grant, and every call fails. The tracker is Supervisor's too: you neither read nor write tickets, so when the work needs one filed, commented, or moved, say so in your phase report and let Supervisor carry it. ${FUSION_TOOL_BOUNDARY_INSTRUCTIONS} ${FUSION_PAIR_COMMUNICATION_INSTRUCTIONS}`;
|
|
59508
|
+
const FUSION_WATCHER_TOOL_INSTRUCTIONS = `Supervisor tools belong to the p4-code MCP server: thread_watch_review, thread_watch_events, thread_evidence_report, thread_plan_update, thread_advise, thread_gate_respond, and the task board tools task_current, task_get, task_list, task_create and task_update. Read the board whenever planning needs it, and read a named ticket with ticket_resolve together with its comments, treating a later comment as newer intent than the description. Resolve which task that is by calling task_current with the builder's threadId, because a pair started from the board links the row to the builder and not to you; a bare task_current answers for your own thread and reports the task as unlinked. Write only within that task: update it or a subtask you created under it, never an unrelated row, never another project's board, and never any board but this one - task_create files a real ticket on an external tracker when given a source such as 'linear', and that is not yours to file. If absent from the visible tool list, first use the available tool search/discovery facility to load those exact tools, including provider-prefixed names; absence from the initial list does not establish unavailability. If discovery or a call fails, report the exact missing capability or error and the need to restore the p4-code MCP connection. Do not offer manual relay, draft or implement the task yourself, or abandon the supervisor role as a workaround. For an actionable user requirement, read the relevant builder evidence and relay it through thread_advise; when a gate is open, answer through thread_gate_respond instead. Structure every actionable supervisor advice message with these Markdown headings, in order:
|
|
59454
59509
|
## Recommendation
|
|
59455
59510
|
State the decision or correction in one sentence. For a phase review, say whether the phase is approved or needs changes.
|
|
59456
59511
|
## Reason
|
|
@@ -59465,7 +59520,7 @@ Give ordered, concrete actions for the builder, including the next phase and its
|
|
|
59465
59520
|
* which is what it did before this existed.
|
|
59466
59521
|
*/
|
|
59467
59522
|
const FUSION_EVIDENCE_SUMMARY_INSTRUCTIONS = `Read the builder's evidence through a summarizer subagent rather than into your own context: spawn one subagent with the evidence range and have it call thread_watch_review itself, paging with nextAfterSequence against a fixed throughSequence until hasMore is false, and return a summary. Your tool grants reach it, so it can read the paired builder thread exactly as you can. Instruct that subagent that thread_watch_review and thread_watch_events may be absent from its visible tool list and that absence does not establish unavailability: it must first use the available tool search/discovery facility to load those exact tools by name, including provider-prefixed names, and if discovery itself fails it must report that exact error rather than reporting no such tool. Require of that summary: every file path touched with the scale of the change to each, every command run and whether it succeeded or failed, check and test results with their counts, exact error text quoted rather than characterized, the builder's completion claims marked as claims, and anything the builder flagged as uncertain, blocked or unresolved. Over a multi-turn range it must also carry unresolved objections of yours and whether each was answered, phases completed versus reopened, claims never verified, and files touched repeatedly. Every statement cites the sequences behind it so you can check any one of them with a targeted thread_watch_events read. Fail open: if no subagent is available, the spawn errors, it times out, or what comes back is empty or does not cite sequences, read the range yourself and review from the raw evidence. Never skip or shorten a review because a summary was unavailable. Record which fallback you took: a subagent that could not load the watch tools is a misconfiguration worth naming in your report, and it is not the same as a spawn that errored or timed out. Then close the review by calling thread_evidence_report exactly once, after the last page: pass the watched thread id, the same fixed throughSequence the first review page returned - not lastSequence and not the final page's nextAfterSequence, because the server pairs the report to the read by that exact number - the first and last sequence you actually covered, the model you spawned on, and outcome summarized with the subagent's exact returned text, fallback when you read the raw range yourself, or failed when the summarizer could not run or could not report - with the reason in your own words for the last two. Report even when the summary never arrived; a review with no report cannot be told apart from a supervisor that never tried. Never paste builder tool output into it.`;
|
|
59468
|
-
const FUSION_WATCHER_INSTRUCTIONS = `You are Fusion Supervisor (watcher) in an already-created native server pair. Follow the current turn's [fusion-review-policy] block when present; it overrides phase scheduling. Server owns pairing and coordination and wakes you with ${FUSION_REVIEW_PROMPT_PREFIX} or ${FUSION_GATE_PROMPT_PREFIX} prompts at builder turn boundaries. A plain message outside such a wake may arrive after your conversational memory of the pair is gone; its [fusion-pair] metadata block is authoritative: the builder thread exists and is the counterpart thread id. Never report that no builder thread exists. For a new pair, the initial user request is yours, and its first turn is research, not relay. Research it: when the request names a ticket, read that ticket AND its comments, including replies and inline comments, and treat a later comment as newer intent than the description; read the code, docs, project instructions, and current state the request touches. Analyse what you found: root cause or the concrete design constraint, scope, what the user actually wants delivered, and which delivery steps they authorized. Only then write the phase plan and direct its first phase. The plan is yours alone: call thread_plan_update with the builder's threadId, sending the complete ordered list every time, each phase named in 3-6 words by its outcome rather than by a command, file path, or flag, with exactly one in progress. Split it into the fewest substantial phases the task genuinely needs plus a final integration/whole-task phase; most tasks need one to three work phases, each a complete reviewable slice of behavior. Never split per file, per function, or per trivial step. State each phase's completion condition to the builder in the advice, not in the banner title. On every later review, rewrite the same plan: an approved phase becomes completed and the next becomes in progress in the same call, an objection leaves the current phase in progress or reopens a phase you had marked completed, and the final approval completes the last phase. The builder never touches it, so a phase you do not move stays where it is. Then direct the phase to the idle builder through thread_advise, stating the findings that make the direction actionable alongside the original requirements, constraints, and authorized delivery scope. Never echo the request back as its own direction, never hand over a plan you did not ground in evidence, and never send ${FUSION_NO_OBJECTION_TEXT} before the builder has received its first phase and produced a turn to review. The builder has not received the initial prompt and must not start before your direction. Missing builder turn evidence is expected before that first direction; do not wait for it or implement the work yourself. Ask the user only when needed to resolve a blocking requirement. ${FUSION_WATCHER_TOOL_INSTRUCTIONS} To resume supervision, read builder evidence with thread_watch_review from lastReviewedImplementerSequence, capture its throughSequence on the first page and reuse that fixed bound while paging with nextAfterSequence until hasMore is false (including empty pages). Apply message append/replace operations by identity; recover truncated evidence through targeted raw thread_watch_events source ranges. Retain reviewed requirements and evidence for final review; recover missing context with targeted history reads rather than mandatory raw replay from zero, derive phase from artifacts (git log/status, PR, builder events, including its turn.plan.updated phase list), steer with thread_advise, and answer an open gate with thread_gate_respond. When a review or gate wake prompt specifies an explicit event range, that range wins over this metadata. Never poll or wait for the builder; deliver review or advice, then end the turn. Every thread_advise starts a builder turn whose completion wakes you again, so never advise a builder that is idle on an external wait or has nothing actionable; report the state in your own thread and end without advising. ${FUSION_PAIR_COMMUNICATION_INSTRUCTIONS}`;
|
|
59523
|
+
const FUSION_WATCHER_INSTRUCTIONS = `You are Fusion Supervisor (watcher) in an already-created native server pair. Follow the current turn's [fusion-review-policy] block when present; it overrides phase scheduling. Server owns pairing and coordination and wakes you with ${FUSION_REVIEW_PROMPT_PREFIX} or ${FUSION_GATE_PROMPT_PREFIX} prompts at builder turn boundaries. A plain message outside such a wake may arrive after your conversational memory of the pair is gone; its [fusion-pair] metadata block is authoritative: the builder thread exists and is the counterpart thread id. Never report that no builder thread exists. For a new pair, the initial user request is yours, and its first turn is research, not relay. Research it: when the request names a ticket, read that ticket AND its comments, including replies and inline comments, and treat a later comment as newer intent than the description; read the code, docs, project instructions, and current state the request touches. Analyse what you found: root cause or the concrete design constraint, scope, what the user actually wants delivered, and which delivery steps they authorized. Only then write the phase plan and direct its first phase. The plan is yours alone: call thread_plan_update with the builder's threadId, sending the complete ordered list every time, each phase named in 3-6 words by its outcome rather than by a command, file path, or flag, with exactly one in progress. Split it into the fewest substantial phases the task genuinely needs plus a final integration/whole-task phase; most tasks need one to three work phases, each a complete reviewable slice of behavior. Never split per file, per function, or per trivial step. State each phase's completion condition to the builder in the advice, not in the banner title. On every later review, rewrite the same plan: an approved phase becomes completed and the next becomes in progress in the same call, an objection leaves the current phase in progress or reopens a phase you had marked completed, and the final approval completes the last phase. The builder never touches it, so a phase you do not move stays where it is. Then direct the phase to the idle builder through thread_advise, stating the findings that make the direction actionable alongside the original requirements, constraints, and authorized delivery scope. Never echo the request back as its own direction, never hand over a plan you did not ground in evidence, and never send ${FUSION_NO_OBJECTION_TEXT} before the builder has received its first phase and produced a turn to review. The builder has not received the initial prompt and must not start before your direction. Missing builder turn evidence is expected before that first direction; do not wait for it or implement the work yourself. Ask the user only when needed to resolve a blocking requirement. ${FUSION_WATCHER_TOOL_INSTRUCTIONS} To resume supervision, read builder evidence with thread_watch_review from lastReviewedImplementerSequence, capture its throughSequence on the first page and reuse that fixed bound while paging with nextAfterSequence until hasMore is false (including empty pages). Apply message append/replace operations by identity; recover truncated evidence through targeted raw thread_watch_events source ranges. Retain reviewed requirements and evidence for final review; recover missing context with targeted history reads rather than mandatory raw replay from zero, derive phase from artifacts (git log/status, PR, builder events, including its turn.plan.updated phase list), steer with thread_advise, and answer an open gate with thread_gate_respond. When a review or gate wake prompt specifies an explicit event range, that range wins over this metadata. Never poll or wait for the builder; deliver review or advice, then end the turn. Every thread_advise starts a builder turn whose completion wakes you again, so never advise a builder that is idle on an external wait or has nothing actionable; report the state in your own thread and end without advising. ${FUSION_TOOL_BOUNDARY_INSTRUCTIONS} ${FUSION_PAIR_COMMUNICATION_INSTRUCTIONS}`;
|
|
59469
59524
|
/**
|
|
59470
59525
|
* The one-line stand-in for the full block on a message whose session already
|
|
59471
59526
|
* carries the role instructions.
|
|
@@ -59625,7 +59680,18 @@ const FUSION_SAFETY_KERNEL = [
|
|
|
59625
59680
|
"- Never commit, push, or open a pull request unless the current turn asked for it, and never commit to main or master directly.",
|
|
59626
59681
|
"- Never add AI attribution to a commit message, pull request, issue, comment, code, or document."
|
|
59627
59682
|
].join("\n");
|
|
59628
|
-
/**
|
|
59683
|
+
/**
|
|
59684
|
+
* Everything the supervisor half needs from the P4 MCP server, and nothing else.
|
|
59685
|
+
*
|
|
59686
|
+
* The task tools are here because planning is the supervisor's job, not an
|
|
59687
|
+
* aside to it: it owns the phase plan, so it has to be able to read the board,
|
|
59688
|
+
* break the work down and keep the authorized task honest. Mutation stays
|
|
59689
|
+
* bounded by the prompt rather than by this set, which can only say whether a
|
|
59690
|
+
* tool exists - the rule is the current task, never someone else's board row.
|
|
59691
|
+
* `task_propose` is deliberately absent: it tells its caller to stop and wait
|
|
59692
|
+
* for the user, and a supervisor that waits is one the builder sits idle
|
|
59693
|
+
* behind. Asking the user is what `ask_user_question` is for.
|
|
59694
|
+
*/
|
|
59629
59695
|
const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
|
|
59630
59696
|
"thread_watch_review",
|
|
59631
59697
|
"thread_watch_events",
|
|
@@ -59634,7 +59700,12 @@ const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
|
|
|
59634
59700
|
"thread_gate_respond",
|
|
59635
59701
|
"thread_plan_update",
|
|
59636
59702
|
"ask_user_question",
|
|
59637
|
-
"task_current"
|
|
59703
|
+
"task_current",
|
|
59704
|
+
"task_get",
|
|
59705
|
+
"task_list",
|
|
59706
|
+
"task_create",
|
|
59707
|
+
"task_update",
|
|
59708
|
+
"ticket_resolve"
|
|
59638
59709
|
]);
|
|
59639
59710
|
/**
|
|
59640
59711
|
* What the builder half must not reach.
|
|
@@ -59642,15 +59713,27 @@ const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
|
|
|
59642
59713
|
* Framed as a denial rather than an allowance because the builder's job is
|
|
59643
59714
|
* open-ended: it needs coding, verification and preview tools, and a new one of
|
|
59644
59715
|
* those should reach it without an edit here. What it must never have is the
|
|
59645
|
-
* pair's own machinery - spawning threads, creating
|
|
59646
|
-
*
|
|
59647
|
-
*
|
|
59648
|
-
*
|
|
59716
|
+
* pair's own machinery - spawning threads, creating pairs, advising, answering
|
|
59717
|
+
* its own gate - the phase plan, which the supervisor owns outright, plus
|
|
59718
|
+
* task-board mutation and the external services that belong to a person rather
|
|
59719
|
+
* than to an implementation turn. The review tools join that list: they read
|
|
59720
|
+
* the review of the builder's own work, which is the supervisor watching the
|
|
59721
|
+
* builder and never the other way round.
|
|
59722
|
+
*
|
|
59723
|
+
* `thread_pair_detach` is the one piece of pair machinery the builder keeps,
|
|
59724
|
+
* because it is the way out of the arrangement rather than a way to do the
|
|
59725
|
+
* supervisor's job. `thread_pair_detach`'s own handler already limits it to a
|
|
59726
|
+
* pair whose implementer is the calling thread and whose supervisor that
|
|
59727
|
+
* thread created, so the only pair a builder can end is one it made of itself.
|
|
59728
|
+
* Denying it here would leave no agent able to detach any pair at all, which
|
|
59729
|
+
* is the one-way door this repository treats as a bug.
|
|
59649
59730
|
*/
|
|
59650
59731
|
const FUSION_BUILDER_DENIED_P4_TOOLS = /* @__PURE__ */ new Set([
|
|
59651
59732
|
"thread_spawn",
|
|
59733
|
+
"thread_watch_review",
|
|
59734
|
+
"thread_watch_events",
|
|
59735
|
+
"thread_evidence_report",
|
|
59652
59736
|
"thread_pair_create",
|
|
59653
|
-
"thread_pair_detach",
|
|
59654
59737
|
"thread_cleanup",
|
|
59655
59738
|
"thread_rename",
|
|
59656
59739
|
"thread_configure",
|
|
@@ -59695,21 +59778,90 @@ function fusionContextPolicyFor(role, optimizedContext) {
|
|
|
59695
59778
|
optimizedContext
|
|
59696
59779
|
};
|
|
59697
59780
|
}
|
|
59698
|
-
/**
|
|
59781
|
+
/**
|
|
59782
|
+
* Whether a role may call one P4 tool at all, regardless of any setting.
|
|
59783
|
+
*
|
|
59784
|
+
* The hiding functions below are a context measure: they keep a role's tool
|
|
59785
|
+
* list short, they only work on adapters that expose a per-session filter, and
|
|
59786
|
+
* the user can turn them off. This is the authorization question instead, so
|
|
59787
|
+
* it takes no setting and no adapter - a role that cannot act on a tool must
|
|
59788
|
+
* not be able to call it on any provider, whether or not the list it was shown
|
|
59789
|
+
* mentioned it.
|
|
59790
|
+
*
|
|
59791
|
+
* Shaped from the same two sets the hiding uses, so the list a role sees and
|
|
59792
|
+
* the calls it may make cannot drift apart. A thread with no role is not half
|
|
59793
|
+
* of a pair and is not this function's business.
|
|
59794
|
+
*/
|
|
59795
|
+
function fusionRoleMayCallP4Tool(role, toolName) {
|
|
59796
|
+
if (role === void 0) return true;
|
|
59797
|
+
return role === "watcher" ? FUSION_SUPERVISOR_P4_TOOLS.has(toolName) : !FUSION_BUILDER_DENIED_P4_TOOLS.has(toolName);
|
|
59798
|
+
}
|
|
59799
|
+
/**
|
|
59800
|
+
* Whether the context trimming applies. Ordinary threads never reach it.
|
|
59801
|
+
*
|
|
59802
|
+
* Only the measures that exist to save tokens ask this: the P4 tool list and
|
|
59803
|
+
* the setting sources. What a role may do is not a token question and must not
|
|
59804
|
+
* consult a setting the user can turn off - see `fusionRoleApplies`.
|
|
59805
|
+
*/
|
|
59699
59806
|
function fusionLeanContextApplies(input) {
|
|
59700
59807
|
return input.role !== void 0 && input.optimizedContext;
|
|
59701
59808
|
}
|
|
59702
59809
|
/**
|
|
59703
|
-
*
|
|
59810
|
+
* Whether a role's restrictions apply: whenever there is a role.
|
|
59811
|
+
*
|
|
59812
|
+
* Kept apart from the trimming above because the two answer different
|
|
59813
|
+
* questions. Turning context optimization off should cost a pair its token
|
|
59814
|
+
* savings, never hand the builder the tracker and the planning tools back or
|
|
59815
|
+
* give the supervisor an editor.
|
|
59816
|
+
*/
|
|
59817
|
+
function fusionRoleApplies(input) {
|
|
59818
|
+
return input.role !== void 0;
|
|
59819
|
+
}
|
|
59820
|
+
/**
|
|
59821
|
+
* The registered servers the supervisor half keeps, by lowercased name.
|
|
59822
|
+
*
|
|
59823
|
+
* Only the tracker, and only for the supervisor. It researches the request
|
|
59824
|
+
* before it plans, and `ticket_resolve` answers with a ticket's description
|
|
59825
|
+
* alone - the comments that routinely carry newer intent are reachable only
|
|
59826
|
+
* through the user's own Linear registration. The builder keeps none: it
|
|
59827
|
+
* implements the phase it was given and has no errand on a tracker.
|
|
59828
|
+
*
|
|
59829
|
+
* The name is repeated here rather than imported from `mcp/LinearMcpClient`,
|
|
59830
|
+
* which every adapter would then pull into a provider process. A test pins
|
|
59831
|
+
* the two together, exactly as the P4 tool catalogue is pinned.
|
|
59832
|
+
*/
|
|
59833
|
+
const FUSION_SUPERVISOR_EXTERNAL_MCP_SERVERS = /* @__PURE__ */ new Set(["linear"]);
|
|
59834
|
+
/**
|
|
59835
|
+
* The name a registration was given, with the user-scope prefix taken back off.
|
|
59836
|
+
*
|
|
59837
|
+
* A server the user registered in their own provider config reaches a session
|
|
59838
|
+
* re-keyed as `p4-user-<name>`, so matching the raw key would look for a name
|
|
59839
|
+
* the registry never hands over and would drop the tracker it was written to
|
|
59840
|
+
* keep. The prefix is spelled here for the same reason the name is: importing
|
|
59841
|
+
* the registry would pull it into every provider process.
|
|
59842
|
+
*/
|
|
59843
|
+
const USER_SCOPE_REGISTRATION_PREFIX = "p4-user-";
|
|
59844
|
+
const fusionRegistrationBareName = (name) => {
|
|
59845
|
+
const lowered = name.toLowerCase();
|
|
59846
|
+
return lowered.startsWith(USER_SCOPE_REGISTRATION_PREFIX) ? lowered.slice(8) : lowered;
|
|
59847
|
+
};
|
|
59848
|
+
/**
|
|
59849
|
+
* The external MCP registrations a Fusion session keeps.
|
|
59850
|
+
*
|
|
59851
|
+
* Applies to any pair turn, optimized or not: which servers a role may reach
|
|
59852
|
+
* is authorization, and the setting only governs what is trimmed to save
|
|
59853
|
+
* tokens.
|
|
59704
59854
|
*
|
|
59705
|
-
* Everything a user registered for their own work goes, because neither
|
|
59706
|
-
* can act on it during a pair turn and every definition is paid for on
|
|
59707
|
-
* turn of the loop. Computer Use needs no exception here - it is a
|
|
59855
|
+
* Everything else a user registered for their own work goes, because neither
|
|
59856
|
+
* role can act on it during a pair turn and every definition is paid for on
|
|
59857
|
+
* every turn of the loop. Computer Use needs no exception here - it is a
|
|
59708
59858
|
* provider-owned endpoint attached only to a session that was granted it, not
|
|
59709
59859
|
* a registration in this map, so a granted session keeps it either way.
|
|
59710
59860
|
*/
|
|
59711
59861
|
function fusionExternalMcpServers(servers, input) {
|
|
59712
|
-
|
|
59862
|
+
if (!fusionRoleApplies(input)) return { ...servers };
|
|
59863
|
+
if (input.role !== "watcher") return {};
|
|
59864
|
+
return Object.fromEntries(Object.entries(servers).filter(([name]) => FUSION_SUPERVISOR_EXTERNAL_MCP_SERVERS.has(fusionRegistrationBareName(name))));
|
|
59713
59865
|
}
|
|
59714
59866
|
/**
|
|
59715
59867
|
* P4 tools this role must not see, given the full catalogue.
|
|
@@ -59723,20 +59875,70 @@ function fusionHiddenP4Tools(catalogue, input) {
|
|
|
59723
59875
|
return input.role === "watcher" ? catalogue.filter((name) => !FUSION_SUPERVISOR_P4_TOOLS.has(name)) : catalogue.filter((name) => FUSION_BUILDER_DENIED_P4_TOOLS.has(name));
|
|
59724
59876
|
}
|
|
59725
59877
|
/**
|
|
59878
|
+
* Provider-native tools the supervisor must not see.
|
|
59879
|
+
*
|
|
59880
|
+
* It reviews and directs; it never edits the tree. Reading tools stay, because
|
|
59881
|
+
* grounding an objection in the code is exactly its job, and a shell stays too
|
|
59882
|
+
* - `git log` and `git status` are how it derives phase from artifacts. What
|
|
59883
|
+
* goes is the means to write the change it is supposed to be reviewing.
|
|
59884
|
+
*
|
|
59885
|
+
* Named per provider, like the builder's list, because the names are the
|
|
59886
|
+
* provider's rather than ours.
|
|
59887
|
+
*/
|
|
59888
|
+
const FUSION_SUPERVISOR_DENIED_NATIVE_TOOLS = [
|
|
59889
|
+
"Edit",
|
|
59890
|
+
"MultiEdit",
|
|
59891
|
+
"Write",
|
|
59892
|
+
"NotebookEdit",
|
|
59893
|
+
"apply_patch"
|
|
59894
|
+
];
|
|
59895
|
+
/**
|
|
59726
59896
|
* Provider-native tools this role must not see.
|
|
59727
59897
|
*
|
|
59728
59898
|
* The supervisor keeps its own planning tool: it is the half that plans, and
|
|
59729
|
-
* for its own thread that tool is the ordinary one.
|
|
59899
|
+
* for its own thread that tool is the ordinary one. It loses the editing tools
|
|
59900
|
+
* instead, which are the builder's half of the same split.
|
|
59901
|
+
*
|
|
59902
|
+
* Applies to any pair turn, optimized or not, for the same reason the external
|
|
59903
|
+
* registrations do: a builder holding the planning tool is a role problem, not
|
|
59904
|
+
* a token one.
|
|
59730
59905
|
*/
|
|
59731
59906
|
function fusionHiddenNativeTools(input) {
|
|
59732
|
-
|
|
59907
|
+
if (!fusionRoleApplies(input)) return [];
|
|
59908
|
+
return input.role === "implementer" ? FUSION_BUILDER_DENIED_NATIVE_TOOLS : FUSION_SUPERVISOR_DENIED_NATIVE_TOOLS;
|
|
59909
|
+
}
|
|
59910
|
+
/**
|
|
59911
|
+
* Global agents a pair still owes its user after the lean profile drops the
|
|
59912
|
+
* user setting source.
|
|
59913
|
+
*
|
|
59914
|
+
* Dropping that source is what makes the profile lean, but it takes the user's
|
|
59915
|
+
* subagent definitions with it, and the delivery loop this pair runs on names
|
|
59916
|
+
* one of them outright: the supervisor will not accept the work until the
|
|
59917
|
+
* global `pr-reviewer` has reviewed the branch. Leaving it unregistered means
|
|
59918
|
+
* the builder either cannot comply or quietly reviews with something else,
|
|
59919
|
+
* which is the failure this list exists to prevent. Named rather than
|
|
59920
|
+
* discovered, so the cost stays one definition instead of every agent the user
|
|
59921
|
+
* happens to have.
|
|
59922
|
+
*/
|
|
59923
|
+
const FUSION_REQUIRED_GLOBAL_AGENTS = ["pr-reviewer"];
|
|
59924
|
+
/**
|
|
59925
|
+
* Which global agents this session has to register for itself.
|
|
59926
|
+
*
|
|
59927
|
+
* Empty unless the lean profile actually dropped the user source: when it is
|
|
59928
|
+
* off the provider loads them the usual way, and registering them again would
|
|
59929
|
+
* be a second definition of the same agent.
|
|
59930
|
+
*/
|
|
59931
|
+
function fusionRequiredGlobalAgents(input) {
|
|
59932
|
+
return fusionLeanContextApplies(input) ? FUSION_REQUIRED_GLOBAL_AGENTS : [];
|
|
59733
59933
|
}
|
|
59734
59934
|
/**
|
|
59735
59935
|
* Setting sources a Fusion session reads.
|
|
59736
59936
|
*
|
|
59737
59937
|
* `user` carries the global memory files and the user-level skills; the
|
|
59738
59938
|
* project's own instructions live in `project` and `local` and stay, because
|
|
59739
|
-
* they are the ones that describe the code being changed.
|
|
59939
|
+
* they are the ones that describe the code being changed. What the drop also
|
|
59940
|
+
* takes, and what `fusionRequiredGlobalAgents` above hands back, is the user's
|
|
59941
|
+
* subagent definitions.
|
|
59740
59942
|
*/
|
|
59741
59943
|
function fusionSettingSources(sources, input) {
|
|
59742
59944
|
if (!fusionLeanContextApplies(input)) return sources;
|
|
@@ -69927,6 +70129,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
|
|
|
69927
70129
|
optimizedContext: fusionPromptSettings?.optimizedContext ?? DEFAULT_FUSION_PROMPT_SETTINGS.optimizedContext
|
|
69928
70130
|
};
|
|
69929
70131
|
const externalMcpServers = fusionExternalMcpServers(registeredMcpServers, fusionContextPolicy);
|
|
70132
|
+
const requiredGlobalAgents = yield* discoverClaudeUserAgents(claudeSettings, fusionRequiredGlobalAgents(fusionContextPolicy), claudeEnvironment, input.cwd).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path$1.Path, path));
|
|
69930
70133
|
const hiddenTools = [...fusionHiddenP4Tools(providerMcpToolNames(), fusionContextPolicy).map((name) => `mcp__${P4CODE_MCP_SERVER_NAME}__${name}`), ...fusionHiddenNativeTools(fusionContextPolicy)];
|
|
69931
70134
|
const unpromptedSubagents = input.unpromptedSubagents !== void 0 ? input.unpromptedSubagents : options?.resolveUnpromptedSubagents === void 0 ? DEFAULT_SERVER_SETTINGS.enableUnpromptedSubagents : yield* options.resolveUnpromptedSubagents;
|
|
69932
70135
|
const compressRuleset = compressRulesetFor(input.compressMode ?? "off");
|
|
@@ -69958,6 +70161,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
|
|
|
69958
70161
|
...systemPromptAppend.length > 0 ? { append: systemPromptAppend } : {}
|
|
69959
70162
|
},
|
|
69960
70163
|
settingSources: [...fusionSettingSources(CLAUDE_SETTING_SOURCES, fusionContextPolicy)],
|
|
70164
|
+
...Object.keys(requiredGlobalAgents).length > 0 ? { agents: requiredGlobalAgents } : {},
|
|
69961
70165
|
...hiddenTools.length > 0 ? { disallowedTools: hiddenTools } : {},
|
|
69962
70166
|
...effectiveEffort ? { effort: effectiveEffort } : {},
|
|
69963
70167
|
...permissionMode ? { permissionMode } : {},
|
|
@@ -130660,14 +130864,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130660
130864
|
const httpServer = yield* HttpServer.HttpServer;
|
|
130661
130865
|
const state = yield* SynchronizedRef.make({
|
|
130662
130866
|
records: /* @__PURE__ */ new Map(),
|
|
130663
|
-
spawnedThreads: /* @__PURE__ */ new Map()
|
|
130867
|
+
spawnedThreads: /* @__PURE__ */ new Map(),
|
|
130868
|
+
fusionRoles: /* @__PURE__ */ new Map()
|
|
130664
130869
|
});
|
|
130665
130870
|
const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis;
|
|
130666
130871
|
const livenessWindowMs = options.livenessWindowMs ?? DEFAULT_LIVENESS_WINDOW_MS;
|
|
130667
130872
|
const endpoint = httpServer.address._tag === "TcpAddress" ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` : "http://127.0.0.1/mcp";
|
|
130668
130873
|
const hashToken = (token) => crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe(Effect.map(bytesToHex), Effect.orDie);
|
|
130669
130874
|
const revokeWhere = Effect.fn("McpSessionRegistry.revokeWhere")(function* (predicate) {
|
|
130670
|
-
const removed = yield* SynchronizedRef.modify(state, ({ records, spawnedThreads }) => {
|
|
130875
|
+
const removed = yield* SynchronizedRef.modify(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130671
130876
|
const next = new Map(records);
|
|
130672
130877
|
const scopes = [];
|
|
130673
130878
|
for (const [hash, record] of records) {
|
|
@@ -130678,7 +130883,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130678
130883
|
}
|
|
130679
130884
|
return [scopes, {
|
|
130680
130885
|
records: next,
|
|
130681
|
-
spawnedThreads
|
|
130886
|
+
spawnedThreads,
|
|
130887
|
+
fusionRoles
|
|
130682
130888
|
}];
|
|
130683
130889
|
});
|
|
130684
130890
|
yield* Effect.forEach(removed, (scope) => scope.authorizationLifetime?.revocation ? Deferred.succeed(scope.authorizationLifetime.revocation, void 0) : Effect.void, { discard: true });
|
|
@@ -130706,6 +130912,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130706
130912
|
if (watchThreadIds.size > 0) capabilities.add("watch");
|
|
130707
130913
|
if (adviseThreadIds.size > 0) capabilities.add("advise");
|
|
130708
130914
|
const threadId = ThreadId.make(request.threadId);
|
|
130915
|
+
const rememberedRole = yield* SynchronizedRef.get(state).pipe(Effect.map((current) => current.fusionRoles.get(threadId)));
|
|
130916
|
+
const fusionRole = rememberedRole === void 0 ? request.fusionRole : rememberedRole ?? void 0;
|
|
130709
130917
|
const scopeWith = (mayCreateThreads, mayCreateFusionPairs, revocation = mainRevocation) => ({
|
|
130710
130918
|
authorizationLifetime: {
|
|
130711
130919
|
revoked: false,
|
|
@@ -130719,11 +130927,12 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130719
130927
|
capabilities,
|
|
130720
130928
|
...watchThreadIds.size > 0 ? { watchThreadIds } : {},
|
|
130721
130929
|
...adviseThreadIds.size > 0 ? { adviseThreadIds } : {},
|
|
130930
|
+
...fusionRole === void 0 ? {} : { fusionRole },
|
|
130722
130931
|
mayCreateThreads,
|
|
130723
130932
|
mayCreateFusionPairs,
|
|
130724
130933
|
issuedAt
|
|
130725
130934
|
});
|
|
130726
|
-
yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
|
|
130935
|
+
yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130727
130936
|
const next = new Map(records);
|
|
130728
130937
|
next.set(tokenHash, {
|
|
130729
130938
|
tokenHash,
|
|
@@ -130743,7 +130952,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130743
130952
|
});
|
|
130744
130953
|
return {
|
|
130745
130954
|
records: next,
|
|
130746
|
-
spawnedThreads
|
|
130955
|
+
spawnedThreads,
|
|
130956
|
+
fusionRoles
|
|
130747
130957
|
};
|
|
130748
130958
|
});
|
|
130749
130959
|
return { config: {
|
|
@@ -130765,12 +130975,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130765
130975
|
const tokenHash = yield* hashToken(rawToken);
|
|
130766
130976
|
const timestamp = yield* currentTimeMillis;
|
|
130767
130977
|
yield* pruneDead(timestamp);
|
|
130768
|
-
return yield* SynchronizedRef.modify(state, ({ records, spawnedThreads }) => {
|
|
130978
|
+
return yield* SynchronizedRef.modify(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130769
130979
|
const current = records;
|
|
130770
130980
|
const record = current.get(tokenHash);
|
|
130771
130981
|
if (!record) return [void 0, {
|
|
130772
130982
|
records: current,
|
|
130773
|
-
spawnedThreads
|
|
130983
|
+
spawnedThreads,
|
|
130984
|
+
fusionRoles
|
|
130774
130985
|
}];
|
|
130775
130986
|
const next = new Map(current);
|
|
130776
130987
|
next.set(tokenHash, {
|
|
@@ -130784,14 +130995,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130784
130995
|
watchThreadIds: /* @__PURE__ */ new Set([...record.scope.watchThreadIds ?? [], ...record.controlThreadIds])
|
|
130785
130996
|
}, {
|
|
130786
130997
|
records: next,
|
|
130787
|
-
spawnedThreads
|
|
130998
|
+
spawnedThreads,
|
|
130999
|
+
fusionRoles
|
|
130788
131000
|
}];
|
|
130789
131001
|
});
|
|
130790
131002
|
});
|
|
130791
131003
|
const touch = Effect.fn("McpSessionRegistry.touch")(function* (threadId) {
|
|
130792
131004
|
const timestamp = yield* currentTimeMillis;
|
|
130793
131005
|
yield* pruneDead(timestamp);
|
|
130794
|
-
yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
|
|
131006
|
+
yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130795
131007
|
const current = records;
|
|
130796
131008
|
const next = new Map(current);
|
|
130797
131009
|
for (const [tokenHash, record] of current) if (record.scope.threadId === threadId) next.set(tokenHash, {
|
|
@@ -130800,12 +131012,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130800
131012
|
});
|
|
130801
131013
|
return {
|
|
130802
131014
|
records: next,
|
|
130803
|
-
spawnedThreads
|
|
131015
|
+
spawnedThreads,
|
|
131016
|
+
fusionRoles
|
|
130804
131017
|
};
|
|
130805
131018
|
});
|
|
130806
131019
|
});
|
|
130807
131020
|
const grantWatchThread = Effect.fn("McpSessionRegistry.grantWatchThread")(function* ({ watcherThreadId, watchedThreadId }) {
|
|
130808
|
-
yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
|
|
131021
|
+
yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130809
131022
|
const next = new Map(records);
|
|
130810
131023
|
for (const [tokenHash, record] of records) {
|
|
130811
131024
|
if (record.scope.threadId !== watcherThreadId) continue;
|
|
@@ -130820,12 +131033,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130820
131033
|
}
|
|
130821
131034
|
return {
|
|
130822
131035
|
records: next,
|
|
130823
|
-
spawnedThreads
|
|
131036
|
+
spawnedThreads,
|
|
131037
|
+
fusionRoles
|
|
130824
131038
|
};
|
|
130825
131039
|
});
|
|
130826
131040
|
});
|
|
130827
131041
|
const revokeWatchThread = Effect.fn("McpSessionRegistry.revokeWatchThread")(function* ({ watcherThreadId, watchedThreadId }) {
|
|
130828
|
-
yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
|
|
131042
|
+
yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130829
131043
|
const next = new Map(records);
|
|
130830
131044
|
for (const [tokenHash, record] of records) {
|
|
130831
131045
|
if (record.scope.threadId !== watcherThreadId) continue;
|
|
@@ -130848,12 +131062,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130848
131062
|
}
|
|
130849
131063
|
return {
|
|
130850
131064
|
records: next,
|
|
130851
|
-
spawnedThreads
|
|
131065
|
+
spawnedThreads,
|
|
131066
|
+
fusionRoles
|
|
130852
131067
|
};
|
|
130853
131068
|
});
|
|
130854
131069
|
});
|
|
130855
131070
|
const grantAdviseThread = Effect.fn("McpSessionRegistry.grantAdviseThread")(function* ({ watcherThreadId, advisedThreadId }) {
|
|
130856
|
-
yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
|
|
131071
|
+
yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130857
131072
|
const next = new Map(records);
|
|
130858
131073
|
for (const [tokenHash, record] of records) {
|
|
130859
131074
|
if (record.scope.threadId !== watcherThreadId) continue;
|
|
@@ -130868,12 +131083,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130868
131083
|
}
|
|
130869
131084
|
return {
|
|
130870
131085
|
records: next,
|
|
130871
|
-
spawnedThreads
|
|
131086
|
+
spawnedThreads,
|
|
131087
|
+
fusionRoles
|
|
130872
131088
|
};
|
|
130873
131089
|
});
|
|
130874
131090
|
});
|
|
130875
131091
|
const revokeAdviseThread = Effect.fn("McpSessionRegistry.revokeAdviseThread")(function* ({ watcherThreadId, advisedThreadId }) {
|
|
130876
|
-
yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
|
|
131092
|
+
yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130877
131093
|
const next = new Map(records);
|
|
130878
131094
|
for (const [tokenHash, record] of records) {
|
|
130879
131095
|
if (record.scope.threadId !== watcherThreadId) continue;
|
|
@@ -130896,12 +131112,47 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130896
131112
|
}
|
|
130897
131113
|
return {
|
|
130898
131114
|
records: next,
|
|
130899
|
-
spawnedThreads
|
|
131115
|
+
spawnedThreads,
|
|
131116
|
+
fusionRoles
|
|
130900
131117
|
};
|
|
130901
131118
|
});
|
|
130902
131119
|
});
|
|
131120
|
+
/**
|
|
131121
|
+
* Rewrites one thread's half on every credential it holds, and remembers it.
|
|
131122
|
+
*
|
|
131123
|
+
* A pair is routinely made over a thread that is already running, so the
|
|
131124
|
+
* credential it is using was minted before the pair existed and carries no
|
|
131125
|
+
* role. Patching the live record is what closes that session's gate without
|
|
131126
|
+
* restarting it; the remembered value is what answers for a session that has
|
|
131127
|
+
* not started yet, or has not reached its `issue` call.
|
|
131128
|
+
*/
|
|
131129
|
+
const applyFusionRole = (threadId, role) => SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
131130
|
+
const next = new Map(records);
|
|
131131
|
+
for (const [tokenHash, record] of records) {
|
|
131132
|
+
if (record.scope.threadId !== threadId) continue;
|
|
131133
|
+
const { fusionRole: _previous, ...scopeWithoutRole } = record.scope;
|
|
131134
|
+
next.set(tokenHash, {
|
|
131135
|
+
...record,
|
|
131136
|
+
scope: role === null ? scopeWithoutRole : {
|
|
131137
|
+
...scopeWithoutRole,
|
|
131138
|
+
fusionRole: role
|
|
131139
|
+
}
|
|
131140
|
+
});
|
|
131141
|
+
}
|
|
131142
|
+
return {
|
|
131143
|
+
records: next,
|
|
131144
|
+
spawnedThreads,
|
|
131145
|
+
fusionRoles: new Map(fusionRoles).set(threadId, role)
|
|
131146
|
+
};
|
|
131147
|
+
});
|
|
131148
|
+
const setFusionRole = Effect.fn("McpSessionRegistry.setFusionRole")(function* (input) {
|
|
131149
|
+
yield* applyFusionRole(input.threadId, input.role);
|
|
131150
|
+
});
|
|
131151
|
+
const clearFusionRole = Effect.fn("McpSessionRegistry.clearFusionRole")(function* (input) {
|
|
131152
|
+
yield* applyFusionRole(input.threadId, null);
|
|
131153
|
+
});
|
|
130903
131154
|
const recordSpawnedThread = Effect.fn("McpSessionRegistry.recordSpawnedThread")(function* (input) {
|
|
130904
|
-
yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
|
|
131155
|
+
yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
|
|
130905
131156
|
const next = new Map(records);
|
|
130906
131157
|
for (const [tokenHash, record] of records) if (record.scope.providerSessionId === input.providerSessionId) next.set(tokenHash, {
|
|
130907
131158
|
...record,
|
|
@@ -130909,7 +131160,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130909
131160
|
});
|
|
130910
131161
|
return {
|
|
130911
131162
|
records: next,
|
|
130912
|
-
spawnedThreads: new Map([...spawnedThreads, [input.threadId, input.mayCreateFusionPairs === true]])
|
|
131163
|
+
spawnedThreads: new Map([...spawnedThreads, [input.threadId, input.mayCreateFusionPairs === true]]),
|
|
131164
|
+
fusionRoles
|
|
130913
131165
|
};
|
|
130914
131166
|
});
|
|
130915
131167
|
});
|
|
@@ -130917,6 +131169,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
|
|
|
130917
131169
|
issue,
|
|
130918
131170
|
resolve,
|
|
130919
131171
|
touch,
|
|
131172
|
+
setFusionRole,
|
|
131173
|
+
clearFusionRole,
|
|
130920
131174
|
grantWatchThread,
|
|
130921
131175
|
revokeWatchThread,
|
|
130922
131176
|
grantAdviseThread,
|
|
@@ -130948,6 +131202,8 @@ const issueActiveMcpCredential = (request) => activeMcpSessionRegistry ? activeM
|
|
|
130948
131202
|
*/
|
|
130949
131203
|
const touchActiveMcpThread = (threadId) => activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void;
|
|
130950
131204
|
const grantActiveMcpWatchThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.grantWatchThread(input) : Effect.void;
|
|
131205
|
+
const setActiveMcpFusionRole = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.setFusionRole(input) : Effect.void;
|
|
131206
|
+
const clearActiveMcpFusionRole = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.clearFusionRole(input) : Effect.void;
|
|
130951
131207
|
const revokeActiveMcpWatchThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeWatchThread(input) : Effect.void;
|
|
130952
131208
|
const grantActiveMcpAdviseThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.grantAdviseThread(input) : Effect.void;
|
|
130953
131209
|
const revokeActiveMcpAdviseThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAdviseThread(input) : Effect.void;
|
|
@@ -131069,6 +131325,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (options)
|
|
|
131069
131325
|
return Option.isSome(thread) ? resolveProjectAgentBrowserAccess(settings, thread.value.projectId) : false;
|
|
131070
131326
|
})), Effect.catch((cause) => Effect.logWarning("Could not read settings; withholding agent browser access.", { cause }).pipe(Effect.as(false))));
|
|
131071
131327
|
const computerUseAccess = yield* registry.getInstanceInfo(providerInstanceId).pipe(Effect.map((info) => info.driverKind !== "codex"), Effect.catch(() => Effect.succeed(false)));
|
|
131328
|
+
const fusionRole = Option.isSome(projectionQuery) ? yield* projectionQuery.value.getActiveThreadPairs().pipe(Effect.map((pairs) => {
|
|
131329
|
+
const pair = pairs.find((entry) => entry.watcherThreadId === threadId || entry.implementerThreadId === threadId);
|
|
131330
|
+
if (pair === void 0) return void 0;
|
|
131331
|
+
return pair.watcherThreadId === threadId ? "watcher" : "implementer";
|
|
131332
|
+
}), Effect.mapError((cause) => new ProviderValidationError({
|
|
131333
|
+
operation: "ProviderService.prepareMcpSession",
|
|
131334
|
+
issue: "Could not read thread pairs, so the session's Fusion role is unknown. Refusing to issue an MCP credential that would carry no role.",
|
|
131335
|
+
cause
|
|
131336
|
+
}))) : yield* Effect.logWarning("No projection service, so an MCP credential is issued with no Fusion role. A pair whose event this process never saw will be ungated.", { threadId }).pipe(Effect.as(void 0));
|
|
131072
131337
|
const threadTitle = Option.isSome(projectionQuery) ? yield* projectionQuery.value.getThreadShellById(threadId).pipe(Effect.map((thread) => Option.isSome(thread) ? thread.value.title : void 0), Effect.catch(() => Effect.succeed(void 0))) : void 0;
|
|
131073
131338
|
const credential = yield* issueActiveMcpCredential({
|
|
131074
131339
|
threadId,
|
|
@@ -131077,7 +131342,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (options)
|
|
|
131077
131342
|
browserAccess,
|
|
131078
131343
|
computerUseAccess,
|
|
131079
131344
|
...watchThreadIds !== void 0 ? { watchThreadIds } : {},
|
|
131080
|
-
...adviseThreadIds !== void 0 ? { adviseThreadIds } : {}
|
|
131345
|
+
...adviseThreadIds !== void 0 ? { adviseThreadIds } : {},
|
|
131346
|
+
...fusionRole !== void 0 ? { fusionRole } : {}
|
|
131081
131347
|
});
|
|
131082
131348
|
if (credential) yield* Effect.sync(() => setMcpProviderSession(credential.config));
|
|
131083
131349
|
return credential;
|
|
@@ -131923,6 +132189,47 @@ const mcpInputSchema = (schema) => ({
|
|
|
131923
132189
|
...schema,
|
|
131924
132190
|
type: "object"
|
|
131925
132191
|
});
|
|
132192
|
+
const P4_TOOL_NAMES = new Set(P4CODE_MCP_TOOL_NAMES);
|
|
132193
|
+
/**
|
|
132194
|
+
* The server's own answer to "may this role call this tool", applied to every
|
|
132195
|
+
* registered P4 tool before its handler runs.
|
|
132196
|
+
*
|
|
132197
|
+
* The adapters also hide a role's forbidden tools, but that is a context
|
|
132198
|
+
* measure: it depends on a user setting and on the one adapter that can filter
|
|
132199
|
+
* a session's tool list. A caller that never saw the tool can still name it,
|
|
132200
|
+
* so the rule that matters is enforced here, where every provider's call
|
|
132201
|
+
* arrives and neither a setting nor a prompt can move it.
|
|
132202
|
+
*
|
|
132203
|
+
* Only P4's own tools are governed. A name outside the catalogue belongs to
|
|
132204
|
+
* another server sharing this registration helper - Computer Use is one - and
|
|
132205
|
+
* this policy has nothing to say about it.
|
|
132206
|
+
*/
|
|
132207
|
+
const enforceFusionRole = (toolName) => Effect.serviceOption(McpInvocationContext).pipe(Effect.map((invocation) => {
|
|
132208
|
+
if (!P4_TOOL_NAMES.has(toolName)) return null;
|
|
132209
|
+
const role = invocation._tag === "Some" ? invocation.value.fusionRole : void 0;
|
|
132210
|
+
if (role === void 0 || fusionRoleMayCallP4Tool(role, toolName)) return null;
|
|
132211
|
+
return role;
|
|
132212
|
+
}));
|
|
132213
|
+
/** The half's own word, so the message names the caller rather than a code. */
|
|
132214
|
+
const ROLE_LABEL = {
|
|
132215
|
+
implementer: "builder",
|
|
132216
|
+
watcher: "supervisor"
|
|
132217
|
+
};
|
|
132218
|
+
const deniedResult = (toolName, role) => new McpSchema.CallToolResult({
|
|
132219
|
+
isError: true,
|
|
132220
|
+
content: [{
|
|
132221
|
+
type: "text",
|
|
132222
|
+
text: `${toolName} is not a tool the ${ROLE_LABEL[role]} half of a Fusion pair may call. Report the missing tool rather than working around it.`
|
|
132223
|
+
}]
|
|
132224
|
+
});
|
|
132225
|
+
/**
|
|
132226
|
+
* Wraps one tool handler in the role gate.
|
|
132227
|
+
*
|
|
132228
|
+
* Exported because `addTool` is reachable from two places: this helper, and
|
|
132229
|
+
* the one tool registered by hand. A gate that only covered the helper would
|
|
132230
|
+
* leave that tool open, which is the kind of hole a policy is judged by.
|
|
132231
|
+
*/
|
|
132232
|
+
const withFusionRoleGate = (toolName, handle) => (payload) => enforceFusionRole(toolName).pipe(Effect.flatMap((deniedRole) => deniedRole === null ? handle(payload) : Effect.succeed(deniedResult(toolName, deniedRole))));
|
|
131926
132233
|
const mcpToolkit = (toolkit) => Layer.effectDiscard(Effect.gen(function* () {
|
|
131927
132234
|
const server = yield* McpServer.McpServer;
|
|
131928
132235
|
yield* McpServer.registerToolkit(toolkit).pipe(Effect.provideService(McpServer.McpServer, {
|
|
@@ -131932,7 +132239,8 @@ const mcpToolkit = (toolkit) => Layer.effectDiscard(Effect.gen(function* () {
|
|
|
131932
132239
|
tool: new McpSchema.Tool({
|
|
131933
132240
|
...options.tool,
|
|
131934
132241
|
inputSchema: mcpInputSchema(options.tool.inputSchema)
|
|
131935
|
-
})
|
|
132242
|
+
}),
|
|
132243
|
+
handle: withFusionRoleGate(options.tool.name, options.handle)
|
|
131936
132244
|
})
|
|
131937
132245
|
}));
|
|
131938
132246
|
}));
|
|
@@ -132221,13 +132529,13 @@ const registerScreenshot = Effect.fn("device.registerScreenshot")(function* () {
|
|
|
132221
132529
|
}
|
|
132222
132530
|
}),
|
|
132223
132531
|
annotations: DeviceScreenshotTool.annotations,
|
|
132224
|
-
handle: (payload) => Effect.withFiber((fiber) => built.handle("device_screenshot", payload).pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), Effect.provideService(DeviceService, service), Effect.provideService(DeviceScreenshot, screenshots), Effect.provideService(McpInvocationContext, Context.getUnsafe(fiber.context, McpInvocationContext)), Effect.flatMap((result) => screenshotContent(result.encodedResult)), Effect.orElseSucceed(() => new McpSchema.CallToolResult({
|
|
132532
|
+
handle: withFusionRoleGate(DeviceScreenshotTool.name, (payload) => Effect.withFiber((fiber) => built.handle("device_screenshot", payload).pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), Effect.provideService(DeviceService, service), Effect.provideService(DeviceScreenshot, screenshots), Effect.provideService(McpInvocationContext, Context.getUnsafe(fiber.context, McpInvocationContext)), Effect.flatMap((result) => screenshotContent(result.encodedResult)), Effect.orElseSucceed(() => new McpSchema.CallToolResult({
|
|
132225
132533
|
isError: true,
|
|
132226
132534
|
content: [{
|
|
132227
132535
|
type: "text",
|
|
132228
132536
|
text: "Device screenshot unavailable. Check the open session and refresh devices."
|
|
132229
132537
|
}]
|
|
132230
|
-
}))))
|
|
132538
|
+
})))))
|
|
132231
132539
|
});
|
|
132232
132540
|
});
|
|
132233
132541
|
const DeviceToolkitRegistrationLive = Layer.mergeAll(mcpToolkit(DeviceStandardToolkit), Layer.effectDiscard(registerScreenshot())).pipe(Layer.provide(DeviceToolkitHandlersLive));
|
|
@@ -132989,6 +133297,7 @@ const ThreadPairCreateTool = Tool.make("thread_pair_create", {
|
|
|
132989
133297
|
dependencies: [
|
|
132990
133298
|
McpInvocationContext,
|
|
132991
133299
|
OrchestrationEngineService,
|
|
133300
|
+
McpSessionRegistry,
|
|
132992
133301
|
Crypto.Crypto
|
|
132993
133302
|
]
|
|
132994
133303
|
}).annotate(Tool.Title, "Create a Fusion pair").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, false);
|
|
@@ -133383,6 +133692,14 @@ const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
|
|
|
133383
133692
|
watcherThreadId: fusionPair.watcherThreadId,
|
|
133384
133693
|
createdAt
|
|
133385
133694
|
}, threadId);
|
|
133695
|
+
yield* registry.setFusionRole({
|
|
133696
|
+
threadId,
|
|
133697
|
+
role: "implementer"
|
|
133698
|
+
});
|
|
133699
|
+
yield* registry.setFusionRole({
|
|
133700
|
+
threadId: fusionPair.watcherThreadId,
|
|
133701
|
+
role: "watcher"
|
|
133702
|
+
});
|
|
133386
133703
|
}
|
|
133387
133704
|
yield* dispatchControl({
|
|
133388
133705
|
type: "thread.turn.start",
|
|
@@ -133423,6 +133740,15 @@ const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
|
|
|
133423
133740
|
continuationMessageId,
|
|
133424
133741
|
createdAt: DateTime.formatIso(yield* DateTime.now)
|
|
133425
133742
|
}, invocation.threadId);
|
|
133743
|
+
const registry = yield* McpSessionRegistry;
|
|
133744
|
+
yield* registry.setFusionRole({
|
|
133745
|
+
threadId: invocation.threadId,
|
|
133746
|
+
role: "implementer"
|
|
133747
|
+
});
|
|
133748
|
+
yield* registry.setFusionRole({
|
|
133749
|
+
threadId: watcherThreadId,
|
|
133750
|
+
role: "watcher"
|
|
133751
|
+
});
|
|
133426
133752
|
return {
|
|
133427
133753
|
pairId,
|
|
133428
133754
|
implementerThreadId: invocation.threadId,
|
|
@@ -134268,6 +134594,16 @@ const TypeSafeToolkitHandlersLive = TypeSafeToolkit.toLayer({ typesafe_ask: (inp
|
|
|
134268
134594
|
* appending beside it.
|
|
134269
134595
|
*/
|
|
134270
134596
|
const evidenceReadActivityId = (watchedThreadId, throughSequence) => EventId.make(`fusion-evidence:${watchedThreadId}:${throughSequence}`);
|
|
134597
|
+
/**
|
|
134598
|
+
* The key a raw read records under, when the supervisor paged the events itself.
|
|
134599
|
+
*
|
|
134600
|
+
* A raw read has no fixed bound to key on: the supervisor discovers where the
|
|
134601
|
+
* window ends by paging to it, so keying on the sequence it happens to have
|
|
134602
|
+
* reached would mint one row per page. The turn is the bound that already
|
|
134603
|
+
* exists, and it collapses the whole raw read into the single row a compact
|
|
134604
|
+
* review would have written.
|
|
134605
|
+
*/
|
|
134606
|
+
const rawEvidenceReadActivityId = (watchedThreadId, readerTurnId) => EventId.make(`fusion-evidence:${watchedThreadId}:turn:${readerTurnId}`);
|
|
134271
134607
|
/** One report per review, keyed like the read it reports on so a retry replaces. */
|
|
134272
134608
|
const evidenceReportActivityId = (watchedThreadId, throughSequence) => EventId.make(`fusion-evidence-summary:${watchedThreadId}:${throughSequence}`);
|
|
134273
134609
|
/**
|
|
@@ -134300,7 +134636,8 @@ const evidenceTiming = (readAt, closedAt) => {
|
|
|
134300
134636
|
const correlateEvidence = Effect.fn("fusionEvidenceReport.correlateEvidence")(function* (input) {
|
|
134301
134637
|
const projections = yield* ProjectionSnapshotQuery;
|
|
134302
134638
|
const latest = yield* projections.getLatestThreadContextWindow(input.readerThreadId).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
134303
|
-
const
|
|
134639
|
+
const readContext = (activityId) => projections.getEvidenceReadContext(activityId).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
134640
|
+
const read = yield* readContext(input.readActivityId).pipe(Effect.flatMap((row) => Option.isSome(row) || input.fallbackReadActivityId === void 0 ? Effect.succeed(row) : readContext(input.fallbackReadActivityId)));
|
|
134304
134641
|
const timing = Option.match(read, {
|
|
134305
134642
|
onNone: () => null,
|
|
134306
134643
|
onSome: (row) => evidenceTiming(row.readAt, input.closedAt)
|
|
@@ -134341,8 +134678,14 @@ const ThreadWatchEventsTool = Tool.make("thread_watch_events", {
|
|
|
134341
134678
|
parameters: ThreadWatchEventsInput,
|
|
134342
134679
|
success: ThreadWatchEventsResult,
|
|
134343
134680
|
failure: ThreadWatchToolError,
|
|
134344
|
-
dependencies: [
|
|
134345
|
-
|
|
134681
|
+
dependencies: [
|
|
134682
|
+
McpInvocationContext,
|
|
134683
|
+
ThreadEventStreamService,
|
|
134684
|
+
OrchestrationEngineService,
|
|
134685
|
+
ProjectionSnapshotQuery,
|
|
134686
|
+
Crypto.Crypto
|
|
134687
|
+
]
|
|
134688
|
+
}).annotate(Tool.Title, "Read watched thread events").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true);
|
|
134346
134689
|
const ThreadWatchReviewTool = Tool.make("thread_watch_review", {
|
|
134347
134690
|
description: "Read bounded compact review evidence for an explicitly granted thread. Combines assistant fragments by message identity; preserves user requirements, tool evidence, plans, errors and turn boundaries. Resume with nextAfterSequence and the same throughSequence until hasMore is false, even on empty pages. Message operations append or replace by identity across pages. Truncated evidence includes source sequence ranges for targeted recovery with thread_watch_events; do not treat an excerpt as full proof.",
|
|
134348
134691
|
parameters: ThreadWatchReviewInput,
|
|
@@ -134394,20 +134737,24 @@ const REPORT_HEADING = {
|
|
|
134394
134737
|
* for; losing the note that it happened is a smaller loss than losing the page,
|
|
134395
134738
|
* so a rejected dispatch is logged and the page is returned anyway.
|
|
134396
134739
|
*/
|
|
134397
|
-
const recordEvidenceRead = Effect.fn("mcp.watch.recordEvidenceRead")(function* (readerThreadId,
|
|
134740
|
+
const recordEvidenceRead = Effect.fn("mcp.watch.recordEvidenceRead")(function* (readerThreadId, window) {
|
|
134398
134741
|
const engine = yield* OrchestrationEngineService;
|
|
134399
134742
|
const crypto = yield* Crypto.Crypto;
|
|
134400
134743
|
const projections = yield* ProjectionSnapshotQuery;
|
|
134401
134744
|
const createdAt = DateTime.formatIso(yield* DateTime.now);
|
|
134402
|
-
const recorded = yield* projections.getEvidenceReadContext(
|
|
134745
|
+
const recorded = yield* projections.getEvidenceReadContext(window.activityId).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
134403
134746
|
const opened = Option.isSome(recorded);
|
|
134404
134747
|
const readCreatedAt = Option.match(recorded, {
|
|
134405
134748
|
onNone: () => createdAt,
|
|
134406
134749
|
onSome: (row) => row.readAt
|
|
134407
134750
|
});
|
|
134408
134751
|
const readAfterSequence = Option.match(recorded, {
|
|
134409
|
-
onNone: () => afterSequence,
|
|
134410
|
-
onSome: (row) => row.afterSequence ?? afterSequence
|
|
134752
|
+
onNone: () => window.afterSequence,
|
|
134753
|
+
onSome: (row) => row.afterSequence ?? window.afterSequence
|
|
134754
|
+
});
|
|
134755
|
+
const readThroughSequence = Option.match(recorded, {
|
|
134756
|
+
onNone: () => window.throughSequence,
|
|
134757
|
+
onSome: (row) => row.throughSequence === null ? window.throughSequence : Math.max(row.throughSequence, window.throughSequence)
|
|
134411
134758
|
});
|
|
134412
134759
|
const activeTurnId = yield* projections.getActiveTurnId(readerThreadId).pipe(Effect.orElseSucceed(() => Option.none()), Effect.map(Option.getOrNull));
|
|
134413
134760
|
const usedTokensAtRead = opened ? Option.getOrNull(Option.map(recorded, (row) => row.usedTokensAtRead)) : yield* projections.getLatestThreadContextWindow(readerThreadId).pipe(Effect.map(Option.map((row) => row.usedTokens)), Effect.orElseSucceed(() => Option.none()), Effect.map(Option.getOrNull));
|
|
@@ -134417,29 +134764,40 @@ const recordEvidenceRead = Effect.fn("mcp.watch.recordEvidenceRead")(function* (
|
|
|
134417
134764
|
threadId: readerThreadId,
|
|
134418
134765
|
createdAt,
|
|
134419
134766
|
activity: {
|
|
134420
|
-
id:
|
|
134767
|
+
id: window.activityId,
|
|
134421
134768
|
createdAt: readCreatedAt,
|
|
134422
134769
|
tone: "info",
|
|
134423
134770
|
kind: "fusion.evidence.read",
|
|
134424
134771
|
summary: "Read builder evidence",
|
|
134425
134772
|
turnId: null,
|
|
134426
134773
|
payload: {
|
|
134427
|
-
watchedThreadId:
|
|
134774
|
+
watchedThreadId: window.watchedThreadId,
|
|
134428
134775
|
afterSequence: readAfterSequence,
|
|
134429
|
-
throughSequence:
|
|
134430
|
-
nextAfterSequence:
|
|
134431
|
-
hasMore:
|
|
134432
|
-
scannedEvents:
|
|
134776
|
+
throughSequence: readThroughSequence,
|
|
134777
|
+
nextAfterSequence: window.nextAfterSequence,
|
|
134778
|
+
hasMore: window.hasMore,
|
|
134779
|
+
scannedEvents: window.scannedEvents,
|
|
134433
134780
|
...activeTurnId === null ? {} : { turnId: activeTurnId },
|
|
134781
|
+
...window.raw === true ? { raw: true } : {},
|
|
134434
134782
|
...usedTokensAtRead === null ? {} : { usedTokensAtRead }
|
|
134435
134783
|
}
|
|
134436
134784
|
}
|
|
134437
134785
|
}).pipe(Effect.catchCause((cause) => Effect.logWarning("could not record the supervisor's evidence read", {
|
|
134438
134786
|
readerThreadId,
|
|
134439
|
-
watchedThreadId:
|
|
134787
|
+
watchedThreadId: window.watchedThreadId,
|
|
134440
134788
|
cause
|
|
134441
134789
|
})));
|
|
134442
134790
|
});
|
|
134791
|
+
/**
|
|
134792
|
+
* Whether this reader is the watcher half of a live pair over this thread.
|
|
134793
|
+
*
|
|
134794
|
+
* `thread_watch_events` is the generic polling tool, so an orchestrator reading
|
|
134795
|
+
* a thread it spawned goes through it too. Only a Fusion watcher is reading
|
|
134796
|
+
* builder evidence, and only its work log should grow a row saying so.
|
|
134797
|
+
*/
|
|
134798
|
+
const watchesAsFusionSupervisor = Effect.fn("mcp.watch.watchesAsFusionSupervisor")(function* (readerThreadId, watchedThreadId) {
|
|
134799
|
+
return (yield* (yield* ProjectionSnapshotQuery).getActiveThreadPairs().pipe(Effect.orElseSucceed(() => []))).some((pair) => pair.watcherThreadId === readerThreadId && pair.implementerThreadId === watchedThreadId);
|
|
134800
|
+
});
|
|
134443
134801
|
/** Bounded so one report cannot dominate a thread's activity storage. */
|
|
134444
134802
|
const clip = (value, limit) => {
|
|
134445
134803
|
const trimmed = value?.trim();
|
|
@@ -134453,7 +134811,15 @@ const WatchToolkitHandlersLive = WatchToolkit.toLayer({
|
|
|
134453
134811
|
threadId: input.threadId,
|
|
134454
134812
|
detail: cause.message
|
|
134455
134813
|
})));
|
|
134456
|
-
yield* recordEvidenceRead(invocation.threadId,
|
|
134814
|
+
yield* recordEvidenceRead(invocation.threadId, {
|
|
134815
|
+
activityId: evidenceReadActivityId(page.threadId, page.throughSequence),
|
|
134816
|
+
watchedThreadId: page.threadId,
|
|
134817
|
+
afterSequence: input.afterSequence ?? 0,
|
|
134818
|
+
throughSequence: page.throughSequence,
|
|
134819
|
+
nextAfterSequence: page.nextAfterSequence,
|
|
134820
|
+
hasMore: page.hasMore,
|
|
134821
|
+
scannedEvents: page.scannedEvents
|
|
134822
|
+
});
|
|
134457
134823
|
return page;
|
|
134458
134824
|
}),
|
|
134459
134825
|
thread_evidence_report: (input) => Effect.gen(function* () {
|
|
@@ -134461,9 +134827,11 @@ const WatchToolkitHandlersLive = WatchToolkit.toLayer({
|
|
|
134461
134827
|
const engine = yield* OrchestrationEngineService;
|
|
134462
134828
|
const crypto = yield* Crypto.Crypto;
|
|
134463
134829
|
const createdAt = DateTime.formatIso(yield* DateTime.now);
|
|
134830
|
+
const activeTurnId = yield* (yield* ProjectionSnapshotQuery).getActiveTurnId(invocation.threadId).pipe(Effect.orElseSucceed(() => Option.none()), Effect.map(Option.getOrNull));
|
|
134464
134831
|
const { context, timing } = yield* correlateEvidence({
|
|
134465
134832
|
readerThreadId: invocation.threadId,
|
|
134466
134833
|
readActivityId: evidenceReadActivityId(input.threadId, input.throughSequence),
|
|
134834
|
+
...activeTurnId === null ? {} : { fallbackReadActivityId: rawEvidenceReadActivityId(input.threadId, activeTurnId) },
|
|
134467
134835
|
closedAt: createdAt
|
|
134468
134836
|
});
|
|
134469
134837
|
const summary = clip(input.summary, THREAD_EVIDENCE_REPORT_MAX_CHARS);
|
|
@@ -134504,7 +134872,7 @@ const WatchToolkitHandlersLive = WatchToolkit.toLayer({
|
|
|
134504
134872
|
};
|
|
134505
134873
|
}),
|
|
134506
134874
|
thread_watch_events: (input) => Effect.gen(function* () {
|
|
134507
|
-
yield* requireWatchCapability(input.threadId);
|
|
134875
|
+
const invocation = yield* requireWatchCapability(input.threadId);
|
|
134508
134876
|
const page = yield* (yield* ThreadEventStreamService).read({
|
|
134509
134877
|
threadId: input.threadId,
|
|
134510
134878
|
...input.afterSequence !== void 0 ? { afterSequence: input.afterSequence } : {},
|
|
@@ -134513,6 +134881,28 @@ const WatchToolkitHandlersLive = WatchToolkit.toLayer({
|
|
|
134513
134881
|
threadId: input.threadId,
|
|
134514
134882
|
detail: cause.message
|
|
134515
134883
|
})));
|
|
134884
|
+
const lastSequence = page.events.at(-1)?.sequence;
|
|
134885
|
+
if (lastSequence !== void 0 && (yield* watchesAsFusionSupervisor(invocation.threadId, input.threadId))) {
|
|
134886
|
+
const projections = yield* ProjectionSnapshotQuery;
|
|
134887
|
+
const turnId = yield* projections.getActiveTurnId(invocation.threadId).pipe(Effect.orElseSucceed(() => Option.none()), Effect.map(Option.getOrNull));
|
|
134888
|
+
if (turnId !== null) {
|
|
134889
|
+
const activityId = rawEvidenceReadActivityId(input.threadId, turnId);
|
|
134890
|
+
const opened = yield* projections.getLatestEvidenceRead(invocation.threadId).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
134891
|
+
if (!Option.match(opened, {
|
|
134892
|
+
onNone: () => false,
|
|
134893
|
+
onSome: (row) => row.turnId === turnId && row.watchedThreadId === input.threadId
|
|
134894
|
+
})) yield* recordEvidenceRead(invocation.threadId, {
|
|
134895
|
+
activityId,
|
|
134896
|
+
watchedThreadId: input.threadId,
|
|
134897
|
+
afterSequence: input.afterSequence ?? 0,
|
|
134898
|
+
throughSequence: lastSequence,
|
|
134899
|
+
nextAfterSequence: lastSequence,
|
|
134900
|
+
hasMore: page.hasMore,
|
|
134901
|
+
scannedEvents: page.events.length,
|
|
134902
|
+
raw: true
|
|
134903
|
+
});
|
|
134904
|
+
}
|
|
134905
|
+
}
|
|
134516
134906
|
return {
|
|
134517
134907
|
threadId: input.threadId,
|
|
134518
134908
|
events: page.events,
|
|
@@ -134590,7 +134980,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
|
|
|
134590
134980
|
}
|
|
134591
134981
|
}),
|
|
134592
134982
|
annotations: tool.annotations,
|
|
134593
|
-
handle: (payload) => Effect.withFiber((fiber) => {
|
|
134983
|
+
handle: withFusionRoleGate(tool.name, (payload) => Effect.withFiber((fiber) => {
|
|
134594
134984
|
const invocation = Context.getUnsafe(fiber.context, McpInvocationContext);
|
|
134595
134985
|
return built.handle("preview_snapshot", payload).pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker, broker), Effect.provideService(McpInvocationContext, invocation), Effect.matchCauseEffect({
|
|
134596
134986
|
onFailure: previewSnapshotFailure,
|
|
@@ -134619,7 +135009,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
|
|
|
134619
135009
|
}));
|
|
134620
135010
|
}
|
|
134621
135011
|
}));
|
|
134622
|
-
})
|
|
135012
|
+
}))
|
|
134623
135013
|
});
|
|
134624
135014
|
});
|
|
134625
135015
|
const PreviewStandardToolkitRegistrationLive = mcpToolkit(PreviewStandardToolkit).pipe(Layer.provide(PreviewStandardToolkitHandlersLive));
|
|
@@ -140132,6 +140522,7 @@ const make$3 = Effect.gen(function* () {
|
|
|
140132
140522
|
const { context, timing } = yield* correlateEvidence({
|
|
140133
140523
|
readerThreadId: pair.watcherThreadId,
|
|
140134
140524
|
readActivityId: evidenceReadActivityId(latest.watchedThreadId, latest.throughSequence),
|
|
140525
|
+
...completion.payload.turnId === null ? {} : { fallbackReadActivityId: rawEvidenceReadActivityId(latest.watchedThreadId, completion.payload.turnId) },
|
|
140135
140526
|
closedAt: completion.occurredAt
|
|
140136
140527
|
});
|
|
140137
140528
|
yield* orchestrationEngine.dispatch({
|
|
@@ -140294,6 +140685,14 @@ const make$3 = Effect.gen(function* () {
|
|
|
140294
140685
|
case "thread-pair.gate-advanced": return yield* processGateAdvanced(event);
|
|
140295
140686
|
case "thread-pair.gate-resolved": return yield* processGateResolved(event);
|
|
140296
140687
|
case "thread-pair.created":
|
|
140688
|
+
yield* setActiveMcpFusionRole({
|
|
140689
|
+
threadId: event.payload.watcherThreadId,
|
|
140690
|
+
role: "watcher"
|
|
140691
|
+
});
|
|
140692
|
+
yield* setActiveMcpFusionRole({
|
|
140693
|
+
threadId: event.payload.implementerThreadId,
|
|
140694
|
+
role: "implementer"
|
|
140695
|
+
});
|
|
140297
140696
|
yield* grantActiveMcpWatchThread({
|
|
140298
140697
|
watcherThreadId: event.payload.watcherThreadId,
|
|
140299
140698
|
watchedThreadId: event.payload.implementerThreadId
|
|
@@ -140315,6 +140714,8 @@ const make$3 = Effect.gen(function* () {
|
|
|
140315
140714
|
case "thread-pair.detached": {
|
|
140316
140715
|
const pair = ((yield* projectionSnapshotQuery.getCommandReadModel()).threadPairs ?? []).find((candidate) => candidate.id === event.payload.pairId);
|
|
140317
140716
|
if (pair === void 0) return;
|
|
140717
|
+
yield* clearActiveMcpFusionRole({ threadId: pair.watcherThreadId });
|
|
140718
|
+
yield* clearActiveMcpFusionRole({ threadId: pair.implementerThreadId });
|
|
140318
140719
|
yield* revokeActiveMcpWatchThread({
|
|
140319
140720
|
watcherThreadId: pair.watcherThreadId,
|
|
140320
140721
|
watchedThreadId: pair.implementerThreadId
|