pi-crew 0.9.46 → 0.9.48

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 (46) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +16 -2
  3. package/dist/build-meta.json +289 -164
  4. package/dist/index.mjs +1744 -2732
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/decisions/2026-07-21-broker-phase4-default-on.md +77 -0
  7. package/docs/decisions/2026-07-21-broker-windows-perms.md +91 -0
  8. package/docs/decisions/2026-07-22-broker-phase4-gated-on.md +99 -0
  9. package/docs/decisions/README.md +3 -0
  10. package/docs/publishing.md +29 -0
  11. package/package.json +3 -1
  12. package/scripts/build-bundle.mjs +7 -0
  13. package/scripts/postinstall.mjs +60 -1
  14. package/scripts/pty_probe.py +174 -0
  15. package/skills/real-test-pi-crew/SKILL.md +659 -0
  16. package/src/config/config.ts +42 -1
  17. package/src/config/defaults.ts +45 -1
  18. package/src/config/types.ts +19 -0
  19. package/src/extension/register.ts +6 -1
  20. package/src/extension/registration/context-builder.ts +4 -0
  21. package/src/extension/registration/lifecycle-handlers.ts +166 -3
  22. package/src/extension/registration/registration-types.ts +9 -0
  23. package/src/prompt/prompt-runtime.ts +108 -0
  24. package/src/runtime/broker-issuer.ts +37 -0
  25. package/src/runtime/child-pi-spawn.ts +53 -0
  26. package/src/runtime/child-pi.ts +42 -11
  27. package/src/runtime/crew-broker-child.ts +88 -0
  28. package/src/runtime/crew-broker-client.ts +673 -0
  29. package/src/runtime/crew-broker-tokens.ts +84 -0
  30. package/src/runtime/crew-broker.ts +1276 -0
  31. package/src/runtime/dynamic-workflow-context.ts +7 -3
  32. package/src/runtime/dynamic-workflow-runner.ts +1 -1
  33. package/src/runtime/plan-templates.ts +8 -6
  34. package/src/schema/config-schema.ts +14 -0
  35. package/src/state/mailbox.ts +43 -0
  36. package/src/ui/key-utils.ts +42 -0
  37. package/src/ui/keybinding-map.ts +29 -3
  38. package/src/ui/run-dashboard.ts +28 -0
  39. package/src/ui/settings-overlay.ts +42 -22
  40. package/src/utils/ndjson.ts +115 -0
  41. package/src/utils/session-utils.ts +30 -0
  42. package/src/utils/socket-path.ts +127 -0
  43. package/workflows/default.workflow.md +1 -1
  44. package/workflows/fast-fix.workflow.md +1 -1
  45. package/workflows/plan-execute.workflow.md +1 -1
  46. package/workflows/review.workflow.md +1 -1
@@ -236,9 +236,13 @@ export function synthesizeAgentConfig(name: string, model?: string): AgentConfig
236
236
  description: `Synthesized agent for dynamic workflow (${name}).`,
237
237
  source: "dynamic",
238
238
  filePath: `<dynamic-workflow>`,
239
- systemPrompt: `You are ${name}.`,
239
+ systemPrompt: `You are ${name}, an agent in a dynamic pi-crew workflow. Use the provided tools (read, grep, find, ls, bash) to investigate the target and produce concrete written findings. Do not return an empty response — always output substantive content for your task.`,
240
240
  model,
241
- tools: [],
241
+ // Round-N fix: give synthesized agents the STANDARD file-investigation toolkit
242
+ // (matches real built-in agents). Previously tools:[] relied on pi-args default
243
+ // behavior, which left most synthesized agents (explorer/analyst/critic/executor)
244
+ // unable to read the codebase → 10/11 agents returned empty/!ok in distill-dwf runs.
245
+ tools: ["read", "grep", "find", "ls", "bash"],
242
246
  inheritProjectContext: false,
243
247
  inheritSkills: false,
244
248
  };
@@ -420,7 +424,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
420
424
  // This correctly reduces spent when actualUsage < ESTIMATE.
421
425
  wfState.spent += (parsed.usage?.input ?? 0) + (parsed.usage?.output ?? 0) - ESTIMATE;
422
426
  reserved = false;
423
- let text = parsed.finalText ?? "";
427
+ let text = childResult.rawFinalText || parsed.finalText || "";
424
428
  // Round-11 test fix: parsePiJsonOutput only extracts text from pi event stream
425
429
  // ({type:"message_end", message:{role:"assistant", content:[...]}}). When the
426
430
  // agent emits plain JSON, plain text, or a different format, finalText is empty.
@@ -208,7 +208,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
208
208
  // it does NOT kill the script. Promise.race with a hard timeout at least returns an
209
209
  // error so the runner doesn't hang. The spawned child process is leaked, but the
210
210
  // dynamic-workflow returns failure promptly. (v1.5: use Worker threads to actually kill.)
211
- const SCRIPT_TIMEOUT_MS = Number.parseInt(process.env.PI_CREW_DWF_SCRIPT_TIMEOUT_MS ?? "", 10) || 600_000; // 10 min default
211
+ const SCRIPT_TIMEOUT_MS = Number.parseInt(process.env.PI_CREW_DWF_SCRIPT_TIMEOUT_MS ?? "", 10) || 1_800_000; // 30 min default (was 10 min; raised for distill-dwf + similar long pipelines)
212
212
  let timeoutHandle: NodeJS.Timeout | undefined;
213
213
  const timeoutPromise = new Promise<never>((_, reject) => {
214
214
  timeoutHandle = setTimeout(() => {
@@ -140,14 +140,15 @@ registerPlanTemplate({
140
140
  {
141
141
  name: "verify",
142
142
  role: "verifier",
143
- taskTemplate: "Verify that all review findings are addressed. Run tests if applicable. Confirm: {{goal}} is achieved.",
143
+ taskTemplate:
144
+ "Verify that all review findings are addressed. Run FAST checks (completes in <2 min): `npm run test:critical && npx tsc --noEmit`. Do NOT run `npm run test:unit` or `npm test` — too slow (642 files, >4 min). Confirm: {{goal}} is achieved.",
144
145
  maxTasks: 1,
145
146
  dependsOn: ["review"],
146
- verificationCommand: "npm test",
147
+ verificationCommand: "npm run test:critical && npx tsc --noEmit",
147
148
  },
148
149
  ],
149
150
  verificationCommands: {
150
- verify: "npm test",
151
+ verify: "npm run test:critical && npx tsc --noEmit",
151
152
  },
152
153
  });
153
154
 
@@ -187,13 +188,14 @@ registerPlanTemplate({
187
188
  {
188
189
  name: "verify",
189
190
  role: "verifier",
190
- taskTemplate: "Verify the complete implementation of: {{goal}}. Run tests, check types, validate all acceptance criteria.",
191
+ taskTemplate:
192
+ "Verify the complete implementation of: {{goal}}. Run FAST checks (`npm run test:critical && npx tsc --noEmit`, completes in <2 min). Do NOT run `npm run test:unit` or `npm test` — too slow for in-loop verification (642 files, >4 min). Validate all acceptance criteria.",
191
193
  maxTasks: 1,
192
194
  dependsOn: ["review"],
193
- verificationCommand: "npm test && npx tsc --noEmit",
195
+ verificationCommand: "npm run test:critical && npx tsc --noEmit",
194
196
  },
195
197
  ],
196
198
  verificationCommands: {
197
- verify: "npm test && npx tsc --noEmit",
199
+ verify: "npm run test:critical && npx tsc --noEmit",
198
200
  },
199
201
  });
@@ -275,6 +275,19 @@ export const PiTeamsUiConfigSchema = Type.Object(
275
275
  { additionalProperties: false },
276
276
  );
277
277
 
278
+ /** Phase 0 inter-pi broker config schema. Numeric limits are bounded per
279
+ * the plan: pathHashLen 4..32, maxFrameBytes 1024..1048576 (default 256 KiB),
280
+ * outboundQueueCap 32..4096 (default 256). */
281
+ export const CrewBrokerConfigSchema = Type.Object(
282
+ {
283
+ enabled: Type.Optional(Type.Boolean()),
284
+ pathHashLen: Type.Optional(Type.Integer({ minimum: 4, maximum: 32 })),
285
+ maxFrameBytes: Type.Optional(Type.Integer({ minimum: 1024, maximum: 1_048_576 })),
286
+ outboundQueueCap: Type.Optional(Type.Integer({ minimum: 32, maximum: 4096 })),
287
+ },
288
+ { additionalProperties: false },
289
+ );
290
+
278
291
  export const PiTeamsConfigSchema = Type.Object(
279
292
  {
280
293
  asyncByDefault: Type.Optional(Type.Boolean()),
@@ -297,6 +310,7 @@ export const PiTeamsConfigSchema = Type.Object(
297
310
  reliability: Type.Optional(PiTeamsReliabilityConfigSchema),
298
311
  otlp: Type.Optional(PiTeamsOtlpConfigSchema),
299
312
  ui: Type.Optional(PiTeamsUiConfigSchema),
313
+ broker: Type.Optional(CrewBrokerConfigSchema),
300
314
  },
301
315
  { additionalProperties: false },
302
316
  );
@@ -14,6 +14,47 @@ export type MailboxMessageKind = "message" | "steer" | "follow-up" | "response"
14
14
  export type MailboxMessagePriority = "urgent" | "normal" | "low";
15
15
  export type MailboxDeliveryMode = "interrupt" | "next_turn";
16
16
 
17
+ // ============================================================================
18
+ // Phase 1.3: post-append observer (single notification point)
19
+ // ============================================================================
20
+ // A registry of callbacks invoked AFTER a durable mailbox append completes
21
+ // (both sync and async paths). The broker registers here to fan out live
22
+ // notifications to connected recipients. The notifier is non-throwing and
23
+ // never blocks the append — it queues work via queueMicrotask so a slow
24
+ // observer cannot stall the mailbox write path. Registration is idempotent.
25
+
26
+ export type MailboxAppendObserver = (message: MailboxMessage) => void;
27
+
28
+ const mailboxAppendObservers = new Set<MailboxAppendObserver>();
29
+
30
+ /** Register a post-append observer. Returns an unsubscribe function. */
31
+ export function registerMailboxAppendObserver(fn: MailboxAppendObserver): () => void {
32
+ mailboxAppendObservers.add(fn);
33
+ return () => {
34
+ mailboxAppendObservers.delete(fn);
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Internal: invoked by appendMailboxMessage[Async] AFTER the durable write +
40
+ * delivery RMW have completed. Non-throwing; never blocks the caller.
41
+ */
42
+ function notifyMailboxAppended(message: MailboxMessage): void {
43
+ if (mailboxAppendObservers.size === 0) return;
44
+ // Snapshot the message so a later mutation by the caller cannot affect
45
+ // what the observer sees.
46
+ const snapshot = { ...message };
47
+ queueMicrotask(() => {
48
+ for (const fn of mailboxAppendObservers) {
49
+ try {
50
+ fn(snapshot);
51
+ } catch {
52
+ /* observer must not break the append path */
53
+ }
54
+ }
55
+ });
56
+ }
57
+
17
58
  export interface MailboxMessage {
18
59
  id: string;
19
60
  runId: string;
@@ -539,6 +580,7 @@ export function appendMailboxMessage(
539
580
  // F4: complete transitions are terminal-ish — keep full durability.
540
581
  writeDeliveryState(manifest, delivery, { durability: "full" });
541
582
  });
583
+ notifyMailboxAppended(complete);
542
584
  return complete;
543
585
  }
544
586
 
@@ -647,6 +689,7 @@ export async function appendMailboxMessageAsync(
647
689
  delivery.updatedAt = createdAt;
648
690
  writeDeliveryState(manifest, delivery, { durability: "full" });
649
691
  });
692
+ notifyMailboxAppended(complete);
650
693
  return complete;
651
694
  }
652
695
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * key-utils.ts — Centralised key matching helpers.
3
+ *
4
+ * Pi 0.81+'s TUI input layer (`@earendil-works/pi-tui`) ships a `matchesKey()`
5
+ * helper that handles multiple terminal key encodings: legacy CSI escapes
6
+ * (`\x1b[A`), application cursor mode (`\x1bOA`), and Kitty keyboard protocol
7
+ * variants. Pi-crew components previously compared against raw escape bytes
8
+ * (`data === "\x1b[A"`), which silently failed on terminals that emit the
9
+ * alternate encodings. This module wraps `matchesKey` with a single
10
+ * `keyOf()` helper so overlay code reads naturally while picking up the
11
+ * terminal-aware match logic for free.
12
+ *
13
+ * Returns the canonical KeyId when matchesKey recognises it, otherwise the
14
+ * raw input string so callers can fall through to ASCII-letter shortcuts.
15
+ */
16
+ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
17
+
18
+ export type PiKeyName = KeyId | string;
19
+
20
+ const COMMON_IDS: readonly KeyId[] = [
21
+ "up",
22
+ "down",
23
+ "left",
24
+ "right",
25
+ "enter",
26
+ "escape",
27
+ "tab",
28
+ "shift+tab",
29
+ "space",
30
+ "backspace",
31
+ "home",
32
+ "end",
33
+ "pageUp",
34
+ "pageDown",
35
+ ];
36
+
37
+ export function keyOf(data: string): KeyId | string {
38
+ for (const id of COMMON_IDS) {
39
+ if (matchesKey(data, id)) return id;
40
+ }
41
+ return data;
42
+ }
@@ -23,9 +23,12 @@
23
23
  * `use-global-shortcuts.ts:38-61`.
24
24
  */
25
25
 
26
+ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
27
+ import { keyOf } from "./key-utils.ts";
28
+
26
29
  export const DASHBOARD_KEYS = {
27
- close: ["q", "\u001b"],
28
- select: ["\r", "\n", "s"],
30
+ close: ["q", "escape", "\u001b"],
31
+ select: ["enter", "s", "\r", "\n", "tab", "\t", " "],
29
32
  help: ["?"],
30
33
  root: {
31
34
  summary: ["u"],
@@ -48,7 +51,7 @@ export const DASHBOARD_KEYS = {
48
51
  health: ["5"],
49
52
  metrics: ["6"],
50
53
  },
51
- navigation: { up: ["k", "\u001b[A"], down: ["j", "\u001b[B"] },
54
+ navigation: { up: ["k", "up"], down: ["j", "down"] },
52
55
  mailbox: {
53
56
  ack: ["A"],
54
57
  nudge: ["N"],
@@ -214,9 +217,32 @@ export { KEY_RESERVED };
214
217
  * arg skipped the `activePane === ...` branches).
215
218
  */
216
219
  export function dashboardActionForKey(data: string, activePane?: ActivePane): DashboardKeyAction | undefined {
220
+ // Two-pass dispatch to preserve case-sensitivity for plain ASCII keys
221
+ // while still normalizing escape sequences via matchesKey().
222
+ //
223
+ // Background: pi-tui's matchesKey() is case-insensitive, so matchesKey("d",
224
+ // "D") === true. A single-pass loop that intermixes exact + matchesKey
225
+ // checks would let the pane-scoped health-diagnostic-export binding
226
+ // (candidate "D") win over the unscoped agents binding (candidate "d")
227
+ // when activePane === "health" — collapsing the d/D case distinction.
228
+ //
229
+ // Pass 1 — exact string match (case-sensitive). Handles literal ASCII
230
+ // keystrokes ('d', 'D', 'q', 'S', …) and preserves their distinct meanings.
217
231
  for (const binding of BINDINGS) {
218
232
  if (binding.pane !== undefined && binding.pane !== activePane) continue;
219
233
  if (binding.keys.includes(data)) return binding.action;
220
234
  }
235
+ // Pass 2 — terminal-aware match for escape sequences / canonical KeyIds.
236
+ // Only reached when no exact ASCII match exists (data is e.g. '\x1b[A',
237
+ // '\x1bOA', or an app-cursor-mode variant). Uses matchesKey() to normalize
238
+ // legacy CSI, app-cursor-mode, and Kitty-protocol variants uniformly.
239
+ const key = keyOf(data);
240
+ for (const binding of BINDINGS) {
241
+ if (binding.pane !== undefined && binding.pane !== activePane) continue;
242
+ for (const candidate of binding.keys) {
243
+ if (key === candidate) return binding.action;
244
+ if (matchesKey(data, candidate as KeyId)) return binding.action;
245
+ }
246
+ }
221
247
  return undefined;
222
248
  }
@@ -401,6 +401,11 @@ function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache
401
401
  }
402
402
 
403
403
  export class RunDashboard implements DashboardComponent {
404
+ // TEMP DIAGNOSTIC (remove after verifying keybind fix on Pi 0.81.1)
405
+ private static _instanceCounter = 0;
406
+ private readonly _instanceId: number;
407
+ // END TEMP DIAGNOSTIC
408
+
404
409
  private selected = 0;
405
410
  private runScrollOffset = 0;
406
411
  private showFullProgress = false;
@@ -426,6 +431,14 @@ export class RunDashboard implements DashboardComponent {
426
431
  theme: unknown = {},
427
432
  options: RunDashboardOptions = {},
428
433
  ) {
434
+ // TEMP DIAGNOSTIC: log every constructor + handleInput + focus change
435
+ this._instanceId = ++RunDashboard._instanceCounter;
436
+ try {
437
+ process.stderr.write(
438
+ `[PI-CREW-DIAG] RunDashboard#${this._instanceId}.constructor runs=${runs.length} workspaceId=${options.workspaceId ?? "n/a"}\n`,
439
+ );
440
+ } catch {}
441
+ // END TEMP DIAGNOSTIC
429
442
  // Filter runs by workspaceId for session isolation
430
443
  // If workspaceId is provided, only show runs owned by that session or runs with no owner (legacy)
431
444
  const filteredRuns = options.workspaceId
@@ -806,7 +819,22 @@ export class RunDashboard implements DashboardComponent {
806
819
  return this.cachedLines;
807
820
  }
808
821
 
822
+ // Pi 0.81+ requires the Focusable contract: a string-indexable `focused`
823
+ // marker that TUI toggles to track which component currently receives
824
+ // input. Without it, isFocusable() returns false and downstream
825
+ // dispatch may skip the component. Declared as an own property so
826
+ // `"focused" in component` is true.
827
+ public focused = false;
828
+
809
829
  handleInput(data: string): void {
830
+ // TEMP DIAGNOSTIC (remove after verifying keybind fix on Pi 0.81.1)
831
+ if (process.env.PI_CREW_BROKER_DIAG_UI === "1") {
832
+ try {
833
+ process.stderr.write(
834
+ `[PI-CREW-DIAG] RunDashboard#${this._instanceId}.handleInput data=${JSON.stringify(data)} focused=${this.focused}\n`,
835
+ );
836
+ } catch {}
837
+ }
810
838
  const action = dashboardActionForKey(data, this.activePane);
811
839
  // K-1: "?" toggles the help overlay; while it is shown, any other key
812
840
  // (including Esc) just dismisses it first instead of acting.
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { truncateToWidth, visibleWidth } from "../utils/visual.ts";
8
8
  import { DynamicCrewBorder } from "./dynamic-border.ts";
9
+ import { keyOf } from "./key-utils.ts";
9
10
  import type { CrewTheme } from "./theme-adapter.ts";
10
11
  import { discoverPiThemes, getActivePiTheme } from "./theme-discovery.ts";
11
12
 
@@ -410,6 +411,7 @@ function currentValueFor(config: Record<string, unknown>, id: string): unknown {
410
411
  // ---------------------------------------------------------------------------
411
412
 
412
413
  class SelectSubmenu {
414
+ public focused = false;
413
415
  private selectedIndex = 0;
414
416
  private scrollOffset = 0;
415
417
  private readonly maxVisible = 14;
@@ -479,21 +481,25 @@ class SelectSubmenu {
479
481
  }
480
482
 
481
483
  handleInput(data: string): void {
482
- if (data === "\x1b[A" || data === "k") {
484
+ // Use keyOf() so that upstream Arrow / Esc encodings (legacy CSI,
485
+ // application cursor mode, Kitty protocol) all dispatch correctly.
486
+ // See key-utils.ts for details.
487
+ const k = keyOf(data);
488
+ if (k === "up" || k === "k") {
483
489
  this.selectedIndex = (this.selectedIndex - 1 + this.items.length) % this.items.length;
484
490
  this.ensureVisible();
485
491
  return;
486
492
  }
487
- if (data === "\x1b[B" || data === "j") {
493
+ if (k === "down" || k === "j") {
488
494
  this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
489
495
  this.ensureVisible();
490
496
  return;
491
497
  }
492
- if (data === "\r" || data === "\n") {
498
+ if (k === "enter") {
493
499
  this.onSelect(this.items[this.selectedIndex]!);
494
500
  return;
495
501
  }
496
- if (data === "\x1b" || data === "q") {
502
+ if (k === "escape" || k === "q") {
497
503
  this.onCancel();
498
504
  return;
499
505
  }
@@ -505,6 +511,7 @@ class SelectSubmenu {
505
511
  // ---------------------------------------------------------------------------
506
512
 
507
513
  class TextinputSubmenu {
514
+ public focused = false;
508
515
  private buffer = "";
509
516
  private readonly title: string;
510
517
  private readonly description: string;
@@ -544,16 +551,17 @@ class TextinputSubmenu {
544
551
  }
545
552
 
546
553
  handleInput(data: string): void {
547
- if (data === "\r" || data === "\n") {
554
+ const k = keyOf(data);
555
+ if (k === "enter") {
548
556
  this.onSubmit(this.buffer);
549
557
  return;
550
558
  }
551
- if (data === "\x1b" || data === "q") {
559
+ if (k === "escape" || k === "q") {
552
560
  this.onCancel();
553
561
  return;
554
562
  }
555
563
  // Backspace
556
- if (data === "\x7f" || data === "\b") {
564
+ if (data === "\x7f" || data === "\b" || k === "backspace") {
557
565
  this.buffer = this.buffer.slice(0, -1);
558
566
  return;
559
567
  }
@@ -570,6 +578,7 @@ class TextinputSubmenu {
570
578
  // ---------------------------------------------------------------------------
571
579
 
572
580
  class AgentOverridesSubmenu {
581
+ public focused = false;
573
582
  private readonly overrides: Record<string, { model?: string; thinking?: string }>;
574
583
  private readonly theme: CrewTheme;
575
584
  private readonly agents: string[];
@@ -654,15 +663,16 @@ class AgentOverridesSubmenu {
654
663
  handleInput(data: string): void {
655
664
  if (this.editField) return this.handleEditInput(data);
656
665
 
657
- if (data === "\x1b[A" || data === "k") {
666
+ const k = keyOf(data);
667
+ if (k === "up" || k === "k") {
658
668
  this.selectedIndex = (this.selectedIndex - 1 + this.agents.length) % this.agents.length;
659
669
  return;
660
670
  }
661
- if (data === "\x1b[B" || data === "j") {
671
+ if (k === "down" || k === "j") {
662
672
  this.selectedIndex = (this.selectedIndex + 1) % this.agents.length;
663
673
  return;
664
674
  }
665
- if (data === "\r" || data === "\n") {
675
+ if (k === "enter") {
666
676
  const agent = this.agents[this.selectedIndex]!;
667
677
  this.editField = "model";
668
678
  this.editBuffer = this.overrides[agent]?.model ?? "";
@@ -674,14 +684,15 @@ class AgentOverridesSubmenu {
674
684
  this.editBuffer = this.overrides[agent]?.thinking ?? "";
675
685
  return;
676
686
  }
677
- if (data === "\x1b") {
687
+ if (k === "escape") {
678
688
  this.onCancel();
679
689
  return;
680
690
  }
681
691
  }
682
692
 
683
693
  private handleEditInput(data: string): void {
684
- if (data === "\r" || data === "\n") {
694
+ const k = keyOf(data);
695
+ if (k === "enter") {
685
696
  const agent = this.agents[this.selectedIndex]!;
686
697
  if (!this.overrides[agent]) this.overrides[agent] = {};
687
698
  if (this.editField === "model") {
@@ -696,11 +707,11 @@ class AgentOverridesSubmenu {
696
707
  this.editField = null;
697
708
  return;
698
709
  }
699
- if (data === "\x1b") {
710
+ if (k === "escape") {
700
711
  this.editField = null;
701
712
  return;
702
713
  }
703
- if (data === "\x7f" || data === "\b") {
714
+ if (data === "\x7f" || data === "\b" || k === "backspace") {
704
715
  this.editBuffer = this.editBuffer.slice(0, -1);
705
716
  return;
706
717
  }
@@ -718,6 +729,10 @@ class SettingsOverlay {
718
729
  private config: Record<string, unknown>;
719
730
  private theme: CrewTheme;
720
731
  private callbacks: SettingsOverlayCallbacks;
732
+ // Pi 0.81+ Focusable contract — see RunDashboard. Same reasoning: without
733
+ // `focused` as an own property, isFocusable() returns false and keybind
734
+ // dispatch may skip the overlay.
735
+ public focused = false;
721
736
  private currentTabIndex = 0;
722
737
  private selectedIndex = 0;
723
738
  private scrollOffset = 0;
@@ -844,6 +859,10 @@ class SettingsOverlay {
844
859
  }
845
860
 
846
861
  handleInput(data: string): void {
862
+ // Route through keyOf() so upstream Arrow/Esc/Tab/Enter encodings
863
+ // (legacy CSI, application cursor mode, Kitty protocol, shift-modifier
864
+ // variants) are all recognized. See key-utils.ts.
865
+ const k = keyOf(data);
847
866
  // Submenu takes priority
848
867
  if (this.submenu) {
849
868
  this.submenu.handleInput(data);
@@ -851,19 +870,19 @@ class SettingsOverlay {
851
870
  }
852
871
 
853
872
  // Escape closes overlay
854
- if (data === "\x1b" || data === "q") {
873
+ if (k === "escape" || k === "q") {
855
874
  this.callbacks.onClose();
856
875
  return;
857
876
  }
858
877
 
859
- // Tab navigation
860
- if (data === "\t" || data === "\x1b[C") {
878
+ // Tab navigation — tab (forward) + shift+tab (backtab)
879
+ if (k === "tab") {
861
880
  this.currentTabIndex = (this.currentTabIndex + 1) % TABS.length;
862
881
  this.selectedIndex = 0;
863
882
  this.scrollOffset = 0;
864
883
  return;
865
884
  }
866
- if (data === "Z" || data === "\x1b[D") {
885
+ if (k === "shift+tab") {
867
886
  this.currentTabIndex = (this.currentTabIndex - 1 + TABS.length) % TABS.length;
868
887
  this.selectedIndex = 0;
869
888
  this.scrollOffset = 0;
@@ -874,19 +893,20 @@ class SettingsOverlay {
874
893
  const tabId = TABS[this.currentTabIndex]?.id ?? "runtime";
875
894
  const settings = SETTINGS.filter((s) => s.tab === tabId);
876
895
 
877
- if (data === "\x1b[A" || data === "k") {
896
+ if (k === "up" || k === "k") {
878
897
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
879
898
  this.ensureVisible(settings.length);
880
899
  return;
881
900
  }
882
- if (data === "\x1b[B" || data === "j") {
901
+ if (k === "down" || k === "j") {
883
902
  this.selectedIndex = Math.min(settings.length - 1, this.selectedIndex + 1);
884
903
  this.ensureVisible(settings.length);
885
904
  return;
886
905
  }
887
906
 
888
- // Activate item
889
- if (data === "\r" || data === "\n" || data === " ") {
907
+ // Activate item — Enter, Space, or Space-bar; matchesKey unifies carriage
908
+ // return / newline / spacebar as the same key family.
909
+ if (k === "enter" || k === "space") {
890
910
  this.activateItem(settings);
891
911
  }
892
912
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * ndjson.ts — Canonical broker NDJSON framing primitives (encoder + decoder
3
+ * + typed errors). Newline-delimited JSON, one frame per `\\n`.
4
+ *
5
+ * Moved out of the parallel-work stub `src/runtime/crew-broker-deps.ts`.
6
+ * The public surface (MAX_BROKER_FRAME_BYTES, BrokerError, BrokerErrorCode,
7
+ * encodeBrokerFrame, NdjsonDecoder) is preserved verbatim so importers
8
+ * can be updated with a single import-path change.
9
+ *
10
+ * No internal dependencies on other src/ modules — only Node built-ins.
11
+ */
12
+
13
+ /** Maximum encoded NDJSON frame size in UTF-8 bytes, INCLUDING the trailing `\n`. */
14
+ export const MAX_BROKER_FRAME_BYTES = 256 * 1024;
15
+
16
+ // ============================================================================
17
+ // BrokerError — typed protocol errors
18
+ // ============================================================================
19
+
20
+ export type BrokerErrorCode = "oversize-frame" | "auth" | "protocol" | "timeout" | "close" | "not-implemented" | "rate-limit";
21
+
22
+ export class BrokerError extends Error {
23
+ readonly code: BrokerErrorCode;
24
+ constructor(code: BrokerErrorCode, message: string) {
25
+ super(message);
26
+ this.name = "BrokerError";
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ // ============================================================================
32
+ // NDJSON encoder
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Encode a value as a single NDJSON frame (one JSON object terminated by `\n`).
37
+ * Rejects values whose encoded byte length exceeds MAX_BROKER_FRAME_BYTES BEFORE
38
+ * returning. Throws BrokerError("oversize-frame") for over-size payloads.
39
+ */
40
+ export function encodeBrokerFrame(value: unknown): Buffer {
41
+ // Stringify with replacer to drop undefined/function values (matches JSON.stringify semantics).
42
+ const json = JSON.stringify(value);
43
+ if (json === undefined) {
44
+ // Cannot encode (e.g. circular) — surface a typed protocol error.
45
+ throw new BrokerError("protocol", "encodeBrokerFrame: value is not JSON-serializable");
46
+ }
47
+ const enc = Buffer.from(json, "utf8");
48
+ // +1 for the trailing '\n'.
49
+ if (enc.length + 1 > MAX_BROKER_FRAME_BYTES) {
50
+ throw new BrokerError("oversize-frame", `frame exceeds ${MAX_BROKER_FRAME_BYTES} bytes (got ${enc.length + 1})`);
51
+ }
52
+ const out = Buffer.allocUnsafe(enc.length + 1);
53
+ enc.copy(out);
54
+ out[enc.length] = 0x0a; // '\n'
55
+ return out;
56
+ }
57
+
58
+ // ============================================================================
59
+ // NDJSON decoder
60
+ // ============================================================================
61
+
62
+ /** Cap the partial-frame accumulator to 2 * MAX_BROKER_FRAME_BYTES. Beyond that
63
+ * we surface oversize-frame and let the caller close. The 2x headroom is
64
+ * enough to assemble one full frame from chunks of any size. */
65
+ const MAX_DECODER_BUFFER = 2 * MAX_BROKER_FRAME_BYTES;
66
+
67
+ export class NdjsonDecoder {
68
+ private buffer: Buffer = Buffer.alloc(0);
69
+
70
+ /**
71
+ * Push a chunk; return the array of complete parsed values (each was
72
+ * followed by a `\n` in the stream). May return zero items (no full
73
+ * frame yet), one item, or many items. Malformed JSON throws
74
+ * BrokerError("protocol"); over-size accumulated buffer throws
75
+ * BrokerError("oversize-frame"). Callers must catch and close the socket.
76
+ */
77
+ push(chunk: Buffer): unknown[] {
78
+ if (chunk.length === 0) return [];
79
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
80
+ if (this.buffer.length > MAX_DECODER_BUFFER) {
81
+ throw new BrokerError("oversize-frame", `decoder buffer exceeded ${MAX_DECODER_BUFFER} bytes`);
82
+ }
83
+ const out: unknown[] = [];
84
+ let idx = this.buffer.indexOf(0x0a);
85
+ while (idx !== -1) {
86
+ const line = this.buffer.subarray(0, idx);
87
+ // Empty lines are skipped (lenient — mirrors herdr/NDJSON practice).
88
+ if (line.length > 0) {
89
+ // Reject an oversize LINE before parse.
90
+ if (line.length > MAX_BROKER_FRAME_BYTES) {
91
+ throw new BrokerError("oversize-frame", `line exceeds ${MAX_BROKER_FRAME_BYTES} bytes (got ${line.length})`);
92
+ }
93
+ try {
94
+ out.push(JSON.parse(line.toString("utf8")));
95
+ } catch (cause) {
96
+ throw new BrokerError("protocol", `ndjson: malformed JSON: ${(cause as Error).message}`);
97
+ }
98
+ }
99
+ // Advance past the consumed line + newline.
100
+ this.buffer = this.buffer.subarray(idx + 1);
101
+ idx = this.buffer.indexOf(0x0a);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ reset(): void {
107
+ this.buffer = Buffer.alloc(0);
108
+ }
109
+ }
110
+
111
+ // ============================================================================
112
+ // External re-exports
113
+ // ============================================================================
114
+ // Re-exported here for callers that import all broker primitives from ndjson.ts.
115
+ export { newBrokerToken } from "../runtime/crew-broker-tokens.ts";
@@ -78,3 +78,33 @@ export function extractSessionId(ctx: unknown): string | undefined {
78
78
  if (typeof raw !== "string" || raw.length === 0) return undefined;
79
79
  return raw;
80
80
  }
81
+
82
+ /**
83
+ * Broker-only session id extractor.
84
+ *
85
+ * Pi's `ExtensionContext` does NOT expose a top-level `sessionId` property on
86
+ * its public surface — the id is reachable via `ctx.sessionManager.getSessionId()`.
87
+ * This helper is only called from `installCrewBrokerLifecycleController.setSessionId`
88
+ * (once per session_start), so the extra method invocation is safe here. It is
89
+ * INTENTIONALLY a separate function from `extractSessionId`, which is called on
90
+ * every `context` event (before every LLM call) from `context-status-injection.ts`
91
+ * — extending that hot path with method calls was observed to freeze the TUI
92
+ * (dashboard opens but is unresponsive, footer does not render) during smoke
93
+ * testing, so it stays on the trivial property lookup.
94
+ *
95
+ * Tries the sessionManager path first, then falls back to a direct
96
+ * `ctx.sessionId` for test mock compatibility.
97
+ */
98
+ export function extractBrokerSessionId(ctx: unknown): string | undefined {
99
+ if (typeof ctx !== "object" || ctx === null) return undefined;
100
+ try {
101
+ const sm = (ctx as { sessionManager?: { getSessionId?: () => unknown } }).sessionManager;
102
+ const viaManager = sm?.getSessionId?.();
103
+ if (typeof viaManager === "string" && viaManager.length > 0) return viaManager;
104
+ const direct = Object.getOwnPropertyDescriptor(ctx, "sessionId")?.value;
105
+ if (typeof direct === "string" && direct.length > 0) return direct;
106
+ return undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }