@saadnvd1/agent-os 0.3.457 → 0.3.460

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 (58) hide show
  1. package/README.md +42 -5
  2. package/app/api/fresh-start/route.ts +19 -0
  3. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
  4. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
  5. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
  6. package/app/api/workspaces/[id]/route.ts +8 -1
  7. package/app/globals.css +10 -0
  8. package/components/Chat/ChatPanel.tsx +87 -4
  9. package/components/Chat/FindBar.tsx +113 -0
  10. package/components/Chat/Tools.tsx +1 -1
  11. package/components/Chat/find-highlight.test.ts +56 -0
  12. package/components/Chat/find-highlight.ts +78 -0
  13. package/components/Chat/rowState.tsx +44 -4
  14. package/components/Chat/stick-to-bottom.ts +7 -1
  15. package/components/Chat/useFind.ts +253 -0
  16. package/components/Pane/MobileTabBar.tsx +18 -0
  17. package/components/Pane/index.tsx +6 -0
  18. package/components/Workspaces/FreshStartForm.tsx +128 -0
  19. package/components/Workspaces/RulesForm.tsx +166 -0
  20. package/components/Workspaces/WorkspaceSettings.tsx +13 -1
  21. package/components/Workspaces/fresh-start.ts +19 -0
  22. package/data/chat/useChat.ts +29 -0
  23. package/data/fresh-start/index.ts +34 -0
  24. package/data/orchestrators/rules.ts +80 -0
  25. package/data/push/topics.ts +4 -0
  26. package/data/workspaces/index.ts +6 -1
  27. package/lib/chat/demo.ts +1 -0
  28. package/lib/chat/events.ts +7 -1
  29. package/lib/chat/find.ts +278 -0
  30. package/lib/chat/refresh-limits.ts +5 -0
  31. package/lib/chat/refresh.ts +82 -0
  32. package/lib/chat/runner.ts +11 -2
  33. package/lib/chat/search.ts +117 -0
  34. package/lib/chat/socket.ts +12 -0
  35. package/lib/chat/worker/extras.ts +11 -1
  36. package/lib/chat/worker/host.ts +70 -5
  37. package/lib/db/changes.ts +3 -0
  38. package/lib/db/migrations.ts +69 -0
  39. package/lib/db/types.ts +9 -0
  40. package/lib/orchestrator/brief.ts +16 -3
  41. package/lib/orchestrator/conditions.ts +10 -3
  42. package/lib/orchestrator/digest.ts +339 -0
  43. package/lib/orchestrator/pr-wake.ts +385 -0
  44. package/lib/orchestrator/presence-binding.ts +5 -0
  45. package/lib/orchestrator/rules.ts +124 -0
  46. package/lib/orchestrator/serve.ts +11 -0
  47. package/lib/orchestrator/tool-names.ts +2 -0
  48. package/lib/orchestrator/tool-schemas.ts +10 -0
  49. package/lib/orchestrator/tools.ts +4 -0
  50. package/lib/orchestrator/watcher.ts +6 -2
  51. package/lib/orchestrator/worker.ts +5 -0
  52. package/lib/security/presence.ts +2 -1
  53. package/lib/tasks/gh.ts +2 -0
  54. package/lib/tasks/pr-activity.ts +295 -0
  55. package/lib/tasks/state.ts +9 -1
  56. package/lib/workspaces.ts +8 -2
  57. package/package.json +1 -1
  58. package/styles/themes.css +5 -0
@@ -0,0 +1,295 @@
1
+ // Reviews and comments on task PRs, read in two steps: a fingerprint of
2
+ // every watched PR in a repository from one GraphQL query (counts and the
3
+ // latest ids), and one PR's reviews and comments only when its fingerprint
4
+ // moved. Plus the part of a failed Actions job's log around its error.
5
+
6
+ import { redact } from "../orchestrator/untrusted";
7
+ import { run } from "./gh";
8
+
9
+ export interface Repo {
10
+ owner: string;
11
+ name: string;
12
+ }
13
+
14
+ export const repoOfPR = (url: string): Repo | null => {
15
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/.exec(url);
16
+ return m ? { owner: m[1], name: m[2] } : null;
17
+ };
18
+
19
+ export interface Fingerprint {
20
+ // Changes whenever a review or a conversation comment is added.
21
+ print: string;
22
+ createdAt: string;
23
+ }
24
+
25
+ const graphql = async (
26
+ dir: string,
27
+ query: string,
28
+ vars: Record<string, string | number>
29
+ ) => {
30
+ const args = ["api", "graphql", "-f", `query=${query}`];
31
+ for (const [k, v] of Object.entries(vars))
32
+ args.push(typeof v === "number" ? "-F" : "-f", `${k}=${v}`);
33
+ return JSON.parse(await run("gh", args, dir, 30000)) as {
34
+ data?: { viewer?: { login: string }; repository?: Record<string, unknown> };
35
+ };
36
+ };
37
+
38
+ type Last = { totalCount: number; nodes: { id: string }[] };
39
+
40
+ // One query for all the given PRs of a repository.
41
+ export async function fingerprints(
42
+ dir: string,
43
+ repo: Repo,
44
+ numbers: number[]
45
+ ): Promise<Map<number, Fingerprint>> {
46
+ const prs = numbers.filter((n) => Number.isInteger(n) && n > 0);
47
+ const out = new Map<number, Fingerprint>();
48
+ if (!prs.length) return out;
49
+ const fields = prs
50
+ .map(
51
+ (n) =>
52
+ `p${n}: pullRequest(number: ${n}) { createdAt reviews(last: 1) { totalCount nodes { id } } comments(last: 1) { totalCount nodes { id } } }`
53
+ )
54
+ .join(" ");
55
+ const res = await graphql(
56
+ dir,
57
+ `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields} } }`,
58
+ { owner: repo.owner, name: repo.name }
59
+ );
60
+ for (const n of prs) {
61
+ const pr = res.data?.repository?.[`p${n}`] as
62
+ | { createdAt: string; reviews: Last; comments: Last }
63
+ | null
64
+ | undefined;
65
+ if (!pr) continue;
66
+ const part = (l: Last) => `${l.totalCount}:${l.nodes[0]?.id ?? ""}`;
67
+ out.set(n, {
68
+ print: `${part(pr.reviews)}/${part(pr.comments)}`,
69
+ createdAt: pr.createdAt,
70
+ });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ export interface PRNote {
76
+ path: string;
77
+ line: number | null;
78
+ body: string;
79
+ }
80
+
81
+ export interface PREvent {
82
+ id: string;
83
+ kind: "review" | "comment";
84
+ // A review's: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING.
85
+ state: string | null;
86
+ author: string;
87
+ // GitHub's word on the author: OWNER, MEMBER, COLLABORATOR, NONE...
88
+ association: string;
89
+ at: string;
90
+ body: string;
91
+ // A review's inline comments.
92
+ notes: PRNote[];
93
+ }
94
+
95
+ export interface PRActivity {
96
+ // Who gh is signed in as: the task's own comments are posted as this.
97
+ viewer: string;
98
+ events: PREvent[];
99
+ }
100
+
101
+ type Author = { login: string } | null;
102
+
103
+ // The latest reviews and conversation comments on one PR.
104
+ export async function prActivity(
105
+ dir: string,
106
+ repo: Repo,
107
+ number: number
108
+ ): Promise<PRActivity> {
109
+ const res = await graphql(
110
+ dir,
111
+ `
112
+ query ($owner: String!, $name: String!, $number: Int!) {
113
+ viewer {
114
+ login
115
+ }
116
+ repository(owner: $owner, name: $name) {
117
+ pullRequest(number: $number) {
118
+ reviews(last: 30) {
119
+ nodes {
120
+ id
121
+ state
122
+ body
123
+ submittedAt
124
+ authorAssociation
125
+ author {
126
+ login
127
+ }
128
+ comments(first: 20) {
129
+ nodes {
130
+ path
131
+ line
132
+ originalLine
133
+ body
134
+ }
135
+ }
136
+ }
137
+ }
138
+ comments(last: 30) {
139
+ nodes {
140
+ id
141
+ body
142
+ createdAt
143
+ authorAssociation
144
+ author {
145
+ login
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ `,
153
+ { owner: repo.owner, name: repo.name, number }
154
+ );
155
+ const pr = res.data?.repository?.pullRequest as
156
+ | {
157
+ reviews: {
158
+ nodes: {
159
+ id: string;
160
+ state: string;
161
+ body: string;
162
+ submittedAt: string | null;
163
+ authorAssociation: string;
164
+ author: Author;
165
+ comments: {
166
+ nodes: {
167
+ path: string;
168
+ line: number | null;
169
+ originalLine: number | null;
170
+ body: string;
171
+ }[];
172
+ };
173
+ }[];
174
+ };
175
+ comments: {
176
+ nodes: {
177
+ id: string;
178
+ body: string;
179
+ createdAt: string;
180
+ authorAssociation: string;
181
+ author: Author;
182
+ }[];
183
+ };
184
+ }
185
+ | null
186
+ | undefined;
187
+ if (!pr || !res.data?.viewer) throw new Error(`PR #${number} not found`);
188
+ return {
189
+ viewer: res.data.viewer.login,
190
+ events: [
191
+ ...pr.reviews.nodes
192
+ .filter((r) => r.submittedAt)
193
+ .map((r) => ({
194
+ id: r.id,
195
+ kind: "review" as const,
196
+ state: r.state,
197
+ author: r.author?.login ?? "ghost",
198
+ association: r.authorAssociation,
199
+ at: r.submittedAt!,
200
+ body: r.body ?? "",
201
+ notes: r.comments.nodes.map((c) => ({
202
+ path: c.path,
203
+ line: c.line ?? c.originalLine,
204
+ body: c.body ?? "",
205
+ })),
206
+ })),
207
+ ...pr.comments.nodes.map((c) => ({
208
+ id: c.id,
209
+ kind: "comment" as const,
210
+ state: null,
211
+ author: c.author?.login ?? "ghost",
212
+ association: c.authorAssociation,
213
+ at: c.createdAt,
214
+ body: c.body ?? "",
215
+ notes: [],
216
+ })),
217
+ ].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)),
218
+ };
219
+ }
220
+
221
+ export const LOG_LINES = 60;
222
+ const LINE_MAX = 240;
223
+ // The line a failure is reported on; the runner's own last line isn't it.
224
+ const ERROR =
225
+ /##\[error\]|\berror\b|\bfail(ed|ure|s)?\b|\bFAIL\b|✗|×|AssertionError|panic:/i;
226
+ const RUNNER_EXIT = /Process completed with exit code/;
227
+
228
+ export interface FailedLog {
229
+ step: string | null;
230
+ tail: string;
231
+ }
232
+
233
+ // `gh run view --log-failed` lines are "job<TAB>step<TAB>time text". Keeps
234
+ // LOG_LINES lines from a little before the first error, without timestamps,
235
+ // colour codes or anything token-shaped.
236
+ export function trimLog(raw: string, max = LOG_LINES): FailedLog {
237
+ const rows = raw
238
+ .split("\n")
239
+ .filter((l) => l.trim())
240
+ .map((l) => {
241
+ const parts = l.split("\t");
242
+ const step = parts.length >= 3 ? parts[1] : null;
243
+ const text = (parts.length >= 3 ? parts.slice(2).join("\t") : l)
244
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
245
+ .replace(/^?\d{4}-\d\d-\d\dT[\d:.]+Z ?/, "");
246
+ return {
247
+ step,
248
+ text: text.length > LINE_MAX ? `${text.slice(0, LINE_MAX)}…` : text,
249
+ };
250
+ });
251
+ if (!rows.length) return { step: null, tail: "" };
252
+ const hit = rows.findIndex(
253
+ (r) => ERROR.test(r.text) && !RUNNER_EXIT.test(r.text)
254
+ );
255
+ const anchor = hit >= 0 ? hit : rows.length - 1;
256
+ const start =
257
+ hit >= 0
258
+ ? Math.max(0, Math.min(hit - Math.floor(max / 3), rows.length - max))
259
+ : Math.max(0, rows.length - max);
260
+ const kept = rows.slice(start, start + max);
261
+ return {
262
+ step: rows[anchor].step,
263
+ tail: redact(kept.map((r) => r.text).join("\n")),
264
+ };
265
+ }
266
+
267
+ // The Actions job a check's details URL points at.
268
+ export const jobOf = (url: string | null | undefined) =>
269
+ url ? (/\/actions\/runs\/\d+\/job\/(\d+)/.exec(url)?.[1] ?? null) : null;
270
+
271
+ // The failed steps' log of an Actions job, trimmed; null for a check that
272
+ // isn't an Actions job.
273
+ export async function failedLog(
274
+ dir: string,
275
+ repo: Repo,
276
+ url: string | null | undefined
277
+ ): Promise<FailedLog | null> {
278
+ const job = jobOf(url);
279
+ if (!job) return null;
280
+ const raw = await run(
281
+ "gh",
282
+ [
283
+ "run",
284
+ "view",
285
+ "--repo",
286
+ `${repo.owner}/${repo.name}`,
287
+ "--job",
288
+ job,
289
+ "--log-failed",
290
+ ],
291
+ dir,
292
+ 60000
293
+ );
294
+ return trimLog(raw);
295
+ }
@@ -18,6 +18,8 @@ export interface TaskPR {
18
18
  // The head commit the checks ran on, and the first failing check's name.
19
19
  head?: string;
20
20
  failing?: string | null;
21
+ // Where that check's run is (an Actions job URL for a workflow check).
22
+ failingUrl?: string | null;
21
23
  // How many checks are registered on the head.
22
24
  checkCount?: number;
23
25
  // The body's Code review section: null when it has none, undefined
@@ -340,11 +342,17 @@ export function blockedReason(paneTail: string): string | null {
340
342
  }
341
343
 
342
344
  // The first failing check in gh's statusCheckRollup, by name.
345
+ const firstFailing = (rollup: RollupEntry[]) =>
346
+ liveChecks(rollup).find((r) => FAILED.includes(outcome(r)));
347
+
343
348
  export function failingCheck(rollup: RollupEntry[]): string | null {
344
- const c = liveChecks(rollup).find((r) => FAILED.includes(outcome(r)));
349
+ const c = firstFailing(rollup);
345
350
  return c ? (c.name ?? c.context ?? null) : null;
346
351
  }
347
352
 
353
+ export const failingUrl = (rollup: RollupEntry[]): string | null =>
354
+ firstFailing(rollup)?.detailsUrl ?? null;
355
+
348
356
  // Keys that answer Claude's folder-trust prompt with "trust", or null when the
349
357
  // pane isn't showing it. The selection marker starts on "No, exit".
350
358
  export function trustPromptKeys(pane: string): string[] | null {
package/lib/workspaces.ts CHANGED
@@ -6,11 +6,15 @@
6
6
  import { randomUUID } from "crypto";
7
7
  import { db, type Workspace } from "./db";
8
8
 
9
- type WorkspaceRow = Omit<Workspace, "collapsed"> & { collapsed: number };
9
+ type WorkspaceRow = Omit<Workspace, "collapsed" | "wake_tasks_on_pr"> & {
10
+ collapsed: number;
11
+ wake_tasks_on_pr: number;
12
+ };
10
13
 
11
14
  const toWorkspace = (row: WorkspaceRow): Workspace => ({
12
15
  ...row,
13
16
  collapsed: !!row.collapsed,
17
+ wake_tasks_on_pr: !!row.wake_tasks_on_pr,
14
18
  });
15
19
 
16
20
  export function listWorkspaces(): Workspace[] {
@@ -57,18 +61,20 @@ export function updateWorkspace(
57
61
  collapsed?: boolean;
58
62
  sortOrder?: number;
59
63
  maxRunningTasks?: number | null;
64
+ wakeTasksOnPR?: boolean;
60
65
  }
61
66
  ): Workspace | null {
62
67
  const existing = getWorkspace(id);
63
68
  if (!existing) return null;
64
69
  const name = updates.name?.trim() || existing.name;
65
70
  db.prepare(
66
- `UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ? WHERE id = ?`
71
+ `UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ?, wake_tasks_on_pr = ? WHERE id = ?`
67
72
  ).run(
68
73
  name,
69
74
  (updates.collapsed ?? existing.collapsed) ? 1 : 0,
70
75
  updates.sortOrder ?? existing.sort_order,
71
76
  taskLimit(updates.maxRunningTasks, existing.max_running_tasks),
77
+ (updates.wakeTasksOnPR ?? existing.wake_tasks_on_pr) ? 1 : 0,
72
78
  id
73
79
  );
74
80
  return getWorkspace(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.457",
3
+ "version": "0.3.460",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/styles/themes.css CHANGED
@@ -24,6 +24,10 @@
24
24
  Cool white with a violet accent
25
25
  =========================================================================== */
26
26
  :root {
27
+ /* Find in a conversation: every match, and the one it's on. */
28
+ --find-match: 48 96% 53% / 0.45;
29
+ --find-current: 25 95% 53%;
30
+ --find-current-foreground: 0 0% 0%;
27
31
  --background: 240 10% 95.5%;
28
32
  --foreground: 240 12% 14%;
29
33
  --card: 0 0% 100%;
@@ -287,6 +291,7 @@
287
291
  Blue-black with a violet accent
288
292
  =========================================================================== */
289
293
  .dark {
294
+ --find-match: 48 96% 53% / 0.35;
290
295
  --background: 240 18% 3.5%;
291
296
  --foreground: 240 10% 93%;
292
297
  --card: 240 14% 6%;