Skip to content

Commit aa1f01d

Browse files
authored
Merge pull request #66 from httpsphl/fix/owner-claude-md
fix(workspace): the owner's CLAUDE.md and auto memory stay out of the bots
2 parents 16ff507 + 1eb401d commit aa1f01d

42 files changed

Lines changed: 1738 additions & 97 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon
6666

6767
- Cada bot é `claude -p` com `stream-json` no stdin e no stdout (spec 7.4, 8, 9.2). O processo fica calado até a primeira mensagem; fechar o stdin o encerra. O formato de entrada não é documentado: qualquer mudança passa por teste real e vai para a seção 19.
6868
- Bots rodam com `--setting-sources project,local` e `--strict-mcp-config`: nada de hooks, skills, modo de permissão ou MCP pessoais do dono. Aprovações passam pela tool `permission_prompt` do nosso MCP.
69+
- Essa flag não tira os `CLAUDE.md` das pastas acima do workspace, e `%USERPROFILE%\.claude\CLAUDE.md` é um deles: quem os tira é `claudeMdExcludes` no `settings.json` gerado (spec 7.5). Os padrões usam `C:/Users/...` com as maiúsculas do cwd; a forma `//c/...` das permission rules não casa ali.
6970
- Servidor MCP por HTTP: o Claude Code aborta a request em 60 s e a chamada em 5 min sem resposta, a menos que o `mcp.json` tenha `timeout` no servidor. A aprovação depende disso.
7071
- Só o `claude.exe` nativo roda. O `claude.cmd` do npm é recusado: passar por `cmd.exe` estraga o quoting dos argumentos.
7172
- O bot recebe o ambiente padrão do usuário (`CreateEnvironmentBlock`), nunca o do daemon: em dev o daemon herda variáveis da sessão do Claude Code (`CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL`...).

‎app/src/dev/preview.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { PROTOCOL_VERSION } from "../lib/protocol.gen";
1313
import { seedBrowser } from "./seedBrowser";
1414
import { seedChats } from "./seedChats";
1515
import { seedChief } from "./seedChief";
16+
import { seedContext } from "./seedContext";
1617
import { seedHands } from "./seedHands";
1718
import { seedRoutines } from "./seedRoutines";
1819
import { seedScreens } from "./seedScreens";
@@ -113,6 +114,13 @@ function seed(fake: FakeBotloft): void {
113114
analyst: analyst.id,
114115
planner: planner.id,
115116
});
117+
seedContext(fake, {
118+
scout: scout.id,
119+
writer: writer.id,
120+
reviewer: reviewer.id,
121+
analyst: analyst.id,
122+
planner: planner.id,
123+
});
116124
seedRoutines(fake, { scout: scout.id, analyst: analyst.id });
117125
seedBrowser(fake, scout.id);
118126
seedScreens(fake, research.id);

‎app/src/dev/seedChats.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ The report is rebuilt on every visit, and Monday is when everyone opens it.
3333
3434
**Not changing:** the report's layout or its sources.`;
3535

36+
/** The kind of command an owner cannot read at a glance. */
37+
const CHECK_LINKS = `cd "C:/Work/Research" && python - <<'EOF'
38+
import re, urllib.request
39+
40+
text = open("week-39-report.md", encoding="utf-8").read()
41+
for url in sorted(set(re.findall(r"https?://[^\\s)]+", text))):
42+
try:
43+
urllib.request.urlopen(url, timeout=10)
44+
except Exception as error:
45+
print("broken:", url, error)
46+
EOF`;
47+
3648
interface Crew {
3749
scout: BotId;
3850
writer: BotId;
@@ -126,12 +138,18 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
126138
talk.read(check.delivery.id);
127139
at(39);
128140
chat.reply(crew.reviewer, "I'll run the link checker first, then compare each figure.");
129-
const command = JSON.stringify({
130-
command: "npm run check-links",
131-
description: "Check the links",
141+
// A command with what the bot says it is for, and one without (spec 10.1).
142+
const why = "Opens every link in the report draft and lists the ones that no longer work";
143+
const links = JSON.stringify({ command: CHECK_LINKS, description: why });
144+
const flat = CHECK_LINKS.split(/\s+/).join(" ").slice(0, 160);
145+
chat.tool(crew.reviewer, "Bash", { summary: flat, explanation: why, input: links });
146+
chat.ask(crew.reviewer, "Bash", flat, links, why);
147+
const figures = JSON.stringify({ command: "npm run compare-figures -- --section 2" });
148+
chat.tool(crew.reviewer, "Bash", {
149+
summary: "npm run compare-figures -- --section 2",
150+
input: figures,
132151
});
133-
chat.tool(crew.reviewer, "Bash", { summary: "Check the links", input: command });
134-
chat.ask(crew.reviewer, "Bash", "npm run check-links", command);
152+
chat.ask(crew.reviewer, "Bash", "npm run compare-figures -- --section 2", figures);
135153
at(12);
136154
const late = talk.say({
137155
from: crew.scout,

‎app/src/dev/seedContext.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// The preview's effort and conversation space (spec 7.4, 8.6): what Claude
2+
// Code would have reported for each bot, and a compaction that ends a
3+
// moment after it is asked for.
4+
5+
import type { FakeBotloft } from "../lib/fake";
6+
import type { BotId, ContextUsage } from "../lib/protocol.gen";
7+
8+
const COMPACTION_MS = 2500;
9+
10+
const holds = (usedTokens: number, windowTokens: number): ContextUsage => ({
11+
usedTokens,
12+
windowTokens,
13+
autoCompactTokens: windowTokens - 33_000,
14+
compacting: false,
15+
updatedAt: Date.now(),
16+
});
17+
18+
export function seedContext(
19+
fake: FakeBotloft,
20+
bots: { scout: BotId; writer: BotId; reviewer: BotId; analyst: BotId; planner: BotId },
21+
): void {
22+
for (const id of [bots.scout, bots.writer, bots.reviewer, bots.planner]) {
23+
fake.bot(id).effortDefault = "medium";
24+
}
25+
// Haiku takes no effort level.
26+
fake.bot(bots.analyst).effortDefault = "none";
27+
fake.bot(bots.reviewer).effort = "high";
28+
fake.setContext(bots.scout, holds(556_000, 1_000_000));
29+
fake.setContext(bots.writer, holds(912_400, 1_000_000));
30+
fake.setContext(bots.reviewer, holds(61_800, 1_000_000));
31+
fake.setContext(bots.analyst, holds(24_300, 200_000));
32+
33+
fake.subscribe((event) => {
34+
if (event.name !== "bot.context" || !event.params.context?.compacting) {
35+
return;
36+
}
37+
const { botId, context } = event.params;
38+
setTimeout(() => {
39+
fake.chat.add(botId, {
40+
kind: "notice",
41+
level: "info",
42+
code: "compacted",
43+
text: "The conversation was compacted: earlier messages are now a summary.",
44+
});
45+
fake.setContext(botId, holds(24_000, context.windowTokens));
46+
}, COMPACTION_MS);
47+
});
48+
}

‎app/src/effort.css‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/* The effort slider below the chat (spec 15.1): five stops from faster to
2+
smarter on a thin track, with a round thumb. */
3+
4+
.effort-slider {
5+
appearance: none;
6+
display: block;
7+
width: 100%;
8+
height: 1.25rem;
9+
margin: 0;
10+
background: transparent;
11+
cursor: pointer;
12+
}
13+
14+
.effort-slider::-webkit-slider-runnable-track {
15+
height: 4px;
16+
border-radius: 999px;
17+
background: var(--line-strong);
18+
}
19+
20+
.effort-slider::-webkit-slider-thumb {
21+
appearance: none;
22+
width: 16px;
23+
height: 16px;
24+
margin-top: -6px;
25+
border: 2px solid var(--panel);
26+
border-radius: 999px;
27+
background: var(--ink);
28+
box-shadow: 0 1px 3px var(--lift);
29+
transition: transform 120ms ease-out;
30+
}
31+
32+
.effort-slider:active::-webkit-slider-thumb {
33+
transform: scale(1.15);
34+
}
35+
36+
/* No level picked and the model's own not known yet: the thumb only waits. */
37+
.effort-slider[data-unset]::-webkit-slider-thumb {
38+
background: var(--muted);
39+
}
40+
41+
.effort-slider:disabled {
42+
cursor: default;
43+
opacity: 0.45;
44+
}

‎app/src/features/chat/ApprovalCard.tsx‎

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,25 @@ import { useId } from "react";
66
import { useT } from "../../i18n";
77
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
88
import { Button } from "../../ui/Button";
9+
import { Details } from "../../ui/Details";
910
import { HELP_TOOL, HelpCard } from "../browser/HelpCard";
1011
import { SITE_TOOL, SiteCard } from "../browser/SiteCard";
12+
import { commandOf, isCommand } from "./command";
1113
import { PLAN_TOOL, PlanCard } from "./PlanCard";
1214
import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard";
1315
import { pretty } from "./ToolLines";
1416
import { toolAction } from "./toolNames";
1517
import { useAnswer } from "./useAnswer";
1618

19+
/** What an answered request was about: the bot's words, or the command or file. */
20+
function About({ approval }: { approval: ApprovalItem }) {
21+
return approval.explanation ? (
22+
<span className="truncate text-muted text-xs">{approval.explanation}</span>
23+
) : (
24+
<span className="truncate font-mono text-muted text-xs">{approval.summary}</span>
25+
);
26+
}
27+
1728
function Answered({ approval }: { approval: ApprovalItem }) {
1829
const t = useT();
1930
const label = toolAction(approval.toolName, t.tools);
@@ -24,17 +35,19 @@ function Answered({ approval }: { approval: ApprovalItem }) {
2435
<p className={line}>
2536
<Check aria-hidden size={14} className="shrink-0 text-ok" />
2637
<span className="shrink-0 font-medium">{t.chat.approval.allowed(label)}</span>
27-
<span className="truncate font-mono text-muted text-xs">{approval.summary}</span>
38+
<About approval={approval} />
2839
</p>
2940
);
3041
case "denied":
3142
return (
3243
<p className={line}>
3344
<Ban aria-hidden size={14} className="shrink-0 text-danger" />
3445
<span className="shrink-0 font-medium">{t.chat.approval.denied(label)}</span>
35-
<span className="truncate text-muted text-xs">
36-
{approval.note ? `“${approval.note}”` : approval.summary}
37-
</span>
46+
{approval.note ? (
47+
<span className="truncate text-muted text-xs">{`“${approval.note}”`}</span>
48+
) : (
49+
<About approval={approval} />
50+
)}
3851
</p>
3952
);
4053
default:
@@ -62,6 +75,39 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
6275
}
6376
}
6477

78+
const BOX =
79+
"max-h-56 overflow-auto break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5";
80+
81+
/**
82+
* A command the bot wants to run: first what the bot says it is for, in
83+
* plain text, then the command itself, one click away. The bot wrote the
84+
* explanation, so the card says so and never shows it in the command's
85+
* place; with none, the command is open from the start (spec 10.1).
86+
*/
87+
function CommandAsked({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
88+
const t = useT();
89+
const said = approval.explanation;
90+
const command = commandOf(approval.input);
91+
return (
92+
<>
93+
{said ? (
94+
<>
95+
<p className="mt-2 break-words text-sm" data-selectable>
96+
{said}
97+
</p>
98+
<p className="mt-1 text-muted text-xs">{t.chat.approval.explainedBy(bot.name)}</p>
99+
</>
100+
) : (
101+
<p className="mt-2 text-ink-soft text-sm">{t.chat.approval.unexplained(bot.name)}</p>
102+
)}
103+
<Details label={t.chat.approval.command} open={!said}>
104+
<div className={BOX}>{command.text}</div>
105+
{!command.whole && <p className="mt-1 font-sans text-warn">{t.chat.tools.commandCut}</p>}
106+
</Details>
107+
</>
108+
);
109+
}
110+
65111
function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
66112
const t = useT();
67113
const { note, setNote, busy, answer } = useAnswer(approval, {
@@ -85,22 +131,20 @@ function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
85131
</span>
86132
{t.chat.approval.wants(bot.name, label)}
87133
</p>
88-
{approval.summary && (
89-
<p className="mt-2 break-words font-mono text-ink-soft text-xs" data-selectable>
90-
{approval.summary}
91-
</p>
134+
{isCommand(approval.toolName) ? (
135+
<CommandAsked approval={approval} bot={bot} />
136+
) : (
137+
<>
138+
{approval.summary && (
139+
<p className="mt-2 break-words font-mono text-ink-soft text-xs" data-selectable>
140+
{approval.summary}
141+
</p>
142+
)}
143+
<Details>
144+
<div className={BOX}>{pretty(approval.input)}</div>
145+
</Details>
146+
</>
92147
)}
93-
<details className="mt-2 text-xs">
94-
<summary className="cursor-default text-muted hover:text-ink">
95-
{t.chat.approval.fullInput}
96-
</summary>
97-
<pre
98-
className="mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5 font-mono"
99-
data-selectable
100-
>
101-
{pretty(approval.input)}
102-
</pre>
103-
</details>
104148
<label htmlFor={noteId} className="sr-only">
105149
{t.chat.approval.noteLabel(bot.name)}
106150
</label>

‎app/src/features/chat/ChatComposer.tsx‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { fileSize } from "../../lib/format";
2020
import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
2121
import { prefs, usePref } from "../../shell/prefs";
2222
import { useApi } from "../../store/context";
23+
import { ContextMeter } from "./ContextMeter";
24+
import { EffortPicker } from "./EffortPicker";
2325
import { rememberImage } from "./images";
2426
import { ModelPicker } from "./ModelPicker";
2527
import { ModePicker } from "./ModePicker";
@@ -70,7 +72,7 @@ export function ChatComposer({
7072
const t = useT();
7173
const [text, setText] = useState("");
7274
const [busy, setBusy] = useState(false);
73-
/** A new mode or model that waits for the bot to finish what it is doing. */
75+
/** A new mode, model or effort, or a compaction, that waits for the bot to finish what it is doing. */
7476
const [later, setLater] = useState<string | null>(null);
7577
const field = useRef<HTMLTextAreaElement>(null);
7678
const picker = useRef<HTMLInputElement>(null);
@@ -159,7 +161,7 @@ export function ChatComposer({
159161
{later}
160162
</p>
161163
)}
162-
<div className="rounded-2xl border border-line-strong bg-panel shadow-sm transition-colors focus-within:border-muted">
164+
<div className="@container rounded-2xl border border-line-strong bg-panel shadow-sm transition-colors focus-within:border-muted">
163165
{files.files.length > 0 && (
164166
<ul aria-label={t.chat.composer.filesToSend} className="flex flex-wrap gap-2 px-3 pt-3">
165167
{files.files.map((file) => (
@@ -181,7 +183,9 @@ export function ChatComposer({
181183
placeholder={t.chat.composer.placeholder(bot.name)}
182184
className="block max-h-60 w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 leading-relaxed outline-none placeholder:text-muted"
183185
/>
184-
<div className="flex items-center gap-1 px-2.5 pb-2.5">
186+
{/* The meter and the effort open over the row's right edge. In a
187+
narrow chat, what is on the right moves to a line of its own. */}
188+
<div className="relative flex flex-wrap items-center gap-x-1 gap-y-1 px-2.5 pb-2.5">
185189
<button
186190
type="button"
187191
aria-label={t.chat.composer.attach}
@@ -210,20 +214,24 @@ export function ChatComposer({
210214
? t.chat.composer.tooLong(text.length, FIELD_LIMITS.message)
211215
: text && (enterSends ? t.chat.composer.keys : t.chat.composer.keysWithCtrl)}
212216
</span>
213-
<ModelPicker bot={bot} onLater={setLater} />
214-
<button
215-
type="submit"
216-
aria-label={t.chat.composer.send}
217-
disabled={empty || tooLong || busy}
218-
className="group/send grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent text-canvas transition-[opacity,transform,background-color] duration-150 hover:opacity-90 active:scale-90 disabled:bg-line-strong disabled:opacity-60"
219-
>
220-
<ArrowUp
221-
aria-hidden
222-
size={16}
223-
strokeWidth={2.25}
224-
className="transition-transform duration-200 group-hover/send:-translate-y-0.5"
225-
/>
226-
</button>
217+
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1">
218+
<ContextMeter bot={bot} onLater={setLater} />
219+
<EffortPicker bot={bot} onLater={setLater} />
220+
<ModelPicker bot={bot} onLater={setLater} />
221+
<button
222+
type="submit"
223+
aria-label={t.chat.composer.send}
224+
disabled={empty || tooLong || busy}
225+
className="group/send grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent text-canvas transition-[opacity,transform,background-color] duration-150 hover:opacity-90 active:scale-90 disabled:bg-line-strong disabled:opacity-60"
226+
>
227+
<ArrowUp
228+
aria-hidden
229+
size={16}
230+
strokeWidth={2.25}
231+
className="transition-transform duration-200 group-hover/send:-translate-y-0.5"
232+
/>
233+
</button>
234+
</div>
227235
</div>
228236
</div>
229237
{files.error && (

0 commit comments

Comments
 (0)