@huseynsnmz/pi-suite 0.2.1 → 0.2.3
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/README.md +8 -1
- package/package.json +6 -1
- package/packages/board/README.md +1 -1
- package/packages/commands/README.md +1 -1
- package/packages/commands/src/index.ts +22 -0
- package/packages/frame/README.md +8 -2
- package/packages/frame/docs/reference.md +1 -1
- package/packages/frame/src/index.ts +8 -3
- package/packages/frame/src/kit/settings/files.ts +8 -8
- package/packages/frame/src/kit/settings/store.ts +3 -1
- package/packages/frame/src/ui/editor.ts +64 -7
- package/packages/frame/src/ui/history-search.ts +21 -0
- package/packages/frame/src/ui/pi-entries.ts +2 -0
- package/packages/frame/src/ui/thinking.ts +34 -0
- package/packages/memory/README.md +1 -1
- package/packages/modes/README.md +6 -2
- package/packages/modes/src/index.ts +9 -7
- package/packages/modes/src/modes.ts +18 -1
- package/packages/router/README.md +29 -9
- package/packages/router/src/config.ts +28 -27
- package/packages/router/src/index.ts +61 -13
- package/packages/router/src/route.ts +16 -10
- package/packages/subscription/LICENSE +21 -0
- package/packages/subscription/README.md +60 -0
- package/packages/subscription/package.json +23 -0
- package/packages/subscription/src/anchors.ts +75 -0
- package/packages/subscription/src/billing.ts +39 -0
- package/packages/subscription/src/catalog.ts +310 -0
- package/packages/subscription/src/cc-sessions.ts +122 -0
- package/packages/subscription/src/config.ts +34 -0
- package/packages/subscription/src/convert.ts +161 -0
- package/packages/subscription/src/index.ts +69 -0
- package/packages/subscription/src/mcp.ts +78 -0
- package/packages/subscription/src/models.ts +65 -0
- package/packages/subscription/src/prompt.ts +57 -0
- package/packages/subscription/src/provider.ts +617 -0
- package/packages/subscription/src/session.ts +294 -0
- package/packages/subscription/src/util.ts +73 -0
- package/packages/web/README.md +5 -5
- package/packages/web/src/config.ts +13 -23
- package/packages/web/src/index.ts +11 -7
- package/packages/web/src/reader.ts +15 -4
- package/packages/web/src/validate.ts +2 -2
- package/src/index.ts +92 -10
- package/src/settings-ui.ts +63 -0
- package/src/settings.ts +116 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.3]
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Router: each role can have its own reasoning level, chosen in `/router` from what that role's model offers.
|
|
8
|
+
- Up and Down with one line typed go through past prompts that start with it. With nothing typed they also reach this folder's other sessions.
|
|
9
|
+
- With `hideThinkingBlock` on, reasoning shows while it's written and collapses when the message is done. `Ctrl+T` opens it.
|
|
10
|
+
- New part, off until you run `/suite on subscription`: Claude models on your Claude login, based on pi-claude-subscription. It asks before anything more is billed as extra usage. See its README for the limits and what Anthropic's terms say.
|
|
11
|
+
|
|
12
|
+
- One settings file, `~/.pi/agent/suite.json`, with a section for each part. `/suite` now opens each part's settings as well as turning it on or off. Old files move in the first time Pi starts, and stay as `.bak`.
|
|
13
|
+
|
|
14
|
+
### Fixes
|
|
15
|
+
|
|
16
|
+
- `router/auto` is always listed in `/model`, and says to run `/router` until `plan` and `build` are set.
|
|
17
|
+
- `/clear` keeps the current model and reasoning level.
|
|
18
|
+
|
|
19
|
+
## [0.2.2]
|
|
20
|
+
|
|
21
|
+
### Fixes
|
|
22
|
+
|
|
23
|
+
- Pi's `codemode` tool now works in plan and read-only modes. Each tool a script calls is still checked, so a script can't write there. Another package's tool named `codemode` stays blocked.
|
|
24
|
+
- A script or other tool can no longer call `edit`, `write`, `powershell`, or bash that isn't read-only, in any mode. The model makes those as their own tool calls, so you see each one and `router/auto` sees that editing has started. With `codemode.mode` set to `only` this is off in normal, auto and yolo, because scripts are then the only way to reach tools.
|
|
25
|
+
|
|
3
26
|
## [0.2.1]
|
|
4
27
|
|
|
5
28
|
### Fixes
|
package/README.md
CHANGED
|
@@ -11,13 +11,20 @@ One package for [Pi](https://pi.dev): modes and permissions, a framed screen, a
|
|
|
11
11
|
| `frame` | The start card, framed editor, tool boxes, footer and the agent's todo list | [README](packages/frame/README.md) |
|
|
12
12
|
| `board` | A board for starting, watching and attaching to background sessions | [README](packages/board/README.md) |
|
|
13
13
|
| `commands` | `/clear` and `/security-review` | [README](packages/commands/README.md) |
|
|
14
|
+
| `subscription` | Off until you turn it on. Claude models on your Claude login, asking before anything more is billed as extra usage | [README](packages/subscription/README.md) |
|
|
15
|
+
|
|
16
|
+
## Settings
|
|
17
|
+
|
|
18
|
+
Run `/suite` to turn parts on or off and change their settings. They're all kept in `~/.pi/agent/suite.json`, with a section for each part, so you can also edit it by hand. Older files such as `router.json` and `web.json` move in the first time Pi starts, and stay beside it as `.bak`. If one can't be read it stays where it is, and you're told, so a broken file can't silently turn off a setting.
|
|
19
|
+
|
|
20
|
+
There's no setting per project, so a repository can't choose where your code goes. The only exception is the frame's look, in `.pi/extensions/pi-harness/settings.json`, once you trust the project. Pi's own files, and the settings of other packages, such as `pi-permission-system`, stay where they are.
|
|
14
21
|
|
|
15
22
|
## Install
|
|
16
23
|
|
|
17
24
|
Requires Pi 1.5 or newer.
|
|
18
25
|
|
|
19
26
|
```bash
|
|
20
|
-
pi install npm:@huseynsnmz/pi-suite@0.2.
|
|
27
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.3
|
|
21
28
|
```
|
|
22
29
|
|
|
23
30
|
If you installed any of these separately, remove them first:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huseynsnmz/pi-suite",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "One Pi package for all of it: modes and permissions, a framed screen, an agent board, memory, web tools with a separate page reader, and a model router. Turn off what you don't want with /suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"README.md",
|
|
29
29
|
"CHANGELOG.md",
|
|
30
30
|
"LICENSE",
|
|
31
|
+
"packages/subscription/src",
|
|
32
|
+
"packages/subscription/package.json",
|
|
33
|
+
"packages/subscription/README.md",
|
|
34
|
+
"packages/subscription/LICENSE",
|
|
31
35
|
"packages/commands/src",
|
|
32
36
|
"packages/commands/package.json",
|
|
33
37
|
"packages/commands/README.md",
|
|
@@ -91,6 +95,7 @@
|
|
|
91
95
|
"test:e2e": "python3 test/e2e/run.py"
|
|
92
96
|
},
|
|
93
97
|
"dependencies": {
|
|
98
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.308",
|
|
94
99
|
"@xterm/headless": "6.0.0",
|
|
95
100
|
"node-pty": "1.1.0",
|
|
96
101
|
"@mozilla/readability": "0.6.0",
|
package/packages/board/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Two commands for Pi. Part of [pi-suite](../../README.md); turn them off with `/s
|
|
|
4
4
|
|
|
5
5
|
## /clear
|
|
6
6
|
|
|
7
|
-
Clears the conversation and starts a new session,
|
|
7
|
+
Clears the conversation and starts a new session, keeping the model and reasoning level you have selected. `/new` goes back to your saved default instead. If the agent is working, it stops first. The old session is kept, and `/resume` brings it back.
|
|
8
8
|
|
|
9
9
|
## /security-review
|
|
10
10
|
|
|
@@ -5,6 +5,14 @@ import { changesToReview, type GitRun, reviewPrompt } from "./security-review.ts
|
|
|
5
5
|
|
|
6
6
|
const REVIEW_MESSAGE = "security-review";
|
|
7
7
|
|
|
8
|
+
/** The model /clear hands to the session it starts. Pi's own new session would take the saved default. */
|
|
9
|
+
const KEPT = Symbol.for("pi-suite:clear-keeps");
|
|
10
|
+
interface Kept {
|
|
11
|
+
provider: string;
|
|
12
|
+
id: string;
|
|
13
|
+
thinking: ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export default function piCommands(pi: ExtensionAPI): void {
|
|
9
17
|
pi.registerCommand("clear", {
|
|
10
18
|
description: "Clear the conversation and start a new session",
|
|
@@ -13,10 +21,24 @@ export default function piCommands(pi: ExtensionAPI): void {
|
|
|
13
21
|
ctx.abort();
|
|
14
22
|
await ctx.waitForIdle();
|
|
15
23
|
}
|
|
24
|
+
const store = globalThis as Record<symbol, Kept | undefined>;
|
|
25
|
+
if (ctx.model) store[KEPT] = { provider: ctx.model.provider, id: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
16
26
|
await ctx.newSession();
|
|
27
|
+
delete store[KEPT]; // Still here only if it was cancelled.
|
|
17
28
|
},
|
|
18
29
|
});
|
|
19
30
|
|
|
31
|
+
// Runs in the new session, where this extension is a new instance, so what /clear kept can't live in it.
|
|
32
|
+
pi.on("session_start", async (event, ctx) => {
|
|
33
|
+
const store = globalThis as Record<symbol, Kept | undefined>;
|
|
34
|
+
const kept = store[KEPT];
|
|
35
|
+
delete store[KEPT];
|
|
36
|
+
if (event.reason !== "new" || kept === undefined) return;
|
|
37
|
+
const model = ctx.modelRegistry.find(kept.provider, kept.id);
|
|
38
|
+
if (model !== undefined) await pi.setModel(model);
|
|
39
|
+
pi.setThinkingLevel(kept.thinking);
|
|
40
|
+
});
|
|
41
|
+
|
|
20
42
|
// The diff goes to the model, and only this one line goes on screen.
|
|
21
43
|
pi.registerMessageRenderer<{ summary: string }>(REVIEW_MESSAGE, (message, _options, theme) =>
|
|
22
44
|
new Text(theme.fg("muted", message.details?.summary ?? "Security review"), 1, 0),
|
package/packages/frame/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Requires Pi 1.5 or newer.
|
|
|
19
19
|
2. Install it:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
pi install npm:@huseynsnmz/pi-suite@0.2.
|
|
22
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.3
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
3. Restart Pi. The start card says `π-frame`.
|
|
@@ -44,6 +44,8 @@ Some pieces, such as rooms, boards, peers and the remote page, appear only with
|
|
|
44
44
|
|---|---|
|
|
45
45
|
| `Ctrl+C` | Stops a run and says `Aborted`. With nothing running, it clears the editor and says to press it again within 2 seconds to exit |
|
|
46
46
|
| `Ctrl+R` | Search what you've typed, here and in this folder's other sessions. `Ctrl+R` again goes further back, `Enter` sends, `Esc` cancels, any other key keeps the match to edit. It replaces Pi's Rename on this key; `/name` still renames |
|
|
47
|
+
| `Up` / `Down` | With nothing typed, browse past prompts, including this folder's other sessions. With one line typed, go through prompts that start with it, as in a shell |
|
|
48
|
+
| `Ctrl+T` | Open or collapse finished reasoning |
|
|
47
49
|
| `Alt+O` | Scroll through the session's tool output, one call at a time |
|
|
48
50
|
| `Ctrl+O` | Expand every call in the transcript |
|
|
49
51
|
| `/todos` | Show the agent's todo list. `/todos clear` empties it and tells the agent |
|
|
@@ -78,7 +80,7 @@ More detail is in [How it works](docs/how-it-works.md) and the [reference](docs/
|
|
|
78
80
|
- **`gh` and `glab` still run in their repositories,** and they call `git` for reads such as the remote list.
|
|
79
81
|
- **`glab` needs a host with `gitlab` in its name.** It starts for that remote, and if a token is set in the environment (`GITLAB_TOKEN`), only for `gitlab.com` or `GITLAB_HOST`, because a repository picks its own remote.
|
|
80
82
|
- **The `git status` protections cover the ways to run code that I know of.** New Git options could add others.
|
|
81
|
-
- **The parts of `@adeildo/pi-kit` 6.0.0 that frame uses are copied into `src/kit`,** with its license, so upstream fixes have to be copied in by hand. Its settings stay in `~/.pi/agent/
|
|
83
|
+
- **The parts of `@adeildo/pi-kit` 6.0.0 that frame uses are copied into `src/kit`,** with its license, so upstream fixes have to be copied in by hand. Its settings stay in `~/.pi/agent/suite.json`.
|
|
82
84
|
- **Another session's view isn't fully covered.** Opening one needs other pi-harness packages, and that view hasn't been tested.
|
|
83
85
|
|
|
84
86
|
## Development
|
|
@@ -103,3 +105,7 @@ MIT. See [LICENSE](LICENSE), which keeps the original copyright notice.
|
|
|
103
105
|
- [pi-look and pi-harness](https://github.com/felipeadeildo/pi-harness) by [adeildo](https://adeildo.dev)
|
|
104
106
|
- The framed editor comes from [pi-open-tui](https://github.com/OldSuns/pi-open-tui), by way of pi-look.
|
|
105
107
|
- The segments with priorities come from [oh-my-pi](https://github.com/can1357/oh-my-pi), by way of pi-look.
|
|
108
|
+
|
|
109
|
+
## Reasoning
|
|
110
|
+
|
|
111
|
+
Set `"hideThinkingBlock": true` in `~/.pi/agent/settings.json`, or press `Ctrl+T` until reasoning is collapsed. It then shows while the model is writing it and collapses to one line once the message is done. `Ctrl+T` opens it again, or click it to open just that one. `Ctrl+T` is Pi's own setting, so it applies to every message and stays set in later sessions.
|
|
@@ -27,7 +27,7 @@ Syntax tokens are borrowed on purpose: a theme spends its hues there, so they gi
|
|
|
27
27
|
|
|
28
28
|
## Settings
|
|
29
29
|
|
|
30
|
-
They live
|
|
30
|
+
They live under `frame` in `~/.pi/agent/suite.json`.
|
|
31
31
|
|
|
32
32
|
| Key | Default | Does |
|
|
33
33
|
| ------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from "node:path";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ACCOUNT,
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
ExtensionContext,
|
|
24
24
|
ReadonlyFooterDataProvider,
|
|
25
25
|
} from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import { keyText, parseSkillBlock } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { getAgentDir, keyText, parseSkillBlock } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { Key, type KeyId, type TUI } from "@earendil-works/pi-tui";
|
|
28
28
|
|
|
29
29
|
import { type GitState, readGit } from "./data/git.ts";
|
|
@@ -157,7 +157,7 @@ export const frameFeature = defineFeature({
|
|
|
157
157
|
|
|
158
158
|
for (const entry of FRAME_SETTINGS) entry.listen(scope, repaint);
|
|
159
159
|
frame.listen(scope, () => live.ctx && installEditor(live.ctx, screen, live, scope));
|
|
160
|
-
const visitsFile = join(
|
|
160
|
+
const visitsFile = join(getAgentDir(), "extensions", "pi-harness", "frame-visits.json");
|
|
161
161
|
let visited = false;
|
|
162
162
|
let lastVisit: LastVisit | undefined;
|
|
163
163
|
|
|
@@ -527,6 +527,9 @@ function pressCtrlC(live: Live, clearEditor: () => void): void {
|
|
|
527
527
|
live.repaint?.();
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
+
/** Everything collapsed has finished, because live reasoning stays open. */
|
|
531
|
+
const THOUGHT_LABEL = "Thought · ctrl+t to expand";
|
|
532
|
+
|
|
530
533
|
function installEditor(
|
|
531
534
|
ctx: ExtensionContext,
|
|
532
535
|
screen: Screen,
|
|
@@ -535,9 +538,11 @@ function installEditor(
|
|
|
535
538
|
): void {
|
|
536
539
|
if (ctx.mode !== "tui") return;
|
|
537
540
|
if (frame.get(scope) === "off") {
|
|
541
|
+
ctx.ui.setHiddenThinkingLabel(undefined);
|
|
538
542
|
ctx.ui.setEditorComponent(undefined);
|
|
539
543
|
return;
|
|
540
544
|
}
|
|
545
|
+
ctx.ui.setHiddenThinkingLabel(THOUGHT_LABEL);
|
|
541
546
|
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
542
547
|
live.tui = tui;
|
|
543
548
|
applyCursor(tui, cursor);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Every app shares the settings files, so a write keeps every key it does not know.
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -14,7 +14,7 @@ export interface SettingsFile {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function globalSettingsPath(): string {
|
|
17
|
-
return join(getAgentDir(), "
|
|
17
|
+
return join(getAgentDir(), "suite.json");
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function projectSettingsPath(cwd: string): string {
|
|
@@ -40,12 +40,12 @@ export function writeSettingsFile(
|
|
|
40
40
|
options: { mode?: number } = {},
|
|
41
41
|
): void {
|
|
42
42
|
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
});
|
|
48
|
-
renameSync(temporary,
|
|
43
|
+
// A linked file stays linked, and a private one stays private. Other parts' settings share it.
|
|
44
|
+
const target = existsSync(path) ? realpathSync(path) : path;
|
|
45
|
+
const mode = options.mode ?? (existsSync(target) ? statSync(target).mode & 0o777 : undefined);
|
|
46
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
47
|
+
writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`, { encoding: "utf8", mode });
|
|
48
|
+
renameSync(temporary, target);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function lookup(data: SettingsData, id: string): unknown {
|
|
@@ -135,7 +135,9 @@ export class SettingsStore {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
#edit(path: string, change: (data: SettingsData) => void): string | undefined {
|
|
138
|
-
const { data } = readSettingsFile(path);
|
|
138
|
+
const { data, warnings } = readSettingsFile(path);
|
|
139
|
+
// Reading a broken file gives nothing, and writing that would wipe what else it holds.
|
|
140
|
+
if (warnings.length > 0) return warnings[0];
|
|
139
141
|
change(data);
|
|
140
142
|
try {
|
|
141
143
|
writeSettingsFile(path, data);
|
|
@@ -15,7 +15,15 @@ import { BOXES, border } from "../render/frame.ts";
|
|
|
15
15
|
import { paintLead, promptLead } from "../render/lead.ts";
|
|
16
16
|
import { SETTINGS_KEY, SETTINGS_SHORTCUT_INPUT } from "../render/keys.ts";
|
|
17
17
|
import type { CursorStyle } from "../settings.ts";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
findPrompt,
|
|
20
|
+
type HistorySearch,
|
|
21
|
+
type PrefixSearch,
|
|
22
|
+
shownText,
|
|
23
|
+
startPrefix,
|
|
24
|
+
startSearch,
|
|
25
|
+
stepPrefix,
|
|
26
|
+
} from "./history-search.ts";
|
|
19
27
|
import { type Screen, type SlotName, slotPieces } from "./screen.ts";
|
|
20
28
|
|
|
21
29
|
type WorkingIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
|
|
@@ -44,6 +52,8 @@ export class FrameEditor extends CustomEditor {
|
|
|
44
52
|
#hiddenBelow = 0;
|
|
45
53
|
#inset = 0;
|
|
46
54
|
#search: HistorySearch | undefined;
|
|
55
|
+
#prefix: PrefixSearch | undefined;
|
|
56
|
+
#seeded = false;
|
|
47
57
|
|
|
48
58
|
constructor(
|
|
49
59
|
tui: TUI,
|
|
@@ -77,13 +87,9 @@ export class FrameEditor extends CustomEditor {
|
|
|
77
87
|
this.#searchKey(this.#search, data);
|
|
78
88
|
return;
|
|
79
89
|
}
|
|
90
|
+
if (this.#prefixKey(data)) return;
|
|
80
91
|
if (matchesKey(data, SEARCH_KEY) && !this.isShowingAutocomplete()) {
|
|
81
|
-
|
|
82
|
-
const own: unknown = Reflect.get(this, "history");
|
|
83
|
-
this.#search = startSearch(this.getText(), [
|
|
84
|
-
...(Array.isArray(own) ? (own as string[]) : []),
|
|
85
|
-
...this.#screen.pastPrompts(),
|
|
86
|
-
]);
|
|
92
|
+
this.#search = startSearch(this.getText(), this.#history());
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
89
95
|
if (this.#keybindings.matches(data, "app.clear") && !this.isShowingAutocomplete()) {
|
|
@@ -101,6 +107,51 @@ export class FrameEditor extends CustomEditor {
|
|
|
101
107
|
if (skillQueryAt(this.getLines(), line, col) !== undefined) openAutocomplete(this);
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
/** What Up browses, then what was typed in this folder's other sessions. Read once, on first use. */
|
|
111
|
+
#history(): readonly string[] {
|
|
112
|
+
const own: unknown = Reflect.get(this, "history");
|
|
113
|
+
if (!Array.isArray(own)) return this.#screen.pastPrompts();
|
|
114
|
+
const entries = own as string[];
|
|
115
|
+
if (!this.#seeded) {
|
|
116
|
+
this.#seeded = true;
|
|
117
|
+
for (const prompt of this.#screen.pastPrompts()) {
|
|
118
|
+
if (!entries.includes(prompt)) entries.push(prompt);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return entries;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Up with one line typed looks back through prompts that start with it, and Down comes forward
|
|
126
|
+
* again. Any other key keeps what's showing. An empty prompt is left to pi's own browsing.
|
|
127
|
+
*/
|
|
128
|
+
#prefixKey(data: string): boolean {
|
|
129
|
+
const up = this.#keybindings.matches(data, "tui.editor.cursorUp");
|
|
130
|
+
const down = this.#keybindings.matches(data, "tui.editor.cursorDown");
|
|
131
|
+
if (!up && !down) {
|
|
132
|
+
this.#prefix = undefined;
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (this.isShowingAutocomplete()) return false;
|
|
136
|
+
if (this.#prefix === undefined) {
|
|
137
|
+
const typed = this.getText();
|
|
138
|
+
const entries = this.#history();
|
|
139
|
+
const browsing = Reflect.get(this, "historyIndex") !== -1;
|
|
140
|
+
if (down || typed === "" || typed.includes("\n") || browsing || !onFirstVisualLine(this)) return false;
|
|
141
|
+
const search = startPrefix(typed, entries);
|
|
142
|
+
// With nothing to go through, the arrows stay pi's.
|
|
143
|
+
if (search.matches.length === 0) return false;
|
|
144
|
+
this.#prefix = search;
|
|
145
|
+
}
|
|
146
|
+
const text = stepPrefix(this.#prefix, up ? 1 : -1);
|
|
147
|
+
if (text === undefined && down) {
|
|
148
|
+
this.#prefix = undefined;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (text !== undefined) this.setText(text);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
104
155
|
/** Enter sends the match, Esc goes back, and any other key keeps the match to edit. */
|
|
105
156
|
#searchKey(search: HistorySearch, data: string): void {
|
|
106
157
|
if (matchesKey(data, SEARCH_KEY)) {
|
|
@@ -248,6 +299,12 @@ export class FrameEditor extends CustomEditor {
|
|
|
248
299
|
}
|
|
249
300
|
}
|
|
250
301
|
|
|
302
|
+
/** The method is private. If a pi release renames it, Up just moves the cursor as before. */
|
|
303
|
+
function onFirstVisualLine(editor: CustomEditor): boolean {
|
|
304
|
+
const check: unknown = Reflect.get(editor, "isOnFirstVisualLine");
|
|
305
|
+
return typeof check === "function" && check.call(editor) === true;
|
|
306
|
+
}
|
|
307
|
+
|
|
251
308
|
/** The method is private; nothing opens if a pi release renames it. */
|
|
252
309
|
function openAutocomplete(editor: CustomEditor): void {
|
|
253
310
|
const open: unknown = Reflect.get(editor, "tryTriggerAutocomplete");
|
|
@@ -21,3 +21,24 @@ export function findPrompt(search: HistorySearch, from: number): number {
|
|
|
21
21
|
export function shownText(search: HistorySearch): string {
|
|
22
22
|
return search.at < 0 ? search.draft : (search.entries[search.at] as string);
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
/** Up with text typed: older prompts that start with it, as in a shell. */
|
|
26
|
+
export interface PrefixSearch {
|
|
27
|
+
typed: string;
|
|
28
|
+
matches: readonly string[];
|
|
29
|
+
at: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function startPrefix(typed: string, entries: readonly string[]): PrefixSearch {
|
|
33
|
+
const wanted = typed.toLowerCase();
|
|
34
|
+
const matches = [...new Set(entries)].filter((entry) => entry !== typed && entry.toLowerCase().startsWith(wanted));
|
|
35
|
+
return { typed, matches, at: -1 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One step older (1) or newer (-1), ending back on what was typed. Undefined past either end. */
|
|
39
|
+
export function stepPrefix(search: PrefixSearch, step: 1 | -1): string | undefined {
|
|
40
|
+
const next = search.at + step;
|
|
41
|
+
if (next < -1 || next >= search.matches.length) return undefined;
|
|
42
|
+
search.at = next;
|
|
43
|
+
return next < 0 ? search.typed : search.matches[next];
|
|
44
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Pi's own transcript entries in the harness frame instead of a background. Pi has no hook for
|
|
2
2
|
// drawing them, so this wraps each component's render.
|
|
3
|
+
import { showThinkingWhileLive } from "./thinking.ts";
|
|
3
4
|
import { CHANGES_CUT, CHANGES_X, entryFrame, type EntryFrame, GLYPH } from "../kit/index.ts";
|
|
4
5
|
import {
|
|
5
6
|
BashExecutionComponent,
|
|
@@ -63,6 +64,7 @@ export function framePiEntries(source: EntryPaint): void {
|
|
|
63
64
|
paint = source;
|
|
64
65
|
if (installed) return;
|
|
65
66
|
installed = true;
|
|
67
|
+
showThinkingWhileLive(() => paint() !== undefined);
|
|
66
68
|
wrap(UserMessageComponent, (self, look) =>
|
|
67
69
|
look.you ? youFrame(look, fromOf(self, look), sentInOf(self, look)) : undefined,
|
|
68
70
|
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Private in pi. If a release renames them, reasoning just follows pi's setting again. */
|
|
4
|
+
interface Message {
|
|
5
|
+
updateContent(message: { content: readonly { type: string }[] }, isStreaming?: boolean): void;
|
|
6
|
+
isStreaming?: boolean;
|
|
7
|
+
thinkingVisibilityOverrides?: Map<number, boolean>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const live = new WeakSet<object>();
|
|
11
|
+
let isActive: () => boolean = () => false;
|
|
12
|
+
let installed = false;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* With pi's setting that collapses reasoning on, it still shows while it's being written, and
|
|
16
|
+
* collapses once the message is done. Ctrl+T or a click opens it again.
|
|
17
|
+
*/
|
|
18
|
+
export function showThinkingWhileLive(active: () => boolean): void {
|
|
19
|
+
isActive = active;
|
|
20
|
+
if (installed) return;
|
|
21
|
+
installed = true;
|
|
22
|
+
const prototype = AssistantMessageComponent.prototype as unknown as Message;
|
|
23
|
+
const own = prototype.updateContent;
|
|
24
|
+
prototype.updateContent = function updateContent(this: Message, message, isStreaming = this.isStreaming) {
|
|
25
|
+
const overrides = this.thinkingVisibilityOverrides;
|
|
26
|
+
if (overrides !== undefined && isActive() && isStreaming === true) {
|
|
27
|
+
live.add(this);
|
|
28
|
+
message.content.forEach((_part, run) => overrides.set(run, false));
|
|
29
|
+
} else if (overrides !== undefined && live.delete(this)) {
|
|
30
|
+
overrides.clear();
|
|
31
|
+
}
|
|
32
|
+
own.call(this, message, isStreaming);
|
|
33
|
+
};
|
|
34
|
+
}
|
package/packages/modes/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Requires Pi 1.5 or newer.
|
|
|
23
23
|
1. Install it:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
pi install npm:@huseynsnmz/pi-suite@0.2.
|
|
26
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.3
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
2. Move pi-suite above `@gotgenes/pi-permission-system`, because `pi install` adds packages last. Otherwise, in `plan` and `read-only` you may be asked to approve an action that is then blocked anyway. It stays blocked either way, and the extension warns you at startup. Skip this step if you don't use that package.
|
|
@@ -55,7 +55,7 @@ To install from git instead, use `pi install git:github.com/huseynsnmz/pi-suite@
|
|
|
55
55
|
| `/mode default` | Pick the mode new sessions start in |
|
|
56
56
|
| `/mode default plan` | Set it directly |
|
|
57
57
|
|
|
58
|
-
The default is saved as `defaultMode` in `~/.pi/agent/
|
|
58
|
+
The default is saved as `defaultMode` under `modes` in `~/.pi/agent/suite.json`, and you can also set it in `/suite`. A resumed session keeps the mode it was in. `yolo` can't be the default, because it has to be asked for each time.
|
|
59
59
|
|
|
60
60
|
`Shift+Tab` normally cycles the thinking level, and this extension takes it over. To keep that action on another key, add this to `~/.pi/agent/keybindings.json`:
|
|
61
61
|
|
|
@@ -123,6 +123,8 @@ Without a classifier, `auto` behaves like `normal`, and the extension warns you
|
|
|
123
123
|
**`web`:** `web_fetch` and `web_search` are allowed in `plan` and `read-only`, as they only read. The same names from another package are blocked there.
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
**Codemode with `pi-permission-system`:** it asks about `codemode` like any tool, once for each script. To skip that, add `"codemode": "allow"` to `permission` in its config. Each tool a script calls is still checked against your rules.
|
|
127
|
+
|
|
126
128
|
**`pi-subagents`:** blocked in `plan` and `read-only`, because a child agent could write files. In `normal` and `auto` it is unchanged.
|
|
127
129
|
|
|
128
130
|
**`pi-router`:** `plan` mode uses its plan model, and approving a plan moves to its build model.
|
|
@@ -135,6 +137,7 @@ Without a classifier, `auto` behaves like `normal`, and the extension warns you
|
|
|
135
137
|
|
|
136
138
|
- **Allowed tools:** `read`, `grep`, `find`, `ls`, read-only `bash`, and `submit_plan` in plan mode.
|
|
137
139
|
- **Blocked tools:** `write`, `edit`, subagents, MCP tools and every other tool, including ones added later.
|
|
140
|
+
- **Codemode:** Pi's `codemode` tool runs, and each tool a script calls is checked like any other. Its `models` calls aren't tools, so they aren't checked: a script can still send data to a classifier or image model you have a login for, at your cost.
|
|
138
141
|
- **Read-only bash:** a fixed list of commands, such as `ls`, `cat`, `rg` and `git status`. A command is refused if it uses `$`, backticks, redirects, subshells, background jobs, unquoted globs, control characters or a path to a binary. Options that write or run code are refused too, including bundled and abbreviated forms such as `sort -uo` and `sort --outp`. Quote a glob you mean literally, as in `find . -name '*.ts'`.
|
|
139
142
|
|
|
140
143
|
Any other command asks you in `plan` and is blocked in `read-only`.
|
|
@@ -145,6 +148,7 @@ These modes keep an honest agent on track. They are not a sandbox.
|
|
|
145
148
|
|
|
146
149
|
- **Reads are not restricted by this extension.** A read-only command can still read secrets, so keep `deny` rules for them in your permission system.
|
|
147
150
|
- **A repo can already be malicious.** Git can run code from a repository's config and hooks, even for `git status`, and a plain clone can bring them. In `plan` and `read-only`, git commands stop counting as read-only where a repository sets anything unusual or has a hook that reading can fire, which covers what's known today. Other tools can have their own ways in, so use a container for repos you don't trust.
|
|
151
|
+
- **Scripts can't change things, in any mode.** A script or other tool can't call `edit`, `write`, `powershell`, or bash that isn't read-only, so each change is its own visible step. The exception is `codemode.mode` set to `only` in `normal`, `auto` and `yolo`, where scripts are the only way to reach tools. There a script's edits aren't in the transcript, so `router/auto` can stay on its plan model. The same goes for any setup with `modes` turned off in `/suite`.
|
|
148
152
|
- **Child agents started earlier keep running.** Switching mode doesn't stop a subagent that is already running.
|
|
149
153
|
- **The classifier hook relies on an internal detail.** There is no public way to switch a registered judge on and off, so this extension wraps it when it registers. It uses the global key that `pi-permission-system` documents for its service map. If that breaks, you get a warning on your first prompt that the judge may auto-approve outside `auto`.
|
|
150
154
|
|
|
@@ -10,7 +10,8 @@ import { registerInterview } from "./interview.ts";
|
|
|
10
10
|
import { registerReviewTools } from "./review/tools.ts";
|
|
11
11
|
import { aliasDefinitions, DENY_OFF_PROMPT, withAliases, withoutAliases } from "./deny-off.ts";
|
|
12
12
|
import { clearGate, hasPermissionSystem, installJudgeGate, JUDGE_NAMES, judgeGated, registerYoloLink, setGateMode, YOLO_LINK } from "./judge-gate.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { readSection, writeSection } from "../../../src/settings.ts";
|
|
14
|
+
import { decideToolCall, displayCommand, fromExpectedPackage, notReadOnly, isMode, scriptedChange, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, resumedMode, startingMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
|
|
14
15
|
|
|
15
16
|
const STATUS_KEY = "pi-modes";
|
|
16
17
|
const DENY_STATUS_KEY = "pi-modes-deny";
|
|
@@ -87,20 +88,16 @@ function packagesLoadThisFirst(): boolean {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
const configPath = () => join(getAgentDir(), "extensions", "pi-modes", "config.json");
|
|
91
|
-
|
|
92
91
|
function readConfig(): Record<string, unknown> {
|
|
93
92
|
try {
|
|
94
|
-
|
|
95
|
-
return typeof config === "object" && config !== null ? (config as Record<string, unknown>) : {};
|
|
93
|
+
return readSection(getAgentDir(), "modes");
|
|
96
94
|
} catch {
|
|
97
95
|
return {};
|
|
98
96
|
}
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
function saveDefaultMode(mode: Mode): void {
|
|
102
|
-
|
|
103
|
-
writeFileSync(configPath(), `${JSON.stringify({ ...readConfig(), defaultMode: mode }, null, 2)}\n`);
|
|
100
|
+
writeSection(getAgentDir(), "modes", { ...readConfig(), defaultMode: mode });
|
|
104
101
|
}
|
|
105
102
|
|
|
106
103
|
function restoredMode(ctx: ExtensionContext): Mode {
|
|
@@ -239,6 +236,11 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
239
236
|
if (ctx.hasUI) ctx.ui.notify("The sandbox went off, so yolo ended.", "warning");
|
|
240
237
|
return { block: true, reason: "Yolo ended because the sandbox is off." };
|
|
241
238
|
}
|
|
239
|
+
// With codemode.mode set to "only", scripts are the one way to reach any tool, so they have to be able to write.
|
|
240
|
+
if (event.parentToolCallId !== undefined && pi.getSettings().codemode?.mode !== "only") {
|
|
241
|
+
const reason = scriptedChange(event.toolName, event.input);
|
|
242
|
+
if (reason !== undefined) return { block: true, reason };
|
|
243
|
+
}
|
|
242
244
|
let decision = decideToolCall(mode, event.toolName, event.input);
|
|
243
245
|
if (decision.action === "allow" && (mode === "plan" || mode === "read-only") && event.toolName === "bash") {
|
|
244
246
|
const risk = runsGit((event.input as { command: string }).command) ? await gitRisk(ctx.cwd) : undefined;
|
|
@@ -10,9 +10,11 @@ const CYCLE: readonly Mode[] = ["normal", "auto", "plan", "read-only"];
|
|
|
10
10
|
|
|
11
11
|
export const SUBMIT_PLAN_TOOL = "submit_plan";
|
|
12
12
|
export const ASK_TOOL = "ask_questions";
|
|
13
|
+
const CODEMODE_TOOL = "codemode";
|
|
13
14
|
|
|
14
15
|
// pi-web's tools only read, and a separate model reads the page for them.
|
|
15
|
-
|
|
16
|
+
// Each tool a codemode script calls comes through this check itself, so the script needs none of its own.
|
|
17
|
+
const READ_TOOLS = new Set(["read", "grep", "find", "ls", "web_fetch", "web_search", "todo", ASK_TOOL, CODEMODE_TOOL]);
|
|
16
18
|
|
|
17
19
|
/** Allowed for what pi-suite's own do. Another package's tool with the name could do anything. */
|
|
18
20
|
const SUITE_TOOLS: ReadonlySet<string> = new Set(["todo", "web_fetch", "web_search", ASK_TOOL]);
|
|
@@ -28,6 +30,7 @@ function realPath(path: string): string | undefined {
|
|
|
28
30
|
|
|
29
31
|
/** The whole suite loads from one file, so that file is what these tools must come from. */
|
|
30
32
|
export function fromExpectedPackage(toolName: string, path: string | undefined, entry = SUITE_ENTRY): boolean {
|
|
33
|
+
if (toolName === CODEMODE_TOOL) return path === "builtin:codemode";
|
|
31
34
|
if (!SUITE_TOOLS.has(toolName)) return true;
|
|
32
35
|
const expected = realPath(entry);
|
|
33
36
|
return path !== undefined && expected !== undefined && realPath(path) === expected;
|
|
@@ -84,6 +87,20 @@ export function decideToolCall(mode: Mode, toolName: string, input: unknown): To
|
|
|
84
87
|
return { action: "block", reason: `${mode} mode blocks ${toolName}. ${leave}` };
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Why a tool can't make this call from inside a script, if it can't. The transcript only shows the script,
|
|
92
|
+
* so a change must be its own step: you can see it, and the router can see that editing has started.
|
|
93
|
+
*/
|
|
94
|
+
export function scriptedChange(toolName: string, input: unknown): string | undefined {
|
|
95
|
+
const asOwnStep = "Call it directly, not from a script.";
|
|
96
|
+
if (/(^|_)(edit|write)$/.test(toolName) || toolName === "powershell") return `${toolName} changes files. ${asOwnStep}`;
|
|
97
|
+
if (!/(^|_)bash$/.test(toolName)) return undefined;
|
|
98
|
+
const command = (input as { command?: unknown } | null)?.command;
|
|
99
|
+
if (typeof command !== "string") return `${toolName} needs a command. ${asOwnStep}`;
|
|
100
|
+
const verdict = classifyBash(command);
|
|
101
|
+
return verdict.readOnly ? undefined : `${verdict.reason}. ${asOwnStep}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
/**
|
|
88
105
|
* Makes a command safe to show in a dialog. Control and bidirectional characters
|
|
89
106
|
* become visible escapes, so a command can't hide or reorder what the user sees.
|