@tt-a1i/openpi 0.1.1 → 0.2.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 (43) hide show
  1. package/README.md +37 -22
  2. package/SETUP.md +8 -6
  3. package/extensions/ask-user/handoff.ts +5 -1
  4. package/extensions/ask-user/index.ts +44 -0
  5. package/extensions/background-terminals/index.ts +118 -29
  6. package/extensions/background-terminals/src/domain.ts +5 -1
  7. package/extensions/background-terminals/src/manager.ts +2 -1
  8. package/extensions/background-terminals/src/prompt.ts +35 -0
  9. package/extensions/background-terminals/src/result-delivery.ts +76 -3
  10. package/extensions/background-terminals/src/ui/tool-result.ts +52 -1
  11. package/extensions/capabilities/index.ts +198 -0
  12. package/extensions/context-pivot/index.ts +21 -0
  13. package/extensions/cron/index.ts +42 -15
  14. package/extensions/execution-convergence/active-evidence.ts +129 -0
  15. package/extensions/execution-convergence/index.ts +442 -0
  16. package/extensions/execution-convergence/workspace-provenance.ts +338 -0
  17. package/extensions/file-search/index.ts +8 -1
  18. package/extensions/file-search/src/binaries.ts +2 -1
  19. package/extensions/git-info/src/runtime.ts +1 -1
  20. package/extensions/goal/controller.ts +2 -1
  21. package/extensions/goal/index.ts +20 -1
  22. package/extensions/plan-mode/index.ts +12 -0
  23. package/extensions/setup/index.ts +93 -7
  24. package/extensions/shared/child-session.ts +40 -4
  25. package/extensions/shared/setup-config.ts +22 -0
  26. package/extensions/shared/setup-episode-state.ts +7 -0
  27. package/extensions/shared/tool-surface.ts +435 -0
  28. package/extensions/subagents/index.ts +15 -0
  29. package/extensions/subagents/src/manager.ts +13 -11
  30. package/extensions/subagents/src/prompt.ts +1 -1
  31. package/extensions/tasks/index.ts +39 -12
  32. package/extensions/ui-customization/footer.ts +6 -1
  33. package/extensions/workflows/graph-projection.ts +6 -4
  34. package/extensions/workflows/index.ts +16 -1
  35. package/extensions/workflows/invocation-ledger.ts +8 -2
  36. package/extensions/workflows/model.ts +5 -1
  37. package/extensions/workflows/prompt.ts +10 -40
  38. package/extensions/workflows/replay-safety.ts +9 -8
  39. package/package.json +10 -10
  40. package/skills/subagents/SKILL.md +6 -0
  41. package/skills/workflows/EXAMPLES.md +58 -0
  42. package/skills/workflows/REFERENCE.md +44 -0
  43. package/skills/workflows/SKILL.md +39 -0
@@ -0,0 +1,338 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ type Origin = "baseline" | "session_created";
5
+
6
+ interface PendingEffects {
7
+ creations: Array<{
8
+ path: string;
9
+ existed: boolean;
10
+ observeOnCommandError: boolean;
11
+ }>;
12
+ removals: string[];
13
+ }
14
+
15
+ interface Options {
16
+ confirmDelete: (paths: readonly string[]) => Promise<boolean>;
17
+ }
18
+
19
+ interface BashAttempt {
20
+ id: string;
21
+ command: string;
22
+ cwd: string;
23
+ }
24
+
25
+ interface WriteAttempt {
26
+ id: string;
27
+ path: string;
28
+ cwd: string;
29
+ }
30
+
31
+ interface ShellInspection {
32
+ creations: string[];
33
+ removals: string[];
34
+ opaqueDestructiveCommand: boolean;
35
+ }
36
+
37
+ const DYNAMIC_PATH = /[*?[$`]/u;
38
+
39
+ function splitShellSegments(command: string) {
40
+ const segments: string[] = [];
41
+ let current = "";
42
+ let quote: "'" | '"' | undefined;
43
+ let escaped = false;
44
+ for (let index = 0; index < command.length; index += 1) {
45
+ const char = command[index] ?? "";
46
+ const next = command[index + 1] ?? "";
47
+ if (escaped) {
48
+ current += char;
49
+ escaped = false;
50
+ continue;
51
+ }
52
+ if (char === "\\" && quote !== "'") {
53
+ current += char;
54
+ escaped = true;
55
+ continue;
56
+ }
57
+ if (quote) {
58
+ current += char;
59
+ if (char === quote) quote = undefined;
60
+ continue;
61
+ }
62
+ if (char === "'" || char === '"') {
63
+ quote = char;
64
+ current += char;
65
+ continue;
66
+ }
67
+ if (
68
+ char === "\n" ||
69
+ char === ";" ||
70
+ (char === "&" && next === "&") ||
71
+ (char === "|" && next === "|")
72
+ ) {
73
+ if (current.trim()) segments.push(current.trim());
74
+ current = "";
75
+ if ((char === "&" || char === "|") && next === char) index += 1;
76
+ continue;
77
+ }
78
+ current += char;
79
+ }
80
+ if (current.trim()) segments.push(current.trim());
81
+ return segments;
82
+ }
83
+
84
+ function shellTokens(segment: string) {
85
+ const tokens: string[] = [];
86
+ let current = "";
87
+ let quote: "'" | '"' | undefined;
88
+ let escaped = false;
89
+ const push = () => {
90
+ if (!current) return;
91
+ tokens.push(current);
92
+ current = "";
93
+ };
94
+ for (const char of segment) {
95
+ if (escaped) {
96
+ current += char;
97
+ escaped = false;
98
+ continue;
99
+ }
100
+ if (char === "\\" && quote !== "'") {
101
+ escaped = true;
102
+ continue;
103
+ }
104
+ if (quote) {
105
+ if (char === quote) quote = undefined;
106
+ else current += char;
107
+ continue;
108
+ }
109
+ if (char === "'" || char === '"') {
110
+ quote = char;
111
+ continue;
112
+ }
113
+ if (/\s/u.test(char)) {
114
+ push();
115
+ continue;
116
+ }
117
+ if (char === ">") {
118
+ push();
119
+ tokens.push(">");
120
+ continue;
121
+ }
122
+ current += char;
123
+ }
124
+ push();
125
+ return tokens;
126
+ }
127
+
128
+ function literalPath(value: string | undefined) {
129
+ if (!value || value === "--" || DYNAMIC_PATH.test(value)) return undefined;
130
+ return value;
131
+ }
132
+
133
+ function inspectShell(command: string): ShellInspection {
134
+ const creations = new Set<string>();
135
+ const removals = new Set<string>();
136
+ let opaqueDestructiveCommand = false;
137
+ for (const segment of splitShellSegments(command)) {
138
+ const tokens = shellTokens(segment);
139
+ const nestedBash = tokens.findIndex(
140
+ (token, index) =>
141
+ token.split("/").at(-1) === "bash" && tokens[index + 1] === "-c",
142
+ );
143
+ if (nestedBash >= 0) {
144
+ const nestedCommand = tokens[nestedBash + 2];
145
+ if (nestedCommand) {
146
+ const nested: ShellInspection = inspectShell(nestedCommand);
147
+ for (const target of nested.creations) creations.add(target);
148
+ for (const target of nested.removals) removals.add(target);
149
+ opaqueDestructiveCommand ||= nested.opaqueDestructiveCommand;
150
+ }
151
+ continue;
152
+ }
153
+ for (let index = 0; index < tokens.length; index += 1) {
154
+ if (tokens[index] !== ">") continue;
155
+ const target = literalPath(tokens[index + 1]);
156
+ if (target) creations.add(target);
157
+ }
158
+
159
+ const executable = tokens[0]?.split("/").at(-1);
160
+ if (executable === "mkdir") {
161
+ const targets: string[] = [];
162
+ let afterOptions = false;
163
+ let supported = true;
164
+ for (const token of tokens.slice(1)) {
165
+ if (!afterOptions && token === "--") {
166
+ afterOptions = true;
167
+ continue;
168
+ }
169
+ if (!afterOptions && (token === "-p" || token === "--parents")) {
170
+ continue;
171
+ }
172
+ if (!afterOptions && token.startsWith("-")) {
173
+ supported = false;
174
+ break;
175
+ }
176
+ const target = literalPath(token);
177
+ if (!target) {
178
+ supported = false;
179
+ break;
180
+ }
181
+ targets.push(target);
182
+ }
183
+ if (supported) {
184
+ for (const target of targets) creations.add(target);
185
+ }
186
+ }
187
+ if (executable !== "rm") continue;
188
+ let sawLiteral = false;
189
+ let afterOptions = false;
190
+ for (const token of tokens.slice(1)) {
191
+ if (!afterOptions && token === "--") {
192
+ afterOptions = true;
193
+ continue;
194
+ }
195
+ if (!afterOptions && token.startsWith("-")) continue;
196
+ const target = literalPath(token);
197
+ if (!target) {
198
+ opaqueDestructiveCommand = true;
199
+ continue;
200
+ }
201
+ sawLiteral = true;
202
+ removals.add(target);
203
+ }
204
+ if (!sawLiteral) opaqueDestructiveCommand = true;
205
+ }
206
+ if (/\brm\b/u.test(command) && removals.size === 0) {
207
+ opaqueDestructiveCommand = true;
208
+ }
209
+ return {
210
+ creations: [...creations],
211
+ removals: [...removals],
212
+ opaqueDestructiveCommand,
213
+ };
214
+ }
215
+
216
+ function containedPath(cwd: string, candidate: string) {
217
+ const absolute = path.resolve(cwd, candidate);
218
+ const relative = path.relative(cwd, absolute);
219
+ if (
220
+ relative === "" ||
221
+ relative.startsWith("..") ||
222
+ path.isAbsolute(relative)
223
+ ) {
224
+ return undefined;
225
+ }
226
+ return { absolute, relative: relative.split(path.sep).join("/") };
227
+ }
228
+
229
+ async function exists(candidate: string) {
230
+ try {
231
+ await lstat(candidate);
232
+ return true;
233
+ } catch (error) {
234
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
235
+ return false;
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ export function createWorkspaceCleanupGuard(options: Options) {
242
+ const origins = new Map<string, Origin>();
243
+ const pending = new Map<string, PendingEffects>();
244
+
245
+ const prepareCreation = async (
246
+ cwd: string,
247
+ candidate: string,
248
+ observeOnCommandError: boolean,
249
+ ) => {
250
+ const contained = containedPath(cwd, candidate);
251
+ if (!contained) return undefined;
252
+ const existed = await exists(contained.absolute);
253
+ if (existed && !origins.has(contained.absolute)) {
254
+ origins.set(contained.absolute, "baseline");
255
+ }
256
+ return { path: contained.absolute, existed, observeOnCommandError };
257
+ };
258
+
259
+ return {
260
+ async beforeWrite(attempt: WriteAttempt) {
261
+ const creation = await prepareCreation(attempt.cwd, attempt.path, false);
262
+ pending.set(attempt.id, {
263
+ creations: creation ? [creation] : [],
264
+ removals: [],
265
+ });
266
+ },
267
+
268
+ async before(attempt: BashAttempt) {
269
+ const inspected = inspectShell(attempt.command);
270
+ const creations: PendingEffects["creations"] = [];
271
+ for (const candidate of inspected.creations) {
272
+ const creation = await prepareCreation(attempt.cwd, candidate, true);
273
+ if (creation) creations.push(creation);
274
+ }
275
+
276
+ const removals: string[] = [];
277
+ const protectedPaths: string[] = [];
278
+ for (const candidate of inspected.removals) {
279
+ const contained = containedPath(attempt.cwd, candidate);
280
+ if (!contained) continue;
281
+ const origin = origins.get(contained.absolute);
282
+ const present = await exists(contained.absolute);
283
+ if (present && !origin) origins.set(contained.absolute, "baseline");
284
+ if (present && origins.get(contained.absolute) !== "session_created") {
285
+ protectedPaths.push(contained.relative);
286
+ }
287
+ removals.push(contained.absolute);
288
+ }
289
+
290
+ if (
291
+ protectedPaths.length > 0 &&
292
+ !(await options.confirmDelete(protectedPaths))
293
+ ) {
294
+ return {
295
+ kind: "block" as const,
296
+ protectedPaths,
297
+ reason: `Blocked cleanup: ${protectedPaths.join(", ")} existed before this agent changed it and is not proven session-created scratch. Retry the cleanup without that path, or obtain explicit user confirmation to delete it.`,
298
+ opaqueDestructiveCommand: inspected.opaqueDestructiveCommand,
299
+ };
300
+ }
301
+
302
+ pending.set(attempt.id, { creations, removals });
303
+ return {
304
+ kind: "allow" as const,
305
+ opaqueDestructiveCommand: inspected.opaqueDestructiveCommand,
306
+ };
307
+ },
308
+
309
+ async after(result: { id: string; isError: boolean }) {
310
+ const effects = pending.get(result.id);
311
+ pending.delete(result.id);
312
+ if (!effects) return;
313
+ for (const creation of effects.creations) {
314
+ if (
315
+ !creation.existed &&
316
+ (!result.isError || creation.observeOnCommandError) &&
317
+ !origins.has(creation.path) &&
318
+ (await exists(creation.path))
319
+ ) {
320
+ origins.set(creation.path, "session_created");
321
+ }
322
+ }
323
+ for (const removed of effects.removals) {
324
+ if (
325
+ origins.get(removed) === "session_created" &&
326
+ !(await exists(removed))
327
+ ) {
328
+ origins.delete(removed);
329
+ }
330
+ }
331
+ },
332
+
333
+ reset() {
334
+ origins.clear();
335
+ pending.clear();
336
+ },
337
+ };
338
+ }
@@ -9,7 +9,7 @@
9
9
  * before executing, and report a clear error if it failed.
10
10
  */
11
11
 
12
- import { NodeServices } from "@effect/platform-node";
12
+ import * as NodeServices from "@effect/platform-node/NodeServices";
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import type {
@@ -53,6 +53,10 @@ import {
53
53
  RG_TOOL_DESCRIPTION,
54
54
  } from "./src/prompt.ts";
55
55
  import { discardCapturedOutput, executeSearchProcess } from "./src/process.ts";
56
+ import {
57
+ OPENPI_TOOL_SURFACE,
58
+ patchOwnedTools,
59
+ } from "../shared/tool-surface.ts";
56
60
 
57
61
  export function makeBinaryInitializers(
58
62
  binDir: string,
@@ -137,6 +141,9 @@ export default function fileSearchTools(pi: ExtensionAPI) {
137
141
  const initializers = makeBinaryInitializers(binDir, target, liveBinaryEnv);
138
142
 
139
143
  pi.on("session_start", async (_event, ctx) => {
144
+ patchOwnedTools(pi, "fileSearch", {
145
+ enable: OPENPI_TOOL_SURFACE.fileSearch.entry,
146
+ });
140
147
  const exit = await Effect.runPromiseExit(
141
148
  Effect.gen(function* () {
142
149
  const initialized = yield* Effect.all(
@@ -12,7 +12,8 @@
12
12
  * network. `liveBinaryEnv` is the real implementation.
13
13
  */
14
14
 
15
- import { NodeHttpClient, NodeServices } from "@effect/platform-node";
15
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
16
+ import * as NodeServices from "@effect/platform-node/NodeServices";
16
17
  import { execFile } from "node:child_process";
17
18
  import { tmpdir } from "node:os";
18
19
  import { dirname, join } from "node:path";
@@ -1,4 +1,4 @@
1
- import { NodeServices } from "@effect/platform-node";
1
+ import * as NodeServices from "@effect/platform-node/NodeServices";
2
2
  import { Cause, Exit, Layer, ManagedRuntime, type Effect } from "effect";
3
3
  import { CommandRunner, CommandRunnerLive } from "./process.ts";
4
4
 
@@ -68,7 +68,8 @@ export function countAssistantTokens(messages: readonly unknown[]) {
68
68
 
69
69
  export function lastAssistantStopReason(messages: readonly unknown[]) {
70
70
  return lastAssistantMessage(messages)?.stopReason as
71
- AssistantStopReason | undefined;
71
+ | AssistantStopReason
72
+ | undefined;
72
73
  }
73
74
 
74
75
  export function isUsageLimitError(messages: readonly unknown[]) {
@@ -6,6 +6,10 @@ import type {
6
6
  import { Text } from "@earendil-works/pi-tui";
7
7
  import { Type } from "typebox";
8
8
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
9
+ import {
10
+ OPENPI_TOOL_SURFACE,
11
+ patchOwnedTools,
12
+ } from "../shared/tool-surface.ts";
9
13
  import { GOAL_CONTINUATION_TYPE, GoalController } from "./controller.ts";
10
14
  import {
11
15
  GOAL_LIMITS,
@@ -88,6 +92,14 @@ export function goalToolResponse(
88
92
 
89
93
  export default function sessionGoal(pi: ExtensionAPI) {
90
94
  const controller = new GoalController(pi);
95
+ const hideLifecycleTools = () =>
96
+ patchOwnedTools(pi, "goal", {
97
+ disable: OPENPI_TOOL_SURFACE.goal.deferred,
98
+ });
99
+ const showLifecycleTools = () =>
100
+ patchOwnedTools(pi, "goal", {
101
+ enable: OPENPI_TOOL_SURFACE.goal.deferred,
102
+ });
91
103
 
92
104
  const updateUi = (ctx: ExtensionContext) => {
93
105
  if (!ctx.hasUI) return;
@@ -130,6 +142,7 @@ export default function sessionGoal(pi: ExtensionAPI) {
130
142
  controller.createId(),
131
143
  );
132
144
  controller.replace(goal);
145
+ showLifecycleTools();
133
146
  const started = controller.kickoff(ctx);
134
147
  return { goal: controller.snapshot() ?? goal, started };
135
148
  };
@@ -325,9 +338,11 @@ export default function sessionGoal(pi: ExtensionAPI) {
325
338
  // contradicts.
326
339
  notify(ctx, goalUpdateMessage(controller.snapshot()!));
327
340
  } else if (parsed.action === "clear") {
341
+ const cleared = controller.clear();
342
+ if (cleared) hideLifecycleTools();
328
343
  notify(
329
344
  ctx,
330
- controller.clear()
345
+ cleared
331
346
  ? "Goal cleared"
332
347
  : "No goal to clear\nThis thread does not currently have a goal.",
333
348
  );
@@ -387,6 +402,8 @@ export default function sessionGoal(pi: ExtensionAPI) {
387
402
 
388
403
  pi.on("session_start", async (event, ctx) => {
389
404
  controller.restore(ctx, event.reason === "fork");
405
+ if (controller.snapshot()) showLifecycleTools();
406
+ else hideLifecycleTools();
390
407
  if (controller.problem()) {
391
408
  notify(
392
409
  ctx,
@@ -419,6 +436,8 @@ export default function sessionGoal(pi: ExtensionAPI) {
419
436
 
420
437
  pi.on("session_tree", (_event, ctx) => {
421
438
  controller.restore(ctx, true);
439
+ if (controller.snapshot()) showLifecycleTools();
440
+ else hideLifecycleTools();
422
441
  updateUi(ctx);
423
442
  });
424
443
 
@@ -36,6 +36,10 @@ import {
36
36
  PLAN_MODE_CHANNEL,
37
37
  type PlanModeState,
38
38
  } from "../shared/plan-mode-state.ts";
39
+ import {
40
+ OPENPI_TOOL_SURFACE,
41
+ patchOwnedTools,
42
+ } from "../shared/tool-surface.ts";
39
43
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
40
44
  import { planBashDecision } from "./bash-policy.ts";
41
45
 
@@ -231,6 +235,12 @@ export function planToolCallDecision(
231
235
  export default function planMode(pi: ExtensionAPI) {
232
236
  let planning = false;
233
237
  let readyPlan: string | undefined;
238
+ const syncPlanTool = () =>
239
+ patchOwnedTools(pi, "plan", {
240
+ ...(planning && !readyPlan
241
+ ? { enable: OPENPI_TOOL_SURFACE.plan.deferred }
242
+ : { disable: OPENPI_TOOL_SURFACE.plan.deferred }),
243
+ });
234
244
 
235
245
  /**
236
246
  * Publish the stance and reflect it in the footer. Every place `planning`
@@ -242,6 +252,7 @@ export default function planMode(pi: ExtensionAPI) {
242
252
  hasUI: boolean;
243
253
  ui: { setStatus: (key: string, value?: string) => void };
244
254
  }) => {
255
+ syncPlanTool();
245
256
  pi.events.emit(PLAN_MODE_CHANNEL, { planning } satisfies PlanModeState);
246
257
  if (!ctx.hasUI) return;
247
258
  ctx.ui.setStatus(
@@ -532,6 +543,7 @@ export default function planMode(pi: ExtensionAPI) {
532
543
  pi.on("session_shutdown", () => {
533
544
  planning = false;
534
545
  readyPlan = undefined;
546
+ syncPlanTool();
535
547
  // Broadcast without a ctx: subagents keeps its own copy of the stance, and
536
548
  // leaving it armed would restrict children in whatever session comes next.
537
549
  pi.events.emit(PLAN_MODE_CHANNEL, { planning } satisfies PlanModeState);