resolve Q-23: no-install ssh-exec remote model (D-96..D-99, T-330)

The user picked the zero-footprint model for SSH-remote workspaces:
stock OpenSSH only, nothing clide-specific installed on the remote.

- D-96 — footprint: ssh -tt PTYs, ControlMaster exec channels, polling
  watcher, RemoteExecutionContext seam; D-56's single-process rule is
  strengthened (no clide process anywhere but the local app).
- D-97 — ssh://[user@]host[:port]/path naming; auth delegates wholly
  to system ssh in BatchMode; Windows is a known v1 gap.
- D-98 — remote-tool contract: shell+git required, pql/claude degrade
  behind banners, one batched connect preflight.
- D-99 — session + per-workspace state identity re-keys on
  (host, repo), amending D-41/D-77; local keeps its identity.

T-330 closes; T-336 expands into T-398..T-402 (connection manager,
ExecutionContext sweep, remote PTY, polling watcher, preflight) with
the blocker graph encoded in the board.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 05:17:26 +02:00
co-authored by Claude Fable 5
parent 6817abaf96
commit b9c0ec4dea
4 changed files with 191 additions and 1 deletions
+75
View File
@@ -4154,3 +4154,78 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.', NULL, '2026-06-12 01:05:38', '2026-06-12 01:05:38', '2026-06-12 01:05:38', NULL, '06d89d9d6456a6263a30f246ac165f0e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 01:10:56', '2026-06-12 01:10:56', '2026-06-12 01:10:56', NULL, '68dd989ecd85d7a231bf03e42b864417', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'description', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).
2026-06-12 progress: the model-independent slice is in — WorkspaceRef value type (lib/kernel/src/workspace_ref.dart: local/remote ctors, ssh://[user@]host[:port]/abs/path parse with rejection of host-less/path-less forms, uri/display, value equality; exported from kernel.dart) and RecentProject host/port/user (back-compatible JSON — absent keys deserialize local; isRemote; ref getter; relativePath renders host:path; copyWith preserves host identity). Tests: test/kernel/src/workspace_ref_test.dart + new RecentProject cases in project_test.dart.
REMAINING (gated on T-330''s footprint pick / T-336 execution layer): migrate ProjectManager._current/current off bare Directory onto WorkspaceRef (touch all .current?.path callers), open() branching on isRemote, resolveProject remote toplevel via the execution layer, swapBackend remote branch (DaemonTransport seam from T-331 is ready), connection lifecycle + preflight + events --since re-sync. The ssh://localhost loopback verify also needs a reachable sshd (none on the dev box).', NULL, '2026-06-12 01:11:07', '2026-06-12 01:11:07', '2026-06-12 01:11:07', NULL, '9818a9e40bff585344ea3308256ccd67', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', 'status', 'backlog', 'done', NULL, '2026-06-12 03:14:44', '2026-06-12 03:14:44', '2026-06-12 03:14:44', NULL, '862d2f0dd02ae19c1608c05478ed6fd9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', 'description', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.
Resolved 2026-06-12: user picked the no-install ssh-exec model. Artifacts landed: D-96 (footprint + D-56 reconciliation), D-97 (ssh:// URI + system-ssh BatchMode auth, Windows deferred), D-98 (remote-tool contract + batched preflight; pql/claude degrade, shell+git required), D-99 (identity keyed on (host, repo), amends D-41/D-77); Q-23 marked Resolved. The ControlMaster latency probe was waived by the decision — latency is an accepted cost of the chosen model, to be measured during T-336 implementation against a real host. T-336 expanded into concrete tickets.', NULL, '2026-06-12 03:14:52', '2026-06-12 03:14:52', '2026-06-12 03:14:52', NULL, 'aa9bb91c7815bbfd2cfd575287693ac2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'description', NULL, 'The foundation module of the no-install ssh-exec execution layer (D-96/D-97). New lib/src/remote/ssh_connection.dart (Flutter-free — will be exercised by dart-test core suites like lib/src/ipc):
- SshConnection(WorkspaceRef ref): owns one ControlMaster connection per (host, port, user). Open: `ssh -o BatchMode=yes -o ControlMaster=auto -o ControlPath=<user-scope socket dir>/%C -o ControlPersist=60 -N` (or -M + background); the ControlPath dir lives in user scope next to the D-70 socket dir, never in the repo (D-93). Surface open errors verbatim (BatchMode auth failures must reach the UI with the D-97 guidance message).
- run(List<String> argv, {String? cwd, String? stdin}) → (exitCode, stdout, stderr): one exec channel over the master (`ssh <dest> -- cd <cwd> && exec ...` with proper shell quoting — write a quoteForShell helper, test it hard: spaces, quotes, $, globs).
- close(): `ssh -O exit` + cleanup. isAlive via `ssh -O check`.
- The ssh binary path is injectable (constructor param defaulting to ''ssh'') — tests use a stub executable (a shell script recording argv and replaying canned stdout/exit codes), so the full lifecycle is testable without sshd. Pattern: test/remote/ssh_connection_test.dart writes the stub into a temp dir via tester-side File I/O (dart test, no Flutter).
- Latency: measure per-run round-trip in debug logs (D-96 accepted the cost; T-330 deferred the measurement to here).
Done when: connection open/run/close lifecycle green under dart test with the stub ssh; quoting helper covered for the hostile cases; BatchMode failure surfaces a typed exception with stderr attached.', NULL, '2026-06-12 03:15:15', '2026-06-12 03:15:15', '2026-06-12 03:15:15', NULL, '6dd627aa5bdf8c8c4e9b8179c55fa601', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', 'description', NULL, 'The sweep half of D-96: subsystems that touch the workspace (git client, pql client, files listing/IO, search engines, editor registry) gain an ExecutionContext seam instead of bare Process.run/File/Directory, so the same subsystem code serves local and ssh-exec workspaces.
Shape: lib/src/remote/execution_context.dart (Flutter-free) —
- abstract ExecutionContext { Future<ProcResult> run(String exe, List<String> args, {String? cwd, String? stdinText}); plus the file primitives actually used: readFile/writeFile/stat/list/exists/delete (audit the real call surface first — grep Process.run + dart:io File/Directory under lib/src/{git,pql,files,search,editor,daemon}). }
- LocalExecutionContext: today''s behavior verbatim (Process.run + dart:io).
- SshExecutionContext(SshConnection) [T-398]: run → exec channel; file primitives via standard remote commands (cat/stat -c/find/test/rm; write via `cat > file` with stdin) — POSIX only per D-98.
Migration order (one subsystem per commit, zero behavior change proven by existing suites): git/operations.dart → pql/client.dart → files/listing.dart → search → editor/registry.dart. Constructor-inject the context defaulting to LocalExecutionContext so call sites don''t churn.
Watcher: the polling watcher (debounced mtime/git-status sweep emitting the same FileChange events; inotifywait opportunistic) is its own follow-up ticket once this seam exists — don''t fold it in here.
Done when: all five subsystems take an ExecutionContext, local default keeps every existing test green untouched, SshExecutionContext passes a stub-ssh suite for run + each file primitive.', NULL, '2026-06-12 03:15:36', '2026-06-12 03:15:36', '2026-06-12 03:15:36', NULL, 'ad670b5cb9daeb051843fb6da71f602f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'description', NULL, 'Interactive channels of the ssh-exec model (D-96). A remote terminal/Claude pane spawns `ssh -tt [-p port] [user@]host -- cd <cwd> && exec <cmd>` LOCALLY via the existing native PTY (posix_openpt + posix_spawn, lib/src/pty/native_pty.dart) — the local PTY wraps the ssh process, the remote side gets its own pty from -tt. So NO new PTY mechanism: implement a RemotePtySpawner that builds the ssh argv from a WorkspaceRef + command and hands it to NativePty.
- Resize: local PTY resize propagates through ssh automatically (SIGWINCH on the local pty → ssh forwards). Verify with a resize test against the stub.
- Exit/loss: ssh exiting (network drop) is a pane exit — the claude pane''s session-end status line (T-372 work) already renders that; terminal pane likewise.
- Env: CLIDE_SOCK etc. (agent_bootstrap.dart) is Phase-3 scope (T-333) — out of scope here.
- Tests: stub ssh (same harness as T-398) + the existing pty dart-test suite pattern (test/pty is serial under dart test).
Done when: a RemotePtySpawner produces correct argv (quoting via T-398''s helper), spawns through NativePty, resize + exit propagate, covered under dart test with the stub.', NULL, '2026-06-12 03:15:55', '2026-06-12 03:15:55', '2026-06-12 03:15:55', NULL, '29f76f72c9879c8728dfc00c1e604a8a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN2MP35NPPK1BRDYY2M428', 'description', NULL, 'D-96''s watching degrade. Remote workspaces get FileChange events from a poller, not inotify, with the SAME event shapes the local watcher (lib/src/files/watcher.dart) emits — consumers must not know the difference.
- Primary mode: debounced sweep over the ExecutionContext (T-399): `git status --porcelain -z` (tracked changes, cheap on the remote) + a `find -newer <stamp>` pass for untracked/ignored-relevant paths, on a ~2s cadence, diffed against the previous snapshot to synthesize add/modify/delete events.
- Opportunistic upgrade: if the D-98 preflight found inotifywait, hold one long-lived exec channel running `inotifywait -m -r` and translate its lines — instant events, still zero-install (inotifywait is the remote''s own tool).
- Pause the sweep while no remote workspace is open; back off (cadence x4) when the pane is unfocused/minimized to respect the round-trip budget.
- Tests: drive with a fake ExecutionContext replaying canned snapshots; assert synthesized event sequences (created/modified/deleted, rename = delete+create), debounce, and the inotifywait line-translation table.
Done when: poller emits watcher-compatible events from snapshot diffs under test, inotifywait mode translates correctly, and cadence/backoff is config-free but bounded.', NULL, '2026-06-12 03:16:12', '2026-06-12 03:16:12', '2026-06-12 03:16:12', NULL, '7e7a88fb0b5d0193bd3ac83f23b62652', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN4QVFVE51MY2N0CWCVXHM', 'description', NULL, 'Implements D-98. On remote connect (after the SshConnection opens, before ProjectOpened fires), run ONE batched probe over the exec channel:
for t in sh git pql claude inotifywait; do printf ''%s='' "$t"; command -v "$t" >/dev/null 2>&1 && "$t" --version 2>/dev/null | head -1 || echo MISSING; done
(or equivalent single round-trip). Parse into a RemoteToolset { git: version?, pql: version?, claude: version?, inotifywait: bool }.
- shell+git MISSING → the open fails with the probe output in the error (actionable, names the host).
- pql MISSING → planning/query surfaces dark behind the D-95-style banner ("pql not found on <host> — install it there to enable tickets/decisions"); version skew vs the bundled local pql is surfaced (toast), not reconciled.
- claude MISSING → Claude pane disabled with notice; everything else live.
- inotifywait presence feeds the T-401 watcher mode pick.
- Tests: parse table from canned probe outputs (all-present, pql-missing, git-missing, weird version strings); fail-the-open path; banner gating is a later UI ticket under Phase 5 (T-335) — this ticket is the probe + model + open-gate only.
Done when: probe runs as one exec round-trip, RemoteToolset drives open-failure for missing required tools, parse covered under dart test.', NULL, '2026-06-12 03:16:30', '2026-06-12 03:16:30', '2026-06-12 03:16:30', NULL, '86ad94f4dc6f7380b39c3ec96ce67d0f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', 'description', 'PLACEHOLDER — exact tickets crystallize after the Phase-0 spike (T-330) picks the footprint model. Both designs below so the choice is a swap, not a redesign. IF auto-pushed agent: execution layer is mostly transport + provisioning — subsystems (GitClient, PqlClient, FilesService, SearchService, NativePty) run UNCHANGED inside the remote agent; buildDispatcher (main.dart ~:176) split into UI-coupled vs headless-safe registrations; new bin/clide_agent.dart hosts the headless set + an IpcServer; RemoteTransport = SSH-tunneled agent socket (stdio bridge); watching is native inotify in the agent (watcher.dart unchanged); plus a provisioning module lib/src/remote/ (push, version-name, launch, GC). IF no-install ssh-exec: introduce a RemoteExecutionContext that each subsystem uses instead of bare Process.run/File/Directory — ssh -tt for PTYs, multiplexed ControlMaster command channels for git/pql/file ops, and a polling watcher (debounced git-status/mtime, or inotifywait if present) emitting the same FileChange events so the UI is unaware; heavier subsystem surface, zero remote footprint. Either way: pql runs where its .pql/ index lives (remote), git runs where .git/ lives (remote), clide still only wraps pql (D-3 preserved). Blocks Phases 3 and 4. Depends on Phase 0 (T-330) and Phase 1 (T-331).', 'PLACEHOLDER — exact tickets crystallize after the Phase-0 spike (T-330) picks the footprint model. Both designs below so the choice is a swap, not a redesign. IF auto-pushed agent: execution layer is mostly transport + provisioning — subsystems (GitClient, PqlClient, FilesService, SearchService, NativePty) run UNCHANGED inside the remote agent; buildDispatcher (main.dart ~:176) split into UI-coupled vs headless-safe registrations; new bin/clide_agent.dart hosts the headless set + an IpcServer; RemoteTransport = SSH-tunneled agent socket (stdio bridge); watching is native inotify in the agent (watcher.dart unchanged); plus a provisioning module lib/src/remote/ (push, version-name, launch, GC). IF no-install ssh-exec: introduce a RemoteExecutionContext that each subsystem uses instead of bare Process.run/File/Directory — ssh -tt for PTYs, multiplexed ControlMaster command channels for git/pql/file ops, and a polling watcher (debounced git-status/mtime, or inotifywait if present) emitting the same FileChange events so the UI is unaware; heavier subsystem surface, zero remote footprint. Either way: pql runs where its .pql/ index lives (remote), git runs where .git/ lives (remote), clide still only wraps pql (D-3 preserved). Blocks Phases 3 and 4. Depends on Phase 0 (T-330) and Phase 1 (T-331).
2026-06-12: footprint decided — no-install ssh-exec (D-96, user pick). This story is now the EXECUTION-LAYER UMBRELLA for that model; the agent-model branch in the description above is dead. Expanded into: T-398 (SSH connection manager — ControlMaster + exec channel, the foundation), T-399 (ExecutionContext seam sweep across git/pql/files/search/editor), T-400 (remote PTY via ssh -tt through the existing NativePty), T-401 (polling watcher), T-402 (D-98 preflight probe). Blocker graph: 399/400/402 by 398; 401 by 399. Close this story when those five are done; T-333/T-334 unblock then.', NULL, '2026-06-12 03:16:59', '2026-06-12 03:16:59', '2026-06-12 03:16:59', NULL, 'ee6546888baa7789804fc49c278a6e60', 2) ON CONFLICT(hash) DO NOTHING;
+80
View File
@@ -4437,3 +4437,83 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:19', '2026-06-12 01:05:38', NULL, '8cdf49e49dfbc07f6bb2ca2532cc65fc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 2 — remote workspace identity + ssh:// open scheme', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-10 13:26:31', '2026-06-12 01:10:56', NULL, '0a918280afc7a04dd2327d0b96334245', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 2 — remote workspace identity + ssh:// open scheme', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).
2026-06-12 progress: the model-independent slice is in — WorkspaceRef value type (lib/kernel/src/workspace_ref.dart: local/remote ctors, ssh://[user@]host[:port]/abs/path parse with rejection of host-less/path-less forms, uri/display, value equality; exported from kernel.dart) and RecentProject host/port/user (back-compatible JSON — absent keys deserialize local; isRemote; ref getter; relativePath renders host:path; copyWith preserves host identity). Tests: test/kernel/src/workspace_ref_test.dart + new RecentProject cases in project_test.dart.
REMAINING (gated on T-330''s footprint pick / T-336 execution layer): migrate ProjectManager._current/current off bare Directory onto WorkspaceRef (touch all .current?.path callers), open() branching on isRemote, resolveProject remote toplevel via the execution layer, swapBackend remote branch (DaemonTransport seam from T-331 is ready), connection lifecycle + preflight + events --since re-sync. The ssh://localhost loopback verify also needs a reachable sshd (none on the dev box).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-10 13:26:31', '2026-06-12 01:11:07', NULL, 'f02bdd147f6d8d3ee4f5d2a5a8ce2cd4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 0 — governance spike: pick remote-execution footprint model (resolve Q-23)', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 13:26:19', '2026-06-12 03:14:44', NULL, '62b3d0067504b4cd30f2532ce724395f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 0 — governance spike: pick remote-execution footprint model (resolve Q-23)', 'GATE for the whole epic — nothing else starts until this lands. Turn Q-23 into D-records and pick the footprint model with evidence. Decide between: (1) No-install ssh-exec (zero remote footprint) — stock ssh: ssh -tt PTYs for terminal/Claude, multiplexed ControlMaster command channels for git/pql/file ops; nothing clide-specific on the remote; watching degrades to polling; no stateful remote process. (2) Auto-pushed self-managed agent (VS Code Remote model) — clide auto-deploys + version-checks one self-contained binary on connect (a headless UI-less deployment of the existing subsystem library: lib/src/daemon, git, pql, files, pty, ipc/server.dart — hosting the same DaemonDispatcher + IpcServer); gains native inotify watching + stateful backend. MUST answer the user''s agent-model sub-questions: is it a proxy/backend? cleanup/GC on disconnect/version-bump/repo-removal? placement (per-repo .clide/agent vs per-host ~/.clide/agent vs shared — per-host shared likely)? multi-client sharing (two local clides / two users — share one agent or one each? IpcServer is already multi-connection, D-72)? version skew (version encoded in binary name, e.g. clide-agent-<ver>, so versions coexist)? Also measure ControlMaster per-command latency for the ssh-exec model. Also decide the remote-tool contract: what must exist remotely (git/pql/claude/shell), whether pql is hard-required or degrades, and how a preflight surfaces what is missing. Include the D-56 reconciliation: ''single process'' is scoped per-host-per-workspace; a headless remote deployment of the subsystem library does not violate the no-second-local-process rule. Artifacts: Q-23 -> Resolved; new D-records for footprint model + D-56 framing, ssh:// URI scheme, remote auth (system ssh, v1, Windows deferred), remote-tool contract, session identity keyed on (host, repo) amending D-41/D-77.
2026-06-12 status: BLOCKED ON USER — the footprint pick (no-install ssh-exec vs auto-pushed agent) is a user decision (the user explicitly does not want to manage remote installs, but the agent model buys inotify + a stateful backend). The decision menu + agent sub-questions are written up in Q-23''s 2026-06-12 triage block; resolve there, then convert to D-records per this ticket''s artifact list.
Also environment-blocked: the ControlMaster latency probe needs a reachable sshd; the dev box has none. Run the probe against a real remote host during the decision session.
Not actually gating everything: the model-independent backbone is proceeding — Phase 1 (T-331, DaemonTransport seam) is done; the model-independent parts of Phase 2 (T-332: WorkspaceRef, ssh:// parsing, RecentProject host fields) don''t need the footprint pick either. T-336 (execution layer) and Phases 3-5 stay gated.
Resolved 2026-06-12: user picked the no-install ssh-exec model. Artifacts landed: D-96 (footprint + D-56 reconciliation), D-97 (ssh:// URI + system-ssh BatchMode auth, Windows deferred), D-98 (remote-tool contract + batched preflight; pql/claude degrade, shell+git required), D-99 (identity keyed on (host, repo), amends D-41/D-77); Q-23 marked Resolved. The ControlMaster latency probe was waived by the decision — latency is an accepted cost of the chosen model, to be measured during T-336 implementation against a real host. T-336 expanded into concrete tickets.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 13:26:19', '2026-06-12 03:14:52', NULL, 'ab762f5d9b8cdf3c931e77ec4c1fa38d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'SSH connection manager — ControlMaster lifecycle + exec channel', NULL, 'backlog', 'high', NULL, NULL, 'D-96', '2026-06-12 03:15:00', '2026-06-12 03:15:00', NULL, '7b714c2845b3d026a1d47903d22d74dc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'SSH connection manager — ControlMaster lifecycle + exec channel', 'The foundation module of the no-install ssh-exec execution layer (D-96/D-97). New lib/src/remote/ssh_connection.dart (Flutter-free — will be exercised by dart-test core suites like lib/src/ipc):
- SshConnection(WorkspaceRef ref): owns one ControlMaster connection per (host, port, user). Open: `ssh -o BatchMode=yes -o ControlMaster=auto -o ControlPath=<user-scope socket dir>/%C -o ControlPersist=60 -N` (or -M + background); the ControlPath dir lives in user scope next to the D-70 socket dir, never in the repo (D-93). Surface open errors verbatim (BatchMode auth failures must reach the UI with the D-97 guidance message).
- run(List<String> argv, {String? cwd, String? stdin}) → (exitCode, stdout, stderr): one exec channel over the master (`ssh <dest> -- cd <cwd> && exec ...` with proper shell quoting — write a quoteForShell helper, test it hard: spaces, quotes, $, globs).
- close(): `ssh -O exit` + cleanup. isAlive via `ssh -O check`.
- The ssh binary path is injectable (constructor param defaulting to ''ssh'') — tests use a stub executable (a shell script recording argv and replaying canned stdout/exit codes), so the full lifecycle is testable without sshd. Pattern: test/remote/ssh_connection_test.dart writes the stub into a temp dir via tester-side File I/O (dart test, no Flutter).
- Latency: measure per-run round-trip in debug logs (D-96 accepted the cost; T-330 deferred the measurement to here).
Done when: connection open/run/close lifecycle green under dart test with the stub ssh; quoting helper covered for the hostile cases; BatchMode failure surfaces a typed exception with stderr attached.', 'backlog', 'high', NULL, NULL, 'D-96', '2026-06-12 03:15:00', '2026-06-12 03:15:15', NULL, '5e331bc95cdc7a9602a8381357678417', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'RemoteExecutionContext seam — subsystems stop calling Process.run/File directly', NULL, 'backlog', 'high', NULL, NULL, 'D-96', '2026-06-12 03:15:21', '2026-06-12 03:15:21', NULL, '111c25c6542fe5783501f917c6ac29d1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'RemoteExecutionContext seam — subsystems stop calling Process.run/File directly', 'The sweep half of D-96: subsystems that touch the workspace (git client, pql client, files listing/IO, search engines, editor registry) gain an ExecutionContext seam instead of bare Process.run/File/Directory, so the same subsystem code serves local and ssh-exec workspaces.
Shape: lib/src/remote/execution_context.dart (Flutter-free) —
- abstract ExecutionContext { Future<ProcResult> run(String exe, List<String> args, {String? cwd, String? stdinText}); plus the file primitives actually used: readFile/writeFile/stat/list/exists/delete (audit the real call surface first — grep Process.run + dart:io File/Directory under lib/src/{git,pql,files,search,editor,daemon}). }
- LocalExecutionContext: today''s behavior verbatim (Process.run + dart:io).
- SshExecutionContext(SshConnection) [T-398]: run → exec channel; file primitives via standard remote commands (cat/stat -c/find/test/rm; write via `cat > file` with stdin) — POSIX only per D-98.
Migration order (one subsystem per commit, zero behavior change proven by existing suites): git/operations.dart → pql/client.dart → files/listing.dart → search → editor/registry.dart. Constructor-inject the context defaulting to LocalExecutionContext so call sites don''t churn.
Watcher: the polling watcher (debounced mtime/git-status sweep emitting the same FileChange events; inotifywait opportunistic) is its own follow-up ticket once this seam exists — don''t fold it in here.
Done when: all five subsystems take an ExecutionContext, local default keeps every existing test green untouched, SshExecutionContext passes a stub-ssh suite for run + each file primitive.', 'backlog', 'high', NULL, NULL, 'D-96', '2026-06-12 03:15:21', '2026-06-12 03:15:36', NULL, '621f327cb486e4a5a6bff7ab832089b6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'remote PTY over ssh -tt for terminal + Claude panes', NULL, 'backlog', 'medium', NULL, NULL, 'D-96', '2026-06-12 03:15:41', '2026-06-12 03:15:41', NULL, 'fd76adffcac27c5db23bfb56fd70c858', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'remote PTY over ssh -tt for terminal + Claude panes', 'Interactive channels of the ssh-exec model (D-96). A remote terminal/Claude pane spawns `ssh -tt [-p port] [user@]host -- cd <cwd> && exec <cmd>` LOCALLY via the existing native PTY (posix_openpt + posix_spawn, lib/src/pty/native_pty.dart) — the local PTY wraps the ssh process, the remote side gets its own pty from -tt. So NO new PTY mechanism: implement a RemotePtySpawner that builds the ssh argv from a WorkspaceRef + command and hands it to NativePty.
- Resize: local PTY resize propagates through ssh automatically (SIGWINCH on the local pty → ssh forwards). Verify with a resize test against the stub.
- Exit/loss: ssh exiting (network drop) is a pane exit — the claude pane''s session-end status line (T-372 work) already renders that; terminal pane likewise.
- Env: CLIDE_SOCK etc. (agent_bootstrap.dart) is Phase-3 scope (T-333) — out of scope here.
- Tests: stub ssh (same harness as T-398) + the existing pty dart-test suite pattern (test/pty is serial under dart test).
Done when: a RemotePtySpawner produces correct argv (quoting via T-398''s helper), spawns through NativePty, resize + exit propagate, covered under dart test with the stub.', 'backlog', 'medium', NULL, NULL, 'D-96', '2026-06-12 03:15:41', '2026-06-12 03:15:55', NULL, 'e79889d7495024f1ab6f850a1372bda5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN2MP35NPPK1BRDYY2M428', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'polling file watcher for remote workspaces', NULL, 'backlog', 'medium', NULL, NULL, 'D-96', '2026-06-12 03:16:00', '2026-06-12 03:16:00', NULL, '8a174958e402b500d1930ff032cb8f0d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN2MP35NPPK1BRDYY2M428', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'polling file watcher for remote workspaces', 'D-96''s watching degrade. Remote workspaces get FileChange events from a poller, not inotify, with the SAME event shapes the local watcher (lib/src/files/watcher.dart) emits — consumers must not know the difference.
- Primary mode: debounced sweep over the ExecutionContext (T-399): `git status --porcelain -z` (tracked changes, cheap on the remote) + a `find -newer <stamp>` pass for untracked/ignored-relevant paths, on a ~2s cadence, diffed against the previous snapshot to synthesize add/modify/delete events.
- Opportunistic upgrade: if the D-98 preflight found inotifywait, hold one long-lived exec channel running `inotifywait -m -r` and translate its lines — instant events, still zero-install (inotifywait is the remote''s own tool).
- Pause the sweep while no remote workspace is open; back off (cadence x4) when the pane is unfocused/minimized to respect the round-trip budget.
- Tests: drive with a fake ExecutionContext replaying canned snapshots; assert synthesized event sequences (created/modified/deleted, rename = delete+create), debounce, and the inotifywait line-translation table.
Done when: poller emits watcher-compatible events from snapshot diffs under test, inotifywait mode translates correctly, and cadence/backoff is config-free but bounded.', 'backlog', 'medium', NULL, NULL, 'D-96', '2026-06-12 03:16:00', '2026-06-12 03:16:12', NULL, 'dbff34b497bba39c54f0bf408f54f7a8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN4QVFVE51MY2N0CWCVXHM', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'connect preflight — remote-tool contract probe (D-98)', NULL, 'backlog', 'medium', NULL, NULL, 'D-98', '2026-06-12 03:16:18', '2026-06-12 03:16:18', NULL, '33c58407367b2c262392c6f95e8f41f0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN4QVFVE51MY2N0CWCVXHM', 'task', '06FB3DQEMTDHF8SV27AKAB8JHW', 'connect preflight — remote-tool contract probe (D-98)', 'Implements D-98. On remote connect (after the SshConnection opens, before ProjectOpened fires), run ONE batched probe over the exec channel:
for t in sh git pql claude inotifywait; do printf ''%s='' "$t"; command -v "$t" >/dev/null 2>&1 && "$t" --version 2>/dev/null | head -1 || echo MISSING; done
(or equivalent single round-trip). Parse into a RemoteToolset { git: version?, pql: version?, claude: version?, inotifywait: bool }.
- shell+git MISSING → the open fails with the probe output in the error (actionable, names the host).
- pql MISSING → planning/query surfaces dark behind the D-95-style banner ("pql not found on <host> — install it there to enable tickets/decisions"); version skew vs the bundled local pql is surfaced (toast), not reconciled.
- claude MISSING → Claude pane disabled with notice; everything else live.
- inotifywait presence feeds the T-401 watcher mode pick.
- Tests: parse table from canned probe outputs (all-present, pql-missing, git-missing, weird version strings); fail-the-open path; banner gating is a later UI ticket under Phase 5 (T-335) — this ticket is the probe + model + open-gate only.
Done when: probe runs as one exec round-trip, RemoteToolset drives open-failure for missing required tools, parse covered under dart test.', 'backlog', 'medium', NULL, NULL, 'D-98', '2026-06-12 03:16:18', '2026-06-12 03:16:30', NULL, '925c2c4148eef4d6159c413dee008733', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Fork — execution layer (expand after Phase-0 spike decides footprint)', 'PLACEHOLDER — exact tickets crystallize after the Phase-0 spike (T-330) picks the footprint model. Both designs below so the choice is a swap, not a redesign. IF auto-pushed agent: execution layer is mostly transport + provisioning — subsystems (GitClient, PqlClient, FilesService, SearchService, NativePty) run UNCHANGED inside the remote agent; buildDispatcher (main.dart ~:176) split into UI-coupled vs headless-safe registrations; new bin/clide_agent.dart hosts the headless set + an IpcServer; RemoteTransport = SSH-tunneled agent socket (stdio bridge); watching is native inotify in the agent (watcher.dart unchanged); plus a provisioning module lib/src/remote/ (push, version-name, launch, GC). IF no-install ssh-exec: introduce a RemoteExecutionContext that each subsystem uses instead of bare Process.run/File/Directory — ssh -tt for PTYs, multiplexed ControlMaster command channels for git/pql/file ops, and a polling watcher (debounced git-status/mtime, or inotifywait if present) emitting the same FileChange events so the UI is unaware; heavier subsystem surface, zero remote footprint. Either way: pql runs where its .pql/ index lives (remote), git runs where .git/ lives (remote), clide still only wraps pql (D-3 preserved). Blocks Phases 3 and 4. Depends on Phase 0 (T-330) and Phase 1 (T-331).
2026-06-12: footprint decided — no-install ssh-exec (D-96, user pick). This story is now the EXECUTION-LAYER UMBRELLA for that model; the agent-model branch in the description above is dead. Expanded into: T-398 (SSH connection manager — ControlMaster + exec channel, the foundation), T-399 (ExecutionContext seam sweep across git/pql/files/search/editor), T-400 (remote PTY via ssh -tt through the existing NativePty), T-401 (polling watcher), T-402 (D-98 preflight probe). Blocker graph: 399/400/402 by 398; 401 by 399. Close this story when those five are done; T-333/T-334 unblock then.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:56', '2026-06-12 03:16:59', NULL, '4fc2f8e6f5436799502a571f7bc8cce6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);