@saadnvd1/agent-os 0.3.616 → 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,7 +693,7 @@ export function ChatPanel({
692
693
  }}
693
694
  pending={{
694
695
  status: p.status,
695
- compacting: !!p.compact && !!compacting,
696
+ compacting: compactsFirst(p, { state, compacting }),
696
697
  error: p.error,
697
698
  onRetry: () => retrySend(p.id),
698
699
  onEdit: () => {
@@ -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.616",
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"