Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
181 commits
Select commit Hold shift + click to select a range
d6776e7
feat(client-runtime): export pure shell and thread reducers
olafura Jun 19, 2026
67a28d3
feat(server): add `t3 tui` — terminal UI for monitoring threads
olafura Jun 19, 2026
88a456a
chore(tui): add dev scripts and document `t3 tui`
olafura Jun 19, 2026
651d50a
feat(tui): add theme module — status pills + relative time
olafura Jun 19, 2026
904084e
feat(tui): web-parity thread-list design
olafura Jun 19, 2026
1618a38
feat(tui): add @t3tools/tui package with JSX terminal UI
olafura Jun 19, 2026
4c1105c
refactor(server): move TUI out of server into @t3tools/tui
olafura Jun 19, 2026
d406787
refactor(tui): expose a declared type boundary, drop server jsx flag
olafura Jun 19, 2026
5355030
feat(tui): migrate the terminal UI to OpenTUI on Bun
olafura Jun 19, 2026
13e21e1
docs(tui): document the Bun requirement; build the package before launch
olafura Jun 19, 2026
b4015fc
fix(tui): render with the terminal's own palette
olafura Jun 19, 2026
86306e8
fix(tui): exit cleanly on a single Ctrl+C
olafura Jun 19, 2026
571df61
fix(tui): send prompt input by using onInput, not onChange
olafura Jun 19, 2026
5188b47
refactor(tui): split the monolith into components mirroring the web
olafura Jun 19, 2026
049c6ff
perf(tui): cache thread state with the web's warm-subscription engine
olafura Jun 19, 2026
191409d
fix(tui): theme the embedded terminal with the user's ANSI palette
olafura Jun 19, 2026
e24ee27
feat(tui): use the terminal and prompt together, with focus toggle + …
olafura Jun 19, 2026
e1f61d4
fix(tui): harden the thread cache (NUL key, deleted-thread retry loop)
olafura Jun 19, 2026
225150e
fix(tui): harden the TUI process lifecycle (IPC, auth, terminal restore)
olafura Jun 19, 2026
5b7b679
fix(tui): code-review UI fixes (new-thread, project list, render timer)
olafura Jun 19, 2026
c5e6963
feat(tui): add thread-action client ops to TuiClient
olafura Jun 19, 2026
0b6346c
feat(tui): plan/build toggle, thread-actions menu, and search filter
olafura Jun 19, 2026
2814259
test(tui): add bun:test harness and BDD specs
olafura Jun 19, 2026
13a9f2f
feat(tui): multiline reply editor with paste, no length cap
olafura Jun 20, 2026
62adab0
feat(tui): show tool calls, changed files, and a working indicator
olafura Jun 20, 2026
2ece1f9
feat(tui): show the proposed-plan card
olafura Jun 20, 2026
f3b1932
feat(tui): implement a proposed plan with ^Y
olafura Jun 20, 2026
3bff8a2
feat(tui): context-window usage meter in the header
olafura Jun 20, 2026
0190816
feat(tui): cycle through all pending approvals
olafura Jun 20, 2026
31490c4
feat(tui): checkpoint revert picker
olafura Jun 20, 2026
5efa3b1
feat(tui): show plan/build mode on the composer prompt
olafura Jun 20, 2026
a8697d2
feat(tui): answer pending user-input requests inline
olafura Jun 20, 2026
bbd6eb9
feat(tui): runtime-mode and plan/build options in the new-thread dialog
olafura Jun 20, 2026
c6226af
feat(tui): turn diff viewer
olafura Jun 20, 2026
ff61a49
feat(tui): model/provider picker
olafura Jun 20, 2026
fb1f59a
feat(tui): branch and worktree fields in the new-thread dialog
olafura Jun 20, 2026
d781034
feat(tui): typed status line with tone glyph + colour
olafura Jun 20, 2026
05f5e0d
feat(tui): animate the working indicator with a braille spinner
olafura Jun 20, 2026
7e606ca
feat(tui): turn separators in the conversation
olafura Jun 20, 2026
b22cfe7
feat(tui): collapsible work log for long tool runs
olafura Jun 20, 2026
1b9c4ff
fix(tui): stop global Ctrl-shortcuts shredding the reply draft
olafura Jun 20, 2026
382f711
feat(tui): resizable, scrollable prompt + edit in $EDITOR (^G)
olafura Jun 20, 2026
9e0be9b
feat(tui): copy and paste in the embedded terminal
olafura Jun 20, 2026
a57483d
feat(tui): web-style composer controls row + contextual footer
olafura Jun 20, 2026
50081a4
feat(tui): native <select> pickers for model, access and reasoning
olafura Jun 20, 2026
41e63aa
feat(tui): make the composer controls and pickers mouse-clickable
olafura Jun 20, 2026
771c19e
fix(tui): picker opens into the space above; robust clickable control…
olafura Jun 20, 2026
f925cec
fix(tui): apply the terminal's detected palette to the theme
olafura Jun 20, 2026
28f1346
fix(tui): strip NUL bytes from ChatView source
olafura Jun 20, 2026
56f4a36
fix(tui): leave plan mode when implementing a plan
olafura Jun 20, 2026
e86de90
feat(tui): red stop button to interrupt a running agent
olafura Jun 20, 2026
660d704
feat(tui): copy-on-select from the conversation timeline
olafura Jun 21, 2026
b20e3f3
revert(tui): drop custom copy-on-select from the timeline
olafura Jun 21, 2026
891217e
fix(tui): restore timeline copy-on-select; gate feedback on real OSC5…
olafura Jun 21, 2026
1bdfa4c
fix(tui): let the terminal own text selection/copy (disable mouse mot…
olafura Jun 21, 2026
2123673
feat(tui): show the "T3 Code" wordmark above Projects in the sidebar
olafura Jun 22, 2026
d76a41f
fix(tui): theme the embedded terminal with the detected palette again
olafura Jun 22, 2026
802993b
feat(tui): sidebar search box between the wordmark and Projects
olafura Jun 22, 2026
1cbdb2b
fix(tui): order sidebar projects by latest activity like the web
olafura Jun 22, 2026
77d8a90
feat(tui): per-file diff rendering with language highlighting + split…
olafura Jun 22, 2026
fb44265
feat(tui): full-thread "all changes" diff + clickable changed-files
olafura Jun 22, 2026
8dc80e0
feat(tui): chat-bubble message layout (user right, assistant left)
olafura Jun 22, 2026
70bcec9
fix(tui): render the user message bubble with markdown + a definite w…
olafura Jun 22, 2026
b7e5e77
fix(tui): right-align the user bubble via alignSelf (was collapsing o…
olafura Jun 22, 2026
15c2224
fix(tui): make the user bubble actually render (definite-width right-…
olafura Jun 22, 2026
95090f5
feat(tui): more breathing room around chat messages
olafura Jun 22, 2026
2f18e20
Mirror web work-log collapsing in the TUI timeline
olafura Jun 22, 2026
08dda19
Fold settled turns behind a "Worked for" row in the TUI
olafura Jun 22, 2026
e7e6fba
Center icon registry + changed-files directory tree with collapse-all
olafura Jun 22, 2026
c3275b4
Move composer controls into the box (model-first) + Shift+Tab plan/build
olafura Jun 22, 2026
4f37156
Add pure git quick-action + menu logic for the TUI
olafura Jun 22, 2026
aa46f6f
Wire git VCS status + stacked actions into the TUI client
olafura Jun 22, 2026
118f23b
Add the right-side source-control panel (^L)
olafura Jun 22, 2026
9bfc499
Add web-parity BDD batch (keyboard table + backlog specs)
olafura Jun 22, 2026
1f38a5f
Add commit-message dialog for right-panel git actions
olafura Jun 22, 2026
17cac97
Add thread next/prev (Alt+↑/↓) + jump (Alt+1…9)
olafura Jun 22, 2026
bfe36d6
Per-file "View diff" + file-type colored icons in changed files
olafura Jun 22, 2026
7b1d1ad
Add a command palette (^K), replacing the mnemonic actions menu
olafura Jun 22, 2026
44ef583
Trim the dead actions-menu branch (ThreadActionsMenu → ThreadOverlays)
olafura Jun 22, 2026
ddf9783
Wire the right-panel Pull action (and hint for publish)
olafura Jun 22, 2026
35fdb35
Support multiple terminals per thread (terminal groups as tabs)
olafura Jun 22, 2026
3d3b344
Keep terminal tabs alive so switching is instant and lossless
olafura Jun 22, 2026
7eb71b9
Show image attachments as a link until OpenTUI renders images inline
olafura Jun 22, 2026
4bd213b
Hardening pass: fix terminal/palette/git/attachment edge cases
olafura Jun 22, 2026
1a0cfcb
Add a workspace file browser (Files surface)
olafura Jun 22, 2026
0fc9ec1
Keep the composer persistent; match the web ChatComposer naming
olafura Jun 22, 2026
85432ca
Let the composer type a custom answer to a pending question
olafura Jun 22, 2026
09439c2
Add a Settings / reference overlay (palette → Settings)
olafura Jun 22, 2026
7109c1e
Float pickers/palette/confirm above the persistent composer
olafura Jun 22, 2026
91cf85d
Reconcile branch with main after rebase
olafura Jun 22, 2026
b1f0559
fix(tui): keep pending requests open on transient failures
olafura Jul 2, 2026
fb6c654
chore: reconcile branch with main after rebase (pnpm 11 + effect diag…
olafura Jul 8, 2026
6554703
fix(tui): terminal scrollback scrolling + discover server-side termin…
olafura Jul 13, 2026
b66f5f9
fix(tui): prevent bracketed-paste injection in the terminal drawer
olafura Jul 13, 2026
023455e
chore(tui): reconcile with upstream/main after rebase
olafura Jul 13, 2026
aff2d53
feat(opentui-image): add kitty image extension
olafura Jul 13, 2026
5a2c52e
feat(tui): render image attachments inline
olafura Jul 13, 2026
44306fa
feat(server): support bounded base64 workspace reads
olafura Jul 13, 2026
97cc55e
feat(tui): stage and send workspace images
olafura Jul 13, 2026
870bf9c
test(tui): track remaining web parity gaps
olafura Jul 13, 2026
54aa10d
feat(tui): expand inline images on click
olafura Jul 13, 2026
c99dd40
fix(tui): stabilize image preview transitions
olafura Jul 13, 2026
bdecb90
fix(tui): wait for composer acknowledgements
olafura Jul 13, 2026
1a29096
feat(tui): pass kitty images through tmux
olafura Jul 13, 2026
93b3bdd
fix(tui): keep kitty images within the viewport
olafura Jul 13, 2026
11b4cd9
fix(tui): restore cursor after kitty placement
olafura Jul 13, 2026
79e657b
fix(tui): stabilize tmux startup and scrolling
olafura Jul 13, 2026
5cfe9ed
test(tui): model new-thread web parity
olafura Jul 15, 2026
5de0404
fix(tui): make new-thread creation atomic
olafura Jul 15, 2026
132aeb6
feat(tui): resolve model option defaults
olafura Jul 15, 2026
7a248b2
fix(tui): apply model and effort on turns
olafura Jul 15, 2026
eedd91b
fix(tui): remove unsafe palette probe
olafura Jul 16, 2026
04e2448
test(tui): audit missing server-backed client features
olafura Jul 16, 2026
c9bd09a
feat(tui): add terminal clear and restart actions
olafura Jul 16, 2026
7489e61
refactor(shared): share terminal link parsing
olafura Jul 16, 2026
cf242ac
feat(tui): link terminal URLs
olafura Jul 16, 2026
424300f
feat(tui): make source control panel usable
olafura Jul 16, 2026
2d7f299
feat(tui): align composer and terminal with web layout
olafura Jul 17, 2026
7ec1fa6
fix(tui): make plan mode selection responsive
olafura Jul 17, 2026
ca64c81
feat(opentui-image): add rich clipboard paste protocol
olafura Jul 17, 2026
7fcd839
feat(tui): paste clipboard images into prompts
olafura Jul 17, 2026
eb9cdaa
fix(tui): stabilize images while scrolling
olafura Jul 17, 2026
b21c5f0
fix(opentui-image): position tmux images with placeholders
olafura Jul 17, 2026
5b9c696
fix(tui): preserve scroll after image preview
olafura Jul 17, 2026
eccb87c
feat(tui): reuse composer for new threads
olafura Jul 17, 2026
7d6bfbc
feat(tui): link timeline URLs
olafura Jul 17, 2026
1460896
fix(tui): render visible terminal cursor
olafura Jul 17, 2026
bdefda3
fix(tui): keep moved terminal cursor visible
olafura Jul 17, 2026
eeb31cb
feat(tui): select workspace and branch for new threads
olafura Jul 17, 2026
d9a1d4e
fix(tui): align responsive chat layout with web
olafura Jul 21, 2026
361cfee
fix(tui): keep timeline content in viewport
olafura Jul 21, 2026
113f744
fix(tui): align timeline with composer column
olafura Jul 21, 2026
19cdbc8
feat(tui): support auto runtime mode
olafura Jul 27, 2026
c7f2678
feat(tui): personalize new-thread prompt
olafura Jul 27, 2026
fb69da4
feat(tui): make new-thread project selectable
olafura Jul 27, 2026
5e24295
fix(tui): use selected project for new threads
olafura Jul 27, 2026
4b7470e
fix(tui): compose immediately for selected projects
olafura Jul 27, 2026
ca55cae
fix(tui): fill terminal layout after focus switch
olafura Jul 27, 2026
b109109
fix(terminal): sync persisted instances across clients
olafura Jul 27, 2026
8778db1
feat(tui): adopt flat lifecycle sidebar
olafura Jul 28, 2026
2db67b8
fix(tui): restore Markdown visual hierarchy
olafura Jul 28, 2026
62e88df
fix(tui): restore collapsed message expansion
olafura Jul 28, 2026
1cbb91a
fix(tui): sync terminal closures across clients
olafura Jul 28, 2026
af27e94
fix(server): broadcast persisted terminal closures
olafura Jul 28, 2026
e3210ee
fix(tui): close the final terminal reliably
olafura Jul 28, 2026
7bf2b82
fix(web): sync externally closed terminals
olafura Jul 28, 2026
631d75f
fix(tui): implement VCS ref cache invalidation
olafura Jul 29, 2026
c62606f
docs(opentui-image): preserve upstream attribution
olafura Jul 30, 2026
5278975
fix(tui): restore prompt focus and toggle active pickers
olafura Jul 30, 2026
38a3d85
fix(tui): restore project onboarding from flat sidebar
olafura Jul 30, 2026
e66ab99
fix(tui): match web project onboarding flow
olafura Jul 30, 2026
2a1ad1a
feat(tui): attach pasted prompt image paths
olafura Jul 30, 2026
c124308
fix(tui): expand prompt image paths
olafura Jul 30, 2026
86e34ad
fix(tui): attach image paths embedded in prompts
olafura Jul 30, 2026
3146e57
fix(tui): split muted palette into readable dim and decorative faint
olafura Jul 30, 2026
1fa87a6
fix(tui): drop grey selection backgrounds and adopt faint decoration
olafura Jul 30, 2026
adb3299
fix(server): pin TERM/COLORTERM for embedded terminal sessions
olafura Jul 30, 2026
22e4554
fix(tui): advertise truecolor for terminals that drop COLORTERM
olafura Jul 30, 2026
561289d
refactor(tui): route status colours through semantic palette roles
olafura Jul 30, 2026
0266967
chore(tui): log detected colour capabilities at startup
olafura Jul 30, 2026
6464867
fix(tui): forward colour env to the renderer in SSH sessions
olafura Jul 30, 2026
73444c4
feat(tui): settle and un-settle threads from the command palette
olafura Jul 30, 2026
3bac51a
fix(tui): scroll the sidebar like a normal list
olafura Jul 30, 2026
438d3df
refactor(tui): give the sidebar a real scrollbox
olafura Jul 30, 2026
5c2693d
fix(tui): render user image attachments inside the message bubble at …
olafura Jul 30, 2026
138f31b
refactor(opentui-image): drop the scroll-pause caption
olafura Jul 30, 2026
80084e8
fix(tui): double the inline image preview budget
olafura Jul 30, 2026
6eba895
fix(server): gate base64 workspace reads to image extensions
olafura Aug 1, 2026
744053f
fix(tui): stop prose pastes from being read as image paths
olafura Aug 1, 2026
3677a5e
fix(tui): keep diff content lines from clobbering the file path
olafura Aug 1, 2026
2b13371
fix(tui): clip labels by display width, not code units
olafura Aug 1, 2026
4f30e9f
fix(web): deliver every terminal closed event
olafura Aug 1, 2026
ce7426f
fix(server): publish closed events for persisted-only terminals
olafura Aug 1, 2026
8ba8b45
fix(opentui-image): report clipboard read failures once
olafura Aug 1, 2026
af9f325
perf(tui): skip redundant timeline work per stream delta
olafura Aug 1, 2026
eb019cc
fix(server): bundle the TUI in release artifacts
olafura Aug 1, 2026
3211709
fix(server): validate runtime state before TUI launch
olafura Aug 1, 2026
e5d091b
perf(web): avoid streaming terminal output for close sync
olafura Aug 1, 2026
a445500
perf(tui): bound mounted conversation history
olafura Aug 1, 2026
f3298c1
fix(tui): refresh sidebar at time boundaries
olafura Aug 1, 2026
748d9c9
perf(tui): remove the repainting working spinner
olafura Aug 1, 2026
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@ brew install --cask t3-code
yay -S t3code-bin
```

### Terminal UI (over SSH, no port forwarding)

If you run a T3 Code server on a remote machine, you can monitor and drive its
threads from a terminal UI that talks to the already-running local server — no
port forwarding required. The TUI renders with [OpenTUI](https://opentui.com)
and runs on [Bun](https://bun.sh), so install Bun on the box first:

```bash
ssh my-remote-box
curl -fsSL https://bun.sh/install | bash # if Bun isn't already installed
t3 tui # or: npx t3@latest tui
```

`t3 tui` (Node) bootstraps auth and launches the UI in a Bun subprocess; if Bun
isn't on `PATH` it prints an install hint and exits.

The prompt is always ready: pick a thread with `↑`/`↓` and just start typing,
then press `Enter` to send. Conversations render as Markdown and follow the
latest reply; scroll with `PgUp`/`PgDn`. The TUI also lets you approve/deny tool
prompts (`^A`/`^R`), interrupt a running turn (`^G`), start new threads (`^N`),
and attach to a thread's terminal (`^E`; `Ctrl-Q` detaches). Start a server
first with `t3 serve` if one isn't already running. Long conversations stay
responsive by showing a bounded page; use the earlier/newer rows to move through
older history.

## Some notes

We are very very early in this project. Expect bugs.
Expand Down
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-node": "0.9.4",
"@opencode-ai/sdk": "^1.3.15",
"@opentui/core": "^0.4.1",
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0",
"sharp": "^0.34.5",
"yaml": "catalog:"
},
"devDependencies": {
Expand Down
21 changes: 20 additions & 1 deletion apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import {
ServerCliDevelopmentIconTargetMissingError,
ServerCliPublishIconSourceMissingError,
ServerCliPublishIconTargetMissingError,
ServerCliTuiBundleImportError,
} from "./cliErrors.ts";
import { findUnresolvedTuiBundleImport } from "./tuiBundle.ts";

interface PackageJson {
name: string;
Expand Down Expand Up @@ -162,6 +164,23 @@ const buildCmd = Command.make(
}),
);

const tuiEntry = path.join(repoRoot, "apps/tui/dist/index.js");
const tuiTarget = path.join(serverDir, "dist/tui/index.js");
if (!(yield* fs.exists(tuiEntry))) {
return yield* new ServerCliBuildAssetMissingError({ assetPath: tuiEntry });
}
yield* fs.makeDirectory(path.dirname(tuiTarget), { recursive: true });
yield* fs.copyFile(tuiEntry, tuiTarget);
const tuiBundle = yield* fs.readFileString(tuiTarget);
const unresolvedImport = findUnresolvedTuiBundleImport(tuiBundle);
if (unresolvedImport !== null) {
return yield* new ServerCliTuiBundleImportError({
assetPath: tuiTarget,
specifier: unresolvedImport,
});
}
yield* Effect.log("[cli] Bundled TUI entry into dist/tui");

const webDist = path.join(repoRoot, "apps/web/dist");
const clientTarget = path.join(serverDir, "dist/client");

Expand Down Expand Up @@ -223,7 +242,7 @@ const publishCmd = Command.make(
const packageJsonPath = path.join(serverDir, "package.json");

// Assert build assets exist
for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
for (const relPath of ["dist/bin.mjs", "dist/client/index.html", "dist/tui/index.js"]) {
const abs = path.join(serverDir, relPath);
if (!(yield* fs.exists(abs))) {
return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });
Expand Down
18 changes: 17 additions & 1 deletion apps/server/scripts/cliErrors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { assert, describe, it } from "@effect/vitest";

import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts";
import {
ServerCliBuildAssetMissingError,
ServerCliCommandExitError,
ServerCliTuiBundleImportError,
} from "./cliErrors.ts";

describe("server CLI errors", () => {
it("preserves failed command context without changing its message", () => {
Expand Down Expand Up @@ -28,4 +32,16 @@ describe("server CLI errors", () => {
"Missing build asset: /repo/server.mjs. Run the build subcommand first.",
);
});

it("identifies an unresolved TUI runtime import", () => {
const error = new ServerCliTuiBundleImportError({
assetPath: "/repo/dist/tui/index.js",
specifier: "@xterm/headless",
});

assert.equal(
error.message,
"TUI bundle contains an unresolved runtime import (@xterm/headless): /repo/dist/tui/index.js",
);
});
});
12 changes: 12 additions & 0 deletions apps/server/scripts/cliErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedErrorClass<Ser
return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`;
}
}

export class ServerCliTuiBundleImportError extends Schema.TaggedErrorClass<ServerCliTuiBundleImportError>()(
"ServerCliTuiBundleImportError",
{
assetPath: Schema.String,
specifier: Schema.String,
},
) {
override get message(): string {
return `TUI bundle contains an unresolved runtime import (${this.specifier}): ${this.assetPath}`;
}
}
27 changes: 27 additions & 0 deletions apps/server/scripts/tuiBundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";

import { findUnresolvedTuiBundleImport } from "./tuiBundle.ts";

describe("findUnresolvedTuiBundleImport", () => {
it("rejects private workspace imports left in the release bundle", () => {
expect(findUnresolvedTuiBundleImport('import { x } from "@t3tools/contracts";')).toBe(
"@t3tools/contracts",
);
});

it("rejects package lookups hidden behind createRequire", () => {
expect(
findUnresolvedTuiBundleImport(
'NodeModule.createRequire(import.meta.url)("@xterm/headless");',
),
).toBe("@xterm/headless");
});

it("allows explicit public and native runtime imports", () => {
expect(
findUnresolvedTuiBundleImport(
'import { createCliRenderer } from "@opentui/core";\nimport sharp from "sharp";',
),
).toBeNull();
});
});
9 changes: 9 additions & 0 deletions apps/server/scripts/tuiBundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const PRIVATE_WORKSPACE_IMPORT = /(?:from\s+|import\(|require\()\s*["'](@t3tools\/[^"']+)["']/u;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High scripts/tuiBundle.ts:1

OPAQUE_PACKAGE_REQUIRE requires ( immediately after the closing ) of createRequire(...), so lookups with whitespace between them (e.g. createRequire(import.meta.url) ("@xterm/headless")) are not detected — findUnresolvedTuiBundleImport returns null and the build passes while shipping a runtime dependency on unavailable node_modules. Additionally, PRIVATE_WORKSPACE_IMPORT only matches from, import(, and require( forms, so bare import "@t3tools/foo" and export { x } from "@t3tools/foo" also return null, letting unresolved private workspace packages through validation. Consider allowing optional whitespace/newlines between createRequire(...) and (, and adding import\s+ and export\s+.*from\s+ as recognized import forms.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/scripts/tuiBundle.ts around line 1:

`OPAQUE_PACKAGE_REQUIRE` requires `(` immediately after the closing `)` of `createRequire(...)`, so lookups with whitespace between them (e.g. `createRequire(import.meta.url) ("@xterm/headless")`) are not detected — `findUnresolvedTuiBundleImport` returns `null` and the build passes while shipping a runtime dependency on unavailable `node_modules`. Additionally, `PRIVATE_WORKSPACE_IMPORT` only matches `from`, `import(`, and `require(` forms, so bare `import "@t3tools/foo"` and `export { x } from "@t3tools/foo"` also return `null`, letting unresolved private workspace packages through validation. Consider allowing optional whitespace/newlines between `createRequire(...)` and `(`, and adding `import\s+` and `export\s+.*from\s+` as recognized import forms.

const OPAQUE_PACKAGE_REQUIRE = /createRequire\([^)]*\)\(\s*["']((?:@[^/"']+\/)?[^/"']+)["']\s*\)/u;

/** Find a package lookup that Bun left unresolved in the staged TUI bundle. */
export function findUnresolvedTuiBundleImport(source: string): string | null {
return (
source.match(PRIVATE_WORKSPACE_IMPORT)?.[1] ?? source.match(OPAQUE_PACKAGE_REQUIRE)?.[1] ?? null
);
}
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.terminalClear]: AuthTerminalOperateScope,
[WS_METHODS.terminalRestart]: AuthTerminalOperateScope,
[WS_METHODS.terminalClose]: AuthTerminalOperateScope,
[WS_METHODS.terminalList]: AuthTerminalOperateScope,
[WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope,
[WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope,
[WS_METHODS.previewOpen]: AuthOrchestrationOperateScope,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { sharedServerCommandFlags } from "./cli/config.ts";
import { projectCommand } from "./cli/project.ts";
import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
import { serviceCommand } from "./cli/service.ts";
import { tuiCommand } from "./cli/tui.ts";

const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);

Expand Down Expand Up @@ -50,6 +51,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
startCommand,
serveCommand,
pairCommand,
tuiCommand,
authCommand,
projectCommand,
serviceCommand,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down Expand Up @@ -186,6 +188,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down Expand Up @@ -270,6 +274,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down Expand Up @@ -339,6 +345,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down Expand Up @@ -393,6 +401,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down
62 changes: 6 additions & 56 deletions apps/server/src/cli/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,7 @@ import * as Option from "effect/Option";
import * as References from "effect/References";
import * as Schema from "effect/Schema";
import { Command, Flag, GlobalFlag } from "effect/unstable/cli";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http";
import { FetchHttpClient } from "effect/unstable/http";

import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerConfig from "../config.ts";
Expand All @@ -54,9 +49,12 @@ import {
resolveHeadlessConnectionString,
} from "../startupAccess.ts";
import { baseDirFlag, DurationFromString } from "./config.ts";
import {
type EnvironmentProbeResult,
isProcessAlive,
probeEnvironmentDescriptor,
} from "./runningServer.ts";

const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
const PAIR_PROBE_TIMEOUT = Duration.millis(2_500);
// Tailscale provisions an HTTPS certificate on the first request to a fresh
// serve mapping, which can take a few seconds.
const TAILSCALE_PROBE_ATTEMPTS = 5;
Expand Down Expand Up @@ -192,54 +190,6 @@ export const formatPairOutput = (input: {
"",
].join("\n");

/**
* Three outcomes, because they drive different decisions: a T3 descriptor
* (pair with it), nothing answering (safe to configure Tailscale Serve), or
* something answering that is not a T3 server (do NOT overwrite its mapping).
*/
type EnvironmentProbeResult =
| { readonly _tag: "descriptor"; readonly descriptor: ExecutionEnvironmentDescriptor }
| { readonly _tag: "unreachable" }
| { readonly _tag: "not-a-t3-server" };

const probeEnvironmentDescriptor = (
baseUrl: string,
): Effect.Effect<EnvironmentProbeResult, never, HttpClient.HttpClient> =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
const request = HttpClientRequest.get(new URL(WELL_KNOWN_ENVIRONMENT_PATH, baseUrl).toString());
const response = yield* client.execute(request).pipe(
Effect.timeout(PAIR_PROBE_TIMEOUT),
// Transport failure or timeout: nothing (reachable) is listening there.
Effect.mapError(() => ({ _tag: "unreachable" }) as const),
);
// Bad-gateway family means a proxy (Tailscale Serve) answered for a
// backend that is gone — a stale mapping, not a live occupant. Treating
// it as unreachable lets `t3 pair --tailscale` repair its own mapping
// after the server's port changed.
if (response.status === 502 || response.status === 503 || response.status === 504) {
return { _tag: "unreachable" } as const;
}
// Anything else that answered HTTP but not with a valid descriptor is
// some other service.
const descriptor = yield* HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)),
Effect.mapError(() => ({ _tag: "not-a-t3-server" }) as const),
);
return { _tag: "descriptor", descriptor } as const;
}).pipe(Effect.catch((outcome) => Effect.succeed(outcome)));

// signal 0 delivers nothing; it only reports whether the pid exists. EPERM
// means it exists but belongs to another user, which still counts as alive.
const isProcessAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error instanceof Error && "code" in error && error.code === "EPERM";
}
};

interface DiscoveredPairTarget {
readonly baseDir: string;
readonly variant: PairStateVariant;
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/cli/runningServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @effect-diagnostics nodeBuiltinImport:off - integration test owns a loopback HTTP server.
import * as NodeHttp from "node:http";

import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { FetchHttpClient } from "effect/unstable/http";

import type { PersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { isLivePersistedServerRuntimeState } from "./runningServer.ts";

const descriptor = {
environmentId: "running-server-test",
label: "running-server-test",
platform: { os: "linux", arch: "x64" },
serverVersion: "0.0.1",
capabilities: { repositoryIdentity: true },
};

const state = (origin: string, pid = process.pid): PersistedServerRuntimeState => ({
version: 1,
pid,
port: Number(new URL(origin).port),
origin,
startedAt: "2026-08-01T00:00:00.000Z",
});

const withServer = <A, E, R>(run: (origin: string) => Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.callback<NodeHttp.Server>((resume) => {
const server = NodeHttp.createServer((request, response) => {
if (request.url === "/.well-known/t3/environment") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(descriptor));
return;
}
response.writeHead(404);
response.end();
});
server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)));
}),
(server) => {
const address = server.address();
if (address === null || typeof address === "string") {
return Effect.die(new Error("Expected a TCP address"));
}
return run(`http://127.0.0.1:${String(address.port)}`);
},
(server) => Effect.sync(() => server.close()),
);

describe("live persisted server validation", () => {
it.effect("accepts a live pid whose origin serves a T3 descriptor", () =>
withServer((origin) =>
Effect.gen(function* () {
assert.isTrue(yield* isLivePersistedServerRuntimeState(state(origin)));
}),
).pipe(Effect.provide(FetchHttpClient.layer)),
);

it.effect("rejects a dead pid even when the origin was reused by T3", () =>
withServer((origin) =>
Effect.gen(function* () {
assert.isFalse(yield* isLivePersistedServerRuntimeState(state(origin, 4_194_305)));
}),
).pipe(Effect.provide(FetchHttpClient.layer)),
);

it.effect("rejects a live pid when the recorded origin is unreachable", () =>
Effect.gen(function* () {
assert.isFalse(yield* isLivePersistedServerRuntimeState(state("http://127.0.0.1:1")));
}).pipe(Effect.provide(FetchHttpClient.layer)),
);
});
Loading
Loading