@saadnvd1/agent-os 0.3.459 → 0.3.461

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 (45) hide show
  1. package/README.md +53 -1
  2. package/app/api/away/route.ts +56 -0
  3. package/app/api/away/seen/route.ts +12 -0
  4. package/app/api/health/route.ts +9 -0
  5. package/app/api/workspaces/[id]/route.ts +8 -1
  6. package/app/page.tsx +2 -0
  7. package/components/Away/AwayReport.tsx +16 -0
  8. package/components/Away/AwayReportDialog.tsx +193 -0
  9. package/components/Away/AwayToggle.tsx +91 -0
  10. package/components/Away/time.test.ts +20 -0
  11. package/components/Away/time.ts +32 -0
  12. package/components/Away/useAwayCommands.ts +43 -0
  13. package/components/Chat/Messages.tsx +1 -0
  14. package/components/CommandPalette/useAppCommands.ts +2 -0
  15. package/components/Pane/MobileTabBar.tsx +14 -1
  16. package/components/SessionList/SidebarHeader.tsx +2 -0
  17. package/components/Workspaces/WorkspaceSettings.tsx +9 -1
  18. package/data/away/index.ts +73 -0
  19. package/data/push/topics.ts +4 -0
  20. package/data/workspaces/index.ts +6 -1
  21. package/lib/away/brief.ts +23 -0
  22. package/lib/away/deploy.ts +173 -0
  23. package/lib/away/report.ts +398 -0
  24. package/lib/away/run.ts +445 -0
  25. package/lib/away/state.ts +201 -0
  26. package/lib/away/sweep.ts +216 -0
  27. package/lib/away/view.ts +51 -0
  28. package/lib/chat/events.ts +1 -1
  29. package/lib/db/changes.ts +3 -0
  30. package/lib/db/migrations.ts +101 -0
  31. package/lib/db/types.ts +9 -0
  32. package/lib/notify/index.ts +7 -0
  33. package/lib/orchestrator/brief.ts +2 -0
  34. package/lib/orchestrator/conditions.ts +10 -3
  35. package/lib/orchestrator/digest.ts +11 -8
  36. package/lib/orchestrator/notes.ts +8 -1
  37. package/lib/orchestrator/pr-wake.ts +385 -0
  38. package/lib/orchestrator/watcher.ts +12 -2
  39. package/lib/project-config/agentos.schema.json +24 -0
  40. package/lib/project-config/schema.ts +17 -0
  41. package/lib/tasks/gh.ts +2 -0
  42. package/lib/tasks/pr-activity.ts +295 -0
  43. package/lib/tasks/state.ts +9 -1
  44. package/lib/workspaces.ts +8 -2
  45. package/package.json +1 -1
package/README.md CHANGED
@@ -494,13 +494,29 @@ root. Every field is optional:
494
494
  "browse": { "login": "/dev/login", "map": "docs/browsing.md" },
495
495
  "database": { "from": "app_development", "env": "DATABASE_NAME" },
496
496
  "notes": ["Kill your own server by port: lsof -ti tcp:$RAILS_PORT"],
497
- "merge": { "method": "squash" }
497
+ "merge": { "method": "squash" },
498
+ "deploy": {
499
+ "deployed": "~/.deploy/deployed",
500
+ "health": "https://app.example.com/up"
501
+ }
498
502
  }
499
503
  ```
500
504
 
501
505
  `merge` sets the project's merge method and branch clean-up (see
502
506
  [Merging settings](#tasks)).
503
507
 
508
+ `deploy` is how [Away mode](#orchestrator) checks a merge went live: `deployed`
509
+ is a file your deploy writes the deployed commit to, `failed` (optional) one it
510
+ writes a commit whose deploy failed to, and `health` (optional) a URL that
511
+ answers 2xx once the server is back (a path alone is this AgentOS server's own,
512
+ as this repository's `agentos.json` uses with `scripts/autodeploy`'s state
513
+ files). A merge counts as deployed once the `deployed` commit contains it and
514
+ `health` answers; as failed when the `failed` commit contains it, the server
515
+ doesn't come back within 3 minutes (or, when its health answer has a
516
+ `startedAt`, hasn't restarted since the merge), or it isn't deployed `within_minutes`
517
+ (30) after the merge. No `deployed` file on the machine means nothing deploys
518
+ there, and nothing is checked.
519
+
504
520
  Every task and worktree session takes a **port slot**, and each named port is
505
521
  `base + slot` (a project with no `ports` still gets a `PORT` from 3100), so
506
522
  parallel sessions never share a dev server port. The slot is kept on the
@@ -900,6 +916,18 @@ it may do on its own, and the lines that always come back to you as asks.
900
916
  unchanged (same `git patch-id`; a fresh review starts otherwise). Not
901
917
  clean: the task is asked once to rebase. Either way, one event. Main and
902
918
  other tasks' branches are never written.
919
+ - **Tasks woken on CI failures and reviews:** a check failing on a task's
920
+ PR head messages the task's own agent once per head, with the check, the
921
+ failing step and about 60 lines of `gh run view --log-failed` around the
922
+ error, telling it to fix the root cause, push and update its Reviewed:
923
+ line. A review or comment from a collaborator (not the account gh runs
924
+ as, which is the task's own) wakes it once per review or comment; one
925
+ GraphQL query per repository a minute fingerprints the open task PRs, and
926
+ only a PR whose fingerprint moved has its reviews read. Comment-only wakes
927
+ stop at 10 a task; changes requested always wake. A task mid-turn gets it
928
+ queued, the orchestrator hears one line per wake, and merged, closed,
929
+ dropped or done tasks aren't watched. On by default; **Settings →
930
+ Workspaces** turns it off per workspace.
903
931
  - **External PRs and sessions:** `review` and `sign_off` also take an open
904
932
  PR no AgentOS task owns (dispatch, a person, dependabot) by `#N`,
905
933
  `owner/repo#N` or URL, as long as its repository is one of the workspace's
@@ -995,6 +1023,30 @@ reset` yourself in a terminal on the machine: it refuses inside an
995
1023
  the same line carries Pause/Resume. While paused it acts on nothing:
996
1024
  events queue, acting tools refuse, and its stack starts and lands hold.
997
1025
  It can still read, note and ask. Resume delivers what queued, folded.
1026
+ - **Away mode:** turn it on when you step away, from the coffee cup in the
1027
+ sidebar header or ⌘K ("Away for 4 hours"), for 1 hour, 4 hours, until
1028
+ morning (8 AM) or until you're back. It's kept in `away_periods` (who set
1029
+ 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
1030
+ ends on its own at its time. Every orchestrator gets an event, and a line
1031
+ leading its digest, saying you're away and to follow work through rather
1032
+ than only react. Every 10 minutes a sweep, written as code with no model
1033
+ scanning, acts on what it finds and logs each step in the orchestrator's
1034
+ chat: a task quiet 30 minutes with no PR (or failing CI) is nudged, at most
1035
+ twice; an idle task with uncommitted work and no PR is asked to commit and
1036
+ open one or say why not; a PR green and settled whose review never started
1037
+ or was lost has it run again (once seen on two sweeps, so the orchestrator
1038
+ had its turn); and after each merge, the project's `deploy` check says
1039
+ whether it went live and the server came back, with a failure sent to the
1040
+ orchestrator as an event. Interactive sessions are yours: one with
1041
+ uncommitted work is asked once to wrap up, and nothing more. Paused
1042
+ workspaces aren't swept. Hard lines still become asks, and while you're
1043
+ away the asks raised, failed deploys and any `aos notify` an orchestrator
1044
+ sends reach your phone as one message per sweep. Turning it off (or its running out) builds "While you were
1045
+ away" from the database: what merged and deployed with PR links, what's
1046
+ still in flight and which gate or blocker holds it, what failed, was dropped
1047
+ or was escalated, and what waits on you (an explicit "none"). It opens on
1048
+ whichever device is open until you dismiss it, a short summary goes to
1049
+ your phone, and the report stays in Settings → Audits (`away/`).
998
1050
 
999
1051
  ## LumifyHub (optional)
1000
1052
 
@@ -0,0 +1,56 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { getDevice } from "@/lib/security/devices";
4
+ import { turnAwayOff, turnAwayOn } from "@/lib/away/run";
5
+ import { awayView } from "@/lib/away/view";
6
+
7
+ // GET /api/away - whether Saad is away, and the report he hasn't dismissed.
8
+ export function GET() {
9
+ return NextResponse.json(awayView());
10
+ }
11
+
12
+ // POST /api/away - { on: true, expiresAt: ISO | null } turns Away on (or
13
+ // moves its end); { on: false } turns it off and builds the report. From a
14
+ // trusted place, as Pause is: it changes what the orchestrators do.
15
+ export async function POST(request: NextRequest) {
16
+ const gate = requireApprover(request);
17
+ if (!gate.ok) return gate.response;
18
+ const body = (await request.json().catch(() => ({}))) as {
19
+ on?: unknown;
20
+ expiresAt?: unknown;
21
+ };
22
+ if (typeof body.on !== "boolean")
23
+ return NextResponse.json(
24
+ { error: "on must be true or false" },
25
+ { status: 400 }
26
+ );
27
+ if (!body.on) {
28
+ turnAwayOff("saad");
29
+ return NextResponse.json(awayView());
30
+ }
31
+ let expiresAt: number | null = null;
32
+ if (body.expiresAt !== null && body.expiresAt !== undefined) {
33
+ expiresAt =
34
+ typeof body.expiresAt === "string" ? Date.parse(body.expiresAt) : NaN;
35
+ if (!Number.isFinite(expiresAt))
36
+ return NextResponse.json(
37
+ { error: "expiresAt must be a time or null" },
38
+ { status: 400 }
39
+ );
40
+ }
41
+ const { approver } = gate;
42
+ const setBy = approver.deviceId
43
+ ? (getDevice(approver.deviceId)?.name ?? "a device")
44
+ : approver.via === "loopback"
45
+ ? "this machine"
46
+ : "the tailnet";
47
+ try {
48
+ turnAwayOn({ setBy, expiresAt });
49
+ } catch (error) {
50
+ return NextResponse.json(
51
+ { error: error instanceof Error ? error.message : String(error) },
52
+ { status: 400 }
53
+ );
54
+ }
55
+ return NextResponse.json(awayView());
56
+ }
@@ -0,0 +1,12 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { markReportSeen } from "@/lib/away/state";
3
+
4
+ // POST /api/away/seen - { id }: dismiss that "While you were away" report.
5
+ // It stays in Settings > Audits.
6
+ export async function POST(request: NextRequest) {
7
+ const { id } = (await request.json().catch(() => ({}))) as { id?: unknown };
8
+ if (typeof id !== "number" || !Number.isInteger(id))
9
+ return NextResponse.json({ error: "id must be a number" }, { status: 400 });
10
+ markReportSeen(id);
11
+ return NextResponse.json({ ok: true });
12
+ }
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
4
+
5
+ // GET /api/health - the server is up and answering; when it started, so a
6
+ // deploy check can tell it restarted after a merge (lib/away/deploy.ts).
7
+ export function GET() {
8
+ return NextResponse.json({ ok: true, startedAt });
9
+ }
@@ -5,7 +5,13 @@ type RouteParams = { params: Promise<{ id: string }> };
5
5
 
6
6
  export async function PATCH(request: NextRequest, { params }: RouteParams) {
7
7
  const { id } = await params;
8
- const { name, collapsed, sortOrder, maxRunningTasks } = await request.json();
8
+ const { name, collapsed, sortOrder, maxRunningTasks, wakeTasksOnPR } =
9
+ await request.json();
10
+ if (wakeTasksOnPR !== undefined && typeof wakeTasksOnPR !== "boolean")
11
+ return NextResponse.json(
12
+ { error: "wakeTasksOnPR is true or false" },
13
+ { status: 400 }
14
+ );
9
15
  let workspace;
10
16
  try {
11
17
  workspace = updateWorkspace(id, {
@@ -13,6 +19,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
13
19
  collapsed,
14
20
  sortOrder,
15
21
  maxRunningTasks,
22
+ wakeTasksOnPR,
16
23
  });
17
24
  } catch (error) {
18
25
  const message = error instanceof Error ? error.message : String(error);
package/app/page.tsx CHANGED
@@ -32,6 +32,7 @@ if (typeof window !== "undefined") {
32
32
  }
33
33
  import { PaneProvider, usePanes } from "@/contexts/PaneContext";
34
34
  import { Pane } from "@/components/Pane";
35
+ import { AwayReport } from "@/components/Away/AwayReport";
35
36
  import { useNotifications } from "@/hooks/useNotifications";
36
37
  import { useViewport } from "@/hooks/useViewport";
37
38
  import { useViewportHeight } from "@/hooks/useViewportHeight";
@@ -128,6 +129,7 @@ const AppDialogs = memo(function AppDialogs() {
128
129
  <LumifyHubDialogs />
129
130
  <UsageDialog />
130
131
  <MoveDialog />
132
+ <AwayReport />
131
133
  <CommandPalette />
132
134
  </>
133
135
  );
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ import dynamic from "next/dynamic";
4
+ import { useAwayQuery } from "@/data/away";
5
+
6
+ const AwayReportDialog = dynamic(
7
+ () => import("./AwayReportDialog").then((m) => m.AwayReportDialog),
8
+ { ssr: false }
9
+ );
10
+
11
+ // Opens "While you were away" when Away has ended and its report hasn't
12
+ // been dismissed, on whichever device is open.
13
+ export function AwayReport() {
14
+ const { data } = useAwayQuery();
15
+ return data?.report ? <AwayReportDialog report={data.report} /> : null;
16
+ }
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { ExternalLink } from "lucide-react";
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import { Button } from "@/components/ui/button";
14
+ import type {
15
+ AwayReport,
16
+ DeployState,
17
+ WorkspaceReport,
18
+ } from "@/lib/away/report";
19
+ import { useDismissAwayReport } from "@/data/away";
20
+ import { settingsUiActions } from "@/stores/settingsUi";
21
+ import { cn } from "@/lib/utils";
22
+ import { awayRange } from "./time";
23
+
24
+ const DEPLOY: Record<DeployState, { label: string; tone: string }> = {
25
+ deployed: {
26
+ label: "Deployed",
27
+ tone: "text-emerald-700 dark:text-emerald-400",
28
+ },
29
+ failed: { label: "Deploy failed", tone: "text-destructive" },
30
+ pending: {
31
+ label: "Deploy not confirmed",
32
+ tone: "text-amber-700 dark:text-amber-300",
33
+ },
34
+ unchecked: { label: "No deploy check", tone: "text-muted-foreground" },
35
+ };
36
+
37
+ function PrLink({ pr, url }: { pr: number | null; url: string | null }) {
38
+ if (!pr) return null;
39
+ if (!url || !url.startsWith("https://"))
40
+ return <span className="text-muted-foreground">#{pr}</span>;
41
+ return (
42
+ <a
43
+ href={url}
44
+ target="_blank"
45
+ rel="noreferrer"
46
+ className="text-primary inline-flex min-h-11 items-center gap-1 underline-offset-2 hover:underline md:min-h-0"
47
+ >
48
+ #{pr}
49
+ <ExternalLink className="h-3 w-3" />
50
+ </a>
51
+ );
52
+ }
53
+
54
+ function Row({ children }: { children: ReactNode }) {
55
+ return (
56
+ <li className="bg-foreground/[0.03] rounded-xl px-3 py-2 text-sm break-words">
57
+ {children}
58
+ </li>
59
+ );
60
+ }
61
+
62
+ function Section<T>({
63
+ title,
64
+ report,
65
+ pick,
66
+ render,
67
+ empty,
68
+ }: {
69
+ title: string;
70
+ report: AwayReport;
71
+ pick: (w: WorkspaceReport) => T[];
72
+ render: (item: T) => ReactNode;
73
+ empty: string;
74
+ }) {
75
+ const groups = report.workspaces.filter((w) => pick(w).length);
76
+ const many = report.workspaces.length > 1;
77
+ return (
78
+ <section className="space-y-1.5">
79
+ <h3 className="label-mono text-muted-foreground">{title}</h3>
80
+ {groups.length ? (
81
+ <ul className="space-y-1.5">
82
+ {groups.flatMap((w) =>
83
+ pick(w).map((item, i) => (
84
+ <Row key={`${w.id}-${i}`}>
85
+ {many && (
86
+ <span className="text-muted-foreground mr-1.5 text-xs">
87
+ {w.name}
88
+ </span>
89
+ )}
90
+ {render(item)}
91
+ </Row>
92
+ ))
93
+ )}
94
+ </ul>
95
+ ) : (
96
+ <p className="text-muted-foreground px-1 text-sm">{empty}</p>
97
+ )}
98
+ </section>
99
+ );
100
+ }
101
+
102
+ export function AwayReportDialog({ report }: { report: AwayReport }) {
103
+ const dismiss = useDismissAwayReport();
104
+ const close = () => dismiss.mutate(report.awayId);
105
+ const t = report.totals;
106
+ return (
107
+ <Dialog open onOpenChange={(open) => !open && close()}>
108
+ <DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto [&>*]:min-w-0">
109
+ <DialogHeader>
110
+ <DialogTitle>While you were away</DialogTitle>
111
+ <DialogDescription>
112
+ {awayRange(report.startedAt, report.endedAt)}
113
+ {report.endedBy === "expired" ? ", when Away ran out" : ""}.{" "}
114
+ {t.merged} merged, {t.inFlight} in flight, {t.failed} failed or
115
+ escalated, {t.waiting} waiting on you.
116
+ </DialogDescription>
117
+ </DialogHeader>
118
+ <div className="space-y-4">
119
+ <Section
120
+ title="Waiting on you"
121
+ report={report}
122
+ pick={(w) => w.waiting}
123
+ empty="None. Nothing is waiting on you."
124
+ render={(a) => (
125
+ <>
126
+ {a.hardLine && (
127
+ <span className="text-destructive mr-1.5 text-xs font-medium">
128
+ Hard line
129
+ </span>
130
+ )}
131
+ <span className="text-muted-foreground mr-1">{a.kind}:</span>
132
+ {a.title}
133
+ </>
134
+ )}
135
+ />
136
+ <Section
137
+ title="Merged"
138
+ report={report}
139
+ pick={(w) => w.merged}
140
+ empty="None."
141
+ render={(m) => (
142
+ <>
143
+ <span className="font-medium">{m.task}</span>{" "}
144
+ <PrLink pr={m.pr} url={m.url} />
145
+ <span className={cn("block text-xs", DEPLOY[m.deploy].tone)}>
146
+ {DEPLOY[m.deploy].label}
147
+ {m.deployDetail ? `: ${m.deployDetail}` : ""}
148
+ </span>
149
+ </>
150
+ )}
151
+ />
152
+ <Section
153
+ title="Still in flight"
154
+ report={report}
155
+ pick={(w) => w.inFlight}
156
+ empty="None."
157
+ render={(f) => (
158
+ <>
159
+ <span className="font-medium">{f.task}</span>{" "}
160
+ <PrLink pr={f.pr} url={f.url} />
161
+ <span className="text-muted-foreground block text-xs">
162
+ {f.why}
163
+ </span>
164
+ </>
165
+ )}
166
+ />
167
+ <Section
168
+ title="Failed or escalated"
169
+ report={report}
170
+ pick={(w) => w.failed}
171
+ empty="None."
172
+ render={(f) => f.what}
173
+ />
174
+ </div>
175
+ <DialogFooter className="gap-2">
176
+ <Button
177
+ variant="secondary"
178
+ className="h-11 md:h-9"
179
+ onClick={() => {
180
+ close();
181
+ settingsUiActions.open("audits");
182
+ }}
183
+ >
184
+ Kept in Audits
185
+ </Button>
186
+ <Button className="h-11 md:h-9" onClick={close}>
187
+ Done
188
+ </Button>
189
+ </DialogFooter>
190
+ </DialogContent>
191
+ </Dialog>
192
+ );
193
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { Coffee, Undo2 } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import {
6
+ ADropdownMenu,
7
+ menuItem,
8
+ separator,
9
+ } from "@/components/a/ADropdownMenu";
10
+ import { Button } from "@/components/ui/button";
11
+ import {
12
+ AWAY_DURATIONS,
13
+ awayEnds,
14
+ useAwayQuery,
15
+ useSetAway,
16
+ type AwayDuration,
17
+ } from "@/data/away";
18
+ import { cn } from "@/lib/utils";
19
+ import { awayUntil } from "./time";
20
+
21
+ // The sidebar header's Away switch: off, a coffee cup that offers how
22
+ // long; on, the same cup in amber, whose menu says until when and turns
23
+ // it off.
24
+ export function AwayToggle() {
25
+ const { data } = useAwayQuery();
26
+ const setAway = useSetAway();
27
+ const away = data?.away ?? null;
28
+ const choose = (d: AwayDuration) =>
29
+ setAway.mutate(
30
+ { expiresAt: awayEnds(d) },
31
+ { onError: (e) => toast.error(e.message) }
32
+ );
33
+ const durations = AWAY_DURATIONS.map((d) =>
34
+ menuItem(away ? d.label.replace(/^For/, "Change to") : d.label, () =>
35
+ choose(d.id)
36
+ )
37
+ );
38
+ const until = away ? awayUntil(away.expiresAt) : "";
39
+ return (
40
+ <ADropdownMenu
41
+ minWidth="220px"
42
+ trigger={
43
+ <Button
44
+ variant="ghost"
45
+ size="icon-sm"
46
+ aria-label={away ? `Away ${until}` : "Away"}
47
+ title={
48
+ away
49
+ ? `Away ${until}`
50
+ : "Away: let the orchestrators follow work through"
51
+ }
52
+ disabled={setAway.isPending}
53
+ className={cn(
54
+ "relative h-11 w-11 md:h-8 md:w-8",
55
+ away &&
56
+ "bg-amber-500/20 text-amber-700 ring-1 ring-amber-500/50 hover:bg-amber-500/30 dark:text-amber-300"
57
+ )}
58
+ >
59
+ <Coffee className="h-4 w-4" />
60
+ </Button>
61
+ }
62
+ items={
63
+ away
64
+ ? [
65
+ menuItem(
66
+ "I'm back",
67
+ () =>
68
+ setAway.mutate(false, {
69
+ onError: (e) => toast.error(e.message),
70
+ }),
71
+ {
72
+ icon: Undo2,
73
+ description: `Away ${until}. Turning it off shows what happened.`,
74
+ }
75
+ ),
76
+ separator(),
77
+ ...durations,
78
+ ]
79
+ : [
80
+ menuItem("Away", () => {}, {
81
+ disabled: true,
82
+ description:
83
+ "Orchestrators follow work through; asks reach your phone in batches.",
84
+ }),
85
+ separator(),
86
+ ...durations,
87
+ ]
88
+ }
89
+ />
90
+ );
91
+ }
@@ -0,0 +1,20 @@
1
+ import { afterAll, describe, expect, it } from "vitest";
2
+ import { awayRange } from "./time";
3
+
4
+ // A zone that isn't UTC, so reading UTC as local would show.
5
+ const zone = process.env.TZ;
6
+ process.env.TZ = "America/Chicago";
7
+ afterAll(() => {
8
+ process.env.TZ = zone;
9
+ });
10
+
11
+ describe("the report's time range", () => {
12
+ it("reads SQLite's UTC times as UTC, not local", () => {
13
+ expect(
14
+ awayRange("2026-10-11 03:01:00", "2026-10-11 05:30:00", "en-US")
15
+ ).toMatch(/^Sat 10:01\sPM to Sun 12:30\sAM$/);
16
+ expect(
17
+ awayRange("2026-10-11 15:00:00", "2026-10-11 17:30:00", "en-US")
18
+ ).toMatch(/^10:00\sAM to 12:30\sPM$/);
19
+ });
20
+ });
@@ -0,0 +1,32 @@
1
+ // `locale` undefined: the browser's own.
2
+ const clock = (d: Date, locale?: string) =>
3
+ d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
4
+
5
+ // "until 4:30 PM", "until Sat 8:00 AM", or "until you're back".
6
+ export function awayUntil(expiresAt: string | null, now = new Date()): string {
7
+ if (!expiresAt) return "until you're back";
8
+ const d = new Date(expiresAt);
9
+ const day =
10
+ d.toDateString() === now.toDateString()
11
+ ? ""
12
+ : `${d.toLocaleDateString(undefined, { weekday: "short" })} `;
13
+ return `until ${day}${clock(d)}`;
14
+ }
15
+
16
+ // The report's times are SQLite's UTC "YYYY-MM-DD HH:MM:SS".
17
+ const utc = (t: string) =>
18
+ new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(t) ? t : `${t.replace(" ", "T")}Z`);
19
+
20
+ export function awayRange(
21
+ startedAt: string,
22
+ endedAt: string,
23
+ locale?: string
24
+ ): string {
25
+ const a = utc(startedAt);
26
+ const b = utc(endedAt);
27
+ const sameDay = a.toDateString() === b.toDateString();
28
+ const day = (d: Date) => d.toLocaleDateString(locale, { weekday: "short" });
29
+ return sameDay
30
+ ? `${clock(a, locale)} to ${clock(b, locale)}`
31
+ : `${day(a)} ${clock(a, locale)} to ${day(b)} ${clock(b, locale)}`;
32
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { Coffee, Undo2 } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import type { PaletteCommand } from "@/lib/palette/registry";
6
+ import { usePaletteCommands } from "@/hooks/usePaletteCommands";
7
+ import {
8
+ AWAY_DURATIONS,
9
+ awayEnds,
10
+ useAwayQuery,
11
+ useSetAway,
12
+ } from "@/data/away";
13
+
14
+ // ⌘K: "Away for 4 hours", "I'm back", and the rest of the toggle's menu.
15
+ export function useAwayCommands(): void {
16
+ const { data } = useAwayQuery();
17
+ const setAway = useSetAway();
18
+ const on = !!data?.away;
19
+ const onError = (e: Error) => toast.error(e.message);
20
+ const commands: PaletteCommand[] = [
21
+ ...(on
22
+ ? [
23
+ {
24
+ id: "away.off",
25
+ title: "I'm back (turn Away off)",
26
+ group: "Away",
27
+ keywords: ["away", "back", "return"],
28
+ icon: Undo2,
29
+ run: () => setAway.mutate(false, { onError }),
30
+ },
31
+ ]
32
+ : []),
33
+ ...AWAY_DURATIONS.map((d) => ({
34
+ id: `away.${d.id}`,
35
+ title: `${on ? "Stay away" : "Away"} ${d.label.replace(/^For/, "for").replace(/^Until/, "until")}`,
36
+ group: "Away",
37
+ keywords: ["away", "afk", "step away", "leave"],
38
+ icon: Coffee,
39
+ run: () => setAway.mutate({ expiresAt: awayEnds(d.id) }, { onError }),
40
+ })),
41
+ ];
42
+ usePaletteCommands("away", commands);
43
+ }
@@ -159,6 +159,7 @@ const NOTE_LABEL = {
159
159
  escalation: "Saad decides",
160
160
  ask: "Ask",
161
161
  pause: "Pause",
162
+ away: "Away",
162
163
  } as const;
163
164
 
164
165
  export function NoteLine({ item }: { item: Of<"note"> }) {
@@ -32,6 +32,7 @@ import { useAddProject } from "@/components/Projects/AddProject/useAddProject";
32
32
  import { usageUiActions } from "@/stores/usageUi";
33
33
  import { settingsCommands } from "@/components/Settings/commands";
34
34
  import { DRAFT_KEYS } from "@/lib/drafts";
35
+ import { useAwayCommands } from "@/components/Away/useAwayCommands";
35
36
 
36
37
  const LAST_THEME = "agentOS-last-theme-";
37
38
 
@@ -181,6 +182,7 @@ export function useAppCommands({
181
182
  })),
182
183
  ];
183
184
  usePaletteCommands("app", app);
185
+ useAwayCommands();
184
186
 
185
187
  usePaletteCommands(
186
188
  "sessions",
@@ -27,6 +27,8 @@ import {
27
27
  Users,
28
28
  } from "lucide-react";
29
29
  import { cn } from "@/lib/utils";
30
+ import { useAwayQuery } from "@/data/away";
31
+ import { awayUntil } from "@/components/Away/time";
30
32
  import type { Session, Project } from "@/lib/db";
31
33
  import type { LucideIcon } from "lucide-react";
32
34
  import { HostBadge } from "@/components/Hosts/HostBadge";
@@ -98,6 +100,7 @@ export function MobileTabBar({
98
100
  onSelectSession,
99
101
  onFind,
100
102
  }: MobileTabBarProps) {
103
+ const away = useAwayQuery().data?.away ?? null;
101
104
  // Find current session index and calculate prev/next
102
105
  const currentIndex = session
103
106
  ? sessions.findIndex((s) => s.id === session.id)
@@ -164,9 +167,19 @@ export function MobileTabBar({
164
167
  e.stopPropagation();
165
168
  onMenuClick();
166
169
  }}
167
- className="h-8 w-8 shrink-0"
170
+ aria-label={
171
+ away ? `Menu (Away ${awayUntil(away.expiresAt)})` : "Menu"
172
+ }
173
+ className="relative h-8 w-8 shrink-0 after:absolute after:-inset-1.5 after:content-['']"
168
174
  >
169
175
  <Menu className="h-4 w-4" />
176
+ {/* The sidebar's amber cup is out of sight here: Away still shows. */}
177
+ {away && (
178
+ <span
179
+ aria-hidden
180
+ className="absolute top-1 right-1 h-2 w-2 rounded-full bg-amber-500"
181
+ />
182
+ )}
170
183
  </Button>
171
184
  )}
172
185