Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"errorSshAuth": "Couldn't sign in to the host. Check the username, private key, and passphrase.",
"errorHostConnection": "Couldn't reach the host. Check that it's online and the address and port are correct.",
"errorHostConnectionLost": "Lost the connection to the host. If you connect over a VPN, check that it's still active.",
"errorHerdrServerUnreachable": "Connected to the host, but herdr isn't running on it. Start herdr on the host, then try again.",
"herdrVersionTooOld": "herdr {found} on this host is older than the minimum supported version {minimum}. Update herdr on the host to start agents.",
"@herdrVersionTooOld": {
"placeholders": {
Expand Down
1 change: 1 addition & 0 deletions app/lib/l10n/app_ja.arb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"errorSshAuth": "ホストにサインインできませんでした。ユーザー名・秘密鍵・パスフレーズを確認してください。",
"errorHostConnection": "ホストに接続できませんでした。オンラインであること、アドレスとポートが正しいことを確認してください。",
"errorHostConnectionLost": "ホストとの接続が切れました。VPN経由で接続している場合は、有効になっているか確認してください。",
"errorHerdrServerUnreachable": "ホストへの接続はできましたが、herdr が起動していません。ホストで herdr を起動してから、もう一度お試しください。",
"herdrVersionTooOld": "このホストの herdr {found} は、drover が対応する最小バージョン {minimum} を下回っています。エージェントを起動するには、ホスト側の herdr を更新してください。",
"errorGeneric": "問題が発生しました。",
"errorDetailsLabel": "詳細",
Expand Down
6 changes: 6 additions & 0 deletions app/lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,12 @@ abstract class AppLocalizations {
/// **'Lost the connection to the host. If you connect over a VPN, check that it\'s still active.'**
String get errorHostConnectionLost;

/// No description provided for @errorHerdrServerUnreachable.
///
/// In en, this message translates to:
/// **'Connected to the host, but herdr isn\'t running on it. Start herdr on the host, then try again.'**
String get errorHerdrServerUnreachable;

/// No description provided for @herdrVersionTooOld.
///
/// In en, this message translates to:
Expand Down
4 changes: 4 additions & 0 deletions app/lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get errorHostConnectionLost =>
'Lost the connection to the host. If you connect over a VPN, check that it\'s still active.';

@override
String get errorHerdrServerUnreachable =>
'Connected to the host, but herdr isn\'t running on it. Start herdr on the host, then try again.';

@override
String herdrVersionTooOld(String found, String minimum) {
return 'herdr $found on this host is older than the minimum supported version $minimum. Update herdr on the host to start agents.';
Expand Down
4 changes: 4 additions & 0 deletions app/lib/l10n/app_localizations_ja.dart
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get errorHostConnectionLost =>
'ホストとの接続が切れました。VPN経由で接続している場合は、有効になっているか確認してください。';

@override
String get errorHerdrServerUnreachable =>
'ホストへの接続はできましたが、herdr が起動していません。ホストで herdr を起動してから、もう一度お試しください。';

@override
String herdrVersionTooOld(String found, String minimum) {
return 'このホストの herdr $found は、drover が対応する最小バージョン $minimum を下回っています。エージェントを起動するには、ホスト側の herdr を更新してください。';
Expand Down
2 changes: 1 addition & 1 deletion app/lib/previews/preview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ final _errorSamples = <(String, Object)>[
),
(
'herdr version unsupported',
const HerdrVersionUnsupportedException(found: '0.7.0', minimum: '0.7.5'),
const HerdrVersionUnsupportedException(found: '0.7.0', minimum: '0.8.0'),
),
];

Expand Down
2 changes: 1 addition & 1 deletion app/lib/src/demo/demo_backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const demoDocsPaneId = 'demo:p3';
const _reviewTabId = 'demo:t2';
const _docsTabId = 'demo:t3';

const _demoHerdrVersion = '0.7.5';
const _demoHerdrVersion = '0.8.0';

/// The demo session's identity, reported as the agent's `agent_session` value.
/// Must be a real UUID: every native-transcript loader gates on
Expand Down
9 changes: 8 additions & 1 deletion app/lib/src/herdr/herdr_version.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ typedef HerdrVersion = (int, int, int);
/// prompt` with no fallback or version detection, and `startAgent`/`agent
/// wait` rely on the `--pane`/`--until` shapes — all 0.7.5-only behaviors
/// documented in docs/herdr-notes.md. Below this, starting an agent errors.
const kMinHerdrVersion = (0, 7, 5);
/// Raised from 0.7.5 to 0.8.0 on 2026-08-13 as a deliberate diagnosability
/// floor, not because a command shape changed: 0.8.0 is the first version that
/// reports a stopped herdr server as a `server_not_running` error envelope,
/// which `app_error.dart` classifies into a message naming herdr rather than
/// the network. On 0.7.x that same failure surfaces as a bare `Error: Os
/// { ... }` string users misread as an SSH failure (issue #160), and drover
/// deliberately does not parse it.
const kMinHerdrVersion = (0, 8, 0);

/// Extracts the first `major.minor.patch` run of digits from raw `herdr
/// --version` stdout (e.g. `"herdr 0.7.5\n"`), or null if none is found.
Expand Down
19 changes: 14 additions & 5 deletions app/lib/src/infra/app_error.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ enum AppErrorKind {
/// The transport layer could not reach or stay connected to the host.
hostConnection,

/// SSH reached the host, but herdr's background server isn't running there.
herdrServerUnreachable,

/// The host's herdr is older than drover's minimum supported version.
herdrVersionUnsupported,

Expand All @@ -26,16 +29,22 @@ enum AppErrorKind {
/// SSH failures reach the UI two ways: bare (from direct SFTP calls like
/// listDirectory/resolvePath) or wrapped as a [HerdrException] whose [cause]
/// is the original SSH exception (from herdr commands, which funnel through
/// `_exec`). Both are handled. A [HerdrException] with a non-null [cause]
/// means the transport threw (a real connectivity problem); one with no cause
/// means herdr ran and reported a failure (or returned junk) — treated as
/// [AppErrorKind.unknown] so we never tell the user to "check the connection"
/// for a command that actually reached the host.
/// `_exec`). Both are handled. A [HerdrException]'s `code` is checked first —
/// `server_not_running` means SSH reached the host but herdr's background
/// server is down, regardless of whether a [cause] happens to be set. Only
/// then does [cause] matter: non-null means the transport threw (a real
/// connectivity problem); null means herdr ran and reported some other
/// failure (or returned junk) — treated as [AppErrorKind.unknown] so we never
/// tell the user to "check the connection" for a command that actually
/// reached the host.
AppErrorKind classifyError(Object error) {
if (error is HerdrVersionUnsupportedException) {
return AppErrorKind.herdrVersionUnsupported;
}
if (error is HerdrException) {
if (error.code == 'server_not_running') {
return AppErrorKind.herdrServerUnreachable;
}
final cause = error.cause;
if (cause is SshHostKeyMismatchException) {
return AppErrorKind.hostKeyMismatch;
Expand Down
2 changes: 2 additions & 0 deletions app/lib/src/widgets/error_message_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ String errorHeadline(
return hostEverConnected
? l10n.errorHostConnectionLost
: l10n.errorHostConnection;
case AppErrorKind.herdrServerUnreachable:
return l10n.errorHerdrServerUnreachable;
case AppErrorKind.herdrVersionUnsupported:
final e = error as HerdrVersionUnsupportedException;
return l10n.herdrVersionTooOld(e.found, e.minimum);
Expand Down
2 changes: 1 addition & 1 deletion app/test/demo/demo_backend_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,6 @@ void main() {

test('reports a supported herdr version', () async {
final client = DemoBackend().buildClient();
expect(await client.version(), contains('0.7.5'));
expect(await client.version(), contains('0.8.0'));
});
}
28 changes: 28 additions & 0 deletions app/test/herdr/herdr_client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,34 @@ void main() {
);
});

test('throws a server_not_running HerdrException when herdr reports its '
'background server is down', () async {
final runner = FakeCommandRunner(
(_) => const CommandResult(
exitCode: 1,
stdout: '',
// Verified against herdr 0.8.0: the envelope lands on stderr with
// an empty stdout (see docs/herdr-notes.md).
stderr:
'{"id":"cli:agent:list","error":{"code":"server_not_running",'
'"message":"no herdr server is running at /tmp/nope.sock; run '
'`herdr` to start or attach it"}}',
),
);
final client = HerdrClient(runner);

await expectLater(
client.listAgents(),
throwsA(
isA<HerdrException>().having(
(e) => e.code,
'code',
'server_not_running',
),
),
);
});

test('throws the coded error when a non-zero exit carries a JSON error '
'envelope on stderr', () async {
final runner = FakeCommandRunner(
Expand Down
4 changes: 2 additions & 2 deletions app/test/herdr/herdr_version_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ void main() {
});

test('accepts a version above the minimum', () {
expect(isHerdrVersionSupported((0, 7, 6)), isTrue);
expect(isHerdrVersionSupported((0, 8, 0)), isTrue);
expect(isHerdrVersionSupported((0, 8, 1)), isTrue);
expect(isHerdrVersionSupported((0, 9, 0)), isTrue);
expect(isHerdrVersionSupported((1, 0, 0)), isTrue);
});
});
Expand Down
18 changes: 18 additions & 0 deletions app/test/infra/app_error_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ void main() {
expect(classifyError(error), AppErrorKind.unknown);
});

test(
'server_not_running without a cause classifies as herdrServerUnreachable',
() {
final error = HerdrException('server_not_running', 'no server');
expect(classifyError(error), AppErrorKind.herdrServerUnreachable);
},
);

test('server_not_running with a cause still classifies as '
'herdrServerUnreachable', () {
final error = HerdrException(
'server_not_running',
'no server',
cause: Exception('socket'),
);
expect(classifyError(error), AppErrorKind.herdrServerUnreachable);
});

test('a plain exception classifies as unknown', () {
expect(classifyError(Exception('weird')), AppErrorKind.unknown);
});
Expand Down
19 changes: 19 additions & 0 deletions app/test/widgets/error_message_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,24 @@ void main() {
);
},
);

testWidgets(
'server_not_running shows a distinct herdr-not-running message',
(tester) async {
await tester.pumpWidget(
_host(const HerdrException('server_not_running', 'no server')),
);
await tester.pumpAndSettle();
expect(
find.textContaining("herdr isn't running on it"),
findsOneWidget,
);
expect(
find.textContaining('address and port are correct'),
findsNothing,
);
expect(find.textContaining('Lost the connection'), findsNothing);
},
);
});
}
4 changes: 2 additions & 2 deletions docs/app-store-submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ before setting anything up.
WHAT YOU NEED
Drover is a client for a machine you already own. To use it for real you need a
computer running Herdr (herdr.dev) with your coding agents in it, SSH access to
that machine with key-based authentication, and Herdr 0.7.5 or newer.
that machine with key-based authentication, and Herdr 0.8.0 or newer.

PRIVACY
Speech recognition runs entirely on your device — Drover will not fall back to
Expand Down Expand Up @@ -157,7 +157,7 @@ Drover は端末から自分のマシンへ SSH で直接つなぎます。途
必要なもの
Drover は、すでにあなたが持っているマシンのためのクライアントです。実際に使う
には、コーディングエージェントを動かしている Herdr (herdr.dev) 入りのコンピュータ、
鍵認証による SSH アクセス、そして Herdr 0.7.5 以降が必要です。
鍵認証による SSH アクセス、そして Herdr 0.8.0 以降が必要です。

プライバシー
音声認識は完全に端末内で処理され、サーバーにフォールバックすることはありません。
Expand Down
16 changes: 15 additions & 1 deletion docs/herdr-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ which agent is running in the pane.
<text>`, which types the text and submits it in a single atomic call.
drover's `HerdrClient.prompt` uses `agent prompt` directly (no fallback, no
version detection — herdr 0.7.5+ only).
- (2026-07-24) drover now enforces this 0.7.5 floor itself: `HerdScreen` reads
- (2026-07-24) drover now enforces a version floor itself — 0.7.5 at the time,
raised to 0.8.0 on 2026-08-13 (see the `server_not_running` note below).
`HerdScreen` reads
`herdr --version` per host (`HerdrClient.version`, `herdr_version.dart`'s
`kMinHerdrVersion`) and shows a persistent warning plus blocks launching new
agents on any host reporting an older version, since the `agent prompt`/
Expand Down Expand Up @@ -146,6 +148,18 @@ which agent is running in the pane.
now checks both stdout and stderr for a JSON error envelope regardless of
exit code, so callers see the real `code` no matter which shape a given
herdr command uses.
- **A stopped/unreachable herdr background server surfaces as a coded error
envelope.** (2026-08-13, herdr 0.8.0) Pointing a command at a missing or
listener-less socket (`HERDR_SOCKET_PATH=/tmp/nope.sock herdr agent list`)
returns exit 1 with an **empty stdout** and the envelope on **stderr**:
`{"error":{"code":"server_not_running","message":"no herdr server is running
at /tmp/nope.sock; run herdr to start or attach it"}}`. No client change was
needed — `HerdrClient._exec` already probes both channels on a non-zero exit
— but note the channel: a stdout-only probe would miss this entirely. Older
herdr instead printed a bare `Error: Os { code: 61, kind: ConnectionRefused,
... }` string on stderr with exit 0 for the same situation, which drover
surfaces as an opaque message users misread as an SSH failure (issue #160);
that is why the minimum supported herdr version moved to 0.8.0.

## Measurements (Stage 0, 2026-07-18, localhost loopback, Claude Code agent)

Expand Down
2 changes: 1 addition & 1 deletion docs/push-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ installs or updates executable code on the Herdr host.

The plugin lives in its own repo,
[`keinstn/drover-notify`](https://github.com/keinstn/drover-notify). The host
needs Herdr 0.7.5 or newer and Node.js 18 or newer. Install it on the host:
needs Herdr 0.8.0 or newer and Node.js 18 or newer. Install it on the host:

```sh
herdr plugin install keinstn/drover-notify
Expand Down
8 changes: 5 additions & 3 deletions site/support/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ You need:
`C:\ProgramData\ssh\administrators_authorized_keys`, not
`~\.ssh\authorized_keys` — otherwise the connection fails with "All
authentication methods failed".
3. **Herdr 0.7.5 or newer** on that machine. Drover enforces this — starting an
agent on an older Herdr fails, because `agent prompt` and the `--pane` /
`--until` command shapes it relies on only exist from 0.7.5.
3. **Herdr 0.8.0 or newer** on that machine. Drover enforces this — starting an
agent on an older Herdr fails. `agent prompt` and the `--pane` / `--until`
command shapes Drover relies on only exist from 0.7.5, and from 0.8.0 Herdr
reports a stopped background server clearly enough for Drover to say so,
instead of showing an error that reads like a network failure.

If you just want to see what Drover does before setting any of this up, use the
demo on the setup screen. It runs entirely on your device with sample data.
Expand Down