Skip to content

Feature/tensoragent ios - #200

Open
zhongkaifu wants to merge 73 commits into
mainfrom
feature/tensoragent-ios
Open

Feature/tensoragent ios#200
zhongkaifu wants to merge 73 commits into
mainfrom
feature/tensoragent-ios

Conversation

@zhongkaifu

Copy link
Copy Markdown
Owner

No description provided.

zhongkaifu and others added 30 commits September 1, 2026 23:40
… Darwin-aware managed layer

- TensorSharp.GGML.Native: TENSORSHARP_GGML_NATIVE_ENABLE_METAL / _IOS CMake options,
  Metal-only code gated on TSG_GGML_USE_METAL (the MPSGraph conv path was gated on
  __APPLE__, which the CPU-only simulator slice does not satisfy), build-ios.sh
  cross-builds device (Metal, embedded shader sources) + simulator (CPU) slices into
  build-ios/GgmlOps.xcframework and validates both slices.
- Backends.GGML: Metal is an Apple-platform backend (iOS/iPadOS/Mac Catalyst), the
  DllImport resolver maps GgmlOps to the main program image on iOS (static link),
  GgmlMemoryPool uses page-aligned mmap on every Darwin platform.
- Runtime/Models: no whole-file prefault on iOS (jetsam), madvise hints on iOS.
- csproj: the native desktop build/copy targets are skipped for the ios TFM.
- eng/fetch-python-ios.sh fetches the CPython iOS distribution TensorAgent embeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sation store, settings, Python bundle staging

- Catalog: Gemma 4 E2B/E4B (dense), Qwen3.5 9B, Qwen3.8 27B (2-bit, experimental), Gemma 4
  26B-A4B and Qwen3.6 35B-A3B MoE (16 GB devices), Qwen-Image-Edit 2511 - sizes and
  SHA-256 read from the Hugging Face tree API, gated by device memory tier.
- ResumableDownloader: Range resumption of the kept .part, streaming SHA-256 (prefix
  re-hashed on resume), retries with backoff, structured progress; ModelStore manages
  one folder per entry with companion files beside the weights.
- ConversationStore persists the Web UI's own message shape so a session can be handed
  back to the page unchanged; SettingsStore for the permission toggles.
- scripts/prepare-python.sh stages CPython 3.13 + numpy/Pillow (BeeWare wheels) + pure
  packages into the .fwork/framework layout iOS can load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ming, multipart uploads, per-launch token)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… can run in-process

Package D of TensorAgent (iOS). Every launch in TensorSharp.AgentHost — the shell
tool, background jobs, package installs, syntax checks, API probes and skill
scripts — now goes through one seam, IShellBackend, instead of reaching
ConfinedProcess/SpawnedProcess directly. ProcessShellBackend is the desktop
implementation and the default everywhere: it writes the wrapper script, builds
the ConfinedLaunch exactly as ShellRunner.RunIn did, launches under the detected
OS sandbox, and rewrites the wrapper-script path/line numbers on the way out.
Desktop results are unchanged; the existing shell, skill-script and drift-guard
suites pass as before.

What a host that cannot start processes (iOS) plugs into:

- ShellLaunch (command line + ShellSession, or argv) carries every host decision;
  IShellJob mirrors ConfinedJob so background jobs are held the same way.
- ShellRunner takes an IShellBackend plus optional ISkillSandbox/ShellProgram/
  ISyntaxVerifier/IApiProbe/IPackageInstaller (interfaces extracted from
  SyntaxCheck, ApiProbe and PackageInstaller; the concrete classes stay the
  defaults). CanRun, NetworkConfinementGuaranteed, UnavailableReason and the
  result's "Not confined on this host" line are all judged from the backend's
  sandbox, so an in-process backend with honest capabilities runs under
  Sandbox=Required without --code-exec-unconfined.
- ShellSession.Load()/Save()/MarkEnvironmentReset() persist cwd and env from the
  host side, parsing the shapes `export -p` emits and writing through the same
  PosixEnvFilter the wrapper pipes through, atomically (rename replaces a planted
  symlink rather than following it). CurrentDirectory/CurrentDirectoryLabel/
  TakeEnvironmentWasReset work identically for both backends.
- ShellProgram.InProcess(); CodeEnvironment.Configure()/Reset() override the PATH
  probes (AvailableTools, interpreter resolution, Python version, WhichCache,
  CodeDiagnostics install prefix) so the declaration describes an embedded runtime.
- SkillScriptRunnerOptions.Backend routes RunConfined through the seam as an argv
  launch; the second copy of the launch sequence is gone.
- iOS guards: PosixSpawn.IsSupported false on iOS/tvOS; SpawnedProcess.TryStart
  answers "this platform cannot start programs"; SkillPathGuard.PathComparison is
  Ordinal on iOS; ShellSession's Darwin open(2) flags apply on iOS/Catalyst; the
  libSystem.Native fstat import falls back to libc fstat with the pinned Darwin
  stat64 layout; InProcessSandbox (Name "in-process") is the iOS candidate in
  SkillSandboxFactory (writes/home/process tree confined, network NOT claimed).

Tests: FakeShellBackendTests + SkillScriptRunnerBackendTests (a recording fake
backend driving ShellRunner and SkillScriptRunner), ShellSessionStateSafetyTests
additions for Load/Save/parsers/reset marker/symlink write/Darwin fstat layout.
docs/agent_skills.md gains a "Launch backends" subsection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… builds and runs for iOS

Package C of TensorAgent. Every native-library media call - OpenCvSharp video frames
and MP4 writing, the ffmpeg tier, Magick.NET image decode/encode/resize - now goes
through TensorSharp.Models.Media.MediaCodecs, whose default managed provider covers
PNG/JPEG/BMP/GIF decode (StbImageSharp plus the existing hand-written PNG decoder),
PNG encode, bilinear/Lanczos resize and WAV/MP3/OGG audio, and whose desktop provider
(compiled only off iOS, registered by a module initializer) keeps today's behaviour
byte-for-byte. Video decode/encode without a provider refuse with a message naming
what is missing rather than falling back silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rp.Server

Package B of TensorAgent. The chat stack the Web UI drives - ModelService, the
generation pipeline, sessions, the skills request plan and streaming loop, every
request parser, the SSE frame builders, sampling defaults, upload policy - moves
verbatim into a new net10.0 class library that does not reference ASP.NET Core, so
the iOS app, the CLI and the server all run ONE implementation.

- Namespaces are unchanged (TensorSharp.Server.*), so the 48 coupled test files and
  the server's own call sites compile untouched.
- BackendCatalog splits: the pure descriptor table, Canonicalize and ToBackendValue
  move; the Cuda/MLX/GGML availability probes stay behind in the server as
  BackendCatalogProbes. ModelLifecycleService takes an injectable tensor-parallel
  group factory instead of referencing TensorSharp.Distributed.
- New WebUiChatService and SkillsService hold what WebUiAdapter and SkillsAdapter did
  minus the transport: preflight failures throw WebUiRequestRejectedException and the
  chat/image-edit/video streams are IAsyncEnumerable<object> of the exact frame
  objects, so the adapters shrink to HttpContext plumbing and the wire contract stays
  byte-identical.
- ModelService gains UnloadModel() and a scheduler-config override so a phone can free
  the model on a memory warning and size the KV pool itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iOS forbids Process.Start, so the agent host's ProcessShellBackend has
nothing to launch there. This is the replacement: a shell that parses and
runs the command itself, inside the app.

ShellSyntax/ShellExec cover the language a coding model actually writes --
pipelines, && || ; and subshells, redirections including heredocs, globs,
parameter expansion, command substitution, arithmetic, for/while/if/case,
and functions. ShellTest implements test/[/[[ ]] with the POSIX 1/2/3
argument rules. The builtin table covers the coreutils a model reaches for
(ls cat grep sed awk find sort cut tr head tail wc diff xargs tar unzip and
the rest), with awk being a real interpreter rather than a pattern match.

Every path a command touches is resolved through ConfinedPaths, so a write
outside WorkRoot, a symlink pointing out of it, and an archive member with
a ../ in its name are all refused rather than followed. Network commands
and script interpreters check ExecutionPolicy first, and say which switch
the user has to flip rather than failing silently.

63 tests pass. Six bugs the tests caught and this commit fixes:

  - `[ -n x ]` was a usage error: -n was missing from the unary operator set
  - `head -20` and `tail -5` ignored the count and printed ten lines
  - `sed -i 's/a/b/' f` ate its own script, because -i was parsed as taking
    a separate argument; GNU never does, and BSD's `-i ''` is now handled
  - awk read `toupper($1)` as a variable next to a group, because the call
    test looked at the space before the name instead of before the paren
  - awk's printf appended a newline that printf must not add
  - unzip and tar let a `../` member escape the extraction directory; the
    sandbox still contained it, but both now refuse it outright

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The IShellBackend seam already existed; this is the implementation below it
for a platform with no Process.Start. A ShellLaunch goes to InProcessShell
instead of to a wrapper script under Seatbelt, and comes back as the same
ConfinedResult the host reads everywhere else, so the ledger, the artifact
capture and every sentence the model sees are unchanged.

The launch's permissions become an ExecutionPolicy: WriteDirectory is the
work root, ReadOnlyDirectory and ReadablePaths are the readable roots, and
AllowNetwork carries straight across. ExecutionPolicy gains WritablePaths
so the host can still grant one exact file outside the work root without
granting its directory, and AllowLoopbackPort so the egress-proxy shape
survives the seam.

The sandbox reports what it actually enforces -- writes, network and home
reads confined by path resolution rather than by the kernel, and a bounded
process tree only because there are no processes. What it cannot do is
preempt: a builtin already inside a long call runs past its deadline, and
the doc comment says so rather than implying a kill.

Three defects the seam tests caught:

  - a per-call workdir was persisted, so `workdir` moved the whole
    conversation instead of one command
  - the saved directory used the resolved spelling (/private/var on macOS)
    which the session validates as outside its own workspace and discards,
    so `cd` never persisted; it is now mapped back to the caller's spelling
  - the saved exports were written but never read back, so `export` and
    `source .venv/bin/activate` did nothing on the next call

78 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page in the WebView is TensorSharp.Server's index.html byte for byte,
so the API under it has to answer the same paths with the same payloads
and the same event frames. WebUiRoutes binds all of them onto the shared
WebUiChatService and SkillsService: chat, models, sessions, uploads,
image edit, video generation and the skills management surface. What
changed is only the transport, HttpListener instead of minimal APIs,
because iOS has no ASP.NET Core runtime pack.

MapAgent adds what only a phone needs, under its own /api/agent prefix so
the shared surface stays exactly shared: the built-in catalog with each
entry's install state, a resumable download as an event stream, the saved
conversations, and the sandbox switches.

Two contract bugs fixed:

  - the event-stream response applied its headers before pulling a frame,
    so a refused chat request (no model loaded, unknown session) arrived
    as an empty 200 instead of a 400 with the reason. The first frame is
    now pulled before any header goes out, the way the desktop adapter
    does it, and the chat service's rejection is translated into the
    transport's own status-carrying exception.
  - the catalog serialized its family and kind enums as numbers, which
    the UI cannot read; they go out as names.

93 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AgentAppHost is what Program.cs is for the desktop server: it builds the
whole object graph from a directory and a settings file. The model service
and its sessions, the skills registry, the code runner over the in-process
shell backend, and the loopback server with the Web UI in front of it.

It lives in the platform-neutral project on purpose. An iOS app cannot be
unit-tested from a terminal, so the wiring is only ever checked if it can
be started, driven over real HTTP and torn down on a development machine,
which is what AgentAppHostTests does.

Model weights go under the cache root and conversations under the data
root, because a 6 GB file that can be downloaded again must not be pushed
into the user's iCloud backup while a transcript, which cannot be
recovered any other way, must.

ConversationRecorder solves the problem the Web UI creates by holding its
history only in the page: on a phone the app is killed constantly, so the
transcript is written host-side instead. The engine session and the saved
conversation are different things with different lifetimes -- a resumed
chat always gets a fresh session, because the KV cache did not survive --
so /api/sessions binds the two and hands the saved messages back for the
page to re-render.

Metal is the default backend and the first one offered; the CPU entry
stays for the simulator, which has no usable GPU.

107 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MAUI head now hosts the real thing instead of a canned stub. What is
left in the iOS project is only what cannot be tested off-device: which
directory the system gives for backed-up data versus re-fetchable files,
how much memory the device has, and where the bundle put the Web UI.

CPython is linked as a dynamic framework with its standard library staged
into the bundle, chosen per slice, so the interpreter's P/Invokes resolve
through the main program handle the same way GgmlOps does.

Verified in the simulator. The app launches, the loopback server binds,
GgmlOps resolves from the main program image, and the Web UI creates a
session through the real route:

  TensorAgent loopback server listening on http://127.0.0.1:51086
  engine probe: TSGgml_CanInitializeBackend(Cpu)=True, (Metal)=False;
    TSGgml_* resolved from the main program image
  Created session via /api/sessions: 0bd64ef68245...

Metal is correctly absent in the simulator slice and the probe says so
rather than pretending. Driving /api from the Mac returns the catalog
with all eight entries and their install state, the models payload with
Metal offered first, and the conversation the page just started.

Two inconsistencies the run exposed: the conversation summary serialized
PascalCase while every other payload is camelCase, and skill scripts were
left off by a build-time default instead of following the user's own code
execution switch.

107 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A skill on a phone is a different proposition from a skill on a
workstation. There is no pip at run time, no browser to drive, no child
process to spawn, and the only interpreter is the CPython staged into the
bundle. A skill whose instructions are excellent but whose scripts import
something that is not there fails on the user's first attempt, which is
worse than not offering it.

scripts/verify-skills.py decides by inspection rather than by optimism.
For each skill it parses every script, resolves each import against the
staged standard library, the staged site packages and the skill's own
files, and refuses anything reaching for a capability iOS does not have.
Twelve of nineteen pass and are bundled; skills/verdicts.json records
every verdict with its reason.

The seven that do not, and what blocks each:

  docx, pptx, xlsx    lxml and defusedxml are not in the bundled runtime,
                      and their validators shell out to LibreOffice
  pdf                 pdfplumber is missing and pdf2image shells out to
                      poppler, which cannot be bundled
  skill-creator       subprocess and webbrowser
  webapp-testing      playwright needs a browser engine
  mcp-builder         an MCP server needs a process and a socket

Two flaws in the checker itself, found by reading its own output: it
tested availability before unavailability, so subprocess and webbrowser
passed because they ARE in the standard library -- importable is not
usable -- and it only recognised a sibling package with an __init__.py,
missing the implicit namespace packages these skills actually use.

Verified in the simulator: the running app reports "12 skills" and lists
all twelve through /api/skills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three pages the Web UI cannot have, because they are about a device rather
than a server: the model catalog with per-entry size, licence and install
state and a resumable download; the saved chats; and the sandbox switches,
written as what they control rather than as feature names.

The chat page gains the row a browser cannot offer -- photo, camera,
video, file and dictation. Attachments upload through the same
/api/upload the paperclip uses and are handed to the page's own
attachment list, so one picked natively and one picked in the page are
the same thing by the time a message is sent. Dictation goes onto Apple's
recogniser directly rather than through a toolkit package, and asks for
on-device recognition: a chat app that runs its model locally must not
ship the user's microphone to a server.

The companion script was written but never served. index.html is
TensorSharp.Server's byte for byte and must stay that way, so it is now
embedded in the assembly and appended as one script tag at request time.
That is what makes session resume, native attachments and dictated text
work at all -- and what lets the app replace the empty state's "start
TensorSharp.Server with --model" with instructions that make sense on a
phone.

Verified in the simulator: the model list renders all eight entries with
their sizes and licences, and the chat's empty state now points at the
Models page. TENSORAGENT_START_PAGE lets the harness screenshot a page
other than the chat, since simctl cannot tap.

Also includes the embedded CPython runtime's own corrections, found by
its author testing rather than reading: PyConfig carried a field the
shipping 3.13 header does not have, a layout sentinel expected the
Python-config default instead of the isolated one, and the wheel
extractor wrote a member before refusing the archive.

207 tests pass, 12 skipped (the live-interpreter tests, which need
TENSORAGENT_PYTHON_ROOT).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The iOS media provider, over ImageIO/CoreGraphics/AVFoundation, so a
multi-modal model can read what a phone actually produces. HEIC is the
reason it exists: it is the default camera format and the managed decoder
cannot read it at all. Orientation goes through the shared ExifOrientation
so all three providers agree byte for byte, and the two pieces most likely
to hide a wrong constant -- premultiplied-alpha recovery and the display
matrix -- live outside the iOS guard so a desktop test can execute them.

The embedded CPython and JavaScriptCore engines are now discovered by the
host rather than passed in as null, so the shell can dispatch python3 and
node. Both report their own availability with a reason, and the engine
line says what is missing before a model tries to use it.

A startup self-test runs representative commands through the real backend
and logs what each did, because the failures that matter here are not
compile errors: an interpreter that links but cannot find its standard
library produces an app that starts perfectly and fails on first use. On
the simulator all eleven checks pass:

  shell, files, awk; python 3.13.14 with its stdlib, numpy and Pillow;
  node and node -p; a write outside the workspace refused; the network
  refused.

Four defects it found, none of which a build would have:

  - the 77 compiled extension modules never reached the bundle. iOS
    refuses to dlopen a Mach-O outside a signed framework, which is why
    each .so ships as its own framework with a .fwork placeholder where
    it used to be; the placeholders were staged and the frameworks were
    not, so every `import binascii` failed and most of the standard
    library went with it. They are embedded, not linked: Python resolves
    them by dlopen, and force-loading 76 dylibs would cost every launch.
  - the audit hook banned ctypes.dlopen outright. On this platform that
    does not stop an attacker, it stops `import numpy`. The rule is now
    about which library: inside the app's own bundle is code that shipped
    signed with the app; everything else is refused as before, on the
    resolved path so a symlink cannot walk out. dlsym and call_function
    stay refused, so a handle cannot be used to reach anything.
  - dlopen(NULL) was refused with it. That returns a handle to the image
    already running and loads nothing; numpy and Pillow both ask for one
    while probing. Allowed for that reason and no other.
  - `node -p` printed nothing, because it was mapped to -e and the
    completion value was discarded -- a command that looks to a model
    like it succeeded and produced no output.

Also narrows two project assertions that used Assert.Single on the whole
csproj and broke as soon as the head bundled more than one thing.

TensorAgent: 219 pass, 12 skipped. Repo: 3055 pass, 1 pre-existing
load-sensitive benchmark that passes in isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t found

An end-to-end suite that loads a real model and drives the real API, so
the one question the hermetic tests cannot answer gets answered: does a
person typing into this app get a useful conversation back.

Measured with Gemma 4 E4B Q8_0 on CPU, four turns in one session:

    turn 1   prompt 2812   reused    0   ( 0.0%)
    turn 2   prompt 2932   reused 2908   (99.2%)
    turn 3   prompt 3019   reused 2996   (99.2%)
    turn 4   prompt 3105   reused 3082   (99.3%)

Only the new message and the previous answer are processed each turn, and
the model recalls facts from every earlier turn. Starting a new chat drops
reuse to zero; rewriting an earlier turn invalidates from the point the
histories diverge and the model then answers from the rewritten history,
not the old one.

Three real defects the suite found, none of which any unit test would:

  - Shutting down released the engine before it stopped serving. A request
    in flight is usually inside the model, so freeing it first unmapped
    weights that native compute threads were still reading, and the
    process died with a segmentation fault in an unrelated-looking kernel.
    Stopping a generation partway hit this every time. The server is now
    closed first and waits for in-flight requests to actually finish.

  - A client that walked away never stopped the work being done for it.
    HttpListener has no disconnect event, so a failed write is how it is
    learned -- and it has to be learned, or the Stop button aborts the
    fetch while the model generates into nothing for the rest of its
    budget, holding the session and draining the battery.

  - An answer was only written down when the NEXT request carried it in
    its history, so a user who asked a question, read the answer and
    switched away lost exactly the answer they were reading. The turn is
    now closed when it ends, reassembled from the same frames the page
    reads, and an aborted turn is saved too because the partial answer is
    what the user is looking at.

Verified across an app restart, which is the phone's real multi-turn
story: the transcript survives, the resumed chat re-prefills once (reuse
0, correctly, since the KV cache did not survive), the model still knows
what it was told before the restart, and the next turn is back to 99.5%
reuse.

223 pass, 21 skipped without weights or a live interpreter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
verify-sim.sh still asserted the demo stub's behaviour: a fixed
max-tokens, a canned SSE round trip, an engine route that moved. It now
checks what the app actually promises -- Metal offered first, a session
that binds a conversation, a catalog covering both families and all three
architectures, the sandbox defaulting to code-on/network-off, the
companion script served, and every startup self-test line.

Two things it caught in the process:

  - The page was being read into a string and written back, which drops a
    byte-order mark and normalises the encoding. The served file was
    therefore no longer the Server's, which defeats the whole reason
    there is no second copy of index.html to keep in step. The script tag
    is now spliced into the bytes.
  - The script presented the launch token as a bearer header the server
    does not accept. It uses the cookie the WebView uses; adding a header
    purely for a test script would widen the surface for a convenience.

Also portable to the bash 3.2 macOS ships, and the byte-identity property
is now pinned by a unit test rather than only on device.

Every simulator check passes: the page, the token gate, the engine line
(python 3.13.14, JavaScriptCore, 12 skills), the catalog, the sandbox
defaults, eleven self-test checks, and the media probe's HEIC decode,
EXIF orientation, MP4 round trip and audio read.

224 pass, 21 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The top-level README gains a highlight, DEVELOPMENT.md gains the project
structure, and TensorAgent/README.md is rewritten from the spike notes it
still carried into a description of the app that now exists: what it does,
how to build and run it, how it differs from the desktop and why, which
skills are bundled and what blocks the rest, and the measured KV cache
reuse across a four-turn conversation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five settings were written to disk and shown in Settings while nothing read
them. A switch that appears to control something and controls nothing is
worse than no switch, so each is now either honoured or gone:

  - "Show reasoning by default" and the default skill selection are
    properties of the page's own controls, so the companion script applies
    them at load. A resumed conversation overrides them afterwards, which
    is the right precedence: what the user last did in THIS chat beats
    what they chose as a default.
  - "Keep the screen awake" is held for exactly the stretch the model is
    working. The page had no event for that, so the script wraps its send
    and reports both edges; on iOS the screen sleeping suspends the app
    and stops a generation partway.
  - "Download over cellular" is enforced before a five-to-ten gigabyte
    download starts, and says which setting to change.
  - "Ask before running" is removed. Enforcing it means holding a tool
    call open for a user decision mid-stream, which is real work; leaving
    the switch there implying it happens is not honest.

Two more things the simulator run exposed:

  - Every launch left an empty chat behind, because the page creates a
    session on load and a session binds a conversation. Twenty launches,
    twenty "Chat Sep 2, 07:38" rows pushing the real ones off the screen.
    A new session now reuses the untouched conversation instead of adding
    another, and an empty one is not listed at all.
  - The page was told Metal was available everywhere. The simulator's
    slice of the engine has no Metal, so the default backend did not
    exist and the user was one tap from a load that fails. The app now
    offers what its probe found, and the simulator check asserts that
    property rather than assuming Metal.

Also adds an event-stream heartbeat. A disconnect is only visible when a
write fails, and during a long prefill there is nothing to write for
minutes -- so Stop appeared to work while the model kept going. A comment
line every five seconds fixes that; the page's reader ignores any line
that is not `data:`.

226 pass, 21 skipped. All simulator checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pt example

The Metal line said it is always the default; it is the default where the
build has it, and the simulator's slice does not. The skills checker's
usage example pointed at one machine's checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sing it

Stopping the server ends the HTTP requests, which was not enough. A
generation those requests started is still inside a graph compute on the
engine's own threads -- cancellation is delivered between tokens, and a
token can take a while on a CPU. Releasing the model at that moment
unmaps weights those threads are reading and the process dies with a
segmentation fault in whichever kernel happened to be running.

The engine reports what it is processing, so shutdown now waits for that
to reach zero before the model is released, capped so a wedged request
cannot stop the app from closing.

Found by the aborted-generation test regressing when the event-stream
heartbeat was added: the heartbeat notices a disconnect during a long
prefill, which made the teardown happen earlier and exposed the race the
earlier ordering fix had only made less likely.

Also fixes the heartbeat itself, which would have spun on cancellation --
a cancelled Task.Delay completes immediately, so looping on it wrote
keep-alives as fast as the socket allowed. It now waits on the pending
frame with its own timeout instead.

226 pass, 21 skipped; the aborted-generation test passes in 26 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Loading a model on ggml_metal and letting the process end aborted it:

    ggml-metal-device.m:962: GGML_ASSERT([rsets->data count] == 0) failed

ggml-metal's device is a C++ static whose destructor asserts that every
residency set has been handed back, and it runs from __cxa_finalize, after
the last managed code. Two independent leaks kept it dirty:

  - ModelBase.Dispose released the weight and KV wrappers but never the
    graph scratch -- the reuse gallocr and the per-graph compute buffer,
    769 MiB and 256 MiB here, each carrying an MTLResidencySet. The two
    diffusion pipelines release that scratch by hand between stages
    precisely because nothing else does; a model that only ever loaded and
    unloaded had no such call anywhere. It is also close to a gigabyte a
    phone does not get back when the user switches models.
  - The app never called GgmlBasicOps.Shutdown() at exit, which the Server
    and CLI both do, so quitting with a model still loaded aborted on the
    weight buffers instead.

A third bug surfaced on the way: TSGgml_ReleaseReuseComputeBuffers freed
the scratch without draining deferred GPU work, so under async compute the
last graph's command buffer was still reading what had just been freed.

ExternalProjects/ggml is a build-time clone that resets on every build, so
none of this could be fixed upstream even if it belonged there.

Two review corrections on top of the diagnosis:

  - The justifying comment claimed every other caller pairs
    ClearOffloadableState with releasing the scratch, citing the two
    pipelines. They do not -- they release the scratch alone. Corrected to
    what is true.
  - The process-exit net was installed from AgentAppHost's constructor,
    which handed it to every test that builds a host. A net that catches
    an undisposed engine also hides one, and that is how this leak went
    unnoticed. The app installs it deliberately at startup; tests get
    none, so a leak still aborts loudly.

Verified: the original repro passes and exits cleanly twice running, and
in 8 s rather than 81 s, because the tests now load on Metal instead of
CPU. MetalLifetimeTests measures device allocation directly and fails with
1023 MiB left over when the fix is removed. TensorAgent 226 pass;
InferenceWeb 3054 pass with two timing benchmarks that pass in isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shell has 102 builtins and had no git. A coding agent wants one: to
see what it changed, to put a file back after a bad edit, and to
checkpoint before trying something. LibGit2Sharp would mean
cross-compiling native libgit2 for iOS, so this is pure C# against the
on-disk format instead.

init, add, commit, status, diff, log, show, checkout --, restore, rm, mv,
plus rev-parse, cat-file, hash-object, ls-files, branch, tag and config,
which share the same machinery. Reflogs are written, so real git reflog
works on the result.

Packfiles are read -- .idx v1 and v2, both delta encodings, the
copy/insert stream, with base memoisation. That was the decision worth
making deliberately: git self-packs after a few thousand objects and any
directory copied from elsewhere arrives packed, so a loose-only reader
would report an EMPTY HISTORY rather than an error. Writing packs is not
implemented; real git reads loose objects natively.

Real git validated it. `git fsck --strict` is clean on a repository this
wrote, `git log` and `git ls-files --stage` agree on every object name, so
the SHA-1 naming, tree sort order, index checksum and commit encoding are
all right. The reverse direction works too, and after `git gc
--aggressive --prune=now` left zero loose objects the builtin still walked
the whole history and printed delta-compressed blobs byte-identically.

One bug found while writing the symlink test, and it was not git's:
ConfinedPaths.Resolve deliberately follows symlinks, which is right for
confinement and wrong for `git add link.txt` -- it committed the target's
bytes under the link's name, losing the link and silently inlining a file
from outside the work tree. GitFileGate.ResolveLeaf confines by the parent
directory and leaves the last component unfollowed, which is git's lstat.
Verified that this is not an escape: the leaf is only ever lstat'd,
deleted, or deleted-then-replaced, and every read that follows a link is
guarded by a LinkTarget check first.

Network subcommands are refused by name with the reason, exit 128. So are
merge, rebase, reset and the rest that rewrite the working tree: a known
subcommand says it is unimplemented, a typo still gets git's own
"'stauts' is not a git command".

41 tests, all executing, none skipped; the four real-git cross-checks run
the system binary and skip with a message where it is absent. Suite: 267
pass, 23 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…right

Two capabilities the app could not do, built from what the bundled
interpreter actually has rather than from what the published skills want.

Research: fetch a page as readable text, search through a configured
endpoint or a documented HTML fallback, and crawl several pages into a
dossier -- stdlib only, no API key, and every path degrading with a
message when the network switch is off. Every network path was verified
against the real internet: curl, wget, urllib, node's fetch, the host
allow-list in all three, and a PyPI wheel install that the next Python run
imports out of the session's package root.

Documents: PDF via reportlab, workbooks via openpyxl, and .docx/.pptx
written as OOXML by hand with zipfile and xml.etree, because lxml has no
iOS wheel on PyPI or the BeeWare index and no recipe in mobile-forge --
so python-docx and python-pptx cannot be bundled at all. Verified by
reading the output back with the real lxml-backed python-pptx and
python-docx: a deck comes back as 3 slides at the right 16:9 EMU with a
real table, and a document as Title/Subtitle/Heading 2/Normal/List
Paragraph with a real table. No Office application has opened them.

Four defects fixed, three of which produced wrong numbers silently:

  - A "=B2*C2" string inside a spec's `rows` IS a formula -- openpyxl
    writes it to the file as one -- but only the separate `formulas` array
    was computed and cached. So the cell went in with no cached value, the
    run reported success, and analyze_table.py then read every revenue as
    0. Reproduced (sum(Revenue)=0 across three regions), fixed, and now
    reports 25.0/12.0/10.5 totalling 47.5.
  - IF did not short-circuit. The evaluator computes as it parses, so
    =IF(B2=0,0,C2/B2) -- the canonical divide-by-zero guard, the reason
    anyone writes IF -- evaluated the division first and refused the whole
    formula. An untaken branch's failure is now carried, not raised.
  - AVERAGE/MEDIAN/MIN/MAX/STDEV/PRODUCT over a range with no numbers
    returned a hardcoded 0.0, which was then cached into the sheet as a
    real number no reader could tell from a measured one. They refuse now,
    which puts the cell in uncomputable_formulas where it belongs.
  - A spec with invented keys (`sections` instead of `blocks`) parsed,
    validated and produced a file containing only its title, exit 0. All
    four writers now name the unknown keys and list the accepted ones.

Also: the research skill mapped every PermissionError to "turn Network
on", including the allow-list refusal -- sending a model to a switch that
was already on. The two refusals now give opposite, correct advice.

And the allow-list itself was unreachable: three runtimes checked
ExecutionPolicy.NetworkHosts and nothing in the app could set it, so it
was always empty and IsHostAllowed short-circuited to true. It is a
setting now, threaded to the policy, with a test that fails if it stops
biting. WheelInstaller's fourth, differently-worded refusal for the same
condition is unified onto the shared sentence.

verdicts.json is regenerated by its own tool rather than hand-edited:
14 of 14 bundled skills pass.

305 pass, 45 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed them

Two catalog entries advertised a device tier they could never run on.
Gemma 4 E2B claimed 6 GB and needs about 6.6 GB resident; E4B UD-Q4_K_XL
claimed 8 GB and needs about 6.8. A 6 GB iPhone would have been shown a
five gigabyte download and then been killed opening it -- the worst thing
this catalog can do, because the user pays for it twice, once in data and
once in the wait.

The budget test only ever checked the 12 GB tier, so neither was caught.
It now applies the same rule to every entry against the tier it
advertises, and a second test walks every real device size and asserts
that whatever ForDevice hands back fits the device that asked. Both
entries move to 12 GB.

The README's model table was written from memory rather than from the
catalog and disagreed with it on three rows and every download size. It
is now generated from the same numbers, with the Hugging Face repository
each file comes from.

306 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…an edit costs

Answering two questions with measurements rather than estimates.

**Does image editing work end to end?** Yes, and it always did once the
three silent bugs were fixed: /uploads is routed, the vision projector is
named so the loader's scan can find it, and the Lightning LoRA reaches
TS_QWEN_IMAGE_LORA. A real edit runs on Metal, the LoRA engages (720
pairs, 4 steps, cfg 1), and the result decodes to the size it claims.

**Does it fit a phone?** Not proven, and the honest numbers are worse than
the catalog assumed. A traced edit shows 1.82 GB through VAE encode, 2.99
GB during text encode -- so the text encoder IS freed as advertised --
then 23.2 GB the instant the DiT loads, held for the whole denoise. The
tier moves from 12 GB to 16.

Two engine changes came out of it.

CPU offload was gated to GgmlCuda, so on Metal it could never engage
whatever the environment said -- and Metal is what a phone runs. It is
enabled there now: the path works, the edit completes, and the pipeline
reports streaming as designed.

The residency budget was sized from what the device says is free
(recommendedMaxWorkingSetSize minus currentAllocatedSize). On a phone
those are not the numbers that matter: iOS kills an app at its jetsam
allowance, a fraction of what the GPU calls free, so sizing from `free`
means deciding the weights fit right up until the process dies.
TS_QWEN_IMAGE_RESIDENT_MB caps it, and TensorAgent publishes an output-area
cap per device alongside the companion paths -- halving the area took a
denoise from 111 s to 50 s.

The new test reports peak Metal allocation and asserts the tier ONLY when
it measured the catalog's own quantisation; with any other DiT it prints
the number and skips, saying which file it had and which it needed. The
figure is currentAllocatedSize, an upper bound on what jetsam counts, so
under budget proves life and over budget is a warning -- and the test says
so rather than implying more.

What is still unproven: the catalog's own Q2_K DiT has not been measured
(it is 5.8 GB smaller than the file on this machine), and nothing has run
on a physical device.

306 pass; QwenImage engine tests 21 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One edit with the Q2_K DiT -- the smallest quantisation unsloth publishes at
7.47 GB -- allocates 16.0 GB on Metal. A 16 GB iPhone grants an app about
11.3 GB before jetsam. Halving the output area saves ~1.2 GB and the
CPU-offload path does not lower allocation on unified memory, so neither
closes a 4.7 GB gap.

The entry now sits at a 24 GB tier, which no phone reports, so ForDevice
never offers it; the Notes carry the measurement rather than the previous
"loaded one at a time to fit", which was true about the loading and wrong
about the fitting. The memory test keeps both claims: the advertised tier
has to hold, and 17.5 GB is a regression ceiling on the number itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zhongkaifu and others added 17 commits September 2, 2026 20:54
Two reports from the phone.

THE MICROPHONE. Holding the talk button said there was no permission. The
Info.plist keys were all present and the request order was right; two other
things were wrong. Both TaskCompletionSources completed WITHOUT
RunContinuationsAsynchronously, so everything after the await -- including
starting the AVAudioSession -- continued on whichever thread iOS delivered the
callback on rather than the main one. And a permission the user has already
refused cannot be asked for again: iOS shows its prompt once and afterwards
returns the stored "no" silently, so the button failed forever with nothing to
do about it. Denied is now distinguished from not-yet-asked, named for what it
is ("Settings › TensorAgent › Microphone"), and carried to the page with a
marker so the refusal comes with an Open Settings button instead of
instructions.

THE UX THAT DID NOT CHANGE. It had, but only half: the page was the new one
while a NATIVE row above it still carried the status and Chats / Models /
Settings chips. Two rows of chrome stacked on a 6.9-inch screen is the one
thing the redesign exists to avoid, and it is why the app still looked like the
old one. The native row is gone. Navigation lives in the page now -- a single ☰
in the same row as the activity dot, the model and New, opening a sheet with
Chats, Models, Settings and About -- and the app routes it through OnPageEvent,
accepting only the routes the shell actually registers so a page cannot
navigate somewhere there is no page for.

Verified on the phone: it serves the new page (16753 bytes) with menu, bar,
busy, model, new, plus, hold, lang, think and voice present, the four routes in
the nav sheet, and zero mentions of TensorSharp.

313 passed, 57 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"The research skill never successfully works." It works from a laptop and
cannot work from a device, and the asymmetry is why every test of it passed.

_ssl.framework ships in the app bundle, so Python CAN speak TLS on iOS. What
is not there is a certificate authority bundle. OpenSSL looks for a trust
store at a path compiled into it, which does not exist inside an app bundle,
so every https:// fails CERTIFICATE_VERIFY_FAILED -- while a development
machine's interpreter quietly uses the system store and succeeds. The skill's
own search endpoint is https. So is nearly everything else it would fetch.

certifi is now staged beside the other packages for both slices (240216 bytes
of authorities), and the bootstrap points OpenSSL at it before anything
imports ssl.

Finding where it was being lost took three wrong guesses, and the answer is
worth recording: the bootstrap set SSL_CERT_FILE on the real environment, and
every run then REPLACED os.environ with a plain dict built from the request
(PythonBootstrap, "a plain dict, not the process environment") -- deliberately,
so a script cannot mutate the app's environment or leak it to a child. The
variable was set and then dropped a moment later. The CA path is now carried
across that swap as the one exception, because it is a property of the
interpreter rather than of the caller, and a caller who forgot it would get a
verification failure on every request with nothing pointing at the cause. A
caller that sets it explicitly still wins.

TheInterpreterHasACertificateStoreAndCanVerifyTls asserts the bundle is found
AND that a default SSL context actually loads authorities from it, so a staged
file that OpenSSL ignores fails the test rather than passing it.

A drift guard did its job on the way: DocumentSkillTests refused the new
staging entry until certifi was registered in ModuleOfPackage.

313 passed, 58 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ible trace

Five things, all reported from the phone.

VOICE HANDS BACK. Releasing the talk button now leaves voice mode, puts the
transcription in the text box and focuses it with the caret at the end.
Speaking is how a message STARTS; reading it back, fixing a word and pressing
send is how it finishes, and staying in voice mode hid the very text the user
needed to check.

A VISIBLE TRACE. The desktop page shows a live activity block and DELETES it
when the step finishes, which suits a wide screen someone is watching. On a
phone the useful thing is the opposite: a short line per step that STAYS, so a
user who looked away can see it read a skill, ran a script and edited a file.
Live status while running, one finished line with its duration after -- the
tool vocabulary is taken from the Server's own activityLabel so the words match
what the desktop says.

SKILLS CAN BE ADDED AND REMOVED. POST /api/skills/from-url installs from a
link, which is how a skill is actually shared -- someone sends a URL and a
phone has nowhere convenient to put a file first. The body may name one archive
or a plain-text list of them, one per line, because a collection is the other
way skills travel; a list reports both halves, so eight of ten landing says so
and names the two that did not. Everything downloaded goes through the same
Install path as an upload, so the path guard on every entry, the
decompressed-size budget and the entry cap all still apply -- a URL is a
different way to arrive, not a different level of trust. Size is capped while
reading rather than trusting Content-Length, which the server chooses.

READING A SKILL. Tapping one opens a sheet with the whole description, the
switch and Remove. The list still clamps to two lines on purpose; what changed
is that the rest is now one tap away instead of unreachable.

claude-api is gone from the built-in list.

One trap worth recording: deleting a skill from the source tree does NOT remove
it from the app bundle. BundleResource copies incrementally and never deletes,
so the build kept shipping claude-api and the simulator kept listing 14 skills
after the directory was gone. The stale output has to be cleared.

Verified on BOTH targets. Physical iPhone 17 Pro Max:
  engine: sh (in-process), python 3.13.14, node (JavaScriptCore),
          installs enabled · code execution on · network on · 13 skills
  skills 13, claude-api absent, from-url rejects a non-http scheme with 400,
  and the page carries the skill, add-skill, language, hold-to-talk and menu
  surfaces.
Simulator: identical, on a clean install.

313 passed, 58 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing a skill only ever worked for one it had installed itself. Most of this
app's skills ship INSIDE the bundle, which is read-only and is rewritten by
every install of the app, so SkillRegistry.Remove refused them outright:
"'brand-guidelines' was discovered under <bundle>/skills and is not managed by
TensorSharp; remove it from that directory instead." There is no "that
directory" on a phone. Deleting a built-in skill was impossible, and any
delete that had appeared to work would have been undone by the next launch.

The registry now keeps a record of what the user removed. Removing an INSTALLED
skill still deletes its directory; removing one that lives in a read-only root
writes the id down instead, and discovery skips it from then on -- including
after the app is reinstalled and the bundle puts the files back. The record
lives in the data root, so it survives updates the way conversations do.
Installing a skill CLEARS its entry first, because installing is the user
asking for it back, and without that a reinstall would succeed while the skill
stayed invisible.

Configured only by the app. The desktop server passes no record file and keeps
the old refusal, which is right for a process whose skill directory an operator
manages with a shell.

Verified on BOTH targets, including the part that matters -- that it does not
come back:

  simulator: 13 skills -> delete brand-guidelines -> 12 -> RELAUNCH -> 12
  iPhone:    13 skills -> delete brand-guidelines -> 12 -> RELAUNCH -> 12,
             engine line agrees ("... network on · 12 skills"),
             and reinstalling the same zip restores it -> 13.

314 passed in TensorAgent, 405 in InferenceWeb's skill suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…that stops copying

Two things the engine was leaving on the table on Apple silicon, both of them
conservatism rather than a missing capability.

A block-quantized K/V cache was admitted by the fused whole-model graphs only on
CUDA, so asking for q8_0 anywhere else silently dropped to the per-op path: decode
3.7 tok/s against 75.6 on f16, with the engine saying so itself. Nothing was
missing. ggml-metal instantiates flash_attn_ext_q8_0 and _q4_0 for exactly the
same dk/dv set as f16, the cache write has kernel_cpy_f32_q8_0, and the native
graphs were already dtype-generic. Measured after, ggml_metal, 3 interleaved reps:
Qwen3.6-35B-A3B decodes 75.5 tok/s at q8_0 against 76.0 at f16, and a token costs
22.4 KiB instead of 41.8. A two-needle recall test at 7,490 tokens returns both
planted values at f16, q8_0 and q4_0.

Gemma 4 does not get it. Its fused paths are fine, but its sliding-window layers
use a CIRCULAR cache whose three managed helpers are float-only, and the 26B-A4B
MoE reaches them on an ordinary prompt: enabling it crashed with "Requires a
Float32 tensor, but found Q8_0" out of CopyToCacheCircular the moment a user
typed. It now declines at load and uses f16, which is what the guard was for
before it was bypassed.

Separately, load-time fusion copies. GGUF stores tensors alphabetically, so the
pairs worth fusing are almost never adjacent and TryCreateConcatenatedView falls
through to a memcpy into fresh anonymous memory - 1.53 GiB on Qwen3.5-9B Q8_0,
6.65 GiB on gpt-oss-20b, against llama.cpp's zero, because it never synthesizes a
weight the file does not contain. On iOS that is charged against jetsam, so the
copy is now declined where a separate-weights path is verified to exist
(SeparateQkv, SupportsSplitGateUpFfn, the recurrent pack's four sources). Peak
footprint 2,137 MB -> 570 MB, bit-identical output. gpt-oss keeps the copy: its
per-expert gate_up has no split path and there is nothing to fall back to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Release device build shipped with no engine at all, and said so only if you
looked: ggmlCpuAvailable=false, ggmlMetalAvailable=false, "Unsupported
architecture: gemma4. Registered: .", and no model would load. Debug was fine,
which is why this only ever broke the shipping configuration.

Two steps fight each other. The link exports what the resolver needs, because
NativeLibrary.GetMainProgramHandle() reaches the statically linked engine by
dlsym:

    xcrun clang++ ... -force_load libGgmlOpsMerged.a
                      -Wl,-exported_symbol,_TSGgml_*

and Release then runs, separately,

    xcrun strip -i -s <obj>/mtouch-symbols.list <executable>

where `strip -s file` keeps ONLY the symbols named in that file. Nothing put
TSGgml_ in it, so they were stripped straight back out after being correctly
exported.

GgmlExportedSymbols.targets names all 248 of them as ReferenceNativeSymbol, which
is the SDK's own way to say "keep this": the items are written into that very list
and also become -u link roots. No leading underscore, because the SDK emits
-u%(Identity) and the linker adds its own. 587 exports, 248 TSGgml, 78 MB, and the
app comes up on Metal.

Not MtouchNoSymbolStrip=true. That also restores the exports but keeps every
symbol, the executable goes 78 MB to 190 MB, and the app dies at launch with
SIGABRT inside load_aot_module before any managed code runs.

One trap worth knowing: iOS link results are cached under obj/macos/<Config> and
deleting the .app does not invalidate them. The same csproj produced either a
78 MB or a 190 MB binary depending on leftover state, which looks exactly like a
nondeterministic crash. Clean obj before comparing two link configurations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TensorAgent was killed by jetsam running Qwen3.5 9B on a 12 GB iPhone, and the
cause was that its context budget never reached the engine. Every catalog entry
carries a ContextLength, and its only reader was the JSON the page renders.
AppSettings.ContextLength, documented as the user's override, was read by nothing
at all. KvCacheDtypeConfig.ConfigureFromEnvironment() was called by the server and
the CLI but never by the MAUI head. So the engine used the GGUF's own number -
262,144 for Qwen3.5 - and a pasted document grew the KV cache until the process
died. Measured on ggml_metal with a 24,696-token prompt: 5,679 MB of physical
footprint unbounded, 943 MB with the budget applied.

EngineMemoryPolicy applies it in UseModel, which is the single funnel every load
arrives at, and before RepointHostedModel because the engine reads these when the
model is constructed. Setting MAX_CONTEXT also switches the cache from growing on
demand to reserving its window once, which is the behaviour to want next to a
jetsam limit: a budget that is wrong is discovered at load, where it can be
reported, instead of arriving as geometric growth mid-conversation.

The catalog changes with it. Qwen entries take a q8_0 cache now that the fused
graphs read one, which is 22.4 KiB/token against f16's 41.8 at decode parity, and
their windows grow to 32768 - or 16384 for the dense 27B, whose 64 attention
layers cost 68 KiB/token even quantized. Gemma stays on f16 and 8192; it refuses a
block-quantized cache. Both mixture-of-experts entries drop to the 12 GB tier: the
test helper that gated them at 16 assumed "Metal wires the mmap'd weights, so
resident memory is roughly the GGUF", and measurement says otherwise - an 11,272 MB
model peaks at 1,211 MB resident / 1,090 MB footprint. That estimate is now two
numbers, because they answer to different limits: anonymous memory to the jetsam
budget, weights to the device's RAM, past which every token faults from flash.

Gemma 4 12B is new. The reply-length limit reaches 256K by doubling rungs rather
than a thousand steps of 256, and says plainly that the context is the real
ceiling, because it is: the generation reserve is trimmed to what the window
leaves after the prompt.

os_proc_available_memory() is wired in too. The app never asked how much memory it
had left, and a jetsam kill writes no stack and no message of its own, so the last
thing the app said about its own budget is the only evidence there is. On the
device it reports 6.40 GB, against the 8.5 GB the tests assume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two silences a user hits and cannot explain.

The first: "here is your PDF", a link, and "error: not found" when you tap it. The
runner hands the page /api/code/artifacts/{runId}/{path} and the app mapped no
such route - TensorSharp.Server has had the endpoint all along, the phone did not -
so every generated document, PDF and PPTX alike, was unreachable. MapCodeArtifacts
serves them the same defensive way the server does: confinement re-checked by the
store rather than trusted from the route, always an attachment, never a content
type a WebView might execute.

That is only half of it, because a WebView cannot save an attachment either: with
no download delegate, tapping one does nothing at all. The link is intercepted and
handed to the share sheet instead, which is what "save it to my phone" means -
Save to Files, Mail, AirDrop. Resolving through the store rather than fetching the
URL keeps the confinement check in one place and skips a loopback round trip for a
file already on disk.

The second: a turn that ran your code, streamed plenty doing it, and then ended
without writing an answer. There was already a message for a turn cut off before
producing anything, but it keys on tokenCount, which counts every streamed piece -
thinking and tool calls included. So that turn had a healthy tokenCount, truncated
was false, and the page showed the step that ran followed by nothing at all.
Whether ANSWER text was ever produced is now tracked separately, and its absence
is said out loud.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…DF, and a switch for all of it

The research skill required a URL, which is the one thing a person asking a
research question does not have. It discovers sources itself now - discover.py
finds them, research.py reads and extracts, analyze.py summarises - and search.py,
which only fetched what you already named, is gone. What a page says is treated as
what a stranger wrote: quoted, weighed, cited, never followed as instruction.

"Turn this photo into a PDF" is one command rather than a program someone has to
write: make_pdf.py --image, each picture a page of its own, scaled to fit with its
proportions kept.

Skills can be switched off entirely. When the toggle is off no skill is offered,
discovered or run.

Three things that had to be fixed for any of it to work end to end. skills_run
went through a child process, which is fatal on iOS, so ICodeRunner now carries
the in-process Backend the interpreter already uses. tempfile.tempdir leaked
between runs, so a second session's workbook was written into the first session's
scratch directory. And the live-Python test classes raced each other over one
process-wide interpreter: they share a collection now, and one DllImport resolver
serves both CPython and JavaScriptCore instead of the two racing for the single
slot, where the loser could not bind its imports for the life of the process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ves the screen

Reopening a saved chat lost everything it carried. Images, audio, video and files
were rendered from a payload the history never kept, so switching back showed the
words and nothing else. The page stores each attachment with the message now - url,
name, kind - and rebuilds the img/audio/video/link on the way back in, and the
whole history is sent rather than a trimmed copy. ChatMessage carries
AttachmentPaths and AttachmentNames so the server side can resolve them, and every
attachment is staged for the interpreter rather than only the ones it guessed at.
A DOM harness runs the real page in JavaScriptCore, so the behaviours that broke
are held by tests that fail against the old page.

A generation belongs to the app, not to the HTTP request that started it. On a
phone the reader goes away constantly - another screen, another app, a display
that dimmed - and each of those used to throw away an answer halfway through.
ChatTurnManager owns the turn, the page re-attaches to it, and a background
assertion keeps it alive long enough to finish. Model downloads got the same
treatment: they outlive the screen that started them, resume from their .part, and
the Models list finds the job again when it reopens.

Uploads get names that cannot collide or escape, multipart parsing is its own
tested unit, and the shell reports a missing command as a missing command instead
of a stack trace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	TensorSharp.AgentHost/Skills/SkillSandbox.cs
#	TensorSharp.Backends.GGML/GgmlNative.cs
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-07T18:50:04.923217Z e8f5467 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8f546718c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return;
try
{
IReadOnlyList<string> resumed = _downloads.ResumeInterrupted(ModelCatalog.Find);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor the cellular policy before auto-resuming downloads

When a Wi-Fi download fails while the app is suspended and the user returns on a cellular-only connection, this foreground callback restarts the multi-gigabyte transfer unconditionally. The normal Models-page entry points check AllowCellularDownloads and DeviceState.IsOnCellularOnly(), but this path has neither the settings store nor the connectivity check, so the default-off cellular policy can be bypassed and incur substantial data usage.

Useful? React with 👍 / 👎.

Comment on lines +112 to +116
CatalogFileRole.Projector,
CatalogFileRole.Lora,
CatalogFileRole.TextEncoder,
CatalogFileRole.Vae,
CatalogFileRole.VisionProjector,

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 Badge Include draft heads in optional downloads

When DownloadOptionalFiles is enabled for either Gemma catalog entry, ModelStore.DownloadAsync includes optional files only when their role appears in this array, but CatalogFileRole.Draft is absent. Consequently the setting documented as downloading optional projector/draft files never fetches either cataloged MTP GGUF, while the job still reports completion.

Useful? React with 👍 / 👎.

// notice is inert.
Options.RepointSamplingDefaults(SamplingDefaultsFor(model));

Options.RepointHostedModel(weights, projector);

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 Badge Configure an installed draft head before loading the model

When a catalog draft GGUF is present, UseModel resolves only the weights and projector and never publishes the draft path or enables speculation before calling ModelService.LoadModel. ModelLifecycleService discovers separate heads solely through TS_SPEC_DRAFT_MODEL/its legacy equivalent, so the advertised optional Gemma draft remains unused even after it is downloaded or otherwise placed in the model directory.

Useful? React with 👍 / 👎.

zhongkaifu and others added 4 commits September 7, 2026 12:00
main split TensorSharp.Server into a library plus a TensorSharp.Server.Host
application; this branch had split it into a host-neutral TensorSharp.Chat
plus TensorSharp.Server. The two splits are compatible — Chat < Server <
Server.Host — so the resolutions keep this branch's layering and apply main's
visibility widening on top:

  BackendCatalog        keep Chat's delegate-based catalog (the probes stay in
                        the Server, which alone may link Cuda/MLX); take main's
                        public class + Canonicalize, which Server.Host's
                        StartupBanner calls.
  UploadContentPolicy   keep Chat's ASP.NET-free content-type table. main made
                        the class public only for BuildStaticFileOptions, which
                        now lives in the Server's UploadStaticFiles, so the
                        policy goes back to internal and UploadStaticFiles is
                        the public one.
  SkillsAdapter,        using-block only; keep this branch's TensorSharp.Chat
  WebUiAdapter          imports. main's internal -> public merged cleanly.
  InferenceWeb.Tests    reference Chat, Server and Server.Host.

Continuing main's split, the three Server-side composition types Program.cs and
StartupBanner now reach across an assembly boundary are public:
BackendCatalogProbes, UploadStaticFiles and DistributedTensorParallel.

main's rename left the runnable project behind in several places; the server's
entry point is TensorSharp.Server.Host and TensorSharp.Server no longer has a
Main, so `dotnet run --project TensorSharp.Server` now errors. Repointed the
release-binaries and test-matrix workflows, the engine_comparison harness
(which launches the server by DLL path), and 35 doc/website commands.

Verified: solution builds; InferenceWeb.Tests 3252 passed / 0 failed;
TensorAgent.Tests 523 passed / 0 failed; the merged server boots, serves the
Web UI from its new home and answers the Ollama and OpenAI endpoints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eng/verify-packages.ps1 compares each packed nuspec's internal dependencies
against a hard-coded list, and two real dependencies were missing from it:

  TensorSharp.Runtime.Logging  added when logging was factored into its own
                               assembly (PR #196), missing on main ever since
  TensorSharp.Chat             added by the host-neutral chat split

So the gate in publish-nuget.yml failed for both TensorSharp.Server and
TensorSharp.Cli. Worse than the gate failing: neither dependency was in the
publish set at all, so a released TensorSharp.Server would have carried two
references that do not exist on nuget.org.

Both projects — and TensorSharp.Server.Host, also packable and also unlisted —
were authored with a package Description and PackageTags, and nothing in the
repo uses the script's EmbeddedAssemblies mechanism, so they ship as their own
packages rather than being folded into their consumers.

Dependency sets are the ones actually packed, not the ones inferred from the
csproj graph. Verified by running the gate: 13 packages verified, exit 0, and
every internal dependency is itself published (AdvUtils.dll stays embedded in
TensorSharp.Tensors).

Also drops a stale reference to a README package table that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Engine comparison — TensorSharp vs llama.cpp (PR smoke)

No report artifact was produced — the benchmark failed before generating results (see the workflow logs).

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.

1 participant