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 +23 -0
- package/docs/plugins.md +73 -3
- package/package.json +2 -1
- package/src/core/agents/agent-prompt.ts +3 -1
- package/src/core/agents/agent-team.ts +2 -0
- package/src/core/agents/team-options.ts +2 -0
- package/src/core/builtin/agent-server.ts +26 -0
- package/src/core/config/config.ts +14 -3
- package/src/core/contract/runtime.ts +5 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/holds.ts +2 -0
- package/src/core/modules/host-shell/git-push.ts +112 -0
- package/src/core/modules/host-shell/shell-paths.ts +160 -0
- package/src/core/modules/host-shell/shell-policy.ts +111 -48
- package/src/core/runtime/conversation-sessions.ts +7 -2
- package/src/core/runtime/extensions/confirmation-gate.ts +6 -7
- package/src/core/runtime/session-factory.ts +16 -0
- package/src/kit/index.ts +20 -0
- package/src/kit/jev.ts +357 -0
- package/src/kit/shell.ts +2 -0
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 `
|
|
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.
|
|
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
|
+
}
|