@yannelli/paseo-linear-plugin 1.0.1 → 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.
- package/OVERVIEW.md +32 -2
- package/README.md +134 -8
- package/client/access.tsx +67 -0
- package/client/browser.tsx +12 -9
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- package/client/launch-assignments.tsx +182 -0
- package/client/launch-choices.ts +21 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +114 -17
- package/client/launch.tsx +55 -9
- package/client/live-agents.tsx +446 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-header.tsx +310 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +351 -0
- package/client/live-panel.tsx +392 -0
- package/client/live-timeline.ts +379 -0
- package/client/pill.tsx +394 -0
- package/client/plugin-icon.tsx +75 -0
- package/client/project-init.tsx +192 -0
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-agents.tsx +113 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +277 -0
- package/client/settings-live.tsx +128 -0
- package/client/settings-overrides.tsx +148 -0
- package/client/settings-projects.tsx +26 -4
- package/client/settings-prompts.tsx +3 -3
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +81 -0
- package/index.client.tsx +160 -51
- package/index.server.ts +32 -5
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +312 -0
- package/server/agent-tools.ts +276 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +89 -0
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +371 -0
- package/server/mcp-http.ts +156 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +133 -0
- package/shared/activity.ts +268 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +157 -0
- package/shared/custom-icon.ts +188 -0
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +248 -0
- package/shared/map-model.ts +240 -0
- package/shared/project-setup.ts +56 -0
- package/shared/prompts.ts +109 -2
- package/shared/settings.ts +145 -3
- 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,146 @@
|
|
|
1
|
+
import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { SubagentLog } from "../shared/live";
|
|
5
|
+
|
|
6
|
+
// Claude writes each subagent's transcript beside the session, also for subagents that run in
|
|
7
|
+
// the background and send nothing to the parent timeline. This reads their tool calls and turns
|
|
8
|
+
// them into timeline items the panel already understands. Nothing here writes or runs anything.
|
|
9
|
+
|
|
10
|
+
const SESSION_ID = /^[A-Za-z0-9-]{8,80}$/;
|
|
11
|
+
const MAX_RUNS = 8;
|
|
12
|
+
const MAX_TAIL_BYTES = 512 * 1024;
|
|
13
|
+
const MAX_ITEMS = 120;
|
|
14
|
+
|
|
15
|
+
/** Folders that can hold Claude's `projects` folder: the env setting, the default, Paseo homes. */
|
|
16
|
+
async function claudeHomes(): Promise<string[]> {
|
|
17
|
+
const homes = [process.env.CLAUDE_CONFIG_DIR, path.join(homedir(), ".claude")];
|
|
18
|
+
const paseoHome = process.env.PASEO_HOME || path.join(homedir(), ".paseo");
|
|
19
|
+
const accounts = await readdir(path.join(paseoHome, "zerosub", "homes")).catch(() => []);
|
|
20
|
+
for (const name of accounts) homes.push(path.join(paseoHome, "zerosub", "homes", name));
|
|
21
|
+
return [...new Set(homes.filter((home): home is string => Boolean(home)))];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const found = new Map<string, string>();
|
|
25
|
+
|
|
26
|
+
async function isDirectory(target: string): Promise<boolean> {
|
|
27
|
+
return (await stat(target).catch(() => null))?.isDirectory() ?? false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The session's `subagents` folder. Claude names the project folder after the cwd. */
|
|
31
|
+
export async function subagentFolder(sessionId: string, cwd: string): Promise<string | null> {
|
|
32
|
+
if (!SESSION_ID.test(sessionId)) return null;
|
|
33
|
+
const known = found.get(sessionId);
|
|
34
|
+
if (known) return known;
|
|
35
|
+
const slug = cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
36
|
+
for (const home of await claudeHomes()) {
|
|
37
|
+
const projects = path.join(home, "projects");
|
|
38
|
+
const guess = path.join(projects, slug, sessionId, "subagents");
|
|
39
|
+
const names = (await isDirectory(guess)) ? [slug] : await readdir(projects).catch(() => []);
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
const folder = path.join(projects, name, sessionId, "subagents");
|
|
42
|
+
if (await isDirectory(folder)) {
|
|
43
|
+
found.set(sessionId, folder);
|
|
44
|
+
return folder;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function tail(file: string): Promise<string> {
|
|
52
|
+
const handle = await open(file, "r");
|
|
53
|
+
try {
|
|
54
|
+
const { size } = await handle.stat();
|
|
55
|
+
const start = Math.max(0, size - MAX_TAIL_BYTES);
|
|
56
|
+
const buffer = Buffer.alloc(size - start);
|
|
57
|
+
await handle.read(buffer, 0, buffer.length, start);
|
|
58
|
+
const text = buffer.toString("utf8");
|
|
59
|
+
// A cut first line is not valid JSON; drop it.
|
|
60
|
+
return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
|
|
61
|
+
} finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Block = Record<string, unknown>;
|
|
67
|
+
type Item = SubagentLog["items"][number];
|
|
68
|
+
|
|
69
|
+
const str = (value: unknown) => (typeof value === "string" ? value : "");
|
|
70
|
+
const lines = (value: unknown) => (str(value) ? str(value).replace(/\n$/, "").split("\n").length : 0);
|
|
71
|
+
|
|
72
|
+
/** A Claude tool call as a Paseo-style tool call item, with paths moved from the worktree. */
|
|
73
|
+
export function toolItem(block: Block, move: (value: string) => string, cwd: string): Item {
|
|
74
|
+
const input = (block.input ?? {}) as Block;
|
|
75
|
+
const name = str(block.name);
|
|
76
|
+
const file = move(str(input.file_path) || str(input.notebook_path));
|
|
77
|
+
let detail: Record<string, unknown> = { type: "unknown" };
|
|
78
|
+
if (name === "Read" && file) detail = { type: "read", filePath: file };
|
|
79
|
+
else if ((name === "Edit" || name === "MultiEdit" || name === "NotebookEdit") && file) {
|
|
80
|
+
const edits = Array.isArray(input.edits) ? (input.edits as Block[]) : [input];
|
|
81
|
+
const added = edits.reduce((sum, edit) => sum + lines(edit.new_string ?? edit.new_source), 0);
|
|
82
|
+
const removed = edits.reduce((sum, edit) => sum + lines(edit.old_string), 0);
|
|
83
|
+
detail = { type: "edit", filePath: file, added, removed };
|
|
84
|
+
} else if (name === "Write" && file) detail = { type: "write", filePath: file, added: lines(input.content) };
|
|
85
|
+
else if (name === "Grep" || name === "Glob") detail = { type: "search", query: str(input.pattern) };
|
|
86
|
+
else if (name === "Bash") detail = { type: "shell", command: move(str(input.command)), cwd };
|
|
87
|
+
return { type: "tool_call", callId: str(block.id), name, status: "running", detail };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Tool calls in a transcript, newest last, with each finished call marked done or failed. */
|
|
91
|
+
export function transcriptItems(text: string, move: (value: string) => string, cwd: string) {
|
|
92
|
+
const items = new Map<string, Item>();
|
|
93
|
+
let finished = false;
|
|
94
|
+
for (const line of text.split("\n")) {
|
|
95
|
+
if (!line.trim()) continue;
|
|
96
|
+
let entry: Block;
|
|
97
|
+
try {
|
|
98
|
+
entry = JSON.parse(line) as Block;
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const message = (entry.message ?? {}) as Block;
|
|
103
|
+
const content = Array.isArray(message.content) ? (message.content as Block[]) : [];
|
|
104
|
+
if (entry.type === "assistant") {
|
|
105
|
+
finished = message.stop_reason === "end_turn" && !content.some((block) => block.type === "tool_use");
|
|
106
|
+
}
|
|
107
|
+
for (const block of content) {
|
|
108
|
+
if (block.type === "tool_use" && str(block.id)) items.set(str(block.id), toolItem(block, move, cwd));
|
|
109
|
+
const done = block.type === "tool_result" ? items.get(str(block.tool_use_id)) : undefined;
|
|
110
|
+
if (done) done.status = block.is_error ? "failed" : "completed";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { items: [...items.values()].slice(-MAX_ITEMS), finished };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The newest subagents of a Claude session, with their tool calls. */
|
|
117
|
+
export async function subagentLogs(sessionId: string, cwd: string): Promise<SubagentLog[]> {
|
|
118
|
+
const folder = await subagentFolder(sessionId, cwd);
|
|
119
|
+
if (!folder) return [];
|
|
120
|
+
const names = (await readdir(folder).catch(() => [])).filter((name) => /^agent-[A-Za-z0-9]+\.jsonl$/.test(name));
|
|
121
|
+
const dated = await Promise.all(
|
|
122
|
+
names.map(async (name) => ({ name, at: (await stat(path.join(folder, name)).catch(() => null))?.mtimeMs ?? 0 })),
|
|
123
|
+
);
|
|
124
|
+
const newest = dated.sort((a, b) => b.at - a.at).slice(0, MAX_RUNS);
|
|
125
|
+
const runs: SubagentLog[] = [];
|
|
126
|
+
for (const { name, at } of newest) {
|
|
127
|
+
const id = name.slice("agent-".length, -".jsonl".length);
|
|
128
|
+
const meta = JSON.parse(
|
|
129
|
+
(await readFile(path.join(folder, `agent-${id}.meta.json`), "utf8").catch(() => "{}")) || "{}",
|
|
130
|
+
) as Block;
|
|
131
|
+
const worktree = str(meta.worktreePath).replace(/\/+$/, "");
|
|
132
|
+
// A subagent in its own worktree sees the same repository, so its paths map onto the agent's.
|
|
133
|
+
const move = (value: string) => (worktree ? value.split(worktree).join(cwd) : value);
|
|
134
|
+
const { items, finished } = transcriptItems(await tail(path.join(folder, name)).catch(() => ""), move, cwd);
|
|
135
|
+
runs.push({
|
|
136
|
+
id,
|
|
137
|
+
type: str(meta.agentType) || "Subagent",
|
|
138
|
+
description: str(meta.description),
|
|
139
|
+
toolUseId: str(meta.toolUseId) || null,
|
|
140
|
+
updatedAt: Math.round(at),
|
|
141
|
+
finished,
|
|
142
|
+
items,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return runs;
|
|
146
|
+
}
|
package/server/sync.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import type { WorkflowState } from "../shared/linear";
|
|
3
|
+
import { syncNowRpc } from "../shared/live";
|
|
4
|
+
import { AGENT_LABELS, isInternalAgent } from "../shared/prompts";
|
|
5
|
+
import { type LinearSettings, projectDefaults, projectEnabled } from "../shared/settings";
|
|
6
|
+
import { planStatusMoves, progressByKey, type TodoLike } from "../shared/todo-sync";
|
|
7
|
+
import type { Paseo } from "./agent-runs";
|
|
8
|
+
import type { LinearAccess } from "./handlers";
|
|
9
|
+
import { agentInfo } from "./live";
|
|
10
|
+
|
|
11
|
+
const STATES_MAX_AGE_MS = 10 * 60_000;
|
|
12
|
+
|
|
13
|
+
export interface SyncDependencies {
|
|
14
|
+
access: LinearAccess;
|
|
15
|
+
readSettings(): Promise<LinearSettings | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SyncResult {
|
|
19
|
+
moves: { identifier: string; from: WorkflowState; to: WorkflowState }[];
|
|
20
|
+
skipped: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function latestTodos(items: readonly { type: string; items?: unknown }[]): TodoLike[] | null {
|
|
24
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
25
|
+
const item = items[index];
|
|
26
|
+
if (item?.type === "todo" && Array.isArray(item.items)) return item.items as TodoLike[];
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerSync(server: PluginServerContext, dependencies: SyncDependencies) {
|
|
32
|
+
const { access, readSettings } = dependencies;
|
|
33
|
+
const teamStates = new Map<string, { at: number; states: Map<string, WorkflowState[]> }>();
|
|
34
|
+
const queues = new Map<string, Promise<unknown>>();
|
|
35
|
+
|
|
36
|
+
async function statesFor(projectId: string | null, teamId: string) {
|
|
37
|
+
const { linear, fingerprint } = await access.connect(projectId);
|
|
38
|
+
const cached = teamStates.get(fingerprint);
|
|
39
|
+
if (cached && Date.now() - cached.at < STATES_MAX_AGE_MS && cached.states.has(teamId)) {
|
|
40
|
+
return cached.states.get(teamId) ?? [];
|
|
41
|
+
}
|
|
42
|
+
const catalog = await linear.catalog();
|
|
43
|
+
const states = new Map(catalog.teams.map((team) => [team.id, team.states]));
|
|
44
|
+
teamStates.set(fingerprint, { at: Date.now(), states });
|
|
45
|
+
return states.get(teamId) ?? [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Reads Linear fresh, not the response cache, so a manual move since the last turn counts.
|
|
49
|
+
async function syncIssue(
|
|
50
|
+
identifier: string,
|
|
51
|
+
projectId: string | null,
|
|
52
|
+
todos: readonly TodoLike[],
|
|
53
|
+
): Promise<SyncResult> {
|
|
54
|
+
const progress = progressByKey(todos);
|
|
55
|
+
if (progress.size === 0) return { moves: [], skipped: "No todo starts with an issue key." };
|
|
56
|
+
const { linear } = await access.connect(projectId);
|
|
57
|
+
const issue = await linear.getIssue(identifier);
|
|
58
|
+
const states = await statesFor(projectId, issue.team.id);
|
|
59
|
+
const moves = planStatusMoves({ parent: issue, progress, states });
|
|
60
|
+
const applied: SyncResult["moves"] = [];
|
|
61
|
+
for (const move of moves) {
|
|
62
|
+
await access.mutate(projectId, (service) =>
|
|
63
|
+
service.updateIssue(move.issueId, { stateId: move.to.id }),
|
|
64
|
+
);
|
|
65
|
+
applied.push({ identifier: move.identifier, from: move.from, to: move.to });
|
|
66
|
+
}
|
|
67
|
+
if (applied.length > 0) {
|
|
68
|
+
console.log(`Linear sync moved ${applied.map((m) => `${m.identifier} to ${m.to.name}`).join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
return { moves: applied, skipped: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One sync per issue at a time, so two turns ending together cannot move an issue twice.
|
|
74
|
+
function queued(identifier: string, run: () => Promise<SyncResult>): Promise<SyncResult> {
|
|
75
|
+
const previous = queues.get(identifier) ?? Promise.resolve();
|
|
76
|
+
const next = previous.catch(() => undefined).then(run);
|
|
77
|
+
queues.set(identifier, next);
|
|
78
|
+
void next.finally(() => {
|
|
79
|
+
if (queues.get(identifier) === next) queues.delete(identifier);
|
|
80
|
+
});
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function syncAgent(
|
|
85
|
+
paseo: Paseo,
|
|
86
|
+
agentId: string,
|
|
87
|
+
todos: readonly TodoLike[] | null,
|
|
88
|
+
automatic = false,
|
|
89
|
+
): Promise<SyncResult> {
|
|
90
|
+
const agent = await agentInfo(paseo, agentId);
|
|
91
|
+
const settings = await readSettings();
|
|
92
|
+
if (settings && !projectEnabled(settings.access, agent.projectId)) {
|
|
93
|
+
return { moves: [], skipped: "Linear is off for this project." };
|
|
94
|
+
}
|
|
95
|
+
if (automatic && !(settings && projectDefaults(settings, agent.projectId).syncTodos)) {
|
|
96
|
+
return { moves: [], skipped: "Todo sync is off for this project." };
|
|
97
|
+
}
|
|
98
|
+
const identifier = agent.labels[AGENT_LABELS.issue];
|
|
99
|
+
if (!identifier || isInternalAgent(agent.labels)) {
|
|
100
|
+
return { moves: [], skipped: "This agent is not linked to a Linear issue." };
|
|
101
|
+
}
|
|
102
|
+
if (agent.labels[AGENT_LABELS.action] !== "implement") {
|
|
103
|
+
return { moves: [], skipped: "Only implementing agents update Linear statuses." };
|
|
104
|
+
}
|
|
105
|
+
let items = todos;
|
|
106
|
+
if (!items) {
|
|
107
|
+
const page = await paseo.agents
|
|
108
|
+
.ref(agentId)
|
|
109
|
+
.timeline.refetch({ direction: "tail", limit: 400, projection: "projected" });
|
|
110
|
+
items = latestTodos(page.entries.map((entry) => entry.item));
|
|
111
|
+
}
|
|
112
|
+
if (!items) return { moves: [], skipped: "The agent has no todos yet." };
|
|
113
|
+
const projectId = agent.labels[AGENT_LABELS.project] ?? null;
|
|
114
|
+
const todoItems = items;
|
|
115
|
+
return queued(identifier, () => syncIssue(identifier, projectId, todoItems));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A project can turn sync on or off for itself, so the check needs the agent's project.
|
|
119
|
+
server.on("agent.turn_ended", async (event, { paseo }) => {
|
|
120
|
+
const settings = await readSettings();
|
|
121
|
+
const anyOn = settings?.live.syncTodos || settings?.projects.some((entry) => entry.overrides.syncTodos);
|
|
122
|
+
if (!anyOn) return;
|
|
123
|
+
const todos = latestTodos(event.timeline);
|
|
124
|
+
if (!todos) return;
|
|
125
|
+
try {
|
|
126
|
+
await syncAgent(paseo, event.agent.id, todos, true);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error("Linear sync failed", error);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
server.handle(syncNowRpc, async ({ agentId }, { paseo }) => syncAgent(paseo, agentId, null));
|
|
133
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { displayCommand, shellActivity } from "./shell-activity";
|
|
2
|
+
import { isSubagentCall, mergeSubagent, type SubagentRun, subagentOf } from "./subagents";
|
|
3
|
+
import type { TodoLike } from "./todo-sync";
|
|
4
|
+
|
|
5
|
+
// Structural view of Paseo timeline items, so shared code needs no protocol import.
|
|
6
|
+
export interface TimelineItemLike {
|
|
7
|
+
type: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type TouchKind = "read" | "edit" | "write" | "search";
|
|
12
|
+
export type EventKind = TouchKind | "list" | "shell" | "agent" | "tool";
|
|
13
|
+
export type ToolStatus = "running" | "completed" | "failed" | "canceled";
|
|
14
|
+
|
|
15
|
+
export interface FileTouch {
|
|
16
|
+
path: string;
|
|
17
|
+
reads: number;
|
|
18
|
+
edits: number;
|
|
19
|
+
added: number;
|
|
20
|
+
removed: number;
|
|
21
|
+
created: boolean;
|
|
22
|
+
/** Position of the last touch in the timeline. Higher is more recent. */
|
|
23
|
+
order: number;
|
|
24
|
+
last: TouchKind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ActivityEvent {
|
|
28
|
+
id: string;
|
|
29
|
+
kind: EventKind;
|
|
30
|
+
text: string;
|
|
31
|
+
/** The command as run, for shell events. */
|
|
32
|
+
detail: string | null;
|
|
33
|
+
path: string | null;
|
|
34
|
+
/** Every file the event read or changed; `path` is the first. */
|
|
35
|
+
paths: string[];
|
|
36
|
+
status: ToolStatus;
|
|
37
|
+
added: number;
|
|
38
|
+
removed: number;
|
|
39
|
+
exitCode: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DirTouch {
|
|
43
|
+
/** Folder with a trailing slash; "" is the repository root. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** Searched recursively, not only listed. */
|
|
46
|
+
deep: boolean;
|
|
47
|
+
order: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Activity {
|
|
51
|
+
files: FileTouch[];
|
|
52
|
+
dirs: DirTouch[];
|
|
53
|
+
/** Subagents started inside the provider, in launch order. */
|
|
54
|
+
subagents: SubagentRun[];
|
|
55
|
+
/** Newest first. */
|
|
56
|
+
events: ActivityEvent[];
|
|
57
|
+
current: ActivityEvent | null;
|
|
58
|
+
todos: TodoLike[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const MAX_EVENTS = 40;
|
|
62
|
+
// Bookkeeping tools: the todos already show what they do.
|
|
63
|
+
const HIDDEN_TOOLS = /^(Task(Create|Update|List|Get|Stop|Output)|TodoWrite|ToolSearch|advisor|task_notification)$/;
|
|
64
|
+
|
|
65
|
+
/** A repo-relative path, or null when the path is outside the agent's folder. */
|
|
66
|
+
export function relativePath(path: string, cwd: string): string | null {
|
|
67
|
+
let value = path.trim().replace(/\\/g, "/");
|
|
68
|
+
const root = cwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
69
|
+
if (root && value.startsWith(`${root}/`)) value = value.slice(root.length + 1);
|
|
70
|
+
else if (value.startsWith("/") || /^[A-Za-z]:\//.test(value)) return null;
|
|
71
|
+
value = value.replace(/^(\.\/)+/, "");
|
|
72
|
+
if (!value || value.split("/").includes("..")) return null;
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function text(value: unknown): string | null {
|
|
77
|
+
return typeof value === "string" ? value : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function lineCount(value: string | null): number {
|
|
81
|
+
if (!value) return 0;
|
|
82
|
+
return value.replace(/\n$/, "").split("\n").length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function diffCounts(detail: Record<string, unknown>): { added: number; removed: number } {
|
|
86
|
+
// Subagent transcripts send counts, not the text.
|
|
87
|
+
if (typeof detail.added === "number") {
|
|
88
|
+
return { added: detail.added, removed: typeof detail.removed === "number" ? detail.removed : 0 };
|
|
89
|
+
}
|
|
90
|
+
const diff = text(detail.unifiedDiff);
|
|
91
|
+
if (diff) {
|
|
92
|
+
let added = 0;
|
|
93
|
+
let removed = 0;
|
|
94
|
+
for (const line of diff.split("\n")) {
|
|
95
|
+
if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
|
|
96
|
+
else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
|
|
97
|
+
}
|
|
98
|
+
return { added, removed };
|
|
99
|
+
}
|
|
100
|
+
return { added: lineCount(text(detail.newString)), removed: lineCount(text(detail.oldString)) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toolStatus(value: unknown): ToolStatus {
|
|
104
|
+
return value === "running" || value === "failed" || value === "canceled" ? value : "completed";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function baseName(path: string): string {
|
|
108
|
+
return path.split("/").pop() ?? path;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Tool calls stream as repeated items with one callId. The last copy wins, in first position.
|
|
112
|
+
function latestToolCalls(items: readonly TimelineItemLike[]) {
|
|
113
|
+
const order: string[] = [];
|
|
114
|
+
const byId = new Map<string, TimelineItemLike>();
|
|
115
|
+
items.forEach((item, index) => {
|
|
116
|
+
if (item.type !== "tool_call") return;
|
|
117
|
+
const id = text(item.callId) ?? `index-${index}`;
|
|
118
|
+
if (!byId.has(id)) order.push(id);
|
|
119
|
+
byId.set(id, item);
|
|
120
|
+
});
|
|
121
|
+
return order.map((id) => ({ id, item: byId.get(id) as TimelineItemLike }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function deriveActivity(items: readonly TimelineItemLike[], cwd: string): Activity {
|
|
125
|
+
const files = new Map<string, FileTouch>();
|
|
126
|
+
const dirs = new Map<string, DirTouch>();
|
|
127
|
+
const events: ActivityEvent[] = [];
|
|
128
|
+
const subagents = new Map<string, SubagentRun>();
|
|
129
|
+
const subagentEvents = new Map<string, ActivityEvent>();
|
|
130
|
+
const resolve = (path: string) => relativePath(path, cwd);
|
|
131
|
+
let todos: TodoLike[] = [];
|
|
132
|
+
for (const item of items) {
|
|
133
|
+
if (item.type === "todo" && Array.isArray(item.items)) todos = item.items as TodoLike[];
|
|
134
|
+
}
|
|
135
|
+
const touch = (path: string, kind: TouchKind, order: number) => {
|
|
136
|
+
const entry = files.get(path) ?? {
|
|
137
|
+
path,
|
|
138
|
+
reads: 0,
|
|
139
|
+
edits: 0,
|
|
140
|
+
added: 0,
|
|
141
|
+
removed: 0,
|
|
142
|
+
created: false,
|
|
143
|
+
order,
|
|
144
|
+
last: kind,
|
|
145
|
+
};
|
|
146
|
+
entry.order = order;
|
|
147
|
+
entry.last = kind;
|
|
148
|
+
files.set(path, entry);
|
|
149
|
+
return entry;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
latestToolCalls(items).forEach(({ id, item }, order) => {
|
|
153
|
+
if (HIDDEN_TOOLS.test(text(item.name) ?? "")) return;
|
|
154
|
+
const signal = subagentOf(item, resolve);
|
|
155
|
+
if (!signal && isSubagentCall(item)) return;
|
|
156
|
+
if (signal) {
|
|
157
|
+
// The launch call and the subagent item are one run, so they make one feed row.
|
|
158
|
+
const run = mergeSubagent(subagents, signal, order);
|
|
159
|
+
const known = subagentEvents.get(run.key);
|
|
160
|
+
const row: ActivityEvent = known ?? {
|
|
161
|
+
id,
|
|
162
|
+
kind: "agent",
|
|
163
|
+
text: run.description ? `${run.type}: ${run.description}` : run.type,
|
|
164
|
+
detail: null,
|
|
165
|
+
path: null,
|
|
166
|
+
paths: [],
|
|
167
|
+
status: run.status,
|
|
168
|
+
added: 0,
|
|
169
|
+
removed: 0,
|
|
170
|
+
exitCode: null,
|
|
171
|
+
};
|
|
172
|
+
row.status = run.status;
|
|
173
|
+
row.paths = run.targets;
|
|
174
|
+
row.path = run.targets[0] ?? null;
|
|
175
|
+
if (!known) {
|
|
176
|
+
subagentEvents.set(run.key, row);
|
|
177
|
+
events.push(row);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const detail = (item.detail ?? {}) as Record<string, unknown>;
|
|
182
|
+
const status = toolStatus(item.status);
|
|
183
|
+
const event: ActivityEvent = {
|
|
184
|
+
id,
|
|
185
|
+
kind: "tool",
|
|
186
|
+
text: text(item.name) ?? "Tool call",
|
|
187
|
+
detail: null,
|
|
188
|
+
path: null,
|
|
189
|
+
paths: [],
|
|
190
|
+
status,
|
|
191
|
+
added: 0,
|
|
192
|
+
removed: 0,
|
|
193
|
+
exitCode: null,
|
|
194
|
+
};
|
|
195
|
+
const raw = text(detail.filePath);
|
|
196
|
+
const path = raw ? relativePath(raw, cwd) : null;
|
|
197
|
+
if (path) event.paths = [path];
|
|
198
|
+
if (detail.type === "read" && raw) {
|
|
199
|
+
event.kind = "read";
|
|
200
|
+
event.path = path;
|
|
201
|
+
event.text = `Read ${path ?? baseName(raw)}`;
|
|
202
|
+
if (path) touch(path, "read", order).reads += 1;
|
|
203
|
+
} else if ((detail.type === "edit" || detail.type === "write") && raw) {
|
|
204
|
+
const write = detail.type === "write";
|
|
205
|
+
const counts = write && typeof detail.added !== "number"
|
|
206
|
+
? { added: lineCount(text(detail.content)), removed: 0 }
|
|
207
|
+
: diffCounts(detail);
|
|
208
|
+
event.kind = write ? "write" : "edit";
|
|
209
|
+
event.path = path;
|
|
210
|
+
event.added = counts.added;
|
|
211
|
+
event.removed = counts.removed;
|
|
212
|
+
event.text = `${write ? "Wrote" : "Edited"} ${path ?? baseName(raw)}`;
|
|
213
|
+
if (path && status !== "failed") {
|
|
214
|
+
const entry = touch(path, event.kind, order);
|
|
215
|
+
entry.edits += 1;
|
|
216
|
+
entry.added += counts.added;
|
|
217
|
+
entry.removed += counts.removed;
|
|
218
|
+
if (write && entry.reads === 0 && entry.edits === 1) entry.created = true;
|
|
219
|
+
}
|
|
220
|
+
} else if (detail.type === "search") {
|
|
221
|
+
event.kind = "search";
|
|
222
|
+
event.text = `Searched ${JSON.stringify(text(detail.query) ?? "")}`;
|
|
223
|
+
const hits = Array.isArray(detail.filePaths) ? detail.filePaths : [];
|
|
224
|
+
for (const hit of hits) {
|
|
225
|
+
const hitPath = typeof hit === "string" ? relativePath(hit, cwd) : null;
|
|
226
|
+
if (hitPath && !files.has(hitPath)) touch(hitPath, "search", order);
|
|
227
|
+
}
|
|
228
|
+
} else if (detail.type === "shell") {
|
|
229
|
+
const command = text(detail.command) ?? "";
|
|
230
|
+
const start = relativePath(text(detail.cwd) ?? "", cwd) ?? "";
|
|
231
|
+
const shell = shellActivity(command, cwd, start);
|
|
232
|
+
event.exitCode = typeof detail.exitCode === "number" ? detail.exitCode : null;
|
|
233
|
+
event.detail = displayCommand(command, cwd);
|
|
234
|
+
event.text = shell.summary;
|
|
235
|
+
event.paths = [...shell.writes.map((write) => write.path), ...shell.reads];
|
|
236
|
+
event.path = event.paths[0] ?? null;
|
|
237
|
+
event.kind = "shell";
|
|
238
|
+
if (shell.writes.length > 0) event.kind = "write";
|
|
239
|
+
else if (shell.reads.length > 0) event.kind = "read";
|
|
240
|
+
else if (shell.pattern !== null) event.kind = "search";
|
|
241
|
+
else if (shell.dirs.length > 0) event.kind = "list";
|
|
242
|
+
for (const read of shell.reads) touch(read, "read", order).reads += 1;
|
|
243
|
+
// A failed write may not have happened; reads count either way, as grep exits 1 on no match.
|
|
244
|
+
const succeeded = status === "completed" && (event.exitCode ?? 0) === 0;
|
|
245
|
+
for (const write of succeeded ? shell.writes : []) {
|
|
246
|
+
const entry = touch(write.path, write.mode, order);
|
|
247
|
+
entry.edits += 1;
|
|
248
|
+
if (write.mode === "write" && entry.reads === 0 && entry.edits === 1) entry.created = true;
|
|
249
|
+
}
|
|
250
|
+
for (const dir of shell.dirs) {
|
|
251
|
+
const known = dirs.get(dir.path);
|
|
252
|
+
dirs.set(dir.path, { path: dir.path, deep: dir.deep || (known?.deep ?? false), order });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
events.push(event);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const recent = events.slice(-MAX_EVENTS).reverse();
|
|
259
|
+
const current = recent.find((event) => event.status === "running") ?? recent[0] ?? null;
|
|
260
|
+
return {
|
|
261
|
+
files: [...files.values()].sort((a, b) => b.order - a.order),
|
|
262
|
+
dirs: [...dirs.values()],
|
|
263
|
+
subagents: [...subagents.values()],
|
|
264
|
+
events: recent,
|
|
265
|
+
current,
|
|
266
|
+
todos,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { GuidanceSchema } from "./settings";
|
|
4
|
+
|
|
5
|
+
// Claude Code hooks for an agent started from an issue. The daemon writes them as a Claude Code
|
|
6
|
+
// plugin and the launch passes its folder with --plugin-dir, so they never replace the settings
|
|
7
|
+
// Paseo itself passes with --settings. Each hook only prints text; none of them runs project code.
|
|
8
|
+
|
|
9
|
+
export const IDENTIFIER = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
10
|
+
|
|
11
|
+
const HookIssueSchema = z.object({
|
|
12
|
+
identifier: z.string().regex(IDENTIFIER),
|
|
13
|
+
title: z.string(),
|
|
14
|
+
children: z.array(z.object({ identifier: z.string().regex(IDENTIFIER), title: z.string() })).max(100),
|
|
15
|
+
});
|
|
16
|
+
export type HookIssue = z.infer<typeof HookIssueSchema>;
|
|
17
|
+
|
|
18
|
+
export const agentHooksRpc = defineRpc({
|
|
19
|
+
name: "linear.agent.hooks",
|
|
20
|
+
input: z.object({ issue: HookIssueSchema, guidance: GuidanceSchema }),
|
|
21
|
+
/** Null when no hook applies or the daemon host cannot run them. */
|
|
22
|
+
output: z.object({ pluginDir: z.string().nullable() }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Single-quotes text for a POSIX shell. */
|
|
26
|
+
export function shellQuote(text: string): string {
|
|
27
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printHook(event: string, context: string) {
|
|
31
|
+
const output = { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
|
|
32
|
+
return { type: "command", command: `printf '%s' ${shellQuote(JSON.stringify(output))}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function issueLines(issue: HookIssue): string[] {
|
|
36
|
+
const lines = [`Linear issue ${issue.identifier}: ${issue.title}`];
|
|
37
|
+
for (const child of issue.children.slice(0, 30)) lines.push(`- ${child.identifier}: ${child.title}`);
|
|
38
|
+
return lines;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The plugin's hooks.json, or null when no guidance option needs a hook. */
|
|
42
|
+
export function claudeHooks(issue: HookIssue, guidance: z.infer<typeof GuidanceSchema>) {
|
|
43
|
+
const hooks: Record<string, unknown[]> = {};
|
|
44
|
+
if (guidance.subagentKeys) {
|
|
45
|
+
const context = [
|
|
46
|
+
`You are a subagent for ${issue.identifier}.`,
|
|
47
|
+
...issueLines(issue),
|
|
48
|
+
"Put the key of the issue you work on in the first line of your reply, such as",
|
|
49
|
+
`"${issue.children[0]?.identifier ?? issue.identifier}: checked the form".`,
|
|
50
|
+
"If your work moves to another issue, state the new key.",
|
|
51
|
+
].join("\n");
|
|
52
|
+
hooks.SubagentStart = [{ hooks: [printHook("SubagentStart", context)] }];
|
|
53
|
+
}
|
|
54
|
+
if (guidance.updateLinear || guidance.subagentKeys || guidance.paseoSubagents) {
|
|
55
|
+
// After compaction the summary can drop the issue, so restate it.
|
|
56
|
+
const context = [
|
|
57
|
+
`You work on ${issue.identifier}. The Linear rules in your first prompt still apply.`,
|
|
58
|
+
...issueLines(issue),
|
|
59
|
+
].join("\n");
|
|
60
|
+
hooks.SessionStart = [{ matcher: "compact", hooks: [printHook("SessionStart", context)] }];
|
|
61
|
+
}
|
|
62
|
+
return Object.keys(hooks).length > 0 ? { hooks } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Claude Code plugin names are lowercase words joined by hyphens. */
|
|
66
|
+
export function hookPluginName(identifier: string): string {
|
|
67
|
+
return `paseo-linear-${identifier.toLowerCase()}`;
|
|
68
|
+
}
|