@saadnvd1/agent-os 0.3.468 → 0.3.471

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 (64) hide show
  1. package/README.md +67 -37
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/projects/clone/route.ts +4 -2
  4. package/app/api/projects/init/route.test.ts +46 -0
  5. package/app/api/projects/init/route.ts +24 -4
  6. package/app/api/usage/route.ts +4 -2
  7. package/components/Chat/ChatPanel.tsx +5 -5
  8. package/components/Chat/StaleChip.tsx +7 -6
  9. package/components/CommandPalette/CommandPalette.tsx +3 -1
  10. package/components/CommandPalette/useAppCommands.ts +10 -0
  11. package/components/Projects/AddProject/AddProjectDialog.tsx +25 -5
  12. package/components/Projects/AddProject/CloneForm.tsx +3 -1
  13. package/components/Projects/AddProject/FolderForm.tsx +98 -4
  14. package/components/Projects/AddProject/NameForm.tsx +3 -1
  15. package/components/Projects/AddProject/WorkspacePick.tsx +56 -0
  16. package/components/Projects/AddProject/useAddExistingProject.ts +58 -0
  17. package/components/SessionList/WorkspaceSwitcher.tsx +10 -0
  18. package/components/Usage/UsageDialog.tsx +31 -2
  19. package/components/Workspaces/FreshStartForm.tsx +13 -13
  20. package/data/projects/add.ts +23 -3
  21. package/data/usage/index.ts +2 -0
  22. package/lib/agents/launch.ts +2 -0
  23. package/lib/chat/driver.ts +8 -0
  24. package/lib/chat/drivers/claude-approvals.ts +40 -0
  25. package/lib/chat/drivers/claude.ts +7 -10
  26. package/lib/chat/refresh.ts +31 -13
  27. package/lib/chat/stale.ts +25 -16
  28. package/lib/chat/worker/extras.ts +1 -0
  29. package/lib/chat/worker/host.ts +61 -22
  30. package/lib/db/migrations.ts +42 -0
  31. package/lib/orchestrator/add-project.ts +35 -0
  32. package/lib/orchestrator/armed.ts +1 -2
  33. package/lib/orchestrator/brief.ts +6 -5
  34. package/lib/orchestrator/claude-cli.ts +5 -0
  35. package/lib/orchestrator/diff.ts +6 -2
  36. package/lib/orchestrator/external-gates.ts +8 -43
  37. package/lib/orchestrator/gates.ts +10 -10
  38. package/lib/orchestrator/pr-wake.ts +2 -2
  39. package/lib/orchestrator/rebase.ts +6 -45
  40. package/lib/orchestrator/review.ts +18 -5
  41. package/lib/orchestrator/scope.ts +9 -0
  42. package/lib/orchestrator/serve.ts +6 -0
  43. package/lib/orchestrator/signoff.ts +13 -8
  44. package/lib/orchestrator/tool-names.ts +13 -6
  45. package/lib/orchestrator/tool-schemas.ts +15 -0
  46. package/lib/orchestrator/tools.ts +2 -0
  47. package/lib/orchestrator/worker.ts +4 -1
  48. package/lib/orchestrator/write-guard.ts +295 -0
  49. package/lib/project-add/index.ts +133 -14
  50. package/lib/project-add/name.ts +1 -0
  51. package/lib/secret-paths.ts +9 -0
  52. package/lib/session-audit/render.ts +1 -1
  53. package/lib/stacks/land.ts +23 -16
  54. package/lib/tasks/brief.ts +3 -14
  55. package/lib/tasks/code-review.ts +69 -107
  56. package/lib/tasks/gh.ts +3 -2
  57. package/lib/tasks/index.ts +1 -1
  58. package/lib/tasks/state.ts +4 -4
  59. package/lib/usage/check-runs.ts +167 -0
  60. package/lib/usage/report.ts +25 -0
  61. package/lib/usage/turns.ts +28 -0
  62. package/package.json +1 -1
  63. package/stores/addProject.ts +12 -3
  64. package/scripts/check-code-review.ts +0 -21
package/README.md CHANGED
@@ -17,8 +17,8 @@ and check on all of it from your phone.
17
17
  Every workspace has one orchestrator, a chat pinned at the top of its list.
18
18
  It starts tasks, answers agents that are blocked, and reviews and merges
19
19
  pull requests through gates: CI green and settled, an independent review of
20
- the exact commit (big diffs are reviewed in parts), the PR's Code review
21
- section, nothing blocked, and the diff in scope. Anything that is yours to
20
+ the exact commit (big diffs are reviewed in parts), nothing blocked, and the
21
+ diff in scope. Anything that is yours to
22
22
  decide (publishing, money, something irreversible, credentials) waits as an
23
23
  ask with Approve, Decline and Reply. Turn on **Require my approval** and
24
24
  sensitive or large PRs wait for you too. [More](#orchestrator)
@@ -312,25 +312,34 @@ The ring in the tab bar (in the composer on a phone) is how full the
312
312
  conversation's context is, measured against the room before auto-compact:
313
313
  amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
314
314
 
315
- A message to a chat idle past its prompt cache (5 minutes or an hour, read
316
- from the agent's own usage) re-reads the whole conversation at the
317
- cache-write price. When the context is also above a threshold (150k tokens by
318
- default, Settings → Workspaces → Compact idle chats; off sets it to never), a
319
- chip above the composer says "Idle 47m · 412k context · Compact first?": Send
320
- compacts and then sends, **Send as-is** skips it for that one message.
321
- Messages nobody is typing (the orchestrator's `send`, AgentOS events and
322
- wakes, `aos send`, schedules) compact first on their own, only between turns,
323
- and the chat shows one quiet line with the context before and after. An
324
- orchestrator isn't compacted (a summary would lose the fence around what it
325
- read from tasks and CI): it starts a fresh conversation from its brief and
326
- the current state instead, as at its fresh-start threshold.
315
+ Chats and tasks compact at 150k tokens of context by default (Settings →
316
+ Workspaces → Compact; off sets it to never), not near the model's window:
317
+ AgentOS launches Claude Code with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to
318
+ that size (its floor is 100k), so the agent compacts itself mid-turn. Between
319
+ turns AgentOS catches what it didn't: a chat over the threshold is compacted
320
+ before the next message, and so is one idle past its prompt cache (5 minutes
321
+ or an hour, read from the agent's own usage), whose next message would
322
+ re-read the whole conversation at the cache-write price. A chip above the
323
+ composer says "Idle 47m · 412k context · Compact first?": Send compacts and
324
+ then sends, **Send as-is** skips it for that one message. Messages nobody is
325
+ typing (the orchestrator's `send`, AgentOS events and wakes, `aos send`,
326
+ schedules) compact first on their own, only between turns, and the chat
327
+ shows one quiet line with the context before and after. An orchestrator
328
+ isn't compacted (a summary would lose the fence around what it read from
329
+ tasks and CI): it starts a fresh conversation from its brief and the current
330
+ state at its fresh-start threshold, or earlier once idle past its cache.
327
331
  On a desktop browser the screen button attaches a screenshot of a screen or
328
332
  window you pick; on a phone, attach a screenshot as a photo.
329
333
 
330
334
  **Usage** (the sidebar's ⋯ menu, or the palette) shows the account's 5-hour
331
335
  and weekly windows, and what chats cost today, over 7 or 30 days, by day,
332
336
  workspace and session. Costs are the agent's own estimates, recorded as each
333
- turn ends.
337
+ turn ends. **Gate reviews** below it shows what the orchestrator's
338
+ independent reviewer and scope check cost, by PR: they run as `claude -p`
339
+ and leave no transcript, so each run's tokens, model and duration are read
340
+ from its JSON result into `check_runs`, and the review's verdict says them
341
+ too. Every compaction is kept in `chat_compactions` (who ran it, and the
342
+ context before and after), so cost per task can be compared by date.
334
343
 
335
344
  **Esc** stops a running turn, as it does in Claude Code's terminal, unless a
336
345
  menu, dialog or the command list is open (those close first). On a phone the
@@ -465,17 +474,15 @@ Starting a task returns at once and shows **Setting up** until the agent
465
474
  launches; a restart in the middle resumes it. A failed setup shows on the task
466
475
  and is in the agent's first prompt (redacted, as data).
467
476
 
468
- Before opening its PR, every task runs `/do-code-review` (the project's
469
- `.claude/skills/do-code-review`, or Claude Code's `/code-review` where a
470
- project has none), fixes the Blocking and High findings, and ends the PR body
471
- with a **Code review** section: the commit it reviewed, the agents that ran,
472
- what it fixed and what it deferred and why. The orchestrator's `sign_off` and
473
- a stack's **Land** refuse a PR without that section for its head commit, and
474
- this repository's CI fails one too. A head AgentOS rebased itself is
475
- covered by the review of the head it rebased: its gates go by what they
476
- recorded, and CI by the section's `Restacked:` line. The review agents are in
477
- [.claude/agents](.claude/agents) and the skill in
478
- [.claude/skills](.claude/skills/README.md).
477
+ Tasks don't review their own PRs. The orchestrator's independent review of
478
+ the PR's exact head commit is the one review: `sign_off` and a stack's
479
+ **Land** refuse a PR without a passing one, and a PR body with AI
480
+ attribution. A head AgentOS rebased itself is covered by the review of the
481
+ head it rebased while the PR's own patch is unchanged. A task may still run
482
+ `/do-code-review` (the project's `.claude/skills/do-code-review`, or Claude
483
+ Code's `/code-review`) on a risky change before its PR; it's no longer
484
+ required. The review agents are in [.claude/agents](.claude/agents) and the
485
+ skill in [.claude/skills](.claude/skills/README.md).
479
486
 
480
487
  Requires the GitHub CLI (`gh`) signed in, and a project with a GitHub remote.
481
488
 
@@ -872,7 +879,11 @@ it may do on its own, and the lines that always come back to you as asks.
872
879
  - **Tools:** `sessions` (every session's status, activity, task, PR, CI and
873
880
  stack position), `read` (the end of a terminal or chat) and `cards` (the
874
881
  linked boards' cards), plus acting tools: `send`, `start_task`,
875
- `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`, `note`,
882
+ `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`,
883
+ `add_project` (a git repository on the machine becomes a project of the
884
+ workspace, or the project already at that path moves in from no workspace;
885
+ folders that hold secrets and projects of another workspace are refused),
886
+ `note`,
876
887
  `rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm` and `ask_saad`. All are served in-process to its chat, refuse
877
888
  any target outside its workspace, and validate their arguments. The one
878
889
  exception is other orchestrators: `orchestrators` lists every workspace and
@@ -882,6 +893,29 @@ it may do on its own, and the lines that always come back to you as asks.
882
893
  approve, answer an ask or pass a gate. The route
883
894
  they call answers only the orchestrator's worker, by a per-orchestrator
884
895
  secret. Its shell runs only `aos` commands that read.
896
+ - **Files:** it reads anywhere and writes (Write, Edit, NotebookEdit) anywhere
897
+ except a project repository, a task worktree (`~/.agent-os/worktrees`),
898
+ AgentOS's own install and state (all of `~/.agent-os` but its own folder),
899
+ secret files (`.env`, keys, `~/.ssh`, `~/.aws`, `gh`'s token, tokenvault and
900
+ the like; the list the merge gates use for secrets handling,
901
+ `lib/secret-paths.ts`), and files that make something run: dotfiles and
902
+ dot-folders at the top of its home (shell, git, Claude and tool config, the
903
+ usage brake's `limits.json`), `~/Library`, `/Library/LaunchAgents` and
904
+ `LaunchDaemons`, git's `core.hooksPath` folders (yours and each project's),
905
+ the folders on `PATH`, and any `.git`, `.claude`, `.mcp.json`, `CLAUDE.md`
906
+ or `AGENTS.md`, since writing one would give it a shell or run code in
907
+ AgentOS's own git; and the temporary checkouts a review or rebase runs in.
908
+ Code changes to a project go through a task, so its PR still passes CI,
909
+ review and scope. It is a list of exceptions, not of allowed places: a
910
+ script a hook or scheduled job runs from an ordinary folder can still be
911
+ written, so an orchestrator misled by text it read could run code that way.
912
+ That's accepted: task agents can already write anywhere, so an allowlist
913
+ on the orchestrator alone would buy little.
914
+ Its chat worker checks every write in a PreToolUse hook before it runs, on
915
+ the path with symlinks and `..` resolved as the system will, and refuses
916
+ with the reason; the repositories are read from the database on each write,
917
+ so a project added since counts. A worker from before an update picks the
918
+ new permissions up at its handover to the new build.
885
919
  - **Brakes:** every start is refused, with the reason, while 4 sessions run in
886
920
  the workspace, after 6 starts in an hour (`orch_max_running` and
887
921
  `orch_max_starts_per_hour` on `workspaces`), or when the account's 5-hour
@@ -907,7 +941,7 @@ it may do on its own, and the lines that always come back to you as asks.
907
941
  it (2 minutes with no new check), its review passed, nothing is
908
942
  `BLOCKED:` or waiting, the diff stays in scope (no secrets, not only
909
943
  lockfiles, nothing outside the repo, within the card) and its stack parent
910
- has merged, and the PR body's Code review section names that commit. `land` judges each item again at its own head right before
944
+ has merged, and the PR body carries no AI attribution. `land` judges each item again at its own head right before
911
945
  merging it. A gate failing again on a different commit, or a repo with no
912
946
  CI, goes to you as an ask, and the orchestrator stops merging that task.
913
947
  A failure counts once per task, gate and head commit, however many times
@@ -926,17 +960,15 @@ it may do on its own, and the lines that always come back to you as asks.
926
960
  conflicts (read in the PR poll AgentOS already makes), `git merge-tree`
927
961
  checks it against the base. Clean, and with the task's agent idle and
928
962
  nothing uncommitted, AgentOS rebases it in the task's worktree and pushes
929
- only its branch with `--force-with-lease`; the Code review section gets a
930
- `Restacked: <reviewed> -> <new>` line so the task needn't review again,
931
- and the orchestrator's review carries over when the PR's own patch is
963
+ only its branch with `--force-with-lease`, and the orchestrator's review
964
+ carries over when the PR's own patch is
932
965
  unchanged (same `git patch-id`; a fresh review starts otherwise). Not
933
966
  clean: the task is asked once to rebase. Either way, one event. Main and
934
967
  other tasks' branches are never written.
935
968
  - **Tasks woken on CI failures and reviews:** a check failing on a task's
936
969
  PR head messages the task's own agent once per head, with the check, the
937
970
  failing step and about 60 lines of `gh run view --log-failed` around the
938
- error, telling it to fix the root cause, push and update its Reviewed:
939
- line. A review or comment from a collaborator (not the account gh runs
971
+ error, telling it to fix the root cause and push. A review or comment from a collaborator (not the account gh runs
940
972
  as, which is the task's own) wakes it once per review or comment; one
941
973
  GraphQL query per repository a minute fingerprints the open task PRs, and
942
974
  only a PR whose fingerprint moved has its reviews read. Comment-only wakes
@@ -949,8 +981,7 @@ it may do on its own, and the lines that always come back to you as asks.
949
981
  `owner/repo#N` or URL, as long as its repository is one of the workspace's
950
982
  projects; anything else is refused. The same gates apply, except that a
951
983
  draft waits and a "blocked", "do not merge" or "wip" label holds it (there's
952
- no session to read), and a body with no Code review section is covered by
953
- the orchestrator's own review of that head. `sessions` also lists tmux
984
+ no session to read). `sessions` also lists tmux
954
985
  sessions AgentOS didn't start that work in a workspace repository or a
955
986
  worktree of it, here or on another machine, with their branch's PR; `read`
956
987
  reads them, and nothing else acts on them.
@@ -1306,8 +1337,7 @@ until `npm install` runs in them. Typecheck, lint and tests are left to CI.
1306
1337
  In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
1307
1338
  which also holds the app's first-load JavaScript to a budget
1308
1339
  (`scripts/check-bundle.mjs`; `npx next experimental-analyze` shows what's in
1309
- it), and fails a pull request whose body has no Code review section for its head
1310
- commit (run `/do-code-review` first; see [Tasks](#tasks)).
1340
+ it).
1311
1341
 
1312
1342
  ### Session audits
1313
1343
 
@@ -2,17 +2,21 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
3
3
 
4
4
  // When a chat's conversation starts over (lib/chat/refresh): context
5
- // tokens for orchestrators and for tasks, and above which a stale chat is
6
- // compacted before a message (lib/chat/stale); 0 for never.
5
+ // tokens for orchestrators and for tasks, and at which chats and tasks
6
+ // compact (lib/chat/stale); 0 for never.
7
7
  export async function GET() {
8
8
  return NextResponse.json(refreshSettings());
9
9
  }
10
10
 
11
11
  export async function PUT(request: NextRequest) {
12
12
  try {
13
- const { orchestratorTokens, taskTokens, staleCompactTokens } =
14
- await request.json();
15
- setRefreshSettings({ orchestratorTokens, taskTokens, staleCompactTokens });
13
+ const body = await request.json();
14
+ setRefreshSettings({
15
+ orchestratorTokens: body.orchestratorTokens,
16
+ taskTokens: body.taskTokens,
17
+ // Its name before it also set the agents' own auto-compact.
18
+ compactTokens: body.compactTokens ?? body.staleCompactTokens,
19
+ });
16
20
  return NextResponse.json(refreshSettings());
17
21
  } catch (error) {
18
22
  const message = error instanceof Error ? error.message : String(error);
@@ -2,14 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { startClone } from "@/lib/project-add";
3
3
 
4
4
  // POST /api/projects/clone - starts cloning { url } into { parent } on a
5
- // machine; GET /api/projects/clone/:job follows it.
5
+ // machine, in { workspaceId } when given; GET /api/projects/clone/:job
6
+ // follows it.
6
7
  export async function POST(request: NextRequest) {
7
8
  try {
8
9
  const body = await request.json();
9
10
  const job = startClone(
10
11
  typeof body.hostId === "string" ? body.hostId : "local",
11
12
  String(body.parent ?? "~"),
12
- String(body.url ?? "").trim()
13
+ String(body.url ?? "").trim(),
14
+ typeof body.workspaceId === "string" ? body.workspaceId : null
13
15
  );
14
16
  return NextResponse.json({ job }, { status: 202 });
15
17
  } catch (error) {
@@ -0,0 +1,46 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { describe, expect, it } from "vitest";
5
+ import type { NextRequest } from "next/server";
6
+ import { getProject } from "@/lib/projects";
7
+ import { createWorkspace } from "@/lib/workspaces";
8
+ import { POST } from "./route";
9
+
10
+ const post = (body: object) =>
11
+ POST({ json: async () => body } as unknown as NextRequest);
12
+
13
+ describe("POST /api/projects/init", () => {
14
+ it("answers a folder that's already a project with 409 and it, and moves it on moveExisting", async () => {
15
+ const a = createWorkspace("Route A");
16
+ const b = createWorkspace("Route B");
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-init-route-"));
18
+
19
+ const made = await post({ path: dir, workspaceId: a.id });
20
+ expect(made.status).toBe(201);
21
+ const { project } = await made.json();
22
+ expect(getProject(project.id)?.workspace_id).toBe(a.id);
23
+
24
+ const again = await post({ path: dir, workspaceId: b.id });
25
+ expect(again.status).toBe(409);
26
+ expect((await again.json()).existing).toEqual({
27
+ id: project.id,
28
+ name: project.name,
29
+ workspaceId: a.id,
30
+ });
31
+
32
+ // Only a real true moves it.
33
+ expect(
34
+ (await post({ path: dir, workspaceId: b.id, moveExisting: "true" }))
35
+ .status
36
+ ).toBe(409);
37
+ const moved = await post({
38
+ path: dir,
39
+ workspaceId: b.id,
40
+ moveExisting: true,
41
+ });
42
+ expect(moved.status).toBe(201);
43
+ expect((await moved.json()).project.id).toBe(project.id);
44
+ expect(getProject(project.id)?.workspace_id).toBe(b.id);
45
+ });
46
+ });
@@ -1,22 +1,42 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { addFolder, initProject } from "@/lib/project-add";
2
+ import { addFolder, initProject, ProjectExistsError } from "@/lib/project-add";
3
3
 
4
4
  // POST /api/projects/init - a project from a folder that exists ({ path }),
5
- // or a new one from a name ({ parent, name }: folder, git init, first commit).
5
+ // or a new one from a name ({ parent, name }: folder, git init, first commit),
6
+ // in { workspaceId } when given. A folder that's already a project answers
7
+ // 409 with it, unless { moveExisting } moves it into the workspace.
6
8
  export async function POST(request: NextRequest) {
7
9
  try {
8
10
  const body = await request.json();
9
11
  const hostId = typeof body.hostId === "string" ? body.hostId : "local";
12
+ const workspaceId =
13
+ typeof body.workspaceId === "string" ? body.workspaceId : null;
10
14
  const project =
11
15
  typeof body.path === "string"
12
- ? await addFolder(hostId, body.path)
16
+ ? (
17
+ await addFolder(hostId, body.path, {
18
+ workspaceId,
19
+ moveExisting: body.moveExisting === true,
20
+ })
21
+ ).project
13
22
  : await initProject(
14
23
  hostId,
15
24
  String(body.parent ?? "~"),
16
- String(body.name ?? "")
25
+ String(body.name ?? ""),
26
+ workspaceId
17
27
  );
18
28
  return NextResponse.json({ project }, { status: 201 });
19
29
  } catch (error) {
30
+ if (error instanceof ProjectExistsError) {
31
+ const { id, name, workspace_id } = error.project;
32
+ return NextResponse.json(
33
+ {
34
+ error: error.message,
35
+ existing: { id, name, workspaceId: workspace_id },
36
+ },
37
+ { status: 409 }
38
+ );
39
+ }
20
40
  const message = error instanceof Error ? error.message : String(error);
21
41
  return NextResponse.json(
22
42
  { error: message.trim().split("\n").pop() },
@@ -1,15 +1,17 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { USAGE_RANGES, type UsageRange } from "@/lib/usage/aggregate";
3
- import { usageReport } from "@/lib/usage/report";
3
+ import { checkRunReport, usageReport } from "@/lib/usage/report";
4
4
  import { usageWindows } from "@/lib/usage/windows";
5
5
 
6
6
  // GET /api/usage?range=today|7d|30d - chat spend by day, session and
7
- // workspace, and the account's rolling windows
7
+ // workspace, what the orchestrator's reviews cost by PR, and the account's
8
+ // rolling windows
8
9
  export async function GET(request: NextRequest) {
9
10
  const asked = request.nextUrl.searchParams.get("range") as UsageRange | null;
10
11
  const range = asked && USAGE_RANGES.includes(asked) ? asked : "7d";
11
12
  return NextResponse.json({
12
13
  report: usageReport(range),
14
+ reviews: checkRunReport(range),
13
15
  windows: await usageWindows(),
14
16
  });
15
17
  }
@@ -285,9 +285,9 @@ export function ChatPanel({
285
285
  toEnd();
286
286
  send(text);
287
287
  };
288
- // A stale chat compacts before the next message unless the reader picks
289
- // "Send as-is", for that send only.
290
- const staleCompactTokens = useFreshStartQuery().data?.staleCompactTokens ?? 0;
288
+ // A chat past the compact threshold compacts before the next message
289
+ // unless the reader picks "Send as-is", for that send only.
290
+ const compactTokens = useFreshStartQuery().data?.compactTokens ?? 0;
291
291
  const [asIsFor, setAsIsFor] = useState<string | null>(null);
292
292
  const asIs = asIsFor === sessionId;
293
293
  const setAsIs = (on: boolean) => setAsIsFor(on ? sessionId : null);
@@ -529,7 +529,7 @@ export function ChatPanel({
529
529
  <StaleChip
530
530
  context={context}
531
531
  state={state}
532
- thresholdTokens={staleCompactTokens}
532
+ thresholdTokens={compactTokens}
533
533
  asIs={asIs}
534
534
  onAsIs={setAsIs}
535
535
  />
@@ -547,7 +547,7 @@ export function ChatPanel({
547
547
  toEnd();
548
548
  const compact = composerCompacts(text, context, {
549
549
  state,
550
- thresholdTokens: staleCompactTokens,
550
+ thresholdTokens: compactTokens,
551
551
  asIs,
552
552
  });
553
553
  setAsIs(false);
@@ -2,14 +2,15 @@
2
2
 
3
3
  import { Minimize2 } from "lucide-react";
4
4
  import type { ChatContext, ChatState } from "@/lib/chat/events";
5
- import { staleLabel, staleness } from "@/lib/chat/stale";
5
+ import { compactionDue, staleLabel } from "@/lib/chat/stale";
6
6
  import { cn } from "@/lib/utils";
7
7
  import { useNow } from "./useNow";
8
8
 
9
- // Above the composer while the chat is stale (lib/chat/stale): its prompt
10
- // cache has expired and its context is long, so the next message would
11
- // re-read all of it at full price. Send compacts first unless the reader
12
- // picks "Send as-is", for that one send only.
9
+ // Above the composer while the chat is due a compaction (lib/chat/stale):
10
+ // its context is past the compact threshold, and maybe its prompt cache has
11
+ // expired too, so the next message would re-read all of it at full price.
12
+ // Send compacts first unless the reader picks "Send as-is", for that one
13
+ // send only.
13
14
  export function StaleChip({
14
15
  context,
15
16
  state,
@@ -25,7 +26,7 @@ export function StaleChip({
25
26
  }) {
26
27
  const watching = !!context?.cacheTtlMs && thresholdTokens > 0;
27
28
  const now = useNow(watching);
28
- const stale = staleness(context, { state, thresholdTokens, now });
29
+ const stale = compactionDue(context, { state, thresholdTokens, now });
29
30
  if (!stale) return null;
30
31
  return (
31
32
  <div
@@ -51,6 +51,8 @@ function PaletteBody() {
51
51
 
52
52
  const results = useMemo(() => {
53
53
  if (query.trim()) return matchCommands(all, query);
54
+ // A picker shows every choice; it's the whole list.
55
+ if (picker) return all;
54
56
  // With no query, long groups are cut short so every group shows.
55
57
  const seen = new Map<string, number>();
56
58
  return all.filter((c) => {
@@ -58,7 +60,7 @@ function PaletteBody() {
58
60
  seen.set(c.group, n);
59
61
  return n <= GROUP_PREVIEW;
60
62
  });
61
- }, [all, query]);
63
+ }, [all, query, picker]);
62
64
  const highlighted = Math.min(active, Math.max(results.length - 1, 0));
63
65
 
64
66
  useEffect(() => {
@@ -29,6 +29,7 @@ import { sidebarUi, sidebarUiActions } from "@/stores/sidebarUi";
29
29
  import { tasksUiActions } from "@/stores/tasksUi";
30
30
  import { newDraft } from "@/stores/drafts";
31
31
  import { useAddProject } from "@/components/Projects/AddProject/useAddProject";
32
+ import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
32
33
  import { usageUiActions } from "@/stores/usageUi";
33
34
  import { settingsCommands } from "@/components/Settings/commands";
34
35
  import { DRAFT_KEYS } from "@/lib/drafts";
@@ -66,6 +67,7 @@ export function useAppCommands({
66
67
  const current = workspaces.find((w) => w.id === workspaceId) ?? null;
67
68
  const dark = resolvedTheme?.startsWith("dark") ?? false;
68
69
  const addProject = useAddProject();
70
+ const addExisting = useAddExistingProject();
69
71
 
70
72
  const app: PaletteCommand[] = [
71
73
  {
@@ -113,6 +115,14 @@ export function useAppCommands({
113
115
  },
114
116
  ...(current
115
117
  ? [
118
+ {
119
+ id: "app.add-project-to-workspace",
120
+ title: `Add project to ${current.name}`,
121
+ group: "Actions",
122
+ keywords: ["existing", "move", "workspace", "project"],
123
+ icon: FolderPlus,
124
+ run: () => addExisting(current),
125
+ },
116
126
  {
117
127
  id: "app.orchestrator",
118
128
  title: `Open ${current.name}'s orchestrator`,
@@ -14,6 +14,7 @@ import { useHostNames } from "@/data/hosts";
14
14
  import { FolderForm } from "./FolderForm";
15
15
  import { CloneForm } from "./CloneForm";
16
16
  import { NameForm } from "./NameForm";
17
+ import { WorkspacePick } from "./WorkspacePick";
17
18
 
18
19
  const TITLES = {
19
20
  folder: "Open a folder",
@@ -21,8 +22,9 @@ const TITLES = {
21
22
  name: "New project",
22
23
  };
23
24
 
24
- // The last step of "Add project" (⌘K): a folder, a clone or a name. Once
25
- // it's made, a draft opens in it.
25
+ // The last step of "Add project" (⌘K): a folder, a clone or a name, into
26
+ // the sidebar's workspace unless changed here. Once it's made, a draft
27
+ // opens in it.
26
28
  export function AddProjectDialog() {
27
29
  const { open } = useSnapshot(addProjectUi);
28
30
  const hostNames = useHostNames();
@@ -43,14 +45,32 @@ export function AddProjectDialog() {
43
45
  : "this machine"}
44
46
  </DialogDescription>
45
47
  </DialogHeader>
48
+ {open && (
49
+ <WorkspacePick
50
+ workspaceId={open.workspaceId}
51
+ onChange={addProjectActions.setWorkspace}
52
+ />
53
+ )}
46
54
  {open?.kind === "folder" && (
47
- <FolderForm hostId={open.hostId} onDone={done} />
55
+ <FolderForm
56
+ hostId={open.hostId}
57
+ workspaceId={open.workspaceId}
58
+ onDone={done}
59
+ />
48
60
  )}
49
61
  {open?.kind === "clone" && (
50
- <CloneForm hostId={open.hostId} onDone={done} />
62
+ <CloneForm
63
+ hostId={open.hostId}
64
+ workspaceId={open.workspaceId}
65
+ onDone={done}
66
+ />
51
67
  )}
52
68
  {open?.kind === "name" && (
53
- <NameForm hostId={open.hostId} onDone={done} />
69
+ <NameForm
70
+ hostId={open.hostId}
71
+ workspaceId={open.workspaceId}
72
+ onDone={done}
73
+ />
54
74
  )}
55
75
  </DialogContent>
56
76
  </Dialog>
@@ -9,9 +9,11 @@ import { ParentPicker } from "./ParentPicker";
9
9
 
10
10
  export function CloneForm({
11
11
  hostId,
12
+ workspaceId,
12
13
  onDone,
13
14
  }: {
14
15
  hostId: string;
16
+ workspaceId: string | null;
15
17
  onDone: (projectId: string) => void;
16
18
  }) {
17
19
  const [url, setUrl] = useState("");
@@ -29,7 +31,7 @@ export function CloneForm({
29
31
  className="space-y-3"
30
32
  onSubmit={(e) => {
31
33
  e.preventDefault();
32
- start.mutate({ hostId, parent, url: url.trim() });
34
+ start.mutate({ hostId, parent, url: url.trim(), workspaceId });
33
35
  }}
34
36
  >
35
37
  <Input