@dieulc/workflow 0.10.0 โ†’ 0.11.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/review.ts CHANGED
@@ -1612,8 +1612,11 @@ export function reviewFooterSegment(
1612
1612
  paused: boolean,
1613
1613
  /** A round owns the turn before its first state event (cold boot). */
1614
1614
  starting = false,
1615
+ /** The reviewer finished; the plan is waiting for Approve/Revise. */
1616
+ handoff = false,
1615
1617
  ): string {
1616
1618
  if (paused) return "๐Ÿ” review paused โ€” /review-resume to continue";
1619
+ if (handoff) return "๐Ÿ” review ยท awaiting decision";
1617
1620
  if (!state) return starting ? "๐Ÿ” review ยท starting" : "";
1618
1621
  return reviewStateSummary(state);
1619
1622
  }
@@ -1632,6 +1635,8 @@ export function reviewStatusVisible(input: {
1632
1635
  roundInFlight?: boolean;
1633
1636
  roundActive?: boolean;
1634
1637
  paused?: boolean;
1638
+ /** The reviewer finished and the browser/inline handoff owns the turn. */
1639
+ handoff?: boolean;
1635
1640
  /**
1636
1641
  * The retained round state's phase, when one exists. A retained *terminal*
1637
1642
  * phase must not make the line visible during a new round's cold boot
@@ -1640,6 +1645,7 @@ export function reviewStatusVisible(input: {
1640
1645
  phase?: ReviewPhase;
1641
1646
  }): boolean {
1642
1647
  if (input.paused === true) return true;
1648
+ if (input.handoff === true) return true;
1643
1649
  if (input.roundInFlight !== true && input.roundActive !== true) return false;
1644
1650
  // `undefined` means the round has not emitted a state yet; there is nothing
1645
1651
  // stale to paint and the lock/working message still describe the round.
@@ -1659,11 +1665,13 @@ export function reviewStatusText(input: {
1659
1665
  roundInFlight?: boolean;
1660
1666
  roundActive?: boolean;
1661
1667
  paused?: boolean;
1668
+ handoff?: boolean;
1662
1669
  }): string {
1663
1670
  const visible = reviewStatusVisible({
1664
1671
  roundInFlight: input.roundInFlight,
1665
1672
  roundActive: input.roundActive,
1666
1673
  paused: input.paused,
1674
+ handoff: input.handoff,
1667
1675
  phase: input.state?.phase,
1668
1676
  });
1669
1677
  return visible
@@ -1671,6 +1679,7 @@ export function reviewStatusText(input: {
1671
1679
  input.state,
1672
1680
  input.paused === true,
1673
1681
  input.roundInFlight === true,
1682
+ input.handoff === true,
1674
1683
  )
1675
1684
  : "";
1676
1685
  }
@@ -1723,18 +1732,77 @@ export function nextReviewRound(input: {
1723
1732
  return { round, allowed: round <= max };
1724
1733
  }
1725
1734
 
1726
- // โ”€โ”€ Mode-switch lock (plan 2026-10-25) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1735
+ // โ”€โ”€ Review turn + mode-switch lock (plan 2026-11-14) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1727
1736
  //
1728
- // While a review owns the turn, mode transitions are refused: switching to
1729
- // Build mid-review would swap the tool set and suspend the reviewer, and
1730
- // switching to Default would leave the reviewer running outside Plan Mode.
1731
- // The lock is a PROJECTION over existing state (never persisted), so every
1732
- // existing exit โ€” land, fail, pause/resume, abort, suspend, teardown โ€”
1733
- // releases it automatically. The post-review handoff prompt is deliberately
1734
- // NOT part of the lock: the independent reviewer has already finished.
1737
+ // Review is an exclusive turn, not a background job: from the moment the
1738
+ // planner hands its plan to the reviewer until the browser/inline decision is
1739
+ // applied, no prompt and no mode transition is accepted. The turn is an
1740
+ // explicit state (unlike the old derived lock) because the handoff phase has
1741
+ // no running child session and must still be locked.
1742
+ //
1743
+ // idle -> running -> paused -> running -> ... -> handoff -> transition -> idle
1744
+ // \-> handoff (reviewer failed/skipped) -> transition -> idle
1745
+ //
1746
+ // `transition` is the short, internal window while a decision stops the
1747
+ // reviewer, applies Build/Plan and sends the follow-up. It is locked so a
1748
+ // queued prompt cannot race the mode change.
1749
+
1750
+ /** The exclusive Review turn. `idle` means no review owns the session. */
1751
+ export type ReviewTurnPhase =
1752
+ | "idle"
1753
+ | "running"
1754
+ | "paused"
1755
+ | "handoff"
1756
+ | "transition";
1757
+
1758
+ export function reviewTurnOwns(phase: ReviewTurnPhase): boolean {
1759
+ return phase !== "idle";
1760
+ }
1761
+
1762
+ /** Commands a user may run while Review owns the turn. */
1763
+ export function reviewInputAllowed(text: string): boolean {
1764
+ const value = text.trim();
1765
+ if (!value.startsWith("/")) return false;
1766
+ return (
1767
+ /^\/review(?:\s|-|$)/i.test(value) ||
1768
+ /^\/model(?:\s|$)/i.test(value) ||
1769
+ /^\/thinking(?:\s|$)/i.test(value) ||
1770
+ /^\/profile(?:\s|$)/i.test(value) ||
1771
+ /^\/role(?:\s|$)/i.test(value)
1772
+ );
1773
+ }
1774
+
1775
+ export type ReviewInputDecision =
1776
+ | { action: "allow" }
1777
+ | { action: "block"; notice: string };
1778
+
1779
+ /** One decision for `pi.on("input")`; pure so the matrix is testable. */
1780
+ export function reviewInputDecision(
1781
+ text: string,
1782
+ phase: ReviewTurnPhase,
1783
+ ): ReviewInputDecision {
1784
+ if (!reviewTurnOwns(phase)) return { action: "allow" };
1785
+ if (reviewInputAllowed(text)) return { action: "allow" };
1786
+ const state =
1787
+ phase === "paused"
1788
+ ? "paused"
1789
+ : phase === "handoff"
1790
+ ? "awaiting a decision"
1791
+ : phase === "transition"
1792
+ ? "applying the decision"
1793
+ : "running";
1794
+ return {
1795
+ action: "block",
1796
+ notice: `Review is ${state} โ€” prompts are locked. /review-pause, /review-resume, /review-abort, or change model/profile.`,
1797
+ };
1798
+ }
1735
1799
 
1736
1800
  /** Which mode-switch lock is active, if any. Derived, never persisted. */
1737
- export type ReviewModeLockKind = "running" | "paused";
1801
+ export type ReviewModeLockKind =
1802
+ | "running"
1803
+ | "paused"
1804
+ | "handoff"
1805
+ | "transition";
1738
1806
 
1739
1807
  export interface ReviewModeLockInput {
1740
1808
  /** `activeReview !== undefined` โ€” set before the child session boots. */
@@ -1745,6 +1813,10 @@ export interface ReviewModeLockInput {
1745
1813
  paused?: boolean;
1746
1814
  /** The gated handoff phase; `"auditing"` counts as running. */
1747
1815
  gatePhase?: ReviewHandoffPhase;
1816
+ /** The browser/inline decision owns the session. */
1817
+ handoff?: boolean;
1818
+ /** A decision is being applied (stop reviewer -> mode -> follow-up). */
1819
+ transition?: boolean;
1748
1820
  }
1749
1821
 
1750
1822
  /**
@@ -1767,9 +1839,30 @@ export function reviewModeLockKind(
1767
1839
  }
1768
1840
  if (input.gatePhase === "paused") return "paused";
1769
1841
  if (input.gatePhase === "auditing") return "running";
1842
+ if (input.transition === true) return "transition";
1843
+ if (input.handoff === true) return "handoff";
1770
1844
  return undefined;
1771
1845
  }
1772
1846
 
1847
+ /**
1848
+ * The narrower "a reviewer round or pause owns the turn" predicate.
1849
+ * Decision handlers must use this (not the full lock): the handoff they are
1850
+ * about to consume is not a stale decision.
1851
+ */
1852
+ export function reviewRoundOwnsTurn(input: {
1853
+ roundInFlight?: boolean;
1854
+ roundActive?: boolean;
1855
+ paused?: boolean;
1856
+ gatePhase?: ReviewHandoffPhase;
1857
+ }): boolean {
1858
+ return (
1859
+ input.paused === true ||
1860
+ input.roundInFlight === true ||
1861
+ input.roundActive === true ||
1862
+ input.gatePhase === "auditing"
1863
+ );
1864
+ }
1865
+
1773
1866
  /**
1774
1867
  * Phase to use when `/review-resume` starts a paused gated round.
1775
1868
  * A gated pause becomes an active audit again; a manual review (idle gate)
@@ -1803,9 +1896,16 @@ export function reviewHandoffParks(input: {
1803
1896
 
1804
1897
  /** One short notice for a blocked mode transition. */
1805
1898
  export function reviewModeLockNotice(kind: ReviewModeLockKind): string {
1806
- return kind === "paused"
1807
- ? "Review is paused โ€” mode switching stays locked. /review-resume to continue, /review-abort to stop, /review-mode off to end review."
1808
- : "Review is running โ€” mode switching is locked. ESC or /review-pause to pause, /review-resume to continue, /review-abort to stop, /review-mode off to end review.";
1899
+ if (kind === "paused") {
1900
+ return "Review is paused โ€” mode switching stays locked. /review-resume to continue, /review-abort to stop.";
1901
+ }
1902
+ if (kind === "handoff") {
1903
+ return "Review decision pending โ€” mode switching is locked. Approve or annotate in the review tab, or /review-abort to skip.";
1904
+ }
1905
+ if (kind === "transition") {
1906
+ return "Review decision is being applied โ€” mode switching is briefly locked.";
1907
+ }
1908
+ return "Review is running โ€” mode switching is locked. ESC or /review-pause to pause, /review-resume to continue, /review-abort to stop.";
1809
1909
  }
1810
1910
 
1811
1911
  /** Footer marker appended to the single `"workflow"` status while locked. */
@@ -1874,23 +1974,80 @@ export function truncateErrorText(input: unknown, max: number = MAX_ERROR_TEXT):
1874
1974
  }
1875
1975
 
1876
1976
  /**
1877
- * Extract the provider error message when a run stopped on an error: scan an
1878
- * `agent_end` event's messages from the end for the last assistant message
1879
- * with `stopReason === "error"`. Structural on purpose โ€” SDK message shapes
1880
- * are not imported here. Returns undefined for clean runs and junk input.
1977
+ * True when an error text describes an *abort*, not a provider failure.
1978
+ *
1979
+ * Review Mode deliberately aborts the reviewer's tail after `review_pass_done`
1980
+ * (and on pause/teardown). Node rejects an aborted operation with its default
1981
+ * reason (`DOMException: This operation was aborted`); provider SDKs use
1982
+ * `Request was aborted`; the Pi SDK's backoff cancellation reports
1983
+ * `Retry cancelled`. The SDK's `lazyStream` setup path can record any of those
1984
+ * as a `stopReason: "error"` assistant message, so a stop-reason check alone
1985
+ * is not enough to tell an abort from a provider fault.
1986
+ *
1987
+ * Timeouts are deliberately NOT aborts: `AbortSignal.timeout()` rejects with
1988
+ * `The operation was aborted due to timeout`, which is a transient provider
1989
+ * failure and must keep its recovery semantics.
1990
+ *
1991
+ * Matching is anchored at the start of the flattened text so a provider payload
1992
+ * that merely mentions "aborted" is not downgraded. Pure and total.
1881
1993
  */
1882
- export function lastAssistantErrorStop(messages: unknown): string | undefined {
1883
- if (!Array.isArray(messages)) return undefined;
1994
+ export function isAbortStopText(input: unknown): boolean {
1995
+ const text =
1996
+ typeof input === "string" ? input.replace(/\s+/g, " ").trim() : "";
1997
+ if (!text) return false;
1998
+ // Checked first: "The operation was aborted due to timeout" is a timeout.
1999
+ if (/\b(?:timed?\s*out|timeout)\b/i.test(text)) return false;
2000
+ // `AgentSession._finishCancelledRetry()` reports a cancelled backoff this
2001
+ // way; it is our own abort, never provider health.
2002
+ if (/^retry\s+cancell?ed\b/i.test(text)) return true;
2003
+ return (
2004
+ /^(?:this|the|request|operation|signal)\b[^.\n]{0,40}abort(?:ed|ing)?\b/i.test(
2005
+ text,
2006
+ ) ||
2007
+ /^abort(?:ed)?\s*error\b/i.test(text) ||
2008
+ /^aborted\b/i.test(text)
2009
+ );
2010
+ }
2011
+
2012
+ /** How the reviewer's last assistant message stopped. */
2013
+ export type ReviewStopClassification =
2014
+ | { kind: "clean" }
2015
+ | { kind: "aborted"; message?: string }
2016
+ | { kind: "provider-error"; message: string };
2017
+
2018
+ /**
2019
+ * Classify the outcome of a child run from an `agent_end` event's messages.
2020
+ *
2021
+ * Structural on purpose โ€” SDK message shapes are not imported here. Scans
2022
+ * backwards to the last assistant message (the only one that decides the run's
2023
+ * outcome); a missing assistant message or junk input is `clean`. An error stop
2024
+ * with an abort-shaped message is `aborted` even when the SDK labelled it
2025
+ * `error` (its setup path does not check the abort signal), so callers never
2026
+ * report a host cancellation as provider health.
2027
+ */
2028
+ export function classifyAssistantStop(
2029
+ messages: unknown,
2030
+ ): ReviewStopClassification {
2031
+ if (!Array.isArray(messages)) return { kind: "clean" };
1884
2032
  for (let i = messages.length - 1; i >= 0; i--) {
1885
2033
  const rec = messages[i] as Record<string, unknown> | null | undefined;
1886
2034
  if (!rec || typeof rec !== "object") continue;
1887
2035
  if (rec.role !== "assistant") continue;
1888
- // Last assistant message reached: a non-error stop means a clean run.
1889
- if (rec.stopReason !== "error") return undefined;
1890
- const err = typeof rec.errorMessage === "string" ? rec.errorMessage : "";
1891
- return err || "unknown provider error";
2036
+ // Last assistant message reached: this one decides the run outcome.
2037
+ const stopReason = rec.stopReason;
2038
+ if (stopReason === "aborted") {
2039
+ const message =
2040
+ typeof rec.errorMessage === "string" ? rec.errorMessage.trim() : "";
2041
+ return message ? { kind: "aborted", message } : { kind: "aborted" };
2042
+ }
2043
+ if (stopReason !== "error") return { kind: "clean" };
2044
+ const err = typeof rec.errorMessage === "string" ? rec.errorMessage.trim() : "";
2045
+ if (isAbortStopText(err)) {
2046
+ return err ? { kind: "aborted", message: err } : { kind: "aborted" };
2047
+ }
2048
+ return { kind: "provider-error", message: err || "unknown provider error" };
1892
2049
  }
1893
- return undefined;
2050
+ return { kind: "clean" };
1894
2051
  }
1895
2052
 
1896
2053
  /**
@@ -1902,14 +2059,19 @@ export function lastAssistantErrorStop(messages: unknown): string | undefined {
1902
2059
  * - `exhaust` โ†’ the retry budget ran out (`auto_retry_end`, `success: false`);
1903
2060
  * the run is about to settle on an error. `message` may be empty when the
1904
2061
  * event carries no `finalError` โ€” callers then keep their earlier message.
1905
- * - `stop` โ†’ the run itself ended with `stopReason === "error"` on its last
1906
- * assistant message (auto-retry disabled, or a non-retryable failure).
2062
+ * - `stop` โ†’ the run itself ended on a provider error
2063
+ * (`stopReason === "error"` with a non-abort message; auto-retry disabled,
2064
+ * or a non-retryable failure).
2065
+ * - `aborted` โ†’ the run ended on an abort (`stopReason === "aborted"`, or an
2066
+ * error stop whose message is abort-shaped โ€” the SDK's setup path mislabels
2067
+ * a host cancellation as `error`). Never provider health.
1907
2068
  * - `none` โ†’ anything else, including a retry that recovered.
1908
2069
  */
1909
2070
  export type ReviewProviderFailure =
1910
2071
  | { kind: "retry"; message: string; retries: number }
1911
2072
  | { kind: "exhaust"; message: string; retries: number }
1912
2073
  | { kind: "stop"; message: string }
2074
+ | { kind: "aborted"; message?: string }
1913
2075
  | { kind: "none" };
1914
2076
 
1915
2077
  /** Structural decode of one provider-failure event. Never throws. */
@@ -1923,6 +2085,8 @@ export function readProviderErrorEvent(
1923
2085
  const message =
1924
2086
  typeof e.errorMessage === "string" ? e.errorMessage.trim() : "";
1925
2087
  if (!message) return { kind: "none" };
2088
+ // A cancelled retry is our own abort, not provider health.
2089
+ if (isAbortStopText(message)) return { kind: "none" };
1926
2090
  return {
1927
2091
  kind: "retry",
1928
2092
  message,
@@ -1932,6 +2096,7 @@ export function readProviderErrorEvent(
1932
2096
  if (type === "auto_retry_end") {
1933
2097
  if (e.success !== false) return { kind: "none" };
1934
2098
  const message = typeof e.finalError === "string" ? e.finalError.trim() : "";
2099
+ if (isAbortStopText(message)) return { kind: "none" };
1935
2100
  return {
1936
2101
  kind: "exhaust",
1937
2102
  message,
@@ -1939,9 +2104,14 @@ export function readProviderErrorEvent(
1939
2104
  };
1940
2105
  }
1941
2106
  if (type === "agent_end") {
1942
- const message = lastAssistantErrorStop(e.messages);
1943
- if (message === undefined) return { kind: "none" };
1944
- return { kind: "stop", message };
2107
+ const stop = classifyAssistantStop(e.messages);
2108
+ if (stop.kind === "aborted") {
2109
+ return stop.message
2110
+ ? { kind: "aborted", message: stop.message }
2111
+ : { kind: "aborted" };
2112
+ }
2113
+ if (stop.kind !== "provider-error") return { kind: "none" };
2114
+ return { kind: "stop", message: stop.message };
1945
2115
  }
1946
2116
  return { kind: "none" };
1947
2117
  }
@@ -2094,18 +2264,25 @@ function providerErrorOps(
2094
2264
  const ops: ReviewTranscriptOp[] = [
2095
2265
  { op: "new", kind: "status", text: "reviewer run ended" },
2096
2266
  ];
2097
- const failed = lastAssistantErrorStop(e.messages);
2098
- if (failed) {
2267
+ // An abort is our own cancellation (pass complete / pause / teardown),
2268
+ // never provider health. The SDK's setup path can label it `error` with an
2269
+ // abort-shaped message, so classification cannot rely on `stopReason`.
2270
+ const stop = classifyAssistantStop(e.messages);
2271
+ if (stop.kind === "provider-error") {
2099
2272
  ops.push({
2100
2273
  op: "new",
2101
2274
  kind: "error",
2102
- text: `reviewer run ended on a provider error: ${truncateErrorText(failed)}`,
2275
+ text: `reviewer run ended on a provider error: ${truncateErrorText(
2276
+ stop.message,
2277
+ )}`,
2103
2278
  });
2104
2279
  }
2105
2280
  return ops;
2106
2281
  }
2107
2282
  if (type === "auto_retry_start") {
2108
2283
  const msg = typeof e.errorMessage === "string" ? e.errorMessage : "";
2284
+ // A cancelled backoff is an abort, not a provider notice.
2285
+ if (isAbortStopText(msg)) return [];
2109
2286
  return [
2110
2287
  {
2111
2288
  op: "new",
@@ -2122,6 +2299,7 @@ function providerErrorOps(
2122
2299
  // retry recovered; the earlier notice already covers that case.
2123
2300
  if (e.success === false) {
2124
2301
  const detail = truncateErrorText(e.finalError);
2302
+ if (isAbortStopText(detail)) return [];
2125
2303
  return [
2126
2304
  {
2127
2305
  op: "new",