pi-orchestrator-config 4.2.1 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +52 -61
  2. package/agents/git-expert.md +5 -1
  3. package/agents/github-expert.md +4 -1
  4. package/extensions/acpx-provider/runtime-models.ts +18 -6
  5. package/extensions/cli-provider/agents/claude.ts +3 -2
  6. package/extensions/cli-provider/agents/cursor.ts +3 -5
  7. package/extensions/cli-provider/agents/gemini.ts +3 -2
  8. package/extensions/cli-provider/discover.ts +7 -7
  9. package/extensions/cli-provider/index.ts +5 -0
  10. package/extensions/cli-provider/runtime-models.ts +18 -6
  11. package/extensions/cli-provider/session-reaper.ts +5 -7
  12. package/extensions/cli-provider/sessions.ts +4 -0
  13. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  14. package/extensions/coms/coms-p2p.ts +1719 -423
  15. package/extensions/coms/coms-shared.ts +261 -39
  16. package/extensions/coms/coms-wrapper.ts +100 -62
  17. package/extensions/coms/index.ts +8 -4
  18. package/extensions/coms/mixed-turn.ts +26 -0
  19. package/extensions/coms/probe-socket.ts +42 -0
  20. package/extensions/coms/themeMap.ts +0 -1
  21. package/extensions/image-gen/image-gen.ts +19 -1
  22. package/extensions/orchestrator/async-agents.ts +509 -85
  23. package/extensions/orchestrator/cron.ts +14 -8
  24. package/extensions/orchestrator/dreaming.ts +31 -24
  25. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  26. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  27. package/extensions/orchestrator/enforcement.ts +79 -25
  28. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  29. package/extensions/orchestrator/git-helpers.ts +2 -2
  30. package/extensions/orchestrator/index.ts +23 -10
  31. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  32. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  33. package/extensions/orchestrator/memory-provenance.ts +29 -25
  34. package/extensions/orchestrator/memory-scoring.ts +23 -18
  35. package/extensions/orchestrator/memory-tools.ts +10 -3
  36. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  37. package/extensions/orchestrator/pi-config-review-state.ts +44 -33
  38. package/extensions/orchestrator/project-settings.ts +220 -21
  39. package/extensions/orchestrator/review-porcelain.ts +4 -0
  40. package/extensions/orchestrator/review-ui.ts +11 -5
  41. package/extensions/orchestrator/rule-placeholders.ts +384 -1
  42. package/extensions/orchestrator/rules.ts +80 -132
  43. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  44. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  45. package/extensions/orchestrator/settings-tui.ts +636 -0
  46. package/extensions/orchestrator/state-jsonl.ts +431 -0
  47. package/extensions/orchestrator/subagent-tool.ts +105 -50
  48. package/extensions/orchestrator/sync-limit.ts +23 -0
  49. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  50. package/extensions/orchestrator/utils.ts +8 -5
  51. package/extensions/pidash/pidash.ts +193 -156
  52. package/extensions/pidiff/pidiff.ts +64 -38
  53. package/extensions/pidiff/spawn-lock.ts +62 -0
  54. package/extensions/pitasks/index.ts +430 -0
  55. package/extensions/pitasks/reminders.ts +37 -0
  56. package/extensions/pitasks/task-store.ts +467 -0
  57. package/extensions/pitasks/task-tools.ts +375 -0
  58. package/extensions/pitasks/task-widget.ts +181 -0
  59. package/extensions/pitasks/types.ts +38 -0
  60. package/extensions/providers/acpx-driver.ts +12 -2
  61. package/extensions/providers/built-in-drivers.ts +6 -6
  62. package/extensions/providers/claude-driver.ts +1 -1
  63. package/extensions/providers/cursor-cli-driver.ts +1 -1
  64. package/extensions/providers/gemini-driver.ts +1 -1
  65. package/extensions/providers/index.ts +144 -26
  66. package/extensions/providers/initialized-guard.ts +27 -0
  67. package/extensions/providers/restore-default-model.ts +596 -0
  68. package/extensions/providers/session-shutdown.ts +39 -0
  69. package/extensions/shared/coms-active.ts +23 -0
  70. package/extensions/shared/create-runtime-provider.ts +2 -0
  71. package/extensions/shared/daemon-manager.ts +0 -8
  72. package/extensions/shared/file-logger.ts +132 -25
  73. package/extensions/shared/logger.ts +50 -0
  74. package/extensions/shared/models-dev.ts +421 -0
  75. package/extensions/shared/oneshot.ts +116 -0
  76. package/extensions/shared/resolve-binary.ts +191 -0
  77. package/extensions/shared/types.ts +3 -0
  78. package/package.json +7 -2
  79. package/prompts/coderabbit-rate-limit.md +2 -0
  80. package/rules/00-orchestrator-core.md +3 -0
  81. package/rules/10-agent-routing.md +1 -1
  82. package/rules/20-code-review-loop.md +38 -16
  83. package/rules/30-prompt-templates.md +4 -0
  84. package/rules/35-memory.md +3 -3
  85. package/rules/40-critical-rules.md +3 -0
  86. package/rules/55-coms-protocol.md +84 -18
  87. package/rules/60-task-tracking.md +2 -0
  88. package/settings-keys.json +481 -20
  89. package/templates/coms-feature-manager-prompt.md +4 -6
  90. package/extensions/coms/coms-net-server.ts +0 -1642
  91. package/extensions/coms/coms-net-wrapper.ts +0 -521
  92. package/extensions/coms/coms-net.ts +0 -1589
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Settings TUI helpers — pure functions extracted for testability.
3
+ * No pi-coding-agent or pi-tui dependencies.
4
+ */
5
+
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join as joinPath } from "node:path";
9
+ import stripJsonComments from "strip-json-comments";
10
+ import {
11
+ type SettingsKeyDef,
12
+ getSettingsPath,
13
+ getGlobalSettingsPath,
14
+ } from "./project-settings.js";
15
+ import { createLogger } from "../shared/logger.js";
16
+
17
+ const log = createLogger("settings_tui");
18
+
19
+ /** Sentinel value displayed for empty strings in the TUI. Centralized to prevent divergence. */
20
+ export const EMPTY_VALUE_SENTINEL = "(empty)";
21
+
22
+ const LOG_PREFIX = "[settings-tui]";
23
+ const loggedErrors = new Set<string>();
24
+ function logWarn(msg: string): void {
25
+ // Deduplicate: only log each unique message once per session to avoid spam
26
+ if (loggedErrors.has(msg)) return;
27
+ loggedErrors.add(msg);
28
+ try {
29
+ const logDir = joinPath(homedir(), ".pi", "logs");
30
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true, mode: 0o700 });
31
+ const logPath = joinPath(logDir, "settings-tui.log");
32
+ appendFileSync(logPath, `${new Date().toISOString()} ${LOG_PREFIX} ${msg}\n`);
33
+ } catch {}
34
+ }
35
+
36
+ // ── Category grouping ───────────────────────────────────────────────
37
+
38
+ export interface CategoryDef {
39
+ label: string;
40
+ keys: string[];
41
+ }
42
+
43
+ import settingsSchema from "../../settings-keys.json" with { type: "json" };
44
+
45
+ // Build CATEGORIES dynamically from schema `group` field.
46
+ // Preserves insertion order from settings-keys.json — groups appear
47
+ // in the order their first key is declared.
48
+ export const CATEGORIES: CategoryDef[] = (() => {
49
+ const groupMap = new Map<string, string[]>();
50
+ for (const [key, def] of Object.entries(settingsSchema)) {
51
+ const group = (def as any).group || "Other";
52
+ if (!groupMap.has(group)) groupMap.set(group, []);
53
+ groupMap.get(group)!.push(key);
54
+ }
55
+ return [...groupMap.entries()].map(([label, keys]) => ({ label, keys }));
56
+ })();
57
+
58
+ // ── Source detection ─────────────────────────────────────────────────
59
+
60
+ export type SettingSource = "P" | "G" | "E" | "D";
61
+
62
+ export function detectSource(key: string, def: SettingsKeyDef, cwd: string): SettingSource {
63
+ // Check project settings file
64
+ const projectPath = getSettingsPath(cwd);
65
+ if (existsSync(projectPath)) {
66
+ try {
67
+ const raw = JSON.parse(stripJsonComments(readFileSync(projectPath, "utf-8")));
68
+ if (typeof raw === "object" && raw !== null && key in raw) return "P";
69
+ } catch (e: any) { logWarn(`parse error in ${projectPath}: ${e?.message?.slice(0, 100)}`); }
70
+ }
71
+
72
+ // Check global settings file (honors setGlobalSettingsPath for tests)
73
+ const globalPath = getGlobalSettingsPath();
74
+ if (existsSync(globalPath)) {
75
+ try {
76
+ const raw = JSON.parse(stripJsonComments(readFileSync(globalPath, "utf-8")));
77
+ if (typeof raw === "object" && raw !== null && key in raw) return "G";
78
+ } catch (e: any) { logWarn(`parse error in ${globalPath}: ${e?.message?.slice(0, 100)}`); }
79
+ }
80
+
81
+ // Check env var
82
+ if (def.env && process.env[def.env] !== undefined && process.env[def.env] !== "") return "E";
83
+
84
+ return "D";
85
+ }
86
+
87
+ // ── Value formatting ────────────────────────────────────────────────
88
+
89
+ export function formatValue(key: string, value: unknown, def: SettingsKeyDef): string {
90
+ if (value === undefined || value === null) return String(def.default);
91
+
92
+ // Mask secret-like keys
93
+ const SECRET_PATTERNS = /token|secret|password|auth/i;
94
+ const looksSecret = SECRET_PATTERNS.test(key) || (typeof def.env === "string" && SECRET_PATTERNS.test(def.env));
95
+ if (def.type === "string" && looksSecret && typeof value === "string" && value !== "") {
96
+ return value.length > 4 ? "••••" + value.slice(-4) : "••••••••";
97
+ }
98
+
99
+ switch (def.type) {
100
+ case "bool":
101
+ case "bool_enable":
102
+ return value ? "true" : "false";
103
+ case "bool_or_string":
104
+ if (typeof value === "boolean") return value ? "true" : "false";
105
+ return String(value);
106
+ case "int":
107
+ case "port":
108
+ case "number":
109
+ return String(value);
110
+ case "string":
111
+ return value === "" ? EMPTY_VALUE_SENTINEL : String(value);
112
+ case "agent_list":
113
+ if (Array.isArray(value)) return value.length > 0 ? value.join(", ") : EMPTY_VALUE_SENTINEL;
114
+ return String(value);
115
+ case "agent_overrides":
116
+ if (typeof value === "object" && value !== null) {
117
+ const entries = Object.entries(value);
118
+ return entries.length > 0 ? `${entries.length} override(s)` : "(none)";
119
+ }
120
+ return "(none)";
121
+ default:
122
+ return String(value);
123
+ }
124
+ }
125
+
126
+ // ── Parse raw value for saving ──────────────────────────────────────
127
+
128
+ export function parseRawValue(key: string, rawValue: string, def: SettingsKeyDef): unknown {
129
+ switch (def.type) {
130
+ case "bool":
131
+ case "bool_enable":
132
+ return rawValue === "true";
133
+
134
+ case "bool_or_string":
135
+ if (rawValue === "true") return true;
136
+ if (rawValue === "false") return false;
137
+ return rawValue;
138
+
139
+ case "int":
140
+ case "port": {
141
+ if (rawValue.trim() === "") return undefined;
142
+ const n = Number(rawValue);
143
+ return Number.isFinite(n) && Number.isInteger(n) ? n : def.default;
144
+ }
145
+
146
+ case "number": {
147
+ if (rawValue.trim() === "") return undefined;
148
+ const n = Number(rawValue);
149
+ return Number.isFinite(n) ? n : def.default;
150
+ }
151
+
152
+ case "string":
153
+ return rawValue === EMPTY_VALUE_SENTINEL ? "" : rawValue;
154
+
155
+ case "agent_list":
156
+ if (!rawValue || rawValue === EMPTY_VALUE_SENTINEL) return [];
157
+ return rawValue.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
158
+
159
+ default:
160
+ return rawValue;
161
+ }
162
+ }
163
+
164
+ // ── Scope-aware file read/write ─────────────────────────────────────
165
+
166
+ export function getFilePathForScope(scope: "project" | "global", cwd: string): string {
167
+ if (scope === "project") {
168
+ return getSettingsPath(cwd);
169
+ }
170
+ return getGlobalSettingsPath();
171
+ }
172
+
173
+ export function readSettingsFile(filePath: string): Record<string, unknown> | null {
174
+ if (!existsSync(filePath)) return {};
175
+ try {
176
+ const raw = JSON.parse(stripJsonComments(readFileSync(filePath, "utf-8")));
177
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) return raw;
178
+ } catch (e: any) { logWarn(`parse error in ${filePath}: ${e?.message?.slice(0, 100)}`); }
179
+ return null;
180
+ }
181
+
182
+ export function writeSettingsFile(filePath: string, data: Record<string, unknown>): void {
183
+ // Write directly to target file. Per issue spec: "comments are lost on write — acceptable
184
+ // since the example.jsonc is the reference."
185
+ const dir = dirname(filePath);
186
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
187
+ const content = JSON.stringify(data, null, 2) + "\n";
188
+ const tmpPath = `${filePath}.${process.pid}.${Date.now().toString(36)}`;
189
+ try {
190
+ writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600, flag: "wx" });
191
+ renameSync(tmpPath, filePath);
192
+ } catch (e: any) {
193
+ // Clean up temp file on failure
194
+ try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch {}
195
+ throw e;
196
+ }
197
+ }
198
+
199
+ // ── Secret submit guard (extracted for testability) ────────────────
200
+
201
+ /** Check if a secret input should be treated as "no change" (cancel). */
202
+ export function isSecretNoChange(val: string | undefined, scopeHasValue: boolean): boolean {
203
+ if (scopeHasValue) return false; // value exists in scope — user is editing, not creating
204
+ // Treat undefined, empty, whitespace-only, and the literal "(empty)" as no-change.
205
+ // "(empty)" is the display placeholder that parseRawValue converts to "".
206
+ return val === undefined || val?.trim() === "" || val?.trim() === EMPTY_VALUE_SENTINEL;
207
+ }
208
+
209
+ /** Resolve the prefill value for a secret key from the current scope's file only. */
210
+ export function resolveSecretPrefill(
211
+ key: string,
212
+ editScope: "project" | "global",
213
+ cwd: string,
214
+ ): { scopeValue: string | null; prefill: string; hint: string } {
215
+ const scopeFile = getFilePathForScope(editScope, cwd);
216
+ const scopeData = readSettingsFile(scopeFile);
217
+ const scopeValue = scopeData && typeof scopeData[key] === "string" ? scopeData[key] as string : null;
218
+ // Never prefill with the raw secret — always start empty to avoid exposing it on screen.
219
+ // The user must type a new value; empty submit is handled by isSecretNoChange.
220
+ const prefill = "";
221
+ const hint = scopeValue !== null
222
+ ? "Value set in this scope — enter new value or empty to clear"
223
+ : "Enter new value (not set in this scope)";
224
+ return { scopeValue, prefill, hint };
225
+ }
226
+
227
+ // ── Provider-filtered model items ───────────────────────────────
228
+
229
+ export type ModelSelectItem = {
230
+ value: string;
231
+ label: string;
232
+ description?: string;
233
+ /** Registry input modalities when present (vision ≠ image generation). */
234
+ input?: string[];
235
+ /** Registry output modalities when present (`image` = image-generation capable). */
236
+ output?: string[];
237
+ };
238
+
239
+ /** True when a model id looks like a Google/Gemini image-generation model. */
240
+ export function isImageGenModelId(id: string): boolean {
241
+ const lower = id.toLowerCase();
242
+ const result =
243
+ lower.includes("imagen") ||
244
+ lower.includes("image-generation") ||
245
+ /(?:^|[-_./])image(?:[-_./]|$)/.test(lower);
246
+ log.debug("isImageGenModelId", { id, result });
247
+ return result;
248
+ }
249
+
250
+ /** Image-gen picker: prefer `output` includes `image`; else id pattern. Not `input` (vision). */
251
+ export function isImageCapablePickerItem(item: ModelSelectItem): boolean {
252
+ if (Array.isArray(item.output) && item.output.includes("image")) {
253
+ log.debug("isImageCapablePickerItem", { id: item.value, via: "output" });
254
+ return true;
255
+ }
256
+ return isImageGenModelId(item.value);
257
+ }
258
+
259
+ export type ModelPickerPairedSettings = {
260
+ agent_provider?: string;
261
+ internal_operations_provider?: string;
262
+ };
263
+
264
+ /** Filter model SelectItems by provider. Returns all models if provider is empty/undefined. */
265
+ export function filterModelsByProvider(
266
+ allModels: ModelSelectItem[],
267
+ provider?: string,
268
+ ): ModelSelectItem[] {
269
+ if (!provider) return allModels;
270
+ return allModels.filter((m) => m.description === provider);
271
+ }
272
+
273
+ /** Registry provider hardwired for image_model picker (no image_provider setting). */
274
+ export const IMAGE_MODEL_PROVIDER = "google";
275
+
276
+ /**
277
+ * Registry provider used to filter the model picker for a settings key.
278
+ * image_model is hardwired to IMAGE_MODEL_PROVIDER (no image_provider setting).
279
+ */
280
+ export function resolveModelPickerProvider(
281
+ key: string,
282
+ paired: ModelPickerPairedSettings = {},
283
+ ): string | undefined {
284
+ if (key === "image_model") {
285
+ log.debug("resolveModelPickerProvider", { key, provider: IMAGE_MODEL_PROVIDER });
286
+ return IMAGE_MODEL_PROVIDER;
287
+ }
288
+ if (key === "agent_model") {
289
+ const p = paired.agent_provider?.trim();
290
+ log.debug("resolveModelPickerProvider", { key, provider: p || undefined });
291
+ return p || undefined;
292
+ }
293
+ if (key === "internal_operations_model") {
294
+ const p = paired.internal_operations_provider?.trim();
295
+ log.debug("resolveModelPickerProvider", { key, provider: p || undefined });
296
+ return p || undefined;
297
+ }
298
+ log.debug("resolveModelPickerProvider", { key, provider: undefined });
299
+ return undefined;
300
+ }
301
+
302
+ /**
303
+ * Models shown in the settings TUI picker for a key.
304
+ * image_model: IMAGE_MODEL_PROVIDER + image-capable only — never falls back to all providers (empty list if none).
305
+ * agent_model / internal_operations_model: fall back to all models when filter is empty.
306
+ */
307
+ export function resolveModelPickerItems(
308
+ key: string,
309
+ allModels: ModelSelectItem[],
310
+ provider?: string,
311
+ ): ModelSelectItem[] {
312
+ // image_model is always google-filtered — ignore caller provider (even truthy non-google)
313
+ const effectiveProvider = key === "image_model" ? IMAGE_MODEL_PROVIDER : provider;
314
+ const filtered = filterModelsByProvider(allModels, effectiveProvider);
315
+ if (key === "image_model") {
316
+ const imageCapable = filtered.filter(isImageCapablePickerItem);
317
+ log.debug("resolveModelPickerItems image_model", {
318
+ googleCount: filtered.length,
319
+ imageCapableCount: imageCapable.length,
320
+ });
321
+ return imageCapable;
322
+ }
323
+ const result = filtered.length > 0 ? filtered : allModels;
324
+ log.debug("resolveModelPickerItems", { key, provider: effectiveProvider, count: result.length });
325
+ return result;
326
+ }
327
+
328
+ /**
329
+ * Decide picker vs free-text for a model settings key.
330
+ * Call at submenu-open time (not build time) so paired provider changes are fresh.
331
+ * image_model with an empty google list → free-text input (never show all providers).
332
+ */
333
+ export type ModelKeySubmenu =
334
+ | { mode: "picker"; models: ModelSelectItem[]; provider?: string }
335
+ | { mode: "input"; provider?: string };
336
+
337
+ export function resolveModelKeySubmenu(
338
+ key: string,
339
+ allModels: ModelSelectItem[],
340
+ paired: ModelPickerPairedSettings = {},
341
+ ): ModelKeySubmenu {
342
+ const provider = resolveModelPickerProvider(key, paired);
343
+ const models = resolveModelPickerItems(key, allModels, provider);
344
+ if (key === "image_model" && models.length === 0) {
345
+ log.debug("resolveModelKeySubmenu", { key, mode: "input", provider });
346
+ return { mode: "input", provider };
347
+ }
348
+ log.debug("resolveModelKeySubmenu", { key, mode: "picker", count: models.length, provider });
349
+ return { mode: "picker", models, provider };
350
+ }
351
+
352
+ // ── Source glyph (colored) ──────────────────────────────────────
353
+
354
+ export function sourceGlyph(source: string, theme: { fg: (color: string, text: string) => string }): string {
355
+ switch (source) {
356
+ case "P": return theme.fg("success", "P");
357
+ case "G": return theme.fg("accent", "G");
358
+ case "E": return theme.fg("warning", "E");
359
+ case "D": return theme.fg("dim", "D");
360
+ default: return theme.fg("dim", "?");
361
+ }
362
+ }
363
+
364
+ // ── Registration helper (testable without pi-coding-agent) ──────────
365
+
366
+ /**
367
+ * Core registration logic extracted for testability.
368
+ * Accepts a minimal pi-like object with registerCommand.
369
+ */
370
+ export function registerSettingsTuiCommand(
371
+ pi: { registerCommand: (name: string, opts: { description: string; handler: (...args: any[]) => any }) => void },
372
+ handler: (args: string, ctx: any) => Promise<void>,
373
+ ): void {
374
+ if (process.env.PI_SUBAGENT_CHILD === "1") return;
375
+
376
+ pi.registerCommand("pi-config-settings", {
377
+ description: "Interactive settings editor for pi-config",
378
+ handler,
379
+ });
380
+ }