arcane-os 0.81.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ # Native application control
2
+
3
+ `connectAppControl` from `arcane-os/core/app-control` connects to one explicitly
4
+ selected running application window. The Windows WebView2 host provides document
5
+ inspection, a PNG of its rendered viewport, and targeted DOM actions through its
6
+ own local named pipe. The application keeps its existing Core child, profile,
7
+ origin and window lifecycle. These operations do not activate the desktop window
8
+ or send global keyboard or pointer input.
9
+
10
+ The Node client uses the SDK's existing `arcane/1` framing and local pipe/socket
11
+ transport on Windows, Linux and macOS. The native window adapter described here
12
+ is Windows WebView2; macOS, Linux and Android hosts do not yet expose these
13
+ operations. An installed generic browser tool does not automatically discover
14
+ this endpoint. A caller must use this public client or CLI explicitly.
15
+
16
+ ## Select the running instance
17
+
18
+ Add an explicit endpoint to the application's existing native launch context:
19
+
20
+ ```json
21
+ {
22
+ "native": {
23
+ "launchContext": {
24
+ "appControl": {
25
+ "endpoint": "\\\\.\\pipe\\arcane-pm-control"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ The same `appControl` record can be supplied in the launch JSON already selected
33
+ by `--arcane-launch-config`. That record is merged by the existing launcher; it
34
+ does not select a different profile or Core topology. Omission starts the usual
35
+ application without a control listener. Each simultaneous application instance
36
+ needs its own endpoint. An occupied endpoint reports the actual startup failure;
37
+ the host does not silently attach to another window or choose another name.
38
+
39
+ Use a matching published Windows host asset when assembling the application.
40
+ The host producer generates `arcane-app-control.js` from the canonical standalone
41
+ DOM operation function and includes it beside `Arcane.exe`. Direct C# composition
42
+ uses `ArcaneHostOptions.ApplicationId`, `AppControlEndpoint`, and
43
+ `AppControlSource` with the same generated source. Ordinary composition leaves
44
+ the two control options unset.
45
+
46
+ ## Public client
47
+
48
+ ```js
49
+ import {connectAppControl} from 'arcane-os/core/app-control';
50
+
51
+ const app = await connectAppControl({
52
+ endpoint: String.raw`\\.\pipe\arcane-pm-control`
53
+ });
54
+ try {
55
+ const page = await app.inspect();
56
+ await app.act({
57
+ documentGeneration: page.documentGeneration,
58
+ selector: 'a[data-route="connections"]',
59
+ action: 'click'
60
+ });
61
+ const contents = await app.inspect({selector: '#pm-content'});
62
+ console.log(contents);
63
+ } finally {
64
+ await app.close();
65
+ }
66
+ ```
67
+
68
+ This example selects an existing navigation link. Its returned action result
69
+ describes the immediate DOM operation. Inspect the resulting page to establish
70
+ the application outcome; a click alone does not establish an asynchronous save,
71
+ connection or other business result.
72
+
73
+ | Member | Contract |
74
+ | --- | --- |
75
+ | `connectAppControl({endpoint, signal?, onError?})` | Connect once to the explicit running host. It never launches an app, creates Core, changes a profile or reconnects automatically. |
76
+ | `status(options?)` | Returns app identity, platform/host, current URL, document generation, readiness, window title/state/client dimensions and available operations. Readiness describes the document, not models or application services. |
77
+ | `inspect(parameters={}, options?)` | Optional CSS `selector` selects zero or more roots; omission selects the document. Optional `documentGeneration` selects the expected document. Returns complete HTML/text, live control state and DOM-derived role/name hints. |
78
+ | `capture(parameters={}, options?)` | Returns `{mimeType:'image/png', data, documentGeneration, url}`. `data` contains the complete base64 PNG. Optional `documentGeneration` selects the expected document. |
79
+ | `act(parameters, options?)` | Requires `documentGeneration`, one unambiguous CSS `selector`, and the action fields below. |
80
+ | `close()` | Closes this caller's connection and cancels its pending requests. It never closes the app or Core. Returns the connection's completion promise. |
81
+ | `closed` | Connection lifetime promise; actual transport failures remain observable. |
82
+ | `endpoint` | The caller-selected local endpoint. |
83
+
84
+ Each operation's separate `options` uses the existing Core client request
85
+ contract, including `signal` and `timeoutMs`. Connection cancellation closes only
86
+ that connection. Cancellation before UI dispatch prevents execution. Once a
87
+ script or capture has been dispatched, cancellation cannot reverse its effects;
88
+ the host still observes its completion. A disconnected caller's completed result
89
+ is retained in the host diagnostics instead of being represented as delivered.
90
+
91
+ ## Document operations
92
+
93
+ Inspection covers the top-level light DOM. Its result contains `scope`, `title`,
94
+ `readyState`, `doctype`, `roots`, `controls`, `frames`, `shadowHosts`,
95
+ `activeElement`, `scrollingElement`, `selection`, `documentGeneration`, and
96
+ `url`. Each root retains its complete `html` and `text`; controls retain their
97
+ attributes and current form, selection, disabled, scrolling and related state.
98
+ Generated CSS selectors are useful for the inspected document; a later DOM edit
99
+ can change their meaning. Application-authored stable selectors are preferable
100
+ when available.
101
+
102
+ Role/name hints are derived from DOM attributes and labels, not a native
103
+ accessibility-tree computation. Frame documents and shadow-root contents are
104
+ outside this selector scope. Frame elements and open-shadow hosts are described;
105
+ closed shadow roots cannot be discovered. Capture is the current rendered
106
+ viewport, not an offscreen full-document rendering. Covered and minimized-window
107
+ capture behavior requires execution evidence in the selected environment.
108
+
109
+ | Action | Parameters and immediate behavior |
110
+ | --- | --- |
111
+ | `click` | Calls the uniquely selected element's DOM `click()` method. |
112
+ | `fill` | Exact string `value` replaces an input, textarea or contenteditable value; input/change events are dispatched. Read-only, disabled and non-text input controls report their actual unsupported state. |
113
+ | `select` | Exact `value` string or `values` string array selects native `<select>` options and dispatches input/change. Multiple values require a multiple-select control. |
114
+ | `scroll` | Absolute numeric `left`, `top`, or both in CSS pixels; the omitted axis retains its position. Returns the resulting scroll state, including browser clamping. |
115
+
116
+ DOM actions use programmatic, untrusted events. They do not supply physical user
117
+ activation for browser features that require it. Results describe the actual
118
+ immediate state, including browser value sanitization; input is not silently
119
+ rewritten by the SDK.
120
+
121
+ The host publishes a new generation when an actual replacement document becomes
122
+ ready. A creation-time document marker is also checked inside the executing
123
+ script so a queued action cannot run against a successor page. During navigation,
124
+ operations report that the document is not ready. A change during completion
125
+ reports the actual result with that failure; actions are never retried. These
126
+ markers are transient document lifecycle state and are not written to saved data.
127
+
128
+ ## CLI
129
+
130
+ ```powershell
131
+ arcane app-control status --endpoint '\\.\pipe\arcane-pm-control'
132
+ arcane app-control inspect --endpoint '\\.\pipe\arcane-pm-control' --selector '#pm-content'
133
+ arcane app-control capture --endpoint '\\.\pipe\arcane-pm-control' --output '.arcane\view.png'
134
+ arcane app-control act --endpoint '\\.\pipe\arcane-pm-control' --request '.arcane\action.json'
135
+ ```
136
+
137
+ The action file is the complete action parameter object, including the actual
138
+ `documentGeneration` returned by the preceding status or inspection. The CLI
139
+ reads that file completely. Paths are relative to the current directory. Capture
140
+ writes the PNG to the explicitly selected file and prints its metadata. The other
141
+ operations print complete JSON. `app-control --help` describes this command's
142
+ options; other Arcane commands retain their existing global output-format option.
143
+
144
+ Pipe startup, framing, document, image and output-file errors remain observable.
145
+ Host shutdown stops new control work and observes accepted operations alongside
146
+ the existing Core drain. It does not wait for desktop input or leave an
147
+ independent controller process running.
@@ -372,6 +372,26 @@ reach developer diagnostics and a concise recovery status stays outside the
372
372
  transcript. Destruction cancels pending preparation, while an already requested
373
373
  print retains its snapshot through `afterprint`.
374
374
 
375
+ Markdown images with local `arcane-media:` references saved through
376
+ [`MarkdownMedia`](runtime-modules.md#markdownmediajs) display automatically in
377
+ new messages, live response chunks, final responses, and restored history.
378
+ Chat resolves references through the existing application-scoped DBOPFS owner
379
+ while preserving the complete original Markdown, model payloads, saved history,
380
+ image descriptions, and ordinary image URLs. Text appears immediately; local
381
+ image reads and decoding proceed independently without delaying streaming or
382
+ session readiness. Failed images retain complete developer diagnostics and show
383
+ a concise message beside that card; successful sibling images remain visible.
384
+
385
+ Each rendered card owns its display URLs. Re-rendering, history replacement,
386
+ transient-message removal, and destruction cancel obsolete display work and
387
+ release those URLs without deleting stored images. Back/Forward Cache retains
388
+ the existing component lifecycle. Explicit `print()` awaits the current images;
389
+ native Print reports images that are still loading or failed instead of claiming
390
+ they are ready. A requested print snapshot retains its image URLs across later
391
+ message changes or component destruction until `afterprint` releases them.
392
+ Applications continue to own image-record names, associations, and cleanup;
393
+ Chat introduces no renderer callback or additional storage API.
394
+
375
395
  Before any session binding begins, callback-mode `sessionStatus` follows the
376
396
  selected sticky LLM role and the existing application-owned `modelName` label
377
397
  (or the selected model ID). Assigning `modelName` refreshes that status even
@@ -58,6 +58,7 @@ runtime layouts.
58
58
  | `arcane-os/core/development` | App-service composition and document-owned Core transport during source development, independently of local AI. |
59
59
  | `arcane-os/core/stdio` | Framed Node stdio transport with graceful runtime drain. |
60
60
  | `arcane-os/core/host` | Node-only reusable Core host lifecycle, app-selected native launch defaults and shared native location resolution. |
61
+ | `arcane-os/core/app-control` | Explicit connection to an existing native window for background document inspection, capture and DOM actions. |
61
62
  | `arcane-os/codex/app-server` | Explicit native Codex App Server session, complete live turns, server requests and recovery. |
62
63
  | `arcane-os/mcp/stdio` | Node MCP STDIO server for app-owned tools and static resources. |
63
64
  | `arcane-os/core/repositories` | Native persistent ArcaneData paths, connected working repositories and scoped non-secret Git identity configuration. |
@@ -428,6 +429,7 @@ The remaining data-only subpaths are eight JSON Schemas and package metadata.
428
429
  | `startCoreHost()` | function | `arcane-os/core/host` | Native Core host composition | Node with readable input and writable output streams |
429
430
  | `startSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC on Windows, Linux and macOS |
430
431
  | `connectSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC and optional explicit headless startup |
432
+ | `connectAppControl()` | function | `arcane-os/core/app-control` | Native application control | Node local IPC; current window adapter is Windows WebView2 |
431
433
  | `runSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node headless entry and optional parent startup IPC |
432
434
  | `startSharedCoreBridge()` | function | `arcane-os/core/host` | Shared Core host | Node readable/writable streams and local IPC |
433
435
  | `readCoreLaunchContext()` | function | `arcane-os/core/host` | Native Core host composition | Node; combines packaged defaults, host state and explicitly selected launch JSON |
@@ -8886,6 +8888,31 @@ try {
8886
8888
  }
8887
8889
  ```
8888
8890
 
8891
+ ## connectAppControl()
8892
+
8893
+ ```text
8894
+ connectAppControl({endpoint,signal,onError}={})
8895
+ ```
8896
+
8897
+ Connects once to the explicit running native window and resolves
8898
+ `{endpoint,closed,status,inspect,capture,act,close}`. The Windows host selects its
8899
+ own listener through `native.launchContext.appControl.endpoint` and preserves the
8900
+ existing Core child, origin, profile and application lifetime. The client uses
8901
+ portable Node local IPC; other native host adapters do not yet implement these
8902
+ document operations. No application is launched, no desktop window is activated,
8903
+ and no automatic reconnect or action retry occurs.
8904
+
8905
+ `status(options)` reports current app/window/document readiness.
8906
+ `inspect(parameters={},options)` returns complete selected light-DOM HTML/text
8907
+ and live control state. `capture(parameters={},options)` returns a full base64 PNG
8908
+ of the rendered viewport. `act(parameters,options)` requires a returned
8909
+ `documentGeneration`, one CSS selector, and `click`, `fill`, `select` or `scroll`
8910
+ fields. Options preserve the Core client signal/timeout contract. `close()`
8911
+ cancels this connection's requests and returns `closed`, without closing the app.
8912
+ Document changes and actual errors remain observable; a dispatched action is
8913
+ not reversed by later cancellation. Full parameter/result, CLI and platform
8914
+ contracts are in [native application control](native-app-control.md).
8915
+
8889
8916
  ## runSharedCoreHost()
8890
8917
 
8891
8918
  Accepts the same options as `startSharedCoreHost(options)` and adds the headless
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.81.0",
3
+ "version": "0.82.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -62,6 +62,7 @@
62
62
  "./core/development": "./src/core/development.mjs",
63
63
  "./core/stdio": "./src/core/stdio.mjs",
64
64
  "./core/host": "./src/core/host.mjs",
65
+ "./core/app-control": "./src/core/app-control.mjs",
65
66
  "./codex/app-server": "./src/codex/app-server.mjs",
66
67
  "./mcp/stdio": "./src/mcp/stdio-server.mjs",
67
68
  "./core/repositories": "./src/core/repositories.mjs",
@@ -886,6 +886,7 @@
886
886
  const aiActivationButton=shadowRoot.querySelector('#ai_activation_button');
887
887
  const [
888
888
  {default:MD},
889
+ {hydrateMarkdownMedia},
889
890
  {default:FileEntity},
890
891
  {ConversationSubmissionBarrier,requireConversationTimeboxDelivery},
891
892
  {requestAIRuntimeIntent,subscribeAIRuntimeState},
@@ -893,6 +894,7 @@
893
894
  {getBrowserDeviceClass}
894
895
  ]=await Promise.all([
895
896
  import('../modules/MD.js'),
897
+ import('../modules/MarkdownMedia.js'),
896
898
  import('../entities/File.js'),
897
899
  import('../modules/ConversationTimebox.js'),
898
900
  import('../modules/AIRuntimeState.js'),
@@ -946,15 +948,19 @@
946
948
  let destroyed=false;
947
949
  let recognitionDraft=null;
948
950
  let writingRecognitionDraft=false;
951
+ const transcriptMedia=new Map();
952
+ let printMediaRetains=null;
949
953
  const printViewPromise=import('../modules/PrintView.js').then(
950
954
  function registerChatPrint({createPrintView}){
951
955
  if(destroyed) return null;
952
956
  return createPrintView({
953
957
  host,
954
- content:function chatPrintContent(){return chatOutput;},
958
+ content:renderedChatPrintContent,
955
959
  title:function chatPrintTitle(){return host.printTitle||'Conversation';},
956
960
  active:function chatPrintActive(){return !destroyed&&chatOutput.children.length>0;},
957
961
  signal:aiRuntimeStateAbortController.signal,
962
+ retain:retainChatPrintMedia,
963
+ prepare:prepareChatPrintMedia,
958
964
  onError:reportChatPrintFailure
959
965
  });
960
966
  }
@@ -1190,6 +1196,7 @@
1190
1196
  for(const item of record.removedNodes) {
1191
1197
  if(item.localName === 'li' && item.parentElement !== chatOutput) {
1192
1198
  transcriptResizeObserver?.unobserve(item);
1199
+ releaseTranscriptMessageMedia(item);
1193
1200
  }
1194
1201
  }
1195
1202
  for(const item of record.addedNodes) {
@@ -1427,14 +1434,72 @@
1427
1434
  return entry;
1428
1435
  }
1429
1436
 
1437
+ function renderTranscriptMarkdown(target,content){
1438
+ if(destroyed) return null;
1439
+ const template=target.ownerDocument.createElement('template');
1440
+ template.innerHTML=new MD(content).rendered;
1441
+ const pending=[...template.content.querySelectorAll('img')].some(
1442
+ function localChatImage(image){
1443
+ return image.getAttribute('src')?.startsWith('arcane-media:');
1444
+ }
1445
+ );
1446
+ // Remove local-reference src values while detached; text renders now,
1447
+ // and the shared media owner observes independent storage and decoding.
1448
+ const handle=hydrateMarkdownMedia(template.content,{
1449
+ signal:aiRuntimeStateAbortController.signal
1450
+ });
1451
+ releaseTranscriptMarkdown(target);
1452
+ const media={handle,pending,error:null,status:null,ready:null};
1453
+ transcriptMedia.set(target,media);
1454
+ if(printMediaRetains){
1455
+ printMediaRetains.add(handle.retain());
1456
+ }
1457
+ target.raw=content;
1458
+ target.replaceChildren(template.content);
1459
+ media.ready=handle.ready.then(function transcriptImagesReady(){
1460
+ media.pending=false;
1461
+ },function transcriptImagesFailed(error){
1462
+ media.pending=false;
1463
+ media.error=error;
1464
+ if(destroyed||transcriptMedia.get(target)!==media) return;
1465
+ arcaneLogging.error('Unable to display saved Chat images.',error);
1466
+ const status=target.ownerDocument.createElement('p');
1467
+ status.className='message_media_status';
1468
+ status.setAttribute('role','status');
1469
+ status.textContent='Some saved images could not be displayed.';
1470
+ media.status=status;
1471
+ target.after(status);
1472
+ scrollTranscriptToBottom();
1473
+ });
1474
+ return media;
1475
+ }
1476
+
1477
+ function releaseTranscriptMarkdown(target){
1478
+ const media=transcriptMedia.get(target);
1479
+ if(!media) return;
1480
+ transcriptMedia.delete(target);
1481
+ media.status?.remove();
1482
+ media.handle.destroy();
1483
+ }
1484
+
1485
+ function releaseTranscriptMessageMedia(item){
1486
+ const markdown=item?.querySelector('.markdown');
1487
+ if(markdown) releaseTranscriptMarkdown(markdown);
1488
+ }
1489
+
1490
+ function removeTranscriptMessage(item){
1491
+ if(!item) return;
1492
+ releaseTranscriptMessageMedia(item);
1493
+ item.remove();
1494
+ }
1495
+
1430
1496
  function setTranscriptMessageContent(item,content,timestamp){
1431
1497
  if(!is.string(content)){
1432
1498
  throw new TypeError('Chat session message content must be a string.');
1433
1499
  }
1434
1500
  item.querySelector('.thinking')?.remove();
1435
1501
  const markdown=item.querySelector('.markdown');
1436
- markdown.raw=content;
1437
- markdown.innerHTML=new MD(content).rendered;
1502
+ renderTranscriptMarkdown(markdown,content);
1438
1503
  if(timestamp!==undefined){
1439
1504
  item.querySelector('.message_timestamp')?.replaceWith(
1440
1505
  transcriptTime(timestamp)
@@ -1495,12 +1560,11 @@
1495
1560
  header.append(heading);
1496
1561
  const markdown=chatOutput.ownerDocument.createElement('div');
1497
1562
  markdown.className='markdown';
1498
- markdown.raw=content;
1499
- markdown.innerHTML=new MD(content).rendered;
1500
1563
  item.append(header,markdown,transcriptTime(timestamp));
1501
1564
  for(const call of normalizeVisibleToolCalls(toolCalls)){
1502
1565
  appendVisibleToolCall(item,call);
1503
1566
  }
1567
+ renderTranscriptMarkdown(markdown,content);
1504
1568
  return item;
1505
1569
  }
1506
1570
 
@@ -1642,6 +1706,9 @@
1642
1706
  setPendingStructuralToolCalls(
1643
1707
  actionable?[...restoredPendingToolCalls.values()]:[]
1644
1708
  );
1709
+ for(const item of chatOutput.children){
1710
+ releaseTranscriptMessageMedia(item);
1711
+ }
1645
1712
  chatOutput.replaceChildren(fragment);
1646
1713
  transcriptFollowing = true;
1647
1714
  transcriptScrollTop = chatOutput.scrollTop;
@@ -3034,9 +3101,7 @@
3034
3101
  thinkingHTML.remove();
3035
3102
  }
3036
3103
 
3037
- target.raw ? target.raw+=text : target.raw=text;
3038
-
3039
- target.innerHTML=new MD(target.raw).rendered;
3104
+ renderTranscriptMarkdown(target,target.raw?target.raw+text:text);
3040
3105
 
3041
3106
  scrollTranscriptToBottom();
3042
3107
  }
@@ -3393,8 +3458,7 @@
3393
3458
  message.querySelector('.thinking')?.remove();
3394
3459
  const markdown=message.querySelector('.markdown');
3395
3460
  const text=visibleErrorMessage(error,'The AI request failed.');
3396
- markdown.raw=text;
3397
- markdown.innerHTML=new MD(text).rendered;
3461
+ renderTranscriptMarkdown(markdown,text);
3398
3462
  scrollTranscriptToBottom();
3399
3463
  return true;
3400
3464
  }
@@ -3421,8 +3485,8 @@
3421
3485
  const response=[...chatOutput.children].find(
3422
3486
  candidate=>candidate.id===`message-${messageId}`
3423
3487
  )||null;
3424
- response?.remove();
3425
- for(const request of requestMessages) request.remove();
3488
+ removeTranscriptMessage(response);
3489
+ for(const request of requestMessages) removeTranscriptMessage(request);
3426
3490
  const existing=textArea.value;
3427
3491
  if(existing!==text){
3428
3492
  textArea.value=existing
@@ -3500,7 +3564,7 @@
3500
3564
  function removeTransientSessionCards(){
3501
3565
  if(retainTurn||!transientCards.length) return;
3502
3566
  // The submitter supplies only nodes created for this submission.
3503
- for(const card of transientCards) card.remove();
3567
+ for(const card of transientCards) removeTranscriptMessage(card);
3504
3568
  transientCards.length=0;
3505
3569
  scrollTranscriptToBottom();
3506
3570
  }
@@ -3675,9 +3739,9 @@
3675
3739
  const response=[...chatOutput.children].find(
3676
3740
  candidate=>candidate.id===`message-${messageId}`
3677
3741
  )||null;
3678
- response?.remove();
3742
+ removeTranscriptMessage(response);
3679
3743
  for(const requestMessage of requestMessages){
3680
- requestMessage.remove();
3744
+ removeTranscriptMessage(requestMessage);
3681
3745
  }
3682
3746
  scrollTranscriptToBottom();
3683
3747
  }else if(retainTurn){
@@ -3692,7 +3756,7 @@
3692
3756
  arcaneLogging.error('Arcane chat request failed.',error);
3693
3757
  if(sessionRequestMessages.every(message=>message.role==='tool')){
3694
3758
  for(const requestMessage of requestMessages){
3695
- requestMessage.remove();
3759
+ removeTranscriptMessage(requestMessage);
3696
3760
  }
3697
3761
  }
3698
3762
  if(retainTurn) renderSessionMessageFailure(messageId,error);
@@ -4136,11 +4200,64 @@
4136
4200
  return message;
4137
4201
  }
4138
4202
 
4203
+ function renderedChatPrintContent(){
4204
+ for(const media of transcriptMedia.values()){
4205
+ if(media.pending){
4206
+ const error=new Error('Saved images are still loading. Print again when they finish.');
4207
+ error.code='ARCANE_MARKDOWN_MEDIA_PENDING';
4208
+ throw error;
4209
+ }
4210
+ if(media.error) throw media.error;
4211
+ }
4212
+ return chatOutput;
4213
+ }
4214
+
4215
+ function retainChatPrintMedia(){
4216
+ renderedChatPrintContent();
4217
+ const releases=new Set();
4218
+ for(const media of transcriptMedia.values()){
4219
+ releases.add(media.handle.retain());
4220
+ }
4221
+ printMediaRetains=releases;
4222
+ return function releaseChatPrintMedia(){
4223
+ if(printMediaRetains===releases){
4224
+ printMediaRetains=null;
4225
+ }
4226
+ for(const release of releases) release();
4227
+ releases.clear();
4228
+ };
4229
+ }
4230
+
4231
+ async function prepareChatPrintMedia(signal){
4232
+ let cancel;
4233
+ const cancelled=new Promise(function waitForChatPrintCancellation(resolve){cancel=resolve;});
4234
+ signal?.addEventListener('abort',cancel,{once:true});
4235
+ try{
4236
+ while(!destroyed&&!signal?.aborted){
4237
+ const selected=[...transcriptMedia];
4238
+ await Promise.race([
4239
+ Promise.all(selected.map(function readyChatMedia([,media]){return media.ready;})),
4240
+ cancelled
4241
+ ]);
4242
+ if(destroyed||signal?.aborted) return false;
4243
+ if(selected.length!==transcriptMedia.size||selected.some(
4244
+ function replacedChatMedia([target,media]){return transcriptMedia.get(target)!==media;}
4245
+ )) continue;
4246
+ renderedChatPrintContent();
4247
+ return true;
4248
+ }
4249
+ return false;
4250
+ }finally{
4251
+ signal?.removeEventListener('abort',cancel);
4252
+ }
4253
+ }
4254
+
4139
4255
  async function print(){
4140
4256
  if(destroyed) return false;
4141
4257
  try{
4142
4258
  const view=await printViewPromise;
4143
- return destroyed||!view?false:await view.print();
4259
+ if(destroyed||!view||!await prepareChatPrintMedia()) return false;
4260
+ return await view.print();
4144
4261
  }catch(error){
4145
4262
  reportChatPrintFailure(error);
4146
4263
  throw error;
@@ -4150,7 +4267,9 @@
4150
4267
  function reportChatPrintFailure(error){
4151
4268
  arcaneLogging.error('Unable to print the conversation.',error);
4152
4269
  if(!destroyed){
4153
- setSessionStatus('error','Unable to open print preview. Please try again.');
4270
+ setSessionStatus('error',error?.code==='ARCANE_MARKDOWN_MEDIA_PENDING'
4271
+ ?'Saved images are still loading. Print again when they finish.'
4272
+ :'Unable to open print preview. Please try again.');
4154
4273
  }
4155
4274
  }
4156
4275
 
@@ -4235,6 +4354,9 @@
4235
4354
  ownership.abort(destroyReason);
4236
4355
  }
4237
4356
  aiRuntimeStateAbortController.abort(destroyReason);
4357
+ for(const target of transcriptMedia.keys()){
4358
+ releaseTranscriptMarkdown(target);
4359
+ }
4238
4360
  aiActivationController.destroy();
4239
4361
  window.removeEventListener('pagehide',handlePageHide);
4240
4362
  window.removeEventListener('pageshow',handlePageShow);
@@ -0,0 +1,106 @@
1
+ import {readFile, writeFile} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {connectAppControl} from '../core/app-control.mjs';
4
+ import {CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
5
+
6
+ const HELP_TEXT=`Usage:
7
+ arcane app-control status --endpoint <pipe-or-socket>
8
+ arcane app-control inspect --endpoint <pipe-or-socket> [--selector <css-selector>]
9
+ arcane app-control capture --endpoint <pipe-or-socket> --output <png-file>
10
+ arcane app-control act --endpoint <pipe-or-socket> --request <json-file>
11
+
12
+ Connects to the selected running native window. Results are complete JSON.
13
+ Capture writes the PNG to --output and prints its metadata. Request and output
14
+ filenames are relative to the current directory. This command does not launch
15
+ an application or change its profile. --output selects the capture filename;
16
+ the other Arcane commands retain their existing output-format option.
17
+ `;
18
+
19
+ function usage(message) {
20
+ throw new CoreError({code: 'APP_CONTROL_USAGE', message, exitCode: 2});
21
+ }
22
+
23
+ function parseArguments(argv) {
24
+ if (!argv.length || argv.includes('--help') || argv.includes('-h')) return {help: true};
25
+ const [action] = argv;
26
+ const allowed = {
27
+ status: ['endpoint'],
28
+ inspect: ['endpoint', 'selector'],
29
+ capture: ['endpoint', 'output'],
30
+ act: ['endpoint', 'request']
31
+ }[action];
32
+ if (!Array.isArray(allowed)) usage('Expected app-control status, inspect, capture, or act.');
33
+ const values = {};
34
+ for (let index = 1; index < argv.length; index += 1) {
35
+ const argument = argv[index];
36
+ const equal = argument.indexOf('=');
37
+ const option = equal === -1 ? argument : argument.slice(0, equal);
38
+ if (!option.startsWith('--') || !allowed.includes(option.slice(2))) {
39
+ usage(`Unexpected argument for app-control ${action}: ${argument}`);
40
+ }
41
+ const value = equal === -1 ? argv[++index] : argument.slice(equal + 1);
42
+ if (value === undefined || value === '' || (equal === -1 && value.startsWith('--'))) {
43
+ usage(`${option} requires a value.`);
44
+ }
45
+ values[option.slice(2)] = value;
46
+ }
47
+ if (!values.endpoint) usage('App control requires --endpoint <pipe-or-socket>.');
48
+ if (action === 'capture' && !values.output) usage('App-control capture requires --output <png-file>.');
49
+ if (action === 'act' && !values.request) usage('App-control act requires --request <json-file>.');
50
+ return {action, ...values};
51
+ }
52
+
53
+ /** This command owns its filename option before the general output-mode parser. */
54
+ export async function runAppControlCli(argv, {cwd = process.cwd(), stdout = process.stdout,
55
+ stderr = process.stderr, controller = new AbortController()} = {}) {
56
+ let connection;
57
+ let result;
58
+ const failures = [];
59
+ function failed(error) {
60
+ if (!failures.includes(error)) failures.push(error);
61
+ }
62
+ function interrupted() { controller.abort(new Error('Interrupted')); }
63
+ process.once('SIGINT', interrupted);
64
+ process.once('SIGTERM', interrupted);
65
+ try {
66
+ const selection = parseArguments(argv);
67
+ if (selection.help) {
68
+ stdout.write(HELP_TEXT);
69
+ return 0;
70
+ }
71
+ stderr.write(`${JSON.stringify({status: 'running', operation: selection.action,
72
+ endpoint: selection.endpoint})}\n`);
73
+ const signal = controller.signal;
74
+ const parameters = selection.action === 'act'
75
+ ? JSON.parse(await readFile(path.resolve(cwd, selection.request), {encoding: 'utf8', signal}))
76
+ : selection.selector === undefined ? {} : {selector: selection.selector};
77
+ connection = await connectAppControl({endpoint: selection.endpoint, signal, onError: failed});
78
+ if (selection.action === 'status') result = await connection.status({signal});
79
+ else if (selection.action === 'inspect') result = await connection.inspect(parameters, {signal});
80
+ else if (selection.action === 'act') result = await connection.act(parameters, {signal});
81
+ else {
82
+ result = await connection.capture(parameters, {signal});
83
+ const output = path.resolve(cwd, selection.output);
84
+ await writeFile(output, Buffer.from(result.data, 'base64'), {signal});
85
+ const {data, ...metadata} = result;
86
+ result = {...metadata, output};
87
+ }
88
+ } catch (error) {
89
+ failed(error);
90
+ } finally {
91
+ if (connection) {
92
+ try { await connection.close(); } catch (error) { failed(error); }
93
+ }
94
+ process.removeListener('SIGINT', interrupted);
95
+ process.removeListener('SIGTERM', interrupted);
96
+ }
97
+ if (failures.length) {
98
+ const error = failures.length === 1 ? failures[0]
99
+ : new AggregateError(failures, 'App control completed with errors.');
100
+ stderr.write(`${JSON.stringify({...(result === undefined ? {} : {result}),
101
+ error: serializeCoreError(error)}, null, 2)}\n`);
102
+ return controller.signal.aborted ? 130 : error.exitCode ?? 1;
103
+ }
104
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
105
+ return 0;
106
+ }
package/src/cli/main.mjs CHANGED
@@ -6,6 +6,7 @@ import {ArcaneError,ERROR_CODES,normalizeError} from '../errors.mjs';
6
6
  import {CLI_NAME,SDK_NAME,SDK_VERSION,OUTPUT_MODES} from '../constants.mjs';
7
7
  import {loadArcaneNativeProvider} from '../native-provider-loader.mjs';
8
8
  import {APP_BUNDLE_EXTENSION} from '../release-bundle.mjs';
9
+ import {runAppControlCli} from './app-control.mjs';
9
10
 
10
11
  const is = new Is(false);
11
12
 
@@ -82,6 +83,7 @@ Usage:
82
83
  ${CLI_NAME} run [--target <target>] [--app <id>] [--cert <pem>] [--key <pem>] [--port 8000] [--http-port 0] [--arcane-root <directory>] [--output-root <directory>] [--format <format>] [--signing <mode>]
83
84
  ${CLI_NAME} update-check
84
85
  ${CLI_NAME} targets
86
+ ${CLI_NAME} app-control status|inspect|capture|act --endpoint <pipe-or-socket> [--help]
85
87
  ${CLI_NAME} repo status|pull|push
86
88
  ${CLI_NAME} mail key set [profile] [--secret-stdin]
87
89
  ${CLI_NAME} mail key status [profile]
@@ -942,6 +944,9 @@ export async function runCli(argv=process.argv.slice(2),{
942
944
  loadNativeProvider=loadArcaneNativeProvider,
943
945
  controller=new AbortController()
944
946
  }={}){
947
+ if(argv[0]==='app-control'){
948
+ return runAppControlCli(argv.slice(1),{cwd,stdout,stderr,controller});
949
+ }
945
950
  let reporter;
946
951
  let command='help';
947
952
  const onSignal=()=>controller.abort(new Error('Interrupted'));