Skip to content

Commit

Agents mode: an overview of every agent's work and spend, and each agent's sessions, memory, routines, spend, activity and versions

- Overview: the workspace's agent budget (owners set it), what waits on you, sessions working now, the roster with live counts, spend by agent, team, kind and who asked, upcoming routines, recently finished - A session's page: its transcript, tree, outputs, cost against its cap; stop it, message it, approve more - Memory by scope with sources; routines on a schedule or on events, suggested from responsibilities; version history on the profile - The owner who set the agent budget is notified at 75, 90 and 100% - Docs: sessions, memory, routines, budgets; only @g1t comes with every workspace, named agents are examples hired from templates

syntaqxcommitted Parente8c036dBrowse files
39 files+3567−2930/39 viewed
+8−1
5656 head: [
5757 { tag: 'link', attrs: { rel: 'icon', href: '/favicon.ico', sizes: '32x32' } },
5858 { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '192x192', href: '/icon-192.png' } },
59− { tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' } },
59+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '512x512', href: '/icon-512.png' } },
60+ { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' } },
61+ { tag: 'link', attrs: { rel: 'manifest', href: '/site.webmanifest' } },
62+ { tag: 'meta', attrs: { name: 'theme-color', content: '#0f0f11' } },
6063 ],
6164 sidebar: [
6265 {
7477 items: [
7578 { label: 'Chat', slug: 'guides/chat' },
7679 { label: 'Agents', slug: 'guides/agents' },
80+ { label: 'Sessions', slug: 'guides/agent-sessions' },
81+ { label: 'Agent memory', slug: 'guides/agent-memory' },
82+ { label: 'Routines', slug: 'guides/agent-routines' },
83+ { label: 'Agent budgets and spend', slug: 'guides/agent-budgets' },
7784 { label: 'What agents can do for whom', slug: 'guides/agent-access' },
7885 { label: 'Docs', slug: 'guides/docs', badge: { text: 'Soon', variant: 'default' } },
7986 { label: 'Agents in your chat app', slug: 'guides/chat-app', badge: { text: 'Soon', variant: 'default' } },
+2−1
117117 ## A support lead asks for a change
118118
119119 Dana leads support. She has read access to `acme/billing` and no write
120−access. She DMs Izzy, the workspace's Support Specialist:
120+access. Say the workspace has hired Izzy from the **Support Specialist**
121+template. Dana DMs her:
121122
122123 <Conversation title="DM with Izzy" topic="Dana has read access to acme/billing">
123124 <Message name="Dana Ruiz" time="14:05">
+83−0
1+---
2+title: Agent budgets and spend
3+description: One monthly budget for every agent together, a budget per agent, and a cap per session. Every reply, session, routine and helper rolls up cleanly to the workspace's bill, and the Agents page shows where the month went.
4+---
5+
6+Agents are for everyone in the company: engineers, support, sales. Their
7+spend has to stay easy to follow and impossible to run away. Every piece of
8+agent work, whoever asked and however many agents it involved, is charged
9+to exactly one agent's budget, and all of it rolls up to the workspace.
10+
11+## How limits stack
12+
13+Before every reply and every session step, g1t checks each level, from the
14+widest to the narrowest. The first one that is used up stops the work.
15+
16+| Level | Set by | What happens when it is reached |
17+| --- | --- | --- |
18+| **The workspace's spend limit and AI credit** | Owners and billing managers, under [Billing](/guides/usage-and-billing/#your-spend-limit). | A hard stop for everything, agents included. |
19+| **The agent budget** | Owners, under **Agents → Budget**. | Every agent's work together, each month. At 100% no agent takes new work until the 1st, or until an owner raises it. |
20+| **An agent's own budget** | Owners, on the agent's **Profile**. | Monthly, and optionally daily. At 100% that agent takes no new work. |
21+| **A session's cap** | The workspace's session cap, or the agent's lower per-session cap. | The session stops at **Needs approval** until an owner approves more. |
22+
23+Months and days are UTC. An idle agent costs nothing.
24+
25+## The agent budget
26+
27+Owners set it from the **Budget** button on the Agents page:
28+
29+| Setting | What it is |
30+| --- | --- |
31+| **Monthly budget for all agents** | Every agent's replies and sessions together. Empty: only the workspace's spend limit applies. |
32+| **Monthly budget for a new agent** | What a newly hired agent's monthly budget starts at. Change each agent's on its profile. |
33+| **Cap for a session** | What each session may spend before an owner approves more. $2 unless you change it; between $0.10 and $500. |
34+
35+The Agents page shows this month's spend against the budget. At 75% and
36+90% it shows a warning; at 100% it says agents have stopped taking new
37+work. The owner who last set the budget is also notified as each of
38+those is crossed, once a month each, in the app and by push if they
39+turned it on.
40+
41+## Who pays for what
42+
43+- **A reply** is paid by the agent that wrote it.
44+- **A session** is paid by the agent that runs it.
45+- **A session's children**, its subagents and the colleagues it brings
46+ in, are paid by the agent at the root of the tree, within the root's cap.
47+ Helping a colleague never spends the helper's own budget.
48+- **A routine's runs** are paid by the routine's agent.
49+
50+So a request that passes through three agents shows as one session tree,
51+one cap, and one agent's spend. See [sessions](/guides/agent-sessions/).
52+
53+## Where the month went
54+
55+The **Agents** page breaks down every agent's spend this month:
56+
57+| View | Shows |
58+| --- | --- |
59+| **By day** | Spend each day, UTC. |
60+| **By agent** | Each agent's share, largest first. |
61+| **By team** | Agents grouped by their team or department. |
62+| **By kind of work** | Chat replies, sessions, routines, helping colleagues and subagents. |
63+| **By who asked** | The work done for each person. Routines and agent-to-agent work have no asker. |
64+| **Costliest sessions** | This month's most expensive session trees. |
65+
66+Each agent's **Spend** tab shows the same for that agent, with **By
67+model**, against its own budget.
68+
69+Spend from sessions in conversations you aren't in still counts in every
70+total; you see what it cost, not what it was about.
71+
72+## On the bill
73+
74+Agent spend is billed as **Agent** usage on the workspace's
75+[Usage](/guides/usage-and-billing/) page and statement, at the same rates
76+as every other agent run. The Agents page's totals are the same money,
77+grouped by agent, team and asker instead of by product.
78+
79+## Next
80+
81+- [Sessions](/guides/agent-sessions/): caps and approving more spend.
82+- [Agents](/guides/agents/#budgets): an agent's own budget.
83+- [Usage and billing](/guides/usage-and-billing/): spend limits, AI credit and rates.
+76−0
1+---
2+title: Agent memory
3+description: What an agent remembers between conversations, where each fact came from, and where it may be recalled. Scopes keep private conversations private; people pin, correct and forget facts.
4+---
5+
6+An agent has **no hidden memory**. Apart from its own definition, it knows
7+only what it reads through a tool while it works, and the facts on its
8+**Memory** tab. Every fact says where it came from, and its **scope**
9+decides, in code, where it may be recalled and who may see it.
10+
11+## Scopes
12+
13+| Scope | Recalled | Who can see and change it |
14+| --- | --- | --- |
15+| **Workspace** | Anywhere in the workspace, for anyone. | Everyone sees it. Owners add, correct and forget it. |
16+| **Conversation** | Only in the channel it came from, and that channel's threads. | People in that channel. |
17+| **Just you** | Only in your direct messages with the agent. | Only you. |
18+
19+An agent keeps a workspace fact only when it learned it somewhere every
20+member can already read, such as a public channel, or when an owner gives it
21+one. A fact from a private channel stays in that channel; a fact from your
22+DM stays with you.
23+
24+Owners don't see other people's private facts. Owners control money and
25+agents, not other people's conversations.
26+
27+## Where facts come from
28+
29+| Source | For example |
30+| --- | --- |
31+| **A message** | Something said in `#support`: *The Enterprise plan includes SSO.* |
32+| **A session** | A session's report: *The nightly export runs at 02:00 UTC.* |
33+| **A person** | Someone told it, on its Memory tab or in chat: *Releases go out on Thursdays.* |
34+
35+Each fact shows who kept it (the agent, or the person who told it), its
36+source and when. When someone corrects a fact, its source becomes them,
37+marked *corrected*.
38+
39+## Add a fact
40+
41+1. Open the agent, then **Memory**.
42+2. Under **Add a fact**, write it in a sentence, up to 500 characters.
43+3. Choose who it is for: **Just me**, **This workspace** (owners only), or **A channel** you are in.
44+4. Choose **Remember**.
45+
46+Facts you add are pinned.
47+
48+## Pin, correct and forget
49+
50+Hover a fact to:
51+
52+- **Pin** it: pinned facts come first whenever the agent recalls facts for
53+ that place.
54+- **Edit** it: correct it in place. The agent uses the corrected fact from
55+ its next reply.
56+- **Forget** it: it is deleted and never recalled again. The agent can
57+ learn it again if it comes up.
58+
59+You can change any fact you can see, except workspace facts, which only
60+owners change.
61+
62+## Limits
63+
64+| | |
65+| --- | --- |
66+| One fact | Up to 500 characters, one paragraph. |
67+| Facts per agent | 2,000. Past that, it must forget before it remembers. |
68+| Facts per reply or session step | The 40 that apply to that place, pinned first. |
69+
70+Customer-data files are never remembered. See
71+[what agents can do for whom](/guides/agent-access/#what-an-agent-can-and-cant-know).
72+
73+## Next
74+
75+- [Sessions](/guides/agent-sessions/): the work that teaches agents most of what they know.
76+- [Agents](/guides/agents/): hiring agents from templates.
+98−0
1+---
2+title: Routines
3+description: Work an agent does without being asked, on a schedule or when something happens in the workspace. Each run is a session posted in a channel, paid from the agent's budget, with the access of the owner who set it up.
4+---
5+
6+A **routine** is work an agent does without being asked: a Monday digest of
7+support themes, a review of every pull request that's ready, a note when a
8+deploy fails. Each run is a [session](/guides/agent-sessions/), posted with
9+its live card in the routine's channel, paid from the agent's budget.
10+
11+## When a routine runs
12+
13+A routine runs **on a schedule**, **when something happens**, or both.
14+
15+### On a schedule
16+
17+Schedules are in UTC.
18+
19+| Every | Runs | For example |
20+| --- | --- | --- |
21+| **Hour** | At a minute past each hour. | *Every hour at :15* |
22+| **Day** | At a time each day. | *Every day at 09:00 UTC* |
23+| **Weekday** | At a time, Monday to Friday. | *Every weekday at 09:00 UTC* |
24+| **Week** | At a time on one day of the week. | *Every Monday at 17:30 UTC* |
25+
26+### When something happens
27+
28+| Event | When |
29+| --- | --- |
30+| **A pull request is ready for review** | Opened ready, or moved out of draft. |
31+| **A pull request is merged** | On any branch it targets. |
32+| **Checks fail on a pull request** | Its required checks failed or errored. |
33+| **An issue is opened** | By a person or an agent. |
34+| **A deploy fails** | A production or preview deploy. |
35+
36+Each run is one session about the one thing that happened. Name the
37+**repositories** to follow, such as `acme/web, acme/api` (up to 20), or
38+leave it empty for every repository the routine's sponsor can read.
39+
40+## Set up a routine
41+
42+Only owners set up routines, because a routine spends the agent's budget
43+without anyone asking.
44+
45+1. Open the agent, then **Routines**, then **New routine**.
46+2. Give it a **name** and **instructions**: what to do each run, and what
47+ to post.
48+3. Under **When**, turn on **On a schedule** and choose how often and at
49+ what time, choose events under **When something happens**, or both.
50+4. Choose the channel it **posts in**: one you and the agent are both in.
51+5. Choose **Add routine**.
52+
53+**Whoever saves a routine is its sponsor.** It runs with the sponsor's
54+access, never more: it reads only what they can read and files issues only
55+where they could.
56+
57+### Suggested routines
58+
59+An agent's responsibilities suggest routines it doesn't have yet. An agent
60+hired from the **QA Engineer** template, whose responsibilities include
61+*Reviewing pull requests for risk and test coverage*, suggests a routine
62+that runs when a pull request is ready for review. Choose **Add** on a
63+suggestion, pick its channel, and save.
64+
65+## Manage routines
66+
67+Each routine on the **Routines** tab shows when it runs, its channel, its
68+sponsor, its next run, a link to its last run's session and how many times
69+it has run. Owners can:
70+
71+- **Run now**: start a run at once, as a session.
72+- **Edit**: change anything. Saving makes you its sponsor.
73+- **Turn it off or on** with its switch.
74+- **Delete** it. The sessions it ran stay, with what they cost.
75+
76+An agent keeps at most 25 routines.
77+
78+### When g1t pauses a routine
79+
80+g1t turns a routine off, and says why on the tab, when it can no longer run
81+safely:
82+
83+- its sponsor left the workspace;
84+- its sponsor is no longer in its channel;
85+- its channel is gone, or the agent is no longer in it;
86+- its agent was archived.
87+
88+An owner who saves it again becomes its sponsor, and it runs again.
89+
90+## Costs
91+
92+A run costs what its session spends, against the agent's budget and the
93+session cap, like any other session. Spend shows as **Routines** under
94+**By kind of work** on the Agents page and the agent's **Spend** tab. See
95+[agent budgets and spend](/guides/agent-budgets/).
96+
97+The next scheduled runs across every agent show under **Upcoming routines**
98+on the Agents page.
+165−0
1+---
2+title: Sessions
3+description: When a request needs real work, an agent spins off a session for it, with its own goal, budget, transcript and live card in the conversation. Steer it, stop it, approve more spend, and see every session an agent is working on or has worked on.
4+---
5+
6+import { Steps } from '@astrojs/starlight/components';
7+import Conversation from '../../../components/Conversation.astro';
8+import Message from '../../../components/Message.astro';
9+import Aside from '../../../components/Aside.astro';
10+
11+**A conversation with an agent is not one long context window.** Talking to
12+an agent stays quick and cheap: a reply reads the conversation, what the
13+agent remembers, and its recent sessions there, and answers in a second or
14+two. When a request needs real work (looking through many threads, filing
15+issues, a digest, an investigation), the agent **spins off a session** for
16+it.
17+
18+A session is one bounded piece of work:
19+
20+| | |
21+| --- | --- |
22+| **A goal** | What it was asked to do, in a sentence. |
23+| **Its own context** | A working memory for this job only, kept compact as it grows, so long work doesn't slow down or bloat. |
24+| **A cap** | The most it may spend before someone approves more. |
25+| **A live card** | Posted where it was asked, updated in place as it works. Progress goes in the card's thread; the report comes back to the conversation. |
26+| **A transcript** | Every step, tool call, steer and result, on its own page. |
27+
28+## How a session starts
29+
30+Ask an agent for something that takes real work, in a channel or a DM:
31+
32+<Conversation title="# support" topic="Customer questions and escalations">
33+<Message name="Dana Ruiz" time="10:12">
34+
35+@izzy can you go through this week's export complaints and file issues for anything that looks like a real bug?
36+
37+</Message>
38+<Message name="Izzy" agent role="Support Specialist" time="10:12">
39+
40+On it. I've started a session for this: **Export complaints this week**. Follow along in its thread.
41+
42+</Message>
43+</Conversation>
44+
45+In this example the workspace hired *Izzy* from the **Support Specialist**
46+template; any agent works the same way. Izzy's card sits under her reply
47+with its status, steps and spend against its cap. A session also starts
48+from a [routine](/guides/agent-routines/), or when another session brings
49+the agent in.
50+
51+| Kind | Started by |
52+| --- | --- |
53+| **Chat** | Someone asking in a conversation. |
54+| **Routine** | A routine's schedule or event. |
55+| **Helper** | Another agent's session bringing this agent in, as a colleague. |
56+| **Subagent** | One of the agent's own [subagents](/guides/agents/#subagents), inside its session. |
57+
58+## Sessions start sessions
59+
60+A session can hand parts of its work to the agent's subagents, or bring in
61+a colleague: Izzy asks the QA agent to reproduce a bug before filing it.
62+Each is a **child session** whose result comes back to the session that
63+started it. The children make a **tree**, and the session page shows it.
64+
65+- A session runs at most **4 children at once**, and a tree is at most **3
66+ levels** deep.
67+- A child only ever sees what the conversation's audience may see, and acts
68+ with the asker's access, never more. See
69+ [what agents can do for whom](/guides/agent-access/).
70+- **Everything in a tree is paid by the agent at its root, within the root's
71+ cap.** A chain of sessions can never escape the budget that started it.
72+
73+## Statuses
74+
75+| Status | Means |
76+| --- | --- |
77+| **Queued** | Waiting for the agent to take its next step. An agent works on as many sessions at once as its capacity allows. |
78+| **Working** | Taking a step: thinking, reading through tools, writing. |
79+| **Waiting on helpers** | Waiting for the sessions it started to report back. |
80+| **Needs approval** | It reached its cap. It stays paused until an owner approves more. |
81+| **Done** | It reported back. Its report is on its page and in the conversation. |
82+| **Stopped** | Someone stopped it, or it was out of budget. |
83+| **Failed** | Something went wrong; its page says what. |
84+
85+A session takes up to eight steps before it reports. If it needs more, it
86+says what it found so far, and a reply in its thread picks it back up.
87+
88+## Steer a session
89+
90+Anyone who can see a session can talk to it while it works, or after:
91+
92+- **Reply in its card's thread** in the conversation, or
93+- **Message this session** at the bottom of its page.
94+
95+A working session reads your message before its next step. A finished one
96+picks the work back up, with everything it knew, and reports again.
97+
98+## Stop a session
99+
100+Choose **Stop** on its page. The session stops where it is, and so does
101+every session under it. What it spent stays spent; its card says it was
102+stopped. Anyone who can see a session can stop it.
103+
104+## Caps and approval
105+
106+Every session starts with a **cap**: the workspace's session cap (**$2** by
107+default; owners change it under **Agents → Budget**), or the agent's
108+per-session cap when that is lower.
109+
110+When a session reaches its cap, it doesn't overrun quietly. It stops at
111+**Needs approval**, its card says so, and it shows under **Waiting on you**
112+on the Agents page for the workspace's owners.
113+
114+<Steps>
115+
116+1. Open the session from **Agents → Waiting on you**, or from its card.
117+2. Choose **Approve more…**.
118+3. Enter a new cap above what it has spent, then **Approve and go on**.
119+
120+</Steps>
121+
122+It picks up where it stopped, and stops again at the new cap. Only owners
123+approve more spend, because an approval spends the workspace's money.
124+
125+## Every session an agent works on
126+
127+An agent's **Sessions** tab, at `g1t.sh/<workspace>/-/agents/<handle>`,
128+lists what it is working on now, then what it worked on before. Filter by
129+**Live** or **Done**. Each row shows the title, its kind, its status, who
130+asked, the conversation, its steps and tool calls, and its spend against
131+its cap; a child session sits under the session that started it.
132+
133+A session's own page shows:
134+
135+- who asked, where, the model, steps, tokens, and spend against the cap;
136+- **Open in chat**, which opens its card's thread;
137+- the transcript: the goal, what the agent said, each tool call with
138+ whether it read, was withheld from this audience, was refused or failed,
139+ steers, updates, children reporting back, and the report;
140+- the session tree, root first, with each session's agent and status;
141+- what it produced: issues it filed, sessions it started, facts it kept.
142+
143+The **Agents** page has every agent's live sessions under **Working now**,
144+and the latest finished ones.
145+
146+<Aside type="note" title="Private sessions">
147+A session belongs to the conversation it came from. If you're not in that
148+conversation, you see that it ran, its status and what it cost, never its
149+title, goal, transcript or report. Owners included: owners control money
150+and agents, not other people's conversations. Its spend still counts
151+everywhere.
152+</Aside>
153+
154+## Issues for whoever asked
155+
156+When a session finds work to do in code, it files issues in a repository
157+the asker can read, on their behalf, and lists them under **What it
158+produced**. Someone who can't change code still gets the issue filed;
159+changes come from people, and agents, who may make them.
160+
161+## Next
162+
163+- [Agent memory](/guides/agent-memory/): what sessions remember, and where.
164+- [Routines](/guides/agent-routines/): sessions on a schedule or when something happens.
165+- [Agent budgets and spend](/guides/agent-budgets/): how it all rolls up.
+4−3
55
66 Every project has an **Agents** section, beside its pull requests: who is
77 working on it right now, what each agent did before, and what agents have
8−learned about the project. The workspace has the same across all of its
9−projects: the **Agent fleet** and **Workspace memory**.
8+learned about the project. Across the workspace, **Agents** in the rail
9+shows every agent, its [sessions](/guides/agent-sessions/) and what it
10+costs, and **Memory** holds what agents know in every project.
1011
1112 | You want to | Go to |
1213 | --- | --- |
1617 | Teach every agent something about this code | **Agents → Memory** |
1718 | Teach every agent something true in every project | **Memory**, in the workspace's sidebar |
1819 | Review what agents, reviews and docs taught | **Agents → Memory**, or **Context → Memory** for the workspace |
19−| See every agent across the workspace, and its cost | **Agent fleet**, in the workspace's sidebar |
20+| See every agent across the workspace, and its cost | **Agents** in the rail: the overview, then each agent's **Spend** |
2021
2122 ## Runs
2223
+61−44
1010 import Aside from '../../../components/Aside.astro';
1111 import Card from '../../../components/Card.astro';
1212
13−**An agent is a colleague you hire into a role.** Margo works in QA, Izzy
14−in Customer Support, David in Sales Operations. Each has a name, a handle
13+**An agent is a colleague you hire into a role.** Every workspace starts
14+with one agent, `@g1t`, the orchestrator. Every other agent is one you
15+hire, usually from a template in a click: for example, Margo from the **QA
16+Engineer** template, Izzy from **Support Specialist**, David from **Sales
17+Operations**. Each has a name, a handle
1518 you mention it by, a title, a team, a list of things it answers for, and a
1619 voice of its own. It sits in the member list next to the people. You DM it,
1720 invite it to channels, and it answers where you asked. A budget caps what it
1821 spends, and g1t picks its model for each step, within limits you set.
1922
2023 A workspace can have as many agents as it likes, and an agent costs nothing
21−while nobody talks to it. A workspace's org chart can read like a real
22−company's:
24+while nobody talks to it. Hire one from each department's template and a
25+workspace's org chart can read like a real company's:
2326
24−| Department | Colleague | Title |
27+| Department | Hired from the template | As |
2528 | --- | --- | --- |
2629 | Engineering | Otto | Software Engineer |
2730 | QA | Margo | QA Engineer |
3134 | Sales | David | Sales Operations |
3235 | Operations | Bruno | Operations Engineer |
3336
34−Above them all is `@g1t`, who knows everyone.
37+None of these come with a workspace: they are what you might hire. Above
38+them all is `@g1t`, who comes with every workspace and knows everyone you
39+hire.
3540
3641 ## g1t, the orchestrator
3742
7984 The agent appears on the Agents page as **Idle**. Open a DM with it from
8085 Chat's **New message**, or invite it to the channels its team works in.
8186
82−### Example: hire Margo into QA
87+### Example: hire Margo from the QA template
8388
8489 <Steps>
8590
8994 2. Put her on your **QA** team.
9095 3. Add one responsibility: *Check every release against the release
9196 checklist in #releases.*
92−4. Set a monthly budget of **$40** and a per-task cap of **$5**.
97+4. Set a monthly budget of **$40** and a per-session cap of **$5**.
9398 5. Choose **Create agent**, then invite `@margo` to `#web` and `#releases`.
9499
95100 </Steps>
137142 | Personality | A preset, plus free text that refines the voice. | Free text up to 1,000 characters. |
138143 | Subagents | Help it keeps for its own work. See [subagents](#subagents). | Run soon. |
139144 | Models | A floor, a ceiling and the providers it may use. | See [model routing](#model-routing). |
140−| Budget | A monthly cap, a daily cap and a cap per task. | Each optional, up to $100,000. |
145+| Budget | A monthly cap, a daily cap and a cap per session. | Each optional, up to $100,000. |
141146 | What it may do alone | Pull requests, merging, production deploys, doc edits. | See [what it may do alone](#what-it-may-do-alone). |
142−| Capacity | How many tasks it works on at once. | 1 to 10. Default 3. |
147+| Capacity | How many sessions it works on at once. | 1 to 10. Default 3. |
143148
144149 Every change to an agent is saved as a new **version**, so what it ran
145150 with is never lost.
163168 | Changes | What it pays attention to, what it asks, when it stops | Tone, length, formality, how it asks questions |
164169 | Never changes | | What it may read, change or spend |
165170
166−The same question, put to Bruno, the Operations Engineer, with two
167−different personalities:
171+Say you hired Bruno from the **Operations Engineer** template. The same
172+question, put to him with two different personalities:
168173
169174 <Conversation title="DM with Bruno" topic="Personality: Crisp">
170175 <Message name="Priya Shah" time="09:15">
205210 Spanish.* or *Uses British spelling.*
206211
207212 ## Subagents
208−
209−<Soon />
210213
211214 Subagents are the specialised help an agent keeps for its own work, the
212−way a person keeps tools for parts of a job. Margo, in QA, comes with two:
215+way a person keeps tools for parts of a job. An agent hired from the **QA
216+Engineer** template comes with two:
213217
214218 | Subagent | What it does |
215219 | --- | --- |
216220 | `flake-hunter` | Runs a flaky test repeatedly, narrows down when and why it fails, and reports the cause with evidence. |
217221 | `migration-checker` | Checks a database migration for locking, data loss, irreversible steps and missing indexes. |
218222
219−You can see, add and change an agent's subagents on its profile today.
220−Running them comes with tasks and sessions. When they run, these rules
221−hold:
223+See, add and change an agent's subagents on its profile. They run inside
224+the agent's [sessions](/guides/agent-sessions/), each as a child session,
225+and these rules hold:
222226
223227 - **Defined on the agent.** Each has a name such as `flake-hunter`, one
224228 line on what it is for, its own instructions, and its own model limits,
226230 - **Not members.** They never appear in Chat or member lists, and never
227231 talk to people. They report to their agent, which speaks for them.
228232 - **Never wider than their agent.** Their access, budget and audience are
229− the agent's or narrower. What they spend counts against the agent's
230− budget and the task.
231−- **Many at once.** Up to 8 of one subagent can run in parallel inside a
232− task, and the task card shows them as sub-steps.
233+ the agent's or narrower. What they spend is paid by the session that
234+ started them, within its cap.
235+- **Several at once.** A session runs up to 4 children at once, and never
236+ more of one subagent than its own limit (1 to 8). The session page shows
237+ each in its tree.
233238
234239 ## Back office and front office
235240
236241 Every agent is **back office**: it works with your team and never talks to
237242 anyone outside the company.
238243
239−David, in Sales Operations, shows how much a back-office agent can do
240−without ever contacting a customer. He reads the customer conversations
244+An agent hired from the **Sales Operations** template, David by default,
245+shows how much a back-office agent can do without ever contacting a
246+customer. He reads the customer conversations
241247 the workspace already has, and:
242248
243249 - summarizes what's happening per account and across them: who is at
271277 | **Hand off** | It offers to bring the right colleague in. On yes, it mentions them with a short brief and they take the thread. Hand-offs are offered, never silent. | *"That's Margo's area. Want me to bring her in?"* |
272278 | **Steer** | When you are about to do something another role owns, it says so and names who to check with. | *"We're in the release freeze. Check with Bruno before merging."* |
273279
280+In a workspace that has hired Izzy from **Support Specialist** and Margo
281+from **QA Engineer**:
282+
274283 <Conversation title="# web" topic="A consult, as it will show">
275284 <Message name="Dana Ruiz" time="11:02">
276285
368377
369378 ## Budgets
370379
371−An agent's budget is checked before every reply and every piece of work. A
380+An agent's budget is checked before every reply and every session step. A
372381 cap left empty means no cap of its own, and only the workspace's limits
373−apply.
382+apply. For how the workspace's agent budget, each agent's budget and each
383+session's cap fit together, see
384+[agent budgets and spend](/guides/agent-budgets/).
374385
375386 | Cap | Resets | What happens when it is reached |
376387 | --- | --- | --- |
377388 | **Monthly** | On the 1st, UTC | The agent stops taking new work and says so in the thread: *I'm out of budget for October. An owner can raise my monthly limit on my profile.* |
378389 | **Daily** | At midnight UTC | The agent says it has used today's budget and will be back tomorrow. |
379−| **Per task** | Each task | One reply or task can't spend more than this. |
390+| **Per session** | Each session | A session starts with this cap when it is lower than the workspace's session cap. At the cap it waits for an owner to [approve more](/guides/agent-sessions/#caps-and-approval). |
380391
381392 Above the agent's own caps, the workspace's limits still hold:
382393
383394 1. The workspace's [spend limit](/guides/usage-and-billing/#your-spend-limit)
384395 and its [AI credit](/guides/usage-and-billing/#ai-credit).
385−2. The agent's monthly and daily caps.
386−3. The agent's per-task cap.
387−4. The plan's [per-run caps](/guides/usage-and-billing/#caps).
396+2. The workspace's [agent budget](/guides/agent-budgets/#the-agent-budget),
397+ across every agent.
398+3. The agent's monthly and daily caps.
399+4. The session's cap.
400+5. The plan's [per-run caps](/guides/usage-and-billing/#caps).
388401
389−The lowest of these is what one reply may spend. The agent's page shows
402+The first one used up stops the work. The agent's page shows
390403 its spend this month against its monthly cap, and its status turns to
391404 **Out of budget** when a cap stops it.
392405
405418
406419 ### A worked example
407420
408−Margo answers *what should we test before Thursday?* in a thread of a dozen
421+Say you hired Margo from the **QA Engineer** template. She answers *what
422+should we test before Thursday?* in a thread of a dozen
409423 messages. The reply reads about 3,000 tokens and writes about 300, on the
410424 fast tier. Say the fast model costs $1 per million input tokens and $5 per
411425 million output tokens, and the agent rate is $0.25 per million tokens
444458
445459 | Tab | What it shows |
446460 | --- | --- |
447−| **Desk** | What it is working on now, and what it is waiting on. |
448−| **Profile** | Its definition: identity, job, personality, models, budget and what it may do alone. Owners edit it here. |
449−| **Spend** | Spend this month against its budget. |
450−| **Activity** | What it did, from the audit log. |
461+| **Sessions** | Every session it is working on, then every one it worked on before, each child under the session that started it. See [sessions](/guides/agent-sessions/). |
462+| **Memory** | What it remembers, by scope, with where each fact came from. Pin, correct or forget facts. See [agent memory](/guides/agent-memory/). |
463+| **Routines** | Work it does on a schedule or when something happens. See [routines](/guides/agent-routines/). |
464+| **Spend** | Spend this month against its budget: by day, kind of work, model and who asked, and its costliest sessions. |
465+| **Activity** | Its latest replies and sessions, with who asked, the model and the cost. |
466+| **Profile** | Its definition: identity, job, personality, models, budget and what it may do alone, with every saved version. Owners edit it here. |
467+
468+The **Agents** page above them all shows the agent budget, sessions waiting
469+on you and working now, every agent with its live sessions and its month's
470+spend, where the month went, upcoming routines and recently finished
471+sessions.
451472
452473 Its status is one of **Idle**, **Working**, **Waiting on you**, **Out of
453474 budget** or **Paused**, on the Agents page and beside its name in Chat.
469490 team's channels and mentions, can be asked to review through the team,
470491 and shows on the team's page. <Soon />
471492 </Card>
472− <Card title="Work from a message" icon="square-terminal">
473− Ask for a change in chat and the agent opens a task with a live card in
474− the thread: steps, files touched, cost so far. Sessions pause when idle
475− and resume with full context. <Soon />
476− </Card>
477493 <Card title="Create one in chat" icon="wand-sparkles">
478494 Describe the agent you want in a message, and confirm the draft card g1t
479495 answers with. Or commit `.g1t/agents/<handle>.md`. <Soon />
483499 and agree in a visible thread when their work would overlap.
484500 <Soon />
485501 </Card>
486− <Card title="Skills and triggers" icon="calendar-clock">
487− Saved procedures an agent repeats, and schedules, events and webhooks
488− that wake it without a mention. <Soon />
502+ <Card title="Skills and webhooks" icon="calendar-clock">
503+ Saved procedures an agent repeats, and webhooks that wake it. Schedules
504+ and workspace events already run as [routines](/guides/agent-routines/).
505+ <Soon />
489506 </Card>
490507 </CardGrid>
491508
+3−2
2525
2626 ## Each agent speaks as itself
2727
28−Messages are posted with the agent's own name and avatar, so `@margo` looks
29−like Margo in both places.
28+Messages are posted with the agent's own name and avatar. If you hired
29+Margo from the **QA Engineer** template, `@margo` looks like Margo in both
30+places.
3031
3132 | To reach an agent | Write |
3233 | --- | --- |
+5−3
2727
2828 ## A conversation
2929
30−Here is a morning in `#web`, a channel with three people and two agents
31−in it: Bruno (`@bruno`, Operations Engineer) and Margo (`@margo`, QA Engineer):
30+Say your workspace has hired two agents from templates: Bruno (`@bruno`)
31+from **Operations Engineer** and Margo (`@margo`) from **QA Engineer**. Here
32+is a morning in `#web`, a channel with three people and those two agents in
33+it:
3234
3335 <Conversation title="# web" topic="The web app, its releases and its bugs">
3436 <Message name="Priya Shah" time="10:42">
254256 a skin tone, and a **This workspace** tab for your
255257 [custom emoji](#custom-emoji). Arrow keys move through it and Enter picks.
256258
257−Hover a reaction's pill to see who reacted, such as *Priya, Otto and 3
259+Hover a reaction's pill to see who reacted, such as *Priya, Dana and 3
258260 more*. Your own reactions are highlighted.
259261
260262 | Limit | |
+3−2
4141 | **Pages know what they describe** | A page can cite code: paths, symbols, endpoints, settings. When a merged pull request changes something a page cites, the page is marked possibly stale and its owners are told. If an agent owns the page, it drafts the update. |
4242 | **Conversations become pages** | "Write this up" in a thread makes a page from it, linked both ways. Decisions made in chat get a home. |
4343
44−Inky, the **Technical Writer** [role](/guides/agents/#role-templates), is
45−already available. Today Inky works in chat; with Docs, Inky will keep a space current:
44+The **Technical Writer** [template](/guides/agents/#role-templates) is
45+already available: hire Inky from it in a click. Today Inky works in chat;
46+with Docs, Inky will keep a space current:
4647 updating pages after merges, writing release notes and the weekly summary,
4748 and turning incident threads into postmortems.
4849
+1−1
541541
542542 1. **Overview**, the [workspace's page](#the-workspaces-page).
543543 2. Its [projects](#pinned-and-recent-projects), ending with **All projects**.
544−3. The places work happens across them: **Agent fleet**, **Context**,
544+3. The places work happens across them: **Agents**, **Context**,
545545 **Memory**, **Security** and [**Packages**](/guides/packages/), with
546546 **Insights**, **Boards** and **Roadmap** soon.
547547 4. **People**, [**Teams**](/guides/teams/), **Usage**, what g1t's runs have
+4−3
1212
1313 **g1t is one workspace where your team and its agents work together.** You
1414 talk in channels and direct messages, and agents are members there like
15−anyone else. You hire them into roles, like colleagues: Margo in QA, Izzy
16−in Customer Support, each with a title, responsibilities, a voice and a
17−budget, and `@g1t` as the orchestrator who knows them all. You DM them or
15+anyone else. Every workspace comes with `@g1t`, the orchestrator who knows
16+them all. The rest you hire into roles from templates, like colleagues: for
17+example, Margo from **QA Engineer** or Izzy from **Support Specialist**,
18+each with a title, responsibilities, a voice and a budget. You DM them or
1819 mention them in a thread. Assign an issue to g1t, and the code it
1920 changes lands through pull requests, checks and review. No separate chat
2021 app, wiki or forge to stitch together.
+4−3
4747
4848 4. **Create an agent**
4949
50− Open **Agents** in the rail and choose **New agent**. Hire into a role,
51− such as **QA**, and keep its defaults: Margo comes with a title,
52− responsibilities, a personality and sensible model limits. Choose
50+ Open **Agents** in the rail and choose **New agent**. The templates are
51+ grouped by department. Pick one, such as **QA Engineer**, and keep its
52+ defaults: you hire Margo, with a title, responsibilities, a personality
53+ and sensible model limits. Choose
5354 **Another name** if you'd like someone else. Set a monthly budget, such as
5455 $10, so you know the most she can spend. See
5556 [hire an agent](/guides/agents/#hire-an-agent). `@g1t`, the
+148−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { AgentMemory } from "@g1t/contracts";
5+
6+import {
7+ isLive,
8+ kindLabel,
9+ memoryGroups,
10+ meterTone,
11+ monthDays,
12+ reposFrom,
13+ routineInWords,
14+ scheduleFrom,
15+ scheduleInWords,
16+ sessionRows,
17+ sessionStatus,
18+ shareOf,
19+ timeField,
20+ topSlices,
21+ untilLabel,
22+ versionChanges,
23+ whereLabel,
24+} from "./agents/format.ts";
25+
26+test("a session's status reads as its card in chat does", () => {
27+ assert.deepEqual(sessionStatus("working"), { label: "Working", tone: "accent", moving: true });
28+ assert.deepEqual(sessionStatus("waiting"), { label: "Waiting on helpers", tone: "info", moving: true });
29+ assert.equal(sessionStatus("needs_approval").label, "Needs approval");
30+ assert.equal(sessionStatus("failed").tone, "danger");
31+ assert.equal(sessionStatus("something new").label, "something new");
32+ assert.ok(isLive("needs_approval"));
33+ assert.ok(!isLive("done"));
34+});
35+
36+test("kinds and places read in words", () => {
37+ assert.equal(kindLabel("helper"), "Helper");
38+ assert.equal(kindLabel("subagent"), "Subagent");
39+ assert.equal(whereLabel({ channel_kind: "channel", channel_name: "releases" }), "#releases");
40+ assert.equal(whereLabel({ channel_kind: "dm", channel_name: null }), "A direct message");
41+});
42+
43+test("spend against a cap warns at 75% and stops at 100%", () => {
44+ assert.equal(shareOf(5, null), null);
45+ assert.equal(shareOf(5, 0), null);
46+ assert.equal(shareOf(50, 100), 0.5);
47+ assert.equal(meterTone(null), "accent");
48+ assert.equal(meterTone(0.5), "accent");
49+ assert.equal(meterTone(0.75), "warn");
50+ assert.equal(meterTone(1.2), "danger");
51+});
52+
53+test("a tree of sessions puts each child under its parent, oldest first", () => {
54+ const s = (id: string, parent: string | null, at: string) => ({ id, parent_id: parent, created_at: at });
55+ const rows = sessionRows([s("root", null, "1"), s("b", "root", "3"), s("a", "root", "2"), s("a1", "a", "4"), s("orphan", "gone", "5")]);
56+ assert.deepEqual(
57+ rows.map((r) => `${r.session.id}:${r.depth}`),
58+ ["root:0", "a:1", "a1:2", "b:1", "orphan:0"],
59+ );
60+ // A cycle still shows each once.
61+ const cycle = sessionRows([s("x", "y", "1"), s("y", "x", "2")]);
62+ assert.equal(cycle.length, 2);
63+});
64+
65+test("a schedule reads as a sentence", () => {
66+ assert.equal(scheduleInWords({ every: "weekday", hour: 9, minute: 0, weekday: 1 }), "Every weekday at 09:00 UTC");
67+ assert.equal(scheduleInWords({ every: "week", hour: 17, minute: 30, weekday: 1 }), "Every Monday at 17:30 UTC");
68+ assert.equal(scheduleInWords({ every: "hour", hour: 0, minute: 15, weekday: 0 }), "Every hour at :15");
69+ assert.equal(scheduleInWords({ every: "hour", hour: 0, minute: 0, weekday: 0 }), "Every hour, on the hour");
70+ assert.equal(scheduleInWords({ every: "day", hour: 7, minute: 5, weekday: 0 }), "Every day at 07:05 UTC");
71+});
72+
73+test("a routine reads as its schedule, its events and its repositories", () => {
74+ const labels = { pull_ready: "A pull request is ready for review", pull_merged: "A pull request is merged" };
75+ assert.equal(
76+ routineInWords({ schedule: null, events: ["pull_ready"], repos: ["acme/web"] }, labels),
77+ "When a pull request is ready for review · acme/web",
78+ );
79+ assert.equal(
80+ routineInWords({ schedule: { every: "day", hour: 9, minute: 0, weekday: 0 }, events: ["pull_ready", "pull_merged"], repos: [] }, labels),
81+ "Every day at 09:00 UTC · When a pull request is ready for review, or a pull request is merged · Any repository",
82+ );
83+ assert.equal(routineInWords({ schedule: null, events: [], repos: [] }, labels), "Never: it has no schedule or events");
84+});
85+
86+test("the routine dialog's fields become a schedule and back", () => {
87+ assert.deepEqual(scheduleFrom("week", "17:30", "5"), { every: "week", hour: 17, minute: 30, weekday: 5 });
88+ assert.deepEqual(scheduleFrom("day", "", ""), { every: "day", hour: 9, minute: 0, weekday: 1 });
89+ assert.equal(scheduleFrom("never", "09:00", "1"), null);
90+ assert.equal(scheduleFrom("day", "25:00", "1"), null);
91+ assert.equal(timeField({ hour: 7, minute: 5 }), "07:05");
92+});
93+
94+test("repositories are typed as a list", () => {
95+ assert.deepEqual(reposFrom("Acme/Web, acme/api acme/web"), ["acme/web", "acme/api"]);
96+ assert.deepEqual(reposFrom(" "), []);
97+ assert.equal(reposFrom("web"), null);
98+});
99+
100+test("memories group by scope, pinned first", () => {
101+ const m = (id: string, scope: AgentMemory["scope"], pinned: boolean, at: string) =>
102+ ({ id, scope, pinned, updated_at: at }) as AgentMemory;
103+ const groups = memoryGroups([m("a", "person", false, "2"), m("b", "workspace", false, "3"), m("c", "workspace", true, "1"), m("d", "person", false, "4")]);
104+ assert.deepEqual(
105+ groups.map((g) => [g.scope, g.memories.map((x) => x.id)]),
106+ [
107+ ["workspace", ["c", "b"]],
108+ ["person", ["d", "a"]],
109+ ],
110+ );
111+});
112+
113+test("a month's days run to today, with spend or 0", () => {
114+ const now = new Date("2026-10-04T12:00:00Z");
115+ assert.deepEqual(monthDays("2026-10", [{ day: "2026-10-02", micros: 5 }], now), [
116+ { day: "2026-10-01", micros: 0 },
117+ { day: "2026-10-02", micros: 5 },
118+ { day: "2026-10-03", micros: 0 },
119+ { day: "2026-10-04", micros: 0 },
120+ ]);
121+ assert.equal(monthDays("2026-09", [], now).length, 30);
122+});
123+
124+test("past five, the rest fold into one", () => {
125+ const slices = Array.from({ length: 8 }, (_, i) => ({ key: `k${i}`, label: `L${i}`, micros: 10 - i, count: 1 }));
126+ const top = topSlices(slices, 5);
127+ assert.equal(top.length, 6);
128+ assert.deepEqual(top[5], { key: "__rest", label: "3 more", micros: 5 + 4 + 3, count: 3 });
129+ assert.equal(topSlices(slices.slice(0, 6), 5).length, 6);
130+});
131+
132+test("until reads short", () => {
133+ const now = Date.parse("2026-10-04T12:00:00Z");
134+ assert.equal(untilLabel("2026-10-04T12:00:30Z", now), "now");
135+ assert.equal(untilLabel("2026-10-04T12:20:00Z", now), "in 20m");
136+ assert.equal(untilLabel("2026-10-04T15:00:00Z", now), "in 3h");
137+ assert.equal(untilLabel("2026-10-06T12:00:00Z", now), "in 2d");
138+});
139+
140+test("a version says what changed from the one before", () => {
141+ assert.equal(versionChanges(null, { instructions: "a" }), "Created");
142+ assert.equal(versionChanges({ instructions: "a", budget: { monthly_micros: 1 } }, { instructions: "b", budget: { monthly_micros: 1 } }), "Changed job");
143+ assert.equal(
144+ versionChanges({ personality_preset: "crisp", personality: "", routing: {} }, { personality_preset: "terse", personality: "x", routing: { floor: "fast" } }),
145+ "Changed personality, models",
146+ );
147+ assert.equal(versionChanges({ capacity: 3 }, { capacity: 3 }), "Saved with no changes");
148+});
+14−4
8383 }) {
8484 const data = useAgentsData();
8585 const agents: Listed[] | null = data?.agents ?? shellAgents;
86+ const live = data?.live ?? {};
8687 const orchestrator = agents?.find((agent) => isOrchestrator(agent)) ?? null;
8788 const specialists = (agents ?? []).filter((agent) => !isOrchestrator(agent));
8889 const row = (agent: Listed) => (
106107 {isOrchestrator(agent) ? "Orchestrator" : agent.status === "idle" ? agent.title || agent.role : `${statusLabel(agent.status)} · ${agent.title || agent.role}`}
107108 </span>
108109 </span>
110+ {(live[agent.id] ?? 0) > 0 && (
111+ <Hint label={`${live[agent.id]} live ${live[agent.id] === 1 ? "session" : "sessions"}`}>
112+ <span className="flex h-5 min-w-5 shrink-0 items-center justify-center gap-1 rounded-full bg-accent/15 px-1.5 text-[0.6875rem] font-medium text-accent tabular-nums">
113+ <span className="size-1.5 animate-pulse rounded-full bg-accent motion-reduce:animate-none" aria-hidden="true" />
114+ {live[agent.id]}
115+ <span className="sr-only"> live sessions</span>
116+ </span>
117+ </Hint>
118+ )}
109119 </NavLink>
110120 </li>
111121 );
125135 <div className="space-y-px">
126136 {!phone && (
127137 <SideLink to={`/${slug}/-/agents`} end icon={<Activity size={15} className="text-faint" />}>
128− Agent fleet
138+ Overview
129139 </SideLink>
130140 )}
131141 {code && (
679689 <div className="grid gap-5 sm:grid-cols-3">
680690 <Money name="monthly" label="Monthly" hint="Per calendar month" value={draft.budget.monthly_micros} error={e.monthly} />
681691 <Money name="daily" label="Daily" hint="Optional" value={draft.budget.daily_micros} error={e.daily} />
682− <Money name="task" label="Per task" hint="Going over asks" value={draft.budget.task_micros} error={e.task} />
692+ <Money name="task" label="Per session" hint="Going over asks an owner" value={draft.budget.task_micros} error={e.task} />
683693 </div>
684694 </FormSection>
685695
705715 </div>
706716 </FormSection>
707717
708− <FormSection title="Capacity" about="How many tasks it works at once. More wait on its desk, each showing its place in line.">
718+ <FormSection title="Capacity" about="How many sessions it works on at once. More wait in line, queued.">
709719 <div className="max-w-40">
710720 <Label htmlFor="capacity" error={e.capacity}>
711− Tasks at once
721+ Sessions at once
712722 </Label>
713723 <input id="capacity" name="capacity" type="number" min={1} max={20} defaultValue={draft.capacity} className={`${FIELD} tabular-nums`} />
714724 </div>
+34−0
1+import { data } from "react-router";
2+
3+import type { Result, User } from "@g1t/contracts";
4+
5+import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server";
6+import type { ActionResult } from "./dialogs";
7+
8+/** What an Agents action knows first: who is asking, in which workspace, whether they own it, and the form. */
9+export async function agentsAction(
10+ request: Request,
11+ context: Parameters<typeof requireUser>[0],
12+ owner: string,
13+): Promise<{ viewer: User; slug: string; isOwner: boolean; form: FormData }> {
14+ assertSameOrigin(request);
15+ const viewer = requireUser(context, request);
16+ const slug = owner.toLowerCase();
17+ const role = roleIn(viewer, slug);
18+ if (!role) throw data(null, { status: 404 });
19+ return { viewer, slug, isOwner: role === "owner", form: await request.formData() };
20+}
21+
22+/** An agents-service call as an action's answer: worked, or why not. */
23+export async function answer<T>(intent: string, call: Promise<Result<T>>): Promise<ActionResult> {
24+ const result = await call.catch(() => null);
25+ if (!result) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
26+ if (!result.ok) return { ok: false, intent, error: result.error.message };
27+ return { ok: true, intent };
28+}
29+
30+/** A loader's agents-service read: its value, or null when it failed (the page says so). */
31+export async function readOrNull<T>(call: Promise<Result<T>>): Promise<T | null> {
32+ const result = await call.catch(() => null);
33+ return result?.ok ? result.value : null;
34+}
+198−0
1+import { type ReactNode, useEffect, useId, useState } from "react";
2+import { useFetcher } from "react-router";
3+
4+import type { AgentPolicy, AgentSession } from "@g1t/contracts";
5+
6+import {
7+ AlertDialog,
8+ AlertDialogCancel,
9+ AlertDialogContent,
10+ AlertDialogDescription,
11+ AlertDialogFooter,
12+ AlertDialogHeader,
13+ AlertDialogTitle,
14+ AlertDialogTrigger,
15+} from "../ui/alert-dialog";
16+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
17+import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
18+import { Input, InputAddon, InputGroup } from "../ui/input";
19+import { dollarsField } from "../../lib/agent-form";
20+import { money } from "../../lib/usage";
21+import { PrivateTitle, SpendOfCap } from "./parts";
22+
23+/** What Agents mode's actions answer. */
24+export type ActionResult = { ok: true; intent?: string } | { ok: false; error: string; field?: string; intent?: string };
25+
26+/** A fetcher whose dialog closes once what it sent has worked. */
27+export function useDialogFetcher(key?: string) {
28+ const fetcher = useFetcher<ActionResult>(key ? { key } : undefined);
29+ const [open, setOpen] = useState(false);
30+ useEffect(() => {
31+ if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false);
32+ }, [fetcher.state, fetcher.data]);
33+ const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
34+ return { fetcher, open, setOpen, error, busy: fetcher.state !== "idle" };
35+}
36+
37+const PRIMARY = "inline-flex items-center justify-center gap-2 rounded-md bg-accent px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-50";
38+const QUIET = "inline-flex items-center justify-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg";
39+
40+/** A dollars field: `$` before it, posted as dollars. */
41+export function DollarsInput({ id, name, defaultValue, placeholder, required }: { id: string; name: string; defaultValue: string; placeholder?: string; required?: boolean }) {
42+ return (
43+ <InputGroup>
44+ <InputAddon>$</InputAddon>
45+ <Input id={id} name={name} inputMode="decimal" defaultValue={defaultValue} placeholder={placeholder} required={required} autoComplete="off" />
46+ </InputGroup>
47+ );
48+}
49+
50+/**
51+ * The workspace's agent budget, for owners: one monthly budget across every
52+ * agent, the budget a new agent starts with, and the cap a session starts with.
53+ */
54+export function BudgetDialog({ policy, action, trigger }: { policy: AgentPolicy; action: string; trigger: ReactNode }) {
55+ const { fetcher, open, setOpen, error, busy } = useDialogFetcher("agents-budget");
56+ const id = useId();
57+ return (
58+ <Dialog open={open} onOpenChange={setOpen}>
59+ <DialogTrigger asChild>{trigger}</DialogTrigger>
60+ <DialogContent>
61+ <DialogHeader>
62+ <DialogTitle>Agent budget</DialogTitle>
63+ <DialogDescription>
64+ What every agent may spend together each month, and what new agents and sessions start with. The workspace&apos;s spend limit still sits above all of it.
65+ </DialogDescription>
66+ </DialogHeader>
67+ <fetcher.Form method="post" action={action} className="grid gap-5">
68+ <input type="hidden" name="intent" value="policy" />
69+ <Field>
70+ <FieldLabel htmlFor={`${id}-monthly`}>Monthly budget for all agents</FieldLabel>
71+ <DollarsInput id={`${id}-monthly`} name="monthly" defaultValue={dollarsField(policy.monthly_micros)} placeholder="No budget of its own" />
72+ <FieldDescription>Every agent&apos;s replies and sessions together, reset on the 1st (UTC). The Agents page warns at 75% and 90%; at 100% agents take no new work. Empty: only the workspace&apos;s spend limit.</FieldDescription>
73+ </Field>
74+ <Field>
75+ <FieldLabel htmlFor={`${id}-agent`}>Monthly budget for a new agent</FieldLabel>
76+ <DollarsInput id={`${id}-agent`} name="default_agent" defaultValue={dollarsField(policy.default_agent_monthly_micros)} placeholder="None" />
77+ <FieldDescription>Filled in when an agent is hired; change each one on its profile.</FieldDescription>
78+ </Field>
79+ <Field>
80+ <FieldLabel htmlFor={`${id}-session`}>Cap for a session</FieldLabel>
81+ <DollarsInput id={`${id}-session`} name="default_session" defaultValue={dollarsField(policy.default_session_micros)} required />
82+ <FieldDescription>A session stops here and asks an owner before spending more, unless its agent&apos;s per-task cap is lower.</FieldDescription>
83+ </Field>
84+ <FieldError>{error}</FieldError>
85+ <DialogFooter>
86+ <button type="button" className={QUIET} onClick={() => setOpen(false)}>
87+ Cancel
88+ </button>
89+ <button type="submit" className={PRIMARY} disabled={busy}>
90+ {busy ? "Saving…" : "Save budget"}
91+ </button>
92+ </DialogFooter>
93+ </fetcher.Form>
94+ </DialogContent>
95+ </Dialog>
96+ );
97+}
98+
99+/** A suggested new cap: double the old one, and always above what it has spent. */
100+export function suggestedCap(session: Pick<AgentSession, "charged_micros" | "cap_micros">): number {
101+ const base = Math.max(session.cap_micros ?? 0, session.charged_micros);
102+ const doubled = Math.max(base * 2, session.charged_micros + 1_000_000);
103+ return Math.ceil(doubled / 10_000) * 10_000;
104+}
105+
106+/** Owners let a session that stopped at its cap go on, with a new cap. */
107+export function ApproveDialog({ slug, session, trigger }: { slug: string; session: AgentSession; trigger: ReactNode }) {
108+ const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`approve-${session.id}`);
109+ const id = useId();
110+ return (
111+ <Dialog open={open} onOpenChange={setOpen}>
112+ <DialogTrigger asChild>{trigger}</DialogTrigger>
113+ <DialogContent>
114+ <DialogHeader>
115+ <DialogTitle>Approve more spend</DialogTitle>
116+ <DialogDescription>
117+ {session.visible ? <>&ldquo;{session.title}&rdquo;</> : <PrivateTitle />} stopped at its cap. A new cap lets it go on; it stops again there.
118+ </DialogDescription>
119+ </DialogHeader>
120+ <p className="rounded-lg border border-line bg-bg/50 px-3 py-2 text-sm text-muted">
121+ {session.agent_name} has spent <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} /> on it so far.
122+ </p>
123+ <fetcher.Form method="post" action={`/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`} className="grid gap-5">
124+ <input type="hidden" name="intent" value="approve" />
125+ <Field>
126+ <FieldLabel htmlFor={`${id}-cap`}>New cap</FieldLabel>
127+ <DollarsInput id={`${id}-cap`} name="cap" defaultValue={dollarsField(suggestedCap(session))} required />
128+ <FieldDescription>More than the {money(session.charged_micros)} it has spent. Charged to {session.agent_name}&apos;s budget.</FieldDescription>
129+ </Field>
130+ <FieldError>{error}</FieldError>
131+ <DialogFooter>
132+ <button type="button" className={QUIET} onClick={() => setOpen(false)}>
133+ Cancel
134+ </button>
135+ <button type="submit" className={PRIMARY} disabled={busy}>
136+ {busy ? "Approving…" : "Approve and go on"}
137+ </button>
138+ </DialogFooter>
139+ </fetcher.Form>
140+ </DialogContent>
141+ </Dialog>
142+ );
143+}
144+
145+/**
146+ * A choice that is asked twice: the trigger opens a question, and only its
147+ * confirm button posts `fields` to `action`.
148+ */
149+export function Confirm({
150+ trigger,
151+ title,
152+ children,
153+ confirm,
154+ action,
155+ fields,
156+ fetcherKey,
157+}: {
158+ trigger: ReactNode;
159+ title: string;
160+ children: ReactNode;
161+ confirm: string;
162+ action?: string;
163+ fields: Record<string, string>;
164+ fetcherKey?: string;
165+}) {
166+ const { fetcher, open, setOpen, error, busy } = useDialogFetcher(fetcherKey);
167+ return (
168+ <AlertDialog open={open} onOpenChange={setOpen}>
169+ <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
170+ <AlertDialogContent>
171+ <AlertDialogHeader>
172+ <AlertDialogTitle>{title}</AlertDialogTitle>
173+ <AlertDialogDescription asChild>
174+ <div>{children}</div>
175+ </AlertDialogDescription>
176+ </AlertDialogHeader>
177+ {error && <p className="text-sm text-danger">{error}</p>}
178+ <fetcher.Form method="post" action={action}>
179+ {Object.entries(fields).map(([name, value]) => (
180+ <input key={name} type="hidden" name={name} value={value} />
181+ ))}
182+ <AlertDialogFooter>
183+ <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
184+ <button
185+ type="submit"
186+ disabled={busy}
187+ className="inline-flex items-center justify-center gap-2 rounded-md bg-danger px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-danger/90 disabled:opacity-50"
188+ >
189+ {busy ? "Working…" : confirm}
190+ </button>
191+ </AlertDialogFooter>
192+ </fetcher.Form>
193+ </AlertDialogContent>
194+ </AlertDialog>
195+ );
196+}
197+
198+export const BUTTONS = { PRIMARY, QUIET };
+292−0
1+/**
2+ * Agents mode's arithmetic and wording: how a session's status and kind
3+ * read, where it ran, how a routine's schedule and events read, a tree of
4+ * sessions in order, memories by scope, a month's days and spend against
5+ * a cap. Pure, with type-only imports, so it can be tested.
6+ */
7+import type {
8+ AgentMemory,
9+ AgentMemoryScope,
10+ AgentSessionKind,
11+ AgentSessionStatus,
12+ RoutineSchedule,
13+ SpendSlice,
14+} from "@g1t/contracts";
15+
16+export type Tone = "neutral" | "accent" | "info" | "warn" | "success" | "danger";
17+
18+/** The statuses of a session that is not over (the contract's `SESSION_LIVE`). */
19+export const LIVE_STATUSES: readonly AgentSessionStatus[] = ["queued", "working", "waiting", "needs_approval"];
20+
21+export function isLive(status: AgentSessionStatus | string): boolean {
22+ return (LIVE_STATUSES as readonly string[]).includes(status);
23+}
24+
25+/** A session's status chip: its words, its tone, and whether it is moving now (a soft pulse). The same words as its card in chat. */
26+export function sessionStatus(status: AgentSessionStatus | string): { label: string; tone: Tone; moving: boolean } {
27+ switch (status) {
28+ case "queued":
29+ return { label: "Queued", tone: "neutral", moving: false };
30+ case "working":
31+ return { label: "Working", tone: "accent", moving: true };
32+ case "waiting":
33+ return { label: "Waiting on helpers", tone: "info", moving: true };
34+ case "needs_approval":
35+ return { label: "Needs approval", tone: "warn", moving: false };
36+ case "done":
37+ return { label: "Done", tone: "success", moving: false };
38+ case "failed":
39+ return { label: "Failed", tone: "danger", moving: false };
40+ case "stopped":
41+ return { label: "Stopped", tone: "neutral", moving: false };
42+ default:
43+ return { label: String(status), tone: "neutral", moving: false };
44+ }
45+}
46+
47+const KINDS: Record<AgentSessionKind, string> = { chat: "Chat", routine: "Routine", helper: "Helper", subagent: "Subagent" };
48+
49+/** Where a session came from, in a word. */
50+export function kindLabel(kind: AgentSessionKind | string): string {
51+ return KINDS[kind as AgentSessionKind] ?? String(kind);
52+}
53+
54+/** The conversation, as a person reads it: `#releases`, or a direct message. */
55+export function whereLabel(at: { channel_kind: "channel" | "dm"; channel_name: string | null }): string {
56+ if (at.channel_kind === "channel" && at.channel_name) return `#${at.channel_name}`;
57+ return at.channel_kind === "dm" ? "A direct message" : "A channel";
58+}
59+
60+/** How much of a cap is spent, 0 to 1 (more when over); null with no cap. */
61+export function shareOf(spent: number, cap: number | null | undefined): number | null {
62+ if (cap == null || cap <= 0) return null;
63+ return Math.max(0, spent) / cap;
64+}
65+
66+/** A meter's tone: calm, then warning at 75%, then danger at 100%. */
67+export function meterTone(share: number | null): "accent" | "warn" | "danger" {
68+ if (share == null || share < 0.75) return "accent";
69+ return share >= 1 ? "danger" : "warn";
70+}
71+
72+/**
73+ * A tree of sessions as rows, each child under its parent, children oldest
74+ * first. A session whose parent is not in the list is a root; roots keep the
75+ * list's order. Cycles (which never happen) cannot loop.
76+ */
77+export function sessionRows<T extends { id: string; parent_id: string | null; created_at: string }>(sessions: T[]): { session: T; depth: number }[] {
78+ const ids = new Set(sessions.map((s) => s.id));
79+ const children = new Map<string, T[]>();
80+ const roots: T[] = [];
81+ for (const session of sessions) {
82+ if (session.parent_id && ids.has(session.parent_id) && session.parent_id !== session.id) {
83+ children.set(session.parent_id, [...(children.get(session.parent_id) ?? []), session]);
84+ } else roots.push(session);
85+ }
86+ const out: { session: T; depth: number }[] = [];
87+ const seen = new Set<string>();
88+ const walk = (session: T, depth: number) => {
89+ if (seen.has(session.id)) return;
90+ seen.add(session.id);
91+ out.push({ session, depth });
92+ const kids = [...(children.get(session.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at));
93+ for (const kid of kids) walk(kid, depth + 1);
94+ };
95+ for (const root of roots) walk(root, 0);
96+ // Anything only reachable through a cycle still shows, at the top.
97+ for (const session of sessions) if (!seen.has(session.id)) walk(session, 0);
98+ return out;
99+}
100+
101+const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
102+
103+export function weekdayName(day: number): string {
104+ return WEEKDAYS[((day % 7) + 7) % 7]!;
105+}
106+
107+const two = (n: number) => String(Math.max(0, Math.floor(n))).padStart(2, "0");
108+
109+/** "Every weekday at 09:00 UTC", "Every hour at :15", "Every Monday at 17:30 UTC". */
110+export function scheduleInWords(schedule: RoutineSchedule): string {
111+ const time = `${two(schedule.hour)}:${two(schedule.minute)} UTC`;
112+ switch (schedule.every) {
113+ case "hour":
114+ return schedule.minute === 0 ? "Every hour, on the hour" : `Every hour at :${two(schedule.minute)}`;
115+ case "day":
116+ return `Every day at ${time}`;
117+ case "weekday":
118+ return `Every weekday at ${time}`;
119+ case "week":
120+ return `Every ${weekdayName(schedule.weekday)} at ${time}`;
121+ default:
122+ return time;
123+ }
124+}
125+
126+/** "A pull request is merged" → "When a pull request is merged". */
127+export function whenEvent(label: string): string {
128+ return `When ${label.charAt(0).toLowerCase()}${label.slice(1)}`;
129+}
130+
131+/**
132+ * When a routine runs, in words: its schedule, its events (`labels` names
133+ * each), and the repositories they come from. "Every weekday at 09:00 UTC",
134+ * "When a pull request is ready for review · acme/web".
135+ */
136+export function routineInWords(
137+ routine: { schedule: RoutineSchedule | null; events?: readonly string[] | null; repos?: readonly string[] | null },
138+ labels: Record<string, string>,
139+): string {
140+ const parts: string[] = [];
141+ if (routine.schedule) parts.push(scheduleInWords(routine.schedule));
142+ const events = routine.events ?? [];
143+ if (events.length > 0) {
144+ const named = events.map((key) => labels[key] ?? key);
145+ const first = whenEvent(named[0]!);
146+ const rest = named.slice(1).map((label) => label.charAt(0).toLowerCase() + label.slice(1));
147+ parts.push([first, ...rest].join(", or "));
148+ const repos = routine.repos ?? [];
149+ parts.push(repos.length > 0 ? repos.join(", ") : "Any repository");
150+ }
151+ return parts.length > 0 ? parts.join(" · ") : "Never: it has no schedule or events";
152+}
153+
154+/** A schedule from the dialog's fields: how often, a time as `HH:MM`, and a weekday. */
155+export function scheduleFrom(every: string, time: string, weekday: string | number): RoutineSchedule | null {
156+ if (every !== "hour" && every !== "day" && every !== "weekday" && every !== "week") return null;
157+ const match = /^(\d{1,2}):(\d{2})$/.exec(String(time ?? "").trim());
158+ const hour = match ? Number(match[1]) : 9;
159+ const minute = match ? Number(match[2]) : 0;
160+ if (hour > 23 || minute > 59) return null;
161+ const day = String(weekday ?? "").trim() === "" ? Number.NaN : Number(weekday);
162+ return { every, hour, minute, weekday: Number.isInteger(day) && day >= 0 && day <= 6 ? day : 1 };
163+}
164+
165+/** `HH:MM` for a time field. */
166+export function timeField(schedule: Pick<RoutineSchedule, "hour" | "minute">): string {
167+ return `${two(schedule.hour)}:${two(schedule.minute)}`;
168+}
169+
170+/** Repositories typed as a list, `acme/web, acme/api`: each `workspace/name`, lowercased, once. Null when one isn't. */
171+export function reposFrom(text: string): string[] | null {
172+ const parts = String(text ?? "")
173+ .split(/[\s,]+/)
174+ .map((p) => p.trim().toLowerCase())
175+ .filter(Boolean);
176+ if (parts.some((p) => !/^[a-z0-9][a-z0-9._-]*\/[a-z0-9._-]+$/.test(p))) return null;
177+ return [...new Set(parts)];
178+}
179+
180+export type MemoryGroup = { scope: AgentMemoryScope; title: string; about: string; memories: AgentMemory[] };
181+
182+/**
183+ * Memories by scope, in the order the page shows them, pinned first in each.
184+ * Empty groups are left out.
185+ */
186+export function memoryGroups(memories: AgentMemory[]): MemoryGroup[] {
187+ const order = (a: AgentMemory, b: AgentMemory) => Number(b.pinned) - Number(a.pinned) || b.updated_at.localeCompare(a.updated_at);
188+ const groups: MemoryGroup[] = [
189+ { scope: "workspace", title: "Workspace", about: "Recalled anywhere in the workspace, for anyone.", memories: [] },
190+ { scope: "channel", title: "Conversations", about: "Recalled only in the channel each came from.", memories: [] },
191+ { scope: "person", title: "Just you", about: "Recalled only in your direct messages with it.", memories: [] },
192+ ];
193+ for (const memory of memories) groups.find((g) => g.scope === memory.scope)?.memories.push(memory);
194+ return groups.filter((g) => g.memories.length > 0).map((g) => ({ ...g, memories: [...g.memories].sort(order) }));
195+}
196+
197+/**
198+ * Every day of a month (`YYYY-MM`) up to today when it is this month, with
199+ * its spend; days with none are 0.
200+ */
201+export function monthDays(period: string, days: { day: string; micros: number }[], now = new Date()): { day: string; micros: number }[] {
202+ const match = /^(\d{4})-(\d{2})$/.exec(period);
203+ if (!match) return days;
204+ const year = Number(match[1]);
205+ const month = Number(match[2]) - 1;
206+ const inMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
207+ const thisMonth = now.getUTCFullYear() === year && now.getUTCMonth() === month;
208+ const last = thisMonth ? now.getUTCDate() : inMonth;
209+ const spent = new Map(days.map((d) => [d.day, d.micros]));
210+ const out: { day: string; micros: number }[] = [];
211+ for (let d = 1; d <= last; d++) {
212+ const key = `${period}-${two(d)}`;
213+ out.push({ day: key, micros: spent.get(key) ?? 0 });
214+ }
215+ return out;
216+}
217+
218+/** The first `n` slices, and the rest folded into one "Everything else". */
219+export function topSlices(slices: SpendSlice[], n = 5): SpendSlice[] {
220+ if (slices.length <= n + 1) return slices;
221+ const rest = slices.slice(n);
222+ return [
223+ ...slices.slice(0, n),
224+ {
225+ key: "__rest",
226+ label: `${rest.length} more`,
227+ micros: rest.reduce((sum, s) => sum + s.micros, 0),
228+ count: rest.reduce((sum, s) => sum + s.count, 0),
229+ },
230+ ];
231+}
232+
233+/** "in 5m", "in 3h", "in 2d"; "now" when due. */
234+export function untilLabel(at: string, now = Date.now()): string {
235+ const ms = new Date(at).getTime() - now;
236+ if (!Number.isFinite(ms) || ms <= 60_000) return "now";
237+ if (ms < 3_600_000) return `in ${Math.round(ms / 60_000)}m`;
238+ if (ms < 86_400_000) return `in ${Math.round(ms / 3_600_000)}h`;
239+ return `in ${Math.round(ms / 86_400_000)}d`;
240+}
241+
242+/** A short day for an axis or a hint: "Oct 3". */
243+export function shortDay(day: string): string {
244+ const at = new Date(`${day}T00:00:00Z`);
245+ if (Number.isNaN(at.getTime())) return day;
246+ return at.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
247+}
248+
249+/** A month key as words: "October 2026". */
250+export function monthName(period: string): string {
251+ const at = new Date(`${period}-01T00:00:00Z`);
252+ if (Number.isNaN(at.getTime())) return period;
253+ return at.toLocaleDateString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
254+}
255+
256+const FIELD_LABELS: Record<string, string> = {
257+ display_name: "name",
258+ handle: "handle",
259+ title: "title",
260+ team: "team",
261+ department: "department",
262+ role: "role",
263+ responsibilities: "responsibilities",
264+ subagents: "subagents",
265+ instructions: "job",
266+ personality_preset: "personality",
267+ personality: "personality",
268+ routing: "models",
269+ budget: "budget",
270+ autonomy: "what it may do alone",
271+ capacity: "capacity",
272+ avatar_seed: "face",
273+ faces: "who it works with",
274+};
275+
276+/**
277+ * What changed between two saved versions of an agent, in words: "job,
278+ * budget". The first version (no `before`) is "Created".
279+ */
280+export function versionChanges(before: Record<string, unknown> | null, after: Record<string, unknown>): string {
281+ if (!before) return "Created";
282+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
283+ const changed: string[] = [];
284+ for (const key of keys) {
285+ if (JSON.stringify(before[key] ?? null) === JSON.stringify(after[key] ?? null)) continue;
286+ const label = FIELD_LABELS[key];
287+ if (label && !changed.includes(label)) changed.push(label);
288+ }
289+ if (changed.length === 0) return "Saved with no changes";
290+ const text = changed.join(", ");
291+ return `Changed ${text}`;
292+}
+295−0
1+import { CornerDownRight, Lock } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { Link } from "react-router";
4+
5+import type { AgentSession, SpendSlice } from "@g1t/contracts";
6+
7+import { AgentAvatar } from "../agent-avatar";
8+import { Badge } from "../ui/badge";
9+import { Hint } from "../ui/hint";
10+import { TimeAgo } from "../ui";
11+import { cn } from "../../lib/cn";
12+import { money } from "../../lib/usage";
13+import { kindLabel, meterTone, monthDays, sessionStatus, shareOf, shortDay, topSlices, whereLabel } from "./format";
14+
15+/** Where a session's page is. */
16+export function sessionHref(slug: string, session: Pick<AgentSession, "id" | "agent_handle">): string {
17+ return `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`;
18+}
19+
20+/** A session's status as a chip; a soft pulse while it is moving. */
21+export function StatusChip({ status, className }: { status: string; className?: string }) {
22+ const chip = sessionStatus(status);
23+ return (
24+ <Badge tone={chip.tone} className={className}>
25+ {chip.moving && (
26+ <span className="relative flex size-1.5">
27+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:animate-none" />
28+ <span className="relative inline-flex size-1.5 rounded-full bg-current" />
29+ </span>
30+ )}
31+ {chip.label}
32+ </Badge>
33+ );
34+}
35+
36+/** Where a session came from: Chat, Routine, Helper or Subagent. */
37+export function KindBadge({ kind, subagent }: { kind: string; subagent?: string | null }) {
38+ return (
39+ <span className="inline-flex h-[1.125rem] shrink-0 items-center rounded-[5px] bg-raised px-1.5 text-[0.6875rem] font-medium text-muted ring-1 ring-line ring-inset">
40+ {kindLabel(kind)}
41+ {subagent ? <span className="ml-1 font-mono text-faint">{subagent}</span> : null}
42+ </span>
43+ );
44+}
45+
46+const BAR: Record<"accent" | "warn" | "danger", string> = { accent: "bg-accent", warn: "bg-warn", danger: "bg-danger" };
47+
48+/** Spend against a cap as a thin bar; a plain track with no cap. */
49+export function Meter({ spent, cap, label, className, size = "md" }: { spent: number; cap: number | null | undefined; label: string; className?: string; size?: "sm" | "md" }) {
50+ const share = shareOf(spent, cap);
51+ const width = share == null ? 0 : Math.min(100, Math.max(share * 100, spent > 0 ? 1.5 : 0));
52+ return (
53+ <div
54+ className={cn("overflow-hidden rounded-full bg-line", size === "sm" ? "h-1" : "h-2", className)}
55+ role="meter"
56+ aria-label={label}
57+ aria-valuemin={0}
58+ aria-valuemax={cap ?? undefined}
59+ aria-valuenow={spent}
60+ >
61+ {share != null && <div className={cn("h-full rounded-full transition-[width]", BAR[meterTone(share)])} style={{ width: `${width}%` }} />}
62+ </div>
63+ );
64+}
65+
66+/** "$0.42 of $2.00", or just what it spent. */
67+export function SpendOfCap({ spent, cap }: { spent: number; cap: number | null | undefined }) {
68+ return (
69+ <span className="tabular-nums">
70+ {money(spent)}
71+ {cap != null && <span className="text-faint"> of {money(cap)}</span>}
72+ </span>
73+ );
74+}
75+
76+/** A session someone else's conversation holds: it ran and what it cost, nothing more. */
77+export function PrivateTitle({ className }: { className?: string }) {
78+ return (
79+ <span className={cn("inline-flex items-center gap-1.5 text-muted italic", className)}>
80+ <Lock size={12} className="shrink-0 not-italic" aria-hidden="true" />A private session
81+ </span>
82+ );
83+}
84+
85+/** "12 steps · 30 tools". */
86+export function stepsLine(session: Pick<AgentSession, "steps" | "tool_calls">): string {
87+ return `${session.steps} ${session.steps === 1 ? "step" : "steps"} · ${session.tool_calls} ${session.tool_calls === 1 ? "tool" : "tools"}`;
88+}
89+
90+/**
91+ * One session as a list shows it: its title (or that it is private), kind,
92+ * status, who asked and where, steps and spend against its cap, and when.
93+ * `depth` indents a child under its parent.
94+ */
95+export function SessionRow({ slug, session, depth = 0, showAgent = false }: { slug: string; session: AgentSession; depth?: number; showAgent?: boolean }) {
96+ const title = session.visible ? <span className="truncate font-medium text-fg">{session.title || "Untitled session"}</span> : <PrivateTitle />;
97+ return (
98+ <li className="group relative">
99+ <div className="flex items-start gap-3 px-4 py-3 transition-colors group-hover:bg-raised/40" style={{ paddingLeft: `${1 + depth * 1.5}rem` }}>
100+ {depth > 0 && <CornerDownRight size={14} className="mt-1 shrink-0 text-faint" aria-hidden="true" />}
101+ {showAgent && (
102+ <span className="mt-0.5 shrink-0">
103+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={22} />
104+ </span>
105+ )}
106+ <div className="min-w-0 grow">
107+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
108+ {session.visible ? (
109+ <Link to={sessionHref(slug, session)} className="min-w-0 truncate after:absolute after:inset-0 hover:underline">
110+ {title}
111+ </Link>
112+ ) : (
113+ title
114+ )}
115+ <KindBadge kind={session.kind} subagent={session.subagent} />
116+ <StatusChip status={session.status} />
117+ </div>
118+ <p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-faint">
119+ {showAgent && <span className="text-muted">{session.agent_name}</span>}
120+ {showAgent && <span aria-hidden="true">·</span>}
121+ {session.visible && session.asked_by_username && (
122+ <>
123+ <span>asked by @{session.asked_by_username}</span>
124+ <span aria-hidden="true">·</span>
125+ </>
126+ )}
127+ {session.visible && (
128+ <>
129+ <span>{whereLabel(session)}</span>
130+ <span aria-hidden="true">·</span>
131+ </>
132+ )}
133+ <span>{stepsLine(session)}</span>
134+ <span aria-hidden="true">·</span>
135+ <TimeAgo at={session.created_at} />
136+ </p>
137+ {session.visible && session.status_note && session.status !== "done" && <p className="mt-1 text-xs text-muted">{session.status_note}</p>}
138+ </div>
139+ <div className="hidden w-32 shrink-0 text-right sm:block">
140+ <p className="text-xs">
141+ <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} />
142+ </p>
143+ {session.cap_micros != null && <Meter spent={session.charged_micros} cap={session.cap_micros} label="Spent of its cap" size="sm" className="mt-1.5" />}
144+ </div>
145+ </div>
146+ </li>
147+ );
148+}
149+
150+/** A live session as a card: the agent, what it is doing, how far and what it has cost. */
151+export function SessionCard({ slug, session, action }: { slug: string; session: AgentSession; action?: ReactNode }) {
152+ return (
153+ <li className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
154+ <div className="flex items-center gap-2.5">
155+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={28} />
156+ <div className="min-w-0 grow leading-tight">
157+ <p className="truncate text-sm font-medium">{session.agent_name}</p>
158+ <p className="truncate text-xs text-faint">
159+ {whereLabel(session)}
160+ {session.asked_by_username ? ` · @${session.asked_by_username}` : ""}
161+ </p>
162+ </div>
163+ <StatusChip status={session.status} />
164+ </div>
165+ <Link to={sessionHref(slug, session)} className="mt-3 line-clamp-2 text-[0.9375rem] font-medium text-fg after:absolute after:inset-0 hover:underline">
166+ {session.visible ? session.title || "Untitled session" : "A private session"}
167+ </Link>
168+ {session.status_note && <p className="mt-1 line-clamp-2 text-xs text-muted">{session.status_note}</p>}
169+ <div className="mt-auto pt-4">
170+ <div className="flex items-center justify-between gap-3 text-xs text-muted">
171+ <span>
172+ {stepsLine(session)} · <TimeAgo at={session.created_at} />
173+ </span>
174+ <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} />
175+ </div>
176+ {session.cap_micros != null && <Meter spent={session.charged_micros} cap={session.cap_micros} label="Spent of its cap" size="sm" className="mt-2" />}
177+ {action && <div className="relative z-10 mt-3">{action}</div>}
178+ </div>
179+ </li>
180+ );
181+}
182+
183+/** A titled panel, as the overview and Spend lay them out. */
184+export function Panel({ title, aside, children, className }: { title: ReactNode; aside?: ReactNode; children: ReactNode; className?: string }) {
185+ return (
186+ <section className={cn("rounded-xl border border-line bg-surface", className)}>
187+ <header className="flex items-baseline justify-between gap-3 border-b border-line px-4 py-2.5">
188+ <h3 className="text-sm font-medium">{title}</h3>
189+ {aside && <span className="text-xs text-faint">{aside}</span>}
190+ </header>
191+ {children}
192+ </section>
193+ );
194+}
195+
196+/**
197+ * Where money went, ranked: each slice's name, a bar against the largest,
198+ * its count and its spend. Past five, the rest fold into one line.
199+ */
200+export function SliceList({
201+ slices,
202+ unit = "",
203+ empty = "Nothing spent yet this month.",
204+ href,
205+ icon,
206+}: {
207+ slices: SpendSlice[];
208+ /** What `count` counts: "sessions"; empty to leave it out. */
209+ unit?: string;
210+ empty?: string;
211+ href?: (slice: SpendSlice) => string | null;
212+ icon?: (slice: SpendSlice) => ReactNode;
213+}) {
214+ const shown = topSlices(slices.filter((s) => s.micros > 0 || s.count > 0));
215+ const max = Math.max(1, ...shown.map((s) => s.micros));
216+ if (shown.length === 0) return <p className="px-4 py-6 text-center text-sm text-faint">{empty}</p>;
217+ return (
218+ <ul className="divide-y divide-line/60">
219+ {shown.map((slice) => {
220+ const to = slice.key !== "__rest" ? href?.(slice) : null;
221+ const name = <span className="truncate">{slice.label}</span>;
222+ return (
223+ <li key={slice.key || "none"} className="px-4 py-2.5 text-sm">
224+ <div className="flex items-center gap-2">
225+ {slice.key !== "__rest" && icon?.(slice)}
226+ {to ? (
227+ <Link to={to} className="min-w-0 truncate hover:underline">
228+ {name}
229+ </Link>
230+ ) : (
231+ <span className={cn("min-w-0 truncate", slice.key === "__rest" && "text-muted")}>{name}</span>
232+ )}
233+ <span className="ml-auto shrink-0 text-xs text-faint tabular-nums">{unit && `${slice.count} ${unit}`}</span>
234+ <span className="w-20 shrink-0 text-right tabular-nums">{money(slice.micros)}</span>
235+ </div>
236+ <div className="mt-1.5 h-1 overflow-hidden rounded-full bg-line/60" aria-hidden="true">
237+ <div className="h-full rounded-full bg-accent/80" style={{ width: `${Math.max((slice.micros / max) * 100, slice.micros > 0 ? 1 : 0)}%` }} />
238+ </div>
239+ </li>
240+ );
241+ })}
242+ </ul>
243+ );
244+}
245+
246+/**
247+ * Spend by day this month as bars, one per day, each with its amount on
248+ * hover or focus; a table for screen readers.
249+ */
250+export function DailyBars({ period, days, className }: { period: string; days: { day: string; micros: number }[]; className?: string }) {
251+ const all = monthDays(period, days);
252+ const max = Math.max(0, ...all.map((d) => d.micros));
253+ return (
254+ <div className={className}>
255+ <div className="flex h-24 items-end gap-0.5" aria-hidden="true">
256+ {all.map((d) => (
257+ <Hint key={d.day} label={`${shortDay(d.day)} · ${money(d.micros)}`}>
258+ <span className="flex h-full min-w-0 flex-1 items-end rounded-t-[3px] hover:bg-raised/60">
259+ <span
260+ className={cn("block w-full rounded-t-[3px]", d.micros > 0 ? "bg-accent/80" : "bg-line/70")}
261+ style={{ height: max > 0 && d.micros > 0 ? `${Math.max(4, (d.micros / max) * 100)}%` : "2px" }}
262+ />
263+ </span>
264+ </Hint>
265+ ))}
266+ </div>
267+ <div className="mt-1.5 flex justify-between text-[0.6875rem] text-faint" aria-hidden="true">
268+ <span>{all[0] ? shortDay(all[0].day) : ""}</span>
269+ <span>{all.length > 1 ? shortDay(all[all.length - 1]!.day) : ""}</span>
270+ </div>
271+ <table className="sr-only">
272+ <caption>Spend by day</caption>
273+ <tbody>
274+ {all.map((d) => (
275+ <tr key={d.day}>
276+ <th scope="row">{shortDay(d.day)}</th>
277+ <td>{money(d.micros)}</td>
278+ </tr>
279+ ))}
280+ </tbody>
281+ </table>
282+ </div>
283+ );
284+}
285+
286+/** A quiet empty state inside a panel or a tab. */
287+export function Quiet({ title, children, icon }: { title: string; children?: ReactNode; icon?: ReactNode }) {
288+ return (
289+ <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
290+ {icon && <span className="mx-auto mb-3 flex size-10 items-center justify-center rounded-xl bg-raised text-muted">{icon}</span>}
291+ <p className="text-sm font-medium">{title}</p>
292+ {children && <div className="mx-auto mt-1 max-w-md text-sm text-muted">{children}</div>}
293+ </div>
294+ );
295+}
+206−0
1+import { type ReactNode, useId, useState } from "react";
2+
3+import { type NewRoutine, ROUTINE_EVENTS, type RoutineEvent, type RoutineSchedule } from "@g1t/contracts";
4+
5+import { CheckboxOption } from "../ui/checkbox";
6+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
7+import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
8+import { Input } from "../ui/input";
9+import { SelectField } from "../ui/select";
10+import { Switch } from "../ui/switch";
11+import { Textarea } from "../ui/textarea";
12+import { BUTTONS, useDialogFetcher } from "./dialogs";
13+import { timeField, weekdayName } from "./format";
14+
15+/** A conversation a routine can post in. */
16+export type RoutineChannel = { id: string; label: string };
17+
18+/** What the dialog starts from: a routine to change, a suggestion to add, or nothing. */
19+export type RoutineDraft = Partial<Omit<NewRoutine, "schedule">> & { id?: string | null; schedule?: RoutineSchedule | null };
20+
21+const EVERY = [
22+ { value: "hour", label: "Every hour" },
23+ { value: "day", label: "Every day" },
24+ { value: "weekday", label: "Every weekday" },
25+ { value: "week", label: "Every week" },
26+];
27+
28+const DEFAULT_SCHEDULE: RoutineSchedule = { every: "weekday", hour: 9, minute: 0, weekday: 1 };
29+
30+/**
31+ * A routine, made or changed, for owners: its name and instructions, when it
32+ * runs (a schedule, things that happen, or both), and where it posts.
33+ * Whoever saves it becomes its sponsor: it runs with their access.
34+ */
35+export function RoutineDialog({
36+ agentName,
37+ draft,
38+ channels,
39+ trigger,
40+ title,
41+}: {
42+ agentName: string;
43+ draft: RoutineDraft;
44+ channels: RoutineChannel[];
45+ trigger: ReactNode;
46+ title: string;
47+}) {
48+ const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`routine-${draft.id ?? draft.name ?? "new"}`);
49+ const id = useId();
50+ const [scheduled, setScheduled] = useState(draft.schedule != null || (draft.events ?? []).length === 0);
51+ const [schedule, setSchedule] = useState<RoutineSchedule>(draft.schedule ?? DEFAULT_SCHEDULE);
52+ const [events, setEvents] = useState<RoutineEvent[]>(draft.events ?? []);
53+ const known = draft.channel_id && !channels.some((c) => c.id === draft.channel_id) ? [{ id: draft.channel_id, label: "Its current channel" }, ...channels] : channels;
54+ return (
55+ <Dialog open={open} onOpenChange={setOpen}>
56+ <DialogTrigger asChild>{trigger}</DialogTrigger>
57+ <DialogContent className="max-w-xl">
58+ <DialogHeader>
59+ <DialogTitle>{title}</DialogTitle>
60+ <DialogDescription>
61+ Each run is a session {agentName} posts in the channel you choose, paid from its budget. It runs with your access, never more: saving makes you its sponsor.
62+ </DialogDescription>
63+ </DialogHeader>
64+ <fetcher.Form method="post" className="grid gap-5">
65+ <input type="hidden" name="intent" value="save" />
66+ {draft.id && <input type="hidden" name="id" value={draft.id} />}
67+ <input type="hidden" name="enabled" value={draft.enabled === false ? "false" : "true"} />
68+ <Field>
69+ <FieldLabel htmlFor={`${id}-name`}>Name</FieldLabel>
70+ <Input id={`${id}-name`} name="name" defaultValue={draft.name ?? ""} maxLength={80} required placeholder="Monday support digest" />
71+ </Field>
72+ <Field>
73+ <FieldLabel htmlFor={`${id}-instructions`}>Instructions</FieldLabel>
74+ <Textarea
75+ id={`${id}-instructions`}
76+ name="instructions"
77+ defaultValue={draft.instructions ?? ""}
78+ rows={4}
79+ maxLength={8000}
80+ required
81+ placeholder="Read last week's #support threads and post the top themes, with links, and what's still open."
82+ />
83+ </Field>
84+
85+ <fieldset className="grid gap-4 rounded-lg border border-line p-4">
86+ <legend className="px-1 text-sm font-medium">When</legend>
87+ <label className="flex items-center justify-between gap-3 text-sm">
88+ <span>
89+ On a schedule
90+ <span className="block text-xs text-faint">In UTC.</span>
91+ </span>
92+ <Switch checked={scheduled} onCheckedChange={setScheduled} aria-label="On a schedule" />
93+ </label>
94+ {scheduled && (
95+ <div className="flex flex-wrap items-center gap-2">
96+ <input type="hidden" name="every" value={schedule.every} />
97+ <input type="hidden" name="weekday" value={schedule.weekday} />
98+ <div className="w-40">
99+ <SelectField
100+ value={schedule.every}
101+ onValueChange={(every) => setSchedule({ ...schedule, every: every as RoutineSchedule["every"] })}
102+ options={EVERY}
103+ size="sm"
104+ aria-label="How often"
105+ />
106+ </div>
107+ {schedule.every === "week" && (
108+ <div className="w-36">
109+ <SelectField
110+ value={String(schedule.weekday)}
111+ onValueChange={(day) => setSchedule({ ...schedule, weekday: Number(day) })}
112+ options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: `on ${weekdayName(d)}` }))}
113+ size="sm"
114+ aria-label="Day of the week"
115+ />
116+ </div>
117+ )}
118+ {schedule.every === "hour" ? (
119+ <label className="flex items-center gap-2 text-sm text-muted">
120+ at minute
121+ <Input
122+ name="minute"
123+ type="number"
124+ min={0}
125+ max={59}
126+ value={schedule.minute}
127+ onChange={(e) => setSchedule({ ...schedule, minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })}
128+ className="h-8 w-20"
129+ />
130+ </label>
131+ ) : (
132+ <label className="flex items-center gap-2 text-sm text-muted">
133+ at
134+ <Input
135+ name="time"
136+ type="time"
137+ value={timeField(schedule)}
138+ onChange={(e) => {
139+ const [h, m] = e.target.value.split(":").map(Number);
140+ setSchedule({ ...schedule, hour: h || 0, minute: m || 0 });
141+ }}
142+ className="h-8 w-28"
143+ />
144+ UTC
145+ </label>
146+ )}
147+ </div>
148+ )}
149+ <div className="border-t border-line pt-4">
150+ <p className="text-sm">When something happens</p>
151+ <p className="text-xs text-faint">Each run is about the one thing that happened, in a repository you can read.</p>
152+ <div className="mt-3 grid gap-2.5">
153+ {ROUTINE_EVENTS.map((event) => (
154+ <CheckboxOption
155+ key={event.key}
156+ name="events"
157+ value={event.key}
158+ checked={events.includes(event.key)}
159+ onCheckedChange={(on) => setEvents(on ? [...events, event.key] : events.filter((e) => e !== event.key))}
160+ label={event.label}
161+ description={event.hint}
162+ />
163+ ))}
164+ </div>
165+ {events.length > 0 && (
166+ <Field className="mt-4">
167+ <FieldLabel htmlFor={`${id}-repos`}>Repositories</FieldLabel>
168+ <Input id={`${id}-repos`} name="repos" defaultValue={(draft.repos ?? []).join(", ")} placeholder="acme/web, acme/api" autoComplete="off" />
169+ <FieldDescription>Empty: every repository you can read.</FieldDescription>
170+ </Field>
171+ )}
172+ </div>
173+ {!scheduled && events.length === 0 && <p className="text-xs text-warn">Choose a schedule, something that happens, or both.</p>}
174+ </fieldset>
175+ {scheduled ? null : <input type="hidden" name="every" value="none" />}
176+
177+ <Field>
178+ <FieldLabel htmlFor={`${id}-channel`}>Posts in</FieldLabel>
179+ {known.length > 0 ? (
180+ <SelectField
181+ id={`${id}-channel`}
182+ name="channel"
183+ defaultValue={draft.channel_id ?? undefined}
184+ options={known.map((c) => ({ value: c.id, label: c.label }))}
185+ placeholder="Choose a conversation"
186+ required
187+ />
188+ ) : (
189+ <p className="text-sm text-muted">You&apos;re in no channel yet. Join one in Chat, with {agentName} in it, first.</p>
190+ )}
191+ <FieldDescription>A channel you and {agentName} are both in.</FieldDescription>
192+ </Field>
193+ <FieldError>{error}</FieldError>
194+ <DialogFooter>
195+ <button type="button" className={BUTTONS.QUIET} onClick={() => setOpen(false)}>
196+ Cancel
197+ </button>
198+ <button type="submit" className={BUTTONS.PRIMARY} disabled={busy || (!scheduled && events.length === 0)}>
199+ {busy ? "Saving…" : draft.id ? "Save routine" : "Add routine"}
200+ </button>
201+ </DialogFooter>
202+ </fetcher.Form>
203+ </DialogContent>
204+ </Dialog>
205+ );
206+}
+1−1
267267 const budget = 40;
268268 return (
269269 <figure
270− aria-label="An agent's profile in g1t: Margo, a QA Engineer on the QA team, with her responsibilities, voice, model routing, budget and what she may do alone"
270+ aria-label="An agent's profile in g1t: Margo, hired from the QA Engineer template, on the QA team, with her responsibilities, voice, model routing, budget and what she may do alone"
271271 className={cn("overflow-hidden rounded-2xl bg-surface text-left ring-1 ring-line", className)}
272272 >
273273 <div className="flex items-center gap-4 px-5 py-5">
+6−5
300300 },
301301 ];
302302
303−/** A workspace's org chart, read like a company's: g1t knows everyone; each department has its colleague. */
303+/** An example org chart, read like a company's: g1t comes with the workspace; each department's colleague was hired from a template. */
304304 const ORG: { team: string; who: string }[] = [
305305 { team: "Engineering", who: "Otto" },
306306 { team: "QA", who: "Margo" },
313313
314314 function OrgChart() {
315315 return (
316− <figure aria-label="An example org chart: g1t at the top, and one agent in each department" className="rounded-2xl bg-surface p-5 ring-1 ring-line">
316+ <figure aria-label="An example org chart: g1t at the top, and one agent hired from a template in each department" className="rounded-2xl bg-surface p-5 ring-1 ring-line">
317317 <div className="flex items-center gap-3">
318318 <AgentAvatar agent={{ handle: "g1t", name: "g1t" }} size={28} />
319319 <p className="text-sm">
498498 Hire agents into roles, like colleagues
499499 </h2>
500500 <p className="mt-4 max-w-xl leading-7 text-muted">
501− Hire Margo into QA. She gets a title, a team and broad responsibilities: review pull requests for risk,
502− write test plans, chase flaky checks. Role templates come by department, each with a name you can shuffle,
503− a voice and sensible limits. Her job decides what she does; her personality only changes how she sounds.
501+ Pick a role template, grouped by department, and hire in a click. For example, hire Margo from the QA
502+ Engineer template. She gets a title, a team and broad responsibilities: review pull requests for risk,
503+ write test plans, chase flaky checks. Every template comes with a name you can shuffle, a voice and
504+ sensible limits. Her job decides what she does; her personality only changes how she sounds.
504505 </p>
505506 <p className="mt-4 max-w-xl leading-7 text-muted">
506507 Nobody picks a model. <span className="text-fg-soft">Auto</span> routes each step to the cheapest model
+1−1
7676
7777 // --- Agents -------------------------------------------------------------
7878 // At work, Sessions and Memory are built (see project-nav.ts), and so is
79− // the workspace's Agent fleet.
79+ // the workspace's Agents overview.
8080 {
8181 key: "playbooks",
8282 title: "Playbooks",
+1−1
1414
1515 /**
1616 * Which of those pages a path is, under `/<workspace>`: null for the
17− * workspace's other pages (settings, Agent fleet, Usage and the rest), for
17+ * workspace's other pages (settings, Usage and the rest), for
1818 * one package's or one team's page, and for anything else.
1919 */
2020 export function workspacePage(pathname: string, slug: string): WorkspacePageKey | null {
+8−4
122122 // What members may do in chat: channels, emoji, default channels.
123123 route("-/settings/chat", "routes/workspace/chat-settings.tsx"),
124124 route("-/repositories", "routes/workspace/repositories.tsx"),
125− // Agents mode: the fleet's runs first, then each of the workspace's own
126− // agents (docs/WORKSPACE.md). `new` is no agent's handle.
125+ // Agents mode: the overview (budget, sessions, roster, spend) first, then
126+ // each of the workspace's own agents and its sessions (docs/WORKSPACE.md).
127+ // `new` is no agent's handle.
127128 route("-/agents", "routes/workspace/agents/layout.tsx", [
128129 index("routes/workspace/agents.tsx"),
129130 route("new", "routes/workspace/agents/new.tsx"),
130131 route(":handle", "routes/workspace/agents/agent.tsx", [
131− index("routes/workspace/agents/desk.tsx"),
132− route("profile", "routes/workspace/agents/profile.tsx"),
132+ index("routes/workspace/agents/sessions.tsx"),
133+ route("sessions/:id", "routes/workspace/agents/session.tsx"),
134+ route("memory", "routes/workspace/agents/memory.tsx"),
135+ route("routines", "routes/workspace/agents/routines.tsx"),
133136 route("spend", "routes/workspace/agents/spend.tsx"),
134137 route("activity", "routes/workspace/agents/activity.tsx"),
138+ route("profile", "routes/workspace/agents/profile.tsx"),
135139 ]),
136140 ]),
137141 // Chat mode: channels by name, direct messages by id, and what the page
+315−88
1−import { Coins } from "lucide-react";
1+import { AlertTriangle, CalendarClock, Coins, Hourglass, Wallet } from "lucide-react";
22 import { Link, data } from "react-router";
33
4−import type { AgentRun } from "@g1t/contracts";
4+import type { AgentPolicy, AgentsOverview, WorkspaceAgent } from "@g1t/contracts";
55
66 import type { Route } from "./+types/agents";
7+import { AgentFace } from "../../components/agents-mode";
8+import { agentsAction, answer, readOrNull } from "../../components/agents/actions.server";
9+import { ApproveDialog, BUTTONS, BudgetDialog } from "../../components/agents/dialogs";
10+import { meterTone, monthName, scheduleInWords, shareOf, untilLabel } from "../../components/agents/format";
11+import { DailyBars, Meter, Panel, Quiet, SessionCard, SessionRow, SliceList, SpendOfCap } from "../../components/agents/parts";
12+import { StatusDot, statusLabel } from "../../components/chat/marks";
13+import { isOrchestrator } from "../../components/orchestrator";
14+import { Badge } from "../../components/ui/badge";
15+import { microsFromDollars } from "../../lib/agent-form";
716 import { page } from "../../lib/meta";
8−import { Idle, RunCard, formatCost, splitRuns, useLiveRefresh } from "../../components/agents";
9−import { agents, billing } from "../../lib/services.server";
10−import { getViewer, roleIn, unwrap } from "../../lib/session.server";
17+import { useRefreshWhile } from "../../lib/refresh";
18+import { workspaceAgents } from "../../lib/services.server";
19+import { getViewer, roleIn } from "../../lib/session.server";
20+import { money } from "../../lib/usage";
1121
1222 export function meta({ params, ...args }: Route.MetaArgs) {
13− return page(args, { title: `Agent fleet · ${params.owner} · g1t` });
23+ return page(args, { title: `Agents · ${params.owner} · g1t` });
1424 }
1525
16−export async function loader({ params, context }: Route.LoaderArgs) {
26+/** Agents mode's front page: the budget, what is waiting and working, the roster, and where the month went. */
27+export async function loader({ params, context }: Route.LoaderArgs): Promise<{ overview: AgentsOverview | null }> {
1728 const viewer = getViewer(context);
18− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
19− const now = new Date();
20− const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
21− const [runs, usage] = await Promise.all([
22− agents.listRuns(viewer, { workspace: params.owner, limit: 150 }),
23− // Usage at price this month: the same figure as Usage and Billing.
24− billing.usage(params.owner, viewer, monthStart).catch(() => null),
25− ]);
26− return { runs: unwrap(runs), monthMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : (usage.value.priceMicros ?? usage.value.spentMicros)) : null };
29+ if (!viewer || !roleIn(viewer, params.owner)) throw data(null, { status: 404 });
30+ return { overview: await readOrNull(workspaceAgents.overview(params.owner.toLowerCase(), viewer)) };
2731 }
2832
29−/** What the runs shown cost, by project. */
30−function byProject(runs: AgentRun[]): { repo: string; runs: number; cost: number }[] {
31− const totals = new Map<string, { repo: string; runs: number; cost: number }>();
32− for (const run of runs) {
33− const repo = `${run.repo.namespace}/${run.repo.name}`;
34− const total = totals.get(repo) ?? { repo, runs: 0, cost: 0 };
35− total.runs += 1;
36− total.cost += run.costUsd ?? 0;
37− totals.set(repo, total);
38− }
39− return [...totals.values()].sort((a, b) => b.cost - a.cost || b.runs - a.runs);
33+/** Owners set the workspace's agent budget. */
34+export async function action({ params, context, request }: Route.ActionArgs) {
35+ const { viewer, slug, form } = await agentsAction(request, context, params.owner);
36+ if (form.get("intent") !== "policy") return { ok: false as const, error: "Unknown request." };
37+ const monthly = microsFromDollars(form.get("monthly"));
38+ const agent = microsFromDollars(form.get("default_agent"));
39+ const session = microsFromDollars(form.get("default_session"));
40+ if ([monthly, agent, session].some((v) => v != null && Number.isNaN(v))) return { ok: false as const, error: "Write each amount in dollars, such as 250 or 2.50." };
41+ const changes: Partial<AgentPolicy> = { monthly_micros: monthly, default_agent_monthly_micros: agent };
42+ if (session != null) changes.default_session_micros = session;
43+ return answer("policy", workspaceAgents.setPolicy(slug, viewer, changes));
4044 }
4145
42−export default function Fleet({ loaderData, params }: Route.ComponentProps) {
43− const { runs, monthMicros } = loaderData;
44− const { live, done } = splitRuns(runs);
45− useLiveRefresh(live.length > 0);
46− const projects = byProject(runs);
47− const failed = done.filter((run) => run.status === "failed").length;
46+export default function AgentsOverviewPage({ loaderData, params }: Route.ComponentProps) {
47+ const { overview } = loaderData;
48+ const slug = params.owner;
49+ useRefreshWhile(Boolean(overview && (overview.live.length > 0 || overview.waiting_on_you.length > 0)));
50+ if (!overview) {
51+ return (
52+ <Quiet title="Agents can't be shown right now">
53+ The agents service didn&apos;t answer. It&apos;s usually back within a minute; reload to try again.
54+ </Quiet>
55+ );
56+ }
57+ const liveCount = Object.values(overview.live_by_agent).reduce((n, c) => n + c, 0);
58+ const handles = new Map(overview.agents.map((agent) => [agent.id, agent]));
4859 return (
4960 <div className="space-y-10">
50− <div className="grid gap-3 sm:grid-cols-3">
51− <div className="rounded-xl border border-line bg-surface p-4">
52− <p className="text-xs text-muted">At work now</p>
53− <p className="mt-1 text-2xl font-semibold tabular-nums">{live.length}</p>
54− </div>
55− <div className="rounded-xl border border-line bg-surface p-4">
56− <p className="text-xs text-muted">Recent runs</p>
57− <p className="mt-1 text-2xl font-semibold tabular-nums">{runs.length}</p>
58− {failed > 0 && <p className="mt-0.5 text-xs text-danger">{failed} failed</p>}
59− </div>
60− <div className="rounded-xl border border-line bg-surface p-4">
61− <p className="flex items-center gap-1 text-xs text-muted">
62− <Coins size={12} />
63− Usage at price, this month
64− </p>
65− <p className="mt-1 text-2xl font-semibold tabular-nums">
66− {monthMicros != null ? `$${(monthMicros / 1_000_000).toFixed(2)}` : (formatCost(runs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0)) ?? "$0.00")}
67− </p>
68− <Link to={`/${params.owner}/-/usage`} className="mt-0.5 block text-xs text-muted hover:text-fg">
69− By product and project
70− </Link>
71− </div>
72− </div>
61+ <BudgetHeader overview={overview} slug={slug} liveCount={liveCount} />
62+
63+ {overview.waiting_on_you.length > 0 && (
64+ <section aria-labelledby="waiting">
65+ <h2 id="waiting" className="flex items-center gap-2 text-sm font-medium">
66+ <Hourglass size={15} className="text-warn" />
67+ Waiting on you
68+ <span className="text-faint">{overview.waiting_on_you.length}</span>
69+ </h2>
70+ <p className="mt-1 text-sm text-muted">Sessions that reached their spend cap. They stay paused until someone who may raise the cap says so.</p>
71+ <ul className="mt-3 grid gap-3 sm:grid-cols-2">
72+ {overview.waiting_on_you.map((session) => (
73+ <SessionCard
74+ key={session.id}
75+ slug={slug}
76+ session={session}
77+ action={
78+ overview.can_manage ? (
79+ <ApproveDialog
80+ slug={slug}
81+ session={session}
82+ trigger={
83+ <button type="button" className={`${BUTTONS.PRIMARY} h-8 w-full py-0`}>
84+ Approve more…
85+ </button>
86+ }
87+ />
88+ ) : (
89+ <p className="text-xs text-faint">An owner approves more spend.</p>
90+ )
91+ }
92+ />
93+ ))}
94+ </ul>
95+ </section>
96+ )}
7397
74− <section>
75− <h2 className="text-sm font-medium">Now</h2>
76− {live.length === 0 ? (
98+ <section aria-labelledby="working">
99+ <h2 id="working" className="text-sm font-medium">
100+ Working now {liveCount > 0 && <span className="text-faint">{liveCount}</span>}
101+ </h2>
102+ {overview.live.length === 0 ? (
77103 <div className="mt-3">
78− <Idle>No agent is at work in any of {params.owner}'s projects right now.</Idle>
104+ <Quiet title={liveCount > 0 ? "Working in conversations you're not in" : "No sessions right now"}>
105+ {liveCount > 0
106+ ? "Agents are working on sessions started in private channels or direct messages. What they cost still shows below."
107+ : "When a request needs real work, an agent spins off a session for it, with its own budget and a live card in the conversation."}
108+ </Quiet>
79109 </div>
80110 ) : (
81− <ul className="mt-3 space-y-3">
82− {live.map((run) => (
83− <RunCard key={run.id} run={run} member showRepo />
111+ <ul className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
112+ {overview.live.map((session) => (
113+ <SessionCard key={session.id} slug={slug} session={session} />
84114 ))}
85115 </ul>
86116 )}
87117 </section>
88118
89− {projects.length > 0 && (
90− <section>
91− <h2 className="text-sm font-medium">By project</h2>
92− <ul className="mt-3 divide-y divide-line rounded-xl border border-line bg-surface">
93− {projects.map((project) => (
94− <li key={project.repo} className="flex items-center gap-3 px-4 py-2.5 text-sm">
95− <Link to={`/${project.repo}/agents`} className="min-w-0 grow truncate font-mono hover:text-accent">
96− {project.repo}
97− </Link>
98− <span className="text-xs text-muted">
99− {project.runs} {project.runs === 1 ? "run" : "runs"}
100− </span>
101− <span className="w-16 text-right font-mono text-xs tabular-nums">{formatCost(project.cost) ?? "—"}</span>
102− </li>
103− ))}
104− </ul>
105− </section>
119+ <Roster slug={slug} agents={overview.agents} live={overview.live_by_agent} />
120+
121+ <SpendSection overview={overview} slug={slug} handles={handles} />
122+
123+ <div className="grid gap-6 lg:grid-cols-2">
124+ <Panel title="Upcoming routines" aside={overview.upcoming.length > 0 ? "UTC" : undefined}>
125+ {overview.upcoming.length === 0 ? (
126+ <p className="px-4 py-6 text-center text-sm text-faint">No routines scheduled. Set one up on an agent&apos;s Routines tab.</p>
127+ ) : (
128+ <ul className="divide-y divide-line/60">
129+ {overview.upcoming.map((routine) => (
130+ <li key={routine.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
131+ <CalendarClock size={15} className="shrink-0 text-faint" />
132+ <div className="min-w-0 grow">
133+ <Link to={`/${slug}/-/agents/${routine.agent_handle}/routines`} className="block truncate font-medium hover:underline">
134+ {routine.name}
135+ </Link>
136+ <p className="truncate text-xs text-faint">
137+ {routine.agent_name}
138+ {routine.schedule ? ` · ${scheduleInWords(routine.schedule)}` : ""}
139+ {routine.channel_name ? ` · #${routine.channel_name}` : ""}
140+ </p>
141+ </div>
142+ {routine.next_run_at && (
143+ <span className="shrink-0 text-xs text-muted tabular-nums" suppressHydrationWarning>
144+ {untilLabel(routine.next_run_at)}
145+ </span>
146+ )}
147+ </li>
148+ ))}
149+ </ul>
150+ )}
151+ </Panel>
152+ <Panel title="Recently finished">
153+ {overview.recent.length === 0 ? (
154+ <p className="px-4 py-6 text-center text-sm text-faint">Finished sessions you can see show here, with what they found.</p>
155+ ) : (
156+ <ul className="divide-y divide-line/60">
157+ {overview.recent.map((session) => (
158+ <SessionRow key={session.id} slug={slug} session={session} showAgent />
159+ ))}
160+ </ul>
161+ )}
162+ </Panel>
163+ </div>
164+ </div>
165+ );
166+}
167+
168+/** The month's spend by every agent against the workspace's agent budget, with the alert and, for owners, the way to change it. */
169+function BudgetHeader({ overview, slug, liveCount }: { overview: AgentsOverview; slug: string; liveCount: number }) {
170+ const { policy, spent_month_micros: spent, alert } = overview;
171+ const share = shareOf(spent, policy.monthly_micros);
172+ const tone = meterTone(share);
173+ return (
174+ <section className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
175+ {alert != null && (
176+ <div
177+ role="status"
178+ className={`mb-5 flex items-start gap-2.5 rounded-lg border px-3.5 py-2.5 text-sm ${
179+ alert >= 100 ? "border-danger/40 bg-danger/10 text-danger" : "border-warn/40 bg-warn/10 text-warn"
180+ }`}
181+ >
182+ <AlertTriangle size={16} className="mt-0.5 shrink-0" />
183+ <span>
184+ {alert >= 100
185+ ? "Agents have used this month's budget. They take no new work until the 1st, or until an owner raises it."
186+ : `Agents have used ${alert}% of this month's budget.`}{" "}
187+ {overview.can_manage ? "" : "An owner can raise it."}
188+ </span>
189+ </div>
106190 )}
191+ <div className="flex flex-wrap items-start justify-between gap-4">
192+ <div>
193+ <p className="flex items-center gap-1.5 text-sm text-muted">
194+ <Wallet size={14} />
195+ Every agent, {monthName(overview.spend.period)}
196+ </p>
197+ <p className="mt-1 flex flex-wrap items-baseline gap-x-2">
198+ <span className="text-3xl font-semibold tracking-tight tabular-nums">{money(spent)}</span>
199+ <span className="text-sm text-muted">{policy.monthly_micros != null ? `of ${money(policy.monthly_micros)}` : "No agent budget: the workspace's spend limit applies"}</span>
200+ </p>
201+ </div>
202+ {overview.can_manage && (
203+ <BudgetDialog
204+ policy={policy}
205+ action={`/${slug}/-/agents?index`}
206+ trigger={
207+ <button type="button" className={`${BUTTONS.QUIET} h-9 py-0`}>
208+ <Coins size={15} />
209+ Budget
210+ </button>
211+ }
212+ />
213+ )}
214+ </div>
215+ {policy.monthly_micros != null && (
216+ <>
217+ <Meter spent={spent} cap={policy.monthly_micros} label="Spent of the agents' monthly budget" className="mt-4" />
218+ <p className="mt-2 text-xs text-faint">
219+ {Math.round((share ?? 0) * 100)}% used{tone === "accent" ? "" : tone === "warn" ? ", past the first alert" : ", at the limit"}. Every session, routine and helper rolls up here, then into the workspace&apos;s bill.
220+ </p>
221+ </>
222+ )}
223+ <dl className="mt-5 grid grid-cols-2 gap-4 border-t border-line pt-4 sm:grid-cols-4">
224+ <Stat label="Working now" value={String(liveCount)} />
225+ <Stat label="Waiting on approval" value={String(overview.waiting_on_you.length)} />
226+ <Stat label="Session cap" value={money(policy.default_session_micros)} />
227+ <Stat label="New agent budget" value={policy.default_agent_monthly_micros != null ? money(policy.default_agent_monthly_micros) : "None"} />
228+ </dl>
229+ </section>
230+ );
231+}
107232
108− {done.length > 0 && (
109− <section>
110− <h2 className="text-sm font-medium">Recently finished</h2>
111− <ul className="mt-3 space-y-3">
112− {done.slice(0, 30).map((run) => (
113− <RunCard key={run.id} run={run} member showRepo />
233+function Stat({ label, value }: { label: string; value: string }) {
234+ return (
235+ <div>
236+ <dt className="text-xs text-faint">{label}</dt>
237+ <dd className="mt-0.5 text-sm font-medium tabular-nums">{value}</dd>
238+ </div>
239+ );
240+}
241+
242+/** Every agent, g1t first: its status, what it is working on and its month against its own budget. */
243+function Roster({ slug, agents, live }: { slug: string; agents: WorkspaceAgent[]; live: Record<string, number> }) {
244+ const sorted = [...agents].sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)));
245+ return (
246+ <section aria-labelledby="roster">
247+ <div className="flex items-baseline justify-between gap-3">
248+ <h2 id="roster" className="text-sm font-medium">
249+ Agents <span className="text-faint">{agents.length}</span>
250+ </h2>
251+ <Link to={`/${slug}/-/agents/new`} className="text-xs text-muted hover:text-fg">
252+ Hire from a template
253+ </Link>
254+ </div>
255+ <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
256+ {sorted.map((agent) => {
257+ const count = live[agent.id] ?? 0;
258+ const cap = agent.budget.monthly_micros;
259+ return (
260+ <li key={agent.id} className="relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
261+ <span className="relative shrink-0">
262+ <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={32} />
263+ <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 ring-2 ring-surface" />
264+ </span>
265+ <div className="min-w-0 grow">
266+ <p className="flex items-center gap-2 text-sm">
267+ <Link to={`/${slug}/-/agents/${agent.handle}`} className="truncate font-medium after:absolute after:inset-0 hover:underline">
268+ {agent.display_name}
269+ </Link>
270+ {count > 0 && (
271+ <Badge tone="accent">
272+ {count} {count === 1 ? "session" : "sessions"}
273+ </Badge>
274+ )}
275+ </p>
276+ <p className="truncate text-xs text-faint">
277+ {isOrchestrator(agent) ? "Orchestrator" : agent.title || agent.role}
278+ {agent.status !== "idle" && ` · ${statusLabel(agent.status)}`}
279+ </p>
280+ </div>
281+ <div className="w-28 shrink-0 text-right sm:w-40">
282+ <p className="text-xs">
283+ <SpendOfCap spent={agent.spent_month_micros} cap={cap} />
284+ </p>
285+ {cap != null && <Meter spent={agent.spent_month_micros} cap={cap} label={`${agent.display_name}'s spend of its monthly budget`} size="sm" className="mt-1.5" />}
286+ </div>
287+ </li>
288+ );
289+ })}
290+ </ul>
291+ </section>
292+ );
293+}
294+
295+/** Where the month went: by day, by agent, by team, by kind of work and by who asked. */
296+function SpendSection({ overview, slug, handles }: { overview: AgentsOverview; slug: string; handles: Map<string, WorkspaceAgent> }) {
297+ const { spend } = overview;
298+ return (
299+ <section aria-labelledby="spend" className="space-y-4">
300+ <div className="flex items-baseline justify-between gap-3">
301+ <h2 id="spend" className="text-sm font-medium">
302+ Spend this month
303+ </h2>
304+ <Link to={`/${slug}/-/usage`} className="text-xs text-muted hover:text-fg">
305+ On the workspace&apos;s usage
306+ </Link>
307+ </div>
308+ <Panel title={<span className="tabular-nums">{money(spend.total_micros)}</span>} aside="By day, UTC">
309+ <DailyBars period={spend.period} days={spend.days} className="px-4 pt-4 pb-3" />
310+ </Panel>
311+ <div className="grid gap-4 lg:grid-cols-2">
312+ <Panel title="By agent">
313+ <SliceList
314+ slices={spend.by_agent}
315+ unit=""
316+ icon={(slice) => {
317+ const agent = handles.get(slice.key);
318+ return agent ? <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={18} /> : null;
319+ }}
320+ href={(slice) => {
321+ const agent = handles.get(slice.key);
322+ return agent ? `/${slug}/-/agents/${agent.handle}/spend` : null;
323+ }}
324+ />
325+ </Panel>
326+ <Panel title="By team">
327+ <SliceList slices={spend.by_team} />
328+ </Panel>
329+ <Panel title="By kind of work">
330+ <SliceList slices={spend.by_kind} unit="runs" />
331+ </Panel>
332+ <Panel title="By who asked" aside="Routines and agent-to-agent work have no asker">
333+ <SliceList slices={spend.by_person} unit="runs" />
334+ </Panel>
335+ </div>
336+ {spend.top_sessions.length > 0 && (
337+ <Panel title="Costliest sessions">
338+ <ul className="divide-y divide-line/60">
339+ {spend.top_sessions.map((session) => (
340+ <SessionRow key={session.id} slug={slug} session={session} showAgent />
114341 ))}
115342 </ul>
116− </section>
343+ </Panel>
117344 )}
118− </div>
345+ </section>
119346 );
120347 }
+108−16
1−import { History } from "lucide-react";
2−import { Link, useOutletContext, useParams } from "react-router";
1+import { History, Lock, MessageSquare, Workflow } from "lucide-react";
2+import { Link, data, useOutletContext } from "react-router";
33
4−import type { WorkspaceAgent } from "@g1t/contracts";
4+import type { AgentActivity, WorkspaceAgent } from "@g1t/contracts";
55
6−/** Everything the agent did, from the audit log. */
7−export default function ActivityTab() {
6+import type { Route } from "./+types/activity";
7+import { readOrNull } from "../../../components/agents/actions.server";
8+import { Quiet, StatusChip } from "../../../components/agents/parts";
9+import { TimeAgo } from "../../../components/ui";
10+import { channelPath } from "../../../lib/chat";
11+import { workspaceAgents } from "../../../lib/services.server";
12+import { requireUser, roleIn } from "../../../lib/session.server";
13+import { money } from "../../../lib/usage";
14+
15+/** Everything the agent did lately: its replies and its sessions, newest first. */
16+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ activity: AgentActivity[] | null }> {
17+ const viewer = requireUser(context, request);
18+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
19+ return { activity: await readOrNull(workspaceAgents.activity(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)) };
20+}
21+
22+const REPLY_STATUS: Record<string, string> = { working: "Writing…", failed: "Failed", blocked: "Held back: budget or access" };
23+
24+export default function ActivityTab({ loaderData, params }: Route.ComponentProps) {
825 const agent = useOutletContext<WorkspaceAgent>();
9− const { owner = "" } = useParams();
26+ const { activity } = loaderData;
27+ if (!activity) return <Quiet title="Activity can't be shown right now">The agents service didn&apos;t answer. Reload in a moment.</Quiet>;
28+ if (activity.length === 0) {
29+ return (
30+ <Quiet title="Nothing yet" icon={<History size={20} />}>
31+ Every reply and session {agent.display_name} takes on shows here, with who asked, the model and what it cost.{" "}
32+ <Link to={`/${params.owner}/-/audit`} className="text-accent hover:underline">
33+ The audit log
34+ </Link>{" "}
35+ has every tool call.
36+ </Quiet>
37+ );
38+ }
1039 return (
11− <div className="rounded-2xl border border-dashed border-line px-6 py-14 text-center">
12− <span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
13− <History size={22} />
14− </span>
15− <h2 className="mt-4 text-base font-semibold">Nothing yet</h2>
16− <p className="mx-auto mt-1.5 max-w-md text-sm text-muted">
17− Every reply, task, pull request and approval {agent.display_name} takes part in shows here, with the version of its profile that did it.
40+ <div className="space-y-4">
41+ <ul className="divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface">
42+ {activity.map((item) => (
43+ <Item key={`${item.kind}:${item.id}`} item={item} slug={params.owner} handle={agent.handle} />
44+ ))}
45+ </ul>
46+ <p className="text-xs text-faint">
47+ The latest replies and sessions. Every tool call is in the{" "}
48+ <Link to={`/${params.owner}/-/audit`} className="text-muted hover:text-fg hover:underline">
49+ audit log
50+ </Link>
51+ .
1852 </p>
19− <Link to={`/${owner}/-/audit`} className="mt-5 inline-block text-sm text-accent hover:underline">
20− Open the workspace&apos;s audit log
21− </Link>
2253 </div>
2354 );
2455 }
56+
57+function Item({ item, slug, handle }: { item: AgentActivity; slug: string; handle: string }) {
58+ const where = item.visible ? (item.channel_name ? `#${item.channel_name}` : "A direct message") : null;
59+ const href = !item.visible
60+ ? null
61+ : item.kind === "session"
62+ ? `/${slug}/-/agents/${handle}/sessions/${item.id}`
63+ : item.ref
64+ ? `${channelPath(slug, { id: item.channel_id, kind: item.channel_name ? "channel" : "dm", name: item.channel_name })}?thread=${encodeURIComponent(item.ref)}`
65+ : null;
66+ const headline = !item.visible ? (
67+ <span className="inline-flex items-center gap-1.5 text-muted italic">
68+ <Lock size={12} className="not-italic" />
69+ In a conversation you&apos;re not in
70+ </span>
71+ ) : item.kind === "session" ? (
72+ <span className="font-medium">{item.title ?? "A session"}</span>
73+ ) : (
74+ <span>
75+ Replied{item.asked_by_username ? <> to @{item.asked_by_username}</> : null} in {where}
76+ </span>
77+ );
78+ return (
79+ <li className="relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
80+ <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-raised text-muted">
81+ {item.kind === "session" ? <Workflow size={14} /> : <MessageSquare size={14} />}
82+ </span>
83+ <div className="min-w-0 grow">
84+ <p className="flex flex-wrap items-center gap-2 text-sm">
85+ {href ? (
86+ <Link to={href} className="min-w-0 truncate after:absolute after:inset-0 hover:underline">
87+ {headline}
88+ </Link>
89+ ) : (
90+ headline
91+ )}
92+ {item.kind === "session" ? (
93+ <StatusChip status={item.status} />
94+ ) : (
95+ item.status !== "replied" && <span className="text-xs text-faint">{REPLY_STATUS[item.status] ?? item.status.replaceAll("_", " ")}</span>
96+ )}
97+ </p>
98+ <p className="mt-0.5 flex flex-wrap gap-x-1.5 text-xs text-faint">
99+ <span>{item.kind === "session" ? "Session" : "Reply"}</span>
100+ {item.kind === "session" && where && <span>· {where}</span>}
101+ {item.kind === "session" && item.visible && item.asked_by_username && <span>· asked by @{item.asked_by_username}</span>}
102+ {item.model && <span className="font-mono">· {item.model}</span>}
103+ {item.tools > 0 && (
104+ <span>
105+ · {item.tools} {item.tools === 1 ? "tool" : "tools"}
106+ </span>
107+ )}
108+ <span>
109+ · <TimeAgo at={item.created_at} />
110+ </span>
111+ </p>
112+ </div>
113+ <span className="shrink-0 text-sm tabular-nums">{money(item.charged_micros)}</span>
114+ </li>
115+ );
116+}
+10−4
101101 <MessageAgent slug={params.owner} agent={agent} variant="accent" />
102102 </header>
103103 <nav aria-label={`${agent.display_name}'s pages`} className="mt-8 flex gap-1 overflow-x-auto border-b border-line [scrollbar-width:none]">
104− <TabLink to={base} end icon={null}>
105− Desk
104+ <TabLink to={base} end also={`${base}/sessions`} icon={null}>
105+ Sessions
106+ </TabLink>
107+ <TabLink to={`${base}/memory`} icon={null}>
108+ Memory
106109 </TabLink>
107− <TabLink to={`${base}/profile`} icon={null}>
108− Profile
110+ <TabLink to={`${base}/routines`} icon={null}>
111+ Routines
109112 </TabLink>
110113 <TabLink to={`${base}/spend`} icon={null}>
111114 Spend
113116 <TabLink to={`${base}/activity`} icon={null}>
114117 Activity
115118 </TabLink>
119+ <TabLink to={`${base}/profile`} icon={null}>
120+ Profile
121+ </TabLink>
116122 </nav>
117123 <div className="pt-8">
118124 <Outlet context={agent} />
+0−45
1−import { useOutletContext, useParams } from "react-router";
2−
3−import type { WorkspaceAgent } from "@g1t/contracts";
4−
5−import { MessageAgent } from "./agent";
6−import { AgentAvatar } from "../../../components/agent-avatar";
7−import { isOrchestrator } from "../../../components/orchestrator";
8−
9−/** An empty desk, in the agent's own voice. */
10−const EMPTY_DESK: Record<string, string> = {
11− crisp: "Nothing on my desk. Send me something and I'll take it from there.",
12− friendly: "My desk is all clear! Send me something to work on and I'll get going.",
13− socratic: "Nothing here yet. What would you like me to look at first?",
14− terse: "Desk empty. Send work.",
15−};
16−
17−/**
18− * Every task the agent holds, as live cards. Tasks arrive with the desk
19− * (docs/WORKSPACE.md, "Tasks"); until then, the way to give it one.
20− */
21−export default function Desk() {
22− const agent = useOutletContext<WorkspaceAgent>();
23− const { owner = "" } = useParams();
24− return (
25− <div className="rounded-2xl border border-dashed border-line bg-surface/40 px-6 py-16 text-center">
26− <span className="mx-auto flex justify-center">
27− <AgentAvatar agent={{ ...agent, builtin: isOrchestrator(agent) }} size={52} />
28− </span>
29− <h2 className="mt-4 text-base font-semibold">No tasks yet</h2>
30− <blockquote className="mx-auto mt-3 max-w-md text-[0.9375rem] text-fg-soft">
31− &ldquo;{EMPTY_DESK[agent.personality_preset] ?? EMPTY_DESK.crisp}&rdquo;
32− <footer className="mt-1 text-xs text-faint">{agent.display_name}</footer>
33− </blockquote>
34− <p className="mx-auto mt-4 max-w-md text-sm text-muted">
35− DM {agent.display_name} to give it one. Each task shows here with its steps, cost and what it&apos;s waiting on.
36− </p>
37− <div className="mt-6 flex justify-center">
38− <MessageAgent slug={owner} agent={agent} variant="accent" />
39− </div>
40− <p className="mt-4 text-xs text-faint">
41− Works up to {agent.capacity} {agent.capacity === 1 ? "task" : "tasks"} at once; more wait here in line.
42− </p>
43− </div>
44− );
45−}
+16−5
88 import { workspaceAgents } from "../../../lib/services.server";
99 import { getViewer, roleIn } from "../../../lib/session.server";
1010
11−/** Agents mode: the workspace's agents, for its sidebar; null when the agents service does not answer. */
12−export type AgentsLayoutData = { slug: string; agents: WorkspaceAgent[] | null };
11+/**
12+ * Agents mode: the workspace's agents, for its sidebar, and how many live
13+ * sessions each has (by agent id); null when the agents service does not answer.
14+ */
15+export type AgentsLayoutData = { slug: string; agents: WorkspaceAgent[] | null; live: Record<string, number> };
1316
1417 export async function loader({ params, context }: Route.LoaderArgs): Promise<AgentsLayoutData> {
1518 const viewer = getViewer(context);
1619 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
17− const listed = await workspaceAgents.list(params.owner.toLowerCase(), viewer!).catch(() => null);
20+ const slug = params.owner.toLowerCase();
21+ const [listed, live] = await Promise.all([
22+ workspaceAgents.list(slug, viewer!).catch(() => null),
23+ // Every live session counts, private ones too: a count says nothing about what it is.
24+ workspaceAgents.sessions(slug, viewer!, { status: "live", limit: 200 }).catch(() => null),
25+ ]);
26+ const counts: Record<string, number> = {};
27+ for (const session of live?.ok ? live.value : []) counts[session.agent_id] = (counts[session.agent_id] ?? 0) + 1;
1828 return {
19− slug: params.owner.toLowerCase(),
29+ slug,
30+ live: counts,
2031 agents: listed?.ok ? listed.value.filter((agent) => !agent.archived_at).sort((a, b) => a.display_name.localeCompare(b.display_name)) : null,
2132 };
2233 }
2334
24−/** The list changes when an agent is made or changed, not when moving between them. */
35+/** The list changes when an agent is made or changed, or work is stopped or started; not when moving between them. */
2536 export function shouldRevalidate({ currentParams, nextParams, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
2637 if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
2738 return currentParams.owner !== nextParams.owner;
+235−0
1+import { Bot, Brain, Pencil, Pin, PinOff, Trash2, User as UserIcon } from "lucide-react";
2+import { useEffect, useRef, useState } from "react";
3+import { data, useFetcher, useOutletContext } from "react-router";
4+
5+import type { AgentMemory, AgentMemoryScope, WorkspaceAgent } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/memory";
8+import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
9+import { type ActionResult, BUTTONS, Confirm } from "../../../components/agents/dialogs";
10+import { memoryGroups } from "../../../components/agents/format";
11+import { Quiet } from "../../../components/agents/parts";
12+import { TimeAgo } from "../../../components/ui";
13+import { Hint } from "../../../components/ui/hint";
14+import { SelectField } from "../../../components/ui/select";
15+import { Textarea } from "../../../components/ui/textarea";
16+import { sidebarOrNull } from "../../../lib/chat.server";
17+import { workspaceAgents } from "../../../lib/services.server";
18+import { requireUser, roleIn } from "../../../lib/session.server";
19+
20+/** The longest fact the agents service keeps. */
21+const MAX_FACT = 500;
22+
23+/** What the agent remembers that the viewer may see, and the channels they could give it facts for. */
24+export async function loader({ params, context, request }: Route.LoaderArgs) {
25+ const viewer = requireUser(context, request);
26+ const role = roleIn(viewer, params.owner);
27+ if (!role) throw data(null, { status: 404 });
28+ const slug = params.owner.toLowerCase();
29+ const [memories, sidebar] = await Promise.all([readOrNull(workspaceAgents.memories(slug, params.handle.toLowerCase(), viewer)), sidebarOrNull(slug, viewer)]);
30+ const channels = (sidebar?.entries ?? [])
31+ .filter((entry) => entry.channel.kind === "channel" && entry.channel.name)
32+ .map((entry) => ({ id: entry.channel.id, name: entry.channel.name! }))
33+ .sort((a, b) => a.name.localeCompare(b.name));
34+ return { memories, channels, isOwner: role === "owner", me: viewer.username };
35+}
36+
37+export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
38+ const { viewer, slug, form } = await agentsAction(request, context, params.owner);
39+ const handle = params.handle.toLowerCase();
40+ const intent = String(form.get("intent") ?? "");
41+ const id = String(form.get("id") ?? "");
42+ if (intent === "remember") {
43+ const body = String(form.get("body") ?? "").trim();
44+ if (!body) return { ok: false, intent, error: "Write what it should remember." };
45+ const asked = String(form.get("scope") ?? "person");
46+ const scope: AgentMemoryScope = asked === "workspace" || asked === "channel" ? asked : "person";
47+ const channel = String(form.get("channel") ?? "");
48+ if (scope === "channel" && !channel) return { ok: false, intent, error: "Choose the channel it's for." };
49+ return answer(intent, workspaceAgents.remember(slug, handle, viewer, { body, scope, scope_ref: scope === "channel" ? channel : null }));
50+ }
51+ if (intent === "edit") return answer(intent, workspaceAgents.updateMemory(slug, handle, viewer, id, { body: String(form.get("body") ?? "") }));
52+ if (intent === "pin") return answer(intent, workspaceAgents.updateMemory(slug, handle, viewer, id, { pinned: form.get("pinned") === "true" }));
53+ if (intent === "forget") return answer(intent, workspaceAgents.forget(slug, handle, viewer, id));
54+ return { ok: false, intent, error: "Unknown request." };
55+}
56+
57+/**
58+ * What the agent remembers, by where it may be recalled: the whole
59+ * workspace, one conversation, or only the viewer's own DMs with it. Each
60+ * fact carries where it came from.
61+ */
62+export default function MemoryTab({ loaderData }: Route.ComponentProps) {
63+ const agent = useOutletContext<WorkspaceAgent>();
64+ const { memories, channels, isOwner } = loaderData;
65+ if (!memories) return <Quiet title="Memory can't be shown right now">The agents service didn&apos;t answer. Reload in a moment.</Quiet>;
66+ const groups = memoryGroups(memories);
67+ return (
68+ <div className="space-y-8">
69+ <p className="max-w-2xl text-sm text-muted">
70+ {agent.display_name} has no hidden memory: only these facts, each recalled only where it belongs. <strong className="font-medium text-fg-soft">Workspace</strong> facts
71+ are recalled anywhere; a <strong className="font-medium text-fg-soft">conversation</strong>&apos;s only there; <strong className="font-medium text-fg-soft">just you</strong>{" "}
72+ only in your direct messages with it. You see the facts from conversations you&apos;re in.
73+ </p>
74+ <AddFact agent={agent} channels={channels} isOwner={isOwner} />
75+ {groups.length === 0 ? (
76+ <Quiet title="Nothing remembered yet" icon={<Brain size={20} />}>
77+ When a session learns something worth keeping, or someone tells {agent.display_name} to remember it, the fact shows here with where it came from.
78+ </Quiet>
79+ ) : (
80+ groups.map((group) => (
81+ <section key={group.scope}>
82+ <div className="flex flex-wrap items-baseline gap-x-3">
83+ <h2 className="text-sm font-medium">
84+ {group.title} <span className="text-faint">{group.memories.length}</span>
85+ </h2>
86+ <p className="text-xs text-faint">{group.about}</p>
87+ </div>
88+ <ul className="mt-3 divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface">
89+ {group.memories.map((memory) => (
90+ <Fact key={memory.id} memory={memory} agent={agent} canChange={memory.scope !== "workspace" || isOwner} />
91+ ))}
92+ </ul>
93+ </section>
94+ ))
95+ )}
96+ </div>
97+ );
98+}
99+
100+function AddFact({ agent, channels, isOwner }: { agent: WorkspaceAgent; channels: { id: string; name: string }[]; isOwner: boolean }) {
101+ const fetcher = useFetcher<ActionResult>({ key: "remember" });
102+ const form = useRef<HTMLFormElement>(null);
103+ const [scope, setScope] = useState<AgentMemoryScope>("person");
104+ const busy = fetcher.state !== "idle";
105+ useEffect(() => {
106+ if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset();
107+ }, [fetcher.state, fetcher.data]);
108+ const options = [
109+ { value: "person", label: "Just me", description: "Recalled only in your direct messages with it." },
110+ ...(isOwner ? [{ value: "workspace", label: "This workspace", description: "Recalled anywhere, for anyone. Owners only." }] : []),
111+ ...(channels.length > 0 ? [{ value: "channel", label: "A channel", description: "Recalled only in that channel and its threads." }] : []),
112+ ];
113+ return (
114+ <fetcher.Form ref={form} method="post" className="rounded-xl border border-line bg-surface p-4">
115+ <input type="hidden" name="intent" value="remember" />
116+ <label htmlFor="fact" className="text-sm font-medium">
117+ Add a fact
118+ </label>
119+ <Textarea
120+ id="fact"
121+ name="body"
122+ rows={2}
123+ maxLength={MAX_FACT}
124+ required
125+ placeholder={`Something ${agent.display_name} should know, such as: Releases go out on Thursdays.`}
126+ className="mt-2"
127+ />
128+ <div className="mt-3 flex flex-wrap items-center gap-2">
129+ <div className="w-44">
130+ <SelectField name="scope" value={scope} onValueChange={(v) => setScope(v as AgentMemoryScope)} options={options} size="sm" aria-label="Who it's recalled for" />
131+ </div>
132+ {scope === "channel" && (
133+ <div className="w-48">
134+ <SelectField name="channel" options={channels.map((c) => ({ value: c.id, label: `#${c.name}` }))} placeholder="Choose a channel" size="sm" aria-label="Channel" required />
135+ </div>
136+ )}
137+ <span className="grow text-xs text-danger">{fetcher.data && !fetcher.data.ok && fetcher.data.intent === "remember" ? fetcher.data.error : ""}</span>
138+ <button type="submit" disabled={busy} className={`${BUTTONS.PRIMARY} h-8 py-0`}>
139+ {busy ? "Saving…" : "Remember"}
140+ </button>
141+ </div>
142+ </fetcher.Form>
143+ );
144+}
145+
146+function Fact({ memory, agent, canChange }: { memory: AgentMemory; agent: WorkspaceAgent; canChange: boolean }) {
147+ const [editing, setEditing] = useState(false);
148+ const edit = useFetcher<ActionResult>({ key: `edit-${memory.id}` });
149+ const pin = useFetcher<ActionResult>({ key: `pin-${memory.id}` });
150+ useEffect(() => {
151+ if (edit.state === "idle" && edit.data?.ok) setEditing(false);
152+ }, [edit.state, edit.data]);
153+ const pinned = pin.formData ? pin.formData.get("pinned") === "true" : memory.pinned;
154+ const by =
155+ memory.created_by_kind === "agent" ? (
156+ <span className="inline-flex items-center gap-1">
157+ <Bot size={11} />
158+ Kept by {memory.created_by === agent.handle ? agent.display_name : `@${memory.created_by}`}
159+ </span>
160+ ) : (
161+ <span className="inline-flex items-center gap-1">
162+ <UserIcon size={11} />
163+ Told by @{memory.created_by}
164+ </span>
165+ );
166+ return (
167+ <li className="group px-4 py-3">
168+ {editing ? (
169+ <edit.Form method="post" className="space-y-2">
170+ <input type="hidden" name="intent" value="edit" />
171+ <input type="hidden" name="id" value={memory.id} />
172+ <Textarea name="body" defaultValue={memory.body} rows={2} maxLength={MAX_FACT} required autoFocus aria-label="The fact" />
173+ <div className="flex items-center justify-end gap-2">
174+ <span className="grow text-xs text-danger">{edit.data && !edit.data.ok ? edit.data.error : ""}</span>
175+ <button type="button" className={`${BUTTONS.QUIET} h-8 py-0`} onClick={() => setEditing(false)}>
176+ Cancel
177+ </button>
178+ <button type="submit" className={`${BUTTONS.PRIMARY} h-8 py-0`} disabled={edit.state !== "idle"}>
179+ {edit.state !== "idle" ? "Saving…" : "Save"}
180+ </button>
181+ </div>
182+ </edit.Form>
183+ ) : (
184+ <div className="flex items-start gap-3">
185+ {pinned && (
186+ <Hint label="Pinned: always recalled where it belongs">
187+ <Pin size={13} className="mt-1 shrink-0 text-accent" aria-label="Pinned" />
188+ </Hint>
189+ )}
190+ <div className="min-w-0 grow">
191+ <p className="text-sm text-fg">{memory.body}</p>
192+ <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-faint">
193+ {memory.scope === "channel" && memory.scope_label && <span className="text-muted">#{memory.scope_label}</span>}
194+ {by}
195+ {memory.source_label && <span>from {memory.source_label}</span>}
196+ <TimeAgo at={memory.updated_at} />
197+ </p>
198+ </div>
199+ {canChange && (
200+ <div className="flex shrink-0 items-center gap-0.5 opacity-100 transition-opacity sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
201+ <pin.Form method="post">
202+ <input type="hidden" name="intent" value="pin" />
203+ <input type="hidden" name="id" value={memory.id} />
204+ <input type="hidden" name="pinned" value={pinned ? "false" : "true"} />
205+ <Hint label={pinned ? "Unpin" : "Pin"}>
206+ <button type="submit" aria-label={pinned ? "Unpin" : "Pin"} className="flex size-7 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
207+ {pinned ? <PinOff size={14} /> : <Pin size={14} />}
208+ </button>
209+ </Hint>
210+ </pin.Form>
211+ <Hint label="Edit">
212+ <button type="button" aria-label="Edit" onClick={() => setEditing(true)} className="flex size-7 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
213+ <Pencil size={14} />
214+ </button>
215+ </Hint>
216+ <Confirm
217+ title="Forget this fact?"
218+ confirm="Forget"
219+ fields={{ intent: "forget", id: memory.id }}
220+ fetcherKey={`forget-${memory.id}`}
221+ trigger={
222+ <button type="button" aria-label="Forget" className="flex size-7 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-danger">
223+ <Trash2 size={14} />
224+ </button>
225+ }
226+ >
227+ {agent.display_name} won&apos;t recall &ldquo;{memory.body}&rdquo; again, anywhere. It can learn it again if it comes up.
228+ </Confirm>
229+ </div>
230+ )}
231+ </div>
232+ )}
233+ </li>
234+ );
235+}
+94−4
1+import { ChevronRight, History } from "lucide-react";
12 import { useState } from "react";
23 import { Form, data, redirect, useNavigation, useOutletContext } from "react-router";
34
4−import type { WorkspaceAgent } from "@g1t/contracts";
5+import type { AgentVersion, WorkspaceAgent } from "@g1t/contracts";
56
67 import type { Route } from "./+types/profile";
78 import { AgentForm } from "../../../components/agents-mode";
9+import { readOrNull } from "../../../components/agents/actions.server";
10+import { versionChanges } from "../../../components/agents/format";
11+import { TimeAgo } from "../../../components/ui";
812 import { isOrchestrator } from "../../../components/orchestrator";
913 import { readAgentForm } from "../../../lib/agent-form";
1014 import { identity, workspaceAgents } from "../../../lib/services.server";
1115 import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
1216
13−/** The workspace's teams, to put the agent on one. */
17+/** The workspace's teams, to put the agent on one, and its saved versions. */
1418 export async function loader({ params, context, request }: Route.LoaderArgs) {
1519 const viewer = requireUser(context, request);
16− const teams = await identity.listTeams(viewer, params.owner).catch(() => null);
17− return { teams: teams?.ok ? teams.value.map((team) => ({ slug: team.slug, name: team.name })) : [] };
20+ const [teams, versions] = await Promise.all([
21+ identity.listTeams(viewer, params.owner).catch(() => null),
22+ readOrNull(workspaceAgents.versions(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)),
23+ ]);
24+ return { teams: teams?.ok ? teams.value.map((team) => ({ slug: team.slug, name: team.name })) : [], versions };
1825 }
1926
2027 /** Saving makes a new version; every run records which one it ran. */
6370 teams={loaderData.teams}
6471 seed={agent.avatar_seed || agent.id}
6572 />
73+ {loaderData.versions && loaderData.versions.length > 0 && <Versions versions={loaderData.versions} current={agent.version} />}
6674 {!isOrchestrator(agent) && <Archive name={agent.display_name} />}
6775 </>
6876 );
110118 </section>
111119 );
112120 }
121+
122+/**
123+ * Every saved version of the agent, newest first: who saved it, when, and
124+ * what changed from the one before. Each run and reply records the version
125+ * it ran with.
126+ */
127+function Versions({ versions, current }: { versions: AgentVersion[]; current: number }) {
128+ const [open, setOpen] = useState(false);
129+ const sorted = [...versions].sort((a, b) => b.version - a.version);
130+ const shown = open ? sorted : sorted.slice(0, 3);
131+ return (
132+ <section className="mt-12 border-t border-line pt-8 md:grid md:grid-cols-[14rem_1fr] md:gap-10">
133+ <div className="mb-4 md:mb-0">
134+ <h2 className="flex items-center gap-2 text-sm font-semibold text-fg">
135+ <History size={14} className="text-faint" />
136+ Version history
137+ </h2>
138+ <p className="mt-1 text-sm text-muted">Every save is a new version. Sessions and replies record the one they ran with.</p>
139+ </div>
140+ <div>
141+ <ol className="divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface">
142+ {shown.map((version) => {
143+ const before = sorted.find((v) => v.version < version.version) ?? null;
144+ const definition = version.definition as Record<string, unknown>;
145+ return (
146+ <li key={version.version} className="px-4 py-3">
147+ <details className="group">
148+ <summary className="flex cursor-pointer list-none items-center gap-3 text-sm [&::-webkit-details-marker]:hidden">
149+ <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
150+ <span className="font-medium tabular-nums">Version {version.version}</span>
151+ {version.version === current && <span className="rounded-full bg-accent/15 px-1.5 py-px text-[0.6875rem] font-medium text-accent">Current</span>}
152+ <span className="min-w-0 grow truncate text-muted">
153+ {before ? versionChanges(before.definition as Record<string, unknown>, definition) : version.version === 1 ? "Created" : "Saved"}
154+ </span>
155+ <span className="shrink-0 text-xs text-faint">
156+ @{version.changed_by} · <TimeAgo at={version.created_at} />
157+ </span>
158+ </summary>
159+ <dl className="mt-3 grid gap-x-6 gap-y-2 pl-7 text-xs sm:grid-cols-2">
160+ {summaryOf(definition).map(([label, value]) => (
161+ <div key={label} className="min-w-0">
162+ <dt className="text-faint">{label}</dt>
163+ <dd className="mt-0.5 line-clamp-3 text-fg-soft">{value}</dd>
164+ </div>
165+ ))}
166+ </dl>
167+ </details>
168+ </li>
169+ );
170+ })}
171+ </ol>
172+ {sorted.length > 3 && (
173+ <button type="button" onClick={() => setOpen(!open)} className="mt-3 text-sm text-muted hover:text-fg">
174+ {open ? "Show fewer" : `Show all ${sorted.length} versions`}
175+ </button>
176+ )}
177+ </div>
178+ </section>
179+ );
180+}
181+
182+/** A saved definition, in a few lines. */
183+function summaryOf(definition: Record<string, unknown>): [string, string][] {
184+ const out: [string, string][] = [];
185+ const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null);
186+ const name = text(definition.display_name);
187+ if (name) out.push(["Name", `${name}${text(definition.handle) ? ` (@${definition.handle})` : ""}`]);
188+ const title = text(definition.title) ?? text(definition.role);
189+ if (title) out.push(["Role", title]);
190+ const job = text(definition.instructions);
191+ if (job) out.push(["Job", job]);
192+ const voice = text(definition.personality_preset);
193+ if (voice) out.push(["Personality", [voice, text(definition.personality)].filter(Boolean).join(" · ")]);
194+ const budget = definition.budget as { monthly_micros?: number | null; task_micros?: number | null } | undefined;
195+ if (budget && (budget.monthly_micros != null || budget.task_micros != null)) {
196+ const dollars = (m: number) => `$${(m / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
197+ out.push(["Budget", [budget.monthly_micros != null ? `${dollars(budget.monthly_micros)} a month` : null, budget.task_micros != null ? `${dollars(budget.task_micros)} a session` : null].filter(Boolean).join(", ")]);
198+ }
199+ const duties = Array.isArray(definition.responsibilities) ? (definition.responsibilities as string[]) : [];
200+ if (duties.length > 0) out.push(["Responsibilities", duties.join("; ")]);
201+ return out;
202+}
+265−0
1+import { AlertTriangle, CalendarClock, Lightbulb, Pencil, Play, Plus, Trash2 } from "lucide-react";
2+import { Link, data, useFetcher, useOutletContext } from "react-router";
3+
4+import { type AgentRoutine, type NewRoutine, ROUTINE_EVENTS, type RoutineEvent, type RoutineSuggestion, type WorkspaceAgent } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/routines";
7+import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
8+import { type ActionResult, BUTTONS, Confirm } from "../../../components/agents/dialogs";
9+import { reposFrom, routineInWords, scheduleFrom, untilLabel } from "../../../components/agents/format";
10+import { Quiet } from "../../../components/agents/parts";
11+import { type RoutineChannel, RoutineDialog } from "../../../components/agents/routine-dialog";
12+import { TimeAgo } from "../../../components/ui";
13+import { Hint } from "../../../components/ui/hint";
14+import { Switch } from "../../../components/ui/switch";
15+import { agentDmOf } from "../../../lib/chat";
16+import { sidebarOrNull } from "../../../lib/chat.server";
17+import { workspaceAgents } from "../../../lib/services.server";
18+import { requireUser, roleIn } from "../../../lib/session.server";
19+
20+const EVENT_LABELS: Record<string, string> = Object.fromEntries(ROUTINE_EVENTS.map((e) => [e.key, e.label]));
21+
22+/** The agent's routines, the ones its responsibilities suggest, and the conversations a routine could post in. */
23+export async function loader({ params, context, request }: Route.LoaderArgs) {
24+ const viewer = requireUser(context, request);
25+ const role = roleIn(viewer, params.owner);
26+ if (!role) throw data(null, { status: 404 });
27+ const slug = params.owner.toLowerCase();
28+ const isOwner = role === "owner";
29+ const [listed, sidebar, agent] = await Promise.all([
30+ readOrNull(workspaceAgents.routines(slug, params.handle.toLowerCase(), viewer)),
31+ // Only owners set routines up, so only they need the channels.
32+ isOwner ? sidebarOrNull(slug, viewer) : Promise.resolve(null),
33+ readOrNull(workspaceAgents.get(slug, params.handle.toLowerCase(), viewer)),
34+ ]);
35+ const channels: RoutineChannel[] = (sidebar?.entries ?? [])
36+ .filter((entry) => (entry.channel.kind === "channel" && entry.channel.name) || (agent && agentDmOf(entry) === agent.id))
37+ .map((entry) => ({ id: entry.channel.id, label: entry.channel.kind === "channel" ? `#${entry.channel.name}` : `Your direct message with ${agent?.display_name ?? "it"}` }))
38+ .sort((a, b) => Number(b.label.startsWith("#")) - Number(a.label.startsWith("#")) || a.label.localeCompare(b.label));
39+ return { routines: listed?.routines ?? null, suggestions: listed?.suggestions ?? [], channels, isOwner };
40+}
41+
42+/** A routine from the dialog's fields. */
43+function routineFrom(form: FormData): { ok: true; input: NewRoutine } | { ok: false; error: string } {
44+ const every = String(form.get("every") ?? "none");
45+ let schedule = null;
46+ if (every !== "none") {
47+ const time = every === "hour" ? `00:${String(Number(form.get("minute") ?? 0)).padStart(2, "0")}` : String(form.get("time") ?? "");
48+ schedule = scheduleFrom(every, time, String(form.get("weekday") ?? ""));
49+ if (!schedule) return { ok: false, error: "That schedule isn't one a routine can run on." };
50+ }
51+ const events = form.getAll("events").map(String) as RoutineEvent[];
52+ const repos = reposFrom(String(form.get("repos") ?? ""));
53+ if (!repos) return { ok: false, error: "Name repositories as workspace/name, separated by commas." };
54+ return {
55+ ok: true,
56+ input: {
57+ name: String(form.get("name") ?? ""),
58+ instructions: String(form.get("instructions") ?? ""),
59+ schedule,
60+ events,
61+ repos: events.length > 0 ? repos : [],
62+ channel_id: String(form.get("channel") ?? ""),
63+ enabled: form.get("enabled") !== "false",
64+ },
65+ };
66+}
67+
68+export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
69+ const { viewer, slug, form } = await agentsAction(request, context, params.owner);
70+ const handle = params.handle.toLowerCase();
71+ const intent = String(form.get("intent") ?? "");
72+ const id = String(form.get("id") ?? "") || null;
73+ if (intent === "save") {
74+ const read = routineFrom(form);
75+ if (!read.ok) return { ok: false, intent, error: read.error };
76+ return answer(intent, workspaceAgents.saveRoutine(slug, handle, viewer, read.input, id));
77+ }
78+ if (intent === "toggle" && id) {
79+ // Turning one on or off saves it as it is, with the switch changed.
80+ const listed = await readOrNull(workspaceAgents.routines(slug, handle, viewer));
81+ const routine = listed?.routines.find((r) => r.id === id);
82+ if (!routine) return { ok: false, intent, error: "There is no such routine." };
83+ const { name, instructions, schedule, events, repos, channel_id } = routine;
84+ return answer(intent, workspaceAgents.saveRoutine(slug, handle, viewer, { name, instructions, schedule, events, repos, channel_id, enabled: form.get("enabled") === "true" }, id));
85+ }
86+ if (intent === "run" && id) return answer(intent, workspaceAgents.runRoutine(slug, handle, viewer, id));
87+ if (intent === "delete" && id) return answer(intent, workspaceAgents.deleteRoutine(slug, handle, viewer, id));
88+ return { ok: false, intent, error: "Unknown request." };
89+}
90+
91+/**
92+ * Work the agent does without being asked: on a schedule, when something
93+ * happens, or both. Each run is a session in the routine's channel.
94+ */
95+export default function Routines({ loaderData, params }: Route.ComponentProps) {
96+ const agent = useOutletContext<WorkspaceAgent>();
97+ const { routines, suggestions, channels, isOwner } = loaderData;
98+ if (!routines) return <Quiet title="Routines can't be shown right now">The agents service didn&apos;t answer. Reload in a moment.</Quiet>;
99+ return (
100+ <div className="space-y-8">
101+ <div className="flex flex-wrap items-start justify-between gap-4">
102+ <p className="max-w-xl text-sm text-muted">
103+ Work {agent.display_name} does without being asked: on a schedule (UTC), when something happens, or both. Each run is a session posted in the
104+ routine&apos;s channel, paid from {agent.display_name}&apos;s budget, with the access of the owner who set it up.
105+ </p>
106+ {isOwner && (
107+ <RoutineDialog
108+ agentName={agent.display_name}
109+ draft={{}}
110+ channels={channels}
111+ title="New routine"
112+ trigger={
113+ <button type="button" className={`${BUTTONS.PRIMARY} h-9 py-0`}>
114+ <Plus size={15} />
115+ New routine
116+ </button>
117+ }
118+ />
119+ )}
120+ </div>
121+
122+ {routines.length === 0 ? (
123+ <Quiet title="No routines yet" icon={<CalendarClock size={20} />}>
124+ {isOwner ? `Add one, or start from a suggestion below.` : `An owner can give ${agent.display_name} work to do on a schedule or when something happens.`}
125+ </Quiet>
126+ ) : (
127+ <ul className="divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface">
128+ {routines.map((routine) => (
129+ <RoutineRow key={routine.id} routine={routine} agent={agent} channels={channels} isOwner={isOwner} slug={params.owner} />
130+ ))}
131+ </ul>
132+ )}
133+
134+ {isOwner && suggestions.length > 0 && <Suggestions agent={agent} suggestions={suggestions} channels={channels} />}
135+ </div>
136+ );
137+}
138+
139+function RoutineRow({ routine, agent, channels, isOwner, slug }: { routine: AgentRoutine; agent: WorkspaceAgent; channels: RoutineChannel[]; isOwner: boolean; slug: string }) {
140+ const toggle = useFetcher<ActionResult>({ key: `toggle-${routine.id}` });
141+ const run = useFetcher<ActionResult>({ key: `run-${routine.id}` });
142+ const enabled = toggle.formData ? toggle.formData.get("enabled") === "true" : routine.enabled;
143+ const failed = [toggle.data, run.data].find((d) => d && !d.ok) as Extract<ActionResult, { ok: false }> | undefined;
144+ return (
145+ <li className="px-4 py-3.5">
146+ <div className="flex items-start gap-3">
147+ <CalendarClock size={16} className={`mt-0.5 shrink-0 ${enabled ? "text-accent" : "text-faint"}`} />
148+ <div className="min-w-0 grow">
149+ <p className={`text-sm font-medium ${enabled ? "" : "text-muted"}`}>{routine.name}</p>
150+ <p className="mt-0.5 text-xs text-muted">{routineInWords(routine, EVENT_LABELS)}</p>
151+ <p className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-xs text-faint">
152+ <span>{routine.channel_name ? `#${routine.channel_name}` : "A direct message"}</span>
153+ {routine.sponsor_username && <span>· runs as @{routine.sponsor_username}</span>}
154+ {enabled && routine.next_run_at && <span suppressHydrationWarning>· next {untilLabel(routine.next_run_at)}</span>}
155+ {routine.last_run_at && (
156+ <span>
157+ · last{" "}
158+ {routine.last_session_id ? (
159+ <Link to={`/${slug}/-/agents/${agent.handle}/sessions/${routine.last_session_id}`} className="text-muted hover:text-fg hover:underline">
160+ <TimeAgo at={routine.last_run_at} />
161+ </Link>
162+ ) : (
163+ <TimeAgo at={routine.last_run_at} />
164+ )}
165+ </span>
166+ )}
167+ <span>
168+ · {routine.runs} {routine.runs === 1 ? "run" : "runs"}
169+ </span>
170+ </p>
171+ {routine.paused_note && (
172+ <p className="mt-2 flex items-start gap-1.5 rounded-md border border-warn/30 bg-warn/10 px-2.5 py-1.5 text-xs text-warn">
173+ <AlertTriangle size={13} className="mt-px shrink-0" />
174+ {routine.paused_note}
175+ </p>
176+ )}
177+ {run.data?.ok && <p className="mt-2 text-xs text-success">Started. It shows on the Sessions tab and in #{routine.channel_name ?? "its channel"}.</p>}
178+ {failed && <p className="mt-2 text-xs text-danger">{failed.error}</p>}
179+ </div>
180+ {isOwner ? (
181+ <div className="flex shrink-0 items-center gap-1">
182+ <run.Form method="post">
183+ <input type="hidden" name="intent" value="run" />
184+ <input type="hidden" name="id" value={routine.id} />
185+ <Hint label="Run now">
186+ <button type="submit" aria-label="Run now" disabled={run.state !== "idle"} className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg disabled:opacity-50">
187+ <Play size={14} />
188+ </button>
189+ </Hint>
190+ </run.Form>
191+ <RoutineDialog
192+ agentName={agent.display_name}
193+ draft={routine}
194+ channels={channels}
195+ title="Edit routine"
196+ trigger={
197+ <button type="button" aria-label="Edit" className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
198+ <Pencil size={14} />
199+ </button>
200+ }
201+ />
202+ <Confirm
203+ title={`Delete “${routine.name}”?`}
204+ confirm="Delete routine"
205+ fields={{ intent: "delete", id: routine.id }}
206+ fetcherKey={`delete-${routine.id}`}
207+ trigger={
208+ <button type="button" aria-label="Delete" className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-danger">
209+ <Trash2 size={14} />
210+ </button>
211+ }
212+ >
213+ It stops running. The sessions it already ran stay, with what they cost.
214+ </Confirm>
215+ <toggle.Form method="post" className="ml-1.5 flex">
216+ <input type="hidden" name="intent" value="toggle" />
217+ <input type="hidden" name="id" value={routine.id} />
218+ <Switch
219+ checked={enabled}
220+ aria-label={enabled ? "Turn off" : "Turn on"}
221+ onCheckedChange={(on) => toggle.submit({ intent: "toggle", id: routine.id, enabled: String(on) }, { method: "post" })}
222+ />
223+ </toggle.Form>
224+ </div>
225+ ) : (
226+ <span className={`shrink-0 text-xs ${enabled ? "text-success" : "text-faint"}`}>{enabled ? "On" : "Off"}</span>
227+ )}
228+ </div>
229+ </li>
230+ );
231+}
232+
233+function Suggestions({ agent, suggestions, channels }: { agent: WorkspaceAgent; suggestions: RoutineSuggestion[]; channels: RoutineChannel[] }) {
234+ return (
235+ <section>
236+ <h2 className="flex items-center gap-2 text-sm font-medium">
237+ <Lightbulb size={15} className="text-accent" />
238+ Suggested from {agent.display_name}&apos;s responsibilities
239+ </h2>
240+ <ul className="mt-3 grid gap-3 sm:grid-cols-2">
241+ {suggestions.map((suggestion) => (
242+ <li key={`${suggestion.responsibility}:${suggestion.routine.name}`} className="flex flex-col rounded-xl border border-dashed border-line bg-surface/60 p-4">
243+ <p className="text-xs text-faint">&ldquo;{suggestion.responsibility}&rdquo;</p>
244+ <p className="mt-2 text-sm font-medium">{suggestion.routine.name}</p>
245+ <p className="mt-0.5 text-xs text-muted">{routineInWords({ schedule: suggestion.routine.schedule, events: suggestion.routine.events, repos: suggestion.routine.repos }, EVENT_LABELS)}</p>
246+ <div className="mt-auto pt-3">
247+ <RoutineDialog
248+ agentName={agent.display_name}
249+ draft={suggestion.routine}
250+ channels={channels}
251+ title="Add routine"
252+ trigger={
253+ <button type="button" className={`${BUTTONS.QUIET} h-8 py-0`}>
254+ <Plus size={14} />
255+ Add
256+ </button>
257+ }
258+ />
259+ </div>
260+ </li>
261+ ))}
262+ </ul>
263+ </section>
264+ );
265+}
+553−0
1+import {
2+ ArrowLeft,
3+ Ban,
4+ BookmarkPlus,
5+ Check,
6+ CircleAlert,
7+ CircleDot,
8+ CornerDownRight,
9+ EyeOff,
10+ Flag,
11+ GitBranch,
12+ Lock,
13+ Megaphone,
14+ MessageSquare,
15+ Send,
16+ Square,
17+ Wrench,
18+} from "lucide-react";
19+import { type ReactNode, useEffect, useRef } from "react";
20+import { Link, data, redirect, useFetcher } from "react-router";
21+
22+import type { AgentSession, AgentSessionDetail, SessionEvent } from "@g1t/contracts";
23+
24+import type { Route } from "./+types/session";
25+import { AgentAvatar } from "../../../components/agent-avatar";
26+import { agentsAction, answer } from "../../../components/agents/actions.server";
27+import { type ActionResult, ApproveDialog, BUTTONS, Confirm } from "../../../components/agents/dialogs";
28+import { isLive, kindLabel, sessionRows, whereLabel } from "../../../components/agents/format";
29+import { KindBadge, Meter, PrivateTitle, SpendOfCap, StatusChip, sessionHref, stepsLine } from "../../../components/agents/parts";
30+import { Markdown } from "../../../components/markdown";
31+import { TimeAgo } from "../../../components/ui";
32+import { Hint } from "../../../components/ui/hint";
33+import { Textarea } from "../../../components/ui/textarea";
34+import { microsFromDollars } from "../../../lib/agent-form";
35+import { channelPath } from "../../../lib/chat";
36+import { cn } from "../../../lib/cn";
37+import { page } from "../../../lib/meta";
38+import { useRefreshWhile } from "../../../lib/refresh";
39+import { workspaceAgents } from "../../../lib/services.server";
40+import { requireUser, roleIn } from "../../../lib/session.server";
41+
42+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
43+ const session = loaderData?.detail?.session;
44+ const title = session?.visible ? session.title : "A session";
45+ return page(args, { title: `${title} · @${params.handle} · ${params.owner} · g1t` });
46+}
47+
48+/** One session: its transcript, its tree, what it produced, and what the viewer may do to it. */
49+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ detail: AgentSessionDetail | null }> {
50+ const viewer = requireUser(context, request);
51+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
52+ const found = await workspaceAgents.session(params.owner.toLowerCase(), params.id, viewer).catch(() => null);
53+ if (found && !found.ok && found.error.code === "not_found") throw data(null, { status: 404 });
54+ const detail = found?.ok ? found.value : null;
55+ // A session is under the agent that runs it.
56+ if (detail && detail.session.agent_handle !== params.handle.toLowerCase()) {
57+ throw redirect(`/${params.owner}/-/agents/${detail.session.agent_handle}/sessions/${detail.session.id}`);
58+ }
59+ return { detail };
60+}
61+
62+/** Stop it (and everything under it), approve more spend, or say something to it. */
63+export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
64+ const { viewer, slug, form } = await agentsAction(request, context, params.owner);
65+ const intent = String(form.get("intent") ?? "");
66+ if (intent === "stop") return answer(intent, workspaceAgents.stopSession(slug, params.id, viewer));
67+ if (intent === "approve") {
68+ const cap = microsFromDollars(form.get("cap"));
69+ if (cap == null || Number.isNaN(cap)) return { ok: false, intent, error: "Write the new cap in dollars, such as 5 or 2.50." };
70+ return answer(intent, workspaceAgents.approveSession(slug, params.id, viewer, cap));
71+ }
72+ if (intent === "steer") {
73+ const body = String(form.get("body") ?? "").trim();
74+ if (!body) return { ok: false, intent, error: "Write something to send." };
75+ return answer(intent, workspaceAgents.steerSession(slug, params.id, viewer, body));
76+ }
77+ return { ok: false, intent, error: "Unknown request." };
78+}
79+
80+export default function SessionPage({ loaderData, params }: Route.ComponentProps) {
81+ const { detail } = loaderData;
82+ const slug = params.owner;
83+ const live = Boolean(detail && isLive(detail.session.status));
84+ useRefreshWhile(live);
85+ const back = (
86+ <Link to={`/${slug}/-/agents/${params.handle}`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
87+ <ArrowLeft size={14} />
88+ Sessions
89+ </Link>
90+ );
91+ if (!detail) {
92+ return (
93+ <div className="space-y-4">
94+ {back}
95+ <div className="rounded-xl border border-dashed border-line px-6 py-14 text-center">
96+ <p className="font-medium">This session can&apos;t be shown right now</p>
97+ <p className="mt-1.5 text-sm text-muted">The agents service didn&apos;t answer. Reload in a moment.</p>
98+ </div>
99+ </div>
100+ );
101+ }
102+ const { session, events, tree } = detail;
103+ return (
104+ <div className="space-y-8">
105+ {back}
106+ <Header slug={slug} detail={detail} />
107+ <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_17rem]">
108+ <div className="min-w-0 space-y-6">
109+ {!session.visible ? (
110+ <div className="flex items-start gap-3 rounded-xl border border-line bg-surface px-4 py-4 text-sm text-muted">
111+ <Lock size={16} className="mt-0.5 shrink-0 text-faint" />
112+ <p>
113+ This session came from a conversation you&apos;re not in. You can see that it ran, how far it got and what it cost, but not what it was about.
114+ </p>
115+ </div>
116+ ) : (
117+ <>
118+ {session.summary && !events.some((e) => e.kind === "result") && <Report body={session.summary} />}
119+ <Transcript events={events} agentHandle={session.agent_handle} agentSeed={session.agent_avatar_seed} live={live} />
120+ {detail.can_steer && <Steer session={session} live={live} />}
121+ </>
122+ )}
123+ </div>
124+ <aside className="space-y-6">
125+ <TreePanel slug={slug} tree={tree} current={session.id} />
126+ {session.visible && <Outputs slug={slug} session={session} />}
127+ </aside>
128+ </div>
129+ </div>
130+ );
131+}
132+
133+function Header({ slug, detail }: { slug: string; detail: AgentSessionDetail }) {
134+ const { session } = detail;
135+ const chat = session.visible
136+ ? `${channelPath(slug, { id: session.channel_id, kind: session.channel_kind, name: session.channel_name })}${session.card_message_id ? `?thread=${encodeURIComponent(session.card_message_id)}` : ""}`
137+ : null;
138+ return (
139+ <header className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
140+ <div className="flex flex-wrap items-center gap-2">
141+ <KindBadge kind={session.kind} subagent={session.subagent} />
142+ <StatusChip status={session.status} />
143+ </div>
144+ <h1 className="mt-3 text-xl font-semibold tracking-tight text-balance sm:text-2xl">
145+ {session.visible ? session.title || "Untitled session" : <PrivateTitle className="not-italic" />}
146+ </h1>
147+ {session.visible && session.status_note && (
148+ <p className={cn("mt-2 text-sm", session.status === "failed" ? "text-danger" : session.status === "needs_approval" ? "text-warn" : "text-muted")}>{session.status_note}</p>
149+ )}
150+ <dl className="mt-5 grid gap-x-6 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
151+ <Meta label="Agent">
152+ <Link to={`/${slug}/-/agents/${session.agent_handle}`} className="inline-flex items-center gap-1.5 hover:underline">
153+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={18} />
154+ {session.agent_name}
155+ </Link>
156+ </Meta>
157+ <Meta label="Asked by">{session.visible && session.asked_by_username ? `@${session.asked_by_username}` : session.kind === "routine" ? "A routine" : "—"}</Meta>
158+ <Meta label="Where">{session.visible ? whereLabel(session) : "A conversation you're not in"}</Meta>
159+ <Meta label="Started">
160+ <TimeAgo at={session.created_at} />
161+ {session.finished_at && (
162+ <span className="text-faint">
163+ {" "}
164+ · ended <TimeAgo at={session.finished_at} />
165+ </span>
166+ )}
167+ </Meta>
168+ <Meta label="Model">{session.model ? <span className="font-mono text-[0.8125rem]">{session.model}</span> : "—"}</Meta>
169+ <Meta label="Work">{stepsLine(session)}</Meta>
170+ <Meta label="Tokens">
171+ <span className="tabular-nums">
172+ {session.input_tokens.toLocaleString("en-US")} in · {session.output_tokens.toLocaleString("en-US")} out
173+ </span>
174+ </Meta>
175+ <Meta label={session.parent_id ? "Spent (paid by its root)" : "Spent"}>
176+ <SpendOfCap spent={session.charged_micros} cap={session.cap_micros} />
177+ {session.cap_micros != null && <Meter spent={session.charged_micros} cap={session.cap_micros} label="Spent of its cap" size="sm" className="mt-1.5" />}
178+ </Meta>
179+ </dl>
180+ {(detail.can_stop || detail.can_approve || chat) && (
181+ <div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
182+ {detail.can_approve && (
183+ <ApproveDialog
184+ slug={slug}
185+ session={session}
186+ trigger={
187+ <button type="button" className={`${BUTTONS.PRIMARY} h-9 py-0`}>
188+ <Check size={15} />
189+ Approve more…
190+ </button>
191+ }
192+ />
193+ )}
194+ {chat && (
195+ <Link to={chat} className={`${BUTTONS.QUIET} h-9 py-0`}>
196+ <MessageSquare size={15} />
197+ Open in chat
198+ </Link>
199+ )}
200+ {detail.can_stop && (
201+ <Confirm
202+ title="Stop this session?"
203+ confirm="Stop session"
204+ fields={{ intent: "stop" }}
205+ fetcherKey={`stop-${session.id}`}
206+ trigger={
207+ <button type="button" className={`${BUTTONS.QUIET} h-9 py-0 hover:border-danger/50 hover:text-danger`}>
208+ <Square size={13} />
209+ Stop
210+ </button>
211+ }
212+ >
213+ {session.agent_name} stops where it is, and so does every session it started. What it spent so far stays spent; its card in the conversation says it was
214+ stopped.
215+ </Confirm>
216+ )}
217+ </div>
218+ )}
219+ </header>
220+ );
221+}
222+
223+function Meta({ label, children }: { label: string; children: ReactNode }) {
224+ return (
225+ <div className="min-w-0">
226+ <dt className="text-xs text-faint">{label}</dt>
227+ <dd className="mt-0.5 truncate">{children}</dd>
228+ </div>
229+ );
230+}
231+
232+const OUTCOMES: Record<string, { icon: ReactNode; label: string }> = {
233+ allowed: { icon: <Check size={13} className="text-success" />, label: "Read" },
234+ withheld: { icon: <EyeOff size={13} className="text-warn" />, label: "Withheld: the audience can't see it" },
235+ refused: { icon: <Ban size={13} className="text-danger" />, label: "Refused" },
236+ error: { icon: <CircleAlert size={13} className="text-danger" />, label: "Failed" },
237+};
238+
239+/** The session's transcript, oldest first: its goal, what it said and did, who steered it, and its report. */
240+function Transcript({ events, agentHandle, agentSeed, live }: { events: SessionEvent[]; agentHandle: string; agentSeed: string; live: boolean }) {
241+ if (events.length === 0) {
242+ return <p className="rounded-xl border border-dashed border-line px-4 py-8 text-center text-sm text-faint">{live ? "Getting started…" : "Nothing was recorded for this session."}</p>;
243+ }
244+ return (
245+ <section aria-label="Transcript">
246+ <ol className="relative space-y-1 before:absolute before:top-3 before:bottom-3 before:left-[0.6875rem] before:w-px before:bg-line">
247+ {events.map((event) => (
248+ <EventItem key={event.seq} event={event} agentHandle={agentHandle} agentSeed={agentSeed} />
249+ ))}
250+ {live && (
251+ <li className="relative flex items-center gap-3 py-2 pl-0 text-sm text-faint">
252+ <span className="relative z-10 flex size-6 items-center justify-center rounded-full bg-bg">
253+ <CircleDot size={13} className="animate-pulse text-accent motion-reduce:animate-none" />
254+ </span>
255+ Working…
256+ </li>
257+ )}
258+ </ol>
259+ </section>
260+ );
261+}
262+
263+function Rail({ children }: { children: ReactNode }) {
264+ return <span className="relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full bg-bg ring-1 ring-line">{children}</span>;
265+}
266+
267+function EventItem({ event, agentHandle, agentSeed }: { event: SessionEvent; agentHandle: string; agentSeed: string }) {
268+ const when = (
269+ <span className="ml-auto shrink-0 text-[0.6875rem] text-faint">
270+ <TimeAgo at={event.created_at} />
271+ </span>
272+ );
273+ switch (event.kind) {
274+ case "goal":
275+ return (
276+ <li className="relative flex gap-3 py-2">
277+ <Rail>
278+ <Flag size={12} className="text-accent" />
279+ </Rail>
280+ <div className="min-w-0 grow rounded-lg border border-accent/30 bg-accent/[0.06] px-3.5 py-2.5">
281+ <p className="flex items-center gap-2 text-xs font-medium text-accent">Goal{when}</p>
282+ <p className="mt-1 text-sm whitespace-pre-wrap text-fg">{event.body}</p>
283+ </div>
284+ </li>
285+ );
286+ case "text":
287+ return (
288+ <li className="relative flex gap-3 py-2">
289+ <span className="relative z-10 shrink-0">
290+ <AgentAvatar agent={{ handle: event.by ?? agentHandle, avatar_seed: event.by && event.by !== agentHandle ? event.by : agentSeed }} size={24} />
291+ </span>
292+ <div className="min-w-0 grow">
293+ <p className="flex items-center gap-2 text-xs text-muted">
294+ <span className="font-medium text-fg">{event.by ? `@${event.by}` : "g1t"}</span>
295+ {when}
296+ </p>
297+ <p className="mt-0.5 text-sm whitespace-pre-wrap text-fg-soft">{event.body}</p>
298+ </div>
299+ </li>
300+ );
301+ case "tool": {
302+ const outcome = OUTCOMES[event.outcome ?? ""] ?? { icon: <Wrench size={12} className="text-faint" />, label: event.outcome ?? "Called" };
303+ const long = event.body.length > 140;
304+ return (
305+ <li className="relative flex gap-3 py-1">
306+ <Rail>
307+ <Hint label={outcome.label}>
308+ <span className="flex" aria-label={outcome.label}>
309+ {outcome.icon}
310+ </span>
311+ </Hint>
312+ </Rail>
313+ <div className="min-w-0 grow pt-0.5">
314+ {long ? (
315+ <details className="group">
316+ <summary className="flex cursor-pointer list-none items-center gap-2 text-[0.8125rem] [&::-webkit-details-marker]:hidden">
317+ <span className="font-mono text-fg">{event.tool ?? "tool"}</span>
318+ <span className="min-w-0 truncate font-mono text-xs text-faint group-open:hidden">{event.body}</span>
319+ {when}
320+ </summary>
321+ <pre className="mt-1.5 overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-xs whitespace-pre-wrap text-muted">{event.body}</pre>
322+ </details>
323+ ) : (
324+ <p className="flex items-center gap-2 text-[0.8125rem]">
325+ <span className="font-mono text-fg">{event.tool ?? "tool"}</span>
326+ <span className="min-w-0 truncate font-mono text-xs text-faint">{event.body}</span>
327+ {when}
328+ </p>
329+ )}
330+ </div>
331+ </li>
332+ );
333+ }
334+ case "steer":
335+ return (
336+ <li className="relative flex gap-3 py-2">
337+ <Rail>
338+ <MessageSquare size={12} className="text-info" />
339+ </Rail>
340+ <div className="min-w-0 grow rounded-lg border border-info/30 bg-info/[0.06] px-3.5 py-2.5">
341+ <p className="flex items-center gap-2 text-xs text-info">
342+ <span className="font-medium">{event.by ? `@${event.by}` : "Someone"}</span> steered it{when}
343+ </p>
344+ <p className="mt-1 text-sm whitespace-pre-wrap text-fg">{event.body}</p>
345+ </div>
346+ </li>
347+ );
348+ case "update":
349+ return (
350+ <li className="relative flex gap-3 py-2">
351+ <Rail>
352+ <Megaphone size={12} className="text-muted" />
353+ </Rail>
354+ <div className="min-w-0 grow">
355+ <p className="flex items-center gap-2 text-xs text-muted">Posted an update in the conversation{when}</p>
356+ <p className="mt-0.5 text-sm whitespace-pre-wrap text-fg-soft">{event.body}</p>
357+ </div>
358+ </li>
359+ );
360+ case "child":
361+ return (
362+ <li className="relative flex gap-3 py-2">
363+ <Rail>
364+ <GitBranch size={12} className="text-merged" />
365+ </Rail>
366+ <div className="min-w-0 grow">
367+ <p className="flex items-center gap-2 text-xs text-muted">
368+ {event.by ? <span className="font-medium text-fg">@{event.by}</span> : null} reported back{when}
369+ </p>
370+ <p className="mt-0.5 text-sm whitespace-pre-wrap text-fg-soft">{event.body}</p>
371+ </div>
372+ </li>
373+ );
374+ case "result":
375+ return (
376+ <li className="relative flex gap-3 py-2">
377+ <Rail>
378+ <Check size={12} className="text-success" />
379+ </Rail>
380+ <div className="min-w-0 grow">
381+ <Report body={event.body} when={event.created_at} />
382+ </div>
383+ </li>
384+ );
385+ default:
386+ return (
387+ <li className="relative flex gap-3 py-1.5">
388+ <Rail>
389+ <CircleDot size={11} className="text-faint" />
390+ </Rail>
391+ <p className="flex min-w-0 grow items-center gap-2 pt-0.5 text-xs text-faint italic">
392+ <span className="min-w-0">{event.body}</span>
393+ {when}
394+ </p>
395+ </li>
396+ );
397+ }
398+}
399+
400+/** What it found or did: its report, rendered. */
401+function Report({ body, when }: { body: string; when?: string }) {
402+ return (
403+ <section className="rounded-xl border border-success/30 bg-surface">
404+ <header className="flex items-center gap-2 border-b border-line px-4 py-2 text-xs font-medium text-success">
405+ Report
406+ {when && (
407+ <span className="ml-auto font-normal text-faint">
408+ <TimeAgo at={when} />
409+ </span>
410+ )}
411+ </header>
412+ <div className="px-4 py-3 text-sm">
413+ <Markdown source={body} />
414+ </div>
415+ </section>
416+ );
417+}
418+
419+/** Say something to the session: it reads it before its next step, or picks back up if it had finished. */
420+function Steer({ session, live }: { session: AgentSession; live: boolean }) {
421+ const fetcher = useFetcher<ActionResult>({ key: `steer-${session.id}` });
422+ const form = useRef<HTMLFormElement>(null);
423+ const busy = fetcher.state !== "idle";
424+ useEffect(() => {
425+ if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset();
426+ }, [fetcher.state, fetcher.data]);
427+ return (
428+ <fetcher.Form ref={form} method="post" className="rounded-xl border border-line bg-surface p-3 focus-within:border-accent-dim">
429+ <input type="hidden" name="intent" value="steer" />
430+ <label htmlFor="steer" className="sr-only">
431+ Message this session
432+ </label>
433+ <Textarea
434+ id="steer"
435+ name="body"
436+ rows={2}
437+ maxLength={4000}
438+ required
439+ placeholder={live ? `Message this session: ${session.agent_name} reads it before its next step` : `Message this session: ${session.agent_name} picks it back up`}
440+ className="min-h-14 border-0 bg-transparent px-1 hover:border-0 focus-visible:ring-0"
441+ onKeyDown={(event) => {
442+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
443+ event.preventDefault();
444+ event.currentTarget.form?.requestSubmit();
445+ }
446+ }}
447+ />
448+ <div className="mt-2 flex items-center justify-between gap-3">
449+ <p className="text-xs text-faint">
450+ {fetcher.data && !fetcher.data.ok ? <span className="text-danger">{fetcher.data.error}</span> : live ? "It reads this before its next step." : "It picks the session back up with everything it knew."}
451+ </p>
452+ <button type="submit" disabled={busy} className={`${BUTTONS.PRIMARY} h-8 py-0`}>
453+ <Send size={14} />
454+ {busy ? "Sending…" : "Send"}
455+ </button>
456+ </div>
457+ </fetcher.Form>
458+ );
459+}
460+
461+/** Every session in this one's tree, root first, each under the one that started it. */
462+function TreePanel({ slug, tree, current }: { slug: string; tree: AgentSession[]; current: string }) {
463+ const rows = sessionRows(tree);
464+ return (
465+ <section>
466+ <h2 className="text-xs font-medium tracking-wide text-faint uppercase">Session tree</h2>
467+ {rows.length <= 1 ? (
468+ <p className="mt-2 text-sm text-muted">It hasn&apos;t brought in a colleague or a subagent. If it does, they show here, paid from this session.</p>
469+ ) : (
470+ <ul className="mt-2 space-y-0.5">
471+ {rows.map(({ session, depth }) => {
472+ const here = session.id === current;
473+ const body = (
474+ <>
475+ {depth > 0 && <CornerDownRight size={12} className="shrink-0 text-faint" />}
476+ <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={18} />
477+ <span className="min-w-0 grow">
478+ <span className="block truncate text-[0.8125rem]">{session.visible ? session.title : "A private session"}</span>
479+ <span className="block truncate text-[0.6875rem] text-faint">
480+ {session.subagent ? `${session.agent_name} · ${session.subagent}` : session.agent_name} · {kindLabel(session.kind)}
481+ </span>
482+ </span>
483+ <StatusChip status={session.status} className="px-1.5" />
484+ </>
485+ );
486+ return (
487+ <li key={session.id} style={{ paddingLeft: `${depth * 0.875}rem` }}>
488+ {here ? (
489+ <div className="flex items-center gap-2 rounded-md bg-raised px-2 py-1.5" aria-current="page">
490+ {body}
491+ </div>
492+ ) : session.visible ? (
493+ <Link to={sessionHref(slug, session)} className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-raised/60">
494+ {body}
495+ </Link>
496+ ) : (
497+ <div className="flex items-center gap-2 px-2 py-1.5 text-muted">{body}</div>
498+ )}
499+ </li>
500+ );
501+ })}
502+ </ul>
503+ )}
504+ <p className="mt-3 text-xs text-faint">Everything in the tree is charged to the agent at its root.</p>
505+ </section>
506+ );
507+}
508+
509+/** What it produced: issues filed, sessions started, memories kept. */
510+function Outputs({ slug, session }: { slug: string; session: AgentSession }) {
511+ return (
512+ <section>
513+ <h2 className="text-xs font-medium tracking-wide text-faint uppercase">What it produced</h2>
514+ {session.outputs.length === 0 ? (
515+ <p className="mt-2 text-sm text-muted">Nothing yet. Issues it files, sessions it starts and facts it keeps show here.</p>
516+ ) : (
517+ <ul className="mt-2 space-y-1">
518+ {session.outputs.map((output, i) => (
519+ <li key={i} className="text-sm">
520+ {output.kind === "issue" ? (
521+ <Link to={`/${output.repo}/issues/${output.number}`} className="flex items-start gap-2 rounded-md px-2 py-1.5 hover:bg-raised/60">
522+ <CircleDot size={14} className="mt-0.5 shrink-0 text-success" />
523+ <span className="min-w-0">
524+ <span className="block truncate">{output.title}</span>
525+ <span className="block font-mono text-[0.6875rem] text-faint">
526+ {output.repo}#{output.number}
527+ </span>
528+ </span>
529+ </Link>
530+ ) : output.kind === "session" ? (
531+ <Link to={`/${slug}/-/agents/${output.agent_handle}/sessions/${output.id}`} className="flex items-start gap-2 rounded-md px-2 py-1.5 hover:bg-raised/60">
532+ <GitBranch size={14} className="mt-0.5 shrink-0 text-merged" />
533+ <span className="min-w-0">
534+ <span className="block truncate">{output.title}</span>
535+ <span className="block text-[0.6875rem] text-faint">Session for @{output.agent_handle}</span>
536+ </span>
537+ </Link>
538+ ) : (
539+ <Link to={`/${slug}/-/agents/${session.agent_handle}/memory`} className="flex items-start gap-2 rounded-md px-2 py-1.5 hover:bg-raised/60">
540+ <BookmarkPlus size={14} className="mt-0.5 shrink-0 text-accent" />
541+ <span className="min-w-0">
542+ <span className="line-clamp-2">{output.body}</span>
543+ <span className="block text-[0.6875rem] text-faint">Kept in memory</span>
544+ </span>
545+ </Link>
546+ )}
547+ </li>
548+ ))}
549+ </ul>
550+ )}
551+ </section>
552+ );
553+}
+130−0
1+import { Link, data, useOutletContext, useSearchParams } from "react-router";
2+
3+import type { AgentSession, WorkspaceAgent } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/sessions";
6+import { MessageAgent } from "./agent";
7+import { AgentAvatar } from "../../../components/agent-avatar";
8+import { readOrNull } from "../../../components/agents/actions.server";
9+import { isLive, sessionRows } from "../../../components/agents/format";
10+import { Quiet, SessionRow } from "../../../components/agents/parts";
11+import { isOrchestrator } from "../../../components/orchestrator";
12+import { cn } from "../../../lib/cn";
13+import { useRefreshWhile } from "../../../lib/refresh";
14+import { workspaceAgents } from "../../../lib/services.server";
15+import { requireUser, roleIn } from "../../../lib/session.server";
16+
17+type Filter = "all" | "live" | "done";
18+
19+/** The agent's sessions, newest first; `?status=live` or `done` narrows them. */
20+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ sessions: AgentSession[] | null; filter: Filter }> {
21+ const viewer = requireUser(context, request);
22+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23+ const asked = new URL(request.url).searchParams.get("status");
24+ const filter: Filter = asked === "live" || asked === "done" ? asked : "all";
25+ const sessions = await readOrNull(
26+ workspaceAgents.sessions(params.owner.toLowerCase(), viewer, { handle: params.handle.toLowerCase(), status: filter === "all" ? null : filter, limit: 100 }),
27+ );
28+ return { sessions, filter };
29+}
30+
31+/** An empty list, in the agent's own voice. */
32+const EMPTY: Record<string, string> = {
33+ crisp: "No sessions yet. Ask me for something that takes real work and I'll start one.",
34+ friendly: "Nothing on my plate yet! Ask me for something bigger than a quick answer and I'll get going.",
35+ socratic: "Nothing here yet. What would you like me to dig into first?",
36+ terse: "No sessions. Send work.",
37+};
38+
39+/**
40+ * Every session the agent is working on or has worked on: live first, then
41+ * finished, each child under the session that started it.
42+ */
43+export default function Sessions({ loaderData, params }: Route.ComponentProps) {
44+ const agent = useOutletContext<WorkspaceAgent>();
45+ const { sessions, filter } = loaderData;
46+ const [search] = useSearchParams();
47+ const live = (sessions ?? []).filter((s) => isLive(s.status));
48+ const done = (sessions ?? []).filter((s) => !isLive(s.status));
49+ useRefreshWhile(live.length > 0);
50+ const tab = (value: Filter, label: string) => {
51+ const next = new URLSearchParams(search);
52+ if (value === "all") next.delete("status");
53+ else next.set("status", value);
54+ const query = next.toString();
55+ return (
56+ <Link
57+ key={value}
58+ to={query ? `?${query}` : "."}
59+ preventScrollReset
60+ aria-current={filter === value ? "true" : undefined}
61+ className={cn("rounded px-2.5 py-1 text-[0.8125rem]", filter === value ? "bg-raised text-fg" : "text-muted hover:text-fg")}
62+ >
63+ {label}
64+ </Link>
65+ );
66+ };
67+ if (!sessions) {
68+ return <Quiet title="Sessions can't be shown right now">The agents service didn&apos;t answer. Reload in a moment.</Quiet>;
69+ }
70+ return (
71+ <div className="space-y-6">
72+ <div className="flex flex-wrap items-center justify-between gap-3">
73+ <p className="max-w-xl text-sm text-muted">
74+ A conversation with {agent.display_name} stays quick. When a request needs real work, it spins off a session with its own budget and a live card in the
75+ conversation. Sessions can bring in colleagues or subagents; all of it is paid from the session that started it.
76+ </p>
77+ <nav aria-label="Which sessions" className="flex rounded-md border border-line p-0.5">
78+ {tab("all", "All")}
79+ {tab("live", "Live")}
80+ {tab("done", "Done")}
81+ </nav>
82+ </div>
83+
84+ {sessions.length === 0 ? (
85+ filter === "all" ? (
86+ <div className="rounded-2xl border border-dashed border-line bg-surface/40 px-6 py-14 text-center">
87+ <span className="mx-auto flex justify-center">
88+ <AgentAvatar agent={{ ...agent, builtin: isOrchestrator(agent) }} size={52} />
89+ </span>
90+ <h2 className="mt-4 text-base font-semibold">No sessions yet</h2>
91+ <blockquote className="mx-auto mt-3 max-w-md text-[0.9375rem] text-fg-soft">
92+ &ldquo;{EMPTY[agent.personality_preset] ?? EMPTY.crisp}&rdquo;
93+ <footer className="mt-1 text-xs text-faint">{agent.display_name}</footer>
94+ </blockquote>
95+ <div className="mt-6 flex justify-center">
96+ <MessageAgent slug={params.owner} agent={agent} variant="accent" />
97+ </div>
98+ <p className="mt-4 text-xs text-faint">
99+ Works up to {agent.capacity} {agent.capacity === 1 ? "session" : "sessions"} at once; more wait in line.
100+ </p>
101+ </div>
102+ ) : (
103+ <Quiet title={filter === "live" ? "Nothing live" : "Nothing finished yet"}>
104+ {filter === "live" ? `${agent.display_name} isn't working on a session right now.` : "Finished sessions stay here with their transcripts and reports."}
105+ </Quiet>
106+ )
107+ ) : (
108+ <>
109+ {live.length > 0 && <SessionGroup title="Live" slug={params.owner} sessions={live} />}
110+ {done.length > 0 && <SessionGroup title={filter === "done" ? "Done" : "Past"} slug={params.owner} sessions={done} />}
111+ </>
112+ )}
113+ </div>
114+ );
115+}
116+
117+function SessionGroup({ title, slug, sessions }: { title: string; slug: string; sessions: AgentSession[] }) {
118+ return (
119+ <section>
120+ <h2 className="text-sm font-medium">
121+ {title} <span className="text-faint">{sessions.length}</span>
122+ </h2>
123+ <ul className="mt-3 divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface">
124+ {sessionRows(sessions).map(({ session, depth }) => (
125+ <SessionRow key={session.id} slug={slug} session={session} depth={depth} />
126+ ))}
127+ </ul>
128+ </section>
129+ );
130+}
+76−49
1−import { useOutletContext } from "react-router";
1+import { data, useOutletContext } from "react-router";
2+
3+import type { AgentSpendBreakdown, WorkspaceAgent } from "@g1t/contracts";
24
3−import type { WorkspaceAgent } from "@g1t/contracts";
5+import type { Route } from "./+types/spend";
6+import { readOrNull } from "../../../components/agents/actions.server";
7+import { meterTone, monthName, shareOf } from "../../../components/agents/format";
8+import { DailyBars, Meter, Panel, Quiet, SessionRow, SliceList } from "../../../components/agents/parts";
9+import { requireUser, roleIn } from "../../../lib/session.server";
10+import { workspaceAgents } from "../../../lib/services.server";
11+import { money } from "../../../lib/usage";
412
5−import { formatDollars } from "../../../lib/agent-form";
13+/** Where this agent's month went. */
14+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ spend: AgentSpendBreakdown | null }> {
15+ const viewer = requireUser(context, request);
16+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
17+ return { spend: await readOrNull(workspaceAgents.spend(params.owner.toLowerCase(), viewer, params.handle.toLowerCase())) };
18+}
619
7−/** Spend this month against the agent's budget; by task and by model as tasks land. */
8−export default function Spend() {
20+/**
21+ * Spend this month against the agent's budget: by day, by kind of work, by
22+ * model, by who asked, and its costliest sessions. A session's helpers and
23+ * subagents are paid here when this agent started it.
24+ */
25+export default function Spend({ loaderData, params }: Route.ComponentProps) {
926 const agent = useOutletContext<WorkspaceAgent>();
10− const spent = agent.spent_month_micros ?? 0;
27+ const { spend } = loaderData;
28+ const spent = spend?.total_micros ?? agent.spent_month_micros ?? 0;
1129 const cap = agent.budget.monthly_micros;
12− const share = cap ? Math.min(1, spent / cap) : 0;
13− const tone = share >= 1 ? "bg-danger" : share >= 0.8 ? "bg-warn" : "bg-accent";
30+ const share = shareOf(spent, cap);
31+ const tone = meterTone(share);
1432 return (
15− <div className="space-y-8">
33+ <div className="space-y-6">
1634 <section className="rounded-2xl border border-line bg-surface p-6">
17− <p className="text-sm text-muted">Spent this month</p>
35+ <p className="text-sm text-muted">Spent in {monthName(spend?.period ?? new Date().toISOString().slice(0, 7))}</p>
1836 <p className="mt-1 flex flex-wrap items-baseline gap-x-2">
19− <span className="text-3xl font-semibold tracking-tight tabular-nums">{formatDollars(spent)}</span>
20− <span className="text-sm text-muted">{cap ? `of ${formatDollars(cap)}` : "No monthly cap: the workspace limit applies"}</span>
37+ <span className="text-3xl font-semibold tracking-tight tabular-nums">{money(spent)}</span>
38+ <span className="text-sm text-muted">{cap != null ? `of ${money(cap)}` : "No monthly budget of its own: the workspace's agent budget applies"}</span>
2139 </p>
2240 {cap != null && (
2341 <>
24− <div
25− className="mt-4 h-2 overflow-hidden rounded-full bg-line"
26− role="meter"
27− aria-label="Spent of the monthly cap"
28− aria-valuemin={0}
29− aria-valuemax={cap}
30− aria-valuenow={spent}
31− >
32− <div className={`h-full rounded-full ${tone} transition-[width]`} style={{ width: `${Math.max(share * 100, spent > 0 ? 1.5 : 0)}%` }} />
33− </div>
34− <p className="mt-2 text-xs text-faint">
35− {Math.round(share * 100)}% used. At 80% it tells the channel that pays for it; at 100% it takes no new tasks.
42+ <Meter spent={spent} cap={cap} label="Spent of its monthly budget" className="mt-4" />
43+ <p className={`mt-2 text-xs ${tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : "text-faint"}`}>
44+ {Math.round((share ?? 0) * 100)}% used. At 100% it takes no new work until the 1st, or until an owner raises its budget on its profile.
3645 </p>
3746 </>
3847 )}
39− <dl className="mt-6 grid grid-cols-2 gap-4 border-t border-line pt-5 sm:grid-cols-3">
40− <div>
41− <dt className="text-xs text-faint">Daily cap</dt>
42− <dd className="mt-0.5 text-sm tabular-nums">{agent.budget.daily_micros != null ? formatDollars(agent.budget.daily_micros) : "None"}</dd>
43− </div>
44− <div>
45− <dt className="text-xs text-faint">Per task</dt>
46− <dd className="mt-0.5 text-sm tabular-nums">{agent.budget.task_micros != null ? formatDollars(agent.budget.task_micros) : "None"}</dd>
47− </div>
48− <div>
49− <dt className="text-xs text-faint">While idle</dt>
50− <dd className="mt-0.5 text-sm">Costs nothing</dd>
51− </div>
48+ <dl className="mt-6 grid grid-cols-2 gap-4 border-t border-line pt-5 sm:grid-cols-4">
49+ <Stat label="Daily cap" value={agent.budget.daily_micros != null ? money(agent.budget.daily_micros) : "None"} />
50+ <Stat label="Per session" value={agent.budget.task_micros != null ? money(agent.budget.task_micros) : "Workspace default"} />
51+ <Stat label="While idle" value="Costs nothing" />
52+ <Stat label="Rolls up to" value="The workspace's bill" />
5253 </dl>
5354 </section>
54− <section>
55− <h2 className="text-sm font-medium">By task and by model</h2>
56− <div className="mt-3 grid gap-3 sm:grid-cols-2">
57− <div className="rounded-xl border border-dashed border-line px-5 py-8">
58− <p className="text-sm font-medium">By task</p>
59− <p className="mt-1 text-[0.8125rem] text-muted">Each task&apos;s spend against its cap shows here once it has worked one.</p>
55+
56+ {!spend ? (
57+ <Quiet title="The breakdown can't be shown right now">The agents service didn&apos;t answer. Reload in a moment.</Quiet>
58+ ) : (
59+ <>
60+ <Panel title="By day" aside="UTC">
61+ <DailyBars period={spend.period} days={spend.days} className="px-4 pt-4 pb-3" />
62+ </Panel>
63+ <div className="grid gap-4 lg:grid-cols-2">
64+ <Panel title="By kind of work">
65+ <SliceList slices={spend.by_kind} unit="runs" />
66+ </Panel>
67+ <Panel title="By model">
68+ <SliceList slices={spend.by_model} unit="runs" />
69+ </Panel>
70+ <Panel title="By who asked" className="lg:col-span-2">
71+ <SliceList slices={spend.by_person} unit="runs" />
72+ </Panel>
6073 </div>
61− <div className="rounded-xl border border-dashed border-line px-5 py-8">
62− <p className="text-sm font-medium">By model</p>
63− <p className="mt-1 text-[0.8125rem] text-muted">Every step records the model that ran it; the split shows here once it has worked.</p>
64− </div>
65− </div>
66− </section>
74+ {spend.top_sessions.length > 0 && (
75+ <Panel title="Costliest sessions" aside="With everything they brought in">
76+ <ul className="divide-y divide-line/60">
77+ {spend.top_sessions.map((session) => (
78+ <SessionRow key={session.id} slug={params.owner} session={session} showAgent={session.agent_id !== agent.id} />
79+ ))}
80+ </ul>
81+ </Panel>
82+ )}
83+ </>
84+ )}
85+ </div>
86+ );
87+}
88+
89+function Stat({ label, value }: { label: string; value: string }) {
90+ return (
91+ <div>
92+ <dt className="text-xs text-faint">{label}</dt>
93+ <dd className="mt-0.5 text-sm tabular-nums">{value}</dd>
6794 </div>
6895 );
6996 }
+2−2
8484 },
8585 billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit and caps, prepaying, and every charge." },
8686 agents: {
87− title: "Agent fleet",
88− about: "Every agent at work across the workspace's projects: what each holds, what it is doing now, and what it has cost.",
87+ title: "Agents",
88+ about: "The workspace's agents together: what they are working on now, what waits on you, and where this month's agent budget went.",
8989 },
9090 memory: {
9191 title: "Workspace memory",
+42−1
3030 import { type AgentRouting as Policy, routingReader } from "../../runner/src/model-env.ts";
3131 import { type Spent, type Tokens, budgetBlock, chargedMicros, replyCapMicros, totalTokens } from "./budget.ts";
3232 import { BUILTIN_NO_MODEL } from "./orchestrator.ts";
33−import { type PolicyRow, policyBlock, readPolicy, workspaceSpendStatements } from "./policy.ts";
33+import { type PolicyRow, alertDue, markAlerted, policyBlock, readPolicy, workspaceSpendStatements } from "./policy.ts";
34+import { dollars } from "./money.ts";
3435 import { type ReplyModel, allowedProviders, replyModel } from "./routing.ts";
3536 import { type Row, definitionOf, periods, spendStatements } from "./store.ts";
3637 import type { ModelAnswer, Send } from "./turn.ts";
4445 MODELS_URL?: string;
4546 HOSTED_AGENT_WORKSPACES: string;
4647 AGENT_ROUTING: string;
48+ /** Notifications: the workspace's agent budget crossing 75, 90 or 100%. */
49+ NOTIFY?: ServiceBinding;
4750 };
4851
4952 /** The longest one model answer may take. */
302305 }
303306 if (charged > 0) {
304307 await db.batch([...spendStatements(db, payer.id, charged, now, input.task), ...workspaceSpendStatements(db, row.workspace_id, charged, now)]);
308+ await budgetAlert(env, slug, row.workspace_id, { ...policy, spent: policy.spent + charged }, month).catch((error: unknown) =>
309+ console.error("agents: a budget alert was not sent", slug, String(error)),
310+ );
305311 }
306312 return { ok: true, value: result, model: model.modelName, tier: named ? null : model.tier, tokens: result.tokens, cost: result.cost, charged };
307313 } finally {
312318 }
313319 }
314320
321+/**
322+ * Tells the owner who set the workspace's agent budget, once per level a
323+ * month, when every agent's spend together crosses 75, 90 or 100% of it.
324+ */
325+async function budgetAlert(env: MeterEnv, slug: string, workspaceId: string, policy: PolicyRow, month: string): Promise<void> {
326+ const level = alertDue(policy);
327+ if (!level || !env.NOTIFY || !policy.monthly_micros) return;
328+ if (!(await markAlerted(env.DB, workspaceId, month, level))) return;
329+ const setBy = await env.DB.prepare("SELECT updated_by FROM agent_policies WHERE workspace_id = ?").bind(workspaceId).first<{ updated_by: string | null }>();
330+ if (!setBy?.updated_by) return;
331+ const title = level >= 100 ? "Your agents have used this month's budget" : `Your agents have used ${level}% of this month's budget`;
332+ const body =
333+ level >= 100
334+ ? `${dollars(policy.spent)} of ${dollars(policy.monthly_micros)}. They won't start new work until it's raised or the month turns.`
335+ : `${dollars(policy.spent)} of ${dollars(policy.monthly_micros)} so far this month.`;
336+ await env.NOTIFY.fetch("https://service/rpc/notify", {
337+ method: "POST",
338+ headers: { "content-type": "application/json" },
339+ body: JSON.stringify({
340+ target: { username: setBy.updated_by },
341+ notification: {
342+ id: `agent-budget:${workspaceId}:${month}:${level}`,
343+ kind: "approval",
344+ workspace: slug,
345+ title,
346+ body,
347+ href: `/${slug}/-/agents`,
348+ actor: { kind: "system", id: "g1t", name: "g1t" },
349+ channel_id: null,
350+ created_at: new Date().toISOString(),
351+ },
352+ }),
353+ });
354+}
355+
315356 export type { PolicyRow };