@p4code/cli 0.3.21 → 0.3.22

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
@@ -96,6 +96,7 @@ import * as RcMap from "effect/RcMap";
96
96
  import { FileFinder } from "@ff-labs/fff-node";
97
97
  import * as LayerMap from "effect/LayerMap";
98
98
  import * as Logger from "effect/Logger";
99
+ import * as PartitionedSemaphore from "effect/PartitionedSemaphore";
99
100
  import * as NodeURL from "node:url";
100
101
  import { createOpencodeClient } from "@opencode-ai/sdk/v2";
101
102
  import { query } from "@anthropic-ai/claude-agent-sdk";
@@ -238,7 +239,7 @@ const make$91 = () => {
238
239
  const layer$82 = Layer.sync(NetService, make$91);
239
240
  //#endregion
240
241
  //#region package.json
241
- var version = "0.3.21";
242
+ var version = "0.3.22";
242
243
  //#endregion
243
244
  //#region src/config.ts
244
245
  /**
@@ -1853,6 +1854,13 @@ const FUSION_NO_OBJECTION_TEXT = "[fusion-no-objection]";
1853
1854
  const FUSION_ADVICE_PROMPT_PREFIX = "[fusion-advice]";
1854
1855
  const FUSION_GATE_PROMPT_PREFIX = "[fusion-gate]";
1855
1856
  const FUSION_ACTIVATION_PROMPT_PREFIX = "[fusion-activation]";
1857
+ /**
1858
+ * Message-id namespaces for server-generated fusion wakes. Wake detection is
1859
+ * keyed off these rather than message text, which a user or expanded skill
1860
+ * could echo.
1861
+ */
1862
+ const FUSION_REVIEW_MESSAGE_ID_PREFIX = "fusion-review:";
1863
+ const FUSION_GATE_MESSAGE_ID_PREFIX = "fusion-gate:";
1856
1864
  const FUSION_GATE_UNWATCHED_SUMMARY = "Gate passed unwatched";
1857
1865
  const FUSION_GATE_DEFAULT_TIMEOUT_MS = 12e4;
1858
1866
  const OrchestrationProposedPlanId = TrimmedNonEmptyString;
@@ -2452,6 +2460,19 @@ const ThreadSessionStopCommand = Schema$1.Struct({
2452
2460
  threadId: ThreadId,
2453
2461
  createdAt: IsoDateTime
2454
2462
  });
2463
+ /**
2464
+ * Force-terminates the provider session's owned OS process tree, separate from
2465
+ * the cooperative `thread.session.stop`. Acceptance surfaces through the
2466
+ * persisted command receipt, whose resultSequence points at the typed
2467
+ * `thread.session-force-stop-requested` event; terminal lifecycle stays on the
2468
+ * authoritative `thread.session-set`.
2469
+ */
2470
+ const ThreadSessionForceStopCommand = Schema$1.Struct({
2471
+ type: Schema$1.Literal("thread.session.force-stop"),
2472
+ commandId: CommandId,
2473
+ threadId: ThreadId,
2474
+ createdAt: IsoDateTime
2475
+ });
2455
2476
  const ThreadPairCreateCommand = Schema$1.Struct({
2456
2477
  type: Schema$1.Literal("thread-pair.create"),
2457
2478
  commandId: CommandId,
@@ -2509,6 +2530,7 @@ const DispatchableClientOrchestrationCommand = Schema$1.Union([
2509
2530
  ThreadUserInputRespondCommand,
2510
2531
  ThreadCheckpointRevertCommand,
2511
2532
  ThreadSessionStopCommand,
2533
+ ThreadSessionForceStopCommand,
2512
2534
  ThreadPairCreateCommand,
2513
2535
  ThreadPairDetachCommand,
2514
2536
  ThreadPairGateResolveCommand
@@ -2539,6 +2561,7 @@ const ClientOrchestrationCommand = Schema$1.Union([
2539
2561
  ThreadUserInputRespondCommand,
2540
2562
  ThreadCheckpointRevertCommand,
2541
2563
  ThreadSessionStopCommand,
2564
+ ThreadSessionForceStopCommand,
2542
2565
  ThreadPairCreateCommand,
2543
2566
  ThreadPairDetachCommand,
2544
2567
  ThreadPairGateResolveCommand
@@ -2550,6 +2573,19 @@ const ThreadSessionSetCommand = Schema$1.Struct({
2550
2573
  session: OrchestrationSession,
2551
2574
  createdAt: IsoDateTime
2552
2575
  });
2576
+ /**
2577
+ * Server-only: atomic convergence of a confirmed force stop. The decider
2578
+ * expands it into durable cancellations for every open approval/user-input
2579
+ * request plus the authoritative `thread.session-set`, committed in one
2580
+ * transaction so a crash can never expose partially cancelled requests.
2581
+ */
2582
+ const ThreadSessionForceStopConvergeCommand = Schema$1.Struct({
2583
+ type: Schema$1.Literal("thread.session.force-stop.converge"),
2584
+ commandId: CommandId,
2585
+ threadId: ThreadId,
2586
+ session: OrchestrationSession,
2587
+ createdAt: IsoDateTime
2588
+ });
2553
2589
  const ThreadTurnCompleteCommand = Schema$1.Struct({
2554
2590
  type: Schema$1.Literal("thread.turn.complete"),
2555
2591
  commandId: CommandId,
@@ -2648,6 +2684,7 @@ const ThreadRevertCompleteCommand = Schema$1.Struct({
2648
2684
  });
2649
2685
  const InternalOrchestrationCommand = Schema$1.Union([
2650
2686
  ThreadSessionSetCommand,
2687
+ ThreadSessionForceStopConvergeCommand,
2651
2688
  ThreadTurnCompleteCommand,
2652
2689
  ThreadPairCursorAdvanceCommand,
2653
2690
  ThreadPairGateOpenCommand,
@@ -2688,6 +2725,7 @@ const OrchestrationEventType = Schema$1.Literals([
2688
2725
  "thread.checkpoint-revert-requested",
2689
2726
  "thread.reverted",
2690
2727
  "thread.session-stop-requested",
2728
+ "thread.session-force-stop-requested",
2691
2729
  "thread.session-set",
2692
2730
  "thread.proposed-plan-upserted",
2693
2731
  "thread.turn-diff-completed",
@@ -2883,6 +2921,10 @@ const ThreadSessionStopRequestedPayload = Schema$1.Struct({
2883
2921
  threadId: ThreadId,
2884
2922
  createdAt: IsoDateTime
2885
2923
  });
2924
+ const ThreadSessionForceStopRequestedPayload = Schema$1.Struct({
2925
+ threadId: ThreadId,
2926
+ createdAt: IsoDateTime
2927
+ });
2886
2928
  const ThreadSessionSetPayload$1 = Schema$1.Struct({
2887
2929
  threadId: ThreadId,
2888
2930
  session: OrchestrationSession
@@ -3124,6 +3166,11 @@ const OrchestrationEvent = Schema$1.Union([
3124
3166
  type: Schema$1.Literal("thread.session-stop-requested"),
3125
3167
  payload: ThreadSessionStopRequestedPayload
3126
3168
  }),
3169
+ Schema$1.Struct({
3170
+ ...EventBaseFields,
3171
+ type: Schema$1.Literal("thread.session-force-stop-requested"),
3172
+ payload: ThreadSessionForceStopRequestedPayload
3173
+ }),
3127
3174
  Schema$1.Struct({
3128
3175
  ...EventBaseFields,
3129
3176
  type: Schema$1.Literal("thread.session-set"),
@@ -6481,6 +6528,23 @@ const ProviderInterruptTurnInput = Schema$1.Struct({
6481
6528
  turnId: Schema$1.optional(TurnId)
6482
6529
  });
6483
6530
  const ProviderStopSessionInput = Schema$1.Struct({ threadId: ThreadId });
6531
+ /**
6532
+ * Result of a force stop. `unsupported` means a live session exists on an
6533
+ * adapter without a registered owned process (the Claude Agent SDK owns its
6534
+ * child privately), so live force termination is refused rather than faked.
6535
+ */
6536
+ const ProviderForceStopSessionOutcome = Schema$1.Literals([
6537
+ "terminated",
6538
+ "already-exited",
6539
+ "kill-timeout",
6540
+ "no-owned-process",
6541
+ "unsupported"
6542
+ ]);
6543
+ Schema$1.Struct({
6544
+ outcome: ProviderForceStopSessionOutcome,
6545
+ provider: Schema$1.NullOr(Schema$1.String),
6546
+ pid: Schema$1.NullOr(Schema$1.Number)
6547
+ });
6484
6548
  const ProviderRespondToRequestInput = Schema$1.Struct({
6485
6549
  threadId: ThreadId,
6486
6550
  requestId: ApprovalRequestId,
@@ -27177,6 +27241,99 @@ const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(funct
27177
27241
  createdAt: command.createdAt
27178
27242
  }
27179
27243
  };
27244
+ case "thread.session.force-stop":
27245
+ yield* requireThread({
27246
+ readModel,
27247
+ command,
27248
+ threadId: command.threadId
27249
+ });
27250
+ return {
27251
+ ...yield* withEventBase({
27252
+ aggregateKind: "thread",
27253
+ aggregateId: command.threadId,
27254
+ occurredAt: command.createdAt,
27255
+ commandId: command.commandId
27256
+ }),
27257
+ type: "thread.session-force-stop-requested",
27258
+ payload: {
27259
+ threadId: command.threadId,
27260
+ createdAt: command.createdAt
27261
+ }
27262
+ };
27263
+ case "thread.session.force-stop.converge": {
27264
+ const thread = yield* requireThread({
27265
+ readModel,
27266
+ command,
27267
+ threadId: command.threadId
27268
+ });
27269
+ const openRequests = /* @__PURE__ */ new Map();
27270
+ for (const activity of thread.activities) {
27271
+ const payload = typeof activity.payload === "object" && activity.payload !== null ? activity.payload : null;
27272
+ const requestId = typeof payload?.requestId === "string" ? payload.requestId : null;
27273
+ if (requestId === null) continue;
27274
+ if (activity.kind === "approval.requested") openRequests.set(requestId, {
27275
+ category: "approval",
27276
+ requestedAt: activity.createdAt
27277
+ });
27278
+ else if (activity.kind === "user-input.requested") openRequests.set(requestId, {
27279
+ category: "user-input",
27280
+ requestedAt: activity.createdAt
27281
+ });
27282
+ else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") openRequests.delete(requestId);
27283
+ }
27284
+ let resolvedAtMillis = Date.parse(command.createdAt);
27285
+ for (const request of openRequests.values()) {
27286
+ const requestedAtMillis = Date.parse(request.requestedAt);
27287
+ if (!Number.isNaN(requestedAtMillis)) resolvedAtMillis = Math.max(resolvedAtMillis, requestedAtMillis + 1);
27288
+ }
27289
+ const resolvedAt = DateTime.formatIso(DateTime.makeUnsafe(resolvedAtMillis));
27290
+ const events = [];
27291
+ for (const [requestId, request] of openRequests) {
27292
+ const base = yield* withEventBase({
27293
+ aggregateKind: "thread",
27294
+ aggregateId: command.threadId,
27295
+ occurredAt: resolvedAt,
27296
+ commandId: command.commandId
27297
+ });
27298
+ events.push({
27299
+ ...base,
27300
+ type: "thread.activity-appended",
27301
+ payload: {
27302
+ threadId: command.threadId,
27303
+ activity: {
27304
+ id: base.eventId,
27305
+ tone: "info",
27306
+ kind: request.category === "approval" ? "approval.resolved" : "user-input.resolved",
27307
+ summary: request.category === "approval" ? "Approval request cancelled by force stop" : "User input request cancelled by force stop",
27308
+ payload: {
27309
+ requestId,
27310
+ detail: "Cancelled by session force stop."
27311
+ },
27312
+ turnId: null,
27313
+ createdAt: resolvedAt
27314
+ }
27315
+ }
27316
+ });
27317
+ }
27318
+ events.push({
27319
+ ...yield* withEventBase({
27320
+ aggregateKind: "thread",
27321
+ aggregateId: command.threadId,
27322
+ occurredAt: resolvedAt,
27323
+ commandId: command.commandId,
27324
+ metadata: {}
27325
+ }),
27326
+ type: "thread.session-set",
27327
+ payload: {
27328
+ threadId: command.threadId,
27329
+ session: {
27330
+ ...command.session,
27331
+ updatedAt: resolvedAt
27332
+ }
27333
+ }
27334
+ });
27335
+ return events;
27336
+ }
27180
27337
  case "thread.session.set": {
27181
27338
  const thread = yield* requireThread({
27182
27339
  readModel,
@@ -65016,6 +65173,102 @@ const ProviderEventLoggersLive = Layer.effect(ProviderEventLoggers, Effect.gen(f
65016
65173
  };
65017
65174
  }));
65018
65175
  //#endregion
65176
+ //#region src/provider/ProviderOwnedProcessRegistry.ts
65177
+ /**
65178
+ * ProviderOwnedProcessRegistry - threadId to owned provider process handles.
65179
+ *
65180
+ * Long-lived provider session runtimes (Codex, ACP for Cursor/Grok, Muse,
65181
+ * OpenCode) register the child process they spawned for a thread. Force stop
65182
+ * kills only through a registered handle while that handle still observes its
65183
+ * process as running. The Claude Agent SDK owns its child privately and never
65184
+ * registers here; live force termination for it is unsupported by design.
65185
+ *
65186
+ * Identity model (fail-closed):
65187
+ * - `handle.isRunning` derives from the spawner's exit Deferred, resolved by
65188
+ * Node's kernel-held child handle - it identifies the direct child without
65189
+ * probing numeric PIDs, so a recycled PID can never look "running".
65190
+ * - Kill is attempted only while the handle observes the child running. On
65191
+ * POSIX the child leads its own process group, and a live leader keeps that
65192
+ * pgid owned, so the spawner's group kill cannot hit a recycled group.
65193
+ * - Once the handle observes exit, nothing is ever signalled: cleanup of
65194
+ * surviving detached grandchildren after leader exit is explicitly
65195
+ * unsupported, because no safe identity for the group remains. The residual
65196
+ * check-to-kill window is the same one the spawner's own scope finalizer
65197
+ * has, and is not widened here.
65198
+ *
65199
+ * Concurrency: every mutation of the registry - register, deregister, exit
65200
+ * observation, force stop - runs inside the same per-thread critical section,
65201
+ * so a force stop can never act on a replaced or exited generation.
65202
+ */
65203
+ const FORCE_STOP_SIGKILL_ESCALATION = Duration.seconds(2);
65204
+ const FORCE_STOP_EXIT_BOUND = Duration.seconds(5);
65205
+ let generationCounter = 0;
65206
+ const entries = /* @__PURE__ */ new Map();
65207
+ const threadLocks = PartitionedSemaphore.makeUnsafe({ permits: 1 });
65208
+ /**
65209
+ * Records the owned child process for a thread and watches its exit so a dead
65210
+ * generation can never be killed. Returns the registration to pass back to
65211
+ * {@link deregisterOwnedProcess} on scope close. Re-registering a thread
65212
+ * replaces the previous generation.
65213
+ */
65214
+ const registerOwnedProcess = (input) => threadLocks.withPermit(input.threadId)(Effect.gen(function* () {
65215
+ generationCounter += 1;
65216
+ const entry = {
65217
+ threadId: input.threadId,
65218
+ generation: generationCounter,
65219
+ handle: input.handle,
65220
+ exited: false
65221
+ };
65222
+ entries.set(input.threadId, entry);
65223
+ yield* input.handle.exitCode.pipe(Effect.ignore, Effect.andThen(threadLocks.withPermit(input.threadId)(Effect.sync(() => {
65224
+ entry.exited = true;
65225
+ if (entries.get(input.threadId) === entry) entries.delete(input.threadId);
65226
+ }))), Effect.forkDetach);
65227
+ return {
65228
+ threadId: input.threadId,
65229
+ generation: entry.generation
65230
+ };
65231
+ }));
65232
+ /** Removes the registration if this exact generation still owns the thread. */
65233
+ const deregisterOwnedProcess = (registration) => threadLocks.withPermit(registration.threadId)(Effect.sync(() => {
65234
+ if (entries.get(registration.threadId)?.generation === registration.generation) entries.delete(registration.threadId);
65235
+ }));
65236
+ /**
65237
+ * Force-terminates the registered owned process for a thread. The whole
65238
+ * decision-and-kill runs in the per-thread critical section, so the entry it
65239
+ * captured cannot be replaced or reaped concurrently. Bounded and idempotent:
65240
+ * repeats after the process is gone report `no-owned-process` or
65241
+ * `already-exited` without signalling anything, and a `kill-timeout` keeps the
65242
+ * registration for retry instead of pretending convergence.
65243
+ */
65244
+ const forceStopOwnedProcess = (threadId, options) => threadLocks.withPermit(threadId)(Effect.gen(function* () {
65245
+ const entry = entries.get(threadId);
65246
+ if (entry === void 0) return { outcome: "no-owned-process" };
65247
+ const pid = Number(entry.handle.pid);
65248
+ const running = yield* entry.handle.isRunning.pipe(Effect.orElseSucceed(() => false));
65249
+ if (entry.exited || !running) {
65250
+ entry.exited = true;
65251
+ if (entries.get(threadId) === entry) entries.delete(threadId);
65252
+ return {
65253
+ outcome: "already-exited",
65254
+ pid
65255
+ };
65256
+ }
65257
+ if (!(yield* entry.handle.kill({
65258
+ killSignal: "SIGTERM",
65259
+ forceKillAfter: options?.sigkillAfter ?? FORCE_STOP_SIGKILL_ESCALATION
65260
+ }).pipe(Effect.andThen(entry.handle.exitCode.pipe(Effect.ignore)), Effect.timeout(options?.exitBound ?? FORCE_STOP_EXIT_BOUND), Effect.as(true), Effect.orElseSucceed(() => false)))) return {
65261
+ outcome: "kill-timeout",
65262
+ pid
65263
+ };
65264
+ entry.exited = true;
65265
+ if (entries.get(threadId) === entry) entries.delete(threadId);
65266
+ return {
65267
+ outcome: "terminated",
65268
+ pid
65269
+ };
65270
+ }));
65271
+ //#endregion
65019
65272
  //#region src/mcp/McpProviderSession.ts
65020
65273
  /**
65021
65274
  * The name p4code declares its own toolkit under, in every provider.
@@ -65826,6 +66079,53 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (options)
65826
66079
  outcomeAttributes: () => providerMetricAttributes(metricProvider, { operation: "stop" })
65827
66080
  }));
65828
66081
  });
66082
+ const forceStopSession = Effect.fn("forceStopSession")(function* (rawInput) {
66083
+ const input = yield* decodeInputOrValidationError({
66084
+ operation: "ProviderService.forceStopSession",
66085
+ schema: ProviderStopSessionInput,
66086
+ payload: rawInput
66087
+ });
66088
+ const routed = yield* resolveRoutableSession({
66089
+ threadId: input.threadId,
66090
+ operation: "ProviderService.forceStopSession",
66091
+ allowRecovery: false
66092
+ }).pipe(Effect.catchCause(() => Effect.succeed(null)));
66093
+ const owned = yield* forceStopOwnedProcess(input.threadId);
66094
+ const provider = routed?.adapter.provider ?? null;
66095
+ if (owned.outcome === "no-owned-process" && routed !== null && routed.isActive) return {
66096
+ outcome: "unsupported",
66097
+ provider,
66098
+ pid: null
66099
+ };
66100
+ if (owned.outcome === "kill-timeout") {
66101
+ yield* analytics.record("provider.session.force-stopped", {
66102
+ provider: provider ?? "unknown",
66103
+ outcome: owned.outcome
66104
+ });
66105
+ return {
66106
+ outcome: owned.outcome,
66107
+ provider,
66108
+ pid: owned.pid
66109
+ };
66110
+ }
66111
+ yield* clearMcpSession(input.threadId);
66112
+ if (routed !== null) yield* directory.upsert({
66113
+ threadId: input.threadId,
66114
+ provider: routed.adapter.provider,
66115
+ providerInstanceId: routed.instanceId,
66116
+ status: "stopped",
66117
+ runtimePayload: { activeTurnId: null }
66118
+ });
66119
+ yield* analytics.record("provider.session.force-stopped", {
66120
+ provider: provider ?? "unknown",
66121
+ outcome: owned.outcome
66122
+ });
66123
+ return {
66124
+ outcome: owned.outcome,
66125
+ provider,
66126
+ pid: "pid" in owned ? owned.pid : null
66127
+ };
66128
+ });
65829
66129
  const listSessions = Effect.fn("listSessions")(function* () {
65830
66130
  const currentAdapters = yield* getAdapterEntries;
65831
66131
  const activeSessions = (yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => adapter.listSessions().pipe(Effect.map((sessions) => sessions.map((session) => ({
@@ -65928,6 +66228,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (options)
65928
66228
  respondToRequest,
65929
66229
  respondToUserInput,
65930
66230
  stopSession,
66231
+ forceStopSession,
65931
66232
  listSessions,
65932
66233
  getCapabilities,
65933
66234
  getInstanceInfo,
@@ -66418,6 +66719,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
66418
66719
  });
66419
66720
  const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe(Effect.andThen(Effect.sleep("1 second")), Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), Effect.ignore);
66420
66721
  yield* Scope.addFinalizer(runtimeScope, terminateChild);
66722
+ if (input.threadId !== void 0) {
66723
+ const ownedProcess = yield* registerOwnedProcess({
66724
+ threadId: input.threadId,
66725
+ handle: child
66726
+ });
66727
+ yield* Scope.addFinalizer(runtimeScope, deregisterOwnedProcess(ownedProcess));
66728
+ }
66421
66729
  const stdoutRef = yield* Ref.make("");
66422
66730
  const stderrRef = yield* Ref.make("");
66423
66731
  const readyDeferred = yield* Deferred.make();
@@ -66478,7 +66786,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
66478
66786
  ...input.environment !== void 0 ? { environment: input.environment } : {},
66479
66787
  ...input.port !== void 0 ? { port: input.port } : {},
66480
66788
  ...input.hostname !== void 0 ? { hostname: input.hostname } : {},
66481
- ...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {}
66789
+ ...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {},
66790
+ ...input.threadId !== void 0 ? { threadId: input.threadId } : {}
66482
66791
  }).pipe(Effect.map((server) => ({
66483
66792
  url: server.url,
66484
66793
  exitCode: server.exitCode,
@@ -90764,6 +91073,11 @@ const makeCodexSessionRuntime = (options) => Effect.gen(function* () {
90764
91073
  command: `${options.binaryPath} app-server`,
90765
91074
  cause
90766
91075
  })));
91076
+ const ownedProcess = yield* registerOwnedProcess({
91077
+ threadId: options.threadId,
91078
+ handle: child
91079
+ });
91080
+ yield* Scope.addFinalizer(runtimeScope, deregisterOwnedProcess(ownedProcess));
90767
91081
  const clientContext = yield* layerChildProcess$1(child).pipe(Layer.build, Effect.provideService(Scope.Scope, runtimeScope));
90768
91082
  const client = yield* Effect.service(CodexAppServerClient).pipe(Effect.provide(clientContext));
90769
91083
  const serverNotifications = yield* Queue.unbounded();
@@ -96798,6 +97112,13 @@ const make$6 = (options) => Effect.gen(function* () {
96798
97112
  command: options.spawn.command,
96799
97113
  cause
96800
97114
  })));
97115
+ if (options.threadId !== void 0) {
97116
+ const ownedProcess = yield* registerOwnedProcess({
97117
+ threadId: options.threadId,
97118
+ handle: child
97119
+ });
97120
+ yield* Scope.addFinalizer(runtimeScope, deregisterOwnedProcess(ownedProcess));
97121
+ }
96801
97122
  const acpContext = yield* Layer.build(layerChildProcess(child, {
96802
97123
  ...options.protocolLogging?.logIncoming !== void 0 ? { logIncoming: options.protocolLogging.logIncoming } : {},
96803
97124
  ...options.protocolLogging?.logOutgoing !== void 0 ? { logOutgoing: options.protocolLogging.logOutgoing } : {},
@@ -98577,6 +98898,7 @@ function makeCursorAdapter(cursorSettings, options) {
98577
98898
  cursorSettings: effectiveCursorSettings,
98578
98899
  ...options?.environment ? { environment: options.environment } : {},
98579
98900
  childProcessSpawner,
98901
+ threadId: input.threadId,
98580
98902
  cwd,
98581
98903
  ...resumeSessionId ? { resumeSessionId } : {},
98582
98904
  clientInfo: {
@@ -99810,6 +100132,7 @@ function makeGrokAdapter(grokSettings, options) {
99810
100132
  grokSettings,
99811
100133
  ...options?.environment ? { environment: options.environment } : {},
99812
100134
  childProcessSpawner,
100135
+ threadId: input.threadId,
99813
100136
  cwd,
99814
100137
  ...resumeSessionId ? { resumeSessionId } : {},
99815
100138
  clientInfo: {
@@ -101294,6 +101617,11 @@ const runMuseExec = Effect.fn("runMuseExec")(function* (input) {
101294
101617
  detail: "Failed to spawn `muse exec`.",
101295
101618
  cause
101296
101619
  })));
101620
+ const ownedProcess = yield* registerOwnedProcess({
101621
+ threadId: input.threadId,
101622
+ handle: child
101623
+ });
101624
+ yield* Scope.addFinalizer(runScope, deregisterOwnedProcess(ownedProcess));
101297
101625
  const stderrRef = yield* Ref.make("");
101298
101626
  const interruptedRef = yield* Ref.make(false);
101299
101627
  const stdoutFiber = yield* child.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.runForEach(input.onLine), Effect.ignore, Effect.forkIn(runScope));
@@ -103181,6 +103509,7 @@ function makeOpenCodeAdapter(openCodeSettings, options) {
103181
103509
  const server = yield* openCodeRuntime.connectToOpenCodeServer({
103182
103510
  binaryPath,
103183
103511
  serverUrl,
103512
+ threadId: input.threadId,
103184
103513
  ...options?.environment ? { environment: options.environment } : {}
103185
103514
  });
103186
103515
  const client = openCodeRuntime.createOpenCodeSdkClient({
@@ -105204,6 +105533,22 @@ const resolvePendingMcpUserInput = Effect.fn("pendingMcpUserInputs.resolve")(fun
105204
105533
  const forgetPendingMcpUserInput = (threadId, requestId) => {
105205
105534
  if (pending.get(requestId)?.threadId === threadId) pending.delete(requestId);
105206
105535
  };
105536
+ /** Ids of the questions a thread has open right now (force-stop snapshot). */
105537
+ const listPendingMcpUserInputRequestIds = (threadId) => [...pending].filter(([, request]) => request.threadId === threadId).map(([requestId]) => requestId);
105538
+ /**
105539
+ * Cancels one pending MCP question (force stop). Interrupts the Deferred
105540
+ * before dropping the entry so the MCP handler blocked on it completes
105541
+ * instead of leaking a suspended fiber. Targeted by request id; the caller
105542
+ * owns the guarantee that the id belongs to the session being killed -
105543
+ * either a pre-kill snapshot, or a re-list taken while the doomed session's
105544
+ * generation is confirmed unchanged and before any stopped state is written.
105545
+ */
105546
+ const failPendingMcpUserInput = Effect.fn("pendingMcpUserInputs.fail")(function* (threadId, requestId) {
105547
+ const request = pending.get(requestId);
105548
+ if (request?.threadId !== threadId) return;
105549
+ pending.delete(requestId);
105550
+ yield* Deferred.interrupt(request.answers);
105551
+ });
105207
105552
  //#endregion
105208
105553
  //#region src/mcp/toolkits/threads/handlers.ts
105209
105554
  const DEFAULT_MEMORY_APPEND_TARGET = "CLAUDE.md";
@@ -107524,7 +107869,22 @@ const DEFAULT_RUNTIME_MODE = "full-access";
107524
107869
  const DEFAULT_THREAD_TITLE = "New thread";
107525
107870
  const NON_SYSTEM_PROVIDER_STRUCTURED_USER_QUESTIONS = structuredUserQuestionPrompt("your provider's structured user-input question tool");
107526
107871
  const FUSION_PROMOTION_INSTRUCTIONS = `Work independently in this normal thread. Fusion is a silent escalation path, not a startup procedure. Do not inspect Fusion tools/skill, mention Fusion status, or announce that Fusion was not invoked. First analyze the task normally. Only if that analysis reveals a concrete unresolved tradeoff, correctness risk, or design decision materially needing a second opinion, stop before implementation, propose Fusion, and ask the user for explicit approval. The user may approve with ordinary affirmative text such as "approved"; /fusion or $fusion also authorizes Fusion directly without a prior proposal. Do not activate, spawn, or promote until one of those authorizations arrives. UI work, complex logic, task size, unfamiliarity, or duration alone never qualifies.`;
107527
- const FUSION_BUILDER_INSTRUCTIONS = `You are Fusion Builder in an already-created native server pair. Server owns pairing and coordination. Do not inspect or invoke the Fusion skill, create/pair/rename threads, or announce/setup Fusion. Start the user's task directly. Before editing, create and maintain a visible task todo split into small independently reviewable phases plus a final integration/whole-task phase. Complete exactly one phase per turn. End every phase turn with phase completed, todo status, changed behavior/files, verification, and remaining phases; 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.`;
107872
+ const FUSION_BUILDER_INSTRUCTIONS = `You are Fusion Builder in an already-created native server pair. Server owns pairing and coordination. Do not inspect or invoke the Fusion skill, create/pair/rename threads, or announce/setup Fusion. Start the user's task directly. Before editing, create and maintain the phase list in the same plan/todo tool you use for ordinary step tracking, never the MCP task board tools - one entry per phase in order, exactly one in progress at a time, marked completed at each phase end - so phases render in the task banner. That list holds phase entries only for the whole task; keep step-level or per-file todos out of it. Prose alone leaves the banner empty. 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 phase is a complete reviewable slice of behavior. Never split per file, per function, or per trivial step: over-splitting spends review turns instead of finishing the job. Add a phase only when a real review boundary, risky decision, or independent behavior separates the work. 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 phase completed, todo status, changed behavior/files, and remaining phases; 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.`;
107873
+ const FUSION_WATCHER_INSTRUCTIONS = `You are Fusion Supervisor (watcher) in an already-created native server pair. Server owns pairing and coordination and wakes you with ${FUSION_REVIEW_PROMPT_PREFIX} or ${FUSION_GATE_PROMPT_PREFIX} prompts at builder turn boundaries; this message arrived outside such a wake, so your conversational memory of the pair may be gone. The pair metadata below is authoritative: the builder thread exists and is the counterpart thread id. Never report that no builder thread exists. To resume supervision, read builder events with thread_watch_events from lastReviewedImplementerSequence with limit 50, paging forward with the last returned sequence rather than requesting a whole range at once, derive phase from artifacts (git log/status, PR, builder events), 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.`;
107874
+ const isFusionWatcherWakeMessageId = (messageId) => messageId.startsWith("fusion-review:") || messageId.startsWith("fusion-gate:");
107875
+ const fusionPairContext = (pair, role) => {
107876
+ const counterpartThreadId = role === "implementer" ? pair.watcherThreadId : pair.implementerThreadId;
107877
+ return [
107878
+ "[fusion-pair]",
107879
+ `role: ${role}`,
107880
+ `pairId: ${pair.id}`,
107881
+ `counterpartThreadId: ${counterpartThreadId}`,
107882
+ `roundCap: ${pair.roundCap}`,
107883
+ `gateTimeoutMs: ${pair.gateTimeoutMs}`,
107884
+ `lastReviewedImplementerSequence: ${pair.lastReviewedImplementerSequence}`,
107885
+ `activeGate: ${pair.activeGate === null ? "none" : JSON.stringify(pair.activeGate)}`
107886
+ ].join("\n");
107887
+ };
107528
107888
  function providerErrorLabel(value) {
107529
107889
  const normalized = value?.trim();
107530
107890
  return normalized && normalized.length > 0 ? normalized : "unknown";
@@ -107609,6 +107969,7 @@ const make$3 = Effect.gen(function* () {
107609
107969
  const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
107610
107970
  const textGeneration = yield* TextGeneration;
107611
107971
  const serverSettingsService = yield* ServerSettingsService;
107972
+ const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
107612
107973
  const serverConfig = yield* ServerConfig$1;
107613
107974
  const serverCommandId = (tag) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));
107614
107975
  const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make));
@@ -108094,7 +108455,7 @@ const make$3 = Effect.gen(function* () {
108094
108455
  ].filter((part) => part !== void 0).join("\n\n");
108095
108456
  const activeFusionPair = ((yield* projectionSnapshotQuery.getCommandReadModel()).threadPairs ?? []).find((pair) => pair.detachedAt === null && (pair.implementerThreadId === input.threadId || pair.watcherThreadId === input.threadId));
108096
108457
  const isFusionBuilder = activeFusionPair?.implementerThreadId === input.threadId;
108097
- const fusionInput = expandedInputWithDocuments === void 0 ? void 0 : activeFusionPair === void 0 ? `${FUSION_PROMOTION_INSTRUCTIONS}\n\n${expandedInputWithDocuments}` : isFusionBuilder ? `${FUSION_BUILDER_INSTRUCTIONS}\n\n${expandedInputWithDocuments}` : expandedInputWithDocuments;
108458
+ const fusionInput = expandedInputWithDocuments === void 0 ? void 0 : activeFusionPair === void 0 ? `${FUSION_PROMOTION_INSTRUCTIONS}\n\n${expandedInputWithDocuments}` : isFusionBuilder ? `${FUSION_BUILDER_INSTRUCTIONS}\n\n${fusionPairContext(activeFusionPair, "implementer")}\n\n${expandedInputWithDocuments}` : isFusionWatcherWakeMessageId(input.messageId) ? expandedInputWithDocuments : `${FUSION_WATCHER_INSTRUCTIONS}\n\n${fusionPairContext(activeFusionPair, "watcher")}\n\n${expandedInputWithDocuments}`;
108098
108459
  const activeSession = yield* providerService.listSessions().pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)));
108099
108460
  const providerHasStructuredQuestionSystemPrompt = activeSession?.provider === "claudeAgent" || activeSession?.provider === "codex";
108100
108461
  const inputWithStructuredQuestionPolicy = fusionInput !== void 0 && !providerHasStructuredQuestionSystemPrompt ? `${NON_SYSTEM_PROVIDER_STRUCTURED_USER_QUESTIONS}\n\n${fusionInput}` : fusionInput;
@@ -108260,6 +108621,7 @@ const make$3 = Effect.gen(function* () {
108260
108621
  })));
108261
108622
  const sendTurnRequest = yield* buildSendTurnRequestForThread({
108262
108623
  threadId: event.payload.threadId,
108624
+ messageId: message.id,
108263
108625
  messageText: message.text,
108264
108626
  ...message.attachments !== void 0 ? { attachments: message.attachments } : {},
108265
108627
  ...event.payload.modelSelection !== void 0 ? { modelSelection: event.payload.modelSelection } : {},
@@ -108368,6 +108730,54 @@ const make$3 = Effect.gen(function* () {
108368
108730
  createdAt: now
108369
108731
  });
108370
108732
  });
108733
+ const processSessionForceStopRequested = Effect.fn("processSessionForceStopRequested")(function* (event) {
108734
+ const thread = yield* resolveThread(event.payload.threadId);
108735
+ if (!thread) return;
108736
+ const now = event.payload.createdAt;
108737
+ const pendingInputIdsBeforeKill = listPendingMcpUserInputRequestIds(thread.id);
108738
+ const result = yield* providerService.forceStopSession({ threadId: thread.id }).pipe(Effect.catchCause((cause) => Effect.logWarning("provider session force stop failed", {
108739
+ threadId: thread.id,
108740
+ cause: Cause.pretty(cause)
108741
+ }).pipe(Effect.as(null))));
108742
+ if (result === null || result.outcome === "kill-timeout" || result.outcome === "unsupported") {
108743
+ yield* appendProviderFailureActivity({
108744
+ threadId: thread.id,
108745
+ kind: "provider.session.force-stop.failed",
108746
+ summary: "Force stop failed",
108747
+ detail: result === null ? "Force stop failed before reaching the provider process." : result.outcome === "unsupported" ? `The active ${result.provider ?? "provider"} session does not expose a server-owned process; force stop is not supported while it runs.` : `The provider process (pid ${result.pid}) did not exit within the bound; the session was left running for retry.`,
108748
+ turnId: thread.session?.activeTurnId ?? null,
108749
+ createdAt: now
108750
+ });
108751
+ return;
108752
+ }
108753
+ yield* Effect.forEach(pendingInputIdsBeforeKill, (requestId) => failPendingMcpUserInput(thread.id, requestId), { discard: true });
108754
+ const current = yield* resolveThread(event.payload.threadId);
108755
+ if (!current) return;
108756
+ if (!((current.session?.updatedAt ?? null) === (thread.session?.updatedAt ?? null) && (current.session?.activeTurnId ?? null) === (thread.session?.activeTurnId ?? null))) {
108757
+ yield* Effect.logInfo("force stop superseded by a newer session; skipping convergence", { threadId: thread.id });
108758
+ return;
108759
+ }
108760
+ yield* Effect.forEach(listPendingMcpUserInputRequestIds(thread.id), (requestId) => failPendingMcpUserInput(thread.id, requestId), { discard: true });
108761
+ yield* serverCommandId("provider-session-force-converge").pipe(Effect.flatMap((commandId) => orchestrationEngine.dispatch({
108762
+ type: "thread.session.force-stop.converge",
108763
+ commandId,
108764
+ threadId: thread.id,
108765
+ session: {
108766
+ threadId: thread.id,
108767
+ status: "stopped",
108768
+ providerName: current.session?.providerName ?? null,
108769
+ ...current.session?.providerInstanceId !== void 0 ? { providerInstanceId: current.session.providerInstanceId } : {},
108770
+ runtimeMode: current.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
108771
+ activeTurnId: null,
108772
+ lastError: current.session?.lastError ?? null,
108773
+ updatedAt: now
108774
+ },
108775
+ createdAt: now
108776
+ })));
108777
+ yield* Effect.all([flushQueuedSettle(thread.id), flushQueuedWorkspaceCleanup(thread.id)], { discard: true });
108778
+ threadSessionRulesetModes.delete(thread.id);
108779
+ threadBackgroundLiveness.clearThreadLiveness(thread.id);
108780
+ });
108371
108781
  const processDomainEvent = Effect.fn("processDomainEvent")(function* (event) {
108372
108782
  yield* Effect.annotateCurrentSpan({
108373
108783
  "orchestration.event_type": event.type,
@@ -108398,6 +108808,9 @@ const make$3 = Effect.gen(function* () {
108398
108808
  case "thread.session-stop-requested":
108399
108809
  yield* processSessionStopRequested(event);
108400
108810
  return;
108811
+ case "thread.session-force-stop-requested":
108812
+ yield* processSessionForceStopRequested(event);
108813
+ return;
108401
108814
  }
108402
108815
  });
108403
108816
  const processDomainEventSafely = (event) => processDomainEvent(event).pipe(Effect.catchCause((cause) => {
@@ -108408,15 +108821,20 @@ const make$3 = Effect.gen(function* () {
108408
108821
  });
108409
108822
  }));
108410
108823
  const worker = yield* makeDrainableWorker(processDomainEventSafely);
108824
+ const forceStopOutstanding = yield* TxRef.make(0);
108825
+ const forceStopContext = yield* Effect.context();
108826
+ const enqueueForceStop = (event) => Effect.uninterruptible(Effect.tx(TxRef.update(forceStopOutstanding, (n) => n + 1)).pipe(Effect.andThen(processDomainEventSafely(event).pipe(Effect.ensuring(Effect.tx(TxRef.update(forceStopOutstanding, (n) => n - 1))), Effect.provideContext(forceStopContext), Effect.forkScoped)), Effect.asVoid));
108827
+ const forceStopDrain = TxRef.get(forceStopOutstanding).pipe(Effect.tap((n) => n > 0 ? Effect.txRetry : Effect.void), Effect.tx);
108411
108828
  return {
108412
108829
  start: Effect.fn("start")(function* () {
108413
108830
  const processEvent = Effect.fn("processEvent")(function* (event) {
108831
+ if (event.type === "thread.session-force-stop-requested") return yield* enqueueForceStop(event);
108414
108832
  if (event.type === "thread.runtime-mode-set" || event.type === "thread.turn-start-requested" || event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || event.type === "thread.session-stop-requested") return yield* worker.enqueue(event);
108415
108833
  });
108416
108834
  yield* Effect.forkScoped(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent));
108417
108835
  yield* restorePendingWorkspaceCleanups().pipe(Effect.catchCause((cause) => Effect.logWarning("startup thread workspace cleanup recovery failed", { cause })));
108418
108836
  }),
108419
- drain: worker.drain
108837
+ drain: Effect.all([worker.drain, forceStopDrain], { discard: true }).pipe(Effect.asVoid)
108420
108838
  };
108421
108839
  });
108422
108840
  const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make$3);
@@ -108937,9 +109355,9 @@ const CheckpointReactorLive = Layer.effect(CheckpointReactor, make$2);
108937
109355
  const GATE_TIMEOUT_SWEEP_INTERVAL = "10 seconds";
108938
109356
  const reviewCommandId = (pairId, sequence) => CommandId.make(`server:fusion:${pairId}:review:${sequence}`);
108939
109357
  const cursorCommandId = (pairId, sequence) => CommandId.make(`server:fusion:${pairId}:cursor:${sequence}`);
108940
- const reviewMessageId = (pairId, sequence) => MessageId.make(`fusion-review:${pairId}:${sequence}`);
109358
+ const reviewMessageId = (pairId, sequence) => MessageId.make(`${FUSION_REVIEW_MESSAGE_ID_PREFIX}${pairId}:${sequence}`);
108941
109359
  const gateCommandId = (pairId, gateId, suffix) => CommandId.make(`server:fusion:${pairId}:gate:${gateId}:${suffix}`);
108942
- const gateMessageId = (gateId, round) => MessageId.make(`fusion-gate:${gateId}:wake:${round}`);
109360
+ const gateMessageId = (gateId, round) => MessageId.make(`${FUSION_GATE_MESSAGE_ID_PREFIX}${gateId}:wake:${round}`);
108943
109361
  const gateActivityId = (gateId, threadId, suffix) => EventId.make(`fusion-gate:${gateId}:${suffix}:${threadId}`);
108944
109362
  const pairFailureCommandId = (pairId, sequence, suffix) => CommandId.make(`server:fusion:${pairId}:provider-failure:${sequence}:${suffix}`);
108945
109363
  const pairFailureActivityId = (pairId, sequence, threadId) => EventId.make(`fusion-provider-failure:${pairId}:${sequence}:${threadId}`);
@@ -108967,34 +109385,38 @@ Never poll thread_watch_events or wait for builder. Deliver review/advice, end t
108967
109385
  * definition of done and its recovery procedure must arrive with each wake,
108968
109386
  * and the current phase must be derived from artifacts rather than remembered.
108969
109387
  */
108970
- const deliveryProtocol = (implementerThreadId) => `Own delivery loop. Completion requires artifact proof from builder events, git, PR - never memory:
109388
+ const deliveryProtocol = (implementerThreadId) => `Delivery loop runs only when the user's task authorizes delivery (commit, push, PR). Never infer authorization from finished code, green checks, or a clean review.
109389
+
109390
+ Authorized - completion requires artifact proof from builder events, git, PR - never memory:
108971
109391
 
108972
109392
  1. Feature/fix branch, explicit-path staging, conventional commit, rebased latest main.
108973
109393
  2. Open PR; builder reports commit and PR URL.
108974
109394
  3. Global pr-reviewer reviewed branch/PR; confirmed findings fixed; affected checks green; follow-up review has no actionable findings.
108975
109395
 
108976
- Code done/no objections but conditions missing: thread_advise ${implementerThreadId} with next delivery step - commit, rebase, PR, global pr-reviewer, fixes, affected checks, repeat until clean. Require final commit and PR URL.
109396
+ Authorized and code done/no objections but conditions missing: thread_advise ${implementerThreadId} with next delivery step - commit, rebase, PR, global pr-reviewer, fixes, affected checks, repeat until clean. Require final commit and PR URL.
109397
+
109398
+ Not authorized: task is complete once code and final-phase verification are done. Report final state and the changed uncommitted paths to the user in this supervisor thread, say delivery needs their authorization, and end the turn. Nothing blocks the builder here, so do not use waitForUser; the prerequisite rule above applies only when the builder reports it is stuck mid-task awaiting authorization. Never advise the builder to branch, stage, commit, push, or open a PR, and never count missing delivery as unfinished work or an objection.
108977
109399
 
108978
109400
  Never instruct/report merge; user owns merging. After restart/context loss, derive phase from workspace git log/status, PR, recent builder events.`;
108979
109401
  const watcherPrompt = (input) => `${FUSION_REVIEW_PROMPT_PREFIX}
108980
109402
  Review completed builder turn ${input.implementerThreadId}.
108981
109403
 
108982
- Call thread_watch_events, threadId ${input.implementerThreadId}, afterSequence ${input.afterSequence}; page through ${input.throughSequence}. Inspect repo when useful.
109404
+ Call thread_watch_events, threadId ${input.implementerThreadId}, afterSequence ${input.afterSequence}, limit 50; repeat with the last returned event's sequence as afterSequence until you reach ${input.throughSequence}. Never request a whole range in one call: an oversized page exceeds the tool output cap and wastes the turn. Inspect repo when useful.
108983
109405
 
108984
109406
  Always report concise:
108985
109407
 
108986
109408
  - Progress: work done; completeness.
108987
- - Verification: checks/results; missing proof.
109409
+ - Verification: final phase only - checks/results, missing proof. Intermediate phases: report "deferred to final phase" and never demand per-phase checks.
108988
109410
  - Assessment: evidence-cited correctness risks, missed requirements, regressions, unsafe/unneeded scope. None: "No objections found."
108989
109411
 
108990
109412
  Determine review boundary from builder's todo status:
108991
109413
 
108992
- - Remaining phases, or final status unclear: intermediate phase review. Always call thread_advise for ${input.implementerThreadId}. With objections, send required corrections and next phase. With none, explicitly approve phase and tell builder to continue next todo phase.
108993
- - Final whole-task phase ready: call thread_watch_events again with afterSequence 0 through ${input.throughSequence}, then inspect full task diff/state. Review original requirements, integration across all phases, verification, and delivery. Advise only for concrete objections or unfinished work; otherwise report no objections and end.
109414
+ - Remaining phases, or final status unclear: intermediate phase review. Always call thread_advise for ${input.implementerThreadId}. With objections, send required corrections and next phase. With none, explicitly approve phase and tell builder to continue next todo phase. Never require tests, typecheck, lint, or builds before the final phase.
109415
+ - Final whole-task phase ready: call thread_watch_events again from afterSequence 0 through ${input.throughSequence}, same limit 50 paging, then inspect full task diff/state. Review original requirements, integration across all phases, verification, and delivery. Advise only for concrete objections or unfinished work; otherwise report no objections and end.
108994
109416
 
108995
109417
  ${watcherPowers(input.implementerThreadId)}
108996
109418
 
108997
- Builder-blocking external prerequisite only: when the builder explicitly reports it cannot process the request until the user supplies access, authentication, credentials, permission, or another unavailable external prerequisite, call thread_advise with waitForUser true, ask the user in this supervisor thread, and end. That result does not wake the builder. Wait for user action or reply; only then call thread_advise normally with the confirmed result. Never send waiting/pending status to the builder because that starts another review cycle. Do not pause for ordinary review objections, design choices, or human-authority decisions the builder can safely await itself.
109419
+ Builder-blocking external prerequisite only: when the builder explicitly reports it cannot process the request until the user supplies access, authentication, credentials, permission, delivery authorization to commit/push/open a PR, or another unavailable external prerequisite, call thread_advise with waitForUser true, ask the user in this supervisor thread, and end. That result does not wake the builder. Wait for user action or reply; only then call thread_advise normally with the confirmed result. Never send waiting/pending status to the builder because that starts another review cycle. Do not pause for ordinary review objections, design choices, or human-authority decisions the builder can safely await itself.
108998
109420
 
108999
109421
  ${deliveryProtocol(input.implementerThreadId)}
109000
109422
 
@@ -109009,7 +109431,7 @@ const gateKindDescription = (gate) => {
109009
109431
  const gatePrompt = (input) => `${FUSION_GATE_PROMPT_PREFIX}
109010
109432
  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}.
109011
109433
 
109012
- First read delta: thread_watch_events, threadId ${input.implementerThreadId}, afterSequence ${input.afterSequence}; page through ${input.throughSequence}. Inspect repo when useful.
109434
+ First read delta: thread_watch_events, threadId ${input.implementerThreadId}, afterSequence ${input.afterSequence}, limit 50; repeat with the last returned event's sequence as afterSequence until you reach ${input.throughSequence}. Never request a whole range in one call: an oversized page exceeds the tool output cap and wastes the turn. Inspect repo when useful.
109013
109435
 
109014
109436
  Then thread_gate_respond, threadId ${input.implementerThreadId}, gateId ${input.gate.id}:
109015
109437
 
@@ -109232,7 +109654,7 @@ const make$1 = Effect.gen(function* () {
109232
109654
  commandId: gateCommandId(event.payload.pairId, event.payload.gateId, "unblock-continue"),
109233
109655
  threadId: implementer.id,
109234
109656
  message: {
109235
- messageId: MessageId.make(`fusion-gate:${event.payload.gateId}:unblock-continue`),
109657
+ messageId: MessageId.make(`${FUSION_GATE_MESSAGE_ID_PREFIX}${event.payload.gateId}:unblock-continue`),
109236
109658
  role: "user",
109237
109659
  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.`,
109238
109660
  attachments: []