Skip to content

feat(core): consolidate session restore, plugin extensions and agent safety - #88

Merged
howdeploy merged 15 commits into
howdeploy:mainfrom
BIackFIame:core/7-auto-profile
Sep 27, 2026
Merged

howdeploy merged 15 commits into
howdeploy:mainfrom
BIackFIame:core/7-auto-profile

Conversation

@BIackFIame

@BIackFIame BIackFIame commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Purpose and scope

This is the consolidated PR for the session, plugin and agent-safety work originally submitted as #81–88. It provides one place to finish the implementation and review the complete integration against main.

The intended features remain in scope. The PR stays Draft while the correctness and integration findings in the maintainer review are addressed. Please push follow-up fixes to this PR's existing branch, core/7-auto-profile, and link each fix and its regression coverage in your reply to the review.

Included changes

Original PR Included behavior
#81 Authenticate the orchestration helper with the connection ID issued by the host. This fix and its tests are incorporated in the later stack.
#82 One persisted session model for reopening and resuming cards, including conversation identity and stopped sessions.
#83 Plugin service processes behind separate native-code trust, with host RPC and service lifecycle management.
#84 Plugin contributions to agent launches: selected options, environment variables, secrets and per-run files.
#85 Plugin-provided session environments with prepare, wrap, resume, describe and release operations.
#86 Base protection, plugin decision hooks, launch policies and secret redaction.
#87 Plugin tools for agents, session events and control, card actions and badges, and dynamic launch choices.
#88 Auto launch profiles, third-party-model downgrade, per-run Codex trust and Claude lifecycle handling.

The branch already contains the #82–87 commits; no cherry-picking or history rewriting is needed to consolidate their review. The #81 authentication implementation and regression tests are included through #87. The superseded PR branches and commit history are retained.

The branch also contains the exact Codex restore fix from #80 by @teo-nex. That PR remains a separately tracked dependency; the consolidation does not change its authorship or merge it independently.

Auto profile behavior

  • Normal remains the default, including for spawn_agent children.
  • Auto is offered for Codex (--approve-for-me), Claude Code (--permission-mode auto with sandbox settings), and Grok (--permission-mode auto).
  • A launch contribution marked thirdPartyModel: true downgrades Auto to the provider's accept-edits behavior. Codex and Claude retain the configured sandbox; Grok has no sandbox or decision-hook guarantee here.
  • Codex receives per-run trust for CanvasTTY's own hooks and for subagent folders within the user's selected local project. The implementation does not write these trust settings to the user's persistent Codex configuration.

Validation status

At reviewed head 1f38707009962f5fb4132e4690dfe5e6609cade8, GitHub reports successful verify, windows-pipe-host and macos-cli-resolution checks.

The original author reported 993/993 suite checks, typecheck, electron-vite build, the secret audit, and 47/47 test:even checks, plus live checks with Claude 2.1.281, Codex 0.156.1 and a local model under a temporary HOME. These are the author's reported results, not additional runs by the maintainer reviewer.

The maintainer review is static: code paths, existing tests and documentation were inspected; no local tests, build or live application checks were run. The review identifies missing regression scenarios despite the existing green CI.

After the review was posted, the author refreshed the stack to e2de0d87cd95751604f26ceb00cb1e8033f8908e. The maintainer inspected that delta: review item 8 (stale conversation identity after Reopen) is addressed in code with added regression cases; items 1–7 remain open. The CI results above belong to the earlier reviewed head and are not a claim about this updated head.

Completion before merge

Address each numbered item in the maintainer review, preserve the intended features, add focused regression coverage, and report validation against the final corrected head. Once that evidence is available, this single PR can be reviewed for merging into main.

Builds on howdeploy#80 (teo-nex, "restore each Codex card to its own
conversation"): its capture of the conversation id from authenticated
lifecycle hooks, the validated id saved per card, `codex resume <id>`,
the resume picker when no id is known and a plain restart forgetting
the id are kept as they are. This extends the same exact resume to
Claude Code (`claude --resume <id>`) and OpenCode (`opencode --session
<id>`); the field is renamed from codexThreadId to threadId for that,
with one per-provider check (canonical UUID for Codex and Claude, `ses_`
id for OpenCode) shared by the hook client, the gateway, the store and
the launch, and v1 records' codexThreadId still read.

Settings → General now offers Don't save / Reopen windows / Continue
conversations (settings v21; the old opt-in boolean migrates
true→continue, false→off). Session records move to v2 (v1 stays
readable): last state at quit or exit, the thread id, a per-card restore
flag, and two validated opaque plugin slots (launch options and an
environment ref, 4 KB each). No scrollback, prompts or secrets are saved.

Restore puts parents before children, resumes a recorded conversation
by id, and without one uses a "latest in this folder" flag only when
that CLI has one card in the folder (otherwise it starts fresh with a
note on the card; Codex opens its picker). Finished cards come back
stopped with Restart / Continue (Continue resumes the card's own
conversation), and a card whose environment is unavailable is held
stopped with its reason instead of running locally. Cards get an
options menu with "Don't restore this card".

For plugins: the v2 record's two opaque slots are where later extension
points keep per-card state across restarts. A launch contributor's chosen
options are saved in `options[pluginId]` and an environment's ref in
`environment`, both validated and capped at 4 KB, so a restored card can
be prepared or placed again (or held stopped with a reason) without the
core knowing what the values mean.
Manifest apiVersion 2 adds `services`: bundled single-file JavaScript
entries (integrity-declared like hook entries in modular plugins). A new
PluginServiceSupervisor runs each service of an enabled plugin as its own
process (process.execPath + ELECTRON_RUN_AS_NODE, cwd = plugin folder,
allow-listed environment without keys, NODE_OPTIONS or CANVASTTY_*),
speaks newline-delimited JSON-RPC 2.0 over stdio (1 MB messages, 15 s
request timeouts, 64 pending), restarts with backoff (at most 5 in 10
minutes), stops politely then with SIGTERM/SIGKILL on disable, uninstall,
update, module change, revoke and quit, and keeps a bounded per-plugin log.

Services run only after a separate per-plugin "Extension native code"
confirmation in Settings -> Agents. Install never grants it; it pins each
entry's SHA-256 (checked before every start) and is revoked by update,
module change, disable, or a changed entry file.

How a plugin uses it: its sandboxed surfaces call their own plugin's
services with host.service.request(serviceId, method, params) and receive
host.service.onEvent; the plugin id is bound by the frame host or the
identity-checked plugin window. A service may call back `log`,
own-plugin `storage.*` (storage permission), `event`, and `secrets.get`
(secrets permission) for its own plugin's secret, for example an API key
of a model it calls; anything else is -32601. This is the base the
following extension points (launch, environments, decisions, tools,
sessions, cards) add host requests to.

Docs (en/ru/zh), schema, plugin-api.d.ts, example
examples/plugins/service-echo (a canvas app that calls its service, and a
token the page saves and the service reads), and
tests/plugin-services.test.mjs, tests/plugin-policy-budget-secrets.test.mjs.
A trusted plugin service can now declare a `launch` block (permission
launch:contribute, one service per plugin): up to 8 boolean, select or
text fields, optionally limited to some agents. The agent launcher shows
them under Advanced; the person turns a plugin on for one launch, and the
checked values (at most 4 KB per plugin) are saved in the session
record's plugin options slot and reused on restart and restore.

Before such a card is spawned, LaunchPipeline sends each chosen plugin's
service `canvastty.launch.prepare` (a host-only method surfaces cannot
send) and merges the answers in plugin-id order: env, secretEnv (names of
the plugin's own secrets, resolved in main, never shown to the service or
any UI, masked as <redacted:secret> in observe/result, the control CLI's
screen/result and failure details), args (appended before the resume
selection) and per-run files ({launchFiles}, removed on exit). A refusal,
a 5 s timeout, an error, an invalid answer, a missing secret, two plugins
setting one name, a reserved or core-set variable, or an approval or
conversation argument (coreOwnedLaunchArgument) refuses the launch with
the reason on the card; it is never started without the contribution.
Restore holds a card whose plugin is unavailable stopped with its reason
and keeps its record. Launches without options or policies stay
synchronous and unchanged.

What an account or policy plugin also needs:

- launch.policy: a contributor with `policy: true` is also asked before
  every agent launch where the person did not choose it (`chosen: false`,
  empty options). That answer may only refuse; a contribution, a timeout
  or an error refuses too, so a policy never lets a launch through by
  failing. Policy-only contributors are not shown in the launcher.
- A select may declare `optionsFrom: "service"`: the launcher asks
  `canvastty.launch.options` (3 s) and lists up to 64 more choices (the
  plugin's accounts, say) after the declared ones; such a value is any
  short text the service re-checks when it prepares.
- spawn_agent takes `launchOptions` ({pluginId: {field: value}}), checked
  exactly like the launcher's, so an orchestrator can start a subagent
  with the account a plugin tool picked.
- Claude Code applies only its last inline --settings, so a plugin's
  inline --settings is merged into CanvasTTY's own (hooks kept); one
  that sets permissions, hooks, sandbox, defaultMode or apiKeyHelper is
  refused.

Docs (en/ru/zh), schema, plugin-api.d.ts, examples
examples/plugins/launch-env (options, a service-filled Profile) and
examples/plugins/yolo-guard (a policy that refuses YOLO launches), and
tests/launch-contributors.test.mjs, tests/plugin-launch-choices.test.mjs,
tests/plugin-policy-budget-secrets.test.mjs.
A trusted plugin service can now list `environments` kinds (permission
environment:provide, up to 8 kinds per plugin with optional launcher
fields and appliesTo, "terminal" included). Kinds are unique within the
plugin and may be split over its services (one per module); each is
answered by the service that lists it. The launcher's Advanced section
shows "Where" (default "This computer"); while a kind applies to
terminals, Open terminal opens the same launcher (folder and Where)
instead of opening at once.

EnvironmentRegistry sends host-only requests: prepare (once, 15 s:
opaque ref <= 4 KB, badge label, optional cwd that becomes the card's
folder), wrap (before every start, 5 s), resume (10 s), release (10 s)
and describe (3 s, badge text). TerminalManager still spawns the PTY:
the launch is planned (planSpawn), wrapped, then spawned. Wrap output is
validated: an absolute executable or a bare name resolved on PATH, never
a shell string; args array without NUL; env/secretEnv under the launch
contributor rules (reserved names and names this launch already sets are
refused), secretEnv resolved from the plugin's own secrets and masked.
The environment sees the launch's own variables without CANVASTTY_*
names or secret values (secretEnvNames lists the latter).

The ref is saved in the v2 record. Restore resumes every saved
environment first, then plans parents before children; a missing,
disabled or untrusted plugin, a `stopped` answer, an error or a timeout
brings the card back stopped with the reason, keeps its record, and never
runs it locally. Closing a card asks once "Keep environment data?" and
releases with the answer; quitting releases nothing unless saving is off
(keepData: true, reason "quit"); a card closed while preparing releases
the new environment unkept.

Launch contributors and policies now receive the card's `environment`
({ pluginId, kind } or null) in canvastty.launch.prepare, so a policy can
allow risky launches only inside an isolated place; the yolo-guard
example now refuses YOLO only outside an environment.

How a plugin uses it: a worktree, container or remote-host plugin
declares its kinds, answers prepare/wrap/resume/release/describe, and
keeps whatever it needs in the opaque ref. Example
examples/plugins/env-worktree: git worktree add in the plugin's data
folder, wrap sets the folder, resume checks it, describe shows the
branch, release removes the worktree and the branch it created unless
kept. Docs (en/ru/zh), schema, plugin-api.d.ts, architecture,
changelogs, and tests/session-environments.test.mjs.
…edaction

Base protection (core, Settings -> Agents, on by default, switchable):
deny-only local rules (shellParse, commandFacts, the deny rules and their
"what to do instead" messages): elevation, pipe to a shell,
download-and-run, disk and format commands, fork bombs, writes or
deletes outside the working folder (/tmp and home included; deleting the
folder itself), with the agent's own plan and memory folders exempt. It
never allows.

Decision hooks (EP-5): permission-gate.mjs is installed as a PreToolUse
hook (Claude Code, Codex, Qwen Code; shells and file writes, YOLO
included) and opencode-decisions.mjs guards OpenCode's
tool.execute.before, both only for launches that need them (base
protection on or a decision plugin applies). They reach RuntimeGateway
over the session's runtime capability, also with status hooks off.
DecisionHooks runs base protection first, then every trusted service
that declares `decide` (permission decision:provide) answers
canvastty.decide in parallel: any deny wins; else any ask (a timeout,
error or unreadable answer is ask); else an allow counts only after the
person's separate "May allow agent actions" confirmation, revoked with
native code trust. Claude takes deny, ask and allow; Codex and Qwen deny
only; OpenCode deny (throw) and allow (permission reply "once"). Cut
input is never allowed.

A decision service may declare `decide.timeoutMs` (1-60 s, 3 s by
default) when it needs longer, for example to ask a local model: the
host waits that long, sends `budgetMs`, and sizes each card's hook,
helper (CANVASTTY_RUNTIME_DECISION_MS) and gateway deadlines at launch
for the longest budget that applies; the default keeps the old
15 s / 12 s / 10 s.

Redaction registry (EP-8): vault values the process reads or writes,
launch secretEnv values per card, values a service registers
(redaction.register) or reads with secrets.get, also wrapped over lines
or JSON-escaped, plus generic key shapes (incl. keys wrapped across
lines). Applied to observe/result and the control CLI's screen, result
and failure details.

How a plugin uses it: a guard or reviewer plugin declares
`decide: { events: ["pre-tool"], appliesTo?, timeoutMs? }` and answers
{ verdict, reason } or null; base protection still runs first and a
failure never allows. Example examples/plugins/deny-rm; docs
(en/ru/zh), schema, d.ts, architecture, changelogs; tests for the deny
table, redaction, precedence, timeout -> ask, allow gating, budgets, and
the real gate through the real gateway and supervisor.
Agent tools (EP-6): a trusted service with tools:agents declares tools
(name, JSON schema, roles). They are listed in canvastty_agents as
<pluginId>__<tool> (dots in the id written as "_", so the name matches
^[a-zA-Z0-9_-]{1,64}$ that Anthropic and OpenAI accept; long ids keep
their start plus a short hash, and a name two tools would share is
listed for neither). The helper asks the bridge (new list_tools message)
what the session sees: orchestrators get the core tools plus plugin
tools, and an agent or subagent card gets the bridge only when a plugin
tool lists its role, and then sees plugin tools only (core tools are
refused for non-orchestrators). Codex enabled_tools and Qwen
allowed-tools carry the session's exact list; Kimi and Hermes (shared
config) keep the core tools. Calls go to canvastty.tools.call with the
caller id and summary; the host checks top-level arguments, and the
answer is redacted, capped (32 K characters, 96 KB JSON) and bounded at
15 s; failures are error results.

Session events and owned cards (EP-4): sessions.subscribe/list
(sessions:events) with created/restored/status/exited/closed
notifications carrying id, provider, role, parent, folders and the
environment ref; masked plain screen text only with
sessions:read-screen. sessions.create (sessions:launch) starts an agent
card through the normal launch pipeline; sessions.send/stop
(sessions:control) only for cards the plugin started. The owning plugin
is saved on the card's session record (ownerPluginId, validated on
load), so ownership survives a restore.

Card badges and actions (EP-7): cards.setBadge (cards:decorate) sets a
plain-text badge (24 chars, tone, tooltip); cardActions with a
providers/environmentKinds/roles filter appear in the card options menu
and call canvastty.cards.invoke on the service that declares them; the
answer shows as a toast on the card. No HTML.

This carries the canvastty_agents helper fix (the capability's
connection id as CANVASTTY_ORCHESTRATION_CONNECTION_ID) unchanged, since
plugin tools reach agents through that helper; it is the same change as
the standalone fix and drops out when that one lands first.

How a plugin uses it: a results or review plugin offers a tool to
orchestrators or subagents, follows the cards it cares about, starts
and drives its own cards, and puts a badge and an action on matching
cards. Example examples/plugins/collect-demo (Show changes on worktree
cards, collect-demo__diffstat for orchestrators); docs en/ru/zh, schema,
d.ts, architecture, changelogs; tests for manifests, naming, listing per
role, routing, redaction and bounds, ownership across restore, events
without screen text, decorations, and the example through the real
supervisor and git.
…x trust

Auto profile: offered only where the CLI has a native auto mode, checked
with each CLI's --help under a fake HOME: Codex --approve-for-me (its own
reviewer in its workspace-write sandbox), Claude Code
--permission-mode auto with its sandbox ({ enabled,
autoAllowBashIfSandboxed: false }) merged into the one --settings, and
Grok --permission-mode auto. Normal stays the default and unchanged;
spawn_agent children stay Normal. The launcher has an Auto button with
a note, cards show an "Auto" badge, and the control CLI (--profile auto),
plugin sessions.create and saved cards accept it.

Third-party models: a launch contribution (or a policy answer) may set
`thirdPartyModel: true`; it only restricts. Auto then runs as
accept-edits in the same sandbox (Codex --sandbox workspace-write
--ask-for-approval on-request, Claude/Grok --permission-mode
acceptEdits), metadata.autoDowngraded is set and the badge reads
"auto · edits", so a model the CLI vendor does not review never gets
the vendor's auto approval.

Codex trust: CanvasTTY's own hooks get per-run `-c hooks.state` trust
(the hashes Codex records), so a card no longer stops at "Hooks need
review". A Codex subagent in or below its orchestrator's folder gets
per-run `-c projects` trust, and launch contributors receive that folder
as `trustedFolder`. Plugins cannot pass --approve-for-me,
approvals_reviewer or -c hooks...

Claude status: the ✳ title reads idle; once a card's hooks report, only
hooks end a turn, and a declined prompt settles idle 3 s after the
answer when no hook moved the card.

How a plugin uses it: an account plugin marks launches that route to a
non-vendor endpoint with thirdPartyModel so Auto downgrades itself, and
can seed the trusted folder into the account's own CLI home. Docs
en/ru/zh, d.ts, changelogs, UI contract; launch-env example (a local
model profile); tests/auto-profile.test.mjs.
@howdeploy howdeploy changed the title feat(agents): add the Auto profile, native sandboxes and per-run Codex trust feat(core): consolidate session restore, plugin extensions and agent safety Sep 27, 2026

@howdeploy howdeploy left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@BIackFIame — the direction of this work matches what we want for CanvasTTY. Please keep the features and finish the integration here. We are consolidating #81–88 into this PR so there is one implementation branch, one discussion and one set of merge criteria.

Review outcome: changes requested before merging into main. The findings below are about concrete code paths in the combined implementation, not a request to replace the design or remove the new functionality.

Reviewed head: 1f38707009962f5fb4132e4690dfe5e6609cade8. This was a static review of the implementation, its callers, existing tests and documentation. I did not run local tests, the build, or live CLI/UI checks. The reproduction and verification cases below are scenarios to cover, not claims that I executed them. The three GitHub CI checks were green at this head.

Update after the concurrent stack refresh: I inspected the delta to e2de0d87cd95751604f26ceb00cb1e8033f8908e. Item 8 is addressed at the code-review level: the restore plan now carries the selected conversation identity, fresh launches discard the previous ID, and regression cases were added for Reopen and held cards. I have not run those tests. Items 1–7 remain open; their implementation paths were not changed by this update. The refreshed #82–87 heads are all ancestors of the refreshed #88 head, so their changes are retained in this consolidated PR.

1. P1 — Preserve a pending environment choice across shutdown and restore

Code: TerminalManager.create, environment preparation, persisted session serialization.

create() stores environmentChoice only on the in-memory session. It immediately persists the card with exitCode: null, so the saved state is running. However, extras.environment is not assigned until environment.prepare resolves, and the serializer does not preserve the pending choice.

If the app exits or crashes while preparation is pending, the saved record looks like an ordinary local session. On the next start it can launch locally in the original directory. This violates the documented guarantee that a selected environment never silently falls back to this computer. A failed prepare also loses its original selection after saving and restoring the failed card, so a later Restart can take the local path.

Required behavior: persist enough information to distinguish a pending or failed environment launch from a local session. After restore, either safely recover the selected environment or hold the card stopped with an explanation. Do not infer “local” from a missing prepared ref. Keep the chosen options available for retry, and make the lifetime of any late prepare result explicit.

Regression coverage: defer prepare, create a card, save/shut down before it resolves, then restore from that record and assert that no local PTY is spawned. Cover a preparation failure followed by app restart and manual Restart as well. The existing environment shutdown test waits for all three launches to finish; its pending-prepare case closes the card rather than shutting down and restoring it.

2. P1 — Deliver the initial subagent prompt only after the asynchronous launch is ready

Code: AgentControlService.spawn/send, asynchronous creation, inputChecked.

spawn() calls TerminalManager.create() and immediately calls send() for initialPrompt. With launch options or an applicable launch policy, create() returns a card whose process is still null. send() accepts it because exitCode is still null; inputChecked() then returns false, and input() discards that result. The orchestration caller receives success while the child eventually opens without its task.

This is an integration regression caused by making launch asynchronous without updating the prompt-delivery contract. It also affects send_to_agent while preparation is pending. A policy can trigger it even when the caller did not explicitly select plugin options.

Required behavior: give callers a reliable launch/delivery contract. Await readiness or retain the pending initial prompt and deliver it exactly once when the process is ready. A refused, cancelled or superseded launch must not be reported as successful delivery, and queued input must not reach a later unrelated restart. Apply the fix at the shared boundary used by these callers.

Regression coverage: call the real orchestration/control path with both prompt and launchOptions, hold preparation pending, then resolve it and check the PTY receives the prompt exactly once. Cover policy-only preparation, refusal, and cancellation before readiness. The existing spawn_agent launch-options test uses a synchronous stub and does not supply a prompt.

3. P1 — Validate and merge every supported Claude settings argument form

Code: launch argument validation, mergeClaudeInlineSettings, coreOwnedLaunchArgument.

Validation considers each argv element separately. It parses protected Claude settings only when the element itself starts with {. The merger recognizes only the two-element form --settings, followed by inline JSON. File paths are deliberately left alone.

Consequently, contributions such as the following escape the intended protection:

["--settings={\"disableAllHooks\":true}"]
["--settings", "{launchFiles}/settings.json"]

The first form is not inspected as JSON. The second can point at a contributed file containing protected settings and is not merged with CanvasTTY's own settings. Under the last---settings behavior documented and measured in this implementation, a later settings argument can also displace the host's hooks and sandbox even when the plugin only intended to set an ordinary option.

Required behavior: validate complete option/value pairs and normalize supported forms before merging. Either reject plugin-provided settings files or load and validate the allowed content before merging it. Keep one effective Claude settings payload with core-owned permission, hook and sandbox settings intact. Reject protected overrides regardless of whether they use an equals form, separate arguments, or a file.

This is about enforcing the selected launch profile and preserving host integration. It is not a claim that already trusted native plugin code is OS-sandboxed.

Regression coverage: exercise equals-form inline JSON, settings files, and allowed plugin settings together with core hooks in Normal and Auto. Assert both rejection of protected overrides and preservation of hooks/sandbox for allowed contributions. Check the resulting behavior with the supported real Claude CLI version as well as argv assertions.

4. P2 — Honor decision budgets longer than the supervisor's 15-second default

Code: PluginServiceSupervisor.hostCall, supervisor construction, existing long-budget test.

The manifest accepts decide.timeoutMs up to 60,000, and the hook/helper/gateway deadlines are sized accordingly. However, hostCall() uses Math.min(timeoutMs, this.options.requestTimeoutMs), and production construction leaves the supervisor at its 15,000 ms default.

A service declaring a 45-second budget and returning deny after 20 seconds is therefore replaced by the timeout's ask result at 15 seconds. Increasing the declared budget does not deliver the advertised behavior.

Required behavior: honor the validated host-call budget while retaining the normal surface-request default and an explicit bounded maximum. Keep the helper, gateway and supervisor deadlines consistent.

Regression coverage: pass a decision through an actual supervisor with a declared budget above 15 seconds and an answer after the default limit; verify the answer is retained within budget and times out beyond budget. Controlled timers are fine if the production call chain is exercised. The current 11-second gateway test bypasses the supervisor and never crosses this limit.

5. P2 — Start plugin services after the host APIs they can call are initialized

Code: early service start, later PluginSessions construction, example startup subscription.

pluginServices.sync() starts services before PluginSessions and PluginCards exist. Several asynchronous initialization steps run in between. A fast service can receive canvastty.initialize and immediately call sessions.subscribe while the optional host callback still returns undefined; the supervisor turns that into Unknown host method.

The shipped collect-demo does exactly this subscription on initialize and only logs the failure. It can remain running without the session map it needs to handle its subagents. Waiting for the start promise later does not undo the failed initialization call.

Required behavior: make service initialization happen only after the advertised host APIs and their dependencies are ready. Ensure services can subscribe before restored-session events are emitted, or reliably receive the equivalent initial snapshot.

Regression coverage: delay host startup, use a service that subscribes immediately on initialize, and verify it gets a valid snapshot and subsequent restored/new session events without a manual disable/enable cycle. Include startup calls to any other advertised APIs that have the same ordering dependency.

6. P2 — Redact complete text before selecting the agent-visible tail

Code: AgentControlService.observe/result, PluginSessions.screen.

These paths currently perform redact(tail(buffer)) or redact(plainText(buffer).slice(-limit)). If the cutoff falls inside a registered secret, its prefix is removed before matching. The remaining suffix no longer matches the full known value and need not match a generic credential pattern, so it can be returned to another agent or a screen-reading plugin.

Required behavior: mask the full available text before cutting it to the requested output size. Preserve the existing output bounds. Inspect sibling output paths for the same ordering mistake.

Regression coverage: register a custom secret that does not match a generic token shape, place it across the observation/screen cutoff, and assert no surviving fragment is exposed. Cover observe_agent, get_agent_result, and plugin screen events. Keep the existing wrapped-secret cases too.

7. P2 — Do not classify mutating Git operations as unconditional reads

Code: GIT_READ, classifyGit.

The unconditional read set includes pull, stash, config, branch, tag and other commands with mutating forms. classifyGit() returns for them before checking whether -C selects an outside repository. For example, git -C /other/repo stash pop can change another repository's worktree, and stash clear can delete its saved stashes, without adding a write/delete-outside fact.

Required behavior: separate genuinely read-only forms from mutating forms and apply the existing outside-project policy to the latter. Preserve legitimate read-only Git usage. This does not require turning the heuristic into a general shell sandbox; it requires fixing the explicit misclassification of commands the implementation already recognizes.

Regression coverage: classify outside-repository mutating stash, pull, branch/tag deletion and config-writing forms, alongside read-only status/log and appropriate listing variants. These parser tests should inspect the verdict and never execute the destructive commands.

8. P2 — Resolve the inherited stale conversation ID finding from #82

Status: addressed in the refreshed head e2de0d8 on static inspection; final validation is still required. The original finding below is retained for traceability.

Code: restored threadId assignment. Previous feedback on #82.

The combined head still unconditionally copies descriptor.threadId into the restored session even when the restore plan starts a fresh conversation, such as Reopen. Until a new lifecycle signal replaces it, persistence and later resume can still refer to the previous conversation. Closing before the new ID arrives, or running without lifecycle reporting, leaves the wrong identity attached to the new card.

Required behavior: retain the saved ID for an actual resume or an intentionally held stopped card. A fresh launch must start with no previous conversation ID; its own valid lifecycle signal can then assign the new one.

Regression coverage: restore a saved ID in Reopen mode, save again before any new lifecycle signal, and assert the old ID is absent. Repeat without lifecycle reporting. Verify that exact Resume and held stopped cards still retain the correct ID.

How to finish this PR

  • Address the seven remaining open items on this branch, with a reply mapping each number to the fixing commit and focused regression coverage. Include final validation for the item 8 fix already supplied. If a finding does not apply, explain the exact production path and provide a case that demonstrates the claimed behavior.
  • Preserve plugin services, launch options, environments, decision hooks, agent tools, session events, card actions and Auto. Fix the shared lifecycle/validation boundaries rather than removing the features or adding unrelated architecture.
  • Run the existing required suite, typecheck and build against the final head, plus the focused cases above. Report which real CLI versions/platforms were checked and distinguish live checks from mocks. The original reported live checks are useful, but do not exercise every failure path above.
  • Keep this PR Draft until the corrections and their evidence are available. We can then review the complete corrected implementation for merging into main.

The source history and authorship remain intact. Consolidating the discussion does not merge any of this code into main.

@howdeploy

Copy link
Copy Markdown
Owner

The consolidation is complete: #81–87 are closed as superseded, and #88 is the single open PR for finishing this series. The source branches were retained, and no code was merged into main.

@BIackFIame, I also saw the concurrent stack refresh to e2de0d8 and inspected the four-file delta. The new restore-plan identity handling and regression cases address item 8 of the review at the code-review level. I updated the review to acknowledge that fix; I have not run the new tests. Items 1–7 remain open, and the changes-requested status remains appropriate.

I verified that every refreshed head from #82 through #87 is contained in the refreshed #88 head. Please continue fixes here on core/7-auto-profile so the remaining implementation work and validation stay in one place.

Detailed review and remaining merge criteria.

…restore

create() kept the launcher's environment choice only in memory and saved the
card as running before the plugin had prepared it. A quit or crash while
prepare was pending (or a failed prepare followed by a restart of the app)
therefore restored the card as an ordinary local session.

- The choice is now part of the saved extras (environmentChoice, validated on
  load; an unreadable one drops the card instead of restoring it locally) until
  prepare succeeds and the environment ref replaces it.
- The restore plan holds a running card with a pending choice stopped
  ("environment-pending", with the reason); nothing is prepared or spawned
  until the person restarts it, which prepares again with the saved options.
  Restart refuses when the chosen plugin is unavailable.
- A failed prepare keeps its choice across an app restart the same way.
- A prepare answer for a launch that no longer exists (closed, restarted, app
  quitting) is never adopted or saved; it is released with keepData false.

Tests: tests/session-environments.test.mjs (pending prepare + quit + restore,
failed prepare + app restart + Restart, unreadable saved choice).
With launch options, an environment or an applicable launch policy, create()
returns a card whose process is still null. spawn() wrote the initial prompt
at once; inputChecked() returned false, input() dropped it, and spawn_agent
still reported success, so the child opened without its task. send_to_agent
and plugin sessions.send had the same gap while a launch was pending, and the
control CLI answered "no longer accepts input" for a card that had not started.

TerminalManager.deliverInput() is now the one delivery rule for these callers:
a running card gets the text at once; a card whose launch is pending gets it
exactly once, when that launch has started. A refused, failed, cancelled or
superseded launch (closed, restarted), or one that does not start within 60 s,
delivers nothing, says why, and drops the text, so it cannot reach a later
restart (a per-card launch epoch is bumped on every restart).

- AgentControlService.spawn/send resolve after delivery and reject with
  PromptNotDeliveredError; spawn_agent/send_to_agent map it to a non-retryable
  INVALID_REQUEST that names the session id (the card stays).
- PluginSessions sessions.send waits the same way; sent is false when the
  launch did not start.
- The control CLI answers NOT_READY for a card whose launch is still being
  prepared, instead of SESSION_EXITED, and queues nothing.

Tests: tests/launch-readiness.test.mjs (spawn_agent with launchOptions and a
prompt through ScopedOrchestrationHandler, policy-only launch plus
send_to_agent, refusal, cancellation, plugin sessions.send, control CLI);
existing spawn/send tests now await the delivery.
…load

Validation looked at each argv element alone and parsed protected Claude
settings only when the element itself was inline JSON; the merger knew only
`--settings <json>`. So `--settings={"disableAllHooks":true}` and
`--settings {launchFiles}/settings.json` passed unchecked, and since Claude
2.1 applies only its last --settings, such an argument displaced CanvasTTY's
hooks (and Auto's sandbox) even when the plugin only meant to set `env`.

- LaunchPipeline checks a Claude contribution's settings as option/value
  pairs: `--settings <json>`, `--settings=<json>`, or `--settings
  {launchFiles}/<file>` naming one of its own launch files (read from the
  contribution, checked like inline JSON and passed inline). Any other file,
  a non-object value, a missing value, or core-owned keys (permissions,
  hooks, disableAllHooks, sandbox, defaultMode, apiKeyHelper) refuse.
- mergeClaudeInlineSettings also merges the equals form, so the launch keeps
  one --settings with the core's hooks, permissions and sandbox.
- --bare, --safe-mode, --allowedTools, --permission-prompt-tool and
  --permission-prompts (claude 2.1.281 --help) are core-owned for Claude.

Tests: tests/claude-launch-settings.test.mjs (every form refused for each
protected key; allowed equals form and launch file normalized; real
TerminalManager + LaunchPipeline in Normal and Auto keep one --settings with
the hooks and Auto's sandbox). Checked live with claude 2.1.281 on Ollama
qwen3.5:9b under a fake HOME: hooks fire with an allowed file/equals
contribution; the old equals-form argv silenced every hook; Auto's sandbox
blocked a write outside the project ("Operation not permitted").
…ervisor

A manifest may declare decide.timeoutMs up to 60 000, and the hook, helper
and gateway deadlines are sized from it, but hostCall() capped every host
call at requestTimeoutMs, which production leaves at the 15 s surface
default. A service with a 45 s budget that answered deny after 20 s was
replaced by the timeout's ask at 15 s.

hostCall() now keeps the caller's validated budget up to an explicit bound,
MAX_HOST_CALL_TIMEOUT_MS = MAX_DECIDE_TIMEOUT_MS (60 s); surface requests
keep requestTimeoutMs. The gate's gateway and helper deadlines
(permissionGateTimings) stay longer than that bound.

Test: tests/plugin-policy-budget-secrets.test.mjs "a decide budget above the
supervisor's 15 s request default is honored through the real supervisor,
and still ends at the budget" (DecisionHooks -> PluginServiceSupervisor ->
a real service process: deny after 16 s within an 18 s budget is kept; an
answer after a 16 s budget is ask). Fixture: tests/fixtures/slow-decide-service.mjs.
index.ts called pluginServices.sync() before PluginSessions, PluginCards and
the plugin secrets existed, with several awaits in between. A service that
called sessions.subscribe on canvastty.initialize (collect-demo does) got
"Unknown host method" and kept running without its session map; secrets.get
and cards.setBadge answered "not ready" the same way.

PluginServiceSupervisor takes waitForHost: services wait in start() until
hostReady(). index.ts sets it and calls hostReady() once sessions, cards,
tools, secrets, the launch pipeline and environments exist, and before saved
cards are restored, so a subscriber gets restored cards as events or in its
subscribe snapshot. dispose() releases a start still waiting, spawning
nothing. Without waitForHost the supervisor starts services at once, as before.

Tests: tests/plugin-startup-order.test.mjs (index.ts order with a delayed
host: a service subscribing on initialize gets a valid snapshot, secrets.get
and cards.setBadge answers, and the restored and new card events; dispose
before hostReady). Fixture: tests/fixtures/startup-subscriber-service.mjs.
…gins read

observe_agent, get_agent_result and plugin screen events did
redact(tail(buffer)) or redact(plainText(buffer).slice(-limit)). A cut inside
a registered secret removed its head before matching, and the remaining tail
matches neither the known value nor, for a custom secret, a generic pattern,
so it reached another agent or a screen-reading plugin. Sibling paths had the
same order: failure details (last lines/8 000 characters of the output,
shown to agents and the control CLI) and the control CLI screen (the xterm
viewport, whose top row can hold the tail of a secret that scrolled off).

- AgentControlService observe/result and PluginSessions screen mask the whole
  buffer, then cut to the same bounds.
- Failure details are built from the masked buffer.
- The control CLI screen masks the scrollback plus viewport, then keeps the
  last `rows` lines.

Tests: tests/secret-redaction.test.mjs (observe_agent, get_agent_result,
plugin screen events and failure details with a custom secret across each
cutoff); tests/agent-control.test.mjs (control screen with the secret split
at the viewport's top edge). Existing wrapped-secret cases unchanged.
GIT_READ listed pull, stash, config, branch, tag, remote, reflog, fetch and
push as unconditional reads, and classifyGit() returned for them before the
outside-project check. `git -C /other/repo stash pop` changed another
worktree and `stash clear` deleted its stashes with no write/delete-outside
fact, so base protection had nothing to deny.

- GIT_READ keeps only subcommands with no changing form.
- gitEffect() parses the others: stash list/show, branch/tag listing and
  --contains style queries, config get/--get/--list/one key, remote
  (-v)/show/get-url, reflog/show and fetch/push --dry-run stay reads;
  stash drop/clear, branch/tag delete, remote remove and reflog
  expire/delete are deletes; every other form (stash push/pop/apply, pull,
  fetch, push, branch/tag create/move/force, config set/unset/add, remote
  add/set-url, ...) is a write. With -C (and now --work-tree/--git-dir)
  naming a folder outside the project, those become write-/delete-outside.
  Inside the project nothing changes.

Test: tests/base-protection.test.mjs "git with -C another repository:
mutating forms are writes or deletes outside; read-only forms stay allowed
(parsed, never run)". The parser runs nothing.
@BIackFIame

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. Items 1–7 are fixed as seven new commits on top of e2de0d8; history is unchanged. Each commit has its own regression tests. Each test was run against the unfixed code first and failed there for the reason the review describes.

Final head: f705911baae8508953d3c36c1f56c97e29e921e4.

1. P1: pending or failed environment choice across shutdown and restore

Commit: 25d3dfc1fabe5ba40a05d468d066997dd3b8a15d

Root cause. The launcher's environment choice lived only on the in-memory session. The card was saved as running with no environment, so a quit during prepare, or a failed prepare followed by an app restart, restored the card as a local session.

Fix.

  • The choice is now saved with the card as environmentChoice until prepare succeeds; the environment ref then replaces it.
  • A saved choice that cannot be read drops the card instead of restoring it locally.
  • On restore, a running card with a pending choice is held stopped. It gets the note environment-pending and a reason. Nothing is prepared or spawned until the person restarts the card, and Restart prepares again with the saved options.
  • Restart refuses when the plugin that owns the choice is unavailable.
  • A failed prepare keeps its choice across an app restart in the same way.
  • A late prepare answer is never adopted or saved. This covers a card that was closed or restarted, or an app that is quitting. The answer is released with keepData: false, reason: "closed". This is documented in docs/plugins*.md and plugin-api.d.ts.

Tests (tests/session-environments.test.mjs):

  • "quitting while prepare is pending: the choice is saved, the card comes back held, never local; Restart prepares with it". Covers a deferred prepare, shutdown, restore with no PTY, the late release, a second quit, Restart, and a plugin that is gone.
  • "a failed prepare keeps its choice across an app restart; manual Restart prepares it again, never locally"
  • "a saved environment choice that cannot be read drops the card instead of restoring it locally"

Live (hidden window). I used the built app with a harness plugin whose prepare never answers.

  1. I created a terminal card in that environment and quit. The saved record had environmentChoice and lastState: running.
  2. On relaunch the card was failed with the environment-pending note. It had no PTY child and an empty buffer, and the plugin received no request.
  3. Restart ran prepare, then wrap, and a wrapped PTY started.

2. P1: initial prompt only after the asynchronous launch is ready

Commit: 38c22a3352d120be5c5cd2a91584743b6d814e83

Root cause. spawn() wrote the prompt right after create(). With launch options, an environment or a launch policy, the process was still null, so inputChecked() returned false. input() dropped that result and spawn_agent still reported success.

Fix.

  • TerminalManager.deliverInput() is now the single delivery rule. A running card gets the text at once. A card whose launch is pending gets it exactly once, after that launch has started.
  • Nothing is delivered in these cases: the launch is refused or fails, the card is cancelled, closed or restarted, or the launch does not start within 60 s. The caller gets the reason and the text is dropped. A per-card launch epoch, bumped on every restart, keeps dropped text out of a later launch.
  • AgentControlService.spawn/send resolve after delivery. spawn_agent and send_to_agent answer a failed delivery with a non-retryable INVALID_REQUEST. The error names the session id, and the card stays.
  • Plugin sessions.send waits in the same way and returns sent: false when the text was not delivered.
  • The control CLI answers NOT_READY for a card whose launch is still being prepared. Before, it answered SESSION_EXITED. It queues nothing.

Tests (tests/launch-readiness.test.mjs, through ScopedOrchestrationHandler with a real TerminalManager and LaunchPipeline):

  • "spawn_agent with launchOptions and a prompt: the prompt reaches the PTY once, after the plugin prepared the launch". This case also checks that a later restart gets nothing.
  • "a launch policy alone (no launch options) also delays delivery; send_to_agent waits the same way"
  • "a refused launch is not a delivery: spawn_agent and send_to_agent fail with the reason and nothing is written later"
  • "cancelled before it was ready: the prompt is dropped, never written to a late launch"
  • "plugin sessions.send waits for the prepared launch and reports a refused one as not sent"
  • "the control CLI says a starting session is not ready instead of claiming the terminal exited"

Live (hidden window). I used the real orchestration-helper.mjs over MCP stdio, started by a stand-in claude in a fake HOME from the orchestrator card's own --mcp-config.

  • It called spawn_agent with launchOptions and prompt.
  • The harness plugin answered launch.prepare after 800 ms, and the tool answered after 911 ms.
  • The child PTY received the prompt exactly once, and the child ran with the plugin's env.
  • On the unfixed head the same flow answered at once and the child PTY received nothing. That run used a mocked script, review88/repro2.

3. P1: Claude settings forms and one merged payload

Commit: 51a65f63956efc0113101f2cbf5c4a9cabd5696d

Root cause. Validation looked at each argv element on its own, and it parsed JSON only when the element started with {. The merger knew only --settings <json>. So --settings={…} and settings files passed unchecked, and Claude applies only the last --settings it gets.

Fix.

  • A Claude contribution's settings are checked as option/value pairs. Three forms are accepted: --settings <json>, --settings=<json>, and --settings {launchFiles}/<file> naming one of the contribution's own launch files. Such a file is read from the contribution, checked like inline JSON, and passed inline.
  • The launch is refused for:
    • any other settings file
    • a value that is not a JSON object
    • a missing value
    • permissions, hooks, disableAllHooks, sandbox, defaultMode or apiKeyHelper
  • The merger also handles the equals form, so each launch has one --settings that keeps the core's hooks and permissions, and Auto's sandbox.
  • --bare, --safe-mode, --allowedTools, --permission-prompt-tool and --permission-prompts are core-owned for Claude. They are listed in claude 2.1.281 --help.

Tests (tests/claude-launch-settings.test.mjs):

  • "protected Claude settings are refused in every form: equals, separate value, launch file, other file"
  • "allowed Claude settings are normalized to one inline JSON: equals form and a launch file both work"
  • "Normal and Auto: one effective --settings keeps CanvasTTY's hooks (and Auto's sandbox) with the plugin's allowed keys". This case runs the real TerminalManager and LaunchPipeline.

Live, real CLI. Claude Code 2.1.281 ran under a fake HOME with a clean environment, on local Ollama qwen3.5:9b (not pulled). The argv came from the production chain: AgentRuntimeBridge (the real hooks and decision gate), then LaunchPipeline, then resolveTerminalLaunch. Hooks were observed at a real RuntimeGateway.

Case Result
Normal, route passed as a launch file one --settings with env and hooks; reply "OK"; SessionStart, UserPromptSubmit, Stop and SessionEnd fired
Normal, route passed in equals form same as the launch-file case
Old argv shape, --settings={…"disableAllHooks":true} last no hook fired
Same contribution through the fixed pipeline refused
Auto, route passed as a launch file one --settings with hooks, sandbox {enabled:true, autoAllowBashIfSandboxed:false} and env; --permission-mode acceptEdits (third-party model)
Auto, touch of a file outside the project, allowed by the decision hook the sandbox refused it: touch: …: Operation not permitted; no file
Normal, same touch the file was created
Auto, sandbox: {enabled:false} in a launch file refused

4. P2: decide budgets above 15 s through the real supervisor

Commit: bff160a3743ba7f043a23f1fbdc1aa58114a0b0c

Root cause. hostCall() capped every host call at requestTimeoutMs, which is the 15 s default in production. A longer decide.timeoutMs budget was cut to 15 s.

Fix.

  • hostCall() keeps the caller's validated budget up to an explicit bound, MAX_HOST_CALL_TIMEOUT_MS. The bound equals MAX_DECIDE_TIMEOUT_MS, which is 60 s.
  • Surface requests keep their 15 s default.
  • The gateway and helper deadlines from permissionGateTimings() stay longer than the bound. The test asserts this.

Test (tests/plugin-policy-budget-secrets.test.mjs): "a decide budget above the supervisor's 15 s request default is honored through the real supervisor, and still ends at the budget". It runs DecisionHooks, then a production-configured PluginServiceSupervisor, then a real service process (tests/fixtures/slow-decide-service.mjs).

  • A deny after 16 s within an 18 s budget is kept.
  • An answer after a 16 s budget becomes ask.
  • The test uses real timers and takes about 17 s.
  • On the unfixed head it failed at 15.0 s with ask.

5. P2: services start after the host APIs are ready

Commit: 54f304405759af255fbbdd2efd3715c403bf3272

Root cause. pluginServices.sync() ran before PluginSessions, PluginCards and the plugin secrets existed. A service that called sessions.subscribe on initialize got "Unknown host method". secrets.get and cards.setBadge answered "not ready" in the same way.

Fix.

  • PluginServiceSupervisor takes a waitForHost option: services wait in start() until hostReady() is called.
  • index.ts calls hostReady() once all of these exist: sessions, cards, tools, secrets, the launch pipeline and environments. It does so before saved cards are restored.
  • dispose() releases a start that is still waiting, and nothing is spawned.
  • The ordering is documented in docs/plugins*.md.

Tests (tests/plugin-startup-order.test.mjs, with fixture tests/fixtures/startup-subscriber-service.mjs):

  • "a service that subscribes on initialize gets a snapshot and restored/new events, even when the host starts slowly". It follows the index.ts order with a 400 ms host delay and checks:
    • a valid snapshot
    • answers from secrets.get and cards.setBadge
    • the restored card seen exactly once, as an event or in the snapshot
    • the created event for a new card
  • "disposing before the host is ready ends the waiting start without spawning anything"

On the unfixed head the first test failed with Unknown host method: sessions.subscribe.

6. P2: redact before selecting the tail

Commit: 243778ca272d07a5bc5643b48b40bfaac3a68154

Root cause. These paths redacted after cutting the text: observe, result, the plugin screen, failure details (the last lines or 8 000 characters of the output), and the control CLI screen (the xterm viewport). A cut inside a registered secret left a tail that matched nothing.

Fix.

  • The full buffer is masked first, then cut to the same bounds as before.
  • The control CLI screen masks the scrollback and the viewport together, then keeps the last rows lines.

Tests. Each case places a custom secret with no generic token shape across the cutoff.

  • tests/secret-redaction.test.mjs:
    • "observe_agent and get_agent_result mask the whole buffer before cutting the tail: no fragment of a custom secret survives"
    • "plugin screen text and failure details mask the whole buffer before cutting"
  • tests/agent-control.test.mjs: "the control CLI screen masks a custom secret the viewport's top edge cuts"

The existing wrapped-secret cases are unchanged. The source assertion in tests/terminal-lifecycle.test.mjs now pins the new order.

7. P2: mutating Git forms

Commit: f705911baae8508953d3c36c1f56c97e29e921e4

Root cause. GIT_READ treated these as unconditional reads: pull, stash, config, branch, tag, remote, reflog, fetch and push. classifyGit() returned before the outside-project check.

Fix.

  • GIT_READ now keeps only subcommands that have no changing form. gitEffect() parses the rest.
  • Read-only:
    • stash list/show
    • branch/tag listing and query flags
    • config get, --get, --list, a single key
    • remote, -v, show, get-url
    • reflog, show
    • fetch/push --dry-run
  • Deletes: stash drop/clear, branch/tag delete, remote remove, reflog expire/delete.
  • Writes: every other form.
  • With -C naming a folder outside the project, the changing forms become write-outside or delete-outside. --work-tree and --git-dir are now handled the same way.
  • Inside the project nothing changed.

Test (tests/base-protection.test.mjs): "git with -C another repository: mutating forms are writes or deletes outside; read-only forms stay allowed (parsed, never run)". It checks 30 changing forms, 33 read-only forms and 7 inside-project forms. It only parses and executes nothing.

8. Final validation of the stale conversation-ID fix

I re-ran the regression tests on f705911; all pass.

  • tests/session-restore-v2.test.mjs:
    • "Reopen windows starts every agent fresh and forgets the old conversation; Don't save clears the store". It saves again with no new lifecycle signal after Reopen, and switches to Continue.
    • "Continue resumes each card's own conversation and never shares the folder's latest one". This is the exact-resume case.
    • "finished cards come back stopped with Restart and Continue; skipped cards stay gone"
    • "a placed card whose environment is unavailable stays stopped and never runs locally"
  • tests/launch-contributors.test.mjs:
    • "Reopen with a launch plugin starts fresh and forgets the old conversation"
    • "restore without the plugin holds the card stopped with its reason and keeps the record"
  • tests/terminal-session-exact-resume.test.mjs: 3 of 3.

26 of 26 pass across these files and tests/terminal-session-restore.test.mjs.

Final head results

All of these ran with a fake HOME:

  • npm run typecheck: ok
  • full suite node --test tests/*.test.mjs: 1013 of 1013
  • electron-vite build: ok
  • audit:secrets: ok
  • test:even: 47 of 47

Typecheck and the full suite also pass at every commit:

Commit Tests
25d3dfc 997
38c22a3 1003
51a65f6 1006
bff160a 1007
54f3044 1009
243778c 1012 (the first run had one timing failure in the unrelated Even G2 SRP handshake test; the whole suite passed on re-run)
f705911 1013

Platform and what was live

  • Platform: macOS 26.6.2 (25G83), Node 23.3.0, Electron 43.2.0.
  • Live with a real CLI: Claude Code 2.1.281 on Ollama qwen3.5:9b (item 3 only).
  • Live in the app (hidden windows, keychain stubbed):
    • item 1, with a harness environment plugin
    • item 2, with the real orchestration helper and a stand-in claude
  • Mocked: all unit and regression tests. Items 4 and 5 use a real service process and the real supervisor.
  • Not re-run for this review: Codex, Grok, OpenCode.

@howdeploy
howdeploy marked this pull request as ready for review September 27, 2026 20:56
@howdeploy
howdeploy merged commit 31f287d into howdeploy:main Sep 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants