@saadnvd1/agent-os 0.3.700 → 0.3.715
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/README.md +1 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/claims/[id]/route.ts +20 -0
- package/app/api/claims/check/route.ts +23 -0
- package/app/api/claims/check.test.ts +113 -0
- package/app/api/claims/exchange/route.ts +42 -0
- package/app/api/claims/outside.test.ts +90 -0
- package/app/api/claims/route.test.ts +193 -0
- package/app/api/claims/route.ts +73 -0
- package/app/api/hosts/[id]/route.ts +9 -1
- package/app/api/peer/unlink/route.ts +17 -0
- package/app/api/peers/[hostId]/audit/route.ts +14 -0
- package/app/api/peers/[hostId]/route.ts +20 -0
- package/app/api/peers/gate.test.ts +64 -1
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +11 -4
- package/app/api/session-groups/[id]/restore/route.ts +11 -0
- package/app/api/session-groups/[id]/route.ts +25 -0
- package/app/api/session-groups/order/route.ts +15 -0
- package/app/api/session-groups/route.ts +21 -0
- package/app/api/sessions/[id]/fork/route.ts +3 -0
- package/app/api/sessions/[id]/group/route.ts +18 -0
- package/app/api/sessions/[id]/route.ts +14 -4
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/sessions/claims.test.ts +122 -0
- package/app/api/sessions/group/route.ts +17 -0
- package/app/api/sessions/route.ts +15 -14
- package/app/page.tsx +5 -0
- package/components/Claims/ClaimFiles.tsx +38 -0
- package/components/Claims/ClaimRow.tsx +82 -0
- package/components/Claims/ClaimsSheet.tsx +176 -0
- package/components/Claims/OverlapPill.tsx +31 -0
- package/components/Claims/StartClaimsNotice.tsx +70 -0
- package/components/Claims/claims-ui.test.ts +161 -0
- package/components/Claims/format.ts +57 -0
- package/components/Claims/index.ts +3 -0
- package/components/Draft/DraftPanel.tsx +40 -2
- package/components/Hosts/AccountMachines.tsx +32 -18
- package/components/Hosts/HostRow.tsx +155 -14
- package/components/Hosts/IncomingLinkSheet.tsx +28 -10
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +7 -1
- package/components/SessionList/SelectionToolbar.tsx +52 -1
- package/components/SessionList/SessionRow.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +17 -0
- package/components/SessionList/SessionShelves.tsx +5 -0
- package/components/SessionList/SidebarBody.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +104 -0
- package/components/SessionList/SidebarContextMenu.tsx +59 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
- package/components/SessionList/index.tsx +18 -14
- package/components/SessionList/useSidebarData.ts +10 -0
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/peers/keys.ts +1 -0
- package/data/peers/queries.ts +31 -0
- package/data/push/topics.ts +4 -0
- package/data/session-groups/index.ts +156 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/pin.ts +2 -6
- package/data/sessions/queries.ts +10 -29
- package/data/sessions/shelf.ts +2 -6
- package/data/tasks/index.ts +2 -0
- package/hooks/useSessions.ts +0 -12
- package/lib/__fixtures__/peer-rpc.ts +65 -33
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/index.ts +8 -0
- package/lib/claims/collectLocal.ts +121 -0
- package/lib/claims/describe.ts +41 -0
- package/lib/claims/exchange.ts +91 -0
- package/lib/claims/goal.ts +45 -0
- package/lib/claims/index.ts +10 -0
- package/lib/claims/loop.ts +51 -0
- package/lib/claims/machine.ts +21 -0
- package/lib/claims/mine.ts +43 -0
- package/lib/claims/notifyOverlaps.ts +101 -0
- package/lib/claims/onMerged.ts +85 -0
- package/lib/claims/outside.ts +36 -0
- package/lib/claims/overlap.ts +87 -0
- package/lib/claims/overlapCache.ts +86 -0
- package/lib/claims/overlapText.ts +36 -0
- package/lib/claims/peers.ts +56 -0
- package/lib/claims/startCheck.ts +100 -0
- package/lib/claims/startWarning.ts +29 -0
- package/lib/claims/store.ts +366 -0
- package/lib/claims/types.ts +102 -0
- package/lib/claims/view.ts +80 -0
- package/lib/claims/yield.ts +24 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
- package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
- package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
- package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
- package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
- package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
- package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
- package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +1 -0
- package/lib/db/types.ts +16 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/orchestration.ts +2 -0
- package/lib/orchestrator/act.ts +54 -4
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claim-wait.ts +71 -0
- package/lib/orchestrator/conditions.ts +28 -1
- package/lib/orchestrator/describe.ts +6 -1
- package/lib/orchestrator/facts.ts +5 -0
- package/lib/orchestrator/serve.ts +37 -2
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +31 -1
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/orchestrator/watcher.ts +19 -1
- package/lib/peer/account.ts +74 -2
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/client.ts +26 -1
- package/lib/peer/link-cli.ts +19 -4
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +14 -2
- package/lib/peer/name.ts +32 -0
- package/lib/peer/pairing.ts +34 -5
- package/lib/peer/pin.ts +10 -3
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-server.ts +53 -5
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/security/demo.ts +7 -0
- package/lib/session-groups/assignSessions.ts +81 -0
- package/lib/session-groups/cli.ts +61 -0
- package/lib/session-groups/createGroup.ts +28 -0
- package/lib/session-groups/index.ts +21 -0
- package/lib/session-groups/removeGroup.ts +36 -0
- package/lib/session-groups/reorderGroups.ts +31 -0
- package/lib/session-groups/resolveGroup.ts +19 -0
- package/lib/session-groups/route.ts +32 -0
- package/lib/session-groups/store.ts +85 -0
- package/lib/session-groups/types.ts +25 -0
- package/lib/session-groups/updateGroup.ts +35 -0
- package/lib/session-groups/workspace.ts +21 -0
- package/lib/sessions/launch.ts +5 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/lib/sidebar/shelves.ts +90 -15
- package/lib/sidebar/visible-order.ts +5 -0
- package/lib/tasks/hold-checks.ts +3 -1
- package/lib/tasks/holds.ts +3 -0
- package/lib/tasks/index.ts +5 -0
- package/lib/tasks/queue-rows.ts +8 -1
- package/lib/tasks/queue-start.ts +1 -0
- package/lib/tasks/queue-types.ts +9 -1
- package/lib/tasks/queue.ts +15 -5
- package/lib/tasks/schedule-types.ts +1 -0
- package/lib/test-temp.ts +19 -3
- package/lib/workspaces.ts +11 -3
- package/lib/worktree-sweep/testTemp.ts +23 -106
- package/package.json +1 -1
- package/scripts/agent-os +1 -1
- package/scripts/autodeploy +2 -0
- package/scripts/autodeploy.test.ts +31 -1
- package/scripts/claim-hook.sh +31 -0
- package/scripts/claim-hook.test.ts +230 -0
- package/scripts/lib/cli-links.sh +36 -0
- package/scripts/lib/commands.sh +3 -0
- package/scripts/lib/machine.sh +27 -2
- package/scripts/lib/machine.test.ts +25 -3
- package/scripts/sync-cli-links +7 -0
- package/scripts/sync-cli-links.test.ts +69 -0
- package/server.ts +3 -0
- package/stores/claimsUi.ts +22 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { runForSession } from "../recipes/store";
|
|
11
11
|
import { db, type StackItemStatus, type StackStatus } from "../db";
|
|
12
12
|
import { isFinished, type TaskPR } from "../tasks/state";
|
|
13
|
+
import { eventLine } from "../claims/overlapText";
|
|
14
|
+
import type { SessionOverlap } from "../claims/mine";
|
|
13
15
|
import { ciWord } from "./describe";
|
|
14
16
|
import type { SessionFacts } from "./facts";
|
|
15
17
|
import { untrusted } from "./untrusted";
|
|
@@ -192,6 +194,29 @@ function stackConditions(s: StackFacts): Condition[] {
|
|
|
192
194
|
return out;
|
|
193
195
|
}
|
|
194
196
|
|
|
197
|
+
// A strong overlap between a session here and other work, anywhere. Held
|
|
198
|
+
// until it ends, so it goes through the events' two-diff damping and
|
|
199
|
+
// REFIRE_MS, and an overlap that only flickers is dropped.
|
|
200
|
+
function overlapConditions(
|
|
201
|
+
facts: SessionFacts[],
|
|
202
|
+
overlaps: SessionOverlap[]
|
|
203
|
+
): Condition[] {
|
|
204
|
+
const names = new Map(facts.map((f) => [f.id, f.name]));
|
|
205
|
+
return overlaps.flatMap((o) => {
|
|
206
|
+
const name = names.get(o.sessionId);
|
|
207
|
+
if (!name) return [];
|
|
208
|
+
return [
|
|
209
|
+
{
|
|
210
|
+
key: conditionKey(`overlap:${o.pairKey}`, o.sessionId),
|
|
211
|
+
subject: o.sessionId,
|
|
212
|
+
line: eventLine(o, name),
|
|
213
|
+
sticky: false,
|
|
214
|
+
low: true,
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
195
220
|
// Sessions the orchestrator itself messaged this recently don't raise
|
|
196
221
|
// "needs input": that's most likely it waiting on its own question.
|
|
197
222
|
export const QUIET_AFTER_MESSAGE_MS = 2 * 60 * 1000;
|
|
@@ -200,11 +225,13 @@ export function conditionsFor(
|
|
|
200
225
|
facts: SessionFacts[],
|
|
201
226
|
stacks: StackFacts[],
|
|
202
227
|
now = Date.now(),
|
|
203
|
-
quiet: ReadonlySet<string> = new Set()
|
|
228
|
+
quiet: ReadonlySet<string> = new Set(),
|
|
229
|
+
overlaps: SessionOverlap[] = []
|
|
204
230
|
): Condition[] {
|
|
205
231
|
return [
|
|
206
232
|
...facts.flatMap((f) => sessionConditions(f, now, quiet.has(f.id))),
|
|
207
233
|
...stacks.flatMap(stackConditions),
|
|
234
|
+
...overlapConditions(facts, overlaps),
|
|
208
235
|
];
|
|
209
236
|
}
|
|
210
237
|
|
|
@@ -37,6 +37,7 @@ function sessionLine(f: SessionFacts): string {
|
|
|
37
37
|
task += `, BLOCKED: ${untrusted(f.name, f.task.blocked)}`;
|
|
38
38
|
parts.push(task);
|
|
39
39
|
}
|
|
40
|
+
if (f.group) parts.push(`in group "${f.group}"`);
|
|
40
41
|
if (f.plan)
|
|
41
42
|
parts.push(
|
|
42
43
|
`${f.plan.awaiting ? "plan ready, awaiting your approve_plan" : f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
|
|
@@ -66,7 +67,8 @@ function sessionLine(f: SessionFacts): string {
|
|
|
66
67
|
// The sessions tool's answer: one line per session, busiest news first.
|
|
67
68
|
export function describeSessions(
|
|
68
69
|
workspace: string,
|
|
69
|
-
facts: SessionFacts[]
|
|
70
|
+
facts: SessionFacts[],
|
|
71
|
+
groups: readonly string[] = []
|
|
70
72
|
): string {
|
|
71
73
|
if (!facts.length) return `No sessions are running in ${workspace}.`;
|
|
72
74
|
const rank = { waiting: 0, running: 1, idle: 2, dead: 3 };
|
|
@@ -82,5 +84,8 @@ export function describeSessions(
|
|
|
82
84
|
return [
|
|
83
85
|
`${facts.length} ${noun} in ${workspace}${summary}:`,
|
|
84
86
|
...sorted.map(sessionLine),
|
|
87
|
+
...(groups.length
|
|
88
|
+
? [`Saad's groups here: ${groups.map((g) => `"${g}"`).join(", ")}.`]
|
|
89
|
+
: []),
|
|
85
90
|
].join("\n");
|
|
86
91
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* read this.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { groupNames } from "../session-groups/store";
|
|
8
9
|
import { db, type Session, type StackItemStatus } from "../db";
|
|
9
10
|
import { statusDetector } from "../status-detector";
|
|
10
11
|
import { hostLink } from "../hosts/remote-api";
|
|
@@ -43,6 +44,8 @@ export interface SessionFacts {
|
|
|
43
44
|
stalled?: Stall | null;
|
|
44
45
|
// Its agent is summarising the context: not stuck, not plain work.
|
|
45
46
|
compacting?: boolean;
|
|
47
|
+
// Saad's group it sits in, when it is in one.
|
|
48
|
+
group?: string | null;
|
|
46
49
|
// Last sign of life, in ms.
|
|
47
50
|
lastActive: number;
|
|
48
51
|
// Open waits (aos send --await, aos wait): who it waits on, and who waits on it.
|
|
@@ -308,6 +311,7 @@ export async function sessionFacts(
|
|
|
308
311
|
): Promise<SessionFacts[]> {
|
|
309
312
|
await statusDetector.refreshCache();
|
|
310
313
|
const badges = waitBadges();
|
|
314
|
+
const groups = groupNames();
|
|
311
315
|
return Promise.all(
|
|
312
316
|
workspaceSessions(workspaceId).map(async (s) => {
|
|
313
317
|
const [{ status, activity, needsInput }, task] = await Promise.all([
|
|
@@ -336,6 +340,7 @@ export async function sessionFacts(
|
|
|
336
340
|
s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
|
|
337
341
|
compacting:
|
|
338
342
|
s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
|
|
343
|
+
group: groups.get(s.session_group_id ?? "") ?? null,
|
|
339
344
|
lastActive: await lastActiveFor(s),
|
|
340
345
|
branch: s.branch_name,
|
|
341
346
|
quick: !!s.quick_of,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resumeOnModel } from "../models/resume";
|
|
2
2
|
import { workspaceTask } from "./targets";
|
|
3
3
|
import { getWorkspace } from "../workspaces";
|
|
4
|
+
import { listGroups } from "../session-groups/store";
|
|
4
5
|
import {
|
|
5
6
|
approveTaskPlan,
|
|
6
7
|
archive,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
send,
|
|
12
13
|
stack,
|
|
13
14
|
stackStatus,
|
|
15
|
+
setGroup,
|
|
14
16
|
startSession,
|
|
15
17
|
startTask,
|
|
16
18
|
startNow,
|
|
@@ -19,6 +21,10 @@ import {
|
|
|
19
21
|
resumeTask,
|
|
20
22
|
stop,
|
|
21
23
|
} from "./act";
|
|
24
|
+
import { describeClaims, liveClaims } from "../claims";
|
|
25
|
+
import { repoKey } from "../git-remote";
|
|
26
|
+
import { expandHome } from "../tasks/session";
|
|
27
|
+
import { workspaceProjects } from "./brief";
|
|
22
28
|
import { readCards } from "./cards";
|
|
23
29
|
import { readLogs } from "./logs";
|
|
24
30
|
import { describeSessions } from "./describe";
|
|
@@ -59,6 +65,7 @@ const ACTING = new Set<ToolName>([
|
|
|
59
65
|
"start_task",
|
|
60
66
|
"set_task_model",
|
|
61
67
|
"start_session",
|
|
68
|
+
"set_group",
|
|
62
69
|
"run_recipe",
|
|
63
70
|
"recipe_save",
|
|
64
71
|
"recipe_update",
|
|
@@ -83,6 +90,21 @@ const ACTING = new Set<ToolName>([
|
|
|
83
90
|
"add_project",
|
|
84
91
|
]);
|
|
85
92
|
|
|
93
|
+
// What live sessions on other machines claim in this workspace's repos.
|
|
94
|
+
async function claimedElsewhere(w: string): Promise<string> {
|
|
95
|
+
const keys = await Promise.all(
|
|
96
|
+
workspaceProjects(w)
|
|
97
|
+
.filter((p) => !p.host_id || p.host_id === "local")
|
|
98
|
+
.map((p) => repoKey(expandHome(p.working_directory)))
|
|
99
|
+
);
|
|
100
|
+
const repos = new Set(keys.filter((k): k is string => !!k));
|
|
101
|
+
if (!repos.size) return "";
|
|
102
|
+
return describeClaims(
|
|
103
|
+
liveClaims().filter((c) => repos.has(c.repo)),
|
|
104
|
+
{ onlyOthers: true }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
// The orchestrator's tools, each scoped to its workspace and its arguments
|
|
87
109
|
// validated, answered as compact text.
|
|
88
110
|
export async function runTool(
|
|
@@ -97,14 +119,20 @@ export async function runTool(
|
|
|
97
119
|
switch (tool) {
|
|
98
120
|
case "sessions": {
|
|
99
121
|
p(tool, raw);
|
|
100
|
-
const [facts, external] = await Promise.all([
|
|
122
|
+
const [facts, external, claimed] = await Promise.all([
|
|
101
123
|
sessionFacts(w),
|
|
102
124
|
externalSessions(w).catch(() => []),
|
|
125
|
+
claimedElsewhere(w).catch(() => ""),
|
|
103
126
|
]);
|
|
104
127
|
resolveStaleAsks(w);
|
|
105
128
|
return [
|
|
106
|
-
describeSessions(
|
|
129
|
+
describeSessions(
|
|
130
|
+
workspace.name,
|
|
131
|
+
facts,
|
|
132
|
+
listGroups(w).map((g) => g.name)
|
|
133
|
+
),
|
|
107
134
|
describeExternal(external),
|
|
135
|
+
claimed,
|
|
108
136
|
]
|
|
109
137
|
.filter(Boolean)
|
|
110
138
|
.join("\n\n");
|
|
@@ -147,7 +175,9 @@ export async function runTool(
|
|
|
147
175
|
machine: a.machine,
|
|
148
176
|
now: a.now,
|
|
149
177
|
nowReason: a.now_reason,
|
|
178
|
+
ignoreClaims: a.ignore_claims,
|
|
150
179
|
piece: a.piece,
|
|
180
|
+
group: a.group,
|
|
151
181
|
});
|
|
152
182
|
}
|
|
153
183
|
case "start_now": {
|
|
@@ -187,8 +217,13 @@ export async function runTool(
|
|
|
187
217
|
name: a.name,
|
|
188
218
|
view: a.view,
|
|
189
219
|
model: a.model,
|
|
220
|
+
group: a.group,
|
|
190
221
|
});
|
|
191
222
|
}
|
|
223
|
+
case "set_group": {
|
|
224
|
+
const a = p(tool, raw);
|
|
225
|
+
return setGroup(w, a.session, a.group);
|
|
226
|
+
}
|
|
192
227
|
case "quick_change": {
|
|
193
228
|
const a = p(tool, raw);
|
|
194
229
|
return quickChange(w, a.project, a.name);
|
|
@@ -20,6 +20,7 @@ export const TOOL_NAMES = {
|
|
|
20
20
|
start_task: name("start_task"),
|
|
21
21
|
set_task_model: name("set_task_model"),
|
|
22
22
|
start_session: name("start_session"),
|
|
23
|
+
set_group: name("set_group"),
|
|
23
24
|
quick_change: name("quick_change"),
|
|
24
25
|
quick_pr: name("quick_pr"),
|
|
25
26
|
stack: name("stack"),
|
|
@@ -22,6 +22,16 @@ const view = z
|
|
|
22
22
|
"How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
|
|
23
23
|
);
|
|
24
24
|
|
|
25
|
+
const group = z
|
|
26
|
+
.string()
|
|
27
|
+
.trim()
|
|
28
|
+
.min(1)
|
|
29
|
+
.max(60)
|
|
30
|
+
.optional()
|
|
31
|
+
.describe(
|
|
32
|
+
"One of Saad's groups in this workspace, by name (see sessions): file it there when he asked, or when it continues work already in that group. It must exist; you can't create groups"
|
|
33
|
+
);
|
|
34
|
+
|
|
25
35
|
const sweepMachine = z
|
|
26
36
|
.string()
|
|
27
37
|
.trim()
|
|
@@ -96,6 +106,7 @@ export const TOOL_SHAPES = {
|
|
|
96
106
|
},
|
|
97
107
|
start_task: {
|
|
98
108
|
project,
|
|
109
|
+
group,
|
|
99
110
|
prompt: z
|
|
100
111
|
.string()
|
|
101
112
|
.trim()
|
|
@@ -122,7 +133,7 @@ export const TOOL_SHAPES = {
|
|
|
122
133
|
.max(200)
|
|
123
134
|
.optional()
|
|
124
135
|
.describe(
|
|
125
|
-
'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first'
|
|
136
|
+
'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first, or "claim:<id>" for work another machine has claimed'
|
|
126
137
|
),
|
|
127
138
|
priority: z
|
|
128
139
|
.enum(PRIORITIES)
|
|
@@ -149,6 +160,12 @@ export const TOOL_SHAPES = {
|
|
|
149
160
|
.max(300)
|
|
150
161
|
.optional()
|
|
151
162
|
.describe("With now: why it can't wait for a slot; logged for Saad"),
|
|
163
|
+
ignore_claims: z
|
|
164
|
+
.boolean()
|
|
165
|
+
.optional()
|
|
166
|
+
.describe(
|
|
167
|
+
"Don't queue it after work another session (here or on a linked machine) has claimed on the same branch or files; start it as usual. Not needed with now"
|
|
168
|
+
),
|
|
152
169
|
piece: z
|
|
153
170
|
.string()
|
|
154
171
|
.trim()
|
|
@@ -196,6 +213,7 @@ export const TOOL_SHAPES = {
|
|
|
196
213
|
},
|
|
197
214
|
start_session: {
|
|
198
215
|
project,
|
|
216
|
+
group,
|
|
199
217
|
prompt: z.string().trim().min(1).max(20000),
|
|
200
218
|
name: z
|
|
201
219
|
.string()
|
|
@@ -211,6 +229,18 @@ export const TOOL_SHAPES = {
|
|
|
211
229
|
'One model for the session, "provider/model"; default: the implementation model. Opus only when the work needs deep reasoning'
|
|
212
230
|
),
|
|
213
231
|
},
|
|
232
|
+
set_group: {
|
|
233
|
+
session,
|
|
234
|
+
group: z
|
|
235
|
+
.string()
|
|
236
|
+
.trim()
|
|
237
|
+
.min(1)
|
|
238
|
+
.max(60)
|
|
239
|
+
.nullable()
|
|
240
|
+
.describe(
|
|
241
|
+
"One of Saad's groups in this workspace by name, or null to take the session out of its group"
|
|
242
|
+
),
|
|
243
|
+
},
|
|
214
244
|
quick_change: {
|
|
215
245
|
project,
|
|
216
246
|
name: z
|
|
@@ -126,11 +126,13 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
126
126
|
message_orchestrator:
|
|
127
127
|
"Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
|
|
128
128
|
start_task:
|
|
129
|
-
'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. piece names the waiting piece of a plan this task builds, so it is claimed and not started a second time. Refused while a brake holds.',
|
|
129
|
+
'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. now true (with now_reason) starts it at once past the running-task limit instead of queueing for a slot, logged; not with after. A strong overlap with work another session has claimed (same branch, or the same files, here or on a linked machine) queues the start after it and says so; the result also lists weaker overlaps. ignore_claims true skips that. piece names the waiting piece of a plan this task builds, so it is claimed and not started a second time. Refused while a brake holds.',
|
|
130
130
|
set_task_model:
|
|
131
131
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
132
132
|
start_session:
|
|
133
133
|
'Start an agent session in a project with a prompt, for work that isn\'t a PR task. It runs on the implementation model unless you give model (one "provider/model"; Opus only when the work needs deep reasoning), as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.',
|
|
134
|
+
set_group:
|
|
135
|
+
"File a session of this workspace in one of Saad's groups (by name), or out of its group with null. Groups only organise his sidebar; they change nothing about the session. You can use groups that exist but can't create, rename or delete them.",
|
|
134
136
|
quick_change:
|
|
135
137
|
"Make a change yourself (without full access, a small low-risk one: a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. Its dependencies install in the background (a note says when). You may write there (Write, Edit) and, with Orchestrator full access, run any git there; without it, only `git -C <path> ...` status, diff, add, rm, mv, restore, reset, log, show, and nowhere else in a project. Without full access, anything needing real work, tests or an investigation is a task (start_task).",
|
|
136
138
|
quick_pr:
|
|
@@ -14,6 +14,9 @@ import { deliverEvents } from "./deliver";
|
|
|
14
14
|
import { recordConditions } from "./events";
|
|
15
15
|
import { sessionFacts, type SessionFacts } from "./facts";
|
|
16
16
|
import { listOrchestrators } from "./home";
|
|
17
|
+
import { claimsShared, machineId } from "../claims/machine";
|
|
18
|
+
import { sessionOverlaps, type SessionOverlap } from "../claims/mine";
|
|
19
|
+
import { liveClaims } from "../claims/store";
|
|
17
20
|
import { BRAKE_SUBJECT, openAsks } from "./asks";
|
|
18
21
|
import { settleStaleAsks } from "./ask-settle";
|
|
19
22
|
import { brakesOn, liftBrake } from "./brakes";
|
|
@@ -35,6 +38,15 @@ async function settleAsks(workspaceId: string): Promise<void> {
|
|
|
35
38
|
if (brake && !(await brakesOn(workspaceId)).length) liftBrake(workspaceId);
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
// Claims code never breaks the watcher: any error means no overlaps.
|
|
42
|
+
function localOverlaps(): SessionOverlap[] {
|
|
43
|
+
try {
|
|
44
|
+
return claimsShared() ? sessionOverlaps(machineId(), liveClaims()) : [];
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
const DELIVER_EVERY_MS = 5000;
|
|
39
51
|
export const DIFF_BUSY_MS = 15 * 1000;
|
|
40
52
|
export const DIFF_IDLE_MS = 60 * 1000;
|
|
@@ -58,7 +70,13 @@ export async function diffWorkspace(
|
|
|
58
70
|
];
|
|
59
71
|
recordConditions(
|
|
60
72
|
workspaceId,
|
|
61
|
-
conditionsFor(
|
|
73
|
+
conditionsFor(
|
|
74
|
+
facts,
|
|
75
|
+
stacks,
|
|
76
|
+
now,
|
|
77
|
+
recentlyMessaged(orchestratorId, now),
|
|
78
|
+
localOverlaps()
|
|
79
|
+
),
|
|
62
80
|
subjects,
|
|
63
81
|
now
|
|
64
82
|
);
|
package/lib/peer/account.ts
CHANGED
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
} from "../connect/control-api";
|
|
20
20
|
import { peerHostAllowed } from "./client";
|
|
21
21
|
import { rawPublic } from "./keys";
|
|
22
|
+
import { cleanMachineName } from "./name";
|
|
23
|
+
import { reconcilePins, type AccountSays } from "./revoke";
|
|
24
|
+
import { getSetting, putSetting } from "../settings-store";
|
|
22
25
|
|
|
23
26
|
export interface AccountPeer {
|
|
24
27
|
id: string;
|
|
@@ -37,9 +40,15 @@ export interface AccountView {
|
|
|
37
40
|
/** When the list was last fetched, 0 if never. */
|
|
38
41
|
fetchedAt: number;
|
|
39
42
|
error: string | null;
|
|
43
|
+
/** Set when the service hasn't answered for a day: since when. */
|
|
44
|
+
uncheckedSince: number | null;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export const LIST_MAX_AGE_MS = 60_000;
|
|
48
|
+
export const UNCHECKED_NOTICE_MS = 24 * 3_600_000;
|
|
49
|
+
const OK_KEY = "peer.account_ok_at";
|
|
50
|
+
// The last answer is written down at most this often (it survives restarts).
|
|
51
|
+
const OK_SAVE_MS = 10 * 60_000;
|
|
43
52
|
// Never more often than this, whoever asks.
|
|
44
53
|
const MIN_GAP_MS = 5_000;
|
|
45
54
|
|
|
@@ -52,6 +61,8 @@ interface State {
|
|
|
52
61
|
stale: boolean;
|
|
53
62
|
error: string | null;
|
|
54
63
|
inflight: Promise<void> | null;
|
|
64
|
+
okAt: number;
|
|
65
|
+
okSavedAt: number;
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export type Fetcher = () => Promise<PeerList | null>;
|
|
@@ -70,6 +81,8 @@ const fresh = (): State => ({
|
|
|
70
81
|
stale: false,
|
|
71
82
|
error: null,
|
|
72
83
|
inflight: null,
|
|
84
|
+
okAt: 0,
|
|
85
|
+
okSavedAt: 0,
|
|
73
86
|
});
|
|
74
87
|
|
|
75
88
|
const state = () => (g.__agentosPeerAccount ??= fresh());
|
|
@@ -97,6 +110,42 @@ export function rawFromSpki(pem: string): string | null {
|
|
|
97
110
|
}
|
|
98
111
|
}
|
|
99
112
|
|
|
113
|
+
/** The service answered: links can be checked against it again. */
|
|
114
|
+
export function noteAccountOk(now = Date.now()) {
|
|
115
|
+
const s = state();
|
|
116
|
+
s.okAt = now;
|
|
117
|
+
if (now - s.okSavedAt < OK_SAVE_MS) return;
|
|
118
|
+
s.okSavedAt = now;
|
|
119
|
+
try {
|
|
120
|
+
putSetting(OK_KEY, String(now));
|
|
121
|
+
} catch {
|
|
122
|
+
// Bookkeeping only.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** When the service last answered; a first look starts the clock. */
|
|
127
|
+
function lastOk(now: number): number {
|
|
128
|
+
const s = state();
|
|
129
|
+
if (s.okAt) return s.okAt;
|
|
130
|
+
let saved = NaN;
|
|
131
|
+
try {
|
|
132
|
+
saved = Number(getSetting(OK_KEY));
|
|
133
|
+
} catch {
|
|
134
|
+
// No database: count from now.
|
|
135
|
+
}
|
|
136
|
+
if (Number.isFinite(saved) && saved > 0) return (s.okAt = saved);
|
|
137
|
+
noteAccountOk(now);
|
|
138
|
+
return now;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function applyToPins(says: AccountSays) {
|
|
142
|
+
try {
|
|
143
|
+
reconcilePins(says);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error("peer revoke:", err);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
100
149
|
function adopt(list: PeerList) {
|
|
101
150
|
const s = state();
|
|
102
151
|
const peers: AccountPeer[] = [];
|
|
@@ -107,7 +156,7 @@ function adopt(list: PeerList) {
|
|
|
107
156
|
if (!signPub || !peerHostAllowed(m.hostname)) continue;
|
|
108
157
|
peers.push({
|
|
109
158
|
id: m.id,
|
|
110
|
-
name: m.name,
|
|
159
|
+
name: cleanMachineName(m.name),
|
|
111
160
|
hostname: m.hostname,
|
|
112
161
|
signPub,
|
|
113
162
|
lastSeenAt: m.lastSeenAt,
|
|
@@ -120,6 +169,11 @@ function adopt(list: PeerList) {
|
|
|
120
169
|
s.fetchedAt = Date.now();
|
|
121
170
|
s.stale = false;
|
|
122
171
|
s.error = null;
|
|
172
|
+
noteAccountOk(s.fetchedAt);
|
|
173
|
+
applyToPins({
|
|
174
|
+
linkMachines: s.entitlements?.linkMachines ?? null,
|
|
175
|
+
listed: new Map(peers.map((p) => [p.id, p.signPub])),
|
|
176
|
+
});
|
|
123
177
|
}
|
|
124
178
|
|
|
125
179
|
async function refresh(): Promise<void> {
|
|
@@ -161,15 +215,19 @@ export async function accountView(
|
|
|
161
215
|
peers: [],
|
|
162
216
|
fetchedAt: 0,
|
|
163
217
|
error: null,
|
|
218
|
+
uncheckedSince: null,
|
|
164
219
|
};
|
|
165
220
|
const s = state();
|
|
166
221
|
if (s.stale || Date.now() - s.fetchedAt > maxAgeMs) await refresh();
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
const ok = lastOk(now);
|
|
167
224
|
return {
|
|
168
225
|
enrolled: true,
|
|
169
226
|
entitlements: s.entitlements,
|
|
170
227
|
peers: s.peers,
|
|
171
228
|
fetchedAt: s.fetchedAt,
|
|
172
229
|
error: s.error,
|
|
230
|
+
uncheckedSince: now - ok > UNCHECKED_NOTICE_MS ? ok : null,
|
|
173
231
|
};
|
|
174
232
|
}
|
|
175
233
|
|
|
@@ -187,12 +245,26 @@ export async function linkingAllowed(): Promise<boolean | null> {
|
|
|
187
245
|
return entitlements ? entitlements.linkMachines : null;
|
|
188
246
|
}
|
|
189
247
|
|
|
190
|
-
/**
|
|
248
|
+
/**
|
|
249
|
+
* What a heartbeat says: the service is there, the plan now, and whether the
|
|
250
|
+
* list changed, which is fetched again at once so a machine removed from the
|
|
251
|
+
* account is suspended within a beat or so.
|
|
252
|
+
*/
|
|
191
253
|
export function noteHeartbeat(beat: Beat) {
|
|
192
254
|
const s = state();
|
|
255
|
+
noteAccountOk();
|
|
256
|
+
const plan = beat.entitlements?.linkMachines;
|
|
257
|
+
const planChanged =
|
|
258
|
+
plan !== undefined && plan !== s.entitlements?.linkMachines;
|
|
193
259
|
if (beat.entitlements) s.entitlements = beat.entitlements;
|
|
194
260
|
if (beat.peersEtag && beat.peersEtag !== s.etag) {
|
|
195
261
|
s.etag = beat.peersEtag;
|
|
196
262
|
s.stale = true;
|
|
197
263
|
}
|
|
264
|
+
// A downgrade suspends now. An upgrade waits for a fresh list, so a pin
|
|
265
|
+
// whose machine left the account meanwhile isn't resumed on the way.
|
|
266
|
+
if (planChanged && plan === false)
|
|
267
|
+
applyToPins({ linkMachines: false, listed: null });
|
|
268
|
+
else if (planChanged) s.stale = true;
|
|
269
|
+
if (s.stale && enrolled()) void refresh();
|
|
198
270
|
}
|
package/lib/peer/allowlist.ts
CHANGED
|
@@ -67,7 +67,7 @@ export const PEER_ROUTES: readonly PeerRoute[] = [
|
|
|
67
67
|
{ method: "POST", pattern: "/api/peer/unlink" },
|
|
68
68
|
{ method: "GET", pattern: "/api/peer/ping" },
|
|
69
69
|
|
|
70
|
-
// The work registry's exchange
|
|
70
|
+
// The work registry's exchange.
|
|
71
71
|
{ method: "POST", pattern: "/api/claims/exchange" },
|
|
72
72
|
];
|
|
73
73
|
|
package/lib/peer/client.ts
CHANGED
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
sealRequest,
|
|
16
16
|
} from "./envelope";
|
|
17
17
|
import { boxPublic, currentIdentity, signPublic, type Identity } from "./keys";
|
|
18
|
-
import { notePinResult } from "./links";
|
|
18
|
+
import { notePinResult, unlinkedByPeer } from "./links";
|
|
19
|
+
import { UNLINKED_PROOF_HEADER, verifyUnlinked } from "./unlinked-proof";
|
|
19
20
|
import type { HostLink } from "../hosts/remote-api";
|
|
20
21
|
import { readCappedBytes } from "../hosts/read-capped-bytes";
|
|
21
22
|
|
|
@@ -67,12 +68,15 @@ export interface ClientDeps {
|
|
|
67
68
|
identity: () => Identity | null;
|
|
68
69
|
audit: (e: AuditEntry) => void;
|
|
69
70
|
noteResult: (hostId: string, error: string | null) => void;
|
|
71
|
+
/** It proved it unlinked this machine: drop the pin here too. */
|
|
72
|
+
unlinked: (hostId: string, name: string) => void;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
const realDeps: ClientDeps = {
|
|
73
76
|
identity: () => currentIdentity(),
|
|
74
77
|
audit: recordPeerCall,
|
|
75
78
|
noteResult: notePinResult,
|
|
79
|
+
unlinked: unlinkedByPeer,
|
|
76
80
|
};
|
|
77
81
|
|
|
78
82
|
export async function peerCall(
|
|
@@ -155,6 +159,27 @@ export async function peerCall(
|
|
|
155
159
|
}
|
|
156
160
|
if (res.status !== 200) {
|
|
157
161
|
const text = raw.subarray(0, 64).toString("utf8").trim();
|
|
162
|
+
if (
|
|
163
|
+
res.status === 403 &&
|
|
164
|
+
text === "unknown peer" &&
|
|
165
|
+
verifyUnlinked(
|
|
166
|
+
signPublic(peer.signPub),
|
|
167
|
+
{
|
|
168
|
+
from: peer.machineId,
|
|
169
|
+
to: identity.machineId,
|
|
170
|
+
nonce: sealed.nonce.toString("base64url"),
|
|
171
|
+
},
|
|
172
|
+
res.headers.get(UNLINKED_PROOF_HEADER)
|
|
173
|
+
)
|
|
174
|
+
) {
|
|
175
|
+
audit("unknown_peer", 403, sealed.envelope.length);
|
|
176
|
+
deps.unlinked(link.hostId, link.hostName);
|
|
177
|
+
throw new PeerCallError(
|
|
178
|
+
`${link.hostName} unlinked this machine. Link it again in Machines.`,
|
|
179
|
+
true,
|
|
180
|
+
403
|
|
181
|
+
);
|
|
182
|
+
}
|
|
158
183
|
const outcome = REFUSALS[text];
|
|
159
184
|
if (outcome && res.status >= 400 && res.status < 500) {
|
|
160
185
|
audit(outcome, res.status, sealed.envelope.length);
|
package/lib/peer/link-cli.ts
CHANGED
|
@@ -100,7 +100,9 @@ async function list(io: CliIo) {
|
|
|
100
100
|
);
|
|
101
101
|
for (const r of requests)
|
|
102
102
|
io.out(
|
|
103
|
-
|
|
103
|
+
r.state === "accepted"
|
|
104
|
+
? `\n${r.name} is linked here. To finish, type this code on ${r.name}: ${r.confirmCode ? showCode(r.confirmCode) : "(see the browser)"}`
|
|
105
|
+
: `\n${r.name} wants to link here: agent-os link accept <the code it shows>`
|
|
104
106
|
);
|
|
105
107
|
if (account.machines.some((m) => !m.linkState))
|
|
106
108
|
io.out("\nLink one with: agent-os link <name>");
|
|
@@ -177,7 +179,12 @@ async function answer(io: CliIo, verb: "accept" | "decline", rest: string[]) {
|
|
|
177
179
|
io,
|
|
178
180
|
"/api/peers/pending"
|
|
179
181
|
);
|
|
180
|
-
const req = pick(
|
|
182
|
+
const req = pick(
|
|
183
|
+
requests.filter((r) => r.state !== "accepted"),
|
|
184
|
+
from,
|
|
185
|
+
(r) => r.machineId,
|
|
186
|
+
"link request"
|
|
187
|
+
);
|
|
181
188
|
if (verb === "decline") {
|
|
182
189
|
await call(io, `/api/peers/pending/${req.id}/decline`, { method: "POST" });
|
|
183
190
|
io.out(`Declined ${req.name}.`);
|
|
@@ -210,10 +217,18 @@ async function unlink(io: CliIo, ref: string | undefined) {
|
|
|
210
217
|
(h) => h.id,
|
|
211
218
|
"linked machine"
|
|
212
219
|
);
|
|
213
|
-
await call
|
|
220
|
+
const r = await call<{ told: boolean; removed: boolean }>(
|
|
221
|
+
io,
|
|
222
|
+
`/api/peers/${host.id}`,
|
|
223
|
+
{ method: "DELETE" }
|
|
224
|
+
);
|
|
214
225
|
io.out(
|
|
215
|
-
|
|
226
|
+
r.told
|
|
227
|
+
? `Unlinked ${host.name}, on both machines.`
|
|
228
|
+
: `Unlinked ${host.name} here. It couldn't be told now; its next call here is refused and it will show as unlinked.`
|
|
216
229
|
);
|
|
230
|
+
if (!r.removed)
|
|
231
|
+
io.out(`It stays in Machines while it still has projects or sessions.`);
|
|
217
232
|
}
|
|
218
233
|
|
|
219
234
|
/** Runs one command; returns the exit code. */
|