@tt-a1i/openpi 0.5.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 (74) hide show
  1. package/README.md +18 -10
  2. package/SETUP.md +8 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/bin/openpi.js +25 -15
  5. package/extensions/ai-providers/LICENSE.upstream +23 -0
  6. package/extensions/ai-providers/README.md +59 -0
  7. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  8. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  9. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  10. package/extensions/ai-providers/antigravity/models.ts +84 -0
  11. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  12. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  13. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  14. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  15. package/extensions/ai-providers/cursor/constants.ts +5 -0
  16. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  17. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  18. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  19. package/extensions/ai-providers/cursor/models.ts +45 -0
  20. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  21. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  22. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  23. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  24. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  25. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  26. package/extensions/ai-providers/index.ts +86 -0
  27. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  28. package/extensions/ai-providers/usage.ts +10 -0
  29. package/extensions/background-terminals/index.ts +8 -1
  30. package/extensions/background-terminals/src/manager.ts +3 -5
  31. package/extensions/background-terminals/src/result-delivery.ts +43 -23
  32. package/extensions/cron/index.ts +68 -27
  33. package/extensions/cron/schedule.ts +5 -1
  34. package/extensions/model-info/cache-diagnostics.ts +220 -0
  35. package/extensions/model-info/index.ts +45 -1
  36. package/extensions/plan-mode/index.ts +75 -4
  37. package/extensions/setup/index.ts +15 -3
  38. package/extensions/shared/child-session.ts +25 -5
  39. package/extensions/shared/completion-inbox.ts +193 -0
  40. package/extensions/shared/setup-config.ts +10 -1
  41. package/extensions/shared/structured-output.ts +154 -0
  42. package/extensions/subagents/index.ts +44 -4
  43. package/extensions/subagents/src/backends/pi.ts +76 -5
  44. package/extensions/subagents/src/domain.ts +16 -1
  45. package/extensions/subagents/src/manager.ts +5 -0
  46. package/extensions/subagents/src/prompt.ts +17 -3
  47. package/extensions/subagents/src/result-artifact.ts +32 -0
  48. package/extensions/subagents/src/result-delivery.ts +33 -14
  49. package/extensions/ui-customization/footer.ts +16 -5
  50. package/extensions/user-input-fold/index.ts +42 -6
  51. package/extensions/web/index.ts +25 -2
  52. package/extensions/workflows/acceptance.ts +43 -19
  53. package/extensions/workflows/completion-projection.ts +3 -1
  54. package/extensions/workflows/dashboard.ts +8 -0
  55. package/extensions/workflows/index.ts +13 -0
  56. package/extensions/workflows/model.ts +5 -1
  57. package/extensions/workflows/prompt.ts +4 -10
  58. package/extensions/workflows/result-delivery.ts +96 -22
  59. package/extensions/workflows/retention.ts +6 -0
  60. package/extensions/workflows/runner.ts +6 -71
  61. package/package.json +7 -7
  62. package/skills/subagents/REFERENCE.md +3 -2
  63. package/skills/subagents/SKILL.md +1 -0
  64. package/skills/workflows/REFERENCE.md +3 -3
  65. package/skills/workflows/SKILL.md +1 -1
  66. package/web/adapter/pi-adapter.ts +3 -0
  67. package/web/host/pi-coding-agent-entry.ts +162 -0
  68. package/web/host/web-host.ts +330 -50
  69. package/web/protocol/types.ts +5 -0
  70. package/web/runtime/pi-runtime.ts +240 -25
  71. package/web/runtime/types.ts +32 -1
  72. package/web/ui/app.js +343 -41
  73. package/web/ui/index.html +3 -0
  74. package/web/ui/styles.css +119 -37
@@ -0,0 +1,213 @@
1
+ import * as http2 from "node:http2";
2
+ import * as net from "node:net";
3
+ import * as tls from "node:tls";
4
+
5
+ export interface CursorHttp2ConnectOptions {
6
+ signal?: AbortSignal;
7
+ timeoutMs?: number;
8
+ }
9
+
10
+ function isLocalOrMetadataHost(hostname: string): boolean {
11
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
12
+ if (
13
+ host === "localhost" ||
14
+ host.endsWith(".localhost") ||
15
+ host === "metadata.google.internal"
16
+ ) {
17
+ return true;
18
+ }
19
+ if (host === "::" || host === "::1" || /^f[cd]/.test(host)) return true;
20
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\./.exec(host);
21
+ if (!ipv4) return false;
22
+ const first = Number(ipv4[1]);
23
+ const second = Number(ipv4[2]);
24
+ return (
25
+ first === 0 ||
26
+ first === 10 ||
27
+ first === 127 ||
28
+ (first === 169 && second === 254) ||
29
+ (first === 172 && second >= 16 && second <= 31) ||
30
+ (first === 192 && second === 168)
31
+ );
32
+ }
33
+
34
+ function shouldBypassProxy(target: URL): boolean {
35
+ if (isLocalOrMetadataHost(target.hostname)) return true;
36
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy;
37
+ if (!noProxy) return false;
38
+ const targetHost = target.hostname.toLowerCase().replace(/^\[|\]$/g, "");
39
+ const targetPort =
40
+ target.port || (target.protocol === "https:" ? "443" : "80");
41
+ for (const rawRule of noProxy.split(/[,\s]+/)) {
42
+ let rule = rawRule.trim().toLowerCase();
43
+ if (!rule) continue;
44
+ if (rule === "*") return true;
45
+ let rulePort: string | undefined;
46
+ const portMatch = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(rule);
47
+ if (portMatch) {
48
+ rule = portMatch[1]!;
49
+ rulePort = portMatch[2];
50
+ }
51
+ if (rulePort && rulePort !== targetPort) continue;
52
+ rule = rule.replace(/^\[|\]$/g, "").replace(/^\./, "");
53
+ if (targetHost === rule || targetHost.endsWith(`.${rule}`)) return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /** Resolve Cursor's provider override first, then the standard proxy variables. */
59
+ export function resolveCursorProxy(target: URL): string | undefined {
60
+ if (shouldBypassProxy(target)) return undefined;
61
+ const protocolProxy =
62
+ target.protocol === "https:"
63
+ ? process.env.HTTPS_PROXY || process.env.https_proxy
64
+ : process.env.HTTP_PROXY || process.env.http_proxy;
65
+ return [
66
+ process.env.PI_PROXY_CURSOR,
67
+ process.env.PI_PROXY,
68
+ protocolProxy,
69
+ process.env.ALL_PROXY || process.env.all_proxy,
70
+ ]
71
+ .map((value) => value?.trim())
72
+ .find((value): value is string => Boolean(value));
73
+ }
74
+
75
+ function connectProxyTunnel(
76
+ proxyUrl: URL,
77
+ targetUrl: URL,
78
+ options: CursorHttp2ConnectOptions,
79
+ ): Promise<net.Socket> {
80
+ if (!["http:", "https:"].includes(proxyUrl.protocol)) {
81
+ return Promise.reject(
82
+ new Error(`Unsupported Cursor proxy protocol: ${proxyUrl.protocol}`),
83
+ );
84
+ }
85
+ if (options.signal?.aborted) {
86
+ return Promise.reject(new Error("Cursor proxy tunnel aborted"));
87
+ }
88
+ const proxyTls = proxyUrl.protocol === "https:";
89
+ const proxyPort = Number(proxyUrl.port || (proxyTls ? 443 : 80));
90
+ const targetPort = Number(
91
+ targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80),
92
+ );
93
+ const targetAuthority = `${targetUrl.hostname}:${targetPort}`;
94
+ let proxyAuthorization: string | undefined;
95
+ if (proxyUrl.username || proxyUrl.password) {
96
+ try {
97
+ const credentials = `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`;
98
+ proxyAuthorization = Buffer.from(credentials).toString("base64");
99
+ } catch (cause) {
100
+ return Promise.reject(
101
+ new Error("Cursor proxy credentials contain invalid percent-encoding", {
102
+ cause,
103
+ }),
104
+ );
105
+ }
106
+ }
107
+ const { promise, resolve, reject } = Promise.withResolvers<net.Socket>();
108
+ let rawSocket: net.Socket | undefined;
109
+ let targetSocket: net.Socket | undefined;
110
+ let timer: ReturnType<typeof setTimeout> | undefined;
111
+ let response = Buffer.alloc(0);
112
+ let settled = false;
113
+
114
+ const cleanup = () => {
115
+ if (timer) clearTimeout(timer);
116
+ options.signal?.removeEventListener("abort", onAbort);
117
+ rawSocket?.removeListener("error", onError);
118
+ rawSocket?.removeListener(proxyTls ? "secureConnect" : "connect", onReady);
119
+ rawSocket?.removeListener("data", onData);
120
+ targetSocket?.removeListener("error", onError);
121
+ targetSocket?.removeListener("secureConnect", onTargetReady);
122
+ };
123
+ const fail = (error: Error) => {
124
+ if (settled) return;
125
+ settled = true;
126
+ cleanup();
127
+ targetSocket?.destroy();
128
+ rawSocket?.destroy();
129
+ reject(error);
130
+ };
131
+ const succeed = (socket: net.Socket) => {
132
+ if (settled) return;
133
+ settled = true;
134
+ cleanup();
135
+ resolve(socket);
136
+ };
137
+ const onAbort = () => fail(new Error("Cursor proxy tunnel aborted"));
138
+ const onError = (error: Error) => fail(error);
139
+ const onTargetReady = () => {
140
+ if (targetSocket) succeed(targetSocket);
141
+ };
142
+ const onData = (chunk: Buffer) => {
143
+ if (!rawSocket) return;
144
+ response = Buffer.concat([response, chunk]);
145
+ if (response.length > 64 * 1024) {
146
+ fail(new Error("Cursor proxy response headers exceed 64 KiB"));
147
+ return;
148
+ }
149
+ const headerEnd = response.indexOf("\r\n\r\n");
150
+ if (headerEnd === -1) return;
151
+ const statusLine = response
152
+ .subarray(0, headerEnd)
153
+ .toString("latin1")
154
+ .split("\r\n")[0];
155
+ if (!/^HTTP\/1\.[01] 200\b/.test(statusLine ?? "")) {
156
+ fail(
157
+ new Error(
158
+ `Cursor proxy tunnel failed: ${statusLine || "invalid response"}`,
159
+ ),
160
+ );
161
+ return;
162
+ }
163
+ rawSocket.removeListener("data", onData);
164
+ if (targetUrl.protocol !== "https:") {
165
+ succeed(rawSocket);
166
+ return;
167
+ }
168
+ targetSocket = tls.connect({
169
+ socket: rawSocket,
170
+ servername: targetUrl.hostname,
171
+ ALPNProtocols: ["h2"],
172
+ });
173
+ targetSocket.once("error", onError);
174
+ targetSocket.once("secureConnect", onTargetReady);
175
+ };
176
+ const onReady = () => {
177
+ if (!rawSocket) return;
178
+ let request = `CONNECT ${targetAuthority} HTTP/1.1\r\nHost: ${targetAuthority}\r\n`;
179
+ if (proxyAuthorization) {
180
+ request += `Proxy-Authorization: Basic ${proxyAuthorization}\r\n`;
181
+ }
182
+ rawSocket.on("data", onData);
183
+ rawSocket.write(`${request}\r\n`);
184
+ };
185
+
186
+ options.signal?.addEventListener("abort", onAbort, { once: true });
187
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
188
+ const timeoutMs = Math.floor(options.timeoutMs);
189
+ timer = setTimeout(
190
+ () =>
191
+ fail(new Error(`Cursor proxy tunnel timed out after ${timeoutMs}ms`)),
192
+ timeoutMs,
193
+ );
194
+ }
195
+ rawSocket = proxyTls
196
+ ? tls.connect({ host: proxyUrl.hostname, port: proxyPort })
197
+ : net.connect({ host: proxyUrl.hostname, port: proxyPort });
198
+ rawSocket.once("error", onError);
199
+ rawSocket.once(proxyTls ? "secureConnect" : "connect", onReady);
200
+ return promise;
201
+ }
202
+
203
+ /** Open Cursor's HTTP/2 session directly or through an HTTP CONNECT proxy. */
204
+ export async function connectCursorHttp2(
205
+ baseUrl: string,
206
+ options: CursorHttp2ConnectOptions = {},
207
+ ): Promise<http2.ClientHttp2Session> {
208
+ const target = new URL(baseUrl);
209
+ const proxy = resolveCursorProxy(target);
210
+ if (!proxy) return http2.connect(target);
211
+ const socket = await connectProxyTunnel(new URL(proxy), target, options);
212
+ return http2.connect(target, { createConnection: () => socket });
213
+ }
@@ -0,0 +1,12 @@
1
+ /** Node >=22 provides Promise.withResolvers; the repo's ES2022 lib needs a shim. */
2
+ declare global {
3
+ interface PromiseConstructor {
4
+ withResolvers<T>(): {
5
+ promise: Promise<T>;
6
+ resolve: (value: T | PromiseLike<T>) => void;
7
+ reject: (reason?: unknown) => void;
8
+ };
9
+ }
10
+ }
11
+
12
+ export {};
@@ -0,0 +1,86 @@
1
+ /**
2
+ * ai-providers — OAuth-backed model providers for pi.
3
+ *
4
+ * Adds OAuth-backed Google Antigravity and Cursor model providers. Both are
5
+ * inert until the user logs in and selects one of their models. Cursor uses
6
+ * AgentService/Run in deliberately chat-only mode: Cursor-native coding tools
7
+ * are not exposed or executed by this extension.
8
+ *
9
+ * Wire protocol: Cloud Code Assist `v1internal:streamGenerateContent` over
10
+ * SSE (see antigravity/provider.ts). Reference implementation: oh-my-pi's
11
+ * google-gemini-cli provider (shared google-gemini-cli/google-antigravity).
12
+ */
13
+
14
+ import { createProvider, type ProviderStreams } from "@earendil-works/pi-ai";
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { createOAuthAuth } from "./oauth-adapter.ts";
17
+ import { encodeApiKey } from "./antigravity/credentials.ts";
18
+ import { fetchAntigravityModels } from "./antigravity/discovery.ts";
19
+ import {
20
+ ANTIGRAVITY_API_URL,
21
+ ANTIGRAVITY_MODELS,
22
+ } from "./antigravity/models.ts";
23
+ import {
24
+ loginAntigravity,
25
+ refreshAntigravityToken,
26
+ } from "./antigravity/oauth.ts";
27
+ import { streamAntigravity } from "./antigravity/provider.ts";
28
+ import { getCursorApiKey } from "./cursor/credentials.ts";
29
+ import { fetchCursorModels } from "./cursor/discovery.ts";
30
+ import { transformCursorImageInput } from "./cursor/input-images.ts";
31
+ import { CURSOR_MODELS } from "./cursor/models.ts";
32
+ import { loginCursor, refreshCursorToken } from "./cursor/oauth.ts";
33
+ import { streamCursor } from "./cursor/provider.ts";
34
+
35
+ function providerStreams(
36
+ streamSimple: ProviderStreams["streamSimple"],
37
+ ): ProviderStreams {
38
+ return {
39
+ stream: (model, context, options) => streamSimple(model, context, options),
40
+ streamSimple,
41
+ };
42
+ }
43
+
44
+ export default function authProviders(pi: ExtensionAPI) {
45
+ pi.on("input", transformCursorImageInput);
46
+
47
+ pi.registerProvider(
48
+ createProvider({
49
+ id: "google-antigravity",
50
+ name: "Google Antigravity",
51
+ baseUrl: ANTIGRAVITY_API_URL,
52
+ api: providerStreams(streamAntigravity),
53
+ auth: {
54
+ oauth: createOAuthAuth({
55
+ name: "Google (Antigravity)",
56
+ isSubscription: true,
57
+ login: loginAntigravity,
58
+ refreshToken: refreshAntigravityToken,
59
+ getApiKey: encodeApiKey,
60
+ }),
61
+ },
62
+ models: ANTIGRAVITY_MODELS,
63
+ fetchModels: fetchAntigravityModels,
64
+ }),
65
+ );
66
+
67
+ pi.registerProvider(
68
+ createProvider({
69
+ id: "cursor",
70
+ name: "Cursor",
71
+ baseUrl: "https://api2.cursor.sh",
72
+ api: providerStreams(streamCursor),
73
+ auth: {
74
+ oauth: createOAuthAuth({
75
+ name: "Cursor",
76
+ isSubscription: true,
77
+ login: loginCursor,
78
+ refreshToken: refreshCursorToken,
79
+ getApiKey: getCursorApiKey,
80
+ }),
81
+ },
82
+ models: CURSOR_MODELS,
83
+ fetchModels: fetchCursorModels,
84
+ }),
85
+ );
86
+ }
@@ -0,0 +1,81 @@
1
+ import type {
2
+ ModelAuth,
3
+ OAuthAuth,
4
+ OAuthCredential,
5
+ OAuthCredentials,
6
+ OAuthLoginCallbacks,
7
+ ProviderAuthInteraction,
8
+ } from "@earendil-works/pi-ai";
9
+
10
+ interface LegacyOAuthImplementation {
11
+ name: string;
12
+ isSubscription?: boolean;
13
+ login(callbacks: CancellableOAuthLoginCallbacks): Promise<OAuthCredentials>;
14
+ refreshToken(
15
+ credential: OAuthCredentials,
16
+ signal: AbortSignal,
17
+ ): Promise<OAuthCredentials>;
18
+ getApiKey(credential: OAuthCredentials): string | Promise<string>;
19
+ }
20
+
21
+ export type CancellableOAuthLoginCallbacks = Omit<
22
+ OAuthLoginCallbacks,
23
+ "onManualCodeInput"
24
+ > & {
25
+ onManualCodeInput?(signal?: AbortSignal): Promise<string>;
26
+ };
27
+
28
+ function legacyCallbacks(
29
+ interaction: ProviderAuthInteraction,
30
+ ): CancellableOAuthLoginCallbacks {
31
+ return {
32
+ signal: interaction.signal,
33
+ onAuth: (info) => interaction.notify({ type: "auth_url", ...info }),
34
+ onDeviceCode: (info) =>
35
+ interaction.notify({ type: "device_code", ...info }),
36
+ onProgress: (message) => interaction.notify({ type: "progress", message }),
37
+ onPrompt: (prompt) =>
38
+ interaction.prompt({
39
+ type: "text",
40
+ message: prompt.message,
41
+ placeholder: prompt.placeholder,
42
+ }),
43
+ onManualCodeInput: (signal) =>
44
+ interaction.prompt({
45
+ type: "manual_code",
46
+ message: "Paste the authorization callback URL or code",
47
+ signal,
48
+ }),
49
+ onSelect: (prompt) =>
50
+ interaction.prompt({
51
+ type: "select",
52
+ message: prompt.message,
53
+ options: prompt.options,
54
+ }),
55
+ };
56
+ }
57
+
58
+ function canonicalCredential(credentials: OAuthCredentials): OAuthCredential {
59
+ return { ...credentials, type: "oauth" };
60
+ }
61
+
62
+ /** Adapt pi's retained extension OAuth callbacks to the native Provider API. */
63
+ export function createOAuthAuth(
64
+ implementation: LegacyOAuthImplementation,
65
+ ): OAuthAuth {
66
+ return {
67
+ name: implementation.name,
68
+ isSubscription: implementation.isSubscription,
69
+ login: async (interaction) =>
70
+ canonicalCredential(
71
+ await implementation.login(legacyCallbacks(interaction)),
72
+ ),
73
+ refresh: async (credential, signal) =>
74
+ canonicalCredential(
75
+ await implementation.refreshToken(credential, signal),
76
+ ),
77
+ toAuth: async (credential): Promise<ModelAuth> => ({
78
+ apiKey: await implementation.getApiKey(credential),
79
+ }),
80
+ };
81
+ }
@@ -0,0 +1,10 @@
1
+ export function emptyUsage() {
2
+ return {
3
+ input: 0,
4
+ output: 0,
5
+ cacheRead: 0,
6
+ cacheWrite: 0,
7
+ totalTokens: 0,
8
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
9
+ };
10
+ }
@@ -32,6 +32,7 @@ import {
32
32
  OPENPI_TOOL_SURFACE,
33
33
  patchOwnedTools,
34
34
  } from "../shared/tool-surface.ts";
35
+ import { completionOwnerFor } from "../shared/completion-inbox.ts";
35
36
  import {
36
37
  projectBackgroundTerminalCapability,
37
38
  registerWebCapability,
@@ -105,7 +106,12 @@ export default function (pi: ExtensionAPI) {
105
106
  let ui: ExtensionUIContext | undefined;
106
107
  let unsubStatus: (() => void) | undefined;
107
108
  let startReservations = 0;
108
- const resultDelivery = createDeferredResultDelivery<TerminalSnapshot>();
109
+ const resultDelivery = createDeferredResultDelivery<TerminalSnapshot>({
110
+ owner: () =>
111
+ sessionContext
112
+ ? completionOwnerFor(sessionContext.sessionManager)
113
+ : undefined,
114
+ });
109
115
  const hideLifecycleTools = () =>
110
116
  patchOwnedTools(pi, "background", {
111
117
  disable: OPENPI_TOOL_SURFACE.background.deferred,
@@ -245,6 +251,7 @@ export default function (pi: ExtensionAPI) {
245
251
  const flushResults = (wake: boolean) => {
246
252
  const snaps = resultDelivery.drain(MAX_RUNNING);
247
253
  if (!deliverResults(snaps, wake)) resultDelivery.restore(snaps);
254
+ else resultDelivery.acknowledge(snaps);
248
255
  };
249
256
 
250
257
  const idleResultBatcher = createIdleResultBatcher({
@@ -395,11 +395,9 @@ export async function signalWindowsProcessTree(
395
395
  : attempt.outcome === "timed_out"
396
396
  ? `taskkill timed out after ${attempt.timeoutMs}ms; helper ${attempt.helperClosed ? "closed after SIGKILL" : `did not close within an additional ${attempt.helperCloseTimeoutMs}ms`}`
397
397
  : `taskkill exited ${attempt.exitCode ?? "without a code"}${attempt.signal ? ` (${attempt.signal})` : ""}`;
398
- if (
399
- attempt.outcome === "timed_out" &&
400
- !attempt.helperClosed &&
401
- !targetExited()
402
- ) {
398
+ // Closing the helper or observing the shell exit does not prove that all
399
+ // descendants exited. Preserve uncertainty instead of killing only the shell.
400
+ if (attempt.outcome === "timed_out") {
403
401
  return { outcome: "unresolved", detail };
404
402
  }
405
403
  // A failed graceful taskkill must leave the shell PID alive for the
@@ -1,44 +1,64 @@
1
1
  import type { ConsumableResultDeliveryQueue } from "../../shared/result-delivery.ts";
2
+ import {
3
+ type CompletionOwner,
4
+ createCompletionInbox,
5
+ } from "../../shared/completion-inbox.ts";
2
6
 
3
7
  /**
4
- * Deferred one-shot delivery map (same semantics as subagents'): a settled
5
- * terminal's result is held here until it is either drained into a follow-up
6
- * message or consumed by a tool call (bg_kill / bg_status) that already
7
- * returned the settlement itself. Keyed by id, so double delivery is
8
- * 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.
9
13
  */
10
- export function createDeferredResultDelivery<T extends { id: string }>() {
11
- 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 }));
12
19
 
13
20
  const queue = {
14
21
  defer(result: T) {
15
- pending.set(result.id, result);
16
- 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();
17
36
  },
18
37
  consume(ids: Iterable<string>) {
19
- for (const id of ids) pending.delete(id);
38
+ inbox.consume("background", ids);
20
39
  },
21
40
  drain(maxResults = Number.POSITIVE_INFINITY) {
22
- const results: T[] = [];
23
- for (const [id, result] of pending) {
24
- if (results.length >= maxResults) break;
25
- results.push(result);
26
- pending.delete(id);
27
- }
28
- return results;
41
+ return inbox
42
+ .claim(owner(), maxResults)
43
+ .map((envelope) => envelope.payload);
29
44
  },
30
45
  restore(results: readonly T[]) {
31
- const current = [...pending.values()];
32
- pending.clear();
33
- for (const result of results) pending.set(result.id, result);
34
- 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}`));
35
54
  },
36
55
  size() {
37
- return pending.size;
56
+ return inbox.size();
38
57
  },
39
58
  clear() {
40
- pending.clear();
59
+ inbox.clear();
41
60
  },
61
+ inspectDeadLetters: inbox.inspectDeadLetters,
42
62
  };
43
63
  return queue satisfies ConsumableResultDeliveryQueue<T>;
44
64
  }
@@ -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();