@saadnvd1/agent-os 0.3.510 → 0.3.512
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/app/api/sessions/route.ts +1 -7
- package/components/Pane/index.tsx +1 -1
- package/components/SessionList/SessionRowMenu.tsx +1 -1
- package/lib/away/activity.ts +27 -0
- package/lib/away/brief.ts +4 -10
- package/lib/away/run.ts +16 -2
- package/lib/chat/runner.ts +2 -5
- package/lib/chat/store.ts +22 -0
- package/lib/db/index.ts +1 -1
- package/lib/db/queries.ts +38 -1
- package/lib/db/types.ts +2 -0
- package/lib/done/archive.ts +2 -1
- package/lib/needs-you.ts +1 -1
- package/lib/orchestrator/digest.ts +1 -1
- package/lib/tasks/index.ts +1 -1
- package/lib/tasks/session.ts +13 -3
- package/package.json +1 -1
|
@@ -13,13 +13,7 @@ export async function GET() {
|
|
|
13
13
|
// Merged and dropped tasks live in the Tasks panel (a moved one lives on
|
|
14
14
|
// the machine it moved to), archived sessions
|
|
15
15
|
// in the Archived view: neither is in the sidebar.
|
|
16
|
-
const sessions =
|
|
17
|
-
(s) =>
|
|
18
|
-
!s.archived_at &&
|
|
19
|
-
s.task_status !== "merged" &&
|
|
20
|
-
s.task_status !== "dropped" &&
|
|
21
|
-
s.task_status !== "moved"
|
|
22
|
-
);
|
|
16
|
+
const sessions = queries.getSidebarSessions(db).all() as Session[];
|
|
23
17
|
const groups = queries.getAllGroups(db).all() as Group[];
|
|
24
18
|
|
|
25
19
|
// Convert expanded from 0/1 to boolean
|
|
@@ -305,7 +305,7 @@ export const Pane = memo(function Pane({
|
|
|
305
305
|
sessionName={chat.name}
|
|
306
306
|
accessLocked={chat.role === "orchestrator"}
|
|
307
307
|
freshStart={
|
|
308
|
-
(chat.role === "orchestrator" || !!chat.
|
|
308
|
+
(chat.role === "orchestrator" || !!chat.has_brief) &&
|
|
309
309
|
!chat.archived_at
|
|
310
310
|
}
|
|
311
311
|
orchestratorOf={
|
|
@@ -175,7 +175,7 @@ export function SessionRowMenu({
|
|
|
175
175
|
{summarizing ? "Summarizing..." : "Fresh start"}
|
|
176
176
|
</Item>
|
|
177
177
|
)}
|
|
178
|
-
{(session.role === "orchestrator" || session.
|
|
178
|
+
{(session.role === "orchestrator" || session.has_brief) &&
|
|
179
179
|
session.view === "chat" &&
|
|
180
180
|
!session.archived_at &&
|
|
181
181
|
!blocked && (
|
package/lib/away/activity.ts
CHANGED
|
@@ -116,6 +116,33 @@ function chatTurn(sessionId: string): number {
|
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// How recently Saad must have messaged an orchestrator for turning Away on
|
|
120
|
+
// to tell it.
|
|
121
|
+
export const SAAD_MESSAGE_WINDOW_MS = 24 * 60 * 60_000;
|
|
122
|
+
|
|
123
|
+
// When Saad last typed to a chat himself: a user item with no origin, so
|
|
124
|
+
// not an event, a peer, a schedule or AgentOS, or his own decision made
|
|
125
|
+
// outside the composer. 0 when he never has.
|
|
126
|
+
function saadMessageAt(sessionId: string): number {
|
|
127
|
+
const row = db
|
|
128
|
+
.prepare(
|
|
129
|
+
`SELECT json_extract(data, '$.createdAt') AS at FROM chat_items
|
|
130
|
+
WHERE session_id = ? AND kind = 'user'
|
|
131
|
+
AND (json_extract(data, '$.origin') IS NULL
|
|
132
|
+
OR json_extract(data, '$.origin.kind') = 'decision')
|
|
133
|
+
ORDER BY seq DESC LIMIT 1`
|
|
134
|
+
)
|
|
135
|
+
.get(sessionId) as { at: number | null } | undefined;
|
|
136
|
+
return row?.at ?? 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Whether Saad has been talking to this session lately. A terminal keeps
|
|
140
|
+
// no record of who typed, so its last turn stands in.
|
|
141
|
+
export function saadMessagedRecently(s: Session, now = Date.now()): boolean {
|
|
142
|
+
const at = s.view === "chat" ? saadMessageAt(s.id) : terminalTurn(s);
|
|
143
|
+
return at > 0 && now - at <= SAAD_MESSAGE_WINDOW_MS;
|
|
144
|
+
}
|
|
145
|
+
|
|
119
146
|
// A terminal's Claude conversation file is written on every turn and on
|
|
120
147
|
// nothing else; other agents fall back to tmux's output clock, which a
|
|
121
148
|
// status poll (capture-pane) doesn't move.
|
package/lib/away/brief.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
activeAway,
|
|
8
8
|
clockTime,
|
|
9
9
|
fromSql,
|
|
10
|
-
reachedWorkspaces,
|
|
11
10
|
untilText,
|
|
12
11
|
type AwayRow,
|
|
13
12
|
} from "./state";
|
|
@@ -18,14 +17,9 @@ export function awayOnLine(row: AwayRow, now = Date.now()): string {
|
|
|
18
17
|
return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps this workspace without messaging any session: it runs lost reviews again, checks the deploy after each merge, and sends you one digest of what's new (stalled tasks, idle ones with uncommitted work and no PR, Saad's own sessions idle with uncommitted work). You decide what needs work and act with your tools. Only sessions with real activity in the last ${awayWindowMinutes()} minutes are listed: leave the rest alone, since a message wakes an agent and re-reads its whole context. Interactive sessions (not tasks) are Saad's: ask a listed one at most once to wrap up, never drive them further. Hard lines still go to ask_saad; while he's away his asks reach his phone in one batch per sweep, so raise them as usual. \`aos notify\` is held for that batch too, so don't send one per item.`;
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
// For the digest: the same line
|
|
22
|
-
//
|
|
23
|
-
export function awayDigestLine(
|
|
24
|
-
workspaceId: string,
|
|
25
|
-
now = Date.now()
|
|
26
|
-
): string | null {
|
|
20
|
+
// For the digest: the same line whenever Away is on, messaged or not, so an
|
|
21
|
+
// orchestrator skipped at the toggle still knows when it next starts.
|
|
22
|
+
export function awayDigestLine(now = Date.now()): string | null {
|
|
27
23
|
const row = activeAway();
|
|
28
|
-
return row
|
|
29
|
-
? awayOnLine(row, now)
|
|
30
|
-
: null;
|
|
24
|
+
return row ? awayOnLine(row, now) : null;
|
|
31
25
|
}
|
package/lib/away/run.ts
CHANGED
|
@@ -45,7 +45,12 @@ import {
|
|
|
45
45
|
SWEEP_EVERY_MS,
|
|
46
46
|
type Candidate,
|
|
47
47
|
} from "./sweep";
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
agentLive,
|
|
50
|
+
awayWindowMs,
|
|
51
|
+
lastTurnAt,
|
|
52
|
+
saadMessagedRecently,
|
|
53
|
+
} from "./activity";
|
|
49
54
|
import { checkDeploy } from "./deploy";
|
|
50
55
|
import { buildAwayReport, phoneSummary, reportMarkdown } from "./report";
|
|
51
56
|
import { awayOnLine } from "./brief";
|
|
@@ -92,6 +97,14 @@ function tellAway(
|
|
|
92
97
|
// keep an orchestrator going that nothing else has.
|
|
93
98
|
if (!orchestratorActive(orchestratorId, now)) return false;
|
|
94
99
|
const told = reachedWorkspaces(away.id).has(workspaceId);
|
|
100
|
+
// A first message goes only to one Saad has messaged lately: waking the
|
|
101
|
+
// rest re-reads their whole context for nothing, and they read the away
|
|
102
|
+
// state in their brief when they next start. One already told keeps its
|
|
103
|
+
// sweeps for the rest of the period.
|
|
104
|
+
if (!told) {
|
|
105
|
+
const s = sessionRow(orchestratorId);
|
|
106
|
+
if (!s || !saadMessagedRecently(s, now)) return false;
|
|
107
|
+
}
|
|
95
108
|
// One transaction: a crash can't leave the marker set with the event
|
|
96
109
|
// never queued.
|
|
97
110
|
db.transaction(() => {
|
|
@@ -133,8 +146,9 @@ export function turnAwayOn(
|
|
|
133
146
|
): AwayRow {
|
|
134
147
|
const { row, started } = startAway(input, now);
|
|
135
148
|
// Sent again when the end moves, so the orchestrator has the new one.
|
|
136
|
-
for (const { workspaceId, orchestratorId } of orchestrated())
|
|
149
|
+
for (const { workspaceId, orchestratorId } of orchestrated()) {
|
|
137
150
|
tellAway(row, workspaceId, orchestratorId, now, !started);
|
|
151
|
+
}
|
|
138
152
|
return row;
|
|
139
153
|
}
|
|
140
154
|
|
package/lib/chat/runner.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
getItem,
|
|
38
38
|
hasItem,
|
|
39
39
|
itemsOfKind,
|
|
40
|
+
recentTasks,
|
|
40
41
|
listItems,
|
|
41
42
|
saveItem,
|
|
42
43
|
settle,
|
|
@@ -865,11 +866,7 @@ export function watchChat(
|
|
|
865
866
|
// The composer lists background work still going or just done, even when
|
|
866
867
|
// it started before the page.
|
|
867
868
|
const recent = Date.now() - 60 * 60 * 1000;
|
|
868
|
-
const tasks =
|
|
869
|
-
(t) =>
|
|
870
|
-
!onPage.has(t.id) &&
|
|
871
|
-
(t.status === "running" || (t.endedAt ?? t.createdAt) > recent)
|
|
872
|
-
);
|
|
869
|
+
const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
|
|
873
870
|
listener({
|
|
874
871
|
type: "snapshot",
|
|
875
872
|
items: present(sessionId, page.items),
|
package/lib/chat/store.ts
CHANGED
|
@@ -176,6 +176,28 @@ export function itemsOfKind<K extends ChatItem["kind"]>(
|
|
|
176
176
|
).map((r) => JSON.parse(r.data));
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// A chat's background tasks still running, or ended after `since` (ms): what
|
|
180
|
+
// the composer lists, read without parsing the rest of a long conversation.
|
|
181
|
+
export function recentTasks(
|
|
182
|
+
sessionId: string,
|
|
183
|
+
since: number
|
|
184
|
+
): Extract<ChatItem, { kind: "task" }>[] {
|
|
185
|
+
flushItems();
|
|
186
|
+
return (
|
|
187
|
+
db
|
|
188
|
+
.prepare(
|
|
189
|
+
`SELECT data FROM chat_items WHERE session_id = ? AND kind = 'task'
|
|
190
|
+
AND (json_extract(data, '$.status') = 'running'
|
|
191
|
+
OR COALESCE(json_extract(data, '$.endedAt'),
|
|
192
|
+
json_extract(data, '$.createdAt')) > ?)
|
|
193
|
+
ORDER BY seq DESC LIMIT 200`
|
|
194
|
+
)
|
|
195
|
+
.all(sessionId, since) as { data: string }[]
|
|
196
|
+
)
|
|
197
|
+
.reverse()
|
|
198
|
+
.map((r) => JSON.parse(r.data));
|
|
199
|
+
}
|
|
200
|
+
|
|
179
201
|
// Tool calls saved as still running.
|
|
180
202
|
export function runningTools(
|
|
181
203
|
sessionId: string
|
package/lib/db/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { runMigrations } from "./migrations";
|
|
|
6
6
|
|
|
7
7
|
// Re-export types and queries
|
|
8
8
|
export * from "./types";
|
|
9
|
-
export { queries } from "./queries";
|
|
9
|
+
export { queries, sessionListColumns } from "./queries";
|
|
10
10
|
export * from "./lumifyhub";
|
|
11
11
|
export * from "./lumifyhub-docs";
|
|
12
12
|
export * from "./stacks";
|
package/lib/db/queries.ts
CHANGED
|
@@ -13,6 +13,28 @@ function getStmt(db: Database.Database, sql: string): Database.Statement {
|
|
|
13
13
|
return stmt;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// Every sessions column but the two large text ones, which only the single
|
|
17
|
+
// session paths read; a list gets has_brief for whether a brief exists.
|
|
18
|
+
const listColumns = new WeakMap<Database.Database, string>();
|
|
19
|
+
export function sessionListColumns(db: Database.Database, alias = ""): string {
|
|
20
|
+
let cols = listColumns.get(db);
|
|
21
|
+
if (!cols) {
|
|
22
|
+
cols = (
|
|
23
|
+
db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
|
|
24
|
+
)
|
|
25
|
+
.map((c) => c.name)
|
|
26
|
+
.filter((n) => n !== "task_brief" && n !== "system_prompt")
|
|
27
|
+
.join(",");
|
|
28
|
+
listColumns.set(db, cols);
|
|
29
|
+
}
|
|
30
|
+
const prefix = alias ? `${alias}.` : "";
|
|
31
|
+
const list = cols
|
|
32
|
+
.split(",")
|
|
33
|
+
.map((c) => `${prefix}${c}`)
|
|
34
|
+
.join(", ");
|
|
35
|
+
return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief`;
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
export const queries = {
|
|
17
39
|
// Sessions
|
|
18
40
|
createSession: (db: Database.Database) =>
|
|
@@ -26,7 +48,22 @@ export const queries = {
|
|
|
26
48
|
getStmt(db, `SELECT * FROM sessions WHERE id = ?`),
|
|
27
49
|
|
|
28
50
|
getAllSessions: (db: Database.Database) =>
|
|
29
|
-
getStmt(
|
|
51
|
+
getStmt(
|
|
52
|
+
db,
|
|
53
|
+
`SELECT ${sessionListColumns(db)} FROM sessions ORDER BY updated_at DESC`
|
|
54
|
+
),
|
|
55
|
+
|
|
56
|
+
// The sidebar's sessions: not archived, and not a task that is over or
|
|
57
|
+
// lives on another machine.
|
|
58
|
+
getSidebarSessions: (db: Database.Database) =>
|
|
59
|
+
getStmt(
|
|
60
|
+
db,
|
|
61
|
+
`SELECT ${sessionListColumns(db)} FROM sessions
|
|
62
|
+
WHERE archived_at IS NULL
|
|
63
|
+
AND (task_status IS NULL
|
|
64
|
+
OR task_status NOT IN ('merged', 'dropped', 'moved'))
|
|
65
|
+
ORDER BY updated_at DESC`
|
|
66
|
+
),
|
|
30
67
|
|
|
31
68
|
// The tmux names AgentOS owns, for telling discovered sessions apart.
|
|
32
69
|
getManagedTmuxNames: (db: Database.Database) =>
|
package/lib/db/types.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface Session {
|
|
|
51
51
|
setup_ms?: number | null;
|
|
52
52
|
setup_error?: string | null;
|
|
53
53
|
task_brief?: string | null;
|
|
54
|
+
// Lists carry this instead of the brief itself (0/1).
|
|
55
|
+
has_brief?: number;
|
|
54
56
|
// How the session is shown: a chat conversation or a terminal.
|
|
55
57
|
view: "chat" | "terminal";
|
|
56
58
|
chat_access: ChatAccess;
|
package/lib/done/archive.ts
CHANGED
|
@@ -44,7 +44,8 @@ export function unarchiveSession(id: string): Session {
|
|
|
44
44
|
export function listArchived(workspaceId?: string): ArchivedView[] {
|
|
45
45
|
const rows = db
|
|
46
46
|
.prepare(
|
|
47
|
-
`SELECT s
|
|
47
|
+
`SELECT s.id, s.name, s.project_id, s.view, s.task_status, s.branch_name,
|
|
48
|
+
s.pr_url, s.worktree_path, s.archived_at, p.name AS project_name, p.workspace_id AS project_workspace
|
|
48
49
|
FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
|
|
49
50
|
WHERE s.archived_at IS NOT NULL
|
|
50
51
|
AND (? IS NULL OR p.workspace_id = ?)
|
package/lib/needs-you.ts
CHANGED
|
@@ -56,7 +56,7 @@ function pendingApproval(
|
|
|
56
56
|
const row = getDb()
|
|
57
57
|
.prepare(
|
|
58
58
|
`SELECT data FROM chat_items WHERE session_id = ?
|
|
59
|
-
AND
|
|
59
|
+
AND kind = 'approval'
|
|
60
60
|
AND json_extract(data, '$.status') = 'pending'
|
|
61
61
|
ORDER BY seq DESC LIMIT 1`
|
|
62
62
|
)
|
|
@@ -324,7 +324,7 @@ export function buildDigest(
|
|
|
324
324
|
): string {
|
|
325
325
|
const checks = checksBySession(workspaceId);
|
|
326
326
|
const failures = failuresBySession(workspaceId);
|
|
327
|
-
const away = awayDigestLine(
|
|
327
|
+
const away = awayDigestLine();
|
|
328
328
|
const head = `## Where things stand (read from AgentOS's database as you started)${away ? `\n\n${away}` : ""}`;
|
|
329
329
|
const body = renderDigest(
|
|
330
330
|
[
|
package/lib/tasks/index.ts
CHANGED
|
@@ -410,7 +410,7 @@ export function movedTasks(): { id: string; movedTo: string | null }[] {
|
|
|
410
410
|
|
|
411
411
|
// Archived tasks aren't listed.
|
|
412
412
|
export async function listTasks(): Promise<TaskView[]> {
|
|
413
|
-
const sessions = taskSessions()
|
|
413
|
+
const sessions = taskSessions(false);
|
|
414
414
|
const [local, remote] = await Promise.all([
|
|
415
415
|
Promise.all(sessions.filter((s) => !isMirror(s)).map(taskView)),
|
|
416
416
|
remoteTaskViews(sessions.filter(isMirror)),
|
package/lib/tasks/session.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import os from "os";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
db,
|
|
5
|
+
queries,
|
|
6
|
+
sessionListColumns,
|
|
7
|
+
stackQueries,
|
|
8
|
+
type Session,
|
|
9
|
+
} from "../db";
|
|
4
10
|
import { getDefaultBranch, isBranchName } from "../git";
|
|
5
11
|
import { parentBranchOf } from "../stacks/guard";
|
|
6
12
|
import { voidApprovalsOn } from "../orchestrator/ask-approvals";
|
|
@@ -15,10 +21,14 @@ import type { TaskPR } from "./state";
|
|
|
15
21
|
|
|
16
22
|
export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
// Without the brief and system prompt: a list doesn't need them.
|
|
25
|
+
export function taskSessions(includeArchived = true): Session[] {
|
|
19
26
|
return db
|
|
20
27
|
.prepare(
|
|
21
|
-
`SELECT
|
|
28
|
+
`SELECT ${sessionListColumns(db)} FROM sessions
|
|
29
|
+
WHERE task_status IS NOT NULL AND task_status != 'moved'
|
|
30
|
+
${includeArchived ? "" : "AND archived_at IS NULL"}
|
|
31
|
+
ORDER BY created_at DESC`
|
|
22
32
|
)
|
|
23
33
|
.all() as Session[];
|
|
24
34
|
}
|