@saadnvd1/agent-os 0.3.698 → 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/send/route.ts +11 -1
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/bus/wait/route.ts +22 -0
- 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/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- 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/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +174 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +14 -0
- 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 +31 -13
- package/app/page.tsx +7 -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 +194 -0
- package/components/Hosts/HostRow.tsx +195 -45
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +246 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +18 -2
- 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/Welcome/ConnectBlock.tsx +1 -1
- 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/index.ts +2 -0
- package/data/peers/keys.ts +7 -0
- package/data/peers/queries.ts +172 -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/brief.ts +3 -1
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +121 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -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/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -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/20261020T1600_bus_waits.ts +35 -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 +20 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/done/archive.ts +2 -0
- 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 +19 -1
- package/lib/orchestrator/facts.ts +22 -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 +270 -0
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/client.ts +26 -1
- package/lib/peer/code.ts +3 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/link-cli.ts +248 -0
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +84 -0
- package/lib/peer/name.ts +32 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +432 -0
- package/lib/peer/pin.ts +62 -0
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-intake.ts +37 -8
- package/lib/peer/rpc-server.ts +72 -6
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +10 -2
- package/lib/security/demo.ts +7 -0
- package/lib/security/gate.ts +3 -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/end.ts +3 -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 +3 -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 +5 -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 +6 -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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which live session a name someone typed reaches, across workspaces.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { db, type Session } from "../db";
|
|
6
|
+
import { getProject } from "../projects";
|
|
7
|
+
import { getWorkspace } from "../workspaces";
|
|
8
|
+
import { previousNames } from "../session-names";
|
|
9
|
+
import { sessionWorkspace } from "../schedules/store";
|
|
10
|
+
import { heldTarget } from "./held-reply";
|
|
11
|
+
import { resolveRef, type Candidate } from "./resolve";
|
|
12
|
+
|
|
13
|
+
export function liveSessions(): Session[] {
|
|
14
|
+
return db
|
|
15
|
+
.prepare(
|
|
16
|
+
`SELECT * FROM session_rows WHERE (task_status IS NULL OR task_status = 'running')
|
|
17
|
+
AND archived_at IS NULL
|
|
18
|
+
ORDER BY updated_at DESC`
|
|
19
|
+
)
|
|
20
|
+
.all() as Session[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function candidates(sessions: Session[]): Candidate[] {
|
|
24
|
+
const old = previousNames();
|
|
25
|
+
const workspaces = new Map<string | null, string | null>();
|
|
26
|
+
const workspaceName = (s: Session) => {
|
|
27
|
+
const id = sessionWorkspace(s);
|
|
28
|
+
if (!workspaces.has(id))
|
|
29
|
+
workspaces.set(id, id ? (getWorkspace(id)?.name ?? null) : null);
|
|
30
|
+
return workspaces.get(id) ?? null;
|
|
31
|
+
};
|
|
32
|
+
return sessions.map((s) => ({
|
|
33
|
+
id: s.id,
|
|
34
|
+
name: s.name,
|
|
35
|
+
projectName: s.project_id ? (getProject(s.project_id)?.name ?? null) : null,
|
|
36
|
+
tmuxName: s.tmux_name,
|
|
37
|
+
previousNames: old.get(s.id) ?? [],
|
|
38
|
+
workspaceName: workspaceName(s),
|
|
39
|
+
orchestrator: s.role === "orchestrator",
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const sessionById = (id: string | null | undefined) =>
|
|
44
|
+
id
|
|
45
|
+
? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
|
|
46
|
+
Session | undefined)
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
// The orchestrator a held caller's ref names, if it names it.
|
|
50
|
+
export function resolveHeldOrchestrator(callerId: string, ref: string) {
|
|
51
|
+
return heldTarget(sessionById(callerId), ref);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A live session by name, old name, project/name, id or id prefix. The note
|
|
55
|
+
// says when the name meant something other than its current owner.
|
|
56
|
+
export function resolveTarget(
|
|
57
|
+
ref: string,
|
|
58
|
+
callerId?: string | null
|
|
59
|
+
): {
|
|
60
|
+
session: Session;
|
|
61
|
+
note?: string;
|
|
62
|
+
} {
|
|
63
|
+
const found = tryResolveTarget(ref, callerId);
|
|
64
|
+
if (!found.ok) throw new Error(found.error);
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "orchestrator" / "my orchestrator": the caller's own workspace orchestrator.
|
|
69
|
+
function ownOrchestrator(
|
|
70
|
+
ref: string,
|
|
71
|
+
sessions: Session[],
|
|
72
|
+
callerId?: string | null
|
|
73
|
+
): Session | undefined {
|
|
74
|
+
if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
|
|
75
|
+
const workspace = sessionById(callerId)?.workspace_id;
|
|
76
|
+
if (!workspace) return undefined;
|
|
77
|
+
return sessions.find(
|
|
78
|
+
(s) => s.role === "orchestrator" && s.workspace_id === workspace
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function tryResolveTarget(
|
|
83
|
+
ref: string,
|
|
84
|
+
callerId?: string | null
|
|
85
|
+
):
|
|
86
|
+
| { ok: true; session: Session; note?: string }
|
|
87
|
+
| { ok: false; none: boolean; error: string } {
|
|
88
|
+
const sessions = liveSessions();
|
|
89
|
+
const own = ownOrchestrator(ref, sessions, callerId);
|
|
90
|
+
if (own) return { ok: true, session: own };
|
|
91
|
+
const r = resolveRef(ref, candidates(sessions));
|
|
92
|
+
if (!r.ok) {
|
|
93
|
+
const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
|
|
94
|
+
? sessions.filter((s) => s.role === "orchestrator")
|
|
95
|
+
: [];
|
|
96
|
+
if (orchestrators.length)
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
none: true,
|
|
100
|
+
error: `You are in no workspace, so "${ref}" is ambiguous. Use <workspace>/orchestrator: ${orchestrators
|
|
101
|
+
.map(
|
|
102
|
+
(s) =>
|
|
103
|
+
`${getWorkspace(s.workspace_id ?? "")?.name ?? "?"}/orchestrator (${s.name}, id ${s.id.slice(0, 8)})`
|
|
104
|
+
)
|
|
105
|
+
.join(", ")}`,
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
none: r.reason === "none",
|
|
110
|
+
error: r.reason === "none" ? `${r.error}. Run: aos peers` : r.error,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
session: sessions.find((s) => s.id === r.id)!,
|
|
116
|
+
note: r.note,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveSession(ref: string): Session {
|
|
121
|
+
return resolveTarget(ref).session;
|
|
122
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Lets the places where a session ends ask for a look at open waits now,
|
|
2
|
+
// without importing the bus. The watcher registers itself here.
|
|
3
|
+
|
|
4
|
+
let kick: (() => void) | null = null;
|
|
5
|
+
|
|
6
|
+
export function onKick(fn: () => void): void {
|
|
7
|
+
kick = fn;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function kickWaits(): void {
|
|
11
|
+
kick?.();
|
|
12
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Every text an agent reads about a wait. Each starts the way a bus message
|
|
2
|
+
// does, so a line left unsent in a terminal's input is recognised as ours.
|
|
3
|
+
|
|
4
|
+
import { shortId } from "../format";
|
|
5
|
+
|
|
6
|
+
const from = "[AgentOS message from AgentOS]";
|
|
7
|
+
const who = (name: string, id: string) => `"${name}" (${shortId(id)})`;
|
|
8
|
+
|
|
9
|
+
export const minutes = (ms: number) =>
|
|
10
|
+
ms >= 3_600_000
|
|
11
|
+
? `${Math.round(ms / 360_000) / 10}h`
|
|
12
|
+
: `${Math.max(1, Math.round(ms / 60_000))}m`;
|
|
13
|
+
|
|
14
|
+
export function nudgeLine(
|
|
15
|
+
waiter: { id: string; name: string },
|
|
16
|
+
request: string | null,
|
|
17
|
+
messageId: number | null
|
|
18
|
+
): string {
|
|
19
|
+
const reply = messageId ? ` --reply ${messageId}` : "";
|
|
20
|
+
return `${from} ${who(waiter.name, waiter.id)} is waiting on your reply${request ? ` to: "${request}"` : ""}. Reply with: aos send ${shortId(waiter.id)}${reply} "<message>"`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function timeoutLine(
|
|
24
|
+
target: { id: string; name: string },
|
|
25
|
+
kind: "reply" | "idle" | "merged",
|
|
26
|
+
waitedMs: number,
|
|
27
|
+
request: string | null,
|
|
28
|
+
targetIs: string
|
|
29
|
+
): string {
|
|
30
|
+
const what =
|
|
31
|
+
kind === "reply"
|
|
32
|
+
? `No reply from ${who(target.name, target.id)}${request ? ` to: "${request}"` : ""}`
|
|
33
|
+
: kind === "idle"
|
|
34
|
+
? `${who(target.name, target.id)} didn't go idle`
|
|
35
|
+
: `${who(target.name, target.id)}'s PR didn't merge`;
|
|
36
|
+
return `${from} ${what} within ${minutes(waitedMs)}. The wait is closed; it is ${targetIs}. See what it did: aos read ${shortId(target.id)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const satisfiedLine = (
|
|
40
|
+
target: { id: string; name: string },
|
|
41
|
+
kind: "idle" | "merged",
|
|
42
|
+
pr?: number | null
|
|
43
|
+
): string =>
|
|
44
|
+
`${from} ${
|
|
45
|
+
kind === "idle"
|
|
46
|
+
? `${who(target.name, target.id)} is idle now`
|
|
47
|
+
: `${who(target.name, target.id)}'s PR${pr ? ` #${pr}` : ""} merged`
|
|
48
|
+
} (you waited for that). See what it did: aos read ${shortId(target.id)}`;
|
|
49
|
+
|
|
50
|
+
export const endedLine = (
|
|
51
|
+
target: { id: string; name: string },
|
|
52
|
+
how: string,
|
|
53
|
+
request: string | null
|
|
54
|
+
): string =>
|
|
55
|
+
`${from} ${who(target.name, target.id)} ended (${how}) with your wait still open${request ? ` on: "${request}"` : ""}. It won't answer.`;
|
|
56
|
+
|
|
57
|
+
export const closedPrLine = (
|
|
58
|
+
target: { id: string; name: string },
|
|
59
|
+
pr: number | null
|
|
60
|
+
): string =>
|
|
61
|
+
`${from} ${who(target.name, target.id)}'s PR${pr ? ` #${pr}` : ""} was closed without merging. The wait is closed.`;
|
|
62
|
+
|
|
63
|
+
export const orchestratorTimeoutLine = (
|
|
64
|
+
waiter: string,
|
|
65
|
+
target: string,
|
|
66
|
+
waitedMs: number,
|
|
67
|
+
request: string | null
|
|
68
|
+
): string =>
|
|
69
|
+
`session ${waiter} waited ${minutes(waitedMs)} on ${target} with no ${request ? `reply to "${request}"` : "result"}; it has been told`;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Registering a wait: `aos send --await` (in sendMessage) and `aos wait`.
|
|
2
|
+
|
|
3
|
+
import { statusDetector } from "../../status-detector";
|
|
4
|
+
import { statusOf } from "../../orchestrator/facts";
|
|
5
|
+
import { duration } from "../../schedules/when";
|
|
6
|
+
import { sessionById, tryResolveTarget } from "../targets";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_TIMEOUT_MS,
|
|
9
|
+
MAX_TIMEOUT_MS,
|
|
10
|
+
MIN_TIMEOUT_MS,
|
|
11
|
+
cantWaitOn,
|
|
12
|
+
openWait,
|
|
13
|
+
type WaitKind,
|
|
14
|
+
} from "./store";
|
|
15
|
+
|
|
16
|
+
// "30m", "2h", "1h30m"; empty is the default.
|
|
17
|
+
export function parseTimeout(text: string | null | undefined): number {
|
|
18
|
+
const t = String(text ?? "").trim();
|
|
19
|
+
if (!t) return DEFAULT_TIMEOUT_MS;
|
|
20
|
+
const ms = duration(t.toLowerCase().replace(/([a-z])(?=\d)/g, "$1 "));
|
|
21
|
+
if (ms === null)
|
|
22
|
+
throw new Error(`Can't read the timeout "${t}": try 30m, 2h or 1h30m`);
|
|
23
|
+
if (ms < MIN_TIMEOUT_MS || ms > MAX_TIMEOUT_MS)
|
|
24
|
+
throw new Error("The timeout is between 1m and 24h");
|
|
25
|
+
return ms;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const UNTILS = ["idle", "merged", "reply"] as const;
|
|
29
|
+
export type Until = (typeof UNTILS)[number];
|
|
30
|
+
|
|
31
|
+
export async function registerWait(opts: {
|
|
32
|
+
fromId: string;
|
|
33
|
+
to: string;
|
|
34
|
+
until: string;
|
|
35
|
+
timeout?: string | null;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
id?: number;
|
|
38
|
+
deadline?: number;
|
|
39
|
+
already?: string;
|
|
40
|
+
target: string;
|
|
41
|
+
}> {
|
|
42
|
+
if (!(UNTILS as readonly string[]).includes(opts.until))
|
|
43
|
+
throw new Error("--until is idle, merged or reply");
|
|
44
|
+
const kind: WaitKind = opts.until as Until;
|
|
45
|
+
const timeoutMs = parseTimeout(opts.timeout);
|
|
46
|
+
const from = sessionById(opts.fromId);
|
|
47
|
+
if (!from) throw new Error("Unknown sender session");
|
|
48
|
+
const found = tryResolveTarget(opts.to, opts.fromId);
|
|
49
|
+
if (!found.ok) throw new Error(found.error);
|
|
50
|
+
const target = found.session;
|
|
51
|
+
if (target.id === from.id) throw new Error("A session can't wait on itself");
|
|
52
|
+
const why = cantWaitOn(target);
|
|
53
|
+
if (why) throw new Error(`Can't wait on ${target.name}: ${why}`);
|
|
54
|
+
|
|
55
|
+
if (kind === "merged") {
|
|
56
|
+
if (target.pr_status === "merged" || target.task_status === "merged")
|
|
57
|
+
return {
|
|
58
|
+
already: `${target.name}'s PR is already merged`,
|
|
59
|
+
target: target.name,
|
|
60
|
+
};
|
|
61
|
+
if (target.pr_status === "closed")
|
|
62
|
+
throw new Error(`${target.name}'s PR was closed without merging`);
|
|
63
|
+
if (!target.task_status && !target.pr_number && !target.branch_name)
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${target.name} isn't a task and has no PR, so there is nothing to merge`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (kind === "idle") {
|
|
69
|
+
await statusDetector.refreshCache();
|
|
70
|
+
if ((await statusOf(target)).status === "idle")
|
|
71
|
+
return { already: `${target.name} is idle now`, target: target.name };
|
|
72
|
+
}
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const deadline = now + timeoutMs;
|
|
75
|
+
const id = openWait({
|
|
76
|
+
waiterId: from.id,
|
|
77
|
+
targetId: target.id,
|
|
78
|
+
kind,
|
|
79
|
+
deadline,
|
|
80
|
+
now,
|
|
81
|
+
});
|
|
82
|
+
return { id, deadline, target: target.name };
|
|
83
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waits: session A waiting on session B, kept in `bus_waits`. A change of
|
|
3
|
+
* state is claimed with a conditional UPDATE and acted on only by the caller
|
|
4
|
+
* whose update changed a row, so overlapping sweeps and restarts never
|
|
5
|
+
* wake anyone twice.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db, type Session } from "../../db";
|
|
9
|
+
import { isHeldRemote } from "../../tasks/contract/held-calls";
|
|
10
|
+
import { notifySessionsChanged } from "../../status/hub";
|
|
11
|
+
|
|
12
|
+
export type WaitKind = "reply" | "idle" | "merged";
|
|
13
|
+
export type WaitOutcome =
|
|
14
|
+
"replied" | "idle" | "merged" | "timeout" | "ended" | "cancelled";
|
|
15
|
+
|
|
16
|
+
export interface WaitRow {
|
|
17
|
+
id: number;
|
|
18
|
+
waiter_id: string;
|
|
19
|
+
target_id: string;
|
|
20
|
+
kind: WaitKind;
|
|
21
|
+
message_id: number | null;
|
|
22
|
+
request: string | null;
|
|
23
|
+
deadline: number;
|
|
24
|
+
created_at: number;
|
|
25
|
+
seen_busy_at: number | null;
|
|
26
|
+
nudged_at: number | null;
|
|
27
|
+
closed_at: number | null;
|
|
28
|
+
outcome: WaitOutcome | null;
|
|
29
|
+
reply_id: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MAX_OPEN_WAITS = 10;
|
|
33
|
+
export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
34
|
+
export const MIN_TIMEOUT_MS = 60_000;
|
|
35
|
+
export const MAX_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
36
|
+
|
|
37
|
+
// A request shown in a line: one line, cut.
|
|
38
|
+
export function excerpt(body: string, max = 100): string {
|
|
39
|
+
const one = body.replace(/\s+/g, " ").trim().replace(/"/g, "'");
|
|
40
|
+
return one.length <= max ? one : `${one.slice(0, max - 1)}…`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function openWait(w: {
|
|
44
|
+
waiterId: string;
|
|
45
|
+
targetId: string;
|
|
46
|
+
kind: WaitKind;
|
|
47
|
+
messageId?: number | null;
|
|
48
|
+
request?: string | null;
|
|
49
|
+
deadline: number;
|
|
50
|
+
now?: number;
|
|
51
|
+
}): number {
|
|
52
|
+
const now = w.now ?? Date.now();
|
|
53
|
+
const open = db
|
|
54
|
+
.prepare(
|
|
55
|
+
`SELECT id FROM bus_waits WHERE waiter_id = ? AND target_id = ? AND kind = ? AND closed_at IS NULL`
|
|
56
|
+
)
|
|
57
|
+
.get(w.waiterId, w.targetId, w.kind) as { id: number } | undefined;
|
|
58
|
+
let id: number;
|
|
59
|
+
if (open) {
|
|
60
|
+
// The same wait again: the newer request and deadline stand.
|
|
61
|
+
db.prepare(
|
|
62
|
+
`UPDATE bus_waits SET message_id = ?, request = ?, deadline = ?, nudged_at = NULL WHERE id = ?`
|
|
63
|
+
).run(w.messageId ?? null, w.request ?? null, w.deadline, open.id);
|
|
64
|
+
id = open.id;
|
|
65
|
+
} else {
|
|
66
|
+
const count = (
|
|
67
|
+
db
|
|
68
|
+
.prepare(
|
|
69
|
+
`SELECT COUNT(*) AS n FROM bus_waits WHERE waiter_id = ? AND closed_at IS NULL`
|
|
70
|
+
)
|
|
71
|
+
.get(w.waiterId) as { n: number }
|
|
72
|
+
).n;
|
|
73
|
+
if (count >= MAX_OPEN_WAITS)
|
|
74
|
+
throw new Error(
|
|
75
|
+
`You already have ${MAX_OPEN_WAITS} waits open; wait for one to finish`
|
|
76
|
+
);
|
|
77
|
+
id = Number(
|
|
78
|
+
db
|
|
79
|
+
.prepare(
|
|
80
|
+
`INSERT INTO bus_waits (waiter_id, target_id, kind, message_id, request, deadline, created_at)
|
|
81
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
82
|
+
)
|
|
83
|
+
.run(
|
|
84
|
+
w.waiterId,
|
|
85
|
+
w.targetId,
|
|
86
|
+
w.kind,
|
|
87
|
+
w.messageId ?? null,
|
|
88
|
+
w.request ?? null,
|
|
89
|
+
w.deadline,
|
|
90
|
+
now
|
|
91
|
+
).lastInsertRowid
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
notifySessionsChanged();
|
|
95
|
+
return id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// True for the one caller that closed it.
|
|
99
|
+
export function claimClose(
|
|
100
|
+
id: number,
|
|
101
|
+
outcome: WaitOutcome,
|
|
102
|
+
replyId: number | null = null,
|
|
103
|
+
now = Date.now()
|
|
104
|
+
): boolean {
|
|
105
|
+
const changed =
|
|
106
|
+
db
|
|
107
|
+
.prepare(
|
|
108
|
+
`UPDATE bus_waits SET closed_at = ?, outcome = ?, reply_id = ? WHERE id = ? AND closed_at IS NULL`
|
|
109
|
+
)
|
|
110
|
+
.run(now, outcome, replyId, id).changes === 1;
|
|
111
|
+
if (changed) notifySessionsChanged();
|
|
112
|
+
return changed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const claimNudge = (id: number, now = Date.now()): boolean =>
|
|
116
|
+
db
|
|
117
|
+
.prepare(
|
|
118
|
+
`UPDATE bus_waits SET nudged_at = ? WHERE id = ? AND closed_at IS NULL AND nudged_at IS NULL`
|
|
119
|
+
)
|
|
120
|
+
.run(now, id).changes === 1;
|
|
121
|
+
|
|
122
|
+
export function markSeenBusy(id: number, now = Date.now()): void {
|
|
123
|
+
db.prepare(
|
|
124
|
+
`UPDATE bus_waits SET seen_busy_at = ? WHERE id = ? AND seen_busy_at IS NULL`
|
|
125
|
+
).run(now, id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const openWaits = (): WaitRow[] =>
|
|
129
|
+
db
|
|
130
|
+
.prepare(`SELECT * FROM bus_waits WHERE closed_at IS NULL ORDER BY id`)
|
|
131
|
+
.all() as WaitRow[];
|
|
132
|
+
|
|
133
|
+
// A reply from `targetId` to `waiterId` settles these.
|
|
134
|
+
export const replyWaitsFor = (waiterId: string, targetId: string): WaitRow[] =>
|
|
135
|
+
db
|
|
136
|
+
.prepare(
|
|
137
|
+
`SELECT * FROM bus_waits WHERE waiter_id = ? AND target_id = ? AND kind = 'reply' AND closed_at IS NULL ORDER BY id`
|
|
138
|
+
)
|
|
139
|
+
.all(waiterId, targetId) as WaitRow[];
|
|
140
|
+
|
|
141
|
+
export interface WaitBadges {
|
|
142
|
+
on: { id: string; name: string; kind: WaitKind; deadline: number }[];
|
|
143
|
+
by: { id: string; name: string }[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Who each session waits on, and who waits on it, for the open waits.
|
|
147
|
+
export function waitBadges(): Map<string, WaitBadges> {
|
|
148
|
+
const rows = db
|
|
149
|
+
.prepare(
|
|
150
|
+
`SELECT w.waiter_id, w.target_id, w.kind, w.deadline,
|
|
151
|
+
COALESCE(a.name, 'a session') AS waiter_name,
|
|
152
|
+
COALESCE(b.name, 'a session') AS target_name
|
|
153
|
+
FROM bus_waits w
|
|
154
|
+
LEFT JOIN sessions a ON a.id = w.waiter_id
|
|
155
|
+
LEFT JOIN sessions b ON b.id = w.target_id
|
|
156
|
+
WHERE w.closed_at IS NULL ORDER BY w.id`
|
|
157
|
+
)
|
|
158
|
+
.all() as {
|
|
159
|
+
waiter_id: string;
|
|
160
|
+
target_id: string;
|
|
161
|
+
kind: WaitKind;
|
|
162
|
+
deadline: number;
|
|
163
|
+
waiter_name: string;
|
|
164
|
+
target_name: string;
|
|
165
|
+
}[];
|
|
166
|
+
const out = new Map<string, WaitBadges>();
|
|
167
|
+
const get = (id: string) => {
|
|
168
|
+
let b = out.get(id);
|
|
169
|
+
if (!b) out.set(id, (b = { on: [], by: [] }));
|
|
170
|
+
return b;
|
|
171
|
+
};
|
|
172
|
+
for (const r of rows) {
|
|
173
|
+
get(r.waiter_id).on.push({
|
|
174
|
+
id: r.target_id,
|
|
175
|
+
name: r.target_name,
|
|
176
|
+
kind: r.kind,
|
|
177
|
+
deadline: r.deadline,
|
|
178
|
+
});
|
|
179
|
+
get(r.target_id).by.push({ id: r.waiter_id, name: r.waiter_name });
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// "B", "B and C", "B, C +2": for a sidebar chip.
|
|
185
|
+
export function namesLine(names: string[]): string {
|
|
186
|
+
const unique = [...new Set(names)];
|
|
187
|
+
if (unique.length <= 2) return unique.join(" and ");
|
|
188
|
+
return `${unique.slice(0, 2).join(", ")} +${unique.length - 2}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Why a wait on this session can't be kept, or null.
|
|
192
|
+
export function cantWaitOn(to: Session): string | null {
|
|
193
|
+
if (isHeldRemote(to) || (to.peer_mirror && !to.task_prompt))
|
|
194
|
+
return "it runs on a linked machine, which can't tell this one when it answers";
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settles open waits: nudges a target that went idle without answering,
|
|
3
|
+
* wakes a waiter whose condition holds, whose deadline passed or whose
|
|
4
|
+
* target ended. Replies settle in sendMessage; this covers the rest. It
|
|
5
|
+
* reads status through statusOf, the same idle test the orchestrator uses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Session } from "../../db";
|
|
9
|
+
import { db } from "../../db";
|
|
10
|
+
import { statusDetector } from "../../status-detector";
|
|
11
|
+
import { statusOf } from "../../orchestrator/facts";
|
|
12
|
+
import { queueEventOnce } from "../../orchestrator/events";
|
|
13
|
+
import { sessionWorkspace } from "../../schedules/store";
|
|
14
|
+
import { sendMessage } from "../index";
|
|
15
|
+
import { onKick } from "./kick";
|
|
16
|
+
import {
|
|
17
|
+
claimClose,
|
|
18
|
+
claimNudge,
|
|
19
|
+
markSeenBusy,
|
|
20
|
+
openWaits,
|
|
21
|
+
type WaitRow,
|
|
22
|
+
} from "./store";
|
|
23
|
+
import {
|
|
24
|
+
closedPrLine,
|
|
25
|
+
endedLine,
|
|
26
|
+
nudgeLine,
|
|
27
|
+
orchestratorTimeoutLine,
|
|
28
|
+
satisfiedLine,
|
|
29
|
+
timeoutLine,
|
|
30
|
+
} from "./lines";
|
|
31
|
+
|
|
32
|
+
// A target that was never seen working is idle only this long after the
|
|
33
|
+
// wait opened: a chat turn can start and end between two looks, and a
|
|
34
|
+
// message may not have been picked up yet.
|
|
35
|
+
export const IDLE_GRACE_MS = 20_000;
|
|
36
|
+
// A terminal reads dead for a moment when tmux hiccups: twice in a row is gone.
|
|
37
|
+
const DEAD_LOOKS = 2;
|
|
38
|
+
const EVERY_MS = 5000;
|
|
39
|
+
|
|
40
|
+
const dead = new Map<string, number>();
|
|
41
|
+
|
|
42
|
+
const row = (id: string) =>
|
|
43
|
+
db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
|
|
44
|
+
Session | undefined;
|
|
45
|
+
|
|
46
|
+
// Why this session can no longer act on a wait, or null.
|
|
47
|
+
function endedHow(s: Session | undefined): string | null {
|
|
48
|
+
if (!s) return "deleted";
|
|
49
|
+
if (s.archived_at) return "archived";
|
|
50
|
+
if (s.task_status === "moved" || s.task_status === "moving") return "moved";
|
|
51
|
+
if (s.task_status && s.task_status !== "running") return s.task_status;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function tell(to: Session, line: string): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
await sendMessage({
|
|
58
|
+
fromId: null,
|
|
59
|
+
fromLabel: "AgentOS",
|
|
60
|
+
to: to.id,
|
|
61
|
+
body: line,
|
|
62
|
+
line,
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`[waits] couldn't tell ${to.name}:`, error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function settle(w: WaitRow, waiter: Session, now: number): Promise<void> {
|
|
70
|
+
const target = row(w.target_id);
|
|
71
|
+
const end = endedHow(target);
|
|
72
|
+
const prMerged =
|
|
73
|
+
target?.pr_status === "merged" || target?.task_status === "merged";
|
|
74
|
+
|
|
75
|
+
if (w.kind === "merged" && prMerged) {
|
|
76
|
+
if (claimClose(w.id, "merged", null, now))
|
|
77
|
+
await tell(waiter, satisfiedLine(target!, "merged", target!.pr_number));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (w.kind === "merged" && target?.pr_status === "closed") {
|
|
81
|
+
if (claimClose(w.id, "ended", null, now))
|
|
82
|
+
await tell(waiter, closedPrLine(target, target.pr_number));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (end || !target) {
|
|
86
|
+
if (claimClose(w.id, "ended", null, now))
|
|
87
|
+
await tell(
|
|
88
|
+
waiter,
|
|
89
|
+
endedLine(
|
|
90
|
+
target ?? { id: w.target_id, name: "the session" },
|
|
91
|
+
end ?? "deleted",
|
|
92
|
+
w.request
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { status } = await statusOf(target);
|
|
99
|
+
if (status === "dead") {
|
|
100
|
+
const n = (dead.get(target.id) ?? 0) + 1;
|
|
101
|
+
dead.set(target.id, n);
|
|
102
|
+
if (n >= DEAD_LOOKS) {
|
|
103
|
+
if (claimClose(w.id, "ended", null, now))
|
|
104
|
+
await tell(
|
|
105
|
+
waiter,
|
|
106
|
+
endedLine(target, "its terminal is gone", w.request)
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
} else dead.delete(target.id);
|
|
111
|
+
|
|
112
|
+
if (status === "running") markSeenBusy(w.id, now);
|
|
113
|
+
const idle =
|
|
114
|
+
status === "idle" &&
|
|
115
|
+
(w.seen_busy_at != null || now - w.created_at >= IDLE_GRACE_MS);
|
|
116
|
+
|
|
117
|
+
if (w.kind === "idle" && idle) {
|
|
118
|
+
if (claimClose(w.id, "idle", null, now))
|
|
119
|
+
await tell(waiter, satisfiedLine(target, "idle"));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (now >= w.deadline) {
|
|
124
|
+
if (!claimClose(w.id, "timeout", null, now)) return;
|
|
125
|
+
const is = status === "running" ? "still working" : status;
|
|
126
|
+
await tell(
|
|
127
|
+
waiter,
|
|
128
|
+
timeoutLine(target, w.kind, w.deadline - w.created_at, w.request, is)
|
|
129
|
+
);
|
|
130
|
+
const workspace = sessionWorkspace(waiter);
|
|
131
|
+
if (workspace && waiter.role !== "orchestrator")
|
|
132
|
+
queueEventOnce(
|
|
133
|
+
workspace,
|
|
134
|
+
`wait-timeout:${w.id}`,
|
|
135
|
+
waiter.id,
|
|
136
|
+
orchestratorTimeoutLine(
|
|
137
|
+
waiter.name,
|
|
138
|
+
target.name,
|
|
139
|
+
w.deadline - w.created_at,
|
|
140
|
+
w.request
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (w.kind === "reply" && idle && !w.nudged_at && claimNudge(w.id, now))
|
|
147
|
+
await tell(target, nudgeLine(waiter, w.request, w.message_id));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let running = false;
|
|
151
|
+
|
|
152
|
+
export async function waitsTick(now = Date.now()): Promise<void> {
|
|
153
|
+
if (running) return;
|
|
154
|
+
running = true;
|
|
155
|
+
try {
|
|
156
|
+
const waits = openWaits();
|
|
157
|
+
if (!waits.length) return;
|
|
158
|
+
await statusDetector.refreshCache();
|
|
159
|
+
for (const w of waits) {
|
|
160
|
+
try {
|
|
161
|
+
const waiter = row(w.waiter_id);
|
|
162
|
+
// A waiter that is gone has no one to tell.
|
|
163
|
+
if (!waiter || endedHow(waiter)) {
|
|
164
|
+
claimClose(w.id, "cancelled", null, now);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
await settle(w, waiter, now);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error(`[waits] wait ${w.id}:`, error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
running = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let timer: NodeJS.Timeout | null = null;
|
|
178
|
+
let kicked = false;
|
|
179
|
+
|
|
180
|
+
export function startWaitWatcher(): void {
|
|
181
|
+
if (timer) return;
|
|
182
|
+
timer = setInterval(() => void waitsTick(), EVERY_MS);
|
|
183
|
+
timer.unref();
|
|
184
|
+
onKick(() => {
|
|
185
|
+
if (kicked) return;
|
|
186
|
+
kicked = true;
|
|
187
|
+
setImmediate(() => {
|
|
188
|
+
kicked = false;
|
|
189
|
+
void waitsTick();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|