@tt-a1i/openpi 0.4.0 → 0.6.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 (141) hide show
  1. package/README.md +116 -46
  2. package/SETUP.md +29 -7
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-launch-card-v1.webp +0 -0
  5. package/bin/openpi.js +155 -0
  6. package/extensions/ai-providers/LICENSE.upstream +23 -0
  7. package/extensions/ai-providers/README.md +59 -0
  8. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  9. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  10. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  11. package/extensions/ai-providers/antigravity/models.ts +84 -0
  12. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  13. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  14. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  15. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  16. package/extensions/ai-providers/cursor/constants.ts +5 -0
  17. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  18. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  19. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  20. package/extensions/ai-providers/cursor/models.ts +45 -0
  21. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  22. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  23. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  24. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  25. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +38 -3
  31. package/extensions/background-terminals/src/domain.ts +2 -0
  32. package/extensions/background-terminals/src/manager.ts +484 -106
  33. package/extensions/background-terminals/src/output.ts +33 -0
  34. package/extensions/background-terminals/src/prompt.ts +13 -5
  35. package/extensions/background-terminals/src/result-delivery.ts +47 -24
  36. package/extensions/clear-context/index.ts +83 -0
  37. package/extensions/context-pivot/index.ts +16 -6
  38. package/extensions/cron/index.ts +68 -27
  39. package/extensions/cron/schedule.ts +12 -2
  40. package/extensions/file-mutation-display/render.ts +17 -257
  41. package/extensions/file-search/src/binaries.ts +57 -41
  42. package/extensions/git-read/index.ts +1 -3
  43. package/extensions/model-info/cache-diagnostics.ts +220 -0
  44. package/extensions/model-info/index.ts +65 -33
  45. package/extensions/model-info/session-metrics.ts +96 -0
  46. package/extensions/plan-mode/bash-policy.ts +54 -9
  47. package/extensions/plan-mode/index.ts +82 -6
  48. package/extensions/post-edit/index.ts +16 -6
  49. package/extensions/sessions/git-stats.ts +258 -72
  50. package/extensions/sessions/index.ts +153 -86
  51. package/extensions/sessions/preview-cache.ts +104 -0
  52. package/extensions/sessions/preview-loader.ts +856 -0
  53. package/extensions/sessions/sessions.ts +43 -4
  54. package/extensions/setup/index.ts +138 -130
  55. package/extensions/shared/activity-status.ts +30 -0
  56. package/extensions/shared/agent-session-page.ts +319 -0
  57. package/extensions/shared/agent-tool-renderer.ts +218 -0
  58. package/extensions/shared/agent-transcript.ts +524 -0
  59. package/extensions/shared/capability-intent.ts +1 -1
  60. package/extensions/shared/child-session.ts +457 -21
  61. package/extensions/shared/completion-inbox.ts +193 -0
  62. package/extensions/shared/result-delivery.ts +34 -0
  63. package/extensions/shared/setup-config.ts +83 -34
  64. package/extensions/shared/setup-episode-state.ts +1 -1
  65. package/extensions/shared/structured-output.ts +154 -0
  66. package/extensions/shared/terminal-text.ts +110 -23
  67. package/extensions/shared/text-projection.ts +72 -15
  68. package/extensions/shared/tool-activity.ts +382 -0
  69. package/extensions/shared/tool-surface.ts +29 -2
  70. package/extensions/shared/transcript-viewport.ts +46 -0
  71. package/extensions/shared/web-observer-registry.ts +390 -0
  72. package/extensions/shared/worktree.ts +11 -0
  73. package/extensions/subagents/index.ts +313 -62
  74. package/extensions/subagents/navigation.ts +34 -5
  75. package/extensions/subagents/src/backend.ts +12 -1
  76. package/extensions/subagents/src/backends/pi.ts +450 -70
  77. package/extensions/subagents/src/domain.ts +21 -1
  78. package/extensions/subagents/src/manager.ts +39 -2
  79. package/extensions/subagents/src/prompt.ts +49 -7
  80. package/extensions/subagents/src/result-artifact.ts +36 -0
  81. package/extensions/subagents/src/result-delivery.ts +39 -14
  82. package/extensions/subagents/src/runtime.ts +15 -1
  83. package/extensions/subagents/src/ui/takeover.ts +73 -257
  84. package/extensions/subagents/src/ui/transcript.ts +38 -535
  85. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  86. package/extensions/suggestions/src/ui.ts +10 -4
  87. package/extensions/tasks/index.ts +0 -3
  88. package/extensions/ui-customization/footer.ts +16 -45
  89. package/extensions/ui-customization/index.ts +0 -4
  90. package/extensions/user-input-fold/index.ts +42 -6
  91. package/extensions/web/index.ts +257 -0
  92. package/extensions/workflows/acceptance.ts +43 -19
  93. package/extensions/workflows/artifacts.ts +137 -47
  94. package/extensions/workflows/completion-projection.ts +459 -0
  95. package/extensions/workflows/coordinator.ts +8 -10
  96. package/extensions/workflows/dashboard.ts +175 -228
  97. package/extensions/workflows/handoff.ts +70 -16
  98. package/extensions/workflows/index.ts +501 -198
  99. package/extensions/workflows/journal.ts +148 -13
  100. package/extensions/workflows/model.ts +79 -5
  101. package/extensions/workflows/navigation.ts +32 -8
  102. package/extensions/workflows/progress-projection.ts +306 -0
  103. package/extensions/workflows/prompt.ts +70 -16
  104. package/extensions/workflows/replay-safety.ts +42 -21
  105. package/extensions/workflows/result-delivery.ts +214 -76
  106. package/extensions/workflows/retention.ts +599 -0
  107. package/extensions/workflows/runner.ts +389 -345
  108. package/extensions/workflows/sandbox-child.cjs +25 -3
  109. package/extensions/workflows/sandbox.ts +62 -8
  110. package/extensions/workflows/serialization.ts +325 -17
  111. package/extensions/workflows/tool-renderer.ts +22 -0
  112. package/extensions/workflows/transcript.ts +149 -0
  113. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  114. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  115. package/package.json +34 -14
  116. package/skills/subagents/REFERENCE.md +190 -0
  117. package/skills/subagents/SKILL.md +2 -1
  118. package/skills/workflows/REFERENCE.md +6 -4
  119. package/skills/workflows/SKILL.md +1 -1
  120. package/web/adapter/pi-adapter.ts +664 -0
  121. package/web/host/browser-launcher.ts +20 -0
  122. package/web/host/pi-coding-agent-entry.ts +162 -0
  123. package/web/host/static-assets.ts +4 -0
  124. package/web/host/terminal-status.ts +38 -0
  125. package/web/host/web-host.ts +1069 -0
  126. package/web/http-dispatcher.ts +125 -0
  127. package/web/protocol/types.ts +467 -0
  128. package/web/runtime/pi-runtime.ts +1206 -0
  129. package/web/runtime/types.ts +102 -0
  130. package/web/runtime/web-host-lease.ts +497 -0
  131. package/web/trace.ts +18 -0
  132. package/web/ui/app.js +1700 -0
  133. package/web/ui/index.html +142 -0
  134. package/web/ui/styles.css +680 -0
  135. package/web/vite.config.mjs +34 -0
  136. package/extensions/execution-convergence/active-evidence.ts +0 -129
  137. package/extensions/execution-convergence/index.ts +0 -442
  138. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  139. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  140. package/extensions/setup/intercom.ts +0 -603
  141. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -0,0 +1,34 @@
1
+ /** Minimum lifecycle contract shared by deferred result queues. */
2
+ export interface ResultDeliveryQueue<T> {
3
+ defer(result: T): void;
4
+ size(): number;
5
+ clear(): void;
6
+ }
7
+
8
+ /** One-shot results are removed by either an explicit consumer or delivery. */
9
+ export interface ConsumableResultDeliveryQueue<T, TId = string>
10
+ extends ResultDeliveryQueue<T> {
11
+ consume(ids: Iterable<TId>): void;
12
+ }
13
+
14
+ /** Stable identity carried by a durable, at-least-once result. */
15
+ export interface DurableResultEnvelope {
16
+ deliveryId: string;
17
+ }
18
+
19
+ /** Per-result acknowledgement for a durable delivery attempt. */
20
+ export interface DurableResultDeliveryReceipt {
21
+ deliveryId: string;
22
+ delivered: boolean;
23
+ error?: string;
24
+ }
25
+
26
+ /**
27
+ * Durable queues can reconstruct pending ownership after restart and retry it
28
+ * at an explicit lifecycle boundary without changing the stable delivery id.
29
+ */
30
+ export interface DurableResultDeliveryQueue<T extends DurableResultEnvelope>
31
+ extends ResultDeliveryQueue<T> {
32
+ restore(result: T): boolean;
33
+ retryPending(): Promise<void>;
34
+ }
@@ -63,6 +63,9 @@ export type FooterLines = readonly (readonly FooterLayoutItem[])[];
63
63
  export const DETAIL_DISPLAYS = ["full", "compact"] as const;
64
64
  export type DetailDisplay = (typeof DETAIL_DISPLAYS)[number];
65
65
 
66
+ export const WEB_THEMES = ["system", "light", "dark"] as const;
67
+ export type WebTheme = (typeof WEB_THEMES)[number];
68
+
66
69
  export const CAPABILITY_DISCOVERY_MODES = ["explicit", "adaptive"] as const;
67
70
  export type CapabilityDiscoveryMode =
68
71
  (typeof CAPABILITY_DISCOVERY_MODES)[number];
@@ -72,6 +75,22 @@ export const DEFAULT_FOOTER_LINES: FooterLines = [
72
75
  ["model", "context", "flex", "git", "pr", "cwd"],
73
76
  ];
74
77
 
78
+ /** Stable skeleton for the legacy flat-selection adapter. */
79
+ const FLAT_FOOTER_ITEM_LINES: FooterLines = [
80
+ [
81
+ "model",
82
+ "thinking",
83
+ "context",
84
+ "cache",
85
+ "cost",
86
+ "throughput",
87
+ "flex",
88
+ "git",
89
+ "pr",
90
+ "cwd",
91
+ ],
92
+ ];
93
+
75
94
  /** The persisted canonical default before model/context moved to the left. */
76
95
  const LEGACY_DEFAULT_FOOTER_LINES: FooterLines = [
77
96
  ["cwd", "git", "pr", "flex", "model", "context"],
@@ -133,12 +152,11 @@ export interface MyPiSetupConfig {
133
152
  readonly maxAgentCalls: number;
134
153
  };
135
154
  readonly ui: {
155
+ readonly webTheme: WebTheme;
136
156
  readonly showHeader: boolean;
137
157
  readonly customFooter: boolean;
138
158
  readonly footerStyle: FooterStyle;
139
- /** Canonical footer layout. `footerItems` is always derived from this. */
140
159
  readonly footerLines: FooterLines;
141
- readonly footerItems: readonly FooterItem[];
142
160
  readonly subagentResultDisplay: DetailDisplay;
143
161
  readonly bashToolDisplay: DetailDisplay;
144
162
  readonly fileMutationDisplay: DetailDisplay;
@@ -165,12 +183,12 @@ export const DEFAULT_SETUP_CONFIG: MyPiSetupConfig = {
165
183
  maxAgentCalls: DEFAULT_WORKFLOW_MAX_AGENT_CALLS,
166
184
  },
167
185
  ui: {
186
+ webTheme: "system",
168
187
  showHeader: false,
169
188
  customFooter: true,
170
189
  footerStyle: DEFAULT_FOOTER_STYLE,
171
190
  footerLines: DEFAULT_FOOTER_LINES,
172
- footerItems: DEFAULT_FOOTER_ITEMS,
173
- subagentResultDisplay: "full",
191
+ subagentResultDisplay: "compact",
174
192
  bashToolDisplay: "compact",
175
193
  fileMutationDisplay: "compact",
176
194
  },
@@ -213,6 +231,9 @@ const isCapabilityDiscoveryMode = (
213
231
  typeof value === "string" &&
214
232
  CAPABILITY_DISCOVERY_MODES.includes(value as CapabilityDiscoveryMode);
215
233
 
234
+ const isWebTheme = (value: unknown): value is WebTheme =>
235
+ typeof value === "string" && WEB_THEMES.includes(value as WebTheme);
236
+
216
237
  export function flattenFooterItems(lines: FooterLines): readonly FooterItem[] {
217
238
  const items: FooterItem[] = [];
218
239
  const seen = new Set<FooterItem>();
@@ -261,13 +282,13 @@ export function normalizeFooterLines(value: unknown): FooterLines {
261
282
  return lines.length > 0 ? lines : DEFAULT_FOOTER_LINES;
262
283
  }
263
284
 
264
- /** Map a flat item list onto the default one-line skeleton (legacy compat). */
285
+ /** Map a flat item list onto the stable one-line skeleton (legacy compat). */
265
286
  export function footerLinesFromItems(
266
287
  items: readonly FooterItem[],
267
288
  ): FooterLines {
268
289
  const selected = new Set(items);
269
290
  return normalizeFooterLines(
270
- DEFAULT_FOOTER_LINES.map((line) =>
291
+ FLAT_FOOTER_ITEM_LINES.map((line) =>
271
292
  line.filter((item) => item === "flex" || selected.has(item)),
272
293
  ),
273
294
  );
@@ -335,7 +356,6 @@ export function applyFooterConfig(
335
356
  return {
336
357
  footerStyle: style,
337
358
  footerLines: normalized,
338
- footerItems: flattenFooterItems(normalized),
339
359
  };
340
360
  }
341
361
 
@@ -358,7 +378,7 @@ function sameFooterLines(left: FooterLines, right: FooterLines) {
358
378
 
359
379
  function parseUiFooter(
360
380
  ui: Record<string, unknown>,
361
- ): Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines" | "footerItems"> {
381
+ ): Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines"> {
362
382
  const style = isFooterStyle(ui.footerStyle)
363
383
  ? ui.footerStyle
364
384
  : DEFAULT_FOOTER_STYLE;
@@ -371,7 +391,6 @@ function parseUiFooter(
371
391
  return {
372
392
  footerStyle: style,
373
393
  footerLines: lines,
374
- footerItems: flattenFooterItems(lines),
375
394
  };
376
395
  }
377
396
 
@@ -382,14 +401,12 @@ function parseUiFooter(
382
401
  return {
383
402
  footerStyle: style,
384
403
  footerLines: lines,
385
- footerItems: flattenFooterItems(lines),
386
404
  };
387
405
  }
388
406
 
389
407
  return {
390
408
  footerStyle: style,
391
409
  footerLines: DEFAULT_FOOTER_LINES,
392
- footerItems: DEFAULT_FOOTER_ITEMS,
393
410
  };
394
411
  }
395
412
 
@@ -476,6 +493,7 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig {
476
493
  ),
477
494
  },
478
495
  ui: {
496
+ webTheme: isWebTheme(ui.webTheme) ? ui.webTheme : "system",
479
497
  showHeader: typeof ui.showHeader === "boolean" ? ui.showHeader : false,
480
498
  customFooter:
481
499
  typeof ui.customFooter === "boolean" ? ui.customFooter : true,
@@ -484,7 +502,7 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig {
484
502
  ui.subagentResultDisplay as DetailDisplay,
485
503
  )
486
504
  ? (ui.subagentResultDisplay as DetailDisplay)
487
- : "full",
505
+ : "compact",
488
506
  bashToolDisplay: DETAIL_DISPLAYS.includes(
489
507
  ui.bashToolDisplay as DetailDisplay,
490
508
  )
@@ -504,9 +522,9 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig {
504
522
  /** An empty or invalid value disables the post-edit command (the safe default). */
505
523
  function parsePostEditCommand(value: unknown) {
506
524
  if (!isRecord(value)) return "";
507
- return typeof value.command === "string"
508
- ? value.command.trim().slice(0, POST_EDIT_COMMAND_MAX_CHARS)
509
- : "";
525
+ if (typeof value.command !== "string") return "";
526
+ const command = value.command.trim();
527
+ return command.length <= POST_EDIT_COMMAND_MAX_CHARS ? command : "";
510
528
  }
511
529
 
512
530
  export function hasSavedSetupConfig() {
@@ -561,6 +579,9 @@ function replacedFields(
561
579
  ) {
562
580
  paths.push("summaries → suggestions");
563
581
  }
582
+ if (path === "ui" && "footerItems" in raw && "footerLines" in normalized) {
583
+ paths.push("ui.footerItems");
584
+ }
564
585
  for (const [key, value] of Object.entries(normalized)) {
565
586
  if (!(key in raw)) continue;
566
587
  const here = path ? `${path}.${key}` : key;
@@ -627,24 +648,55 @@ const sameOwner = (left: LockOwner, right: LockOwner) =>
627
648
  const sameFile = (left: Stats, right: Stats) =>
628
649
  left.dev === right.dev && left.ino === right.ino;
629
650
 
651
+ const parsePowerShellProcessStartedAt = (stdout: string) => {
652
+ const startedAt = Number(stdout.trim());
653
+ return isPositiveInteger(startedAt) ? startedAt : undefined;
654
+ };
655
+
656
+ const parsePsProcessStartedAt = (stdout: string) => {
657
+ const startedAt = Date.parse(stdout.trim());
658
+ return Number.isFinite(startedAt) ? startedAt : undefined;
659
+ };
660
+
661
+ export function processStartedAtQuery(
662
+ pid: number,
663
+ platform = process.platform,
664
+ ) {
665
+ if (platform === "win32") {
666
+ const script = [
667
+ "$ErrorActionPreference='Stop'",
668
+ `$p=Get-Process -Id ${pid}`,
669
+ "[Console]::Out.Write(([DateTimeOffset]$p.StartTime).ToUnixTimeMilliseconds())",
670
+ ].join("; ");
671
+ return {
672
+ command: "powershell.exe",
673
+ args: ["-NoProfile", "-NonInteractive", "-Command", script],
674
+ env: undefined,
675
+ parseOutput: parsePowerShellProcessStartedAt,
676
+ };
677
+ }
678
+ return {
679
+ command: "ps",
680
+ args: ["-o", "lstart=", "-p", String(pid)],
681
+ env: { LC_ALL: "C" },
682
+ parseOutput: parsePsProcessStartedAt,
683
+ };
684
+ }
685
+
630
686
  const queryProcessStartedAt = (pid: number) =>
631
687
  new Promise<number | undefined>((resolve) => {
632
- if (process.platform === "win32") {
633
- resolve(undefined);
634
- return;
635
- }
688
+ const query = processStartedAtQuery(pid);
636
689
  try {
637
690
  execFile(
638
- "ps",
639
- ["-o", "lstart=", "-p", String(pid)],
691
+ query.command,
692
+ query.args,
640
693
  {
641
694
  encoding: "utf8",
642
- env: { ...process.env, LC_ALL: "C" },
695
+ env: query.env ? { ...process.env, ...query.env } : process.env,
643
696
  timeout: 1_000,
644
697
  },
645
698
  (error, stdout) => {
646
- const startedAt = error ? Number.NaN : Date.parse(stdout.trim());
647
- resolve(Number.isFinite(startedAt) ? startedAt : undefined);
699
+ resolve(error ? undefined : query.parseOutput(stdout));
648
700
  },
649
701
  );
650
702
  } catch {
@@ -938,13 +990,15 @@ async function withSetupConfigLock<A>(action: () => Promise<A>) {
938
990
  }
939
991
 
940
992
  async function writeSetupConfig(config: MyPiSetupConfig) {
993
+ const canonical = parseSetupConfig(config);
941
994
  const tempPath = `${SETUP_CONFIG_PATH}.${process.pid}.${randomUUID()}.tmp`;
942
995
  try {
943
- await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, {
996
+ await writeFile(tempPath, `${JSON.stringify(canonical, null, 2)}\n`, {
944
997
  encoding: "utf8",
945
998
  mode: 0o600,
946
999
  });
947
1000
  await rename(tempPath, SETUP_CONFIG_PATH);
1001
+ return canonical;
948
1002
  } catch (error) {
949
1003
  await unlink(tempPath).catch(() => undefined);
950
1004
  throw error;
@@ -958,10 +1012,7 @@ export async function saveSetupConfig(config: MyPiSetupConfig) {
958
1012
  });
959
1013
  }
960
1014
 
961
- export function formatSetupConfig(
962
- config = loadSetupConfig(),
963
- integrationLines: readonly string[] = [],
964
- ) {
1015
+ export function formatSetupConfig(config = loadSetupConfig()) {
965
1016
  const suggestionModel = config.suggestions.model;
966
1017
  const suggestions =
967
1018
  !config.suggestions.enabled || !suggestionModel
@@ -974,13 +1025,12 @@ export function formatSetupConfig(
974
1025
  `Capability discovery: ${config.capabilities.discovery}`,
975
1026
  suggestions,
976
1027
  `Workflows: ${config.workflows.concurrency} concurrent agents · ${config.workflows.maxAgentCalls} total calls`,
977
- `UI: large header ${config.ui.showHeader ? "on" : "off"} · custom footer ${footer}`,
1028
+ `UI: Web theme ${config.ui.webTheme} · large header ${config.ui.showHeader ? "on" : "off"} · custom footer ${footer}`,
978
1029
  `Subagent results: ${config.ui.subagentResultDisplay === "full" ? "full by default" : "compact status summary (Ctrl+O expands full output)"}`,
979
1030
  `Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`,
980
1031
  `Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`,
981
- `Post-edit command: ${config.postEdit.command ? config.postEdit.command : "off"}`,
1032
+ `Post-edit command: ${config.postEdit.command ? "configured" : "off"}`,
982
1033
  `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(" · ")}`,
983
- ...integrationLines,
984
1034
  ].join("\n");
985
1035
  }
986
1036
 
@@ -998,8 +1048,7 @@ export async function updateSetupConfig(
998
1048
  return withSetupConfigLock(async () => {
999
1049
  const raw = readDocumentForWrite();
1000
1050
  const current = parseSetupConfig(raw);
1001
- const config = mutate(current);
1002
- await writeSetupConfig(config);
1051
+ const config = await writeSetupConfig(mutate(current));
1003
1052
  return { config, replaced: replacedFields(raw, current) };
1004
1053
  });
1005
1054
  }
@@ -2,6 +2,6 @@
2
2
  export const OPENPI_SETUP_EPISODE_CHANNEL = "openpi:setup-episode";
3
3
 
4
4
  export interface OpenPiSetupEpisodeState {
5
- /** True for both armed and actively running setup episodes. */
5
+ /** True only while the owned setup writer is active for a delivered request. */
6
6
  readonly active: boolean;
7
7
  }
@@ -0,0 +1,154 @@
1
+ import {
2
+ defineTool,
3
+ type ToolDefinition,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { type TSchema, Type } from "typebox";
6
+
7
+ export const STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION =
8
+ "When your task is complete, call the `structured_output` tool exactly once as your final action, with fields matching the required schema. Do not write any other text after it.";
9
+
10
+ export const STRUCTURED_OUTPUT_TOOL_DESCRIPTION =
11
+ "Return your final result as structured data matching the required schema. Call this exactly once, as your last action; do not write any other text after it.";
12
+
13
+ export const STRUCTURED_RESULT_LIMITS = Object.freeze({
14
+ schemaDepth: 24,
15
+ schemaNodes: 10_000,
16
+ resultBytes: 2 * 1024 * 1024,
17
+ resultDepth: 24,
18
+ resultNodes: 100_000,
19
+ resultStringBytes: 1024 * 1024,
20
+ });
21
+
22
+ export interface EncodedStructuredResult {
23
+ readonly value: unknown;
24
+ readonly json: string;
25
+ readonly byteLength: number;
26
+ }
27
+
28
+ function safeRecordKey(key: string) {
29
+ return key !== "__proto__" && key !== "constructor" && key !== "prototype";
30
+ }
31
+
32
+ /** Preserve the caller's full JSON Schema instead of lossy keyword conversion. */
33
+ export function jsonSchemaToTypebox(schema: unknown): TSchema {
34
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
35
+ throw new Error("structured output schema must be a bounded JSON object");
36
+ }
37
+ const seen = new WeakSet<object>();
38
+ let nodes = 0;
39
+ const validate = (current: unknown, depth: number): boolean => {
40
+ if (
41
+ ++nodes > STRUCTURED_RESULT_LIMITS.schemaNodes ||
42
+ depth > STRUCTURED_RESULT_LIMITS.schemaDepth
43
+ ) {
44
+ return false;
45
+ }
46
+ if (
47
+ current === null ||
48
+ typeof current === "string" ||
49
+ typeof current === "boolean"
50
+ ) {
51
+ return true;
52
+ }
53
+ if (typeof current === "number") return Number.isFinite(current);
54
+ if (Array.isArray(current)) {
55
+ return current.every((item) => validate(item, depth + 1));
56
+ }
57
+ if (typeof current !== "object" || seen.has(current)) return false;
58
+ seen.add(current);
59
+ return Object.keys(current).every(
60
+ (key) =>
61
+ safeRecordKey(key) &&
62
+ validate((current as Record<string, unknown>)[key], depth + 1),
63
+ );
64
+ };
65
+ if (!validate(schema, 0)) {
66
+ throw new Error("structured output schema must be a bounded JSON object");
67
+ }
68
+ return Type.Unsafe(schema);
69
+ }
70
+
71
+ /** Encode one complete JSON result or fail before any truncated artifact exists. */
72
+ export function encodeStructuredResult(
73
+ value: unknown,
74
+ ): EncodedStructuredResult {
75
+ const seen = new WeakSet<object>();
76
+ let nodes = 0;
77
+ const validate = (current: unknown, depth: number): void => {
78
+ if (++nodes > STRUCTURED_RESULT_LIMITS.resultNodes) {
79
+ throw new Error("structured result exceeds the node limit");
80
+ }
81
+ if (depth > STRUCTURED_RESULT_LIMITS.resultDepth) {
82
+ throw new Error("structured result exceeds the depth limit");
83
+ }
84
+ if (typeof current === "string") {
85
+ if (
86
+ Buffer.byteLength(current, "utf8") >
87
+ STRUCTURED_RESULT_LIMITS.resultStringBytes
88
+ ) {
89
+ throw new Error("structured result contains an oversized string");
90
+ }
91
+ return;
92
+ }
93
+ if (
94
+ current === null ||
95
+ typeof current === "boolean" ||
96
+ (typeof current === "number" && Number.isFinite(current))
97
+ ) {
98
+ return;
99
+ }
100
+ if (typeof current !== "object" || seen.has(current)) {
101
+ throw new Error(
102
+ "structured result must contain only acyclic JSON values",
103
+ );
104
+ }
105
+ seen.add(current);
106
+ if (Array.isArray(current)) {
107
+ for (const item of current) validate(item, depth + 1);
108
+ return;
109
+ }
110
+ for (const [key, item] of Object.entries(current)) {
111
+ if (!safeRecordKey(key)) {
112
+ throw new Error("structured result contains an unsafe object key");
113
+ }
114
+ validate(item, depth + 1);
115
+ }
116
+ };
117
+ validate(value, 0);
118
+ const json = JSON.stringify(value);
119
+ const byteLength = Buffer.byteLength(json, "utf8");
120
+ if (byteLength > STRUCTURED_RESULT_LIMITS.resultBytes) {
121
+ throw new Error("structured result exceeds the total byte limit");
122
+ }
123
+ return { value, json, byteLength };
124
+ }
125
+
126
+ /** One-shot terminating child tool shared by Workflow and Direct Subagent. */
127
+ export function createStructuredOutputTool(
128
+ schema: unknown,
129
+ capture: (value: unknown) => void,
130
+ ): ToolDefinition {
131
+ return defineTool({
132
+ name: "structured_output",
133
+ label: "Structured Output",
134
+ description: STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
135
+ parameters: jsonSchemaToTypebox(schema),
136
+ async execute(_toolCallId, params) {
137
+ capture(params);
138
+ return {
139
+ content: [{ type: "text", text: "Recorded structured result." }],
140
+ details: params,
141
+ terminate: true,
142
+ };
143
+ },
144
+ });
145
+ }
146
+
147
+ export function childToolsWithStructuredOutput(
148
+ tools: readonly string[] | undefined,
149
+ structured: boolean,
150
+ ) {
151
+ return tools
152
+ ? [...new Set([...tools, ...(structured ? ["structured_output"] : [])])]
153
+ : undefined;
154
+ }
@@ -1,35 +1,122 @@
1
- // OSC may end in BEL or ST. DCS/SOS/PM/APC end only in ST; treating BEL as
2
- // their terminator would expose the rest of a still-hidden payload. Both
3
- // patterns consume an unterminated string through the bounded input's end.
4
- // eslint-disable-next-line no-control-regex
5
- const OSC_PATTERN =
6
- /(?:\u001b\]|\u009d)(?:[^\u0007\u001b\u009c]|\u001b(?!\\))*(?:\u0007|\u001b\\|\u009c|$)/g;
7
- // eslint-disable-next-line no-control-regex
8
- const ST_STRING_PATTERN =
9
- /(?:\u001b[PX^_]|[\u0090\u0098\u009e\u009f])(?:[^\u001b\u009c]|\u001b(?!\\))*(?:\u001b\\|\u009c|$)/g;
10
- // eslint-disable-next-line no-control-regex
11
- const CSI_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
12
- // Remaining two-byte/charset escape forms (for example ESC ( 0).
13
- // eslint-disable-next-line no-control-regex
14
- const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
15
1
  // Invisible Unicode format controls can reorder or disguise untrusted text.
16
2
  // Keep only ZWNJ/ZWJ (U+200C/U+200D), which carry legitimate shaping and emoji
17
3
  // semantics; variation selectors are marks rather than Cf and remain intact.
18
- const UNSAFE_FORMAT_PATTERN = /(?![\u200c\u200d])\p{Cf}/gu;
4
+ const UNSAFE_FORMAT_CHARACTER = /\p{Cf}/u;
19
5
  // eslint-disable-next-line no-control-regex
20
6
  const TERMINAL_CONTROL_PATTERN =
21
7
  /[\u0000-\u001f\u007f-\u009f]|(?:(?![\u200c\u200d])\p{Cf})/u;
22
8
 
9
+ type SanitizerState =
10
+ | "text"
11
+ | "escape"
12
+ | "escape_intermediate"
13
+ | "csi"
14
+ | "osc"
15
+ | "st_string";
16
+
17
+ /** Stateful terminal-text projection for streams whose retained head can move. */
18
+ export class TerminalTextSanitizer {
19
+ private state: SanitizerState = "text";
20
+ private stringEscape = false;
21
+
22
+ push(text: string) {
23
+ let safe = "";
24
+ for (const character of text) {
25
+ const code = character.codePointAt(0)!;
26
+
27
+ if (this.state === "osc" || this.state === "st_string") {
28
+ if (character === "\u009c") {
29
+ this.state = "text";
30
+ this.stringEscape = false;
31
+ } else if (this.state === "osc" && character === "\u0007") {
32
+ this.state = "text";
33
+ this.stringEscape = false;
34
+ } else if (this.stringEscape && character === "\\") {
35
+ this.state = "text";
36
+ this.stringEscape = false;
37
+ } else {
38
+ this.stringEscape = character === "\u001b";
39
+ }
40
+ continue;
41
+ }
42
+
43
+ if (this.state === "csi") {
44
+ if (code >= 0x40 && code <= 0x7e) this.state = "text";
45
+ else if (code < 0x20 || code > 0x3f) {
46
+ this.state = "text";
47
+ safe += this.safeCharacter(character, code);
48
+ }
49
+ continue;
50
+ }
51
+
52
+ if (this.state === "escape" || this.state === "escape_intermediate") {
53
+ if (this.state === "escape") {
54
+ if (character === "[") {
55
+ this.state = "csi";
56
+ continue;
57
+ }
58
+ if (character === "]") {
59
+ this.state = "osc";
60
+ continue;
61
+ }
62
+ if (
63
+ character === "P" ||
64
+ character === "X" ||
65
+ character === "^" ||
66
+ character === "_"
67
+ ) {
68
+ this.state = "st_string";
69
+ continue;
70
+ }
71
+ }
72
+ if (code >= 0x20 && code <= 0x2f) {
73
+ this.state = "escape_intermediate";
74
+ } else if (code >= 0x30 && code <= 0x7e) {
75
+ this.state = "text";
76
+ } else {
77
+ this.state = "text";
78
+ safe += this.safeCharacter(character, code);
79
+ }
80
+ continue;
81
+ }
82
+
83
+ if (character === "\u001b") {
84
+ this.state = "escape";
85
+ } else if (character === "\u009b") {
86
+ this.state = "csi";
87
+ } else if (character === "\u009d") {
88
+ this.state = "osc";
89
+ } else if (
90
+ character === "\u0090" ||
91
+ character === "\u0098" ||
92
+ character === "\u009e" ||
93
+ character === "\u009f"
94
+ ) {
95
+ this.state = "st_string";
96
+ } else {
97
+ safe += this.safeCharacter(character, code);
98
+ }
99
+ }
100
+ return safe;
101
+ }
102
+
103
+ private safeCharacter(character: string, code: number) {
104
+ if (character === "\t") return " ";
105
+ if (
106
+ character === "\n" ||
107
+ character === "\u200c" ||
108
+ character === "\u200d"
109
+ ) {
110
+ return character;
111
+ }
112
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return "";
113
+ return UNSAFE_FORMAT_CHARACTER.test(character) ? "" : character;
114
+ }
115
+ }
116
+
23
117
  /** Strip terminal control sequences and direction-spoofing format controls. */
24
118
  export function sanitizeTerminalText(text: string) {
25
- return text
26
- .replace(OSC_PATTERN, "")
27
- .replace(ST_STRING_PATTERN, "")
28
- .replace(CSI_PATTERN, "")
29
- .replace(ESCAPE_PATTERN, "")
30
- .replace(UNSAFE_FORMAT_PATTERN, "")
31
- .replaceAll("\t", " ")
32
- .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
119
+ return new TerminalTextSanitizer().push(text);
33
120
  }
34
121
 
35
122
  /** Reject raw terminal or direction-spoofing controls in display patterns. */