@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-beta.1

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 (56) hide show
  1. package/OVERVIEW.md +20 -2
  2. package/README.md +91 -5
  3. package/client/access.tsx +66 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/graph-camera.tsx +219 -0
  6. package/client/graph-labels.ts +147 -0
  7. package/client/launch-choices.ts +15 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +86 -16
  10. package/client/launch.tsx +19 -5
  11. package/client/live-agents.tsx +522 -0
  12. package/client/live-cursor.tsx +293 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-graph.tsx +518 -0
  15. package/client/live-header.tsx +341 -0
  16. package/client/live-issues.tsx +345 -0
  17. package/client/live-map.tsx +345 -0
  18. package/client/live-panel.tsx +465 -0
  19. package/client/live-timeline.ts +376 -0
  20. package/client/pill.tsx +393 -0
  21. package/client/project-init.tsx +168 -0
  22. package/client/settings-access.tsx +56 -0
  23. package/client/settings-live.tsx +145 -0
  24. package/client/settings-projects.tsx +9 -0
  25. package/client/tool-buttons.tsx +68 -0
  26. package/client/web.ts +45 -0
  27. package/index.client.tsx +39 -1
  28. package/index.server.ts +29 -5
  29. package/package.json +1 -1
  30. package/server/agent-hooks.ts +37 -0
  31. package/server/agent-runs.ts +312 -0
  32. package/server/agent-tools.ts +240 -0
  33. package/server/handlers.ts +12 -1
  34. package/server/init.ts +65 -0
  35. package/server/links.ts +153 -0
  36. package/server/live.ts +360 -0
  37. package/server/mcp-http.ts +152 -0
  38. package/server/stores.ts +126 -0
  39. package/server/subagent-logs.ts +146 -0
  40. package/server/sync.ts +127 -0
  41. package/shared/activity.ts +312 -0
  42. package/shared/agent-hooks.ts +68 -0
  43. package/shared/agent-tools.ts +130 -0
  44. package/shared/graph-camera.ts +93 -0
  45. package/shared/graph-geometry.ts +122 -0
  46. package/shared/graph-model.ts +319 -0
  47. package/shared/links.ts +252 -0
  48. package/shared/live.ts +243 -0
  49. package/shared/map-model.ts +192 -0
  50. package/shared/project-setup.ts +55 -0
  51. package/shared/prompts.ts +65 -2
  52. package/shared/settings.ts +88 -1
  53. package/shared/shell-activity.ts +309 -0
  54. package/shared/shell-lexer.ts +180 -0
  55. package/shared/subagents.ts +106 -0
  56. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,55 @@
1
+ import type { InitProposal } from "./live";
2
+ import type { ProjectConfig } from "./settings";
3
+
4
+ // A setup proposal shows only fields that change; accepted fields append to the prompts.
5
+ export type ProposalField = "instructions" | "steps" | "implement" | "review";
6
+
7
+ export interface FieldDiff {
8
+ field: ProposalField;
9
+ label: string;
10
+ current: string;
11
+ proposed: string;
12
+ }
13
+
14
+ export function proposalDiffs(config: ProjectConfig, proposal: InitProposal): FieldDiff[] {
15
+ const steps = proposal.steps.map((step) => step.trim()).filter(Boolean);
16
+ const rows: FieldDiff[] = [
17
+ {
18
+ field: "instructions",
19
+ label: "Project instructions",
20
+ current: config.instructions.trim(),
21
+ proposed: proposal.instructions.trim(),
22
+ },
23
+ {
24
+ field: "steps",
25
+ label: "Project steps",
26
+ current: config.steps.join("\n"),
27
+ proposed: steps.join("\n"),
28
+ },
29
+ {
30
+ field: "implement",
31
+ label: "Implement: append to the prompt",
32
+ current: config.implement.mode === "default" ? "" : config.implement.text.trim(),
33
+ proposed: proposal.implement.trim(),
34
+ },
35
+ {
36
+ field: "review",
37
+ label: "Review: append to the prompt",
38
+ current: config.review.mode === "default" ? "" : config.review.text.trim(),
39
+ proposed: proposal.review.trim(),
40
+ },
41
+ ];
42
+ return rows.filter((row) => row.proposed && row.proposed !== row.current);
43
+ }
44
+
45
+ export function applyProposal(
46
+ proposal: InitProposal,
47
+ fields: ReadonlySet<ProposalField>,
48
+ ): Partial<ProjectConfig> {
49
+ const patch: Partial<ProjectConfig> = {};
50
+ if (fields.has("instructions")) patch.instructions = proposal.instructions.trim();
51
+ if (fields.has("steps")) patch.steps = proposal.steps.map((s) => s.trim()).filter(Boolean);
52
+ if (fields.has("implement")) patch.implement = { mode: "append", text: proposal.implement.trim() };
53
+ if (fields.has("review")) patch.review = { mode: "append", text: proposal.review.trim() };
54
+ return patch;
55
+ }
package/shared/prompts.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { TOOLS_SERVER } from "./agent-tools";
1
2
  import type { IssueDetail } from "./linear";
2
- import type { AgentAction, LinearSettings, ProjectConfig } from "./settings";
3
+ import type { AgentAction, Guidance, LinearSettings, ProjectConfig } from "./settings";
4
+ import { todoConventionLine } from "./todo-sync";
3
5
 
4
6
  export const DEFAULT_TEMPLATES: Record<AgentAction, string> = {
5
7
  implement: [
@@ -111,6 +113,47 @@ export interface ComposePromptInput {
111
113
  project: ProjectConfig | null;
112
114
  includeComments: boolean;
113
115
  extraInstructions: string;
116
+ /** Launch options that add instructions; the project's saved ones when omitted. */
117
+ guidance?: Guidance;
118
+ }
119
+
120
+ /** The instructions each guidance option adds, in prompt order. */
121
+ export function guidanceSections(issue: IssueDetail, guidance: Guidance): string[] {
122
+ const sections: string[] = [];
123
+ const example = issue.children[0]?.identifier ?? issue.identifier;
124
+ if (guidance.updateLinear) {
125
+ sections.push(
126
+ [
127
+ "Keep Linear current while you work:",
128
+ `- Start each todo with the issue key it is for, such as "${example}: add the form".`,
129
+ "- Mark a todo in progress when you start it and completed when it is done. Linear Live moves the issue from these todos.",
130
+ `- Use the ${TOOLS_SERVER} MCP tools: read_issue shows an issue, and edit_issue changes its description.`,
131
+ "- When you finish a task list item in a description, check it off: replace `- [ ] item` with `- [x] item`.",
132
+ "- When the plan changes, update the description. Do not post comments.",
133
+ ].join("\n"),
134
+ );
135
+ }
136
+ if (guidance.subagentKeys) {
137
+ sections.push(
138
+ [
139
+ "When you start a subagent:",
140
+ `- Begin its description and its prompt with the Linear issue key it works on, such as "${example}: review the form".`,
141
+ "- Tell it to put that key in the first line of its reply, and to state the new key if its work moves to another issue.",
142
+ ].join("\n"),
143
+ );
144
+ }
145
+ if (guidance.paseoSubagents) {
146
+ sections.push(
147
+ [
148
+ "Hand off work to Paseo agents, not to your built-in subagent tool:",
149
+ `- Start each one with the ${TOOLS_SERVER} start_agent tool, one agent for each sub-issue. It runs on your provider and model.`,
150
+ "- Set its thinking to fit the task: low for search and reading, medium for routine edits, high for design, debugging, and review.",
151
+ `- Start its title with the issue key, such as "${example}: write the tests". Give the full task in its prompt, because it does not see your conversation.`,
152
+ "- Call wait_agent for each agent and read its result before you continue.",
153
+ ].join("\n"),
154
+ );
155
+ }
156
+ return sections;
114
157
  }
115
158
 
116
159
  export function composePrompt(input: ComposePromptInput): string {
@@ -128,6 +171,8 @@ export function composePrompt(input: ComposePromptInput): string {
128
171
  ];
129
172
  // A template that drops {{issue}} still gets the snapshot so the agent never works blind.
130
173
  if (!/\{\{\s*issue\s*\}\}/.test(template)) sections.push(snapshot);
174
+ const convention = input.action === "implement" ? todoConventionLine(issue.children) : null;
175
+ if (convention) sections.push(convention);
131
176
  const instructions = project?.instructions.trim();
132
177
  if (instructions) sections.push(`Project instructions:\n${instructions}`);
133
178
  const steps = project?.steps.map((step) => step.trim()).filter(Boolean) ?? [];
@@ -136,6 +181,8 @@ export function composePrompt(input: ComposePromptInput): string {
136
181
  `Project steps:\n${steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}`,
137
182
  );
138
183
  }
184
+ const guidance = input.guidance ?? project?.guidance;
185
+ if (guidance) sections.push(...guidanceSections(issue, guidance));
139
186
  const extra = input.extraInstructions.trim();
140
187
  if (extra) sections.push(`Additional instructions:\n${extra}`);
141
188
  return sections.join("\n\n");
@@ -147,4 +194,20 @@ export function agentTitle(action: AgentAction, issue: { identifier: string; tit
147
194
  return `${prefix}: ${title}`.slice(0, 120);
148
195
  }
149
196
 
150
- export const AGENT_LABELS = { issue: "linear.issue", action: "linear.action" } as const;
197
+ export const AGENT_LABELS = {
198
+ issue: "linear.issue",
199
+ action: "linear.action",
200
+ /** The Paseo project whose Linear key loaded the issue. */
201
+ project: "linear.project",
202
+ explore: "linear.explore",
203
+ init: "linear.init",
204
+ /** The grant of the agent's Linear MCP tools, so a tool call can find its agent. */
205
+ tools: "linear.tools",
206
+ /** The grant whose start_agent call made this agent. */
207
+ startedBy: "linear.started-by",
208
+ } as const;
209
+
210
+ /** Agents the plugin starts for itself: explore and project setup. */
211
+ export function isInternalAgent(labels: Readonly<Record<string, string>> | undefined): boolean {
212
+ return Boolean(labels?.[AGENT_LABELS.explore] || labels?.[AGENT_LABELS.init]);
213
+ }
@@ -1,4 +1,4 @@
1
- import { defineSettings } from "@getpaseo/plugin";
1
+ import { defineRpc, defineSettings } from "@getpaseo/plugin";
2
2
  import { z } from "zod";
3
3
 
4
4
  export const AGENT_ACTIONS = ["implement", "review"] as const;
@@ -12,6 +12,18 @@ const ActionOverrideSchema = z.object({
12
12
  text: z.string().default(""),
13
13
  });
14
14
 
15
+ /** Extra instructions the launch adds to the prompt. Saved per project. */
16
+ export const GuidanceSchema = z.object({
17
+ /** Keep Linear current: todos with sub-issue keys, and comments when a Linear tool exists. */
18
+ updateLinear: z.boolean().default(false),
19
+ /** Subagents state the issue key they work on. Claude agents also get a hook that tells them. */
20
+ subagentKeys: z.boolean().default(false),
21
+ /** Hand work to Paseo agents on the same provider, with thinking that fits the task. */
22
+ paseoSubagents: z.boolean().default(false),
23
+ });
24
+ export type Guidance = z.infer<typeof GuidanceSchema>;
25
+ export const GUIDANCE_KEYS = ["updateLinear", "subagentKeys", "paseoSubagents"] as const;
26
+
15
27
  export const ProjectConfigSchema = z.object({
16
28
  projectId: z.string(),
17
29
  displayName: z.string(),
@@ -21,12 +33,32 @@ export const ProjectConfigSchema = z.object({
21
33
  steps: z.array(z.string()).default([]),
22
34
  implement: ActionOverrideSchema.prefault({}),
23
35
  review: ActionOverrideSchema.prefault({}),
36
+ guidance: GuidanceSchema.prefault({}),
24
37
  });
25
38
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
26
39
 
40
+ /** Settings with a project's guidance saved, adding the project when it has no entry yet. */
41
+ export function withProjectGuidance(
42
+ values: LinearSettings,
43
+ project: { projectId: string; displayName: string; rootPath: string },
44
+ guidance: Guidance,
45
+ ): LinearSettings {
46
+ const known = values.projects.find((entry) => entry.projectId === project.projectId);
47
+ return { ...values, projects: upsertProject(values, { ...(known ?? emptyProjectConfig(project)), guidance }) };
48
+ }
49
+
27
50
  export const ISOLATIONS = ["worktree", "workspace"] as const;
28
51
  export type Isolation = (typeof ISOLATIONS)[number];
29
52
 
53
+ export const MAPPING_MODES = ["semantic", "explore"] as const;
54
+ export type MappingMode = (typeof MAPPING_MODES)[number];
55
+
56
+ export const LIVE_VIEWS = ["map", "graph"] as const;
57
+ /** Auto follows the running agents; fit shows the whole graph. */
58
+ export const GRAPH_CAMERAS = ["auto", "fit"] as const;
59
+ export type GraphCamera = (typeof GRAPH_CAMERAS)[number];
60
+ export type LiveView = (typeof LIVE_VIEWS)[number];
61
+
30
62
  export const linearSettings = defineSettings({
31
63
  id: "linear",
32
64
  scope: "host",
@@ -45,6 +77,29 @@ export const linearSettings = defineSettings({
45
77
  assignToMe: z.boolean().default(true),
46
78
  })
47
79
  .prefault({}),
80
+ // Explore runs cost provider usage and sync writes to Linear, so both start off.
81
+ live: z
82
+ .object({
83
+ enabled: z.boolean().default(true),
84
+ mapping: z.enum(MAPPING_MODES).default("semantic"),
85
+ exploreProvider: z.string().default(""),
86
+ /** Thinking option of the explore model; empty is the model's default. */
87
+ exploreEffort: z.string().default(""),
88
+ syncTodos: z.boolean().default(false),
89
+ view: z.enum(LIVE_VIEWS).default("map"),
90
+ issuesCollapsed: z.boolean().default(false),
91
+ graphCamera: z.enum(GRAPH_CAMERAS).default("auto"),
92
+ /** The wheel and drags scroll the page instead of moving the graph. */
93
+ graphLocked: z.boolean().default(false),
94
+ })
95
+ .prefault({}),
96
+ // Where the plugin works: every project, minus or plus per-project switches.
97
+ access: z
98
+ .object({
99
+ allProjects: z.boolean().default(true),
100
+ projects: z.record(z.string(), z.boolean()).default({}),
101
+ })
102
+ .prefault({}),
48
103
  projects: z.array(ProjectConfigSchema).default([]),
49
104
  }),
50
105
  });
@@ -66,3 +121,35 @@ export function upsertProject(
66
121
  const others = settings.projects.filter((entry) => entry.projectId !== next.projectId);
67
122
  return [...others, next];
68
123
  }
124
+
125
+ export type ProjectAccess = LinearSettings["access"];
126
+
127
+ /** A project switch wins; projects without one follow the all-projects switch. */
128
+ export function projectEnabled(access: ProjectAccess, projectId: string | null | undefined) {
129
+ if (!projectId) return access.allProjects;
130
+ return access.projects[projectId] ?? access.allProjects;
131
+ }
132
+
133
+ /** Sets one project's switch, dropping it when it matches the all-projects switch. */
134
+ export function withProjectAccess(
135
+ access: ProjectAccess,
136
+ projectId: string,
137
+ enabled: boolean,
138
+ ): ProjectAccess {
139
+ const { [projectId]: _previous, ...others } = access.projects;
140
+ return {
141
+ ...access,
142
+ projects: enabled === access.allProjects ? others : { ...others, [projectId]: enabled },
143
+ };
144
+ }
145
+
146
+ export const AccessSchema = z.object({
147
+ allProjects: z.boolean(),
148
+ projects: z.record(z.string(), z.boolean()),
149
+ });
150
+
151
+ export const accessRpc = defineRpc({
152
+ name: "linear.access.get",
153
+ input: z.object({}),
154
+ output: AccessSchema,
155
+ });
@@ -0,0 +1,309 @@
1
+ import { lexShell, splitCommands, type Word } from "./shell-lexer";
2
+
3
+ // Agents that read through the shell (`cat`, `sed -n`, `grep`, `ls`) touch files without a Read
4
+ // tool call. This finds those files in a command, so Linear Live can show them. It only knows
5
+ // common read, search, and list commands; anything else yields no paths.
6
+
7
+ export interface ExploredDir {
8
+ /** Repo-relative folder with a trailing slash; "" is the root. */
9
+ path: string;
10
+ /** Searched recursively, not only listed. */
11
+ deep: boolean;
12
+ }
13
+
14
+ export interface ShellWrite {
15
+ path: string;
16
+ mode: "write" | "edit";
17
+ }
18
+
19
+ export interface ShellActivity {
20
+ reads: string[];
21
+ writes: ShellWrite[];
22
+ dirs: ExploredDir[];
23
+ pattern: string | null;
24
+ summary: string;
25
+ }
26
+
27
+ const KEYWORDS = new Set(["do", "then", "else", "elif", "if", "while", "until", "!", "{", "time"]);
28
+ const WRAPPERS = new Set(["sudo", "command", "nice", "nohup", "env", "builtin", "exec"]);
29
+ // Not named in a summary: shell setup, and filters that only shape another command's output.
30
+ const NO_VERB = new Set(
31
+ "cd echo printf true false sleep export set : head tail sort uniq wc cut tr grep egrep cat xargs"
32
+ .split(" "),
33
+ );
34
+ const SUBCOMMANDS = new Set(["git", "npm", "npx", "bun", "bunx", "pnpm", "yarn", "gh", "linear"]);
35
+ const LONG_VALUES = new Set([
36
+ "glob", "type", "type-not", "max-count", "context", "after-context", "before-context", "regexp",
37
+ "file", "max-depth", "sort", "include", "exclude", "exclude-dir", "color", "colors", "encoding",
38
+ ]);
39
+ const READERS: Record<string, string> = {
40
+ cat: "", nl: "bdfhilnsvw", less: "", more: "", bat: "rlHm", tac: "", wc: "", file: "",
41
+ stat: "c", head: "nc", tail: "ncs", sort: "ktoS", uniq: "fs", cut: "dfcb", diff: "", cmp: "",
42
+ column: "st", xxd: "lsc", od: "", strings: "n", md5sum: "", sha256sum: "",
43
+ };
44
+ const SEARCHERS: Record<string, string> = {
45
+ grep: "efABCmd", egrep: "efABCmd", fgrep: "efABCmd", rg: "efABCmgtTMdj", ag: "ABCmG",
46
+ ack: "ABCm",
47
+ };
48
+
49
+ interface Parsed {
50
+ options: Set<string>;
51
+ values: Map<string, string[]>;
52
+ positionals: Word[];
53
+ /** Positionals after `--`. */
54
+ dashed: Word[];
55
+ }
56
+
57
+ function parseArgs(args: readonly Word[], shortValues: string): Parsed {
58
+ const parsed: Parsed = { options: new Set(), values: new Map(), positionals: [], dashed: [] };
59
+ const value = (name: string, text: string | undefined) => {
60
+ if (text !== undefined) parsed.values.set(name, [...(parsed.values.get(name) ?? []), text]);
61
+ };
62
+ let ended = false;
63
+ for (let index = 0; index < args.length; index += 1) {
64
+ const arg = args[index] as Word;
65
+ const { text } = arg;
66
+ if (ended) parsed.dashed.push(arg);
67
+ else if (text === "--") ended = true;
68
+ else if (text === "-" || !text.startsWith("-") || arg.dynamic) parsed.positionals.push(arg);
69
+ else if (text.startsWith("--")) {
70
+ const [name = "", inline] = text.slice(2).split(/=(.*)/s);
71
+ parsed.options.add(name);
72
+ if (inline !== undefined) value(name, inline);
73
+ else if (LONG_VALUES.has(name)) value(name, args[++index]?.text);
74
+ } else {
75
+ for (let at = 1; at < text.length; at += 1) {
76
+ const flag = text[at] as string;
77
+ parsed.options.add(flag);
78
+ if (!shortValues.includes(flag)) continue;
79
+ value(flag, at + 1 < text.length ? text.slice(at + 1) : args[++index]?.text);
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ return parsed;
85
+ }
86
+
87
+ const MAX_PATTERN = 28;
88
+
89
+ function hasExtension(path: string): boolean {
90
+ return /[^./][^/]*\.[A-Za-z0-9]+$/.test(path.split("/").pop() ?? "");
91
+ }
92
+
93
+ function shortName(path: string): string {
94
+ const parts = path.replace(/\/$/, "").split("/");
95
+ return path.endsWith("/") ? parts.slice(-2).join("/") || "the repository" : (parts.pop() ?? path);
96
+ }
97
+
98
+ function names(paths: readonly string[]): string {
99
+ const shown = [...new Set(paths.map(shortName))];
100
+ const extra = shown.length - 2;
101
+ return `${shown.slice(0, 2).join(", ")}${extra > 0 ? ` +${extra}` : ""}`;
102
+ }
103
+
104
+ export function parseShell(command: string, cwd: string, start = ""): ShellActivity {
105
+ const root = cwd.replace(/\/+$/, "");
106
+ const reads = new Set<string>();
107
+ const writes = new Map<string, ShellWrite["mode"]>();
108
+ const dirs = new Map<string, boolean>();
109
+ const verbs: string[] = [];
110
+ const env = new Map<string, Word[]>();
111
+ let pattern: string | null = null;
112
+ let base: string | null = start;
113
+
114
+ const resolve = (raw: string, from: string | null): string | null => {
115
+ let joined: string;
116
+ if (raw.startsWith("/")) {
117
+ if (raw === root || raw === `${root}/`) return "";
118
+ if (!raw.startsWith(`${root}/`)) return null;
119
+ joined = raw.slice(root.length + 1);
120
+ } else if (from === null || raw.startsWith("~")) return null;
121
+ else joined = from ? `${from}/${raw}` : raw;
122
+ const parts: string[] = [];
123
+ for (const part of joined.split("/")) {
124
+ if (!part || part === ".") continue;
125
+ if (part !== "..") parts.push(part);
126
+ else if (parts.pop() === undefined) return null;
127
+ }
128
+ return parts.join("/");
129
+ };
130
+ const expand = (word: Word): Word[] => {
131
+ if (!word.dynamic) return [word];
132
+ const name = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/.exec(word.text)?.[1];
133
+ return name ? (env.get(name) ?? []) : [];
134
+ };
135
+ const addDir = (path: string | null, deep: boolean) => {
136
+ if (path === null) return;
137
+ const dir = path === "" ? "" : `${path}/`;
138
+ dirs.set(dir, deep || (dirs.get(dir) ?? false));
139
+ };
140
+ // A glob reads from its folder; a plain word is a file, or a folder when `asDir` says so.
141
+ const target = (word: Word, from: string | null, asDir: (path: string) => boolean, deep = false) => {
142
+ if (word.globAt >= 0) {
143
+ const prefix = word.text.slice(0, word.globAt);
144
+ addDir(resolve(prefix.slice(0, prefix.lastIndexOf("/") + 1), from), deep);
145
+ return;
146
+ }
147
+ const path = resolve(word.text, from);
148
+ if (path === null) return;
149
+ if (path === "" || asDir(word.text)) addDir(path, deep);
150
+ else reads.add(path);
151
+ };
152
+ const never = () => false;
153
+ const noExtension = (text: string) => text.endsWith("/") || !hasExtension(text);
154
+
155
+ for (const simple of splitCommands(lexShell(command))) {
156
+ let words = simple.words;
157
+ while (words[0] && (KEYWORDS.has(words[0].text) || /^[A-Za-z_]\w*=/.test(words[0].text))) {
158
+ words = words.slice(1);
159
+ }
160
+ while (words[0] && WRAPPERS.has(words[0].text)) words = words.slice(1);
161
+ const first = words[0]?.text;
162
+ if (first === "for" && words[2]?.text === "in" && words[1]) {
163
+ env.set(words[1].text, words.slice(3).flatMap(expand));
164
+ continue;
165
+ }
166
+ const args = words.slice(1).flatMap(expand);
167
+ for (const { operator, target: file } of simple.redirects) {
168
+ if (file.dynamic || file.text.startsWith("/dev/")) continue;
169
+ const path = resolve(file.text, base);
170
+ if (path === null || path === "") continue;
171
+ if (operator === "<") reads.add(path);
172
+ else if (operator === ">" || operator === ">>") writes.set(path, operator === ">" ? "write" : "edit");
173
+ }
174
+ if (!first || first === "done" || first === "fi") continue;
175
+ let from = base;
176
+ let name = first.split("/").pop() ?? first;
177
+ let rest = args;
178
+ if (name === "cd" || name === "pushd") {
179
+ base = args[0] ? resolve(args[0].text, base) : base;
180
+ continue;
181
+ }
182
+ if (name === "git") {
183
+ const global = parseArgs(args, "Cc");
184
+ const at = args.findIndex((arg) => arg === global.positionals[0]);
185
+ for (const dir of global.values.get("C") ?? []) from = resolve(dir, base);
186
+ name = `git ${global.positionals[0]?.text ?? ""}`.trim();
187
+ rest = at < 0 ? [] : args.slice(at + 1);
188
+ }
189
+ if (!NO_VERB.has(name)) {
190
+ const sub = SUBCOMMANDS.has(name) ? args.find((arg) => !arg.text.startsWith("-")) : undefined;
191
+ verbs.push(sub ? `${name} ${sub.text}` : name);
192
+ }
193
+ if (name in READERS) {
194
+ for (const word of parseArgs(rest, READERS[name] ?? "").positionals) target(word, from, never);
195
+ } else if (name === "sed" || name === "awk" || name === "gawk" || name === "jq") {
196
+ const parsed = parseArgs(rest, name === "sed" ? "efl" : name === "jq" ? "" : "Ffv");
197
+ const scripted = parsed.options.has("e") || parsed.options.has("f");
198
+ const files = parsed.positionals.slice(scripted && name !== "jq" ? 0 : 1);
199
+ const inPlace = name === "sed" && parsed.options.has("i");
200
+ for (const word of files) {
201
+ if (/^\w+=/.test(word.text)) continue;
202
+ const path = resolve(word.text, from);
203
+ if (inPlace && path) writes.set(path, "edit");
204
+ else target(word, from, never);
205
+ }
206
+ } else if (name in SEARCHERS || name === "git grep") {
207
+ const parsed = parseArgs(rest, SEARCHERS[name] ?? "efABCm");
208
+ const listing = parsed.options.has("files");
209
+ const given = parsed.values.get("e")?.[0] ?? parsed.values.get("regexp")?.[0];
210
+ const explicit = given !== undefined || parsed.options.has("f") || listing;
211
+ const paths = [...parsed.positionals.slice(explicit ? 0 : 1), ...parsed.dashed];
212
+ // A grep in a pipe without files or -r filters output and searches nothing.
213
+ const recursive = parsed.options.has("r") || parsed.options.has("R");
214
+ const filter = /^[ef]?grep$/.test(name) && !recursive && paths.length === 0;
215
+ if (filter) continue;
216
+ if (!listing) pattern ??= given ?? parsed.positionals[0]?.text ?? null;
217
+ if (paths.length === 0) addDir(from, true);
218
+ for (const word of paths) target(word, from, noExtension, true);
219
+ } else if (name === "ls" || name === "tree" || name === "find" || name === "fd") {
220
+ const parsed = parseArgs(rest, name === "tree" ? "LPIo" : name === "fd" ? "etdE" : "");
221
+ const deep = name !== "ls" || parsed.options.has("R");
222
+ const expression = rest.findIndex((word) => /^[-(!]/.test(word.text));
223
+ let paths = parsed.positionals;
224
+ // find takes folders first and then an expression whose words are not paths.
225
+ if (name === "find") paths = expression < 0 ? rest : rest.slice(0, expression);
226
+ if (name === "fd") paths = paths.slice(1);
227
+ if (paths.length === 0) addDir(from, deep);
228
+ for (const word of paths) target(word, from, (text) => name !== "ls" || noExtension(text), deep);
229
+ } else if (/^git (show|diff|log|blame|annotate)$/.test(name)) {
230
+ const parsed = parseArgs(rest, "nSGL");
231
+ for (const word of parsed.positionals) {
232
+ const path = word.text.includes(":") ? word.text.slice(word.text.indexOf(":") + 1) : null;
233
+ if (path) target({ ...word, text: path }, from, never);
234
+ }
235
+ for (const word of parsed.dashed) target(word, from, noExtension);
236
+ if (name === "git blame" && parsed.dashed.length === 0) {
237
+ const last = parsed.positionals.at(-1);
238
+ if (last) target(last, from, never);
239
+ }
240
+ } else if (name === "git ls-files") {
241
+ const parsed = parseArgs(rest, "x");
242
+ const paths = [...parsed.positionals, ...parsed.dashed];
243
+ if (paths.length === 0) addDir(from, true);
244
+ for (const word of paths) target(word, from, noExtension, true);
245
+ } else if (name === "tee" || name === "touch") {
246
+ for (const word of parseArgs(rest, "").positionals) {
247
+ const path = resolve(word.text, from);
248
+ if (path) writes.set(path, name === "tee" && rest.some((w) => w.text === "-a") ? "edit" : "write");
249
+ }
250
+ } else if (name === "cp" || name === "mv") {
251
+ const files = parseArgs(rest, "St").positionals;
252
+ const destination = files.pop();
253
+ for (const word of files) if (name === "cp") target(word, from, never);
254
+ const path = destination ? resolve(destination.text, from) : null;
255
+ if (path && hasExtension(path)) writes.set(path, "write");
256
+ }
257
+ }
258
+
259
+ const writeList = [...writes].map(([path, mode]) => ({ path, mode }));
260
+ const readList = [...reads].filter((path) => !writes.has(path));
261
+ const dirList = [...dirs].map(([path, deep]) => ({ path, deep }));
262
+ const parts: string[] = [];
263
+ if (writeList.length > 0) parts.push(`wrote ${names(writeList.map((write) => write.path))}`);
264
+ if (readList.length > 0) parts.push(`read ${names(readList)}`);
265
+ if (pattern !== null) {
266
+ const scope = dirList.filter((dir) => dir.deep && dir.path !== "").map((dir) => dir.path);
267
+ const shown = pattern.replace(/\\\|/g, "|");
268
+ const short = shown.length > MAX_PATTERN ? `${shown.slice(0, MAX_PATTERN - 1)}…` : shown;
269
+ parts.push(`searched "${short}"${scope.length > 0 ? ` in ${names(scope)}` : ""}`);
270
+ } else if (parts.length === 0 && dirList.length > 0) {
271
+ parts.push(`listed ${names(dirList.map((dir) => dir.path || "./"))}`);
272
+ }
273
+ if (parts.length === 0 && verbs.length > 0) {
274
+ const shown = [...new Set(verbs)];
275
+ parts.push(`ran ${shown.slice(0, 2).join(", ")}${shown.length > 2 ? ` +${shown.length - 2}` : ""}`);
276
+ }
277
+ const summary = parts.slice(0, 2).join(", ") || "Ran a command";
278
+ return {
279
+ reads: readList,
280
+ writes: writeList,
281
+ dirs: dirList,
282
+ pattern,
283
+ summary: summary.charAt(0).toUpperCase() + summary.slice(1),
284
+ };
285
+ }
286
+
287
+ const MAX_CACHED = 500;
288
+ const cache = new Map<string, ShellActivity>();
289
+
290
+ /** parseShell with a cache: the panel derives activity again on every timeline update. */
291
+ export function shellActivity(command: string, cwd: string, start = ""): ShellActivity {
292
+ const key = `${cwd}\u0000${start}\u0000${command}`;
293
+ const hit = cache.get(key);
294
+ if (hit) return hit;
295
+ const parsed = parseShell(command, cwd, start);
296
+ if (cache.size >= MAX_CACHED) cache.delete(cache.keys().next().value as string);
297
+ cache.set(key, parsed);
298
+ return parsed;
299
+ }
300
+
301
+ /** The command as shown in the feed: one line, without a leading `cd` into the agent folder. */
302
+ export function displayCommand(command: string, cwd: string): string {
303
+ const root = cwd.replace(/\/+$/, "");
304
+ const firstLine = command.split("\n")[0] ?? command;
305
+ return firstLine
306
+ .replace(new RegExp(`^cd\\s+['"]?${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/?['"]?\\s*&&\\s*`), "")
307
+ .replaceAll(`${root}/`, "")
308
+ .trim();
309
+ }