Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
@@ -0,0 +1,188 @@
/**
* Message stream container: renders the ChatItem list; auto-sticks to the
* bottom while streaming — an upward swipe immediately pauses follow, and scrolling back near
* the bottom resumes it (see stream-follow.ts for the exact rule).
* StreamRenderContext threads the pending-approval map and approval callback down to tool
* cards at any nesting depth.
*/
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { S } from "../../lib/strings";
import type { ChatItem } from "../../lib/omni/stream-model";
import type { TaskStats } from "../../lib/omni/task-stats";
import type { PendingApproval } from "./use-session-stream";
import { EmptyState } from "../../components/ui/empty-state";
import { MessageItem } from "./message-item";
import { WorkGroup, isWorkItem } from "./work-group";
import { createStreamFollow } from "./stream-follow";
import type { StreamFollow } from "./stream-follow";
/** Context passed down to nested rendering (pending approvals + approval submit callback + current origin chain). */
export interface StreamRenderContext {
/** approvalKey(origin, toolCallId) → pending approval (disambiguates by origin when parent/child session tool_call_ids collide). */
pendingApprovals: ReadonlyMap<string, PendingApproval>;
onApprove: (toolCallId: string, decision: "allow" | "deny", origin: string[]) => Promise<void>;
/** Origin chain at the current render level (empty array for the main session; subagent cards append one level each). */
origin: string[];
/**
* Whether the Task at this level is still running (taskState for the main session, its own
* running state for a subagent card). The "Reasoning & Tools" group uses this to decide: as
* long as the model might still call another tool, the trailing group always shows "Running".
*/
taskRunning: boolean;
/** Converts this turn's stats into cost (USD) using the current Model pricing; returns null when no price is configured (cost is hidden). */
taskCost?: (stats: TaskStats) => number | null;
/** Opens the Files panel and navigates to this file (triggered by clicking the file-summary card at the end of a message; takes a Workspace-relative path); the card doesn't render if this isn't wired up. */
onOpenFile?: (path: string) => void;
/** Absolute Workspace path of the current Session (used by the file-summary card to normalize body paths). */
workspace?: string | null;
/** Batch file-existence check (with session-level caching); the card doesn't render if this isn't wired up. */
statFiles?: (paths: string[]) => Promise<ReadonlySet<string>>;
}
/** Pure list rendering (reused recursively inside subagent cards): consecutive thinking + tool-call items are aggregated into one "Reasoning & Tools" group. */
export function MessageItems({ items, ctx }: { items: ChatItem[]; ctx: StreamRenderContext }) {
// Split into segments first — group (consecutive thinking + tool calls) or single (everything
// else) — then render. WorkGroup needs to know whether it's the last segment (current turn
// still in progress) to decide its default expanded/collapsed state.
type Seg = { type: "group"; items: ChatItem[] } | { type: "single"; item: ChatItem };
const segs: Seg[] = [];
let run: ChatItem[] = [];
const flushRun = () => {
if (run.length > 0) {
segs.push({ type: "group", items: run });
run = [];
}
};
for (const item of items) {
if (isWorkItem(item)) run.push(item);
else {
flushRun();
segs.push({ type: "single", item });
}
}
flushRun();
const renderSeg = (seg: Seg, i: number): ReactNode =>
seg.type === "group" ? (
<WorkGroup
key={`wg-${seg.items[0]!.id}`}
items={seg.items}
ctx={ctx}
isLast={i === segs.length - 1}
/>
) : (
<MessageItem key={seg.item.id} item={seg.item} ctx={ctx} />
);
/**
* Each turn's AI-side content (reply, reasoning-and-tools group, compaction banner, ...) plus
* its trailing stats row shares a single group container. The stats row is that turn's footer
* (timestamp and copy button live there, and the whole row is transparent by default), so
* hovering **any** content within the turn must be able to reveal it.
*
* The stats row can't simply be paired with the adjacent assistant reply — if compaction
* happens mid-turn, the compaction banner gets inserted between them (items:
* assistant_text → compaction → task_stats), breaking the pairing instantly. The stats row
* would then become a strip that's both invisible and unhoverable (the element's own `group`
* class doesn't apply to itself: group-hover is a descendant selector).
*
* The container is created as soon as the turn's **first** segment appears, keyed by that
* segment's id, and the key never changes afterward. If we waited for the stats row to arrive
* before moving already-rendered groups into a new container, React would treat it as a
* position change — unmount and remount — and the WorkGroup and tool-card expanded states
* (each backed by its own internal useState) would reset instantly: any tool details the user
* had manually expanded would collapse the moment the reply finishes.
*
* User messages never enter this container: they have their own footer, and including them
* would make hovering a user message also light up the AI's stats row.
*/
const nodes: ReactNode[] = [];
let turn: { seg: Seg; i: number }[] = [];
const flushTurn = () => {
if (turn.length === 0) return;
const first = turn[0]!.seg;
const key = first.type === "group" ? first.items[0]!.id : first.item.id;
const body = turn;
turn = [];
nodes.push(
<div key={`turn-${key}`} className="group">
{body.map((t) => renderSeg(t.seg, t.i))}
</div>,
);
};
for (let i = 0; i < segs.length; i++) {
const seg = segs[i]!;
const isUserMsg =
seg.type === "single" && (seg.item.kind === "user_text" || seg.item.kind === "user_image");
if (isUserMsg) {
flushTurn();
nodes.push(renderSeg(seg, i));
continue;
}
turn.push({ seg, i });
// Stats row = end of this turn; seal the container here. Anything after belongs to the next turn.
if (seg.type === "single" && seg.item.kind === "task_stats") flushTurn();
}
flushTurn(); // Turn not yet finished (stats row hasn't arrived): container already exists, subsequent content is appended directly
return <>{nodes}</>;
}
export function MessageStream({
items,
version,
ctx,
}: {
items: ChatItem[];
/** View-model version number (a repaint signal for in-place updates that also drives auto-scroll). */
version: number;
ctx: StreamRenderContext;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
// An upward-swipe intent immediately exits auto-follow; scrolling back near the bottom resumes it — see stream-follow.ts (#75) for the exact rule.
const followRef = useRef<StreamFollow | null>(null);
const follow = (followRef.current ??= createStreamFollow());
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
follow.scrolled({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
});
};
useEffect(() => {
const el = scrollRef.current;
if (el && follow.stick) el.scrollTop = el.scrollHeight;
}, [version, follow]);
return (
<div
ref={scrollRef}
onScroll={onScroll}
onWheel={(e) => follow.wheel(e.deltaY)}
onTouchStart={(e) => {
const t = e.touches[0];
if (t) follow.touchStart(t.clientY);
}}
onTouchMove={(e) => {
const t = e.touches[0];
if (t) follow.touchMove(t.clientY);
}}
onTouchEnd={() => follow.touchEnd()}
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
>
<div className="mx-auto max-w-3xl">
{items.length === 0 ? (
<EmptyState title={S.chat.emptyStream} />
) : (
<MessageItems items={items} ctx={ctx} />
)}
</div>
</div>
);
}