@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-alpha.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 (65) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +138 -11
  3. package/client/access.tsx +67 -0
  4. package/client/agent-options.ts +12 -2
  5. package/client/agent-preferences.ts +112 -0
  6. package/client/browser.tsx +12 -9
  7. package/client/compat.ts +23 -5
  8. package/client/connect.tsx +3 -2
  9. package/client/launch-assignments.tsx +182 -0
  10. package/client/launch-choices.ts +23 -6
  11. package/client/launch-guidance.tsx +103 -0
  12. package/client/launch-plan.ts +166 -42
  13. package/client/launch.tsx +55 -9
  14. package/client/live-agents.tsx +446 -0
  15. package/client/live-feed.tsx +151 -0
  16. package/client/live-header.tsx +310 -0
  17. package/client/live-issues.tsx +345 -0
  18. package/client/live-map.tsx +351 -0
  19. package/client/live-panel.tsx +392 -0
  20. package/client/live-timeline.ts +379 -0
  21. package/client/pill.tsx +394 -0
  22. package/client/plugin-icon.tsx +75 -0
  23. package/client/project-init.tsx +192 -0
  24. package/client/project-knowledge.tsx +87 -0
  25. package/client/settings-access.tsx +56 -0
  26. package/client/settings-agents.tsx +113 -0
  27. package/client/settings-fields.tsx +11 -3
  28. package/client/settings-icon.tsx +277 -0
  29. package/client/settings-live.tsx +128 -0
  30. package/client/settings-overrides.tsx +148 -0
  31. package/client/settings-projects.tsx +26 -4
  32. package/client/settings-prompts.tsx +3 -3
  33. package/client/timeline-card.tsx +2 -1
  34. package/client/tool-buttons.tsx +68 -0
  35. package/client/ui.tsx +16 -7
  36. package/client/web.ts +81 -0
  37. package/index.client.tsx +160 -51
  38. package/index.server.ts +32 -5
  39. package/package.json +1 -1
  40. package/server/agent-hooks.ts +37 -0
  41. package/server/agent-runs.ts +312 -0
  42. package/server/agent-tools.ts +276 -0
  43. package/server/handlers.ts +12 -1
  44. package/server/init.ts +89 -0
  45. package/server/inspect.ts +392 -0
  46. package/server/knowledge.ts +157 -0
  47. package/server/live.ts +371 -0
  48. package/server/mcp-http.ts +156 -0
  49. package/server/stores.ts +126 -0
  50. package/server/subagent-logs.ts +146 -0
  51. package/server/sync.ts +133 -0
  52. package/shared/activity.ts +268 -0
  53. package/shared/agent-hooks.ts +68 -0
  54. package/shared/agent-tools.ts +157 -0
  55. package/shared/custom-icon.ts +188 -0
  56. package/shared/knowledge.ts +255 -0
  57. package/shared/live.ts +248 -0
  58. package/shared/map-model.ts +240 -0
  59. package/shared/project-setup.ts +56 -0
  60. package/shared/prompts.ts +109 -2
  61. package/shared/settings.ts +145 -3
  62. package/shared/shell-activity.ts +309 -0
  63. package/shared/shell-lexer.ts +180 -0
  64. package/shared/subagents.ts +106 -0
  65. package/shared/todo-sync.ts +158 -0
@@ -1,5 +1,6 @@
1
- import { defineSettings } from "@getpaseo/plugin";
1
+ import { defineRpc, defineSettings } from "@getpaseo/plugin";
2
2
  import { z } from "zod";
3
+ import { IconSettingsSchema } from "./custom-icon";
3
4
 
4
5
  export const AGENT_ACTIONS = ["implement", "review"] as const;
5
6
  export type AgentAction = (typeof AGENT_ACTIONS)[number];
@@ -12,6 +13,53 @@ const ActionOverrideSchema = z.object({
12
13
  text: z.string().default(""),
13
14
  });
14
15
 
16
+ /** Extra instructions the launch adds to the prompt. Saved per project. */
17
+ export const GuidanceSchema = z.object({
18
+ /** Keep Linear current: todos with sub-issue keys, and comments when a Linear tool exists. */
19
+ updateLinear: z.boolean().default(false),
20
+ /** Subagents state the issue key they work on. Claude agents also get a hook that tells them. */
21
+ subagentKeys: z.boolean().default(false),
22
+ /** Hand work to Paseo agents on the same provider, with thinking that fits the task. */
23
+ paseoSubagents: z.boolean().default(false),
24
+ });
25
+ export type Guidance = z.infer<typeof GuidanceSchema>;
26
+ export const GUIDANCE_KEYS = ["updateLinear", "subagentKeys", "paseoSubagents"] as const;
27
+
28
+ export const ISOLATIONS = ["worktree", "workspace"] as const;
29
+ export type Isolation = (typeof ISOLATIONS)[number];
30
+
31
+ /** The most agents the agent limit setting offers; 0 means no limit. */
32
+ export const MAX_AGENTS_LIMIT = 16;
33
+ const AgentLimit = z.number().int().min(0).max(MAX_AGENTS_LIMIT);
34
+
35
+ /** The plugin's linear MCP server, and the agents its start_agent tool starts. */
36
+ export const ToolSettingsSchema = z.object({
37
+ /** Agents started from an issue get the linear MCP server when their options need it. */
38
+ enabled: z.boolean().default(true),
39
+ /** The edit_issue tool, which changes issue descriptions. */
40
+ allowEdits: z.boolean().default(true),
41
+ /** The launch page offers an agent for each sub-issue. */
42
+ assignAgents: z.boolean().default(true),
43
+ /** Agents that one launch may run at the same time with start_agent; 0 has no limit. */
44
+ maxAgents: AgentLimit.default(0),
45
+ });
46
+ export type ToolSettings = z.infer<typeof ToolSettingsSchema>;
47
+
48
+ /** A project's own values. A missing value follows the setting for all projects. */
49
+ export const ProjectOverridesSchema = z.object({
50
+ provider: z.string().optional(),
51
+ isolation: z.enum(ISOLATIONS).optional(),
52
+ includeComments: z.boolean().optional(),
53
+ moveToStarted: z.boolean().optional(),
54
+ assignToMe: z.boolean().optional(),
55
+ syncTodos: z.boolean().optional(),
56
+ tools: z.boolean().optional(),
57
+ allowEdits: z.boolean().optional(),
58
+ assignAgents: z.boolean().optional(),
59
+ maxAgents: AgentLimit.optional(),
60
+ });
61
+ export type ProjectOverrides = z.infer<typeof ProjectOverridesSchema>;
62
+
15
63
  export const ProjectConfigSchema = z.object({
16
64
  projectId: z.string(),
17
65
  displayName: z.string(),
@@ -21,11 +69,23 @@ export const ProjectConfigSchema = z.object({
21
69
  steps: z.array(z.string()).default([]),
22
70
  implement: ActionOverrideSchema.prefault({}),
23
71
  review: ActionOverrideSchema.prefault({}),
72
+ guidance: GuidanceSchema.prefault({}),
73
+ overrides: ProjectOverridesSchema.prefault({}),
24
74
  });
25
75
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
26
76
 
27
- export const ISOLATIONS = ["worktree", "workspace"] as const;
28
- export type Isolation = (typeof ISOLATIONS)[number];
77
+ /** Settings with a project's guidance saved, adding the project when it has no entry yet. */
78
+ export function withProjectGuidance(
79
+ values: LinearSettings,
80
+ project: { projectId: string; displayName: string; rootPath: string },
81
+ guidance: Guidance,
82
+ ): LinearSettings {
83
+ const known = values.projects.find((entry) => entry.projectId === project.projectId);
84
+ return { ...values, projects: upsertProject(values, { ...(known ?? emptyProjectConfig(project)), guidance }) };
85
+ }
86
+
87
+ export const MAPPING_MODES = ["semantic", "explore"] as const;
88
+ export type MappingMode = (typeof MAPPING_MODES)[number];
29
89
 
30
90
  export const linearSettings = defineSettings({
31
91
  id: "linear",
@@ -45,12 +105,62 @@ export const linearSettings = defineSettings({
45
105
  assignToMe: z.boolean().default(true),
46
106
  })
47
107
  .prefault({}),
108
+ // Explore runs cost provider usage and sync writes to Linear, so both start off.
109
+ live: z
110
+ .object({
111
+ enabled: z.boolean().default(true),
112
+ mapping: z.enum(MAPPING_MODES).default("semantic"),
113
+ exploreProvider: z.string().default(""),
114
+ /** Thinking option of the explore model; empty is the model's default. */
115
+ exploreEffort: z.string().default(""),
116
+ syncTodos: z.boolean().default(false),
117
+ issuesCollapsed: z.boolean().default(false),
118
+ })
119
+ .prefault({}),
120
+ tools: ToolSettingsSchema.prefault({}),
121
+ // Where the plugin works: every project, minus or plus per-project switches.
122
+ access: z
123
+ .object({
124
+ allProjects: z.boolean().default(true),
125
+ projects: z.record(z.string(), z.boolean()).default({}),
126
+ })
127
+ .prefault({}),
48
128
  projects: z.array(ProjectConfigSchema).default([]),
129
+ /** The icon of pills and other plugin places: built-in or the user's SVG. */
130
+ icon: IconSettingsSchema.prefault({}),
49
131
  }),
50
132
  });
51
133
 
52
134
  export type LinearSettings = z.output<typeof linearSettings.schema>;
53
135
 
136
+ /** The settings that a project can change, with its own values applied. */
137
+ export interface ProjectDefaults {
138
+ launch: LinearSettings["launch"];
139
+ syncTodos: boolean;
140
+ tools: ToolSettings;
141
+ }
142
+
143
+ export function projectDefaults(values: LinearSettings, projectId: string | null | undefined): ProjectDefaults {
144
+ const own = (projectId ? values.projects.find((entry) => entry.projectId === projectId)?.overrides : undefined) ?? {};
145
+ const { launch, tools } = values;
146
+ return {
147
+ launch: {
148
+ provider: own.provider ?? launch.provider,
149
+ isolation: own.isolation ?? launch.isolation,
150
+ includeComments: own.includeComments ?? launch.includeComments,
151
+ moveToStarted: own.moveToStarted ?? launch.moveToStarted,
152
+ assignToMe: own.assignToMe ?? launch.assignToMe,
153
+ },
154
+ syncTodos: own.syncTodos ?? values.live.syncTodos,
155
+ tools: {
156
+ enabled: own.tools ?? tools.enabled,
157
+ allowEdits: own.allowEdits ?? tools.allowEdits,
158
+ assignAgents: own.assignAgents ?? tools.assignAgents,
159
+ maxAgents: own.maxAgents ?? tools.maxAgents,
160
+ },
161
+ };
162
+ }
163
+
54
164
  export function emptyProjectConfig(project: {
55
165
  projectId: string;
56
166
  displayName: string;
@@ -66,3 +176,35 @@ export function upsertProject(
66
176
  const others = settings.projects.filter((entry) => entry.projectId !== next.projectId);
67
177
  return [...others, next];
68
178
  }
179
+
180
+ export type ProjectAccess = LinearSettings["access"];
181
+
182
+ /** A project switch wins; projects without one follow the all-projects switch. */
183
+ export function projectEnabled(access: ProjectAccess, projectId: string | null | undefined) {
184
+ if (!projectId) return access.allProjects;
185
+ return access.projects[projectId] ?? access.allProjects;
186
+ }
187
+
188
+ /** Sets one project's switch, dropping it when it matches the all-projects switch. */
189
+ export function withProjectAccess(
190
+ access: ProjectAccess,
191
+ projectId: string,
192
+ enabled: boolean,
193
+ ): ProjectAccess {
194
+ const { [projectId]: _previous, ...others } = access.projects;
195
+ return {
196
+ ...access,
197
+ projects: enabled === access.allProjects ? others : { ...others, [projectId]: enabled },
198
+ };
199
+ }
200
+
201
+ export const AccessSchema = z.object({
202
+ allProjects: z.boolean(),
203
+ projects: z.record(z.string(), z.boolean()),
204
+ });
205
+
206
+ export const accessRpc = defineRpc({
207
+ name: "linear.access.get",
208
+ input: z.object({}),
209
+ output: AccessSchema,
210
+ });
@@ -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
+ }
@@ -0,0 +1,180 @@
1
+ // Splits a shell command into words, operators, and redirects, the way the shell would
2
+ // before running it. Expansions are kept as text and marked, since their values are unknown.
3
+
4
+ export interface Word {
5
+ kind: "word";
6
+ text: string;
7
+ /** Contains `$` or backtick expansion, so the shell may change the text. */
8
+ dynamic: boolean;
9
+ /** Index of the first unquoted glob character, or -1. */
10
+ globAt: number;
11
+ }
12
+ export type Token = Word | { kind: "op"; text: string } | { kind: "redirect"; text: string };
13
+
14
+ function readExpansion(input: string, start: number): number {
15
+ const open = input[start + 1];
16
+ if (input[start] === "`") {
17
+ const end = input.indexOf("`", start + 1);
18
+ return end < 0 ? input.length : end + 1;
19
+ }
20
+ if (open === "(" || open === "{") {
21
+ const close = open === "(" ? ")" : "}";
22
+ let depth = 0;
23
+ for (let index = start + 1; index < input.length; index += 1) {
24
+ if (input[index] === open) depth += 1;
25
+ else if (input[index] === close && --depth === 0) return index + 1;
26
+ }
27
+ return input.length;
28
+ }
29
+ let index = start + 1;
30
+ while (index < input.length && /[A-Za-z0-9_@*#?!-]/.test(input[index] as string)) index += 1;
31
+ return index === start + 1 ? start + 1 : index;
32
+ }
33
+
34
+ /** Splits a command into words, operators, and redirects. Heredoc bodies are skipped. */
35
+ export function lexShell(input: string): Token[] {
36
+ const tokens: Token[] = [];
37
+ const heredocs: { delimiter: string; strip: boolean }[] = [];
38
+ let text: string | null = null;
39
+ let dynamic = false;
40
+ let globAt = -1;
41
+ let index = 0;
42
+ const add = (value: string) => {
43
+ text = (text ?? "") + value;
44
+ };
45
+ const flush = () => {
46
+ if (text !== null) tokens.push({ kind: "word", text, dynamic, globAt });
47
+ text = null;
48
+ dynamic = false;
49
+ globAt = -1;
50
+ };
51
+ const skipHeredocs = () => {
52
+ for (const { delimiter, strip } of heredocs.splice(0)) {
53
+ while (index < input.length) {
54
+ const end = input.indexOf("\n", index) < 0 ? input.length : input.indexOf("\n", index);
55
+ const line = input.slice(index, end);
56
+ index = end + 1;
57
+ if ((strip ? line.replace(/^\t+/, "") : line) === delimiter) break;
58
+ }
59
+ }
60
+ };
61
+ while (index < input.length) {
62
+ const char = input[index] as string;
63
+ const next = input[index + 1] ?? "";
64
+ if (char === "\n") {
65
+ flush();
66
+ tokens.push({ kind: "op", text: ";" });
67
+ index += 1;
68
+ skipHeredocs();
69
+ } else if (char === " " || char === "\t" || char === "\r") {
70
+ flush();
71
+ index += 1;
72
+ } else if (char === "#" && text === null) {
73
+ while (index < input.length && input[index] !== "\n") index += 1;
74
+ } else if (char === "\\") {
75
+ if (next !== "\n") add(next);
76
+ index += 2;
77
+ } else if (char === "'") {
78
+ const end = input.indexOf("'", index + 1);
79
+ const stop = end < 0 ? input.length : end;
80
+ add(input.slice(index + 1, stop));
81
+ index = stop + 1;
82
+ } else if (char === '"') {
83
+ let value = "";
84
+ index += 1;
85
+ while (index < input.length && input[index] !== '"') {
86
+ const inner = input[index] as string;
87
+ if (inner === "\\" && '"\\$`'.includes(input[index + 1] ?? "")) {
88
+ value += input[index + 1];
89
+ index += 2;
90
+ continue;
91
+ }
92
+ if (inner === "$" || inner === "`") dynamic = true;
93
+ value += inner;
94
+ index += 1;
95
+ }
96
+ add(value);
97
+ index += 1;
98
+ } else if (char === "$" || char === "`") {
99
+ const end = readExpansion(input, index);
100
+ dynamic = true;
101
+ add(input.slice(index, end));
102
+ index = end;
103
+ } else if ((char === "&" || char === "|") && next === char) {
104
+ flush();
105
+ tokens.push({ kind: "op", text: char + char });
106
+ index += 2;
107
+ } else if (char === "&" && next === ">") {
108
+ flush();
109
+ tokens.push({ kind: "redirect", text: ">" });
110
+ index += input[index + 2] === ">" ? 3 : 2;
111
+ } else if (char === "|" || char === ";" || char === "&" || char === "(" || char === ")") {
112
+ flush();
113
+ tokens.push({ kind: "op", text: char === "|" ? "|" : ";" });
114
+ index += char === "|" && next === "&" ? 2 : 1;
115
+ } else if (char === ">" || char === "<") {
116
+ // A bare number before the arrow is a file descriptor, as in `2>/dev/null`.
117
+ if (text !== null && /^\d+$/.test(text) && !dynamic) text = null;
118
+ else flush();
119
+ if (char === "<" && next === "<" && input[index + 2] !== "<") {
120
+ index += 2;
121
+ const strip = input[index] === "-";
122
+ if (strip) index += 1;
123
+ while (input[index] === " ") index += 1;
124
+ let delimiter = "";
125
+ while (index < input.length && !/[\s;&|<>]/.test(input[index] as string)) {
126
+ if (!`'"\\`.includes(input[index] as string)) delimiter += input[index];
127
+ index += 1;
128
+ }
129
+ heredocs.push({ delimiter, strip });
130
+ continue;
131
+ }
132
+ let operator = char;
133
+ index += 1;
134
+ while (input[index] === "<" || (char === ">" && input[index] === ">")) {
135
+ operator += input[index];
136
+ index += 1;
137
+ }
138
+ if (input[index] === "&") {
139
+ index += 1;
140
+ while (/[0-9-]/.test(input[index] ?? "")) index += 1;
141
+ continue;
142
+ }
143
+ if (input[index] === "|") index += 1;
144
+ tokens.push({ kind: "redirect", text: operator });
145
+ } else {
146
+ if (globAt < 0 && (char === "*" || char === "?" || char === "[")) globAt = (text ?? "").length;
147
+ add(char);
148
+ index += 1;
149
+ }
150
+ }
151
+ flush();
152
+ return tokens;
153
+ }
154
+
155
+ export interface SimpleCommand {
156
+ words: Word[];
157
+ redirects: { operator: string; target: Word }[];
158
+ }
159
+
160
+ export function splitCommands(tokens: readonly Token[]): SimpleCommand[] {
161
+ const list: SimpleCommand[] = [];
162
+ let current: SimpleCommand = { words: [], redirects: [] };
163
+ let pending: string | null = null;
164
+ for (const token of tokens) {
165
+ if (token.kind === "op") {
166
+ pending = null;
167
+ if (current.words.length > 0 || current.redirects.length > 0) list.push(current);
168
+ current = { words: [], redirects: [] };
169
+ } else if (token.kind === "redirect") {
170
+ pending = token.text;
171
+ } else if (pending) {
172
+ current.redirects.push({ operator: pending, target: token });
173
+ pending = null;
174
+ } else {
175
+ current.words.push(token);
176
+ }
177
+ }
178
+ if (current.words.length > 0 || current.redirects.length > 0) list.push(current);
179
+ return list;
180
+ }