myrlin-workbook 0.9.0 → 0.9.2
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.
- package/README.md +2 -0
- package/package.json +64 -64
- package/src/gui.js +5 -2
- package/src/web/auth.js +399 -308
- package/src/web/pty-manager.js +4 -1
- package/src/web/public/app.js +60 -37
- package/src/web/public/index.html +5 -3
- package/src/web/public/terminal.js +5 -0
- package/src/web/server.js +14 -3
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@ CWM_PASSWORD=mypassword npx myrlin-workbook
|
|
|
54
54
|
|
|
55
55
|
Password lookup order: `CWM_PASSWORD` env var > `~/.myrlin/config.json` > `./state/config.json` > auto-generate.
|
|
56
56
|
|
|
57
|
+
On startup, the console prints a clickable URL with a one-time token (e.g., `http://127.0.0.1:3456?token=<random>`). Click it to auto-login — the token is single-use and expires after 60 seconds, so it's safe even if it appears in terminal logs. The token is stripped from the URL bar immediately after login.
|
|
58
|
+
|
|
57
59
|
### Prerequisites
|
|
58
60
|
|
|
59
61
|
- **Node.js 18+** ([download](https://nodejs.org))
|
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "myrlin-workbook",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
|
|
5
|
-
"main": "src/index.js",
|
|
6
|
-
"bin": {
|
|
7
|
-
"myrlin-workbook": "./src/gui.js",
|
|
8
|
-
"myrlin": "./src/gui.js",
|
|
9
|
-
"myrlin-tui": "./src/index.js",
|
|
10
|
-
"cwm": "./src/index.js"
|
|
11
|
-
},
|
|
12
|
-
"scripts": {
|
|
13
|
-
"start": "node src/index.js",
|
|
14
|
-
"demo": "node src/demo.js",
|
|
15
|
-
"gui": "node src/supervisor.js",
|
|
16
|
-
"gui:bare": "node src/gui.js",
|
|
17
|
-
"gui:demo": "node src/supervisor.js --demo",
|
|
18
|
-
"test": "node test/run.js",
|
|
19
|
-
"mcp:visual-qa": "node src/mcp/visual-qa.js",
|
|
20
|
-
"gui:cdp": "node src/supervisor.js --cdp",
|
|
21
|
-
"postinstall": "node scripts/postinstall.js",
|
|
22
|
-
"restart": "bash scripts/restart-gui.sh"
|
|
23
|
-
},
|
|
24
|
-
"repository": {
|
|
25
|
-
"type": "git",
|
|
26
|
-
"url": "https://github.com/therealarthur/myrlin-workbook.git"
|
|
27
|
-
},
|
|
28
|
-
"homepage": "https://github.com/therealarthur/myrlin-workbook",
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=18.0.0"
|
|
31
|
-
},
|
|
32
|
-
"keywords": [
|
|
33
|
-
"claude",
|
|
34
|
-
"workspace",
|
|
35
|
-
"manager",
|
|
36
|
-
"terminal",
|
|
37
|
-
"tui",
|
|
38
|
-
"ai",
|
|
39
|
-
"coding-assistant",
|
|
40
|
-
"session-manager",
|
|
41
|
-
"developer-tools",
|
|
42
|
-
"xterm",
|
|
43
|
-
"myrlin"
|
|
44
|
-
],
|
|
45
|
-
"author": "Arthur",
|
|
46
|
-
"license": "AGPL-3.0-only",
|
|
47
|
-
"dependencies": {
|
|
48
|
-
"blessed": "^0.1.81",
|
|
49
|
-
"blessed-contrib": "^4.11.0",
|
|
50
|
-
"chalk": "^5.6.2",
|
|
51
|
-
"chrome-remote-interface": "^0.34.0",
|
|
52
|
-
"express": "^5.2.1",
|
|
53
|
-
"node-pty": "^1.1.0",
|
|
54
|
-
"ws": "^8.19.0"
|
|
55
|
-
},
|
|
56
|
-
"devDependencies": {
|
|
57
|
-
"@playwright/test": "^1.58.2",
|
|
58
|
-
"@xterm/addon-fit": "^0.11.0",
|
|
59
|
-
"@xterm/addon-web-links": "^0.12.0",
|
|
60
|
-
"@xterm/xterm": "^6.0.0",
|
|
61
|
-
"ffmpeg-static": "^5.3.0",
|
|
62
|
-
"sharp": "^0.34.5"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "myrlin-workbook",
|
|
3
|
+
"version": "0.9.2",
|
|
4
|
+
"description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"myrlin-workbook": "./src/gui.js",
|
|
8
|
+
"myrlin": "./src/gui.js",
|
|
9
|
+
"myrlin-tui": "./src/index.js",
|
|
10
|
+
"cwm": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node src/index.js",
|
|
14
|
+
"demo": "node src/demo.js",
|
|
15
|
+
"gui": "node src/supervisor.js",
|
|
16
|
+
"gui:bare": "node src/gui.js",
|
|
17
|
+
"gui:demo": "node src/supervisor.js --demo",
|
|
18
|
+
"test": "node test/run.js",
|
|
19
|
+
"mcp:visual-qa": "node src/mcp/visual-qa.js",
|
|
20
|
+
"gui:cdp": "node src/supervisor.js --cdp",
|
|
21
|
+
"postinstall": "node scripts/postinstall.js",
|
|
22
|
+
"restart": "bash scripts/restart-gui.sh"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/therealarthur/myrlin-workbook.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/therealarthur/myrlin-workbook",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"claude",
|
|
34
|
+
"workspace",
|
|
35
|
+
"manager",
|
|
36
|
+
"terminal",
|
|
37
|
+
"tui",
|
|
38
|
+
"ai",
|
|
39
|
+
"coding-assistant",
|
|
40
|
+
"session-manager",
|
|
41
|
+
"developer-tools",
|
|
42
|
+
"xterm",
|
|
43
|
+
"myrlin"
|
|
44
|
+
],
|
|
45
|
+
"author": "Arthur",
|
|
46
|
+
"license": "AGPL-3.0-only",
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"blessed": "^0.1.81",
|
|
49
|
+
"blessed-contrib": "^4.11.0",
|
|
50
|
+
"chalk": "^5.6.2",
|
|
51
|
+
"chrome-remote-interface": "^0.34.0",
|
|
52
|
+
"express": "^5.2.1",
|
|
53
|
+
"node-pty": "^1.1.0",
|
|
54
|
+
"ws": "^8.19.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@playwright/test": "^1.58.2",
|
|
58
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
59
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
60
|
+
"@xterm/xterm": "^6.0.0",
|
|
61
|
+
"ffmpeg-static": "^5.3.0",
|
|
62
|
+
"sharp": "^0.34.5"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/gui.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const { getStore } = require('./state/store');
|
|
17
17
|
const { startServer, getPtyManager } = require('./web/server');
|
|
18
18
|
const { backupFrontend } = require('./web/backup');
|
|
19
|
+
const { generateStartupToken } = require('./web/auth');
|
|
19
20
|
|
|
20
21
|
// ─── Initialize Store ──────────────────────────────────────
|
|
21
22
|
|
|
@@ -92,7 +93,9 @@ const port = parseInt(process.env.PORT, 10) || 3456;
|
|
|
92
93
|
const host = process.env.CWM_HOST || '127.0.0.1';
|
|
93
94
|
const server = startServer(port, host);
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
const startupToken = generateStartupToken();
|
|
97
|
+
const authUrl = `http://${host}:${port}?token=${encodeURIComponent(startupToken)}`;
|
|
98
|
+
console.log(`CWM GUI running at ${authUrl}`);
|
|
96
99
|
console.log('Press Ctrl+C to stop.');
|
|
97
100
|
|
|
98
101
|
// Snapshot frontend files as "last known good" on successful start
|
|
@@ -167,7 +170,7 @@ function openBrowserWithCDP(url, cdpPort) {
|
|
|
167
170
|
if (!process.env.CWM_NO_OPEN) {
|
|
168
171
|
const cdpEnabled = process.argv.includes('--cdp');
|
|
169
172
|
const cdpPort = parseInt(process.env.CDP_PORT, 10) || 9222;
|
|
170
|
-
const url =
|
|
173
|
+
const url = authUrl;
|
|
171
174
|
|
|
172
175
|
if (cdpEnabled) {
|
|
173
176
|
openBrowserWithCDP(url, cdpPort);
|
package/src/web/auth.js
CHANGED
|
@@ -1,308 +1,399 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Authentication module for Claude Workspace Manager Web API.
|
|
3
|
-
* Uses a simple in-memory token approach with Bearer token auth.
|
|
4
|
-
*
|
|
5
|
-
* - POST /api/auth/login - Validates password, returns a Bearer token
|
|
6
|
-
* - POST /api/auth/logout - Invalidates the token
|
|
7
|
-
* - GET /api/auth/check - Validates current token
|
|
8
|
-
*
|
|
9
|
-
* Protected routes use the requireAuth middleware which checks
|
|
10
|
-
* the Authorization: Bearer <token> header.
|
|
11
|
-
*
|
|
12
|
-
* Password is loaded from (in priority order):
|
|
13
|
-
* 1. CWM_PASSWORD environment variable
|
|
14
|
-
* 2. ~/.myrlin/config.json (persists across npx updates/reinstalls)
|
|
15
|
-
* 3. ./state/config.json (local project config)
|
|
16
|
-
* 4. Auto-generated on first run (saved to both locations)
|
|
17
|
-
*
|
|
18
|
-
* SPDX-License-Identifier: AGPL-3.0-only
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const crypto = require('crypto');
|
|
22
|
-
const fs = require('fs');
|
|
23
|
-
const path = require('path');
|
|
24
|
-
const os = require('os');
|
|
25
|
-
|
|
26
|
-
// ─── Configuration ─────────────────────────────────────────
|
|
27
|
-
const TOKEN_BYTE_LENGTH = 32;
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
* @
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
*
|
|
76
|
-
* @
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
95
|
-
* @param {string}
|
|
96
|
-
* @param {string}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
savePasswordToFile(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
console.log('
|
|
152
|
-
console.log('
|
|
153
|
-
console.log('
|
|
154
|
-
console.log('
|
|
155
|
-
console.log('
|
|
156
|
-
console.log('');
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Authentication module for Claude Workspace Manager Web API.
|
|
3
|
+
* Uses a simple in-memory token approach with Bearer token auth.
|
|
4
|
+
*
|
|
5
|
+
* - POST /api/auth/login - Validates password, returns a Bearer token
|
|
6
|
+
* - POST /api/auth/logout - Invalidates the token
|
|
7
|
+
* - GET /api/auth/check - Validates current token
|
|
8
|
+
*
|
|
9
|
+
* Protected routes use the requireAuth middleware which checks
|
|
10
|
+
* the Authorization: Bearer <token> header.
|
|
11
|
+
*
|
|
12
|
+
* Password is loaded from (in priority order):
|
|
13
|
+
* 1. CWM_PASSWORD environment variable
|
|
14
|
+
* 2. ~/.myrlin/config.json (persists across npx updates/reinstalls)
|
|
15
|
+
* 3. ./state/config.json (local project config)
|
|
16
|
+
* 4. Auto-generated on first run (saved to both locations)
|
|
17
|
+
*
|
|
18
|
+
* SPDX-License-Identifier: AGPL-3.0-only
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
|
|
26
|
+
// ─── Configuration ─────────────────────────────────────────
|
|
27
|
+
const TOKEN_BYTE_LENGTH = 32;
|
|
28
|
+
const STARTUP_TOKEN_TTL_MS = 60 * 1000; // 60 seconds
|
|
29
|
+
const HOME_CONFIG_DIR = path.join(os.homedir(), '.myrlin');
|
|
30
|
+
const HOME_CONFIG_FILE = path.join(HOME_CONFIG_DIR, 'config.json');
|
|
31
|
+
const LOCAL_CONFIG_DIR = path.join(__dirname, '..', '..', 'state');
|
|
32
|
+
const LOCAL_CONFIG_FILE = path.join(LOCAL_CONFIG_DIR, 'config.json');
|
|
33
|
+
|
|
34
|
+
// ─── Rate Limiting ─────────────────────────────────────────
|
|
35
|
+
// Simple in-memory rate limiter: max 5 login attempts per IP per 60 seconds
|
|
36
|
+
const LOGIN_RATE_LIMIT = 5;
|
|
37
|
+
const LOGIN_RATE_WINDOW_MS = 60 * 1000; // 1 minute
|
|
38
|
+
const loginAttempts = new Map(); // IP -> { count, resetAt }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if a login attempt from this IP should be rate-limited.
|
|
42
|
+
* @param {string} ip - Client IP address
|
|
43
|
+
* @returns {boolean} true if rate limited (should reject)
|
|
44
|
+
*/
|
|
45
|
+
function isRateLimited(ip) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const entry = loginAttempts.get(ip);
|
|
48
|
+
|
|
49
|
+
if (!entry || now > entry.resetAt) {
|
|
50
|
+
// Window expired or new IP - start fresh
|
|
51
|
+
loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_RATE_WINDOW_MS });
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
entry.count++;
|
|
56
|
+
if (entry.count > LOGIN_RATE_LIMIT) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Clean up stale rate limit entries every 5 minutes
|
|
63
|
+
setInterval(() => {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const [ip, entry] of loginAttempts) {
|
|
66
|
+
if (now > entry.resetAt) {
|
|
67
|
+
loginAttempts.delete(ip);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}, 5 * 60 * 1000).unref();
|
|
71
|
+
|
|
72
|
+
// ─── Password Management ──────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read password from a config file, returns null if not found.
|
|
76
|
+
* @param {string} filePath - Path to config.json
|
|
77
|
+
* @returns {string|null}
|
|
78
|
+
*/
|
|
79
|
+
function readPasswordFromFile(filePath) {
|
|
80
|
+
try {
|
|
81
|
+
if (fs.existsSync(filePath)) {
|
|
82
|
+
const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
83
|
+
if (config.password && typeof config.password === 'string') {
|
|
84
|
+
return config.password;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (_) {
|
|
88
|
+
// Corrupted config - skip
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Save password to a config file (merges with existing keys).
|
|
95
|
+
* @param {string} dir - Config directory path
|
|
96
|
+
* @param {string} filePath - Config file path
|
|
97
|
+
* @param {string} password - Password to save
|
|
98
|
+
*/
|
|
99
|
+
function savePasswordToFile(dir, filePath, password) {
|
|
100
|
+
try {
|
|
101
|
+
if (!fs.existsSync(dir)) {
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
const config = {};
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(filePath)) {
|
|
107
|
+
Object.assign(config, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
|
|
108
|
+
}
|
|
109
|
+
} catch (_) {}
|
|
110
|
+
config.password = password;
|
|
111
|
+
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Non-fatal: password still works in memory for this session
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Load or generate the auth password.
|
|
119
|
+
* Priority: env var > ~/.myrlin/config.json > ./state/config.json > auto-generate.
|
|
120
|
+
* When auto-generating, saves to both ~/.myrlin/ and ./state/ so the password
|
|
121
|
+
* persists across npx cache clears and project reinstalls.
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
function loadPassword() {
|
|
125
|
+
// 1. Environment variable (highest priority, always wins)
|
|
126
|
+
if (process.env.CWM_PASSWORD) {
|
|
127
|
+
return process.env.CWM_PASSWORD;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 2. Home directory config (~/.myrlin/config.json) — persists across reinstalls
|
|
131
|
+
const homePassword = readPasswordFromFile(HOME_CONFIG_FILE);
|
|
132
|
+
if (homePassword) {
|
|
133
|
+
// Also sync to local config so it's visible in the project
|
|
134
|
+
savePasswordToFile(LOCAL_CONFIG_DIR, LOCAL_CONFIG_FILE, homePassword);
|
|
135
|
+
return homePassword;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 3. Local project config (./state/config.json)
|
|
139
|
+
const localPassword = readPasswordFromFile(LOCAL_CONFIG_FILE);
|
|
140
|
+
if (localPassword) {
|
|
141
|
+
// Promote to home config for persistence across reinstalls
|
|
142
|
+
savePasswordToFile(HOME_CONFIG_DIR, HOME_CONFIG_FILE, localPassword);
|
|
143
|
+
return localPassword;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 4. Auto-generate and save to both locations
|
|
147
|
+
const generated = crypto.randomBytes(16).toString('base64url');
|
|
148
|
+
savePasswordToFile(HOME_CONFIG_DIR, HOME_CONFIG_FILE, generated);
|
|
149
|
+
savePasswordToFile(LOCAL_CONFIG_DIR, LOCAL_CONFIG_FILE, generated);
|
|
150
|
+
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log('══════════════════════════════════════════════════');
|
|
153
|
+
console.log(' CWM auto-generated password: ' + generated);
|
|
154
|
+
console.log(' Saved to: ~/.myrlin/config.json');
|
|
155
|
+
console.log(' Set CWM_PASSWORD env var to override.');
|
|
156
|
+
console.log('══════════════════════════════════════════════════');
|
|
157
|
+
console.log('');
|
|
158
|
+
|
|
159
|
+
return generated;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const AUTH_PASSWORD = loadPassword();
|
|
163
|
+
|
|
164
|
+
// In-memory set of valid tokens. Tokens survive for the lifetime of
|
|
165
|
+
// the server process. A restart invalidates all tokens (acceptable
|
|
166
|
+
// for a local dev-tool).
|
|
167
|
+
const activeTokens = new Set();
|
|
168
|
+
|
|
169
|
+
// ─── One-Time Startup Tokens ──────────────────────────────
|
|
170
|
+
// Map of token → { createdAt, used }. Single-use, short-lived tokens
|
|
171
|
+
// embedded in the startup URL so the browser can auto-login without
|
|
172
|
+
// exposing the actual password.
|
|
173
|
+
const startupTokens = new Map();
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Generate a one-time startup token for URL-based auto-login.
|
|
177
|
+
* The token is single-use and expires after STARTUP_TOKEN_TTL_MS.
|
|
178
|
+
* @returns {string} The generated token
|
|
179
|
+
*/
|
|
180
|
+
function generateStartupToken() {
|
|
181
|
+
const token = crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex');
|
|
182
|
+
startupTokens.set(token, { createdAt: Date.now(), used: false });
|
|
183
|
+
return token;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Clean up expired/used startup tokens every 5 minutes
|
|
187
|
+
setInterval(() => {
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
for (const [token, entry] of startupTokens) {
|
|
190
|
+
if (entry.used || now - entry.createdAt > STARTUP_TOKEN_TTL_MS) {
|
|
191
|
+
startupTokens.delete(token);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}, 5 * 60 * 1000).unref();
|
|
195
|
+
|
|
196
|
+
// ─── Helpers ───────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Generate a cryptographically random hex token.
|
|
200
|
+
* @returns {string} 64-character hex string
|
|
201
|
+
*/
|
|
202
|
+
function generateToken() {
|
|
203
|
+
return crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Extract the Bearer token from an Authorization header value.
|
|
208
|
+
* Returns null if the header is missing or malformed.
|
|
209
|
+
* @param {string|undefined} headerValue - The raw Authorization header
|
|
210
|
+
* @returns {string|null}
|
|
211
|
+
*/
|
|
212
|
+
function extractBearerToken(headerValue) {
|
|
213
|
+
if (!headerValue || typeof headerValue !== 'string') return null;
|
|
214
|
+
const parts = headerValue.split(' ');
|
|
215
|
+
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
|
|
216
|
+
return parts[1];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Middleware ─────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Express middleware that requires a valid Bearer token.
|
|
223
|
+
* Responds with 401 if the token is missing or invalid.
|
|
224
|
+
*/
|
|
225
|
+
function requireAuth(req, res, next) {
|
|
226
|
+
const token = extractBearerToken(req.headers.authorization);
|
|
227
|
+
|
|
228
|
+
if (!token || !activeTokens.has(token)) {
|
|
229
|
+
return res.status(401).json({
|
|
230
|
+
error: 'Unauthorized',
|
|
231
|
+
message: 'Valid Bearer token required. POST /api/auth/login to authenticate.',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Attach token to request for downstream use (e.g. logout)
|
|
236
|
+
req.authToken = token;
|
|
237
|
+
next();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Route Setup ───────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Mount authentication routes on the Express app.
|
|
244
|
+
* These routes are NOT protected by requireAuth - they are public.
|
|
245
|
+
*
|
|
246
|
+
* @param {import('express').Express} app - The Express application
|
|
247
|
+
*/
|
|
248
|
+
function setupAuth(app) {
|
|
249
|
+
/**
|
|
250
|
+
* POST /api/auth/login
|
|
251
|
+
* Body: { password: string }
|
|
252
|
+
* Returns: { success: true, token: string } or { success: false, error: string }
|
|
253
|
+
*/
|
|
254
|
+
app.post('/api/auth/login', (req, res) => {
|
|
255
|
+
// Rate limiting
|
|
256
|
+
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
|
|
257
|
+
if (isRateLimited(clientIp)) {
|
|
258
|
+
return res.status(429).json({
|
|
259
|
+
success: false,
|
|
260
|
+
error: 'Too many login attempts. Try again in 1 minute.',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const { password } = req.body || {};
|
|
265
|
+
|
|
266
|
+
if (!password || typeof password !== 'string') {
|
|
267
|
+
return res.status(400).json({
|
|
268
|
+
success: false,
|
|
269
|
+
error: 'Missing or invalid password field in request body.',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Constant-time comparison to mitigate timing attacks
|
|
274
|
+
const passwordBuffer = Buffer.from(password, 'utf-8');
|
|
275
|
+
const expectedBuffer = Buffer.from(AUTH_PASSWORD, 'utf-8');
|
|
276
|
+
const isValid =
|
|
277
|
+
passwordBuffer.length === expectedBuffer.length &&
|
|
278
|
+
crypto.timingSafeEqual(passwordBuffer, expectedBuffer);
|
|
279
|
+
|
|
280
|
+
if (!isValid) {
|
|
281
|
+
return res.status(403).json({
|
|
282
|
+
success: false,
|
|
283
|
+
error: 'Invalid password.',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const token = generateToken();
|
|
288
|
+
activeTokens.add(token);
|
|
289
|
+
|
|
290
|
+
return res.json({ success: true, token });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* POST /api/auth/token-login
|
|
295
|
+
* Body: { token: string }
|
|
296
|
+
* Exchanges a one-time startup token for a session Bearer token.
|
|
297
|
+
* The startup token must exist, not be expired (60s TTL), and not already used.
|
|
298
|
+
* Returns: { success: true, token: string } or { success: false, error: string }
|
|
299
|
+
*/
|
|
300
|
+
app.post('/api/auth/token-login', (req, res) => {
|
|
301
|
+
// Rate limiting (same as login)
|
|
302
|
+
const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
|
|
303
|
+
if (isRateLimited(clientIp)) {
|
|
304
|
+
return res.status(429).json({
|
|
305
|
+
success: false,
|
|
306
|
+
error: 'Too many login attempts. Try again in 1 minute.',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const { token: startupToken } = req.body || {};
|
|
311
|
+
|
|
312
|
+
if (!startupToken || typeof startupToken !== 'string') {
|
|
313
|
+
return res.status(400).json({
|
|
314
|
+
success: false,
|
|
315
|
+
error: 'Missing or invalid token field in request body.',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const entry = startupTokens.get(startupToken);
|
|
320
|
+
if (!entry) {
|
|
321
|
+
return res.status(403).json({
|
|
322
|
+
success: false,
|
|
323
|
+
error: 'Invalid or expired startup token.',
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Check expiry
|
|
328
|
+
if (Date.now() - entry.createdAt > STARTUP_TOKEN_TTL_MS) {
|
|
329
|
+
startupTokens.delete(startupToken);
|
|
330
|
+
return res.status(403).json({
|
|
331
|
+
success: false,
|
|
332
|
+
error: 'Startup token has expired.',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Check single-use
|
|
337
|
+
if (entry.used) {
|
|
338
|
+
return res.status(403).json({
|
|
339
|
+
success: false,
|
|
340
|
+
error: 'Startup token has already been used.',
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Mark as used and remove from map (single-use)
|
|
345
|
+
startupTokens.delete(startupToken);
|
|
346
|
+
|
|
347
|
+
// Issue a session token
|
|
348
|
+
const sessionToken = generateToken();
|
|
349
|
+
activeTokens.add(sessionToken);
|
|
350
|
+
|
|
351
|
+
return res.json({ success: true, token: sessionToken });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* POST /api/auth/logout
|
|
356
|
+
* Requires Authorization: Bearer <token>
|
|
357
|
+
* Removes the token from the active set.
|
|
358
|
+
*/
|
|
359
|
+
app.post('/api/auth/logout', (req, res) => {
|
|
360
|
+
const token = extractBearerToken(req.headers.authorization);
|
|
361
|
+
|
|
362
|
+
if (token) {
|
|
363
|
+
activeTokens.delete(token);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return res.json({ success: true });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* GET /api/auth/check
|
|
371
|
+
* Returns whether the provided Bearer token is still valid.
|
|
372
|
+
*/
|
|
373
|
+
app.get('/api/auth/check', (req, res) => {
|
|
374
|
+
const token = extractBearerToken(req.headers.authorization);
|
|
375
|
+
const authenticated = !!token && activeTokens.has(token);
|
|
376
|
+
|
|
377
|
+
return res.json({ authenticated });
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Check if a raw token string is valid (exists in activeTokens).
|
|
383
|
+
* Used by SSE endpoint which can't use requireAuth middleware.
|
|
384
|
+
* @param {string} token - The raw token string
|
|
385
|
+
* @returns {boolean}
|
|
386
|
+
*/
|
|
387
|
+
function isValidToken(token) {
|
|
388
|
+
return !!token && activeTokens.has(token);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─── Exports ───────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
module.exports = {
|
|
394
|
+
setupAuth,
|
|
395
|
+
requireAuth,
|
|
396
|
+
isValidToken,
|
|
397
|
+
generateStartupToken,
|
|
398
|
+
_startupTokens: startupTokens,
|
|
399
|
+
};
|
package/src/web/pty-manager.js
CHANGED
|
@@ -186,7 +186,10 @@ class PtySessionManager {
|
|
|
186
186
|
fullCommand += ' --verbose';
|
|
187
187
|
}
|
|
188
188
|
if (model) {
|
|
189
|
-
|
|
189
|
+
// Single-quote the model value so shell glob characters in aliases like
|
|
190
|
+
// sonnet[1m] are not expanded by bash before being passed to claude.
|
|
191
|
+
const safeModel = "'" + model.replace(/'/g, "'\\''") + "'";
|
|
192
|
+
fullCommand += ' --model ' + safeModel;
|
|
190
193
|
}
|
|
191
194
|
// Extra flags (e.g. from worktree task flags checkboxes), validated upstream
|
|
192
195
|
if (Array.isArray(flags)) {
|
package/src/web/public/app.js
CHANGED
|
@@ -1816,25 +1816,42 @@ class CWMApp {
|
|
|
1816
1816
|
|
|
1817
1817
|
|
|
1818
1818
|
|
|
1819
|
+
async _initializeApp() {
|
|
1820
|
+
this.showApp();
|
|
1821
|
+
this.initDragAndDrop();
|
|
1822
|
+
this.initTerminalResize();
|
|
1823
|
+
this.initTerminalGroups();
|
|
1824
|
+
this.initTerminalPaneSwipe();
|
|
1825
|
+
this.initNotesEditor();
|
|
1826
|
+
this.initAIInsights();
|
|
1827
|
+
await this.loadAll();
|
|
1828
|
+
this.connectSSE();
|
|
1829
|
+
this.startConflictChecks();
|
|
1830
|
+
this.checkForUpdates();
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1819
1833
|
async init() {
|
|
1820
1834
|
// Restore sidebar width & collapse state from localStorage
|
|
1821
1835
|
this.restoreSidebarState();
|
|
1822
1836
|
|
|
1837
|
+
// Auto-login via URL ?token=xxx parameter (one-time startup token)
|
|
1838
|
+
const params = new URLSearchParams(window.location.search);
|
|
1839
|
+
const urlToken = params.get('token');
|
|
1840
|
+
if (urlToken) {
|
|
1841
|
+
// Always strip token from URL to avoid leaking in browser history/referrer
|
|
1842
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
1843
|
+
try {
|
|
1844
|
+
await this.tokenLogin(urlToken);
|
|
1845
|
+
return; // tokenLogin() handles showApp/loadAll/connectSSE
|
|
1846
|
+
} catch {
|
|
1847
|
+
// Fall through to normal login form
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1823
1851
|
if (this.state.token) {
|
|
1824
1852
|
const valid = await this.checkAuth();
|
|
1825
1853
|
if (valid) {
|
|
1826
|
-
this.
|
|
1827
|
-
this.initDragAndDrop();
|
|
1828
|
-
this.initTerminalResize();
|
|
1829
|
-
this.initTerminalGroups();
|
|
1830
|
-
// Initialize mobile swipe gestures for pane switching
|
|
1831
|
-
this.initTerminalPaneSwipe();
|
|
1832
|
-
this.initNotesEditor();
|
|
1833
|
-
this.initAIInsights();
|
|
1834
|
-
await this.loadAll();
|
|
1835
|
-
this.connectSSE();
|
|
1836
|
-
this.startConflictChecks();
|
|
1837
|
-
this.checkForUpdates();
|
|
1854
|
+
await this._initializeApp();
|
|
1838
1855
|
} else {
|
|
1839
1856
|
this.state.token = null;
|
|
1840
1857
|
localStorage.removeItem('cwm_token');
|
|
@@ -1911,18 +1928,7 @@ class CWMApp {
|
|
|
1911
1928
|
if (data.success && data.token) {
|
|
1912
1929
|
this.state.token = data.token;
|
|
1913
1930
|
localStorage.setItem('cwm_token', data.token);
|
|
1914
|
-
this.
|
|
1915
|
-
this.initDragAndDrop();
|
|
1916
|
-
this.initTerminalResize();
|
|
1917
|
-
this.initTerminalGroups();
|
|
1918
|
-
// Initialize mobile swipe gestures for pane switching
|
|
1919
|
-
this.initTerminalPaneSwipe();
|
|
1920
|
-
this.initNotesEditor();
|
|
1921
|
-
this.initAIInsights();
|
|
1922
|
-
await this.loadAll();
|
|
1923
|
-
this.connectSSE();
|
|
1924
|
-
this.startConflictChecks();
|
|
1925
|
-
this.checkForUpdates();
|
|
1931
|
+
await this._initializeApp();
|
|
1926
1932
|
} else {
|
|
1927
1933
|
this.els.loginError.textContent = 'Invalid password. Please try again.';
|
|
1928
1934
|
}
|
|
@@ -1934,6 +1940,17 @@ class CWMApp {
|
|
|
1934
1940
|
}
|
|
1935
1941
|
}
|
|
1936
1942
|
|
|
1943
|
+
async tokenLogin(startupToken) {
|
|
1944
|
+
const data = await this.api('POST', '/api/auth/token-login', { token: startupToken });
|
|
1945
|
+
if (data.success && data.token) {
|
|
1946
|
+
this.state.token = data.token;
|
|
1947
|
+
localStorage.setItem('cwm_token', data.token);
|
|
1948
|
+
await this._initializeApp();
|
|
1949
|
+
} else {
|
|
1950
|
+
throw new Error(data.error || 'Token login failed');
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1937
1954
|
async logout() {
|
|
1938
1955
|
try {
|
|
1939
1956
|
await this.api('POST', '/api/auth/logout');
|
|
@@ -2787,9 +2804,11 @@ class CWMApp {
|
|
|
2787
2804
|
const currentModel = session.model || null;
|
|
2788
2805
|
|
|
2789
2806
|
const modelOptions = [
|
|
2790
|
-
{ id: '
|
|
2791
|
-
{ id: '
|
|
2792
|
-
{ id: '
|
|
2807
|
+
{ id: 'opus', label: 'Opus' },
|
|
2808
|
+
{ id: 'sonnet', label: 'Sonnet' },
|
|
2809
|
+
{ id: 'haiku', label: 'Haiku' },
|
|
2810
|
+
{ id: 'sonnet[1m]', label: 'Sonnet 1M' },
|
|
2811
|
+
{ id: 'opusplan', label: 'OpusPlan' },
|
|
2793
2812
|
];
|
|
2794
2813
|
|
|
2795
2814
|
const items = [];
|
|
@@ -3569,8 +3588,8 @@ class CWMApp {
|
|
|
3569
3588
|
{ key: 'enableTd', label: 'td Task Management', description: 'Show td issue tracking integration (github.com/marcus/td). When disabled, hides all td UI including the docs panel section and sidebar toggle.', category: 'Advanced' },
|
|
3570
3589
|
{ key: 'tdBinary', label: 'td Binary Path', description: 'Optional. td is an alternative task management system (github.com/marcus/td) — Myrlin works fine without it. If installed, set the absolute path to the binary here, or leave blank to use the TD_BINARY environment variable or "td" from PATH. Example: /home/user/go/bin/td', category: 'Advanced', type: 'server-text', placeholder: 'e.g. /home/user/go/bin/td', apiEndpoint: '/api/td/binary', apiField: 'binary' },
|
|
3571
3590
|
{ key: 'maxConcurrentTasks', label: 'Max Concurrent Tasks', description: 'Maximum number of worktree tasks that can run simultaneously (1-8)', category: 'Advanced', type: 'number', min: 1, max: 8 },
|
|
3572
|
-
{ key: 'defaultModelPlanning', label: 'Default Model (Planning)', description: 'Auto-assign when tasks enter Planning. Haiku is fast/cheap for exploration. Only applies to tasks without a model set.', category: 'Advanced', type: 'select', options: [{ value: '', label: 'None' }, { value: '
|
|
3573
|
-
{ key: 'defaultModelRunning', label: 'Default Model (Running)', description: 'Auto-assign when tasks enter Running. Sonnet balances speed and quality for implementation. Only applies to tasks without a model set.', category: 'Advanced', type: 'select', options: [{ value: '', label: 'None' }, { value: '
|
|
3591
|
+
{ key: 'defaultModelPlanning', label: 'Default Model (Planning)', description: 'Auto-assign when tasks enter Planning. Haiku is fast/cheap for exploration. Only applies to tasks without a model set.', category: 'Advanced', type: 'select', options: [{ value: '', label: 'None' }, { value: 'haiku', label: 'Haiku (fast, cheap)' }, { value: 'sonnet', label: 'Sonnet (balanced)' }, { value: 'opus', label: 'Opus (thorough)' }, { value: 'sonnet[1m]', label: 'Sonnet 1M' }, { value: 'opusplan', label: 'OpusPlan' }] },
|
|
3592
|
+
{ key: 'defaultModelRunning', label: 'Default Model (Running)', description: 'Auto-assign when tasks enter Running. Sonnet balances speed and quality for implementation. Only applies to tasks without a model set.', category: 'Advanced', type: 'select', options: [{ value: '', label: 'None' }, { value: 'haiku', label: 'Haiku (fast, cheap)' }, { value: 'sonnet', label: 'Sonnet (balanced)' }, { value: 'opus', label: 'Opus (thorough)' }, { value: 'sonnet[1m]', label: 'Sonnet 1M' }, { value: 'opusplan', label: 'OpusPlan' }] },
|
|
3574
3593
|
{ key: 'anthropicApiKey', label: 'Anthropic API Key', description: 'Required for AI-powered session finder. Uses Claude Haiku for fast, low-cost semantic search across your projects and sessions. Get a key at console.anthropic.com.', category: 'AI', type: 'server-text', placeholder: 'sk-ant-...', apiEndpoint: '/api/keys/anthropic', apiField: 'key' },
|
|
3575
3594
|
{ key: 'cfNamedTunnel', label: 'Cloudflare Named Tunnel', description: 'Expose Myrlin on the internet via your own domain. Go to one.dash.cloudflare.com → Networks → Tunnels → Create a tunnel, then copy the token from the install command (the long eyJ… string).', category: 'Remote Access', type: 'tunnel' },
|
|
3576
3595
|
];
|
|
@@ -5226,10 +5245,12 @@ class CWMApp {
|
|
|
5226
5245
|
|
|
5227
5246
|
// Model selection submenu
|
|
5228
5247
|
const modelOptions = [
|
|
5229
|
-
{ id: '',
|
|
5230
|
-
{ id: '
|
|
5231
|
-
{ id: '
|
|
5232
|
-
{ id: '
|
|
5248
|
+
{ id: '', label: 'Default' },
|
|
5249
|
+
{ id: 'opus', label: 'Opus' },
|
|
5250
|
+
{ id: 'sonnet', label: 'Sonnet' },
|
|
5251
|
+
{ id: 'haiku', label: 'Haiku' },
|
|
5252
|
+
{ id: 'sonnet[1m]', label: 'Sonnet 1M' },
|
|
5253
|
+
{ id: 'opusplan', label: 'OpusPlan' },
|
|
5233
5254
|
];
|
|
5234
5255
|
const currentTaskModel = task.model || '';
|
|
5235
5256
|
const currentModelLabel = currentTaskModel ? (modelOptions.find(m => m.id === currentTaskModel)?.label || 'Custom') : 'Default';
|
|
@@ -13733,10 +13754,12 @@ class CWMApp {
|
|
|
13733
13754
|
|
|
13734
13755
|
// Add model selector
|
|
13735
13756
|
fields.push({ key: 'model', label: 'Model', type: 'select', options: [
|
|
13736
|
-
{ value: '',
|
|
13737
|
-
{ value: '
|
|
13738
|
-
{ value: '
|
|
13739
|
-
{ value: '
|
|
13757
|
+
{ value: '', label: 'Default' },
|
|
13758
|
+
{ value: 'opus', label: 'Opus' },
|
|
13759
|
+
{ value: 'sonnet', label: 'Sonnet' },
|
|
13760
|
+
{ value: 'haiku', label: 'Haiku' },
|
|
13761
|
+
{ value: 'sonnet[1m]', label: 'Sonnet 1M' },
|
|
13762
|
+
{ value: 'opusplan', label: 'OpusPlan' },
|
|
13740
13763
|
]});
|
|
13741
13764
|
|
|
13742
13765
|
const result = await this.showPromptModal({
|
|
@@ -1467,9 +1467,11 @@
|
|
|
1467
1467
|
<label class="launcher-form-label" for="launcher-model">Model</label>
|
|
1468
1468
|
<select id="launcher-model" class="launcher-form-input">
|
|
1469
1469
|
<option value="">Default</option>
|
|
1470
|
-
<option value="
|
|
1471
|
-
<option value="
|
|
1472
|
-
<option value="
|
|
1470
|
+
<option value="opus">Opus</option>
|
|
1471
|
+
<option value="sonnet">Sonnet</option>
|
|
1472
|
+
<option value="haiku">Haiku</option>
|
|
1473
|
+
<option value="sonnet[1m]">Sonnet 1M (long context)</option>
|
|
1474
|
+
<option value="opusplan">OpusPlan (plan with Opus, run with Sonnet)</option>
|
|
1473
1475
|
</select>
|
|
1474
1476
|
</div>
|
|
1475
1477
|
</div>
|
|
@@ -252,6 +252,11 @@ class TerminalPane {
|
|
|
252
252
|
this._needsInput = false; // Whether a question was detected that wasn't auto-answered
|
|
253
253
|
this._needsInputTimer = null; // Timer to clear needsInput after new output
|
|
254
254
|
this._autoTrustEnabled = false; // Set by app layer for worktree task terminals
|
|
255
|
+
// Write batching buffers — must be initialized here so _status() calls in
|
|
256
|
+
// mount() (before connectWs runs) don't produce "undefined" prefixes.
|
|
257
|
+
this._writeBuf = '';
|
|
258
|
+
this._activitySample = '';
|
|
259
|
+
this._writeRaf = null;
|
|
255
260
|
}
|
|
256
261
|
|
|
257
262
|
_log(msg) {
|
package/src/web/server.js
CHANGED
|
@@ -2846,10 +2846,21 @@ app.get('/api/cost/dashboard', requireAuth, (req, res) => {
|
|
|
2846
2846
|
}
|
|
2847
2847
|
}
|
|
2848
2848
|
|
|
2849
|
-
//
|
|
2850
|
-
|
|
2849
|
+
// Apportion session cost to the period using per-message cost and
|
|
2850
|
+
// message timestamps, not the full session cost. This ensures "Last 24h"
|
|
2851
|
+
// only counts cost from messages sent in the last 24 hours, not the
|
|
2852
|
+
// entire lifetime of any session that was recently active.
|
|
2853
|
+
if (!cutoffDate) {
|
|
2851
2854
|
periodCost += sessionCost;
|
|
2852
|
-
} else if (
|
|
2855
|
+
} else if (samples && samples.length > 0 && costData.messageCount > 0) {
|
|
2856
|
+
const perMsgCost = sessionCost / costData.messageCount;
|
|
2857
|
+
let periodMessages = 0;
|
|
2858
|
+
for (const sample of samples) {
|
|
2859
|
+
if (sample.ts && sample.ts >= cutoffDate) periodMessages++;
|
|
2860
|
+
}
|
|
2861
|
+
periodCost += perMsgCost * periodMessages;
|
|
2862
|
+
} else if (costData.lastMessage && costData.lastMessage >= cutoffDate) {
|
|
2863
|
+
// Fallback: no timestamp samples available, use full cost
|
|
2853
2864
|
periodCost += sessionCost;
|
|
2854
2865
|
}
|
|
2855
2866
|
|