@saadnvd1/agent-os 0.3.474 → 0.3.477

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 CHANGED
@@ -35,7 +35,7 @@ wait under **Queued** with their place in line and start on their own, and
35
35
 
36
36
  ### Each kind of work on the right model
37
37
 
38
- Planning and gate reviews run on Opus, implementation, the orchestrator and
38
+ Planning, gate reviews and the orchestrator run on Opus, implementation and
39
39
  scope checks on Sonnet, titles and summaries on Haiku, each editable in
40
40
  Settings → Models and per workspace. A task plans on one model and
41
41
  implements on another in the same conversation, can be resumed on any
@@ -459,7 +459,7 @@ Each kind of work AgentOS launches an agent for runs on its own model, set in
459
459
  | Task planning (a task's plan step) | `anthropic/claude-opus-5-5` |
460
460
  | Gate review | `anthropic/claude-opus-5-5` |
461
461
  | Task implementation | `anthropic/claude-sonnet-5-5` |
462
- | Orchestrator | `anthropic/claude-sonnet-5-5` |
462
+ | Orchestrator | `anthropic/claude-opus-5-5` |
463
463
  | Scope check | `anthropic/claude-sonnet-5-5` |
464
464
  | Interactive sessions | `anthropic/claude-opus-5-5` |
465
465
  | Chat & summary helpers (titles, summaries, PR text) | `anthropic/claude-haiku-4-5-20251001` |
@@ -1174,23 +1174,29 @@ reset` yourself in a terminal on the machine: it refuses inside an
1174
1174
  sidebar header or ⌘K ("Away for 4 hours"), for 1 hour, 4 hours, until
1175
1175
  morning (8 AM) or until you're back. It's kept in `away_periods` (who set
1176
1176
  it, when, when it ends), the cup turns amber while it's on (with an amber dot on the phone's menu button), and it
1177
- ends on its own at its time. Every orchestrator gets an event, and a line
1177
+ ends on its own at its time. It works only through orchestrators that
1178
+ had a real turn (yours or theirs, not an AgentOS event or a status poll)
1179
+ in the last hour, set in Settings → Workspaces → Away mode; the rest
1180
+ aren't told or woken. Each active orchestrator gets an event, and a line
1178
1181
  leading its digest, saying you're away and to follow work through rather
1179
1182
  than only react. Every 10 minutes a sweep, written as code with no model
1180
- scanning, acts on what it finds and logs each step in the orchestrator's
1181
- chat: a task quiet 30 minutes with no PR (or failing CI) is nudged, at most
1182
- twice; an idle task with uncommitted work and no PR is asked to commit and
1183
- open one or say why not; a PR green and settled whose review never started
1184
- or was lost has it run again (once seen on two sweeps, so the orchestrator
1185
- had its turn); and after each merge, the project's `deploy` check says
1186
- whether it went live and the server came back, with a failure sent to the
1187
- orchestrator as an event. Interactive sessions are yours: one with
1188
- uncommitted work is asked once to wrap up, and nothing more. Paused
1183
+ scanning, runs a review again when a PR is green and settled but its
1184
+ review never started or was lost (once seen on two sweeps, so the
1185
+ orchestrator had its turn), checks after each merge whether the project's
1186
+ `deploy` went live and the server came back, and sends the orchestrator
1187
+ one digest of what's new: tasks quiet 30 minutes with no PR (or failing
1188
+ CI), idle tasks with uncommitted work and no PR, and your own sessions
1189
+ idle with uncommitted work. Only sessions with a real turn inside the
1190
+ window are listed, never stopped, finished or archived ones, and nothing
1191
+ goes out when nothing changed. The sweep messages no session itself: the
1192
+ orchestrator decides what needs work, and asks one of your sessions at
1193
+ most once to wrap up. Away's own messages don't count as activity. Paused
1189
1194
  workspaces aren't swept. Hard lines still become asks, and while you're
1190
1195
  away the asks raised, failed deploys and any `aos notify` an orchestrator
1191
1196
  sends reach your phone as one message per sweep. Turning it off (or its running out) builds "While you were
1192
- away" from the database: what merged and deployed with PR links, what's
1193
- still in flight and which gate or blocker holds it, what failed, was dropped
1197
+ away" from the database, for the workspaces Away reached: what merged and
1198
+ deployed with PR links, what the sweep flagged that's still in flight and
1199
+ which gate or blocker holds it, what failed, was dropped
1194
1200
  or was escalated, and what waits on you (an explicit "none"). It opens on
1195
1201
  whichever device is open until you dismiss it, a short summary goes to
1196
1202
  your phone, and the report stays in Settings → Audits (`away/`).
@@ -0,0 +1,26 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { awayWindowMinutes, setAwayWindowMinutes } from "@/lib/away/activity";
4
+
5
+ // GET /api/away/settings - how recent a session's last real turn must be
6
+ // for Away mode to touch it (lib/away/activity).
7
+ export function GET() {
8
+ return NextResponse.json({ windowMinutes: awayWindowMinutes() });
9
+ }
10
+
11
+ // PUT /api/away/settings - { windowMinutes }. From a trusted place, as
12
+ // turning Away on is.
13
+ export async function PUT(request: NextRequest) {
14
+ const gate = requireApprover(request);
15
+ if (!gate.ok) return gate.response;
16
+ try {
17
+ const { windowMinutes } = (await request.json().catch(() => ({}))) as {
18
+ windowMinutes?: unknown;
19
+ };
20
+ setAwayWindowMinutes(windowMinutes);
21
+ return NextResponse.json({ windowMinutes: awayWindowMinutes() });
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return NextResponse.json({ error: message }, { status: 400 });
25
+ }
26
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import {
8
+ useAwaySettingsQuery,
9
+ useUpdateAwaySettings,
10
+ type AwaySettings,
11
+ } from "@/data/away";
12
+ import {
13
+ MAX_WINDOW_MINUTES,
14
+ MIN_WINDOW_MINUTES,
15
+ parseWindow,
16
+ } from "@/lib/away/window-limits";
17
+
18
+ // Settings → Workspaces: how recent a session's work must be for Away mode
19
+ // to touch it. App-wide.
20
+ export function AwayWindowForm() {
21
+ const { data, isPending, isError, error } = useAwaySettingsQuery();
22
+ if (isPending)
23
+ return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
24
+ if (isError)
25
+ return <p className="text-destructive text-sm">{error.message}</p>;
26
+ return <Form key={data.windowMinutes} saved={data} />;
27
+ }
28
+
29
+ function Form({ saved }: { saved: AwaySettings }) {
30
+ const update = useUpdateAwaySettings();
31
+ const [text, setText] = useState(String(saved.windowMinutes));
32
+ const minutes = parseWindow(text);
33
+ const error =
34
+ minutes === null
35
+ ? `A whole number from ${MIN_WINDOW_MINUTES} to ${MAX_WINDOW_MINUTES}`
36
+ : null;
37
+ return (
38
+ <form
39
+ onSubmit={(e) => {
40
+ e.preventDefault();
41
+ if (minutes === null) return;
42
+ update.mutate(
43
+ { windowMinutes: minutes },
44
+ {
45
+ onSuccess: () => toast.success("Away mode settings saved"),
46
+ onError: (err) => toast.error(err.message),
47
+ }
48
+ );
49
+ }}
50
+ className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
51
+ >
52
+ <h3 className="text-sm font-medium">Away mode</h3>
53
+ <p className="text-muted-foreground text-sm">
54
+ Away mode works through orchestrators that were busy in this window, and
55
+ only lists sessions with a real turn inside it. Older ones are left
56
+ alone: a message wakes an agent and re-reads its whole conversation.
57
+ </p>
58
+ <label className="flex items-center justify-between gap-3 text-sm">
59
+ <span className="text-muted-foreground">Recent means the last</span>
60
+ <span className="flex items-center gap-2">
61
+ <Input
62
+ type="number"
63
+ inputMode="numeric"
64
+ min={MIN_WINDOW_MINUTES}
65
+ max={MAX_WINDOW_MINUTES}
66
+ step={1}
67
+ value={text}
68
+ onChange={(e) => setText(e.target.value)}
69
+ aria-label="Away window, minutes"
70
+ aria-invalid={!!error}
71
+ className="h-11 w-24 text-base sm:h-9 md:text-sm"
72
+ />
73
+ <span className="text-muted-foreground">min</span>
74
+ </span>
75
+ </label>
76
+ {error && <p className="text-destructive text-xs">{error}</p>}
77
+ <div className="flex justify-end">
78
+ <Button
79
+ type="submit"
80
+ size="sm"
81
+ className="h-11 sm:h-8"
82
+ disabled={
83
+ !!error || minutes === saved.windowMinutes || update.isPending
84
+ }
85
+ >
86
+ Save
87
+ </Button>
88
+ </div>
89
+ </form>
90
+ );
91
+ }
@@ -6,6 +6,7 @@ import { TaskLimitForm } from "./TaskLimitForm";
6
6
  import { RulesForm } from "./RulesForm";
7
7
  import { FreshStartForm } from "./FreshStartForm";
8
8
  import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
9
+ import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
9
10
 
10
11
  // Settings > Workspaces: each workspace's own settings.
11
12
  export function WorkspaceSettings() {
@@ -52,6 +53,7 @@ export function WorkspaceSettings() {
52
53
  </section>
53
54
  ))}
54
55
  <FreshStartForm />
56
+ <AwayWindowForm />
55
57
  </div>
56
58
  );
57
59
  }
@@ -71,3 +71,39 @@ export function awayEnds(d: AwayDuration, now = new Date()): Date | null {
71
71
  if (now.getHours() >= 5) morning.setDate(morning.getDate() + 1);
72
72
  return morning;
73
73
  }
74
+
75
+ // How recent a session's last real turn must be for Away mode to touch it.
76
+ export interface AwaySettings {
77
+ windowMinutes: number;
78
+ }
79
+
80
+ const settingsKey = [...awayKeys.all, "settings"] as const;
81
+
82
+ async function json<T>(res: Response): Promise<T> {
83
+ const data = await res.json().catch(() => ({}));
84
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
85
+ return data;
86
+ }
87
+
88
+ export function useAwaySettingsQuery() {
89
+ return useQuery({
90
+ queryKey: settingsKey,
91
+ queryFn: async () => json<AwaySettings>(await fetch("/api/away/settings")),
92
+ staleTime: 60_000,
93
+ });
94
+ }
95
+
96
+ export function useUpdateAwaySettings() {
97
+ const queryClient = useQueryClient();
98
+ return useMutation({
99
+ mutationFn: async (settings: AwaySettings) =>
100
+ json<AwaySettings>(
101
+ await fetch("/api/away/settings", {
102
+ method: "PUT",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify(settings),
105
+ })
106
+ ),
107
+ onSuccess: (data) => queryClient.setQueryData(settingsKey, data),
108
+ });
109
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Real activity, for Away mode: when a session last had a user or agent
3
+ * turn. Not `updated_at` (status polls bump it) and not tmux's activity
4
+ * clock where a better one exists, and never a turn Away mode itself
5
+ * started (an away line or digest an orchestrator was sent). The window
6
+ * past which a session counts as left alone is app-wide, in Settings →
7
+ * Workspaces.
8
+ */
9
+
10
+ import fs from "fs";
11
+ import { db, type Session } from "../db";
12
+ import type { ChatOrigin } from "../chat/events";
13
+ import { chatState } from "../chat/runner";
14
+ import { runningWorkers } from "../chat/worker/client";
15
+ import { statusDetector } from "../status-detector";
16
+ import { expandHome } from "../tasks/session";
17
+ import { isClaudeSessionId, transcriptPath } from "../tasks/transcript";
18
+
19
+ import {
20
+ DEFAULT_WINDOW_MINUTES,
21
+ MAX_WINDOW_MINUTES,
22
+ MIN_WINDOW_MINUTES,
23
+ } from "./window-limits";
24
+
25
+ export const WINDOW_KEY = "away.window_minutes";
26
+
27
+ export function awayWindowMinutes(): number {
28
+ const row = db
29
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
30
+ .get(WINDOW_KEY) as { value: string } | undefined;
31
+ const n = row ? Number(row.value) : DEFAULT_WINDOW_MINUTES;
32
+ return Number.isInteger(n) &&
33
+ n >= MIN_WINDOW_MINUTES &&
34
+ n <= MAX_WINDOW_MINUTES
35
+ ? n
36
+ : DEFAULT_WINDOW_MINUTES;
37
+ }
38
+
39
+ export const awayWindowMs = () => awayWindowMinutes() * 60_000;
40
+
41
+ export function setAwayWindowMinutes(value: unknown): number {
42
+ if (
43
+ typeof value !== "number" ||
44
+ !Number.isInteger(value) ||
45
+ value < MIN_WINDOW_MINUTES ||
46
+ value > MAX_WINDOW_MINUTES
47
+ )
48
+ throw new Error(
49
+ `The window is a whole number of minutes from ${MIN_WINDOW_MINUTES} to ${MAX_WINDOW_MINUTES}`
50
+ );
51
+ db.prepare(
52
+ `INSERT INTO settings (key, value) VALUES (?, ?)
53
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
54
+ ).run(WINDOW_KEY, String(value));
55
+ return value;
56
+ }
57
+
58
+ // Kinds a turn writes; notes, errors, compactions and MCP listings aren't
59
+ // anyone working.
60
+ const TURN_KINDS = new Set([
61
+ "user",
62
+ "assistant",
63
+ "reasoning",
64
+ "tool",
65
+ "todos",
66
+ "plan",
67
+ "artifact",
68
+ "turn_end",
69
+ ]);
70
+ const SCAN = 200;
71
+
72
+ interface Item {
73
+ kind?: string;
74
+ createdAt?: number;
75
+ origin?: ChatOrigin;
76
+ }
77
+
78
+ // Newest first: a turn's items come before the message that started it.
79
+ // A turn Away mode started doesn't count; one an event, a peer or a
80
+ // schedule started counts from its first reply, not from the message.
81
+ export function lastChatTurn(items: Item[]): number {
82
+ let reply = 0;
83
+ for (const item of items) {
84
+ if (!item.kind || !TURN_KINDS.has(item.kind)) continue;
85
+ const at = item.createdAt ?? 0;
86
+ if (item.kind !== "user") {
87
+ reply = Math.max(reply, at);
88
+ continue;
89
+ }
90
+ const origin = item.origin;
91
+ if (origin?.away) {
92
+ reply = 0;
93
+ continue;
94
+ }
95
+ // Typed by the reader, or the reader's own decision.
96
+ if (!origin || origin.kind === "decision") return Math.max(reply, at);
97
+ if (reply) return reply;
98
+ }
99
+ return reply;
100
+ }
101
+
102
+ function chatTurn(sessionId: string): number {
103
+ const rows = db
104
+ .prepare(
105
+ `SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq DESC LIMIT ?`
106
+ )
107
+ .all(sessionId, SCAN) as { data: string }[];
108
+ return lastChatTurn(
109
+ rows.map((r) => {
110
+ try {
111
+ return JSON.parse(r.data) as Item;
112
+ } catch {
113
+ return {};
114
+ }
115
+ })
116
+ );
117
+ }
118
+
119
+ // A terminal's Claude conversation file is written on every turn and on
120
+ // nothing else; other agents fall back to tmux's output clock, which a
121
+ // status poll (capture-pane) doesn't move.
122
+ function terminalTurn(s: Session): number {
123
+ if (s.claude_session_id && isClaudeSessionId(s.claude_session_id)) {
124
+ try {
125
+ const cwd = expandHome(s.worktree_path || s.working_directory);
126
+ return fs.statSync(transcriptPath(cwd, s.claude_session_id)).mtimeMs;
127
+ } catch {
128
+ // No file here: fall through.
129
+ }
130
+ }
131
+ return (
132
+ statusDetector.getTimestamp(s.tmux_name, s.host_id || undefined) * 1000
133
+ );
134
+ }
135
+
136
+ export function lastTurnAt(s: Session): number {
137
+ return s.view === "chat" ? chatTurn(s.id) : terminalTurn(s);
138
+ }
139
+
140
+ // Whether a chat's agent is up: a worker that went idle long enough exits,
141
+ // and a message would start it again and re-read the whole conversation.
142
+ // A terminal's is its tmux session (facts say "dead" when it's gone).
143
+ export function agentLive(s: Session): boolean {
144
+ if (s.view !== "chat")
145
+ return statusDetector.sessionExists(s.tmux_name, s.host_id || undefined);
146
+ return chatState(s.id) !== null || runningWorkers().includes(s.id);
147
+ }
package/lib/away/brief.ts CHANGED
@@ -7,17 +7,25 @@ import {
7
7
  activeAway,
8
8
  clockTime,
9
9
  fromSql,
10
+ reachedWorkspaces,
10
11
  untilText,
11
12
  type AwayRow,
12
13
  } from "./state";
13
14
  import { SWEEP_EVERY_MS } from "./sweep";
15
+ import { awayWindowMinutes } from "./activity";
14
16
 
15
17
  export function awayOnLine(row: AwayRow, now = Date.now()): string {
16
- return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps: it nudges stalled tasks, asks idle ones with uncommitted work to commit and open a PR, runs lost reviews again and checks the deploy after each merge; what needs you comes as events, and each step is in your log. Interactive sessions (not tasks) are Saad's: in Away mode only ask them to wrap up, never drive them further. Hard lines still go to ask_saad; while he's away his asks reach his phone in one batch per sweep, so raise them as usual. \`aos notify\` is held for that batch too, so don't send one per item.`;
18
+ return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps this workspace without messaging any session: it runs lost reviews again, checks the deploy after each merge, and sends you one digest of what's new (stalled tasks, idle ones with uncommitted work and no PR, Saad's own sessions idle with uncommitted work). You decide what needs work and act with your tools. Only sessions with real activity in the last ${awayWindowMinutes()} minutes are listed: leave the rest alone, since a message wakes an agent and re-reads its whole context. Interactive sessions (not tasks) are Saad's: ask a listed one at most once to wrap up, never drive them further. Hard lines still go to ask_saad; while he's away his asks reach his phone in one batch per sweep, so raise them as usual. \`aos notify\` is held for that batch too, so don't send one per item.`;
17
19
  }
18
20
 
19
- // For the digest: the same line while Away is on, else nothing.
20
- export function awayDigestLine(now = Date.now()): string | null {
21
+ // For the digest: the same line while Away is on and has reached this
22
+ // workspace, else nothing.
23
+ export function awayDigestLine(
24
+ workspaceId: string,
25
+ now = Date.now()
26
+ ): string | null {
21
27
  const row = activeAway();
22
- return row ? awayOnLine(row, now) : null;
28
+ return row && reachedWorkspaces(row.id).has(workspaceId)
29
+ ? awayOnLine(row, now)
30
+ : null;
23
31
  }
@@ -87,19 +87,22 @@ export function inFlightWhy(t: {
87
87
  checks: CheckRow[];
88
88
  failures: FailureRow[];
89
89
  askKinds: string[];
90
- nudges: number;
90
+ // Times the sweep listed it for its orchestrator this period.
91
+ flagged: number;
91
92
  }): string {
92
93
  const escalated = t.failures.filter((f) => f.escalated_at);
93
94
  if (escalated.length)
94
95
  return `with you: the ${escalated.map((f) => f.gate).join(" and ")} gate failed twice`;
95
96
  if (t.askKinds.length) return `waiting on your answer (${t.askKinds[0]})`;
96
- const nudged = t.nudges ? `; nudged ${t.nudges}x while you were away` : "";
97
- if (!t.prNumber) return `no PR yet${nudged}`;
97
+ const flagged = t.flagged
98
+ ? `; flagged to the orchestrator ${t.flagged}x while you were away`
99
+ : "";
100
+ if (!t.prNumber) return `no PR yet${flagged}`;
98
101
  const of = (kind: string) => t.checks.find((c) => c.kind === kind);
99
102
  const review = of("review");
100
103
  const ci = of("ci");
101
104
  if (ci && ci.status !== "pass" && ci.status !== "running")
102
- return `CI failing at ${sha7(ci.sha)}${nudged}`;
105
+ return `CI failing at ${sha7(ci.sha)}${flagged}`;
103
106
  if (review?.status === "block")
104
107
  return `review found blocking issues at ${sha7(review.sha)}`;
105
108
  const failed = t.failures.filter((f) => f.count > 0);
@@ -113,6 +116,9 @@ export function inFlightWhy(t: {
113
116
  return "PR open and reviewed, waiting on sign-off";
114
117
  }
115
118
 
119
+ // What the sweep did to a task (./run), not just saw.
120
+ const TOUCHED = "'stalled', 'commit', 'review'";
121
+
116
122
  interface TaskRow {
117
123
  id: string;
118
124
  name: string;
@@ -165,6 +171,8 @@ function merged(
165
171
  });
166
172
  }
167
173
 
174
+ // Running tasks the sweep touched: listed for the orchestrator, or their
175
+ // review run again. Ones it left alone aren't counted.
168
176
  function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
169
177
  const tasks = db
170
178
  .prepare(
@@ -173,16 +181,18 @@ function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
173
181
  FROM sessions s JOIN projects p ON p.id = s.project_id
174
182
  WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
175
183
  AND s.task_status = 'running'
184
+ AND EXISTS (SELECT 1 FROM away_actions a WHERE a.away_id = ?
185
+ AND a.subject = s.id AND a.kind IN (${TOUCHED}))
176
186
  ORDER BY s.created_at, s.id`
177
187
  )
178
- .all(workspaceId) as TaskRow[];
188
+ .all(workspaceId, awayId) as TaskRow[];
179
189
  if (!tasks.length) return [];
180
190
  const checks = checksBySession(workspaceId);
181
191
  const failures = failuresBySession(workspaceId);
182
192
  const asks = openAsks(workspaceId);
183
- const nudges = db.prepare(
193
+ const flagged = db.prepare(
184
194
  `SELECT COUNT(*) AS n FROM away_actions
185
- WHERE away_id = ? AND subject = ? AND kind = 'nudge'`
195
+ WHERE away_id = ? AND subject = ? AND kind IN ('stalled', 'commit')`
186
196
  );
187
197
  return tasks.map((t) => ({
188
198
  task: t.name,
@@ -197,7 +207,7 @@ function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
197
207
  askKinds: asks
198
208
  .filter((a) => a.subject === `task:${t.id}`)
199
209
  .map((a) => a.kind),
200
- nudges: (nudges.get(awayId, t.id) as { n: number }).n,
210
+ flagged: (flagged.get(awayId, t.id) as { n: number }).n,
201
211
  }),
202
212
  }));
203
213
  }
@@ -261,12 +271,16 @@ function waiting(workspaceId: string): WaitingItem[] {
261
271
  }));
262
272
  }
263
273
 
274
+ // Only the workspaces Away mode reached (`reached`, whose orchestrator was
275
+ // active and told); the rest were left alone.
264
276
  export function buildAwayReport(
265
277
  away: AwayRow,
266
- deployConfigured: (projectId: string) => boolean
278
+ deployConfigured: (projectId: string) => boolean,
279
+ reached: ReadonlySet<string>
267
280
  ): AwayReport {
268
281
  const end = away.ended_at ?? away.started_at;
269
282
  const workspaces = listWorkspaces()
283
+ .filter((w) => reached.has(w.id))
270
284
  .map((w) => ({
271
285
  id: w.id,
272
286
  name: w.name,