@tt-a1i/openpi 0.1.0

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 (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,971 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, readFileSync, watch, type Stats } from "node:fs";
4
+ import {
5
+ link,
6
+ mkdir,
7
+ readFile,
8
+ readdir,
9
+ rename,
10
+ stat,
11
+ unlink,
12
+ writeFile,
13
+ } from "node:fs/promises";
14
+ import { basename, join } from "node:path";
15
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import {
17
+ SUBAGENT_ROLE_NAMES,
18
+ type SubagentRoleModel,
19
+ type SubagentRoleModels,
20
+ } from "./subagent-roles.ts";
21
+
22
+ export const REASONING_LEVELS = [
23
+ "off",
24
+ "minimal",
25
+ "low",
26
+ "medium",
27
+ "high",
28
+ "xhigh",
29
+ "max",
30
+ ] as const;
31
+
32
+ export type ReasoningLevel = (typeof REASONING_LEVELS)[number];
33
+
34
+ export const FOOTER_ITEMS = [
35
+ "cwd",
36
+ "model",
37
+ "thinking",
38
+ "context",
39
+ "cache",
40
+ "cost",
41
+ "throughput",
42
+ "git",
43
+ "pr",
44
+ ] as const;
45
+
46
+ export type FooterItem = (typeof FOOTER_ITEMS)[number];
47
+
48
+ export const FOOTER_LAYOUT_ITEMS = [...FOOTER_ITEMS, "flex"] as const;
49
+ export type FooterLayoutItem = (typeof FOOTER_LAYOUT_ITEMS)[number];
50
+
51
+ export const FOOTER_STYLES = ["plain", "powerline", "powerline-mono"] as const;
52
+ export type FooterStyle = (typeof FOOTER_STYLES)[number];
53
+
54
+ export const FOOTER_PRESETS = [
55
+ "compact",
56
+ "powerline",
57
+ "powerline-mono",
58
+ ] as const;
59
+ export type FooterPreset = (typeof FOOTER_PRESETS)[number];
60
+
61
+ export type FooterLines = readonly (readonly FooterLayoutItem[])[];
62
+
63
+ export const DETAIL_DISPLAYS = ["full", "compact"] as const;
64
+ export type DetailDisplay = (typeof DETAIL_DISPLAYS)[number];
65
+
66
+ /** Canonical default layout: one-line Powerline dashboard with flex alignment. */
67
+ export const DEFAULT_FOOTER_LINES: FooterLines = [
68
+ [
69
+ "cwd",
70
+ "model",
71
+ "thinking",
72
+ "context",
73
+ "cache",
74
+ "cost",
75
+ "throughput",
76
+ "flex",
77
+ "git",
78
+ "pr",
79
+ ],
80
+ ];
81
+
82
+ export const DEFAULT_FOOTER_STYLE: FooterStyle = "powerline";
83
+
84
+ export const DEFAULT_FOOTER_ITEMS: readonly FooterItem[] =
85
+ flattenFooterItems(DEFAULT_FOOTER_LINES);
86
+
87
+ export interface FooterPresetDefinition {
88
+ readonly style: FooterStyle;
89
+ readonly lines: FooterLines;
90
+ }
91
+
92
+ export const FOOTER_PRESET_DEFINITIONS: Record<
93
+ FooterPreset,
94
+ FooterPresetDefinition
95
+ > = {
96
+ compact: {
97
+ style: "plain",
98
+ lines: [["cwd", "model", "thinking", "context", "flex", "git", "pr"]],
99
+ },
100
+ powerline: {
101
+ style: "powerline",
102
+ lines: DEFAULT_FOOTER_LINES,
103
+ },
104
+ "powerline-mono": {
105
+ style: "powerline-mono",
106
+ lines: DEFAULT_FOOTER_LINES,
107
+ },
108
+ };
109
+
110
+ export interface SuggestionModelConfig {
111
+ readonly provider: string;
112
+ readonly model: string;
113
+ readonly reasoning: ReasoningLevel;
114
+ }
115
+
116
+ export const DEFAULT_WORKFLOW_CONCURRENCY = 8;
117
+ export const DEFAULT_WORKFLOW_MAX_AGENT_CALLS = 128;
118
+ export const MAX_WORKFLOW_CONCURRENCY = 64;
119
+ export const MAX_WORKFLOW_AGENT_CALLS = 1_024;
120
+ /** Bound on the single post-edit command string. */
121
+ export const POST_EDIT_COMMAND_MAX_CHARS = 500;
122
+
123
+ export const SETUP_CONFIG_CHANGED_CHANNEL = "my-pi-setup:config-changed";
124
+
125
+ export interface MyPiSetupConfig {
126
+ readonly suggestions: {
127
+ readonly enabled: boolean;
128
+ readonly model?: SuggestionModelConfig;
129
+ };
130
+ readonly workflows: {
131
+ readonly concurrency: number;
132
+ readonly maxAgentCalls: number;
133
+ };
134
+ readonly ui: {
135
+ readonly showHeader: boolean;
136
+ readonly customFooter: boolean;
137
+ readonly footerStyle: FooterStyle;
138
+ /** Canonical footer layout. `footerItems` is always derived from this. */
139
+ readonly footerLines: FooterLines;
140
+ readonly footerItems: readonly FooterItem[];
141
+ readonly subagentResultDisplay: DetailDisplay;
142
+ readonly bashToolDisplay: DetailDisplay;
143
+ readonly fileMutationDisplay: DetailDisplay;
144
+ };
145
+ /**
146
+ * One optional command run after a turn that touched files. Deliberately a
147
+ * single command, not an event-hook engine: the trust surface stays one
148
+ * user-typed string, and it is off (empty) by default.
149
+ */
150
+ readonly postEdit: {
151
+ readonly command: string;
152
+ };
153
+ readonly subagents: {
154
+ /** Per-built-in-role assignments; missing roles inherit the parent model. */
155
+ readonly roleModels: SubagentRoleModels;
156
+ };
157
+ }
158
+
159
+ export const DEFAULT_SETUP_CONFIG: MyPiSetupConfig = {
160
+ suggestions: { enabled: false },
161
+ workflows: {
162
+ concurrency: DEFAULT_WORKFLOW_CONCURRENCY,
163
+ maxAgentCalls: DEFAULT_WORKFLOW_MAX_AGENT_CALLS,
164
+ },
165
+ ui: {
166
+ showHeader: false,
167
+ customFooter: true,
168
+ footerStyle: DEFAULT_FOOTER_STYLE,
169
+ footerLines: DEFAULT_FOOTER_LINES,
170
+ footerItems: DEFAULT_FOOTER_ITEMS,
171
+ subagentResultDisplay: "full",
172
+ bashToolDisplay: "compact",
173
+ fileMutationDisplay: "compact",
174
+ },
175
+ postEdit: { command: "" },
176
+ subagents: { roleModels: {} },
177
+ };
178
+
179
+ export const SETUP_CONFIG_PATH = join(getAgentDir(), "my-pi-setup.json");
180
+ const SETUP_CONFIG_LOCK_PATH = `${SETUP_CONFIG_PATH}.lock`;
181
+ const SETUP_CONFIG_LOCK_TIMEOUT_MS = 5_000;
182
+ const SETUP_CONFIG_LOCK_VERSION = 1;
183
+ const ESTIMATED_PROCESS_STARTED_AT = Math.max(
184
+ 1,
185
+ Math.round(Date.now() - process.uptime() * 1_000),
186
+ );
187
+
188
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
189
+ typeof value === "object" && value !== null && !Array.isArray(value);
190
+
191
+ const isReasoningLevel = (value: unknown): value is ReasoningLevel =>
192
+ typeof value === "string" &&
193
+ REASONING_LEVELS.includes(value as ReasoningLevel);
194
+
195
+ const isFooterItem = (value: unknown): value is FooterItem =>
196
+ typeof value === "string" && FOOTER_ITEMS.includes(value as FooterItem);
197
+
198
+ const isFooterLayoutItem = (value: unknown): value is FooterLayoutItem =>
199
+ typeof value === "string" &&
200
+ FOOTER_LAYOUT_ITEMS.includes(value as FooterLayoutItem);
201
+
202
+ const isFooterStyle = (value: unknown): value is FooterStyle =>
203
+ typeof value === "string" && FOOTER_STYLES.includes(value as FooterStyle);
204
+
205
+ const isFooterPreset = (value: unknown): value is FooterPreset =>
206
+ typeof value === "string" && FOOTER_PRESETS.includes(value as FooterPreset);
207
+
208
+ export function flattenFooterItems(lines: FooterLines): readonly FooterItem[] {
209
+ const items: FooterItem[] = [];
210
+ const seen = new Set<FooterItem>();
211
+ for (const line of lines) {
212
+ for (const item of line) {
213
+ if (item === "flex" || seen.has(item)) continue;
214
+ seen.add(item);
215
+ items.push(item);
216
+ }
217
+ }
218
+ return items;
219
+ }
220
+
221
+ /**
222
+ * Normalize a candidate footer layout:
223
+ * unknown items removed, at most one flex per line, first metric wins across
224
+ * lines, empty lines dropped. Falls back to the default when nothing remains.
225
+ */
226
+ export function normalizeFooterLines(value: unknown): FooterLines {
227
+ if (!Array.isArray(value)) return DEFAULT_FOOTER_LINES;
228
+
229
+ const seen = new Set<FooterItem>();
230
+ const lines: FooterLayoutItem[][] = [];
231
+
232
+ for (const rawLine of value) {
233
+ if (!Array.isArray(rawLine)) continue;
234
+ const line: FooterLayoutItem[] = [];
235
+ let hasFlex = false;
236
+ for (const raw of rawLine) {
237
+ if (!isFooterLayoutItem(raw)) continue;
238
+ if (raw === "flex") {
239
+ if (hasFlex) continue;
240
+ hasFlex = true;
241
+ line.push("flex");
242
+ continue;
243
+ }
244
+ if (seen.has(raw)) continue;
245
+ seen.add(raw);
246
+ line.push(raw);
247
+ }
248
+ if (line.length > 0 && !(line.length === 1 && line[0] === "flex")) {
249
+ lines.push(line);
250
+ }
251
+ }
252
+
253
+ return lines.length > 0 ? lines : DEFAULT_FOOTER_LINES;
254
+ }
255
+
256
+ /** Map a flat item list onto the default one-line skeleton (legacy compat). */
257
+ export function footerLinesFromItems(
258
+ items: readonly FooterItem[],
259
+ ): FooterLines {
260
+ const selected = new Set(items);
261
+ return normalizeFooterLines(
262
+ DEFAULT_FOOTER_LINES.map((line) =>
263
+ line.filter((item) => item === "flex" || selected.has(item)),
264
+ ),
265
+ );
266
+ }
267
+
268
+ export function resolveFooterPreset(
269
+ preset: FooterPreset,
270
+ ): FooterPresetDefinition {
271
+ return FOOTER_PRESET_DEFINITIONS[preset];
272
+ }
273
+
274
+ export function formatFooterLines(lines: FooterLines) {
275
+ return lines
276
+ .map((line) =>
277
+ line
278
+ .map((item) => (item === "flex" ? "|flex|" : item))
279
+ .join(" ")
280
+ .replace(/ \|flex\| /g, " |flex| "),
281
+ )
282
+ .join(" / ");
283
+ }
284
+
285
+ export interface FooterConfigUpdates {
286
+ readonly preset?: FooterPreset;
287
+ readonly style?: FooterStyle;
288
+ readonly lines?: FooterLines;
289
+ readonly items?: readonly FooterItem[];
290
+ }
291
+
292
+ /**
293
+ * Apply footer updates: current → preset → style/lines overrides.
294
+ * `items` is the legacy flat override (mapped onto the default skeleton).
295
+ * Providing both `items` and `lines` is an error.
296
+ */
297
+ export function applyFooterConfig(
298
+ current: Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines">,
299
+ updates: FooterConfigUpdates,
300
+ ) {
301
+ if (updates.items !== undefined && updates.lines !== undefined) {
302
+ throw new Error(
303
+ "ui_footer_items and ui_footer_lines cannot be provided together; use ui_footer_lines for multi-line layouts, or ui_footer_items for the legacy flat selection.",
304
+ );
305
+ }
306
+
307
+ let style = current.footerStyle;
308
+ let lines = current.footerLines;
309
+
310
+ if (updates.preset !== undefined) {
311
+ const resolved = resolveFooterPreset(updates.preset);
312
+ style = resolved.style;
313
+ lines = resolved.lines;
314
+ }
315
+ if (updates.style !== undefined) style = updates.style;
316
+ if (updates.lines !== undefined) lines = normalizeFooterLines(updates.lines);
317
+ if (updates.items !== undefined) {
318
+ const items = [
319
+ ...new Set(updates.items.filter(isFooterItem)),
320
+ ] as FooterItem[];
321
+ lines = footerLinesFromItems(
322
+ items.length > 0 ? items : DEFAULT_FOOTER_ITEMS,
323
+ );
324
+ }
325
+
326
+ const normalized = normalizeFooterLines(lines);
327
+ return {
328
+ footerStyle: style,
329
+ footerLines: normalized,
330
+ footerItems: flattenFooterItems(normalized),
331
+ };
332
+ }
333
+
334
+ function parseFooterItems(value: unknown): readonly FooterItem[] {
335
+ if (!Array.isArray(value)) return DEFAULT_FOOTER_ITEMS;
336
+ const items = [...new Set(value.filter(isFooterItem))];
337
+ return items.length > 0 ? items : DEFAULT_FOOTER_ITEMS;
338
+ }
339
+
340
+ function parseUiFooter(
341
+ ui: Record<string, unknown>,
342
+ ): Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines" | "footerItems"> {
343
+ const style = isFooterStyle(ui.footerStyle)
344
+ ? ui.footerStyle
345
+ : DEFAULT_FOOTER_STYLE;
346
+
347
+ if (ui.footerLines !== undefined) {
348
+ const lines = normalizeFooterLines(ui.footerLines);
349
+ return {
350
+ footerStyle: style,
351
+ footerLines: lines,
352
+ footerItems: flattenFooterItems(lines),
353
+ };
354
+ }
355
+
356
+ // Legacy: only footerItems → filter the default one-line skeleton.
357
+ if (ui.footerItems !== undefined) {
358
+ const items = parseFooterItems(ui.footerItems);
359
+ const lines = footerLinesFromItems(items);
360
+ return {
361
+ footerStyle: style,
362
+ footerLines: lines,
363
+ footerItems: flattenFooterItems(lines),
364
+ };
365
+ }
366
+
367
+ return {
368
+ footerStyle: style,
369
+ footerLines: DEFAULT_FOOTER_LINES,
370
+ footerItems: DEFAULT_FOOTER_ITEMS,
371
+ };
372
+ }
373
+
374
+ function parseSubagentRoleModels(value: unknown): SubagentRoleModels {
375
+ if (!isRecord(value)) return {};
376
+
377
+ const roleModels: Partial<Record<string, SubagentRoleModel>> = {};
378
+ for (const role of SUBAGENT_ROLE_NAMES) {
379
+ const candidate = value[role];
380
+ if (!isRecord(candidate)) continue;
381
+ const provider = readString(candidate.provider);
382
+ const model = readString(candidate.model);
383
+ if (provider && model) roleModels[role] = { provider, model };
384
+ }
385
+ return roleModels;
386
+ }
387
+
388
+ function readString(value: unknown) {
389
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
390
+ }
391
+
392
+ function boundedInteger(value: unknown, fallback: number, maximum: number) {
393
+ return typeof value === "number" &&
394
+ Number.isInteger(value) &&
395
+ value >= 1 &&
396
+ value <= maximum
397
+ ? value
398
+ : fallback;
399
+ }
400
+
401
+ export function parseSetupConfig(value: unknown): MyPiSetupConfig {
402
+ if (!isRecord(value)) return DEFAULT_SETUP_CONFIG;
403
+
404
+ // `summaries` is the pre-suggestion config key. Read it once as a migration
405
+ // source; every subsequent save writes only the canonical `suggestions` key.
406
+ const suggestions = isRecord(value.suggestions)
407
+ ? value.suggestions
408
+ : isRecord(value.summaries)
409
+ ? value.summaries
410
+ : {};
411
+ const requestedEnabled =
412
+ typeof suggestions.enabled === "boolean" ? suggestions.enabled : false;
413
+ const rawModel = isRecord(suggestions.model) ? suggestions.model : undefined;
414
+ const model =
415
+ rawModel &&
416
+ typeof rawModel.provider === "string" &&
417
+ rawModel.provider.trim() &&
418
+ typeof rawModel.model === "string" &&
419
+ rawModel.model.trim() &&
420
+ isReasoningLevel(rawModel.reasoning)
421
+ ? {
422
+ provider: rawModel.provider.trim(),
423
+ model: rawModel.model.trim(),
424
+ reasoning: rawModel.reasoning,
425
+ }
426
+ : undefined;
427
+
428
+ const workflows = isRecord(value.workflows) ? value.workflows : {};
429
+ const ui = isRecord(value.ui) ? value.ui : {};
430
+ const subagents = isRecord(value.subagents) ? value.subagents : {};
431
+ const footer = parseUiFooter(ui);
432
+ return {
433
+ suggestions: {
434
+ enabled: requestedEnabled && Boolean(model),
435
+ ...(model ? { model } : {}),
436
+ },
437
+ workflows: {
438
+ concurrency: boundedInteger(
439
+ workflows.concurrency,
440
+ DEFAULT_WORKFLOW_CONCURRENCY,
441
+ MAX_WORKFLOW_CONCURRENCY,
442
+ ),
443
+ maxAgentCalls: boundedInteger(
444
+ workflows.maxAgentCalls,
445
+ DEFAULT_WORKFLOW_MAX_AGENT_CALLS,
446
+ MAX_WORKFLOW_AGENT_CALLS,
447
+ ),
448
+ },
449
+ ui: {
450
+ showHeader: typeof ui.showHeader === "boolean" ? ui.showHeader : false,
451
+ customFooter:
452
+ typeof ui.customFooter === "boolean" ? ui.customFooter : true,
453
+ ...footer,
454
+ subagentResultDisplay: DETAIL_DISPLAYS.includes(
455
+ ui.subagentResultDisplay as DetailDisplay,
456
+ )
457
+ ? (ui.subagentResultDisplay as DetailDisplay)
458
+ : "full",
459
+ bashToolDisplay: DETAIL_DISPLAYS.includes(
460
+ ui.bashToolDisplay as DetailDisplay,
461
+ )
462
+ ? (ui.bashToolDisplay as DetailDisplay)
463
+ : "compact",
464
+ fileMutationDisplay: DETAIL_DISPLAYS.includes(
465
+ ui.fileMutationDisplay as DetailDisplay,
466
+ )
467
+ ? (ui.fileMutationDisplay as DetailDisplay)
468
+ : "compact",
469
+ },
470
+ postEdit: { command: parsePostEditCommand(value.postEdit) },
471
+ subagents: { roleModels: parseSubagentRoleModels(subagents.roleModels) },
472
+ };
473
+ }
474
+
475
+ /** An empty or invalid value disables the post-edit command (the safe default). */
476
+ function parsePostEditCommand(value: unknown) {
477
+ if (!isRecord(value)) return "";
478
+ return typeof value.command === "string"
479
+ ? value.command.trim().slice(0, POST_EDIT_COMMAND_MAX_CHARS)
480
+ : "";
481
+ }
482
+
483
+ export function hasSavedSetupConfig() {
484
+ return existsSync(SETUP_CONFIG_PATH);
485
+ }
486
+
487
+ export function loadSetupConfig() {
488
+ try {
489
+ return parseSetupConfig(
490
+ JSON.parse(readFileSync(SETUP_CONFIG_PATH, "utf8")),
491
+ );
492
+ } catch {
493
+ return DEFAULT_SETUP_CONFIG;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Refuse to overwrite a file we could not read. A save always starts from
499
+ * `loadSetupConfig()`, which degrades an unreadable document to defaults, so
500
+ * writing anyway would silently replace every saved preference. Rendering
501
+ * paths keep degrading; only the writer fails closed.
502
+ */
503
+ function readDocumentForWrite() {
504
+ if (!existsSync(SETUP_CONFIG_PATH)) return undefined;
505
+ try {
506
+ return JSON.parse(readFileSync(SETUP_CONFIG_PATH, "utf8")) as unknown;
507
+ } catch (error) {
508
+ throw new Error(
509
+ `Refusing to overwrite unreadable config at ${SETUP_CONFIG_PATH} (${error instanceof Error ? error.message : String(error)}). Fix the file, or delete it to start from defaults, then retry.`,
510
+ );
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Known fields whose on-disk value was normalized or migrated. Absent fields
516
+ * are not reported: only a value the user wrote and will silently lose.
517
+ */
518
+ function replacedFields(
519
+ raw: unknown,
520
+ normalized: unknown,
521
+ path = "",
522
+ ): string[] {
523
+ if (!isRecord(raw) || !isRecord(normalized)) {
524
+ return JSON.stringify(raw) === JSON.stringify(normalized) ? [] : [path];
525
+ }
526
+ const paths: string[] = [];
527
+ if (
528
+ path === "" &&
529
+ "summaries" in raw &&
530
+ !("suggestions" in raw) &&
531
+ "suggestions" in normalized
532
+ ) {
533
+ paths.push("summaries → suggestions");
534
+ }
535
+ for (const [key, value] of Object.entries(normalized)) {
536
+ if (!(key in raw)) continue;
537
+ const here = path ? `${path}.${key}` : key;
538
+ paths.push(...replacedFields(raw[key], value, here));
539
+ }
540
+ return paths;
541
+ }
542
+
543
+ const isErrno = (error: unknown, code: string) =>
544
+ error instanceof Error && "code" in error && error.code === code;
545
+
546
+ interface LockIdentity {
547
+ readonly pid: number;
548
+ readonly processStartedAt: number;
549
+ readonly processStartedAtVerified: boolean;
550
+ readonly token: string;
551
+ }
552
+
553
+ interface LockOwner extends LockIdentity {
554
+ readonly version: typeof SETUP_CONFIG_LOCK_VERSION;
555
+ readonly createdAt: number;
556
+ }
557
+
558
+ type LockOwnerRead =
559
+ | { kind: "owner"; owner: LockOwner }
560
+ | { kind: "missing" }
561
+ | { kind: "unknown" };
562
+
563
+ type ProcessLiveness = "live" | "dead" | "unknown";
564
+
565
+ const UUID_PATTERN =
566
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
567
+
568
+ const isPositiveInteger = (value: unknown): value is number =>
569
+ typeof value === "number" && Number.isSafeInteger(value) && value > 0;
570
+
571
+ function parseLockOwner(value: unknown): LockOwner | undefined {
572
+ if (!isRecord(value)) return undefined;
573
+ if (value.version !== SETUP_CONFIG_LOCK_VERSION) return undefined;
574
+ if (!isPositiveInteger(value.pid)) return undefined;
575
+ if (!isPositiveInteger(value.processStartedAt)) return undefined;
576
+ if (typeof value.processStartedAtVerified !== "boolean") return undefined;
577
+ if (!isPositiveInteger(value.createdAt)) return undefined;
578
+ if (typeof value.token !== "string" || !UUID_PATTERN.test(value.token))
579
+ return undefined;
580
+ return {
581
+ version: SETUP_CONFIG_LOCK_VERSION,
582
+ pid: value.pid,
583
+ processStartedAt: value.processStartedAt,
584
+ processStartedAtVerified: value.processStartedAtVerified,
585
+ createdAt: value.createdAt,
586
+ token: value.token,
587
+ };
588
+ }
589
+
590
+ const sameOwner = (left: LockOwner, right: LockOwner) =>
591
+ left.version === right.version &&
592
+ left.pid === right.pid &&
593
+ left.processStartedAt === right.processStartedAt &&
594
+ left.processStartedAtVerified === right.processStartedAtVerified &&
595
+ left.createdAt === right.createdAt &&
596
+ left.token === right.token;
597
+
598
+ const sameFile = (left: Stats, right: Stats) =>
599
+ left.dev === right.dev && left.ino === right.ino;
600
+
601
+ const queryProcessStartedAt = (pid: number) =>
602
+ new Promise<number | undefined>((resolve) => {
603
+ if (process.platform === "win32") {
604
+ resolve(undefined);
605
+ return;
606
+ }
607
+ try {
608
+ execFile(
609
+ "ps",
610
+ ["-o", "lstart=", "-p", String(pid)],
611
+ {
612
+ encoding: "utf8",
613
+ env: { ...process.env, LC_ALL: "C" },
614
+ timeout: 1_000,
615
+ },
616
+ (error, stdout) => {
617
+ const startedAt = error ? Number.NaN : Date.parse(stdout.trim());
618
+ resolve(Number.isFinite(startedAt) ? startedAt : undefined);
619
+ },
620
+ );
621
+ } catch {
622
+ resolve(undefined);
623
+ }
624
+ });
625
+
626
+ const makeLockIdentity = async (): Promise<LockIdentity> => {
627
+ const processStartedAt = await queryProcessStartedAt(process.pid);
628
+ return {
629
+ pid: process.pid,
630
+ processStartedAt: processStartedAt ?? ESTIMATED_PROCESS_STARTED_AT,
631
+ processStartedAtVerified: processStartedAt !== undefined,
632
+ token: randomUUID(),
633
+ };
634
+ };
635
+
636
+ /**
637
+ * The owner document is fully written before `link()` atomically publishes the
638
+ * lock path. Its companion hard link remains for the whole critical section.
639
+ * A dead-owner recovery atomically renames that companion to its own PID/token,
640
+ * so another recovery can take over if the recovering process is also killed.
641
+ */
642
+ const makeLockOwner = async (): Promise<LockOwner> => ({
643
+ version: SETUP_CONFIG_LOCK_VERSION,
644
+ ...(await makeLockIdentity()),
645
+ createdAt: Date.now(),
646
+ });
647
+
648
+ const claimPathFor = (owner: LockOwner) =>
649
+ `${SETUP_CONFIG_LOCK_PATH}.owner.${owner.pid}.${owner.processStartedAt}.${owner.token}`;
650
+
651
+ const recoveryPathFor = (owner: LockOwner, recovery: LockIdentity) =>
652
+ `${claimPathFor(owner)}.recovering.${recovery.pid}.${recovery.processStartedAt}.${recovery.processStartedAtVerified ? 1 : 0}.${recovery.token}`;
653
+
654
+ const parseRecoveryIdentity = (owner: LockOwner, name: string) => {
655
+ const prefix = `${basename(claimPathFor(owner))}.recovering.`;
656
+ if (!name.startsWith(prefix)) return undefined;
657
+ const [rawPid, rawStartedAt, rawVerified, token, ...extra] = name
658
+ .slice(prefix.length)
659
+ .split(".");
660
+ const pid = Number(rawPid);
661
+ const processStartedAt = Number(rawStartedAt);
662
+ if (
663
+ extra.length > 0 ||
664
+ !isPositiveInteger(pid) ||
665
+ !isPositiveInteger(processStartedAt) ||
666
+ (rawVerified !== "0" && rawVerified !== "1") ||
667
+ !token ||
668
+ !UUID_PATTERN.test(token)
669
+ ) {
670
+ return undefined;
671
+ }
672
+ return {
673
+ pid,
674
+ processStartedAt,
675
+ processStartedAtVerified: rawVerified === "1",
676
+ token,
677
+ } satisfies LockIdentity;
678
+ };
679
+
680
+ async function readLockOwner(path: string): Promise<LockOwnerRead> {
681
+ try {
682
+ const owner = parseLockOwner(JSON.parse(await readFile(path, "utf8")));
683
+ return owner ? { kind: "owner", owner } : { kind: "unknown" };
684
+ } catch (error) {
685
+ return isErrno(error, "ENOENT") ? { kind: "missing" } : { kind: "unknown" };
686
+ }
687
+ }
688
+
689
+ async function processLiveness(
690
+ identity: LockIdentity,
691
+ ): Promise<ProcessLiveness> {
692
+ try {
693
+ process.kill(identity.pid, 0);
694
+ } catch (error) {
695
+ return isErrno(error, "ESRCH") ? "dead" : "unknown";
696
+ }
697
+ if (!identity.processStartedAtVerified) return "unknown";
698
+ const processStartedAt = await queryProcessStartedAt(identity.pid);
699
+ if (processStartedAt === undefined) return "unknown";
700
+ return processStartedAt === identity.processStartedAt ? "live" : "dead";
701
+ }
702
+
703
+ const lockTimeoutError = () =>
704
+ new Error(
705
+ `Timed out waiting for another Pi process to finish updating ${SETUP_CONFIG_PATH}.`,
706
+ );
707
+
708
+ function waitForSetupConfigLock(deadline: number) {
709
+ const remaining = deadline - Date.now();
710
+ if (remaining <= 0) return Promise.reject(lockTimeoutError());
711
+
712
+ return new Promise<void>((resolve, reject) => {
713
+ let settled = false;
714
+ let watcher: ReturnType<typeof watch> | undefined;
715
+ const timer = setTimeout(() => {
716
+ // fs.watch may coalesce or drop events. Recheck the atomic lock path at
717
+ // the deadline so a released lock cannot become a false timeout.
718
+ if (!existsSync(SETUP_CONFIG_LOCK_PATH)) finish();
719
+ else finish(lockTimeoutError());
720
+ }, remaining);
721
+ const finish = (error?: unknown) => {
722
+ if (settled) return;
723
+ settled = true;
724
+ clearTimeout(timer);
725
+ watcher?.close();
726
+ if (error) reject(error);
727
+ else resolve();
728
+ };
729
+
730
+ try {
731
+ const lockName = basename(SETUP_CONFIG_LOCK_PATH);
732
+ watcher = watch(getAgentDir(), (_event, filename) => {
733
+ if (filename === null || filename.toString() === lockName) finish();
734
+ });
735
+ watcher.on("error", finish);
736
+ // Close the race where the owner released the lock before the watcher
737
+ // became active. The caller always retries the atomic link afterwards.
738
+ if (!existsSync(SETUP_CONFIG_LOCK_PATH)) queueMicrotask(finish);
739
+ } catch (error) {
740
+ finish(error);
741
+ }
742
+ });
743
+ }
744
+
745
+ async function findRecoveryClaim(owner: LockOwner) {
746
+ const prefix = `${basename(claimPathFor(owner))}.recovering.`;
747
+ let names: string[];
748
+ try {
749
+ names = (await readdir(getAgentDir())).filter((name) =>
750
+ name.startsWith(prefix),
751
+ );
752
+ } catch {
753
+ return undefined;
754
+ }
755
+ if (names.length !== 1) return undefined;
756
+ const name = names[0]!;
757
+ const recovery = parseRecoveryIdentity(owner, name);
758
+ return recovery ? { path: join(getAgentDir(), name), recovery } : undefined;
759
+ }
760
+
761
+ async function takeRecoveryClaim(owner: LockOwner, lockStat: Stats) {
762
+ const ownerClaimPath = claimPathFor(owner);
763
+ const recovery = await makeLockIdentity();
764
+ const recoveryPath = recoveryPathFor(owner, recovery);
765
+ const claimOwner = await readLockOwner(ownerClaimPath);
766
+
767
+ if (claimOwner.kind === "owner") {
768
+ if (!sameOwner(claimOwner.owner, owner)) return undefined;
769
+ try {
770
+ if (!sameFile(lockStat, await stat(ownerClaimPath))) return undefined;
771
+ await rename(ownerClaimPath, recoveryPath);
772
+ return recoveryPath;
773
+ } catch (error) {
774
+ return isErrno(error, "ENOENT") ? null : undefined;
775
+ }
776
+ }
777
+ if (claimOwner.kind === "unknown") return undefined;
778
+
779
+ const existing = await findRecoveryClaim(owner);
780
+ if (!existing) return undefined;
781
+ if ((await processLiveness(existing.recovery)) !== "dead") return undefined;
782
+ const existingOwner = await readLockOwner(existing.path);
783
+ if (
784
+ existingOwner.kind !== "owner" ||
785
+ !sameOwner(existingOwner.owner, owner)
786
+ ) {
787
+ return undefined;
788
+ }
789
+ try {
790
+ if (!sameFile(lockStat, await stat(existing.path))) return undefined;
791
+ await rename(existing.path, recoveryPath);
792
+ return recoveryPath;
793
+ } catch (error) {
794
+ return isErrno(error, "ENOENT") ? null : undefined;
795
+ }
796
+ }
797
+
798
+ async function restoreOwnerClaim(owner: LockOwner, recoveryPath: string) {
799
+ try {
800
+ await link(recoveryPath, claimPathFor(owner));
801
+ await unlink(recoveryPath);
802
+ } catch {
803
+ // Keep every uncertain ownership artifact in place; a later writer will
804
+ // fail closed rather than unlinking a lock it cannot prove is stale.
805
+ }
806
+ }
807
+
808
+ async function recoverStaleSetupConfigLock() {
809
+ const lockRead = await readLockOwner(SETUP_CONFIG_LOCK_PATH);
810
+ if (lockRead.kind === "missing") return true;
811
+ if (
812
+ lockRead.kind !== "owner" ||
813
+ (await processLiveness(lockRead.owner)) !== "dead"
814
+ ) {
815
+ return false;
816
+ }
817
+
818
+ let lockStat: Stats;
819
+ try {
820
+ lockStat = await stat(SETUP_CONFIG_LOCK_PATH);
821
+ } catch (error) {
822
+ return isErrno(error, "ENOENT");
823
+ }
824
+
825
+ const recoveryPath = await takeRecoveryClaim(lockRead.owner, lockStat);
826
+ if (recoveryPath === null) return true;
827
+ if (!recoveryPath) return false;
828
+
829
+ const currentLock = await readLockOwner(SETUP_CONFIG_LOCK_PATH);
830
+ const recoveryOwner = await readLockOwner(recoveryPath);
831
+ try {
832
+ if (
833
+ currentLock.kind !== "owner" ||
834
+ recoveryOwner.kind !== "owner" ||
835
+ !sameOwner(currentLock.owner, lockRead.owner) ||
836
+ !sameOwner(recoveryOwner.owner, lockRead.owner) ||
837
+ (await processLiveness(currentLock.owner)) !== "dead" ||
838
+ !sameFile(await stat(SETUP_CONFIG_LOCK_PATH), await stat(recoveryPath))
839
+ ) {
840
+ await restoreOwnerClaim(lockRead.owner, recoveryPath);
841
+ return false;
842
+ }
843
+
844
+ await unlink(SETUP_CONFIG_LOCK_PATH);
845
+ await unlink(recoveryPath).catch(() => undefined);
846
+ return true;
847
+ } catch (error) {
848
+ if (isErrno(error, "ENOENT")) {
849
+ await unlink(recoveryPath).catch(() => undefined);
850
+ return true;
851
+ }
852
+ await restoreOwnerClaim(lockRead.owner, recoveryPath);
853
+ throw error;
854
+ }
855
+ }
856
+
857
+ async function releaseSetupConfigLock(owner: LockOwner, claimPath: string) {
858
+ const lockRead = await readLockOwner(SETUP_CONFIG_LOCK_PATH);
859
+ const claimRead = await readLockOwner(claimPath);
860
+ if (
861
+ lockRead.kind !== "owner" ||
862
+ claimRead.kind !== "owner" ||
863
+ !sameOwner(lockRead.owner, owner) ||
864
+ !sameOwner(claimRead.owner, owner) ||
865
+ !sameFile(await stat(SETUP_CONFIG_LOCK_PATH), await stat(claimPath))
866
+ ) {
867
+ throw new Error(
868
+ `Refusing to release setup config lock with uncertain ownership at ${SETUP_CONFIG_LOCK_PATH}.`,
869
+ );
870
+ }
871
+ await unlink(SETUP_CONFIG_LOCK_PATH);
872
+ await unlink(claimPath).catch(() => undefined);
873
+ }
874
+
875
+ async function withSetupConfigLock<A>(action: () => Promise<A>) {
876
+ await mkdir(getAgentDir(), { recursive: true });
877
+ const owner = await makeLockOwner();
878
+ const claimPath = claimPathFor(owner);
879
+ await writeFile(claimPath, `${JSON.stringify(owner)}\n`, {
880
+ encoding: "utf8",
881
+ flag: "wx",
882
+ mode: 0o600,
883
+ });
884
+ const deadline = Date.now() + SETUP_CONFIG_LOCK_TIMEOUT_MS;
885
+ let acquired = false;
886
+
887
+ try {
888
+ while (true) {
889
+ try {
890
+ await link(claimPath, SETUP_CONFIG_LOCK_PATH);
891
+ acquired = true;
892
+ break;
893
+ } catch (error) {
894
+ if (!isErrno(error, "EEXIST")) throw error;
895
+ if (await recoverStaleSetupConfigLock()) continue;
896
+ await waitForSetupConfigLock(deadline);
897
+ }
898
+ }
899
+
900
+ try {
901
+ return await action();
902
+ } finally {
903
+ await releaseSetupConfigLock(owner, claimPath);
904
+ acquired = false;
905
+ }
906
+ } finally {
907
+ if (!acquired) await unlink(claimPath).catch(() => undefined);
908
+ }
909
+ }
910
+
911
+ async function writeSetupConfig(config: MyPiSetupConfig) {
912
+ const tempPath = `${SETUP_CONFIG_PATH}.${process.pid}.${randomUUID()}.tmp`;
913
+ try {
914
+ await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, {
915
+ encoding: "utf8",
916
+ mode: 0o600,
917
+ });
918
+ await rename(tempPath, SETUP_CONFIG_PATH);
919
+ } catch (error) {
920
+ await unlink(tempPath).catch(() => undefined);
921
+ throw error;
922
+ }
923
+ }
924
+
925
+ export async function saveSetupConfig(config: MyPiSetupConfig) {
926
+ await withSetupConfigLock(async () => {
927
+ readDocumentForWrite();
928
+ await writeSetupConfig(config);
929
+ });
930
+ }
931
+
932
+ export function formatSetupConfig(config = loadSetupConfig()) {
933
+ const suggestionModel = config.suggestions.model;
934
+ const suggestions =
935
+ !config.suggestions.enabled || !suggestionModel
936
+ ? "Next-action suggestions: disabled"
937
+ : `Next-action suggestions: ${suggestionModel.provider}/${suggestionModel.model} · ${suggestionModel.reasoning} · Right accepts`;
938
+ const footer = config.ui.customFooter
939
+ ? `on · ${config.ui.footerStyle} · ${formatFooterLines(config.ui.footerLines)}`
940
+ : "off";
941
+ return [
942
+ suggestions,
943
+ `Workflows: ${config.workflows.concurrency} concurrent agents · ${config.workflows.maxAgentCalls} total calls`,
944
+ `UI: large header ${config.ui.showHeader ? "on" : "off"} · custom footer ${footer}`,
945
+ `Subagent results: ${config.ui.subagentResultDisplay === "full" ? "full by default" : "compact preview (expand for full output)"}`,
946
+ `Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "folded preview (Ctrl+O expands all)"}`,
947
+ `Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "folded preview (Ctrl+O expands all)"}`,
948
+ `Post-edit command: ${config.postEdit.command ? config.postEdit.command : "off"}`,
949
+ `Agent role models (Subagents + Workflows): ${SUBAGENT_ROLE_NAMES.map((role) => `${role} ${config.subagents.roleModels[role] ? `${config.subagents.roleModels[role].provider}/${config.subagents.roleModels[role].model}` : "inherit"}`).join(" · ")}`,
950
+ ].join("\n");
951
+ }
952
+
953
+ export { isFooterItem, isFooterLayoutItem, isFooterStyle, isFooterPreset };
954
+
955
+ /**
956
+ * Read-modify-write against the document as it is on disk right now, so a
957
+ * config changed by another session since this one loaded it is patched
958
+ * rather than replaced wholesale. Returns the fields whose stored value was
959
+ * normalized or migrated, so the caller can say so out loud.
960
+ */
961
+ export async function updateSetupConfig(
962
+ mutate: (current: MyPiSetupConfig) => MyPiSetupConfig,
963
+ ) {
964
+ return withSetupConfigLock(async () => {
965
+ const raw = readDocumentForWrite();
966
+ const current = parseSetupConfig(raw);
967
+ const config = mutate(current);
968
+ await writeSetupConfig(config);
969
+ return { config, replaced: replacedFields(raw, current) };
970
+ });
971
+ }