@saadnvd1/agent-os 0.3.615 → 0.3.620

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.
@@ -21,6 +21,7 @@ import {
21
21
  } from "@/lib/chat/group";
22
22
  import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
23
23
  import { isFreshCommand, withFresh } from "@/lib/chat/commands";
24
+ import { compactsFirst } from "@/lib/chat/pending";
24
25
  import { useFreshStart } from "@/data/sessions";
25
26
  import {
26
27
  escapeAction,
@@ -692,6 +693,7 @@ export function ChatPanel({
692
693
  }}
693
694
  pending={{
694
695
  status: p.status,
696
+ compacting: compactsFirst(p, { state, compacting }),
695
697
  error: p.error,
696
698
  onRetry: () => retrySend(p.id),
697
699
  onEdit: () => {
@@ -50,12 +50,16 @@ describe("AssistantMessage quote action", () => {
50
50
 
51
51
  describe("a sent message not in the chat yet", () => {
52
52
  const user = { id: "user-c-1", kind: "user", text: "An article" } as never;
53
- const render = (status: "sending" | "accepted" | "failed") =>
53
+ const render = (
54
+ status: "sending" | "accepted" | "failed",
55
+ compacting?: boolean
56
+ ) =>
54
57
  renderToStaticMarkup(
55
58
  createElement(UserMessage, {
56
59
  item: user,
57
60
  pending: {
58
61
  status,
62
+ compacting,
59
63
  error: "connection lost",
60
64
  onRetry: vi.fn(),
61
65
  onEdit: vi.fn(),
@@ -68,6 +72,9 @@ describe("a sent message not in the chat yet", () => {
68
72
  expect(render("sending")).toContain("opacity-60");
69
73
  expect(render("sending")).toContain("Sending");
70
74
  expect(render("accepted")).toContain("Starting the agent");
75
+ expect(render("accepted", true)).toContain("Compacting first");
76
+ expect(render("accepted", true)).not.toContain("Starting the agent");
77
+ expect(render("sending", true)).toContain("Sending");
71
78
  });
72
79
 
73
80
  it("says a failed one wasn't sent, with Retry and Edit", () => {
@@ -98,6 +98,8 @@ const UserText = memo(function UserText({ text }: { text: string }) {
98
98
  // A sent message not in the chat yet (lib/chat/pending).
99
99
  export interface PendingStatus {
100
100
  status: PendingSend["status"];
101
+ // The chat is compacting for this send (its real state, not a guess).
102
+ compacting?: boolean;
101
103
  error?: string;
102
104
  onRetry: () => void;
103
105
  onEdit: () => void;
@@ -141,7 +143,11 @@ function SendStatus({ pending }: { pending: PendingStatus }) {
141
143
  className="text-muted-foreground status-late flex items-center gap-1.5 font-mono text-[11px]"
142
144
  >
143
145
  <span className="status-live bg-muted-foreground h-1.5 w-1.5 rounded-full" />
144
- {pending.status === "accepted" ? "Starting the agent" : "Sending"}
146
+ {pending.status !== "accepted"
147
+ ? "Sending"
148
+ : pending.compacting
149
+ ? "Compacting first"
150
+ : "Starting the agent"}
145
151
  </div>
146
152
  );
147
153
  }
@@ -173,6 +173,7 @@ export function DesktopTabBar({
173
173
  {session?.working_directory && (
174
174
  <PaneViewToggle
175
175
  sessionId={session.id}
176
+ isChat={session.view === "chat"}
176
177
  viewMode={viewMode}
177
178
  isLocalSession={isLocalSession}
178
179
  isConductor={isConductor}
@@ -18,6 +18,8 @@ import type { ViewMode } from "@/contexts/PaneContext";
18
18
 
19
19
  interface PaneViewToggleProps {
20
20
  sessionId?: string;
21
+ // The session runs as a chat, so its main view is the chat, not a terminal.
22
+ isChat?: boolean;
21
23
  viewMode: ViewMode;
22
24
  isLocalSession: boolean;
23
25
  isConductor: boolean;
@@ -75,6 +77,7 @@ function ToggleButton({
75
77
  // session only gets the terminal (and workers).
76
78
  export function PaneViewToggle({
77
79
  sessionId,
80
+ isChat,
78
81
  viewMode,
79
82
  isLocalSession,
80
83
  isConductor,
@@ -89,7 +92,7 @@ export function PaneViewToggle({
89
92
  return (
90
93
  <div className="bg-accent/50 flex shrink-0 items-center rounded-md p-0.5">
91
94
  <ToggleButton
92
- label="Terminal"
95
+ label={isChat ? "Chat" : "Terminal"}
93
96
  active={viewMode === "terminal"}
94
97
  onClick={() => onViewModeChange("terminal")}
95
98
  >
@@ -45,29 +45,35 @@ export function SidebarToggle({
45
45
  );
46
46
  }
47
47
 
48
- export function DesktopAppActions() {
48
+ // A pinned sidebar has its own New button, so this one shows only while the
49
+ // sidebar is hidden.
50
+ export function DesktopAppActions({ showNew }: { showNew: boolean }) {
49
51
  return (
50
52
  <div
51
53
  className="flex shrink-0 items-center gap-0.5"
52
54
  onClick={(e) => e.stopPropagation()}
53
55
  >
54
56
  <InboxButton />
55
- <Tooltip>
56
- <TooltipTrigger asChild>
57
- <Button
58
- size="icon-sm"
59
- aria-label="New session"
60
- className="ml-1 h-7 w-7"
61
- onClick={() => newDraft({ kind: "current" })}
62
- >
63
- <Plus className="h-4 w-4" />
64
- </Button>
65
- </TooltipTrigger>
66
- <TooltipContent>
67
- <p>New session</p>
68
- <p className="text-muted-foreground text-xs">{DRAFT_KEYS.current}</p>
69
- </TooltipContent>
70
- </Tooltip>
57
+ {showNew && (
58
+ <Tooltip>
59
+ <TooltipTrigger asChild>
60
+ <Button
61
+ size="icon-sm"
62
+ aria-label="New session"
63
+ className="ml-1 h-7 w-7"
64
+ onClick={() => newDraft({ kind: "current" })}
65
+ >
66
+ <Plus className="h-4 w-4" />
67
+ </Button>
68
+ </TooltipTrigger>
69
+ <TooltipContent>
70
+ <p>New session</p>
71
+ <p className="text-muted-foreground text-xs">
72
+ {DRAFT_KEYS.current}
73
+ </p>
74
+ </TooltipContent>
75
+ </Tooltip>
76
+ )}
71
77
  </div>
72
78
  );
73
79
  }
@@ -59,7 +59,7 @@ export function DesktopView({
59
59
  leading={
60
60
  <SidebarToggle isPinned={isPinned} togglePin={togglePin} />
61
61
  }
62
- trailing={<DesktopAppActions />}
62
+ trailing={<DesktopAppActions showNew={!isPinned} />}
63
63
  >
64
64
  <PaneLayout renderPane={renderPane} />
65
65
  </PaneBarSlotsProvider>
@@ -1,4 +1,4 @@
1
- import type { ChatImage } from "./events";
1
+ import type { ChatImage, ChatState } from "./events";
2
2
 
3
3
  // A message the composer sent that isn't in the chat or its queue yet. It
4
4
  // shows as a bubble at once, and stays (as failed, with Retry) if it never
@@ -113,6 +113,18 @@ export function pendingReducer(
113
113
  }
114
114
  }
115
115
 
116
+ // A compact-first send reads "Compacting first" from the server accepting it
117
+ // until compaction is over. The chat is idle in that gap (compaction starts
118
+ // it running), so idle means it hasn't begun; running with no compaction
119
+ // means it finished, or the server decided not to compact.
120
+ export const compactsFirst = (
121
+ p: Pick<PendingSend, "status" | "compact">,
122
+ chat: { state: ChatState; compacting: unknown }
123
+ ) =>
124
+ !!p.compact &&
125
+ p.status === "accepted" &&
126
+ (!!chat.compacting || chat.state === "idle");
127
+
116
128
  // What a new socket sends again: anything not failed and not over its sends.
117
129
  // Safe to repeat: the server drops an id already in the chat or its queue.
118
130
  export const toResend = (state: PendingSend[]) =>
@@ -8,11 +8,14 @@ import { experimentOn } from "../experiments";
8
8
  import { getOrchestrator } from "../orchestrator/home";
9
9
  import { quickPR, startQuickChange } from "../orchestrator/quick";
10
10
  import { addRule } from "../orchestrator/rules";
11
- import { isRemoteHost } from "../hosts";
11
+ import { hostExec, isRemoteHost } from "../hosts";
12
+ import { shellQuote } from "../hosts/ssh";
12
13
  import { getProject } from "../projects";
13
14
  import { createRecipe, getRecipe, updateRecipe } from "../recipes/store";
14
15
  import { recipePrefill } from "../recipes/draft";
15
- import { createTask } from "../tasks";
16
+ import { createTask, dropTask } from "../tasks";
17
+ import { gitOk } from "../git-exec";
18
+ import { expandHome } from "../tasks/session";
16
19
  import { claimCard, getCard, releaseCard, settleCard } from "./store";
17
20
  import type { Card, LessonPayload, RecipePayload } from "./types";
18
21
 
@@ -46,6 +49,22 @@ export function withLesson(text: string, line: string): string {
46
49
  const clean = (s: string | undefined, max: number) =>
47
50
  (s ?? "").replace(/\s+/g, " ").trim().slice(0, max);
48
51
 
52
+ // Whether the remote checkout ignores CLAUDE.md; null when it can't be told.
53
+ async function remoteIgnores(hostId: string, dir: string) {
54
+ const rest = dir.replace(/^~/, "");
55
+ const cd = rest === dir ? shellQuote(dir) : `"$HOME"${shellQuote(rest)}`;
56
+ try {
57
+ const { stdout } = await hostExec(
58
+ hostId,
59
+ `cd ${cd} && git check-ignore -q CLAUDE.md; echo $?`
60
+ );
61
+ const code = stdout.trim().split("\n").pop();
62
+ return code === "0" ? true : code === "1" ? false : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
49
68
  async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
50
69
  const project = p.projectId ? getProject(p.projectId) : null;
51
70
  if (!project) throw new CardRefused("That project is gone");
@@ -55,6 +74,28 @@ async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
55
74
  .prepare(`SELECT name FROM sessions WHERE id = ?`)
56
75
  .get(card.session_id) as { name: string } | undefined
57
76
  )?.name;
77
+ // Ignored CLAUDE.md is local dev notes: a worktree never has it, so a quick
78
+ // change or task would have nothing to commit and no PR could open.
79
+ if (isRemoteHost(project.host_id)) {
80
+ if (
81
+ (await remoteIgnores(project.host_id, project.working_directory)) !==
82
+ false
83
+ )
84
+ throw new CardRefused(
85
+ `${project.name} runs on another machine and its CLAUDE.md is gitignored or can't be checked; add the lesson there by hand`
86
+ );
87
+ } else if (
88
+ await gitOk(expandHome(project.working_directory), [
89
+ "check-ignore",
90
+ "-q",
91
+ "CLAUDE.md",
92
+ ])
93
+ ) {
94
+ const file = path.join(expandHome(project.working_directory), "CLAUDE.md");
95
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
96
+ fs.writeFileSync(file, withLesson(before, line));
97
+ return "CLAUDE.md";
98
+ }
58
99
  if (
59
100
  workspaceId &&
60
101
  getOrchestrator(workspaceId) &&
@@ -68,12 +109,17 @@ async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
68
109
  const file = path.join(quick.worktreePath, "CLAUDE.md");
69
110
  const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
70
111
  fs.writeFileSync(file, withLesson(before, line));
71
- await quickPR(
72
- workspaceId,
73
- quick.id,
74
- "docs: add a lesson to CLAUDE.md",
75
- `${line}${source ? `\n\nFrom the session "${source}".` : ""}`
76
- );
112
+ try {
113
+ await quickPR(
114
+ workspaceId,
115
+ quick.id,
116
+ "docs: add a lesson to CLAUDE.md",
117
+ `${line}${source ? `\n\nFrom the session "${source}".` : ""}`
118
+ );
119
+ } catch (e) {
120
+ await dropTask(quick.id).catch(() => {});
121
+ throw e;
122
+ }
77
123
  return quick.id;
78
124
  }
79
125
  const task = await createTask({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.615",
3
+ "version": "0.3.620",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"