Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ jobs:
# Every built-in theme's text, states and terminal meet their contrast
# (src/themes/contrast.ts).
- run: pnpm check:themes
# The split tree's moves (lib/layout.test.ts).
# packages/core, shared with the phone app, imports only itself and
# has no DOM (scripts/check-core.mjs).
- run: pnpm check:core
# The split tree's moves (lib/layout.test.ts), and packages/core's tests.
- run: pnpm test
- run: pnpm build
# The smoke suite (e2e/): the production build in Playwright's Chromium,
Expand Down Expand Up @@ -291,3 +294,43 @@ jobs:
- run: pnpm install --frozen-lockfile
# The pages are the repository's docs/ folder; a broken page fails here.
- run: pnpm build

mobile:
# Only what was asked for: see "on" above.
if: github.event_name != 'pull_request' || (github.event.action != 'labeled' || github.event.label.name == 'run-ci') && contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ${{ (vars.SELF_HOSTED == 'true' && github.event_name == 'push' && github.actor == github.repository_owner) && fromJSON('["self-hosted","omarchy"]') || 'ubuntu-latest' }}
defaults:
run:
working-directory: mobile
steps:
# omarchy runs only the owner's pushes; its job-started hook
# (ci/runner/job-started.sh) refuses anything else too.
- name: Only the owner's pushes run on a self-hosted runner
if: runner.environment == 'self-hosted'
working-directory: ${{ runner.temp }}
env:
ACTOR: ${{ github.actor }}
TRIGGERING_ACTOR: ${{ github.triggering_actor }}
OWNER: ${{ github.repository_owner }}
EVENT: ${{ github.event_name }}
run: |
if [ "$ACTOR" != "$OWNER" ] || [ "$TRIGGERING_ACTOR" != "$OWNER" ] || [ "$EVENT" != push ]; then
echo "::error::$EVENT by $ACTOR may not run on a self-hosted runner"
exit 1
fi
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 11
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
# A self-hosted runner keeps its pnpm store between jobs.
cache: ${{ runner.environment == 'github-hosted' && 'pnpm' || '' }}
cache-dependency-path: mobile/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
# The phone app (mobile/AGENTS.md). No iOS build here: EAS builds
# it. Metro's bundle checks that packages/core resolves.
- run: pnpm typecheck
- run: pnpm test
- run: pnpm export
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Shipyard (formerly Berth) runs coding agents on your own machines. This repo has
- `cmd/berthd`, `internal/box`: the box daemon. It runs agents, terminals and services in its own tmux server.
- `cmd/berth`, `internal/agent`: the laptop CLI and agent (UI API on 1378, proxy on 1377, phone on 1379).
- `app/`: the Tauri 2 + React desktop app (coss-ui, 23 themes). `app/e2e` holds the Playwright suite on mock fixtures.
- `packages/core`: plain TypeScript shared by `app/` and the phone app (session state, screen and transcript reading, pairing links). Only relative `./x.ts` imports and the plugin SDK's types; no DOM (`check:core`). `app/src/lib/<name>.ts` re-exports it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The <name>.ts re-export rule doesn't match the code. conversation and pairing have no app/src/lib/<name>.ts shim, and state is exposed as state-model.ts. Name the exceptions or say that the shims exist only for some modules.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At AGENTS.md, line 8:

<comment>The `<name>.ts` re-export rule doesn't match the code. `conversation` and `pairing` have no `app/src/lib/<name>.ts` shim, and `state` is exposed as `state-model.ts`. Name the exceptions or say that the shims exist only for some modules.</comment>

<file context>
@@ -5,6 +5,7 @@ Shipyard (formerly Berth) runs coding agents on your own machines. This repo has
 - `cmd/berthd`, `internal/box`: the box daemon. It runs agents, terminals and services in its own tmux server.
 - `cmd/berth`, `internal/agent`: the laptop CLI and agent (UI API on 1378, proxy on 1377, phone on 1379).
 - `app/`: the Tauri 2 + React desktop app (coss-ui, 23 themes). `app/e2e` holds the Playwright suite on mock fixtures.
+- `packages/core`: plain TypeScript shared by `app/` and the phone app (session state, screen and transcript reading, pairing links). Only relative `./x.ts` imports and the plugin SDK's types; no DOM (`check:core`). `app/src/lib/<name>.ts` re-exports it.
 - `docs/`: the docs as MDX, built by `docs-site/` (Fumadocs). `site/` is the landing page and `install.sh`.
 
</file context>

- `mobile/`: the phone app (Expo, iOS first), which talks to boxes' phone API over the tailnet. Its rules are in `mobile/AGENTS.md`; check it with `cd mobile && pnpm typecheck && pnpm test && pnpm export`.
- `docs/`: the docs as MDX, built by `docs-site/` (Fumadocs). `site/` is the landing page and `install.sh`.

**Names:** "Shipyard" is the brand in everything people read. `berth`, `berthd`, `~/.berth`, `BERTH_*`, `berth://`, `dev.berth.app`, `berth.prefs` and the `berth-*` skill ids are identifiers. Don't rename them; a later change will, with migrations.
Expand All @@ -17,7 +19,7 @@ Run these before you call something done:
go vet ./... && go test -race ./... # -race: CI runs it, and it has caught real races
(cd app/src-tauri && cargo check)
cd app && CI=true pnpm install --frozen-lockfile
pnpm typecheck:plugins && pnpm check:titles && pnpm check:type && pnpm check:grid && pnpm check:csp && pnpm check:themes && pnpm test && pnpm build
pnpm typecheck:plugins && pnpm check:titles && pnpm check:type && pnpm check:grid && pnpm check:csp && pnpm check:themes && pnpm check:core && pnpm test && pnpm build
npx tsc -p e2e
(cd docs-site && pnpm build) # when docs/ changed
```
Expand All @@ -29,7 +31,7 @@ Browser tests: run the specs you touched, not the whole suite (`E2E_PORT=14xx np
- **Ports:** 1420 is the dev app and 1377–1379 the laptop agent: never use or kill them. Vite and Playwright use `E2E_PORT` in 1421–1439, and you check the port is free first.
- **Hands off:** never touch `~/.berth`, `~/Library/Application Support/berth` or the `dev.berth.berthd` launchd job. Kill only PIDs you started; no `pkill node`/`vite`/`next-server`.
- **No symlinked `node_modules`** (pnpm breaks without a TTY). Install per worktree.
- **New unit test files** go in `app/src/lib/` as `*.test.ts`; `pnpm test` finds them there (`src/lib/**/*.test.ts`). Don't list them in `package.json`.
- **New unit test files** go in `app/src/lib/` (or `packages/core/src/` beside shared code) as `*.test.ts`; `pnpm test` finds them there. Don't list them in `package.json`.
- **No `title=` attributes** in the app (`check:titles`); use `<Tip>`.
- **Layout grid** (`check:grid`, "The layout grid" in `app/src/index.css`): a top band is `h-bar`, a pane's toolbar `h-bar-pane`; boxes start on `px-edge`, their content on `pl-lane`, a full page on `px-page`. No raw pixel gutters (`pl-[11px]`); a deliberate exception gets a `grid-ok: why` comment.
- **Text sizes come from the type scale** (`text-body`, `text-desc`, `text-meta`, `text-tag`, … in `index.css`), never `text-sm` or `text-[11px]` (`check:type`). coss ui's own parts in `components/ui` keep theirs.
Expand Down
19 changes: 19 additions & 0 deletions app/e2e/phone-apps.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, mockOnly, test } from "./fixtures";

// Settings › Phone › Phone apps: the Shipyard phone apps a box notifies
// through the push relay, and removing one.

test("a phone that gets notifications is listed with its box, and Remove stops them", async ({ app }) => {
mockOnly("turning phone access on writes to a box");
await app.open();
const phone = await app.openSettings("phone");
const apps = phone.locator("section").filter({ has: app.page.getByRole("heading", { name: "Phone apps", exact: true }) });
await expect(apps).toContainText("No phone gets notifications yet");

await phone.getByRole("switch", { name: "Phone access on devl" }).click();
await expect(apps).toContainText("Acme iPhone");
await expect(apps).toContainText("devl · needs you");

await apps.getByRole("button", { name: "Remove" }).click();
await expect(apps).toContainText("No phone gets notifications yet");
});
3 changes: 2 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@
"check:grid": "node scripts/check-grid.mjs",
"check:plugin-consent": "node scripts/check-plugin-consent.mjs",
"check:csp": "node scripts/check-csp.mjs",
"check:core": "node scripts/check-core.mjs && tsc -p ../packages/core",
"check:themes": "node --experimental-strip-types --test src/themes/contrast.test.ts",
"test:e2e": "tsc --noEmit -p e2e && pnpm -s build && playwright test",
"smoke:live": "node e2e/live.mjs",
"perf": "node perf/bench.mjs",
"perf:guard": "node perf/guard.mjs",
"perf:soak": "node perf/soak.mjs",
"perf:terminals": "node perf/terminals.mjs",
"test": "node --experimental-strip-types --test 'src/lib/**/*.test.ts'"
"test": "node --experimental-strip-types --test 'src/lib/**/*.test.ts' '../packages/core/src/**/*.test.ts'"
},
"dependencies": {
"@base-ui/react": "^1.8.0",
Expand Down
41 changes: 41 additions & 0 deletions app/scripts/check-core.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Fails when packages/core reaches past itself: an import of a package, an
// alias (@/lib/…), a path without its .ts extension, or the DOM.
//
// The desktop app (Vite), the phone app (Metro) and node's test runner all
// load packages/core's files as they are, so each may import only its
// neighbours by a relative path with .ts, plus types from the plugin SDK.
// tsc -p ../packages/core (in check:core, after this) checks the rest:
// that lib has no DOM.
//
// node scripts/check-core.mjs
import { readdir, readFile } from "node:fs/promises";
import { join, relative, resolve } from "node:path";

const app = resolve(import.meta.dirname, "..");
const core = resolve(app, "..", "packages", "core", "src");
const SDK = "../../plugin-sdk/src/types.ts";

const problems = [];
for (const name of (await readdir(core, { recursive: true })).filter((n) => n.endsWith(".ts"))) {
const file = join(core, name);
const test = name.endsWith(".test.ts");
const src = await readFile(file, "utf8");
for (const m of src.matchAll(/^\s*(import|export)\s+(type\s+)?[^;]*?\sfrom\s+"([^"]+)"/gms)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: check:core misses side-effect imports and every single-quoted import/export, allowing forbidden packages or extensionless neighbours to bypass the portability check. Parse both quote styles and side-effect syntax, or use a TypeScript-aware import parser.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/scripts/check-core.mjs, line 23:

<comment>`check:core` misses side-effect imports and every single-quoted import/export, allowing forbidden packages or extensionless neighbours to bypass the portability check. Parse both quote styles and side-effect syntax, or use a TypeScript-aware import parser.</comment>

<file context>
@@ -0,0 +1,41 @@
+  const file = join(core, name);
+  const test = name.endsWith(".test.ts");
+  const src = await readFile(file, "utf8");
+  for (const m of src.matchAll(/^\s*(import|export)\s+(type\s+)?[^;]*?\sfrom\s+"([^"]+)"/gms)) {
+    const [, , type, spec] = m;
+    const line = src.slice(0, m.index).split("\n").length + (m[0].match(/^\s*/)[0].split("\n").length - 1);
</file context>

const [, , type, spec] = m;
const line = src.slice(0, m.index).split("\n").length + (m[0].match(/^\s*/)[0].split("\n").length - 1);
const where = `${relative(resolve(app, ".."), file)}:${line}`;
if (test && spec.startsWith("node:")) continue;
if (spec === SDK) {
if (!type) problems.push(`${where}: import only types from the plugin SDK (import type …)`);
continue;
}
if (!spec.startsWith("./")) problems.push(`${where}: "${spec}" is outside packages/core; import a neighbour by ./name.ts`);
else if (!spec.endsWith(".ts")) problems.push(`${where}: "${spec}" needs its .ts extension`);
}
}

if (problems.length) {
console.error(`packages/core may only import its own files (./x.ts) and the plugin SDK's types:\n\n${problems.join("\n")}`);
process.exit(1);
}
console.log("check:core: packages/core imports only itself");
32 changes: 6 additions & 26 deletions app/src/lib/conversation-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { create } from "zustand";

import type { Artifact, CrewMember, TranscriptItem } from "@/lib/transcript";
import { fillItems, mergeItems, offOf, resyncItems } from "../../../packages/core/src/conversation.ts";

// The conversations the app holds for agents shown as a conversation rather
// than a terminal: each session's recent items and the crew working beside
Expand Down Expand Up @@ -34,9 +35,7 @@ interface ConversationState {
setArtifacts(key: string, list: Artifact[]): void;
}

// Where an item's line starts in the agent's record (boxes send it as off;
// 0 is left out).
export const offOf = (it: TranscriptItem) => (it as { off?: number }).off ?? 0;
export { offOf };

// spill hears the items a chat lets go past KEEP, oldest first: the chat's
// older turns (lib/history) take them, so what it shows has no hole.
Expand All @@ -56,30 +55,11 @@ export const useConversations = create<ConversationState>()((set, get) => {
crew: {},
artifacts: {},
push: (key, item) => put(key, [...(get().items[key] ?? []), item]),
merge: (key, incoming) => {
const list = [...(get().items[key] ?? [])];
const at = new Map(list.map((it, i) => [it.id, i]));
for (const it of incoming) {
const i = at.get(it.id);
if (i === undefined) {
at.set(it.id, list.length);
list.push(it);
} else list[i] = it;
}
put(key, list);
},
resync: (key, window, start, fresh) => {
const held = fresh ? [] : (get().items[key] ?? []).filter((it) => offOf(it) < start);
const ids = new Set(window.map((it) => it.id));
put(key, [...held.filter((it) => !ids.has(it.id)), ...window]);
},
merge: (key, incoming) => put(key, mergeItems(get().items[key] ?? [], incoming)),
resync: (key, window, start, fresh) => put(key, resyncItems(get().items[key] ?? [], window, start, fresh)),
fill: (key, items) => {
const list = get().items[key] ?? [];
const have = new Set(list.map((it) => it.id));
const add = items.filter((it) => !have.has(it.id));
if (!add.length) return;
// In the record's order; a sort that keeps the order of equals.
put(key, [...list, ...add].map((it, i) => [it, i] as const).sort((a, b) => offOf(a[0]) - offOf(b[0]) || a[1] - b[1]).map(([it]) => it));
const list = fillItems(get().items[key] ?? [], items);
if (list) put(key, list);
},
update: (key, id, patch) =>
set((s) => ({ items: { ...s.items, [key]: (s.items[key] ?? []).map((i) => (i.id === id ? ({ ...i, ...patch } as TranscriptItem) : i)) } })),
Expand Down
Loading