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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.16] - 2026-10-04
9
+
10
+ ### Added
11
+
12
+ - `pi-roundtable/kit` owns a Jev compaction engine on pi-jev-compaction 1.0.0 (now a dependency, pinned exactly): `jevCompact(input, options)` and two adapters, `jevCompactionExtension({ logger })` for the `compaction` session tool (placed through `session.compaction.wrap`, engine `JEV_COMPACTION_ENGINE`, `pi-jev-compaction`) and `jevCompactor({ logger })` for pi-roundtable-sandbox's `compaction` option. Both log each fallback to Pi's summary with its reason and `tokensBefore`, except a missing API key, which they log once (`Jev is not configured, so compaction uses Pi's summary`) and then compact through Pi's summary quietly: the key is optional.
13
+ - Values: `jevCompact`, `jevCompactionExtension`, `jevCompactor`, `isRuleLoad`, `JEV_COMPACTION_ENGINE`, `JEV_GOAL`, `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS`; types: `JevCompactInput`, `JevCompactOptions`, `JevCompactOutcome`, `JevCompactor`, `JevCompactRequest`, `JevExtensionOptions`, `JevSkipReason`.
14
+ - Unlike pi-jev-compaction's own `compactPiSession`, a summary carries the previous summary once, not twice, so a chain of compactions no longer doubles (one session's grew from 8k to 303k characters over five compactions).
15
+ - A previous summary over 60,000 tokens (`previousSummaryLimitTokens`) skips Jev, so Pi's summary condenses the chain.
16
+ - Jev judges with a goal (`JEV_GOAL`, option `goal`) that keeps the tool results that set rules still in force, which it dropped without one, and the summary ends with the rule loads it summarized (skills, notebook system prompts, reads of SKILL.md, AGENTS.md and `.agents/skills/`; option `ruleLoad`, default `isRuleLoad`) for the agent to load again.
17
+
18
+ ### Changed
19
+
20
+ - The shell hold rule lets more of the agents' own work run.
21
+ - The agents get a scratch dir, the config's new `scratchDir` (default `<os temp dir>/<discord.rootCommand>-scratch`, created with mode 0700 at startup; a symlink or another user's dir is refused), and their `bash` runs with `TMPDIR` pointing to it. Redirects, `tee`, and `write` and `edit` inside it run as inside the workspace; the rest of `/tmp` stays held. `AgentSessions.scratchDir` and `HoldContext.scratchDir` carry it, and the agents' prompt names it.
22
+ - `rm` is no longer always held: it runs when every operand resolves, after the line's literal variable assignments, `cd`, `..` and symlinks, inside the workspace or the scratch dir without being one of them. A command substitution, an unknown variable, a root itself, `/`, or no operand keeps it held.
23
+ - `git push` stays held by `shellHoldRule`; the new `shellHoldRuleFor({ ownPushOwners, heldPushRepos })` (type `PushPolicy`) lets a plain push to a GitHub repository of a listed owner run, unless it forces, deletes, pushes tags or a mirror, or the repository is in `heldPushRepos` or its remote cannot be read.
24
+ - A relative write target or `rm` operand resolves from the line's last `cd`, not always from the workspace.
25
+ - Value: `shellHoldRuleFor`; type: `PushPolicy`.
26
+
27
+ ### Fixed
28
+
29
+ - pi-roundtable-coding frees a finished job's repository before delivering its report, so the turn that receives the report can ship it or start the next worker there instead of being refused with "still using".
30
+
8
31
  ## [0.7.15] - 2026-10-03
9
32
 
10
33
  ### Changed
package/docs/plugins.md CHANGED
@@ -63,11 +63,19 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
63
63
  - Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
64
64
  - Diagnostics: `scrubDiagnostic(text, max = 600)` masks credentials (URL userinfo, token shapes, secret-named assignments and JSON fields, `Authorization`/`Cookie`/`x-api-key` headers, JWTs, PEM blocks), turns control characters other than tab and newline into spaces, and cuts the result at `max` characters.
65
65
  It scans only the first `max * 4` characters (at least 4,096), in linear time, so pass it git, gh or provider error text before showing that text to a user.
66
- - Shell: `SHELL_TOOLS` and `shellHoldRule`, the hold rule that keeps risky host-shell commands behind the owner's approval.
66
+ - Shell: `SHELL_TOOLS`, and `shellHoldRule` and `shellHoldRuleFor(policy)`, the hold rule that keeps risky host-shell commands behind the owner's approval; see [the shell rule](#the-shell-rule).
67
67
  - Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
68
68
  - Mirroring a built-in tool in a worker that cannot reach the host: `SCHEDULE_TOOLS`, `scheduleToolSpecs({ locale, timeZone })`, `isScheduleTool`, `callScheduleTool`, `DELEGATE_TOOL` and `DELEGATE_TOOL_SPEC`.
69
69
  The specs take the locale and time zone for their descriptions, so the worker needs no process-wide setting.
70
70
  - Compaction: `CompactionTiers` gives a session the core's compaction tiers (`settings()` for Pi's `SettingsManager`, `wrapCompactor(factory, onBypass)` to hold a compaction extension back past the ceiling, `latest()`), with `SOFT_COMPACT_TOKENS` (300,000), `HARD_COMPACT_TOKENS` (500,000), `COMPACT_HEADROOM_TOKENS` (50,000), `compactionEngine(details, engine)` and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`.
71
+ - Jev compaction: `jevCompact(input, options)` compacts through Jev (pi-jev-compaction 1.0.0) and returns `{ compaction }` or `{ skipped, detail? }` (a `JevSkipReason`: pi-jev-compaction's fallbacks `no_key`, `aborted`, `nothing_to_compact`, `no_candidates`, `cannot_fit`, `jev_error`, `reduction_too_small`, or `previous_summary_too_large`), for Pi's own summary to run instead.
72
+ Its summary carries the previous summary once, as the transcript's `[previous compaction]` message, with `estimatedTokensAfter` measured on the final text; a previous summary over `previousSummaryLimitTokens` (default `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS`, 60,000) skips without calling Jev, so Pi's summary condenses the chain.
73
+ Jev judges with `goal` (default `JEV_GOAL`: keep the tool results that set rules still in force, drop stale lookups, listings and finished edits), and the summary ends with a `## Rules loaded before this compaction` section naming each rule load among the summarized messages (tool name and JSON arguments cut at 200 characters) for the agent to load again; `ruleLoad(tool, args)` picks them (default `isRuleLoad`: tools ending in `invoke-skill` or `get-system-prompt`, and tools ending in `read` whose `path` is a `SKILL.md`, an `AGENTS.md` or under `.agents/skills/`).
74
+ `config` passes pi-jev-compaction's settings (`apiKey`, `model`, thresholds) over its config file and environment; `asker` replaces Jev's service, for tests.
75
+ The API key is optional: without one, `jevCompact` skips with `no_key` without calling Jev, and compaction is Pi's own summary.
76
+ Two adapters log each skip as `Jev leaves the compaction to Pi's summary` with `reason`, `detail` and `tokensBefore` through the `logger` they take: `jevCompactionExtension({ logger, ...options })`, the `compaction` session tool's extension, placed as `session.compaction.wrap(jevCompactionExtension({ logger }))` under the engine `JEV_COMPACTION_ENGINE` (`pi-jev-compaction`), and `jevCompactor({ logger, ...options })`, a `JevCompactor` for pi-roundtable-sandbox's `compaction` option that returns the compaction or `undefined` and logs with the `channel`.
77
+ A `no_key` skip is logged once per adapter, as `Jev is not configured, so compaction uses Pi's summary`, and then not again; every other skip is logged each time.
78
+ The types are `JevCompactInput`, `JevCompactOptions`, `JevCompactOutcome`, `JevCompactor`, `JevCompactRequest`, `JevExtensionOptions` and `JevSkipReason`.
71
79
  - Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`).
72
80
  - Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, and `searchTerms` for memory search.
73
81
 
@@ -505,6 +513,36 @@ A rule whose verdict depends on the input, such as one that holds only a `delete
505
513
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
506
514
  A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it.
507
515
 
516
+ #### The shell rule
517
+
518
+ `shellHoldRule` in `pi-roundtable/kit` judges the agents' `bash`, `write` and `edit` calls; the agent server links it, and a session without a workspace has no shell.
519
+ Its scratch roots are the shared workspace (`HoldContext.workspace`) and the scratch dir (`HoldContext.scratchDir`).
520
+ The scratch dir is the config's `scratchDir`, by default `<os temp dir>/<discord.rootCommand>-scratch` (such as `/tmp/roundtable-scratch`); the agent server creates it with mode 0700 at startup and refuses one that is a symlink or another user's, and the agents' `bash` runs with `TMPDIR` pointing to it, so `mktemp` and tools write there.
521
+ `AgentSessions.scratchDir` carries it to a runtime, and the agents' prompt tells them to write temporary files there.
522
+
523
+ - A `>`, `>>` or `tee` target, and a `write` or `edit` path, inside a scratch root run; anything else, the rest of `/tmp` included, is held.
524
+ - An `rm` runs when every operand resolves inside a scratch root and none is a root itself or `/`. Operands resolve after the variables assigned earlier in the same command line (`NAME=value` and `export NAME=value` with literal values; `$TMPDIR` is the scratch dir and `$HOME` the service user's home), from the directory of the last `cd`, through `..` and, for paths that exist, symlinks, so a link out of a root is held. A glob is judged by its directory part. A command substitution, an unknown variable, `~user`, an `rm` without operands, and `xargs rm` are held.
525
+ - `sudo`, `kill`, `dd`, `mkfs` and the other programs held whatever their arguments stay held, as do service, container, firewall and package changes, `git reset --hard`, and `gh` writes.
526
+ - `git push` is held, `force-pushes` for a force push and otherwise as a push to GitHub.
527
+
528
+ `shellHoldRuleFor({ ownPushOwners?, heldPushRepos? })` (its options are the type `PushPolicy`) is the same rule with plain pushes to the owner's own repositories let through; `shellHoldRule` is `shellHoldRuleFor()`, which holds every push.
529
+ A push runs without a hold when all of these hold:
530
+
531
+ - it does not force (`-f`, `--force*`, a `+` refspec), delete (`--delete`, `-d`, a `:ref` refspec), or push tags or more than its refs (`--tags`, `--follow-tags`, `--mirror`, `--all`, `--prune`), takes no other option than `-u`, `-q`, `-v`, `-n`, `--no-verify`, `--atomic`, `--porcelain`, `--progress` and `-o`, and names no `refs/tags/…` ref or local tag;
532
+ - the repository directory is known: `git -C <dir>`, else the last `cd` in the command line, else the workspace; a line with a subshell or `||` does not follow its `cd`;
533
+ - every push URL of the remote (the named one, default `origin`, read with `git remote get-url --push --all` with a short timeout and no shell; or a URL given in its place) is a GitHub repository, https or ssh, whose owner is in `ownPushOwners` and whose `owner/repo` is not in `heldPushRepos`.
534
+
535
+ Git's `-c`, `--git-dir` and `--work-tree`, a `GIT_*` variable before `git`, and any failure to read the remote keep the push held.
536
+
537
+ ```ts
538
+ import { shellHoldRuleFor } from "pi-roundtable/kit";
539
+
540
+ const shell = shellHoldRuleFor({
541
+ ownPushOwners: ["octocat"],
542
+ heldPushRepos: ["octocat/deployed-app"],
543
+ });
544
+ ```
545
+
508
546
  ### `prompt`: text added to every agent turn
509
547
 
510
548
  Each section's `build` gets the agent, the speaker (undefined between turns), and the turn's scope.
@@ -1001,7 +1039,7 @@ The slot is a `RuntimeFactory`: `(deps: RuntimeDeps) => AgentRuntime`, called on
1001
1039
  `deps` provides `logger`, `env`, `owner`, `toolTiers`, and the host's `judge`.
1002
1040
  Its `sessions()` gives you linked hold rules, packages, session tools, and personas from preflight onwards; call it in a turn, when those parts are available.
1003
1041
  `prompts(conversation, speaker)` gives you the owner's approval and question cards on the conversation's surface, or `undefined`.
1004
- `agents` holds the agent server's per-agent settings: `workDir`, `skills(name)`, `modelOf(name)`, and `turnChannel(scope)`.
1042
+ `agents` holds the agent server's per-agent settings: `workDir`, `scratchDir` (the agents' shell's `TMPDIR`), `skills(name)`, `modelOf(name)`, and `turnChannel(scope)`.
1005
1043
  `confirmations` stores held actions across restarts.
1006
1044
 
1007
1045
  An `AgentRuntime` has these methods:
@@ -1489,6 +1527,22 @@ Use it for tools that `defineTool` cannot express, such as a set that changes wh
1489
1527
  Each extension needs a unique name; the core reserves `read-attachment`, `confirmation-gate`, `ask-user`, `self-compact-guard`, and `active-tools`.
1490
1528
  A runtime of your own pins the active tools the way the core does: `activeToolsExtension(() => tools)` from `pi-roundtable/kit` is the extension the core places last, so its handler runs after every other extension's.
1491
1529
  At most one plugin may add a `compaction` extension, and it must name the `engine` its compactions record.
1530
+ The core's Jev compactor is one such extension:
1531
+
1532
+ ```ts
1533
+ sessionTools: [
1534
+ {
1535
+ name: "jev-compaction",
1536
+ phase: "compaction",
1537
+ engine: JEV_COMPACTION_ENGINE,
1538
+ snapshot: () => ({
1539
+ revision: 0,
1540
+ factory: (session) =>
1541
+ session.compaction.wrap(jevCompactionExtension({ logger })),
1542
+ }),
1543
+ },
1544
+ ],
1545
+ ```
1492
1546
 
1493
1547
  <!-- example: examples/session-tools.ts -->
1494
1548
  ```ts
@@ -2075,7 +2129,7 @@ It returns:
2075
2129
  |---|---|
2076
2130
  | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
2077
2131
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
2078
- | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace? })` returns the description of a call that must be approved first, or `undefined` |
2132
+ | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined` |
2079
2133
  | `runTool(name, args, { speaker, channel }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads |
2080
2134
  | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
2081
2135
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
@@ -2666,6 +2720,7 @@ Import from the entries listed below; source area files are internal.
2666
2720
  | `OwnerNotifier` | `pi-roundtable/kit` | type |
2667
2721
  | `PreviousTurn` | `pi-roundtable/kit` | type |
2668
2722
  | `PromptSlot` | `pi-roundtable/kit` | type |
2723
+ | `PushPolicy` | `pi-roundtable/kit` | type |
2669
2724
  | `SCHEDULE_TOOLS` | `pi-roundtable/kit` | value |
2670
2725
  | `SHELL_TOOLS` | `pi-roundtable/kit` | value |
2671
2726
  | `SKILL_LIST_TOOL` | `pi-roundtable/kit` | value |
@@ -2707,6 +2762,20 @@ Import from the entries listed below; source area files are internal.
2707
2762
  | `CompactionEngine` | `pi-roundtable/kit` | type |
2708
2763
  | `CompactionHistory` | `pi-roundtable/kit` | type |
2709
2764
  | `LatestCompaction` | `pi-roundtable/kit` | type |
2765
+ | `isRuleLoad` | `pi-roundtable/kit` | value |
2766
+ | `JEV_COMPACTION_ENGINE` | `pi-roundtable/kit` | value |
2767
+ | `JEV_GOAL` | `pi-roundtable/kit` | value |
2768
+ | `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS` | `pi-roundtable/kit` | value |
2769
+ | `jevCompact` | `pi-roundtable/kit` | value |
2770
+ | `jevCompactionExtension` | `pi-roundtable/kit` | value |
2771
+ | `jevCompactor` | `pi-roundtable/kit` | value |
2772
+ | `JevCompactInput` | `pi-roundtable/kit` | type |
2773
+ | `JevCompactOptions` | `pi-roundtable/kit` | type |
2774
+ | `JevCompactOutcome` | `pi-roundtable/kit` | type |
2775
+ | `JevCompactor` | `pi-roundtable/kit` | type |
2776
+ | `JevCompactRequest` | `pi-roundtable/kit` | type |
2777
+ | `JevExtensionOptions` | `pi-roundtable/kit` | type |
2778
+ | `JevSkipReason` | `pi-roundtable/kit` | type |
2710
2779
  | `isScheduleTool` | `pi-roundtable/kit` | value |
2711
2780
  | `lastAssistant` | `pi-roundtable/kit` | value |
2712
2781
  | `mcpAdapterExtension` | `pi-roundtable/kit` | value |
@@ -2723,6 +2792,7 @@ Import from the entries listed below; source area files are internal.
2723
2792
  | `searchTerms` | `pi-roundtable/kit` | value |
2724
2793
  | `settleTurn` | `pi-roundtable/kit` | value |
2725
2794
  | `shellHoldRule` | `pi-roundtable/kit` | value |
2795
+ | `shellHoldRuleFor` | `pi-roundtable/kit` | value |
2726
2796
  | `skillListExtension` | `pi-roundtable/kit` | value |
2727
2797
  | `splitReply` | `pi-roundtable/kit` | value |
2728
2798
  | `stringList` | `pi-roundtable/kit` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.15",
3
+ "version": "0.7.16",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,6 +61,7 @@
61
61
  "@earendil-works/pi-coding-agent": ">=0.99.2 <2",
62
62
  "canvas": "3.2.3",
63
63
  "discord.js": "14.27.0",
64
+ "pi-jev-compaction": "1.0.0",
64
65
  "pi-mcp-adapter": "5.0.0",
65
66
  "pino": "10.3.1",
66
67
  "typebox": "1.3.34",
@@ -18,6 +18,8 @@ export function agentSystemPrompt(input: {
18
18
  owner: OwnerIdentity;
19
19
  /** The host account the shell and file tools run as. */
20
20
  shellUser: string;
21
+ /** The scratch dir the shell's TMPDIR points to, for temporary files. */
22
+ scratchDir?: string;
21
23
  group?: { group: AgentGroup; members: readonly Agent[] };
22
24
  /** Who the turn is for; without one, or the owner, the prompt speaks to the owner. */
23
25
  speaker?: Speaker;
@@ -35,7 +37,7 @@ export function agentSystemPrompt(input: {
35
37
  `You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} Discord agent server. Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
36
38
  roleText(agent, coordinator, who),
37
39
  `The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; ${input.avatars === false ? "" : "agent_avatar redraws an avatar; "}schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
38
- `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}. Commands that are destructive or change the system, and writes outside the workspace, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm.`,
40
+ `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}.${input.scratchDir ? ` Write temporary files under the scratch dir ${input.scratchDir} ($TMPDIR), not elsewhere in /tmp.` : ""} Commands that are destructive or change the system, and writes outside the workspace${input.scratchDir ? " and the scratch dir" : ""}, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm.`,
39
41
  ];
40
42
  if (group) {
41
43
  const others = group.members
@@ -131,6 +131,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
131
131
  entryChannelId,
132
132
  owner,
133
133
  shellUser,
134
+ scratchDir,
134
135
  } = this.#options;
135
136
  const agent = store.agent(scope.name);
136
137
  if (!agent) throw new AgentError(`no agent ${scope.name}`);
@@ -146,6 +147,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
146
147
  workDir,
147
148
  owner,
148
149
  shellUser,
150
+ ...(scratchDir ? { scratchDir } : {}),
149
151
  avatars: this.#avatars(),
150
152
  ...(group
151
153
  ? {
@@ -35,6 +35,8 @@ export interface AgentTeamOptions
35
35
  workDir: string;
36
36
  /** The host account the agents' shell and file tools run as. */
37
37
  shellUser: string;
38
+ /** The scratch dir the agents' shell writes temporary files to. */
39
+ scratchDir?: string;
38
40
  /** The prompt every persona shares, the assistant's included; each agent's starts with it. */
39
41
  sharedPrompt: string;
40
42
  /** The shared prompt for speakers other than the owner, when `sharedPrompt` speaks to the owner. */
@@ -1,3 +1,4 @@
1
+ import { chmodSync, lstatSync, mkdirSync } from "node:fs";
1
2
  import { join } from "node:path";
2
3
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
4
  import type { SQL } from "bun";
@@ -12,6 +13,7 @@ import { discordKey } from "../agents/team-keys.ts";
12
13
  import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
13
14
  import type { AgentRuntime, AgentSessions } from "../contract/runtime.ts";
14
15
  import type { ChannelKey } from "../domain/conversation.ts";
16
+ import { ConfigError } from "../domain/errors.ts";
15
17
  import type { OwnerIdentity } from "../identity.ts";
16
18
  import { ConfirmationJudge } from "../judging/confirmation-judge.ts";
17
19
  import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
@@ -53,6 +55,11 @@ export interface AgentServerOptions {
53
55
  judgeThreshold: number;
54
56
  /** The shell's shared working directory. */
55
57
  workDir: string;
58
+ /**
59
+ * The agents' scratch dir, created with mode 0700 when the agent server starts: their shell's
60
+ * TMPDIR, where writes and removals run without a hold.
61
+ */
62
+ scratchDir?: string;
56
63
  /** The host account the agents' shell and file tools run as. */
57
64
  shellUser: string;
58
65
  /** The prompt every agent starts with, and the one for speakers other than the owner. */
@@ -178,6 +185,7 @@ export function agentServerPlugin(
178
185
  owner: options.owner,
179
186
  toolTiers: context.toolTiers,
180
187
  shellUser: options.shellUser,
188
+ ...(options.scratchDir ? { scratchDir: options.scratchDir } : {}),
181
189
  store,
182
190
  channels: connection.agentChannels(options.guildId),
183
191
  studio,
@@ -227,6 +235,9 @@ export function agentServerPlugin(
227
235
  });
228
236
  const agentSessions: AgentSessions = {
229
237
  workDir: options.workDir,
238
+ ...(options.scratchDir
239
+ ? { scratchDir: ensureScratchDir(options.scratchDir) }
240
+ : {}),
230
241
  modelOf: (name) => built.modelOf(name),
231
242
  skills: (name) => built.skillsOf(name),
232
243
  turnChannel: (scope) => built.turnChannel(scope),
@@ -339,3 +350,18 @@ export function agentServerPlugin(
339
350
  },
340
351
  };
341
352
  }
353
+
354
+ /**
355
+ * Creates the scratch dir for the service user alone. A shared temp dir lets anyone create the
356
+ * path first, so a symlink or a dir the service user does not own is refused.
357
+ */
358
+ export function ensureScratchDir(dir: string): string {
359
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
360
+ const stat = lstatSync(dir);
361
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.())
362
+ throw new ConfigError(
363
+ `the scratch dir ${dir} is not a directory of this service's user. Remove it or set scratchDir to another path.`,
364
+ );
365
+ chmodSync(dir, 0o700);
366
+ return dir;
367
+ }
@@ -1,3 +1,5 @@
1
+ import { tmpdir } from "node:os";
2
+ import { join } from "node:path";
1
3
  import type { AgentSeed } from "../agents/agent-rules.ts";
2
4
  import { ConfigError } from "../domain/errors.ts";
3
5
  import { isLocale, type Locale } from "../i18n/index.ts";
@@ -103,6 +105,11 @@ export interface RoundtableConfig {
103
105
  avatar?: string;
104
106
  /** The agents' shared working directory; default `<dataDir>/work`. */
105
107
  workDir?: string;
108
+ /**
109
+ * The agents' scratch dir: their shell runs with TMPDIR pointing to it, and writes and removals
110
+ * inside it run without a hold. Default `<os temp dir>/<discord.rootCommand>-scratch`.
111
+ */
112
+ scratchDir?: string;
106
113
  /**
107
114
  * Where the skill registry reads built-in skills and keeps linked repositories. `false` leaves
108
115
  * the `skills` addon out: agents carry no skills, and no skill tools exist. Stored skills stay
@@ -191,6 +198,7 @@ const schema = shape({
191
198
  }),
192
199
  avatar: optional(text),
193
200
  workDir: optional(text),
201
+ scratchDir: optional(text),
194
202
  skills: optional(
195
203
  orOff(shape({ builtinDir: optional(text), reposDir: optional(text) })),
196
204
  ),
@@ -243,6 +251,7 @@ export interface ResolvedConfig {
243
251
  };
244
252
  avatar?: string;
245
253
  workDir: string;
254
+ scratchDir: string;
246
255
  /** `false` when the skills addon is off. */
247
256
  skills: false | { builtinDir?: string; reposDir?: string };
248
257
  memory: boolean;
@@ -265,6 +274,9 @@ export function resolveConfig(input: unknown): ResolvedConfig {
265
274
  const name = config.name ?? "Roundtable";
266
275
  const model = modelOf(config.model, "model");
267
276
  const thinking = config.thinking ?? "medium";
277
+ const rootCommand =
278
+ config.discord.rootCommand ??
279
+ name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
268
280
  return {
269
281
  name,
270
282
  owner: {
@@ -276,9 +288,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
276
288
  token: config.discord.token,
277
289
  guild: config.discord.guild,
278
290
  entryChannel: config.discord.entryChannel,
279
- rootCommand:
280
- config.discord.rootCommand ??
281
- name.toLowerCase().replace(/[^a-z0-9-]+/g, "-"),
291
+ rootCommand,
282
292
  admin: config.discord.admin ?? true,
283
293
  ...(config.discord.refusalHint === undefined
284
294
  ? {}
@@ -321,6 +331,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
321
331
  },
322
332
  ...(config.avatar ? { avatar: config.avatar } : {}),
323
333
  workDir: config.workDir ?? `${config.dataDir}/work`,
334
+ scratchDir: config.scratchDir ?? join(tmpdir(), `${rootCommand}-scratch`),
324
335
  skills: config.skills ?? {},
325
336
  memory: config.memory ?? true,
326
337
  ...(config.ops ? { ops: config.ops } : {}),
@@ -82,6 +82,11 @@ export interface LoadedSkill {
82
82
  export interface AgentSessions {
83
83
  /** The shell's working directory, shared by every agent; writes outside it are held. */
84
84
  workDir: string;
85
+ /**
86
+ * The host's scratch dir: the agents' shell runs with TMPDIR pointing to it, and writes and
87
+ * removals inside it run without a hold, like the workspace's.
88
+ */
89
+ scratchDir?: string;
85
90
  /**
86
91
  * The skills the agent carries, read at the start of every run; a change rebuilds its
87
92
  * sessions, keeping their history.
@@ -211,6 +211,7 @@ export async function defineRoundtable(
211
211
  thinking: config.thinking,
212
212
  judgeThreshold: config.judge.threshold,
213
213
  workDir: config.workDir,
214
+ scratchDir: config.scratchDir,
214
215
  shellUser: userInfo().username,
215
216
  prompts: { shared, guest },
216
217
  avatarListener: "public",
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
+ }