Skip to content
C.W.K.
Lesson 01 of 05 · published

A Worker That Never Touches the Mac

~15 min · python-worker, pipes, json-lines, code-signing, watchdog

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Python never controls the Mac." — Tinker architecture

Two Languages, One Rule Set

Every family app that Pippa can operate declares its actions through one shared contract, the kit's AppActions class: who may call, how the caller is named, where receipts go, which actions need a request ID, how big an answer may be. That contract is written once, in Python, and vendored into every app. Tinker is a Swift app. Rewriting the contract in Swift would create a second copy that drifts; the architecture forbids it in so many words — "No copied reimplementation of the app-door contract." So Tinker runs the real Python contract, but in a shape where Python can decide everything about the request and nothing about the Mac.

One Process per Request

For each door request, the resident agent's BridgeWorker launches a short-lived Python process from Tinker's named Conda environment, running the worker.py bundled in the app's resources. It connects two inherited pipes: the agent writes one JSON line to the worker's stdin describing the request, and reads JSON lines from its stdout. The worker builds the AppActions registry, applies the contract's rules, and when a handler needs Tinker's state or an effect, it does not touch anything — it prints a call line naming the action, its arguments and the admitted actor, and blocks reading stdin. The agent dispatches that call on the main actor through the very same engine the menu uses, writes the result back as a line, and the worker continues. When the worker prints a result line, the exchange is over and the process exits.

Small Details That Were Bugs

Each of these was found by running it. The worker is started with python -B: without it, Python writes a __pycache__ directory next to the bundled script — inside the signed app's Resources — and a signed bundle whose contents changed no longer matches its seal. Lines are capped at 64 KiB in both directions so a runaway message cannot grow memory without bound. The agent disables SIGPIPE on its write end, so a worker that exits early produces an error instead of killing the agent. Reads take whatever the pipe holds and split lines from a buffer; the first version read one byte per system call. And a detached watchdog terminates a worker still running after 75 seconds, then kills it a second later — because a worker stuck on a pipe or a network call must never pin the door.

What the Worker May Do on Its Own

Besides invoke and manifest, the worker answers two internal operations the agent asks for directly: port, which reads Tinker's port from the vendored family registry, and observe, which asks Watchfire for a snapshot (track 8). Neither touches the Mac's UI. Everything that changes the screen goes through a call line back to Swift.

Code

The worker's side of a native call (from bridge/worker.py)·python
import json, sys
from kit_app_actions import AppActionRefused

def emit(value):
    print(json.dumps(value, ensure_ascii=False, allow_nan=False), flush=True)

CALLER = {"label": None}   # one process serves one invocation

def native(name, args):
    emit({"type": "call", "name": name, "args": args, "actor": CALLER["label"]})
    line = sys.stdin.readline(65537)
    if not line or len(line) > 65536:
        raise AppActionRefused("The resident Tinker agent did not answer.")
    result = json.loads(line)
    if not result.get("ok"):
        raise AppActionRefused(result.get("error", "Tinker refused the action."))
    return result.get("data", {})
The agent's side: launch, exchange lines, answer calls (trimmed)·swift
let process = Process(), input = Pipe(), output = Pipe()
process.executableURL = home.appendingPathComponent("miniconda3/envs/cwk-tinker/bin/python")
// -B: a runtime cache inside Resources would invalidate the signed app seal.
process.arguments = ["-B", resources.appendingPathComponent("bridge/worker.py").path]
process.standardInput = input; process.standardOutput = output
guard fcntl(input.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) != -1 else {
  throw TinkerError("The app-door pipe could not disable SIGPIPE.")
}
try process.run()
let watchdog = Task.detached {
  try await Task.sleep(for: .seconds(75))
  if process.isRunning { process.terminate() }
  try await Task.sleep(for: .seconds(1))
  if process.isRunning { kill(process.processIdentifier, SIGKILL) }
}
defer { watchdog.cancel() }
try send(request)
while true {
  let response = try JSONDecoder().decode(Response.self, from: try line())
  if response.type == "result" { return response }
  guard response.type == "call", let name = response.name, let args = response.args else {
    throw TinkerError("Invalid app-door callback.")
  }
  try send(await dispatch(name, args, response.actor))   // main-actor engine
}
One exchange on the wire·text
agent  -> worker  {"op":"invoke","name":"preset.apply","envelope":{...},"headers":{...}}
worker -> agent   {"type":"call","name":"preset.apply","args":{"id":"edit"},"actor":"pippa:<brain>"}
agent  -> worker  {"ok":true,"data":{"summary":"Final Cut editing applied (3 changes) · ..."}}
worker -> agent   {"type":"result","status":200,"body":{"ok":true,"summary":"..."}}

External links

Exercise

Sketch a different split: the Python worker is a long-running daemon instead of one process per request. List what you would gain, and list every guarantee from this lesson you would have to re-earn (signing, stuck calls, crash isolation, the actor label).
Hint
You would save the launch cost and could cache the registry. You would have to restart the daemon safely on update without touching the signed bundle, add per-request timeouts inside it instead of killing a whole process, keep one request's actor from leaking into another's (the current worker can keep the caller in a module-level variable precisely because one process serves one invocation), and make sure a crashed daemon cannot strand a pending execution. One process per request makes all of that free.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.