@saadnvd1/agent-os 0.3.851 → 0.3.859
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/leads/adopt/route.ts +35 -0
- package/app/api/leads/proposals/route.ts +52 -0
- package/app/api/leads/route.test.ts +95 -0
- package/app/api/leads/route.ts +60 -0
- package/app/api/sessions/[id]/fresh/route.ts +2 -1
- package/app/api/sessions/[id]/route.ts +9 -1
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
- package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
- package/components/Analyst/pair.ts +2 -1
- package/components/Chat/ChatPanel.tsx +1 -0
- package/components/Chat/Forks.tsx +24 -2
- package/components/Orchestrator/AskCard.tsx +6 -0
- package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
- package/components/Pane/index.tsx +5 -7
- package/components/Schedules/ScheduleForm.tsx +2 -1
- package/components/SessionList/RowSubtitle.tsx +2 -1
- package/components/SessionList/SessionRow.tsx +2 -1
- package/components/SessionList/useDoneAction.ts +8 -2
- package/components/SessionList/useSidebarData.ts +8 -3
- package/content/whats-new.json +24 -0
- package/data/done/index.ts +7 -1
- package/hooks/useNeedsYou.ts +10 -4
- package/hooks/useOrchestratorHeader.ts +2 -1
- package/lib/analyst/createLink.ts +3 -1
- package/lib/away/report.ts +2 -1
- package/lib/bus/format.ts +4 -1
- package/lib/bus/index.ts +9 -2
- package/lib/bus/read-scope.ts +4 -3
- package/lib/bus/resolve.ts +4 -1
- package/lib/bus/role-alias.ts +2 -1
- package/lib/bus/targets.ts +8 -3
- package/lib/bus/waits/tick.ts +2 -1
- package/lib/chat/compact-context.ts +2 -1
- package/lib/chat/mention-roots.ts +2 -1
- package/lib/chat/runner.ts +3 -2
- package/lib/chat/settings.ts +3 -2
- package/lib/chat/worker/extras.ts +4 -1
- package/lib/chat/worker/host.ts +6 -5
- package/lib/db/migrations/20261102T1944_leads.ts +142 -0
- package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
- package/lib/db/session-parts/columns.ts +2 -0
- package/lib/db/types.ts +10 -2
- package/lib/done/archive.ts +3 -2
- package/lib/done/auto-archive.ts +2 -1
- package/lib/done/bulk.ts +4 -3
- package/lib/done/index.ts +3 -2
- package/lib/done/plan.ts +5 -2
- package/lib/experiments/registry.ts +11 -0
- package/lib/experiments/view.ts +3 -1
- package/lib/fork/brief.ts +2 -2
- package/lib/fork/extras.ts +4 -2
- package/lib/fork/guard.ts +4 -2
- package/lib/fork/scratch.ts +102 -12
- package/lib/fork/settle.ts +31 -7
- package/lib/fork/start.ts +5 -4
- package/lib/fork/tick.ts +19 -0
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +10 -0
- package/lib/fork/worktree.ts +7 -3
- package/lib/health/env/sendFixNotes.ts +3 -2
- package/lib/hosts/peer-actions.ts +2 -1
- package/lib/hosts/peer-sessions.ts +3 -1
- package/lib/leads/actor.ts +16 -0
- package/lib/leads/adopt.ts +150 -0
- package/lib/leads/area.ts +77 -0
- package/lib/leads/brief-common.ts +122 -0
- package/lib/leads/brief.ts +155 -0
- package/lib/leads/charters.ts +99 -0
- package/lib/leads/deliver.ts +24 -0
- package/lib/leads/digest.ts +166 -0
- package/lib/leads/direction.ts +202 -0
- package/lib/leads/home.ts +196 -0
- package/lib/leads/index.ts +8 -0
- package/lib/leads/memos.ts +70 -0
- package/lib/leads/messages.ts +112 -0
- package/lib/leads/profiles.ts +83 -0
- package/lib/leads/roles.ts +114 -0
- package/lib/leads/settings.ts +55 -0
- package/lib/leads/templates.ts +51 -0
- package/lib/leads/tools.ts +159 -0
- package/lib/leads/types.ts +88 -0
- package/lib/leads/usage.ts +46 -0
- package/lib/learning-cards/input.ts +2 -1
- package/lib/live/drive.ts +2 -1
- package/lib/models/launch.ts +2 -1
- package/lib/models/session-role.ts +2 -1
- package/lib/needs-you.ts +12 -6
- package/lib/notify/index.ts +2 -1
- package/lib/orchestrator/act.ts +3 -1
- package/lib/orchestrator/ask-tool.ts +10 -0
- package/lib/orchestrator/ask-view.ts +2 -0
- package/lib/orchestrator/asks.ts +48 -8
- package/lib/orchestrator/brief.ts +32 -67
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/facts.ts +2 -1
- package/lib/orchestrator/full-access.ts +2 -1
- package/lib/orchestrator/home.ts +8 -7
- package/lib/orchestrator/mcp-servers.ts +7 -6
- package/lib/orchestrator/notes.ts +15 -4
- package/lib/orchestrator/overview.ts +3 -1
- package/lib/orchestrator/recipe-tools.ts +2 -1
- package/lib/orchestrator/reminders.ts +2 -1
- package/lib/orchestrator/tools.ts +43 -13
- package/lib/orchestrator/worker.ts +45 -13
- package/lib/orchestrator/write-guard.ts +2 -2
- package/lib/projects.ts +3 -2
- package/lib/resurrect/find.ts +2 -1
- package/lib/resurrect/index.ts +3 -2
- package/lib/schedules/index.ts +3 -2
- package/lib/schedules/start.ts +2 -1
- package/lib/schedules/store.ts +3 -2
- package/lib/session-audit/sources.ts +4 -3
- package/lib/session-groups/workspace.ts +2 -1
- package/lib/sessions/launch.ts +2 -1
- package/lib/sidebar/machines.ts +2 -1
- package/lib/sidebar/row-actions.ts +2 -1
- package/lib/sidebar/row-badge.ts +3 -1
- package/lib/sidebar/row-menu.ts +6 -3
- package/lib/sidebar/shelves.ts +3 -3
- package/lib/status/collect.ts +4 -3
- package/lib/tasks/held-replies.ts +2 -1
- package/lib/tasks/move-targets.ts +2 -1
- package/lib/tasks/pause.ts +2 -1
- package/lib/tasks/starter.ts +3 -2
- package/lib/workspaces.ts +4 -3
- package/lib/worktree-sweep/findSettled.ts +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
import { createSessionRowsView } from "../session-parts/view";
|
|
3
|
+
|
|
4
|
+
const columnsOf = (db: Parameters<Migration["up"]>[0], table: string) =>
|
|
5
|
+
(db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(
|
|
6
|
+
(c) => c.name
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
key: "20261102T1944_leads",
|
|
11
|
+
up: (db) => {
|
|
12
|
+
// Leads (lib/leads): a lead is a standing chat like the orchestrator,
|
|
13
|
+
// with role 'lead' and a lead_kind; the orchestrator is the 'tech' lead
|
|
14
|
+
// once its workspace adds leads, and a workspace has one 'chief'.
|
|
15
|
+
// lead_since is when it became one, so its usage counts from then.
|
|
16
|
+
// Additive: a workspace without leads has none of these rows.
|
|
17
|
+
const sessions = columnsOf(db, "sessions");
|
|
18
|
+
if (!sessions.includes("lead_kind"))
|
|
19
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN lead_kind TEXT`);
|
|
20
|
+
if (!sessions.includes("lead_since"))
|
|
21
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN lead_since TEXT`);
|
|
22
|
+
createSessionRowsView(db);
|
|
23
|
+
db.exec(
|
|
24
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_lead
|
|
25
|
+
ON sessions(workspace_id, lead_kind) WHERE role = 'lead'`
|
|
26
|
+
);
|
|
27
|
+
db.exec(
|
|
28
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chief
|
|
29
|
+
ON sessions(workspace_id) WHERE role = 'chief'`
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// A row means the workspace added leads. The chief's autonomy starts at
|
|
33
|
+
// acting only when asked; a later setting lifts it without code.
|
|
34
|
+
db.exec(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS workspace_leads (
|
|
36
|
+
workspace_id TEXT PRIMARY KEY,
|
|
37
|
+
enabled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
38
|
+
chief_autonomy TEXT NOT NULL DEFAULT 'on_request'
|
|
39
|
+
CHECK (chief_autonomy IN ('on_request', 'acts'))
|
|
40
|
+
)
|
|
41
|
+
`);
|
|
42
|
+
db.exec(`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS lead_charters (
|
|
44
|
+
workspace_id TEXT NOT NULL,
|
|
45
|
+
lead_kind TEXT NOT NULL,
|
|
46
|
+
title TEXT NOT NULL,
|
|
47
|
+
area_text TEXT NOT NULL DEFAULT '',
|
|
48
|
+
charter_md TEXT NOT NULL DEFAULT '',
|
|
49
|
+
goal TEXT NOT NULL DEFAULT '',
|
|
50
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
51
|
+
PRIMARY KEY (workspace_id, lead_kind)
|
|
52
|
+
)
|
|
53
|
+
`);
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS lead_areas (
|
|
56
|
+
workspace_id TEXT NOT NULL,
|
|
57
|
+
lead_kind TEXT NOT NULL,
|
|
58
|
+
kind TEXT NOT NULL CHECK (kind IN ('project', 'path', 'board')),
|
|
59
|
+
value TEXT NOT NULL,
|
|
60
|
+
PRIMARY KEY (workspace_id, lead_kind, kind, value)
|
|
61
|
+
)
|
|
62
|
+
`);
|
|
63
|
+
// Pinned direction quotes a chat item the person wrote; a newer pin
|
|
64
|
+
// supersedes an older one and nothing is deleted.
|
|
65
|
+
db.exec(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS lead_direction (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
workspace_id TEXT NOT NULL,
|
|
69
|
+
lead_kind TEXT NOT NULL,
|
|
70
|
+
session_id TEXT NOT NULL,
|
|
71
|
+
chat_item_id TEXT NOT NULL,
|
|
72
|
+
quote TEXT NOT NULL,
|
|
73
|
+
set_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
74
|
+
superseded_by INTEGER
|
|
75
|
+
)
|
|
76
|
+
`);
|
|
77
|
+
db.exec(
|
|
78
|
+
`CREATE INDEX IF NOT EXISTS idx_lead_direction
|
|
79
|
+
ON lead_direction(workspace_id, lead_kind, id)`
|
|
80
|
+
);
|
|
81
|
+
// Proposed pins (an adopted lead's, or a lead's suggestion) wait here
|
|
82
|
+
// until the person confirms or drops them.
|
|
83
|
+
db.exec(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS lead_pin_proposals (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
workspace_id TEXT NOT NULL,
|
|
87
|
+
lead_kind TEXT NOT NULL,
|
|
88
|
+
session_id TEXT NOT NULL,
|
|
89
|
+
chat_item_id TEXT NOT NULL,
|
|
90
|
+
quote TEXT NOT NULL,
|
|
91
|
+
status TEXT NOT NULL DEFAULT 'open'
|
|
92
|
+
CHECK (status IN ('open', 'pinned', 'dropped')),
|
|
93
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
94
|
+
resolved_at TEXT
|
|
95
|
+
)
|
|
96
|
+
`);
|
|
97
|
+
db.exec(
|
|
98
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_lead_pin_proposals_open
|
|
99
|
+
ON lead_pin_proposals(session_id, chat_item_id) WHERE status = 'open'`
|
|
100
|
+
);
|
|
101
|
+
db.exec(`
|
|
102
|
+
CREATE TABLE IF NOT EXISTS lead_memos (
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
workspace_id TEXT NOT NULL,
|
|
105
|
+
lead_kind TEXT NOT NULL,
|
|
106
|
+
text TEXT NOT NULL,
|
|
107
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
108
|
+
)
|
|
109
|
+
`);
|
|
110
|
+
db.exec(
|
|
111
|
+
`CREATE INDEX IF NOT EXISTS idx_lead_memos
|
|
112
|
+
ON lead_memos(workspace_id, lead_kind, id)`
|
|
113
|
+
);
|
|
114
|
+
db.exec(`
|
|
115
|
+
CREATE TABLE IF NOT EXISTS lead_messages (
|
|
116
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
117
|
+
workspace_id TEXT NOT NULL,
|
|
118
|
+
from_kind TEXT NOT NULL,
|
|
119
|
+
to_kind TEXT NOT NULL,
|
|
120
|
+
thread TEXT NOT NULL,
|
|
121
|
+
text TEXT NOT NULL,
|
|
122
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
123
|
+
read_at TEXT
|
|
124
|
+
)
|
|
125
|
+
`);
|
|
126
|
+
db.exec(
|
|
127
|
+
`CREATE INDEX IF NOT EXISTS idx_lead_messages_to
|
|
128
|
+
ON lead_messages(workspace_id, to_kind, id)`
|
|
129
|
+
);
|
|
130
|
+
db.exec(
|
|
131
|
+
`CREATE INDEX IF NOT EXISTS idx_lead_messages_thread
|
|
132
|
+
ON lead_messages(workspace_id, thread, id)`
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// The decision log and asks per lead; every existing row is Tech's.
|
|
136
|
+
for (const table of ["orchestrator_notes", "orchestrator_asks"])
|
|
137
|
+
if (!columnsOf(db, table).includes("lead_kind"))
|
|
138
|
+
db.exec(
|
|
139
|
+
`ALTER TABLE ${table} ADD COLUMN lead_kind TEXT NOT NULL DEFAULT 'tech'`
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261102T2300_needs_you_chat_per_session",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// The Needs-you "waiting in chat" item was keyed per workspace
|
|
7
|
+
// (approval:<workspace id>), so one lead's waiting chat would hide
|
|
8
|
+
// another's. It is keyed per session now; a hidden one stays hidden on
|
|
9
|
+
// the workspace's orchestrator, the only chat that showed it. Safe to
|
|
10
|
+
// repeat: a renamed key no longer matches.
|
|
11
|
+
db.exec(`
|
|
12
|
+
UPDATE OR IGNORE needs_you_dismissed
|
|
13
|
+
SET key = 'approval:' || (
|
|
14
|
+
SELECT s.id FROM sessions s
|
|
15
|
+
WHERE s.role = 'orchestrator'
|
|
16
|
+
AND s.workspace_id = needs_you_dismissed.workspace_id)
|
|
17
|
+
WHERE key = 'approval:' || workspace_id
|
|
18
|
+
AND EXISTS (
|
|
19
|
+
SELECT 1 FROM sessions s
|
|
20
|
+
WHERE s.role = 'orchestrator'
|
|
21
|
+
AND s.workspace_id = needs_you_dismissed.workspace_id)
|
|
22
|
+
`);
|
|
23
|
+
},
|
|
24
|
+
} satisfies Migration;
|
package/lib/db/types.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { AgentType } from "../providers";
|
|
2
2
|
import type { ChatAccess } from "../chat/events";
|
|
3
3
|
|
|
4
|
+
// The standing chats a workspace can have (lib/leads/roles.ts).
|
|
5
|
+
export type SessionRole = "orchestrator" | "lead" | "chief";
|
|
6
|
+
|
|
4
7
|
export interface Session {
|
|
5
8
|
id: string;
|
|
6
9
|
name: string;
|
|
@@ -118,8 +121,13 @@ export interface Session {
|
|
|
118
121
|
// The agent role it was started in (lib/models/agent-roles) and its crew.
|
|
119
122
|
agent_role?: string | null;
|
|
120
123
|
crew_id?: string | null;
|
|
121
|
-
// A workspace's standing
|
|
122
|
-
|
|
124
|
+
// A workspace's standing chat (and that workspace), or null: its
|
|
125
|
+
// orchestrator, and with Leads its other leads and chief (lib/leads).
|
|
126
|
+
role: SessionRole | null;
|
|
127
|
+
// A lead's department ('tech' for the orchestrator once leads are added)
|
|
128
|
+
// and when it became one.
|
|
129
|
+
lead_kind?: string | null;
|
|
130
|
+
lead_since?: string | null;
|
|
123
131
|
// An orchestrator's tool-call secret.
|
|
124
132
|
orch_token?: string | null;
|
|
125
133
|
workspace_id: string | null;
|
package/lib/done/archive.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Archived sessions: out of the sidebar, the needs-you count and the
|
|
2
2
|
// orchestrator's view, but kept with their history and chat items.
|
|
3
3
|
|
|
4
|
+
import { isStandingRole, roleNoun } from "../leads/roles";
|
|
4
5
|
import fs from "fs";
|
|
5
6
|
import { db, queries, type Session } from "../db";
|
|
6
7
|
import { statusOf } from "../orchestrator/facts";
|
|
@@ -102,9 +103,9 @@ export async function archiveChecked(
|
|
|
102
103
|
const s = queries.getSession(db).get(id) as Session | undefined;
|
|
103
104
|
if (!s) throw new Error("Session not found");
|
|
104
105
|
if (s.archived_at) throw new Error(`${s.name} is already archived`);
|
|
105
|
-
if (s.role
|
|
106
|
+
if (isStandingRole(s.role))
|
|
106
107
|
throw new Error(
|
|
107
|
-
`${s.name} is a workspace's
|
|
108
|
+
`${s.name} is a workspace's ${roleNoun(s.role)}; it can't be archived`
|
|
108
109
|
);
|
|
109
110
|
if (peerSessionLink(s))
|
|
110
111
|
throw new Error(`${s.name} runs on another machine: archive it there`);
|
package/lib/done/auto-archive.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// session, an orchestrator, one that needs input or is working, or one with
|
|
4
4
|
// an open PR. Mirrors the sidebar's Done placement (lib/sidebar/shelves).
|
|
5
5
|
|
|
6
|
+
import { sqlNoRole } from "../leads/roles";
|
|
6
7
|
import { db, type Session } from "../db";
|
|
7
8
|
import { fromSqliteTime } from "../session-meta";
|
|
8
9
|
import { statusOf } from "../orchestrator/facts";
|
|
@@ -34,7 +35,7 @@ export async function archiveStaleDone(now = Date.now()): Promise<string[]> {
|
|
|
34
35
|
const rows = db
|
|
35
36
|
.prepare(
|
|
36
37
|
`SELECT * FROM session_rows
|
|
37
|
-
WHERE archived_at IS NULL AND pinned = 0 AND
|
|
38
|
+
WHERE archived_at IS NULL AND pinned = 0 AND ${sqlNoRole()}
|
|
38
39
|
AND peer_mirror = 0 AND quick_of IS NULL
|
|
39
40
|
AND (pr_status IS NULL OR pr_status != 'open')`
|
|
40
41
|
)
|
package/lib/done/bulk.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* The preview says what will happen to each before anything does.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { sqlNoRole, isStandingRole, roleLabel } from "../leads/roles";
|
|
8
9
|
import { db, type Session } from "../db";
|
|
9
10
|
import { getProject } from "../projects";
|
|
10
11
|
import { workspaceProjects } from "../orchestrator/brief";
|
|
@@ -52,7 +53,7 @@ function scopeSessions(scope: DoneScope): Session[] {
|
|
|
52
53
|
.prepare(
|
|
53
54
|
`SELECT * FROM session_rows
|
|
54
55
|
WHERE project_id IN (${projectIds.map(() => "?").join(", ")})
|
|
55
|
-
AND
|
|
56
|
+
AND ${sqlNoRole()} AND archived_at IS NULL
|
|
56
57
|
-- A linked machine's own sessions are cleaned up there.
|
|
57
58
|
AND peer_mirror = 0
|
|
58
59
|
-- A task another machine's orchestrator moved here is its to finish.
|
|
@@ -80,9 +81,9 @@ export async function idleSessions(
|
|
|
80
81
|
export function scopeOf(session: Session): DoneScope {
|
|
81
82
|
// An orchestrator acts through its tools, which Pause holds and which are
|
|
82
83
|
// logged; aos would reach the same sessions past both.
|
|
83
|
-
if (session.role
|
|
84
|
+
if (isStandingRole(session.role))
|
|
84
85
|
throw new Error(
|
|
85
|
-
`${session.name} is
|
|
86
|
+
`${session.name} is ${roleLabel(session.role)}: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
|
|
86
87
|
);
|
|
87
88
|
const project = session.project_id ? getProject(session.project_id) : null;
|
|
88
89
|
if (!project || project.is_uncategorized)
|
package/lib/done/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* its own action, archive.ts). What it will do is decided first (plan.ts).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { isStandingRole, RoleLabel } from "../leads/roles";
|
|
11
12
|
import { endPauseOnFinish } from "../tasks/pause-hold";
|
|
12
13
|
import { db, queries, type Session } from "../db";
|
|
13
14
|
import { endSessionAgent } from "../sessions/end";
|
|
@@ -132,8 +133,8 @@ export async function doneSession(
|
|
|
132
133
|
// A demo archives and nothing more: no merge, no agent to stop, no
|
|
133
134
|
// worktree to touch.
|
|
134
135
|
if (demoMode()) {
|
|
135
|
-
if (s.role
|
|
136
|
-
throw new Error(
|
|
136
|
+
if (isStandingRole(s.role))
|
|
137
|
+
throw new Error(`${RoleLabel(s.role)} isn't marked done.`);
|
|
137
138
|
if (s.archived_at) throw new Error(`${s.name} is already archived.`);
|
|
138
139
|
if (s.task_prompt) setTask(db, s.id, { task_status: "done" });
|
|
139
140
|
archiveSession(s.id);
|
package/lib/done/plan.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* single done acts on it; the bulk preview shows it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { isStandingRole, roleNoun } from "../leads/roles";
|
|
7
8
|
import { db, stackQueries as sq, type Session } from "../db";
|
|
8
9
|
import type { TaskPR } from "../tasks";
|
|
9
10
|
import { prFor } from "../tasks/session";
|
|
@@ -90,8 +91,10 @@ export async function planDone(
|
|
|
90
91
|
s: Session,
|
|
91
92
|
callerId?: string | null
|
|
92
93
|
): Promise<DonePlan> {
|
|
93
|
-
if (s.role
|
|
94
|
-
return refuse(
|
|
94
|
+
if (isStandingRole(s.role))
|
|
95
|
+
return refuse(
|
|
96
|
+
`${s.name} is a workspace's ${roleNoun(s.role)}; it can't be done`
|
|
97
|
+
);
|
|
95
98
|
if (s.archived_at) return refuse(`${s.name} is already archived`);
|
|
96
99
|
if (callerId && callerId === s.id)
|
|
97
100
|
return refuse("A session can't mark itself done");
|
|
@@ -14,6 +14,8 @@ export interface Experiment {
|
|
|
14
14
|
status: ExperimentStatus;
|
|
15
15
|
// It shows suggestions, so an accept rate means something for it.
|
|
16
16
|
suggests?: boolean;
|
|
17
|
+
// Built but not offered in Settings yet: it can be set, nothing lists it.
|
|
18
|
+
unlisted?: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const EXPERIMENTS = [
|
|
@@ -34,6 +36,15 @@ export const EXPERIMENTS = [
|
|
|
34
36
|
area: "sessions",
|
|
35
37
|
status: "experimental",
|
|
36
38
|
},
|
|
39
|
+
{
|
|
40
|
+
id: "leads",
|
|
41
|
+
name: "Leads",
|
|
42
|
+
description:
|
|
43
|
+
"Department heads and a chief of staff, per workspace. Off, nothing changes. On, you choose which workspaces get leads; each gets its own chief.",
|
|
44
|
+
area: "orchestrator",
|
|
45
|
+
status: "experimental",
|
|
46
|
+
unlisted: true,
|
|
47
|
+
},
|
|
37
48
|
] as const satisfies readonly Experiment[];
|
|
38
49
|
|
|
39
50
|
export type ExperimentId = (typeof EXPERIMENTS)[number]["id"];
|
package/lib/experiments/view.ts
CHANGED
|
@@ -15,5 +15,7 @@ export const experimentView = (id: ExperimentId): ExperimentView => ({
|
|
|
15
15
|
|
|
16
16
|
export const experimentsView = () => ({
|
|
17
17
|
locked: experimentsLocked(),
|
|
18
|
-
experiments: EXPERIMENTS.
|
|
18
|
+
experiments: EXPERIMENTS.filter((e: Experiment) => !e.unlisted).map((e) =>
|
|
19
|
+
experimentView(e.id)
|
|
20
|
+
),
|
|
19
21
|
});
|
package/lib/fork/brief.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { FORK_LIMITS } from "./options";
|
|
4
4
|
import type { ChildRow } from "./types";
|
|
5
5
|
|
|
6
|
-
export const PARENT_BRIEF = `Forks: when the user asks you to fork, or to do things in parallel in background copies, run \`aos fork "<instruction>" ...\` (one instruction per fork), keep working, and results arrive as a message. Also when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own
|
|
6
|
+
export const PARENT_BRIEF = `Forks: when the user asks you to fork, or to do things in parallel in background copies, run \`aos fork "<instruction>" ...\` (one instruction per fork), keep working, and results arrive as a message. Also when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own checkout). Fork only work that doesn't depend on your next step.`;
|
|
7
7
|
|
|
8
8
|
export const CHILD_BRIEF = `You are a fork child: a background copy of another session, started to do one piece of work and report back. You have its abilities, not its checkout: don't change the parent's files, push, open a PR or fork. Put temp work (scripts, repros, notes) in $AOS_SCRATCH. Run builds and tests only through \`aos heavy -- <command>\`, which queues you with the other forks. To ask the parent something, \`aos send <parent> "<question>"\`; that doesn't finish you. When done, \`aos return "<result>"\` (first line: the answer in a sentence; then the detail; \`--failed\` if you couldn't do it). Your reply when you end your turn without it is used as the result, after a short wait.`;
|
|
9
9
|
|
|
@@ -22,7 +22,7 @@ export function childFirstMessage(opts: {
|
|
|
22
22
|
opts.instruction.trim(),
|
|
23
23
|
"",
|
|
24
24
|
opts.worktree && opts.checkout
|
|
25
|
-
? `You work in your own checkout (${opts.checkout}), a copy of the parent's changes as they were when you started. Scratch folder: ${opts.scratch}.`
|
|
25
|
+
? `You work in your own checkout (${opts.checkout}), a copy of the parent's changes as they were when you started. Scratch folder: ${opts.scratch}. Both are kept after you return.`
|
|
26
26
|
: `You work in the parent's checkout: read freely, but don't change it. Scratch folder: ${opts.scratch} ($AOS_SCRATCH).`,
|
|
27
27
|
"Finish with `aos return`.",
|
|
28
28
|
];
|
package/lib/fork/extras.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** What a chat worker adds for fork parents and children. */
|
|
2
2
|
|
|
3
|
+
import { isStandingRole } from "../leads/roles";
|
|
3
4
|
import os from "os";
|
|
4
5
|
import type { Session } from "../db";
|
|
5
6
|
import type { ToolGuard } from "../chat/driver";
|
|
@@ -7,7 +8,7 @@ import { CHILD_BRIEF, PARENT_BRIEF } from "./brief";
|
|
|
7
8
|
import { forkToolGuard } from "./guard";
|
|
8
9
|
import { getChild } from "./ledger";
|
|
9
10
|
import { forksOn } from "./options";
|
|
10
|
-
import { scratchOf, worktreeOf } from "./scratch";
|
|
11
|
+
import { forksRoot, scratchOf, worktreeOf } from "./scratch";
|
|
11
12
|
import { db } from "../db";
|
|
12
13
|
|
|
13
14
|
const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
|
|
@@ -16,7 +17,7 @@ const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
|
|
|
16
17
|
// parent, so their prompts match as far as they can).
|
|
17
18
|
export function forkBrief(session: Session): string | null {
|
|
18
19
|
if (getChild(session.id)) return CHILD_BRIEF;
|
|
19
|
-
if (!forksOn() || session.role
|
|
20
|
+
if (!forksOn() || isStandingRole(session.role) || session.view !== "chat")
|
|
20
21
|
return null;
|
|
21
22
|
return PARENT_BRIEF;
|
|
22
23
|
}
|
|
@@ -46,6 +47,7 @@ export function childExtras(
|
|
|
46
47
|
parent?.working_directory ?? session.working_directory
|
|
47
48
|
),
|
|
48
49
|
scratch,
|
|
50
|
+
forksRoot: forksRoot(),
|
|
49
51
|
ownCheckout: child.worktree ? worktreeOf(session.id) : null,
|
|
50
52
|
cwd: expand(session.working_directory),
|
|
51
53
|
}),
|
package/lib/fork/guard.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface ForkGuardContext {
|
|
|
27
27
|
parentCheckout: string;
|
|
28
28
|
// The child's scratch folder: free to write.
|
|
29
29
|
scratch: string;
|
|
30
|
+
// Where every fork's folders live; the others' are never written.
|
|
31
|
+
forksRoot: string;
|
|
30
32
|
// Its own throwaway checkout, when it has one: free to write.
|
|
31
33
|
ownCheckout: string | null;
|
|
32
34
|
// Where the child runs; relative paths start here.
|
|
@@ -39,7 +41,7 @@ const WHERE = (c: ForkGuardContext) =>
|
|
|
39
41
|
: `put temp work in ${c.scratch} (AOS_SCRATCH), or ask for \`aos fork --worktree\` to edit code`;
|
|
40
42
|
|
|
41
43
|
const noWrite = (c: ForkGuardContext, what: string) =>
|
|
42
|
-
`Refused: ${what} would change the parent's checkout, which
|
|
44
|
+
`Refused: ${what} would change the parent's checkout or another fork's folder, which they are working in. ${WHERE(c)}.`;
|
|
43
45
|
|
|
44
46
|
// Commands whose every path argument is something they change.
|
|
45
47
|
const MUTATORS = new Set([
|
|
@@ -134,7 +136,7 @@ async function protectedPath(
|
|
|
134
136
|
c.ownCheckout ? physicalPath(c.ownCheckout) : Promise.resolve(null),
|
|
135
137
|
]);
|
|
136
138
|
if (within(r, scratch) || (own && within(r, own))) return false;
|
|
137
|
-
return within(r, parent);
|
|
139
|
+
return within(r, parent) || within(r, await physicalPath(c.forksRoot));
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
// A file tool's refusal, or null.
|
package/lib/fork/scratch.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A fork child's own folders under the data root: a scratch folder for temp
|
|
3
|
-
* work (scripts, repros, notes), and, when it was asked for,
|
|
4
|
-
* checkout.
|
|
5
|
-
*
|
|
3
|
+
* work (scripts, repros, notes), and, when it was asked for, its own
|
|
4
|
+
* checkout. Settling a child removes nothing: they are kept while its
|
|
5
|
+
* session is open, and tidied up after it closes only when nothing in them
|
|
6
|
+
* would be lost. Deleting the child's session is the one thing that removes
|
|
7
|
+
* them whatever they hold.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import fs from "fs";
|
|
9
11
|
import path from "path";
|
|
10
12
|
import { agentosHome } from "../agentos-home";
|
|
11
13
|
import { git } from "../git-exec";
|
|
14
|
+
import { describeChanges } from "../worktree-placed";
|
|
12
15
|
|
|
13
16
|
export const forksRoot = () => agentosHome("forks");
|
|
14
17
|
export const forkHome = (childId: string) => agentosHome("forks", childId);
|
|
@@ -17,6 +20,89 @@ export const scratchOf = (childId: string) =>
|
|
|
17
20
|
export const worktreeOf = (childId: string) =>
|
|
18
21
|
path.join(forkHome(childId), "wt");
|
|
19
22
|
|
|
23
|
+
// The tree the checkout started from (the parent's changes and what setup
|
|
24
|
+
// placed included), so what the child changed can be told from it.
|
|
25
|
+
export const baseOf = (childId: string) => path.join(forkHome(childId), "base");
|
|
26
|
+
|
|
27
|
+
const MAX_LISTED = 50;
|
|
28
|
+
|
|
29
|
+
// A tree of the checkout as it is now, taken through a temporary index so
|
|
30
|
+
// the child's own index is left alone. Ignored files aren't in it.
|
|
31
|
+
export async function snapshotTree(
|
|
32
|
+
wt: string,
|
|
33
|
+
childId: string
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
const index = path.join(forkHome(childId), "index.tmp");
|
|
36
|
+
const env = { GIT_INDEX_FILE: index };
|
|
37
|
+
try {
|
|
38
|
+
await git(wt, ["read-tree", "HEAD"], { env });
|
|
39
|
+
await git(wt, ["add", "-A", "--", ".", ":(exclude)node_modules"], { env });
|
|
40
|
+
return (await git(wt, ["write-tree"], { env })).trim();
|
|
41
|
+
} finally {
|
|
42
|
+
await fs.promises.rm(index, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function filesUnder(dir: string, rel = ""): Promise<string[]> {
|
|
47
|
+
const out: string[] = [];
|
|
48
|
+
const entries = await fs.promises
|
|
49
|
+
.readdir(path.join(dir, rel), { withFileTypes: true })
|
|
50
|
+
.catch(() => []);
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (out.length >= MAX_LISTED) break;
|
|
53
|
+
const r = path.join(rel, e.name);
|
|
54
|
+
if (e.isDirectory()) out.push(...(await filesUnder(dir, r)));
|
|
55
|
+
else out.push(r);
|
|
56
|
+
}
|
|
57
|
+
return out.slice(0, MAX_LISTED);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// What removing the child's folders would lose: files in its scratch, and
|
|
61
|
+
// changes in its checkout since it started. Null when that can't be told.
|
|
62
|
+
export async function forkLosses(childId: string): Promise<string[] | null> {
|
|
63
|
+
const lost = (await filesUnder(scratchOf(childId))).map(
|
|
64
|
+
(f) => `scratch/${f}`
|
|
65
|
+
);
|
|
66
|
+
const wt = worktreeOf(childId);
|
|
67
|
+
if (fs.existsSync(wt)) {
|
|
68
|
+
try {
|
|
69
|
+
const base = (
|
|
70
|
+
await fs.promises.readFile(baseOf(childId), "utf8").catch(() => "")
|
|
71
|
+
).trim();
|
|
72
|
+
if (base) {
|
|
73
|
+
const now = await snapshotTree(wt, childId);
|
|
74
|
+
const names = await git(wt, ["diff", "--name-only", base, now]);
|
|
75
|
+
lost.push(...names.split("\n").filter(Boolean));
|
|
76
|
+
} else {
|
|
77
|
+
const st = await git(wt, ["status", "--porcelain"]);
|
|
78
|
+
lost.push(
|
|
79
|
+
...st
|
|
80
|
+
.split("\n")
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map((l) => l.slice(3))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return lost;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Removes the child's folders only when nothing in them would be lost.
|
|
93
|
+
export async function tidyFork(childId: string): Promise<boolean> {
|
|
94
|
+
if (!fs.existsSync(forkHome(childId))) return true;
|
|
95
|
+
const lost = await forkLosses(childId);
|
|
96
|
+
if (lost?.length === 0) {
|
|
97
|
+
await removeFork(childId);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
console.log(
|
|
101
|
+
`[forks] kept ${childId}: ${lost ? describeChanges(lost) : "it couldn't be checked"}`
|
|
102
|
+
);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
20
106
|
// Made on demand, so a settled child that is talked to again has one.
|
|
21
107
|
export async function ensureScratch(childId: string): Promise<string> {
|
|
22
108
|
const dir = scratchOf(childId);
|
|
@@ -50,18 +136,22 @@ export async function removeFork(childId: string): Promise<void> {
|
|
|
50
136
|
await fs.promises.rm(home, { recursive: true, force: true, maxRetries: 3 });
|
|
51
137
|
}
|
|
52
138
|
|
|
53
|
-
// Folders of children
|
|
54
|
-
//
|
|
139
|
+
// Folders of children whose session is closed or gone, tidied the same safe
|
|
140
|
+
// way: what a crash left, and children closed after they settled. `keep`
|
|
141
|
+
// says whose folders are still in use. Patches beside the folders stay.
|
|
55
142
|
export async function sweepForks(
|
|
56
143
|
keep: (childId: string) => boolean
|
|
57
|
-
): Promise<number> {
|
|
144
|
+
): Promise<{ removed: number; kept: number }> {
|
|
58
145
|
const root = forksRoot();
|
|
59
|
-
const
|
|
146
|
+
const entries = await fs.promises
|
|
147
|
+
.readdir(root, { withFileTypes: true })
|
|
148
|
+
.catch(() => []);
|
|
60
149
|
let removed = 0;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
removed++;
|
|
150
|
+
let kept = 0;
|
|
151
|
+
for (const e of entries) {
|
|
152
|
+
if (!e.isDirectory() || keep(e.name)) continue;
|
|
153
|
+
if (await tidyFork(e.name)) removed++;
|
|
154
|
+
else kept++;
|
|
65
155
|
}
|
|
66
|
-
return removed;
|
|
156
|
+
return { removed, kept };
|
|
67
157
|
}
|
package/lib/fork/settle.ts
CHANGED
|
@@ -26,7 +26,15 @@ import {
|
|
|
26
26
|
markDeliveredIds,
|
|
27
27
|
} from "./ledger";
|
|
28
28
|
import { FORK_LIMITS } from "./options";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
baseOf,
|
|
31
|
+
forkLosses,
|
|
32
|
+
forksRoot,
|
|
33
|
+
scratchOf,
|
|
34
|
+
snapshotTree,
|
|
35
|
+
tidyFork,
|
|
36
|
+
worktreeOf,
|
|
37
|
+
} from "./scratch";
|
|
30
38
|
import type { ChildOutcome, ChildRow, ResultKind } from "./types";
|
|
31
39
|
|
|
32
40
|
const sessionOf = (id: string) =>
|
|
@@ -53,14 +61,17 @@ function tokensOf(childId: string) {
|
|
|
53
61
|
export const patchPath = (childId: string) =>
|
|
54
62
|
path.join(forksRoot(), `${childId}.patch`);
|
|
55
63
|
|
|
56
|
-
// What a child changed in its own checkout
|
|
57
|
-
//
|
|
64
|
+
// What a child changed in its own checkout (not what the parent had when it
|
|
65
|
+
// started), kept as a patch the parent can apply.
|
|
58
66
|
async function keepPatch(childId: string): Promise<string | null> {
|
|
59
67
|
const wt = worktreeOf(childId);
|
|
60
68
|
if (!fs.existsSync(wt)) return null;
|
|
61
69
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
const base = (
|
|
71
|
+
await fs.promises.readFile(baseOf(childId), "utf8").catch(() => "")
|
|
72
|
+
).trim();
|
|
73
|
+
const now = await snapshotTree(wt, childId);
|
|
74
|
+
const diff = await git(wt, ["diff", base || "HEAD", now, "--binary"], {
|
|
64
75
|
maxBuffer: 64 * 1024 * 1024,
|
|
65
76
|
});
|
|
66
77
|
if (!diff.trim()) return null;
|
|
@@ -89,12 +100,19 @@ export async function settleAndDeliver(
|
|
|
89
100
|
const row = getChild(childId);
|
|
90
101
|
if (!row || row.state === "settled") return false;
|
|
91
102
|
let result = s.result ?? null;
|
|
92
|
-
if (row.worktree
|
|
103
|
+
if (row.worktree) {
|
|
93
104
|
const patch = await keepPatch(childId);
|
|
94
105
|
if (patch)
|
|
95
106
|
result =
|
|
96
107
|
`${result ?? ""}\n\nIts changes are in ${patch} (apply with \`git apply ${patch}\`).`.trim();
|
|
97
108
|
}
|
|
109
|
+
if (
|
|
110
|
+
(await forkLosses(childId).catch(() => null))?.some((f) =>
|
|
111
|
+
f.startsWith("scratch/")
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
result =
|
|
115
|
+
`${result ?? ""}\n\nIts scratch files are in ${scratchOf(childId)}.`.trim();
|
|
98
116
|
const changed = settleChild(childId, {
|
|
99
117
|
...s,
|
|
100
118
|
result,
|
|
@@ -103,7 +121,11 @@ export async function settleAndDeliver(
|
|
|
103
121
|
if (!changed) return false;
|
|
104
122
|
// Cancelled by its parent or the user: they know, so nothing is sent.
|
|
105
123
|
if (s.outcome === "cancelled") markDeliveredIds([childId]);
|
|
106
|
-
|
|
124
|
+
// Its folders stay while its session is open; a closed one's go only
|
|
125
|
+
// when nothing in them would be lost.
|
|
126
|
+
const session = sessionOf(childId);
|
|
127
|
+
if (!session || session.archived_at)
|
|
128
|
+
await tidyFork(childId).catch(() => undefined);
|
|
107
129
|
const { admitQueued } = await import("./start");
|
|
108
130
|
await admitQueued(row.parent_session_id).catch(() => undefined);
|
|
109
131
|
const { kickForks } = await import("./tick");
|
|
@@ -175,6 +197,8 @@ const lastReply = (childId: string): string => {
|
|
|
175
197
|
export async function lookAtChild(row: ChildRow, now: number): Promise<void> {
|
|
176
198
|
if (row.state === "queued" || row.state === "settled") return;
|
|
177
199
|
const session = sessionOf(row.child_session_id);
|
|
200
|
+
// Its checkout is still being made: no session row yet.
|
|
201
|
+
if (!session && row.state === "starting") return;
|
|
178
202
|
if (!session || session.archived_at) {
|
|
179
203
|
await settleAndDeliver(row.child_session_id, {
|
|
180
204
|
outcome: "failed",
|