bootstrap clide-hosted Claude sessions (Epic B)
Per D-83 the clide-hosted stream-json session is the primary dogfood target — the process clide spawns, so the one clide can fully equip. Adds a Flutter-free agent_bootstrap module and injects it centrally in ClaudeSessionOrchestrator.spawn so every hosted session (primary, secondary, fork, teammate) is handed: - CLIDE_SOCK (the per-workspace socket, D-70) + CLIDE_WORKSPACE in its env, and `clide` prepended to PATH when not already resolvable, so `clide …` works with zero manual socket discovery (T-215); - a system-prompt context note (--append-system-prompt) telling it it is inside clide and how to drive the IDE via `clide …`, plus the D-6 parity contract; the note merges with the team preamble into one --append-system-prompt (T-216); - a Bash(clide:*) allow rule (--allowedTools) so clide calls aren't prompted (T-217). The PATH resolver no-ops when clide is already on PATH (installed case, T-211) and falls back to ~/.local/bin and the dev native/<os>-<arch> tree; it degrades gracefully when none is found. Pure helpers are unit-tested; the fork-args test is made position-independent. Closes T-214, T-215, T-216, T-217 (under T-208 "Give Claude hands"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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/<pid>.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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<prefix>:*)` — see `permissions.allow` in `claude_config.dart`.
|
||||
const String clideBashAllowRule = 'Bash(clide:*)';
|
||||
|
||||
/// Spawn args that carry the [clideBashAllowRule] into a session.
|
||||
const List<String> 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 <subsystem> <verb>`. '
|
||||
'Subsystems that respond today: `files` (workspace tree — `clide files root`, `files list`), '
|
||||
'`editor` (`clide editor open <path>`, `editor active`), `git` (`clide git status`), '
|
||||
'`search` (`clide search grep <query>`), `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<String, String> agentEnvDelta({
|
||||
required String workspaceRoot,
|
||||
required String socketPath,
|
||||
required String? currentPath,
|
||||
required String? clideCliDir,
|
||||
}) {
|
||||
final delta = <String, String>{
|
||||
'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
|
||||
/// `<dir>/clide`. Both are injected so the resolver is pure and testable.
|
||||
String? resolveClideCliDir({
|
||||
required String? currentPath,
|
||||
required List<String> 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/<os>-<arch>/` 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<String, String> envDelta;
|
||||
final List<String> 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<String, String>? base}) {
|
||||
final home = Platform.environment['HOME'];
|
||||
final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH'];
|
||||
final candidates = <String>[
|
||||
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;
|
||||
}
|
||||
@@ -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 = <String>[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;
|
||||
|
||||
@@ -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 <subsystem> <verb>'));
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 <source> 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')));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user