Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Session titles settle from the whole record, server-side

A session's title now says where it came from (title_source: prompt, agent, person, item, pull, summary) and when it settled. The first prompt names it provisionally; the work service settles it in its cron once there is signal (a work item or pull request it works on, three prompts, or half an hour), again only when its scope changes (at most every 20 minutes, and only when the new title says something else), and a last time as it ends. A person's title is pinned (title_session); an agent's is kept unless the session went on to other work after naming it. Titles come from the workspace's Ask provider through integrations' new ai_brief, bounded (five calls a session at most), with a fallback: the work item's title, the pull request's, else the most telling prompt's intent without its preamble. Existing sessions are settled once by the same job. The Claude Code hook no longer titles sessions or nudges Claude to retitle them; SessionStart says once to set a title only for the whole job. The site shows a provisional title muted, and a session's page renames (pins) it or lets g1t name it again.

G1T-Session:
ses_01m56fqty1ej98f36jtcarc5kz
syntaqxcommitted Parentf1f77ceBrowse files
23 files+2071−2700/23 viewed
+29−23
77 machine to the work on g1t. With it installed, working in a g1t repository:
88
99 - each Claude Code session is recorded on g1t as a
10− [session](/guides/why-blame/#sessions), titled with what you asked for
11− and attached to its work item and pull request, so the people and agents
10+ [session](/guides/why-blame/#sessions), named by g1t from the work it
11+ does and attached to its work item and pull request, so the people and agents
1212 also working there can see it, work item or not;
1313 - Claude starts each session knowing the work item it is on: its definition
1414 of done, the repositories it is meant to land in, who has claimed it, its
6363 the host in `G1T_HOST` for a self-hosted g1t. Everywhere else its hooks do
6464 nothing. A session that starts in a folder with no g1t remote is recorded
6565 once it has one: from the next prompt, or the `git remote`, `git push` or
66−`g1t repo create` command that added it, titled by its first prompt.
66+`g1t repo create` command that added it.
6767
6868 ### Update
6969
9999 | --- | --- | --- |
100100 | A session starts or resumes | `g1t session start --harness claude_code --harness-session-id ID` | A session for this Claude Code session. Starting the same session again, as a resume does, finds it rather than making another. |
101101 | You send a prompt | `g1t session log --kind prompt TEXT` | Your prompt's first 2,000 characters. |
102−| You send a prompt that says what it is for, while the session has no title | `g1t session heartbeat --json`, then, if the session has no title, `g1t session attach --title INTENT` | Its title: what the prompt is for, as its first line or sentence without pasted text, image placeholders (`[Image #1]`), code blocks or markdown, up to 80 characters, cut on a word with `…`. A slash command, or a prompt that is only pasted text or images, gives no title, and the next prompt titles the session. A title the session already has (one Claude, you or an earlier prompt set) is never replaced. Not sent with prompts kept back. |
103102 | Claude uses a tool | `g1t session log --kind tool_call TEXT` | The tool's name and one line on what it was called with: the command, file, pattern or address, up to 200 characters, with anything that looks like a token or password replaced by `…`. |
104103 | Claude uses a skill, or you run one (`/g1t:work-on-item 42`) | `g1t session log --kind skill NAME` | The skill's name, nothing more, once in a turn. A plugin's skill or command you type as `/plugin:name` is recorded even with prompts kept back. |
105104 | Claude writes a file, makes a branch or a commit, or claims a work item | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--item`) | What it touched: the path from the repository's root, the branch, the commit, the work item. A file is one Claude edited or wrote, or one a shell command changed while it ran (the uncommitted files before and after it), never what was uncommitted already, so sessions sharing a working copy do not claim each other's work. g1t ties the session to the pull requests and work items these lead to, and tells two sessions that touch the same file. |
150149 than a second, Claude is told to load it itself.
151150
152151 With no work item in sight, Claude is told that the session is already
153−recorded on g1t and shows to the team with what it is for, so it can just
154−work; see [Work with no work item yet](#work-with-no-work-item-yet).
152+recorded on g1t and shows to the team, so it can just work, and that g1t
153+names the session itself: it sets a title only if it can name the whole job; see [Work with no work item yet](#work-with-no-work-item-yet).
155154
156155 Claude is also told to put the trailer `G1t-Session: $G1T_SESSION` on the
157156 commits it makes, so g1t ties them, and the pull request they land in, to
169168 Most sessions start from a question or a request, not a work item, and
170169 most never need one. The session is the record of that work:
171170
172−1. **It is titled with what it is for.** At the session's first prompt
173− that says what it is for, the plugin gives the session a title from
174− it, unless it has one. The title is what you typed: text you pasted
175− and image placeholders are left out, so a prompt that starts with a
176− paste is titled by the words around it. Slash commands (`/clear`,
177− `/g1t:status`) and a prompt that is only a paste give no title; the
178− next prompt titles the session. A session whose title is only a tag,
179− such as `<pasted_content id="5c50">` from a plugin older than 0.2.4,
180− shows on the site by its first prompt's intent instead, else
181− **Session on** its branch. To name
182− it yourself, or have Claude name it, run
183− `g1t session attach --title "Fix the preview builds"`; that replaces
184− the title the prompt gave. Claude keeps it true as the work turns
185− out to be something else: the plugin tells it at the start that it
186− can retitle the session, and reminds it at the second prompt and every
187− fifth after (not while the CLI is signed out or too old).
171+1. **g1t names it from the work it does.** Until there is enough to go
172+ on, the session is called what its first prompt asked for (its first
173+ line or sentence, without pasted text, image placeholders, code or
174+ markdown), shown muted as provisional. g1t then settles a title from
175+ the session's whole record: once it claims or attaches a work item or
176+ opens a pull request, after a few prompts, or after half an hour. With
177+ an [AI provider](/guides/ai/) chosen for Ask, the provider names it in
178+ a few words, in the work item's terms; without one, it is the work
179+ item's title, else the pull request's, else what its most telling
180+ prompt asks for, without greetings and preambles. It is named again
181+ only when what the session works on changes, at most every 20 minutes,
182+ and a last time when it ends. Claude is told this once at the start,
183+ and to name the session itself with
184+ `g1t session attach --title "Fix the preview builds"` only if it can
185+ name the whole job: g1t keeps that title, unless the session goes on to
186+ other work items or pull requests, when it is named again as it ends.
187+ Rename a session on its page to pin your own title: g1t and Claude never
188+ replace it. See [Session titles](/guides/sessions/#session-titles).
188189 2. **It shows to the team.** The project's overview and its **Sessions** page
189190 list it in **Live now** under its pull request, else its branch, else
190191 under **No branch yet**, with who it is for, Claude Code, its state,
330331 the same time; the `g1t` CLI is how you see them and how they see you.
331332
332333 - When you start, record a session and keep its returned ID:
333− `g1t session start --harness codex --title "Fix the preview builds" --no-remember`
334+ `g1t session start --harness codex --no-remember`
334335 (or `cursor`, `copilot`, `other`: the harness you are). Set `G1T_SESSION`
335336 for every command in this run: `G1T_SESSION=ses_ID g1t ...` in Bash,
336337 or `$env:G1T_SESSION='ses_ID'; g1t ...` in PowerShell. Shell state may
340341 few minutes and `g1t session end` when you stop. Only create subagents
341342 when the user's task permits delegation; give each its own session with
342343 `--parent PARENT_ID --no-remember` and its own G1T_SESSION.
344+- Log what you are asked as the session's prompt:
345+ `g1t session log --kind prompt "TEXT"` (it shows only to whoever typed
346+ it). g1t names the session from it for now, and settles a title from all
347+ the session does: its work items, pull requests and prompts. Set one
348+ with `g1t session attach --title "…"` only if you can name the whole job.
343349 - With no work item, just work: the session shows to the team. When the
344350 work becomes a change others should know about (a branch to push, a pull
345351 request), look for its work item (`g1t work list`) and claim it; if
+44−9
1717 - **Dependencies**: the work items that have to land before this one.
1818
1919 Until there is a work item, the session is the unit: work that starts from
20−a question or a quick request is recorded as a session titled with what it
21−is for, and shows to the team on its branch or pull request. It opens or
20+a question or a quick request is recorded as a session, named by g1t from
21+what it does ([Session titles](#session-titles)), and shows to the team on its branch or pull request. It opens or
2222 claims a work item only when it becomes a change others should know about;
2323 see [Work with no work item yet](#work-with-no-work-item-yet).
2424
4343 is not the default branch), the most recently heard from first; those
4444 on neither are last, under **No branch yet**. Each session shows its
4545 title, who it acts for, its harness, its state, when it was last heard
46− from and its branch. A session with no title is named by what its
47− first prompt was for, else **Session on** its branch, else **Untitled
48− session**. A title that is only a tag, such as
46+ from and its branch. A provisional title (what its first prompt asked
47+ for, until g1t settles one) shows muted. A session with no title is
48+ named by what its first prompt was for, else **Session on** its branch,
49+ else **Untitled session**. A title that is only a tag, such as
4950 `<pasted_content id="5c50">`, counts as no title.
5051 - **Merge queue**: what is waiting to land and what is being tested,
5152 as on the [Merge queue](/guides/merge-queue/#the-merge-queue-page) page.
9091 Anthropic AI provider: see
9192 [Start a session on your Anthropic account](#start-a-session-on-your-anthropic-account).
9293
94+## Session titles
95+
96+A session's title names the whole of its work, and settles instead of
97+following each turn. Each title says where it came from (`title_source`)
98+and when it settled (`title_settled_at`):
99+
100+| Source | What it is |
101+| --- | --- |
102+| `prompt` | Provisional: what its first prompt asked for (its first line or sentence, without pasted text, image placeholders, code or markdown). Shown muted. |
103+| `item`, `pull`, `summary` | Settled by g1t from the session's record. |
104+| `agent` | Its harness or agent named it (`title` on start or update, `g1t session attach --title`). |
105+| `person` | Someone renamed it on its page: pinned. Neither g1t nor the agent replaces it. **Let g1t name it** unpins it. |
106+
107+g1t settles a provisional title once there is enough to go on: the session
108+claims or is attached to a work item, opens or pushes a pull request,
109+records three prompts, or has run for 30 minutes. It names it again only
110+when what the session works on changes (another work item or pull
111+request, or many more prompts), at most every 20 minutes and only when the
112+new title says something else, and a last time when the session ends,
113+from everything it did. An agent's own title is kept, unless the session
114+went on to other work items or pull requests after it was named, when it
115+is named again as it ends.
116+
117+With an [AI provider](/guides/ai/) chosen for Ask, the provider names the
118+session in a few words, on its fast model, from a short digest of the
119+record: its work items and pull requests, its prompts (as recorded, with
120+credentials taken out), its subagents' tasks, where it changed files, its
121+commits and what it reported. A session is asked about at most five times,
122+and one whose work is a single work item or pull request takes that title
123+without asking. Without a provider, or when it fails, the title is the
124+work item's, else the pull request's, else what the session's most telling
125+prompt asks for, without greetings and preambles ("This project used
126+to…", "I think", "So"). A subagent goes by its task and is not titled.
127+
93128 ## Picking up a work item
94129
95130 Before working on a work item, load its whole context in one call:
132167 session already started, so a hook can call it every time without
133168 checking first. Give `item` (the work item's number in the
134169 repository's workspace), `pull` and `branch` when you know them, and a
135− `title`: what the session is for, in a few words (up to 200
136− characters).
170+ `title` only if you can name the whole job in a few words (up to 200
171+ characters): otherwise g1t names it (see [Session titles](#session-titles)).
137172 2. **Heartbeat** with `session` `update`, or
138173 [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/)
139174 with an empty body. The same call attaches the session to a work item
140− or pull request, records the `head_commit` it pushed, or changes its
141− `title`.
175+ or pull request, records the `head_commit` it pushed, or names it
176+ (`title`; a title a person pinned stays).
142177 3. **Record** what happens with `session` `record`, or
143178 [`POST /repos/{owner}/{name}/sessions/{id}/entries`](/reference/api/sessions/record-session-entries/):
144179 prompts, reasoning, tool calls, notes and the skills it used (`skill`,
+14−3
6767 type SessionEnder,
6868 sessionTitle,
6969 sessionTree,
70+ titleHint,
71+ titleIsProvisional,
7072 } from "../lib/coordination";
7173 import { AgentSetup } from "./agent-setup";
7274 import { Input, SubmitButton, TimeAgo } from "./ui";
309311 <Actor username={session.actor.username} harness={session.harness} />
310312 <div className="min-w-0 grow">
311313 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
312− <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
313− {sessionTitle(session)}
314− </Link>
314+ {titleIsProvisional(session) ? (
315+ // Muted while it is what the first prompt asked: g1t names it once the work is clear.
316+ <Hint label={titleHint(session)}>
317+ <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium text-muted hover:text-accent">
318+ {sessionTitle(session)}
319+ </Link>
320+ </Hint>
321+ ) : (
322+ <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
323+ {sessionTitle(session)}
324+ </Link>
325+ )}
315326 <HarnessBadge harness={session.harness} />
316327 <SessionStateBadge state={session.state} />
317328 </p>
+17−0
2424 resolutionOf,
2525 sessionTree,
2626 span,
27+ titleHint,
28+ titleIsProvisional,
2729 standingFailure,
2830 touchedByKind,
2931 treeSize,
180182 assert.equal(sessionTitle({ title: null, branch: null }, "look at the CI"), "Look at the CI", "a first prompt given stands in");
181183 });
182184
185+test("a title from the first prompt is provisional until g1t, the agent or a person names the session", () => {
186+ assert.equal(titleIsProvisional({ title: "This project used to be in syntaqx/g1t", titleSource: "prompt", parentId: null }), true);
187+ assert.equal(titleIsProvisional({ title: null, titleSource: null, parentId: null }), true);
188+ for (const titleSource of ["agent", "person", "item", "pull", "summary"] as const) {
189+ assert.equal(titleIsProvisional({ title: "Move issues to Work items", titleSource, parentId: null }), false, titleSource);
190+ }
191+ // From before titles said where they came from: as it is.
192+ assert.equal(titleIsProvisional({ title: "Rewrite the auth flow", parentId: null }), false);
193+ // A subagent goes by its task.
194+ assert.equal(titleIsProvisional({ title: null, titleSource: null, parentId: "ses_1" }), false);
195+ assert.match(titleHint({ title: "Mine", titleSource: "person", parentId: null }) ?? "", /keeps this title/);
196+ assert.match(titleHint({ title: "x", titleSource: "prompt", parentId: null }) ?? "", /first prompt/);
197+ assert.equal(titleHint({ title: "x", titleSource: "agent", parentId: "ses_1" }), undefined);
198+});
199+
183200 test("a prompt's intent is its first line or sentence, plain, clipped on a word", () => {
184201 assert.equal(intentTitle("Make preview builds faster. They time out on big repositories."), "Make preview builds faster");
185202 // What an editor puts ahead of the prompt is not the prompt.
+30−2
128128 * What a prompt is for, as a session's title: its first line or sentence,
129129 * without code blocks or markdown, whitespace collapsed, clipped at
130130 * TITLE_LIMIT on a word with "…". Null for a slash command, a turn the
131− * harness starts itself, or nothing to say. The Claude Code plugin
132− * (plugins/claude-code/hooks/g1t.mjs) titles its sessions the same way.
131+ * harness starts itself, or nothing to say. The work service gives a
132+ * session its provisional title the same way, from its first prompt
133+ * (services/work/src/coordination/titles.rs `intent_title`).
133134 */
134135 export function intentTitle(prompt: string | null | undefined): string | null {
135136 let text = String(prompt ?? "").replace(/^\uFEFF/, "");
183184 return session.branch ? `Session on ${session.branch}` : "Untitled session";
184185 }
185186
187+/**
188+ * Whether what a session is called is still provisional: what its first
189+ * prompt asked, until g1t settles a title from all it did. Shown muted. A
190+ * subagent goes by its task, which is never provisional.
191+ */
192+export function titleIsProvisional(session: Pick<WorkSession, "title" | "titleSource" | "parentId">): boolean {
193+ if (session.parentId) return false;
194+ return session.titleSource === "prompt" || (!session.title?.trim() && session.titleSource == null);
195+}
196+
197+/** Why a title reads as it does, for a hint on it. */
198+export function titleHint(session: Pick<WorkSession, "title" | "titleSource" | "parentId">): string | undefined {
199+ if (session.parentId) return undefined;
200+ switch (session.titleSource) {
201+ case "person":
202+ return "Named by a person: g1t keeps this title.";
203+ case "agent":
204+ return "Named by its agent.";
205+ case "item":
206+ case "pull":
207+ case "summary":
208+ return "Named by g1t from what the session did.";
209+ default:
210+ return titleIsProvisional(session) ? "From its first prompt, for now: g1t names it once what it is doing is clear." : undefined;
211+ }
212+}
213+
186214 // ── Live now ────────────────────────────────────────────────────────────
187215
188216 /** One work item being worked on: its claims and the sessions on it. */
+71−4
1−import { ArrowLeft, Bot, ChevronRight, ExternalLink, GitBranch, GitCommitHorizontal, Sparkles, Square } from "lucide-react";
1+import { ArrowLeft, Bot, ChevronRight, ExternalLink, GitBranch, GitCommitHorizontal, Pencil, Sparkles, Square } from "lucide-react";
22 import { type ReactNode, useState } from "react";
33 import { Form, Link, redirect } from "react-router";
44
88 import { page } from "../../lib/meta";
99 import { Actor, CoordinationFeed, EndSessionDialog, HarnessBadge, SessionStateBadge } from "../../components/coordination";
1010 import { SessionEntries } from "../../components/session-entries";
11−import { EmptyState, SubmitButton, TimeAgo } from "../../components/ui";
11+import { EmptyState, Input, SubmitButton, TimeAgo } from "../../components/ui";
1212 import { Alert } from "../../components/ui/alert";
1313 import { Badge } from "../../components/ui/badge";
1414 import { Button } from "../../components/ui/button";
2929 pendingLines,
3030 sessionTitle,
3131 sessionTree,
32+ titleHint,
33+ titleIsProvisional,
3234 touchedByKind,
3335 viaLabel,
3436 } from "../../lib/coordination";
115117 if (!ended) return { error: "The session could not be ended. Try again in a moment." };
116118 return ended.ok ? { ended: true } : { error: ended.error.message };
117119 }
118− if (form.get("action") !== "stop") return { error: "There is nothing to do here but stop or end the session." };
120+ // Naming it pins its title; an empty one lets g1t name it again. The work service checks who may.
121+ if (form.get("action") === "title") {
122+ const title = String(form.get("title") ?? "").trim();
123+ if (title.length > 200) return { error: "A title is at most 200 characters." };
124+ const titled = await work.titleWorkSession(user, path, params.id, title || null).catch(() => null);
125+ if (!titled) return { error: "The title could not be saved. Try again in a moment." };
126+ return titled.ok ? { titled: true } : { error: titled.error.message };
127+ }
128+ if (form.get("action") !== "stop") return { error: "There is nothing to do here but stop, end or name the session." };
119129 const result = await agentSessions.stop(user, path, params.id).catch(() => null);
120130 if (!result) return { error: "The session could not be stopped. Try again in a moment." };
121131 return result.ok ? null : { error: result.error.message };
122132 }
123133
134+/**
135+ * The session's name, muted while it is provisional, and for whoever may,
136+ * a way to name it (pinning it: g1t keeps a person's title) or to let g1t
137+ * name it again.
138+ */
139+function SessionHeading({ session, name, canTitle }: { session: WorkSession; name: string; canTitle: boolean }) {
140+ const [editing, setEditing] = useState(false);
141+ const provisional = titleIsProvisional(session);
142+ if (editing) {
143+ return (
144+ <div className="flex min-w-0 grow flex-wrap items-center gap-2">
145+ <Form method="post" className="flex min-w-0 grow items-center gap-2" onSubmit={() => setEditing(false)}>
146+ <input type="hidden" name="action" value="title" />
147+ <Input
148+ name="title"
149+ defaultValue={provisional ? "" : (session.title ?? "")}
150+ placeholder={name}
151+ maxLength={200}
152+ required
153+ autoFocus
154+ aria-label="Session title"
155+ />
156+ <SubmitButton size="xs" match={{ action: "title" }} pending="Saving…">
157+ Save
158+ </SubmitButton>
159+ <Button type="button" variant="ghost" size="xs" onClick={() => setEditing(false)}>
160+ Cancel
161+ </Button>
162+ </Form>
163+ {session.titleSource === "person" && (
164+ <Form method="post" onSubmit={() => setEditing(false)}>
165+ <input type="hidden" name="action" value="title" />
166+ <input type="hidden" name="title" value="" />
167+ <Button type="submit" variant="ghost" size="xs">
168+ Let g1t name it
169+ </Button>
170+ </Form>
171+ )}
172+ </div>
173+ );
174+ }
175+ const hint = titleHint(session);
176+ const text = <span className="min-w-0">{name}</span>;
177+ return (
178+ <h2 className={`flex min-w-0 grow items-center gap-1.5 text-lg font-semibold tracking-tight wrap-break-word ${provisional ? "text-muted" : ""}`}>
179+ {hint ? <Hint label={hint}>{text}</Hint> : text}
180+ {canTitle && (
181+ <Hint label="Rename">
182+ <Button type="button" variant="ghost" size="icon-xs" aria-label="Rename this session" onClick={() => setEditing(true)}>
183+ <Pencil size={13} />
184+ </Button>
185+ </Hint>
186+ )}
187+ </h2>
188+ );
189+}
190+
124191 /** A part of the page under a heading. */
125192 function Section({ title, aside, children }: { title: ReactNode; aside?: ReactNode; children: ReactNode }) {
126193 return (
346413
347414 <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
348415 <Actor username={session.actor.username} harness={session.harness} size={24} />
349− <h2 className="min-w-0 grow text-lg font-semibold tracking-tight wrap-break-word">{sessionName(session, firstPrompt)}</h2>
416+ <SessionHeading session={session} name={sessionName(session, firstPrompt)} canTitle={canEnd && !isSubagent} />
350417 {isSubagent && session.agentType && (
351418 <Badge tone="accent">
352419 <Bot size={11} />
+21−0
283283 pub problem: Option<String>,
284284 }
285285
286+/// `ai_brief`: a short answer from the AI provider a workspace chose for
287+/// Ask, on its fast model, for g1t's own use (a session's title). Only
288+/// services ask it, over their bindings. Returns `Outcome<Option<String>>`:
289+/// none when the workspace has no provider chosen, a failure when the
290+/// provider was asked and could not answer.
291+#[derive(Debug, Serialize, Deserialize)]
292+pub struct AiBriefArgs {
293+ pub workspace: String,
294+ pub system: String,
295+ /// What to answer about, already bounded by the caller (at most
296+ /// [`MAX_BRIEF_INPUT_CHARS`] are sent).
297+ pub input: String,
298+ /// At most [`MAX_BRIEF_TOKENS`].
299+ pub max_tokens: u32,
300+}
301+
302+/// The most of an [`AiBriefArgs`] input sent to the provider.
303+pub const MAX_BRIEF_INPUT_CHARS: usize = 6_000;
304+/// The longest answer an [`AiBriefArgs`] may ask for.
305+pub const MAX_BRIEF_TOKENS: u32 = 200;
306+
286307 /// `ask_settings`: what Ask answers with. Returns `Outcome<AskSetup>`.
287308 /// Members only.
288309 #[derive(Debug, Serialize, Deserialize)]
+79−0
206206 /// what it is for until it has a title.
207207 #[serde(default, skip_serializing_if = "Option::is_none")]
208208 pub first_prompt: Option<String>,
209+ /// Where its title came from; none for one with no title, or one from
210+ /// before titles said.
211+ #[serde(default)]
212+ pub title_source: Option<TitleSource>,
213+ /// RFC 3339: when its title was last settled (by g1t, its agent or a
214+ /// person).
215+ #[serde(default)]
216+ pub title_settled_at: Option<String>,
217+}
218+
219+/// Where a session's title came from. g1t settles a title from the
220+/// session's whole record (coordination/titles.rs in services/work): a
221+/// [`TitleSource::Prompt`] title is provisional until it does, a
222+/// [`TitleSource::Person`] one is pinned and never replaced.
223+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
224+#[serde(rename_all = "snake_case")]
225+pub enum TitleSource {
226+ /// What its first prompt was for: provisional.
227+ Prompt,
228+ /// Its harness or agent named it (`g1t session attach --title`). Kept,
229+ /// unless the session went on to other work items or pull requests
230+ /// after it was named, when it is settled again as it ends.
231+ Agent,
232+ /// A person named it from the site: pinned.
233+ Person,
234+ /// The title of the work item it works on.
235+ Item,
236+ /// The title of the pull request it opened or works on.
237+ Pull,
238+ /// g1t named the whole of its work: with the workspace's AI provider,
239+ /// or from its most telling prompt.
240+ Summary,
209241 }
210242
243+impl TitleSource {
244+ pub fn as_str(self) -> &'static str {
245+ match self {
246+ TitleSource::Prompt => "prompt",
247+ TitleSource::Agent => "agent",
248+ TitleSource::Person => "person",
249+ TitleSource::Item => "item",
250+ TitleSource::Pull => "pull",
251+ TitleSource::Summary => "summary",
252+ }
253+ }
254+
255+ pub fn parse(name: &str) -> Option<TitleSource> {
256+ match name.trim() {
257+ "prompt" => Some(TitleSource::Prompt),
258+ "agent" => Some(TitleSource::Agent),
259+ "person" => Some(TitleSource::Person),
260+ "item" => Some(TitleSource::Item),
261+ "pull" => Some(TitleSource::Pull),
262+ "summary" => Some(TitleSource::Summary),
263+ _ => None,
264+ }
265+ }
266+
267+ /// Whether g1t settled it from the session's record.
268+ pub fn settled(self) -> bool {
269+ matches!(self, TitleSource::Item | TitleSource::Pull | TitleSource::Summary)
270+ }
271+}
272+
273+/// `title_session`: a person names a session from the site, pinning its
274+/// title: g1t never replaces it, nor does its agent. No `title` unpins
275+/// it: g1t names it again from its record. Whoever runs the session may,
276+/// and anyone with write access to the repository. Returns
277+/// `Outcome<Session>`.
278+#[derive(Debug, Serialize, Deserialize)]
279+pub struct TitleSessionArgs {
280+ pub actor: User,
281+ pub repo: RepoPath,
282+ pub id: String,
283+ #[serde(default)]
284+ pub title: Option<String>,
285+}
286+
287+/// The most characters a session's title keeps.
288+pub const MAX_SESSION_TITLE_CHARS: usize = 200;
289+
211290 /// The most characters of an untitled session's first prompt a list gives.
212291 pub const MAX_FIRST_PROMPT_CHARS: usize = 300;
213292
+1−0
623623 getWorkSession: (repo, id, viewer, afterSeq = 0) =>
624624 call("get_work_session", { repo, id, viewer, after_seq: afterSeq }),
625625 endWorkSession: (actor, repo, id) => call("end_session", { actor, repo, id, by_hand: true }),
626+ titleWorkSession: (actor, repo, id, title) => call("title_session", { actor, repo, id, title }),
626627 pullSessions: (repo, viewer, numbers) => call("pull_sessions", { repo, viewer, numbers }),
627628 listDecisions: (thread, viewer) => call("list_decisions", { ...threadArgs(thread), viewer }),
628629 };
+20−0
14381438 subagents?: number;
14391439 /** In a list, for one with no title: the start of the first prompt it recorded. */
14401440 firstPrompt?: string | null;
1441+ /** Where its title came from; none for one with no title, or from before titles said. */
1442+ titleSource?: TitleSource | null;
1443+ /** When its title was last settled. */
1444+ titleSettledAt?: string | null;
14411445 };
14421446
1447+/**
1448+ * Where a session's title came from. g1t settles a title from the whole of
1449+ * the session's record: `prompt` is provisional (its first prompt's
1450+ * intent) until it does; `person` is pinned from the site and never
1451+ * replaced; `agent` is its agent's own (`g1t session attach --title`);
1452+ * `item`, `pull` and `summary` are g1t's.
1453+ */
1454+export type TitleSource = "prompt" | "agent" | "person" | "item" | "pull" | "summary";
1455+
14431456 /** What kind of thing a session touched. */
14441457 export type TouchKind = "file" | "commit" | "branch" | "pull" | "item";
14451458
17141727 */
17151728 endWorkSession(actor: User, repo: RepoPath, id: string): Promise<Result<WorkSession>>;
17161729 /**
1730+ * A person names a session from the site, pinning its title: neither
1731+ * g1t nor its agent replaces it. No title unpins it, and g1t names it
1732+ * again from its record. The actor's own, or anyone's with write access
1733+ * to the repository (`title_session`).
1734+ */
1735+ titleWorkSession(actor: User, repo: RepoPath, id: string, title: string | null): Promise<Result<WorkSession>>;
1736+ /**
17171737 * The harness sessions tied to some pull requests (at most 100), any way
17181738 * a session is tied to one; a subagent's as the session that started it.
17191739 * Only pull requests with any are listed.
+13−113
99 // work item, g1t work view N, given to Claude as context
1010 // UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters)
1111 // and, for a plugin's skill typed as /plugin:name,
12−// g1t session log --kind skill NAME; at the session's
13−// first prompt that is not a slash command, its intent
14−// as the title, unless it has one: g1t session heartbeat
15−// --json, then g1t session attach --title INTENT
12+// g1t session log --kind skill NAME. g1t names the
13+// session itself: for now from its first prompt, then
14+// from all it did (services/work/src/coordination/titles.rs)
1615 // PreToolUse Agent: the task it is given, kept until its subagent starts,
1716 // which is paired with it by the call's tool_use_id or its
1817 // prompt (see pairSubagent).
6665 export const SUMMARY_LIMIT = 200;
6766 /** The most of a work item's work context given to Claude. */
6867 export const CONTEXT_LIMIT = 8000;
69−/** The most of a prompt's intent that titles a session. */
70−export const TITLE_LIMIT = 80;
7168 /** The most of a subagent's task (its description) that is sent. */
7269 export const TASK_LIMIT = 200;
7370 /** The most of a subagent's prompt that is sent. */
269266 .join("\n")
270267 .trim();
271268 }
272−
273−/** A turn the harness starts itself, as a tag: `<command-name>`, `<task-notification>`, one with attributes. */
274−const HARNESS_TAG = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i;
275269
276−/** What an editor adds ahead of a prompt, a whole block at a time: `<ide_opened_file>…</ide_opened_file>`. */
277−const IDE_CONTEXT = /^\s*<(ide_[a-z_]+|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/i;
278−
279−/**
280− * What a prompt is for, as a session's title: its first line or sentence,
281− * without code blocks or markdown, whitespace collapsed, clipped at
282− * TITLE_LIMIT on a word with "…". Null for a slash command (/clear,
283− * /g1t:status 4), a turn Claude Code starts itself, or nothing to say.
284− * apps/web/app/lib/coordination.ts reads an untitled session's first
285− * prompt the same way.
286− */
287−export function intentTitle(prompt) {
288− let text = String(prompt ?? "").replace(/^\uFEFF/, "");
289− // What the harness pasted in or attached is not what the prompt is for:
290− // pasted text (Claude Code's <pasted_content id="…">…</pasted_content>)
291− // and image placeholders ([Image #1], [Image: source: …]) go first.
292− text = text
293− .replace(/<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content[^>]*>|$)/gi, "\n")
294− .replace(/\[Image(?: #\d+)?(?::[^\]\n]*)?\]/g, " ");
295− // Context an editor puts before what was typed (VS Code's
296− // <ide_opened_file>, <ide_selection>) is not a turn the harness started.
297− while (IDE_CONTEXT.test(text)) text = text.replace(IDE_CONTEXT, "");
298− if (/^\s*\//.test(text) || HARNESS_TAG.test(text)) return null;
299− text = text
300− .replace(/```[\s\S]*?(```|$)/g, "\n")
301− .replace(/~~~[\s\S]*?(~~~|$)/g, "\n")
302− .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
303− .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
304− .replace(/`([^`]*)`/g, "$1");
305− const line = text
306− .split(/\r?\n/)
307− .map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "").replace(/(\*\*|__|~~)(.+?)\1/g, "$2").trim())
308− .find((l) => /[\p{L}\p{N}]/u.test(l));
309− if (!line) return null;
310− // Its first sentence: up to a stop followed by a space.
311− let intent = line.replace(/\s+/g, " ").split(/(?<=[.!?])\s+(?=\S)/)[0].trim();
312− if (intent.length > TITLE_LIMIT) {
313− const cut = intent.slice(0, TITLE_LIMIT - 1);
314− const space = cut.lastIndexOf(" ");
315− intent = `${(space > TITLE_LIMIT / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, "")}…`;
316− } else {
317− intent = intent.replace(/[\s,;:.]+$/, "");
318− }
319− if (intent.length < 2) return null;
320− return intent[0].toLocaleUpperCase() + intent.slice(1);
321−}
322−
323270 /** The plugin skill a prompt runs when it is typed as one: `g1t:work-on-item` for `/g1t:work-on-item 42`. */
324271 export function typedSkill(prompt) {
325272 const text = String(prompt ?? "").replace(/^/, "");
372319 // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent.
373320 const skill = typedSkill(prompt);
374321 if (skill) job.steps.push(skillStep(skill));
375− // What it is for titles the session, until it has a title (see hook and work).
376− const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(redact(prompt));
377− if (intent) job.steps.push({ title: intent });
322+ // g1t names the session from its prompts and all it does: nothing to send for that.
378323 return job.steps.length ? job : null;
379324 }
380325 case "SubagentStart":
606551 shown: (session) => join(dir, `${safe}.shown.${safeName(session)}.json`),
607552 item: join(dir, `${safe}.item`),
608553 turn: join(dir, `${safe}.turn`),
609− titled: join(dir, `${safe}.titled`),
610554 task: (key) => join(dir, `${safe}.task.${safeName(key)}.json`),
611555 shell: (toolUseId) => join(dir, `${safe}.shell.${safeName(toolUseId)}.json`),
612556 unrecorded: join(dir, `${safe}.unrecorded.json`),
856800 child.on("error", () => {});
857801 child.unref();
858802 } catch {}
859−}
860−
861−/** Whether a session, as `--json` prints it, has a title; unreadable reads as having one, so nothing is replaced. */
862−export function hasTitle(stdout) {
863− try {
864− const title = JSON.parse(stdout).title;
865− return typeof title === "string" && title.trim() !== "";
866− } catch {
867− return true;
868− }
869803 }
870804
871805 /** The session id in a `g1t session start --json` answer. */
975909 if (branch && branch !== "HEAD") touch.push("--branch", branch);
976910 cli(bin, touch, opts);
977911 }
978− } else if (step.title) {
979− // Only a session with no title: one set before (by the agent, a
980− // person, or this at an earlier prompt) is never replaced.
981− const seen = cli(bin, ["session", "heartbeat", "--json"], opts);
982− if (seen.status === EXIT_AUTH) return setProblem(env, "signed_out");
983− if (seen.status === EXIT_USAGE) return setProblem(env, "refused", "session heartbeat");
984− if (seen.status !== 0 || hasTitle(seen.stdout)) continue;
985− const out = cli(bin, ["session", "attach", `--title=${step.title}`], opts);
986− if (out.status === EXIT_USAGE) return setProblem(env, "refused", "session attach");
987912 } else if (step.touchHead) {
988913 const head = git(job.cwd, ["rev-parse", "HEAD"]);
989914 if (head) cli(bin, ["session", "touch", "--commit", head], opts);
1023948 const TRAILER = "Add the trailer `G1t-Session: $G1T_SESSION` to the commits you make (git commit --trailer), so g1t ties them to this session. A commit pushed straight to the default branch that finishes a work item says `Closes #N` in its message (`Part of #N` when it does only part of it): a bare `(#N)` only links it, and the item stays open.";
1024949
1025950 /**
1026− * How the agent keeps its session's title true: it starts as the first
1027− * prompt's intent, which often stops being what the session is about.
951+ * What the agent is told of its session's title, once: g1t names the
952+ * session from all it does, so it sets one only for the whole job.
1028953 */
1029−export const RETITLE = 'Its title starts as your first prompt; once what you are actually doing is clear, and whenever it changes, retitle it in a few words with `g1t session attach --title "…"`.';
1030−
1031−/**
1032− * At which prompts the agent is reminded to retitle: the second (what the
1033− * work is has usually shown by then) and every fifth after.
1034− */
1035−export function retitleNudge(turn) {
1036− return turn === 2 || (turn > 2 && turn % 5 === 0) ? `g1t: if this session's title no longer says what it is doing, retitle it: \`g1t session attach --title "…"\`.` : null;
1037−}
954+export const TITLE_NOTE = 'g1t names the session from what it does; set a title with `g1t session attach --title "…"` only if you can name the whole job.';
1038955
1039956 /**
1040957 * SessionStart: marks the session as one in a g1t working copy, and
1077994 context = `g1t: ${where} points at work item #${item}. Load what is known about it with \`g1t work view ${item}\`, and claim it with \`g1t work claim ${item}\` before you start. ${TRAILER}`;
1078995 }
1079996 } else {
1080− context = `g1t: this session is recorded on g1t and shows to the team, titled with what you are asked to do. Just work. If it turns into a change others should know about (a branch to push, a pull request), first look for its work item (\`g1t work list\`) and claim it with \`g1t work claim N\`; if there is none, open a work item and claim it with \`g1t work claim --title "…"\`. Never open work items for questions, exploration or trivial fixes. ${RETITLE} ${TRAILER}`;
997+ context = `g1t: this session is recorded on g1t and shows to the team. Just work. If it turns into a change others should know about (a branch to push, a pull request), first look for its work item (\`g1t work list\`) and claim it with \`g1t work claim N\`; if there is none, open a work item and claim it with \`g1t work claim --title "…"\`. Never open work items for questions, exploration or trivial fixes. ${TITLE_NOTE} ${TRAILER}`;
1081998 }
1082999 if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context };
10831000 return output;
11721089 * A session that started where there was no g1t remote (SessionStart left
11731090 * it unrecorded) is recorded once there is one: looked for at each prompt,
11741091 * and after a shell command that may have added one (mayAddRemote), never
1175− * after other tools. Returns what SessionStart would have printed, and the
1176− * title its first prompt with an intent gave it, or null while there is no
1177− * g1t remote (keeping that title for later).
1092+ * after other tools. Returns what SessionStart would have printed, or null
1093+ * while there is no g1t remote.
11781094 */
11791095 export function lateStart(event, env, files) {
11801096 const name = event?.hook_event_name;
11811097 if (name !== "UserPromptSubmit" && name !== "PostToolUse") return null;
11821098 if (name === "PostToolUse" && !(SHELL_TOOLS.has(event.tool_name) && mayAddRemote(event.tool_input?.command))) return null;
11831099 if (!existsSync(files.unrecorded)) return null;
1184− const kept = readJson(files.unrecorded, {}) ?? {};
11851100 const output = sessionStart(event, env, findCli(env));
1186− if (!output) {
1187− const intent = name === "UserPromptSubmit" && !kept.title && env.G1T_RECORD_PROMPTS !== "0" ? intentTitle(redact(event.prompt)) : null;
1188− if (intent) write(files.unrecorded, JSON.stringify({ ...kept, title: intent }));
1189− return null;
1190− }
1101+ if (!output) return null;
11911102 rmSync(files.unrecorded, { force: true });
1192− return { output, title: kept.title ?? null };
1103+ return { output };
11931104 }
11941105
11951106 /** Runs one hook: what it prints, and the job it hands to the background. */
12311142 if (name === "SessionEnd" && !existsSync(files.marker)) rmSync(files.unrecorded, { force: true });
12321143 // Only sessions found in a g1t working copy are recorded.
12331144 if (!existsSync(files.marker) || !findCli(env)) return { output: null, job: null };
1234− if (late) {
1235− job.start = true;
1236− // What its first prompt was for titles it, over what this one is for.
1237− if (late.title) job.steps = [...job.steps.filter((step) => !step.title), { title: late.title }];
1238− }
1145+ if (late) job.start = true;
12391146 for (const step of job.steps) if (step.skill) step.key = `skill:${safeName(step.skill)}:${turn}`;
1240− // The first prompt with an intent titles the session; later ones do not try.
1241− if (job.steps.some((step) => step.title)) {
1242− if (existsSync(files.titled)) job.steps = job.steps.filter((step) => !step.title);
1243− else write(files.titled, "1");
1244− }
12451147 if (name === "SubagentStart") {
12461148 // Paired with its Agent call in the background half (pairSubagent), which may wait for Claude Code's files.
12471149 job.pair = true;
12531155 const context = [
12541156 late?.output.hookSpecificOutput?.additionalContext,
12551157 name === "UserPromptSubmit" ? updateContext(event, env) : null,
1256− // Only while it is being recorded: not with the CLI signed out or too old.
1257− name === "UserPromptSubmit" && !readJson(join(dataDir(env), "notice.json"), {}).problem ? retitleNudge(turn) : null,
12581158 ]
12591159 .filter(Boolean)
12601160 .join("\n\n");
+22−79
88 import { fileURLToPath } from "node:url";
99
1010 import {
11− retitleNudge,
11+ TITLE_NOTE,
1212 updateContext,
1313 UPDATES_REPEAT_MS,
1414 howToUpdate,
1717 agentResult,
1818 branchFromCommand,
1919 g1tTool,
20− hasTitle,
2120 hook,
22− intentTitle,
2321 repoPath,
2422 MIN_CLI,
25− TITLE_LIMIT,
2623 oldNode,
2724 older,
2825 typedSkill,
7370 assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, {}).steps[0].args.at(-1), "hi");
7471 });
7572
76− test("a prompt's intent is its first line or sentence, plain, clipped on a word", () => {
77− assert.equal(intentTitle("fix the login redirect"), "Fix the login redirect");
78− assert.equal(intentTitle("Make preview builds faster. They time out on big repositories."), "Make preview builds faster");
79− assert.equal(intentTitle("Why does `src/app.ts` fail? It worked yesterday."), "Why does src/app.ts fail?");
80− assert.equal(intentTitle("```js\nconst a = 1;\n```\n## Clean **this** up\nmore"), "Clean this up");
81− assert.equal(intentTitle("\n\n- look at [the docs](https://docs.g1t.sh) for g1t.mjs"), "Look at the docs for g1t.mjs");
82− assert.equal(intentTitle(" spread over\tspaces "), "Spread over spaces");
83− const long = intentTitle("[TEST] Tidy up the README and then look at the CI pipeline, which keeps failing on the Windows runners");
84− assert.ok(long.length <= TITLE_LIMIT, long);
85− assert.ok(long.endsWith("…") && !long.endsWith(" …"), long);
86− assert.ok(long.startsWith("[TEST] Tidy up the README"), long);
87− assert.ok("[TEST] Tidy up the README and then look at the CI pipeline, which keeps failing on the Windows runners".startsWith(long.slice(0, -1)));
88− // What was pasted or attached is left out: the words typed around it title the session.
89− assert.equal(intentTitle('<pasted_content id="5c50">\nUncaught TypeError: x is undefined\n</pasted_content>\nAlso you broke the buttons'), "Also you broke the buttons");
90− assert.equal(intentTitle("[Image #1] the PR link is missing on Home"), "The PR link is missing on Home");
91− assert.equal(intentTitle("[Image: source: C:\\tmp\\1.png]\nmake it render"), "Make it render");
92− assert.equal(intentTitle('<pasted_content id="1">only a paste'), null);
93− // What VS Code puts ahead of what was typed is not a turn Claude Code started.
94− assert.equal(
95− intentTitle("<ide_opened_file>The user opened the file c:\\a\\Dockerfile in the IDE.</ide_opened_file>\nWhether it's the desktop app or the page, it crashes"),
96− "Whether it's the desktop app or the page, it crashes",
97− );
98− assert.equal(intentTitle("<ide_selection>const a = 1;</ide_selection>\n<ide_opened_file>a.ts</ide_opened_file>\nrename this"), "Rename this");
99− assert.equal(intentTitle("<ide_opened_file>a.ts</ide_opened_file>\n/clear"), null);
100− // Not intents: slash commands, turns Claude Code starts, nothing to say.
101− for (const prompt of ["/clear", "/compact keep the plan", "/g1t:status 4", "<command-name>/clear</command-name>", '<local-command-stdout lang="en">ok</local-command-stdout>', "```\ncode only\n```", "?", "", null]) {
102− assert.equal(intentTitle(prompt), null, String(prompt));
73+ test("UserPromptSubmit sends no title: g1t names the session from what it records", () => {
74+ for (const prompt of ["fix the login redirect", "/clear", "Build me a todo app. Use React."]) {
75+ const job = plan({ ...base, hook_event_name: "UserPromptSubmit", prompt }, {});
76+ assert.ok(job.steps.every((s) => s.args && !s.title && !s.args.includes("attach")), prompt);
10377 }
104− });
105−
106− test("UserPromptSubmit titles the session with its intent, unless prompts are kept back", () => {
107− const job = plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "fix the login redirect" }, {});
108− assert.deepEqual(job.steps.at(-1), { title: "Fix the login redirect" });
109− assert.ok(!plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "/clear" }, {}).steps.some((s) => s.title));
11078 assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "fix it" }, { G1T_RECORD_PROMPTS: "0" }), null);
111− assert.equal(hasTitle(JSON.stringify({ id: "ses_1", title: null })), false);
112− assert.equal(hasTitle(JSON.stringify({ id: "ses_1", title: " " })), false);
113− assert.equal(hasTitle(JSON.stringify({ id: "ses_1", title: "Fix it" })), true);
114− assert.equal(hasTitle("not json"), true, "unreadable: nothing is replaced");
11579 });
11680
11781 test("G1T_RECORD_PROMPTS=0 keeps prompts back, and G1T_RECORD=0 everything", () => {
600564 assert.equal(pairSubagent({ id: "a7", type: "Explore" }, files, join(dir, "t.jsonl"), { G1T_RECORD_PROMPTS: "0" }).prompt, undefined);
601565 });
602566
603− test("a session started with no g1t remote is recorded once a command adds one, titled by its first prompt", () => {
567+ test("a session started with no g1t remote is recorded once a command adds one", () => {
604568 git("remote", "remove", "origin");
605569 const at = (event) => ({ ...base, cwd: dir, ...event });
606570 assert.deepEqual(hook(at({ hook_event_name: "SessionStart", source: "startup" }), env), { output: null, job: null });
617581 assert.equal(job.start, true);
618582 assert.equal(output.hookSpecificOutput.hookEventName, "PostToolUse");
619583 assert.match(output.hookSpecificOutput.additionalContext, /work item #42/, "told what SessionStart would have told it");
620− assert.deepEqual(job.steps.at(-1), { title: "Build me a todo app" });
621584 work(job, env);
622585 const recorded = calls().filter((c) => c.args[0] === "session");
623586 assert.deepEqual(recorded[0].args, ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123", "--json"]);
624587 assert.ok(recorded.some((c) => c.args[1] === "log" && c.args.at(-1).startsWith("Bash: g1t repo create acme/app")));
625− assert.ok(recorded.some((c) => c.args.join(" ") === "session attach --title=Build me a todo app"));
626− // From here on it is recorded like any other, and titled once.
588+ // g1t names it, from what it records from here on: no title is sent.
589+ assert.ok(!recorded.some((c) => c.args[1] === "attach"));
627590 const next = hook(at({ hook_event_name: "UserPromptSubmit", prompt: "Now add tests" }), env).job;
628− assert.ok(next && !next.steps.some((s) => s.title) && !next.start);
591+ assert.ok(next && !next.start);
592+ assert.deepEqual(next.steps.map((s) => s.args.slice(0, 4)), [["session", "log", "--kind", "prompt"]]);
629593 });
630594
631595 test("a session given a g1t remote between prompts is recorded at the next one", () => {
636600 git("remote", "set-url", "origin", "https://g1t.sh/acme/app.git");
637601 const { job } = hook(at({ hook_event_name: "UserPromptSubmit", prompt: "fix the login" }), env);
638602 assert.equal(job.start, true);
639− assert.deepEqual(job.steps.at(-1), { title: "Fix the login" }, "no earlier prompt said what it is for: this one does");
603+ assert.equal(job.steps.at(-1).args.at(-1), "fix the login", "its prompt is recorded, and g1t names the session from it");
640604 assert.ok(existsSync(sessionFiles(base.session_id, env).marker));
641605 assert.ok(!existsSync(sessionFiles(base.session_id, env).unrecorded));
642606 });
836800 assert.match(context, /Just work/);
837801 assert.match(context, /`g1t work claim --title "…"`/);
838802 assert.match(context, /Never open work items for questions, exploration or trivial fixes/);
839− assert.match(context, /retitle it in a few words with `g1t session attach --title "…"`/);
803+ // Told once that g1t names the session, and when to name it itself.
804+ assert.ok(context.includes(TITLE_NOTE));
805+ assert.match(context, /only if you can name the whole job/);
806+ assert.doesNotMatch(context, /retitle/i);
840807 assert.ok(context.length < 1100, `injected every session, so short: ${context.length}`);
841808 assert.ok(!calls().some((c) => c.args[0] === "work"), "no work item's context is asked for");
842809 });
843810
844− test("Claude is reminded to retitle the session at its second prompt and every fifth after, no more", () => {
845− assert.deepEqual(
846− Array.from({ length: 16 }, (_, i) => i + 1).filter((turn) => retitleNudge(turn)),
847− [2, 5, 10, 15],
848− );
849− assert.match(retitleNudge(2), /g1t session attach --title/);
850− });
851−
852− test("the session's first prompt with an intent titles it, once, and never over a title it has", () => {
811+ test("Claude is never nudged to retitle, and no prompt sets a title", () => {
853812 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
854813 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
855− const prompt = (text, more = {}) => hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: text }, { ...env, ...more }).job;
856− // A slash command says nothing of what the session is for.
857− work(prompt("/clear"), env);
858− assert.ok(!calls().some((c) => c.args[1] === "attach" || c.args[1] === "heartbeat"));
859− work(prompt("[TEST] fix the preview builds. They time out."), env);
860− const titled = calls().filter((c) => c.args[1] === "heartbeat" || c.args[1] === "attach");
861− assert.deepEqual(titled, [
862− { args: ["session", "heartbeat", "--json"], session: "ses_fake01" },
863− { args: ["session", "attach", "--title=[TEST] fix the preview builds"], session: "ses_fake01" },
864− ]);
865− // Later prompts do not try again.
866− const next = prompt("now the tests");
867− assert.ok(!next.steps.some((s) => s.title));
868− });
869−
870− test("a session titled already keeps its title", () => {
871− hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
872− work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
873− const job = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env).job;
874− work(job, { ...env, FAKE_G1T_TITLE: "Rewrite the auth flow" });
875− assert.ok(calls().some((c) => c.args[1] === "heartbeat"));
876− assert.ok(!calls().some((c) => c.args[1] === "attach"), "a title set before is never replaced");
814+ for (const text of ["/clear", "[TEST] fix the preview builds. They time out.", "now the tests", "and the docs", "commit it", "push"]) {
815+ const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: text }, env);
816+ assert.doesNotMatch(JSON.stringify(output ?? {}), /retitle|attach --title/, text);
817+ if (job) work(job, env);
818+ }
819+ assert.ok(!calls().some((c) => c.args[1] === "attach" || (c.args[1] === "heartbeat" && c.args.includes("--json"))), "no title is set or looked for");
877820 });
878821
879822 test("Claude's own commands get the session's id once it has started", () => {
+6−1
44 the same time; the `g1t` CLI is how you see them and how they see you.
55
66 - When you start, record a session and keep its returned ID:
7− `g1t session start --harness codex --title "Fix the preview builds" --no-remember`
7+ `g1t session start --harness codex --no-remember`
88 (or `cursor`, `copilot`, `other`: the harness you are). Set `G1T_SESSION`
99 for every command in this run: `G1T_SESSION=ses_ID g1t ...` in Bash,
1010 or `$env:G1T_SESSION='ses_ID'; g1t ...` in PowerShell. Shell state may
1414 few minutes and `g1t session end` when you stop. Only create subagents
1515 when the user's task permits delegation; give each its own session with
1616 `--parent PARENT_ID --no-remember` and its own G1T_SESSION.
17+- Log what you are asked as the session's prompt:
18+ `g1t session log --kind prompt "TEXT"` (it shows only to whoever typed
19+ it). g1t names the session from it for now, and settles a title from all
20+ the session does: its work items, pull requests and prompts. Set one
21+ with `g1t session attach --title "…"` only if you can name the whole job.
1722 - With no work item, just work: the session shows to the team. When the
1823 work becomes a change others should know about (a branch to push, a pull
1924 request), look for its work item (`g1t work list`) and claim it; if
+57−1
2222 use g1t_secrets as crypto;
2323
2424 /// The methods this module answers.
25−pub const METHODS: &[&str] = &["ai_list", "ai_add", "ai_update", "ai_remove", "ai_test", "ask_settings", "set_ask_settings", "ask"];
25+pub const METHODS: &[&str] = &["ai_list", "ai_add", "ai_update", "ai_remove", "ai_test", "ask_settings", "set_ask_settings", "ask", "ai_brief"];
2626
2727 /// Answers one of [`METHODS`].
2828 pub async fn route(service: &Integrations, method: &str, body: Value) -> Result<Response> {
3535 "ask_settings" => reply(&service.ask_settings(args(body)?).await?),
3636 "set_ask_settings" => reply(&service.set_ask_settings(args(body)?).await?),
3737 "ask" => service.ask(args(body)?).await,
38+ "ai_brief" => reply(&service.ai_brief(args(body)?).await?),
3839 _ => Response::error("Unknown method", 404),
3940 }
4041 }
643644 Ok(Response::from_stream(lines)?.with_headers(headers))
644645 }
645646
647+ /// A short answer for g1t's own use (`AiBriefArgs`): the workspace's
648+ /// Ask provider, on its fast model when it lists one, without tools.
649+ /// None when the workspace has chosen none, or it cannot be used.
650+ pub(crate) async fn ai_brief(&self, a: AiBriefArgs) -> Result<Outcome<Option<String>>> {
651+ let workspace = a.workspace.to_lowercase();
652+ let (setup, row) = self.ask_setup(&workspace).await?;
653+ let (Some(row), None) = (row, setup.problem) else {
654+ return Ok(Outcome::Ok(None));
655+ };
656+ let model = if setup.auto { auto::choose(&auto_models(&row), row.config().model.as_deref(), auto::Need::Quick) } else { setup.model };
657+ let Some(model) = model else {
658+ return Ok(Outcome::Ok(None));
659+ };
660+ let provider = row.provider();
661+ let config = row.config();
662+ let key = self.ai_secret(&row);
663+ let input: String = a.input.chars().take(MAX_BRIEF_INPUT_CHARS).collect();
664+ let messages = [AskMessage { role: AskRole::User, content: input }];
665+ let built = match ask::build(&ask::Request {
666+ provider,
667+ config: &config,
668+ key: key.as_deref(),
669+ model: &model,
670+ system: &a.system,
671+ messages: &messages,
672+ rounds: &[],
673+ tools: &[],
674+ answer_now: false,
675+ max_tokens: a.max_tokens.clamp(1, MAX_BRIEF_TOKENS),
676+ }) {
677+ Ok(built) => built,
678+ Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
679+ };
680+ let headers: Vec<(&str, &str)> = built.headers.iter().map(|(name, value)| (name.as_str(), value.as_str())).collect();
681+ let mut response = match http::open(Method::Post, &built.url, &headers, Some(built.body)).await {
682+ Ok(response) => response,
683+ Err(error) => return Ok(fail(FailureCode::Unavailable, format!("{} could not be reached: {error}", provider.label()))),
684+ };
685+ let status = response.status_code();
686+ if !(200..300).contains(&status) {
687+ let problem = ask_loop::refused_by(provider, status, response.text().await.unwrap_or_default());
688+ self.ai_note(&row.id, Some(&problem)).await?;
689+ return Ok(fail(FailureCode::Unavailable, problem));
690+ }
691+ let bytes = response.bytes().await?;
692+ let mut round = ask::Round::new(provider, false);
693+ round.feed(&bytes);
694+ round.end();
695+ if let Some(problem) = round.error() {
696+ return Ok(fail(FailureCode::Unavailable, problem.to_owned()));
697+ }
698+ let text = round.text().trim().to_owned();
699+ Ok(Outcome::Ok((!text.is_empty()).then_some(text)))
700+ }
701+
646702 /// An answer that never started: its credential ends now.
647703 pub(crate) async fn ask_refused(&self, mut answering: ask_loop::Answering, problem: &str) -> Result<Response> {
648704 if let Some(id) = answering.credential.take() {
+32−0
1+-- Session titles that settle (src/coordination/titles.rs). A session's
2+-- title says where it came from (`title_source`): `prompt` (provisional,
3+-- the first prompt's intent), `agent` (its harness or agent said so),
4+-- `person` (pinned from the site, never replaced), `item` or `pull` (the
5+-- work item's or pull request's title) or `summary` (g1t named the whole
6+-- of the work, with the workspace's AI provider or without one).
7+--
8+-- g1t settles a title when one is due (`title_due_at`, read by the cron):
9+-- once the session has enough to go on, again only when what it works on
10+-- changes (at most every 20 minutes), and a last time when it ends
11+-- (`title_final`). `title_basis` is what it was settled from (its work
12+-- items and pull requests), `title_prompts` how many prompts it had then,
13+-- `title_calls` how many times the AI provider was asked (a handful at
14+-- most), and `prompts` how many prompts it recorded.
15+ALTER TABLE sessions ADD COLUMN title_source TEXT;
16+ALTER TABLE sessions ADD COLUMN title_settled_at TEXT;
17+ALTER TABLE sessions ADD COLUMN title_due_at TEXT;
18+ALTER TABLE sessions ADD COLUMN title_final INTEGER NOT NULL DEFAULT 0;
19+ALTER TABLE sessions ADD COLUMN title_basis TEXT;
20+ALTER TABLE sessions ADD COLUMN title_prompts INTEGER NOT NULL DEFAULT 0;
21+ALTER TABLE sessions ADD COLUMN title_calls INTEGER NOT NULL DEFAULT 0;
22+ALTER TABLE sessions ADD COLUMN prompts INTEGER NOT NULL DEFAULT 0;
23+
24+UPDATE sessions SET prompts = (
25+ SELECT COUNT(*) FROM session_entries e WHERE e.session_id = sessions.id AND e.kind = 'prompt'
26+);
27+
28+-- Every session started before this is settled once (the backfill): a
29+-- subagent keeps its task, so only sessions at the top of a tree.
30+UPDATE sessions SET title_due_at = COALESCE(ended_at, last_seen_at) WHERE parent_id IS NULL;
31+
32+CREATE INDEX sessions_title_due ON sessions (title_due_at) WHERE title_due_at IS NOT NULL;
+12−5
211211 async fn link(&self, session_id: &str, repo_id: &str, kind: LinkKind, number: u32, item_id: &str, via: LinkVia) {
212212 let at = rfc3339(now_ms());
213213 let linked = async {
214− self.db
215− .prepare(INSERT_LINK)
214+ let new = self
215+ .db
216+ .prepare(format!("{INSERT_LINK} RETURNING via"))
216217 .bind(&[
217218 session_id.into(),
218219 repo_id.into(),
219220 kind.as_str().into(),
220221 number.into(),
221222 via.as_str().into(),
222− at.into(),
223+ at.as_str().into(),
223224 item_id.into(),
224225 ])?
225− .run()
226− .await
226+ .all()
227+ .await?
228+ .results::<serde_json::Value>()?;
229+ // Tied to what it works on for the first time: its title is due (titles.rs).
230+ if !new.is_empty() && via != LinkVia::Ref {
231+ self.db.prepare(super::titles::linked_sql()).bind(&[session_id.into(), at.as_str().into()])?.run().await?;
232+ }
233+ Ok::<(), worker::Error>(())
227234 };
228235 if let Err(error) = linked.await {
229236 worker::console_error!("session {session_id}: not tied to {} #{number}{item_id}: {error}", kind.as_str());
+13−4
1717 mod overlaps;
1818 mod sessions;
1919 pub(crate) mod shown;
20+pub(crate) mod titles;
2021 #[cfg(test)]
2122 mod tests;
2223
5051 " AS item,
5152 pull, branch, actor_id, actor_name, harness, harness_session_id,
5253 title, state, head_commit, started_at, last_seen_at, ended_at, end_reason,
53− parent_id, agent_type, task, prompt, result, model"
54+ parent_id, agent_type, task, prompt, result, model, title_source, title_settled_at"
5455 );
5556
5657 /// A claim's columns, with its work item's workspace and number.
99100 result: Option<String>,
100101 #[serde(default)]
101102 model: Option<String>,
103+ #[serde(default)]
104+ title_source: Option<String>,
105+ #[serde(default)]
106+ title_settled_at: Option<String>,
102107 }
103108
104109 impl SessionRow {
129134 model: self.model,
130135 subagents: 0,
131136 first_prompt: None,
137+ title_source: self.title_source.as_deref().and_then(TitleSource::parse),
138+ title_settled_at: self.title_settled_at,
132139 }
133140 }
134141 }
189196 const SWEEP_LIMIT: u32 = 50;
190197
191198 /// Ends a repository's (`?1`) session `?3` if last seen at or before `?2`,
192−/// as `timed_out`, as of when it was last seen.
199+/// as `timed_out`, as of when it was last seen; its title is settled a
200+/// last time (titles.rs).
193201 pub(crate) fn time_out_sessions_sql() -> String {
194202 format!(
195− "UPDATE sessions SET state = 'ended', ended_at = last_seen_at, end_reason = 'timed_out'
203+ "UPDATE sessions SET state = 'ended', ended_at = last_seen_at, end_reason = 'timed_out', {final_due}
196204 WHERE repo_id = ?1 AND state = 'active' AND last_seen_at <= ?2 AND id = ?3
197− RETURNING {SESSION_COLUMNS}"
205+ RETURNING {SESSION_COLUMNS}",
206+ final_due = titles::final_due("last_seen_at")
198207 )
199208 }
200209
+103−22
3131 /// The most pull requests one `pull_sessions` asks about.
3232 const MAX_PULL_SESSIONS: usize = 100;
3333 use super::updates::push_delivery;
34−use super::{SESSION_COLUMNS, SessionRow, after_minutes, shown, tidy, unwrap_or_fail};
34+use super::{SESSION_COLUMNS, SessionRow, after_minutes, shown, tidy, titles, unwrap_or_fail};
3535 use crate::Work;
3636 use crate::items;
3737 use crate::rows::NumberRow;
4040 const ENTRY_PAGE: u32 = 500;
4141 /// The most sessions one list returns.
4242 const LIST_LIMIT: u32 = 100;
43−const MAX_TITLE_CHARS: usize = 200;
43+const MAX_TITLE_CHARS: usize = MAX_SESSION_TITLE_CHARS;
4444 const MAX_BRANCH_CHARS: usize = 255;
4545 const MAX_ID_CHARS: usize = 200;
4646 const MAX_COMMIT_CHARS: usize = 64;
125125 format!(
126126 "INSERT INTO sessions
127127 (id, repo_id, item_id, pull, branch, actor_id, actor_name, harness, harness_session_id,
128− title, state, head_commit, started_at, last_seen_at, parent_id, agent_type, task, prompt, model)
129− VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
128+ title, state, head_commit, started_at, last_seen_at, parent_id, agent_type, task, prompt, model,
129+ title_source, title_settled_at)
130+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18,
131+ CASE WHEN ?10 IS NULL THEN NULL ELSE 'agent' END, CASE WHEN ?10 IS NULL THEN NULL ELSE ?12 END)
130132 ON CONFLICT (repo_id, actor_id, harness, harness_session_id)
131133 WHERE harness_session_id IS NOT NULL DO NOTHING
132134 RETURNING {SESSION_COLUMNS}"
141143 )
142144 }
143145
144−/// The fields `?1`–`?10` of [`Fields::binds`], each kept when not given.
146+/// The fields `?1`–`?10` of [`Fields::binds`], each kept when not given;
147+/// a title as [`titles::SET_TITLE`] takes it, from `?13` (`?11` now).
145148 const SET_FIELDS: &str = "item_id = COALESCE(?1, item_id), pull = COALESCE(?2, pull), branch = COALESCE(?3, branch),
146− head_commit = COALESCE(?4, head_commit), title = COALESCE(?5, title),
149+ head_commit = COALESCE(?4, head_commit),
147150 agent_type = COALESCE(?6, agent_type), task = COALESCE(?7, task), prompt = COALESCE(?8, prompt),
148151 model = COALESCE(?9, model), result = COALESCE(?10, result)";
149152
150153 /// A heartbeat: seen now (`?11`), active again, with whichever fields are
151−/// given, of session `?12`.
154+/// given, of session `?12`; a title given came from `?13`. One that ended
155+/// is named again as it ends again.
152156 pub(crate) fn resume_sql() -> String {
153157 format!(
154158 "UPDATE sessions
155− SET {SET_FIELDS},
159+ SET {SET_FIELDS}, {set_title},
160+ title_final = CASE WHEN state = 'ended' THEN 0 ELSE title_final END,
156161 last_seen_at = ?11, state = 'active', ended_at = NULL, end_reason = NULL
157162 WHERE id = ?12
158− RETURNING {SESSION_COLUMNS}"
163+ RETURNING {SESSION_COLUMNS}",
164+ set_title = titles::SET_TITLE,
159165 )
160166 }
161167
162168 /// The fields of session `?12`, changed without bringing it back: for a
163−/// subagent's late hooks (`?11` unused, so the binds are the same).
169+/// subagent's late hooks (the binds are the same).
164170 pub(crate) fn refresh_sql() -> String {
165171 format!(
166− "UPDATE sessions SET {SET_FIELDS}
172+ "UPDATE sessions SET {SET_FIELDS}, {set_title}
167173 WHERE id = ?12 AND ?11 IS NOT NULL
168− RETURNING {SESSION_COLUMNS}"
174+ RETURNING {SESSION_COLUMNS}",
175+ set_title = titles::SET_TITLE,
169176 )
170177 }
171178
330337 prompt: fields.prompt.clone(),
331338 result: None,
332339 model: fields.model.clone(),
340+ title_source: fields.title.as_ref().map(|_| TitleSource::Agent.as_str().to_owned()),
341+ title_settled_at: fields.title.as_ref().map(|_| at.clone()),
333342 }
334343 .at(now);
335344 let started = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor));
412421 let parent_id = row.parent_id.clone();
413422 let id = row.id.clone();
414423 let at = rfc3339(now);
424+ let current_source = row.title_source.as_deref().and_then(TitleSource::parse);
425+ // A title given: its agent's, unless it is what g1t calls it already.
426+ let given = fields.title.as_deref().map(|title| titles::given_source(title, row.title.as_deref(), current_source));
427+ let pinned = current_source == Some(TitleSource::Person);
415428 let mut binds = fields.binds();
416429 binds.push(at.as_str().into());
417430 binds.push(id.as_str().into());
431+ binds.push(given.map_or(JsValue::NULL, |source| source.as_str().into()));
418432 let sql = if finished { refresh_sql() } else { resume_sql() };
419433 let mut statements = vec![self.db.prepare(sql).bind(&binds)?];
420434 // Brought back from having ended: announced with the change.
425439 pull: fields.pull.or(row.pull),
426440 branch: fields.branch.clone().or(row.branch.clone()),
427441 head_commit: fields.head_commit.clone().or(row.head_commit.clone()),
428− title: fields.title.clone().or(row.title.clone()),
442+ title: if pinned { row.title.clone() } else { fields.title.clone().or(row.title.clone()) },
443+ title_source: if pinned { row.title_source.clone() } else { given.map(|source| source.as_str().to_owned()).or(row.title_source.clone()) },
429444 agent_type: fields.agent_type.clone().or(row.agent_type.clone()),
430445 task: fields.task.clone().or(row.task.clone()),
431446 prompt: fields.prompt.clone().or(row.prompt.clone()),
628643 let mut statements = vec![self
629644 .db
630645 .prepare(format!(
631− "UPDATE sessions SET state = 'ended', ended_at = ?, end_reason = ?, last_seen_at = ?, result = COALESCE(?, result)
632− WHERE id = ? AND state = 'active'
633− RETURNING {SESSION_COLUMNS}"
646+ "UPDATE sessions SET state = 'ended', ended_at = ?1, end_reason = ?2, last_seen_at = ?1, result = COALESCE(?3, result),
647+ {final_due}
648+ WHERE id = ?4 AND state = 'active'
649+ RETURNING {SESSION_COLUMNS}",
650+ final_due = titles::final_due("?1"),
634651 ))
635− .bind(&[at.as_str().into(), reason.as_str().into(), at.as_str().into(), text_or_null(&result), row.id.as_str().into()])?];
652+ .bind(&[at.as_str().into(), reason.as_str().into(), text_or_null(&result), row.id.as_str().into()])?];
636653 let expected = end_one(row.clone(), &reason, result.clone());
637654 let sent = push_delivery(
638655 &mut statements,
696713 Ok(Outcome::Ok(session))
697714 }
698715
716+ /// A person names a session from the site (`TitleSessionArgs`): pinned,
717+ /// or with no title, unpinned for g1t to name again.
718+ pub(crate) async fn title_session(&self, a: TitleSessionArgs) -> Result<Outcome<Session>> {
719+ let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
720+ let Some(row) = self.session_row(&repo.id, a.id.trim()).await? else {
721+ return Ok(not_found());
722+ };
723+ let can_push = matches!(crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::Push), Outcome::Ok(()));
724+ if !may_end_session(&row.actor_id, &a.actor.id, can_push) {
725+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only whoever runs this session, or someone with write access to the repository, can name it."));
726+ }
727+ let at = rfc3339(now_ms());
728+ let title = tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS);
729+ let statement = match &title {
730+ Some(title) => self.db.prepare(titles::PIN_TITLE).bind(&[title.as_str().into(), at.as_str().into(), row.id.as_str().into()])?,
731+ None => self.db.prepare(titles::UNPIN_TITLE).bind(&[at.as_str().into(), row.id.as_str().into()])?,
732+ };
733+ statement.run().await?;
734+ let Some(row) = self.session_row(&repo.id, &row.id).await? else {
735+ return Ok(not_found());
736+ };
737+ let session = row.at(now_ms());
738+ self.publish_as(
739+ "session.progressed",
740+ &repo.id,
741+ Some(a.actor.id.clone()),
742+ SessionProgressed { repo_id: repo.id.clone(), number: session.pull.or(session.item), session_id: session.id.clone(), root_id: None },
743+ )
744+ .await?;
745+ let mut sessions = [session];
746+ shown::sessions(&mut sessions, &Some(a.actor.clone()), &repo.namespace);
747+ let [session] = sessions;
748+ Ok(Outcome::Ok(session))
749+ }
750+
699751 pub(crate) async fn get_work_session(&self, a: WorkSessionArgs) -> Result<Outcome<SessionDetail>> {
700752 let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?);
701753 let Some(row) = self.session_row(&repo.id, a.id.trim()).await? else {
932984 let total = a.entries.len() as u32;
933985 let mut statements = Vec::with_capacity(a.entries.len());
934986 let mut prompts = String::new();
987+ // Each prompt's place among the statements, and what the first one with one is for.
988+ let mut prompt_at: Vec<usize> = Vec::new();
989+ let mut intent: Option<String> = None;
935990 for keyed in a.entries {
936991 let entry = keyed.entry;
937992 let kind = serde_json::to_value(entry.kind)?;
942997 // A skill is its name, nothing more.
943998 text = text.trim().chars().take(MAX_AGENT_CHARS).collect();
944999 }
945− if entry.kind == SessionEntryKind::Prompt && prompts.len() < MAX_REF_TEXT {
946− prompts.push_str(&text);
947− prompts.push('\n');
1000+ if entry.kind == SessionEntryKind::Prompt {
1001+ if prompts.len() < MAX_REF_TEXT {
1002+ prompts.push_str(&text);
1003+ prompts.push('\n');
1004+ }
1005+ prompt_at.push(statements.len());
1006+ if intent.is_none() {
1007+ intent = titles::intent_title(&text);
1008+ }
9481009 }
9491010 let key = tidy(keyed.key.as_deref(), MAX_KEY_CHARS);
9501011 // Each takes the next number of the session, and of its pull
9661027 );
9671028 }
9681029 let mut recorded = 0;
969− for result in self.db.batch(statements).await? {
970− recorded += result.results::<NumberRow>()?.len() as u32;
1030+ let mut prompted = 0;
1031+ for (index, result) in self.db.batch(statements).await?.into_iter().enumerate() {
1032+ let new = result.results::<NumberRow>()?.len() as u32;
1033+ recorded += new;
1034+ if prompt_at.contains(&index) {
1035+ prompted += new;
1036+ }
1037+ }
1038+ if prompted > 0 {
1039+ // The first prompt names the session for now; enough of them settle its title (titles.rs).
1040+ let now = now_ms();
1041+ self.db
1042+ .prepare(titles::prompted_sql())
1043+ .bind(&[
1044+ session.id.as_str().into(),
1045+ prompted.into(),
1046+ text_or_null(&intent),
1047+ rfc3339(now).into(),
1048+ after_minutes(now, titles::SETTLE_AFTER_MINUTES).into(),
1049+ ])?
1050+ .run()
1051+ .await?;
9711052 }
9721053 if !prompts.is_empty() && recorded > 0 {
9731054 self.link_refs(&session.id, &repo.id, &prompts).await?;
+2−0
211211 model: Some("claude-opus-5-5".into()),
212212 subagents: 0,
213213 first_prompt: Some("ugh fix it".into()),
214+ title_source: None,
215+ title_settled_at: None,
214216 }
215217 }
216218
+163−2
180180 &resume_sql(),
181181 params![
182182 None::<String>, 9, None::<String>, "abc123", None::<String>, None::<String>, None::<String>, None::<String>,
183− None::<String>, None::<String>, late, "ses_1"
183+ None::<String>, None::<String>, late, "ses_1", None::<String>
184184 ],
185185 |row| row.get(11),
186186 )
207207 &refresh_sql(),
208208 params![
209209 None::<String>, None::<u32>, None::<String>, None::<String>, None::<String>, None::<String>, "List endpoints",
210− None::<String>, None::<String>, None::<String>, rfc3339(NOW), "ses_c1"
210+ None::<String>, None::<String>, None::<String>, rfc3339(NOW), "ses_c1", None::<String>
211211 ],
212212 |row| Ok((row.get(11)?, row.get(19)?, row.get(21)?)),
213213 )
669669 let next = update_id("integration.failed", "qe_1:att_3:abc123");
670670 assert_ne!(next, id);
671671 }
672+
673+// --- Titles that settle (titles.rs) ----------------------------------------
674+
675+/// A session's title as stored: its text, source and when it is due.
676+fn titled(db: &Connection, id: &str) -> (Option<String>, Option<String>, Option<String>) {
677+ db.query_row("SELECT title, title_source, title_due_at FROM sessions WHERE id = ?", [id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
678+ .unwrap()
679+}
680+
681+/// Records `n` prompts the first of which is for `intent`, at `at`.
682+fn prompted(db: &Connection, id: &str, n: u32, intent: Option<&str>, at: u64) {
683+ db.execute(&super::titles::prompted_sql(), params![id, n, intent, rfc3339(at), after_minutes(at, super::titles::SETTLE_AFTER_MINUTES)]).unwrap();
684+}
685+
686+/// A heartbeat giving the title `title` from `source`, at `at`.
687+fn heartbeat_titled(db: &Connection, id: &str, title: &str, source: &str, at: u64) {
688+ db.query_row(
689+ &resume_sql(),
690+ params![
691+ None::<String>, None::<u32>, None::<String>, None::<String>, title, None::<String>, None::<String>, None::<String>,
692+ None::<String>, None::<String>, rfc3339(at), id, source
693+ ],
694+ |_| Ok(()),
695+ )
696+ .unwrap();
697+}
698+
699+const MINUTE: u64 = 60_000;
700+
701+#[test]
702+fn the_first_prompt_names_a_session_for_now_and_enough_of_them_settle_it() {
703+ let db = database();
704+ start(&db, "ses_1", Some("a"));
705+ prompted(&db, "ses_1", 1, Some("This project used to be in syntaqx/g1t"), NOW);
706+ let due_later = after_minutes(NOW, super::titles::SETTLE_AFTER_MINUTES);
707+ assert_eq!(titled(&db, "ses_1"), (Some("This project used to be in syntaqx/g1t".into()), Some("prompt".into()), Some(due_later.clone())));
708+ // A later prompt does not rename it, nor bring it forward yet.
709+ prompted(&db, "ses_1", 1, Some("Fix the remotes"), NOW + MINUTE);
710+ assert_eq!(titled(&db, "ses_1").0.as_deref(), Some("This project used to be in syntaqx/g1t"));
711+ assert_eq!(titled(&db, "ses_1").2, Some(due_later));
712+ // The third: due now.
713+ prompted(&db, "ses_1", 1, None, NOW + 2 * MINUTE);
714+ assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW + 2 * MINUTE)));
715+ // A subagent is never titled from prompts.
716+ start_child(&db, "ses_c", "ses_1", "a/agent/1", "Explore");
717+ prompted(&db, "ses_c", 3, Some("Look around"), NOW);
718+ assert_eq!(titled(&db, "ses_c"), (None, None, None));
719+}
720+
721+#[test]
722+fn an_agents_title_is_kept_and_a_pinned_one_never_replaced() {
723+ let db = database();
724+ start(&db, "ses_1", Some("a"));
725+ prompted(&db, "ses_1", 1, Some("Fix login"), NOW);
726+ heartbeat_titled(&db, "ses_1", "Rewrite the auth flow", "agent", NOW + MINUTE);
727+ assert_eq!(titled(&db, "ses_1"), (Some("Rewrite the auth flow".into()), Some("agent".into()), None));
728+ // Its prompts and links leave it be until it ends.
729+ prompted(&db, "ses_1", 5, None, NOW + 2 * MINUTE);
730+ db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 2 * MINUTE)]).unwrap();
731+ assert_eq!(titled(&db, "ses_1").2, None);
732+ // A person pins it: neither the agent nor g1t replaces it.
733+ db.execute(super::titles::PIN_TITLE, params!["Auth, rewritten", rfc3339(NOW + 3 * MINUTE), "ses_1"]).unwrap();
734+ heartbeat_titled(&db, "ses_1", "Something else", "agent", NOW + 4 * MINUTE);
735+ prompted(&db, "ses_1", 5, None, NOW + 4 * MINUTE);
736+ db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 4 * MINUTE)]).unwrap();
737+ assert_eq!(titled(&db, "ses_1"), (Some("Auth, rewritten".into()), Some("person".into()), None));
738+ // Not even as it ends.
739+ let late = rfc3339(NOW + g1t_contracts::work::SESSION_TIMEOUT_MS * 2);
740+ db.prepare(&time_out_sessions_sql()).unwrap().query_map(params!["rep_1", late, "ses_1"], |_| Ok(())).unwrap().count();
741+ assert_eq!(titled(&db, "ses_1").2, None);
742+ // Unpinned: g1t names it again.
743+ db.execute(super::titles::UNPIN_TITLE, params![rfc3339(NOW + 5 * MINUTE), "ses_1"]).unwrap();
744+ assert_eq!(titled(&db, "ses_1"), (Some("Auth, rewritten".into()), Some("prompt".into()), Some(rfc3339(NOW + 5 * MINUTE))));
745+}
746+
747+#[test]
748+fn an_agent_echoing_the_provisional_title_keeps_it_provisional() {
749+ let db = database();
750+ start(&db, "ses_1", Some("a"));
751+ prompted(&db, "ses_1", 1, Some("Fix login"), NOW);
752+ heartbeat_titled(&db, "ses_1", "Fix login", "prompt", NOW + MINUTE);
753+ let (_, source, due) = titled(&db, "ses_1");
754+ assert_eq!((source.as_deref(), due), (Some("prompt"), Some(after_minutes(NOW, super::titles::SETTLE_AFTER_MINUTES))));
755+}
756+
757+#[test]
758+fn a_settled_title_is_due_again_only_after_a_while_and_a_change() {
759+ let db = database();
760+ start(&db, "ses_1", Some("a"));
761+ let settled = rfc3339(NOW);
762+ db.execute(
763+ "UPDATE sessions SET title = 'Move issues to Work items', title_source = 'item', title_settled_at = ?1, title_due_at = NULL,
764+ title_prompts = 3, prompts = 3 WHERE id = 'ses_1'",
765+ [settled.as_str()],
766+ )
767+ .unwrap();
768+ // Tied to more work five minutes on: due, but not before twenty minutes after it settled.
769+ db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 5 * MINUTE)]).unwrap();
770+ assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW + 20 * MINUTE)));
771+ // Later still: as soon as it is tied.
772+ db.execute("UPDATE sessions SET title_due_at = NULL WHERE id = 'ses_1'", []).unwrap();
773+ db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 45 * MINUTE)]).unwrap();
774+ assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW + 45 * MINUTE)));
775+ // Prompts: due again only once they grew past more_prompts(3) = 8.
776+ db.execute("UPDATE sessions SET title_due_at = NULL WHERE id = 'ses_1'", []).unwrap();
777+ prompted(&db, "ses_1", 4, None, NOW + 50 * MINUTE);
778+ assert_eq!(titled(&db, "ses_1").2, None);
779+ prompted(&db, "ses_1", 1, None, NOW + 51 * MINUTE);
780+ assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW + 51 * MINUTE)));
781+ // Settled for good as it ended: nothing makes it due but ending again.
782+ db.execute("UPDATE sessions SET title_due_at = NULL, title_final = 1, state = 'ended' WHERE id = 'ses_1'", []).unwrap();
783+ db.execute(&super::titles::linked_sql(), params!["ses_1", rfc3339(NOW + 90 * MINUTE)]).unwrap();
784+ assert_eq!(titled(&db, "ses_1").2, None);
785+ // Brought back, it is named again as it ends again.
786+ heartbeat_titled(&db, "ses_1", "x", "agent", NOW + 91 * MINUTE);
787+ let kept: u32 = db.query_row("SELECT title_final FROM sessions WHERE id = 'ses_1'", [], |row| row.get(0)).unwrap();
788+ assert_eq!(kept, 0);
789+}
790+
791+#[test]
792+fn a_session_timing_out_is_named_a_last_time_its_subagents_not() {
793+ let db = database();
794+ start(&db, "ses_1", Some("a"));
795+ start_child(&db, "ses_c", "ses_1", "a/agent/1", "Explore");
796+ let late = rfc3339(NOW + g1t_contracts::work::SESSION_TIMEOUT_MS);
797+ for id in ["ses_1", "ses_c"] {
798+ db.prepare(&time_out_sessions_sql()).unwrap().query_map(params!["rep_1", late, id], |_| Ok(())).unwrap().count();
799+ }
800+ assert_eq!(titled(&db, "ses_1").2, Some(rfc3339(NOW)));
801+ assert_eq!(titled(&db, "ses_c").2, None);
802+}
803+
804+#[test]
805+fn sessions_from_before_titles_settled_are_settled_once() {
806+ let db = crate::migrated::database_before("0051_session_titles.sql");
807+ let at = rfc3339(NOW);
808+ db.execute(
809+ "INSERT INTO sessions (id, repo_id, actor_id, actor_name, harness, title, state, started_at, last_seen_at, ended_at)
810+ VALUES ('ses_old', 'rep_1', 'usr_1', 'ana', 'claude_code', 'This project used to be in syntaqx/g1t', 'ended', ?1, ?1, ?1)",
811+ [at.as_str()],
812+ )
813+ .unwrap();
814+ db.execute(
815+ "INSERT INTO sessions (id, repo_id, actor_id, actor_name, harness, state, started_at, last_seen_at, parent_id)
816+ VALUES ('ses_sub', 'rep_1', 'usr_1', 'ana', 'claude_code', 'ended', ?1, ?1, 'ses_old')",
817+ [at.as_str()],
818+ )
819+ .unwrap();
820+ for n in 1..=2u32 {
821+ db.execute(
822+ "INSERT INTO session_entries (seq, kind, text, at, session_id, session_seq) VALUES (?1, 'prompt', 'hi', ?2, 'ses_old', ?1)",
823+ params![n, at],
824+ )
825+ .unwrap();
826+ }
827+ crate::migrated::migrate_from(&db, "0051_session_titles.sql");
828+ let (prompts, due): (u32, Option<String>) =
829+ db.query_row("SELECT prompts, title_due_at FROM sessions WHERE id = 'ses_old'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
830+ assert_eq!((prompts, due), (2, Some(at)));
831+ assert_eq!(titled(&db, "ses_sub").2, None);
832+}
+1304−0
A large diff (1,304 lines) is not shown until you ask for it.
+14−1
283283 events: Fetcher,
284284 /// GitHub Actions: runs a merge queue's `merge_group` workflows.
285285 actions: Fetcher,
286+ /// Integrations: the workspace's AI provider names sessions
287+ /// (coordination/titles.rs). None where it is not bound.
288+ integrations: Option<Fetcher>,
286289 /// Where this request's time went, for its `Server-Timing`.
287290 timing: g1t_kit::d1::Timing,
288291 /// A pull request's rows read in one batch for this request
25592562 repos: env.service("REPOS")?,
25602563 events: env.service("EVENTS")?,
25612564 actions: env.service("ACTIONS")?,
2565+ integrations: env.service("INTEGRATIONS").ok(),
25622566 timing: g1t_kit::d1::Timing::default(),
25632567 prefetched: std::cell::RefCell::new(None),
25642568 known_repos: std::cell::RefCell::new(std::collections::HashMap::new()),
26702674 "start_session" => reply(&work.start_session(args(body)?).await?),
26712675 "update_session" => reply(&work.update_session(args(body)?).await?),
26722676 "end_session" => reply(&work.end_session(args(body)?).await?),
2677+ "title_session" => reply(&work.title_session(args(body)?).await?),
26732678 "get_work_session" => reply(&work.get_work_session(args(body)?).await?),
26742679 "list_work_sessions" => reply(&work.list_work_sessions(args(body)?).await?),
26752680 "pull_sessions" => reply(&work.pull_sessions(args(body)?).await?),
27112716 }
27122717
27132718 /// The cron trigger: moves on the job that moves issues into work items
2714−/// (items/migration.rs), until it is done.
2719+/// (items/migration.rs), until it is done, and settles the sessions'
2720+/// titles that are due (coordination/titles.rs).
27152721 #[event(scheduled)]
27162722 async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
27172723 let moved = async {
27212727 if let Err(error) = moved.await {
27222728 worker::console_error!("moving issues into work items: {error}");
27232729 }
2730+ let settled = async {
2731+ let work = service(&env)?;
2732+ work.settle_titles().await
2733+ };
2734+ if let Err(error) = settled.await {
2735+ worker::console_error!("settling session titles: {error}");
2736+ }
27242737 }
27252738
27262739 /// Events from the bus, delivered on this service's own queue.
+4−1
2929 { "binding": "IDENTITY", "service": "g1t-identity" },
3030 { "binding": "REPOS", "service": "g1t-repos" },
3131 { "binding": "EVENTS", "service": "g1t-events" },
32− { "binding": "ACTIONS", "service": "g1t-actions" }
32+ { "binding": "ACTIONS", "service": "g1t-actions" },
33+ // The workspace's AI provider, which names sessions from what they
34+ // did (src/coordination/titles.rs, integrations' `ai_brief`).
35+ { "binding": "INTEGRATIONS", "service": "g1t-integrations" }
3336 ],
3437 "queues": {
3538 "consumers": [{ "queue": "g1t-events-work", "max_batch_size": 100, "max_batch_timeout": 1, "max_retries": 3, "dead_letter_queue": "g1t-events-dlq" }]

Comments

Sign in to comment.