@aeondave/pi-persona 1.17.0 → 1.17.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.
@@ -0,0 +1,251 @@
1
+ /** Public, persistable pi-persona preferences and their resolved runtime config. */
2
+
3
+ import { isThinkingLevel } from "./types.ts";
4
+ import { resolveConfig, type PiPersonaConfig } from "./config.ts";
5
+
6
+ export type CompanionId = "mind" | "flow";
7
+
8
+ /** Preferences contributed by the optional companion extensions to the shared settings panel. */
9
+ export interface CompanionPreferenceValues {
10
+ mindCapture: "auto" | "prompt" | "off";
11
+ mindNudge: boolean;
12
+ flowEnabled: boolean;
13
+ flowPort: number;
14
+ flowIdleMs: number;
15
+ }
16
+
17
+ export type PersonaPreferences = Partial<Omit<PiPersonaConfig, "disabled" | "stateFile"> & CompanionPreferenceValues>;
18
+ export type SettingId = keyof PersonaPreferences;
19
+ export type SettingKind = "boolean" | "number" | "string" | "string-list" | "choice";
20
+
21
+ export interface PersonaSetting {
22
+ readonly id: SettingId;
23
+ readonly group: string;
24
+ readonly label: string;
25
+ readonly description: string;
26
+ readonly kind: SettingKind;
27
+ readonly choices?: readonly string[];
28
+ readonly env?: string;
29
+ readonly applyWhen: string;
30
+ readonly owner?: CompanionId;
31
+ readonly min?: number;
32
+ readonly max?: number;
33
+ }
34
+
35
+ const APPLY_WHEN = "next Pi restart or native extension reload";
36
+ const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
37
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
38
+
39
+ /** The one registry used by settings UI, validation, and environment precedence. */
40
+ export const PERSONA_SETTINGS: readonly PersonaSetting[] = [
41
+ { id: "extraDirs", group: "Discovery", label: "Extra persona directories", description: "Additional directories searched for persona and agent definitions.", kind: "string-list", env: "PI_PERSONA_DIRS", applyWhen: APPLY_WHEN },
42
+ { id: "defaultPersona", group: "Startup", label: "Default persona", description: "Persona selected unless --persona is explicit; takes precedence over the remembered persona.", kind: "string", env: "PI_PERSONA_DEFAULT", applyWhen: APPLY_WHEN },
43
+ { id: "keybinding", group: "Interface", label: "Persona cycle key", description: "Key used to cycle through installed personas.", kind: "string", env: "PI_PERSONA_KEY", applyWhen: APPLY_WHEN },
44
+ { id: "persist", group: "Startup", label: "Remember last persona", description: "Restore the last explicitly selected persona in a later session.", kind: "boolean", env: "PI_PERSONA_PERSIST", applyWhen: APPLY_WHEN },
45
+ { id: "delegateDefaultAllow", group: "Delegation", label: "Allow delegation by default", description: "Fallback for delegate targets when the persona declares neither an allow nor a deny list; explicit rules take precedence.", kind: "boolean", env: "PI_PERSONA_DELEGATE_DEFAULT", applyWhen: APPLY_WHEN },
46
+ { id: "seed", group: "Discovery", label: "Install bundled defaults on first run", description: "Opt in to installing bundled personas and agents once.", kind: "boolean", env: "PI_PERSONA_SEED", applyWhen: APPLY_WHEN },
47
+ { id: "childThinking", group: "Delegation", label: "Delegated thinking level", description: "Thinking level forced on delegated child agents.", kind: "choice", choices: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], env: "PI_PERSONA_CHILD_THINKING", applyWhen: APPLY_WHEN },
48
+ { id: "engine", group: "Delegation", label: "Sub-agent engine", description: "Run sub-agents in-process or as child Pi processes.", kind: "choice", choices: ["inproc", "child"], env: "PI_PERSONA_ENGINE", applyWhen: APPLY_WHEN },
49
+ { id: "peekEveryMs", group: "Delegation", label: "Progress and stall check interval", description: "Milliseconds between async progress/stall checks; zero disables checks.", kind: "number", env: "PI_PERSONA_PEEK_MS", applyWhen: APPLY_WHEN, min: 0, max: MAX_TIMER_DELAY_MS },
50
+ { id: "checkInEveryMs", group: "Delegation", label: "Progress check-in interval", description: "Milliseconds between operator-only progress entries; zero disables check-ins.", kind: "number", env: "PI_PERSONA_CHECKIN_MS", applyWhen: APPLY_WHEN, min: 0, max: MAX_TIMER_DELAY_MS },
51
+ { id: "agentHardTimeoutMs", group: "Delegation", label: "Agent hard timeout", description: "Opt-in wall-clock limit in milliseconds for one agent run; zero means unlimited.", kind: "number", env: "PI_PERSONA_AGENT_MAX_MS", applyWhen: APPLY_WHEN, min: 0, max: MAX_TIMER_DELAY_MS },
52
+ { id: "agentStartupTimeoutMs", group: "Delegation", label: "Agent startup deadline", description: "Milliseconds allowed without first progress while an agent starts; zero disables the deadline.", kind: "number", env: "PI_PERSONA_AGENT_STARTUP_MS", applyWhen: APPLY_WHEN, min: 0, max: MAX_TIMER_DELAY_MS },
53
+ { id: "nudge", group: "Delegation", label: "Delegation nudges", description: "Delegation and persistence checkpoints; disabling silences both.", kind: "boolean", env: "PI_PERSONA_NUDGE", applyWhen: APPLY_WHEN },
54
+ { id: "broker", group: "Collaboration", label: "Cross-process communication broker", description: "Connect child-engine agents to supervisor messaging and steering.", kind: "boolean", env: "PI_PERSONA_BROKER", applyWhen: APPLY_WHEN },
55
+ { id: "exocom", group: "Collaboration", label: "External agent communication", description: "Enable communication between independent top-level Pi instances.", kind: "boolean", env: "PI_PERSONA_EXOCOM", applyWhen: APPLY_WHEN },
56
+ { id: "asyncRetain", group: "Delegation", label: "Retained async results", description: "Number of settled async runs retained for later collection.", kind: "number", env: "PI_PERSONA_ASYNC_RETAIN", applyWhen: APPLY_WHEN, min: 1, max: MAX_SAFE_INTEGER },
57
+ { id: "ledgerV2", group: "Delegation", label: "Expanded retry-loop identity", description: "Include role, tools, and isolation in the repeated-delegation guard key.", kind: "boolean", env: "PI_PERSONA_LEDGER_V2", applyWhen: APPLY_WHEN },
58
+ { id: "spine", group: "Prompt", label: "Supervisor spine", description: "Shared prompt layer for the supervisor: off, on for the bundled layer, or a file path.", kind: "string", env: "PI_PERSONA_SPINE", applyWhen: APPLY_WHEN },
59
+ { id: "spineLegs", group: "Prompt", label: "Delegated-leg spine", description: "Prompt layer for delegated agents; follows the supervisor selection unless set separately.", kind: "string", env: "PI_PERSONA_SPINE_LEGS", applyWhen: APPLY_WHEN },
60
+ { id: "changeReport", group: "Delegation", label: "Report files changed by agents", description: "Include a git-based changed-files report for non-worktree agent runs.", kind: "boolean", env: "PI_PERSONA_LEG_CHANGE_REPORT", applyWhen: APPLY_WHEN },
61
+ { id: "mindCapture", group: "Mind", label: "Memory capture mode", description: "Capture explicit user requests (auto), suggest only (prompt), or disable cue detection (off). The memory tool remains available.", kind: "choice", choices: ["auto", "prompt", "off"], env: "PI_PERSONA_MIND_CAPTURE", owner: "mind", applyWhen: "next Pi restart or native extension reload" },
62
+ { id: "mindNudge", group: "Mind", label: "Mind suggestions", description: "Optional memory/backlog suggestions, not explicit user capture. Only the exact environment value off disables them.", kind: "boolean", env: "PI_PERSONA_MIND_NUDGE", owner: "mind", applyWhen: "next Pi restart or native extension reload" },
63
+ { id: "flowEnabled", group: "Flow", label: "Attach Flow by default", description: "Enable this Pi's default dashboard attachment. --no-flow opts out for one run; other Pi sessions keep their shared service.", kind: "boolean", owner: "flow", applyWhen: "next Pi restart or native extension reload" },
64
+ { id: "flowPort", group: "Flow", label: "Flow shared-service port", description: "Desired loopback port; 0 asks the operating system for an available port. Invalid extension environment values fall back to 7874.", kind: "number", env: "PI_PERSONA_FLOW_PORT", owner: "flow", applyWhen: "next shared-service start after Pi restart or native extension reload; an existing service is unchanged", min: 0, max: 65_535 },
65
+ { id: "flowIdleMs", group: "Flow", label: "Flow idle grace (advanced)", description: "Milliseconds with no Pi registrations and no browser SSE clients before service shutdown (50–60000). Invalid environment values use 60000 with a Flow warning.", kind: "number", env: "PI_PERSONA_FLOW_IDLE_MS", owner: "flow", applyWhen: "next shared-service start after Pi restart or native extension reload; an existing service is unchanged", min: 50, max: 60_000 },
66
+ ];
67
+
68
+ const SETTING_BY_ID = new Map<SettingId, PersonaSetting>(PERSONA_SETTINGS.map((setting) => [setting.id, setting]));
69
+
70
+ function hasExplicitEnvOverride(setting: PersonaSetting, env: Record<string, string | undefined>): boolean {
71
+ if (!setting.env) return false;
72
+ const value = env[setting.env];
73
+ if (value === undefined) return false;
74
+ // The existing resolver deliberately treats an empty legs selector as "follow spine".
75
+ return setting.id === "spineLegs" && value.trim() === "" ? false : true;
76
+ }
77
+
78
+ /**
79
+ * Whether a defined environment key pins a setting using the existing resolver semantics.
80
+ * Even a value the resolver normalizes/rejects still pins its resulting default; the one
81
+ * deliberate exception is blank PI_PERSONA_SPINE_LEGS, which means "follow spine".
82
+ */
83
+ export function settingHasEnvironmentOverride(
84
+ setting: PersonaSetting | SettingId,
85
+ env: Record<string, string | undefined>,
86
+ ): boolean {
87
+ const definition = typeof setting === "string" ? SETTING_BY_ID.get(setting) : setting;
88
+ return definition ? hasExplicitEnvOverride(definition, env) : false;
89
+ }
90
+
91
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
92
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
93
+ const prototype = Object.getPrototypeOf(value) as unknown;
94
+ return prototype === Object.prototype || prototype === null;
95
+ }
96
+
97
+ function validateValue(setting: PersonaSetting, value: unknown): unknown {
98
+ switch (setting.kind) {
99
+ case "boolean":
100
+ if (typeof value !== "boolean") throw new Error(`invalid preference ${setting.id}: expected a boolean`);
101
+ return value;
102
+ case "number":
103
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
104
+ throw new Error(`invalid preference ${setting.id}: expected a safe integer`);
105
+ }
106
+ if (setting.min !== undefined && value < setting.min) throw new Error(`invalid preference ${setting.id}: must be at least ${setting.min}`);
107
+ if (setting.max !== undefined && value > setting.max) throw new Error(`invalid preference ${setting.id}: must be at most ${setting.max}`);
108
+ return value;
109
+ case "string":
110
+ if (typeof value !== "string") throw new Error(`invalid preference ${setting.id}: expected a string`);
111
+ if (setting.id === "defaultPersona" || setting.id === "keybinding") {
112
+ const normalized = value.trim();
113
+ if (!normalized) throw new Error(`invalid preference ${setting.id}: value must not be blank`);
114
+ return normalized;
115
+ }
116
+ return value;
117
+ case "string-list":
118
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
119
+ throw new Error(`invalid preference ${setting.id}: expected a list of strings`);
120
+ }
121
+ return [...value];
122
+ case "choice":
123
+ if (typeof value !== "string" || !setting.choices?.includes(value)) {
124
+ throw new Error(`invalid preference ${setting.id}: expected one of ${setting.choices?.join(", ") ?? "the listed choices"}`);
125
+ }
126
+ if (setting.id === "childThinking" && !isThinkingLevel(value)) {
127
+ throw new Error(`invalid preference ${setting.id}: unsupported thinking level`);
128
+ }
129
+ return value;
130
+ }
131
+ }
132
+
133
+ /** Validate JSON-sourced preferences strictly. Unknown keys are rejected so a typo cannot hide. */
134
+ export function validatePreferences(value: unknown): PersonaPreferences {
135
+ if (!isPlainObject(value)) throw new Error("invalid persona preferences: expected an object");
136
+ const preferences: Record<string, unknown> = {};
137
+ for (const [key, item] of Object.entries(value)) {
138
+ if (!SETTING_BY_ID.has(key as SettingId)) {
139
+ throw new Error(`unknown persona preference: ${key}`);
140
+ }
141
+ const setting = SETTING_BY_ID.get(key as SettingId);
142
+ if (!setting) throw new Error(`unknown persona preference: ${key}`);
143
+ Object.defineProperty(preferences, key, {
144
+ value: validateValue(setting, item), enumerable: true, configurable: true, writable: true,
145
+ });
146
+ }
147
+ return preferences as PersonaPreferences;
148
+ }
149
+
150
+ /** Resolve startup settings: environment values remain explicit overrides over saved preferences. */
151
+ export function resolvePersonaConfig(
152
+ preferences: PersonaPreferences,
153
+ env: Record<string, string | undefined>,
154
+ ): PiPersonaConfig {
155
+ const saved = validatePreferences(preferences);
156
+ const config = resolveConfig(env);
157
+ for (const setting of PERSONA_SETTINGS) {
158
+ if (setting.owner !== undefined) continue;
159
+ if (settingHasEnvironmentOverride(setting, env)) continue;
160
+ if (!Object.hasOwn(saved, setting.id)) continue;
161
+ const value = saved[setting.id];
162
+ if (value === undefined) continue;
163
+ if (setting.id === "spine") {
164
+ if (typeof value !== "string") throw new Error("invalid saved spine preference");
165
+ config.spine = resolveConfig({ PI_PERSONA_SPINE: value }).spine;
166
+ } else if (setting.id === "spineLegs") {
167
+ if (typeof value !== "string") throw new Error("invalid saved spineLegs preference");
168
+ config.spineLegs = resolveConfig({ PI_PERSONA_SPINE_LEGS: value }).spineLegs;
169
+ } else {
170
+ Object.assign(config, { [setting.id]: Array.isArray(value) ? [...value] : value });
171
+ }
172
+ }
173
+ const savedSpine = Object.hasOwn(saved, "spine") && env.PI_PERSONA_SPINE === undefined;
174
+ const savedLegs = Object.hasOwn(saved, "spineLegs") && !settingHasEnvironmentOverride("spineLegs", env);
175
+ const envLegsFollow = env.PI_PERSONA_SPINE_LEGS === undefined || env.PI_PERSONA_SPINE_LEGS.trim() === "";
176
+ if (savedSpine && !savedLegs && envLegsFollow) config.spineLegs = config.spine;
177
+ return config;
178
+ }
179
+
180
+ function resolveMindCapture(value: string | undefined): CompanionPreferenceValues["mindCapture"] {
181
+ const normalized = value?.trim().toLowerCase();
182
+ return normalized === "prompt" || normalized === "off" ? normalized : "auto";
183
+ }
184
+
185
+ function resolveFlowPort(value: string | undefined): number {
186
+ if (value === undefined || !/^\d+$/.test(value)) return 7874;
187
+ const port = Number(value);
188
+ return Number.isSafeInteger(port) && port >= 0 && port <= 65_535 ? port : 7874;
189
+ }
190
+
191
+ function resolveFlowIdleMs(value: string | undefined): number {
192
+ if (value === undefined || !/^\d+$/.test(value)) return 60_000;
193
+ const idleMs = Number(value);
194
+ return Number.isSafeInteger(idleMs) && idleMs >= 50 && idleMs <= 60_000 ? idleMs : 60_000;
195
+ }
196
+
197
+ /** Resolve pi-persona config plus companion previews using the companions' current env grammar. */
198
+ export function resolveSettingsValues(
199
+ preferences: PersonaPreferences,
200
+ env: Record<string, string | undefined>,
201
+ ): PiPersonaConfig & CompanionPreferenceValues {
202
+ const saved = validatePreferences(preferences);
203
+ const config = resolvePersonaConfig(saved, env);
204
+ const values: CompanionPreferenceValues = {
205
+ mindCapture: resolveMindCapture(env.PI_PERSONA_MIND_CAPTURE),
206
+ mindNudge: env.PI_PERSONA_MIND_NUDGE !== "off",
207
+ flowEnabled: true,
208
+ flowPort: resolveFlowPort(env.PI_PERSONA_FLOW_PORT),
209
+ flowIdleMs: resolveFlowIdleMs(env.PI_PERSONA_FLOW_IDLE_MS),
210
+ };
211
+ for (const setting of PERSONA_SETTINGS) {
212
+ if (setting.owner === undefined || settingHasEnvironmentOverride(setting, env)) continue;
213
+ if (!Object.hasOwn(saved, setting.id)) continue;
214
+ const value = saved[setting.id];
215
+ if (value !== undefined) Object.assign(values, { [setting.id]: Array.isArray(value) ? [...value] : value });
216
+ }
217
+ return { ...config, ...values };
218
+ }
219
+
220
+ /** Parse the text field used by a settings editor according to registry metadata. */
221
+ export function parseSettingInput(setting: PersonaSetting, text: string): unknown {
222
+ switch (setting.kind) {
223
+ case "boolean": {
224
+ const value = text.trim().toLowerCase();
225
+ if (["true", "on", "yes", "1"].includes(value)) return true;
226
+ if (["false", "off", "no", "0"].includes(value)) return false;
227
+ throw new Error(`invalid ${setting.id}: enter true or false`);
228
+ }
229
+ case "number": {
230
+ const trimmed = text.trim();
231
+ if (!trimmed) throw new Error(`invalid ${setting.id}: enter a number`);
232
+ const value = Number(trimmed);
233
+ return validateValue(setting, value);
234
+ }
235
+ case "string":
236
+ return validateValue(setting, text);
237
+ case "string-list":
238
+ if (text.trimStart().startsWith("[")) {
239
+ let parsed: unknown;
240
+ try {
241
+ parsed = JSON.parse(text) as unknown;
242
+ } catch {
243
+ throw new Error(`invalid ${setting.id}: expected a JSON string array or one item per line`);
244
+ }
245
+ return validateValue(setting, parsed);
246
+ }
247
+ return validateValue(setting, text.split(/\r?\n/).map((item) => item.trim()).filter(Boolean));
248
+ case "choice":
249
+ return validateValue(setting, text.trim());
250
+ }
251
+ }
@@ -153,7 +153,7 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
153
153
  if (isPositiveFiniteMs(spec.timeoutMs)) childOptions.timeoutMs = spec.timeoutMs;
154
154
  if (onProgress) {
155
155
  childOptions.onProgress = (snap) =>
156
- onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
156
+ onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolOutputAt !== undefined ? { toolOutputAt: snap.toolOutputAt } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
157
157
  }
158
158
  // The run aborts if EITHER the whole-run signal or this agent's own (UI stop) fires.
159
159
  const signal = combineSignals(deps.signal, callSignal);
@@ -50,6 +50,8 @@ export interface AsyncRun {
50
50
  * rose) — NOT merely the last event. A worker looping without headway keeps emitting but
51
51
  * never bumps this, so `now - lastAdvanceAt` is what tells "working" from "stuck". */
52
52
  lastAdvanceAt?: number;
53
+ /** Waiting for semaphore admission; queued time is not agent execution time. */
54
+ queued?: boolean;
53
55
  /** Internal bookkeeping: true once the completion listeners have fired for this entry. Guards a
54
56
  * force-settle racing the run thunk's natural resolution so listeners fire exactly once. Lives on
55
57
  * the entry (not a side Set) so it survives pruning and the `launch` closure still sees it. */
@@ -93,7 +95,7 @@ export class AsyncRunTracker {
93
95
  this.maxRetained = opts?.maxRetained ?? 25;
94
96
  }
95
97
 
96
- launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean }, run: RunThunk): string {
98
+ launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean }, run: RunThunk): string {
97
99
  this.seq += 1;
98
100
  const id = `run-${this.seq.toString(36)}`;
99
101
  const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
@@ -104,7 +106,7 @@ export class AsyncRunTracker {
104
106
  status: "running",
105
107
  progress: { output: "", turns: 0, tokens: 0 },
106
108
  startedAt,
107
- lastAdvanceAt: startedAt,
109
+ ...(meta.queued ? { queued: true } : { lastAdvanceAt: startedAt }),
108
110
  };
109
111
  if (meta.mutates) entry.mutates = true;
110
112
  // Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
@@ -122,10 +124,12 @@ export class AsyncRunTracker {
122
124
  try {
123
125
  Promise.resolve(
124
126
  run((s) => {
125
- // Only a REAL advance (more output, or a higher turn/token count) resets the stall
126
- // clock — a repeated identical snapshot from a spinning worker must not mask a stall.
127
+ // Newly emitted textual tool output is a progress advance. Use the injected clock,
128
+ // never the stream's event timestamp, for stall-age accounting.
127
129
  const prev = entry.progress;
128
- if (s.output.length > prev.output.length || s.tokens > prev.tokens || s.turns > prev.turns) {
130
+ const toolOutputAdvanced = s.toolOutputAt !== undefined && Number.isFinite(s.toolOutputAt)
131
+ && s.toolOutputAt >= 0 && s.toolOutputAt !== prev.toolOutputAt;
132
+ if (!entry.queued && (s.output.length > prev.output.length || s.tokens > prev.tokens || s.turns > prev.turns || toolOutputAdvanced)) {
129
133
  entry.lastAdvanceAt = this.now();
130
134
  }
131
135
  entry.progress = s;
@@ -179,6 +183,15 @@ export class AsyncRunTracker {
179
183
  }
180
184
  }
181
185
 
186
+ /** Start the stall window when a queued run actually acquires its concurrency slot. */
187
+ markAdmitted(id: string): boolean {
188
+ const entry = this.runs.get(id);
189
+ if (!entry || entry.status !== "running" || entry.settled || entry.queued !== true) return false;
190
+ delete entry.queued;
191
+ entry.lastAdvanceAt = this.now();
192
+ return true;
193
+ }
194
+
182
195
  /**
183
196
  * Force a still-"running" run to settle as stopped and fire its completion listeners. Escape hatch
184
197
  * for a run whose hard-stop handle is gone (consumed by a prior abort, or never registered) while
@@ -445,6 +458,12 @@ export function clipRunOutput(text: string, id: string, maxChars: number, label
445
458
  return `${text.slice(0, headChars)}${marker}${tailChars > 0 ? text.slice(-tailChars) : ""}`;
446
459
  }
447
460
 
461
+ export function isAsyncRunStalled(run: AsyncRun, now: number, stallMs: number): boolean {
462
+ return run.status === "running" && run.queued !== true && stallMs > 0
463
+ && run.lastAdvanceAt !== undefined && Number.isFinite(run.lastAdvanceAt) && run.lastAdvanceAt >= 0
464
+ && now - run.lastAdvanceAt >= stallMs;
465
+ }
466
+
448
467
  export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
449
468
  if (runs.length === 0) return "No async runs.";
450
469
  const running = runs.filter((r) => r.status === "running").length;
@@ -460,10 +479,11 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
460
479
  const head = `[${r.id}] ${name} — ${r.status}`;
461
480
  if (r.status === "running") {
462
481
  let line = `${head} (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
463
- if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
464
- else if (now !== undefined && stallMs !== undefined && stallMs > 0 && r.lastAdvanceAt !== undefined) {
465
- const stalledFor = now - r.lastAdvanceAt;
466
- if (stalledFor >= stallMs) line += ` ⚠ possibly stuck (no progress for ${Math.round(stalledFor / 1000)}s)`;
482
+ if (r.queued) line += " · queued (waiting for concurrency slot)";
483
+ else if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
484
+ else if (now !== undefined && stallMs !== undefined && isAsyncRunStalled(r, now, stallMs)) {
485
+ const stalledFor = now - r.lastAdvanceAt!;
486
+ line += ` ⚠ possibly stuck (no progress for ${Math.round(stalledFor / 1000)}s)`;
467
487
  }
468
488
  return line;
469
489
  }
@@ -490,8 +510,22 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
490
510
  * forgotten. Pure w.r.t. the injected `now`, so it is unit-tested without a real clock.
491
511
  */
492
512
  export class PeekWatcher {
493
- /** runId → the `lastAdvanceAt` value at which we reported it stalled (a later advance re-arms). */
513
+ /** runId → the `lastAdvanceAt` value at which the operator episode was reported. */
494
514
  private readonly reportedAt = new Map<string, number>();
515
+ /** Runs whose one lifetime model stall wake was successfully delivered. */
516
+ private readonly modelWoken = new Set<string>();
517
+
518
+ firstWakeCandidates(runs: AsyncRun[]): AsyncRun[] {
519
+ return runs.filter((run) => !this.modelWoken.has(run.id));
520
+ }
521
+
522
+ acknowledgeModelWake(runIds: readonly string[]): void {
523
+ for (const id of runIds) this.modelWoken.add(id);
524
+ }
525
+
526
+ retryReports(runs: readonly AsyncRun[]): void {
527
+ for (const run of runs) this.reportedAt.delete(run.id);
528
+ }
495
529
 
496
530
  /** The running legs that have NEWLY crossed the stall window since we last reported them. */
497
531
  poll(runs: AsyncRun[], now: number, stallMs: number, waitingForSupervisor: ReadonlySet<string> = new Set()): AsyncRun[] {
@@ -500,6 +534,10 @@ export class PeekWatcher {
500
534
  for (const r of runs) {
501
535
  if (r.status !== "running") continue;
502
536
  live.add(r.id);
537
+ if (r.queued) {
538
+ this.reportedAt.delete(r.id);
539
+ continue;
540
+ }
503
541
  if (waitingForSupervisor.has(r.id)) {
504
542
  this.reportedAt.delete(r.id); // a live ask is not a stall; re-evaluate immediately when it settles
505
543
  continue;
@@ -507,7 +545,7 @@ export class PeekWatcher {
507
545
  if (r.lastAdvanceAt === undefined) continue;
508
546
  const prev = this.reportedAt.get(r.id);
509
547
  if (prev !== undefined && r.lastAdvanceAt > prev) this.reportedAt.delete(r.id); // advanced ⇒ re-arm
510
- if (stallMs > 0 && now - r.lastAdvanceAt >= stallMs && !this.reportedAt.has(r.id)) {
548
+ if (isAsyncRunStalled(r, now, stallMs) && !this.reportedAt.has(r.id)) {
511
549
  this.reportedAt.set(r.id, r.lastAdvanceAt);
512
550
  newlyStuck.push(r);
513
551
  }
@@ -519,6 +557,7 @@ export class PeekWatcher {
519
557
  /** Forget all tracked state (session start / reload hygiene). */
520
558
  reset(): void {
521
559
  this.reportedAt.clear();
560
+ this.modelWoken.clear();
522
561
  }
523
562
  }
524
563
 
@@ -548,8 +587,9 @@ export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string
548
587
  }
549
588
 
550
589
  /** The operator-only periodic status card. It carries the progress digest without model instructions. */
551
- export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string> }): string {
552
- return `Async activity · routine check-in\n\n${buildPeekDigest(runs, opts)}`;
590
+ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string>; reason?: "routine" | "stall" }): string {
591
+ const reason = opts.reason ?? "routine";
592
+ return `Async activity · ${reason === "stall" ? "possible stall" : "routine check-in"}\n\n${buildPeekDigest(runs, opts)}`;
553
593
  }
554
594
 
555
595
  /**
@@ -242,7 +242,7 @@ export async function runChildAgent(
242
242
  const onLine = (line: string) => {
243
243
  if (!line.trim()) return;
244
244
  try {
245
- const toolEvent = applyEvent(state, JSON.parse(line));
245
+ const toolEvent = applyEvent(state, JSON.parse(line), Date.now());
246
246
  // Ordinary stream progress stays coalesced to one snapshot per stdout chunk below.
247
247
  // Tool lifecycle transitions are the exception: several can share one chunk and each
248
248
  // authoritative transition must cross the progress seam exactly once.
@@ -666,7 +666,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
666
666
  // One progress tick, routed exactly as the subscription routes them: the per-call sink
667
667
  // takes the AgentProgress shape (no `turns`), else the construction-time sink.
668
668
  const emitProgress = (snap: ProgressSnapshot): void => {
669
- if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
669
+ if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolOutputAt !== undefined ? { toolOutputAt: snap.toolOutputAt } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
670
670
  else if (deps.onProgress) deps.onProgress(snap);
671
671
  };
672
672
  let contractDef: ContractDef | undefined = requestedDef;
@@ -674,7 +674,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
674
674
  try {
675
675
  unsub = session.subscribe((ev) => {
676
676
  armIdle(); // any event = activity → reset the idle clock
677
- const toolEvent = applyEvent(state, ev);
677
+ const toolEvent = applyEvent(state, ev, Date.now());
678
678
  // Track the open calls so an abandoned one can be closed once the run settles.
679
679
  if (toolEvent) {
680
680
  if (toolEvent.phase === "start") openToolCalls.set(toolEvent.callId, toolEvent.name);
@@ -735,7 +735,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
735
735
  // launch path (extension.ts) only has the latter.
736
736
  const snap = snapshot(state);
737
737
  const activity = `✉ from ${env.from}`;
738
- if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, activity });
738
+ if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.toolOutputAt !== undefined ? { toolOutputAt: snap.toolOutputAt } : {}), activity });
739
739
  else if (deps.onProgress) deps.onProgress({ ...snap, activity });
740
740
  }
741
741
  };
@@ -61,6 +61,8 @@ export interface StreamState {
61
61
  /** Calls whose start was surfaced but whose end has not arrived. This is not a retention
62
62
  * cache: active ids are never evicted merely because completed calls churn. */
63
63
  activeToolCalls?: Set<string>;
64
+ /** Timestamp of the latest textual tool_execution_update, used only as a liveness signal. */
65
+ toolOutputAt?: number;
64
66
  /** Recently completed call ids whose start/end phase was surfaced. Both caches are bounded;
65
67
  * they suppress duplicate wire events without growing for the lifetime of a long run. */
66
68
  seenToolStarts?: Set<string>;
@@ -236,7 +238,7 @@ function appendTranscript(state: StreamState, chunk: string, sep: string): void
236
238
 
237
239
  /** Fold one parsed stream event into the accumulating state (in place), returning a
238
240
  * deduplicated structured lifecycle transition when the runtime supplied a stable call id. */
239
- export function applyEvent(state: StreamState, event: unknown): ToolEvent | undefined {
241
+ export function applyEvent(state: StreamState, event: unknown, observedAt?: number): ToolEvent | undefined {
240
242
  if (!isObject(event)) return undefined;
241
243
 
242
244
  // Track the current tool so the UI can show "running: grep src/…" while a
@@ -271,6 +273,20 @@ export function applyEvent(state: StreamState, event: unknown): ToolEvent | unde
271
273
  return toolEvent;
272
274
  }
273
275
 
276
+ if (event.type === "tool_execution_update") {
277
+ const callId = event.toolCallId;
278
+ const toolName = event.toolName;
279
+ const partialResult = event.partialResult;
280
+ const hasText = isObject(partialResult) && Array.isArray(partialResult.content)
281
+ && partialResult.content.some((part) => isObject(part) && part.type === "text" && typeof part.text === "string" && part.text.length > 0);
282
+ if (typeof callId === "string" && callId.length > 0
283
+ && typeof toolName === "string" && toolName.length > 0
284
+ && state.activeToolCalls?.has(callId) === true && hasText && observedAt !== undefined) {
285
+ state.toolOutputAt = observedAt;
286
+ }
287
+ return undefined;
288
+ }
289
+
274
290
  // Live partial: pi streams the growing message as `message_update` events. Surface
275
291
  // its text (or reasoning) so a long-thinking agent shows progress, not just tokens.
276
292
  // ONLY the assistant's own message counts: pi also emits message_start/message_end for
@@ -341,6 +357,8 @@ export interface ProgressSnapshot {
341
357
  tokens: number;
342
358
  /** The tool currently running (e.g. "grep src/…"), if any. */
343
359
  activity?: string;
360
+ /** Timestamp of the latest textual tool output update, when available. */
361
+ toolOutputAt?: number;
344
362
  /** One authoritative runtime tool lifecycle transition, when this snapshot is event-driven. */
345
363
  toolEvent?: ToolEvent;
346
364
  }
@@ -354,6 +372,7 @@ export function snapshot(state: StreamState, toolEvent?: ToolEvent): ProgressSna
354
372
  tokens: state.usage.input + state.usage.output,
355
373
  };
356
374
  if (state.activity !== undefined) snap.activity = state.activity;
375
+ if (state.toolOutputAt !== undefined) snap.toolOutputAt = state.toolOutputAt;
357
376
  if (toolEvent !== undefined) snap.toolEvent = toolEvent;
358
377
  return snap;
359
378
  }
@@ -0,0 +1,29 @@
1
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
2
+ import { PERSONA_SETTINGS, validatePreferences, type CompanionPreferenceValues } from "../core/settings.ts";
3
+
4
+ export const COMPANION_SETTINGS_CHANNEL = "pi-persona:settings:collect:v1";
5
+ export interface CompanionSettingsSnapshot {
6
+ mind?: Partial<Pick<CompanionPreferenceValues, "mindCapture" | "mindNudge">>;
7
+ flow?: Partial<Pick<CompanionPreferenceValues, "flowEnabled" | "flowPort" | "flowIdleMs">>;
8
+ }
9
+
10
+ /** Synchronous, optional discovery on Pi's public bus. No plugin import, model request or writer. */
11
+ export function collectCompanionSettings(bus: Pick<EventBus, "emit"> | undefined): CompanionSettingsSnapshot {
12
+ const snapshot: CompanionSettingsSnapshot = {};
13
+ if (!bus) return snapshot;
14
+ try {
15
+ bus.emit(COMPANION_SETTINGS_CHANNEL, { add: (id: unknown, values: unknown): void => {
16
+ if (id !== "mind" && id !== "flow") return;
17
+ if (!values || typeof values !== "object" || Array.isArray(values)) return;
18
+ const keys = PERSONA_SETTINGS.filter((setting) => setting.owner === id).map((setting) => setting.id);
19
+ const reportedKeys = Object.keys(values);
20
+ if (reportedKeys.length === 0 || !reportedKeys.every((key) => keys.includes(key as keyof CompanionPreferenceValues))) return;
21
+ try {
22
+ const validated = validatePreferences(values);
23
+ if (id === "mind") snapshot.mind = validated as Partial<Pick<CompanionPreferenceValues, "mindCapture" | "mindNudge">>;
24
+ else snapshot.flow = validated as Partial<Pick<CompanionPreferenceValues, "flowEnabled" | "flowPort" | "flowIdleMs">>;
25
+ } catch { /* A malformed contribution is not an editable settings owner. */ }
26
+ } });
27
+ } catch { /* Keep valid responses if another extension listener throws. */ }
28
+ return snapshot;
29
+ }