@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +19 -1
- package/README.md +77 -6
- package/client/access.tsx +66 -0
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/browser.tsx +12 -9
- package/client/graph-labels.ts +137 -0
- package/client/launch-choices.ts +18 -5
- package/client/launch-plan.ts +88 -39
- package/client/launch.tsx +9 -4
- package/client/live-agents.tsx +407 -0
- package/client/live-cursor.tsx +291 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +434 -0
- package/client/live-header.tsx +312 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +444 -0
- package/client/live-timeline.ts +341 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +121 -0
- package/client/settings-projects.tsx +9 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +24 -4
- package/package.json +1 -1
- package/server/agent-runs.ts +299 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +64 -0
- package/server/links.ts +153 -0
- package/server/live.ts +334 -0
- package/server/stores.ts +119 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +308 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +200 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +16 -1
- package/shared/settings.ts +57 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { pathsIn } from "./live";
|
|
2
|
+
|
|
3
|
+
// Subagents that run inside the provider (Claude's Agent tool, Codex sub-agents) report only
|
|
4
|
+
// a type, a description, and while the launch streams, the prompt. Their own file reads never
|
|
5
|
+
// reach the parent timeline, so the files their prompt names are the best clue to their place.
|
|
6
|
+
|
|
7
|
+
export type SubagentStatus = "running" | "completed" | "failed" | "canceled";
|
|
8
|
+
|
|
9
|
+
export interface SubagentRun {
|
|
10
|
+
/** Type and description, the same for the launch call and the subagent item. */
|
|
11
|
+
key: string;
|
|
12
|
+
type: string;
|
|
13
|
+
description: string;
|
|
14
|
+
status: SubagentStatus;
|
|
15
|
+
/** Repo-relative files and folders its prompt or description names. */
|
|
16
|
+
targets: string[];
|
|
17
|
+
order: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SubagentSignal {
|
|
21
|
+
key: string;
|
|
22
|
+
type: string;
|
|
23
|
+
description: string;
|
|
24
|
+
/** Null for the launch call: it completes as soon as the subagent starts in the background. */
|
|
25
|
+
status: SubagentStatus | null;
|
|
26
|
+
targets: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ToolItem = Readonly<Record<string, unknown>>;
|
|
30
|
+
|
|
31
|
+
const LAUNCHERS = new Set(["Agent", "Task"]);
|
|
32
|
+
|
|
33
|
+
function text(value: unknown): string {
|
|
34
|
+
return typeof value === "string" ? value : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function statusOf(value: unknown): SubagentStatus {
|
|
38
|
+
return value === "failed" || value === "canceled" || value === "completed" ? value : "running";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A call that starts, reports on, or waits for a subagent. */
|
|
42
|
+
export function isSubagentCall(item: ToolItem): boolean {
|
|
43
|
+
const detail = (item.detail ?? {}) as Record<string, unknown>;
|
|
44
|
+
return detail.type === "sub_agent" || (LAUNCHERS.has(text(item.name)) && detail.type === "unknown");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The subagent a tool call starts or reports on, or null for any other call or a wait. */
|
|
48
|
+
export function subagentOf(
|
|
49
|
+
item: ToolItem,
|
|
50
|
+
resolve: (path: string) => string | null,
|
|
51
|
+
): SubagentSignal | null {
|
|
52
|
+
const detail = (item.detail ?? {}) as Record<string, unknown>;
|
|
53
|
+
let type: string;
|
|
54
|
+
let description: string;
|
|
55
|
+
let prompt = "";
|
|
56
|
+
let status: SubagentStatus | null;
|
|
57
|
+
if (detail.type === "sub_agent") {
|
|
58
|
+
type = text(detail.subAgentType);
|
|
59
|
+
description = text(detail.description);
|
|
60
|
+
status = statusOf(item.status);
|
|
61
|
+
const actions = Array.isArray(detail.actions) ? detail.actions : [];
|
|
62
|
+
prompt = actions.map((action) => text((action as { summary?: unknown }).summary)).join("\n");
|
|
63
|
+
} else if (LAUNCHERS.has(text(item.name)) && detail.type === "unknown") {
|
|
64
|
+
const input = (detail.input ?? {}) as Record<string, unknown>;
|
|
65
|
+
type = text(input.subagent_type);
|
|
66
|
+
description = text(input.description);
|
|
67
|
+
prompt = text(input.prompt);
|
|
68
|
+
status = null;
|
|
69
|
+
} else {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (!description && !type) return null;
|
|
73
|
+
// Waiting for background agents shows as one more Agent call; it starts nothing.
|
|
74
|
+
if (/^wait(ing)? (for|on)\b/i.test(description)) return null;
|
|
75
|
+
const named = pathsIn(`${description}\n${prompt}`)
|
|
76
|
+
.map(resolve)
|
|
77
|
+
.filter((path): path is string => path !== null && path !== "");
|
|
78
|
+
return {
|
|
79
|
+
key: `${type}\u0000${description}`,
|
|
80
|
+
type: type || "Subagent",
|
|
81
|
+
description,
|
|
82
|
+
status,
|
|
83
|
+
targets: [...new Set(named)],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Folds launch and report calls into one run per subagent, in launch order. */
|
|
88
|
+
export function mergeSubagent(
|
|
89
|
+
runs: Map<string, SubagentRun>,
|
|
90
|
+
signal: SubagentSignal,
|
|
91
|
+
order: number,
|
|
92
|
+
): SubagentRun {
|
|
93
|
+
const known = runs.get(signal.key);
|
|
94
|
+
const run: SubagentRun = known ?? {
|
|
95
|
+
key: signal.key,
|
|
96
|
+
type: signal.type,
|
|
97
|
+
description: signal.description,
|
|
98
|
+
status: "running",
|
|
99
|
+
targets: [],
|
|
100
|
+
order,
|
|
101
|
+
};
|
|
102
|
+
if (signal.status) run.status = signal.status;
|
|
103
|
+
run.targets = [...new Set([...run.targets, ...signal.targets])];
|
|
104
|
+
runs.set(signal.key, run);
|
|
105
|
+
return run;
|
|
106
|
+
}
|