diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 8c446118..d87111e5 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -58,3 +58,10 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, Resolved (2026-06-03): recorded as D-83. Decision — clide commits to BOTH agent models with the clide-HOSTED stream-json session (D-77/D-78) as the PRIMARY dogfood target (the one clide spawns, so the one Epic B/T-214 bootstraps: CLIDE_SOCK/CLIDE_WORKSPACE + PATH + context note + Bash(clide *) allow rule), and the EXTERNAL CLI driver (D-68) as a first-class but SECONDARY, best-effort integration (manual install via T-212; no promise to observe its non-clide tool use). Epic C/T-218 parity is scoped to clide''s own surfaces reflected through the CLI in both directions; an external agent''s side-channel reads/tests/git are out of parity scope.', NULL, '2026-06-03 09:05:48', '2026-06-03 09:05:48', '2026-06-03 09:05:48', NULL, 'c6b6237b6f3d7f072a4b9b0a4cc30bc9', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-224', 'status', 'in_progress', 'done', NULL, '2026-06-03 09:05:50', '2026-06-03 09:05:50', '2026-06-03 09:05:50', NULL, 'e6b1c1f085d251762e927b9ea9d20076', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-215', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 09:09:43', '2026-06-03 09:09:43', '2026-06-03 09:09:43', NULL, '6fc70c3a2c6102268eefaa757bf6b1d9', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-217', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 09:09:43', '2026-06-03 09:09:43', '2026-06-03 09:09:43', NULL, '7751a9475bab7a458df1fa29ba115167', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-216', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 09:09:43', '2026-06-03 09:09:43', '2026-06-03 09:09:43', NULL, 'a21641c8d9b1e859255cceffe93db738', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-217', 'status', 'in_progress', 'done', NULL, '2026-06-03 09:18:28', '2026-06-03 09:18:28', '2026-06-03 09:18:28', NULL, '980afb083f759f6210c2ef24fea52c37', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-216', 'status', 'in_progress', 'done', NULL, '2026-06-03 09:18:28', '2026-06-03 09:18:28', '2026-06-03 09:18:28', NULL, 'a76b209ff9d4985dd2f78a858a64c8cc', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-215', 'status', 'in_progress', 'done', NULL, '2026-06-03 09:18:28', '2026-06-03 09:18:28', '2026-06-03 09:18:28', NULL, 'ff89bb398cb6f4911b1c5aac4e307834', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-214', 'status', 'backlog', 'done', NULL, '2026-06-03 09:18:38', '2026-06-03 09:18:38', '2026-06-03 09:18:38', NULL, '6266403d6e099befafc78b5d368e938a', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 6ddfae7e..107d03d3 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -85,3 +85,7 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-224', 'task', 'T-208', 'Decide the dogfood agent model (governance Q/D record)', 'Gap 5 from self-analysis.md — foundational. Two distinct ''Claude inside clide'' stories with different gaps: (A) the clide-HOSTED stream-json session (D-77/D-78) — the user''s primary Claude pane, which itself needs `clide` on PATH to drive the surrounding IDE; (B) an EXTERNAL agent (e.g. a Claude Code harness, TERM_PROGRAM=zed as observed) driving via `clide ...` IPC, which clide cannot fully observe because plain file reads / `make test` / `git` bypass clide. Decide the intended model — hosted, external, or both — and record it under governance/ (claim via `pql decisions claim`). Gates how Epic B bootstraps and how Epic C frames observability. Resolved (2026-06-03): recorded as D-83. Decision — clide commits to BOTH agent models with the clide-HOSTED stream-json session (D-77/D-78) as the PRIMARY dogfood target (the one clide spawns, so the one Epic B/T-214 bootstraps: CLIDE_SOCK/CLIDE_WORKSPACE + PATH + context note + Bash(clide *) allow rule), and the EXTERNAL CLI driver (D-68) as a first-class but SECONDARY, best-effort integration (manual install via T-212; no promise to observe its non-clide tool use). Epic C/T-218 parity is scoped to clide''s own surfaces reflected through the CLI in both directions; an external agent''s side-channel reads/tests/git are out of parity scope.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-02 18:13:52', '2026-06-03 09:05:50', NULL, '8ba80265666e3103ea82c02fdf48e632', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-215', 'task', 'T-214', 'Export CLIDE_SOCK/CLIDE_WORKSPACE + ensure clide on the child PATH', 'When clide spawns/hosts an agent (the stream-json session per D-77, and any launched agent), inject CLIDE_SOCK (the per-workspace deterministic socket, D-70) and CLIDE_WORKSPACE into the child env, and guarantee `clide` is on the child PATH. Acceptance: a hosted agent''s shell has both vars and `clide ping` works with zero manual socket discovery. Blocked by Epic A delivery.', 'done', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, '220432969915ff7b84a64a3ef880399f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-217', 'task', 'T-214', 'Pre-seed a Bash(clide *) permission allow rule for hosted agents', 'So the hosted agent is not prompted on every `clide ...` call. Acceptance: clide-hosted sessions get a Bash(clide *) allow rule in their effective settings.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, 'e592d53b85d813172fc1eab377c407ba', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-216', 'task', 'T-214', 'Inject an agent context note describing the CLI surface + parity contract', 'Give the hosted agent a short note (CLAUDE.md fragment / system reminder) stating it is inside clide and how to drive the editor / git / file tree / panes via `clide ...`, plus the D-6 parity contract. Acceptance: a hosted agent is told, without the user having to explain it.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, 'ec64cf891f2150b6b6421a85e842899f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-214', 'epic', 'T-208', 'Bootstrap the clide-hosted/launched agent', 'Gap 2 from self-analysis.md. Even with the binary installed, a fresh session has no signal it is hosted by clide. There IS an MCP discovery lock (~/.claude/ide/.lock pointing at workspace + SSE URL) but NOTHING for the Bash/CLI path: no CLIDE_SOCK/CLIDE_WORKSPACE env var, no injected context note, no pre-seeded Bash(clide *) allow rule. Discovery is the difference between a capability existing and getting used — the probing agent had to be told ''you''re inside clide'' and then hunt for the socket.', 'done', 'high', NULL, NULL, 'D-6', '2026-06-02 18:13:52', '2026-06-03 09:18:38', NULL, '5c952e43be93015d72028c67267cc608', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e195d4f..f658cafe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- clide-hosted Claude sessions are now bootstrapped to drive the IDE: each + spawned session gets `CLIDE_SOCK`/`CLIDE_WORKSPACE` in its env and `clide` on + its PATH, a system-prompt note telling it it is inside clide and how to use + `clide …`, and a `Bash(clide:*)` allow rule so those calls aren't prompted. + Applies to primary, secondary, fork, and teammate sessions. (T-214, D-83) - The `clide` CLI now ships on PATH: `make build` and `make install` compile the C client by default, and `make install` places it at `~/.local/bin/clide` on Linux and macOS (the GUI launches via its desktop entry). Previously no diff --git a/lib/builtin/claude/src/agent_bootstrap.dart b/lib/builtin/claude/src/agent_bootstrap.dart new file mode 100644 index 00000000..99af464c --- /dev/null +++ b/lib/builtin/claude/src/agent_bootstrap.dart @@ -0,0 +1,168 @@ +/// Bootstrap for clide-hosted Claude sessions (Epic B / T-208, D-83). +/// +/// D-83 names the clide-HOSTED stream-json session the primary dogfood +/// target: it is the process clide spawns, so clide controls its +/// environment and can hand it everything it needs to drive the IDE +/// without the user explaining anything. This module assembles that +/// hand-off, injected centrally in [ClaudeSessionOrchestrator.spawn] so +/// every hosted session (primary, secondary, fork, teammate) gets it: +/// +/// * T-215 — `CLIDE_SOCK` / `CLIDE_WORKSPACE` env + `clide` on PATH, so +/// `clide …` resolves and points at this workspace's socket with zero +/// manual discovery. +/// * T-216 — a context note (`--append-system-prompt`) telling the agent +/// it is inside clide and how to drive it via `clide …` (the D-6 +/// parity contract). +/// * T-217 — a `Bash(clide:*)` allow rule (`--allowedTools`) so the agent +/// is not prompted on every `clide …` call. +/// +/// The pure helpers ([clideContextNote], [agentEnvDelta], [resolveClideCliDir]) +/// hold the logic and are unit-tested directly; [agentBootstrap] is the thin +/// IO wrapper the orchestrator calls. Flutter-free by design. +library; + +import 'dart:ffi' show Abi; +import 'dart:io'; + +import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath; + +/// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a +/// hosted session (T-217), so the agent isn't prompted on every IDE call. +/// Claude Code's settings/flag syntax for a command-scoped Bash rule is +/// `Bash(:*)` — see `permissions.allow` in `claude_config.dart`. +const String clideBashAllowRule = 'Bash(clide:*)'; + +/// Spawn args that carry the [clideBashAllowRule] into a session. +const List clideAllowedToolsArgs = ['--allowedTools', clideBashAllowRule]; + +/// The agent context note injected via `--append-system-prompt` (T-216). +/// +/// Tells a hosted session it is inside clide and how to drive the IDE +/// through the `clide` CLI. Lists only subsystems that dispatch today; +/// the orient-snapshot (`clide status`) and live pane/editor reflection +/// arrive with Epic C (T-218..T-221) and are deliberately left out so the +/// note never points the agent at a command that returns nothing yet. +String clideContextNote(String workspaceRoot) => 'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE ' + 'surface as a `clide` command on your PATH; drive it with `clide `. ' + 'Subsystems that respond today: `files` (workspace tree — `clide files root`, `files list`), ' + '`editor` (`clide editor open `, `editor active`), `git` (`clide git status`), ' + '`search` (`clide search grep `), `pql` (planning/query), and `panel`/`pane` (layout). ' + 'Each command prints JSON to stdout; exit codes are 0=ok, 1=handled error, 3=unknown command. ' + 'CLIDE_WORKSPACE holds the workspace root ($workspaceRoot) and CLIDE_SOCK the IPC socket. ' + 'Parity contract (D-6): every action the user takes in the UI has a `clide` verb, and `clide` ' + 'is how you observe and drive the same workspace the user sees — prefer it for IDE actions so ' + 'your work and the user\'s stay in one shared workspace.'; + +/// Build the environment DELTA to overlay on a hosted session's inherited +/// environment (T-215). `Process.start` keeps the parent environment by +/// default, so this returns only the keys to add/override: +/// +/// * `CLIDE_SOCK` — the per-workspace socket ([workspaceSocketPath], D-70). +/// * `CLIDE_WORKSPACE` — the workspace root. +/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide` +/// is not already resolvable), otherwise left untouched. +Map agentEnvDelta({ + required String workspaceRoot, + required String socketPath, + required String? currentPath, + required String? clideCliDir, +}) { + final delta = { + 'CLIDE_SOCK': socketPath, + 'CLIDE_WORKSPACE': workspaceRoot, + }; + if (clideCliDir != null && clideCliDir.isNotEmpty) { + delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath'; + } + return delta; +} + +/// Locate the directory to prepend to a hosted agent's PATH so `clide` +/// resolves (T-215). Returns null when `clide` is ALREADY on [currentPath] +/// (the installed case — T-211 drops it in `~/.local/bin`, normally already +/// on PATH) or when no candidate holds an executable `clide` (degrade +/// gracefully — the session still spawns, the agent just can't call `clide`). +/// +/// [candidateDirs] is an ordered fallback list; [isExecutableFile] probes +/// `/clide`. Both are injected so the resolver is pure and testable. +String? resolveClideCliDir({ + required String? currentPath, + required List candidateDirs, + required bool Function(String path) isExecutableFile, +}) { + if (currentPath != null) { + for (final dir in currentPath.split(':')) { + if (dir.isNotEmpty && isExecutableFile('$dir/clide')) return null; + } + } + for (final dir in candidateDirs) { + if (dir.isNotEmpty && isExecutableFile('$dir/clide')) return dir; + } + return null; +} + +/// The `native/-/` directory name the Makefile builds the C client +/// into (e.g. `linux-x64`, `macos-arm64`) — used to find the dev-tree binary +/// when clide runs un-installed (dogfooding clide-on-clide). +String nativeClideDirName({Abi? abi}) { + switch (abi ?? Abi.current()) { + case Abi.macosArm64: + return 'macos-arm64'; + case Abi.macosX64: + return 'macos-x64'; + case Abi.linuxArm64: + return 'linux-arm64'; + case Abi.linuxX64: + return 'linux-x64'; + default: + // Windows / other — clide is desktop linux/macos today; fall back to a + // best-effort name so the probe simply misses rather than throwing. + return Platform.isMacOS ? 'macos-x64' : 'linux-x64'; + } +} + +/// The result of [agentBootstrap]: the env delta to overlay and the extra +/// spawn args (context note + allow rule) to prepend to a session's argv. +class AgentBootstrap { + const AgentBootstrap({required this.envDelta, required this.extraArgs}); + final Map envDelta; + final List extraArgs; +} + +/// Assemble the full bootstrap for a session spawned in [workspaceRoot] +/// (the IO wrapper over the pure helpers). [base] is the session's existing +/// env (usually null → inherit clide's). The returned [AgentBootstrap.extraArgs] +/// carries the context note; team callers append their own preamble and the +/// orchestrator merges both into one `--append-system-prompt`. +AgentBootstrap agentBootstrap(String workspaceRoot, {Map? base}) { + final home = Platform.environment['HOME']; + final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH']; + final candidates = [ + if (home != null && home.isNotEmpty) '$home/.local/bin', + '$workspaceRoot/native/${nativeClideDirName()}', + File(Platform.resolvedExecutable).parent.path, + ]; + final cliDir = resolveClideCliDir( + currentPath: currentPath, + candidateDirs: candidates, + isExecutableFile: _isExecutableFile, + ); + final delta = agentEnvDelta( + workspaceRoot: workspaceRoot, + socketPath: workspaceSocketPath(workspaceRoot), + currentPath: currentPath, + clideCliDir: cliDir, + ); + return AgentBootstrap( + envDelta: {...?base, ...delta}, + extraArgs: ['--allowedTools', clideBashAllowRule], + ); +} + +bool _isExecutableFile(String path) { + final f = File(path); + if (!f.existsSync()) return false; + // On POSIX an executable bit is what matters; statSync mode's owner-exec + // bit (0100) is a sufficient, dependency-free check. + return (f.statSync().mode & 0x40) != 0; +} diff --git a/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart index e9a6e6f9..848df01d 100644 --- a/lib/builtin/claude/src/session_orchestrator.dart +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -15,6 +15,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:clide/builtin/claude/src/agent_bootstrap.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/session_naming.dart'; import 'package:clide/builtin/claude/src/stream_json_session.dart'; @@ -200,17 +201,31 @@ class ClaudeSessionOrchestrator extends ChangeNotifier { // own claude session id from the init event (T-172). All other sessions use // the normal --resume / --session-id selection. var sessionArgs = spec.isFork ? forkSessionArgs(spec.forkSourceSessionId!) : claudeLaunchArgs(spec.sessionId, resume: spec.resume); + + // Epic B (D-83): every clide-hosted session is told it is inside clide + // (T-216), gets `clide …` pre-approved (T-217), and is handed + // CLIDE_SOCK/CLIDE_WORKSPACE + `clide` on PATH (T-215). The clide context + // note and the team preamble merge into ONE --append-system-prompt (claude + // honours a single one). + final preambles = [clideContextNote(spec.cwd)]; if (spec.team) { final name = spec.memberName ?? spec.role; broker.addMember(TeamMemberRef(id: spec.id, name: name, role: spec.role)); mcpServers.add(TeamMcpServer(broker: broker, memberId: spec.id)); - sessionArgs = ['--append-system-prompt', _teamSystemPrompt(name, spec.role), ...sessionArgs]; + preambles.add(_teamSystemPrompt(name, spec.role)); } + final bootstrap = agentBootstrap(spec.cwd, base: spec.env); + sessionArgs = [ + '--append-system-prompt', + preambles.join('\n\n'), + ...bootstrap.extraArgs, + ...sessionArgs, + ]; final proc = await _factory( sessionArgs: sessionArgs, cwd: spec.cwd, - env: spec.env, + env: bootstrap.envDelta, ); final session = StreamJsonSession(proc, mcpServers: mcpServers)..start(); final seed = spec.resume && spec.transcriptPath != null ? await _readTranscriptTail(spec.transcriptPath!) : null; diff --git a/test/builtin/claude/agent_bootstrap_test.dart b/test/builtin/claude/agent_bootstrap_test.dart new file mode 100644 index 00000000..3f5216ad --- /dev/null +++ b/test/builtin/claude/agent_bootstrap_test.dart @@ -0,0 +1,124 @@ +/// Tests for the clide-hosted agent bootstrap (Epic B / T-215..T-217, D-83). +library; + +import 'package:clide/builtin/claude/src/agent_bootstrap.dart'; +import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath; +import 'package:test/test.dart'; + +void main() { + group('clideContextNote (T-216)', () { + test('names clide, the clide CLI, the env vars, and the parity contract', () { + final note = clideContextNote('/home/dev/proj'); + expect(note, contains('inside clide')); + expect(note, contains('clide ')); + expect(note, contains('CLIDE_WORKSPACE')); + expect(note, contains('CLIDE_SOCK')); + expect(note, contains('/home/dev/proj')); + expect(note, contains('D-6')); + }); + + test('lists only subsystems that dispatch today; not the unfinished status snapshot', () { + final note = clideContextNote('/repo'); + expect(note, contains('git status')); + expect(note, contains('editor active')); + // `clide status` (T-221) and live pane reflection (Epic C) are not wired + // yet — the note must not point the agent at a command that returns + // nothing, mirroring the T-213 "never mislead an agent" guard. + expect(note, isNot(contains('clide status'))); + }); + }); + + group('clideBashAllowRule (T-217)', () { + test('is the command-scoped Bash rule and rides on --allowedTools', () { + expect(clideBashAllowRule, 'Bash(clide:*)'); + expect(clideAllowedToolsArgs, ['--allowedTools', 'Bash(clide:*)']); + }); + }); + + group('agentEnvDelta (T-215)', () { + test('always exports CLIDE_SOCK + CLIDE_WORKSPACE', () { + final d = agentEnvDelta( + workspaceRoot: '/repo', + socketPath: '/run/clide/abc.sock', + currentPath: '/usr/bin', + clideCliDir: null, + ); + expect(d['CLIDE_WORKSPACE'], '/repo'); + expect(d['CLIDE_SOCK'], '/run/clide/abc.sock'); + }); + + test('leaves PATH untouched when clide is already resolvable (clideCliDir null)', () { + final d = agentEnvDelta( + workspaceRoot: '/repo', + socketPath: '/s.sock', + currentPath: '/usr/bin', + clideCliDir: null, + ); + expect(d.containsKey('PATH'), isFalse); + }); + + test('prepends the cli dir to PATH when given', () { + final d = agentEnvDelta( + workspaceRoot: '/repo', + socketPath: '/s.sock', + currentPath: '/usr/bin:/bin', + clideCliDir: '/home/dev/.local/bin', + ); + expect(d['PATH'], '/home/dev/.local/bin:/usr/bin:/bin'); + }); + + test('sets PATH to just the cli dir when there is no current PATH', () { + final d = agentEnvDelta( + workspaceRoot: '/repo', + socketPath: '/s.sock', + currentPath: null, + clideCliDir: '/opt/clide/bin', + ); + expect(d['PATH'], '/opt/clide/bin'); + }); + }); + + group('resolveClideCliDir (T-215)', () { + test('returns null when clide already resolves on PATH (no PATH change needed)', () { + final dir = resolveClideCliDir( + currentPath: '/usr/bin:/home/dev/.local/bin', + candidateDirs: const ['/repo/native/linux-x64'], + isExecutableFile: (p) => p == '/home/dev/.local/bin/clide', + ); + expect(dir, isNull); + }); + + test('returns the first candidate holding an executable clide when not on PATH', () { + final dir = resolveClideCliDir( + currentPath: '/usr/bin', + candidateDirs: const ['/nope', '/repo/native/linux-x64', '/also'], + isExecutableFile: (p) => p == '/repo/native/linux-x64/clide', + ); + expect(dir, '/repo/native/linux-x64'); + }); + + test('returns null when nothing holds clide', () { + final dir = resolveClideCliDir( + currentPath: '/usr/bin', + candidateDirs: const ['/a', '/b'], + isExecutableFile: (_) => false, + ); + expect(dir, isNull); + }); + }); + + group('agentBootstrap (IO wrapper)', () { + test('socket in the delta matches workspaceSocketPath; allow rule in extraArgs', () { + final b = agentBootstrap('/some/workspace'); + expect(b.envDelta['CLIDE_SOCK'], workspaceSocketPath('/some/workspace')); + expect(b.envDelta['CLIDE_WORKSPACE'], '/some/workspace'); + expect(b.extraArgs, ['--allowedTools', 'Bash(clide:*)']); + }); + + test('merges over the provided base env', () { + final b = agentBootstrap('/ws', base: {'FOO': 'bar'}); + expect(b.envDelta['FOO'], 'bar'); + expect(b.envDelta['CLIDE_WORKSPACE'], '/ws'); + }); + }); +} diff --git a/test/builtin/claude/session_orchestrator_test.dart b/test/builtin/claude/session_orchestrator_test.dart index bff54e25..232e7595 100644 --- a/test/builtin/claude/session_orchestrator_test.dart +++ b/test/builtin/claude/session_orchestrator_test.dart @@ -230,8 +230,12 @@ void main() { const sourceId = 'bbbb2222-2222-4222-8222-222222222222'; await orch.spawn(forkSpec('fork-1', sourceId)); expect(capturedArgs, isNotNull); - expect(capturedArgs![0], '--resume'); - expect(capturedArgs![1], sourceId); + // The Epic B bootstrap (T-215..T-217) prepends --append-system-prompt + // + --allowedTools, so --resume is no longer at index 0 — but + // they stay adjacent and --fork-session is present, no --session-id. + final resumeAt = capturedArgs!.indexOf('--resume'); + expect(resumeAt, greaterThanOrEqualTo(0)); + expect(capturedArgs![resumeAt + 1], sourceId); expect(capturedArgs!, contains('--fork-session')); expect(capturedArgs!, isNot(contains('--session-id'))); });