@yuangc/paseo-plugin-workspace-overview 0.0.0-stage → 1.0.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 (68) hide show
  1. package/client/agent-preview/PreviewActions.tsx +56 -0
  2. package/client/agent-preview/useAutoCommit.ts +91 -0
  3. package/client/components/AgentCommandMenu.tsx +51 -0
  4. package/client/components/AgentConversationPreview.tsx +620 -0
  5. package/client/components/AgentConversationTimeline.tsx +177 -0
  6. package/client/components/AgentCreatorPanel.tsx +152 -0
  7. package/client/components/AgentQuestionCard.tsx +244 -0
  8. package/client/components/AgentRow.tsx +108 -0
  9. package/client/components/BadgeWithTooltip.tsx +119 -0
  10. package/client/components/BranchRow.tsx +121 -0
  11. package/client/components/ControlGlyph.tsx +18 -0
  12. package/client/components/HeatmapCalendar.tsx +165 -0
  13. package/client/components/ModelBrowserMenu.tsx +135 -0
  14. package/client/components/PaseoAssistantMessage.tsx +216 -0
  15. package/client/components/PaseoShellDetail.tsx +128 -0
  16. package/client/components/PaseoStreamBadge.tsx +94 -0
  17. package/client/components/PaseoTimelineItem.tsx +147 -0
  18. package/client/components/PaseoToolCallGroup.tsx +52 -0
  19. package/client/components/PaseoToolDetail.tsx +55 -0
  20. package/client/components/ProjectCard.tsx +196 -0
  21. package/client/components/ProjectIcon.tsx +49 -0
  22. package/client/components/ProviderBrandIcon.tsx +52 -0
  23. package/client/components/ResizeHandle.tsx +91 -0
  24. package/client/components/SharedComposerInput.tsx +383 -0
  25. package/client/components/Tooltip.tsx +49 -0
  26. package/client/components/WorkspaceAgentRow.tsx +93 -0
  27. package/client/components/WorkspaceBadge.tsx +39 -0
  28. package/client/components/WorkspaceCreateComposer.tsx +170 -0
  29. package/client/components/WorkspaceCreatorPanel.tsx +256 -0
  30. package/client/components/WorkspaceLaunchMenu.tsx +76 -0
  31. package/client/components/WorkspaceMenu.tsx +116 -0
  32. package/client/components/WorkspaceSelectControl.tsx +52 -0
  33. package/client/components/agent-conversation-types.ts +7 -0
  34. package/client/components/workspace-creator-styles.ts +322 -0
  35. package/client/hooks/useAgentActivityHeatmap.ts +178 -0
  36. package/client/hooks/useFilter.ts +107 -0
  37. package/client/hooks/useKeyboardInset.ts +37 -0
  38. package/client/hooks/useResizableWidth.ts +70 -0
  39. package/client/hooks/useWorkspaces.ts +128 -0
  40. package/client/overview/useProjectOptions.ts +73 -0
  41. package/client/web.ts +160 -0
  42. package/client/workspace-agents.tsx +71 -0
  43. package/client/workspace-creator/badge-styles.ts +13 -0
  44. package/client/workspace-creator/constants.ts +14 -0
  45. package/client/workspace-creator/control-styles.ts +12 -0
  46. package/client/workspace-creator/provider-selection.ts +51 -0
  47. package/client/workspace-creator/submit-workspace.ts +67 -0
  48. package/client/workspace-creator/types.ts +40 -0
  49. package/client/workspace-creator/ui-utils.ts +15 -0
  50. package/client/workspace-creator/useProviderCatalog.ts +89 -0
  51. package/client/workspace-overview.tsx +533 -0
  52. package/index.client.tsx +36 -0
  53. package/index.server.ts +33 -0
  54. package/package.json +28 -4
  55. package/paseo-plugin.json +4 -0
  56. package/server/agent-config-impl.ts +57 -0
  57. package/server/auto-commit-impl.ts +221 -0
  58. package/server/git-branches-impl.ts +81 -0
  59. package/server/project-icon-impl.ts +102 -0
  60. package/server/terminal-launch-impl.ts +56 -0
  61. package/shared/agent-activity.ts +16 -0
  62. package/shared/agent-config.ts +30 -0
  63. package/shared/auto-commit.ts +38 -0
  64. package/shared/git-branches.ts +18 -0
  65. package/shared/overview-types.ts +87 -0
  66. package/shared/project-icon.ts +13 -0
  67. package/shared/terminal-launch.ts +20 -0
  68. package/README.md +0 -3
@@ -0,0 +1,33 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { projectIconRpc } from "./shared/project-icon";
3
+ import { readPaseoProjectIcon } from "./server/project-icon-impl";
4
+ import { gitBranchesRpc } from "./shared/git-branches";
5
+ import { readGitBranches } from "./server/git-branches-impl";
6
+ import { terminalLaunchRpc } from "./shared/terminal-launch";
7
+ import { launchWorkspaceTerminal } from "./server/terminal-launch-impl";
8
+ import { autoCommitInfoRpc, autoCommitStartRpc, autoCommitStatusRpc } from "./shared/auto-commit";
9
+ import { readAutoCommitInfo, readAutoCommitTaskStatus, startAutoCommitTask } from "./server/auto-commit-impl";
10
+ import { agentCancelRpc, agentConfigSetRpc, agentRewindRpc, pluginReloadRpc } from "./shared/agent-config";
11
+ import { cancelAgent, closeAgentConfigClient, rewindAgent, setAgentConfig, reloadPlugin } from "./server/agent-config-impl";
12
+
13
+ export default function contribute(server: PluginServerContext) {
14
+ server.handle(projectIconRpc, async ({ projectId, projectDirectory }) => ({
15
+ dataUri: await readPaseoProjectIcon(projectId, projectDirectory),
16
+ }));
17
+
18
+ server.handle(gitBranchesRpc, async ({ projectDirectory }) =>
19
+ readGitBranches(projectDirectory),
20
+ );
21
+
22
+ server.handle(terminalLaunchRpc, launchWorkspaceTerminal);
23
+
24
+ server.handle(autoCommitInfoRpc, readAutoCommitInfo);
25
+ server.handle(autoCommitStartRpc, startAutoCommitTask);
26
+ server.handle(autoCommitStatusRpc, ({ taskId }) => readAutoCommitTaskStatus(taskId));
27
+ server.handle(agentConfigSetRpc, setAgentConfig);
28
+ server.handle(agentCancelRpc, cancelAgent);
29
+ server.handle(agentRewindRpc, rewindAgent);
30
+ server.handle(pluginReloadRpc, reloadPlugin);
31
+
32
+ return () => closeAgentConfigClient();
33
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@yuangc/paseo-plugin-workspace-overview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "scripts": {
5
+ "typecheck": "tsc --noEmit"
6
+ },
7
+ "devDependencies": {
8
+ "@getpaseo/client": "0.8.0",
9
+ "@getpaseo/plugin": "0.8.0",
10
+ "@getpaseo/protocol": "0.8.0",
11
+ "@tanstack/react-query": "^5.90.11",
12
+ "@types/react": "~19.2.0",
13
+ "react": "19.1.0",
14
+ "react-native": "0.81.5",
15
+ "typescript": "^5.9.3",
16
+ "zod": "^4.4.3"
17
+ },
18
+ "description": "Paseo plugin: workspace overview, agent previews, heatmap, auto-commit, terminal launch",
19
+ "files": [
20
+ "paseo-plugin.json",
21
+ "index.client.tsx",
22
+ "index.server.ts",
23
+ "client/",
24
+ "server/",
25
+ "shared/"
26
+ ],
27
+ "repository": {
28
+ "url": "git+https://github.com/ygcedu/paseo-plugin-workspace-overview.git"
29
+ }
30
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "id": "workspace-overview",
3
+ "requirements": { "paseo": ">=0.8.0" }
4
+ }
@@ -0,0 +1,57 @@
1
+ import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
2
+ import type { RpcInput } from "@getpaseo/plugin";
3
+ import { agentCancelRpc, agentConfigSetRpc, agentRewindRpc, pluginReloadRpc } from "../shared/agent-config";
4
+
5
+ let clientPromise: Promise<DaemonClient> | null = null;
6
+
7
+ async function getClient(): Promise<DaemonClient> {
8
+ if (!clientPromise) {
9
+ clientPromise = (async () => {
10
+ const client = new DaemonClient({
11
+ url: "ws://127.0.0.1:6767/ws",
12
+ clientId: `workspace-overview-${process.pid}`,
13
+ clientType: "cli",
14
+ appVersion: "0.8.0",
15
+ reconnect: { enabled: false },
16
+ });
17
+ await client.connect();
18
+ return client;
19
+ })().catch((error) => {
20
+ clientPromise = null;
21
+ throw error;
22
+ });
23
+ }
24
+ return clientPromise;
25
+ }
26
+
27
+ export async function setAgentConfig({ agentId, field, value }: RpcInput<typeof agentConfigSetRpc>) {
28
+ const client = await getClient();
29
+ if (field === "model") await client.setAgentModel(agentId, value);
30
+ else if (field === "mode") await client.setAgentMode(agentId, value);
31
+ else await client.setAgentThinkingOption(agentId, value);
32
+ return { ok: true as const };
33
+ }
34
+
35
+ export async function cancelAgent({ agentId }: RpcInput<typeof agentCancelRpc>) {
36
+ const client = await getClient();
37
+ await client.cancelAgent(agentId);
38
+ return { ok: true as const };
39
+ }
40
+
41
+ export async function rewindAgent({ agentId, messageId, mode }: RpcInput<typeof agentRewindRpc>) {
42
+ const client = await getClient();
43
+ await client.rewindAgent(agentId, messageId, mode);
44
+ return { ok: true as const };
45
+ }
46
+
47
+ export async function reloadPlugin({ pluginId }: RpcInput<typeof pluginReloadRpc>) {
48
+ const client = await getClient();
49
+ await client.reloadPlugin(pluginId);
50
+ return { ok: true as const };
51
+ }
52
+
53
+ export async function closeAgentConfigClient() {
54
+ const pending = clientPromise;
55
+ clientPromise = null;
56
+ if (pending) await (await pending).close();
57
+ }
@@ -0,0 +1,221 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { realpath } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join, parse } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { randomUUID } from "node:crypto";
7
+ import { promisify } from "node:util";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const PLUGIN_ID = "workspace-overview";
11
+ const PI_MODEL = "9router/coding";
12
+
13
+ interface AutoCommitTask {
14
+ taskId: string;
15
+ cwd: string;
16
+ status: "running" | "done" | "error";
17
+ output: string;
18
+ error: string | null;
19
+ exitCode: number | null;
20
+ startedAt: string;
21
+ finishedAt: string | null;
22
+ cleanupScheduled: boolean;
23
+ }
24
+
25
+ const tasks = new Map<string, AutoCommitTask>();
26
+ const startingDirectories = new Set<string>();
27
+
28
+ function getModuleDirectory(): string | null {
29
+ const metaUrl = (import.meta as { url?: string }).url;
30
+ if (typeof metaUrl === "string" && metaUrl.length > 0) {
31
+ return dirname(fileURLToPath(metaUrl));
32
+ }
33
+
34
+ const stack = new Error().stack ?? "";
35
+ const match = stack.match(/\(?((?:\/[^():]+)+\/auto-commit-impl\.server\.[tj]s)/);
36
+ return match ? dirname(match[1]) : null;
37
+ }
38
+
39
+ function isPluginGitDirectory(directory: string): boolean {
40
+ return existsSync(join(directory, "paseo-plugin.json")) && existsSync(join(directory, ".git"));
41
+ }
42
+
43
+ function findPluginGitDirectoryFromCandidates(): string | null {
44
+ const moduleDirectory = getModuleDirectory();
45
+ const candidates = [moduleDirectory, process.cwd()].filter((value): value is string => Boolean(value));
46
+ for (const candidate of candidates) {
47
+ let directory = candidate;
48
+ const root = parse(directory).root;
49
+ while (directory && directory !== root) {
50
+ if (isPluginGitDirectory(directory)) {
51
+ return directory;
52
+ }
53
+ directory = dirname(directory);
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ async function findPluginGitDirectoryFromPaseoCli(): Promise<string | null> {
60
+ try {
61
+ const { stdout } = await execFileAsync("paseo", ["plugin", "ls"], {
62
+ encoding: "utf8",
63
+ maxBuffer: 1024 * 1024,
64
+ });
65
+ for (const line of stdout.split("\n")) {
66
+ if (!line.trim().startsWith(PLUGIN_ID)) continue;
67
+ const columns = line.trim().split(/\s{2,}/);
68
+ const directory = columns.find((value) => value.startsWith("/"));
69
+ if (directory && isPluginGitDirectory(directory)) return directory;
70
+ }
71
+ } catch {
72
+ return null;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ async function findPluginGitDirectory(): Promise<string> {
78
+ const directory =
79
+ findPluginGitDirectoryFromCandidates() ??
80
+ (await findPluginGitDirectoryFromPaseoCli());
81
+ if (!directory) {
82
+ throw new Error(`找不到 ${PLUGIN_ID} 插件的 git 目录`);
83
+ }
84
+ return directory;
85
+ }
86
+
87
+ async function runGit(projectDirectory: string, args: string[]): Promise<string> {
88
+ const { stdout } = await execFileAsync("git", ["-C", projectDirectory, ...args], {
89
+ encoding: "utf8",
90
+ maxBuffer: 1024 * 1024,
91
+ });
92
+ return stdout.trim();
93
+ }
94
+
95
+ async function tryGit(projectDirectory: string, args: string[]): Promise<string | null> {
96
+ try {
97
+ return await runGit(projectDirectory, args);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export async function readAutoCommitInfo(): Promise<{
104
+ cwd: string;
105
+ currentBranch: string | null;
106
+ hasChanges: boolean;
107
+ status: string;
108
+ recentMessages: string[];
109
+ }> {
110
+ const projectDirectory = await findPluginGitDirectory();
111
+ return readAutoCommitInfoForDirectory(projectDirectory);
112
+ }
113
+
114
+ async function readAutoCommitInfoForDirectory(projectDirectory: string): Promise<{
115
+ cwd: string;
116
+ currentBranch: string | null;
117
+ hasChanges: boolean;
118
+ status: string;
119
+ recentMessages: string[];
120
+ }> {
121
+ const [status, currentBranch, log] = await Promise.all([
122
+ runGit(projectDirectory, ["status", "--short"]),
123
+ tryGit(projectDirectory, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
124
+ tryGit(projectDirectory, ["log", "--format=%s", "-8"]),
125
+ ]);
126
+
127
+ return {
128
+ cwd: projectDirectory,
129
+ currentBranch,
130
+ hasChanges: status.length > 0,
131
+ status,
132
+ recentMessages: (log ?? "").split("\n").filter(Boolean),
133
+ };
134
+ }
135
+
136
+ function buildAutoCommitPrompt() {
137
+ return [
138
+ "请帮我提交当前仓库里的待提交改动。",
139
+ "",
140
+ "你自己判断怎么提交:可以是一个 commit,也可以根据改动内容拆成多个 commit。",
141
+ "",
142
+ "请先用 git 查看最近几条 commit message,参考这个仓库已有的 message 风格。",
143
+ "然后自己阅读当前所有待提交代码,包括已修改文件和还没被 git 管理的新文件,理解改动内容后再提交。",
144
+ "",
145
+ "不要 push,也不要丢弃用户改动。",
146
+ ].join("\n");
147
+ }
148
+
149
+ function appendTaskOutput(task: AutoCommitTask, chunk: Buffer | string) {
150
+ task.output = `${task.output}${chunk.toString()}`.slice(-20_000);
151
+ }
152
+
153
+ function scheduleTaskCleanup(taskId: string) {
154
+ const timer = setTimeout(() => tasks.delete(taskId), 10 * 60_000);
155
+ timer.unref();
156
+ }
157
+
158
+ export async function startAutoCommitTask({ cwd }: { cwd: string }): Promise<{ taskId: string; cwd: string }> {
159
+ const directory = await realpath(cwd);
160
+ const running = [...tasks.values()].find((task) => task.cwd === directory && task.status === "running");
161
+ if (running) return { taskId: running.taskId, cwd: running.cwd };
162
+ if (startingDirectories.has(directory)) throw new Error("当前仓库正在启动提交任务,请稍后重试");
163
+ startingDirectories.add(directory);
164
+ try {
165
+ const info = await readAutoCommitInfoForDirectory(directory);
166
+ if (!info.hasChanges) {
167
+ throw new Error("没有待提交改动");
168
+ }
169
+
170
+ const task: AutoCommitTask = {
171
+ taskId: randomUUID(),
172
+ cwd: info.cwd,
173
+ status: "running",
174
+ output: "",
175
+ error: null,
176
+ exitCode: null,
177
+ startedAt: new Date().toISOString(),
178
+ finishedAt: null,
179
+ cleanupScheduled: false,
180
+ };
181
+ tasks.set(task.taskId, task);
182
+
183
+ const child = spawn("pi", ["--model", PI_MODEL, "--approve", "-p", buildAutoCommitPrompt()], {
184
+ cwd: info.cwd,
185
+ env: process.env,
186
+ stdio: ["ignore", "pipe", "pipe"],
187
+ });
188
+
189
+ child.stdout.on("data", (chunk) => appendTaskOutput(task, chunk));
190
+ child.stderr.on("data", (chunk) => appendTaskOutput(task, chunk));
191
+ child.on("error", (error) => {
192
+ task.status = "error";
193
+ task.error = error.message;
194
+ task.finishedAt = new Date().toISOString();
195
+ if (!task.cleanupScheduled) {
196
+ task.cleanupScheduled = true;
197
+ scheduleTaskCleanup(task.taskId);
198
+ }
199
+ });
200
+ child.on("close", (code) => {
201
+ task.exitCode = code;
202
+ task.status = code === 0 ? "done" : "error";
203
+ task.error = code === 0 ? null : task.error ?? `pi 进程退出,exit code ${code}`;
204
+ task.finishedAt = new Date().toISOString();
205
+ if (!task.cleanupScheduled) {
206
+ task.cleanupScheduled = true;
207
+ scheduleTaskCleanup(task.taskId);
208
+ }
209
+ });
210
+
211
+ return { taskId: task.taskId, cwd: task.cwd };
212
+ } finally {
213
+ startingDirectories.delete(directory);
214
+ }
215
+ }
216
+
217
+ export function readAutoCommitTaskStatus(taskId: string): AutoCommitTask {
218
+ const task = tasks.get(taskId);
219
+ if (!task) throw new Error(`找不到一键提交任务: ${taskId}`);
220
+ return task;
221
+ }
@@ -0,0 +1,81 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ interface GitBranchItem {
7
+ id: string;
8
+ label: string;
9
+ detail: string;
10
+ }
11
+
12
+ async function runGit(projectDirectory: string, args: string[]): Promise<string | null> {
13
+ try {
14
+ const { stdout } = await execFileAsync("git", ["-C", projectDirectory, ...args], {
15
+ encoding: "utf8",
16
+ maxBuffer: 1024 * 1024,
17
+ });
18
+ return stdout.trim();
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export async function readGitBranches(projectDirectory: string): Promise<{
25
+ currentBranch: string | null;
26
+ defaultBranch: string | null;
27
+ branches: GitBranchItem[];
28
+ }> {
29
+ const [refsOutput, currentBranchOutput, originHeadOutput] = await Promise.all([
30
+ runGit(projectDirectory, [
31
+ "for-each-ref",
32
+ "--format=%(refname)%09%(refname:short)",
33
+ "refs/heads",
34
+ "refs/remotes",
35
+ ]),
36
+ runGit(projectDirectory, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
37
+ runGit(projectDirectory, [
38
+ "symbolic-ref",
39
+ "--quiet",
40
+ "--short",
41
+ "refs/remotes/origin/HEAD",
42
+ ]),
43
+ ]);
44
+
45
+ const branches = (refsOutput ?? "")
46
+ .split("\n")
47
+ .map((line) => line.split("\t"))
48
+ .filter(([refName, shortName]) =>
49
+ Boolean(refName && shortName && !refName.endsWith("/HEAD")),
50
+ )
51
+ .map(([refName, shortName]) => ({
52
+ id: shortName,
53
+ label: shortName,
54
+ detail: refName.startsWith("refs/heads/") ? "本地分支" : "远程分支",
55
+ }));
56
+
57
+ const localBranchIds = new Set(
58
+ branches.filter((branch) => branch.detail === "本地分支").map((branch) => branch.id),
59
+ );
60
+ const remoteDefault = originHeadOutput?.replace(/^origin\//, "") ?? null;
61
+ const defaultBranch =
62
+ currentBranchOutput ??
63
+ (remoteDefault && localBranchIds.has(remoteDefault) ? remoteDefault : originHeadOutput) ??
64
+ (localBranchIds.has("main") ? "main" : null) ??
65
+ (localBranchIds.has("master") ? "master" : null) ??
66
+ branches[0]?.id ??
67
+ null;
68
+
69
+ branches.sort((left, right) => {
70
+ if (left.id === currentBranchOutput) return -1;
71
+ if (right.id === currentBranchOutput) return 1;
72
+ if (left.detail !== right.detail) return left.detail === "本地分支" ? -1 : 1;
73
+ return left.label.localeCompare(right.label);
74
+ });
75
+
76
+ return {
77
+ currentBranch: currentBranchOutput,
78
+ defaultBranch,
79
+ branches,
80
+ };
81
+ }
@@ -0,0 +1,102 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ const ICON_NAMES = [
7
+ "favicon.svg",
8
+ "favicon.png",
9
+ "favico.svg",
10
+ "favico.png",
11
+ "icon.svg",
12
+ "icon.png",
13
+ "app-icon.svg",
14
+ "app-icon.png",
15
+ "apple-touch-icon.png",
16
+ "logo.svg",
17
+ "logo.png",
18
+ "favicon.ico",
19
+ ];
20
+
21
+ const ICON_DIRECTORIES = ["public", "static", "priv/static", "assets", "images", "img", ""];
22
+ const IGNORED_DIRECTORIES = new Set([".git", "node_modules", "dist", "build", ".next", ".nuxt", ".output", "coverage", ".cache", "vendor"]);
23
+
24
+ function mimeType(bytes: Buffer, filePath: string): string | null {
25
+ if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
26
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
27
+ if (bytes.toString("ascii", 0, 6) === "GIF87a" || bytes.toString("ascii", 0, 6) === "GIF89a") return "image/gif";
28
+ if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "image/webp";
29
+ if (bytes.toString("utf8", 0, 256).includes("<svg") || path.extname(filePath).toLowerCase() === ".svg") return "image/svg+xml";
30
+ if (path.extname(filePath).toLowerCase() === ".ico") return "image/x-icon";
31
+ return null;
32
+ }
33
+
34
+ async function readIcon(filePath: string): Promise<string | null> {
35
+ try {
36
+ const bytes = await readFile(filePath);
37
+ const mime = mimeType(bytes, filePath);
38
+ return mime ? `data:${mime};base64,${bytes.toString("base64")}` : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function iconNameMatches(name: string): boolean {
45
+ return ICON_NAMES.includes(name.toLowerCase()) || /^(favicon-|apple-touch-icon-|icon-|android-chrome-|mstile-).+\.(svg|png)$/i.test(name);
46
+ }
47
+
48
+ async function findIconInDirectory(directory: string): Promise<string | null> {
49
+ try {
50
+ const entries = await readdir(directory);
51
+ const ordered = [
52
+ ...ICON_NAMES.flatMap((name) => entries.filter((entry) => entry.toLowerCase() === name)),
53
+ ...entries.filter(iconNameMatches),
54
+ ];
55
+ for (const entry of [...new Set(ordered)]) {
56
+ const icon = await readIcon(path.join(directory, entry));
57
+ if (icon) return icon;
58
+ }
59
+ } catch {
60
+ // Missing optional directory.
61
+ }
62
+ return null;
63
+ }
64
+
65
+ async function findIconRecursively(directory: string, depth: number): Promise<string | null> {
66
+ const direct = await findIconInDirectory(directory);
67
+ if (direct || depth <= 0) return direct;
68
+ try {
69
+ const entries = await readdir(directory);
70
+ for (const entry of entries) {
71
+ if (IGNORED_DIRECTORIES.has(entry)) continue;
72
+ const child = path.join(directory, entry);
73
+ try {
74
+ if (!(await stat(child)).isDirectory()) continue;
75
+ } catch {
76
+ continue;
77
+ }
78
+ const icon = await findIconRecursively(child, depth - 1);
79
+ if (icon) return icon;
80
+ }
81
+ } catch {
82
+ // Unreadable directory.
83
+ }
84
+ return null;
85
+ }
86
+
87
+ export async function readPaseoProjectIcon(projectId: string, projectDirectory: string): Promise<string | null> {
88
+ const paseoHome = process.env.PASEO_HOME?.trim() || path.join(homedir(), ".paseo");
89
+ const key = createHash("sha256").update(projectId).digest("hex");
90
+ const custom = await readIcon(path.join(paseoHome, "projects", "icons", `${key}.bin`));
91
+ if (custom) return custom;
92
+
93
+ for (const directory of ICON_DIRECTORIES) {
94
+ const icon = await findIconRecursively(path.join(projectDirectory, directory), directory ? 2 : 0);
95
+ if (icon) return icon;
96
+ }
97
+ for (const monorepoDirectory of ["packages", "apps"]) {
98
+ const icon = await findIconRecursively(path.join(projectDirectory, monorepoDirectory), 3);
99
+ if (icon) return icon;
100
+ }
101
+ return null;
102
+ }
@@ -0,0 +1,56 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+ const PROMPT_SENTINEL = "{{{prompt}}}";
6
+
7
+ function shellQuote(value: string): string {
8
+ return `'${value.replaceAll("'", `'\\''`)}'`;
9
+ }
10
+
11
+ function resolveProfileCommand(profile: { command: string; args: string[] }, prompt: string): string {
12
+ const replacePrompt = (value: string) => value.split(PROMPT_SENTINEL).join(prompt);
13
+ const args = profile.args.flatMap((arg) => {
14
+ const promptOnly = arg === PROMPT_SENTINEL || arg.endsWith(`=${PROMPT_SENTINEL}`);
15
+ return !prompt && promptOnly ? [] : [replacePrompt(arg)];
16
+ });
17
+ return [replacePrompt(profile.command), ...args].map(shellQuote).join(" ");
18
+ }
19
+
20
+ function findTerminalId(value: unknown): string | null {
21
+ if (!value || typeof value !== "object") return null;
22
+ const record = value as Record<string, unknown>;
23
+ if (typeof record.id === "string") return record.id;
24
+ for (const nested of Object.values(record)) {
25
+ const id = findTerminalId(nested);
26
+ if (id) return id;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export async function launchWorkspaceTerminal(input: {
32
+ workspaceDirectory: string;
33
+ workspaceId: string;
34
+ serverId: string;
35
+ prompt: string;
36
+ profile: { name: string; command: string; args: string[] } | null;
37
+ }): Promise<{ terminalId: string }> {
38
+ const createArgs = ["terminal", "create", "--cwd", input.workspaceDirectory, "--json"];
39
+ if (input.profile?.name) createArgs.push("--name", input.profile.name);
40
+ const { stdout } = await execFileAsync("paseo", createArgs, { encoding: "utf8" });
41
+ const terminalId = findTerminalId(JSON.parse(stdout));
42
+ if (!terminalId) throw new Error("Paseo did not return the created terminal id");
43
+
44
+ const line = input.profile
45
+ ? resolveProfileCommand(input.profile, input.prompt.trim())
46
+ : input.prompt.trim();
47
+ if (line) {
48
+ await execFileAsync(
49
+ "paseo",
50
+ ["terminal", "send-keys", "--literal", terminalId, `${line}\r`, "--json"],
51
+ { encoding: "utf8" },
52
+ );
53
+ }
54
+
55
+ return { terminalId };
56
+ }
@@ -0,0 +1,16 @@
1
+ import type { AgentEntry } from "./overview-types";
2
+
3
+ /**
4
+ * Returns the effective activity timestamp (ms since epoch) for an agent.
5
+ *
6
+ * Prefers `lastUserMessageAt` (real user interaction); falls back to
7
+ * `updatedAt` (metadata/status change) so the agent still appears in
8
+ * sorting and display without being invisible when no user message exists.
9
+ * Returns 0 when neither field is available — sort-last sentinel.
10
+ */
11
+ export function agentActivityMs(agent: AgentEntry): number {
12
+ const iso = agent.lastUserMessageAt ?? agent.updatedAt;
13
+ if (!iso) return 0;
14
+ const t = new Date(iso).getTime();
15
+ return Number.isNaN(t) ? 0 : t;
16
+ }
@@ -0,0 +1,30 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const agentConfigSetRpc = defineRpc({
5
+ name: "overview.agent-config.set",
6
+ input: z.object({
7
+ agentId: z.string().min(1),
8
+ field: z.enum(["model", "mode", "thinking"]),
9
+ value: z.string().min(1),
10
+ }),
11
+ output: z.object({ ok: z.literal(true) }),
12
+ });
13
+
14
+ export const agentCancelRpc = defineRpc({
15
+ name: "overview.agent.cancel",
16
+ input: z.object({ agentId: z.string().min(1) }),
17
+ output: z.object({ ok: z.literal(true) }),
18
+ });
19
+
20
+ export const agentRewindRpc = defineRpc({
21
+ name: "overview.agent.rewind",
22
+ input: z.object({ agentId: z.string().min(1), messageId: z.string().min(1), mode: z.enum(["conversation", "files", "both"]) }),
23
+ output: z.object({ ok: z.literal(true) }),
24
+ });
25
+
26
+ export const pluginReloadRpc = defineRpc({
27
+ name: "overview.plugin.reload",
28
+ input: z.object({ pluginId: z.string().min(1) }),
29
+ output: z.object({ ok: z.literal(true) }),
30
+ });
@@ -0,0 +1,38 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const autoCommitInfoRpc = defineRpc({
5
+ name: "auto_commit_info",
6
+ input: z.object({}),
7
+ output: z.object({
8
+ cwd: z.string(),
9
+ currentBranch: z.string().nullable(),
10
+ hasChanges: z.boolean(),
11
+ status: z.string(),
12
+ recentMessages: z.array(z.string()),
13
+ }),
14
+ });
15
+
16
+ export const autoCommitStartRpc = defineRpc({
17
+ name: "auto_commit_start",
18
+ input: z.object({ cwd: z.string().min(1) }),
19
+ output: z.object({
20
+ taskId: z.string(),
21
+ cwd: z.string(),
22
+ }),
23
+ });
24
+
25
+ export const autoCommitStatusRpc = defineRpc({
26
+ name: "auto_commit_status",
27
+ input: z.object({ taskId: z.string().min(1) }),
28
+ output: z.object({
29
+ taskId: z.string(),
30
+ cwd: z.string(),
31
+ status: z.enum(["running", "done", "error"]),
32
+ output: z.string(),
33
+ error: z.string().nullable(),
34
+ exitCode: z.number().nullable(),
35
+ startedAt: z.string(),
36
+ finishedAt: z.string().nullable(),
37
+ }),
38
+ });