@alexeiled/pi-fusion 0.7.0 → 0.9.0

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/README.md CHANGED
@@ -140,6 +140,8 @@ Do not use it for trivial edits, formatting, or obvious one-step fixes.
140
140
  /fusion -p <name> <prompt>
141
141
  /fusion status
142
142
  /fusion stop
143
+ /fusion continue <fusion-run-id> <panelist-number>
144
+ /fusion finish <fusion-run-id> <panelist-number>
143
145
  /fusion init
144
146
  ```
145
147
 
@@ -156,7 +158,7 @@ Other Pi extensions can control Fusion through the versioned event-bus contract
156
158
  Methods:
157
159
 
158
160
  - `ping` — return the RPC version and supported methods
159
- - `start` — requires `prompt` and a non-empty `operationId`. It accepts optional `profile` and versioned `outputContract` (`plan-review-v1`). Reusing an operation ID returns the original run instead of starting another, including after Fusion restores the Pi session history.
161
+ - `start` — requires `prompt` and a non-empty `operationId`. It accepts optional `profile`, versioned `outputContract` (`plan-review-v1`), and positive-integer millisecond deadline overrides: `panelistTimeoutMs`, `panelTimeoutMs`, `panelGraceMs`, and `judgeTimeoutMs`. Reusing an operation ID returns the original run instead of starting another, including after Fusion restores the Pi session history.
160
162
  - `status` — return structured run state by `operationId`, `runId`, or the current/last run
161
163
  - `result` — return a terminal run and report. An active run returns `not_ready`
162
164
  - `cancel` — cancel the selected active run, or report that the selected terminal run was not cancelled
@@ -207,12 +209,15 @@ For commands, config, and troubleshooting details, see [`docs/user-guide.md`](./
207
209
  - Config is optional. Defaults work. Use `/fusion init` when you want project config.
208
210
  - Project config lives at `.pi/fusion.json`. Global config lives at `~/.pi/agent/fusion.json`.
209
211
  - Output appears as a Pi custom message. Active progress also uses the `fusion` status key.
210
- - Active runs are reconciled from `pi-subagents` lifecycle artifacts, not only completion events.
212
+ - Active runs are reconciled from `pi-subagents` lifecycle artifacts, not only completion events. Verified panel outputs survive a panel deadline; unavailable perspectives and timeout failures are disclosed in a partial report when quorum is not met or coverage is incomplete.
213
+ - Normal panels refill free concurrency slots immediately. Agreement-stopping panels retain quorum-sized rounds. Default panel time covers the configured concurrency waves; explicit deadlines remain hard caps.
214
+ - Optional `panelistSoftTimeoutMs` asks the parent for a deadline decision. The parent can approve one continuation within the existing hard budget or ask for current findings. No reply within one minute requests finalization. Fusion reserves one minute to finalize, never revives children or extends hard deadlines, and cannot guarantee a final answer from an unresponsive provider.
215
+ - Incomplete terminal snapshots get a bounded five-second reconciliation window. Late errors are retained; at a confirmed workflow deadline, genuinely absent slots become explicit failures instead of discarding successful answers.
211
216
  - `pi-fusion` does not own the footer.
212
217
  - Fusion sends your prompt and any inspected snippets to every panel model, and to the judge, through `pi-subagents`.
213
218
  - Reports include available per-panel and judge time, aggregate model time, usage, estimated cost, and model failure details. Missing provider usage is shown as unknown. `$0.0000` is a known zero cost.
214
219
  - `Model` is lifecycle metadata. `Configured model` is the profile request. Both appear when the run differs from the request.
215
- - `stopWhenPanelAgrees` is an opt-in profile setting. It requires matching high-confidence decision records with no request for more evidence, evaluates panelists in pairs so it can avoid starting later work, records skipped panelists in the report, and still runs the judge.
220
+ - `stopWhenPanelAgrees` is an opt-in profile setting. It requires matching high-confidence decision records with no request for more evidence, initially executes only the configured synthesis quorum (not always two) so it can avoid starting later work, records skipped panelists in the report, and still runs the judge.
216
221
  - Panel answers reach the judge in an order seeded from the run id, not in config order. A fixed order advantages the same member on every run, because judges favour whichever candidate they see first or last.
217
222
  - Panelists can search the web by opting in to the `fusion-panelist-web` agent, which requires `pi-web-providers`. Defaults stay local-only on purpose: tool names are a strict allowlist, so an agent declaring a tool whose extension is missing fails every task that uses it.
218
223
  - `synthesis: "merge"` switches from picking the best answer to merging answers that covered different facets, using the `fusion-composer` agent. Panel members get facets through their optional `question` field. See the user guide.
@@ -30,7 +30,9 @@ same working directory at the same time.
30
30
  - `web_research` is slow and expensive. Use `web_search` or `web_answer` unless
31
31
  the task truly needs a deep report.
32
32
 
33
- Work independently. Do not ask other agents. Do not run subagents.
33
+ Work independently. Do not consult other panelists. Do not run subagents.
34
+ Parent supervisor coordination is allowed for progress updates and deadline decisions.
35
+ When asked to finalize, return verified findings and name unfinished checks.
34
36
 
35
37
  Return concise Markdown with these sections.
36
38
 
@@ -18,7 +18,9 @@ Requires `pi-web-providers`. Without it every task using this agent fails with
18
18
  Work independently. Inspect relevant local files when the task needs code evidence.
19
19
  Search the web when the task turns on external facts: library behaviour, version
20
20
  differences, standards, or anything you would otherwise have to guess. Cite what
21
- you retrieved. Do not edit files. Do not ask other agents. Do not run subagents.
21
+ you retrieved. Do not edit files. Do not consult other panelists. Do not run subagents.
22
+ Parent supervisor coordination is allowed for progress updates and deadline decisions.
23
+ When asked to finalize, return verified findings and name unfinished checks.
22
24
 
23
25
  Return concise Markdown with these sections.
24
26
 
@@ -15,7 +15,9 @@ You are a pi-fusion panelist.
15
15
  Work independently. Inspect relevant local files when the task needs code evidence.
16
16
  You have no web access: answer from the repository and your own knowledge, and say
17
17
  so plainly when a question turns on external facts you cannot retrieve.
18
- Do not edit files. Do not ask other agents. Do not run subagents.
18
+ Do not edit files. Do not consult other panelists. Do not run subagents.
19
+ Parent supervisor coordination is allowed for progress updates and deadline decisions.
20
+ When asked to finalize, return verified findings and name unfinished checks.
19
21
 
20
22
  If the task defines an exact caller output contract, follow it instead of the
21
23
  sections and decision record below. Return only the caller's required syntax.
@@ -29,7 +29,7 @@ prompts, or both. **Mixing models is the main lever.** The config that
29
29
  `/fusion init` writes sets no `model`. By default you therefore get one model in
30
30
  three roles. Give each member its own `model` to get the real benefit.
31
31
 
32
- Fusion launches new panels through `pi-subagents` `workflowScript`; the panel and judge remain separate durable runs. Older runs created as a single `pi-subagents` chain remain supported when restored.
32
+ Fusion launches new panels through `pi-subagents` `workflowScript`; the panel and judge remain separate durable runs. At start, Fusion persists a small resolved profile snapshot (panel labels/models/roles, quorum, synthesis and judge settings) and uses it after restart, so later config edits cannot change an active run's reconciliation, report, or synthesis spawn. Older runs created before this snapshot, including single-chain runs, retain the legacy config-lookup restore fallback. Before each public RPC spawn, Fusion persists a spawn intent. If Pi crashes after that RPC might have started but before its remote run ID is saved, restore fails that local run with an explicit recovery warning instead of spawning a possible duplicate (public RPC cannot safely adopt by correlation key). A corrupt newest run snapshot is likewise refused rather than reviving an older active run.
33
33
 
34
34
  The base Pi session stays in control. Fusion is a tool for decisions, not a replacement for normal coding.
35
35
 
@@ -45,6 +45,8 @@ Preferred command shape:
45
45
  /fusion --panel <entries> <prompt>
46
46
  /fusion status
47
47
  /fusion stop
48
+ /fusion continue <fusion-run-id> <panelist-number>
49
+ /fusion finish <fusion-run-id> <panelist-number>
48
50
  /fusion init
49
51
  ```
50
52
 
@@ -152,18 +154,60 @@ Profile:
152
154
 
153
155
  - `panel`: one or more panel members
154
156
  - `judge`: judge agent config
155
- - `concurrency`: max parallel panelists. When `stopWhenPanelAgrees` is on, Fusion evaluates the first two panelists before launching another batch so it can avoid work after strong agreement.
156
- - `timeoutMs`: legacy shared wall-clock timeout in milliseconds. It remains supported as the fallback for both stages. New profiles default to 15 minutes through the stage-specific fields; existing explicit `timeoutMs` values are preserved. Use a stage-specific field when panel and synthesis need different deadlines.
157
- - `panelTimeoutMs`: panel workflow wall-clock timeout. It overrides `timeoutMs` for the panel. The default profile uses 15 minutes.
158
- - `judgeTimeoutMs`: synthesis workflow wall-clock timeout. It overrides `timeoutMs` for the judge or composer. The default profile uses 15 minutes.
157
+ - `concurrency`: max parallel panelists. Ordinary panels immediately start the next queued member when a slot finishes or fails, while retaining configured result order. When `stopWhenPanelAgrees` is on, Fusion initially executes only the resolved synthesis quorum (capped by `concurrency`) before launching another batch, so a non-default quorum—not always the first two—governs early agreement.
158
+ - `timeoutMs`: legacy shared wall-clock timeout in milliseconds. It is a fallback only; `/fusion status` warns when it supplied an effective deadline.
159
+ - `panelistSoftTimeoutMs`: optional soft deadline, measured from each child's actual start, not its queue time. Requires `pi-subagents` RPC advertising `nonRecoveringSteer`. It must leave more than one minute before the effective hard child deadline, and the panel budget must cover every concurrency wave plus grace.
160
+ - `panelistTimeoutMs`: per-panelist deadline. Fusion caps it below the enclosing panel deadline.
161
+ - `panelTimeoutMs`: panel workflow wall-clock deadline. When neither it nor legacy `timeoutMs` is set, the default is 15 minutes multiplied by `ceil(panel size / concurrency)`. An explicit short deadline can still cut queued work off; soft-deadline profiles reject insufficient wave budgets.
162
+ - `panelGraceMs`: reserved time between a child deadline and the enclosing panel deadline (default 5 seconds).
163
+ - `judgeTimeoutMs`: synthesis workflow deadline. For every deadline the precedence is per-run CLI/tool/RPC override, stage profile field, legacy `timeoutMs`, then the built-in default.
164
+ - `minimumSuccessfulPanelists`: `"majority"` (default), `"all"`, or a positive number. It is the panel quorum for synthesis. For a multi-member panel, numeric `1` is effectively `2`: synthesis needs two candidate answers. A one-member panel remains a direct single-panel result.
159
165
  - `context`: `fresh` or `fork`
160
- - `stopWhenPanelAgrees`: optional boolean, default `false`. When it is on, Fusion can stop the panelists that have not finished yet. All four conditions must hold: two or more finished panelists give the same normalized recommendation, every one of them reports `high` confidence, none of them asks for more evidence, and work remains. The judge still runs over the answers already collected. This policy is fixed on purpose. There is no threshold to tune.
166
+ - `stopWhenPanelAgrees`: optional boolean, default `false`. When it is on, Fusion can stop the panelists that have not finished yet only after the configured synthesis quorum is already met. The agreement conditions also require two or more finished panelists to give the same normalized recommendation, every one to report `high` confidence, none to ask for more evidence, and work to remain. Thus an `"all"` quorum never skips unfinished panelists. The judge still runs over the answers already collected. This policy is fixed on purpose. There is no threshold to tune.
161
167
  - `synthesis`: rarely needed. Inferred from the panel — any member with a `question` means `merge`, otherwise `select`. Set it only to override that. See [Synthesis modes](#synthesis-modes).
162
168
  - `blindPanelLabels`: optional boolean, default `false`. When it is on, the judge sees `Candidate A`, `Candidate B`, and so on, instead of the configured labels. Fusion also withholds agent names and artifact paths, because they contain the member id. A role label reads as an authority cue before the judge compares any content. Your report always shows the real names.
163
169
  - `panelToolBudget`: optional `{ "soft": n, "hard": n, "block": "*" | [tools...] }` applied to each panelist. Fusion uses `{ "soft": 8, "hard": 12, "block": "*" }` when omitted. After `hard`, the selected tools are blocked so the panelist can still finalise.
164
170
  - `judgeToolBudget`: optional `{ "soft": n, "hard": n, "block": "*" | [tools...] }` for the judge or composer. Fusion uses `{ "soft": 8, "hard": 12, "block": "*" }` when omitted. `soft` is a nudge. After `hard`, the selected tools are blocked so synthesis can still finalise. `soft` or `hard` must be positive integers when present, and `soft` must not be larger than `hard` when both are present. Legacy soft-only budgets remain valid.
165
171
 
166
- Timeouts are hard workflow deadlines. A child terminated at the deadline can report exit 143. Fusion keeps completed panel slots and failed panel slots separate, never relabels a compact completion payload, and fails closed when lifecycle sources disagree. A timed-out judge never becomes a panel-only success. Synthesis also fails closed when any configured panelist is missing or failed, unless the missing slots were explicitly stopped after strong agreement.
172
+ Timeouts are hard workflow deadlines. A child terminated at the deadline can report exit 143. Fusion durably keeps verified completed slots, turns terminal running/interrupted slots into typed failures, and fails closed when lifecycle sources genuinely disagree. A timed-out judge never becomes a panel-only success. When at least one valid panel result exists, Fusion produces either synthesis at quorum or an explicitly unsynthesized partial report below quorum; failures and timeouts are disclosed as missing coverage. Only zero successful outputs fail outright. Fusion never automatically retries a failed panelist, restarts a panel, or extends a deadline.
173
+
174
+ ### Soft deadline decisions
175
+
176
+ For a six-member panel with concurrency four, a bounded review budget can be:
177
+
178
+ ```json
179
+ {
180
+ "panelistSoftTimeoutMs": 600000,
181
+ "panelistTimeoutMs": 960000,
182
+ "panelTimeoutMs": 2100000,
183
+ "panelGraceMs": 30000,
184
+ "judgeTimeoutMs": 600000
185
+ }
186
+ ```
187
+
188
+ After ten minutes of actual child runtime, Fusion asks the parent agent to decide
189
+ and requests a progress update from that panelist. The parent may ask the user,
190
+ then call `resolve_fusion_deadline` with the Fusion `runId`, one-based `panelist`
191
+ number, and `decision: "continue"` or `"finish"`. Users can use the corresponding
192
+ `/fusion continue` or `/fusion finish` command. The judge is not involved.
193
+
194
+ A decision is accepted only for the same live child in a pending request. No
195
+ reply within one minute requests finalization. A continuation uses the already
196
+ reserved budget: in this example, investigation ends at minute 15 and the hard
197
+ stop remains minute 16. There is no second extension. Decisions survive reload;
198
+ expired requests and completed/replaced children cannot receive a new grant.
199
+
200
+ Guidance is sent through non-recovering `steer` RPC. Receipts are not proof of
201
+ model compliance. Missing routes are visible in `/fusion status`; Fusion does
202
+ not revive a child, reset a timer, or retry inference. A blocked provider or tool
203
+ may not produce a final answer before the hard stop. Verified completed answers
204
+ remain available for partial reporting.
205
+
206
+ At a terminal workflow deadline, incomplete lifecycle snapshots get up to five
207
+ seconds to settle. A late child error keeps its actual cause. After that window,
208
+ slots still absent with unambiguous identities become deadline failures. Duplicate
209
+ or conflicting identities still fail closed. Incomplete *successful* workflows
210
+ are not silently repaired into successes.
167
211
 
168
212
  Panel member:
169
213
 
@@ -294,9 +338,7 @@ run does not fail, so a mismatch appears as an empty report, not as an error.
294
338
  }
295
339
  ```
296
340
 
297
- Under `merge`, one surviving panelist still goes to the composer. Fusion does
298
- not return that answer directly. One facet is not the answer, and the report must
299
- name what is missing.
341
+ Under `merge`, surviving outputs at quorum go to the composer even when some facets are unavailable. The composer must name uncovered facets rather than presenting complete coverage. Below quorum Fusion posts an explicitly partial coverage report; it never presents one facet as the full answer.
300
342
 
301
343
  Judge:
302
344
 
@@ -483,7 +525,9 @@ For an economical mixed panel, give each member a fast or inexpensive frontier,
483
525
  - raise `panelTimeoutMs` or `judgeTimeoutMs` for slower models
484
526
  - keep `panelToolBudget` and `judgeToolBudget` bounded so agents finalise before the deadline
485
527
  - inspect `/fusion status`; panel failures and the workflow timeout must both be present
486
- - retry only after the run is terminal
528
+ - a timeout ends that child/run attempt; Fusion does not retry panelists, restart the panel, or extend deadlines
529
+ - failed-only retry is deliberately not exposed yet: terminal failure state persists the failed panel slot indices for recovery/provenance, but `/fusion` starts a new independent run rather than replaying only those slots
530
+ - retry manually only after the run is terminal
487
531
 
488
532
  Run is stuck or no longer useful:
489
533
 
@@ -501,4 +545,4 @@ Notes:
501
545
 
502
546
  - `Panel run` is the normal panel phase for new Fusion runs.
503
547
  - `Judge run` is the normal synthesis phase for new runs. `Fallback judge run` appears only while restoring a legacy chain that completed without its judge result.
504
- - If `pi-subagents` completion notifications are delayed or missed, Fusion still reconciles from lifecycle artifacts written under the subagent async run directory.
548
+ - If `pi-subagents` completion notifications are delayed or missed, Fusion still reconciles from lifecycle artifacts written under the subagent async run directory. The full result artifact is preferred over compact completion events.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-fusion",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Stronger answers for hard Pi questions via a parallel model panel + judge, built on pi-subagents",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -76,10 +76,11 @@ better.
76
76
  ## After the call
77
77
 
78
78
  The tool returns at once. The panel and the synthesis step run in the background,
79
- and Fusion posts the report when they finish.
79
+ and Fusion posts the report when they finish. A panel deadline can produce an explicitly partial report that names unavailable perspectives and timeout coverage gaps.
80
80
 
81
81
  - Do not call the tool again while a run is active. It returns a conflict with
82
82
  the id of the active run.
83
83
  - Do not summarize or predict the report. Wait for it.
84
84
  - To show progress or stop a run, tell the user to type `/fusion status` or
85
85
  `/fusion stop`. No tool does this.
86
+ - Do not invoke another review automatically after a partial report or timeout. Fusion does not retry failed panelists; users can inspect `/fusion status` and manually rerun only after the current run is terminal.
package/src/commands.ts CHANGED
@@ -19,6 +19,8 @@ const FUSION_HELP = [
19
19
  "/fusion --panel <models> <prompt>",
20
20
  "/fusion status",
21
21
  "/fusion stop",
22
+ "/fusion continue <fusion-run-id> <panelist-number>",
23
+ "/fusion finish <fusion-run-id> <panelist-number>",
22
24
  "/fusion init",
23
25
  ].join("\n");
24
26
 
@@ -29,6 +31,7 @@ export interface FusionRuntimeCommandHandler {
29
31
  ): Promise<unknown>;
30
32
  showStatus(ctx: ExtensionCommandContext): Promise<unknown>;
31
33
  cancelActiveRun(ctx: ExtensionCommandContext): Promise<unknown>;
34
+ resolvePanelDeadline?(runId: string, panelist: number, decision: "continue" | "finish"): Promise<unknown>;
32
35
  }
33
36
 
34
37
  export function registerFusionCommands(
@@ -43,6 +46,20 @@ export function registerFusionCommands(
43
46
  return;
44
47
  }
45
48
 
49
+ const decision = args.trim().match(/^(continue|finish)\s+(\S+)\s+([1-9]\d*)$/);
50
+ if (decision && handler.resolvePanelDeadline) {
51
+ try {
52
+ await handler.resolvePanelDeadline(decision[2]!, Number(decision[3]), decision[1] === "continue" ? "continue" : "finish");
53
+ ctx.ui.notify("Deadline decision recorded; guidance requested. Hard deadline unchanged.", "info");
54
+ } catch (error: unknown) {
55
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
56
+ }
57
+ return;
58
+ }
59
+ if (/^(continue|finish)(?:\s|$)/.test(args.trim())) {
60
+ ctx.ui.notify("Use /fusion continue|finish <fusion-run-id> <panelist-number>.", "error");
61
+ return;
62
+ }
46
63
  const inlineCommand = parseFusionInlineCommand(args);
47
64
  if (inlineCommand === "init") {
48
65
  await runFusionInit(ctx);
package/src/config.ts CHANGED
@@ -325,18 +325,45 @@ function isFusionProfile(value: unknown): value is FusionProfile {
325
325
  return false;
326
326
  if (value.timeoutMs !== undefined && !isPositiveInteger(value.timeoutMs))
327
327
  return false;
328
+ if (value.panelistSoftTimeoutMs !== undefined && !isPositiveInteger(value.panelistSoftTimeoutMs)) return false;
329
+ if (
330
+ value.panelistTimeoutMs !== undefined &&
331
+ !isPositiveInteger(value.panelistTimeoutMs)
332
+ ) {
333
+ return false;
334
+ }
328
335
  if (
329
336
  value.panelTimeoutMs !== undefined &&
330
337
  !isPositiveInteger(value.panelTimeoutMs)
331
338
  ) {
332
339
  return false;
333
340
  }
341
+ if (
342
+ value.panelGraceMs !== undefined &&
343
+ !isPositiveInteger(value.panelGraceMs)
344
+ ) {
345
+ return false;
346
+ }
347
+ const panelTimeoutMs = value.panelTimeoutMs ?? value.timeoutMs ?? 900_000;
348
+ const panelGraceMs = value.panelGraceMs ?? 5_000;
349
+ // Never accept a configuration that would leave child panelists a 1ms
350
+ // timeout after deadline capping. Overrides receive the same validation when
351
+ // their effective values are resolved at run start.
352
+ if (panelGraceMs >= panelTimeoutMs) return false;
334
353
  if (
335
354
  value.judgeTimeoutMs !== undefined &&
336
355
  !isPositiveInteger(value.judgeTimeoutMs)
337
356
  ) {
338
357
  return false;
339
358
  }
359
+ if (
360
+ value.minimumSuccessfulPanelists !== undefined &&
361
+ value.minimumSuccessfulPanelists !== "majority" &&
362
+ value.minimumSuccessfulPanelists !== "all" &&
363
+ !isPositiveInteger(value.minimumSuccessfulPanelists)
364
+ ) {
365
+ return false;
366
+ }
340
367
  if (value.context !== undefined && !isFusionContextMode(value.context))
341
368
  return false;
342
369
  if (
@@ -1,8 +1,8 @@
1
1
  import { FusionArgsError } from "./errors.js";
2
- import type { ParsedFusionArgs } from "./types.js";
2
+ import type { FusionTimeoutOverrides, ParsedFusionArgs } from "./types.js";
3
3
 
4
4
  const FUSION_USAGE =
5
- "Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion --panel <models> <prompt> | /fusion status | /fusion stop | /fusion init.";
5
+ "Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion --panel <models> <prompt> [--panelist-timeout-ms n --panel-timeout-ms n --panel-grace-ms n --judge-timeout-ms n] | /fusion status | /fusion stop | /fusion init.";
6
6
 
7
7
  export type FusionInlineCommand = "init" | "status" | "stop";
8
8
 
@@ -28,6 +28,13 @@ export function parseFusionArgs(
28
28
 
29
29
  let profile: string | undefined;
30
30
  let panel: string[] | undefined;
31
+ const timeoutOverrides: FusionTimeoutOverrides = {};
32
+ const timeoutOptions: Record<string, keyof FusionTimeoutOverrides> = {
33
+ "--panelist-timeout-ms": "panelistTimeoutMs",
34
+ "--panel-timeout-ms": "panelTimeoutMs",
35
+ "--panel-grace-ms": "panelGraceMs",
36
+ "--judge-timeout-ms": "judgeTimeoutMs",
37
+ };
31
38
  const promptTokens: string[] = [];
32
39
 
33
40
  for (let index = 0; index < tokens.length; index++) {
@@ -81,6 +88,25 @@ export function parseFusionArgs(
81
88
  continue;
82
89
  }
83
90
 
91
+ const timeoutKey = timeoutOptions[token];
92
+ const timeoutEquals = Object.entries(timeoutOptions).find(([option]) =>
93
+ token.startsWith(`${option}=`),
94
+ );
95
+ if (promptTokens.length === 0 && (timeoutKey || timeoutEquals)) {
96
+ const key = timeoutKey ?? timeoutEquals?.[1];
97
+ const raw = timeoutKey ? tokens[index + 1] : token.slice((timeoutEquals?.[0].length ?? 0) + 1);
98
+ const value = raw ? Number(raw) : NaN;
99
+ if (!key || !Number.isInteger(value) || value <= 0) {
100
+ throw new FusionArgsError(`Timeout options require a positive integer milliseconds value. ${FUSION_USAGE}`);
101
+ }
102
+ if (timeoutOverrides[key] !== undefined) {
103
+ throw new FusionArgsError(`${token.split("=")[0]} can only be provided once.`);
104
+ }
105
+ timeoutOverrides[key] = value;
106
+ if (timeoutKey) index++;
107
+ continue;
108
+ }
109
+
84
110
  if (promptTokens.length === 0 && token.startsWith("-")) {
85
111
  throw new FusionArgsError(`Unknown option ${token}. ${FUSION_USAGE}`);
86
112
  }
@@ -95,6 +121,7 @@ export function parseFusionArgs(
95
121
  prompt,
96
122
  ...(profile ? { profile } : {}),
97
123
  ...(panel ? { panel } : {}),
124
+ ...(Object.keys(timeoutOverrides).length ? { timeoutOverrides } : {}),
98
125
  };
99
126
  }
100
127
 
package/src/fusion-rpc.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  CallerOutputContract,
13
13
  FusionPhase,
14
14
  FusionRun,
15
+ FusionTimeoutOverrides,
15
16
  ParsedFusionArgs,
16
17
  } from "./types.js";
17
18
  import { isNonEmptyString, isRecord } from "./utils.js";
@@ -151,6 +152,7 @@ interface StartParams {
151
152
  profile?: string;
152
153
  operationId: string;
153
154
  outputContract?: CallerOutputContract;
155
+ timeoutOverrides?: FusionTimeoutOverrides;
154
156
  }
155
157
 
156
158
  interface RunParams {
@@ -425,6 +427,8 @@ function parseStartParams(input: unknown): StartParams {
425
427
  );
426
428
  }
427
429
 
430
+ const timeoutOverrides = parseTimeoutOverrides(input);
431
+
428
432
  const outputContract = input.outputContract;
429
433
  if (outputContract !== undefined && !isCallerOutputContract(outputContract)) {
430
434
  throw invalidParams(
@@ -437,9 +441,31 @@ function parseStartParams(input: unknown): StartParams {
437
441
  operationId,
438
442
  ...(profile === undefined ? {} : { profile }),
439
443
  ...(outputContract === undefined ? {} : { outputContract }),
444
+ ...(timeoutOverrides ? { timeoutOverrides } : {}),
440
445
  };
441
446
  }
442
447
 
448
+ function parseTimeoutOverrides(
449
+ input: Record<string, unknown>,
450
+ ): FusionTimeoutOverrides | undefined {
451
+ const fields = [
452
+ ["panelistTimeoutMs", "panelistTimeoutMs"],
453
+ ["panelTimeoutMs", "panelTimeoutMs"],
454
+ ["panelGraceMs", "panelGraceMs"],
455
+ ["judgeTimeoutMs", "judgeTimeoutMs"],
456
+ ] as const;
457
+ const overrides: FusionTimeoutOverrides = {};
458
+ for (const [wireName, key] of fields) {
459
+ const value = input[wireName];
460
+ if (value === undefined) continue;
461
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
462
+ throw invalidParams(`${wireName} must be a positive integer when provided.`);
463
+ }
464
+ overrides[key] = value;
465
+ }
466
+ return Object.keys(overrides).length ? overrides : undefined;
467
+ }
468
+
443
469
  function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
444
470
  return {
445
471
  prompt: input.prompt,
@@ -448,6 +474,9 @@ function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
448
474
  ...(input.outputContract === undefined
449
475
  ? {}
450
476
  : { outputContract: input.outputContract }),
477
+ ...(input.timeoutOverrides === undefined
478
+ ? {}
479
+ : { timeoutOverrides: input.timeoutOverrides }),
451
480
  };
452
481
  }
453
482
 
package/src/index.ts CHANGED
@@ -25,29 +25,51 @@ function registerFusionTool(
25
25
  "Pass panel only when the user names the models to compare. Otherwise omit it and let the profile decide.",
26
26
  ],
27
27
  parameters: Type.Object({
28
- prompt: Type.String({ description: "What to review or discuss" }),
28
+ prompt: Type.String({
29
+ minLength: 1,
30
+ pattern: ".*\\S.*",
31
+ description: "What to review or discuss (must contain non-whitespace text)",
32
+ }),
29
33
  profile: Type.Optional(
30
- Type.String({ description: "Fusion profile name (optional)" }),
34
+ Type.String({ minLength: 1, description: "Fusion profile name (optional)" }),
31
35
  ),
32
36
  panel: Type.Optional(
33
- Type.Array(Type.String(), {
37
+ Type.Array(Type.String({ minLength: 1 }), {
38
+ minItems: 1,
34
39
  description:
35
40
  "Models to use for this run, overriding the profile panel. Each entry is <model> or <agent>:<model>. Use only when the user names specific models.",
36
41
  }),
37
42
  ),
43
+ panelistTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Per-panelist deadline in milliseconds" })),
44
+ panelTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Panel workflow deadline in milliseconds" })),
45
+ panelGraceMs: Type.Optional(Type.Integer({ minimum: 1, description: "Reserved grace between child and panel deadlines in milliseconds" })),
46
+ judgeTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Judge/composer deadline in milliseconds" })),
38
47
  }),
39
48
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
40
49
  const result = await orchestrator.startRun(
41
50
  {
42
51
  prompt: params.prompt,
43
- ...(params.profile ? { profile: params.profile } : {}),
44
- ...(params.panel?.length ? { panel: params.panel } : {}),
52
+ ...(params.profile !== undefined ? { profile: params.profile } : {}),
53
+ ...(params.panel !== undefined ? { panel: params.panel } : {}),
54
+ ...(params.panelistTimeoutMs !== undefined ||
55
+ params.panelTimeoutMs !== undefined ||
56
+ params.panelGraceMs !== undefined ||
57
+ params.judgeTimeoutMs !== undefined
58
+ ? {
59
+ timeoutOverrides: {
60
+ ...(params.panelistTimeoutMs !== undefined ? { panelistTimeoutMs: params.panelistTimeoutMs } : {}),
61
+ ...(params.panelTimeoutMs !== undefined ? { panelTimeoutMs: params.panelTimeoutMs } : {}),
62
+ ...(params.panelGraceMs !== undefined ? { panelGraceMs: params.panelGraceMs } : {}),
63
+ ...(params.judgeTimeoutMs !== undefined ? { judgeTimeoutMs: params.judgeTimeoutMs } : {}),
64
+ },
65
+ }
66
+ : {}),
45
67
  },
46
68
  ctx,
47
69
  );
48
70
  const text =
49
71
  result.status === "started"
50
- ? "Fusion panel review started. The report will be posted when the panel and judge finish."
72
+ ? "Fusion panel review started. The report will be posted when the panel finishes; synthesis may be skipped below quorum."
51
73
  : result.status === "conflict"
52
74
  ? `A fusion run is already active (${result.activeRunId}). Do not start another; wait for its report.`
53
75
  : `Fusion review failed to start: ${result.status === "failed" ? result.error : result.status}`;
@@ -57,6 +79,12 @@ function registerFusionTool(
57
79
  prompt: params.prompt,
58
80
  profile: params.profile,
59
81
  panel: params.panel,
82
+ timeoutOverrides: {
83
+ panelistTimeoutMs: params.panelistTimeoutMs,
84
+ panelTimeoutMs: params.panelTimeoutMs,
85
+ panelGraceMs: params.panelGraceMs,
86
+ judgeTimeoutMs: params.judgeTimeoutMs,
87
+ },
60
88
  status: result.status,
61
89
  },
62
90
  };
@@ -70,11 +98,26 @@ export default function fusionExtension(pi: ExtensionAPI): void {
70
98
  const orchestrator = new FusionOrchestrator({
71
99
  rpc: new SubagentsRpcClient({ events: pi.events }),
72
100
  runStore: store,
73
- sendMessage: (message) => pi.sendMessage(message),
101
+ sendMessage: (message, options) => pi.sendMessage(message, options),
74
102
  });
75
103
 
76
104
  registerFusionCommands(pi, orchestrator);
77
105
  registerFusionTool(pi, orchestrator);
106
+ pi.registerTool({
107
+ name: "resolve_fusion_deadline",
108
+ label: "Fusion Deadline Decision",
109
+ description: "Answer a pending Fusion soft-deadline request. Continue once within the existing hard budget, or ask the panelist to finish with current findings. Does not restart runs or extend hard deadlines. Delivery receipt is not proof the model complied.",
110
+ parameters: Type.Object({
111
+ runId: Type.String({ minLength: 1 }),
112
+ panelist: Type.Integer({ minimum: 1, description: "One-based panelist number from the deadline notice" }),
113
+ decision: Type.String({ enum: ["continue", "finish"] }),
114
+ }),
115
+ async execute(_id, params) {
116
+ if (params.decision !== "continue" && params.decision !== "finish") throw new Error("Expected continue or finish.");
117
+ const details = await orchestrator.resolvePanelDeadline(params.runId, params.panelist, params.decision);
118
+ return { content: [{ type: "text", text: "Decision recorded and guidance requested. The hard deadline is unchanged; the receipt does not prove model compliance." }], details };
119
+ },
120
+ });
78
121
 
79
122
  const unsubscribeComplete = pi.events.on(
80
123
  SUBAGENT_ASYNC_COMPLETE_EVENT,