@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.
Files changed (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. 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
+ }