@nextclaw/companion 0.1.1-beta.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 (31) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/dist/src/companion-session-view.service.test.js +27 -0
  4. package/dist/src/launcher/index.js +22 -0
  5. package/dist/src/main.js +22 -0
  6. package/dist/src/preload/index.js +13 -0
  7. package/dist/src/services/companion-application.service.js +68 -0
  8. package/dist/src/services/companion-runtime-client.service.js +88 -0
  9. package/dist/src/services/companion-session-view.service.js +52 -0
  10. package/dist/src/services/companion-tray.service.js +41 -0
  11. package/dist/src/services/companion-window.service.js +106 -0
  12. package/dist/src/stores/companion-runtime-state.store.js +21 -0
  13. package/dist/src/stores/companion-window-position.store.js +30 -0
  14. package/dist/src/types/companion.types.js +2 -0
  15. package/dist/src/utils/companion-renderer-html.utils.js +194 -0
  16. package/package.json +31 -0
  17. package/scripts/smoke.mjs +20 -0
  18. package/src/companion-session-view.service.test.ts +37 -0
  19. package/src/launcher/index.ts +24 -0
  20. package/src/main.ts +23 -0
  21. package/src/preload/index.ts +13 -0
  22. package/src/services/companion-application.service.ts +76 -0
  23. package/src/services/companion-runtime-client.service.ts +118 -0
  24. package/src/services/companion-session-view.service.ts +63 -0
  25. package/src/services/companion-tray.service.ts +44 -0
  26. package/src/services/companion-window.service.ts +115 -0
  27. package/src/stores/companion-runtime-state.store.ts +23 -0
  28. package/src/stores/companion-window-position.store.ts +31 -0
  29. package/src/types/companion.types.ts +37 -0
  30. package/src/utils/companion-renderer-html.utils.ts +192 -0
  31. package/tsconfig.json +13 -0
@@ -0,0 +1,115 @@
1
+ import { app, BrowserWindow, ipcMain, shell } from "electron";
2
+ import type { CompanionAvatarView } from "../types/companion.types.js";
3
+ import type { CompanionWindowPositionStore } from "../stores/companion-window-position.store.js";
4
+ import { renderCompanionHtml } from "../utils/companion-renderer-html.utils.js";
5
+
6
+ export class CompanionWindowService {
7
+ private window: BrowserWindow | null = null;
8
+ private currentView: CompanionAvatarView | null = null;
9
+
10
+ constructor(
11
+ private readonly preloadPath: string,
12
+ private readonly positionStore: CompanionWindowPositionStore
13
+ ) {}
14
+
15
+ readonly create = async (): Promise<void> => {
16
+ if (this.window) {
17
+ return;
18
+ }
19
+
20
+ const bounds = this.positionStore.read();
21
+ this.window = new BrowserWindow({
22
+ width: 112,
23
+ height: 132,
24
+ x: bounds?.x,
25
+ y: bounds?.y,
26
+ frame: false,
27
+ transparent: true,
28
+ resizable: false,
29
+ movable: true,
30
+ alwaysOnTop: true,
31
+ skipTaskbar: true,
32
+ hasShadow: false,
33
+ webPreferences: {
34
+ preload: this.preloadPath,
35
+ contextIsolation: true,
36
+ nodeIntegration: false
37
+ }
38
+ });
39
+
40
+ this.window.on("close", () => {
41
+ this.persistBounds();
42
+ });
43
+ this.window.on("move", () => {
44
+ this.persistBounds();
45
+ });
46
+ this.window.on("moved", () => {
47
+ this.persistBounds();
48
+ });
49
+ this.window.on("closed", () => {
50
+ this.window = null;
51
+ });
52
+
53
+ ipcMain.removeHandler("companion:open");
54
+ ipcMain.handle("companion:open", async () => {
55
+ const openUrl = this.currentView?.openUrl;
56
+ if (openUrl) {
57
+ await shell.openExternal(openUrl);
58
+ }
59
+ return null;
60
+ });
61
+ ipcMain.removeHandler("companion:quit");
62
+ ipcMain.handle("companion:quit", async () => {
63
+ app.quit();
64
+ return null;
65
+ });
66
+
67
+ ipcMain.removeAllListeners("companion:ready");
68
+ ipcMain.on("companion:ready", () => {
69
+ if (this.currentView) {
70
+ this.window?.webContents.send("companion:view", this.currentView);
71
+ }
72
+ });
73
+
74
+ await this.window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(renderCompanionHtml())}`);
75
+ };
76
+
77
+ readonly show = (): void => {
78
+ this.window?.showInactive();
79
+ };
80
+
81
+ readonly toggleVisibility = (): void => {
82
+ if (!this.window) {
83
+ return;
84
+ }
85
+ if (this.window.isVisible()) {
86
+ this.window.hide();
87
+ return;
88
+ }
89
+ this.window.showInactive();
90
+ };
91
+
92
+ readonly updateView = (view: CompanionAvatarView): void => {
93
+ this.currentView = view;
94
+ this.window?.webContents.send("companion:view", view);
95
+ };
96
+
97
+ readonly destroy = (): void => {
98
+ ipcMain.removeHandler("companion:open");
99
+ ipcMain.removeHandler("companion:quit");
100
+ ipcMain.removeAllListeners("companion:ready");
101
+ this.window?.destroy();
102
+ this.window = null;
103
+ };
104
+
105
+ private readonly persistBounds = (): void => {
106
+ const bounds = this.window?.getBounds();
107
+ if (!bounds) {
108
+ return;
109
+ }
110
+ this.positionStore.write({
111
+ x: bounds.x,
112
+ y: bounds.y
113
+ });
114
+ };
115
+ }
@@ -0,0 +1,23 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ type CompanionRuntimeState = {
5
+ pid: number;
6
+ startedAt: string;
7
+ baseUrl: string;
8
+ };
9
+
10
+ export class CompanionRuntimeStateStore {
11
+ constructor(private readonly filePath: string) {}
12
+
13
+ readonly write = (state: CompanionRuntimeState): void => {
14
+ mkdirSync(dirname(this.filePath), { recursive: true });
15
+ writeFileSync(this.filePath, JSON.stringify(state, null, 2));
16
+ };
17
+
18
+ readonly clear = (): void => {
19
+ if (existsSync(this.filePath)) {
20
+ rmSync(this.filePath, { force: true });
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ type WindowBounds = {
5
+ x?: number;
6
+ y?: number;
7
+ };
8
+
9
+ export class CompanionWindowPositionStore {
10
+ constructor(private readonly filePath: string) {}
11
+
12
+ readonly read = (): WindowBounds | null => {
13
+ if (!existsSync(this.filePath)) {
14
+ return null;
15
+ }
16
+ try {
17
+ return JSON.parse(readFileSync(this.filePath, "utf-8")) as WindowBounds;
18
+ } catch {
19
+ return null;
20
+ }
21
+ };
22
+
23
+ readonly write = (bounds: WindowBounds): void => {
24
+ mkdirSync(dirname(this.filePath), { recursive: true });
25
+ writeFileSync(this.filePath, JSON.stringify(bounds, null, 2));
26
+ };
27
+
28
+ static readonly fromUserData = (userDataPath: string): CompanionWindowPositionStore => {
29
+ return new CompanionWindowPositionStore(resolve(userDataPath, "companion-window.json"));
30
+ };
31
+ }
@@ -0,0 +1,37 @@
1
+ export type CompanionAvatarView = {
2
+ state: "running" | "idle" | "offline";
3
+ title: string;
4
+ subtitle: string;
5
+ avatarUrl?: string;
6
+ sessionId?: string;
7
+ agentId?: string;
8
+ openUrl: string;
9
+ };
10
+
11
+ export type CompanionOfflineReason = {
12
+ summary: string;
13
+ };
14
+
15
+ export type CompanionAgentProfile = {
16
+ id: string;
17
+ displayName?: string;
18
+ avatarUrl?: string;
19
+ };
20
+
21
+ export type CompanionSessionSummary = {
22
+ sessionId: string;
23
+ agentId?: string;
24
+ updatedAt: string;
25
+ messageCount: number;
26
+ status?: "idle" | "running";
27
+ };
28
+
29
+ export type CompanionSessionViewInput = {
30
+ agents: CompanionAgentProfile[];
31
+ sessions: CompanionSessionSummary[];
32
+ };
33
+
34
+ export type CompanionAppOptions = {
35
+ baseUrl: string;
36
+ runtimeStatePath?: string;
37
+ };
@@ -0,0 +1,192 @@
1
+ const COMPANION_HTML = `<!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>NextClaw Companion</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --ring: rgba(12, 84, 190, 0.2);
11
+ --panel: rgba(255, 255, 255, 0.88);
12
+ --ink: #16324f;
13
+ --muted: #5f6c7c;
14
+ --idle: #d7dce2;
15
+ --running: #26a269;
16
+ --offline: #d64545;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ html, body {
20
+ margin: 0;
21
+ width: 100%;
22
+ height: 100%;
23
+ overflow: hidden;
24
+ background: transparent;
25
+ font-family: "SF Pro Display", "Helvetica Neue", sans-serif;
26
+ }
27
+ body {
28
+ display: grid;
29
+ place-items: center;
30
+ }
31
+ button {
32
+ border: 0;
33
+ background: none;
34
+ padding: 0;
35
+ cursor: pointer;
36
+ }
37
+ .shell {
38
+ width: 112px;
39
+ height: 132px;
40
+ display: grid;
41
+ justify-items: center;
42
+ gap: 8px;
43
+ }
44
+ .avatar {
45
+ width: 96px;
46
+ height: 96px;
47
+ border-radius: 28px;
48
+ background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(235,241,247,0.94));
49
+ box-shadow: 0 14px 36px rgba(13, 30, 51, 0.16), inset 0 0 0 1px rgba(255,255,255,0.82);
50
+ position: relative;
51
+ display: grid;
52
+ place-items: center;
53
+ overflow: hidden;
54
+ -webkit-app-region: drag;
55
+ }
56
+ .avatar::after {
57
+ content: "";
58
+ position: absolute;
59
+ inset: 4px;
60
+ border-radius: 24px;
61
+ box-shadow: inset 0 0 0 1px var(--ring);
62
+ pointer-events: none;
63
+ }
64
+ .avatar img {
65
+ width: 100%;
66
+ height: 100%;
67
+ object-fit: cover;
68
+ }
69
+ .initials {
70
+ width: 100%;
71
+ height: 100%;
72
+ display: grid;
73
+ place-items: center;
74
+ color: var(--ink);
75
+ font-size: 28px;
76
+ font-weight: 700;
77
+ letter-spacing: 0;
78
+ background: radial-gradient(circle at top, rgba(255,255,255,0.98), rgba(223,232,242,0.96));
79
+ }
80
+ .status {
81
+ position: absolute;
82
+ right: 8px;
83
+ bottom: 8px;
84
+ width: 14px;
85
+ height: 14px;
86
+ border-radius: 999px;
87
+ border: 2px solid var(--panel);
88
+ background: var(--idle);
89
+ }
90
+ .status[data-state="running"] { background: var(--running); }
91
+ .status[data-state="offline"] { background: var(--offline); }
92
+ .meta {
93
+ width: 100%;
94
+ padding: 0 4px;
95
+ text-align: center;
96
+ -webkit-app-region: no-drag;
97
+ cursor: pointer;
98
+ }
99
+ .title {
100
+ color: var(--ink);
101
+ font-size: 12px;
102
+ font-weight: 700;
103
+ line-height: 1.25;
104
+ white-space: nowrap;
105
+ overflow: hidden;
106
+ text-overflow: ellipsis;
107
+ }
108
+ .subtitle {
109
+ color: var(--muted);
110
+ font-size: 11px;
111
+ line-height: 1.2;
112
+ margin-top: 2px;
113
+ white-space: nowrap;
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ }
117
+ .actions {
118
+ position: absolute;
119
+ top: 6px;
120
+ right: 6px;
121
+ display: flex;
122
+ gap: 4px;
123
+ -webkit-app-region: no-drag;
124
+ }
125
+ .icon-button {
126
+ width: 18px;
127
+ height: 18px;
128
+ border-radius: 999px;
129
+ background: rgba(22, 50, 79, 0.12);
130
+ color: var(--ink);
131
+ font-size: 11px;
132
+ font-weight: 700;
133
+ display: grid;
134
+ place-items: center;
135
+ }
136
+ </style>
137
+ </head>
138
+ <body>
139
+ <div class="shell">
140
+ <div class="avatar">
141
+ <div class="actions">
142
+ <button class="icon-button" id="close-button" type="button" aria-label="Quit Companion">x</button>
143
+ </div>
144
+ <div class="initials" id="initials">NC</div>
145
+ <img id="avatar-image" alt="" hidden />
146
+ <span class="status" id="status-dot" data-state="idle"></span>
147
+ </div>
148
+ <div class="meta" id="open-button" role="button" tabindex="0" aria-label="Open NextClaw">
149
+ <div class="title" id="title">NextClaw</div>
150
+ <div class="subtitle" id="subtitle">Waiting</div>
151
+ </div>
152
+ </div>
153
+ <script>
154
+ const titleNode = document.getElementById("title");
155
+ const subtitleNode = document.getElementById("subtitle");
156
+ const initialsNode = document.getElementById("initials");
157
+ const avatarImageNode = document.getElementById("avatar-image");
158
+ const statusNode = document.getElementById("status-dot");
159
+ const openButtonNode = document.getElementById("open-button");
160
+ const closeButtonNode = document.getElementById("close-button");
161
+ const applyView = (view) => {
162
+ titleNode.textContent = view.title;
163
+ subtitleNode.textContent = view.subtitle;
164
+ initialsNode.textContent = (view.title || "NC").slice(0, 2).toUpperCase();
165
+ statusNode.dataset.state = view.state;
166
+ if (view.avatarUrl) {
167
+ avatarImageNode.src = view.avatarUrl;
168
+ avatarImageNode.hidden = false;
169
+ initialsNode.hidden = true;
170
+ } else {
171
+ avatarImageNode.hidden = true;
172
+ avatarImageNode.removeAttribute("src");
173
+ initialsNode.hidden = false;
174
+ }
175
+ };
176
+ window.nextclawCompanion.onView(applyView);
177
+ openButtonNode.addEventListener("click", () => window.nextclawCompanion.open());
178
+ openButtonNode.addEventListener("keydown", (event) => {
179
+ if (event.key === "Enter" || event.key === " ") {
180
+ event.preventDefault();
181
+ window.nextclawCompanion.open();
182
+ }
183
+ });
184
+ closeButtonNode.addEventListener("click", () => window.nextclawCompanion.quit());
185
+ window.nextclawCompanion.ready();
186
+ </script>
187
+ </body>
188
+ </html>`;
189
+
190
+ export function renderCompanionHtml(): string {
191
+ return COMPANION_HTML;
192
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "rootDir": ".",
6
+ "module": "Node16",
7
+ "moduleResolution": "Node16",
8
+ "verbatimModuleSyntax": false,
9
+ "types": ["node", "electron"],
10
+ "lib": ["DOM", "ES2022"]
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }