@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 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.16";
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
- 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. ${FUSION_PAIR_COMMUNICATION_INSTRUCTIONS}`;
59453
- 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, and thread_gate_respond. 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:
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
- /** Everything the supervisor half needs from the P4 MCP server, and nothing else. */
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 or detaching pairs,
59646
- * advising, answering its own gate - the phase plan, which the supervisor
59647
- * owns outright, plus task-board mutation and the external services that
59648
- * belong to a person rather than to an implementation turn.
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
- /** Whether the lean profile applies at all. Ordinary threads never reach it. */
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
- * The external MCP registrations a Fusion session keeps: none.
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 role
59706
- * can act on it during a pair turn and every definition is paid for on every
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
- return fusionLeanContextApplies(input) ? {} : { ...servers };
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
- return fusionLeanContextApplies(input) && input.role === "implementer" ? FUSION_BUILDER_DENIED_NATIVE_TOOLS : [];
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 read = yield* projections.getEvidenceReadContext(input.readActivityId).pipe(Effect.orElseSucceed(() => Option.none()));
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: [McpInvocationContext, ThreadEventStreamService]
134345
- }).annotate(Tool.Title, "Read watched thread events").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true);
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, page, afterSequence) {
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(evidenceReadActivityId(page.threadId, page.throughSequence)).pipe(Effect.orElseSucceed(() => Option.none()));
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: evidenceReadActivityId(page.threadId, page.throughSequence),
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: page.threadId,
134774
+ watchedThreadId: window.watchedThreadId,
134428
134775
  afterSequence: readAfterSequence,
134429
- throughSequence: page.throughSequence,
134430
- nextAfterSequence: page.nextAfterSequence,
134431
- hasMore: page.hasMore,
134432
- scannedEvents: page.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: page.threadId,
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, page, input.afterSequence ?? 0);
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