pi-crew 0.11.7 → 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 (45) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +121 -1
  4. package/dist/index.mjs +528 -510
  5. package/docs/publishing.md +29 -0
  6. package/index.ts +3 -3
  7. package/package.json +8 -5
  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 +8 -1
  11. package/src/config/env-vars.ts +16 -1
  12. package/src/config/types.ts +28 -4
  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/runtime/child-pi/child-pi-spawn.ts +7 -0
  19. package/src/runtime/child-pi/child-pi.ts +37 -2
  20. package/src/runtime/child-pi/pi-version.ts +187 -0
  21. package/src/runtime/classifier/classifier-service.ts +298 -0
  22. package/src/runtime/dispatch-batch.ts +4 -0
  23. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  24. package/src/runtime/model/pi-args.ts +62 -3
  25. package/src/runtime/process/zombie-scanner.ts +168 -7
  26. package/src/runtime/rpc/README.md +64 -25
  27. package/src/runtime/rpc/rpc-worker.ts +146 -26
  28. package/src/runtime/run-worker.ts +89 -15
  29. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  30. package/src/runtime/skill-instructions.ts +66 -1
  31. package/src/runtime/surface/resolve-surface.ts +34 -19
  32. package/src/runtime/task-runner/child-executor.ts +132 -0
  33. package/src/runtime/task-runner/pre-execution.ts +8 -0
  34. package/src/runtime/task-runner.ts +6 -0
  35. package/src/schema/config-schema.ts +20 -8
  36. package/src/schema/team-tool-schema.ts +4 -2
  37. package/src/state/contracts.ts +5 -0
  38. package/src/ui/format-helpers.ts +11 -0
  39. package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
  40. package/src/ui/rail.ts +14 -1
  41. package/src/ui/settings-overlay.ts +11 -2
  42. package/src/ui/widget/task-list.ts +10 -3
  43. package/src/ui/widget/widget-renderer.ts +53 -4
  44. package/src/utils/relative-time.ts +11 -3
  45. package/src/utils/visual.ts +32 -2
@@ -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 {
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(
@@ -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;