@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 +1 -1
- package/app/api/shares/preview/route.ts +1 -0
- package/app/api/shares/route.test.ts +8 -0
- package/app/api/shares/route.ts +1 -0
- package/components/Share/ShareDialog.tsx +50 -18
- package/components/Usage/WindowCard.tsx +19 -8
- package/lib/connect/control-api.ts +2 -0
- package/lib/git-remote.ts +47 -0
- package/lib/orchestrator/brief.ts +1 -1
- package/lib/orchestrator/repo-slug.ts +2 -24
- package/lib/orchestrator/usage.ts +120 -28
- package/lib/plans/features.ts +47 -6
- package/lib/plans/pieces.ts +1 -1
- package/lib/plans/release.ts +106 -11
- package/lib/plans/view.ts +2 -8
- package/lib/share/connect.ts +1 -0
- package/lib/share/markdown.ts +134 -0
- package/lib/share/service.ts +13 -2
- package/lib/share/view.ts +4 -3
- package/lib/usage/rate-limits.ts +78 -14
- package/lib/usage/turns.ts +1 -1
- package/lib/usage/windows.ts +70 -15
- package/package.json +4 -1
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,
|
|
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)
|
|
@@ -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);
|
package/app/api/shares/route.ts
CHANGED
|
@@ -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], {
|
|
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="
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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(
|
|
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
|
|
40
|
-
: w.
|
|
41
|
-
? `
|
|
42
|
-
: w.
|
|
43
|
-
? `
|
|
44
|
-
:
|
|
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
|
|
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
|
-
|
|
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`.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
99
|
+
return JSON.parse(fs.readFileSync(file, "utf8")) as LimitsFile;
|
|
85
100
|
} catch {
|
|
86
|
-
return
|
|
101
|
+
return null;
|
|
87
102
|
}
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
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)
|
package/lib/plans/features.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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 = ?`)
|
package/lib/plans/pieces.ts
CHANGED
|
@@ -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})
|
|
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
|
});
|