Repository navigation
Conversation
…packages/core The phone app will read sessions, screens, transcripts and pairing links the way the desktop app does. packages/core holds that code as plain TypeScript with only relative imports and no DOM, so Vite, Metro and node's test runner load it as it is. app/src/lib re-exports each file, so nothing in the app changes. check:core keeps the package to itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Expo app (iOS first, Android later from the same code) that pairs with boxes from the desktop app's existing QR code and shows which agents need you across them: a session's conversation or its screen, the answers it offers, keys and a prompt to send; runs, their gates and cancelling them. Boxes at the phone API's version 2 stream their events and send notifications through the push relay; older boxes are read every few seconds. Tokens stay in the keychain, and the demo works without a box. Session state, screen and transcript reading come from packages/core, shared with the desktop app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fail Reading the notification that opened the app throws where notifications aren't supported, which stopped the rest of start-up; it's now caught, as is reading the notification setting. The demo's boxes are current boxes with a conversation to read, and say they're made up instead of "Connecting". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings → Phone shows each Shipyard phone app the boxes notify through the push relay, with which boxes, what it hears and when it last got one, and Remove stops them on every box. The pairing steps mention the iPhone app, and ntfy is described as what the web app and Android use. The phone guide and changelog cover the iPhone app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
8 issues found across 31 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. 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. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="AGENTS.md">
<violation number="1" location="AGENTS.md:8">
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.</violation>
</file>
<file name="packages/core/tsconfig.json">
<violation number="1" location="packages/core/tsconfig.json:20">
P2: The `exclude` of `src/**/*.test.ts` means no check typechecks the core tests: `check:core`'s `tsc` skips them, the app's tsconfig doesn't include them, and `node --experimental-strip-types` doesn't type-check. Consider a separate test tsconfig that supplies `node:test` and `node:assert` types, so the tests get checked too.</violation>
</file>
<file name="packages/core/src/derive.ts">
<violation number="1" location="packages/core/src/derive.ts:106">
P2: `sessionName` counts a service's terminal as a duplicate shell, so a plain shell in a worktree that has a service gets numbered. If the service was created first, the shell is labelled "Shell 2" and the service itself can be labelled "web 2". Exclude services from the `same` filter, as `worktreeSessions` already does.</violation>
</file>
<file name="packages/core/src/screen.ts">
<violation number="1" location="packages/core/src/screen.ts:28">
P3: `meaningfulTail` returns every line when `count` is 0, because `slice(-0)` is `slice(0)`. Return an empty list for a zero count.</violation>
<violation number="2" location="packages/core/src/screen.ts:76">
P2: `menuIn` keeps numbered lines from earlier in the window, so a numbered list above a permission menu gets read as its options and the real menu's items are dropped as duplicate keys. Restart the menu at each "1." line and only accept the next number in sequence.</violation>
</file>
<file name="app/scripts/check-core.mjs">
<violation number="1" location="app/scripts/check-core.mjs:23">
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.</violation>
</file>
<file name="packages/core/src/pairing.ts">
<violation number="1" location="packages/core/src/pairing.ts:20">
P2: `pairingLink` interpolates `undefined` when the first box URL has no valid origin, producing `undefined/#pair=...`. Return `undefined` after checking the first origin before constructing the link.</violation>
<violation number="2" location="packages/core/src/pairing.ts:48">
P2: `originOf` accepts malformed authorities such as `http://box:bad` and `http://:1379`, so `parsePairing` can return unusable boxes that the phone page rejects with `new URL`. Validate the host and port before returning an origin.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
View guided diff | Turn on auto-fix | Re-trigger cubic
| "types": [] | ||
| }, | ||
| "include": ["src"], | ||
| "exclude": ["src/**/*.test.ts"] |
There was a problem hiding this comment.
P2: The exclude of src/**/*.test.ts means no check typechecks the core tests: check:core's tsc skips them, the app's tsconfig doesn't include them, and node --experimental-strip-types doesn't type-check. Consider a separate test tsconfig that supplies node:test and node:assert types, so the tests get checked too.
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 packages/core/tsconfig.json, line 20:
<comment>The `exclude` of `src/**/*.test.ts` means no check typechecks the core tests: `check:core`'s `tsc` skips them, the app's tsconfig doesn't include them, and `node --experimental-strip-types` doesn't type-check. Consider a separate test tsconfig that supplies `node:test` and `node:assert` types, so the tests get checked too.</comment>
<file context>
@@ -0,0 +1,21 @@
+ "types": []
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.ts"]
+}
</file context>
| const agent = agentOf(s); | ||
| let name = title || (agent ? agentLabel(agent) : s.service || "Shell"); | ||
| if (title && opts.agent) name += ` · ${agent ? agentLabel(agent) : "Shell"}`; | ||
| const same = title ? [] : (opts.sessions ?? []).filter((o) => o.dir === s.dir && agentOf(o) === agent && !o.exited && !o.title?.trim()); |
There was a problem hiding this comment.
P2: sessionName counts a service's terminal as a duplicate shell, so a plain shell in a worktree that has a service gets numbered. If the service was created first, the shell is labelled "Shell 2" and the service itself can be labelled "web 2". Exclude services from the same filter, as worktreeSessions already does.
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 packages/core/src/derive.ts, line 106:
<comment>`sessionName` counts a service's terminal as a duplicate shell, so a plain shell in a worktree that has a service gets numbered. If the service was created first, the shell is labelled "Shell 2" and the service itself can be labelled "web 2". Exclude services from the `same` filter, as `worktreeSessions` already does.</comment>
<file context>
@@ -0,0 +1,215 @@
+ const agent = agentOf(s);
+ let name = title || (agent ? agentLabel(agent) : s.service || "Shell");
+ if (title && opts.agent) name += ` · ${agent ? agentLabel(agent) : "Shell"}`;
+ const same = title ? [] : (opts.sessions ?? []).filter((o) => o.dir === s.dir && agentOf(o) === agent && !o.exited && !o.title?.trim());
+ if (same.length > 1) {
+ const order = same.sort((a, b) => a.created.localeCompare(b.created) || a.name.localeCompare(b.name));
</file context>
| const same = title ? [] : (opts.sessions ?? []).filter((o) => o.dir === s.dir && agentOf(o) === agent && !o.exited && !o.title?.trim()); | |
| const same = title ? [] : (opts.sessions ?? []).filter((o) => o.dir === s.dir && agentOf(o) === agent && !o.service && !o.exited && !o.title?.trim()); |
| if (!m || out.some((c) => c.key === m[1])) continue; | ||
| if (!out.length) first = i; | ||
| out.push({ key: m[1], label: m[2].trim() }); | ||
| } |
There was a problem hiding this comment.
P2: menuIn keeps numbered lines from earlier in the window, so a numbered list above a permission menu gets read as its options and the real menu's items are dropped as duplicate keys. Restart the menu at each "1." line and only accept the next number in sequence.
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 packages/core/src/screen.ts, line 76:
<comment>`menuIn` keeps numbered lines from earlier in the window, so a numbered list above a permission menu gets read as its options and the real menu's items are dropped as duplicate keys. Restart the menu at each "1." line and only accept the next number in sequence.</comment>
<file context>
@@ -0,0 +1,145 @@
+ let first = -1;
+ for (let i = Math.max(0, lines.length - 14); i < lines.length; i++) {
+ const m = lines[i].match(/^\s*(?:[❯›>]\s*)?(\d)[.)]\s+(\S.*)$/);
+ if (!m || out.some((c) => c.key === m[1])) continue;
+ if (!out.length) first = i;
+ out.push({ key: m[1], label: m[2].trim() });
</file context>
| if (!m || out.some((c) => c.key === m[1])) continue; | |
| if (!out.length) first = i; | |
| out.push({ key: m[1], label: m[2].trim() }); | |
| } | |
| if (!m) continue; | |
| // A menu counts up from 1: a "1." starts it over, so a list above it is not read. | |
| if (m[1] === "1") { out.length = 0; first = i; } | |
| else if (m[1] !== String(out.length + 1)) continue; | |
| out.push({ key: m[1], label: m[2].trim() }); |
| 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)) { |
There was a problem hiding this comment.
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>
| // originOf is a URL's scheme, host and port, lowercased; undefined unless it | ||
| // is http or https. | ||
| export function originOf(url: string): string | undefined { | ||
| const m = url.trim().match(/^(https?):\/\/([^/?#@\s]+)(?=$|[/?#])/i); |
There was a problem hiding this comment.
P2: originOf accepts malformed authorities such as http://box:bad and http://:1379, so parsePairing can return unusable boxes that the phone page rejects with new URL. Validate the host and port before returning an origin.
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 packages/core/src/pairing.ts, line 48:
<comment>`originOf` accepts malformed authorities such as `http://box:bad` and `http://:1379`, so `parsePairing` can return unusable boxes that the phone page rejects with `new URL`. Validate the host and port before returning an origin.</comment>
<file context>
@@ -0,0 +1,111 @@
+// originOf is a URL's scheme, host and port, lowercased; undefined unless it
+// is http or https.
+export function originOf(url: string): string | undefined {
+ const m = url.trim().match(/^(https?):\/\/([^/?#@\s]+)(?=$|[/?#])/i);
+ return m ? `${m[1].toLowerCase()}://${m[2].toLowerCase()}` : undefined;
+}
</file context>
| export function pairingLink(boxes: PairedBox[]): string | undefined { | ||
| if (!boxes.length) return undefined; | ||
| const json = JSON.stringify(boxes.map((b) => ({ name: b.name, url: b.url, token: b.token }))); | ||
| return `${originOf(boxes[0].url)}/#pair=${toBase64url(utf8(json))}`; |
There was a problem hiding this comment.
P2: pairingLink interpolates undefined when the first box URL has no valid origin, producing undefined/#pair=.... Return undefined after checking the first origin before constructing the link.
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 packages/core/src/pairing.ts, line 20:
<comment>`pairingLink` interpolates `undefined` when the first box URL has no valid origin, producing `undefined/#pair=...`. Return `undefined` after checking the first origin before constructing the link.</comment>
<file context>
@@ -0,0 +1,111 @@
+export function pairingLink(boxes: PairedBox[]): string | undefined {
+ if (!boxes.length) return undefined;
+ const json = JSON.stringify(boxes.map((b) => ({ name: b.name, url: b.url, token: b.token })));
+ return `${originOf(boxes[0].url)}/#pair=${toBase64url(utf8(json))}`;
+}
+
</file context>
| return `${originOf(boxes[0].url)}/#pair=${toBase64url(utf8(json))}`; | |
| const origin = originOf(boxes[0].url); | |
| if (!origin) return undefined; | |
| return `${origin}/#pair=${toBase64url(utf8(json))}`; |
| - `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. |
There was a problem hiding this comment.
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>
| if (!RULE.test(l) && !CHROME.some((re) => re.test(l.trim()))) lines.push(l); | ||
| } | ||
| while (lines.at(-1) === "") lines.pop(); | ||
| const tail = lines.slice(-count); |
There was a problem hiding this comment.
P3: meaningfulTail returns every line when count is 0, because slice(-0) is slice(0). Return an empty list for a zero count.
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 packages/core/src/screen.ts, line 28:
<comment>`meaningfulTail` returns every line when `count` is 0, because `slice(-0)` is `slice(0)`. Return an empty list for a zero count.</comment>
<file context>
@@ -0,0 +1,145 @@
+ if (!RULE.test(l) && !CHROME.some((re) => re.test(l.trim()))) lines.push(l);
+ }
+ while (lines.at(-1) === "") lines.pop();
+ const tail = lines.slice(-count);
+ while (tail[0] === "") tail.shift();
+ // Drop the indentation the lines share, so narrow cards show words.
</file context>
| const tail = lines.slice(-count); | |
| const tail = count > 0 ? lines.slice(-count) : []; |
feat(mobile): add Shipyard for iPhone
First of the phone app's PRs. The iPhone app (#209, stacked on this one) reads sessions, screens, transcripts and pairing links the way the desktop app does, so that code moves into
packages/core.What changes
packages/core/src:derive,format,net,screen,transcript, the pure half ofquestions, the agent half ofstate-model(state.ts), the conversation list helpers (conversation.ts, taken out ofconversation-store.ts) andpairing.ts(pairingLink, plus a newparsePairingwith its own base64url/UTF-8, since React Native lacks some globals)../x.tsimports andimport typefrom the plugin SDK, no DOM. Vite, Metro andnode --testall load it as it is.app/src/lib/<name>.tsre-exports each file, so no app import changes.check:core(in CI's app job and AGENTS.md) rejects imports from outside the package and runstscwith no DOM lib.pnpm testalso globs../packages/core/src/**/*.test.ts. New tests forparsePairing.fs.allowincludespackages/core(checked: the dev server serves it).No user-visible change.
Checks:
typecheck:plugins,check:titles/type/grid/csp/themes/core,pnpm test(632 pass),pnpm build,tsc -p e2e.🤖 Generated with Claude Code