@saadnvd1/agent-os 0.3.567 → 0.3.570

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ and check on all of it from your phone.
20
20
  - **Built for your phone.** The whole app works on a phone; pair it with a QR code. [More](docs/guides/phone.md)
21
21
  - **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
22
22
  - **Chat or terminal, same conversation.** Streaming replies, steps and diffs, plan mode, slash commands, `@file`, undo. [More](docs/guides/chat.md)
23
- - **Share a chat or a page.** A read-only snapshot link with secrets, home paths and machine names masked, and a preview before anything leaves your machine. [More](docs/guides/sharing.md)
23
+ - **Share a chat or a page.** A read-only snapshot link with secrets, home paths and machine names masked, a preview before anything leaves your machine, and a Markdown copy to hand to an agent. [More](docs/guides/sharing.md)
24
24
  - **Project config.** `agentos.json` gives each session its own ports, environment, files and a private Postgres copy. [More](docs/reference/project-config.md)
25
25
  - **Schedules and recipes.** Start work on a timer, or save a job that comes up again. [More](docs/guides/schedules.md)
26
26
  - **Plans in pieces and stacks.** A plan splits into several PRs only for a real reason; a board can run as stacked PRs. [More](docs/guides/tasks.md#plans-in-pieces)
@@ -15,6 +15,7 @@ export async function POST(request: Request) {
15
15
  const page = await buildPage(req);
16
16
  return NextResponse.json({
17
17
  html: page.html,
18
+ markdown: page.markdown,
18
19
  title: page.title,
19
20
  bytes: page.bytes,
20
21
  hash: page.hash,
@@ -184,6 +184,13 @@ describe("preview", () => {
184
184
  expect(p.html).not.toContain(tailnet);
185
185
  expect(p.html).toContain("[secret]");
186
186
  expect(p.hits).toMatchObject({ secret: 1, path: 1, host: 1 });
187
+ expect(p.markdown).toContain("**User:**");
188
+ expect(p.bytes).toBe(
189
+ Buffer.byteLength(p.html) + Buffer.byteLength(p.markdown)
190
+ );
191
+ expect(p.markdown).toContain("[secret]");
192
+ for (const leak of [secret, home, tailnet])
193
+ expect(p.markdown).not.toContain(leak);
187
194
  expect(p.items.map((i: { id: string }) => i.id)).toEqual(["u1", "a1"]);
188
195
  expect(seen).toHaveLength(0);
189
196
  });
@@ -254,6 +261,7 @@ describe("create", () => {
254
261
  title: "Share me",
255
262
  expires_in: "7d",
256
263
  html: p.html,
264
+ markdown: p.markdown,
257
265
  });
258
266
  for (const leak of [secret, home, tailnet])
259
267
  expect(upload.body).not.toContain(leak);
@@ -40,6 +40,7 @@ export async function POST(request: Request) {
40
40
  kind: req.kind,
41
41
  title: page.title,
42
42
  html: page.html,
43
+ markdown: page.markdown,
43
44
  expiresIn,
44
45
  });
45
46
  insertSharedLink({
@@ -75,6 +75,7 @@ function ShareBody({
75
75
  const [error, setError] = useState<string | null>(null);
76
76
  const [created, setCreated] = useState<string | null>(null);
77
77
  const [copied, setCopied] = useState(false);
78
+ const [tab, setTab] = useState<"page" | "markdown">("page");
78
79
  const previewReq = useSharePreview();
79
80
  const create = useCreateShare();
80
81
  const seq = useRef(0);
@@ -131,14 +132,16 @@ function ShareBody({
131
132
  }
132
133
  };
133
134
 
134
- const download = () => {
135
+ const download = (what: "html" | "md") => {
135
136
  if (!preview) return;
136
137
  const url = URL.createObjectURL(
137
- new Blob([preview.html], { type: "text/html" })
138
+ new Blob([what === "md" ? preview.markdown : preview.html], {
139
+ type: what === "md" ? "text/markdown" : "text/html",
140
+ })
138
141
  );
139
142
  const a = document.createElement("a");
140
143
  a.href = url;
141
- a.download = fileName(target.title);
144
+ a.download = fileName(target.title, what);
142
145
  a.click();
143
146
  URL.revokeObjectURL(url);
144
147
  };
@@ -172,20 +175,40 @@ function ShareBody({
172
175
  </div>
173
176
 
174
177
  <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto md:flex-row md:overflow-hidden">
175
- <div className="bg-background min-h-[45vh] flex-1 overflow-hidden rounded-lg border md:min-h-0">
176
- {preview ? (
177
- <iframe
178
- title="Preview of what will be shared"
179
- sandbox="allow-scripts"
180
- referrerPolicy="no-referrer"
181
- srcDoc={preview.html}
182
- className="h-full min-h-[45vh] w-full md:min-h-0"
183
- />
184
- ) : (
185
- <p className="text-muted-foreground p-4 text-sm">
186
- {error ?? "Preparing the preview…"}
187
- </p>
188
- )}
178
+ <div className="flex min-h-[45vh] flex-1 flex-col gap-2 md:min-h-0">
179
+ <div className="flex gap-2" role="group" aria-label="Preview format">
180
+ {(["page", "markdown"] as const).map((t) => (
181
+ <Button
182
+ key={t}
183
+ size="sm"
184
+ variant={tab === t ? "default" : "outline"}
185
+ className="min-h-11"
186
+ aria-pressed={tab === t}
187
+ onClick={() => setTab(t)}
188
+ >
189
+ {t === "page" ? "Page" : "Markdown"}
190
+ </Button>
191
+ ))}
192
+ </div>
193
+ <div className="bg-background min-h-0 flex-1 overflow-hidden rounded-lg border">
194
+ {preview && tab === "markdown" ? (
195
+ <pre className="h-full overflow-auto p-4 font-mono text-xs break-words whitespace-pre-wrap">
196
+ {preview.markdown}
197
+ </pre>
198
+ ) : preview ? (
199
+ <iframe
200
+ title="Preview of what will be shared"
201
+ sandbox="allow-scripts"
202
+ referrerPolicy="no-referrer"
203
+ srcDoc={preview.html}
204
+ className="h-full min-h-[45vh] w-full md:min-h-0"
205
+ />
206
+ ) : (
207
+ <p className="text-muted-foreground p-4 text-sm">
208
+ {error ?? "Preparing the preview…"}
209
+ </p>
210
+ )}
211
+ </div>
189
212
  </div>
190
213
 
191
214
  <div className="flex shrink-0 flex-col gap-4 md:w-80 md:overflow-y-auto">
@@ -304,11 +327,20 @@ function ShareBody({
304
327
  variant="outline"
305
328
  className="min-h-11"
306
329
  disabled={!preview || preview.tooBig || empty}
307
- onClick={download}
330
+ onClick={() => download("html")}
308
331
  >
309
332
  <Download className="h-4 w-4" />
310
333
  Download HTML
311
334
  </Button>
335
+ <Button
336
+ variant="outline"
337
+ className="min-h-11"
338
+ disabled={!preview || preview.tooBig || empty}
339
+ onClick={() => download("md")}
340
+ >
341
+ <Download className="h-4 w-4" />
342
+ Download Markdown
343
+ </Button>
312
344
  {linked && (
313
345
  <Button
314
346
  className="min-h-11"
@@ -19,29 +19,40 @@ export function WindowCard({
19
19
  window: WindowView | null | undefined;
20
20
  }) {
21
21
  const level = w ? meterLevel(w.pct) : "ok";
22
+ const stale = !!w?.stale;
22
23
  return (
23
24
  <div className="bg-foreground/[0.03] space-y-2 rounded-xl p-3">
24
25
  <div className="flex items-baseline justify-between gap-2">
25
26
  <span className="text-muted-foreground text-xs">{label}</span>
26
- <span className="text-lg font-semibold tabular-nums">
27
+ <span
28
+ className={cn(
29
+ "text-lg font-semibold tabular-nums",
30
+ stale && "text-muted-foreground"
31
+ )}
32
+ >
27
33
  {w ? `${w.estimated ? "~" : ""}${w.pct}%` : "–"}
28
34
  </span>
29
35
  </div>
30
36
  <div className="bg-foreground/10 h-1.5 overflow-hidden rounded-full">
31
37
  <div
32
- className={cn("h-full rounded-full", BAR[level])}
38
+ className={cn(
39
+ "h-full rounded-full",
40
+ stale ? "bg-foreground/30" : BAR[level]
41
+ )}
33
42
  style={{ width: `${Math.min(w?.pct ?? 0, 100)}%` }}
34
43
  />
35
44
  </div>
36
45
  <p className="text-muted-foreground text-[11px]">
37
46
  {w?.estimated && "Estimate from recorded usage · "}
38
47
  {!w
39
- ? "Not available"
40
- : w.capsIn
41
- ? `Runs out in ${formatDuration(w.capsIn)} at this rate`
42
- : w.resetsIn !== null
43
- ? `Resets in ${formatDuration(w.resetsIn)}`
44
- : "Used"}
48
+ ? "Not sampled"
49
+ : w.stale
50
+ ? `Last seen ${w.ageSec !== undefined ? `${formatDuration(w.ageSec)} ago` : "a while ago"}${w.resetsIn !== null ? ` · resets in ${formatDuration(w.resetsIn)}` : ""}`
51
+ : w.capsIn
52
+ ? `Runs out in ${formatDuration(w.capsIn)} at this rate`
53
+ : w.resetsIn !== null
54
+ ? `Resets in ${formatDuration(w.resetsIn)}`
55
+ : "Used"}
45
56
  </p>
46
57
  </div>
47
58
  );
@@ -297,6 +297,7 @@ export async function uploadShare(
297
297
  kind: "chat" | "artifact";
298
298
  title: string;
299
299
  html: string;
300
+ markdown: string;
300
301
  expiresIn: ShareExpiry;
301
302
  }
302
303
  ): Promise<RemoteShare & { url: string }> {
@@ -307,6 +308,7 @@ export async function uploadShare(
307
308
  kind: share.kind,
308
309
  title: share.title,
309
310
  html: share.html,
311
+ markdown: share.markdown,
310
312
  expires_in: share.expiresIn,
311
313
  }
312
314
  );
@@ -0,0 +1,47 @@
1
+ // Which GitHub repository a folder is a clone of ("owner/repo", lower
2
+ // case), read from its origin's URL as configured, with no gh call. Kept free
3
+ // of the task and orchestrator modules so plan checks can use it.
4
+
5
+ import { git, gitSync } from "./git-exec";
6
+
7
+ const GITHUB_REMOTE =
8
+ /^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/:]+@)github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i;
9
+
10
+ export function slugOfRemote(url: string | null | undefined): string | null {
11
+ const m = GITHUB_REMOTE.exec((url ?? "").trim());
12
+ return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
13
+ }
14
+
15
+ // A remote's URL doesn't change under a running server often enough to ask
16
+ // git on every lookup.
17
+ const SLUG_TTL_MS = 5 * 60 * 1000;
18
+ const slugs = new Map<string, { at: number; slug: string | null }>();
19
+
20
+ export async function repoSlug(dir: string): Promise<string | null> {
21
+ const hit = slugs.get(dir);
22
+ if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.slug;
23
+ const url = await git(dir, ["config", "--get", "remote.origin.url"], {
24
+ timeout: 10000,
25
+ }).catch(() => "");
26
+ const slug = slugOfRemote(url);
27
+ slugs.set(dir, { at: Date.now(), slug });
28
+ return slug;
29
+ }
30
+
31
+ // The same, without waiting: a cached local `git config` read for callers that
32
+ // can't be async. Null when the folder isn't a GitHub clone.
33
+ export function repoSlugSync(dir: string): string | null {
34
+ const hit = slugs.get(dir);
35
+ if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.slug;
36
+ let url = "";
37
+ try {
38
+ url = gitSync(dir, ["config", "--get", "remote.origin.url"], {
39
+ timeout: 5000,
40
+ });
41
+ } catch {
42
+ url = "";
43
+ }
44
+ const slug = slugOfRemote(url);
45
+ slugs.set(dir, { at: Date.now(), slug });
46
+ return slug;
47
+ }
@@ -8,7 +8,7 @@ import { mergeApprovalsOn } from "./merge-approvals";
8
8
  import { noApprovalsOn } from "../approvals/no-approvals";
9
9
  import { buildDigest } from "./digest";
10
10
 
11
- const BRAKES_ON = `Starting work (\`start_task\`, \`start_session\`, \`stack\`) is braked: at most a set number of sessions running in this workspace (4 by default), at most a set number of starts an hour (6 by default), and nothing new once the account's usage window would run out before it resets, or can't be read at all. Each card of a stack you start counts as a start of its own; a braked card waits in the stack. A brake refuses the start with its reason and writes one note; running work carries on. Don't retry a braked start in a loop: carry on with reviews, answers and merges, and start again when a later event gives you reason to.`;
11
+ const BRAKES_ON = `Starting work (\`start_task\`, \`start_session\`, \`stack\`) is braked: at most a set number of sessions running in this workspace (4 by default), at most a set number of starts an hour (6 by default), and nothing new once the account's usage window would run out before it resets, or isn't being sampled and was near its cap when last seen. Each card of a stack you start counts as a start of its own; a braked card waits in the stack. A brake refuses the start with its reason and writes one note; running work carries on. Don't retry a braked start in a loop: carry on with reviews, answers and merges, and start again when a later event gives you reason to.`;
12
12
 
13
13
  const BRAKES_OFF = `Starting work has no limits right now: no cap on running sessions or starts, and no usage-window check. Saad's Pause still stops new starts; the start tools say so when it's on.`;
14
14
 
@@ -4,33 +4,11 @@
4
4
  // of its projects'.
5
5
 
6
6
  import type { Project } from "../db";
7
- import { git } from "../git-exec";
8
7
  import { expandHome } from "../tasks/session";
9
8
  import { workspaceProjects } from "./brief";
9
+ import { repoSlug } from "../git-remote";
10
10
 
11
- const GITHUB_REMOTE =
12
- /^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/:]+@)github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i;
13
-
14
- export function slugOfRemote(url: string | null | undefined): string | null {
15
- const m = GITHUB_REMOTE.exec((url ?? "").trim());
16
- return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
17
- }
18
-
19
- // A remote's URL doesn't change under a running server often enough to ask
20
- // git on every lookup.
21
- const SLUG_TTL_MS = 5 * 60 * 1000;
22
- const slugs = new Map<string, { at: number; slug: string | null }>();
23
-
24
- export async function repoSlug(dir: string): Promise<string | null> {
25
- const hit = slugs.get(dir);
26
- if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.slug;
27
- const url = await git(dir, ["config", "--get", "remote.origin.url"], {
28
- timeout: 10000,
29
- }).catch(() => "");
30
- const slug = slugOfRemote(url);
31
- slugs.set(dir, { at: Date.now(), slug });
32
- return slug;
33
- }
11
+ export { repoSlug, repoSlugSync, slugOfRemote } from "../git-remote";
34
12
 
35
13
  export interface WorkspaceRepo {
36
14
  project: Project;
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Where the account's 5-hour usage window stands, ported from dispatch's
3
- * `account_limit`. The statusline in ~/dev/dev-settings samples the window's
4
- * used percentage into limits.json, keyed on when it resets; this only reads
5
- * it. A sample that's a little stale or about a window that already reset
6
- * says nothing about the burn rate, so the projection is skipped. But a
7
- * file that's missing, unreadable or not sampled for 15 minutes means the
8
- * window can't be checked at all, and then starts are refused: the brake
9
- * fails closed.
3
+ * `account_limit`. Two things sample it: AgentOS's own agents (every
4
+ * rate-limit event a chat worker sees is stored in `rate_limit_readings`) and
5
+ * the terminal statusline, which writes limits.json. This reads both and uses
6
+ * whichever was sampled last, never mixing their samples. A sample that's a
7
+ * little stale or about a window that already reset says nothing about the
8
+ * burn rate, so the projection is skipped. Past 15 minutes without a sample
9
+ * the window is unknown: that is said plainly, with the last value seen, and
10
+ * a start is refused only when nothing was ever sampled or the last value was
11
+ * near the cap and its window hasn't reset.
10
12
  */
11
13
 
12
14
  import fs from "fs";
13
15
  import os from "os";
14
16
  import path from "path";
17
+ import { lastAgentActivity, windowSamples } from "../usage/rate-limits";
15
18
 
16
19
  export const LIMITS_FILE =
17
20
  process.env.AGENTOS_LIMITS_FILE ??
@@ -72,31 +75,110 @@ export function usageWindow(
72
75
 
73
76
  // Past this the window can't be vouched for at all (seconds).
74
77
  const UNKNOWN_AFTER = 15 * 60;
78
+ // A start is refused on an unknown window only when it was this full.
79
+ const STALE_REFUSE_PCT = 90;
75
80
 
76
- export type UsageState = { window: UsageWindow | null } | { unknown: string };
81
+ // What was last seen of the window, however long ago (seconds).
82
+ export interface LastSeen {
83
+ pct: number;
84
+ resetsAt: number | null;
85
+ sampledAt: number;
86
+ }
77
87
 
78
- export function readUsage(
79
- file = LIMITS_FILE,
80
- nowSec = Date.now() / 1000
81
- ): UsageState {
82
- let state: LimitsFile;
88
+ export type UsageState =
89
+ | { window: UsageWindow | null }
90
+ | { unknown: string; last?: LastSeen | null };
91
+
92
+ export interface Series {
93
+ window: number | null;
94
+ samples: [number, number][];
95
+ }
96
+
97
+ function readLimitsFile(file: string): LimitsFile | null {
83
98
  try {
84
- state = JSON.parse(fs.readFileSync(file, "utf8")) as LimitsFile;
99
+ return JSON.parse(fs.readFileSync(file, "utf8")) as LimitsFile;
85
100
  } catch {
86
- return { unknown: `the usage window can't be read (${file})` };
101
+ return null;
87
102
  }
88
- const samples = Array.isArray(state.samples)
103
+ }
104
+
105
+ function toSeries(state: LimitsFile | null): Series | null {
106
+ const samples = Array.isArray(state?.samples)
89
107
  ? (state.samples as unknown[][])
108
+ .map((s) => [Number(s[0]), Number(s[1])] as [number, number])
109
+ .filter(([at, pct]) => Number.isFinite(at) && Number.isFinite(pct))
90
110
  : [];
91
- const newest = Number(samples.at(-1)?.[0]);
92
- if (!Number.isFinite(newest))
93
- return { unknown: "the usage window has no samples" };
94
- const age = nowSec - newest;
95
- if (age > UNKNOWN_AFTER)
96
- return {
97
- unknown: `the usage window was last sampled ${minutes(age)} ago, so it can't be checked`,
98
- };
99
- return { window: usageWindow(state, nowSec) };
111
+ if (!samples.length) return null;
112
+ return {
113
+ window: typeof state?.window === "number" ? state.window : null,
114
+ samples,
115
+ };
116
+ }
117
+
118
+ function storedSeries(): Series | null {
119
+ const { resetsAt, samples } = windowSamples("five_hour");
120
+ return samples.length
121
+ ? { window: resetsAt === null ? null : resetsAt / 1000, samples }
122
+ : null;
123
+ }
124
+
125
+ // A ledger that can't be read is a source with nothing in it.
126
+ const quietly = <T>(read: () => T | null): T | null => {
127
+ try {
128
+ return read();
129
+ } catch {
130
+ return null;
131
+ }
132
+ };
133
+
134
+ const clock = (sec: number, nowSec: number): string => {
135
+ const d = new Date(sec * 1000);
136
+ const time = d.toLocaleTimeString("en-US", {
137
+ hour: "numeric",
138
+ minute: "2-digit",
139
+ });
140
+ return d.toDateString() === new Date(nowSec * 1000).toDateString()
141
+ ? time
142
+ : `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${time}`;
143
+ };
144
+
145
+ // Why the window isn't known: nothing was ever sampled, no agent has run
146
+ // since the last sample, or agents ran without reporting it.
147
+ export function unknownText(
148
+ sampledAt: number | null,
149
+ activityAt: number | null,
150
+ nowSec: number
151
+ ): string {
152
+ if (sampledAt === null)
153
+ return "No agent has reported the usage window yet, so usage isn't sampled";
154
+ if (activityAt === null || activityAt / 1000 <= sampledAt + 60)
155
+ return `No agent has run since ${clock(sampledAt, nowSec)}, so usage isn't sampled`;
156
+ return `Agents ran at ${clock(activityAt / 1000, nowSec)} but didn't report the usage window; it was last sampled at ${clock(sampledAt, nowSec)}`;
157
+ }
158
+
159
+ export function readUsage(
160
+ file = LIMITS_FILE,
161
+ nowSec = Date.now() / 1000,
162
+ stored: () => Series | null = storedSeries,
163
+ activity: () => number | null = lastAgentActivity
164
+ ): UsageState {
165
+ const newestAt = (s: Series) => s.samples[s.samples.length - 1][0];
166
+ const sources = [toSeries(readLimitsFile(file)), quietly(stored)].filter(
167
+ (s): s is Series => s !== null
168
+ );
169
+ const chosen = sources.sort((a, b) => newestAt(b) - newestAt(a))[0] ?? null;
170
+ const sampledAt = chosen ? newestAt(chosen) : null;
171
+ if (chosen && sampledAt !== null && nowSec - sampledAt <= UNKNOWN_AFTER)
172
+ return { window: usageWindow(chosen, nowSec) };
173
+ const activityAt = quietly(activity);
174
+ const newest = chosen && chosen.samples[chosen.samples.length - 1];
175
+ return {
176
+ unknown: unknownText(sampledAt, activityAt, nowSec),
177
+ last:
178
+ chosen && newest && sampledAt !== null
179
+ ? { pct: Math.trunc(newest[1]), resetsAt: chosen.window, sampledAt }
180
+ : null,
181
+ };
100
182
  }
101
183
 
102
184
  function minutes(seconds: number): string {
@@ -104,11 +186,21 @@ function minutes(seconds: number): string {
104
186
  return m >= 60 ? `${Math.floor(m / 60)}h${m % 60}m` : `${m}m`;
105
187
  }
106
188
 
107
- // Why the window refuses a new start, or null.
189
+ // Why the window refuses a new start, or null. An unknown window refuses
190
+ // unless it was last seen low or its window has since reset: nothing is
191
+ // sampling, so the figure can't have moved, and the next start's own run
192
+ // samples it again.
108
193
  export function windowRefusal(
109
- state: UsageState | UsageWindow | null
194
+ state: UsageState | UsageWindow | null,
195
+ nowSec = Date.now() / 1000
110
196
  ): string | null {
111
- if (state && "unknown" in state) return state.unknown;
197
+ if (state && "unknown" in state) {
198
+ const last = state.last;
199
+ if (!last) return state.unknown;
200
+ if (last.resetsAt !== null && last.resetsAt <= nowSec) return null;
201
+ if (last.pct < STALE_REFUSE_PCT) return null;
202
+ return `${state.unknown}; it was ${last.pct}% used when last seen${last.resetsAt === null ? "" : ` and resets at ${clock(last.resetsAt, nowSec)}`}`;
203
+ }
112
204
  const w = state && "window" in state ? state.window : state;
113
205
  if (!w) return null;
114
206
  if (w.pct >= 100)
@@ -7,6 +7,7 @@ import path from "path";
7
7
  import { randomUUID } from "crypto";
8
8
  import { db, type Session } from "../db";
9
9
  import { loadProjectConfig } from "../project-config";
10
+ import { repoSlugSync, slugOfRemote } from "../git-remote";
10
11
  import {
11
12
  applyEdits,
12
13
  checkPieces,
@@ -77,6 +78,33 @@ function ref(id: string): ProjectRef | null {
77
78
  return { id: p.id, name: p.name, deploys: !!deploy?.deployed };
78
79
  }
79
80
 
81
+ // A repo named in a plan resolves by project name, then by the repo's folder
82
+ // name, then by its GitHub owner/name (or just the name part), ignoring case.
83
+ // A folder or GitHub match must be a single project: two would be a guess.
84
+ export function resolveRepo<
85
+ T extends { id: string; name: string; working_directory: string },
86
+ >(rows: T[], token: string): T | null {
87
+ const want = token
88
+ .trim()
89
+ .replace(/^https?:\/\/github\.com\//i, "")
90
+ .replace(/\.git$/i, "")
91
+ .replace(/\/+$/, "")
92
+ .toLowerCase();
93
+ if (!want) return null;
94
+ const named = rows.filter((r) => r.name.toLowerCase() === want);
95
+ if (named.length) return named.length === 1 ? named[0] : null;
96
+ const folder = (r: T) =>
97
+ path.basename(expandHome(r.working_directory)).toLowerCase();
98
+ const byFolder = rows.filter((r) => folder(r) === want);
99
+ if (byFolder.length) return byFolder.length === 1 ? byFolder[0] : null;
100
+ const asSlug = slugOfRemote(`https://github.com/${want}`) ?? null;
101
+ const bySlug = rows.filter((r) => {
102
+ const slug = repoSlugSync(expandHome(r.working_directory));
103
+ return slug && (slug === asSlug || slug.split("/")[1] === want);
104
+ });
105
+ return bySlug.length === 1 ? bySlug[0] : null;
106
+ }
107
+
80
108
  export function checkContext(
81
109
  s: Pick<Session, "project_id">
82
110
  ): CheckContext | null {
@@ -84,16 +112,14 @@ export function checkContext(
84
112
  if (!own) return null;
85
113
  const rows = db
86
114
  .prepare(
87
- `SELECT id, name FROM projects
115
+ `SELECT id, name, working_directory FROM projects
88
116
  WHERE workspace_id = (SELECT workspace_id FROM projects WHERE id = ?)`
89
117
  )
90
- .all(own.id) as { id: string; name: string }[];
118
+ .all(own.id) as { id: string; name: string; working_directory: string }[];
91
119
  return {
92
120
  own,
93
121
  find: (name) => {
94
- const hit = rows.find(
95
- (r) => r.name.toLowerCase() === name.trim().toLowerCase()
96
- );
122
+ const hit = resolveRepo(rows, name);
97
123
  return hit ? ref(hit.id) : null;
98
124
  },
99
125
  };
@@ -122,10 +148,25 @@ export function piecesOf(featureId: string): FeaturePiece[] {
122
148
 
123
149
  export function pieceOfSession(sessionId: string): FeaturePiece | undefined {
124
150
  return db
125
- .prepare(`SELECT * FROM feature_pieces WHERE session_id = ?`)
151
+ .prepare(
152
+ `SELECT * FROM feature_pieces WHERE session_id = ? ORDER BY position`
153
+ )
126
154
  .get(sessionId) as FeaturePiece | undefined;
127
155
  }
128
156
 
157
+ // The PR a piece's task merged, when it is in the piece's own repository: a
158
+ // session's PR is always in its project's repo, so another project's merge is
159
+ // not this piece's work.
160
+ export function prOf(p: FeaturePiece): number | null {
161
+ if (!p.session_id) return null;
162
+ const s = db
163
+ .prepare(`SELECT pr_number, project_id FROM sessions WHERE id = ?`)
164
+ .get(p.session_id) as
165
+ | { pr_number: number | null; project_id: string | null }
166
+ | undefined;
167
+ return s && s.project_id === p.project_id ? s.pr_number : null;
168
+ }
169
+
129
170
  export function featureOfPlan(sessionId: string): Feature | undefined {
130
171
  return db
131
172
  .prepare(`SELECT * FROM features WHERE plan_session_id = ?`)
@@ -208,7 +208,7 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
208
208
  const found = ctx.find(r.repo);
209
209
  if (!found)
210
210
  errors.push(
211
- `"${r.name}": (repo: ${r.repo}) is not a project of this workspace`
211
+ `"${r.name}": (repo: ${r.repo}) doesn't match one project of this workspace (by name, folder or GitHub repo)`
212
212
  );
213
213
  return found ?? ctx.own;
214
214
  });