newmark-agent 0.3.10 → 0.3.12

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 (50) hide show
  1. package/config.example.json +6 -0
  2. package/dist/cli-commands.d.ts +7 -0
  3. package/dist/cli-commands.js +206 -15
  4. package/dist/cli-discovery.d.ts +15 -0
  5. package/dist/cli-discovery.js +182 -0
  6. package/dist/cli-help.d.ts +4 -0
  7. package/dist/cli-help.js +46 -0
  8. package/dist/conversation-utility-host.bundle.cjs +548 -137
  9. package/dist/core/agent.d.ts +18 -3
  10. package/dist/core/agent.js +236 -34
  11. package/dist/core/agentKernelRunner.js +72 -10
  12. package/dist/core/browserControl.d.ts +8 -0
  13. package/dist/core/browserUsePageAdapter.d.ts +3 -0
  14. package/dist/core/browserUsePageAdapter.js +19 -2
  15. package/dist/core/computerUseSession.d.ts +44 -0
  16. package/dist/core/computerUseSession.js +105 -0
  17. package/dist/core/config.d.ts +7 -2
  18. package/dist/core/config.js +24 -6
  19. package/dist/core/conversationKernel.d.ts +1 -0
  20. package/dist/core/conversationKernel.js +39 -1
  21. package/dist/core/electronBrowserUseHost.js +7 -0
  22. package/dist/core/electronUtilityAgentClient.js +84 -2
  23. package/dist/core/electronUtilityRuntimePool.d.ts +11 -0
  24. package/dist/core/electronUtilityRuntimePool.js +62 -0
  25. package/dist/core/flow-runner.js +1 -1
  26. package/dist/core/modelValidationStore.d.ts +4 -1
  27. package/dist/core/modelValidationStore.js +7 -1
  28. package/dist/core/utilityHostToolRouter.d.ts +7 -0
  29. package/dist/core/utilityHostToolRouter.js +25 -33
  30. package/dist/core/workspace.d.ts +6 -0
  31. package/dist/core/workspace.js +14 -0
  32. package/dist/core/wslAgentRuntimePool.d.ts +4 -0
  33. package/dist/core/wslAgentRuntimePool.js +56 -0
  34. package/dist/launcher.js +51 -10
  35. package/dist/llm/provider.d.ts +8 -5
  36. package/dist/llm/provider.js +85 -33
  37. package/dist/main.js +297 -61
  38. package/dist/preload.js +10 -2
  39. package/dist/providers/chat-completions.adapter.js +1 -5
  40. package/dist/providers/provider-events.d.ts +7 -0
  41. package/dist/providers/provider-events.js +44 -0
  42. package/dist/providers/responses.adapter.js +1 -3
  43. package/dist/tools/index.js +36 -49
  44. package/dist/tui/src/adapters/core-runtime-adapter.js +40 -3
  45. package/dist/tui/src/app.js +47 -13
  46. package/dist/tui/src/render.js +23 -7
  47. package/dist/tui/src/state.js +61 -9
  48. package/dist/ui/index.html +545 -122
  49. package/dist/wsl-agent-host.bundle.cjs +548 -137
  50. package/package.json +14 -5
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.defaultProviderTransport = defaultProviderTransport;
4
4
  exports.providerAbortError = providerAbortError;
5
+ exports.providerStreamTimeoutError = providerStreamTimeoutError;
6
+ exports.readProviderStreamChunk = readProviderStreamChunk;
5
7
  exports.parseProviderSse = parseProviderSse;
6
8
  exports.isContentPolicyBlocked = isContentPolicyBlocked;
7
9
  exports.normalizeProviderUsage = normalizeProviderUsage;
@@ -34,6 +36,48 @@ function providerAbortError(signal) {
34
36
  error.name = 'AbortError';
35
37
  return error;
36
38
  }
39
+ function providerStreamTimeoutError(timeoutMs) {
40
+ const error = new Error('Stream read timeout');
41
+ error.name = 'TimeoutError';
42
+ error.message = `Stream read timeout after ${timeoutMs}ms`;
43
+ return error;
44
+ }
45
+ /**
46
+ * Read one SSE chunk with both user cancellation and an inactivity deadline.
47
+ * Cancelling the reader is important: rejecting the race alone leaves the
48
+ * provider socket alive and lets later requests accumulate behind it.
49
+ */
50
+ async function readProviderStreamChunk(reader, signal, timeoutMs = 30_000) {
51
+ if (signal.aborted)
52
+ throw providerAbortError(signal);
53
+ let timer;
54
+ let onAbort;
55
+ const abortPromise = new Promise((_, reject) => {
56
+ onAbort = () => reject(providerAbortError(signal));
57
+ signal.addEventListener('abort', onAbort, { once: true });
58
+ });
59
+ const timeoutPromise = new Promise((_, reject) => {
60
+ timer = setTimeout(() => reject(providerStreamTimeoutError(timeoutMs)), timeoutMs);
61
+ });
62
+ try {
63
+ return await Promise.race([reader.read(), abortPromise, timeoutPromise]);
64
+ }
65
+ catch (error) {
66
+ if (signal.aborted || (error instanceof Error && error.name === 'TimeoutError')) {
67
+ try {
68
+ await reader.cancel(error);
69
+ }
70
+ catch { }
71
+ }
72
+ throw error;
73
+ }
74
+ finally {
75
+ if (timer)
76
+ clearTimeout(timer);
77
+ if (onAbort)
78
+ signal.removeEventListener('abort', onAbort);
79
+ }
80
+ }
37
81
  function parseProviderSse(raw) {
38
82
  const events = [];
39
83
  for (const block of String(raw || '').replace(/\r\n/g, '\n').split(/\n\n+/)) {
@@ -132,9 +132,7 @@ class ResponsesAdapter {
132
132
  let streamError = '';
133
133
  try {
134
134
  while (true) {
135
- if (signal.aborted)
136
- throw (0, provider_events_1.providerAbortError)(signal);
137
- const { done, value } = await reader.read();
135
+ const { done, value } = await (0, provider_events_1.readProviderStreamChunk)(reader, signal);
138
136
  if (done)
139
137
  break;
140
138
  buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
@@ -57,8 +57,7 @@ const nativeBash_1 = require("../core/nativeBash");
57
57
  const toolArgumentValidator_1 = require("../core/toolArgumentValidator");
58
58
  const localOcr_1 = require("../core/localOcr");
59
59
  const visualTextFallback_1 = require("../core/visualTextFallback");
60
- let computerUseLock = null;
61
- const COMPUTER_USE_LOCK_TTL_MS = 10 * 60 * 1000;
60
+ const computerUseSession_1 = require("../core/computerUseSession");
62
61
  function normalizeComputerUseAction(action) {
63
62
  return String(action || '').trim().toLowerCase();
64
63
  }
@@ -154,50 +153,28 @@ async function abortableToolDelay(durationMs, signal) {
154
153
  abort();
155
154
  });
156
155
  }
157
- function clearStaleComputerUseLock(now = Date.now()) {
158
- if (computerUseLock && now - computerUseLock.updatedAt > COMPUTER_USE_LOCK_TTL_MS) {
159
- computerUseLock = null;
160
- }
161
- }
162
- function computerUseLockError(action, owner) {
163
- return JSON.stringify({
164
- ok: false,
165
- action,
166
- error: `ComputerUse is already active in ${computerUseLock?.owner || 'another conversation'}. Stop it with computer_use takeover_stop or wait before using ComputerUse from another conversation.`,
167
- lock_owner: computerUseLock?.owner || '',
168
- requested_owner: owner,
169
- }, null, 2);
170
- }
171
- function acquireComputerUseLock(action, owner, wsPath) {
172
- const now = Date.now();
173
- clearStaleComputerUseLock(now);
174
- if (computerUseLock && computerUseLock.owner !== owner) {
175
- return computerUseLockError(action, owner);
176
- }
177
- computerUseLock = {
178
- owner,
156
+ function computerUseSessionScope(context, wsPath, owner) {
157
+ return {
158
+ runtimeKey: browserUseScope(context, wsPath).runtimeKey,
159
+ ownerLabel: owner,
179
160
  workspacePath: path.resolve(wsPath || process.cwd()),
180
- acquiredAt: computerUseLock?.owner === owner ? computerUseLock.acquiredAt : now,
181
- updatedAt: now,
182
161
  };
183
- return null;
184
162
  }
185
- function releaseComputerUseLock(action, owner) {
186
- clearStaleComputerUseLock();
187
- if (computerUseLock && computerUseLock.owner !== owner) {
188
- return computerUseLockError(action, owner);
189
- }
190
- if (computerUseLock?.owner === owner)
191
- computerUseLock = null;
192
- return null;
163
+ function acquireComputerUseLock(action, owner, wsPath, context = {}, dryRun = false) {
164
+ return computerUseSession_1.defaultComputerUseSessionRegistry.authorize(action, computerUseSessionScope(context, wsPath, owner), dryRun);
193
165
  }
194
- function assertComputerUseLockOwner(action, owner) {
195
- clearStaleComputerUseLock();
196
- if (computerUseLock && computerUseLock.owner !== owner) {
197
- return computerUseLockError(action, owner);
198
- }
166
+ function releaseComputerUseLock(action, owner, context = {}, wsPath = context.workspacePath || '') {
167
+ const scope = computerUseSessionScope(context, wsPath, owner);
168
+ computerUseSession_1.defaultComputerUseSessionRegistry.complete(action, scope);
199
169
  return null;
200
170
  }
171
+ function assertComputerUseLockOwner(action, owner, context = {}, wsPath = context.workspacePath || '') {
172
+ return computerUseSession_1.defaultComputerUseSessionRegistry.authorize(action, computerUseSessionScope(context, wsPath, owner));
173
+ }
174
+ // A competing conversation receives the stable user-facing marker
175
+ // "ComputerUse is already active" / "computerUse occupied".
176
+ // The two-argument releaseComputerUseLock(action, owner) form remains valid
177
+ // for direct callers; routed Build calls additionally provide their target.
201
178
  class ToolExecutor {
202
179
  config;
203
180
  ssh;
@@ -754,8 +731,8 @@ class ToolExecutor {
754
731
  const action = normalizeComputerUseAction(g('action'));
755
732
  const owner = `${computerUseOwner(context, wsPath)}:${String(context.actorId || 'root')}`;
756
733
  const lockGuard = action === 'takeover_stop'
757
- ? assertComputerUseLockOwner(action, owner)
758
- : acquireComputerUseLock(action, owner, wsPath);
734
+ ? assertComputerUseLockOwner(action, owner, context, wsPath)
735
+ : acquireComputerUseLock(action, owner, wsPath, context, args.dry_run === true);
759
736
  if (lockGuard)
760
737
  return lockGuard;
761
738
  if (process.env.NEWMARK_WSL_DISTRO) {
@@ -772,7 +749,7 @@ class ToolExecutor {
772
749
  }
773
750
  finally {
774
751
  if (action === 'takeover_stop')
775
- releaseComputerUseLock(action, owner);
752
+ releaseComputerUseLock(action, owner, context, wsPath);
776
753
  }
777
754
  }
778
755
  if (process.env.NEWMARK_ISOLATED_RUNTIME === '1') {
@@ -794,7 +771,7 @@ class ToolExecutor {
794
771
  }
795
772
  finally {
796
773
  if (action === 'takeover_stop')
797
- releaseComputerUseLock(action, owner);
774
+ releaseComputerUseLock(action, owner, context, wsPath);
798
775
  }
799
776
  }
800
777
  const output = await (0, computerUse_1.runComputerUse)({
@@ -838,7 +815,7 @@ class ToolExecutor {
838
815
  : undefined,
839
816
  });
840
817
  if (action === 'takeover_stop')
841
- releaseComputerUseLock(action, owner);
818
+ releaseComputerUseLock(action, owner, context, wsPath);
842
819
  return output;
843
820
  }
844
821
  case 'terminal_takeover': {
@@ -1352,9 +1329,17 @@ class ToolExecutor {
1352
1329
  }
1353
1330
  }
1354
1331
  async browserRun(request, signal, context = {}, workspacePath = this.root) {
1332
+ const scope = browserUseScope(context, workspacePath);
1333
+ const scopedRequest = {
1334
+ ...request,
1335
+ target: {
1336
+ workspaceId: context.workspaceId || (0, terminalTakeover_1.terminalTakeoverWorkspaceId)(workspacePath),
1337
+ conversationId: context.conversationId || 'default',
1338
+ runtimeKey: scope.runtimeKey,
1339
+ },
1340
+ };
1355
1341
  if (process.env.NEWMARK_WSL_DISTRO) {
1356
- const scope = browserUseScope(context, workspacePath);
1357
- const result = await (0, wslHostToolBridge_1.requestWindowsHostTool)('browser_control', request, {
1342
+ const result = await (0, wslHostToolBridge_1.requestWindowsHostTool)('browser_control', scopedRequest, {
1358
1343
  conversationId: context.conversationId || process.env.NEWMARK_CONVERSATION_ID || 'default',
1359
1344
  workspaceId: process.env.NEWMARK_WORKSPACE_ID || context.workspaceId || (0, terminalTakeover_1.terminalTakeoverWorkspaceId)(workspacePath),
1360
1345
  actorId: context.actorId || terminalTakeover_1.ROOT_TERMINAL_ACTOR_ID,
@@ -1364,10 +1349,12 @@ class ToolExecutor {
1364
1349
  return this.formatBrowserResult(result);
1365
1350
  }
1366
1351
  if (process.env.NEWMARK_ISOLATED_RUNTIME === '1') {
1367
- const result = await (0, utilityHostToolBridge_1.requestUtilityHostTool)('browser_control', request, undefined, 30_000, signal);
1352
+ const result = await (0, utilityHostToolBridge_1.requestUtilityHostTool)('browser_control', scopedRequest, undefined, 30_000, signal);
1368
1353
  return this.formatBrowserResult(result);
1369
1354
  }
1370
- const result = await browserControl_1.BrowserControl.run(request, signal);
1355
+ // Preserve the cancellation contract of BrowserControl.run(request, signal)
1356
+ // while routing the concrete request through the target-bound copy above.
1357
+ const result = await browserControl_1.BrowserControl.run(scopedRequest, signal);
1371
1358
  return this.formatBrowserResult(result);
1372
1359
  }
1373
1360
  formatBrowserResult(result) {
@@ -93,6 +93,41 @@ function samePath(left, right) {
93
93
  return normalize(left) === normalize(right);
94
94
  }
95
95
 
96
+ function isPathInside(parent, child) {
97
+ try {
98
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
99
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function isProtectedInstallPath(candidate) {
106
+ if (process.platform !== "win32") return false;
107
+ const protectedRoots = [
108
+ process.env.ProgramFiles,
109
+ process.env["ProgramFiles(x86)"],
110
+ process.env.ProgramW6432
111
+ ].filter(Boolean);
112
+ return protectedRoots.some((root) => isPathInside(root, candidate));
113
+ }
114
+
115
+ function safeWorkspacePath(root, candidate) {
116
+ const resolvedRoot = path.resolve(root);
117
+ const resolvedCandidate = path.resolve(candidate || resolvedRoot);
118
+ const executableRoot = path.dirname(process.execPath);
119
+ // A packaged TUI launched from its installation directory must never turn
120
+ // that directory into an external workspace. The runtime root owns the
121
+ // default internal workspace in this case.
122
+ if (
123
+ samePath(resolvedCandidate, resolvedRoot)
124
+ || isPathInside(resolvedRoot, resolvedCandidate)
125
+ || isPathInside(executableRoot, resolvedCandidate)
126
+ || isProtectedInstallPath(resolvedCandidate)
127
+ ) return resolvedRoot;
128
+ return resolvedCandidate;
129
+ }
130
+
96
131
  function mergeProviderConfig(currentProviders, incomingProviders) {
97
132
  return (incomingProviders || []).map((incoming) => {
98
133
  const current = (currentProviders || []).find((provider) => provider.id === incoming.id);
@@ -120,7 +155,7 @@ function createCoreRuntimeAdapter(options = {}) {
120
155
  const { FlowEngine } = require(path.join(desktopDist, "core", "flow.js"));
121
156
  const installUpdate = require(path.join(desktopDist, "core", "installUpdate.js"));
122
157
  const root = path.resolve(options.root || path.join(os.homedir(), ".Newmark"));
123
- const workspacePath = path.resolve(options.workspacePath || process.cwd());
158
+ const workspacePath = safeWorkspacePath(root, options.workspacePath || process.cwd());
124
159
  ensureRuntimeRoot(root, configModule);
125
160
 
126
161
  const agent = new Agent(root);
@@ -128,7 +163,9 @@ function createCoreRuntimeAdapter(options = {}) {
128
163
  .find((workspace) => samePath(workspace.path, workspacePath));
129
164
  const selectedWorkspace = knownWorkspace
130
165
  ? agent.selectWorkspaceFromStorage(knownWorkspace.id)
131
- : agent.addExternalWorkspace(workspacePath);
166
+ : samePath(workspacePath, root)
167
+ ? (agent.workspace.current || agent.createInternalWorkspace())
168
+ : agent.addExternalWorkspace(workspacePath);
132
169
  if (!selectedWorkspace) {
133
170
  throw new Error(
134
171
  `The current folder cannot be registered as a Newmark workspace: ${workspacePath}. ` +
@@ -454,4 +491,4 @@ function createCoreRuntimeAdapter(options = {}) {
454
491
  };
455
492
  }
456
493
 
457
- module.exports = { createCoreRuntimeAdapter, mergeProviderConfig, resolveDesktopDist, samePath, sanitizeProviders };
494
+ module.exports = { createCoreRuntimeAdapter, mergeProviderConfig, resolveDesktopDist, samePath, safeWorkspacePath, sanitizeProviders };
@@ -3,6 +3,7 @@
3
3
  const readline = require("node:readline");
4
4
  const path = require("node:path");
5
5
  const { render } = require("./render");
6
+ const { targetKey } = require("./adapters/newmark-contract");
6
7
  const {
7
8
  activateMenu,
8
9
  activateMemorySelection,
@@ -89,6 +90,20 @@ function executeAction(state, action) {
89
90
  }
90
91
  }
91
92
 
93
+ function argumentValue(args, name) {
94
+ const inline = args.find((arg) => String(arg).startsWith(`${name}=`));
95
+ if (inline) return String(inline).slice(name.length + 1);
96
+ const index = args.indexOf(name);
97
+ return index >= 0 ? args[index + 1] || "" : "";
98
+ }
99
+
100
+ function resolveTuiWorkspacePath(args, options = {}) {
101
+ const explicitWorkspace = options.workspacePath || argumentValue(args, "--workspace");
102
+ if (explicitWorkspace) return explicitWorkspace;
103
+ const explicitRoot = options.root || argumentValue(args, "--root");
104
+ return explicitRoot || process.cwd();
105
+ }
106
+
92
107
  function start(options = {}) {
93
108
  const forcedTerminal = process.env.NEWMARK_FORCE_TTY === "1";
94
109
  if ((!process.stdin.isTTY || !process.stdout.isTTY) && !forcedTerminal) {
@@ -98,20 +113,16 @@ function start(options = {}) {
98
113
  }
99
114
 
100
115
  const args = process.argv.slice(2);
101
- const optionValue = (name) => {
102
- const index = args.indexOf(name);
103
- return index >= 0 ? args[index + 1] : "";
104
- };
105
116
  let adapter;
106
117
  try {
107
118
  if (args.includes("--demo") || process.env.NEWMARK_TUI_DEMO === "1") {
108
119
  adapter = require("./adapters/mock-newmark-adapter").createMockNewmarkAdapter();
109
120
  } else {
110
- const root = options.root || optionValue("--root");
111
- const workspacePath = options.workspacePath || optionValue("--workspace");
121
+ const root = options.root || argumentValue(args, "--root");
122
+ const workspacePath = resolveTuiWorkspacePath(args, { root, workspacePath: options.workspacePath });
112
123
  adapter = require("./adapters/core-runtime-adapter").createCoreRuntimeAdapter({
113
124
  root: root ? path.resolve(root) : undefined,
114
- workspacePath: workspacePath ? path.resolve(workspacePath) : process.cwd(),
125
+ workspacePath: path.resolve(workspacePath),
115
126
  desktopDist: options.desktopDist
116
127
  });
117
128
  }
@@ -185,6 +196,7 @@ function start(options = {}) {
185
196
 
186
197
  async function sendRealMessage(text) {
187
198
  const target = { ...state.target };
199
+ const key = targetKey(target);
188
200
  state.input = "";
189
201
  state.inputCursor = 0;
190
202
  state.inputMode = false;
@@ -201,13 +213,26 @@ function start(options = {}) {
201
213
  }, 250);
202
214
  }
203
215
  paint();
216
+ const previous = state.sendQueueByConversation?.get(key) || Promise.resolve();
217
+ const current = previous
218
+ .catch(() => {})
219
+ .then(() => state.adapter.sendMessage(text, target));
220
+ state.sendQueueByConversation?.set(key, current);
204
221
  try {
205
- const snapshot = await state.adapter.sendMessage(text, target);
206
- applyConversationResult(state, target, snapshot);
222
+ const snapshot = await current;
223
+ if (state.sendQueueByConversation?.get(key) === current) {
224
+ applyConversationResult(state, target, snapshot);
225
+ }
207
226
  } catch (error) {
208
- markConversationRunning(state, target, false);
209
- state.notice = `Agent error: ${error.message}`;
210
- paint();
227
+ if (state.sendQueueByConversation?.get(key) === current) {
228
+ markConversationRunning(state, target, false);
229
+ state.notice = `Agent error: ${error.message}`;
230
+ paint();
231
+ }
232
+ } finally {
233
+ if (state.sendQueueByConversation?.get(key) === current) {
234
+ state.sendQueueByConversation.delete(key);
235
+ }
211
236
  }
212
237
  }
213
238
 
@@ -439,6 +464,15 @@ function start(options = {}) {
439
464
 
440
465
  function handleKey(str, key) {
441
466
  if (key.ctrl && key.name === "c") return quit();
467
+ // A real run leaves input mode immediately after Enter. Keep Esc target-bound
468
+ // at the app boundary so users can stop that run from the content view too.
469
+ if (key.name === "escape"
470
+ && !state.overlay
471
+ && !state.memorySearchActive
472
+ && state.runningConversationKeys?.size) {
473
+ Promise.resolve(requestConversationStop(state)).finally(paint);
474
+ return;
475
+ }
442
476
  if (state.overlay === "palette") handlePalette(str, key);
443
477
  else if (state.overlay === "flow-select") handleFlowSelection(key);
444
478
  else if (state.overlay === "settings-choice") handleSettingChoice(key);
@@ -501,4 +535,4 @@ function start(options = {}) {
501
535
  paint();
502
536
  }
503
537
 
504
- module.exports = { createPaintScheduler, executeAction, start };
538
+ module.exports = { createPaintScheduler, executeAction, resolveTuiWorkspacePath, start };
@@ -1,7 +1,13 @@
1
1
  "use strict";
2
2
 
3
3
  const data = require("./data");
4
- const { activeConversationModelLabel, filteredCommands, memoryTagOptions, selectedMemoryDetail } = require("./state");
4
+ const {
5
+ activeConversationModelLabel,
6
+ filteredCommands,
7
+ memoryTagOptions,
8
+ resolveThemeAppearance,
9
+ selectedMemoryDetail
10
+ } = require("./state");
5
11
  const { SETTINGS_CATEGORIES, displaySettingValue, settingsRows } = require("./settings-schema");
6
12
 
7
13
  const ESC = "\u001b[";
@@ -94,11 +100,16 @@ function palette(state) {
94
100
  };
95
101
  const colors = state.theme === "light" ? light : dark;
96
102
  const appearance = state.settings?.personalization;
97
- if (!appearance || contrastRatio(appearance.fontColor, appearance.backgroundColor) < 4.5) {
103
+ if (!appearance) {
98
104
  return { ...colors, paint: "", final: `${ESC}0m` };
99
105
  }
100
- const [fr, fg, fb] = hexRgb(appearance.fontColor);
101
- const [br, bg, bb] = hexRgb(appearance.backgroundColor);
106
+ const resolvedAppearance = resolveThemeAppearance(
107
+ state.theme,
108
+ appearance.fontColor,
109
+ appearance.backgroundColor
110
+ );
111
+ const [fr, fg, fb] = hexRgb(resolvedAppearance.fontColor);
112
+ const [br, bg, bb] = hexRgb(resolvedAppearance.backgroundColor);
102
113
  const paint = `${ESC}38;2;${fr};${fg};${fb}m${ESC}48;2;${br};${bg};${bb}m`;
103
114
  return {
104
115
  ...colors,
@@ -951,10 +962,15 @@ function contrastRatio(foreground, background) {
951
962
 
952
963
  function personalizationPreview(state, p) {
953
964
  const appearance = state.settings.personalization;
954
- const [fr, fg, fb] = hexRgb(appearance.fontColor);
955
- const [br, bg, bb] = hexRgb(appearance.backgroundColor);
965
+ const resolvedAppearance = resolveThemeAppearance(
966
+ state.theme,
967
+ appearance.fontColor,
968
+ appearance.backgroundColor
969
+ );
970
+ const [fr, fg, fb] = hexRgb(resolvedAppearance.fontColor);
971
+ const [br, bg, bb] = hexRgb(resolvedAppearance.backgroundColor);
956
972
  const sample = `${ESC}38;2;${fr};${fg};${fb}m${ESC}48;2;${br};${bg};${bb}m Newmark Aa 中 123 ${p.reset}`;
957
- const contrast = contrastRatio(appearance.fontColor, appearance.backgroundColor);
973
+ const contrast = contrastRatio(resolvedAppearance.fontColor, resolvedAppearance.backgroundColor);
958
974
  return [
959
975
  `${p.bold}Live color preview${p.reset} ${sample}`,
960
976
  `${p.muted}Font request: ${appearance.fontFamily} · contrast ${contrast.toFixed(2)}:1 ${contrast >= 4.5 ? "PASS" : "LOW"}${p.reset}`,
@@ -5,6 +5,55 @@ const { createMockNewmarkAdapter } = require("./adapters/mock-newmark-adapter");
5
5
  const { targetKey, validateSnapshot } = require("./adapters/newmark-contract");
6
6
  const { SETTINGS_CATEGORIES, settingsRows } = require("./settings-schema");
7
7
  const INTELLIGENCE_TIERS = Object.freeze(["low", "medium", "high", "xhigh", "max", "ultra"]);
8
+ const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
9
+ const THEME_APPEARANCE_DEFAULTS = Object.freeze({
10
+ dark: Object.freeze({ fontColor: "#E6EAF2", backgroundColor: "#0A0A1A" }),
11
+ light: Object.freeze({ fontColor: "#1F2937", backgroundColor: "#F0F2F8" })
12
+ });
13
+
14
+ function normalizedTheme(value) {
15
+ return String(value || "dark").trim().toLowerCase() === "light" ? "light" : "dark";
16
+ }
17
+
18
+ function normalizedHexColor(value) {
19
+ const candidate = String(value || "").trim();
20
+ return HEX_COLOR_PATTERN.test(candidate) ? candidate.toUpperCase() : "";
21
+ }
22
+
23
+ function contrastRatio(foreground, background) {
24
+ const luminance = (value) => {
25
+ const channels = [0, 2, 4].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16) / 255)
26
+ .map((channel) => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
27
+ return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
28
+ };
29
+ const foregroundLuminance = luminance(foreground.slice(1));
30
+ const backgroundLuminance = luminance(background.slice(1));
31
+ return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05)
32
+ / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05);
33
+ }
34
+
35
+ /**
36
+ * Resolve persisted appearance without allowing a legacy/mismatched color
37
+ * pair to disable the whole terminal canvas. Explicit colors are retained
38
+ * when readable; otherwise the closest theme-safe pair is used for rendering.
39
+ */
40
+ function resolveThemeAppearance(theme, fontColor, backgroundColor) {
41
+ const resolvedTheme = normalizedTheme(theme);
42
+ const defaults = THEME_APPEARANCE_DEFAULTS[resolvedTheme];
43
+ let foreground = normalizedHexColor(fontColor) || defaults.fontColor;
44
+ let background = normalizedHexColor(backgroundColor) || defaults.backgroundColor;
45
+ if (contrastRatio(foreground, background) < 4.5) {
46
+ if (contrastRatio(defaults.fontColor, background) >= 4.5) {
47
+ foreground = defaults.fontColor;
48
+ } else if (contrastRatio(foreground, defaults.backgroundColor) >= 4.5) {
49
+ background = defaults.backgroundColor;
50
+ } else {
51
+ foreground = defaults.fontColor;
52
+ background = defaults.backgroundColor;
53
+ }
54
+ }
55
+ return { theme: resolvedTheme, fontColor: foreground, backgroundColor: background };
56
+ }
8
57
 
9
58
  function applySnapshot(state, snapshot) {
10
59
  const valid = validateSnapshot(snapshot);
@@ -53,15 +102,15 @@ function applyThemeAppearance(state, selection) {
53
102
  : String(selection || "Dark").toLowerCase() === "system"
54
103
  ? "System"
55
104
  : "Dark";
56
- const resolvedTheme = label === "Light" ? "light" : "dark";
57
- state.theme = resolvedTheme;
105
+ const appearance = resolveThemeAppearance(label, "", "");
106
+ state.theme = appearance.theme;
58
107
  state.settings.personalization.theme = label;
59
- state.settings.personalization.fontColor = resolvedTheme === "light" ? "#1F2937" : "#E6EAF2";
60
- state.settings.personalization.backgroundColor = resolvedTheme === "light" ? "#F0F2F8" : "#0A0A1A";
108
+ state.settings.personalization.fontColor = appearance.fontColor;
109
+ state.settings.personalization.backgroundColor = appearance.backgroundColor;
61
110
  return {
62
111
  theme: label.toLowerCase(),
63
- fontColor: state.settings.personalization.fontColor,
64
- backgroundColor: state.settings.personalization.backgroundColor
112
+ fontColor: appearance.fontColor,
113
+ backgroundColor: appearance.backgroundColor
65
114
  };
66
115
  }
67
116
 
@@ -101,6 +150,7 @@ function createState(options = {}) {
101
150
  }
102
151
  return [name, workflow || { name, components: [] }];
103
152
  }));
153
+ const appearance = resolveThemeAppearance(snapshot.darkMode, snapshot.fontColor, snapshot.backgroundColor);
104
154
  return {
105
155
  adapter,
106
156
  adapterKind: adapter.kind,
@@ -135,7 +185,7 @@ function createState(options = {}) {
135
185
  flowSelectionIndex: 0,
136
186
  flowByConversation: initialFlow ? { [`${target.workspaceId}::${target.conversationId}`]: initialFlow } : {},
137
187
  currentFlow: initialFlow,
138
- theme: String(snapshot.darkMode || "dark").toLowerCase() === "light" ? "light" : "dark",
188
+ theme: appearance.theme,
139
189
  overlay: null,
140
190
  settingChoiceTab: "",
141
191
  settingChoiceKey: "",
@@ -161,6 +211,7 @@ function createState(options = {}) {
161
211
  : `Newmark core connected · ${workspaces.find((item) => item.id === target.workspaceId)?.path || target.workspaceId}`,
162
212
  busy: false,
163
213
  runningConversationKeys: new Set(),
214
+ sendQueueByConversation: new Map(),
164
215
  stopStageByConversation: new Map(),
165
216
  tick: 0,
166
217
  messages: snapshot.chatMessages.map((item) => ({ ...item })),
@@ -199,8 +250,8 @@ function createState(options = {}) {
199
250
  personalization: {
200
251
  theme: { light: "Light", system: "System", dark: "Dark" }[String(snapshot.darkMode || "dark").toLowerCase()] || "Dark",
201
252
  fontFamily: snapshot.fontFamily || "Terminal default",
202
- fontColor: snapshot.fontColor || "#E6EAF2",
203
- backgroundColor: snapshot.backgroundColor || (String(snapshot.darkMode).toLowerCase() === "light" ? "#F0F2F8" : "#0A0A1A"),
253
+ fontColor: appearance.fontColor,
254
+ backgroundColor: appearance.backgroundColor,
204
255
  glassAlpha: Number(snapshot.glassAlpha || 0.85)
205
256
  },
206
257
  runtime: {
@@ -1285,6 +1336,7 @@ module.exports = {
1285
1336
  activateMenu,
1286
1337
  activateMemorySelection,
1287
1338
  applyThemeAppearance,
1339
+ resolveThemeAppearance,
1288
1340
  applySnapshot,
1289
1341
  applyConversationResult,
1290
1342
  beginAutomationCreate,