@saadnvd1/agent-os 0.3.460 → 0.3.462

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 (38) hide show
  1. package/README.md +51 -3
  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/page.tsx +2 -0
  6. package/components/Away/AwayReport.tsx +16 -0
  7. package/components/Away/AwayReportDialog.tsx +193 -0
  8. package/components/Away/AwayToggle.tsx +91 -0
  9. package/components/Away/time.test.ts +20 -0
  10. package/components/Away/time.ts +32 -0
  11. package/components/Away/useAwayCommands.ts +43 -0
  12. package/components/Chat/Messages.tsx +1 -0
  13. package/components/CommandPalette/useAppCommands.ts +2 -0
  14. package/components/Pane/MobileTabBar.tsx +14 -1
  15. package/components/SessionList/SidebarHeader.tsx +2 -0
  16. package/data/away/index.ts +73 -0
  17. package/data/push/topics.ts +4 -0
  18. package/lib/away/brief.ts +23 -0
  19. package/lib/away/deploy.ts +173 -0
  20. package/lib/away/report.ts +398 -0
  21. package/lib/away/run.ts +445 -0
  22. package/lib/away/state.ts +201 -0
  23. package/lib/away/sweep.ts +216 -0
  24. package/lib/away/view.ts +51 -0
  25. package/lib/chat/events.ts +1 -1
  26. package/lib/db/changes.ts +3 -0
  27. package/lib/db/migrations.ts +69 -0
  28. package/lib/db/types.ts +4 -0
  29. package/lib/notify/index.ts +7 -0
  30. package/lib/orchestrator/digest.ts +11 -8
  31. package/lib/orchestrator/notes.ts +8 -1
  32. package/lib/orchestrator/watcher.ts +6 -0
  33. package/lib/project-config/agentos.schema.json +24 -0
  34. package/lib/project-config/schema.ts +17 -0
  35. package/lib/worktree-hooks.ts +9 -0
  36. package/package.json +1 -1
  37. package/scripts/hooks/staged-secrets.test.ts +92 -0
  38. package/scripts/hooks/staged-secrets.ts +64 -0
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
@@ -1007,6 +1023,30 @@ reset` yourself in a terminal on the machine: it refuses inside an
1007
1023
  the same line carries Pause/Resume. While paused it acts on nothing:
1008
1024
  events queue, acting tools refuse, and its stack starts and lands hold.
1009
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/`).
1010
1050
 
1011
1051
  ## LumifyHub (optional)
1012
1052
 
@@ -1222,8 +1262,16 @@ descriptors, child processes and spawns per minute; `scripts/perf/execs.mjs
1222
1262
  spawns with eslogger (macOS) or strace (Linux) and need passwordless sudo for
1223
1263
  that one number. Run them on a machine with room to spare.
1224
1264
 
1225
- A pre-commit hook formats and lints staged files, then typechecks and runs the
1226
- tests. CI runs `scripts/check --build` on every pull request and push to main,
1265
+ A pre-commit hook (`.husky/pre-commit`, installed by `npm install`) checks
1266
+ only what is staged, in well under two seconds: `prettier --check`, failing
1267
+ with the `npx prettier --write …` command that fixes it, and a scan of the
1268
+ added lines for token-shaped secrets with the merge gate's own rule
1269
+ (`scripts/hooks/staged-secrets.ts` reads `secretsIn` from
1270
+ `lib/orchestrator/diff.ts`), naming the file and line. Build a test's fake
1271
+ token at runtime (`"gh" + "p_" + "a".repeat(36)`) rather than writing it
1272
+ whole. It runs in the worktrees AgentOS makes too, which have no `.husky/_`
1273
+ until `npm install` runs in them. Typecheck, lint and tests are left to CI.
1274
+ In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
1227
1275
  which also holds the app's first-load JavaScript to a budget
1228
1276
  (`scripts/check-bundle.mjs`; `npx next experimental-analyze` shows what's in
1229
1277
  it), and fails a pull request whose body has no Code review section for its head
@@ -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
+ }
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
 
@@ -35,6 +35,7 @@ import { settingsUiActions } from "@/stores/settingsUi";
35
35
  import { usageUiActions } from "@/stores/usageUi";
36
36
  import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
37
37
  import { LoadGauge } from "./LoadGauge";
38
+ import { AwayToggle } from "@/components/Away/AwayToggle";
38
39
  import { useSchedulesGlance } from "./useSchedulesGlance";
39
40
  import { cn } from "@/lib/utils";
40
41
 
@@ -67,6 +68,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
67
68
  <LoadGauge />
68
69
  </div>
69
70
  <div className="flex shrink-0 items-center gap-0.5">
71
+ <AwayToggle />
70
72
  <ADropdownMenu
71
73
  trigger={
72
74
  <Button