@saadnvd1/agent-os 0.3.502 → 0.3.505

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 (46) hide show
  1. package/README.md +47 -0
  2. package/app/api/projects/[id]/session-logs/route.ts +39 -0
  3. package/app/api/session-logs/[id]/route.ts +18 -0
  4. package/components/Pane/DesktopTabBar.tsx +6 -1
  5. package/components/Pane/MobileTabBar.tsx +14 -1
  6. package/components/Pane/PaneViewToggle.tsx +14 -2
  7. package/components/Pane/index.tsx +28 -1
  8. package/components/PrStageBadge.tsx +78 -0
  9. package/components/Projects/ProjectSettingsDialog.tsx +11 -0
  10. package/components/SessionList/LiveLine.tsx +75 -0
  11. package/components/SessionList/RowParts.tsx +1 -7
  12. package/components/SessionList/RowShell.tsx +1 -1
  13. package/components/SessionList/RowSubtitle.tsx +4 -0
  14. package/components/SessionList/SessionList.types.ts +2 -0
  15. package/components/SessionList/SessionRow.tsx +9 -1
  16. package/components/SessionLogs/LogDocument.tsx +29 -0
  17. package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
  18. package/components/views/types.ts +2 -0
  19. package/contexts/PaneContext.tsx +1 -1
  20. package/data/session-logs/index.ts +73 -0
  21. package/lib/db/migrations.ts +24 -0
  22. package/lib/done/index.ts +2 -0
  23. package/lib/orchestrator/armed.ts +2 -0
  24. package/lib/orchestrator/brief.ts +1 -0
  25. package/lib/orchestrator/checks.ts +2 -0
  26. package/lib/orchestrator/facts.ts +1 -0
  27. package/lib/orchestrator/logs.ts +81 -0
  28. package/lib/orchestrator/serve.ts +5 -0
  29. package/lib/orchestrator/task-state.ts +30 -7
  30. package/lib/orchestrator/tool-names.ts +1 -0
  31. package/lib/orchestrator/tool-schemas.ts +13 -0
  32. package/lib/orchestrator/tools.ts +1 -0
  33. package/lib/session-logs/build.ts +253 -0
  34. package/lib/session-logs/folder.ts +145 -0
  35. package/lib/session-logs/gather.ts +301 -0
  36. package/lib/session-logs/index.ts +178 -0
  37. package/lib/session-logs/store.ts +105 -0
  38. package/lib/sidebar/live-status.ts +52 -0
  39. package/lib/sidebar/shelves.ts +5 -0
  40. package/lib/status/collect.ts +6 -0
  41. package/lib/status/pr-stages.ts +55 -0
  42. package/lib/tasks/finish.ts +3 -0
  43. package/lib/tasks/pr-stage.ts +106 -0
  44. package/lib/tasks/session.ts +17 -0
  45. package/package.json +1 -1
  46. package/scripts/backfill-session-logs.ts +70 -0
@@ -0,0 +1,140 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ScrollText } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import { Input } from "@/components/ui/input";
14
+ import {
15
+ useProjectLogsQuery,
16
+ useSetLogFolder,
17
+ type LogHeader,
18
+ } from "@/data/session-logs";
19
+ import { LogDocument } from "./LogDocument";
20
+
21
+ const day = (h: LogHeader) => (h.mergedAt ?? h.createdAt).slice(0, 10);
22
+
23
+ function LogRow({ log, onOpen }: { log: LogHeader; onOpen: () => void }) {
24
+ return (
25
+ <li>
26
+ <button
27
+ type="button"
28
+ onClick={onOpen}
29
+ className="hover:bg-accent/50 flex min-h-11 w-full items-center gap-3 px-3 py-2 text-left transition-colors"
30
+ >
31
+ <span className="min-w-0 flex-1">
32
+ <span className="block truncate text-sm font-medium">{log.name}</span>
33
+ <span className="text-muted-foreground block truncate text-xs">
34
+ {day(log)}
35
+ {log.prNumber ? ` · #${log.prNumber}` : ""}
36
+ {` · $${log.costUsd.toFixed(2)}`}
37
+ </span>
38
+ </span>
39
+ <ScrollText className="text-muted-foreground h-4 w-4 shrink-0" />
40
+ </button>
41
+ </li>
42
+ );
43
+ }
44
+
45
+ // A project's logs of merged tasks, newest first, and the folder their
46
+ // files go to (the default is outside every repository).
47
+ export function ProjectLogsSection({
48
+ projectId,
49
+ open,
50
+ }: {
51
+ projectId: string;
52
+ open: boolean;
53
+ }) {
54
+ const { data, isPending, isError, error } = useProjectLogsQuery(
55
+ projectId,
56
+ open
57
+ );
58
+ const save = useSetLogFolder(projectId);
59
+ const [draft, setDraft] = useState<string | null>(null);
60
+ const [viewing, setViewing] = useState<LogHeader | null>(null);
61
+ const folder = draft ?? data?.folder ?? "";
62
+
63
+ return (
64
+ <div className="space-y-2">
65
+ <h3 className="text-sm font-medium">Session logs</h3>
66
+ <p className="text-muted-foreground text-xs">
67
+ When a task merges, AgentOS writes a log of it from what it already
68
+ recorded. No model is involved.
69
+ </p>
70
+ <div className="space-y-1.5">
71
+ <label htmlFor={`log-folder-${projectId}`} className="text-sm">
72
+ Session log folder
73
+ </label>
74
+ <div className="flex gap-2">
75
+ <Input
76
+ id={`log-folder-${projectId}`}
77
+ value={folder}
78
+ onChange={(e) => setDraft(e.target.value)}
79
+ placeholder={data ? `${data.defaultFolder}/…` : "~/path/to/logs"}
80
+ className="h-11 flex-1 md:h-9"
81
+ onKeyDown={(e) => {
82
+ if (e.key !== "Enter") return;
83
+ // Not the project form's submit.
84
+ e.preventDefault();
85
+ if (draft !== null)
86
+ save.mutate(draft, { onSuccess: () => setDraft(null) });
87
+ }}
88
+ />
89
+ <Button
90
+ type="button"
91
+ variant="outline"
92
+ className="h-11 md:h-9"
93
+ disabled={save.isPending || draft === null}
94
+ onClick={() =>
95
+ save.mutate(draft ?? "", { onSuccess: () => setDraft(null) })
96
+ }
97
+ >
98
+ Save
99
+ </Button>
100
+ </div>
101
+ {save.isError && (
102
+ <p className="text-destructive text-sm">{save.error.message}</p>
103
+ )}
104
+ <p className="text-muted-foreground text-xs">
105
+ An absolute or ~ path, never inside the project&apos;s repository.
106
+ Empty uses the default.
107
+ </p>
108
+ </div>
109
+ {isPending && (
110
+ <div className="bg-muted/40 h-16 animate-pulse rounded-lg" />
111
+ )}
112
+ {isError && <p className="text-destructive text-sm">{error.message}</p>}
113
+ {data &&
114
+ (data.logs.length ? (
115
+ <ul className="bg-muted/40 divide-border max-h-64 divide-y overflow-y-auto rounded-lg">
116
+ {data.logs.map((l) => (
117
+ <LogRow key={l.id} log={l} onOpen={() => setViewing(l)} />
118
+ ))}
119
+ </ul>
120
+ ) : (
121
+ <p className="text-muted-foreground text-sm">
122
+ No merged tasks logged yet.
123
+ </p>
124
+ ))}
125
+ <Dialog open={!!viewing} onOpenChange={(o) => !o && setViewing(null)}>
126
+ <DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden [&>*]:min-w-0">
127
+ <DialogHeader>
128
+ <DialogTitle className="truncate">
129
+ {viewing?.name ?? "Log"}
130
+ </DialogTitle>
131
+ <DialogDescription>Session log</DialogDescription>
132
+ </DialogHeader>
133
+ <div className="h-[70vh]">
134
+ {viewing && <LogDocument sessionId={viewing.id} />}
135
+ </div>
136
+ </DialogContent>
137
+ </Dialog>
138
+ </div>
139
+ );
140
+ }
@@ -1,3 +1,4 @@
1
+ import type { PrStage } from "@/lib/tasks/pr-stage";
1
2
  import type { SessionNeed } from "@/lib/sidebar/shelves";
2
3
  import type { Session } from "@/lib/db";
3
4
  import type { ProjectWithDevServers } from "@/lib/projects";
@@ -15,6 +16,7 @@ export interface SessionStatus {
15
16
  unread?: boolean;
16
17
  // A chat summarising its context; status stays "running".
17
18
  compacting?: { since: number; before?: number };
19
+ prStage?: PrStage;
18
20
  claudeSessionId?: string | null;
19
21
  // From a program's OSC 7501 report: untrusted, rendered as plain text.
20
22
  detail?: string | null;
@@ -26,7 +26,7 @@ import {
26
26
  } from "@/lib/panes";
27
27
  import { useViewport } from "@/hooks/useViewport";
28
28
 
29
- export type ViewMode = "terminal" | "files" | "git" | "workers";
29
+ export type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
30
30
 
31
31
  export interface PaneViewState {
32
32
  viewMode: ViewMode;
@@ -0,0 +1,73 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { LogHeader } from "@/lib/session-logs/store";
3
+
4
+ export type { LogHeader };
5
+
6
+ export const sessionLogKeys = {
7
+ all: ["session-logs"] as const,
8
+ task: (id: string) => [...sessionLogKeys.all, "task", id] as const,
9
+ project: (id: string) => [...sessionLogKeys.all, "project", id] as const,
10
+ };
11
+
12
+ export interface TaskLog {
13
+ header: LogHeader;
14
+ markdown: string;
15
+ }
16
+
17
+ export interface ProjectLogs {
18
+ folder: string | null;
19
+ defaultFolder: string;
20
+ logs: LogHeader[];
21
+ }
22
+
23
+ // null: this task has no log (it didn't merge, or it predates logs).
24
+ export function useTaskLogQuery(sessionId: string | undefined, enabled = true) {
25
+ return useQuery({
26
+ queryKey: sessionLogKeys.task(sessionId ?? ""),
27
+ queryFn: async (): Promise<TaskLog | null> => {
28
+ const res = await fetch(
29
+ `/api/session-logs/${encodeURIComponent(sessionId!)}`
30
+ );
31
+ if (res.status === 404) return null;
32
+ const data = await res.json();
33
+ if (!res.ok) throw new Error(data.error || "Request failed");
34
+ return data;
35
+ },
36
+ enabled: enabled && !!sessionId,
37
+ // A written log doesn't change; a task that just merged may not have
38
+ // one for a moment.
39
+ staleTime: (query) => (query.state.data ? Infinity : 10_000),
40
+ });
41
+ }
42
+
43
+ async function projectLogs(res: Response): Promise<ProjectLogs> {
44
+ const data = await res.json();
45
+ if (!res.ok) throw new Error(data.error || "Request failed");
46
+ return data;
47
+ }
48
+
49
+ export function useProjectLogsQuery(projectId: string, enabled = true) {
50
+ return useQuery({
51
+ queryKey: sessionLogKeys.project(projectId),
52
+ queryFn: async () =>
53
+ projectLogs(await fetch(`/api/projects/${projectId}/session-logs`)),
54
+ enabled,
55
+ staleTime: 30_000,
56
+ });
57
+ }
58
+
59
+ export function useSetLogFolder(projectId: string) {
60
+ const queryClient = useQueryClient();
61
+ return useMutation({
62
+ mutationFn: async (folder: string) =>
63
+ projectLogs(
64
+ await fetch(`/api/projects/${projectId}/session-logs`, {
65
+ method: "PUT",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify({ folder }),
68
+ })
69
+ ),
70
+ onSuccess: (data) =>
71
+ queryClient.setQueryData(sessionLogKeys.project(projectId), data),
72
+ });
73
+ }
@@ -1593,6 +1593,30 @@ const migrations: Migration[] = [
1593
1593
  })();
1594
1594
  },
1595
1595
  },
1596
+ {
1597
+ id: 69,
1598
+ name: "session_logs",
1599
+ up: (db) => {
1600
+ // One log per merged task, written by AgentOS from data it already
1601
+ // holds (lib/session-logs). Never deleted: the database is the record,
1602
+ // the markdown file on disk is a copy.
1603
+ db.exec(`
1604
+ CREATE TABLE IF NOT EXISTS session_logs (
1605
+ session_id TEXT PRIMARY KEY,
1606
+ project_id TEXT,
1607
+ workspace_id TEXT,
1608
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1609
+ markdown TEXT NOT NULL,
1610
+ data TEXT NOT NULL,
1611
+ file_path TEXT
1612
+ );
1613
+ CREATE INDEX IF NOT EXISTS idx_session_logs_project
1614
+ ON session_logs(project_id, created_at);
1615
+ CREATE INDEX IF NOT EXISTS idx_session_logs_workspace
1616
+ ON session_logs(workspace_id, created_at);
1617
+ `);
1618
+ },
1619
+ },
1596
1620
  ];
1597
1621
 
1598
1622
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/done/index.ts CHANGED
@@ -25,6 +25,7 @@ import { sessionMergePolicy } from "../tasks/finish";
25
25
  import { releasePorts } from "../ports";
26
26
  import { dropSessionDatabase } from "../project-config/database";
27
27
  import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
28
+ import { captureMergedTask } from "../session-logs";
28
29
 
29
30
  export interface DoneOptions {
30
31
  // Who asked: the orchestrator's gate failures count toward Saad, as its
@@ -102,6 +103,7 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
102
103
  : `UPDATE sessions SET task_status = 'done' WHERE id = ?`
103
104
  ).run(s.id);
104
105
  syncTaskCardInBackground(s, status, pr);
106
+ if (status === "merged") void captureMergedTask(s.id, { head: pr?.head });
105
107
  }
106
108
 
107
109
  export async function doneSession(
@@ -12,6 +12,7 @@
12
12
  * database, so it survives a restart.
13
13
  */
14
14
 
15
+ import { notifyStatusChanged } from "../status/hub";
15
16
  import { db, type Session } from "../db";
16
17
  import type { TaskPR } from "../tasks/state";
17
18
  import { latestApproval } from "./ask-approvals";
@@ -37,6 +38,7 @@ export function setArmed(sessionId: string, armed: boolean): void {
37
38
  merge_armed_seen = NULL WHERE id = ?`
38
39
  ).run(sessionId);
39
40
  looked.delete(sessionId);
41
+ notifyStatusChanged();
40
42
  }
41
43
 
42
44
  // The workspace a task's PR merges through when armed: its project's, with
@@ -95,6 +95,7 @@ Reading:
95
95
  - \`${TOOL_NAMES.sessions}\`: every session in the workspace with its project, view, status, what it's doing, task/PR/CI state and stack position. After them, external sessions: tmux sessions AgentOS didn't start (dispatch, a person's own terminal), on this machine or another, working in one of the workspace's repositories or a worktree of it, each with its branch's open PR. They're read-only: \`read\` works on them; \`send\`, \`stop\` and \`done\` don't. Gate their PRs with \`review\` and \`sign_off\` by number.
96
96
  - \`${TOOL_NAMES.read}\` (session, lines?): the end of a session's terminal or chat, or an external session's screen.
97
97
  - \`${TOOL_NAMES.cards}\` (board?): the cards on the workspace's LumifyHub boards.
98
+ - \`${TOOL_NAMES.logs}\` (project?, task?, n?): the logs AgentOS wrote when tasks merged. Without task, the latest headers; with it, that task's whole log (brief, plan, timeline, usage, outcome). Check it before repeating work or re-planning a job a merged task already did.
98
99
  - \`${TOOL_NAMES.stack_status}\` (id): one stack's items, PRs and progress.
99
100
  - \`${TOOL_NAMES.orchestrators}\`: every workspace, and whether it has an orchestrator.
100
101
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { db } from "../db";
8
+ import { notifyStatusChanged } from "../status/hub";
8
9
 
9
10
  // ci: when the check count on a commit last changed, for settling.
10
11
  export type CheckKind = "review" | "scope" | "ci";
@@ -57,6 +58,7 @@ export function putCheck(row: {
57
58
  row.status,
58
59
  row.detail ?? null
59
60
  );
61
+ notifyStatusChanged();
60
62
  return getCheck(row.sessionId, row.sha, row.kind)!;
61
63
  }
62
64
 
@@ -200,6 +200,7 @@ export async function statusOf(s: Session): Promise<{
200
200
 
201
201
  // A commit's time never changes: read once per sha.
202
202
  const committed = new Map<string, number>();
203
+ export const knownCommitTime = (sha: string) => committed.get(sha) ?? 0;
203
204
 
204
205
  export async function settleIn(
205
206
  workspaceId: string,
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The orchestrator's `logs` tool: session logs read from the database as
3
+ * they were written. Reading only; nothing here asks a model to summarise.
4
+ */
5
+
6
+ import { formatDuration } from "../session-logs/build";
7
+ import { getLog, listLogs, type LogHeader } from "../session-logs";
8
+ import { db } from "../db";
9
+ import { workspaceProject } from "./targets";
10
+ import { untrusted } from "./untrusted";
11
+
12
+ // About 8k tokens: what one full log may return.
13
+ export const LOG_CHAR_CAP = 30000;
14
+
15
+ const headerLine = (h: LogHeader) =>
16
+ [
17
+ (h.mergedAt ?? h.createdAt).slice(0, 10),
18
+ `${h.name} [${h.id.slice(0, 8)}]`,
19
+ h.project ?? "no project",
20
+ h.prNumber ? `#${h.prNumber}` : "no PR",
21
+ h.durationMs !== null ? formatDuration(h.durationMs) : "?",
22
+ `$${h.costUsd.toFixed(2)}`,
23
+ h.hasPlan ? "plan" : h.planKept ? "no plan" : "plan not kept",
24
+ ].join(" · ");
25
+
26
+ function findOne(
27
+ workspaceId: string,
28
+ projectId: string | null,
29
+ ref: string
30
+ ): string {
31
+ const r = ref.trim().toLowerCase();
32
+ const pr = /^#?(\d+)$/.exec(r);
33
+ const rows = db
34
+ .prepare(
35
+ `SELECT session_id AS id, json_extract(data, '$.name') AS name,
36
+ json_extract(data, '$.prNumber') AS pr
37
+ FROM session_logs WHERE workspace_id = ? AND (? IS NULL OR project_id = ?)
38
+ ORDER BY json_extract(data, '$.mergedAt') DESC`
39
+ )
40
+ .all(workspaceId, projectId, projectId) as {
41
+ id: string;
42
+ name: string;
43
+ pr: number | null;
44
+ }[];
45
+ const hit = rows.filter(
46
+ (x) =>
47
+ x.id === ref ||
48
+ x.id.startsWith(r) ||
49
+ x.name.toLowerCase() === r ||
50
+ (pr !== null && x.pr === Number(pr[1]))
51
+ );
52
+ if (hit.length === 1) return hit[0].id;
53
+ if (hit.length > 1)
54
+ throw new Error(
55
+ `"${ref}" matches ${hit.length} logs (${hit.map((x) => x.id.slice(0, 8)).join(", ")}); use an id`
56
+ );
57
+ throw new Error(`No log for "${ref}" in this workspace.`);
58
+ }
59
+
60
+ export function readLogs(
61
+ workspaceId: string,
62
+ a: { project?: string; task?: string; n?: number }
63
+ ): string {
64
+ const projectId = a.project
65
+ ? workspaceProject(workspaceId, a.project).id
66
+ : null;
67
+ if (a.task) {
68
+ const row = getLog(findOne(workspaceId, projectId, a.task))!;
69
+ const md =
70
+ row.markdown.length > LOG_CHAR_CAP
71
+ ? `${row.markdown.slice(0, LOG_CHAR_CAP)}\n… (trimmed; ${row.markdown.length} characters in all)`
72
+ : row.markdown;
73
+ return untrusted("session log", md);
74
+ }
75
+ const logs = listLogs(
76
+ { workspaceId, ...(projectId ? { projectId } : {}) },
77
+ a.n ?? 10
78
+ );
79
+ if (!logs.length) return "No session logs yet.";
80
+ return untrusted("session logs", logs.map(headerLine).join("\n"));
81
+ }
@@ -12,6 +12,7 @@ import {
12
12
  stop,
13
13
  } from "./act";
14
14
  import { readCards } from "./cards";
15
+ import { readLogs } from "./logs";
15
16
  import { describeSessions } from "./describe";
16
17
  import { sessionFacts } from "./facts";
17
18
  import { describeExternal, externalSessions } from "./external-sessions";
@@ -85,6 +86,10 @@ export async function runTool(
85
86
  }
86
87
  case "cards":
87
88
  return readCards(w, p(tool, raw).board);
89
+ case "logs": {
90
+ const a = p(tool, raw);
91
+ return readLogs(w, a);
92
+ }
88
93
  case "send": {
89
94
  const a = p(tool, raw);
90
95
  return send(w, a.session, a.message, {
@@ -114,6 +114,20 @@ async function peerChatWaiting(
114
114
  // check has registered on it for 2 minutes. Seconds still to wait.
115
115
  export const SETTLE_S = 120;
116
116
 
117
+ // When the check count last changed, as ciSettleIn stored it.
118
+ const lastSeen = (detail: string | null | undefined) =>
119
+ detail ? (JSON.parse(detail) as { count: number; at: number }) : null;
120
+
121
+ const settleLeft = (
122
+ last: { count: number; at: number } | null,
123
+ checkCount: number,
124
+ committedAt: number,
125
+ now: number
126
+ ) => {
127
+ const changedAt = last && last.count === checkCount ? last.at : now;
128
+ return Math.max(0, Math.max(committedAt, changedAt) + SETTLE_S - now);
129
+ };
130
+
117
131
  export function ciSettleIn(input: {
118
132
  workspaceId: string;
119
133
  taskId: string;
@@ -123,11 +137,7 @@ export function ciSettleIn(input: {
123
137
  now?: number;
124
138
  }): number {
125
139
  const now = Math.floor((input.now ?? Date.now()) / 1000);
126
- const seen = getCheck(input.taskId, input.sha, "ci");
127
- const last = seen?.detail
128
- ? (JSON.parse(seen.detail) as { count: number; at: number })
129
- : null;
130
- const changedAt = last && last.count === input.checkCount ? last.at : now;
140
+ const last = lastSeen(getCheck(input.taskId, input.sha, "ci")?.detail);
131
141
  if (!last || last.count !== input.checkCount)
132
142
  putCheck({
133
143
  workspaceId: input.workspaceId,
@@ -137,8 +147,21 @@ export function ciSettleIn(input: {
137
147
  status: "pass",
138
148
  detail: JSON.stringify({ count: input.checkCount, at: now }),
139
149
  });
140
- const settledAt = Math.max(input.committedAt, changedAt) + SETTLE_S;
141
- return Math.max(0, settledAt - now);
150
+ return settleLeft(last, input.checkCount, input.committedAt, now);
151
+ }
152
+
153
+ // The same answer from what ciSettleIn stored, writing nothing: for a
154
+ // status that only looks.
155
+ export function ciSettleLeft(input: {
156
+ taskId: string;
157
+ sha: string;
158
+ checkCount: number;
159
+ committedAt: number;
160
+ now?: number;
161
+ }): number {
162
+ const now = Math.floor((input.now ?? Date.now()) / 1000);
163
+ const last = lastSeen(getCheck(input.taskId, input.sha, "ci")?.detail);
164
+ return settleLeft(last, input.checkCount, input.committedAt, now);
142
165
  }
143
166
 
144
167
  // A head with no checks for this long after its PR became mergeable, in a
@@ -10,6 +10,7 @@ export const TOOL_NAMES = {
10
10
  sessions: name("sessions"),
11
11
  read: name("read"),
12
12
  cards: name("cards"),
13
+ logs: name("logs"),
13
14
  send: name("send"),
14
15
  orchestrators: name("orchestrators"),
15
16
  message_orchestrator: name("message_orchestrator"),
@@ -57,6 +57,19 @@ export const TOOL_SHAPES = {
57
57
  cards: {
58
58
  board: ref("One board, by board or project name").optional(),
59
59
  },
60
+ logs: {
61
+ project: project.optional(),
62
+ task: ref(
63
+ "One merged task's log, by task name, id (or its first characters) or PR (#12); without it, the latest headers"
64
+ ).optional(),
65
+ n: z
66
+ .number()
67
+ .int()
68
+ .min(1)
69
+ .max(50)
70
+ .optional()
71
+ .describe("How many headers, newest first (default 10)"),
72
+ },
60
73
  send: {
61
74
  session,
62
75
  message: z.string().trim().min(1).max(8000),
@@ -55,6 +55,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
55
55
  "Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
56
56
  read: "The end of one session's terminal or chat, or an external session's screen, as plain text (capped at about 4k tokens).",
57
57
  cards: "Cards on this workspace's linked LumifyHub boards, by list.",
58
+ logs: "Session logs AgentOS wrote when tasks merged: the last N headers (task, PR, cost, duration) of the workspace or one project, or one log in full (brief, plan, timeline, usage, outcome). Read straight from the database; nothing is summarised.",
58
59
  send: "Message a session in this workspace over the bus. It arrives as its next prompt. When Saad changes a running task's scope, tell it with scope_change set, so its review judges the PR against the brief as amended.",
59
60
  orchestrators:
60
61
  "Every workspace, and whether it has an orchestrator you can message.",