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.
- package/CHANGELOG.md +83 -0
- package/README.md +16 -2
- package/dist/build-meta.json +289 -164
- package/dist/index.mjs +1744 -2732
- package/dist/index.mjs.map +4 -4
- package/docs/decisions/2026-07-21-broker-phase4-default-on.md +77 -0
- package/docs/decisions/2026-07-21-broker-windows-perms.md +91 -0
- package/docs/decisions/2026-07-22-broker-phase4-gated-on.md +99 -0
- package/docs/decisions/README.md +3 -0
- package/docs/publishing.md +29 -0
- package/package.json +3 -1
- package/scripts/build-bundle.mjs +7 -0
- package/scripts/postinstall.mjs +60 -1
- package/scripts/pty_probe.py +174 -0
- package/skills/real-test-pi-crew/SKILL.md +659 -0
- package/src/config/config.ts +42 -1
- package/src/config/defaults.ts +45 -1
- package/src/config/types.ts +19 -0
- package/src/extension/register.ts +6 -1
- package/src/extension/registration/context-builder.ts +4 -0
- package/src/extension/registration/lifecycle-handlers.ts +166 -3
- package/src/extension/registration/registration-types.ts +9 -0
- package/src/prompt/prompt-runtime.ts +108 -0
- package/src/runtime/broker-issuer.ts +37 -0
- package/src/runtime/child-pi-spawn.ts +53 -0
- package/src/runtime/child-pi.ts +42 -11
- package/src/runtime/crew-broker-child.ts +88 -0
- package/src/runtime/crew-broker-client.ts +673 -0
- package/src/runtime/crew-broker-tokens.ts +84 -0
- package/src/runtime/crew-broker.ts +1276 -0
- package/src/runtime/dynamic-workflow-context.ts +7 -3
- package/src/runtime/dynamic-workflow-runner.ts +1 -1
- package/src/runtime/plan-templates.ts +8 -6
- package/src/schema/config-schema.ts +14 -0
- package/src/state/mailbox.ts +43 -0
- package/src/ui/key-utils.ts +42 -0
- package/src/ui/keybinding-map.ts +29 -3
- package/src/ui/run-dashboard.ts +28 -0
- package/src/ui/settings-overlay.ts +42 -22
- package/src/utils/ndjson.ts +115 -0
- package/src/utils/session-utils.ts +30 -0
- package/src/utils/socket-path.ts +127 -0
- package/workflows/default.workflow.md +1 -1
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- 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
|
-
|
|
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) ||
|
|
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:
|
|
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:
|
|
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
|
);
|
package/src/state/mailbox.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -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", "
|
|
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", "
|
|
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
|
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
498
|
+
if (k === "enter") {
|
|
493
499
|
this.onSelect(this.items[this.selectedIndex]!);
|
|
494
500
|
return;
|
|
495
501
|
}
|
|
496
|
-
if (
|
|
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
|
-
|
|
554
|
+
const k = keyOf(data);
|
|
555
|
+
if (k === "enter") {
|
|
548
556
|
this.onSubmit(this.buffer);
|
|
549
557
|
return;
|
|
550
558
|
}
|
|
551
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
671
|
+
if (k === "down" || k === "j") {
|
|
662
672
|
this.selectedIndex = (this.selectedIndex + 1) % this.agents.length;
|
|
663
673
|
return;
|
|
664
674
|
}
|
|
665
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
873
|
+
if (k === "escape" || k === "q") {
|
|
855
874
|
this.callbacks.onClose();
|
|
856
875
|
return;
|
|
857
876
|
}
|
|
858
877
|
|
|
859
|
-
// Tab navigation
|
|
860
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|