pi-cockpit 0.21.0 → 0.22.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cockpit",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "@earendil-works/pi-ai": "*",
44
44
  "@earendil-works/pi-coding-agent": "*",
45
45
  "@earendil-works/pi-tui": "*",
46
- "pi-maestro-teammate": "^2.4.0",
46
+ "pi-maestro-teammate": "^2.5.0",
47
47
  "typebox": "*"
48
48
  },
49
49
  "peerDependenciesMeta": {
@@ -72,7 +72,7 @@
72
72
  "@earendil-works/pi-coding-agent": "0.84.4",
73
73
  "@earendil-works/pi-tui": "0.84.4",
74
74
  "@types/node": "^22.0.0",
75
- "pi-maestro-teammate": "2.4.0",
75
+ "pi-maestro-teammate": "2.5.0",
76
76
  "typebox": "1.3.7",
77
77
  "typescript": "^5.5.0"
78
78
  },
package/src/agent-bar.ts CHANGED
@@ -241,7 +241,9 @@ export function renderSessionBarLine(
241
241
  theme: Theme,
242
242
  shortcutHint?: SessionBarHintValue,
243
243
  ): string {
244
- const w = Math.max(1, width);
244
+ // A live main-screen row must not write the terminal's final column: doing so
245
+ // arms auto-wrap, while pi-tui still tracks the cursor on the original row.
246
+ const w = Math.max(1, width - 1);
245
247
  if (!shortcutHint) return renderContent(w);
246
248
  const hintSpec = typeof shortcutHint === "string" ? { text: shortcutHint, color: "dim" as const } : shortcutHint;
247
249
  const hint = theme.fg(hintSpec.color ?? "dim", hintSpec.text);
@@ -262,7 +264,7 @@ export function renderAgentBar(
262
264
  theme: Theme,
263
265
  options: AgentBarRenderOptions = {},
264
266
  ): string[] {
265
- if (endpoints.length === 0) return [truncateToWidth(chip(MAIN_SESSION_LABEL, true, "accent", theme), Math.max(1, width), "…")];
267
+ if (endpoints.length === 0) return [truncateToWidth(chip(MAIN_SESSION_LABEL, true, "accent", theme), Math.max(1, width - 1), "…")];
266
268
  const now = options.now ?? 0;
267
269
  // A stale selection (id no longer in the endpoint list) must not leave the
268
270
  // bar without a highlighted chip: fall back to root / first endpoint.
@@ -11,19 +11,227 @@ import type {
11
11
  } from "pi-maestro-teammate/v1/types";
12
12
  import type {
13
13
  RuntimeAgentReadEntityV2,
14
+ RuntimeReadModelChangeV2,
14
15
  RuntimeReadModelDeltaV2,
15
16
  RuntimeReadModelOwnershipV2,
16
17
  RuntimeReadModelSnapshotV2,
17
- } from "pi-maestro-teammate/v2/runtime";
18
- import {
19
- RuntimeReadModelProjectionV2,
20
- parseRuntimeReadModelDeltaV2,
21
- parseRuntimeReadModelOwnershipV2,
22
- parseRuntimeReadModelSnapshotV2,
18
+ RuntimeReadModelSourceV2,
23
19
  } from "pi-maestro-teammate/v2/runtime";
24
20
  import { sanitizeExtensionStatusText } from "./extension-status.ts";
25
21
  import { EXPERT_LEADER_NAME, type AgentRow, type AgentStatus } from "./types.ts";
26
22
 
23
+ export const RUNTIME_READ_MODEL_DELTA_EVENT = "teammate:runtime-read-model-delta-v2";
24
+ export const RUNTIME_READ_MODEL_QUERY_EVENT = "teammate:runtime-read-model-query-v2";
25
+ export const RUNTIME_READ_MODEL_SNAPSHOT_EVENT = "teammate:runtime-read-model-snapshot-v2";
26
+ export const RUNTIME_READ_MODEL_UNAVAILABLE_EVENT = "teammate:runtime-read-model-unavailable-v2";
27
+
28
+ /** Keep Cockpit loadable when its optional Teammate peer is absent. */
29
+ export function runtimeV2ReadEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
30
+ return env.PI_RUNTIME_BROKER?.trim().toLowerCase() === "sqlite"
31
+ && (env.PI_RUNTIME_V2_READ === undefined || env.PI_RUNTIME_V2_READ === "1");
32
+ }
33
+
34
+ function plainObject(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ function safeInteger(value: unknown, minimum = 0): value is number {
39
+ return Number.isSafeInteger(value) && (value as number) >= minimum;
40
+ }
41
+
42
+ function identifier(value: unknown): value is string {
43
+ return typeof value === "string" && value.length > 0 && value.length <= 1024 && !value.includes("\0");
44
+ }
45
+
46
+ function parseRuntimeReadModelOwnershipV2(value: unknown): RuntimeReadModelOwnershipV2 | undefined {
47
+ if (!plainObject(value)
48
+ || !identifier(value.workspaceId)
49
+ || !identifier(value.sessionId)
50
+ || !identifier(value.sourceId)
51
+ || !safeInteger(value.generation, 1)) return undefined;
52
+ return {
53
+ workspaceId: value.workspaceId,
54
+ sessionId: value.sessionId,
55
+ sourceId: value.sourceId,
56
+ generation: value.generation,
57
+ };
58
+ }
59
+
60
+ function parseSource(value: unknown): RuntimeReadModelSourceV2 | undefined {
61
+ if (!plainObject(value)
62
+ || !identifier(value.streamId)
63
+ || !safeInteger(value.revision, 1)
64
+ || !safeInteger(value.generation, 1)) return undefined;
65
+ const projection = value.projection === undefined
66
+ ? undefined
67
+ : parseRuntimeReadModelOwnershipV2(value.projection);
68
+ if (value.projection !== undefined && !projection) return undefined;
69
+ return {
70
+ streamId: value.streamId,
71
+ revision: value.revision,
72
+ generation: value.generation,
73
+ ...(projection ? { projection } : {}),
74
+ };
75
+ }
76
+
77
+ const AGENT_STATUSES = new Set<RuntimeAgentReadEntityV2["status"]>([
78
+ "pending", "running", "retrying", "sleeping", "completed", "failed", "terminated",
79
+ ]);
80
+
81
+ function parseRuntimeEntity(value: unknown): RuntimeAgentReadEntityV2 | undefined {
82
+ if (!plainObject(value)
83
+ || !identifier(value.correlationId)
84
+ || !safeInteger(value.generation, 1)
85
+ || !identifier(value.agent)
86
+ || !AGENT_STATUSES.has(value.status as RuntimeAgentReadEntityV2["status"])
87
+ || !safeInteger(value.startedAt)
88
+ || !safeInteger(value.lastActivityAt)) return undefined;
89
+ for (const key of ["name", "task", "spawnedBy", "parentCorrelationId", "phase", "requestedModel", "resolvedModel", "lastMessage", "error"] as const) {
90
+ if (value[key] !== undefined && typeof value[key] !== "string") return undefined;
91
+ }
92
+ if (value.dependencies !== undefined
93
+ && (!Array.isArray(value.dependencies) || value.dependencies.some((item) => !safeInteger(item)))) return undefined;
94
+ if (value.attemptedModels !== undefined
95
+ && (!Array.isArray(value.attemptedModels) || value.attemptedModels.some((item) => typeof item !== "string"))) return undefined;
96
+ if (value.projection !== undefined && !parseRuntimeReadModelOwnershipV2(value.projection)) return undefined;
97
+ return structuredClone(value) as unknown as RuntimeAgentReadEntityV2;
98
+ }
99
+
100
+ function parseRuntimeReadModelDeltaV2(value: unknown): RuntimeReadModelDeltaV2 | undefined {
101
+ if (!plainObject(value)
102
+ || value.version !== 2
103
+ || value.revision !== 1
104
+ || value.kind !== "agent-runs-delta"
105
+ || !safeInteger(value.baseCursor)
106
+ || !safeInteger(value.nextCursor, 1)
107
+ || value.nextCursor <= value.baseCursor
108
+ || !Array.isArray(value.changes)) return undefined;
109
+ const source = parseSource(value.source);
110
+ if (!source) return undefined;
111
+ const changes: RuntimeReadModelChangeV2[] = [];
112
+ for (const change of value.changes) {
113
+ if (!plainObject(change)) return undefined;
114
+ if (change.kind === "upsert") {
115
+ const entity = parseRuntimeEntity(change.entity);
116
+ if (!entity) return undefined;
117
+ changes.push({ kind: "upsert", entity });
118
+ continue;
119
+ }
120
+ if (change.kind === "tombstone"
121
+ && identifier(change.correlationId)
122
+ && safeInteger(change.generation, 1)) {
123
+ changes.push({ kind: "tombstone", correlationId: change.correlationId, generation: change.generation });
124
+ continue;
125
+ }
126
+ return undefined;
127
+ }
128
+ return {
129
+ version: 2,
130
+ revision: 1,
131
+ kind: "agent-runs-delta",
132
+ baseCursor: value.baseCursor,
133
+ nextCursor: value.nextCursor,
134
+ source,
135
+ changes,
136
+ };
137
+ }
138
+
139
+ function parseRuntimeReadModelSnapshotV2(value: unknown): RuntimeReadModelSnapshotV2 | undefined {
140
+ if (!plainObject(value)
141
+ || value.version !== 2
142
+ || value.revision !== 1
143
+ || value.kind !== "agent-runs-snapshot"
144
+ || !safeInteger(value.cursor)
145
+ || !Array.isArray(value.agents)) return undefined;
146
+ const source = parseSource(value.source);
147
+ if (!source) return undefined;
148
+ const agents = value.agents.map(parseRuntimeEntity);
149
+ if (agents.some((agent) => agent === undefined)) return undefined;
150
+ return {
151
+ version: 2,
152
+ revision: 1,
153
+ kind: "agent-runs-snapshot",
154
+ cursor: value.cursor,
155
+ source,
156
+ agents: agents as RuntimeAgentReadEntityV2[],
157
+ };
158
+ }
159
+
160
+ function cloneRuntimeEntity(entity: RuntimeAgentReadEntityV2): RuntimeAgentReadEntityV2 {
161
+ return structuredClone(entity);
162
+ }
163
+
164
+ class RuntimeReadModelProjectionV2 {
165
+ readonly #agents = new Map<string, RuntimeAgentReadEntityV2>();
166
+ #cursor = 0;
167
+ #source: RuntimeReadModelSourceV2 = { streamId: "uninitialized", revision: 1, generation: 1 };
168
+ #initialized = false;
169
+
170
+ get cursor(): number { return this.#cursor; }
171
+
172
+ applySnapshot(input: unknown): boolean {
173
+ const snapshot = parseRuntimeReadModelSnapshotV2(input);
174
+ if (!snapshot) return false;
175
+ if (this.#initialized && snapshot.source.streamId === this.#source.streamId) {
176
+ if (snapshot.source.generation < this.#source.generation) return false;
177
+ if (snapshot.source.generation === this.#source.generation && snapshot.cursor < this.#cursor) return false;
178
+ }
179
+ const next = new Map<string, RuntimeAgentReadEntityV2>();
180
+ for (const agent of snapshot.agents) {
181
+ const existing = next.get(agent.correlationId);
182
+ if (existing && existing.generation > agent.generation) continue;
183
+ next.set(agent.correlationId, cloneRuntimeEntity(agent));
184
+ }
185
+ this.#agents.clear();
186
+ for (const [id, agent] of next) this.#agents.set(id, agent);
187
+ this.#cursor = snapshot.cursor;
188
+ this.#source = { ...snapshot.source };
189
+ this.#initialized = true;
190
+ return true;
191
+ }
192
+
193
+ applyDelta(input: unknown): boolean {
194
+ const delta = parseRuntimeReadModelDeltaV2(input);
195
+ if (!delta
196
+ || delta.baseCursor !== this.#cursor
197
+ || delta.nextCursor <= this.#cursor
198
+ || delta.source.streamId !== this.#source.streamId
199
+ || delta.source.generation !== this.#source.generation
200
+ || JSON.stringify(delta.source.projection) !== JSON.stringify(this.#source.projection)
201
+ || delta.source.revision !== delta.nextCursor) return false;
202
+ const next = new Map([...this.#agents].map(([id, agent]) => [id, cloneRuntimeEntity(agent)]));
203
+ for (const change of delta.changes) {
204
+ if (change.kind === "upsert") {
205
+ const current = next.get(change.entity.correlationId);
206
+ if (current && change.entity.generation < current.generation) return false;
207
+ next.set(change.entity.correlationId, cloneRuntimeEntity(change.entity));
208
+ continue;
209
+ }
210
+ const current = next.get(change.correlationId);
211
+ if (current && change.generation < current.generation) return false;
212
+ if (!current || change.generation === current.generation) next.delete(change.correlationId);
213
+ }
214
+ this.#agents.clear();
215
+ for (const [id, agent] of next) this.#agents.set(id, agent);
216
+ this.#cursor = delta.nextCursor;
217
+ this.#source = { ...delta.source };
218
+ return true;
219
+ }
220
+
221
+ snapshot(): RuntimeReadModelSnapshotV2 {
222
+ return {
223
+ version: 2,
224
+ revision: 1,
225
+ kind: "agent-runs-snapshot",
226
+ cursor: this.#cursor,
227
+ source: { ...this.#source },
228
+ agents: [...this.#agents.values()]
229
+ .sort((left, right) => left.correlationId.localeCompare(right.correlationId))
230
+ .map(cloneRuntimeEntity),
231
+ };
232
+ }
233
+ }
234
+
27
235
  const STATUS_TEXT_MAX = 48;
28
236
  /** Selected-session output kept for the expandable fixed detail region. */
29
237
  export const SESSION_CONTENT_MAX = 2_048;
package/src/index.ts CHANGED
@@ -4,7 +4,18 @@ import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext, type Exte
4
4
  import type { TUI } from "@earendil-works/pi-tui";
5
5
  import { ambientKeysShouldYield, capturingOverlayVisible } from "./capturing-overlay.ts";
6
6
  import { Key, decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
- import { AgentReadStoreRouter, effectiveAgentStatus, type CompletePayload, type MessagePayload, type StartedPayload } from "./agents-store.ts";
7
+ import {
8
+ AgentReadStoreRouter,
9
+ RUNTIME_READ_MODEL_DELTA_EVENT,
10
+ RUNTIME_READ_MODEL_QUERY_EVENT,
11
+ RUNTIME_READ_MODEL_SNAPSHOT_EVENT,
12
+ RUNTIME_READ_MODEL_UNAVAILABLE_EVENT,
13
+ effectiveAgentStatus,
14
+ runtimeV2ReadEnabled,
15
+ type CompletePayload,
16
+ type MessagePayload,
17
+ type StartedPayload,
18
+ } from "./agents-store.ts";
8
19
  import { AmbientSurfaceCache, nextUiPromptDepth, shouldHideWorkingDuration, statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
9
20
  import { generateTitleWithModel } from "./title-llm.ts";
10
21
  import { suggestTitle } from "./title-gen.ts";
@@ -120,13 +131,6 @@ import {
120
131
  type CockpitConfig,
121
132
  } from "./types.ts";
122
133
  import type { MailboxHostRegistry } from "pi-maestro-teammate/v1/mailbox";
123
- import {
124
- RUNTIME_READ_MODEL_DELTA_EVENT,
125
- RUNTIME_READ_MODEL_QUERY_EVENT,
126
- RUNTIME_READ_MODEL_SNAPSHOT_EVENT,
127
- RUNTIME_READ_MODEL_UNAVAILABLE_EVENT,
128
- runtimeV2ReadEnabled,
129
- } from "pi-maestro-teammate/v2/runtime";
130
134
 
131
135
  export {
132
136
  EndpointStore,
@@ -1283,7 +1283,15 @@ export class MaestroSettingsShell implements Component, Focusable {
1283
1283
  const change = this.params.coordinator.changes(providerId).find((entry) => entry.key === definition.key && entry.scope === scope);
1284
1284
  if (change?.operation === "unset") return this.t("settings.inherited");
1285
1285
  if (change?.operation === "set") return this.formatValue(definition, change.value);
1286
- return this.displayConfigured(definition, this.configuredValue(this.params.coordinator.baseline(providerId), definition.key, scope));
1286
+ const baseline = this.params.coordinator.baseline(providerId);
1287
+ const configured = this.configuredValue(baseline, definition.key, scope);
1288
+ if (!configured || configured.state === "absent") {
1289
+ const effective = baseline?.effective.values.find((value) => value.key === definition.key);
1290
+ if (effective) {
1291
+ return `${this.t("settings.state.absent")} · ${this.t("settings.effective")} ${this.formatValue(definition, effective.value)}`;
1292
+ }
1293
+ }
1294
+ return this.displayConfigured(definition, configured);
1287
1295
  }
1288
1296
 
1289
1297
  private configuredValue(snapshot: SettingsSnapshot | undefined, key: string, scope: SettingsScope) {
@@ -96,6 +96,9 @@ export function makeAgentWidget(deps: AgentWidgetDeps) {
96
96
  return {
97
97
  render(width: number): string[] {
98
98
  const cfg = deps.getConfig();
99
+ // Agent rows update while teammate runs. Leave the final terminal column
100
+ // untouched so auto-wrap cannot move the real cursor below pi-tui's model.
101
+ const liveWidth = Math.max(1, width - 1);
99
102
  // graph(...) is the dispatch container, not an additional worker. Keep it
100
103
  // in AgentsStore for linkage and cleanup, and bridge its visible descendants
101
104
  // to the nearest non-graph ancestor for rendering.
@@ -137,7 +140,7 @@ export function makeAgentWidget(deps: AgentWidgetDeps) {
137
140
  if (runCount) headerSegs.push({ text: theme.fg("dim", tuiT("common.running", { count: runCount })), priority: 80, clippable: false });
138
141
  if (pendingCount) headerSegs.push({ text: theme.fg("dim", tuiT("common.pending", { count: pendingCount })), priority: 60, clippable: false });
139
142
  if (sleepingCount) headerSegs.push({ text: theme.fg("dim", tuiT("common.sleeping", { count: sleepingCount })), priority: 50, clippable: false });
140
- const headerLine = fitLineByPriority(headerSegs, width, UTILS, theme.fg("dim", g.separator), g.ellipsis);
143
+ const headerLine = fitLineByPriority(headerSegs, liveWidth, UTILS, theme.fg("dim", g.separator), g.ellipsis);
141
144
  // Focused-session priority: while a selected session's detail block is
142
145
  // open, it owns the Agent height allowance and the roster collapses to
143
146
  // this one-line summary. The per-agent rows stay reachable through the
@@ -183,12 +186,12 @@ export function makeAgentWidget(deps: AgentWidgetDeps) {
183
186
  above > 0 ? `↑ ${tuiT("common.more", { count: above })}` : "",
184
187
  below > 0 ? `↓ ${tuiT("common.more", { count: below })}` : "",
185
188
  ].filter(Boolean).join(` ${g.separator} `)),
186
- width,
189
+ liveWidth,
187
190
  g.ellipsis,
188
191
  )
189
192
  : undefined;
190
193
  if (marker && above > 0) lines.push(marker);
191
- lines.push(...renderAgents(visible, cfg.agentsMode, width, paint, UTILS, { ...opts, withHead: false, maxRows: windowRows + 1 }));
194
+ lines.push(...renderAgents(visible, cfg.agentsMode, liveWidth, paint, UTILS, { ...opts, withHead: false, maxRows: windowRows + 1 }));
192
195
  if (marker && above === 0) lines.push(marker);
193
196
  return lines;
194
197
  },
package/src/window-bar.ts CHANGED
@@ -59,7 +59,7 @@ export function renderWindowBar(
59
59
  endpoint,
60
60
  }));
61
61
  if (tabs.length === 0) {
62
- return [truncateToWidth(theme.fg("muted", tuiT("window.empty")), Math.max(1, width), "…")];
62
+ return [truncateToWidth(theme.fg("muted", tuiT("window.empty")), Math.max(1, width - 1), "…")];
63
63
  }
64
64
  return [renderSessionBarLine(
65
65
  (availableWidth) => renderSessionTabLine(tabs, availableWidth, {