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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user