@saadnvd1/agent-os 0.3.470 → 0.3.472

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.
Files changed (95) hide show
  1. package/README.md +139 -36
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/models/route.ts +34 -0
  4. package/app/api/models/test/route.ts +17 -0
  5. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  6. package/app/api/tasks/[id]/model/route.ts +23 -0
  7. package/app/api/tasks/route.ts +5 -1
  8. package/app/api/usage/compare/route.ts +21 -0
  9. package/app/api/usage/route.ts +8 -3
  10. package/app/api/workspaces/[id]/models/route.ts +27 -0
  11. package/components/Chat/ChatPanel.tsx +5 -5
  12. package/components/Chat/StaleChip.tsx +7 -6
  13. package/components/CommandPalette/useAppCommands.ts +19 -1
  14. package/components/Draft/DraftChips.tsx +28 -0
  15. package/components/Draft/DraftPanel.tsx +1 -1
  16. package/components/Models/ModelSelect.tsx +67 -0
  17. package/components/Models/ModelsSection.tsx +199 -0
  18. package/components/Models/ProviderCard.tsx +271 -0
  19. package/components/SessionList/SidebarHeader.tsx +5 -1
  20. package/components/Settings/SettingsDialog.test.ts +3 -0
  21. package/components/Settings/SettingsDialog.tsx +9 -0
  22. package/components/Settings/commands.ts +19 -0
  23. package/components/Settings/entry-points.test.ts +1 -0
  24. package/components/Tasks/ResumeOnModel.tsx +72 -0
  25. package/components/Tasks/TaskRow.tsx +4 -0
  26. package/components/Usage/CompareView.tsx +211 -0
  27. package/components/Usage/ModelSpend.tsx +133 -0
  28. package/components/Usage/UsageDialog.tsx +69 -5
  29. package/components/Usage/format.ts +9 -0
  30. package/components/Workspaces/FreshStartForm.tsx +13 -13
  31. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  32. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  33. package/data/models/index.ts +125 -0
  34. package/data/usage/index.ts +5 -0
  35. package/lib/agents/launch.ts +13 -2
  36. package/lib/chat/context.ts +1 -1
  37. package/lib/chat/refresh.ts +31 -13
  38. package/lib/chat/runner.ts +5 -0
  39. package/lib/chat/stale.ts +25 -16
  40. package/lib/chat/worker/extras.ts +12 -0
  41. package/lib/chat/worker/host.ts +70 -24
  42. package/lib/chat/worker/main.ts +5 -2
  43. package/lib/db/migrations.ts +70 -0
  44. package/lib/db/types.ts +6 -0
  45. package/lib/drafts.ts +3 -0
  46. package/lib/models/config.ts +218 -0
  47. package/lib/models/launch.ts +93 -0
  48. package/lib/models/plan-step.ts +94 -0
  49. package/lib/models/providers.ts +163 -0
  50. package/lib/models/resolve.ts +246 -0
  51. package/lib/models/resume.ts +99 -0
  52. package/lib/models/roles.ts +98 -0
  53. package/lib/models/secrets.ts +86 -0
  54. package/lib/models/session-model.ts +40 -0
  55. package/lib/models/test-provider.ts +116 -0
  56. package/lib/orchestrator/act.ts +13 -1
  57. package/lib/orchestrator/armed.ts +1 -2
  58. package/lib/orchestrator/brief.ts +5 -4
  59. package/lib/orchestrator/claude-cli.ts +48 -14
  60. package/lib/orchestrator/diff.ts +1 -1
  61. package/lib/orchestrator/external-gates.ts +8 -43
  62. package/lib/orchestrator/gates.ts +10 -10
  63. package/lib/orchestrator/home.ts +2 -2
  64. package/lib/orchestrator/pr-wake.ts +2 -2
  65. package/lib/orchestrator/rebase.ts +6 -45
  66. package/lib/orchestrator/review.ts +20 -5
  67. package/lib/orchestrator/scope.ts +11 -0
  68. package/lib/orchestrator/serve.ts +13 -0
  69. package/lib/orchestrator/signoff.ts +13 -8
  70. package/lib/orchestrator/tool-names.ts +1 -0
  71. package/lib/orchestrator/tool-schemas.ts +38 -0
  72. package/lib/orchestrator/tools.ts +3 -1
  73. package/lib/pr-generation.ts +5 -2
  74. package/lib/providers/registry.ts +1 -1
  75. package/lib/session-audit/render.ts +1 -1
  76. package/lib/session-titles.ts +1 -1
  77. package/lib/sessions/launch.ts +19 -4
  78. package/lib/settings-url.ts +1 -0
  79. package/lib/stacks/land.ts +23 -16
  80. package/lib/tasks/brief.ts +3 -14
  81. package/lib/tasks/code-review.ts +69 -107
  82. package/lib/tasks/gh.ts +3 -2
  83. package/lib/tasks/index.ts +60 -9
  84. package/lib/tasks/queue.ts +14 -5
  85. package/lib/tasks/start.ts +7 -2
  86. package/lib/tasks/state.ts +4 -4
  87. package/lib/usage/by-model.ts +105 -0
  88. package/lib/usage/check-runs.ts +170 -0
  89. package/lib/usage/compare.ts +280 -0
  90. package/lib/usage/cost.ts +71 -0
  91. package/lib/usage/report.ts +51 -0
  92. package/lib/usage/turns.ts +107 -18
  93. package/package.json +1 -1
  94. package/stores/usageUi.ts +11 -2
  95. package/scripts/check-code-review.ts +0 -21
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ModelSelect } from "@/components/Models/ModelSelect";
5
+ import {
6
+ useModelsQuery,
7
+ useUpdateWorkspaceModels,
8
+ useWorkspaceModelsQuery,
9
+ } from "@/data/models";
10
+ import { MODEL_ROLES, ROLE_LABELS, type ModelRole } from "@/lib/models/roles";
11
+
12
+ // The roles that run inside a workspace; interactive sessions and helpers
13
+ // follow Settings.
14
+ const WORKSPACE_ROLES = MODEL_ROLES.filter(
15
+ (r): r is Exclude<ModelRole, "interactive" | "helper"> =>
16
+ r !== "interactive" && r !== "helper"
17
+ );
18
+
19
+ // A workspace's own role models, over Settings > Models.
20
+ export function WorkspaceModelsForm({ workspaceId }: { workspaceId: string }) {
21
+ const [open, setOpen] = useState(false);
22
+ const models = useModelsQuery(open);
23
+ const own = useWorkspaceModelsQuery(workspaceId);
24
+ const update = useUpdateWorkspaceModels(workspaceId);
25
+ const roles = own.data?.roles ?? {};
26
+ const set = Object.keys(roles).length;
27
+
28
+ return (
29
+ <div className="space-y-2">
30
+ <button
31
+ type="button"
32
+ className="text-muted-foreground hover:text-foreground h-11 text-left text-xs md:h-auto"
33
+ onClick={() => setOpen((o) => !o)}
34
+ aria-expanded={open}
35
+ >
36
+ Models: {set ? `${set} set for this workspace` : "as Settings › Models"}{" "}
37
+ {open ? "▴" : "▾"}
38
+ </button>
39
+ {open && (
40
+ <ul className="space-y-2">
41
+ {WORKSPACE_ROLES.map((role) => (
42
+ <li key={role} className="space-y-1">
43
+ <label
44
+ htmlFor={`ws-${workspaceId}-${role}`}
45
+ className="text-muted-foreground block text-xs"
46
+ >
47
+ {ROLE_LABELS[role].label}
48
+ </label>
49
+ <ModelSelect
50
+ id={`ws-${workspaceId}-${role}`}
51
+ value={roles[role] ?? null}
52
+ effective={models.data?.effective}
53
+ inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
54
+ disabled={update.isPending || !models.data}
55
+ onChange={(ref) => {
56
+ const next = { ...roles };
57
+ if (ref) next[role] = ref;
58
+ else delete next[role];
59
+ update.mutate({ roles: next });
60
+ }}
61
+ />
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ )}
66
+ {update.isError && (
67
+ <p className="text-destructive text-xs">{update.error.message}</p>
68
+ )}
69
+ </div>
70
+ );
71
+ }
@@ -5,6 +5,7 @@ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
5
5
  import { TaskLimitForm } from "./TaskLimitForm";
6
6
  import { RulesForm } from "./RulesForm";
7
7
  import { FreshStartForm } from "./FreshStartForm";
8
+ import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
8
9
 
9
10
  // Settings > Workspaces: each workspace's own settings.
10
11
  export function WorkspaceSettings() {
@@ -41,6 +42,7 @@ export function WorkspaceSettings() {
41
42
  workspace={w}
42
43
  />
43
44
  <RulesForm workspaceId={w.id} />
45
+ <WorkspaceModelsForm workspaceId={w.id} />
44
46
  <SettingToggle
45
47
  title="Wake tasks on CI failures and reviews"
46
48
  detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
@@ -0,0 +1,125 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type {
3
+ EffectiveModels,
4
+ ModelSettings,
5
+ WorkspaceModels,
6
+ } from "@/lib/models/config";
7
+ import type { ModelRole } from "@/lib/models/roles";
8
+ import type { ProviderTest } from "@/lib/models/test-provider";
9
+ import type { ResumeResult } from "@/lib/models/resume";
10
+ import type { ModelOverride } from "@/lib/models/resolve";
11
+ import type { Comparison } from "@/lib/usage/compare";
12
+
13
+ // Model routing (lib/models): role models, providers, prices.
14
+ export const modelKeys = {
15
+ all: ["models"] as const,
16
+ workspace: (id: string) => [...modelKeys.all, "workspace", id] as const,
17
+ compare: (days: number) => [...modelKeys.all, "compare", days] as const,
18
+ };
19
+
20
+ export interface ModelsResponse {
21
+ saved: ModelSettings;
22
+ effective: EffectiveModels;
23
+ defaults: Record<ModelRole, string>;
24
+ }
25
+
26
+ async function json<T>(res: Response): Promise<T> {
27
+ const data = await res.json().catch(() => ({}));
28
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
29
+ return data;
30
+ }
31
+
32
+ const send = (url: string, method: string, body: unknown) =>
33
+ fetch(url, {
34
+ method,
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify(body),
37
+ });
38
+
39
+ export function useModelsQuery(enabled = true) {
40
+ return useQuery({
41
+ queryKey: modelKeys.all,
42
+ queryFn: async () => json<ModelsResponse>(await fetch("/api/models")),
43
+ staleTime: 60_000,
44
+ enabled,
45
+ });
46
+ }
47
+
48
+ export function useUpdateModels() {
49
+ const queryClient = useQueryClient();
50
+ return useMutation({
51
+ mutationFn: async (settings: ModelSettings) =>
52
+ json<ModelsResponse>(await send("/api/models", "PUT", settings)),
53
+ onSuccess: (data) => queryClient.setQueryData(modelKeys.all, data),
54
+ });
55
+ }
56
+
57
+ export function useTestProvider() {
58
+ return useMutation({
59
+ mutationFn: async (input: { provider: string; model?: string }) =>
60
+ json<ProviderTest>(await send("/api/models/test", "POST", input)),
61
+ });
62
+ }
63
+
64
+ export function useWorkspaceModelsQuery(workspaceId: string) {
65
+ return useQuery({
66
+ queryKey: modelKeys.workspace(workspaceId),
67
+ queryFn: async () =>
68
+ json<WorkspaceModels>(
69
+ await fetch(`/api/workspaces/${workspaceId}/models`)
70
+ ),
71
+ staleTime: 60_000,
72
+ });
73
+ }
74
+
75
+ export function useUpdateWorkspaceModels(workspaceId: string) {
76
+ const queryClient = useQueryClient();
77
+ return useMutation({
78
+ mutationFn: async (models: WorkspaceModels) =>
79
+ json<WorkspaceModels>(
80
+ await send(`/api/workspaces/${workspaceId}/models`, "PUT", models)
81
+ ),
82
+ onSuccess: (data) =>
83
+ queryClient.setQueryData(modelKeys.workspace(workspaceId), data),
84
+ });
85
+ }
86
+
87
+ export function useResumeOnModel() {
88
+ return useMutation({
89
+ mutationFn: async (input: {
90
+ taskId: string;
91
+ model: ModelOverride;
92
+ message?: string;
93
+ }) =>
94
+ json<ResumeResult>(
95
+ await send(`/api/tasks/${input.taskId}/model`, "POST", {
96
+ model: input.model,
97
+ message: input.message,
98
+ })
99
+ ),
100
+ });
101
+ }
102
+
103
+ export function useCompareQuery(days: number, enabled = true) {
104
+ return useQuery({
105
+ queryKey: modelKeys.compare(days),
106
+ queryFn: async () =>
107
+ json<Comparison>(await fetch(`/api/usage/compare?days=${days}`)),
108
+ enabled,
109
+ staleTime: 60_000,
110
+ placeholderData: (prev) => prev,
111
+ });
112
+ }
113
+
114
+ // Every model a role can be set to: each usable provider's, as references.
115
+ export function modelChoices(effective: EffectiveModels | undefined) {
116
+ return (effective?.providers ?? [])
117
+ .filter((p) => p.enabled)
118
+ .flatMap((p) =>
119
+ p.models.map((m) => ({
120
+ ref: `${p.id}/${m}`,
121
+ label: m,
122
+ provider: p.label,
123
+ }))
124
+ );
125
+ }
@@ -1,6 +1,7 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
2
  import type { UsageRange, UsageReport } from "@/lib/usage/aggregate";
3
3
  import type { UsageWindows } from "@/lib/usage/windows";
4
+ import type { SpendByModel } from "@/lib/usage/by-model";
4
5
 
5
6
  export const usageKeys = {
6
7
  all: ["usage"] as const,
@@ -9,7 +10,11 @@ export const usageKeys = {
9
10
 
10
11
  export interface UsageResponse {
11
12
  report: UsageReport;
13
+ // The orchestrator's reviewer and scope check runs, by PR.
14
+ reviews: UsageReport;
12
15
  windows: UsageWindows;
16
+ // By model and role, in the plan's 5-hour window and the last 24 hours.
17
+ models: { fiveHour: SpendByModel; day: SpendByModel };
13
18
  }
14
19
 
15
20
  export function useUsageQuery(range: UsageRange, enabled = true) {
@@ -4,11 +4,13 @@ import path from "path";
4
4
  import { execFile } from "child_process";
5
5
  import { promisify } from "util";
6
6
  import { getProvider } from "../providers";
7
+ import { sessionShellEnv } from "../models/launch";
7
8
  import { shellQuote } from "../hosts/ssh";
8
9
  import { runInBackground } from "../async-operations";
9
10
  import { trustPromptKeys } from "../tasks/state";
10
11
  import { BUS_BRIEF } from "./brief";
11
12
  import { selfUrl } from "./self-env";
13
+ import { autoCompactEnv } from "../chat/refresh";
12
14
  import { CLAUDE_STATUS_SETTINGS_FLAG } from "../program-status/claude-flag";
13
15
  import {
14
16
  sessionProjectEnv,
@@ -35,6 +37,7 @@ export function ensureBusBrief(): void {
35
37
  export function agentEnv(sessionId: string): Record<string, string> {
36
38
  return {
37
39
  ...sessionProjectEnv(sessionId),
40
+ ...autoCompactEnv(sessionId),
38
41
  AGENTOS_SESSION_ID: sessionId,
39
42
  AGENTOS_URL: selfUrl(),
40
43
  PATH: `${AOS_BIN_DIR}:${process.env.PATH ?? ""}`,
@@ -62,6 +65,8 @@ export async function launchClaude(opts: {
62
65
  tmuxName: string;
63
66
  cwd: string;
64
67
  model: string;
68
+ // The model's provider (lib/models); Anthropic when absent.
69
+ provider?: string | null;
65
70
  prompt: string;
66
71
  brief?: string;
67
72
  // Continue this Claude conversation; the prompt is its next message.
@@ -86,7 +91,13 @@ export async function launchClaude(opts: {
86
91
  sessionId: opts.resume,
87
92
  })
88
93
  .join(" ");
89
- const agent = `export PATH=${shellQuote(AOS_BIN_DIR)}:"$HOME/.local/bin:$PATH"; ${provider.command} ${flags} ${CLAUDE_STATUS_SETTINGS_FLAG} --append-system-prompt-file ${shellQuote(briefFile)} "$(cat ${shellQuote(promptFile)})"; exec "\${SHELL:-/bin/sh}" -l`;
94
+ // The provider's key is fetched by the pane's own shell from its
95
+ // reference: only the reference is in this command line.
96
+ const routing = sessionShellEnv({
97
+ model: opts.model,
98
+ model_provider: opts.provider ?? null,
99
+ });
100
+ const agent = `export PATH=${shellQuote(AOS_BIN_DIR)}:"$HOME/.local/bin:$PATH"; ${routing.exportKey} ${provider.command} ${flags} ${CLAUDE_STATUS_SETTINGS_FLAG} --append-system-prompt-file ${shellQuote(briefFile)} "$(cat ${shellQuote(promptFile)})"; exec "\${SHELL:-/bin/sh}" -l`;
90
101
  await execFileAsync(
91
102
  "tmux",
92
103
  [
@@ -96,7 +107,7 @@ export async function launchClaude(opts: {
96
107
  opts.tmuxName,
97
108
  "-c",
98
109
  opts.cwd,
99
- ...envArgs(agentEnv(opts.sessionId)),
110
+ ...envArgs({ ...agentEnv(opts.sessionId), ...routing.env }),
100
111
  agent,
101
112
  ],
102
113
  { cwd: opts.cwd }
@@ -49,7 +49,7 @@ export function formatTokens(n: number): string {
49
49
  return String(n);
50
50
  }
51
51
 
52
- type ModelUsage = Record<
52
+ export type ModelUsage = Record<
53
53
  string,
54
54
  {
55
55
  inputTokens?: number;
@@ -6,8 +6,8 @@
6
6
  * default: its state is in the database and its brief carries it
7
7
  * (lib/orchestrator/digest). A task keeps its progress only in its
8
8
  * conversation and its branch, so for tasks it's off unless switched on.
9
- * App-wide, in Settings → Workspaces, with the size past which an idle
10
- * chat is compacted before a message goes in (lib/chat/stale).
9
+ * App-wide, in Settings → Workspaces, with the size at which chats and
10
+ * tasks compact (lib/chat/stale).
11
11
  */
12
12
 
13
13
  import { db } from "../db";
@@ -15,18 +15,20 @@ import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
15
15
 
16
16
  export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
17
17
  export const TASK_KEY = "chat.task_refresh_tokens";
18
- export const STALE_COMPACT_KEY = "chat.stale_compact_tokens";
18
+ // Named for what it first did: compact chats idle past their cache.
19
+ export const COMPACT_KEY = "chat.stale_compact_tokens";
19
20
  export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
20
- export const DEFAULT_STALE_COMPACT_TOKENS = 150_000;
21
+ export const DEFAULT_COMPACT_TOKENS = 150_000;
21
22
  export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
22
23
 
23
24
  export interface RefreshSettings {
24
25
  // Context tokens at which a conversation starts over; 0 is off.
25
26
  orchestratorTokens: number;
26
27
  taskTokens: number;
27
- // Context tokens above which a chat whose prompt cache has expired is
28
- // compacted before the next message; 0 is off.
29
- staleCompactTokens: number;
28
+ // Context tokens at which a chat's or task's agent compacts: Claude
29
+ // Code's own auto-compact window, and between turns a chat over it (or
30
+ // idle past its cache) is compacted before the next message. 0 is off.
31
+ compactTokens: number;
30
32
  }
31
33
 
32
34
  function read(key: string, fallback: number): number {
@@ -41,7 +43,7 @@ export function refreshSettings(): RefreshSettings {
41
43
  return {
42
44
  orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
43
45
  taskTokens: read(TASK_KEY, 0),
44
- staleCompactTokens: read(STALE_COMPACT_KEY, DEFAULT_STALE_COMPACT_TOKENS),
46
+ compactTokens: read(COMPACT_KEY, DEFAULT_COMPACT_TOKENS),
45
47
  };
46
48
  }
47
49
 
@@ -74,16 +76,32 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
74
76
  ]);
75
77
  if (input.taskTokens !== undefined)
76
78
  checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
77
- if (input.staleCompactTokens !== undefined)
78
- checked.push([
79
- STALE_COMPACT_KEY,
80
- checkRefreshTokens(input.staleCompactTokens),
81
- ]);
79
+ if (input.compactTokens !== undefined)
80
+ checked.push([COMPACT_KEY, checkRefreshTokens(input.compactTokens)]);
82
81
  db.transaction(() => {
83
82
  for (const [key, n] of checked) put.run(key, String(n));
84
83
  })();
85
84
  }
86
85
 
86
+ // Claude Code's own auto-compact window, in plain tokens (it reads "150k"
87
+ // as 150 and clamps anything under 100k up to it).
88
+ export const AUTO_COMPACT_ENV = "CLAUDE_CODE_AUTO_COMPACT_WINDOW";
89
+ const AUTO_COMPACT_MIN = 100_000;
90
+
91
+ // What an agent is launched with so it compacts itself at the threshold.
92
+ // Not an orchestrator: it starts over from its brief at its own threshold
93
+ // instead, since a summary would carry what it read from tasks and CI
94
+ // without its untrusted fence. Nothing when it's off, so a window set in
95
+ // the user's own environment still applies.
96
+ export function autoCompactEnv(sessionId: string): Record<string, string> {
97
+ const row = db
98
+ .prepare(`SELECT role FROM sessions WHERE id = ?`)
99
+ .get(sessionId) as { role: string | null } | undefined;
100
+ const tokens = refreshSettings().compactTokens;
101
+ if (row?.role === "orchestrator" || tokens <= 0) return {};
102
+ return { [AUTO_COMPACT_ENV]: String(Math.max(AUTO_COMPACT_MIN, tokens)) };
103
+ }
104
+
87
105
  const k = (tokens: number) => `${Math.round(tokens / 1000)}k`;
88
106
 
89
107
  // What the agent reads ahead of its first message in the new conversation.
@@ -5,6 +5,7 @@
5
5
  * events to whoever is watching. Restarting the server leaves turns running.
6
6
  */
7
7
 
8
+ import { handoffAfterPlan } from "../models/plan-step";
8
9
  import { randomUUID } from "crypto";
9
10
  import os from "os";
10
11
  import { db, type Session } from "../db";
@@ -163,6 +164,10 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
163
164
  clearTimeout(live.retiring);
164
165
  live.retiring = undefined;
165
166
  retireIfStale(sessionId, live);
167
+ if (e.state === "idle")
168
+ void handoffAfterPlan(sessionId, (id, m) => sendQueuedNow(id, m)).catch(
169
+ (err) => console.error(`Plan handoff for ${sessionId} failed:`, err)
170
+ );
166
171
  } else if (e.type === "suggestion") {
167
172
  emit(sessionId, e);
168
173
  if (live.retiring && e.text) retire(sessionId, live);
package/lib/chat/stale.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Stale chats: a message sent to a conversation whose prompt cache has
3
- * expired re-reads the whole history at the cache-write price. When it is
4
- * also long, compacting first is far cheaper than carrying it (one summary
5
- * turn, then a small cache write instead of a huge one). The one check every
6
- * send path uses: the composer to offer it (components/Chat/StaleChip), the
7
- * chat worker to do it for automated sends (lib/chat/worker/host).
2
+ * Compacting between turns. A chat's agent compacts itself at the compact
3
+ * threshold (Claude Code's auto-compact window, set at launch by
4
+ * lib/agents/launch). Between turns AgentOS catches what it didn't: a chat
5
+ * over the threshold is compacted before the next message, and so is one
6
+ * whose prompt cache has expired, since that message would re-read the
7
+ * whole history at the cache-write price (one summary turn, then a small
8
+ * cache write instead of a huge one). The one check every send path uses:
9
+ * the composer to offer it (components/Chat/StaleChip), the chat worker to
10
+ * do it for automated sends (lib/chat/worker/host).
8
11
  *
9
12
  * Pure, so the browser and the worker read the same rule.
10
13
  */
@@ -38,21 +41,26 @@ export function cacheTtlMs(
38
41
  export interface Staleness {
39
42
  idleMs: number;
40
43
  tokens: number;
44
+ // Idle past the cache its last turn wrote, not only over the threshold.
45
+ stale: boolean;
41
46
  }
42
47
 
43
- // Stale: no turn running, the last one ended longer ago than the cache it
44
- // wrote lives, and the context is above the threshold (0 is off). Null when
45
- // it isn't, or when the chat never said which cache it uses.
46
- export function staleness(
48
+ // Due a compaction before the next message: no turn running and the
49
+ // context above the threshold (0 is off). Null when it isn't, or when the
50
+ // chat never said which cache it uses (only Claude's chats do).
51
+ export function compactionDue(
47
52
  context: ChatContext | null | undefined,
48
53
  opts: { state: ChatState; thresholdTokens: number; now?: number }
49
54
  ): Staleness | null {
50
55
  if (opts.state === "running" || opts.state === "waiting") return null;
51
56
  if (!context?.cacheTtlMs || opts.thresholdTokens <= 0) return null;
52
- const idleMs = (opts.now ?? Date.now()) - context.at;
53
- if (idleMs <= context.cacheTtlMs) return null;
54
57
  if (context.usedTokens <= opts.thresholdTokens) return null;
55
- return { idleMs, tokens: context.usedTokens };
58
+ const idleMs = (opts.now ?? Date.now()) - context.at;
59
+ return {
60
+ idleMs,
61
+ tokens: context.usedTokens,
62
+ stale: idleMs > context.cacheTtlMs,
63
+ };
56
64
  }
57
65
 
58
66
  export function formatIdle(ms: number): string {
@@ -62,9 +70,10 @@ export function formatIdle(ms: number): string {
62
70
  return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
63
71
  }
64
72
 
65
- // "Idle 47m · 412k context"
73
+ // "Idle 47m · 412k context", or "412k context" while its cache is warm.
66
74
  export function staleLabel(s: Staleness): string {
67
- return `Idle ${formatIdle(s.idleMs)} · ${formatTokens(s.tokens)} context`;
75
+ const size = `${formatTokens(s.tokens)} context`;
76
+ return s.stale ? `Idle ${formatIdle(s.idleMs)} · ${size}` : size;
68
77
  }
69
78
 
70
79
  // Whether the composer's send compacts first: the chip was showing and the
@@ -75,7 +84,7 @@ export function composerCompacts(
75
84
  opts: { state: ChatState; thresholdTokens: number; asIs: boolean }
76
85
  ): boolean {
77
86
  if (opts.asIs || text.trimStart().startsWith("/")) return false;
78
- return !!staleness(context, opts);
87
+ return !!compactionDue(context, opts);
79
88
  }
80
89
 
81
90
  // The chat's line for a compaction: "Compacted before sending · idle 47m ·
@@ -1,6 +1,7 @@
1
1
  import type { Session } from "../../db";
2
2
  import type { ChatStartOptions } from "../driver";
3
3
  import { refreshSettings } from "../refresh";
4
+ import { sessionProviderEnv } from "../../models/launch";
4
5
 
5
6
  export type RoleExtras = Pick<
6
7
  ChatStartOptions,
@@ -17,11 +18,22 @@ export type RoleExtras = Pick<
17
18
  // Start over on a new conversation once its context passes this many
18
19
  // tokens (lib/chat/refresh); absent or 0, never.
19
20
  refresh?: { atTokens: number; role: "orchestrator" | "task" };
21
+ // What the session's model provider needs in the agent's environment
22
+ // (lib/models), its key fetched just now. Held by this process only.
23
+ env?: Record<string, string>;
20
24
  };
21
25
 
22
26
  // What a session's role adds to its chat. An orchestrator's module loads
23
27
  // only for one; a task's chat gets its brief, as its terminal agent would.
24
28
  export async function roleExtras(session: Session): Promise<RoleExtras> {
29
+ const [extras, env] = await Promise.all([
30
+ ownExtras(session),
31
+ sessionProviderEnv(session),
32
+ ]);
33
+ return env ? { ...extras, env } : extras;
34
+ }
35
+
36
+ async function ownExtras(session: Session): Promise<RoleExtras> {
25
37
  if (session.role === "orchestrator")
26
38
  return (await import("../../orchestrator/worker")).orchestratorExtras(
27
39
  session