@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
package/lib/fork/start.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* holds a slot never waits on its own children.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isStandingRole, RoleLabel } from "../leads/roles";
|
|
8
9
|
import os from "os";
|
|
9
10
|
import { randomUUID } from "crypto";
|
|
10
11
|
import { db, queries, type Session } from "../db";
|
|
@@ -34,7 +35,7 @@ import {
|
|
|
34
35
|
setState,
|
|
35
36
|
} from "./ledger";
|
|
36
37
|
import { FORK_LIMITS, FORKS_OFF, forksOn, type DeliveryMode } from "./options";
|
|
37
|
-
import { ensureScratch,
|
|
38
|
+
import { ensureScratch, tidyFork } from "./scratch";
|
|
38
39
|
import { parentSnapshot } from "./snapshot";
|
|
39
40
|
import { createForkWorktree } from "./worktree";
|
|
40
41
|
import type { ChildRow } from "./types";
|
|
@@ -66,8 +67,8 @@ export function refusal(parent: Session | undefined): string | null {
|
|
|
66
67
|
if (!forksOn()) return FORKS_OFF;
|
|
67
68
|
if (!parent) return "Unknown session";
|
|
68
69
|
if (getChild(parent.id)) return "A fork child can't fork";
|
|
69
|
-
if (parent.role
|
|
70
|
-
return
|
|
70
|
+
if (isStandingRole(parent.role))
|
|
71
|
+
return `${RoleLabel(parent.role)} doesn't fork: start tasks or sessions instead`;
|
|
71
72
|
if (parent.archived_at) return "That session is archived";
|
|
72
73
|
if (parent.view !== "chat") return "Forks work from a chat session";
|
|
73
74
|
if (parent.host_id && parent.host_id !== "local")
|
|
@@ -266,7 +267,7 @@ async function startChild(childId: string): Promise<void> {
|
|
|
266
267
|
} catch (error) {
|
|
267
268
|
const why = error instanceof Error ? error.message : String(error);
|
|
268
269
|
const { settleAndDeliver } = await import("./settle");
|
|
269
|
-
await
|
|
270
|
+
await tidyFork(childId).catch(() => undefined);
|
|
270
271
|
await settleAndDeliver(childId, {
|
|
271
272
|
outcome: "failed",
|
|
272
273
|
summary: `couldn't start: ${why.split("\n")[0]}`,
|
package/lib/fork/tick.ts
CHANGED
|
@@ -7,12 +7,30 @@ import { deliverDue } from "./deliver";
|
|
|
7
7
|
import { lookAtChild } from "./settle";
|
|
8
8
|
import { tickForkWaits } from "./waits";
|
|
9
9
|
import { forksOn } from "./options";
|
|
10
|
+
import { sweepForks } from "./scratch";
|
|
10
11
|
import type { ChildRow } from "./types";
|
|
11
12
|
|
|
12
13
|
const EVERY_MS = 5000;
|
|
13
14
|
let running = false;
|
|
14
15
|
let timer: NodeJS.Timeout | null = null;
|
|
15
16
|
let kicked = false;
|
|
17
|
+
const SWEEP_EVERY_MS = 10 * 60_000;
|
|
18
|
+
let sweptAt = 0;
|
|
19
|
+
|
|
20
|
+
// Folders of children whose session closed after they settled, or a crash.
|
|
21
|
+
async function sweepClosed(now: number): Promise<void> {
|
|
22
|
+
if (now - sweptAt < SWEEP_EVERY_MS) return;
|
|
23
|
+
sweptAt = now;
|
|
24
|
+
const live = db.prepare(
|
|
25
|
+
`SELECT 1 FROM session_children c
|
|
26
|
+
LEFT JOIN sessions s ON s.id = c.child_session_id
|
|
27
|
+
WHERE c.child_session_id = ?
|
|
28
|
+
AND (c.state != 'settled' OR (s.id IS NOT NULL AND s.archived_at IS NULL))`
|
|
29
|
+
);
|
|
30
|
+
await sweepForks((id) => !!live.get(id)).catch((error) =>
|
|
31
|
+
console.error("[forks] sweep:", error)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
16
34
|
|
|
17
35
|
export async function forksTick(now = Date.now()): Promise<void> {
|
|
18
36
|
if (running || !forksOn()) return;
|
|
@@ -33,6 +51,7 @@ export async function forksTick(now = Date.now()): Promise<void> {
|
|
|
33
51
|
await admitQueued(parent);
|
|
34
52
|
await tickForkWaits(now);
|
|
35
53
|
await deliverDue(now);
|
|
54
|
+
await sweepClosed(now);
|
|
36
55
|
} finally {
|
|
37
56
|
running = false;
|
|
38
57
|
}
|
package/lib/fork/types.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface ChildView {
|
|
|
65
65
|
excerpt: string | null;
|
|
66
66
|
resultKind: ResultKind | null;
|
|
67
67
|
worktree: boolean;
|
|
68
|
+
// Marked done or archived as a session: it leaves the strip.
|
|
69
|
+
done: boolean;
|
|
68
70
|
fellBack: boolean;
|
|
69
71
|
startedAt: number | null;
|
|
70
72
|
settledAt: number | null;
|
package/lib/fork/view.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** What clients and `aos children` show, and the numbers behind them. */
|
|
2
2
|
|
|
3
3
|
import { lastItems } from "../chat/store";
|
|
4
|
+
import { db } from "../db";
|
|
4
5
|
import { childrenOf, getGroup } from "./ledger";
|
|
5
6
|
import type { ChildRow, ChildView, GroupView } from "./types";
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ export const childView = (c: ChildRow): ChildView => ({
|
|
|
17
18
|
excerpt: c.result ? c.result.slice(0, 600) : null,
|
|
18
19
|
resultKind: c.result_kind,
|
|
19
20
|
worktree: !!c.worktree,
|
|
21
|
+
done: false,
|
|
20
22
|
fellBack: !!c.fell_back,
|
|
21
23
|
startedAt: c.started_at,
|
|
22
24
|
settledAt: c.settled_at,
|
|
@@ -32,6 +34,13 @@ const activityOf = (c: ChildRow): string | null => {
|
|
|
32
34
|
return tool && tool.kind === "tool" ? tool.title : null;
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
const isDone = (id: string): boolean =>
|
|
38
|
+
!!db
|
|
39
|
+
.prepare(
|
|
40
|
+
`SELECT 1 FROM sessions WHERE id = ? AND (settled_at IS NOT NULL OR archived_at IS NOT NULL)`
|
|
41
|
+
)
|
|
42
|
+
.get(id);
|
|
43
|
+
|
|
35
44
|
export function childrenView(parentId: string): {
|
|
36
45
|
children: ChildView[];
|
|
37
46
|
groups: GroupView[];
|
|
@@ -57,6 +66,7 @@ export function childrenView(parentId: string): {
|
|
|
57
66
|
children: rows.map((c, i) => ({
|
|
58
67
|
...childView(c),
|
|
59
68
|
n: i + 1,
|
|
69
|
+
done: isDone(c.child_session_id),
|
|
60
70
|
activity: activityOf(c),
|
|
61
71
|
})),
|
|
62
72
|
groups,
|
package/lib/fork/worktree.ts
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* A throwaway checkout for a fork child that has to edit code to find its
|
|
3
3
|
* answer (`aos fork --worktree`): detached at the parent's HEAD, with the
|
|
4
4
|
* parent's uncommitted changes applied on top, so it sees what the parent
|
|
5
|
-
* sees. No branch is made and nothing from it is merged; it is
|
|
6
|
-
* the child
|
|
5
|
+
* sees. No branch is made and nothing from it is merged; it is kept until
|
|
6
|
+
* the child's session is closed with nothing changed in it.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { git } from "../git-exec";
|
|
12
12
|
import { setupWorktree } from "../env-setup";
|
|
13
|
-
import { forkHome, worktreeOf } from "./scratch";
|
|
13
|
+
import { baseOf, forkHome, snapshotTree, worktreeOf } from "./scratch";
|
|
14
14
|
|
|
15
15
|
const MAX_UNTRACKED = 200;
|
|
16
16
|
const MAX_UNTRACKED_BYTES = 5_000_000;
|
|
@@ -70,6 +70,10 @@ export async function createForkWorktree(opts: {
|
|
|
70
70
|
throw new Error(
|
|
71
71
|
setup.steps.find((s) => !s.success)?.error ?? "worktree setup failed"
|
|
72
72
|
);
|
|
73
|
+
await fs.promises.writeFile(
|
|
74
|
+
baseOf(opts.childId),
|
|
75
|
+
await snapshotTree(wt, opts.childId)
|
|
76
|
+
);
|
|
73
77
|
return {
|
|
74
78
|
cwd: path.join(wt, path.relative(top, cwd)),
|
|
75
79
|
root: wt,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { db } from "@/lib/db";
|
|
2
|
+
import type { SessionRole } from "@/lib/db/types";
|
|
2
3
|
import { MIN } from "../types";
|
|
3
4
|
import { fixFor } from "./fixText";
|
|
4
5
|
import { classById } from "./classes";
|
|
@@ -10,11 +11,11 @@ export const STALE_MS = 120 * MIN;
|
|
|
10
11
|
export interface NoteDeps {
|
|
11
12
|
send: (sessionId: string, body: string) => Promise<void>;
|
|
12
13
|
// Waiting on a person: an approval or a question.
|
|
13
|
-
blocked: (sessionId: string, role:
|
|
14
|
+
blocked: (sessionId: string, role: SessionRole | null) => boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
interface Target {
|
|
17
|
-
role:
|
|
18
|
+
role: SessionRole | null;
|
|
18
19
|
archived_at: string | null;
|
|
19
20
|
task_status: string | null;
|
|
20
21
|
pause_state: string | null;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* (the menu shows the same reasons: lib/hosts/remote-menu.ts).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isPeerStanding } from "../leads/roles";
|
|
8
9
|
import { db, type Session } from "../db";
|
|
9
10
|
import { stopChat } from "../chat/runner";
|
|
10
11
|
import { deleteItems } from "../chat/store";
|
|
@@ -151,7 +152,7 @@ export async function doneOnPeer(
|
|
|
151
152
|
throw new Error(
|
|
152
153
|
`${session.name} is a task on ${link.hostName}: sign it off or drop it in Tasks there.`
|
|
153
154
|
);
|
|
154
|
-
if (there.role)
|
|
155
|
+
if (isPeerStanding(there.role))
|
|
155
156
|
throw new Error(`${session.name} isn't marked done on ${link.hostName}.`);
|
|
156
157
|
const { outcome } = await hostApi<{
|
|
157
158
|
outcome?: { text?: unknown; worktree?: { action?: unknown } };
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* used here are kept, checked and cleaned.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isPeerStanding } from "../leads/roles";
|
|
8
9
|
import type { TmuxSessionInfo } from "../status-detector";
|
|
9
10
|
import { isValidTmuxName } from "./attach";
|
|
10
11
|
import { syncPeerMirrors } from "./peer-sync";
|
|
@@ -113,7 +114,8 @@ export function toPeerSession(
|
|
|
113
114
|
if (raw.host_id && raw.host_id !== "local") return null;
|
|
114
115
|
// Its orchestrator and its tasks belong to its own workspace and gates:
|
|
115
116
|
// they're not mirrored as plain sessions here.
|
|
116
|
-
if (raw.role || raw.task_prompt || raw.task_status)
|
|
117
|
+
if (isPeerStanding(raw.role) || raw.task_prompt || raw.task_status)
|
|
118
|
+
return null;
|
|
117
119
|
const tmuxName = typeof raw.tmux_name === "string" ? raw.tmux_name : "";
|
|
118
120
|
const view = raw.view === "chat" ? "chat" : "terminal";
|
|
119
121
|
// A terminal is opened by its tmux name; any name it gives must be one.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
import type { Session } from "@/lib/db/types";
|
|
3
|
+
|
|
4
|
+
// The standing session whose tool call is running: what it sends, starts and
|
|
5
|
+
// notes is its own, not the orchestrator's. Outside a tool call (an event, a
|
|
6
|
+
// route, a timer) there is none, and everything stays the orchestrator's.
|
|
7
|
+
const acting = new AsyncLocalStorage<Session>();
|
|
8
|
+
|
|
9
|
+
export const asActor = <T>(session: Session, fn: () => T): T =>
|
|
10
|
+
acting.run(session, fn);
|
|
11
|
+
|
|
12
|
+
// The caller in this workspace, if a standing session's call is running.
|
|
13
|
+
export function actorIn(workspaceId: string): Session | undefined {
|
|
14
|
+
const s = acting.getStore();
|
|
15
|
+
return s && s.workspace_id === workspaceId ? s : undefined;
|
|
16
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adopting an existing chat as a lead. The session keeps its id, its whole
|
|
3
|
+
* chat and its agent's conversation: it restarts on the same conversation
|
|
4
|
+
* with the lead's brief and tools, and is asked to carry what matters across
|
|
5
|
+
* (its memo, and pins proposed from the owner's own messages). Only when the
|
|
6
|
+
* owner confirms the pins, in one tap, does it switch over to a fresh start
|
|
7
|
+
* that reads the memo and the direction instead of the old conversation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { owner } from "../owner";
|
|
12
|
+
import { itemsOfKind } from "../chat/store";
|
|
13
|
+
import { originOf } from "../chat/origin";
|
|
14
|
+
import {
|
|
15
|
+
chatStateNow,
|
|
16
|
+
freshStartChat,
|
|
17
|
+
sendChat,
|
|
18
|
+
stopChat,
|
|
19
|
+
} from "../chat/runner";
|
|
20
|
+
import { removeStaleSocket, waitForExit } from "../chat/worker/client";
|
|
21
|
+
import { AGENTOS_SENDER } from "../orchestrator/deliver";
|
|
22
|
+
import { clip } from "../orchestrator/digest";
|
|
23
|
+
import { addNote } from "../orchestrator/notes";
|
|
24
|
+
import { resolveProposals } from "./direction";
|
|
25
|
+
import { addLeads, getLead, leadTitle, seedCharter } from "./home";
|
|
26
|
+
import { latestMemo } from "./memos";
|
|
27
|
+
import { isLeadKind, isStandingRole, sqlNoRole } from "./roles";
|
|
28
|
+
import { TECH, type DirectionPin, type LeadKind } from "./types";
|
|
29
|
+
|
|
30
|
+
// How many of the owner's last messages the handover offers as pins.
|
|
31
|
+
const OFFERED = 20;
|
|
32
|
+
|
|
33
|
+
const sessionRow = (id: string) =>
|
|
34
|
+
db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
|
|
35
|
+
Session | undefined;
|
|
36
|
+
|
|
37
|
+
// Why this session can't become this lead, or null.
|
|
38
|
+
function refusal(s: Session | undefined, kind: LeadKind): string | null {
|
|
39
|
+
if (!s) return "No such session";
|
|
40
|
+
if (kind === TECH)
|
|
41
|
+
return "The Tech lead is the workspace's orchestrator; adopt another kind";
|
|
42
|
+
if (!isLeadKind(kind)) return `"${kind}" isn't a lead's name`;
|
|
43
|
+
if (isStandingRole(s.role)) return "That session already heads something";
|
|
44
|
+
if (s.view !== "chat" || s.agent_type !== "claude")
|
|
45
|
+
return "Only a Claude chat session can be adopted as a lead";
|
|
46
|
+
if (s.task_status || s.worktree_path)
|
|
47
|
+
return "A task's session can't be a lead";
|
|
48
|
+
if (s.archived_at) return "Unarchive the session first";
|
|
49
|
+
if (s.host_id && s.host_id !== "local")
|
|
50
|
+
return "Only a session on this machine can be adopted";
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handover(sessionId: string, title: string): string {
|
|
55
|
+
const who = owner();
|
|
56
|
+
const offered = itemsOfKind(sessionId, "user")
|
|
57
|
+
.filter((i) => originOf(i) === null && i.text.trim())
|
|
58
|
+
.slice(-OFFERED)
|
|
59
|
+
.map((i) => `- [item ${i.id}] ${clip(i.text, 300)}`);
|
|
60
|
+
return [
|
|
61
|
+
`You are now this workspace's ${title}. AgentOS kept this conversation, so you still know everything in it; your brief and tools are a lead's from this message on.`,
|
|
62
|
+
`Before you switch over to a fresh start (which carries only your memo and your pinned direction, never this conversation), carry across what matters:`,
|
|
63
|
+
`1. Write your memo with update_memo: what you're working on, what's decided and why, and what's next. Keep it short (about 1.5k tokens).`,
|
|
64
|
+
`2. Propose pins with propose_pin, one call per message, for ${who}'s messages below that set standing direction (not one-off requests). ${who} confirms them with one tap; until he does, none of it is direction.`,
|
|
65
|
+
offered.length
|
|
66
|
+
? `${who}'s recent messages in this chat:\n${offered.join("\n")}`
|
|
67
|
+
: `${who} has no messages in this chat to pin.`,
|
|
68
|
+
"Then say in a sentence or two what you saved. The switch happens when he confirms.",
|
|
69
|
+
].join("\n\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Stops the session's worker, if one runs, and waits for it to be gone, so
|
|
73
|
+
// the next message starts one with the lead's brief and tools.
|
|
74
|
+
async function restartWorker(sessionId: string): Promise<void> {
|
|
75
|
+
const state = await chatStateNow(sessionId).catch(() => null);
|
|
76
|
+
if (state === "running" || state === "waiting")
|
|
77
|
+
throw new Error("Adopt it when its current turn has ended");
|
|
78
|
+
stopChat(sessionId);
|
|
79
|
+
await waitForExit(sessionId);
|
|
80
|
+
removeStaleSocket(sessionId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function adoptLead(input: {
|
|
84
|
+
sessionId: string;
|
|
85
|
+
workspaceId: string;
|
|
86
|
+
kind: LeadKind;
|
|
87
|
+
}): Promise<Session> {
|
|
88
|
+
const { sessionId, workspaceId, kind } = input;
|
|
89
|
+
const why = refusal(sessionRow(sessionId), kind);
|
|
90
|
+
if (why) throw new Error(why);
|
|
91
|
+
if (getLead(workspaceId, kind))
|
|
92
|
+
throw new Error(
|
|
93
|
+
`This workspace already has a ${leadTitle(workspaceId, kind)}`
|
|
94
|
+
);
|
|
95
|
+
await restartWorker(sessionId);
|
|
96
|
+
addLeads(workspaceId, []);
|
|
97
|
+
seedCharter(workspaceId, kind);
|
|
98
|
+
try {
|
|
99
|
+
const changed = db
|
|
100
|
+
.prepare(
|
|
101
|
+
`UPDATE sessions SET role = ?, lead_kind = ?, lead_since = datetime('now'),
|
|
102
|
+
workspace_id = ?, pinned = 1
|
|
103
|
+
WHERE id = ? AND ${sqlNoRole()}`
|
|
104
|
+
)
|
|
105
|
+
.run("lead", kind, workspaceId, sessionId).changes;
|
|
106
|
+
if (!changed) throw new Error("That session already heads something");
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// The unique (workspace, kind) index: someone adopted one just now.
|
|
109
|
+
if (getLead(workspaceId, kind)?.id !== sessionId)
|
|
110
|
+
throw new Error(
|
|
111
|
+
`This workspace already has a ${leadTitle(workspaceId, kind)}`
|
|
112
|
+
);
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
const title = leadTitle(workspaceId, kind);
|
|
116
|
+
addNote(
|
|
117
|
+
workspaceId,
|
|
118
|
+
`Adopted as the ${title}. It writes its memo and proposes pins now; it switches over to a fresh start once ${owner()} confirms them.`,
|
|
119
|
+
"note",
|
|
120
|
+
kind
|
|
121
|
+
);
|
|
122
|
+
await sendChat(sessionId, {
|
|
123
|
+
text: handover(sessionId, title),
|
|
124
|
+
queue: true,
|
|
125
|
+
from: AGENTOS_SENDER,
|
|
126
|
+
origin: { kind: "system", label: "AgentOS" },
|
|
127
|
+
});
|
|
128
|
+
return sessionRow(sessionId)!;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The owner's one tap: pins the proposals confirmed and drops the rest. With
|
|
132
|
+
// switchOver, the lead then starts fresh on its memo and direction; an
|
|
133
|
+
// adopted lead does this once, after writing its memo.
|
|
134
|
+
export async function confirmPins(input: {
|
|
135
|
+
workspaceId: string;
|
|
136
|
+
kind: LeadKind;
|
|
137
|
+
confirm: number[];
|
|
138
|
+
switchOver?: boolean;
|
|
139
|
+
}): Promise<{ pins: DirectionPin[]; fresh: "now" | "after-turn" | null }> {
|
|
140
|
+
const { workspaceId, kind } = input;
|
|
141
|
+
const lead = getLead(workspaceId, kind);
|
|
142
|
+
if (!lead) throw new Error("This workspace has no such lead");
|
|
143
|
+
if (input.switchOver && !latestMemo(workspaceId, kind))
|
|
144
|
+
throw new Error(
|
|
145
|
+
"It hasn't written its memo yet, so a fresh start would lose its history"
|
|
146
|
+
);
|
|
147
|
+
const pins = resolveProposals(workspaceId, kind, input.confirm);
|
|
148
|
+
const fresh = input.switchOver ? await freshStartChat(lead.id) : null;
|
|
149
|
+
return { pins, fresh };
|
|
150
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Whether a lead's start or stop falls inside its area. The area is data
|
|
2
|
+
// (lead_areas): the projects and boards the lead owns. A target that can't
|
|
3
|
+
// be found, or sits in no project, is refused: the check fails closed.
|
|
4
|
+
|
|
5
|
+
import type { Project } from "@/lib/db/types";
|
|
6
|
+
import { getProject } from "../projects";
|
|
7
|
+
import { findRecipe } from "../recipes/store";
|
|
8
|
+
import { findWorkspaceSession } from "../orchestrator/read";
|
|
9
|
+
import { workspaceProject, workspaceTask } from "../orchestrator/targets";
|
|
10
|
+
import { leadAreas } from "./charters";
|
|
11
|
+
import { leadTitle } from "./home";
|
|
12
|
+
import type { LeadKind } from "./types";
|
|
13
|
+
|
|
14
|
+
type Args = Record<string, unknown>;
|
|
15
|
+
type Found = Project | null | undefined;
|
|
16
|
+
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
17
|
+
|
|
18
|
+
// Each area-checked tool, and the project its call acts on.
|
|
19
|
+
const TARGET: Record<string, (w: string, a: Args) => Found> = {
|
|
20
|
+
start_task: (w, a) => workspaceProject(w, str(a.project)),
|
|
21
|
+
start_session: (w, a) => workspaceProject(w, str(a.project)),
|
|
22
|
+
stack: (w, a) => workspaceProject(w, str(a.target), { boards: true }),
|
|
23
|
+
run_recipe: (w, a) => getProject(findRecipe(w, str(a.name)).project_id),
|
|
24
|
+
...Object.fromEntries(
|
|
25
|
+
[
|
|
26
|
+
"drop",
|
|
27
|
+
"pause_task",
|
|
28
|
+
"resume_task",
|
|
29
|
+
"set_priority",
|
|
30
|
+
"approve_plan",
|
|
31
|
+
"plan_pieces",
|
|
32
|
+
"set_task_model",
|
|
33
|
+
].map((t) => [
|
|
34
|
+
t,
|
|
35
|
+
(w: string, a: Args) =>
|
|
36
|
+
getProject(workspaceTask(w, str(a.task)).project_id ?? ""),
|
|
37
|
+
])
|
|
38
|
+
),
|
|
39
|
+
...Object.fromEntries(
|
|
40
|
+
["stop", "archive", "resurrect", "set_group"].map((t) => [
|
|
41
|
+
t,
|
|
42
|
+
(w: string, a: Args) =>
|
|
43
|
+
getProject(findWorkspaceSession(w, str(a.session)).project_id ?? ""),
|
|
44
|
+
])
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const AREA_CHECKED = Object.keys(TARGET);
|
|
49
|
+
|
|
50
|
+
export function areaRefusal(
|
|
51
|
+
workspaceId: string,
|
|
52
|
+
kind: LeadKind,
|
|
53
|
+
tool: string,
|
|
54
|
+
args: unknown
|
|
55
|
+
): string | null {
|
|
56
|
+
const target = TARGET[tool];
|
|
57
|
+
if (!target) return null;
|
|
58
|
+
let project: Found;
|
|
59
|
+
try {
|
|
60
|
+
project = target(workspaceId, (args ?? {}) as Args);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return e instanceof Error ? e.message : String(e);
|
|
63
|
+
}
|
|
64
|
+
// Not one of the workspace's projects (a session in none): not its area.
|
|
65
|
+
if (!project)
|
|
66
|
+
return `That isn't in a project of your area, so ${tool} is refused. Hand it to the Tech lead with send.`;
|
|
67
|
+
const areas = leadAreas(workspaceId, kind);
|
|
68
|
+
const owns = areas.some(
|
|
69
|
+
(a) =>
|
|
70
|
+
(a.kind === "project" && a.value === project.id) ||
|
|
71
|
+
(a.kind === "board" &&
|
|
72
|
+
(a.value === project.lh_board_id || a.value === project.lh_board_name))
|
|
73
|
+
);
|
|
74
|
+
if (owns) return null;
|
|
75
|
+
const title = leadTitle(workspaceId, kind);
|
|
76
|
+
return `"${project.name}" isn't in your area as ${title}, so ${tool} is refused there. Hand the work to the Tech lead with send, or ask the owner to add the project to your area.`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// What every standing session's brief shares: the orchestrator's (the Tech
|
|
2
|
+
// lead's) and another lead's. Owner naming, brakes, the hard lines and the
|
|
3
|
+
// project list are said once here, so the two briefs can't drift apart.
|
|
4
|
+
|
|
5
|
+
import { db, type Project } from "../db";
|
|
6
|
+
import { getDefaultBranch } from "../git";
|
|
7
|
+
import { brakesEnabled } from "../orchestrator/brakes";
|
|
8
|
+
import { noApprovalsOn } from "../approvals/no-approvals";
|
|
9
|
+
import { ORCHESTRATOR_SERVER } from "../orchestrator/tool-names";
|
|
10
|
+
|
|
11
|
+
export const up = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
12
|
+
|
|
13
|
+
const BRAKES_ON = `Starting work (\`start_task\`, \`start_session\`, \`stack\`) is braked: at most a set number of sessions running in this workspace (4 by default), at most a set number of starts an hour (6 by default), and nothing new once the account's usage window would run out before it resets, or isn't being sampled and was near its cap when last seen. Each card of a stack you start counts as a start of its own; a braked card waits in the stack. A brake refuses the start with its reason and writes one note; running work carries on. Don't retry a braked start in a loop: carry on with reviews, answers and merges, and start again when a later event gives you reason to.`;
|
|
14
|
+
|
|
15
|
+
const BRAKES_OFF = (who: string) =>
|
|
16
|
+
`Starting work has no limits right now: no cap on running sessions or starts, and no usage-window check. ${up(who)}'s Pause still stops new starts; the start tools say so when it's on.`;
|
|
17
|
+
|
|
18
|
+
// Read when the brief is built, so a switch flipped in Settings reaches the
|
|
19
|
+
// next start.
|
|
20
|
+
export const brakesText = (who: string) =>
|
|
21
|
+
brakesEnabled() ? BRAKES_ON : BRAKES_OFF(who);
|
|
22
|
+
|
|
23
|
+
const NO_APPROVALS = (who: string) => `## No approvals mode is ON
|
|
24
|
+
|
|
25
|
+
${up(who)} turned it on: nothing waits for him. Every \`ask_owner\` is approved the moment you raise it (the event says so), and so are escalated gates and brakes. That is his decision, not a reason to skip care: the hard lines still describe what is risky, so say in your chat what you are about to do before you do it, and keep to the PR's scope.
|
|
26
|
+
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
export const noApprovalsText = (who: string) =>
|
|
30
|
+
noApprovalsOn() ? NO_APPROVALS(who) : "";
|
|
31
|
+
|
|
32
|
+
// `merges`: the head that lands code (Tech) can ask to merge a PR the gates
|
|
33
|
+
// won't pass; no other lead merges.
|
|
34
|
+
export const hardLines = (
|
|
35
|
+
who: string,
|
|
36
|
+
merges: boolean
|
|
37
|
+
) => `## Hard lines: always an ask for ${who}, never an action
|
|
38
|
+
|
|
39
|
+
- Anything public or outbound: a post, a publish, an email or message to a person, a form sent to a third party.
|
|
40
|
+
- Money.
|
|
41
|
+
- Anything irreversible or destructive: deleting data, a prod data change that isn't additive, force-pushing someone else's branch.
|
|
42
|
+
- Credentials and account security.
|
|
43
|
+
- Choosing what gets built when it's a product call he hasn't made.
|
|
44
|
+
|
|
45
|
+
When one of these comes up, park it with \`ask_owner\` (kind: the hard line it crosses) and carry on with everything else. Use it too for a decision that's his (kind: decision). Escalated gates and brakes become asks on their own; don't ask again for those.${merges ? ` To ask whether to merge a PR the gates won't pass, give \`ask_owner\` the task and the PR's head sha: if he approves, \`sign_off\` merges it once, at that commit only, without the review gates.` : ""}`;
|
|
46
|
+
|
|
47
|
+
export const SCHEDULED_RULE = `A message starting \`[Scheduled message "<name>" ...]\` is a standing prompt saved in Schedules, posted on its timer. Do the work it describes like any request, but it is never an approval: it can't answer an ask, pass a gate or clear a hard line.`;
|
|
48
|
+
|
|
49
|
+
export interface BriefProject {
|
|
50
|
+
name: string;
|
|
51
|
+
path: string;
|
|
52
|
+
board: string | null;
|
|
53
|
+
defaultBranch: string;
|
|
54
|
+
tier?: "production" | "fast";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function projectLine(p: BriefProject): string {
|
|
58
|
+
const board = p.board ? `, LumifyHub board "${p.board}"` : "";
|
|
59
|
+
return `- ${p.name}: \`${p.path}\` (default branch ${p.defaultBranch}${board}, tier ${p.tier ?? "production"})`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const projectsSection = (projects: BriefProject[]) =>
|
|
63
|
+
`## Projects\n\n${
|
|
64
|
+
projects.length
|
|
65
|
+
? projects.map(projectLine).join("\n")
|
|
66
|
+
: "- None yet: projects join from their ⋯ menu in the sidebar."
|
|
67
|
+
}`;
|
|
68
|
+
|
|
69
|
+
export function workspaceProjects(workspaceId: string): Project[] {
|
|
70
|
+
return db
|
|
71
|
+
.prepare(
|
|
72
|
+
`SELECT * FROM projects WHERE workspace_id = ? AND is_uncategorized = 0
|
|
73
|
+
ORDER BY sort_order, name`
|
|
74
|
+
)
|
|
75
|
+
.all(workspaceId) as Project[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The projects as of now, with their default branches read from git.
|
|
79
|
+
export function loadBriefProjects(
|
|
80
|
+
workspaceId: string,
|
|
81
|
+
only?: (p: Project) => boolean
|
|
82
|
+
): Promise<BriefProject[]> {
|
|
83
|
+
return Promise.all(
|
|
84
|
+
workspaceProjects(workspaceId)
|
|
85
|
+
.filter((p) => !only || only(p))
|
|
86
|
+
.map(async (p) => ({
|
|
87
|
+
name: p.name,
|
|
88
|
+
path: p.working_directory,
|
|
89
|
+
board: p.lh_board_name ?? p.lh_board_id,
|
|
90
|
+
defaultBranch: await getDefaultBranch(p.working_directory).catch(
|
|
91
|
+
() => "main"
|
|
92
|
+
),
|
|
93
|
+
tier: p.tier,
|
|
94
|
+
}))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const TOOL_LINE = new RegExp(`^- \`mcp__${ORCHESTRATOR_SERVER}__(\\w+)\``);
|
|
99
|
+
|
|
100
|
+
// "(`mcp__agentos__a`, `mcp__agentos__b`)": the tools prose names instead.
|
|
101
|
+
const TOOL_REFS = /\((`mcp__\w+?__\w+`(?:, `mcp__\w+?__\w+`)*)\)/g;
|
|
102
|
+
const REF = /`mcp__\w+?__(\w+)`/;
|
|
103
|
+
|
|
104
|
+
// A tools section cut to the tools a profile has: a bullet naming a tool
|
|
105
|
+
// outside it goes, and so does such a tool in the prose's lists of tools
|
|
106
|
+
// to use instead; with none left, the Tech lead is the one to ask.
|
|
107
|
+
export function keepToolLines(
|
|
108
|
+
text: string,
|
|
109
|
+
has: (tool: string) => boolean
|
|
110
|
+
): string {
|
|
111
|
+
return text
|
|
112
|
+
.split("\n")
|
|
113
|
+
.filter((line) => {
|
|
114
|
+
const m = TOOL_LINE.exec(line);
|
|
115
|
+
return !m || has(m[1]);
|
|
116
|
+
})
|
|
117
|
+
.join("\n")
|
|
118
|
+
.replace(TOOL_REFS, (_, list: string) => {
|
|
119
|
+
const kept = list.split(", ").filter((ref) => has(REF.exec(ref)![1]));
|
|
120
|
+
return `(${kept.length ? kept.join(", ") : "hand it to the Tech lead with send"})`;
|
|
121
|
+
});
|
|
122
|
+
}
|