@tt-a1i/openpi 0.1.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 +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
|
|
4
|
+
import type { GoalSnapshot } from "./state.ts";
|
|
5
|
+
|
|
6
|
+
export interface GoalToolDetails {
|
|
7
|
+
goal?: GoalSnapshot;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatTokensCompact(value: number) {
|
|
12
|
+
const safe = Math.max(0, value);
|
|
13
|
+
if (safe === 0) return "0";
|
|
14
|
+
if (safe < 1_000) return Math.floor(safe).toString();
|
|
15
|
+
const [divisor, suffix] =
|
|
16
|
+
safe >= 1_000_000_000_000
|
|
17
|
+
? [1_000_000_000_000, "T"]
|
|
18
|
+
: safe >= 1_000_000_000
|
|
19
|
+
? [1_000_000_000, "B"]
|
|
20
|
+
: safe >= 1_000_000
|
|
21
|
+
? [1_000_000, "M"]
|
|
22
|
+
: [1_000, "K"];
|
|
23
|
+
const scaled = safe / divisor;
|
|
24
|
+
const decimals = scaled < 10 ? 2 : scaled < 100 ? 1 : 0;
|
|
25
|
+
const fixed = scaled.toFixed(decimals);
|
|
26
|
+
const formatted = fixed.includes(".")
|
|
27
|
+
? fixed.replace(/0+$/u, "").replace(/\.$/u, "")
|
|
28
|
+
: fixed;
|
|
29
|
+
return `${formatted}${suffix}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatGoalElapsedSeconds(value: number) {
|
|
33
|
+
const seconds = Math.max(0, Math.floor(value));
|
|
34
|
+
if (seconds < 60) return `${seconds}s`;
|
|
35
|
+
const minutes = Math.floor(seconds / 60);
|
|
36
|
+
if (minutes < 60) return `${minutes}m`;
|
|
37
|
+
const hours = Math.floor(minutes / 60);
|
|
38
|
+
const remainingMinutes = minutes % 60;
|
|
39
|
+
if (hours >= 24) {
|
|
40
|
+
const days = Math.floor(hours / 24);
|
|
41
|
+
return `${days}d ${hours % 24}h ${remainingMinutes}m`;
|
|
42
|
+
}
|
|
43
|
+
return remainingMinutes === 0
|
|
44
|
+
? `${hours}h`
|
|
45
|
+
: `${hours}h ${remainingMinutes}m`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function goalFooterText(goal: GoalSnapshot) {
|
|
49
|
+
switch (goal.status) {
|
|
50
|
+
case "active":
|
|
51
|
+
return goal.tokenBudget === undefined
|
|
52
|
+
? `Pursuing goal (${formatGoalElapsedSeconds(goal.timeUsedSeconds)})`
|
|
53
|
+
: `Pursuing goal (${formatTokensCompact(goal.tokensUsed)} / ${formatTokensCompact(goal.tokenBudget)})`;
|
|
54
|
+
case "paused":
|
|
55
|
+
return "Goal paused (/goal resume)";
|
|
56
|
+
case "blocked":
|
|
57
|
+
return "Goal blocked (/goal resume)";
|
|
58
|
+
case "usage_limited":
|
|
59
|
+
return "Goal hit usage limits (/goal resume)";
|
|
60
|
+
case "budget_limited":
|
|
61
|
+
return goal.tokenBudget === undefined
|
|
62
|
+
? "Goal abandoned"
|
|
63
|
+
: `Goal unmet (${formatTokensCompact(goal.tokensUsed)} / ${formatTokensCompact(goal.tokenBudget)} tokens)`;
|
|
64
|
+
case "complete": {
|
|
65
|
+
if (goal.completionAcknowledged) return "";
|
|
66
|
+
const usage =
|
|
67
|
+
goal.tokenBudget === undefined
|
|
68
|
+
? formatGoalElapsedSeconds(goal.timeUsedSeconds)
|
|
69
|
+
: `${formatTokensCompact(goal.tokensUsed)} tokens`;
|
|
70
|
+
return `Goal achieved (${usage})`;
|
|
71
|
+
}
|
|
72
|
+
case "cleared":
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function truncateGoalObjective(objective: string, width = 44) {
|
|
78
|
+
return truncateToWidth(
|
|
79
|
+
sanitizeTerminalText(objective).replace(/\s+/gu, " "),
|
|
80
|
+
width,
|
|
81
|
+
"…",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function goalContinuationLabel(details: unknown) {
|
|
86
|
+
if (!isRecord(details)) return "↻ Goal continuation";
|
|
87
|
+
if (details.kind === "objective_updated") return "↻ Goal objective updated";
|
|
88
|
+
if (details.kind === "budget_limit") return "↻ Goal budget reached";
|
|
89
|
+
return "↻ Goal continuation";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderGoalTool(
|
|
93
|
+
details: GoalToolDetails | undefined,
|
|
94
|
+
expanded: boolean,
|
|
95
|
+
theme: Theme,
|
|
96
|
+
background?: (text: string) => string,
|
|
97
|
+
) {
|
|
98
|
+
if (!details?.goal) {
|
|
99
|
+
return new Text(
|
|
100
|
+
theme.fg(
|
|
101
|
+
"dim",
|
|
102
|
+
sanitizeTerminalText(details?.message ?? "No goal is currently set."),
|
|
103
|
+
),
|
|
104
|
+
0,
|
|
105
|
+
0,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const goal = details.goal;
|
|
109
|
+
const usage = goal.tokenBudget
|
|
110
|
+
? `${formatTokensCompact(goal.tokensUsed)} / ${formatTokensCompact(goal.tokenBudget)} tokens`
|
|
111
|
+
: `${formatGoalElapsedSeconds(goal.timeUsedSeconds)} · ${formatTokensCompact(goal.tokensUsed)} tokens`;
|
|
112
|
+
const lines = [
|
|
113
|
+
`${theme.fg("accent", theme.bold("Goal"))} ${theme.fg(statusColor(goal), sanitizeTerminalText(statusLabel(goal.status)))}`,
|
|
114
|
+
theme.fg(
|
|
115
|
+
"text",
|
|
116
|
+
expanded
|
|
117
|
+
? sanitizeTerminalText(goal.objective)
|
|
118
|
+
: truncateGoalObjective(goal.objective, 70),
|
|
119
|
+
),
|
|
120
|
+
theme.fg("dim", usage),
|
|
121
|
+
];
|
|
122
|
+
if (expanded && goal.reason) {
|
|
123
|
+
lines.push(theme.fg("dim", `Reason: ${sanitizeTerminalText(goal.reason)}`));
|
|
124
|
+
}
|
|
125
|
+
// ToolExecutionComponent normally paints the enclosing Box, but terminals
|
|
126
|
+
// can expose an unpainted suffix when a styled child line resets its own
|
|
127
|
+
// attributes. Painting each Goal result row as well makes the full-width
|
|
128
|
+
// status background deterministic; the enclosing Box still owns padding.
|
|
129
|
+
return new Text(lines.join("\n"), 0, 0, background);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function statusColor(goal: GoalSnapshot) {
|
|
133
|
+
if (goal.status === "active") return "accent" as const;
|
|
134
|
+
if (goal.status === "complete") return "success" as const;
|
|
135
|
+
if (
|
|
136
|
+
goal.status === "blocked" ||
|
|
137
|
+
goal.status === "usage_limited" ||
|
|
138
|
+
goal.status === "budget_limited"
|
|
139
|
+
) {
|
|
140
|
+
return "warning" as const;
|
|
141
|
+
}
|
|
142
|
+
return "muted" as const;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function statusLabel(status: GoalSnapshot["status"]) {
|
|
146
|
+
switch (status) {
|
|
147
|
+
case "usage_limited":
|
|
148
|
+
return "usage limited";
|
|
149
|
+
case "budget_limited":
|
|
150
|
+
return "limited by budget";
|
|
151
|
+
default:
|
|
152
|
+
return sanitizeTerminalText(status);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
157
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
158
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
emptyModelInfoState,
|
|
7
|
+
MODEL_INFO_CHANNEL,
|
|
8
|
+
REFRESH_CHANNEL,
|
|
9
|
+
} from "../shared/dashboard-state.ts";
|
|
10
|
+
|
|
11
|
+
const CHARS_PER_ESTIMATED_TOKEN = 4;
|
|
12
|
+
const LIVE_UPDATE_INTERVAL_MS = 200;
|
|
13
|
+
|
|
14
|
+
function getSessionMetrics(ctx: ExtensionContext) {
|
|
15
|
+
let cost = 0;
|
|
16
|
+
let cacheRead = 0;
|
|
17
|
+
let promptTokens = 0;
|
|
18
|
+
|
|
19
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
20
|
+
let usage;
|
|
21
|
+
if (entry.type === "message") {
|
|
22
|
+
const message = entry.message;
|
|
23
|
+
if (message.role === "assistant" || message.role === "toolResult") {
|
|
24
|
+
usage = message.usage;
|
|
25
|
+
}
|
|
26
|
+
} else if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
27
|
+
usage = entry.usage;
|
|
28
|
+
}
|
|
29
|
+
if (!usage) continue;
|
|
30
|
+
cost += usage.cost.total;
|
|
31
|
+
cacheRead += usage.cacheRead;
|
|
32
|
+
promptTokens += usage.input + usage.cacheRead + usage.cacheWrite;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
cost,
|
|
37
|
+
cachePercent: promptTokens > 0 ? (cacheRead / promptTokens) * 100 : null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function estimateContentTokens(characters: number) {
|
|
42
|
+
return Math.ceil(characters / CHARS_PER_ESTIMATED_TOKEN);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function modelInfo(pi: ExtensionAPI) {
|
|
46
|
+
let state = emptyModelInfoState();
|
|
47
|
+
let contentStreamStart: number | null = null;
|
|
48
|
+
let lastContentDeltaAt: number | null = null;
|
|
49
|
+
let contentCharacters = 0;
|
|
50
|
+
let firstContentDeltaCharacters = 0;
|
|
51
|
+
let contentDeltaCount = 0;
|
|
52
|
+
let sawToolCall = false;
|
|
53
|
+
let runContentTokens = 0;
|
|
54
|
+
let runContentStreamMs = 0;
|
|
55
|
+
let lastLiveUpdate = 0;
|
|
56
|
+
let currentContext: ExtensionContext | undefined;
|
|
57
|
+
|
|
58
|
+
const publish = () => pi.events.emit(MODEL_INFO_CHANNEL, { ...state });
|
|
59
|
+
|
|
60
|
+
function refresh(ctx: ExtensionContext) {
|
|
61
|
+
currentContext = ctx;
|
|
62
|
+
const model = ctx.model;
|
|
63
|
+
const usage = ctx.getContextUsage();
|
|
64
|
+
const metrics = getSessionMetrics(ctx);
|
|
65
|
+
|
|
66
|
+
state = {
|
|
67
|
+
...state,
|
|
68
|
+
provider: model?.provider ?? "",
|
|
69
|
+
modelId: model?.id ?? "no-model",
|
|
70
|
+
modelName: model?.name ?? model?.id ?? "No model",
|
|
71
|
+
thinking: model?.reasoning ? pi.getThinkingLevel() : "off",
|
|
72
|
+
contextTokens: usage?.tokens ?? null,
|
|
73
|
+
contextWindow: usage?.contextWindow ?? model?.contextWindow ?? 0,
|
|
74
|
+
contextPercent: usage?.percent ?? null,
|
|
75
|
+
cachePercent: metrics.cachePercent,
|
|
76
|
+
cost: metrics.cost,
|
|
77
|
+
};
|
|
78
|
+
publish();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resetMessageTracking() {
|
|
82
|
+
contentStreamStart = null;
|
|
83
|
+
lastContentDeltaAt = null;
|
|
84
|
+
contentCharacters = 0;
|
|
85
|
+
firstContentDeltaCharacters = 0;
|
|
86
|
+
contentDeltaCount = 0;
|
|
87
|
+
sawToolCall = false;
|
|
88
|
+
lastLiveUpdate = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stopRefreshListener = pi.events.on(REFRESH_CHANNEL, () => {
|
|
92
|
+
if (currentContext) refresh(currentContext);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.on("session_start", (_event, ctx) => {
|
|
96
|
+
resetMessageTracking();
|
|
97
|
+
runContentTokens = 0;
|
|
98
|
+
runContentStreamMs = 0;
|
|
99
|
+
state = { ...state, tokensPerSecond: null, generating: false };
|
|
100
|
+
refresh(ctx);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
pi.on("model_select", (event, ctx) => {
|
|
104
|
+
state = {
|
|
105
|
+
...state,
|
|
106
|
+
provider: event.model.provider,
|
|
107
|
+
modelId: event.model.id,
|
|
108
|
+
modelName: event.model.name,
|
|
109
|
+
thinking: event.model.reasoning ? pi.getThinkingLevel() : "off",
|
|
110
|
+
contextWindow: event.model.contextWindow,
|
|
111
|
+
};
|
|
112
|
+
refresh(ctx);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
pi.on("thinking_level_select", (event) => {
|
|
116
|
+
state = { ...state, thinking: event.level };
|
|
117
|
+
publish();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
121
|
+
runContentTokens = 0;
|
|
122
|
+
runContentStreamMs = 0;
|
|
123
|
+
resetMessageTracking();
|
|
124
|
+
state = { ...state, tokensPerSecond: null, generating: true };
|
|
125
|
+
refresh(ctx);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.on("message_start", (event) => {
|
|
129
|
+
if (event.message.role === "assistant") resetMessageTracking();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
pi.on("message_update", (event) => {
|
|
133
|
+
if (event.message.role !== "assistant") return;
|
|
134
|
+
|
|
135
|
+
const streamEvent = event.assistantMessageEvent;
|
|
136
|
+
if (streamEvent.type === "toolcall_delta") {
|
|
137
|
+
sawToolCall = true;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
streamEvent.type !== "text_delta" &&
|
|
142
|
+
streamEvent.type !== "thinking_delta"
|
|
143
|
+
)
|
|
144
|
+
return;
|
|
145
|
+
if (!streamEvent.delta) return;
|
|
146
|
+
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
if (contentStreamStart === null) {
|
|
149
|
+
contentStreamStart = now;
|
|
150
|
+
firstContentDeltaCharacters = streamEvent.delta.length;
|
|
151
|
+
}
|
|
152
|
+
lastContentDeltaAt = now;
|
|
153
|
+
contentCharacters += streamEvent.delta.length;
|
|
154
|
+
contentDeltaCount += 1;
|
|
155
|
+
|
|
156
|
+
const elapsedMs = now - contentStreamStart;
|
|
157
|
+
const streamedCharacters = contentCharacters - firstContentDeltaCharacters;
|
|
158
|
+
if (
|
|
159
|
+
contentDeltaCount < 2 ||
|
|
160
|
+
elapsedMs <= 0 ||
|
|
161
|
+
streamedCharacters <= 0 ||
|
|
162
|
+
now - lastLiveUpdate < LIVE_UPDATE_INTERVAL_MS
|
|
163
|
+
) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
lastLiveUpdate = now;
|
|
167
|
+
|
|
168
|
+
state = {
|
|
169
|
+
...state,
|
|
170
|
+
tokensPerSecond:
|
|
171
|
+
estimateContentTokens(streamedCharacters) / (elapsedMs / 1000),
|
|
172
|
+
};
|
|
173
|
+
publish();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
pi.on("message_end", (event, ctx) => {
|
|
177
|
+
if (event.message.role !== "assistant") return;
|
|
178
|
+
|
|
179
|
+
sawToolCall ||= event.message.content.some(
|
|
180
|
+
(block) => block.type === "toolCall",
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (contentStreamStart !== null && contentCharacters > 0) {
|
|
184
|
+
const streamEnd = lastContentDeltaAt ?? contentStreamStart;
|
|
185
|
+
const streamMs = streamEnd - contentStreamStart;
|
|
186
|
+
const estimatedFirstDeltaTokens = estimateContentTokens(
|
|
187
|
+
firstContentDeltaCharacters,
|
|
188
|
+
);
|
|
189
|
+
// Measure tokens received after the first content event over the interval
|
|
190
|
+
// from the first event to the last. This avoids counting an initial chunk
|
|
191
|
+
// as if it were generated instantaneously at t=0.
|
|
192
|
+
const streamedTokens =
|
|
193
|
+
!sawToolCall && event.message.usage.output > 0
|
|
194
|
+
? Math.max(0, event.message.usage.output - estimatedFirstDeltaTokens)
|
|
195
|
+
: Math.max(
|
|
196
|
+
0,
|
|
197
|
+
estimateContentTokens(contentCharacters) -
|
|
198
|
+
estimatedFirstDeltaTokens,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// A single event or a sub-50ms burst has no useful observable cadence.
|
|
202
|
+
if (contentDeltaCount >= 2 && streamMs >= 50 && streamedTokens > 0) {
|
|
203
|
+
runContentTokens += streamedTokens;
|
|
204
|
+
runContentStreamMs += streamMs;
|
|
205
|
+
state = {
|
|
206
|
+
...state,
|
|
207
|
+
tokensPerSecond: runContentTokens / (runContentStreamMs / 1000),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
resetMessageTracking();
|
|
213
|
+
refresh(ctx);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
pi.on("turn_end", (_event, ctx) => refresh(ctx));
|
|
217
|
+
|
|
218
|
+
// Compaction and branch moves rewrite history, so the cached percentage is
|
|
219
|
+
// stale the moment they land. Pi reports unknown occupancy until the next
|
|
220
|
+
// assistant reply, which is the honest state to show.
|
|
221
|
+
pi.on("session_compact", (_event, ctx) => refresh(ctx));
|
|
222
|
+
|
|
223
|
+
pi.on("session_tree", (_event, ctx) => refresh(ctx));
|
|
224
|
+
|
|
225
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
226
|
+
state = { ...state, generating: false };
|
|
227
|
+
refresh(ctx);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
pi.on("session_shutdown", () => {
|
|
231
|
+
stopRefreshListener();
|
|
232
|
+
currentContext = undefined;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding whether an ARBITRARY shell command is read-only is undecidable, so
|
|
3
|
+
* this does not try. It answers a much narrower question: is this command one
|
|
4
|
+
* of a few known investigation commands, built ONLY from parts on an explicit
|
|
5
|
+
* allowlist? Anything not on a list is refused — the same fail-closed stance
|
|
6
|
+
* as PLAN_SAFE_TOOLS one level up, one level finer.
|
|
7
|
+
*
|
|
8
|
+
* It exists because planning without `git log`/`git diff`/`git status` means
|
|
9
|
+
* planning without history: `read`/`rg` show what the code says, never why it
|
|
10
|
+
* came to say it.
|
|
11
|
+
*
|
|
12
|
+
* THREAT MODEL. The adversary is a model that would start changing things
|
|
13
|
+
* before the user approved a plan — including one steered by injected text it
|
|
14
|
+
* read while investigating. It is NOT a hostile repository: `git diff` honors
|
|
15
|
+
* `diff.external` from the repo's own config, so a checkout whose `.git/config`
|
|
16
|
+
* is attacker-controlled can run a program through a command this module
|
|
17
|
+
* allows. Nothing here can prevent that, and nothing above it tries to: pi
|
|
18
|
+
* already runs the project's own tooling under the user's trust decision.
|
|
19
|
+
*
|
|
20
|
+
* EVERY LIST BELOW IS AN ALLOWLIST, deliberately. An earlier version scanned
|
|
21
|
+
* for known-dangerous flags instead, and review found four separate escapes in
|
|
22
|
+
* one pass (`git grep -O<cmd>` executes, `file --compile` writes, `tree -ao`
|
|
23
|
+
* writes, `date -s` sets the clock). A denylist over an unbounded flag space
|
|
24
|
+
* cannot be finished; a missing allowlist entry only costs a refusal.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Any of these means the text is more than one plain command — a pipeline, a
|
|
29
|
+
* sequence, a redirect, a substitution, a glob, or a background job. Rather
|
|
30
|
+
* than parse shell (where every parser bug is a bypass), refuse outright.
|
|
31
|
+
*
|
|
32
|
+
* `\` is here because a line continuation splices in the next line; `$` covers
|
|
33
|
+
* both `$(...)` and a `$VAR` that expands into arguments never inspected here.
|
|
34
|
+
*/
|
|
35
|
+
const SHELL_METACHARACTERS = /[;&|<>$`\\!*?{}()[\]\n\r#]/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tilde expansion, but only where a shell would actually expand it: at the
|
|
39
|
+
* start of a word. `HEAD~3` is ordinary revision syntax and must survive,
|
|
40
|
+
* while `~/notes` and `~user/x` resolve to a path this module never sees.
|
|
41
|
+
*/
|
|
42
|
+
const TILDE_EXPANSION = /(^|\s)~/;
|
|
43
|
+
|
|
44
|
+
/** Quotes hide word boundaries from the tokenizer below, so they are refused too. */
|
|
45
|
+
const QUOTES = /["']/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read-only git subcommands. Absent on purpose: `config`, `stash`, `tag`,
|
|
49
|
+
* `branch`, `remote`, `reflog`, `worktree` — each has a listing form, but
|
|
50
|
+
* telling it apart from the writing form means parsing that subcommand's own
|
|
51
|
+
* grammar, which is the analysis this module refuses to do.
|
|
52
|
+
*/
|
|
53
|
+
const GIT_SUBCOMMANDS = new Set([
|
|
54
|
+
"log",
|
|
55
|
+
"diff",
|
|
56
|
+
"status",
|
|
57
|
+
"show",
|
|
58
|
+
"blame",
|
|
59
|
+
"shortlog",
|
|
60
|
+
"describe",
|
|
61
|
+
"rev-parse",
|
|
62
|
+
"rev-list",
|
|
63
|
+
"ls-files",
|
|
64
|
+
"ls-tree",
|
|
65
|
+
"cat-file",
|
|
66
|
+
"merge-base",
|
|
67
|
+
"name-rev",
|
|
68
|
+
"whatchanged",
|
|
69
|
+
"grep",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Flags accepted after a git subcommand. The admission rule for this list is
|
|
74
|
+
* narrow: a flag qualifies only if it shapes OUTPUT or SELECTS commits, and
|
|
75
|
+
* never names a program, a file to write, or a path git will execute from.
|
|
76
|
+
* That is why `-O`/`--open-files-in-pager` (runs a program per match),
|
|
77
|
+
* `-o`/`--output` (writes a file) and `--contents` (reads an out-of-tree file)
|
|
78
|
+
* are absent, and why an unrecognized flag is refused rather than assumed dull.
|
|
79
|
+
*
|
|
80
|
+
* A `--flag=value` form is matched on the `--flag` part; a value given as the
|
|
81
|
+
* next word is admitted by the non-flag branch of the scan.
|
|
82
|
+
*/
|
|
83
|
+
const GIT_FLAGS = new Set([
|
|
84
|
+
// Patch and stat shaping.
|
|
85
|
+
"-p",
|
|
86
|
+
"--patch",
|
|
87
|
+
"--no-patch",
|
|
88
|
+
"-s",
|
|
89
|
+
"--stat",
|
|
90
|
+
"--shortstat",
|
|
91
|
+
"--numstat",
|
|
92
|
+
"--summary",
|
|
93
|
+
"--raw",
|
|
94
|
+
"--name-only",
|
|
95
|
+
"--name-status",
|
|
96
|
+
"--no-color",
|
|
97
|
+
"--color",
|
|
98
|
+
"--word-diff",
|
|
99
|
+
"-U",
|
|
100
|
+
"--unified",
|
|
101
|
+
"--no-ext-diff",
|
|
102
|
+
// Commit formatting.
|
|
103
|
+
"--oneline",
|
|
104
|
+
"--graph",
|
|
105
|
+
"--abbrev-commit",
|
|
106
|
+
"--no-abbrev-commit",
|
|
107
|
+
"--format",
|
|
108
|
+
"--pretty",
|
|
109
|
+
"--date",
|
|
110
|
+
"--relative-date",
|
|
111
|
+
"--decorate",
|
|
112
|
+
"--no-decorate",
|
|
113
|
+
// Commit selection.
|
|
114
|
+
"-n",
|
|
115
|
+
"--max-count",
|
|
116
|
+
"--skip",
|
|
117
|
+
"--since",
|
|
118
|
+
"--after",
|
|
119
|
+
"--until",
|
|
120
|
+
"--before",
|
|
121
|
+
"--author",
|
|
122
|
+
"--committer",
|
|
123
|
+
"--grep",
|
|
124
|
+
"--all",
|
|
125
|
+
"--branches",
|
|
126
|
+
"--tags",
|
|
127
|
+
"--remotes",
|
|
128
|
+
"--first-parent",
|
|
129
|
+
"--no-merges",
|
|
130
|
+
"--merges",
|
|
131
|
+
"--reverse",
|
|
132
|
+
"--follow",
|
|
133
|
+
"--topo-order",
|
|
134
|
+
"--date-order",
|
|
135
|
+
// Diff/blame comparison.
|
|
136
|
+
"--cached",
|
|
137
|
+
"--staged",
|
|
138
|
+
"-w",
|
|
139
|
+
"--ignore-all-space",
|
|
140
|
+
"--ignore-space-change",
|
|
141
|
+
"-M",
|
|
142
|
+
"-C",
|
|
143
|
+
"-L",
|
|
144
|
+
"--find-renames",
|
|
145
|
+
"--find-copies",
|
|
146
|
+
// Plumbing queries used while orienting.
|
|
147
|
+
"--abbrev-ref",
|
|
148
|
+
"--show-toplevel",
|
|
149
|
+
"--git-dir",
|
|
150
|
+
"--is-inside-work-tree",
|
|
151
|
+
"--verify",
|
|
152
|
+
"--short",
|
|
153
|
+
"--porcelain",
|
|
154
|
+
"--branch",
|
|
155
|
+
"-b",
|
|
156
|
+
"-u",
|
|
157
|
+
"--untracked-files",
|
|
158
|
+
"-t",
|
|
159
|
+
"-r",
|
|
160
|
+
"--long",
|
|
161
|
+
"--count",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
/** gh subcommands paired with the verbs under each that only read. */
|
|
165
|
+
const GH_SUBCOMMANDS = new Map<string, ReadonlySet<string>>([
|
|
166
|
+
["pr", new Set(["view", "list", "diff", "checks", "status"])],
|
|
167
|
+
["issue", new Set(["view", "list", "status"])],
|
|
168
|
+
["search", new Set(["code", "commits", "issues", "prs", "repos"])],
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Flags accepted after a gh verb. `--web` is absent on purpose: it opens the
|
|
173
|
+
* user's browser, which reads nothing and is a side effect they did not ask a
|
|
174
|
+
* planning step to cause.
|
|
175
|
+
*/
|
|
176
|
+
const GH_FLAGS = new Set([
|
|
177
|
+
"--json",
|
|
178
|
+
"--jq",
|
|
179
|
+
"--template",
|
|
180
|
+
"--repo",
|
|
181
|
+
"-R",
|
|
182
|
+
"--state",
|
|
183
|
+
"--limit",
|
|
184
|
+
"-L",
|
|
185
|
+
"--author",
|
|
186
|
+
"--assignee",
|
|
187
|
+
"--label",
|
|
188
|
+
"--search",
|
|
189
|
+
"--draft",
|
|
190
|
+
"--base",
|
|
191
|
+
"--head",
|
|
192
|
+
"--owner",
|
|
193
|
+
"--language",
|
|
194
|
+
"--comments",
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
/** `-5`, `-20`: git's count shorthand, which is a number rather than a flag. */
|
|
198
|
+
const NUMERIC_SHORTHAND = /^-\d+$/;
|
|
199
|
+
|
|
200
|
+
export interface BashPlanDecision {
|
|
201
|
+
allowed: boolean;
|
|
202
|
+
/** Why it was refused, phrased for the model that must react to it. */
|
|
203
|
+
reason?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const refuse = (reason: string): BashPlanDecision => ({
|
|
207
|
+
allowed: false,
|
|
208
|
+
reason,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** Split `--flag=value` into the flag part the allowlists are keyed on. */
|
|
212
|
+
function flagName(word: string) {
|
|
213
|
+
const eq = word.indexOf("=");
|
|
214
|
+
return eq === -1 ? word : word.slice(0, eq);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Check the argument tail. Words after `--` are pathspecs by definition and
|
|
219
|
+
* need no check; before it, a word either is an allowlisted flag or is not a
|
|
220
|
+
* flag at all (a path, ref, or pattern, none of which can execute).
|
|
221
|
+
*/
|
|
222
|
+
function scanArguments(
|
|
223
|
+
words: readonly string[],
|
|
224
|
+
allowed: ReadonlySet<string>,
|
|
225
|
+
program: string,
|
|
226
|
+
): BashPlanDecision {
|
|
227
|
+
for (const word of words) {
|
|
228
|
+
if (word === "--") break;
|
|
229
|
+
if (!word.startsWith("-")) continue;
|
|
230
|
+
if (NUMERIC_SHORTHAND.test(word)) continue;
|
|
231
|
+
if (!allowed.has(flagName(word))) {
|
|
232
|
+
return refuse(
|
|
233
|
+
`plan mode does not recognize "${word}" as a read-only ${program} option, so it will not run this command`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { allowed: true };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Whether this exact command may run during plan mode. The contract is
|
|
242
|
+
* one-directional: `allowed: true` means proven read-only by the rules above,
|
|
243
|
+
* `allowed: false` means only "not proven", never "proven dangerous".
|
|
244
|
+
*/
|
|
245
|
+
export function planBashDecision(command: unknown): BashPlanDecision {
|
|
246
|
+
if (typeof command !== "string") {
|
|
247
|
+
return refuse("plan mode could not read the command to check it");
|
|
248
|
+
}
|
|
249
|
+
const text = command.trim();
|
|
250
|
+
if (!text) return refuse("plan mode received an empty command");
|
|
251
|
+
|
|
252
|
+
if (SHELL_METACHARACTERS.test(text)) {
|
|
253
|
+
return refuse(
|
|
254
|
+
"plan mode only runs a single plain command — no pipes, redirects, substitutions, globs, or chained commands",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (QUOTES.test(text)) {
|
|
258
|
+
return refuse("plan mode only runs unquoted commands while planning");
|
|
259
|
+
}
|
|
260
|
+
if (TILDE_EXPANSION.test(text)) {
|
|
261
|
+
return refuse(
|
|
262
|
+
"plan mode does not run commands with `~` paths — give a path relative to the project instead",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const [program, ...rest] = text.split(/\s+/);
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* The subcommand must be the FIRST word, never "the first word that is not a
|
|
270
|
+
* flag". Skipping over flags assumes they are value-less, and git's
|
|
271
|
+
* `--namespace x` / `--attr-source x` and gh's cobra parser each consume the
|
|
272
|
+
* next word — so a skip-to-first-non-flag rule lets `git --namespace log
|
|
273
|
+
* push --force` donate the allowlisted subcommand to the flag and run the
|
|
274
|
+
* one behind it. Requiring position refuses `git -P log` too; that is a
|
|
275
|
+
* false negative, which this module is allowed to have.
|
|
276
|
+
*/
|
|
277
|
+
if (program === "git") {
|
|
278
|
+
const [subcommand, ...args] = rest;
|
|
279
|
+
if (!subcommand || !GIT_SUBCOMMANDS.has(subcommand)) {
|
|
280
|
+
return refuse(
|
|
281
|
+
`plan mode allows only a read-only git subcommand immediately after "git" (log, diff, status, show, blame, …), not "${subcommand ?? "(none)"}"`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return scanArguments(args, GIT_FLAGS, "git");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (program === "gh") {
|
|
288
|
+
const [subcommand, verb, ...args] = rest;
|
|
289
|
+
const verbs = subcommand ? GH_SUBCOMMANDS.get(subcommand) : undefined;
|
|
290
|
+
if (!verbs) {
|
|
291
|
+
return refuse(
|
|
292
|
+
`plan mode allows only "gh pr", "gh issue" or "gh search" while planning, not "${subcommand ?? "(none)"}"`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (!verb || !verbs.has(verb)) {
|
|
296
|
+
return refuse(
|
|
297
|
+
`plan mode allows only read verbs after "gh ${subcommand}" (${[...verbs].join(", ")}), not "${verb ?? "(none)"}"`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return scanArguments(args, GH_FLAGS, "gh");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/*
|
|
304
|
+
* Nothing else is admitted. `ls`, `cat`, `head`, `tail` and `wc` were on an
|
|
305
|
+
* earlier version of this list and are gone: plan mode already grants the
|
|
306
|
+
* `ls`, `read`, `grep` and `fd`/`rg` TOOLS, so those shell forms added no
|
|
307
|
+
* capability while each contributed its own flag grammar to get wrong
|
|
308
|
+
* (`file --compile` and `tree -ao` both write files).
|
|
309
|
+
*/
|
|
310
|
+
return refuse(
|
|
311
|
+
`plan mode runs only read-only git and gh investigation commands while planning, not "${program}" — use the read, ls, grep or fd tools for files`,
|
|
312
|
+
);
|
|
313
|
+
}
|