@p4code/cli 0.2.11 → 0.2.13

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
@@ -237,7 +237,7 @@ const make$89 = () => {
237
237
  const layer$80 = Layer.sync(NetService, make$89);
238
238
  //#endregion
239
239
  //#region package.json
240
- var version = "0.2.11";
240
+ var version = "0.2.13";
241
241
  //#endregion
242
242
  //#region src/config.ts
243
243
  /**
@@ -1833,6 +1833,10 @@ const OrchestrationMessage = Schema$1.Struct({
1833
1833
  });
1834
1834
  const FUSION_REVIEW_PROMPT_PREFIX = "[fusion-review]";
1835
1835
  const FUSION_NO_OBJECTION_TEXT = "[fusion-no-objection]";
1836
+ const FUSION_ADVICE_PROMPT_PREFIX = "[fusion-advice]";
1837
+ const FUSION_GATE_PROMPT_PREFIX = "[fusion-gate]";
1838
+ const FUSION_GATE_UNWATCHED_SUMMARY = "Gate passed unwatched";
1839
+ const FUSION_GATE_DEFAULT_TIMEOUT_MS = 12e4;
1836
1840
  const OrchestrationProposedPlanId = TrimmedNonEmptyString;
1837
1841
  const OrchestrationProposedPlan = Schema$1.Struct({
1838
1842
  id: OrchestrationProposedPlanId,
@@ -1950,12 +1954,57 @@ const OrchestrationThread = Schema$1.Struct({
1950
1954
  checkpoints: Schema$1.Array(OrchestrationCheckpointSummary),
1951
1955
  session: Schema$1.NullOr(OrchestrationSession)
1952
1956
  });
1957
+ const ThreadPairGateKind = Schema$1.Literals([
1958
+ "plan",
1959
+ "command-approval",
1960
+ "repeated-failure"
1961
+ ]);
1962
+ /**
1963
+ * Where an open gate stands. `awaiting-watcher` is the only state the fail-open
1964
+ * timeout runs against: a watcher that never answers must not brick the run,
1965
+ * while an implementer revising or a human deciding may take as long as needed.
1966
+ */
1967
+ const ThreadPairGateState = Schema$1.Literals([
1968
+ "awaiting-watcher",
1969
+ "awaiting-implementer",
1970
+ "escalated"
1971
+ ]);
1972
+ const ThreadPairGateId = TrimmedNonEmptyString;
1973
+ const ThreadPairGateOutcome = Schema$1.Literals([
1974
+ "approved",
1975
+ "unwatched",
1976
+ "human",
1977
+ "cancelled"
1978
+ ]);
1979
+ const ThreadPairGateResolver = Schema$1.Literals([
1980
+ "watcher",
1981
+ "timeout",
1982
+ "human",
1983
+ "system"
1984
+ ]);
1985
+ const OrchestrationThreadPairGate = Schema$1.Struct({
1986
+ id: ThreadPairGateId,
1987
+ kind: ThreadPairGateKind,
1988
+ state: ThreadPairGateState,
1989
+ /** Watcher objections spent so far. The pair's roundCap bounds this. */
1990
+ round: NonNegativeInt,
1991
+ requestId: Schema$1.NullOr(ApprovalRequestId),
1992
+ planId: Schema$1.NullOr(OrchestrationProposedPlanId),
1993
+ commandTitle: Schema$1.NullOr(TrimmedNonEmptyString),
1994
+ openedAt: IsoDateTime,
1995
+ /** Set whenever the gate is waiting on the watcher; the timeout base. */
1996
+ awaitingWatcherSince: Schema$1.NullOr(IsoDateTime),
1997
+ updatedAt: IsoDateTime
1998
+ });
1953
1999
  /** Persisted relationship between two otherwise ordinary threads. */
1954
2000
  const OrchestrationThreadPair = Schema$1.Struct({
1955
2001
  id: ThreadPairId,
1956
2002
  implementerThreadId: ThreadId,
1957
2003
  watcherThreadId: ThreadId,
1958
2004
  lastReviewedImplementerSequence: NonNegativeInt,
2005
+ roundCap: NonNegativeInt.pipe(Schema$1.withDecodingDefault(Effect.succeed(2))),
2006
+ gateTimeoutMs: NonNegativeInt.pipe(Schema$1.withDecodingDefault(Effect.succeed(FUSION_GATE_DEFAULT_TIMEOUT_MS))),
2007
+ activeGate: Schema$1.NullOr(OrchestrationThreadPairGate).pipe(Schema$1.withDecodingDefault(Effect.succeed(null))),
1959
2008
  createdAt: IsoDateTime,
1960
2009
  detachedAt: Schema$1.NullOr(IsoDateTime)
1961
2010
  });
@@ -2312,6 +2361,8 @@ const ThreadPairCreateCommand = Schema$1.Struct({
2312
2361
  pairId: ThreadPairId,
2313
2362
  implementerThreadId: ThreadId,
2314
2363
  watcherThreadId: ThreadId,
2364
+ roundCap: Schema$1.optional(NonNegativeInt),
2365
+ gateTimeoutMs: Schema$1.optional(NonNegativeInt),
2315
2366
  createdAt: IsoDateTime
2316
2367
  });
2317
2368
  const ThreadPairDetachCommand = Schema$1.Struct({
@@ -2320,6 +2371,20 @@ const ThreadPairDetachCommand = Schema$1.Struct({
2320
2371
  pairId: ThreadPairId,
2321
2372
  createdAt: IsoDateTime
2322
2373
  });
2374
+ /**
2375
+ * Resolves the pair's open gate. Client-dispatchable because "Skip the gate"
2376
+ * and "Answer it yourself" are human overrides; the watcher's own resolutions
2377
+ * arrive through the advise toolkit, which dispatches this same command.
2378
+ */
2379
+ const ThreadPairGateResolveCommand = Schema$1.Struct({
2380
+ type: Schema$1.Literal("thread-pair.gate.resolve"),
2381
+ commandId: CommandId,
2382
+ pairId: ThreadPairId,
2383
+ gateId: ThreadPairGateId,
2384
+ outcome: ThreadPairGateOutcome,
2385
+ resolvedBy: ThreadPairGateResolver,
2386
+ resolvedAt: IsoDateTime
2387
+ });
2323
2388
  const DispatchableClientOrchestrationCommand = Schema$1.Union([
2324
2389
  ProjectCreateCommand,
2325
2390
  ProjectMetaUpdateCommand,
@@ -2344,7 +2409,8 @@ const DispatchableClientOrchestrationCommand = Schema$1.Union([
2344
2409
  ThreadCheckpointRevertCommand,
2345
2410
  ThreadSessionStopCommand,
2346
2411
  ThreadPairCreateCommand,
2347
- ThreadPairDetachCommand
2412
+ ThreadPairDetachCommand,
2413
+ ThreadPairGateResolveCommand
2348
2414
  ]);
2349
2415
  const ClientOrchestrationCommand = Schema$1.Union([
2350
2416
  ProjectCreateCommand,
@@ -2370,7 +2436,8 @@ const ClientOrchestrationCommand = Schema$1.Union([
2370
2436
  ThreadCheckpointRevertCommand,
2371
2437
  ThreadSessionStopCommand,
2372
2438
  ThreadPairCreateCommand,
2373
- ThreadPairDetachCommand
2439
+ ThreadPairDetachCommand,
2440
+ ThreadPairGateResolveCommand
2374
2441
  ]);
2375
2442
  const ThreadSessionSetCommand = Schema$1.Struct({
2376
2443
  type: Schema$1.Literal("thread.session.set"),
@@ -2399,6 +2466,31 @@ const ThreadPairCursorAdvanceCommand = Schema$1.Struct({
2399
2466
  implementerSequence: NonNegativeInt,
2400
2467
  advancedAt: IsoDateTime
2401
2468
  });
2469
+ /** Server-only: gates are opened by detection reactors, never by clients. */
2470
+ const ThreadPairGateOpenCommand = Schema$1.Struct({
2471
+ type: Schema$1.Literal("thread-pair.gate.open"),
2472
+ commandId: CommandId,
2473
+ pairId: ThreadPairId,
2474
+ gateId: ThreadPairGateId,
2475
+ kind: ThreadPairGateKind,
2476
+ requestId: Schema$1.optional(ApprovalRequestId),
2477
+ planId: Schema$1.optional(OrchestrationProposedPlanId),
2478
+ commandTitle: Schema$1.optional(TrimmedNonEmptyString),
2479
+ openedAt: IsoDateTime
2480
+ });
2481
+ /** Server-only: moves the open gate between its states and counts rounds. */
2482
+ const ThreadPairGateAdvanceCommand = Schema$1.Struct({
2483
+ type: Schema$1.Literal("thread-pair.gate.advance"),
2484
+ commandId: CommandId,
2485
+ pairId: ThreadPairId,
2486
+ gateId: ThreadPairGateId,
2487
+ transition: Schema$1.Literals([
2488
+ "objection",
2489
+ "implementer-responded",
2490
+ "escalate"
2491
+ ]),
2492
+ advancedAt: IsoDateTime
2493
+ });
2402
2494
  const ThreadMessageAssistantDeltaCommand = Schema$1.Struct({
2403
2495
  type: Schema$1.Literal("thread.message.assistant.delta"),
2404
2496
  commandId: CommandId,
@@ -2454,6 +2546,8 @@ const InternalOrchestrationCommand = Schema$1.Union([
2454
2546
  ThreadSessionSetCommand,
2455
2547
  ThreadTurnCompleteCommand,
2456
2548
  ThreadPairCursorAdvanceCommand,
2549
+ ThreadPairGateOpenCommand,
2550
+ ThreadPairGateAdvanceCommand,
2457
2551
  ThreadMessageAssistantDeltaCommand,
2458
2552
  ThreadMessageAssistantCompleteCommand,
2459
2553
  ThreadProposedPlanUpsertCommand,
@@ -2494,7 +2588,10 @@ const OrchestrationEventType = Schema$1.Literals([
2494
2588
  "thread.turn-completed",
2495
2589
  "thread-pair.created",
2496
2590
  "thread-pair.detached",
2497
- "thread-pair.cursor-advanced"
2591
+ "thread-pair.cursor-advanced",
2592
+ "thread-pair.gate-opened",
2593
+ "thread-pair.gate-advanced",
2594
+ "thread-pair.gate-resolved"
2498
2595
  ]);
2499
2596
  const OrchestrationAggregateKind = Schema$1.Literals([
2500
2597
  "project",
@@ -2683,6 +2780,8 @@ const ThreadPairCreatedPayload$1 = Schema$1.Struct({
2683
2780
  implementerThreadId: ThreadId,
2684
2781
  watcherThreadId: ThreadId,
2685
2782
  lastReviewedImplementerSequence: NonNegativeInt,
2783
+ roundCap: Schema$1.optional(NonNegativeInt),
2784
+ gateTimeoutMs: Schema$1.optional(NonNegativeInt),
2686
2785
  createdAt: IsoDateTime
2687
2786
  });
2688
2787
  const ThreadPairDetachedPayload$1 = Schema$1.Struct({
@@ -2694,6 +2793,36 @@ const ThreadPairCursorAdvancedPayload$1 = Schema$1.Struct({
2694
2793
  implementerSequence: NonNegativeInt,
2695
2794
  advancedAt: IsoDateTime
2696
2795
  });
2796
+ /**
2797
+ * Gate events carry both thread ids so reactors and clients can act on them
2798
+ * without a pair lookup - the wake, the activity fan-out and the gated pill
2799
+ * all key on the halves, not the pair id.
2800
+ */
2801
+ const ThreadPairGateOpenedPayload$1 = Schema$1.Struct({
2802
+ pairId: ThreadPairId,
2803
+ implementerThreadId: ThreadId,
2804
+ watcherThreadId: ThreadId,
2805
+ gate: OrchestrationThreadPairGate,
2806
+ openedAt: IsoDateTime
2807
+ });
2808
+ const ThreadPairGateAdvancedPayload$1 = Schema$1.Struct({
2809
+ pairId: ThreadPairId,
2810
+ implementerThreadId: ThreadId,
2811
+ watcherThreadId: ThreadId,
2812
+ gate: OrchestrationThreadPairGate,
2813
+ advancedAt: IsoDateTime
2814
+ });
2815
+ const ThreadPairGateResolvedPayload$1 = Schema$1.Struct({
2816
+ pairId: ThreadPairId,
2817
+ implementerThreadId: ThreadId,
2818
+ watcherThreadId: ThreadId,
2819
+ gateId: ThreadPairGateId,
2820
+ kind: ThreadPairGateKind,
2821
+ requestId: Schema$1.NullOr(ApprovalRequestId),
2822
+ outcome: ThreadPairGateOutcome,
2823
+ resolvedBy: ThreadPairGateResolver,
2824
+ resolvedAt: IsoDateTime
2825
+ });
2697
2826
  const ThreadProposedPlanUpsertedPayload$1 = Schema$1.Struct({
2698
2827
  threadId: ThreadId,
2699
2828
  proposedPlan: OrchestrationProposedPlan
@@ -2894,6 +3023,21 @@ const OrchestrationEvent = Schema$1.Union([
2894
3023
  ...EventBaseFields,
2895
3024
  type: Schema$1.Literal("thread-pair.cursor-advanced"),
2896
3025
  payload: ThreadPairCursorAdvancedPayload$1
3026
+ }),
3027
+ Schema$1.Struct({
3028
+ ...EventBaseFields,
3029
+ type: Schema$1.Literal("thread-pair.gate-opened"),
3030
+ payload: ThreadPairGateOpenedPayload$1
3031
+ }),
3032
+ Schema$1.Struct({
3033
+ ...EventBaseFields,
3034
+ type: Schema$1.Literal("thread-pair.gate-advanced"),
3035
+ payload: ThreadPairGateAdvancedPayload$1
3036
+ }),
3037
+ Schema$1.Struct({
3038
+ ...EventBaseFields,
3039
+ type: Schema$1.Literal("thread-pair.gate-resolved"),
3040
+ payload: ThreadPairGateResolvedPayload$1
2897
3041
  })
2898
3042
  ]);
2899
3043
  const OrchestrationThreadStreamItem = Schema$1.Union([
@@ -9261,6 +9405,82 @@ var TrackerApiKeyError = class extends Schema$1.TaggedErrorClass()("TrackerApiKe
9261
9405
  }
9262
9406
  };
9263
9407
  //#endregion
9408
+ //#region ../../packages/contracts/src/threadAdvise.ts
9409
+ /**
9410
+ * Thread advising over MCP - the cross-thread write surface behind the advise
9411
+ * capability.
9412
+ *
9413
+ * Watching (`threadWatch`) is read-only by design; this module is the change
9414
+ * that makes cross-thread access write-capable, which is why advise is a
9415
+ * separate capability rather than a wider watch. Read access never implies the
9416
+ * ability to steer: a session holding `watch` alone cannot call anything here,
9417
+ * and a session holding `advise` reaches exactly the threads in its explicit
9418
+ * `adviseThreadIds` grant - in practice the paired implementer and nothing
9419
+ * else. A request for any other thread fails closed.
9420
+ *
9421
+ * @module contracts/threadAdvise
9422
+ */
9423
+ const ThreadAdviseInput = Schema$1.Struct({
9424
+ threadId: ThreadId.annotate({ description: "The paired builder thread to advise. Must be explicitly granted." }),
9425
+ message: TrimmedNonEmptyString.annotate({ description: "The advice to post into the builder thread. The builder sees it as supervisor advice and is expected to respond." }),
9426
+ interrupt: Schema$1.optional(Schema$1.Boolean.annotate({ description: "Cancel the builder's running turn before posting, so the advice is read now rather than at the next boundary. Use only when continuing would waste or damage work." }))
9427
+ });
9428
+ const ThreadAdviseResult = Schema$1.Struct({
9429
+ threadId: ThreadId,
9430
+ messageId: MessageId,
9431
+ interrupted: Schema$1.Boolean
9432
+ });
9433
+ const ThreadGateRespondInput = Schema$1.Struct({
9434
+ threadId: ThreadId.annotate({ description: "The paired builder thread whose open gate this answers." }),
9435
+ gateId: ThreadPairGateId.annotate({ description: "The open gate's id, given in the gate review prompt." }),
9436
+ decision: Schema$1.Literals(["approve", "object"]).annotate({ description: "approve clears the gate and lets the builder proceed. object sends the message to the builder as an objection and spends one exchange round; when the rounds are used up the gate escalates to the human instead." }),
9437
+ message: Schema$1.optional(TrimmedNonEmptyString.annotate({ description: "The objection to deliver. Required when decision is object." }))
9438
+ });
9439
+ const ThreadGateRespondResult = Schema$1.Struct({
9440
+ threadId: ThreadId,
9441
+ gateId: ThreadPairGateId,
9442
+ kind: ThreadPairGateKind,
9443
+ outcome: Schema$1.Literals([
9444
+ "approved",
9445
+ "objection-sent",
9446
+ "escalated"
9447
+ ])
9448
+ });
9449
+ var AdviseToolUnavailableError = class extends Schema$1.TaggedErrorClass()("AdviseToolUnavailableError", {
9450
+ capability: Schema$1.Literal("advise"),
9451
+ environmentId: EnvironmentId,
9452
+ threadId: ThreadId,
9453
+ providerSessionId: TrimmedNonEmptyString,
9454
+ providerInstanceId: ProviderInstanceId
9455
+ }) {
9456
+ get message() {
9457
+ return `MCP credential does not grant the ${this.capability} capability.`;
9458
+ }
9459
+ };
9460
+ /**
9461
+ * The capability is present but the requested thread is not in the granted
9462
+ * set - the same split `threadWatch` makes, so an adviser can tell "you
9463
+ * cannot advise at all" apart from "not this thread".
9464
+ */
9465
+ var ThreadAdviseNotPermittedError = class extends Schema$1.TaggedErrorClass()("ThreadAdviseNotPermittedError", { threadId: ThreadId }) {
9466
+ get message() {
9467
+ return `This session was not granted advise access to thread ${this.threadId}.`;
9468
+ }
9469
+ };
9470
+ var ThreadAdviseFailedError = class extends Schema$1.TaggedErrorClass()("ThreadAdviseFailedError", {
9471
+ threadId: ThreadId,
9472
+ detail: Schema$1.String
9473
+ }) {
9474
+ get message() {
9475
+ return `Advising thread ${this.threadId} failed: ${this.detail}`;
9476
+ }
9477
+ };
9478
+ const ThreadAdviseToolError = Schema$1.Union([
9479
+ AdviseToolUnavailableError,
9480
+ ThreadAdviseNotPermittedError,
9481
+ ThreadAdviseFailedError
9482
+ ]);
9483
+ //#endregion
9264
9484
  //#region ../../packages/contracts/src/threadControl.ts
9265
9485
  /**
9266
9486
  * Thread control over MCP - the toolkit an agent uses to act on its own thread
@@ -9332,7 +9552,10 @@ const ThreadSpawnResult = Schema$1.Struct({
9332
9552
  projectId: ProjectId,
9333
9553
  title: TrimmedNonEmptyString
9334
9554
  });
9335
- const ThreadPairCreateInput = Schema$1.Struct({ watcherThreadId: ThreadId.annotate({ description: "The supervisor thread to pair with this session's own builder thread. It must have been created by this session through thread_spawn." }) });
9555
+ const ThreadPairCreateInput = Schema$1.Struct({
9556
+ watcherThreadId: ThreadId.annotate({ description: "The supervisor thread to pair with this session's own builder thread. It must have been created by this session through thread_spawn." }),
9557
+ gateTimeoutMs: Schema$1.optional(NonNegativeInt.annotate({ description: "How long a gate waits on the supervisor before failing open, in milliseconds. Defaults to 120000." }))
9558
+ });
9336
9559
  const ThreadPairCreateResult = Schema$1.Struct({
9337
9560
  pairId: ThreadPairId,
9338
9561
  implementerThreadId: ThreadId,
@@ -15176,6 +15399,29 @@ var _046_ThreadPairs_default = Effect.gen(function* () {
15176
15399
  `;
15177
15400
  });
15178
15401
  //#endregion
15402
+ //#region src/persistence/Migrations/047_ThreadPairGates.ts
15403
+ /**
15404
+ * Fusion second cut: per-pair gate configuration and the persisted open gate.
15405
+ *
15406
+ * The open gate lives in a JSON column rather than its own table because a
15407
+ * pair holds at most one open gate at a time and gate history already lives in
15408
+ * the event log. Persisting it here is what makes the fail-open timeout
15409
+ * enforceable across a server restart: the sweeper reads this row, not any
15410
+ * in-memory approval state.
15411
+ */
15412
+ var _047_ThreadPairGates_default = Effect.gen(function* () {
15413
+ const sql = yield* SqlClient.SqlClient;
15414
+ yield* sql`
15415
+ ALTER TABLE thread_pairs ADD COLUMN round_cap INTEGER NOT NULL DEFAULT 2
15416
+ `;
15417
+ yield* sql`
15418
+ ALTER TABLE thread_pairs ADD COLUMN gate_timeout_ms INTEGER NOT NULL DEFAULT 120000
15419
+ `;
15420
+ yield* sql`
15421
+ ALTER TABLE thread_pairs ADD COLUMN active_gate_json TEXT
15422
+ `;
15423
+ });
15424
+ //#endregion
15179
15425
  //#region src/persistence/Migrations.ts
15180
15426
  /**
15181
15427
  * MigrationsLive - Migration runner with inline loader
@@ -15426,6 +15672,11 @@ const migrationEntries = [
15426
15672
  46,
15427
15673
  "ThreadPairs",
15428
15674
  _046_ThreadPairs_default
15675
+ ],
15676
+ [
15677
+ 47,
15678
+ "ThreadPairGates",
15679
+ _047_ThreadPairGates_default
15429
15680
  ]
15430
15681
  ];
15431
15682
  const makeMigrationLoader = (throughId) => Migrator.fromRecord(Object.fromEntries(migrationEntries.filter(([id]) => throughId === void 0 || id <= throughId).map(([id, name, migration]) => [`${id}_${name}`, migration])));
@@ -18094,6 +18345,51 @@ var _009_Feed_default = Effect.gen(function* () {
18094
18345
  VALUES (${id}, ${name}, ${kind}, ${feedUrl}, ${siteUrl}, 1, ${now}, ${now})`;
18095
18346
  });
18096
18347
  //#endregion
18348
+ //#region src/hub/Migrations/010_DefaultFeedSources.ts
18349
+ var _010_DefaultFeedSources_default = Effect.gen(function* () {
18350
+ const sql = yield* SqlClient.SqlClient;
18351
+ const now = DateTime.formatIso(yield* DateTime.now);
18352
+ for (const [id, name, kind, feedUrl, siteUrl] of [
18353
+ [
18354
+ "hacker-news",
18355
+ "Hacker News",
18356
+ "hacker_news",
18357
+ "https://hnrss.org/frontpage",
18358
+ "https://news.ycombinator.com"
18359
+ ],
18360
+ [
18361
+ "techcrunch",
18362
+ "TechCrunch",
18363
+ "syndication",
18364
+ "https://techcrunch.com/feed/",
18365
+ "https://techcrunch.com"
18366
+ ],
18367
+ [
18368
+ "the-verge",
18369
+ "The Verge",
18370
+ "syndication",
18371
+ "https://www.theverge.com/rss/index.xml",
18372
+ "https://www.theverge.com"
18373
+ ],
18374
+ [
18375
+ "ars-technica",
18376
+ "Ars Technica",
18377
+ "syndication",
18378
+ "https://feeds.arstechnica.com/arstechnica/index",
18379
+ "https://arstechnica.com"
18380
+ ],
18381
+ [
18382
+ "wired",
18383
+ "Wired",
18384
+ "syndication",
18385
+ "https://www.wired.com/feed/rss",
18386
+ "https://www.wired.com"
18387
+ ]
18388
+ ]) yield* sql`INSERT OR IGNORE INTO feed_sources
18389
+ (id, name, kind, feed_url, site_url, enabled, created_at, updated_at)
18390
+ VALUES (${id}, ${name}, ${kind}, ${feedUrl}, ${siteUrl}, 1, ${now}, ${now})`;
18391
+ });
18392
+ //#endregion
18097
18393
  //#region src/hub/Migrations.ts
18098
18394
  /**
18099
18395
  * Hub migrations.
@@ -18149,6 +18445,11 @@ const hubMigrationEntries = [
18149
18445
  9,
18150
18446
  "Feed",
18151
18447
  _009_Feed_default
18448
+ ],
18449
+ [
18450
+ 10,
18451
+ "DefaultFeedSources",
18452
+ _010_DefaultFeedSources_default
18152
18453
  ]
18153
18454
  ];
18154
18455
  const hubMigrationLoader = Migrator.fromRecord(Object.fromEntries(hubMigrationEntries.map(([id, name, migration]) => [`${id}_${name}`, migration])));
@@ -22889,6 +23190,9 @@ const ThreadActivityAppendedPayload = ThreadActivityAppendedPayload$1;
22889
23190
  const ThreadPairCreatedPayload = ThreadPairCreatedPayload$1;
22890
23191
  const ThreadPairDetachedPayload = ThreadPairDetachedPayload$1;
22891
23192
  const ThreadPairCursorAdvancedPayload = ThreadPairCursorAdvancedPayload$1;
23193
+ const ThreadPairGateOpenedPayload = ThreadPairGateOpenedPayload$1;
23194
+ const ThreadPairGateAdvancedPayload = ThreadPairGateAdvancedPayload$1;
23195
+ const ThreadPairGateResolvedPayload = ThreadPairGateResolvedPayload$1;
22892
23196
  //#endregion
22893
23197
  //#region src/orchestration/projector.ts
22894
23198
  function checkpointStatusToLatestTurnState(status) {
@@ -22980,6 +23284,9 @@ function projectEvent(model, event) {
22980
23284
  implementerThreadId: payload.implementerThreadId,
22981
23285
  watcherThreadId: payload.watcherThreadId,
22982
23286
  lastReviewedImplementerSequence: payload.lastReviewedImplementerSequence,
23287
+ roundCap: payload.roundCap ?? 2,
23288
+ gateTimeoutMs: payload.gateTimeoutMs ?? 12e4,
23289
+ activeGate: null,
22983
23290
  createdAt: payload.createdAt,
22984
23291
  detachedAt: null
22985
23292
  }]
@@ -22998,6 +23305,27 @@ function projectEvent(model, event) {
22998
23305
  lastReviewedImplementerSequence: payload.implementerSequence
22999
23306
  } : pair)
23000
23307
  })));
23308
+ case "thread-pair.gate-opened": return decodeForEvent(ThreadPairGateOpenedPayload, event.payload, event.type, "payload").pipe(Effect.map((payload) => ({
23309
+ ...nextBase,
23310
+ threadPairs: (nextBase.threadPairs ?? []).map((pair) => pair.id === payload.pairId ? {
23311
+ ...pair,
23312
+ activeGate: payload.gate
23313
+ } : pair)
23314
+ })));
23315
+ case "thread-pair.gate-advanced": return decodeForEvent(ThreadPairGateAdvancedPayload, event.payload, event.type, "payload").pipe(Effect.map((payload) => ({
23316
+ ...nextBase,
23317
+ threadPairs: (nextBase.threadPairs ?? []).map((pair) => pair.id === payload.pairId ? {
23318
+ ...pair,
23319
+ activeGate: payload.gate
23320
+ } : pair)
23321
+ })));
23322
+ case "thread-pair.gate-resolved": return decodeForEvent(ThreadPairGateResolvedPayload, event.payload, event.type, "payload").pipe(Effect.map((payload) => ({
23323
+ ...nextBase,
23324
+ threadPairs: (nextBase.threadPairs ?? []).map((pair) => pair.id === payload.pairId ? {
23325
+ ...pair,
23326
+ activeGate: null
23327
+ } : pair)
23328
+ })));
23001
23329
  case "project.created": return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe(Effect.map((payload) => {
23002
23330
  const existing = nextBase.projects.find((entry) => entry.id === payload.projectId);
23003
23331
  const nextProject = {
@@ -23604,6 +23932,8 @@ const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(funct
23604
23932
  implementerThreadId: command.implementerThreadId,
23605
23933
  watcherThreadId: command.watcherThreadId,
23606
23934
  lastReviewedImplementerSequence: readModel.snapshotSequence,
23935
+ ...command.roundCap !== void 0 ? { roundCap: command.roundCap } : {},
23936
+ ...command.gateTimeoutMs !== void 0 ? { gateTimeoutMs: command.gateTimeoutMs } : {},
23607
23937
  createdAt: command.createdAt
23608
23938
  }
23609
23939
  };
@@ -23674,6 +24004,118 @@ const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(funct
23674
24004
  }
23675
24005
  };
23676
24006
  }
24007
+ case "thread-pair.gate.open": {
24008
+ const pair = (readModel.threadPairs ?? []).find((candidate) => candidate.id === command.pairId);
24009
+ if (pair === void 0 || pair.detachedAt !== null) return yield* new OrchestrationCommandInvariantError({
24010
+ commandType: command.type,
24011
+ detail: `Active thread pair '${command.pairId}' does not exist.`
24012
+ });
24013
+ if (pair.activeGate !== null) return yield* new OrchestrationCommandInvariantError({
24014
+ commandType: command.type,
24015
+ detail: `Thread pair '${command.pairId}' already has an open gate.`
24016
+ });
24017
+ return {
24018
+ ...yield* withEventBase({
24019
+ aggregateKind: "thread-pair",
24020
+ aggregateId: command.pairId,
24021
+ occurredAt: command.openedAt,
24022
+ commandId: command.commandId
24023
+ }),
24024
+ type: "thread-pair.gate-opened",
24025
+ payload: {
24026
+ pairId: command.pairId,
24027
+ implementerThreadId: pair.implementerThreadId,
24028
+ watcherThreadId: pair.watcherThreadId,
24029
+ gate: {
24030
+ id: command.gateId,
24031
+ kind: command.kind,
24032
+ state: "awaiting-watcher",
24033
+ round: 0,
24034
+ requestId: command.requestId ?? null,
24035
+ planId: command.planId ?? null,
24036
+ commandTitle: command.commandTitle ?? null,
24037
+ openedAt: command.openedAt,
24038
+ awaitingWatcherSince: command.openedAt,
24039
+ updatedAt: command.openedAt
24040
+ },
24041
+ openedAt: command.openedAt
24042
+ }
24043
+ };
24044
+ }
24045
+ case "thread-pair.gate.advance": {
24046
+ const pair = (readModel.threadPairs ?? []).find((candidate) => candidate.id === command.pairId);
24047
+ const gate = pair?.detachedAt === null ? pair.activeGate : null;
24048
+ if (pair === void 0 || gate === null || gate === void 0 || gate.id !== command.gateId) return yield* new OrchestrationCommandInvariantError({
24049
+ commandType: command.type,
24050
+ detail: `Open gate '${command.gateId}' does not exist on thread pair '${command.pairId}'.`
24051
+ });
24052
+ const transition = command.transition;
24053
+ if (transition === "objection" && gate.state !== "awaiting-watcher" || transition === "implementer-responded" && gate.state !== "awaiting-implementer" || transition === "escalate" && gate.state === "escalated") return yield* new OrchestrationCommandInvariantError({
24054
+ commandType: command.type,
24055
+ detail: `Gate '${command.gateId}' in state '${gate.state}' cannot take transition '${transition}'.`
24056
+ });
24057
+ const nextGate = transition === "objection" ? {
24058
+ ...gate,
24059
+ state: "awaiting-implementer",
24060
+ round: gate.round + 1,
24061
+ awaitingWatcherSince: null,
24062
+ updatedAt: command.advancedAt
24063
+ } : transition === "implementer-responded" ? {
24064
+ ...gate,
24065
+ state: "awaiting-watcher",
24066
+ awaitingWatcherSince: command.advancedAt,
24067
+ updatedAt: command.advancedAt
24068
+ } : {
24069
+ ...gate,
24070
+ state: "escalated",
24071
+ awaitingWatcherSince: null,
24072
+ updatedAt: command.advancedAt
24073
+ };
24074
+ return {
24075
+ ...yield* withEventBase({
24076
+ aggregateKind: "thread-pair",
24077
+ aggregateId: command.pairId,
24078
+ occurredAt: command.advancedAt,
24079
+ commandId: command.commandId
24080
+ }),
24081
+ type: "thread-pair.gate-advanced",
24082
+ payload: {
24083
+ pairId: command.pairId,
24084
+ implementerThreadId: pair.implementerThreadId,
24085
+ watcherThreadId: pair.watcherThreadId,
24086
+ gate: nextGate,
24087
+ advancedAt: command.advancedAt
24088
+ }
24089
+ };
24090
+ }
24091
+ case "thread-pair.gate.resolve": {
24092
+ const pair = (readModel.threadPairs ?? []).find((candidate) => candidate.id === command.pairId);
24093
+ const gate = pair?.detachedAt === null ? pair.activeGate : null;
24094
+ if (pair === void 0 || gate === null || gate === void 0 || gate.id !== command.gateId) return yield* new OrchestrationCommandInvariantError({
24095
+ commandType: command.type,
24096
+ detail: `Open gate '${command.gateId}' does not exist on thread pair '${command.pairId}'.`
24097
+ });
24098
+ return {
24099
+ ...yield* withEventBase({
24100
+ aggregateKind: "thread-pair",
24101
+ aggregateId: command.pairId,
24102
+ occurredAt: command.resolvedAt,
24103
+ commandId: command.commandId
24104
+ }),
24105
+ type: "thread-pair.gate-resolved",
24106
+ payload: {
24107
+ pairId: command.pairId,
24108
+ implementerThreadId: pair.implementerThreadId,
24109
+ watcherThreadId: pair.watcherThreadId,
24110
+ gateId: gate.id,
24111
+ kind: gate.kind,
24112
+ requestId: gate.requestId,
24113
+ outcome: command.outcome,
24114
+ resolvedBy: command.resolvedBy,
24115
+ resolvedAt: command.resolvedAt
24116
+ }
24117
+ };
24118
+ }
23677
24119
  case "thread.archive": {
23678
24120
  yield* requireThreadNotArchived({
23679
24121
  readModel,
@@ -24366,7 +24808,10 @@ function commandToAggregateRef(command) {
24366
24808
  };
24367
24809
  case "thread-pair.create":
24368
24810
  case "thread-pair.detach":
24369
- case "thread-pair.cursor.advance": return {
24811
+ case "thread-pair.cursor.advance":
24812
+ case "thread-pair.gate.open":
24813
+ case "thread-pair.gate.advance":
24814
+ case "thread-pair.gate.resolve": return {
24370
24815
  aggregateKind: "thread-pair",
24371
24816
  aggregateId: command.pairId
24372
24817
  };
@@ -26285,6 +26730,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
26285
26730
  implementer_thread_id,
26286
26731
  watcher_thread_id,
26287
26732
  last_reviewed_implementer_sequence,
26733
+ round_cap,
26734
+ gate_timeout_ms,
26735
+ active_gate_json,
26288
26736
  created_at,
26289
26737
  detached_at
26290
26738
  ) VALUES (
@@ -26292,6 +26740,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
26292
26740
  ${event.payload.implementerThreadId},
26293
26741
  ${event.payload.watcherThreadId},
26294
26742
  ${event.payload.lastReviewedImplementerSequence},
26743
+ ${event.payload.roundCap ?? 2},
26744
+ ${event.payload.gateTimeoutMs ?? 12e4},
26745
+ NULL,
26295
26746
  ${event.payload.createdAt},
26296
26747
  NULL
26297
26748
  )
@@ -26299,10 +26750,28 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
26299
26750
  implementer_thread_id = excluded.implementer_thread_id,
26300
26751
  watcher_thread_id = excluded.watcher_thread_id,
26301
26752
  last_reviewed_implementer_sequence = excluded.last_reviewed_implementer_sequence,
26753
+ round_cap = excluded.round_cap,
26754
+ gate_timeout_ms = excluded.gate_timeout_ms,
26755
+ active_gate_json = NULL,
26302
26756
  created_at = excluded.created_at,
26303
26757
  detached_at = NULL
26304
26758
  `.pipe(Effect.mapError(toPersistenceSqlError("ProjectionPipeline.threadPairs:create")));
26305
26759
  return;
26760
+ case "thread-pair.gate-opened":
26761
+ case "thread-pair.gate-advanced":
26762
+ yield* sql`
26763
+ UPDATE thread_pairs
26764
+ SET active_gate_json = ${JSON.stringify(event.payload.gate)}
26765
+ WHERE pair_id = ${event.payload.pairId}
26766
+ `.pipe(Effect.mapError(toPersistenceSqlError("ProjectionPipeline.threadPairs:gate")));
26767
+ return;
26768
+ case "thread-pair.gate-resolved":
26769
+ yield* sql`
26770
+ UPDATE thread_pairs
26771
+ SET active_gate_json = NULL
26772
+ WHERE pair_id = ${event.payload.pairId}
26773
+ `.pipe(Effect.mapError(toPersistenceSqlError("ProjectionPipeline.threadPairs:gateResolve")));
26774
+ return;
26306
26775
  case "thread-pair.detached":
26307
26776
  yield* sql`
26308
26777
  UPDATE thread_pairs
@@ -27714,6 +28183,9 @@ const ProjectionThreadPairDbRowSchema = Schema$1.Struct({
27714
28183
  implementerThreadId: ThreadId,
27715
28184
  watcherThreadId: ThreadId,
27716
28185
  lastReviewedImplementerSequence: NonNegativeInt,
28186
+ roundCap: NonNegativeInt,
28187
+ gateTimeoutMs: NonNegativeInt,
28188
+ activeGate: Schema$1.NullOr(Schema$1.fromJsonString(OrchestrationThreadPairGate)),
27717
28189
  createdAt: IsoDateTime,
27718
28190
  detachedAt: Schema$1.NullOr(IsoDateTime)
27719
28191
  });
@@ -27898,10 +28370,31 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
27898
28370
  implementer_thread_id AS "implementerThreadId",
27899
28371
  watcher_thread_id AS "watcherThreadId",
27900
28372
  last_reviewed_implementer_sequence AS "lastReviewedImplementerSequence",
28373
+ round_cap AS "roundCap",
28374
+ gate_timeout_ms AS "gateTimeoutMs",
28375
+ active_gate_json AS "activeGate",
27901
28376
  created_at AS "createdAt",
27902
28377
  detached_at AS "detachedAt"
27903
28378
  FROM thread_pairs
27904
28379
  ORDER BY created_at ASC, pair_id ASC
28380
+ `
28381
+ });
28382
+ const getThreadPairRow = SqlSchema.findOneOption({
28383
+ Request: ThreadPairId,
28384
+ Result: ProjectionThreadPairDbRowSchema,
28385
+ execute: (pairId) => sql`
28386
+ SELECT
28387
+ pair_id AS "id",
28388
+ implementer_thread_id AS "implementerThreadId",
28389
+ watcher_thread_id AS "watcherThreadId",
28390
+ last_reviewed_implementer_sequence AS "lastReviewedImplementerSequence",
28391
+ round_cap AS "roundCap",
28392
+ gate_timeout_ms AS "gateTimeoutMs",
28393
+ active_gate_json AS "activeGate",
28394
+ created_at AS "createdAt",
28395
+ detached_at AS "detachedAt"
28396
+ FROM thread_pairs
28397
+ WHERE pair_id = ${pairId}
27905
28398
  `
27906
28399
  });
27907
28400
  const listThreadRows = SqlSchema.findAll({
@@ -29056,6 +29549,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
29056
29549
  messageCreatedAt: row.messageCreatedAt
29057
29550
  })) };
29058
29551
  });
29552
+ const getThreadPairById = (pairId) => getThreadPairRow(pairId).pipe(Effect.mapError(toPersistenceSqlOrDecodeError$1("ProjectionSnapshotQuery.getThreadPairById:query", "ProjectionSnapshotQuery.getThreadPairById:decodeRow")));
29059
29553
  const getThreadShellById = (threadId) => Effect.gen(function* () {
29060
29554
  const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([
29061
29555
  getActiveThreadRowById({ threadId }).pipe(Effect.mapError(toPersistenceSqlOrDecodeError$1("ProjectionSnapshotQuery.getThreadShellById:getThread:query", "ProjectionSnapshotQuery.getThreadShellById:getThread:decodeRow"))),
@@ -29186,6 +29680,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
29186
29680
  getFirstActiveThreadIdByProjectId,
29187
29681
  getThreadCheckpointContext,
29188
29682
  getFullThreadDiffContext,
29683
+ getThreadPairById,
29189
29684
  getThreadShellById,
29190
29685
  getThreadDetailById,
29191
29686
  getThreadDetailSnapshot
@@ -45681,6 +46176,24 @@ const requireWatchCapability = Effect.fn("mcp.requireWatchCapability")(function*
45681
46176
  if (!invocation.watchThreadIds?.has(watchedThreadId)) return yield* new ThreadWatchNotPermittedError({ threadId: watchedThreadId });
45682
46177
  return invocation;
45683
46178
  });
46179
+ /**
46180
+ * The advise guard mirrors the watch guard's two checks - capability present
46181
+ * AND thread in the explicit grant - because advising carries strictly more
46182
+ * power than watching. A watch-only credential fails the first check here no
46183
+ * matter what threads it can read.
46184
+ */
46185
+ const requireAdviseCapability = Effect.fn("mcp.requireAdviseCapability")(function* (advisedThreadId) {
46186
+ const invocation = yield* McpInvocationContext;
46187
+ if (!invocation.capabilities.has("advise")) return yield* new AdviseToolUnavailableError({
46188
+ capability: "advise",
46189
+ environmentId: invocation.environmentId,
46190
+ threadId: invocation.threadId,
46191
+ providerSessionId: invocation.providerSessionId,
46192
+ providerInstanceId: invocation.providerInstanceId
46193
+ });
46194
+ if (!invocation.adviseThreadIds?.has(advisedThreadId)) return yield* new ThreadAdviseNotPermittedError({ threadId: advisedThreadId });
46195
+ return invocation;
46196
+ });
45684
46197
  /** Third of the trio, for the same reason the second exists. */
45685
46198
  const requireThreadCapability = Effect.fn("mcp.requireThreadCapability")(function* () {
45686
46199
  const invocation = yield* McpInvocationContext;
@@ -60494,7 +61007,6 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
60494
61007
  const serverSelfUpdate = yield* ServerSelfUpdate;
60495
61008
  const textGeneration = yield* TextGeneration;
60496
61009
  const config = yield* ServerConfig$1;
60497
- const allowAbsoluteFileReads = config.mode === "desktop" && !isRemoteReachableHost(config.host);
60498
61010
  const lifecycleEvents = yield* ServerLifecycleEvents;
60499
61011
  const serverSettings = yield* ServerSettingsService;
60500
61012
  const hubLink = yield* HubLink;
@@ -60612,10 +61124,24 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
60612
61124
  implementerThreadId: event.payload.implementerThreadId,
60613
61125
  watcherThreadId: event.payload.watcherThreadId,
60614
61126
  lastReviewedImplementerSequence: event.payload.lastReviewedImplementerSequence,
61127
+ roundCap: event.payload.roundCap ?? 2,
61128
+ gateTimeoutMs: event.payload.gateTimeoutMs ?? 12e4,
61129
+ activeGate: null,
60615
61130
  createdAt: event.payload.createdAt,
60616
61131
  detachedAt: null
60617
61132
  }
60618
61133
  }));
61134
+ case "thread-pair.gate-opened":
61135
+ case "thread-pair.gate-advanced":
61136
+ case "thread-pair.gate-resolved":
61137
+ case "thread-pair.cursor-advanced": return projectionSnapshotQuery.getThreadPairById(event.payload.pairId).pipe(Effect.retry({ times: 1 }), Effect.map(Option.map((pair) => ({
61138
+ kind: "thread-pair-upserted",
61139
+ sequence: event.sequence,
61140
+ pair
61141
+ }))), Effect.tapError((error) => Effect.logWarning("orchestration shell pair refetch failed", {
61142
+ pairId: event.payload.pairId,
61143
+ error
61144
+ })), Effect.orElseSucceed(() => Option.none()));
60619
61145
  case "thread-pair.detached": return Effect.succeed(Option.some({
60620
61146
  kind: "thread-pair-removed",
60621
61147
  sequence: event.sequence,
@@ -61228,17 +61754,11 @@ const makeWsRpcLayer = (currentSession, previewAutomationBroker) => WsRpcGroup.t
61228
61754
  ...projectEntriesFailureContext(cause),
61229
61755
  cause
61230
61756
  }))), { "rpc.aggregate": "workspace" }),
61231
- [WS_METHODS.projectsReadFile]: (input) => observeRpcEffect$1(WS_METHODS.projectsReadFile, Effect.gen(function* () {
61232
- if ("absolutePath" in input && !allowAbsoluteFileReads) return yield* new ProjectReadFileError({
61233
- ...input,
61234
- failure: "workspace_path_outside_root"
61235
- });
61236
- return yield* workspaceFileSystem.readFile(input).pipe(Effect.mapError((cause) => new ProjectReadFileError({
61237
- ...input,
61238
- ...projectFileFailureContext(cause),
61239
- cause
61240
- })));
61241
- }), { "rpc.aggregate": "workspace" }),
61757
+ [WS_METHODS.projectsReadFile]: (input) => observeRpcEffect$1(WS_METHODS.projectsReadFile, workspaceFileSystem.readFile(input).pipe(Effect.mapError((cause) => new ProjectReadFileError({
61758
+ ...input,
61759
+ ...projectFileFailureContext(cause),
61760
+ cause
61761
+ }))), { "rpc.aggregate": "workspace" }),
61242
61762
  [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect$1(WS_METHODS.projectsWriteFile, workspaceFileSystem.writeFile(input).pipe(Effect.mapError((cause) => new ProjectWriteFileError({
61243
61763
  cwd: input.cwd,
61244
61764
  relativePath: input.relativePath,
@@ -62125,12 +62645,14 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
62125
62645
  const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
62126
62646
  const tokenHash = yield* hashToken(rawToken);
62127
62647
  const watchThreadIds = new Set((request.watchThreadIds ?? []).map((threadId) => ThreadId.make(threadId)));
62648
+ const adviseThreadIds = new Set((request.adviseThreadIds ?? []).map((threadId) => ThreadId.make(threadId)));
62128
62649
  const capabilities = /* @__PURE__ */ new Set([
62129
62650
  "preview",
62130
62651
  "tasks",
62131
62652
  "threads"
62132
62653
  ]);
62133
62654
  if (watchThreadIds.size > 0) capabilities.add("watch");
62655
+ if (adviseThreadIds.size > 0) capabilities.add("advise");
62134
62656
  const threadId = ThreadId.make(request.threadId);
62135
62657
  const scopeWith = (mayCreateThreads) => ({
62136
62658
  environmentId,
@@ -62139,6 +62661,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
62139
62661
  providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
62140
62662
  capabilities,
62141
62663
  ...watchThreadIds.size > 0 ? { watchThreadIds } : {},
62664
+ ...adviseThreadIds.size > 0 ? { adviseThreadIds } : {},
62142
62665
  mayCreateThreads,
62143
62666
  issuedAt
62144
62667
  });
@@ -62254,6 +62777,54 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
62254
62777
  };
62255
62778
  });
62256
62779
  });
62780
+ const grantAdviseThread = Effect.fn("McpSessionRegistry.grantAdviseThread")(function* ({ watcherThreadId, advisedThreadId }) {
62781
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreadIds }) => {
62782
+ const next = new Map(records);
62783
+ for (const [tokenHash, record] of records) {
62784
+ if (record.scope.threadId !== watcherThreadId) continue;
62785
+ next.set(tokenHash, {
62786
+ ...record,
62787
+ scope: {
62788
+ ...record.scope,
62789
+ capabilities: /* @__PURE__ */ new Set([...record.scope.capabilities, "advise"]),
62790
+ adviseThreadIds: /* @__PURE__ */ new Set([...record.scope.adviseThreadIds ?? [], advisedThreadId])
62791
+ }
62792
+ });
62793
+ }
62794
+ return {
62795
+ records: next,
62796
+ spawnedThreadIds
62797
+ };
62798
+ });
62799
+ });
62800
+ const revokeAdviseThread = Effect.fn("McpSessionRegistry.revokeAdviseThread")(function* ({ watcherThreadId, advisedThreadId }) {
62801
+ yield* SynchronizedRef.update(state, ({ records, spawnedThreadIds }) => {
62802
+ const next = new Map(records);
62803
+ for (const [tokenHash, record] of records) {
62804
+ if (record.scope.threadId !== watcherThreadId) continue;
62805
+ const adviseThreadIds = new Set(record.scope.adviseThreadIds ?? []);
62806
+ adviseThreadIds.delete(advisedThreadId);
62807
+ const capabilities = new Set(record.scope.capabilities);
62808
+ if (adviseThreadIds.size === 0) capabilities.delete("advise");
62809
+ const { adviseThreadIds: _previousAdviseThreadIds, ...scopeWithoutAdvise } = record.scope;
62810
+ next.set(tokenHash, {
62811
+ ...record,
62812
+ scope: adviseThreadIds.size > 0 ? {
62813
+ ...scopeWithoutAdvise,
62814
+ capabilities,
62815
+ adviseThreadIds
62816
+ } : {
62817
+ ...scopeWithoutAdvise,
62818
+ capabilities
62819
+ }
62820
+ });
62821
+ }
62822
+ return {
62823
+ records: next,
62824
+ spawnedThreadIds
62825
+ };
62826
+ });
62827
+ });
62257
62828
  const recordSpawnedThread = Effect.fn("McpSessionRegistry.recordSpawnedThread")(function* (input) {
62258
62829
  yield* SynchronizedRef.update(state, ({ records, spawnedThreadIds }) => {
62259
62830
  const next = new Map(records);
@@ -62277,6 +62848,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (options
62277
62848
  touch,
62278
62849
  grantWatchThread,
62279
62850
  revokeWatchThread,
62851
+ grantAdviseThread,
62852
+ revokeAdviseThread,
62280
62853
  recordSpawnedThread,
62281
62854
  revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(function* (providerSessionId) {
62282
62855
  yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId);
@@ -62305,6 +62878,8 @@ const issueActiveMcpCredential = (request) => activeMcpSessionRegistry ? activeM
62305
62878
  const touchActiveMcpThread = (threadId) => activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void;
62306
62879
  const grantActiveMcpWatchThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.grantWatchThread(input) : Effect.void;
62307
62880
  const revokeActiveMcpWatchThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeWatchThread(input) : Effect.void;
62881
+ const grantActiveMcpAdviseThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.grantAdviseThread(input) : Effect.void;
62882
+ const revokeActiveMcpAdviseThread = (input) => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAdviseThread(input) : Effect.void;
62308
62883
  const revokeActiveMcpThread = (threadId) => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void;
62309
62884
  const revokeAllActiveMcpCredentials = () => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void;
62310
62885
  //#endregion
@@ -62390,10 +62965,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (options)
62390
62965
  const directory = yield* ProviderSessionDirectory;
62391
62966
  const runtimeEventPubSub = yield* PubSub.unbounded();
62392
62967
  const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
62393
- const prepareMcpSession = (threadId, providerInstanceId, watchThreadIds) => issueActiveMcpCredential({
62968
+ const prepareMcpSession = (threadId, providerInstanceId, watchThreadIds, adviseThreadIds) => issueActiveMcpCredential({
62394
62969
  threadId,
62395
62970
  providerInstanceId,
62396
- ...watchThreadIds !== void 0 ? { watchThreadIds } : {}
62971
+ ...watchThreadIds !== void 0 ? { watchThreadIds } : {},
62972
+ ...adviseThreadIds !== void 0 ? { adviseThreadIds } : {}
62397
62973
  }).pipe(Effect.tap((credential) => credential ? Effect.sync(() => setMcpProviderSession(credential.config)) : Effect.void));
62398
62974
  const clearMcpSession = (threadId) => revokeActiveMcpThread(threadId).pipe(Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))));
62399
62975
  const publishRuntimeEvent = (event) => Effect.succeed(event).pipe(Effect.tap((canonicalEvent) => canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) : Effect.void), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), Effect.asVoid);
@@ -62574,7 +63150,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (options)
62574
63150
  "provider.cwd.effective": effectiveCwd ?? ""
62575
63151
  });
62576
63152
  const adapter = yield* registry.getByInstance(resolvedInstanceId);
62577
- yield* prepareMcpSession(threadId, resolvedInstanceId, options?.watchThreadIds);
63153
+ yield* prepareMcpSession(threadId, resolvedInstanceId, options?.watchThreadIds, options?.adviseThreadIds);
62578
63154
  const session = yield* adapter.startSession({
62579
63155
  ...input,
62580
63156
  providerInstanceId: resolvedInstanceId,
@@ -100761,7 +101337,7 @@ const invokeTargeted = (operation, input, timeoutMs) => {
100761
101337
  const { tabId, ...operationInput } = input;
100762
101338
  return invoke(operation, operationInput, timeoutMs, tabId);
100763
101339
  };
100764
- const handlers$3 = {
101340
+ const handlers$4 = {
100765
101341
  preview_status: (input) => invokeTargeted("status", input ?? {}),
100766
101342
  preview_open: (input) => invokeTargeted("open", normalizePreviewOpenInput(input)),
100767
101343
  preview_navigate: (input) => invokeTargeted("navigate", input, input.timeoutMs),
@@ -100777,10 +101353,10 @@ const handlers$3 = {
100777
101353
  preview_recording_start: (input) => invokeTargeted("recordingStart", input ?? {}),
100778
101354
  preview_recording_stop: (input) => invokeTargeted("recordingStop", input ?? {})
100779
101355
  };
100780
- const { preview_snapshot, ...standardHandlers } = handlers$3;
101356
+ const { preview_snapshot, ...standardHandlers } = handlers$4;
100781
101357
  const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer(standardHandlers);
100782
101358
  const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({ preview_snapshot });
100783
- PreviewToolkit.toLayer(handlers$3);
101359
+ PreviewToolkit.toLayer(handlers$4);
100784
101360
  //#endregion
100785
101361
  //#region src/mcp/TicketResolver.ts
100786
101362
  var TicketResolver = class extends Context.Service()("@p4code/cli/mcp/TicketResolver") {};
@@ -101751,6 +102327,214 @@ const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
101751
102327
  })
101752
102328
  });
101753
102329
  //#endregion
102330
+ //#region src/mcp/toolkits/advise/tools.ts
102331
+ /**
102332
+ * The write half of cross-thread access, and the reason advise is its own
102333
+ * capability: `thread_watch_events` reads another thread, these tools steer
102334
+ * one. Both handlers admit only threads in the session's explicit
102335
+ * `adviseThreadIds` grant - in practice the paired implementer - so a
102336
+ * watch-only credential cannot reach them at all and an advise credential
102337
+ * cannot reach past its own pair.
102338
+ */
102339
+ const ThreadAdviseTool = Tool.make("thread_advise", {
102340
+ description: "Post advice into the paired builder thread. The builder sees the message as supervisor advice and responds in its own pane. Set interrupt to cancel the builder's running turn first, when continuing would waste or damage work. Do not use this while a gate is awaiting your answer - answer the gate with thread_gate_respond instead. After advising, end your turn; you are woken automatically when the builder finishes responding.",
102341
+ parameters: ThreadAdviseInput,
102342
+ success: ThreadAdviseResult,
102343
+ failure: ThreadAdviseToolError,
102344
+ dependencies: [
102345
+ McpInvocationContext,
102346
+ OrchestrationEngineService,
102347
+ ProjectionSnapshotQuery,
102348
+ Crypto.Crypto
102349
+ ]
102350
+ }).annotate(Tool.Title, "Advise the paired builder").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, false);
102351
+ const ThreadGateRespondTool = Tool.make("thread_gate_respond", {
102352
+ description: "Answer the open gate on the paired builder thread. approve clears the gate and lets the builder proceed; object delivers your objection to the builder and spends one exchange round - when the rounds are used up the gate escalates to the human instead. The gate id is given in the gate review prompt. After answering, end your turn; you are woken automatically at the next boundary.",
102353
+ parameters: ThreadGateRespondInput,
102354
+ success: ThreadGateRespondResult,
102355
+ failure: ThreadAdviseToolError,
102356
+ dependencies: [
102357
+ McpInvocationContext,
102358
+ OrchestrationEngineService,
102359
+ ProjectionSnapshotQuery,
102360
+ Crypto.Crypto
102361
+ ]
102362
+ }).annotate(Tool.Title, "Answer the open gate").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, false);
102363
+ const AdviseToolkit = Toolkit.make(ThreadAdviseTool, ThreadGateRespondTool);
102364
+ //#endregion
102365
+ //#region src/mcp/toolkits/advise/handlers.ts
102366
+ const newId = Effect.gen(function* () {
102367
+ return yield* (yield* Crypto.Crypto).randomUUIDv4.pipe(Effect.orDie);
102368
+ });
102369
+ /** Every dispatch here reports failure as the one error the toolkit declares. */
102370
+ const dispatchAdvise = Effect.fn("mcp.advise.dispatch")(function* (command, threadId) {
102371
+ yield* (yield* OrchestrationEngineService).dispatch(command).pipe(Effect.mapError((cause) => new ThreadAdviseFailedError({
102372
+ threadId,
102373
+ detail: cause.message
102374
+ })));
102375
+ });
102376
+ const readCommandModel = Effect.fn("mcp.advise.readModel")(function* (threadId) {
102377
+ return yield* (yield* ProjectionSnapshotQuery).getCommandReadModel().pipe(Effect.mapError((cause) => new ThreadAdviseFailedError({
102378
+ threadId,
102379
+ detail: cause.message
102380
+ })));
102381
+ });
102382
+ /**
102383
+ * Starts a turn on the advised thread carrying the supervisor's message. The
102384
+ * thread's own modes ride along so advice never changes how the builder runs.
102385
+ */
102386
+ const sendAdviceTurn = Effect.fn("mcp.advise.sendAdviceTurn")(function* (input) {
102387
+ const messageId = MessageId.make(yield* newId);
102388
+ yield* dispatchAdvise({
102389
+ type: "thread.turn.start",
102390
+ commandId: CommandId.make(yield* newId),
102391
+ threadId: input.thread.id,
102392
+ message: {
102393
+ messageId,
102394
+ role: "user",
102395
+ text: input.text,
102396
+ attachments: []
102397
+ },
102398
+ runtimeMode: input.thread.runtimeMode,
102399
+ interactionMode: input.thread.interactionMode,
102400
+ compressMode: input.thread.compressMode,
102401
+ unpromptedSubagents: input.thread.unpromptedSubagents,
102402
+ createdAt: input.createdAt
102403
+ }, input.thread.id);
102404
+ return messageId;
102405
+ });
102406
+ const AdviseToolkitHandlersLive = AdviseToolkit.toLayer({
102407
+ thread_advise: (input) => Effect.gen(function* () {
102408
+ yield* requireAdviseCapability(input.threadId);
102409
+ const readModel = yield* readCommandModel(input.threadId);
102410
+ const thread = readModel.threads.find((candidate) => candidate.id === input.threadId && candidate.deletedAt === null);
102411
+ if (thread === void 0) return yield* new ThreadAdviseFailedError({
102412
+ threadId: input.threadId,
102413
+ detail: "The advised thread does not exist."
102414
+ });
102415
+ const gatedPair = (readModel.threadPairs ?? []).find((pair) => pair.detachedAt === null && pair.implementerThreadId === input.threadId && pair.activeGate !== null && pair.activeGate.state === "awaiting-watcher");
102416
+ if (gatedPair !== void 0) return yield* new ThreadAdviseFailedError({
102417
+ threadId: input.threadId,
102418
+ detail: `Gate '${gatedPair.activeGate?.id}' is awaiting your answer. Answer it with thread_gate_respond instead of thread_advise.`
102419
+ });
102420
+ const createdAt = DateTime.formatIso(yield* DateTime.now);
102421
+ const interrupted = input.interrupt === true;
102422
+ if (interrupted) yield* dispatchAdvise({
102423
+ type: "thread.turn.interrupt",
102424
+ commandId: CommandId.make(yield* newId),
102425
+ threadId: input.threadId,
102426
+ createdAt
102427
+ }, input.threadId);
102428
+ const messageId = yield* sendAdviceTurn({
102429
+ thread,
102430
+ text: `${FUSION_ADVICE_PROMPT_PREFIX} ${input.message}`,
102431
+ createdAt
102432
+ });
102433
+ return {
102434
+ threadId: input.threadId,
102435
+ messageId,
102436
+ interrupted
102437
+ };
102438
+ }),
102439
+ thread_gate_respond: (input) => Effect.gen(function* () {
102440
+ yield* requireAdviseCapability(input.threadId);
102441
+ const readModel = yield* readCommandModel(input.threadId);
102442
+ const pair = (readModel.threadPairs ?? []).find((candidate) => candidate.detachedAt === null && candidate.implementerThreadId === input.threadId);
102443
+ const gate = pair?.activeGate ?? null;
102444
+ if (pair === void 0 || gate === null || gate.id !== input.gateId) return yield* new ThreadAdviseFailedError({
102445
+ threadId: input.threadId,
102446
+ detail: `Gate '${input.gateId}' is not open on this thread. It may already be resolved.`
102447
+ });
102448
+ if (gate.state !== "awaiting-watcher") return yield* new ThreadAdviseFailedError({
102449
+ threadId: input.threadId,
102450
+ detail: `Gate '${input.gateId}' is not awaiting the supervisor (state: ${gate.state}).`
102451
+ });
102452
+ const thread = readModel.threads.find((candidate) => candidate.id === input.threadId && candidate.deletedAt === null);
102453
+ if (thread === void 0) return yield* new ThreadAdviseFailedError({
102454
+ threadId: input.threadId,
102455
+ detail: "The advised thread does not exist."
102456
+ });
102457
+ const now = DateTime.formatIso(yield* DateTime.now);
102458
+ if (input.decision === "approve") {
102459
+ yield* dispatchAdvise({
102460
+ type: "thread-pair.gate.resolve",
102461
+ commandId: CommandId.make(yield* newId),
102462
+ pairId: pair.id,
102463
+ gateId: gate.id,
102464
+ outcome: "approved",
102465
+ resolvedBy: "watcher",
102466
+ resolvedAt: now
102467
+ }, input.threadId);
102468
+ if (gate.kind === "command-approval" && gate.requestId !== null) yield* dispatchAdvise({
102469
+ type: "thread.approval.respond",
102470
+ commandId: CommandId.make(yield* newId),
102471
+ threadId: input.threadId,
102472
+ requestId: gate.requestId,
102473
+ decision: "accept",
102474
+ createdAt: now
102475
+ }, input.threadId);
102476
+ if (gate.kind === "repeated-failure") yield* sendAdviceTurn({
102477
+ thread,
102478
+ text: `${FUSION_GATE_PROMPT_PREFIX} The supervisor reviewed the repeated command failures and cleared the gate. Continue the work.`,
102479
+ createdAt: now
102480
+ });
102481
+ return {
102482
+ threadId: input.threadId,
102483
+ gateId: gate.id,
102484
+ kind: gate.kind,
102485
+ outcome: "approved"
102486
+ };
102487
+ }
102488
+ if (input.message === void 0) return yield* new ThreadAdviseFailedError({
102489
+ threadId: input.threadId,
102490
+ detail: "An objection requires a message for the builder."
102491
+ });
102492
+ if (gate.round >= pair.roundCap) {
102493
+ yield* dispatchAdvise({
102494
+ type: "thread-pair.gate.advance",
102495
+ commandId: CommandId.make(yield* newId),
102496
+ pairId: pair.id,
102497
+ gateId: gate.id,
102498
+ transition: "escalate",
102499
+ advancedAt: now
102500
+ }, input.threadId);
102501
+ return {
102502
+ threadId: input.threadId,
102503
+ gateId: gate.id,
102504
+ kind: gate.kind,
102505
+ outcome: "escalated"
102506
+ };
102507
+ }
102508
+ yield* dispatchAdvise({
102509
+ type: "thread-pair.gate.advance",
102510
+ commandId: CommandId.make(yield* newId),
102511
+ pairId: pair.id,
102512
+ gateId: gate.id,
102513
+ transition: "objection",
102514
+ advancedAt: now
102515
+ }, input.threadId);
102516
+ if (gate.kind === "command-approval" && gate.requestId !== null) yield* dispatchAdvise({
102517
+ type: "thread.approval.respond",
102518
+ commandId: CommandId.make(yield* newId),
102519
+ threadId: input.threadId,
102520
+ requestId: gate.requestId,
102521
+ decision: "decline",
102522
+ createdAt: now
102523
+ }, input.threadId);
102524
+ yield* sendAdviceTurn({
102525
+ thread,
102526
+ text: `${FUSION_ADVICE_PROMPT_PREFIX} ${input.message}`,
102527
+ createdAt: now
102528
+ });
102529
+ return {
102530
+ threadId: input.threadId,
102531
+ gateId: gate.id,
102532
+ kind: gate.kind,
102533
+ outcome: "objection-sent"
102534
+ };
102535
+ })
102536
+ });
102537
+ //#endregion
101754
102538
  //#region src/mcp/toolkits/watch/tools.ts
101755
102539
  /**
101756
102540
  * Cross-thread by design, which is exactly why it is the most guarded tool in
@@ -101891,12 +102675,13 @@ const PreviewToolkitRegistrationLive = Layer.mergeAll(PreviewStandardToolkitRegi
101891
102675
  const TaskToolkitRegistrationLive = McpServer.toolkit(TaskToolkit).pipe(Layer.provide(TaskToolkitHandlersLive));
101892
102676
  const ThreadToolkitRegistrationLive = McpServer.toolkit(ThreadToolkit).pipe(Layer.provide(ThreadToolkitHandlersLive));
101893
102677
  const WatchToolkitRegistrationLive = McpServer.toolkit(WatchToolkit).pipe(Layer.provide(WatchToolkitHandlersLive));
102678
+ const AdviseToolkitRegistrationLive = McpServer.toolkit(AdviseToolkit).pipe(Layer.provide(AdviseToolkitHandlersLive));
101894
102679
  const McpTransportLive = McpServer.layerHttp({
101895
102680
  name: "P4Code",
101896
102681
  version,
101897
102682
  path: "/mcp"
101898
102683
  }).pipe(Layer.provide(McpAuthMiddlewareLive));
101899
- const layer = Layer.mergeAll(PreviewToolkitRegistrationLive, TaskToolkitRegistrationLive, ThreadToolkitRegistrationLive, WatchToolkitRegistrationLive).pipe(Layer.provideMerge(McpTransportLive));
102684
+ const layer = Layer.mergeAll(PreviewToolkitRegistrationLive, TaskToolkitRegistrationLive, ThreadToolkitRegistrationLive, WatchToolkitRegistrationLive, AdviseToolkitRegistrationLive).pipe(Layer.provideMerge(McpTransportLive));
101900
102685
  //#endregion
101901
102686
  //#region src/orchestration/Services/CheckpointReactor.ts
101902
102687
  /**
@@ -102526,6 +103311,7 @@ function runtimeEventToActivities(event, taskTitle, compressMode) {
102526
103311
  summary: event.payload.title ?? "Tool",
102527
103312
  payload: {
102528
103313
  itemType: event.payload.itemType,
103314
+ ...event.payload.status ? { status: event.payload.status } : {},
102529
103315
  ...event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {},
102530
103316
  ...event.payload.data !== void 0 ? { data: event.payload.data } : {}
102531
103317
  },
@@ -103559,6 +104345,10 @@ const make$3 = Effect.gen(function* () {
103559
104345
  watcherThreadId: threadId,
103560
104346
  watchedThreadId
103561
104347
  }), { discard: true });
104348
+ yield* Effect.forEach(watchThreadIds, (advisedThreadId) => grantActiveMcpAdviseThread({
104349
+ watcherThreadId: threadId,
104350
+ advisedThreadId
104351
+ }), { discard: true });
103562
104352
  const resolveActiveSession = (threadId) => providerService.listSessions().pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)));
103563
104353
  const activeSession = yield* resolveActiveSession(threadId);
103564
104354
  const activeThreadSession = thread.session !== null && thread.session.status !== "stopped" && activeSession ? thread.session : null;
@@ -103648,7 +104438,10 @@ const make$3 = Effect.gen(function* () {
103648
104438
  runtimeMode: desiredRuntimeMode,
103649
104439
  compressMode: thread.compressMode,
103650
104440
  unpromptedSubagents: thread.unpromptedSubagents
103651
- }, watchThreadIds.length > 0 ? { watchThreadIds } : void 0);
104441
+ }, watchThreadIds.length > 0 ? {
104442
+ watchThreadIds,
104443
+ adviseThreadIds: watchThreadIds
104444
+ } : void 0);
103652
104445
  };
103653
104446
  const bindSessionToThread = (session) => Effect.gen(function* () {
103654
104447
  if (session.providerInstanceId === void 0) return yield* new ProviderAdapterRequestError({
@@ -104581,9 +105374,28 @@ const make$2 = Effect.gen(function* () {
104581
105374
  const CheckpointReactorLive = Layer.effect(CheckpointReactor, make$2);
104582
105375
  //#endregion
104583
105376
  //#region src/orchestration/Layers/FusionWatcherReactor.ts
105377
+ const GATE_TIMEOUT_SWEEP_INTERVAL = "10 seconds";
104584
105378
  const reviewCommandId = (pairId, sequence) => CommandId.make(`server:fusion:${pairId}:review:${sequence}`);
104585
105379
  const cursorCommandId = (pairId, sequence) => CommandId.make(`server:fusion:${pairId}:cursor:${sequence}`);
104586
105380
  const reviewMessageId = (pairId, sequence) => MessageId.make(`fusion-review:${pairId}:${sequence}`);
105381
+ const gateCommandId = (pairId, gateId, suffix) => CommandId.make(`server:fusion:${pairId}:gate:${gateId}:${suffix}`);
105382
+ const gateMessageId = (gateId, round) => MessageId.make(`fusion-gate:${gateId}:wake:${round}`);
105383
+ const gateActivityId = (gateId, threadId, suffix) => EventId.make(`fusion-gate:${gateId}:${suffix}:${threadId}`);
105384
+ /**
105385
+ * What the watcher can do, spelled out in every wake. The instructions repeat
105386
+ * per prompt because the watcher has no separate system prompt: these messages
105387
+ * are the only place its powers and its stop condition are stated. The stop
105388
+ * condition matters as much as the powers - a watcher that polls
105389
+ * thread_watch_events waiting for new activity burns its turn spinning, which
105390
+ * is exactly the "supervisor stuck working" failure this text exists to
105391
+ * prevent.
105392
+ */
105393
+ const watcherPowers = (implementerThreadId) => `You can steer the builder:
105394
+
105395
+ - thread_advise with threadId ${implementerThreadId} posts advice into the builder thread. Use it when the builder should change course, and whenever the user asks you to tell, instruct or guide the builder.
105396
+ - thread_advise with interrupt: true cancels the builder's running turn before the advice lands. Reserve it for scope drift or work that is causing damage right now.
105397
+
105398
+ Never call thread_watch_events repeatedly to wait for new activity, and never wait for the builder to respond: deliver your review and any advice, then end your turn. The server wakes you at the next turn boundary.`;
104587
105399
  const watcherPrompt = (input) => `${FUSION_REVIEW_PROMPT_PREFIX}
104588
105400
  Review builder thread ${input.implementerThreadId} after its accepted turn completion.
104589
105401
 
@@ -104595,14 +105407,361 @@ Give the user a concise status report after every review:
104595
105407
  - Verification: summarize checks run and their results, including missing verification.
104596
105408
  - Assessment: list concrete objections such as correctness risks, missed requirements, regressions, unsafe changes, or unnecessary scope. Cite evidence. If none exist, say "No objections found."
104597
105409
 
105410
+ ${watcherPowers(input.implementerThreadId)}
105411
+
104598
105412
  Mention blockers or unfinished work explicitly. Do not work silently. Do not return only ${FUSION_NO_OBJECTION_TEXT}.`;
105413
+ const gateKindDescription = (gate) => {
105414
+ switch (gate.kind) {
105415
+ case "plan": return "the builder finished a proposed plan and is paused for your review before work starts";
105416
+ case "command-approval": return `the builder raised an approval request (request ${gate.requestId ?? "unknown"}) and its turn is blocked until it is answered`;
105417
+ case "repeated-failure": return `the same command failed 3 times (${gate.commandTitle ?? "unknown command"}) and the builder's turn was interrupted for your review`;
105418
+ }
105419
+ };
105420
+ const gatePrompt = (input) => `${FUSION_GATE_PROMPT_PREFIX}
105421
+ Gate ${input.gate.id} is open on builder thread ${input.implementerThreadId}: ${gateKindDescription(input.gate)}. Round ${Math.min(input.gate.round + 1, input.roundCap)} of ${input.roundCap}.
105422
+
105423
+ Read the delta first: call thread_watch_events with threadId ${input.implementerThreadId} and afterSequence ${input.afterSequence}, paging through sequence ${input.throughSequence}. Inspect the repository when useful.
105424
+
105425
+ Then answer with thread_gate_respond, threadId ${input.implementerThreadId}, gateId ${input.gate.id}:
105426
+
105427
+ - decision "approve" clears the gate and lets the builder proceed.
105428
+ - decision "object" with a message delivers your objection to the builder and spends one exchange round. After ${input.roundCap} objections the gate escalates to the user instead.
105429
+
105430
+ If you do not answer within ${Math.round(input.gateTimeoutMs / 1e3)} seconds the gate fails open and is recorded as passed unwatched. Answer the gate, tell the user your reasoning in a few sentences, then end your turn - do not wait for the builder.`;
104599
105431
  const make$1 = Effect.gen(function* () {
104600
105432
  const orchestrationEngine = yield* OrchestrationEngineService;
104601
105433
  const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
105434
+ /**
105435
+ * Consecutive identical command failures per implementer thread. In-memory
105436
+ * on purpose: the counter's turn dies with the server anyway, and the cost
105437
+ * of losing it is one extra failed attempt after a restart, not a stuck
105438
+ * approval.
105439
+ */
105440
+ const repeatedFailures = /* @__PURE__ */ new Map();
105441
+ /**
105442
+ * Gate detection and human-override resolution act on the CURRENT persisted
105443
+ * gate state, so replaying them against historical events is wrong twice
105444
+ * over: an old plan event would open a fresh gate, and an old user message
105445
+ * would resolve a gate that opened long after it. They therefore run only
105446
+ * for events sequenced after the head observed at startup. The replay-safe
105447
+ * paths (reviews, gate wakes, activities) keep their deterministic command
105448
+ * ids instead.
105449
+ */
105450
+ let liveEventsAfterSequence = Number.MAX_SAFE_INTEGER;
105451
+ const readPairs = Effect.gen(function* () {
105452
+ const readModel = yield* projectionSnapshotQuery.getCommandReadModel();
105453
+ return {
105454
+ readModel,
105455
+ activePairs: (readModel.threadPairs ?? []).filter((pair) => pair.detachedAt === null)
105456
+ };
105457
+ });
105458
+ const appendGateActivity = Effect.fn("FusionWatcherReactor.appendGateActivity")(function* (input) {
105459
+ yield* orchestrationEngine.dispatch({
105460
+ type: "thread.activity.append",
105461
+ commandId: CommandId.make(`server:fusion:gate:${input.gateId}:${input.suffix}:${input.threadId}`),
105462
+ threadId: input.threadId,
105463
+ activity: {
105464
+ id: gateActivityId(input.gateId, input.threadId, input.suffix),
105465
+ tone: input.tone,
105466
+ kind: input.kind,
105467
+ summary: input.summary,
105468
+ payload: input.payload,
105469
+ turnId: null,
105470
+ createdAt: input.createdAt
105471
+ },
105472
+ createdAt: input.createdAt
105473
+ });
105474
+ });
105475
+ const appendGateActivityToBoth = Effect.fn("FusionWatcherReactor.appendGateActivityToBoth")(function* (input) {
105476
+ yield* appendGateActivity({
105477
+ ...input,
105478
+ threadId: input.pair.implementerThreadId
105479
+ });
105480
+ yield* appendGateActivity({
105481
+ ...input,
105482
+ threadId: input.pair.watcherThreadId
105483
+ });
105484
+ });
105485
+ /** Starts one watcher turn for the open gate and advances the pair cursor. */
105486
+ const wakeWatcherForGate = Effect.fn("FusionWatcherReactor.wakeWatcherForGate")(function* (event) {
105487
+ const gate = event.payload.gate;
105488
+ if (gate.state !== "awaiting-watcher") return;
105489
+ const { readModel, activePairs } = yield* readPairs;
105490
+ const pair = activePairs.find((candidate) => candidate.id === event.payload.pairId);
105491
+ if (pair === void 0) return;
105492
+ const watcher = readModel.threads.find((thread) => thread.id === pair.watcherThreadId && thread.deletedAt === null);
105493
+ if (watcher === void 0) return;
105494
+ yield* orchestrationEngine.dispatch({
105495
+ type: "thread.turn.start",
105496
+ commandId: gateCommandId(pair.id, gate.id, `wake:${gate.round}`),
105497
+ threadId: watcher.id,
105498
+ message: {
105499
+ messageId: gateMessageId(gate.id, gate.round),
105500
+ role: "user",
105501
+ text: gatePrompt({
105502
+ implementerThreadId: pair.implementerThreadId,
105503
+ gate,
105504
+ roundCap: pair.roundCap,
105505
+ gateTimeoutMs: pair.gateTimeoutMs,
105506
+ afterSequence: pair.lastReviewedImplementerSequence,
105507
+ throughSequence: event.sequence
105508
+ }),
105509
+ attachments: []
105510
+ },
105511
+ runtimeMode: watcher.runtimeMode,
105512
+ interactionMode: watcher.interactionMode,
105513
+ compressMode: watcher.compressMode,
105514
+ unpromptedSubagents: watcher.unpromptedSubagents,
105515
+ createdAt: event.payload.gate.updatedAt
105516
+ });
105517
+ if (event.sequence > pair.lastReviewedImplementerSequence) yield* orchestrationEngine.dispatch({
105518
+ type: "thread-pair.cursor.advance",
105519
+ commandId: cursorCommandId(pair.id, event.sequence),
105520
+ pairId: pair.id,
105521
+ implementerSequence: event.sequence,
105522
+ advancedAt: event.payload.gate.updatedAt
105523
+ });
105524
+ });
105525
+ const processGateOpened = Effect.fn("FusionWatcherReactor.processGateOpened")(function* (event) {
105526
+ const gate = event.payload.gate;
105527
+ yield* appendGateActivityToBoth({
105528
+ pair: event.payload,
105529
+ gateId: gate.id,
105530
+ suffix: "opened",
105531
+ tone: "approval",
105532
+ kind: "fusion.gate.opened",
105533
+ summary: gate.kind === "plan" ? "Gate opened: plan review" : gate.kind === "command-approval" ? "Gate opened: approval review" : "Gate opened: repeated command failures",
105534
+ payload: {
105535
+ pairId: event.payload.pairId,
105536
+ gateId: gate.id,
105537
+ gateKind: gate.kind,
105538
+ ...gate.requestId !== null ? { requestId: gate.requestId } : {},
105539
+ ...gate.commandTitle !== null ? { commandTitle: gate.commandTitle } : {}
105540
+ },
105541
+ createdAt: event.payload.openedAt
105542
+ });
105543
+ yield* wakeWatcherForGate(event);
105544
+ });
105545
+ const processGateAdvanced = Effect.fn("FusionWatcherReactor.processGateAdvanced")(function* (event) {
105546
+ const gate = event.payload.gate;
105547
+ if (gate.state === "escalated") {
105548
+ yield* appendGateActivityToBoth({
105549
+ pair: event.payload,
105550
+ gateId: gate.id,
105551
+ suffix: "escalated",
105552
+ tone: "approval",
105553
+ kind: "fusion.gate.escalated",
105554
+ summary: "Gate escalated: the builder and supervisor did not converge",
105555
+ payload: {
105556
+ pairId: event.payload.pairId,
105557
+ gateId: gate.id,
105558
+ gateKind: gate.kind,
105559
+ round: gate.round
105560
+ },
105561
+ createdAt: event.payload.advancedAt
105562
+ });
105563
+ return;
105564
+ }
105565
+ yield* wakeWatcherForGate(event);
105566
+ });
105567
+ const processGateResolved = Effect.fn("FusionWatcherReactor.processGateResolved")(function* (event) {
105568
+ const { outcome, resolvedBy, kind, requestId } = event.payload;
105569
+ const unwatched = outcome === "unwatched";
105570
+ yield* appendGateActivityToBoth({
105571
+ pair: event.payload,
105572
+ gateId: event.payload.gateId,
105573
+ suffix: `resolved:${outcome}`,
105574
+ tone: unwatched ? "error" : "approval",
105575
+ kind: unwatched ? "fusion.gate.unwatched" : "fusion.gate.resolved",
105576
+ summary: unwatched ? FUSION_GATE_UNWATCHED_SUMMARY : outcome === "approved" ? "Gate approved by the supervisor" : "Gate answered by the user",
105577
+ payload: {
105578
+ pairId: event.payload.pairId,
105579
+ gateId: event.payload.gateId,
105580
+ gateKind: kind,
105581
+ outcome,
105582
+ resolvedBy
105583
+ },
105584
+ createdAt: event.payload.resolvedAt
105585
+ });
105586
+ if (outcome !== "unwatched" && outcome !== "human") return;
105587
+ if (kind === "command-approval" && requestId !== null && unwatched) yield* orchestrationEngine.dispatch({
105588
+ type: "thread.approval.respond",
105589
+ commandId: gateCommandId(event.payload.pairId, event.payload.gateId, "timeout-accept"),
105590
+ threadId: event.payload.implementerThreadId,
105591
+ requestId: ApprovalRequestId.make(requestId),
105592
+ decision: "accept",
105593
+ createdAt: event.payload.resolvedAt
105594
+ });
105595
+ if (kind === "repeated-failure") {
105596
+ const { readModel } = yield* readPairs;
105597
+ const implementer = readModel.threads.find((thread) => thread.id === event.payload.implementerThreadId && thread.deletedAt === null);
105598
+ if (implementer === void 0) return;
105599
+ if (outcome === "human" && implementer.latestTurn?.state === "running") return;
105600
+ yield* orchestrationEngine.dispatch({
105601
+ type: "thread.turn.start",
105602
+ commandId: gateCommandId(event.payload.pairId, event.payload.gateId, "unblock-continue"),
105603
+ threadId: implementer.id,
105604
+ message: {
105605
+ messageId: MessageId.make(`fusion-gate:${event.payload.gateId}:unblock-continue`),
105606
+ role: "user",
105607
+ text: unwatched ? `${FUSION_GATE_PROMPT_PREFIX} The supervisor did not answer the repeated-failure gate within the timeout. The gate passed unwatched. Continue the work.` : `${FUSION_GATE_PROMPT_PREFIX} The user skipped the repeated-failure gate. Continue the work.`,
105608
+ attachments: []
105609
+ },
105610
+ runtimeMode: implementer.runtimeMode,
105611
+ interactionMode: implementer.interactionMode,
105612
+ compressMode: implementer.compressMode,
105613
+ unpromptedSubagents: implementer.unpromptedSubagents,
105614
+ createdAt: event.payload.resolvedAt
105615
+ });
105616
+ }
105617
+ });
105618
+ const openGate = Effect.fn("FusionWatcherReactor.openGate")(function* (input) {
105619
+ yield* orchestrationEngine.dispatch({
105620
+ type: "thread-pair.gate.open",
105621
+ commandId: CommandId.make(`server:fusion:${input.pair.id}:gate-open:${input.sequence}`),
105622
+ pairId: input.pair.id,
105623
+ gateId: ThreadPairGateId.make(`${input.pair.id}:g${input.sequence}`),
105624
+ kind: input.kind,
105625
+ ...input.requestId !== void 0 ? { requestId: ApprovalRequestId.make(input.requestId) } : {},
105626
+ ...input.planId !== void 0 ? { planId: input.planId } : {},
105627
+ ...input.commandTitle !== void 0 ? { commandTitle: input.commandTitle } : {},
105628
+ openedAt: input.occurredAt
105629
+ });
105630
+ });
105631
+ const processPlanUpserted = Effect.fn("FusionWatcherReactor.processPlanUpserted")(function* (event) {
105632
+ if (event.sequence <= liveEventsAfterSequence) return;
105633
+ const { activePairs } = yield* readPairs;
105634
+ const pair = activePairs.find((candidate) => candidate.implementerThreadId === event.payload.threadId);
105635
+ if (pair === void 0 || pair.activeGate !== null) return;
105636
+ yield* openGate({
105637
+ pair,
105638
+ sequence: event.sequence,
105639
+ kind: "plan",
105640
+ occurredAt: event.occurredAt,
105641
+ planId: event.payload.proposedPlan.id
105642
+ });
105643
+ });
105644
+ const activityRequestId = (payload) => {
105645
+ if (typeof payload !== "object" || payload === null) return void 0;
105646
+ const requestId = payload["requestId"];
105647
+ return typeof requestId === "string" && requestId.length > 0 ? requestId : void 0;
105648
+ };
105649
+ const processActivityAppended = Effect.fn("FusionWatcherReactor.processActivityAppended")(function* (event) {
105650
+ if (event.sequence <= liveEventsAfterSequence) return;
105651
+ const activity = event.payload.activity;
105652
+ const { activePairs } = yield* readPairs;
105653
+ const pair = activePairs.find((candidate) => candidate.implementerThreadId === event.payload.threadId);
105654
+ if (pair === void 0) return;
105655
+ if (activity.kind === "approval.requested") {
105656
+ if (pair.activeGate !== null) return;
105657
+ const requestId = activityRequestId(activity.payload);
105658
+ if (requestId === void 0) return;
105659
+ yield* openGate({
105660
+ pair,
105661
+ sequence: event.sequence,
105662
+ kind: "command-approval",
105663
+ occurredAt: event.occurredAt,
105664
+ requestId
105665
+ });
105666
+ return;
105667
+ }
105668
+ if (activity.kind === "approval.resolved") {
105669
+ const requestId = activityRequestId(activity.payload);
105670
+ const gate = pair.activeGate;
105671
+ if (gate === null || gate.kind !== "command-approval" || requestId === void 0 || gate.requestId !== requestId) return;
105672
+ yield* orchestrationEngine.dispatch({
105673
+ type: "thread-pair.gate.resolve",
105674
+ commandId: gateCommandId(pair.id, gate.id, `external-resolve:${event.sequence}`),
105675
+ pairId: pair.id,
105676
+ gateId: gate.id,
105677
+ outcome: "human",
105678
+ resolvedBy: "human",
105679
+ resolvedAt: event.occurredAt
105680
+ });
105681
+ return;
105682
+ }
105683
+ if (activity.kind === "tool.completed") {
105684
+ const payload = typeof activity.payload === "object" && activity.payload !== null ? activity.payload : void 0;
105685
+ if (payload?.["itemType"] !== "command_execution") return;
105686
+ const key = activity.summary;
105687
+ if (payload["status"] !== "failed") {
105688
+ repeatedFailures.delete(event.payload.threadId);
105689
+ return;
105690
+ }
105691
+ const previous = repeatedFailures.get(event.payload.threadId);
105692
+ const count = previous !== void 0 && previous.key === key ? previous.count + 1 : 1;
105693
+ repeatedFailures.set(event.payload.threadId, {
105694
+ key,
105695
+ count
105696
+ });
105697
+ if (count < 3 || pair.activeGate !== null) return;
105698
+ repeatedFailures.delete(event.payload.threadId);
105699
+ yield* orchestrationEngine.dispatch({
105700
+ type: "thread.turn.interrupt",
105701
+ commandId: CommandId.make(`server:fusion:${pair.id}:gate-interrupt:${event.sequence}`),
105702
+ threadId: pair.implementerThreadId,
105703
+ createdAt: event.occurredAt
105704
+ });
105705
+ yield* openGate({
105706
+ pair,
105707
+ sequence: event.sequence,
105708
+ kind: "repeated-failure",
105709
+ occurredAt: event.occurredAt,
105710
+ commandTitle: key
105711
+ });
105712
+ }
105713
+ });
105714
+ const processApprovalResponseRequested = Effect.fn("FusionWatcherReactor.processApprovalResponseRequested")(function* (event) {
105715
+ if (event.sequence <= liveEventsAfterSequence) return;
105716
+ const { activePairs } = yield* readPairs;
105717
+ const pair = activePairs.find((candidate) => candidate.implementerThreadId === event.payload.threadId);
105718
+ const gate = pair?.activeGate ?? null;
105719
+ if (pair === void 0 || gate === null || gate.kind !== "command-approval" || gate.requestId !== event.payload.requestId) return;
105720
+ yield* orchestrationEngine.dispatch({
105721
+ type: "thread-pair.gate.resolve",
105722
+ commandId: gateCommandId(pair.id, gate.id, `respond-resolve:${event.sequence}`),
105723
+ pairId: pair.id,
105724
+ gateId: gate.id,
105725
+ outcome: "human",
105726
+ resolvedBy: "human",
105727
+ resolvedAt: event.payload.createdAt
105728
+ });
105729
+ });
105730
+ const isFusionProtocolText = (text) => text.startsWith("[fusion-review]") || text.startsWith("[fusion-gate]") || text.startsWith("[fusion-advice]");
105731
+ const processMessageSent = Effect.fn("FusionWatcherReactor.processMessageSent")(function* (event) {
105732
+ if (event.sequence <= liveEventsAfterSequence) return;
105733
+ if (event.payload.role !== "user") return;
105734
+ if (isFusionProtocolText(event.payload.text)) return;
105735
+ const { activePairs } = yield* readPairs;
105736
+ const pair = activePairs.find((candidate) => candidate.implementerThreadId === event.payload.threadId);
105737
+ const gate = pair?.activeGate ?? null;
105738
+ if (pair === void 0 || gate === null) return;
105739
+ yield* orchestrationEngine.dispatch({
105740
+ type: "thread-pair.gate.resolve",
105741
+ commandId: gateCommandId(pair.id, gate.id, `human-message:${event.sequence}`),
105742
+ pairId: pair.id,
105743
+ gateId: gate.id,
105744
+ outcome: "human",
105745
+ resolvedBy: "human",
105746
+ resolvedAt: event.payload.createdAt
105747
+ });
105748
+ });
104602
105749
  const processReview = Effect.fn("FusionWatcherReactor.processReview")(function* (pair, completion) {
104603
105750
  const readModel = yield* projectionSnapshotQuery.getCommandReadModel();
104604
105751
  const currentPair = (readModel.threadPairs ?? []).find((candidate) => candidate.id === pair.id);
104605
105752
  if (currentPair === void 0 || currentPair.detachedAt !== null || completion.sequence <= currentPair.lastReviewedImplementerSequence) return;
105753
+ const gate = currentPair.activeGate;
105754
+ if (gate !== null) {
105755
+ if (gate.state === "awaiting-implementer") yield* orchestrationEngine.dispatch({
105756
+ type: "thread-pair.gate.advance",
105757
+ commandId: gateCommandId(currentPair.id, gate.id, `responded:${completion.sequence}`),
105758
+ pairId: currentPair.id,
105759
+ gateId: gate.id,
105760
+ transition: "implementer-responded",
105761
+ advancedAt: completion.occurredAt
105762
+ });
105763
+ return;
105764
+ }
104606
105765
  const watcher = readModel.threads.find((thread) => thread.id === currentPair.watcherThreadId && thread.deletedAt === null);
104607
105766
  if (watcher === void 0) return;
104608
105767
  yield* orchestrationEngine.dispatch({
@@ -104645,42 +105804,98 @@ const make$1 = Effect.gen(function* () {
104645
105804
  discard: true
104646
105805
  });
104647
105806
  });
104648
- const processEvent = Effect.fn("FusionWatcherReactor.processEvent")(function* (event) {
104649
- if (event.type === "thread.turn-completed") {
104650
- yield* processCompletion(event);
104651
- return;
104652
- }
104653
- if (event.type === "thread-pair.created") {
104654
- yield* grantActiveMcpWatchThread({
104655
- watcherThreadId: event.payload.watcherThreadId,
104656
- watchedThreadId: event.payload.implementerThreadId
105807
+ /**
105808
+ * The fail-open timeout, enforced against the persisted gate rather than
105809
+ * any in-memory state, so a server restart mid-gate resumes the countdown
105810
+ * instead of stranding the run.
105811
+ */
105812
+ const sweepGateTimeouts = Effect.gen(function* () {
105813
+ const now = yield* Clock.currentTimeMillis;
105814
+ const { activePairs } = yield* readPairs;
105815
+ for (const pair of activePairs) {
105816
+ const gate = pair.activeGate;
105817
+ if (gate === null || gate.state !== "awaiting-watcher") continue;
105818
+ if (gate.awaitingWatcherSince === null) continue;
105819
+ const since = Date.parse(gate.awaitingWatcherSince);
105820
+ if (Number.isNaN(since) || now - since < pair.gateTimeoutMs) continue;
105821
+ yield* orchestrationEngine.dispatch({
105822
+ type: "thread-pair.gate.resolve",
105823
+ commandId: gateCommandId(pair.id, gate.id, `timeout:${gate.round}`),
105824
+ pairId: pair.id,
105825
+ gateId: gate.id,
105826
+ outcome: "unwatched",
105827
+ resolvedBy: "timeout",
105828
+ resolvedAt: new Date(now).toISOString()
104657
105829
  });
104658
- return;
104659
105830
  }
104660
- const pair = ((yield* projectionSnapshotQuery.getCommandReadModel()).threadPairs ?? []).find((candidate) => candidate.id === event.payload.pairId);
104661
- if (pair === void 0) return;
104662
- yield* revokeActiveMcpWatchThread({
104663
- watcherThreadId: pair.watcherThreadId,
104664
- watchedThreadId: pair.implementerThreadId
104665
- });
105831
+ });
105832
+ const FUSION_REACTOR_EVENT_TYPES = /* @__PURE__ */ new Set([
105833
+ "thread.turn-completed",
105834
+ "thread.proposed-plan-upserted",
105835
+ "thread.activity-appended",
105836
+ "thread.approval-response-requested",
105837
+ "thread.message-sent",
105838
+ "thread-pair.created",
105839
+ "thread-pair.detached",
105840
+ "thread-pair.gate-opened",
105841
+ "thread-pair.gate-advanced",
105842
+ "thread-pair.gate-resolved"
105843
+ ]);
105844
+ const processEvent = Effect.fn("FusionWatcherReactor.processEvent")(function* (event) {
105845
+ switch (event.type) {
105846
+ case "thread.turn-completed": return yield* processCompletion(event);
105847
+ case "thread.proposed-plan-upserted": return yield* processPlanUpserted(event);
105848
+ case "thread.activity-appended": return yield* processActivityAppended(event);
105849
+ case "thread.approval-response-requested": return yield* processApprovalResponseRequested(event);
105850
+ case "thread.message-sent": return yield* processMessageSent(event);
105851
+ case "thread-pair.gate-opened": return yield* processGateOpened(event);
105852
+ case "thread-pair.gate-advanced": return yield* processGateAdvanced(event);
105853
+ case "thread-pair.gate-resolved": return yield* processGateResolved(event);
105854
+ case "thread-pair.created":
105855
+ yield* grantActiveMcpWatchThread({
105856
+ watcherThreadId: event.payload.watcherThreadId,
105857
+ watchedThreadId: event.payload.implementerThreadId
105858
+ });
105859
+ yield* grantActiveMcpAdviseThread({
105860
+ watcherThreadId: event.payload.watcherThreadId,
105861
+ advisedThreadId: event.payload.implementerThreadId
105862
+ });
105863
+ return;
105864
+ case "thread-pair.detached": {
105865
+ const pair = ((yield* projectionSnapshotQuery.getCommandReadModel()).threadPairs ?? []).find((candidate) => candidate.id === event.payload.pairId);
105866
+ if (pair === void 0) return;
105867
+ yield* revokeActiveMcpWatchThread({
105868
+ watcherThreadId: pair.watcherThreadId,
105869
+ watchedThreadId: pair.implementerThreadId
105870
+ });
105871
+ yield* revokeActiveMcpAdviseThread({
105872
+ watcherThreadId: pair.watcherThreadId,
105873
+ advisedThreadId: pair.implementerThreadId
105874
+ });
105875
+ return;
105876
+ }
105877
+ }
104666
105878
  });
104667
105879
  const processSafely = (event) => processEvent(event).pipe(Effect.catchCause((cause) => {
104668
105880
  if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
104669
- return Effect.logWarning("fusion watcher reactor failed to process completion", {
105881
+ return Effect.logWarning("fusion watcher reactor failed to process event", {
104670
105882
  eventType: event.type,
104671
105883
  sequence: event.sequence,
104672
105884
  cause: Cause.pretty(cause)
104673
105885
  });
104674
105886
  }));
104675
105887
  const worker = yield* makeDrainableWorker(processSafely);
104676
- const enqueueEvent = (event) => event.type === "thread.turn-completed" || event.type === "thread-pair.created" || event.type === "thread-pair.detached" ? worker.enqueue(event) : Effect.void;
105888
+ const enqueueEvent = (event) => FUSION_REACTOR_EVENT_TYPES.has(event.type) ? worker.enqueue(event) : Effect.void;
104677
105889
  return {
104678
105890
  start: Effect.fn("FusionWatcherReactor.start")(function* () {
104679
105891
  yield* Effect.forkScoped(Stream.runForEach(orchestrationEngine.streamDomainEvents, enqueueEvent));
105892
+ yield* Effect.forkScoped(sweepGateTimeouts.pipe(Effect.catchCause((cause) => Effect.logWarning("fusion gate timeout sweep failed", { cause: Cause.pretty(cause) })), Effect.repeat(Schedule.spaced(GATE_TIMEOUT_SWEEP_INTERVAL))));
104680
105893
  const headSequence = yield* orchestrationEngine.latestSequence;
105894
+ liveEventsAfterSequence = headSequence;
104681
105895
  yield* Stream.runForEach(orchestrationEngine.readEvents(0, Math.max(1, headSequence)), enqueueEvent).pipe(Effect.catchCause((cause) => Effect.logWarning("fusion watcher reactor failed historical replay", { cause: Cause.pretty(cause) })));
104682
105896
  }),
104683
- drain: worker.drain
105897
+ drain: worker.drain,
105898
+ sweepGates: sweepGateTimeouts.pipe(Effect.catchCause((cause) => Effect.logWarning("fusion gate timeout sweep failed", { cause: Cause.pretty(cause) })))
104684
105899
  };
104685
105900
  });
104686
105901
  const FusionWatcherReactorLive = Layer.effect(FusionWatcherReactor, make$1);