@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.
- package/AGENTS.md +22 -3
- package/CHANGELOG.md +30 -0
- package/README.md +13 -2
- package/docs/ARCHITECTURE.md +46 -8
- package/docs/REFERENCE.md +107 -3
- package/package.json +2 -2
- package/src/core/config.ts +1 -1
- package/src/core/settings.ts +251 -0
- package/src/engine/adapter.ts +1 -1
- package/src/engine/async.ts +53 -13
- package/src/engine/child.ts +1 -1
- package/src/engine/inproc.ts +3 -3
- package/src/engine/stream.ts +20 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/persona-model-settings.ts +318 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension.ts +148 -37
- package/src/orchestration/sdk.ts +2 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/delegate-tool.ts +6 -6
- package/src/ui/persona-settings.ts +322 -0
- package/src/persona/state.ts +0 -28
|
@@ -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
|
+
}
|
package/src/engine/adapter.ts
CHANGED
|
@@ -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);
|
package/src/engine/async.ts
CHANGED
|
@@ -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
|
-
//
|
|
126
|
-
//
|
|
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
|
-
|
|
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 (
|
|
464
|
-
else if (
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
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 (
|
|
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
|
|
552
|
-
|
|
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
|
/**
|
package/src/engine/child.ts
CHANGED
|
@@ -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.
|
package/src/engine/inproc.ts
CHANGED
|
@@ -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
|
};
|
package/src/engine/stream.ts
CHANGED
|
@@ -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
|
+
}
|