@sjhmars/happy-bridge 0.1.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.
@@ -0,0 +1,40 @@
1
+ /** Machine-scoped Happy socket: spawn, stop-session, slim listDirectory. */
2
+ import type { CryptoContext } from './encryption.ts';
3
+ import type { SpawnSessionOptions, SpawnSessionResult, VirtualWorkspace } from './types.ts';
4
+ /** Machine RPC handlers owned by the bridge. */
5
+ export interface MachineHandlers {
6
+ spawn: (options: SpawnSessionOptions) => Promise<SpawnSessionResult>;
7
+ /** Continue an offline Happy row (`resume-happy-session`). */
8
+ resume: (happySessionId: string) => Promise<SpawnSessionResult>;
9
+ stopSession: (happySessionId: string) => void;
10
+ listWorkspaces: () => VirtualWorkspace[];
11
+ log: (message: string) => void;
12
+ }
13
+ /**
14
+ * Machine-scoped connection so the App New button reaches this Host.
15
+ */
16
+ export declare class HappyMachineSocket {
17
+ readonly machineId: string;
18
+ private readonly token;
19
+ private readonly serverUrl;
20
+ private readonly crypto;
21
+ private readonly handlers;
22
+ private socket;
23
+ private aliveTimer;
24
+ private readonly rpcMethods;
25
+ /**
26
+ * @param machineId - stable machine id.
27
+ * @param token - bearer token.
28
+ * @param serverUrl - API origin.
29
+ * @param crypto - machine encryption.
30
+ * @param handlers - spawn / resume / list / stop.
31
+ */
32
+ constructor(machineId: string, token: string, serverUrl: string, crypto: CryptoContext, handlers: MachineHandlers);
33
+ /** Connect and register prefixed RPCs. Does not register bash/writeFile. */
34
+ connect(): Promise<void>;
35
+ private bindRpc;
36
+ private sendAlive;
37
+ /** Close the machine socket. */
38
+ dispose(): void;
39
+ }
40
+ //# sourceMappingURL=machine.d.ts.map
@@ -0,0 +1,23 @@
1
+ /** Happy terminal pairing: POST /v1/auth/request, QR URL, poll until authorized. */
2
+ import type { Credentials } from './types.ts';
3
+ /** Live pairing attempt the settings card can display. */
4
+ export interface PairingAttempt {
5
+ /** Mobile `happy://terminal?...` URL. */
6
+ mobileUrl: string;
7
+ /** Web App connect URL. */
8
+ webUrl: string;
9
+ /** QR PNG data URL of the mobile URL. */
10
+ qrDataUrl: string;
11
+ /** Stop polling. */
12
+ abort: () => void;
13
+ /** Resolves with credentials when the phone approves. */
14
+ done: Promise<Omit<Credentials, 'machineId'>>;
15
+ }
16
+ /**
17
+ * Start one terminal auth request and poll until authorized.
18
+ * @param serverUrl - Happy API origin.
19
+ * @param appUrl - Happy App origin for the web URL.
20
+ * @returns URLs plus a promise that settles on success or abort.
21
+ */
22
+ export declare function startPairing(serverUrl: string, appUrl: string): Promise<PairingAttempt>;
23
+ //# sourceMappingURL=pairing.d.ts.map
@@ -0,0 +1,53 @@
1
+ /** Map Happy picker paths onto registered workspace directories. Do not mkdir. */
2
+ import type { VirtualWorkspace } from './types.ts';
3
+ /** Virtual home the Happy directory picker starts in. */
4
+ export declare const VIRTUAL_HOME = "/dsh-workspaces";
5
+ /**
6
+ * Normalize a path for comparison: slashes, no trailing slash (except root), Windows drive case.
7
+ * @param value - real or virtual path.
8
+ * @returns comparable spelling.
9
+ */
10
+ export declare function normalizePath(value: string): string;
11
+ /**
12
+ * Build virtual workspace entries from real registered roots.
13
+ * @param workspaces - `{ path, title, id }` from `workspaceRegistry.list()`.
14
+ * @returns virtual POSIX paths under {@link VIRTUAL_HOME}.
15
+ */
16
+ export declare function virtualWorkspaces(workspaces: readonly {
17
+ id: string;
18
+ path: string;
19
+ title: string;
20
+ }[]): VirtualWorkspace[];
21
+ /**
22
+ * Find the registered workspace that owns a real session cwd.
23
+ * Nested roots pick the longest matching path.
24
+ * @param realCwd - session `header.cwd` or persisted meta.cwd.
25
+ * @param workspaces - virtual mapping.
26
+ */
27
+ export declare function matchVirtualWorkspace(realCwd: string, workspaces: readonly VirtualWorkspace[]): VirtualWorkspace | undefined;
28
+ /**
29
+ * Resolve a spawn `directory` to a registered workspace real path.
30
+ * @param directory - Happy spawn directory (virtual or real).
31
+ * @param workspaces - virtual mapping.
32
+ * @returns real path, or `undefined` when it is not a registered workspace.
33
+ */
34
+ export declare function resolveSpawnDirectory(directory: string, workspaces: readonly VirtualWorkspace[]): string | undefined;
35
+ /**
36
+ * Slim `listDirectory`: only the virtual home and workspace roots.
37
+ * @param requestPath - path the App asked to list.
38
+ * @param workspaces - virtual mapping.
39
+ * @returns Happy listDirectory payload.
40
+ */
41
+ export declare function listVirtualDirectory(requestPath: string, workspaces: readonly VirtualWorkspace[]): {
42
+ success: true;
43
+ entries: {
44
+ name: string;
45
+ type: 'directory';
46
+ }[];
47
+ } | {
48
+ success: false;
49
+ error: string;
50
+ };
51
+ /** Windows-aware basename for a real workspace path shown as a fallback slug. */
52
+ export declare function realBasename(realPath: string): string;
53
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1,34 @@
1
+ /** Typert Remote for the settings card: pairing status, start, disconnect. */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
4
+ import type { HappyBridge } from './bridge.ts';
5
+ import type { PairingStatus } from './types.ts';
6
+ /**
7
+ * Host RPC the browser settings card calls.
8
+ */
9
+ export declare class HappyBridgeService extends TypertRemoteService {
10
+ /** Live bridge; swapped when settings rebuild. */
11
+ live: HappyBridge | undefined;
12
+ /**
13
+ * @param ctx - Host context.
14
+ */
15
+ constructor(ctx: Context);
16
+ /**
17
+ * Current pairing / connection snapshot, including a QR data URL while pairing.
18
+ * @returns status for the settings card.
19
+ */
20
+ getStatus(): Promise<PairingStatus>;
21
+ /**
22
+ * Start or resume pairing.
23
+ */
24
+ startPairing(): Promise<PairingStatus>;
25
+ /**
26
+ * Disconnect Happy. The web UI keeps running.
27
+ */
28
+ disconnect(): Promise<PairingStatus>;
29
+ /**
30
+ * Drop the current login and start a fresh QR pairing.
31
+ */
32
+ rePair(): Promise<PairingStatus>;
33
+ }
34
+ //# sourceMappingURL=remote.d.ts.map
@@ -0,0 +1,22 @@
1
+ /** Prefixed Happy RPC: encrypt params/results, wait for `rpc-registered`. */
2
+ import type { Socket } from 'socket.io-client';
3
+ import { type CryptoContext } from './encryption.ts';
4
+ /** Handler for one decrypted RPC method. */
5
+ export type RpcHandler = (params: unknown) => unknown | Promise<unknown>;
6
+ /**
7
+ * Register `{prefix}:{method}` and wait for the server ack (with timeout).
8
+ * @param socket - connected Socket.IO socket.
9
+ * @param prefix - machineId or sessionId.
10
+ * @param method - bare method name.
11
+ * @param crypto - same variant as chat.
12
+ * @param handler - decrypted params in, plaintext result out.
13
+ * @param log - warning logger.
14
+ */
15
+ export declare function registerRpc(socket: Socket, prefix: string, method: string, crypto: CryptoContext, handler: RpcHandler, log: (message: string) => void): Promise<void>;
16
+ /**
17
+ * Re-emit `rpc-register` after a Socket.IO reconnect without adding another handler.
18
+ * @param socket - connected socket.
19
+ * @param method - already-prefixed `{id}:{name}` method.
20
+ */
21
+ export declare function requestRpcRegister(socket: Socket, method: string): void;
22
+ //# sourceMappingURL=rpc.d.ts.map
@@ -0,0 +1,145 @@
1
+ /** One Happy session-scoped socket: encrypt chat, metadata, agentState, permission RPC. */
2
+ import { type CryptoContext } from './encryption.ts';
3
+ import { type HappyInbound } from './inbound.ts';
4
+ import type { PermissionRpc } from './types.ts';
5
+ /** Callbacks the bridge installs on one Happy session. */
6
+ export interface SessionHandlers {
7
+ /** Decrypted inbound chat or file. */
8
+ onInbound: (message: InboundMessage) => void;
9
+ /** Phone answered a permission / fake-tool request. */
10
+ onPermission: (rpc: PermissionRpc) => void;
11
+ /** Phone tapped Stop. Happy App `sessionAbort` for Rig sends `{}`. */
12
+ onAbort: () => void;
13
+ /** App archived or deleted this Happy session. */
14
+ onArchived: () => void;
15
+ /** App restored an archived session (lifecycle back to running). */
16
+ onResumed: () => void;
17
+ /** Phone changed Happy session metadata (model picker, without sending). */
18
+ onCatalog: (meta: Record<string, unknown>) => void;
19
+ /** Log line. */
20
+ log: (message: string) => void;
21
+ }
22
+ /** Normalized inbound payload from Happy. */
23
+ export type InboundMessage = HappyInbound;
24
+ /**
25
+ * Session-scoped Happy client for one mirrored or spawned conversation.
26
+ */
27
+ export declare class HappySessionSocket {
28
+ readonly happySessionId: string;
29
+ private readonly token;
30
+ private readonly serverUrl;
31
+ private readonly crypto;
32
+ private readonly handlers;
33
+ private socket;
34
+ private metadataVersion;
35
+ private agentStateVersion;
36
+ private aliveTimer;
37
+ private turnId;
38
+ private thinking;
39
+ private rpcReady;
40
+ private readonly rpcMethods;
41
+ /**
42
+ * @param happySessionId - Happy cloud session id.
43
+ * @param token - bearer token.
44
+ * @param serverUrl - API origin.
45
+ * @param crypto - content encryption for this session.
46
+ * @param handlers - inbound callbacks.
47
+ */
48
+ constructor(happySessionId: string, token: string, serverUrl: string, crypto: CryptoContext, handlers: SessionHandlers);
49
+ /** Connect, register permission + abort + killSession, start keepalive. */
50
+ connect(initialMetadataVersion?: number, initialAgentStateVersion?: number): Promise<void>;
51
+ /** Stop session-alive so an App archive can stick. */
52
+ stopKeepAlive(): void;
53
+ /** Close the socket and keepalive. */
54
+ dispose(): void;
55
+ /** Whether the Happy session-scoped socket is connected. */
56
+ isConnected(): boolean;
57
+ /** Current Happy turn id for agent envelopes. */
58
+ currentTurn(): string | undefined;
59
+ /**
60
+ * Open a Happy turn (turn-start).
61
+ * @param time - original log time when replaying history.
62
+ * @returns the new turn id.
63
+ */
64
+ startTurn(time?: number): string;
65
+ /**
66
+ * Close the Happy turn.
67
+ * @param status - completed / failed / cancelled.
68
+ * @param time - original log time when replaying history.
69
+ */
70
+ endTurn(status: 'completed' | 'failed' | 'cancelled', time?: number): void;
71
+ /**
72
+ * Send an agent text or service envelope.
73
+ * @param kind - `text` or `service`.
74
+ * @param text - markdown body.
75
+ * @param time - original log time when replaying history.
76
+ * @param thinking - `true` for a reasoning block the App can collapse.
77
+ */
78
+ sendText(kind: 'text' | 'service', text: string, time?: number, thinking?: boolean): void;
79
+ /**
80
+ * Send a user text envelope (history backfill or echo).
81
+ * @param text - markdown body.
82
+ * @param time - original log time when replaying history.
83
+ */
84
+ sendUser(text: string, time?: number): void;
85
+ /**
86
+ * Send a user file envelope after the encrypted blob is already on Happy.
87
+ * Matches CLI `uploadLocalImageAttachmentEnvelope`: `size` is plaintext bytes.
88
+ * @param file - Happy `ref` plus display fields.
89
+ * @param time - original log time when replaying history.
90
+ */
91
+ sendFile(file: {
92
+ ref: string;
93
+ name: string;
94
+ size: number;
95
+ mimeType?: string;
96
+ }, time?: number): void;
97
+ /**
98
+ * Send a tool-call card.
99
+ * @param call - matching id. A later start with the same id updates description.
100
+ * @param name - Happy tool name (PascalCase knownTools, or a camouflage).
101
+ * @param args - tool arguments. Happy merges by keeping already-seen keys.
102
+ * @param title - short heading (schema-required; App often ignores it).
103
+ * @param description - row subtitle the App actually shows.
104
+ * @param time - original log time when replaying history.
105
+ */
106
+ sendToolStart(call: string, name: string, args: Record<string, unknown>, title: string, description: string, time?: number): void;
107
+ /**
108
+ * Close a tool-call card.
109
+ * @param call - matching id.
110
+ * @param time - original log time when replaying history.
111
+ */
112
+ sendToolEnd(call: string, time?: number): void;
113
+ /**
114
+ * Emit session-alive so the App shows the session as linked / online.
115
+ * Happy CLI sends this immediately and every 2s; without it the list archives the row.
116
+ * @param thinking - `agent/status === running`.
117
+ */
118
+ keepAlive(thinking: boolean): void;
119
+ /**
120
+ * Restart session-alive if the timer was cleared. Happy lists the row as
121
+ * offline once heartbeats stop; opening the chat on the phone does not
122
+ * start them again.
123
+ */
124
+ ensureKeepAlive(): void;
125
+ private emitAlive;
126
+ private ensureAliveTimer;
127
+ /** Tell Happy this session process is gone so the App can archive or delete it. */
128
+ endSession(): void;
129
+ /**
130
+ * Encrypt and push session metadata.
131
+ * @param metadata - plaintext catalog object.
132
+ */
133
+ updateMetadata(metadata: unknown): void;
134
+ private emitMetadata;
135
+ /**
136
+ * Encrypt and push agentState (permission requests).
137
+ * @param agentState - plaintext agentState.
138
+ */
139
+ updateState(agentState: unknown): void;
140
+ private sendAgent;
141
+ private emitEnvelope;
142
+ private onMetadataUpdate;
143
+ private dispatchInbound;
144
+ }
145
+ //# sourceMappingURL=session-socket.d.ts.map
@@ -0,0 +1,104 @@
1
+ /** Shared plugin types: config, credentials, remote grant, and Happy wire extras. */
2
+ /** Remote-control depth the phone is allowed on this Host. */
3
+ export type RemoteGrant = 'watch' | 'chat' | 'approve' | 'full';
4
+ /** Validated plugin config. */
5
+ export interface Config {
6
+ /** Master switch. */
7
+ enabled: boolean;
8
+ /** Happy API origin. */
9
+ serverUrl: string;
10
+ /** Happy App origin used to build the web pairing URL. */
11
+ appUrl: string;
12
+ /** Credential directory; empty means `%USERPROFILE%\.dsh\happy-bridge`. */
13
+ credentialDir: string;
14
+ /** Start or resume pairing when the plugin loads. */
15
+ pairOnStart: boolean;
16
+ /** How deeply the phone may control this Host. */
17
+ remoteGrant: RemoteGrant;
18
+ }
19
+ /** Encryption variant stored with the account credentials. */
20
+ export type EncryptionVariant = 'legacy' | 'dataKey';
21
+ /** Account credentials after pairing or loading `~/.happy/access.key`. */
22
+ export interface Credentials {
23
+ /** Bearer token for HTTP and Socket.IO. */
24
+ token: string;
25
+ /** Content-encryption variant. */
26
+ encryption: {
27
+ type: 'legacy';
28
+ secret: Uint8Array;
29
+ } | {
30
+ type: 'dataKey';
31
+ publicKey: Uint8Array;
32
+ machineKey: Uint8Array;
33
+ };
34
+ /** Stable machine id for machine-scoped sockets. */
35
+ machineId: string;
36
+ }
37
+ /** Pairing / connection snapshot served to the settings card. */
38
+ export interface PairingStatus {
39
+ /** Whether a usable token is on disk and not locally disconnected. */
40
+ paired: boolean;
41
+ /** Whether we are currently polling `/v1/auth/request`. */
42
+ pairing: boolean;
43
+ /** Mobile `happy://terminal?...` URL, when pairing. */
44
+ mobileUrl?: string;
45
+ /** Web App connect URL, when pairing. */
46
+ webUrl?: string;
47
+ /** QR PNG as a data URL for the mobile pairing URL. */
48
+ qrDataUrl?: string;
49
+ /** Last error shown on the card. */
50
+ error?: string;
51
+ /** Configured Happy API origin. */
52
+ serverUrl: string;
53
+ /** Machine id once known. */
54
+ machineId?: string;
55
+ /** How many mirrored Happy session sockets exist. */
56
+ sessionCount?: number;
57
+ /** How many of those sockets are currently connected. */
58
+ linkedCount?: number;
59
+ }
60
+ /** One pending Happy file waiting to ride with the next user text. */
61
+ export interface PendingFile {
62
+ /** Original display name. */
63
+ name: string;
64
+ /** Decrypted bytes. */
65
+ bytes: Uint8Array;
66
+ /** Declared or sniffed media type. */
67
+ mimeType: string;
68
+ }
69
+ /** Spawn RPC input (Happy CLI `SpawnSessionOptions` subset). */
70
+ export interface SpawnSessionOptions {
71
+ directory: string;
72
+ sessionId?: string;
73
+ permissionMode?: string;
74
+ modelMode?: string;
75
+ effortLevel?: string;
76
+ }
77
+ /** Spawn RPC result. */
78
+ export type SpawnSessionResult = {
79
+ type: 'success';
80
+ sessionId: string;
81
+ } | {
82
+ type: 'error';
83
+ errorMessage: string;
84
+ };
85
+ /** One registered workspace exposed as a virtual directory under `/dsh-workspaces`. */
86
+ export interface VirtualWorkspace {
87
+ /** Virtual POSIX path the Happy picker can join. */
88
+ virtualPath: string;
89
+ /** Real workspace directory. */
90
+ realPath: string;
91
+ /** Display name. */
92
+ title: string;
93
+ }
94
+ /** Permission RPC body from the Happy App. */
95
+ export interface PermissionRpc {
96
+ id: string;
97
+ approved: boolean;
98
+ /** Happy Allow-always; remembered in this process only. */
99
+ decision?: string;
100
+ updatedInput?: {
101
+ answers?: Record<string, string>;
102
+ };
103
+ }
104
+ //# sourceMappingURL=types.d.ts.map
package/package.json ADDED
@@ -0,0 +1,111 @@
1
+ {
2
+ "name": "@sjhmars/happy-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Pair an already-running dsh web/desktop client with Happy App so the phone remote-controls the same harness sessions",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./lib/types/client/index.d.ts",
15
+ "default": "./lib/client.js"
16
+ },
17
+ "./invariant": {
18
+ "types": "./lib/types/invariant.d.ts",
19
+ "default": "./lib/invariant.js"
20
+ },
21
+ "./src/*": "./src/*",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/invariant.js",
27
+ "lib/client.js",
28
+ "lib/types/**/*.d.ts",
29
+ "cordis.patch.yml"
30
+ ],
31
+ "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "disclosure": {
36
+ "cloud": true,
37
+ "network": [
38
+ "api.cluster-fluster.com"
39
+ ],
40
+ "offlineMode": false,
41
+ "apiKeys": [],
42
+ "permissions": [
43
+ "file-system:credentials",
44
+ "network:happy-relay"
45
+ ],
46
+ "jurisdiction": [],
47
+ "retention": "credentials"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/sjhmars/dsh-plugins.git",
52
+ "directory": "plugins/happy-bridge"
53
+ },
54
+ "peerDependencies": {
55
+ "@deepseek-ai/cordis": ">=0.1.0-rc.5",
56
+ "@deepseek-ai/schemastery": ">=0.1.0-rc.5",
57
+ "@deepseek-ai/dsh-agent": ">=0.1.0-rc.5",
58
+ "@deepseek-ai/dsh-attachment": ">=0.1.0-rc.5",
59
+ "@deepseek-ai/dsh-client-connection": ">=0.1.0-rc.5",
60
+ "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.5",
61
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.5",
62
+ "@deepseek-ai/dsh-client-ui-settings-plugins": ">=0.1.0-rc.5",
63
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.5",
64
+ "@deepseek-ai/dsh-commands": ">=0.1.0-rc.5",
65
+ "@deepseek-ai/dsh-invariants": ">=0.1.0-rc.5",
66
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.5",
67
+ "@deepseek-ai/dsh-permission-presets": ">=0.1.0-rc.5",
68
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.5",
69
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.5",
70
+ "@deepseek-ai/dsh-skill": ">=0.1.0-rc.5",
71
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.5",
72
+ "@deepseek-ai/dsh-user-approval": ">=0.1.0-rc.5",
73
+ "@deepseek-ai/dsh-user-questions": ">=0.1.0-rc.5",
74
+ "@deepseek-ai/dsh-workspace": ">=0.1.0-rc.5",
75
+ "react": "^18.2.0"
76
+ },
77
+ "dependencies": {
78
+ "@paralleldrive/cuid2": "^2.2.2",
79
+ "@slopus/happy-wire": "^0.1.0",
80
+ "qrcode": "^1.5.4",
81
+ "socket.io-client": "^4.8.1",
82
+ "tweetnacl": "^1.0.3"
83
+ },
84
+ "devDependencies": {
85
+ "@types/node": "^22.20.0",
86
+ "@types/qrcode": "^1.5.5",
87
+ "@types/react": "~18.3.31",
88
+ "lightningcss": "^1.32.0",
89
+ "react": "^18.3.1",
90
+ "tsdown": "^0.22.2",
91
+ "typescript": "^6.0.3"
92
+ },
93
+ "dsh": {
94
+ "bundle": {
95
+ "patch": "./cordis.patch.yml"
96
+ },
97
+ "client": {
98
+ "inject": [
99
+ "@deepseek-ai/dsh-client-runtime",
100
+ "@deepseek-ai/dsh-client-locale",
101
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
102
+ ],
103
+ "platform": "web"
104
+ }
105
+ },
106
+ "scripts": {
107
+ "build": "tsc -p tsconfig.json && tsdown -c tsdown.config.ts",
108
+ "typecheck": "tsc -p tsconfig.json --noEmit",
109
+ "test": "node --experimental-strip-types --test tests/*.test.ts"
110
+ }
111
+ }