From 679b5ba9d9c160b360e67e1bcb4e1b218fa5bdb3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 27 Jun 2026 15:51:13 +0200 Subject: [PATCH] feat(cli): clide instances / instance verbs for instance discovery (T-247) Closes the observability half of T-247: a way to find and identify running clides. `clide instances` scans the runtime socket dir, probes each live *.sock, and prints its identity (version/pid/workspace/socketPath) as jsonl; dead sockets are skipped. `clide instance` reports the one you're connected to. Combined with CLIDE_SOCK honoring (this same ticket), you can now list instances and pin the CLI to a chosen one. Server: a new `instance` dispatcher command (registered in buildDispatcher with the live workspace/pid/socket) returns the identity map; added to the argv translator's umbrella set so a bare `instance` token routes to it. Client: a POSIX dir-scan in clide.c (Windows stub until it ships). Tests: e2e `instances` lists the live test server with its identity; a cc-free unit test covers the `instance` command shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 10 ++++ CHANGELOG.md | 4 ++ lib/main.dart | 8 ++- lib/src/cli/argv_to_request.dart | 2 +- lib/src/daemon/instance_command.dart | 16 ++++++ native/clide-cli/clide.c | 62 ++++++++++++++++++++++- test/cli/clide_cli_e2e_test.dart | 25 +++++++++ test/daemon/instance_command_test.dart | 19 +++++++ 9 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 lib/src/daemon/instance_command.dart create mode 100644 test/daemon/instance_command_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 64c372a3..00da4d1f 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -6785,3 +6785,4 @@ the budget now lives only on the Activity tab, next to the /usage refresh control that fetches it (T-415). The three-tab card facelift stays. Q-34 answered: budget surfaces once, on Activity.', NULL, '2026-06-27 05:55:56', '2026-06-27 05:55:56.468', '2026-06-27 05:55:56.468', NULL, 'ba7329e54bf44c2cb5a9e7fb63e685f3', 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 ('06FB0TNQM6QCYT7JBF352YVJ48', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 06:06:03', '2026-06-27 06:06:03.770', '2026-06-27 06:06:03.770', NULL, 'c1cff9a2f682f3bb8f719f6771cf36c3', 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 ('06FB0TNQM6QCYT7JBF352YVJ48', 'status', 'in_progress', 'done', NULL, '2026-06-27 13:51:01', '2026-06-27 13:51:01.108', '2026-06-27 13:51:01.108', NULL, 'd87bdd8b0216a2362c9c41d9f5fa0afa', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index cd442502..84b2966b 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -8446,3 +8446,13 @@ Fix (scope to confirm): - Add a way to enumerate/identify live instances (e.g. a `clide instances` verb, or include the instance id/socket path + pid in `clide version`) so a human or agent can pick the right one. Acceptance: a bogus `CLIDE_SOCK` fails loudly instead of returning data from a different instance; a valid `CLIDE_SOCK` pins the CLI to that instance; startup leaves exactly one live socket for one running app (no orphan accumulation); there is a CLI affordance to list/identify running instances.', 'in_progress', 'medium', NULL, NULL, 'D-6', '2026-06-05 21:28:48', '2026-06-27 06:06:03.770', NULL, '15ee4bd8e47ee2b0c1d2919fc2e3e013', 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 ('06FB0TNQM6QCYT7JBF352YVJ48', 'bug', NULL, 'clide CLI ignores CLIDE_SOCK and auto-discovers; stale sockets orphaned in runtime dir', 'The `clide` CLI does not honor the `CLIDE_SOCK` env var — it auto-discovers a running instance instead. Proof: with `CLIDE_SOCK=/run/user/1000/clide/DOES_NOT_EXIST.sock`, `clide version` still returns `{"version":"2.1.0"}` from the live app. Separately, the runtime socket dir accumulates orphaned socket files: `/run/user/1000/clide/` held two `.sock` entries while only one GUI process (the `build/linux/x64/debug/bundle/clide` bundle) was running, so a previous run''s socket was never cleaned up. + +Why it matters: today (single instance) it''s harmless, but it''s a latent split-brain + observability hole. If two clide instances are ever live on the same machine, the CLI attaches to whichever discovery resolves first, with (a) no way to target a specific instance and (b) no way to find out which one you''re talking to. Combined with the stale-socket litter, `clide` could silently drive the wrong window. This is a D-6 surface gap — the CLI must be able to address the same instance the user is in. + +Fix (scope to confirm): +- Honor `CLIDE_SOCK` when set (explicit target beats discovery); error clearly if that socket is dead rather than silently falling back. +- Clean up orphaned/stale socket files on app startup (and on clean shutdown) — detect a dead listener and unlink before binding a new hash. +- Add a way to enumerate/identify live instances (e.g. a `clide instances` verb, or include the instance id/socket path + pid in `clide version`) so a human or agent can pick the right one. + +Acceptance: a bogus `CLIDE_SOCK` fails loudly instead of returning data from a different instance; a valid `CLIDE_SOCK` pins the CLI to that instance; startup leaves exactly one live socket for one running app (no orphan accumulation); there is a CLI affordance to list/identify running instances.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-05 21:28:48', '2026-06-27 13:51:01.108', NULL, 'fc3fc8365369e5c99b03aafccc3cda0f', 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 031165a3..3381234e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **`clide instances` / `clide instance` CLI verbs.** `instances` lists every + live clide on the machine with its version, pid, workspace, and socket path + (probing the runtime dir); `instance` reports the one you're connected to — so + you can find and target a specific instance via `CLIDE_SOCK`. (T-247) - **Summer Night theme + high-contrast sibling.** The legacy v1.2 palette is fleshed out to full token + syntax coverage and ships alongside a contrast-hardened `summer-night-hc`, both selectable in Settings → Appearance. diff --git a/lib/main.dart b/lib/main.dart index 41ad62b9..c1c1d7e9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,14 +32,16 @@ import 'package:clide/builtin/vim/vim.dart'; import 'package:clide/builtin/tickets/tickets.dart'; import 'package:clide/builtin/todos/todos.dart'; import 'package:clide/builtin/welcome/welcome.dart'; -import 'dart:io' show Directory, File, Platform; +import 'dart:io' show Directory, File, Platform, pid; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/clide.dart' show clideVersion; import 'package:clide/src/daemon/dispatcher.dart'; import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/git_commands.dart'; import 'package:clide/src/daemon/image_commands.dart'; +import 'package:clide/src/daemon/instance_command.dart'; import 'package:clide/src/daemon/log_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/daemon/status_command.dart'; @@ -286,6 +288,10 @@ Future main() async { // visible to `pane list` by snapshotting the kernel PanelRegistry + // LayoutArrangement at request time — no mirrored state to drift. registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement)); + // `clide instance` — this instance's identity (version/pid/workspace/socket) + // so `clide instances` can list every live instance and a human/agent can + // tell which one a socket belongs to (T-247). + registerInstanceCommand(dispatcher, version: clideVersion, pid: pid, workspace: workRoot.path, socketPath: workspaceSocketPath(workRoot.path)); // `clide log level []` — the live verbosity toggle's CLI half (T-433, // D-6 parity with the output-dock Level chip). Persists via the kernel // settings, captured post-boot. diff --git a/lib/src/cli/argv_to_request.dart b/lib/src/cli/argv_to_request.dart index cf388269..28a54f93 100644 --- a/lib/src/cli/argv_to_request.dart +++ b/lib/src/cli/argv_to_request.dart @@ -27,7 +27,7 @@ import 'package:clide/src/ipc/schema_v1.dart'; /// split. Match the IDs the dispatcher exposes directly. `tail` and /// `events` are handled by the IPC server itself (streaming / cursor-pull /// event reads, T-129 / T-223) rather than the dispatcher. -const Set _umbrellaCommands = {'status', 'tail', 'events', 'version', 'ping', 'capabilities'}; +const Set _umbrellaCommands = {'status', 'tail', 'events', 'version', 'ping', 'capabilities', 'instance'}; /// Sealed result of translating argv. Caller (the IPC server, or the /// C client wrapper in T-126) handles either branch. diff --git a/lib/src/daemon/instance_command.dart b/lib/src/daemon/instance_command.dart new file mode 100644 index 00000000..24f468bf --- /dev/null +++ b/lib/src/daemon/instance_command.dart @@ -0,0 +1,16 @@ +/// Registers the `instance` command — this running clide's identity in one +/// round-trip (T-247): version, pid, workspace root, and socket path. It's the +/// per-instance metadata the `clide instances` CLI verb aggregates (by probing +/// every live socket in the runtime dir), and it lets a human or agent confirm +/// *which* instance a given socket belongs to. +/// +/// Thin + Flutter-free: the caller (main.dart) passes the values it already +/// holds at dispatcher-build time, so this stays trivially testable. +library; + +import '../ipc/envelope.dart'; +import 'dispatcher.dart'; + +void registerInstanceCommand(DaemonDispatcher d, {required String version, required int pid, required String workspace, required String socketPath}) { + d.register('instance', (req) async => IpcResponse.ok(id: req.id, data: {'version': version, 'pid': pid, 'workspace': workspace, 'socketPath': socketPath})); +} diff --git a/native/clide-cli/clide.c b/native/clide-cli/clide.c index ca22ec63..ad6fd1b1 100644 --- a/native/clide-cli/clide.c +++ b/native/clide-cli/clide.c @@ -44,6 +44,7 @@ #include #else #define _POSIX_C_SOURCE 200809L +#include #include #include #include @@ -357,15 +358,74 @@ static const char *json_value(const char *buf, const char *key, size_t *out_len) return start; } +/* `clide instances` (T-247): probe every *.sock in the runtime dir, ask each + * live one who it is (`instance`), and print its identity JSON one per line + * (jsonl). Dead sockets are skipped — this is also how you find which instance + * to point CLIDE_SOCK at. */ +#ifndef _WIN32 +static int list_instances(void) { + char dir[4096]; +#ifdef __APPLE__ + const char *home = getenv("HOME"); + if (!home || !*home) home = "/tmp"; + snprintf(dir, sizeof(dir), "%s/Library/Caches/clide", home); +#else + const char *xdg = getenv("XDG_RUNTIME_DIR"); + if (!xdg || !*xdg) xdg = "/tmp"; + snprintf(dir, sizeof(dir), "%s/clide", xdg); +#endif + DIR *d = opendir(dir); + if (!d) return 0; /* no dir yet → no instances; not an error */ + char *qargv[] = {(char *)"instance"}; + struct dirent *ent; + while ((ent = readdir(d)) != NULL) { + size_t nlen = strlen(ent->d_name); + if (nlen < 5 || strcmp(ent->d_name + nlen - 5, ".sock") != 0) continue; + char path[4096]; + if (snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name) >= (int)sizeof(path)) continue; + sock_t fd = connect_unix(path); + if (fd == NET_INVALID) continue; /* dead socket — skip */ + char req[1024]; + if (build_request(1, qargv, (long long)clide_getpid(), req, sizeof(req)) == 0 && + net_write(fd, req, (int)strlen(req)) == (int)strlen(req)) { + char resp[65536]; + if (read_line(fd, resp, sizeof(resp)) == 0) { + size_t dlen = 0; + const char *data = json_value(resp, "data", &dlen); + if (data) { + fwrite(data, 1, dlen, stdout); + fputc('\n', stdout); + } + } + } + net_close(fd); + } + closedir(d); + fflush(stdout); + return 0; +} +#else +static int list_instances(void) { + fprintf(stderr, "clide: `instances` is not supported on Windows yet\n"); + return EX_USAGE; +} +#endif + int main(int argc, char **argv) { /* argv[0] is the program name; everything after is what the user * typed after `clide`. */ if (argc < 2) { fprintf(stderr, "usage: clide [args...]\n" - " clide status | tail | version | ping\n"); + " clide instances | status | tail | version | ping\n"); return EX_USAGE; } + /* `instances` scans the runtime dir rather than connecting to one socket + * (T-247) — handle it before the single-target resolution below. */ + if (strcmp(argv[1], "instances") == 0) { + return list_instances(); + } + /* CLIDE_SOCK is an explicit target that beats workspace discovery (T-247): * a spawned agent inherits the parent app's socket path here, and a human * can pin a specific instance. When it's set we connect to it and FAIL diff --git a/test/cli/clide_cli_e2e_test.dart b/test/cli/clide_cli_e2e_test.dart index 1fa35e5c..cc2a95da 100644 --- a/test/cli/clide_cli_e2e_test.dart +++ b/test/cli/clide_cli_e2e_test.dart @@ -16,6 +16,7 @@ import 'package:clide/kernel/src/events/types.dart'; import 'package:clide/kernel/src/log.dart'; import 'package:clide/src/cli/argv_dispatch.dart'; import 'package:clide/src/daemon/dispatcher.dart'; +import 'package:clide/src/daemon/instance_command.dart'; import 'package:clide/src/ipc/envelope.dart'; import 'package:clide/src/ipc/server.dart'; import 'package:test/test.dart'; @@ -58,6 +59,10 @@ void main() { events: streamingBus, ); await server.start(); + // The `instance` command isn't a dispatcher builtin (main.dart registers it + // with the live workspace/pid); register it here so `clide instances` has + // identity to read back (T-247). + registerInstanceCommand(dispatcher, version: '9.9.9-test', pid: 4242, workspace: workspaceRoot.path, socketPath: server.socketPath); }); tearDownAll(() async { @@ -203,5 +208,25 @@ void main() { expect(r.stderr.toString(), contains('cannot connect')); expect(r.stdout.toString().trim(), isEmpty, reason: 'must not return data from a different instance'); }); + + test('instances lists live instances with their identity (T-247)', () async { + if (!hasCC) { + markTestSkipped('cc not available'); + return; + } + final r = await Process.run(binaryPath, ['instances'], workingDirectory: workspaceRoot.path, environment: const {'CLIDE_SOCK': ''}); + expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}'); + // This test server is one live instance; its socket path must appear. + // Other live clides on the machine may also be listed — assert ours is + // present and carries the full identity payload, not an exact count. + final lines = const LineSplitter().convert(r.stdout.toString()); + final mine = lines.where((l) => l.contains(server.socketPath)).toList(); + expect(mine, hasLength(1), reason: 'expected exactly one line for our socket, got: $lines'); + final obj = jsonDecode(mine.single) as Map; + expect(obj['workspace'], workspaceRoot.path); + expect(obj['version'], '9.9.9-test'); + expect(obj['pid'], 4242); + expect(obj['socketPath'], server.socketPath); + }); }); } diff --git a/test/daemon/instance_command_test.dart b/test/daemon/instance_command_test.dart new file mode 100644 index 00000000..a1bbf68c --- /dev/null +++ b/test/daemon/instance_command_test.dart @@ -0,0 +1,19 @@ +/// Tests for the `instance` command verb (T-247) — this clide's identity +/// (version / pid / workspace / socket path), the per-instance metadata the +/// `clide instances` CLI verb aggregates. Covers the dispatch mechanics +/// independent of the C client. +library; + +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/instance_command.dart'; +import 'package:test/test.dart'; + +void main() { + test('instance resolves and returns version/pid/workspace/socketPath', () async { + final d = DaemonDispatcher(); + registerInstanceCommand(d, version: '1.2.3', pid: 4242, workspace: '/repo', socketPath: '/run/user/1000/clide/abc.sock'); + final r = await d.dispatch(IpcRequest(id: '1', cmd: 'instance', args: const {})); + expect(r.ok, isTrue); + expect(r.data, {'version': '1.2.3', 'pid': 4242, 'workspace': '/repo', 'socketPath': '/run/user/1000/clide/abc.sock'}); + }); +}