New @webnet/sftp package skeleton: big-endian SSH cursor (string/nameList/
mpint), SSH message constants, openssh-key-v1 Ed25519 key parse/serialize
with PKCS#8 seed wrapping and generateHostKey(), host-key/signature blob
codecs with Ed25519/RSA verification and SHA256 fingerprints, SFTP v3
packet/ATTRS codecs with Stat mapping and packet reassembly, SSH_FX status
to VFSError mapping, unix ls longname formatter, and lexical path helpers.
95 unit tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3bV2ANs8WMHxefD5pF9LN
Several worker.test.ts tests (WorkerSSHSession.resize()/close() flagged
as flaky in CI, plus other close()-message, callback-firing, and
pumpStreamToPort tests) waited a flat setTimeout(r, 10) after
postMessage before asserting the message arrived — the same race
already fixed for closed-state checks in b917cd8. Under CI load the
10ms sleep can elapse before the message is delivered, causing
sporadic AssertionErrors.
Added a generic waitFor(predicate, timeoutMs?) helper that polls via
setImmediate, replaced every fixed sleep with a poll on the actual
condition, and consolidated the four pre-existing
`while (!x.closed) await setImmediate()` spin-loops from b917cd8 onto
the same helper for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every job in the old checks.yml/test-node.yml/test-browser.yml
independently repeated checkout + tailscale submodule clone + npm ci,
and 5 of 7 also independently rebuilt the whole workspace via Turbo.
There was no caching anywhere.
Live smoke tests against this Gitea instance found actions/cache
(and setup-node's built-in cache: npm, same API) times out against
the built-in cache proxy - a server-side issue, not fixable from
workflow YAML - and actions/upload-artifact/download-artifact v4
refuse to run at all (GHES detection). v3 of the artifact actions
work correctly, so they're used as the handoff mechanism instead.
.gitea/workflows/checks.yml, test-node.yml, and test-browser.yml are
merged into one ci.yml. A new `install` job does the real work once
(submodule clone, npm ci, build) and hands node_modules plus Turbo's
local cache off via v3 artifacts to typecheck/typetest/node-tests/
browser-tests (all `needs: install`), instead of every job reinstalling
and rebuilding from scratch. lint/format never touched the submodule
or build output, so they drop that step and stay independent for fast
feedback. The shared checkout+submodule+setup-node preamble is now a
composite action, .gitea/actions/setup/action.yml (composite actions
require the repo to already be checked out, so actions/checkout stays
a separate first step in every job). A concurrency group cancels
superseded runs on the same ref.
The node_modules archive step must include per-package node_modules
overrides, not just the root: packages/xml and packages/vfs pin a
newer local TypeScript than the workspace root, installed by npm as
nested packages/{xml,vfs}/node_modules/typescript. Missing those in
the archive caused those two packages to silently typecheck against
the wrong TypeScript version.
Cross-run caching (reusing a previous run's install/build) isn't
available until the Gitea instance's cache backend is fixed
server-side - that's a separate, out-of-scope follow-up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace racy `setTimeout(r, 10)` waits with a `setImmediate` poll on
the `.closed` property. The 10 ms sleep was insufficient under load,
causing `readFrom()`/`read()`/`write()`/`accept()` to run before the
"closed" message was delivered, resulting in "packet conn closed"
instead of the expected "already closed" error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix `suggegestedName` typo in showSaveFilePicker call (extra "ge");
the browser silently ignored the unknown key so the Save dialog opened
with a blank filename on Chrome/Edge
- Defer URL.revokeObjectURL to setTimeout() so Safari's download
manager has a task boundary to open the object URL before revocation
- Add AI_CHANGES.md entry for the @webnet/react/@webnet/utils integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- example-app: replace local useClient duplicate with @webnet/react, add
useLocalStorage to persist SharedWorker preference across page reloads
- example-app: replace local fmtSize duplicate with @webnet/utils/fmtSize,
use download() util in WaitingFileDebug instead of inline logic
- test-app: add @webnet/utils dependency and expose it on window alongside
other globals for console testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts the useClient and useLocalStorage hooks into @webnet/react,
and the download, upload, readBlob, and fmtSize utilities into @webnet/utils,
so they can be shared by tailshare and other apps without living inside
a single app package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add instructions for agents to apply `Agentic` + `Agent/<model-line>`
labels to every PR they open (creating the labels via `tea labels create`
if absent), and to auto-finalise PRs without user prompting when the
work is trivial or was fully specified upfront.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IpnClientHandle.disconnect() JSDoc clarifies that the main-thread path
terminates IPN while the worker path is a soft detach
- connectWithFallback logs a console.warn with the caught error when falling
back from the SharedWorker path, so unexpected fallbacks are diagnosable
- IpnMainThreadHandle constructor comment explains that pre-run() callbacks
are intentionally dropped (run() snapshots current state on subscribe)
- lock?.release ?? null -> lock ? () => lock.release() : null to avoid
passing an unbound method reference
- Browser test: first test adds stateStorage: "memory" to avoid leaving IDB
state between runs
- Browser test: new test covers the IDB lock-contention path — acquires the
lock manually then confirms connectMainThread rejects immediately
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WorkerConfig.wasmUrl now accepts string | ArrayBuffer | ArrayBufferView,
allowing tests (and callers) to pass raw WASM bytes without going through
a URL fetch (useful in Node.js where file:// fetch is unsupported)
- Restructured connect.test.ts to share a single WASM+IPN instance across
tests via before()/after() hooks, preventing uncaught Go goroutine errors
that fired after sequential shutdown of multiple WASM runtimes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IpnClientHandle interface: shared contract for both SharedWorker client and
main-thread handle (connectionMode, store, state, running, fileOps, run,
disconnect)
- IpnMainThreadHandle: wraps IPN + Redux store; all IpnClient methods proxy
directly; run() fires user callbacks with current state and delegates future
state changes via runWithStore; disconnect() releases the lock and shuts down
- connectMainThread: IndexedDB-backed state uses a web lock keyed to the DB
name ("tsconnect-idb:<name>"), rejecting immediately if held; in-memory
state skips the lock entirely
- connectWithFallback: tries SharedWorker first, falls back to main thread if
unavailable, disabled (disableSharedWorker option), or on worker init failure
- useBuildIpnWorker updated to use connectWithFallback, returns IpnClientHandle
- Tests: 6 Node tests verifying main-thread init and fallback behaviour; 4
browser tests (Chromium + Firefox) verifying worker path and explicit
disable fallback via esbuild-bundled inline build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds connectMainThread(config) for browsers that do not support
SharedWorker (e.g. Chrome on Android before 2025). It mirrors the
worker's init path but runs in the main thread, returning an IPN
instance directly instead of a worker-proxy client.
Uses navigator.locks.request with { ifAvailable: true } to protect
against concurrent tabs; rejects immediately if the lock is already
held. The lock is released when the returned IPN.shutdown() resolves.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify that the second close() call returns false (the #closed guard path
added in the previous commit).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WorkerConn/WorkerTCPListener/WorkerPacketConn/WorkerSSHSession.close() sent a
"close" message but never called port.close(). The port stayed active until the
worker sent a "closed" reply, which never happens in tests. Node.js's --test
runner uses process.on('exit') rather than process.exit(), so an undrained event
loop (open MessagePort handles) caused the test process to hang indefinitely.
Fix: close() now calls this.#port.close() and immediately rejects pending
promises. WorkerSSHSession gains a #closed guard to make close() idempotent.
Test cleanup updated to call close() methods rather than leaking ports.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Always base PRs on origin/main (fetch before branching)
- Watch CI results after push and address errors
- Spawn autonomous Sonnet-class code review when PR work is complete
- Send push notification to user when work settles or needs unblocking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The setFileOpsConfig handler in worker.ts and the setFileOpsConfig method
in client.ts previously used duplicate inline type literals. Extracting
FileOpsLimits from protocol.ts ties both to a single source of truth so
the compiler catches any future skew.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add fileOpsMaxFiles, fileOpsMaxTotalSize, and fileOpsMaxFileSize to
WorkerConfig so callers can set initial Taildrop limits at startup rather
than having to call setFileOpsConfig() after connecting. The options are
passed straight through to FsaFileOps.createFromOpfs(). Runtime changes
via setFileOpsConfig() continue to work as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
src/index.ts imports ../dist/wasm_exec.js which only exists after build.sh
runs; the generic typecheck task only declares ^build (dependencies' build)
as a prerequisite, leaving the package's own dist absent in a fresh env.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a ControlledFileOps interface (UserIPNFileOps + observable limits +
onChange) satisfied by InMemoryFileOps and FsaFileOps. Bind its state
into a new fileOps Redux slice so file storage metrics and limits are
observable via store.getState().fileOps and broadcast to all clients.
In tsconnect-worker: hoist the FsaFileOps instance to module scope,
wire bindFileOpsToStore after init, and handle a new setFileOpsConfig
call that lets clients reconfigure maxFiles/maxTotalSize/maxFileSize
at runtime. Limit changes propagate back via the existing action
broadcast path so all connected clients see updated state immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tsx hardcodes keepNames:true in its esbuild transform, injecting a __name
helper at module scope. Playwright's page.evaluate() serializes callbacks
via .toString(), so the helper is absent in the browser context. Inject a
matching polyfill via addInitScript on every new page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- serveDirectory: replace regex path traversal guard with resolve+relative
check, which is simpler to reason about and handles encoded edge cases
- drop test:browser:coverage scripts; c8 only covers Node orchestration
code, not browser-executed code inside page.evaluate() — the resulting
lcov is nearly empty and would confuse CI coverage dashboards
- guard server?.close() in after() hooks so teardown doesn't throw a
TypeError if the before() hook failed to start the server
- extract setupLoopback() helper inside each page.evaluate() in
transport.browser.ts to avoid verbatim repetition between tests
- add comment in helpers.browser.ts explaining the IDB readonly-transaction
trick that ensures setState()'s async write has settled before opening
a second IndexedDBState instance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses Playwright as a library within node:test (not @playwright/test) to
keep the same runner and script conventions. Browser test files use the
*.browser.ts extension so the existing src/**/*.test.ts glob picks up zero
browser tests, leaving the regular test suite unaffected.
Key pieces:
- .npmrc: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 prevents binary downloads on
npm ci; browsers are installed explicitly in CI via playwright install
- @webnet/browser-test-utils: new private package exporting forBrowsers()
(iterates chromium + firefox, handles browser lifecycle within node:test
suite/before/after) and a serveDirectory() helper (minimal http.createServer
that serves a built dist/ or out/ directory so the browser can fetch ES
modules via dynamic import())
- test:browser / test:browser:coverage scripts added to transport, vfs,
tsconnect following the same c8 + lcov pattern as test:coverage
- turbo.json: test:browser and test:browser:coverage tasks depend on build +
^build (dist/ must exist before the browser can import from it)
- .gitea/workflows/test-browser.yml: CI pipeline that installs browsers with
--with-deps then runs npm run test:browser
Integration tests:
- DataChannelTransport: real RTCPeerConnection loopback (both peers in one
page context), exercises send/receive and close propagation
- FsaVFS: OPFS round-trip (writeFile/readFile), stat, readdir, delete
- IndexedDBState: multi-instance persistence (write via instance 1, open
fresh instance 2 and verify IDB round-trip), empty-DB initialisation
- FsaFileOps: write/read/stat/remove cycle and rename/listFiles over OPFS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move #fileSizes.set in openWriter to after all async FSA ops, so a
failed getFileHandle/createWritable/seek doesn't leave a phantom entry
inflating fileCount and totalSize
- Fix rename to stat the file when it wasn't previously tracked, so
untracked files don't become permanently invisible after a rename
- Document stat/totalSize inconsistency during active writes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds maxFiles/maxTotalSize/maxFileSize limit getters/setters,
fileCount/totalSize/openFiles getters, and onChange/offChange
notification to FsaFileOps, mirroring InMemoryFileOps. File sizes
are tracked in memory via a #fileSizes map; createFromOpfs now scans
the directory at startup so pre-existing files count toward limits.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Splits the VFS abstraction (AsyncVFS, Stat, VFSError, VFSErrorCode) and
its implementations (MemoryVFS, NodeVFS, FsaVFS) into a new standalone
@webnet/vfs package, following the same pattern as the @webnet/transport
split from @webnet/http.
packages/drive now depends on @webnet/vfs and imports directly from it.
packages/test-app likewise imports MemoryVFS and FsaVFS from @webnet/vfs.
The drive package.json export map drops the ./vfs/* sub-exports.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add "Reviewing pull requests" section to AGENTS.md: use `tea` (not
`gh`), validate assumptions with the user in interactive mode, and
post feedback directly on the PR in autonomous mode.
- Consolidate the AI disclosure out of README.md into a standalone
AI_CHANGES.md; README.md now links to it.
- Add .gitattributes with merge=union on AI_CHANGES.md so parallel
branches each appending entries never produce conflicts.
- Update the "AI disclosure" section in AGENTS.md to point agents at
AI_CHANGES.md instead of README.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bugs:
- Liveness detection now fires for all clients, including those that connect
while init is pending. Previously, the "hello" message was consumed by the
onFirst handler before registerClient set up its port.onmessage listener,
so navigator.locks.request was never called for the first tab and any tab
that connected during init. Fix: unified onHello handler in onconnect
extracts lockName from every "hello"; registerClient always takes lockName
and acquires the lock immediately.
- Remove dead bodyCallbacks field from bridgeDriveHandler pending map. The
map was allocated on every request but never read or populated; body chunk
reads go directly through entry.req.readBodyChunk().
- Drive cleanup in cleanupClient now only installs the no-op handler when no
other client still has driveRegistered = true, preventing the surviving
client's handler from being silently replaced.
Code quality:
- wrapDispatch: replace (s as any).dispatch with a two-step cast through
unknown — avoids the any escape hatch while satisfying RTK's overloaded
ThunkDispatch type.
- IndexedDBState.setState: add tx.onerror handler so failed IDB writes are
logged rather than silently dropped.
- pumpStreamToPort fire-and-forget in openWaitingFile fallback: add
.catch(() => {}) to make the intentional discard explicit.
- tsconfig.json: add skipLibCheck: true to match tsconfig.worker.json (both
transitively import @webnet/tsconnect which has an unresolved wasm_exec.js
declaration issue).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>