newmark-agent 0.3.11 → 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 (39) 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 +2 -0
  7. package/dist/cli-help.js +25 -1
  8. package/dist/conversation-utility-host.bundle.cjs +357 -84
  9. package/dist/core/agent.d.ts +18 -3
  10. package/dist/core/agent.js +214 -30
  11. package/dist/core/agentKernelRunner.js +53 -8
  12. package/dist/core/config.d.ts +7 -2
  13. package/dist/core/config.js +24 -6
  14. package/dist/core/conversationKernel.js +1 -1
  15. package/dist/core/electronUtilityRuntimePool.d.ts +11 -0
  16. package/dist/core/electronUtilityRuntimePool.js +62 -0
  17. package/dist/core/flow-runner.js +1 -1
  18. package/dist/core/modelValidationStore.d.ts +4 -1
  19. package/dist/core/modelValidationStore.js +7 -1
  20. package/dist/core/workspace.d.ts +6 -0
  21. package/dist/core/workspace.js +14 -0
  22. package/dist/core/wslAgentRuntimePool.d.ts +4 -0
  23. package/dist/core/wslAgentRuntimePool.js +56 -0
  24. package/dist/launcher.js +40 -11
  25. package/dist/llm/provider.d.ts +8 -5
  26. package/dist/llm/provider.js +85 -33
  27. package/dist/main.js +167 -45
  28. package/dist/preload.js +6 -0
  29. package/dist/providers/chat-completions.adapter.js +1 -5
  30. package/dist/providers/provider-events.d.ts +7 -0
  31. package/dist/providers/provider-events.js +44 -0
  32. package/dist/providers/responses.adapter.js +1 -3
  33. package/dist/tui/src/adapters/core-runtime-adapter.js +40 -3
  34. package/dist/tui/src/app.js +47 -13
  35. package/dist/tui/src/render.js +23 -7
  36. package/dist/tui/src/state.js +61 -9
  37. package/dist/ui/index.html +250 -84
  38. package/dist/wsl-agent-host.bundle.cjs +357 -84
  39. package/package.json +14 -5
@@ -43,6 +43,19 @@ const child_process_1 = require("child_process");
43
43
  const agentKernelDiagnostics_1 = require("../core/agentKernelDiagnostics");
44
44
  const chat_messages_1 = require("../providers/chat-messages");
45
45
  const providers_1 = require("../providers");
46
+ // Keep provider requests below the release-harness/user-visible command
47
+ // deadline. A provider that does not answer must produce one bounded error;
48
+ // it must not restart the same request through every Windows transport.
49
+ const DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 90_000;
50
+ const MIN_PROVIDER_REQUEST_TIMEOUT_MS = 50;
51
+ function providerTimeoutError(timeoutMs) {
52
+ const error = new Error(`Provider request timed out after ${timeoutMs}ms`);
53
+ error.name = 'TimeoutError';
54
+ return error;
55
+ }
56
+ function isProviderTimeoutError(error) {
57
+ return error instanceof Error && error.name === 'TimeoutError';
58
+ }
46
59
  function abortFailure(signal) {
47
60
  const reason = signal?.reason;
48
61
  const error = reason instanceof Error ? reason : new Error(reason ? String(reason) : 'LLM request aborted');
@@ -84,15 +97,37 @@ class LLMProvider {
84
97
  explicitProtocol;
85
98
  openAIMode;
86
99
  useProviderAdaptersV2;
100
+ requestTimeoutMs;
87
101
  static nodeHttpTransport = null;
88
102
  static powershellTransport = null;
89
- constructor(name, baseUrl, apiKey, explicitProtocol, openAIMode = 'chat_stream', useProviderAdaptersV2 = false) {
103
+ constructor(name, baseUrl, apiKey, explicitProtocol, openAIMode = 'chat_stream', useProviderAdaptersV2 = false, requestTimeoutMs = DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS) {
90
104
  this.name = name;
91
105
  this.baseUrl = baseUrl;
92
106
  this.apiKey = apiKey;
93
107
  this.explicitProtocol = explicitProtocol;
94
108
  this.openAIMode = openAIMode;
95
109
  this.useProviderAdaptersV2 = useProviderAdaptersV2;
110
+ this.requestTimeoutMs = requestTimeoutMs;
111
+ }
112
+ effectiveRequestTimeout(timeoutMs) {
113
+ const requested = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS;
114
+ const configured = Number.isFinite(this.requestTimeoutMs) && this.requestTimeoutMs > 0
115
+ ? this.requestTimeoutMs
116
+ : DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS;
117
+ return Math.max(MIN_PROVIDER_REQUEST_TIMEOUT_MS, Math.min(requested, configured));
118
+ }
119
+ async withRequestTimeout(promise, timeoutMs, signal) {
120
+ let timer;
121
+ const timeoutPromise = new Promise((_, reject) => {
122
+ timer = setTimeout(() => reject(providerTimeoutError(timeoutMs)), timeoutMs);
123
+ });
124
+ try {
125
+ return await abortable(Promise.race([promise, timeoutPromise]), signal);
126
+ }
127
+ finally {
128
+ if (timer)
129
+ clearTimeout(timer);
130
+ }
96
131
  }
97
132
  intelligenceConfig(tier) {
98
133
  switch (tier) {
@@ -212,6 +247,7 @@ class LLMProvider {
212
247
  };
213
248
  }
214
249
  async postJsonWithFetchFallback(url, headers, body, timeoutMs = 120000, signal) {
250
+ const effectiveTimeout = this.effectiveRequestTimeout(timeoutMs);
215
251
  // Electron utility processes can leave an undici response body pending when
216
252
  // several isolated workers concurrently call a plain-HTTP local provider.
217
253
  // Node's HTTP client owns the full body lifecycle and is deterministic for
@@ -224,7 +260,7 @@ class LLMProvider {
224
260
  return '';
225
261
  } })();
226
262
  this.transportDiagnostic('loopback:start', pathname);
227
- const local = await this.nodeHttpJson('POST', url, headers, JSON.stringify(body), signal);
263
+ const local = await this.nodeHttpJson('POST', url, headers, JSON.stringify(body), signal, effectiveTimeout);
228
264
  this.transportDiagnostic('loopback:complete', `status=${local.status} bytes=${Buffer.byteLength(local.body || '')}`);
229
265
  return {
230
266
  ok: local.status >= 200 && local.status < 300,
@@ -240,7 +276,7 @@ class LLMProvider {
240
276
  forwardAbort();
241
277
  else
242
278
  signal?.addEventListener('abort', forwardAbort, { once: true });
243
- const timer = setTimeout(() => abort.abort(), timeoutMs);
279
+ const timer = setTimeout(() => abort.abort(providerTimeoutError(effectiveTimeout)), effectiveTimeout);
244
280
  try {
245
281
  const response = await fetch(url, {
246
282
  method: 'POST',
@@ -253,9 +289,11 @@ class LLMProvider {
253
289
  catch (e) {
254
290
  if (signal?.aborted)
255
291
  throw abortFailure(signal);
292
+ if (abort.signal.aborted)
293
+ throw abortFailure(abort.signal);
256
294
  if (!this.shouldUseNodeHttpFallback(e))
257
295
  throw e;
258
- const fallback = await this.nodeHttpJson('POST', url, headers, JSON.stringify(body), signal);
296
+ const fallback = await this.nodeHttpJson('POST', url, headers, JSON.stringify(body), signal, effectiveTimeout);
259
297
  return {
260
298
  ok: fallback.status >= 200 && fallback.status < 300,
261
299
  status: fallback.status,
@@ -270,16 +308,19 @@ class LLMProvider {
270
308
  }
271
309
  }
272
310
  async getJsonWithFetchFallback(url, headers, timeoutMs = 30000) {
311
+ const effectiveTimeout = this.effectiveRequestTimeout(timeoutMs);
273
312
  const abort = new AbortController();
274
- const timer = setTimeout(() => abort.abort(), timeoutMs);
313
+ const timer = setTimeout(() => abort.abort(providerTimeoutError(effectiveTimeout)), effectiveTimeout);
275
314
  try {
276
315
  const response = await fetch(url, { method: 'GET', headers, signal: abort.signal });
277
316
  return response;
278
317
  }
279
318
  catch (e) {
319
+ if (abort.signal.aborted)
320
+ throw abortFailure(abort.signal);
280
321
  if (!this.shouldUseNodeHttpFallback(e))
281
322
  throw e;
282
- const fallback = await this.nodeHttpJson('GET', url, headers);
323
+ const fallback = await this.nodeHttpJson('GET', url, headers, '', undefined, effectiveTimeout);
283
324
  return {
284
325
  ok: fallback.status >= 200 && fallback.status < 300,
285
326
  status: fallback.status,
@@ -293,16 +334,21 @@ class LLMProvider {
293
334
  }
294
335
  }
295
336
  shouldUseNodeHttpFallback(error) {
296
- return ((error instanceof TypeError && /fetch failed/i.test(error.message)) ||
297
- (error instanceof Error && /abort/i.test(error.name || error.message)));
337
+ // Abort is a completed control decision (user cancellation or our own
338
+ // deadline), not evidence that a second transport can succeed. Retrying it
339
+ // on Windows used to create a second 120s request after the first timeout.
340
+ return error instanceof TypeError && /fetch failed/i.test(error.message);
298
341
  }
299
- nodeHttpJson(method, urlValue, headers, body = '', signal) {
342
+ nodeHttpJson(method, urlValue, headers, body = '', signal, timeoutMs = DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS) {
343
+ const effectiveTimeout = this.effectiveRequestTimeout(timeoutMs);
300
344
  if (LLMProvider.nodeHttpTransport) {
301
- return abortable(LLMProvider.nodeHttpTransport(method, urlValue, headers, body), signal).catch(error => {
345
+ return this.withRequestTimeout(LLMProvider.nodeHttpTransport(method, urlValue, headers, body), effectiveTimeout, signal).catch(error => {
302
346
  if (signal?.aborted)
303
347
  throw abortFailure(signal);
348
+ if (isProviderTimeoutError(error))
349
+ throw error;
304
350
  if (process.platform === 'win32') {
305
- return this.powershellJson(method, urlValue, headers, body, signal);
351
+ return this.powershellJson(method, urlValue, headers, body, signal, effectiveTimeout);
306
352
  }
307
353
  throw error;
308
354
  });
@@ -350,8 +396,8 @@ class LLMProvider {
350
396
  fail(new Error('Node HTTP response closed before completion'));
351
397
  });
352
398
  });
353
- req.setTimeout(120000, () => {
354
- req.destroy(new Error('Node HTTP fallback timeout'));
399
+ req.setTimeout(effectiveTimeout, () => {
400
+ req.destroy(providerTimeoutError(effectiveTimeout));
355
401
  });
356
402
  req.on('error', reject);
357
403
  const onAbort = () => req.destroy(abortFailure(signal));
@@ -366,15 +412,18 @@ class LLMProvider {
366
412
  }).catch(error => {
367
413
  if (signal?.aborted)
368
414
  throw abortFailure(signal);
415
+ if (isProviderTimeoutError(error))
416
+ throw error;
369
417
  if (process.platform === 'win32') {
370
- return this.powershellJson(method, urlValue, headers, body, signal);
418
+ return this.powershellJson(method, urlValue, headers, body, signal, effectiveTimeout);
371
419
  }
372
420
  throw error;
373
421
  });
374
422
  }
375
- powershellJson(method, urlValue, headers, body = '', signal) {
423
+ powershellJson(method, urlValue, headers, body = '', signal, timeoutMs = DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS) {
424
+ const effectiveTimeout = this.effectiveRequestTimeout(timeoutMs);
376
425
  if (LLMProvider.powershellTransport) {
377
- return LLMProvider.powershellTransport(method, urlValue, headers, body);
426
+ return this.withRequestTimeout(LLMProvider.powershellTransport(method, urlValue, headers, body), effectiveTimeout, signal);
378
427
  }
379
428
  return new Promise((resolve, reject) => {
380
429
  const headerJson = JSON.stringify(headers);
@@ -403,7 +452,7 @@ class LLMProvider {
403
452
  ' $raw = $headerJson | ConvertFrom-Json',
404
453
  ' foreach ($p in $raw.PSObject.Properties) { $headers[$p.Name] = [string]$p.Value }',
405
454
  '}',
406
- '$params = @{ Uri = $uri; Method = $method; Headers = $headers; UseBasicParsing = $true; TimeoutSec = 120 }',
455
+ `'$params = @{ Uri = $uri; Method = $method; Headers = $headers; UseBasicParsing = $true; TimeoutSec = ${Math.max(1, Math.ceil(effectiveTimeout / 1000))} }`,
407
456
  'if ($method -eq "POST") { $params["Body"] = $bodyJson }',
408
457
  'if ($method -eq "POST") { $params["ContentType"] = "application/json; charset=utf-8" }',
409
458
  '$resp = Invoke-WebRequest @params',
@@ -437,8 +486,8 @@ class LLMProvider {
437
486
  const timer = setTimeout(() => {
438
487
  child.kill();
439
488
  cleanup();
440
- reject(new Error('PowerShell HTTP fallback timeout'));
441
- }, 130000);
489
+ reject(providerTimeoutError(effectiveTimeout));
490
+ }, effectiveTimeout + 5000);
442
491
  child.stdout.setEncoding('utf8');
443
492
  child.stderr.setEncoding('utf8');
444
493
  child.stdout.on('data', chunk => { stdout += chunk; });
@@ -926,10 +975,10 @@ class LLMProvider {
926
975
  return this.shouldUseResponsesFallback(Number(match[1]), errorText);
927
976
  }
928
977
  /**
929
- * Loopback-aware transport injected into adapter `execute`. Mirrors the
930
- * legacy orchestration exactly: streaming requests go through fetch with a
931
- * 120s timeout and degrade to a non-streaming node-http request on fetch
932
- * failure; non-streaming requests reuse postJsonWithFetchFallback.
978
+ * Loopback-aware transport injected into adapter `execute`. Streaming
979
+ * requests retain the fetch-to-node fallback for transport failures, while
980
+ * a local deadline is returned directly so one request cannot become a
981
+ * second Windows fallback request.
933
982
  */
934
983
  buildProviderAdapterTransport() {
935
984
  return async (request, signal) => {
@@ -940,7 +989,8 @@ class LLMProvider {
940
989
  forwardAbort();
941
990
  else
942
991
  signal?.addEventListener('abort', forwardAbort, { once: true });
943
- const timer = setTimeout(() => abort.abort(), 120000);
992
+ const effectiveTimeout = this.effectiveRequestTimeout(120000);
993
+ const timer = setTimeout(() => abort.abort(providerTimeoutError(effectiveTimeout)), effectiveTimeout);
944
994
  try {
945
995
  try {
946
996
  return await fetch(request.url, {
@@ -953,11 +1003,13 @@ class LLMProvider {
953
1003
  catch (error) {
954
1004
  if (signal?.aborted)
955
1005
  throw abortFailure(signal);
1006
+ if (abort.signal.aborted)
1007
+ throw abortFailure(abort.signal);
956
1008
  if (!this.shouldUseNodeHttpFallback(error))
957
1009
  throw error;
958
1010
  const fallbackHeaders = { ...request.headers };
959
1011
  delete fallbackHeaders['Accept'];
960
- const fallback = await this.postJsonWithFetchFallback(request.url, fallbackHeaders, { ...request.body, stream: false }, 120000, signal);
1012
+ const fallback = await this.postJsonWithFetchFallback(request.url, fallbackHeaders, { ...request.body, stream: false }, effectiveTimeout, signal);
961
1013
  return this.toTransportResponse(fallback);
962
1014
  }
963
1015
  }
@@ -1071,7 +1123,8 @@ class LLMProvider {
1071
1123
  forwardAbort();
1072
1124
  else
1073
1125
  signal?.addEventListener('abort', forwardAbort, { once: true });
1074
- const timeout = setTimeout(() => abort.abort(), 120000);
1126
+ const effectiveTimeout = this.effectiveRequestTimeout(120000);
1127
+ const timeout = setTimeout(() => abort.abort(providerTimeoutError(effectiveTimeout)), effectiveTimeout);
1075
1128
  let reader = null;
1076
1129
  try {
1077
1130
  let response;
@@ -1084,11 +1137,13 @@ class LLMProvider {
1084
1137
  });
1085
1138
  }
1086
1139
  catch (e) {
1140
+ if (signal?.aborted)
1141
+ throw abortFailure(signal);
1142
+ if (abort.signal.aborted)
1143
+ throw abortFailure(abort.signal);
1087
1144
  if (!this.shouldUseNodeHttpFallback(e))
1088
1145
  throw e;
1089
1146
  clearTimeout(timeout);
1090
- if (signal?.aborted)
1091
- throw abortFailure(signal);
1092
1147
  yield* this.githubModelsChatNonStreaming(url, body, signal);
1093
1148
  return;
1094
1149
  }
@@ -1108,12 +1163,9 @@ class LLMProvider {
1108
1163
  let currentReasoningContent = '';
1109
1164
  let contentPolicyBlocked = false;
1110
1165
  let emittedContent = false;
1166
+ const streamSignal = signal || new AbortController().signal;
1111
1167
  while (true) {
1112
- if (signal?.aborted)
1113
- throw abortFailure(signal);
1114
- const readPromise = reader.read();
1115
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Stream read timeout')), 30000));
1116
- const { done, value } = await Promise.race([readPromise, timeoutPromise]);
1168
+ const { done, value } = await (0, providers_1.readProviderStreamChunk)(reader, streamSignal);
1117
1169
  if (done)
1118
1170
  break;
1119
1171
  buffer += decoder.decode(value, { stream: true });
package/dist/main.js CHANGED
@@ -51,6 +51,7 @@ const electronBrowserUseHost_1 = require("./core/electronBrowserUseHost");
51
51
  const flow_1 = require("./core/flow");
52
52
  const flow_runner_1 = require("./core/flow-runner");
53
53
  const cli_commands_1 = require("./cli-commands");
54
+ const cli_discovery_1 = require("./cli-discovery");
54
55
  const config_1 = require("./core/config");
55
56
  const memoryLab_1 = require("./core/memoryLab");
56
57
  const installUpdate_1 = require("./core/installUpdate");
@@ -426,7 +427,11 @@ function createAppIconImage(size) {
426
427
  return size ? icon.resize({ width: size, height: size }) : icon;
427
428
  }
428
429
  function userArgs() {
429
- return process.argv.slice(1);
430
+ const args = process.argv.slice(1);
431
+ // The native Windows console wrapper inserts Electron's `--` boundary before
432
+ // user arguments. Electron leaves that boundary in process.argv; normalize it
433
+ // before command discovery so the literal `Newmark.exe help` path terminates.
434
+ return args[0] === '--' ? args.slice(1) : args;
430
435
  }
431
436
  function argValue(args, key) {
432
437
  const idx = args.indexOf(key);
@@ -486,14 +491,14 @@ function positionalAfter(args, commandName) {
486
491
  return values;
487
492
  }
488
493
  // First-run initialization
489
- function firstRunInit(root) {
494
+ function firstRunInit(root, options = {}) {
490
495
  fs.mkdirSync(root, { recursive: true });
491
496
  for (const d of ['skills', 'Work', 'Flow', 'archive', 'Memory Lab']) {
492
497
  fs.mkdirSync(path.join(root, d), { recursive: true });
493
498
  }
494
499
  new memoryLab_1.MemoryLabManager(root);
495
500
  const configModule = require('./core/config');
496
- configModule.ensureRootConfig(root);
501
+ configModule.ensureRootConfig(root, options);
497
502
  if (!fs.existsSync(path.join(root, 'agent.md'))) {
498
503
  fs.writeFileSync(path.join(root, 'agent.md'), '# Newmark Agent\n\nYou are a powerful coding assistant.\n', 'utf-8');
499
504
  }
@@ -583,18 +588,13 @@ function ensureElectronUtilityRuntimeHost() {
583
588
  return host;
584
589
  }
585
590
  function legacyUserDataRoot() {
586
- try {
587
- return electron_1.app.getPath('userData');
588
- }
589
- catch {
590
- if (process.platform === 'win32') {
591
- const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
592
- return path.join(appData, 'Newmark Agent');
593
- }
594
- if (process.platform === 'darwin')
595
- return path.join(os.homedir(), 'Library', 'Application Support', 'Newmark Agent');
596
- return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'Newmark Agent');
591
+ if (process.platform === 'win32') {
592
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
593
+ return path.join(appData, 'Newmark Agent');
597
594
  }
595
+ if (process.platform === 'darwin')
596
+ return path.join(os.homedir(), 'Library', 'Application Support', 'Newmark Agent');
597
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'Newmark Agent');
598
598
  }
599
599
  function migrateLegacyRuntimeRoot(root) {
600
600
  const targetRoot = path.resolve(root);
@@ -677,6 +677,15 @@ function resolveRoot(args) {
677
677
  return writableRuntimeRoot(explicitRoot);
678
678
  return getRoot();
679
679
  }
680
+ function resolveTuiWorkspacePath(args, root) {
681
+ const explicitWorkspace = pathArgValue(args, '--workspace');
682
+ if (explicitWorkspace)
683
+ return explicitWorkspace;
684
+ // An explicitly isolated runtime must not silently register the caller's
685
+ // cwd as an external workspace. Keep the opt-in --workspace escape hatch,
686
+ // while making the safe one-argument form fully self-contained.
687
+ return pathArgValue(args, '--root') ? root : process.cwd();
688
+ }
680
689
  function startupLogPath() {
681
690
  try {
682
691
  const userData = userRuntimeRoot();
@@ -1026,24 +1035,54 @@ const args = userArgs();
1026
1035
  const command = args.find(a => a === 'flow' || a === 'edit');
1027
1036
  const isTuiArg = args.some(arg => arg.toLowerCase() === '--tui');
1028
1037
  const hasCliCommand = args.some(a => cli_commands_1.CLI_COMMANDS.includes(a));
1038
+ const isFlowArg = command === 'flow';
1039
+ const isEditArg = command === 'edit';
1029
1040
  const isHelpArg = !hasCliCommand && (args.some(arg => ['--help', '-h'].includes(arg.toLowerCase())) || args[0]?.toLowerCase() === 'help');
1030
- const isVersionArg = !hasCliCommand && args.some(arg => ['--version', '-v'].includes(arg.toLowerCase()));
1041
+ const isVersionArg = !hasCliCommand && (0, cli_discovery_1.isVersionArgument)(args);
1042
+ const isReadOnlyValidation = hasCliCommand && args.includes('validate-models') && !args.includes('--persist');
1031
1043
  const isViewerArg = args.some(arg => arg.toLowerCase() === '--newmark-viewer');
1032
1044
  const isCliArg = args.includes('--cli');
1033
1045
  const isServerArg = args.includes('--server');
1034
- const isFlowArg = command === 'flow';
1035
- const isEditArg = command === 'edit';
1046
+ const invalidArgument = (0, cli_discovery_1.invalidTopLevelArgument)(args);
1047
+ if (invalidArgument) {
1048
+ console.error(`Invalid Newmark argument: ${invalidArgument}`);
1049
+ process.exit(2);
1050
+ }
1051
+ // Electron's Chromium profile is a separate state boundary from Newmark's
1052
+ // business root. Bind both before any ready event so --root cannot leave
1053
+ // Preferences, DIPS, DevTools ports, cookies, or session storage in the real
1054
+ // default AppData directory. The dedicated subdirectories keep Chromium's
1055
+ // files separate from the durable Newmark config/workspace files.
1056
+ const runtimeRoot = resolveRoot(args);
1057
+ const electronUserDataRoot = path.join(runtimeRoot, 'Electron');
1058
+ const electronSessionDataRoot = path.join(electronUserDataRoot, 'session-data');
1059
+ try {
1060
+ fs.mkdirSync(electronSessionDataRoot, { recursive: true });
1061
+ electron_1.app.setPath('userData', electronUserDataRoot);
1062
+ electron_1.app.setPath('sessionData', electronSessionDataRoot);
1063
+ }
1064
+ catch (error) {
1065
+ console.error(`Unable to isolate Electron user-data directory: ${error instanceof Error ? error.message : String(error)}`);
1066
+ process.exit(1);
1067
+ }
1036
1068
  // Help/version are terminating discovery commands. They must be handled
1037
1069
  // before Electron's GUI/TUI/server branches can initialize runtime state or
1038
1070
  // spawn a window, so a product-new tester can use them safely in any surface.
1039
1071
  if (isHelpArg) {
1040
- console.log((0, cli_help_1.newmarkHelpText)((0, installUpdate_1.currentAppVersion)()));
1072
+ console.log(isFlowArg ? (0, cli_help_1.newmarkFlowHelpText)() : isEditArg ? (0, cli_help_1.newmarkEditHelpText)() : (0, cli_help_1.newmarkHelpText)((0, installUpdate_1.currentAppVersion)()));
1041
1073
  process.exit(0);
1042
1074
  }
1043
1075
  if (isVersionArg) {
1044
1076
  console.log((0, installUpdate_1.currentAppVersion)());
1045
1077
  process.exit(0);
1046
1078
  }
1079
+ const unknownCommand = electron_1.app.isPackaged && !hasCliCommand && !isTuiArg && !isCliArg && !isServerArg && !isViewerArg
1080
+ ? (0, cli_discovery_1.unknownTopLevelCommand)(args)
1081
+ : undefined;
1082
+ if (unknownCommand) {
1083
+ console.error(`Unknown Newmark command or argument: ${unknownCommand}. Run --help to see the supported entrypoints.`);
1084
+ process.exit(2);
1085
+ }
1047
1086
  function viewerEscape(value) {
1048
1087
  return String(value || '').replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] || character);
1049
1088
  }
@@ -1118,7 +1157,9 @@ if (isViewerArg) {
1118
1157
  }).catch(error => { console.error(`Unable to open Newmark viewer: ${error instanceof Error ? error.message : String(error)}`); electron_1.app.quit(); });
1119
1158
  }
1120
1159
  else if (isTuiArg) {
1121
- const isConsoleLauncher = electron_1.app.isPackaged && path.basename(process.execPath).toLowerCase() === 'newmark.exe';
1160
+ const isConsoleLauncher = electron_1.app.isPackaged && (path.basename(process.execPath).toLowerCase() === 'newmark.exe'
1161
+ || path.basename(process.execPath).toLowerCase() === 'newmark console runtime.exe'
1162
+ || process.env.NEWMARK_CONSOLE_WRAPPER === '1');
1122
1163
  if (isConsoleLauncher && process.env.NEWMARK_TUI_SIDECAR !== '1') {
1123
1164
  const tuiProcess = (0, child_process_1.spawnSync)(process.execPath, [path.join(__dirname, 'launcher.js'), ...args], {
1124
1165
  cwd: process.cwd(),
@@ -1126,6 +1167,11 @@ else if (isTuiArg) {
1126
1167
  ...process.env,
1127
1168
  ELECTRON_RUN_AS_NODE: '1',
1128
1169
  NEWMARK_TUI_SIDECAR: '1',
1170
+ // The GUI Electron host does not expose the inherited ConPTY handles
1171
+ // as Node TTY streams. The console wrapper has already established
1172
+ // that this is the terminal entrypoint, so preserve the terminal
1173
+ // contract for the sidecar without weakening ordinary GUI launches.
1174
+ NEWMARK_FORCE_TTY: isConsoleLauncher ? '1' : process.env.NEWMARK_FORCE_TTY,
1129
1175
  },
1130
1176
  stdio: 'inherit',
1131
1177
  windowsHide: false,
@@ -1146,12 +1192,12 @@ else if (isTuiArg) {
1146
1192
  const root = resolveRoot(args);
1147
1193
  firstRunInit(root);
1148
1194
  const { start } = require('./tui/src/app');
1149
- start({ root, workspacePath: process.cwd(), desktopDist: __dirname });
1195
+ start({ root, workspacePath: resolveTuiWorkspacePath(args, root), desktopDist: __dirname });
1150
1196
  }
1151
1197
  else if (hasCliCommand) {
1152
1198
  (async () => {
1153
1199
  const root = resolveRoot(args);
1154
- firstRunInit(root);
1200
+ firstRunInit(root, { readOnly: isReadOnlyValidation });
1155
1201
  const handled = await (0, cli_commands_1.runCliCommand)(root, args);
1156
1202
  const code = typeof process.exitCode === 'number' ? process.exitCode : 0;
1157
1203
  exitCli(handled ? code : 1);
@@ -1312,6 +1358,51 @@ else {
1312
1358
  });
1313
1359
  electron_1.app.whenReady().then(async () => {
1314
1360
  let root = resolveRoot(args);
1361
+ let workspaceRegistryWatcher = null;
1362
+ let workspaceRegistryWatchTimer = null;
1363
+ const workspaceRegistryFiles = new Set(['Local.json', 'External.json', 'State.json']);
1364
+ const broadcastWorkspaceChanged = (files) => {
1365
+ for (const win of electron_1.BrowserWindow.getAllWindows()) {
1366
+ if (win.isDestroyed())
1367
+ continue;
1368
+ win.webContents.send('workspace:changed', { files, root });
1369
+ }
1370
+ };
1371
+ const refreshWorkspaceRegistryFromDisk = (files) => {
1372
+ if (!agent)
1373
+ return;
1374
+ agent.refreshWorkspaceRegistryFromStorage();
1375
+ const current = agent.workspace.current;
1376
+ if (current)
1377
+ workspaceSelectionCoordinator?.setCurrent(current.id || current.path || current.name);
1378
+ broadcastWorkspaceChanged(files);
1379
+ };
1380
+ const ensureWorkspaceRegistryWatcher = () => {
1381
+ if (workspaceRegistryWatcher)
1382
+ return;
1383
+ const workDir = path.join(root, 'Work');
1384
+ try {
1385
+ workspaceRegistryWatcher = fs.watch(workDir, { persistent: false }, (_eventType, filename) => {
1386
+ const changed = String(filename || '');
1387
+ if (changed && !workspaceRegistryFiles.has(path.basename(changed)))
1388
+ return;
1389
+ if (workspaceRegistryWatchTimer)
1390
+ clearTimeout(workspaceRegistryWatchTimer);
1391
+ workspaceRegistryWatchTimer = setTimeout(() => {
1392
+ workspaceRegistryWatchTimer = null;
1393
+ try {
1394
+ refreshWorkspaceRegistryFromDisk(changed ? [path.basename(changed)] : ['Work']);
1395
+ }
1396
+ catch (error) {
1397
+ logStartupFailure('workspace-registry-refresh', error);
1398
+ }
1399
+ }, 90);
1400
+ });
1401
+ }
1402
+ catch (error) {
1403
+ logStartupFailure('workspace-registry-watch', error);
1404
+ }
1405
+ };
1315
1406
  const fileRouter = new workspaceFileRouter_1.WorkspaceFileRouter(() => path.resolve(agent?.workspace.current?.path || root));
1316
1407
  const pdfPreviewServer = new pdfPreviewServer_1.PdfPreviewServer((token, ownerId) => fileRouter.resolvePdfCapability(token, ownerId));
1317
1408
  await pdfPreviewServer.start();
@@ -1718,6 +1809,7 @@ else {
1718
1809
  agent = new agent_1.Agent(root);
1719
1810
  mcpManager = new mcpManager_1.McpManager(root);
1720
1811
  activeAgentBackendMode = process.platform === 'win32' && agent.config.getBool('agent', 'run_in_wsl') ? 'wsl' : 'windows';
1812
+ ensureWorkspaceRegistryWatcher();
1721
1813
  restoreStoredFlowSuspension();
1722
1814
  recordStartup('agent-ready');
1723
1815
  }
@@ -2207,8 +2299,17 @@ else {
2207
2299
  (0, runtimeLifecycle_1.markRuntimeLifecycleClean)(root, 'main');
2208
2300
  };
2209
2301
  electron_1.app.on('will-quit', event => {
2210
- if (_forceQuit)
2302
+ // Window-close and tray-exit both enter this path. The runtime pools
2303
+ // must get a bounded graceful-shutdown window regardless of which
2304
+ // surface initiated the close; otherwise a stuck child can keep the
2305
+ // Electron parent and its renderer tree alive indefinitely.
2306
+ if (_forceQuit || !appExitCleanupComplete)
2211
2307
  armForcedExitDeadline('will-quit');
2308
+ if (workspaceRegistryWatchTimer)
2309
+ clearTimeout(workspaceRegistryWatchTimer);
2310
+ workspaceRegistryWatchTimer = null;
2311
+ workspaceRegistryWatcher?.close();
2312
+ workspaceRegistryWatcher = null;
2212
2313
  startupDeferredTasks?.cancel();
2213
2314
  agent?.flushWorkspaceConversationState();
2214
2315
  conversationKernel?.flushPersistence();
@@ -2539,6 +2640,13 @@ else {
2539
2640
  else
2540
2641
  await electronUtilityRuntimePool?.stopTarget(target);
2541
2642
  };
2643
+ const forceStopTargetRuntime = async (target) => {
2644
+ if (wslBackendEnabled())
2645
+ await wslAgentRuntimePool?.forceStopTarget(target);
2646
+ else
2647
+ await electronUtilityRuntimePool?.forceStopTarget(target);
2648
+ };
2649
+ const archiveInFlight = new Map();
2542
2650
  const isolatedConversationAgent = (target) => {
2543
2651
  const normalized = (0, conversationTarget_1.normalizeConversationTarget)(target);
2544
2652
  const isolated = new agent_1.Agent(root, { agentOnly: true });
@@ -3732,32 +3840,46 @@ else {
3732
3840
  return { ok: false, error: 'Agent not initialized' };
3733
3841
  const target = conversationRuntimeTarget(targetInput || agent.activeConversationId || 'default');
3734
3842
  const normalized = (0, conversationTarget_1.normalizeConversationTarget)(target);
3735
- assertTargetNotMutating(normalized);
3736
- mutatingRuntimeKeys.add(normalized.runtimeKey);
3843
+ const existing = archiveInFlight.get(normalized.runtimeKey);
3844
+ if (existing)
3845
+ return existing;
3846
+ const operation = (async () => {
3847
+ mutatingRuntimeKeys.add(normalized.runtimeKey);
3848
+ try {
3849
+ // Archive is a destructive lifecycle command. It intentionally
3850
+ // bypasses the normal mutation/active-prompt guard and hard-stops
3851
+ // any resident runtime before touching conversation persistence.
3852
+ await forceStopTargetRuntime(normalized);
3853
+ const currentWorkspacePath = path.resolve(agent.workspace.current?.path || '');
3854
+ const targetWorkspacePath = path.resolve(normalized.workspace?.path || '');
3855
+ const ownsTargetWorkspace = !!normalized.workspace
3856
+ && !!agent.workspace.current
3857
+ && currentWorkspacePath === targetWorkspacePath;
3858
+ // The host Agent owns the current workspace persistence cache. An
3859
+ // isolated owner is used for another workspace. The archive writer
3860
+ // starts payload I/O in parallel and finalizes deletion against the
3861
+ // latest locked state snapshot, so rapid clicks do not serialize on
3862
+ // large Markdown bodies or lose a sibling deletion.
3863
+ const archiveOwner = ownsTargetWorkspace ? agent : isolatedConversationAgent(normalized);
3864
+ const archived = await archiveOwner.archiveConversationAsync(normalized.conversationId);
3865
+ if (!archived)
3866
+ return { ok: false, error: 'Conversation archive could not be written.' };
3867
+ return { ok: true, fileName: archived, conversationId: normalized.conversationId, workspaceId: normalized.workspaceId };
3868
+ }
3869
+ catch (error) {
3870
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
3871
+ }
3872
+ finally {
3873
+ mutatingRuntimeKeys.delete(normalized.runtimeKey);
3874
+ }
3875
+ })();
3876
+ archiveInFlight.set(normalized.runtimeKey, operation);
3737
3877
  try {
3738
- const peek = peekTargetRuntime(normalized);
3739
- if (peek.running || peek.stopping)
3740
- return { ok: false, error: 'Cannot archive a conversation while its runtime is running or stopping.' };
3741
- if (peek.resident)
3742
- await stopTargetRuntime(normalized);
3743
- const currentWorkspacePath = path.resolve(agent.workspace.current?.path || '');
3744
- const targetWorkspacePath = path.resolve(normalized.workspace?.path || '');
3745
- const ownsTargetWorkspace = !!normalized.workspace
3746
- && !!agent.workspace.current
3747
- && currentWorkspacePath === targetWorkspacePath;
3748
- // The host Agent owns the current workspace persistence cache. Archiving
3749
- // through it prevents a delayed host flush from resurrecting the target.
3750
- const archiveOwner = ownsTargetWorkspace ? agent : isolatedConversationAgent(normalized);
3751
- const archived = archiveOwner.archiveConversation(normalized.conversationId);
3752
- if (!archived)
3753
- return { ok: false, error: 'Conversation archive could not be written.' };
3754
- return { ok: true, fileName: archived, conversationId: normalized.conversationId, workspaceId: normalized.workspaceId };
3755
- }
3756
- catch (error) {
3757
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
3878
+ return await operation;
3758
3879
  }
3759
3880
  finally {
3760
- mutatingRuntimeKeys.delete(normalized.runtimeKey);
3881
+ if (archiveInFlight.get(normalized.runtimeKey) === operation)
3882
+ archiveInFlight.delete(normalized.runtimeKey);
3761
3883
  }
3762
3884
  });
3763
3885
  electron_1.ipcMain.handle('agent:listArchives', async (_event, scope) => {
package/dist/preload.js CHANGED
@@ -173,6 +173,12 @@ contextBridge.exposeInMainWorld('api', {
173
173
  onAgentWorkEvent: (callback) => {
174
174
  ipcRenderer.on('agent:workEvent', callback);
175
175
  },
176
+ onWorkspaceChanged: (callback) => {
177
+ ipcRenderer.on('workspace:changed', (_event, payload) => callback(payload));
178
+ },
179
+ removeWorkspaceChangedListener: () => {
180
+ ipcRenderer.removeAllListeners('workspace:changed');
181
+ },
176
182
  removeAgentWorkEventListener: () => {
177
183
  ipcRenderer.removeAllListeners('agent:workEvent');
178
184
  },
@@ -96,11 +96,7 @@ class ChatCompletionsAdapter {
96
96
  let emittedTool = false;
97
97
  try {
98
98
  while (true) {
99
- if (signal.aborted)
100
- throw (0, provider_events_1.providerAbortError)(signal);
101
- const readPromise = reader.read();
102
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Stream read timeout')), 30000));
103
- const { done, value } = await Promise.race([readPromise, timeoutPromise]);
99
+ const { done, value } = await (0, provider_events_1.readProviderStreamChunk)(reader, signal);
104
100
  if (done)
105
101
  break;
106
102
  buffer += decoder.decode(value, { stream: true });
@@ -12,6 +12,13 @@ export declare function defaultProviderTransport(request: SerializedProviderRequ
12
12
  * (`name === 'AbortError'`, preserves the abort reason when present).
13
13
  */
14
14
  export declare function providerAbortError(signal?: AbortSignal): Error;
15
+ export declare function providerStreamTimeoutError(timeoutMs: number): Error;
16
+ /**
17
+ * Read one SSE chunk with both user cancellation and an inactivity deadline.
18
+ * Cancelling the reader is important: rejecting the race alone leaves the
19
+ * provider socket alive and lets later requests accumulate behind it.
20
+ */
21
+ export declare function readProviderStreamChunk(reader: ReadableStreamDefaultReader<Uint8Array>, signal: AbortSignal, timeoutMs?: number): Promise<ReadableStreamReadResult<Uint8Array>>;
15
22
  export declare function parseProviderSse(raw: string): Array<{
16
23
  event?: string;
17
24
  data: string;