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) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 15:51:13 +02:00
co-authored by Claude Opus 4.8
parent ceab497ba8
commit 679b5ba9d9
9 changed files with 144 additions and 3 deletions
@@ -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;
+10
View File
@@ -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);
+4
View File
@@ -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.
+7 -1
View File
@@ -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<void> 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 [<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.
+1 -1
View File
@@ -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<String> _umbrellaCommands = {'status', 'tail', 'events', 'version', 'ping', 'capabilities'};
const Set<String> _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.
+16
View File
@@ -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}));
}
+61 -1
View File
@@ -44,6 +44,7 @@
#include <process.h>
#else
#define _POSIX_C_SOURCE 200809L
#include <dirent.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
@@ -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 <subsystem> <verb> [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
+25
View File
@@ -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<String, Object?>;
expect(obj['workspace'], workspaceRoot.path);
expect(obj['version'], '9.9.9-test');
expect(obj['pid'], 4242);
expect(obj['socketPath'], server.socketPath);
});
});
}
+19
View File
@@ -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'});
});
}