pi-quiet 0.1.2 → 0.2.0

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/README.md CHANGED
@@ -19,16 +19,13 @@ rendering returns in full — complete commands, full output, truncation
19
19
  warnings. Press it again to re-collapse. It's a live toggle over the whole
20
20
  transcript: work collapsed, expand when something looks off.
21
21
 
22
- ## Display-only, by construction
22
+ ## What it owns, by construction
23
23
 
24
- The extension re-registers the built-in `bash` tool via pi's own
25
- `createBashToolDefinition` and overrides only the two render slots. Execution
26
- *is* the built-in tool:
24
+ The extension re-registers the built-in `bash` tool via pi's own `createBashToolDefinition`. It is the one package on a rig that registers `bash`, and it owns three things about it:
27
25
 
28
- - the model receives the full, untruncated-by-us output;
29
- - permission systems, reviewers, and sandboxes that hook execution are
30
- unaffected;
31
- - session logs store the full result.
26
+ - **Rendering.** The two render slots above. Execution *is* the built-in tool: the model receives the full output, permission systems, reviewers, and sandboxes that hook execution are unaffected, and session logs store the full result.
27
+ - **pi's shell settings.** The built-in is constructed with `shellCommandPrefix` and `shellPath` — read with the same settings pi itself would use, including pi's own project-trust decision for the current directory (`session_start` is where that decision becomes available, so that is where the tool is registered). A trusted project's `.pi/settings.json` contributes its `shellCommandPrefix` and `shellPath`; an untrusted project's are ignored, exactly as pi ignores them. Re-registration on each `session_start` means a `/reload` after editing settings picks up the new prefix.
28
+ - **Session identity.** Every command's environment carries `AGENT_SESSION_ID`, set from pi's per-command `PI_SESSION_ID`. Tools that scope themselves to the session that ran them (muster, galley, tackle) read that neutral name. It is set per command from pi's live session, never from the extension's process environment, so an in-process subagent can never leave the parent's commands carrying a child's id.
32
29
 
33
30
  ## Install
34
31
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-quiet",
3
- "version": "0.1.2",
4
- "description": "Calm tool rendering for the pi coding agent: each bash call collapses to one line; ctrl+o brings back full built-in rendering. Display-only execution, permissions, and model context are untouched.",
3
+ "version": "0.2.0",
4
+ "description": "This rig's bash tool for the pi coding agent: calm one-line rendering (ctrl+o expands), pi's shell settings honoured, AGENT_SESSION_ID stamped per command from PI_SESSION_ID. Execution is still pi's built-in.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -15,10 +15,12 @@
15
15
  "pi-coding-agent",
16
16
  "tui",
17
17
  "tool-output",
18
- "quiet"
18
+ "quiet",
19
+ "session-identity"
19
20
  ],
20
21
  "files": [
21
22
  "src",
23
+ "!src/*.test.ts",
22
24
  "README.md"
23
25
  ],
24
26
  "pi": {
@@ -27,11 +29,13 @@
27
29
  ]
28
30
  },
29
31
  "scripts": {
30
- "typecheck": "tsc --noEmit -p tsconfig.json"
32
+ "typecheck": "tsc --noEmit -p tsconfig.json",
33
+ "test": "node --experimental-strip-types --test src/*.test.ts"
31
34
  },
32
35
  "devDependencies": {
33
36
  "@earendil-works/pi-coding-agent": "^0.84.3",
34
37
  "@earendil-works/pi-tui": "^0.84.3",
38
+ "@types/node": "^22.20.1",
35
39
  "typescript": "^5.6.0"
36
40
  }
37
41
  }
@@ -0,0 +1,39 @@
1
+ import type { BashSpawnContext, BashToolOptions } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const SESSION_ID_VAR = "AGENT_SESSION_ID";
4
+
5
+ // The two pi settings the built-in bash tool is constructed with. pi's own
6
+ // SettingsManager satisfies this; tests pass a literal.
7
+ export type ShellSettings = {
8
+ getShellCommandPrefix(): string | undefined;
9
+ getShellPath(): string | undefined;
10
+ };
11
+
12
+ // Stamps the harness-neutral session id on every command from pi's own
13
+ // per-command PI_SESSION_ID, which pi injects into `context.env` before the
14
+ // hook runs. Per command, from pi's live session manager, never from
15
+ // process.env: an in-process child session (pi-subagents) can write
16
+ // process.env, but it cannot change what pi puts here for THIS command.
17
+ // When pi did not expose a session (exposeSessionEnvironment: false), an
18
+ // inherited value is removed rather than passed through — a command never
19
+ // carries an identity that is not its session's.
20
+ export function identitySpawnHook(context: BashSpawnContext): BashSpawnContext {
21
+ const env: NodeJS.ProcessEnv = { ...context.env };
22
+ const id = env.PI_SESSION_ID;
23
+ if (id === undefined || id === "") delete env[SESSION_ID_VAR];
24
+ else env[SESSION_ID_VAR] = id;
25
+ return { ...context, env };
26
+ }
27
+
28
+ // Options for createBashToolDefinition that match what pi itself passes to
29
+ // the built-in (settings.shellCommandPrefix and settings.shellPath) plus the
30
+ // identity hook. Unset settings are omitted so pi's defaults apply.
31
+ export function buildBashOptions(settings: ShellSettings): BashToolOptions {
32
+ const commandPrefix = settings.getShellCommandPrefix();
33
+ const shellPath = settings.getShellPath();
34
+ return {
35
+ ...(commandPrefix !== undefined ? { commandPrefix } : {}),
36
+ ...(shellPath !== undefined ? { shellPath } : {}),
37
+ spawnHook: identitySpawnHook,
38
+ };
39
+ }
package/src/index.ts CHANGED
@@ -1,23 +1,36 @@
1
1
  /**
2
- * quiet-tools: collapse bash tool chatter to one line per call.
2
+ * pi-quiet: this rig's bash tool registration.
3
3
  *
4
- * Re-registers the built-in bash tool (execution untouched it IS the
5
- * built-in, via createBashToolDefinition) and overrides only the two render
6
- * slots. Collapsed (the default): the call renders as a single truncated
7
- * `$ command` line and the result as a single muted summary line. Expanded
8
- * (ctrl+o / app.tools.expand): both slots defer to the built-in renderer,
9
- * so the full command, streamed output, truncation warnings, and duration
10
- * come back exactly as stock pi shows them.
4
+ * Re-registers the built-in bash tool via pi's own createBashToolDefinition
5
+ * and owns three things about it:
11
6
  *
12
- * Display-only by design: permission gating, auto-review, and the sandbox
13
- * all hook execution, which this file never touches.
7
+ * 1. Rendering. Collapsed (the default): the call renders as a single
8
+ * truncated `$ command` line and the result as a single muted summary
9
+ * line. Expanded (ctrl+o / app.tools.expand): both slots defer to the
10
+ * built-in renderer, so the full command, streamed output, truncation
11
+ * warnings, and duration come back exactly as stock pi shows them.
12
+ * 2. pi's shell settings. The built-in is constructed with
13
+ * settings.shellCommandPrefix and settings.shellPath, as pi itself does;
14
+ * a re-registration that passed nothing silently dropped both. The
15
+ * settings are read with pi's own project-trust decision, so a trusted
16
+ * project's .pi/settings.json contributes its shellCommandPrefix and
17
+ * shellPath and an untrusted one's are ignored — exactly as pi ignores
18
+ * them.
19
+ * 3. Session identity. A spawn hook copies pi's per-command PI_SESSION_ID
20
+ * into AGENT_SESSION_ID, the harness-neutral name tools read — see
21
+ * bash-options.ts for why that must be per command and never process.env.
22
+ *
23
+ * Execution is still the built-in: permission gating, auto-review, and the
24
+ * sandbox all hook execution, which this file never replaces.
14
25
  */
15
26
 
16
27
  import {
17
28
  createBashToolDefinition,
29
+ SettingsManager,
18
30
  type ExtensionAPI,
19
31
  } from "@earendil-works/pi-coding-agent";
20
32
  import { Text } from "@earendil-works/pi-tui";
33
+ import { buildBashOptions } from "./bash-options.ts";
21
34
 
22
35
  function firstLine(command: string): { line: string; more: number } {
23
36
  const lines = command.split("\n");
@@ -32,45 +45,60 @@ function resultText(result: { content?: { type: string; text?: string }[] }): st
32
45
  }
33
46
 
34
47
  export default function quietTools(pi: ExtensionAPI) {
35
- const builtin = createBashToolDefinition(process.cwd());
48
+ // Registration happens at session_start, not at load: that is the first
49
+ // point where pi's own project-trust decision for this cwd is available on
50
+ // the context. Reading settings with that flag makes the tool identical to
51
+ // the one pi would have built — a trusted project's shellCommandPrefix and
52
+ // shellPath apply, an untrusted project's are ignored. session_start also
53
+ // fires for reload/new/resume/fork, and re-registering under the same name
54
+ // each time is the point: a `/reload` after editing settings picks up the
55
+ // new prefix.
56
+ pi.on("session_start", (_event, ctx) => {
57
+ const cwd = ctx.cwd ?? process.cwd();
58
+ // Fail closed on an older pi that does not expose the decision.
59
+ const projectTrusted =
60
+ typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : false;
61
+ const settings = SettingsManager.create(cwd, undefined, { projectTrusted });
62
+ const builtin = createBashToolDefinition(cwd, buildBashOptions(settings));
36
63
 
37
- pi.registerTool({
38
- ...builtin,
64
+ pi.registerTool({
65
+ ...builtin,
39
66
 
40
- renderCall(args: any, theme: any, context: any) {
41
- if (context.expanded && builtin.renderCall) {
42
- return builtin.renderCall(args, theme, context);
43
- }
44
- const command = typeof args?.command === "string" ? args.command : "...";
45
- const { line, more } = firstLine(command.trim());
46
- const suffix = more > 0 ? theme.fg("muted", ` (+${more} lines)`) : "";
47
- // Text handles width truncation; keep the line itself short anyway so
48
- // narrow panes stay one visual row.
49
- const shown = line.length > 200 ? `${line.slice(0, 200)}…` : line;
50
- return new Text(
51
- theme.fg("toolTitle", theme.bold(`$ ${shown}`)) + suffix,
52
- 0,
53
- 0,
54
- );
55
- },
67
+ renderCall(args: any, theme: any, context: any) {
68
+ if (context.expanded && builtin.renderCall) {
69
+ return builtin.renderCall(args, theme, context);
70
+ }
71
+ const command = typeof args?.command === "string" ? args.command : "...";
72
+ const { line, more } = firstLine(command.trim());
73
+ const suffix = more > 0 ? theme.fg("muted", ` (+${more} lines)`) : "";
74
+ // Text handles width truncation; keep the line itself short anyway so
75
+ // narrow panes stay one visual row.
76
+ const shown = line.length > 200 ? `${line.slice(0, 200)}…` : line;
77
+ return new Text(
78
+ theme.fg("toolTitle", theme.bold(`$ ${shown}`)) + suffix,
79
+ 0,
80
+ 0,
81
+ );
82
+ },
56
83
 
57
- renderResult(result: any, options: any, theme: any, context: any) {
58
- if (options.expanded && builtin.renderResult) {
59
- return builtin.renderResult(result, options, theme, context);
60
- }
61
- if (options.isPartial) {
62
- return new Text(theme.fg("muted", " … running"), 0, 0);
63
- }
64
- const text = resultText(result).trim();
65
- const lineCount = text ? text.split("\n").length : 0;
66
- const status = result.isError ? theme.fg("error", "error") : "ok";
67
- const tail = text ? text.split("\n").at(-1) ?? "" : "";
68
- const tailShown = tail.length > 120 ? `${tail.slice(0, 120)}…` : tail;
69
- const summary =
70
- lineCount <= 1
71
- ? ` → ${status}${tailShown ? theme.fg("toolOutput", ` · ${tailShown}`) : ""}`
72
- : ` → ${status} · ${lineCount} lines${tailShown ? theme.fg("toolOutput", ` · … ${tailShown}`) : ""}`;
73
- return new Text(theme.fg("muted", summary), 0, 0);
74
- },
84
+ renderResult(result: any, options: any, theme: any, context: any) {
85
+ if (options.expanded && builtin.renderResult) {
86
+ return builtin.renderResult(result, options, theme, context);
87
+ }
88
+ if (options.isPartial) {
89
+ return new Text(theme.fg("muted", " … running"), 0, 0);
90
+ }
91
+ const text = resultText(result).trim();
92
+ const lineCount = text ? text.split("\n").length : 0;
93
+ const status = result.isError ? theme.fg("error", "error") : "ok";
94
+ const tail = text ? text.split("\n").at(-1) ?? "" : "";
95
+ const tailShown = tail.length > 120 ? `${tail.slice(0, 120)}…` : tail;
96
+ const summary =
97
+ lineCount <= 1
98
+ ? ` → ${status}${tailShown ? theme.fg("toolOutput", ` · ${tailShown}`) : ""}`
99
+ : ` → ${status} · ${lineCount} lines${tailShown ? theme.fg("toolOutput", ` · … ${tailShown}`) : ""}`;
100
+ return new Text(theme.fg("muted", summary), 0, 0);
101
+ },
102
+ });
75
103
  });
76
104
  }