Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
fee3bde
Take on a cron parser, the one piece of time arithmetic not worth owning
guidovizoso Aug 26, 2026
0ede96d
Give a deployment somewhere to keep a standing instruction
guidovizoso Aug 26, 2026
08eb43d
Read a cron in somebody's timezone, and say it back in words
guidovizoso Aug 26, 2026
f61f074
Keep and guard a person's standing instructions
guidovizoso Aug 26, 2026
c0497d5
Say every accepted schedule in words, and pin the recompute rules
guidovizoso Aug 26, 2026
40e4d68
Promise only the prose the renderer can keep
guidovizoso Aug 26, 2026
7233527
Move a routine's clock exactly once, however many sweeps race
guidovizoso Aug 26, 2026
a159419
Count only the runs the fatigue rule can act on
guidovizoso Aug 26, 2026
5aa3e03
Put Routines in the catalogue, the first entry that never leaves the …
guidovizoso Aug 26, 2026
2cf7d35
Say what a built-in connector needs, which is nothing
guidovizoso Aug 26, 2026
b3e060a
Let a Bot keep, change and drop routines by calling tools
guidovizoso Aug 26, 2026
85a8cda
Tell the truth about a write that landed and a routine that sleeps
guidovizoso Aug 26, 2026
593df68
Run a routine's turn with nobody's browser open
guidovizoso Aug 26, 2026
0425dc6
Pin the order the fatigue rule reads in, and the cut it makes
guidovizoso Aug 26, 2026
8943427
Open one door for the worker, and compare the secret behind it
guidovizoso Aug 26, 2026
c348d81
Leave a trace when the worker is turned away
guidovizoso Aug 26, 2026
1b7e2ae
Run a routine's turn into the thread the person will read
guidovizoso Aug 26, 2026
5117d39
Stop once, clean only what was taken, and name the real reason
guidovizoso Aug 26, 2026
b488202
Offer every due routine to the shared queue, once
guidovizoso Aug 26, 2026
4940497
Say which order the clock and the queue really move in
guidovizoso Aug 26, 2026
5cee46b
Consume a claimed firing honestly, and reap what is done with
guidovizoso Aug 26, 2026
85cf58d
Loop the sweep on a laptop, the way a cluster schedules it
guidovizoso Aug 26, 2026
90ac965
Guard the worker start against every look-alike process
guidovizoso Aug 26, 2026
cb89684
Schedule the routines sweep the way the culler is scheduled
guidovizoso Aug 26, 2026
a700643
Refuse at install what would crash-loop at midnight
guidovizoso Aug 26, 2026
b840e58
Serve a person their own standing instructions
guidovizoso Aug 26, 2026
3217b5d
Show every standing instruction, and let a person stop one
guidovizoso Aug 26, 2026
feec808
Say when the next run is, and type what the last one was
guidovizoso Aug 26, 2026
de96a9f
Say what a routine is, who it runs as, and what it will not do
guidovizoso Aug 26, 2026
70302e9
Tell the truth about the secret and the single container
guidovizoso Aug 26, 2026
2f38233
Name the pods that fail when the key is missing
guidovizoso Aug 26, 2026
d31aa9b
Bound every dispatch, and close the runs that never ran
guidovizoso Aug 26, 2026
7319a30
Renumber the routines migration behind the attention inbox
guidovizoso Aug 26, 2026
c9ab8b2
Format what main merged unformatted
guidovizoso Aug 26, 2026
076fec9
Give the endpoint test the attention slot main added
guidovizoso Aug 26, 2026
251d588
Name the channel by id when its name cannot name it
guidovizoso Aug 26, 2026
1f46504
Seed a history the model will accept, however a turn once ended
guidovizoso Aug 26, 2026
9da3ea9
Tell a firing turn that it is one
guidovizoso Aug 26, 2026
bcf1be1
Take the attention inbox out of this branch, keeping its applied migr…
guidovizoso Aug 27, 2026
ec01ee2
Clamp a routine's instruction to three lines on the list
guidovizoso Aug 27, 2026
2b61f7d
Judge a schedule by its whole cycle, and close the runs nobody will f…
guidovizoso Aug 27, 2026
4433e95
Drop the attention table on purpose, and index routines by owner
guidovizoso Aug 27, 2026
63e851a
Say '2 minutes ago' from one module, not two copies
guidovizoso Aug 27, 2026
7e0f43b
Ask a surviving server whether the worker can reach it before keeping it
guidovizoso Aug 27, 2026
4115d9b
Let the store's own types and caps speak for the routine tools
guidovizoso Aug 27, 2026
4f8c0a3
Merge remote-tracking branch 'origin/main' into guido/routines
guidovizoso Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,12 @@ COMPUTER_RUNTIME=
# may call tools back and it is told so rather than being quietly allowed.
AGENT_TOOL_TOKEN=

# The secret the routine worker presents when it hands a run back to this server. It authenticates
# the handoff of one routine run id, and nothing else: the server re-reads the routine, the Bot and
# everything else it needs from the database, so the worker cannot use this to inject an instruction.
#
# Left empty, the internal endpoint refuses every call and no routine ever fires — the correct state
# for a deployment that has not stood up a worker. Set for one that has: openssl rand -base64 32.
# Do not accept a default in production.
WORKER_SHARED_SECRET=

33 changes: 19 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,26 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A Bot in trouble no longer needs somebody watching
### A Bot can be asked to do something on a schedule

"Every weekday at nine, post the standup notes here" is now something a Bot can be asked rather than
something somebody has to remember. A routine created this way runs under its own creator's grants —
it can do exactly what they could do in chat, and nothing more — and its reply lands in the channel as
an ordinary Bot message: it lights the unread dot the same way any other message does, and it appears
in the transcript rather than anywhere separate. A routine that fails posts one message about its
first failure and, after ten in a row, switches itself off with a final one rather than failing
forever unnoticed.

The deployment gains two tables, via migration `0021`.

**This needs a new process.** A worker fires due routines by calling this deployment's own API server,
and a deployment that never starts one schedules nothing — the routine sits on the Routines page with
a next run time like any other, and nothing on the screen says a worker is missing. `WORKER_SHARED_SECRET`
is the credential the worker presents; a deployment without it configured refuses every handoff rather
than accepting one it cannot attribute. `scripts/start.sh` runs the worker locally; the Helm chart
turns it on with `routines.enabled` and takes the secret as `secrets.workerSharedSecret`. No new port
is opened for any of this — the worker only ever calls out to the server it already trusts.

A boundary refusal or a stalled run was recorded and then waited for a person to happen to look — at
the right channel, or at the audit page an administrator has and nobody else does. The trail knew;
nobody was told.

**Attention**, in the sidebar for everybody, shows the refusals and stalled runs nobody has handled
yet, scoped to the Bots this person may use, with a badge saying how many. Marking one handled
clears it for everyone and records who did; two people pressing Resolve at once is settled by the
database rather than by luck, and the second is told who got there first.

It is a view over the trail, not a second record of it. Refusals and stalls are already written
transactionally by the gateway and the stall guard, so the inbox cannot miss one and nothing new
runs on the action path. The only state it owns is the resolution, held beside the append-only trail
rather than in it. The trail itself still keeps everything; the inbox is only what is open now.
### A channel a Bot has spoken in unseen shows a dot

The sidebar marks a channel when a Bot has said something since you last had it open: a dot beside
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
- **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events.
- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port. The supervisor binds there too, because it holds the Docker socket and its token is a shared secret rather than a network boundary.
- **Durable threads and memory**: conversations survive restarts through CopilotKit Intelligence, and each deployment stamps the threads it owns.
- **Routines**: ask a Bot to do something on a schedule and it does, running as you, in the channel you asked in. A 15-minute floor and a cap of 20 enabled routines keep a sentence from scheduling more than a person meant, and ten failures in a row switch a routine off rather than burn model spend forever. Needs a worker process; see [docs/routines.md](docs/routines.md).

## Bring your own agent

Expand Down
62 changes: 13 additions & 49 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
IconBellRinging,
IconBolt,
IconBox,
IconClock,
IconLogout,
IconPlus,
IconSearch,
Expand Down Expand Up @@ -46,7 +46,6 @@ import {
SidebarRail,
} from "@/components/ui/sidebar";
import { signOutMutationOptions } from "@/lib/auth/mutations";
import { attentionListQueryOptions } from "@/lib/attention/queries";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import {
type ChannelSummary,
Expand All @@ -55,6 +54,7 @@ import {
import { useChannelEvents } from "@/lib/channels/use-channel-events";
import { appConfig } from "@/lib/generated/application-config";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
import { relativeTime } from "@/lib/relative-time";
import { Button } from "../ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
import { Channel } from "./channel";
Expand Down Expand Up @@ -206,9 +206,6 @@ function ChannelRow({

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: currentUser } = useQuery(currentUserQueryOptions());
// Unhandled attention items this person may see; drawn as a badge only when nonzero.
const attentionCount =
useQuery(attentionListQueryOptions()).data?.length ?? 0;
const queryClient = useQueryClient();
const navigate = useNavigate();
const signOut = useMutation(signOutMutationOptions(queryClient));
Expand Down Expand Up @@ -327,71 +324,62 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarFooter>
<SidebarMenu className="gap-px">
<SidebarMenuItem>
{/*
* Above Skills because it is the row that can be urgent. The count is the number of
* unhandled items this person may see; zero draws no badge, because an empty inbox
* asking for attention is the boy who cried wolf.
*/}
{/* Beside Agents rather than inside Admin: writing a skill is something anybody does. */}
<SidebarMenuButton
className="hover:bg-foreground/5 h-10"
render={(props) => (
<Link
{...props}
to="/attention"
to="/skills"
activeProps={{
className: "bg-foreground/5",
}}
/>
)}
>
<div className="size-[28px] flex items-center justify-center">
<IconBellRinging />
<IconBox />
</div>
<span className="text-sm trackint-tight">Attention</span>
{attentionCount > 0 ? (
<span className="ml-auto rounded-full bg-destructive px-1.5 text-destructive-foreground text-xs tabular-nums">
{attentionCount}
</span>
) : null}
<span className="text-sm trackint-tight">Skills</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
{/* Beside Agents rather than inside Admin: writing a skill is something anybody does. */}
<SidebarMenuButton
className="hover:bg-foreground/5 h-10"
render={(props) => (
<Link
{...props}
to="/skills"
to="/agents"
activeProps={{
className: "bg-foreground/5",
}}
/>
)}
>
<div className="size-[28px] flex items-center justify-center">
<IconBox />
<IconBolt />
</div>
<span className="text-sm trackint-tight">Skills</span>
<span className="text-sm trackint-tight">Agents</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
{/* Beside Skills and Agents rather than inside Admin: a routine is something anybody has. */}
<SidebarMenuButton
className="hover:bg-foreground/5 h-10"
render={(props) => (
<Link
{...props}
to="/agents"
to="/routines"
activeProps={{
className: "bg-foreground/5",
}}
/>
)}
>
<div className="size-[28px] flex items-center justify-center">
<IconBolt />
<IconClock />
</div>
<span className="text-sm trackint-tight">Agents</span>
<span className="text-sm trackint-tight">Routines</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
Expand Down Expand Up @@ -447,27 +435,3 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Sidebar>
);
}

const RELATIVE_UNITS = [
{ limit: 60_000, divisor: 1_000, unit: "second" },
{ limit: 3_600_000, divisor: 60_000, unit: "minute" },
{ limit: 86_400_000, divisor: 3_600_000, unit: "hour" },
{ limit: 604_800_000, divisor: 86_400_000, unit: "day" },
{ limit: Number.POSITIVE_INFINITY, divisor: 604_800_000, unit: "week" },
] as const;

const relativeFormat = new Intl.RelativeTimeFormat(undefined, {
numeric: "auto",
});

/** Locale-aware relative timestamp, e.g. "2 minutes ago". */
function relativeTime(iso: string) {
const elapsed = Date.now() - new Date(iso).getTime();
const scale =
RELATIVE_UNITS.find(({ limit }) => Math.abs(elapsed) < limit) ??
RELATIVE_UNITS[RELATIVE_UNITS.length - 1];
return relativeFormat.format(
-Math.round(elapsed / scale.divisor),
scale.unit,
);
}
Loading