@saadnvd1/agent-os 0.3.697 → 0.3.700

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.
Files changed (88) hide show
  1. package/app/api/bus/send/route.ts +11 -1
  2. package/app/api/bus/wait/route.ts +22 -0
  3. package/app/api/peer/hello/reveal/route.ts +6 -0
  4. package/app/api/peer/hello/route.ts +9 -0
  5. package/app/api/peer/hello/status/route.ts +6 -0
  6. package/app/api/peer/ping/route.ts +10 -0
  7. package/app/api/peers/account/route.ts +9 -0
  8. package/app/api/peers/gate.test.ts +111 -0
  9. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  10. package/app/api/peers/link/[id]/route.ts +20 -0
  11. package/app/api/peers/link/route.ts +23 -0
  12. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  13. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  14. package/app/api/peers/pending/route.ts +7 -0
  15. package/app/api/sessions/route.ts +18 -1
  16. package/app/page.tsx +2 -0
  17. package/components/Hosts/AccountMachines.tsx +180 -0
  18. package/components/Hosts/HostRow.tsx +44 -35
  19. package/components/Hosts/HostsDialog.tsx +49 -37
  20. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  21. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  22. package/components/Hosts/sheet.ts +3 -0
  23. package/components/SessionList/RowSubtitle.tsx +11 -1
  24. package/components/Welcome/ConnectBlock.tsx +1 -1
  25. package/data/peers/index.ts +2 -0
  26. package/data/peers/keys.ts +6 -0
  27. package/data/peers/queries.ts +141 -0
  28. package/lib/__fixtures__/connect-transport.ts +93 -0
  29. package/lib/__fixtures__/peer-machine.ts +45 -0
  30. package/lib/__fixtures__/peer-rpc.ts +224 -0
  31. package/lib/agents/brief.ts +3 -1
  32. package/lib/bus/format.ts +25 -4
  33. package/lib/bus/index.ts +113 -106
  34. package/lib/bus/resolve.ts +16 -2
  35. package/lib/bus/targets.ts +122 -0
  36. package/lib/bus/waits/kick.ts +12 -0
  37. package/lib/bus/waits/lines.ts +69 -0
  38. package/lib/bus/waits/register.ts +83 -0
  39. package/lib/bus/waits/store.ts +196 -0
  40. package/lib/bus/waits/tick.ts +192 -0
  41. package/lib/connect/control-api.ts +108 -2
  42. package/lib/connect/heartbeat.ts +11 -3
  43. package/lib/connect/serve.ts +2 -0
  44. package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
  45. package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
  46. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  47. package/lib/db/types.ts +6 -0
  48. package/lib/done/archive.ts +2 -0
  49. package/lib/hosts/index.ts +28 -7
  50. package/lib/hosts/peer-socket.ts +3 -1
  51. package/lib/hosts/read-capped-bytes.ts +23 -0
  52. package/lib/hosts/remote-api.ts +112 -41
  53. package/lib/orchestrator/describe.ts +13 -0
  54. package/lib/orchestrator/facts.ts +17 -0
  55. package/lib/peer/account.ts +198 -0
  56. package/lib/peer/allowlist.ts +136 -0
  57. package/lib/peer/audit.ts +144 -0
  58. package/lib/peer/cli.ts +24 -0
  59. package/lib/peer/client.ts +188 -0
  60. package/lib/peer/code.ts +3 -0
  61. package/lib/peer/envelope.ts +424 -0
  62. package/lib/peer/hello-route.ts +32 -0
  63. package/lib/peer/hello.ts +346 -0
  64. package/lib/peer/internal.ts +22 -0
  65. package/lib/peer/keys.ts +122 -0
  66. package/lib/peer/link-cli.ts +233 -0
  67. package/lib/peer/links.ts +96 -0
  68. package/lib/peer/machines.ts +72 -0
  69. package/lib/peer/nonce-store.ts +23 -0
  70. package/lib/peer/notify.ts +32 -0
  71. package/lib/peer/pairing-out.ts +382 -0
  72. package/lib/peer/pairing.ts +403 -0
  73. package/lib/peer/pin.ts +55 -0
  74. package/lib/peer/replay.ts +81 -0
  75. package/lib/peer/rpc-intake.ts +258 -0
  76. package/lib/peer/rpc-server.ts +369 -0
  77. package/lib/schedules/when.ts +1 -1
  78. package/lib/security/auth.ts +51 -6
  79. package/lib/security/gate.ts +38 -12
  80. package/lib/security/route-guard.ts +3 -1
  81. package/lib/session-artifacts/peer.ts +12 -1
  82. package/lib/sessions/end.ts +3 -0
  83. package/lib/tasks/contract/authorize.ts +4 -0
  84. package/lib/worktree-sweep/testTemp.ts +2 -0
  85. package/package.json +1 -1
  86. package/scripts/agent-os +2 -0
  87. package/scripts/lib/commands.sh +2 -0
  88. package/server.ts +21 -0
package/lib/bus/index.ts CHANGED
@@ -5,16 +5,20 @@
5
5
  */
6
6
 
7
7
  import { db, type Session } from "../db";
8
- import { getProject } from "../projects";
9
8
  import { statusDetector } from "../status-detector";
10
9
  import { chatState, sendChatConfirmed } from "../chat/runner";
11
10
  import { chatActivityLine } from "../chat/activity";
12
11
  import { getWorkspace } from "../workspaces";
13
12
  import type { ChatOrigin, PeerMessage } from "../chat/events";
14
13
  import { sessionRowInfo } from "../session-meta";
15
- import { previousNames } from "../session-names";
16
14
  import { deliverToPane, type Delivery } from "./delivery";
17
- import { resolveRef, wasNames, type Candidate } from "./resolve";
15
+ import { wasNames } from "./resolve";
16
+ import {
17
+ candidates,
18
+ liveSessions,
19
+ sessionById,
20
+ tryResolveTarget,
21
+ } from "./targets";
18
22
  import { tmuxPane } from "./tmux-pane";
19
23
  import { pauseHoldOf, pausedNote } from "../tasks/pause-hold";
20
24
  import { isHeldRemote, relayToHeldTask } from "../tasks/contract/held-calls";
@@ -25,9 +29,22 @@ import {
25
29
  type QueuedTarget,
26
30
  } from "../tasks/queue-held";
27
31
  import { heldOrchestrator, heldTarget } from "./held-reply";
32
+ import {
33
+ cantWaitOn,
34
+ claimClose,
35
+ excerpt,
36
+ openWait,
37
+ replyWaitsFor,
38
+ waitBadges,
39
+ } from "./waits/store";
28
40
  import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
29
41
 
30
42
  export * from "./format";
43
+ export {
44
+ resolveHeldOrchestrator,
45
+ resolveSession,
46
+ resolveTarget,
47
+ } from "./targets";
31
48
  export type { Delivery } from "./delivery";
32
49
 
33
50
  export interface Peer {
@@ -44,35 +61,11 @@ export interface Peer {
44
61
  orchestratorOf: string | null;
45
62
  // The asking session's own workspace orchestrator.
46
63
  yours?: boolean;
64
+ // Names of the sessions it waits on, and of those waiting on it.
65
+ waitingOn: string[];
66
+ waitedBy: string[];
47
67
  }
48
68
 
49
- function liveSessions(): Session[] {
50
- return db
51
- .prepare(
52
- `SELECT * FROM session_rows WHERE (task_status IS NULL OR task_status = 'running')
53
- AND archived_at IS NULL
54
- ORDER BY updated_at DESC`
55
- )
56
- .all() as Session[];
57
- }
58
-
59
- function candidates(sessions: Session[]): Candidate[] {
60
- const old = previousNames();
61
- return sessions.map((s) => ({
62
- id: s.id,
63
- name: s.name,
64
- projectName: s.project_id ? (getProject(s.project_id)?.name ?? null) : null,
65
- tmuxName: s.tmux_name,
66
- previousNames: old.get(s.id) ?? [],
67
- }));
68
- }
69
-
70
- const sessionById = (id: string | null | undefined) =>
71
- id
72
- ? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
73
- Session | undefined)
74
- : undefined;
75
-
76
69
  // A chat has no pane: its worker is the state, and it can always take a
77
70
  // message (queued as its next prompt), so it is never "not running".
78
71
  async function peerState(
@@ -105,6 +98,7 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
105
98
  const named = candidates(sessions);
106
99
  const caller = sessionById(callerId);
107
100
  const orchestrator = heldOrchestrator(caller);
101
+ const badges = waitBadges();
108
102
  const listed = await Promise.all(
109
103
  sessions.map(async (s, i) => {
110
104
  const info = await peerState(s);
@@ -118,6 +112,8 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
118
112
  running: info.running,
119
113
  doing: info.subtitle,
120
114
  orchestratorOf: orchestratorOf(s),
115
+ waitingOn: badges.get(s.id)?.on.map((w) => w.name) ?? [],
116
+ waitedBy: badges.get(s.id)?.by.map((w) => w.name) ?? [],
121
117
  ...(caller?.workspace_id &&
122
118
  s.role === "orchestrator" &&
123
119
  s.workspace_id === caller.workspace_id && { yours: true }),
@@ -137,84 +133,15 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
137
133
  running: true,
138
134
  doing: `your orchestrator, on ${orchestrator.heldFrom}`,
139
135
  orchestratorOf: null,
136
+ waitingOn: [],
137
+ waitedBy: [],
140
138
  yours: true,
141
139
  },
142
140
  ];
143
141
  }
144
142
 
145
- // The orchestrator a held caller's ref names, if it names it.
146
- export function resolveHeldOrchestrator(callerId: string, ref: string) {
147
- return heldTarget(sessionById(callerId), ref);
148
- }
149
-
150
- // A live session by name, old name, project/name, id or id prefix. The note
151
- // says when the name meant something other than its current owner.
152
- export function resolveTarget(
153
- ref: string,
154
- callerId?: string | null
155
- ): {
156
- session: Session;
157
- note?: string;
158
- } {
159
- const found = tryResolveTarget(ref, callerId);
160
- if (!found.ok) throw new Error(found.error);
161
- return found;
162
- }
163
-
164
- // "orchestrator" / "my orchestrator": the caller's own workspace orchestrator.
165
- function ownOrchestrator(
166
- ref: string,
167
- sessions: Session[],
168
- callerId?: string | null
169
- ): Session | undefined {
170
- if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
171
- const workspace = sessionById(callerId)?.workspace_id;
172
- if (!workspace) return undefined;
173
- return sessions.find(
174
- (s) => s.role === "orchestrator" && s.workspace_id === workspace
175
- );
176
- }
177
-
178
- function tryResolveTarget(
179
- ref: string,
180
- callerId?: string | null
181
- ):
182
- | { ok: true; session: Session; note?: string }
183
- | { ok: false; none: boolean; error: string } {
184
- const sessions = liveSessions();
185
- const own = ownOrchestrator(ref, sessions, callerId);
186
- if (own) return { ok: true, session: own };
187
- const r = resolveRef(ref, candidates(sessions));
188
- if (!r.ok) {
189
- const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
190
- ? sessions.filter((s) => s.role === "orchestrator")
191
- : [];
192
- if (orchestrators.length)
193
- return {
194
- ok: false,
195
- none: true,
196
- error: `You are in no workspace, so "${ref}" is ambiguous. Orchestrators: ${orchestrators
197
- .map((s) => `${s.name} (id ${s.id.slice(0, 8)})`)
198
- .join(", ")}`,
199
- };
200
- return {
201
- ok: false,
202
- none: r.reason === "none",
203
- error: r.reason === "none" ? `${r.error}. Run: aos peers` : r.error,
204
- };
205
- }
206
- return {
207
- ok: true,
208
- session: sessions.find((s) => s.id === r.id)!,
209
- note: r.note,
210
- };
211
- }
212
-
213
- export function resolveSession(ref: string): Session {
214
- return resolveTarget(ref).session;
215
- }
216
-
217
143
  type Row = {
144
+ reply_to: number | null;
218
145
  id: number;
219
146
  from_id: string | null;
220
147
  from_name: string;
@@ -234,11 +161,12 @@ const toView = (r: Row): BusMessageView => ({
234
161
  body: r.body,
235
162
  createdAt: r.created_at,
236
163
  readAt: r.read_at,
164
+ replyTo: r.reply_to,
237
165
  });
238
166
 
239
167
  // Messages are kept by id; names shown are the sessions' names now, the
240
168
  // stored ones only for a session that's gone.
241
- const SELECT = `SELECT m.id, m.from_id, m.to_id, m.body, m.created_at, m.read_at,
169
+ const SELECT = `SELECT m.id, m.from_id, m.to_id, m.body, m.created_at, m.read_at, m.reply_to,
242
170
  CASE WHEN m.from_id IS NULL THEN m.from_name
243
171
  ELSE COALESCE(f.name, m.from_name) END AS from_name,
244
172
  COALESCE(t.name, m.to_name) AS to_name
@@ -338,10 +266,17 @@ export async function sendMessage(opts: {
338
266
  // A message pulled from a linked machine, by its key there: one already
339
267
  // stored is not stored or delivered again.
340
268
  relayKey?: string;
269
+ // Answers this message (one the recipient sent the sender). Without it a
270
+ // message answers the recipient's oldest open wait on the sender.
271
+ replyTo?: number;
272
+ // The sender waits for the recipient's reply; ms until it gives up.
273
+ awaitMs?: number;
341
274
  }): Promise<{
342
275
  message: BusMessageView;
343
276
  delivery: Delivery;
344
277
  note?: string;
278
+ // The wait recorded for --await, or why none was.
279
+ wait?: { id: number; deadline: number } | { notWaiting: string };
345
280
  // The task it was held for, when that task is still in the start queue.
346
281
  heldForQueued?: boolean;
347
282
  }> {
@@ -380,10 +315,13 @@ export async function sendMessage(opts: {
380
315
  };
381
316
  }
382
317
 
318
+ const reply = from ? replyTarget(from, to, opts.replyTo) : null;
319
+ if (opts.awaitMs && !from) throw new Error("Only a session can wait");
320
+
383
321
  const fromName = from?.name ?? opts.fromLabel ?? HUMAN;
384
322
  const { lastInsertRowid } = db
385
323
  .prepare(
386
- `INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_key) VALUES (?, ?, ?, ?, ?, ?)`
324
+ `INSERT INTO bus_messages (from_id, from_name, to_id, to_name, body, relay_key, reply_to) VALUES (?, ?, ?, ?, ?, ?, ?)`
387
325
  )
388
326
  .run(
389
327
  from?.id ?? null,
@@ -391,9 +329,13 @@ export async function sendMessage(opts: {
391
329
  to.id,
392
330
  to.name,
393
331
  body,
394
- opts.relayKey ?? null
332
+ opts.relayKey ?? null,
333
+ reply?.id ?? null
395
334
  );
396
335
  const id = Number(lastInsertRowid);
336
+ // The reply is stored: whatever it answers is settled, whether or not
337
+ // the wake line gets through.
338
+ if (from) settleReplyWaits(to.id, from.id, id);
397
339
 
398
340
  await statusDetector.refreshCache();
399
341
  const delivery = await deliver(
@@ -402,7 +344,12 @@ export async function sendMessage(opts: {
402
344
  wakeLine(
403
345
  { fromName, fromId: from?.id ?? null, body },
404
346
  // The linked machine's inbox never held a long body.
405
- { inline: isHeldRemote(to) }
347
+ {
348
+ inline: isHeldRemote(to),
349
+ replyTo: reply
350
+ ? { id: reply.id, excerpt: excerpt(reply.body, 60) }
351
+ : undefined,
352
+ }
406
353
  ),
407
354
  {
408
355
  name: fromName,
@@ -427,13 +374,73 @@ export async function sendMessage(opts: {
427
374
  ).run(id);
428
375
  const message = toView(db.prepare(`${SELECT} WHERE m.id = ?`).get(id) as Row);
429
376
  const heldNote = hold ? pausedNote(to.name, hold) : null;
377
+ const wait =
378
+ opts.awaitMs && from
379
+ ? keepWait(from, to, id, body, delivery, opts.awaitMs)
380
+ : undefined;
430
381
  return {
431
382
  message,
432
383
  delivery,
433
384
  note: [note, heldNote].filter(Boolean).join("; ") || undefined,
385
+ wait,
434
386
  };
435
387
  }
436
388
 
389
+ // The message `replyTo` names, or the oldest request `from` was waited on
390
+ // for by `to`: the one this message answers.
391
+ function replyTarget(
392
+ from: Session,
393
+ to: Session,
394
+ replyTo?: number
395
+ ): { id: number; body: string } | null {
396
+ if (replyTo !== undefined) {
397
+ const m = db
398
+ .prepare(
399
+ `SELECT id, body FROM bus_messages WHERE id = ? AND from_id = ? AND to_id = ?`
400
+ )
401
+ .get(replyTo, to.id, from.id) as { id: number; body: string } | undefined;
402
+ if (!m)
403
+ throw new Error(`Message #${replyTo} wasn't sent to you by ${to.name}`);
404
+ return m;
405
+ }
406
+ const wait = replyWaitsFor(to.id, from.id).find((w) => w.message_id);
407
+ if (!wait) return null;
408
+ return (
409
+ (db
410
+ .prepare(`SELECT id, body FROM bus_messages WHERE id = ?`)
411
+ .get(wait.message_id) as { id: number; body: string } | undefined) ?? null
412
+ );
413
+ }
414
+
415
+ // `from` answered `waiter`: every reply wait it had on `from` is settled.
416
+ function settleReplyWaits(waiter: string, from: string, replyId: number) {
417
+ for (const w of replyWaitsFor(waiter, from))
418
+ claimClose(w.id, "replied", replyId);
419
+ }
420
+
421
+ function keepWait(
422
+ from: Session,
423
+ to: Session,
424
+ messageId: number,
425
+ body: string,
426
+ delivery: Delivery,
427
+ ms: number
428
+ ): { id: number; deadline: number } | { notWaiting: string } {
429
+ if (delivery.state === "failed") return { notWaiting: "it wasn't delivered" };
430
+ const why = cantWaitOn(to);
431
+ if (why) return { notWaiting: why };
432
+ const deadline = Date.now() + ms;
433
+ const id = openWait({
434
+ waiterId: from.id,
435
+ targetId: to.id,
436
+ kind: "reply",
437
+ messageId,
438
+ request: excerpt(body),
439
+ deadline,
440
+ });
441
+ return { id, deadline };
442
+ }
443
+
437
444
  function storeForHolder(
438
445
  from: Session,
439
446
  to: { id: string; name: string; heldFrom: string },
@@ -11,6 +11,8 @@ export interface Candidate {
11
11
  tmuxName: string;
12
12
  // Newest first.
13
13
  previousNames: string[];
14
+ workspaceName?: string | null;
15
+ orchestrator?: boolean;
14
16
  }
15
17
 
16
18
  export type Resolution =
@@ -20,7 +22,13 @@ export type Resolution =
20
22
  export const MIN_ID_PREFIX = 4;
21
23
 
22
24
  const label = (c: Candidate) =>
23
- `${c.projectName ? `${c.projectName}/` : ""}${c.name} (id ${c.id.slice(0, 8)})`;
25
+ `${
26
+ c.orchestrator && c.workspaceName
27
+ ? `${c.workspaceName}/`
28
+ : c.projectName
29
+ ? `${c.projectName}/`
30
+ : ""
31
+ }${c.name} (id ${c.id.slice(0, 8)})`;
24
32
 
25
33
  function ambiguous(ref: string, matches: Candidate[]): Resolution {
26
34
  return {
@@ -32,7 +40,13 @@ function ambiguous(ref: string, matches: Candidate[]): Resolution {
32
40
 
33
41
  const matchesOld = (r: string, c: Candidate) =>
34
42
  c.previousNames.some((old) =>
35
- matchesRef(r, { ...c, id: "", tmuxName: "", name: old })
43
+ matchesRef(r, {
44
+ ...c,
45
+ id: "",
46
+ tmuxName: "",
47
+ name: old,
48
+ orchestrator: false,
49
+ })
36
50
  );
37
51
 
38
52
  export function resolveRef(ref: string, candidates: Candidate[]): Resolution {
@@ -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
+ }