@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
@@ -13,13 +13,17 @@
13
13
  */
14
14
 
15
15
  import type { OutputView } from "./domain.ts";
16
+ import { TerminalTextSanitizer } from "../../shared/terminal-text.ts";
16
17
 
17
18
  export class OutputBuffer {
18
19
  private chunks: string[] = [];
20
+ private modelSafeChunks: string[] = [];
19
21
  /** Bytes currently retained across `chunks`. */
20
22
  private retainedBytes = 0;
23
+ private modelSafeRetainedBytes = 0;
21
24
  /** Cached join of `chunks`; invalidated on push so 1Hz UI ticks are cheap. */
22
25
  private cachedText: string | undefined = "";
26
+ private cachedModelSafeText: string | undefined = "";
23
27
  /** Bumped on every push; lets the UI cache derived line layouts. */
24
28
  version = 0;
25
29
  totalBytes = 0;
@@ -28,6 +32,7 @@ export class OutputBuffer {
28
32
 
29
33
  private readonly maxRetainedBytes: number;
30
34
  private readonly spill?: (chunk: string) => unknown;
35
+ private readonly modelSanitizer = new TerminalTextSanitizer();
31
36
 
32
37
  constructor(maxRetainedBytes: number, spill?: (chunk: string) => unknown) {
33
38
  this.maxRetainedBytes = maxRetainedBytes;
@@ -36,6 +41,7 @@ export class OutputBuffer {
36
41
 
37
42
  push(chunk: string) {
38
43
  if (chunk.length === 0) return true;
44
+ this.retainModelSafeText(this.modelSanitizer.push(chunk));
39
45
  let bytes = Buffer.byteLength(chunk, "utf8");
40
46
  this.totalBytes += bytes;
41
47
  const spillAccepted = this.spill?.(chunk) !== false;
@@ -74,11 +80,38 @@ export class OutputBuffer {
74
80
 
75
81
  view(): OutputView {
76
82
  this.cachedText ??= this.chunks.join("");
83
+ this.cachedModelSafeText ??= this.modelSafeChunks.join("");
77
84
  return {
78
85
  text: this.cachedText,
86
+ modelSafeText: this.cachedModelSafeText,
79
87
  totalBytes: this.totalBytes,
80
88
  truncatedBytes: this.truncatedBytes,
81
89
  spillPath: this.spillPath,
82
90
  };
83
91
  }
92
+
93
+ private retainModelSafeText(text: string) {
94
+ if (text.length === 0) return;
95
+ let bytes = Buffer.byteLength(text, "utf8");
96
+ if (bytes > this.maxRetainedBytes) {
97
+ this.modelSafeChunks = [];
98
+ this.modelSafeRetainedBytes = 0;
99
+ const raw = Buffer.from(text, "utf8");
100
+ let start = raw.length - this.maxRetainedBytes;
101
+ while (start < raw.length && (raw[start] & 0xc0) === 0x80) start++;
102
+ text = raw.subarray(start).toString("utf8");
103
+ bytes = raw.length - start;
104
+ }
105
+ this.modelSafeChunks.push(text);
106
+ this.modelSafeRetainedBytes += bytes;
107
+ while (
108
+ this.modelSafeRetainedBytes > this.maxRetainedBytes &&
109
+ this.modelSafeChunks.length > 1
110
+ ) {
111
+ const evicted = this.modelSafeChunks.shift();
112
+ if (evicted === undefined) break;
113
+ this.modelSafeRetainedBytes -= Buffer.byteLength(evicted, "utf8");
114
+ }
115
+ this.cachedModelSafeText = undefined;
116
+ }
84
117
  }
@@ -6,6 +6,7 @@ import {
6
6
  formatSize,
7
7
  truncateTail,
8
8
  } from "@earendil-works/pi-coding-agent";
9
+ import { sanitizeTerminalText } from "../../shared/terminal-text.ts";
9
10
  import {
10
11
  formatDuration,
11
12
  formatElapsed,
@@ -139,7 +140,7 @@ function outputSection(
139
140
  maxLines: number,
140
141
  ) {
141
142
  if (view.totalBytes === 0) return `${label}: (empty)`;
142
- const truncation = truncateTail(view.text, {
143
+ const truncation = truncateTail(view.modelSafeText, {
143
144
  maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
144
145
  maxLines: Math.min(maxLines, DEFAULT_MAX_LINES),
145
146
  });
@@ -186,12 +187,15 @@ export function buildTerminalBatchResultMessage(
186
187
  messages: readonly string[],
187
188
  omitted = 0,
188
189
  ) {
189
- if (messages.length === 1 && omitted === 0) return messages[0]!;
190
- const summaries = messages.map(
190
+ const sanitizedMessages = messages.map(sanitizeTerminalText);
191
+ if (sanitizedMessages.length === 1 && omitted === 0) {
192
+ return sanitizedMessages[0]!;
193
+ }
194
+ const summaries = sanitizedMessages.map(
191
195
  (message) => message.split("\n", 1)[0] || "Background terminal result",
192
196
  );
193
197
  const header = [
194
- `${messages.length} background terminal result${messages.length === 1 ? "" : "s"}:`,
198
+ `${sanitizedMessages.length} background terminal result${sanitizedMessages.length === 1 ? "" : "s"}:`,
195
199
  ...summaries.map((summary) => `- ${summary}`),
196
200
  omitted > 0
197
201
  ? `- ${omitted} older result${omitted === 1 ? "" : "s"} omitted from this bounded batch; use bg_list/bg_status for retained details.`
@@ -206,7 +210,7 @@ export function buildTerminalBatchResultMessage(
206
210
  `${header}${logsHeader}${truncationMarker}`,
207
211
  "utf8",
208
212
  );
209
- const logs = truncateTail(messages.join("\n\n"), {
213
+ const logs = truncateTail(sanitizedMessages.join("\n\n"), {
210
214
  maxBytes: Math.max(1, RESULT_BATCH_MAX - fixedBytes),
211
215
  maxLines: DEFAULT_MAX_LINES,
212
216
  });
@@ -220,6 +224,10 @@ export function buildKillReport(results: ReadonlyArray<KillResult>) {
220
224
  if (entry.killed) {
221
225
  return `Killed ${entry.id} "${entry.title}" (${entry.exit}).`;
222
226
  }
227
+ if (entry.terminationFailed) {
228
+ const detail = entry.errorText ? ` ${entry.errorText}.` : "";
229
+ return `Could not confirm process-tree termination for ${entry.id} "${entry.title}" (${entry.exit}).${detail}`;
230
+ }
223
231
  if (entry.wasRunning) {
224
232
  // The natural exit won the race with the kill signal.
225
233
  return `${entry.id} "${entry.title}" exited on its own before the kill landed (${entry.exit}).`;
@@ -1,43 +1,66 @@
1
+ import type { ConsumableResultDeliveryQueue } from "../../shared/result-delivery.ts";
2
+ import {
3
+ type CompletionOwner,
4
+ createCompletionInbox,
5
+ } from "../../shared/completion-inbox.ts";
6
+
1
7
  /**
2
- * Deferred one-shot delivery map (same semantics as subagents'): a settled
3
- * terminal's result is held here until it is either drained into a follow-up
4
- * message or consumed by a tool call (bg_kill / bg_status) that already
5
- * returned the settlement itself. Keyed by id, so double delivery is
6
- * structurally impossible — whoever drains first wins.
8
+ * Deferred one-shot delivery adapter (same semantics as subagents'): a
9
+ * settled terminal's result is held in the shared inbox until it is either
10
+ * drained into a follow-up message or consumed by a tool call (bg_kill /
11
+ * bg_status) that already returned the settlement itself. Stable ids make
12
+ * double delivery structurally impossible — whoever claims first wins.
7
13
  */
8
- export function createDeferredResultDelivery<T extends { id: string }>() {
9
- const pending = new Map<string, T>();
14
+ export function createDeferredResultDelivery<T extends { id: string }>(
15
+ options: { readonly owner?: () => CompletionOwner | undefined } = {},
16
+ ) {
17
+ const inbox = createCompletionInbox<T>();
18
+ const owner = options.owner ?? (() => ({ sessionId: "test", epoch: 0 }));
10
19
 
11
- return {
20
+ const queue = {
12
21
  defer(result: T) {
13
- pending.set(result.id, result);
14
- return pending.size;
22
+ const currentOwner = owner();
23
+ inbox.defer(
24
+ {
25
+ deliveryId: `background:${result.id}`,
26
+ owner: currentOwner ?? { sessionId: "unowned", epoch: 0 },
27
+ producer: "background",
28
+ producerId: result.id,
29
+ terminalRef: { kind: "terminal-snapshot", id: result.id },
30
+ wake: "producer-policy",
31
+ payload: result,
32
+ },
33
+ currentOwner,
34
+ );
35
+ return inbox.size();
15
36
  },
16
37
  consume(ids: Iterable<string>) {
17
- for (const id of ids) pending.delete(id);
38
+ inbox.consume("background", ids);
18
39
  },
19
40
  drain(maxResults = Number.POSITIVE_INFINITY) {
20
- const results: T[] = [];
21
- for (const [id, result] of pending) {
22
- if (results.length >= maxResults) break;
23
- results.push(result);
24
- pending.delete(id);
25
- }
26
- return results;
41
+ return inbox
42
+ .claim(owner(), maxResults)
43
+ .map((envelope) => envelope.payload);
27
44
  },
28
45
  restore(results: readonly T[]) {
29
- const current = [...pending.values()];
30
- pending.clear();
31
- for (const result of results) pending.set(result.id, result);
32
- for (const result of current) pending.set(result.id, result);
46
+ inbox.retryClaimed(
47
+ "background",
48
+ results.map((result) => result.id),
49
+ owner(),
50
+ );
51
+ },
52
+ acknowledge(results: readonly T[]) {
53
+ inbox.acknowledge(results.map((result) => `background:${result.id}`));
33
54
  },
34
55
  size() {
35
- return pending.size;
56
+ return inbox.size();
36
57
  },
37
58
  clear() {
38
- pending.clear();
59
+ inbox.clear();
39
60
  },
61
+ inspectDeadLetters: inbox.inspectDeadLetters,
40
62
  };
63
+ return queue satisfies ConsumableResultDeliveryQueue<T>;
41
64
  }
42
65
 
43
66
  /**
@@ -0,0 +1,83 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionContext,
4
+ type KeybindingsManager,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Key, matchesKey, type EditorComponent } from "@earendil-works/pi-tui";
7
+ import {
8
+ BelowEditorNavigationEditor,
9
+ BelowEditorStripState,
10
+ } from "../shared/below-editor-navigation.ts";
11
+ import {
12
+ registerEditorLayer,
13
+ removeEditorLayer,
14
+ } from "../shared/editor-layers.ts";
15
+
16
+ export function shouldClearContext(
17
+ data: string,
18
+ editorText: string,
19
+ isIdle: boolean,
20
+ ) {
21
+ return isIdle && editorText.length === 0 && matchesKey(data, Key.ctrl("c"));
22
+ }
23
+
24
+ export class ClearContextEditor extends BelowEditorNavigationEditor {
25
+ private readonly isIdle: () => boolean;
26
+
27
+ constructor(
28
+ base: EditorComponent,
29
+ keybindings: KeybindingsManager,
30
+ isIdle: () => boolean,
31
+ ) {
32
+ super(
33
+ base,
34
+ keybindings,
35
+ new BelowEditorStripState(),
36
+ () => false,
37
+ () => undefined,
38
+ () => undefined,
39
+ );
40
+ this.isIdle = isIdle;
41
+ }
42
+
43
+ override handleInput(data: string) {
44
+ if (shouldClearContext(data, this.getText(), this.isIdle())) {
45
+ // Route through Pi's built-in /new command so session replacement keeps
46
+ // Pi's persistence, cleanup, and transcript lifecycle as the source of truth.
47
+ this.setText("/new");
48
+ this.onSubmit?.("/new");
49
+ return;
50
+ }
51
+
52
+ super.handleInput(data);
53
+ }
54
+ }
55
+
56
+ function installClearContextShortcut(pi: ExtensionAPI, ctx: ExtensionContext) {
57
+ if (ctx.mode !== "tui") return () => {};
58
+
59
+ registerEditorLayer(pi, ctx, {
60
+ id: "clear-context",
61
+ order: 100,
62
+ wrap: (base, _tui, _theme, keybindings) =>
63
+ new ClearContextEditor(base, keybindings, () => ctx.isIdle()),
64
+ });
65
+
66
+ return () => removeEditorLayer(pi, "clear-context");
67
+ }
68
+
69
+ export default function clearContext(pi: ExtensionAPI) {
70
+ let removeShortcut = () => {};
71
+
72
+ pi.on("session_start", (_event, ctx) => {
73
+ removeShortcut();
74
+ removeShortcut = installClearContextShortcut(pi, ctx);
75
+ });
76
+
77
+ pi.on("session_shutdown", () => {
78
+ removeShortcut();
79
+ removeShortcut = () => {};
80
+ });
81
+ }
82
+
83
+ export { installClearContextShortcut };
@@ -11,6 +11,9 @@ import {
11
11
 
12
12
  export const MIN_CONTEXT_PIVOT_TOKENS = 30_000;
13
13
  const STATUS_KEY = "context-pivot";
14
+ const NOTHING_TO_COMPACT_ERROR = "Nothing to compact (session too small)";
15
+ const NO_DISCARDABLE_HISTORY_MESSAGE =
16
+ "Context pivot could not run: this session has no discardable history to compact. Continue in the current session, or use /sessions to choose another session; to begin cleanly, start a new Session in Pi.";
14
17
 
15
18
  interface PendingPivot {
16
19
  brief: string;
@@ -57,6 +60,13 @@ export function buildPivotSummary(brief: string): string {
57
60
  ].join("\n");
58
61
  }
59
62
 
63
+ function formatContextPivotError(error: unknown): string {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ return message === NOTHING_TO_COMPACT_ERROR
66
+ ? NO_DISCARDABLE_HISTORY_MESSAGE
67
+ : `Context pivot failed: ${message}`;
68
+ }
69
+
60
70
  function impossibleKeptId(entries: readonly SessionEntry[]) {
61
71
  return `${entries.at(-1)?.id ?? "context-pivot"}-context-pivot-cut`;
62
72
  }
@@ -73,7 +83,7 @@ function validateBrief(brief: string, ctx: ExtensionContext) {
73
83
  }
74
84
  if (tokens < MIN_CONTEXT_PIVOT_TOKENS) {
75
85
  throw new Error(
76
- `Context is only ${Math.round(tokens).toLocaleString()} tokens; use context_pivot once context reaches at least ${MIN_CONTEXT_PIVOT_TOKENS.toLocaleString()} tokens, or /handoff for a genuinely new session.`,
86
+ `Context is only ${Math.round(tokens).toLocaleString()} tokens; use context_pivot once context reaches at least ${MIN_CONTEXT_PIVOT_TOKENS.toLocaleString()} tokens, or use /sessions to browse or switch an existing session; start a new Session in Pi when a clean session is needed.`,
77
87
  );
78
88
  }
79
89
  }
@@ -127,7 +137,7 @@ export default function contextPivot(pi: ExtensionAPI) {
127
137
  name: "context_pivot",
128
138
  label: "Context Pivot",
129
139
  description:
130
- "Deliberately replace a long, noisy active context with a concise brief for the next phase while staying in the same Pi session. Use once context is at least 30k tokens and the work is moving between phases such as research → implementation or implementation → review; below 30k it is rejected. Use /handoff instead for a genuinely new session.",
140
+ "Deliberately replace a long, noisy active context with a concise brief for the next phase while staying in the same Pi session. Use once context is at least 30k tokens and the work is moving between phases such as research → implementation or implementation → review; below 30k it is rejected. Use /sessions to browse or switch an existing session; start a new Session in Pi when a genuinely new session is needed.",
131
141
  promptSnippet:
132
142
  "Compress a long current session into a clean brief before changing phase",
133
143
  promptGuidelines: [
@@ -176,11 +186,11 @@ export default function contextPivot(pi: ExtensionAPI) {
176
186
  onError: (error) => {
177
187
  if (pivotGeneration === generation) pending = undefined;
178
188
  clear();
189
+ const message = formatContextPivotError(error);
179
190
  if (ctx.hasUI) {
180
- ctx.ui.notify(
181
- `Context pivot failed: ${error instanceof Error ? error.message : String(error)}`,
182
- "error",
183
- );
191
+ ctx.ui.notify(message, "error");
192
+ } else {
193
+ console.error(message);
184
194
  }
185
195
  },
186
196
  });
@@ -17,6 +17,9 @@ import type {
17
17
  } from "@earendil-works/pi-coding-agent";
18
18
  import {
19
19
  advanceDeliveredJobs,
20
+ CRON_DELIVERY_MAX_BYTES,
21
+ CRON_DELIVERY_MAX_JOBS,
22
+ CRON_MAX_JOBS,
20
23
  type CronJob,
21
24
  dueJobs,
22
25
  formatInterval,
@@ -41,6 +44,48 @@ const SYSTEM_RUNTIME: CronRuntime = {
41
44
  },
42
45
  };
43
46
 
47
+ function deliveryMessage(due: readonly CronJob[]) {
48
+ const jobs = due.map((job) => ({
49
+ id: job.id,
50
+ prompt: job.prompt,
51
+ recurring: job.intervalMs !== undefined,
52
+ }));
53
+ return due.length === 1
54
+ ? {
55
+ customType: "cron-fire",
56
+ content: `[cron ${jobs[0]!.id} · ${jobs[0]!.recurring ? "recurring" : "once"}]\n${jobs[0]!.prompt}`,
57
+ display: true,
58
+ details: jobs[0]!,
59
+ }
60
+ : {
61
+ customType: "cron-fire",
62
+ content: `${due.length} scheduled prompts are due:\n\n${jobs
63
+ .map(
64
+ (job) =>
65
+ `[cron ${job.id} · ${job.recurring ? "recurring" : "once"}]\n${job.prompt}`,
66
+ )
67
+ .join("\n\n")}`,
68
+ display: true,
69
+ details: { count: jobs.length, jobs },
70
+ };
71
+ }
72
+
73
+ function dueDeliveryBatch(due: readonly CronJob[]) {
74
+ const selected: CronJob[] = [];
75
+ for (const job of due.slice(0, CRON_DELIVERY_MAX_JOBS)) {
76
+ const candidate = [...selected, job];
77
+ const message = deliveryMessage(candidate);
78
+ if (
79
+ new TextEncoder().encode(message.content).byteLength >
80
+ CRON_DELIVERY_MAX_BYTES
81
+ ) {
82
+ break;
83
+ }
84
+ selected.push(job);
85
+ }
86
+ return selected;
87
+ }
88
+
44
89
  export default function cron(
45
90
  pi: ExtensionAPI,
46
91
  runtime: CronRuntime = SYSTEM_RUNTIME,
@@ -58,30 +103,7 @@ export default function cron(
58
103
  const fire = (due: readonly CronJob[]) => {
59
104
  if (due.length === 0) return true;
60
105
  try {
61
- const jobs = due.map((job) => ({
62
- id: job.id,
63
- prompt: job.prompt,
64
- recurring: job.intervalMs !== undefined,
65
- }));
66
- const message =
67
- due.length === 1
68
- ? {
69
- customType: "cron-fire",
70
- content: `[cron ${jobs[0]!.id} · ${jobs[0]!.recurring ? "recurring" : "once"}]\n${jobs[0]!.prompt}`,
71
- display: true,
72
- details: jobs[0],
73
- }
74
- : {
75
- customType: "cron-fire",
76
- content: `${due.length} scheduled prompts are due:\n\n${jobs
77
- .map(
78
- (job) =>
79
- `[cron ${job.id} · ${job.recurring ? "recurring" : "once"}]\n${job.prompt}`,
80
- )
81
- .join("\n\n")}`,
82
- display: true,
83
- details: { count: jobs.length, jobs },
84
- };
106
+ const message = deliveryMessage(due);
85
107
  pi.sendMessage<
86
108
  | { id: number; prompt: string; recurring: boolean }
87
109
  | {
@@ -109,9 +131,11 @@ export default function cron(
109
131
  const now = runtime.now();
110
132
  const due = dueJobs(jobs, now);
111
133
  if (due.length === 0) return;
134
+ const batch = dueDeliveryBatch(due);
135
+ if (batch.length === 0) return;
112
136
  const deliveredIds = new Set<number>();
113
- if (fire(due)) {
114
- for (const job of due) deliveredIds.add(job.id);
137
+ if (fire(batch)) {
138
+ for (const job of batch) deliveredIds.add(job.id);
115
139
  }
116
140
  jobs = advanceDeliveredJobs(jobs, deliveredIds, runtime.now());
117
141
  if (jobs.length === 0) stopTicker();
@@ -170,12 +194,29 @@ export default function cron(
170
194
  return;
171
195
  }
172
196
 
197
+ if (jobs.length >= CRON_MAX_JOBS) {
198
+ ctx.ui.notify(
199
+ `A session can have at most ${CRON_MAX_JOBS} scheduled prompts. Remove one before adding another.`,
200
+ "warning",
201
+ );
202
+ return;
203
+ }
204
+
173
205
  const intervalMs = parsed.intervalMs!;
206
+ const now = runtime.now();
207
+ const nextRunAt = now + intervalMs;
208
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(nextRunAt)) {
209
+ ctx.ui.notify(
210
+ "Scheduled time is too far in the future. Use a shorter duration.",
211
+ "warning",
212
+ );
213
+ return;
214
+ }
174
215
  const job: CronJob = {
175
216
  id: nextId++,
176
217
  prompt: parsed.prompt!,
177
218
  ...(parsed.oneShot ? {} : { intervalMs }),
178
- nextRunAt: runtime.now() + intervalMs,
219
+ nextRunAt,
179
220
  };
180
221
  jobs.push(job);
181
222
  startTicker();
@@ -22,6 +22,9 @@ const UNITS: Record<string, number> = {
22
22
 
23
23
  export const MIN_INTERVAL_MS = 30_000;
24
24
  export const CRON_PROMPT_MAX_CHARS = 2_000;
25
+ export const CRON_MAX_JOBS = 64;
26
+ export const CRON_DELIVERY_MAX_JOBS = 16;
27
+ export const CRON_DELIVERY_MAX_BYTES = 48 * 1024;
25
28
 
26
29
  /**
27
30
  * Parse a duration like `30s`, `5m`, `2h`. Deliberately a small duration
@@ -33,7 +36,8 @@ export function parseDuration(text: string): number | undefined {
33
36
  if (!match) return undefined;
34
37
  const value = Number(match[1]);
35
38
  if (!Number.isFinite(value) || value <= 0) return undefined;
36
- return value * UNITS[match[2].toLowerCase()];
39
+ const durationMs = value * UNITS[match[2].toLowerCase()];
40
+ return Number.isSafeInteger(durationMs) ? durationMs : undefined;
37
41
  }
38
42
 
39
43
  export interface ParsedCronCommand {
@@ -81,8 +85,14 @@ export function parseCronCommand(raw: string): ParsedCronCommand {
81
85
  error: `Minimum interval is ${MIN_INTERVAL_MS / 1000}s (the scheduler polls about that often).`,
82
86
  };
83
87
  }
84
- const prompt = schedule[3].trim().slice(0, CRON_PROMPT_MAX_CHARS);
88
+ const prompt = schedule[3].trim();
85
89
  if (!prompt) return { action: "help", error: "Provide a prompt to run." };
90
+ if (prompt.length > CRON_PROMPT_MAX_CHARS) {
91
+ return {
92
+ action: "help",
93
+ error: `Prompt is too long. Maximum is ${CRON_PROMPT_MAX_CHARS} characters.`,
94
+ };
95
+ }
86
96
  return {
87
97
  action: "add",
88
98
  intervalMs,