@hanamesh/dsh-app-host 0.2.0-rc.2 → 0.6.14

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 (120) hide show
  1. package/LICENSE +4 -7
  2. package/README.md +140 -13
  3. package/consistency.json +41 -6
  4. package/dist/catalog/fixtures/consumer.json +35 -0
  5. package/dist/catalog/fixtures/provider.json +107 -0
  6. package/dist/catalog/suite.d.ts +4 -0
  7. package/dist/catalog/suite.js +50 -0
  8. package/dist/client-ui.d.ts +21 -3
  9. package/dist/client-ui.js +961 -94
  10. package/dist/client.js +2 -2
  11. package/dist/control-origin.js +9 -0
  12. package/dist/descriptor.js +6 -0
  13. package/dist/dsh-compat.js +6 -0
  14. package/dist/dsh.d.ts +21 -4
  15. package/dist/dsh.js +149 -58
  16. package/dist/gateway.js +140 -11
  17. package/dist/git-process.js +64 -0
  18. package/dist/git-worker.js +50 -0
  19. package/dist/index.d.ts +9 -7
  20. package/dist/install-target/fixtures/catalog.json +58 -0
  21. package/dist/install-target/fixtures/consumer-cases.json +148 -0
  22. package/dist/install-target/fixtures/install-failure-cases.json +58 -0
  23. package/dist/install-target/fixtures/provider-cases.json +618 -0
  24. package/dist/install-target/fixtures/reference-consumer.js +37 -0
  25. package/dist/install-target/fixtures/source-profile.js +123 -0
  26. package/dist/install-target/harness.js +43 -0
  27. package/dist/install-target/run.js +28 -0
  28. package/dist/install-target/suite.d.ts +19 -0
  29. package/dist/install-target/suite.js +328 -0
  30. package/dist/installation-status.d.ts +21 -0
  31. package/dist/installation-status.js +2 -0
  32. package/dist/library/catalog.js +163 -19
  33. package/dist/library/client-identity.js +31 -0
  34. package/dist/library/desktop-cli.js +7 -0
  35. package/dist/library/domain.js +3 -1
  36. package/dist/library/handoff.js +246 -0
  37. package/dist/library/install.js +138 -32
  38. package/dist/library/installation-cli.js +48 -0
  39. package/dist/library/installation-command-worker.js +27 -0
  40. package/dist/library/installation-status-http.js +26 -0
  41. package/dist/library/installation-status.js +91 -0
  42. package/dist/library/installation-worker.js +44 -0
  43. package/dist/library/locate.js +70 -9
  44. package/dist/library/node-binding.js +11 -0
  45. package/dist/library/recovery.js +66 -0
  46. package/dist/library/routes.js +90 -14
  47. package/dist/library/service.js +123 -27
  48. package/dist/library/suite.js +130 -0
  49. package/dist/manager.js +24 -8
  50. package/dist/node-runtime/domain.js +6 -0
  51. package/dist/node-runtime/releases.json +59 -0
  52. package/dist/node-runtime/service.js +101 -0
  53. package/dist/owned-probe-diagnostic.js +53 -0
  54. package/dist/router/receipts.js +8 -11
  55. package/dist/router/routes.js +7 -7
  56. package/dist/routes.js +9 -8
  57. package/dist/runtime.js +11 -1
  58. package/dist/usage-evidence.js +18 -86
  59. package/dist/workspace-origin.js +17 -0
  60. package/docs/APP_PACKAGE.md +4 -0
  61. package/docs/CONTRACT.md +75 -11
  62. package/docs/DSH_COMPATIBILITY.md +94 -0
  63. package/docs/DSH_INTEGRATION.md +41 -0
  64. package/docs/INSTALLATION_STATUS.md +96 -0
  65. package/docs/INSTALL_TARGET.md +130 -0
  66. package/docs/LIBRARY.md +44 -3
  67. package/docs/LICENSES.json +189 -61
  68. package/docs/PROVENANCE.json +53 -12
  69. package/docs/SECURITY.md +31 -0
  70. package/node_modules/@hanamesh/lib-provision/LICENSE +15 -0
  71. package/node_modules/@hanamesh/lib-provision/README.md +134 -0
  72. package/node_modules/@hanamesh/lib-provision/THIRD_PARTY_NOTICES.md +17 -0
  73. package/node_modules/@hanamesh/lib-provision/bin/hanamesh-provision.js +13 -0
  74. package/node_modules/@hanamesh/lib-provision/consistency.json +20 -0
  75. package/node_modules/@hanamesh/lib-provision/contract/CHANGELOG.md +5 -0
  76. package/node_modules/@hanamesh/lib-provision/contract/conformance.mjs +80 -0
  77. package/node_modules/@hanamesh/lib-provision/contract/fixtures/consumer.json +9 -0
  78. package/node_modules/@hanamesh/lib-provision/contract/fixtures/provider.json +11 -0
  79. package/node_modules/@hanamesh/lib-provision/contract/lib-provision.schema.json +47 -0
  80. package/node_modules/@hanamesh/lib-provision/package.json +50 -0
  81. package/package.json +81 -33
  82. package/schemas/catalog-provider-page.schema.json +884 -7
  83. package/schemas/catalog-source.schema.json +9 -7
  84. package/schemas/install-target.schema.json +298 -0
  85. package/dist/provision/LICENSE +0 -6
  86. package/dist/provision/THIRD_PARTY_NOTICES.md +0 -13
  87. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.d.ts +0 -0
  88. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.js +0 -0
  89. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.d.ts +0 -0
  90. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.js +0 -0
  91. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.d.ts +0 -0
  92. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.js +0 -0
  93. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.d.ts +0 -0
  94. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.js +0 -0
  95. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.d.ts +0 -0
  96. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.js +0 -0
  97. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.d.ts +0 -0
  98. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.js +0 -0
  99. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.d.ts +0 -0
  100. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.js +0 -0
  101. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.d.ts +0 -0
  102. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.js +0 -0
  103. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.d.ts +0 -0
  104. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.js +0 -0
  105. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.d.ts +0 -0
  106. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.js +0 -0
  107. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.d.ts +0 -0
  108. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.js +0 -0
  109. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.d.ts +0 -0
  110. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.js +0 -0
  111. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.d.ts +0 -0
  112. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.js +0 -0
  113. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.d.ts +0 -0
  114. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.js +0 -0
  115. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.d.ts +0 -0
  116. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.js +0 -0
  117. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.d.ts +0 -0
  118. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.js +0 -0
  119. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.d.ts +0 -0
  120. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.js +0 -0
package/dist/client.js CHANGED
@@ -1,8 +1,8 @@
1
+ import { isWorkspaceOrigin } from './workspace-origin.js';
1
2
  /** Workspace-side SDK only. Never inject this module into an application iframe. */
2
3
  export class WorkspaceAppClient {
3
4
  constructor({origin=globalThis.location?.origin,fetch:fetcher=globalThis.fetch,headers=()=>({})}={}) {
4
- const parsed=new URL(origin);
5
- if(parsed.origin!==origin||parsed.protocol!=='http:'||parsed.hostname!=='127.0.0.1'||!parsed.port)throw new TypeError('Expected exact loopback workspace origin.');
5
+ if(!isWorkspaceOrigin(origin))throw new TypeError('Expected exact loopback workspace origin.');
6
6
  if(typeof fetcher!=='function'||typeof headers!=='function')throw new TypeError('fetch and host headers provider are required.');
7
7
  this.origin=origin;this.fetcher=fetcher;this.headers=headers;
8
8
  }
@@ -0,0 +1,9 @@
1
+ import {workspaceOrigin, loopbackOrigin} from './descriptor.js';
2
+ import {DESKTOP_ORIGIN} from './workspace-origin.js';
3
+ /** Desktop authenticates and forwards to HTTP after removing Origin; keep both identities. */
4
+ export function controlOrigins(parentOrigin, transportOrigin) {
5
+ const parent = workspaceOrigin(parentOrigin);
6
+ const transport = loopbackOrigin(transportOrigin ?? parent);
7
+ return {parent, transport, forwarded: parent === DESKTOP_ORIGIN,
8
+ acceptsOrigin: origin => origin === parent || (parent === DESKTOP_ORIGIN && origin === undefined)};
9
+ }
@@ -1,3 +1,4 @@
1
+ import { isWorkspaceOrigin } from './workspace-origin.js';
1
2
  import { isAbsolute } from 'node:path';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { requireCondition, copy } from './errors.js';
@@ -14,6 +15,11 @@ export function loopbackOrigin(value) {
14
15
  'INVALID_ORIGIN', 'Only an explicit http://127.0.0.1:<port> origin is supported by this candidate.');
15
16
  return url.origin;
16
17
  }
18
+ /** Workspace parent only; application upstream URLs still use loopbackOrigin. */
19
+ export function workspaceOrigin(value) {
20
+ requireCondition(isWorkspaceOrigin(value), 'INVALID_ORIGIN', 'Expected an exact HTTP loopback or dsh-app://app workspace origin.');
21
+ return value;
22
+ }
17
23
  /** npm package name (optionally scoped), at most 128 characters — the same shape the usage plugin accepts as `hanaRef`. */
18
24
  export const packageName = value => typeof value === 'string' && value.length <= 128 && /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
19
25
  export function validateDefinition(input) {
@@ -0,0 +1,6 @@
1
+ import semver from 'semver';
2
+
3
+ /** Public host compatibility; exact dependency versions in devDependencies remain reproducible test inputs. */
4
+ export const DSH_RANGE='>=0.2.0-rc.2 <0.3.0-0 || >=0.2.1-0 <0.3.0-0';
5
+ /** saveExact entered the public PluginManager contract in 0.2.1-alpha.2. Unknown versions fail closed. */
6
+ export function managerSupportsExactSave(version){return typeof version==='string'&&semver.satisfies(version,'>=0.2.1-alpha.2 <0.3.0-0',{includePrerelease:true});}
package/dist/dsh.d.ts CHANGED
@@ -1,17 +1,19 @@
1
+ import type {InstallationReaderConfig} from './installation-status.js';
1
2
  import type { AppHost, AppDefinition, AuthenticatedSubject, Awaitable, DshStorageBinding } from './index.js';
2
3
  export const name: 'hanamesh-app-host';
3
- export const DSH_TARGET: '0.2.0-rc.2';
4
+ export const DSH_TARGET: '>=0.2.0-rc.2 <0.3.0-0 || >=0.2.1-0 <0.3.0-0';
4
5
  export const inject: readonly ['webServer', 'storageDomain', 'connection'];
5
6
  export const BROWSER_PRINCIPAL: 'dsh-browser';
6
7
  export const ROUTES: readonly string[];
7
8
  export const Config: unknown;
8
9
  export const appHostDomainSpec: { readonly name: 'hanamesh_app_host'; readonly version: 1; readonly layout: 'single' };
9
10
  export interface DshPluginConfig {
10
- dataRoot?: string; parentOrigin?: string; nodeBinary?: string; router?:{codingOauth?:{mode?:'http'|'file'}};
11
+ nodeRuntime?:{mode?:'managed'|'external';version?:string;sourceBase?:string;storageRoot?:string;externalBinary?:string};
12
+ dataRoot?: string; parentOrigin?: string; frameAncestors?: string[]; nodeBinary?: string; router?:{codingOauth?:{mode?:'http'|'file'}};
11
13
  /** `sources` undefined → the HanaMesh catalog source; explicit `[]` → none. `profileDir`/`profileName`/`dshBin` (and
12
- * `nodeBinary`) are inferred on plain DSH when undefined — see docs/CONTRACT.md 「应用库配置」; explicit values win. */
14
+ * `nodeBinary`) are inferred from the proven Desktop layout or plain DSH when undefined — see docs/CONTRACT.md 「应用库配置」; explicit values win. */
13
15
  /** rc.28: the same keys serve the HanaMesh market (plugins + applications; routes `/hanamesh/library*`, see docs/CONTRACT.md「市场」). */
14
- library?:{fixture?:string;sources?:Array<{manifestUrl:string;enabled?:boolean}>;profileDir?:string;profileName?:string;dshBin?:string;registry?:string;allowPrerelease?:boolean};
16
+ library?:{fixture?:string;sources?:Array<{manifestUrl:string;enabled?:boolean}>;profileDir?:string;profileName?:string;dshBin?:string;registry?:string;allowPrerelease?:boolean;eventRetention?:number;installationReaders?:Partial<Record<'claude'|'codex',InstallationReaderConfig>>};
15
17
  applications?: AppDefinition[]; leaseTtlMs?: number; sweepIntervalMs?: number;
16
18
  }
17
19
  /** Adapt an open `ctx.storageDomain` handle (single layout, global snapshot) to the store binding. */
@@ -23,3 +25,18 @@ export function browserAuthentication(connection: { requestRejection(request: {
23
25
  };
24
26
  /** Cordis plugin entry; activates once webServer, storageDomain and connection are provided. */
25
27
  export function apply(ctx: unknown, config: DshPluginConfig): Promise<void>;
28
+
29
+ /** AppHost-owned HTTP settings on GET/POST /hanamesh/library/suite-source. Catalog sources stay independent. */
30
+ export type SuiteSourceMode='configured'|'core';
31
+ /** Public Core protocol-1 observation; no provider revision, generation or watch is supplied. */
32
+ export interface CoreSuiteSourceState {
33
+ readonly protocolVersion:'1';readonly status:'disabled'|'ready'|'failed'|'disposed';
34
+ readonly selection:'configured'|'custom'|'disabled';readonly registry:string|null;
35
+ readonly archiveCount:number;readonly lastError:string|null;
36
+ }
37
+ export interface SuiteSourceSettings {
38
+ readonly mode:SuiteSourceMode;readonly configuredRegistry:string;
39
+ readonly observation:{readonly observable:true;readonly state:CoreSuiteSourceState}|{readonly observable:false;readonly code:string};
40
+ }
41
+ /** POST accepts exactly this shape; saving selects a source and does not authorize installation. */
42
+ export interface SuiteSourceSettingsInput {mode:SuiteSourceMode;}
package/dist/dsh.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { createInstallationStatusReader } from './library/installation-status.js';
1
2
  /**
2
- * DSH plugin entry for @hanamesh/dsh-app-host, bound to the PINNED public APIs
3
- * of the official DSH 0.2.0-rc.2 release:
3
+ * DSH plugin entry for @hanamesh/dsh-app-host, using the public APIs audited in
4
+ * official DSH 0.2.0-rc.2 and 0.2.1-alpha.2:
4
5
  *
5
6
  * ctx.storageDomain.open(spec) @deepseek-ai/dsh-storage-domain — `single` layout, one
6
7
  * global snapshot; `global.set()` is one whole-image publish
@@ -23,7 +24,7 @@ import { defineDomain } from '@deepseek-ai/dsh-storage-domain';
23
24
  import { AppHost } from './manager.js';
24
25
  import { DshDomainSnapshotStore, emptySnapshot } from './store.js';
25
26
  import { createHttpHandler } from './routes.js';
26
- import { requireCondition } from './errors.js';
27
+ import { requireCondition,SerialQueue } from './errors.js';
27
28
  import { routerDomainSpec } from './router/domain.js';
28
29
  import { createProviderSources } from './router/sources.js';
29
30
  import { createRouter } from './router/broker.js';
@@ -31,40 +32,55 @@ import { createRouterHttpHandler, ROUTER_ROUTES } from './router/routes.js';
31
32
  import { libraryDomainSpec } from './library/domain.js';
32
33
  import { createLibraryService } from './library/service.js';
33
34
  import { createLibraryInstaller } from './library/install.js';
34
- import { resolveLibraryLocations } from './library/locate.js';
35
+ import { inferDesktopLayout, resolveLibraryLocations } from './library/locate.js';
35
36
  import { createLibraryHttpHandler, LIBRARY_ROUTES } from './library/routes.js';
37
+ import { createHandoffQueue, createHandoffHandler, createHandoffListener } from './library/handoff.js';
38
+ import { detectClient } from './library/client-identity.js';
36
39
  import { createUsageEvidence } from './usage-evidence.js';
37
40
  import { createReceiptLedger } from './router/receipts.js';
38
41
  import { receiptsDomainSpec } from './router/receipts-domain.js';
42
+ import {createNodeRuntime} from './node-runtime/service.js';
43
+ import {nodeRuntimeDomainSpec} from './node-runtime/domain.js';
44
+ import {nodeBinding,nodeStatusBinding} from './library/node-binding.js';
45
+ import {DSH_RANGE} from './dsh-compat.js';
39
46
 
40
47
  export const name = 'hanamesh-app-host';
41
- export const DSH_TARGET = '0.2.0-rc.2';
48
+ export const DSH_TARGET = DSH_RANGE;
42
49
  export const inject = ['webServer', 'storageDomain', 'connection'];
43
50
  /** One authenticated local browser session per profile; DSH web has no per-user principals. */
44
51
  export const BROWSER_PRINCIPAL = 'dsh-browser';
45
52
  /** Every route the HTTP handler answers; registered individually so nothing else is shadowed. */
46
53
  export const ROUTES = Object.freeze(['/hanamesh/apps', '/apps/open', '/apps/resume', '/apps/recover',
47
- '/apps/close', '/apps/heartbeat', '/apps/stop', '/apps/events']);
54
+ '/apps/close', '/apps/heartbeat', '/apps/stop', '/apps/events', '/apps/bootstrap-authorize']);
48
55
 
49
56
  export const Config = z.object({
50
57
  /** Absolute canonical directory that owns every instance data root (secureDirectory refuses symlinks). */
51
58
  dataRoot: z.string().default(join(process.env.DSH_HOME ?? process.env.HOME ?? '.', 'data', 'hanamesh-apps')),
52
- /** Exact loopback workspace origin; defaults to the running web server's own origin. */
59
+ /** Exact workspace origin; inferred Desktop uses dsh-app://app, plain DSH uses HTTP loopback. */
53
60
  parentOrigin: z.string(),
54
61
  /** Extra origins allowed as frame ancestors of app views — the desktop shell's own webview origin
55
62
  * (e.g. `tauri://localhost`) when the DSH workspace itself runs inside the shell's iframe. */
56
- frameAncestors: z.array(z.string()),
63
+ frameAncestors: z.union([z.array(z.string()),z.const(undefined)]),
57
64
  /** Standalone Node executable used for guardian/launcher processes; required under Electron. */
58
65
  nodeBinary: z.string(),
66
+ nodeRuntime:z.object({mode:z.string(),version:z.string(),sourceBase:z.string(),storageRoot:z.string(),externalBinary:z.string()}),
59
67
  router: z.object({ codingOauth: z.object({ mode:z.string() }) }),
60
68
  library: z.object({
61
69
  fixture:z.string(),
62
70
  /** Undefined (not configured) → the HanaMesh catalog source; an explicit `[]` → no source. schemastery would
63
71
  * coerce a missing array to `[]`, so the union keeps "unset" distinguishable from "deliberately empty". */
64
72
  sources:z.union([z.array(z.any()),z.const(undefined)]),
65
- /** All four are set by the desktop shell overlay; on plain DSH they are inferred (see library/locate.js). */
73
+ /** Locations are inferred from the verified Desktop argv/profile or plain DSH (see library/locate.js). */
66
74
  profileDir:z.string(),profileName:z.string(),dshBin:z.string(),
67
75
  registry:z.string(),allowPrerelease:z.boolean(),
76
+ /** Publicly named event history capacity; a lost cursor reports resetRequired. */
77
+ eventRetention:z.number().default(512),
78
+ /** Explicit official CLI readers; unset means unknown, never inferred from PATH or private files. */
79
+ installationReaders:z.union([z.any(),z.const(undefined)]),
80
+ /** Website → client handoff listener (library/handoff.js). Unset fields use the defaults shown in the library
81
+ * settings page, where they can also be changed (saved values win over this config). */
82
+ handoff:z.object({enabled:z.boolean(),ports:z.union([z.array(z.number()),z.const(undefined)]),
83
+ allowedOrigins:z.union([z.array(z.string()),z.const(undefined)]),ttlMs:z.number(),pollMs:z.number()}),
68
84
  }),
69
85
  applications: z.array(z.any()).default([]),
70
86
  leaseTtlMs: z.number(),
@@ -115,80 +131,155 @@ export async function apply(ctx, config) {
115
131
  config ??= {};
116
132
  const dataRoot = config.dataRoot ?? join(process.env.DSH_HOME ?? process.env.HOME ?? '.', 'data', 'hanamesh-apps');
117
133
  requireCondition(typeof dataRoot === 'string', 'INVALID_ROOT', 'dataRoot is required.');
118
- const parentOrigin = config.parentOrigin ?? `http://127.0.0.1:${webServer.port}`;
119
- const domain = await facility.open(appHostDomainSpec);
120
- let routerDomain,receiptsDomain,receipts,libraryDomain,library;
121
- const store = new DshDomainSnapshotStore(domainBinding(domain));
122
- const host = new AppHost({ store, dataRoot, parentOrigin, frameAncestors: config.frameAncestors ?? [],
123
- ...(config.nodeBinary === undefined ? {} : { nodeBinary: config.nodeBinary }),
124
- ...(config.leaseTtlMs === undefined ? {} : { leaseTtlMs: config.leaseTtlMs }),
125
- ...(config.sweepIntervalMs === undefined ? {} : { sweepIntervalMs: config.sweepIntervalMs }) });
134
+ const desktop = await inferDesktopLayout();
135
+ const transportOrigin = `http://127.0.0.1:${webServer.port}`;
136
+ const parentOrigin = config.parentOrigin ?? desktop?.parentOrigin ?? transportOrigin;
137
+ const frameAncestors = config.frameAncestors ?? desktop?.frameAncestors ?? [];
138
+ const locations = await resolveLibraryLocations(config.library??{}, {nodeBinary:config.nodeBinary, desktop});
139
+ // Pair each acquisition immediately, including partial route registration.
140
+ // The same once-only release belongs to both failed startup and Cordis unload.
141
+ const acquired = [];
142
+ let closing = false, cleanupTail = Promise.resolve();
143
+ const drain = () => {
144
+ closing = true;
145
+ const pending = cleanupTail.then(async () => {
146
+ const failed = [];
147
+ while (acquired.length) {
148
+ const { label, release } = acquired.pop();
149
+ try { await release(); }
150
+ catch {
151
+ failed.push(label);
152
+ try { ctx.logger?.warn?.('APPHOST_CLEANUP_FAILED %s', label); } catch {}
153
+ }
154
+ }
155
+ requireCondition(failed.length === 0, 'APPHOST_CLEANUP_FAILED',
156
+ 'AppHost resource cleanup failed.', { resources:failed });
157
+ });
158
+ cleanupTail = pending.catch(() => {});
159
+ return pending;
160
+ };
161
+ // Cordis unloads separate effects concurrently. One drain preserves resource
162
+ // dependencies, including ledger persistence and stopping before domain close.
163
+ ctx.effect(() => drain, 'hanameshApps.dispose');
164
+ const own = (label, action) => {
165
+ let pending;
166
+ const release = () => pending ??= (async () => { await action(); })();
167
+ acquired.push({ label, release });
168
+ // If an awaited acquisition completed after unload began, catch drains its
169
+ // newly acquired resource too; no further activation can publish a seat.
170
+ requireCondition(!closing, 'HOST_CLOSED', 'AppHost activation is being disposed.');
171
+ return release;
172
+ };
173
+ const optional = (name, receive) => {
174
+ const listener = ctx.inject([name], injected => {
175
+ receive(injected.get(name)); return () => receive(undefined);
176
+ });
177
+ own(`listener:${name}`, () => listener.dispose());
178
+ };
179
+ let host, store, nodeRuntime, library;
126
180
  let initialized = false;
127
181
  try {
182
+ const nodeDomain=await facility.open(nodeRuntimeDomainSpec);
183
+ own('node-runtime-domain',()=>nodeDomain.close());
184
+ nodeRuntime=createNodeRuntime({domain:nodeDomain,dataRoot,config:config.nodeRuntime,initialBinary:locations.nodeBinary,
185
+ busy:()=>Boolean(library?.events().operations.length)||Boolean(host?.instanceList(BROWSER_PRINCIPAL).some(i=>!['stopped','failed','interrupted'].includes(i.status)))});
186
+ own('node-runtime',()=>nodeRuntime.close());await nodeRuntime.init();
187
+ const domain = await facility.open(appHostDomainSpec);
188
+ const closeDomain = own('app-domain', () => domain.close());
189
+ store = new DshDomainSnapshotStore(domainBinding({ global:domain.global, close:closeDomain }));
190
+ host = new AppHost({ store, dataRoot, parentOrigin, frameAncestors,
191
+ nodeBinaryProvider:()=>nodeRuntime.executable,
192
+ ...(config.leaseTtlMs === undefined ? {} : { leaseTtlMs: config.leaseTtlMs }),
193
+ ...(config.sweepIntervalMs === undefined ? {} : { sweepIntervalMs: config.sweepIntervalMs }) });
194
+ own('host', () => initialized ? host.dispose() : store.close());
128
195
  for (const definition of config.applications ?? []) host.register(definition);
129
196
  await host.init(); initialized = true;
130
- const auth = { parentOrigin, ...browserAuthentication(connection) };
197
+ const auth = { parentOrigin, transportOrigin, ...browserAuthentication(connection) };
131
198
  const handler = createHttpHandler(host, auth);
132
- const disposers = ROUTES.map(path => webServer.register({ kind: 'exact', path, handler }));
133
- let credentialService, llmService, oauthService, usageService;
134
- ctx.inject(['credentials'], injected => { credentialService=injected.get('credentials');return()=>{credentialService=undefined;}; });
135
- // rc.27: usage evidence goes through the usage plugin's record seat when (and only when) that plugin is loaded.
136
- ctx.inject(['hanameshUsage'], injected => { usageService=injected.get('hanameshUsage');return()=>{usageService=undefined;}; });
137
- // T6: hourly usage receipts live in app-host's own domain; the usage `use` event carries the hour's receipt.
138
- receiptsDomain = await facility.open(receiptsDomainSpec);
139
- receipts = createReceiptLedger({ domain:receiptsDomain });
199
+ for (const path of ROUTES) own(`route:${path}`, webServer.register({ kind:'exact', path, handler }));
200
+ let credentialService, llmService, oauthService, usageService, coreService, suiteSourceService;
201
+ optional('credentials', service => { credentialService=service; });
202
+ // Optional Usage service presence does not provide original application success facts.
203
+ optional('hanameshUsage', service => { usageService=service; });
204
+ optional('hanameshCore', service => { coreService=service; });
205
+ optional('hanameshSuiteSource', service => { suiteSourceService=service; });
206
+ // Router hourly counts remain local diagnostics, never synthesized Usage open/use facts.
207
+ const receiptsDomain = await facility.open(receiptsDomainSpec);
208
+ own('receipts-domain', () => receiptsDomain.close());
209
+ const receipts = createReceiptLedger({ domain:receiptsDomain });
210
+ own('receipts', () => receipts.close());
140
211
  const evidence = createUsageEvidence({ host, seat:()=>usageService, receipts, logger:ctx.logger });
141
- ctx.effect(() => async () => { evidence.close(); await evidence.settle(); await receipts.close(); await receiptsDomain.close(); }, 'hanameshApps.usage');
142
- ctx.inject(['llm'], injected => { llmService=injected.get('llm');return()=>{llmService=undefined;}; });
143
- ctx.inject(['hanameshOAuth'], injected => { oauthService=injected.get('hanameshOAuth');return()=>{oauthService=undefined;}; });
212
+ own('usage', async () => { evidence.close(); await evidence.settle(); });
213
+ optional('llm', service => { llmService=service; });
214
+ optional('hanameshOAuth', service => { oauthService=service; });
144
215
  const credentials = {
145
216
  describe: async ref => credentialService ? await credentialService.describe(ref) : { configured:false,writable:false },
146
217
  resolve: async ref => credentialService ? await credentialService.resolve(ref) : undefined,
147
218
  describeRecord: async key => credentialService ? await credentialService.describeRecord(key) : { configured:false,writable:false },
148
219
  };
149
- routerDomain = await facility.open(routerDomainSpec);
220
+ const routerDomain = await facility.open(routerDomainSpec);
221
+ own('router-domain', () => routerDomain.close());
150
222
  const sources = createProviderSources({ credentials, apps:host, llm:()=>llmService,
151
223
  webOrigin:`http://127.0.0.1:${webServer.port}`, codingOauth:config.router?.codingOauth });
152
224
  const router = createRouter({ credentials, domain:routerDomain, apps:host, sources, oauth:()=>oauthService, receiptLedger:receipts,
153
225
  onInject:({ appId, instanceId, routes }) => { receipts.inject({ appId, instanceId, routes }); } });
154
- const detachResolver=host.setCredentialResolver(router.credentialResolver),detachObserver=host.subscribe(router.observe);
226
+ own('resolver', host.setCredentialResolver(router.credentialResolver));
227
+ own('router-observer', host.subscribe(router.observe));
155
228
  const routerHandler=createRouterHttpHandler(router,auth);
156
- const routerDisposers=ROUTER_ROUTES.map(path=>webServer.register({kind:'exact',path,handler:routerHandler}));
157
- libraryDomain=await facility.open(libraryDomainSpec);
158
- // Explicit config wins; on plain DSH (no shell overlay) the profile, DSH bin and Node are inferred from this process.
159
- const locations=await resolveLibraryLocations(config.library??{},{nodeBinary:config.nodeBinary});
229
+ for (const path of ROUTER_ROUTES) own(`route:${path}`, webServer.register({kind:'exact',path,handler:routerHandler}));
230
+ const libraryDomain=await facility.open(libraryDomainSpec);
231
+ const closeLibraryDomain=own('library-domain', () => libraryDomain.close());
232
+ // Explicit config wins; infer only proven process/profile locations.
160
233
  const libraryConfig={...(config.library??{}),sources:locations.sources,profileDir:locations.profileDir,profileName:locations.profileName};
161
234
  if(locations.inferred.length&&typeof ctx.logger?.info==='function')ctx.logger.info('hanamesh-app-host library: inferred %s',JSON.stringify(Object.fromEntries(
162
235
  locations.inferred.map(key=>[key,key==='sources'?locations.sources.map(source=>source.manifestUrl):locations[key]]))));
163
- let provisionApi,installer;
164
- if(locations.profileDir&&locations.profileName&&locations.nodeBinary){
165
- let dshBin=locations.dshBin;
166
- if(!dshBin)try{dshBin=createRequire(join(locations.profileDir,'package.json')).resolve('@deepseek-ai/dsh/lib/bin.js');}catch{dshBin=undefined;}
167
- if(dshBin){
168
- provisionApi=await import('./provision/index.js');
169
- installer=createLibraryInstaller({profileDir:locations.profileDir,profileName:locations.profileName,dataRoot,nodeBinary:locations.nodeBinary,
170
- dshBin,registry:libraryConfig.registry,allowPrerelease:libraryConfig.allowPrerelease,provision:provisionApi.provision,remove:provisionApi.remove,
171
- emit:event=>library?.emit(event)});
172
- }
236
+ // Exact-save capability comes from the host's public package metadata. Older managers use the public CLI
237
+ // for installs and preserve restart-required; applications and compensation also use that CLI.
238
+ let pluginManager;
239
+ optional('pluginManager', service => { pluginManager=service; });
240
+ let dshBin=locations.dshBin;
241
+ if(!dshBin&&locations.profileDir&&!desktop)try{dshBin=createRequire(join(locations.profileDir,'package.json')).resolve('@deepseek-ai/dsh/lib/bin.js');}catch{dshBin=undefined;}
242
+ const cliUsable=Boolean(locations.desktopLauncher||dshBin&&(!desktop||locations.desktopCli||config.library?.dshBin));
243
+ let provisionApi,installer;const profileWriter=new SerialQueue();
244
+ if(locations.profileDir&&locations.profileName){
245
+ provisionApi=await import('@hanamesh/lib-provision');
246
+ installer=nodeBinding(()=>nodeRuntime.executable,nodeBinary=>createLibraryInstaller({profileDir:locations.profileDir,profileName:locations.profileName,dataRoot,
247
+ ...(cliUsable?{nodeBinary,dshBin,desktopCli:locations.desktopCli,desktopLauncher:locations.desktopLauncher}:{}),pluginManagerVersion:locations.pluginManagerVersion,profileWriter,
248
+ registry:libraryConfig.registry,allowPrerelease:libraryConfig.allowPrerelease,provision:provisionApi.provision,remove:provisionApi.remove,ledger:provisionApi.ledger,
249
+ coreService:()=>coreService,suiteSourceService:()=>suiteSourceService,suiteSourceMode:()=>library?.suiteSourceSettings().mode??'configured',pluginManager:()=>pluginManager,emit:event=>library?.emit(event)}));
173
250
  }
174
- library=createLibraryService({domain:libraryDomain,host,config:libraryConfig,dataRoot,ledgerReader:provisionApi?.ledger??(async()=>({schema:1,items:{}})),installer});
251
+ /** What a person must do so the library can install, in their words (C-MARKET probe `error.need`). */
252
+ const unavailable=()=>{
253
+ if(!locations.profileDir)return[{need:'找不到这个 DSH profile 的目录',how:'用 dsh plugin --profile <名字> add 安装 HanaMesh 应用库,或在插件设置 library.profileDir 里填 profile 目录'}];
254
+ if(installer?.ready)return[];
255
+ if(!nodeRuntime.executable)return[{need:'安装环境尚未准备',how:'打开 AppHost「安装环境」,点击「准备安装环境」,核对来源、版本与体积后确认;完成后自动可用,无需重启或填写路径。'}];
256
+ if(installer?.unavailable?.length)return installer.unavailable;
257
+ const rows=[];
258
+ if(!locations.nodeBinary)rows.push({need:'找不到能运行 DSH 命令行的 Node',how:'在 AppHost 插件配置中,将 nodeBinary 填为独立 Node.js 24 的绝对路径(24.13.1 或更新的 24.x);完全退出后重开。'});
259
+ if(!dshBin||(desktop&&!locations.desktopCli&&!config.library?.dshBin))rows.push({need:'找不到可在受控进程中运行的 DSH 命令行',how:'恢复客户端随附的公开 dsh 命令启动器,或在插件设置 library.dshBin 里填 dsh 的 lib/bin.js'});
260
+ return rows;
261
+ };
262
+ const identity=await detectClient({desktop});
263
+ if(typeof ctx.logger?.info==='function')ctx.logger.info('hanamesh-app-host library: client %s (%s)',identity.client,identity.evidence);
264
+ library=createLibraryService({domain:{global:libraryDomain.global,close:closeLibraryDomain},host,config:libraryConfig,dataRoot,ledgerReader:provisionApi?.ledger??(async()=>({schema:1,items:{}})),installer,client:identity.client,unavailable,suiteSourceService:()=>suiteSourceService,installationStatus:nodeStatusBinding(()=>nodeRuntime.executable,workerNode=>createInstallationStatusReader({client:identity.client,core:()=>coreService,workerNode,readers:libraryConfig.installationReaders})),logger:ctx.logger});
265
+ own('library', () => library.close());
175
266
  await library.init();
176
267
  // rc.27: app bundles register plain `app.json` (no package name); the installed scan knows which npm package ships
177
268
  // which appId, and that package name is the usage-evidence `hanaRef`. Scan failure only costs evidence, never startup.
178
- try { for (const row of await library.installed()) if (row.appId && row.packageName) { try { host.bindPackageName(row.appId,row.packageName); } catch {} } }
269
+ try { for (const row of await library.installed()) if (row.appId && row.packageName) { try { host.bindPackageName(row.appId,row.packageName); } catch (error) { ctx.logger?.warn?.('hanamesh-app-host usage: %s not bound to %s (%s)', row.appId, row.packageName, error?.code ?? error?.message); } } }
179
270
  catch (error) { if (typeof ctx.logger?.debug === 'function') ctx.logger.debug('hanamesh-app-host usage: installed scan unavailable (%s)', error?.code ?? error?.message); }
180
- const libraryHandler=createLibraryHttpHandler(library,auth);
181
- const libraryDisposers=LIBRARY_ROUTES.map(path=>webServer.register({kind:'exact',path,handler:libraryHandler}));
182
- ctx.effect(() => () => { for (const dispose of disposers.splice(0)) dispose(); }, 'hanameshApps.routes');
183
- ctx.effect(() => async () => { for(const dispose of routerDisposers.splice(0))dispose();detachResolver();detachObserver();await routerDomain.close(); }, 'hanameshApps.router');
184
- ctx.effect(() => async () => { for(const dispose of libraryDisposers.splice(0))dispose();await library.close(); }, 'hanameshApps.library');
185
- ctx.provide('hanameshApps', host);
271
+ const queue=createHandoffQueue({ttlMs:()=>library.handoffSettings().ttlMs,onError:(message,error)=>ctx.logger?.warn?.('%s: %s',message,error?.message??error)});
272
+ own('handoff-observer', library.observe(event=>queue.observe(event)));
273
+ const listener=createHandoffListener({settings:()=>library.handoffSettings(),logger:ctx.logger,
274
+ handlerFor:port=>createHandoffHandler({library,queue,settings:()=>library.handoffSettings(),port})});
275
+ own('handoff-listener', () => listener.stop());
276
+ await listener.start();
277
+ const libraryHandler=createLibraryHttpHandler(library,{...auth,nodeRuntime,handoff:{queue,listener,restart:()=>listener.start()}});
278
+ for (const path of LIBRARY_ROUTES) own(`route:${path}`, webServer.register({kind:'exact',path,handler:libraryHandler}));
279
+ own('seat', ctx.provide('hanameshApps', host));
186
280
  } catch (error) {
187
- await library?.close().catch(()=>{});await libraryDomain?.close().catch(()=>{});await routerDomain?.close().catch(()=>{});await receipts?.close().catch(()=>{});await receiptsDomain?.close().catch(()=>{});
188
- if (initialized) await host.dispose().catch(() => {}); else await store.close().catch(() => {});
281
+ // Cleanup names failures without replacing the original activation error.
282
+ await drain().catch(() => {});
189
283
  throw error;
190
284
  }
191
- // Disposal order: routes go first (registered later, disposed earlier by Cordis), then the host
192
- // stops every owned instance and closes the domain. An attached instance is never signalled.
193
- ctx.effect(() => async () => { await host.dispose(); }, 'hanameshApps.dispose');
194
285
  }