@pellux/goodvibes-daemon 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * sessions-command.ts — `goodvibes-daemon sessions list|kill <id>`.
3
+ *
4
+ * The sessions this daemon HOSTS: conversation loops running inside it, which
5
+ * outlive the client that started them. That is the whole reason they need a
6
+ * command — a terminal's own session dies with the terminal and never needs
7
+ * listing from outside, while a hosted one can be running on a headless box
8
+ * with nothing attached to it at all.
9
+ *
10
+ * The verbs are `sessions.hosted.list` and `sessions.hosted.kill`, and both are
11
+ * declared ws-only in the method catalog — they have no REST binding, so they
12
+ * go through `callDaemonWsVerb` rather than `callDaemonVerb`. Same target, same
13
+ * operator token, same --host/--port/--token convention; only the transport is
14
+ * different. A daemon built without hosted sessions answers "does not know the
15
+ * verb", which is reported as exactly that.
16
+ *
17
+ * This module decides nothing about what a session IS. It parses, calls, and
18
+ * renders.
19
+ */
20
+ import { callDaemonWsVerb } from '../cluster/daemon-ws-call.ts';
21
+ import {
22
+ resolveTargetOrFailure,
23
+ type DaemonCommandResult,
24
+ type RemoteCommandDeps,
25
+ type RemoteCommandFlags,
26
+ } from './status-command.ts';
27
+
28
+ export const SESSIONS_SUBCOMMANDS = ['list', 'kill'] as const;
29
+ export type SessionsSubcommand = (typeof SESSIONS_SUBCOMMANDS)[number];
30
+
31
+ export function isSessionsSubcommand(value: string | undefined): value is SessionsSubcommand {
32
+ return typeof value === 'string' && (SESSIONS_SUBCOMMANDS as readonly string[]).includes(value);
33
+ }
34
+
35
+ /** Only the fields this command prints; the daemon's record carries more. */
36
+ export interface HostedSessionRecord {
37
+ readonly id?: string;
38
+ readonly title?: string;
39
+ readonly workspaceRoot?: string;
40
+ readonly status?: string;
41
+ readonly detachPolicy?: string;
42
+ readonly effectiveDetachPolicy?: string;
43
+ readonly attachedClients?: number | readonly string[];
44
+ readonly updatedAt?: number;
45
+ readonly turnCount?: number;
46
+ readonly restoredFromDisk?: boolean;
47
+ readonly endedReason?: string;
48
+ }
49
+
50
+ interface HostedListPayload {
51
+ readonly sessions?: readonly HostedSessionRecord[];
52
+ }
53
+
54
+ interface HostedKillPayload {
55
+ readonly session?: HostedSessionRecord;
56
+ }
57
+
58
+ export interface SessionsCommandFlags extends RemoteCommandFlags {
59
+ /** `--all`: include sessions that have already ended. */
60
+ readonly all: boolean;
61
+ }
62
+
63
+ export interface RunSessionsCommandInput extends RemoteCommandDeps {
64
+ readonly flags: SessionsCommandFlags;
65
+ /** Positional words after `sessions` — the subcommand and its argument. */
66
+ readonly args: readonly string[];
67
+ }
68
+
69
+ function usage(binary = 'goodvibes-daemon'): string[] {
70
+ return [
71
+ ` ${binary} sessions list [--all] [--json]`,
72
+ ` ${binary} sessions kill <id> [--json]`,
73
+ ];
74
+ }
75
+
76
+ function refusal(message: string, json: boolean): DaemonCommandResult {
77
+ return {
78
+ exitCode: 2,
79
+ lines: json
80
+ ? [JSON.stringify({ ok: false, error: message, fix: usage().join(' | ') }, null, 2)]
81
+ : [`sessions: ${message}`, ...usage()],
82
+ };
83
+ }
84
+
85
+ function attachedCount(value: HostedSessionRecord['attachedClients']): number | undefined {
86
+ if (typeof value === 'number') return value;
87
+ if (Array.isArray(value)) return value.length;
88
+ return undefined;
89
+ }
90
+
91
+ function describeAge(at: number | undefined, now: number): string {
92
+ if (at === undefined) return '';
93
+ const seconds = Math.max(0, Math.floor((now - at) / 1000));
94
+ if (seconds < 60) return `${seconds}s ago`;
95
+ if (seconds < 3_600) return `${Math.floor(seconds / 60)}m ago`;
96
+ if (seconds < 86_400) return `${Math.floor(seconds / 3_600)}h ago`;
97
+ return `${Math.floor(seconds / 86_400)}d ago`;
98
+ }
99
+
100
+ function renderSession(session: HostedSessionRecord, now: number): string[] {
101
+ const attached = attachedCount(session.attachedClients);
102
+ const head = `${session.id ?? '(no id)'} ${session.status ?? 'unknown'}`;
103
+ const lines = [head];
104
+ if (session.title) lines.push(` ${session.title}`);
105
+ if (session.workspaceRoot) lines.push(` in ${session.workspaceRoot}`);
106
+ const facts: string[] = [];
107
+ if (session.turnCount !== undefined) facts.push(`${session.turnCount} turns`);
108
+ if (attached !== undefined) facts.push(attached === 1 ? '1 client attached' : `${attached} clients attached`);
109
+ if (session.effectiveDetachPolicy) facts.push(`on last detach: ${session.effectiveDetachPolicy}`);
110
+ if (session.updatedAt !== undefined) facts.push(describeAge(session.updatedAt, now));
111
+ if (session.restoredFromDisk === true) facts.push('restored from disk');
112
+ if (session.endedReason) facts.push(`ended: ${session.endedReason}`);
113
+ if (facts.length > 0) lines.push(` ${facts.join(' · ')}`);
114
+ return lines;
115
+ }
116
+
117
+ /**
118
+ * `sessions list` / `sessions kill <id>`.
119
+ *
120
+ * Exit 0 when the daemon answered, 1 when it refused or could not be reached,
121
+ * 2 when the command line was wrong. A `kill` with no id is a usage refusal
122
+ * rather than a "kill everything" — there is no shape of this command that ends
123
+ * more than the one session named.
124
+ */
125
+ export async function runSessionsCommand(input: RunSessionsCommandInput): Promise<DaemonCommandResult> {
126
+ const { flags, args } = input;
127
+ const subcommand = args[0];
128
+ if (subcommand === undefined) {
129
+ return refusal('name what to do with the sessions.', flags.json);
130
+ }
131
+ if (!isSessionsSubcommand(subcommand)) {
132
+ return refusal(`'${subcommand}' is not a sessions command — try list or kill.`, flags.json);
133
+ }
134
+ const sessionId = args[1];
135
+ if (subcommand === 'kill' && sessionId === undefined) {
136
+ return refusal('kill needs the session to end — run `sessions list` to see them.', flags.json);
137
+ }
138
+ if (args.length > (subcommand === 'kill' ? 2 : 1)) {
139
+ return refusal(`'${args[subcommand === 'kill' ? 2 : 1]}' is one argument too many.`, flags.json);
140
+ }
141
+
142
+ const resolved = resolveTargetOrFailure(flags, input);
143
+ if (!resolved.ok) return resolved.result;
144
+ const target = resolved.target;
145
+ const socketOption = input.socketFactory ? { socketFactory: input.socketFactory } : {};
146
+ const now = input.now?.() ?? Date.now();
147
+
148
+ if (subcommand === 'kill') {
149
+ const outcome = await callDaemonWsVerb<HostedKillPayload>(target, 'sessions.hosted.kill', {
150
+ body: { sessionId },
151
+ ...socketOption,
152
+ });
153
+ if (!outcome.ok) {
154
+ return {
155
+ exitCode: 1,
156
+ lines: flags.json
157
+ ? [JSON.stringify({ ok: false, error: outcome.error, fix: outcome.fix }, null, 2)]
158
+ : [outcome.error, ` ${outcome.fix}`],
159
+ };
160
+ }
161
+ if (flags.json) {
162
+ return { exitCode: 0, lines: [JSON.stringify({ ok: true, data: outcome.data }, null, 2)] };
163
+ }
164
+ const session = outcome.data.session;
165
+ return {
166
+ exitCode: 0,
167
+ lines: [
168
+ `ended ${session?.id ?? sessionId}`,
169
+ ...(session ? renderSession(session, now).slice(1) : []),
170
+ 'the record is kept, with the reason it ended, until the retention window retires it.',
171
+ ],
172
+ };
173
+ }
174
+
175
+ const outcome = await callDaemonWsVerb<HostedListPayload>(target, 'sessions.hosted.list', {
176
+ body: { includeTerminated: flags.all },
177
+ ...socketOption,
178
+ });
179
+ if (!outcome.ok) {
180
+ return {
181
+ exitCode: 1,
182
+ lines: flags.json
183
+ ? [JSON.stringify({ ok: false, error: outcome.error, fix: outcome.fix }, null, 2)]
184
+ : [outcome.error, ` ${outcome.fix}`],
185
+ };
186
+ }
187
+ if (flags.json) {
188
+ return { exitCode: 0, lines: [JSON.stringify({ ok: true, data: outcome.data }, null, 2)] };
189
+ }
190
+
191
+ const sessions = outcome.data.sessions ?? [];
192
+ const where = target.isLocal ? 'this machine' : target.baseUrl;
193
+ if (sessions.length === 0) {
194
+ return {
195
+ exitCode: 0,
196
+ lines: [
197
+ `the daemon on ${where} is hosting no sessions${flags.all ? '' : ' (add --all to include ones that have ended)'}.`,
198
+ ],
199
+ };
200
+ }
201
+ return {
202
+ exitCode: 0,
203
+ lines: [
204
+ `${sessions.length} session${sessions.length === 1 ? '' : 's'} hosted by the daemon on ${where}:`,
205
+ '',
206
+ ...sessions.flatMap((session) => [...renderSession(session, now), '']),
207
+ ],
208
+ };
209
+ }
@@ -0,0 +1,481 @@
1
+ /**
2
+ * status-command.ts — `goodvibes-daemon status` and `goodvibes-daemon update`.
3
+ *
4
+ * The question a headless box's operator asks first: is it up, what version, on
5
+ * what address, is anything unhealthy, and what did it do to itself while I was
6
+ * not looking. Before this, the binary answered none of that — `status` fell
7
+ * through the parser and started a SECOND daemon in the foreground.
8
+ *
9
+ * WHERE EACH LINE COMES FROM
10
+ *
11
+ * Everything about the RUNNING daemon comes from that daemon, over the
12
+ * --host/--port/--token convention @pellux/goodvibes-terminal-shell
13
+ * established: `/status` for identity, `/api/health` for the health roll-up and
14
+ * the address it actually bound, `/api/channels/status` for the channels, and
15
+ * `/api/cluster/status` for this machine's place in its group. Hosted sessions
16
+ * are a ws-only verb family, so they go through `callDaemonWsVerb` — same
17
+ * target, same token, different transport.
18
+ *
19
+ * Everything about the daemon's own HISTORY — uptime, the receipts it wrote,
20
+ * the version an automatic rollback rejected — comes from files on the daemon's
21
+ * host, because no verb reports them. That makes those lines local-only, and
22
+ * they say so for a remote target instead of being guessed at.
23
+ *
24
+ * NOTHING HERE DECIDES WHAT AN ANSWER MEANS. A sub-question that fails is one
25
+ * line saying it failed; only an unreachable daemon is a failed command.
26
+ */
27
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
28
+ import {
29
+ resolveRemoteDaemonTarget,
30
+ type DaemonFetch,
31
+ type DaemonVerbOutcome,
32
+ type RemoteDaemonTarget,
33
+ } from '@pellux/goodvibes-terminal-shell';
34
+ import { callDaemonRoute } from '../cluster/raw-reply-route.ts';
35
+ import { callDaemonWsVerb, type DaemonWebSocketFactory } from '../cluster/daemon-ws-call.ts';
36
+ import {
37
+ describeLocalDaemonState,
38
+ formatDuration,
39
+ type LocalDaemonState,
40
+ type LocalStateIo,
41
+ } from './local-daemon-state.ts';
42
+
43
+ export interface DaemonCommandResult {
44
+ readonly lines: readonly string[];
45
+ readonly exitCode: number;
46
+ }
47
+
48
+ /** The target flags every remote-capable subcommand takes. */
49
+ export interface RemoteCommandFlags {
50
+ readonly host: string | undefined;
51
+ readonly port: number | undefined;
52
+ readonly token: string | undefined;
53
+ readonly json: boolean;
54
+ }
55
+
56
+ export interface RemoteCommandDeps {
57
+ readonly configManager: Pick<ConfigManager, 'get'>;
58
+ readonly daemonHomeDir: string;
59
+ /** Where the daemon writes its own lifecycle/receipt files, for the local-only lines. */
60
+ readonly controlPlaneConfigDir: string;
61
+ readonly fetchImpl?: DaemonFetch | undefined;
62
+ readonly socketFactory?: DaemonWebSocketFactory | undefined;
63
+ readonly readToken?: ((daemonHomeDir: string) => string | undefined) | undefined;
64
+ readonly now?: (() => number) | undefined;
65
+ readonly localStateIo?: LocalStateIo | undefined;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // The shapes this command reads. Only the fields it prints are declared: the
70
+ // daemon's schemas carry a great deal more, and re-declaring all of it here
71
+ // would be a second contract to keep in step with the first.
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * `/status`. Answers with the payload itself, not a `{ ok, data }` wrapper.
76
+ *
77
+ * It carries a `cluster` block the operator contract does not list, and that
78
+ * block is where this daemon's ROLE in its group comes from. `cluster.uptimeMs`
79
+ * is the coordinator's, not the daemon's — it reads 0 on a daemon that has been
80
+ * up for hours — so the uptime line comes from the lifecycle marker instead.
81
+ */
82
+ interface ControlStatusPayload {
83
+ readonly status?: string;
84
+ readonly version?: string;
85
+ readonly cluster?: {
86
+ readonly enabled?: boolean;
87
+ readonly role?: string;
88
+ readonly nodeId?: string;
89
+ readonly heldSurfaceCount?: number;
90
+ readonly consumersRunning?: boolean;
91
+ /** The daemon BUILD's version, which is not always `version` above. */
92
+ readonly version?: string;
93
+ };
94
+ }
95
+
96
+ interface HealthNetworkBinding {
97
+ readonly host?: string;
98
+ readonly port?: number;
99
+ readonly scheme?: string;
100
+ readonly ready?: boolean;
101
+ readonly errors?: readonly string[];
102
+ }
103
+
104
+ interface HealthPayload {
105
+ readonly overall?: string;
106
+ readonly degradedDomains?: readonly string[];
107
+ readonly providerProblems?: readonly string[];
108
+ readonly integrationProblems?: readonly string[];
109
+ readonly mcpProblems?: { readonly degraded?: readonly string[]; readonly quarantined?: readonly string[] };
110
+ readonly network?: { readonly controlPlane?: HealthNetworkBinding };
111
+ }
112
+
113
+ interface ChannelsPayload {
114
+ readonly channels?: readonly {
115
+ readonly id?: string;
116
+ readonly label?: string;
117
+ readonly state?: string;
118
+ readonly enabled?: boolean;
119
+ }[];
120
+ }
121
+
122
+ /** `/api/cluster/status`. Wrapped, and the group's own view of membership. */
123
+ interface ClusterStatusPayload {
124
+ readonly membership?: string;
125
+ readonly groupName?: string | null;
126
+ readonly groupId?: string | null;
127
+ readonly nodeName?: string;
128
+ readonly memberCount?: number;
129
+ readonly advice?: string;
130
+ }
131
+
132
+ interface HostedSessionsPayload {
133
+ readonly sessions?: readonly { readonly id?: string; readonly status?: string }[];
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function failure(error: string, fix: string, json: boolean): DaemonCommandResult {
139
+ return {
140
+ lines: json
141
+ ? [JSON.stringify({ ok: false, error, fix }, null, 2)]
142
+ : [error, ` ${fix}`],
143
+ exitCode: 1,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Resolve the daemon to talk to, or explain why not.
149
+ *
150
+ * Shared by `status`, `update`, `sessions` and `pair` so all four default to
151
+ * this machine and refuse in the same words when they cannot.
152
+ */
153
+ export function resolveTargetOrFailure(
154
+ flags: RemoteCommandFlags,
155
+ deps: RemoteCommandDeps,
156
+ ): { readonly ok: true; readonly target: RemoteDaemonTarget } | { readonly ok: false; readonly result: DaemonCommandResult } {
157
+ const resolved = resolveRemoteDaemonTarget({
158
+ flags: {
159
+ ...(flags.host === undefined ? {} : { host: flags.host }),
160
+ ...(flags.port === undefined ? {} : { port: flags.port }),
161
+ ...(flags.token === undefined ? {} : { token: flags.token }),
162
+ },
163
+ configManager: deps.configManager,
164
+ daemonHomeDir: deps.daemonHomeDir,
165
+ ...(deps.readToken ? { readToken: deps.readToken } : {}),
166
+ });
167
+ if (!resolved.ok) {
168
+ return { ok: false, result: failure(resolved.error, resolved.fix, flags.json) };
169
+ }
170
+ return { ok: true, target: resolved.target };
171
+ }
172
+
173
+ function optionalLine(label: string, value: string | undefined): string[] {
174
+ return value === undefined ? [] : [`${label}${value}`];
175
+ }
176
+
177
+ /**
178
+ * The version, and a second line when the daemon states two different ones.
179
+ *
180
+ * `/status` reports `version` from the platform package while the cluster block
181
+ * it carries reports the DAEMON build's version, and against a live daemon
182
+ * those disagreed — 1.21.0 against 1.28.0. Printing one of them silently would
183
+ * put a number on this page that is wrong for whichever question the reader had
184
+ * in mind, so both are printed and labelled until the daemon states one.
185
+ */
186
+ function versionLines(identity: ControlStatusPayload): string[] {
187
+ const platform = identity.version;
188
+ const build = identity.cluster?.version;
189
+ if (platform === undefined) return optionalLine(' version: ', build);
190
+ if (build === undefined || build === platform) return [` version: ${platform}`];
191
+ return [
192
+ ` version: ${build} (the daemon build)`,
193
+ ` ${platform} (the platform package it reports on /status)`,
194
+ ];
195
+ }
196
+
197
+ /** The uptime / update / rollback block, or the one line saying why it is absent. */
198
+ function localStateLines(state: LocalDaemonState): string[] {
199
+ if (!state.available) return [` history: ${state.unavailableReason}`];
200
+ const lines: string[] = [];
201
+ if (state.uptimeMs !== undefined) {
202
+ lines.push(` uptime: ${formatDuration(state.uptimeMs)}`);
203
+ } else if (state.marker?.state === 'clean-shutdown') {
204
+ lines.push(' uptime: the last daemon on this host shut down cleanly; this is a fresh start or none');
205
+ } else {
206
+ lines.push(' uptime: not recorded yet on this host');
207
+ }
208
+ if (state.marker && state.marker.failedStarts > 0) {
209
+ lines.push(` starts: ${state.marker.failedStarts} consecutive start attempts did not finish starting`);
210
+ }
211
+ if (state.marker?.rejectedVersion !== undefined) {
212
+ lines.push(
213
+ ` rejected: ${state.marker.rejectedVersion} crash looped and was rolled back — `
214
+ + 'the update loop will not install that version again',
215
+ );
216
+ }
217
+ if (state.rolledBack) {
218
+ lines.push(' rollback: an automatic rollback is in force; no fully-started boot has cleared it yet');
219
+ }
220
+ if (state.receipts.length > 0) {
221
+ lines.push(' receipts:');
222
+ for (const receipt of state.receipts) {
223
+ lines.push(` ${new Date(receipt.at).toISOString()} ${receipt.text}`);
224
+ }
225
+ } else {
226
+ lines.push(' receipts: none written');
227
+ }
228
+ return lines;
229
+ }
230
+
231
+ function healthLines(outcome: DaemonVerbOutcome<HealthPayload>): string[] {
232
+ if (!outcome.ok) return [` health: could not read — ${outcome.error}`];
233
+ const health = outcome.data;
234
+ const lines = [` health: ${health.overall ?? 'unknown'}`];
235
+ const binding = health.network?.controlPlane;
236
+ if (binding?.host !== undefined && binding.port !== undefined) {
237
+ const ready = binding.ready === false ? ' (NOT ready)' : '';
238
+ lines.push(` bound: ${binding.scheme ?? 'http'}://${binding.host}:${binding.port}${ready}`);
239
+ for (const error of binding.errors ?? []) lines.push(` ${error}`);
240
+ }
241
+ for (const domain of health.degradedDomains ?? []) lines.push(` degraded: ${domain}`);
242
+ for (const problem of health.providerProblems ?? []) lines.push(` provider: ${problem}`);
243
+ for (const problem of health.integrationProblems ?? []) lines.push(` integration: ${problem}`);
244
+ for (const server of health.mcpProblems?.quarantined ?? []) lines.push(` mcp quarantined: ${server}`);
245
+ return lines;
246
+ }
247
+
248
+ /**
249
+ * The channel roll-up.
250
+ *
251
+ * Only a channel that is switched ON and not healthy is named. Every channel
252
+ * the daemon knows about appears in this payload, and a daemon with one
253
+ * configured channel ships sixteen more in state `disabled` — listing those as
254
+ * problems produced a seventeen-line wall under a healthy daemon and buried the
255
+ * one line that meant something.
256
+ */
257
+ function channelLines(outcome: DaemonVerbOutcome<ChannelsPayload>): string[] {
258
+ if (!outcome.ok) return [` channels: could not read — ${outcome.error}`];
259
+ const channels = outcome.data.channels ?? [];
260
+ if (channels.length === 0) return [' channels: none configured'];
261
+ const on = channels.filter((channel) => channel.enabled !== false);
262
+ const unhealthy = on.filter((channel) => channel.state !== undefined
263
+ && channel.state !== 'ready'
264
+ && channel.state !== 'healthy'
265
+ && channel.state !== 'connected');
266
+ const lines = [` channels: ${on.length} of ${channels.length} switched on`];
267
+ for (const channel of unhealthy) {
268
+ lines.push(` ${channel.label ?? channel.id ?? 'a channel'}: ${channel.state}`);
269
+ }
270
+ return lines;
271
+ }
272
+
273
+ /**
274
+ * This machine's place in its group.
275
+ *
276
+ * Two sources, because neither answers the whole question: `/status` carries
277
+ * the ROLE this node currently holds, and `/api/cluster/status` carries the
278
+ * GROUP it holds that role in. A daemon with sharing switched off says so and
279
+ * stops — a role inside no group is not information.
280
+ */
281
+ function clusterLines(
282
+ identity: ControlStatusPayload,
283
+ outcome: DaemonVerbOutcome<ClusterStatusPayload>,
284
+ ): string[] {
285
+ const role = identity.cluster?.role;
286
+ if (identity.cluster?.enabled === false) {
287
+ return [' cluster: off — this machine handles its own inbound work'];
288
+ }
289
+ if (!outcome.ok) {
290
+ return [
291
+ role === undefined
292
+ ? ` cluster: could not read — ${outcome.error}`
293
+ : ` cluster: ${role} (the group view could not be read — ${outcome.error})`,
294
+ ];
295
+ }
296
+ const cluster = outcome.data;
297
+ if (cluster.membership === 'no-group') {
298
+ return [' cluster: in no group yet — `goodvibes-daemon cluster create` starts one'];
299
+ }
300
+ const group = cluster.groupName ?? cluster.groupId ?? 'its group';
301
+ const members = cluster.memberCount === undefined ? '' : ` of ${cluster.memberCount}`;
302
+ return [` cluster: ${role ?? cluster.membership ?? 'a member'} in "${group}"${members}`];
303
+ }
304
+
305
+ function hostedSessionLines(outcome: DaemonVerbOutcome<HostedSessionsPayload>): string[] {
306
+ if (!outcome.ok) return [` sessions: could not read — ${outcome.error}`];
307
+ const sessions = outcome.data.sessions ?? [];
308
+ return [` sessions: ${sessions.length} hosted by this daemon`];
309
+ }
310
+
311
+ export interface RunStatusCommandInput extends RemoteCommandDeps {
312
+ readonly flags: RemoteCommandFlags;
313
+ }
314
+
315
+ /**
316
+ * `goodvibes-daemon status [--json]`.
317
+ *
318
+ * Exit 0 when the daemon answered its identity call, 1 when it could not be
319
+ * reached. Every other sub-question that fails is one line inside a successful
320
+ * report — a daemon with a broken channel is up, and saying otherwise would be
321
+ * the kind of wrong that makes an operator distrust the whole page.
322
+ */
323
+ export async function runStatusCommand(input: RunStatusCommandInput): Promise<DaemonCommandResult> {
324
+ const { flags } = input;
325
+ const resolved = resolveTargetOrFailure(flags, input);
326
+ if (!resolved.ok) return resolved.result;
327
+ const target = resolved.target;
328
+ const fetchImpl = input.fetchImpl ?? fetch;
329
+
330
+ // `/status` answers with the payload itself; `/api/cluster/*` wraps it. Each
331
+ // call states which, because reading one as the other turns a healthy 200
332
+ // into "the daemon refused the request".
333
+ const identity = await callDaemonRoute<ControlStatusPayload>(
334
+ target, '/status', { method: 'GET', envelope: 'raw' }, fetchImpl,
335
+ );
336
+ if (!identity.ok) return failure(identity.error, identity.fix, flags.json);
337
+
338
+ const [health, channels, cluster, hosted] = await Promise.all([
339
+ callDaemonRoute<HealthPayload>(target, '/api/health', { method: 'GET', envelope: 'raw' }, fetchImpl),
340
+ callDaemonRoute<ChannelsPayload>(target, '/api/channels/status', { method: 'GET', envelope: 'raw' }, fetchImpl),
341
+ callDaemonRoute<ClusterStatusPayload>(target, '/api/cluster/status', { method: 'GET', envelope: 'wrapped' }, fetchImpl),
342
+ callDaemonWsVerb<HostedSessionsPayload>(target, 'sessions.hosted.list', {
343
+ ...(input.socketFactory ? { socketFactory: input.socketFactory } : {}),
344
+ }),
345
+ ]);
346
+
347
+ const local = describeLocalDaemonState({
348
+ isLocal: target.isLocal,
349
+ controlPlaneConfigDir: input.controlPlaneConfigDir,
350
+ ...(input.now ? { now: input.now } : {}),
351
+ ...(input.localStateIo ? { io: input.localStateIo } : {}),
352
+ });
353
+
354
+ if (flags.json) {
355
+ return {
356
+ exitCode: 0,
357
+ lines: [JSON.stringify({
358
+ ok: true,
359
+ data: {
360
+ target: target.baseUrl,
361
+ isLocal: target.isLocal,
362
+ identity: identity.data,
363
+ health: health.ok ? health.data : { error: health.error },
364
+ channels: channels.ok ? channels.data : { error: channels.error },
365
+ cluster: cluster.ok ? cluster.data : { error: cluster.error },
366
+ hostedSessions: hosted.ok
367
+ ? { count: (hosted.data.sessions ?? []).length, sessions: hosted.data.sessions ?? [] }
368
+ : { error: hosted.error },
369
+ local: local.available
370
+ ? {
371
+ uptimeMs: local.uptimeMs,
372
+ marker: local.marker,
373
+ receipts: local.receipts,
374
+ rolledBack: local.rolledBack,
375
+ }
376
+ : { available: false, reason: local.unavailableReason },
377
+ },
378
+ }, null, 2)],
379
+ };
380
+ }
381
+
382
+ const where = target.isLocal ? 'this machine' : target.baseUrl;
383
+ return {
384
+ exitCode: 0,
385
+ lines: [
386
+ `goodvibes daemon on ${where}`,
387
+ ...versionLines(identity.data),
388
+ ...optionalLine(' state: ', identity.data.status),
389
+ ...healthLines(health),
390
+ ...localStateLines(local),
391
+ ...channelLines(channels),
392
+ ...clusterLines(identity.data, cluster),
393
+ ...hostedSessionLines(hosted),
394
+ ],
395
+ };
396
+ }
397
+
398
+ export interface RunUpdateCommandInput extends RemoteCommandDeps {
399
+ readonly flags: RemoteCommandFlags & { readonly check: boolean };
400
+ }
401
+
402
+ /**
403
+ * `goodvibes-daemon update [--check]`.
404
+ *
405
+ * What the daemon knows about its own updates: the running version, the
406
+ * receipts it wrote about swaps and restarts, the version an automatic rollback
407
+ * rejected, and whether a rollback is in force.
408
+ *
409
+ * --check is honest about a gap. The daemon runs the whole self-update loop
410
+ * itself — it checks hourly, swaps at an idle moment and keeps the outgoing
411
+ * binary — but the control plane publishes NO verb to trigger that check early:
412
+ * the operator contract this build was written against has no update method of
413
+ * any kind (no `update.*`, no `admin.update`, nothing under `control.` that
414
+ * checks). Rather than invent a verb this daemon does not answer, --check says
415
+ * so and names the two things that do work: waiting for the hourly check, or
416
+ * restarting the service, which checks on the way up.
417
+ */
418
+ export async function runUpdateCommand(input: RunUpdateCommandInput): Promise<DaemonCommandResult> {
419
+ const { flags } = input;
420
+ const resolved = resolveTargetOrFailure(flags, input);
421
+ if (!resolved.ok) return resolved.result;
422
+ const target = resolved.target;
423
+ const fetchImpl = input.fetchImpl ?? fetch;
424
+
425
+ const identity = await callDaemonRoute<ControlStatusPayload>(
426
+ target, '/status', { method: 'GET', envelope: 'raw' }, fetchImpl,
427
+ );
428
+ if (!identity.ok) return failure(identity.error, identity.fix, flags.json);
429
+
430
+ const local = describeLocalDaemonState({
431
+ isLocal: target.isLocal,
432
+ controlPlaneConfigDir: input.controlPlaneConfigDir,
433
+ ...(input.now ? { now: input.now } : {}),
434
+ ...(input.localStateIo ? { io: input.localStateIo } : {}),
435
+ });
436
+
437
+ const checkNote = flags.check
438
+ ? [
439
+ '',
440
+ 'update --check: this daemon publishes no verb to trigger an update check early.',
441
+ ' It checks once an hour on its own and swaps only at an idle moment.',
442
+ ' To make it check now, restart it — it checks on the way up:',
443
+ ' goodvibes-daemon restart-service',
444
+ ]
445
+ : [];
446
+
447
+ if (flags.json) {
448
+ return {
449
+ exitCode: 0,
450
+ lines: [JSON.stringify({
451
+ ok: true,
452
+ data: {
453
+ target: target.baseUrl,
454
+ isLocal: target.isLocal,
455
+ version: identity.data.version,
456
+ checkRequested: flags.check,
457
+ checkVerbAvailable: false,
458
+ local: local.available
459
+ ? {
460
+ marker: local.marker,
461
+ receipts: local.receipts,
462
+ rejectedVersion: local.marker?.rejectedVersion,
463
+ rolledBack: local.rolledBack,
464
+ }
465
+ : { available: false, reason: local.unavailableReason },
466
+ },
467
+ }, null, 2)],
468
+ };
469
+ }
470
+
471
+ const where = target.isLocal ? 'this machine' : target.baseUrl;
472
+ return {
473
+ exitCode: 0,
474
+ lines: [
475
+ `goodvibes daemon updates on ${where}`,
476
+ ...versionLines(identity.data),
477
+ ...localStateLines(local),
478
+ ...checkNote,
479
+ ],
480
+ };
481
+ }