@saadnvd1/agent-os 0.3.567 → 0.3.569

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.
@@ -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
  );
@@ -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
  });
@@ -9,13 +9,14 @@ import { createTask } from "../tasks";
9
9
  import { startOrQueue } from "../tasks/queue";
10
10
  import { braked, BrakeRefused } from "../orchestrator/brakes";
11
11
  import { queueEvent, queueEventOnce } from "../orchestrator/events";
12
- import { amendBrief } from "../orchestrator/amendments";
12
+ import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
13
13
  import { usageRows } from "../session-logs/gather";
14
14
  import { pieceBrief } from "./pieces";
15
15
  import {
16
16
  getFeature,
17
17
  planPathOf,
18
18
  piecesOf,
19
+ prOf,
19
20
  type Feature,
20
21
  type FeaturePiece,
21
22
  } from "./features";
@@ -46,8 +47,10 @@ const setPiece = (id: number, status: string, note: string | null = null) =>
46
47
  .prepare(`UPDATE feature_pieces SET status = ?, note = ? WHERE id = ?`)
47
48
  .run(status, note, id);
48
49
 
50
+ const RELEASED_ANYWAY = "released anyway";
51
+
49
52
  // What a piece's task did: merged, or ended some other way.
50
- function syncPieces(pieces: FeaturePiece[]): void {
53
+ function syncPieces(feature: Feature, pieces: FeaturePiece[]): void {
51
54
  for (const p of pieces) {
52
55
  if (p.status !== "building") continue;
53
56
  const s = sessionOf(p.session_id);
@@ -65,8 +68,19 @@ function syncPieces(pieces: FeaturePiece[]): void {
65
68
  else if (!q) setPiece(p.id, "blocked", "its task is gone");
66
69
  continue;
67
70
  }
68
- if (s.task_status === "merged") setPiece(p.id, "merged");
69
- else if (s.task_status === "dropped" || s.task_status === "done")
71
+ if (s.task_status === "merged") {
72
+ // A PR counts only for a piece in its own repository.
73
+ if (s.project_id && s.project_id !== p.project_id) {
74
+ const where = getProject(s.project_id)?.name ?? "another project";
75
+ const note = `its task merged #${s.pr_number ?? "?"} in ${where}, not in ${getProject(p.project_id)?.name ?? "this piece's project"}`;
76
+ setPiece(p.id, "blocked", note);
77
+ say(
78
+ feature,
79
+ `wrong-repo-${p.id}`,
80
+ `Feature "${feature.name}": piece ${p.position} ${p.name} ${note}. If that PR already built it, drop the piece; otherwise release it anyway.`
81
+ );
82
+ } else setPiece(p.id, "merged");
83
+ } else if (s.task_status === "dropped" || s.task_status === "done")
70
84
  setPiece(p.id, "dropped", `its task was ${s.task_status}`);
71
85
  }
72
86
  }
@@ -141,11 +155,77 @@ function beforeLines(all: FeaturePiece[], upTo: number): string[] {
141
155
  return all
142
156
  .filter((p) => p.position < upTo && p.status === "merged")
143
157
  .map((p) => {
144
- const pr = sessionOf(p.session_id)?.pr_number;
158
+ const pr = prOf(p);
145
159
  return `${p.position} ${p.name} merged${pr ? ` as #${pr}` : ""}`;
146
160
  });
147
161
  }
148
162
 
163
+ // The planning task's own merge covers what it built in its repository: its
164
+ // own piece there, else every piece still waiting there. A recorded scope
165
+ // change means it may have built only part, so nothing is credited and the
166
+ // duplicate guard asks a person instead.
167
+ function creditParentMerge(feature: Feature, all: FeaturePiece[]): void {
168
+ const parent = sessionOf(feature.plan_session_id);
169
+ if (!parent || parent.task_status !== "merged" || !parent.project_id) return;
170
+ if (
171
+ all.some(
172
+ (p) => p.session_id === parent.id && p.project_id === parent.project_id
173
+ )
174
+ )
175
+ return;
176
+ if (amendmentsOf(parent.id).length) return;
177
+ const covered = all.filter(
178
+ (p) => p.status === "waiting" && p.project_id === parent.project_id
179
+ );
180
+ if (!covered.length) return;
181
+ for (const p of covered)
182
+ db.prepare(
183
+ `UPDATE feature_pieces SET status = 'merged', session_id = ?, note = ?
184
+ WHERE id = ? AND status = 'waiting'`
185
+ ).run(
186
+ parent.id,
187
+ `built by the planning task in #${parent.pr_number ?? "?"}`,
188
+ p.id
189
+ );
190
+ queueEventOnce(
191
+ feature.workspace_id,
192
+ `feature:parent-built-${feature.id}-${parent.pr_number}`,
193
+ feature.plan_session_id,
194
+ `Feature "${feature.name}": the planning task's own PR #${parent.pr_number} built ${covered.map((p) => `${p.position} ${p.name}`).join(", ")}, so ${covered.length === 1 ? "that piece is" : "those pieces are"} marked merged and won't start again.`
195
+ );
196
+ }
197
+
198
+ // A merged PR from this feature in the piece's repository that no merged
199
+ // piece there accounts for may already contain the piece's work.
200
+ function unaccountedMerge(
201
+ feature: Feature,
202
+ all: FeaturePiece[],
203
+ piece: FeaturePiece
204
+ ): Session | null {
205
+ const ids = new Set<string>([
206
+ feature.plan_session_id,
207
+ ...all.map((p) => p.session_id).filter((id): id is string => !!id),
208
+ ]);
209
+ for (const id of ids) {
210
+ const s = sessionOf(id);
211
+ if (
212
+ !s ||
213
+ s.task_status !== "merged" ||
214
+ !s.pr_number ||
215
+ s.project_id !== piece.project_id
216
+ )
217
+ continue;
218
+ const accounted = all.some(
219
+ (p) =>
220
+ p.status === "merged" &&
221
+ p.session_id === id &&
222
+ p.project_id === piece.project_id
223
+ );
224
+ if (!accounted) return s;
225
+ }
226
+ return null;
227
+ }
228
+
149
229
  async function releaseOne(
150
230
  feature: Feature,
151
231
  all: FeaturePiece[],
@@ -183,6 +263,20 @@ async function releaseOne(
183
263
  }
184
264
  }
185
265
  }
266
+ if (piece.note !== RELEASED_ANYWAY) {
267
+ const dup = unaccountedMerge(feature, all, piece);
268
+ if (dup) {
269
+ const where = getProject(piece.project_id)?.name ?? "its project";
270
+ const note = `${where} already has #${dup.pr_number} merged from this feature; check it doesn't already build this`;
271
+ setPiece(piece.id, "blocked", note);
272
+ say(
273
+ feature,
274
+ `duplicate-${piece.id}`,
275
+ `Feature "${feature.name}": piece ${piece.position} ${piece.name} is held: ${note}. Release it anyway to start it, or drop it.`
276
+ );
277
+ return;
278
+ }
279
+ }
186
280
  const claimed = db
187
281
  .prepare(
188
282
  `UPDATE feature_pieces SET status = 'building', session_id = ?, note = NULL
@@ -229,8 +323,8 @@ function close(feature: Feature, all: FeaturePiece[], deps: ReleaseDeps): void {
229
323
  return;
230
324
  }
231
325
  if (live.some((p) => p.status !== "merged")) return;
232
- const sessions = live
233
- .map((p) => sessionOf(p.session_id))
326
+ const sessions = [...new Set(live.map((p) => p.session_id))]
327
+ .map((id) => sessionOf(id))
234
328
  .filter(Boolean) as Session[];
235
329
  const cost = sessions.reduce(
236
330
  (n, s) => n + usageRows(s.id).reduce((m, r) => m + r.costUsd, 0),
@@ -265,7 +359,7 @@ function close(feature: Feature, all: FeaturePiece[], deps: ReleaseDeps): void {
265
359
  say(
266
360
  feature,
267
361
  `done-${feature.id}`,
268
- `Feature "${feature.name}" is merged: ${live.length} ${live.length === 1 ? "PR" : "PRs"}.${short}`
362
+ `Feature "${feature.name}" is merged: ${sessions.length} ${sessions.length === 1 ? "PR" : "PRs"}.${short}`
269
363
  );
270
364
  deps.pushFeatureMerged(feature);
271
365
  }
@@ -302,7 +396,8 @@ export async function releasePieces(
302
396
  .prepare(`SELECT * FROM features WHERE status = 'building'`)
303
397
  .all() as Feature[];
304
398
  for (const feature of features) {
305
- syncPieces(piecesOf(feature.id));
399
+ syncPieces(feature, piecesOf(feature.id));
400
+ creditParentMerge(feature, piecesOf(feature.id));
306
401
  const all = piecesOf(feature.id);
307
402
  for (const piece of all.filter((p) => p.status === "waiting"))
308
403
  await releaseOne(feature, piecesOf(feature.id), piece, deps).catch((e) =>
@@ -319,9 +414,9 @@ export function releaseAnyway(pieceId: number): boolean {
319
414
  db
320
415
  .prepare(
321
416
  `UPDATE feature_pieces SET status = 'waiting', after_position = NULL,
322
- after_kind = NULL, note = NULL WHERE id = ? AND status = 'blocked'`
417
+ after_kind = NULL, note = ? WHERE id = ? AND status = 'blocked'`
323
418
  )
324
- .run(pieceId).changes > 0
419
+ .run(RELEASED_ANYWAY, pieceId).changes > 0
325
420
  );
326
421
  }
327
422
 
package/lib/plans/view.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // is, and each piece's state in one line.
3
3
 
4
4
  import { db } from "../db";
5
- import { piecesOf, type Feature, type FeaturePiece } from "./features";
5
+ import { piecesOf, prOf, type Feature, type FeaturePiece } from "./features";
6
6
 
7
7
  export interface PieceView {
8
8
  id: number;
@@ -40,13 +40,7 @@ const LINE = {
40
40
  };
41
41
 
42
42
  function pieceView(p: FeaturePiece): PieceView {
43
- const pr = p.session_id
44
- ? ((
45
- db
46
- .prepare(`SELECT pr_number FROM sessions WHERE id = ?`)
47
- .get(p.session_id) as { pr_number: number | null } | undefined
48
- )?.pr_number ?? null)
49
- : null;
43
+ const pr = prOf(p);
50
44
  return {
51
45
  id: p.id,
52
46
  position: p.position,
@@ -32,23 +32,38 @@ const num = (v: unknown) =>
32
32
  const eventPercent = (fraction: number) => fraction * 100;
33
33
 
34
34
  // Readings in what the CLI emits: a rate_limit_event's info, or a plan's
35
- // per-window object. Anything else gives none.
35
+ // per-window object. Anything else gives none. An event's top-level reading
36
+ // is only there when a threshold was crossed, while its `unifiedWindows`
37
+ // (read from the same response headers) carries both windows every time, so
38
+ // that one wins for a window both name.
36
39
  export function parseRateLimit(info: unknown): Reading[] {
37
40
  if (!info || typeof info !== "object") return [];
38
41
  const i = info as Record<string, unknown>;
39
- const out: Reading[] = [];
42
+ const out = new Map<LimitWindow, Reading>();
40
43
  const status = typeof i.status === "string" ? i.status : null;
41
- const type = i.rateLimitType;
42
- const raw = num(i.utilization);
43
- if ((type === "five_hour" || type === "seven_day") && raw !== null) {
44
- const reset = num(i.resetsAt);
45
- out.push({
46
- window: type,
44
+ const eventReading = (
45
+ window: LimitWindow,
46
+ fraction: unknown,
47
+ resetsAt: unknown
48
+ ) => {
49
+ const raw = num(fraction);
50
+ if (raw === null) return;
51
+ const reset = num(resetsAt);
52
+ out.set(window, {
53
+ window,
47
54
  utilization: eventPercent(raw),
48
55
  raw,
49
56
  resetsAt: reset === null ? null : reset * 1000,
50
57
  status,
51
58
  });
59
+ };
60
+ const type = i.rateLimitType;
61
+ if (type === "five_hour" || type === "seven_day")
62
+ eventReading(type, i.utilization, i.resetsAt);
63
+ const unified = i.unifiedWindows as Record<string, unknown> | undefined;
64
+ for (const w of WINDOWS) {
65
+ const o = unified?.[w] as Record<string, unknown> | null | undefined;
66
+ if (o && typeof o === "object") eventReading(w, o.utilization, o.resetsAt);
52
67
  }
53
68
  for (const w of WINDOWS) {
54
69
  const o = i[w] as Record<string, unknown> | null | undefined;
@@ -56,7 +71,7 @@ export function parseRateLimit(info: unknown): Reading[] {
56
71
  if (!o || u === null || u === undefined) continue;
57
72
  const reset =
58
73
  typeof o.resets_at === "string" ? Date.parse(o.resets_at) : NaN;
59
- out.push({
74
+ out.set(w, {
60
75
  window: w,
61
76
  utilization: u,
62
77
  raw: u,
@@ -64,11 +79,16 @@ export function parseRateLimit(info: unknown): Reading[] {
64
79
  status,
65
80
  });
66
81
  }
67
- return out;
82
+ return [...out.values()];
68
83
  }
69
84
 
85
+ // An unchanged reading refreshes the last row's time at most this often, so
86
+ // the newest row's `at` is when the value was last seen, not when it moved.
87
+ const REFRESH_MS = 60_000;
88
+
70
89
  // Stores a reading only when it differs from that window's last, so a busy
71
- // day adds a handful of rows, not one per response.
90
+ // day adds a handful of rows, not one per response. A reading that hasn't
91
+ // moved only touches the last row's time. Returns the rows added.
72
92
  export function recordReadings(
73
93
  readings: Reading[],
74
94
  source: string,
@@ -79,19 +99,31 @@ export function recordReadings(
79
99
  for (const r of readings) {
80
100
  const last = db
81
101
  .prepare(
82
- `SELECT utilization, resets_at, status FROM rate_limit_readings
102
+ `SELECT id, at, utilization, resets_at, status FROM rate_limit_readings
83
103
  WHERE window = ? ORDER BY id DESC LIMIT 1`
84
104
  )
85
105
  .get(r.window) as
86
- | { utilization: number; resets_at: number | null; status: string | null }
106
+ | {
107
+ id: number;
108
+ at: number;
109
+ utilization: number;
110
+ resets_at: number | null;
111
+ status: string | null;
112
+ }
87
113
  | undefined;
88
114
  if (
89
115
  last &&
90
116
  last.utilization === r.utilization &&
91
117
  last.resets_at === r.resetsAt &&
92
118
  last.status === r.status
93
- )
119
+ ) {
120
+ if (at - last.at >= REFRESH_MS)
121
+ db.prepare(`UPDATE rate_limit_readings SET at = ? WHERE id = ?`).run(
122
+ at,
123
+ last.id
124
+ );
94
125
  continue;
126
+ }
95
127
  db.prepare(
96
128
  `INSERT INTO rate_limit_readings (at, window, utilization, raw, resets_at, status, session_id, source)
97
129
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
@@ -135,6 +167,38 @@ export function lastReading(
135
167
  : null;
136
168
  }
137
169
 
170
+ // A window's readings in its current reset period over the hour up to the
171
+ // newest, oldest first as [epoch seconds, percent]: the series a burn rate
172
+ // is fitted to. Empty when there are none.
173
+ export function windowSamples(
174
+ window: LimitWindow,
175
+ spanMs = 3_600_000
176
+ ): { resetsAt: number | null; samples: [number, number][] } {
177
+ const newest = lastReading(window);
178
+ if (!newest) return { resetsAt: null, samples: [] };
179
+ const rows = db
180
+ .prepare(
181
+ `SELECT at, utilization FROM rate_limit_readings
182
+ WHERE window = ? AND resets_at IS ? AND at >= ? ORDER BY at, id`
183
+ )
184
+ .all(window, newest.resetsAt, newest.at - spanMs) as {
185
+ at: number;
186
+ utilization: number;
187
+ }[];
188
+ return {
189
+ resetsAt: newest.resetsAt,
190
+ samples: rows.map((r) => [r.at / 1000, r.utilization]),
191
+ };
192
+ }
193
+
194
+ // When any agent call was last recorded.
195
+ export function lastAgentActivity(): number | null {
196
+ const r = db.prepare(`SELECT MAX(at) AS at FROM usage_calls`).get() as {
197
+ at: number | null;
198
+ };
199
+ return r.at;
200
+ }
201
+
138
202
  const WEEK = 7 * 24 * 3600 * 1000;
139
203
 
140
204
  export function weightedBetween(from: number, to: number): number {
@@ -33,7 +33,7 @@ function savedTotals(sessionId: string): UsageTotals | null {
33
33
  }
34
34
 
35
35
  // The plan's 5-hour window when a turn on it ended, for its share of the
36
- // plan (lib/usage/compare): unknown when the statusline isn't sampling.
36
+ // plan (lib/usage/compare): unknown when nothing is sampling it.
37
37
  export function planWindowPct(): number | null {
38
38
  const u = readUsage();
39
39
  return "window" in u && u.window ? u.window.pct : null;
@@ -1,15 +1,23 @@
1
1
  /**
2
- * The account's rolling usage windows. The 5-hour one comes from the
3
- * statusline's samples (lib/orchestrator/usage.ts), which also give its burn
4
- * rate; the weekly one only the agent knows, so it's asked through a
5
- * short-lived agent start (no turn is spent) and kept for a few minutes.
2
+ * The account's rolling usage windows. The 5-hour one comes from the samples
3
+ * in lib/orchestrator/usage.ts (AgentOS's own agents and the terminal
4
+ * statusline, whichever is fresher), which also give its burn rate; the
5
+ * weekly one from the readings AgentOS's agents store, else the plan's answer
6
+ * to a short-lived agent start (no turn is spent), kept for a few minutes.
7
+ * With nothing fresh, the last value seen is shown as such.
6
8
  */
7
9
 
8
10
  import os from "os";
9
11
  import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
10
12
  import { InputQueue } from "../chat/queue";
11
- import { readUsage } from "../orchestrator/usage";
12
- import { currentWindow, type WindowReading } from "./rate-limits";
13
+ import { readUsage, type LastSeen } from "../orchestrator/usage";
14
+ import {
15
+ currentWindow,
16
+ lastReading,
17
+ parseRateLimit,
18
+ recordReadings,
19
+ type WindowReading,
20
+ } from "./rate-limits";
13
21
 
14
22
  export interface WindowView {
15
23
  pct: number;
@@ -20,6 +28,11 @@ export interface WindowView {
20
28
  burnPerMin?: number | null;
21
29
  // Worked out from recorded usage, not read from the account.
22
30
  estimated?: boolean;
31
+ // Not sampled lately: the last value seen, and when (epoch ms).
32
+ stale?: boolean;
33
+ sampledAt?: number;
34
+ // Seconds since then, when this was worked out.
35
+ ageSec?: number;
23
36
  }
24
37
 
25
38
  export interface UsageWindows {
@@ -71,7 +84,12 @@ async function askAgent(): Promise<PlanLimits | null> {
71
84
  );
72
85
  }),
73
86
  ]);
74
- return usage.rate_limits_available ? (usage.rate_limits ?? null) : null;
87
+ const limits = usage.rate_limits_available
88
+ ? ((usage.rate_limits ?? null) as PlanLimits | null)
89
+ : null;
90
+ // A plan answer is a reading too: keep it for the screens and brakes.
91
+ if (limits) recordReadings(parseRateLimit(limits), "plan");
92
+ return limits;
75
93
  } finally {
76
94
  clearTimeout(timer);
77
95
  input.end();
@@ -125,6 +143,34 @@ const fromReading = (
125
143
  }
126
144
  : null;
127
145
 
146
+ // The last value seen, shown as such, while its window is still open.
147
+ export function fromLast(
148
+ last: LastSeen | null | undefined,
149
+ nowSec = Date.now() / 1000
150
+ ): WindowView | null {
151
+ if (!last || (last.resetsAt !== null && last.resetsAt <= nowSec)) return null;
152
+ return {
153
+ pct: last.pct,
154
+ resetsIn:
155
+ last.resetsAt === null ? null : Math.round(last.resetsAt - nowSec),
156
+ stale: true,
157
+ sampledAt: Math.round(last.sampledAt * 1000),
158
+ ageSec: Math.max(0, Math.round(nowSec - last.sampledAt)),
159
+ };
160
+ }
161
+
162
+ function lastWeekly(nowMs: number): WindowView | null {
163
+ const r = lastReading("seven_day");
164
+ return fromLast(
165
+ r && {
166
+ pct: Math.round(r.utilization),
167
+ resetsAt: r.resetsAt === null ? null : r.resetsAt / 1000,
168
+ sampledAt: r.at / 1000,
169
+ },
170
+ nowMs / 1000
171
+ );
172
+ }
173
+
128
174
  export async function usageWindows(): Promise<UsageWindows> {
129
175
  const sampled = readUsage();
130
176
  const read = (w: "five_hour" | "seven_day") => {
@@ -144,14 +190,23 @@ export async function usageWindows(): Promise<UsageWindows> {
144
190
  capsIn: sampled.window.capsIn,
145
191
  burnPerMin: sampled.window.burnPerMin,
146
192
  }
147
- : (fromPlan(plan.limits?.five_hour) ?? read("five_hour"));
148
- const sevenDay = fromPlan(plan.limits?.seven_day) ?? read("seven_day");
193
+ : (fromPlan(plan.limits?.five_hour) ??
194
+ read("five_hour") ??
195
+ ("unknown" in sampled ? fromLast(sampled.last) : null));
196
+ let sevenDay: WindowView | null = null;
197
+ try {
198
+ sevenDay =
199
+ fromPlan(plan.limits?.seven_day) ??
200
+ read("seven_day") ??
201
+ lastWeekly(Date.now());
202
+ } catch {
203
+ // No readings to show.
204
+ }
149
205
  const note =
150
- !sevenDay || !fiveHour
151
- ? (plan.error ??
152
- ("unknown" in sampled
153
- ? sampled.unknown
154
- : "the plan's limits aren't available for this login"))
155
- : undefined;
206
+ "unknown" in sampled && (!fiveHour || fiveHour.stale)
207
+ ? sampled.unknown
208
+ : !sevenDay || !fiveHour
209
+ ? (plan.error ?? "the plan's limits aren't available for this login")
210
+ : undefined;
156
211
  return { fiveHour, sevenDay, note };
157
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.567",
3
+ "version": "0.3.569",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"