Skip to content

refactor(app): share session state, screen and transcript reading in packages/core - #206

Open
pumfleet wants to merge 5 commits into
mainfrom
feat/core-package
Open

pumfleet wants to merge 5 commits into
mainfrom
feat/core-package

Conversation

@pumfleet

@pumfleet pumfleet commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

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 of questions, the agent half of state-model (state.ts), the conversation list helpers (conversation.ts, taken out of conversation-store.ts) and pairing.ts (pairingLink, plus a new parsePairing with its own base64url/UTF-8, since React Native lacks some globals).
  • Plain TypeScript: only relative ./x.ts imports and import type from the plugin SDK, no DOM. Vite, Metro and node --test all load it as it is.
  • app/src/lib/<name>.ts re-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 runs tsc with no DOM lib.
  • Tests moved with their code; pnpm test also globs ../packages/core/src/**/*.test.ts. New tests for parsePairing.
  • Vite's fs.allow includes packages/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

View guided diff Turn on auto-fix

pumfleet and others added 4 commits October 29, 2026 15:36
…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>
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
berth Ready Ready Preview Nov 1, 2026 3:31pm UTC
berth-docs Ready Ready Preview Nov 1, 2026 3:31pm UTC

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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"]

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: 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());

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: 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>
Suggested change
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());

Comment on lines +76 to +79
if (!m || out.some((c) => c.key === m[1])) continue;
if (!out.length) first = i;
out.push({ key: m[1], label: m[2].trim() });
}

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: 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>
Suggested change
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)) {

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>

// 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);

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: 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))}`;

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: 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>
Suggested change
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))}`;

Comment thread AGENTS.md
- `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>

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);

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: 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>
Suggested change
const tail = lines.slice(-count);
const tail = count > 0 ? lines.slice(-count) : [];

feat(mobile): add Shipyard for iPhone

This branch was successfully deployed

2 active deployments
Preview – berth-docs — 30ce5fa1 Deployed Nov 1, 2026 by vercel[bot]
Preview – berth — 30ce5fa1 Deployed Nov 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants