Skip to content

feat(windows): Windows support across core services + Claude Code core - #1789

Merged
john-the-dev merged 221 commits into
sonichi:mainfrom
thinkall:support-windows-11
Sep 16, 2026
Merged

john-the-dev merged 221 commits into
sonichi:mainfrom
thinkall:support-windows-11

Conversation

@thinkall

@thinkall thinkall commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

What changed, and why?

Adds native Windows support for Sutando's voice, screen capture, clipboard, notifications, task dispatch, dashboard/API, and messaging infrastructure, with PowerShell startup and shutdown scripts. AppleScript automation and the Swift menu-bar app remain macOS-only.

The September 15 follow-up fixes the review blockers on parent 722c77c3: POSIX Python resolver parsing, Windows file-opening command interpretation, notification owner routing, startup argument quoting and dependency installation, Unicode workspace/clipboard handling, abandoned dispatcher claims, and Windows image resizing. Process-probe failures now report unknown status instead of claiming the service is stopped. The unrelated subagent-policy additions were removed.

Review first

  1. src/platform.ts, src/sutando_platform.py: OS boundaries, literal file opening, image resizing and process probes.
  2. src/startup.ps1, src/workspace_default.ps1, scripts/python-binary.{sh,ps1}: clean install, interpreter selection and launch arguments.
  3. src/task-dispatcher.ps1, src/restart.ps1: authorization, instance ownership, interruption recovery and child cleanup.
  4. src/notify.ps1: delegation to the existing dm-result.py owner-resolution and send policy.
  5. Windows regression suites and .github/workflows/windows-runtime.yml.

Authorization and task lifecycle

Owner tasks run through Claude. Discord collaborators require a verified envelope and a current access entry; their tasks also run through Claude. Other non-owner tasks use codex exec --sandbox read-only, or are refused when Codex is unavailable. Owner and collaborator turns in the same channel share a resumed session. Producer-side task-body confinement and collaborator envelope verification remain essential boundaries.

The dispatcher holds an exclusive lifetime lock, publishes results atomically, and archives interrupted claims with an explicit interruption result. It never automatically replays them: actions may already have completed or may still be running after a crash. Restart stops the process tree. Recovery preserves an existing result, but does not promise exactly-once delivery if a result was consumed before the archive step.

Before/after evidence

Parent: 722c77c3. Runtime fix: fb07a5f8; current head 511a2068 also fixes Windows newline conversion in the notification test fixture, reports all Windows regression failures, uses a Python 3.9-compatible watcher annotation, ports pending-question locks, and shares the existing bounded Windows atomic-replace retry policy between pending questions and process pins. Local host: macOS arm64, Node 24.4.1 and temporary PowerShell 7.6.6. Tests use isolated workspaces and deterministic CLI/delivery shims.

python3 tests/skills/worker-pool/pool-inbox-resolver.test.py
Before: Ran 43 tests; FAILED (failures=11)
After:  Ran 43 tests; OK

bash tests/python-binary-sh.test.sh
After: passed=40 failed=0 (includes sh and dash source/resolve cases)

node --import tsx --test tests/platform-windows-io.test.ts
Before: 1 passed, 3 failed
After:  4 passed, 0 failed

python3 tests/notify-ps1-dm-delegation.test.py
Before: 2 errors (shared send_dm never called)
After:  Ran 2 tests; OK (python and py-only launchers)

python3 tests/windows-dispatcher-recovery.test.py
Before: FAIL: task-dispatcher already running (unrelated live PID)
After:  Ran 1 test; OK

The dispatcher test starts the actual PowerShell dispatcher and FileSystemWatcher, checks instance contention, abandoned-claim archival without replay and existing-result preservation, force-kills and restarts it, then receives PORTABLE_OWNER_OK for a fresh task. This is production-path integration with a deterministic inference shim, not a live Windows account-delivery claim.

The workspace encoding test fails on the parent by falling back to the default workspace under cp1252/ASCII; the fix returns the configured Unicode path and restores the caller's encoding settings.

Validation

Local passing checks:

  • TypeScript typecheck, skills typecheck and bundle build.
  • 24 focused Node tests covering platform IO, app switching and file-tool contracts.
  • Python: 13 process tests, 10 platform tests, 4 HTTP tests and 27 screenshot assertions.
  • Resize failure/backend tests: 2 pass; native System.Drawing case requires Windows.
  • PowerShell startup tests: real Node/PowerShell argument round trips, five installation cases, and Unicode workspace resolution.
  • Windows Python launcher regression, documentation audit, AGENTS sync, diff whitespace and full-branch repository review scanner.

Final-head CI (511a2068):

  • Windows runtime: passed. Native startup, Unicode workspace resolution, platform IO, owner/non-owner crash/restart integration, collaborator authorization, notification delivery, System.Drawing resizing, process/lock regressions, TypeScript integrations and Python entry-point compilation all ran successfully.
  • Coverage gate: passed — 98% of changed Python lines (719 lines, 13 missing; required 95%).
  • Python and Node/shell CI: passed — full Python suite, TypeScript typechecking, Node, shell and ESM tests.
  • CLA, static lints and bundle smoke check: passed.

Native CI exposed two additional pending-question portability defects: POSIX-only fcntl imports and reader/writer sharing violations during replacement. Dismissal now uses the shared cross-platform sidecar lock; Windows readers join that lock, and atomic publication shares the existing bounded Windows replacement retry policy with process pins. The 54 dismissal tests pass natively, including concurrent reads, cross-process writers, bounded retries and failure cleanup. Local process-pin atomicity tests also pass 16/16.

Documentation, deployment and remaining review gates

README and canonical docs/built-in-tools.md describe the current Windows behavior. No data migration is introduced. Restart Windows services when deploying; interrupted tasks require checking their outcome before resubmission.

  • A fresh Windows post-restart live round trip (owner, sandboxed non-owner, collaborator, reply delivery and archival) is still required. Earlier live transcripts in the discussion predate the current dispatcher and do not establish this head's behavior.
  • Existing change-request reviews require reviewer action; this update does not dismiss them or substitute for approval.

@CLAassistant

CLAassistant commented Jun 26, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@thinkall
thinkall force-pushed the support-windows-11 branch 2 times, most recently from 4cdeacd to 4e14306 Compare June 26, 2026 06:14
thinkall and others added 19 commits June 26, 2026 14:19
… core

Adds a cross-platform abstraction layer (src/platform.ts + src/sutando_platform.py)
and PowerShell twins of the bash startup/restart/notify/watch + skill installer
scripts so the headless Sutando loop (voice, screen capture, task bridge,
dashboard, agent API, Claude Code core) runs on Windows. macOS-only tools
(AppleScript-driven app automation, QuickTime/Chrome control) degrade with a
clear `macOSOnly` error instead of silently no-op'ing.

- src/platform.{ts,py}: notify / clipboard / screen capture / process probe /
  port-in-use / open-with-default helpers; macOS path = legacy osascript +
  pbcopy + screencapture, Windows path = PowerShell System.Drawing + Get/Set-
  Clipboard + Win32_Process + netstat.
- src/{startup,restart,notify,watch-tasks-stream}.ps1: PowerShell twins of the
  .sh originals. startup.ps1 also installs skills (skills/install.ps1) and
  auto-launches the core Claude Code session via scripts/start-cli.ps1 in a
  fresh Windows Terminal window.
- scripts/start-cli.ps1: Windows twin of scripts/start-cli.sh. Generates a
  temp launcher .ps1 to avoid shell-quoting hell across wt.exe + pwsh.
- skills/install.ps1: junction-based skill installer (no admin needed) so
  /schedule-crons and other slash commands resolve in the core agent.
- src/inline-tools.ts + browser-tools.ts + meeting-tools.ts + recording-tools.ts:
  gate macOS-only tools with macOSOnlyError; rewrite clipboard tool to route
  through platform.ts (cross-platform); replace hardcoded /tmp/ with tmpdir().
- src/voice-agent.ts: relax assertMacOS to a warning; gate Zoom auto-detect
  to darwin; route fatal-close notification through platformNotify; accept the
  AQ.A... Gemini key prefix in addition to legacy AIza...
- src/task-bridge.ts: replace pgrep watcher-liveness probe with a PID-file
  sentinel on Windows; skip fast-path concat (bash-dependent) on win32.
- src/screen-capture-server.py: branch single-display capture through
  sutando_platform.capture_screen so PowerShell System.Drawing is used on
  Windows; per-display screencapture -D<n> stays macOS-only.
- README.md: new "Windows support" section documenting what works, what
  returns macOSOnly errors, and the Windows quickstart.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Claude Code 2.1.168 on Windows does not include the Monitor tool (verified:
not in the agent's runtime tool list AND the literal "Monitor" string is
absent from claude.exe). Without Monitor there is no push-based file-watch
primitive available to the long-running sutando-core agent, so user chat
tasks would only be picked up on the */5 proactive-loop cron tick — too
slow for chat.

- src/task-dispatcher.ps1: standalone FileSystemWatcher over tasks/. Claims
  each new task via atomic rename to .processing, pipes the body into
  `claude --print --dangerously-skip-permissions`, writes results/<id>.txt,
  archives to tasks/archive/. Single-instance via PID sentinel. Smoke-tested:
  ~5s end-to-end per task.
- src/startup.ps1: auto-launches the dispatcher in a hidden background
  window. Opt out with -SkipDispatcher.
- src/restart.ps1: added task-dispatcher to the kill-pattern list.
- scripts/start-cli.ps1: boot prompt changed from /schedule-crons to
  /proactive-loop so the first cron tick is not gated on a 5min wait;
  also generates a temp launcher .ps1 to bypass wt.exe/pwsh argv quoting
  issues; renamed $pid loop var to $corePid (PowerShell automatic).
- src/watch-tasks-stream.ps1: rewrote to use FileSystemWatcher.WaitForChanged
  in the main pipeline so Write-Output reaches the script's stdout (the
  previous Register-ObjectEvent path emitted into a separate runspace's
  output stream, never reaching parent stdout).
- CLAUDE.md + skills/proactive-loop/SKILL.md: kept the macOS Monitor
  instructions verbatim, added a Windows-specific paragraph explaining
  that Monitor is absent on Windows and pointing at task-dispatcher.ps1.
- README.md: replaced the cron-workaround paragraph (wrong premise) with
  an accurate description of the Monitor gap and the dispatcher design,
  including the per-task no-shared-context trade-off.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Previously every dispatched task was a fresh `claude --print` subprocess
with no shared context across chat turns. Now each task is mapped to a
per-channel claude session that persists across dispatcher restarts.

- First task on a channel gets a freshly-minted UUID passed to
  `claude --print --session-id <uuid> --output-format json`. Subsequent
  tasks on the same channel use `--resume <uuid>` so claude continues the
  same conversation.
- Channel resolved from the task file's `channel_id` header (defaults to
  `local`). Voice / discord / telegram / etc. each keep their own thread,
  so contexts don't bleed.
- Session map persisted at <workspace>/state/dispatcher-sessions.json,
  written via tmp-file + rename for crash safety. Loaded on startup,
  serialized under a Monitor lock so concurrent task processing doesn't
  race on the map.
- --output-format json so we can extract the `.result` field and check
  `.is_error` structurally instead of pattern-matching stdout. If claude
  rotates the session_id mid-conversation (spec-allowed), the new id is
  captured and persisted.

Verified end-to-end on Windows:
  turn 1 (channel=continuity-test): "remember pineapple" -> "Got it..."
  turn 2 (same channel):            "what was the word?"  -> "pineapple"
  session map persisted across both turns; turn 2 = 4s (resume vs 21s cold).

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
…ival

- src/task-dispatcher.ps1: `Get-TaskHeader` now stops scanning at the first
  `task:` line, so a multi-line task body that legitimately contains text
  like `channel_id: forge` cannot spoof a header. Mirrors the TS-side
  stop-at-task convention from PR sonichi#982 (`task-bridge.ts:_isVoiceTask`).
- skills/proactive-loop/SKILL.md step 1: spell out that the consumer must
  move processed task files from `tasks/<id>.txt` to `tasks/archive/<id>.txt`.
  On macOS the long-lived task-bridge.ts archives automatically; on Windows
  the core agent is sometimes the consumer (dispatcher restart, etc.) and
  must archive itself — otherwise the watcher's `$seen` set only suppresses
  re-emission within one watcher lifetime, and a restart re-emits every
  file still in `tasks/`.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
On Windows, str(repo) yields backslash paths (C:\code\sutando), so the
forward-slash-only replace left the slug unchanged. Passing that absolute
path into Path.home()/...slug discards the home prefix entirely, so the
memory-dir check always probed an impossible path and perpetually reported
"not yet created" regardless of actual memory state. Replace ':' and '\'
too, matching Claude Code's project-slug convention (C--code-sutando).

Co-Authored-By: Claude <noreply@anthropic.com>
…rm find_pids

health-check shelled out to /usr/bin/pgrep, which doesn't exist on Windows —
the call raised FileNotFoundError, was swallowed, and every bridge was
reported "configured but not running" even with a live process. Add
find_pids() to sutando_platform (pgrep on macOS/Linux, Get-CimInstance on
Windows) and route the discord/telegram bridge check through it.

find_pids honors a trailing `$` as an end-of-command-line anchor on both
platforms (pgrep regex; PowerShell EndsWith), so the real
`python src/<name>-bridge.py` process matches while transient shells that
merely mention the script mid-command-line are excluded — otherwise the
Windows substring match tripped the "multiple processes" zombie warning.

Verified on Windows: discord-bridge (live PID) now reports ok/running.

Co-Authored-By: Claude <noreply@anthropic.com>
…artup

Add msvcrt byte-range locking fallback in single_instance.py for Windows
(POSIX fcntl path unchanged), launch the Discord bridge from startup.ps1,
and make the single-instance test suite Windows-safe by closing lock FDs
before temp-dir cleanup and passing paths via env instead of source
interpolation.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds tests/find-pids.test.py exercising the matcher used by health-check's
bridge/service detection: nonsense→[], finds a spawned child by a unique
marker, and the `$` end-anchor matches a trailing marker while rejecting a
mid-command-line one (the property that keeps the bridge check from matching
transient shells that merely mention the script).

Writing the test surfaced a self-match in the Windows branch: the pattern is
interpolated into the Get-CimInstance command, so the probe's own powershell
process matched unanchored `.Contains` patterns. Tag the script with a fixed
sentinel and skip it (and $PID). Anchored callers (the only shipping use,
e.g. `discord-bridge\.py$`) were already unaffected; verified still ok.

Run with `python` (not `python3`) on Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
The dispatcher piped raw task files to the chat subprocess with no
framing, and two parse bugs let task metadata leak into the prompt:

- Get-TaskPrompt swallowed trailing headers (source, channel_id,
  access_tier, ...) into the body, so the model treated them as the
  user's message and narrated its own internals back.
- Get-TaskHeader bailed at the `task:` line, so channel_id/source were
  never read; every chat task collapsed onto one shared session and the
  framing never applied.

Add a per-source conversational preamble (Discord/Telegram/Slack/voice)
instructing the reply to answer directly and never mention dispatchers,
task IDs, sessions, files, or repo state. Stop the prompt body at the
first known header; honor a fixed header allowlist when scanning. Force
UTF-8 end to end so degree/dash glyphs stop arriving as mojibake.

Co-Authored-By: Claude <noreply@anthropic.com>
Path.rename raises FileExistsError on Windows when the target exists; Path.replace overwrites atomically on all platforms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
bridge-supervisor.ps1 keeps a chat bridge online via a restart-on-exit loop with capped backoff; install-bridge-task.ps1 registers it as a scheduled task.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On Windows, `python3` on PATH is the Microsoft Store app-execution-alias
stub: `command -v python3` succeeds but running it exits 49 without
executing. This broke `scripts/sutando-config.sh` — the foundation of
workspace resolution the proactive loop + startup chain depend on.

Add `scripts/python-bin.sh::resolve_python_bin` — returns the first
candidate that actually runs via a functional `-c pass` probe
(python3 → py → python), rejecting the stub. No-op on macOS/Linux where
real python3 stays first. Honors SUTANDO_PYTHON_BIN override.

Route all interpreter calls in sutando-config.sh through the resolved
$PYBIN.

Co-Authored-By: Claude <noreply@anthropic.com>
All five Windows PowerShell scripts resolved the workspace with pre-M0
logic ($SUTANDO_WORKSPACE else ~/.sutando/workspace). The M0 contract
(v0.8 / sonichi#1440) makes the default <repo>/workspace/ and no longer honors
$SUTANDO_WORKSPACE, so on a stock box the bash/Python services and the
.ps1 services resolved different workspaces — a split-brain where the
dispatcher polled one tasks/ dir while the watcher/bridges used another.

Add src/workspace_default.ps1::Resolve-SutandoWorkspace — shells to the
canonical `bash scripts/sutando-config.sh workspace`, falls back to
<repo>/workspace/ when bash is unavailable, CWD-independent via
$PSScriptRoot. Twin of workspace_default.py / .ts. Repoint startup,
watch-tasks-stream, task-dispatcher, bridge-supervisor, and notify to
dot-source it.

Co-Authored-By: Claude <noreply@anthropic.com>
The Windows task-dispatcher feeds the task: body to
`claude --print --dangerously-skip-permissions` (full caps, no sandbox). For
non-owner Discord tasks the bridge appends a ===SUTANDO SYSTEM INSTRUCTIONS===
fence telling the consumer to run under codex --sandbox read-only, but writes it
AFTER the metadata headers. Get-TaskPrompt stops at the first header line, so the
fence is discarded and the bare body would run unsandboxed — violating CLAUDE.md's
non-owner-MUST-be-sandboxed contract. macOS is unaffected (core agent reads the
whole file incl. the fence).

Add a tier guard in Process-Task: read access_tier via Get-TaskHeader; if set and
not owner, write the bridge's `Sandbox unavailable; refusing non-owner task.`
sentinel, archive, and return without invoking claude. Owner tier and no-tier
tasks (voice/local) are untouched.

Verified on Windows 11: synthetic non-owner task (malicious body + fence) → tier
reads `other`, guard fires, fence confirmed stripped from prompt. Owner/no-tier →
guard does not fire. PSParser clean; find-pids/single-instance/proactive-routing
suites still pass.

Co-Authored-By: Claude <noreply@anthropic.com>
The macOS Zoom auto-detect block lost its catch clause during the rebase
conflict resolution, leaving the top-level try unterminated. esbuild/tsx
then failed to parse the module (TransformError: Unexpected brace at the
next top-level import).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Thin shortcut for restart.ps1 -StopOnly, mirroring stop.sh's delegation
to restart.sh --stop-only. Documented in the README Windows-scripts list.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
discord-bridge.py hardcoded INBOX_DIR = Path("/tmp/discord-inbox") and
called .mkdir(exist_ok=True). On Windows /tmp has no parent on the drive
root, so module import died with WinError 3 — blocking even importing the
bridge (and the discord-bridge-mod-judge-dispatcher test suite) on Windows.
Use the OS temp dir off-POSIX; the macOS/Linux /tmp path is byte-for-byte
unchanged (os.name == "posix" branch). Added parents=True so the Windows
temp subdir is created robustly.

Co-Authored-By: Claude <noreply@anthropic.com>
The non-owner codex dispatch wrote its prompt to a hardcoded
`/tmp/sutando-<id>.txt`. On Windows `Path("/tmp/...").write_text()` raises
WinError 3 (no parent), the same class of bug already fixed for INBOX_DIR in
e8c29a5 but missed here. Resolve the dir via tempfile.gettempdir() off-POSIX;
the posix branch keeps the literal `/tmp` path so macOS/Linux is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Get-TaskHeader scanned every line for key: value, so a non-owner could
forge �ccess_tier: owner on a second line of the 	ask: body and bypass
the tier sandbox guard in Process-Task. Mirror Get-TaskPrompt's body
boundary: once inside the 	ask: body, ignore lines until a genuine
trailing-header line or the ---/fence marker, matching keys only outside
the body. Defense-in-depth beyond producer-side confine_user_content.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@thinkall
thinkall force-pushed the support-windows-11 branch from 4e14306 to 436446d Compare June 26, 2026 06:19
@thinkall thinkall changed the title feat(windows): Windows 11 support across core services + Claude Code core feat(windows): Windows support across core services + Claude Code core Jun 26, 2026

@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: 872cc934bb

ℹ️ 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".

Comment thread src/task-dispatcher.ps1 Resolved
Comment thread src/startup.ps1 Resolved
Comment thread src/voice-agent.ts Resolved
Comment thread src/inline-tools.ts Resolved
thinkall and others added 3 commits June 26, 2026 14:31
… is not forge defense

The Get-TaskHeader body-skip comment (436446d) claimed to close the forged
`access_tier: owner` privilege-escalation hole as defense-in-depth. Empirically
it does not: the body boundary is detected by HEADER_LINE_RE, so a forged
`access_tier:` line ends the body and is then read as a trusted header (a
`task:`+`access_tier: owner` two-liner returns 'owner', verified via pwsh). The
real, sufficient defense is producer-side confine_user_content, which
U+200B-prefixes header-shaped user lines so they no longer match here. Comment
now states this accurately and scopes the unconfined hand-dropped file as
needing a producer-side body fence, not a dispatcher heuristic. Behavior
unchanged (comment-only).

Co-Authored-By: Claude <noreply@anthropic.com>
Filter Get-CimInstance to python processes before the CommandLine
scan instead of enumerating every Win32_Process.

Co-Authored-By: Claude <noreply@anthropic.com>

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the macOS-path changes and cross-platform abstractions. CLA signed ✓, all CI checks pass ✓ (tsc + tests, workspace-resolution lints).

Changes to existing macOS files — look correct:

  • discord-bridge.py: tmp.replace() instead of tmp.rename() — right fix; on Windows rename() raises if the destination exists, replace() is the cross-platform atomic swap. The tempfile.gettempdir() substitutions for /tmp are correct.
  • health-check.py: find_pids replaces the hardcoded /usr/bin/pgrep -f; slug now handles : and \ for Windows drive paths.
  • inline-tools.ts / browser-tools.ts / recording-tools.ts / meeting-tools.ts: macOSOnlyError guards at the top of each macOS-specific execute() — clean degradation, no macOS behavior change.
  • scripts/sutando-config.sh: sources python-bin.sh to avoid the Windows Store stub. No-op on macOS (probe passes immediately). Adds one python3 -c pass call per invocation — fast in practice since this is called once per pass, not in a tight loop.

One question for author/maintainer:

README documents the Windows workspace default as %USERPROFILE%\.sutando\workspace\, while macOS defaults to <repo>/workspace/ (in-repo, per M0 contract). That's a platform-divergent default — intentional? If the workspace is expected to hold git-ignored runtime state and the user is running from a git checkout, an in-repo default would be consistent across platforms. If the Windows default is intentional (user profile = more standard on Windows), that's fine, but it should be noted in docs/workspace-config.md so users migrating between machines know the paths differ.

Cannot verify on macOS:

The task-dispatcher.ps1 security properties (non-owner access-tier guard, atomic file claim) and the PowerShell-specific services (startup.ps1, bridge-supervisor.ps1) need a Windows reviewer. The PR description's claim that Get-TaskHeader is confined outside the task: body (to prevent header-forgery by a non-owner) is load-bearing from a security standpoint — worth a second set of Windows eyes before merge.

Overall the macOS path is clean. Deferring to a Windows reviewer + the maintainer for the workspace-default question.

@stellaxiang stellaxiang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the macOS changes, platform abstraction, and dispatcher security model. Architecture is sound; one documentation bug needs fixing before merge.

What looks correct:

  • src/platform.ts: Clean abstraction layer. Soft macOSOnlyError on unsupported platform — no crash. PowerShell clipboard/notify/kill/port-check implementations look correct for the stated behaviors.
  • task-dispatcher.ps1 security model: Get-TaskHeader skips the task body region to avoid body-to-header confusion. The comment honestly disclaims that access_tier: owner forged in a trailing header position would be read as real — and correctly notes the actual defense is confine_user_content (PR #1743, already in main). Non-owner tasks are refused rather than silently downgraded: that's the right conservative behavior for a host that has no codex --sandbox equivalent available.
  • workspace_default.ps1: Correctly shells to bash scripts/sutando-config.sh workspace first (M0 canonical), falls back to <repo>/workspace/, and explicitly does NOT honor $SUTANDO_WORKSPACE (matches the v0.8/#1440 deprecation). Good.

Blocking: README workspace default doesn't match the code

README Windows section says:

defaults to %USERPROFILE%\.sutando\workspace\ unless SUTANDO_WORKSPACE is set in .env

But workspace_default.ps1 does not implement that default anywhere — it falls back to <repo>/workspace/ (the M0 in-repo default). And $SUTANDO_WORKSPACE is explicitly NOT honored per the resolver code (NOTE: $SUTANDO_WORKSPACE is intentionally NOT honored.). A Windows user following the README would look for files in %USERPROFILE%\.sutando\workspace\ and set SUTANDO_WORKSPACE — both wrong. Fix the README to match the actual resolver: <repo>/workspace/ (same as macOS), configurable via sutando.config.local.json.

Cannot verify — Windows CI gap

The CI doesn't have a Windows runner, so the task-dispatcher.ps1 end-to-end flow, FileSystemWatcher atomic rename, and bridge-supervisor.ps1 lifecycle management can't be CI-verified. The PR notes this. John's comment about needing Windows eyes for the dispatcher is correct — this is a known gap, just flagging for the maintainer to track.

Minor: macOS behavior unchanged

Confirmed: every macOS-specific path is still taken when process.platform === 'darwin'; no new code runs there. John's earlier review of the macOS changes is accurate.

(Bot review — no merge; owner merges.)

Stand: Stella Act I

bassilkhilo-ag2 and others added 2 commits September 15, 2026 23:07
startup.ps1 launches the core through scripts/start-cli.ps1, which
handed Start-Process argument arrays containing $psExe.Source
(C:\Program Files\PowerShell\7\pwsh.exe) and a %TEMP% launcher path.
Start-Process joins array elements with bare spaces, so both paths
split at the space: Windows Terminal received `C:\Program` as the
command, and a profile directory with a space broke the launcher path
on the plain-window branch too. startup.ps1 already solved this for its
own launches with ConvertTo-NativeArgumentString, but the helper was
defined inside startup.ps1 only.

The helper moves verbatim to scripts/native-arguments.ps1 (next to
scripts/python-binary.ps1, the existing dot-sourced helper), and
startup.ps1, start-cli.ps1 and the dispatcher's -Background self-launch
all dot-source it, so there is one quoting policy. The dispatcher was
already quoting $PSCommandPath by hand; it now uses the shared helper,
which also handles embedded quotes and trailing backslashes.

The generated launcher interpolated the repo path, the claude path and
the arguments into single-quoted literals, and only the arguments had
their quotes doubled. A path such as C:\Users\O'Brien produced a
launcher that does not parse. Every interpolated value now goes through
one ConvertTo-SingleQuotedLiteral helper.

tests/windows-startup.test.ps1 now dot-sources the shared helper and
runs start-cli.ps1 end to end with mocked process discovery, for both
the Windows Terminal and the plain pwsh branch, with pwsh under
"Program Files", a claude path containing an apostrophe and a TEMP
directory containing spaces and an apostrophe. The mocked Start-Process
joins arrays the way the real cmdlet does, and the resulting command
line is round-tripped through a real child process to prove the argv
boundaries survive; the generated launcher must parse and must resolve
to the same claude path and arguments. Against the previous start-cli
the round trip fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
restart.ps1 ran `taskkill /T /F` on every task-dispatcher match and on
the sutando-core claude process. A restart requested from chat runs as
task-dispatcher -> claude --print -> pwsh restart.ps1 (or
sutando-core -> pwsh restart.ps1), so tree-killing the dispatcher or the
core also killed restart.ps1 before it reached startup.ps1, leaving
every service stopped. Nothing restarts them until the user notices.

Two mechanisms, each covering what the other cannot:

1. A full restart now relaunches itself detached (Start-Process with
   redirected output to <workspace>/logs/restart.log, -Detached and
   -LauncherPid) and the launcher exits immediately. The detached
   instance waits for the launcher to exit, then stops and starts. This
   is what keeps the restart alive after its caller dies: without it, a
   restart that merely avoided killing itself would still be a child of
   `claude --print` and bound by its Bash tool timeout, which can end a
   long startup together with the services it launched. The launch uses the shared
   ConvertTo-NativeArgumentString, so a repo path with spaces survives.

2. The stop list is computed by Get-SutandoStopPlan from one
   Win32_Process snapshot: it walks the ParentProcessId chain from the
   current process (terminating on unknown parents and on cycles from
   recycled ids) and never tree-kills an ancestor. An ancestor match is
   stopped on its own with `taskkill /F`, which removes the stale
   dispatcher or core without reaching this script. This is the
   correctness guarantee for -StopOnly (stop.ps1 from chat, which does
   not detach because it has no startup to protect) and for a detached
   instance whose parent link still resolves.

Relaunching detached was chosen over only excluding ancestors because
exclusion alone leaves the restart inside the caller's lifetime and
timeouts. Detaching alone was not enough either: taskkill /T follows
parent ids, so correctness would rest on the launcher having exited
first. The cost is that an interactive full restart prints the log path
instead of streaming startup output, and its exit code no longer
reflects startup's; the README says where the log is.

tests/windows-restart.test.ps1 runs the real restart.ps1 against
synthetic process tables with Get-CimInstance, taskkill, Start-Process
and pwsh mocked: the dispatcher-ancestor chat chain (dispatcher stopped
alone, other services and the core tree-killed, startup run once), the
core-ancestor StopOnly chain (no relaunch, no startup), an orphaned
detached instance, a recycled-id cycle, and the plain invocation, which
must only relaunch itself detached with the exact quoted arguments.
The core-ancestor case fails against the previous script. It is wired
into the Windows runtime workflow next to windows-startup.test.ps1 and
also passes on macOS pwsh.

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

Copy link
Copy Markdown
Collaborator

I pushed fixes for the three code issues from my re-review (as a maintainer edit on top of 5aa03212). Each new test fails at 5aa03212 and passes at 06526e91.

N1: a failed archive no longer stops the dispatcher (d1ea3de9)

  • Retry: src/task-dispatcher.ps1 retries the archive move 20 × 100 ms, the same pattern as atomic_replace.py.

  • Persistent failure: it logs the error and leaves the .processing claim where it is.

    • Only task-*.txt files are ever dispatched, so that task can't run twice.
    • The next startup archives the claim once the file is free.
  • No throws: neither the main loop nor the startup sweep can throw anymore.

  • Test: tests/windows-dispatcher-recovery.test.py has a new archive-failure case with the archive dir read-only. It checks:

    • the result is published once;
    • the dispatcher serves the next task;
    • a restart gets past both stuck claims and keeps running;
    • once the dir is writable, the claims are archived, with exactly 3 Claude calls for 3 tasks.

    At 5aa03212 it fails with "dispatcher exited 1".

  • Reproduced on macOS pwsh 7.6.6 (archive dir mode 555):

    • before: uncaught exception: Access ... denied, the dispatcher stopped, and it stopped again after restart;
    • after: archive failed after 20 attempts; claim left in place and the task will not run again, and the dispatcher was still running after restart.

N3: core launch arguments are quoted in start-cli.ps1 (f23718d5)

  • Shared helper: ConvertTo-NativeArgumentString moved unchanged into scripts/native-arguments.ps1. startup.ps1, scripts/start-cli.ps1 and the dispatcher's -Background self-launch all dot-source it, so there is one quoting policy.

  • Also fixed: a path with an apostrophe (C:\Users\O'Brien) produced a launcher script that didn't parse.

  • Test: tests/windows-startup.test.ps1 now runs start-cli.ps1 on both the Windows Terminal and plain pwsh branches, with:

    • pwsh under a Program Files path;
    • a Claude path containing an apostrophe;
    • a TEMP folder with spaces.

    The command line is round-tripped through a real child process, and the generated launcher must parse. At 5aa03212 it fails with "Arguments changed".

N2: restart.ps1 no longer kills its own caller (06526e91)

Two mechanisms, because each closes a gap the other leaves:

  • Detached relaunch. A full restart relaunches itself detached. The copy that was called exits right away, and the detached copy waits for it, then stops and starts everything, logging to <workspace>/logs/restart.log. This survives the dispatcher, or the claude --print that asked for the restart, being killed. It also avoids that call's Bash-tool timeout cutting a long startup short.
  • Ancestor exclusion. The kill list comes from one process snapshot and walks the parent chain, stopping on unknown parents and on cycles. A matching service that is an ancestor of restart.ps1 is stopped without /T. This protects -StopOnly (used by stop.ps1), which doesn't detach.
  • Trade-off: a full restart run from a terminal now prints the log path, and its exit code no longer reflects startup. The README line is updated.
  • Test: tests/windows-restart.test.ps1 (new, wired into windows-runtime.yml right after the startup test) runs the real restart.ps1 against made-up process tables: the chat chain, stop from the core, an orphaned detached copy, a parent-id cycle, and the plain call, which must only relaunch detached. At 5aa03212 the core case fails, because the core is tree-killed.

Checks run locally (macOS)

  • Parser check on all 7 changed .ps1 files: 0 errors.
  • windows-dispatcher-recovery.test.py: OK.
  • windows-startup.test.ps1: pass. windows-restart.test.ps1: pass.
  • windows-discord-python and windows-workspace-encoding: pass.
  • scripts/review-checks.sh --diff on the change: PASS.
  • ruff check (0.15.22) on the changed .py: pass.

Only Windows CI can prove

  • windows-dispatcher-live and windows-dispatcher-collaborator, which exercise -Background with the shared helper; they need claude.cmd shims.
  • Real taskkill and Win32_Process behaviour.
  • Whether the detached restart survives its caller on Windows. If Claude Code puts its children in a kill-on-close job object, the detached copy would be caught too.
  • Real Defender or OneDrive locks. The test uses a read-only dir instead.

Still open from my review, not code: a live Windows round trip at the final head, liususan091219's CHANGES_REQUESTED, and an independent second approval.

🤖 Generated with Claude Code

@elCaptnCode

elCaptnCode commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Native Windows review at head 06526e91 (re-checked against 7b19f0f1), on a real Windows 11 install rather than pwsh on macOS. I ran the PowerShell suites myself and drove a live voice session against this head. Items marked reproduced were executed here; nothing is inferred from a green badge.

Environment. Windows 11 Home 10.0.26200 · pwsh 7.6.6 · Python 3.13.7 · Node 24.19.0 · claude present only as claude.ps1 / claude.cmd (no .exe).


New: the voice agent does not know it is on Windows

This is not in any existing review, and it only shows up in a live session.

src/voice-agent-config.ts hardcodes the host into the system instruction — "You run entirely on the owner's local Mac — not in the cloud. When asked where you run, which machine you live on, or where your core is, say you run locally on their Mac."

On Windows that is the only statement about the host the model ever receives — buildInstructions() contains zero occurrences of win32, Windows, or process.platform. Meanwhile inlineTools (src/inline-tools.ts:1437) is a static array whose only conditional is _presenterActive, so the model is also shown press_key, type_text, volume, brightness, toggle_tasks, slide_control and fullscreen on Windows.

The single correction anywhere in the system is inside macOSOnlyError — "Running on ${process.platform}; ask the user to perform this action manually." — which the model reads only after it has already called a macOS-only tool.

Observed (reproduced, live voice session at this head): the agent believes it is on a Mac, then switches to knowing it is on Windows partway through a session — deterministically, at the first macOS-only tool failure. It fails safely (macOSOnlyError, no false success, which matches the design @stellaxiang approved), but every Windows session begins with the agent mis-modelling its own machine and planning around tools that cannot work.

Already fixed upstream — @thinkall landed c6fa5e85 ("identify the host platform in voice prompts") independently while I was writing this, and it is in the current head 7b19f0f1. I had the same fix staged and have dropped it in favour of theirs; nothing is needed here. Recording the observation anyway because it was found by running voice on Windows rather than by reading, and because of the next paragraph.

One correction worth keeping for anyone who revisits the tool surface: switch_app is not macOS-only — it has a real Windows branch via activateWindowsApp (inline-tools.ts:287-298), and its macOSOnlyError is Linux-only. Filtering "tools that call macOSOnlyError" would therefore delete a working Windows tool. Of the eight, seven are genuinely macOS-only. Narrowing the advertised surface on non-macOS is still open, and is the part c6fa5e85 does not address — the model is still shown press_key, type_text, volume, brightness, toggle_tasks, slide_control and fullscreen on Windows.

Also confirmed working by voice in the same session: clipboard round trip with ° / — / → / CJK (no mojibake), screen capture and description, and macOS-only tools refusing cleanly without dropping the session.


@bassilkhilo-ag2's N1 — verified against the trigger it actually describes

The committed regression, tests/windows-dispatcher-recovery.test.py, is skipUnless(PWSH and os.name == "posix") and injects failure with a mode-555 archive directory. The commit message describes a different trigger: "Defender, the search indexer and OneDrive briefly hold newly written files open and the rename fails with a sharing violation."

Two different error paths, and the sharing-violation one had no coverage on the only platform where it happens. windows-dispatcher-live.test.ps1 — the suite the skip message defers to — has no archive-failure case either.

So I injected the real trigger: an open handle with FileShare.Read (no delete share), which is what a scanner holds. First, that the premise is right (reproduced):

MOVE: threw -> IOException: The process cannot access the file because it is being used by another process.
RETRY-AFTER-RELEASE: succeeded

Then the fixed dispatcher against it (reproduced, isolated workspace):

1_dispatcher_survived_failed_sweep         True
2_interrupted_notice_published             True
3_stuck_claim_left_in_place                True
4_new_task_served                          True
6_dispatcher_alive                         True
7_restart_survived_same_claim              True
8_archived_after_release                   True
10_claude_calls_expect_1                   1
11_stuck_result_is_interrupted_notice      True

[...] task-stuck: archive failed after 20 attempts; claim left in place and the task will not run again
      (The process cannot access the file because it is being used by another process.)
[...] task-stuck: archived interrupted claim without retrying

It survives the failed sweep, keeps serving, survives a restart against the same claim, archives cleanly on release, and invokes claude exactly once — no replay. Suggested follow-up: add a sharing-violation case to windows-dispatcher-live.test.ps1; the committed Windows coverage still only exercises the read-only-directory path.

Why this matters in practice

My own production Windows install runs a build predating this fix, and it shows the bug this PR removes:

tasks/task-1789255395642.txt.processing   claimed Sep 12 19:23
results/task-1789255395642.txt            never created

A local-voice task claimed three days ago, worker died, and that claim is the only log line about it. The dispatcher has restarted since and walked straight past it — that build has 0 occurrences of Resolve-InterruptedTask and never sweeps *.processing. This PR's dispatcher has 3 and sweeps at task-dispatcher.ps1:599, so the user would have received the interrupted notice instead of silence.

N2 — checked on a real process tree

N2 was filed "Read, not run on Windows". I built a genuine three-deep chain whose ancestor's command line matches task-dispatcher, then called the production Get-SutandoStopPlan from the descendant against a live Win32_Process snapshot. No plan was executed.

probe in own plan? = False   (must be False)
   PID 60548  Tree=False  <- ancestor; Tree MUST be False
   PID 50324  Tree=False  <- ancestor; Tree MUST be False
ancestors in plan: 2; any tree-killed: False

Ancestors land in the plan with Tree=False, so they are stopped alone rather than with /T — a restart running beneath the dispatcher survives.

Scope honesty: tests/windows-restart.test.ps1 is fully synthetic by design (line 2: "no real process is stopped"; it shadows Get-CimInstance, taskkill, Start-Process, pwsh). So N2 is now decision-verified against real process data, which is better than "read", but the kill has still never been executed end to end.

start-cli.ps1 quoting

tests/windows-startup.test.ps1 passes natively here — it reports failing on @bassilkhilo-ag2's Mac only because it copies Homebrew's node. It spawns real child processes and round-trips process.argv, so this is real execution: native argv round trips, 2 start-cli launches and 5 installation cases passed.

Live round trip — partial, and labelled as such

CI drives the dispatcher with a claude.cmd shim echoing canned JSON. I ran the same path with the real CLIs (reproduced):

Leg Result
claude resolution claude.ps1 — the shim path, no .exe on this box
Owner turn OWNER_LIVE_OK via the real API
Post-restart turn POSTRESTART_OK, session resumed, not recreated
Guest → sandbox routing correct
Unsigned collaborator: true not trusted into the owner path — routed to the sandbox

This closes the shim question from the original Codex review: a .ps1 shim resolves correctly, a case CI's claude.cmd never exercised.

Two legs remain open and I am not claiming them. No Discord bridge delivery — access_tier derives from the Discord sender_id, so the collaborator and non-owner legs need messages from other people's accounts. And the sandbox produced no reply: codex is installed but unauthenticated here (404 on model refresh), so the guest path returned Sandbox unavailable. Routing is proven; the sandbox reply is not. I would keep live-test-requested set until the Discord legs are done.

Other gates, run natively

windows-dispatcher-live.test.ps1 full green (interrupted_claim_archived, restart_descendants_stopped, post_restart_result: WINDOWS_OWNER_OK, reused_pid_ignored, duplicate_dispatcher_blocked, sandbox + collaborator + structured-error paths) · windows-restart.test.ps1 · PowerShell parse gate over every .ps1 · tsc --noEmit rc 0 · ruff clean · 10-test Python regression batch, 0 failures.

Also fixed in thinkall#6

N3 is broader than filed. resize_image fails on U+2018 and U+201B as well as U+2019 — reproduced before/after:

path segment before after
Smith, O'Brien (U+0027) ok ok
O’Brien (U+2019) fail ok
O‘Brien (U+2018) fail ok
O‛Brien (U+201B) fail ok
spaces, $, backtick, [](), ;&, CJK ok ok

Both paths now travel in the environment, matching openWithDefault.

windows-dispatcher-live.test.ps1 fails open on workspace isolation. It isolates via SUTANDO_TEST_MODE=1 + SUTANDO_WORKSPACE, but that override is honored only inside src/sutando_config.resolve_workspace, so Resolve-SutandoWorkspace reaches it only when the Python probe works. With python not resolvable on the PATH the test builds, the resolver silently returns <repo>/workspace — and the test drives the real dispatcher against a developer's live workspace, then fails on a timeout with that dispatcher still running, because $dispatcherPid was never learned so the cleanup tree-kill never fires.

Reproduced — I hit this before diagnosing it. With python hidden from PATH:

python visible: False
intended : C:\Users\...\Temp\iso-12517ccf...
resolved : C:\Repos\...\repo\workspace

The test now resolves through the production resolver and refuses to start unless it matches the temp path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 01ca5234-bb52-47e4-8e86-33e745ea385a
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 01ca5234-bb52-47e4-8e86-33e745ea385a
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

@elCaptnCode — coordinating on my last standing blocker for this review.

CI at head 1042a9f586323405c45453494ed8b316fed3a58b finished (I approved the 18 stuck action_required runs). All green, including PowerShell + Windows runtime, whose live dispatcher job confirms the routing itself is correct: non_owner_result: WINDOWS_SANDBOX_OK, and the collaborator-authorization suite covers forged-body/tampered/unsigned/wrong-channel/revoked cases, all correctly sandboxed. But that same job's own output shows the sandbox couldn't actually produce a reply in this CI environment: sandbox_failure: "Sandbox unavailable (codex exit 125) — no reply generated." So CI proves the routing is right; it can't prove a real reply comes back.

That's exactly the gap you already have the setup to close — the live Rui-Mac / your-Windows Discord bridge from your 2026-09-09 comment on this thread. Could you run one more round at this head (or whatever head is final when you get to it): a genuine non-owner/collaborator message through that live Discord setup, and post the actual request + reply here, plus whatever confirms it went through Codex read-only routing (log line, timestamp, or similar) and which revision it ran against. No credentials in the post, obviously.

This closes the last item on my 5aa03212/19:14 review — everything else I raised is fixed and verified. Whenever Rui has a non-owner message ready to send, the channel/bot mention on the Discord side is between the two of you; I don't have visibility into that setup from here.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Reviewer decision, explicit as requested: I'm not converting to approve yet, and I'm keeping the live non-owner round trip as a pre-merge item rather than a documented follow-up. Explaining why, since this is a real ask to reconsider and deserves a real answer, not just a restated position.

What CI green actually proves here, precisely. non_owner_result: WINDOWS_SANDBOX_OK and the 8-case collaborator-authorization suite prove the routing decision is correct — a non-owner request gets classified and dispatched to the sandbox path, not the direct path. That's real and it's good evidence. It does not prove the sandbox path works end-to-end on real Windows, because the same job's own log says why: sandbox_failure: "Sandbox unavailable (codex exit 125) — no reply generated." There is no codex binary in this CI environment, so nothing has ever executed the actual reply-generation step on this platform. Routing-correct-but-unexercised is a different claim than working.

Why that specific gap matters on this PR, not as a general principle. This isn't a case of holding a routine Windows port to some abstract live-test standard. This PR's own history is the evidence: HOME unset breaking the capture-token path, screencapture shelling out unconditionally, quoted-path suffix matching breaking process detection, python-binary.sh's bash-only syntax breaking a POSIX caller — every one of these was CI-green and found only by someone actually running the code on real Windows. The non-owner sandbox path is newer than all of those (ab294a1, 2026-09-11) and has had zero live exercise. Given the base rate on this exact PR, "CI passed" has not been a reliable predictor of "works on real Windows" here.

Why I'm not treating it as a documented follow-up. This is a security boundary, not a feature gap — it's the mechanism that keeps a non-owner from getting direct/unsandboxed access. A documented-follow-up gap on a security boundary means it ships live and gets found in production if it's wrong, which is a worse place to find it than here.

Making it easier to close, since I don't think the ask should just get a flat no. It doesn't have to go through Discord specifically — that's just the fastest existing harness. Any genuine live invocation works: elCaptnCode (or anyone with a real Windows install) hand-crafts one non-owner task file, runs it through the actual dispatcher at this head, and posts the real output. That's a much smaller lift than a live cross-instance Discord session, and it's the same evidence I asked for either way.

I'll re-review and move my CHANGES_REQUESTED the moment that lands — everything else on this PR is genuinely done.

elCaptnCode and others added 7 commits September 16, 2026 22:06
resize_image built its PowerShell script by interpolating the input and
output paths into single-quoted literals, escaping only the ASCII
apostrophe (U+0027). PowerShell also treats U+2018-U+201B as single-quote
delimiters, so a profile or folder named O’Brien produced a script that
does not parse: the resize failed and screen-capture-server returned
HTTP 500 for every frame over the 400 KB cap.

Both paths now travel in the environment (SUTANDO_RESIZE_INPUT /
SUTANDO_RESIZE_OUTPUT) and the script reads $env:, so no path character
reaches script source. This is the pattern openWithDefault already uses
in src/platform.ts for the same class of bug. The two integers were
already int()-cast and stay interpolated.

Reproduced and verified on Windows 11 / pwsh 7.6.6: before the change
U+2019 fails while ASCII ' succeeds; after it, all of plain, ASCII ',
U+2018, U+2019, U+201B, spaces, $, backtick, brackets, semicolon,
ampersand and CJK path segments resize correctly.

tests/image-resize.test.py gains a native Windows regression that
resizes a real frame under an O’Brien directory, and the existing mock
case now asserts the path is absent from script source and present in
the child environment. Both fail against the previous implementation.

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

windows-dispatcher-live.test.ps1 isolates itself by setting
SUTANDO_TEST_MODE=1 and SUTANDO_WORKSPACE to a temp directory. That
override is honored only inside src/sutando_config.resolve_workspace, so
Resolve-SutandoWorkspace reaches it only when the Python probe works. If
python is not resolvable on the PATH the test builds, the resolver
silently returns <repo>/workspace instead, and the test drives the real
dispatcher against the developer's live workspace: it creates workspace
scaffolding in the checkout, answers whatever real tasks are queued with
the fake claude shim, and then fails on a timeout with that dispatcher
still running, because $dispatcherPid was never learned so the cleanup
tree-kill never fires.

Confirmed on Windows 11: with python hidden from PATH the resolver
returns C:\...\repo\workspace while SUTANDO_WORKSPACE points at TEMP, and
a dispatcher started that way logs workspace=<repo>\workspace.

The test now resolves the workspace through the production resolver and
refuses to start the dispatcher unless it matches the intended temp path.
Cleanup was already guarded against deleting outside TEMP; this guards
the startup side, before any process is spawned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
REVIEW.md caps code comment blocks at 2 lines; the explanation of the
quote-delimiter class belongs in the PR body, which carries it.

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

An empty prompt threw out of Process-Task, so the drain's recovery path
published 'This task was interrupted. Its actions may have completed…'
for a task that never ran. The dispatcher now publishes a specific
outcome, archives the claim and keeps serving.

The native live suite gains two cases: an empty body, and an archive
blocked by a scanner-style open handle (FileShare.Read, no delete
share). The shim now counts invocations so the suite proves neither the
stuck claim nor the interrupted claim is replayed (7 calls for 7 turns).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
describe_screen, point_at and the recording describer shelled out to
sips unconditionally, so Windows sent full-size frames to the model and
point_at wrote its temp file under /tmp. resizeImage() in platform.ts
keeps the sips argv contract on macOS and uses System.Drawing through
PowerShell on Windows, with both paths passed in the environment so a
typographic apostrophe in a profile name cannot break the script.

Tests: mocked Windows and macOS contracts, the failure fallback, and a
native Windows case that paints a 1200x300 PNG and checks the 400x100
JPEG output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tool table was static, so on Windows the model was shown press_key,
type_text, volume, brightness, toggle_tasks, slide_control and
fullscreen and planned around them until the first macOSOnlyError.
forHostPlatform() drops those seven from inlineTools and ownerOnlyTools
off macOS; switch_app stays because it has a Windows branch. macOS keeps
the identical table and order.

The seven join envDependentToolNames, so the behaviour-anchor fixture is
host independent again; the regenerated fixture only removes their
entries, the instruction hashes are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…prove sh sourcing

workspace_default.ps1 now wraps both [Console]::OutputEncoding
assignments in try/catch like task-dispatcher.ps1 does, so a host
without a console cannot throw out of the resolver.

docs/subagent-delegation.md returns to main's text (the only change was
a removed blank line).

The sh/dash no-candidates case in python-binary-sh.test.sh now echoes a
sentinel after sourcing, so it fails against a file that cannot be
sourced under POSIX sh instead of passing on empty output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Native Windows pass at head c6d49bf7 (7 commits on top of 1042a9f5, pushed as a maintainer edit). Everything below was run on this machine; nothing is inferred from a badge.

Host. Windows 11 Home 10.0.26200 · pwsh 7.6.6 · Python 3.12.10 (python 3.11 on PATH, py launcher 3.12) · Node 22.16.0 · Claude Code 2.1.273 as claude.ps1 / claude.cmd (no .exe) · Codex CLI 0.154.0 installed, not authenticated.

What landed

Cherry-picked from thinkall#6 with authorship preserved (the anchor commit there is superseded by 1042a9f5): 3d58dee5 resize under a typographic apostrophe, 1beb46a3 fail-closed dispatcher-test isolation, 2a7be400 comment cap.

New, one concern per commit:

Commit Review item Change
63f7cb1b N4 (empty prompt got the "interrupted" notice) + elCaptnCode's sharing-violation follow-up Empty task publishes its own outcome and is archived. windows-dispatcher-live.test.ps1 adds an archive blocked by a FileShare.Read handle and an empty body; the shim counts invocations so no claim is replayed.
dc15df56 N6 (TS vision paths only called sips) resizeImage() in platform.ts: sips argv on macOS, System.Drawing via PowerShell on Windows, paths in the environment. describe_screen, point_at (temp file now under os.tmpdir(), was /tmp) and the recording describer use it.
91c3d8e2 elCaptnCode: macOS-only tools still advertised on Windows forHostPlatform() drops press_key, type_text, volume, brightness, toggle_tasks, slide_control, fullscreen from inlineTools/ownerOnlyTools off macOS. switch_app stays (Windows branch). macOS table and order unchanged. The seven join envDependentToolNames; the regenerated anchor fixture only removes their entries, instruction hashes unchanged.
c6d49bf7 workspace_default.ps1 setters not in try/catch; stray blank line in docs/subagent-delegation.md; no-candidates sh/dash case passed against the old file All three. The sh/dash case now echoes a sentinel after sourcing.

N5 (probe budget): measured here, probe_pids('discord-bridge') takes 1.39 s cold and 0.94–1.10 s warm, under the 3 s dashboard budget. Left as is.

Before/after

tests/windows-dispatcher-live.test.ps1 (extended) against src/task-dispatcher.ps1 at 2a7be400:
  empty prompt did not get its own outcome: This task was interrupted. Its actions may have completed or may still
  be running; check the outcome before submitting it again.
same suite at c6d49bf7:
  {"interrupted_claim_archived":true,"archive_sharing_violation_survived":true,"stuck_claim_archived_after_release":true,
   "empty_prompt_result":"This task had no message to act on, so nothing was run. Send it again with the request included.",
   "claude_calls":7,"restart_descendants_stopped":true,"post_restart_result":"WINDOWS_OWNER_OK","reused_pid_ignored":true,
   "duplicate_dispatcher_blocked":true,"dispatcher_alive":true,"owner_result":"WINDOWS_OWNER_OK","stale_session_recovered":true,
   "non_owner_result":"WINDOWS_SANDBOX_OK","sandbox_failure":"Sandbox unavailable (codex exit 125) — no reply generated.",
   "unsigned_collaborator_result":"WINDOWS_SANDBOX_OK","structured_error":"task-dispatcher: claude error: API Error: 500 fake gateway"}

tests/voice-behavior-anchors.test.ts at 1042a9f5 + host gate: not ok 16 - tool-table anchor matches the committed fixture
after adding the seven to envDependentToolNames and regenerating: 13 pass, 0 fail; fixture diff is 92 deletions, 0 additions.

Suites run natively at c6d49bf

  • tsc --noEmit rc 0. eslint on the touched files: 0 errors (pre-existing no-explicit-any warnings only).
  • Node: platform-windows-io 8/8 (incl. a native System.Drawing case: 1200×300 PNG → 400×100 JPEG under …\resize-O’Brien-…\frame 王.png), inline-tools-host-surface 3/3, inline-tools-presenter-gate, switch-app-windows, voice-behavior-anchors, screen-companion-work-retained, inline-tools-keystroke-setup-hint: 54 pass, 0 fail.
  • Python: image-resize 4/4 (no skips), find-pids 13/13, notify-ps1-dm-delegation 2/2, sutando-platform 10/10. windows-dispatcher-recovery skips by design (POSIX shims).
  • PowerShell: windows-dispatcher-live (above), windows-startup ("native argv round trips, 2 start-cli launches and 5 installation cases passed"), windows-restart ("4 stop plans and the detached relaunch passed"), windows-workspace-encoding passed.
  • scripts/review-checks.sh over the diff: hardcoded-paths and root-artifacts clean.
  • Not proven here: python-binary-sh.test.sh under MSYS dash, which cannot source the file even at 1042a9f5 (Bad fd number); the sh (bash) cases pass and Linux CI covers dash.

Live round trip on this install (real claude, production scripts, live workspace)

  1. pwsh -File src/task-dispatcher.ps1 -Background → log: task-dispatcher started. claude=…\npm\claude.ps1 codex=…\npm\codex.ps1.
  2. Owner turn (source: chat, access_tier: owner), task asked for the host OS and the token OWNER_LIVE_OK:
    [22:22:01] task-chat-1789582921: processing channel=local-chat session=760ed043-… (NEW)
    [22:22:08] task-chat-1789582921: done
    result: I'm running on Windows 11 with PowerShell as the primary shell (Git Bash is also available). OWNER_LIVE_OK
    
  3. Non-owner turn (source: discord, access_tier: guest, bridge-style system-instructions fence):
    [22:22:17] task-discord-guest-1789582937: processing non-owner task in codex read-only sandbox (access_tier=guest)
    result: Sandbox unavailable (codex exit 1) — no reply generated.
    
    Routing to the sandbox path is real; the reply is not, because Codex is not logged in on this host. The owner has decided not to set Codex up on this machine, so this leg stays at the fail-closed sentinel here.
  4. Restart through the production scripts: src/restart.ps1 -StopOnly (stop plan ran, all services stopped, this Claude Code session untouched), then src/startup.ps1 -SkipCore -NoBrowser (skills install, voice agent 9900, web client 8080, dashboard 7844, agent API 7843, screen capture 7845 all listening; new dispatcher pid 75728 with session map: loaded 2 channel(s)).
  5. Post-restart owner turn on the same channel:
    [22:28:53] task-chat-1789583333: processing channel=local-chat session=760ed043-…   ← resumed, not (NEW)
    [22:29:02] task-chat-1789583333: done
    result: You asked me to end my previous reply with the token OWNER_LIVE_OK. POSTRESTART_OK
    
  6. Services stopped again with restart.ps1 -StopOnly; all five ports free.

Not run here: Discord/Telegram bridge delivery (no bot token on this host), and a verified-collaborator turn (needs a real Discord envelope).

One observation, not root-caused: running startup.ps1 from a bash pipeline (… | grep | tail) left the pipeline waiting after startup exited, so some launched child kept the inherited stdout handle. restart.ps1's detached path redirects both handles and did not show this. Worth a look as a follow-up; it does not affect the desktop launcher path.

CI at c6d49bf7 was held for fork approval; I approved the 18 runs and will re-check them before recording a review.

🤖 Generated with Claude Code

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at head c6d49bf7, run natively on Windows 11 (details and raw output in my comment above, #1789 (comment)).

Decision: APPROVE. This replaces my CHANGES_REQUESTED at 5aa03212.

Status of the "still needed" list from that review:

# Item Status at c6d49bf
1 N1: dispatcher survives a failed archive Fixed in d1ea3de9; reproduced natively with a FileShare.Read handle on the claim (new case in windows-dispatcher-live.test.ps1): result published once, claim left in place, next task served, restart survives, claim archived after release, 7 claude calls for 7 turns.
2 N2: restart must not kill its own caller Fixed in 06526e91; windows-restart.test.ps1 passes natively ("4 stop plans and the detached relaunch"). A production restart.ps1 -StopOnly run from inside a Claude Code session on this host stopped the dispatcher and services and left the calling session alive.
3 start-cli.ps1 quoting Fixed in f23718d5; windows-startup.test.ps1 passes natively (real argv round trips, 2 start-cli launches, 5 install cases).
4 Live Windows round trip at the final head Done at c6d49bf7 with the real claude.ps1: owner turn (OWNER_LIVE_OK), production stop + startup.ps1, post-restart turn on the same channel resumed session 760ed043 and recalled the earlier token (POSTRESTART_OK). Guest task routed to the codex read-only path and returned the fail-closed sentinel; Codex is not authenticated on this host and the owner has chosen not to set it up here, so the sandbox reply itself is not exercised on Windows. Bridge delivery and a verified-collaborator envelope were not run (no bot token on this host).
5 liususan091219's CHANGES_REQUESTED at d8c33719 Her asks are met in the tree: src/platform.ts, src/sutando_platform.py, src/startup.ps1, src/task-dispatcher.ps1 exist; the PR diff against main adds no ~/.sutando/ literal (the lint check passes). Still needs her re-review or a maintainer dismissal; I am not dismissing it in this review.
6 Independent second approval This one. Note: like the 09-15 fixes, the 63f7cb1b..c6d49bf7 commits were pushed from this account as maintainer edits, so treat this approval with the same caveat john-the-dev gave his.

Non-blocking notes from that review: N3, N4, N6 and the workspace_default.ps1 / doc-hunk / sh-sentinel items are fixed at this head; N5 is measured at ~1 s per probe here and left alone. elCaptnCode's tool-surface finding is fixed (91c3d8e2).

CI at c6d49bf7: see the check list on this PR; I approved the fork-held runs and re-checked them before recording this review.

Merge still requires liususan091219's change request to be cleared and the code-owner rule to be satisfied.

🤖 Generated with Claude Code

@john-the-dev
john-the-dev dismissed stale reviews from liususan091219 and liususan091219 September 16, 2026 19:00

Dismissed at Rui’s explicit request. Susan withdrew this finding in review 5059865086; Bassil reconfirmed it resolved and approved current head c6d49bf in review 5226924662. The platform abstraction and PowerShell files are present.

@john-the-dev
john-the-dev merged commit 13e7eb2 into sonichi:main Sep 16, 2026
23 checks passed
sonichi added a commit that referenced this pull request Sep 17, 2026
…loads on Windows

The Windows CI job (added by #1789, after this branch forked) failed the
whole suite at import time: `import fcntl` at module scope has no
Windows equivalent, so every test file that imports notify_reviewers.py
crashed before running a single case.

Guarded the import (try/except ModuleNotFoundError -> msvcrt) and gave
_ledger_lock a lock_fd/unlock_fd pair that branches on which primitive is
available -- same shape as src/file_lock.py's cross-platform helper, which
landed on main via #1789 after this branch forked; duplicated here rather
than rebasing 60+ commits to reach it, with a comment pointing at the fold.

Verified: all 19 notify-reviewers/sci-notify-reviewers test files pass
(rc=0 each), including notify-reviewers-routing-unit.test.py and
notify-reviewers-park-claim.test.py which exercise _ledger_lock directly --
the POSIX path is unchanged behaviorally (still plain fcntl.flock under the
same call sites). ruff clean, no unused imports. Cannot execute the msvcrt
branch on this host; the Windows CI job on this push is the real witness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

live-test-requested A change-request asks for live-test/repro evidence (voice/vision/runtime policy) request-change Has an active CHANGES_REQUESTED review slack touches the Slack bridge/surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants