A card above the prompt with the Timelines issue this session works on; installed by `gm agent mod`

A Claude Code mod: a card above the prompt with the Timelines issue the session works on. gm agent mod installs it (see apps/cli/README.md); gm embeds this folder's runtime files (.claude-plugin, hooks, types).
hooks/register.tsx: the hooks module (the card, the toggle, polling, and following the session's gm commands).hooks/gm.ts: pure logic over gm's JSON and the session's commands.types/index.d.ts: the mod's self-contained $.state and allowlisted agent identity contract. Claude loads it independently: no imports, re-exports from another file, require, or reference directives. Runtime helpers import its named types, never the reverse.Load this folder in Claude Code: it reloads on every save.
claude --plugin-dir apps/cli/assets/claude-code-mod
Claude Code then lays its API declarations in .claude-plugin/types/ (git-ignored), which tsconfig.json extends; repository lint skips this folder because those declarations only exist once Claude Code has loaded it.
claude plugin validate apps/cli/assets/claude-code-mod
tsc -p apps/cli/assets/claude-code-mod # after a load
node --test apps/cli/assets/claude-code-mod/tests/*.test.ts # also in cli#test
The tests are Node unit tests of hooks/gm.ts and hooks/identity.ts, not claude plugin test ones. Node tests do not validate the manifest contract; run claude plugin validate as well.
hooks/register.tsx 264 lines1// Claude Code mod (CORE-303), installed by `gm agent mod`: a corner card
2// above the prompt with the gitmatrix issue this session works on. The chord
3// `gm agent mod` binds to ACTION (ctrl+x g) or /gm-work toggles it.
4import { atom, read, update } from "claude-code";
5import type { EngineInterface, Register } from "claude-code";
6
7import type { GmSnapshot } from "../types";
8import { resolveIdentity } from "./identity";
9import { currentTimeline, gmCalls, metaLine, myClaims, nextFocus, toWork } from "./gm";
10
11type Engine = EngineInterface;
12
13/**
14 * The engine keybinding action the hidden toggle Button answers to. Mods
15 * can't declare actions of their own, so this borrows one that is idle
16 * unless the diff panel has pre-session changes. `gm agent mod` binds a
17 * chord to the same action (claude_mod.rs's ACTION).
18 */
19const ACTION = "app:toggleDiffPreSession";
20const COMMAND = "gm-work";
21const STORE_SHOWN = "isShown";
22const REFRESH_MS = 60_000;
23const CARD_COLUMNS = 46;
24
25const isShown = atom({ plugin: "gitmatrix-work", key: "isShown" } as const, true);
26const snapshot = atom({ plugin: "gitmatrix-work", key: "snapshot" } as const, null);
27const focusKey = atom({ plugin: "gitmatrix-work", key: "focusKey" } as const, null);
28
29// Module state: a hot reload starts these over, which is fine.
30let checkout: string | null = null;
31let isLoading = false;
32let isStale = false;
33
34async function gm($: Engine, args: string[]): Promise<unknown> {
35 const ran = await $.process.run(["gm", ...args, "-o", "json"], { timeoutMs: 20_000 });
36 if (ran.exitCode !== 0) {
37 throw new Error("Timelines work unavailable");
38 }
39 return JSON.parse(ran.stdout);
40}
41
42async function load($: Engine): Promise<GmSnapshot> {
43 const now = await $.clock.now();
44 const cwd = await $.session.cwd();
45 if (checkout !== cwd) {
46 checkout = cwd;
47 await update($, focusKey, () => null);
48 await update($, snapshot, () => null);
49 }
50 const identity = await resolveIdentity(
51 (argv, cwd) => $.process.run(argv, { cwd, timeoutMs: 20_000 }),
52 cwd,
53 );
54 if (!identity) {
55 await update($, focusKey, () => null);
56 return { identity: null, work: null, error: null, fetchedAt: now };
57 }
58 const agentId = identity.id;
59 const previous = await read($, snapshot);
60 if (previous?.identity && previous.identity.id !== agentId) {
61 await update($, focusKey, () => null);
62 }
63 try {
64 const focus = await read($, focusKey);
65 const listing = gm($, [
66 "issue",
67 "list",
68 "--status",
69 "in_progress",
70 "--all-teams",
71 "--limit",
72 "100",
73 ]);
74 const timelines = gm($, ["timeline", "list"]).catch(() => null);
75 const focused = focus === null ? null : gm($, ["issue", "show", focus]);
76 const claims = myClaims(await listing, agentId, now);
77 const key = focus ?? claims[0]?.key ?? null;
78 if (key === null) return { identity, work: null, error: null, fetchedAt: now };
79 const shown = await (focused ?? gm($, ["issue", "show", key]));
80 const work = toWork(shown, {
81 agentId,
82 timeline: currentTimeline(await timelines),
83 otherClaims: claims.filter((claim) => claim.key !== key).length,
84 });
85 return { identity, work, error: null, fetchedAt: now };
86 } catch {
87 return { identity, work: null, error: "Timelines work unavailable", fetchedAt: now };
88 }
89}
90
91/** Reloads the card; a call during a load runs one more after it. */
92async function refresh($: Engine): Promise<void> {
93 if (isLoading) {
94 isStale = true;
95 return;
96 }
97 isLoading = true;
98 try {
99 do {
100 isStale = false;
101 const cwd = await $.session.cwd();
102 const next = await load($);
103 if (cwd !== (await $.session.cwd())) isStale = true;
104 if (!isStale) await update($, snapshot, () => next);
105 } while (isStale);
106 } finally {
107 isLoading = false;
108 }
109}
110
111async function toggle($: Engine): Promise<boolean> {
112 const shown = !(await read($, isShown));
113 await update($, isShown, () => shown);
114 await $.store.set(STORE_SHOWN, shown);
115 if (shown) void refresh($);
116 return shown;
117}
118
119export const register: Register = (on) => {
120 on("session.start", async ($, e, next) => {
121 await $.command.register({
122 name: COMMAND,
123 description: "Toggle the Timelines work card",
124 });
125 checkout = null;
126 await update($, snapshot, () => null);
127 await update($, focusKey, () => null);
128 await update($, isShown, () => true);
129 if ((await $.store.get(STORE_SHOWN)) === false) await update($, isShown, () => false);
130 $.clock.after(0, () => void refresh($));
131 $.clock.every(REFRESH_MS, () => {
132 void read($, isShown).then((shown) => (shown ? refresh($) : undefined));
133 });
134 return next(e);
135 });
136
137 on("command.run", { command: COMMAND }, async ($) => {
138 const shown = await toggle($);
139 return { text: shown ? "Timelines work card shown." : "Timelines work card hidden." };
140 });
141
142 // Follows the session's own gm commands: what it claims, checkpoints,
143 // completes or releases becomes (or stops being) the card's issue.
144 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
145 const ran = await next(e);
146 const calls = gmCalls(e.command);
147 if (calls.length === 0 || ran.deny !== undefined || ran.isError) return ran;
148 const focus = await read($, focusKey);
149 const focused = nextFocus(focus, calls, ran.text ?? "");
150 if (focused !== focus) await update($, focusKey, () => focused);
151 $.clock.after(0, () => void refresh($));
152 return ran;
153 }).catch(($, e, next) => next(e));
154
155 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
156 if (e.props.hasSurvey) return next(e);
157 const { Box, Button, Text } = $.ui.resolve(e);
158
159 // Always mounted, so the chord works while the card is hidden too.
160 const toggleButton = (
161 <Box display="none">
162 <Button key="gm-work-toggle" label="gm" plain action={ACTION} onPress={() => toggle($)} />
163 </Box>
164 );
165 if (!(await read($, isShown))) return toggleButton;
166
167 const snap = await read($, snapshot);
168 const now = await $.clock.now();
169 const width = Math.min(CARD_COLUMNS, e.props.bodyColumns);
170 const isFramed = e.props.maxRows >= 5 && width >= 20;
171 const lines: JSX.Element[] = [];
172 if (snap?.identity) {
173 lines.push(
174 <Text bold wrap="truncate-end">
175 Agent: {snap.identity.name}
176 </Text>,
177 );
178 if (snap.identity.accountable) {
179 lines.push(
180 <Text dimColor wrap="truncate-end">
181 Accountable: {snap.identity.accountable}
182 </Text>,
183 );
184 }
185 }
186
187 if (snap === null) {
188 lines.push(
189 <Text dimColor wrap="truncate-end">
190 Timelines: loading…
191 </Text>,
192 );
193 } else if (snap.work === null) {
194 lines.push(
195 <Text dimColor wrap="truncate-end">
196 {!snap.identity
197 ? "No Timelines agent identity"
198 : snap.error
199 ? "Timelines: unavailable"
200 : "No Timelines issue claimed"}
201 </Text>,
202 );
203 lines.push(
204 <Text dimColor wrap="truncate-end">
205 {!snap.identity
206 ? "gm agent setup configures this checkout"
207 : (snap.error ?? "gm issue next picks one")}
208 </Text>,
209 );
210 } else {
211 const { work } = snap;
212 lines.push(
213 <Text wrap="truncate-end">
214 <Text bold color="claude">
215 {work.key}
216 </Text>{" "}
217 {work.title}
218 </Text>,
219 );
220 lines.push(
221 <Text dimColor wrap="truncate-end">
222 {metaLine(work, now)}
223 </Text>,
224 );
225 if (snap.error) {
226 lines.push(
227 <Text color="warning" wrap="truncate-end">
228 ⚠ {snap.error}
229 </Text>,
230 );
231 } else if (work.checkpoint) {
232 lines.push(
233 <Text dimColor wrap="truncate-end">
234 ↳ {work.checkpoint}
235 </Text>,
236 );
237 } else if (work.otherClaims > 0) {
238 lines.push(
239 <Text dimColor wrap="truncate-end">
240 +{work.otherClaims} more claimed issue{work.otherClaims === 1 ? "" : "s"}
241 </Text>,
242 );
243 }
244 }
245
246 const shownLines = lines.slice(0, Math.max(1, e.props.maxRows - (isFramed ? 2 : 0)));
247 return (
248 <Box flexDirection="row" justifyContent="flex-end">
249 {toggleButton}
250 <Box
251 key="gm-work-card"
252 flexDirection="column"
253 width={width}
254 borderStyle={isFramed ? "round" : undefined}
255 borderDimColor
256 paddingX={isFramed ? 1 : 0}
257 >
258 {shownLines}
259 </Box>
260 </Box>
261 );
262 });
263};
264hooks/identity.ts 54 lines1// Shared by the Claude mod and the Oh My Pi extension. Never retain the raw
2// response: /agents/self may gain fields that don't belong in a harness UI.
3import type { GmAgentIdentity } from "../types";
4type Run = (argv: string[], cwd: string) => Promise<{ exitCode: number; stdout: string }>;
5
6function label(value: unknown): string | null {
7 return typeof value === "string" &&
8 value.trim().length > 0 &&
9 value.length <= 200 &&
10 !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value)
11 ? value.trim()
12 : null;
13}
14
15export function agentIdentity(value: unknown): GmAgentIdentity | null {
16 if (!value || typeof value !== "object") return null;
17 const agent = (value as Record<string, unknown>).agent;
18 if (!agent || typeof agent !== "object" || Array.isArray(agent)) return null;
19 const fields = agent as Record<string, unknown>;
20 const id = label(fields.id);
21 const name = label(fields.name);
22 if (
23 !id ||
24 !name ||
25 (fields.kind !== undefined && fields.kind !== "agent") ||
26 fields.revokedAt != null
27 )
28 return null;
29 const owner = fields.accountable;
30 const accountable =
31 owner && typeof owner === "object" && !Array.isArray(owner)
32 ? (label((owner as Record<string, unknown>).displayName) ??
33 label((owner as Record<string, unknown>).id))
34 : null;
35 return { id, name, accountable };
36}
37
38export function identityLine(identity: GmAgentIdentity): string {
39 return `Agent: ${identity.name}${identity.accountable ? ` · accountable: ${identity.accountable}` : ""}`;
40}
41
42export async function resolveIdentity(run: Run, cwd: string): Promise<GmAgentIdentity | null> {
43 try {
44 const checkout = await run(["git", "rev-parse", "--show-toplevel"], cwd);
45 if (checkout.exitCode !== 0) return null;
46 const result = await run(["gm", "agent", "show", "-o", "json"], cwd);
47 return result.exitCode === 0 ? agentIdentity(JSON.parse(result.stdout)) : null;
48 } catch {
49 // No checkout, credentials, gm, network, or valid identity: fail closed.
50 // In particular, never turn stderr or a thrown subprocess error into UI.
51 return null;
52 }
53}
54hooks/gm.ts 182 lines1// Pure helpers: reading gm's JSON and the Bash commands that run gm.
2import type { GmWork } from "../types";
3
4const ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/;
5
6/** One `gm issue <sub> [KEY]` (or any other gm command, as `sub: null`). */
7export type GmCall = { sub: string | null; key: string | null };
8
9/**
10 * The gm commands in a shell command line, in order. `agent gm` (the
11 * isolated product-testing wrapper) is skipped: it talks to another server.
12 */
13export function gmCalls(command: string): GmCall[] {
14 const calls: GmCall[] = [];
15 for (const segment of command.split(/&&|\|\||[;&|\n]/)) {
16 const words = segment.trim().split(/\s+/).filter(Boolean);
17 let at = 0;
18 while (at < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[at] ?? "")) at += 1;
19 if (words[at] !== "gm" && !(words[at] ?? "").endsWith("/gm")) continue;
20 if (segment.includes("agent gm")) continue;
21 const rest = words.slice(at + 1);
22 const issueAt = rest.indexOf("issue");
23 if (issueAt === -1) {
24 calls.push({ sub: null, key: null });
25 continue;
26 }
27 const sub = rest[issueAt + 1] ?? null;
28 const key = rest.slice(issueAt + 2).find((word) => ISSUE_KEY.test(word)) ?? null;
29 calls.push({ sub, key });
30 }
31 return calls;
32}
33
34/** What the session's gm commands mean for the issue it is focused on. */
35export function nextFocus(
36 focus: string | null,
37 calls: readonly GmCall[],
38 output: string,
39): string | null {
40 let next = focus;
41 for (const call of calls) {
42 if (call.sub === "next") {
43 next = claimedKey(output) ?? next;
44 } else if (
45 call.key &&
46 ["claim", "resume", "checkpoint", "renew", "evidence"].includes(call.sub ?? "")
47 ) {
48 next = call.key;
49 } else if (
50 call.key &&
51 ["complete", "release"].includes(call.sub ?? "") &&
52 (next === null || next === call.key)
53 ) {
54 next = null;
55 }
56 }
57 return next;
58}
59
60/** The key `gm issue next` claimed, from its JSON output. */
61export function claimedKey(output: string): string | null {
62 try {
63 const parsed = JSON.parse(output) as { claimed?: { key?: unknown } | null };
64 const key = parsed.claimed?.key;
65 if (typeof key === "string") return key;
66 } catch {
67 // Not whole JSON (other output around it): fall back to a scan.
68 }
69 return /"claimed"\s*:\s*\{[^}]*?"key"\s*:\s*"([A-Z][A-Z0-9]*-\d+)"/.exec(output)?.[1] ?? null;
70}
71
72type Claim = { actorId?: string; agent?: string; expiresAt?: string } | null;
73type ListedIssue = { key: string; updatedAt?: string; claim?: Claim };
74
75/** In-progress issues whose live claim belongs to `agentId`, newest first. */
76export function myClaims(list: unknown, agentId: string | null, now: number): ListedIssue[] {
77 const issues = (list as { issues?: ListedIssue[] } | null)?.issues ?? [];
78 return issues
79 .filter((issue) => {
80 const claim = issue.claim;
81 if (!claim || !agentId || claim.actorId !== agentId) return false;
82 return !claim.expiresAt || Date.parse(claim.expiresAt) > now;
83 })
84 .toSorted((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
85}
86
87/** The checkout's timeline from `gm timeline list`, if it works in one. */
88export function currentTimeline(listed: unknown): string | null {
89 const current = (listed as { current?: unknown } | null)?.current;
90 if (typeof current === "string") return current;
91 if (current && typeof current === "object") {
92 const { name, slug } = current as { name?: unknown; slug?: unknown };
93 if (typeof name === "string") return name;
94 if (typeof slug === "string") return slug;
95 }
96 return null;
97}
98
99type Note = { kind?: string; body?: string; createdAt?: string };
100
101/** The newest checkpoint's first line, markdown stripped. */
102export function latestCheckpoint(notes: readonly Note[] | undefined): string | null {
103 const checkpoint = (notes ?? [])
104 .filter((note) => note.kind === "checkpoint" && note.body)
105 .toSorted((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0];
106 if (!checkpoint?.body) return null;
107 const line = checkpoint.body.split("\n").find((text) => text.trim() !== "") ?? "";
108 return (
109 line
110 .replace(/[*_`]+/g, "")
111 .replace(/\s+/g, " ")
112 .trim() || null
113 );
114}
115
116/** The card's view of `gm issue show <key>`. */
117export function toWork(
118 shown: unknown,
119 context: { agentId: string | null; timeline: string | null; otherClaims: number },
120): GmWork {
121 const { issue, notes } = shown as {
122 issue: {
123 key: string;
124 title?: string;
125 status?: string;
126 priority?: number | null;
127 claim?: Claim;
128 };
129 notes?: Note[];
130 };
131 const claim = issue.claim ?? null;
132 const isMine = claim !== null && claim.actorId === context.agentId;
133 return {
134 key: issue.key,
135 title: issue.title ?? "",
136 status: issue.status ?? "unknown",
137 priority: typeof issue.priority === "number" ? issue.priority : null,
138 claimedBy: claim && !isMine ? (claim.agent ?? "someone else") : null,
139 leaseExpiresAt: isMine ? (claim?.expiresAt ?? null) : null,
140 timeline: context.timeline,
141 checkpoint: latestCheckpoint(notes),
142 otherClaims: context.otherClaims,
143 };
144}
145
146/** `lease 41m`, `lease 1h05m`, or `lease expired`. */
147export function leaseLabel(expiresAt: string | null, now: number): string | null {
148 if (!expiresAt) return null;
149 const minutes = Math.floor((Date.parse(expiresAt) - now) / 60_000);
150 if (Number.isNaN(minutes)) return null;
151 if (minutes < 0) return "lease expired";
152 if (minutes < 60) return `lease ${minutes}m`;
153 return `lease ${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
154}
155
156/** The second line: status, priority, lease or holder, timeline. */
157export function metaLine(work: GmWork, now: number): string {
158 const parts = [work.status.replace(/_/g, " ")];
159 if (work.priority !== null) parts.push(`P${work.priority}`);
160 const lease = leaseLabel(work.leaseExpiresAt, now);
161 if (lease) parts.push(lease);
162 if (work.claimedBy) parts.push(`held by ${work.claimedBy}`);
163 if (work.timeline) parts.push(`⎇ ${work.timeline}`);
164 return parts.join(" · ");
165}
166
167/** gm's error message from its JSON stderr, else the first line. */
168export function gmErrorMessage(stderr: string): string | null {
169 try {
170 const parsed = JSON.parse(stderr) as { error?: { message?: unknown } };
171 if (typeof parsed.error?.message === "string") return parsed.error.message;
172 } catch {
173 // Plain text.
174 }
175 return (
176 stderr
177 .split("\n")
178 .find((line) => line.trim() !== "")
179 ?.trim() ?? null
180 );
181}
182types/index.d.ts 34 lines1// Claude loads this manifest contract on its own: keep it free of imports.
2export type GmAgentIdentity = { id: string; name: string; accountable: string | null };
3
4export type GmWork = {
5 key: string;
6 title: string;
7 status: string;
8 priority: number | null;
9 /** Who holds the claim when it is not this checkout's agent. */
10 claimedBy: string | null;
11 leaseExpiresAt: string | null;
12 timeline: string | null;
13 checkpoint: string | null;
14 /** Other in-progress issues this agent also holds a claim on. */
15 otherClaims: number;
16};
17
18export type GmSnapshot = {
19 identity: GmAgentIdentity | null;
20 work: GmWork | null;
21 error: string | null;
22 fetchedAt: number;
23};
24
25declare module "claude-code" {
26 interface PluginState {
27 "gitmatrix-work": {
28 isShown: boolean;
29 snapshot: GmSnapshot | null;
30 focusKey: string | null;
31 };
32 }
33}
34