@p4code/cli 0.5.15 → 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.15";
268
+ var version$1 = "0.5.17";
269
269
  //#endregion
270
270
  //#region src/config.ts
271
271
  /**
@@ -12960,8 +12960,10 @@ const ThreadEvidenceActivityOutcome = Schema$1.Literals([
12960
12960
  * Server clock around one review, never the supervisor's own account of it.
12961
12961
  *
12962
12962
  * `readAt` is when the evidence-read row was recorded and `closedAt` when the
12963
- * row that closes the review was: the report for a reported review, the turn
12964
- * boundary for an unreported one.
12963
+ * row that closes the review was: the report for a reported review, the
12964
+ * completed supervisor turn for an unreported one. A supervisor turn that
12965
+ * failed or was interrupted closes nothing, so its read keeps no timing until
12966
+ * some later turn reports on it.
12965
12967
  */
12966
12968
  const ThreadEvidenceReportTiming = Schema$1.Struct({
12967
12969
  readAt: IsoDateTime,
@@ -29729,13 +29731,17 @@ function parseMarkdownFrontmatter(contents) {
29729
29731
  const name = typeof record.name === "string" ? record.name.trim() : "";
29730
29732
  const description = typeof record.description === "string" ? record.description.trim() : "";
29731
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() : "";
29732
29736
  const requiredInteractionMode = record["required-interaction-mode"] === "default" || record["required-interaction-mode"] === "plan" ? record["required-interaction-mode"] : void 0;
29733
29737
  return {
29734
29738
  kind: "parsed",
29735
29739
  ...name ? { name } : {},
29736
29740
  ...description ? { description } : {},
29737
29741
  ...disableModelInvocation === void 0 ? {} : { disableModelInvocation },
29738
- ...requiredInteractionMode === void 0 ? {} : { requiredInteractionMode }
29742
+ ...requiredInteractionMode === void 0 ? {} : { requiredInteractionMode },
29743
+ ...tools && tools.length > 0 ? { tools } : {},
29744
+ ...model ? { model } : {}
29739
29745
  };
29740
29746
  }
29741
29747
  //#endregion
@@ -30039,6 +30045,42 @@ const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (config
30039
30045
  scope: "project"
30040
30046
  }] : []], disabledSkills);
30041
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
+ });
30042
30084
  //#endregion
30043
30085
  //#region src/mcp/ClaudeMcpFiles.ts
30044
30086
  /**
@@ -41618,7 +41660,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
41618
41660
  const EvidenceReadContextRow = Schema$1.Struct({
41619
41661
  usedTokensAtRead: Schema$1.NullOr(Schema$1.Number),
41620
41662
  readAt: Schema$1.String,
41621
- afterSequence: Schema$1.NullOr(Schema$1.Number)
41663
+ afterSequence: Schema$1.NullOr(Schema$1.Number),
41664
+ throughSequence: Schema$1.NullOr(Schema$1.Number)
41622
41665
  });
41623
41666
  const getEvidenceReadContextRow = SqlSchema.findOneOption({
41624
41667
  Request: Schema$1.String,
@@ -41631,16 +41674,87 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
41631
41674
  ELSE NULL
41632
41675
  END AS "usedTokensAtRead",
41633
41676
  created_at AS "readAt",
41634
- json_extract(payload_json, '$.afterSequence') AS "afterSequence"
41677
+ json_extract(payload_json, '$.afterSequence') AS "afterSequence",
41678
+ json_extract(payload_json, '$.throughSequence') AS "throughSequence"
41635
41679
  FROM projection_thread_activities
41636
41680
  WHERE activity_id = ${activityId}
41637
41681
  LIMIT 1
41682
+ `
41683
+ });
41684
+ const TurnStartRow = Schema$1.Struct({
41685
+ threadId: ThreadId,
41686
+ messageId: Schema$1.NullOr(Schema$1.String),
41687
+ startedAt: Schema$1.NullOr(Schema$1.String)
41688
+ });
41689
+ /**
41690
+ * The message a turn was started from, by the table's own key.
41691
+ *
41692
+ * Thread and turn together, because the unique key is the pair: a turn id is
41693
+ * provider-supplied for some providers, and matching on it alone would let
41694
+ * one provider's collision answer for another thread.
41695
+ */
41696
+ const getTurnStartRow = SqlSchema.findOneOption({
41697
+ Request: Schema$1.Struct({
41698
+ threadId: ThreadId,
41699
+ turnId: TurnId
41700
+ }),
41701
+ Result: TurnStartRow,
41702
+ execute: (input) => sql`
41703
+ SELECT
41704
+ thread_id AS "threadId",
41705
+ pending_message_id AS "messageId",
41706
+ started_at AS "startedAt"
41707
+ FROM projection_turns
41708
+ WHERE thread_id = ${input.threadId}
41709
+ AND turn_id = ${input.turnId}
41710
+ LIMIT 1
41711
+ `
41712
+ });
41713
+ /** The turn a thread's session is running, by the session projection's own row. */
41714
+ const getActiveTurnIdRow = SqlSchema.findOneOption({
41715
+ Request: ThreadId,
41716
+ Result: Schema$1.Struct({ activeTurnId: Schema$1.NullOr(Schema$1.String) }),
41717
+ execute: (threadId) => sql`
41718
+ SELECT active_turn_id AS "activeTurnId"
41719
+ FROM projection_thread_sessions
41720
+ WHERE thread_id = ${threadId}
41721
+ LIMIT 1
41722
+ `
41723
+ });
41724
+ const ThreadMessageStampRow = Schema$1.Struct({
41725
+ messageId: Schema$1.String,
41726
+ createdAt: Schema$1.String
41727
+ });
41728
+ /**
41729
+ * The newest message on a thread whose id starts with a prefix.
41730
+ *
41731
+ * The server names the messages it sends itself, so asking by id prefix asks
41732
+ * what the server last sent - a question no turn row can be talked out of by
41733
+ * whichever turn happened to adopt a pending start.
41734
+ */
41735
+ const getLatestThreadMessageWithIdPrefixRow = SqlSchema.findOneOption({
41736
+ Request: Schema$1.Struct({
41737
+ threadId: ThreadId,
41738
+ idPrefix: Schema$1.String
41739
+ }),
41740
+ Result: ThreadMessageStampRow,
41741
+ execute: (input) => sql`
41742
+ SELECT
41743
+ message_id AS "messageId",
41744
+ created_at AS "createdAt"
41745
+ FROM projection_thread_messages
41746
+ WHERE thread_id = ${input.threadId}
41747
+ AND message_id LIKE ${`${input.idPrefix}%`} ESCAPE '\\'
41748
+ ORDER BY created_at DESC, message_id DESC
41749
+ LIMIT 1
41638
41750
  `
41639
41751
  });
41640
41752
  const LatestEvidenceReadRow = Schema$1.Struct({
41641
41753
  watchedThreadId: ThreadId,
41642
41754
  afterSequence: Schema$1.NullOr(Schema$1.Number),
41643
41755
  throughSequence: Schema$1.Number,
41756
+ /** The supervisor turn that recorded the read, absent on rows written before it was stamped. */
41757
+ turnId: Schema$1.NullOr(Schema$1.String),
41644
41758
  readAt: Schema$1.String,
41645
41759
  hasReport: Schema$1.Number
41646
41760
  });
@@ -41659,6 +41773,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
41659
41773
  json_extract(reads.payload_json, '$.watchedThreadId') AS "watchedThreadId",
41660
41774
  json_extract(reads.payload_json, '$.afterSequence') AS "afterSequence",
41661
41775
  json_extract(reads.payload_json, '$.throughSequence') AS "throughSequence",
41776
+ json_extract(reads.payload_json, '$.turnId') AS "turnId",
41662
41777
  reads.created_at AS "readAt",
41663
41778
  EXISTS (
41664
41779
  SELECT 1
@@ -41674,6 +41789,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
41674
41789
  WHERE reads.thread_id = ${threadId}
41675
41790
  AND reads.kind = 'fusion.evidence.read'
41676
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
41677
41796
  ORDER BY reads.created_at DESC, reads.rowid DESC
41678
41797
  LIMIT 1
41679
41798
  `
@@ -43467,10 +43586,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
43467
43586
  });
43468
43587
  const getLatestThreadContextWindow = (threadId) => getLatestContextWindowRow(threadId).pipe(Effect.mapError(toPersistenceSqlOrDecodeError$1("ProjectionSnapshotQuery.getLatestThreadContextWindow:query", "ProjectionSnapshotQuery.getLatestThreadContextWindow:decodeRow")));
43469
43588
  const getEvidenceReadContext = (activityId) => getEvidenceReadContextRow(activityId).pipe(Effect.mapError(toPersistenceSqlOrDecodeError$1("ProjectionSnapshotQuery.getEvidenceReadContext:query", "ProjectionSnapshotQuery.getEvidenceReadContext:decodeRow")));
43589
+ const getTurnStart = (threadId, turnId) => getTurnStartRow({
43590
+ threadId,
43591
+ turnId
43592
+ }).pipe(Effect.mapError(toPersistenceSqlOrDecodeError$1("ProjectionSnapshotQuery.getTurnStart:query", "ProjectionSnapshotQuery.getTurnStart:decodeRow")));
43593
+ const getActiveTurnId = (threadId) => getActiveTurnIdRow(threadId).pipe(Effect.map(Option.flatMap((row) => Option.fromNullable(row.activeTurnId))), Effect.mapError(toPersistenceSqlOrDecodeError$1("ProjectionSnapshotQuery.getActiveTurnId:query", "ProjectionSnapshotQuery.getActiveTurnId:decodeRow")));
43594
+ const getLatestThreadMessageWithIdPrefix = (threadId, idPrefix) => getLatestThreadMessageWithIdPrefixRow({
43595
+ threadId,
43596
+ idPrefix
43597
+ }).pipe(Effect.mapError(toPersistenceSqlOrDecodeError$1("ProjectionSnapshotQuery.getLatestThreadMessageWithIdPrefix:query", "ProjectionSnapshotQuery.getLatestThreadMessageWithIdPrefix:decodeRow")));
43470
43598
  const getLatestEvidenceRead = (threadId) => getLatestEvidenceReadRow(threadId).pipe(Effect.map(Option.map((row) => ({
43471
43599
  watchedThreadId: row.watchedThreadId,
43472
43600
  afterSequence: row.afterSequence,
43473
43601
  throughSequence: row.throughSequence,
43602
+ turnId: row.turnId,
43474
43603
  readAt: row.readAt,
43475
43604
  hasReport: row.hasReport !== 0
43476
43605
  }))), Effect.mapError(toPersistenceSqlOrDecodeError$1("ProjectionSnapshotQuery.getLatestEvidenceRead:query", "ProjectionSnapshotQuery.getLatestEvidenceRead:decodeRow")));
@@ -43698,6 +43827,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
43698
43827
  getThreadPairById,
43699
43828
  getActiveThreadPairs,
43700
43829
  getLatestEvidenceRead,
43830
+ getTurnStart,
43831
+ getLatestThreadMessageWithIdPrefix,
43832
+ getActiveTurnId,
43701
43833
  getThreadShellById,
43702
43834
  getThreadDetailById,
43703
43835
  getHandoffContext,
@@ -59363,8 +59495,17 @@ const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(functio
59363
59495
  * written as if the setting applied only to user-facing answers.
59364
59496
  */
59365
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.`;
59366
- 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}`;
59367
- 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:
59368
59509
  ## Recommendation
59369
59510
  State the decision or correction in one sentence. For a phase review, say whether the phase is approved or needs changes.
59370
59511
  ## Reason
@@ -59379,7 +59520,7 @@ Give ordered, concrete actions for the builder, including the next phase and its
59379
59520
  * which is what it did before this existed.
59380
59521
  */
59381
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.`;
59382
- 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}`;
59383
59524
  /**
59384
59525
  * The one-line stand-in for the full block on a message whose session already
59385
59526
  * carries the role instructions.
@@ -59539,7 +59680,18 @@ const FUSION_SAFETY_KERNEL = [
59539
59680
  "- Never commit, push, or open a pull request unless the current turn asked for it, and never commit to main or master directly.",
59540
59681
  "- Never add AI attribution to a commit message, pull request, issue, comment, code, or document."
59541
59682
  ].join("\n");
59542
- /** 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
+ */
59543
59695
  const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
59544
59696
  "thread_watch_review",
59545
59697
  "thread_watch_events",
@@ -59548,7 +59700,12 @@ const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
59548
59700
  "thread_gate_respond",
59549
59701
  "thread_plan_update",
59550
59702
  "ask_user_question",
59551
- "task_current"
59703
+ "task_current",
59704
+ "task_get",
59705
+ "task_list",
59706
+ "task_create",
59707
+ "task_update",
59708
+ "ticket_resolve"
59552
59709
  ]);
59553
59710
  /**
59554
59711
  * What the builder half must not reach.
@@ -59556,15 +59713,27 @@ const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
59556
59713
  * Framed as a denial rather than an allowance because the builder's job is
59557
59714
  * open-ended: it needs coding, verification and preview tools, and a new one of
59558
59715
  * those should reach it without an edit here. What it must never have is the
59559
- * pair's own machinery - spawning threads, creating or detaching pairs,
59560
- * advising, answering its own gate - the phase plan, which the supervisor
59561
- * owns outright, plus task-board mutation and the external services that
59562
- * 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.
59563
59730
  */
59564
59731
  const FUSION_BUILDER_DENIED_P4_TOOLS = /* @__PURE__ */ new Set([
59565
59732
  "thread_spawn",
59733
+ "thread_watch_review",
59734
+ "thread_watch_events",
59735
+ "thread_evidence_report",
59566
59736
  "thread_pair_create",
59567
- "thread_pair_detach",
59568
59737
  "thread_cleanup",
59569
59738
  "thread_rename",
59570
59739
  "thread_configure",
@@ -59609,21 +59778,90 @@ function fusionContextPolicyFor(role, optimizedContext) {
59609
59778
  optimizedContext
59610
59779
  };
59611
59780
  }
59612
- /** 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
+ */
59613
59806
  function fusionLeanContextApplies(input) {
59614
59807
  return input.role !== void 0 && input.optimizedContext;
59615
59808
  }
59616
59809
  /**
59617
- * 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.
59618
59854
  *
59619
- * Everything a user registered for their own work goes, because neither role
59620
- * can act on it during a pair turn and every definition is paid for on every
59621
- * 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
59622
59858
  * provider-owned endpoint attached only to a session that was granted it, not
59623
59859
  * a registration in this map, so a granted session keeps it either way.
59624
59860
  */
59625
59861
  function fusionExternalMcpServers(servers, input) {
59626
- 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))));
59627
59865
  }
59628
59866
  /**
59629
59867
  * P4 tools this role must not see, given the full catalogue.
@@ -59637,20 +59875,70 @@ function fusionHiddenP4Tools(catalogue, input) {
59637
59875
  return input.role === "watcher" ? catalogue.filter((name) => !FUSION_SUPERVISOR_P4_TOOLS.has(name)) : catalogue.filter((name) => FUSION_BUILDER_DENIED_P4_TOOLS.has(name));
59638
59876
  }
59639
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
+ /**
59640
59896
  * Provider-native tools this role must not see.
59641
59897
  *
59642
59898
  * The supervisor keeps its own planning tool: it is the half that plans, and
59643
- * 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.
59644
59905
  */
59645
59906
  function fusionHiddenNativeTools(input) {
59646
- 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 : [];
59647
59933
  }
59648
59934
  /**
59649
59935
  * Setting sources a Fusion session reads.
59650
59936
  *
59651
59937
  * `user` carries the global memory files and the user-level skills; the
59652
59938
  * project's own instructions live in `project` and `local` and stay, because
59653
- * 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.
59654
59942
  */
59655
59943
  function fusionSettingSources(sources, input) {
59656
59944
  if (!fusionLeanContextApplies(input)) return sources;
@@ -69841,6 +70129,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
69841
70129
  optimizedContext: fusionPromptSettings?.optimizedContext ?? DEFAULT_FUSION_PROMPT_SETTINGS.optimizedContext
69842
70130
  };
69843
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));
69844
70133
  const hiddenTools = [...fusionHiddenP4Tools(providerMcpToolNames(), fusionContextPolicy).map((name) => `mcp__${P4CODE_MCP_SERVER_NAME}__${name}`), ...fusionHiddenNativeTools(fusionContextPolicy)];
69845
70134
  const unpromptedSubagents = input.unpromptedSubagents !== void 0 ? input.unpromptedSubagents : options?.resolveUnpromptedSubagents === void 0 ? DEFAULT_SERVER_SETTINGS.enableUnpromptedSubagents : yield* options.resolveUnpromptedSubagents;
69846
70135
  const compressRuleset = compressRulesetFor(input.compressMode ?? "off");
@@ -69872,6 +70161,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
69872
70161
  ...systemPromptAppend.length > 0 ? { append: systemPromptAppend } : {}
69873
70162
  },
69874
70163
  settingSources: [...fusionSettingSources(CLAUDE_SETTING_SOURCES, fusionContextPolicy)],
70164
+ ...Object.keys(requiredGlobalAgents).length > 0 ? { agents: requiredGlobalAgents } : {},
69875
70165
  ...hiddenTools.length > 0 ? { disallowedTools: hiddenTools } : {},
69876
70166
  ...effectiveEffort ? { effort: effectiveEffort } : {},
69877
70167
  ...permissionMode ? { permissionMode } : {},
@@ -130574,14 +130864,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130574
130864
  const httpServer = yield* HttpServer.HttpServer;
130575
130865
  const state = yield* SynchronizedRef.make({
130576
130866
  records: /* @__PURE__ */ new Map(),
130577
- spawnedThreads: /* @__PURE__ */ new Map()
130867
+ spawnedThreads: /* @__PURE__ */ new Map(),
130868
+ fusionRoles: /* @__PURE__ */ new Map()
130578
130869
  });
130579
130870
  const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis;
130580
130871
  const livenessWindowMs = options.livenessWindowMs ?? DEFAULT_LIVENESS_WINDOW_MS;
130581
130872
  const endpoint = httpServer.address._tag === "TcpAddress" ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` : "http://127.0.0.1/mcp";
130582
130873
  const hashToken = (token) => crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe(Effect.map(bytesToHex), Effect.orDie);
130583
130874
  const revokeWhere = Effect.fn("McpSessionRegistry.revokeWhere")(function* (predicate) {
130584
- const removed = yield* SynchronizedRef.modify(state, ({ records, spawnedThreads }) => {
130875
+ const removed = yield* SynchronizedRef.modify(state, ({ records, spawnedThreads, fusionRoles }) => {
130585
130876
  const next = new Map(records);
130586
130877
  const scopes = [];
130587
130878
  for (const [hash, record] of records) {
@@ -130592,7 +130883,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130592
130883
  }
130593
130884
  return [scopes, {
130594
130885
  records: next,
130595
- spawnedThreads
130886
+ spawnedThreads,
130887
+ fusionRoles
130596
130888
  }];
130597
130889
  });
130598
130890
  yield* Effect.forEach(removed, (scope) => scope.authorizationLifetime?.revocation ? Deferred.succeed(scope.authorizationLifetime.revocation, void 0) : Effect.void, { discard: true });
@@ -130620,6 +130912,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130620
130912
  if (watchThreadIds.size > 0) capabilities.add("watch");
130621
130913
  if (adviseThreadIds.size > 0) capabilities.add("advise");
130622
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;
130623
130917
  const scopeWith = (mayCreateThreads, mayCreateFusionPairs, revocation = mainRevocation) => ({
130624
130918
  authorizationLifetime: {
130625
130919
  revoked: false,
@@ -130633,11 +130927,12 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130633
130927
  capabilities,
130634
130928
  ...watchThreadIds.size > 0 ? { watchThreadIds } : {},
130635
130929
  ...adviseThreadIds.size > 0 ? { adviseThreadIds } : {},
130930
+ ...fusionRole === void 0 ? {} : { fusionRole },
130636
130931
  mayCreateThreads,
130637
130932
  mayCreateFusionPairs,
130638
130933
  issuedAt
130639
130934
  });
130640
- yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
130935
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
130641
130936
  const next = new Map(records);
130642
130937
  next.set(tokenHash, {
130643
130938
  tokenHash,
@@ -130657,7 +130952,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130657
130952
  });
130658
130953
  return {
130659
130954
  records: next,
130660
- spawnedThreads
130955
+ spawnedThreads,
130956
+ fusionRoles
130661
130957
  };
130662
130958
  });
130663
130959
  return { config: {
@@ -130679,12 +130975,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130679
130975
  const tokenHash = yield* hashToken(rawToken);
130680
130976
  const timestamp = yield* currentTimeMillis;
130681
130977
  yield* pruneDead(timestamp);
130682
- return yield* SynchronizedRef.modify(state, ({ records, spawnedThreads }) => {
130978
+ return yield* SynchronizedRef.modify(state, ({ records, spawnedThreads, fusionRoles }) => {
130683
130979
  const current = records;
130684
130980
  const record = current.get(tokenHash);
130685
130981
  if (!record) return [void 0, {
130686
130982
  records: current,
130687
- spawnedThreads
130983
+ spawnedThreads,
130984
+ fusionRoles
130688
130985
  }];
130689
130986
  const next = new Map(current);
130690
130987
  next.set(tokenHash, {
@@ -130698,14 +130995,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130698
130995
  watchThreadIds: /* @__PURE__ */ new Set([...record.scope.watchThreadIds ?? [], ...record.controlThreadIds])
130699
130996
  }, {
130700
130997
  records: next,
130701
- spawnedThreads
130998
+ spawnedThreads,
130999
+ fusionRoles
130702
131000
  }];
130703
131001
  });
130704
131002
  });
130705
131003
  const touch = Effect.fn("McpSessionRegistry.touch")(function* (threadId) {
130706
131004
  const timestamp = yield* currentTimeMillis;
130707
131005
  yield* pruneDead(timestamp);
130708
- yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
131006
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
130709
131007
  const current = records;
130710
131008
  const next = new Map(current);
130711
131009
  for (const [tokenHash, record] of current) if (record.scope.threadId === threadId) next.set(tokenHash, {
@@ -130714,12 +131012,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130714
131012
  });
130715
131013
  return {
130716
131014
  records: next,
130717
- spawnedThreads
131015
+ spawnedThreads,
131016
+ fusionRoles
130718
131017
  };
130719
131018
  });
130720
131019
  });
130721
131020
  const grantWatchThread = Effect.fn("McpSessionRegistry.grantWatchThread")(function* ({ watcherThreadId, watchedThreadId }) {
130722
- yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
131021
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
130723
131022
  const next = new Map(records);
130724
131023
  for (const [tokenHash, record] of records) {
130725
131024
  if (record.scope.threadId !== watcherThreadId) continue;
@@ -130734,12 +131033,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130734
131033
  }
130735
131034
  return {
130736
131035
  records: next,
130737
- spawnedThreads
131036
+ spawnedThreads,
131037
+ fusionRoles
130738
131038
  };
130739
131039
  });
130740
131040
  });
130741
131041
  const revokeWatchThread = Effect.fn("McpSessionRegistry.revokeWatchThread")(function* ({ watcherThreadId, watchedThreadId }) {
130742
- yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
131042
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
130743
131043
  const next = new Map(records);
130744
131044
  for (const [tokenHash, record] of records) {
130745
131045
  if (record.scope.threadId !== watcherThreadId) continue;
@@ -130762,12 +131062,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130762
131062
  }
130763
131063
  return {
130764
131064
  records: next,
130765
- spawnedThreads
131065
+ spawnedThreads,
131066
+ fusionRoles
130766
131067
  };
130767
131068
  });
130768
131069
  });
130769
131070
  const grantAdviseThread = Effect.fn("McpSessionRegistry.grantAdviseThread")(function* ({ watcherThreadId, advisedThreadId }) {
130770
- yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
131071
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
130771
131072
  const next = new Map(records);
130772
131073
  for (const [tokenHash, record] of records) {
130773
131074
  if (record.scope.threadId !== watcherThreadId) continue;
@@ -130782,12 +131083,13 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130782
131083
  }
130783
131084
  return {
130784
131085
  records: next,
130785
- spawnedThreads
131086
+ spawnedThreads,
131087
+ fusionRoles
130786
131088
  };
130787
131089
  });
130788
131090
  });
130789
131091
  const revokeAdviseThread = Effect.fn("McpSessionRegistry.revokeAdviseThread")(function* ({ watcherThreadId, advisedThreadId }) {
130790
- yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
131092
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
130791
131093
  const next = new Map(records);
130792
131094
  for (const [tokenHash, record] of records) {
130793
131095
  if (record.scope.threadId !== watcherThreadId) continue;
@@ -130810,12 +131112,47 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130810
131112
  }
130811
131113
  return {
130812
131114
  records: next,
130813
- spawnedThreads
131115
+ spawnedThreads,
131116
+ fusionRoles
130814
131117
  };
130815
131118
  });
130816
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
+ });
130817
131154
  const recordSpawnedThread = Effect.fn("McpSessionRegistry.recordSpawnedThread")(function* (input) {
130818
- yield* SynchronizedRef.update(state, ({ records, spawnedThreads }) => {
131155
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreads, fusionRoles }) => {
130819
131156
  const next = new Map(records);
130820
131157
  for (const [tokenHash, record] of records) if (record.scope.providerSessionId === input.providerSessionId) next.set(tokenHash, {
130821
131158
  ...record,
@@ -130823,7 +131160,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130823
131160
  });
130824
131161
  return {
130825
131162
  records: next,
130826
- spawnedThreads: new Map([...spawnedThreads, [input.threadId, input.mayCreateFusionPairs === true]])
131163
+ spawnedThreads: new Map([...spawnedThreads, [input.threadId, input.mayCreateFusionPairs === true]]),
131164
+ fusionRoles
130827
131165
  };
130828
131166
  });
130829
131167
  });
@@ -130831,6 +131169,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
130831
131169
  issue,
130832
131170
  resolve,
130833
131171
  touch,
131172
+ setFusionRole,
131173
+ clearFusionRole,
130834
131174
  grantWatchThread,
130835
131175
  revokeWatchThread,
130836
131176
  grantAdviseThread,
@@ -130862,6 +131202,8 @@ const issueActiveMcpCredential = (request) => activeMcpSessionRegistry ? activeM
130862
131202
  */
130863
131203
  const touchActiveMcpThread = (threadId) => activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void;
130864
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;
130865
131207
  const revokeActiveMcpWatchThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeWatchThread(input) : Effect.void;
130866
131208
  const grantActiveMcpAdviseThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.grantAdviseThread(input) : Effect.void;
130867
131209
  const revokeActiveMcpAdviseThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAdviseThread(input) : Effect.void;
@@ -130983,6 +131325,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (options)
130983
131325
  return Option.isSome(thread) ? resolveProjectAgentBrowserAccess(settings, thread.value.projectId) : false;
130984
131326
  })), Effect.catch((cause) => Effect.logWarning("Could not read settings; withholding agent browser access.", { cause }).pipe(Effect.as(false))));
130985
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));
130986
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;
130987
131338
  const credential = yield* issueActiveMcpCredential({
130988
131339
  threadId,
@@ -130991,7 +131342,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (options)
130991
131342
  browserAccess,
130992
131343
  computerUseAccess,
130993
131344
  ...watchThreadIds !== void 0 ? { watchThreadIds } : {},
130994
- ...adviseThreadIds !== void 0 ? { adviseThreadIds } : {}
131345
+ ...adviseThreadIds !== void 0 ? { adviseThreadIds } : {},
131346
+ ...fusionRole !== void 0 ? { fusionRole } : {}
130995
131347
  });
130996
131348
  if (credential) yield* Effect.sync(() => setMcpProviderSession(credential.config));
130997
131349
  return credential;
@@ -131837,6 +132189,47 @@ const mcpInputSchema = (schema) => ({
131837
132189
  ...schema,
131838
132190
  type: "object"
131839
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))));
131840
132233
  const mcpToolkit = (toolkit) => Layer.effectDiscard(Effect.gen(function* () {
131841
132234
  const server = yield* McpServer.McpServer;
131842
132235
  yield* McpServer.registerToolkit(toolkit).pipe(Effect.provideService(McpServer.McpServer, {
@@ -131846,7 +132239,8 @@ const mcpToolkit = (toolkit) => Layer.effectDiscard(Effect.gen(function* () {
131846
132239
  tool: new McpSchema.Tool({
131847
132240
  ...options.tool,
131848
132241
  inputSchema: mcpInputSchema(options.tool.inputSchema)
131849
- })
132242
+ }),
132243
+ handle: withFusionRoleGate(options.tool.name, options.handle)
131850
132244
  })
131851
132245
  }));
131852
132246
  }));
@@ -132135,13 +132529,13 @@ const registerScreenshot = Effect.fn("device.registerScreenshot")(function* () {
132135
132529
  }
132136
132530
  }),
132137
132531
  annotations: DeviceScreenshotTool.annotations,
132138
- 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({
132139
132533
  isError: true,
132140
132534
  content: [{
132141
132535
  type: "text",
132142
132536
  text: "Device screenshot unavailable. Check the open session and refresh devices."
132143
132537
  }]
132144
- }))))
132538
+ })))))
132145
132539
  });
132146
132540
  });
132147
132541
  const DeviceToolkitRegistrationLive = Layer.mergeAll(mcpToolkit(DeviceStandardToolkit), Layer.effectDiscard(registerScreenshot())).pipe(Layer.provide(DeviceToolkitHandlersLive));
@@ -132903,6 +133297,7 @@ const ThreadPairCreateTool = Tool.make("thread_pair_create", {
132903
133297
  dependencies: [
132904
133298
  McpInvocationContext,
132905
133299
  OrchestrationEngineService,
133300
+ McpSessionRegistry,
132906
133301
  Crypto.Crypto
132907
133302
  ]
132908
133303
  }).annotate(Tool.Title, "Create a Fusion pair").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, false);
@@ -133297,6 +133692,14 @@ const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
133297
133692
  watcherThreadId: fusionPair.watcherThreadId,
133298
133693
  createdAt
133299
133694
  }, threadId);
133695
+ yield* registry.setFusionRole({
133696
+ threadId,
133697
+ role: "implementer"
133698
+ });
133699
+ yield* registry.setFusionRole({
133700
+ threadId: fusionPair.watcherThreadId,
133701
+ role: "watcher"
133702
+ });
133300
133703
  }
133301
133704
  yield* dispatchControl({
133302
133705
  type: "thread.turn.start",
@@ -133337,6 +133740,15 @@ const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
133337
133740
  continuationMessageId,
133338
133741
  createdAt: DateTime.formatIso(yield* DateTime.now)
133339
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
+ });
133340
133752
  return {
133341
133753
  pairId,
133342
133754
  implementerThreadId: invocation.threadId,
@@ -134182,6 +134594,16 @@ const TypeSafeToolkitHandlersLive = TypeSafeToolkit.toLayer({ typesafe_ask: (inp
134182
134594
  * appending beside it.
134183
134595
  */
134184
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}`);
134185
134607
  /** One report per review, keyed like the read it reports on so a retry replaces. */
134186
134608
  const evidenceReportActivityId = (watchedThreadId, throughSequence) => EventId.make(`fusion-evidence-summary:${watchedThreadId}:${throughSequence}`);
134187
134609
  /**
@@ -134214,7 +134636,8 @@ const evidenceTiming = (readAt, closedAt) => {
134214
134636
  const correlateEvidence = Effect.fn("fusionEvidenceReport.correlateEvidence")(function* (input) {
134215
134637
  const projections = yield* ProjectionSnapshotQuery;
134216
134638
  const latest = yield* projections.getLatestThreadContextWindow(input.readerThreadId).pipe(Effect.orElseSucceed(() => Option.none()));
134217
- 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)));
134218
134641
  const timing = Option.match(read, {
134219
134642
  onNone: () => null,
134220
134643
  onSome: (row) => evidenceTiming(row.readAt, input.closedAt)
@@ -134255,8 +134678,14 @@ const ThreadWatchEventsTool = Tool.make("thread_watch_events", {
134255
134678
  parameters: ThreadWatchEventsInput,
134256
134679
  success: ThreadWatchEventsResult,
134257
134680
  failure: ThreadWatchToolError,
134258
- dependencies: [McpInvocationContext, ThreadEventStreamService]
134259
- }).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);
134260
134689
  const ThreadWatchReviewTool = Tool.make("thread_watch_review", {
134261
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.",
134262
134691
  parameters: ThreadWatchReviewInput,
@@ -134308,21 +134737,26 @@ const REPORT_HEADING = {
134308
134737
  * for; losing the note that it happened is a smaller loss than losing the page,
134309
134738
  * so a rejected dispatch is logged and the page is returned anyway.
134310
134739
  */
134311
- const recordEvidenceRead = Effect.fn("mcp.watch.recordEvidenceRead")(function* (readerThreadId, page, afterSequence) {
134740
+ const recordEvidenceRead = Effect.fn("mcp.watch.recordEvidenceRead")(function* (readerThreadId, window) {
134312
134741
  const engine = yield* OrchestrationEngineService;
134313
134742
  const crypto = yield* Crypto.Crypto;
134314
134743
  const projections = yield* ProjectionSnapshotQuery;
134315
134744
  const createdAt = DateTime.formatIso(yield* DateTime.now);
134316
- 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()));
134317
134746
  const opened = Option.isSome(recorded);
134318
134747
  const readCreatedAt = Option.match(recorded, {
134319
134748
  onNone: () => createdAt,
134320
134749
  onSome: (row) => row.readAt
134321
134750
  });
134322
134751
  const readAfterSequence = Option.match(recorded, {
134323
- onNone: () => afterSequence,
134324
- onSome: (row) => row.afterSequence ?? afterSequence
134752
+ onNone: () => window.afterSequence,
134753
+ onSome: (row) => row.afterSequence ?? window.afterSequence
134325
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)
134758
+ });
134759
+ const activeTurnId = yield* projections.getActiveTurnId(readerThreadId).pipe(Effect.orElseSucceed(() => Option.none()), Effect.map(Option.getOrNull));
134326
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));
134327
134761
  yield* engine.dispatch({
134328
134762
  type: "thread.activity.append",
@@ -134330,28 +134764,40 @@ const recordEvidenceRead = Effect.fn("mcp.watch.recordEvidenceRead")(function* (
134330
134764
  threadId: readerThreadId,
134331
134765
  createdAt,
134332
134766
  activity: {
134333
- id: evidenceReadActivityId(page.threadId, page.throughSequence),
134767
+ id: window.activityId,
134334
134768
  createdAt: readCreatedAt,
134335
134769
  tone: "info",
134336
134770
  kind: "fusion.evidence.read",
134337
134771
  summary: "Read builder evidence",
134338
134772
  turnId: null,
134339
134773
  payload: {
134340
- watchedThreadId: page.threadId,
134774
+ watchedThreadId: window.watchedThreadId,
134341
134775
  afterSequence: readAfterSequence,
134342
- throughSequence: page.throughSequence,
134343
- nextAfterSequence: page.nextAfterSequence,
134344
- hasMore: page.hasMore,
134345
- scannedEvents: page.scannedEvents,
134776
+ throughSequence: readThroughSequence,
134777
+ nextAfterSequence: window.nextAfterSequence,
134778
+ hasMore: window.hasMore,
134779
+ scannedEvents: window.scannedEvents,
134780
+ ...activeTurnId === null ? {} : { turnId: activeTurnId },
134781
+ ...window.raw === true ? { raw: true } : {},
134346
134782
  ...usedTokensAtRead === null ? {} : { usedTokensAtRead }
134347
134783
  }
134348
134784
  }
134349
134785
  }).pipe(Effect.catchCause((cause) => Effect.logWarning("could not record the supervisor's evidence read", {
134350
134786
  readerThreadId,
134351
- watchedThreadId: page.threadId,
134787
+ watchedThreadId: window.watchedThreadId,
134352
134788
  cause
134353
134789
  })));
134354
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
+ });
134355
134801
  /** Bounded so one report cannot dominate a thread's activity storage. */
134356
134802
  const clip = (value, limit) => {
134357
134803
  const trimmed = value?.trim();
@@ -134365,7 +134811,15 @@ const WatchToolkitHandlersLive = WatchToolkit.toLayer({
134365
134811
  threadId: input.threadId,
134366
134812
  detail: cause.message
134367
134813
  })));
134368
- 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
+ });
134369
134823
  return page;
134370
134824
  }),
134371
134825
  thread_evidence_report: (input) => Effect.gen(function* () {
@@ -134373,9 +134827,11 @@ const WatchToolkitHandlersLive = WatchToolkit.toLayer({
134373
134827
  const engine = yield* OrchestrationEngineService;
134374
134828
  const crypto = yield* Crypto.Crypto;
134375
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));
134376
134831
  const { context, timing } = yield* correlateEvidence({
134377
134832
  readerThreadId: invocation.threadId,
134378
134833
  readActivityId: evidenceReadActivityId(input.threadId, input.throughSequence),
134834
+ ...activeTurnId === null ? {} : { fallbackReadActivityId: rawEvidenceReadActivityId(input.threadId, activeTurnId) },
134379
134835
  closedAt: createdAt
134380
134836
  });
134381
134837
  const summary = clip(input.summary, THREAD_EVIDENCE_REPORT_MAX_CHARS);
@@ -134416,7 +134872,7 @@ const WatchToolkitHandlersLive = WatchToolkit.toLayer({
134416
134872
  };
134417
134873
  }),
134418
134874
  thread_watch_events: (input) => Effect.gen(function* () {
134419
- yield* requireWatchCapability(input.threadId);
134875
+ const invocation = yield* requireWatchCapability(input.threadId);
134420
134876
  const page = yield* (yield* ThreadEventStreamService).read({
134421
134877
  threadId: input.threadId,
134422
134878
  ...input.afterSequence !== void 0 ? { afterSequence: input.afterSequence } : {},
@@ -134425,6 +134881,28 @@ const WatchToolkitHandlersLive = WatchToolkit.toLayer({
134425
134881
  threadId: input.threadId,
134426
134882
  detail: cause.message
134427
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
+ }
134428
134906
  return {
134429
134907
  threadId: input.threadId,
134430
134908
  events: page.events,
@@ -134502,7 +134980,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
134502
134980
  }
134503
134981
  }),
134504
134982
  annotations: tool.annotations,
134505
- handle: (payload) => Effect.withFiber((fiber) => {
134983
+ handle: withFusionRoleGate(tool.name, (payload) => Effect.withFiber((fiber) => {
134506
134984
  const invocation = Context.getUnsafe(fiber.context, McpInvocationContext);
134507
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({
134508
134986
  onFailure: previewSnapshotFailure,
@@ -134531,7 +135009,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
134531
135009
  }));
134532
135010
  }
134533
135011
  }));
134534
- })
135012
+ }))
134535
135013
  });
134536
135014
  });
134537
135015
  const PreviewStandardToolkitRegistrationLive = mcpToolkit(PreviewStandardToolkit).pipe(Layer.provide(PreviewStandardToolkitHandlersLive));
@@ -140039,24 +140517,12 @@ const make$3 = Effect.gen(function* () {
140039
140517
  yield* processReview(pair, completion, turn);
140040
140518
  }
140041
140519
  });
140042
- /**
140043
- * Closes a supervisor turn that read the builder's evidence and ended without
140044
- * reporting on it.
140045
- *
140046
- * The row it writes is keyed exactly like the supervisor's own report, so a
140047
- * later report replaces it rather than doubling it, and it carries the same
140048
- * server-measured context and timing. Without it, a review that was never
140049
- * reported is indistinguishable in stored history from one that was never
140050
- * attempted.
140051
- */
140052
- const recordUnreportedEvidence = Effect.fn("FusionWatcherReactor.recordUnreportedEvidence")(function* (pair, completion) {
140053
- const read = yield* projectionSnapshotQuery.getLatestEvidenceRead(pair.watcherThreadId).pipe(Effect.orElseSucceed(() => Option.none()));
140054
- if (Option.isNone(read)) return;
140055
- const latest = read.value;
140056
- if (latest.hasReport || latest.watchedThreadId !== pair.implementerThreadId) return;
140520
+ /** Writes the row, once the caller has established the turn owed one. */
140521
+ const stampUnreported = Effect.fn("FusionWatcherReactor.stampUnreported")(function* (pair, completion, latest) {
140057
140522
  const { context, timing } = yield* correlateEvidence({
140058
140523
  readerThreadId: pair.watcherThreadId,
140059
140524
  readActivityId: evidenceReadActivityId(latest.watchedThreadId, latest.throughSequence),
140525
+ ...completion.payload.turnId === null ? {} : { fallbackReadActivityId: rawEvidenceReadActivityId(latest.watchedThreadId, completion.payload.turnId) },
140060
140526
  closedAt: completion.occurredAt
140061
140527
  });
140062
140528
  yield* orchestrationEngine.dispatch({
@@ -140086,6 +140552,39 @@ const make$3 = Effect.gen(function* () {
140086
140552
  }
140087
140553
  });
140088
140554
  });
140555
+ /**
140556
+ * Closes a supervisor turn that read the builder's evidence and ended without
140557
+ * reporting on it.
140558
+ *
140559
+ * The row it writes is keyed exactly like the supervisor's own report, so a
140560
+ * later report replaces it rather than doubling it, and it carries the same
140561
+ * server-measured context and timing. Without it, a review that was never
140562
+ * reported is indistinguishable in stored history from one that was never
140563
+ * attempted.
140564
+ */
140565
+ const recordUnreportedEvidence = Effect.fn("FusionWatcherReactor.recordUnreportedEvidence")(function* (pair, completion, input) {
140566
+ const read = yield* projectionSnapshotQuery.getLatestEvidenceRead(pair.watcherThreadId).pipe(Effect.orElseSucceed(() => Option.none()));
140567
+ if (Option.isNone(read)) return;
140568
+ const latest = read.value;
140569
+ if (latest.hasReport || latest.watchedThreadId !== pair.implementerThreadId) return;
140570
+ if (completion.payload.turnId === null) return;
140571
+ const turn = yield* projectionSnapshotQuery.getTurnStart(pair.watcherThreadId, completion.payload.turnId).pipe(Effect.orElseSucceed(() => Option.none()));
140572
+ if (Option.isNone(turn)) return;
140573
+ const started = turn.value;
140574
+ if (started.threadId !== pair.watcherThreadId) return;
140575
+ const readAt = Date.parse(latest.readAt);
140576
+ if (latest.turnId === null) {
140577
+ if (input.replayed) return;
140578
+ if (started.startedAt !== null && readAt < Date.parse(started.startedAt)) return;
140579
+ if (readAt > Date.parse(completion.occurredAt)) return;
140580
+ } else if (latest.turnId !== completion.payload.turnId) return;
140581
+ const wakeAt = (prefix) => projectionSnapshotQuery.getLatestThreadMessageWithIdPrefix(pair.watcherThreadId, prefix).pipe(Effect.map(Option.flatMap((wake) => {
140582
+ const sentAt = Date.parse(wake.createdAt);
140583
+ return Number.isNaN(sentAt) || sentAt > readAt ? Option.none() : Option.some(sentAt);
140584
+ })), Effect.orElseSucceed(() => Option.none()));
140585
+ if (!(started.messageId === null ? yield* Effect.map(Effect.all([wakeAt("fusion-review:"), wakeAt("fusion-gate:")]), ([review, gate]) => Option.isSome(review) && (Option.isNone(gate) || gate.value < review.value)) : started.messageId.startsWith("fusion-review:"))) return;
140586
+ yield* stampUnreported(pair, completion, latest);
140587
+ });
140089
140588
  const processCompletion = Effect.fn("FusionWatcherReactor.processCompletion")(function* (event) {
140090
140589
  const activePairs = ((yield* projectionSnapshotQuery.getCommandReadModel()).threadPairs ?? []).filter((pair) => pair.detachedAt === null);
140091
140590
  const failedPair = activePairs.find((pair) => pair.implementerThreadId === event.payload.threadId || pair.watcherThreadId === event.payload.threadId);
@@ -140101,7 +140600,7 @@ const make$3 = Effect.gen(function* () {
140101
140600
  }
140102
140601
  if (event.payload.state !== "completed") return;
140103
140602
  const watcherPair = activePairs.find((pair) => pair.watcherThreadId === event.payload.threadId);
140104
- if (watcherPair !== void 0) yield* recordUnreportedEvidence(watcherPair, event).pipe(Effect.catchCause((cause) => Effect.logWarning("could not record an unreported Fusion evidence read", {
140603
+ if (watcherPair !== void 0) yield* recordUnreportedEvidence(watcherPair, event, { replayed: event.sequence <= liveEventsAfterSequence }).pipe(Effect.catchCause((cause) => Effect.logWarning("could not record an unreported Fusion evidence read", {
140105
140604
  pairId: watcherPair.id,
140106
140605
  cause: Cause.pretty(cause)
140107
140606
  })));
@@ -140186,6 +140685,14 @@ const make$3 = Effect.gen(function* () {
140186
140685
  case "thread-pair.gate-advanced": return yield* processGateAdvanced(event);
140187
140686
  case "thread-pair.gate-resolved": return yield* processGateResolved(event);
140188
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
+ });
140189
140696
  yield* grantActiveMcpWatchThread({
140190
140697
  watcherThreadId: event.payload.watcherThreadId,
140191
140698
  watchedThreadId: event.payload.implementerThreadId
@@ -140207,6 +140714,8 @@ const make$3 = Effect.gen(function* () {
140207
140714
  case "thread-pair.detached": {
140208
140715
  const pair = ((yield* projectionSnapshotQuery.getCommandReadModel()).threadPairs ?? []).find((candidate) => candidate.id === event.payload.pairId);
140209
140716
  if (pair === void 0) return;
140717
+ yield* clearActiveMcpFusionRole({ threadId: pair.watcherThreadId });
140718
+ yield* clearActiveMcpFusionRole({ threadId: pair.implementerThreadId });
140210
140719
  yield* revokeActiveMcpWatchThread({
140211
140720
  watcherThreadId: pair.watcherThreadId,
140212
140721
  watchedThreadId: pair.implementerThreadId