pi-crew 0.11.6 → 0.11.8

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.
Files changed (58) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +216 -1
  4. package/dist/index.mjs +531 -508
  5. package/docs/publishing.md +29 -0
  6. package/index.ts +3 -3
  7. package/package.json +9 -6
  8. package/scripts/install-hooks.mjs +32 -0
  9. package/skills/real-test-pi-crew/SKILL.md +31 -18
  10. package/src/config/config-validation.ts +35 -0
  11. package/src/config/env-vars.ts +38 -0
  12. package/src/config/types.ts +71 -0
  13. package/src/extension/registration/foreground-run-controller.ts +19 -10
  14. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  15. package/src/extension/registration/lifecycle.ts +7 -90
  16. package/src/extension/registration/render-loop.ts +502 -0
  17. package/src/extension/team-tool/doctor.ts +6 -0
  18. package/src/prompt/prompt-runtime.ts +33 -4
  19. package/src/runtime/child-pi/child-pi-spawn.ts +14 -0
  20. package/src/runtime/child-pi/child-pi.ts +130 -6
  21. package/src/runtime/child-pi/pi-version.ts +187 -0
  22. package/src/runtime/child-pi/session-recovery.ts +334 -0
  23. package/src/runtime/classifier/classifier-service.ts +298 -0
  24. package/src/runtime/dispatch-batch.ts +4 -0
  25. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  26. package/src/runtime/model/model-scope.ts +141 -21
  27. package/src/runtime/model/pi-args.ts +166 -6
  28. package/src/runtime/peer-dep.ts +24 -0
  29. package/src/runtime/process/zombie-scanner.ts +168 -7
  30. package/src/runtime/rpc/README.md +139 -0
  31. package/src/runtime/rpc/frame-client.ts +328 -0
  32. package/src/runtime/rpc/rpc-worker.ts +271 -0
  33. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  34. package/src/runtime/run-worker.ts +91 -3
  35. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  36. package/src/runtime/skill-instructions.ts +66 -1
  37. package/src/runtime/surface/resolve-surface.ts +34 -19
  38. package/src/runtime/surface/surface-spawn.ts +8 -1
  39. package/src/runtime/task-output-context.ts +191 -32
  40. package/src/runtime/task-packet.ts +3 -0
  41. package/src/runtime/task-runner/child-executor.ts +172 -2
  42. package/src/runtime/task-runner/post-execution.ts +5 -3
  43. package/src/runtime/task-runner/pre-execution.ts +32 -1
  44. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  45. package/src/runtime/task-runner.ts +6 -0
  46. package/src/schema/config-schema.ts +37 -0
  47. package/src/schema/team-tool-schema.ts +14 -2
  48. package/src/state/contracts.ts +5 -0
  49. package/src/ui/format-helpers.ts +11 -0
  50. package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
  51. package/src/ui/pi-ui-compat.ts +18 -9
  52. package/src/ui/rail.ts +14 -1
  53. package/src/ui/settings-overlay.ts +11 -2
  54. package/src/ui/widget/index.ts +0 -1
  55. package/src/ui/widget/task-list.ts +10 -3
  56. package/src/ui/widget/widget-renderer.ts +53 -4
  57. package/src/utils/relative-time.ts +11 -3
  58. package/src/utils/visual.ts +32 -2
@@ -75,6 +75,29 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
75
75
  graceTurns: Type.Optional(Type.Integer({ minimum: 1 })),
76
76
  taskTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
77
77
  inheritContext: Type.Optional(Type.Boolean({ sensitive: true })),
78
+ // W2 (session-file recovery): when a worker dies without a final assistant
79
+ // event (exitCode null / killed), tail-replay its session JSONL and attach
80
+ // the last complete assistant record to the run result. Default ON; env
81
+ // PI_CREW_SESSION_RECOVERY overrides. Kill switch, not a secret — no
82
+ // sensitive mark (same policy as surface: picks behavior, not authority).
83
+ sessionRecovery: Type.Optional(Type.Boolean()),
84
+ // R3-19/D5 reversal: hermetic worker spawns (--no-extensions on worker
85
+ // argv; explicit -e prompt-runtime + --skill flags unaffected). Default
86
+ // TRUE — off-switch for operators who want the old ambient-stack parity.
87
+ // Env PI_CREW_HERMETIC_WORKERS overrides either way. Behavior pick, not
88
+ // authority — no sensitive mark.
89
+ hermeticWorkers: Type.Optional(Type.Boolean()),
90
+ // P2-1 (pi 1.0.4 adoption): host-process retry-triage classifier seam
91
+ // (src/runtime/classifier/). DORMANT — default FALSE; enabling turns on
92
+ // transient-vs-permanent classification before expensive re-attempts.
93
+ // Env PI_CREW_CLASSIFIER_ENABLED overrides either way. Behavior pick,
94
+ // not authority — no sensitive mark.
95
+ classifierEnabled: Type.Optional(Type.Boolean()),
96
+ // P2-1: classifier model id for the seam above. Default resolved at the
97
+ // consumer (resolveClassifierModel → "opencode/jev-1.13-free"); env
98
+ // PI_CREW_CLASSIFIER_MODEL overrides. Catalog ids only — availability
99
+ // depends on provider credentials at runtime.
100
+ classifierModel: Type.Optional(Type.String({ minLength: 1 })),
78
101
  promptMode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("append")])),
79
102
  groupJoin: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")])),
80
103
  groupJoinAckTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
@@ -94,6 +117,10 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
94
117
  ),
95
118
  ),
96
119
  excludeContextBash: Type.Optional(Type.Boolean()),
120
+ // Handoff budget: est-token (chars/4) cap on the dynamic.dependencyContext
121
+ // prompt layer. Matches parseHandoffBudgetTokens in config-validation.ts
122
+ // (out-of-range values are dropped → render default 1800).
123
+ handoffBudgetTokens: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000_000 })),
97
124
  agentExtensions: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { sensitive: true })),
98
125
  // Mux-surface policy (spec v0.7 §8.2.4): no sensitive marks — surface
99
126
  // picks where worker processes live, not what they may do.
@@ -118,6 +145,16 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
118
145
  ),
119
146
  ),
120
147
  modelFallback: Type.Optional(PiTeamsModelFallbackConfigSchema),
148
+ // W7 (P2-3) EXPERIMENTAL: worker transport selector, WIRED at the
149
+ // run-worker seam since e2728f68 (D1/DR2 plumbing, 2026-10-06): "rpc"
150
+ // selects the prototype RPC frame-client transport (src/runtime/rpc/),
151
+ // "stdio" (absent/default) is the production child-pi path. Env
152
+ // PI_CREW_WORKER_TRANSPORT=rpc overrides this config (env wins —
153
+ // established precedence; an invalid env value fails safe to stdio).
154
+ // Callers carrying runtimeConfig (child-executor, run-coalesced-task-
155
+ // group) thread it to the seam; callers without runtimeConfig keep the
156
+ // default. Not sensitive — picks a transport, not authority.
157
+ workerTransport: Type.Optional(Type.Union([Type.Literal("stdio"), Type.Literal("rpc")])),
121
158
  },
122
159
  { additionalProperties: false },
123
160
  );
@@ -280,6 +280,14 @@ const sharedFields = {
280
280
  description: "Mark certain bash commands as excludeFromContext to reduce context tokens (default: false).",
281
281
  }),
282
282
  ),
283
+ handoffBudgetTokens: Type.Optional(
284
+ Type.Integer({
285
+ minimum: 1,
286
+ maximum: 1_000_000,
287
+ description:
288
+ "Est-token (chars/4) budget cap on the dependency-context layer of worker prompts (default 1800). When exceeded, later dependencies are trimmed to summary head + artifact pointer. Out-of-range values are ignored.",
289
+ }),
290
+ ),
283
291
  budgetTotal: Type.Optional(
284
292
  // Empty-string unset marker accepted (Tier-9: models emit "" when unset).
285
293
  // 0 accepted as "unset/disabled" (models emit 0 for off); still rejects 1-999
@@ -414,7 +422,7 @@ const sharedFields = {
414
422
  focus: Type.Optional(
415
423
  Type.String({
416
424
  description:
417
- "Sub-focus for the doctor action. 'zombies' runs a READ-ONLY scan for orphaned pi-crew sub-agent processes (identified by PI_CREW_KIND=subagent); it never kills and never matches the user's interactive main session.",
425
+ "Sub-focus for the doctor action. 'zombies' runs a READ-ONLY scan for orphaned pi-crew sub-agent processes (identified by PI_CREW_KIND=subagent); it never kills and never matches the user's interactive main session. Also WARN-lists foreign CLI-spawned pi processes (AI_AGENT=pi markers, R3-16) — report-only, never kill candidates.",
418
426
  }),
419
427
  ),
420
428
  };
@@ -568,7 +576,9 @@ export interface TeamToolParamsValue {
568
576
  config?: Record<string, unknown>;
569
577
  /** Sub-focus for the `doctor` action. `"zombies"` runs a READ-ONLY scan for
570
578
  * orphaned pi-crew sub-agent processes (identified by PI_CREW_KIND=subagent);
571
- * it never kills and never matches the user's interactive main session. */
579
+ * it never kills and never matches the user's interactive main session. Also
580
+ * WARN-lists foreign CLI-spawned pi processes (AI_AGENT=pi markers, R3-16) —
581
+ * report-only, never kill candidates. */
572
582
  focus?: string;
573
583
  dryRun?: boolean;
574
584
  confirm?: boolean;
@@ -596,6 +606,8 @@ export interface TeamToolParamsValue {
596
606
  b?: number;
597
607
  /** Mark certain bash commands as excludeFromContext to reduce context tokens (default: false). */
598
608
  excludeContextBash?: boolean;
609
+ /** Est-token (chars/4) budget cap on the dependency-context layer of worker prompts (default 1800). Later dependencies get trimmed to summary head + artifact pointer when exceeded. */
610
+ handoffBudgetTokens?: number;
599
611
  /** Total token budget for the run. When set, enables budget tracking (minimum 1000). */
600
612
  budgetTotal?: number;
601
613
  /** When true, skip budget enforcement entirely (explicit opt-out). */
@@ -106,6 +106,11 @@ export const TEAM_EVENT_TYPES = [
106
106
  "task.respond_delivered",
107
107
  "task.respond_missed",
108
108
  "task.retried",
109
+ // P2-1 (pi 1.0.4 adoption, 2026-10-07): retry-triage classifier decision —
110
+ // fired by child-executor's triageRetryWithClassifier ONLY when a real
111
+ // classifier answer informed the retry-vs-skip decision (dormant unless
112
+ // runtime.classifierEnabled). Diagnostic only — never transitions state.
113
+ "task.retry_triage",
109
114
  // WP-2/R2 waiting-producer (ADR-0 2026-08-17-waiting-producer-ask item 10):
110
115
  // `ask` tool lifecycle — requested on park acceptance, answered on delivery
111
116
  // (mailbox or requeue+inject), timedout on deadline expiry (both the
@@ -12,6 +12,17 @@ export function formatTokens(n: number): string {
12
12
  return `${(n / 1_000_000).toFixed(1)}M`;
13
13
  }
14
14
 
15
+ /**
16
+ * Canonical generic duration formatter (QW#5 collision resolution):
17
+ * `45ms` / `12.3s` / `3m5s` / `75m` — minutes are unbounded (no hours
18
+ * bucket) and there is no invalid-input guard. Two same-stemmed helpers are
19
+ * DELIBERATELY separate dialects and must not be merged into this one
20
+ * (their output formats are design-pinned):
21
+ * - `formatTaskElapsed` (ui/widget/task-list.ts) — plan-card elapsed:
22
+ * `2m 49s` / `1h 03m`, empty string on invalid input.
23
+ * - `formatRelativeCompact` (utils/relative-time.ts) — relative-time
24
+ * ladder buckets `84m` / `14h` / `2d14h`.
25
+ */
15
26
  export function formatDuration(ms: number): string {
16
27
  if (ms < 1000) return `${ms}ms`;
17
28
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
@@ -14,11 +14,16 @@
14
14
  * canopy/hint rows are NOT part of the split any more, so the hint keeps its
15
15
  * full width instead of being cropped to 60% of the overlay.
16
16
  *
17
+ * R3-6 (IME): the ACTIVE field emits pi-tui's zero-width `CURSOR_MARKER` at
18
+ * its value end — the host TUI strips it and parks the hardware cursor there
19
+ * so the IME candidate window follows the logical text cursor (CJK input).
20
+ *
17
21
  * Keys/behaviour are unchanged (same `overlay:*` dispatch, same free-text
18
22
  * passthrough, same validation errors, same discard confirmation). Hints come
19
23
  * from `formatHint` (discard LAST, keys through `keyToken`).
20
24
  */
21
25
 
26
+ import { CURSOR_MARKER } from "@earendil-works/pi-tui";
22
27
  import type { MailboxDirection } from "../../state/coordination/mailbox.ts";
23
28
  import { pad, sanitizeLine, truncate } from "../../utils/visual.ts";
24
29
  import { overlayActionForKey } from "../keybinding-map.ts";
@@ -117,7 +122,16 @@ export class MailboxComposeOverlay {
117
122
  // (the field is a single-line cell; multi-line content belongs to the
118
123
  // preview column).
119
124
  const value = sanitizeLine(this.fields[field] ?? "");
120
- return `${active ? CURSOR : " "} ${label}: ${truncate(value, Math.max(8, width - label.length - 5))}`;
125
+ // R3-6 (IME): CURSOR_MARKER is pi-tui's zero-width APC sequence — the host
126
+ // TUI finds it, positions the HARDWARE cursor there (IME candidate window
127
+ // anchor for CJK input), and strips it before painting. This overlay's
128
+ // fields append/backspace at end-of-value only, so the logical cursor IS
129
+ // the value end — same pattern as pi-tui's own Input component
130
+ // (marker + fake cursor). The `›` prefix stays: it is the RAIL list-selection
131
+ // glyph, orthogonal to the text cursor. Direction checkbox row gets NO
132
+ // marker — it toggles on space and has no text insertion point.
133
+ const marker = active && !this.confirm ? CURSOR_MARKER : "";
134
+ return `${active ? CURSOR : " "} ${label}: ${truncate(value, Math.max(8, width - label.length - 5))}${marker}`;
121
135
  }
122
136
 
123
137
  private activeName(): FieldName {
@@ -1,5 +1,21 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ /**
4
+ * Host-compatibility shims for the `ExtensionContext["ui"]` surface.
5
+ *
6
+ * RETENTION POLICY (W4 2026-10-03): these typeof-based capability guards are
7
+ * deliberate no-ops, NOT version guards, and must be kept. pi-crew declares
8
+ * `"@earendil-works/pi-coding-agent": "*"` and `"@earendil-works/pi-tui": "*"`
9
+ * as optional peerDependencies with no minimum-version floor
10
+ * (package.json peerDependencies/peerDependenciesMeta), so host installs older
11
+ * than any SDK floor we verify against must keep working — retiring these
12
+ * shims would crash hosts whose UI predates
13
+ * `setWidget`/`setFooter`/`custom`/`setStatus`/`setWorkingIndicator`.
14
+ * Verified live: each guard is a pure feature check; there is no version
15
+ * constant or gate in this file. New host UI APIs should be added here
16
+ * (feature-detected), not called directly from src/.
17
+ */
18
+
3
19
  export interface WorkingIndicatorOptions {
4
20
  frames?: string[];
5
21
  intervalMs?: number;
@@ -9,7 +25,6 @@ type UiContext = Pick<ExtensionContext, "ui">;
9
25
  type ExtensionUi = ExtensionContext["ui"];
10
26
  type WidgetContent = string[] | ((tui: unknown, theme: unknown) => unknown);
11
27
  type WidgetOptions = Parameters<ExtensionUi["setWidget"]>[2];
12
- type WidgetOptionsWithPersist = WidgetOptions & { persist?: boolean };
13
28
 
14
29
  type CustomOptions = Parameters<ExtensionUi["custom"]>[1];
15
30
 
@@ -38,16 +53,10 @@ export function setWorkingIndicator(ctx: UiContext, options?: WorkingIndicatorOp
38
53
 
39
54
  /** Install or update an extension widget slot.
40
55
  * No-op when the host UI predates the `setWidget` API. */
41
- export function setExtensionWidget(
42
- ctx: UiContext,
43
- key: string,
44
- content: WidgetContent | undefined,
45
- options?: WidgetOptionsWithPersist,
46
- ): void {
47
- const { persist: _persist, ...widgetOptions } = options ?? {};
56
+ export function setExtensionWidget(ctx: UiContext, key: string, content: WidgetContent | undefined, options?: WidgetOptions): void {
48
57
  const record = maybeRecord(ctx.ui);
49
58
  const fn = record?.setWidget;
50
- if (typeof fn === "function") fn.call(ctx.ui, key, content as never, widgetOptions as WidgetOptions);
59
+ if (typeof fn === "function") fn.call(ctx.ui, key, content as never, options);
51
60
  }
52
61
 
53
62
  /**
package/src/ui/rail.ts CHANGED
@@ -58,7 +58,20 @@ export const RAIL = { open: "┏", section: "┣", body: "┃", close: "┗" } a
58
58
  */
59
59
  export const PI_EXPAND_CHORD = "ctrl+o";
60
60
 
61
- /** Selection cursor (lists) and active/step marker (sections, goals). */
61
+ /** Selection cursor (lists) and active/step marker (sections, goals).
62
+ *
63
+ * R3-6 audit (2026-10-04): this glyph marks LIST SELECTION in dashboards,
64
+ * pickers, settings menus, and compose field rows — a navigation affordance,
65
+ * not a text insertion point. It therefore deliberately does NOT adopt
66
+ * pi-tui's `CURSOR_MARKER` (the zero-width APC sequence that anchors the
67
+ * IME candidate window at a TEXT cursor). Sites that DO own a text cursor —
68
+ * mailbox-compose field values, settings text-input/edit buffers — emit
69
+ * `CURSOR_MARKER` at their buffer end in addition to any visual glyph.
70
+ * Not converted, by design: run-dashboard run rows, settings menu rows,
71
+ * agents-jobs-browser rows, mailbox-detail rows, agent-picker rows,
72
+ * schedules-pane job rows (all list selection); widget dock `❯` (a
73
+ * single-line focus acknowledgement, not an input); mascot `█` frames
74
+ * (animation glyphs). */
62
75
  export const CURSOR = "›";
63
76
  export const ACTIVE = "▸";
64
77
 
@@ -4,6 +4,7 @@
4
4
  * label/value alignment, inline toggle, select submenu, and text input.
5
5
  */
6
6
 
7
+ import { CURSOR_MARKER } from "@earendil-works/pi-tui";
7
8
  import { truncateToWidth, visibleWidth } from "../utils/visual.ts";
8
9
  import { keyOf, matchesKey } from "./key-utils.ts";
9
10
  import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLine } from "./rail.ts";
@@ -601,7 +602,12 @@ class TextinputSubmenu {
601
602
  lines.push(this.theme.fg("muted", this.description));
602
603
  }
603
604
  lines.push("");
604
- lines.push(` ${this.buffer}█`);
605
+ // R3-6 (IME): the marker before the fake `█` cursor is pi-tui's
606
+ // zero-width CURSOR_MARKER — the host TUI strips it and positions the
607
+ // hardware cursor there so the IME candidate window tracks the buffer
608
+ // end (this submenu's buffer appends/erases at end only). Emitted
609
+ // unconditionally: the row renders only while this input owns keys.
610
+ lines.push(` ${this.buffer}${CURSOR_MARKER}█`);
605
611
  lines.push("");
606
612
  lines.push(
607
613
  this.theme.fg(
@@ -730,7 +736,10 @@ class AgentOverridesSubmenu {
730
736
  const lines: string[] = [];
731
737
  lines.push(this.theme.bold(this.theme.fg("accent", `Edit ${agent} ${field}`)));
732
738
  lines.push("");
733
- lines.push(` ${this.editBuffer}█`);
739
+ // R3-6 (IME): same CURSOR_MARKER contract as TextinputSubmenu — the
740
+ // edit buffer is end-append only, so the marker rides immediately before
741
+ // the fake `█` cursor (pi-tui Input's marker+cursorChar pattern).
742
+ lines.push(` ${this.editBuffer}${CURSOR_MARKER}█`);
734
743
  lines.push("");
735
744
  lines.push(
736
745
  this.theme.fg(
@@ -522,7 +522,6 @@ export function updateCrewWidget(
522
522
  const model = state.model;
523
523
  setExtensionWidget(ctx, WIDGET_KEY, ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui)) as never, {
524
524
  placement: piPlacement,
525
- persist: true,
526
525
  });
527
526
  state.lastVisibility = "visible";
528
527
  state.lastPlacement = placement;
@@ -84,8 +84,15 @@ function taskStatusIcon(task: TeamTaskState): string {
84
84
  }
85
85
  }
86
86
 
87
- /** `45s` / `2m 49s` / `1h 03m`. */
88
- function formatDuration(ms: number): string {
87
+ /**
88
+ * Plan-card elapsed dialect (QW#5): `45s` / `2m 49s` / `1h 03m`, empty
89
+ * string for non-finite/negative input. Deliberately NOT ui/format-helpers'
90
+ * canonical `formatDuration` (that one renders `12.3s`/`3m5s` with no hours
91
+ * bucket and no invalid-input guard) — the spaced, zero-padded, hour-capable
92
+ * format here is pinned by the pi-tasks plan-card display. Renamed from a
93
+ * local `formatDuration` to end the three-way name collision.
94
+ */
95
+ function formatTaskElapsed(ms: number): string {
89
96
  if (!Number.isFinite(ms) || ms < 0) return "";
90
97
  const totalSeconds = Math.floor(ms / 1000);
91
98
  if (totalSeconds < 60) return `${totalSeconds}s`;
@@ -110,7 +117,7 @@ function runningSuffix(task: TeamTaskState): string {
110
117
  const start = Date.parse(task.startedAt ?? "");
111
118
  if (Number.isFinite(start) && start > 0) {
112
119
  const end = task.finishedAt ? Date.parse(task.finishedAt) : Date.now();
113
- const elapsed = formatDuration(end - start);
120
+ const elapsed = formatTaskElapsed(end - start);
114
121
  if (elapsed) parts.push(elapsed);
115
122
  }
116
123
  const input = task.usage?.input ?? 0;
@@ -4,11 +4,14 @@
4
4
  * Extracted from crew-widget.ts.
5
5
  */
6
6
 
7
+ import { pathToFileURL } from "node:url";
8
+ import * as piTui from "@earendil-works/pi-tui";
7
9
  import { getCrewScheduler, getScheduledJobs, getScheduledJobsHiddenCountView } from "../../extension/team-tool/handle-schedule.ts";
8
10
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
9
11
  import { isPlanApprovalStatePending } from "../../runtime/plan-approval.ts";
10
12
  import { isFinishedRunStatus } from "../../runtime/process-status.ts";
11
13
  import type { ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
14
+ import type { TeamRunManifest } from "../../state/types.ts";
12
15
  import { formatRelativeTime } from "../../utils/relative-time.ts";
13
16
  import { truncate, visibleWidth } from "../../utils/visual.ts";
14
17
  import { Box, Text } from "../layout-primitives.ts";
@@ -95,9 +98,52 @@ export function widgetRailSlot(runs: readonly WidgetRun[]): RailSlot {
95
98
  /** `<team>` (or `team/workflow` when they differ); several live runs collapse
96
99
  * to `<n> runs` — the counts on the row stay aggregate. */
97
100
  function dockSubject(runs: WidgetRun[]): string {
98
- if (runs.length > 1) return `${runs.length} runs`;
99
- const first = runs[0];
100
- return first ? shortRunLabel(first.run) : "idle";
101
+ if (runs.length === 0) return "idle";
102
+ const label = runs.length > 1 ? `${runs.length} runs` : shortRunLabel(runs[0]!.run);
103
+ // R3-9: the dock's ONE clickable action — the subject opens the LATEST
104
+ // run's artifacts directory (`file://` OSC-8 link). Terminals without
105
+ // OSC-8 support ignore the escapes and render the plain label.
106
+ return linkify(label, latestArtifactsUrl(runs));
107
+ }
108
+
109
+ // ── R3-9: OSC-8 dock link ─────────────────────────────────────────────
110
+
111
+ type TuiHyperlink = (text: string, url: string) => string;
112
+
113
+ /** pi-tui's OSC-8 helper, resolved defensively: the peer range is `*`, so a
114
+ * host running a pi-tui build without the export must still render the
115
+ * plain dock instead of failing the namespace import. */
116
+ const tuiHyperlink: TuiHyperlink | undefined = (() => {
117
+ const candidate = (piTui as { hyperlink?: unknown }).hyperlink;
118
+ return typeof candidate === "function" ? (candidate as TuiHyperlink) : undefined;
119
+ })();
120
+
121
+ /** `file://` URL for an artifacts dir; `undefined` keeps the label plain. */
122
+ function artifactsDirUrl(artifactsRoot: string | undefined): string | undefined {
123
+ if (!artifactsRoot) return undefined;
124
+ try {
125
+ return pathToFileURL(artifactsRoot).href;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ /** Latest (by createdAt) run's artifacts dir — the dock link target. */
132
+ function latestArtifactsUrl(runs: readonly WidgetRun[]): string | undefined {
133
+ let latest: TeamRunManifest | undefined;
134
+ for (const entry of runs) {
135
+ const candidate = entry.run;
136
+ if (!candidate.artifactsRoot) continue;
137
+ if (!latest || (candidate.createdAt ?? "") > (latest.createdAt ?? "")) latest = candidate;
138
+ }
139
+ return artifactsDirUrl(latest?.artifactsRoot);
140
+ }
141
+
142
+ /** Wrap in OSC-8 when both the pi-tui helper and a target exist; plain text
143
+ * otherwise (clean degrade on non-hyperlink terminals and older hosts). */
144
+ function linkify(text: string, url: string | undefined): string {
145
+ if (!url || !tuiHyperlink) return text;
146
+ return tuiHyperlink(text, url);
101
147
  }
102
148
 
103
149
  /**
@@ -389,7 +435,10 @@ export function buildWidgetLines(
389
435
  */
390
436
  export function colorWidgetLine(line: string, index: number, theme: CrewTheme, slot: RailSlot = "border"): string {
391
437
  let result = line;
392
- if (index === 0 && !result.includes("\u001b")) {
438
+ // R3-9: the guard detects THEME styling (CSI `ESC[`), not any escape — the
439
+ // dock's OSC-8 artifacts link (introduced by `dockSubject`) is not styling
440
+ // and must NOT opt the line out of the identity paint pass.
441
+ if (index === 0 && !result.includes("\u001b[")) {
393
442
  // `┏|┃ <WORD> ▸ <subject>`: rail glyph takes the state slot, the identity
394
443
  // word accent+bold, the subject toolTitle+bold. The subject is bounded by
395
444
  // the first ` ·` so a count segment can never be swallowed.
@@ -15,11 +15,19 @@
15
15
  export function formatRelativeTime(now: Date, target: Date): string {
16
16
  const deltaMs = target.getTime() - now.getTime();
17
17
  if (deltaMs === 0) return "now";
18
- if (deltaMs > 0) return `in ${formatDurationCompact(deltaMs)}`;
19
- return `${formatDurationCompact(-deltaMs)} ago`;
18
+ if (deltaMs > 0) return `in ${formatRelativeCompact(deltaMs)}`;
19
+ return `${formatRelativeCompact(-deltaMs)} ago`;
20
20
  }
21
21
 
22
- function formatDurationCompact(ms: number): string {
22
+ /**
23
+ * Relative-time bucket ladder (dialect D6-T4): `45s` / `84m` / `14h` /
24
+ * `2d14h`, zero-hour remainder collapses to `3d`. A distinct dialect from
25
+ * ui/format-helpers' canonical `formatDuration` (QW#5 collision resolution):
26
+ * the bucket boundaries (60s / 120m / 48h) are design-approved for relative
27
+ * timestamps and must not adopt the generic `3m5s` rendering. Formerly named
28
+ * `formatDurationCompact`; private to this module.
29
+ */
30
+ function formatRelativeCompact(ms: number): string {
23
31
  const totalSeconds = Math.floor(ms / 1000);
24
32
  if (totalSeconds < 60) return `${totalSeconds}s`;
25
33
  const totalMinutes = Math.floor(totalSeconds / 60);
@@ -60,11 +60,41 @@ export function __test__visibleWidthCacheSize(): number {
60
60
  function consumeAnsi(input: string, index: number): number {
61
61
  const char = input[index];
62
62
  if (!char || char !== "\u001b") return 0;
63
- if (input[index + 1] !== "[") return 0;
63
+ const next = input[index + 1];
64
+ if (next === "[") {
65
+ let i = index + 2;
66
+ while (i < input.length) {
67
+ const code = input.charCodeAt(i);
68
+ if (code >= 0x40 && code <= 0x7e) return i - index + 1;
69
+ i++;
70
+ }
71
+ return 0;
72
+ }
73
+ // R3-9: OSC sequences (`ESC ] … BEL` or `ESC ] … ESC \`) — OSC-8 hyperlinks
74
+ // (pi-tui `hyperlink()`, used by the crew widget) ride this branch. The
75
+ // truncation/wrap/sanitize helpers must treat the whole sequence as ONE
76
+ // zero-width unit, matching pi-tui's `visibleWidth` (which already strips
77
+ // OSC), or a clipped line would slice mid-escape and leak raw bytes into
78
+ // the terminal. Malformed payloads (stray control char) fall back to the
79
+ // pre-existing behaviour of treating the ESC as ordinary data.
80
+ //
81
+ // U6/DR4 (pi 1.0.4 adoption, 2026-10-06): APC sequences (`ESC _ … BEL` or
82
+ // `ESC _ … ESC \`) share the exact same grammar, so they ride the same
83
+ // scanner. pi-tui's `CURSOR_MARKER` — now a documented public contract —
84
+ // is an APC string (`\u001b_pi:c\u0007`) that focused components emit at
85
+ // the cursor position. Without this branch, truncateToWidth/wrapHard
86
+ // walked the marker's `_pi:c` payload as ordinary text (~6 visible
87
+ // columns) and could slice the sequence in half; with it, the whole
88
+ // marker is consumed as ONE zero-width unit, matching pi-tui's
89
+ // `visibleWidth` (which already zero-widths APC). Same malformed fallback:
90
+ // a stray control char cannot belong to an OSC/APC payload either.
91
+ if (next !== "]" && next !== "_") return 0;
64
92
  let i = index + 2;
65
93
  while (i < input.length) {
66
94
  const code = input.charCodeAt(i);
67
- if (code >= 0x40 && code <= 0x7e) return i - index + 1;
95
+ if (code === 0x07) return i - index + 1; // BEL terminator
96
+ if (code === 0x1b && input[i + 1] === "\\") return i - index + 2; // ST terminator
97
+ if (code < 0x20) return 0; // control char cannot belong to an OSC/APC payload
68
98
  i++;
69
99
  }
70
100
  return 0;