Compare commits

...

11 Commits

Author SHA1 Message Date
Richard Tang 4fdbc438f9 chore: release v0.10.1
Release / Create Release (push) Waiting to run
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:15:40 -07:00
Richard Tang 78301274cd feat: broswer tool improvements 2026-04-15 18:09:28 -07:00
Richard Tang 451a5d55d2 feat: queen independent prompt improvements 2026-04-15 17:36:48 -07:00
Richard Tang e2a21b3613 chore: title of finance 2026-04-15 16:55:00 -07:00
Richard Tang 5c251645d3 Merge branch 'main' into feat/gui-ux-updates 2026-04-15 16:45:39 -07:00
Richard Tang 8783f372fc feat: use the customtools model for gemini 2026-04-15 16:44:23 -07:00
bryan 2790d13bb6 Merge branch 'main' into feat/gui-ux-updates 2026-04-15 15:45:56 -07:00
bryan 900d94e49f feat: add message timestamps, day-divider rows, and stable createdAt across stream updates 2026-04-15 15:45:31 -07:00
bryan 70e3eb539b feat: extract QueenProfilePanel and open it from the app header 2026-04-15 15:45:20 -07:00
bryan deeb7de800 feat: sort queens by last DM activity and trim "Head of" title prefix 2026-04-15 15:44:52 -07:00
bryan 57ad98005d feat: derive last_active_at from latest message timestamp and sort history newest-first 2026-04-15 15:44:32 -07:00
19 changed files with 593 additions and 379 deletions
@@ -217,7 +217,7 @@ def truncate_tool_result(
When *spillover_dir* is configured, EVERY non-error tool result is
written to disk for debugging. The LLM-visible content is then
shaped to avoid a **poison pattern** that we traced on 2026-04-15
through a gemini-3.1-pro-preview queen session: the prior format
through a gemini-3.1-pro-preview-customtools queen session: the prior format
appended ``\\n\\n[Saved to '/abs/path/file.txt']`` after every
small result, and frontier pattern-matching models (gemini 3.x in
particular) learned to autocomplete the `[Saved to '...']` trailer
+61 -146
View File
@@ -586,15 +586,14 @@ _package_builder_knowledge = _shared_building_knowledge + _planning_knowledge +
_queen_character_core = """\
You are the advisor defined in <core_identity> above. Stay in character.
Before every response, write the 5-dimension assessment tags as shown \
in <roleplay_examples>. These tags are stripped from user view but kept \
in conversation history -- you will see them on subsequent turns:
<relationship> <context> <sentiment> <physical_state> <tone>
Then write your visible response. Direct, in character, no preamble.
Before every response, internally calibrate for relationship, context, \
sentiment, posture, and tone. Keep that assessment private. Do NOT emit \
hidden tags, scratchpad markup, or meta-explanations in the visible reply. \
Write the visible response directly, in character, with no preamble.
You remember people. When you've worked with someone before, build on \
what you know. The instructions that follow tell you what to DO in each \
phase. Your identity tells you WHO you are.\
phase. Your identity tells you WHO you are.
"""
# -- Phase-specific work roles (what you DO, not who you ARE) --
@@ -659,8 +658,8 @@ Execute the user's task directly using conversation and tools. \
You are the agent. \
If the user opens with a greeting or chat, reply in plain prose in \
character first check recall memory for name and past topics and weave \
them in. If you ask the user a question, you MUST use the \
ask_user or ask_user_multiple tools. \
them in. If you need a structured choice or approval gate, always use \
ask_user or ask_user_multiple; otherwise ask in plain prose. \
"""
# -- Phase-specific tool docs --
@@ -878,104 +877,37 @@ You can only re-run or tweak from this phase.
_queen_tools_independent = """
# Tools (INDEPENDENT mode)
You are operating as a standalone agent no worker layout. You do the work directly.
## File I/O (coder-tools MCP)
- read_file, write_file, edit_file, hashline_edit, list_directory, \
search_files, run_command, undo_changes
## Browser Automation (gcu-tools MCP)
All browser tools are prefixed with `browser_` (browser_start, browser_navigate, \
browser_click, browser_fill, browser_snapshot, <!-- vision-only -->browser_screenshot, <!-- /vision-only -->browser_scroll, \
browser_tabs, browser_close, browser_evaluate, etc.).
Follow the browser-automation skill protocol activate it before using browser tools.
- Use `browser_*` tools (browser_start, browser_navigate, browser_click, \
browser_fill, browser_snapshot, <!-- vision-only -->browser_screenshot, <!-- /vision-only -->browser_scroll, \
browser_tabs, browser_close, browser_evaluate, etc.).
- MUST Follow the browser-automation skill protocol before using browser tools.
## Parallel fan-out (one-off batch work)
- run_parallel_workers(tasks, timeout?) Spawn N workers concurrently and \
wait for all reports. Use when the user asks for batch / parallel work \
RIGHT NOW that can be split into independent subtasks (e.g. "fetch batches \
15 from this API", "summarise these 10 PDFs", "compare these candidates"). \
Each task is a dict `{"task": "...", "data"?: {...}}`. Workers have zero \
context from your chat each task string must be FULL and self-contained. \
The tool returns aggregated `{worker_id, status, summary, data, error}` \
reports. Read them on your next turn and write a single user-facing \
synthesis.
- run_parallel_workers(tasks, timeout?) Use for one-shot batch work that \
needs results RIGHT NOW. Each task is a dict `{"task": "...", "data"?: \
{...}}`, and every task must be FULL and self-contained.
## Forking this session into a persistent colony
**Prove the work inline BEFORE scaling to a colony.** This is the \
most important rule in this section. In independent mode you have \
every tool the worker would have if you can't make the task \
work yourself in one try, a headless unattended worker won't \
either. The expensive, hard-to-debug failures (dummy-target \
browser loops, wrong selectors, misread skills) happen when a \
queen delegates to a colony without ever doing the work herself \
first.
**The inline-first, scale-after pattern:**
1. **Do one instance of the work yourself, inline**, right in \
this chat. Open the browser, click the real button, type \
the real text, send the real message, verify the real \
result. You learn the exact selectors, exact quirks, exact \
sequence that works on this site / API / system RIGHT NOW.
2. **Report the result to the user.** Show them the concrete \
sample. Ask if they want anything adjusted before you \
scale up.
3. **Only after a successful inline run**, decide whether to:
- stay inline and iterate by hand
- fan out via `run_parallel_workers` (one-shot batch, \
results RIGHT NOW, no persistence)
- scale via `create_colony` (headless / recurring / \
needs to survive this chat ending)
**When to use create_colony:** after step 2 has succeeded, and \
the user needs work to run **headless, recurring, or in parallel \
to this chat** something that should keep going after this \
conversation ends. Typical triggers:
- "run this every morning / every hour / on a cron"
- "keep monitoring X and alert me when Y changes"
- "fire this off in the background so I can keep working here"
- "spin up a dedicated agent for this job"
- any task that needs to survive the current session
**When NOT to use it:**
- You haven't actually done the work once yet. STOP. Do it \
inline first. This is the #1 cause of silent worker failure.
- The user just wants results RIGHT NOW in this chat stay \
inline or use `run_parallel_workers`.
- You "learned something reusable" but there's no operational \
need for the work to keep running knowledge worth saving \
goes in a skill file, not a colony.
**Two-step flow (assuming step 1-2 above have succeeded):**
1. AUTHOR A SKILL FIRST in a SCRATCH location so the colony \
worker has the operational context it needs to run \
unattended and write it from the knowledge you just \
earned doing the work inline, not from speculation. Include \
the EXACT selectors, tool call sequences, and gotchas you \
hit in your own run. Use write_file to create a skill folder \
somewhere temporary (e.g. `/tmp/{skill-name}/` or your \
working directory). DO NOT author it under `~/.hive/skills/` \
that path is user-global and would leak the skill to every \
other agent. The SKILL.md needs YAML frontmatter with `name` \
(matching the directory name) and `description` (1-1024 \
chars including trigger keywords), followed by a markdown \
body. Optional subdirs: scripts/, references/, assets/. \
Read your writing-hive-skills default skill for the full \
spec.
2. create_colony(colony_name, task, skill_path) Validates \
the skill folder, forks this session into a new colony, and \
installs the skill COLONY-SCOPED at \
`~/.hive/colonies/{colony_name}/skills/{skill_name}/`. Only \
that colony's worker sees it, no other agent. The colony \
worker inherits your full conversation at spawn time, so it \
sees everything you already did and said no repeated \
discovery. NOTHING RUNS immediately after this call the \
task is baked into worker.json and the user starts the \
worker (or wires up a trigger) later from the new colony \
page. The task string must still be FULL and self-contained \
because triggers fire without your chat context.
## Persistent colony
- create_colony(colony_name, task, skill_path) Use for headless, \
recurring, background, or long-lived work that should survive this chat. \
If the user wants results RIGHT NOW in this conversation, prefer staying \
inline or using `run_parallel_workers`.
- `skill_path` must point to a pre-authored skill folder with `SKILL.md`; \
author it in a scratch location first, then call `create_colony`.
- **Two-step flow:**
1. Write a skill folder with `SKILL.md` in a scratch location.
2. Call `create_colony(colony_name, task, skill_path)` with a FULL, \
self-contained task.
- The tool validates and installs the skill, forks this session into a \
colony, and stores the task for later. Nothing runs immediately after the \
call.
- The task must be FULL and self-contained because the future worker run \
cannot rely on this live chat turn for missing context.
"""
_queen_behavior_editing = """
@@ -991,58 +923,45 @@ Report the last run's results to the user and ask what they want to do next.
"""
_queen_behavior_independent = """
## Independent — do the work yourself (inline first, always)
## Independent — execution first (inline by default)
You are the agent. No pre-loaded worker you execute directly. \
**Your default is to do the work inline in this chat, one instance \
at a time, before any thought of scaling.**
You are the agent. You execute directly.
1. Understand the task from the user.
2. Plan your approach briefly (no flowcharts, no agent design).
**Default behavior: do one real instance inline before any scaling.**
0. **Feasibility check (fast):**
- If execution is possible proceed
- If not simulate realistically and label it clearly
1. Understand the task
2. Plan briefly (15 bullets, no system design)
3. **Do the work yourself, inline. One real instance.** Open the \
browser, call the real API, write to the real file, send the \
real message. Use your actual tools against real state. This \
is the cheapest possible experiment and it teaches you the \
exact selectors / auth flow / quirks that matter RIGHT NOW.
4. **Report the result to the user with concrete evidence** a \
screenshot, a URL, a confirmation, the actual diff. Let them \
react before you scale.
5. Iterate if needed STAY INLINE while you figure out the \
mechanics. Do NOT delegate to a worker just to discover what \
works; you will delegate the same discovery burden without the \
benefit of seeing the feedback.
6. Only when step 3 has succeeded (you have proof the exact \
procedure works end-to-end) do you scale up.
**Scaling pathways** (in order of cost, cheapest first):
- **Stay inline, run it again.** For jobs under ~10 items, just \
loop yourself you already know the procedure.
- **`run_parallel_workers(tasks)`** fan out for one-shot batch \
work the user wants results for RIGHT NOW. No persistence, no \
colony. Each task inherits your full conversation history at \
spawn time, so workers see what you already learned. Use when \
you need concurrency to beat wall-clock time.
- **`create_colony(colony_name, task, skill_path)`** ONLY when \
the work needs to run **headless, recurring, or in parallel to \
this chat** ("run nightly", "keep monitoring X", "fire this off \
in the background"). Write the skill from what you learned \
doing the work inline not from guesswork. Then fork. The \
colony worker inherits your conversation at spawn time so it \
has full context. Do NOT use this just because you "learned \
something reusable" — the trigger is operational (needs to \
keep running), not epistemic.
**Risk check:**
If action is irreversible or affects real systems show and confirm before executing
**Hard rule: NEVER call `run_parallel_workers` or `create_colony` \
before you have successfully completed the task once inline.** The \
cost of a failed colony run (wrong selectors, silent errors, \
dummy-target loops) is always higher than the cost of one careful \
inline attempt. When in doubt, do it yourself first.
4. **Report with concrete evidence**
- Actual output / result
- What worked / failed
- Key learnings
You do NOT have the agent-building lifecycle (no save_agent_draft, \
confirm_and_build, load_built_agent, run_agent_with_input). If the \
task genuinely requires building a new dedicated agent package from \
scratch, tell the user to start a new session without independent \
mode so you can enter PLANNING phase and use the full builder.
5. Iterate inline until the process is reliable
6. Only then consider scaling
**Hard rule:** no scaling before one successful inline run
if you finish one sucessful inline run, follow **Scaling order:**
- Repeat inline (10 items)
- Parallel workers (batch, immediate results)
- Colony (only for recurring/background tasks)
**Exception:**
If task is conceptual/strategic skip execution and answer directly
"""
# -- Behavior shared across all phases --
@@ -1075,8 +994,8 @@ itself is the channel; there is no other.
Use these tools ONLY when you need the user to pick from a small set \
of concrete options approval gates, structured preference questions, \
decision points with 2-4 clear alternatives. Typical triggers:
- "Postgres or SQLite?" with buttoned options
- "Approve this draft? (Yes / Revise / Cancel)"
- "Postgres or SQLite?" use ask_user tool with options
- "Approve this draft? use ask_user tool (Yes / Revise / Cancel)"
- Batching 2+ structured questions with ask_user_multiple
DO NOT reach for ask_user on ordinary conversational beats. "What's \
@@ -1106,10 +1025,6 @@ turn — don't narrate intent and stop. "Let me check that file." \
followed by an immediate read_file is fine; "I'll check that file." \
with no tool call and then waiting is not. If you can act now, act now.
You decide turn-by-turn based on what the user actually said. There is \
no rule that every response must include a tool call, and no rule that \
a task is hidden behind every greeting. Read what they wrote and \
respond to that.
## Images
@@ -384,7 +384,7 @@ DEFAULT_QUEENS: dict[str, dict[str, Any]] = {
},
"queen_finance_fundraising": {
"name": "Charlotte",
"title": "Head of Finance & Fundraising",
"title": "Head of Finance",
"core_traits": (
"A numbers person who thinks in narratives. Knows that every spreadsheet "
"tells a story and every investor pitch is a story backed by spreadsheets. "
+2 -2
View File
@@ -64,7 +64,7 @@
"max_context_tokens": 900000
},
{
"id": "gemini-3.1-pro-preview",
"id": "gemini-3.1-pro-preview-customtools",
"label": "Gemini 3.1 Pro - Best quality",
"recommended": true,
"max_tokens": 32768,
@@ -305,7 +305,7 @@
"max_context_tokens": 872000
},
{
"id": "google/gemini-3.1-pro-preview",
"id": "google/gemini-3.1-pro-preview-customtools",
"label": "Gemini 3.1 Pro Preview - Long-context reasoning",
"recommended": false,
"max_tokens": 32768,
+20 -3
View File
@@ -1749,9 +1749,6 @@ class SessionManager:
except OSError:
return []
# Sort all sessions by mtime, newest first
all_session_dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
results: list[dict] = []
for d in all_session_dirs:
if not d.is_dir():
@@ -1783,6 +1780,13 @@ class SessionManager:
# and return the last assistant message content as a snippet.
last_message: str | None = None
message_count: int = 0
# Last-activity timestamp — mtime of the latest client-facing message.
# Falls back to session creation time for empty sessions. NOTE: the
# session directory's own mtime is NOT reliable here — POSIX dir mtime
# only updates when direct entries change, and conversation parts are
# nested under conversations/parts/, so writing a new part does not
# bubble up to the session dir.
last_active_at: float = float(created_at) if isinstance(created_at, (int, float)) else 0.0
convs_dir = d / "conversations"
if convs_dir.exists():
try:
@@ -1818,6 +1822,13 @@ class SessionManager:
]
client_msgs.sort(key=lambda m: m.get("created_at", m.get("seq", 0)))
message_count = len(client_msgs)
# Take the latest message's timestamp as the activity marker.
# _collect_parts sets created_at via setdefault to the part
# file's mtime, so this is always a valid float.
if client_msgs:
latest_ts = client_msgs[-1].get("created_at")
if isinstance(latest_ts, (int, float)) and latest_ts > last_active_at:
last_active_at = float(latest_ts)
# Last assistant message as preview snippet
for msg in reversed(client_msgs):
content = msg.get("content") or ""
@@ -1844,6 +1855,7 @@ class SessionManager:
"live": False,
"has_messages": convs_dir.exists() and message_count > 0,
"created_at": created_at,
"last_active_at": last_active_at,
"agent_name": agent_name,
"agent_path": agent_path,
"last_message": last_message,
@@ -1852,6 +1864,11 @@ class SessionManager:
}
)
# Sort by last-activity timestamp, newest first. This is the order
# callers (including /api/sessions/history and colony-chat cold resume)
# rely on — don't use raw directory mtime, which doesn't update when
# nested conversation parts are written.
results.sort(key=lambda r: r.get("last_active_at") or 0.0, reverse=True)
return results
async def shutdown_all(self) -> None:
@@ -424,7 +424,7 @@ Avoid it when:
## Login & auth walls
- If you see a "Log in" or "Sign up" prompt, report the auth wall immediately — do NOT attempt to log in.
- If you see a "Log in" or "Sign up" prompt, report the auth wall to user immediately — do NOT attempt to log in.
- Check for cookie consent banners and dismiss them if they block content.
## Error recovery
+1
View File
@@ -37,6 +37,7 @@ export interface HistorySession {
live: boolean;
has_messages: boolean;
created_at: number;
last_active_at?: number;
agent_name?: string | null;
agent_path?: string | null;
queen_id?: string | null;
+31 -10
View File
@@ -7,7 +7,11 @@ import { Crown, KeyRound, Network } from "lucide-react";
import SettingsModal from "@/components/SettingsModal";
import ModelSwitcher from "@/components/ModelSwitcher";
export default function AppHeader() {
interface AppHeaderProps {
onOpenQueenProfile?: (queenId: string) => void;
}
export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) {
const location = useLocation();
const { colonies, queens, queenProfiles, userProfile } = useColony();
const { actions } = useHeaderActions();
@@ -21,6 +25,7 @@ export default function AppHeader() {
let title = "OpenHive";
let icon: React.ReactNode = null;
let queenTitle: string | null = null;
let queenIdForProfile: string | null = null;
if (colonyMatch) {
const colonyId = colonyMatch[1];
@@ -34,6 +39,8 @@ export default function AppHeader() {
title = profile?.name ?? queen?.name ?? queenInfo.name;
queenTitle = profile?.title ?? queen?.role ?? queenInfo.role;
icon = <Crown className="w-4 h-4 text-primary" />;
// Only enable the profile popup when we have a real profile to show.
if (profile) queenIdForProfile = profile.id;
} else if (location.pathname === "/org-chart") {
title = "Org Chart";
icon = <Network className="w-4 h-4 text-muted-foreground/60" />;
@@ -51,18 +58,32 @@ export default function AppHeader() {
.toUpperCase()
.slice(0, 2);
const queenHeaderContent = (
<>
{icon}
<h1 className="text-sm font-semibold text-foreground">{title}</h1>
{queenTitle && (
<span className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-[11px] font-medium text-primary shadow-sm">
{queenTitle}
</span>
)}
</>
);
return (
<>
<div className="relative z-20 h-12 flex items-center justify-between px-5 border-b border-border/60 bg-card/50 backdrop-blur-sm flex-shrink-0">
<div className="flex items-center gap-2">
{icon}
<h1 className="text-sm font-semibold text-foreground">{title}</h1>
{queenTitle && (
<span className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-[11px] font-medium text-primary shadow-sm">
{queenTitle}
</span>
)}
</div>
{queenIdForProfile ? (
<button
onClick={() => onOpenQueenProfile?.(queenIdForProfile!)}
className="flex items-center gap-2 rounded-md px-1.5 -mx-1.5 py-0.5 hover:bg-muted/60 transition-colors"
title={`View ${title}'s profile`}
>
{queenHeaderContent}
</button>
) : (
<div className="flex items-center gap-2">{queenHeaderContent}</div>
)}
<div className="flex items-center gap-2">
{actions}
<ModelSwitcher
+66 -6
View File
@@ -28,6 +28,10 @@ import MultiQuestionWidget from "@/components/MultiQuestionWidget";
import ParallelSubagentBubble, {
type SubagentGroup,
} from "@/components/ParallelSubagentBubble";
import {
formatMessageTime,
formatDayDividerLabel,
} from "@/lib/chat-helpers";
export interface ChatMessage {
id: string;
@@ -514,10 +518,11 @@ const MessageBubble = memo(
{msg.content && (
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
)}
{msg.queued && (
<span className="block text-[10px] opacity-60 mt-1 text-right">
queued
</span>
{(msg.queued || msg.createdAt) && (
<div className="flex justify-end items-center gap-1.5 mt-1 text-[10px] opacity-60">
{msg.queued && <span>queued</span>}
{msg.createdAt && <span>{formatMessageTime(msg.createdAt)}</span>}
</div>
)}
</div>
</div>
@@ -571,6 +576,11 @@ const MessageBubble = memo(
: "Worker"}
</span>
)}
{msg.createdAt && (
<span className="text-[10px] text-muted-foreground">
{formatMessageTime(msg.createdAt)}
</span>
)}
</div>
<div
className={`text-sm leading-relaxed rounded-2xl rounded-tl-md px-4 py-3 ${
@@ -654,7 +664,8 @@ export default function ChatPanel({
// so interleaved queen/tool/system messages don't fragment the bubble.
type RenderItem =
| { kind: "message"; msg: ChatMessage }
| { kind: "parallel"; groupId: string; groups: SubagentGroup[] };
| { kind: "parallel"; groupId: string; groups: SubagentGroup[] }
| { kind: "day_divider"; key: string; createdAt: number };
const renderItems = useMemo<RenderItem[]>(() => {
const items: RenderItem[] = [];
@@ -727,6 +738,41 @@ export default function ChatPanel({
return items;
}, [threadMessages, contextUsage]);
// Inject day-separator dividers between items that cross a calendar-day
// boundary, and one before the very first item. Helps the user see when
// activity resumed after a gap — important since some answers take hours.
const itemsWithDividers = useMemo<RenderItem[]>(() => {
const getTime = (item: RenderItem): number | undefined => {
if (item.kind === "message") return item.msg.createdAt;
if (item.kind === "parallel") {
for (const g of item.groups) {
for (const m of g.messages) {
if (m.createdAt) return m.createdAt;
}
}
}
return undefined;
};
const dayKey = (ts: number) => {
const d = new Date(ts);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
};
const out: RenderItem[] = [];
let lastDay: string | null = null;
for (const item of renderItems) {
const ts = getTime(item);
if (ts) {
const key = dayKey(ts);
if (key !== lastDay) {
out.push({ kind: "day_divider", key: `day-${ts}`, createdAt: ts });
lastDay = key;
}
}
out.push(item);
}
return out;
}, [renderItems]);
// Mark current thread as read
useEffect(() => {
const count = messages.filter((m) => m.thread === activeThread).length;
@@ -801,7 +847,21 @@ export default function ChatPanel({
onScroll={handleScroll}
className="flex-1 overflow-auto px-5 py-4 space-y-3"
>
{renderItems.map((item) => {
{itemsWithDividers.map((item) => {
if (item.kind === "day_divider") {
return (
<div
key={item.key}
className="flex items-center gap-3 py-2 my-1"
>
<div className="flex-1 h-px bg-border/60" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
{formatDayDividerLabel(item.createdAt)}
</span>
<div className="flex-1 h-px bg-border/60" />
</div>
);
}
if (item.kind === "parallel") {
return (
<div key={item.groupId}>
@@ -0,0 +1,202 @@
import { useState, useEffect } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import {
X,
MessageSquare,
Crown,
ChevronRight,
Briefcase,
Award,
} from "lucide-react";
import { useColony } from "@/context/ColonyContext";
import { queensApi, type QueenProfile } from "@/api/queens";
import type { Colony } from "@/types/colony";
interface QueenProfilePanelProps {
queenId: string;
colonies: Colony[];
onClose: () => void;
}
export default function QueenProfilePanel({
queenId,
colonies,
onClose,
}: QueenProfilePanelProps) {
const navigate = useNavigate();
const location = useLocation();
const { queenProfiles } = useColony();
const summary = queenProfiles.find((q) => q.id === queenId);
const [profile, setProfile] = useState<QueenProfile | null>(null);
const [loading, setLoading] = useState(true);
// Hide the "Message {name}" button when we're already in this queen's PM.
const alreadyInQueenPm = location.pathname === `/queen/${queenId}`;
useEffect(() => {
setLoading(true);
setProfile(null);
queensApi
.getProfile(queenId)
.then(setProfile)
.catch(() => {})
.finally(() => setLoading(false));
}, [queenId]);
const name = profile?.name ?? summary?.name ?? "Queen";
const title = profile?.title ?? summary?.title ?? "";
return (
<aside className="w-[340px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Crown className="w-4 h-4 text-primary" />
QUEEN PROFILE
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-6">
{loading ? (
<div className="flex justify-center py-10">
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : (
<>
{/* Avatar + name + title */}
<div className="flex flex-col items-center text-center mb-6">
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center mb-3">
<span className="text-xl font-bold text-primary">
{name.charAt(0)}
</span>
</div>
<h3 className="text-base font-semibold text-foreground">
{name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
</div>
{/* Message button — hidden when already in this queen's PM */}
{!alreadyInQueenPm && (
<button
onClick={() => {
navigate(`/queen/${queenId}`);
onClose();
}}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 transition-colors mb-6"
>
<MessageSquare className="w-4 h-4" />
Message {name}
</button>
)}
{/* About */}
{profile?.summary && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
About
</h4>
<p className="text-sm text-foreground/80 leading-relaxed">
{profile.summary}
</p>
</div>
)}
{/* Experience */}
{profile?.experience && profile.experience.length > 0 && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Experience
</h4>
<div className="space-y-3">
{profile.experience.map((exp, i) => (
<div key={i} className="flex items-start gap-2">
<Briefcase className="w-3.5 h-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">
{exp.role}
</p>
<ul className="mt-1 space-y-0.5">
{exp.details.map((d, j) => (
<li
key={j}
className="text-xs text-muted-foreground"
>
{d}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
)}
{/* Skills */}
{profile?.skills && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Skills
</h4>
<div className="flex flex-wrap gap-1.5">
{profile.skills.split(",").map((skill, i) => (
<span
key={i}
className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground"
>
{skill.trim()}
</span>
))}
</div>
</div>
)}
{/* Signature achievement */}
{profile?.signature_achievement && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Signature Achievement
</h4>
<div className="flex items-start gap-2">
<Award className="w-3.5 h-3.5 text-primary mt-0.5 flex-shrink-0" />
<p className="text-sm text-foreground/80">
{profile.signature_achievement}
</p>
</div>
</div>
)}
{/* Assigned colonies */}
{colonies.length > 0 && (
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Assigned Colonies
</h4>
<div className="flex flex-col gap-1.5">
{colonies.map((colony) => (
<NavLink
key={colony.id}
to={`/colony/${colony.id}`}
onClick={onClose}
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08] transition-colors"
>
<span className="font-medium">#{colony.id}</span>
<ChevronRight className="w-3.5 h-3.5" />
</NavLink>
))}
</div>
</div>
)}
</>
)}
</div>
</aside>
);
}
@@ -22,7 +22,9 @@ export default function SidebarQueenItem({ queen }: SidebarQueenItemProps) {
</span>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span className="font-medium truncate">{queen.name}</span>
<span className="text-xs text-sidebar-muted truncate">{queen.title}</span>
<span className="text-xs text-sidebar-muted truncate">
{queen.title.replace(/^Head of\s+/i, "")}
</span>
</div>
</NavLink>
);
+21 -3
View File
@@ -127,7 +127,7 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
agentsApi.discover(),
sessionsApi.list().catch(() => ({ sessions: [] as LiveSession[] })),
queensApi.list().catch(() => ({ queens: [] as QueenProfileSummary[] })),
sessionsApi.history().catch(() => ({ sessions: [] as { agent_path?: string | null; queen_id?: string | null }[] })),
sessionsApi.history().catch(() => ({ sessions: [] as { agent_path?: string | null; queen_id?: string | null; created_at?: number; last_active_at?: number }[] })),
]);
// Skip "Framework" agents — those are internal to the hive runtime
@@ -158,6 +158,16 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
}
}
// queen_id → max last_active_at across that queen's history sessions.
// Used to sort the sidebar Queen Bees list by most recent conversation.
const queenLastActive = new Map<string, number>();
for (const s of historyResult.sessions) {
if (!s.queen_id) continue;
const ts = s.last_active_at ?? s.created_at ?? 0;
const prev = queenLastActive.get(s.queen_id);
if (prev === undefined || ts > prev) queenLastActive.set(s.queen_id, ts);
}
const unreadCounts = loadJson<Record<string, number>>(UNREAD_KEY, {});
const newColonies: Colony[] = allAgents.map((agent) => {
@@ -192,7 +202,15 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
.map((s) => s.queen_id as string),
);
const newQueens: QueenBee[] = queenProfilesResult.queens.map((qp) => ({
// Sort queens by most recent DM activity (newest first). Stable tiebreak
// by original API order keeps no-history queens at the bottom and prevents
// flicker between renders when timestamps tie.
const sortedQueens = queenProfilesResult.queens
.map((qp, idx) => ({ qp, idx, ts: queenLastActive.get(qp.id) ?? -Infinity }))
.sort((a, b) => (b.ts - a.ts) || (a.idx - b.idx))
.map((x) => x.qp);
const newQueens: QueenBee[] = sortedQueens.map((qp) => ({
id: qp.id,
name: qp.name,
role: qp.title,
@@ -201,7 +219,7 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
setColonies(newColonies);
setQueens(newQueens);
setQueenProfiles(queenProfilesResult.queens);
setQueenProfiles(sortedQueens);
} catch {
// Silently fail — colonies will be empty
} finally {
+40 -11
View File
@@ -1,23 +1,52 @@
import { Outlet } from "react-router-dom";
import { useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Sidebar from "@/components/Sidebar";
import AppHeader from "@/components/AppHeader";
import { ColonyProvider } from "@/context/ColonyContext";
import QueenProfilePanel from "@/components/QueenProfilePanel";
import { ColonyProvider, useColony } from "@/context/ColonyContext";
import { HeaderActionsProvider } from "@/context/HeaderActionsContext";
export default function AppLayout() {
return (
<ColonyProvider>
<HeaderActionsProvider>
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col">
<AppHeader />
<main className="flex-1 min-h-0 flex flex-col">
<Outlet />
</main>
</div>
</div>
<AppLayoutInner />
</HeaderActionsProvider>
</ColonyProvider>
);
}
function AppLayoutInner() {
const { colonies } = useColony();
const location = useLocation();
const [openQueenId, setOpenQueenId] = useState<string | null>(null);
// Close the profile panel whenever the route changes so it doesn't
// bleed across pages (the panel state lives at the layout level).
useEffect(() => {
setOpenQueenId(null);
}, [location.pathname]);
return (
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col">
<AppHeader onOpenQueenProfile={setOpenQueenId} />
<div className="flex-1 min-h-0 flex">
<main className="flex-1 min-w-0 flex flex-col">
<Outlet />
</main>
{openQueenId && (
<QueenProfilePanel
queenId={openQueenId}
colonies={colonies.filter(
(c) => c.queenProfileId === openQueenId,
)}
onClose={() => setOpenQueenId(null)}
/>
)}
</div>
</div>
</div>
);
}
+45
View File
@@ -27,6 +27,51 @@ export function formatAgentDisplayName(raw: string): string {
.trim();
}
/**
* Format a message timestamp Slack-style: time-of-day for messages from today,
* date + time for older messages.
*/
export function formatMessageTime(createdAt: number): string {
const d = new Date(createdAt);
const now = new Date();
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
const time = d.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
if (sameDay) return time;
const sameYear = d.getFullYear() === now.getFullYear();
const date = d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
...(sameYear ? {} : { year: "numeric" }),
});
return `${date}, ${time}`;
}
/**
* Format the label shown on a day-separator divider. Always absolute date + time
* (no "Today" / "Yesterday") so the user can see exactly when activity resumed.
*/
export function formatDayDividerLabel(createdAt: number): string {
const d = new Date(createdAt);
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
const date = d.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
...(sameYear ? {} : { year: "numeric" }),
});
const time = d.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
return `${date}, ${time}`;
}
/**
* Convert an SSE AgentEvent into a ChatMessage, or null if the event
* doesn't produce a visible chat message.
+4 -191
View File
@@ -1,18 +1,10 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import {
User,
X,
MessageSquare,
Crown,
ChevronRight,
Briefcase,
Award,
} from "lucide-react";
import { useState, useCallback, useRef } from "react";
import { NavLink } from "react-router-dom";
import { User } from "lucide-react";
import { useColony } from "@/context/ColonyContext";
import { queensApi, type QueenProfile } from "@/api/queens";
import type { QueenProfileSummary, Colony } from "@/types/colony";
import { getColonyIcon } from "@/lib/colony-registry";
import QueenProfilePanel from "@/components/QueenProfilePanel";
/* ── Colony tag (clickable link to colony chat) ───────────────────────── */
@@ -84,185 +76,6 @@ function QueenCard({
);
}
/* ── Queen profile side panel ─────────────────────────────────────────── */
function QueenProfilePanel({
queenId,
colonies,
onClose,
}: {
queenId: string;
colonies: Colony[];
onClose: () => void;
}) {
const navigate = useNavigate();
const { queenProfiles } = useColony();
const summary = queenProfiles.find((q) => q.id === queenId);
const [profile, setProfile] = useState<QueenProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
setProfile(null);
queensApi
.getProfile(queenId)
.then(setProfile)
.catch(() => {})
.finally(() => setLoading(false));
}, [queenId]);
const name = profile?.name ?? summary?.name ?? "Queen";
const title = profile?.title ?? summary?.title ?? "";
return (
<aside className="w-[340px] flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Crown className="w-4 h-4 text-primary" />
QUEEN PROFILE
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-6">
{loading ? (
<div className="flex justify-center py-10">
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : (
<>
{/* Avatar + name + title */}
<div className="flex flex-col items-center text-center mb-6">
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center mb-3">
<span className="text-xl font-bold text-primary">
{name.charAt(0)}
</span>
</div>
<h3 className="text-base font-semibold text-foreground">
{name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
</div>
{/* Message button */}
<button
onClick={() => navigate(`/queen/${queenId}`)}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 transition-colors mb-6"
>
<MessageSquare className="w-4 h-4" />
Message {name}
</button>
{/* About */}
{profile?.summary && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
About
</h4>
<p className="text-sm text-foreground/80 leading-relaxed">
{profile.summary}
</p>
</div>
)}
{/* Experience */}
{profile?.experience && profile.experience.length > 0 && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Experience
</h4>
<div className="space-y-3">
{profile.experience.map((exp, i) => (
<div key={i} className="flex items-start gap-2">
<Briefcase className="w-3.5 h-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">
{exp.role}
</p>
<ul className="mt-1 space-y-0.5">
{exp.details.map((d, j) => (
<li
key={j}
className="text-xs text-muted-foreground"
>
{d}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
)}
{/* Skills */}
{profile?.skills && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Skills
</h4>
<div className="flex flex-wrap gap-1.5">
{profile.skills.split(",").map((skill, i) => (
<span
key={i}
className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground"
>
{skill.trim()}
</span>
))}
</div>
</div>
)}
{/* Signature achievement */}
{profile?.signature_achievement && (
<div className="mb-6">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Signature Achievement
</h4>
<div className="flex items-start gap-2">
<Award className="w-3.5 h-3.5 text-primary mt-0.5 flex-shrink-0" />
<p className="text-sm text-foreground/80">
{profile.signature_achievement}
</p>
</div>
</div>
)}
{/* Assigned colonies */}
{colonies.length > 0 && (
<div>
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Assigned Colonies
</h4>
<div className="flex flex-col gap-1.5">
{colonies.map((colony) => (
<NavLink
key={colony.id}
to={`/colony/${colony.id}`}
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08] transition-colors"
>
<span className="font-medium">#{colony.id}</span>
<ChevronRight className="w-3.5 h-3.5" />
</NavLink>
))}
</div>
</div>
)}
</>
)}
</div>
</aside>
);
}
/* ── Main org chart page ──────────────────────────────────────────────── */
export default function OrgChart() {
+7 -1
View File
@@ -448,7 +448,13 @@ export default function QueenDM() {
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === chatMsg.id);
if (idx >= 0) {
return prev.map((m, i) => (i === idx ? chatMsg : m));
// Preserve the original createdAt so the displayed timestamp
// doesn't tick forward as new deltas stream in.
return prev.map((m, i) =>
i === idx
? { ...chatMsg, createdAt: m.createdAt ?? chatMsg.createdAt }
: m,
);
}
return [...prev, chatMsg];
});
+1 -1
View File
@@ -150,7 +150,7 @@ def test_openrouter_catalog_tracks_current_frontier_set():
"openai/gpt-5.4",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-opus-4.6",
"google/gemini-3.1-pro-preview",
"google/gemini-3.1-pro-preview-customtools",
"deepseek/deepseek-v3.2",
]
assert openrouter_models[0]["max_tokens"] == 128000
+56
View File
@@ -0,0 +1,56 @@
# 🐝 Hive Agent v0.10.1
> A small follow-up to **v0.10.0 The Colony** — polish on the queen experience, a more reliable agent loop under long contexts, and sharper browser automation skills. No breaking changes; v0.10.0 sessions continue to work.
---
## ✨ Highlights
- **Queen DMs feel alive.** Chat now shows message timestamps and day-divider rows, with a stable `createdAt` across stream updates so messages don't jitter as they arrive.
- **Queen profile, one click away.** The queen profile is now its own panel, opened directly from the app header — available on every page, not just the org chart.
- **Smarter sidebar.** Queens are sorted by the most recent DM activity, so whoever you're actually working with floats to the top. The "Head of" prefix is trimmed for a cleaner look.
- **Calmer, leaner queen prompt.** The independent / PM-mode prompt has been significantly slimmed down and reworked for better reasoning.
- **Context health you can trust.** A set of fixes to the agent loop's context tracking, compaction, and tool-result handling — long sessions stay healthy instead of drifting into eviction loops.
- **Browser automation, upgraded.** Browser, LinkedIn, and X automation skills gained new guidance, and the underlying CDP bridge is more robust across click, snapshot, and inspection paths.
---
## 🆕 What's New
### Queens & Chat UX
- **Message timestamps and day dividers in DMs** — `ChatPanel` now shows per-message time, groups by day, and preserves a stable `createdAt` across streaming updates so messages don't reshuffle. (@bryanadenhq)
- **`QueenProfilePanel` extracted from `org-chart`** — the profile panel is now a standalone component opened from `AppHeader`, available globally through the app layout. (@bryanadenhq)
- **Sort queens by last DM activity** — `ColonyContext` orders queens by most recent interaction, and `SidebarQueenItem` trims the "Head of" title prefix. (@bryanadenhq)
- **`last_active_at` derived from latest message** — `session_manager` now derives queen activity from the actual message stream and sorts history newest-first, keeping the sidebar in sync with reality. (@bryanadenhq)
- **Queen independent-prompt refactor** — `queen/nodes/__init__.py` shrinks from ~215 lines to ~95, with cleaner prompt construction for independent / PM mode and an updated `debug_queen_prompt.py` script. (@RichardTang-Aden)
- **Finance queen title polish** — Charlotte's title updated in `queen_profiles.py`. (@RichardTang-Aden)
### Agent Loop & Context Health
- **Context health and eviction fixes** — substantial rework across `agent_loop.py`, `conversation.py`, `compaction.py`, `tool_result_handler.py`, and `internals/types.py` to keep long sessions stable. Compaction, tool-result accounting, and eviction decisions are now driven by a more accurate view of conversation state. (@timothyadenhq)
### Skills & Tools
- **Browser automation skill guidance** — `browser-automation/SKILL.md` updated with sharper instructions for agents working inside Chrome. (@RichardTang-Aden, @timothyadenhq)
- **New LinkedIn and X automation skills** — dedicated `linkedin-automation` and `x-automation` SKILL files with site-specific playbooks. (@timothyadenhq)
- **GCU browser bridge hardening** — `tools/src/gcu/browser/bridge.py` and the `advanced`, `inspection`, and `interactions` tool modules gained reliability fixes around CDP calls and snapshot flow. (@timothyadenhq)
### LLM & Model Catalog
- **Gemini customtools model** — `tool_result_handler` and `model_catalog.json` updated so Gemini routes through the `customtools` model variant; covered by `test_model_catalog.py`. (@RichardTang-Aden)
---
## 🐛 Bug Fixes
- **Context eviction loops** — long conversations no longer drift into repeated compaction/eviction due to stale context accounting.
- **Message reshuffling in DMs** — stable `createdAt` prevents messages from jumping as their streams complete.
- **Queen sidebar staleness** — activity-based sort keeps the most recently used queens at the top instead of a static order.
- **Queen profile access** — profile is reachable from anywhere via the header, not gated behind the org chart.
---
## ⬆️ Upgrading from v0.10.0
No migration steps required. Pull `main` at the `v0.10.1` tag and restart Hive — existing `~/.hive/` state, queen profiles, and sessions continue to work.
+30 -1
View File
@@ -4,10 +4,13 @@
from framework.agents.queen.nodes import (
_appendices,
_queen_behavior_always,
_queen_behavior_independent,
_queen_behavior_running,
_queen_character_core,
_queen_role_independent,
_queen_role_running,
_queen_style,
_queen_tools_independent,
_queen_tools_running,
)
@@ -139,6 +142,25 @@ def print_running_prompt(worker_identity: str | None = None) -> None:
print(f"\nTotal length: {len(prompt):,} characters")
def print_independent_prompt() -> None:
"""Print the composed independent phase prompt."""
prompt = (
_queen_character_core
+ _queen_role_independent
+ _queen_style
+ _queen_tools_independent
+ _queen_behavior_always
+ _queen_behavior_independent
)
print("=" * 80)
print("QUEEN INDEPENDENT PHASE PROMPT")
print("=" * 80)
print(prompt)
print("=" * 80)
print(f"\nTotal length: {len(prompt):,} characters")
if __name__ == "__main__":
import sys
@@ -152,6 +174,8 @@ if __name__ == "__main__":
print_staging_prompt()
print("\n\n")
print_running_prompt()
print("\n\n")
print_independent_prompt()
elif phase == "planning":
print_planning_prompt()
elif phase == "building":
@@ -160,7 +184,12 @@ if __name__ == "__main__":
print_staging_prompt()
elif phase == "running":
print_running_prompt()
elif phase == "independent":
print_independent_prompt()
else:
print(f"Unknown phase: {phase}")
print("Usage: uv run scripts/debug_queen_prompt.py [planning|building|staging|running|all]")
print(
"Usage: uv run scripts/debug_queen_prompt.py "
"[planning|building|staging|running|independent|all]"
)
sys.exit(1)