@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,159 @@
|
|
|
1
|
+
// The tool calls of a workspace's standing sessions, as the server answers
|
|
2
|
+
// them: the caller's profile decides which tools it has, a lead's starts
|
|
3
|
+
// and stops stay inside its area, and what it does is recorded as its own.
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { Session } from "@/lib/db/types";
|
|
7
|
+
import { isToolName, runTool } from "../orchestrator/serve";
|
|
8
|
+
import { owner } from "../owner";
|
|
9
|
+
import { asActor } from "./actor";
|
|
10
|
+
import { areaRefusal } from "./area";
|
|
11
|
+
import { currentDirection, openProposals, proposePin } from "./direction";
|
|
12
|
+
import { leadTitle, listLeads } from "./home";
|
|
13
|
+
import { latestMemo, writeMemo } from "./memos";
|
|
14
|
+
import {
|
|
15
|
+
isLeadToolName,
|
|
16
|
+
profileFor,
|
|
17
|
+
profileHas,
|
|
18
|
+
type LeadToolName,
|
|
19
|
+
type Profile,
|
|
20
|
+
} from "./profiles";
|
|
21
|
+
import { isLeadKind, leadKindOf, roleNoun } from "./roles";
|
|
22
|
+
import { hasLeads } from "./settings";
|
|
23
|
+
import type { LeadKind } from "./types";
|
|
24
|
+
|
|
25
|
+
export const LEAD_TOOL_SHAPES = {
|
|
26
|
+
update_memo: {
|
|
27
|
+
text: z
|
|
28
|
+
.string()
|
|
29
|
+
.trim()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(6000)
|
|
32
|
+
.describe(
|
|
33
|
+
"The whole memo, rewritten: what you're working on, what's decided, what's next"
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
propose_pin: {
|
|
37
|
+
item: z
|
|
38
|
+
.string()
|
|
39
|
+
.trim()
|
|
40
|
+
.min(1)
|
|
41
|
+
.max(200)
|
|
42
|
+
.describe("The chat item id of the owner's message to pin"),
|
|
43
|
+
},
|
|
44
|
+
direction: {
|
|
45
|
+
lead: z
|
|
46
|
+
.string()
|
|
47
|
+
.trim()
|
|
48
|
+
.max(32)
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
"Another lead's kind (marketing, product, tech); yours if left out"
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
} satisfies Record<LeadToolName, z.ZodRawShape>;
|
|
55
|
+
|
|
56
|
+
export const LEAD_TOOL_DESCRIPTIONS: Record<LeadToolName, string> = {
|
|
57
|
+
update_memo:
|
|
58
|
+
"Rewrite your memo: the short note (about 1.5k tokens) you carry across a fresh start, with your pinned direction. Every version is kept.",
|
|
59
|
+
propose_pin:
|
|
60
|
+
"Propose one of the owner's own messages in your chat as pinned direction. The quote is copied by the server, and it is direction only once he confirms it with a tap.",
|
|
61
|
+
direction:
|
|
62
|
+
"The pinned direction in force for you or another lead of this workspace (newest first), with your open proposals and memo.",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function parseLeadArgs<T extends LeadToolName>(tool: T, args: unknown) {
|
|
66
|
+
const parsed = z
|
|
67
|
+
.object(LEAD_TOOL_SHAPES[tool])
|
|
68
|
+
.strict()
|
|
69
|
+
.safeParse(args ?? {});
|
|
70
|
+
if (!parsed.success)
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Bad arguments for ${tool}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "args"} ${i.message}`).join("; ")}`
|
|
73
|
+
);
|
|
74
|
+
return parsed.data as unknown as z.infer<
|
|
75
|
+
z.ZodObject<(typeof LEAD_TOOL_SHAPES)[T]>
|
|
76
|
+
>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function directionText(w: string, kind: LeadKind, me: LeadKind): string {
|
|
80
|
+
const pins = currentDirection(w, kind);
|
|
81
|
+
const title = leadTitle(w, kind);
|
|
82
|
+
const lines = [
|
|
83
|
+
pins.length
|
|
84
|
+
? `Pinned direction for the ${title}, from ${owner()}, newest first:\n${pins.map((p) => `- [pin ${p.id}, ${p.setAt.slice(0, 10)}] "${p.quote}"`).join("\n")}`
|
|
85
|
+
: `The ${title} has no pinned direction.`,
|
|
86
|
+
];
|
|
87
|
+
if (kind === me) {
|
|
88
|
+
const open = openProposals(w, kind);
|
|
89
|
+
if (open.length)
|
|
90
|
+
lines.push(
|
|
91
|
+
`Proposed, waiting for ${owner()}'s tap:\n${open.map((p) => `- [proposal ${p.id}] "${p.quote}"`).join("\n")}`
|
|
92
|
+
);
|
|
93
|
+
const memo = latestMemo(w, kind);
|
|
94
|
+
lines.push(memo ? `Your memo:\n${memo.text}` : "You have no memo yet.");
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function runLeadTool(
|
|
100
|
+
w: string,
|
|
101
|
+
caller: Session,
|
|
102
|
+
kind: LeadKind,
|
|
103
|
+
tool: LeadToolName,
|
|
104
|
+
raw: unknown
|
|
105
|
+
): string {
|
|
106
|
+
switch (tool) {
|
|
107
|
+
case "update_memo": {
|
|
108
|
+
const memo = writeMemo(w, kind, parseLeadArgs(tool, raw).text);
|
|
109
|
+
return `Memo saved (${memo.text.length} characters). A fresh start reads it with your pinned direction.`;
|
|
110
|
+
}
|
|
111
|
+
case "propose_pin": {
|
|
112
|
+
const out = proposePin({
|
|
113
|
+
workspaceId: w,
|
|
114
|
+
leadKind: kind,
|
|
115
|
+
sessionId: caller.id,
|
|
116
|
+
chatItemId: parseLeadArgs(tool, raw).item,
|
|
117
|
+
});
|
|
118
|
+
if ("error" in out) throw new Error(out.error);
|
|
119
|
+
return `Proposed as pin ${out.id}: "${out.quote}". It's direction once ${owner()} confirms it.`;
|
|
120
|
+
}
|
|
121
|
+
case "direction": {
|
|
122
|
+
const lead = parseLeadArgs(tool, raw).lead ?? kind;
|
|
123
|
+
if (!isLeadKind(lead)) throw new Error(`No lead "${lead}"`);
|
|
124
|
+
if (!listLeads(w).some((s) => leadKindOf(s) === lead))
|
|
125
|
+
throw new Error(`This workspace has no ${lead} lead`);
|
|
126
|
+
return directionText(w, lead, kind);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class NotYours extends Error {}
|
|
132
|
+
export class UnknownTool extends Error {}
|
|
133
|
+
|
|
134
|
+
// One tool call from a standing session. Unknown names stay "unknown", as
|
|
135
|
+
// they always were; a known tool outside the caller's profile is refused.
|
|
136
|
+
export async function runStandingTool(
|
|
137
|
+
w: string,
|
|
138
|
+
caller: Session,
|
|
139
|
+
tool: string,
|
|
140
|
+
args: unknown
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
const profile: Profile | null = profileFor(caller, hasLeads(w));
|
|
143
|
+
if (!profile)
|
|
144
|
+
throw new NotYours(`The ${roleNoun(caller.role)} has no tools here yet`);
|
|
145
|
+
if (!isToolName(tool) && !isLeadToolName(tool))
|
|
146
|
+
throw new UnknownTool("Unknown tool");
|
|
147
|
+
if (!profileHas(profile, tool))
|
|
148
|
+
throw new NotYours(
|
|
149
|
+
`${tool} isn't one of your tools as ${leadTitle(w, profile.kind)}: it's the Tech lead's. Hand it over with send.`
|
|
150
|
+
);
|
|
151
|
+
if (isLeadToolName(tool))
|
|
152
|
+
return runLeadTool(w, caller, profile.kind, tool, args);
|
|
153
|
+
if (!isToolName(tool)) throw new UnknownTool("Unknown tool");
|
|
154
|
+
const refusal = profile.inArea
|
|
155
|
+
? areaRefusal(w, profile.kind, tool, args)
|
|
156
|
+
: null;
|
|
157
|
+
if (refusal) throw new Error(refusal);
|
|
158
|
+
return asActor(caller, () => runTool(w, tool, args ?? {}));
|
|
159
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// A lead's department: the shipped kinds, or a custom slug.
|
|
2
|
+
export type LeadKind = string;
|
|
3
|
+
|
|
4
|
+
export const TECH: LeadKind = "tech";
|
|
5
|
+
export const BUILT_IN_KINDS = ["tech", "marketing", "product"] as const;
|
|
6
|
+
|
|
7
|
+
// How far a workspace's chief of staff may go without being asked. V1 acts
|
|
8
|
+
// only on request; its sweep reads and raises asks.
|
|
9
|
+
export type ChiefAutonomy = "on_request" | "acts";
|
|
10
|
+
|
|
11
|
+
export interface WorkspaceLeads {
|
|
12
|
+
workspaceId: string;
|
|
13
|
+
enabledAt: string;
|
|
14
|
+
chiefAutonomy: ChiefAutonomy;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LeadCharter {
|
|
18
|
+
workspaceId: string;
|
|
19
|
+
leadKind: LeadKind;
|
|
20
|
+
title: string;
|
|
21
|
+
areaText: string;
|
|
22
|
+
charterMd: string;
|
|
23
|
+
goal: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type LeadAreaKind = "project" | "path" | "board";
|
|
28
|
+
|
|
29
|
+
export interface LeadArea {
|
|
30
|
+
kind: LeadAreaKind;
|
|
31
|
+
value: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DirectionPin {
|
|
35
|
+
id: number;
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
leadKind: LeadKind;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
chatItemId: string;
|
|
40
|
+
quote: string;
|
|
41
|
+
setAt: string;
|
|
42
|
+
supersededBy: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ProposalStatus = "open" | "pinned" | "dropped";
|
|
46
|
+
|
|
47
|
+
export interface PinProposal {
|
|
48
|
+
id: number;
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
leadKind: LeadKind;
|
|
51
|
+
sessionId: string;
|
|
52
|
+
chatItemId: string;
|
|
53
|
+
quote: string;
|
|
54
|
+
status: ProposalStatus;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
resolvedAt: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LeadMemo {
|
|
60
|
+
id: number;
|
|
61
|
+
workspaceId: string;
|
|
62
|
+
leadKind: LeadKind;
|
|
63
|
+
text: string;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LeadMessage {
|
|
68
|
+
id: number;
|
|
69
|
+
workspaceId: string;
|
|
70
|
+
fromKind: LeadKind;
|
|
71
|
+
toKind: LeadKind;
|
|
72
|
+
thread: string;
|
|
73
|
+
text: string;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
readAt: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LeadUsage {
|
|
79
|
+
sessionId: string;
|
|
80
|
+
leadKind: LeadKind;
|
|
81
|
+
role: string;
|
|
82
|
+
// Model calls (one per assistant response).
|
|
83
|
+
calls: number;
|
|
84
|
+
inputTokens: number;
|
|
85
|
+
cacheReadTokens: number;
|
|
86
|
+
cacheWriteTokens: number;
|
|
87
|
+
outputTokens: number;
|
|
88
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import { leadKindOf, sqlStanding } from "./roles";
|
|
3
|
+
import type { LeadUsage } from "./types";
|
|
4
|
+
|
|
5
|
+
// What each standing chat in a workspace spent since `sinceMs`, from the
|
|
6
|
+
// calls every chat worker already records (lib/usage/calls). An adopted
|
|
7
|
+
// lead counts only from when it became one, so week one is measured, not
|
|
8
|
+
// guessed.
|
|
9
|
+
export function leadUsage(workspaceId: string, sinceMs: number): LeadUsage[] {
|
|
10
|
+
const rows = db
|
|
11
|
+
.prepare(
|
|
12
|
+
`SELECT s.id AS session_id, s.role, s.lead_kind,
|
|
13
|
+
COUNT(c.id) AS calls,
|
|
14
|
+
COALESCE(SUM(c.input_tokens), 0) AS input,
|
|
15
|
+
COALESCE(SUM(c.cache_read_tokens), 0) AS cache_read,
|
|
16
|
+
COALESCE(SUM(c.cache_write_5m_tokens + c.cache_write_1h_tokens), 0) AS cache_write,
|
|
17
|
+
COALESCE(SUM(c.output_tokens), 0) AS output
|
|
18
|
+
FROM sessions s
|
|
19
|
+
LEFT JOIN usage_calls c ON c.session_id = s.id
|
|
20
|
+
AND c.at >= MAX(?, COALESCE(
|
|
21
|
+
CAST(strftime('%s', s.lead_since) AS INTEGER) * 1000, 0))
|
|
22
|
+
WHERE s.workspace_id = ? AND ${sqlStanding("s.role")}
|
|
23
|
+
GROUP BY s.id
|
|
24
|
+
ORDER BY s.role, s.lead_kind`
|
|
25
|
+
)
|
|
26
|
+
.all(sinceMs, workspaceId) as {
|
|
27
|
+
session_id: string;
|
|
28
|
+
role: string;
|
|
29
|
+
lead_kind: string | null;
|
|
30
|
+
calls: number;
|
|
31
|
+
input: number;
|
|
32
|
+
cache_read: number;
|
|
33
|
+
cache_write: number;
|
|
34
|
+
output: number;
|
|
35
|
+
}[];
|
|
36
|
+
return rows.map((r) => ({
|
|
37
|
+
sessionId: r.session_id,
|
|
38
|
+
leadKind: leadKindOf(r) ?? r.role,
|
|
39
|
+
role: r.role,
|
|
40
|
+
calls: r.calls,
|
|
41
|
+
inputTokens: r.input,
|
|
42
|
+
cacheReadTokens: r.cache_read,
|
|
43
|
+
cacheWriteTokens: r.cache_write,
|
|
44
|
+
outputTokens: r.output,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// brief, the final report and the tool and user steps, each cut down.
|
|
3
3
|
// Never the transcript.
|
|
4
4
|
|
|
5
|
+
import { isStandingRole, roleNoun } from "../leads/roles";
|
|
5
6
|
import { db, queries, type Session } from "../db";
|
|
6
7
|
import { itemsOfKind, itemsOfKinds } from "../chat/store";
|
|
7
8
|
import { isGitRepo } from "../git";
|
|
@@ -61,7 +62,7 @@ export async function readSession(id: string): Promise<SessionInput | null> {
|
|
|
61
62
|
BRIEF
|
|
62
63
|
);
|
|
63
64
|
let skip: string | null = null;
|
|
64
|
-
if (s.role
|
|
65
|
+
if (isStandingRole(s.role)) skip = roleNoun(s.role);
|
|
65
66
|
else if (s.quick_of) skip = "quick change";
|
|
66
67
|
else if (!steps.length && !final) skip = "no conversation";
|
|
67
68
|
|
package/lib/live/drive.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* worker's guest gate (lib/chat/worker/guest-gate).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isStandingRole, roleLabel } from "../leads/roles";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import path from "path";
|
|
11
12
|
import { randomBytes } from "crypto";
|
|
@@ -69,7 +70,7 @@ const home = (p: string) => path.resolve(p.replace(/^~(?=$|\/)/, os.homedir()));
|
|
|
69
70
|
export function driveRefusal(s: Session): string | null {
|
|
70
71
|
if (s.view !== "chat" || s.agent_type !== "claude")
|
|
71
72
|
return "Claude chat sessions only";
|
|
72
|
-
if (s.role) return
|
|
73
|
+
if (isStandingRole(s.role)) return `Not for ${roleLabel(s.role)}`;
|
|
73
74
|
if (!s.task_prompt) return "Task sessions only";
|
|
74
75
|
if (!s.worktree_path) return "Task sessions with their own worktree only";
|
|
75
76
|
const project = s.project_id ? getProject(s.project_id) : undefined;
|
package/lib/models/launch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* name for its CLI, and the provider's endpoint and key in its environment.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { isStandingRole } from "../leads/roles";
|
|
6
7
|
import type { Session } from "../db";
|
|
7
8
|
import {
|
|
8
9
|
defaultRoute,
|
|
@@ -72,7 +73,7 @@ export async function routeOrchestrator(
|
|
|
72
73
|
session: Session,
|
|
73
74
|
exec?: SecretCommand
|
|
74
75
|
): Promise<Session> {
|
|
75
|
-
if (session.role
|
|
76
|
+
if (!isStandingRole(session.role)) return session;
|
|
76
77
|
let routed;
|
|
77
78
|
try {
|
|
78
79
|
routed = resolveRole("orchestrator", { workspaceId: session.workspace_id });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isStandingRole } from "../leads/roles";
|
|
1
2
|
import { orchestratorOf } from "../tasks/starter";
|
|
2
3
|
import type { ModelRole } from "./roles";
|
|
3
4
|
|
|
@@ -9,7 +10,7 @@ export function sessionRole(session: {
|
|
|
9
10
|
plan_phase?: string | null;
|
|
10
11
|
started_by?: string | null;
|
|
11
12
|
}): ModelRole {
|
|
12
|
-
if (session.role
|
|
13
|
+
if (isStandingRole(session.role)) return "orchestrator";
|
|
13
14
|
// Work an orchestrator started is implementation, not a person's session.
|
|
14
15
|
if (!session.task_status)
|
|
15
16
|
return orchestratorOf(session.started_by) ? "implement" : "interactive";
|
package/lib/needs-you.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { getDb, type Session } from "./db";
|
|
2
2
|
import type { ChatItem, ChatState } from "./chat/events";
|
|
3
3
|
import { originOf } from "./chat/origin";
|
|
4
|
-
import {
|
|
4
|
+
import { standingAskCount } from "./orchestrator/asks";
|
|
5
|
+
import { isStandingRole } from "./leads/roles";
|
|
5
6
|
import { notifyStatusChanged } from "./status/hub";
|
|
6
7
|
import type { SessionNeed } from "./sidebar/shelves";
|
|
7
8
|
import type { ProgramSummary } from "./program-status/records";
|
|
@@ -36,7 +37,12 @@ export function needsYou(
|
|
|
36
37
|
Partial<
|
|
37
38
|
Pick<
|
|
38
39
|
Session,
|
|
39
|
-
|
|
40
|
+
| "role"
|
|
41
|
+
| "lead_kind"
|
|
42
|
+
| "workspace_id"
|
|
43
|
+
| "task_status"
|
|
44
|
+
| "settled_at"
|
|
45
|
+
| "archived_at"
|
|
40
46
|
>
|
|
41
47
|
>,
|
|
42
48
|
chatState: ChatState | null
|
|
@@ -46,8 +52,8 @@ export function needsYou(
|
|
|
46
52
|
// A finished task's agent is stopped: its last turn is history, not news.
|
|
47
53
|
if (session.task_status && session.task_status !== "running") return false;
|
|
48
54
|
if (chatState === "waiting") return true;
|
|
49
|
-
if (session.role
|
|
50
|
-
return
|
|
55
|
+
if (isStandingRole(session.role))
|
|
56
|
+
return standingAskCount({ ...session, role: session.role }) > 0;
|
|
51
57
|
if (session.archived_at) return false;
|
|
52
58
|
const latest = latestChatItem(session.id);
|
|
53
59
|
// Put on the Done shelf after its last turn: that turn is history, not
|
|
@@ -89,7 +95,7 @@ export function chatNeed(
|
|
|
89
95
|
state: ChatState | null,
|
|
90
96
|
asks = 0
|
|
91
97
|
): SessionNeed | null {
|
|
92
|
-
if (session.role
|
|
98
|
+
if (isStandingRole(session.role))
|
|
93
99
|
return asks > 0 || state === "waiting" ? "answer" : null;
|
|
94
100
|
if (state === "waiting")
|
|
95
101
|
return pendingApproval(session.id)?.questions?.length
|
|
@@ -135,7 +141,7 @@ export function isUnread(
|
|
|
135
141
|
const seen = sqliteMs(session.last_seen_at);
|
|
136
142
|
if (session.view === "chat") {
|
|
137
143
|
if (chatState === "running" || chatState === "waiting") return false;
|
|
138
|
-
if (session.role
|
|
144
|
+
if (isStandingRole(session.role))
|
|
139
145
|
return orchestratorReplied(session.id, seen);
|
|
140
146
|
const latest = latestChatItem(session.id);
|
|
141
147
|
return latest?.kind === "turn_end" && latest.createdAt > seen;
|
package/lib/notify/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Needs-you list has those.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isStandingRole } from "../leads/roles";
|
|
8
9
|
import { db, type Session } from "../db";
|
|
9
10
|
import { Limiter, type Outcome } from "./limiter";
|
|
10
11
|
import { holdForBatch, msToNextSweep } from "../away/state";
|
|
@@ -167,7 +168,7 @@ export async function notifyFrom(
|
|
|
167
168
|
const body = text.trim();
|
|
168
169
|
if (!body) throw new Error("Nothing to send");
|
|
169
170
|
if (
|
|
170
|
-
session?.role
|
|
171
|
+
isStandingRole(session?.role) &&
|
|
171
172
|
session.workspace_id &&
|
|
172
173
|
holdForBatch(session.workspace_id, session.id, body)
|
|
173
174
|
)
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { findPiece, pieceLabel, workspacePieces } from "../plans/claim";
|
|
|
44
44
|
import { approvePlanNow } from "../models/plan-step";
|
|
45
45
|
import { braked } from "./brakes";
|
|
46
46
|
import { getOrchestrator } from "./home";
|
|
47
|
+
import { actorIn } from "../leads/actor";
|
|
47
48
|
import { addNote } from "./notes";
|
|
48
49
|
import { findWorkspaceSession } from "./read";
|
|
49
50
|
import { assignSessions, resolveGroup } from "../session-groups";
|
|
@@ -64,8 +65,9 @@ import {
|
|
|
64
65
|
import { pauseUntil, type PausePreset } from "../sidebar/snooze";
|
|
65
66
|
import type { Priority } from "../tasks/schedule-types";
|
|
66
67
|
|
|
68
|
+
// Who is acting: the lead whose tool call this is, else the orchestrator.
|
|
67
69
|
function orchestratorId(workspaceId: string): string {
|
|
68
|
-
const o = getOrchestrator(workspaceId);
|
|
70
|
+
const o = actorIn(workspaceId) ?? getOrchestrator(workspaceId);
|
|
69
71
|
if (!o) throw new Error("This workspace has no orchestrator yet");
|
|
70
72
|
return o.id;
|
|
71
73
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { actorIn } from "../leads/actor";
|
|
2
|
+
import { leadKindOf } from "../leads/roles";
|
|
3
|
+
import { TECH } from "../leads/types";
|
|
1
4
|
import type { Session } from "../db";
|
|
2
5
|
import { prFor } from "../tasks/session";
|
|
3
6
|
import {
|
|
@@ -90,6 +93,12 @@ export async function askSaad(
|
|
|
90
93
|
sha?: string;
|
|
91
94
|
}
|
|
92
95
|
): Promise<string> {
|
|
96
|
+
const actor = actorIn(workspaceId);
|
|
97
|
+
const leadKind = (actor && leadKindOf(actor)) ?? TECH;
|
|
98
|
+
if (a.task && leadKind !== TECH)
|
|
99
|
+
throw new Error(
|
|
100
|
+
"Only the Tech lead asks to merge a PR; hand it over with send"
|
|
101
|
+
);
|
|
93
102
|
if (!a.task !== !a.sha)
|
|
94
103
|
throw new Error(
|
|
95
104
|
"Give both task and sha to ask about merging a PR at a commit, or neither"
|
|
@@ -116,6 +125,7 @@ export async function askSaad(
|
|
|
116
125
|
link: merge ? merge.url : (a.link ?? null),
|
|
117
126
|
sha: merge?.sha ?? null,
|
|
118
127
|
raisedBy: merge ? "orchestrator" : null,
|
|
128
|
+
leadKind,
|
|
119
129
|
});
|
|
120
130
|
} catch (error) {
|
|
121
131
|
if (error instanceof AskRefused) return `Not asked: ${error.message}.`;
|
|
@@ -30,6 +30,8 @@ export interface AskView {
|
|
|
30
30
|
binding: string;
|
|
31
31
|
presence: boolean;
|
|
32
32
|
createdAt: string;
|
|
33
|
+
// The lead it is for: "tech" (the orchestrator) unless a lead raised it.
|
|
34
|
+
leadKind: string;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// A question the orchestrator put to Saad in chat, open until answered.
|
package/lib/orchestrator/asks.ts
CHANGED
|
@@ -18,8 +18,15 @@ import { addNote } from "./notes";
|
|
|
18
18
|
import { cleanTitle, classifyKind, titleSubject } from "./ask-text";
|
|
19
19
|
import { revokePasskey } from "../security/passkeys";
|
|
20
20
|
import type { AskKind } from "./ask-view";
|
|
21
|
+
import { TECH, type LeadKind } from "../leads/types";
|
|
22
|
+
import { leadKindOf } from "../leads/roles";
|
|
21
23
|
import { owner, ownerUi } from "../owner";
|
|
22
24
|
|
|
25
|
+
const tellLead = (workspaceId: string, kind: LeadKind, line: string) =>
|
|
26
|
+
import("../leads/deliver")
|
|
27
|
+
.then((m) => m.tellLead(workspaceId, kind, line))
|
|
28
|
+
.catch((e) => console.error(`Ask answer for the ${kind} lead:`, e));
|
|
29
|
+
|
|
23
30
|
export { ASK_KINDS, type AskKind } from "./ask-view";
|
|
24
31
|
|
|
25
32
|
// The kinds the orchestrator raises itself; gate and brake asks come from
|
|
@@ -79,6 +86,9 @@ export interface AskRow {
|
|
|
79
86
|
// "orchestrator" for a merge ask it raised with ask_owner; null when the
|
|
80
87
|
// gates, the brakes or AgentOS raised or last wrote it.
|
|
81
88
|
raised_by: string | null;
|
|
89
|
+
// The lead it is for (lib/leads): "tech", the orchestrator, unless a lead
|
|
90
|
+
// raised it. Two leads on one subject share the first one's ask.
|
|
91
|
+
lead_kind: LeadKind;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
export type AskAnswer =
|
|
@@ -165,6 +175,7 @@ export function raiseAsk(input: {
|
|
|
165
175
|
sha?: string | null;
|
|
166
176
|
brakeKey?: string | null;
|
|
167
177
|
raisedBy?: "orchestrator" | null;
|
|
178
|
+
leadKind?: LeadKind;
|
|
168
179
|
}): { ask: AskRow; created: boolean } {
|
|
169
180
|
const title = cleanTitle(input.title);
|
|
170
181
|
if (!title) throw new Error("An ask needs a title");
|
|
@@ -197,8 +208,8 @@ export function raiseAsk(input: {
|
|
|
197
208
|
if (refused) throw new AskRefused(refused);
|
|
198
209
|
const { lastInsertRowid } = db
|
|
199
210
|
.prepare(
|
|
200
|
-
`INSERT INTO orchestrator_asks (workspace_id, subject, kind, title, detail, link, sha, brake_key, raised_by)
|
|
201
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
211
|
+
`INSERT INTO orchestrator_asks (workspace_id, subject, kind, title, detail, link, sha, brake_key, raised_by, lead_kind)
|
|
212
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
202
213
|
)
|
|
203
214
|
.run(
|
|
204
215
|
input.workspaceId,
|
|
@@ -209,7 +220,8 @@ export function raiseAsk(input: {
|
|
|
209
220
|
link,
|
|
210
221
|
sha,
|
|
211
222
|
brakeKey,
|
|
212
|
-
raisedBy
|
|
223
|
+
raisedBy,
|
|
224
|
+
input.leadKind ?? TECH
|
|
213
225
|
);
|
|
214
226
|
return {
|
|
215
227
|
ask: getAsk(input.workspaceId, Number(lastInsertRowid))!,
|
|
@@ -254,17 +266,34 @@ export function openAsks(workspaceId: string): AskRow[] {
|
|
|
254
266
|
.all(workspaceId) as AskRow[];
|
|
255
267
|
}
|
|
256
268
|
|
|
257
|
-
export function openAskCount(
|
|
269
|
+
export function openAskCount(
|
|
270
|
+
workspaceId: string | null | undefined,
|
|
271
|
+
leadKind?: LeadKind
|
|
272
|
+
): number {
|
|
258
273
|
if (!workspaceId) return 0;
|
|
274
|
+
const lead = leadKind === undefined ? "" : " AND lead_kind = ?";
|
|
259
275
|
return (
|
|
260
276
|
db
|
|
261
277
|
.prepare(
|
|
262
|
-
`SELECT COUNT(*) AS n FROM orchestrator_asks WHERE workspace_id = ? AND status = 'open'`
|
|
278
|
+
`SELECT COUNT(*) AS n FROM orchestrator_asks WHERE workspace_id = ? AND status = 'open'${lead}`
|
|
263
279
|
)
|
|
264
|
-
.get(workspaceId
|
|
280
|
+
.get(workspaceId, ...(leadKind === undefined ? [] : [leadKind])) as {
|
|
281
|
+
n: number;
|
|
282
|
+
}
|
|
265
283
|
).n;
|
|
266
284
|
}
|
|
267
285
|
|
|
286
|
+
// The open asks a standing session answers for: its own lead's. The chief
|
|
287
|
+
// heads no department, so none are its.
|
|
288
|
+
export function standingAskCount(s: {
|
|
289
|
+
role: string | null;
|
|
290
|
+
lead_kind?: string | null;
|
|
291
|
+
workspace_id?: string | null;
|
|
292
|
+
}): number {
|
|
293
|
+
const kind = leadKindOf(s);
|
|
294
|
+
return kind ? openAskCount(s.workspace_id, kind) : 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
268
297
|
const short = (sha: string | null) => (sha ? sha.slice(0, 7) : "its head");
|
|
269
298
|
|
|
270
299
|
// What an approval lets the orchestrator do, said in the event so it can't
|
|
@@ -320,6 +349,10 @@ export function answerAsk(
|
|
|
320
349
|
const text =
|
|
321
350
|
answer.action === "reply" ? cap(answer.text, 4000) : answer.action;
|
|
322
351
|
if (answer.action === "reply" && !text) throw new Error("The reply is empty");
|
|
352
|
+
// Another lead's ask is answered in its own chat; Tech's events stay
|
|
353
|
+
// exactly as they were.
|
|
354
|
+
const ownLead =
|
|
355
|
+
ask.lead_kind && ask.lead_kind !== TECH ? ask.lead_kind : null;
|
|
323
356
|
const line = answerLine(
|
|
324
357
|
ask,
|
|
325
358
|
answer.action === "reply" ? { action: "reply", text } : answer,
|
|
@@ -330,11 +363,18 @@ export function answerAsk(
|
|
|
330
363
|
`UPDATE orchestrator_asks SET status = ?, answer = ?, resolved_at = datetime('now') WHERE id = ?`
|
|
331
364
|
).run(STATUS_OF[answer.action], text, id);
|
|
332
365
|
if (ask.kind === "passkey") settlePasskeyAsk(ask, answer.action);
|
|
333
|
-
else queueEvent(workspaceId, answerKey(id), null, line);
|
|
366
|
+
else if (!ownLead) queueEvent(workspaceId, answerKey(id), null, line);
|
|
334
367
|
})();
|
|
368
|
+
if (ownLead && ask.kind !== "passkey")
|
|
369
|
+
void tellLead(workspaceId, ownLead, line);
|
|
335
370
|
// An auto-approval is noted once, by recordAutoApproval.
|
|
336
371
|
if (by === "saad")
|
|
337
|
-
addNote(
|
|
372
|
+
addNote(
|
|
373
|
+
workspaceId,
|
|
374
|
+
`${ownerUi(true)} answered ${line}`,
|
|
375
|
+
"ask",
|
|
376
|
+
ownLead ?? undefined
|
|
377
|
+
);
|
|
338
378
|
return getAsk(workspaceId, id)!;
|
|
339
379
|
}
|
|
340
380
|
|