@pellux/goodvibes-tui 0.25.0 → 0.27.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 (146) hide show
  1. package/CHANGELOG.md +22 -3
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +2422 -1040
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/conversation-rendering.ts +2 -2
  8. package/src/core/conversation.ts +49 -2
  9. package/src/core/session-recovery.ts +24 -18
  10. package/src/core/system-message-router.ts +42 -26
  11. package/src/core/turn-event-wiring.ts +12 -16
  12. package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +29 -24
  13. package/src/daemon/handlers/calendar/index.ts +316 -0
  14. package/src/daemon/handlers/context.ts +29 -0
  15. package/src/daemon/handlers/contracts.ts +77 -0
  16. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  17. package/src/daemon/handlers/drafts/index.ts +17 -0
  18. package/src/daemon/handlers/drafts/register.ts +331 -0
  19. package/src/daemon/handlers/email/config.ts +164 -0
  20. package/src/daemon/handlers/email/index.ts +43 -0
  21. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  22. package/src/daemon/handlers/email/runtime.ts +140 -0
  23. package/src/daemon/handlers/email/validation.ts +147 -0
  24. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  25. package/src/daemon/handlers/errors.ts +18 -0
  26. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  27. package/src/daemon/handlers/inbox/index.ts +211 -0
  28. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  29. package/src/daemon/{channels → handlers}/inbox/poller.ts +6 -5
  30. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  31. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +10 -11
  32. package/src/daemon/{channels → handlers}/inbox/providers/email.ts +2 -1
  33. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  34. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +4 -3
  35. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +11 -12
  36. package/src/daemon/handlers/index.ts +107 -0
  37. package/src/daemon/handlers/register.ts +161 -0
  38. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  39. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  40. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  41. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  42. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  43. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  44. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  45. package/src/daemon/handlers/remote/index.ts +119 -0
  46. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  47. package/src/daemon/handlers/remote/service.ts +191 -0
  48. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  49. package/src/daemon/handlers/routing/index.ts +261 -0
  50. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  51. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  52. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  53. package/src/daemon/handlers/triage/index.ts +57 -0
  54. package/src/daemon/handlers/triage/integration.ts +213 -0
  55. package/src/daemon/{triage → handlers/triage}/pipeline.ts +60 -71
  56. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  57. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  58. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  59. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  60. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  61. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  62. package/src/daemon/handlers/triage/types.ts +50 -0
  63. package/src/export/gist-uploader.ts +3 -1
  64. package/src/input/command-registry.ts +1 -0
  65. package/src/input/commands/cloudflare-runtime.ts +2 -1
  66. package/src/input/commands/guidance-runtime.ts +2 -4
  67. package/src/input/commands/health-runtime.ts +13 -7
  68. package/src/input/commands/knowledge.ts +2 -1
  69. package/src/input/commands/runtime-services.ts +40 -10
  70. package/src/input/handler-command-route.ts +1 -1
  71. package/src/input/handler-interactions.ts +2 -1
  72. package/src/input/handler-modal-routes.ts +2 -1
  73. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  74. package/src/input/handler-onboarding.ts +8 -8
  75. package/src/input/handler-ui-state.ts +1 -1
  76. package/src/input/tts-settings-actions.ts +2 -1
  77. package/src/main.ts +52 -59
  78. package/src/panels/agent-inspector-panel.ts +72 -2
  79. package/src/panels/agent-logs-panel.ts +127 -19
  80. package/src/panels/base-panel.ts +2 -1
  81. package/src/panels/builtin/agent.ts +3 -1
  82. package/src/panels/builtin/development.ts +1 -0
  83. package/src/panels/builtin/session.ts +1 -1
  84. package/src/panels/context-visualizer-panel.ts +24 -14
  85. package/src/panels/cost-tracker-panel.ts +7 -13
  86. package/src/panels/debug-panel.ts +11 -22
  87. package/src/panels/docs-panel.ts +14 -24
  88. package/src/panels/file-explorer-panel.ts +2 -1
  89. package/src/panels/file-preview-panel.ts +2 -1
  90. package/src/panels/git-panel.ts +10 -17
  91. package/src/panels/marketplace-panel.ts +2 -1
  92. package/src/panels/memory-panel.ts +2 -1
  93. package/src/panels/project-planning-panel.ts +5 -4
  94. package/src/panels/provider-health-panel.ts +56 -67
  95. package/src/panels/schedule-panel.ts +4 -7
  96. package/src/panels/session-browser-panel.ts +13 -21
  97. package/src/panels/skills-panel.ts +2 -1
  98. package/src/panels/tasks-panel.ts +2 -7
  99. package/src/panels/thinking-panel.ts +6 -11
  100. package/src/panels/token-budget-panel.ts +31 -15
  101. package/src/panels/tool-inspector-panel.ts +10 -18
  102. package/src/panels/work-plan-panel.ts +2 -1
  103. package/src/panels/wrfc-panel.ts +37 -35
  104. package/src/renderer/agent-detail-modal.ts +2 -2
  105. package/src/renderer/modal-utils.ts +0 -10
  106. package/src/renderer/process-modal.ts +2 -2
  107. package/src/runtime/bootstrap-command-context.ts +3 -0
  108. package/src/runtime/bootstrap-command-parts.ts +3 -1
  109. package/src/runtime/bootstrap-core.ts +31 -31
  110. package/src/runtime/bootstrap-shell.ts +1 -0
  111. package/src/runtime/onboarding/apply.ts +4 -3
  112. package/src/runtime/onboarding/snapshot.ts +4 -3
  113. package/src/runtime/process-lifecycle.ts +195 -0
  114. package/src/runtime/services.ts +52 -36
  115. package/src/runtime/wrfc-persistence.ts +20 -5
  116. package/src/shell/ui-openers.ts +3 -2
  117. package/src/verification/live-verifier.ts +4 -3
  118. package/src/version.ts +1 -1
  119. package/src/core/context-auto-compact.ts +0 -110
  120. package/src/daemon/calendar/index.ts +0 -52
  121. package/src/daemon/calendar/register.ts +0 -527
  122. package/src/daemon/channels/drafts/index.ts +0 -22
  123. package/src/daemon/channels/drafts/register.ts +0 -449
  124. package/src/daemon/channels/inbox/index.ts +0 -58
  125. package/src/daemon/channels/inbox/register.ts +0 -247
  126. package/src/daemon/channels/routing/index.ts +0 -39
  127. package/src/daemon/channels/routing/register.ts +0 -296
  128. package/src/daemon/email/index.ts +0 -68
  129. package/src/daemon/email/register.ts +0 -715
  130. package/src/daemon/operator/index.ts +0 -43
  131. package/src/daemon/operator/register-helper.ts +0 -150
  132. package/src/daemon/operator/surfaces.ts +0 -137
  133. package/src/daemon/operator/types.ts +0 -207
  134. package/src/daemon/remote/backends/index.ts +0 -34
  135. package/src/daemon/remote/index.ts +0 -74
  136. package/src/daemon/remote/register.ts +0 -411
  137. package/src/daemon/triage/index.ts +0 -59
  138. package/src/daemon/triage/integration.ts +0 -179
  139. package/src/daemon/triage/register.ts +0 -231
  140. package/src/daemon/triage/tagger.ts +0 -777
  141. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  142. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  143. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  144. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  145. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  146. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Linchpin helper that binds a typed host handler to an SDK-registered gateway
3
+ * method descriptor BY ID.
4
+ *
5
+ * The SDK's `GatewayMethodCatalog` constructor auto-registers every builtin
6
+ * descriptor (channels.* / email.* / calendar.* / ...) with `handler:undefined`.
7
+ * The host attaches its implementation by looking up that canonical descriptor
8
+ * via `catalog.get(id)` and re-registering it WITH the handler using
9
+ * `{ replace: true }`. Only the handler slot changes; the SDK's id, schema,
10
+ * access, scopes, and dangerous flags are preserved verbatim. No descriptor or
11
+ * schema is ever authored here.
12
+ */
13
+ import type {
14
+ GatewayMethodCatalog,
15
+ GatewayMethodInvocation,
16
+ GatewayMethodInvocationContext,
17
+ } from './contracts.ts';
18
+ import { HandlerError, REQUIRE_CONFIRM } from './errors.ts';
19
+
20
+ /** Function returned by every registration; calling it removes the attached handler. */
21
+ export type Unregister = () => void;
22
+
23
+ /** Normalized, host-facing principal/auth context derived from the SDK invocation. */
24
+ export interface HandlerContextEnvelope {
25
+ readonly principalId: string;
26
+ readonly admin: boolean;
27
+ readonly scopes: string[];
28
+ readonly explicitUserRequest: boolean;
29
+ readonly authToken: string;
30
+ }
31
+
32
+ /** Typed, host-facing shape of a single method invocation. */
33
+ export interface HandlerInvocation<TBody> {
34
+ readonly body: TBody;
35
+ readonly query: Record<string, string>;
36
+ readonly context: HandlerContextEnvelope;
37
+ }
38
+
39
+ /** A host handler: receives the typed invocation, returns the method result. */
40
+ export type TypedHandler<TBody, TResult> = (
41
+ input: HandlerInvocation<TBody>,
42
+ ) => Promise<TResult>;
43
+
44
+ export interface RegisterHandlerOptions {
45
+ /**
46
+ * When true, the wrapper enforces a confirmation gate before the handler
47
+ * runs: `body.confirm === true` AND `context.explicitUserRequest === true`.
48
+ */
49
+ readonly confirm?: boolean;
50
+ }
51
+
52
+ function errorMessage(error: unknown): string {
53
+ if (error instanceof Error) return error.message;
54
+ return String(error);
55
+ }
56
+
57
+ /**
58
+ * Derive the host-facing context envelope from the raw SDK invocation context.
59
+ * `explicitUserRequest` is carried in `context.metadata.explicitUserRequest`.
60
+ */
61
+ export function normalizeContext(c: GatewayMethodInvocationContext): HandlerContextEnvelope {
62
+ const metadata = c.metadata ?? {};
63
+ return {
64
+ principalId: c.principalId ?? '',
65
+ admin: c.admin === true,
66
+ scopes: c.scopes ? [...c.scopes] : [],
67
+ explicitUserRequest: metadata.explicitUserRequest === true,
68
+ authToken: c.authToken ?? '',
69
+ };
70
+ }
71
+
72
+ function normalizeQuery(query: GatewayMethodInvocation['query']): Record<string, string> {
73
+ const out: Record<string, string> = {};
74
+ if (!query) return out;
75
+ for (const [key, value] of Object.entries(query)) {
76
+ if (typeof value === 'string') out[key] = value;
77
+ else if (value != null) out[key] = String(value);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Guard a confirmation-gated mutation. Throws `HandlerError(REQUIRE_CONFIRM, 403)`
84
+ * unless the caller both set `body.confirm === true` and the request was an
85
+ * explicit user request.
86
+ */
87
+ export function assertConfirmed(
88
+ body: unknown,
89
+ ctx: { readonly explicitUserRequest: boolean },
90
+ ): void {
91
+ const confirmed =
92
+ typeof body === 'object'
93
+ && body !== null
94
+ && (body as { confirm?: unknown }).confirm === true;
95
+ if (!confirmed || ctx.explicitUserRequest !== true) {
96
+ throw new HandlerError('explicit user confirmation required', REQUIRE_CONFIRM, 403);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Attach a typed handler to the SDK-registered descriptor identified by
102
+ * `methodId`. Reuses the descriptor the catalog already holds and re-registers
103
+ * it with the wrapped handler via `{ replace: true }`, flipping the builtin
104
+ * method from HTTP-fallback to in-process execution.
105
+ */
106
+ export function registerCatalogHandler<TBody, TResult>(
107
+ catalog: GatewayMethodCatalog,
108
+ methodId: string,
109
+ handler: TypedHandler<TBody, TResult>,
110
+ options?: RegisterHandlerOptions,
111
+ ): Unregister {
112
+ const descriptor = catalog.get(methodId);
113
+ if (!descriptor) {
114
+ throw new HandlerError(`Unknown gateway method: ${methodId}`, 'UNKNOWN_METHOD', 404);
115
+ }
116
+
117
+ const wrapped = async (inv: GatewayMethodInvocation): Promise<unknown> => {
118
+ const context = normalizeContext(inv.context);
119
+ const body = inv.body as TBody;
120
+ if (options?.confirm) assertConfirmed(inv.body, context);
121
+ try {
122
+ return await handler({ body, query: normalizeQuery(inv.query), context });
123
+ } catch (error) {
124
+ if (error instanceof HandlerError) throw error;
125
+ throw new HandlerError(errorMessage(error), 'HANDLER_FAILED', 500);
126
+ }
127
+ };
128
+
129
+ return catalog.register(descriptor, wrapped, { replace: true });
130
+ }
131
+
132
+ /** A single id→handler binding for batch registration. */
133
+ export interface CatalogHandlerEntry {
134
+ readonly id: string;
135
+ readonly handler: TypedHandler<unknown, unknown>;
136
+ readonly options?: RegisterHandlerOptions;
137
+ }
138
+
139
+ /**
140
+ * Register many handlers at once. Returns a single `Unregister` that tears them
141
+ * down in REVERSE registration order (best-effort: a failing teardown never
142
+ * aborts the rest).
143
+ */
144
+ export function registerCatalogHandlers(
145
+ catalog: GatewayMethodCatalog,
146
+ entries: readonly CatalogHandlerEntry[],
147
+ ): Unregister {
148
+ const teardowns: Unregister[] = [];
149
+ for (const entry of entries) {
150
+ teardowns.push(registerCatalogHandler(catalog, entry.id, entry.handler, entry.options));
151
+ }
152
+ return () => {
153
+ for (let i = teardowns.length - 1; i >= 0; i -= 1) {
154
+ try {
155
+ teardowns[i]!();
156
+ } catch {
157
+ // best-effort teardown; continue unwinding the rest
158
+ }
159
+ }
160
+ };
161
+ }
@@ -10,6 +10,7 @@ import {
10
10
  type DispatchPayload,
11
11
  BackendDispatchError,
12
12
  resolveTimeout,
13
+ buildRemoteShellCommand,
13
14
  } from './types.ts';
14
15
  import { runProcess } from './process-runner.ts';
15
16
  import { tokenizeCommand } from './local-process.ts';
@@ -100,9 +101,7 @@ export function createCloudTerminalBackend(ctx: BackendContext): Backend {
100
101
  'REMOTE_BACKEND_CREDENTIAL_MISSING',
101
102
  );
102
103
  }
103
- const remoteCommand = payload?.args && payload.args.length > 0
104
- ? `${command} ${payload.args.join(' ')}`
105
- : command;
104
+ const remoteCommand = buildRemoteShellCommand(command, payload?.args);
106
105
  const { args, credEnvKey } = buildArgs(config, remoteCommand);
107
106
  const credPath = await writeCredentialFile(peer.peerId, credential);
108
107
  const env: Record<string, string> = { ...(payload?.env ?? {}) };
@@ -133,5 +132,11 @@ export function createCloudTerminalBackend(ctx: BackendContext): Backend {
133
132
  await rm(credPath, { force: true }).catch(() => {});
134
133
  }
135
134
  },
135
+ async teardown(): Promise<void> {
136
+ // Per-invocation cred files are already removed in dispatch's finally;
137
+ // sweep the cloud-creds/ dir on teardown to clear any file orphaned by a
138
+ // hard crash mid-dispatch so no provider credential outlives the daemon.
139
+ await rm(credDir, { recursive: true, force: true }).catch(() => {});
140
+ },
136
141
  };
137
142
  }
@@ -7,6 +7,7 @@ import {
7
7
  type DispatchPayload,
8
8
  BackendDispatchError,
9
9
  resolveTimeout,
10
+ buildRemoteShellCommand,
10
11
  } from './types.ts';
11
12
  import { runProcess } from './process-runner.ts';
12
13
  import { tokenizeCommand } from './local-process.ts';
@@ -52,9 +53,7 @@ export function createDockerBackend(ctx: BackendContext): Backend {
52
53
  env.DOCKER_HOST = resolved;
53
54
  }
54
55
 
55
- const innerCommand = payload?.args && payload.args.length > 0
56
- ? `${command} ${payload.args.join(' ')}`
57
- : command;
56
+ const innerCommand = buildRemoteShellCommand(command, payload?.args);
58
57
 
59
58
  const args = ['docker', 'exec'];
60
59
  if (payload?.stdin !== undefined) args.push('-i');
@@ -0,0 +1,40 @@
1
+ import type { PeerRecord } from '../peer-registry.ts';
2
+ import { type Backend, type BackendContext } from './types.ts';
3
+ import { createLocalProcessBackend } from './local-process.ts';
4
+ import { createDockerBackend } from './docker.ts';
5
+ import { createSshBackend } from './ssh.ts';
6
+ import { createCloudTerminalBackend } from './cloud-terminal.ts';
7
+
8
+ export type {
9
+ Backend,
10
+ BackendContext,
11
+ BackendDispatchResult,
12
+ DispatchPayload,
13
+ } from './types.ts';
14
+ export {
15
+ BackendDispatchError,
16
+ DEFAULT_SYNC_TIMEOUT_MS,
17
+ MAX_SYNC_TIMEOUT_MS,
18
+ resolveTimeout,
19
+ } from './types.ts';
20
+
21
+ /**
22
+ * Build the full backend map keyed by backendKind. Each backend resolves its
23
+ * own credentials lazily from the daemon credential store on dispatch; no
24
+ * network or process work happens at construction time.
25
+ */
26
+ export function createBackends(
27
+ ctx: BackendContext,
28
+ ): Map<PeerRecord['backendKind'], Backend> {
29
+ const backends: Backend[] = [
30
+ createLocalProcessBackend(ctx),
31
+ createDockerBackend(ctx),
32
+ createSshBackend(ctx),
33
+ createCloudTerminalBackend(ctx),
34
+ ];
35
+ const map = new Map<PeerRecord['backendKind'], Backend>();
36
+ for (const backend of backends) {
37
+ map.set(backend.kind, backend);
38
+ }
39
+ return map;
40
+ }
@@ -44,10 +44,9 @@ function getBunSpawn(): BunSpawn {
44
44
  return globalBun.spawn as unknown as BunSpawn;
45
45
  }
46
46
 
47
- type StreamReader = ReadableStreamDefaultReader<Uint8Array>;
48
-
49
- async function drainReader(reader: StreamReader | null): Promise<string> {
50
- if (!reader) return '';
47
+ async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
48
+ if (!stream) return '';
49
+ const reader = stream.getReader();
51
50
  const decoder = new TextDecoder();
52
51
  let out = '';
53
52
  try {
@@ -57,14 +56,8 @@ async function drainReader(reader: StreamReader | null): Promise<string> {
57
56
  if (value) out += decoder.decode(value, { stream: true });
58
57
  }
59
58
  out += decoder.decode();
60
- } catch {
61
- // Reader was cancelled (e.g. on timeout) or errored — return what we have.
62
59
  } finally {
63
- try {
64
- reader.releaseLock();
65
- } catch {
66
- // lock already released
67
- }
60
+ reader.releaseLock();
68
61
  }
69
62
  return out;
70
63
  }
@@ -73,6 +66,9 @@ async function drainReader(reader: StreamReader | null): Promise<string> {
73
66
  * Spawn a subprocess and capture its output with a hard timeout. The first
74
67
  * element of `args` is the executable. Throws only on spawn failure; non-zero
75
68
  * exit codes are returned in the result.
69
+ *
70
+ * On timeout the child is killed with SIGKILL and `child.exited` is awaited so
71
+ * no orphaned subprocess is left running after the timeout fires.
76
72
  */
77
73
  export async function runProcess(options: RunOptions): Promise<RunResult> {
78
74
  if (options.args.length === 0) {
@@ -97,15 +93,6 @@ export async function runProcess(options: RunOptions): Promise<RunResult> {
97
93
  await child.stdin.end();
98
94
  }
99
95
 
100
- // Begin draining stdout/stderr immediately, holding the readers so we can
101
- // cancel them on timeout. Otherwise a killed shell whose grandchild (e.g.
102
- // `sh -c 'sleep 5'`) inherited the stdout pipe keeps the stream open until
103
- // that orphan exits, blocking us for the child's full lifetime.
104
- const stdoutReader = child.stdout ? child.stdout.getReader() : null;
105
- const stderrReader = child.stderr ? child.stderr.getReader() : null;
106
- const stdoutPromise = drainReader(stdoutReader);
107
- const stderrPromise = drainReader(stderrReader);
108
-
109
96
  let timedOut = false;
110
97
  let timer: ReturnType<typeof setTimeout> | undefined;
111
98
  const timeoutPromise = new Promise<void>((resolve) => {
@@ -120,27 +107,16 @@ export async function runProcess(options: RunOptions): Promise<RunResult> {
120
107
  }, options.timeoutMs);
121
108
  });
122
109
 
123
- // Resolve as soon as the process exits OR the timeout fires.
124
- await Promise.race([child.exited, timeoutPromise]);
125
- if (timer) clearTimeout(timer);
110
+ // Always await the child's real exit. On timeout we SIGKILL above, then the
111
+ // race resolves via child.exited (which settles once the kill takes effect),
112
+ // guaranteeing the subprocess is reaped rather than orphaned.
113
+ const [stdout, stderr, exitCode] = await Promise.all([
114
+ readStream(child.stdout),
115
+ readStream(child.stderr),
116
+ Promise.race([child.exited, timeoutPromise.then(() => child.exited)]),
117
+ ]);
126
118
 
127
- if (timedOut) {
128
- // Unblock the drains in case an orphaned grandchild still holds the pipe.
129
- try {
130
- await stdoutReader?.cancel();
131
- } catch {
132
- // already closed
133
- }
134
- try {
135
- await stderrReader?.cancel();
136
- } catch {
137
- // already closed
138
- }
139
- }
140
-
141
- const stdout = await stdoutPromise;
142
- const stderr = await stderrPromise;
143
- const exitCode = await child.exited.catch(() => -1);
119
+ if (timer) clearTimeout(timer);
144
120
 
145
121
  return {
146
122
  exitCode: typeof exitCode === 'number' ? exitCode : -1,
@@ -10,6 +10,7 @@ import {
10
10
  type DispatchPayload,
11
11
  BackendDispatchError,
12
12
  resolveTimeout,
13
+ buildRemoteShellCommand,
13
14
  } from './types.ts';
14
15
  import { runProcess } from './process-runner.ts';
15
16
  import { tokenizeCommand } from './local-process.ts';
@@ -80,9 +81,7 @@ export function createSshBackend(ctx: BackendContext): Backend {
80
81
  const identity = await ensureIdentity(peer, config);
81
82
  const port = config.sshPort ?? 22;
82
83
  const target = `${config.sshUser}@${config.sshHost}`;
83
- const remoteCommand = payload?.args && payload.args.length > 0
84
- ? `${command} ${payload.args.join(' ')}`
85
- : command;
84
+ const remoteCommand = buildRemoteShellCommand(command, payload?.args);
86
85
 
87
86
  const args = [
88
87
  'ssh',
@@ -116,5 +115,11 @@ export function createSshBackend(ctx: BackendContext): Backend {
116
115
  stderr: result.timedOut ? `${result.stderr}\n[remote] ssh command timed out` : result.stderr,
117
116
  };
118
117
  },
118
+ async teardown(): Promise<void> {
119
+ // Sweep every pooled key file plus the ssh-keys/ dir so no private-key
120
+ // material (or stale ControlMaster sockets) outlives the daemon process.
121
+ pool.clear();
122
+ await rm(keyDir, { recursive: true, force: true }).catch(() => {});
123
+ },
119
124
  };
120
125
  }
@@ -1,5 +1,5 @@
1
- import type { DaemonCredentialStore } from '../../operator/index.ts';
2
- import type { OperatorLogger } from '../../operator/index.ts';
1
+ import type { DaemonCredentialStore } from '../../credentials.ts';
2
+ import type { HandlerLogger } from '../../context.ts';
3
3
  import type { PeerRecord } from '../peer-registry.ts';
4
4
 
5
5
  /**
@@ -28,7 +28,7 @@ export interface BackendDispatchResult {
28
28
 
29
29
  export interface BackendContext {
30
30
  credentials: DaemonCredentialStore;
31
- logger: OperatorLogger;
31
+ logger: HandlerLogger;
32
32
  /** Daemon home dir — used for ephemeral key material under a 0700 subdir. */
33
33
  homeDirectory: string;
34
34
  }
@@ -48,6 +48,13 @@ export interface Backend {
48
48
  command: string,
49
49
  payload?: DispatchPayload,
50
50
  ): Promise<BackendDispatchResult>;
51
+ /**
52
+ * Best-effort cleanup of any on-disk material the backend created (ephemeral
53
+ * key/credential files and their containing dirs). Called from the surface
54
+ * teardown so secrets do not outlive the daemon process. Optional: backends
55
+ * that write nothing to disk omit it.
56
+ */
57
+ teardown?(): Promise<void>;
51
58
  }
52
59
 
53
60
  export const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
@@ -69,3 +76,22 @@ export class BackendDispatchError extends Error {
69
76
  this.code = code;
70
77
  }
71
78
  }
79
+
80
+ /**
81
+ * Compose the remote-shell command line for the shell-style backends
82
+ * (docker `sh -c`, ssh remote command, cloud-CLI `--command`/`--scripts`).
83
+ *
84
+ * REMOTE-SHELL SEMANTICS (intentional, documented asymmetry vs local-process):
85
+ * positional `payload.args` are joined onto the command with a single space and
86
+ * are NOT shell-escaped, because these backends hand a single command STRING to
87
+ * a remote shell — the operator's `command` may itself contain pipes, redirects,
88
+ * globs, or quoting that must survive the hop verbatim. The local-process
89
+ * backend, by contrast, never invokes a shell and passes args as discrete argv.
90
+ *
91
+ * This surface is operator/admin-gated (the SDK route enforces
92
+ * confirmation/explicitUserRequest before dispatch), so callers that need
93
+ * literal arguments must pre-quote them inside `command` or `args`.
94
+ */
95
+ export function buildRemoteShellCommand(command: string, args?: string[]): string {
96
+ return args && args.length > 0 ? `${command} ${args.join(' ')}` : command;
97
+ }
@@ -1,5 +1,6 @@
1
- import { sha256First } from '../operator/index.ts';
2
- import type { OperatorLogger, DaemonCredentialStore } from '../operator/index.ts';
1
+ import { createHash } from 'node:crypto';
2
+ import type { HandlerLogger } from '../context.ts';
3
+ import type { DaemonCredentialStore } from '../credentials.ts';
3
4
  import { PeerRegistry, type PeerRecord } from './peer-registry.ts';
4
5
  import {
5
6
  type Backend,
@@ -9,10 +10,16 @@ import {
9
10
  createBackends,
10
11
  } from './backends/index.ts';
11
12
 
13
+ /** SHA-256 of input, truncated to the first `hexChars` hex characters. */
14
+ function sha256First(input: string, hexChars: number): string {
15
+ const digest = createHash('sha256').update(input, 'utf-8').digest('hex');
16
+ return digest.slice(0, Math.max(0, hexChars));
17
+ }
18
+
12
19
  // ---------------------------------------------------------------------------
13
20
  // Work-item hook — long-running invocations are enqueued as work items visible
14
- // in remote.work.list. The surface does not own the distributed runtime; the
15
- // integrator wires this hook to the existing remoteRunnerRegistry.
21
+ // in remote.work.list. The dispatcher does not own the distributed runtime; the
22
+ // integrator wires this hook to the DistributedRuntimeManager work queue.
16
23
  // ---------------------------------------------------------------------------
17
24
 
18
25
  export interface RemoteWorkItemInput {
@@ -53,7 +60,7 @@ export interface RemoteInvokeResult {
53
60
  export interface RemoteDispatcherOptions {
54
61
  registry: PeerRegistry;
55
62
  credentials: DaemonCredentialStore;
56
- logger: OperatorLogger;
63
+ logger: HandlerLogger;
57
64
  homeDirectory: string;
58
65
  /** Optional hook for enqueuing long-running work items. */
59
66
  workEnqueuer?: RemoteWorkEnqueuer;
@@ -84,7 +91,7 @@ export class RemoteDispatcher {
84
91
  private readonly registry: PeerRegistry;
85
92
  private readonly backends: Map<PeerRecord['backendKind'], Backend>;
86
93
  private readonly workEnqueuer?: RemoteWorkEnqueuer;
87
- private readonly logger: OperatorLogger;
94
+ private readonly logger: HandlerLogger;
88
95
 
89
96
  constructor(options: RemoteDispatcherOptions) {
90
97
  this.registry = options.registry;
@@ -157,4 +164,18 @@ export class RemoteDispatcher {
157
164
  stdoutDigest: sha256First(fullStdout, 64),
158
165
  };
159
166
  }
167
+
168
+ /**
169
+ * Best-effort teardown: invoke every backend's optional teardown so ephemeral
170
+ * key/credential material (ssh-keys/, cloud-creds/) is swept from disk and
171
+ * does not outlive the daemon. Failures are swallowed — teardown must never
172
+ * throw during surface shutdown.
173
+ */
174
+ async teardown(): Promise<void> {
175
+ await Promise.all(
176
+ [...this.backends.values()].map((backend) =>
177
+ backend.teardown ? backend.teardown().catch(() => {}) : Promise.resolve(),
178
+ ),
179
+ );
180
+ }
160
181
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Remote handler surface — the host backend for `remote.peers.*`.
3
+ *
4
+ * `remote.peers.invoke` is NOT a catalog method: the SDK publishes it as an HTTP
5
+ * route and injects a `DistributedRuntimeRouteService` (the host's
6
+ * `HostDistributedRuntime`) into `DaemonRemoteRouteContext.distributedRuntime`.
7
+ * This module wires that service together:
8
+ *
9
+ * - a `PeerRegistry` (peer-registry.sqlite; credential fields are
10
+ * goodvibes://secrets/ refs, embedded secrets rejected),
11
+ * - a `RemoteDispatcher` routing by backendKind to the docker/ssh/
12
+ * cloud-terminal/local-process backends,
13
+ * - the SDK `DistributedRuntimeManager` (store: tui/remote/distributed-runtime.json)
14
+ * backing the 16 peer/pairing/work methods,
15
+ * - the `HostDistributedRuntime` service implementing the 17-method contract.
16
+ *
17
+ * The returned registration is plugged into the foundation
18
+ * `DaemonHandlerSurfaceProviders.registerRemote` and surfaced on
19
+ * `DaemonHandlerSurfaces.remoteSurface` + `.remoteDispatch`. Stores init lazily
20
+ * (peer registry init is backgrounded) so wiring stays synchronous.
21
+ */
22
+ import { join } from 'node:path';
23
+ import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
24
+ import type { HandlerContext } from '../context.ts';
25
+ import type {
26
+ RemoteSurfaceRegistration,
27
+ RemoteInvokeAdapter,
28
+ } from '../index.ts';
29
+ import { PeerRegistry } from './peer-registry.ts';
30
+ import { RemoteDispatcher, type RemoteWorkEnqueuer } from './dispatcher.ts';
31
+ import { HostDistributedRuntime } from './service.ts';
32
+
33
+ type DistributedRuntimeManager = operations.DistributedRuntimeManager;
34
+
35
+ const DISTRIBUTED_RUNTIME_STORE = join('tui', 'remote', 'distributed-runtime.json');
36
+
37
+ export interface RegisterRemoteSurfaceOptions {
38
+ /**
39
+ * Inject the distributed runtime manager (integration may construct it in
40
+ * services.ts so other runtime bridges can attach to the same instance). When
41
+ * omitted, the surface builds its own manager rooted under the project's
42
+ * .goodvibes directory.
43
+ */
44
+ readonly manager?: DistributedRuntimeManager;
45
+ }
46
+
47
+ /**
48
+ * Build the remote surface. Returns the teardown, the host
49
+ * `DistributedRuntimeRouteService` the SDK facade injects, and the
50
+ * `remote.peers.invoke` dispatch adapter.
51
+ */
52
+ export function registerRemoteSurface(
53
+ ctx: HandlerContext,
54
+ options?: RegisterRemoteSurfaceOptions,
55
+ ): RemoteSurfaceRegistration {
56
+ const registry = new PeerRegistry(ctx.workingDirectory);
57
+
58
+ const manager =
59
+ options?.manager
60
+ ?? new operations.DistributedRuntimeManager(
61
+ join(ctx.workingDirectory, '.goodvibes', DISTRIBUTED_RUNTIME_STORE),
62
+ );
63
+
64
+ // Adapt the SDK manager's work queue to the dispatcher's enqueue hook so a
65
+ // production `remote.peers.invoke {async:true}` creates a work item visible
66
+ // in remote.work.list and returns its id (instead of running synchronously).
67
+ const workEnqueuer: RemoteWorkEnqueuer = {
68
+ enqueue: async (item) => {
69
+ // item.backendKind is intentionally NOT forwarded: the SDK enqueueWork
70
+ // contract has no backendKind parameter, and the work runner re-resolves
71
+ // the backend from the live peer record at claim time (so a peer that is
72
+ // re-registered onto a different backend before the work runs is honored).
73
+ const work = await manager.enqueueWork({
74
+ peerId: item.peerId,
75
+ command: item.command,
76
+ actor: item.queuedBy,
77
+ ...(item.payload !== undefined ? { payload: item.payload } : {}),
78
+ });
79
+ return { workId: work.id };
80
+ },
81
+ };
82
+
83
+ const dispatcher = new RemoteDispatcher({
84
+ registry,
85
+ credentials: ctx.credentials,
86
+ logger: ctx.logger,
87
+ homeDirectory: ctx.homeDirectory,
88
+ workEnqueuer,
89
+ });
90
+
91
+ const service = new HostDistributedRuntime(manager, dispatcher);
92
+
93
+ // Initialize persistent state lazily in the background so surface
94
+ // construction stays synchronous and never blocks daemon bootstrap.
95
+ void registry.init().catch((error) => {
96
+ ctx.logger.error('remote peer registry init failed', { error });
97
+ });
98
+ if (!options?.manager) {
99
+ void manager.start().catch((error) => {
100
+ ctx.logger.error('distributed runtime manager start failed', { error });
101
+ });
102
+ }
103
+
104
+ const dispatch: RemoteInvokeAdapter = {
105
+ invoke: (input: Record<string, unknown>) => service.invokePeer(input),
106
+ };
107
+
108
+ const unregister = (): void => {
109
+ registry.close();
110
+ // Best-effort, fire-and-forget sweep of ephemeral key/credential material
111
+ // (ssh-keys/, cloud-creds/) so no secret-bearing file outlives the surface.
112
+ // Kept off the synchronous teardown path; failures are swallowed inside.
113
+ void dispatcher.teardown().catch((error) => {
114
+ ctx.logger.error('remote backend teardown failed', { error });
115
+ });
116
+ };
117
+
118
+ return { unregister, service, dispatch };
119
+ }