pi-roundtable 0.7.15 → 0.7.16

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.
@@ -1,7 +1,17 @@
1
- import { homedir } from "node:os";
2
- import { basename, isAbsolute, relative, resolve } from "node:path";
3
- import type { HoldRule } from "../../holds.ts";
1
+ import { basename } from "node:path";
2
+ import type { HoldContext, HoldRule } from "../../holds.ts";
4
3
  import { assistantName } from "../../i18n/index.ts";
4
+ import { ownPush, type PushPolicy } from "./git-push.ts";
5
+ import {
6
+ follow,
7
+ insideRoots,
8
+ type LineState,
9
+ lineState,
10
+ resolvePath,
11
+ rmHeld,
12
+ } from "./shell-paths.ts";
13
+
14
+ export type { PushPolicy } from "./git-push.ts";
5
15
 
6
16
  const MAX_SHOWN_COMMAND = 600;
7
17
 
@@ -18,7 +28,6 @@ const ALWAYS_HELD = new Set([
18
28
  "sudo",
19
29
  "su",
20
30
  "doas",
21
- "rm",
22
31
  "dd",
23
32
  "shutdown",
24
33
  "reboot",
@@ -76,15 +85,17 @@ const WRAPPERS = new Set([
76
85
  "xargs",
77
86
  ]);
78
87
 
79
- /** A path is inside the workspace when it resolves under it; `~` means the service user's home. */
80
- function insideWorkspace(path: string, workspace: string): boolean {
81
- const expanded =
82
- path === "~" || path.startsWith("~/")
83
- ? `${homedir()}${path.slice(1)}`
84
- : path;
85
- const target = resolve(workspace, expanded);
86
- const rel = relative(resolve(workspace), target);
87
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
88
+ /**
89
+ * Whether a write to the word stays inside a scratch root. A word the line cannot resolve is read
90
+ * as written from the workspace, as before variables were followed.
91
+ */
92
+ function writable(word: string, state: LineState): boolean {
93
+ if (word === "/dev/null") return true;
94
+ const path =
95
+ resolvePath(word, state) ??
96
+ resolvePath(word, { ...state, vars: new Map(), cwd: state.workspace }) ??
97
+ word;
98
+ return insideRoots(path, state);
88
99
  }
89
100
 
90
101
  /** Splits a command line into simple commands of words; quotes are removed, operators separate. */
@@ -123,8 +134,11 @@ export function simpleCommands(command: string): string[][] {
123
134
  if (ch === "\n") endCommand();
124
135
  else endWord();
125
136
  } else if (";&|()`{}".includes(ch)) {
137
+ // A word cut by a command substitution keeps its mark, so its value counts as unknown.
138
+ if (ch === "`" && inWord) word += ch;
126
139
  endCommand();
127
140
  } else if (ch === "$" && command[i + 1] === "(") {
141
+ if (inWord) word += "$(";
128
142
  endCommand();
129
143
  i++;
130
144
  } else if (ch === ">" || ch === "<") {
@@ -147,17 +161,22 @@ export function simpleCommands(command: string): string[][] {
147
161
  return commands;
148
162
  }
149
163
 
150
- /** Drops leading variable assignments, wrappers, and their options, leaving the real program. */
151
- function program(words: string[]): string[] {
164
+ /**
165
+ * Drops leading variable assignments, wrappers, and their options, leaving the real program;
166
+ * `prefix` collects what was dropped.
167
+ */
168
+ function program(words: string[], prefix: string[] = []): string[] {
152
169
  let rest = words;
153
170
  for (;;) {
154
171
  const [first] = rest;
155
172
  if (first === undefined) return rest;
156
173
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) {
174
+ prefix.push(first);
157
175
  rest = rest.slice(1);
158
176
  continue;
159
177
  }
160
178
  if (WRAPPERS.has(basename(first))) {
179
+ prefix.push(basename(first));
161
180
  rest = rest.slice(1);
162
181
  // Wrapper options and the timeout's duration.
163
182
  while (
@@ -173,33 +192,37 @@ function program(words: string[]): string[] {
173
192
  }
174
193
  }
175
194
 
176
- /** Why one simple command is held, or undefined. */
177
- function heldReason(words: string[], workspace: string): string | undefined {
195
+ /** Why one simple command is held, or undefined; reads the line's state as it is before it. */
196
+ function heldReason(
197
+ words: string[],
198
+ state: LineState,
199
+ policy: PushPolicy,
200
+ ): string | undefined {
178
201
  for (let i = 0; i < words.length; i++) {
179
202
  const op = words[i];
180
203
  if (op === ">" || op === ">>") {
181
204
  const target = words[i + 1];
182
- if (
183
- target &&
184
- target !== "/dev/null" &&
185
- !insideWorkspace(target, workspace)
186
- )
187
- return `writes to ${target}`;
205
+ if (target && !writable(target, state)) return `writes to ${target}`;
188
206
  }
189
207
  }
208
+ const prefix: string[] = [];
190
209
  const argv = program(
191
210
  words.filter(
192
211
  (w, i) =>
193
212
  !["<", ">", ">>", "dup"].includes(w) &&
194
213
  !["<", ">", ">>"].includes(words[i - 1] ?? ""),
195
214
  ),
215
+ prefix,
196
216
  );
197
217
  const [head, ...args] = argv;
198
218
  if (!head) return undefined;
199
219
  const name = basename(head);
200
220
  if (ALWAYS_HELD.has(name) || /^mkfs(\..+)?$/.test(name)) return name;
221
+ if (name === "rm")
222
+ // xargs adds operands nobody can see here.
223
+ return prefix.includes("xargs") ? "rm" : rmHeld(args, state);
201
224
  if ((name === "bash" || name === "sh" || name === "zsh") && args[0] === "-c")
202
- return commandHeld(args[1] ?? "", workspace);
225
+ return commandHeld(args[1] ?? "", state, policy);
203
226
  const verbs = args.filter((a) => !a.startsWith("-"));
204
227
  switch (name) {
205
228
  case "systemctl":
@@ -255,11 +278,14 @@ function heldReason(words: string[], workspace: string): string | undefined {
255
278
  ? "installs or removes packages"
256
279
  : undefined;
257
280
  case "git": {
258
- const [verb, ...rest] = gitCommand(args);
259
- if (verb === "push")
281
+ const [globals, [verb, ...rest]] = gitCommand(args);
282
+ if (verb === "push") {
283
+ const plain = !prefix.some((w) => w.startsWith("GIT_"));
284
+ if (plain && ownPush(globals, rest, state, policy)) return undefined;
260
285
  return rest.some((a) => a === "-f" || a.startsWith("--force"))
261
286
  ? "force-pushes"
262
287
  : `pushes to GitHub, which deploys ${assistantName()} when the branch is main`;
288
+ }
263
289
  if (verb === "reset" && rest.includes("--hard"))
264
290
  return "git reset --hard";
265
291
  return undefined;
@@ -270,11 +296,9 @@ function heldReason(words: string[], workspace: string): string | undefined {
270
296
  return args.includes("-r") ? "removes a crontab" : undefined;
271
297
  case "tee": {
272
298
  const target = args.find(
273
- (a) => !a.startsWith("-") && !insideWorkspace(a, workspace),
299
+ (a) => !a.startsWith("-") && !writable(a, state),
274
300
  );
275
- return target && target !== "/dev/null"
276
- ? `writes to ${target}`
277
- : undefined;
301
+ return target ? `writes to ${target}` : undefined;
278
302
  }
279
303
  default:
280
304
  return undefined;
@@ -290,12 +314,12 @@ const GIT_VALUE_OPTIONS = new Set([
290
314
  "--namespace",
291
315
  ]);
292
316
 
293
- /** The git subcommand and its arguments, past global options such as `-C <dir>`. */
294
- function gitCommand(args: string[]): string[] {
317
+ /** Git's global options, such as `-C <dir>`, and the subcommand with its arguments. */
318
+ function gitCommand(args: string[]): [string[], string[]] {
295
319
  let i = 0;
296
320
  while (i < args.length && (args[i] as string).startsWith("-"))
297
321
  i += GIT_VALUE_OPTIONS.has(args[i] as string) ? 2 : 1;
298
- return args.slice(i);
322
+ return [args.slice(0, i), args.slice(i)];
299
323
  }
300
324
 
301
325
  const GH_BODY_FLAGS = ["-f", "-F", "--field", "--raw-field", "--input"];
@@ -348,44 +372,83 @@ function ghHeld(args: string[]): string | undefined {
348
372
  }
349
373
  }
350
374
 
351
- function commandHeld(command: string, workspace: string): string | undefined {
375
+ /** Why a command line is held; a `bash -c` inside one starts from the outer line's state. */
376
+ function commandHeld(
377
+ command: string,
378
+ outer: LineState,
379
+ policy: PushPolicy,
380
+ ): string | undefined {
381
+ const state: LineState = {
382
+ ...lineState(command, outer.workspace, undefined),
383
+ roots: outer.roots,
384
+ vars: new Map(outer.vars),
385
+ cwd: outer.cwd,
386
+ };
352
387
  for (const words of simpleCommands(command)) {
353
- const reason = heldReason(words, workspace);
388
+ const reason = heldReason(words, state, policy);
354
389
  if (reason) return reason;
390
+ follow(program(words), state);
391
+ // `export` and `cd` are kept by program(); bare assignments are dropped by it.
392
+ if (words.every((w) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)))
393
+ follow(words, state);
355
394
  }
356
395
  return undefined;
357
396
  }
358
397
 
359
398
  /**
360
- * Pi's shell and file tools: a bash command that is destructive or reaches outside the shared
361
- * workspace, and a write or edit outside it, wait for the owner. Reading always runs.
399
+ * Pi's shell and file tools: a bash command that is destructive or reaches outside the scratch
400
+ * roots (the shared workspace and the scratch dir), and a write or edit outside them, wait for the
401
+ * owner. Reading always runs.
362
402
  */
363
403
  export function shellActionNeedingConfirmation(
364
404
  toolName: string,
365
405
  input: Record<string, unknown>,
366
- workspace: string,
406
+ context: HoldContext & { workspace: string },
407
+ policy: PushPolicy = {},
367
408
  ): string | undefined {
409
+ const state = lineState(
410
+ typeof input.command === "string" ? input.command : "",
411
+ context.workspace,
412
+ context.scratchDir,
413
+ );
368
414
  if (toolName === "bash") {
369
415
  const command = typeof input.command === "string" ? input.command : "";
370
- const reason = commandHeld(command, workspace);
416
+ const reason = commandHeld(command, state, policy);
371
417
  return reason
372
418
  ? `run the shell command \`${command.slice(0, MAX_SHOWN_COMMAND)}\` on the host (${reason})`
373
419
  : undefined;
374
420
  }
375
421
  if (toolName === "write" || toolName === "edit") {
376
422
  const path = typeof input.path === "string" ? input.path : "";
377
- return insideWorkspace(path.replace(/^@/, ""), workspace)
423
+ return writable(path.replace(/^@/, ""), state)
378
424
  ? undefined
379
425
  : `${toolName} the file ${path} on the host`;
380
426
  }
381
427
  return undefined;
382
428
  }
383
429
 
384
- /** Shell calls of a session with a workspace; sessions without one have no shell. */
385
- export const shellHoldRule: Readonly<HoldRule> = Object.freeze<HoldRule>({
386
- name: "shell",
387
- describe: (tool, input, { workspace }) =>
388
- workspace === undefined
389
- ? undefined
390
- : shellActionNeedingConfirmation(tool, input, workspace),
391
- });
430
+ /**
431
+ * The shell rule with a push policy: a plain push to a repository of `ownPushOwners`, other than
432
+ * `heldPushRepos`, runs without a hold. Shell calls of a session without a workspace have no shell.
433
+ */
434
+ export function shellHoldRuleFor(policy: PushPolicy = {}): Readonly<HoldRule> {
435
+ const frozen: PushPolicy = {
436
+ ownPushOwners: [...(policy.ownPushOwners ?? [])],
437
+ heldPushRepos: [...(policy.heldPushRepos ?? [])],
438
+ };
439
+ return Object.freeze<HoldRule>({
440
+ name: "shell",
441
+ describe: (tool, input, context) =>
442
+ context.workspace === undefined
443
+ ? undefined
444
+ : shellActionNeedingConfirmation(
445
+ tool,
446
+ input,
447
+ { ...context, workspace: context.workspace },
448
+ frozen,
449
+ ),
450
+ });
451
+ }
452
+
453
+ /** The strict shell rule: every push is held. */
454
+ export const shellHoldRule: Readonly<HoldRule> = shellHoldRuleFor();
@@ -63,12 +63,17 @@ export class ConversationSessions {
63
63
  async gate(key: ChannelKey, agent: boolean): Promise<ConfirmationGate> {
64
64
  let gate = this.#gates.get(key);
65
65
  if (!gate) {
66
- const workspace = agent ? this.#options.agents?.workDir : undefined;
66
+ const agents = agent ? this.#options.agents : undefined;
67
67
  gate = new ConfirmationGate(
68
68
  this.#factory.link().holds,
69
69
  this.#options.owner,
70
70
  await this.#options.confirmations.load(key),
71
- workspace,
71
+ agents
72
+ ? {
73
+ workspace: agents.workDir,
74
+ ...(agents.scratchDir ? { scratchDir: agents.scratchDir } : {}),
75
+ }
76
+ : {},
72
77
  this.#tiers,
73
78
  );
74
79
  this.#gates.set(key, gate);
@@ -4,7 +4,7 @@ import type {
4
4
  PendingConfirmation,
5
5
  } from "../../domain/conversation.ts";
6
6
  import type { OwnerPrompts } from "../../domain/owner-prompts.ts";
7
- import { type HoldCheck, higherTier } from "../../holds.ts";
7
+ import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
8
8
  import { messages } from "../../i18n/index.ts";
9
9
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
10
10
  import type { ToolTiers } from "../../tool-tiers.ts";
@@ -60,18 +60,18 @@ export class ConfirmationGate {
60
60
  readonly #owner: OwnerIdentity;
61
61
  /** Whom the running turn's held actions and refusals speak of: the turn's speaker. */
62
62
  #addressee: OwnerIdentity;
63
- readonly #workspace: string | undefined;
63
+ readonly #context: HoldContext;
64
64
  readonly #tiers: ToolTiers | undefined;
65
65
 
66
66
  /**
67
67
  * `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
68
- * and its writes outside the workspace are held too.
68
+ * and its writes outside the workspace and the context's scratch dir are held too.
69
69
  */
70
70
  constructor(
71
71
  holds: HoldCheck,
72
72
  owner: OwnerIdentity,
73
73
  pending?: PendingConfirmation,
74
- workspace?: string,
74
+ context: HoldContext = {},
75
75
  tiers?: ToolTiers,
76
76
  ) {
77
77
  this.#tiers = tiers;
@@ -79,7 +79,7 @@ export class ConfirmationGate {
79
79
  this.#owner = owner;
80
80
  this.#addressee = owner;
81
81
  this.#pending = pending;
82
- this.#workspace = workspace;
82
+ this.#context = context;
83
83
  }
84
84
 
85
85
  /** Held actions awaiting the owner's answer; expired ones count as none. */
@@ -156,8 +156,7 @@ export class ConfirmationGate {
156
156
 
157
157
  /** The call when it needs the owner's confirmation and no approval releases it. */
158
158
  #needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
159
- const context =
160
- this.#workspace === undefined ? {} : { workspace: this.#workspace };
159
+ const context = this.#context;
161
160
  const action = this.#holds(tool, input, context);
162
161
  if (!action) return undefined;
163
162
  const minTier = this.#holds.approvalTier?.(tool, input, context);
@@ -3,9 +3,11 @@ import { join } from "node:path";
3
3
  import {
4
4
  type AgentSession,
5
5
  createAgentSession,
6
+ createBashToolDefinition,
6
7
  DefaultResourceLoader,
7
8
  type ModelRuntime,
8
9
  type SessionManager,
10
+ type ToolDefinition,
9
11
  } from "@earendil-works/pi-coding-agent";
10
12
  import { channelSegment } from "../attachments/attachment-dir.ts";
11
13
  import type { ChannelKey } from "../domain/conversation.ts";
@@ -279,6 +281,10 @@ export class SessionFactory {
279
281
  const { session } = await createAgentSession({
280
282
  cwd,
281
283
  agentDir,
284
+ // Pi's bash under its own name, run with TMPDIR at the agents' scratch dir.
285
+ ...(agents?.scratchDir
286
+ ? { customTools: [scratchBash(cwd, agents.scratchDir)] }
287
+ : {}),
282
288
  thinkingLevel: thinking,
283
289
  modelRuntime: this.#modelRuntime,
284
290
  resourceLoader,
@@ -305,3 +311,13 @@ export class SessionFactory {
305
311
  return Object.assign(state, { session });
306
312
  }
307
313
  }
314
+
315
+ /** Pi's bash tool with TMPDIR set to `scratchDir`, so `mktemp` and tools write there. */
316
+ export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
317
+ return createBashToolDefinition(cwd, {
318
+ spawnHook: (context) => ({
319
+ ...context,
320
+ env: { ...context.env, TMPDIR: scratchDir },
321
+ }),
322
+ }) as unknown as ToolDefinition;
323
+ }
package/src/kit/index.ts CHANGED
@@ -44,6 +44,24 @@ export {
44
44
  thinkingLabel,
45
45
  } from "./domain.ts";
46
46
  export { holdChain } from "./holds.ts";
47
+ export type {
48
+ JevCompactInput,
49
+ JevCompactOptions,
50
+ JevCompactOutcome,
51
+ JevCompactor,
52
+ JevCompactRequest,
53
+ JevExtensionOptions,
54
+ JevSkipReason,
55
+ } from "./jev.ts";
56
+ export {
57
+ isRuleLoad,
58
+ JEV_COMPACTION_ENGINE,
59
+ JEV_GOAL,
60
+ JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS,
61
+ jevCompact,
62
+ jevCompactionExtension,
63
+ jevCompactor,
64
+ } from "./jev.ts";
47
65
  export type {
48
66
  EffortBrief,
49
67
  EffortJudgeOptions,
@@ -79,8 +97,10 @@ export {
79
97
  } from "./presentation.ts";
80
98
  export { packageDir, serveUnix } from "./process.ts";
81
99
  export {
100
+ type PushPolicy,
82
101
  SHELL_TOOLS,
83
102
  shellHoldRule,
103
+ shellHoldRuleFor,
84
104
  } from "./shell.ts";
85
105
  export {
86
106
  checkRepoName,