Repository navigation
feat(pointer-teacher): productionize the embodied pointer into Sutando - #889
Conversation
Brings Clicky's "flying pointer teacher" into the live app. The tracer proved the loop end-to-end; this folds it in: - point_at inline tool (src/browser-tools.ts): captures the main display via :7845, resolves the Target with gemini-3-flash-preview in Gemini's native point format with thinking disabled (ADR-0001 — 1-3px in the POC; flash-lite was 108px off, Clicky's raw-pixel prompt 23-69px off), writes <workspace>/state/pointer-cmd.json, returns the `say` line + an instruction so the voice agent narrates it. - Registered in inlineTools + ownerOnlyTools (both required arrays). - PointerOverlayView + setupPointerOverlay/flyPointer/holdPointer (src/Sutando/main.swift): screenSaver-level click-through window, Clicky bezier flight, beep + pulsing halo, ported verbatim from the tracer. Driven by the app's existing DispatchSource dir-watch idiom (same as watchResults) on <workspace>/state — resolves IPC open item. Running inside Sutando.app's real menubar GUI session is the fix for the "I didn't see any pointer" visibility problem the standalone tracer binary hit (no GUI-session access when launched from the agent shell). v1 ships voice-driven; the arming hotkey stays future scope (privacy property already holds — capture happens only on invocation). AX-first resolution (open item sonichi#3) remains the documented next layer. Swift compiles with the app's build flags; full tsc --noEmit passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex review of 9896c53 — verdict "ship with fixes". All 5 applied: - HIGH (main.swift): flyPointer() now invalidates the hold/pulse/fade timers from a prior point_at and resets halo/alpha/showLabel, so a stale fade can no longer hide a new marker mid-flight. Timers are tracked (pointerPulseTimer/HoldTimer/FadeTimer); the fade closure takes [weak self]. - MED (browser-tools.ts): atomic IPC publish — write a sibling .tmp then renameSync over pointer-cmd.json so the dir-watcher never reads a half-written JSON. - MED: AbortSignal timeouts + res.ok checks on both the :7845 capture and the Gemini fetch — point_at is on the inline lane and must not hang it. - MED: per-invocation temp screenshot path + success flag so a failed sips can't feed a stale prior screenshot to the model; cleaned up. - LOW: validate the parsed point is finite and in [0,1000] before publishing, instead of reporting success on garbage coords. Swift compiles with the app flags; full tsc --noEmit passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gnostics User showed the marker was a fat 44px triangle inside a big pulsing halo ring, centered ON the target so it covered what it points at — nothing like Clicky's. Read Clicky's OverlayWindow.swift and matched it: - Removed the stroked halo ring entirely. Clicky has none — just a soft blue glow shadow (radius 8, grows with flight scale). - Triangle 44px → 18px (Clicky uses 16; +2 for retina legibility), Clicky's exact vertex ratios. - Rest pose is now a -35° cursor tilt (PointerOverlayView.restAngle), not a straight-up triangle — reads as a pointer. - pollPointerCmd offsets the target +8px right / 12px below the element (Clicky's "sit beside it, not on top") + 20px screen clamp, so the marker points AT the thing without covering it. - Mid-flight scale swoop to ~1.3x (Clicky's animateBezierFlightArc), settles to scale 1 + cursor pose on arrival. - Hairline white edge for legibility on dark/light UIs; smaller offset label so it never covers the target. Also added logToFile diagnostics to pollPointerCmd/flyPointer (accepted ts/coords, raw→target, START/ARRIVED, window visible+level) — this is how the "not working" report was root-caused to a rendering issue, not a broken pipeline. Swift compiles with the app flags; restarted + log-verified end to end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second Codex round on this branch (after 0cfb60c), all 4 findings: - [High] browser-tools.ts: point_at temp file was per-process (`pointer-cmd.json.<pid>.tmp`), so two overlapping async calls clobbered each other's write/rename. Unique per-call temp name via a shared publishPointerCmd() helper (pid+ms+random); atomic rename + Swift ts-guard still picks the winner. - [High] stale-overlay feedback loop: the :7845 server uses the `screencapture` CLI (raw framebuffer, ignores NSWindow.sharingType), so Codex's sharingType=.none would not have worked. Instead point_at publishes {hide:true} and waits ~250ms before capturing; main.swift learns the hide command (cancels timers, alpha 0, orderOut) and flyPointer re-orders the window front. - [Medium] display reconfig: overlay was sized once at launch but pollPointerCmd maps against the current NSScreen.main.frame. Added a didChangeScreenParametersNotification observer + resizePointerOverlay(). - [Medium] POC tracer pointer-overlay.swift: hold() used local pulse/hold/fade timers that survived a new fly(). Mirrored the production stored-timer pattern so they're cancellable. TS typecheck clean; both Swift files compile (-O). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…status gemini-3-flash-preview has no shutdown date on Google's deprecation page (verified 2026-05-19); the May 25 gemini-3.1-flash-lite-preview deprecation (sonichi#715) is a different model family. Add the POINTER_MODEL env override to .env.example as the zero-code-change escape hatch and record the verified status as design §5 open item 6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219
left a comment
There was a problem hiding this comment.
Reviewed feat/pointer-teacher-design HEAD — 914 LoC across 13 files (browser-tools.ts inline tool, Swift overlay in main.swift, IPC via state/pointer-cmd.json, design doc + ADR + tracer-bullet POC). Tight feature scope; the 5-commit history (1 feat + 3 fix from Codex review rounds + 1 docs) reads like a well-iterated piece of work.
Like (a lot):
- The diff carries its own audit trail — multiple inline
Codex review, high/mediumcomments call out the bug + the fix. Concretely caught and addressed in this PR:- Hide-before-capture race (
screencaptureignoresNSWindow.sharingType→ a stale on-screen pointer biases the next vision input); resolved via the{hide:true}IPC marker + 250ms grace. - Atomic publish using a fixed per-process temp name → could clobber on overlapping calls; resolved with per-invocation
${pid}.${Date.now()}.${random}.tmp. - Stale fade/hold/pulse timers from a prior
point_atmutating the new marker mid-flight; resolved by a full timer-cancel + visual-state reset at the top offlyPointer. didChangeScreenParametersNotificationrebinds overlay frame so coord mapping survives display reconfig.- Failed
sips -Z 1568now falls back to the full-size capture path with aresizedflag — no path where the next call could re-read a stale shrunk file.
- Hide-before-capture race (
- Decision-guide compliance (CLAUDE.md "Where does new code belong?"). The table in the PR body maps each piece to the right layer and the inline-tool placement is justified by the sub-second hot path. Tool is correctly added to BOTH
inlineToolsandownerOnlyToolsarrays ininline-tools.ts([[feedback_register_all_tools]]). - Deprecation risk handled, not hand-waved. PR body actually documents checking Google's deprecation page on 2026-05-19 for
gemini-3-flash-preview— no shutdown announced, escape hatch is thePOINTER_MODELenv override. That's exactly the level of evidence I want on a preview-id dependency. - Defensive parsing in the
point_atexecute: JSON.parse → regex fallback → NaN/out-of-range guard. A garbage[y,x]is rejected before reaching the Swift overlay; can't fly the marker off-screen. - Swift ts-monotonic guard is primed from on-disk state at
setupPointerOverlay, so apointer-cmd.jsonleft over from a previous run doesn't fire on app launch.
Asks (ranked, mostly non-blocking):
-
Workspace-path drift risk (medium).
POINTER_CMD_PATH = join(process.cwd(), 'state', 'pointer-cmd.json'), but the Swift side usesworkspace + "/state/..."whereworkspaceis the app's resolved workspace dir. Per CLAUDE.md "Workspace contract" the canonical resolution is$SUTANDO_WORKSPACE(or~/.sutando/workspacedefault), NOTprocess.cwd(). On the migrated default these can disagree the same way the 2026-05-16 orphaned-DM incident did — Node writes to<repo>/state/pointer-cmd.jsonwhile the Swift app watches~/.sutando/workspace/state/pointer-cmd.json. Should use the TS workspace-default helper (process.env.SUTANDO_WORKSPACE || join(homedir(), '.sutando', 'workspace')) instead ofprocess.cwd(). -
pointAtToolis imported but not re-exported ininline-tools.ts. Theexport { describeScreenTool, clickTool, scrollAndDescribeTool, … } from './browser-tools.js';line (line 17) does not listpointAtTool— only the next-lineimportdoes. For symmetry withdescribeScreenTool/clickTool(both re-exported), add to the re-export list. Today only the array-membership matters since the tool is consumed viainlineTools/ownerOnlyTools, but a future directimport { pointAtTool } from './inline-tools.js'would silently fail. -
Resolver logic is testable in isolation — JSON.parse → regex-fallback → NaN/range gate is ~30 lines of pure logic. PR body argues "no unit tests by design" because GUI/vision/Swift, which is right for the overlay + IPC. But the resolver could carry stdlib-only tests in the #814 style (pin: missing-fence parses, fence-wrapped parses, regex-fallback path on noisy reply, NaN rejection, out-of-range rejection). Maybe 50 LoC, would catch a future regex regression.
-
Hide-then-capture race is bounded by a fixed 250ms grace, but not acked. If the DispatchSource event handler is delayed (heavy GC, disk pressure) past 250ms, the overlay is still on-screen when
screencapturefires. Defense-in-depth would be a state-ack file (pointer-state.jsonwith{visible:false, ts:...}) the Node side polls before capturing. Today's value is probably fine; flag as future hardening for self-repair. -
Magic numbers without overrides:
8.0shold,0.7sfade,60_000msmodel timeout,1568pxsips resize,250mshide grace. None are env-overridable; tomorrow when one of them needs tuning, you'll be back in this file. Lowest-risk one to expose today isPOINTER_HOLD_SECONDSsince teaching pace varies per user. -
Model timeout is 60s on the inline-tool lane. That's a long wait for a voice agent if the model hangs.
describe_screenuses comparable budget (worth a grep to confirm), but on the inline-tool budget spec (sub-second target), 60s seems generous. Worth either documenting why pointer-teacher needs the long budget or aligning withdescribe_screen. -
POINTER_MODELenv override is the escape hatch for the eventual preview-id deprecation, but a swap requires re-running the grill POC per ADR-0001. Worth dropping a one-liner pointer in.env.examplenext toPOINTER_MODEL=...saying "if changing this, seepointer-teacher-tracer/run.shto re-validate" — so the future operator who triesPOINTER_MODEL=gemini-3.5-flash-ga(after deprecation) doesn't ship a 108px-off model.
Overall: ship it. The biggest substantive ask is the workspace-path drift (#1) — that's the kind of bug that fires silently after a deployment env change and is annoying to chase. The rest are polish or follow-up.
vidhuUC
left a comment
There was a problem hiding this comment.
Cold review of feat/pointer-teacher-design.
Overall: looks good to merge. One flag and one nit, neither blocking.
Flag (non-blocking, but worth a follow-up issue)
POINTER_CMD_PATH is resolved at module load time via process.cwd():
const POINTER_CMD_PATH = join(process.cwd(), 'state', 'pointer-cmd.json');This is consistent with how tasks/ and results/ are resolved elsewhere in the codebase, so it's fine for now. But if SUTANDO_WORKSPACE ever becomes the canonical state root (it's already the intended owner of state/), this will silently diverge. The resolveWorkspace() helper is already imported in conversation-server.ts — would be a clean follow-up to route state/pointer-cmd.json through it when that migration lands. Not a blocker.
Nit (cosmetic)
CONTEXT.md was added to the repo root — probably better as docs/pointer-teacher-context.md to stay consistent with the rest of the feature docs (docs/pointer-teacher-design.md, docs/adr/0001-...). Easy rename if the owner cares, otherwise fine.
What I checked
publishPointerCmdatomic write/rename pattern — correct; per-invocation tmp name prevents concurrentpoint_atclobbering.- Clear-before-capture (
hide: true+ 250ms) — necessary and well-explained;screencapturereads the raw framebuffer. - Model/prompt selection — ADR-0001 justifies all three decisions (gemini-3-flash-preview, native
[y,x]format, thinking off). Swapping any would need re-running the POC; comment says so. POINTER_MODELenv escape hatch — clean.- Tracer kept in
pointer-teacher-tracer/as the proof artifact — good call. - Design docs and ADR are thorough and match the implementation.
LGTM for merge pending owner review.
|
feat(pointer-teacher) — reviewed, strong LGTM The AX-first → What works well:
One question: Otherwise: architecture is sound, docs/ADR are thorough, the preview-id longevity concern is acknowledged with a clear mitigation. Ready to ship. |
liususan091219
left a comment
There was a problem hiding this comment.
Review — productionize Pointer Teacher (Lucy)
Genuinely careful PR — this is high-quality work and it shows. Things I like:
point_atis registered in bothinlineToolsandownerOnlyTools(src/inline-tools.ts) — the dual-registration that's bitten other tools is done right here.- The IPC publish is hardened the right way:
publishPointerCmdwrites to a per-call unique temp (pid + ms + random) thenrenameSync— atomic, and overlappingpoint_atcalls can't clobber each other. The prior fixed-temp-name race (cited as "Codex review, high") is genuinely closed. - Defensive on the hot path throughout: 8s capture timeout, 60s model timeout,
AbortSignal.timeouteverywhere, NaN/out-of-range point rejection before it reaches the overlay, per-invocation screenshot temp + success flag so a failedsipscan't feed a stale frame to the model. - The stale-overlay hide-then-250ms-then-capture sequence is a sharp catch —
screencapturegrabs the raw framebuffer and ignoresNSWindow.sharingType, so this is the only way to keep a previous pointer out of the model's input. Well reasoned. - The
POINTER_MODEL+ native-point-format +thinkingBudget:0choices are locked with a POC-citing comment that tells the next person not to swap them without re-running the grill. That's exactly the right way to protect a load-bearing empirical choice.
One load-bearing question and two placement nits:
1. Does the IPC path actually match where Sutando.app watches? POINTER_CMD_PATH = join(process.cwd(), 'state', 'pointer-cmd.json'). The comment says "process.cwd() is the repo root for core ... which is also what the Swift app resolves as workspace." But CLAUDE.md's workspace contract says per-user mutable state (including state/) lives under $SUTANDO_WORKSPACE (default ~/.sutando/workspace/), resolved via resolveWorkspace() from workspace_default.js — and that Sutando.app's AppDelegate.workspace resolves there too, not the repo root. If core runs with cwd = repo root and Sutando.app watches ~/.sutando/workspace/state/, the rename lands in one dir and the DispatchSource watches another — the pointer never flies. Either core is actually launched with cwd = the workspace (in which case the comment is misleading and should say so), or this should resolve through resolveWorkspace() to match the Swift side. This is THE thing to confirm before merge — the whole feature is an IPC handshake and both ends must agree on the path. (VOICE_SESSION_CONTEXT_PATH / tasks/ / results/ being cited as precedent doesn't settle it — if those also use process.cwd(), they have the same latent bug, just unexercised.)
2. pointer-teacher-tracer/ at repo root. A 300-line POC tracer (swift + py + run.sh) lands as a new top-level dir. Per the CLAUDE.md decision guide a self-contained thing belongs under skills/ or docs/, not a loose root dir. Is the tracer meant to ship, or is it scaffolding that should be gitignored / moved under docs/ or skills/pointer-teacher/?
3. CONTEXT.md at repo root. It's a Pointer-Teacher domain glossary — useful, but a feature-specific doc at repo root competes with README.md/CLAUDE.md. The PR already adds docs/pointer-teacher-design.md and docs/adr/0001-*; the glossary would sit more naturally next to them in docs/.
Merge state: BEHIND — needs an update-branch.
Net: the runtime code is some of the most carefully-defended tool code I've reviewed in this repo. (1) is the only thing I'd block on — confirm the two ends of the IPC agree on the path.
— Lucy (Mac Studio bot)
sonichi
left a comment
There was a problem hiding this comment.
Cold-review pass from Sutando-Mini.
LGTM. The inline tool is tight — every hairpin the Codex/Lucy/Vidhu rounds caught is reflected in the diff:
- Overlay-clear-before-capture (
publishPointerCmd({ hide: true })+ 250ms settle) — fixes the screencapture-ignores-NSWindow.sharingType bias. - Per-invocation unique temp names for both the IPC publish and the sips downscale → no clobber on overlapping
point_atcalls. Number.isFinite+ 0-1000 range guard before publish → no NaN-rocket off-screen.- Dual registration in
inlineTools+ownerOnlyToolsis the correct shape per the bites we've taken on similar tools before.
Load-bearing knobs that the ADR/design protect (and the diff respects):
POINTER_MODEL=gemini-3-flash-previewvia env override (not the deprecatedgemini-3.1-flash-lite-previewfamily — different model, separate deprecation track, verified).thinkingConfig: { thinkingBudget: 0 }— required, the model burns budget reasoning otherwise.- Gemini's native
{"point":[y,x]}0-1000 format, not the Clicky raw-pixel prompt.
Nit (+1 to vidhuUC's flag, non-blocking):
POINTER_CMD_PATH = join(process.cwd(), 'state', 'pointer-cmd.json')andpublishPointerCmd'smkdirSync(join(process.cwd(), 'state'), ...)both resolve viaprocess.cwd(). Per the workspace contract (CLAUDE.md), the canonical resolver isresolveWorkspace()fromsrc/workspace_default.ts. Today they coincide for core (cwd == repo root == workspace), so it's correct — but if$SUTANDO_WORKSPACEever points elsewhere this will silently write to the wrong place. Worth a tracked follow-up issue (vidhu already noted), not a blocker for v1.
Recommend merging after vidhu's workspace_default flag is filed as a follow-up issue. Nice work — the multi-round Codex polish shows in the diff.
|
Hi @chetanunadkat — friendly heads-up: the CLA bot still shows the agreement as unsigned for this PR. Could you sign at https://cla-assistant.io/sonichi/sutando?pullRequest=889 when you get a moment? That's currently the last gate before merge (substance is well-reviewed: 4 LGTMs across Lucy, Vidhu, Bassil, and the Mini cold-review; Vidhu's only flag is tracked as a follow-up in #934). I also just pushed an update-branch to bring this in sync with main so CI re-runs against the latest. Thanks for the careful work on the ADR + Codex review rounds. |
…() (closes #934) (#971) The TS side of the pointer-teacher IPC was resolving the state-file path via process.cwd() instead of resolveWorkspace(). Works on the launch path (Sutando.app starts services from the repo root, and the repo-root state/ happens to be the same APFS inode as workspace/state/ on the dev machine), but silently bifurcates the moment SUTANDO_WORKSPACE points anywhere else — Sutando.app would read <workspace>/state/pointer-cmd.json while point_at writes <cwd>/state/pointer-cmd.json, and the bezier pointer would never fly. No error, just silence. The Swift side already routes through AppDelegate.workspace (which calls resolveWorkspace()), so this brings the TS writer in line with the Swift reader. Vidhu flagged this on #889 review; non-blocking and tracked as #934.
sonichi#889) * feat(pointer-teacher): productionize the embodied pointer into Sutando Brings Clicky's "flying pointer teacher" into the live app. The tracer proved the loop end-to-end; this folds it in: - point_at inline tool (src/browser-tools.ts): captures the main display via :7845, resolves the Target with gemini-3-flash-preview in Gemini's native point format with thinking disabled (ADR-0001 — 1-3px in the POC; flash-lite was 108px off, Clicky's raw-pixel prompt 23-69px off), writes <workspace>/state/pointer-cmd.json, returns the `say` line + an instruction so the voice agent narrates it. - Registered in inlineTools + ownerOnlyTools (both required arrays). - PointerOverlayView + setupPointerOverlay/flyPointer/holdPointer (src/Sutando/main.swift): screenSaver-level click-through window, Clicky bezier flight, beep + pulsing halo, ported verbatim from the tracer. Driven by the app's existing DispatchSource dir-watch idiom (same as watchResults) on <workspace>/state — resolves IPC open item. Running inside Sutando.app's real menubar GUI session is the fix for the "I didn't see any pointer" visibility problem the standalone tracer binary hit (no GUI-session access when launched from the agent shell). v1 ships voice-driven; the arming hotkey stays future scope (privacy property already holds — capture happens only on invocation). AX-first resolution (open item sonichi#3) remains the documented next layer. Swift compiles with the app's build flags; full tsc --noEmit passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pointer-teacher): apply Codex review findings Codex review of 9896c53 — verdict "ship with fixes". All 5 applied: - HIGH (main.swift): flyPointer() now invalidates the hold/pulse/fade timers from a prior point_at and resets halo/alpha/showLabel, so a stale fade can no longer hide a new marker mid-flight. Timers are tracked (pointerPulseTimer/HoldTimer/FadeTimer); the fade closure takes [weak self]. - MED (browser-tools.ts): atomic IPC publish — write a sibling .tmp then renameSync over pointer-cmd.json so the dir-watcher never reads a half-written JSON. - MED: AbortSignal timeouts + res.ok checks on both the :7845 capture and the Gemini fetch — point_at is on the inline lane and must not hang it. - MED: per-invocation temp screenshot path + success flag so a failed sips can't feed a stale prior screenshot to the model; cleaned up. - LOW: validate the parsed point is finite and in [0,1000] before publishing, instead of reporting success on garbage coords. Swift compiles with the app flags; full tsc --noEmit passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pointer-teacher): match Clicky's real pointer technique + add diagnostics User showed the marker was a fat 44px triangle inside a big pulsing halo ring, centered ON the target so it covered what it points at — nothing like Clicky's. Read Clicky's OverlayWindow.swift and matched it: - Removed the stroked halo ring entirely. Clicky has none — just a soft blue glow shadow (radius 8, grows with flight scale). - Triangle 44px → 18px (Clicky uses 16; +2 for retina legibility), Clicky's exact vertex ratios. - Rest pose is now a -35° cursor tilt (PointerOverlayView.restAngle), not a straight-up triangle — reads as a pointer. - pollPointerCmd offsets the target +8px right / 12px below the element (Clicky's "sit beside it, not on top") + 20px screen clamp, so the marker points AT the thing without covering it. - Mid-flight scale swoop to ~1.3x (Clicky's animateBezierFlightArc), settles to scale 1 + cursor pose on arrival. - Hairline white edge for legibility on dark/light UIs; smaller offset label so it never covers the target. Also added logToFile diagnostics to pollPointerCmd/flyPointer (accepted ts/coords, raw→target, START/ARRIVED, window visible+level) — this is how the "not working" report was root-caused to a rendering issue, not a broken pipeline. Swift compiles with the app flags; restarted + log-verified end to end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pointer-teacher): apply Codex review round 2 Second Codex round on this branch (after 0cfb60c), all 4 findings: - [High] browser-tools.ts: point_at temp file was per-process (`pointer-cmd.json.<pid>.tmp`), so two overlapping async calls clobbered each other's write/rename. Unique per-call temp name via a shared publishPointerCmd() helper (pid+ms+random); atomic rename + Swift ts-guard still picks the winner. - [High] stale-overlay feedback loop: the :7845 server uses the `screencapture` CLI (raw framebuffer, ignores NSWindow.sharingType), so Codex's sharingType=.none would not have worked. Instead point_at publishes {hide:true} and waits ~250ms before capturing; main.swift learns the hide command (cancels timers, alpha 0, orderOut) and flyPointer re-orders the window front. - [Medium] display reconfig: overlay was sized once at launch but pollPointerCmd maps against the current NSScreen.main.frame. Added a didChangeScreenParametersNotification observer + resizePointerOverlay(). - [Medium] POC tracer pointer-overlay.swift: hold() used local pulse/hold/fade timers that survived a new fly(). Mirrored the production stored-timer pattern so they're cancellable. TS typecheck clean; both Swift files compile (-O). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(pointer-teacher): document POINTER_MODEL + verified deprecation status gemini-3-flash-preview has no shutdown date on Google's deprecation page (verified 2026-05-19); the May 25 gemini-3.1-flash-lite-preview deprecation (sonichi#715) is a different model family. Add the POINTER_MODEL env override to .env.example as the zero-code-change escape hatch and record the verified status as design §5 open item 6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…() (closes sonichi#934) (sonichi#971) The TS side of the pointer-teacher IPC was resolving the state-file path via process.cwd() instead of resolveWorkspace(). Works on the launch path (Sutando.app starts services from the repo root, and the repo-root state/ happens to be the same APFS inode as workspace/state/ on the dev machine), but silently bifurcates the moment SUTANDO_WORKSPACE points anywhere else — Sutando.app would read <workspace>/state/pointer-cmd.json while point_at writes <cwd>/state/pointer-cmd.json, and the bezier pointer would never fly. No error, just silence. The Swift side already routes through AppDelegate.workspace (which calls resolveWorkspace()), so this brings the TS writer in line with the Swift reader. Vidhu flagged this on sonichi#889 review; non-blocking and tracked as sonichi#934.
What
Productionizes the Pointer Teacher — Clicky's embodied "point at the thing while teaching" loop, brought into Sutando. Voice/utterance →
point_at(query)→ screen capture (:7845) → Gemini vision →state/pointer-cmd.json→Sutando.appflies a bézier pointer to the target → the voice agent speaks thesayline.Design:
docs/pointer-teacher-design.md· Pointer-brain decision:docs/adr/0001-pointer-teacher-brain.mdWhere it lives (per the CLAUDE.md decision guide)
point_atinline tool (sub-second hot path)src/browser-tools.tsPointerOverlayView+setupPointerOverlay/flyPointer/holdPointersrc/Sutando/main.swiftDispatchSourceidiom)state/pointer-cmd.jsonpointer-teacher-tracer/5 commits: 1
feat+ 3fix(Codex review rounds) + 1docs(model risk). 13 files, +914/−4.POINTER_MODELdefaults togemini-3-flash-previewand is env-overridable (process.env.POINTER_MODEL || …).VISION_MODEL(describe_screen).VISION_MODEL's preview→GA migration was a separate change already onmain(fix(vision): migrate gemini-3.1-flash-lite preview→GA before May 25 deprecation #715, the May 25 2026gemini-3.1-flash-lite-previewdeprecation). This PR does not touchVISION_MODEL.[y,x]0–1000 format +thinkingBudget:0combo is load-bearing and POC-proven (design §4: CLAUDE.md 3 px, Search icon 1 px;gemini-3.1-flash-litewas 108 px off). Per ADR-0001 it must not be swapped without re-running the pointing POC.Deprecation risk — checked, not assumed:
gemini-3-flash-previewwas verified on Google's deprecation page on 2026-05-19 — no shutdown date announced. It is a different model family from the deprecatedgemini-3.1-flash-lite-preview. If Google later deprecates the-previewid, thePOINTER_MODELenv override is a zero-code-change escape hatch (documented in.env.example). Re-benchmarking on a GA id is a tracked follow-up (design §5, open item 6).Validation
npm run typecheck(CI gate) — passnpm test(CI gate) — passpointer-teacher-tracer/(./run.sh "where do I commit here?"). This PR has no unit tests by design: the feature is GUI/vision/Swift (screen capture → vision model → AppKit overlay), which the tracer-bullet exercises end-to-end as the proof artifact rather than via mockable units.Privacy / security
point_atinvocation — no ambient capture (the privacy property the originally-planned hotkey gate was protecting already holds; gate deferred as reversible future scope, design §6).Scope guards (deliberate, not gaps)
Vision-only (AX-first resolution is tracked open item #3); single/main display; reactive mode (no Lesson Spine — later layer). All documented in design §5/§6.
🤖 Generated with Claude Code