pi-roundtable 0.7.15 → 0.7.17

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 (36) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/docs/plugins.md +94 -4
  3. package/package.json +2 -1
  4. package/src/core/agents/agent-ports.ts +9 -0
  5. package/src/core/agents/agent-prompt.ts +3 -1
  6. package/src/core/agents/agent-team.ts +2 -0
  7. package/src/core/agents/team-options.ts +2 -0
  8. package/src/core/agents/team-turn-types.ts +1 -1
  9. package/src/core/agents/team-turns.ts +24 -0
  10. package/src/core/builtin/agent-server.ts +37 -0
  11. package/src/core/config/config.ts +30 -3
  12. package/src/core/contract/runtime.ts +5 -0
  13. package/src/core/contract/surface.ts +8 -0
  14. package/src/core/define-roundtable.ts +3 -0
  15. package/src/core/discord/agent-discord.ts +33 -0
  16. package/src/core/discord/discord-surface.ts +20 -0
  17. package/src/core/domain/interim.ts +16 -0
  18. package/src/core/domain/ports.ts +6 -0
  19. package/src/core/holds.ts +2 -0
  20. package/src/core/modules/host-shell/git-push.ts +112 -0
  21. package/src/core/modules/host-shell/shell-paths.ts +160 -0
  22. package/src/core/modules/host-shell/shell-policy.ts +111 -48
  23. package/src/core/routing/conversation-turns.ts +3 -0
  24. package/src/core/routing/surface-port.ts +1 -0
  25. package/src/core/runtime/conversation-sessions.ts +7 -2
  26. package/src/core/runtime/extensions/ask-user.ts +1 -1
  27. package/src/core/runtime/extensions/confirmation-gate.ts +6 -7
  28. package/src/core/runtime/interim-text.ts +207 -0
  29. package/src/core/runtime/pi-agent-runtime.ts +13 -2
  30. package/src/core/runtime/prompt-slot.ts +13 -2
  31. package/src/core/runtime/runtime-types.ts +8 -0
  32. package/src/core/runtime/session-factory.ts +16 -0
  33. package/src/index.ts +5 -0
  34. package/src/kit/index.ts +20 -0
  35. package/src/kit/jev.ts +357 -0
  36. package/src/kit/shell.ts +2 -0
@@ -23,6 +23,7 @@ import type {
23
23
  DashboardBoard,
24
24
  } from "../agents/agent-ports.ts";
25
25
  import { AgentError } from "../domain/errors.ts";
26
+ import type { InterimPosts } from "../domain/interim.ts";
26
27
  import { messages } from "../i18n/index.ts";
27
28
  import type { Logger } from "../log.ts";
28
29
 
@@ -69,6 +70,38 @@ export class DiscordAgentChannels implements AgentChannels {
69
70
  }
70
71
  }
71
72
 
73
+ interim(
74
+ channelId: string,
75
+ as: Omit<AgentPost, "thinking" | "chunks" | "files">,
76
+ ): InterimPosts {
77
+ const threadId = as.threadId ? { threadId: as.threadId } : {};
78
+ return {
79
+ post: async (text) => {
80
+ const webhook = await this.#webhook(channelId);
81
+ try {
82
+ const message = await webhook.send({
83
+ username: as.name,
84
+ avatarURL: as.avatarUrl,
85
+ allowedMentions: { parse: ["users"] },
86
+ ...threadId,
87
+ content: text,
88
+ });
89
+ return {
90
+ edit: async (change) => {
91
+ await webhook.editMessage(message, {
92
+ content: change,
93
+ ...threadId,
94
+ });
95
+ },
96
+ };
97
+ } catch (error) {
98
+ this.#webhooks.delete(channelId);
99
+ throw error;
100
+ }
101
+ },
102
+ };
103
+ }
104
+
72
105
  async createChannel(
73
106
  name: string,
74
107
  topic: string,
@@ -17,6 +17,7 @@ import type {
17
17
  InboundMessage,
18
18
  OutboundReply,
19
19
  } from "../domain/conversation.ts";
20
+ import type { InterimPosts } from "../domain/interim.ts";
20
21
  import type { OwnerPrompts } from "../domain/owner-prompts.ts";
21
22
  import type { OwnerNotifier } from "../domain/ports.ts";
22
23
  import { messages } from "../i18n/index.ts";
@@ -159,6 +160,25 @@ export class DiscordSurface
159
160
  return this.#options.prompts(channel, speaker);
160
161
  }
161
162
 
163
+ /** The channel's interim posts: ordinary messages, edited in place for the progress line. */
164
+ interim(channel: ChannelKey): InterimPosts | undefined {
165
+ if (!channel.startsWith(PREFIX)) return undefined;
166
+ return {
167
+ post: async (text) => {
168
+ const target = await this.#sendable(channel);
169
+ const message = await target.send({
170
+ content: text,
171
+ allowedMentions: { parse: ["users"] },
172
+ });
173
+ return {
174
+ edit: async (change) => {
175
+ await message.edit({ content: change });
176
+ },
177
+ };
178
+ },
179
+ };
180
+ }
181
+
162
182
  async start(onMessage: (message: InboundMessage) => void): Promise<void> {
163
183
  const { logger, token } = this.#options;
164
184
  this.#client.on(Events.MessageCreate, (message) => {
@@ -0,0 +1,16 @@
1
+ /** A message an interim post made, which the turn edits in place as it goes. */
2
+ export interface InterimMessage {
3
+ edit(text: string): Promise<void>;
4
+ }
5
+
6
+ /**
7
+ * Where a running turn posts the text it writes before its final answer: the channel the
8
+ * final reply goes to, under the same name. Each call posts one ordinary message of at most
9
+ * Discord's 2000 characters.
10
+ */
11
+ export interface InterimPosts {
12
+ post(text: string): Promise<InterimMessage>;
13
+ }
14
+
15
+ /** Whether a turn posts its intermediate text as it goes; "on" by default. */
16
+ export type InterimTextMode = "on" | "off";
@@ -2,6 +2,7 @@ import type { AgentTurnScope, TurnSelection } from "../sessions.ts";
2
2
  import type { Speaker } from "../speakers.ts";
3
3
  import type { TurnAttachments } from "./attachment.ts";
4
4
  import type { ChannelKey } from "./conversation.ts";
5
+ import type { InterimPosts } from "./interim.ts";
5
6
 
6
7
  export type { AgentTurnScope };
7
8
 
@@ -32,6 +33,11 @@ export interface TurnRequest {
32
33
  * schedule's turn is not.
33
34
  */
34
35
  interactive?: boolean;
36
+ /**
37
+ * Where the turn posts the text it writes before its final answer, as it goes; the caller
38
+ * hands the channel its reply goes to. Absent = only the final reply is posted.
39
+ */
40
+ interim?: InterimPosts;
35
41
  }
36
42
 
37
43
  /** The judge's question and answer shapes. */
package/src/core/holds.ts CHANGED
@@ -5,6 +5,8 @@ import { type Tier, tierAtLeast } from "./speakers.ts";
5
5
  export interface HoldContext {
6
6
  /** The shared workspace of a session with a shell; calls reaching outside it may be held. */
7
7
  workspace?: string;
8
+ /** The session's scratch dir, where its shell's TMPDIR points; writes and removals inside it run. */
9
+ scratchDir?: string;
8
10
  }
9
11
 
10
12
  /**
@@ -0,0 +1,112 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { type LineState, resolvePath } from "./shell-paths.ts";
3
+
4
+ /** Which pushes run without the owner's approval; see `shellHoldRuleFor`. */
5
+ export interface PushPolicy {
6
+ /** GitHub owners whose repositories take a plain push without a hold. */
7
+ ownPushOwners?: readonly string[];
8
+ /** `owner/repo` names that stay held although their owner is listed. */
9
+ heldPushRepos?: readonly string[];
10
+ }
11
+
12
+ const GIT_TIMEOUT_MS = 3_000;
13
+
14
+ /** Push options that neither force, delete, nor push tags or more than the named refs. */
15
+ const PLAIN_FLAGS = new Set([
16
+ "-u",
17
+ "--set-upstream",
18
+ "-q",
19
+ "--quiet",
20
+ "-v",
21
+ "--verbose",
22
+ "-n",
23
+ "--dry-run",
24
+ "--no-verify",
25
+ "--verify",
26
+ "--atomic",
27
+ "--porcelain",
28
+ "--progress",
29
+ "--no-progress",
30
+ ]);
31
+
32
+ /** `owner/repo` of a GitHub remote URL, https or ssh, or undefined. */
33
+ export function githubRepo(url: string): string | undefined {
34
+ const match =
35
+ /^(?:https:\/\/(?:[^@/]+@)?github\.com\/|ssh:\/\/git@github\.com(?::\d+)?\/|git@github\.com:)([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/i.exec(
36
+ url.trim(),
37
+ );
38
+ return match ? `${match[1]}/${match[2]}`.toLowerCase() : undefined;
39
+ }
40
+
41
+ function git(dir: string, args: readonly string[]): string | undefined {
42
+ try {
43
+ return execFileSync("git", ["-C", dir, ...args], {
44
+ encoding: "utf8",
45
+ timeout: GIT_TIMEOUT_MS,
46
+ stdio: ["ignore", "pipe", "ignore"],
47
+ });
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Whether `git <global> push <args>` is a plain push to one of the owner's own GitHub repositories:
55
+ * no force, deletion, tags or mirror, and a remote whose push URLs all belong to a listed owner.
56
+ */
57
+ export function ownPush(
58
+ globals: readonly string[],
59
+ args: readonly string[],
60
+ state: LineState,
61
+ policy: PushPolicy,
62
+ ): boolean {
63
+ const owners = (policy.ownPushOwners ?? []).map((o) => o.toLowerCase());
64
+ if (owners.length === 0) return false;
65
+ let dir = state.cwd;
66
+ for (let i = 0; i < globals.length; i++) {
67
+ // `-c`, `--git-dir` and the like can point the push elsewhere.
68
+ if (globals[i] !== "-C") return false;
69
+ const next = globals[++i];
70
+ if (next === undefined || dir === undefined) return false;
71
+ dir = resolvePath(next, { ...state, cwd: dir });
72
+ }
73
+ if (dir === undefined) return false;
74
+ const positional: string[] = [];
75
+ for (let i = 0; i < args.length; i++) {
76
+ const arg = args[i] as string;
77
+ if (arg === "-o" || arg === "--push-option") i++;
78
+ else if (arg.startsWith("--push-option=")) continue;
79
+ else if (arg.startsWith("-")) {
80
+ if (!PLAIN_FLAGS.has(arg)) return false;
81
+ } else positional.push(arg);
82
+ }
83
+ const [remote = "origin", ...refspecs] = positional;
84
+ for (const refspec of refspecs) {
85
+ if (refspec.startsWith("+") || refspec.startsWith(":")) return false;
86
+ if (refspec.includes("refs/tags/")) return false;
87
+ for (const ref of refspec.split(":"))
88
+ if (
89
+ ref &&
90
+ git(dir, ["show-ref", "--verify", "--quiet", `refs/tags/${ref}`]) !==
91
+ undefined
92
+ )
93
+ return false;
94
+ }
95
+ const urls = /[:/]/.test(remote)
96
+ ? [remote]
97
+ : git(dir, ["remote", "get-url", "--push", "--all", remote])
98
+ ?.split("\n")
99
+ .filter(Boolean);
100
+ if (!urls || urls.length === 0) return false;
101
+ const held = new Set(
102
+ (policy.heldPushRepos ?? []).map((r) => r.toLowerCase()),
103
+ );
104
+ return urls.every((url) => {
105
+ const repo = githubRepo(url);
106
+ return (
107
+ repo !== undefined &&
108
+ owners.includes(repo.split("/")[0] as string) &&
109
+ !held.has(repo)
110
+ );
111
+ });
112
+ }
@@ -0,0 +1,160 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import {
4
+ basename,
5
+ dirname,
6
+ isAbsolute,
7
+ join,
8
+ relative,
9
+ resolve,
10
+ } from "node:path";
11
+
12
+ /**
13
+ * What the commands of one command line know as they are read in order: the variables assigned
14
+ * earlier with literal values, and the directory the last `cd` moved to.
15
+ */
16
+ export interface LineState {
17
+ /** The shared workspace, where a command line starts. */
18
+ workspace: string;
19
+ /** Directories a command writes and removes in without a hold: the workspace and the scratch dir. */
20
+ roots: readonly string[];
21
+ /** Variables with known values; a name mapped to undefined was assigned something unknown. */
22
+ vars: Map<string, string | undefined>;
23
+ /** The working directory, or undefined once a `cd` went somewhere unknown. */
24
+ cwd: string | undefined;
25
+ /** Whether `cd` can be followed: false in a line with subshells or `||`, where the last `cd` may not apply. */
26
+ followsCd: boolean;
27
+ }
28
+
29
+ export function lineState(
30
+ command: string,
31
+ workspace: string,
32
+ scratchDir: string | undefined,
33
+ ): LineState {
34
+ const vars = new Map<string, string | undefined>([["HOME", homedir()]]);
35
+ if (scratchDir !== undefined) vars.set("TMPDIR", scratchDir);
36
+ return {
37
+ workspace,
38
+ roots: scratchDir === undefined ? [workspace] : [workspace, scratchDir],
39
+ vars,
40
+ cwd: workspace,
41
+ followsCd: !/(^|[^$])\(|\|\|/.test(command),
42
+ };
43
+ }
44
+
45
+ const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
46
+
47
+ /**
48
+ * The word with the line's variables substituted and `~` expanded, or undefined when it holds a
49
+ * command substitution, an unknown variable or `~user`.
50
+ */
51
+ export function expand(word: string, state: LineState): string | undefined {
52
+ if (word.includes("`") || word.includes("$(")) return undefined;
53
+ let unknown = false;
54
+ const substituted = word.replace(
55
+ /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g,
56
+ (_, braced: string | undefined, plain: string | undefined) => {
57
+ const value = state.vars.get((braced ?? plain) as string);
58
+ if (value === undefined) unknown = true;
59
+ return value ?? "";
60
+ },
61
+ );
62
+ if (unknown || substituted.includes("$")) return undefined;
63
+ if (substituted === "~" || substituted.startsWith("~/"))
64
+ return `${homedir()}${substituted.slice(1)}`;
65
+ return substituted.startsWith("~") ? undefined : substituted;
66
+ }
67
+
68
+ /** The absolute path a word names, from the line's working directory; undefined when unknown. */
69
+ export function resolvePath(
70
+ word: string,
71
+ state: LineState,
72
+ ): string | undefined {
73
+ const path = expand(word, state);
74
+ if (path === undefined) return undefined;
75
+ if (isAbsolute(path)) return resolve(path);
76
+ return state.cwd === undefined ? undefined : resolve(state.cwd, path);
77
+ }
78
+
79
+ /** Records a simple command's effect on the line: an assignment, an `export`, or a `cd`. */
80
+ export function follow(argv: readonly string[], state: LineState): void {
81
+ const [head, ...args] = argv;
82
+ if (head === undefined) return;
83
+ const assignments =
84
+ head === "export"
85
+ ? args
86
+ : argv.every((w) => ASSIGNMENT.test(w))
87
+ ? argv
88
+ : [];
89
+ for (const word of assignments) {
90
+ const match = ASSIGNMENT.exec(word);
91
+ if (match)
92
+ state.vars.set(match[1] as string, expand(match[2] as string, state));
93
+ }
94
+ if (head === "cd" || head === "pushd" || head === "popd") {
95
+ const target = args.find((a) => !a.startsWith("-"));
96
+ state.cwd =
97
+ state.followsCd && head === "cd" && target !== undefined
98
+ ? resolvePath(target, state)
99
+ : undefined;
100
+ }
101
+ }
102
+
103
+ /** Whether `path` is `root` or under it, comparing the paths as written. */
104
+ export function under(path: string, root: string): boolean {
105
+ const rel = relative(resolve(root), resolve(path));
106
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
107
+ }
108
+
109
+ /** The real path of `path`: its nearest existing ancestor's real path, joined with the rest. */
110
+ function realPath(path: string): string {
111
+ let existing = path;
112
+ while (!existsSync(existing) && dirname(existing) !== existing)
113
+ existing = dirname(existing);
114
+ try {
115
+ return join(realpathSync(existing), relative(existing, path));
116
+ } catch {
117
+ return path;
118
+ }
119
+ }
120
+
121
+ /** Whether a write to `path` stays inside a root, as written. */
122
+ export function insideRoots(path: string, state: LineState): boolean {
123
+ return state.roots.some((root) => under(path, root));
124
+ }
125
+
126
+ /**
127
+ * Why an `rm` is held, or undefined when every operand resolves, through symlinks, under a scratch
128
+ * root without being one; a glob is judged by its directory part.
129
+ */
130
+ export function rmHeld(
131
+ args: readonly string[],
132
+ state: LineState,
133
+ ): string | undefined {
134
+ const operands: string[] = [];
135
+ let options = true;
136
+ for (const arg of args) {
137
+ if (options && arg === "--") options = false;
138
+ else if (options && arg.startsWith("-") && arg !== "-") continue;
139
+ else operands.push(arg);
140
+ }
141
+ if (operands.length === 0) return "rm";
142
+ const roots = state.roots.map(realPath);
143
+ for (const operand of operands) {
144
+ const glob = /[*?[]/.exec(operand);
145
+ const pattern = glob ? operand : undefined;
146
+ const word = glob
147
+ ? operand.slice(0, operand.lastIndexOf("/", glob.index) + 1) || "."
148
+ : operand;
149
+ // `.*` once matched `..`; a glob of hidden names is held.
150
+ if (pattern && basename(pattern).startsWith(".")) return "rm";
151
+ const path = resolvePath(word, state);
152
+ if (path === undefined || path === "/") return "rm";
153
+ const real = realPath(path);
154
+ const inside = roots.some((root) =>
155
+ pattern ? under(real, root) : under(real, root) && real !== root,
156
+ );
157
+ if (!inside) return "rm";
158
+ }
159
+ return undefined;
160
+ }
@@ -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();