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
@@ -82,6 +82,8 @@ export function conversationTurns(
82
82
  const hideStop = surfaces.showStop(channel);
83
83
  const turn = { kind, channel, speaker };
84
84
  events.turnStarted(turn);
85
+ // A claim that posts its own reply formats its own text, so only the default reply posts as it goes.
86
+ const interim = input.reply ? undefined : surfaces.interim(channel);
85
87
  let result: TurnResult;
86
88
  try {
87
89
  result = await settleTurn(
@@ -102,6 +104,7 @@ export function conversationTurns(
102
104
  ...(input.confirmed ? { confirmed: true } : {}),
103
105
  ...(input.steerable ? { steerable: true } : {}),
104
106
  ...(input.interactive ? { interactive: true } : {}),
107
+ ...(interim ? { interim } : {}),
105
108
  }),
106
109
  ),
107
110
  "conversation turn",
@@ -41,5 +41,6 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
41
41
  unreact: async (channel, messageId, emoji) =>
42
42
  of(channel)?.unreact?.(channel, messageId, emoji),
43
43
  prompts: (channel, speaker) => of(channel)?.prompts?.(channel, speaker),
44
+ interim: (channel) => of(channel)?.interim?.(channel),
44
45
  };
45
46
  }
@@ -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);
@@ -37,7 +37,7 @@ export function askUserExtension(
37
37
  pi.registerTool({
38
38
  name: ASK_USER_TOOL,
39
39
  label: `Ask ${o.name}`,
40
- description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
40
+ description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
41
41
  parameters: Type.Object(
42
42
  {
43
43
  question: Type.String({ minLength: 1, maxLength: 1500 }),
@@ -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);
@@ -0,0 +1,207 @@
1
+ import type { InterimMessage, InterimPosts } from "../domain/interim.ts";
2
+ import type { TurnRequest } from "../domain/ports.ts";
3
+ import type { Logger } from "../log.ts";
4
+ import {
5
+ DISCORD_MESSAGE_LIMIT,
6
+ splitReply,
7
+ } from "../presentation/reply-splitter.ts";
8
+ import { textOf } from "../shared/session-messages.ts";
9
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
10
+
11
+ /** An intermediate text this long or longer is posted as an ordinary message. */
12
+ export const PRIMARY_CHARS = 400;
13
+ /** The progress message is edited at most this often; its last state always lands. */
14
+ export const PROGRESS_EDIT_MS = 1_500;
15
+
16
+ const STRUCTURE = /^\s{0,3}(#{1,6}\s|```|~~~|[-*+]\s|\d+[.)]\s|\|.*\|)/m;
17
+
18
+ /**
19
+ * Whether an intermediate text is content the owner must read, such as a proposal or findings:
20
+ * long, or written with a Markdown heading, list, table, or code fence. Shorter narration
21
+ * between tool calls goes to the progress message instead.
22
+ */
23
+ export function isPrimaryText(
24
+ text: string,
25
+ primaryChars = PRIMARY_CHARS,
26
+ ): boolean {
27
+ return text.length >= primaryChars || STRUCTURE.test(text);
28
+ }
29
+
30
+ /**
31
+ * A run's progress message: its narration lines, then the tools it called, all in Discord's
32
+ * small text and kept inside one message by dropping the oldest lines behind a "…" marker.
33
+ */
34
+ export function progressText(
35
+ lines: readonly string[],
36
+ tools: ReadonlyMap<string, number>,
37
+ ): string {
38
+ const small = (line: string) => `-# ${line}`;
39
+ const toolLine =
40
+ tools.size > 0
41
+ ? small(
42
+ [...tools]
43
+ .map(([name, count]) => (count > 1 ? `${name} ×${count}` : name))
44
+ .join(" · "),
45
+ ).slice(0, DISCORD_MESSAGE_LIMIT)
46
+ : undefined;
47
+ const body = lines
48
+ .flatMap((text) => text.split("\n"))
49
+ .map((line) => line.trim())
50
+ .filter(Boolean)
51
+ .map(small);
52
+ const tail = toolLine ? [toolLine] : [];
53
+ const fits = (kept: string[]) =>
54
+ [...kept, ...tail].join("\n").length <= DISCORD_MESSAGE_LIMIT;
55
+ if (fits(body)) return [...body, ...tail].join("\n");
56
+ const marker = small("…");
57
+ let kept = body;
58
+ while (kept.length > 0 && !fits([marker, ...kept])) kept = kept.slice(1);
59
+ if (kept.length === 0 && body.length > 0) {
60
+ // One line alone is too long: keep its end.
61
+ const room =
62
+ DISCORD_MESSAGE_LIMIT - [marker, ...tail].join("\n").length - 4;
63
+ const last = body.at(-1) ?? "";
64
+ kept = room > 0 ? [small(`…${last.slice(-room)}`)] : [];
65
+ return [...kept, ...tail].join("\n");
66
+ }
67
+ return [marker, ...kept, ...tail].join("\n");
68
+ }
69
+
70
+ interface Run {
71
+ lines: string[];
72
+ tools: Map<string, number>;
73
+ message?: InterimMessage;
74
+ shown?: string;
75
+ lastAt: number;
76
+ timer?: ReturnType<typeof setTimeout>;
77
+ }
78
+
79
+ export interface InterimPosterOptions {
80
+ logger: Logger;
81
+ channel: string;
82
+ primaryChars?: number;
83
+ editMs?: number;
84
+ }
85
+
86
+ /**
87
+ * Posts a turn's intermediate text as it goes. A tool-calling assistant message whose text is
88
+ * primary is posted as ordinary messages; shorter text joins the run's progress message with
89
+ * the tools called, edited in place. A primary post or a card starts a new run. Posts go out
90
+ * in order, and a failed one is logged, never thrown.
91
+ */
92
+ export class InterimPoster {
93
+ readonly #posts: InterimPosts;
94
+ readonly #options: InterimPosterOptions;
95
+ #queue: Promise<void> = Promise.resolve();
96
+ #run: Run = newRun();
97
+
98
+ constructor(posts: InterimPosts, options: InterimPosterOptions) {
99
+ this.#posts = posts;
100
+ this.#options = options;
101
+ }
102
+
103
+ /** A finished message of the turn; only a tool-calling assistant message is posted. */
104
+ messageEnd(message: {
105
+ role: string;
106
+ content?: unknown;
107
+ stopReason?: string;
108
+ }): void {
109
+ if (message.role !== "assistant" || message.stopReason !== "toolUse")
110
+ return;
111
+ const text = textOf(message.content).trim();
112
+ if (!text) return;
113
+ if (isPrimaryText(text, this.#options.primaryChars)) {
114
+ this.#endRun();
115
+ for (const chunk of splitReply(text))
116
+ this.#enqueue(async () => {
117
+ await this.#posts.post(chunk);
118
+ }, "interim text not posted");
119
+ return;
120
+ }
121
+ this.#run.lines.push(text);
122
+ this.#changed();
123
+ }
124
+
125
+ /** A tool the turn started, counted on the run's tool line. */
126
+ toolStart(name: string): void {
127
+ const { tools } = this.#run;
128
+ tools.set(name, (tools.get(name) ?? 0) + 1);
129
+ this.#changed();
130
+ }
131
+
132
+ /**
133
+ * Brings every post up to date and waits for them, then starts a new run: before a card, so
134
+ * the text written before it shows above it, and when the turn ends, before its final reply.
135
+ */
136
+ async flush(): Promise<void> {
137
+ this.#endRun();
138
+ await this.#queue;
139
+ }
140
+
141
+ #endRun(): void {
142
+ const run = this.#run;
143
+ this.#run = newRun();
144
+ clearTimeout(run.timer);
145
+ run.timer = undefined;
146
+ this.#render(run);
147
+ }
148
+
149
+ /** The run changed: shown now, or once the edit interval has passed. */
150
+ #changed(): void {
151
+ const run = this.#run;
152
+ if (run.timer) return;
153
+ const wait =
154
+ run.lastAt + (this.#options.editMs ?? PROGRESS_EDIT_MS) - now();
155
+ if (wait <= 0) {
156
+ this.#render(run);
157
+ return;
158
+ }
159
+ run.timer = setTimeout(() => {
160
+ run.timer = undefined;
161
+ this.#render(run);
162
+ }, wait);
163
+ }
164
+
165
+ #render(run: Run): void {
166
+ if (run.lines.length === 0 && run.tools.size === 0) return;
167
+ run.lastAt = now();
168
+ const text = progressText(run.lines, run.tools);
169
+ this.#enqueue(async () => {
170
+ if (text === run.shown) return;
171
+ if (run.message) await run.message.edit(text);
172
+ else run.message = await this.#posts.post(text);
173
+ run.shown = text;
174
+ }, "progress message not posted");
175
+ }
176
+
177
+ #enqueue(step: () => Promise<void>, failure: string): void {
178
+ const { logger, channel } = this.#options;
179
+ this.#queue = this.#queue.then(step).catch((error: unknown) => {
180
+ logger.warn({ channel, err: error }, failure);
181
+ });
182
+ }
183
+ }
184
+
185
+ /** The turn's poster, when its caller gave a place for interim posts and the host left them on. */
186
+ export function interimPoster(
187
+ request: Pick<TurnRequest, "interim" | "channel">,
188
+ options: Pick<
189
+ PiAgentRuntimeOptions,
190
+ "interimText" | "interimPrimaryChars" | "logger"
191
+ >,
192
+ ): InterimPoster | undefined {
193
+ if (!request.interim || options.interimText === "off") return undefined;
194
+ return new InterimPoster(request.interim, {
195
+ logger: options.logger,
196
+ channel: request.channel,
197
+ ...(options.interimPrimaryChars
198
+ ? { primaryChars: options.interimPrimaryChars }
199
+ : {}),
200
+ });
201
+ }
202
+
203
+ function newRun(): Run {
204
+ return { lines: [], tools: new Map(), lastAt: 0 };
205
+ }
206
+
207
+ const now = () => Date.now();
@@ -36,6 +36,7 @@ import {
36
36
  confirmedTurnText,
37
37
  } from "./extensions/confirmation-gate.ts";
38
38
  import { COMPACT_TOOL } from "./extensions/self-compact-guard.ts";
39
+ import { interimPoster } from "./interim-text.ts";
39
40
  import { PromptSlot, workTimeout } from "./prompt-slot.ts";
40
41
  import {
41
42
  type PiAgentRuntimeOptions,
@@ -181,9 +182,17 @@ export class PiAgentRuntime implements AgentRuntime {
181
182
  // Collected as they end: a compaction during the turn shortens session.messages.
182
183
  const turnMessages: TurnMessage[] = [];
183
184
  const toolCalls: string[] = [];
185
+ // Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
186
+ const interim = interimPoster(request, this.#options);
184
187
  const unsubscribe = session.subscribe((event) => {
185
- if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
186
- if (event.type === "message_end") turnMessages.push(event.message);
188
+ if (event.type === "tool_execution_start") {
189
+ toolCalls.push(event.toolName);
190
+ interim?.toolStart(event.toolName);
191
+ }
192
+ if (event.type === "message_end") {
193
+ turnMessages.push(event.message);
194
+ interim?.messageEnd(event.message);
195
+ }
187
196
  });
188
197
  const slot = this.#sessions.slot(key);
189
198
  slot.bind(
@@ -191,6 +200,7 @@ export class PiAgentRuntime implements AgentRuntime {
191
200
  ? this.#options.prompts?.(request.channel, request.speaker)
192
201
  : undefined,
193
202
  request.agent?.name ?? assistantName(),
203
+ interim ? () => interim.flush() : undefined,
194
204
  );
195
205
  // Time spent waiting on the owner's cards does not count towards the timeout.
196
206
  const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
@@ -237,6 +247,7 @@ export class PiAgentRuntime implements AgentRuntime {
237
247
  cancelTimeout();
238
248
  slot.unbind();
239
249
  unsubscribe();
250
+ await interim?.flush();
240
251
  gate.endTurn();
241
252
  const usage = session.getContextUsage();
242
253
  if (usage)
@@ -10,14 +10,23 @@ import { assistantName } from "../i18n/index.ts";
10
10
  */
11
11
  export class PromptSlot {
12
12
  #prompts: OwnerPrompts | undefined;
13
+ #beforeCard: (() => Promise<void>) | undefined;
13
14
  #asker = assistantName();
14
15
  #open = 0;
15
16
  #openSince = 0;
16
17
  #waited = 0;
17
18
 
18
- /** Binds the turn's prompts; `asker` names who asks, the assistant or an agent. */
19
- bind(prompts: OwnerPrompts | undefined, asker: string): void {
19
+ /**
20
+ * Binds the turn's prompts; `asker` names who asks, the assistant or an agent. `beforeCard`
21
+ * runs before each card opens, such as posting the text the turn wrote before it.
22
+ */
23
+ bind(
24
+ prompts: OwnerPrompts | undefined,
25
+ asker: string,
26
+ beforeCard?: () => Promise<void>,
27
+ ): void {
20
28
  this.#prompts = prompts;
29
+ this.#beforeCard = beforeCard;
21
30
  this.#asker = asker;
22
31
  this.#open = 0;
23
32
  this.#waited = 0;
@@ -25,6 +34,7 @@ export class PromptSlot {
25
34
 
26
35
  unbind(): void {
27
36
  this.#prompts = undefined;
37
+ this.#beforeCard = undefined;
28
38
  }
29
39
 
30
40
  get asker(): string {
@@ -49,6 +59,7 @@ export class PromptSlot {
49
59
  }
50
60
 
51
61
  async #waiting<T>(ask: () => Promise<T>): Promise<T> {
62
+ await this.#beforeCard?.();
52
63
  if (this.#open++ === 0) this.#openSince = Date.now();
53
64
  try {
54
65
  return await ask();
@@ -6,6 +6,7 @@ import type {
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import type { AgentSessions, LoadedSkill } from "../contract/runtime.ts";
8
8
  import type { ChannelKey } from "../domain/conversation.ts";
9
+ import type { InterimTextMode } from "../domain/interim.ts";
9
10
  import type { OwnerPrompts } from "../domain/owner-prompts.ts";
10
11
  import type { OwnerIdentity } from "../identity.ts";
11
12
  import type { Logger } from "../log.ts";
@@ -66,6 +67,13 @@ export interface PiAgentRuntimeOptions {
66
67
  toolTiers?: ToolTiers;
67
68
  /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
68
69
  turnTimeoutMs?: number;
70
+ /**
71
+ * Whether a turn given a place for interim posts shows the text it writes before its final
72
+ * answer as it goes; default "on". "off" posts only the final reply.
73
+ */
74
+ interimText?: InterimTextMode;
75
+ /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
76
+ interimPrimaryChars?: number;
69
77
  /** How long a new session waits for its MCP tools to register. */
70
78
  mcpConnectTimeoutMs?: number;
71
79
  }
@@ -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/index.ts CHANGED
@@ -88,6 +88,11 @@ export {
88
88
  MemoryError,
89
89
  ScheduleError,
90
90
  } from "./core/domain/errors.ts";
91
+ export type {
92
+ InterimMessage,
93
+ InterimPosts,
94
+ InterimTextMode,
95
+ } from "./core/domain/interim.ts";
91
96
  export type {
92
97
  Approval,
93
98
  AskOption,
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,